订单配置

This commit is contained in:
tianmo committed 2026-09-30 11:02:58 +08:00
1 parent e81481623c
commit c7f43e30a9
11 files changed
+492 -19

No files matched your search

@@ -1,6 +1,8 @@
import { Controller, Post, Req, Res } from '@nestjs/common';
import { Controller, Get, Logger, Post, Query, Req, Res } from '@nestjs/common';
import type { Request, Response } from 'express';
import { Public } from '../../common/auth';
import { decryptWechatMessage, sameHex, wechatSignature, xmlField } from '../../common/wechat-msg';
import { InjectConfig, type AppConfig } from '../../config/config';
import { PaymentsService } from './payments.service';
/** 取原始请求体。虚拟支付回调是 XML,必须拿到未经解析的字节。 */
@@ -16,19 +18,74 @@ function rawBodyOf(req: Request): string {
*/
@Controller('v1/payments')
export class PaymentsController {
constructor(private readonly payments: PaymentsService) {}
private readonly logger = new Logger(PaymentsController.name);
constructor(
private readonly payments: PaymentsService,
@InjectConfig() private readonly cfg: AppConfig,
) {}
/**
* 微信配置「消息推送 URL」时的验签请求。
* 后台点保存会发 GET ?signature&timestamp&nonce&echostr(安全模式换成 msg_signature + 加密 echostr),
* 验签通过必须原样返回 echostr,否则一直提示「服务器地址验证失败」。
*/
@Public()
@Get('notify')
verify(@Query() query: Record<string, string>, @Res() res: Response): void {
const token = this.cfg.WX_MSG_TOKEN;
const aesKey = this.cfg.WX_MSG_AES_KEY;
const timestamp = query.timestamp ?? '';
const nonce = query.nonce ?? '';
const echostr = query.echostr ?? '';
if (token && query.msg_signature && aesKey) {
if (sameHex(wechatSignature(token, timestamp, nonce, echostr), query.msg_signature)) {
res.type('text/plain').send(decryptWechatMessage(aesKey, echostr));
return;
}
} else if (token && sameHex(wechatSignature(token, timestamp, nonce), query.signature ?? '')) {
res.type('text/plain').send(echostr);
return;
}
this.logger.warn('消息推送地址校验失败:签名不匹配,或 .env 未配置 WX_MSG_TOKEN');
res.status(401).send('');
}
@Public()
@Post('notify')
async notify(@Req() req: Request, @Res() res: Response): Promise<void> {
const xml = await this.payments.handleDeliverNotify(rawBodyOf(req));
res.type('application/xml').send(xml);
const body = this.decode(req);
if (body.includes('xpay_goods_deliver_notify')) {
res.type('application/xml').send(await this.payments.handleDeliverNotify(body));
return;
}
// 退款推送走的是同一个消息推送地址,漏了这段退款就永远不会被记上
if (body.includes('xpay_refund_notify')) {
res.type('application/xml').send(await this.payments.handleRefundNotify(body));
return;
}
// 地址会收到全部事件(投诉、风控、代币等),其余统一回 success 表示已收到
res.type('text/plain').send('success');
}
@Public()
@Post('refund-notify')
async refundNotify(@Req() req: Request, @Res() res: Response): Promise<void> {
const xml = await this.payments.handleRefundNotify(rawBodyOf(req));
res.type('application/xml').send(xml);
res.type('application/xml').send(await this.payments.handleRefundNotify(this.decode(req)));
}
/** 取原始报文;安全模式下包体是 <Encrypt> 密文,先解出来。 */
private decode(req: Request): string {
const raw = rawBodyOf(req);
const aesKey = this.cfg.WX_MSG_AES_KEY;
if (!aesKey || !raw.includes('<Encrypt>')) return raw;
try {
return decryptWechatMessage(aesKey, xmlField(raw, 'Encrypt'));
} catch (err) {
this.logger.error(`消息推送解密失败,请核对 WX_MSG_AES_KEY:${err instanceof Error ? err.message : err}`);
return raw;
}
}
}
@@ -22,6 +22,21 @@ interface DeliverNotify {
GoodsInfo?: { ProductId?: string; Quantity?: number };
}
/** 退款推送的字段名和发货推送完全不同,别照抄。 */
interface RefundNotify {
Event?: string;
OpenId?: string;
WxRefundId?: string;
WxOrderId?: string;
/** 商户单号,对应我们下单时的 out_trade_no */
MchOrderId?: string;
/** 退款金额,单位分 */
RefundFee?: number;
/** 0 为退款成功,非 0 为失败 */
RetCode?: number;
RetMsg?: string;
}
@Injectable()
export class PaymentsService {
private readonly logger = new Logger(PaymentsService.name);
@@ -82,23 +97,62 @@ export class PaymentsService {
}
}
/** 处理退款推送。 */
/**
* 处理退款推送。
*
* 两个坑:
* 1. 商户单号在 MchOrderId,不是发货推送里的 OutTradeNo
* 2. 退款失败时也会推,必须先看 RetCode,否则会把失败的退款标成已退款
*/
async handleRefundNotify(rawXml: string): Promise<string> {
const logId = await this.logNotify(rawXml);
try {
const parsed = this.parser.parse(rawXml) as { xml?: { OutTradeNo?: string } };
const outTradeNo = parsed.xml?.OutTradeNo ?? '';
if (outTradeNo) {
await this.prisma.active.order.updateMany({
where: { out_trade_no: outTradeNo },
data: { status: OrderStatus.REFUNDED, refunded_at: new Date() },
});
const parsed = this.parser.parse(rawXml) as { xml?: RefundNotify };
const notify = parsed.xml ?? {};
const outTradeNo = notify.MchOrderId === undefined ? '' : String(notify.MchOrderId);
if (!outTradeNo) throw new Error('退款推送缺少 MchOrderId');
const retCode = notify.RetCode === undefined ? 0 : Number(notify.RetCode);
if (retCode !== 0) {
await this.markNotify(logId, 1, `退款未成功:${notify.RetMsg ?? retCode}`);
return this.okXml();
}
await this.markNotify(logId, 1, '退款已记录');
const order = await this.prisma.active.order.findUnique({ where: { out_trade_no: outTradeNo } });
if (!order) throw new Error(`退款推送找不到订单:${outTradeNo}`);
if (order.status === OrderStatus.REFUNDED) {
await this.markNotify(logId, 1, '重复推送,已忽略');
return this.okXml();
}
// 退款要同时收回权益,否则用户钱退了课还能看。
// 只删这笔订单发出去的那一条(source=1 且 ref_id 对上),
// 动不到老数据迁移来的 source=2 权益。两个写操作必须同事务。
// 已知上限:已经下发出去的播放地址在签名有效期内(默认 30 分钟)仍可播放,
// 换密钥或缩短 PLAY_URL_TTL_SECONDS 才能做得更严。
await this.prisma.active.$transaction([
this.prisma.active.order.update({
where: { id: order.id },
data: { status: OrderStatus.REFUNDED, refunded_at: new Date() },
}),
this.prisma.active.entitlement.deleteMany({
where: {
user_id: order.user_id,
course_id: order.course_id,
source: 1,
ref_id: order.id,
},
}),
]);
await this.markNotify(logId, 1, `退款成功并收回权益 ${notify.WxRefundId ?? ''} ${notify.RefundFee ?? ''}分`);
this.logger.log(`退款已记录 order=${outTradeNo} refund=${notify.WxRefundId ?? '-'}`);
return this.okXml();
} catch (err) {
await this.markNotify(logId, 2, err instanceof Error ? err.message : String(err));
return '<xml><ErrCode>1</ErrCode><ErrMsg><![CDATA[fail]]></ErrMsg></xml>';
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`退款处理失败:${message}`);
await this.markNotify(logId, 2, message);
return `<xml><ErrCode>1</ErrCode><ErrMsg><![CDATA[${message}]]></ErrMsg></xml>`;
}
}
@@ -201,4 +255,3 @@ export class PaymentsService {
return '<xml><ErrCode>0</ErrCode><ErrMsg><![CDATA[success]]></ErrMsg></xml>';
}
}