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×tamp&nonce&echostr(安全模式换成 msg_signature + 加密 echostr), * 验签通过必须原样返回 echostr,否则一直提示「服务器地址验证失败」。 */ @Public() @Get('notify') verify(@Query() query: Record, @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 { 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 { res.type('application/xml').send(await this.payments.handleRefundNotify(this.decode(req))); } /** 取原始报文;安全模式下包体是 密文,先解出来。 */ private decode(req: Request): string { const raw = rawBodyOf(req); const aesKey = this.cfg.WX_MSG_AES_KEY; if (!aesKey || !raw.includes('')) 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; } } }