From 09e732bfa3391891da1d8f80f40b929dfbafe296 Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Sun, 2 Aug 2026 11:49:35 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BE=AE=E4=BF=A1JSSDK=E5=AF=B9=E6=8E=A5?= =?UTF-8?q?=EF=BC=8C=E4=BB=A3=E4=B8=8B=E5=8D=95=E6=94=AF=E4=BB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MP_verify_ayPJ4CQqbUcec3jX.txt | 1 + .../src/components/ProxyOrderModal.tsx | 374 ++++++++++++------ apps/h5-partner/package.json | 4 +- .../public/MP_verify_ayPJ4CQqbUcec3jX.txt | 1 + apps/h5-partner/src/lib/api.ts | 4 + apps/h5-partner/src/lib/proxyOrderStatus.ts | 36 ++ .../src/pages/ProxyOrderDetailPage.tsx | 127 +++++- .../src/pages/ProxyOrderListPage.tsx | 11 +- apps/h5-partner/src/pages/ProxyOrderPage.tsx | 262 +++++++++++- .../public/MP_verify_ayPJ4CQqbUcec3jX.txt | 1 + deploy/nginx-dukang-staging.conf | 8 + deploy/nginx-dukanghaoke.conf | 8 + deploy/nginx-mp-verify-staging.conf | 8 + deploy/nginx-mp-verify.conf | 8 + deploy/public/MP_verify_ayPJ4CQqbUcec3jX.txt | 1 + packages/shared-types/src/trade.ts | 36 +- pnpm-lock.yaml | 6 + .../src/integrations/pay/pay.interface.ts | 12 +- .../src/integrations/pay/pay.mock.provider.ts | 21 +- .../integrations/pay/pay.router.provider.ts | 13 +- .../integrations/pay/pay.wechat.provider.ts | 44 ++- .../wechat/wechat.api.provider.ts | 49 +++ .../wechat/wechat.disabled.provider.ts | 4 + .../integrations/wechat/wechat.interface.ts | 8 + .../wechat/wechat.mock.provider.ts | 4 + .../wechat/wechat.router.provider.ts | 9 + .../ops/admin-proxy-orders.controller.ts | 23 +- .../src/modules/ops/dto/hq-proxy-order.dto.ts | 6 + .../trade/dto/partner-proxy-order.dto.ts | 5 + .../src/modules/trade/trade.controller.ts | 38 +- .../src/modules/trade/trade.service.ts | 304 +++++++++++--- 杜康好客-v3-PRD.md | 5 +- 32 files changed, 1223 insertions(+), 218 deletions(-) create mode 100644 MP_verify_ayPJ4CQqbUcec3jX.txt create mode 100644 apps/h5-partner/public/MP_verify_ayPJ4CQqbUcec3jX.txt create mode 100644 apps/h5-partner/src/lib/proxyOrderStatus.ts create mode 100644 apps/h5-shop/public/MP_verify_ayPJ4CQqbUcec3jX.txt create mode 100644 deploy/nginx-mp-verify-staging.conf create mode 100644 deploy/nginx-mp-verify.conf create mode 100644 deploy/public/MP_verify_ayPJ4CQqbUcec3jX.txt diff --git a/MP_verify_ayPJ4CQqbUcec3jX.txt b/MP_verify_ayPJ4CQqbUcec3jX.txt new file mode 100644 index 0000000..968bcf7 --- /dev/null +++ b/MP_verify_ayPJ4CQqbUcec3jX.txt @@ -0,0 +1 @@ +ayPJ4CQqbUcec3jX \ No newline at end of file diff --git a/apps/admin-web/src/components/ProxyOrderModal.tsx b/apps/admin-web/src/components/ProxyOrderModal.tsx index f7423e1..55ef4df 100644 --- a/apps/admin-web/src/components/ProxyOrderModal.tsx +++ b/apps/admin-web/src/components/ProxyOrderModal.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { Alert, Button, @@ -7,6 +7,7 @@ import { Input, InputNumber, Modal, + QRCode, Radio, Select, Space, @@ -18,6 +19,8 @@ import type { PartnerProxyDeliveryMode, PartnerProxyOrderOptions, PartnerProxyOrderPreviewResult, + ProxyOrderCreateResponse, + ProxyOrderPayResponse, } from '@dukang/shared-types'; import ChinaRegionCascader from './ChinaRegionCascader'; import { parseRegionCodes } from '../lib/china-region'; @@ -29,6 +32,13 @@ type ProxyOrderModalProps = { onSuccess: (order: { id: string; orderNo: string }) => void; }; +type PayStatus = { + id: string; + orderNo: string; + status: string; + payStatus: string; +}; + function fmtMoney(n: number) { return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } @@ -49,8 +59,22 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder const [previewLoading, setPreviewLoading] = useState(false); const [submitting, setSubmitting] = useState(false); + const [step, setStep] = useState<'form' | 'pay'>('form'); + const [created, setCreated] = useState(null); + const [codeUrl, setCodeUrl] = useState(null); + const [payLoading, setPayLoading] = useState(false); + const [mockConfirming, setMockConfirming] = useState(false); + const pollRef = useRef(null); + const region = useMemo(() => parseRegionCodes(regionCodes), [regionCodes]); + function stopPoll() { + if (pollRef.current != null) { + window.clearInterval(pollRef.current); + pollRef.current = null; + } + } + useEffect(() => { if (!open) return; setLoadingOptions(true); @@ -64,8 +88,8 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder }, [open]); useEffect(() => { - if (!open || !productId || quantity < 1) { - setPreview(null); + if (!open || !productId || quantity < 1 || step !== 'form') { + if (step === 'form') setPreview(null); return; } const timer = window.setTimeout(() => { @@ -85,9 +109,12 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder .finally(() => setPreviewLoading(false)); }, 300); return () => window.clearTimeout(timer); - }, [open, productId, quantity, deliveryMode, region?.city, region?.district]); + }, [open, productId, quantity, deliveryMode, region?.city, region?.district, step]); + + useEffect(() => () => stopPoll(), []); function resetForm() { + stopPoll(); setPhone(''); setReceiverName(''); setRegionCodes([]); @@ -98,6 +125,11 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder setAutoReceive(false); setPreview(null); setProductId(options?.products[0]?.id); + setStep('form'); + setCreated(null); + setCodeUrl(null); + setPayLoading(false); + setMockConfirming(false); } function handleClose() { @@ -120,6 +152,23 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder return null; } + function startPoll(orderId: string) { + stopPoll(); + pollRef.current = window.setInterval(() => { + void request(`/admin/proxy-orders/${orderId}/pay-status`) + .then((st) => { + if (st.payStatus === 'PAID') { + stopPoll(); + message.success(`支付成功:${st.orderNo}`); + const payload = { id: st.id, orderNo: st.orderNo }; + resetForm(); + onSuccess(payload); + } + }) + .catch(() => undefined); + }, 2000); + } + async function submit() { const err = validateForm(); if (err) { @@ -143,17 +192,46 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder setSubmitting(true); try { - const order = await request<{ id: string; orderNo: string }>('/admin/proxy-orders', { + const order = await request('/admin/proxy-orders', { method: 'POST', body: JSON.stringify(payload), }); - message.success(`代下单成功:${order.orderNo}`); - resetForm(); - onSuccess(order); + setCreated(order); + setStep('pay'); + setPayLoading(true); + const pay = await request(`/admin/proxy-orders/${order.id}/pay`, { + method: 'POST', + body: JSON.stringify({ payMethod: 'NATIVE' }), + }); + setCodeUrl(pay.codeUrl ?? null); + startPoll(order.id); } catch (e) { message.error(e instanceof Error ? e.message : '下单失败'); + setStep('form'); + setCreated(null); } finally { setSubmitting(false); + setPayLoading(false); + } + } + + async function handleMockConfirm() { + if (!created) return; + setMockConfirming(true); + try { + const st = await request(`/admin/proxy-orders/${created.id}/pay/mock-confirm`, { + method: 'POST', + body: '{}', + }); + stopPoll(); + message.success(`支付成功:${st.orderNo}`); + const payload = { id: st.id, orderNo: st.orderNo }; + resetForm(); + onSuccess(payload); + } catch (e) { + message.error(e instanceof Error ? e.message : '模拟支付失败'); + } finally { + setMockConfirming(false); } } @@ -166,138 +244,178 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder return ( - - - + step === 'pay' ? ( + + + + + ) : ( + + + + + ) } > - - -
- - setPhone(e.target.value.replace(/\D/g, '').slice(0, 11))} + {step === 'pay' && created ? ( +
+ - - - - ({ - value: p.id, - label: `${p.code} · ${p.name}`, - }))} - /> - - ) : null} - - - { - setDeliveryMode(e.target.value); - if (e.target.value !== 'ADDRESS') setAutoReceive(false); - }} - options={[ - { value: 'ADDRESS', label: '配送到址' }, - { value: 'ON_SITE_PICKUP', label: '现场提货' }, - ]} - /> - - - {deliveryMode === 'ADDRESS' ? ( - <> - - setReceiverName(e.target.value)} - /> - - - - - - setAddressDetail(e.target.value)} - /> - - - setAutoReceive(e.target.checked)}> - 同意自动收货(配送到址必选) - - - - ) : null} - -
- 费用预览 - {previewLoading ? ( -
计算中…
- ) : preview ? ( - -
履约:{deliveryLabel}
-
商品金额:¥{fmtMoney(preview.productAmount)}
-
实付:¥{fmtMoney(preview.payAmount)}
-
好客权益:¥{fmtMoney(preview.benefitAmount)}
+ {payLoading && !codeUrl ? ( + 正在生成收款码… + ) : codeUrl ? ( + + + 请使用微信扫一扫完成支付(本地可用下方模拟支付) + + {codeUrl} + ) : ( -
- 请完善商品与数量 -
+ 收款码生成失败,请关闭后重试 )}
- + ) : ( + <> + + +
+ + setPhone(e.target.value.replace(/\D/g, '').slice(0, 11))} + /> + + + + ({ + value: p.id, + label: `${p.code} · ${p.name}`, + }))} + /> + + ) : null} + + + { + setDeliveryMode(e.target.value); + if (e.target.value !== 'ADDRESS') setAutoReceive(false); + }} + options={[ + { value: 'ADDRESS', label: '配送到址' }, + { value: 'ON_SITE_PICKUP', label: '现场提货' }, + ]} + /> + + + {deliveryMode === 'ADDRESS' ? ( + <> + + setReceiverName(e.target.value)} + /> + + + + + + setAddressDetail(e.target.value)} + /> + + + setAutoReceive(e.target.checked)}> + 同意自动收货(配送到址必选) + + + + ) : null} + +
+ 费用预览 + {previewLoading ? ( +
计算中…
+ ) : preview ? ( + +
履约:{deliveryLabel}
+
商品金额:¥{fmtMoney(preview.productAmount)}
+
实付:¥{fmtMoney(preview.payAmount)}
+
好客权益:¥{fmtMoney(preview.benefitAmount)}
+
+ ) : ( +
+ 请完善商品与数量 +
+ )} +
+
+ + )} ); } diff --git a/apps/h5-partner/package.json b/apps/h5-partner/package.json index ca1ff80..c241d9e 100644 --- a/apps/h5-partner/package.json +++ b/apps/h5-partner/package.json @@ -9,15 +9,17 @@ "lint": "echo ok" }, "dependencies": { - "@dukang/shared-ui": "workspace:*", "@dukang/shared-types": "workspace:*", + "@dukang/shared-ui": "workspace:*", "@dukang/weixin-sdk": "workspace:*", "element-china-area-data": "^6.1.0", + "qrcode": "^1.5.4", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.26.0" }, "devDependencies": { + "@types/qrcode": "^1.5.6", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", diff --git a/apps/h5-partner/public/MP_verify_ayPJ4CQqbUcec3jX.txt b/apps/h5-partner/public/MP_verify_ayPJ4CQqbUcec3jX.txt new file mode 100644 index 0000000..968bcf7 --- /dev/null +++ b/apps/h5-partner/public/MP_verify_ayPJ4CQqbUcec3jX.txt @@ -0,0 +1 @@ +ayPJ4CQqbUcec3jX \ No newline at end of file diff --git a/apps/h5-partner/src/lib/api.ts b/apps/h5-partner/src/lib/api.ts index 59ce2d2..d1f1386 100644 --- a/apps/h5-partner/src/lib/api.ts +++ b/apps/h5-partner/src/lib/api.ts @@ -113,6 +113,10 @@ export function clearAuth(options?: { keepProfile?: boolean }) { } } +export function getToken() { + return localStorage.getItem(ACCESS_TOKEN); +} + export function isLoggedIn() { return !!localStorage.getItem(ACCESS_TOKEN); } diff --git a/apps/h5-partner/src/lib/proxyOrderStatus.ts b/apps/h5-partner/src/lib/proxyOrderStatus.ts new file mode 100644 index 0000000..a550db7 --- /dev/null +++ b/apps/h5-partner/src/lib/proxyOrderStatus.ts @@ -0,0 +1,36 @@ +const STATUS_LABELS: Record = { + PENDING_PAY: '待付款', + PENDING_SHIP: '待发货', + OUT_WAREHOUSE: '已出库', + SHIPPING: '配送中', + PENDING_RECEIVE: '待收货', + COMPLETED: '已完成', + CANCELLED: '已取消', + REFUNDING: '退款中', + REFUNDED: '已退款', +}; + +const PAY_LABELS: Record = { + UNPAID: '未支付', + PAYING: '支付中', + PAID: '已付款', + REFUNDING: '退款中', + REFUNDED: '已退款', +}; + +export function proxyOrderStatusLabel(status?: string | null) { + if (!status) return '—'; + return STATUS_LABELS[status] || status; +} + +export function proxyOrderPayLabel(payStatus?: string | null) { + if (!payStatus) return '—'; + return PAY_LABELS[payStatus] || payStatus; +} + +export function proxyOrderStatusColor(status?: string | null) { + if (status === 'PENDING_PAY') return 'var(--color-warning, #d48806)'; + if (status === 'COMPLETED') return 'var(--color-success-green)'; + if (status === 'CANCELLED' || status === 'REFUNDED') return 'var(--color-text-muted, #999)'; + return 'var(--color-primary, #8b1e1e)'; +} diff --git a/apps/h5-partner/src/pages/ProxyOrderDetailPage.tsx b/apps/h5-partner/src/pages/ProxyOrderDetailPage.tsx index 7de79c1..83ab15b 100644 --- a/apps/h5-partner/src/pages/ProxyOrderDetailPage.tsx +++ b/apps/h5-partner/src/pages/ProxyOrderDetailPage.tsx @@ -3,6 +3,12 @@ import { useNavigate, useParams } from 'react-router-dom'; import PageHeader from '@dukang/shared-ui/PageHeader'; import type { PartnerProxyOrderListItem } from '@dukang/shared-types'; import { request } from '../lib/api'; +import { + proxyOrderPayLabel, + proxyOrderStatusColor, + proxyOrderStatusLabel, +} from '../lib/proxyOrderStatus'; +import { toastError, toastSuccess } from '../lib/toast'; function fmtMoney(n: number) { return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); @@ -21,14 +27,30 @@ function deliveryLabel(order: PartnerProxyOrderListItem) { if (t === 'ON_SITE_PICKUP') return '现场提货'; if (t === 'CROSS_CITY') return '跨城配送'; if (t === 'LOCAL') return '同城配送'; - return '线下代下单'; + return '代下单'; } +type TrackNode = { + time?: string; + status?: string; + description?: string; +}; + +type TrackResult = { + nodes?: TrackNode[]; + trackingNo?: string | null; + provider?: string | null; + manualQueryUrl?: string | null; +}; + export default function ProxyOrderDetailPage() { const { id } = useParams(); const navigate = useNavigate(); const [order, setOrder] = useState(null); const [error, setError] = useState(''); + const [track, setTrack] = useState(null); + const [trackError, setTrackError] = useState(''); + const [paying, setPaying] = useState(false); useEffect(() => { document.title = '代下单详情'; @@ -41,7 +63,42 @@ export default function ProxyOrderDetailPage() { .catch((e) => setError(e instanceof Error ? e.message : '加载失败')); }, [id]); + useEffect(() => { + if (!id || !order) return; + const dt = String(order.deliveryType || '').toUpperCase(); + if (dt === 'ON_SITE_PICKUP' || order.payStatus !== 'PAID') { + setTrack(null); + return; + } + void request('PARTNER_H5', `/partner/proxy-orders/${id}/track`, { silent: true }) + .then(setTrack) + .catch((e) => setTrackError(e instanceof Error ? e.message : '物流暂不可用')); + }, [id, order]); + + async function continuePay() { + if (!id) return; + setPaying(true); + try { + await request('PARTNER_H5', `/partner/proxy-orders/${id}/pay/mock-confirm`, { + method: 'POST', + body: '{}', + silent: true, + }); + toastSuccess('支付成功'); + const refreshed = await request( + 'PARTNER_H5', + `/partner/proxy-orders/${id}`, + ); + setOrder(refreshed); + } catch (e) { + toastError(e instanceof Error ? e.message : '支付失败'); + } finally { + setPaying(false); + } + } + const img = order?.imageResource?.url || ''; + const isOnSite = String(order?.deliveryType || '').toUpperCase() === 'ON_SITE_PICKUP'; return (
@@ -60,8 +117,11 @@ export default function ProxyOrderDetailPage() {
NO. {order.orderNo} - - 已完成 + + {proxyOrderStatusLabel(order.status)}
@@ -99,7 +159,7 @@ export default function ProxyOrderDetailPage() {

姓名:{order.receiverName || '—'}

手机:{order.receiverPhone || '—'}

- 地址:{order.receiverAddress || (String(order.deliveryType).toUpperCase() === 'ON_SITE_PICKUP' ? '现场提货' : '—')} + 地址:{order.receiverAddress || (isOnSite ? '现场提货' : '—')}

@@ -107,9 +167,64 @@ export default function ProxyOrderDetailPage() {

订单信息

配送方式:{deliveryLabel(order)}

下单时间:{fmtTime(order.createdAt)}

-

支付状态:已付款(线下)

-

订单状态:已完成并发放权益

+

+ 支付状态:{proxyOrderPayLabel(order.payStatus)} +

+

+ 订单状态:{proxyOrderStatusLabel(order.status)} +

+ + {order.payStatus === 'UNPAID' || order.status === 'PENDING_PAY' ? ( + + ) : null} + + {!isOnSite && order.payStatus === 'PAID' ? ( +
+

物流信息

+ {trackError ? ( +

{trackError}

+ ) : !track ? ( +

加载物流…

+ ) : ( + <> + {track.trackingNo ? ( +

+ 运单号:{track.trackingNo} + {track.provider ? `(${track.provider})` : ''} +

+ ) : null} + {track.manualQueryUrl ? ( +

+ + 查看物流官网 + +

+ ) : null} + {(track.nodes ?? []).length === 0 ? ( +

暂无物流轨迹(待总部发货后更新)

+ ) : ( +
    + {(track.nodes ?? []).map((n, i) => ( +
  • +
    {fmtTime(n.time)}
    +
    {n.description || n.status || '—'}
    +
  • + ))} +
+ )} + + )} +
+ ) : null}
)}
diff --git a/apps/h5-partner/src/pages/ProxyOrderListPage.tsx b/apps/h5-partner/src/pages/ProxyOrderListPage.tsx index 0c7267a..501d3df 100644 --- a/apps/h5-partner/src/pages/ProxyOrderListPage.tsx +++ b/apps/h5-partner/src/pages/ProxyOrderListPage.tsx @@ -4,6 +4,10 @@ import PageHeader from '@dukang/shared-ui/PageHeader'; import PullToRefresh from '@dukang/shared-ui/PullToRefresh'; import type { PartnerProxyOrderListItem, PartnerProxyOrderListResponse } from '@dukang/shared-types'; import { isLoggedIn, request } from '../lib/api'; +import { + proxyOrderStatusColor, + proxyOrderStatusLabel, +} from '../lib/proxyOrderStatus'; function fmtMoney(n: number) { return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); @@ -98,8 +102,11 @@ export default function ProxyOrderListPage() {
NO. {o.orderNo} - - 已完成 + + {proxyOrderStatusLabel(o.status)}
diff --git a/apps/h5-partner/src/pages/ProxyOrderPage.tsx b/apps/h5-partner/src/pages/ProxyOrderPage.tsx index 7c21075..5a68563 100644 --- a/apps/h5-partner/src/pages/ProxyOrderPage.tsx +++ b/apps/h5-partner/src/pages/ProxyOrderPage.tsx @@ -1,8 +1,10 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; +import QRCode from 'qrcode'; import PageHeader from '@dukang/shared-ui/PageHeader'; +import { invokeWechatPay } from '@dukang/weixin-sdk'; import ChinaRegionPicker from '../components/ChinaRegionPicker'; -import { request } from '../lib/api'; +import { getToken, request } from '../lib/api'; import { formatRegionLabel, parseRegionCodes } from '../lib/china-region'; import { clearProxyOrderDraft, @@ -11,11 +13,16 @@ import { type ProxyOrderDraft, } from '../lib/proxyOrderDraft'; import { toastError, toastSuccess } from '../lib/toast'; +import { isWechatEnv } from '../lib/weixin'; +import { fetchPartnerProfile, partnerHasWechatBinding } from '../lib/wechat-auth'; import type { PartnerProxyDeliveryMode, PartnerProxyOrderCreateRequest, PartnerProxyOrderOptions, PartnerProxyOrderPreviewResult, + ProxyOrderCreateResponse, + ProxyOrderPayResponse, + ProxyPayMethod, } from '@dukang/shared-types'; function fmtMoney(n: number) { @@ -57,10 +64,34 @@ export default function ProxyOrderPage() { const [previewLoading, setPreviewLoading] = useState(false); const [submitting, setSubmitting] = useState(false); const [msg, setMsg] = useState(''); + const [step, setStep] = useState<'form' | 'pay'>('form'); + const [created, setCreated] = useState(null); + const [payMethod, setPayMethod] = useState('NATIVE'); + const [codeUrl, setCodeUrl] = useState(null); + const [qrDataUrl, setQrDataUrl] = useState(null); + const [paying, setPaying] = useState(false); + const pollRef = useRef(null); const region = useMemo(() => parseRegionCodes(regionCodes), [regionCodes]); const regionLabel = region ? formatRegionLabel(region) : ''; + function stopPoll() { + if (pollRef.current != null) { + window.clearInterval(pollRef.current); + pollRef.current = null; + } + } + + useEffect(() => () => stopPoll(), []); + + useEffect(() => { + if (!codeUrl) { + setQrDataUrl(null); + return; + } + void QRCode.toDataURL(codeUrl, { width: 220, margin: 1 }).then(setQrDataUrl).catch(() => setQrDataUrl(null)); + }, [codeUrl]); + function persistDraft(overrides?: Partial) { saveProxyOrderDraft({ phone, @@ -157,6 +188,82 @@ export default function ProxyOrderPage() { return null; } + function startPoll(orderId: string) { + stopPoll(); + pollRef.current = window.setInterval(() => { + void request<{ payStatus: string; orderNo: string }>( + 'PARTNER_H5', + `/partner/proxy-orders/${orderId}/pay-status`, + { silent: true }, + ) + .then((st) => { + if (st.payStatus === 'PAID') { + stopPoll(); + toastSuccess(`支付成功:${st.orderNo}`); + clearProxyOrderDraft(); + navigate(`/center/proxy-orders/${orderId}`, { replace: true }); + } + }) + .catch(() => undefined); + }, 2000); + } + + async function startPay(orderId: string, method: ProxyPayMethod) { + setPaying(true); + setMsg(''); + try { + if (method === 'JSAPI') { + if (!isWechatEnv()) { + throw new Error('请在微信内打开合伙人端以使用微信代付'); + } + const profile = await fetchPartnerProfile(); + if (!partnerHasWechatBinding(profile)) { + throw new Error('请先绑定微信后再代付'); + } + } + + const pay = await request( + 'PARTNER_H5', + `/partner/proxy-orders/${orderId}/pay`, + { + method: 'POST', + body: JSON.stringify({ payMethod: method }), + silent: true, + }, + ); + + if (pay.mode === 'mock') { + toastSuccess('支付成功'); + clearProxyOrderDraft(); + navigate(`/center/proxy-orders/${orderId}`, { replace: true }); + return; + } + + if (pay.mode === 'native' && pay.codeUrl) { + setCodeUrl(pay.codeUrl); + startPoll(orderId); + return; + } + + if (pay.mode === 'jsapi' && pay.prepay) { + await invokeWechatPay(pay.prepay, { + apiBase: '/api/v1', + clientApp: 'PARTNER_H5', + getAccessToken: getToken, + platform: 'wechat-h5', + }); + startPoll(orderId); + return; + } + + throw new Error('支付发起失败'); + } catch (e) { + setMsg(e instanceof Error ? e.message : '支付失败'); + } finally { + setPaying(false); + } + } + async function submit() { setMsg(''); const err = validateForm(); @@ -181,14 +288,16 @@ export default function ProxyOrderPage() { setSubmitting(true); try { - const order = await request<{ id: string; orderNo: string }>('PARTNER_H5', '/partner/proxy-orders', { + const order = await request('PARTNER_H5', '/partner/proxy-orders', { method: 'POST', body: JSON.stringify(payload), silent: true, }); - toastSuccess(`代下单成功:${order.orderNo}`); clearProxyOrderDraft(); - navigate(`/center/proxy-orders/${order.id}`); + setCreated(order); + setStep('pay'); + setCodeUrl(null); + await startPay(order.id, payMethod); } catch (e) { setMsg(e instanceof Error ? e.message : '下单失败'); } finally { @@ -196,6 +305,25 @@ export default function ProxyOrderPage() { } } + async function handleMockConfirm() { + if (!created) return; + setPaying(true); + try { + await request('PARTNER_H5', `/partner/proxy-orders/${created.id}/pay/mock-confirm`, { + method: 'POST', + body: '{}', + silent: true, + }); + stopPoll(); + toastSuccess('支付成功'); + navigate(`/center/proxy-orders/${created.id}`, { replace: true }); + } catch (e) { + setMsg(e instanceof Error ? e.message : '模拟支付失败'); + } finally { + setPaying(false); + } + } + const selectedProduct = options?.products.find((p) => p.id === productId); const deliveryLabel = preview?.deliveryType === 'ON_SITE_PICKUP' @@ -206,10 +334,97 @@ export default function ProxyOrderPage() { return (
- navigate(-1)} /> + navigate(-1)} />
- {loadingOptions ? ( + {step === 'pay' && created ? ( + <> +

+ 订单 {created.orderNo} · 应付 ¥{fmtMoney(created.payAmount)} +

+

+ {created.deliveryType === 'ON_SITE_PICKUP' + ? '支付成功后进入现场提货闭环' + : '支付成功后进入待发货,由总部履约'} +

+ +
+ +
+ + +
+
+ + {payMethod === 'NATIVE' ? ( +
+ {qrDataUrl ? ( + 收款码 + ) : ( +

{paying ? '生成收款码中…' : '暂无收款码'}

+ )} +

+ 请使用微信扫码支付 +

+ +
+ ) : ( +
+

将调起微信支付(本地 Mock 可能直接入账)

+ + +
+ )} + + {msg ? ( +

+ {msg} +

+ ) : null} + + ) : loadingOptions ? (

加载商品…

) : ( <> @@ -350,11 +565,36 @@ export default function ProxyOrderPage() { checked={autoReceive} onChange={(e) => setAutoReceive(e.target.checked)} /> - 同意自动收货(线下代下单提交后视为已送达并发放权益) + 同意自动收货(配送到址必选) ) : null} +
+ +
+ + +
+

+ {payMethod === 'NATIVE' + ? '提交后展示商家收款码,客户或现场扫码支付' + : '提交后在微信内由您代客户完成支付(需已绑定微信)'} +

+
+

费用明细

{previewLoading ? ( @@ -398,15 +638,15 @@ export default function ProxyOrderPage() {

- 确认线下已收款后提交:将自动创建/关联用户,订单标记为代下单并直接完成,同时发放对应权益。客户订单列表会显示您的姓名。 + 提交后进入在线支付;支付成功后发放权益。配送单将进入待发货由总部履约,现场提货走自提闭环。

)} diff --git a/apps/h5-shop/public/MP_verify_ayPJ4CQqbUcec3jX.txt b/apps/h5-shop/public/MP_verify_ayPJ4CQqbUcec3jX.txt new file mode 100644 index 0000000..968bcf7 --- /dev/null +++ b/apps/h5-shop/public/MP_verify_ayPJ4CQqbUcec3jX.txt @@ -0,0 +1 @@ +ayPJ4CQqbUcec3jX \ No newline at end of file diff --git a/deploy/nginx-dukang-staging.conf b/deploy/nginx-dukang-staging.conf index 2976490..dd2ccbb 100644 --- a/deploy/nginx-dukang-staging.conf +++ b/deploy/nginx-dukang-staging.conf @@ -13,6 +13,8 @@ server { default_type "text/plain"; } + include /opt/dukang-staging/deploy/nginx-mp-verify-staging.conf; + location / { return 301 https://$host$request_uri; } @@ -55,6 +57,8 @@ server { client_max_body_size 50m; + include /opt/dukang-staging/deploy/nginx-mp-verify-staging.conf; + location /api/ { proxy_pass http://127.0.0.1:8190; proxy_http_version 1.1; @@ -91,6 +95,8 @@ server { client_max_body_size 50m; + include /opt/dukang-staging/deploy/nginx-mp-verify-staging.conf; + location /api/ { proxy_pass http://127.0.0.1:8190; proxy_http_version 1.1; @@ -127,6 +133,8 @@ server { client_max_body_size 50m; + include /opt/dukang-staging/deploy/nginx-mp-verify-staging.conf; + location /api/ { proxy_pass http://127.0.0.1:8190; proxy_http_version 1.1; diff --git a/deploy/nginx-dukanghaoke.conf b/deploy/nginx-dukanghaoke.conf index 65497c9..ea7b433 100644 --- a/deploy/nginx-dukanghaoke.conf +++ b/deploy/nginx-dukanghaoke.conf @@ -9,6 +9,8 @@ server { default_type "text/plain"; } + include /opt/dukang/deploy/nginx-mp-verify.conf; + location / { return 301 https://$host$request_uri; } @@ -53,6 +55,8 @@ server { client_max_body_size 50m; + include /opt/dukang/deploy/nginx-mp-verify.conf; + location /api/ { proxy_pass http://127.0.0.1:8090; proxy_http_version 1.1; @@ -89,6 +93,8 @@ server { client_max_body_size 50m; + include /opt/dukang/deploy/nginx-mp-verify.conf; + location /api/ { proxy_pass http://127.0.0.1:8090; proxy_http_version 1.1; @@ -125,6 +131,8 @@ server { client_max_body_size 50m; + include /opt/dukang/deploy/nginx-mp-verify.conf; + location /api/ { proxy_pass http://127.0.0.1:8090; proxy_http_version 1.1; diff --git a/deploy/nginx-mp-verify-staging.conf b/deploy/nginx-mp-verify-staging.conf new file mode 100644 index 0000000..18baa97 --- /dev/null +++ b/deploy/nginx-mp-verify-staging.conf @@ -0,0 +1,8 @@ +# 微信 JS 安全域名校验文件(Staging) +# 文件放在 /opt/dukang-staging/public/MP_verify_*.txt +location ~ ^/MP_verify_.*\.txt$ { + root /opt/dukang-staging/public; + default_type text/plain; + charset utf-8; + access_log off; +} diff --git a/deploy/nginx-mp-verify.conf b/deploy/nginx-mp-verify.conf new file mode 100644 index 0000000..996f2a1 --- /dev/null +++ b/deploy/nginx-mp-verify.conf @@ -0,0 +1,8 @@ +# 微信 JS 安全域名校验文件(根目录直出,勿走 SPA) +# 文件放在 /opt/dukang/public/MP_verify_*.txt +location ~ ^/MP_verify_.*\.txt$ { + root /opt/dukang/public; + default_type text/plain; + charset utf-8; + access_log off; +} diff --git a/deploy/public/MP_verify_ayPJ4CQqbUcec3jX.txt b/deploy/public/MP_verify_ayPJ4CQqbUcec3jX.txt new file mode 100644 index 0000000..968bcf7 --- /dev/null +++ b/deploy/public/MP_verify_ayPJ4CQqbUcec3jX.txt @@ -0,0 +1 @@ +ayPJ4CQqbUcec3jX \ No newline at end of file diff --git a/packages/shared-types/src/trade.ts b/packages/shared-types/src/trade.ts index 1aa2a4d..3f02fa9 100644 --- a/packages/shared-types/src/trade.ts +++ b/packages/shared-types/src/trade.ts @@ -1,3 +1,5 @@ +import type { WechatJsapiPrepayParams } from './wechat'; + export interface OrderDto { id: string; orderNo: string; @@ -27,11 +29,41 @@ export interface OrderPreviewResult { deliveryType: 'LOCAL' | 'CROSS_CITY'; } +export type ProxyPayMethod = 'NATIVE' | 'JSAPI'; + export interface PayOrderResult { - mode?: 'jsapi' | 'mock'; + mode?: 'jsapi' | 'mock' | 'native'; orderId?: string; + codeUrl?: string; + prepay?: WechatJsapiPrepayParams; + payExpireAt?: string | null; } +export type ProxyOrderPayRequest = { + payMethod: ProxyPayMethod; +}; + +export type ProxyOrderPayResponse = { + mode: 'jsapi' | 'mock' | 'native'; + orderId: string; + orderNo?: string; + codeUrl?: string; + prepay?: WechatJsapiPrepayParams; + payExpireAt?: string | null; +}; + +export type ProxyOrderCreateResponse = { + id: string; + orderNo: string; + status: string; + payStatus: string; + payAmount: number; + benefitAmount: number; + deliveryType: string; + payExpireAt: string | null; + proxyPartnerName?: string | null; +}; + export interface DeliveryDto { provider?: string; shippingAt?: string; @@ -127,7 +159,7 @@ export type PartnerProxyOrderListResponse = { pageSize: number; }; -/** 总部代下单(线下完成发权益,无需短信验证) */ +/** 总部代下单(在线支付后发权益) */ export type HqProxyOrderCreateRequest = { phone: string; deliveryMode: PartnerProxyDeliveryMode; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d4b0244..fe5e9f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,6 +84,9 @@ importers: element-china-area-data: specifier: ^6.1.0 version: 6.1.0 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 react: specifier: ^18.3.1 version: 18.3.1 @@ -94,6 +97,9 @@ importers: specifier: ^6.26.0 version: 6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) devDependencies: + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 '@types/react': specifier: ^18.3.3 version: 18.3.31 diff --git a/server/dukang-api/src/integrations/pay/pay.interface.ts b/server/dukang-api/src/integrations/pay/pay.interface.ts index dd0f991..ecad49c 100644 --- a/server/dukang-api/src/integrations/pay/pay.interface.ts +++ b/server/dukang-api/src/integrations/pay/pay.interface.ts @@ -1,9 +1,17 @@ import type { WechatJsapiPrepayParams } from '@dukang/shared-types'; +export type PayMethod = 'JSAPI' | 'NATIVE'; + export type PayOrderResult = | { mode: 'mock'; externalNo: string } - | { mode: 'jsapi'; prepay: WechatJsapiPrepayParams }; + | { mode: 'jsapi'; prepay: WechatJsapiPrepayParams } + | { mode: 'native'; codeUrl: string; externalNo: string }; export interface IPayProvider { - payOrder(orderId: bigint, openId?: string, platform?: 'h5' | 'mini'): Promise; + payOrder( + orderId: bigint, + openId?: string, + platform?: 'h5' | 'mini', + payMethod?: PayMethod, + ): Promise; } diff --git a/server/dukang-api/src/integrations/pay/pay.mock.provider.ts b/server/dukang-api/src/integrations/pay/pay.mock.provider.ts index 4b00cd4..9c65272 100644 --- a/server/dukang-api/src/integrations/pay/pay.mock.provider.ts +++ b/server/dukang-api/src/integrations/pay/pay.mock.provider.ts @@ -1,13 +1,30 @@ import { Injectable } from '@nestjs/common'; import { loadAppConfig } from '@dukang/shared-types'; -import type { IPayProvider, PayOrderResult } from './pay.interface'; +import { PrismaService } from '../../common/prisma/prisma.module'; +import type { IPayProvider, PayMethod, PayOrderResult } from './pay.interface'; @Injectable() export class PayMockProvider implements IPayProvider { - async payOrder(_orderId: bigint, _openId?: string, _platform?: 'h5' | 'mini'): Promise { + constructor(private readonly prisma: PrismaService) {} + + async payOrder( + orderId: bigint, + _openId?: string, + _platform?: 'h5' | 'mini', + payMethod: PayMethod = 'JSAPI', + ): Promise { if (!loadAppConfig().mockPay) { throw new Error('Real WeChat pay requires PayWechatProvider'); } + if (payMethod === 'NATIVE') { + const order = await this.prisma.order.findUnique({ where: { id: orderId } }); + const orderNo = order?.orderNo ?? orderId.toString(); + return { + mode: 'native', + codeUrl: `mock://wechat-pay/native?orderNo=${encodeURIComponent(orderNo)}`, + externalNo: `MOCK-NATIVE-${Date.now()}`, + }; + } return { mode: 'mock', externalNo: `MOCK-${Date.now()}` }; } } diff --git a/server/dukang-api/src/integrations/pay/pay.router.provider.ts b/server/dukang-api/src/integrations/pay/pay.router.provider.ts index 9c9d035..dedd4df 100644 --- a/server/dukang-api/src/integrations/pay/pay.router.provider.ts +++ b/server/dukang-api/src/integrations/pay/pay.router.provider.ts @@ -1,10 +1,10 @@ import { Injectable } from '@nestjs/common'; import { loadAppConfig } from '@dukang/shared-types'; -import type { IPayProvider, PayOrderResult } from './pay.interface'; +import type { IPayProvider, PayMethod, PayOrderResult } from './pay.interface'; import { PayMockProvider } from './pay.mock.provider'; import { PayWechatProvider } from './pay.wechat.provider'; -/** 按当前 process.env 动态选择 Mock / 微信 JSAPI 支付 */ +/** 按当前 process.env 动态选择 Mock / 微信支付 */ @Injectable() export class PayRouterProvider implements IPayProvider { constructor( @@ -16,7 +16,12 @@ export class PayRouterProvider implements IPayProvider { return loadAppConfig().mockPay ? this.mock : this.wechat; } - payOrder(orderId: bigint, openId?: string, platform?: 'h5' | 'mini'): Promise { - return this.resolve().payOrder(orderId, openId, platform); + payOrder( + orderId: bigint, + openId?: string, + platform?: 'h5' | 'mini', + payMethod?: PayMethod, + ): Promise { + return this.resolve().payOrder(orderId, openId, platform, payMethod); } } diff --git a/server/dukang-api/src/integrations/pay/pay.wechat.provider.ts b/server/dukang-api/src/integrations/pay/pay.wechat.provider.ts index 3b44fcd..e42502e 100644 --- a/server/dukang-api/src/integrations/pay/pay.wechat.provider.ts +++ b/server/dukang-api/src/integrations/pay/pay.wechat.provider.ts @@ -3,7 +3,7 @@ import { loadAppConfig } from '@dukang/shared-types'; import { PrismaService } from '../../common/prisma/prisma.module'; import { WECHAT_PROVIDER } from '../integrations.constants'; import type { IWechatProvider } from '../wechat/wechat.interface'; -import type { IPayProvider, PayOrderResult } from './pay.interface'; +import type { IPayProvider, PayMethod, PayOrderResult } from './pay.interface'; @Injectable() export class PayWechatProvider implements IPayProvider { @@ -14,28 +14,60 @@ export class PayWechatProvider implements IPayProvider { @Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider, ) {} - async payOrder(orderId: bigint, openId?: string, platform: 'h5' | 'mini' = 'h5'): Promise { + async payOrder( + orderId: bigint, + openId?: string, + platform: 'h5' | 'mini' = 'h5', + payMethod: PayMethod = 'JSAPI', + ): Promise { if (loadAppConfig().mockPay) { + if (payMethod === 'NATIVE') { + const order = await this.prisma.order.findUnique({ where: { id: orderId } }); + const orderNo = order?.orderNo ?? orderId.toString(); + return { + mode: 'native', + codeUrl: `mock://wechat-pay/native?orderNo=${encodeURIComponent(orderNo)}`, + externalNo: `MOCK-NATIVE-${Date.now()}`, + }; + } return { mode: 'mock', externalNo: `MOCK-${Date.now()}` }; } if (!this.wechat.isPayEnabled()) { throw new Error('微信支付未配置:请关闭 MOCK_PAY 并配置 WX_MCH_ID 等商户参数'); } - if (!openId) { - throw new Error('微信支付需要用户 openId,请先完成微信授权登录'); - } const order = await this.prisma.order.findUnique({ where: { id: orderId } }); if (!order) throw new Error('订单不存在'); const amountFen = Math.round(Number(order.payAmount) * 100); + const notifyUrl = process.env.WX_PAY_NOTIFY_URL ?? ''; + + if (payMethod === 'NATIVE') { + this.logger.log(`create NATIVE prepay order=${order.orderNo} mchid=${this.wechat.getMchId()}`); + const { codeUrl } = await this.wechat.createNativePrepay({ + orderNo: order.orderNo, + description: `杜康好客订单 ${order.orderNo}`, + amountFen, + notifyUrl, + }); + return { + mode: 'native', + codeUrl, + externalNo: `NATIVE-${order.orderNo}`, + }; + } + + if (!openId) { + throw new Error('微信支付需要用户 openId,请先完成微信授权登录'); + } + this.logger.log(`create JSAPI prepay order=${order.orderNo} mchid=${this.wechat.getMchId()} platform=${platform}`); const prepay = await this.wechat.createJsapiPrepay({ orderNo: order.orderNo, description: `杜康好客订单 ${order.orderNo}`, amountFen, openId, - notifyUrl: process.env.WX_PAY_NOTIFY_URL ?? '', + notifyUrl, platform, }); return { mode: 'jsapi', prepay }; 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 c1b1c77..03715a1 100644 --- a/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts +++ b/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts @@ -553,6 +553,55 @@ export class WechatApiProvider implements IWechatProvider { }; } + async createNativePrepay(params: { + orderNo: string; + description: string; + amountFen: number; + notifyUrl: string; + }) { + if (!this.isPayEnabled()) { + throw new InternalServerErrorException( + '微信支付未配置:请关闭 MOCK_PAY 并配置 WX_MCH_ID、WX_MCH_SERIAL_NO、WX_MCH_PRIVATE_KEY、WX_API_V3_KEY', + ); + } + const notifyUrl = params.notifyUrl || this.notifyUrl; + if (!notifyUrl) { + throw new InternalServerErrorException('请配置 WX_PAY_NOTIFY_URL'); + } + const payAppId = this.appId || this.miniAppId; + if (!payAppId) { + throw new InternalServerErrorException('微信支付未配置:请设置 WX_APP_ID'); + } + const body = { + appid: payAppId, + mchid: this.mchId, + description: params.description, + out_trade_no: params.orderNo, + notify_url: notifyUrl, + amount: { total: params.amountFen, currency: 'CNY' }, + }; + const path = '/v3/pay/transactions/native'; + const payload = JSON.stringify(body); + const auth = this.signPayRequest('POST', path, payload); + const res = await this.fetchPayJson<{ code_url?: string }>( + `https://api.mch.weixin.qq.com${path}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: auth, + }, + body: payload, + }, + ); + if (!res.code_url) { + throw new InternalServerErrorException('微信 Native 下单失败'); + } + this.logger.log(`NATIVE prepay ok mchid=${this.mchId} orderNo=${params.orderNo}`); + return { codeUrl: res.code_url }; + } + async parsePayNotification( headers: Record, rawBody: string, 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 fb8761b..e1625e7 100644 --- a/server/dukang-api/src/integrations/wechat/wechat.disabled.provider.ts +++ b/server/dukang-api/src/integrations/wechat/wechat.disabled.provider.ts @@ -51,6 +51,10 @@ export class WechatDisabledProvider implements IWechatProvider { return this.disabled(); } + createNativePrepay() { + return this.disabled(); + } + parsePayNotification() { 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 1312537..27736dd 100644 --- a/server/dukang-api/src/integrations/wechat/wechat.interface.ts +++ b/server/dukang-api/src/integrations/wechat/wechat.interface.ts @@ -132,6 +132,14 @@ export interface IWechatProvider { platform?: 'h5' | 'mini'; }): Promise; + /** 创建 Native 扫码支付 code_url */ + createNativePrepay(params: { + orderNo: string; + description: string; + amountFen: number; + notifyUrl: string; + }): Promise<{ codeUrl: string }>; + /** 解析并验签支付回调通知 */ parsePayNotification( headers: Record, 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 27cff03..bf9ce80 100644 --- a/server/dukang-api/src/integrations/wechat/wechat.mock.provider.ts +++ b/server/dukang-api/src/integrations/wechat/wechat.mock.provider.ts @@ -80,6 +80,10 @@ export class WechatMockProvider implements IWechatProvider { throw new NotImplementedException('FEATURE_DISABLED'); } + createNativePrepay(): never { + throw new NotImplementedException('FEATURE_DISABLED'); + } + parsePayNotification(): never { throw new NotImplementedException('FEATURE_DISABLED'); } 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 81f1c2a..a8ece09 100644 --- a/server/dukang-api/src/integrations/wechat/wechat.router.provider.ts +++ b/server/dukang-api/src/integrations/wechat/wechat.router.provider.ts @@ -80,6 +80,15 @@ export class WechatRouterProvider implements IWechatProvider { return this.resolve().createJsapiPrepay(params); } + createNativePrepay(params: { + orderNo: string; + description: string; + amountFen: number; + notifyUrl: string; + }) { + return this.resolve().createNativePrepay(params); + } + parsePayNotification( headers: Record, rawBody: string, diff --git a/server/dukang-api/src/modules/ops/admin-proxy-orders.controller.ts b/server/dukang-api/src/modules/ops/admin-proxy-orders.controller.ts index 59ecb9e..b6d029a 100644 --- a/server/dukang-api/src/modules/ops/admin-proxy-orders.controller.ts +++ b/server/dukang-api/src/modules/ops/admin-proxy-orders.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Req, UseGuards } from '@nestjs/common'; import type { Request } from 'express'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; @@ -10,7 +10,11 @@ import type { AuthUser } from '../../common/guards/jwt-auth.guard'; import { HqOperation } from '../../common/hq-operation/hq-operation.decorator'; import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants'; import { TradeService } from '../trade/trade.service'; -import { HqProxyOrderCreateDto, HqProxyOrderPreviewDto } from './dto/hq-proxy-order.dto'; +import { + HqProxyOrderCreateDto, + HqProxyOrderPayDto, + HqProxyOrderPreviewDto, +} from './dto/hq-proxy-order.dto'; @Controller('admin/proxy-orders') @UseGuards(HqAuthGuard, HqPermissionGuard) @@ -42,4 +46,19 @@ export class AdminProxyOrdersController { ) { return this.tradeService.createHqProxyOrder(user.actorId, dto, req); } + + @Post(':id/pay') + pay(@Param('id') id: string, @Body() dto: HqProxyOrderPayDto) { + return this.tradeService.payHqProxyOrder(BigInt(id), dto.payMethod ?? 'NATIVE'); + } + + @Post(':id/pay/mock-confirm') + mockConfirmPay(@Param('id') id: string) { + return this.tradeService.mockConfirmProxyPay(BigInt(id), { hq: true }); + } + + @Get(':id/pay-status') + payStatus(@Param('id') id: string) { + return this.tradeService.getProxyPayStatus(BigInt(id)); + } } diff --git a/server/dukang-api/src/modules/ops/dto/hq-proxy-order.dto.ts b/server/dukang-api/src/modules/ops/dto/hq-proxy-order.dto.ts index 80f4417..e6441c7 100644 --- a/server/dukang-api/src/modules/ops/dto/hq-proxy-order.dto.ts +++ b/server/dukang-api/src/modules/ops/dto/hq-proxy-order.dto.ts @@ -85,3 +85,9 @@ export class HqProxyOrderCreateDto { @IsString() promoCodeId?: string; } + +export class HqProxyOrderPayDto { + @IsOptional() + @IsIn(['NATIVE', 'JSAPI']) + payMethod?: 'NATIVE' | 'JSAPI'; +} diff --git a/server/dukang-api/src/modules/trade/dto/partner-proxy-order.dto.ts b/server/dukang-api/src/modules/trade/dto/partner-proxy-order.dto.ts index 984c00e..1b120a8 100644 --- a/server/dukang-api/src/modules/trade/dto/partner-proxy-order.dto.ts +++ b/server/dukang-api/src/modules/trade/dto/partner-proxy-order.dto.ts @@ -93,3 +93,8 @@ export class PartnerProxyOrderCreateDto { @IsString() promoCodeId?: string; } + +export class PartnerProxyOrderPayDto { + @IsIn(['NATIVE', 'JSAPI']) + payMethod: 'NATIVE' | 'JSAPI'; +} diff --git a/server/dukang-api/src/modules/trade/trade.controller.ts b/server/dukang-api/src/modules/trade/trade.controller.ts index 729edd1..be09d65 100644 --- a/server/dukang-api/src/modules/trade/trade.controller.ts +++ b/server/dukang-api/src/modules/trade/trade.controller.ts @@ -8,6 +8,7 @@ import { PartnerPermissionGuard } from '../../common/guards/partner-permission.g import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { PartnerProxyOrderCreateDto, + PartnerProxyOrderPayDto, PartnerProxyOrderPreviewDto, } from './dto/partner-proxy-order.dto'; import { ManualShipOrderDto } from '../ops/dto/admin-mutate.dto'; @@ -223,11 +224,6 @@ export class PartnerProxyOrderController { ); } - @Get(':id') - detail(@CurrentUser() user: AuthUser, @Param('id') id: string) { - return this.tradeService.getPartnerProxyOrder(user.actorId, BigInt(id)); - } - @Post('preview') preview(@CurrentUser() user: AuthUser, @Body() dto: PartnerProxyOrderPreviewDto) { return this.tradeService.previewPartnerProxyOrderForPartner(user.actorId, dto); @@ -241,4 +237,36 @@ export class PartnerProxyOrderController { ) { return this.tradeService.createPartnerProxyOrder(user.actorId, dto, req); } + + @Post(':id/pay') + pay( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() dto: PartnerProxyOrderPayDto, + ) { + return this.tradeService.payPartnerProxyOrder(user.actorId, BigInt(id), dto.payMethod); + } + + @Post(':id/pay/mock-confirm') + mockConfirmPay(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.tradeService.mockConfirmProxyPay(BigInt(id), { + partnerAccountId: user.actorId, + }); + } + + @Get(':id/pay-status') + async payStatus(@CurrentUser() user: AuthUser, @Param('id') id: string) { + await this.tradeService.getPartnerProxyOrder(user.actorId, BigInt(id)); + return this.tradeService.getProxyPayStatus(BigInt(id)); + } + + @Get(':id/track') + track(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.tradeService.getPartnerProxyOrderTrack(user.actorId, BigInt(id)); + } + + @Get(':id') + detail(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.tradeService.getPartnerProxyOrder(user.actorId, BigInt(id)); + } } diff --git a/server/dukang-api/src/modules/trade/trade.service.ts b/server/dukang-api/src/modules/trade/trade.service.ts index ee2b27d..37b309b 100644 --- a/server/dukang-api/src/modules/trade/trade.service.ts +++ b/server/dukang-api/src/modules/trade/trade.service.ts @@ -1257,7 +1257,7 @@ export class TradeService { } const orderNo = generateOrderNo(); - const now = new Date(); + const payExpireAt = new Date(Date.now() + 30 * 60 * 1000); const location = buildOrderClientLocationSnapshot( req, this.ipGeoService.resolve(extractClientIp(req)), @@ -1271,10 +1271,10 @@ export class TradeService { orderType: 'PROXY', userId: user.id, cityId: city.id, - status: 'COMPLETED', - payStatus: 'PAID', + status: 'PENDING_PAY', + payStatus: 'UNPAID', deliveryType: preview.deliveryType, - channelSource: 'OFFLINE_PROXY', + channelSource: 'PROXY_ONLINE', promoCodeId, productId: product.id, barcode69: product.barcode69, @@ -1299,9 +1299,7 @@ export class TradeService { ipProvince: location.ipProvince, ipCity: location.ipCity, ipDistrict: location.ipDistrict, - paidAt: now, - shippedAt: now, - completedAt: now, + payExpireAt, partnerAccountIdAtPay: paySnapshot?.partnerAccountId ?? primary.id, orderCommissionRateAtPay: paySnapshot?.orderCommissionRate ?? null, proxyPartnerAccountId: primary.id, @@ -1312,23 +1310,13 @@ export class TradeService { include: { product: true, imageResource: true }, }); - await tx.orderDelivery.create({ - data: { - orderId: created.id, - provider: 'MANUAL', - outWarehouseAt: now, - shippingAt: now, - deliveredAt: now, - }, - }); - await tx.commonEvent.create({ data: buildOrderStatusEvent({ orderId: created.id, fromStatus: 'PENDING_PAY', - toStatus: 'COMPLETED', + toStatus: 'PENDING_PAY', operator: 'PARTNER_PROXY', - remark: `合伙人线下代下单 mode=${body.deliveryMode} customer=${normalizedPhone} partner=${primary.id}`, + remark: `合伙人代下单待支付 mode=${body.deliveryMode} customer=${normalizedPhone} partner=${primary.id}`, }), }); @@ -1342,8 +1330,6 @@ export class TradeService { return created; }); - await this.benefitService.grantOnOrderPaid(order.id); - this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', { partnerAccountId: primary.id, eventName: 'partner_proxy_order_create', @@ -1359,7 +1345,17 @@ export class TradeService { }, }); - return serializeBigInt(mapOrderCompat(order)); + return { + id: order.id.toString(), + orderNo: order.orderNo, + status: order.status, + payStatus: order.payStatus, + payAmount: Number(order.payAmount), + benefitAmount: Number(order.benefitAmount), + deliveryType: order.deliveryType, + payExpireAt: order.payExpireAt?.toISOString() ?? null, + proxyPartnerName: primary.name, + }; } /** 合伙人代下单列表:按 proxyPartnerAccountId 归属,与管仓订单无关 */ @@ -1547,7 +1543,7 @@ export class TradeService { } const orderNo = generateOrderNo(); - const now = new Date(); + const payExpireAt = new Date(Date.now() + 30 * 60 * 1000); const location = buildOrderClientLocationSnapshot( req, this.ipGeoService.resolve(extractClientIp(req)), @@ -1561,10 +1557,10 @@ export class TradeService { orderType: 'PROXY', userId: user.id, cityId: city.id, - status: 'COMPLETED', - payStatus: 'PAID', + status: 'PENDING_PAY', + payStatus: 'UNPAID', deliveryType: preview.deliveryType, - channelSource: 'OFFLINE_PROXY', + channelSource: 'PROXY_ONLINE', promoCodeId, productId: product.id, barcode69: product.barcode69, @@ -1589,9 +1585,7 @@ export class TradeService { ipProvince: location.ipProvince, ipCity: location.ipCity, ipDistrict: location.ipDistrict, - paidAt: now, - shippedAt: now, - completedAt: now, + payExpireAt, partnerAccountIdAtPay: paySnapshot?.partnerAccountId ?? null, orderCommissionRateAtPay: paySnapshot?.orderCommissionRate ?? null, proxyPartnerAccountId: null, @@ -1602,23 +1596,13 @@ export class TradeService { include: { product: true, imageResource: true }, }); - await tx.orderDelivery.create({ - data: { - orderId: created.id, - provider: 'MANUAL', - outWarehouseAt: now, - shippingAt: now, - deliveredAt: now, - }, - }); - await tx.commonEvent.create({ data: buildOrderStatusEvent({ orderId: created.id, fromStatus: 'PENDING_PAY', - toStatus: 'COMPLETED', + toStatus: 'PENDING_PAY', operator: 'HQ_PROXY', - remark: `总部线下代下单 mode=${body.deliveryMode} customer=${normalizedPhone} hq=${hq.id}`, + remark: `总部代下单待支付 mode=${body.deliveryMode} customer=${normalizedPhone} hq=${hq.id}`, }), }); @@ -1632,15 +1616,245 @@ export class TradeService { return created; }); - await this.benefitService.grantOnOrderPaid(order.id); - - return serializeBigInt({ - id: order.id, + return { + id: order.id.toString(), orderNo: order.orderNo, status: order.status, + payStatus: order.payStatus, payAmount: Number(order.payAmount), benefitAmount: Number(order.benefitAmount), + deliveryType: order.deliveryType, + payExpireAt: order.payExpireAt?.toISOString() ?? null, proxyPartnerName: proxyDisplayName, + }; + } + + /** 合伙人代下单支付:NATIVE 商家码 / JSAPI 合伙人微信代付 */ + async payPartnerProxyOrder( + partnerAccountId: bigint, + orderId: bigint, + payMethod: 'NATIVE' | 'JSAPI', + ) { + const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); + const order = await this.prisma.order.findFirst({ + where: { + id: orderId, + orderType: 'PROXY', + proxyPartnerAccountId: primary.id, + }, }); + if (!order) throw new NotFoundException('代下单不存在'); + if (order.status !== 'PENDING_PAY' || order.payStatus !== 'UNPAID') { + throw new BadRequestException('订单状态不可支付'); + } + if (order.payExpireAt && order.payExpireAt.getTime() < Date.now()) { + throw new BadRequestException('订单已超时未支付'); + } + + let openId: string | undefined; + if (payMethod === 'JSAPI') { + openId = primary.wxOpenId ?? undefined; + const appConfig = loadAppConfig(); + if (!appConfig.mockPay && !openId) { + throw new BadRequestException('请先在微信内登录并绑定微信后再代付'); + } + } + + const payResult = await this.payProvider.payOrder(orderId, openId, 'h5', payMethod); + + if (payResult.mode === 'native') { + return { + mode: 'native' as const, + orderId: order.id.toString(), + orderNo: order.orderNo, + codeUrl: payResult.codeUrl, + payExpireAt: order.payExpireAt?.toISOString() ?? null, + }; + } + + if (payResult.mode === 'jsapi') { + return { + mode: 'jsapi' as const, + orderId: order.id.toString(), + orderNo: order.orderNo, + prepay: payResult.prepay, + payExpireAt: order.payExpireAt?.toISOString() ?? null, + }; + } + + await this.markProxyOrderPaid(order.id, payResult.externalNo, 'PARTNER_PROXY_MOCK_PAY'); + return { + mode: 'mock' as const, + orderId: order.id.toString(), + orderNo: order.orderNo, + payExpireAt: order.payExpireAt?.toISOString() ?? null, + }; + } + + /** 总部代下单支付:仅 Native 收款码 */ + async payHqProxyOrder(orderId: bigint, payMethod: 'NATIVE' | 'JSAPI' = 'NATIVE') { + if (payMethod !== 'NATIVE') { + throw new BadRequestException('总部代下单仅支持收款码支付'); + } + const order = await this.prisma.order.findFirst({ + where: { id: orderId, orderType: 'PROXY' }, + }); + if (!order) throw new NotFoundException('代下单不存在'); + if (order.status !== 'PENDING_PAY' || order.payStatus !== 'UNPAID') { + throw new BadRequestException('订单状态不可支付'); + } + if (order.payExpireAt && order.payExpireAt.getTime() < Date.now()) { + throw new BadRequestException('订单已超时未支付'); + } + + const payResult = await this.payProvider.payOrder(orderId, undefined, 'h5', 'NATIVE'); + if (payResult.mode !== 'native') { + throw new BadRequestException('无法生成收款码'); + } + return { + mode: 'native' as const, + orderId: order.id.toString(), + orderNo: order.orderNo, + codeUrl: payResult.codeUrl, + payExpireAt: order.payExpireAt?.toISOString() ?? null, + }; + } + + /** Mock 环境确认代下单支付 */ + async mockConfirmProxyPay( + orderId: bigint, + opts: { partnerAccountId?: bigint; hq?: boolean }, + ) { + const appConfig = loadAppConfig(); + if (!appConfig.mockPay) { + throw new BadRequestException('仅 MOCK_PAY 环境可用'); + } + + let order; + if (opts.partnerAccountId != null) { + const primary = await this.partnerCityService.resolvePrimaryAccount(opts.partnerAccountId); + order = await this.prisma.order.findFirst({ + where: { + id: orderId, + orderType: 'PROXY', + proxyPartnerAccountId: primary.id, + }, + }); + } else { + order = await this.prisma.order.findFirst({ + where: { id: orderId, orderType: 'PROXY' }, + }); + } + if (!order) throw new NotFoundException('代下单不存在'); + if (order.payStatus === 'PAID') { + return this.getProxyPayStatus(order.id); + } + if (order.status !== 'PENDING_PAY') { + throw new BadRequestException('订单状态不可支付'); + } + + await this.markProxyOrderPaid( + order.id, + `MOCK-CONFIRM-${Date.now()}`, + opts.hq ? 'HQ_PROXY_MOCK_PAY' : 'PARTNER_PROXY_MOCK_PAY', + ); + return this.getProxyPayStatus(order.id); + } + + async getProxyPayStatus(orderId: bigint) { + const order = await this.prisma.order.findUnique({ + where: { id: orderId }, + select: { + id: true, + orderNo: true, + status: true, + payStatus: true, + payAmount: true, + deliveryType: true, + payExpireAt: true, + paidAt: true, + }, + }); + if (!order) throw new NotFoundException('订单不存在'); + return { + id: order.id.toString(), + orderNo: order.orderNo, + status: order.status, + payStatus: order.payStatus, + payAmount: Number(order.payAmount), + deliveryType: order.deliveryType, + payExpireAt: order.payExpireAt?.toISOString() ?? null, + paidAt: order.paidAt?.toISOString() ?? null, + }; + } + + async getPartnerProxyOrderTrack(partnerAccountId: bigint, orderId: bigint) { + const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); + const order = await this.prisma.order.findFirst({ + where: { + id: orderId, + orderType: 'PROXY', + proxyPartnerAccountId: primary.id, + }, + select: { id: true, deliveryType: true }, + }); + if (!order) throw new NotFoundException('代下单不存在'); + return this.fulfillmentService.getOrderTrack(orderId); + } + + private async markProxyOrderPaid(orderId: bigint, externalNo: string, operator: string) { + const order = await this.prisma.order.findUnique({ where: { id: orderId } }); + if (!order) throw new NotFoundException('订单不存在'); + if (order.payStatus === 'PAID') return; + + const now = new Date(); + const paySnapshot = await this.partnerCityService.resolveForOrder( + order.cityId, + order.receiverDistrict, + ); + const toStatus = + order.deliveryType === 'ON_SITE_PICKUP' ? 'PENDING_RECEIVE' : 'PENDING_SHIP'; + + await this.prisma.$transaction(async (tx) => { + await tx.order.update({ + where: { id: order.id }, + data: { + status: toStatus, + payStatus: 'PAID', + paidAt: now, + payExternalNo: externalNo, + partnerAccountIdAtPay: paySnapshot?.partnerAccountId ?? order.partnerAccountIdAtPay, + orderCommissionRateAtPay: + paySnapshot?.orderCommissionRate ?? order.orderCommissionRateAtPay, + }, + }); + await tx.logThirdParty.create({ + data: { + provider: 'WECHAT_PAY', + scene: 'ORDER_PAY', + refType: 'ORDER', + refId: order.id, + externalNo, + amount: order.payAmount, + status: 'SUCCESS', + }, + }); + await tx.commonEvent.create({ + data: buildOrderStatusEvent({ + orderId: order.id, + fromStatus: 'PENDING_PAY', + toStatus, + operator, + }), + }); + const delivery = await tx.orderDelivery.findUnique({ where: { orderId: order.id } }); + if (!delivery) { + await tx.orderDelivery.create({ + data: { orderId: order.id, provider: 'MANUAL' }, + }); + } + }); + + await this.afterOrderPaid(order.id); } } diff --git a/杜康好客-v3-PRD.md b/杜康好客-v3-PRD.md index d9b9b41..e665531 100644 --- a/杜康好客-v3-PRD.md +++ b/杜康好客-v3-PRD.md @@ -97,7 +97,7 @@ | SC-04 | 门店核销 | 出码/报手机号→核销→权益扣减→门店账本×60% | | SC-05 | 拓店入驻 | 合伙人录入→负责人复核→总部审核→试核销100元→营业 | | SC-06 | 售后工单 | 用户四类型→总部审→仓/合伙人协同→补发/退款 | -| SC-07 | 代下单 | 总部/合伙人手机号建用户下单(Wave 3);合伙人侧:双短信确认、线下完成发权益、订单记代下单人;总部本期对齐后续 | +| SC-07 | 代下单 | 总部/合伙人手机号建用户下单;在线支付(商家收款码 / 合伙人微信代付)后发权益;配送进入待发货由总部履约,现场提货走自提闭环;订单记代下单人 | | SC-08 | 问卷+评价 | 成交后问卷;核销后门店评价 | | SC-09 | 推广归因 | 推广码进小程序→绑定合伙人→统计成交/佣金 | @@ -403,7 +403,7 @@ | OPT | 波次 | 说明 | |-----|------|------| -| OPT-002 代下单 | W3 | 合伙人主账号:客户验码→选品/履约(配送须勾选自动收货或现场提货选门店)→合伙人确认码;线下已收款直接完成发权益;新用户 sourceType=PARTNER_PROXY;总部代下单后续对齐 | +| OPT-002 代下单 | W3 | 合伙人主账号:选品/履约(配送须勾选自动收货或现场提货)→创建待支付订单→收款码或微信代付(合伙人 openId)→支付成功发权益;配送单 PENDING_SHIP 由总部发货,合伙人可看物流;现场提货支付后自提闭环;新用户 sourceType=PARTNER_PROXY;总部代下单同为在线收款码支付 | | OPT-006 弱网 | W3 | W1~2 重试+人工补核销 | | OPT-010 未出账提现 | W2 | 含 FIN-001~003 | | OPT-005 现场提货 | W2 | — | @@ -432,6 +432,7 @@ | 版本 | 日期 | 说明 | |------|------|------| +| v3.0.2 | 2026-08-02 | OPT-002/SC-07:代下单改为在线支付(收款码/合伙人微信代付);去掉线下已收款直完成;配送单总部履约、合伙人可看物流 | | v3.0.1 | 2026-07-27 | ACC-012/OPT-002/SC-07:明确合伙人代下单双短信、线下完成、来源 PARTNER_PROXY、C 端展示代下单人 | | v3.0 | 2026-07-11 | 由产品 PRD v1.3 整理为工程 V3.0 事实源;配套 `@dukang-v3` skill 与 `v3-delivery-lead` agent |