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
+11
View File
@@ -19,6 +19,17 @@ export const ORDER_STATUS_LABELS: Record<string, string> = {
REFUNDED: '已退款', 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 */ /** 订单状态 Tag 颜色(Ant Design preset */
export const ORDER_STATUS_COLORS: Record<string, string> = { export const ORDER_STATUS_COLORS: Record<string, string> = {
PENDING_PAY: 'red', PENDING_PAY: 'red',
+39 -5
View File
@@ -24,6 +24,7 @@ import {
DELIVERY_PROVIDER_LABELS, DELIVERY_PROVIDER_LABELS,
ORDER_STATUS_COLORS, ORDER_STATUS_COLORS,
ORDER_STATUS_LABELS, ORDER_STATUS_LABELS,
ORDER_STATUS_OPERATOR_LABELS,
fmtTime, fmtTime,
} from '../lib/constants'; } from '../lib/constants';
import { FULFILLMENT_HOLD_REASON_LABELS } from '@dukang/shared-types'; import { FULFILLMENT_HOLD_REASON_LABELS } from '@dukang/shared-types';
@@ -102,7 +103,13 @@ type OrderDetail = AdminOrderRow & {
listUnitPrice?: number; listUnitPrice?: number;
items?: AdminOrderItem[]; items?: AdminOrderItem[];
payment?: Record<string, unknown> | null; 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>>; benefitCoupons?: Array<Record<string, unknown>>;
redeemSummary?: OrderRedeemSummary | null; redeemSummary?: OrderRedeemSummary | null;
redeemRecords?: OrderRedeemRecord[]; redeemRecords?: OrderRedeemRecord[];
@@ -765,13 +772,40 @@ export default function OrdersPage() {
<Typography.Title level={5} style={{ marginTop: 16 }}></Typography.Title> <Typography.Title level={5} style={{ marginTop: 16 }}></Typography.Title>
<Table <Table
size="small" size="small"
rowKey="createdAt" rowKey={(_, i) => String(i)}
pagination={false} pagination={false}
dataSource={detail.statusLogs} dataSource={detail.statusLogs}
columns={[ columns={[
{ title: '从', dataIndex: 'fromStatus', render: (v) => v || '—' }, {
{ title: '', dataIndex: 'toStatus', render: (v) => ORDER_STATUS_LABELS[v] || v }, title: '',
{ title: '时间', dataIndex: 'createdAt', render: (v) => new Date(v).toLocaleString('zh-CN') }, 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'),
},
]} ]}
/> />
</> </>
+54 -1
View File
@@ -1,13 +1,66 @@
import './lib/text-encoding-polyfill'; 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 WechatShareBootstrap from './components/WechatShareBootstrap';
import { patchTaroH5Hooks } from './lib/patch-taro-h5-hooks'; import { patchTaroH5Hooks } from './lib/patch-taro-h5-hooks';
import { handleWechatOrderConfirmShow } from './lib/wechat-order-confirm';
import './app.css'; import './app.css';
// H5:在首屏 page hooks 执行前,把 Taro.useDidShow 等绑到与 createReactApp 同一份 runtime // H5:在首屏 page hooks 执行前,把 Taro.useDidShow 等绑到与 createReactApp 同一份 runtime
patchTaroH5Hooks(); patchTaroH5Hooks();
function App({ children }: PropsWithChildren) { 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 ( return (
<> <>
<WechatShareBootstrap /> <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 { useEffect, useMemo, useState } from 'react';
import { View, Text } from '@tarojs/components'; 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 PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader'; import SubPageHeader from '../../components/SubPageHeader';
import ShareNavButton from '../../components/ShareNavButton'; import ShareNavButton from '../../components/ShareNavButton';
@@ -9,6 +9,7 @@ import ContactCsButton from '../../components/ContactCsButton';
import { request, toast } from '../../lib/api'; import { request, toast } from '../../lib/api';
import { buildPayUrl } from '../../lib/checkout-nav'; import { buildPayUrl } from '../../lib/checkout-nav';
import { maskPhone } from '../../lib/phone'; import { maskPhone } from '../../lib/phone';
import { confirmOrderReceive, type WechatConfirmPayload } from '../../lib/wechat-order-confirm';
import { import {
DEFAULT_SHARE_DESC, DEFAULT_SHARE_DESC,
DEFAULT_SHARE_TITLE, DEFAULT_SHARE_TITLE,
@@ -43,6 +44,7 @@ type OrderDetail = {
isProxyOrder?: boolean; isProxyOrder?: boolean;
proxyPartnerName?: string | null; proxyPartnerName?: string | null;
items?: OrderItem[]; items?: OrderItem[];
wechatConfirm?: WechatConfirmPayload | null;
}; };
const STATUS_LABELS: Record<string, string> = { const STATUS_LABELS: Record<string, string> = {
@@ -82,6 +84,14 @@ export default function OrderDetailPage() {
.catch((e) => toast(e instanceof Error ? e.message : '加载失败')); .catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
}, [orderId]); }, [orderId]);
useDidShow(() => {
if (!orderId) return;
// 从微信确认收货组件返回后刷新
request<OrderDetail>(`/trade/orders/${orderId}`)
.then(setOrder)
.catch(() => {});
});
const isReship = !!order?.originOrderId; const isReship = !!order?.originOrderId;
const isProxy = !!order && (order.isProxyOrder || order.orderType === 'PROXY'); const isProxy = !!order && (order.isProxyOrder || order.orderType === 'PROXY');
const canPay = !!order && order.status === 'PENDING_PAY' && !isReship; const canPay = !!order && order.status === 'PENDING_PAY' && !isReship;
@@ -127,7 +137,9 @@ export default function OrderDetailPage() {
const { confirm } = await Taro.showModal({ const { confirm } = await Taro.showModal({
title: '确认收货?', title: '确认收货?',
content: '请确认已收到商品。确认后订单将完成,好客权益可正常使用。', content: isWeapp
? '将打开微信确认收货,完成后订单即完结,无需再点服务通知。'
: '请确认已收到商品。确认后订单将完成,好客权益可正常使用。',
confirmText: '确认收货', confirmText: '确认收货',
cancelText: '再想想', cancelText: '再想想',
}); });
@@ -135,12 +147,19 @@ export default function OrderDetailPage() {
setConfirming(true); setConfirming(true);
try { try {
const updated = await request<OrderDetail>(`/trade/orders/${order.id}/confirm-receive`, { const mode = await confirmOrderReceive({
method: 'POST', orderId: order.id,
data: {}, wechatConfirm: order.wechatConfirm,
onLocalSuccess: async () => {
const updated = await request<OrderDetail>(`/trade/orders/${order.id}`);
setOrder(updated);
toast('已确认收货');
},
}); });
setOrder(updated); if (mode === 'wechat') {
toast('已确认收货'); // 回跳后由 App.onShow / 本页 useDidShow 处理
return;
}
} catch (e) { } catch (e) {
toast(e instanceof Error ? e.message : '确认收货失败'); toast(e instanceof Error ? e.message : '确认收货失败');
} finally { } finally {
@@ -5,6 +5,9 @@ import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader'; import SubPageHeader from '../../components/SubPageHeader';
import { request, toast } from '../../lib/api'; import { request, toast } from '../../lib/api';
import { getProductMainImage } from '../../lib/product-images'; import { getProductMainImage } from '../../lib/product-images';
import { confirmOrderReceive, type WechatConfirmPayload } from '../../lib/wechat-order-confirm';
const isWeapp = process.env.TARO_ENV === 'weapp';
type OrderDetail = { type OrderDetail = {
id: string; id: string;
@@ -22,6 +25,7 @@ type OrderDetail = {
}; };
imageUrl?: string | null; imageUrl?: string | null;
mainImageUrl?: string | null; mainImageUrl?: string | null;
wechatConfirm?: WechatConfirmPayload | null;
}; };
export default function PickupReceivePage() { export default function PickupReceivePage() {
@@ -45,17 +49,33 @@ export default function PickupReceivePage() {
}); });
async function confirmReceive() { 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); setSubmitting(true);
try { try {
await request(`/trade/orders/${orderId}/confirm-receive`, { const mode = await confirmOrderReceive({
method: 'POST', orderId,
data: {}, 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);
},
}); });
toast('确认收货成功', 'success'); if (mode === 'wechat') return;
setTimeout(() => {
Taro.redirectTo({ url: '/pages/orders/index?tab=done' });
}, 500);
} catch (e) { } catch (e) {
toast(e instanceof Error ? e.message : '确认失败'); toast(e instanceof Error ? e.message : '确认失败');
} finally { } 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 { loadAppConfig } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module'; import { PrismaService } from '../../common/prisma/prisma.module';
import { WECHAT_PROVIDER } from '../integrations.constants'; import { WECHAT_PROVIDER } from '../integrations.constants';
@@ -15,6 +15,8 @@ const MIN_PAID_AGE_MS = 65_000;
const RETRY_DELAY_MS = 60_000; const RETRY_DELAY_MS = 60_000;
const MAX_ATTEMPTS = 5; const MAX_ATTEMPTS = 5;
const RETRYABLE_ERRCODES = new Set([10060001, -1, 10060012, 10060019]); const RETRYABLE_ERRCODES = new Set([10060001, -1, 10060012, 10060019]);
/** 微信 order_state3 确认收货;4 交易完成 */
const WECHAT_CONFIRMED_STATES = new Set([3, 4]);
function sleep(ms: number) { function sleep(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms)); return new Promise<void>((resolve) => setTimeout(resolve, ms));
@@ -33,6 +35,68 @@ export class WechatOrderShippingService {
@Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider, @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 等待入库窗口,失败可重试,不阻断主履约流程 */ /** 异步安全上报:按 paidAt 等待入库窗口,失败可重试,不阻断主履约流程 */
uploadForOrderSafe(orderId: bigint) { uploadForOrderSafe(orderId: bigint) {
void this.scheduleAndUpload(orderId).catch((err) => { void this.scheduleAndUpload(orderId).catch((err) => {
@@ -10,6 +10,7 @@ import type {
WechatWxaCodeUnlimitedInput, WechatWxaCodeUnlimitedInput,
WechatUploadShippingInfoInput, WechatUploadShippingInfoInput,
WechatUploadShippingInfoResult, WechatUploadShippingInfoResult,
WechatOrderShippingQueryResult,
WechatDeliveryCompany, WechatDeliveryCompany,
} from './wechat.interface'; } from './wechat.interface';
import { logWechatAuth, type WechatActorRef } from './wechat-log.util'; 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[]> { async getDeliveryList(): Promise<WechatDeliveryCompany[]> {
const accessToken = await this.getMiniAccessToken(); 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 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(); return this.disabled();
} }
getOrderShippingInfo() {
return this.disabled();
}
getDeliveryList() { getDeliveryList() {
return this.disabled(); return this.disabled();
} }
@@ -69,6 +69,16 @@ export type WechatUploadShippingInfoResult = {
errmsg: string; 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 = { export type WechatDeliveryCompany = {
deliveryId: string; deliveryId: string;
deliveryName: string; deliveryName: string;
@@ -137,6 +147,13 @@ export interface IWechatProvider {
*/ */
uploadShippingInfo(input: WechatUploadShippingInfoInput): Promise<WechatUploadShippingInfoResult>; uploadShippingInfo(input: WechatUploadShippingInfoInput): Promise<WechatUploadShippingInfoResult>;
/** 查询支付单发货/确认收货状态 */
getOrderShippingInfo(input: {
transactionId?: string;
mchId?: string;
outTradeNo?: string;
}): Promise<WechatOrderShippingQueryResult>;
/** 获取运力公司列表(快递公司 delivery_id */ /** 获取运力公司列表(快递公司 delivery_id */
getDeliveryList(): Promise<WechatDeliveryCompany[]>; getDeliveryList(): Promise<WechatDeliveryCompany[]>;
} }
@@ -99,6 +99,15 @@ export class WechatMockProvider implements IWechatProvider {
return { errcode: 0, errmsg: 'ok' }; return { errcode: 0, errmsg: 'ok' };
} }
async getOrderShippingInfo() {
return {
errcode: 0,
errmsg: 'ok',
orderState: 3,
finishShipping: true,
};
}
async getDeliveryList() { async getDeliveryList() {
return [ return [
{ deliveryId: 'SF', deliveryName: '顺丰速运' }, { deliveryId: 'SF', deliveryName: '顺丰速运' },
@@ -95,6 +95,10 @@ export class WechatRouterProvider implements IWechatProvider {
return this.resolve().uploadShippingInfo(input); return this.resolve().uploadShippingInfo(input);
} }
getOrderShippingInfo(input: Parameters<IWechatProvider['getOrderShippingInfo']>[0]) {
return this.resolve().getOrderShippingInfo(input);
}
getDeliveryList() { getDeliveryList() {
return this.resolve().getDeliveryList(); return this.resolve().getDeliveryList();
} }
@@ -67,10 +67,11 @@ export class TradeController {
confirmReceive( confirmReceive(
@CurrentUser() user: AuthUser, @CurrentUser() user: AuthUser,
@Param('id') id: string, @Param('id') id: string,
@Body() body?: { onSitePickup?: boolean }, @Body() body?: { onSitePickup?: boolean; source?: 'USER' | 'WECHAT_COMPONENT' },
) { ) {
return this.tradeService.confirmReceive(user.actorId, BigInt(id), { return this.tradeService.confirmReceive(user.actorId, BigInt(id), {
onSitePickup: !!body?.onSitePickup, onSitePickup: !!body?.onSitePickup,
source: body?.source === 'WECHAT_COMPONENT' ? 'WECHAT_COMPONENT' : 'USER',
}); });
} }
@@ -493,7 +493,11 @@ export class TradeService {
where: orderStatusLogWhere(orderId), where: orderStatusLogWhere(orderId),
orderBy: { createdAt: 'desc' }, 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) { async getOrderTrack(userId: bigint, orderId: bigint) {
@@ -537,7 +541,7 @@ export class TradeService {
async confirmReceive( async confirmReceive(
userId: bigint, userId: bigint,
orderId: bigint, orderId: bigint,
_opts?: { onSitePickup?: boolean }, opts?: { onSitePickup?: boolean; source?: 'USER' | 'WECHAT_COMPONENT' },
) { ) {
const order = await this.prisma.order.findFirst({ where: { id: orderId, userId } }); const order = await this.prisma.order.findFirst({ where: { id: orderId, userId } });
if (!order) throw new NotFoundException('订单不存在'); if (!order) throw new NotFoundException('订单不存在');
@@ -546,13 +550,23 @@ export class TradeService {
throw new BadRequestException('当前状态不可确认收货'); throw new BadRequestException('当前状态不可确认收货');
} }
await this.applyStatusTransition( const viaWechat = opts?.source === 'WECHAT_COMPONENT';
order.id, if (viaWechat) {
order.status, await this.wechatOrderShipping.assertWechatUserConfirmed(orderId);
'COMPLETED', }
order.deliveryType === 'ON_SITE_PICKUP' ? 'USER_ON_SITE' : 'USER',
order.deliveryType === 'ON_SITE_PICKUP' ? '用户现场取货确认收货' : undefined, 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); return this.getOrder(userId, orderId);
} }