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:
@@ -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_state:3 确认收货;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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user