通知日志筛选 + 支付推送按订单所在库处理
后台 - 通知日志页新增筛选:处理结果(成功/失败/已忽略)+ 关键词(事件名、订单号、原始报文) 服务端 - 通知日志接口支持 processed / keyword 过滤 - 支付推送改为「先查生产库、再查开发库」,命中的库即后续写操作的库; 原先只查小程序当前数据源,数据源一被切换,真实推送就查不到订单 - 去掉「开发库不能退款」的拦截:开发库的单是微信沙箱里真下单产生的,可以正常退
This commit is contained in:
1 parent
f05a11f0f3
commit
058d677fc1
8 files changed
+172
-60
No files matched your search
@@ -1,7 +1,5 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"van-loading": "@vant/weapp/loading/index"
|
||||
},
|
||||
"usingComponents": {},
|
||||
"onReachBottomDistance":50,
|
||||
"enablePullDownRefresh":true
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"van-dialog": "@vant/weapp/dialog/index",
|
||||
"van-field": "@vant/weapp/field/index",
|
||||
"van-button": "@vant/weapp/button/index",
|
||||
"van-popup": "@vant/weapp/popup/index",
|
||||
"van-image": "@vant/weapp/image/index"
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatDateTime } from '@/lib/datetime';
|
||||
import { Button, Card, Empty, Notice } from '@/components/ui';
|
||||
import { Button, Card, Empty, Input, Notice, Select } from '@/components/ui';
|
||||
|
||||
interface NotifyLogRow {
|
||||
id: number;
|
||||
@@ -29,22 +29,35 @@ export default function NotifyLogsPage() {
|
||||
const [items, setItems] = useState<NotifyLogRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
/** '-1' = 全部结果 */
|
||||
const [processedFilter, setProcessedFilter] = useState('-1');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const load = useCallback((nextPage: number) => {
|
||||
setError('');
|
||||
api
|
||||
.listNotifyLogs(nextPage, PAGE_SIZE)
|
||||
.then((res) => {
|
||||
setItems(res.items as unknown as NotifyLogRow[]);
|
||||
setTotal(res.total);
|
||||
setPage(res.page);
|
||||
})
|
||||
.catch((err: { message: string }) => setError(err.message));
|
||||
}, []);
|
||||
const load = useCallback(
|
||||
(nextPage: number, nextKeyword: string) => {
|
||||
setError('');
|
||||
api
|
||||
.listNotifyLogs({
|
||||
page: nextPage,
|
||||
pageSize: PAGE_SIZE,
|
||||
processed: processedFilter === '-1' ? undefined : Number(processedFilter),
|
||||
keyword: nextKeyword,
|
||||
})
|
||||
.then((res) => {
|
||||
setItems(res.items as unknown as NotifyLogRow[]);
|
||||
setTotal(res.total);
|
||||
setPage(res.page);
|
||||
})
|
||||
.catch((err: { message: string }) => setError(err.message));
|
||||
},
|
||||
[processedFilter],
|
||||
);
|
||||
|
||||
// 筛选一变,load 的引用就变,这里会带着当前关键词回到第 1 页重拉
|
||||
useEffect(() => {
|
||||
load(1);
|
||||
load(1, keyword);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [load]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
@@ -60,7 +73,29 @@ export default function NotifyLogsPage() {
|
||||
<Notice message={error} />
|
||||
|
||||
<Card>
|
||||
<div className="mb-4 text-sm text-gray-500">共 {total} 条通知</div>
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-sm text-gray-500">共 {total} 条通知</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-32">
|
||||
<Select
|
||||
value={processedFilter}
|
||||
onChange={setProcessedFilter}
|
||||
options={[
|
||||
{ value: -1, label: '全部结果' },
|
||||
{ value: 1, label: '成功' },
|
||||
{ value: 2, label: '失败' },
|
||||
{ value: 0, label: '已忽略' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-60">
|
||||
<Input value={keyword} onChange={setKeyword} placeholder="事件名 / 订单号 / 说明…" />
|
||||
</div>
|
||||
<Button variant="ghost" onClick={() => load(1, keyword)}>
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<Empty />
|
||||
@@ -110,10 +145,10 @@ export default function NotifyLogsPage() {
|
||||
第 {page} / {totalPages} 页
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" disabled={page <= 1} onClick={() => load(page - 1)}>
|
||||
<Button variant="ghost" disabled={page <= 1} onClick={() => load(page - 1, keyword)}>
|
||||
上一页
|
||||
</Button>
|
||||
<Button variant="ghost" disabled={page >= totalPages} onClick={() => load(page + 1)}>
|
||||
<Button variant="ghost" disabled={page >= totalPages} onClick={() => load(page + 1, keyword)}>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -161,8 +161,9 @@ export const api = {
|
||||
listRefundRequests: (page: number, pageSize: number) =>
|
||||
request<Paged<Record<string, unknown>>>(`/admin/v1/refund-requests${qs({ page, pageSize })}`),
|
||||
|
||||
listNotifyLogs: (page: number, pageSize: number) =>
|
||||
request<Paged<Record<string, unknown>>>(`/admin/v1/notify-logs${qs({ page, pageSize })}`),
|
||||
/** 通知日志。processed:0 已忽略 / 1 成功 / 2 失败;keyword 匹配事件名、说明和原始报文。 */
|
||||
listNotifyLogs: (params: { page: number; pageSize: number; processed?: number; keyword?: string }) =>
|
||||
request<Paged<Record<string, unknown>>>(`/admin/v1/notify-logs${qs({ ...params })}`),
|
||||
|
||||
/** 站点级信息:对外域名。后台只读展示,也用来拼封面图地址 */
|
||||
siteInfo: () =>
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"fileNames":["./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es5.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.iterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.weakref.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.array.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.error.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.object.d.ts",".Line truncated
|
||||
{"fileNames":["./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es5.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.iterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.weakref.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.array.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.error.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.object.d.ts",".Line truncated
|
||||
@@ -217,8 +217,16 @@ export class AdminController {
|
||||
@AdminEnvParam() env: DataSourceEnv,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
@Query('processed') processed?: string,
|
||||
@Query('keyword') keyword?: string,
|
||||
) {
|
||||
return this.admin.listNotifyLogs(env, Number(page) || 1, Number(pageSize) || 20);
|
||||
// 空字符串要当成「没传」——Number('') 是 0,会被误当成「只见已忽略」
|
||||
const statusText = processed ?? '';
|
||||
const status = statusText === '' ? undefined : Number(statusText);
|
||||
return this.admin.listNotifyLogs(env, Number(page) || 1, Number(pageSize) || 20, {
|
||||
processed: status !== undefined && Number.isFinite(status) ? status : undefined,
|
||||
keyword: keyword ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
/** 后台需要的站点级信息(静态资源域名等),不分数据源。 */
|
||||
|
||||
@@ -421,18 +421,39 @@ export class AdminService {
|
||||
* 通知日志:微信发来的每一条推送(发货、退款问询、退款成功、投诉、风控…)。
|
||||
* 用来回答「这条通知到底来没来、我们怎么处理的」。
|
||||
*/
|
||||
async listNotifyLogs(env: DataSourceEnv, page = 1, pageSize = 20) {
|
||||
async listNotifyLogs(
|
||||
env: DataSourceEnv,
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
filters: { processed?: number; keyword?: string } = {},
|
||||
) {
|
||||
const db = this.prisma.clientFor(env);
|
||||
const size = Math.min(Math.max(pageSize, 1), 100);
|
||||
const current = Math.max(page, 1);
|
||||
const keyword = (filters.keyword ?? '').trim();
|
||||
|
||||
// 关键词同时搜事件名、说明和原始报文——想找某笔订单的通知,直接输订单号就行
|
||||
const where: Prisma.PaymentNotifyLogWhereInput = {
|
||||
...(filters.processed === undefined ? {} : { processed: filters.processed }),
|
||||
...(keyword
|
||||
? {
|
||||
OR: [
|
||||
{ event: { contains: keyword } },
|
||||
{ err_msg: { contains: keyword } },
|
||||
{ raw_body: { contains: keyword } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const [items, total] = await db.$transaction([
|
||||
db.paymentNotifyLog.findMany({
|
||||
where,
|
||||
orderBy: { id: 'desc' },
|
||||
skip: (current - 1) * size,
|
||||
take: size,
|
||||
}),
|
||||
db.paymentNotifyLog.count(),
|
||||
db.paymentNotifyLog.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -538,13 +559,14 @@ export class AdminService {
|
||||
};
|
||||
}
|
||||
|
||||
/** 发起退款(仅非 iOS 终端)。退款成功由退款推送回写订单状态。 */
|
||||
/**
|
||||
* 发起退款(仅非 iOS 终端)。退款成功由退款推送回写订单状态。
|
||||
*
|
||||
* 开发库的订单也是在微信**沙箱**里真下单产生的(env=1、有平台单号),所以一样能退:
|
||||
* 用沙箱密钥 + 沙箱环境去查单退款,不涉及真钱。
|
||||
* 安全边界靠下面的 env 一致性校验——它挡住「拿现网配置去退沙箱单」这类错配。
|
||||
*/
|
||||
async refundOrder(env: DataSourceEnv, orderId: number, reason: string, reasonCode: string) {
|
||||
// 开发库的订单在微信侧不存在,发退款请求必然失败,直接拦住
|
||||
if (env === 'develop') {
|
||||
throw AppError.badRequest('当前数据源是开发库,订单在微信侧不存在,无法退款');
|
||||
}
|
||||
|
||||
const db = this.prisma.clientFor(env);
|
||||
const order = await db.order.findUnique({ where: { id: orderId }, include: { user: true } });
|
||||
if (!order) throw AppError.notFound('订单不存在');
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import { APP_CONFIG, type AppConfig } from '../../config/config';
|
||||
import { decryptSecret } from '../../common/crypto';
|
||||
@@ -80,6 +81,36 @@ export class PaymentsService {
|
||||
private readonly wxpay: WxPayService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 微信推送里只有单号,没有「这笔订单在哪个库」的信息——订单落在哪个库,
|
||||
* 取决于**下单时**的小程序数据源。所以按单号找订单要两个库都试:
|
||||
* 先当前用的那个,再另一个。
|
||||
*
|
||||
* 只查当前库的后果:数据源一被切换,真实的发货/退款推送就"查不到订单",
|
||||
* 返回非 0 后微信还会重试 15 次,通知日志里刷一屏失败。
|
||||
*/
|
||||
private databases(): PrismaClient[] {
|
||||
// 先生产库:绝大多数订单都在那里,命中就少一次查询。
|
||||
// 开发库只有「下单时小程序刚好切到开发库」才会有单,放后面查。
|
||||
return this.prisma.dev ? [this.prisma.main, this.prisma.dev] : [this.prisma.main];
|
||||
}
|
||||
|
||||
/** 两个库里按单号找订单,返回命中的连接和订单。 */
|
||||
private async findOrder<T>(
|
||||
lookup: (db: PrismaClient) => Promise<T | null>,
|
||||
): Promise<{ db: PrismaClient; order: T } | null> {
|
||||
for (const db of this.databases()) {
|
||||
const order = await lookup(db);
|
||||
if (order) return { db, order };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 处理结果里写上订单所在库,数据源切换时便于排查。 */
|
||||
private dbLabel(db: PrismaClient): string {
|
||||
return db === this.prisma.main ? '生产库' : '开发库';
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理发货推送。
|
||||
*
|
||||
@@ -112,11 +143,12 @@ export class PaymentsService {
|
||||
notify.WeChatPayInfo?.TransactionId ||
|
||||
`local:${outTradeNo}`;
|
||||
|
||||
const order = await this.prisma.active.order.findUnique({
|
||||
where: { out_trade_no: outTradeNo },
|
||||
include: { user: true },
|
||||
});
|
||||
if (!order) throw new Error(`订单不存在:${outTradeNo}`);
|
||||
// 推送只带单号,不告诉我们订单在哪个库:两个库都找一遍
|
||||
const hit = await this.findOrder((db) =>
|
||||
db.order.findUnique({ where: { out_trade_no: outTradeNo }, include: { user: true } }),
|
||||
);
|
||||
if (!hit) throw new Error(`两个库都找不到订单:${outTradeNo}`);
|
||||
const { db, order } = hit;
|
||||
if (order.user.openid !== openid) throw new Error(`推送 openid 与订单不符:${outTradeNo}`);
|
||||
|
||||
if (order.status === OrderStatus.DELIVERED || order.status === OrderStatus.REFUNDED) {
|
||||
@@ -124,9 +156,10 @@ export class PaymentsService {
|
||||
return this.okXml();
|
||||
}
|
||||
|
||||
await this.grantEntitlement(order.user_id, order.course_id, order.id, order.out_trade_no, wxOrderId);
|
||||
await this.markNotify(logId, 1, '发货成功');
|
||||
this.logger.log(`发货成功 order=${outTradeNo} wx=${wxOrderId}`);
|
||||
await this.grantEntitlement(db, order.user_id, order.course_id, order.id, order.out_trade_no, wxOrderId);
|
||||
const label = this.dbLabel(db);
|
||||
await this.markNotify(logId, 1, db === this.prisma.active ? '发货成功' : `发货成功(订单在${label})`);
|
||||
this.logger.log(`发货成功 order=${outTradeNo} wx=${wxOrderId} db=${label}`);
|
||||
return this.okXml();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
@@ -157,8 +190,12 @@ export class PaymentsService {
|
||||
return this.okXml();
|
||||
}
|
||||
|
||||
const order = await this.prisma.active.order.findUnique({ where: { out_trade_no: outTradeNo } });
|
||||
if (!order) throw new Error(`退款推送找不到订单:${outTradeNo}`);
|
||||
// 和发货推送一样:订单可能在另一个库(下单时数据源不同),两个库都找
|
||||
const hit = await this.findOrder((db) =>
|
||||
db.order.findUnique({ where: { out_trade_no: outTradeNo } }),
|
||||
);
|
||||
if (!hit) throw new Error(`两个库都找不到订单:${outTradeNo}`);
|
||||
const { db, order } = hit;
|
||||
if (order.status === OrderStatus.REFUNDED) {
|
||||
await this.markNotify(logId, 1, '重复推送,已忽略');
|
||||
return this.okXml();
|
||||
@@ -169,12 +206,12 @@ export class PaymentsService {
|
||||
// 动不到老数据迁移来的 source=2 权益。两个写操作必须同事务。
|
||||
// 已知上限:已经下发出去的播放地址在签名有效期内(默认 30 分钟)仍可播放,
|
||||
// 换密钥或缩短 PLAY_URL_TTL_SECONDS 才能做得更严。
|
||||
await this.prisma.active.$transaction([
|
||||
this.prisma.active.order.update({
|
||||
await db.$transaction([
|
||||
db.order.update({
|
||||
where: { id: order.id },
|
||||
data: { status: OrderStatus.REFUNDED, refunded_at: new Date() },
|
||||
}),
|
||||
this.prisma.active.entitlement.deleteMany({
|
||||
db.entitlement.deleteMany({
|
||||
where: {
|
||||
user_id: order.user_id,
|
||||
course_id: order.course_id,
|
||||
@@ -184,8 +221,14 @@ export class PaymentsService {
|
||||
}),
|
||||
]);
|
||||
|
||||
await this.markNotify(logId, 1, `退款成功并收回权益 ${notify.WxRefundId ?? ''} ${notify.RefundFee ?? ''}分`);
|
||||
this.logger.log(`退款已记录 order=${outTradeNo} refund=${notify.WxRefundId ?? '-'}`);
|
||||
const label = this.dbLabel(db);
|
||||
const where = db === this.prisma.active ? '' : `(订单在${label})`;
|
||||
await this.markNotify(
|
||||
logId,
|
||||
1,
|
||||
`退款成功并收回权益 ${notify.WxRefundId ?? ''} ${notify.RefundFee ?? ''}分${where}`,
|
||||
);
|
||||
this.logger.log(`退款已记录 order=${outTradeNo} refund=${notify.WxRefundId ?? '-'} db=${label}`);
|
||||
return this.okXml();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
@@ -225,13 +268,19 @@ export class PaymentsService {
|
||||
const provideStatus = fieldOf(rawBody, 'provide_status');
|
||||
const reason = fieldOf(rawBody, 'refund_request_reason');
|
||||
|
||||
// pay_order_id 是「支付订单号」,我们自己的 out_trade_no 和平台单号都试一遍
|
||||
const order = payOrderId
|
||||
? await this.prisma.active.order.findFirst({
|
||||
where: { OR: [{ out_trade_no: payOrderId }, { wx_order_id: payOrderId }] },
|
||||
select: { id: true, out_trade_no: true, status: true, refund_consent_at: true },
|
||||
})
|
||||
// pay_order_id 是「支付订单号」,我们自己的 out_trade_no 和平台单号都试一遍;
|
||||
// 同样两个库都找(数据源切换时订单可能在另一个库)
|
||||
const hit = payOrderId
|
||||
? await this.findOrder((db) =>
|
||||
db.order.findFirst({
|
||||
where: { OR: [{ out_trade_no: payOrderId }, { wx_order_id: payOrderId }] },
|
||||
select: { id: true, out_trade_no: true, status: true, refund_consent_at: true },
|
||||
}),
|
||||
)
|
||||
: null;
|
||||
const order = hit?.order ?? null;
|
||||
/** 退款申请记在订单所在的库;没找到订单就记在当前库 */
|
||||
const recordDb = hit?.db ?? this.prisma.active;
|
||||
|
||||
// 查得到订单就信自己的状态;查不到才退回用推送里的发货状态
|
||||
const delivered = order ? order.status === OrderStatus.DELIVERED : provideStatus === '1';
|
||||
@@ -255,7 +304,7 @@ export class PaymentsService {
|
||||
our_result: resultCode,
|
||||
our_evidence: evidence.slice(0, 255),
|
||||
};
|
||||
await this.prisma.active.refundRequest.upsert({
|
||||
await recordDb.refundRequest.upsert({
|
||||
where: { pay_order_id: payOrderId },
|
||||
create: { pay_order_id: payOrderId, ...record },
|
||||
update: { ...record, query_count: { increment: 1 } },
|
||||
@@ -328,6 +377,7 @@ export class PaymentsService {
|
||||
}
|
||||
|
||||
await this.grantEntitlement(
|
||||
this.prisma.active,
|
||||
order.user_id,
|
||||
order.course_id,
|
||||
order.id,
|
||||
@@ -400,6 +450,7 @@ export class PaymentsService {
|
||||
|
||||
/** 标记订单已发货并发放权益。两个写操作必须同事务。 */
|
||||
private async grantEntitlement(
|
||||
db: PrismaClient,
|
||||
userId: number,
|
||||
courseId: number,
|
||||
orderId: number,
|
||||
@@ -407,8 +458,8 @@ export class PaymentsService {
|
||||
wxOrderId: string,
|
||||
): Promise<void> {
|
||||
const now = new Date();
|
||||
await this.prisma.active.$transaction([
|
||||
this.prisma.active.order.update({
|
||||
await db.$transaction([
|
||||
db.order.update({
|
||||
where: { id: orderId },
|
||||
data: {
|
||||
status: OrderStatus.DELIVERED,
|
||||
@@ -417,7 +468,7 @@ export class PaymentsService {
|
||||
delivered_at: now,
|
||||
},
|
||||
}),
|
||||
this.prisma.active.entitlement.upsert({
|
||||
db.entitlement.upsert({
|
||||
where: { user_id_course_id: { user_id: userId, course_id: courseId } },
|
||||
create: {
|
||||
user_id: userId,
|
||||
|
||||
Reference in new issue
Block a user