Merge pull request '合并到main 创建8.1最后一波修改,后面测试和正式就会分离' (#11) from dev into main
CI / verify (push) Has been cancelled

Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/11
This commit was merged in pull request #11.
This commit is contained in:
2026-07-31 11:27:55 +08:00
77 changed files with 4232 additions and 797 deletions
@@ -0,0 +1,303 @@
import { useEffect, useMemo, useState } from 'react';
import {
Alert,
Button,
Checkbox,
Form,
Input,
InputNumber,
Modal,
Radio,
Select,
Space,
Typography,
message,
} from 'antd';
import type {
HqProxyOrderCreateRequest,
PartnerProxyDeliveryMode,
PartnerProxyOrderOptions,
PartnerProxyOrderPreviewResult,
} from '@dukang/shared-types';
import ChinaRegionCascader from './ChinaRegionCascader';
import { parseRegionCodes } from '../lib/china-region';
import { request } from '../lib/api';
type ProxyOrderModalProps = {
open: boolean;
onClose: () => void;
onSuccess: (order: { id: string; orderNo: string }) => void;
};
function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrderModalProps) {
const [options, setOptions] = useState<PartnerProxyOrderOptions | null>(null);
const [loadingOptions, setLoadingOptions] = useState(false);
const [phone, setPhone] = useState('');
const [receiverName, setReceiverName] = useState('');
const [regionCodes, setRegionCodes] = useState<string[]>([]);
const [addressDetail, setAddressDetail] = useState('');
const [productId, setProductId] = useState<string>();
const [quantity, setQuantity] = useState(2);
const [promoCodeId, setPromoCodeId] = useState<string>();
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>('ADDRESS');
const [autoReceive, setAutoReceive] = useState(false);
const [preview, setPreview] = useState<PartnerProxyOrderPreviewResult | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const region = useMemo(() => parseRegionCodes(regionCodes), [regionCodes]);
useEffect(() => {
if (!open) return;
setLoadingOptions(true);
request<PartnerProxyOrderOptions>('/admin/proxy-orders/options')
.then((data) => {
setOptions(data);
if (data.products[0]) setProductId(data.products[0].id);
})
.catch((e) => message.error(e instanceof Error ? e.message : '加载失败'))
.finally(() => setLoadingOptions(false));
}, [open]);
useEffect(() => {
if (!open || !productId || quantity < 1) {
setPreview(null);
return;
}
const timer = window.setTimeout(() => {
setPreviewLoading(true);
request<PartnerProxyOrderPreviewResult>('/admin/proxy-orders/preview', {
method: 'POST',
body: JSON.stringify({
productId,
quantity,
deliveryMode,
receiverCity: deliveryMode === 'ADDRESS' ? region?.city || undefined : undefined,
receiverDistrict: deliveryMode === 'ADDRESS' ? region?.district || undefined : undefined,
}),
})
.then(setPreview)
.catch(() => setPreview(null))
.finally(() => setPreviewLoading(false));
}, 300);
return () => window.clearTimeout(timer);
}, [open, productId, quantity, deliveryMode, region?.city, region?.district]);
function resetForm() {
setPhone('');
setReceiverName('');
setRegionCodes([]);
setAddressDetail('');
setQuantity(2);
setPromoCodeId(undefined);
setDeliveryMode('ADDRESS');
setAutoReceive(false);
setPreview(null);
setProductId(options?.products[0]?.id);
}
function handleClose() {
resetForm();
onClose();
}
function validateForm(): string | null {
if (!/^1\d{10}$/.test(phone.trim())) return '请输入有效手机号';
if (!productId) return '请选择商品';
if (deliveryMode === 'ADDRESS') {
if (!region?.province || !region?.city || !region?.district) return '请选择省市区';
if (!addressDetail.trim()) return '请填写详细地址';
if (!autoReceive) return '配送到址须勾选同意自动收货';
} else {
const product = options?.products.find((p) => p.id === productId);
if (product && product.allowOnSitePickup === false) return '该商品不支持现场提货';
}
if (!preview) return '请等待费用计算完成';
return null;
}
async function submit() {
const err = validateForm();
if (err) {
message.warning(err);
return;
}
const payload: HqProxyOrderCreateRequest = {
phone: phone.trim(),
deliveryMode,
autoReceive: deliveryMode === 'ADDRESS' ? true : undefined,
receiverName: receiverName.trim() || undefined,
province: deliveryMode === 'ADDRESS' ? region?.province : undefined,
city: deliveryMode === 'ADDRESS' ? region?.city : undefined,
district: deliveryMode === 'ADDRESS' ? region?.district : undefined,
addressDetail: deliveryMode === 'ADDRESS' ? addressDetail.trim() : undefined,
productId: productId!,
quantity,
promoCodeId: promoCodeId || undefined,
};
setSubmitting(true);
try {
const order = await request<{ id: string; orderNo: string }>('/admin/proxy-orders', {
method: 'POST',
body: JSON.stringify(payload),
});
message.success(`代下单成功:${order.orderNo}`);
resetForm();
onSuccess(order);
} catch (e) {
message.error(e instanceof Error ? e.message : '下单失败');
} finally {
setSubmitting(false);
}
}
const deliveryLabel =
preview?.deliveryType === 'ON_SITE_PICKUP'
? '现场提货'
: preview?.deliveryType === 'CROSS_CITY'
? '跨城配送'
: '同城配送';
return (
<Modal
title="代下单"
open={open}
onCancel={handleClose}
width={640}
destroyOnClose
footer={
<Space>
<Button onClick={handleClose}></Button>
<Button type="primary" loading={submitting || loadingOptions} onClick={() => void submit()}>
</Button>
</Space>
}
>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="线下已收款:提交后订单直接完成并发放好客权益"
/>
<Form layout="vertical">
<Form.Item label="用户手机号" required>
<Input
placeholder="11 位手机号"
value={phone}
maxLength={11}
onChange={(e) => setPhone(e.target.value.replace(/\D/g, '').slice(0, 11))}
/>
</Form.Item>
<Form.Item label="酒品" required>
<Select
showSearch
optionFilterProp="label"
loading={loadingOptions}
placeholder="选择商品"
value={productId}
onChange={setProductId}
options={(options?.products ?? []).map((p) => ({
value: p.id,
label: `${p.name} · ${p.spec} · ¥${fmtMoney(p.price)}`,
}))}
/>
</Form.Item>
<Form.Item label="数量" required>
<InputNumber
min={1}
value={quantity}
onChange={(v) => setQuantity(Math.max(1, Number(v) || 1))}
style={{ width: '100%' }}
/>
</Form.Item>
{(options?.promoCodes.length ?? 0) > 0 ? (
<Form.Item label="绑定推广码(选填)">
<Select
allowClear
placeholder="不绑定"
value={promoCodeId}
onChange={setPromoCodeId}
options={(options?.promoCodes ?? []).map((p) => ({
value: p.id,
label: `${p.code} · ${p.name}`,
}))}
/>
</Form.Item>
) : null}
<Form.Item label="履约方式" required>
<Radio.Group
value={deliveryMode}
onChange={(e) => {
setDeliveryMode(e.target.value);
if (e.target.value !== 'ADDRESS') setAutoReceive(false);
}}
options={[
{ value: 'ADDRESS', label: '配送到址' },
{ value: 'ON_SITE_PICKUP', label: '现场提货' },
]}
/>
</Form.Item>
{deliveryMode === 'ADDRESS' ? (
<>
<Form.Item label="收货人(选填)">
<Input
placeholder="默认:用户+手机尾号"
value={receiverName}
maxLength={32}
onChange={(e) => setReceiverName(e.target.value)}
/>
</Form.Item>
<Form.Item label="省市区" required>
<ChinaRegionCascader value={regionCodes} onChange={setRegionCodes} />
</Form.Item>
<Form.Item label="详细地址" required>
<Input.TextArea
rows={2}
placeholder="街道门牌等"
value={addressDetail}
maxLength={256}
onChange={(e) => setAddressDetail(e.target.value)}
/>
</Form.Item>
<Form.Item>
<Checkbox checked={autoReceive} onChange={(e) => setAutoReceive(e.target.checked)}>
</Checkbox>
</Form.Item>
</>
) : null}
<div style={{ background: '#fafafa', borderRadius: 8, padding: 12 }}>
<Typography.Text type="secondary"></Typography.Text>
{previewLoading ? (
<div></div>
) : preview ? (
<Space direction="vertical" size={2} style={{ width: '100%', marginTop: 8 }}>
<div>{deliveryLabel}</div>
<div>¥{fmtMoney(preview.productAmount)}</div>
<div>¥{fmtMoney(preview.payAmount)}</div>
<div>¥{fmtMoney(preview.benefitAmount)}</div>
</Space>
) : (
<div style={{ marginTop: 8 }}>
<Typography.Text type="secondary"></Typography.Text>
</div>
)}
</div>
</Form>
</Modal>
);
}
+4
View File
@@ -148,6 +148,10 @@ export type AdminOrderRow = {
fulfillmentWarehouse?: { id: string; name: string } | null;
fulfillmentHold?: boolean;
fulfillmentHoldReason?: string | null;
orderType?: string;
isProxyOrder?: boolean;
proxyPartnerName?: string | null;
proxyPartnerPhone?: string | null;
user?: { id: string; userNo: string; phone: string | null; nickname: string | null };
delivery?: {
provider: string;
+11
View File
@@ -19,6 +19,17 @@ export const ORDER_STATUS_LABELS: Record<string, string> = {
REFUNDED: '已退款',
};
/** 订单状态流转操作人(common_event.param3 */
export const ORDER_STATUS_OPERATOR_LABELS: Record<string, string> = {
USER: '用户',
USER_ON_SITE: '用户·现场取货',
USER_WECHAT_CONFIRM: '用户·微信确认收货',
WECHAT_TRADE_MANAGE: '微信结算同步',
MOCK: 'Mock',
MOCK_PAY: 'Mock 支付',
SYSTEM: '系统',
};
/** 订单状态 Tag 颜色(Ant Design preset */
export const ORDER_STATUS_COLORS: Record<string, string> = {
PENDING_PAY: 'red',
+170 -16
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import {
Alert,
Button,
@@ -24,9 +25,11 @@ 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';
import ProxyOrderModal from '../components/ProxyOrderModal';
type ShipDefaults = {
provider: string;
@@ -102,7 +105,13 @@ type OrderDetail = AdminOrderRow & {
listUnitPrice?: number;
items?: AdminOrderItem[];
payment?: Record<string, unknown> | null;
statusLogs?: Array<{ fromStatus: string | null; toStatus: string; createdAt: string }>;
statusLogs?: Array<{
fromStatus: string | null;
toStatus: string;
operator?: string | null;
remark?: string | null;
createdAt: string;
}>;
benefitCoupons?: Array<Record<string, unknown>>;
redeemSummary?: OrderRedeemSummary | null;
redeemRecords?: OrderRedeemRecord[];
@@ -167,6 +176,7 @@ export default function OrdersPage() {
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
const [batchDeleteOpen, setBatchDeleteOpen] = useState(false);
const [batchDeleting, setBatchDeleting] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [shipDefaults, setShipDefaults] = useState<ShipDefaults | null>(null);
const [shipping, setShipping] = useState(false);
const [logisticsShipping, setLogisticsShipping] = useState(false);
@@ -175,7 +185,9 @@ export default function OrdersPage() {
const [shipTarget, setShipTarget] = useState<OrderDetail | null>(null);
const [shipMode, setShipMode] = useState<ShipMode>('EXPRESS');
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
const [proxyOpen, setProxyOpen] = useState(false);
const canDeleteOrders = (profile?.permissionKeys ?? []).includes('orders_delete');
const canProxyOrder = (profile?.permissionKeys ?? []).includes('orders');
const selectedOrders = useMemo(
() => (data?.items ?? []).filter((row) => selectedRowKeys.includes(row.id)),
@@ -207,6 +219,7 @@ export default function OrdersPage() {
const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
if (values.orderNo) qs.set('orderNo', values.orderNo);
if (values.status) qs.set('status', values.status);
if (values.orderType) qs.set('orderType', values.orderType);
if (values.cityId) qs.set('cityId', values.cityId);
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
if (values.fulfillmentHold) qs.set('fulfillmentHold', 'true');
@@ -379,6 +392,43 @@ export default function OrdersPage() {
}
}
function askDeleteOrder(id: string, orderNo?: string) {
Modal.confirm({
title: '确认删除订单?',
content: (
<div>
<p>{orderNo ? ` ${orderNo}` : ''} </p>
<p style={{ color: 'var(--ant-color-error)', marginBottom: 0 }}>线</p>
</div>
),
okText: '确认删除',
okType: 'danger',
cancelText: '取消',
onOk: () => deleteOrder(id),
});
}
async function deleteOrder(id: string) {
setDeletingId(id);
try {
const res = await request<{ deleted: number; message: string }>(`/admin/orders/${id}`, {
method: 'DELETE',
});
message.success(res.message || '订单已删除');
if (detail?.id === id) {
setDrawerOpen(false);
setDetail(null);
}
setSelectedRowKeys((prev) => prev.filter((k) => k !== id));
void load();
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败');
throw e;
} finally {
setDeletingId(null);
}
}
const columns: ColumnsType<AdminOrderRow> = [
{ title: '订单号', dataIndex: 'orderNo', width: 180 },
{
@@ -408,6 +458,11 @@ export default function OrdersPage() {
<Space size={4} wrap>
<Tag color={ORDER_STATUS_COLORS[s] || 'default'}>{ORDER_STATUS_LABELS[s] || s}</Tag>
{row.fulfillmentHold ? <Tag color="orange"></Tag> : null}
{row.orderType === 'PROXY' || row.isProxyOrder ? (
<Tag color="purple" title={row.proxyPartnerName || undefined}>
</Tag>
) : null}
</Space>
),
},
@@ -415,7 +470,8 @@ export default function OrdersPage() {
title: '配送',
dataIndex: 'deliveryType',
width: 90,
render: (v) => (v === 'LOCAL' ? '同城' : '跨城'),
render: (v) =>
v === 'ON_SITE_PICKUP' ? '现场提货' : v === 'LOCAL' ? '同城' : '跨城',
},
{ title: '实付', dataIndex: 'payAmount', width: 90, render: (v) => `¥${v}` },
{ title: '收货人', dataIndex: 'receiverName', width: 90 },
@@ -439,7 +495,7 @@ export default function OrdersPage() {
},
{
title: '操作',
width: 140,
width: 180,
fixed: 'right',
render: (_, row) => (
<Space size={0}>
@@ -451,6 +507,17 @@ export default function OrdersPage() {
</Button>
)}
{canDeleteOrders && (
<Button
type="link"
size="small"
danger
loading={deletingId === row.id}
onClick={() => askDeleteOrder(row.id, row.orderNo)}
>
</Button>
)}
</Space>
),
},
@@ -460,16 +527,38 @@ export default function OrdersPage() {
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
{canDeleteOrders ? (
<Button
danger
disabled={!selectedRowKeys.length}
onClick={() => setBatchDeleteOpen(true)}
>
{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
</Button>
) : null}
<Space>
{canProxyOrder ? (
<Button type="primary" onClick={() => setProxyOpen(true)}>
</Button>
) : null}
{canDeleteOrders ? (
<Button
danger
disabled={!selectedRowKeys.length}
onClick={() => setBatchDeleteOpen(true)}
>
{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
</Button>
) : null}
</Space>
</Space>
{!canDeleteOrders && profile ? (
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="当前账号无「删除订单」权限"
description={
<span>
<Link to="/hq-permissions"></Link>
</span>
}
/>
) : null}
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
<Form.Item name="orderNo" label="订单号">
<Input placeholder="DK..." allowClear />
@@ -477,6 +566,17 @@ export default function OrdersPage() {
<Form.Item name="status" label="状态">
<Select allowClear style={{ width: 120 }} options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item name="orderType" label="类型">
<Select
allowClear
style={{ width: 120 }}
placeholder="全部"
options={[
{ value: 'NORMAL', label: '普通订单' },
{ value: 'PROXY', label: '代下单' },
]}
/>
</Form.Item>
<Form.Item name="cityId" label="城市">
<Select
allowClear
@@ -540,6 +640,15 @@ export default function OrdersPage() {
</Button>
)}
{canDeleteOrders && (
<Button
danger
loading={deletingId === detail.id}
onClick={() => askDeleteOrder(detail.id, detail.orderNo)}
>
</Button>
)}
<Typography.Text type="secondary"></Typography.Text>
<Select
value={detail.status}
@@ -581,8 +690,16 @@ export default function OrdersPage() {
'大单待确认'}
</Tag>
) : null}
{detail.orderType === 'PROXY' || detail.isProxyOrder ? (
<Tag color="purple"></Tag>
) : null}
</Space>
</Descriptions.Item>
{detail.orderType === 'PROXY' || detail.isProxyOrder || detail.proxyPartnerName ? (
<Descriptions.Item label="代下单人">
{[detail.proxyPartnerName, detail.proxyPartnerPhone].filter(Boolean).join(' / ') || '—'}
</Descriptions.Item>
) : null}
<Descriptions.Item label="用户">{detail.user?.userNo} / {detail.user?.nickname}</Descriptions.Item>
<Descriptions.Item label="商品额">¥{detail.productAmount}</Descriptions.Item>
<Descriptions.Item label="实付">¥{detail.payAmount}</Descriptions.Item>
@@ -765,13 +882,40 @@ export default function OrdersPage() {
<Typography.Title level={5} style={{ marginTop: 16 }}></Typography.Title>
<Table
size="small"
rowKey="createdAt"
rowKey={(_, i) => String(i)}
pagination={false}
dataSource={detail.statusLogs}
columns={[
{ title: '从', dataIndex: 'fromStatus', render: (v) => v || '—' },
{ title: '', dataIndex: 'toStatus', render: (v) => ORDER_STATUS_LABELS[v] || v },
{ title: '时间', dataIndex: 'createdAt', render: (v) => new Date(v).toLocaleString('zh-CN') },
{
title: '',
dataIndex: 'fromStatus',
width: 100,
render: (v) => (v ? ORDER_STATUS_LABELS[v] || v : '—'),
},
{
title: '到',
dataIndex: 'toStatus',
width: 100,
render: (v) => ORDER_STATUS_LABELS[v] || v,
},
{
title: '操作人',
dataIndex: 'operator',
width: 120,
render: (v) => ORDER_STATUS_OPERATOR_LABELS[v] || v || '—',
},
{
title: '备注',
dataIndex: 'remark',
ellipsis: true,
render: (v) => v || '—',
},
{
title: '时间',
dataIndex: 'createdAt',
width: 170,
render: (v) => new Date(v).toLocaleString('zh-CN'),
},
]}
/>
</>
@@ -1078,6 +1222,16 @@ export default function OrdersPage() {
]}
/>
</Modal>
<ProxyOrderModal
open={proxyOpen}
onClose={() => setProxyOpen(false)}
onSuccess={(order) => {
setProxyOpen(false);
void load();
void openDetail(order.id);
}}
/>
</div>
);
}
+116 -7
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useMemo, useRef, useState } from 'react';
import {
Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Popconfirm, Select, Space,
Switch, Table, Tabs, Tag, Typography, message,
@@ -32,6 +32,8 @@ type Row = {
status: string;
sortOrder: number;
allowOnSitePickup?: boolean;
visibilityWhitelistEnabled?: boolean;
visibilityPhones?: string[];
mainImageUrl?: string | null;
carouselUrls?: string[];
detailImageUrls?: string[];
@@ -51,6 +53,8 @@ type ProductFormValues = {
status?: string;
sortOrder?: number;
allowOnSitePickup?: boolean;
visibilityWhitelistEnabled?: boolean;
visibilityPhones?: string[];
coverUrl?: string;
carouselUrls?: string[];
detailImageUrls?: string[];
@@ -59,6 +63,13 @@ type ProductFormValues = {
features?: Array<{ icon?: string; title?: string; desc?: string }>;
};
type UserPickRow = {
id: string;
phone?: string | null;
nickname?: string | null;
userNo?: string;
};
function mapDetailToForm(d: Record<string, unknown>) {
const detail = (d.detailContent ?? {}) as ProductDetailContentDto;
return {
@@ -66,6 +77,8 @@ function mapDetailToForm(d: Record<string, unknown>) {
coverUrl: (d as { mainImageUrl?: string }).mainImageUrl,
carouselUrls: ((d as Row).carouselUrls?.length ? (d as Row).carouselUrls : ['']) as string[],
detailImageUrls: ((d as Row).detailImageUrls?.length ? (d as Row).detailImageUrls : ['']) as string[],
visibilityWhitelistEnabled: !!(d as Row).visibilityWhitelistEnabled,
visibilityPhones: ((d as Row).visibilityPhones ?? []) as string[],
storyTitle: detail.storyTitle ?? '',
storyText: detail.storyText ?? '',
features: detail.features?.length
@@ -91,6 +104,10 @@ function buildProductPayload(v: ProductFormValues) {
features: features.length ? features : undefined,
};
const visibilityPhones = (v.visibilityPhones ?? [])
.map((p) => String(p || '').replace(/\D/g, '').trim())
.filter(Boolean);
return {
skuCode: v.skuCode,
barcode69: v.barcode69,
@@ -103,6 +120,8 @@ function buildProductPayload(v: ProductFormValues) {
status: v.status,
sortOrder: v.sortOrder,
allowOnSitePickup: !!v.allowOnSitePickup,
visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled,
visibilityPhones,
coverUrl: v.coverUrl,
carouselUrls,
detailImageUrls,
@@ -183,7 +202,88 @@ function ProductDetailFields({ form, aromaType }: { form: FormInstance; aromaTyp
);
}
function BaseInfoFields({ mode }: { mode: 'create' | 'edit' }) {
function VisibilityWhitelistFields({ form }: { form: FormInstance }) {
const [userOptions, setUserOptions] = useState<UserPickRow[]>([]);
const [userSearching, setUserSearching] = useState(false);
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
async function searchUsers(keyword: string) {
const q = keyword.trim();
if (searchTimer.current) clearTimeout(searchTimer.current);
if (!q) {
setUserOptions([]);
return;
}
searchTimer.current = setTimeout(() => {
void (async () => {
setUserSearching(true);
try {
const qs = new URLSearchParams({ page: '1', pageSize: '20', phone: q });
const res = await request<{ items: UserPickRow[] }>(`/admin/users?${qs}`);
setUserOptions((res.items ?? []).filter((u) => !!u.phone));
} catch {
setUserOptions([]);
} finally {
setUserSearching(false);
}
})();
}, 300);
}
const enabled = Form.useWatch('visibilityWhitelistEnabled', form);
return (
<>
<Form.Item
name="visibilityWhitelistEnabled"
label="可见白名单"
valuePropName="checked"
extra="开启后仅名单内手机号在 C 端可见/可购,用于在线测试"
>
<Switch checkedChildren="开" unCheckedChildren="关" />
</Form.Item>
{enabled ? (
<>
<Form.Item
name="visibilityPhones"
label="白名单手机号"
rules={[{ required: true, message: '请至少添加一个手机号' }]}
extra="可直接输入多个手机号回车添加,或从下方用户库选择"
>
<Select
mode="tags"
tokenSeparators={[',', ' ', '', ';', '']}
placeholder="输入手机号后回车"
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item label="从用户库添加">
<Select
showSearch
filterOption={false}
placeholder="按手机号搜索用户"
loading={userSearching}
options={userOptions.map((u) => ({
value: u.phone!,
label: `${u.phone}${u.nickname ? ` · ${u.nickname}` : ''}${u.userNo ? `${u.userNo}` : ''}`,
}))}
onSearch={searchUsers}
onSelect={(phone: string) => {
const cur = (form.getFieldValue('visibilityPhones') as string[] | undefined) ?? [];
if (!cur.includes(phone)) {
form.setFieldsValue({ visibilityPhones: [...cur, phone] });
}
}}
notFoundContent={userSearching ? '搜索中…' : '输入手机号搜索'}
/>
</Form.Item>
</>
) : null}
</>
);
}
function BaseInfoFields({ mode, form }: { mode: 'create' | 'edit'; form: FormInstance }) {
return (
<>
{mode === 'create' && (
@@ -223,6 +323,7 @@ function BaseInfoFields({ mode }: { mode: 'create' | 'edit' }) {
<Form.Item name="allowOnSitePickup" label="允许现场取货" valuePropName="checked">
<Switch checkedChildren="开" unCheckedChildren="关" />
</Form.Item>
<VisibilityWhitelistFields form={form} />
<Form.Item name="coverUrl" label="封面">
<OssUpload bizType="COVER" mediaType="IMAGE" />
</Form.Item>
@@ -264,7 +365,7 @@ export default function ProductsPage() {
}
}
const columns: ColumnsType<Row> = [
const columns: ColumnsType<Row> = useMemo(() => [
{ title: 'SKU', dataIndex: 'skuCode', width: 90 },
{ title: '商品名', dataIndex: 'name', width: 180, ellipsis: true },
{ title: '香型', dataIndex: 'aromaType', width: 80, render: (v) => AROMA_TYPE_LABELS[v] || v },
@@ -274,6 +375,13 @@ export default function ProductsPage() {
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => (
<Tag color={s === 'ON_SALE' ? 'green' : undefined}>{PRODUCT_STATUS_LABELS[s] || s}</Tag>
) },
{
title: '白名单',
dataIndex: 'visibilityWhitelistEnabled',
width: 90,
render: (v: boolean, row) =>
v ? <Tag color="orange">{row.visibilityPhones?.length ?? 0}</Tag> : <Tag></Tag>,
},
{
title: '现场取货',
dataIndex: 'allowOnSitePickup',
@@ -305,7 +413,7 @@ export default function ProductsPage() {
</Space>
),
},
];
], [detail, editForm]);
return (
<div>
@@ -323,7 +431,7 @@ export default function ProductsPage() {
</Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</Form>
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1140 }}
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1240 }}
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
<Drawer title="编辑商品" width={720} open={drawerOpen} onClose={() => setDrawerOpen(false)}
extra={detail && (
@@ -345,7 +453,7 @@ export default function ProductsPage() {
</Descriptions>
<Form form={editForm} layout="vertical">
<Tabs items={[
{ key: 'base', label: '基础信息', children: <BaseInfoFields mode="edit" /> },
{ key: 'base', label: '基础信息', children: <BaseInfoFields mode="edit" form={editForm} /> },
{
key: 'detail',
label: '详情页',
@@ -372,11 +480,12 @@ export default function ProductsPage() {
}} width={720}>
<Form form={createForm} layout="vertical" initialValues={{
aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0, allowOnSitePickup: false,
visibilityWhitelistEnabled: false, visibilityPhones: [],
carouselUrls: [''], detailImageUrls: [''],
features: [{ icon: 'water_drop', title: '', desc: '' }],
}}>
<Tabs items={[
{ key: 'base', label: '基础信息', children: <BaseInfoFields mode="create" /> },
{ key: 'base', label: '基础信息', children: <BaseInfoFields mode="create" form={createForm} /> },
{
key: 'detail',
label: '详情页',
+4
View File
@@ -16,6 +16,8 @@ import SettlementPage from './pages/SettlementPage';
import WeeklyReportPage from './pages/WeeklyReportPage';
import ProxyOrderPage from './pages/ProxyOrderPage';
import ProxyOrderProductsPage from './pages/ProxyOrderProductsPage';
import ProxyOrderListPage from './pages/ProxyOrderListPage';
import ProxyOrderDetailPage from './pages/ProxyOrderDetailPage';
import StaffListPage from './pages/StaffListPage';
import StaffCreatePage from './pages/StaffCreatePage';
import LeaderboardPage from './pages/LeaderboardPage';
@@ -33,6 +35,8 @@ function PrimaryRoutes() {
<Route path="/center/settlement" element={<SettlementPage />} />
<Route path="/center/staff" element={<StaffListPage />} />
<Route path="/center/staff/new" element={<StaffCreatePage />} />
<Route path="/center/proxy-orders" element={<ProxyOrderListPage />} />
<Route path="/center/proxy-orders/:id" element={<ProxyOrderDetailPage />} />
<Route path="/proxy-order" element={<ProxyOrderPage />} />
<Route path="/proxy-order/products" element={<ProxyOrderProductsPage />} />
<Route path="/reports/weekly" element={<WeeklyReportPage />} />
@@ -1,8 +1,9 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import type { ClientRuntimeConfig } from '@dukang/shared-types';
import { request } from '../lib/api';
import {
placeToPicked,
type LbsPlaceItem,
buildTencentLocPickerUrl,
parseTencentLocPickerMessage,
type TencentPickedLocation,
} from '../lib/tencentLocPicker';
@@ -12,117 +13,84 @@ type Props = {
onPick: (loc: TencentPickedLocation) => void;
latitude?: number | null;
longitude?: number | null;
/** 保留兼容,iframe 选点不使用 */
region?: string | null;
};
let cachedKey: string | null | undefined;
async function loadTencentLbsKey(): Promise<string> {
if (cachedKey) return cachedKey;
const cfg = await request<ClientRuntimeConfig>('PARTNER_H5', '/common/client-config', {
silent: true,
});
const key = (cfg.tencentLbsKey || '').trim();
if (!key) {
throw new Error('未配置腾讯位置服务 Key,请联系管理员在系统设置中配置');
}
cachedKey = key;
return key;
}
export default function TencentLocPickerOverlay({
open,
onClose,
onPick,
latitude,
longitude,
region,
}: Props) {
const [keyword, setKeyword] = useState('');
const [items, setItems] = useState<LbsPlaceItem[]>([]);
const [key, setKey] = useState<string | null>(cachedKey ?? null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
/** 选点结果仅存内存,界面不展示经纬度 */
const [pending, setPending] = useState<TencentPickedLocation | null>(null);
const [hint, setHint] = useState('输入地点名称搜索');
const seqRef = useRef(0);
const [iframeReady, setIframeReady] = useState(false);
useEffect(() => {
if (!open) {
setKeyword('');
setItems([]);
setPending(null);
setHint('输入地点名称搜索');
setIframeReady(false);
setError('');
return;
}
const lat = latitude != null ? Number(latitude) : NaN;
const lng = longitude != null ? Number(longitude) : NaN;
if (Number.isFinite(lat) && Number.isFinite(lng) && !(lat === 0 && lng === 0)) {
void loadNearby(lat, lng);
let cancelled = false;
if (key) return;
setLoading(true);
void loadTencentLbsKey()
.then((k) => {
if (!cancelled) setKey(k);
})
.catch((e) => {
if (!cancelled) setError(e instanceof Error ? e.message : '加载地图配置失败');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [open, key]);
useEffect(() => {
if (!open) return;
function onMessage(event: MessageEvent) {
const picked = parseTencentLocPickerMessage(event.data);
if (!picked) return;
setPending(picked);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, [open]);
async function loadNearby(lat: number, lng: number) {
const seq = ++seqRef.current;
setLoading(true);
setHint('正在加载附近地点…');
try {
const res = await request<{ items: LbsPlaceItem[] }>(
'PARTNER_H5',
`/common/lbs/nearby?lat=${encodeURIComponent(String(lat))}&lng=${encodeURIComponent(String(lng))}`,
{ silent: true },
);
if (seq !== seqRef.current) return;
setItems(res.items ?? []);
setHint(res.items?.length ? `附近 ${res.items.length} 个地点` : '附近暂无地点,请搜索');
} catch (e) {
if (seq !== seqRef.current) return;
setItems([]);
setHint(e instanceof Error ? e.message : '加载附近地点失败');
} finally {
if (seq === seqRef.current) setLoading(false);
}
}
async function runSearch() {
const trimmed = keyword.trim();
if (!trimmed) {
setHint('请输入搜索关键词');
return;
}
const seq = ++seqRef.current;
setLoading(true);
setHint('搜索中…');
try {
const params = new URLSearchParams({ keyword: trimmed });
if (region?.trim()) params.set('region', region.trim());
const lat = latitude != null ? Number(latitude) : NaN;
const lng = longitude != null ? Number(longitude) : NaN;
if (Number.isFinite(lat) && Number.isFinite(lng)) {
params.set('lat', String(lat));
params.set('lng', String(lng));
}
const res = await request<{ items: LbsPlaceItem[] }>(
'PARTNER_H5',
`/common/lbs/suggest?${params.toString()}`,
{ silent: true },
);
if (seq !== seqRef.current) return;
setItems(res.items ?? []);
setHint(res.items?.length ? `找到 ${res.items.length} 个结果` : '无匹配结果');
} catch (e) {
if (seq !== seqRef.current) return;
setItems([]);
setHint(e instanceof Error ? e.message : '搜索失败');
} finally {
if (seq === seqRef.current) setLoading(false);
}
}
function useBrowserLocation() {
if (!navigator.geolocation) {
setHint('当前浏览器不支持定位');
return;
}
setLoading(true);
navigator.geolocation.getCurrentPosition(
(pos) => {
const lat = pos.coords.latitude;
const lng = pos.coords.longitude;
setPending({ latitude: lat, longitude: lng, name: '当前位置' });
void loadNearby(lat, lng);
},
() => {
setLoading(false);
setHint('定位失败,请检查定位权限');
},
{ enableHighAccuracy: true, timeout: 12000 },
);
}
const src = useMemo(() => {
if (!key) return '';
const lat = latitude != null ? Number(latitude) : undefined;
const lng = longitude != null ? Number(longitude) : undefined;
return buildTencentLocPickerUrl(key, {
latitude: lat != null && Number.isFinite(lat) ? lat : undefined,
longitude: lng != null && Number.isFinite(lng) ? lng : undefined,
});
}, [key, latitude, longitude]);
if (!open) return null;
@@ -134,6 +102,7 @@ export default function TencentLocPickerOverlay({
return (
<div
className="partner-locpicker-overlay"
style={{
position: 'fixed',
inset: 0,
@@ -141,123 +110,124 @@ export default function TencentLocPickerOverlay({
background: '#fff',
display: 'flex',
flexDirection: 'column',
height: '100dvh',
maxHeight: '-webkit-fill-available',
paddingTop: 'env(safe-area-inset-top)',
paddingBottom: 'env(safe-area-inset-bottom)',
boxSizing: 'border-box',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 16px',
flexDirection: 'column',
padding: '12px 12px 10px',
borderBottom: '1px solid rgba(0,0,0,0.06)',
flexShrink: 0,
gap: 8,
gap: 10,
background: '#fff',
}}
>
<button type="button" className="partner-btn-outline" style={{ padding: '6px 12px' }} onClick={onClose}>
</button>
<span style={{ fontWeight: 600 }}></span>
<button
type="button"
className="partner-btn-primary"
style={{ padding: '6px 12px', opacity: pending ? 1 : 0.45, width: 'auto' }}
disabled={!pending}
onClick={confirmPick}
<h2
style={{
margin: 0,
textAlign: 'center',
fontWeight: 700,
fontSize: 17,
lineHeight: 1.3,
}}
>
</button>
</div>
<div style={{ padding: '12px 16px', borderBottom: '1px solid rgba(0,0,0,0.04)', flexShrink: 0 }}>
<div style={{ display: 'flex', gap: 8 }}>
<input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void runSearch();
}}
placeholder="输入小区 / 写字楼 / 门店名称"
style={{
flex: 1,
padding: '10px 12px',
border: '1px solid rgba(0,0,0,0.12)',
borderRadius: 8,
fontSize: 14,
}}
/>
</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<button
type="button"
className="partner-btn-primary"
style={{ width: 'auto', padding: '8px 14px' }}
disabled={loading}
onClick={() => void runSearch()}
style={{
flex: 1,
padding: '12px 16px',
fontSize: 15,
fontWeight: 600,
opacity: pending ? 1 : 0.45,
width: 'auto',
}}
disabled={!pending}
onClick={confirmPick}
>
</button>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 8, alignItems: 'center' }}>
<button
type="button"
className="partner-btn-outline"
style={{ padding: '6px 12px' }}
disabled={loading}
onClick={useBrowserLocation}
style={{ padding: '12px 16px', minWidth: 72, flexShrink: 0 }}
onClick={onClose}
>
</button>
<span className="label-md text-muted">{hint}</span>
</div>
{pending ? (
<p className="label-md text-muted" style={{ margin: '8px 0 0' }}>
{pending.latitude.toFixed(6)}, {pending.longitude.toFixed(6)}
{pending.name ? ` · ${pending.name}` : ''}
</p>
) : null}
</div>
<div style={{ flex: 1, minHeight: 0, overflow: 'auto', WebkitOverflowScrolling: 'touch' }}>
{loading && !items.length ? (
<p
className="label-md text-muted"
style={{
margin: 0,
padding: '8px 14px',
borderBottom: '1px solid rgba(0,0,0,0.04)',
flexShrink: 0,
fontSize: 12,
}}
>
{pending ? '已选定位置,请点右上角「确认」' : '在地图上点选或搜索地点,选中后点右上角「确认」'}
</p>
<div style={{ flex: 1, minHeight: 0, position: 'relative', background: '#f5f5f5' }}>
{loading ? (
<p className="label-md text-muted" style={{ padding: 24, textAlign: 'center' }}>
</p>
) : items.length ? (
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
{items.map((item) => {
const active =
pending?.latitude === item.latitude && pending?.longitude === item.longitude;
return (
<li key={`${item.id}-${item.latitude}-${item.longitude}`}>
<button
type="button"
onClick={() => setPending(placeToPicked(item))}
style={{
width: '100%',
textAlign: 'left',
padding: '12px 16px',
border: 0,
borderBottom: '1px solid rgba(0,0,0,0.06)',
background: active ? 'rgba(166, 29, 36, 0.06)' : '#fff',
cursor: 'pointer',
}}
>
<div style={{ fontWeight: 600, fontSize: 15 }}>{item.title}</div>
<div className="label-md text-muted" style={{ marginTop: 4 }}>
{item.address}
</div>
<div className="label-md text-muted" style={{ marginTop: 2, fontSize: 12 }}>
{item.latitude.toFixed(6)}, {item.longitude.toFixed(6)}
</div>
</button>
</li>
);
})}
</ul>
) : (
<p className="label-md text-muted" style={{ padding: 24, textAlign: 'center' }}>
{hint}
</p>
)}
) : error ? (
<div style={{ padding: 24 }}>
<p className="label-md" style={{ color: 'var(--color-heritage-red, #a61d24)' }}>
{error}
</p>
<p className="label-md text-muted" style={{ marginTop: 12 }}>
Key apis.map.qq.com
</p>
</div>
) : src ? (
<>
{!iframeReady ? (
<p
className="label-md text-muted"
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
margin: 0,
zIndex: 1,
pointerEvents: 'none',
}}
>
</p>
) : null}
<iframe
title="地图选点"
src={src}
allow="geolocation *"
onLoad={() => setIframeReady(true)}
style={{
width: '100%',
height: '100%',
border: 0,
display: 'block',
background: '#fff',
}}
/>
</>
) : null}
</div>
</div>
);
@@ -0,0 +1,56 @@
import type { PartnerProxyDeliveryMode } from '@dukang/shared-types';
const PROXY_ORDER_DRAFT_KEY = 'partner_proxy_order_draft_v2';
export type ProxyOrderDraft = {
phone: string;
receiverName: string;
regionCodes: string[];
addressDetail: string;
productId: string;
quantity: number;
promoCodeId: string;
deliveryMode: PartnerProxyDeliveryMode;
autoReceive: boolean;
};
export function saveProxyOrderDraft(draft: ProxyOrderDraft) {
try {
sessionStorage.setItem(PROXY_ORDER_DRAFT_KEY, JSON.stringify(draft));
} catch {
/* ignore quota / private mode */
}
}
export function loadProxyOrderDraft(): ProxyOrderDraft | null {
try {
const raw = sessionStorage.getItem(PROXY_ORDER_DRAFT_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<ProxyOrderDraft>;
if (!parsed || typeof parsed !== 'object') return null;
return {
phone: typeof parsed.phone === 'string' ? parsed.phone : '',
receiverName: typeof parsed.receiverName === 'string' ? parsed.receiverName : '',
regionCodes: Array.isArray(parsed.regionCodes)
? parsed.regionCodes.filter((c): c is string => typeof c === 'string')
: [],
addressDetail: typeof parsed.addressDetail === 'string' ? parsed.addressDetail : '',
productId: typeof parsed.productId === 'string' ? parsed.productId : '',
quantity: typeof parsed.quantity === 'number' && parsed.quantity >= 1 ? parsed.quantity : 2,
promoCodeId: typeof parsed.promoCodeId === 'string' ? parsed.promoCodeId : '',
deliveryMode: parsed.deliveryMode === 'ON_SITE_PICKUP' ? 'ON_SITE_PICKUP' : 'ADDRESS',
autoReceive: parsed.autoReceive === true,
};
} catch {
return null;
}
}
export function clearProxyOrderDraft() {
try {
sessionStorage.removeItem(PROXY_ORDER_DRAFT_KEY);
sessionStorage.removeItem('partner_proxy_order_draft_v1');
} catch {
/* ignore */
}
}
@@ -24,3 +24,78 @@ export function placeToPicked(item: LbsPlaceItem): TencentPickedLocation {
cityname: item.city || undefined,
};
}
type LocPickerMessage = {
module?: string;
latlng?: { lat?: number; lng?: number };
poiaddress?: string;
poiname?: string;
cityname?: string;
};
const REFERER = 'dukang';
function coerceMessageData(data: unknown): LocPickerMessage | null {
if (data == null) return null;
if (typeof data === 'string') {
const trimmed = data.trim();
if (!trimmed || (trimmed[0] !== '{' && trimmed[0] !== '[')) return null;
try {
return JSON.parse(trimmed) as LocPickerMessage;
} catch {
return null;
}
}
if (typeof data === 'object') return data as LocPickerMessage;
return null;
}
/** 腾讯地图选点组件(iframe */
export function buildTencentLocPickerUrl(
key: string,
options?: { latitude?: number; longitude?: number },
): string {
const params = new URLSearchParams({
search: '1',
type: '1',
key,
referer: REFERER,
policy: '1',
total: '20',
radius: '2000',
mapdraggable: '1',
});
const lat = options?.latitude;
const lng = options?.longitude;
if (
lat != null &&
lng != null &&
Number.isFinite(lat) &&
Number.isFinite(lng) &&
!(lat === 0 && lng === 0)
) {
params.set('coord', `${lat},${lng}`);
params.set('coordtype', '5');
}
return `https://apis.map.qq.com/tools/locpicker?${params.toString()}`;
}
export function parseTencentLocPickerMessage(data: unknown): TencentPickedLocation | null {
const loc = coerceMessageData(data);
if (!loc || loc.module !== 'locationPicker') return null;
const lat = Number(loc.latlng?.lat);
const lng = Number(loc.latlng?.lng);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
let name = loc.poiname?.trim() || undefined;
const address = loc.poiaddress?.trim() || undefined;
if (name === '我的位置' && address) name = address;
return {
latitude: lat,
longitude: lng,
address,
name,
cityname: loc.cityname?.trim() || undefined,
};
}
+9
View File
@@ -219,6 +219,15 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag
<span className="material-symbols-outlined text-muted">chevron_right</span>
</div>
</Link>
<Link to="/center/proxy-orders" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon">
<span className="material-symbols-outlined">receipt_long</span>
</div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</Link>
<Link to="/stores?audit=pending" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon">
@@ -0,0 +1,117 @@
import { useEffect, useState } from 'react';
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';
function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function fmtTime(value: string | undefined) {
if (!value) return '—';
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function deliveryLabel(order: PartnerProxyOrderListItem) {
const t = String(order.deliveryType || '').toUpperCase();
if (t === 'ON_SITE_PICKUP') return '现场提货';
if (t === 'CROSS_CITY') return '跨城配送';
if (t === 'LOCAL') return '同城配送';
return '线下代下单';
}
export default function ProxyOrderDetailPage() {
const { id } = useParams();
const navigate = useNavigate();
const [order, setOrder] = useState<PartnerProxyOrderListItem | null>(null);
const [error, setError] = useState('');
useEffect(() => {
document.title = '代下单详情';
}, []);
useEffect(() => {
if (!id) return;
void request<PartnerProxyOrderListItem>('PARTNER_H5', `/partner/proxy-orders/${id}`)
.then(setOrder)
.catch((e) => setError(e instanceof Error ? e.message : '加载失败'));
}, [id]);
const img = order?.imageResource?.url || '';
return (
<div className="page-no-tab partner-orders-page">
<PageHeader title="代下单详情" onBack={() => navigate('/center/proxy-orders')} />
{error && (
<p className="partner-form-error" role="alert" style={{ margin: '16px 20px' }}>
{error}
</p>
)}
{!order && !error && <p className="label-md text-muted" style={{ margin: 20 }}></p>}
{order && (
<div style={{ padding: '0 16px 32px' }}>
<div className="partner-order-card" style={{ pointerEvents: 'none' }}>
<div className="partner-order-card-top">
<span className="label-md text-muted">NO. {order.orderNo}</span>
<span className="label-md" style={{ color: 'var(--color-success-green)', fontWeight: 600 }}>
</span>
</div>
<div className="partner-order-product">
<div
className="partner-order-product-img"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
background: img ? `center/cover no-repeat url(${img})` : undefined,
}}
>
{!img && <span className="material-symbols-outlined text-muted">liquor</span>}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<h3 className="headline-md">{order.productName || '杜康好酒'}</h3>
{order.productSpec && (
<p className="label-md text-muted" style={{ marginTop: 4 }}>
{order.productSpec}
</p>
)}
<p className="body-md" style={{ marginTop: 8 }}>
×{order.quantity} · ¥{fmtMoney(Number(order.payAmount || 0))}
</p>
<p className="label-md text-muted" style={{ marginTop: 4 }}>
¥{fmtMoney(Number(order.benefitAmount || 0))}
</p>
</div>
</div>
</div>
<section className="partner-form-card" style={{ marginTop: 12 }}>
<h3 className="headline-md" style={{ marginBottom: 12 }}></h3>
<p className="body-md">{order.receiverName || '—'}</p>
<p className="body-md" style={{ marginTop: 8 }}>{order.receiverPhone || '—'}</p>
<p className="body-md" style={{ marginTop: 8 }}>
{order.receiverAddress || (String(order.deliveryType).toUpperCase() === 'ON_SITE_PICKUP' ? '现场提货' : '—')}
</p>
</section>
<section className="partner-form-card" style={{ marginTop: 12 }}>
<h3 className="headline-md" style={{ marginBottom: 12 }}></h3>
<p className="body-md">{deliveryLabel(order)}</p>
<p className="body-md" style={{ marginTop: 8 }}>{fmtTime(order.createdAt)}</p>
<p className="body-md" style={{ marginTop: 8 }}>线</p>
<p className="body-md" style={{ marginTop: 8 }}></p>
</section>
</div>
)}
</div>
);
}
@@ -0,0 +1,142 @@
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import { Link, useNavigate } from 'react-router-dom';
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';
function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function fmtTime(value: string | undefined) {
if (!value) return '—';
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function deliveryLabel(item: PartnerProxyOrderListItem) {
const t = String(item.deliveryType || '').toUpperCase();
if (t === 'ON_SITE_PICKUP') return '现场提货';
if (t === 'CROSS_CITY') return '跨城配送';
if (t === 'LOCAL') return '同城配送';
return '线下代下单';
}
function coverUrl(item: PartnerProxyOrderListItem) {
return item.imageResource?.url || '';
}
export default function ProxyOrderListPage() {
const navigate = useNavigate();
const [list, setList] = useState<PartnerProxyOrderListItem[]>([]);
const [total, setTotal] = useState(0);
const [keyword, setKeyword] = useState('');
const [q, setQ] = useState('');
const [loading, setLoading] = useState(true);
const loadList = useCallback(() => {
if (!isLoggedIn()) {
navigate('/login');
return Promise.resolve();
}
setLoading(true);
const params = new URLSearchParams({ page: '1', pageSize: '50' });
if (q.trim()) params.set('keyword', q.trim());
return request<PartnerProxyOrderListResponse>('PARTNER_H5', `/partner/proxy-orders?${params}`)
.then((res) => {
setList(Array.isArray(res.list) ? res.list : []);
setTotal(Number(res.total) || 0);
})
.catch(() => {
setList([]);
setTotal(0);
})
.finally(() => setLoading(false));
}, [navigate, q]);
useEffect(() => {
document.title = '代下单列表';
}, []);
useEffect(() => {
void loadList();
}, [loadList]);
function submitSearch(e: FormEvent) {
e.preventDefault();
setQ(keyword.trim());
}
return (
<PullToRefresh onRefresh={loadList} className="page-no-tab partner-orders-page">
<PageHeader title="代下单列表" onBack={() => navigate('/center')} />
<div style={{ padding: '0 20px 12px' }}>
<form className="partner-search" onSubmit={submitSearch}>
<span className="material-symbols-outlined">search</span>
<input
placeholder="订单号 / 手机号 / 姓名 / 商品"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
</form>
<p className="label-md text-muted" style={{ marginTop: 8 }}>
{loading ? '加载中…' : `${total} 笔代下单`}
</p>
</div>
<div style={{ padding: '0 16px 24px' }}>
{!loading && list.length === 0 && <div className="empty"></div>}
{list.map((o) => {
const orderId = String(o.id);
const img = coverUrl(o);
return (
<Link key={orderId} to={`/center/proxy-orders/${orderId}`} className="partner-order-card" style={{ marginBottom: 12 }}>
<div className="partner-order-card-top">
<span className="label-md text-muted">NO. {o.orderNo}</span>
<span className="label-md" style={{ color: 'var(--color-success-green)', fontWeight: 600 }}>
</span>
</div>
<div className="partner-order-product">
<div
className="partner-order-product-img"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
background: img ? `center/cover no-repeat url(${img})` : undefined,
}}
>
{!img && <span className="material-symbols-outlined text-muted">liquor</span>}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<h3 className="headline-md line-2-clamp">{o.productName || '杜康好酒'}</h3>
<p className="text-variant body-md" style={{ marginTop: 4 }}>
×{o.quantity} · ¥{fmtMoney(Number(o.payAmount || 0))}
</p>
<p className="label-md text-muted" style={{ marginTop: 4 }}>
{deliveryLabel(o)} · {fmtTime(o.createdAt)}
</p>
</div>
</div>
<div className="partner-order-address">
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>person</span>
<p className="line-2-clamp">
{o.receiverName || '客户'}
{o.receiverPhone ? ` · ${o.receiverPhone}` : ''}
{o.receiverAddress ? ` · ${o.receiverAddress}` : ''}
</p>
</div>
</Link>
);
})}
</div>
</PullToRefresh>
);
}
+88 -176
View File
@@ -4,6 +4,12 @@ import PageHeader from '@dukang/shared-ui/PageHeader';
import ChinaRegionPicker from '../components/ChinaRegionPicker';
import { request } from '../lib/api';
import { formatRegionLabel, parseRegionCodes } from '../lib/china-region';
import {
clearProxyOrderDraft,
loadProxyOrderDraft,
saveProxyOrderDraft,
type ProxyOrderDraft,
} from '../lib/proxyOrderDraft';
import { toastError, toastSuccess } from '../lib/toast';
import type {
PartnerProxyDeliveryMode,
@@ -16,33 +22,60 @@ function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function initialDraft(): ProxyOrderDraft {
return (
loadProxyOrderDraft() ?? {
phone: '',
receiverName: '',
regionCodes: [],
addressDetail: '',
productId: '',
quantity: 2,
promoCodeId: '',
deliveryMode: 'ADDRESS',
autoReceive: false,
}
);
}
export default function ProxyOrderPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const draft0 = useMemo(() => initialDraft(), []);
const [options, setOptions] = useState<PartnerProxyOrderOptions | null>(null);
const [loadingOptions, setLoadingOptions] = useState(true);
const [phone, setPhone] = useState('');
const [customerSmsCode, setCustomerSmsCode] = useState('');
const [partnerSmsCode, setPartnerSmsCode] = useState('');
const [receiverName, setReceiverName] = useState('');
const [regionCodes, setRegionCodes] = useState<string[]>([]);
const [addressDetail, setAddressDetail] = useState('');
const [productId, setProductId] = useState('');
const [quantity, setQuantity] = useState(2);
const [promoCodeId, setPromoCodeId] = useState('');
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>('ADDRESS');
const [autoReceive, setAutoReceive] = useState(false);
const [confirmStep, setConfirmStep] = useState(false);
const [phone, setPhone] = useState(draft0.phone);
const [receiverName, setReceiverName] = useState(draft0.receiverName);
const [regionCodes, setRegionCodes] = useState<string[]>(draft0.regionCodes);
const [addressDetail, setAddressDetail] = useState(draft0.addressDetail);
const [productId, setProductId] = useState(draft0.productId);
const [quantity, setQuantity] = useState(draft0.quantity);
const [promoCodeId, setPromoCodeId] = useState(draft0.promoCodeId);
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>(draft0.deliveryMode);
const [autoReceive, setAutoReceive] = useState(draft0.autoReceive);
const [preview, setPreview] = useState<PartnerProxyOrderPreviewResult | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [customerCooldown, setCustomerCooldown] = useState(0);
const [partnerCooldown, setPartnerCooldown] = useState(0);
const [submitting, setSubmitting] = useState(false);
const [msg, setMsg] = useState('');
const region = useMemo(() => parseRegionCodes(regionCodes), [regionCodes]);
const regionLabel = region ? formatRegionLabel(region) : '';
function persistDraft(overrides?: Partial<ProxyOrderDraft>) {
saveProxyOrderDraft({
phone,
receiverName,
regionCodes,
addressDetail,
productId,
quantity,
promoCodeId,
deliveryMode,
autoReceive,
...overrides,
});
}
useEffect(() => {
request<PartnerProxyOrderOptions>('PARTNER_H5', '/partner/proxy-orders/options')
.then((data) => {
@@ -53,6 +86,7 @@ export default function ProxyOrderPage() {
const fromQuery = searchParams.get('productId');
if (fromQuery && data.products.some((p) => p.id === fromQuery)) {
setProductId(fromQuery);
persistDraft({ productId: fromQuery });
} else if (data.products[0] && !productId) {
setProductId(data.products[0].id);
}
@@ -64,9 +98,28 @@ export default function ProxyOrderPage() {
useEffect(() => {
const fromQuery = searchParams.get('productId');
if (fromQuery) setProductId(fromQuery);
if (fromQuery) {
setProductId(fromQuery);
persistDraft({ productId: fromQuery });
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- only sync productId from URL
}, [searchParams]);
useEffect(() => {
persistDraft();
// eslint-disable-next-line react-hooks/exhaustive-deps -- keep draft in sync for product-picker roundtrip
}, [
phone,
receiverName,
regionCodes,
addressDetail,
productId,
quantity,
promoCodeId,
deliveryMode,
autoReceive,
]);
useEffect(() => {
if (!productId || quantity < 1) {
setPreview(null);
@@ -92,64 +145,10 @@ export default function ProxyOrderPage() {
return () => clearTimeout(timer);
}, [productId, quantity, deliveryMode, region?.city, region?.district]);
function startCooldown(setter: (n: number | ((s: number) => number)) => void) {
setter(60);
const timer = setInterval(() => {
setter((s) => {
if (s <= 1) {
clearInterval(timer);
return 0;
}
return s - 1;
});
}, 1000);
}
async function sendCustomerSms() {
setMsg('');
if (!/^1\d{10}$/.test(phone.trim())) {
setMsg('请输入有效手机号');
return;
}
try {
const res = await request<{ maskedPhone: string }>(
'PARTNER_H5',
'/partner/proxy-orders/send-customer-sms',
{
method: 'POST',
body: JSON.stringify({ phone: phone.trim() }),
silent: true,
},
);
setMsg(`客户验证码已发送至 ${res.maskedPhone}`);
startCooldown(setCustomerCooldown);
} catch (e) {
setMsg(e instanceof Error ? e.message : '发送失败');
}
}
async function sendPartnerSms() {
setMsg('');
try {
const res = await request<{ maskedPhone: string }>(
'PARTNER_H5',
'/partner/proxy-orders/send-partner-sms',
{ method: 'POST', body: '{}', silent: true },
);
setMsg(`确认验证码已发送至合伙人手机 ${res.maskedPhone}`);
startCooldown(setPartnerCooldown);
} catch (e) {
setMsg(e instanceof Error ? e.message : '发送失败');
}
}
function validateForm(): string | null {
if (!/^1\d{10}$/.test(phone.trim())) return '请输入有效手机号';
if (!customerSmsCode.trim()) return '请输入客户验证码';
if (!productId) return '请选择商品';
if (deliveryMode !== 'ADDRESS') {
// 现场提货不需要地址与门店
} else {
if (deliveryMode === 'ADDRESS') {
if (!region?.province || !region?.city || !region?.district) return '请选择省市区';
if (!addressDetail.trim()) return '请填写详细地址';
if (!autoReceive) return '配送到址须勾选同意自动收货';
@@ -158,18 +157,6 @@ export default function ProxyOrderPage() {
return null;
}
async function openConfirmStep() {
setMsg('');
const err = validateForm();
if (err) {
setMsg(err);
return;
}
setConfirmStep(true);
setPartnerSmsCode('');
await sendPartnerSms();
}
async function submit() {
setMsg('');
const err = validateForm();
@@ -177,15 +164,9 @@ export default function ProxyOrderPage() {
setMsg(err);
return;
}
if (!partnerSmsCode.trim()) {
setMsg('请输入合伙人确认验证码');
return;
}
const payload: PartnerProxyOrderCreateRequest = {
phone: phone.trim(),
customerSmsCode: customerSmsCode.trim(),
partnerSmsCode: partnerSmsCode.trim(),
deliveryMode,
autoReceive: deliveryMode === 'ADDRESS' ? true : undefined,
receiverName: receiverName.trim() || undefined,
@@ -206,7 +187,8 @@ export default function ProxyOrderPage() {
silent: true,
});
toastSuccess(`代下单成功:${order.orderNo}`);
navigate(`/orders/${order.id}`);
clearProxyOrderDraft();
navigate(`/center/proxy-orders/${order.id}`);
} catch (e) {
setMsg(e instanceof Error ? e.message : '下单失败');
} finally {
@@ -233,34 +215,12 @@ export default function ProxyOrderPage() {
<>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<div className="partner-input-row">
<div className="partner-input-wrap" style={{ flex: 1 }}>
<input
className="partner-input"
placeholder="11 位手机号"
value={phone}
onChange={(e) => setPhone(e.target.value.replace(/\D/g, '').slice(0, 11))}
/>
</div>
<button
type="button"
className="btn btn-secondary"
disabled={customerCooldown > 0}
onClick={() => void sendCustomerSms()}
>
{customerCooldown > 0 ? `${customerCooldown}s` : '获取验证码'}
</button>
</div>
</section>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<div className="partner-input-wrap">
<input
className="partner-input"
placeholder="发至客户手机"
value={customerSmsCode}
onChange={(e) => setCustomerSmsCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="11 位手机"
value={phone}
onChange={(e) => setPhone(e.target.value.replace(/\D/g, '').slice(0, 11))}
/>
</div>
</section>
@@ -270,11 +230,12 @@ export default function ProxyOrderPage() {
<button
type="button"
className="partner-proxy-product-picker"
onClick={() =>
onClick={() => {
persistDraft();
navigate(
`/proxy-order/products${productId ? `?selected=${encodeURIComponent(productId)}` : ''}`,
)
}
);
}}
>
{selectedProduct ? (
<>
@@ -428,73 +389,24 @@ export default function ProxyOrderPage() {
)}
</section>
{confirmStep ? (
<section className="partner-form-section partner-proxy-confirm">
<label className="partner-form-label"></label>
<p className="label-md text-muted" style={{ marginBottom: 8 }}>
线
</p>
<div className="partner-input-row">
<div className="partner-input-wrap" style={{ flex: 1 }}>
<input
className="partner-input"
placeholder="发至合伙人手机"
value={partnerSmsCode}
onChange={(e) => setPartnerSmsCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
/>
</div>
<button
type="button"
className="btn btn-secondary"
disabled={partnerCooldown > 0}
onClick={() => void sendPartnerSms()}
>
{partnerCooldown > 0 ? `${partnerCooldown}s` : '重新发送'}
</button>
</div>
</section>
) : null}
{msg ? (
<p className="partner-form-error" role="alert">
{msg}
</p>
) : null}
{!confirmStep ? (
<button
type="button"
className="btn btn-primary btn-block"
disabled={!preview}
onClick={() => void openConfirmStep()}
style={{ marginTop: 16 }}
>
</button>
) : (
<div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
<button
type="button"
className="btn btn-secondary"
style={{ flex: 1 }}
onClick={() => setConfirmStep(false)}
>
</button>
<button
type="button"
className="btn btn-primary"
style={{ flex: 1 }}
disabled={submitting || !preview}
onClick={() => void submit()}
>
{submitting ? '提交中…' : '验证并提交'}
</button>
</div>
)}
<button
type="button"
className="btn btn-primary btn-block"
disabled={submitting || !preview}
onClick={() => void submit()}
style={{ marginTop: 16 }}
>
{submitting ? '提交中…' : '确认代下单'}
</button>
<p className="label-md text-muted" style={{ marginTop: 12, lineHeight: 1.5 }}>
线/
线/
</p>
</>
)}
+5 -35
View File
@@ -13,8 +13,6 @@ import { resolveRegionBinding } from '../lib/china-region';
import { checkStorePhoneAvailable } from '../lib/storePhone';
import { formatStoreCoords, locateStorePosition } from '../lib/storeLocate';
import { fetchPartnerCities, type OpenCityOption } from '../lib/upload';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
@@ -101,8 +99,6 @@ export default function StoreCreatePage() {
const [categoryTree, setCategoryTree] = useState<StoreCategoryNode[]>([]);
const [locating, setLocating] = useState(false);
const [mapPickerOpen, setMapPickerOpen] = useState(false);
const draftSaveDisabledRef = useRef(false);
@@ -662,31 +658,6 @@ export default function StoreCreatePage() {
<textarea rows={2} placeholder="请输入详细门牌号" value={form.address} onChange={(e) => patchForm({ address: e.target.value })} />
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8, flexWrap: 'wrap' }}>
<button
type="button"
className="partner-btn-outline"
style={{ padding: '8px 12px', fontSize: 13 }}
disabled={locating}
onClick={() => {
void (async () => {
setLocating(true);
try {
const pos = await locateStorePosition();
patchForm({
latitude: String(pos.latitude),
longitude: String(pos.longitude),
});
toastSuccess('已获取当前坐标');
} catch (e) {
toastError(e instanceof Error ? e.message : '定位失败');
} finally {
setLocating(false);
}
})();
}}
>
{locating ? '定位中…' : '获取当前位置'}
</button>
<button
type="button"
className="partner-btn-outline"
@@ -696,11 +667,12 @@ export default function StoreCreatePage() {
</button>
<span className="label-md text-muted">
{formatStoreCoords(form.latitude, form.longitude)
? `坐标:${formatStoreCoords(form.latitude, form.longitude)}`
: '未定位(可定位或地图选点)'}
{form.latitude.trim() && form.longitude.trim() ? '已选点' : '未选点'}
</span>
</div>
{/* 经纬度仅内存/提交用,界面不展示 */}
<input type="hidden" name="latitude" value={form.latitude} readOnly />
<input type="hidden" name="longitude" value={form.longitude} readOnly />
</div>
@@ -1164,9 +1136,7 @@ export default function StoreCreatePage() {
longitude: String(loc.longitude),
...(loc.address && !form.address.trim() ? { address: loc.address } : {}),
});
toastSuccess(
`已选点 ${loc.latitude.toFixed(6)}, ${loc.longitude.toFixed(6)}${loc.name ? `${loc.name}` : ''}`,
);
toastSuccess(loc.name ? `已选定:${loc.name}` : '位置已选定');
}}
/>
+4 -41
View File
@@ -6,7 +6,6 @@ import { toastError, toastSuccess } from '../lib/toast';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { canManagePartnerStore } from '../lib/partnerAccess';
import { normalizeStringArray, patchEnvPhotoAt } from '../lib/storeDraft';
import { formatStoreCoords, locateStorePosition } from '../lib/storeLocate';
import OssUploadField from '../components/OssUploadField';
import TencentLocPickerOverlay from '../components/TencentLocPickerOverlay';
import {
@@ -55,7 +54,6 @@ export default function StoreDetailPage() {
const [statusSaving, setStatusSaving] = useState(false);
const [saving, setSaving] = useState(false);
const [mediaSaving, setMediaSaving] = useState(false);
const [locating, setLocating] = useState(false);
const [mapPickerOpen, setMapPickerOpen] = useState(false);
const [actionError, setActionError] = useState('');
const [closeConfirmOpen, setCloseConfirmOpen] = useState(false);
@@ -346,35 +344,6 @@ export default function StoreDetailPage() {
<textarea disabled={readOnly} rows={2} value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} />
{!readOnly ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8, flexWrap: 'wrap' }}>
<button
type="button"
className="partner-btn-outline"
style={{ padding: '8px 12px', fontSize: 13 }}
disabled={locating}
onClick={() => {
void (async () => {
setLocating(true);
setActionError('');
try {
const pos = await locateStorePosition();
setForm((prev) => ({
...prev,
latitude: String(pos.latitude),
longitude: String(pos.longitude),
}));
toastSuccess('已获取当前坐标');
} catch (e) {
const msg = e instanceof Error ? e.message : '定位失败';
setActionError(msg);
toastError(msg);
} finally {
setLocating(false);
}
})();
}}
>
{locating ? '定位中…' : '获取当前位置'}
</button>
<button
type="button"
className="partner-btn-outline"
@@ -384,16 +353,12 @@ export default function StoreDetailPage() {
</button>
<span className="label-md text-muted">
{formatStoreCoords(form.latitude, form.longitude)
? `坐标:${formatStoreCoords(form.latitude, form.longitude)}`
: '未定位'}
{form.latitude.trim() && form.longitude.trim() ? '已选点' : '未选点'}
</span>
</div>
) : formatStoreCoords(form.latitude, form.longitude) ? (
<p className="label-md text-muted" style={{ marginTop: 6 }}>
{formatStoreCoords(form.latitude, form.longitude)}
</p>
) : null}
<input type="hidden" name="latitude" value={form.latitude} readOnly />
<input type="hidden" name="longitude" value={form.longitude} readOnly />
</div>
<div className="partner-field">
<label></label>
@@ -523,9 +488,7 @@ export default function StoreDetailPage() {
longitude: String(loc.longitude),
...(loc.address && !prev.address.trim() ? { address: loc.address } : {}),
}));
toastSuccess(
`已选点 ${loc.latitude.toFixed(6)}, ${loc.longitude.toFixed(6)}${loc.name ? `${loc.name}` : ''}`,
);
toastSuccess(loc.name ? `已选定:${loc.name}` : '位置已选定');
}}
/>
</div>
+54 -1
View File
@@ -1,13 +1,66 @@
import './lib/text-encoding-polyfill';
import { PropsWithChildren } from 'react';
import { PropsWithChildren, useRef } from 'react';
import Taro, { useDidShow } from '@tarojs/taro';
import WechatShareBootstrap from './components/WechatShareBootstrap';
import { patchTaroH5Hooks } from './lib/patch-taro-h5-hooks';
import { handleWechatOrderConfirmShow } from './lib/wechat-order-confirm';
import './app.css';
// H5:在首屏 page hooks 执行前,把 Taro.useDidShow 等绑到与 createReactApp 同一份 runtime
patchTaroH5Hooks();
function App({ children }: PropsWithChildren) {
const handlingRef = useRef(false);
useDidShow((options?: {
referrerInfo?: {
appId?: string;
extraData?: { status?: string; errormsg?: string; req_extradata?: Record<string, string> };
};
}) => {
if (process.env.TARO_ENV !== 'weapp') return;
if (handlingRef.current) return;
const referrerInfo =
options?.referrerInfo ||
(typeof Taro.getEnterOptionsSync === 'function'
? (
Taro.getEnterOptionsSync() as {
referrerInfo?: {
appId?: string;
extraData?: {
status?: string;
errormsg?: string;
req_extradata?: Record<string, string>;
};
};
}
).referrerInfo
: undefined);
if (!referrerInfo?.appId) return;
handlingRef.current = true;
void handleWechatOrderConfirmShow({ referrerInfo })
.then((result) => {
if (result.redirectUrl) {
Taro.redirectTo({ url: result.redirectUrl }).catch(() => {
Taro.reLaunch({ url: result.redirectUrl! });
});
return;
}
if (!result.handled || !result.orderId) return;
const pages = Taro.getCurrentPages();
const cur = pages[pages.length - 1] as { route?: string } | undefined;
const route = cur?.route || '';
if (route.includes('pickup-receive')) {
Taro.redirectTo({ url: '/pages/orders/index?tab=all' }).catch(() => {});
}
})
.finally(() => {
handlingRef.current = false;
});
});
return (
<>
<WechatShareBootstrap />
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

+3
View File
@@ -1,6 +1,7 @@
import Taro from '@tarojs/taro';
import { ClientApp } from '@dukang/shared-types';
import { forceReloadAfterAccountMerge } from './auth-nav';
import { resetStoresSessionBootstrap } from './stores-session';
function resolveApiBase(): string {
const origin =
@@ -56,6 +57,8 @@ export function isLoggedIn(): boolean {
export function logout() {
clearAuth();
// 主动退出才重置门店「当次登录」会话;401 清 token 不要打断门店筛选
resetStoresSessionBootstrap();
Taro.reLaunch({ url: '/pages/home/index' });
}
+135
View File
@@ -0,0 +1,135 @@
/**
* 门店列表「当次登录」会话 —— 用 Taro Storage 持久化,
* 避免模块多实例 / globalThis 不可靠导致切 tab 后当成首次进入。
* 仅 logout 时 clear。
*/
import Taro from '@tarojs/taro';
export type StoresSessionRegion = {
province: string;
city: string;
district: string;
};
export type StoresSessionCategory = {
parentId: string;
parentName: string;
childId: string;
childName: string;
};
export type StoresListCache = {
cityKey: string;
cityCode: string;
listRegion: StoresSessionRegion;
items: unknown[];
filterRegion: StoresSessionRegion;
keyword: string;
keywordInput: string;
category: StoresSessionCategory;
};
type StoresSession = {
bootstrapped: boolean;
cache: StoresListCache | null;
};
const STORAGE_KEY = 'dukang_stores_session_v1';
let memory: StoresSession | null = null;
function emptySession(): StoresSession {
return { bootstrapped: false, cache: null };
}
function readSession(): StoresSession {
if (memory) return memory;
try {
const raw = Taro.getStorageSync(STORAGE_KEY);
if (!raw) {
memory = emptySession();
return memory;
}
const parsed = (typeof raw === 'string' ? JSON.parse(raw) : raw) as Partial<StoresSession>;
memory = {
bootstrapped: !!parsed.bootstrapped,
cache: (parsed.cache as StoresListCache | null) ?? null,
};
return memory;
} catch {
memory = emptySession();
return memory;
}
}
function writeSession(next: StoresSession) {
memory = next;
try {
Taro.setStorageSync(STORAGE_KEY, JSON.stringify(next));
} catch {
/* ignore quota */
}
}
export function isStoresSessionBootstrapped(): boolean {
return readSession().bootstrapped;
}
export function markStoresSessionBootstrapped(): void {
const cur = readSession();
writeSession({ ...cur, bootstrapped: true });
}
export function getStoresListCache(): StoresListCache | null {
return readSession().cache;
}
export function setStoresListCache(cache: StoresListCache | null): void {
const cur = readSession();
writeSession({ ...cur, bootstrapped: true, cache });
}
export function patchStoresFilterCache(
patch: Partial<
Pick<StoresListCache, 'filterRegion' | 'keyword' | 'keywordInput' | 'category'>
>,
): void {
const cur = readSession();
if (!cur.cache) {
// 列表尚未写入时也要记下用户筛选,避免切回丢失
writeSession({
bootstrapped: true,
cache: {
cityKey: '',
cityCode: '',
listRegion: patch.filterRegion ?? { province: '', city: '', district: '' },
items: [],
filterRegion: patch.filterRegion ?? { province: '', city: '', district: '' },
keyword: patch.keyword ?? '',
keywordInput: patch.keywordInput ?? '',
category: patch.category ?? {
parentId: '',
parentName: '',
childId: '',
childName: '',
},
},
});
return;
}
writeSession({
...cur,
bootstrapped: true,
cache: { ...cur.cache, ...patch },
});
}
export function resetStoresSessionBootstrap(): void {
memory = emptySession();
try {
Taro.removeStorageSync(STORAGE_KEY);
} catch {
/* ignore */
}
}
@@ -0,0 +1,299 @@
import Taro from '@tarojs/taro';
import { request, toast } from './api';
import { fetchClientConfig } from './pay-wechat';
/** 微信确认收货组件来源 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 OpenBusinessViewOptions = {
businessType: string;
extraData: Record<string, string>;
success?: () => void;
fail?: (err: { errMsg?: string }) => void;
complete?: () => void;
};
type MiniWx = {
openBusinessView?: (opts: OpenBusinessViewOptions) => void;
};
/**
* 取小程序原生 wx.openBusinessView。
* 官方兼容写法:`if (wx.openBusinessView) { ... }`(不要用 canIUse 挡业务组件)。
* Taro 未封装该 API;模块作用域下可能读不到全局 wx,需多重回退。
*/
function getOpenBusinessView(): ((opts: OpenBusinessViewOptions) => void) | null {
if (process.env.TARO_ENV !== 'weapp') return null;
const candidates: Array<MiniWx | null | undefined> = [];
try {
// eslint-disable-next-line no-undef
if (typeof wx !== 'undefined') candidates.push(wx as MiniWx);
} catch {
/* ignore */
}
const g = globalThis as typeof globalThis & { wx?: MiniWx };
candidates.push(g.wx);
try {
// 跳出 bundler 模块作用域,读微信运行时全局
const fromRuntime = new Function(
'return typeof wx !== "undefined" ? wx : null',
)() as MiniWx | null;
candidates.push(fromRuntime);
} catch {
/* ignore */
}
for (const api of candidates) {
if (api && typeof api.openBusinessView === 'function') {
return api.openBusinessView.bind(api);
}
}
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;
}
}
function normalizePayload(payload?: WechatConfirmPayload | null): WechatConfirmPayload {
return {
merchantId: payload?.merchantId?.trim() || undefined,
merchantTradeNo: payload?.merchantTradeNo?.trim() || undefined,
transactionId: payload?.transactionId?.trim() || undefined,
};
}
async function resolveConfirmPayload(
orderId: string,
hint?: WechatConfirmPayload | null,
): Promise<WechatConfirmPayload> {
const fromHint = normalizePayload(hint);
if (fromHint.transactionId || (fromHint.merchantId && fromHint.merchantTradeNo)) {
return fromHint;
}
const order = await request<{
orderNo?: string;
payExternalNo?: string | null;
payment?: { externalNo?: string | null } | null;
wechatConfirm?: WechatConfirmPayload | null;
}>(`/trade/orders/${orderId}`);
const fromApi = normalizePayload(order.wechatConfirm);
if (fromApi.transactionId || (fromApi.merchantId && fromApi.merchantTradeNo)) {
return fromApi;
}
const transactionId =
order.payExternalNo?.trim() || order.payment?.externalNo?.trim() || undefined;
return normalizePayload({
transactionId,
merchantTradeNo: order.orderNo,
merchantId: fromApi.merchantId,
});
}
/**
* 拉起微信「确认收货」半屏组件。
* @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) {
console.warn('[wechat-order-confirm] openBusinessView unavailable', {
taroEnv: process.env.TARO_ENV,
});
return Promise.resolve('unsupported');
}
const payload = normalizePayload(opts.payload);
const transactionId = payload.transactionId;
const merchantId = payload.merchantId;
const merchantTradeNo = payload.merchantTradeNo;
if (!transactionId && !(merchantId && merchantTradeNo)) {
console.warn('[wechat-order-confirm] missing pay ref', payload);
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) => {
let settled = false;
const done = (mode: 'opened' | 'unsupported' | 'missing_pay_ref') => {
if (settled) return;
settled = true;
resolve(mode);
};
try {
open({
businessType: 'weappOrderConfirm',
extraData,
success: () => done('opened'),
fail: (err) => {
Taro.removeStorageSync(PENDING_KEY);
console.error('[wechat-order-confirm] openBusinessView fail', err, extraData);
toast(err?.errMsg || '无法打开微信确认收货,请稍后重试');
done('unsupported');
},
});
} catch (err) {
Taro.removeStorageSync(PENDING_KEY);
console.error('[wechat-order-confirm] openBusinessView throw', err);
toast('无法打开微信确认收货组件');
done('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 };
}
}
async function confirmLocally(opts: {
orderId: string;
onSitePickup?: boolean;
onLocalSuccess?: () => void | Promise<void>;
}): Promise<'local'> {
await request(`/trade/orders/${opts.orderId}/confirm-receive`, {
method: 'POST',
data: {
onSitePickup: !!opts.onSitePickup,
source: 'USER',
},
});
await opts.onLocalSuccess?.();
return 'local';
}
/**
* 统一入口:
* - 小程序 + 真实支付:必须拉起 weappOrderConfirm,禁止静默降级
* - Mock / H5:本地确认
*/
export async function confirmOrderReceive(opts: {
orderId: string;
wechatConfirm?: WechatConfirmPayload | null;
onSitePickup?: boolean;
redirectUrl?: string;
onLocalSuccess?: () => void | Promise<void>;
}): Promise<'wechat' | 'local'> {
const isWeapp = process.env.TARO_ENV === 'weapp';
if (!isWeapp) {
return confirmLocally(opts);
}
let mockPay = false;
try {
const cfg = await fetchClientConfig();
mockPay = !!cfg.mockPay;
} catch {
mockPay = false;
}
if (mockPay) {
return confirmLocally(opts);
}
const payload = await resolveConfirmPayload(opts.orderId, opts.wechatConfirm);
const mode = await openWechatOrderConfirm({
orderId: opts.orderId,
payload,
redirectUrl: opts.redirectUrl,
});
if (mode === 'opened') return 'wechat';
if (mode === 'missing_pay_ref') {
throw new Error('缺少微信支付单号,无法打开确认收货组件');
}
throw new Error('当前环境无法打开微信确认收货组件,请用微信最新版打开小程序后重试');
}
+3 -2
View File
@@ -1,5 +1,5 @@
import { useCallback, useMemo, useState } from 'react';
import { View, Text } from '@tarojs/components';
import { View, Text, Image } from '@tarojs/components';
import Taro, { useDidShow, usePullDownRefresh, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import ShareNavButton from '../../components/ShareNavButton';
@@ -13,6 +13,7 @@ import {
DEFAULT_SHARE_TITLE,
toWeappShareMessage,
} from '../../lib/wechat-share';
import iconBenefit from '../../assets/tabbar/benefit-active.png';
type BenefitSummary = {
totalBalance: number;
@@ -153,7 +154,7 @@ export default function BenefitPage() {
</View>
</View>
<View className="benefit-hero-logo">
<Text></Text>
<Image className="benefit-hero-logo-img" src={iconBenefit} mode="aspectFit" />
</View>
</View>
<View
+5 -17
View File
@@ -38,8 +38,8 @@ type MiniHomeConfig = {
const AROMA_TABS = [
{ key: 'QINGXIANG', label: '清香型' },
{ key: 'NONGXIANG', label: '浓香型' },
{ key: 'JIANGXIANG', label: '酱香型' },
{ key: 'NONGXIANG', label: '浓香型' },
] as const;
export default function HomePage() {
@@ -111,21 +111,6 @@ export default function HomePage() {
})();
});
const availableAromas = useMemo(
() =>
AROMA_TABS.filter((item) =>
products.some((product) => product.aromaType === item.key),
),
[products],
);
useEffect(() => {
if (loading || availableAromas.length === 0) return;
if (!availableAromas.some((item) => item.key === tab)) {
setTab(availableAromas[0].key);
}
}, [availableAromas, loading, tab]);
function openProductDetail(id: string) {
Taro.navigateTo({ url: `/pages/product-detail/index?id=${id}` });
}
@@ -187,7 +172,7 @@ export default function HomePage() {
<View className="home-aroma-nav">
<View className="home-aroma-tabs">
{availableAromas.map((t) => (
{AROMA_TABS.map((t) => (
<Text
key={t.key}
className={`home-aroma-tab${tab === t.key ? ' home-aroma-tab--active' : ''}`}
@@ -205,6 +190,9 @@ export default function HomePage() {
{!loading && products.length === 0 ? (
<View className="home-empty"></View>
) : null}
{!loading && products.length > 0 && filtered.length === 0 ? (
<View className="home-empty">线</View>
) : null}
{!loading &&
filtered.map((p) => {
const thumb = getProductMainImage(p);
+27 -16
View File
@@ -23,24 +23,33 @@ import {
} from '../../lib/mini-wechat-profile';
import { isLoggedIn, logout, request, toast, type UserProfile } from '../../lib/api';
import { isWechatEnv } from '../../lib/weixin';
import { maskPhone } from '../../lib/phone';
import {
DEFAULT_SHARE_DESC,
DEFAULT_SHARE_TITLE,
toWeappShareMessage,
} from '../../lib/wechat-share';
import iconPendingPay from '../../assets/icons/待付款.png';
import iconPaid from '../../assets/icons/已付款.png';
import iconCompleted from '../../assets/icons/已完成.png';
import iconAddress from '../../assets/icons/地址管理.png';
import iconStores from '../../assets/icons/可用门店.png';
import iconCs from '../../assets/icons/联系客服.png';
import iconQualification from '../../assets/icons/资质公示.png';
import iconAbout from '../../assets/icons/关于我们.png';
const ORDER_SHORTCUTS = [
{ tab: 'pending_pay', icon: '付', label: '待付款' },
{ tab: 'paid', icon: '包', label: '已付款' },
{ tab: 'completed', icon: '成', label: '已完成' },
{ tab: 'pending_pay', icon: iconPendingPay, label: '待付款' },
{ tab: 'paid', icon: iconPaid, label: '已付款' },
{ tab: 'completed', icon: iconCompleted, label: '已完成' },
] as const;
const SERVICES = [
{ icon: '址', label: '地址管理', url: '/pages/addresses/index' },
{ icon: '店', label: '可用门店', tab: '/pages/stores/index' },
{ icon: '服', label: '联系客服', url: '/pages/customer-service/index' },
{ icon: '资', label: '资质公示', action: 'qualification' as const },
{ icon: '关', label: '关于我们', action: 'about' as const },
{ icon: iconAddress, label: '地址管理', url: '/pages/addresses/index' },
{ icon: iconStores, label: '可用门店', tab: '/pages/stores/index' },
{ icon: iconCs, label: '联系客服', url: '/pages/customer-service/index' },
{ icon: iconQualification, label: '资质公示', action: 'qualification' as const },
{ icon: iconAbout, label: '关于我们', action: 'about' as const },
] as const;
const isWeapp = process.env.TARO_ENV === 'weapp';
@@ -341,13 +350,15 @@ export default function MinePage() {
const nickname = display.nickname || '用户';
const needProfileFill = isWeapp && needsWxProfileFill(display);
const avatarProfileReady = isWeapp ? !needProfileFill : hasWechat;
const memberLabel = needProfileFill
? '点击头像完善资料'
: isWeapp || hasWechat
? '好客会员'
: canWxAuth
const maskedPhone = profile?.phone ? maskPhone(String(profile.phone)) : '';
// 昵称下优先展示脱敏手机号;无手机号时再提示完善资料/授权
const memberLabel =
maskedPhone ||
(needProfileFill
? '点击头像完善资料'
: !isWeapp && !hasWechat && canWxAuth
? '点击头像授权'
: '好客会员';
: '未绑定手机');
const avatarClickable = isWeapp || (!hasWechat && canWxAuth);
const previewAvatar = draftAvatarUrl || display.avatarUrl;
@@ -455,7 +466,7 @@ export default function MinePage() {
}
>
<View className="mine-order-icon">
<Text>{item.icon}</Text>
<Image className="mine-order-icon-img" src={item.icon} mode="aspectFit" />
</View>
{count > 0 ? (
<Text className="mine-order-badge">{count > 99 ? '99+' : count}</Text>
@@ -479,7 +490,7 @@ export default function MinePage() {
onClick={() => handleService(item)}
>
<View className="mine-service-icon">
<Text>{item.icon}</Text>
<Image className="mine-service-icon-img" src={item.icon} mode="aspectFit" />
</View>
<Text className="mine-service-label">{item.label}</Text>
</View>
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useRouter, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
import Taro, { useDidShow, useRouter, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
import ShareNavButton from '../../components/ShareNavButton';
@@ -9,6 +9,7 @@ import ContactCsButton from '../../components/ContactCsButton';
import { request, toast } from '../../lib/api';
import { buildPayUrl } from '../../lib/checkout-nav';
import { maskPhone } from '../../lib/phone';
import { confirmOrderReceive, type WechatConfirmPayload } from '../../lib/wechat-order-confirm';
import {
DEFAULT_SHARE_DESC,
DEFAULT_SHARE_TITLE,
@@ -43,6 +44,7 @@ type OrderDetail = {
isProxyOrder?: boolean;
proxyPartnerName?: string | null;
items?: OrderItem[];
wechatConfirm?: WechatConfirmPayload | null;
};
const STATUS_LABELS: Record<string, string> = {
@@ -82,6 +84,14 @@ export default function OrderDetailPage() {
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
}, [orderId]);
useDidShow(() => {
if (!orderId) return;
// 从微信确认收货组件返回后刷新
request<OrderDetail>(`/trade/orders/${orderId}`)
.then(setOrder)
.catch(() => {});
});
const isReship = !!order?.originOrderId;
const isProxy = !!order && (order.isProxyOrder || order.orderType === 'PROXY');
const canPay = !!order && order.status === 'PENDING_PAY' && !isReship;
@@ -127,7 +137,9 @@ export default function OrderDetailPage() {
const { confirm } = await Taro.showModal({
title: '确认收货?',
content: '请确认已收到商品。确认后订单将完成,好客权益可正常使用。',
content: isWeapp
? '将打开微信确认收货,完成后订单即完结,无需再点服务通知。'
: '请确认已收到商品。确认后订单将完成,好客权益可正常使用。',
confirmText: '确认收货',
cancelText: '再想想',
});
@@ -135,12 +147,19 @@ export default function OrderDetailPage() {
setConfirming(true);
try {
const updated = await request<OrderDetail>(`/trade/orders/${order.id}/confirm-receive`, {
method: 'POST',
data: {},
const mode = await confirmOrderReceive({
orderId: order.id,
wechatConfirm: order.wechatConfirm,
onLocalSuccess: async () => {
const updated = await request<OrderDetail>(`/trade/orders/${order.id}`);
setOrder(updated);
toast('已确认收货');
},
});
setOrder(updated);
toast('已确认收货');
if (mode === 'wechat') {
// 回跳后由 App.onShow / 本页 useDidShow 处理
return;
}
} catch (e) {
toast(e instanceof Error ? e.message : '确认收货失败');
} finally {
+17 -6
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import { View, Text, Image } from '@tarojs/components';
import Taro, { usePullDownRefresh, useRouter } from '@tarojs/taro';
import Taro, { useDidShow, usePullDownRefresh, useRouter } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
import { request, toast } from '../../lib/api';
@@ -15,10 +15,16 @@ const TABS = [
{ key: 'completed', label: '已完成' },
] as const;
/** 兼容历史链接 tab=done */
function normalizeOrdersTab(raw?: string): string {
if (!raw) return 'all';
if (raw === 'done') return 'completed';
return TABS.some((t) => t.key === raw) ? raw : 'all';
}
function orderStatusLabel(tab: string, status?: string): string {
if (tab !== 'all') {
return TABS.find((t) => t.key === tab)?.label || status || '';
}
const tabLabel = TABS.find((t) => t.key === tab)?.label;
if (tab !== 'all' && tabLabel) return tabLabel;
if (!status) return '';
return ORDER_STATUS_LABELS[status] || status;
}
@@ -47,8 +53,7 @@ type OrderRow = {
export default function OrdersPage() {
const router = useRouter();
const initialTab = (router.params.tab as string) || 'all';
const [tab, setTab] = useState(initialTab);
const [tab, setTab] = useState(() => normalizeOrdersTab(router.params.tab as string));
const [orders, setOrders] = useState<OrderRow[]>([]);
const [loading, setLoading] = useState(true);
@@ -72,6 +77,12 @@ export default function OrdersPage() {
void loadOrders();
}, [loadOrders]);
useDidShow(() => {
const next = normalizeOrdersTab(router.params.tab as string);
if (next !== tab) setTab(next);
else void loadOrders();
});
usePullDownRefresh(() => {
void loadOrders().finally(() => Taro.stopPullDownRefresh());
});
@@ -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;
@@ -20,10 +23,19 @@ type OrderDetail = {
mainImageUrl?: string | null;
carouselUrls?: string[] | null;
};
items?: Array<{
productName?: string;
productSpec?: string;
productImage?: string;
quantity?: number;
}>;
imageUrl?: string | null;
mainImageUrl?: string | null;
wechatConfirm?: WechatConfirmPayload | null;
};
const ORDERS_ALL_URL = '/pages/orders/index?tab=all';
export default function PickupReceivePage() {
const router = useRouter();
const orderId = router.params.id ?? router.params.orderId ?? '';
@@ -45,17 +57,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: ORDERS_ALL_URL,
onLocalSuccess: async () => {
toast('确认收货成功', 'success');
setTimeout(() => {
Taro.redirectTo({ url: ORDERS_ALL_URL });
}, 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 {
@@ -63,9 +91,11 @@ export default function PickupReceivePage() {
}
}
const name = order?.productName || order?.product?.name || '商品';
const spec = order?.productSpec || order?.product?.spec;
const item = order?.items?.[0];
const name = item?.productName || order?.productName || order?.product?.name || '商品';
const spec = item?.productSpec || order?.productSpec || order?.product?.spec;
const image =
(item?.productImage || '').trim() ||
order?.mainImageUrl ||
order?.imageUrl ||
(order?.product ? getProductMainImage(order.product) : '') ||
+220 -58
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { View, Text, Image, Input } from '@tarojs/components';
import Taro, { useDidShow, usePullDownRefresh, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
@@ -28,6 +28,13 @@ import {
import { FALLBACK_CITY_CODE } from '../../lib/product-images';
import { formatDistanceMeters } from '../../lib/geo';
import { request, toast } from '../../lib/api';
import {
getStoresListCache,
isStoresSessionBootstrapped,
markStoresSessionBootstrapped,
patchStoresFilterCache,
setStoresListCache,
} from '../../lib/stores-session';
import {
DEFAULT_SHARE_DESC,
DEFAULT_SHARE_TITLE,
@@ -55,20 +62,49 @@ type Store = {
distanceMeters?: number | null;
};
function makeCityKey(region: Pick<RegionSelection, 'province' | 'city'>): string {
return `${region.province}|${region.city}`;
}
/**
* 定位城市若未开城,接口会 fallback 到郑州 cityCode
* 筛选器必须与真实拉取城市一致,否则列表被客户端滤空。
*/
function regionForCatalogFetch(resolved: {
openCity: boolean;
cityCode?: string;
region: RegionSelection;
}): { cityCode: string; region: RegionSelection } {
const cityCode = getCityCodeForCatalog(resolved);
if (resolved.openCity && resolved.cityCode) {
return { cityCode, region: toCityWideRegion(resolved.region) };
}
return { cityCode: FALLBACK_CITY_CODE, region: toCityWideRegion(DEFAULT_REGION) };
}
export default function StoresPage() {
const [stores, setStores] = useState<Store[]>([]);
const [loading, setLoading] = useState(true);
const [keywordInput, setKeywordInput] = useState('');
const [keyword, setKeyword] = useState('');
const [region, setRegion] = useState<RegionSelection>(DEFAULT_REGION);
const cached = getStoresListCache();
const [stores, setStores] = useState<Store[]>(() => (cached?.items as Store[] | undefined) ?? []);
const [loading, setLoading] = useState(() => !cached && !isStoresSessionBootstrapped());
const [keywordInput, setKeywordInput] = useState(() => cached?.keywordInput ?? '');
const [keyword, setKeyword] = useState(() => cached?.keyword ?? '');
const [region, setRegion] = useState<RegionSelection>(
() => cached?.filterRegion ?? cached?.listRegion ?? DEFAULT_REGION,
);
const [regionOpen, setRegionOpen] = useState(false);
const [category, setCategory] = useState<CategorySelection>(EMPTY_CATEGORY);
const [category, setCategory] = useState<CategorySelection>(
() => cached?.category ?? EMPTY_CATEGORY,
);
const [categoryOpen, setCategoryOpen] = useState(false);
const [categoryTree, setCategoryTree] = useState<StoreCategoryNode[]>([]);
const [cityCode, setCityCode] = useState<string>(FALLBACK_CITY_CODE);
const [userCoords, setUserCoords] = useState<UserCoords | null>(() => readCachedUserCoords());
const [locating, setLocating] = useState(false);
const fetchCityKeyRef = useRef<string | null>(cached?.cityKey ?? null);
const fetchSeqRef = useRef(0);
const regionRef = useRef(region);
regionRef.current = region;
const regionLabel = formatRegionLabel(region);
const categoryLabel = formatCategoryLabel(category);
const showBootLoading = loading && stores.length === 0;
const childIdsByParent = useMemo(() => {
const map = new Map<string, string[]>();
@@ -81,13 +117,91 @@ export default function StoresPage() {
return map;
}, [categoryTree]);
async function fetchStores(
nextCode: string,
coords: UserCoords | null,
cityKey: string,
listRegion: RegionSelection,
/** 写入会话的筛选器;默认保留用户当前选择 */
filterRegion: RegionSelection = regionRef.current,
) {
const seq = ++fetchSeqRef.current;
const qs = new URLSearchParams();
if (nextCode) qs.set('cityCode', nextCode);
if (coords) {
qs.set('lat', String(coords.latitude));
qs.set('lng', String(coords.longitude));
}
const path = qs.toString() ? `/stores?${qs}` : '/stores';
try {
const list = await request<Store[]>(path);
if (seq !== fetchSeqRef.current) return;
const items = Array.isArray(list) ? list : [];
setStores(items);
fetchCityKeyRef.current = cityKey;
const prev = getStoresListCache();
setStoresListCache({
cityKey,
cityCode: nextCode,
listRegion: toCityWideRegion(listRegion),
items,
filterRegion,
keyword: prev?.keyword ?? keyword,
keywordInput: prev?.keywordInput ?? keywordInput,
category: prev?.category ?? category,
});
} catch (e) {
if (seq !== fetchSeqRef.current) return;
toast(e instanceof Error ? e.message : '加载失败');
} finally {
if (seq === fetchSeqRef.current) setLoading(false);
}
}
/**
* 首次进入:弹窗 + 定位 + 拉列表。
* 同次再切回:只同步 tab 选中态,不改筛选、不拉接口、不 setState。
*/
useDidShow(() => {
syncTabBarSelected(1);
void resolveUserCity().then((resolved) => {
setRegion(toCityWideRegion(resolved.region));
setCityCode(getCityCodeForCatalog(resolved));
setUserCoords(readCachedUserCoords());
});
if (isStoresSessionBootstrapped()) {
return;
}
markStoresSessionBootstrapped();
void (async () => {
const { confirm } = await Taro.showModal({
title: '获取当前位置',
content: '是否允许获取当前位置来搜索附近门店?拒绝后将按默认城市展示,可下拉刷新重新定位。',
confirmText: '允许',
cancelText: '暂不',
}).catch(() => ({ confirm: false, cancel: true }));
if (confirm) {
setLoading(true);
const resolved = await resolveUserCity(true);
const { cityCode, region: nextRegion } = regionForCatalogFetch(resolved);
const nextCityKey = makeCityKey(nextRegion);
setRegion(nextRegion);
regionRef.current = nextRegion;
await fetchStores(
cityCode,
readCachedUserCoords(),
nextCityKey,
nextRegion,
nextRegion,
);
return;
}
const nextRegion = toCityWideRegion(DEFAULT_REGION);
const nextCityKey = makeCityKey(nextRegion);
setRegion(nextRegion);
regionRef.current = nextRegion;
setLoading(true);
await fetchStores(FALLBACK_CITY_CODE, null, nextCityKey, nextRegion, nextRegion);
})();
});
useEffect(() => {
@@ -96,49 +210,29 @@ export default function StoresPage() {
.catch(() => setCategoryTree([]));
}, []);
const loadStores = useCallback(() => {
setLoading(true);
const qs = new URLSearchParams();
if (cityCode) qs.set('cityCode', cityCode);
const coords = userCoords ?? readCachedUserCoords();
if (coords) {
qs.set('lat', String(coords.latitude));
qs.set('lng', String(coords.longitude));
}
const path = qs.toString() ? `/stores?${qs}` : '/stores';
return request<Store[]>(path)
.then((list) => setStores(Array.isArray(list) ? list : []))
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'))
.finally(() => setLoading(false));
}, [cityCode, userCoords]);
useEffect(() => {
void loadStores();
}, [loadStores]);
usePullDownRefresh(() => {
void (async () => {
try {
const resolved = await resolveUserCity(true);
setRegion(toCityWideRegion(resolved.region));
const nextCode = getCityCodeForCatalog(resolved);
setCityCode(nextCode);
const coords = readCachedUserCoords();
setUserCoords(coords);
setLoading(true);
const qs = new URLSearchParams();
if (nextCode) qs.set('cityCode', nextCode);
if (coords) {
qs.set('lat', String(coords.latitude));
qs.set('lng', String(coords.longitude));
const { cityCode, region: nextRegion } = regionForCatalogFetch(resolved);
const nextCityKey = makeCityKey(nextRegion);
setRegion(nextRegion);
regionRef.current = nextRegion;
if (fetchCityKeyRef.current !== nextCityKey) {
setStores([]);
setLoading(true);
}
const path = qs.toString() ? `/stores?${qs}` : '/stores';
const list = await request<Store[]>(path);
setStores(Array.isArray(list) ? list : []);
await fetchStores(
cityCode,
readCachedUserCoords(),
nextCityKey,
nextRegion,
nextRegion,
);
} catch (e) {
toast(e instanceof Error ? e.message : '加载失败');
} finally {
setLoading(false);
} finally {
Taro.stopPullDownRefresh();
}
})();
@@ -165,7 +259,9 @@ export default function StoresPage() {
});
function applySearch() {
setKeyword(keywordInput.trim());
const next = keywordInput.trim();
setKeyword(next);
patchStoresFilterCache({ keyword: next, keywordInput });
}
function resetFilters() {
@@ -173,6 +269,49 @@ export default function StoresPage() {
setKeyword('');
setCategory(EMPTY_CATEGORY);
setRegion(DEFAULT_REGION);
regionRef.current = DEFAULT_REGION;
patchStoresFilterCache({
keyword: '',
keywordInput: '',
category: EMPTY_CATEGORY,
filterRegion: DEFAULT_REGION,
});
}
async function locateToUserRegion() {
if (locating) return;
const { confirm } = await Taro.showModal({
title: '获取当前位置',
content: '是否允许获取当前位置,并将筛选定位到您所在的城市与区县?',
confirmText: '允许',
cancelText: '暂不',
}).catch(() => ({ confirm: false, cancel: true }));
if (!confirm) return;
setLocating(true);
setLoading(true);
try {
const resolved = await resolveUserCity(true);
// 筛选器用真实省市+区县;拉数仍按开城 cityCode(未开城则郑州)
const filterRegion = resolved.region;
const { cityCode, region: listRegion } = regionForCatalogFetch(resolved);
const nextCityKey = makeCityKey(listRegion);
setRegion(filterRegion);
regionRef.current = filterRegion;
await fetchStores(
cityCode,
readCachedUserCoords(),
nextCityKey,
listRegion,
filterRegion,
);
toast(`已定位到${formatRegionLabel(filterRegion)}`, 'success');
} catch (e) {
toast(e instanceof Error ? e.message : '定位失败');
setLoading(false);
} finally {
setLocating(false);
}
}
function formatHours(store: Store) {
@@ -226,16 +365,32 @@ export default function StoresPage() {
<Text className="store-filter-chip-text">{categoryLabel}</Text>
<Text className="store-filter-chip-arrow"></Text>
</View>
<Text className="store-filter-reset" onClick={resetFilters}>
</Text>
<View
className="store-filter-icon-btn"
onClick={resetFilters}
aria-label="重置筛选"
>
{/* 小程序 View 伪元素不稳定,用 Text 保证真机可见 */}
<Text className="store-filter-icon-glyph"></Text>
</View>
<View
className={`store-filter-icon-btn${locating ? ' store-filter-icon-btn--busy' : ''}`}
onClick={() => {
void locateToUserRegion();
}}
aria-label="获取当前位置"
>
<Text className="store-filter-icon-glyph"></Text>
</View>
</View>
</View>
<View className="store-list">
{loading ? <View className="u-empty"></View> : null}
{!loading && filtered.length === 0 ? <View className="u-empty"></View> : null}
{!loading &&
{showBootLoading ? <View className="u-empty"></View> : null}
{!showBootLoading && filtered.length === 0 ? (
<View className="u-empty"></View>
) : null}
{!showBootLoading &&
filtered.map((s) => (
<View
key={s.id}
@@ -282,14 +437,21 @@ export default function StoresPage() {
value={region}
levels={3}
onClose={() => setRegionOpen(false)}
onConfirm={(next) => setRegion(next)}
onConfirm={(next) => {
setRegion(next);
regionRef.current = next;
patchStoresFilterCache({ filterRegion: next });
}}
/>
<CategoryPicker
open={categoryOpen}
tree={categoryTree}
value={category}
onClose={() => setCategoryOpen(false)}
onConfirm={(next) => setCategory(next)}
onConfirm={(next) => {
setCategory(next);
patchStoresFilterCache({ category: next });
}}
/>
</PageShell>
);
+7
View File
@@ -126,6 +126,13 @@
color: var(--color-heritage-red);
font-weight: 700;
font-size: 18px;
overflow: hidden;
}
.benefit-hero-logo-img {
width: 28px;
height: 28px;
display: block;
}
.benefit-hero-cta {
+14 -10
View File
@@ -255,16 +255,18 @@
.mine-order-icon {
width: 36px;
height: 36px;
border-radius: 10px;
background: rgba(166, 29, 36, 0.08);
display: flex;
align-items: center;
justify-content: center;
color: var(--color-heritage-red);
font-size: 16px;
margin-bottom: 4px;
}
.mine-order-icon-img {
width: 28px;
height: 28px;
display: block;
}
.mine-order-badge {
position: absolute;
top: 2px;
@@ -300,18 +302,20 @@
}
.mine-service-icon {
width: 32px;
height: 32px;
border-radius: 8px;
background: var(--color-surface-container-low);
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-heritage-red);
font-size: 14px;
margin-bottom: 4px;
}
.mine-service-icon-img {
width: 28px;
height: 28px;
display: block;
}
.mine-service-label {
font-size: 11px;
color: var(--color-on-surface);
+16 -4
View File
@@ -116,15 +116,27 @@
color: var(--color-subtle-gray);
}
.store-filter-reset {
.store-filter-icon-btn {
flex-shrink: 0;
width: 36px;
height: 36px;
padding: 0 10px;
border-radius: var(--radius-md);
background: var(--color-surface-container-low);
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
color: var(--color-heritage-red);
box-sizing: border-box;
}
.store-filter-icon-btn--busy {
opacity: 0.5;
}
.store-filter-icon-glyph {
font-size: 18px;
line-height: 1;
font-weight: 700;
color: var(--color-heritage-red);
}
+8
View File
@@ -14,6 +14,14 @@ declare const definePageConfig: (config: Record<string, unknown>) => Record<stri
declare const TARO_APP_API_ORIGIN: string;
/** 微信小程序全局(Taro 未封装的 API 如 openBusinessView 需直接调用) */
declare const wx: {
openBusinessView?: (opts: Record<string, unknown>) => void;
canIUse?: (schema: string) => boolean;
requestPayment?: (opts: Record<string, unknown>) => void;
[key: string]: unknown;
};
declare namespace NodeJS {
interface ProcessEnv {
TARO_ENV: 'weapp' | 'h5' | string;
+7 -1
View File
@@ -21,8 +21,13 @@ export interface AppConfig {
aliyunSmsProxyOrderTemplateCode: string;
aliyunSmsAccessKeyId: string;
aliyunSmsAccessKeySecret: string;
/** 腾讯位置服务 Key(逆地理编码) */
/** 腾讯位置服务 Key(逆地理编码 / 地点搜索 */
tencentLbsKey: string;
/**
* 腾讯位置服务 SecretKeySK
* 控制台开启 WebServiceAPI 签名校验后生成;仅服务端签名用,勿下发前端
*/
tencentLbsSecretKey: string;
/** C 端 H5 落地页(推广码二维码链接前缀) */
userH5Url: string;
}
@@ -127,6 +132,7 @@ export function loadAppConfig(env?: Record<string, string | undefined>): AppConf
aliyunSmsAccessKeyId: e.ALIYUN_SMS_ACCESS_KEY_ID ?? e.OSS_ACCESS_KEY_ID ?? '',
aliyunSmsAccessKeySecret: e.ALIYUN_SMS_ACCESS_KEY_SECRET ?? e.OSS_ACCESS_KEY_SECRET ?? '',
tencentLbsKey: e.TENCENT_LBS_KEY ?? '',
tencentLbsSecretKey: e.TENCENT_LBS_SECRET_KEY ?? '',
userH5Url: (e.USER_H5_URL || DEFAULT_USER_H5_URL).replace(/\/$/, ''),
};
return {
+1 -1
View File
@@ -36,7 +36,7 @@ export const HQ_PERMISSION_CATALOG = [
export type HqPermissionKey = (typeof HQ_PERMISSION_CATALOG)[number]['key'];
/** 危险操作:角色默认与超管自动权限均不含,需在权限分配中显式勾选 */
/** 危险操作:非超管角色默认不含,需在权限分配中显式勾选;超管默认拥有 */
export const HQ_DANGEROUS_PERMISSION_KEYS = [
'users_delete',
'orders_delete',
+32 -2
View File
@@ -96,8 +96,6 @@ export type PartnerProxyOrderPreviewResult = {
export type PartnerProxyOrderCreateRequest = {
phone: string;
customerSmsCode: string;
partnerSmsCode: string;
deliveryMode: PartnerProxyDeliveryMode;
/** 地址配送时必须为 true */
autoReceive?: boolean;
@@ -111,3 +109,35 @@ export type PartnerProxyOrderCreateRequest = {
quantity: number;
promoCodeId?: string;
};
/** 合伙人代下单列表项(与 OrderDto 兼容,附带收货信息) */
export type PartnerProxyOrderListItem = OrderDto & {
receiverName?: string | null;
receiverPhone?: string | null;
receiverAddress?: string | null;
deliveryType?: string | null;
productSpec?: string | null;
imageResource?: { url?: string } | null;
};
export type PartnerProxyOrderListResponse = {
list: PartnerProxyOrderListItem[];
total: number;
page: number;
pageSize: number;
};
/** 总部代下单(线下完成发权益,无需短信验证) */
export type HqProxyOrderCreateRequest = {
phone: string;
deliveryMode: PartnerProxyDeliveryMode;
autoReceive?: boolean;
receiverName?: string;
province?: string;
city?: string;
district?: string;
addressDetail?: string;
productId: string;
quantity: number;
promoCodeId?: string;
};
+7 -2
View File
@@ -55,14 +55,19 @@ WX_API_V3_KEY=
# 微信平台公钥证书 PEM(生产环境必填,用于回调验签;开发可暂留空)
WX_PLATFORM_CERT=
WX_PAY_NOTIFY_URL=https://api.dukanghaoke.com/api/v1/callbacks/wechat/pay
# 小程序消息推送(发货管理确认收货/结算事件):后台 URL 填下方地址,数据格式建议 JSON,加密方式建议安全模式
# https://api.dukanghaoke.com/api/v1/callbacks/wechat/message
WX_MINI_MSG_TOKEN=
WX_MINI_MSG_AES_KEY=
# 企业微信智能机器人总开关(Bot 实例在 HQ「企微机器人」模块创建)
WECOM_AIBOT_ENABLED=false
# 腾讯位置服务(地理编码 / 逆地理 / 地点搜索选点,服务端调用)
# 控制台须开启 WebServiceAPI服务端调用建议 Key 不设域名白名单,或改用 IP 白名单
# (浏览器内嵌官方选点组件已弃用,避免 mapapi.qq.com / formatted_addresses 崩溃)
# 控制台须开启 WebServiceAPI推荐开启「签名校验」并配置下方 SK(服务端自动附 sig)
# 未开签名校验时可只填 KEY;SK 勿下发前端
TENCENT_LBS_KEY=
TENCENT_LBS_SECRET_KEY=
# 阿里云 OSSali-oss@6.x;凭证齐全时直传,缺失则服务端报错)
# RAM 用户需具备 PutObject 权限;可在控制台 Bucket 授权策略中为该 RAM UID 授予读写
@@ -42,6 +42,9 @@ WX_MCH_PRIVATE_KEY=
WX_API_V3_KEY=
WX_PLATFORM_CERT=
WX_PAY_NOTIFY_URL=https://api.dukanghaoke.com/api/v1/callbacks/wechat/pay
# 消息推送 URL: https://api.dukanghaoke.com/api/v1/callbacks/wechat/message
WX_MINI_MSG_TOKEN=
WX_MINI_MSG_AES_KEY=
# 企业微信机器人总开关(实例在 HQ 企微机器人模块维护)
WECOM_AIBOT_ENABLED=false
+19 -2
View File
@@ -593,18 +593,35 @@ model CommonProductItem {
status ProductStatus @default(DRAFT)
sortOrder Int @default(0) @map("sort_order")
allowOnSitePickup Boolean @default(false) @map("allow_on_site_pickup")
/// Online test: only listed phones can see/buy when enabled
visibilityWhitelistEnabled Boolean @default(false) @map("visibility_whitelist_enabled")
coverResourceId BigInt? @map("cover_resource_id") @db.UnsignedBigInt
detailContent Json? @map("detail_content")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
coverResource CommonResource? @relation("ProductCover", fields: [coverResourceId], references: [id], onDelete: SetNull)
orders Order[]
coverResource CommonResource? @relation("ProductCover", fields: [coverResourceId], references: [id], onDelete: SetNull)
orders Order[]
visibilityPhones CommonProductVisibilityPhone[]
@@index([status, aromaType])
@@map("common_product_item")
}
/// Product visibility whitelist phones (match by bound phone)
model CommonProductVisibilityPhone {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
productId BigInt @map("product_id") @db.UnsignedBigInt
phone String @db.VarChar(20)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
product CommonProductItem @relation(fields: [productId], references: [id], onDelete: Cascade)
@@unique([productId, phone])
@@index([phone])
@@map("common_product_visibility_phone")
}
model CommonProductDetailTemplate {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
code String @unique @db.VarChar(32)
@@ -4,10 +4,16 @@ import { TradeModule } from '../modules/trade/trade.module';
import { PrismaModule } from '../common/prisma/prisma.module';
import { WechatPayCallbackController } from './wechat-pay.controller';
import { WechatRefundCallbackController } from './wechat-refund.controller';
import { WechatMessageCallbackController } from './wechat-message.controller';
import { DeliveryCallbackController } from './delivery-track.controller';
@Module({
imports: [IntegrationsModule, TradeModule, PrismaModule],
controllers: [WechatPayCallbackController, WechatRefundCallbackController, DeliveryCallbackController],
controllers: [
WechatPayCallbackController,
WechatRefundCallbackController,
WechatMessageCallbackController,
DeliveryCallbackController,
],
})
export class CallbacksModule {}
@@ -0,0 +1,118 @@
import {
Controller,
Get,
HttpStatus,
Logger,
Post,
Query,
Req,
Res,
} from '@nestjs/common';
import type { Request, Response } from 'express';
import {
decryptWechatEncrypt,
normalizeTradeManageEvent,
parseWechatPushBody,
verifyWechatMsgSignature,
verifyWechatUrlSignature,
} from '../integrations/wechat/wechat-msg-crypto.util';
import { WechatTradeManageService } from '../integrations/wechat/wechat-trade-manage.service';
/**
* 小程序消息推送(发货信息管理事件)。
* 后台配置 URLhttps://{host}/api/v1/callbacks/wechat/message
* 需配置 WX_MINI_MSG_TOKEN;安全模式另需 WX_MINI_MSG_AES_KEY。
*/
@Controller('callbacks/wechat')
export class WechatMessageCallbackController {
private readonly logger = new Logger(WechatMessageCallbackController.name);
constructor(private readonly tradeManage: WechatTradeManageService) {}
@Get('message')
verify(
@Query('signature') signature: string,
@Query('timestamp') timestamp: string,
@Query('nonce') nonce: string,
@Query('echostr') echostr: string,
@Res() res: Response,
) {
const token = (process.env.WX_MINI_MSG_TOKEN || '').trim();
if (!token) {
this.logger.error('WX_MINI_MSG_TOKEN 未配置,无法完成消息推送 URL 验证');
return res.status(HttpStatus.SERVICE_UNAVAILABLE).send('msg token not configured');
}
if (!verifyWechatUrlSignature(token, timestamp, nonce, signature)) {
this.logger.warn('wechat message URL verify failed');
return res.status(HttpStatus.FORBIDDEN).send('invalid signature');
}
return res.status(HttpStatus.OK).send(echostr ?? '');
}
@Post('message')
async receive(
@Req() req: Request & { rawBody?: Buffer },
@Query('signature') signature: string,
@Query('timestamp') timestamp: string,
@Query('nonce') nonce: string,
@Query('msg_signature') msgSignature: string,
@Query('encrypt_type') encryptType: string,
@Res() res: Response,
) {
const token = (process.env.WX_MINI_MSG_TOKEN || '').trim();
if (!token) {
return res.status(HttpStatus.SERVICE_UNAVAILABLE).send('msg token not configured');
}
try {
const raw =
req.rawBody?.toString('utf8') ??
(typeof req.body === 'string'
? req.body
: Buffer.isBuffer(req.body)
? req.body.toString('utf8')
: JSON.stringify(req.body ?? {}));
let body = parseWechatPushBody(raw);
const encrypt = typeof body.Encrypt === 'string' ? body.Encrypt : undefined;
const secure = encryptType === 'aes' || !!encrypt;
if (secure) {
if (!encrypt) {
return res.status(HttpStatus.BAD_REQUEST).send('missing Encrypt');
}
const aesKey = (process.env.WX_MINI_MSG_AES_KEY || '').trim();
if (!aesKey) {
this.logger.error('安全模式推送但未配置 WX_MINI_MSG_AES_KEY');
return res.status(HttpStatus.SERVICE_UNAVAILABLE).send('aes key not configured');
}
if (
!verifyWechatMsgSignature(token, timestamp, nonce, encrypt, msgSignature || String(body.MsgSignature || ''))
) {
return res.status(HttpStatus.FORBIDDEN).send('invalid msg_signature');
}
const appId = (process.env.WX_MINI_APP_ID || process.env.WX_APP_ID || '').trim();
const plain = decryptWechatEncrypt(encrypt, aesKey, appId || undefined);
body = parseWechatPushBody(plain);
} else if (!verifyWechatUrlSignature(token, timestamp, nonce, signature)) {
return res.status(HttpStatus.FORBIDDEN).send('invalid signature');
}
// 云托管探活
if (String(body.action || '') === 'CheckContainerPath') {
return res.status(HttpStatus.OK).send('success');
}
const evt = normalizeTradeManageEvent(body);
if (evt.event) {
await this.tradeManage.handleEvent(evt);
}
return res.status(HttpStatus.OK).send('success');
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`wechat message push failed: ${message}`);
// 仍回 success,避免微信疯狂重试;错误已记日志
return res.status(HttpStatus.OK).send('success');
}
}
}
@@ -7,6 +7,7 @@ import {
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import {
HQ_DANGEROUS_PERMISSION_KEYS,
HQ_ROLE_DEFAULT_PERMISSIONS,
expandHqPermissionKeys,
hasAnySystemSettingsPermission,
@@ -43,8 +44,12 @@ export class HqPermissionsResolver {
const userKeys = userRows.map((r) => r.permissionKey);
if (account.adminRole === 'SUPER_ADMIN') {
// 超管默认拥有业务/管理权限,但不含危险操作;危险权限需按用户显式勾选
return expandHqPermissionKeys([...hqBasePermissionKeys(), ...userKeys]);
// 超管含危险操作(删用户/订单/城市);其他角色仍需在权限分配中显式勾选
return expandHqPermissionKeys([
...hqBasePermissionKeys(),
...HQ_DANGEROUS_PERMISSION_KEYS,
...userKeys,
]);
}
const roleRows = await this.prisma.hqRolePermission.findMany({
@@ -22,6 +22,8 @@ export const HqOperationAction = {
ORDER_SHIP: 'ORDER_SHIP',
ORDER_STATUS_DEBUG: 'ORDER_STATUS_DEBUG',
ORDER_BATCH_DELETE: 'ORDER_BATCH_DELETE',
ORDER_DELETE: 'ORDER_DELETE',
ORDER_PROXY_CREATE: 'ORDER_PROXY_CREATE',
STORE_CREATE: 'STORE_CREATE',
STORE_UPDATE: 'STORE_UPDATE',
STORE_STATUS: 'STORE_STATUS',
@@ -122,6 +124,8 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
[HqOperationAction.ORDER_SHIP]: '订单发货',
[HqOperationAction.ORDER_STATUS_DEBUG]: '订单状态调试',
[HqOperationAction.ORDER_BATCH_DELETE]: '批量删除订单',
[HqOperationAction.ORDER_DELETE]: '删除订单',
[HqOperationAction.ORDER_PROXY_CREATE]: '总部代下单',
[HqOperationAction.STORE_CREATE]: '新增门店',
[HqOperationAction.STORE_UPDATE]: '编辑门店',
[HqOperationAction.STORE_STATUS]: '变更门店状态',
@@ -77,6 +77,25 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
{ key: 'WX_API_V3_KEY', label: 'APIv3 密钥', group: G.wechat, type: 'password', secret: true, requiresRestart: true },
{ key: 'WX_PLATFORM_CERT', label: '微信平台公钥证书', group: G.wechat, type: 'textarea', secret: true, requiresRestart: true },
{ key: 'WX_PAY_NOTIFY_URL', label: '支付回调 URL', group: G.wechat, type: 'string', requiresRestart: false },
{
key: 'WX_MINI_MSG_TOKEN',
label: '小程序消息推送 Token',
group: G.wechat,
type: 'password',
secret: true,
requiresRestart: true,
description:
'小程序后台「开发-开发管理-消息推送」Token;回调 URL=/api/v1/callbacks/wechat/message',
},
{
key: 'WX_MINI_MSG_AES_KEY',
label: '小程序消息推送 EncodingAESKey',
group: G.wechat,
type: 'password',
secret: true,
requiresRestart: true,
description: '消息推送安全模式 EncodingAESKey43 位);明文模式可留空',
},
{
key: 'MINI_HOME_BANNERS',
@@ -107,7 +126,25 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
{ key: 'OSS_MAX_UPLOAD_BYTES', label: '单文件上限(字节)', group: G.oss, type: 'number', requiresRestart: false },
{ key: 'USER_H5_URL', label: 'C 端 H5 落地页', group: G.app, type: 'string', requiresRestart: false },
{ key: 'TENCENT_LBS_KEY', label: '腾讯位置服务 Key(须开 WebServiceAPI;服务端地点搜索/地理编码)', group: G.app, type: 'password', secret: true, requiresRestart: false },
{
key: 'TENCENT_LBS_KEY',
label: '腾讯位置服务 Key',
group: G.app,
type: 'password',
secret: true,
requiresRestart: false,
description: '须开启 WebServiceAPI;服务端地理编码/地点搜索与前端选点组件共用',
},
{
key: 'TENCENT_LBS_SECRET_KEY',
label: '腾讯位置服务 SecretKeySK',
group: G.app,
type: 'password',
secret: true,
requiresRestart: false,
description:
'控制台开启 WebServiceAPI「签名校验」后生成;仅服务端计算 sig,勿泄露。配置后接口请求自动附带签名',
},
{ key: 'DEPLOY_WEBHOOK_URL', label: '发布 Webhook URL', group: G.deploy, type: 'string', requiresRestart: false },
{ key: 'DEPLOY_WEBHOOK_SECRET', label: '发布 Webhook Secret', group: G.deploy, type: 'password', secret: true, requiresRestart: false },
@@ -12,6 +12,8 @@ import { WechatApiProvider } from './wechat/wechat.api.provider';
import { WechatDisabledProvider } from './wechat/wechat.disabled.provider';
import { WechatMockProvider } from './wechat/wechat.mock.provider';
import { WechatRouterProvider } from './wechat/wechat.router.provider';
import { WechatOrderShippingService } from './wechat/wechat-order-shipping.service';
import { WechatTradeManageService } from './wechat/wechat-trade-manage.service';
import { OssAliyunProvider } from './oss/oss.aliyun.provider';
import { TencentLbsProvider } from './map/tencent-lbs.provider';
import {
@@ -38,6 +40,8 @@ import { DELIVERY_QUEUE } from '../jobs/jobs.constants';
WechatMockProvider,
WechatRouterProvider,
{ provide: WECHAT_PROVIDER, useExisting: WechatRouterProvider },
WechatOrderShippingService,
WechatTradeManageService,
PayMockProvider,
PayWechatProvider,
PayRouterProvider,
@@ -55,6 +59,8 @@ import { DELIVERY_QUEUE } from '../jobs/jobs.constants';
PAY_PROVIDER,
DELIVERY_PROVIDER,
WECHAT_PROVIDER,
WechatOrderShippingService,
WechatTradeManageService,
OSS_PROVIDER,
MAP_PROVIDER,
TencentLbsProvider,
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { loadAppConfig } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import type { WechatActorRef } from '../wechat/wechat-log.util';
import { buildTencentLbsRequestUrl } from './tencent-lbs.sign';
export type ReverseGeocodeResult = {
province: string;
@@ -54,11 +55,22 @@ export class TencentLbsProvider {
constructor(private readonly prisma: PrismaService) {}
/** 每次读取,避免构造时缓存、以及系统设置热更新后仍用旧 Key */
/** 每次读取,避免构造时缓存、以及系统设置热更新后仍用旧 Key/SK */
private getLbsKey() {
return (loadAppConfig().tencentLbsKey || '').trim();
}
private getLbsSecretKey() {
return (loadAppConfig().tencentLbsSecretKey || '').trim();
}
private lbsUrl(path: string, params: Record<string, string>) {
return buildTencentLbsRequestUrl(path, params, {
key: this.getLbsKey(),
secretKey: this.getLbsSecretKey(),
});
}
isEnabled() {
return !!this.getLbsKey();
}
@@ -91,12 +103,10 @@ export class TencentLbsProvider {
return null;
}
const url = new URL('https://apis.map.qq.com/ws/geocoder/v1/');
url.searchParams.set('address', trimmed);
url.searchParams.set('key', this.getLbsKey());
const url = this.lbsUrl('/ws/geocoder/v1', { address: trimmed });
try {
const res = await fetch(url.toString());
const res = await fetch(url);
const data = (await res.json()) as {
status?: number;
message?: string;
@@ -169,14 +179,13 @@ export class TencentLbsProvider {
return null;
}
const location = `${latitude},${longitude}`;
const url = new URL('https://apis.map.qq.com/ws/geocoder/v1/');
url.searchParams.set('location', location);
url.searchParams.set('key', this.getLbsKey());
url.searchParams.set('get_poi', '0');
const url = this.lbsUrl('/ws/geocoder/v1', {
location: `${latitude},${longitude}`,
get_poi: '0',
});
try {
const res = await fetch(url.toString());
const res = await fetch(url);
const data = (await res.json()) as {
status?: number;
message?: string;
@@ -265,25 +274,25 @@ export class TencentLbsProvider {
return { items: [], error: 'TENCENT_LBS_KEY 未配置' };
}
const url = new URL('https://apis.map.qq.com/ws/place/v1/suggestion');
url.searchParams.set('keyword', trimmed.slice(0, 64));
url.searchParams.set('key', this.getLbsKey());
url.searchParams.set('policy', '1');
url.searchParams.set('page_index', '1');
url.searchParams.set('page_size', '20');
const params: Record<string, string> = {
keyword: trimmed.slice(0, 64),
policy: '1',
page_index: '1',
page_size: '20',
};
const region = options?.region?.trim();
if (region) url.searchParams.set('region', region);
if (region) params.region = region;
if (
options?.latitude != null &&
options?.longitude != null &&
Number.isFinite(options.latitude) &&
Number.isFinite(options.longitude)
) {
url.searchParams.set('location', `${options.latitude},${options.longitude}`);
params.location = `${options.latitude},${options.longitude}`;
}
try {
const res = await fetch(url.toString());
const res = await fetch(this.lbsUrl('/ws/place/v1/suggestion', params));
const data = (await res.json()) as {
status?: number;
message?: string;
@@ -315,14 +324,14 @@ export class TencentLbsProvider {
}
const radius = Math.min(5000, Math.max(200, Math.round(radiusMeters)));
const url = new URL('https://apis.map.qq.com/ws/place/v1/explore');
url.searchParams.set('boundary', `nearby(${latitude},${longitude},${radius})`);
url.searchParams.set('policy', '1');
url.searchParams.set('page_size', '20');
url.searchParams.set('key', this.getLbsKey());
const url = this.lbsUrl('/ws/place/v1/explore', {
boundary: `nearby(${latitude},${longitude},${radius})`,
policy: '1',
page_size: '20',
});
try {
const res = await fetch(url.toString());
const res = await fetch(url);
const data = (await res.json()) as {
status?: number;
message?: string;
@@ -352,13 +361,13 @@ export class TencentLbsProvider {
return { item: null, error: 'TENCENT_LBS_KEY 未配置' };
}
const url = new URL('https://apis.map.qq.com/ws/geocoder/v1/');
url.searchParams.set('location', `${latitude},${longitude}`);
url.searchParams.set('key', this.getLbsKey());
url.searchParams.set('get_poi', '1');
const url = this.lbsUrl('/ws/geocoder/v1', {
location: `${latitude},${longitude}`,
get_poi: '1',
});
try {
const res = await fetch(url.toString());
const res = await fetch(url);
const data = (await res.json()) as {
status?: number;
message?: string;
@@ -0,0 +1,39 @@
import { createHash } from 'node:crypto';
const LBS_HOST = 'https://apis.map.qq.com';
/**
* 腾讯位置服务 WebServiceAPIGET)签名 URL。
* 控制台开启 SN/签名校验后须附带 sig;SecretKey 仅服务端使用。
*
* sig = md5(请求路径 + "?" + 按参数名升序的原始 query + SK)
* @see https://lbs.qq.com/FAQ/server_faq.html
*/
export function buildTencentLbsRequestUrl(
path: string,
params: Record<string, string>,
options: { key: string; secretKey?: string },
): string {
const key = options.key.trim();
if (!key) {
throw new Error('TENCENT_LBS_KEY 未配置');
}
const pathname = (path.startsWith('/') ? path : `/${path}`).replace(/\/+$/, '') || '/';
const all: Record<string, string> = { ...params, key };
const sortedKeys = Object.keys(all).sort();
const rawQuery = sortedKeys.map((k) => `${k}=${all[k]}`).join('&');
const search = new URLSearchParams();
for (const k of sortedKeys) {
search.set(k, all[k]);
}
const sk = options.secretKey?.trim();
if (sk) {
const sig = createHash('md5').update(`${pathname}?${rawQuery}${sk}`).digest('hex');
search.set('sig', sig);
}
return `${LBS_HOST}${pathname}?${search.toString()}`;
}
@@ -0,0 +1,158 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'crypto';
/** 微信消息推送:Token/timestamp/nonce[/Encrypt] 字典序拼接后 SHA1 */
export function wechatMsgSha1(...parts: string[]): string {
return createHash('sha1').update([...parts].sort().join('')).digest('hex');
}
export function verifyWechatUrlSignature(
token: string,
timestamp: string,
nonce: string,
signature: string,
): boolean {
if (!token || !timestamp || !nonce || !signature) return false;
return wechatMsgSha1(token, timestamp, nonce) === signature;
}
export function verifyWechatMsgSignature(
token: string,
timestamp: string,
nonce: string,
encrypt: string,
msgSignature: string,
): boolean {
if (!token || !timestamp || !nonce || !encrypt || !msgSignature) return false;
return wechatMsgSha1(token, timestamp, nonce, encrypt) === msgSignature;
}
function decodeAesKey(encodingAESKey: string): Buffer {
const key = Buffer.from(`${encodingAESKey.trim()}=`, 'base64');
if (key.length !== 32) {
throw new Error(`EncodingAESKey 无效(解码后应为 32 字节,实际 ${key.length}`);
}
return key;
}
/**
* 解密微信安全模式 Encrypt 字段。
* FullStr = random(16) + msg_len(4 BE) + msg + appid
*/
export function decryptWechatEncrypt(
encryptBase64: string,
encodingAESKey: string,
expectedAppId?: string,
): string {
const aesKey = decodeAesKey(encodingAESKey);
const iv = aesKey.subarray(0, 16);
const decipher = createDecipheriv('aes-256-cbc', aesKey, iv);
const decrypted = Buffer.concat([
decipher.update(Buffer.from(encryptBase64, 'base64')),
decipher.final(),
]);
if (decrypted.length < 20) {
throw new Error('解密结果过短');
}
const msgLen = decrypted.readUInt32BE(16);
const msgStart = 20;
const msgEnd = msgStart + msgLen;
if (msgEnd > decrypted.length) {
throw new Error('解密消息长度非法');
}
const msg = decrypted.subarray(msgStart, msgEnd).toString('utf8');
const appId = decrypted.subarray(msgEnd).toString('utf8');
if (expectedAppId && appId && appId !== expectedAppId) {
throw new Error(`appid 不匹配: got=${appId}`);
}
return msg;
}
/** 加密回包(一般回复 success 明文即可,此函数供需要加密回包时使用) */
export function encryptWechatReply(
plain: string,
encodingAESKey: string,
appId: string,
): string {
const aesKey = decodeAesKey(encodingAESKey);
const iv = aesKey.subarray(0, 16);
const random = randomBytes(16);
const msg = Buffer.from(plain, 'utf8');
const msgLen = Buffer.alloc(4);
msgLen.writeUInt32BE(msg.length, 0);
const full = Buffer.concat([random, msgLen, msg, Buffer.from(appId, 'utf8')]);
const cipher = createCipheriv('aes-256-cbc', aesKey, iv);
return Buffer.concat([cipher.update(full), cipher.final()]).toString('base64');
}
/** 简易 XML 标签提取(微信推送字段无嵌套结构) */
export function parseSimpleXml(xml: string): Record<string, string> {
const out: Record<string, string> = {};
const re = /<([A-Za-z0-9_]+)>(?:<!\[CDATA\[([\s\S]*?)\]\]>|([^<]*))<\/\1>/g;
let m: RegExpExecArray | null;
while ((m = re.exec(xml))) {
out[m[1]] = (m[2] ?? m[3] ?? '').trim();
}
return out;
}
export function parseWechatPushBody(raw: string): Record<string, unknown> {
const trimmed = raw.trim();
if (!trimmed) return {};
if (trimmed.startsWith('{')) {
return JSON.parse(trimmed) as Record<string, unknown>;
}
return parseSimpleXml(trimmed);
}
export type WechatTradeManageEvent = {
event: string;
toUserName?: string;
fromUserName?: string;
createTime?: number;
transactionId?: string;
merchantId?: string;
subMerchantId?: string;
merchantTradeNo?: string;
payTime?: number;
shippedTime?: number;
estimatedSettlementTime?: number;
/** 1 手动确认;2 自动确认(结算推送才有) */
confirmReceiveMethod?: number;
confirmReceiveTime?: number;
settlementTime?: number;
msg?: string;
raw: Record<string, unknown>;
};
function num(v: unknown): number | undefined {
if (v == null || v === '') return undefined;
const n = typeof v === 'number' ? v : Number(v);
return Number.isFinite(n) ? n : undefined;
}
function str(v: unknown): string | undefined {
if (v == null) return undefined;
const s = String(v).trim();
return s || undefined;
}
export function normalizeTradeManageEvent(body: Record<string, unknown>): WechatTradeManageEvent {
return {
event: str(body.Event ?? body.event) || '',
toUserName: str(body.ToUserName),
fromUserName: str(body.FromUserName),
createTime: num(body.CreateTime),
transactionId: str(body.transaction_id),
merchantId: str(body.merchant_id),
subMerchantId: str(body.sub_merchant_id),
merchantTradeNo: str(body.merchant_trade_no),
payTime: num(body.pay_time),
shippedTime: num(body.shipped_time),
estimatedSettlementTime: num(body.estimated_settlement_time),
confirmReceiveMethod: num(body.confirm_receive_method),
confirmReceiveTime: num(body.confirm_receive_time),
settlementTime: num(body.settlement_time),
msg: str(body.msg),
raw: body,
};
}
@@ -0,0 +1,310 @@
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';
import type { IWechatProvider } from './wechat.interface';
import {
formatWechatUploadTime,
maskReceiverPhone,
resolveExpressCompanyId,
} from './wechat-order-shipping.util';
/** 微信要求支付成功后约 1 分钟才入库,过早调用会返回 10060001 */
const MIN_PAID_AGE_MS = 65_000;
/** 10060001 / 系统繁忙时的重试间隔 */
const RETRY_DELAY_MS = 60_000;
const MAX_ATTEMPTS = 5;
const RETRYABLE_ERRCODES = new Set([10060001, -1, 10060012, 10060019]);
/** 微信 order_state3 确认收货;4 交易完成 */
const WECHAT_CONFIRMED_STATES = new Set([3, 4]);
function sleep(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms));
}
/**
* 小程序发货信息管理:订单发货/自提后向微信录入发货信息,解冻交易资金。
* @see https://developers.weixin.qq.com/miniprogram/dev/platform-capabilities/business-capabilities/order-shipping/order-shipping.html
*/
@Injectable()
export class WechatOrderShippingService {
private readonly logger = new Logger(WechatOrderShippingService.name);
constructor(
private readonly prisma: PrismaService,
@Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider,
) {}
/** 供 C 端拉起微信确认收货组件 */
buildConfirmPayload(order: {
orderNo: string;
payStatus: string;
payExternalNo: string | null;
}): {
merchantId?: string;
merchantTradeNo: string;
transactionId?: string;
} | null {
if (order.payStatus !== 'PAID') return null;
const mchId = this.wechat.getMchId()?.trim() || undefined;
const transactionId = order.payExternalNo?.trim() || undefined;
if (!transactionId && !mchId) return null;
return {
merchantId: mchId,
merchantTradeNo: order.orderNo,
transactionId,
};
}
/**
* 校验用户已通过微信确认收货组件完成确认(或订单已结算)。
* Mock 支付环境跳过。
*/
async assertWechatUserConfirmed(orderId: bigint): Promise<void> {
const cfg = loadAppConfig();
if (cfg.mockPay) return;
if (!this.wechat.isPayEnabled() && this.wechat.isMock()) return;
const order = await this.prisma.order.findUnique({
where: { id: orderId },
select: { orderNo: true, payExternalNo: true },
});
if (!order) throw new BadRequestException('订单不存在');
const transactionId = order.payExternalNo?.trim();
const mchId = this.wechat.getMchId()?.trim();
if (!transactionId && !(mchId && order.orderNo)) {
throw new BadRequestException('缺少微信支付单号,无法校验微信确认收货');
}
const result = await this.wechat.getOrderShippingInfo({
transactionId: transactionId || undefined,
mchId: transactionId ? undefined : mchId,
outTradeNo: transactionId ? undefined : order.orderNo,
});
if (result.errcode && result.errcode !== 0) {
this.logger.warn(
`get_order for confirm failed order=${order.orderNo} ${result.errcode} ${result.errmsg}`,
);
throw new BadRequestException(
result.errmsg || '查询微信订单状态失败,请稍后重试',
);
}
if (result.orderState == null || !WECHAT_CONFIRMED_STATES.has(result.orderState)) {
throw new BadRequestException(
'请先在微信确认收货组件中完成确认(勿仅点服务通知外的按钮)',
);
}
}
/** 异步安全上报:按 paidAt 等待入库窗口,失败可重试,不阻断主履约流程 */
uploadForOrderSafe(orderId: bigint) {
void this.scheduleAndUpload(orderId).catch((err) => {
const msg = err instanceof Error ? err.message : String(err);
this.logger.error(`upload shipping info failed order=${orderId}: ${msg}`);
});
}
private async scheduleAndUpload(orderId: bigint) {
const paidAtRow = await this.prisma.order.findUnique({
where: { id: orderId },
select: { paidAt: true, orderNo: true },
});
if (!paidAtRow?.paidAt) {
await this.uploadForOrder(orderId);
return;
}
const ageMs = Date.now() - paidAtRow.paidAt.getTime();
const waitMs = Math.max(0, MIN_PAID_AGE_MS - ageMs);
if (waitMs > 0) {
this.logger.log(
`WeChat upload_shipping_info wait ${waitMs}ms for pay入库 order=${paidAtRow.orderNo}`,
);
await sleep(waitMs);
}
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
const result = await this.uploadForOrder(orderId);
if (!result || result.skipped || result.ok) return;
const retryable = result.errcode != null && RETRYABLE_ERRCODES.has(result.errcode);
if (!retryable || attempt >= MAX_ATTEMPTS) return;
this.logger.warn(
`WeChat upload_shipping_info retry ${attempt}/${MAX_ATTEMPTS} ` +
`order=${paidAtRow.orderNo} errcode=${result.errcode} in ${RETRY_DELAY_MS}ms`,
);
await sleep(RETRY_DELAY_MS);
}
}
async uploadForOrder(
orderId: bigint,
): Promise<{ skipped?: string; ok?: boolean; errcode?: number } | void> {
const cfg = loadAppConfig();
if (cfg.mockPay) {
return { skipped: 'MOCK_PAY' };
}
if (!this.wechat.isPayEnabled() && this.wechat.isMock()) {
return { skipped: 'MOCK_WECHAT' };
}
const order = await this.prisma.order.findUnique({
where: { id: orderId },
include: {
delivery: true,
user: { select: { wxOpenId: true } },
},
});
if (!order) return { skipped: 'ORDER_NOT_FOUND' };
if (order.payStatus !== 'PAID') return { skipped: 'NOT_PAID' };
const openId = order.user?.wxOpenId?.trim();
if (!openId) return { skipped: 'NO_OPENID' };
const transactionId = order.payExternalNo?.trim();
const mchId = this.wechat.getMchId()?.trim();
if (!transactionId && !(mchId && order.orderNo)) {
return { skipped: 'NO_PAY_REF' };
}
// 已成功上报则跳过(幂等)
const existed = await this.prisma.logThirdParty.findFirst({
where: {
provider: 'WECHAT_PAY',
scene: 'UPLOAD_SHIPPING',
refType: 'ORDER',
refId: orderId,
status: 'SUCCESS',
},
});
if (existed) return { skipped: 'ALREADY_UPLOADED' };
const logisticsType = this.resolveLogisticsType(order.deliveryType, order.delivery);
const trackingNo = order.delivery?.trackingNo?.trim() || undefined;
let expressCompany = resolveExpressCompanyId(order.delivery?.logisticsCompany);
if (logisticsType === 1 && trackingNo && !expressCompany) {
try {
const list = await this.wechat.getDeliveryList();
expressCompany = resolveExpressCompanyId(order.delivery?.logisticsCompany, list);
} catch (err) {
this.logger.warn(
`get_delivery_list failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
// 快递模式缺运力 ID 时降级为同城,避免接口硬失败
let finalLogisticsType = logisticsType;
if (finalLogisticsType === 1 && (!trackingNo || !expressCompany)) {
finalLogisticsType = 2;
}
const itemDesc = `${order.productName}${order.productSpec ? `${order.productSpec}` : ''}*${order.quantity}`
.replace(/\s+/g, ' ')
.slice(0, 120);
const shippingItem: {
trackingNo?: string;
expressCompany?: string;
itemDesc: string;
contact?: { receiverContact: string };
} = { itemDesc };
if (finalLogisticsType === 1) {
shippingItem.trackingNo = trackingNo;
shippingItem.expressCompany = expressCompany;
if (expressCompany === 'SF') {
shippingItem.contact = {
receiverContact: maskReceiverPhone(order.receiverPhone),
};
}
} else if (trackingNo) {
// 同城也可附带运单信息
shippingItem.trackingNo = trackingNo;
if (expressCompany) shippingItem.expressCompany = expressCompany;
}
const input = {
orderNumberType: (transactionId ? 2 : 1) as 1 | 2,
transactionId: transactionId || undefined,
mchId: transactionId ? undefined : mchId,
outTradeNo: transactionId ? undefined : order.orderNo,
logisticsType: finalLogisticsType,
deliveryMode: 1 as const,
shippingList: [shippingItem],
uploadTime: formatWechatUploadTime(),
payerOpenId: openId,
};
const requestUrl = 'https://api.weixin.qq.com/wxa/sec/order/upload_shipping_info';
try {
const result = await this.wechat.uploadShippingInfo(input);
const ok = result.errcode === 0;
await this.prisma.logThirdParty.create({
data: {
provider: 'WECHAT_PAY',
scene: 'UPLOAD_SHIPPING',
refType: 'ORDER',
refId: orderId,
requestUrl,
requestBody: {
orderNo: order.orderNo,
logisticsType: finalLogisticsType,
trackingNo: shippingItem.trackingNo,
expressCompany: shippingItem.expressCompany,
orderNumberType: input.orderNumberType,
transactionId: transactionId || undefined,
outTradeNo: input.outTradeNo,
payerOpenId: openId,
},
responseBody: { errcode: result.errcode, errmsg: result.errmsg },
externalNo: transactionId || order.orderNo,
status: ok ? 'SUCCESS' : 'FAILED',
errorMessage: ok ? undefined : `${result.errcode}:${result.errmsg}`.slice(0, 512),
},
});
if (!ok) {
this.logger.warn(
`WeChat upload_shipping_info order=${order.orderNo} ${result.errcode} ${result.errmsg}`,
);
return { ok: false, errcode: result.errcode };
}
this.logger.log(`WeChat upload_shipping_info ok order=${order.orderNo}`);
return { ok: true };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await this.prisma.logThirdParty.create({
data: {
provider: 'WECHAT_PAY',
scene: 'UPLOAD_SHIPPING',
refType: 'ORDER',
refId: orderId,
requestUrl,
requestBody: {
orderNo: order.orderNo,
orderNumberType: input.orderNumberType,
transactionId: transactionId || undefined,
},
status: 'FAILED',
errorMessage: message.slice(0, 512),
externalNo: transactionId || order.orderNo,
},
});
throw err;
}
}
private resolveLogisticsType(
deliveryType: string,
delivery: { trackingNo: string | null; logisticsCompany: string | null; provider: string } | null,
): 1 | 2 | 4 {
if (deliveryType === 'ON_SITE_PICKUP') return 4;
if (deliveryType === 'LOCAL') return 2;
// CROSS_CITY:有运单走快递,否则同城兜底
if (delivery?.trackingNo?.trim()) return 1;
return 2;
}
}
@@ -0,0 +1,74 @@
/** 常见快递公司名称 → 微信运力 IDget_delivery_list 的 delivery_id */
const STATIC_EXPRESS_MAP: Array<{ id: string; aliases: string[] }> = [
{ id: 'SF', aliases: ['sf', '顺丰', '顺丰速运', '顺丰快递'] },
{ id: 'STO', aliases: ['sto', '申通', '申通快递'] },
{ id: 'YTO', aliases: ['yto', '圆通', '圆通速递', '圆通快递'] },
{ id: 'ZTO', aliases: ['zto', '中通', '中通快递'] },
{ id: 'YD', aliases: ['yd', '韵达', '韵达速递', '韵达快递'] },
{ id: 'HTKY', aliases: ['htky', '百世', '百世快递', '汇通'] },
{ id: 'EMS', aliases: ['ems', '邮政', '中国邮政', '邮政快递'] },
{ id: 'JD', aliases: ['jd', '京东', '京东快递', '京东物流'] },
{ id: 'JTSD', aliases: ['jtsd', 'jt', '极兔', '极兔速递', '极兔快递'] },
{ id: 'UC', aliases: ['uc', '优速', '优速快递'] },
{ id: 'DBL', aliases: ['dbl', '德邦', '德邦快递', '德邦物流'] },
{ id: 'ANE', aliases: ['ane', '安能', '安能物流'] },
];
export function maskReceiverPhone(phone: string): string {
const digits = phone.replace(/\D/g, '');
if (digits.length >= 7) {
return `${digits.slice(0, 3)}****${digits.slice(-4)}`;
}
return phone.trim();
}
/** RFC3339 +08:00,微信发货录入 upload_time 要求 */
export function formatWechatUploadTime(date = new Date()): string {
const fmt = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
const parts = Object.fromEntries(fmt.formatToParts(date).map((p) => [p.type, p.value]));
const ms = String(date.getMilliseconds()).padStart(3, '0');
return `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}.${ms}+08:00`;
}
export function resolveExpressCompanyId(
logisticsCompany: string | null | undefined,
deliveryList?: Array<{ deliveryId: string; deliveryName: string }>,
): string | undefined {
const raw = (logisticsCompany || '').trim();
if (!raw) return undefined;
const upper = raw.toUpperCase();
// 已是运力 ID
if (/^[A-Z0-9_()-]{2,32}$/.test(upper) && !/[\u4e00-\u9fff]/.test(raw)) {
return upper;
}
const lower = raw.toLowerCase();
for (const row of STATIC_EXPRESS_MAP) {
if (row.aliases.some((a) => lower.includes(a.toLowerCase()) || a === raw)) {
return row.id;
}
}
if (deliveryList?.length) {
const hit = deliveryList.find(
(d) =>
d.deliveryId.toUpperCase() === upper ||
d.deliveryName === raw ||
d.deliveryName.includes(raw) ||
raw.includes(d.deliveryName),
);
if (hit) return hit.deliveryId;
}
return undefined;
}
@@ -0,0 +1,166 @@
import { Injectable, Logger } from '@nestjs/common';
import { buildOrderStatusEvent } from '../../common/event/event.helpers';
import { PrismaService } from '../../common/prisma/prisma.module';
import { WechatOrderShippingService } from './wechat-order-shipping.service';
import type { WechatTradeManageEvent } from './wechat-msg-crypto.util';
/**
* 小程序发货信息管理相关消息推送处理。
* @see https://developers.weixin.qq.com/miniprogram/dev/platform-capabilities/business-capabilities/order-shipping/order-shipping.html
*/
@Injectable()
export class WechatTradeManageService {
private readonly logger = new Logger(WechatTradeManageService.name);
constructor(
private readonly prisma: PrismaService,
private readonly wechatOrderShipping: WechatOrderShippingService,
) {}
async handleEvent(evt: WechatTradeManageEvent): Promise<{ handled: string }> {
switch (evt.event) {
case 'trade_manage_order_settlement':
return this.handleOrderSettlement(evt);
case 'trade_manage_remind_shipping':
return this.handleRemindShipping(evt);
case 'trade_manage_remind_access_api':
case 'wxa_trade_controlled':
await this.logEvent(evt, null, 'SUCCESS', evt.msg || evt.event);
return { handled: evt.event };
default:
await this.logEvent(evt, null, 'SUCCESS', `ignored:${evt.event || 'empty'}`);
return { handled: 'ignored' };
}
}
private async handleOrderSettlement(evt: WechatTradeManageEvent) {
const order = await this.findOrder(evt);
const isConfirmOrSettle =
evt.confirmReceiveTime != null || evt.settlementTime != null || evt.confirmReceiveMethod != null;
if (!isConfirmOrSettle) {
// 发货时推送:仅有 shipped_time / estimated_settlement_time
await this.logEvent(evt, order?.id ?? null, 'SUCCESS', 'shipped_notify');
return { handled: 'settlement_shipped_notify' };
}
if (!order) {
await this.logEvent(evt, null, 'FAILED', 'ORDER_NOT_FOUND');
this.logger.warn(
`trade_manage_order_settlement order not found tradeNo=${evt.merchantTradeNo} tx=${evt.transactionId}`,
);
return { handled: 'settlement_order_missing' };
}
if (order.status === 'COMPLETED') {
await this.logEvent(evt, order.id, 'SUCCESS', 'already_completed');
return { handled: 'settlement_already_completed' };
}
if (order.payStatus !== 'PAID') {
await this.logEvent(evt, order.id, 'FAILED', 'NOT_PAID');
return { handled: 'settlement_not_paid' };
}
const methodLabel =
evt.confirmReceiveMethod === 2
? '微信自动确认收货'
: evt.confirmReceiveMethod === 1
? '微信手动确认收货'
: '微信订单结算';
const now = new Date();
const remark = [
methodLabel,
evt.confirmReceiveTime ? `confirmAt=${evt.confirmReceiveTime}` : null,
evt.settlementTime ? `settleAt=${evt.settlementTime}` : null,
]
.filter(Boolean)
.join(' | ')
.slice(0, 512);
await this.prisma.$transaction(async (tx) => {
await tx.order.update({
where: { id: order.id },
data: {
status: 'COMPLETED',
completedAt: order.completedAt ?? now,
},
});
await tx.orderDelivery.updateMany({
where: { orderId: order.id, deliveredAt: null },
data: { deliveredAt: now },
});
await tx.commonEvent.create({
data: buildOrderStatusEvent({
orderId: order.id,
fromStatus: order.status,
toStatus: 'COMPLETED',
operator: 'WECHAT_TRADE_MANAGE',
remark,
}),
});
});
await this.logEvent(evt, order.id, 'SUCCESS', methodLabel);
this.logger.log(
`WeChat confirm/settle → COMPLETED order=${order.orderNo} method=${evt.confirmReceiveMethod ?? '-'}`,
);
return { handled: 'settlement_completed' };
}
private async handleRemindShipping(evt: WechatTradeManageEvent) {
const order = await this.findOrder(evt);
await this.logEvent(evt, order?.id ?? null, order ? 'SUCCESS' : 'FAILED', evt.msg || 'remind_shipping');
if (order) {
this.wechatOrderShipping.uploadForOrderSafe(order.id);
}
return { handled: 'remind_shipping' };
}
private async findOrder(evt: WechatTradeManageEvent) {
if (evt.merchantTradeNo) {
const byNo = await this.prisma.order.findUnique({ where: { orderNo: evt.merchantTradeNo } });
if (byNo) return byNo;
}
if (evt.transactionId) {
return this.prisma.order.findFirst({
where: { payExternalNo: evt.transactionId },
orderBy: { id: 'desc' },
});
}
return null;
}
private async logEvent(
evt: WechatTradeManageEvent,
orderId: bigint | null,
status: 'SUCCESS' | 'FAILED',
note: string,
) {
await this.prisma.logThirdParty.create({
data: {
provider: 'WECHAT_PAY',
scene: 'TRADE_MANAGE_PUSH',
refType: orderId ? 'ORDER' : 'SYSTEM',
refId: orderId ?? undefined,
requestUrl: 'callbacks/wechat/message',
requestBody: {
event: evt.event,
merchantTradeNo: evt.merchantTradeNo,
transactionId: evt.transactionId,
confirmReceiveMethod: evt.confirmReceiveMethod,
confirmReceiveTime: evt.confirmReceiveTime,
settlementTime: evt.settlementTime,
shippedTime: evt.shippedTime,
estimatedSettlementTime: evt.estimatedSettlementTime,
msg: evt.msg,
},
responseBody: { note },
externalNo: evt.transactionId || evt.merchantTradeNo,
status,
errorMessage: status === 'FAILED' ? note.slice(0, 512) : undefined,
},
});
}
}
@@ -8,6 +8,10 @@ import type {
WechatCodeSession,
WechatOAuthSession,
WechatWxaCodeUnlimitedInput,
WechatUploadShippingInfoInput,
WechatUploadShippingInfoResult,
WechatOrderShippingQueryResult,
WechatDeliveryCompany,
} from './wechat.interface';
import { logWechatAuth, type WechatActorRef } from './wechat-log.util';
import {
@@ -20,9 +24,11 @@ import {
type TokenCache = { accessToken: string; expiresAt: number };
type TicketCache = { ticket: string; expiresAt: number };
const ACCESS_TOKEN_KEY = 'wechat:access_token';
const MINI_ACCESS_TOKEN_KEY = 'wechat:mini_access_token';
/** 使用 getStableAccessToken,与旧 cgi-bin/token 隔离,避免多端抢刷新导致 40001 */
const ACCESS_TOKEN_KEY = 'wechat:stable_access_token';
const MINI_ACCESS_TOKEN_KEY = 'wechat:mini_stable_access_token';
const JSAPI_TICKET_KEY = 'wechat:jsapi_ticket';
const TOKEN_INVALID_ERRCODES = new Set([40001, 40014, 42001]);
@Injectable()
export class WechatApiProvider implements IWechatProvider {
@@ -303,6 +309,146 @@ export class WechatApiProvider implements IWechatProvider {
return buf;
}
async uploadShippingInfo(input: WechatUploadShippingInfoInput): Promise<WechatUploadShippingInfoResult> {
const orderKey: Record<string, string | number> = {
order_number_type: input.orderNumberType,
};
if (input.orderNumberType === 2) {
if (!input.transactionId) {
throw new BadRequestException('微信支付单号不能为空');
}
orderKey.transaction_id = input.transactionId;
} else {
if (!input.mchId || !input.outTradeNo) {
throw new BadRequestException('商户号与商户单号不能为空');
}
orderKey.mchid = input.mchId;
orderKey.out_trade_no = input.outTradeNo;
}
const body = {
order_key: orderKey,
logistics_type: input.logisticsType,
delivery_mode: input.deliveryMode ?? 1,
shipping_list: input.shippingList.map((row) => {
const item: Record<string, unknown> = {
item_desc: row.itemDesc.slice(0, 120),
};
if (row.trackingNo) item.tracking_no = row.trackingNo;
if (row.expressCompany) item.express_company = row.expressCompany;
if (row.contact?.consignorContact || row.contact?.receiverContact) {
item.contact = {
...(row.contact.consignorContact
? { consignor_contact: row.contact.consignorContact }
: {}),
...(row.contact.receiverContact
? { receiver_contact: row.contact.receiverContact }
: {}),
};
}
return item;
}),
upload_time: input.uploadTime,
payer: { openid: input.payerOpenId },
};
const callOnce = async (accessToken: string) => {
const apiUrl = `https://api.weixin.qq.com/wxa/sec/order/upload_shipping_info?access_token=${accessToken}`;
return this.fetchJson<{ errcode?: number; errmsg?: string }>(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)) {
this.logger.warn(
`upload_shipping_info token invalid ${data.errcode}, refresh stable token and retry`,
);
accessToken = await this.getMiniAccessToken(true);
data = await callOnce(accessToken);
}
return {
errcode: data.errcode ?? -1,
errmsg: data.errmsg ?? 'unknown',
};
}
async getOrderShippingInfo(input: {
transactionId?: string;
mchId?: string;
outTradeNo?: string;
}): Promise<WechatOrderShippingQueryResult> {
const callOnce = async (accessToken: string) => {
const body: Record<string, string> = {};
if (input.transactionId) {
body.transaction_id = input.transactionId;
} else {
if (!input.mchId || !input.outTradeNo) {
throw new BadRequestException('请提供 transaction_id 或 mchid+out_trade_no');
}
body.merchant_id = input.mchId;
body.merchant_trade_no = input.outTradeNo;
}
const apiUrl = `https://api.weixin.qq.com/wxa/sec/order/get_order?access_token=${accessToken}`;
return this.fetchJson<{
errcode?: number;
errmsg?: string;
order?: {
transaction_id?: string;
merchant_trade_no?: string;
order_state?: number;
shipping?: { finish_shipping?: boolean };
};
}>(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
};
let accessToken = await this.getMiniAccessToken();
let data = await callOnce(accessToken);
if (TOKEN_INVALID_ERRCODES.has(data.errcode ?? -1)) {
accessToken = await this.getMiniAccessToken(true);
data = await callOnce(accessToken);
}
return {
errcode: data.errcode ?? 0,
errmsg: data.errmsg ?? 'ok',
orderState: data.order?.order_state,
transactionId: data.order?.transaction_id,
merchantTradeNo: data.order?.merchant_trade_no,
finishShipping: data.order?.shipping?.finish_shipping,
};
}
async getDeliveryList(): Promise<WechatDeliveryCompany[]> {
const accessToken = await this.getMiniAccessToken();
const apiUrl = `https://api.weixin.qq.com/cgi-bin/express/delivery/open_msg/get_delivery_list?access_token=${accessToken}`;
const data = await this.fetchJson<{
errcode?: number;
errmsg?: string;
delivery_list?: Array<{ delivery_id?: string; delivery_name?: string }>;
}>(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
});
if (data.errcode && data.errcode !== 0) {
this.logger.warn(`get_delivery_list failed: ${data.errcode} ${data.errmsg}`);
return [];
}
return (data.delivery_list ?? [])
.filter((row) => row.delivery_id && row.delivery_name)
.map((row) => ({
deliveryId: row.delivery_id!,
deliveryName: row.delivery_name!,
}));
}
async getPhoneNumberByCode(code: string, platform: 'mini' | 'h5', actorRef?: WechatActorRef): Promise<string> {
if (platform === 'h5') {
throw new InternalServerErrorException('H5 请使用短信绑定手机号');
@@ -462,31 +608,18 @@ export class WechatApiProvider implements IWechatProvider {
return raw;
}
private async getAccessToken(): Promise<string> {
const cached = await this.redis.getJson<TokenCache>(ACCESS_TOKEN_KEY);
if (cached && cached.expiresAt > Date.now()) return cached.accessToken;
const url = new URL('https://api.weixin.qq.com/cgi-bin/token');
url.searchParams.set('grant_type', 'client_credential');
url.searchParams.set('appid', this.appId);
url.searchParams.set('secret', this.appSecret);
const data = await this.fetchJson<{ access_token?: string; expires_in?: number; errcode?: number; errmsg?: string }>(
url.toString(),
);
if (!data.access_token) {
throw new InternalServerErrorException(data.errmsg || '获取 access_token 失败');
}
const ttl = Math.max((data.expires_in ?? 7200) - 300, 60);
await this.redis.setJson(
ACCESS_TOKEN_KEY,
{ accessToken: data.access_token, expiresAt: Date.now() + ttl * 1000 },
ttl,
);
return data.access_token;
private async getAccessToken(forceRefresh = false): Promise<string> {
return this.fetchStableAccessToken({
cacheKey: ACCESS_TOKEN_KEY,
appId: this.appId,
appSecret: this.appSecret,
forceRefresh,
label: '服务号',
});
}
/** 小程序 access_tokengetPhoneNumber 等 wxa 接口必须用小程序 AppID) */
private async getMiniAccessToken(): Promise<string> {
/** 小程序 access_tokengetPhoneNumber / 发货管理等 wxa 接口必须用小程序 AppID) */
private async getMiniAccessToken(forceRefresh = false): Promise<string> {
const appId = this.miniAppId;
const appSecret = this.miniAppSecret;
if (!appId || !appSecret) {
@@ -494,22 +627,60 @@ export class WechatApiProvider implements IWechatProvider {
'小程序未配置:请设置 WX_MINI_APP_ID / WX_MINI_APP_SECRET',
);
}
const cached = await this.redis.getJson<TokenCache>(MINI_ACCESS_TOKEN_KEY);
if (cached && cached.expiresAt > Date.now()) return cached.accessToken;
return this.fetchStableAccessToken({
cacheKey: MINI_ACCESS_TOKEN_KEY,
appId,
appSecret,
forceRefresh,
label: '小程序',
});
}
const url = new URL('https://api.weixin.qq.com/cgi-bin/token');
url.searchParams.set('grant_type', 'client_credential');
url.searchParams.set('appid', appId);
url.searchParams.set('secret', appSecret);
const data = await this.fetchJson<{ access_token?: string; expires_in?: number; errcode?: number; errmsg?: string }>(
url.toString(),
);
if (!data.access_token) {
throw new InternalServerErrorException(data.errmsg || '获取小程序 access_token 失败');
/**
* @see https://developers.weixin.qq.com/miniprogram/dev/server/API/mp-access-token/api_getstableaccesstoken.html
*/
private async fetchStableAccessToken(opts: {
cacheKey: string;
appId: string;
appSecret: string;
forceRefresh: boolean;
label: string;
}): Promise<string> {
if (!opts.appId || !opts.appSecret) {
throw new InternalServerErrorException(`${opts.label}未配置 AppID/Secret`);
}
if (!opts.forceRefresh) {
const cached = await this.redis.getJson<TokenCache>(opts.cacheKey);
if (cached && cached.expiresAt > Date.now()) return cached.accessToken;
} else {
await this.redis.del(opts.cacheKey);
}
const data = await this.fetchJson<{
access_token?: string;
expires_in?: number;
errcode?: number;
errmsg?: string;
}>('https://api.weixin.qq.com/cgi-bin/stable_token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'client_credential',
appid: opts.appId,
secret: opts.appSecret,
force_refresh: !!opts.forceRefresh,
}),
});
if (!data.access_token) {
this.logger.error(
`getStableAccessToken ${opts.label} failed: ${data.errcode} ${data.errmsg}`,
);
throw new InternalServerErrorException(data.errmsg || `获取${opts.label} access_token 失败`);
}
// 稳定版会提前约 5 分钟轮换;本地再提前 5 分钟过期,避免踩边
const ttl = Math.max((data.expires_in ?? 7200) - 300, 60);
await this.redis.setJson(
MINI_ACCESS_TOKEN_KEY,
opts.cacheKey,
{ accessToken: data.access_token, expiresAt: Date.now() + ttl * 1000 },
ttl,
);
@@ -58,4 +58,16 @@ export class WechatDisabledProvider implements IWechatProvider {
getWxaCodeUnlimited(_input: WechatWxaCodeUnlimitedInput): Promise<Buffer> {
return this.disabled();
}
uploadShippingInfo() {
return this.disabled();
}
getOrderShippingInfo() {
return this.disabled();
}
getDeliveryList() {
return this.disabled();
}
}
@@ -39,6 +39,51 @@ export type WechatWxaCodeUnlimitedInput = {
isHyaline?: boolean;
};
/** 小程序发货信息管理 — 发货信息录入 */
export type WechatUploadShippingInfoInput = {
/** 1=商户单号;2=微信支付单号 */
orderNumberType: 1 | 2;
transactionId?: string;
mchId?: string;
outTradeNo?: string;
/** 1 快递 2 同城 3 虚拟 4 自提 */
logisticsType: 1 | 2 | 3 | 4;
/** 1 统一发货 2 分拆发货 */
deliveryMode?: 1 | 2;
shippingList: Array<{
trackingNo?: string;
/** 微信运力 ID,如 SF / STO */
expressCompany?: string;
itemDesc: string;
contact?: {
consignorContact?: string;
receiverContact?: string;
};
}>;
uploadTime: string;
payerOpenId: string;
};
export type WechatUploadShippingInfoResult = {
errcode: number;
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;
};
export interface IWechatProvider {
isEnabled(): boolean;
@@ -95,4 +140,20 @@ export interface IWechatProvider {
/** 获取不限制的小程序码(PNG Buffer),须服务端调用 */
getWxaCodeUnlimited(input: WechatWxaCodeUnlimitedInput): Promise<Buffer>;
/**
* 小程序发货信息录入(交易资金解冻前置)
* @see https://developers.weixin.qq.com/miniprogram/dev/server/API/order_shipping/api_uploadshippinginfo.html
*/
uploadShippingInfo(input: WechatUploadShippingInfoInput): Promise<WechatUploadShippingInfoResult>;
/** 查询支付单发货/确认收货状态 */
getOrderShippingInfo(input: {
transactionId?: string;
mchId?: string;
outTradeNo?: string;
}): Promise<WechatOrderShippingQueryResult>;
/** 获取运力公司列表(快递公司 delivery_id */
getDeliveryList(): Promise<WechatDeliveryCompany[]>;
}
@@ -94,4 +94,27 @@ export class WechatMockProvider implements IWechatProvider {
color: { dark: '#1f1a17', light: '#ffffff' },
});
}
async uploadShippingInfo() {
return { errcode: 0, errmsg: 'ok' };
}
async getOrderShippingInfo() {
return {
errcode: 0,
errmsg: 'ok',
orderState: 3,
finishShipping: true,
};
}
async getDeliveryList() {
return [
{ deliveryId: 'SF', deliveryName: '顺丰速运' },
{ deliveryId: 'STO', deliveryName: '申通快递' },
{ deliveryId: 'YTO', deliveryName: '圆通速递' },
{ deliveryId: 'ZTO', deliveryName: '中通快递' },
{ deliveryId: 'YD', deliveryName: '韵达速递' },
];
}
}
@@ -90,4 +90,16 @@ export class WechatRouterProvider implements IWechatProvider {
getWxaCodeUnlimited(input: Parameters<IWechatProvider['getWxaCodeUnlimited']>[0]) {
return this.resolve().getWxaCodeUnlimited(input);
}
uploadShippingInfo(input: Parameters<IWechatProvider['uploadShippingInfo']>[0]) {
return this.resolve().uploadShippingInfo(input);
}
getOrderShippingInfo(input: Parameters<IWechatProvider['getOrderShippingInfo']>[0]) {
return this.resolve().getOrderShippingInfo(input);
}
getDeliveryList() {
return this.resolve().getDeliveryList();
}
}
+4 -1
View File
@@ -25,7 +25,10 @@ async function bootstrap() {
app.use(
json({
verify: (req, _res, buf) => {
if (req.url?.includes('/callbacks/wechat/pay')) {
if (
req.url?.includes('/callbacks/wechat/pay') ||
req.url?.includes('/callbacks/wechat/message')
) {
(req as { rawBody?: Buffer }).rawBody = buf;
}
},
@@ -1,5 +1,8 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import { CatalogService } from './catalog.service';
import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
@Controller('catalog')
export class CatalogController {
@@ -11,12 +14,25 @@ export class CatalogController {
}
@Get('products')
products(@Query('aromaType') aromaType?: string, @Query('cityCode') cityCode?: string) {
return this.catalogService.listProducts(aromaType, cityCode);
@UseGuards(OptionalJwtAuthGuard)
async products(
@CurrentUser() user: AuthUser | undefined,
@Query('aromaType') aromaType?: string,
@Query('cityCode') cityCode?: string,
) {
const viewerPhone = await this.resolveViewerPhone(user);
return this.catalogService.listProducts(aromaType, cityCode, { phone: viewerPhone });
}
@Get('products/:id')
product(@Param('id') id: string) {
return this.catalogService.getProduct(BigInt(id));
@UseGuards(OptionalJwtAuthGuard)
async product(@CurrentUser() user: AuthUser | undefined, @Param('id') id: string) {
const viewerPhone = await this.resolveViewerPhone(user);
return this.catalogService.getProduct(BigInt(id), { phone: viewerPhone });
}
private async resolveViewerPhone(user?: AuthUser) {
if (!user || user.actorType !== 'USER') return null;
return this.catalogService.resolveUserPhone(user.actorId);
}
}
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { IamModule } from '../iam/iam.module';
import { CatalogController } from './catalog.controller';
import { CatalogService } from './catalog.service';
@Module({
imports: [IamModule],
controllers: [CatalogController],
providers: [CatalogService],
exports: [CatalogService],
@@ -3,6 +3,17 @@ import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { groupResourcesByProductId, mapProductMedia } from './catalog.mapper';
export type CatalogViewer = {
/** C 端用户手机号;无则无法看到白名单商品 */
phone?: string | null;
/** 仅总部代下单等运营场景跳过白名单;合伙人端必须遵守白名单 */
bypassWhitelist?: boolean;
};
function normalizePhone(phone: string | null | undefined): string {
return (phone || '').replace(/\D/g, '').trim();
}
@Injectable()
export class CatalogService {
constructor(private readonly prisma: PrismaService) {}
@@ -34,7 +45,7 @@ export class CatalogService {
);
}
async listProducts(aromaType?: string, cityCode?: string) {
async listProducts(aromaType?: string, cityCode?: string, viewer?: CatalogViewer) {
if (cityCode) {
const city = await this.prisma.commonCity.findFirst({
where: { code: cityCode, status: 'ACTIVE' },
@@ -45,10 +56,15 @@ export class CatalogService {
const products = await this.prisma.commonProductItem.findMany({
where: { status: 'ON_SALE', ...(aromaType ? { aromaType: aromaType as never } : {}) },
orderBy: { sortOrder: 'asc' },
include: { coverResource: true },
include: {
coverResource: true,
visibilityPhones: { select: { phone: true } },
},
});
const productIds = products.map((p) => p.id);
const visible = products.filter((p) => this.isVisibleToViewer(p, viewer));
const productIds = visible.map((p) => p.id);
const resources = productIds.length
? await this.prisma.commonResource.findMany({
where: {
@@ -63,10 +79,13 @@ export class CatalogService {
const resourceMap = groupResourcesByProductId(resources);
return serializeBigInt(
products.map((p) => {
visible.map((p) => {
const media = mapProductMedia(p, resourceMap.get(p.id.toString()) ?? []);
const { visibilityPhones, ...rest } = p;
return {
...p,
...rest,
visibilityWhitelistEnabled: p.visibilityWhitelistEnabled,
visibilityPhones: visibilityPhones.map((row) => row.phone),
benefitAmount: p.benefitAmount ?? p.price,
price: Number(p.price),
benefitDisplay: Number(p.benefitAmount ?? p.price),
@@ -76,12 +95,18 @@ export class CatalogService {
);
}
async getProduct(id: bigint) {
async getProduct(id: bigint, viewer?: CatalogViewer) {
const product = await this.prisma.commonProductItem.findUnique({
where: { id },
include: { coverResource: true },
include: {
coverResource: true,
visibilityPhones: { select: { phone: true } },
},
});
if (!product) return null;
if (!this.isVisibleToViewer(product, viewer)) {
return null;
}
const resources = await this.prisma.commonResource.findMany({
where: {
@@ -94,11 +119,51 @@ export class CatalogService {
});
const media = mapProductMedia(product, resources);
const { visibilityPhones, ...rest } = product;
return serializeBigInt({
...product,
...rest,
visibilityWhitelistEnabled: product.visibilityWhitelistEnabled,
visibilityPhones: visibilityPhones.map((row) => row.phone),
benefitAmount: product.benefitAmount ?? product.price,
price: Number(product.price),
...media,
});
}
/** 下单前校验:白名单商品仅白名单手机号可买 */
async assertPurchasable(productId: bigint, viewerPhone?: string | null) {
const product = await this.prisma.commonProductItem.findUnique({
where: { id: productId },
include: { visibilityPhones: { select: { phone: true } } },
});
if (!product || product.status !== 'ON_SALE') {
throw new BadRequestException('商品不可购买');
}
if (!this.isVisibleToViewer(product, { phone: viewerPhone })) {
throw new BadRequestException('该商品暂不对当前账号开放');
}
return product;
}
async resolveUserPhone(userId: bigint): Promise<string | null> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { phone: true },
});
return user?.phone ?? null;
}
isVisibleToViewer(
product: {
visibilityWhitelistEnabled: boolean;
visibilityPhones: Array<{ phone: string }>;
},
viewer?: CatalogViewer,
): boolean {
if (viewer?.bypassWhitelist) return true;
if (!product.visibilityWhitelistEnabled) return true;
const phone = normalizePhone(viewer?.phone);
if (!phone) return false;
return product.visibilityPhones.some((row) => normalizePhone(row.phone) === phone);
}
}
@@ -1,5 +1,6 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import {
HQ_DANGEROUS_PERMISSION_KEYS,
HQ_PERMISSION_CATALOG,
HQ_ROLE_DEFAULT_PERMISSIONS,
LEGACY_SYSTEM_SETTINGS_KEY,
@@ -82,7 +83,10 @@ export class AdminHqPermissionsService {
const userPermissionKeys = expandHqPermissionKeys(userPerms.map((p) => p.permissionKey));
if (account.adminRole === 'SUPER_ADMIN') {
const rolePermissionKeys = hqBasePermissionKeys();
const rolePermissionKeys = [
...hqBasePermissionKeys(),
...HQ_DANGEROUS_PERMISSION_KEYS,
] as HqPermissionKey[];
const effectivePermissionKeys = [
...new Set([...rolePermissionKeys, ...userPermissionKeys]),
] as HqPermissionKey[];
@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import {
HqPermissionGuard,
@@ -43,6 +43,18 @@ export class AdminOrdersController {
return this.ordersService.detail(BigInt(id));
}
@Delete(':id')
@UseGuards(HqPermissionGuard)
@RequireHqPermissions('orders_delete')
@HqOperation({
action: HqOperationAction.ORDER_DELETE,
refType: 'ORDER',
refIdParam: 'id',
})
remove(@Param('id') id: string) {
return this.ordersService.deleteOrder(BigInt(id));
}
/** HQ 发货:调用小飞侠创建运单并更新配送信息 */
@Post(':id/ship')
@HqOperation({
@@ -31,6 +31,7 @@ export class AdminOrdersService {
if (query.orderNo) where.orderNo = { contains: query.orderNo };
if (query.status) where.status = query.status as Prisma.EnumOrderStatusFilter['equals'];
if (query.orderType) where.orderType = query.orderType as Prisma.EnumOrderTypeFilter['equals'];
if (query.userId) where.userId = BigInt(query.userId);
if (query.cityId) where.cityId = BigInt(query.cityId);
if (query.receiverPhone) where.receiverPhone = { contains: query.receiverPhone };
@@ -335,6 +336,25 @@ export class AdminOrdersService {
};
}
async deleteOrder(id: bigint) {
const order = await this.prisma.order.findUnique({
where: { id },
select: { id: true, orderNo: true },
});
if (!order) throw new NotFoundException('订单不存在');
await this.prisma.$transaction(async (tx) => {
await this.deleteOrdersInTx(tx, [id]);
});
return {
ok: true,
deleted: 1,
orderNo: order.orderNo,
message: `订单 ${order.orderNo} 及关联业务数据已删除`,
};
}
private async deleteOrdersInTx(tx: Prisma.TransactionClient, orderIds: bigint[]) {
if (!orderIds.length) return;
@@ -368,6 +388,10 @@ export class AdminOrdersService {
await tx.benefitCoupon.deleteMany({ where: { id: { in: couponIds } } });
}
await tx.userInvoice.deleteMany({ where: { orderId: { in: orderIds } } });
await tx.wineryBillItem.deleteMany({ where: { orderId: { in: orderIds } } });
await tx.logisticsBillItem.deleteMany({ where: { orderId: { in: orderIds } } });
await tx.order.updateMany({
where: { originOrderId: { in: orderIds } },
data: { originOrderId: null },
@@ -6,6 +6,24 @@ import { groupResourcesByProductId, mapProductMedia } from '../catalog/catalog.m
import type { AdminProductsQueryDto } from './dto/admin-query.dto';
import type { CreateProductDto, UpdateProductDto } from './dto/admin-mutate.dto';
function normalizePhones(phones?: string[]): string[] {
if (!phones?.length) return [];
const out: string[] = [];
const seen = new Set<string>();
for (const raw of phones) {
const phone = String(raw || '')
.replace(/\D/g, '')
.trim();
if (!phone || seen.has(phone)) continue;
if (!/^1\d{10}$/.test(phone)) {
throw new BadRequestException(`手机号格式无效:${raw}`);
}
seen.add(phone);
out.push(phone);
}
return out;
}
@Injectable()
export class AdminProductsService {
constructor(private readonly prisma: PrismaService) {}
@@ -24,7 +42,10 @@ export class AdminProductsService {
orderBy: { sortOrder: 'asc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: { coverResource: true },
include: {
coverResource: true,
visibilityPhones: { select: { phone: true }, orderBy: { phone: 'asc' } },
},
}),
this.prisma.commonProductItem.count({ where }),
]);
@@ -54,7 +75,10 @@ export class AdminProductsService {
async detail(id: bigint) {
const product = await this.prisma.commonProductItem.findUnique({
where: { id },
include: { coverResource: true },
include: {
coverResource: true,
visibilityPhones: { select: { phone: true }, orderBy: { phone: 'asc' } },
},
});
if (!product) throw new NotFoundException('商品不存在');
@@ -77,6 +101,9 @@ export class AdminProductsService {
});
if (exists) throw new BadRequestException('SKU 或 69 码已存在');
const phones = normalizePhones(dto.visibilityPhones);
const whitelistEnabled = !!dto.visibilityWhitelistEnabled;
const product = await this.prisma.commonProductItem.create({
data: {
skuCode: dto.skuCode,
@@ -90,9 +117,17 @@ export class AdminProductsService {
status: (dto.status ?? 'DRAFT') as 'DRAFT' | 'ON_SALE' | 'OFF_SALE',
sortOrder: dto.sortOrder ?? 0,
allowOnSitePickup: dto.allowOnSitePickup ?? false,
visibilityWhitelistEnabled: whitelistEnabled,
...(dto.detailContent !== undefined
? { detailContent: dto.detailContent as Prisma.InputJsonValue }
: {}),
...(phones.length
? {
visibilityPhones: {
create: phones.map((phone) => ({ phone })),
},
}
: {}),
},
});
@@ -120,12 +155,19 @@ export class AdminProductsService {
...(dto.status !== undefined ? { status: dto.status as 'DRAFT' | 'ON_SALE' | 'OFF_SALE' } : {}),
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
...(dto.allowOnSitePickup !== undefined ? { allowOnSitePickup: dto.allowOnSitePickup } : {}),
...(dto.visibilityWhitelistEnabled !== undefined
? { visibilityWhitelistEnabled: !!dto.visibilityWhitelistEnabled }
: {}),
...(dto.detailContent !== undefined
? { detailContent: dto.detailContent as Prisma.InputJsonValue }
: {}),
},
});
if (dto.visibilityPhones !== undefined) {
await this.syncVisibilityPhones(id, normalizePhones(dto.visibilityPhones));
}
if (dto.coverUrl) {
await this.syncCover(id, dto.coverUrl);
}
@@ -154,13 +196,30 @@ export class AdminProductsService {
return { ok: true };
}
private async syncVisibilityPhones(productId: bigint, phones: string[]) {
await this.prisma.$transaction(async (tx) => {
await tx.commonProductVisibilityPhone.deleteMany({ where: { productId } });
if (!phones.length) return;
await tx.commonProductVisibilityPhone.createMany({
data: phones.map((phone) => ({ productId, phone })),
});
});
}
private formatProduct(
product: Prisma.CommonProductItemGetPayload<{ include: { coverResource: true } }>,
product: Prisma.CommonProductItemGetPayload<{
include: {
coverResource: true;
visibilityPhones: { select: { phone: true } };
};
}>,
extraResources: Prisma.CommonResourceGetPayload<object>[],
) {
const media = mapProductMedia(product, extraResources);
const phones = product.visibilityPhones?.map((row) => row.phone) ?? [];
return {
...product,
visibilityPhones: phones,
price: Number(product.price),
benefitAmount: Number(product.benefitAmount ?? product.price),
...media,
@@ -0,0 +1,45 @@
import { Body, Controller, Get, 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';
import {
HqPermissionGuard,
RequireHqPermissions,
} from '../../common/guards/hq-permission.guard';
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';
@Controller('admin/proxy-orders')
@UseGuards(HqAuthGuard, HqPermissionGuard)
@RequireHqPermissions('orders')
export class AdminProxyOrdersController {
constructor(private readonly tradeService: TradeService) {}
@Get('options')
options() {
return this.tradeService.getHqProxyOrderOptions();
}
@Post('preview')
preview(@Body() dto: HqProxyOrderPreviewDto) {
return this.tradeService.previewPartnerProxyOrder(dto, { bypassWhitelist: true });
}
@Post()
@HqOperation({
action: HqOperationAction.ORDER_PROXY_CREATE,
refType: 'ORDER',
batch: true,
includeBody: true,
})
create(
@CurrentUser() user: AuthUser,
@Body() dto: HqProxyOrderCreateDto,
@Req() req: Request,
) {
return this.tradeService.createHqProxyOrder(user.actorId, dto, req);
}
}
@@ -1110,6 +1110,17 @@ export class CreateProductDto {
@IsBoolean()
allowOnSitePickup?: boolean;
/** 开启后仅白名单手机号在 C 端可见/可购 */
@IsOptional()
@IsBoolean()
visibilityWhitelistEnabled?: boolean;
/** 可见白名单手机号列表 */
@IsOptional()
@IsArray()
@IsString({ each: true })
visibilityPhones?: string[];
@IsOptional()
@IsString()
coverUrl?: string;
@@ -1162,6 +1173,17 @@ export class UpdateProductDto {
@IsBoolean()
allowOnSitePickup?: boolean;
/** 开启后仅白名单手机号在 C 端可见/可购 */
@IsOptional()
@IsBoolean()
visibilityWhitelistEnabled?: boolean;
/** 可见白名单手机号列表 */
@IsOptional()
@IsArray()
@IsString({ each: true })
visibilityPhones?: string[];
@IsOptional()
@IsString()
coverUrl?: string;
@@ -48,6 +48,10 @@ export class AdminOrdersQueryDto extends PaginationQueryDto {
@IsString()
status?: string;
@IsOptional()
@IsIn(['NORMAL', 'PROXY'])
orderType?: string;
@IsOptional()
@IsString()
userId?: string;
@@ -0,0 +1,87 @@
import { Type } from 'class-transformer';
import {
IsBoolean,
IsIn,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Matches,
MaxLength,
Min,
ValidateIf,
} from 'class-validator';
export class HqProxyOrderPreviewDto {
@IsString()
@IsNotEmpty()
productId: string;
@Type(() => Number)
@IsInt()
@Min(1)
quantity: number;
@IsOptional()
@IsIn(['ADDRESS', 'ON_SITE_PICKUP'])
deliveryMode?: 'ADDRESS' | 'ON_SITE_PICKUP';
@IsOptional()
@IsString()
receiverCity?: string;
@IsOptional()
@IsString()
receiverDistrict?: string;
}
export class HqProxyOrderCreateDto {
@IsString()
@Matches(/^1\d{10}$/, { message: '请输入有效手机号' })
phone: string;
@IsIn(['ADDRESS', 'ON_SITE_PICKUP'])
deliveryMode: 'ADDRESS' | 'ON_SITE_PICKUP';
@ValidateIf((o: HqProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS')
@IsBoolean()
autoReceive?: boolean;
@IsOptional()
@IsString()
@MaxLength(32)
receiverName?: string;
@ValidateIf((o: HqProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS')
@IsString()
@MaxLength(32)
province?: string;
@ValidateIf((o: HqProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS')
@IsString()
@MaxLength(32)
city?: string;
@ValidateIf((o: HqProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS')
@IsString()
@MaxLength(32)
district?: string;
@ValidateIf((o: HqProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS')
@IsString()
@MaxLength(256)
addressDetail?: string;
@IsString()
@IsNotEmpty()
productId: string;
@Type(() => Number)
@IsInt()
@Min(1)
quantity: number;
@IsOptional()
@IsString()
promoCodeId?: string;
}
@@ -10,6 +10,7 @@ import { AdminUsersController } from './admin-users.controller';
import { AdminUsersService } from './admin-users.service';
import { AdminOrdersController } from './admin-orders.controller';
import { AdminOrdersService } from './admin-orders.service';
import { AdminProxyOrdersController } from './admin-proxy-orders.controller';
import { AdminStoresController, AdminStoreAccountsController, AdminStoreMediaController } from './admin-stores.controller';
import { AdminStoreCategoriesController } from './admin-store-categories.controller';
import { AdminStoresService } from './admin-stores.service';
@@ -76,6 +77,7 @@ import { AdminFulfillmentProvidersController } from './admin-fulfillment-provide
AdminDeployController,
AdminUsersController,
AdminOrdersController,
AdminProxyOrdersController,
AdminStoresController,
AdminStoreAccountsController,
AdminStoreMediaController,
@@ -39,25 +39,11 @@ export class PartnerProxyOrderPreviewDto {
receiverDistrict?: string;
}
export class PartnerProxyOrderSendCustomerSmsDto {
@IsString()
@Matches(/^1\d{10}$/, { message: '请输入有效手机号' })
phone: string;
}
export class PartnerProxyOrderCreateDto {
@IsString()
@Matches(/^1\d{10}$/, { message: '请输入有效手机号' })
phone: string;
@IsString()
@IsNotEmpty()
customerSmsCode: string;
@IsString()
@IsNotEmpty()
partnerSmsCode: string;
@IsIn(['ADDRESS', 'ON_SITE_PICKUP'])
deliveryMode: 'ADDRESS' | 'ON_SITE_PICKUP';
@@ -9,7 +9,6 @@ import { CurrentUser } from '../../common/decorators/current-user.decorator';
import {
PartnerProxyOrderCreateDto,
PartnerProxyOrderPreviewDto,
PartnerProxyOrderSendCustomerSmsDto,
} from './dto/partner-proxy-order.dto';
import { ManualShipOrderDto } from '../ops/dto/admin-mutate.dto';
import { CreateAfterSaleTicketDto, CreateInvoiceDto } from './dto/after-sale.dto';
@@ -67,10 +66,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',
});
}
@@ -208,25 +208,29 @@ export class PartnerProxyOrderController {
return this.tradeService.getPartnerProxyOrderOptions(user.actorId);
}
@Get()
list(
@CurrentUser() user: AuthUser,
@Query('page') page = '1',
@Query('pageSize') pageSize = '20',
@Query('keyword') keyword?: string,
) {
return this.tradeService.listPartnerProxyOrders(
user.actorId,
Number(page),
Number(pageSize),
keyword,
);
}
@Get(':id')
detail(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.tradeService.getPartnerProxyOrder(user.actorId, BigInt(id));
}
@Post('preview')
preview(@Body() dto: PartnerProxyOrderPreviewDto) {
return this.tradeService.previewPartnerProxyOrder(dto);
}
/** @deprecated 兼容:转发客户短信 */
@Post('send-sms')
sendSms(@Body() dto: PartnerProxyOrderSendCustomerSmsDto) {
return this.tradeService.sendPartnerProxyCustomerSms(dto.phone);
}
@Post('send-customer-sms')
sendCustomerSms(@Body() dto: PartnerProxyOrderSendCustomerSmsDto) {
return this.tradeService.sendPartnerProxyCustomerSms(dto.phone);
}
@Post('send-partner-sms')
sendPartnerSms(@CurrentUser() user: AuthUser) {
return this.tradeService.sendPartnerProxyPartnerSms(user.actorId);
preview(@CurrentUser() user: AuthUser, @Body() dto: PartnerProxyOrderPreviewDto) {
return this.tradeService.previewPartnerProxyOrderForPartner(user.actorId, dto);
}
@Post()
@@ -12,7 +12,7 @@ import {
orderTabToStatuses,
validateMinPurchase,
} from '@dukang/domain';
import { loadAppConfig, ClientApp, SmsScene, WECHAT_AUTH_REQUIRED } from '@dukang/shared-types';
import { loadAppConfig, ClientApp, WECHAT_AUTH_REQUIRED } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { AnalyticsService } from '../analytics/analytics.service';
@@ -31,6 +31,7 @@ import { extractClientIp } from '../../common/geo/client-ip.util';
import { buildOrderStatusEvent, orderStatusLogWhere } from '../../common/event/event.helpers';
import { mapOrderCompat, mapStatusLogCompat } from '../../common/compat/v31-compat';
import { FulfillmentService } from '../fulfillment/fulfillment.service';
import { WechatOrderShippingService } from '../../integrations/wechat/wechat-order-shipping.service';
import type { Request } from 'express';
@Injectable()
@@ -49,13 +50,15 @@ export class TradeService {
private readonly promoCodeService: PromoCodeService,
@Inject(forwardRef(() => FulfillmentService))
private readonly fulfillmentService: FulfillmentService,
private readonly wechatOrderShipping: WechatOrderShippingService,
) {}
async preview(
userId: bigint,
body: { productId: string; quantity: number; addressId?: string; onSitePickup?: boolean },
) {
const product = await this.catalogService.getProduct(BigInt(body.productId));
const viewerPhone = await this.catalogService.resolveUserPhone(userId);
const product = await this.catalogService.getProduct(BigInt(body.productId), { phone: viewerPhone });
if (!product || product.status !== 'ON_SALE') {
throw new BadRequestException('商品不可购买');
}
@@ -349,6 +352,8 @@ export class TradeService {
}
if (order.deliveryType === 'ON_SITE_PICKUP') {
// 现场取货:支付后即向微信录入「用户自提」发货信息
this.wechatOrderShipping.uploadForOrderSafe(orderId);
return;
}
@@ -488,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) {
@@ -532,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('订单不存在');
@@ -541,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);
}
@@ -1021,12 +1040,18 @@ export class TradeService {
}),
});
});
// 发货信息管理:进入 SHIPPING 时向微信录入(解冻结算前置)
if (targetStatus === 'SHIPPING' && currentStatus !== 'SHIPPING') {
this.wechatOrderShipping.uploadForOrderSafe(orderId);
}
}
async getPartnerProxyOrderOptions(partnerAccountId: bigint) {
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
const [products, promoCodes, stores] = await Promise.all([
this.catalogService.listProducts(),
// 遵守商品白名单:未开放的测试酒不对合伙人可见
this.catalogService.listProducts(undefined, undefined, { phone: primary.phone }),
this.promoCodeService.listActiveOptions(),
this.prisma.store.findMany({
where: { partnerAccountId: primary.id },
@@ -1066,15 +1091,18 @@ export class TradeService {
});
}
async previewPartnerProxyOrder(body: {
productId: string;
quantity: number;
deliveryMode?: 'ADDRESS' | 'ON_SITE_PICKUP';
storeId?: string;
receiverCity?: string;
receiverDistrict?: string;
}) {
const product = await this.catalogService.getProduct(BigInt(body.productId));
async previewPartnerProxyOrder(
body: {
productId: string;
quantity: number;
deliveryMode?: 'ADDRESS' | 'ON_SITE_PICKUP';
storeId?: string;
receiverCity?: string;
receiverDistrict?: string;
},
viewer?: { phone?: string | null; bypassWhitelist?: boolean },
) {
const product = await this.catalogService.getProduct(BigInt(body.productId), viewer ?? {});
if (!product || product.status !== 'ON_SALE') {
throw new BadRequestException('商品不可购买');
}
@@ -1120,45 +1148,26 @@ export class TradeService {
};
}
async sendPartnerProxyCustomerSms(phone: string) {
const normalizedPhone = phone.trim();
await this.authService.sendSms(normalizedPhone, SmsScene.PARTNER_PROXY_CUSTOMER, {
clientApp: ClientApp.PARTNER_H5,
});
const masked =
normalizedPhone.length >= 7
? `${normalizedPhone.slice(0, 3)}****${normalizedPhone.slice(-4)}`
: normalizedPhone;
return { ok: true, maskedPhone: masked };
}
/** @deprecated 兼容旧前端:转发为客户短信 */
async sendPartnerProxyOrderSms(phone: string) {
return this.sendPartnerProxyCustomerSms(phone);
}
async sendPartnerProxyPartnerSms(partnerAccountId: bigint) {
/** 合伙人代下单预览:按合伙人手机号遵守商品白名单 */
async previewPartnerProxyOrderForPartner(
partnerAccountId: bigint,
body: {
productId: string;
quantity: number;
deliveryMode?: 'ADDRESS' | 'ON_SITE_PICKUP';
storeId?: string;
receiverCity?: string;
receiverDistrict?: string;
},
) {
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
const partnerPhone = primary.phone?.trim();
if (!partnerPhone || !/^1\d{10}$/.test(partnerPhone)) {
throw new BadRequestException('合伙人手机号无效,无法发送确认验证码');
}
await this.authService.sendSms(partnerPhone, SmsScene.PARTNER_PROXY_ORDER, {
clientApp: ClientApp.PARTNER_H5,
});
const masked =
partnerPhone.length >= 7
? `${partnerPhone.slice(0, 3)}****${partnerPhone.slice(-4)}`
: partnerPhone;
return { ok: true, maskedPhone: masked };
return this.previewPartnerProxyOrder(body, { phone: primary.phone });
}
async createPartnerProxyOrder(
partnerAccountId: bigint,
body: {
phone: string;
customerSmsCode: string;
partnerSmsCode: string;
deliveryMode: 'ADDRESS' | 'ON_SITE_PICKUP';
autoReceive?: boolean;
storeId?: string;
@@ -1174,23 +1183,15 @@ export class TradeService {
req: Request,
) {
const normalizedPhone = body.phone.trim();
if (!/^1\d{10}$/.test(normalizedPhone)) {
throw new BadRequestException('请输入有效手机号');
}
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
const partnerPhone = primary.phone?.trim();
if (!partnerPhone || !/^1\d{10}$/.test(partnerPhone)) {
throw new BadRequestException('合伙人手机号无效');
}
await this.authService.verifySmsCode(
normalizedPhone,
body.customerSmsCode.trim(),
SmsScene.PARTNER_PROXY_CUSTOMER,
);
await this.authService.verifySmsCode(
partnerPhone,
body.partnerSmsCode.trim(),
SmsScene.PARTNER_PROXY_ORDER,
);
if (body.deliveryMode === 'ADDRESS' && body.autoReceive !== true) {
throw new BadRequestException('配送到址须勾选同意自动收货');
}
@@ -1206,14 +1207,17 @@ export class TradeService {
sourceLabel: `代下单·${maskedPartnerPhone}`,
});
const preview = await this.previewPartnerProxyOrder({
productId: body.productId,
quantity: body.quantity,
deliveryMode: body.deliveryMode,
storeId: body.storeId,
receiverCity: body.city,
receiverDistrict: body.district,
});
const preview = await this.previewPartnerProxyOrder(
{
productId: body.productId,
quantity: body.quantity,
deliveryMode: body.deliveryMode,
storeId: body.storeId,
receiverCity: body.city,
receiverDistrict: body.district,
},
{ phone: primary.phone },
);
const product = await this.prisma.commonProductItem.findUniqueOrThrow({
where: { id: BigInt(body.productId) },
@@ -1355,6 +1359,288 @@ export class TradeService {
},
});
return this.getPartnerOrder(partnerAccountId, order.id);
return serializeBigInt(mapOrderCompat(order));
}
/** 合伙人代下单列表:按 proxyPartnerAccountId 归属,与管仓订单无关 */
async listPartnerProxyOrders(
partnerAccountId: bigint,
page = 1,
pageSize = 20,
keyword?: string,
) {
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
const kw = keyword?.trim();
const where = {
orderType: 'PROXY' as const,
proxyPartnerAccountId: primary.id,
...(kw
? {
OR: [
{ orderNo: { contains: kw } },
{ receiverPhone: { contains: kw } },
{ receiverName: { contains: kw } },
{ productName: { contains: kw } },
],
}
: {}),
};
const take = Math.min(Math.max(pageSize, 1), 50);
const skip = (Math.max(page, 1) - 1) * take;
const [list, total] = await Promise.all([
this.prisma.order.findMany({
where,
include: {
delivery: true,
imageResource: true,
user: { select: { phone: true, nickname: true } },
},
orderBy: { createdAt: 'desc' },
skip,
take,
}),
this.prisma.order.count({ where }),
]);
return {
list: serializeBigInt(list.map(mapOrderCompat)),
total,
page: Math.max(page, 1),
pageSize: take,
};
}
async getPartnerProxyOrder(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,
},
include: {
delivery: true,
user: true,
imageResource: true,
},
});
if (!order) throw new NotFoundException('代下单不存在');
const statusLogs = await this.prisma.commonEvent.findMany({
where: orderStatusLogWhere(orderId),
orderBy: { createdAt: 'desc' },
});
return serializeBigInt(mapOrderCompat({ ...order, statusLogs: mapStatusLogCompat(statusLogs) }));
}
/** HQ 代下单:商品/推广码选项(运营侧可看白名单测试酒) */
async getHqProxyOrderOptions() {
const [products, promoCodes] = await Promise.all([
this.catalogService.listProducts(undefined, undefined, { bypassWhitelist: true }),
this.promoCodeService.listActiveOptions(),
]);
return serializeBigInt({
products: products.map((p) => ({
id: p.id,
name: p.name,
spec: p.spec,
price: Number(p.price),
benefitAmount: p.benefitAmount != null ? Number(p.benefitAmount) : null,
coverUrl: (p as { mainImageUrl?: string | null }).mainImageUrl ?? null,
allowOnSitePickup: !!(p as { allowOnSitePickup?: boolean }).allowOnSitePickup,
})),
promoCodes,
stores: [],
});
}
async createHqProxyOrder(
hqAccountId: bigint,
body: {
phone: string;
deliveryMode: 'ADDRESS' | 'ON_SITE_PICKUP';
autoReceive?: boolean;
receiverName?: string;
province?: string;
city?: string;
district?: string;
addressDetail?: string;
productId: string;
quantity: number;
promoCodeId?: string;
},
req: Request,
) {
const normalizedPhone = body.phone.trim();
if (!/^1\d{10}$/.test(normalizedPhone)) {
throw new BadRequestException('请输入有效手机号');
}
const hq = await this.prisma.hqAccount.findUnique({ where: { id: hqAccountId } });
if (!hq || hq.status !== 'ACTIVE') {
throw new BadRequestException('总部账号无效');
}
const operatorPhone = hq.phone?.trim();
if (!operatorPhone || !/^1\d{10}$/.test(operatorPhone)) {
throw new BadRequestException('总部账号手机号无效');
}
if (body.deliveryMode === 'ADDRESS' && body.autoReceive !== true) {
throw new BadRequestException('配送到址须勾选同意自动收货');
}
const maskedOperatorPhone =
operatorPhone.length >= 7
? `${operatorPhone.slice(0, 3)}****${operatorPhone.slice(-4)}`
: operatorPhone;
const proxyDisplayName = `总部·${hq.name}`;
const user = await this.authService.findOrCreateUserByPhone(normalizedPhone, {
sourceType: 'PARTNER_PROXY',
sourceRefId: hq.id,
sourceLabel: `总部代下单·${maskedOperatorPhone}`,
});
const preview = await this.previewPartnerProxyOrder(
{
productId: body.productId,
quantity: body.quantity,
deliveryMode: body.deliveryMode,
receiverCity: body.city,
receiverDistrict: body.district,
},
{ bypassWhitelist: true },
);
const product = await this.prisma.commonProductItem.findUniqueOrThrow({
where: { id: BigInt(body.productId) },
});
const city = await this.prisma.commonCity.findFirstOrThrow({ where: { status: 'ACTIVE' } });
let receiverName = body.receiverName?.trim() || `用户${normalizedPhone.slice(-4)}`;
let receiverProvince = body.province?.trim() || '';
let receiverCity = body.city?.trim() || '';
let receiverDistrict = body.district?.trim() || '';
let receiverAddress = '';
let commissionDistrict = receiverDistrict;
if (body.deliveryMode === 'ON_SITE_PICKUP') {
receiverName = body.receiverName?.trim() || `用户${normalizedPhone.slice(-4)}`;
receiverProvince = '现场';
receiverCity = '现场';
receiverDistrict = '取货';
receiverAddress = '现场提货';
commissionDistrict = '';
} else {
if (!receiverProvince || !receiverCity || !receiverDistrict) {
throw new BadRequestException('请选择省市区');
}
if (!body.addressDetail?.trim()) {
throw new BadRequestException('请填写详细地址');
}
receiverAddress = `${receiverProvince}${receiverCity}${receiverDistrict}${body.addressDetail.trim()}`;
}
const paySnapshot = await this.partnerCityService.resolveForOrder(city.id, commissionDistrict);
let promoCodeId: bigint | undefined;
if (body.promoCodeId?.trim()) {
promoCodeId = BigInt(body.promoCodeId.trim());
await this.promoCodeService.attributeUserToPromo(user.id, promoCodeId);
}
const orderNo = generateOrderNo();
const now = new Date();
const location = buildOrderClientLocationSnapshot(
req,
this.ipGeoService.resolve(extractClientIp(req)),
undefined,
);
const order = await this.prisma.$transaction(async (tx) => {
const created = await tx.order.create({
data: {
orderNo,
orderType: 'PROXY',
userId: user.id,
cityId: city.id,
status: 'COMPLETED',
payStatus: 'PAID',
deliveryType: preview.deliveryType,
channelSource: 'OFFLINE_PROXY',
promoCodeId,
productId: product.id,
barcode69: product.barcode69,
productName: product.name,
productSpec: product.spec,
imageResourceId: product.coverResourceId,
quantity: body.quantity,
listUnitPrice: product.price,
listAmount: preview.productAmount,
productAmount: preview.productAmount,
payAmount: preview.payAmount,
benefitAmount: preview.benefitAmount,
freightAmount: 0,
freightPayType: preview.deliveryType === 'CROSS_CITY' ? 'COD' : null,
receiverName,
receiverPhone: normalizedPhone,
receiverAddress,
receiverProvince,
receiverCity,
receiverDistrict,
clientIp: location.clientIp,
ipProvince: location.ipProvince,
ipCity: location.ipCity,
ipDistrict: location.ipDistrict,
paidAt: now,
shippedAt: now,
completedAt: now,
partnerAccountIdAtPay: paySnapshot?.partnerAccountId ?? null,
orderCommissionRateAtPay: paySnapshot?.orderCommissionRate ?? null,
proxyPartnerAccountId: null,
proxyPartnerName: proxyDisplayName,
proxyPartnerPhone: operatorPhone,
remark: `总部代下单 hqAccountId=${hq.id} deliveryMode=${body.deliveryMode} customer=${normalizedPhone}`,
},
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',
operator: 'HQ_PROXY',
remark: `总部线下代下单 mode=${body.deliveryMode} customer=${normalizedPhone} hq=${hq.id}`,
}),
});
if (promoCodeId) {
await tx.commonPromoCode.update({
where: { id: promoCodeId },
data: { orderCount: { increment: 1 } },
});
}
return created;
});
await this.benefitService.grantOnOrderPaid(order.id);
return serializeBigInt({
id: order.id,
orderNo: order.orderNo,
status: order.status,
payAmount: Number(order.payAmount),
benefitAmount: Number(order.benefitAmount),
proxyPartnerName: proxyDisplayName,
});
}
}