Files
YelayMaths/yelaymaths_api/src/modules/payments/payments.controller.ts
T
2026-09-30 11:02:58 +08:00

92 lines
3.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,必须拿到未经解析的字节。 */
function rawBodyOf(req: Request): string {
const withRaw = req as Request & { rawBody?: Buffer };
if (withRaw.rawBody) return withRaw.rawBody.toString('utf8');
return typeof req.body === 'string' ? req.body : String(req.body ?? '');
}
/**
* 虚拟支付回调。由微信服务器调用,不带小程序身份,靠签名与业务校验保证安全。
* 请求体是 XML,应答也必须是 XML。
*/
@Controller('v1/payments')
export class PaymentsController {
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 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> {
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;
}
}
}