From 014fa90dcd3df4d18796890411f7e7d9ee0d54bd Mon Sep 17 00:00:00 2001 From: YeomaiPC Date: Thu, 1 Oct 2026 12:16:22 +0800 Subject: [PATCH] =?UTF-8?q?=E5=85=9C=E5=BA=95=E6=9F=A5=E5=8D=95=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E9=80=80=E6=AC=BE=E4=B8=AD=E7=9A=84=E8=AE=A2=E5=8D=95?= =?UTF-8?q?=20+=20=E6=89=AB=E4=B8=A4=E4=B8=AA=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 退款推送丢了订单会永远停在「退款中」;现在每 5 分钟的兜底查单也会查这类订单: 微信侧已是已退款(5)或用户退款完成(8) → 自动回写状态并收回权益,refunded_at 用微信的 update_time - 兜底查单同时扫生产库与开发库,支付环境跟着库走(生产用现网、开发用沙箱) - 「标记退款 + 收回权益」抽成公共方法,退款推送与兜底查单共用 --- .../src/modules/payments/payments.service.ts | 94 +++++++++++++------ .../src/modules/payments/wxpay.service.ts | 2 + 2 files changed, 69 insertions(+), 27 deletions(-) diff --git a/yelaymaths_api/src/modules/payments/payments.service.ts b/yelaymaths_api/src/modules/payments/payments.service.ts index b720a72..f3e8867 100644 --- a/yelaymaths_api/src/modules/payments/payments.service.ts +++ b/yelaymaths_api/src/modules/payments/payments.service.ts @@ -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 { - 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 { + 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 { + 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 { const row = await this.prisma.active.paymentNotifyLog.create({ diff --git a/yelaymaths_api/src/modules/payments/wxpay.service.ts b/yelaymaths_api/src/modules/payments/wxpay.service.ts index 802c549..78935a9 100644 --- a/yelaymaths_api/src/modules/payments/wxpay.service.ts +++ b/yelaymaths_api/src/modules/payments/wxpay.service.ts @@ -222,6 +222,8 @@ export interface XpayQueryResult { wx_order_id?: string; status?: number; paid_time?: number; + /** 最后一次状态变更时间;退款完成时就是退款时间 */ + update_time?: number; /** 当前单剩余可退金额,单位分 */ left_fee?: number; };