feat(trade): WeChat confirm-receive component + admin status log columns

Open weappOrderConfirm in mini-user so users confirm in-app instead of
service notice; verify via get_order before completing. Show operator/remark
on admin order status timeline.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-29 16:48:44 +08:00
parent 05dffbfa15
commit 1a0afb6d39
14 changed files with 505 additions and 32 deletions
@@ -1,4 +1,4 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { Inject, Injectable, Logger, BadRequestException } from '@nestjs/common';
import { loadAppConfig } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { WECHAT_PROVIDER } from '../integrations.constants';
@@ -15,6 +15,8 @@ const MIN_PAID_AGE_MS = 65_000;
const RETRY_DELAY_MS = 60_000;
const MAX_ATTEMPTS = 5;
const RETRYABLE_ERRCODES = new Set([10060001, -1, 10060012, 10060019]);
/** 微信 order_state3 确认收货;4 交易完成 */
const WECHAT_CONFIRMED_STATES = new Set([3, 4]);
function sleep(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms));
@@ -33,6 +35,68 @@ export class WechatOrderShippingService {
@Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider,
) {}
/** 供 C 端拉起微信确认收货组件 */
buildConfirmPayload(order: {
orderNo: string;
payStatus: string;
payExternalNo: string | null;
}): {
merchantId?: string;
merchantTradeNo: string;
transactionId?: string;
} | null {
if (order.payStatus !== 'PAID') return null;
const mchId = this.wechat.getMchId()?.trim() || undefined;
const transactionId = order.payExternalNo?.trim() || undefined;
if (!transactionId && !mchId) return null;
return {
merchantId: mchId,
merchantTradeNo: order.orderNo,
transactionId,
};
}
/**
* 校验用户已通过微信确认收货组件完成确认(或订单已结算)。
* Mock 支付环境跳过。
*/
async assertWechatUserConfirmed(orderId: bigint): Promise<void> {
const cfg = loadAppConfig();
if (cfg.mockPay) return;
if (!this.wechat.isPayEnabled() && this.wechat.isMock()) return;
const order = await this.prisma.order.findUnique({
where: { id: orderId },
select: { orderNo: true, payExternalNo: true },
});
if (!order) throw new BadRequestException('订单不存在');
const transactionId = order.payExternalNo?.trim();
const mchId = this.wechat.getMchId()?.trim();
if (!transactionId && !(mchId && order.orderNo)) {
throw new BadRequestException('缺少微信支付单号,无法校验微信确认收货');
}
const result = await this.wechat.getOrderShippingInfo({
transactionId: transactionId || undefined,
mchId: transactionId ? undefined : mchId,
outTradeNo: transactionId ? undefined : order.orderNo,
});
if (result.errcode && result.errcode !== 0) {
this.logger.warn(
`get_order for confirm failed order=${order.orderNo} ${result.errcode} ${result.errmsg}`,
);
throw new BadRequestException(
result.errmsg || '查询微信订单状态失败,请稍后重试',
);
}
if (result.orderState == null || !WECHAT_CONFIRMED_STATES.has(result.orderState)) {
throw new BadRequestException(
'请先在微信确认收货组件中完成确认(勿仅点服务通知外的按钮)',
);
}
}
/** 异步安全上报:按 paidAt 等待入库窗口,失败可重试,不阻断主履约流程 */
uploadForOrderSafe(orderId: bigint) {
void this.scheduleAndUpload(orderId).catch((err) => {
@@ -10,6 +10,7 @@ import type {
WechatWxaCodeUnlimitedInput,
WechatUploadShippingInfoInput,
WechatUploadShippingInfoResult,
WechatOrderShippingQueryResult,
WechatDeliveryCompany,
} from './wechat.interface';
import { logWechatAuth, type WechatActorRef } from './wechat-log.util';
@@ -375,6 +376,55 @@ export class WechatApiProvider implements IWechatProvider {
};
}
async getOrderShippingInfo(input: {
transactionId?: string;
mchId?: string;
outTradeNo?: string;
}): Promise<WechatOrderShippingQueryResult> {
const callOnce = async (accessToken: string) => {
const body: Record<string, string> = {};
if (input.transactionId) {
body.transaction_id = input.transactionId;
} else {
if (!input.mchId || !input.outTradeNo) {
throw new BadRequestException('请提供 transaction_id 或 mchid+out_trade_no');
}
body.merchant_id = input.mchId;
body.merchant_trade_no = input.outTradeNo;
}
const apiUrl = `https://api.weixin.qq.com/wxa/sec/order/get_order?access_token=${accessToken}`;
return this.fetchJson<{
errcode?: number;
errmsg?: string;
order?: {
transaction_id?: string;
merchant_trade_no?: string;
order_state?: number;
shipping?: { finish_shipping?: boolean };
};
}>(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
};
let accessToken = await this.getMiniAccessToken();
let data = await callOnce(accessToken);
if (TOKEN_INVALID_ERRCODES.has(data.errcode ?? -1)) {
accessToken = await this.getMiniAccessToken(true);
data = await callOnce(accessToken);
}
return {
errcode: data.errcode ?? 0,
errmsg: data.errmsg ?? 'ok',
orderState: data.order?.order_state,
transactionId: data.order?.transaction_id,
merchantTradeNo: data.order?.merchant_trade_no,
finishShipping: data.order?.shipping?.finish_shipping,
};
}
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}`;
@@ -63,6 +63,10 @@ export class WechatDisabledProvider implements IWechatProvider {
return this.disabled();
}
getOrderShippingInfo() {
return this.disabled();
}
getDeliveryList() {
return this.disabled();
}
@@ -69,6 +69,16 @@ export type WechatUploadShippingInfoResult = {
errmsg: string;
};
export type WechatOrderShippingQueryResult = {
errcode: number;
errmsg: string;
/** 1待发货 2已发货 3确认收货 4交易完成 5已退款 */
orderState?: number;
transactionId?: string;
merchantTradeNo?: string;
finishShipping?: boolean;
};
export type WechatDeliveryCompany = {
deliveryId: string;
deliveryName: string;
@@ -137,6 +147,13 @@ export interface IWechatProvider {
*/
uploadShippingInfo(input: WechatUploadShippingInfoInput): Promise<WechatUploadShippingInfoResult>;
/** 查询支付单发货/确认收货状态 */
getOrderShippingInfo(input: {
transactionId?: string;
mchId?: string;
outTradeNo?: string;
}): Promise<WechatOrderShippingQueryResult>;
/** 获取运力公司列表(快递公司 delivery_id */
getDeliveryList(): Promise<WechatDeliveryCompany[]>;
}
@@ -99,6 +99,15 @@ export class WechatMockProvider implements IWechatProvider {
return { errcode: 0, errmsg: 'ok' };
}
async getOrderShippingInfo() {
return {
errcode: 0,
errmsg: 'ok',
orderState: 3,
finishShipping: true,
};
}
async getDeliveryList() {
return [
{ deliveryId: 'SF', deliveryName: '顺丰速运' },
@@ -95,6 +95,10 @@ export class WechatRouterProvider implements IWechatProvider {
return this.resolve().uploadShippingInfo(input);
}
getOrderShippingInfo(input: Parameters<IWechatProvider['getOrderShippingInfo']>[0]) {
return this.resolve().getOrderShippingInfo(input);
}
getDeliveryList() {
return this.resolve().getDeliveryList();
}
@@ -67,10 +67,11 @@ export class TradeController {
confirmReceive(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() body?: { onSitePickup?: boolean },
@Body() body?: { onSitePickup?: boolean; source?: 'USER' | 'WECHAT_COMPONENT' },
) {
return this.tradeService.confirmReceive(user.actorId, BigInt(id), {
onSitePickup: !!body?.onSitePickup,
source: body?.source === 'WECHAT_COMPONENT' ? 'WECHAT_COMPONENT' : 'USER',
});
}
@@ -493,7 +493,11 @@ export class TradeService {
where: orderStatusLogWhere(orderId),
orderBy: { createdAt: 'desc' },
});
return serializeBigInt(mapOrderCompat({ ...order, statusLogs: mapStatusLogCompat(statusLogs) }));
const mapped = mapOrderCompat({ ...order, statusLogs: mapStatusLogCompat(statusLogs) });
return serializeBigInt({
...mapped,
wechatConfirm: this.wechatOrderShipping.buildConfirmPayload(order),
});
}
async getOrderTrack(userId: bigint, orderId: bigint) {
@@ -537,7 +541,7 @@ export class TradeService {
async confirmReceive(
userId: bigint,
orderId: bigint,
_opts?: { onSitePickup?: boolean },
opts?: { onSitePickup?: boolean; source?: 'USER' | 'WECHAT_COMPONENT' },
) {
const order = await this.prisma.order.findFirst({ where: { id: orderId, userId } });
if (!order) throw new NotFoundException('订单不存在');
@@ -546,13 +550,23 @@ export class TradeService {
throw new BadRequestException('当前状态不可确认收货');
}
await this.applyStatusTransition(
order.id,
order.status,
'COMPLETED',
order.deliveryType === 'ON_SITE_PICKUP' ? 'USER_ON_SITE' : 'USER',
order.deliveryType === 'ON_SITE_PICKUP' ? '用户现场取货确认收货' : undefined,
);
const viaWechat = opts?.source === 'WECHAT_COMPONENT';
if (viaWechat) {
await this.wechatOrderShipping.assertWechatUserConfirmed(orderId);
}
const operator = viaWechat
? 'USER_WECHAT_CONFIRM'
: order.deliveryType === 'ON_SITE_PICKUP' || opts?.onSitePickup
? 'USER_ON_SITE'
: 'USER';
const remark = viaWechat
? '用户经微信确认收货组件确认'
: order.deliveryType === 'ON_SITE_PICKUP' || opts?.onSitePickup
? '用户现场取货确认收货'
: undefined;
await this.applyStatusTransition(order.id, order.status, 'COMPLETED', operator, remark);
return this.getOrder(userId, orderId);
}