From 1a0afb6d39aabc026afc16cc3b2abb1379c5ebad Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Wed, 29 Jul 2026 16:48:44 +0800 Subject: [PATCH] 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 --- apps/admin-web/src/lib/constants.ts | 11 ++ apps/admin-web/src/pages/OrdersPage.tsx | 44 ++++- apps/mini-user/src/app.tsx | 55 +++++- .../mini-user/src/lib/wechat-order-confirm.ts | 173 ++++++++++++++++++ .../src/pages/order-detail/index.tsx | 33 +++- .../src/pages/pickup-receive/index.tsx | 36 +++- .../wechat/wechat-order-shipping.service.ts | 66 ++++++- .../wechat/wechat.api.provider.ts | 50 +++++ .../wechat/wechat.disabled.provider.ts | 4 + .../integrations/wechat/wechat.interface.ts | 17 ++ .../wechat/wechat.mock.provider.ts | 9 + .../wechat/wechat.router.provider.ts | 4 + .../src/modules/trade/trade.controller.ts | 3 +- .../src/modules/trade/trade.service.ts | 32 +++- 14 files changed, 505 insertions(+), 32 deletions(-) create mode 100644 apps/mini-user/src/lib/wechat-order-confirm.ts diff --git a/apps/admin-web/src/lib/constants.ts b/apps/admin-web/src/lib/constants.ts index 48ec1ca..41e52d6 100644 --- a/apps/admin-web/src/lib/constants.ts +++ b/apps/admin-web/src/lib/constants.ts @@ -19,6 +19,17 @@ export const ORDER_STATUS_LABELS: Record = { REFUNDED: '已退款', }; +/** 订单状态流转操作人(common_event.param3) */ +export const ORDER_STATUS_OPERATOR_LABELS: Record = { + 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 = { PENDING_PAY: 'red', diff --git a/apps/admin-web/src/pages/OrdersPage.tsx b/apps/admin-web/src/pages/OrdersPage.tsx index f077be4..853cb46 100644 --- a/apps/admin-web/src/pages/OrdersPage.tsx +++ b/apps/admin-web/src/pages/OrdersPage.tsx @@ -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 | 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>; redeemSummary?: OrderRedeemSummary | null; redeemRecords?: OrderRedeemRecord[]; @@ -765,13 +772,40 @@ export default function OrdersPage() { 状态流转 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'), + }, ]} /> diff --git a/apps/mini-user/src/app.tsx b/apps/mini-user/src/app.tsx index 03f8fd7..d649774 100644 --- a/apps/mini-user/src/app.tsx +++ b/apps/mini-user/src/app.tsx @@ -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 }; + }; + }) => { + 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; + }; + }; + } + ).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 ( <> diff --git a/apps/mini-user/src/lib/wechat-order-confirm.ts b/apps/mini-user/src/lib/wechat-order-confirm.ts new file mode 100644 index 0000000..be87030 --- /dev/null +++ b/apps/mini-user/src/lib/wechat-order-confirm.ts @@ -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; + 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 = {}; + 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; +}; + +/** + * 处理确认收货组件回跳(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; +}): 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'; +} diff --git a/apps/mini-user/src/pages/order-detail/index.tsx b/apps/mini-user/src/pages/order-detail/index.tsx index 37ea90b..5744d6e 100644 --- a/apps/mini-user/src/pages/order-detail/index.tsx +++ b/apps/mini-user/src/pages/order-detail/index.tsx @@ -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 = { @@ -82,6 +84,14 @@ export default function OrderDetailPage() { .catch((e) => toast(e instanceof Error ? e.message : '加载失败')); }, [orderId]); + useDidShow(() => { + if (!orderId) return; + // 从微信确认收货组件返回后刷新 + request(`/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(`/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(`/trade/orders/${order.id}`); + setOrder(updated); + toast('已确认收货'); + }, }); - setOrder(updated); - toast('已确认收货'); + if (mode === 'wechat') { + // 回跳后由 App.onShow / 本页 useDidShow 处理 + return; + } } catch (e) { toast(e instanceof Error ? e.message : '确认收货失败'); } finally { diff --git a/apps/mini-user/src/pages/pickup-receive/index.tsx b/apps/mini-user/src/pages/pickup-receive/index.tsx index bf8c7e9..d709e4c 100644 --- a/apps/mini-user/src/pages/pickup-receive/index.tsx +++ b/apps/mini-user/src/pages/pickup-receive/index.tsx @@ -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); + }, }); - 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 { diff --git a/server/dukang-api/src/integrations/wechat/wechat-order-shipping.service.ts b/server/dukang-api/src/integrations/wechat/wechat-order-shipping.service.ts index e68cb9e..8ff1ba6 100644 --- a/server/dukang-api/src/integrations/wechat/wechat-order-shipping.service.ts +++ b/server/dukang-api/src/integrations/wechat/wechat-order-shipping.service.ts @@ -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((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 { + 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) => { diff --git a/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts b/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts index f8354b4..c1b1c77 100644 --- a/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts +++ b/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts @@ -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 { + const callOnce = async (accessToken: string) => { + const body: Record = {}; + 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 { const accessToken = await this.getMiniAccessToken(); const apiUrl = `https://api.weixin.qq.com/cgi-bin/express/delivery/open_msg/get_delivery_list?access_token=${accessToken}`; diff --git a/server/dukang-api/src/integrations/wechat/wechat.disabled.provider.ts b/server/dukang-api/src/integrations/wechat/wechat.disabled.provider.ts index fa23e2a..fb8761b 100644 --- a/server/dukang-api/src/integrations/wechat/wechat.disabled.provider.ts +++ b/server/dukang-api/src/integrations/wechat/wechat.disabled.provider.ts @@ -63,6 +63,10 @@ export class WechatDisabledProvider implements IWechatProvider { return this.disabled(); } + getOrderShippingInfo() { + return this.disabled(); + } + getDeliveryList() { return this.disabled(); } diff --git a/server/dukang-api/src/integrations/wechat/wechat.interface.ts b/server/dukang-api/src/integrations/wechat/wechat.interface.ts index f5c98d7..1312537 100644 --- a/server/dukang-api/src/integrations/wechat/wechat.interface.ts +++ b/server/dukang-api/src/integrations/wechat/wechat.interface.ts @@ -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; + /** 查询支付单发货/确认收货状态 */ + getOrderShippingInfo(input: { + transactionId?: string; + mchId?: string; + outTradeNo?: string; + }): Promise; + /** 获取运力公司列表(快递公司 delivery_id) */ getDeliveryList(): Promise; } diff --git a/server/dukang-api/src/integrations/wechat/wechat.mock.provider.ts b/server/dukang-api/src/integrations/wechat/wechat.mock.provider.ts index 2ed3402..27cff03 100644 --- a/server/dukang-api/src/integrations/wechat/wechat.mock.provider.ts +++ b/server/dukang-api/src/integrations/wechat/wechat.mock.provider.ts @@ -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: '顺丰速运' }, diff --git a/server/dukang-api/src/integrations/wechat/wechat.router.provider.ts b/server/dukang-api/src/integrations/wechat/wechat.router.provider.ts index 498b292..81f1c2a 100644 --- a/server/dukang-api/src/integrations/wechat/wechat.router.provider.ts +++ b/server/dukang-api/src/integrations/wechat/wechat.router.provider.ts @@ -95,6 +95,10 @@ export class WechatRouterProvider implements IWechatProvider { return this.resolve().uploadShippingInfo(input); } + getOrderShippingInfo(input: Parameters[0]) { + return this.resolve().getOrderShippingInfo(input); + } + getDeliveryList() { return this.resolve().getDeliveryList(); } diff --git a/server/dukang-api/src/modules/trade/trade.controller.ts b/server/dukang-api/src/modules/trade/trade.controller.ts index df2be87..7dc004b 100644 --- a/server/dukang-api/src/modules/trade/trade.controller.ts +++ b/server/dukang-api/src/modules/trade/trade.controller.ts @@ -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', }); } diff --git a/server/dukang-api/src/modules/trade/trade.service.ts b/server/dukang-api/src/modules/trade/trade.service.ts index 501328b..7ee9543 100644 --- a/server/dukang-api/src/modules/trade/trade.service.ts +++ b/server/dukang-api/src/modules/trade/trade.service.ts @@ -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); }