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:
@@ -19,6 +19,17 @@ export const ORDER_STATUS_LABELS: Record<string, string> = {
|
||||
REFUNDED: '已退款',
|
||||
};
|
||||
|
||||
/** 订单状态流转操作人(common_event.param3) */
|
||||
export const ORDER_STATUS_OPERATOR_LABELS: Record<string, string> = {
|
||||
USER: '用户',
|
||||
USER_ON_SITE: '用户·现场取货',
|
||||
USER_WECHAT_CONFIRM: '用户·微信确认收货',
|
||||
WECHAT_TRADE_MANAGE: '微信结算同步',
|
||||
MOCK: 'Mock',
|
||||
MOCK_PAY: 'Mock 支付',
|
||||
SYSTEM: '系统',
|
||||
};
|
||||
|
||||
/** 订单状态 Tag 颜色(Ant Design preset) */
|
||||
export const ORDER_STATUS_COLORS: Record<string, string> = {
|
||||
PENDING_PAY: 'red',
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
DELIVERY_PROVIDER_LABELS,
|
||||
ORDER_STATUS_COLORS,
|
||||
ORDER_STATUS_LABELS,
|
||||
ORDER_STATUS_OPERATOR_LABELS,
|
||||
fmtTime,
|
||||
} from '../lib/constants';
|
||||
import { FULFILLMENT_HOLD_REASON_LABELS } from '@dukang/shared-types';
|
||||
@@ -102,7 +103,13 @@ type OrderDetail = AdminOrderRow & {
|
||||
listUnitPrice?: number;
|
||||
items?: AdminOrderItem[];
|
||||
payment?: Record<string, unknown> | null;
|
||||
statusLogs?: Array<{ fromStatus: string | null; toStatus: string; createdAt: string }>;
|
||||
statusLogs?: Array<{
|
||||
fromStatus: string | null;
|
||||
toStatus: string;
|
||||
operator?: string | null;
|
||||
remark?: string | null;
|
||||
createdAt: string;
|
||||
}>;
|
||||
benefitCoupons?: Array<Record<string, unknown>>;
|
||||
redeemSummary?: OrderRedeemSummary | null;
|
||||
redeemRecords?: OrderRedeemRecord[];
|
||||
@@ -765,13 +772,40 @@ export default function OrdersPage() {
|
||||
<Typography.Title level={5} style={{ marginTop: 16 }}>状态流转</Typography.Title>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="createdAt"
|
||||
rowKey={(_, i) => String(i)}
|
||||
pagination={false}
|
||||
dataSource={detail.statusLogs}
|
||||
columns={[
|
||||
{ title: '从', dataIndex: 'fromStatus', render: (v) => v || '—' },
|
||||
{ title: '到', dataIndex: 'toStatus', render: (v) => ORDER_STATUS_LABELS[v] || v },
|
||||
{ title: '时间', dataIndex: 'createdAt', render: (v) => new Date(v).toLocaleString('zh-CN') },
|
||||
{
|
||||
title: '从',
|
||||
dataIndex: 'fromStatus',
|
||||
width: 100,
|
||||
render: (v) => (v ? ORDER_STATUS_LABELS[v] || v : '—'),
|
||||
},
|
||||
{
|
||||
title: '到',
|
||||
dataIndex: 'toStatus',
|
||||
width: 100,
|
||||
render: (v) => ORDER_STATUS_LABELS[v] || v,
|
||||
},
|
||||
{
|
||||
title: '操作人',
|
||||
dataIndex: 'operator',
|
||||
width: 120,
|
||||
render: (v) => ORDER_STATUS_OPERATOR_LABELS[v] || v || '—',
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'remark',
|
||||
ellipsis: true,
|
||||
render: (v) => v || '—',
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 170,
|
||||
render: (v) => new Date(v).toLocaleString('zh-CN'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -1,13 +1,66 @@
|
||||
import './lib/text-encoding-polyfill';
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { PropsWithChildren, useRef } from 'react';
|
||||
import Taro, { useDidShow } from '@tarojs/taro';
|
||||
import WechatShareBootstrap from './components/WechatShareBootstrap';
|
||||
import { patchTaroH5Hooks } from './lib/patch-taro-h5-hooks';
|
||||
import { handleWechatOrderConfirmShow } from './lib/wechat-order-confirm';
|
||||
import './app.css';
|
||||
|
||||
// H5:在首屏 page hooks 执行前,把 Taro.useDidShow 等绑到与 createReactApp 同一份 runtime
|
||||
patchTaroH5Hooks();
|
||||
|
||||
function App({ children }: PropsWithChildren) {
|
||||
const handlingRef = useRef(false);
|
||||
|
||||
useDidShow((options?: {
|
||||
referrerInfo?: {
|
||||
appId?: string;
|
||||
extraData?: { status?: string; errormsg?: string; req_extradata?: Record<string, string> };
|
||||
};
|
||||
}) => {
|
||||
if (process.env.TARO_ENV !== 'weapp') return;
|
||||
if (handlingRef.current) return;
|
||||
|
||||
const referrerInfo =
|
||||
options?.referrerInfo ||
|
||||
(typeof Taro.getEnterOptionsSync === 'function'
|
||||
? (
|
||||
Taro.getEnterOptionsSync() as {
|
||||
referrerInfo?: {
|
||||
appId?: string;
|
||||
extraData?: {
|
||||
status?: string;
|
||||
errormsg?: string;
|
||||
req_extradata?: Record<string, string>;
|
||||
};
|
||||
};
|
||||
}
|
||||
).referrerInfo
|
||||
: undefined);
|
||||
if (!referrerInfo?.appId) return;
|
||||
|
||||
handlingRef.current = true;
|
||||
void handleWechatOrderConfirmShow({ referrerInfo })
|
||||
.then((result) => {
|
||||
if (result.redirectUrl) {
|
||||
Taro.redirectTo({ url: result.redirectUrl }).catch(() => {
|
||||
Taro.reLaunch({ url: result.redirectUrl! });
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!result.handled || !result.orderId) return;
|
||||
const pages = Taro.getCurrentPages();
|
||||
const cur = pages[pages.length - 1] as { route?: string } | undefined;
|
||||
const route = cur?.route || '';
|
||||
if (route.includes('pickup-receive')) {
|
||||
Taro.redirectTo({ url: '/pages/orders/index?tab=done' }).catch(() => {});
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
handlingRef.current = false;
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<WechatShareBootstrap />
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
import { request, toast } from './api';
|
||||
|
||||
/** 微信确认收货组件来源 AppId(官方固定) */
|
||||
export const WECHAT_ORDER_CONFIRM_APPID = 'wx1183b055aeec94d1';
|
||||
|
||||
const PENDING_KEY = 'pending_wechat_order_confirm';
|
||||
|
||||
export type WechatConfirmPayload = {
|
||||
merchantId?: string | null;
|
||||
merchantTradeNo?: string | null;
|
||||
transactionId?: string | null;
|
||||
};
|
||||
|
||||
type PendingConfirm = {
|
||||
orderId: string;
|
||||
redirectUrl?: string;
|
||||
};
|
||||
|
||||
type OpenBusinessViewFn = (opts: {
|
||||
businessType: string;
|
||||
extraData: Record<string, string>;
|
||||
success?: () => void;
|
||||
fail?: (err: { errMsg?: string }) => void;
|
||||
}) => void;
|
||||
|
||||
function getOpenBusinessView(): OpenBusinessViewFn | null {
|
||||
if (process.env.TARO_ENV !== 'weapp') return null;
|
||||
const taroAny = Taro as unknown as { openBusinessView?: OpenBusinessViewFn };
|
||||
if (typeof taroAny.openBusinessView === 'function') return taroAny.openBusinessView.bind(Taro);
|
||||
const wxAny = (globalThis as { wx?: { openBusinessView?: OpenBusinessViewFn } }).wx;
|
||||
if (wxAny && typeof wxAny.openBusinessView === 'function') {
|
||||
return wxAny.openBusinessView.bind(wxAny);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function savePendingWechatOrderConfirm(pending: PendingConfirm) {
|
||||
Taro.setStorageSync(PENDING_KEY, JSON.stringify(pending));
|
||||
}
|
||||
|
||||
export function takePendingWechatOrderConfirm(): PendingConfirm | null {
|
||||
try {
|
||||
const raw = Taro.getStorageSync(PENDING_KEY);
|
||||
if (!raw) return null;
|
||||
Taro.removeStorageSync(PENDING_KEY);
|
||||
return typeof raw === 'string' ? (JSON.parse(raw) as PendingConfirm) : (raw as PendingConfirm);
|
||||
} catch {
|
||||
Taro.removeStorageSync(PENDING_KEY);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉起微信「确认收货」半屏组件,资金侧确认与自家订单同步。
|
||||
* @see https://developers.weixin.qq.com/miniprogram/dev/platform-capabilities/business-capabilities/order-shipping/order-shipping-half.html
|
||||
*/
|
||||
export function openWechatOrderConfirm(opts: {
|
||||
orderId: string;
|
||||
payload: WechatConfirmPayload;
|
||||
redirectUrl?: string;
|
||||
}): Promise<'opened' | 'unsupported' | 'missing_pay_ref'> {
|
||||
const open = getOpenBusinessView();
|
||||
if (!open) return Promise.resolve('unsupported');
|
||||
|
||||
const transactionId = opts.payload.transactionId?.trim();
|
||||
const merchantId = opts.payload.merchantId?.trim();
|
||||
const merchantTradeNo = opts.payload.merchantTradeNo?.trim();
|
||||
if (!transactionId && !(merchantId && merchantTradeNo)) {
|
||||
return Promise.resolve('missing_pay_ref');
|
||||
}
|
||||
|
||||
const extraData: Record<string, string> = {};
|
||||
if (transactionId) extraData.transaction_id = transactionId;
|
||||
if (merchantId) extraData.merchant_id = merchantId;
|
||||
if (merchantTradeNo) extraData.merchant_trade_no = merchantTradeNo;
|
||||
|
||||
savePendingWechatOrderConfirm({
|
||||
orderId: opts.orderId,
|
||||
redirectUrl: opts.redirectUrl,
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
open({
|
||||
businessType: 'weappOrderConfirm',
|
||||
extraData,
|
||||
success: () => resolve('opened'),
|
||||
fail: (err) => {
|
||||
Taro.removeStorageSync(PENDING_KEY);
|
||||
toast(err?.errMsg || '无法打开微信确认收货,请升级微信后重试');
|
||||
resolve('unsupported');
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
type ReferrerExtra = {
|
||||
status?: string;
|
||||
errormsg?: string;
|
||||
req_extradata?: Record<string, string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理确认收货组件回跳(App/页面 onShow)。
|
||||
* 成功则调用后端同步订单,并返回是否已处理。
|
||||
*/
|
||||
export async function handleWechatOrderConfirmShow(options?: {
|
||||
referrerInfo?: { appId?: string; extraData?: ReferrerExtra };
|
||||
}): Promise<{ handled: boolean; orderId?: string; redirectUrl?: string }> {
|
||||
const info = options?.referrerInfo;
|
||||
if (!info?.appId || info.appId !== WECHAT_ORDER_CONFIRM_APPID) {
|
||||
return { handled: false };
|
||||
}
|
||||
|
||||
const pending = takePendingWechatOrderConfirm();
|
||||
const status = info.extraData?.status;
|
||||
if (status === 'cancel') {
|
||||
toast('已取消确认收货');
|
||||
return { handled: true, orderId: pending?.orderId };
|
||||
}
|
||||
if (status === 'fail') {
|
||||
toast(info.extraData?.errormsg || '微信确认收货失败');
|
||||
return { handled: true, orderId: pending?.orderId };
|
||||
}
|
||||
if (status !== 'success' || !pending?.orderId) {
|
||||
return { handled: true };
|
||||
}
|
||||
|
||||
try {
|
||||
await request(`/trade/orders/${pending.orderId}/confirm-receive`, {
|
||||
method: 'POST',
|
||||
data: { source: 'WECHAT_COMPONENT' },
|
||||
});
|
||||
toast('确认收货成功', 'success');
|
||||
return {
|
||||
handled: true,
|
||||
orderId: pending.orderId,
|
||||
redirectUrl: pending.redirectUrl,
|
||||
};
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : '同步订单失败');
|
||||
return { handled: true, orderId: pending.orderId };
|
||||
}
|
||||
}
|
||||
|
||||
/** 统一入口:小程序走微信组件;H5/无能力时降级为本地确认 */
|
||||
export async function confirmOrderReceive(opts: {
|
||||
orderId: string;
|
||||
wechatConfirm?: WechatConfirmPayload | null;
|
||||
onSitePickup?: boolean;
|
||||
redirectUrl?: string;
|
||||
/** 降级本地确认成功后的回调(不经过微信回跳) */
|
||||
onLocalSuccess?: () => void | Promise<void>;
|
||||
}): Promise<'wechat' | 'local'> {
|
||||
const mode = await openWechatOrderConfirm({
|
||||
orderId: opts.orderId,
|
||||
payload: opts.wechatConfirm || {},
|
||||
redirectUrl: opts.redirectUrl,
|
||||
});
|
||||
|
||||
if (mode === 'opened') return 'wechat';
|
||||
|
||||
// Mock / H5 / 缺支付单号:本地确认(不通知微信资金侧)
|
||||
await request(`/trade/orders/${opts.orderId}/confirm-receive`, {
|
||||
method: 'POST',
|
||||
data: {
|
||||
onSitePickup: !!opts.onSitePickup,
|
||||
source: 'USER',
|
||||
},
|
||||
});
|
||||
await opts.onLocalSuccess?.();
|
||||
return 'local';
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useRouter, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
|
||||
import Taro, { useDidShow, useRouter, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
import ShareNavButton from '../../components/ShareNavButton';
|
||||
@@ -9,6 +9,7 @@ import ContactCsButton from '../../components/ContactCsButton';
|
||||
import { request, toast } from '../../lib/api';
|
||||
import { buildPayUrl } from '../../lib/checkout-nav';
|
||||
import { maskPhone } from '../../lib/phone';
|
||||
import { confirmOrderReceive, type WechatConfirmPayload } from '../../lib/wechat-order-confirm';
|
||||
import {
|
||||
DEFAULT_SHARE_DESC,
|
||||
DEFAULT_SHARE_TITLE,
|
||||
@@ -43,6 +44,7 @@ type OrderDetail = {
|
||||
isProxyOrder?: boolean;
|
||||
proxyPartnerName?: string | null;
|
||||
items?: OrderItem[];
|
||||
wechatConfirm?: WechatConfirmPayload | null;
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
@@ -82,6 +84,14 @@ export default function OrderDetailPage() {
|
||||
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
|
||||
}, [orderId]);
|
||||
|
||||
useDidShow(() => {
|
||||
if (!orderId) return;
|
||||
// 从微信确认收货组件返回后刷新
|
||||
request<OrderDetail>(`/trade/orders/${orderId}`)
|
||||
.then(setOrder)
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
const isReship = !!order?.originOrderId;
|
||||
const isProxy = !!order && (order.isProxyOrder || order.orderType === 'PROXY');
|
||||
const canPay = !!order && order.status === 'PENDING_PAY' && !isReship;
|
||||
@@ -127,7 +137,9 @@ export default function OrderDetailPage() {
|
||||
|
||||
const { confirm } = await Taro.showModal({
|
||||
title: '确认收货?',
|
||||
content: '请确认已收到商品。确认后订单将完成,好客权益可正常使用。',
|
||||
content: isWeapp
|
||||
? '将打开微信确认收货,完成后订单即完结,无需再点服务通知。'
|
||||
: '请确认已收到商品。确认后订单将完成,好客权益可正常使用。',
|
||||
confirmText: '确认收货',
|
||||
cancelText: '再想想',
|
||||
});
|
||||
@@ -135,12 +147,19 @@ export default function OrderDetailPage() {
|
||||
|
||||
setConfirming(true);
|
||||
try {
|
||||
const updated = await request<OrderDetail>(`/trade/orders/${order.id}/confirm-receive`, {
|
||||
method: 'POST',
|
||||
data: {},
|
||||
});
|
||||
const mode = await confirmOrderReceive({
|
||||
orderId: order.id,
|
||||
wechatConfirm: order.wechatConfirm,
|
||||
onLocalSuccess: async () => {
|
||||
const updated = await request<OrderDetail>(`/trade/orders/${order.id}`);
|
||||
setOrder(updated);
|
||||
toast('已确认收货');
|
||||
},
|
||||
});
|
||||
if (mode === 'wechat') {
|
||||
// 回跳后由 App.onShow / 本页 useDidShow 处理
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : '确认收货失败');
|
||||
} finally {
|
||||
|
||||
@@ -5,6 +5,9 @@ import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
import { request, toast } from '../../lib/api';
|
||||
import { getProductMainImage } from '../../lib/product-images';
|
||||
import { confirmOrderReceive, type WechatConfirmPayload } from '../../lib/wechat-order-confirm';
|
||||
|
||||
const isWeapp = process.env.TARO_ENV === 'weapp';
|
||||
|
||||
type OrderDetail = {
|
||||
id: string;
|
||||
@@ -22,6 +25,7 @@ type OrderDetail = {
|
||||
};
|
||||
imageUrl?: string | null;
|
||||
mainImageUrl?: string | null;
|
||||
wechatConfirm?: WechatConfirmPayload | null;
|
||||
};
|
||||
|
||||
export default function PickupReceivePage() {
|
||||
@@ -45,17 +49,33 @@ export default function PickupReceivePage() {
|
||||
});
|
||||
|
||||
async function confirmReceive() {
|
||||
if (!orderId || submitting) return;
|
||||
if (!orderId || !order || submitting) return;
|
||||
|
||||
if (isWeapp) {
|
||||
const { confirm } = await Taro.showModal({
|
||||
title: '确认收货?',
|
||||
content: '将打开微信确认收货,完成后订单即完结,无需再点服务通知。',
|
||||
confirmText: '确认收货',
|
||||
cancelText: '再想想',
|
||||
});
|
||||
if (!confirm) return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await request(`/trade/orders/${orderId}/confirm-receive`, {
|
||||
method: 'POST',
|
||||
data: {},
|
||||
});
|
||||
const mode = await confirmOrderReceive({
|
||||
orderId,
|
||||
wechatConfirm: order.wechatConfirm,
|
||||
onSitePickup: true,
|
||||
redirectUrl: '/pages/orders/index?tab=done',
|
||||
onLocalSuccess: async () => {
|
||||
toast('确认收货成功', 'success');
|
||||
setTimeout(() => {
|
||||
Taro.redirectTo({ url: '/pages/orders/index?tab=done' });
|
||||
}, 500);
|
||||
},
|
||||
});
|
||||
if (mode === 'wechat') return;
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : '确认失败');
|
||||
} finally {
|
||||
|
||||
@@ -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