From bf9cd1bffd944bf0076b41c5491466b129fc93b5 Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Wed, 29 Jul 2026 14:35:21 +0800 Subject: [PATCH] feat(trade): upload WeChat mini program shipping info Hook SHIPPING and on-site pickup to upload_shipping_info for settlement unlock. Co-authored-by: Cursor --- .../src/integrations/integrations.module.ts | 3 + .../wechat/wechat-order-shipping.service.ts | 192 ++++++++++++++++++ .../wechat/wechat-order-shipping.util.ts | 74 +++++++ .../wechat/wechat.api.provider.ts | 83 ++++++++ .../wechat/wechat.disabled.provider.ts | 8 + .../integrations/wechat/wechat.interface.ts | 44 ++++ .../wechat/wechat.mock.provider.ts | 14 ++ .../wechat/wechat.router.provider.ts | 8 + .../src/modules/trade/trade.service.ts | 9 + 9 files changed, 435 insertions(+) create mode 100644 server/dukang-api/src/integrations/wechat/wechat-order-shipping.service.ts create mode 100644 server/dukang-api/src/integrations/wechat/wechat-order-shipping.util.ts diff --git a/server/dukang-api/src/integrations/integrations.module.ts b/server/dukang-api/src/integrations/integrations.module.ts index 8e239df..23c4b32 100644 --- a/server/dukang-api/src/integrations/integrations.module.ts +++ b/server/dukang-api/src/integrations/integrations.module.ts @@ -12,6 +12,7 @@ import { WechatApiProvider } from './wechat/wechat.api.provider'; 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 { OssAliyunProvider } from './oss/oss.aliyun.provider'; import { TencentLbsProvider } from './map/tencent-lbs.provider'; import { @@ -38,6 +39,7 @@ import { DELIVERY_QUEUE } from '../jobs/jobs.constants'; WechatMockProvider, WechatRouterProvider, { provide: WECHAT_PROVIDER, useExisting: WechatRouterProvider }, + WechatOrderShippingService, PayMockProvider, PayWechatProvider, PayRouterProvider, @@ -55,6 +57,7 @@ import { DELIVERY_QUEUE } from '../jobs/jobs.constants'; PAY_PROVIDER, DELIVERY_PROVIDER, WECHAT_PROVIDER, + WechatOrderShippingService, OSS_PROVIDER, MAP_PROVIDER, TencentLbsProvider, 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 new file mode 100644 index 0000000..f490d8e --- /dev/null +++ b/server/dukang-api/src/integrations/wechat/wechat-order-shipping.service.ts @@ -0,0 +1,192 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { loadAppConfig } from '@dukang/shared-types'; +import { PrismaService } from '../../common/prisma/prisma.module'; +import { WECHAT_PROVIDER } from '../integrations.constants'; +import type { IWechatProvider } from './wechat.interface'; +import { + formatWechatUploadTime, + maskReceiverPhone, + resolveExpressCompanyId, +} from './wechat-order-shipping.util'; + +/** + * 小程序发货信息管理:订单发货/自提后向微信录入发货信息,解冻交易资金。 + * @see https://developers.weixin.qq.com/miniprogram/dev/platform-capabilities/business-capabilities/order-shipping/order-shipping.html + */ +@Injectable() +export class WechatOrderShippingService { + private readonly logger = new Logger(WechatOrderShippingService.name); + + constructor( + private readonly prisma: PrismaService, + @Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider, + ) {} + + /** 异步安全上报,失败只记日志,不阻断主履约流程 */ + uploadForOrderSafe(orderId: bigint) { + void this.uploadForOrder(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> { + const cfg = loadAppConfig(); + if (cfg.mockPay) { + return { skipped: 'MOCK_PAY' }; + } + if (!this.wechat.isPayEnabled() && this.wechat.isMock()) { + return { skipped: 'MOCK_WECHAT' }; + } + + const order = await this.prisma.order.findUnique({ + where: { id: orderId }, + include: { + delivery: true, + user: { select: { wxOpenId: true } }, + }, + }); + if (!order) return { skipped: 'ORDER_NOT_FOUND' }; + if (order.payStatus !== 'PAID') return { skipped: 'NOT_PAID' }; + + const openId = order.user?.wxOpenId?.trim(); + if (!openId) return { skipped: 'NO_OPENID' }; + + const transactionId = order.payExternalNo?.trim(); + const mchId = this.wechat.getMchId()?.trim(); + if (!transactionId && !(mchId && order.orderNo)) { + return { skipped: 'NO_PAY_REF' }; + } + + // 已成功上报则跳过(幂等) + const existed = await this.prisma.logThirdParty.findFirst({ + where: { + provider: 'WECHAT_PAY', + scene: 'UPLOAD_SHIPPING', + refType: 'ORDER', + refId: orderId, + status: 'SUCCESS', + }, + }); + if (existed) return { skipped: 'ALREADY_UPLOADED' }; + + const logisticsType = this.resolveLogisticsType(order.deliveryType, order.delivery); + const trackingNo = order.delivery?.trackingNo?.trim() || undefined; + let expressCompany = resolveExpressCompanyId(order.delivery?.logisticsCompany); + + if (logisticsType === 1 && trackingNo && !expressCompany) { + try { + const list = await this.wechat.getDeliveryList(); + expressCompany = resolveExpressCompanyId(order.delivery?.logisticsCompany, list); + } catch (err) { + this.logger.warn( + `get_delivery_list failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + // 快递模式缺运力 ID 时降级为同城,避免接口硬失败 + let finalLogisticsType = logisticsType; + if (finalLogisticsType === 1 && (!trackingNo || !expressCompany)) { + finalLogisticsType = 2; + } + + const itemDesc = `${order.productName}${order.productSpec ? `(${order.productSpec})` : ''}*${order.quantity}` + .replace(/\s+/g, ' ') + .slice(0, 120); + + const shippingItem: { + trackingNo?: string; + expressCompany?: string; + itemDesc: string; + contact?: { receiverContact: string }; + } = { itemDesc }; + + if (finalLogisticsType === 1) { + shippingItem.trackingNo = trackingNo; + shippingItem.expressCompany = expressCompany; + if (expressCompany === 'SF') { + shippingItem.contact = { + receiverContact: maskReceiverPhone(order.receiverPhone), + }; + } + } else if (trackingNo) { + // 同城也可附带运单信息 + shippingItem.trackingNo = trackingNo; + if (expressCompany) shippingItem.expressCompany = expressCompany; + } + + const input = { + orderNumberType: (transactionId ? 2 : 1) as 1 | 2, + transactionId: transactionId || undefined, + mchId: transactionId ? undefined : mchId, + outTradeNo: transactionId ? undefined : order.orderNo, + logisticsType: finalLogisticsType, + deliveryMode: 1 as const, + shippingList: [shippingItem], + uploadTime: formatWechatUploadTime(), + payerOpenId: openId, + }; + + const requestUrl = 'https://api.weixin.qq.com/wxa/sec/order/upload_shipping_info'; + try { + const result = await this.wechat.uploadShippingInfo(input); + const ok = result.errcode === 0; + await this.prisma.logThirdParty.create({ + data: { + provider: 'WECHAT_PAY', + scene: 'UPLOAD_SHIPPING', + refType: 'ORDER', + refId: orderId, + requestUrl, + requestBody: { + orderNo: order.orderNo, + logisticsType: finalLogisticsType, + trackingNo: shippingItem.trackingNo, + expressCompany: shippingItem.expressCompany, + orderNumberType: input.orderNumberType, + }, + responseBody: { errcode: result.errcode, errmsg: result.errmsg }, + externalNo: transactionId || order.orderNo, + status: ok ? 'SUCCESS' : 'FAILED', + errorMessage: ok ? undefined : `${result.errcode}:${result.errmsg}`.slice(0, 512), + }, + }); + if (!ok) { + this.logger.warn( + `WeChat upload_shipping_info order=${order.orderNo} ${result.errcode} ${result.errmsg}`, + ); + return { ok: false }; + } + this.logger.log(`WeChat upload_shipping_info ok order=${order.orderNo}`); + return { ok: true }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + await this.prisma.logThirdParty.create({ + data: { + provider: 'WECHAT_PAY', + scene: 'UPLOAD_SHIPPING', + refType: 'ORDER', + refId: orderId, + requestUrl, + requestBody: { orderNo: order.orderNo }, + status: 'FAILED', + errorMessage: message.slice(0, 512), + externalNo: transactionId || order.orderNo, + }, + }); + throw err; + } + } + + private resolveLogisticsType( + deliveryType: string, + delivery: { trackingNo: string | null; logisticsCompany: string | null; provider: string } | null, + ): 1 | 2 | 4 { + if (deliveryType === 'ON_SITE_PICKUP') return 4; + if (deliveryType === 'LOCAL') return 2; + // CROSS_CITY:有运单走快递,否则同城兜底 + if (delivery?.trackingNo?.trim()) return 1; + return 2; + } +} diff --git a/server/dukang-api/src/integrations/wechat/wechat-order-shipping.util.ts b/server/dukang-api/src/integrations/wechat/wechat-order-shipping.util.ts new file mode 100644 index 0000000..9b115e9 --- /dev/null +++ b/server/dukang-api/src/integrations/wechat/wechat-order-shipping.util.ts @@ -0,0 +1,74 @@ +/** 常见快递公司名称 → 微信运力 ID(get_delivery_list 的 delivery_id) */ +const STATIC_EXPRESS_MAP: Array<{ id: string; aliases: string[] }> = [ + { id: 'SF', aliases: ['sf', '顺丰', '顺丰速运', '顺丰快递'] }, + { id: 'STO', aliases: ['sto', '申通', '申通快递'] }, + { id: 'YTO', aliases: ['yto', '圆通', '圆通速递', '圆通快递'] }, + { id: 'ZTO', aliases: ['zto', '中通', '中通快递'] }, + { id: 'YD', aliases: ['yd', '韵达', '韵达速递', '韵达快递'] }, + { id: 'HTKY', aliases: ['htky', '百世', '百世快递', '汇通'] }, + { id: 'EMS', aliases: ['ems', '邮政', '中国邮政', '邮政快递'] }, + { id: 'JD', aliases: ['jd', '京东', '京东快递', '京东物流'] }, + { id: 'JTSD', aliases: ['jtsd', 'jt', '极兔', '极兔速递', '极兔快递'] }, + { id: 'UC', aliases: ['uc', '优速', '优速快递'] }, + { id: 'DBL', aliases: ['dbl', '德邦', '德邦快递', '德邦物流'] }, + { id: 'ANE', aliases: ['ane', '安能', '安能物流'] }, +]; + +export function maskReceiverPhone(phone: string): string { + const digits = phone.replace(/\D/g, ''); + if (digits.length >= 7) { + return `${digits.slice(0, 3)}****${digits.slice(-4)}`; + } + return phone.trim(); +} + +/** RFC3339 +08:00,微信发货录入 upload_time 要求 */ +export function formatWechatUploadTime(date = new Date()): string { + const fmt = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + const parts = Object.fromEntries(fmt.formatToParts(date).map((p) => [p.type, p.value])); + const ms = String(date.getMilliseconds()).padStart(3, '0'); + return `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}.${ms}+08:00`; +} + +export function resolveExpressCompanyId( + logisticsCompany: string | null | undefined, + deliveryList?: Array<{ deliveryId: string; deliveryName: string }>, +): string | undefined { + const raw = (logisticsCompany || '').trim(); + if (!raw) return undefined; + + const upper = raw.toUpperCase(); + // 已是运力 ID + if (/^[A-Z0-9_()-]{2,32}$/.test(upper) && !/[\u4e00-\u9fff]/.test(raw)) { + return upper; + } + + const lower = raw.toLowerCase(); + for (const row of STATIC_EXPRESS_MAP) { + if (row.aliases.some((a) => lower.includes(a.toLowerCase()) || a === raw)) { + return row.id; + } + } + + if (deliveryList?.length) { + const hit = deliveryList.find( + (d) => + d.deliveryId.toUpperCase() === upper || + d.deliveryName === raw || + d.deliveryName.includes(raw) || + raw.includes(d.deliveryName), + ); + if (hit) return hit.deliveryId; + } + + return undefined; +} diff --git a/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts b/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts index 959ac77..5a66aab 100644 --- a/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts +++ b/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts @@ -8,6 +8,9 @@ import type { WechatCodeSession, WechatOAuthSession, WechatWxaCodeUnlimitedInput, + WechatUploadShippingInfoInput, + WechatUploadShippingInfoResult, + WechatDeliveryCompany, } from './wechat.interface'; import { logWechatAuth, type WechatActorRef } from './wechat-log.util'; import { @@ -303,6 +306,86 @@ export class WechatApiProvider implements IWechatProvider { return buf; } + async uploadShippingInfo(input: WechatUploadShippingInfoInput): Promise { + const accessToken = await this.getMiniAccessToken(); + const orderKey: Record = { + order_number_type: input.orderNumberType, + }; + if (input.orderNumberType === 2) { + if (!input.transactionId) { + throw new BadRequestException('微信支付单号不能为空'); + } + orderKey.transaction_id = input.transactionId; + } else { + if (!input.mchId || !input.outTradeNo) { + throw new BadRequestException('商户号与商户单号不能为空'); + } + orderKey.mchid = input.mchId; + orderKey.out_trade_no = input.outTradeNo; + } + + const body = { + order_key: orderKey, + logistics_type: input.logisticsType, + delivery_mode: input.deliveryMode ?? 1, + shipping_list: input.shippingList.map((row) => { + const item: Record = { + item_desc: row.itemDesc.slice(0, 120), + }; + if (row.trackingNo) item.tracking_no = row.trackingNo; + if (row.expressCompany) item.express_company = row.expressCompany; + if (row.contact?.consignorContact || row.contact?.receiverContact) { + item.contact = { + ...(row.contact.consignorContact + ? { consignor_contact: row.contact.consignorContact } + : {}), + ...(row.contact.receiverContact + ? { receiver_contact: row.contact.receiverContact } + : {}), + }; + } + return item; + }), + upload_time: input.uploadTime, + payer: { openid: input.payerOpenId }, + }; + + const apiUrl = `https://api.weixin.qq.com/wxa/sec/order/upload_shipping_info?access_token=${accessToken}`; + const data = await this.fetchJson<{ errcode?: number; errmsg?: string }>(apiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return { + errcode: data.errcode ?? -1, + errmsg: data.errmsg ?? 'unknown', + }; + } + + async getDeliveryList(): Promise { + const accessToken = await this.getMiniAccessToken(); + const apiUrl = `https://api.weixin.qq.com/cgi-bin/express/delivery/open_msg/get_delivery_list?access_token=${accessToken}`; + const data = await this.fetchJson<{ + errcode?: number; + errmsg?: string; + delivery_list?: Array<{ delivery_id?: string; delivery_name?: string }>; + }>(apiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{}', + }); + if (data.errcode && data.errcode !== 0) { + this.logger.warn(`get_delivery_list failed: ${data.errcode} ${data.errmsg}`); + return []; + } + return (data.delivery_list ?? []) + .filter((row) => row.delivery_id && row.delivery_name) + .map((row) => ({ + deliveryId: row.delivery_id!, + deliveryName: row.delivery_name!, + })); + } + async getPhoneNumberByCode(code: string, platform: 'mini' | 'h5', actorRef?: WechatActorRef): Promise { if (platform === 'h5') { throw new InternalServerErrorException('H5 请使用短信绑定手机号'); diff --git a/server/dukang-api/src/integrations/wechat/wechat.disabled.provider.ts b/server/dukang-api/src/integrations/wechat/wechat.disabled.provider.ts index 385ece9..fa23e2a 100644 --- a/server/dukang-api/src/integrations/wechat/wechat.disabled.provider.ts +++ b/server/dukang-api/src/integrations/wechat/wechat.disabled.provider.ts @@ -58,4 +58,12 @@ export class WechatDisabledProvider implements IWechatProvider { getWxaCodeUnlimited(_input: WechatWxaCodeUnlimitedInput): Promise { return this.disabled(); } + + uploadShippingInfo() { + return this.disabled(); + } + + getDeliveryList() { + return this.disabled(); + } } diff --git a/server/dukang-api/src/integrations/wechat/wechat.interface.ts b/server/dukang-api/src/integrations/wechat/wechat.interface.ts index c44ee97..f5c98d7 100644 --- a/server/dukang-api/src/integrations/wechat/wechat.interface.ts +++ b/server/dukang-api/src/integrations/wechat/wechat.interface.ts @@ -39,6 +39,41 @@ export type WechatWxaCodeUnlimitedInput = { isHyaline?: boolean; }; +/** 小程序发货信息管理 — 发货信息录入 */ +export type WechatUploadShippingInfoInput = { + /** 1=商户单号;2=微信支付单号 */ + orderNumberType: 1 | 2; + transactionId?: string; + mchId?: string; + outTradeNo?: string; + /** 1 快递 2 同城 3 虚拟 4 自提 */ + logisticsType: 1 | 2 | 3 | 4; + /** 1 统一发货 2 分拆发货 */ + deliveryMode?: 1 | 2; + shippingList: Array<{ + trackingNo?: string; + /** 微信运力 ID,如 SF / STO */ + expressCompany?: string; + itemDesc: string; + contact?: { + consignorContact?: string; + receiverContact?: string; + }; + }>; + uploadTime: string; + payerOpenId: string; +}; + +export type WechatUploadShippingInfoResult = { + errcode: number; + errmsg: string; +}; + +export type WechatDeliveryCompany = { + deliveryId: string; + deliveryName: string; +}; + export interface IWechatProvider { isEnabled(): boolean; @@ -95,4 +130,13 @@ export interface IWechatProvider { /** 获取不限制的小程序码(PNG Buffer),须服务端调用 */ getWxaCodeUnlimited(input: WechatWxaCodeUnlimitedInput): Promise; + + /** + * 小程序发货信息录入(交易资金解冻前置) + * @see https://developers.weixin.qq.com/miniprogram/dev/server/API/order_shipping/api_uploadshippinginfo.html + */ + uploadShippingInfo(input: WechatUploadShippingInfoInput): Promise; + + /** 获取运力公司列表(快递公司 delivery_id) */ + getDeliveryList(): Promise; } diff --git a/server/dukang-api/src/integrations/wechat/wechat.mock.provider.ts b/server/dukang-api/src/integrations/wechat/wechat.mock.provider.ts index 98fcb7a..2ed3402 100644 --- a/server/dukang-api/src/integrations/wechat/wechat.mock.provider.ts +++ b/server/dukang-api/src/integrations/wechat/wechat.mock.provider.ts @@ -94,4 +94,18 @@ export class WechatMockProvider implements IWechatProvider { color: { dark: '#1f1a17', light: '#ffffff' }, }); } + + async uploadShippingInfo() { + return { errcode: 0, errmsg: 'ok' }; + } + + async getDeliveryList() { + return [ + { deliveryId: 'SF', deliveryName: '顺丰速运' }, + { deliveryId: 'STO', deliveryName: '申通快递' }, + { deliveryId: 'YTO', deliveryName: '圆通速递' }, + { deliveryId: 'ZTO', deliveryName: '中通快递' }, + { deliveryId: 'YD', deliveryName: '韵达速递' }, + ]; + } } diff --git a/server/dukang-api/src/integrations/wechat/wechat.router.provider.ts b/server/dukang-api/src/integrations/wechat/wechat.router.provider.ts index 109363b..498b292 100644 --- a/server/dukang-api/src/integrations/wechat/wechat.router.provider.ts +++ b/server/dukang-api/src/integrations/wechat/wechat.router.provider.ts @@ -90,4 +90,12 @@ export class WechatRouterProvider implements IWechatProvider { getWxaCodeUnlimited(input: Parameters[0]) { return this.resolve().getWxaCodeUnlimited(input); } + + uploadShippingInfo(input: Parameters[0]) { + return this.resolve().uploadShippingInfo(input); + } + + getDeliveryList() { + return this.resolve().getDeliveryList(); + } } diff --git a/server/dukang-api/src/modules/trade/trade.service.ts b/server/dukang-api/src/modules/trade/trade.service.ts index 2a869fb..495101d 100644 --- a/server/dukang-api/src/modules/trade/trade.service.ts +++ b/server/dukang-api/src/modules/trade/trade.service.ts @@ -31,6 +31,7 @@ import { extractClientIp } from '../../common/geo/client-ip.util'; import { buildOrderStatusEvent, orderStatusLogWhere } from '../../common/event/event.helpers'; import { mapOrderCompat, mapStatusLogCompat } from '../../common/compat/v31-compat'; import { FulfillmentService } from '../fulfillment/fulfillment.service'; +import { WechatOrderShippingService } from '../../integrations/wechat/wechat-order-shipping.service'; import type { Request } from 'express'; @Injectable() @@ -49,6 +50,7 @@ export class TradeService { private readonly promoCodeService: PromoCodeService, @Inject(forwardRef(() => FulfillmentService)) private readonly fulfillmentService: FulfillmentService, + private readonly wechatOrderShipping: WechatOrderShippingService, ) {} async preview( @@ -349,6 +351,8 @@ export class TradeService { } if (order.deliveryType === 'ON_SITE_PICKUP') { + // 现场取货:支付后即向微信录入「用户自提」发货信息 + this.wechatOrderShipping.uploadForOrderSafe(orderId); return; } @@ -1021,6 +1025,11 @@ export class TradeService { }), }); }); + + // 发货信息管理:进入 SHIPPING 时向微信录入(解冻结算前置) + if (targetStatus === 'SHIPPING' && currentStatus !== 'SHIPPING') { + this.wechatOrderShipping.uploadForOrderSafe(orderId); + } } async getPartnerProxyOrderOptions(partnerAccountId: bigint) {