兜底查单覆盖退款中的订单 + 扫两个库

- 退款推送丢了订单会永远停在「退款中」;现在每 5 分钟的兜底查单也会查这类订单:
  微信侧已是已退款(5)或用户退款完成(8) → 自动回写状态并收回权益,refunded_at 用微信的 update_time
- 兜底查单同时扫生产库与开发库,支付环境跟着库走(生产用现网、开发用沙箱)
- 「标记退款 + 收回权益」抽成公共方法,退款推送与兜底查单共用
This commit is contained in:
Yeomai committed 2026-10-01 12:16:22 +08:00
1 parent 058d677fc1
commit 014fa90dcd
2 files changed
+69 -27

No files matched your search

@@ -2,6 +2,7 @@ import { Inject, Injectable, Logger } from '@nestjs/common';
import type { PrismaClient } from '@prisma/client'; import type { PrismaClient } from '@prisma/client';
import { XMLParser } from 'fast-xml-parser'; import { XMLParser } from 'fast-xml-parser';
import { APP_CONFIG, type AppConfig } from '../../config/config'; import { APP_CONFIG, type AppConfig } from '../../config/config';
import type { DataSourceEnv } from '../../common/admin-env';
import { decryptSecret } from '../../common/crypto'; import { decryptSecret } from '../../common/crypto';
import { AppError, ErrorCode } from '../../common/errors'; import { AppError, ErrorCode } from '../../common/errors';
import { cdata, customerServiceMsgTypeLabel, eventOf, fieldOf } from '../../common/wechat-msg'; import { cdata, customerServiceMsgTypeLabel, eventOf, fieldOf } from '../../common/wechat-msg';
@@ -201,25 +202,7 @@ export class PaymentsService {
return this.okXml(); return this.okXml();
} }
// 退款要同时收回权益,否则用户钱退了课还能看。 await this.markRefunded(db, order.id, order.user_id, order.course_id);
// 只删这笔订单发出去的那一条(source=1 且 ref_id 对上),
// 动不到老数据迁移来的 source=2 权益。两个写操作必须同事务。
// 已知上限:已经下发出去的播放地址在签名有效期内(默认 30 分钟)仍可播放,
// 换密钥或缩短 PLAY_URL_TTL_SECONDS 才能做得更严。
await db.$transaction([
db.order.update({
where: { id: order.id },
data: { status: OrderStatus.REFUNDED, refunded_at: new Date() },
}),
db.entitlement.deleteMany({
where: {
user_id: order.user_id,
course_id: order.course_id,
source: 1,
ref_id: order.id,
},
}),
]);
const label = this.dbLabel(db); const label = this.dbLabel(db);
const where = db === this.prisma.active ? '' : `(订单在${label})`; const where = db === this.prisma.active ? '' : `(订单在${label})`;
@@ -349,25 +332,57 @@ export class PaymentsService {
* 由定时任务每 5 分钟调用一次。 * 由定时任务每 5 分钟调用一次。
*/ */
async reconcilePendingOrders(): Promise<number> { async reconcilePendingOrders(): Promise<number> {
const source = this.prisma.activeEnv; // 两个库都扫:订单落在哪个库取决于**下单时**小程序的数据源,
// 只扫当前库会漏掉另一边的单(数据源一切换就漏)。
let fixed = 0;
for (const db of this.databases()) {
fixed += await this.reconcileDatabase(db);
}
return fixed;
}
/** 扫一个库。支付环境跟着这个库走:生产库用现网配置,开发库用沙箱配置。 */
private async reconcileDatabase(db: PrismaClient): Promise<number> {
const source: DataSourceEnv = db === this.prisma.main ? 'production' : 'develop';
if (!this.wxpay.enabledFor(source)) return 0; if (!this.wxpay.enabledFor(source)) return 0;
const pending = await this.prisma.active.order.findMany({ const pending = await db.order.findMany({
where: { status: { in: [OrderStatus.CREATED, OrderStatus.PAID] } }, where: {
OR: [
{ status: { in: [OrderStatus.CREATED, OrderStatus.PAID] } },
// 已发起退款、状态却还停在「已开通」——说明退款推送没到,要主动去问微信
{ status: OrderStatus.DELIVERED, refund_requested_at: { not: null } },
],
},
include: { user: true }, include: { user: true },
orderBy: { created_at: 'asc' }, orderBy: { created_at: 'asc' },
take: 100, take: 100,
}); });
let delivered = 0; let fixed = 0;
for (const order of pending) { for (const order of pending) {
try { try {
const result = await this.wxpay.queryOrder(order.out_trade_no, order.user.openid, source); const result = await this.wxpay.queryOrder(order.out_trade_no, order.user.openid, source);
const status = result.order?.status;
// ── 退款中的单:微信侧已退(5 已退款 / 8 用户退款完成)→ 补记并收回权益 ──
if (order.refund_requested_at) {
if (status === 5 || status === 8) {
const at = result.order?.update_time
? new Date(result.order.update_time * 1000)
: new Date();
await this.markRefunded(db, order.id, order.user_id, order.course_id, at);
fixed++;
this.logger.log(`兜底补记退款 order=${order.out_trade_no}`);
}
continue;
}
// ── 待发货的单:微信侧已支付 → 补发货 ──
const wxOrderId = result.wx_order_id ?? result.order?.wx_order_id; const wxOrderId = result.wx_order_id ?? result.order?.wx_order_id;
// 只认「已支付待发货 / 发货中 / 已发货」这三种状态。 // 只认「已支付待发货 / 发货中 / 已发货」这三种状态。
// 不能用「有没有 wx_order_id」当判据——已关闭(6)和已退款(5)的单也有单号, // 不能用「有没有 wx_order_id」当判据——已关闭(6)和已退款(5)的单也有单号,
// 那样会把关闭的沙箱单当成已支付补发出去(真实发生过)。 // 那样会把关闭的沙箱单当成已支付补发出去(真实发生过)。
const status = result.order?.status;
const isPaid = status === 2 || status === 3 || status === 4; const isPaid = status === 2 || status === 3 || status === 4;
if (!isPaid) { if (!isPaid) {
if (status !== undefined && status !== 1) { if (status !== undefined && status !== 1) {
@@ -377,14 +392,14 @@ export class PaymentsService {
} }
await this.grantEntitlement( await this.grantEntitlement(
this.prisma.active, db,
order.user_id, order.user_id,
order.course_id, order.course_id,
order.id, order.id,
order.out_trade_no, order.out_trade_no,
wxOrderId ?? `reconciled-${order.out_trade_no}`, wxOrderId ?? `reconciled-${order.out_trade_no}`,
); );
delivered++; fixed++;
this.logger.log(`兜底补发成功 order=${order.out_trade_no}`); this.logger.log(`兜底补发成功 order=${order.out_trade_no}`);
} catch (err) { } catch (err) {
this.logger.warn( this.logger.warn(
@@ -392,7 +407,7 @@ export class PaymentsService {
); );
} }
} }
return delivered; return fixed;
} }
/** /**
@@ -483,6 +498,31 @@ export class PaymentsService {
]); ]);
} }
/**
* 标记订单已退款并收回这笔订单发出去的权益。退款推送与兜底查单共用同一套逻辑。
*
* 只删这一笔订单发的权益(source=1 且 ref_id 对上),动不到老数据迁移来的 source=2。
* 已知上限:已经下发出去的播放地址在签名有效期内(默认 30 分钟)仍可访问,
* 要更严得换密钥或缩短 PLAY_URL_TTL_SECONDS。
*/
private async markRefunded(
db: PrismaClient,
orderId: number,
userId: number,
courseId: number,
refundedAt: Date = new Date(),
): Promise<void> {
await db.$transaction([
db.order.update({
where: { id: orderId },
data: { status: OrderStatus.REFUNDED, refunded_at: refundedAt },
}),
db.entitlement.deleteMany({
where: { user_id: userId, course_id: courseId, source: 1, ref_id: orderId },
}),
]);
}
/** 记一条原始推送。event 默认从报文里取,取不到时允许调用方指定(客服消息没有 Event)。 */ /** 记一条原始推送。event 默认从报文里取,取不到时允许调用方指定(客服消息没有 Event)。 */
private async logNotify(rawXml: string, event?: string): Promise<number> { private async logNotify(rawXml: string, event?: string): Promise<number> {
const row = await this.prisma.active.paymentNotifyLog.create({ const row = await this.prisma.active.paymentNotifyLog.create({
@@ -222,6 +222,8 @@ export interface XpayQueryResult {
wx_order_id?: string; wx_order_id?: string;
status?: number; status?: number;
paid_time?: number; paid_time?: number;
/** 最后一次状态变更时间;退款完成时就是退款时间 */
update_time?: number;
/** 当前单剩余可退金额,单位分 */ /** 当前单剩余可退金额,单位分 */
left_fee?: number; left_fee?: number;
}; };