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

- 退款推送丢了订单会永远停在「退款中」;现在每 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 { XMLParser } from 'fast-xml-parser';
import { APP_CONFIG, type AppConfig } from '../../config/config';
import type { DataSourceEnv } from '../../common/admin-env';
import { decryptSecret } from '../../common/crypto';
import { AppError, ErrorCode } from '../../common/errors';
import { cdata, customerServiceMsgTypeLabel, eventOf, fieldOf } from '../../common/wechat-msg';
@@ -201,25 +202,7 @@ export class PaymentsService {
return this.okXml();
}
// 退款要同时收回权益,否则用户钱退了课还能看。
// 只删这笔订单发出去的那一条(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,
},
}),
]);
await this.markRefunded(db, order.id, order.user_id, order.course_id);
const label = this.dbLabel(db);
const where = db === this.prisma.active ? '' : `(订单在${label})`;
@@ -349,25 +332,57 @@ export class PaymentsService {
* 由定时任务每 5 分钟调用一次。
*/
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;
const pending = await this.prisma.active.order.findMany({
where: { status: { in: [OrderStatus.CREATED, OrderStatus.PAID] } },
const pending = await db.order.findMany({
where: {
OR: [
{ status: { in: [OrderStatus.CREATED, OrderStatus.PAID] } },
// 已发起退款、状态却还停在「已开通」——说明退款推送没到,要主动去问微信
{ status: OrderStatus.DELIVERED, refund_requested_at: { not: null } },
],
},
include: { user: true },
orderBy: { created_at: 'asc' },
take: 100,
});
let delivered = 0;
let fixed = 0;
for (const order of pending) {
try {
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;
// 只认「已支付待发货 / 发货中 / 已发货」这三种状态。
// 不能用「有没有 wx_order_id」当判据——已关闭(6)和已退款(5)的单也有单号,
// 那样会把关闭的沙箱单当成已支付补发出去(真实发生过)。
const status = result.order?.status;
const isPaid = status === 2 || status === 3 || status === 4;
if (!isPaid) {
if (status !== undefined && status !== 1) {
@@ -377,14 +392,14 @@ export class PaymentsService {
}
await this.grantEntitlement(
this.prisma.active,
db,
order.user_id,
order.course_id,
order.id,
order.out_trade_no,
wxOrderId ?? `reconciled-${order.out_trade_no}`,
);
delivered++;
fixed++;
this.logger.log(`兜底补发成功 order=${order.out_trade_no}`);
} catch (err) {
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)。 */
private async logNotify(rawXml: string, event?: string): Promise<number> {
const row = await this.prisma.active.paymentNotifyLog.create({
@@ -222,6 +222,8 @@ export interface XpayQueryResult {
wx_order_id?: string;
status?: number;
paid_time?: number;
/** 最后一次状态变更时间;退款完成时就是退款时间 */
update_time?: number;
/** 当前单剩余可退金额,单位分 */
left_fee?: number;
};