feat(trade): WeChat trade-manage push + delay shipping upload

Receive trade_manage_order_settlement to sync confirm-receive/settle;
wait ~65s and retry on 10060001 before upload_shipping_info.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-29 15:37:42 +08:00
parent ed7fd4e013
commit a0a6c14758
10 changed files with 541 additions and 7 deletions
@@ -4,10 +4,16 @@ import { TradeModule } from '../modules/trade/trade.module';
import { PrismaModule } from '../common/prisma/prisma.module';
import { WechatPayCallbackController } from './wechat-pay.controller';
import { WechatRefundCallbackController } from './wechat-refund.controller';
import { WechatMessageCallbackController } from './wechat-message.controller';
import { DeliveryCallbackController } from './delivery-track.controller';
@Module({
imports: [IntegrationsModule, TradeModule, PrismaModule],
controllers: [WechatPayCallbackController, WechatRefundCallbackController, DeliveryCallbackController],
controllers: [
WechatPayCallbackController,
WechatRefundCallbackController,
WechatMessageCallbackController,
DeliveryCallbackController,
],
})
export class CallbacksModule {}
@@ -0,0 +1,118 @@
import {
Controller,
Get,
HttpStatus,
Logger,
Post,
Query,
Req,
Res,
} from '@nestjs/common';
import type { Request, Response } from 'express';
import {
decryptWechatEncrypt,
normalizeTradeManageEvent,
parseWechatPushBody,
verifyWechatMsgSignature,
verifyWechatUrlSignature,
} from '../integrations/wechat/wechat-msg-crypto.util';
import { WechatTradeManageService } from '../integrations/wechat/wechat-trade-manage.service';
/**
* 小程序消息推送(发货信息管理事件)。
* 后台配置 URLhttps://{host}/api/v1/callbacks/wechat/message
* 需配置 WX_MINI_MSG_TOKEN;安全模式另需 WX_MINI_MSG_AES_KEY。
*/
@Controller('callbacks/wechat')
export class WechatMessageCallbackController {
private readonly logger = new Logger(WechatMessageCallbackController.name);
constructor(private readonly tradeManage: WechatTradeManageService) {}
@Get('message')
verify(
@Query('signature') signature: string,
@Query('timestamp') timestamp: string,
@Query('nonce') nonce: string,
@Query('echostr') echostr: string,
@Res() res: Response,
) {
const token = (process.env.WX_MINI_MSG_TOKEN || '').trim();
if (!token) {
this.logger.error('WX_MINI_MSG_TOKEN 未配置,无法完成消息推送 URL 验证');
return res.status(HttpStatus.SERVICE_UNAVAILABLE).send('msg token not configured');
}
if (!verifyWechatUrlSignature(token, timestamp, nonce, signature)) {
this.logger.warn('wechat message URL verify failed');
return res.status(HttpStatus.FORBIDDEN).send('invalid signature');
}
return res.status(HttpStatus.OK).send(echostr ?? '');
}
@Post('message')
async receive(
@Req() req: Request & { rawBody?: Buffer },
@Query('signature') signature: string,
@Query('timestamp') timestamp: string,
@Query('nonce') nonce: string,
@Query('msg_signature') msgSignature: string,
@Query('encrypt_type') encryptType: string,
@Res() res: Response,
) {
const token = (process.env.WX_MINI_MSG_TOKEN || '').trim();
if (!token) {
return res.status(HttpStatus.SERVICE_UNAVAILABLE).send('msg token not configured');
}
try {
const raw =
req.rawBody?.toString('utf8') ??
(typeof req.body === 'string'
? req.body
: Buffer.isBuffer(req.body)
? req.body.toString('utf8')
: JSON.stringify(req.body ?? {}));
let body = parseWechatPushBody(raw);
const encrypt = typeof body.Encrypt === 'string' ? body.Encrypt : undefined;
const secure = encryptType === 'aes' || !!encrypt;
if (secure) {
if (!encrypt) {
return res.status(HttpStatus.BAD_REQUEST).send('missing Encrypt');
}
const aesKey = (process.env.WX_MINI_MSG_AES_KEY || '').trim();
if (!aesKey) {
this.logger.error('安全模式推送但未配置 WX_MINI_MSG_AES_KEY');
return res.status(HttpStatus.SERVICE_UNAVAILABLE).send('aes key not configured');
}
if (
!verifyWechatMsgSignature(token, timestamp, nonce, encrypt, msgSignature || String(body.MsgSignature || ''))
) {
return res.status(HttpStatus.FORBIDDEN).send('invalid msg_signature');
}
const appId = (process.env.WX_MINI_APP_ID || process.env.WX_APP_ID || '').trim();
const plain = decryptWechatEncrypt(encrypt, aesKey, appId || undefined);
body = parseWechatPushBody(plain);
} else if (!verifyWechatUrlSignature(token, timestamp, nonce, signature)) {
return res.status(HttpStatus.FORBIDDEN).send('invalid signature');
}
// 云托管探活
if (String(body.action || '') === 'CheckContainerPath') {
return res.status(HttpStatus.OK).send('success');
}
const evt = normalizeTradeManageEvent(body);
if (evt.event) {
await this.tradeManage.handleEvent(evt);
}
return res.status(HttpStatus.OK).send('success');
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`wechat message push failed: ${message}`);
// 仍回 success,避免微信疯狂重试;错误已记日志
return res.status(HttpStatus.OK).send('success');
}
}
}