113 lines
4.6 KiB
TypeScript
113 lines
4.6 KiB
TypeScript
import { Controller, Get, Logger, Post, Query, Req, Res } from '@nestjs/common';
|
||
import type { Request, Response } from 'express';
|
||
import { Public } from '../../common/auth';
|
||
import {
|
||
decryptWechatMessage,
|
||
isCustomerServicePush,
|
||
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<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;
|
||
}
|
||
// iOS 退款问询必须 3 秒内表态,否则微信向 Apple 回「不确定」
|
||
if (body.includes('xpay_subscribe_ios_refund_query_notify')) {
|
||
const answer = await this.payments.handleIosRefundQuery(body);
|
||
// 来的是 XML 信封就回 XML,来的是 JSON 就回 JSON
|
||
res.type(body.trimStart().startsWith('<') ? 'application/xml' : 'application/json').send(answer);
|
||
return;
|
||
}
|
||
// 客服消息走的也是这个地址:用户点客服按钮(user_enter_tempsession)或给客服发消息。
|
||
// 不单独分流的话,这些推送只会被记成「未识别的事件」,用户也等不到任何回应。
|
||
if (isCustomerServicePush(body)) {
|
||
res.type('text/plain').send(await this.payments.handleCustomerService(body));
|
||
return;
|
||
}
|
||
// 地址会收到全部事件(投诉、风控、代币等),其余统一回 success 表示已收到。
|
||
// 先落一条日志,否则这类事件事后完全查不到痕迹。
|
||
await this.payments.logIgnoredNotify(body);
|
||
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;
|
||
}
|
||
}
|
||
}
|