兜底查单覆盖退款中的订单 + 扫两个库
- 退款推送丢了订单会永远停在「退款中」;现在每 5 分钟的兜底查单也会查这类订单: 微信侧已是已退款(5)或用户退款完成(8) → 自动回写状态并收回权益,refunded_at 用微信的 update_time - 兜底查单同时扫生产库与开发库,支付环境跟着库走(生产用现网、开发用沙箱) - 「标记退款 + 收回权益」抽成公共方法,退款推送与兜底查单共用
This commit is contained in:
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;
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user