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:
@@ -55,6 +55,10 @@ WX_API_V3_KEY=
|
|||||||
# 微信平台公钥证书 PEM(生产环境必填,用于回调验签;开发可暂留空)
|
# 微信平台公钥证书 PEM(生产环境必填,用于回调验签;开发可暂留空)
|
||||||
WX_PLATFORM_CERT=
|
WX_PLATFORM_CERT=
|
||||||
WX_PAY_NOTIFY_URL=https://api.dukanghaoke.com/api/v1/callbacks/wechat/pay
|
WX_PAY_NOTIFY_URL=https://api.dukanghaoke.com/api/v1/callbacks/wechat/pay
|
||||||
|
# 小程序消息推送(发货管理确认收货/结算事件):后台 URL 填下方地址,数据格式建议 JSON,加密方式建议安全模式
|
||||||
|
# https://api.dukanghaoke.com/api/v1/callbacks/wechat/message
|
||||||
|
WX_MINI_MSG_TOKEN=
|
||||||
|
WX_MINI_MSG_AES_KEY=
|
||||||
|
|
||||||
# 企业微信智能机器人总开关(Bot 实例在 HQ「企微机器人」模块创建)
|
# 企业微信智能机器人总开关(Bot 实例在 HQ「企微机器人」模块创建)
|
||||||
WECOM_AIBOT_ENABLED=false
|
WECOM_AIBOT_ENABLED=false
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ WX_MCH_PRIVATE_KEY=
|
|||||||
WX_API_V3_KEY=
|
WX_API_V3_KEY=
|
||||||
WX_PLATFORM_CERT=
|
WX_PLATFORM_CERT=
|
||||||
WX_PAY_NOTIFY_URL=https://api.dukanghaoke.com/api/v1/callbacks/wechat/pay
|
WX_PAY_NOTIFY_URL=https://api.dukanghaoke.com/api/v1/callbacks/wechat/pay
|
||||||
|
# 消息推送 URL: https://api.dukanghaoke.com/api/v1/callbacks/wechat/message
|
||||||
|
WX_MINI_MSG_TOKEN=
|
||||||
|
WX_MINI_MSG_AES_KEY=
|
||||||
|
|
||||||
# 企业微信机器人总开关(实例在 HQ 企微机器人模块维护)
|
# 企业微信机器人总开关(实例在 HQ 企微机器人模块维护)
|
||||||
WECOM_AIBOT_ENABLED=false
|
WECOM_AIBOT_ENABLED=false
|
||||||
|
|||||||
@@ -4,10 +4,16 @@ import { TradeModule } from '../modules/trade/trade.module';
|
|||||||
import { PrismaModule } from '../common/prisma/prisma.module';
|
import { PrismaModule } from '../common/prisma/prisma.module';
|
||||||
import { WechatPayCallbackController } from './wechat-pay.controller';
|
import { WechatPayCallbackController } from './wechat-pay.controller';
|
||||||
import { WechatRefundCallbackController } from './wechat-refund.controller';
|
import { WechatRefundCallbackController } from './wechat-refund.controller';
|
||||||
|
import { WechatMessageCallbackController } from './wechat-message.controller';
|
||||||
import { DeliveryCallbackController } from './delivery-track.controller';
|
import { DeliveryCallbackController } from './delivery-track.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [IntegrationsModule, TradeModule, PrismaModule],
|
imports: [IntegrationsModule, TradeModule, PrismaModule],
|
||||||
controllers: [WechatPayCallbackController, WechatRefundCallbackController, DeliveryCallbackController],
|
controllers: [
|
||||||
|
WechatPayCallbackController,
|
||||||
|
WechatRefundCallbackController,
|
||||||
|
WechatMessageCallbackController,
|
||||||
|
DeliveryCallbackController,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class CallbacksModule {}
|
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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 小程序消息推送(发货信息管理事件)。
|
||||||
|
* 后台配置 URL:https://{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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,6 +77,25 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
|
|||||||
{ key: 'WX_API_V3_KEY', label: 'APIv3 密钥', group: G.wechat, type: 'password', secret: true, requiresRestart: true },
|
{ key: 'WX_API_V3_KEY', label: 'APIv3 密钥', group: G.wechat, type: 'password', secret: true, requiresRestart: true },
|
||||||
{ key: 'WX_PLATFORM_CERT', label: '微信平台公钥证书', group: G.wechat, type: 'textarea', secret: true, requiresRestart: true },
|
{ key: 'WX_PLATFORM_CERT', label: '微信平台公钥证书', group: G.wechat, type: 'textarea', secret: true, requiresRestart: true },
|
||||||
{ key: 'WX_PAY_NOTIFY_URL', label: '支付回调 URL', group: G.wechat, type: 'string', requiresRestart: false },
|
{ key: 'WX_PAY_NOTIFY_URL', label: '支付回调 URL', group: G.wechat, type: 'string', requiresRestart: false },
|
||||||
|
{
|
||||||
|
key: 'WX_MINI_MSG_TOKEN',
|
||||||
|
label: '小程序消息推送 Token',
|
||||||
|
group: G.wechat,
|
||||||
|
type: 'password',
|
||||||
|
secret: true,
|
||||||
|
requiresRestart: true,
|
||||||
|
description:
|
||||||
|
'小程序后台「开发-开发管理-消息推送」Token;回调 URL=/api/v1/callbacks/wechat/message',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'WX_MINI_MSG_AES_KEY',
|
||||||
|
label: '小程序消息推送 EncodingAESKey',
|
||||||
|
group: G.wechat,
|
||||||
|
type: 'password',
|
||||||
|
secret: true,
|
||||||
|
requiresRestart: true,
|
||||||
|
description: '消息推送安全模式 EncodingAESKey(43 位);明文模式可留空',
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
key: 'MINI_HOME_BANNERS',
|
key: 'MINI_HOME_BANNERS',
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { WechatDisabledProvider } from './wechat/wechat.disabled.provider';
|
|||||||
import { WechatMockProvider } from './wechat/wechat.mock.provider';
|
import { WechatMockProvider } from './wechat/wechat.mock.provider';
|
||||||
import { WechatRouterProvider } from './wechat/wechat.router.provider';
|
import { WechatRouterProvider } from './wechat/wechat.router.provider';
|
||||||
import { WechatOrderShippingService } from './wechat/wechat-order-shipping.service';
|
import { WechatOrderShippingService } from './wechat/wechat-order-shipping.service';
|
||||||
|
import { WechatTradeManageService } from './wechat/wechat-trade-manage.service';
|
||||||
import { OssAliyunProvider } from './oss/oss.aliyun.provider';
|
import { OssAliyunProvider } from './oss/oss.aliyun.provider';
|
||||||
import { TencentLbsProvider } from './map/tencent-lbs.provider';
|
import { TencentLbsProvider } from './map/tencent-lbs.provider';
|
||||||
import {
|
import {
|
||||||
@@ -40,6 +41,7 @@ import { DELIVERY_QUEUE } from '../jobs/jobs.constants';
|
|||||||
WechatRouterProvider,
|
WechatRouterProvider,
|
||||||
{ provide: WECHAT_PROVIDER, useExisting: WechatRouterProvider },
|
{ provide: WECHAT_PROVIDER, useExisting: WechatRouterProvider },
|
||||||
WechatOrderShippingService,
|
WechatOrderShippingService,
|
||||||
|
WechatTradeManageService,
|
||||||
PayMockProvider,
|
PayMockProvider,
|
||||||
PayWechatProvider,
|
PayWechatProvider,
|
||||||
PayRouterProvider,
|
PayRouterProvider,
|
||||||
@@ -58,6 +60,7 @@ import { DELIVERY_QUEUE } from '../jobs/jobs.constants';
|
|||||||
DELIVERY_PROVIDER,
|
DELIVERY_PROVIDER,
|
||||||
WECHAT_PROVIDER,
|
WECHAT_PROVIDER,
|
||||||
WechatOrderShippingService,
|
WechatOrderShippingService,
|
||||||
|
WechatTradeManageService,
|
||||||
OSS_PROVIDER,
|
OSS_PROVIDER,
|
||||||
MAP_PROVIDER,
|
MAP_PROVIDER,
|
||||||
TencentLbsProvider,
|
TencentLbsProvider,
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'crypto';
|
||||||
|
|
||||||
|
/** 微信消息推送:Token/timestamp/nonce[/Encrypt] 字典序拼接后 SHA1 */
|
||||||
|
export function wechatMsgSha1(...parts: string[]): string {
|
||||||
|
return createHash('sha1').update([...parts].sort().join('')).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyWechatUrlSignature(
|
||||||
|
token: string,
|
||||||
|
timestamp: string,
|
||||||
|
nonce: string,
|
||||||
|
signature: string,
|
||||||
|
): boolean {
|
||||||
|
if (!token || !timestamp || !nonce || !signature) return false;
|
||||||
|
return wechatMsgSha1(token, timestamp, nonce) === signature;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyWechatMsgSignature(
|
||||||
|
token: string,
|
||||||
|
timestamp: string,
|
||||||
|
nonce: string,
|
||||||
|
encrypt: string,
|
||||||
|
msgSignature: string,
|
||||||
|
): boolean {
|
||||||
|
if (!token || !timestamp || !nonce || !encrypt || !msgSignature) return false;
|
||||||
|
return wechatMsgSha1(token, timestamp, nonce, encrypt) === msgSignature;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeAesKey(encodingAESKey: string): Buffer {
|
||||||
|
const key = Buffer.from(`${encodingAESKey.trim()}=`, 'base64');
|
||||||
|
if (key.length !== 32) {
|
||||||
|
throw new Error(`EncodingAESKey 无效(解码后应为 32 字节,实际 ${key.length})`);
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解密微信安全模式 Encrypt 字段。
|
||||||
|
* FullStr = random(16) + msg_len(4 BE) + msg + appid
|
||||||
|
*/
|
||||||
|
export function decryptWechatEncrypt(
|
||||||
|
encryptBase64: string,
|
||||||
|
encodingAESKey: string,
|
||||||
|
expectedAppId?: string,
|
||||||
|
): string {
|
||||||
|
const aesKey = decodeAesKey(encodingAESKey);
|
||||||
|
const iv = aesKey.subarray(0, 16);
|
||||||
|
const decipher = createDecipheriv('aes-256-cbc', aesKey, iv);
|
||||||
|
const decrypted = Buffer.concat([
|
||||||
|
decipher.update(Buffer.from(encryptBase64, 'base64')),
|
||||||
|
decipher.final(),
|
||||||
|
]);
|
||||||
|
if (decrypted.length < 20) {
|
||||||
|
throw new Error('解密结果过短');
|
||||||
|
}
|
||||||
|
const msgLen = decrypted.readUInt32BE(16);
|
||||||
|
const msgStart = 20;
|
||||||
|
const msgEnd = msgStart + msgLen;
|
||||||
|
if (msgEnd > decrypted.length) {
|
||||||
|
throw new Error('解密消息长度非法');
|
||||||
|
}
|
||||||
|
const msg = decrypted.subarray(msgStart, msgEnd).toString('utf8');
|
||||||
|
const appId = decrypted.subarray(msgEnd).toString('utf8');
|
||||||
|
if (expectedAppId && appId && appId !== expectedAppId) {
|
||||||
|
throw new Error(`appid 不匹配: got=${appId}`);
|
||||||
|
}
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 加密回包(一般回复 success 明文即可,此函数供需要加密回包时使用) */
|
||||||
|
export function encryptWechatReply(
|
||||||
|
plain: string,
|
||||||
|
encodingAESKey: string,
|
||||||
|
appId: string,
|
||||||
|
): string {
|
||||||
|
const aesKey = decodeAesKey(encodingAESKey);
|
||||||
|
const iv = aesKey.subarray(0, 16);
|
||||||
|
const random = randomBytes(16);
|
||||||
|
const msg = Buffer.from(plain, 'utf8');
|
||||||
|
const msgLen = Buffer.alloc(4);
|
||||||
|
msgLen.writeUInt32BE(msg.length, 0);
|
||||||
|
const full = Buffer.concat([random, msgLen, msg, Buffer.from(appId, 'utf8')]);
|
||||||
|
const cipher = createCipheriv('aes-256-cbc', aesKey, iv);
|
||||||
|
return Buffer.concat([cipher.update(full), cipher.final()]).toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 简易 XML 标签提取(微信推送字段无嵌套结构) */
|
||||||
|
export function parseSimpleXml(xml: string): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
const re = /<([A-Za-z0-9_]+)>(?:<!\[CDATA\[([\s\S]*?)\]\]>|([^<]*))<\/\1>/g;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = re.exec(xml))) {
|
||||||
|
out[m[1]] = (m[2] ?? m[3] ?? '').trim();
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseWechatPushBody(raw: string): Record<string, unknown> {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return {};
|
||||||
|
if (trimmed.startsWith('{')) {
|
||||||
|
return JSON.parse(trimmed) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
return parseSimpleXml(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WechatTradeManageEvent = {
|
||||||
|
event: string;
|
||||||
|
toUserName?: string;
|
||||||
|
fromUserName?: string;
|
||||||
|
createTime?: number;
|
||||||
|
transactionId?: string;
|
||||||
|
merchantId?: string;
|
||||||
|
subMerchantId?: string;
|
||||||
|
merchantTradeNo?: string;
|
||||||
|
payTime?: number;
|
||||||
|
shippedTime?: number;
|
||||||
|
estimatedSettlementTime?: number;
|
||||||
|
/** 1 手动确认;2 自动确认(结算推送才有) */
|
||||||
|
confirmReceiveMethod?: number;
|
||||||
|
confirmReceiveTime?: number;
|
||||||
|
settlementTime?: number;
|
||||||
|
msg?: string;
|
||||||
|
raw: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function num(v: unknown): number | undefined {
|
||||||
|
if (v == null || v === '') return undefined;
|
||||||
|
const n = typeof v === 'number' ? v : Number(v);
|
||||||
|
return Number.isFinite(n) ? n : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function str(v: unknown): string | undefined {
|
||||||
|
if (v == null) return undefined;
|
||||||
|
const s = String(v).trim();
|
||||||
|
return s || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeTradeManageEvent(body: Record<string, unknown>): WechatTradeManageEvent {
|
||||||
|
return {
|
||||||
|
event: str(body.Event ?? body.event) || '',
|
||||||
|
toUserName: str(body.ToUserName),
|
||||||
|
fromUserName: str(body.FromUserName),
|
||||||
|
createTime: num(body.CreateTime),
|
||||||
|
transactionId: str(body.transaction_id),
|
||||||
|
merchantId: str(body.merchant_id),
|
||||||
|
subMerchantId: str(body.sub_merchant_id),
|
||||||
|
merchantTradeNo: str(body.merchant_trade_no),
|
||||||
|
payTime: num(body.pay_time),
|
||||||
|
shippedTime: num(body.shipped_time),
|
||||||
|
estimatedSettlementTime: num(body.estimated_settlement_time),
|
||||||
|
confirmReceiveMethod: num(body.confirm_receive_method),
|
||||||
|
confirmReceiveTime: num(body.confirm_receive_time),
|
||||||
|
settlementTime: num(body.settlement_time),
|
||||||
|
msg: str(body.msg),
|
||||||
|
raw: body,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -9,6 +9,17 @@ import {
|
|||||||
resolveExpressCompanyId,
|
resolveExpressCompanyId,
|
||||||
} from './wechat-order-shipping.util';
|
} from './wechat-order-shipping.util';
|
||||||
|
|
||||||
|
/** 微信要求支付成功后约 1 分钟才入库,过早调用会返回 10060001 */
|
||||||
|
const MIN_PAID_AGE_MS = 65_000;
|
||||||
|
/** 10060001 / 系统繁忙时的重试间隔 */
|
||||||
|
const RETRY_DELAY_MS = 60_000;
|
||||||
|
const MAX_ATTEMPTS = 5;
|
||||||
|
const RETRYABLE_ERRCODES = new Set([10060001, -1, 10060012, 10060019]);
|
||||||
|
|
||||||
|
function sleep(ms: number) {
|
||||||
|
return new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 小程序发货信息管理:订单发货/自提后向微信录入发货信息,解冻交易资金。
|
* 小程序发货信息管理:订单发货/自提后向微信录入发货信息,解冻交易资金。
|
||||||
* @see https://developers.weixin.qq.com/miniprogram/dev/platform-capabilities/business-capabilities/order-shipping/order-shipping.html
|
* @see https://developers.weixin.qq.com/miniprogram/dev/platform-capabilities/business-capabilities/order-shipping/order-shipping.html
|
||||||
@@ -22,15 +33,51 @@ export class WechatOrderShippingService {
|
|||||||
@Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider,
|
@Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** 异步安全上报,失败只记日志,不阻断主履约流程 */
|
/** 异步安全上报:按 paidAt 等待入库窗口,失败可重试,不阻断主履约流程 */
|
||||||
uploadForOrderSafe(orderId: bigint) {
|
uploadForOrderSafe(orderId: bigint) {
|
||||||
void this.uploadForOrder(orderId).catch((err) => {
|
void this.scheduleAndUpload(orderId).catch((err) => {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
this.logger.error(`upload shipping info failed order=${orderId}: ${msg}`);
|
this.logger.error(`upload shipping info failed order=${orderId}: ${msg}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async uploadForOrder(orderId: bigint): Promise<{ skipped?: string; ok?: boolean } | void> {
|
private async scheduleAndUpload(orderId: bigint) {
|
||||||
|
const paidAtRow = await this.prisma.order.findUnique({
|
||||||
|
where: { id: orderId },
|
||||||
|
select: { paidAt: true, orderNo: true },
|
||||||
|
});
|
||||||
|
if (!paidAtRow?.paidAt) {
|
||||||
|
await this.uploadForOrder(orderId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ageMs = Date.now() - paidAtRow.paidAt.getTime();
|
||||||
|
const waitMs = Math.max(0, MIN_PAID_AGE_MS - ageMs);
|
||||||
|
if (waitMs > 0) {
|
||||||
|
this.logger.log(
|
||||||
|
`WeChat upload_shipping_info wait ${waitMs}ms for pay入库 order=${paidAtRow.orderNo}`,
|
||||||
|
);
|
||||||
|
await sleep(waitMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||||
|
const result = await this.uploadForOrder(orderId);
|
||||||
|
if (!result || result.skipped || result.ok) return;
|
||||||
|
|
||||||
|
const retryable = result.errcode != null && RETRYABLE_ERRCODES.has(result.errcode);
|
||||||
|
if (!retryable || attempt >= MAX_ATTEMPTS) return;
|
||||||
|
|
||||||
|
this.logger.warn(
|
||||||
|
`WeChat upload_shipping_info retry ${attempt}/${MAX_ATTEMPTS} ` +
|
||||||
|
`order=${paidAtRow.orderNo} errcode=${result.errcode} in ${RETRY_DELAY_MS}ms`,
|
||||||
|
);
|
||||||
|
await sleep(RETRY_DELAY_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadForOrder(
|
||||||
|
orderId: bigint,
|
||||||
|
): Promise<{ skipped?: string; ok?: boolean; errcode?: number } | void> {
|
||||||
const cfg = loadAppConfig();
|
const cfg = loadAppConfig();
|
||||||
if (cfg.mockPay) {
|
if (cfg.mockPay) {
|
||||||
return { skipped: 'MOCK_PAY' };
|
return { skipped: 'MOCK_PAY' };
|
||||||
@@ -145,6 +192,9 @@ export class WechatOrderShippingService {
|
|||||||
trackingNo: shippingItem.trackingNo,
|
trackingNo: shippingItem.trackingNo,
|
||||||
expressCompany: shippingItem.expressCompany,
|
expressCompany: shippingItem.expressCompany,
|
||||||
orderNumberType: input.orderNumberType,
|
orderNumberType: input.orderNumberType,
|
||||||
|
transactionId: transactionId || undefined,
|
||||||
|
outTradeNo: input.outTradeNo,
|
||||||
|
payerOpenId: openId,
|
||||||
},
|
},
|
||||||
responseBody: { errcode: result.errcode, errmsg: result.errmsg },
|
responseBody: { errcode: result.errcode, errmsg: result.errmsg },
|
||||||
externalNo: transactionId || order.orderNo,
|
externalNo: transactionId || order.orderNo,
|
||||||
@@ -156,7 +206,7 @@ export class WechatOrderShippingService {
|
|||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`WeChat upload_shipping_info order=${order.orderNo} ${result.errcode} ${result.errmsg}`,
|
`WeChat upload_shipping_info order=${order.orderNo} ${result.errcode} ${result.errmsg}`,
|
||||||
);
|
);
|
||||||
return { ok: false };
|
return { ok: false, errcode: result.errcode };
|
||||||
}
|
}
|
||||||
this.logger.log(`WeChat upload_shipping_info ok order=${order.orderNo}`);
|
this.logger.log(`WeChat upload_shipping_info ok order=${order.orderNo}`);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
@@ -169,7 +219,11 @@ export class WechatOrderShippingService {
|
|||||||
refType: 'ORDER',
|
refType: 'ORDER',
|
||||||
refId: orderId,
|
refId: orderId,
|
||||||
requestUrl,
|
requestUrl,
|
||||||
requestBody: { orderNo: order.orderNo },
|
requestBody: {
|
||||||
|
orderNo: order.orderNo,
|
||||||
|
orderNumberType: input.orderNumberType,
|
||||||
|
transactionId: transactionId || undefined,
|
||||||
|
},
|
||||||
status: 'FAILED',
|
status: 'FAILED',
|
||||||
errorMessage: message.slice(0, 512),
|
errorMessage: message.slice(0, 512),
|
||||||
externalNo: transactionId || order.orderNo,
|
externalNo: transactionId || order.orderNo,
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { buildOrderStatusEvent } from '../../common/event/event.helpers';
|
||||||
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
|
import { WechatOrderShippingService } from './wechat-order-shipping.service';
|
||||||
|
import type { WechatTradeManageEvent } from './wechat-msg-crypto.util';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 小程序发货信息管理相关消息推送处理。
|
||||||
|
* @see https://developers.weixin.qq.com/miniprogram/dev/platform-capabilities/business-capabilities/order-shipping/order-shipping.html
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class WechatTradeManageService {
|
||||||
|
private readonly logger = new Logger(WechatTradeManageService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly wechatOrderShipping: WechatOrderShippingService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async handleEvent(evt: WechatTradeManageEvent): Promise<{ handled: string }> {
|
||||||
|
switch (evt.event) {
|
||||||
|
case 'trade_manage_order_settlement':
|
||||||
|
return this.handleOrderSettlement(evt);
|
||||||
|
case 'trade_manage_remind_shipping':
|
||||||
|
return this.handleRemindShipping(evt);
|
||||||
|
case 'trade_manage_remind_access_api':
|
||||||
|
case 'wxa_trade_controlled':
|
||||||
|
await this.logEvent(evt, null, 'SUCCESS', evt.msg || evt.event);
|
||||||
|
return { handled: evt.event };
|
||||||
|
default:
|
||||||
|
await this.logEvent(evt, null, 'SUCCESS', `ignored:${evt.event || 'empty'}`);
|
||||||
|
return { handled: 'ignored' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleOrderSettlement(evt: WechatTradeManageEvent) {
|
||||||
|
const order = await this.findOrder(evt);
|
||||||
|
const isConfirmOrSettle =
|
||||||
|
evt.confirmReceiveTime != null || evt.settlementTime != null || evt.confirmReceiveMethod != null;
|
||||||
|
|
||||||
|
if (!isConfirmOrSettle) {
|
||||||
|
// 发货时推送:仅有 shipped_time / estimated_settlement_time
|
||||||
|
await this.logEvent(evt, order?.id ?? null, 'SUCCESS', 'shipped_notify');
|
||||||
|
return { handled: 'settlement_shipped_notify' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
await this.logEvent(evt, null, 'FAILED', 'ORDER_NOT_FOUND');
|
||||||
|
this.logger.warn(
|
||||||
|
`trade_manage_order_settlement order not found tradeNo=${evt.merchantTradeNo} tx=${evt.transactionId}`,
|
||||||
|
);
|
||||||
|
return { handled: 'settlement_order_missing' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.status === 'COMPLETED') {
|
||||||
|
await this.logEvent(evt, order.id, 'SUCCESS', 'already_completed');
|
||||||
|
return { handled: 'settlement_already_completed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.payStatus !== 'PAID') {
|
||||||
|
await this.logEvent(evt, order.id, 'FAILED', 'NOT_PAID');
|
||||||
|
return { handled: 'settlement_not_paid' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const methodLabel =
|
||||||
|
evt.confirmReceiveMethod === 2
|
||||||
|
? '微信自动确认收货'
|
||||||
|
: evt.confirmReceiveMethod === 1
|
||||||
|
? '微信手动确认收货'
|
||||||
|
: '微信订单结算';
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const remark = [
|
||||||
|
methodLabel,
|
||||||
|
evt.confirmReceiveTime ? `confirmAt=${evt.confirmReceiveTime}` : null,
|
||||||
|
evt.settlementTime ? `settleAt=${evt.settlementTime}` : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' | ')
|
||||||
|
.slice(0, 512);
|
||||||
|
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
await tx.order.update({
|
||||||
|
where: { id: order.id },
|
||||||
|
data: {
|
||||||
|
status: 'COMPLETED',
|
||||||
|
completedAt: order.completedAt ?? now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.orderDelivery.updateMany({
|
||||||
|
where: { orderId: order.id, deliveredAt: null },
|
||||||
|
data: { deliveredAt: now },
|
||||||
|
});
|
||||||
|
await tx.commonEvent.create({
|
||||||
|
data: buildOrderStatusEvent({
|
||||||
|
orderId: order.id,
|
||||||
|
fromStatus: order.status,
|
||||||
|
toStatus: 'COMPLETED',
|
||||||
|
operator: 'WECHAT_TRADE_MANAGE',
|
||||||
|
remark,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.logEvent(evt, order.id, 'SUCCESS', methodLabel);
|
||||||
|
this.logger.log(
|
||||||
|
`WeChat confirm/settle → COMPLETED order=${order.orderNo} method=${evt.confirmReceiveMethod ?? '-'}`,
|
||||||
|
);
|
||||||
|
return { handled: 'settlement_completed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleRemindShipping(evt: WechatTradeManageEvent) {
|
||||||
|
const order = await this.findOrder(evt);
|
||||||
|
await this.logEvent(evt, order?.id ?? null, order ? 'SUCCESS' : 'FAILED', evt.msg || 'remind_shipping');
|
||||||
|
if (order) {
|
||||||
|
this.wechatOrderShipping.uploadForOrderSafe(order.id);
|
||||||
|
}
|
||||||
|
return { handled: 'remind_shipping' };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findOrder(evt: WechatTradeManageEvent) {
|
||||||
|
if (evt.merchantTradeNo) {
|
||||||
|
const byNo = await this.prisma.order.findUnique({ where: { orderNo: evt.merchantTradeNo } });
|
||||||
|
if (byNo) return byNo;
|
||||||
|
}
|
||||||
|
if (evt.transactionId) {
|
||||||
|
return this.prisma.order.findFirst({
|
||||||
|
where: { payExternalNo: evt.transactionId },
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async logEvent(
|
||||||
|
evt: WechatTradeManageEvent,
|
||||||
|
orderId: bigint | null,
|
||||||
|
status: 'SUCCESS' | 'FAILED',
|
||||||
|
note: string,
|
||||||
|
) {
|
||||||
|
await this.prisma.logThirdParty.create({
|
||||||
|
data: {
|
||||||
|
provider: 'WECHAT_PAY',
|
||||||
|
scene: 'TRADE_MANAGE_PUSH',
|
||||||
|
refType: orderId ? 'ORDER' : 'SYSTEM',
|
||||||
|
refId: orderId ?? undefined,
|
||||||
|
requestUrl: 'callbacks/wechat/message',
|
||||||
|
requestBody: {
|
||||||
|
event: evt.event,
|
||||||
|
merchantTradeNo: evt.merchantTradeNo,
|
||||||
|
transactionId: evt.transactionId,
|
||||||
|
confirmReceiveMethod: evt.confirmReceiveMethod,
|
||||||
|
confirmReceiveTime: evt.confirmReceiveTime,
|
||||||
|
settlementTime: evt.settlementTime,
|
||||||
|
shippedTime: evt.shippedTime,
|
||||||
|
estimatedSettlementTime: evt.estimatedSettlementTime,
|
||||||
|
msg: evt.msg,
|
||||||
|
},
|
||||||
|
responseBody: { note },
|
||||||
|
externalNo: evt.transactionId || evt.merchantTradeNo,
|
||||||
|
status,
|
||||||
|
errorMessage: status === 'FAILED' ? note.slice(0, 512) : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,10 @@ async function bootstrap() {
|
|||||||
app.use(
|
app.use(
|
||||||
json({
|
json({
|
||||||
verify: (req, _res, buf) => {
|
verify: (req, _res, buf) => {
|
||||||
if (req.url?.includes('/callbacks/wechat/pay')) {
|
if (
|
||||||
|
req.url?.includes('/callbacks/wechat/pay') ||
|
||||||
|
req.url?.includes('/callbacks/wechat/message')
|
||||||
|
) {
|
||||||
(req as { rawBody?: Buffer }).rawBody = buf;
|
(req as { rawBody?: Buffer }).rawBody = buf;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user