订单配置

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

@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import { decryptWechatMessage, sameHex, wechatSignature, xmlField } from './wechat-msg';
// 用例全部取自微信官方文档「消息推送」的签名与加解密示例,改坏签名算法会立刻失败。
describe('wechat-msg', () => {
it('明文模式验签与官方示例一致', () => {
expect(wechatSignature('AAAAA', '1714036504', '1514711492')).toBe(
'f464b24fc39322e44b38aa78f5edd27bd1441696',
);
});
it('安全模式验签把 Encrypt 一起排序', () => {
const encrypt = '+qdx1OKCy+5JPCBFWw70tm0fJGb2Jmeia4FCB7kao+/Q5c/ohsOzQHi8khUOb05JCpj0JB4RvQMkUyus8TPxLKJGQqcvZqzDpVzazhZv6JsXUnnR8XGT740XgXZUXQ7vJVnAG+tE8NUd4yFyjPy7GgiaviNrlCTj+l5kdfMuFUPpRSrfMZuMcp3Fn2Pede2IuQrKEYwKSqFIZoNqJ4M8EajAsjLY2km32IIjdf8YL/P50F7mStwntrA2cPDrM1kb6mOcfBgRtWygb3VIYnSeOBrebufAlr7F9mFUPAJGj04=';
expect(wechatSignature('AAAAA', '1714112445', '415670741', encrypt)).toBe(
'046e02f8204d34f8ba5fa3b1db94908f3df2e9b3',
);
});
it('能解出官方示例的密文', () => {
const encrypt = '+qdx1OKCy+5JPCBFWw70tm0fJGb2Jmeia4FCB7kao+/Q5c/ohsOzQHi8khUOb05JCpj0JB4RvQMkUyus8TPxLKJGQqcvZqzDpVzazhZv6JsXUnnR8XGT740XgXZUXQ7vJVnAG+tE8NUd4yFyjPy7GgiaviNrlCTj+l5kdfMuFUPpRSrfMZuMcp3Fn2Pede2IuQrKEYwKSqFIZoNqJ4M8EajAsjLY2km32IIjdf8YL/P50F7mStwntrA2cPDrM1kb6mOcfBgRtWygb3VIYnSeOBrebufAlr7F9mFUPAJGj04=';
const plain = decryptWechatMessage('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', encrypt);
expect(plain).toContain('"Event"');
expect(plain).toContain('debug_demo');
});
it('xmlField 兼容 CDATA 与普通标签', () => {
expect(xmlField('<xml><Encrypt><![CDATA[abc=]]></Encrypt></xml>', 'Encrypt')).toBe('abc=');
expect(xmlField('<xml><OutTradeNo>T123</OutTradeNo></xml>', 'OutTradeNo')).toBe('T123');
expect(xmlField('<xml/>', 'Encrypt')).toBe('');
});
it('sameHex 只认完全相同的签名', () => {
expect(sameHex('ab', 'ab')).toBe(true);
expect(sameHex('ab', 'ac')).toBe(false);
expect(sameHex('ab', 'abc')).toBe(false);
expect(sameHex('', '')).toBe(false);
});
});
+42
View File
@@ -0,0 +1,42 @@
import { createDecipheriv, createHash, timingSafeEqual } from 'node:crypto';
/**
* 微信「消息推送」服务器地址相关的两个协议细节。
*
* 小程序后台填了 URL 之后,微信会先发一个 GET 来验签,验不过就一直显示
* 「服务器地址验证失败」,也就配不上发货推送。虚拟支付文档说的「发货推送 URL」
* 指的就是这个地址。
*/
/** 签名 = sha1(Token 与 URL 参数按字典序排序后拼接)。安全模式下把 Encrypt 也算进去。 */
export function wechatSignature(token: string, ...parts: string[]): string {
return createHash('sha1').update([token, ...parts].sort().join('')).digest('hex');
}
export function sameHex(a: string, b: string): boolean {
const x = Buffer.from(a);
const y = Buffer.from(b);
return x.length > 0 && x.length === y.length && timingSafeEqual(x, y);
}
/** 取 XML 标签文本(兼容 CDATA)。 */
export function xmlField(xml: string, field: string): string {
const match = new RegExp(`<${field}>(?:<!\\[CDATA\\[)?([\\s\\S]*?)(?:\\]\\]>)?</${field}>`).exec(xml);
return match ? match[1]!.trim() : '';
}
/**
* 安全模式:AES-256-CBC。
* 密钥 = Base64(EncodingAESKey + '='),IV 取密钥前 16 字节。
* 明文 = 16 字节随机数 + 4 字节长度 + msg + appId,填充是 PKCS#7(块长 32)。
* 填充长度可能是 17~32,OpenSSL 的自动去填充只认 ≤16,所以关掉自己剥。
*/
export function decryptWechatMessage(encodingAesKey: string, base64: string): string {
const key = Buffer.from(encodingAesKey + '=', 'base64');
const decipher = createDecipheriv('aes-256-cbc', key, key.subarray(0, 16));
decipher.setAutoPadding(false);
const raw = Buffer.concat([decipher.update(Buffer.from(base64, 'base64')), decipher.final()]);
const pad = raw[raw.length - 1]!;
const body = pad >= 1 && pad <= 32 ? raw.subarray(0, raw.length - pad) : raw;
return body.subarray(20, 20 + body.readUInt32BE(16)).toString('utf8');
}
+8
View File
@@ -38,6 +38,14 @@ const envSchema = z.object({
WX_APPID: z.string().min(1),
WX_APPSECRET: z.string().min(1),
/**
* 小程序后台「消息推送」的 Token / EncodingAESKey。
* 发货推送走的正是这个地址,配置时微信会发 GET 验签,必须与后台填的值一致。
* 选「明文模式」只需 Token;选「安全模式」两个都要。
*/
WX_MSG_TOKEN: z.string().default(''),
WX_MSG_AES_KEY: z.string().default(''),
/** 虚拟支付,未开通时留空 */
WX_OFFER_ID: z.string().default(''),
/** 现网支付密钥 */
@@ -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>';
}
}