From a0a6c14758c8e4b8336fc33f03e25ac8bd005c9c Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Wed, 29 Jul 2026 15:37:42 +0800 Subject: [PATCH] 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 --- server/dukang-api/.env.example | 4 + server/dukang-api/.env.production.example | 3 + .../src/callbacks/callbacks.module.ts | 8 +- .../callbacks/wechat-message.controller.ts | 118 +++++++++++++ .../system-config/system-config.registry.ts | 19 ++ .../src/integrations/integrations.module.ts | 3 + .../wechat/wechat-msg-crypto.util.ts | 158 +++++++++++++++++ .../wechat/wechat-order-shipping.service.ts | 64 ++++++- .../wechat/wechat-trade-manage.service.ts | 166 ++++++++++++++++++ server/dukang-api/src/main.ts | 5 +- 10 files changed, 541 insertions(+), 7 deletions(-) create mode 100644 server/dukang-api/src/callbacks/wechat-message.controller.ts create mode 100644 server/dukang-api/src/integrations/wechat/wechat-msg-crypto.util.ts create mode 100644 server/dukang-api/src/integrations/wechat/wechat-trade-manage.service.ts diff --git a/server/dukang-api/.env.example b/server/dukang-api/.env.example index 2b8d809..99e7da3 100644 --- a/server/dukang-api/.env.example +++ b/server/dukang-api/.env.example @@ -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 diff --git a/server/dukang-api/.env.production.example b/server/dukang-api/.env.production.example index 90fd272..c70678f 100644 --- a/server/dukang-api/.env.production.example +++ b/server/dukang-api/.env.production.example @@ -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 diff --git a/server/dukang-api/src/callbacks/callbacks.module.ts b/server/dukang-api/src/callbacks/callbacks.module.ts index 7d3a179..536b7e0 100644 --- a/server/dukang-api/src/callbacks/callbacks.module.ts +++ b/server/dukang-api/src/callbacks/callbacks.module.ts @@ -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 {} diff --git a/server/dukang-api/src/callbacks/wechat-message.controller.ts b/server/dukang-api/src/callbacks/wechat-message.controller.ts new file mode 100644 index 0000000..0b7dec0 --- /dev/null +++ b/server/dukang-api/src/callbacks/wechat-message.controller.ts @@ -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'); + } + } +} diff --git a/server/dukang-api/src/common/system-config/system-config.registry.ts b/server/dukang-api/src/common/system-config/system-config.registry.ts index e791109..6310e7c 100644 --- a/server/dukang-api/src/common/system-config/system-config.registry.ts +++ b/server/dukang-api/src/common/system-config/system-config.registry.ts @@ -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', diff --git a/server/dukang-api/src/integrations/integrations.module.ts b/server/dukang-api/src/integrations/integrations.module.ts index 23c4b32..6f3501c 100644 --- a/server/dukang-api/src/integrations/integrations.module.ts +++ b/server/dukang-api/src/integrations/integrations.module.ts @@ -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, diff --git a/server/dukang-api/src/integrations/wechat/wechat-msg-crypto.util.ts b/server/dukang-api/src/integrations/wechat/wechat-msg-crypto.util.ts new file mode 100644 index 0000000..dce3ea6 --- /dev/null +++ b/server/dukang-api/src/integrations/wechat/wechat-msg-crypto.util.ts @@ -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 { + const out: Record = {}; + const re = /<([A-Za-z0-9_]+)>(?:|([^<]*))<\/\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 { + const trimmed = raw.trim(); + if (!trimmed) return {}; + if (trimmed.startsWith('{')) { + return JSON.parse(trimmed) as Record; + } + 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; +}; + +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): 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, + }; +} diff --git a/server/dukang-api/src/integrations/wechat/wechat-order-shipping.service.ts b/server/dukang-api/src/integrations/wechat/wechat-order-shipping.service.ts index f490d8e..e68cb9e 100644 --- a/server/dukang-api/src/integrations/wechat/wechat-order-shipping.service.ts +++ b/server/dukang-api/src/integrations/wechat/wechat-order-shipping.service.ts @@ -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((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, diff --git a/server/dukang-api/src/integrations/wechat/wechat-trade-manage.service.ts b/server/dukang-api/src/integrations/wechat/wechat-trade-manage.service.ts new file mode 100644 index 0000000..2986be5 --- /dev/null +++ b/server/dukang-api/src/integrations/wechat/wechat-trade-manage.service.ts @@ -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, + }, + }); + } +} diff --git a/server/dukang-api/src/main.ts b/server/dukang-api/src/main.ts index 0e9a2e7..551a09d 100644 --- a/server/dukang-api/src/main.ts +++ b/server/dukang-api/src/main.ts @@ -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; } },