订单配置
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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×tamp&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>';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user