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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: '详情页',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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}` : '位置已选定');
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
@@ -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' });
|
||||
}
|
||||
|
||||
|
||||
@@ -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('当前环境无法打开微信确认收货组件,请用微信最新版打开小程序后重试');
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) : '') ||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -21,8 +21,13 @@ export interface AppConfig {
|
||||
aliyunSmsProxyOrderTemplateCode: string;
|
||||
aliyunSmsAccessKeyId: string;
|
||||
aliyunSmsAccessKeySecret: string;
|
||||
/** 腾讯位置服务 Key(逆地理编码) */
|
||||
/** 腾讯位置服务 Key(逆地理编码 / 地点搜索) */
|
||||
tencentLbsKey: string;
|
||||
/**
|
||||
* 腾讯位置服务 SecretKey(SK)
|
||||
* 控制台开启 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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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=
|
||||
|
||||
# 阿里云 OSS(ali-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
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
* 小程序消息推送(发货信息管理事件)。
|
||||
* 后台配置 URL:https://{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: '消息推送安全模式 EncodingAESKey(43 位);明文模式可留空',
|
||||
},
|
||||
|
||||
{
|
||||
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: '腾讯位置服务 SecretKey(SK)',
|
||||
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';
|
||||
|
||||
/**
|
||||
* 腾讯位置服务 WebServiceAPI(GET)签名 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_state:3 确认收货;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 @@
|
||||
/** 常见快递公司名称 → 微信运力 ID(get_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_token(getPhoneNumber 等 wxa 接口必须用小程序 AppID) */
|
||||
private async getMiniAccessToken(): Promise<string> {
|
||||
/** 小程序 access_token(getPhoneNumber / 发货管理等 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||