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(生产环境必填,用于回调验签;开发可暂留空)
|
||||
WX_PLATFORM_CERT=
|
||||
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「企微机器人」模块创建)
|
||||
WECOM_AIBOT_ENABLED=false
|
||||
|
||||
@@ -42,6 +42,9 @@ WX_MCH_PRIVATE_KEY=
|
||||
WX_API_V3_KEY=
|
||||
WX_PLATFORM_CERT=
|
||||
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 企微机器人模块维护)
|
||||
WECOM_AIBOT_ENABLED=false
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
* 小程序消息推送(发货信息管理事件)。
|
||||
* 后台配置 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_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_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',
|
||||
|
||||
@@ -13,6 +13,7 @@ import { WechatDisabledProvider } from './wechat/wechat.disabled.provider';
|
||||
import { WechatMockProvider } from './wechat/wechat.mock.provider';
|
||||
import { WechatRouterProvider } from './wechat/wechat.router.provider';
|
||||
import { WechatOrderShippingService } from './wechat/wechat-order-shipping.service';
|
||||
import { WechatTradeManageService } from './wechat/wechat-trade-manage.service';
|
||||
import { OssAliyunProvider } from './oss/oss.aliyun.provider';
|
||||
import { TencentLbsProvider } from './map/tencent-lbs.provider';
|
||||
import {
|
||||
@@ -40,6 +41,7 @@ import { DELIVERY_QUEUE } from '../jobs/jobs.constants';
|
||||
WechatRouterProvider,
|
||||
{ provide: WECHAT_PROVIDER, useExisting: WechatRouterProvider },
|
||||
WechatOrderShippingService,
|
||||
WechatTradeManageService,
|
||||
PayMockProvider,
|
||||
PayWechatProvider,
|
||||
PayRouterProvider,
|
||||
@@ -58,6 +60,7 @@ import { DELIVERY_QUEUE } from '../jobs/jobs.constants';
|
||||
DELIVERY_PROVIDER,
|
||||
WECHAT_PROVIDER,
|
||||
WechatOrderShippingService,
|
||||
WechatTradeManageService,
|
||||
OSS_PROVIDER,
|
||||
MAP_PROVIDER,
|
||||
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,
|
||||
} 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
|
||||
@@ -22,15 +33,51 @@ export class WechatOrderShippingService {
|
||||
@Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider,
|
||||
) {}
|
||||
|
||||
/** 异步安全上报,失败只记日志,不阻断主履约流程 */
|
||||
/** 异步安全上报:按 paidAt 等待入库窗口,失败可重试,不阻断主履约流程 */
|
||||
uploadForOrderSafe(orderId: bigint) {
|
||||
void this.uploadForOrder(orderId).catch((err) => {
|
||||
void this.scheduleAndUpload(orderId).catch((err) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
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();
|
||||
if (cfg.mockPay) {
|
||||
return { skipped: 'MOCK_PAY' };
|
||||
@@ -145,6 +192,9 @@ export class WechatOrderShippingService {
|
||||
trackingNo: shippingItem.trackingNo,
|
||||
expressCompany: shippingItem.expressCompany,
|
||||
orderNumberType: input.orderNumberType,
|
||||
transactionId: transactionId || undefined,
|
||||
outTradeNo: input.outTradeNo,
|
||||
payerOpenId: openId,
|
||||
},
|
||||
responseBody: { errcode: result.errcode, errmsg: result.errmsg },
|
||||
externalNo: transactionId || order.orderNo,
|
||||
@@ -156,7 +206,7 @@ export class WechatOrderShippingService {
|
||||
this.logger.warn(
|
||||
`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}`);
|
||||
return { ok: true };
|
||||
@@ -169,7 +219,11 @@ export class WechatOrderShippingService {
|
||||
refType: 'ORDER',
|
||||
refId: orderId,
|
||||
requestUrl,
|
||||
requestBody: { orderNo: order.orderNo },
|
||||
requestBody: {
|
||||
orderNo: order.orderNo,
|
||||
orderNumberType: input.orderNumberType,
|
||||
transactionId: transactionId || undefined,
|
||||
},
|
||||
status: 'FAILED',
|
||||
errorMessage: message.slice(0, 512),
|
||||
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(
|
||||
json({
|
||||
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;
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user