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 <cursoragent@cursor.com>
This commit is contained in:
2026-07-29 14:35:21 +08:00
parent ca621c9db8
commit bf9cd1bffd
9 changed files with 435 additions and 0 deletions
@@ -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,
@@ -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;
}
}
@@ -0,0 +1,74 @@
/** 常见快递公司名称 → 微信运力 IDget_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;
}
@@ -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<WechatUploadShippingInfoResult> {
const accessToken = await this.getMiniAccessToken();
const orderKey: Record<string, string | number> = {
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<string, unknown> = {
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<WechatDeliveryCompany[]> {
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<string> {
if (platform === 'h5') {
throw new InternalServerErrorException('H5 请使用短信绑定手机号');
@@ -58,4 +58,12 @@ export class WechatDisabledProvider implements IWechatProvider {
getWxaCodeUnlimited(_input: WechatWxaCodeUnlimitedInput): Promise<Buffer> {
return this.disabled();
}
uploadShippingInfo() {
return this.disabled();
}
getDeliveryList() {
return this.disabled();
}
}
@@ -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<Buffer>;
/**
* 小程序发货信息录入(交易资金解冻前置)
* @see https://developers.weixin.qq.com/miniprogram/dev/server/API/order_shipping/api_uploadshippinginfo.html
*/
uploadShippingInfo(input: WechatUploadShippingInfoInput): Promise<WechatUploadShippingInfoResult>;
/** 获取运力公司列表(快递公司 delivery_id */
getDeliveryList(): Promise<WechatDeliveryCompany[]>;
}
@@ -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: '韵达速递' },
];
}
}
@@ -90,4 +90,12 @@ export class WechatRouterProvider implements IWechatProvider {
getWxaCodeUnlimited(input: Parameters<IWechatProvider['getWxaCodeUnlimited']>[0]) {
return this.resolve().getWxaCodeUnlimited(input);
}
uploadShippingInfo(input: Parameters<IWechatProvider['uploadShippingInfo']>[0]) {
return this.resolve().uploadShippingInfo(input);
}
getDeliveryList() {
return this.resolve().getDeliveryList();
}
}
@@ -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) {