通知日志筛选 + 支付推送按订单所在库处理
后台 - 通知日志页新增筛选:处理结果(成功/失败/已忽略)+ 关键词(事件名、订单号、原始报文) 服务端 - 通知日志接口支持 processed / keyword 过滤 - 支付推送改为「先查生产库、再查开发库」,命中的库即后续写操作的库; 原先只查小程序当前数据源,数据源一被切换,真实推送就查不到订单 - 去掉「开发库不能退款」的拦截:开发库的单是微信沙箱里真下单产生的,可以正常退
This commit is contained in:
1 parent
f05a11f0f3
commit
058d677fc1
8 files changed
+172
-60
No files matched your search
@@ -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