Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 029c17347e | |||
| b626db5d84 | |||
| d13a6ee3cd | |||
| 10fa361983 | |||
| 0269802e59 | |||
| e4e9eb2169 | |||
| 9d800a4cbd | |||
| 2a9493165b | |||
| 6816835fb8 | |||
| 571959900b | |||
| 397bdad73f | |||
| 83b1b0e5f6 | |||
| 1933b9b608 | |||
| a2abbb6169 | |||
| 53a79d1255 |
@@ -39,6 +39,9 @@ src/
|
||||
|
||||
## UI 约束
|
||||
|
||||
- 使用 shared-ui CSS 变量与组件,勿各端自造设计 token
|
||||
- **mini-user 微信 `openType` Button**:祖先禁止 `e.stopPropagation()`(Taro→`catchtap`,选头像/手机号等会静默失效);遮罩与 sheet 拆开绑关闭。见 `.cursor/rules/mini-user-weapp-opentype.mdc`
|
||||
|
||||
- C 端订单 **5 Tab**(含 pending_ship)
|
||||
- 门店列表仅 `OPEN` 状态
|
||||
- 原型 `pages/` 只读;路由对照 `pages/ROUTE_MAP.md`
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
description: 微信小程序 open-type 按钮踩坑 — 禁止祖先 catchtap / stopPropagation
|
||||
globs: apps/mini-user/**/*.{tsx,ts,css,scss}
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# mini-user · 微信 open-type 硬规则
|
||||
|
||||
## 禁止(必踩坑)
|
||||
|
||||
**现象**:`Button openType="chooseAvatar" | getPhoneNumber | getUserInfo | share | contact"` 点击无反应、无回调。
|
||||
|
||||
**根因**:祖先节点上的 `onClick={(e) => e.stopPropagation()}` 在 Taro 微信端会编译成 **`catchtap`**,拦截子级 `button` 的原生 open-type 能力。
|
||||
|
||||
```tsx
|
||||
// ❌ 弹层内容上 stopPropagation — 内部 chooseAvatar 会失效
|
||||
<View className="mask" onClick={close}>
|
||||
<View className="sheet" onClick={(e) => e.stopPropagation()}>
|
||||
<Button openType="chooseAvatar" onChooseAvatar={...}>选头像</Button>
|
||||
</View>
|
||||
</View>
|
||||
```
|
||||
|
||||
## 正确写法
|
||||
|
||||
遮罩与内容拆开:只在 **backdrop** 上关弹层,**sheet 不要**绑 stopPropagation / catchtap。
|
||||
|
||||
```tsx
|
||||
// ✅
|
||||
<View className="mask">
|
||||
<View className="backdrop" onClick={close} />
|
||||
<View className="sheet">
|
||||
<Button openType="chooseAvatar" plain hoverClass="none" onChooseAvatar={...}>
|
||||
...
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
```
|
||||
|
||||
## 附加
|
||||
|
||||
- `Button` 内 `Image` / 文案加 `pointer-events: none`(或父级 `> * { pointer-events: none }`),避免抢触摸
|
||||
- 同类能力:`getPhoneNumber`、`contact`、`share` 同样忌祖先 `catchtap`
|
||||
- 详情见知识库「C 端 · 踩坑 · chooseAvatar」
|
||||
|
||||
参照实现:`apps/mini-user/src/pages/mine/index.tsx` 资料弹层。
|
||||
@@ -11,7 +11,7 @@
|
||||
| **V3.0 唯一需求源** | [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md) | 产品需求与三波交付(**只看这个**) |
|
||||
| **现状对照** | [`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md) | 已完成 / 冲突 / 缺口审计 |
|
||||
| **V3 实现验收** | [`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md) | 核销规则、分工、闭环 |
|
||||
| ~~V2 / preV1~~ | 历史参考 | **不再作为需求依据** |
|
||||
| ~~V2 / preV1~~ | 历史参考(**已压缩**;V2 全文见 git 2026-08-06 前) | **不再作为需求依据** |
|
||||
|
||||
**禁止**:臆造 PRD 未定义规则;依赖 `doc/` 下过时文档;跨 OWNER 直写他人 Prisma 表。
|
||||
|
||||
@@ -137,6 +137,9 @@ C 端门店仅 status=OPEN
|
||||
订单 Tab:待付款 | 已付款 | 已完成
|
||||
```
|
||||
|
||||
**iOS 微信 H5 JSSDK**:登录/OAuth 后禁止仅 SPA 跳转再调扫码;见 `packages/weixin-sdk/GOTCHAS.md`、知识库「门店端 · 踩坑」。
|
||||
**微信小程序 open-type**:`chooseAvatar` 等 Button 的祖先禁止 `stopPropagation`(会编成 catchtap);见知识库「C 端 · 踩坑」、`.cursor/rules/mini-user-weapp-opentype.mdc`。
|
||||
|
||||
## 环境与发版
|
||||
|
||||
| 环境 | 分支 | 目录 | 端口 | 域名 |
|
||||
|
||||
@@ -22,6 +22,7 @@ V2 规划中的 `mini-partner` / `mini-hq` 非 V3 主交付。
|
||||
- 禁止 import `server/` 或另一个 `apps/*` 的源码
|
||||
- UI 共享组件优先 `@dukang/shared-ui`
|
||||
- C 端订单列表 **3 Tab**:`待付款 | 已付款 | 已完成`(Tab key: `pending_pay` / `paid` / `completed`)
|
||||
- **mini-user 微信 open-type**:含 `chooseAvatar` / `getPhoneNumber` 等的 `Button`,祖先禁止 `stopPropagation`(会编成 `catchtap` 导致点击无反应);见知识库 C 端踩坑、`.cursor/rules/mini-user-weapp-opentype.mdc`
|
||||
|
||||
## 新页面 workflow
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ import PartnerLogsPage from './pages/PartnerLogsPage';
|
||||
import WechatBindingsPage from './pages/WechatBindingsPage';
|
||||
import HqPermissionsPage from './pages/HqPermissionsPage';
|
||||
import SystemSettingsPage from './pages/SystemSettingsPage';
|
||||
import TestWhitelistPage from './pages/TestWhitelistPage';
|
||||
import WecomBotsPage from './pages/WecomBotsPage';
|
||||
import WecomMessagePushesPage from './pages/WecomMessagePushesPage';
|
||||
import WecomBotLogsPage from './pages/WecomBotLogsPage';
|
||||
@@ -136,6 +137,7 @@ export default function App() {
|
||||
<Route path="/deliveries" element={<DeliveriesPage />} />
|
||||
<Route path="/deliveries/xiaofeixia" element={<XiaofeixiaTestPage />} />
|
||||
<Route path="/hq-permissions" element={<HqPermissionsPage />} />
|
||||
<Route path="/test-whitelist" element={<TestWhitelistPage />} />
|
||||
<Route path="/system-settings" element={<SystemSettingsPage />} />
|
||||
<Route path="/hq-accounts" element={<HqAccountsPage />} />
|
||||
</Route>
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Empty,
|
||||
Image,
|
||||
Space,
|
||||
Spin,
|
||||
Timeline,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { OrderTrackDto, OrderTrackNodeDto } from '@dukang/shared-types';
|
||||
import { isXfxProviderCode } from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { DELIVERY_PROVIDER_LABELS, fmtTime } from '../lib/constants';
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
orderId: string | null;
|
||||
orderNo?: string | null;
|
||||
orderStatus?: string | null;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function providerLabel(provider?: string | null, company?: string | null) {
|
||||
if (company) return company;
|
||||
if (!provider) return '—';
|
||||
if (DELIVERY_PROVIDER_LABELS[provider]) return DELIVERY_PROVIDER_LABELS[provider];
|
||||
if (isXfxProviderCode(provider)) return '小飞侠';
|
||||
return provider;
|
||||
}
|
||||
|
||||
function sortOldestFirst(nodes: OrderTrackNodeDto[]) {
|
||||
return [...nodes].sort((a, b) => {
|
||||
const ta = new Date(a.createTime).getTime();
|
||||
const tb = new Date(b.createTime).getTime();
|
||||
if (Number.isNaN(ta) && Number.isNaN(tb)) return 0;
|
||||
if (Number.isNaN(ta)) return 1;
|
||||
if (Number.isNaN(tb)) return -1;
|
||||
return ta - tb;
|
||||
});
|
||||
}
|
||||
|
||||
export default function OrderTrackDrawer({
|
||||
open,
|
||||
orderId,
|
||||
orderNo,
|
||||
orderStatus,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [track, setTrack] = useState<OrderTrackDto | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!orderId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await request<OrderTrackDto>(`/admin/orders/${orderId}/track`);
|
||||
setTrack(data);
|
||||
} catch (e) {
|
||||
setTrack(null);
|
||||
message.error(e instanceof Error ? e.message : '加载路由失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [orderId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !orderId) {
|
||||
setTrack(null);
|
||||
return;
|
||||
}
|
||||
void load();
|
||||
}, [open, orderId, load]);
|
||||
|
||||
const nodes = useMemo(
|
||||
() => (track?.nodes?.length ? sortOldestFirst(track.nodes) : []),
|
||||
[track?.nodes],
|
||||
);
|
||||
const timelineComplete = ['PENDING_RECEIVE', 'DELIVERED', 'COMPLETED'].includes(
|
||||
orderStatus || '',
|
||||
);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="物流路由"
|
||||
width={520}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
extra={
|
||||
<Button onClick={() => void load()} loading={loading} disabled={!orderId}>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
{(orderNo || track?.trackingNo || track?.provider) && (
|
||||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||||
{orderNo ? <Descriptions.Item label="订单号">{orderNo}</Descriptions.Item> : null}
|
||||
<Descriptions.Item label="配送方式">
|
||||
{providerLabel(track?.provider, track?.logisticsCompany)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="运单号">{track?.trackingNo || '—'}</Descriptions.Item>
|
||||
{track?.manualQueryUrl ? (
|
||||
<Descriptions.Item label="物流查询">
|
||||
<a href={track.manualQueryUrl} target="_blank" rel="noreferrer">
|
||||
打开物流查询
|
||||
</a>
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
{track?.estimatedArrival?.arriveTime ? (
|
||||
<Descriptions.Item label="预计送达">
|
||||
{fmtTime(track.estimatedArrival.arriveTime)}
|
||||
{track.estimatedArrival.siteName
|
||||
? `(${track.estimatedArrival.siteName})`
|
||||
: ''}
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
</Descriptions>
|
||||
)}
|
||||
|
||||
{track?.signPhotoUrls && track.signPhotoUrls.length > 0 ? (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||
签收照片
|
||||
</Typography.Title>
|
||||
<Image.PreviewGroup>
|
||||
<Space wrap>
|
||||
{track.signPhotoUrls.map((url) => (
|
||||
<Image
|
||||
key={url}
|
||||
src={url}
|
||||
width={96}
|
||||
height={96}
|
||||
style={{ objectFit: 'cover', borderRadius: 6 }}
|
||||
/>
|
||||
))}
|
||||
</Space>
|
||||
</Image.PreviewGroup>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||
物流动态
|
||||
</Typography.Title>
|
||||
{nodes.length > 0 ? (
|
||||
<Timeline
|
||||
items={nodes.map((node, index) => {
|
||||
const isLatest = index === nodes.length - 1;
|
||||
return {
|
||||
color: isLatest ? (timelineComplete ? 'green' : 'blue') : 'gray',
|
||||
children: (
|
||||
<div>
|
||||
{node.statusName ? (
|
||||
<Typography.Text strong style={{ display: 'block' }}>
|
||||
{node.statusName}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
<Typography.Text style={{ display: 'block', whiteSpace: 'pre-wrap' }}>
|
||||
{node.trackInfo || '—'}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{fmtTime(node.createTime)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={
|
||||
track?.manualQueryUrl
|
||||
? '暂无实时路由节点,可使用上方物流查询链接'
|
||||
: '暂无路由信息,请稍后刷新'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Spin>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -146,6 +146,7 @@ const MENU_ITEMS: MenuProps['items'] = [
|
||||
],
|
||||
},
|
||||
{ key: '/hq-permissions', icon: <LockOutlined />, label: '权限分配' },
|
||||
{ key: '/test-whitelist', icon: <SafetyOutlined />, label: '白名单管理' },
|
||||
{ key: '/system-settings', icon: <SettingOutlined />, label: '系统设置' },
|
||||
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
|
||||
];
|
||||
@@ -215,6 +216,7 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
|
||||
'/logs/third-party': 'logs',
|
||||
'/logs/domain-events': 'logs',
|
||||
'/hq-permissions': 'hq_permissions',
|
||||
'/test-whitelist': 'test_whitelist',
|
||||
'/system-settings': 'system_settings_any',
|
||||
'/hq-accounts': 'hq_accounts',
|
||||
};
|
||||
|
||||
@@ -167,6 +167,7 @@ export type AdminUserRow = {
|
||||
sourceLabel: string | null;
|
||||
createdAt: string;
|
||||
orderCount: number;
|
||||
isTest?: boolean;
|
||||
};
|
||||
|
||||
export type AdminOrderItem = {
|
||||
@@ -199,6 +200,7 @@ export type AdminOrderRow = {
|
||||
isProxyOrder?: boolean;
|
||||
proxyPartnerName?: string | null;
|
||||
proxyPartnerPhone?: string | null;
|
||||
isTest?: boolean;
|
||||
user?: { id: string; userNo: string; phone: string | null; nickname: string | null };
|
||||
delivery?: {
|
||||
provider: string;
|
||||
|
||||
@@ -59,6 +59,7 @@ type Row = {
|
||||
accountCount: number;
|
||||
subAccounts?: SubRow[];
|
||||
createdAt: string;
|
||||
isTest?: boolean;
|
||||
};
|
||||
|
||||
type PartnerDetail = Row & {
|
||||
@@ -124,14 +125,15 @@ export default function CityPartnersPage() {
|
||||
const [createForm] = Form.useForm();
|
||||
const [subForm] = Form.useForm();
|
||||
const [subEditForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const [filters, setFilters] = useState<Record<string, string | boolean>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/partners',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.companyName) qs.set('companyName', filters.companyName);
|
||||
if (filters.phone) qs.set('phone', filters.phone);
|
||||
if (filters.cityId) qs.set('cityId', filters.cityId);
|
||||
if (filters.companyName) qs.set('companyName', String(filters.companyName));
|
||||
if (filters.phone) qs.set('phone', String(filters.phone));
|
||||
if (filters.cityId) qs.set('cityId', String(filters.cityId));
|
||||
if (filters.excludeTest) qs.set('excludeTest', 'true');
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
@@ -270,7 +272,18 @@ export default function CityPartnersPage() {
|
||||
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
|
||||
},
|
||||
{ title: '公司名', dataIndex: 'companyName', ellipsis: true, width: 140 },
|
||||
{ title: '主账号姓名', dataIndex: 'name', width: 100, ellipsis: true },
|
||||
{
|
||||
title: '主账号姓名',
|
||||
dataIndex: 'name',
|
||||
width: 120,
|
||||
ellipsis: true,
|
||||
render: (v, row) => (
|
||||
<Space size={4}>
|
||||
<span>{v}</span>
|
||||
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ title: '登录手机', dataIndex: 'phone', width: 120 },
|
||||
{
|
||||
title: '管辖',
|
||||
@@ -387,6 +400,9 @@ export default function CityPartnersPage() {
|
||||
<Form.Item name="phone" label="手机">
|
||||
<Input allowClear placeholder="登录手机" />
|
||||
</Form.Item>
|
||||
<Form.Item name="excludeTest" valuePropName="checked">
|
||||
<Checkbox>过滤测试账号</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">
|
||||
|
||||
@@ -3,13 +3,26 @@ import {
|
||||
Button, Descriptions, Drawer, Form, Input, Space, Table, Typography, message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import OrderTrackDrawer from '../components/OrderTrackDrawer';
|
||||
import { request } from '../lib/api';
|
||||
import { ORDER_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
id: string; provider: string; trackingNo: string | null; providerOrderNo: string | null; updatedAt: string;
|
||||
order?: { orderNo: string; status: string; receiverName: string; receiverPhone: string; deliveryType: string };
|
||||
id: string;
|
||||
orderId: string;
|
||||
provider: string;
|
||||
trackingNo: string | null;
|
||||
providerOrderNo: string | null;
|
||||
updatedAt: string;
|
||||
order?: {
|
||||
id: string;
|
||||
orderNo: string;
|
||||
status: string;
|
||||
receiverName: string;
|
||||
receiverPhone: string;
|
||||
deliveryType: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default function DeliveriesPage() {
|
||||
@@ -29,6 +42,22 @@ export default function DeliveriesPage() {
|
||||
);
|
||||
const [detail, setDetail] = useState<Row | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [trackOpen, setTrackOpen] = useState(false);
|
||||
const [trackOrderId, setTrackOrderId] = useState<string | null>(null);
|
||||
const [trackOrderNo, setTrackOrderNo] = useState<string | null>(null);
|
||||
const [trackOrderStatus, setTrackOrderStatus] = useState<string | null>(null);
|
||||
|
||||
function openTrack(row: Row) {
|
||||
const orderId = row.orderId || row.order?.id;
|
||||
if (!orderId) {
|
||||
message.warning('缺少关联订单,无法查询路由');
|
||||
return;
|
||||
}
|
||||
setTrackOrderId(orderId);
|
||||
setTrackOrderNo(row.order?.orderNo ?? null);
|
||||
setTrackOrderStatus(row.order?.status ?? null);
|
||||
setTrackOpen(true);
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '订单号', dataIndex: ['order', 'orderNo'], width: 170 },
|
||||
@@ -39,14 +68,17 @@ export default function DeliveriesPage() {
|
||||
{ title: '收货人', dataIndex: ['order', 'receiverName'], width: 90 },
|
||||
{ title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作', width: 80,
|
||||
title: '操作', width: 140,
|
||||
render: (_, row) => (
|
||||
<Space size={0}>
|
||||
<Button type="link" size="small" onClick={() => openTrack(row)}>路由</Button>
|
||||
<Button type="link" size="small" onClick={async () => {
|
||||
const d = await request<Row>(`/admin/deliveries/${row.id}`);
|
||||
setDetail(d);
|
||||
editForm.setFieldsValue({ provider: d.provider, trackingNo: d.trackingNo, providerOrderNo: d.providerOrderNo });
|
||||
setDrawerOpen(true);
|
||||
}}>编辑</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -87,9 +119,22 @@ export default function DeliveriesPage() {
|
||||
<Form.Item name="providerOrderNo" label="第三方单号"><Input /></Form.Item>
|
||||
<Form.Item name="trackingNo" label="运单号"><Input /></Form.Item>
|
||||
</Form>
|
||||
<Button
|
||||
style={{ marginTop: 8 }}
|
||||
onClick={() => openTrack(detail)}
|
||||
>
|
||||
查看路由
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
<OrderTrackDrawer
|
||||
open={trackOpen}
|
||||
orderId={trackOrderId}
|
||||
orderNo={trackOrderNo}
|
||||
orderStatus={trackOrderStatus}
|
||||
onClose={() => setTrackOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
Collapse,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
} from '../lib/constants';
|
||||
import { FULFILLMENT_HOLD_REASON_LABELS } from '@dukang/shared-types';
|
||||
import ProxyOrderModal from '../components/ProxyOrderModal';
|
||||
import OrderTrackDrawer from '../components/OrderTrackDrawer';
|
||||
|
||||
type ShipDefaults = {
|
||||
provider: string;
|
||||
@@ -186,6 +188,7 @@ export default function OrdersPage() {
|
||||
const [shipMode, setShipMode] = useState<ShipMode>('EXPRESS');
|
||||
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
|
||||
const [proxyOpen, setProxyOpen] = useState(false);
|
||||
const [trackOpen, setTrackOpen] = useState(false);
|
||||
const canDeleteOrders = (profile?.permissionKeys ?? []).includes('orders_delete');
|
||||
const canProxyOrder = (profile?.permissionKeys ?? []).includes('orders');
|
||||
|
||||
@@ -223,6 +226,7 @@ export default function OrdersPage() {
|
||||
if (values.cityId) qs.set('cityId', values.cityId);
|
||||
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
|
||||
if (values.fulfillmentHold) qs.set('fulfillmentHold', 'true');
|
||||
if (values.excludeTest) qs.set('excludeTest', 'true');
|
||||
const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`);
|
||||
setData(res);
|
||||
} finally {
|
||||
@@ -430,7 +434,17 @@ export default function OrdersPage() {
|
||||
}
|
||||
|
||||
const columns: ColumnsType<AdminOrderRow> = [
|
||||
{ title: '订单号', dataIndex: 'orderNo', width: 180 },
|
||||
{
|
||||
title: '订单号',
|
||||
dataIndex: 'orderNo',
|
||||
width: 200,
|
||||
render: (v, row) => (
|
||||
<Space size={4}>
|
||||
<span>{v}</span>
|
||||
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '城市',
|
||||
width: 90,
|
||||
@@ -598,6 +612,9 @@ export default function OrdersPage() {
|
||||
options={[{ value: true, label: '仅待确认大单' }]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="excludeTest" valuePropName="checked">
|
||||
<Checkbox>过滤测试账号</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">查询</Button>
|
||||
@@ -859,7 +876,20 @@ export default function OrdersPage() {
|
||||
</Descriptions>
|
||||
|
||||
{detail.delivery && (
|
||||
<Descriptions column={1} bordered size="small" title="配送" style={{ marginTop: 16 }}>
|
||||
<Descriptions
|
||||
column={1}
|
||||
bordered
|
||||
size="small"
|
||||
title={
|
||||
<Space>
|
||||
<span>配送</span>
|
||||
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => setTrackOpen(true)}>
|
||||
查看路由
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Descriptions.Item label="快递公司">
|
||||
{detail.delivery.logisticsCompany ||
|
||||
DELIVERY_PROVIDER_LABELS[detail.delivery.provider] ||
|
||||
@@ -1232,6 +1262,14 @@ export default function OrdersPage() {
|
||||
void openDetail(order.id);
|
||||
}}
|
||||
/>
|
||||
|
||||
<OrderTrackDrawer
|
||||
open={trackOpen}
|
||||
orderId={detail?.id ?? null}
|
||||
orderNo={detail?.orderNo}
|
||||
orderStatus={detail?.status}
|
||||
onClose={() => setTrackOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Popconfirm, Select, Space,
|
||||
Switch, Table, Tabs, Tag, Typography, message,
|
||||
@@ -67,13 +67,6 @@ 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;
|
||||
const row = d as Row;
|
||||
@@ -112,10 +105,6 @@ function buildProductPayload(v: ProductFormValues) {
|
||||
features: features.length ? features : undefined,
|
||||
};
|
||||
|
||||
const visibilityPhones = (v.visibilityPhones ?? [])
|
||||
.map((p) => String(p || '').replace(/\D/g, '').trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
barcode69: v.barcode69,
|
||||
name: v.name,
|
||||
@@ -131,7 +120,6 @@ function buildProductPayload(v: ProductFormValues) {
|
||||
allowCrossCityDelivery:
|
||||
v.allowOnlinePurchase === false ? false : v.allowCrossCityDelivery !== false,
|
||||
visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled,
|
||||
visibilityPhones,
|
||||
coverUrl: v.coverUrl,
|
||||
carouselUrls,
|
||||
detailImageUrls,
|
||||
@@ -213,33 +201,6 @@ function ProductDetailFields({ form, aromaType }: { form: FormInstance; aromaTyp
|
||||
}
|
||||
|
||||
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 (
|
||||
@@ -248,46 +209,14 @@ function VisibilityWhitelistFields({ form }: { form: FormInstance }) {
|
||||
name="visibilityWhitelistEnabled"
|
||||
label="可见白名单"
|
||||
valuePropName="checked"
|
||||
extra="开启后仅名单内手机号在 C 端可见/可购,用于在线测试"
|
||||
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>
|
||||
</>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
|
||||
可见手机号见白名单管理
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
@@ -419,8 +348,8 @@ export default function ProductsPage() {
|
||||
title: '白名单',
|
||||
dataIndex: 'visibilityWhitelistEnabled',
|
||||
width: 90,
|
||||
render: (v: boolean, row) =>
|
||||
v ? <Tag color="orange">限{row.visibilityPhones?.length ?? 0}人</Tag> : <Tag>公开</Tag>,
|
||||
render: (v: boolean) =>
|
||||
v ? <Tag color="orange">限测</Tag> : <Tag>公开</Tag>,
|
||||
},
|
||||
{
|
||||
title: '履约',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd';
|
||||
import { Button, Checkbox, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
|
||||
import RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDescriptions';
|
||||
@@ -14,6 +14,7 @@ type Row = {
|
||||
settleAmount: number;
|
||||
channel?: RedeemChannel;
|
||||
createdAt: string;
|
||||
isTest?: boolean;
|
||||
user?: { userNo: string; phone: string | null; nickname?: string | null };
|
||||
store?: { name: string; cityName: string };
|
||||
coupon?: { couponNo: string };
|
||||
@@ -26,14 +27,15 @@ function maskPhone(phone: string | null | undefined) {
|
||||
|
||||
export default function RedeemRecordsPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const [filters, setFilters] = useState<Record<string, string | boolean>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
|
||||
'/admin/redeem-records',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.redeemNo) qs.set('redeemNo', filters.redeemNo);
|
||||
if (filters.storeId) qs.set('storeId', filters.storeId);
|
||||
if (filters.channel) qs.set('channel', filters.channel);
|
||||
if (filters.redeemNo) qs.set('redeemNo', String(filters.redeemNo));
|
||||
if (filters.storeId) qs.set('storeId', String(filters.storeId));
|
||||
if (filters.channel) qs.set('channel', String(filters.channel));
|
||||
if (filters.excludeTest) qs.set('excludeTest', 'true');
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
@@ -53,7 +55,21 @@ export default function RedeemRecordsPage() {
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '核销号', dataIndex: 'redeemNo', width: 180 },
|
||||
{
|
||||
title: '核销号',
|
||||
dataIndex: 'redeemNo',
|
||||
width: 200,
|
||||
render: (v, row) => (
|
||||
<span>
|
||||
{v}
|
||||
{row.isTest ? (
|
||||
<Tag color="orange" style={{ marginLeft: 6 }}>
|
||||
测试
|
||||
</Tag>
|
||||
) : null}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '方式',
|
||||
dataIndex: 'channel',
|
||||
@@ -124,6 +140,9 @@ export default function RedeemRecordsPage() {
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="excludeTest" valuePropName="checked">
|
||||
<Checkbox>过滤测试账号</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button, Descriptions, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
|
||||
Button, Checkbox, Descriptions, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
@@ -15,6 +15,7 @@ type Row = {
|
||||
name: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
isTest?: boolean;
|
||||
storeCount?: number;
|
||||
staffCount?: number;
|
||||
bankAccountName?: string | null;
|
||||
@@ -30,13 +31,14 @@ type StoreOption = { id: string; name: string };
|
||||
export default function StoreAccountsPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const [filters, setFilters] = useState<Record<string, string | boolean>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/store-accounts',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.phone) qs.set('phone', filters.phone);
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.phone) qs.set('phone', String(filters.phone));
|
||||
if (filters.status) qs.set('status', String(filters.status));
|
||||
if (filters.excludeTest) qs.set('excludeTest', 'true');
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
@@ -73,7 +75,17 @@ export default function StoreAccountsPage() {
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '姓名', dataIndex: 'name', width: 100 },
|
||||
{
|
||||
title: '姓名',
|
||||
dataIndex: 'name',
|
||||
width: 120,
|
||||
render: (v, row) => (
|
||||
<Space size={4}>
|
||||
<span>{v}</span>
|
||||
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||
{
|
||||
title: '绑定门店',
|
||||
@@ -162,6 +174,9 @@ export default function StoreAccountsPage() {
|
||||
options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="excludeTest" valuePropName="checked">
|
||||
<Checkbox>过滤测试账号</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||
</Form>
|
||||
<Table
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
@@ -277,36 +278,7 @@ function StoreAuditMediaSection({ detail }: { detail: Record<string, unknown> })
|
||||
);
|
||||
}
|
||||
|
||||
type UserPickRow = { id: string; phone?: string | null; nickname?: string | null; userNo?: string | null };
|
||||
|
||||
function StoreVisibilityWhitelistFields({ 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 (
|
||||
@@ -315,46 +287,14 @@ function StoreVisibilityWhitelistFields({ form }: { form: FormInstance }) {
|
||||
name="visibilityWhitelistEnabled"
|
||||
label="可见白名单"
|
||||
valuePropName="checked"
|
||||
extra="开启后仅名单内手机号在 C 端可见,用于在线测试"
|
||||
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>
|
||||
</>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
|
||||
可见手机号见白名单管理
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
@@ -375,6 +315,7 @@ type StoreRow = {
|
||||
createdAt: string;
|
||||
visibilityWhitelistEnabled?: boolean;
|
||||
visibilityPhones?: string[];
|
||||
isTest?: boolean;
|
||||
cityRef?: { name: string; code: string };
|
||||
partner?: { id?: string; companyName?: string | null; name?: string | null; phone?: string | null };
|
||||
account?: {
|
||||
@@ -424,8 +365,8 @@ export default function StoresPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm<StoreCreateForm>();
|
||||
const [filters, setFilters] = useState<Record<string, string>>(() => {
|
||||
const init: Record<string, string> = {};
|
||||
const [filters, setFilters] = useState<Record<string, string | boolean>>(() => {
|
||||
const init: Record<string, string | boolean> = {};
|
||||
if (initialCityId) init.cityId = initialCityId;
|
||||
if (initialPartnerId) init.partnerId = initialPartnerId;
|
||||
return init;
|
||||
@@ -434,12 +375,13 @@ export default function StoresPage() {
|
||||
'/admin/stores',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.name) qs.set('name', filters.name);
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.auditStatus) qs.set('auditStatus', filters.auditStatus);
|
||||
if (filters.phone) qs.set('phone', filters.phone);
|
||||
if (filters.cityId) qs.set('cityId', filters.cityId);
|
||||
if (filters.partnerId) qs.set('partnerId', filters.partnerId);
|
||||
if (filters.name) qs.set('name', String(filters.name));
|
||||
if (filters.status) qs.set('status', String(filters.status));
|
||||
if (filters.auditStatus) qs.set('auditStatus', String(filters.auditStatus));
|
||||
if (filters.phone) qs.set('phone', String(filters.phone));
|
||||
if (filters.cityId) qs.set('cityId', String(filters.cityId));
|
||||
if (filters.partnerId) qs.set('partnerId', String(filters.partnerId));
|
||||
if (filters.excludeTest) qs.set('excludeTest', 'true');
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
@@ -597,6 +539,7 @@ export default function StoresPage() {
|
||||
visibilityPhones: Array.isArray(d.visibilityPhones)
|
||||
? (d.visibilityPhones as string[])
|
||||
: [],
|
||||
isTest: !!d.isTest,
|
||||
});
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
@@ -637,9 +580,7 @@ export default function StoresPage() {
|
||||
bankAccountNo: v.bankAccountNo ?? null,
|
||||
bankBranch: v.bankBranch ?? null,
|
||||
visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled,
|
||||
visibilityPhones: ((v.visibilityPhones as string[] | undefined) ?? [])
|
||||
.map((p) => String(p || '').replace(/\D/g, '').trim())
|
||||
.filter(Boolean),
|
||||
isTest: !!v.isTest,
|
||||
...(hasCoords
|
||||
? { latitude: Number(v.latitude), longitude: Number(v.longitude) }
|
||||
: {}),
|
||||
@@ -813,9 +754,6 @@ export default function StoresPage() {
|
||||
bankBranch: values.bankBranch.trim(),
|
||||
settlementRate: Number(values.settlementRate ?? 60) / 100,
|
||||
visibilityWhitelistEnabled: !!values.visibilityWhitelistEnabled,
|
||||
visibilityPhones: (values.visibilityPhones ?? [])
|
||||
.map((p: string) => String(p || '').replace(/\D/g, '').trim())
|
||||
.filter(Boolean),
|
||||
}),
|
||||
});
|
||||
message.success('门店已创建');
|
||||
@@ -839,7 +777,12 @@ export default function StoresPage() {
|
||||
title: '封面', dataIndex: 'coverUrl', width: 72,
|
||||
render: (url) => url ? <Image src={url} width={48} height={48} style={{ objectFit: 'cover', borderRadius: 4 }} /> : '—',
|
||||
},
|
||||
{ title: '门店名', dataIndex: 'name', width: 140 },
|
||||
{ title: '门店名', dataIndex: 'name', width: 160, render: (v, row) => (
|
||||
<Space size={4}>
|
||||
<span>{v}</span>
|
||||
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
||||
</Space>
|
||||
) },
|
||||
{
|
||||
title: '分类',
|
||||
width: 100,
|
||||
@@ -879,8 +822,8 @@ export default function StoresPage() {
|
||||
title: '可见',
|
||||
dataIndex: 'visibilityWhitelistEnabled',
|
||||
width: 90,
|
||||
render: (v, row) =>
|
||||
v ? <Tag color="orange">限{row.visibilityPhones?.length ?? 0}人</Tag> : <Tag>公开</Tag>,
|
||||
render: (v) =>
|
||||
v ? <Tag color="orange">限测</Tag> : <Tag>公开</Tag>,
|
||||
},
|
||||
{ title: '介绍', dataIndex: 'intro', width: 160, ellipsis: true, render: (v) => v || '—' },
|
||||
{ title: '店长', dataIndex: ['account', 'name'], width: 90, render: (v) => v || '—' },
|
||||
@@ -939,6 +882,9 @@ export default function StoresPage() {
|
||||
options={Object.entries(STORE_AUDIT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="excludeTest" valuePropName="checked">
|
||||
<Checkbox>过滤测试账号</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||
<Form.Item>
|
||||
<Button
|
||||
@@ -1176,6 +1122,14 @@ export default function StoresPage() {
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<StoreVisibilityWhitelistFields form={editForm} />
|
||||
<Form.Item
|
||||
name="isTest"
|
||||
label="测试门店"
|
||||
valuePropName="checked"
|
||||
extra="测试门店核销不计入结算账单;联系电话命中全局白名单时会自动标记"
|
||||
>
|
||||
<Switch checkedChildren="是" unCheckedChildren="否" />
|
||||
</Form.Item>
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,687 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
|
||||
const MOCK_KEYS = ['MOCK_SMS', 'MOCK_WECHAT', 'MOCK_PAY'] as const;
|
||||
|
||||
type PhoneRow = {
|
||||
id: string;
|
||||
phone: string;
|
||||
note: string | null;
|
||||
createdByHqId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type AccountType = 'user' | 'store_account' | 'partner' | 'store' | 'order';
|
||||
|
||||
type LinkedPayload = {
|
||||
phone: PhoneRow;
|
||||
users: Array<{ id: string; userNo: string; phone: string | null; nickname: string | null; isTest: boolean; status: number }>;
|
||||
storeAccounts: Array<{ id: string; phone: string; name: string; isTest: boolean; status: string }>;
|
||||
partners: Array<{ id: string; phone: string; name: string; companyName: string | null; isTest: boolean; status: string }>;
|
||||
stores: Array<{ id: string; name: string; phone: string; isTest: boolean; status: string }>;
|
||||
};
|
||||
|
||||
const ACCOUNT_TYPE_OPTIONS: { value: AccountType; label: string }[] = [
|
||||
{ value: 'user', label: 'C 端用户' },
|
||||
{ value: 'store_account', label: '门店账号' },
|
||||
{ value: 'partner', label: '合伙人' },
|
||||
{ value: 'store', label: '门店' },
|
||||
{ value: 'order', label: '订单' },
|
||||
];
|
||||
|
||||
export default function TestWhitelistPage() {
|
||||
const [mockSms, setMockSms] = useState(false);
|
||||
const [mockWechat, setMockWechat] = useState(false);
|
||||
const [mockPay, setMockPay] = useState(false);
|
||||
const [mockLoading, setMockLoading] = useState(true);
|
||||
const [mockSaving, setMockSaving] = useState(false);
|
||||
|
||||
const [phoneForm] = Form.useForm();
|
||||
const [phones, setPhones] = useState<Paginated<PhoneRow> | null>(null);
|
||||
const [phonesLoading, setPhonesLoading] = useState(false);
|
||||
const [phonePage, setPhonePage] = useState(1);
|
||||
const [phonePageSize, setPhonePageSize] = useState(20);
|
||||
const [phoneFilters, setPhoneFilters] = useState<{ phone?: string }>({});
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [addForm] = Form.useForm();
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editRow, setEditRow] = useState<PhoneRow | null>(null);
|
||||
const [editForm] = Form.useForm();
|
||||
const [migrating, setMigrating] = useState(false);
|
||||
|
||||
const [accountType, setAccountType] = useState<AccountType>('user');
|
||||
const [accountPhone, setAccountPhone] = useState('');
|
||||
const [accounts, setAccounts] = useState<Paginated<Record<string, unknown>> | null>(null);
|
||||
const [accountsLoading, setAccountsLoading] = useState(false);
|
||||
const [accountPage, setAccountPage] = useState(1);
|
||||
const [accountPageSize, setAccountPageSize] = useState(20);
|
||||
|
||||
const [linkedOpen, setLinkedOpen] = useState(false);
|
||||
const [linkedLoading, setLinkedLoading] = useState(false);
|
||||
const [linked, setLinked] = useState<LinkedPayload | null>(null);
|
||||
|
||||
async function loadMockFlags() {
|
||||
setMockLoading(true);
|
||||
try {
|
||||
const cfg = await request<{ mockSms: boolean; mockWechat: boolean; mockPay: boolean }>(
|
||||
'/admin/test-whitelist/mock-flags',
|
||||
);
|
||||
setMockSms(!!cfg.mockSms);
|
||||
setMockWechat(!!cfg.mockWechat);
|
||||
setMockPay(!!cfg.mockPay);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载 Mock 配置失败');
|
||||
} finally {
|
||||
setMockLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMockFlags(next: { MOCK_SMS?: boolean; MOCK_WECHAT?: boolean; MOCK_PAY?: boolean }) {
|
||||
const body: { mockSms?: boolean; mockWechat?: boolean; mockPay?: boolean } = {};
|
||||
if (next.MOCK_SMS !== undefined) body.mockSms = next.MOCK_SMS;
|
||||
if (next.MOCK_WECHAT !== undefined) body.mockWechat = next.MOCK_WECHAT;
|
||||
if (next.MOCK_PAY !== undefined) body.mockPay = next.MOCK_PAY;
|
||||
setMockSaving(true);
|
||||
try {
|
||||
const cfg = await request<{ mockSms: boolean; mockWechat: boolean; mockPay: boolean }>(
|
||||
'/admin/test-whitelist/mock-flags',
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
setMockSms(!!cfg.mockSms);
|
||||
setMockWechat(!!cfg.mockWechat);
|
||||
setMockPay(!!cfg.mockPay);
|
||||
message.success('已保存');
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败');
|
||||
await loadMockFlags();
|
||||
} finally {
|
||||
setMockSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const loadPhones = useCallback(async () => {
|
||||
setPhonesLoading(true);
|
||||
try {
|
||||
const qs = new URLSearchParams({
|
||||
page: String(phonePage),
|
||||
pageSize: String(phonePageSize),
|
||||
});
|
||||
if (phoneFilters.phone) qs.set('phone', phoneFilters.phone);
|
||||
const res = await request<Paginated<PhoneRow>>(`/admin/test-whitelist/phones?${qs}`);
|
||||
setPhones(res);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载手机号名单失败');
|
||||
} finally {
|
||||
setPhonesLoading(false);
|
||||
}
|
||||
}, [phonePage, phonePageSize, phoneFilters]);
|
||||
|
||||
const loadAccounts = useCallback(async () => {
|
||||
setAccountsLoading(true);
|
||||
try {
|
||||
const qs = new URLSearchParams({
|
||||
type: accountType,
|
||||
page: String(accountPage),
|
||||
pageSize: String(accountPageSize),
|
||||
});
|
||||
if (accountPhone.trim()) qs.set('phone', accountPhone.trim());
|
||||
const res = await request<Paginated<Record<string, unknown>>>(
|
||||
`/admin/test-whitelist/accounts?${qs}`,
|
||||
);
|
||||
setAccounts(res);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载测试账号失败');
|
||||
} finally {
|
||||
setAccountsLoading(false);
|
||||
}
|
||||
}, [accountType, accountPage, accountPageSize, accountPhone]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadMockFlags();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadPhones();
|
||||
}, [loadPhones]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadAccounts();
|
||||
}, [loadAccounts]);
|
||||
|
||||
async function onAddPhone() {
|
||||
const v = await addForm.validateFields();
|
||||
try {
|
||||
await request('/admin/test-whitelist/phones', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone: v.phone, note: v.note || undefined }),
|
||||
});
|
||||
message.success('已添加');
|
||||
setAddOpen(false);
|
||||
addForm.resetFields();
|
||||
setPhonePage(1);
|
||||
void loadPhones();
|
||||
void loadAccounts();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '添加失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function onEditPhone() {
|
||||
if (!editRow) return;
|
||||
const v = await editForm.validateFields();
|
||||
try {
|
||||
await request(`/admin/test-whitelist/phones/${editRow.id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ note: v.note ?? null }),
|
||||
});
|
||||
message.success('已更新');
|
||||
setEditOpen(false);
|
||||
setEditRow(null);
|
||||
void loadPhones();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '更新失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function onDeletePhone(id: string) {
|
||||
try {
|
||||
await request(`/admin/test-whitelist/phones/${id}`, { method: 'DELETE' });
|
||||
message.success('已删除');
|
||||
void loadPhones();
|
||||
void loadAccounts();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function onMigrate() {
|
||||
setMigrating(true);
|
||||
try {
|
||||
const res = await request<{ importedCandidates: number; added: number }>(
|
||||
'/admin/test-whitelist/migrate-visibility',
|
||||
{ method: 'POST' },
|
||||
);
|
||||
message.success(
|
||||
`导入完成:候选 ${res.importedCandidates} 个,新增 ${res.added} 个`,
|
||||
);
|
||||
void loadPhones();
|
||||
void loadAccounts();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '导入失败');
|
||||
} finally {
|
||||
setMigrating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function openLinked(row: PhoneRow) {
|
||||
setLinkedOpen(true);
|
||||
setLinkedLoading(true);
|
||||
setLinked(null);
|
||||
try {
|
||||
const res = await request<LinkedPayload>(`/admin/test-whitelist/phones/${row.id}/linked`);
|
||||
setLinked(res);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载关联失败');
|
||||
setLinkedOpen(false);
|
||||
} finally {
|
||||
setLinkedLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const phoneColumns: ColumnsType<PhoneRow> = [
|
||||
{ title: '手机号', dataIndex: 'phone', width: 140 },
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'note',
|
||||
ellipsis: true,
|
||||
render: (v) => v || '—',
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 170,
|
||||
render: fmtTime,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 220,
|
||||
render: (_, row) => (
|
||||
<Space size={0}>
|
||||
<Button type="link" size="small" onClick={() => void openLinked(row)}>
|
||||
关联账号
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setEditRow(row);
|
||||
editForm.setFieldsValue({ note: row.note ?? '' });
|
||||
setEditOpen(true);
|
||||
}}
|
||||
>
|
||||
编辑备注
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确认移出白名单?"
|
||||
description="将同步清除该手机号关联账号的测试标记"
|
||||
okText="确认"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => void onDeletePhone(row.id)}
|
||||
>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
function accountColumns(): ColumnsType<Record<string, unknown>> {
|
||||
if (accountType === 'user') {
|
||||
return [
|
||||
{ title: '用户编号', dataIndex: 'userNo', width: 120 },
|
||||
{ title: '昵称', dataIndex: 'nickname', width: 100, render: (v) => (v as string) || '—' },
|
||||
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||
{
|
||||
title: '标记',
|
||||
dataIndex: 'isTest',
|
||||
width: 80,
|
||||
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
|
||||
},
|
||||
{ title: '注册', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
|
||||
];
|
||||
}
|
||||
if (accountType === 'store_account') {
|
||||
return [
|
||||
{ title: '姓名', dataIndex: 'name', width: 100 },
|
||||
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||
{ title: '状态', dataIndex: 'status', width: 90 },
|
||||
{
|
||||
title: '标记',
|
||||
dataIndex: 'isTest',
|
||||
width: 80,
|
||||
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
|
||||
},
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
|
||||
];
|
||||
}
|
||||
if (accountType === 'partner') {
|
||||
return [
|
||||
{ title: '姓名', dataIndex: 'name', width: 100 },
|
||||
{ title: '公司', dataIndex: 'companyName', ellipsis: true, render: (v) => (v as string) || '—' },
|
||||
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||
{
|
||||
title: '标记',
|
||||
dataIndex: 'isTest',
|
||||
width: 80,
|
||||
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
|
||||
},
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
|
||||
];
|
||||
}
|
||||
if (accountType === 'store') {
|
||||
return [
|
||||
{ title: '门店名', dataIndex: 'name', width: 160, ellipsis: true },
|
||||
{ title: '城市', dataIndex: 'cityName', width: 90 },
|
||||
{ title: '电话', dataIndex: 'phone', width: 120 },
|
||||
{ title: '状态', dataIndex: 'status', width: 90 },
|
||||
{
|
||||
title: '标记',
|
||||
dataIndex: 'isTest',
|
||||
width: 80,
|
||||
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
|
||||
},
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
|
||||
];
|
||||
}
|
||||
return [
|
||||
{ title: '订单号', dataIndex: 'orderNo', width: 180 },
|
||||
{ title: '状态', dataIndex: 'status', width: 110 },
|
||||
{
|
||||
title: '实付',
|
||||
dataIndex: 'payAmount',
|
||||
width: 90,
|
||||
render: (v) => `¥${v}`,
|
||||
},
|
||||
{ title: '收货手机', dataIndex: 'receiverPhone', width: 120 },
|
||||
{
|
||||
title: '用户手机',
|
||||
width: 120,
|
||||
render: (_, row) =>
|
||||
(row.user as { phone?: string | null } | undefined)?.phone || '—',
|
||||
},
|
||||
{
|
||||
title: '标记',
|
||||
dataIndex: 'isTest',
|
||||
width: 80,
|
||||
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
|
||||
},
|
||||
{ title: '下单', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
|
||||
];
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ marginTop: 0 }}>
|
||||
白名单管理
|
||||
</Typography.Title>
|
||||
|
||||
<Card size="small" loading={mockLoading} style={{ marginBottom: 16 }}>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
||||
Mock 开关(与系统设置同源,勾选 = 不做真实验证)
|
||||
</Typography.Text>
|
||||
<Space wrap>
|
||||
<Checkbox
|
||||
checked={mockSms}
|
||||
disabled={mockSaving}
|
||||
onChange={(e) => void saveMockFlags({ MOCK_SMS: e.target.checked })}
|
||||
>
|
||||
短信不做真实验证
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
checked={mockWechat}
|
||||
disabled={mockSaving}
|
||||
onChange={(e) => void saveMockFlags({ MOCK_WECHAT: e.target.checked })}
|
||||
>
|
||||
微信不做真实验证
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
checked={mockPay}
|
||||
disabled={mockSaving}
|
||||
onChange={(e) => void saveMockFlags({ MOCK_PAY: e.target.checked })}
|
||||
>
|
||||
支付不做真实验证
|
||||
</Checkbox>
|
||||
</Space>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginTop: 8, fontSize: 12 }}>
|
||||
配置键:{MOCK_KEYS.join(' / ')}
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'phones',
|
||||
label: '手机号名单',
|
||||
children: (
|
||||
<>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }} wrap>
|
||||
<Form
|
||||
form={phoneForm}
|
||||
layout="inline"
|
||||
onFinish={(v) => {
|
||||
setPhoneFilters({ phone: v.phone || undefined });
|
||||
setPhonePage(1);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="phone" label="手机号">
|
||||
<Input allowClear placeholder="模糊搜索" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Space>
|
||||
<Button
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: '从可见性白名单导入',
|
||||
content: '将商品/门店旧可见性手机号合并入全局名单(幂等),并同步测试标记。',
|
||||
okText: '开始导入',
|
||||
cancelText: '取消',
|
||||
onOk: () => onMigrate(),
|
||||
});
|
||||
}}
|
||||
loading={migrating}
|
||||
>
|
||||
从可见性白名单导入
|
||||
</Button>
|
||||
<Button type="primary" onClick={() => setAddOpen(true)}>
|
||||
添加手机号
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={phonesLoading}
|
||||
columns={phoneColumns}
|
||||
dataSource={phones?.items ?? []}
|
||||
pagination={{
|
||||
current: phonePage,
|
||||
pageSize: phonePageSize,
|
||||
total: phones?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPhonePage(p);
|
||||
setPhonePageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'accounts',
|
||||
label: '测试账号记录',
|
||||
children: (
|
||||
<>
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Select
|
||||
style={{ width: 140 }}
|
||||
value={accountType}
|
||||
options={ACCOUNT_TYPE_OPTIONS}
|
||||
onChange={(v: AccountType) => {
|
||||
setAccountType(v);
|
||||
setAccountPage(1);
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="按手机号筛选"
|
||||
style={{ width: 160 }}
|
||||
value={accountPhone}
|
||||
onChange={(e) => setAccountPhone(e.target.value)}
|
||||
onPressEnter={() => {
|
||||
setAccountPage(1);
|
||||
void loadAccounts();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
if (accountPage !== 1) setAccountPage(1);
|
||||
else void loadAccounts();
|
||||
}}
|
||||
>
|
||||
查询
|
||||
</Button>
|
||||
</Space>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={accountsLoading}
|
||||
columns={accountColumns()}
|
||||
dataSource={accounts?.items ?? []}
|
||||
scroll={{ x: 900 }}
|
||||
pagination={{
|
||||
current: accountPage,
|
||||
pageSize: accountPageSize,
|
||||
total: accounts?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setAccountPage(p);
|
||||
setAccountPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="添加白名单手机号"
|
||||
open={addOpen}
|
||||
onCancel={() => setAddOpen(false)}
|
||||
onOk={() => void onAddPhone()}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={addForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="phone"
|
||||
label="手机号"
|
||||
rules={[
|
||||
{ required: true, message: '请输入手机号' },
|
||||
{ pattern: /^1\d{10}$/, message: '请输入 11 位手机号' },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="1xxxxxxxxxx" maxLength={11} />
|
||||
</Form.Item>
|
||||
<Form.Item name="note" label="备注">
|
||||
<Input.TextArea rows={2} maxLength={256} showCount />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`编辑备注 · ${editRow?.phone ?? ''}`}
|
||||
open={editOpen}
|
||||
onCancel={() => {
|
||||
setEditOpen(false);
|
||||
setEditRow(null);
|
||||
}}
|
||||
onOk={() => void onEditPhone()}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="note" label="备注">
|
||||
<Input.TextArea rows={2} maxLength={256} showCount />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Drawer
|
||||
title={linked ? `关联账号 · ${linked.phone.phone}` : '关联账号'}
|
||||
open={linkedOpen}
|
||||
onClose={() => setLinkedOpen(false)}
|
||||
width={560}
|
||||
destroyOnClose
|
||||
>
|
||||
{linkedLoading ? (
|
||||
<Typography.Text type="secondary">加载中…</Typography.Text>
|
||||
) : linked ? (
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Descriptions size="small" column={1} bordered>
|
||||
<Descriptions.Item label="手机号">{linked.phone.phone}</Descriptions.Item>
|
||||
<Descriptions.Item label="备注">{linked.phone.note || '—'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<div>
|
||||
<Typography.Title level={5}>C 端用户({linked.users.length})</Typography.Title>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
dataSource={linked.users}
|
||||
columns={[
|
||||
{ title: '编号', dataIndex: 'userNo' },
|
||||
{ title: '昵称', dataIndex: 'nickname', render: (v) => v || '—' },
|
||||
{
|
||||
title: '测试',
|
||||
dataIndex: 'isTest',
|
||||
width: 70,
|
||||
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Title level={5}>门店账号({linked.storeAccounts.length})</Typography.Title>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
dataSource={linked.storeAccounts}
|
||||
columns={[
|
||||
{ title: '姓名', dataIndex: 'name' },
|
||||
{ title: '状态', dataIndex: 'status', width: 90 },
|
||||
{
|
||||
title: '测试',
|
||||
dataIndex: 'isTest',
|
||||
width: 70,
|
||||
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Title level={5}>合伙人({linked.partners.length})</Typography.Title>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
dataSource={linked.partners}
|
||||
columns={[
|
||||
{ title: '姓名', dataIndex: 'name' },
|
||||
{ title: '公司', dataIndex: 'companyName', render: (v) => v || '—' },
|
||||
{
|
||||
title: '测试',
|
||||
dataIndex: 'isTest',
|
||||
width: 70,
|
||||
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Title level={5}>门店({linked.stores.length})</Typography.Title>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
dataSource={linked.stores}
|
||||
columns={[
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{ title: '状态', dataIndex: 'status', width: 90 },
|
||||
{
|
||||
title: '测试',
|
||||
dataIndex: 'isTest',
|
||||
width: 70,
|
||||
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Space>
|
||||
) : null}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -112,6 +112,7 @@ export default function UsersPage() {
|
||||
if (values.status !== undefined && values.status !== '') {
|
||||
qs.set('status', String(values.status));
|
||||
}
|
||||
if (values.excludeTest) qs.set('excludeTest', 'true');
|
||||
const res = await request<Paginated<AdminUserRow>>(`/admin/users?${qs}`);
|
||||
setData(res);
|
||||
} finally {
|
||||
@@ -237,7 +238,17 @@ export default function UsersPage() {
|
||||
];
|
||||
|
||||
const columns: ColumnsType<AdminUserRow> = [
|
||||
{ title: '用户编号', dataIndex: 'userNo', width: 120 },
|
||||
{
|
||||
title: '用户编号',
|
||||
dataIndex: 'userNo',
|
||||
width: 140,
|
||||
render: (v, row) => (
|
||||
<Space size={4}>
|
||||
<span>{v}</span>
|
||||
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ title: '昵称', dataIndex: 'nickname', width: 100 },
|
||||
{
|
||||
title: '手机',
|
||||
@@ -362,6 +373,9 @@ export default function UsersPage() {
|
||||
{ value: 0, label: '停用' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="excludeTest" valuePropName="checked">
|
||||
<Checkbox>过滤测试账号</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">查询</Button>
|
||||
|
||||
@@ -2,7 +2,10 @@ type WechatScanAuthModalProps = {
|
||||
open: boolean;
|
||||
loading?: boolean;
|
||||
error?: string;
|
||||
/** bind=首次绑定;recover=扫码 JSSDK 失败后的恢复引导 */
|
||||
mode?: 'bind' | 'recover';
|
||||
onAuthorize: () => void;
|
||||
onRefresh?: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
@@ -10,28 +13,43 @@ export default function WechatScanAuthModal({
|
||||
open,
|
||||
loading,
|
||||
error,
|
||||
mode = 'bind',
|
||||
onAuthorize,
|
||||
onRefresh,
|
||||
onCancel,
|
||||
}: WechatScanAuthModalProps) {
|
||||
if (!open) return null;
|
||||
|
||||
const isRecover = mode === 'recover';
|
||||
|
||||
return (
|
||||
<div className="shop-scan-auth-overlay" role="dialog" aria-modal="true" aria-labelledby="shop-scan-auth-title">
|
||||
<div className="shop-scan-auth-card">
|
||||
<div className="shop-scan-auth-icon">
|
||||
<span className="material-symbols-outlined shop-fill-icon">qr_code_scanner</span>
|
||||
<span className="material-symbols-outlined shop-fill-icon">
|
||||
{isRecover ? 'sync_problem' : 'qr_code_scanner'}
|
||||
</span>
|
||||
</div>
|
||||
<h2 id="shop-scan-auth-title" className="shop-scan-auth-title">微信授权</h2>
|
||||
<h2 id="shop-scan-auth-title" className="shop-scan-auth-title">
|
||||
{isRecover ? '扫码能力未就绪' : '微信授权'}
|
||||
</h2>
|
||||
<p className="shop-scan-auth-desc">
|
||||
扫码核销需使用微信相机扫描用户核销码。请先完成微信授权并允许使用摄像头。
|
||||
{isRecover
|
||||
? '微信扫码接口校验失败(常见于 iPhone 登录/授权后)。请先刷新页面;仍失败再重新授权微信。'
|
||||
: '扫码核销需使用微信相机扫描用户核销码。请先完成微信授权并允许使用摄像头。'}
|
||||
</p>
|
||||
{error && <p className="shop-scan-auth-error" role="alert">{error}</p>}
|
||||
<div className="shop-scan-auth-actions">
|
||||
<button type="button" className="shop-scan-auth-cancel" onClick={onCancel} disabled={loading}>
|
||||
取消
|
||||
</button>
|
||||
{isRecover && onRefresh ? (
|
||||
<button type="button" className="shop-scan-auth-confirm" onClick={onRefresh} disabled={loading}>
|
||||
刷新页面
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" className="shop-scan-auth-confirm" onClick={onAuthorize} disabled={loading}>
|
||||
{loading ? '跳转授权中…' : '微信授权'}
|
||||
{loading ? '跳转授权中…' : isRecover ? '重新授权微信' : '微信授权'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,9 @@ import { isIosDevice } from '@dukang/weixin-sdk';
|
||||
/** 扫码前发起 OAuth 时标记,回跳后在首页续扫 */
|
||||
export const SHOP_PENDING_SCAN_KEY = 'shop_pending_scan';
|
||||
|
||||
/** 短信登录后绑定微信等 OAuth 回跳:下次手动扫码加长预热(不自动打开相机) */
|
||||
export const SHOP_SCAN_WARMUP_KEY = 'shop_scan_warmup';
|
||||
|
||||
export function markPendingScanAfterAuth(): void {
|
||||
try {
|
||||
sessionStorage.setItem(SHOP_PENDING_SCAN_KEY, '1');
|
||||
@@ -27,6 +30,25 @@ export function clearPendingScanAfterAuth(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** OAuth(含短信登录后绑微信)回跳后,标记下一次扫码需要加长预热 */
|
||||
export function markScanWarmupAfterAuth(): void {
|
||||
try {
|
||||
sessionStorage.setItem(SHOP_SCAN_WARMUP_KEY, '1');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function consumeScanWarmupAfterAuth(): boolean {
|
||||
try {
|
||||
if (sessionStorage.getItem(SHOP_SCAN_WARMUP_KEY) !== '1') return false;
|
||||
sessionStorage.removeItem(SHOP_SCAN_WARMUP_KEY);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** OAuth 回跳后延迟再调 scanQRCode(iOS JSSDK 离线校验更慢) */
|
||||
export function getPostAuthScanDelayMs(): number {
|
||||
return isIosDevice() ? 1200 : 600;
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ClientRuntimeConfig, WechatLoginResult } from '@dukang/shared-type
|
||||
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
|
||||
import { isWechatEnv, weixinSdk } from './weixin';
|
||||
import { markScanWarmupAfterAuth } from './shop-scan-auth';
|
||||
import { request, saveWechatSession, type ShopSessionPayload } from './api';
|
||||
|
||||
export type ShopAccountProfile = {
|
||||
@@ -162,15 +163,19 @@ export async function loginShopWithWechat(): Promise<ShopSessionPayload | null |
|
||||
if (!isWechatEnv()) {
|
||||
throw new Error(WECHAT_INAPP_REQUIRED_MSG);
|
||||
}
|
||||
markScanWarmupAfterAuth();
|
||||
const result = await weixinSdk.login();
|
||||
if (result) return handleShopWechatLoginResult(result);
|
||||
}
|
||||
|
||||
/** 短信登录成功后于微信内自动发起 OAuth,绑定 openId 便于后续免登 */
|
||||
export async function bindShopWechatAfterSmsLogin(): Promise<void> {
|
||||
export async function bindShopWechatAfterSmsLogin(session?: ShopSessionPayload): Promise<'skipped' | void> {
|
||||
const config = await fetchClientConfig();
|
||||
if (!isWxAuthorizeEnabled(config)) return;
|
||||
if (!isWechatEnv()) return;
|
||||
if (!isWxAuthorizeEnabled(config)) return 'skipped';
|
||||
if (!isWechatEnv()) return 'skipped';
|
||||
// 已绑定则勿再 OAuth:每次 OAuth 回跳都会重置 iOS JSSDK 入场 URL,易导致扫码失败
|
||||
if (session?.account?.hasWechat) return 'skipped';
|
||||
markScanWarmupAfterAuth();
|
||||
await weixinSdk.login();
|
||||
}
|
||||
|
||||
@@ -180,5 +185,6 @@ export async function authorizeShopWechat(): Promise<WechatLoginResult | void> {
|
||||
if (!isWechatEnv()) {
|
||||
throw new Error(WECHAT_INAPP_REQUIRED_MSG);
|
||||
}
|
||||
markScanWarmupAfterAuth();
|
||||
return weixinSdk.login();
|
||||
}
|
||||
|
||||
@@ -1,293 +1,624 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
|
||||
|
||||
import { isIosDevice, isScanPermissionWarmupError } from '@dukang/weixin-sdk';
|
||||
|
||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||
|
||||
import { request } from '../lib/api';
|
||||
|
||||
import { parseRedeemTokenFromScan } from '../lib/redeem-scan';
|
||||
|
||||
import {
|
||||
|
||||
authorizeShopWechat,
|
||||
|
||||
checkNeedsWechatAuth,
|
||||
|
||||
fetchShopAccount,
|
||||
|
||||
} from '../lib/wechat-auth';
|
||||
|
||||
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
||||
|
||||
import {
|
||||
|
||||
clearPendingScanAfterAuth,
|
||||
|
||||
consumeScanWarmupAfterAuth,
|
||||
|
||||
getPostAuthScanDelayMs,
|
||||
|
||||
markPendingScanAfterAuth,
|
||||
|
||||
peekPendingScanAfterAuth,
|
||||
|
||||
} from '../lib/shop-scan-auth';
|
||||
|
||||
import WechatScanAuthModal from '../components/WechatScanAuthModal';
|
||||
|
||||
import { useStorePageView } from '../lib/usePageView';
|
||||
|
||||
import { trackStore } from '../lib/analytics';
|
||||
|
||||
|
||||
|
||||
function formatMoney(n: number) {
|
||||
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function formatScanError(e: unknown, opts?: { afterAuth?: boolean }): string {
|
||||
|
||||
const msg = e instanceof Error ? e.message : '扫码失败,请重试';
|
||||
|
||||
getPostAuthScanDelayMs,
|
||||
|
||||
if (/invalid signature|config:fail|signature/i.test(msg)) {
|
||||
|
||||
return '微信扫码签名校验失败,请刷新页面或重新授权微信后重试';
|
||||
|
||||
}
|
||||
|
||||
if (isScanPermissionWarmupError(msg)) {
|
||||
|
||||
if (opts?.afterAuth) {
|
||||
peekPendingScanAfterAuth,
|
||||
|
||||
return '微信授权后扫码仍未就绪,请刷新页面或重新授权微信';
|
||||
|
||||
}
|
||||
} from '../lib/shop-scan-auth';
|
||||
|
||||
return '微信扫码能力未就绪,请刷新页面或重新授权微信';
|
||||
|
||||
}
|
||||
|
||||
return msg;
|
||||
|
||||
}
|
||||
|
||||
function isScanRecoverableError(msg: string): boolean {
|
||||
|
||||
return isScanPermissionWarmupError(msg) || /签名校验失败|扫码能力未就绪|请刷新页面/i.test(msg);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export default function HomePage() {
|
||||
|
||||
useStorePageView('store_home_view');
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { ready, authenticated } = useStoreSession();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const [dash, setDash] = useState<Record<string, unknown> | null>(null);
|
||||
|
||||
const [scanMsg, setScanMsg] = useState('');
|
||||
|
||||
const [scanning, setScanning] = useState(false);
|
||||
|
||||
const [authModalOpen, setAuthModalOpen] = useState(false);
|
||||
|
||||
const [authModalMode, setAuthModalMode] = useState<'bind' | 'recover'>('bind');
|
||||
|
||||
const [authLoading, setAuthLoading] = useState(false);
|
||||
|
||||
const [authError, setAuthError] = useState('');
|
||||
|
||||
const pendingScanStartedRef = useRef(false);
|
||||
|
||||
|
||||
|
||||
const loadDashboard = useCallback(() => {
|
||||
|
||||
return request<Record<string, unknown>>('SHOP_H5', '/shop/dashboard')
|
||||
|
||||
.then((d) => {
|
||||
|
||||
setDash(d);
|
||||
|
||||
})
|
||||
|
||||
.catch(() => {});
|
||||
|
||||
}, []);
|
||||
|
||||
}
|
||||
|
||||
if (isScanPermissionWarmupError(msg)) {
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
return '微信授权已完成,扫码权限仍在准备中,请再点一次「扫码核销」';
|
||||
|
||||
void loadDashboard();
|
||||
}
|
||||
|
||||
return '微信权限校验尚未完成,请等待 1~2 秒后再次点击扫码(首次绑定微信时较常见)';
|
||||
|
||||
}
|
||||
|
||||
return msg;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}, [loadDashboard]);
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
function onResume() {
|
||||
|
||||
setScanning(false);
|
||||
|
||||
void loadDashboard();
|
||||
|
||||
}
|
||||
|
||||
function onVisibility() {
|
||||
|
||||
if (document.visibilityState === 'visible') onResume();
|
||||
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
|
||||
window.addEventListener('pageshow', onResume);
|
||||
|
||||
window.addEventListener('focus', onResume);
|
||||
|
||||
return () => {
|
||||
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
|
||||
window.removeEventListener('pageshow', onResume);
|
||||
|
||||
window.removeEventListener('focus', onResume);
|
||||
|
||||
};
|
||||
|
||||
}, [loadDashboard]);
|
||||
|
||||
|
||||
|
||||
const runScan = useCallback(
|
||||
|
||||
async (opts?: { postAuthWarmup?: boolean }) => {
|
||||
|
||||
trackStore('store_redeem_scan_start');
|
||||
|
||||
if (!isWechatEnv()) {
|
||||
|
||||
setScanMsg('请在微信内打开门店端进行扫码核销');
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
setScanning(true);
|
||||
|
||||
if (!opts?.postAuthWarmup) {
|
||||
|
||||
setScanMsg('');
|
||||
|
||||
}
|
||||
|
||||
try {
|
||||
const [scanning, setScanning] = useState(false);
|
||||
|
||||
// iOS / OAuth 回跳后须重新 wx.config(签名用入场 URL)
|
||||
if (opts?.postAuthWarmup || isIosDevice()) {
|
||||
|
||||
weixinSdk.reset();
|
||||
|
||||
}
|
||||
|
||||
await weixinSdk.init();
|
||||
|
||||
const raw = await weixinSdk.scanQrCode(
|
||||
|
||||
opts?.postAuthWarmup ? { postAuthWarmup: true } : undefined,
|
||||
|
||||
);
|
||||
|
||||
if (!raw) {
|
||||
|
||||
void loadDashboard();
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
const token = parseRedeemTokenFromScan(raw);
|
||||
|
||||
if (!token) {
|
||||
|
||||
setScanMsg('无法识别核销码,请扫描用户出示的核销二维码');
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
navigate(`/redeem?token=${encodeURIComponent(token)}`);
|
||||
|
||||
} catch (e) {
|
||||
setDash(d);
|
||||
|
||||
})
|
||||
|
||||
const tip = formatScanError(e, { afterAuth: opts?.postAuthWarmup });
|
||||
|
||||
setScanMsg(tip);
|
||||
|
||||
if (isScanRecoverableError(tip)) {
|
||||
|
||||
setAuthModalMode('recover');
|
||||
|
||||
setAuthError(tip);
|
||||
|
||||
setAuthModalOpen(true);
|
||||
|
||||
}
|
||||
|
||||
} finally {
|
||||
|
||||
setScanning(false);
|
||||
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
[loadDashboard, navigate],
|
||||
|
||||
);
|
||||
|
||||
|
||||
|
||||
// OAuth 由 StoreSessionContext 单例处理;此处仅在授权完成后续扫
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
if (!ready || !authenticated || !isWechatEnv()) return;
|
||||
|
||||
if (searchParams.get('code')) return;
|
||||
|
||||
if (!peekPendingScanAfterAuth() || pendingScanStartedRef.current) return;
|
||||
|
||||
|
||||
|
||||
pendingScanStartedRef.current = true;
|
||||
|
||||
clearPendingScanAfterAuth();
|
||||
|
||||
setAuthModalOpen(false);
|
||||
|
||||
setAuthLoading(false);
|
||||
|
||||
setAuthError('');
|
||||
|
||||
setScanMsg('微信授权成功,正在准备扫码…');
|
||||
|
||||
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
|
||||
void runScan({ postAuthWarmup: true });
|
||||
|
||||
}, getPostAuthScanDelayMs());
|
||||
|
||||
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
|
||||
}, [ready, authenticated, searchParams, runScan]);
|
||||
|
||||
|
||||
|
||||
async function handleScan() {
|
||||
|
||||
setScanMsg('');
|
||||
|
||||
if (!isWechatEnv()) {
|
||||
|
||||
setScanMsg('请在微信内打开门店端进行扫码核销');
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
const profile = await fetchShopAccount();
|
||||
|
||||
if (await checkNeedsWechatAuth(profile)) {
|
||||
|
||||
pendingScanStartedRef.current = false;
|
||||
|
||||
setAuthModalMode('bind');
|
||||
|
||||
setAuthError('');
|
||||
|
||||
setAuthModalOpen(true);
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
|
||||
const needWarmup = consumeScanWarmupAfterAuth();
|
||||
|
||||
await runScan(needWarmup ? { postAuthWarmup: true } : undefined);
|
||||
|
||||
} catch (e) {
|
||||
|
||||
setScanMsg(e instanceof Error ? e.message : '无法发起扫码');
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function startWechatAuth() {
|
||||
|
||||
setAuthLoading(true);
|
||||
|
||||
setAuthError('');
|
||||
|
||||
try {
|
||||
|
||||
pendingScanStartedRef.current = false;
|
||||
|
||||
markPendingScanAfterAuth();
|
||||
|
||||
await authorizeShopWechat();
|
||||
|
||||
} catch (e) {
|
||||
|
||||
clearPendingScanAfterAuth();
|
||||
|
||||
setAuthError(e instanceof Error ? e.message : '微信授权失败');
|
||||
|
||||
setAuthLoading(false);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
const store = dash?.store as Record<string, unknown> | undefined;
|
||||
|
||||
const recent = (dash?.recentRecords as Array<Record<string, unknown>>) || [];
|
||||
|
||||
const status = String(store?.status || '');
|
||||
|
||||
const open = status === 'OPEN';
|
||||
|
||||
const hoursParts: string[] = [];
|
||||
|
||||
if (store?.openTime && store?.closeTime) hoursParts.push(`${store.openTime} - ${store.closeTime}`);
|
||||
|
||||
if (store?.openTime2 && store?.closeTime2) hoursParts.push(`${store.openTime2} - ${store.closeTime2}`);
|
||||
|
||||
const hoursText = hoursParts.length ? hoursParts.join(',') : '10:00 - 22:00';
|
||||
|
||||
const statusText =
|
||||
|
||||
status === 'CLOSED' ? '永久关闭' : open ? '当前正在营业中' : '当前临时闭店';
|
||||
|
||||
|
||||
|
||||
return (
|
||||
|
||||
<PullToRefresh onRefresh={loadDashboard} className="shop-home-page">
|
||||
|
||||
<header className="shop-home-header">
|
||||
|
||||
<h1 className="app-page-title">门店管理中心</h1>
|
||||
|
||||
</header>
|
||||
|
||||
|
||||
|
||||
<div className="shop-home-content">
|
||||
|
||||
<section className="shop-home-hero">
|
||||
|
||||
<div className="shop-home-hero-store">
|
||||
|
||||
<span className="material-symbols-outlined shop-fill-icon">store</span>
|
||||
|
||||
<h2>{String(store?.name || '门店')}</h2>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="shop-home-stats">
|
||||
|
||||
<div className="shop-home-stat">
|
||||
|
||||
<p className="shop-home-stat-label">今日核销笔数</p>
|
||||
|
||||
<p className="shop-home-stat-value">{Number(dash?.todayCount || 0)}</p>
|
||||
|
||||
<p className="shop-home-stat-sub">
|
||||
|
||||
扫码 {Number(dash?.todayScanCount || 0)} · 手机号 {Number(dash?.todayPhoneCount || 0)}
|
||||
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="shop-home-stat">
|
||||
|
||||
<p className="shop-home-stat-label">今日到账金额</p>
|
||||
|
||||
<p className="shop-home-stat-value">
|
||||
|
||||
<span style={{ fontSize: 18 }}>¥</span>
|
||||
|
||||
{formatMoney(Number(dash?.todayAmount || 0))}
|
||||
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
<section className="shop-home-scan">
|
||||
|
||||
<button
|
||||
|
||||
type="button"
|
||||
|
||||
className="shop-home-scan-btn"
|
||||
|
||||
disabled={scanning}
|
||||
|
||||
onClick={() => void handleScan()}
|
||||
|
||||
>
|
||||
|
||||
<span className="material-symbols-outlined">qr_code_scanner</span>
|
||||
|
||||
</button>
|
||||
|
||||
<p className="shop-home-scan-label">{scanning ? '正在打开相机…' : '扫码核销'}</p>
|
||||
|
||||
{scanMsg && <p className="shop-home-scan-msg" role="alert">{scanMsg}</p>}
|
||||
|
||||
<Link to="/redeem/phone" className="shop-home-phone-link">
|
||||
|
||||
<span className="material-symbols-outlined">smartphone</span>
|
||||
|
||||
手机号核销
|
||||
|
||||
</Link>
|
||||
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
<section className="shop-home-status">
|
||||
|
||||
<div className="shop-home-status-left">
|
||||
|
||||
<div className={`shop-home-status-icon${open ? '' : ' closed'}`}>
|
||||
|
||||
<span className="material-symbols-outlined shop-fill-icon">schedule</span>
|
||||
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
<p className="shop-home-status-title">营业状态</p>
|
||||
|
||||
<p className="shop-home-status-sub">{statusText}</p>
|
||||
|
||||
<p className="shop-home-status-sub">营业时间: {hoursText}</p>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<label className="shop-home-switch" onClick={() => navigate('/status')}>
|
||||
|
||||
<input type="checkbox" checked={open && status !== 'CLOSED'} readOnly tabIndex={-1} />
|
||||
|
||||
<span className="shop-home-switch-track" />
|
||||
|
||||
</label>
|
||||
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
<section>
|
||||
|
||||
<div className="shop-home-records-head">
|
||||
|
||||
<h3 className="shop-home-records-title">核销记录</h3>
|
||||
|
||||
<Link to="/records" className="shop-home-records-link">
|
||||
|
||||
查看全部
|
||||
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>chevron_right</span>
|
||||
|
||||
</Link>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="shop-home-record-list">
|
||||
|
||||
{recent.length === 0 && (
|
||||
|
||||
<p className="shop-home-status-sub" style={{ textAlign: 'center', padding: '16px 0' }}>暂无核销记录</p>
|
||||
|
||||
)}
|
||||
|
||||
{recent.map((r) => (
|
||||
|
||||
<div key={String(r.id)} className="shop-home-record-item">
|
||||
|
||||
<div>
|
||||
|
||||
<p className="shop-home-record-time">核销时间</p>
|
||||
|
||||
<p className="shop-home-record-value">
|
||||
|
||||
{new Date(String(r.createdAt)).toLocaleString('zh-CN')}
|
||||
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
<p className="shop-home-record-amount">¥{formatMoney(Number(r.amount))}</p>
|
||||
|
||||
</div>
|
||||
|
||||
))}
|
||||
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<WechatScanAuthModal
|
||||
|
||||
open={authModalOpen}
|
||||
|
||||
mode={authModalMode}
|
||||
|
||||
loading={authLoading}
|
||||
|
||||
error={authError}
|
||||
|
||||
onAuthorize={() => void startWechatAuth()}
|
||||
|
||||
|
||||
async function handleScan() {
|
||||
|
||||
setScanMsg('');
|
||||
|
||||
onRefresh={() => {
|
||||
|
||||
window.location.reload();
|
||||
|
||||
}}
|
||||
|
||||
onCancel={() => {
|
||||
|
||||
setAuthModalOpen(false);
|
||||
|
||||
setAuthModalMode('bind');
|
||||
|
||||
setAuthError('');
|
||||
|
||||
clearPendingScanAfterAuth();
|
||||
|
||||
pendingScanStartedRef.current = false;
|
||||
|
||||
}}
|
||||
|
||||
/>
|
||||
|
||||
</PullToRefresh>
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -159,9 +159,9 @@ export default function LoginPage() {
|
||||
});
|
||||
saveRememberedSession(data);
|
||||
applySession(data);
|
||||
if (isWechatEnv() && wxAuthorize) {
|
||||
if (isWechatEnv() && wxAuthorize && !data.account?.hasWechat) {
|
||||
setMsg('登录成功,正在关联微信…');
|
||||
await bindShopWechatAfterSmsLogin();
|
||||
await bindShopWechatAfterSmsLogin(data);
|
||||
return;
|
||||
}
|
||||
routeAfterShopLogin(data, navigate);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { hardNavigateInWechat, shouldHardNavigateForJssdk } from '@dukang/weixin-sdk';
|
||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||
import {
|
||||
needsStoreSelection,
|
||||
@@ -10,6 +11,14 @@ import {
|
||||
type ShopStoreOption,
|
||||
} from '../lib/api';
|
||||
|
||||
function goShopHome(navigate: (path: string, opts?: { replace?: boolean }) => void) {
|
||||
if (shouldHardNavigateForJssdk()) {
|
||||
hardNavigateInWechat('/');
|
||||
return;
|
||||
}
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
|
||||
export default function SelectStorePage() {
|
||||
const navigate = useNavigate();
|
||||
const { applySession, store, authenticated } = useStoreSession();
|
||||
@@ -37,7 +46,7 @@ export default function SelectStorePage() {
|
||||
async function onSelect(storeId: string) {
|
||||
if (loadingId) return;
|
||||
if (storeId === currentStoreId) {
|
||||
navigate('/', { replace: true });
|
||||
goShopHome(navigate);
|
||||
return;
|
||||
}
|
||||
setLoadingId(storeId);
|
||||
@@ -45,7 +54,7 @@ export default function SelectStorePage() {
|
||||
try {
|
||||
const session = await selectStore(storeId);
|
||||
applySession(session);
|
||||
navigate('/', { replace: true });
|
||||
goShopHome(navigate);
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '选店失败');
|
||||
} finally {
|
||||
@@ -166,9 +175,11 @@ export function routeAfterShopLogin(
|
||||
session: ShopSessionPayload,
|
||||
navigate: (path: string, opts?: { replace?: boolean }) => void,
|
||||
) {
|
||||
if (needsStoreSelection(session)) {
|
||||
navigate('/select-store', { replace: true });
|
||||
const path = needsStoreSelection(session) ? '/select-store' : '/';
|
||||
// iOS 微信:必须整页跳转,让业务页成为 JSSDK 新入场 URL,否则扫码验签必挂
|
||||
if (shouldHardNavigateForJssdk()) {
|
||||
hardNavigateInWechat(path);
|
||||
return;
|
||||
}
|
||||
navigate('/', { replace: true });
|
||||
navigate(path, { replace: true });
|
||||
}
|
||||
|
||||
@@ -794,6 +794,7 @@
|
||||
|
||||
.shop-scan-auth-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { CUSTOMER_SERVICE_PHONE } from '@dukang/shared-types';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { track } from '../lib/analytics';
|
||||
import { openWecomCustomerService } from '../lib/customer-service';
|
||||
import {
|
||||
getCustomerServicePhone,
|
||||
loadCustomerServicePhone,
|
||||
openWecomCustomerService,
|
||||
} from '../lib/customer-service';
|
||||
|
||||
type ContactCustomerSheetProps = {
|
||||
orderId?: string;
|
||||
@@ -9,7 +13,13 @@ type ContactCustomerSheetProps = {
|
||||
};
|
||||
|
||||
export default function ContactCustomerSheet({ orderId, onClose }: ContactCustomerSheetProps) {
|
||||
const tel = CUSTOMER_SERVICE_PHONE.replace(/-/g, '');
|
||||
const [phone, setPhone] = useState(getCustomerServicePhone);
|
||||
|
||||
useEffect(() => {
|
||||
void loadCustomerServicePhone().then(setPhone);
|
||||
}, []);
|
||||
|
||||
const tel = phone.replace(/-/g, '');
|
||||
|
||||
function openPhone() {
|
||||
track('cs_contact', { type: 'phone', orderId });
|
||||
@@ -41,7 +51,7 @@ export default function ContactCustomerSheet({ orderId, onClose }: ContactCustom
|
||||
</div>
|
||||
<div className="contact-customer-option-body">
|
||||
<p className="contact-customer-option-title">拨打总部客服电话</p>
|
||||
<p className="contact-customer-option-sub">{CUSTOMER_SERVICE_PHONE}</p>
|
||||
<p className="contact-customer-option-sub">{phone}</p>
|
||||
</div>
|
||||
<span className="material-symbols-outlined contact-customer-chevron">chevron_right</span>
|
||||
</button>
|
||||
|
||||
@@ -1,12 +1,31 @@
|
||||
import { CUSTOMER_SERVICE_PHONE, CUSTOMER_SERVICE_WECOM_URL } from '@dukang/shared-types';
|
||||
import { fetchClientConfig } from './pay-wechat';
|
||||
import { isWechatEnv } from './weixin';
|
||||
|
||||
let cachedPhone = CUSTOMER_SERVICE_PHONE;
|
||||
|
||||
/** 企微客服链接:优先 Vite env,否则 shared-types 默认 */
|
||||
export function getCustomerServiceWecomUrl(): string {
|
||||
const fromEnv = import.meta.env.VITE_CS_WECOM_URL?.trim();
|
||||
return fromEnv || CUSTOMER_SERVICE_WECOM_URL;
|
||||
}
|
||||
|
||||
export function getCustomerServicePhone(): string {
|
||||
return cachedPhone;
|
||||
}
|
||||
|
||||
/** 从系统设置拉取客服电话(失败则保持默认常量) */
|
||||
export async function loadCustomerServicePhone(): Promise<string> {
|
||||
try {
|
||||
const cfg = await fetchClientConfig();
|
||||
const phone = cfg.customerServicePhone?.trim();
|
||||
if (phone) cachedPhone = phone;
|
||||
} catch {
|
||||
/* keep fallback */
|
||||
}
|
||||
return cachedPhone;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开企业微信客服会话(须在微信内;需用户点击手势)。
|
||||
* @returns true 已跳转;false 非微信环境已提示
|
||||
@@ -20,4 +39,5 @@ export function openWecomCustomerService(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @deprecated 请用 getCustomerServicePhone(),保留兼容旧引用 */
|
||||
export { CUSTOMER_SERVICE_PHONE };
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import SubPageHeader from '../components/SubPageHeader';
|
||||
import { CUSTOMER_SERVICE_PHONE, openWecomCustomerService } from '../lib/customer-service';
|
||||
import {
|
||||
getCustomerServicePhone,
|
||||
loadCustomerServicePhone,
|
||||
openWecomCustomerService,
|
||||
} from '../lib/customer-service';
|
||||
import { track } from '../lib/analytics';
|
||||
|
||||
export default function CustomerServicePage() {
|
||||
const navigate = useNavigate();
|
||||
const tel = CUSTOMER_SERVICE_PHONE.replace(/-/g, '');
|
||||
const [phone, setPhone] = useState(getCustomerServicePhone);
|
||||
|
||||
useEffect(() => {
|
||||
void loadCustomerServicePhone().then(setPhone);
|
||||
}, []);
|
||||
|
||||
const tel = phone.replace(/-/g, '');
|
||||
|
||||
function openOnline() {
|
||||
track('cs_contact', { type: 'wecom_kf' });
|
||||
@@ -27,7 +38,7 @@ export default function CustomerServicePage() {
|
||||
|
||||
<a className="customer-service-phone-link" href={`tel:${tel}`}>
|
||||
<span className="material-symbols-outlined">call</span>
|
||||
或拨打客服电话 {CUSTOMER_SERVICE_PHONE}
|
||||
或拨打客服电话 {phone}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@dukang/mini-user",
|
||||
"version": "3.4.13",
|
||||
"version": "3.4.15",
|
||||
"private": true,
|
||||
"description": "杜康好客 · C 端用户微信小程序(Taro)",
|
||||
"scripts": {
|
||||
|
||||
@@ -6,6 +6,7 @@ export default defineAppConfig({
|
||||
'pages/mine/index',
|
||||
'pages/product-detail/index',
|
||||
'pages/store-detail/index',
|
||||
'pages/store-package-detail/index',
|
||||
'pages/order-confirm/index',
|
||||
'pages/order-confirm-pickup/index',
|
||||
'pages/pay/index',
|
||||
|
||||
@@ -6,11 +6,13 @@ import WechatShareBootstrap from './components/WechatShareBootstrap';
|
||||
import { patchTaroH5Hooks } from './lib/patch-taro-h5-hooks';
|
||||
import { handleWechatOrderConfirmShow } from './lib/wechat-order-confirm';
|
||||
import { installClientErrorReporting } from './lib/client-error';
|
||||
import { prefetchShareBrandAssets } from './lib/wechat-share';
|
||||
import './app.css';
|
||||
|
||||
// H5:在首屏 page hooks 执行前,把 Taro.useDidShow 等绑到与 createReactApp 同一份 runtime
|
||||
patchTaroH5Hooks();
|
||||
installClientErrorReporting();
|
||||
prefetchShareBrandAssets();
|
||||
|
||||
function App({ children }: PropsWithChildren) {
|
||||
const handlingRef = useRef(false);
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -7,6 +7,8 @@ type ProductCarouselProps = {
|
||||
alt: string;
|
||||
variant?: 'home' | 'detail' | 'store';
|
||||
previewable?: boolean;
|
||||
/** cover=aspectFill 裁剪铺满;contain=aspectFit 缩放完整显示(门店门头固定区) */
|
||||
imageFit?: 'cover' | 'contain';
|
||||
};
|
||||
|
||||
/** 商品/门店轮播(对齐 h5 ProductCarousel 三变体) */
|
||||
@@ -15,12 +17,14 @@ export default function ProductCarousel({
|
||||
alt,
|
||||
variant = 'detail',
|
||||
previewable = false,
|
||||
imageFit = 'cover',
|
||||
}: ProductCarouselProps) {
|
||||
const slides = images.length > 0 ? images : [''];
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const prefix =
|
||||
variant === 'store' ? 'store-detail-carousel' : variant === 'home' ? 'home-carousel' : 'detail-carousel';
|
||||
const imageMode = 'aspectFill';
|
||||
const isContain = imageFit === 'contain';
|
||||
const wrapClass = `${prefix}-wrap${isContain ? ` ${prefix}-wrap--contain` : ''}`;
|
||||
|
||||
function previewAt(index: number) {
|
||||
const urls = slides.filter(Boolean);
|
||||
@@ -30,7 +34,7 @@ export default function ProductCarousel({
|
||||
}
|
||||
|
||||
return (
|
||||
<View className={`${prefix}-wrap`}>
|
||||
<View className={wrapClass}>
|
||||
<Swiper
|
||||
className={prefix}
|
||||
circular={slides.length > 1}
|
||||
@@ -42,7 +46,7 @@ export default function ProductCarousel({
|
||||
<Image
|
||||
className={`${prefix}-image`}
|
||||
src={src}
|
||||
mode={imageMode}
|
||||
mode={isContain ? 'aspectFit' : 'aspectFill'}
|
||||
alt={alt}
|
||||
onClick={previewable ? () => previewAt(index) : undefined}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
BRAND_LOGO_MARK_URL,
|
||||
BRAND_LOGO_URL,
|
||||
BRAND_LOGO_WIDE_URL,
|
||||
CUSTOMER_SERVICE_PHONE,
|
||||
QUALIFICATION_DISCLOSURE_URL,
|
||||
type ClientRuntimeConfig,
|
||||
} from '@dukang/shared-types';
|
||||
import { fetchClientConfig } from './pay-wechat';
|
||||
|
||||
export type BrandAssets = {
|
||||
brandLogoUrl: string;
|
||||
brandLogoWideUrl: string;
|
||||
brandLogoMarkUrl: string;
|
||||
qualificationDisclosureUrl: string;
|
||||
customerServicePhone: string;
|
||||
};
|
||||
|
||||
const FALLBACK: BrandAssets = {
|
||||
brandLogoUrl: BRAND_LOGO_URL,
|
||||
brandLogoWideUrl: BRAND_LOGO_WIDE_URL,
|
||||
brandLogoMarkUrl: BRAND_LOGO_MARK_URL,
|
||||
qualificationDisclosureUrl: QUALIFICATION_DISCLOSURE_URL,
|
||||
customerServicePhone: CUSTOMER_SERVICE_PHONE,
|
||||
};
|
||||
|
||||
let cached: BrandAssets | null = null;
|
||||
let inflight: Promise<BrandAssets> | null = null;
|
||||
|
||||
function fromConfig(config: ClientRuntimeConfig | null | undefined): BrandAssets {
|
||||
return {
|
||||
brandLogoUrl: config?.brandLogoUrl?.trim() || FALLBACK.brandLogoUrl,
|
||||
brandLogoWideUrl: config?.brandLogoWideUrl?.trim() || FALLBACK.brandLogoWideUrl,
|
||||
brandLogoMarkUrl: config?.brandLogoMarkUrl?.trim() || FALLBACK.brandLogoMarkUrl,
|
||||
qualificationDisclosureUrl:
|
||||
config?.qualificationDisclosureUrl?.trim() || FALLBACK.qualificationDisclosureUrl,
|
||||
customerServicePhone: config?.customerServicePhone?.trim() || FALLBACK.customerServicePhone,
|
||||
};
|
||||
}
|
||||
|
||||
/** 同步读取最近一次缓存(未拉取前返回代码默认常量) */
|
||||
export function getBrandAssetsSync(): BrandAssets {
|
||||
return cached ?? FALLBACK;
|
||||
}
|
||||
|
||||
/** 拉取 /common/client-config 中的品牌与客服配置并缓存 */
|
||||
export async function loadBrandAssets(force = false): Promise<BrandAssets> {
|
||||
if (!force && cached) return cached;
|
||||
if (!force && inflight) return inflight;
|
||||
inflight = fetchClientConfig()
|
||||
.then((cfg) => {
|
||||
cached = fromConfig(cfg);
|
||||
return cached;
|
||||
})
|
||||
.catch(() => {
|
||||
cached = FALLBACK;
|
||||
return cached;
|
||||
})
|
||||
.finally(() => {
|
||||
inflight = null;
|
||||
});
|
||||
return inflight;
|
||||
}
|
||||
|
||||
export function applyBrandFromClientConfig(config: ClientRuntimeConfig | null | undefined) {
|
||||
cached = fromConfig(config);
|
||||
return cached;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import Taro from '@tarojs/taro';
|
||||
import { fetchClientConfig } from './pay-wechat';
|
||||
|
||||
/** 与 package.json version 同步,供服务端 minClientVersion 比对 */
|
||||
export const APP_VERSION = '3.4.13';
|
||||
export const APP_VERSION = '3.4.15';
|
||||
|
||||
export const APP_VERSION_LABEL = `v${APP_VERSION}`;
|
||||
|
||||
|
||||
@@ -1,16 +1,126 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
import { BRAND_LOGO_URL } from '@dukang/shared-types';
|
||||
import type { WechatShareData } from '@dukang/weixin-sdk';
|
||||
import { getWechatShareLink, isWechatBrowser, isMiniProgram } from '@dukang/weixin-sdk';
|
||||
import {
|
||||
applyShareTitleTemplate,
|
||||
DEFAULT_SHARE_DESC,
|
||||
DEFAULT_SHARE_HINT,
|
||||
DEFAULT_SHARE_TITLE,
|
||||
resolveMiniShareRuntime,
|
||||
type ClientRuntimeConfig,
|
||||
type MiniShareRuntime,
|
||||
type MiniShareSceneConfig,
|
||||
} from '@dukang/shared-types';
|
||||
import { toast } from './api';
|
||||
import { getBrandAssetsSync, loadBrandAssets } from './brand-assets';
|
||||
import { fetchClientConfig } from './pay-wechat';
|
||||
import { isWechatEnv, weixinSdk } from './weixin';
|
||||
|
||||
export const DEFAULT_SHARE_TITLE = '你吃饭,杜康买单';
|
||||
export const DEFAULT_SHARE_DESC = '杜康好客 · 买美酒,享好礼,全城门店可用';
|
||||
export const WECHAT_SHARE_HINT = '请点击右上角 ··· 分享给好友';
|
||||
export type ShareScene =
|
||||
| 'home'
|
||||
| 'stores'
|
||||
| 'storeDetail'
|
||||
| 'benefit'
|
||||
| 'mine'
|
||||
| 'productDetail'
|
||||
| 'orderDetail';
|
||||
|
||||
export { DEFAULT_SHARE_TITLE, DEFAULT_SHARE_DESC };
|
||||
|
||||
export const WECHAT_SHARE_HINT = DEFAULT_SHARE_HINT;
|
||||
|
||||
const FALLBACK_SHARE: MiniShareRuntime = resolveMiniShareRuntime({});
|
||||
|
||||
let shareCached: MiniShareRuntime | null = null;
|
||||
|
||||
export function getShareRuntimeSync(): MiniShareRuntime {
|
||||
return shareCached ?? FALLBACK_SHARE;
|
||||
}
|
||||
|
||||
export function applyShareFromClientConfig(config?: ClientRuntimeConfig | null) {
|
||||
if (config?.share) {
|
||||
shareCached = config.share;
|
||||
return shareCached;
|
||||
}
|
||||
return getShareRuntimeSync();
|
||||
}
|
||||
|
||||
export async function loadShareConfig(force = false): Promise<MiniShareRuntime> {
|
||||
if (!force && shareCached) return shareCached;
|
||||
try {
|
||||
const cfg = await fetchClientConfig();
|
||||
if (cfg?.share) {
|
||||
shareCached = cfg.share;
|
||||
return shareCached;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
shareCached = FALLBACK_SHARE;
|
||||
return shareCached;
|
||||
}
|
||||
|
||||
export function getDefaultShareImageUrl(): string {
|
||||
return BRAND_LOGO_URL;
|
||||
const share = getShareRuntimeSync();
|
||||
return share.default.imageUrl || getBrandAssetsSync().brandLogoUrl;
|
||||
}
|
||||
|
||||
export function getShareHint(): string {
|
||||
return getShareRuntimeSync().hint || DEFAULT_SHARE_HINT;
|
||||
}
|
||||
|
||||
/** 预热分享配置与品牌图 */
|
||||
export function prefetchShareBrandAssets() {
|
||||
void loadBrandAssets();
|
||||
void loadShareConfig();
|
||||
}
|
||||
|
||||
function sceneConfig(scene?: ShareScene): MiniShareSceneConfig {
|
||||
const runtime = getShareRuntimeSync();
|
||||
if (!scene) return runtime.default;
|
||||
return runtime[scene] ?? runtime.default;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装页面分享:场景配置优先;空字段用 dynamic → 默认分享。
|
||||
* orderDetail 标题支持 {productName}。
|
||||
*/
|
||||
export function buildSceneSharePayload(
|
||||
scene: ShareScene,
|
||||
options?: {
|
||||
path?: string;
|
||||
/** 业务动态标题(场景配置为空时使用) */
|
||||
dynamicTitle?: string | null;
|
||||
dynamicDesc?: string | null;
|
||||
dynamicImageUrl?: string | null;
|
||||
titleVars?: Record<string, string | undefined | null>;
|
||||
},
|
||||
): PageSharePayload {
|
||||
const def = getShareRuntimeSync().default;
|
||||
const sc = sceneConfig(scene);
|
||||
let title = (sc.title || '').trim();
|
||||
if (title && options?.titleVars) {
|
||||
const vars = options.titleVars;
|
||||
const missingRequired = Object.entries(vars).some(
|
||||
([key, value]) => title.includes(`{${key}}`) && !(value != null && String(value).trim()),
|
||||
);
|
||||
title = missingRequired ? '' : applyShareTitleTemplate(title, vars);
|
||||
}
|
||||
if (!title) {
|
||||
title = (options?.dynamicTitle || '').trim() || def.title;
|
||||
}
|
||||
const desc = (sc.desc || '').trim() || (options?.dynamicDesc || '').trim() || def.desc;
|
||||
const imgUrl =
|
||||
(sc.imageUrl || '').trim() ||
|
||||
(options?.dynamicImageUrl || '').trim() ||
|
||||
def.imageUrl ||
|
||||
getDefaultShareImageUrl();
|
||||
return {
|
||||
title,
|
||||
desc,
|
||||
path: options?.path,
|
||||
imgUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDefaultShareData(
|
||||
@@ -24,9 +134,10 @@ export function buildDefaultShareData(
|
||||
link = '';
|
||||
}
|
||||
}
|
||||
const def = getShareRuntimeSync().default;
|
||||
return {
|
||||
title: overrides?.title ?? DEFAULT_SHARE_TITLE,
|
||||
desc: overrides?.desc ?? DEFAULT_SHARE_DESC,
|
||||
title: overrides?.title ?? def.title,
|
||||
desc: overrides?.desc ?? def.desc,
|
||||
link,
|
||||
imgUrl: overrides?.imgUrl ?? getDefaultShareImageUrl(),
|
||||
};
|
||||
@@ -68,7 +179,7 @@ export async function handleShareButtonClick(
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
toast(WECHAT_SHARE_HINT);
|
||||
toast(getShareHint());
|
||||
return { showGuide: false };
|
||||
}
|
||||
|
||||
@@ -89,7 +200,7 @@ export async function handleShareButtonClick(
|
||||
if (result.invoked) {
|
||||
return { showGuide: false };
|
||||
}
|
||||
toast(WECHAT_SHARE_HINT);
|
||||
toast(getShareHint());
|
||||
return { showGuide: true };
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : '分享配置失败,请刷新后重试');
|
||||
@@ -99,9 +210,20 @@ export async function handleShareButtonClick(
|
||||
|
||||
/** 供 useShareAppMessage 使用的标题/路径/图 */
|
||||
export function toWeappShareMessage(payload?: PageSharePayload) {
|
||||
const def = getShareRuntimeSync().default;
|
||||
return {
|
||||
title: payload?.title || DEFAULT_SHARE_TITLE,
|
||||
title: payload?.title || def.title,
|
||||
path: payload?.path || '/pages/home/index',
|
||||
imageUrl: payload?.imgUrl || getDefaultShareImageUrl(),
|
||||
};
|
||||
}
|
||||
|
||||
/** 供 useShareTimeline */
|
||||
export function toWeappShareTimeline(payload?: PageSharePayload, query = '') {
|
||||
const def = getShareRuntimeSync().default;
|
||||
return {
|
||||
title: payload?.title || def.title,
|
||||
query,
|
||||
imageUrl: payload?.imgUrl || getDefaultShareImageUrl(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@ import { goLogin } from '../../lib/auth-nav';
|
||||
import { isLoggedIn, request, toast } from '../../lib/api';
|
||||
import { navBarStyle, tabNavContentStyle, useNavBarMetrics } from '../../lib/nav-bar';
|
||||
import {
|
||||
DEFAULT_SHARE_DESC,
|
||||
DEFAULT_SHARE_TITLE,
|
||||
buildSceneSharePayload,
|
||||
toWeappShareMessage,
|
||||
toWeappShareTimeline,
|
||||
} from '../../lib/wechat-share';
|
||||
import { formatMoney } from '../../lib/money';
|
||||
import iconBenefit from '../../assets/tabbar/benefit-active.png';
|
||||
@@ -104,20 +104,15 @@ export default function BenefitPage() {
|
||||
const available = coupons.filter((c) => c.status === 'ACTIVE' && c.balance > 0);
|
||||
|
||||
const sharePayload = useMemo(
|
||||
() => ({
|
||||
title: '好客权益 · 杜康好客',
|
||||
desc: DEFAULT_SHARE_DESC,
|
||||
() =>
|
||||
buildSceneSharePayload('benefit', {
|
||||
path: '/pages/benefit/index',
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
useShareAppMessage(() => toWeappShareMessage(sharePayload));
|
||||
useShareTimeline(() => ({
|
||||
title: sharePayload.title || DEFAULT_SHARE_TITLE,
|
||||
query: '',
|
||||
imageUrl: sharePayload.imgUrl,
|
||||
}));
|
||||
useShareTimeline(() => toWeappShareTimeline(sharePayload));
|
||||
|
||||
return (
|
||||
<PageShell variant="tab" className="benefit-page">
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { CUSTOMER_SERVICE_PHONE } from '@dukang/shared-types';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
import ContactCsButton from '../../components/ContactCsButton';
|
||||
import { toast } from '../../lib/api';
|
||||
import { getBrandAssetsSync, loadBrandAssets } from '../../lib/brand-assets';
|
||||
|
||||
const isWeapp = process.env.TARO_ENV === 'weapp';
|
||||
const DIAL_PHONE = CUSTOMER_SERVICE_PHONE.replace(/-/g, '');
|
||||
|
||||
function dialPhone() {
|
||||
Taro.makePhoneCall({ phoneNumber: DIAL_PHONE }).catch(() => toast('无法拨打电话'));
|
||||
function dialPhone(phone: string) {
|
||||
const tel = phone.replace(/-/g, '');
|
||||
Taro.makePhoneCall({ phoneNumber: tel }).catch(() => toast('无法拨打电话'));
|
||||
}
|
||||
|
||||
export default function CustomerServicePage() {
|
||||
const [phone, setPhone] = useState(() => getBrandAssetsSync().customerServicePhone);
|
||||
|
||||
useEffect(() => {
|
||||
void loadBrandAssets().then((b) => setPhone(b.customerServicePhone));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PageShell variant="sub" className="cs-page">
|
||||
<SubPageHeader title="联系客服" />
|
||||
@@ -30,14 +37,14 @@ export default function CustomerServicePage() {
|
||||
<ContactCsButton className="cs-online-btn" session={{ from: 'customer-service' }} />
|
||||
) : null}
|
||||
|
||||
<View className={isWeapp ? 'cs-phone-link' : 'cs-call-btn'} onClick={dialPhone}>
|
||||
<View className={isWeapp ? 'cs-phone-link' : 'cs-call-btn'} onClick={() => dialPhone(phone)}>
|
||||
<Text>
|
||||
{isWeapp ? `或拨打客服电话 ${CUSTOMER_SERVICE_PHONE}` : '拨打客服电话'}
|
||||
{isWeapp ? `或拨打客服电话 ${phone}` : '拨打客服电话'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{!isWeapp ? (
|
||||
<Text className="cs-phone-display">{CUSTOMER_SERVICE_PHONE}</Text>
|
||||
<Text className="cs-phone-display">{phone}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</PageShell>
|
||||
|
||||
@@ -28,10 +28,12 @@ import {
|
||||
normalizeFulfillmentFlags,
|
||||
} from '../../lib/product-fulfillment';
|
||||
import { getCityCodeForCatalog, resolveUserCity } from '../../lib/user-location';
|
||||
import type { ClientRuntimeConfig } from '@dukang/shared-types';
|
||||
import {
|
||||
DEFAULT_SHARE_DESC,
|
||||
DEFAULT_SHARE_TITLE,
|
||||
applyShareFromClientConfig,
|
||||
buildSceneSharePayload,
|
||||
toWeappShareMessage,
|
||||
toWeappShareTimeline,
|
||||
} from '../../lib/wechat-share';
|
||||
import { trackPageView } from '../../lib/analytics';
|
||||
type Product = {
|
||||
@@ -85,8 +87,9 @@ export default function HomePage() {
|
||||
}, [cityCode]);
|
||||
|
||||
const loadMiniHome = useCallback(() => {
|
||||
return request<{ miniHome?: MiniHomeConfig }>('/common/client-config')
|
||||
return request<ClientRuntimeConfig>('/common/client-config')
|
||||
.then((cfg) => {
|
||||
applyShareFromClientConfig(cfg);
|
||||
const banners = Array.isArray(cfg.miniHome?.banners)
|
||||
? cfg.miniHome!.banners.filter((u) => typeof u === 'string' && !!u.trim())
|
||||
: [];
|
||||
@@ -221,21 +224,16 @@ export default function HomePage() {
|
||||
const footerUrl = miniHome.footerUrl;
|
||||
|
||||
const sharePayload = useMemo(
|
||||
() => ({
|
||||
title: DEFAULT_SHARE_TITLE,
|
||||
desc: DEFAULT_SHARE_DESC,
|
||||
() =>
|
||||
buildSceneSharePayload('home', {
|
||||
path: '/pages/home/index',
|
||||
imgUrl: banners[0] || undefined,
|
||||
dynamicImageUrl: banners[0] || undefined,
|
||||
}),
|
||||
[banners],
|
||||
);
|
||||
|
||||
useShareAppMessage(() => toWeappShareMessage(sharePayload));
|
||||
useShareTimeline(() => ({
|
||||
title: sharePayload.title || DEFAULT_SHARE_TITLE,
|
||||
query: '',
|
||||
imageUrl: sharePayload.imgUrl,
|
||||
}));
|
||||
useShareTimeline(() => toWeappShareTimeline(sharePayload));
|
||||
|
||||
function scrollToAroma(key: AromaKey) {
|
||||
setActiveAroma(key);
|
||||
|
||||
@@ -10,7 +10,11 @@ import {
|
||||
import PageShell from '../../components/PageShell';
|
||||
import WechatLoginButton from '../../components/WechatLoginButton';
|
||||
import PhoneQuickLoginButton from '../../components/PhoneQuickLoginButton';
|
||||
import { BRAND_LOGO_WIDE_URL } from '@dukang/shared-types';
|
||||
import {
|
||||
applyBrandFromClientConfig,
|
||||
getBrandAssetsSync,
|
||||
loadBrandAssets,
|
||||
} from '../../lib/brand-assets';
|
||||
import { finishLoginNavigate, forceReloadAfterAccountMerge } from '../../lib/auth-nav';
|
||||
import {
|
||||
bindWechatForUser,
|
||||
@@ -98,11 +102,18 @@ export default function LoginPage() {
|
||||
const [wxAuthorize, setWxAuthorize] = useState(true);
|
||||
const [completeMode, setCompleteMode] = useState<'phone' | 'wechat' | null>(null);
|
||||
const [showSmsForm, setShowSmsForm] = useState(!IS_WEAPP);
|
||||
const [logoWideUrl, setLogoWideUrl] = useState(() => getBrandAssetsSync().brandLogoWideUrl);
|
||||
|
||||
useEffect(() => {
|
||||
request<ClientRuntimeConfig>('/common/client-config')
|
||||
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
|
||||
.catch(() => setWxAuthorize(true));
|
||||
.then((config) => {
|
||||
setWxAuthorize(isWxAuthorizeEnabled(config));
|
||||
setLogoWideUrl(applyBrandFromClientConfig(config).brandLogoWideUrl);
|
||||
})
|
||||
.catch(() => {
|
||||
setWxAuthorize(true);
|
||||
void loadBrandAssets().then((b) => setLogoWideUrl(b.brandLogoWideUrl));
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -394,7 +405,7 @@ export default function LoginPage() {
|
||||
<View className="login-header">
|
||||
<View className="login-logo-wrap">
|
||||
<View className="login-logo">
|
||||
<Image className="login-logo-img" src={BRAND_LOGO_WIDE_URL} mode="aspectFit" />
|
||||
<Image className="login-logo-img" src={logoWideUrl} mode="aspectFit" />
|
||||
</View>
|
||||
<Text className="login-logo-badge">官方</Text>
|
||||
</View>
|
||||
|
||||
@@ -2,8 +2,6 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { View, Text, Image, Button, Input, ScrollView } from '@tarojs/components';
|
||||
import Taro, { useDidShow, usePullDownRefresh, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
|
||||
import {
|
||||
BRAND_LOGO_MARK_URL,
|
||||
QUALIFICATION_DISCLOSURE_URL,
|
||||
isWxAuthorizeEnabled,
|
||||
type ClientRuntimeConfig,
|
||||
} from '@dukang/shared-types';
|
||||
@@ -13,6 +11,11 @@ import WechatShareReady from '../../components/WechatShareReady';
|
||||
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
|
||||
import { goLogin } from '../../lib/auth-nav';
|
||||
import { bindWechatForUser } from '../../lib/wechat-auth';
|
||||
import {
|
||||
applyBrandFromClientConfig,
|
||||
getBrandAssetsSync,
|
||||
loadBrandAssets,
|
||||
} from '../../lib/brand-assets';
|
||||
import {
|
||||
fetchMiniWechatUserInfo,
|
||||
isDefaultMiniNickname,
|
||||
@@ -26,9 +29,10 @@ import { isWechatEnv } from '../../lib/weixin';
|
||||
import { APP_VERSION_LABEL } from '../../lib/client-version';
|
||||
import { maskPhone } from '../../lib/phone';
|
||||
import {
|
||||
DEFAULT_SHARE_DESC,
|
||||
DEFAULT_SHARE_TITLE,
|
||||
applyShareFromClientConfig,
|
||||
buildSceneSharePayload,
|
||||
toWeappShareMessage,
|
||||
toWeappShareTimeline,
|
||||
} from '../../lib/wechat-share';
|
||||
import iconPendingPay from '../../assets/icons/待付款.png';
|
||||
import iconPaid from '../../assets/icons/已付款.png';
|
||||
@@ -70,7 +74,11 @@ export default function MinePage() {
|
||||
const [savingProfile, setSavingProfile] = useState(false);
|
||||
const [profileLoadError, setProfileLoadError] = useState('');
|
||||
const [qualificationOpen, setQualificationOpen] = useState(false);
|
||||
|
||||
const [brandMarkUrl, setBrandMarkUrl] = useState(() => getBrandAssetsSync().brandLogoMarkUrl);
|
||||
const [qualificationUrl, setQualificationUrl] = useState(
|
||||
() => getBrandAssetsSync().qualificationDisclosureUrl,
|
||||
);
|
||||
const [shareTick, setShareTick] = useState(0);
|
||||
function resetGuestState() {
|
||||
setProfile(null);
|
||||
setBenefitBalance(0);
|
||||
@@ -141,24 +149,30 @@ export default function MinePage() {
|
||||
|
||||
useEffect(() => {
|
||||
request<ClientRuntimeConfig>('/common/client-config')
|
||||
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
|
||||
.catch(() => setWxAuthorize(true));
|
||||
.then((config) => {
|
||||
setWxAuthorize(isWxAuthorizeEnabled(config));
|
||||
const brand = applyBrandFromClientConfig(config);
|
||||
applyShareFromClientConfig(config);
|
||||
setBrandMarkUrl(brand.brandLogoMarkUrl);
|
||||
setQualificationUrl(brand.qualificationDisclosureUrl);
|
||||
setShareTick((n) => n + 1);
|
||||
})
|
||||
.catch(() => {
|
||||
setWxAuthorize(true);
|
||||
void loadBrandAssets();
|
||||
});
|
||||
}, []);
|
||||
|
||||
const sharePayload = useMemo(
|
||||
() => ({
|
||||
title: '杜康好客 · 我的',
|
||||
desc: DEFAULT_SHARE_DESC,
|
||||
() =>
|
||||
buildSceneSharePayload('mine', {
|
||||
path: '/pages/mine/index',
|
||||
}),
|
||||
[],
|
||||
[shareTick],
|
||||
);
|
||||
|
||||
useShareAppMessage(() => toWeappShareMessage(sharePayload));
|
||||
useShareTimeline(() => ({
|
||||
title: sharePayload.title || DEFAULT_SHARE_TITLE,
|
||||
query: '',
|
||||
}));
|
||||
useShareTimeline(() => toWeappShareTimeline(sharePayload));
|
||||
|
||||
async function ensureWechatBound(): Promise<boolean> {
|
||||
if (profile?.hasWechat) return true;
|
||||
@@ -304,7 +318,7 @@ export default function MinePage() {
|
||||
if (displayAvatarUrl) {
|
||||
return <Image className="mine-avatar-img" src={displayAvatarUrl} mode="aspectFill" />;
|
||||
}
|
||||
return <Image className="mine-avatar-img" src={BRAND_LOGO_MARK_URL} mode="aspectFit" />;
|
||||
return <Image className="mine-avatar-img" src={brandMarkUrl} mode="aspectFit" />;
|
||||
}
|
||||
|
||||
if (!authed) {
|
||||
@@ -317,7 +331,7 @@ export default function MinePage() {
|
||||
<View className="mine-profile">
|
||||
<View className="mine-avatar-wrap mine-avatar-wrap--action" onClick={() => goLogin('/pages/mine/index')}>
|
||||
<View className="mine-avatar mine-avatar--wx-pending">
|
||||
<Image className="mine-avatar-img" src={BRAND_LOGO_MARK_URL} mode="aspectFit" />
|
||||
<Image className="mine-avatar-img" src={brandMarkUrl} mode="aspectFit" />
|
||||
</View>
|
||||
</View>
|
||||
<View>
|
||||
@@ -510,8 +524,13 @@ export default function MinePage() {
|
||||
{shouldRenderPageTabBar() ? <UserTabBar selected={3} /> : null}
|
||||
|
||||
{profileSheetOpen ? (
|
||||
<View className="mine-profile-sheet-mask" onClick={() => !savingProfile && setProfileSheetOpen(false)}>
|
||||
<View className="mine-profile-sheet" onClick={(e) => e.stopPropagation()}>
|
||||
<View className="mine-profile-sheet-mask">
|
||||
{/* 遮罩单独绑 tap,勿在含 chooseAvatar 的祖先上用 stopPropagation(会编译成 catchtap 导致选头像无反应) */}
|
||||
<View
|
||||
className="mine-profile-sheet-backdrop"
|
||||
onClick={() => !savingProfile && setProfileSheetOpen(false)}
|
||||
/>
|
||||
<View className="mine-profile-sheet">
|
||||
<Text className="mine-profile-sheet-title">完善头像与昵称</Text>
|
||||
<Text className="mine-profile-sheet-hint">
|
||||
点击头像选择,并填写昵称后保存(用于个人中心展示)
|
||||
@@ -520,13 +539,14 @@ export default function MinePage() {
|
||||
className="mine-profile-avatar-btn"
|
||||
openType="chooseAvatar"
|
||||
hoverClass="none"
|
||||
plain
|
||||
onChooseAvatar={onChooseAvatar}
|
||||
>
|
||||
<View className="mine-profile-avatar-preview">
|
||||
{previewAvatar ? (
|
||||
<Image className="mine-avatar-img" src={previewAvatar} mode="aspectFill" />
|
||||
) : (
|
||||
<Image className="mine-avatar-img" src={BRAND_LOGO_MARK_URL} mode="aspectFit" />
|
||||
<Image className="mine-avatar-img" src={brandMarkUrl} mode="aspectFit" />
|
||||
)}
|
||||
</View>
|
||||
<Text className="mine-profile-avatar-tip">点击选择头像</Text>
|
||||
@@ -579,7 +599,7 @@ export default function MinePage() {
|
||||
<View className="mine-qualification-body">
|
||||
<Image
|
||||
className="mine-qualification-img"
|
||||
src={QUALIFICATION_DISCLOSURE_URL}
|
||||
src={qualificationUrl}
|
||||
mode="widthFix"
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -143,7 +143,7 @@ export default function OrderConfirmPickupPage() {
|
||||
|
||||
const { confirm } = await Taro.showModal({
|
||||
title: '确认提交订单',
|
||||
content: `确认提交现场提货订单?共 ${quantity} 瓶,应付 ¥${Number(preview?.payAmount ?? 0).toFixed(2)}。`,
|
||||
content: `请确保您已拿到货品,货款将直接打给商家,如不是现场交易请选择立即购买方式下单,我们会为您安排配送到家。`,
|
||||
confirmText: '确认提交',
|
||||
cancelText: '再想想',
|
||||
});
|
||||
|
||||
@@ -22,9 +22,9 @@ import { fetchOrderTrack } from '../../lib/order-logistics';
|
||||
import { maskPhone } from '../../lib/phone';
|
||||
import { confirmOrderReceive, type WechatConfirmPayload } from '../../lib/wechat-order-confirm';
|
||||
import {
|
||||
DEFAULT_SHARE_DESC,
|
||||
DEFAULT_SHARE_TITLE,
|
||||
buildSceneSharePayload,
|
||||
toWeappShareMessage,
|
||||
toWeappShareTimeline,
|
||||
} from '../../lib/wechat-share';
|
||||
import { usePageView } from '../../lib/usePageView';
|
||||
|
||||
@@ -174,19 +174,20 @@ export default function OrderDetailPage() {
|
||||
: '';
|
||||
|
||||
const sharePayload = useMemo(
|
||||
() => ({
|
||||
title: productName !== '杜康商品' ? `我买了${productName} · 杜康好客` : DEFAULT_SHARE_TITLE,
|
||||
desc: DEFAULT_SHARE_DESC,
|
||||
() =>
|
||||
buildSceneSharePayload('orderDetail', {
|
||||
path: orderId ? `/pages/order-detail/index?id=${orderId}` : '/pages/home/index',
|
||||
titleVars: {
|
||||
productName: productName && productName !== '杜康商品' ? productName : '',
|
||||
},
|
||||
}),
|
||||
[productName, orderId],
|
||||
);
|
||||
|
||||
useShareAppMessage(() => toWeappShareMessage(sharePayload));
|
||||
useShareTimeline(() => ({
|
||||
title: sharePayload.title || DEFAULT_SHARE_TITLE,
|
||||
query: orderId ? `id=${orderId}` : '',
|
||||
}));
|
||||
useShareTimeline(() =>
|
||||
toWeappShareTimeline(sharePayload, orderId ? `id=${orderId}` : ''),
|
||||
);
|
||||
|
||||
function goPay() {
|
||||
if (!order) return;
|
||||
|
||||
@@ -27,9 +27,9 @@ import {
|
||||
normalizeFulfillmentFlags,
|
||||
} from '../../lib/product-fulfillment';
|
||||
import {
|
||||
DEFAULT_SHARE_DESC,
|
||||
DEFAULT_SHARE_TITLE,
|
||||
buildSceneSharePayload,
|
||||
toWeappShareMessage,
|
||||
toWeappShareTimeline,
|
||||
} from '../../lib/wechat-share';
|
||||
import iconHome from '../../assets/tabbar/home.png';
|
||||
import { usePageView } from '../../lib/usePageView';
|
||||
@@ -88,21 +88,20 @@ export default function ProductDetailPage() {
|
||||
});
|
||||
|
||||
const sharePayload = useMemo(
|
||||
() => ({
|
||||
title: product?.name || DEFAULT_SHARE_TITLE,
|
||||
desc: product?.subtitle || DEFAULT_SHARE_DESC,
|
||||
() =>
|
||||
buildSceneSharePayload('productDetail', {
|
||||
path: `/pages/product-detail/index?id=${productId}`,
|
||||
imgUrl: (product ? getProductMainImage(product) : '') || undefined,
|
||||
dynamicTitle: product?.name,
|
||||
dynamicDesc: product?.subtitle,
|
||||
dynamicImageUrl: product ? getProductMainImage(product) : undefined,
|
||||
}),
|
||||
[product, productId],
|
||||
);
|
||||
|
||||
useShareAppMessage(() => toWeappShareMessage(sharePayload));
|
||||
useShareTimeline(() => ({
|
||||
title: sharePayload.title || DEFAULT_SHARE_TITLE,
|
||||
query: productId ? `id=${productId}` : '',
|
||||
imageUrl: sharePayload.imgUrl,
|
||||
}));
|
||||
useShareTimeline(() =>
|
||||
toWeappShareTimeline(sharePayload, productId ? `id=${productId}` : ''),
|
||||
);
|
||||
|
||||
function goBack() {
|
||||
const pages = Taro.getCurrentPages();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { View, Text, Image, ScrollView } from '@tarojs/components';
|
||||
import { View, Text, Image } from '@tarojs/components';
|
||||
import Taro, {
|
||||
useDidShow,
|
||||
useLoad,
|
||||
@@ -19,9 +19,9 @@ import { maskPhone } from '../../lib/phone';
|
||||
import { track } from '../../lib/analytics';
|
||||
import { formatShanghaiDateTime } from '../../lib/datetime';
|
||||
import {
|
||||
DEFAULT_SHARE_DESC,
|
||||
DEFAULT_SHARE_TITLE,
|
||||
buildSceneSharePayload,
|
||||
toWeappShareMessage,
|
||||
toWeappShareTimeline,
|
||||
} from '../../lib/wechat-share';
|
||||
|
||||
type StoreMedia = {
|
||||
@@ -156,14 +156,9 @@ export default function StoreDetailPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [headerSolid, setHeaderSolid] = useState(false);
|
||||
const [activePackageIndex, setActivePackageIndex] = useState(0);
|
||||
const storeRef = useRef<Store | null>(null);
|
||||
storeRef.current = store;
|
||||
|
||||
useEffect(() => {
|
||||
setActivePackageIndex(0);
|
||||
}, [store?.id]);
|
||||
|
||||
usePageScroll(({ scrollTop }) => {
|
||||
setHeaderSolid(scrollTop > 100);
|
||||
});
|
||||
@@ -242,18 +237,15 @@ export default function StoreDetailPage() {
|
||||
void loadRecentRedeems(id);
|
||||
});
|
||||
|
||||
const sharePayload = useMemo(
|
||||
() => {
|
||||
const sharePayload = useMemo(() => {
|
||||
const envFirst = store ? envPhotoUrls(store)[0] : undefined;
|
||||
return {
|
||||
title: store?.name || DEFAULT_SHARE_TITLE,
|
||||
desc: store?.intro?.trim() || store?.address || DEFAULT_SHARE_DESC,
|
||||
return buildSceneSharePayload('storeDetail', {
|
||||
path: `/pages/store-detail/index?id=${storeId}`,
|
||||
imgUrl: store?.coverUrl || envFirst || store?.carouselUrls?.[0] || undefined,
|
||||
};
|
||||
},
|
||||
[store, storeId],
|
||||
);
|
||||
dynamicTitle: store?.name,
|
||||
dynamicDesc: store?.intro?.trim() || store?.address,
|
||||
dynamicImageUrl: store?.coverUrl || envFirst || store?.carouselUrls?.[0],
|
||||
});
|
||||
}, [store, storeId]);
|
||||
|
||||
const marqueeLines = useMemo(
|
||||
() => recentRedeems.map(formatRecentRedeemLine).filter(Boolean),
|
||||
@@ -261,11 +253,9 @@ export default function StoreDetailPage() {
|
||||
);
|
||||
|
||||
useShareAppMessage(() => toWeappShareMessage(sharePayload));
|
||||
useShareTimeline(() => ({
|
||||
title: sharePayload.title || DEFAULT_SHARE_TITLE,
|
||||
query: storeId ? `id=${storeId}` : '',
|
||||
imageUrl: sharePayload.imgUrl,
|
||||
}));
|
||||
useShareTimeline(() =>
|
||||
toWeappShareTimeline(sharePayload, storeId ? `id=${storeId}` : ''),
|
||||
);
|
||||
|
||||
function goBack() {
|
||||
const pages = Taro.getCurrentPages();
|
||||
@@ -326,13 +316,18 @@ export default function StoreDetailPage() {
|
||||
const envPhotos = envPhotoUrls(store);
|
||||
const heroImages = uniqueUrls([store.coverUrl, ...(store.carouselUrls || [])]);
|
||||
const packages = store.packages ?? [];
|
||||
const activePackage = packages[activePackageIndex] ?? packages[0];
|
||||
|
||||
const intro = store.intro?.trim() || '';
|
||||
const benefitRuleRaw = store.benefitUsageRule?.trim() || '';
|
||||
const benefitRule =
|
||||
benefitRuleRaw && !/^null$/i.test(benefitRuleRaw) ? benefitRuleRaw : '';
|
||||
|
||||
function openPackageDetail(index: number) {
|
||||
Taro.navigateTo({
|
||||
url: `/pages/store-package-detail/index?storeId=${storeId}&index=${index}`,
|
||||
});
|
||||
}
|
||||
|
||||
function previewEnv(index: number) {
|
||||
if (!envPhotos.length) return;
|
||||
Taro.previewImage({
|
||||
@@ -353,7 +348,13 @@ export default function StoreDetailPage() {
|
||||
/>
|
||||
|
||||
<View className="store-detail-hero full-bleed">
|
||||
<ProductCarousel images={heroImages} alt={store.name} variant="store" previewable />
|
||||
<ProductCarousel
|
||||
images={heroImages}
|
||||
alt={store.name}
|
||||
variant="store"
|
||||
previewable
|
||||
imageFit="contain"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="store-detail-info-card">
|
||||
@@ -408,49 +409,20 @@ export default function StoreDetailPage() {
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{packages.length > 0 && activePackage ? (
|
||||
{packages.length > 0 ? (
|
||||
<View className="store-detail-section store-detail-section--packages">
|
||||
<Text className="store-detail-section-title">门店套餐</Text>
|
||||
{packages.length > 1 ? (
|
||||
<ScrollView className="store-detail-package-tabs" scrollX showScrollbar={false} enhanced>
|
||||
<View className="store-detail-package-tabs-inner">
|
||||
<View className="store-detail-package-list">
|
||||
{packages.map((pkg, index) => (
|
||||
<View
|
||||
key={`${pkg.name}-${index}`}
|
||||
className={`store-detail-package-tab${
|
||||
index === activePackageIndex ? ' store-detail-package-tab--active' : ''
|
||||
}`}
|
||||
onClick={() => setActivePackageIndex(index)}
|
||||
className="store-detail-package-list-item"
|
||||
onClick={() => openPackageDetail(index)}
|
||||
>
|
||||
<Text className="store-detail-package-tab-text">{pkg.name}</Text>
|
||||
<Text className="store-detail-package-list-title">{pkg.name}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
) : null}
|
||||
<View className="store-detail-package-panel">
|
||||
{activePackage.imageUrl ? (
|
||||
<Image
|
||||
className="store-detail-package-thumb"
|
||||
src={activePackage.imageUrl}
|
||||
mode="aspectFill"
|
||||
/>
|
||||
) : null}
|
||||
<View className="store-detail-package-panel-body">
|
||||
{packages.length === 1 ? (
|
||||
<Text className="store-detail-package-name">{activePackage.name}</Text>
|
||||
) : null}
|
||||
<Text className="store-detail-package-body">
|
||||
{formatRedeemAmountYuan(activePackage.price)} 元 · {activePackage.dishes}
|
||||
</Text>
|
||||
{activePackage.usableTime ? (
|
||||
<Text className="store-detail-package-meta">使用时间:{activePackage.usableTime}</Text>
|
||||
) : null}
|
||||
{activePackage.otherNotes ? (
|
||||
<Text className="store-detail-package-meta">说明:{activePackage.otherNotes}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '套餐详情',
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { View, Text, Image } from '@tarojs/components';
|
||||
import Taro, { useLoad, useRouter } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import PageNavBar from '../../components/PageNavBar';
|
||||
import { request, toast } from '../../lib/api';
|
||||
|
||||
type StorePackage = {
|
||||
name: string;
|
||||
price: string | number;
|
||||
dishes: string;
|
||||
usableTime?: string | null;
|
||||
otherNotes?: string | null;
|
||||
imageUrl?: string | null;
|
||||
};
|
||||
|
||||
type Store = {
|
||||
id: string;
|
||||
name: string;
|
||||
packages?: StorePackage[] | null;
|
||||
};
|
||||
|
||||
function pickStoreId(raw?: string | null) {
|
||||
return String(raw || '')
|
||||
.trim()
|
||||
.replace(/[^\d]/g, '');
|
||||
}
|
||||
|
||||
function parsePackageIndex(raw?: string | null) {
|
||||
const n = Number.parseInt(String(raw ?? ''), 10);
|
||||
return Number.isFinite(n) && n >= 0 ? n : -1;
|
||||
}
|
||||
|
||||
function formatPriceYuan(price: string | number) {
|
||||
const n = typeof price === 'number' ? price : Number(price);
|
||||
if (!Number.isFinite(n)) return '0';
|
||||
if (Math.abs(n - Math.round(n)) < 1e-9) return String(Math.round(n));
|
||||
return n.toFixed(2).replace(/\.?0+$/, '');
|
||||
}
|
||||
|
||||
export default function StorePackageDetailPage() {
|
||||
const router = useRouter();
|
||||
const [storeId, setStoreId] = useState(() => pickStoreId(router.params.storeId));
|
||||
const [storeName, setStoreName] = useState('');
|
||||
const [pkg, setPkg] = useState<StorePackage | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
|
||||
const loadPackage = useCallback(async (sid: string, index: number) => {
|
||||
if (!sid) {
|
||||
setLoading(false);
|
||||
setLoadError('缺少门店参数');
|
||||
return;
|
||||
}
|
||||
if (index < 0) {
|
||||
setLoading(false);
|
||||
setLoadError('套餐不存在');
|
||||
return;
|
||||
}
|
||||
setLoadError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await request<Store>(`/stores/${sid}`);
|
||||
const packages = data?.packages ?? [];
|
||||
const item = packages[index];
|
||||
if (!data?.id || !item) {
|
||||
setPkg(null);
|
||||
setLoadError('套餐不存在或已下架');
|
||||
toast('套餐不存在或已下架');
|
||||
return;
|
||||
}
|
||||
setStoreName(data.name);
|
||||
setPkg(item);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '加载失败';
|
||||
setLoadError(msg);
|
||||
toast(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useLoad((options) => {
|
||||
const sid = pickStoreId(options?.storeId || router.params.storeId);
|
||||
const index = parsePackageIndex(options?.index ?? router.params.index);
|
||||
setStoreId(sid);
|
||||
void loadPackage(sid, index);
|
||||
});
|
||||
|
||||
function goBack() {
|
||||
const pages = Taro.getCurrentPages();
|
||||
if (pages.length > 1) Taro.navigateBack();
|
||||
else if (storeId) {
|
||||
Taro.redirectTo({ url: `/pages/store-detail/index?id=${storeId}` });
|
||||
} else {
|
||||
Taro.switchTab({ url: '/pages/stores/index' });
|
||||
}
|
||||
}
|
||||
|
||||
function previewImage(url: string) {
|
||||
Taro.previewImage({ urls: [url], current: url }).catch(() => toast('无法预览图片'));
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageShell variant="scroll" className="store-package-detail-page">
|
||||
<PageNavBar title="套餐详情" solid onBack={goBack} />
|
||||
<View className="page-with-nav-bar u-empty">加载中…</View>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (!pkg) {
|
||||
return (
|
||||
<PageShell variant="scroll" className="store-package-detail-page">
|
||||
<PageNavBar title="套餐详情" solid onBack={goBack} />
|
||||
<View className="page-with-nav-bar u-empty">{loadError || '套餐不存在'}</View>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const imageUrl = (pkg.imageUrl || '').trim();
|
||||
|
||||
return (
|
||||
<PageShell variant="scroll" className="store-package-detail-page">
|
||||
<PageNavBar title={pkg.name} solid titleVisible onBack={goBack} />
|
||||
|
||||
<View className="store-package-detail-body">
|
||||
<View className="store-package-detail-inner">
|
||||
<View className="store-package-detail-header">
|
||||
<View className="store-package-detail-title-row">
|
||||
<Text className="store-package-detail-title">{pkg.name}</Text>
|
||||
<Text className="store-package-detail-price">¥{formatPriceYuan(pkg.price)}</Text>
|
||||
</View>
|
||||
{storeName ? (
|
||||
<Text className="store-package-detail-store">{storeName}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{imageUrl ? (
|
||||
<View
|
||||
className="store-package-detail-photo"
|
||||
onClick={() => previewImage(imageUrl)}
|
||||
>
|
||||
<Image
|
||||
className="store-package-detail-photo-image"
|
||||
src={imageUrl}
|
||||
mode="widthFix"
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View className="store-package-detail-content">
|
||||
<View className="store-detail-package-field">
|
||||
<Text className="store-detail-package-field-label">菜品</Text>
|
||||
<Text className="store-detail-package-field-value">{pkg.dishes || '—'}</Text>
|
||||
</View>
|
||||
{pkg.usableTime ? (
|
||||
<View className="store-detail-package-field">
|
||||
<Text className="store-detail-package-field-label">使用时间</Text>
|
||||
<Text className="store-detail-package-field-value">{pkg.usableTime}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{pkg.otherNotes ? (
|
||||
<View className="store-detail-package-field">
|
||||
<Text className="store-detail-package-field-label">其他说明</Text>
|
||||
<Text className="store-detail-package-field-value">{pkg.otherNotes}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
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 { StoreStatus, STORE_STATUS_LABELS } from '@dukang/shared-types';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import TabMainHeader from '../../components/TabMainHeader';
|
||||
import WechatShareReady from '../../components/WechatShareReady';
|
||||
@@ -37,10 +36,11 @@ import {
|
||||
setStoresListCache,
|
||||
} from '../../lib/stores-session';
|
||||
import {
|
||||
DEFAULT_SHARE_DESC,
|
||||
DEFAULT_SHARE_TITLE,
|
||||
buildSceneSharePayload,
|
||||
toWeappShareMessage,
|
||||
toWeappShareTimeline,
|
||||
} from '../../lib/wechat-share';
|
||||
import openBadgeImg from '../../assets/icons/store-open-badge.png';
|
||||
|
||||
type Store = {
|
||||
id: string;
|
||||
@@ -342,33 +342,24 @@ export default function StoresPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function formatHours(store: Store) {
|
||||
function hoursText(store: Store): string {
|
||||
const parts: string[] = [];
|
||||
if (store.openTime && store.closeTime) parts.push(`${store.openTime}-${store.closeTime}`);
|
||||
if (store.openTime2 && store.closeTime2) parts.push(`${store.openTime2}-${store.closeTime2}`);
|
||||
return parts.length ? `营业时间: ${parts.join(',')}` : '营业时间: 10:00-22:00';
|
||||
}
|
||||
|
||||
function formatStatus(store: Store) {
|
||||
const status = store.status as StoreStatus | undefined;
|
||||
if (status && STORE_STATUS_LABELS[status]) return STORE_STATUS_LABELS[status];
|
||||
return STORE_STATUS_LABELS[StoreStatus.OPEN];
|
||||
if (!parts.length) parts.push('10:00-22:00');
|
||||
return `营业时间: ${parts.join(' ')}`;
|
||||
}
|
||||
|
||||
const sharePayload = useMemo(
|
||||
() => ({
|
||||
title: '杜康好客门店',
|
||||
desc: DEFAULT_SHARE_DESC,
|
||||
() =>
|
||||
buildSceneSharePayload('stores', {
|
||||
path: '/pages/stores/index',
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
useShareAppMessage(() => toWeappShareMessage(sharePayload));
|
||||
useShareTimeline(() => ({
|
||||
title: sharePayload.title || DEFAULT_SHARE_TITLE,
|
||||
query: '',
|
||||
}));
|
||||
useShareTimeline(() => toWeappShareTimeline(sharePayload));
|
||||
|
||||
return (
|
||||
<PageShell variant="tab" className="store-page no-tab-header">
|
||||
@@ -431,42 +422,38 @@ export default function StoresPage() {
|
||||
className="store-card"
|
||||
onClick={() => Taro.navigateTo({ url: `/pages/store-detail/index?id=${s.id}` })}
|
||||
>
|
||||
<View className="store-card-cover-wrap">
|
||||
{s.coverUrl ? (
|
||||
<Image className="store-card-cover" src={s.coverUrl} mode="aspectFill" />
|
||||
) : (
|
||||
<View className="store-card-cover store-card-cover--empty" />
|
||||
)}
|
||||
<Image
|
||||
className="store-card-open-badge"
|
||||
src={openBadgeImg}
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</View>
|
||||
<View className="store-card-body">
|
||||
{/* 第1行:标题 + 距离 */}
|
||||
{/* 第1行:标题(截断无省略号,顶到最右) */}
|
||||
<View className="store-card-row store-card-row--head">
|
||||
<Text className="store-card-name" numberOfLines={1}>
|
||||
{s.name}
|
||||
<Text className="store-card-name">{s.name}</Text>
|
||||
</View>
|
||||
{/* 第2行:地址(最多两行)+ 距离 */}
|
||||
<View className="store-card-row store-card-row--mid">
|
||||
<Text className="store-card-address" numberOfLines={2}>
|
||||
{s.address || (s.district ? `${s.district}` : '地址待完善')}
|
||||
</Text>
|
||||
<Text className="store-card-distance">
|
||||
{formatDistanceMeters(s.distanceMeters)}
|
||||
</Text>
|
||||
</View>
|
||||
{/* 第2行:状态 + 营业时间(含第二段) */}
|
||||
<View className="store-card-row store-card-row--meta">
|
||||
<Text className="store-card-status">{formatStatus(s)}</Text>
|
||||
<Text className="store-card-hours">{formatHours(s)}</Text>
|
||||
</View>
|
||||
{/* 第3行:地址 + 去核销 */}
|
||||
<View className="store-card-row store-card-row--foot">
|
||||
<Text className="store-card-address" numberOfLines={1}>
|
||||
{s.address || (s.district ? `${s.district}` : '地址待完善')}
|
||||
</Text>
|
||||
<View
|
||||
className="store-card-cta"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
Taro.navigateTo({ url: '/pages/redeem/index' });
|
||||
}}
|
||||
>
|
||||
<Text className="store-card-cta-text">去核销</Text>
|
||||
</View>
|
||||
{/* 第3行:营业时间(同行) */}
|
||||
<View className="store-card-row store-card-row--hours">
|
||||
<Text className="store-card-hours">{hoursText(s)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text className="store-card-arrow">›</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
@@ -386,13 +386,20 @@
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
background: rgba(20, 16, 14, 0.45);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mine-profile-sheet-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(20, 16, 14, 0.45);
|
||||
}
|
||||
|
||||
.mine-profile-sheet {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
box-sizing: border-box;
|
||||
@@ -422,8 +429,9 @@
|
||||
margin: 20px auto 0;
|
||||
padding: 0;
|
||||
width: auto;
|
||||
background: transparent;
|
||||
border: none;
|
||||
height: auto;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
line-height: 1.2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -434,6 +442,11 @@
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* 子节点不抢触摸,保证 open-type=chooseAvatar 由 Button 本人响应 */
|
||||
.mine-profile-avatar-btn > * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mine-profile-avatar-preview {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
|
||||
@@ -12,7 +12,22 @@
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
overflow: hidden;
|
||||
background: var(--color-surface-container);
|
||||
/* background: var(--color-surface-container); */
|
||||
background-color: #000;
|
||||
}
|
||||
|
||||
/* 门店门头:固定 4:3 区域,图片 aspectFit 缩放完整显示(不裁剪) */
|
||||
.store-detail-carousel-wrap--contain .store-detail-carousel-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.store-detail-carousel-wrap--contain .store-detail-carousel-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
object-position: center center;
|
||||
}
|
||||
|
||||
.store-detail-carousel {
|
||||
@@ -29,6 +44,7 @@
|
||||
|
||||
.store-detail-carousel-image {
|
||||
object-fit: cover;
|
||||
object-position: center center;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -59,7 +75,7 @@
|
||||
}
|
||||
|
||||
.store-detail-info-card {
|
||||
margin: -40px var(--space-page) 16px;
|
||||
margin: 0 var(--space-page) 16px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
background: var(--color-card);
|
||||
@@ -246,100 +262,142 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.store-detail-package-tabs {
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.store-detail-package-tabs-inner {
|
||||
display: inline-flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.store-detail-package-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
max-width: 132px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.store-detail-package-tab--active {
|
||||
background: rgba(166, 29, 36, 0.1);
|
||||
}
|
||||
|
||||
.store-detail-package-tab-text {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--color-text-secondary, #666);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.store-detail-package-tab--active .store-detail-package-tab-text {
|
||||
color: var(--color-heritage-red, #a61d24);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.store-detail-package-panel {
|
||||
.store-detail-package-list {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
border-radius: var(--radius-md, 8px);
|
||||
background: var(--color-surface-container, #f7f7f7);
|
||||
box-sizing: border-box;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.store-detail-package-thumb {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
.store-detail-package-list-item {
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.store-detail-package-panel-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
.store-detail-package-list-item:first-child {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.store-detail-package-body,
|
||||
.store-detail-package-meta {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
.store-detail-package-list-item:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.store-detail-package-name {
|
||||
.store-detail-package-list-title {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
font-weight: 500;
|
||||
color: var(--color-on-surface);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.store-detail-package-list-item:active {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
/* ── 套餐详情页(独立于门店详情) ── */
|
||||
.store-package-detail-page {
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
.store-package-detail-body {
|
||||
/* 避开状态栏+胶囊导航,并额外 12px 顶白 */
|
||||
padding: calc(var(--nav-bar-height, 56px) + 12px) 0 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.store-package-detail-inner {
|
||||
/* 左右留白,内容不贴边 */
|
||||
padding: 8px 16px 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.store-package-detail-header {
|
||||
padding: 8px 16px 4px 16px;
|
||||
}
|
||||
|
||||
.store-package-detail-title-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.store-package-detail-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: var(--font-headline);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary, #1a1a1a);
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.4;
|
||||
color: var(--color-ink-black);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.store-detail-package-body {
|
||||
.store-package-detail-price {
|
||||
flex-shrink: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
color: #a61d24;
|
||||
color: var(--color-heritage-red, #a61d24);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.store-package-detail-store {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary, #666);
|
||||
line-height: 1.45;
|
||||
line-height: 1.5;
|
||||
color: var(--color-on-surface-variant);
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.store-detail-package-meta {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-tertiary, #999);
|
||||
margin-top: 4px;
|
||||
.store-package-detail-photo {
|
||||
margin: 14px 0 0;
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
background: var(--color-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.store-package-detail-photo-image {
|
||||
width: 100%;
|
||||
display: block;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.store-package-detail-content {
|
||||
margin-top: 16px;
|
||||
background: var(--color-card);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 16px;
|
||||
box-shadow: var(--shadow-card);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.store-detail-package-field-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-on-surface-variant);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.store-detail-package-field-value {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: var(--color-on-surface);
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.store-detail-package-field {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.store-detail-package-field:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.store-detail-package-dispute {
|
||||
|
||||
@@ -144,12 +144,12 @@
|
||||
padding: 4px var(--space-page) 16px;
|
||||
}
|
||||
|
||||
/* 左图右文,卡片等高 */
|
||||
/* 左图 + 中间文案 + 右侧箭头 */
|
||||
.store-card {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
box-sizing: border-box;
|
||||
background: var(--color-card);
|
||||
@@ -158,11 +158,19 @@
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.store-card-cover {
|
||||
.store-card-cover-wrap {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.store-card-cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface-container);
|
||||
display: block;
|
||||
}
|
||||
@@ -171,31 +179,42 @@
|
||||
background: var(--color-surface-container);
|
||||
}
|
||||
|
||||
.store-card-open-badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.store-card-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 96px;
|
||||
min-height: 96px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.store-card-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 第1行:加粗标题(单行截断)+ 右对齐距离 */
|
||||
/* 第1行:加粗标题(单行截断,不显示 …,宽度顶到最右) */
|
||||
.store-card-row--head {
|
||||
gap: 8px;
|
||||
height: 22px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.store-card-name {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
font-family: var(--font-headline);
|
||||
font-size: 15px;
|
||||
@@ -203,84 +222,70 @@
|
||||
line-height: 22px;
|
||||
color: #1a1a1a;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-overflow: clip;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.store-card-distance {
|
||||
flex-shrink: 0;
|
||||
max-width: 40%;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 22px;
|
||||
color: #999;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 第2行:营业状态 + 营业时间(可含两段) */
|
||||
.store-card-row--meta {
|
||||
gap: 6px;
|
||||
min-height: 20px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.store-card-status {
|
||||
flex-shrink: 0;
|
||||
padding: 0 6px;
|
||||
margin-top: 1px;
|
||||
border-radius: 4px;
|
||||
background: rgba(45, 106, 79, 0.12);
|
||||
color: #2d6a4f;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.store-card-hours {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
line-height: 16px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* 第3行:地址(单行截断)+ 右对齐去核销 */
|
||||
.store-card-row--foot {
|
||||
/* 第2行:地址最多两行 + 右对齐距离 */
|
||||
.store-card-row--mid {
|
||||
gap: 8px;
|
||||
height: 28px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.store-card-address {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 9px;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
line-height: 28px;
|
||||
line-height: 16px;
|
||||
max-height: 32px;
|
||||
color: #999;
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
}
|
||||
|
||||
.store-card-distance {
|
||||
flex-shrink: 0;
|
||||
max-width: 40%;
|
||||
padding-top: 1px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 16px;
|
||||
color: #999;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 第3行:营业时间同行 */
|
||||
.store-card-row--hours {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.store-card-hours {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
line-height: 16px;
|
||||
color: #999;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-overflow: clip;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.store-card-cta {
|
||||
.store-card-arrow {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 14px;
|
||||
height: 26px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-heritage-red, #a61d24);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.store-card-cta-text {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 26px;
|
||||
color: #fff;
|
||||
white-space: nowrap;
|
||||
align-self: center;
|
||||
width: 16px;
|
||||
font-size: 20px;
|
||||
font-weight: 300;
|
||||
line-height: 1;
|
||||
color: #ccc;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -32,29 +32,29 @@ export interface AppConfig {
|
||||
userH5Url: string;
|
||||
}
|
||||
|
||||
/** 推广码 / C 端 H5 默认落地页(未配置 USER_H5_URL 时使用) */
|
||||
/** 推广码 / C 端 H5 默认落地页(系统设置 USER_H5_URL 未配时回退;HQ 可改) */
|
||||
export const DEFAULT_USER_H5_URL = 'https://user.runxian.top/user';
|
||||
|
||||
/** 品牌 Logo OSS 根路径(改环境时只改此处) */
|
||||
/** 品牌 Logo OSS 根路径(默认;系统设置 BRAND_LOGO_OSS_BASE 可覆盖) */
|
||||
export const BRAND_LOGO_OSS_BASE = 'https://dukang-dev.oss-cn-beijing.aliyuncs.com/logo/';
|
||||
|
||||
/** 方形 Logo(首页等品牌展示;商品列表顶栏仍用文字标题) */
|
||||
/** 方形 Logo(默认;系统设置 BRAND_LOGO_URL 可覆盖) */
|
||||
export const BRAND_LOGO_URL = `${BRAND_LOGO_OSS_BASE}logo.png`;
|
||||
|
||||
/** 长方形 Logo(含文字,登录等场景) */
|
||||
/** 长方形 Logo(默认;系统设置 BRAND_LOGO_WIDE_URL 可覆盖) */
|
||||
export const BRAND_LOGO_WIDE_URL = `${BRAND_LOGO_OSS_BASE}logo1.png`;
|
||||
|
||||
/** 仅图标 Logo(默认头像:未微信授权时) */
|
||||
/** 仅图标 Logo(默认;系统设置 BRAND_LOGO_MARK_URL 可覆盖) */
|
||||
export const BRAND_LOGO_MARK_URL = `${BRAND_LOGO_OSS_BASE}logo2.png`;
|
||||
|
||||
/** 小程序静态资源(资质公示等) */
|
||||
/** 小程序静态资源根路径(默认;系统设置 MINI_USER_STATIC_OSS_BASE 可覆盖) */
|
||||
export const MINI_USER_STATIC_OSS_BASE =
|
||||
'https://dukang-dev.oss-cn-beijing.aliyuncs.com/static/mini-user/';
|
||||
|
||||
/** 「我的」页资质公示长图 */
|
||||
/** 「我的」页资质公示长图(默认;系统设置 QUALIFICATION_DISCLOSURE_URL 可覆盖) */
|
||||
export const QUALIFICATION_DISCLOSURE_URL = `${MINI_USER_STATIC_OSS_BASE}qualification-disclosure.png`;
|
||||
|
||||
/** 总部客服电话(C 端联系客服) */
|
||||
/** 总部客服电话(默认;系统设置 CUSTOMER_SERVICE_PHONE 可覆盖) */
|
||||
export const CUSTOMER_SERVICE_PHONE = '13203801799';
|
||||
|
||||
/**
|
||||
@@ -64,6 +64,137 @@ export const CUSTOMER_SERVICE_PHONE = '13203801799';
|
||||
export const CUSTOMER_SERVICE_WECOM_URL =
|
||||
'https://work.weixin.qq.com/kfid/kfc8b88659a1dffa8cd';
|
||||
|
||||
/** 从 env / 系统设置解析的 C 端品牌与客服展示配置(缺省回退常量) */
|
||||
export type ClientBrandRuntime = {
|
||||
userH5Url: string;
|
||||
brandLogoOssBase: string;
|
||||
brandLogoUrl: string;
|
||||
brandLogoWideUrl: string;
|
||||
brandLogoMarkUrl: string;
|
||||
miniUserStaticOssBase: string;
|
||||
qualificationDisclosureUrl: string;
|
||||
customerServicePhone: string;
|
||||
};
|
||||
|
||||
export function resolveClientBrandRuntime(
|
||||
env?: Record<string, string | undefined>,
|
||||
): ClientBrandRuntime {
|
||||
const e = readEnv(env);
|
||||
const brandBase = (e.BRAND_LOGO_OSS_BASE || BRAND_LOGO_OSS_BASE).trim().replace(/\/*$/, '/');
|
||||
const staticBase = (e.MINI_USER_STATIC_OSS_BASE || MINI_USER_STATIC_OSS_BASE)
|
||||
.trim()
|
||||
.replace(/\/*$/, '/');
|
||||
return {
|
||||
userH5Url: (e.USER_H5_URL || DEFAULT_USER_H5_URL).replace(/\/$/, ''),
|
||||
brandLogoOssBase: brandBase,
|
||||
brandLogoUrl: (e.BRAND_LOGO_URL || '').trim() || `${brandBase}logo.png`,
|
||||
brandLogoWideUrl: (e.BRAND_LOGO_WIDE_URL || '').trim() || `${brandBase}logo1.png`,
|
||||
brandLogoMarkUrl: (e.BRAND_LOGO_MARK_URL || '').trim() || `${brandBase}logo2.png`,
|
||||
miniUserStaticOssBase: staticBase,
|
||||
qualificationDisclosureUrl:
|
||||
(e.QUALIFICATION_DISCLOSURE_URL || '').trim() ||
|
||||
`${staticBase}qualification-disclosure.png`,
|
||||
customerServicePhone: (e.CUSTOMER_SERVICE_PHONE || CUSTOMER_SERVICE_PHONE).trim(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Mock 短信固定验证码(系统设置 MOCK_SMS_FIXED_CODE 可覆盖) */
|
||||
export function resolveMockSmsFixedCode(env?: Record<string, string | undefined>): string {
|
||||
const code = (readEnv(env).MOCK_SMS_FIXED_CODE || '').trim();
|
||||
return code || MOCK_SMS_FIXED_CODE;
|
||||
}
|
||||
|
||||
/** 小程序 / H5 默认分享文案与引导(系统设置「小程序分享配置」可覆盖) */
|
||||
export const DEFAULT_SHARE_TITLE = '你吃饭,我买单';
|
||||
export const DEFAULT_SHARE_DESC = '杜康好客 · 买美酒,享好礼,全城门店可用';
|
||||
export const DEFAULT_SHARE_HINT = '请点击右上角 ··· 分享给好友';
|
||||
export const DEFAULT_SHARE_STORES_TITLE = '杜康好客门店';
|
||||
export const DEFAULT_SHARE_BENEFIT_TITLE = '好客权益 · 杜康好客';
|
||||
export const DEFAULT_SHARE_MINE_TITLE = '杜康好客 · 我的';
|
||||
/** 订单详情分享标题模板,可用 {productName} */
|
||||
export const DEFAULT_SHARE_ORDER_TITLE = '我买了{productName} · 杜康好客';
|
||||
|
||||
export type MiniShareSceneConfig = {
|
||||
title: string;
|
||||
desc: string;
|
||||
imageUrl: string;
|
||||
};
|
||||
|
||||
/** 各场景分享文案/图(空字符串表示该字段走动态内容或默认分享) */
|
||||
export type MiniShareRuntime = {
|
||||
hint: string;
|
||||
default: MiniShareSceneConfig;
|
||||
home: MiniShareSceneConfig;
|
||||
stores: MiniShareSceneConfig;
|
||||
storeDetail: MiniShareSceneConfig;
|
||||
benefit: MiniShareSceneConfig;
|
||||
mine: MiniShareSceneConfig;
|
||||
productDetail: MiniShareSceneConfig;
|
||||
orderDetail: MiniShareSceneConfig;
|
||||
};
|
||||
|
||||
function pickShareScene(
|
||||
e: Record<string, string | undefined>,
|
||||
prefix: string,
|
||||
fallback: Partial<MiniShareSceneConfig> = {},
|
||||
): MiniShareSceneConfig {
|
||||
return {
|
||||
title: (e[`${prefix}_TITLE`] || '').trim() || fallback.title || '',
|
||||
desc: (e[`${prefix}_DESC`] || '').trim() || fallback.desc || '',
|
||||
imageUrl: (e[`${prefix}_IMAGE_URL`] || '').trim() || fallback.imageUrl || '',
|
||||
};
|
||||
}
|
||||
|
||||
/** 从 env / 系统设置解析小程序分享配置 */
|
||||
export function resolveMiniShareRuntime(
|
||||
env?: Record<string, string | undefined>,
|
||||
): MiniShareRuntime {
|
||||
const e = readEnv(env);
|
||||
const brand = resolveClientBrandRuntime(e);
|
||||
const defaults: MiniShareSceneConfig = {
|
||||
title: (e.SHARE_DEFAULT_TITLE || '').trim() || DEFAULT_SHARE_TITLE,
|
||||
desc: (e.SHARE_DEFAULT_DESC || '').trim() || DEFAULT_SHARE_DESC,
|
||||
imageUrl: (e.SHARE_DEFAULT_IMAGE_URL || '').trim() || brand.brandLogoUrl,
|
||||
};
|
||||
return {
|
||||
hint: (e.SHARE_HINT || '').trim() || DEFAULT_SHARE_HINT,
|
||||
default: defaults,
|
||||
home: pickShareScene(e, 'SHARE_HOME', {
|
||||
title: defaults.title,
|
||||
desc: defaults.desc,
|
||||
}),
|
||||
stores: pickShareScene(e, 'SHARE_STORES', {
|
||||
title: DEFAULT_SHARE_STORES_TITLE,
|
||||
desc: defaults.desc,
|
||||
}),
|
||||
storeDetail: pickShareScene(e, 'SHARE_STORE_DETAIL'),
|
||||
benefit: pickShareScene(e, 'SHARE_BENEFIT', {
|
||||
title: DEFAULT_SHARE_BENEFIT_TITLE,
|
||||
desc: defaults.desc,
|
||||
}),
|
||||
mine: pickShareScene(e, 'SHARE_MINE', {
|
||||
title: DEFAULT_SHARE_MINE_TITLE,
|
||||
desc: defaults.desc,
|
||||
}),
|
||||
productDetail: pickShareScene(e, 'SHARE_PRODUCT_DETAIL'),
|
||||
orderDetail: pickShareScene(e, 'SHARE_ORDER_DETAIL', {
|
||||
title: DEFAULT_SHARE_ORDER_TITLE,
|
||||
desc: defaults.desc,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** 订单等标题模板:替换 {productName} 等占位符 */
|
||||
export function applyShareTitleTemplate(
|
||||
template: string,
|
||||
vars: Record<string, string | undefined | null>,
|
||||
): string {
|
||||
return template.replace(/\{(\w+)\}/g, (_, key: string) => {
|
||||
const v = vars[key];
|
||||
return v != null && String(v).trim() ? String(v).trim() : '';
|
||||
}).replace(/\s{2,}/g, ' ').trim();
|
||||
}
|
||||
|
||||
function readEnv(env?: Record<string, string | undefined>) {
|
||||
return (
|
||||
env ??
|
||||
@@ -142,5 +273,5 @@ export function loadAppConfig(env?: Record<string, string | undefined>): AppConf
|
||||
};
|
||||
}
|
||||
|
||||
/** Mock 短信环境固定验证码 */
|
||||
/** Mock 短信环境固定验证码(默认;系统设置 MOCK_SMS_FIXED_CODE 可覆盖) */
|
||||
export const MOCK_SMS_FIXED_CODE = '999888';
|
||||
|
||||
@@ -18,6 +18,7 @@ export const HQ_PERMISSION_CATALOG = [
|
||||
{ key: 'llm_configs', label: '语言模型配置', group: '业务' },
|
||||
{ key: 'knowledge_bases', label: '知识库', group: '业务' },
|
||||
{ key: 'dev_plan', label: '开发计划', group: '业务' },
|
||||
{ key: 'test_whitelist', label: '白名单管理', group: '业务' },
|
||||
{ key: 'resources', label: 'OSS 资源库', group: '业务' },
|
||||
{ key: 'logs', label: '日志', group: '业务' },
|
||||
{ key: 'users_delete', label: '删除用户', group: '危险操作' },
|
||||
@@ -116,6 +117,7 @@ export const HQ_ROLE_DEFAULT_PERMISSIONS: Record<string, HqPermissionKey[]> = {
|
||||
'llm_configs',
|
||||
'knowledge_bases',
|
||||
'dev_plan',
|
||||
'test_whitelist',
|
||||
'resources',
|
||||
'logs',
|
||||
'system_settings_wechat_mini',
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { MiniShareRuntime } from './config';
|
||||
|
||||
/** 微信 JSSDK 初始化参数(后端签名下发) */
|
||||
export interface WechatJssdkConfig {
|
||||
appId: string;
|
||||
@@ -50,6 +52,20 @@ export type ClientRuntimeConfig = {
|
||||
};
|
||||
/** 小程序最低兼容版本(semver,如 3.4.13);客户端低于此值时提示更新 */
|
||||
minClientVersion?: string | null;
|
||||
/** C 端 H5 落地页(推广码等) */
|
||||
userH5Url?: string;
|
||||
/** 方形品牌 Logo */
|
||||
brandLogoUrl?: string;
|
||||
/** 长方形品牌 Logo(登录) */
|
||||
brandLogoWideUrl?: string;
|
||||
/** 图标 Logo(默认头像) */
|
||||
brandLogoMarkUrl?: string;
|
||||
/** 「我的」资质公示长图 */
|
||||
qualificationDisclosureUrl?: string;
|
||||
/** 总部客服电话 */
|
||||
customerServicePhone?: string;
|
||||
/** 小程序各场景分享文案/图 */
|
||||
share?: MiniShareRuntime;
|
||||
};
|
||||
|
||||
/** 是否展示微信授权入口 */
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# @dukang/weixin-sdk · 踩坑
|
||||
|
||||
## iOS 微信 H5:JSSDK 入场 URL(扫码 / 定位 / 选图)
|
||||
|
||||
### 症状
|
||||
|
||||
- 登录或 OAuth 后立刻调 `scanQRCode` / `getLocation` / `chooseImage` 失败
|
||||
- 错误类似:`permission value is offline verifying`、`invalid signature`
|
||||
- 业务文案常被写成「权限校验尚未完成,请等 1~2 秒」——**多数情况下等无效**
|
||||
- 关掉 webview 再进(整页重载)立即恢复
|
||||
|
||||
### 原因
|
||||
|
||||
iOS 微信对 JS-SDK 验签使用的是**本次 document 加载时的 URL**(去掉 `#` 后的完整 URL,**含 query**)。
|
||||
`history.pushState` / `replaceState`(含 React Router)**不会**更新微信内部用于验签的 URL。
|
||||
|
||||
典型错误链路:
|
||||
|
||||
1. OAuth 回跳:`/login?code=xxx&state=yyy`(入场 URL)
|
||||
2. SPA:`navigate('/')`
|
||||
3. 用当前页 `/` 或「去掉 code 后的 `/login`」去签名 → 与微信内部 URL 不一致 → 失败
|
||||
|
||||
### 正确做法
|
||||
|
||||
1. **业务跳转(登录成功 / 选店进首页)**:iOS 微信内用 `hardNavigateInWechat(path)` / `location.replace`,让目标页成为新的入场 URL。
|
||||
2. **签名 URL**:`getJssdkSignUrl()` 在 iOS 上返回入场 URL;OAuth 的 `code/state` **必须保留**参与签名;后端 `jssdk-config` 只去 `#`,不要删 query。
|
||||
3. **先 `captureIosJssdkEntryUrl()`,再 `stripOAuthParamsFromLocation()`**。
|
||||
4. 失败恢复:引导用户刷新页面或重新走 OAuth,而不是无限「再点一次」。
|
||||
|
||||
### 相关 API
|
||||
|
||||
- `captureIosJssdkEntryUrl` / `getJssdkSignUrl`
|
||||
- `shouldHardNavigateForJssdk` / `hardNavigateInWechat`
|
||||
- `stripOAuthParamsFromLocation`
|
||||
@@ -5,10 +5,13 @@ export {
|
||||
ensureJssdkReady,
|
||||
isJssdkReady,
|
||||
normalizeJssdkPageUrl,
|
||||
jssdkUrlWithoutHash,
|
||||
getJssdkSignUrl,
|
||||
captureIosJssdkEntryUrl,
|
||||
resetJssdkConfig,
|
||||
stripOAuthParamsFromLocation,
|
||||
hardNavigateInWechat,
|
||||
shouldHardNavigateForJssdk,
|
||||
} from './jssdk';
|
||||
export { formatScanFailMessage, isScanPermissionWarmupError } from './scan';
|
||||
export {
|
||||
|
||||
@@ -1,31 +1,53 @@
|
||||
import type { WechatJssdkConfig } from '@dukang/shared-types';
|
||||
import { isWechatBrowser, isWechatDevTools } from './env';
|
||||
import { isIosDevice, isWechatBrowser, isWechatDevTools } from './env';
|
||||
import { DEFAULT_JS_API_LIST } from './types';
|
||||
|
||||
const JSSDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
|
||||
const SIGN_URL_CACHE_KEY = 'dukang_wx_sign_url_v2';
|
||||
/** 旧版错误地把 SPA 当前 URL 写入 session;清理以免干扰排查 */
|
||||
const LEGACY_SIGN_URL_CACHE_KEY = 'dukang_wx_sign_url_v2';
|
||||
|
||||
let scriptPromise: Promise<void> | null = null;
|
||||
let configured = false;
|
||||
let configuredUrl: string | null = null;
|
||||
|
||||
/**
|
||||
* iOS 微信 WebView:JSSDK 签名校验用的是「本次 document 加载」的入场 URL(含 query),
|
||||
* SPA pushState/replaceState 后 location.href 会变,但微信仍按入场 URL 验签。
|
||||
* OAuth 回跳带 code/state 时也必须按入场完整 query 签名,不可剔除。
|
||||
* 模块级变量:整页刷新(含 OAuth / location.replace)会重置;同页 SPA 保持不变。
|
||||
*/
|
||||
let iosEntryUrl: string | null = null;
|
||||
|
||||
/** 清除 JSSDK 配置缓存(路由切换后须重新 wx.config) */
|
||||
export function resetJssdkConfig(): void {
|
||||
configured = false;
|
||||
configuredUrl = null;
|
||||
}
|
||||
|
||||
/** 参与 JSSDK 签名的页面 URL:与微信文档一致,取 location.href 去掉 # 后的部分;剔除 OAuth 回调参数 */
|
||||
export function normalizeJssdkPageUrl(rawUrl: string): string {
|
||||
/** 仅去 hash,保留全部 query(含 OAuth code/state)— iOS 入场签名必须如此 */
|
||||
export function jssdkUrlWithoutHash(rawUrl: string): string {
|
||||
return rawUrl.split('#')[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化签名 URL。
|
||||
* - 默认:去 hash;可保留 code/state(由 keepOAuthQuery 控制)
|
||||
* - Android / 当前页签名:通常已 stripOAuth 后再签,keepOAuthQuery=false
|
||||
*/
|
||||
export function normalizeJssdkPageUrl(rawUrl: string, opts?: { keepOAuthQuery?: boolean }): string {
|
||||
const keepOAuth = !!opts?.keepOAuthQuery;
|
||||
try {
|
||||
const url = new URL(rawUrl);
|
||||
url.hash = '';
|
||||
if (!keepOAuth) {
|
||||
url.searchParams.delete('code');
|
||||
url.searchParams.delete('state');
|
||||
}
|
||||
const query = url.searchParams.toString();
|
||||
return `${url.origin}${url.pathname}${query ? `?${query}` : ''}`;
|
||||
} catch {
|
||||
const noHash = rawUrl.split('#')[0];
|
||||
const noHash = jssdkUrlWithoutHash(rawUrl);
|
||||
if (keepOAuth) return noHash;
|
||||
try {
|
||||
const url = new URL(noHash, typeof window !== 'undefined' ? window.location.origin : 'https://localhost');
|
||||
url.searchParams.delete('code');
|
||||
@@ -42,25 +64,48 @@ function signUrlChanged(prev: string | null, current: string): boolean {
|
||||
return prev !== current;
|
||||
}
|
||||
|
||||
/** 记录最近一次签名 URL;SPA 路由或 ?step= 变化时须重新 wx.config */
|
||||
export function captureIosJssdkEntryUrl(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (!isWechatBrowser() || isWechatDevTools()) return;
|
||||
const current = normalizeJssdkPageUrl(window.location.href);
|
||||
const existing = sessionStorage.getItem(SIGN_URL_CACHE_KEY);
|
||||
if (!existing) {
|
||||
sessionStorage.setItem(SIGN_URL_CACHE_KEY, current);
|
||||
return;
|
||||
}
|
||||
if (signUrlChanged(existing, current)) {
|
||||
sessionStorage.setItem(SIGN_URL_CACHE_KEY, current);
|
||||
resetJssdkConfig();
|
||||
function clearLegacySignUrlCache(): void {
|
||||
try {
|
||||
sessionStorage.removeItem(LEGACY_SIGN_URL_CACHE_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取参与 JSSDK 签名的 URL(始终为当前页完整 URL,含 query) */
|
||||
/**
|
||||
* 捕获 iOS 微信入场 URL(每个 document 生命周期只记一次;保留 code/state)。
|
||||
* Android / 非微信环境为 no-op。
|
||||
*/
|
||||
export function captureIosJssdkEntryUrl(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (!isIosDevice() || !isWechatBrowser() || isWechatDevTools()) return;
|
||||
clearLegacySignUrlCache();
|
||||
if (iosEntryUrl) return;
|
||||
iosEntryUrl = normalizeJssdkPageUrl(window.location.href, { keepOAuthQuery: true });
|
||||
}
|
||||
|
||||
/** 获取参与 JSSDK 签名的 URL;iOS 微信内固定为本次入场 URL(含 OAuth query) */
|
||||
export function getJssdkSignUrl(rawUrl?: string): string {
|
||||
return normalizeJssdkPageUrl(rawUrl ?? (typeof window !== 'undefined' ? window.location.href : ''));
|
||||
if (typeof window !== 'undefined' && isIosDevice() && isWechatBrowser() && !isWechatDevTools()) {
|
||||
captureIosJssdkEntryUrl();
|
||||
if (iosEntryUrl) return iosEntryUrl;
|
||||
}
|
||||
return normalizeJssdkPageUrl(rawUrl ?? (typeof window !== 'undefined' ? window.location.href : ''), {
|
||||
keepOAuthQuery: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* iOS 微信内业务跳转须用整页导航,使下一页成为新的 JSSDK 入场 URL。
|
||||
* SPA navigate 会导致扫码/定位等 JSAPI 验签失败。
|
||||
*/
|
||||
export function hardNavigateInWechat(path: string): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.location.replace(path);
|
||||
}
|
||||
|
||||
export function shouldHardNavigateForJssdk(): boolean {
|
||||
return typeof window !== 'undefined' && isIosDevice() && isWechatBrowser() && !isWechatDevTools();
|
||||
}
|
||||
|
||||
function isJssdkDebugEnabled(): boolean {
|
||||
@@ -76,6 +121,8 @@ export function stripOAuthParamsFromLocation(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
const url = new URL(window.location.href);
|
||||
if (!url.searchParams.has('code') && !url.searchParams.has('state')) return;
|
||||
// iOS:须先锁定入场 URL(含 code/state),再 replaceState
|
||||
captureIosJssdkEntryUrl();
|
||||
url.searchParams.delete('code');
|
||||
url.searchParams.delete('state');
|
||||
const query = url.searchParams.toString();
|
||||
@@ -126,7 +173,7 @@ export async function initWechatJssdk(options: {
|
||||
}): Promise<void> {
|
||||
captureIosJssdkEntryUrl();
|
||||
const { apiBase, clientApp, getAccessToken } = options;
|
||||
const pageUrl = options.url ?? getJssdkSignUrl();
|
||||
const pageUrl = getJssdkSignUrl(options.url);
|
||||
await loadScript();
|
||||
if (!window.wx) throw new Error('微信 JSSDK 不可用');
|
||||
|
||||
@@ -164,7 +211,7 @@ export async function ensureJssdkReady(options: {
|
||||
jsApiList?: string[];
|
||||
}): Promise<void> {
|
||||
captureIosJssdkEntryUrl();
|
||||
const pageUrl = options.url ?? getJssdkSignUrl();
|
||||
const pageUrl = getJssdkSignUrl(options.url);
|
||||
if (configuredUrl && signUrlChanged(configuredUrl, pageUrl)) {
|
||||
resetJssdkConfig();
|
||||
}
|
||||
|
||||
@@ -812,6 +812,21 @@ model CommonProductVisibilityPhone {
|
||||
@@map("common_product_visibility_phone")
|
||||
}
|
||||
|
||||
/// 全局测试白名单手机号(测试账号 + 限测商品/门店可见)
|
||||
model CommonTestWhitelistPhone {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
phone String @unique @db.VarChar(20)
|
||||
note String? @db.VarChar(256)
|
||||
createdByHqId BigInt? @map("created_by_hq_id") @db.UnsignedBigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
createdBy HqAccount? @relation("TestWhitelistCreatedBy", fields: [createdByHqId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([createdAt])
|
||||
@@map("common_test_whitelist_phone")
|
||||
}
|
||||
|
||||
model CommonProductDetailTemplate {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
code String @unique @db.VarChar(32)
|
||||
@@ -1043,6 +1058,8 @@ model PartnerAccount {
|
||||
bankBranch String? @map("bank_branch") @db.VarChar(128)
|
||||
weeklyStoreTarget Int? @default(20) @map("weekly_store_target")
|
||||
managedWarehouseId BigInt? @unique @map("managed_warehouse_id") @db.UnsignedBigInt
|
||||
/// 测试合伙人账号
|
||||
isTest Boolean @default(false) @map("is_test")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
@@ -1059,6 +1076,7 @@ model PartnerAccount {
|
||||
@@index([parentAccountId])
|
||||
@@index([wxOpenId])
|
||||
@@index([contactPhone])
|
||||
@@index([isTest])
|
||||
@@map("partner_account")
|
||||
}
|
||||
|
||||
@@ -1102,6 +1120,7 @@ model HqAccount {
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
permissions HqAccountPermission[]
|
||||
testWhitelistPhones CommonTestWhitelistPhone[] @relation("TestWhitelistCreatedBy")
|
||||
|
||||
@@map("hq_account")
|
||||
}
|
||||
@@ -1140,6 +1159,8 @@ model User {
|
||||
nickname String? @db.VarChar(64)
|
||||
avatarResourceId BigInt? @map("avatar_resource_id") @db.UnsignedBigInt
|
||||
status Int @default(1) @db.TinyInt
|
||||
/// 测试账号:命中全局测试白名单手机号
|
||||
isTest Boolean @default(false) @map("is_test")
|
||||
sourceType UserSourceType @default(ORGANIC) @map("source_type")
|
||||
sourceRefId BigInt? @map("source_ref_id") @db.UnsignedBigInt
|
||||
sourceLabel String? @map("source_label") @db.VarChar(128)
|
||||
@@ -1166,6 +1187,7 @@ model User {
|
||||
@@index([referrerUserId])
|
||||
@@index([mergedIntoUserId])
|
||||
@@index([wxOpenId])
|
||||
@@index([isTest])
|
||||
@@map("user_user")
|
||||
}
|
||||
|
||||
@@ -1253,6 +1275,8 @@ model Store {
|
||||
visibilityWhitelistEnabled Boolean @default(false) @map("visibility_whitelist_enabled")
|
||||
/// FIN-001:允许未出账手动提现的白名单门店
|
||||
withdrawWhitelistEnabled Boolean @default(false) @map("withdraw_whitelist_enabled")
|
||||
/// 测试门店:不计结算 / HQ 可手动标记
|
||||
isTest Boolean @default(false) @map("is_test")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
@@ -1274,6 +1298,7 @@ model Store {
|
||||
@@index([cityId, status])
|
||||
@@index([partnerAccountId])
|
||||
@@index([auditStatus, createdAt])
|
||||
@@index([isTest])
|
||||
@@map("store_store")
|
||||
}
|
||||
|
||||
@@ -1344,6 +1369,8 @@ model StoreAccount {
|
||||
bankBranch String? @map("bank_branch") @db.VarChar(128)
|
||||
status AccountStatus @default(ACTIVE)
|
||||
lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3)
|
||||
/// 测试门店账号(商户)
|
||||
isTest Boolean @default(false) @map("is_test")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
@@ -1354,6 +1381,7 @@ model StoreAccount {
|
||||
withdrawRequests StoreWithdrawRequest[]
|
||||
|
||||
@@index([parentAccountId])
|
||||
@@index([isTest])
|
||||
@@map("store_account")
|
||||
}
|
||||
|
||||
@@ -1432,6 +1460,8 @@ model Order {
|
||||
fulfillmentHold Boolean @default(false) @map("fulfillment_hold")
|
||||
fulfillmentHoldReason String? @map("fulfillment_hold_reason") @db.VarChar(64)
|
||||
remark String? @db.VarChar(512)
|
||||
/// 测试订单快照(下单时取自 User.isTest)
|
||||
isTest Boolean @default(false) @map("is_test")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
@@ -1456,6 +1486,7 @@ model Order {
|
||||
@@index([gpsCity])
|
||||
@@index([fulfillmentWarehouseId])
|
||||
@@index([proxyPartnerAccountId])
|
||||
@@index([isTest])
|
||||
@@map("user_order")
|
||||
}
|
||||
|
||||
@@ -1548,6 +1579,8 @@ model RedeemRecord {
|
||||
settleAmount Decimal @map("settle_amount") @db.Decimal(10, 2)
|
||||
/// SCAN=qrcode, PHONE=phone
|
||||
channel RedeemChannel @default(SCAN)
|
||||
/// 测试核销快照(User.isTest || Store.isTest)
|
||||
isTest Boolean @default(false) @map("is_test")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
|
||||
@@ -1560,6 +1593,7 @@ model RedeemRecord {
|
||||
|
||||
@@index([storeId, createdAt])
|
||||
@@index([storeId, channel, createdAt])
|
||||
@@index([isTest])
|
||||
@@map("user_redeem_record")
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,16 @@ async function main() {
|
||||
|
||||
await prisma.storePayout.deleteMany();
|
||||
|
||||
await prisma.storeWithdrawPayoutItem.deleteMany();
|
||||
|
||||
await prisma.storeWithdrawRequest.deleteMany();
|
||||
|
||||
await prisma.storeBill.deleteMany();
|
||||
|
||||
await prisma.redeemPendingRecord.deleteMany();
|
||||
|
||||
await prisma.logStoreAnalytics.deleteMany();
|
||||
|
||||
await prisma.storeRating.deleteMany();
|
||||
|
||||
await prisma.redeemRecord.deleteMany();
|
||||
@@ -83,16 +93,28 @@ async function main() {
|
||||
|
||||
await prisma.user.deleteMany();
|
||||
|
||||
await prisma.storeAccount.updateMany({ data: { parentAccountId: null } });
|
||||
await prisma.storeAccount.deleteMany();
|
||||
|
||||
await prisma.store.deleteMany();
|
||||
|
||||
await prisma.partnerBill.deleteMany();
|
||||
|
||||
await prisma.logisticsBillItem.deleteMany();
|
||||
|
||||
await prisma.logisticsPrepaidLedger.deleteMany();
|
||||
|
||||
await prisma.logisticsBill.deleteMany();
|
||||
|
||||
await prisma.wineryBillItem.deleteMany();
|
||||
|
||||
await prisma.wineryBill.deleteMany();
|
||||
|
||||
await prisma.cityWarehouse.deleteMany();
|
||||
|
||||
await prisma.fulfillmentProvider.deleteMany();
|
||||
|
||||
await prisma.partnerAccount.updateMany({ data: { parentAccountId: null } });
|
||||
await prisma.partnerAccount.deleteMany();
|
||||
|
||||
await prisma.commonCity.deleteMany();
|
||||
@@ -101,6 +123,7 @@ async function main() {
|
||||
|
||||
await prisma.commonProductDetailTemplate.deleteMany();
|
||||
|
||||
await prisma.commonStoreCategory.updateMany({ data: { parentId: null } });
|
||||
await prisma.commonStoreCategory.deleteMany();
|
||||
|
||||
await prisma.commonPromoCode.deleteMany();
|
||||
@@ -914,6 +937,25 @@ async function main() {
|
||||
|
||||
|
||||
|
||||
const testWhitelistPhones = [
|
||||
'13800000001',
|
||||
'13700000001',
|
||||
'13700000002',
|
||||
'13910000001',
|
||||
'13910000002',
|
||||
];
|
||||
for (const phone of testWhitelistPhones) {
|
||||
await prisma.commonTestWhitelistPhone.upsert({
|
||||
where: { phone },
|
||||
create: { phone, note: 'seed 测试账号' },
|
||||
update: {},
|
||||
});
|
||||
await prisma.user.updateMany({ where: { phone }, data: { isTest: true } });
|
||||
await prisma.storeAccount.updateMany({ where: { phone }, data: { isTest: true } });
|
||||
await prisma.partnerAccount.updateMany({ where: { phone }, data: { isTest: true } });
|
||||
await prisma.store.updateMany({ where: { phone }, data: { isTest: true } });
|
||||
}
|
||||
|
||||
console.log('Seed complete:', {
|
||||
|
||||
city: city.name,
|
||||
@@ -928,6 +970,8 @@ async function main() {
|
||||
|
||||
stores: createdStores.length,
|
||||
|
||||
testWhitelistPhones,
|
||||
|
||||
testPhones: {
|
||||
|
||||
user: '13800000001',
|
||||
|
||||
@@ -21,6 +21,7 @@ import { CityScopeModule } from './modules/city-scope/city-scope.module';
|
||||
import { CommonModule } from './modules/common/common.module';
|
||||
import { HqOperationModule } from './common/hq-operation/hq-operation.module';
|
||||
import { SystemConfigModule } from './common/system-config/system-config.module';
|
||||
import { TestWhitelistModule } from './common/test-whitelist/test-whitelist.module';
|
||||
import { DevPlanModule } from './modules/dev-plan/dev-plan.module';
|
||||
import { CallbacksModule } from './callbacks/callbacks.module';
|
||||
import { WecomModule } from './integrations/wecom/wecom.module';
|
||||
@@ -37,6 +38,7 @@ import { RequestIdMiddleware } from './common/logging/request-id.middleware';
|
||||
}),
|
||||
PrismaModule,
|
||||
SystemConfigModule,
|
||||
TestWhitelistModule,
|
||||
GeoModule,
|
||||
RedisModule,
|
||||
AlertModule,
|
||||
|
||||
@@ -47,12 +47,14 @@ export class DeliveryCallbackService {
|
||||
|
||||
const payload = this.xiaofeixiaProvider.parseTrackCallback(body);
|
||||
if (!payload) {
|
||||
const response = this.courier.buildTrackCallbackResponse(false);
|
||||
await logCourierCall(this.prisma, {
|
||||
...baseLog,
|
||||
responseBody: response,
|
||||
status: 'FAILED',
|
||||
errorMessage: '回调体解析失败',
|
||||
});
|
||||
return this.courier.buildTrackCallbackResponse(false);
|
||||
return response;
|
||||
}
|
||||
|
||||
const order = payload.outNumber
|
||||
@@ -64,13 +66,15 @@ export class DeliveryCallbackService {
|
||||
: null;
|
||||
|
||||
if (!order) {
|
||||
const response = this.courier.buildTrackCallbackResponse(false);
|
||||
await logCourierCall(this.prisma, {
|
||||
...baseLog,
|
||||
responseBody: response,
|
||||
status: 'FAILED',
|
||||
errorMessage: '订单不存在',
|
||||
externalNo: payload.outNumber || payload.trackingNumber,
|
||||
});
|
||||
return this.courier.buildTrackCallbackResponse(false);
|
||||
return response;
|
||||
}
|
||||
|
||||
const targetStatus = this.xiaofeixiaProvider.mapTrackStatus(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Body, Controller, Param, Post } from '@nestjs/common';
|
||||
import { Body, Controller, Param, Post, Res } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { DeliveryCallbackService } from './delivery-callback.service';
|
||||
|
||||
@Controller('callbacks')
|
||||
@@ -7,21 +8,28 @@ export class DeliveryCallbackController {
|
||||
|
||||
/** 小飞侠/物流路由变化回调(适配器入口) */
|
||||
@Post('courier/:provider/track')
|
||||
trackByProvider(@Param('provider') provider: string, @Body() body: unknown) {
|
||||
return this.deliveryCallbackService.handleTrackCallback(
|
||||
async trackByProvider(
|
||||
@Param('provider') provider: string,
|
||||
@Body() body: unknown,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const result = await this.deliveryCallbackService.handleTrackCallback(
|
||||
provider,
|
||||
body,
|
||||
`/api/v1/callbacks/courier/${provider}/track`,
|
||||
);
|
||||
// 直出承运商约定结构,避免被全局 { code:0, data } 包装
|
||||
return res.status(200).json(result);
|
||||
}
|
||||
|
||||
/** 兼容旧路径,默认按小飞侠解析 */
|
||||
@Post('delivery/track')
|
||||
trackLegacy(@Body() body: unknown) {
|
||||
return this.deliveryCallbackService.handleTrackCallback(
|
||||
async trackLegacy(@Body() body: unknown, @Res() res: Response) {
|
||||
const result = await this.deliveryCallbackService.handleTrackCallback(
|
||||
'xfx',
|
||||
body,
|
||||
'/api/v1/callbacks/delivery/track',
|
||||
);
|
||||
return res.status(200).json(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,10 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import {
|
||||
HQ_DANGEROUS_PERMISSION_KEYS,
|
||||
HQ_PERMISSION_CATALOG,
|
||||
HQ_ROLE_DEFAULT_PERMISSIONS,
|
||||
expandHqPermissionKeys,
|
||||
hasAnySystemSettingsPermission,
|
||||
hqBasePermissionKeys,
|
||||
type HqPermissionKey,
|
||||
} from '@dukang/shared-types';
|
||||
import { PrismaService } from '../prisma/prisma.module';
|
||||
@@ -28,7 +27,7 @@ export const RequireAnySystemSettings = () =>
|
||||
export class HqPermissionsResolver {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async resolveEffectiveKeys(actorId: bigint): Promise<HqPermissionKey[]> {
|
||||
private async loadActiveAccount(actorId: bigint) {
|
||||
const account = await this.prisma.hqAccount.findUnique({
|
||||
where: { id: actorId },
|
||||
select: { adminRole: true, status: true },
|
||||
@@ -36,6 +35,14 @@ export class HqPermissionsResolver {
|
||||
if (!account || account.status !== 'ACTIVE') {
|
||||
throw new ForbiddenException('HQ 账号不可用');
|
||||
}
|
||||
return account;
|
||||
}
|
||||
|
||||
async resolveAccess(actorId: bigint): Promise<{
|
||||
keys: HqPermissionKey[];
|
||||
isSuperAdmin: boolean;
|
||||
}> {
|
||||
const account = await this.loadActiveAccount(actorId);
|
||||
|
||||
const userRows = await this.prisma.hqAccountPermission.findMany({
|
||||
where: { hqAccountId: actorId },
|
||||
@@ -44,12 +51,14 @@ export class HqPermissionsResolver {
|
||||
const userKeys = userRows.map((r) => r.permissionKey);
|
||||
|
||||
if (account.adminRole === 'SUPER_ADMIN') {
|
||||
// 超管含危险操作(删用户/订单/城市);其他角色仍需在权限分配中显式勾选
|
||||
return expandHqPermissionKeys([
|
||||
...hqBasePermissionKeys(),
|
||||
...HQ_DANGEROUS_PERMISSION_KEYS,
|
||||
// 超管拥有权限目录内全部项(含后续新增),另含危险操作与用户级附加项
|
||||
return {
|
||||
isSuperAdmin: true,
|
||||
keys: expandHqPermissionKeys([
|
||||
...HQ_PERMISSION_CATALOG.map((p) => p.key),
|
||||
...userKeys,
|
||||
]);
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
const roleRows = await this.prisma.hqRolePermission.findMany({
|
||||
@@ -62,7 +71,15 @@ export class HqPermissionsResolver {
|
||||
? roleRows.map((r) => r.permissionKey)
|
||||
: [...(HQ_ROLE_DEFAULT_PERMISSIONS[account.adminRole] ?? [])];
|
||||
|
||||
return expandHqPermissionKeys([...roleKeys, ...userKeys]);
|
||||
return {
|
||||
isSuperAdmin: false,
|
||||
keys: expandHqPermissionKeys([...roleKeys, ...userKeys]),
|
||||
};
|
||||
}
|
||||
|
||||
async resolveEffectiveKeys(actorId: bigint): Promise<HqPermissionKey[]> {
|
||||
const { keys } = await this.resolveAccess(actorId);
|
||||
return keys;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +96,7 @@ export class HqPermissionGuard implements CanActivate {
|
||||
if (!user || user.actorType !== 'HQ') {
|
||||
throw new ForbiddenException('需要 HQ 权限');
|
||||
}
|
||||
const keys = await this.resolver.resolveEffectiveKeys(user.actorId);
|
||||
const { keys, isSuperAdmin } = await this.resolver.resolveAccess(user.actorId);
|
||||
req.hqPermissionKeys = keys;
|
||||
|
||||
const required =
|
||||
@@ -89,6 +106,7 @@ export class HqPermissionGuard implements CanActivate {
|
||||
]) ?? [];
|
||||
|
||||
if (!required.length) return true;
|
||||
if (isSuperAdmin) return true;
|
||||
if (required.includes('__any_system_settings__')) {
|
||||
if (!hasAnySystemSettingsPermission(keys)) {
|
||||
throw new ForbiddenException('无系统设置权限');
|
||||
|
||||
@@ -1,4 +1,22 @@
|
||||
import type { SystemConfigFieldMeta, SystemConfigGroupMeta } from '@dukang/shared-types';
|
||||
import {
|
||||
BRAND_LOGO_MARK_URL,
|
||||
BRAND_LOGO_OSS_BASE,
|
||||
BRAND_LOGO_URL,
|
||||
BRAND_LOGO_WIDE_URL,
|
||||
CUSTOMER_SERVICE_PHONE,
|
||||
DEFAULT_SHARE_BENEFIT_TITLE,
|
||||
DEFAULT_SHARE_DESC,
|
||||
DEFAULT_SHARE_HINT,
|
||||
DEFAULT_SHARE_MINE_TITLE,
|
||||
DEFAULT_SHARE_ORDER_TITLE,
|
||||
DEFAULT_SHARE_STORES_TITLE,
|
||||
DEFAULT_SHARE_TITLE,
|
||||
DEFAULT_USER_H5_URL,
|
||||
MINI_USER_STATIC_OSS_BASE,
|
||||
MOCK_SMS_FIXED_CODE,
|
||||
QUALIFICATION_DISCLOSURE_URL,
|
||||
} from '@dukang/shared-types';
|
||||
|
||||
/** 运行环境、安全与鉴权仅保留在 .env,不在 HQ 系统设置中维护 */
|
||||
export const SYSTEM_CONFIG_GROUPS: SystemConfigGroupMeta[] = [
|
||||
@@ -6,6 +24,7 @@ export const SYSTEM_CONFIG_GROUPS: SystemConfigGroupMeta[] = [
|
||||
{ key: 'sms', label: '短信' },
|
||||
{ key: 'wechat', label: '微信' },
|
||||
{ key: 'wechat_mini', label: '微信小程序配置' },
|
||||
{ key: 'wechat_mini_share', label: '小程序分享配置' },
|
||||
{ key: 'oss', label: '对象存储 OSS' },
|
||||
{ key: 'app', label: '应用链接' },
|
||||
{ key: 'deploy', label: '发布部署' },
|
||||
@@ -18,6 +37,7 @@ const G = {
|
||||
sms: 'sms',
|
||||
wechat: 'wechat',
|
||||
wechat_mini: 'wechat_mini',
|
||||
wechat_mini_share: 'wechat_mini_share',
|
||||
oss: 'oss',
|
||||
app: 'app',
|
||||
deploy: 'deploy',
|
||||
@@ -122,9 +142,282 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
|
||||
group: G.wechat_mini,
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
placeholder: '3.4.13',
|
||||
placeholder: '3.4.15',
|
||||
description: 'semver 格式;客户端低于此版本时提示更新',
|
||||
},
|
||||
{
|
||||
key: 'USER_H5_URL',
|
||||
label: 'C 端 H5 落地页',
|
||||
group: G.wechat_mini,
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
placeholder: 'https://user.example.com/user',
|
||||
description: '推广码二维码 / 未配置时的默认落地页前缀(无末尾斜杠)',
|
||||
},
|
||||
{
|
||||
key: 'BRAND_LOGO_OSS_BASE',
|
||||
label: '品牌 Logo OSS 根路径',
|
||||
group: G.wechat_mini,
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
placeholder: 'https://xxx.oss-cn-beijing.aliyuncs.com/logo/',
|
||||
description: '仅作说明/备份;下方三张 Logo 请直接配置完整 URL',
|
||||
},
|
||||
{
|
||||
key: 'BRAND_LOGO_URL',
|
||||
label: '方形 Logo',
|
||||
group: G.wechat_mini,
|
||||
type: 'image',
|
||||
requiresRestart: false,
|
||||
description: '品牌方形 Logo;分享默认图未单独配置时回退此图',
|
||||
},
|
||||
{
|
||||
key: 'BRAND_LOGO_WIDE_URL',
|
||||
label: '长方形 Logo',
|
||||
group: G.wechat_mini,
|
||||
type: 'image',
|
||||
requiresRestart: false,
|
||||
description: '含文字,登录页等场景',
|
||||
},
|
||||
{
|
||||
key: 'BRAND_LOGO_MARK_URL',
|
||||
label: '图标 Logo',
|
||||
group: G.wechat_mini,
|
||||
type: 'image',
|
||||
requiresRestart: false,
|
||||
description: '默认头像(未微信授权时)',
|
||||
},
|
||||
{
|
||||
key: 'MINI_USER_STATIC_OSS_BASE',
|
||||
label: '小程序静态资源 OSS 根路径',
|
||||
group: G.wechat_mini,
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
placeholder: 'https://xxx.oss-cn-beijing.aliyuncs.com/static/mini-user/',
|
||||
description: '资质公示等静态资源根路径;完整 URL 优先用下方「资质公示图」',
|
||||
},
|
||||
{
|
||||
key: 'QUALIFICATION_DISCLOSURE_URL',
|
||||
label: '资质公示长图',
|
||||
group: G.wechat_mini,
|
||||
type: 'image',
|
||||
requiresRestart: false,
|
||||
description: '「我的」页资质公示大图',
|
||||
},
|
||||
{
|
||||
key: 'CUSTOMER_SERVICE_PHONE',
|
||||
label: '总部客服电话',
|
||||
group: G.wechat_mini,
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
placeholder: '13203801799',
|
||||
description: 'C 端联系客服拨号号码',
|
||||
},
|
||||
{
|
||||
key: 'MOCK_SMS_FIXED_CODE',
|
||||
label: 'Mock 短信固定验证码',
|
||||
group: G.wechat_mini,
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
placeholder: '999888',
|
||||
description: '仅 MOCK_SMS 开启时生效;留空则用默认 999888',
|
||||
},
|
||||
|
||||
// —— 小程序分享配置 ——
|
||||
{
|
||||
key: 'SHARE_HINT',
|
||||
label: '分享引导文案',
|
||||
group: G.wechat_mini_share,
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
placeholder: DEFAULT_SHARE_HINT,
|
||||
description: '点击分享按钮后的 Toast 提示(小程序内)',
|
||||
},
|
||||
{
|
||||
key: 'SHARE_DEFAULT_TITLE',
|
||||
label: '默认分享标题',
|
||||
group: G.wechat_mini_share,
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
placeholder: DEFAULT_SHARE_TITLE,
|
||||
description: '各场景未单独配置标题时使用',
|
||||
},
|
||||
{
|
||||
key: 'SHARE_DEFAULT_DESC',
|
||||
label: '默认分享描述',
|
||||
group: G.wechat_mini_share,
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
placeholder: DEFAULT_SHARE_DESC,
|
||||
description: 'H5 分享卡片描述;小程序朋友圈/好友卡片副文案回退',
|
||||
},
|
||||
{
|
||||
key: 'SHARE_DEFAULT_IMAGE_URL',
|
||||
label: '默认分享图',
|
||||
group: G.wechat_mini_share,
|
||||
type: 'image',
|
||||
requiresRestart: false,
|
||||
description: '各场景未配置图且无业务图时使用;建议接近 5:4',
|
||||
},
|
||||
{
|
||||
key: 'SHARE_HOME_TITLE',
|
||||
label: '首页 · 标题',
|
||||
group: G.wechat_mini_share,
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
},
|
||||
{
|
||||
key: 'SHARE_HOME_DESC',
|
||||
label: '首页 · 描述',
|
||||
group: G.wechat_mini_share,
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
},
|
||||
{
|
||||
key: 'SHARE_HOME_IMAGE_URL',
|
||||
label: '首页 · 分享图',
|
||||
group: G.wechat_mini_share,
|
||||
type: 'image',
|
||||
requiresRestart: false,
|
||||
description: '留空则优先用首页首张轮播图,再回退默认分享图',
|
||||
},
|
||||
{
|
||||
key: 'SHARE_STORES_TITLE',
|
||||
label: '门店列表 · 标题',
|
||||
group: G.wechat_mini_share,
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
placeholder: DEFAULT_SHARE_STORES_TITLE,
|
||||
},
|
||||
{
|
||||
key: 'SHARE_STORES_DESC',
|
||||
label: '门店列表 · 描述',
|
||||
group: G.wechat_mini_share,
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
},
|
||||
{
|
||||
key: 'SHARE_STORES_IMAGE_URL',
|
||||
label: '门店列表 · 分享图',
|
||||
group: G.wechat_mini_share,
|
||||
type: 'image',
|
||||
requiresRestart: false,
|
||||
},
|
||||
{
|
||||
key: 'SHARE_STORE_DETAIL_TITLE',
|
||||
label: '门店详情 · 标题',
|
||||
group: G.wechat_mini_share,
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
description: '留空则用门店名称',
|
||||
},
|
||||
{
|
||||
key: 'SHARE_STORE_DETAIL_DESC',
|
||||
label: '门店详情 · 描述',
|
||||
group: G.wechat_mini_share,
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
description: '留空则用门店简介/地址',
|
||||
},
|
||||
{
|
||||
key: 'SHARE_STORE_DETAIL_IMAGE_URL',
|
||||
label: '门店详情 · 分享图',
|
||||
group: G.wechat_mini_share,
|
||||
type: 'image',
|
||||
requiresRestart: false,
|
||||
description: '留空则用门头图/环境图',
|
||||
},
|
||||
{
|
||||
key: 'SHARE_BENEFIT_TITLE',
|
||||
label: '权益页 · 标题',
|
||||
group: G.wechat_mini_share,
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
placeholder: DEFAULT_SHARE_BENEFIT_TITLE,
|
||||
},
|
||||
{
|
||||
key: 'SHARE_BENEFIT_DESC',
|
||||
label: '权益页 · 描述',
|
||||
group: G.wechat_mini_share,
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
},
|
||||
{
|
||||
key: 'SHARE_BENEFIT_IMAGE_URL',
|
||||
label: '权益页 · 分享图',
|
||||
group: G.wechat_mini_share,
|
||||
type: 'image',
|
||||
requiresRestart: false,
|
||||
},
|
||||
{
|
||||
key: 'SHARE_MINE_TITLE',
|
||||
label: '我的 · 标题',
|
||||
group: G.wechat_mini_share,
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
placeholder: DEFAULT_SHARE_MINE_TITLE,
|
||||
},
|
||||
{
|
||||
key: 'SHARE_MINE_DESC',
|
||||
label: '我的 · 描述',
|
||||
group: G.wechat_mini_share,
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
},
|
||||
{
|
||||
key: 'SHARE_MINE_IMAGE_URL',
|
||||
label: '我的 · 分享图',
|
||||
group: G.wechat_mini_share,
|
||||
type: 'image',
|
||||
requiresRestart: false,
|
||||
},
|
||||
{
|
||||
key: 'SHARE_PRODUCT_DETAIL_TITLE',
|
||||
label: '商品详情 · 标题',
|
||||
group: G.wechat_mini_share,
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
description: '留空则用商品名称',
|
||||
},
|
||||
{
|
||||
key: 'SHARE_PRODUCT_DETAIL_DESC',
|
||||
label: '商品详情 · 描述',
|
||||
group: G.wechat_mini_share,
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
description: '留空则用商品副标题',
|
||||
},
|
||||
{
|
||||
key: 'SHARE_PRODUCT_DETAIL_IMAGE_URL',
|
||||
label: '商品详情 · 分享图',
|
||||
group: G.wechat_mini_share,
|
||||
type: 'image',
|
||||
requiresRestart: false,
|
||||
description: '留空则用商品主图',
|
||||
},
|
||||
{
|
||||
key: 'SHARE_ORDER_DETAIL_TITLE',
|
||||
label: '订单详情 · 标题',
|
||||
group: G.wechat_mini_share,
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
placeholder: DEFAULT_SHARE_ORDER_TITLE,
|
||||
description: '支持 {productName} 占位符',
|
||||
},
|
||||
{
|
||||
key: 'SHARE_ORDER_DETAIL_DESC',
|
||||
label: '订单详情 · 描述',
|
||||
group: G.wechat_mini_share,
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
},
|
||||
{
|
||||
key: 'SHARE_ORDER_DETAIL_IMAGE_URL',
|
||||
label: '订单详情 · 分享图',
|
||||
group: G.wechat_mini_share,
|
||||
type: 'image',
|
||||
requiresRestart: false,
|
||||
},
|
||||
|
||||
{ key: 'OSS_ACCESS_KEY_ID', label: 'OSS AccessKey ID', group: G.oss, type: 'password', secret: true, requiresRestart: true },
|
||||
{ key: 'OSS_ACCESS_KEY_SECRET', label: 'OSS AccessKey Secret', group: G.oss, type: 'password', secret: true, requiresRestart: true },
|
||||
@@ -137,7 +430,6 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
|
||||
{ key: 'OSS_UPLOAD_EXPIRE_SECONDS', label: '直传凭证有效期(秒)', group: G.oss, type: 'number', requiresRestart: false },
|
||||
{ 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',
|
||||
@@ -247,6 +539,37 @@ export const SYSTEM_CONFIG_RETIRED_KEYS = [
|
||||
|
||||
export const SYSTEM_CONFIG_KEY_SET = new Set(SYSTEM_CONFIG_FIELDS.map((f) => f.key));
|
||||
|
||||
/** 表单空值时展示 / 启动补种的默认值(与 shared-types 常量对齐) */
|
||||
export const SYSTEM_CONFIG_DEFAULTS: Record<string, string> = {
|
||||
USER_H5_URL: DEFAULT_USER_H5_URL.replace(/\/$/, ''),
|
||||
BRAND_LOGO_OSS_BASE: BRAND_LOGO_OSS_BASE,
|
||||
BRAND_LOGO_URL: BRAND_LOGO_URL,
|
||||
BRAND_LOGO_WIDE_URL: BRAND_LOGO_WIDE_URL,
|
||||
BRAND_LOGO_MARK_URL: BRAND_LOGO_MARK_URL,
|
||||
MINI_USER_STATIC_OSS_BASE: MINI_USER_STATIC_OSS_BASE,
|
||||
QUALIFICATION_DISCLOSURE_URL: QUALIFICATION_DISCLOSURE_URL,
|
||||
CUSTOMER_SERVICE_PHONE: CUSTOMER_SERVICE_PHONE,
|
||||
MOCK_SMS_FIXED_CODE: MOCK_SMS_FIXED_CODE,
|
||||
SHARE_HINT: DEFAULT_SHARE_HINT,
|
||||
SHARE_DEFAULT_TITLE: DEFAULT_SHARE_TITLE,
|
||||
SHARE_DEFAULT_DESC: DEFAULT_SHARE_DESC,
|
||||
SHARE_DEFAULT_IMAGE_URL: BRAND_LOGO_URL,
|
||||
SHARE_HOME_TITLE: DEFAULT_SHARE_TITLE,
|
||||
SHARE_HOME_DESC: DEFAULT_SHARE_DESC,
|
||||
SHARE_STORES_TITLE: DEFAULT_SHARE_STORES_TITLE,
|
||||
SHARE_STORES_DESC: DEFAULT_SHARE_DESC,
|
||||
SHARE_BENEFIT_TITLE: DEFAULT_SHARE_BENEFIT_TITLE,
|
||||
SHARE_BENEFIT_DESC: DEFAULT_SHARE_DESC,
|
||||
SHARE_MINE_TITLE: DEFAULT_SHARE_MINE_TITLE,
|
||||
SHARE_MINE_DESC: DEFAULT_SHARE_DESC,
|
||||
SHARE_ORDER_DETAIL_TITLE: DEFAULT_SHARE_ORDER_TITLE,
|
||||
SHARE_ORDER_DETAIL_DESC: DEFAULT_SHARE_DESC,
|
||||
};
|
||||
|
||||
export function getSystemConfigField(key: string): SystemConfigFieldMeta | undefined {
|
||||
return SYSTEM_CONFIG_FIELDS.find((f) => f.key === key);
|
||||
}
|
||||
|
||||
export function getSystemConfigDefault(key: string): string {
|
||||
return SYSTEM_CONFIG_DEFAULTS[key] ?? '';
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
import { loadAppConfig, parseMiniHomeBanners, serializeMiniHomeBanners } from '@dukang/shared-types';
|
||||
import { PrismaService } from '../prisma/prisma.module';
|
||||
import {
|
||||
SYSTEM_CONFIG_DEFAULTS,
|
||||
SYSTEM_CONFIG_FIELDS,
|
||||
SYSTEM_CONFIG_GROUPS,
|
||||
SYSTEM_CONFIG_KEY_SET,
|
||||
@@ -42,6 +43,7 @@ export class SystemConfigService implements OnModuleInit {
|
||||
try {
|
||||
await this.purgeRetiredKeys();
|
||||
await this.seedMissingFromProcessEnv();
|
||||
await this.seedMissingDefaults();
|
||||
const rows = await this.prisma.systemConfig.findMany();
|
||||
applyEnvOverlay(Object.fromEntries(rows.map((r) => [r.configKey, r.value])));
|
||||
this.lastUpdatedAt = rows.reduce<Date | null>(
|
||||
@@ -82,7 +84,7 @@ export class SystemConfigService implements OnModuleInit {
|
||||
for (const field of fields) {
|
||||
const fromDb = dbMap.get(field.key);
|
||||
const fromEnv = process.env[field.key];
|
||||
const raw = fromDb ?? fromEnv ?? '';
|
||||
const raw = (fromDb ?? fromEnv ?? SYSTEM_CONFIG_DEFAULTS[field.key] ?? '').trim();
|
||||
if (field.secret) {
|
||||
if (raw) configuredSecrets.push(field.key);
|
||||
values[field.key] = '';
|
||||
@@ -227,6 +229,26 @@ export class SystemConfigService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
/** 将代码默认值写入空缺配置,便于 HQ 表单可见、可改 */
|
||||
private async seedMissingDefaults() {
|
||||
const overlay: Record<string, string> = {};
|
||||
for (const field of SYSTEM_CONFIG_FIELDS) {
|
||||
const defaultVal = SYSTEM_CONFIG_DEFAULTS[field.key];
|
||||
if (!defaultVal?.trim()) continue;
|
||||
const existing = await this.prisma.systemConfig.findUnique({ where: { configKey: field.key } });
|
||||
if (existing?.value?.trim()) continue;
|
||||
if (process.env[field.key]?.trim()) continue;
|
||||
const value = this.normalizeByMeta(field, defaultVal);
|
||||
await this.prisma.systemConfig.upsert({
|
||||
where: { configKey: field.key },
|
||||
create: { configKey: field.key, value },
|
||||
update: { value },
|
||||
});
|
||||
overlay[field.key] = value;
|
||||
}
|
||||
if (Object.keys(overlay).length) applyEnvOverlay(overlay);
|
||||
}
|
||||
|
||||
private normalizeByMeta(meta: { key?: string; type: string }, raw: string): string {
|
||||
if (meta.type === 'boolean') {
|
||||
return raw === 'true' || raw === '1' ? 'true' : 'false';
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { TestWhitelistService } from './test-whitelist.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [TestWhitelistService],
|
||||
exports: [TestWhitelistService],
|
||||
})
|
||||
export class TestWhitelistModule {}
|
||||
@@ -0,0 +1,335 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.module';
|
||||
import { serializeBigInt } from '../decorators/current-user.decorator';
|
||||
|
||||
export function normalizeTestPhone(phone: string | null | undefined): string {
|
||||
return (phone || '').replace(/\D/g, '').trim();
|
||||
}
|
||||
|
||||
export function assertMobilePhone(phone: string): string {
|
||||
const p = normalizeTestPhone(phone);
|
||||
if (!/^1\d{10}$/.test(p)) {
|
||||
throw new BadRequestException(`手机号格式无效:${phone}`);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TestWhitelistService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async isPhoneInWhitelist(phone: string | null | undefined): Promise<boolean> {
|
||||
const p = normalizeTestPhone(phone);
|
||||
if (!p) return false;
|
||||
const row = await this.prisma.commonTestWhitelistPhone.findUnique({
|
||||
where: { phone: p },
|
||||
select: { id: true },
|
||||
});
|
||||
return !!row;
|
||||
}
|
||||
|
||||
async assertGlobalWhitelistNotEmpty() {
|
||||
const count = await this.prisma.commonTestWhitelistPhone.count();
|
||||
if (count === 0) {
|
||||
throw new BadRequestException('全局测试白名单为空,请先在「白名单管理」添加手机号');
|
||||
}
|
||||
}
|
||||
|
||||
async listPhones(query: { phone?: string; page?: number; pageSize?: number }) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: Prisma.CommonTestWhitelistPhoneWhereInput = {};
|
||||
if (query.phone) {
|
||||
where.phone = { contains: normalizeTestPhone(query.phone) || query.phone };
|
||||
}
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.commonTestWhitelistPhone.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.commonTestWhitelistPhone.count({ where }),
|
||||
]);
|
||||
return serializeBigInt({ items, total, page, pageSize });
|
||||
}
|
||||
|
||||
async addPhone(input: { phone: string; note?: string; createdByHqId?: bigint }) {
|
||||
const phone = assertMobilePhone(input.phone);
|
||||
const existing = await this.prisma.commonTestWhitelistPhone.findUnique({ where: { phone } });
|
||||
if (existing) {
|
||||
throw new BadRequestException('该手机号已在白名单中');
|
||||
}
|
||||
const row = await this.prisma.commonTestWhitelistPhone.create({
|
||||
data: {
|
||||
phone,
|
||||
note: input.note?.trim() || null,
|
||||
createdByHqId: input.createdByHqId ?? null,
|
||||
},
|
||||
});
|
||||
await this.syncTestFlagsForPhone(phone, true);
|
||||
return serializeBigInt(row);
|
||||
}
|
||||
|
||||
async updatePhone(id: bigint, input: { note?: string | null }) {
|
||||
const row = await this.prisma.commonTestWhitelistPhone.findUnique({ where: { id } });
|
||||
if (!row) throw new NotFoundException('白名单记录不存在');
|
||||
const updated = await this.prisma.commonTestWhitelistPhone.update({
|
||||
where: { id },
|
||||
data: { note: input.note === undefined ? undefined : input.note?.trim() || null },
|
||||
});
|
||||
return serializeBigInt(updated);
|
||||
}
|
||||
|
||||
async removePhone(id: bigint) {
|
||||
const row = await this.prisma.commonTestWhitelistPhone.findUnique({ where: { id } });
|
||||
if (!row) throw new NotFoundException('白名单记录不存在');
|
||||
await this.prisma.commonTestWhitelistPhone.delete({ where: { id } });
|
||||
await this.syncTestFlagsForPhone(row.phone, false);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** 同步账号/门店 isTest,并回填订单/核销快照 */
|
||||
async syncTestFlagsForPhone(phone: string, isTest: boolean) {
|
||||
const p = normalizeTestPhone(phone);
|
||||
if (!p) return;
|
||||
|
||||
await this.prisma.user.updateMany({ where: { phone: p }, data: { isTest } });
|
||||
await this.prisma.storeAccount.updateMany({ where: { phone: p }, data: { isTest } });
|
||||
await this.prisma.partnerAccount.updateMany({ where: { phone: p }, data: { isTest } });
|
||||
|
||||
if (isTest) {
|
||||
await this.prisma.store.updateMany({ where: { phone: p }, data: { isTest: true } });
|
||||
} else {
|
||||
// 仅清除「联系电话命中且当前不在白名单」的自动标;手动标的门店若电话已不在名单则保持 isTest(运营可再关)
|
||||
// 简化:电话命中且移出名单时置 false;手动标的非该电话门店不受影响
|
||||
await this.prisma.store.updateMany({ where: { phone: p }, data: { isTest: false } });
|
||||
}
|
||||
|
||||
const users = await this.prisma.user.findMany({
|
||||
where: { phone: p },
|
||||
select: { id: true },
|
||||
});
|
||||
const userIds = users.map((u) => u.id);
|
||||
if (userIds.length) {
|
||||
await this.prisma.order.updateMany({
|
||||
where: { userId: { in: userIds } },
|
||||
data: { isTest },
|
||||
});
|
||||
}
|
||||
|
||||
const stores = await this.prisma.store.findMany({
|
||||
where: { phone: p },
|
||||
select: { id: true },
|
||||
});
|
||||
const storeIds = stores.map((s) => s.id);
|
||||
|
||||
if (userIds.length || storeIds.length) {
|
||||
const or: Prisma.RedeemRecordWhereInput[] = [];
|
||||
if (userIds.length) or.push({ userId: { in: userIds } });
|
||||
if (storeIds.length) or.push({ storeId: { in: storeIds } });
|
||||
await this.prisma.redeemRecord.updateMany({
|
||||
where: { OR: or },
|
||||
data: { isTest },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async listAccounts(query: {
|
||||
type: 'user' | 'store_account' | 'partner' | 'store' | 'order';
|
||||
phone?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const phone = query.phone ? normalizeTestPhone(query.phone) : '';
|
||||
|
||||
if (query.type === 'user') {
|
||||
const where: Prisma.UserWhereInput = { isTest: true };
|
||||
if (phone) where.phone = { contains: phone };
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.user.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
select: {
|
||||
id: true,
|
||||
userNo: true,
|
||||
phone: true,
|
||||
nickname: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
isTest: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.user.count({ where }),
|
||||
]);
|
||||
return serializeBigInt({ items, total, page, pageSize, type: query.type });
|
||||
}
|
||||
|
||||
if (query.type === 'store_account') {
|
||||
const where: Prisma.StoreAccountWhereInput = { isTest: true };
|
||||
if (phone) where.phone = { contains: phone };
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.storeAccount.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
select: {
|
||||
id: true,
|
||||
phone: true,
|
||||
name: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
isTest: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.storeAccount.count({ where }),
|
||||
]);
|
||||
return serializeBigInt({ items, total, page, pageSize, type: query.type });
|
||||
}
|
||||
|
||||
if (query.type === 'partner') {
|
||||
const where: Prisma.PartnerAccountWhereInput = { isTest: true };
|
||||
if (phone) where.phone = { contains: phone };
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.partnerAccount.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
select: {
|
||||
id: true,
|
||||
phone: true,
|
||||
name: true,
|
||||
companyName: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
isTest: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.partnerAccount.count({ where }),
|
||||
]);
|
||||
return serializeBigInt({ items, total, page, pageSize, type: query.type });
|
||||
}
|
||||
|
||||
if (query.type === 'store') {
|
||||
const where: Prisma.StoreWhereInput = { isTest: true };
|
||||
if (phone) where.phone = { contains: phone };
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.store.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
phone: true,
|
||||
status: true,
|
||||
cityName: true,
|
||||
createdAt: true,
|
||||
isTest: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.store.count({ where }),
|
||||
]);
|
||||
return serializeBigInt({ items, total, page, pageSize, type: query.type });
|
||||
}
|
||||
|
||||
const where: Prisma.OrderWhereInput = { isTest: true };
|
||||
if (phone) {
|
||||
where.OR = [
|
||||
{ receiverPhone: { contains: phone } },
|
||||
{ user: { phone: { contains: phone } } },
|
||||
];
|
||||
}
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.order.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
select: {
|
||||
id: true,
|
||||
orderNo: true,
|
||||
status: true,
|
||||
payStatus: true,
|
||||
payAmount: true,
|
||||
receiverPhone: true,
|
||||
createdAt: true,
|
||||
isTest: true,
|
||||
user: { select: { id: true, phone: true, userNo: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.order.count({ where }),
|
||||
]);
|
||||
return serializeBigInt({ items, total, page, pageSize, type: query.type });
|
||||
}
|
||||
|
||||
async linkedForPhoneId(id: bigint) {
|
||||
const row = await this.prisma.commonTestWhitelistPhone.findUnique({ where: { id } });
|
||||
if (!row) throw new NotFoundException('白名单记录不存在');
|
||||
const phone = row.phone;
|
||||
const [users, storeAccounts, partners, stores] = await Promise.all([
|
||||
this.prisma.user.findMany({
|
||||
where: { phone },
|
||||
select: { id: true, userNo: true, phone: true, nickname: true, isTest: true, status: true },
|
||||
}),
|
||||
this.prisma.storeAccount.findMany({
|
||||
where: { phone },
|
||||
select: { id: true, phone: true, name: true, isTest: true, status: true },
|
||||
}),
|
||||
this.prisma.partnerAccount.findMany({
|
||||
where: { phone },
|
||||
select: { id: true, phone: true, name: true, companyName: true, isTest: true, status: true },
|
||||
}),
|
||||
this.prisma.store.findMany({
|
||||
where: { phone },
|
||||
select: { id: true, name: true, phone: true, isTest: true, status: true },
|
||||
}),
|
||||
]);
|
||||
return serializeBigInt({
|
||||
phone: row,
|
||||
users,
|
||||
storeAccounts,
|
||||
partners,
|
||||
stores,
|
||||
});
|
||||
}
|
||||
|
||||
/** 从旧商品/门店可见性子表导入全局名单(幂等) */
|
||||
async migrateVisibilityPhones(createdByHqId?: bigint) {
|
||||
const [productPhones, storePhones] = await Promise.all([
|
||||
this.prisma.commonProductVisibilityPhone.findMany({ select: { phone: true } }),
|
||||
this.prisma.storeVisibilityPhone.findMany({ select: { phone: true } }),
|
||||
]);
|
||||
const set = new Set<string>();
|
||||
for (const row of [...productPhones, ...storePhones]) {
|
||||
const p = normalizeTestPhone(row.phone);
|
||||
if (/^1\d{10}$/.test(p)) set.add(p);
|
||||
}
|
||||
let added = 0;
|
||||
for (const phone of set) {
|
||||
const exists = await this.prisma.commonTestWhitelistPhone.findUnique({ where: { phone } });
|
||||
if (exists) {
|
||||
await this.syncTestFlagsForPhone(phone, true);
|
||||
continue;
|
||||
}
|
||||
await this.prisma.commonTestWhitelistPhone.create({
|
||||
data: {
|
||||
phone,
|
||||
note: '自可见性白名单迁移',
|
||||
createdByHqId: createdByHqId ?? null,
|
||||
},
|
||||
});
|
||||
await this.syncTestFlagsForPhone(phone, true);
|
||||
added += 1;
|
||||
}
|
||||
return { importedCandidates: set.size, added };
|
||||
}
|
||||
}
|
||||
@@ -138,7 +138,15 @@ export class XiaofeixiaProvider implements ICourierProvider {
|
||||
|
||||
parseTrackCallback(body: unknown): TrackCallbackPayload | null {
|
||||
if (!body || typeof body !== 'object') return null;
|
||||
const raw = body as Record<string, unknown>;
|
||||
const root = body as Record<string, unknown>;
|
||||
// 兼容顶层字段或 data/payload 包裹
|
||||
const nested =
|
||||
root.data && typeof root.data === 'object'
|
||||
? (root.data as Record<string, unknown>)
|
||||
: root.payload && typeof root.payload === 'object'
|
||||
? (root.payload as Record<string, unknown>)
|
||||
: null;
|
||||
const raw = nested ? { ...nested, ...root } : root;
|
||||
const outNumber = String(raw.outNumber ?? '').trim();
|
||||
const trackingNumber = String(raw.number ?? raw.trackingNumber ?? '').trim();
|
||||
const status = String(raw.status ?? '').trim();
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { resolveMockSmsFixedCode } from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { MockSmsCodeService } from '../../common/mock-sms-code/mock-sms-code.service';
|
||||
import type { ISmsProvider, SmsActorRef, SmsSendResult } from './sms.interface';
|
||||
import { MOCK_SMS_FIXED_CODE, SmsCodeStore } from './sms-code.store';
|
||||
import { SmsCodeStore } from './sms-code.store';
|
||||
|
||||
function maskPhone(phone: string) {
|
||||
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
|
||||
@@ -19,7 +20,8 @@ export class SmsMockProvider implements ISmsProvider {
|
||||
) {}
|
||||
|
||||
async send(phone: string, scene: string, actorRef?: SmsActorRef): Promise<SmsSendResult> {
|
||||
const code = await this.smsCodeStore.storeCode(phone, scene, MOCK_SMS_FIXED_CODE);
|
||||
const fixedCode = resolveMockSmsFixedCode(process.env);
|
||||
const code = await this.smsCodeStore.storeCode(phone, scene, fixedCode);
|
||||
await this.mockSmsCodeService.record(phone, scene, code);
|
||||
const masked = maskPhone(phone);
|
||||
this.logger.log(`Mock SMS → ${masked} scene=${scene} code=${code}`);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { NestFactory } from '@nestjs/core';
|
||||
import { loadAppConfig } from '@dukang/shared-types';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { json } from 'express';
|
||||
import { json, urlencoded } from 'express';
|
||||
import { AppModule } from './app.module';
|
||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
|
||||
@@ -26,6 +26,7 @@ async function bootstrap() {
|
||||
app.setGlobalPrefix('api/v1');
|
||||
app.set('trust proxy', true);
|
||||
app.enableCors({ origin: true, credentials: true });
|
||||
// 微信支付等需 rawBody;小飞侠回调多为 x-www-form-urlencoded
|
||||
app.use(
|
||||
json({
|
||||
verify: (req, _res, buf) => {
|
||||
@@ -39,6 +40,7 @@ async function bootstrap() {
|
||||
},
|
||||
}),
|
||||
);
|
||||
app.use(urlencoded({ extended: true }));
|
||||
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
|
||||
app.useGlobalFilters(new HttpExceptionFilter(app.get(AlertService)));
|
||||
app.useGlobalInterceptors(new ResponseInterceptor(), app.get(LoggingInterceptor));
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import {
|
||||
TestWhitelistService,
|
||||
normalizeTestPhone,
|
||||
} from '../../common/test-whitelist/test-whitelist.service';
|
||||
import { groupResourcesByProductId, mapProductMedia } from './catalog.mapper';
|
||||
|
||||
export type CatalogViewer = {
|
||||
@@ -10,13 +14,32 @@ export type CatalogViewer = {
|
||||
bypassWhitelist?: boolean;
|
||||
};
|
||||
|
||||
function normalizePhone(phone: string | null | undefined): string {
|
||||
return (phone || '').replace(/\D/g, '').trim();
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CatalogService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly testWhitelist: TestWhitelistService,
|
||||
) {}
|
||||
|
||||
private async whitelistPhoneSet(): Promise<Set<string>> {
|
||||
const rows = await this.prisma.commonTestWhitelistPhone.findMany({
|
||||
select: { phone: true },
|
||||
});
|
||||
return new Set(rows.map((r) => normalizeTestPhone(r.phone)).filter(Boolean));
|
||||
}
|
||||
|
||||
isVisibleToViewer(
|
||||
product: { visibilityWhitelistEnabled: boolean },
|
||||
viewer?: CatalogViewer,
|
||||
whitelistPhones?: Set<string>,
|
||||
): boolean {
|
||||
if (viewer?.bypassWhitelist) return true;
|
||||
if (!product.visibilityWhitelistEnabled) return true;
|
||||
const phone = normalizeTestPhone(viewer?.phone);
|
||||
if (!phone) return false;
|
||||
if (whitelistPhones) return whitelistPhones.has(phone);
|
||||
return false;
|
||||
}
|
||||
|
||||
async listCities() {
|
||||
const cities = await this.prisma.commonCity.findMany({
|
||||
@@ -58,11 +81,13 @@ export class CatalogService {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: {
|
||||
coverResource: true,
|
||||
visibilityPhones: { select: { phone: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const visible = products.filter((p) => this.isVisibleToViewer(p, viewer));
|
||||
const whitelistPhones = products.some((p) => p.visibilityWhitelistEnabled)
|
||||
? await this.whitelistPhoneSet()
|
||||
: new Set<string>();
|
||||
const visible = products.filter((p) => this.isVisibleToViewer(p, viewer, whitelistPhones));
|
||||
|
||||
const productIds = visible.map((p) => p.id);
|
||||
const resources = productIds.length
|
||||
@@ -81,7 +106,7 @@ export class CatalogService {
|
||||
return serializeBigInt(
|
||||
visible.map((p) => {
|
||||
const media = mapProductMedia(p, resourceMap.get(p.id.toString()) ?? []);
|
||||
const { visibilityPhones: _phones, visibilityWhitelistEnabled: _wl, ...rest } = p;
|
||||
const { visibilityWhitelistEnabled: _wl, ...rest } = p;
|
||||
return {
|
||||
...rest,
|
||||
benefitAmount: p.benefitAmount ?? p.price,
|
||||
@@ -98,11 +123,13 @@ export class CatalogService {
|
||||
where: { id },
|
||||
include: {
|
||||
coverResource: true,
|
||||
visibilityPhones: { select: { phone: true } },
|
||||
},
|
||||
});
|
||||
if (!product) return null;
|
||||
if (!this.isVisibleToViewer(product, viewer)) {
|
||||
const whitelistPhones = product.visibilityWhitelistEnabled
|
||||
? await this.whitelistPhoneSet()
|
||||
: new Set<string>();
|
||||
if (!this.isVisibleToViewer(product, viewer, whitelistPhones)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -117,7 +144,7 @@ export class CatalogService {
|
||||
});
|
||||
|
||||
const media = mapProductMedia(product, resources);
|
||||
const { visibilityPhones: _phones, visibilityWhitelistEnabled: _wl, ...rest } = product;
|
||||
const { visibilityWhitelistEnabled: _wl, ...rest } = product;
|
||||
return serializeBigInt({
|
||||
...rest,
|
||||
benefitAmount: product.benefitAmount ?? product.price,
|
||||
@@ -126,18 +153,20 @@ export class CatalogService {
|
||||
});
|
||||
}
|
||||
|
||||
/** 下单前校验:白名单商品仅白名单手机号可买 */
|
||||
/** 下单前校验:白名单商品仅全局测试白名单手机号可买 */
|
||||
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 })) {
|
||||
if (product.visibilityWhitelistEnabled) {
|
||||
const ok = await this.testWhitelist.isPhoneInWhitelist(viewerPhone);
|
||||
if (!ok) {
|
||||
throw new BadRequestException('该商品暂不对当前账号开放');
|
||||
}
|
||||
}
|
||||
return product;
|
||||
}
|
||||
|
||||
@@ -148,18 +177,4 @@ export class CatalogService {
|
||||
});
|
||||
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,9 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { parseMiniHomeBanners } from '@dukang/shared-types';
|
||||
import {
|
||||
parseMiniHomeBanners,
|
||||
resolveClientBrandRuntime,
|
||||
resolveMiniShareRuntime,
|
||||
} from '@dukang/shared-types';
|
||||
import { SystemConfigService } from '../../common/system-config/system-config.service';
|
||||
|
||||
@Controller('common')
|
||||
@@ -12,6 +16,8 @@ export class ClientConfigController {
|
||||
const env = this.systemConfig.getMergedEnv();
|
||||
const footer = (env.MINI_HOME_FOOTER_URL ?? '').trim();
|
||||
const minClientVersion = (env.MINI_USER_MIN_VERSION ?? '').trim() || null;
|
||||
const brand = resolveClientBrandRuntime(env);
|
||||
const share = resolveMiniShareRuntime(env);
|
||||
return {
|
||||
mockPay: cfg.mockPay,
|
||||
wechatPayEnabled: cfg.wechatPayEnabled,
|
||||
@@ -25,6 +31,13 @@ export class ClientConfigController {
|
||||
banners: parseMiniHomeBanners(env.MINI_HOME_BANNERS),
|
||||
footerUrl: footer || null,
|
||||
},
|
||||
userH5Url: brand.userH5Url,
|
||||
brandLogoUrl: brand.brandLogoUrl,
|
||||
brandLogoWideUrl: brand.brandLogoWideUrl,
|
||||
brandLogoMarkUrl: brand.brandLogoMarkUrl,
|
||||
qualificationDisclosureUrl: brand.qualificationDisclosureUrl,
|
||||
customerServicePhone: brand.customerServicePhone,
|
||||
share,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,18 +83,13 @@ export class WechatController {
|
||||
return this.wechat.createJssdkConfig(pageUrl, actorRef);
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅去 hash。勿剔除 code/state:
|
||||
* iOS 微信用「document 入场 URL」验签,OAuth 回跳页的 query 必须原样参与签名。
|
||||
*/
|
||||
private normalizeJssdkUrl(rawUrl: string): string {
|
||||
try {
|
||||
const parsed = new URL(rawUrl);
|
||||
parsed.hash = '';
|
||||
parsed.searchParams.delete('code');
|
||||
parsed.searchParams.delete('state');
|
||||
const query = parsed.searchParams.toString();
|
||||
return `${parsed.origin}${parsed.pathname}${query ? `?${query}` : ''}`;
|
||||
} catch {
|
||||
return rawUrl.split('#')[0];
|
||||
}
|
||||
}
|
||||
|
||||
@Get('oauth-url')
|
||||
oauthUrl(
|
||||
|
||||
@@ -25,6 +25,7 @@ import { AnalyticsService } from '../analytics/analytics.service';
|
||||
import { UserAddressService } from './user-address.service';
|
||||
import { ResourceService } from '../common/resource.service';
|
||||
import { HqPermissionsResolver } from '../../common/guards/hq-permission.guard';
|
||||
import { TestWhitelistService } from '../../common/test-whitelist/test-whitelist.service';
|
||||
|
||||
import type { User } from '@prisma/client';
|
||||
|
||||
@@ -67,8 +68,21 @@ export class AuthService {
|
||||
private readonly userAddressService: UserAddressService,
|
||||
@Inject(forwardRef(() => ResourceService)) private readonly resourceService: ResourceService,
|
||||
private readonly hqPermissions: HqPermissionsResolver,
|
||||
private readonly testWhitelist: TestWhitelistService,
|
||||
) {}
|
||||
|
||||
/** 登录/绑号后按全局白名单同步 isTest */
|
||||
private async syncTestFlagByPhone(phone: string) {
|
||||
const isTest = await this.testWhitelist.isPhoneInWhitelist(phone);
|
||||
await Promise.all([
|
||||
this.prisma.user.updateMany({ where: { phone }, data: { isTest } }),
|
||||
this.prisma.storeAccount.updateMany({ where: { phone }, data: { isTest } }),
|
||||
this.prisma.partnerAccount.updateMany({ where: { phone }, data: { isTest } }),
|
||||
this.prisma.store.updateMany({ where: { phone }, data: { isTest } }),
|
||||
]);
|
||||
return isTest;
|
||||
}
|
||||
|
||||
private assertMobilePhone(phone: string) {
|
||||
const trimmed = phone.trim();
|
||||
if (!/^1[3-9]\d{9}$/.test(trimmed)) {
|
||||
@@ -375,12 +389,14 @@ export class AuthService {
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
const isTest = await this.testWhitelist.isPhoneInWhitelist(normalizedPhone);
|
||||
user = await this.prisma.user.create({
|
||||
data: {
|
||||
phone: normalizedPhone,
|
||||
phoneVerifiedAt: new Date(),
|
||||
userNo: generateUserNo(),
|
||||
nickname: `用户${normalizedPhone.slice(-4)}`,
|
||||
isTest,
|
||||
sourceType: source?.sourceType ?? 'ORGANIC',
|
||||
sourceRefId: source?.sourceRefId,
|
||||
sourceLabel: source?.sourceLabel,
|
||||
@@ -401,6 +417,11 @@ export class AuthService {
|
||||
include: { avatar: true },
|
||||
});
|
||||
}
|
||||
await this.syncTestFlagByPhone(normalizedPhone);
|
||||
user = await this.prisma.user.findUniqueOrThrow({
|
||||
where: { id: user.id },
|
||||
include: { avatar: true },
|
||||
});
|
||||
await this.assertActiveUser(user.id);
|
||||
}
|
||||
|
||||
@@ -810,12 +831,14 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
if (!user) {
|
||||
const isTest = await this.testWhitelist.isPhoneInWhitelist(normalizedPhone);
|
||||
user = await this.prisma.user.create({
|
||||
data: {
|
||||
phone: normalizedPhone,
|
||||
phoneVerifiedAt: new Date(),
|
||||
userNo: generateUserNo(),
|
||||
nickname: `用户${normalizedPhone.slice(-4)}`,
|
||||
isTest,
|
||||
cityPreference: {
|
||||
create: {
|
||||
selectedCityCode: '410100',
|
||||
@@ -852,6 +875,12 @@ export class AuthService {
|
||||
|
||||
if (!user) throw new BadRequestException('登录失败');
|
||||
|
||||
await this.syncTestFlagByPhone(normalizedPhone);
|
||||
user = await this.prisma.user.findUniqueOrThrow({
|
||||
where: { id: user.id },
|
||||
include: { avatar: true },
|
||||
});
|
||||
|
||||
this.analyticsService.trackOneSafe(user.id, clientApp, {
|
||||
eventName: method === 'sms' ? 'sms_login' : 'wechat_phone_login',
|
||||
extraJson: { method },
|
||||
@@ -998,6 +1027,7 @@ export class AuthService {
|
||||
if (!account) throw new BadRequestException('该手机号未绑定门店');
|
||||
if (account.status !== 'ACTIVE') throw new BadRequestException('门店账号已停用');
|
||||
if (!account.bindings.length) throw new BadRequestException('该账号未绑定任何门店');
|
||||
await this.syncTestFlagByPhone(normalizedPhone);
|
||||
await this.prisma.storeAccount.update({
|
||||
where: { id: account.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
@@ -1032,6 +1062,7 @@ export class AuthService {
|
||||
});
|
||||
if (!account) throw new BadRequestException('未找到合伙人账号');
|
||||
if (account.status !== 'ACTIVE') throw new BadRequestException('合伙人账号已停用');
|
||||
await this.syncTestFlagByPhone(normalizedPhone);
|
||||
const primary = await this.resolvePrimaryAccount(account.id);
|
||||
await this.prisma.partnerAccount.update({
|
||||
where: { id: account.id },
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
HQ_DANGEROUS_PERMISSION_KEYS,
|
||||
HQ_PERMISSION_CATALOG,
|
||||
HQ_ROLE_DEFAULT_PERMISSIONS,
|
||||
LEGACY_SYSTEM_SETTINGS_KEY,
|
||||
expandHqPermissionKeys,
|
||||
hqBasePermissionKeys,
|
||||
type HqPermissionKey,
|
||||
} from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
@@ -83,10 +81,9 @@ export class AdminHqPermissionsService {
|
||||
const userPermissionKeys = expandHqPermissionKeys(userPerms.map((p) => p.permissionKey));
|
||||
|
||||
if (account.adminRole === 'SUPER_ADMIN') {
|
||||
const rolePermissionKeys = [
|
||||
...hqBasePermissionKeys(),
|
||||
...HQ_DANGEROUS_PERMISSION_KEYS,
|
||||
] as HqPermissionKey[];
|
||||
const rolePermissionKeys = HQ_PERMISSION_CATALOG.map(
|
||||
(p) => p.key,
|
||||
) as HqPermissionKey[];
|
||||
const effectivePermissionKeys = [
|
||||
...new Set([...rolePermissionKeys, ...userPermissionKeys]),
|
||||
] as HqPermissionKey[];
|
||||
|
||||
@@ -38,6 +38,12 @@ export class AdminOrdersController {
|
||||
return this.ordersService.getShipDefaults();
|
||||
}
|
||||
|
||||
/** 物流路由(小飞侠实时轨迹,对齐 C 端 GET /trade/orders/:id/track) */
|
||||
@Get(':id/track')
|
||||
track(@Param('id') id: string) {
|
||||
return this.ordersService.getOrderTrack(BigInt(id));
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@Param('id') id: string) {
|
||||
return this.ordersService.detail(BigInt(id));
|
||||
|
||||
@@ -43,6 +43,7 @@ export class AdminOrdersService {
|
||||
if (query.createdFrom) where.createdAt.gte = new Date(query.createdFrom);
|
||||
if (query.createdTo) where.createdAt.lte = new Date(query.createdTo);
|
||||
}
|
||||
if (query.excludeTest) where.isTest = false;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.order.findMany({
|
||||
@@ -63,6 +64,15 @@ export class AdminOrdersService {
|
||||
return serializeBigInt({ items, total, page, pageSize });
|
||||
}
|
||||
|
||||
async getOrderTrack(id: bigint) {
|
||||
const order = await this.prisma.order.findUnique({
|
||||
where: { id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
return serializeBigInt(await this.fulfillmentService.getOrderTrack(id));
|
||||
}
|
||||
|
||||
async detail(id: bigint) {
|
||||
const order = await this.prisma.order.findUnique({
|
||||
where: { id },
|
||||
|
||||
@@ -42,6 +42,7 @@ export class AdminPartnersService {
|
||||
if (query.phone) where.phone = { contains: query.phone };
|
||||
if (query.cityId) where.cityId = BigInt(query.cityId);
|
||||
if (query.partnerId) where.id = BigInt(query.partnerId);
|
||||
if (query.excludeTest) where.isTest = false;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.partnerAccount.findMany({
|
||||
|
||||
@@ -5,6 +5,7 @@ import { serializeBigInt } from '../../common/decorators/current-user.decorator'
|
||||
import { groupResourcesByProductId, mapProductMedia } from '../catalog/catalog.mapper';
|
||||
import type { AdminProductsQueryDto } from './dto/admin-query.dto';
|
||||
import type { CreateProductDto, UpdateProductDto } from './dto/admin-mutate.dto';
|
||||
import { TestWhitelistService } from '../../common/test-whitelist/test-whitelist.service';
|
||||
|
||||
function normalizePhones(phones?: string[]): string[] {
|
||||
if (!phones?.length) return [];
|
||||
@@ -57,7 +58,10 @@ function resolveFulfillmentFlags(input: {
|
||||
|
||||
@Injectable()
|
||||
export class AdminProductsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly testWhitelist: TestWhitelistService,
|
||||
) {}
|
||||
|
||||
async list(query: AdminProductsQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
@@ -140,6 +144,11 @@ export class AdminProductsService {
|
||||
|
||||
const phones = normalizePhones(dto.visibilityPhones);
|
||||
const whitelistEnabled = !!dto.visibilityWhitelistEnabled;
|
||||
if (whitelistEnabled) {
|
||||
await this.testWhitelist.assertGlobalWhitelistNotEmpty();
|
||||
}
|
||||
// 手机号统一在「白名单管理」维护;此处忽略分实体 phones(兼容旧客户端传参)
|
||||
void phones;
|
||||
|
||||
const product = await this.createWithGeneratedSku({
|
||||
barcode69: dto.barcode69,
|
||||
@@ -158,13 +167,6 @@ export class AdminProductsService {
|
||||
...(dto.detailContent !== undefined
|
||||
? { detailContent: dto.detailContent as Prisma.InputJsonValue }
|
||||
: {}),
|
||||
...(phones.length
|
||||
? {
|
||||
visibilityPhones: {
|
||||
create: phones.map((phone) => ({ phone })),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
if (dto.coverUrl) {
|
||||
@@ -225,9 +227,10 @@ export class AdminProductsService {
|
||||
},
|
||||
});
|
||||
|
||||
if (dto.visibilityPhones !== undefined) {
|
||||
await this.syncVisibilityPhones(id, normalizePhones(dto.visibilityPhones));
|
||||
if (dto.visibilityWhitelistEnabled) {
|
||||
await this.testWhitelist.assertGlobalWhitelistNotEmpty();
|
||||
}
|
||||
// 分实体手机号已废弃;忽略 dto.visibilityPhones
|
||||
|
||||
if (dto.coverUrl) {
|
||||
await this.syncCover(id, dto.coverUrl);
|
||||
|
||||
@@ -42,6 +42,7 @@ export class AdminRedeemService {
|
||||
if (query.channel === 'SCAN' || query.channel === 'PHONE') {
|
||||
where.channel = query.channel;
|
||||
}
|
||||
if (query.excludeTest) where.isTest = false;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.redeemRecord.findMany({
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
UpdateStoreMediaDto,
|
||||
UpdateStoreStatusDto,
|
||||
} from './dto/admin-mutate.dto';
|
||||
import { TestWhitelistService } from '../../common/test-whitelist/test-whitelist.service';
|
||||
|
||||
/** 选填文案:空 / null / "null" 一律存库为 null,避免 String(null)==="null" */
|
||||
function normalizeStoreOptionalText(value: unknown): string | null {
|
||||
@@ -51,6 +52,7 @@ export class AdminStoresService {
|
||||
private readonly partnerCityService: PartnerCityService,
|
||||
private readonly storeCategoryService: StoreCategoryService,
|
||||
private readonly analyticsService: AnalyticsService,
|
||||
private readonly testWhitelist: TestWhitelistService,
|
||||
) {}
|
||||
|
||||
async listStores(query: AdminStoresQueryDto) {
|
||||
@@ -65,6 +67,7 @@ export class AdminStoresService {
|
||||
if (query.cityId) where.cityId = BigInt(query.cityId);
|
||||
if (query.partnerId) where.partnerAccountId = BigInt(query.partnerId);
|
||||
if (query.phone) where.phone = { contains: query.phone };
|
||||
if (query.excludeTest) where.isTest = false;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.store.findMany({
|
||||
@@ -283,18 +286,7 @@ export class AdminStoresService {
|
||||
? !!dto.visibilityWhitelistEnabled
|
||||
: current.visibilityWhitelistEnabled;
|
||||
if (nextEnabled) {
|
||||
const phones =
|
||||
dto.visibilityPhones !== undefined
|
||||
? normalizeVisibilityPhones(dto.visibilityPhones)
|
||||
: (
|
||||
await this.prisma.storeVisibilityPhone.findMany({
|
||||
where: { storeId: id },
|
||||
select: { phone: true },
|
||||
})
|
||||
).map((p) => p.phone);
|
||||
if (!phones.length) {
|
||||
throw new BadRequestException('开启白名单时请至少添加一个手机号');
|
||||
}
|
||||
await this.testWhitelist.assertGlobalWhitelistNotEmpty();
|
||||
}
|
||||
}
|
||||
const bankTouched =
|
||||
@@ -372,18 +364,13 @@ export class AdminStoresService {
|
||||
...(dto.visibilityWhitelistEnabled !== undefined
|
||||
? { visibilityWhitelistEnabled: !!dto.visibilityWhitelistEnabled }
|
||||
: {}),
|
||||
...(dto.isTest !== undefined ? { isTest: !!dto.isTest } : {}),
|
||||
...(latitude != null && longitude != null ? { latitude, longitude } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (dto.visibilityPhones !== undefined) {
|
||||
const phones = normalizeVisibilityPhones(dto.visibilityPhones);
|
||||
await tx.storeVisibilityPhone.deleteMany({ where: { storeId: id } });
|
||||
if (phones.length) {
|
||||
await tx.storeVisibilityPhone.createMany({
|
||||
data: phones.map((phone) => ({ storeId: id, phone })),
|
||||
});
|
||||
}
|
||||
// 分实体手机号已废弃,忽略写入
|
||||
}
|
||||
|
||||
if (dto.coverUrl) {
|
||||
@@ -505,11 +492,14 @@ export class AdminStoresService {
|
||||
throw new BadRequestException('好客权益券使用规则最多 1000 字');
|
||||
}
|
||||
|
||||
const visibilityPhones = normalizeVisibilityPhones(dto.visibilityPhones);
|
||||
const whitelistEnabled = !!dto.visibilityWhitelistEnabled;
|
||||
if (whitelistEnabled && !visibilityPhones.length) {
|
||||
throw new BadRequestException('开启白名单时请至少添加一个手机号');
|
||||
if (whitelistEnabled) {
|
||||
await this.testWhitelist.assertGlobalWhitelistNotEmpty();
|
||||
}
|
||||
const isTest =
|
||||
dto.isTest !== undefined
|
||||
? !!dto.isTest
|
||||
: await this.testWhitelist.isPhoneInWhitelist(normalizedPhone);
|
||||
|
||||
const store = await this.prisma.store.create({
|
||||
data: {
|
||||
@@ -531,18 +521,12 @@ export class AdminStoresService {
|
||||
openTime2: openTime2 || null,
|
||||
closeTime2: closeTime2 || null,
|
||||
visibilityWhitelistEnabled: whitelistEnabled,
|
||||
isTest,
|
||||
...(latitude != null && longitude != null ? { latitude, longitude } : {}),
|
||||
status: 'OPEN',
|
||||
auditStatus: 'APPROVED',
|
||||
auditedAt: new Date(),
|
||||
rejectReason: null,
|
||||
...(visibilityPhones.length
|
||||
? {
|
||||
visibilityPhones: {
|
||||
create: visibilityPhones.map((phone) => ({ phone })),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -746,6 +730,7 @@ export class AdminStoresService {
|
||||
where.bindings = { some: { storeId: BigInt(query.storeId) } };
|
||||
}
|
||||
if (query.status) where.status = query.status as Prisma.EnumAccountStatusFilter['equals'];
|
||||
if (query.excludeTest) where.isTest = false;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.storeAccount.findMany({
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import {
|
||||
HqPermissionGuard,
|
||||
RequireHqPermissions,
|
||||
} from '../../common/guards/hq-permission.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { TestWhitelistService } from '../../common/test-whitelist/test-whitelist.service';
|
||||
import { SystemConfigService } from '../../common/system-config/system-config.service';
|
||||
|
||||
class ListPhonesQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
pageSize?: number = 20;
|
||||
}
|
||||
|
||||
class AddPhoneDto {
|
||||
@IsString()
|
||||
phone!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(256)
|
||||
note?: string;
|
||||
}
|
||||
|
||||
class UpdatePhoneDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(256)
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
class ListAccountsQueryDto {
|
||||
@IsIn(['user', 'store_account', 'partner', 'store', 'order'])
|
||||
type!: 'user' | 'store_account' | 'partner' | 'store' | 'order';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
pageSize?: number = 20;
|
||||
}
|
||||
|
||||
class UpdateMockFlagsDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
mockSms?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
mockWechat?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
mockPay?: boolean;
|
||||
}
|
||||
|
||||
@Controller('admin/test-whitelist')
|
||||
@UseGuards(HqAuthGuard, HqPermissionGuard)
|
||||
@RequireHqPermissions('test_whitelist')
|
||||
export class AdminTestWhitelistController {
|
||||
constructor(
|
||||
private readonly testWhitelist: TestWhitelistService,
|
||||
private readonly systemConfig: SystemConfigService,
|
||||
) {}
|
||||
|
||||
@Get('mock-flags')
|
||||
async getMockFlags() {
|
||||
const form = await this.systemConfig.getForm(['feature']);
|
||||
const v = form.values;
|
||||
return {
|
||||
mockSms: v.MOCK_SMS === 'true' || v.MOCK_SMS === '1',
|
||||
mockWechat: v.MOCK_WECHAT === 'true' || v.MOCK_WECHAT === '1',
|
||||
mockPay: v.MOCK_PAY === 'true' || v.MOCK_PAY === '1',
|
||||
};
|
||||
}
|
||||
|
||||
@Put('mock-flags')
|
||||
async updateMockFlags(@Body() dto: UpdateMockFlagsDto) {
|
||||
const values: Record<string, string> = {};
|
||||
if (dto.mockSms !== undefined) values.MOCK_SMS = String(dto.mockSms);
|
||||
if (dto.mockWechat !== undefined) values.MOCK_WECHAT = String(dto.mockWechat);
|
||||
if (dto.mockPay !== undefined) values.MOCK_PAY = String(dto.mockPay);
|
||||
if (Object.keys(values).length) {
|
||||
await this.systemConfig.update({ values }, ['feature']);
|
||||
}
|
||||
return this.getMockFlags();
|
||||
}
|
||||
|
||||
@Get('phones')
|
||||
listPhones(@Query() query: ListPhonesQueryDto) {
|
||||
return this.testWhitelist.listPhones(query);
|
||||
}
|
||||
|
||||
@Post('phones')
|
||||
addPhone(@Body() dto: AddPhoneDto, @CurrentUser() actor: AuthUser) {
|
||||
return this.testWhitelist.addPhone({
|
||||
phone: dto.phone,
|
||||
note: dto.note,
|
||||
createdByHqId: actor.actorId,
|
||||
});
|
||||
}
|
||||
|
||||
@Patch('phones/:id')
|
||||
updatePhone(@Param('id') id: string, @Body() dto: UpdatePhoneDto) {
|
||||
return this.testWhitelist.updatePhone(BigInt(id), { note: dto.note });
|
||||
}
|
||||
|
||||
@Delete('phones/:id')
|
||||
removePhone(@Param('id') id: string) {
|
||||
return this.testWhitelist.removePhone(BigInt(id));
|
||||
}
|
||||
|
||||
@Get('accounts')
|
||||
listAccounts(@Query() query: ListAccountsQueryDto) {
|
||||
return this.testWhitelist.listAccounts(query);
|
||||
}
|
||||
|
||||
@Get('phones/:id/linked')
|
||||
linked(@Param('id') id: string) {
|
||||
return this.testWhitelist.linkedForPhoneId(BigInt(id));
|
||||
}
|
||||
|
||||
@Post('migrate-visibility')
|
||||
migrate(@CurrentUser() actor: AuthUser) {
|
||||
return this.testWhitelist.migrateVisibilityPhones(actor.actorId);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ function mapAdminUserRow(u: {
|
||||
sourceType: string;
|
||||
sourceRefId: bigint | null;
|
||||
sourceLabel: string | null;
|
||||
isTest?: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
_count: { orders: number };
|
||||
@@ -37,6 +38,7 @@ function mapAdminUserRow(u: {
|
||||
sourceType: u.sourceType,
|
||||
sourceRefId: u.sourceRefId,
|
||||
sourceLabel: u.sourceLabel,
|
||||
isTest: !!u.isTest,
|
||||
createdAt: u.createdAt,
|
||||
updatedAt: u.updatedAt,
|
||||
orderCount: u._count.orders,
|
||||
@@ -58,6 +60,7 @@ export class AdminUsersService {
|
||||
if (query.status !== undefined) where.status = query.status;
|
||||
if (query.phoneVerified === '1') where.phoneVerifiedAt = { not: null };
|
||||
if (query.phoneVerified === '0') where.phoneVerifiedAt = null;
|
||||
if (query.excludeTest) where.isTest = false;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.user.findMany({
|
||||
@@ -78,6 +81,7 @@ export class AdminUsersService {
|
||||
sourceType: true,
|
||||
sourceRefId: true,
|
||||
sourceLabel: true,
|
||||
isTest: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
_count: { select: { orders: true } },
|
||||
|
||||
@@ -128,16 +128,21 @@ export class CreateStoreDto {
|
||||
@Min(0)
|
||||
avgPrice?: number;
|
||||
|
||||
/** 开启后仅白名单手机号在 C 端可见 */
|
||||
/** 开启后仅全局测试白名单手机号在 C 端可见 */
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
visibilityWhitelistEnabled?: boolean;
|
||||
|
||||
/** 可见白名单手机号列表 */
|
||||
/** @deprecated 已并入全局白名单,忽略 */
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
visibilityPhones?: string[];
|
||||
|
||||
/** 测试门店标记 */
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isTest?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateStoreDto {
|
||||
@@ -227,16 +232,21 @@ export class UpdateStoreDto {
|
||||
@IsString()
|
||||
bankBranch?: string | null;
|
||||
|
||||
/** 开启后仅白名单手机号在 C 端可见 */
|
||||
/** 开启后仅全局测试白名单手机号在 C 端可见 */
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
visibilityWhitelistEnabled?: boolean;
|
||||
|
||||
/** 可见白名单手机号列表 */
|
||||
/** @deprecated 已并入全局白名单,忽略 */
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
visibilityPhones?: string[];
|
||||
|
||||
/** 测试门店标记 */
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isTest?: boolean;
|
||||
}
|
||||
|
||||
export class CreateStoreAccountDto {
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
import { Type, Transform } from 'class-transformer';
|
||||
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
|
||||
function toOptionalBoolean(value: unknown): boolean | undefined {
|
||||
if (value === undefined || value === null || value === '') return undefined;
|
||||
if (value === true || value === 'true' || value === '1' || value === 1) return true;
|
||||
if (value === false || value === 'false' || value === '0' || value === 0) return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export class PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@@ -37,6 +44,12 @@ export class AdminUsersQueryDto extends PaginationQueryDto {
|
||||
@Type(() => Number)
|
||||
@IsIn([0, 1])
|
||||
status?: number;
|
||||
|
||||
/** 勾选「过滤测试账号」时传 true */
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => toOptionalBoolean(value))
|
||||
@IsBoolean()
|
||||
excludeTest?: boolean;
|
||||
}
|
||||
|
||||
export class AdminOrdersQueryDto extends PaginationQueryDto {
|
||||
@@ -75,6 +88,11 @@ export class AdminOrdersQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
createdTo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => toOptionalBoolean(value))
|
||||
@IsBoolean()
|
||||
excludeTest?: boolean;
|
||||
}
|
||||
|
||||
/** 概览页用户/订单 ECharts 聚合筛选 */
|
||||
@@ -128,6 +146,11 @@ export class AdminStoresQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
auditStatus?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => toOptionalBoolean(value))
|
||||
@IsBoolean()
|
||||
excludeTest?: boolean;
|
||||
}
|
||||
|
||||
export class AdminStoreAccountsQueryDto extends PaginationQueryDto {
|
||||
@@ -142,6 +165,11 @@ export class AdminStoreAccountsQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => toOptionalBoolean(value))
|
||||
@IsBoolean()
|
||||
excludeTest?: boolean;
|
||||
}
|
||||
|
||||
export class AdminPartnersQueryDto extends PaginationQueryDto {
|
||||
@@ -164,6 +192,11 @@ export class AdminPartnersQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
partnerId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => toOptionalBoolean(value))
|
||||
@IsBoolean()
|
||||
excludeTest?: boolean;
|
||||
}
|
||||
|
||||
export class AdminCityWarehousesQueryDto extends PaginationQueryDto {
|
||||
@@ -247,6 +280,11 @@ export class AdminRedeemRecordsQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
channel?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => toOptionalBoolean(value))
|
||||
@IsBoolean()
|
||||
excludeTest?: boolean;
|
||||
}
|
||||
|
||||
export class AdminStoreRatingsQueryDto extends PaginationQueryDto {
|
||||
|
||||
@@ -80,6 +80,7 @@ import { AdminDevPlanController } from '../dev-plan/admin-dev-plan.controller';
|
||||
import { AdminFulfillmentProvidersController } from './admin-fulfillment-providers.controller';
|
||||
import { AdminDomainEventsController } from './admin-domain-events.controller';
|
||||
import { AdminDomainEventsService } from './admin-domain-events.service';
|
||||
import { AdminTestWhitelistController } from './admin-test-whitelist.controller';
|
||||
|
||||
@Module({
|
||||
imports: [CityScopeModule, IamModule, TradeModule, AnalyticsModule, FulfillmentModule, BenefitModule, CommonModule, IntegrationsModule, WecomModule, LlmModule, RedeemModule, StoreModule, DevPlanModule],
|
||||
@@ -128,6 +129,7 @@ import { AdminDomainEventsService } from './admin-domain-events.service';
|
||||
AdminKnowledgeBasesController,
|
||||
AdminDevPlanController,
|
||||
AdminFulfillmentProvidersController,
|
||||
AdminTestWhitelistController,
|
||||
],
|
||||
providers: [
|
||||
AdminDashboardService,
|
||||
|
||||
@@ -180,6 +180,15 @@ export class RedeemService {
|
||||
const settleAmount = calcRedeemSettleAmount(amount, settlementRate);
|
||||
const redeemChannel = analyticsExtra?.channel === 'phone' ? 'PHONE' : 'SCAN';
|
||||
|
||||
const [userRow, storeRow] = await Promise.all([
|
||||
this.prisma.user.findUnique({ where: { id: userId }, select: { isTest: true } }),
|
||||
this.prisma.store.findUnique({
|
||||
where: { id: account.storeId },
|
||||
select: { isTest: true },
|
||||
}),
|
||||
]);
|
||||
const isTest = !!(userRow?.isTest || storeRow?.isTest);
|
||||
|
||||
let record;
|
||||
try {
|
||||
record = await this.prisma.$transaction(async (tx) => {
|
||||
@@ -194,6 +203,7 @@ export class RedeemService {
|
||||
amount,
|
||||
settleAmount,
|
||||
channel: redeemChannel,
|
||||
isTest,
|
||||
allocations: {
|
||||
create: normalizedAllocations.map((item, index) => ({
|
||||
couponId: BigInt(item.couponId),
|
||||
@@ -204,6 +214,7 @@ export class RedeemService {
|
||||
},
|
||||
});
|
||||
|
||||
if (!isTest) {
|
||||
await this.settlementService.createStorePayout(
|
||||
redeemRecord.id,
|
||||
account.storeId,
|
||||
@@ -212,6 +223,7 @@ export class RedeemService {
|
||||
settlementRate,
|
||||
tx,
|
||||
);
|
||||
}
|
||||
|
||||
return redeemRecord;
|
||||
});
|
||||
|
||||
@@ -1322,9 +1322,12 @@ export class SettlementService {
|
||||
cityId: primary.cityId,
|
||||
payStatus: 'PAID',
|
||||
paidAt: { gte: periodStart, lte: periodEnd },
|
||||
isTest: false,
|
||||
},
|
||||
});
|
||||
const orderCommission = orders.reduce((sum, o) => {
|
||||
const orderCommission = primary.isTest
|
||||
? 0
|
||||
: orders.reduce((sum, o) => {
|
||||
if (o.partnerAccountIdAtPay) {
|
||||
if (o.partnerAccountIdAtPay !== primary.id) return sum;
|
||||
const rate = o.orderCommissionRateAtPay != null ? Number(o.orderCommissionRateAtPay) : 0;
|
||||
@@ -1338,14 +1341,16 @@ export class SettlementService {
|
||||
select: { id: true },
|
||||
});
|
||||
const storeIds = stores.map((s) => s.id);
|
||||
const redeems = storeIds.length
|
||||
? await this.prisma.redeemRecord.findMany({
|
||||
const redeems =
|
||||
primary.isTest || !storeIds.length
|
||||
? []
|
||||
: await this.prisma.redeemRecord.findMany({
|
||||
where: {
|
||||
storeId: { in: storeIds },
|
||||
createdAt: { gte: periodStart, lte: periodEnd },
|
||||
isTest: false,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
});
|
||||
const redeemCommission = redeems.reduce(
|
||||
(sum, r) => sum + Number(r.amount) * redeemCommissionRate,
|
||||
0,
|
||||
@@ -1588,6 +1593,7 @@ export class SettlementService {
|
||||
payStatus: 'PAID',
|
||||
deliveryType: { in: ['LOCAL', 'CROSS_CITY'] },
|
||||
completedAt: { gte: start, lt: end },
|
||||
isTest: false,
|
||||
},
|
||||
orderBy: { completedAt: 'asc' },
|
||||
});
|
||||
@@ -1921,6 +1927,7 @@ export class SettlementService {
|
||||
quantity: true,
|
||||
deliveryType: true,
|
||||
payStatus: true,
|
||||
isTest: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1929,6 +1936,7 @@ export class SettlementService {
|
||||
|
||||
const eligible = deliveries.filter(
|
||||
(d) =>
|
||||
!d.order.isTest &&
|
||||
d.order.payStatus === 'PAID' &&
|
||||
(d.order.deliveryType === 'LOCAL' || d.order.deliveryType === 'CROSS_CITY'),
|
||||
);
|
||||
|
||||
@@ -16,6 +16,10 @@ import { PartnerCityService } from '../city-scope/partner-city.service';
|
||||
import { AuthService } from '../iam/auth.service';
|
||||
import { StoreCategoryService } from './store-category.service';
|
||||
import { TencentLbsProvider } from '../../integrations/map/tencent-lbs.provider';
|
||||
import {
|
||||
TestWhitelistService,
|
||||
normalizeTestPhone,
|
||||
} from '../../common/test-whitelist/test-whitelist.service';
|
||||
|
||||
function haversineMeters(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
||||
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||
@@ -43,10 +47,6 @@ export type StoreViewer = {
|
||||
bypassWhitelist?: boolean;
|
||||
};
|
||||
|
||||
function normalizePhone(phone: string | null | undefined): string {
|
||||
return (phone || '').replace(/\D/g, '').trim();
|
||||
}
|
||||
|
||||
function parseOptionalCoord(value: unknown, kind: 'lat' | 'lng' = 'lng'): number | null {
|
||||
if (value == null || value === '') return null;
|
||||
const n = typeof value === 'number' ? value : Number(value);
|
||||
@@ -67,8 +67,16 @@ export class StoreService {
|
||||
private readonly authService: AuthService,
|
||||
private readonly storeCategoryService: StoreCategoryService,
|
||||
private readonly tencentLbs: TencentLbsProvider,
|
||||
private readonly testWhitelist: TestWhitelistService,
|
||||
) {}
|
||||
|
||||
private async whitelistPhoneSet(): Promise<Set<string>> {
|
||||
const rows = await this.prisma.commonTestWhitelistPhone.findMany({
|
||||
select: { phone: true },
|
||||
});
|
||||
return new Set(rows.map((r) => normalizeTestPhone(r.phone)).filter(Boolean));
|
||||
}
|
||||
|
||||
private storeAddressText(store: {
|
||||
province?: string | null;
|
||||
cityName?: string | null;
|
||||
@@ -116,17 +124,16 @@ export class StoreService {
|
||||
}
|
||||
|
||||
isVisibleToViewer(
|
||||
store: {
|
||||
visibilityWhitelistEnabled: boolean;
|
||||
visibilityPhones: Array<{ phone: string }>;
|
||||
},
|
||||
store: { visibilityWhitelistEnabled: boolean },
|
||||
viewer?: StoreViewer,
|
||||
whitelistPhones?: Set<string>,
|
||||
): boolean {
|
||||
if (viewer?.bypassWhitelist) return true;
|
||||
if (!store.visibilityWhitelistEnabled) return true;
|
||||
const phone = normalizePhone(viewer?.phone);
|
||||
const phone = normalizeTestPhone(viewer?.phone);
|
||||
if (!phone) return false;
|
||||
return store.visibilityPhones.some((row) => normalizePhone(row.phone) === phone);
|
||||
if (whitelistPhones) return whitelistPhones.has(phone);
|
||||
return false;
|
||||
}
|
||||
|
||||
async listOpenStores(
|
||||
@@ -145,12 +152,14 @@ export class StoreService {
|
||||
include: {
|
||||
category: true,
|
||||
coverResource: true,
|
||||
visibilityPhones: { select: { phone: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const visible = stores.filter((s) => this.isVisibleToViewer(s, viewer));
|
||||
const whitelistPhones = stores.some((s) => s.visibilityWhitelistEnabled)
|
||||
? await this.whitelistPhoneSet()
|
||||
: new Set<string>();
|
||||
const visible = stores.filter((s) => this.isVisibleToViewer(s, viewer, whitelistPhones));
|
||||
|
||||
const hasUser =
|
||||
userLat != null &&
|
||||
@@ -167,7 +176,7 @@ export class StoreService {
|
||||
const items: StoreListItem[] = [];
|
||||
for (const store of visible) {
|
||||
const coords = await this.ensureStoreCoordinates(store);
|
||||
const { visibilityPhones: _phones, visibilityWhitelistEnabled: _wl, ...rest } = store;
|
||||
const { visibilityWhitelistEnabled: _wl, ...rest } = store;
|
||||
const mapped = mapStoreCompat({
|
||||
...rest,
|
||||
latitude: coords?.latitude ?? store.latitude,
|
||||
@@ -197,10 +206,12 @@ export class StoreService {
|
||||
include: {
|
||||
category: true,
|
||||
coverResource: true,
|
||||
visibilityPhones: { select: { phone: true } },
|
||||
},
|
||||
});
|
||||
if (!store || !this.isVisibleToViewer(store, viewer)) {
|
||||
const whitelistPhones = store?.visibilityWhitelistEnabled
|
||||
? await this.whitelistPhoneSet()
|
||||
: new Set<string>();
|
||||
if (!store || !this.isVisibleToViewer(store, viewer, whitelistPhones)) {
|
||||
throw new NotFoundException('门店不存在');
|
||||
}
|
||||
const coords = await this.ensureStoreCoordinates(store);
|
||||
@@ -212,7 +223,7 @@ export class StoreService {
|
||||
where: { storeId: id },
|
||||
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
const { visibilityPhones: _phones, visibilityWhitelistEnabled: _wl, ...rest } = store;
|
||||
const { visibilityWhitelistEnabled: _wl, ...rest } = store;
|
||||
return serializeBigInt(
|
||||
mapStoreCompat({
|
||||
...rest,
|
||||
|
||||
@@ -218,6 +218,11 @@ export class TradeService {
|
||||
const promoCodeId =
|
||||
attribution?.promoCode?.status === 'ACTIVE' ? attribution.promoCodeId : undefined;
|
||||
|
||||
const buyer = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { isTest: true },
|
||||
});
|
||||
|
||||
const order = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.order.create({
|
||||
data: {
|
||||
@@ -258,6 +263,7 @@ export class TradeService {
|
||||
benefitAmount: preview.benefitAmount,
|
||||
payExpireAt,
|
||||
promoCodeId,
|
||||
isTest: !!buyer?.isTest,
|
||||
},
|
||||
include: { product: true, imageResource: true },
|
||||
});
|
||||
|
||||
+24
-3796
File diff suppressed because it is too large
Load Diff
+28
-422
@@ -1,435 +1,41 @@
|
||||
# 杜康好客 · preV1 编码手册(Mock 联调版)
|
||||
# 杜康好客 · preV1 编码手册(Mock 联调)
|
||||
|
||||
> **V3 交付提示**:preV1 不再作为交付验收标准。核销规则以 [`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md) §3 为准(无 ¥500 上限)。
|
||||
> **非 V3 验收标准**。核销等规则以 [`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md) 为准。
|
||||
> 原则:**同 V2 库表与 API 路径**,用 Mock/Flag/Seed 跳过外部依赖。
|
||||
|
||||
> **版本**:preV1(在 V2 完整规格之上的**裁剪实现阶段**)
|
||||
> **完整规格(V2)**:[`杜康好客-V2编码手册.md`](./杜康好客-V2编码手册.md) — PRD、DB v3.1、API、任务卡均以 V2 为准
|
||||
> **原则**:**不删表、不删 V2 API 路径、不改 V2 字段语义**;preV1 用 Mock / Feature Flag / Seed 跳过外部依赖,开关关闭即切回 V2 真实流程。
|
||||
## 六条裁剪
|
||||
|
||||
---
|
||||
| # | 裁剪 | 替代 |
|
||||
|---|------|------|
|
||||
| 1 | 无 HQ 前端 | `/admin/*` 脚本或后续 admin-web |
|
||||
| 2 | 三端均 H5 | `h5-user` / `h5-shop` / `h5-partner` |
|
||||
| 3 | 登录 | 手机号 + 固定码 `999888` |
|
||||
| 4 | 支付 | Mock 即时成功 |
|
||||
| 5 | 配送 | Mock 状态推进 |
|
||||
| 6 | 短信 | Mock |
|
||||
|
||||
## 文档索引
|
||||
## Feature Flag(`.env`)
|
||||
|
||||
| 章节 | 内容 |
|
||||
| Flag | 作用 |
|
||||
|------|------|
|
||||
| §0 | 版本关系(preV1 vs V2) |
|
||||
| §1 | preV1 范围:六条裁剪规则 |
|
||||
| §2 | 三端 H5 与仓库结构 |
|
||||
| §3 | 认证 Mock(无微信) |
|
||||
| §4 | 支付 Mock |
|
||||
| §5 | 配送与订单状态 Mock |
|
||||
| §6 | 无总部端:能力替代 |
|
||||
| §7 | preV1 功能清单 |
|
||||
| §8 | Feature Flag 与 V2 切换 |
|
||||
| §9 | preV1 里程碑与任务卡 |
|
||||
| §10 | API / 数据行为差异速查 |
|
||||
| `MOCK_SMS` | 验证码 999888 |
|
||||
| `MOCK_PAY` | 支付同步成功 |
|
||||
| `MOCK_DELIVERY` | 配送 Mock 推进 |
|
||||
|
||||
---
|
||||
|
||||
# §0、版本关系
|
||||
|
||||
| 维度 | preV1(本文) | V2(完整版) |
|
||||
|------|---------------|--------------|
|
||||
| 定位 | 三端 H5 联调,跑通购酒→发券→核销主链路 | 四端小程序/H5 + 真实微信/物流/支付 |
|
||||
| 客户端 | **用户 / 门店 / 合伙人 均为 H5** | C端+合伙人+总部小程序,门店 H5 |
|
||||
| 总部 HQ | **无独立端** | `pages/hq/` + AdminAuth |
|
||||
| 登录 | **仅手机号 + 固定验证码** | 短信 + 微信授权/绑定 |
|
||||
| 支付 | **Mock 即时成功** | 微信 JSAPI + 回调 |
|
||||
| 配送 | **Mock 状态推进** | 小飞侠/物流回调 |
|
||||
| 数据库 | **同 V2 v3.1(28 表)** | 同左 |
|
||||
| API 路径 | **同 V2 §六**(行为可 Mock) | 完整实现 |
|
||||
|
||||
**升级路径**:preV1 完成后,按 §8 逐项打开 Flag、补小程序端与 HQ 端,**无需重构表结构**。
|
||||
|
||||
---
|
||||
|
||||
# §1、preV1 范围:六条裁剪规则
|
||||
|
||||
### 1.1 删掉 HQ 端
|
||||
|
||||
- **不做**:`apps/mini-hq`、`pages/hq/` 任何页面、AdminAuth 前端。
|
||||
- **保留**:`hq_account` 表、§六 全部 `/admin/*` 路由(preV1 可不实现 Controller,或仅内部/脚本调用)。
|
||||
- **替代**:见 §6。
|
||||
|
||||
### 1.2 用户 / 门店 / 合伙人 均改为 H5
|
||||
|
||||
| V2 端 | preV1 App | `X-Client-App` | 原型参照 |
|
||||
|-------|-----------|----------------|----------|
|
||||
| C端小程序 | `apps/h5-user` | `USER_H5` | `pages/user/` |
|
||||
| 门店 H5 | `apps/h5-shop` | `SHOP_H5` | `pages/shop/` |
|
||||
| 合伙人小程序 | `apps/h5-partner` | `PARTNER_H5` | `pages/partner/` |
|
||||
|
||||
- JWT `actorType` 不变:`USER` / `STORE` / `PARTNER`。
|
||||
- V2 的 `USER_MINI` / `PARTNER_MINI` / `HQ_MINI` 枚举**保留**,preV1 不用即可。
|
||||
|
||||
### 1.3 微信登录与授权暂不走
|
||||
|
||||
- **不调用**:微信 `code2session`、获取手机号组件、微信 OAuth。
|
||||
- **不写入**:`wx_open_id` / `wx_union_id`(保持 NULL)。
|
||||
- **仅实现**:`POST /auth/sms/send` + `POST /auth/login/sms`(及三端等价路径 `/shop/auth/*`、`/partner/auth/*`)。
|
||||
- V2 的 `POST /auth/login/wechat`、`/auth/wechat/bind-phone`:**保留路由**,preV1 返回 `501 FEATURE_DISABLED` 或 Flag 关闭时不注册。
|
||||
|
||||
### 1.4 手机号验证固定 Mock
|
||||
|
||||
| 项 | preV1 约定 |
|
||||
|----|------------|
|
||||
| 验证码 | 固定 **`123456`**(全端、全 scene 通用) |
|
||||
| 开关 | `MOCK_SMS=true` 时:不调用短信网关,不写入 `log_third_party(SMS)` 真实外呼 |
|
||||
| 校验 | `AuthService.verifyCode(phone, code)` 内:`if (MOCK_SMS && code === '123456') return ok` |
|
||||
| 限流 | preV1 可放宽;V2 打开真实短信后启用频率限制 |
|
||||
|
||||
**测试账号(Seed,见 §6.1)**
|
||||
|
||||
| 角色 | 手机号 | 表 |
|
||||
|------|--------|-----|
|
||||
| C端用户 | `13800000001` | `user_user` |
|
||||
| 门店 | `13900000001` | `store_account` |
|
||||
| 合伙人主账号 | `13700000001` | `partner_account` |
|
||||
|
||||
### 1.5 配送与订单状态 Mock(跳过第三方)
|
||||
|
||||
- **不调用**:小飞侠 API、物流查询 API;`log_third_party` 的 `XFX` / `LOGISTICS` 在 preV1 可不写入(或写 MOCK 占位)。
|
||||
- **仍创建**:支付成功后 `user_order_delivery` 空壳(与 V2 一致 1:1)。
|
||||
- **状态推进**(任选其一,推荐 A+B):
|
||||
- **A. 自动任务**:`MOCK_DELIVERY_AUTO=true` 时,支付成功 30s 后 BullMQ 任务:`PENDING_SHIP → OUT_WAREHOUSE → SHIPPING → PENDING_RECEIVE → COMPLETED`,并写 `common_event(ORDER_STATUS)`。
|
||||
- **B. 合伙人手动**:`POST /partner/orders/:id/mock-advance-delivery`(preV1 专用,V2 可保留为内部测试接口或 Flag 保护)。
|
||||
- **user_order** 冗余时间字段 `shipped_at` / `completed_at` 与 `user_order_delivery.shipping_at` / `delivered_at` **双写**(同 V2)。
|
||||
|
||||
### 1.6 跳过支付
|
||||
|
||||
- **不调用**:微信统一下单、支付回调验签。
|
||||
- **用户侧**:确认订单页按钮文案可为「提交订单(Mock 支付)」;仍调用 **`POST /trade/orders/:id/pay`**(路径与 V2 相同)。
|
||||
- **服务端**:`MOCK_PAY=true` 时 `PayService` 同步:
|
||||
1. `user_order.pay_status=PAID`,`paid_at=NOW()`,`pay_external_no='MOCK-{orderNo}'`
|
||||
2. 可选写 `log_third_party(WECHAT_PAY, scene=ORDER_PAY, status=SUCCESS, external_no=MOCK-...)`
|
||||
3. 订单 `status=PENDING_SHIP` + `common_event(ORDER_STATUS)`
|
||||
4. 调用 `BenefitService.grantOnOrderPaid(orderId)`(**真实发券逻辑,非 Mock**)
|
||||
5. 预创建 `user_order_delivery`
|
||||
- V2 切换:`MOCK_PAY=false` 时走 `wechatpay-node-v3` + `/callbacks/wechat/pay`。
|
||||
|
||||
---
|
||||
|
||||
# §2、三端 H5 与仓库结构
|
||||
|
||||
preV1 Monorepo(在 V2 目标结构上演进,**暂不建 mini-* / mini-hq**):
|
||||
|
||||
```
|
||||
dukang/
|
||||
├── apps/
|
||||
│ ├── h5-user/ # C端 H5(Taro H5 或 Vite+React,与 V2 技术栈一致即可)
|
||||
│ ├── h5-shop/ # 门店 H5
|
||||
│ └── h5-partner/ # 合伙人 H5
|
||||
├── packages/
|
||||
│ ├── shared-types/ # 含 USER_H5 / PARTNER_H5 / SHOP_H5
|
||||
│ └── domain/
|
||||
├── server/dukang-api/ # 同 V2 单体 NestJS
|
||||
├── pages/{user,shop,partner}/ # UI 参照(hq 仅 V2 用)
|
||||
├── 杜康好客-V2编码手册.md
|
||||
└── 杜康好客-preV1编码手册.md # 本文
|
||||
```
|
||||
|
||||
**请求头**:各 H5 固定 `X-Client-App: USER_H5 | SHOP_H5 | PARTNER_H5`。
|
||||
|
||||
**页面实现**:字段、Tab、跳转以 V2 手册 §二、§三 原型为准;C 端订单 **5 Tab(含待发货)** 不变。
|
||||
|
||||
---
|
||||
|
||||
# §3、认证 Mock(无微信)
|
||||
|
||||
### 3.1 实现的登录路径
|
||||
|
||||
| 端 | 发送验证码 | 登录 |
|
||||
|----|------------|------|
|
||||
| C端 | `POST /auth/sms/send` scene=`USER_LOGIN` | `POST /auth/login/sms` |
|
||||
| 门店 | `POST /auth/sms/send` scene=`STORE_LOGIN` | `POST /shop/auth/login/sms` |
|
||||
| 合伙人 | `POST /auth/sms/send` scene=`PARTNER_LOGIN` | `POST /partner/auth/login/sms` |
|
||||
|
||||
### 3.2 preV1 不实现的登录路径
|
||||
|
||||
| 路径 | preV1 行为 |
|
||||
|------|------------|
|
||||
| `POST /auth/login/wechat` | 501 或 Flag 关闭 |
|
||||
| `POST /auth/wechat/bind-phone` | 501 |
|
||||
| `POST /shop/auth/login/wechat` | 501 |
|
||||
| `POST /partner/auth/login/wechat` | 501 |
|
||||
| `POST /admin/auth/*` | 不暴露给前端(无 HQ 端) |
|
||||
|
||||
### 3.3 实现要点(便于 V2 切换)
|
||||
|
||||
```typescript
|
||||
// packages/shared-types 或 server config
|
||||
export const AppConfig = {
|
||||
mockSms: process.env.MOCK_SMS === 'true',
|
||||
mockSmsCode: process.env.MOCK_SMS_CODE ?? '123456',
|
||||
mockPay: process.env.MOCK_PAY === 'true',
|
||||
mockDeliveryAuto: process.env.MOCK_DELIVERY_AUTO === 'true',
|
||||
};
|
||||
|
||||
// AuthService — 单一验证码入口,V2 只改内部实现
|
||||
async verifySmsCode(phone: string, code: string, scene: string) {
|
||||
if (AppConfig.mockSms && code === AppConfig.mockSmsCode) return;
|
||||
// V2: 查 Redis / log_third_party / 真实短信平台
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# §4、支付 Mock
|
||||
|
||||
### 4.1 用户流程(与 V2 UI 一致,跳过收银台)
|
||||
|
||||
```
|
||||
确认订单 → POST /trade/orders(锁单 PENDING_PAY)
|
||||
→ POST /trade/orders/:id/pay
|
||||
[MOCK_PAY=true] 同步成功,无跳转微信
|
||||
→ 订单列表可见「待发货」
|
||||
→ 权益页可见新券
|
||||
```
|
||||
|
||||
### 4.2 字段与表(与 V2 相同)
|
||||
|
||||
| 写入 | 说明 |
|
||||
|------|------|
|
||||
| `user_order.pay_status` | `PAID` |
|
||||
| `user_order.paid_at` | 当前时间 |
|
||||
| `user_order.pay_external_no` | `MOCK-{orderNo}` |
|
||||
| `user_order.status` | `PENDING_SHIP` |
|
||||
| `log_third_party` | 可选 MOCK 记录,便于 V2 对账逻辑联调 |
|
||||
| `user_benefit_coupon` + `common_event(BENEFIT_LEDGER,GRANT)` | **真实业务**,非 Mock |
|
||||
|
||||
### 4.3 preV1 不做的支付相关能力
|
||||
|
||||
- 微信 prepay 参数、支付回调、`/callbacks/wechat/pay` 验签(路由保留,Mock 模式不触发)。
|
||||
- 退款微信 API:preV1 **整模块可跳过**(§7);表与 `common_ticket(REFUND)` 仍保留。
|
||||
|
||||
---
|
||||
|
||||
# §5、配送与订单状态 Mock
|
||||
|
||||
### 5.1 自动推进状态机(推荐默认开启)
|
||||
|
||||
`MOCK_DELIVERY_AUTO=true` 时,支付成功后注册延时任务:
|
||||
|
||||
| 延时 | from → to | 配送表 |
|
||||
|------|-----------|--------|
|
||||
| T+0 | `PENDING_SHIP` → `OUT_WAREHOUSE` | `out_warehouse_at` |
|
||||
| T+10s | → `SHIPPING` | `shipping_at`,`user_order.shipped_at` |
|
||||
| T+30s | → `PENDING_RECEIVE` | — |
|
||||
| T+60s | → `COMPLETED` | `delivered_at`,`user_order.completed_at` |
|
||||
|
||||
每次 transition 写 `common_event(ORDER_STATUS)`;`provider` 填 `MANUAL` 或 `MOCK`。
|
||||
|
||||
### 5.2 合伙人端手动推进(可选)
|
||||
|
||||
`POST /partner/orders/:id/mock-advance-delivery`
|
||||
|
||||
- Guard:`PartnerAuth` + 订单 `city_id` 属合伙人辖区。
|
||||
- Body:`{ targetStatus: 'SHIPPING' | 'COMPLETED' | ... }`
|
||||
- preV1 专用;V2 生产环境 `MOCK_DELIVERY=false` 时返回 403。
|
||||
|
||||
### 5.3 改址拦截(preV1 简化)
|
||||
|
||||
- V2 PRD:用户改址 → 合伙人拦截配送。
|
||||
- preV1:`PUT /trade/orders/:id/address` **仅更新** `user_order` 收货字段 + `common_event`;**不**调第三方、不建复杂 intercept 表(V2 仍用 `common_ticket(ALERT)`,preV1 可省略工单)。
|
||||
|
||||
---
|
||||
|
||||
# §6、无总部端:能力替代
|
||||
|
||||
HQ 能力在 preV1 通过 **Seed + 合伙人 H5 子集 + 可选内部 API** 覆盖,**表结构不删**。
|
||||
|
||||
### 6.1 启动 Seed(`prisma/seed-prev1.ts` 或 SQL)
|
||||
|
||||
| 数据 | 内容 |
|
||||
|------|------|
|
||||
| `common_city` | 郑州 `ACTIVE`,`local_min_qty=2`,`cross_min_qty=6` |
|
||||
| `common_city_commission_rule` | 默认佣金比例 |
|
||||
| `common_product_item` | 4 款清香型 `ON_SALE`,含 `barcode_69` |
|
||||
| `common_store_category` | 火锅/地方菜等 |
|
||||
| `partner_partner` + `partner_account` | 郑州合伙人 + 主账号 `13700000001` |
|
||||
| `store_store` + `store_account` | 至少 2 家 `OPEN` 门店 |
|
||||
| `user_user` | 测试用户 `13800000001` |
|
||||
| `hq_account` | 可 Seed 1 条供未来 V2,preV1 无 UI |
|
||||
|
||||
商品图/门头图:preV1 可用 **占位 URL** 或 `common_resource` 写死 CDN;V2 换 OSS 上传流程即可。
|
||||
|
||||
### 6.2 原 HQ 功能 → preV1 替代
|
||||
|
||||
| V2 HQ 功能 | preV1 替代 |
|
||||
|------------|------------|
|
||||
| 开城 / 商品 CRUD | **Seed 固定**;变更改 Seed 或直连 DB(开发环境) |
|
||||
| 门店审核 | 合伙人提交后 **`AUTO_APPROVE_STORE=true` 自动通过**,写 `common_event(STORE_AUDIT,APPROVED)` |
|
||||
| 订单中心 / 发货 | 合伙人 H5 看辖区订单;跨城发货 preV1 跳过或 Mock 运单号 |
|
||||
| 推广码 | **跳过 UI**;`channel_source` 可手填或 Seed 一条 `common_promo_code` |
|
||||
| 退款 / 客服工单 | **跳过**(或合伙人 H5 仅查看,不发起微信退款) |
|
||||
| 结算中心 T+1/T+30 | **Mock 状态**:核销后 `store_payout.status=PENDING`;合伙人 `partner_bill` 可 Seed 一条 `CONFIRMED` 演示 |
|
||||
| 数据报表 / 埋点 | 埋点 **可写 `log_user_analytics`**;总部报表 **不做** |
|
||||
|
||||
### 6.3 合伙人 H5 在 preV1 的扩展(承接部分 HQ)
|
||||
|
||||
在 V2 合伙人 API 基础上,preV1 **额外开放**(Flag 保护):
|
||||
|
||||
| 能力 | 说明 |
|
||||
|------|------|
|
||||
| 门店审核自动通过 | 配置项,非新表 |
|
||||
| Mock 推进配送 | §5.2 |
|
||||
| 查看辖区订单 | V2 已有 `GET /partner/orders` |
|
||||
|
||||
---
|
||||
|
||||
# §7、preV1 功能清单
|
||||
|
||||
### 7.1 必做(跑通主链路)
|
||||
|
||||
| 模块 | 功能 | V2 预留 |
|
||||
|------|------|---------|
|
||||
| IAM | 三端短信 Mock 登录 | 微信登录路由保留 |
|
||||
| Catalog | 读商品/开城(Seed) | `/admin/products` 未实现 |
|
||||
| Trade | 预览、下单、Mock 支付、5 Tab 订单、改址 | 真实微信支付 |
|
||||
| Benefit | 发券、券列表、明细 | 同 V2 |
|
||||
| Redeem | Redis 核销码、门店扫码确认、评价 | 同 V2 |
|
||||
| Store | C 端门店列表/详情;合伙人录店;**自动审核** | HQ 人工审核 |
|
||||
| Settlement | 核销后 `store_payout` PENDING | T+1 打款任务可 Mock 为手动改 PAID |
|
||||
| Analytics | 可选:批量写 `log_user_analytics` | 同 V2 |
|
||||
|
||||
### 7.2 preV1 明确跳过(V2 补)
|
||||
|
||||
| 模块 | 跳过内容 |
|
||||
|------|----------|
|
||||
| HQ 端 | 全部页面与 AdminAuth 前端 |
|
||||
| 微信 | 登录、支付、退款、订阅消息 |
|
||||
| 第三方 | 小飞侠、物流、真实短信 |
|
||||
| 运营 | 退款工单、推广码管理 UI、总部报表 |
|
||||
| 合伙人 | 拦截配送完整流程、T+30 真实打款、提现 |
|
||||
| 小程序 | 全部(preV1 仅 H5) |
|
||||
|
||||
### 7.3 业务规则(与 V2 相同,Mock 不减免)
|
||||
|
||||
- 同城起购 **2 瓶** / 跨城 **6 瓶**(`packages/domain`)
|
||||
- 权益金额 `benefit_amount ?? price`
|
||||
- 核销上限 **¥500**,Redis 码 **5 分钟**
|
||||
- C 端门店仅 **`OPEN`**
|
||||
- 订单 **5 Tab**(含 `pending_ship`)
|
||||
|
||||
---
|
||||
|
||||
# §8、Feature Flag 与 V2 切换
|
||||
|
||||
### 8.1 环境变量(`.env.example`)
|
||||
## 启动验证
|
||||
|
||||
```bash
|
||||
# preV1 Mock 开关(true = Mock 模式)
|
||||
MOCK_SMS=true
|
||||
MOCK_SMS_CODE=123456
|
||||
MOCK_PAY=true
|
||||
MOCK_DELIVERY_AUTO=true
|
||||
AUTO_APPROVE_STORE=true
|
||||
|
||||
# V2 就绪后逐项 false,并配置真实密钥
|
||||
WECHAT_PAY_ENABLED=false
|
||||
WECHAT_AUTH_ENABLED=false
|
||||
XFX_ENABLED=false
|
||||
SMS_PROVIDER=mock
|
||||
pnpm dev:api && pnpm dev:user && pnpm dev:shop && pnpm dev:partner
|
||||
node scripts/smoke-prev1.mjs
|
||||
```
|
||||
|
||||
### 8.2 切换检查表(preV1 → V2)
|
||||
## 与 V2 差异速查
|
||||
|
||||
| 步骤 | 动作 |
|
||||
|------|------|
|
||||
| 1 | `MOCK_PAY=false`,配置商户号,启用 `/callbacks/wechat/pay` |
|
||||
| 2 | `MOCK_SMS=false`,接入短信,`log_third_party(SMS)` |
|
||||
| 3 | `MOCK_DELIVERY_AUTO=false`,对接小飞侠/物流回调 |
|
||||
| 4 | `AUTO_APPROVE_STORE=false`,上线 `apps/mini-hq` + 门店审核 |
|
||||
| 5 | 新增 `apps/mini-user`、`apps/mini-partner`,`X-Client-App` 改 MINI |
|
||||
| 6 | `WECHAT_AUTH_ENABLED=true`,实现 wechat 登录/bind-phone |
|
||||
| 7 | 启用退款、结算 Job、推广码 HQ 页面 |
|
||||
| 项 | preV1 | V2/V3 |
|
||||
|----|-------|-------|
|
||||
| 客户端 | 三 H5 | 小程序 + H5 |
|
||||
| 总部 | 无 UI | admin-web |
|
||||
| 微信 | 无 | JSAPI/OAuth |
|
||||
| 表结构 | v3.1 同左 | 同左 |
|
||||
|
||||
### 8.3 代码组织(避免 Mock 散落)
|
||||
|
||||
```
|
||||
server/dukang-api/src/
|
||||
├── integrations/
|
||||
│ ├── sms/
|
||||
│ │ ├── sms.interface.ts # ISmsProvider
|
||||
│ │ ├── sms.mock.provider.ts # preV1
|
||||
│ │ └── sms.aliyun.provider.ts # V2
|
||||
│ ├── pay/
|
||||
│ │ ├── pay.interface.ts
|
||||
│ │ ├── pay.mock.provider.ts
|
||||
│ │ └── pay.wechat.provider.ts
|
||||
│ └── delivery/
|
||||
│ ├── delivery.interface.ts
|
||||
│ ├── delivery.mock.provider.ts
|
||||
│ └── delivery.xfx.provider.ts
|
||||
```
|
||||
|
||||
`TradeModule` / `AuthModule` 只依赖 **interface**,由 `ConfigModule` 注入 Mock 或 Real 实现。
|
||||
|
||||
---
|
||||
|
||||
# §9、preV1 里程碑与任务卡
|
||||
|
||||
> 详细 API/表结构见 V2 手册 §五、§六;任务 ID 前缀 **`P1-`**,与 V2 `M0~M6` 并行命名空间。
|
||||
|
||||
| 里程碑 | 交付 | 出口标准 |
|
||||
|--------|------|----------|
|
||||
| **P1-M0** | Monorepo 三 H5 + API 骨架 + Seed | 三端登录页、`GET /health`、Seed 郑州+4 SKU |
|
||||
| **P1-M1** | IAM Mock + 商品/门店读 | 三端 Mock 登录;C 端首页 4 款酒 |
|
||||
| **P1-M2** | 下单 + Mock 支付 + 5 Tab 订单 | 2 瓶起购;支付后待发货+发券 |
|
||||
| **P1-M3** | 权益 + 核销 + 门店 H5 | 端到端核销;`store_payout` PENDING |
|
||||
| **P1-M4** | 合伙人录店 + 自动审核 | C 端可见新门店 |
|
||||
| **P1-M5** | Mock 配送自动推进 | 订单可到已完成 |
|
||||
| **P1-M6** | 埋点可选 + 联调修复 | 主链路冒烟通过 |
|
||||
|
||||
### 任务卡(精简)
|
||||
|
||||
| ID | 任务 | 验收 |
|
||||
|----|------|------|
|
||||
| P1-M0-001 | pnpm workspace + `h5-user/shop/partner` | 三端 `dev` 可编译 |
|
||||
| P1-M0-002 | `shared-types` + Mock Flags | 导出 `AppConfig` |
|
||||
| P1-M0-003 | NestJS + Prisma + `init_v3.sql` | `prisma validate` |
|
||||
| P1-M0-004 | `seed-prev1.ts` | 郑州/4SKU/测试账号 |
|
||||
| P1-M1-001 | `SmsMockProvider` + 三端 login/sms | 123456 登录 |
|
||||
| P1-M1-002 | C 端首页/详情 Public catalog | 对照 `pages/user/2,3` |
|
||||
| P1-M2-001 | preview + create order | 起购校验 |
|
||||
| P1-M2-002 | `PayMockProvider` + grant benefit | Mock 支付发券 |
|
||||
| P1-M2-003 | 订单 5 Tab | `pending_ship` 有数据 |
|
||||
| P1-M3-001 | 权益页 + redeem token | ¥500 上限 |
|
||||
| P1-M3-002 | 门店扫码核销 | `pages/shop/3~5` |
|
||||
| P1-M4-001 | 合伙人录店 + AUTO_APPROVE | C 端门店可见 |
|
||||
| P1-M5-001 | `DeliveryMockProvider` 自动推进 | 订单 COMPLETED |
|
||||
|
||||
---
|
||||
|
||||
# §10、API / 数据行为差异速查
|
||||
|
||||
| API / 行为 | V2 | preV1 |
|
||||
|------------|-----|-------|
|
||||
| `X-Client-App` | USER_MINI / … | USER_H5 / SHOP_H5 / PARTNER_H5 |
|
||||
| `/admin/*` | AdminAuth 小程序 | 无前端;Seed/脚本 |
|
||||
| `/auth/login/wechat` | 实现 | 501 或 Flag 关 |
|
||||
| `/auth/sms/send` | 真实短信 | 固定码,不外呼 |
|
||||
| `/trade/orders/:id/pay` | 微信 prepay | 同步 Mock 成功 |
|
||||
| `/callbacks/wechat/pay` | 验签回调 | 不触发 |
|
||||
| `/callbacks/xfx/delivery` | 配送回调 | 不触发 |
|
||||
| `log_third_party` | 真实流水 | 可选 MOCK 行或跳过 |
|
||||
| 门店审核 | HQ 审 | `AUTO_APPROVE_STORE` |
|
||||
| 退款 | HQ 工单+微信退款 | **跳过** |
|
||||
| 推广码 UI | HQ | **跳过** |
|
||||
| 核销 / 权益 / 订单表 | 真实 | **真实(同 V2)** |
|
||||
|
||||
---
|
||||
|
||||
## 原型参照(preV1 仍用 V2 映射)
|
||||
|
||||
| H5 App | 原型目录 |
|
||||
|--------|----------|
|
||||
| h5-user | `pages/user/` |
|
||||
| h5-shop | `pages/shop/` |
|
||||
| h5-partner | `pages/partner/` |
|
||||
|
||||
C 端订单列表:**5 Tab(含待发货)**;个人中心无会员标签;城市示例以 **郑州** 为准。
|
||||
|
||||
---
|
||||
|
||||
*preV1 为 V2 的 Mock 联调阶段;完整 PRD、DB DDL、全量 API 见 [`杜康好客-V2编码手册.md`](./杜康好客-V2编码手册.md)。*
|
||||
升级:逐项关 Mock → 接真实集成 → 补 admin-web / mini-user。
|
||||
|
||||
+2
-168
@@ -1,169 +1,3 @@
|
||||
# 杜康好客 · V3 编码手册(交付业务版)
|
||||
# 杜康好客 · v2.1 编码手册
|
||||
|
||||
> **版本定位**:V3 是基于当前数据库 v3.1 的完整交付版本,目标不是 Mock 联调,而是把「购酒 → 发券 → 到店核销 → 门店打款 → 合伙人结算 → 总部运营」全流程跑通。
|
||||
> **对照文件**:V2 手册定义完整产品蓝图;preV1 手册定义 Mock 联调裁剪;本文件定义 V3 的交付口径、核销新规则、两人分工与业务闭环任务口径。
|
||||
> **数据库事实**:当前 Prisma schema 已是 v3.1,V3 不默认新增大表,优先补齐业务闭环、第三方集成、任务调度、验收测试与运营后台。
|
||||
|
||||
---
|
||||
|
||||
## 1. V3 交付目标
|
||||
|
||||
V3 必须达到可业务验收状态:
|
||||
|
||||
1. C 端用户能登录、选城、浏览商品、下单、支付、查看订单、获得权益、到店核销。
|
||||
2. 门店端能登录、扫码/输码核销、查看核销记录、管理营业状态,并形成待打款记录。
|
||||
3. 合伙人端能登录、录入门店、管理门店、查看辖区订单、处理配送/补发、查看账单与经营数据。
|
||||
4. WebAdmin 能完成开城、商品、门店审核、订单、权益、核销、配送、退款/补发、结算、资源和账号管理。
|
||||
5. 后端能完成真实支付回调、配送状态推进、退款/补发工单、门店 T+1、合伙人 T+30、日志与审计。
|
||||
6. 测试能覆盖主链路、关键边界和生产开关,不再只依赖一条 happy path 冒烟。
|
||||
|
||||
---
|
||||
|
||||
## 2. V3 端与负责人
|
||||
|
||||
| 负责人 | 主责端 | 主责后端/公共范围 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `jacy-dukang` | `apps/h5-user`、`apps/admin-web` | `packages/*`、`iam`、`catalog`、`trade`、`benefit`、`settlement`、`ops`、`callbacks`、`jobs`、`integrations`、Prisma | Tech lead,负责架构、主交易链路、支付退款、后台运营、交付验收 |
|
||||
| `刘景尧` | `apps/h5-shop`、`apps/h5-partner` | `store`、`redeem`,并配合 `settlement`、配送/核销联调 | 负责门店、合伙人、录店、核销、门店体验与辖区履约 |
|
||||
|
||||
协作规则:
|
||||
|
||||
- `apps/*` 只走 HTTP API 与 `packages/shared-types`,禁止 import `server/*` 或其他 app。
|
||||
- 后端跨模块只调用 exported Service,禁止为了赶进度直接写他人领域表。
|
||||
- 涉及 API、枚举、DTO、业务规则变更,必须同步 `packages/shared-types`、`packages/domain` 与本手册。
|
||||
- Prisma 迁移由 `jacy-dukang` 主导;涉及 `store` / `redeem` 表或核销流程时 `刘景尧` 必须 Review。
|
||||
|
||||
---
|
||||
|
||||
## 3. V3 核销规则(已替代 V2 的 ¥500 上限)
|
||||
|
||||
### 3.1 两种核销入口
|
||||
|
||||
| 入口 | 前端表现 | API 入参 | 限制规则 | 券扣减方式 |
|
||||
|---|---|---|---|---|
|
||||
| 直接点核销 | 用户在权益首页点击「去使用」 | `{ amount }`,不带 `couponId` | `0 < amount <= 用户全部 ACTIVE 权益总余额` | 按券创建时间 FIFO 扣减,可跨多张权益 |
|
||||
| 指向单据核销 | 用户在某张权益/核销单点击「立即核销」 | `{ couponId, amount }` | `0 < amount <= 该单据当前可用金额` | 只扣减该单据 |
|
||||
|
||||
### 3.2 后端不变量
|
||||
|
||||
- 核销码只存在 Redis,TTL = 5 分钟。
|
||||
- 生成核销码前必须校验金额,门店确认核销时必须二次校验。
|
||||
- 门店确认时使用券 `version` 乐观锁,避免并发重复扣减。
|
||||
- 核销成功后写入:
|
||||
- `user_redeem_record`
|
||||
- `common_event(BENEFIT_LEDGER, REDEEM)`
|
||||
- `store_payout(PENDING)`
|
||||
- 若核销码绑定门店,确认核销时只能由该门店使用;若未绑定门店,任意 `OPEN` 门店可确认。
|
||||
- 已关闭或暂停门店不可核销。
|
||||
|
||||
### 3.3 前端提示
|
||||
|
||||
- 直接核销:显示「最高可核销 = 当前好客权益总余额」。
|
||||
- 单据核销:显示「最高可核销 = 当前单据可用金额」。
|
||||
- 不再展示「单次最高可核销 ¥500.00」。
|
||||
|
||||
---
|
||||
|
||||
## 4. V3 完整业务闭环
|
||||
|
||||
### 4.1 C 端购酒与权益
|
||||
|
||||
1. 用户打开 H5,完成手机号/微信登录。
|
||||
2. 选择城市,首页展示已开城商品。
|
||||
3. 进入商品详情,选择数量与收货地址。
|
||||
4. 订单预览校验同城 2 瓶、跨城 6 瓶。
|
||||
5. 创建订单,状态 `PENDING_PAY`。
|
||||
6. 发起支付,Mock 环境同步成功,生产环境走微信 JSAPI。
|
||||
7. 支付成功回调幂等更新订单为 `PENDING_SHIP`。
|
||||
8. 根据商品 `benefitAmount ?? price` 发放好客权益。
|
||||
9. 用户在权益页直接核销或指定单据核销。
|
||||
10. 门店确认核销后,用户可评价,权益余额与流水更新。
|
||||
|
||||
### 4.2 门店核销与打款
|
||||
|
||||
1. 门店账号登录。
|
||||
2. 首页扫码或输入核销码。
|
||||
3. 后端校验核销码、门店状态、权益余额、单据金额。
|
||||
4. 核销成功生成记录。
|
||||
5. 系统创建 `store_payout(PENDING)`,预计 T+1 打款。
|
||||
6. 系统按门店绑定关系计算并记录对应合伙人的核销收益,用于合伙人账单与经营统计。
|
||||
7. WebAdmin 财务确认或 Job 自动推进打款状态。
|
||||
8. 门店端可查看核销记录与打款状态。
|
||||
9. 绑定合伙人端可查看辖区门店对应的核销订单、核销金额、门店打款状态与合伙人收益。
|
||||
|
||||
### 4.3 合伙人拓店与履约
|
||||
|
||||
1. 合伙人登录工作台。
|
||||
2. 录入门店资料、门头/环境图、合同资料、银行卡信息。
|
||||
3. V3 由 WebAdmin 审核门店,审核通过后门店才可对 C 端可见并参与核销。
|
||||
4. 合伙人查看辖区订单。
|
||||
5. 配送 Mock 或真实配送推进订单。
|
||||
6. 异常时发起/处理补发、改址拦截、配送异常。
|
||||
7. 合伙人查看月度账单、佣金、经营周报。
|
||||
|
||||
### 4.4 WebAdmin 运营
|
||||
|
||||
1. 管理员登录。
|
||||
2. 配置开城、商品、合伙人、门店分类。
|
||||
3. 审核门店。
|
||||
4. 查看订单与配送。
|
||||
5. 处理退款、补发、客服工单。
|
||||
6. 管理权益、核销、资源、账号。
|
||||
7. 财务确认门店 T+1 和合伙人 T+30 结算。
|
||||
8. 查看运营报表、异常预警、第三方日志。
|
||||
|
||||
---
|
||||
|
||||
## 5. V3 验收用例清单
|
||||
|
||||
### 必过主链路
|
||||
|
||||
1. C 端手机号登录成功。
|
||||
2. 首页展示郑州 4 个上架商品。
|
||||
3. 同城 1 瓶下单失败,2 瓶成功。
|
||||
4. 跨城 5 瓶下单失败,6 瓶成功。
|
||||
5. 支付成功后订单进入待发货,并发放权益。
|
||||
6. 直接核销不带 `couponId`,金额可达到总余额。
|
||||
7. 单据核销带 `couponId`,金额不能超过该单据余额。
|
||||
8. 门店扫码确认核销成功。
|
||||
9. 核销后生成 `store_payout(PENDING)`。
|
||||
10. 门店关闭后不可核销,C 端不可见关闭门店。
|
||||
11. 合伙人录店后进入审核流,审核通过后 C 端可见。
|
||||
12. 配送自动或真实回调推进到完成。
|
||||
13. 退款工单通过后订单/权益/第三方日志一致。
|
||||
14. 门店 T+1 打款状态可确认。
|
||||
15. 合伙人 T+30 账单可生成并确认。
|
||||
|
||||
### 必过后台链路
|
||||
|
||||
1. WebAdmin 登录成功。
|
||||
2. 创建/编辑/上下架商品。
|
||||
3. 审核门店。
|
||||
4. 查询订单与配送单。
|
||||
5. 查询权益券、核销记录、打款记录。
|
||||
6. 处理退款/补发/异常工单。
|
||||
7. 查看第三方日志与运营报表。
|
||||
8. 导出或核对财务数据。
|
||||
|
||||
---
|
||||
|
||||
## 6. 当前已知技术债
|
||||
|
||||
| 优先级 | 技术债 | 处理要求 |
|
||||
|---|---|---|
|
||||
| P0 | 旧文档与规则仍有 ¥500 上限描述 | V3 以后以本手册为准;后续批量清理 V2/preV1 中过时描述 |
|
||||
| P0 | `lint` 多数为 `echo ok` | 交付验收前必须接入有效检查 |
|
||||
| P0 | smoke 覆盖不足 | 按 4.x 业务闭环补齐主流程冒烟 |
|
||||
| P1 | shared-types DTO 不全 | 按接口稳定度分批上提 |
|
||||
| P1 | 跨模块直写 Prisma 表 | 逐步改为 exported Service |
|
||||
| P1 | 真实短信、配送、退款未闭环 | 按 4.1、4.3、4.4 对应业务闭环完成 |
|
||||
| P2 | `admin-web` 与 V2 HQ 小程序形态不一致 | V3 先以 WebAdmin 交付,是否迁小程序另立版本 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 版本冻结规则
|
||||
|
||||
- V3 业务规则以本文件为准。
|
||||
- V2 手册仍作为完整蓝图参考,但与 V3 冲突时,V3 优先。
|
||||
- preV1 手册只作为 Mock 联调历史参考,不再作为交付验收标准。
|
||||
- 未写入本文件的新增需求,不进入 V3 交付范围;如必须加入,先更新本手册与任务表负责人。
|
||||
> **已合并**。内容与 [`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md) 重复,请以 **v3 编码手册** 为准。本文件保留作历史链接占位。
|
||||
|
||||
+90
-512
@@ -1,571 +1,149 @@
|
||||
# 杜康好客 · V3.0 产品需求文档(PRD)
|
||||
# 杜康好客 · V3.0 PRD
|
||||
|
||||
> **版本**:v3.0(源自产品 PRD v1.3,2026-07-10)
|
||||
> **作者**:吕丹(产品)· 工程对齐:jacy-dukang
|
||||
> **状态**:本期交付事实源(与 V2 蓝图冲突时 **V3.0 优先**)
|
||||
> **关联**:[`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md)(实现与验收口径)· [`AGENTS.md`](./AGENTS.md)(AI 入口)
|
||||
> **v3.0**(2026-07-10)· 产品事实源 · 冲突时 **V3 > V2**
|
||||
> 实现:[`v3编码手册`](./杜康好客-v3编码手册.md) · 审计:[`v3-现状对照`](./杜康好客-v3-现状对照.md)
|
||||
|
||||
---
|
||||
## 0. 说明
|
||||
|
||||
## 0. 文档说明
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 四端 | C 小程序 · 门店/合伙人 H5 · 总部 **WebAdmin**(工程口径,非 H5) |
|
||||
| 试点 | 郑州 · 同城小飞侠 · 跨城总部物流到付 |
|
||||
| 工程差异 | C 端现 `mini-user`/h5-user;核销 TTL **3min**;订单 Tab **三态** |
|
||||
|
||||
| 字段 | 内容 |
|
||||
|------|------|
|
||||
| 项目名称 | 杜康好客 — 四端系统 |
|
||||
| 覆盖端 | 用户端(**微信小程序**)、门店端(H5)、城市合伙人端(H5)、总部端(H5) |
|
||||
| 试点城市 | 郑州 |
|
||||
| 配送 | 同城小飞侠;跨城总部物流到付 |
|
||||
| 主销场景 | 品鉴会 + 小程序 |
|
||||
### 版本变更索引
|
||||
|
||||
### 0.1 与仓库现状的差异(实现迁移注记)
|
||||
|
||||
| PRD v3.0 目标 | 当前 preV1/V3 代码现状 | 处理 |
|
||||
|---------------|------------------------|------|
|
||||
| C 端微信小程序 | `apps/h5-user`(H5) | V3.0 以小程序为交付形态;H5 可作联调过渡 |
|
||||
| 总部 H5 | `apps/admin-web`(WebAdmin) | V3.0 功能对齐总部 H5 职责;形态可暂用 admin-web |
|
||||
| 核销码 TTL 3 分钟 | 编码手册写 5 分钟 | **以本 PRD 3 分钟为准** |
|
||||
| 订单 Tab 三态 | preV1 五 Tab | **以本 PRD 待付款/已付款/已完成为准** |
|
||||
|
||||
### 0.2 变更:3.4.10 门店套餐
|
||||
|
||||
| 版本 | 日期 | 说明 |
|
||||
|------|------|------|
|
||||
| **3.4.10** | 2026-08-03 | 新增 §3.9 门店套餐;REQ-P-027 / REQ-S-021 / REQ-H-025 / REQ-U-027;开发设计见 [`杜康好客-门店套餐功能开发文档-v3.4.10.md`](./杜康好客-门店套餐功能开发文档-v3.4.10.md) |
|
||||
|
||||
### 0.3 变更:3.4.11 开发计划
|
||||
|
||||
| 版本 | 日期 | 说明 |
|
||||
|------|------|------|
|
||||
| **3.4.11** | 2026-08-04 | 新增 §3.10 开发计划、§3.11 企微机器人角色与权限重构;REQ-H-026 ~ REQ-H-028;开发设计见 [`杜康好客-开发计划功能开发文档-v3.4.11.md`](./杜康好客-开发计划功能开发文档-v3.4.11.md) |
|
||||
|
||||
### 0.4 变更:3.4.12 工单迭代
|
||||
|
||||
| 版本 | 日期 | 说明 |
|
||||
|------|------|------|
|
||||
| **3.4.12** | 2026-08-04 | 售后退款回滚、mini-user 门店详情、酒厂 T+3、门店多笔提现、开发计划批量编辑/审批企微派发、技术支持编辑/附件/批量改状态、套餐 imageUrl;开发设计见 [`杜康好客-v3.4.12-工单迭代开发文档.md`](./杜康好客-v3.4.12-工单迭代开发文档.md) |
|
||||
|
||||
### 0.5 变更:3.4.13 体验优化
|
||||
|
||||
| 版本 | 日期 | 说明 |
|
||||
|------|------|------|
|
||||
| **3.4.13** | 2026-08-05 | 推广码归因统计 + **指标事件日志/高峰趋势**、核销用户信息 + **权益券详情加宽/核销详情增强**、技术支持工单优先级、mini-user **我的页版本号/低于 min 强制退出**、门店/商品/提货/**物流增强(签收照/拨号/ETA/路由回调签收→已完成)**、H5 登录校验、合伙人微信暂停禁登、**OSS 图片超 10MB 客户端压缩**;开发设计见 [`杜康好客-v3.4.13-体验优化开发文档.md`](./杜康好客-v3.4.13-体验优化开发文档.md) |
|
||||
| 版 | 日期 | 要点 | 开发文档 |
|
||||
|----|------|------|----------|
|
||||
| 3.4.10 | 08-03 | 门店套餐 | [`门店套餐`](./杜康好客-门店套餐功能开发文档-v3.4.10.md) |
|
||||
| 3.4.11 | 08-04 | 开发计划 + 企微机器人/消息推送 | [`开发计划`](./杜康好客-开发计划功能开发文档-v3.4.11.md) |
|
||||
| 3.4.12 | 08-04 | 退款回滚/财务/批量任务·工单/套餐 imageUrl | [`v3.4.12`](./杜康好客-v3.4.12-工单迭代开发文档.md) |
|
||||
| 3.4.13 | 08-05 | metrics/核销详情/版本联动 PUBLISHED/mini 体验/H5 OAuth | [`v3.4.13`](./杜康好客-v3.4.13-体验优化开发文档.md) |
|
||||
| 3.4.14 | 08-06 | mini-user 门头/套餐详情;小程序可配置;**统一测试白名单(不计账+限测可见+Mock旁路)** | [`v3.4.14`](./杜康好客-v3.4.14-mini-user门店体验开发文档.md) |
|
||||
| 3.4.15 | 08-06 | mini-user 门店列表卡片:去核销改箭头、地址/距离/营业时间重排、营业中角标 | [`v3.4.15`](./杜康好客-v3.4.15-mini-user门店列表优化.md) |
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 1.1 商业模型
|
||||
**模型**:总部供酒 → 合伙人拓店 → 门店核销 1:1 好客权益(酒+餐)。
|
||||
|
||||
总部供酒 → 城市合伙人分销拓店 → 门店核销好客权益 → C 端买酒送 **1:1 等价好客权益**,覆盖「酒 + 餐」。
|
||||
| 锚点 | 值 |
|
||||
|------|-----|
|
||||
| 酒水成本 | ~售价 3 折 |
|
||||
| 门店结算 | 核销额 × **60%** |
|
||||
| 合伙人佣金池 | 订单+核销 ≤ 订单额 **5%**(默认 **0%+3%**) |
|
||||
| 权益 | 实付 **1:1**,永久;口径 `benefit_amount ?? price` |
|
||||
|
||||
**成本与分润锚点**:
|
||||
**G1~G5**:四端闭环 · 购酒履约 · 权益核销结算 · 合伙人拓店对账 · 售后/发票/代下单/推广/评价。
|
||||
|
||||
- 酒水成本约售价 **3 折**
|
||||
- 门店结算 = 核销金额 × **60%**
|
||||
- 单合伙人佣金池:订单佣金比例 + 核销佣金比例 ≤ 订单金额 **5%**(默认 **0% + 3%**)
|
||||
- 权益:支付成功赠送订单实付金额 **1:1**,永久有效
|
||||
|
||||
### 1.2 本期目标 G1–G5
|
||||
|
||||
| ID | 目标 |
|
||||
|----|------|
|
||||
| G1 | 四端上线并可独立闭环运营 |
|
||||
| G2 | 购酒履约(同城/跨城/现场提货)跑通 |
|
||||
| G3 | 好客权益发放 → 门店核销 → 门店 6 折结算 + 手动提现跑通 |
|
||||
| G4 | 合伙人拓店审核、多合伙人管辖、佣金配置、独立对账打款 |
|
||||
| G5 | 售后工单、发票、代下单、问卷、门店评价、推广码归因本期交付 |
|
||||
|
||||
### 1.3 成功标准
|
||||
|
||||
**核心(门禁)**
|
||||
|
||||
| ID | 标准 | 波次 |
|
||||
|----|------|------|
|
||||
| S1 | 用户可下单(含支付与权益发放) | Wave 1(7.10) |
|
||||
| S2 | 门店可核销 | Wave 1(7.10) |
|
||||
| S3 | 城市合伙人可添加门店 | Wave 1(7.10) |
|
||||
| S4 | 门店佣金(结算款)可手动提现 | Wave 2(7.15) |
|
||||
|
||||
**链路验收**
|
||||
|
||||
| ID | 标准 |
|
||||
|----|------|
|
||||
| S5 | 郑州用户:浏览→下单支付→履约/提货→权益到账→门店核销 |
|
||||
| S6 | 跨城推总部物流;现场提货提交即完成 |
|
||||
| S7 | 门店 T+1 按核销额 6 折出账;各合伙人按配置比例独立对账 |
|
||||
| S8 | 工单四类型总部可审;发票可申请可回传 |
|
||||
| S9 | 推广码归因合伙人;订单佣金按履约规则解析(跨城归总部) |
|
||||
**门禁 S1~S4**:下单+权益(W1) · 核销(W1) · 拓店(W1) · 门店提现(W2)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 角色与场景
|
||||
|
||||
### 2.1 角色
|
||||
| 角色 | 端 | 诉求 |
|
||||
|------|-----|------|
|
||||
| C 用户 | 小程序 | 买酒、权益、核销、售后 |
|
||||
| 门店/店员 | H5 | 核销、营业、提现(W2) |
|
||||
| 合伙人 | H5 | 拓店、订单、账单 |
|
||||
| 总部 | WebAdmin | 审核、结算、运营 |
|
||||
|
||||
| 角色 | 端 | 核心诉求 |
|
||||
|------|-----|----------|
|
||||
| C 端用户 | 小程序 | 买酒、收权益、门店核销、售后/发票 |
|
||||
| 门店主账号 | H5 | 核销、子账号管理、营业状态、结算提现 |
|
||||
| 门店店员子账号 | H5 | 仅核销 + 本店记录(Wave 2) |
|
||||
| 合伙人管理员 | H5 | 子账号、管辖范围经营数据、代下单等 |
|
||||
| 合伙人推广员 | H5 | 仅门店入驻 + 看自己提交的店 |
|
||||
| 总部运营/客服/财务 | H5 | 商品、审核、工单、发票、结算打款、报表 |
|
||||
**SC-01~09**:同城购酒 · 现场提货 · 跨城 · 核销 · 拓店 · 售后 · 代下单 · 问卷评价 · 推广归因(见 PRD 原文路径摘要)。
|
||||
|
||||
### 2.2 主场景 SC-01 ~ SC-09
|
||||
|
||||
| ID | 场景 | 路径摘要 |
|
||||
|----|------|----------|
|
||||
| SC-01 | 同城购酒 | 浏览→≥2瓶→微信支付→小飞侠→确认/24h自动→权益→核销 |
|
||||
| SC-02 | 现场提货 | 隐藏入口→支付→直接已完成→权益 |
|
||||
| SC-03 | 跨城购酒 | 未开通城市→≥1箱→总部物流到付→权益;订单佣金归总部 |
|
||||
| SC-04 | 门店核销 | 出码/报手机号→核销→权益扣减→门店账本×60% |
|
||||
| SC-05 | 拓店入驻 | 合伙人录入→负责人复核→总部审核→试核销100元→营业 |
|
||||
| SC-06 | 售后工单 | 用户四类型→总部审→仓/合伙人协同→补发/退款 |
|
||||
| SC-07 | 代下单 | 总部/合伙人手机号建用户下单;在线支付(商家收款码 / 合伙人微信代付)后发权益;配送进入待发货由总部履约,现场提货走自提闭环;订单记代下单人 |
|
||||
| SC-08 | 问卷+评价 | 成交后问卷;核销后门店评价 |
|
||||
| SC-09 | 推广归因 | 推广码进小程序→绑定合伙人→统计成交/佣金 |
|
||||
|
||||
### 2.3 权限摘要
|
||||
|
||||
- 合伙人数据**平级隔离**(全城/区域不可互查)
|
||||
- 推广员:仅门店入驻 + 看自己提交的店(须在管辖范围内)
|
||||
- 门店主账号:子账号、核销、提现、营业状态;店员:仅核销+本店记录
|
||||
- 总部:审核、管辖与佣金配置、打款、全数据
|
||||
- 手机号脱敏:门店/合伙人中间 4 位加密;总部仅管理员看全号
|
||||
**权限**:合伙人平级隔离;推广员仅拓店+看自己店;手机号脱敏(门店/合伙人中间4位)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 业务闭环与核心规则
|
||||
## 3. 核心规则
|
||||
|
||||
### 3.1 五条主闭环
|
||||
### 3.1 五条闭环
|
||||
|
||||
| 闭环 | 触发 | 关键节点 | 终点 |
|
||||
|------|------|----------|------|
|
||||
| 购酒履约 | 用户下单 | 同城小飞侠/跨城总部物流/现场提货 | 已完成 + 权益 1:1 |
|
||||
| 权益核销 | 用户出码/报号 | 门店确认 | 权益扣减 + 门店账本×60% + 核销佣金释放 |
|
||||
| 拓店入驻 | 合伙人录入 | 总部审核 + 试核销 | 营业中 C 端可见 |
|
||||
| 售后工单 | 用户发起 | 总部审 + 协同 | 补发/退款或驳回 |
|
||||
| 结算提现 | 核销入账 | T+1 出账 + 未出账可提 | 总部审后打款 |
|
||||
购酒履约 · 权益核销(60%+核销佣金) · 拓店入驻 · 售后工单 · 结算提现(T+1 出账+未出账可提)。
|
||||
|
||||
### 3.2 订单状态机
|
||||
### 3.2 订单
|
||||
|
||||
```
|
||||
待付款 ──支付成功──► 已付款 ──履约完成/确认收货/24h自动──► 已完成
|
||||
└─ 30 分钟未付取消
|
||||
```
|
||||
|
||||
- **同城**:仓配履约——有仓且绑 API 承运商则自动推单(首期小飞侠);有仓选自管则管仓方手工填单;**无仓**则总部传统快递填单
|
||||
- **大单拦截**:同城订单 **≥10 箱(箱规 6 瓶,即 ≥60 瓶)** 不自动推小飞侠;订单打标「大单待确认」,由总部确认后推小飞侠或自配送(填快递单)
|
||||
- **跨城**:总部传统快递到付填单;订单佣金归总部
|
||||
- **现场提货**:支付后直接已完成;有现场推广码则订单佣金归码所属合伙人,无码归总部
|
||||
`待付款 → 已付款 → 已完成`;30min 未付取消。同城自动推小飞侠(有仓+API);**≥10箱(60瓶)** 大单待 HQ 确认。跨城总部物流到付。现场提货支付即完成。
|
||||
|
||||
### 3.3 佣金与结算
|
||||
|
||||
| 类型 | 比例 | 归属 | 释放时机 |
|
||||
|------|------|------|----------|
|
||||
| 订单佣金 | 按合伙人配置(默认 0%) | §3.3.1 解析 | 支付成功落快照 |
|
||||
| 核销佣金 | 按合伙人配置(默认 3%) | 核销门店归属合伙人 | 按核销金额占比释放 |
|
||||
| 门店结算 | 核销额×60% | 该门店 | T+1 出账;未出账可提现 |
|
||||
|
||||
**约束**:单合伙人订单+核销佣金 ≤ 5%;比例变更仅对新单/新核销生效;账单展示快照。
|
||||
|
||||
#### 3.3.1 多合伙人管辖(v1.2)
|
||||
|
||||
| 类型 | 管辖范围 | 限制 |
|
||||
|------|----------|------|
|
||||
| 全城合伙人 | 该城未被区域占用的区县 | 每城最多 1 名 |
|
||||
| 区域合伙人 | 总部勾选的区县 | 每城多名;同一区县不可重复 |
|
||||
|
||||
**同城订单佣金解析**:收货区县→区域合伙人;未命中→全城合伙人;再无→总部。
|
||||
|
||||
**核销佣金**:归核销门店归属合伙人(拓店绑定,总部可调整;变更后仅影响新核销)。
|
||||
|
||||
**推广码**:归因统计;除现场提货「有码归码」外,不决定同城/跨城订单佣金。
|
||||
|
||||
#### 3.3.2 门店结算与提现
|
||||
|
||||
| 规则 | 结论 |
|
||||
|------|------|
|
||||
| 结算金额 | 核销金额 × 60% |
|
||||
| T+1 出账 | 自然日统计,次日形成账期;法定节假日顺延 |
|
||||
| 手动提现 | 未出账金额可提现(不必等出账日) |
|
||||
| 打款 | 提现申请→总部审核→打款至入驻收款账户 |
|
||||
| 结算异议 | 核销后 3 个工作日内(附凭证) |
|
||||
|
||||
**试点护栏 FIN-001~003**:
|
||||
|
||||
| ID | 规则 |
|
||||
|----|------|
|
||||
| FIN-001 | 未出账提现仅总部白名单门店 |
|
||||
| FIN-002 | 单店单日上限默认 ¥5,000(可配置) |
|
||||
| FIN-003 | 工作日提现 T+0 审完;超时看板预警 |
|
||||
|
||||
各合伙人独立 **T+30** 月账单、独立确认、独立打款(无上下级二次分账)。
|
||||
|
||||
### 3.4 门店账号模型(Wave 2)
|
||||
|
||||
- **一号多店**:同一手机号可作多家店主账号
|
||||
- **主账号唯一**:每店入驻第三步负责人手机号,每店仅一个主账号
|
||||
- **店员子账号**:主账号创建,仅归属单店
|
||||
- **多店登录**:多店时选店列表;7 天免登记住上次所选
|
||||
|
||||
### 3.5 门店入驻 SOP
|
||||
|
||||
对齐《门店签约 SOP v3.0》S1–S12。
|
||||
|
||||
**三步录入**:
|
||||
|
||||
1. 基础信息(门头展示名/执照全称、筛选:面积≥200㎡、客单价≥60、包房≥5 等)
|
||||
2. 证照/合同/照片 + **附件一结构化规则**
|
||||
3. 结算信息(法人收款或授权书+银行卡短信校验)
|
||||
|
||||
**流程**:`暂存 → 待负责人复核 → 待总部审核 → 通过(待试核销) → 试核销100元 → 正式入驻 + 短信`
|
||||
|
||||
合伙人创建门店时,手机号已关联其他店 → 提示确认后可继续。
|
||||
|
||||
### 3.6 工单四类型(§4.4)
|
||||
|
||||
| 类型 | 总部决策 | 通过后 |
|
||||
|------|----------|--------|
|
||||
| 仅退款 | 同意/驳回 | 原路退款 |
|
||||
| 破损补发 | 同意/驳回 | 负责仓配送+取回;通知管仓合伙人 |
|
||||
| 破损退货 | 同意/驳回 | 负责仓取回→退款 |
|
||||
| 退货退款 | 同意/驳回 | 通知归属合伙人+负责仓取回→退款 |
|
||||
|
||||
### 3.7 城市多仓与仓配(Wave 3)
|
||||
|
||||
- 一城多仓;每仓最多关联 1 名管仓合伙人
|
||||
- **仓配管理**(总部):注册第三方履约接口(小飞侠、京东、顺丰等);启用后仓库方可选择;**承运商需配置银行账户、结算方式(充值/挂账月结)、计价标准**
|
||||
- **仓库设置**:履约方式 = API 自动推单(选已注册承运商)或 **自管**(手工填运单号 + 查询链接模板)
|
||||
- 同城有仓订单支付后自动按仓配置推单;自管仓由管仓合伙人/总部代填单;**≥10 箱大单除外**(见 §3.2)
|
||||
- 同城无仓 / 跨城:总部传统快递填单
|
||||
- 佣金与仓无关(订单佣金仍按 §3.3.1);仓用于履约与工单协同
|
||||
- 未关联合伙人的仓 → 总部直派
|
||||
|
||||
#### 3.7.1 物流对账(总部财务)
|
||||
|
||||
| 规则 | 结论 |
|
||||
|------|------|
|
||||
| 汇总维度 | 按快递/仓配承运商(`FulfillmentProvider`)独立月账单 |
|
||||
| 计费口径 | 账期内已发货订单瓶数 × 承运商计价标准(账单落快照) |
|
||||
| 小飞侠默认价 | **2 瓶 6 元**;每加 1 瓶 **+2 元**;**6 瓶一箱 14 元**(整箱按箱费,余瓶按起送阶梯) |
|
||||
| 结算方式 | **前期充值扣款**(`PREPAID`):总部向承运商账户充值,月账单自动/确认扣余额;**后期挂账月结**(`MONTHLY_CREDIT`):生成应付账单后总部确认打款 |
|
||||
| 银行账户 | 每承运商配置收款户名/开户行/支行/账号,打款对照 |
|
||||
| 出账节奏 | 自然月;每月 1 日汇总上月;可手工按承运商/月生成 |
|
||||
### 3.8 弱网核销兜底(Wave 3 · OPT-006)
|
||||
|
||||
- Wave 1~2:网络异常明确提示+重试;总部预警+人工补核销
|
||||
- Wave 3:连续**网络类**失败 5 次→拍照兜底→上传→总部 T+0 补核销
|
||||
- 业务错误(码过期、余额不足等)不计入 5 次;幂等防重复扣款
|
||||
|
||||
### 3.9 门店套餐(引入于 **3.4.10**)
|
||||
|
||||
> 实现设计见 [`杜康好客-门店套餐功能开发文档-v3.4.10.md`](./杜康好客-门店套餐功能开发文档-v3.4.10.md)
|
||||
|
||||
门店可向 C 端展示餐饮套餐信息(与酒水 SKU 无关),用于核销前用户了解可核销内容;**套餐变更审核与整店入驻审核独立**。
|
||||
|
||||
#### 3.9.1 套餐数据结构
|
||||
|
||||
| 字段 | 说明 | 示例 |
|
||||
| 类型 | 默认 | 释放 |
|
||||
|------|------|------|
|
||||
| 套餐名称 | 必填(单条内) | 套餐A |
|
||||
| 价格 | 元 | 198 |
|
||||
| 菜品 | 文本 | 红烧肉、红烧鱼、油焖茄子 |
|
||||
| 使用时间 | 文本 | 节假日除外 |
|
||||
| 其他说明 | 文本 | 不可叠加 |
|
||||
| 订单佣金 | 配置(0%) | 支付快照 |
|
||||
| 核销佣金 | 配置(3%) | 核销时按门店归属合伙人 |
|
||||
| 门店结算 | 60% | T+1;未出账可提现 |
|
||||
|
||||
- 可不填;可多条;**同一门店最多 10 条**
|
||||
- C 端仅展示**已审核通过(生效中)**的套餐,自上而下标题+内容形式
|
||||
**多合伙人**:全城/区域管辖;同城订单佣金按收货区县解析;核销佣金归拓店合伙人。
|
||||
**FIN-001~003**:白名单未出账提现 · 单店日限 ¥5000 · T+0 审完预警。
|
||||
合伙人 **T+30** 独立月账/打款。
|
||||
|
||||
#### 3.9.2 维护与审核
|
||||
### 3.4~3.8 Wave 能力
|
||||
|
||||
| 端 | 能力 | 审核 |
|
||||
|----|------|------|
|
||||
| 合伙人拓店 | 新建流程增加「套餐」页(可跳过) | 随门店或独立提审(见开发文档) |
|
||||
| 合伙人门店详情 | 套餐列表 → 编辑 → **提交审核** | 是 |
|
||||
| 门店端 | 套餐列表 → 编辑 → **提交审核** | 是 |
|
||||
| 总部 | 门店详情新增「套餐」Tab | **直接保存生效,无需审核** |
|
||||
- **3.4** 门店一号多店、主账号、店员子账号(W2)
|
||||
- **3.5** 拓店三步 + SOP + 试核销100
|
||||
- **3.6** 工单四类型:仅退款/破损补发/破损退货/退货退款
|
||||
- **3.7** 多仓、承运商、物流月结对账(小飞侠:2瓶6元 +2/瓶,6瓶箱14元)
|
||||
- **3.8** 弱网5次拍照兜底(W3)
|
||||
|
||||
- 合伙/门店提交后 → 总部收到套餐变更审核 → **通过/驳回**
|
||||
- **通过**:自动替换该店生效套餐;**驳回**:保留上一版生效套餐
|
||||
- 提审中:C 端仍展示上一版已通过套餐(从未通过则不展示)
|
||||
### 3.9~3.11 增量(详开发文档)
|
||||
|
||||
#### 3.9.3 客服异议
|
||||
| § | 主题 | 文档 |
|
||||
|---|------|------|
|
||||
| 3.9 | 门店套餐 ≤10 条、独立审核 | v3.4.10 |
|
||||
| 3.10 | 开发计划/任务/版本/技术支持联动 | v3.4.11 |
|
||||
| 3.11 | 企微智能机器人 + 消息推送 Webhook | v3.4.11 |
|
||||
|
||||
- 用户对核销过程中套餐内容有异议 → 可提交客服申诉
|
||||
- 工单/客服类型新增:**套餐异议**(`PACKAGE_DISPUTE`)
|
||||
---
|
||||
|
||||
### 3.10 开发计划(引入于 **3.4.11**)
|
||||
## 4. REQ 索引
|
||||
|
||||
> 实现设计见 [`杜康好客-开发计划功能开发文档-v3.4.11.md`](./杜康好客-开发计划功能开发文档-v3.4.11.md)
|
||||
> 完整 REQ:`.cursor/skills/dukang-v3/reference-req-index.md`
|
||||
|
||||
总部 admin-web 新增一级菜单 **开发计划**,含版本列表、任务列表、开发设置;与 **技术支持** 工单审批联动(通过后创建开发任务)。
|
||||
| 端 | ID 范围 | 模块要点 |
|
||||
|----|---------|----------|
|
||||
| 用户 | U-001~027 | 四Tab/支付/权益3min/门店/套餐/工单/发票/推广/评价 |
|
||||
| 门店 | S-001~021 | 核销双通道/记录×60%/提现/子账号/套餐提审 |
|
||||
| 合伙人 | P-001~027 | 子账号/拓店+套餐/订单账单/代下单(W3) |
|
||||
| 总部 | H-001~028 | 开城/审核/结算/推广/套餐/开发计划 |
|
||||
|
||||
| 子模块 | 要点 |
|
||||
|--------|------|
|
||||
| 版本 | CRUD;多选关联任务;状态流转自动写时间戳/用时 |
|
||||
| 任务 | 全局任务池 CRUD;勾选 → **评审派发** → 任务派发助手 Webhook(@ 开发者 userid) |
|
||||
| 设置 | 任务派发助手(单例);本地审核 AI 助手 |
|
||||
| 技术支持 | 单一「审批」入口;通过须 ≥1 开发任务;批量 AI 预审 → 人工确认 |
|
||||
**SKU 锚价**:128/168/298/498(瓶)· 768/1008/1788/2988(箱6瓶)。
|
||||
|
||||
企微接入:开发计划任务派发、运营告警、技术支持工单通知均走 HQ「企微机器人 → 消息推送」(Webhook 多实例 + 条件勾选);对话能力由「智能机器人」WebSocket SDK 承担。
|
||||
---
|
||||
|
||||
### 3.11 企微机器人(引入于 **3.4.11**)
|
||||
## 5. 非功能 NFR-001~010
|
||||
|
||||
> HQ 菜单:**企微机器人** → 智能机器人 `/wecom/bots`、消息推送 `/wecom/pushes`、智能机器人日志 `/logs/wecom-bots`
|
||||
7天免登 · 微信单支付 · 验证码3min · 脱敏鉴权 · 幂等 · 弱网/兜底 · 兼容 · 审计 · 24h履约 · 推广归因落库。
|
||||
|
||||
#### 3.11.1 智能机器人(长连接)
|
||||
---
|
||||
|
||||
| 角色 | 默认能力 |
|
||||
|------|----------|
|
||||
| **客服助手** | 订单/配送/门店/核销/售后工单只读 + 创建售后工单 + 短信验证查用户 |
|
||||
| **财务助手** | 门店/合伙人/酒厂/物流账单、打款、提现 **只读** |
|
||||
| **运营助手** | 订单/门店/用户/核销/配送 **只读** |
|
||||
| **技术支持** | 技术支持工单只读/创建;**审批**(通过/驳回);开发计划只读 |
|
||||
| **自定义** | HQ 手工勾选模块化权限 |
|
||||
## 6. 数据与集成
|
||||
|
||||
**权限模型**:按模块划分(如 `order.read`、`finance.store_bill.read`、`support_ticket.review`);废弃 `api.read.all`、`db.read`。聊天回复仅 Markdown 业务摘要,禁止 JSON/tool 泄露。
|
||||
**实体**:用户/商品/订单(佣金快照)/权益/门店/套餐/核销/工单/结算/合伙人/推广/仓(W3)/待处理核销(W3)。
|
||||
|
||||
**审批**:仅机器人配置的 **SuperAdmin 企微 userid 白名单** 可执行 `support_ticket.review`;「通过」须自动从工单标题创建 **1 条** 开发任务(类型映射同 HQ 审批)。
|
||||
|
||||
**审计**:每条查询/审批写入 `log_wecom_bot`,Admin 可筛选 bot、userid、action、时间。
|
||||
|
||||
#### 3.11.2 消息推送(Webhook 多实例,v3.4.11)
|
||||
|
||||
| 字段 | 说明 |
|
||||
| TECH | 系统 |
|
||||
|------|------|
|
||||
| 名称 / 头像 | HQ 列表展示 |
|
||||
| Webhook URL | 企微群机器人 Webhook(**运行时仅读 DB,不读 .env**) |
|
||||
| 启用 | 列表开关 |
|
||||
| @ userid | 可选,markdown `<@userid>` |
|
||||
| 推送条件 | 多选 eventKey,可同时匹配多条推送 |
|
||||
| 011~012 | 微信支付/短信 |
|
||||
| 013 | 小飞侠 |
|
||||
| 014~016 | 微信能力/分享/腾讯位置 |
|
||||
|
||||
**推送条件(eventKey)**:
|
||||
|
||||
| key | 触发场景 |
|
||||
|-----|----------|
|
||||
| `alert.ops` | 售后工单、客户端错误等运营告警 |
|
||||
| `support_ticket.created` | 新建技术支持工单(独立 Markdown,可与 alert.ops 并行) |
|
||||
| `alert.pay` / `alert.redeem` | 支付/核销异常 |
|
||||
| `alert.system` | 5xx、监控、回调 |
|
||||
| `alert.settlement` | 结算 scheduler |
|
||||
| `dev_plan.task_dispatch` | 开发任务评审派发 |
|
||||
|
||||
**迁移**:首次空表时从 `.env` `WECOM_ALERT_WEBHOOK_URL` seed「运营告警」;原 `dev_plan_settings.task_dispatch_*` seed「开发任务派发」。废弃系统设置 `WECOM_ALERT_ENABLED` 与开发设置任务派发 Webhook UI。
|
||||
**金额**:权益=实付1:1 · 门店结算=核销×60% · 试核销=**100元** · 码TTL=**3min**。
|
||||
|
||||
---
|
||||
|
||||
## 4. 功能需求(REQ 索引)
|
||||
## 7~8. UI 与 ACC
|
||||
|
||||
> 完整 REQ 结论见 [`.cursor/skills/dukang-v3/reference-req-index.md`](./.cursor/skills/dukang-v3/reference-req-index.md)
|
||||
|
||||
### 4.1 用户端(小程序)— REQ-U-001 ~ 027
|
||||
|
||||
| 模块 | 要点 |
|
||||
|------|------|
|
||||
| 导航账号 | 四 Tab;无感登录;确认下单提示绑定手机号(可选、不强制);仅微信支付;7 天免登 |
|
||||
| 商品下单 | 4 款酒祖杜康 SKU;同城≥2瓶免运费;跨城≥1箱;现场提货隐藏;锁单30分钟 |
|
||||
| 权益核销 | 1:1 发放;出码 3 分钟;核销前规则弹窗(OPT-011);附件一规则详情 |
|
||||
| 门店 | 列表/详情/搜索/省市区筛选;仅营业中展示;立即核销 |
|
||||
| **门店套餐(3.4.10)** | 门店详情展示生效套餐(标题+内容);套餐异议申诉入口(REQ-U-027) |
|
||||
| 增长售后 | 四类型工单 + **套餐异议**;发票四组合;成交问卷(无激励);推广码;微信商品分享 |
|
||||
| 评价 | 核销后门店评价 |
|
||||
|
||||
**SKU 价格锚点**
|
||||
|
||||
| 商品 | 瓶价 | 箱价(6瓶) |
|
||||
|------|------|-----------|
|
||||
| 酒祖杜康(国标特级10)53° | ¥128 | ¥768 |
|
||||
| 酒祖杜康(国标特级15)53° | ¥168 | ¥1008 |
|
||||
| 酒祖杜康(国标特级20)53° | ¥298 | ¥1788 |
|
||||
| 酒祖杜康(国标特级30)53° | ¥498 | ¥2988 |
|
||||
|
||||
### 4.2 门店端(H5)— REQ-S-001 ~ 021
|
||||
|
||||
| 模块 | 要点 |
|
||||
|------|------|
|
||||
| 登录 | 主账号/店员;多店选店(Wave 2);7 天免登 |
|
||||
| 核销 | 扫码大按钮 + 手机号通道;手机号、金额、验证码与确认核销同页完成,先按手机号和金额发送验证码,验证成功后直接核销;今日汇总;弱网处理 |
|
||||
| 记录结算 | 筛今日/7日/1月/全部;到账金额×60%;T+1 出账 |
|
||||
| 提现 | 未出账可提(FIN 护栏);提现记录;结算异议 3 工作日 |
|
||||
| 账号 | 主账号管理店员(Wave 2);待处理核销单(Wave 3) |
|
||||
| **门店套餐(3.4.10)** | 套餐列表/编辑;提交审核(REQ-S-021) |
|
||||
|
||||
### 4.3 合伙人端(H5)— REQ-P-001 ~ 027
|
||||
|
||||
| 模块 | 要点 |
|
||||
|------|------|
|
||||
| 权限 | 管理员 vs 推广员菜单裁剪 |
|
||||
| 拓店 | 三步录入 + **套餐页(3.4.10,可跳过)**;一号多店确认;负责人复核;试核销100元;附件一 |
|
||||
| 经营 | 首页三卡;排行;订单/权益/工单;佣金快照下钻 |
|
||||
| 财务 | 本合伙人月账对账(Wave 2/3) |
|
||||
| 扩展 | 代下单(Wave 3);管仓只读(Wave 3) |
|
||||
| **门店套餐(3.4.10)** | 拓店套餐页;门店详情套餐列表/编辑/提审(REQ-P-027) |
|
||||
|
||||
### 4.4 总部端(H5)— REQ-H-001 ~ 028
|
||||
|
||||
| 模块 | 要点 |
|
||||
|------|------|
|
||||
| 基础 | 商品管理;营销规则 1:1 权益;签约主体:山西领势酒业有限责任公司 |
|
||||
| 城市合伙人 | 开通城市;创建合伙人(全城/区域+佣金);仓库管理(Wave 3) |
|
||||
| 审核交易 | 门店审核;**套餐变更审核(3.4.10)**;全量订单/权益/工单;发票 2 工作日;补核销 |
|
||||
| 结算 | 门店+合伙人双 Tab;提现审;白名单配置;**物流对账**(按承运商月结) |
|
||||
| 增长 | 推广码;问卷;评价;腾讯位置热力图 |
|
||||
| **门店套餐(3.4.10)** | 门店详情「套餐」Tab 直存;套餐变更审核通过/驳回(REQ-H-025) |
|
||||
| **开发计划(3.4.11)** | 版本/任务/设置;企微 Agent;任务评审派发;技术支持审批联动(REQ-H-026~028) |
|
||||
|
||||
### 4.5 端职责矩阵
|
||||
|
||||
| 能力 | 用户端 | 门店端 | 合伙人端 | 总部端 |
|
||||
|------|:------:|:------:|:--------:|:------:|
|
||||
| 下单/支付/权益 | ● | | 代下单 | 代下单 |
|
||||
| 核销 | 出码 | ● | 看记录 | 看全量 |
|
||||
| 拓店 | 看门店 | 改营业 | ●录入 | ●审核 |
|
||||
| 工单 | ●发起 | 查看 | 查看 | ●决策 |
|
||||
| 结算提现 | | ● | 对账确认 | ●审打款 |
|
||||
| 推广码/问卷/评价 | ● | 被评价 | 看数据 | 配置/看板 |
|
||||
| **门店套餐(3.4.10)** | 看生效套餐 | 编辑提审 | 编辑提审 | ●直存/●审核 |
|
||||
主色杜康红 `#8B1E1E` · 权益金 `#C4A35A` · 空态人话+下一步。
|
||||
ACC 全量:`.cursor/skills/dukang-v3/reference-acc.md`
|
||||
|
||||
---
|
||||
|
||||
## 5. 非功能需求
|
||||
## 9. 三波交付
|
||||
|
||||
| ID | 类别 | 结论 |
|
||||
|----|------|------|
|
||||
| NFR-001 | 登录态 | 四端 7 天免登 |
|
||||
| NFR-002 | 支付 | 仅微信支付;待付款锁单 30 分钟 |
|
||||
| NFR-003 | 短信 | 登录/核销验证码 3 分钟;核销成功必发短信 |
|
||||
| NFR-004 | 安全 | 按端/角色脱敏与鉴权 |
|
||||
| NFR-005 | 幂等 | 支付回调、核销、提现、补核销 |
|
||||
| NFR-006 | 可用性 | Wave 1~2 弱网重试;Wave 3 拍照兜底 |
|
||||
| NFR-007 | 兼容 | 小程序两主版本;H5 微信内置浏览器 |
|
||||
| NFR-008 | 审计 | 工单、审核、打款、分账配置留痕 |
|
||||
| NFR-009 | 履约 | 同城目标 24h;送达未确认 24h 自动完成 |
|
||||
| NFR-010 | 归因 | 推广码与佣金归属下单时落库 |
|
||||
| 波 | 日期 | 门禁 | 含 | 不含 |
|
||||
|----|------|------|-----|------|
|
||||
| W1 | 7.10 | S1~S4 | 登录商品支付核销拓店子账号 | 提现推广现场跨城工单发票 |
|
||||
| W2 | 7.15 | S1~S5 | 提现FIN推广现场门店子账号多合伙 | 代下单弱网多仓跨城发票问卷 |
|
||||
| W3 | 7.22 | 全量 | 代下单弱网多仓跨城工单发票看板 | — |
|
||||
|
||||
---
|
||||
|
||||
## 6. 数据实体与外部依赖
|
||||
|
||||
### 6.1 核心实体(TECH-001 ~ 019)
|
||||
|
||||
用户、商品、订单(含佣金快照)、好客权益账本、门店(主账号/店员)、**门店套餐(3.4.10)**、核销单、工单、结算/提现、合伙人(管辖+佣金)、推广码、仓库(W3)、待处理核销单(W3)、门店账号(W2)。
|
||||
|
||||
### 6.2 外部对接
|
||||
|
||||
| ID | 系统 | 用途 |
|
||||
|----|------|------|
|
||||
| TECH-011 | 微信支付 | 下单、原路退 |
|
||||
| TECH-012 | 短信网关 | 登录/核销验证码、成功通知 |
|
||||
| TECH-013 | 小飞侠 | 同城推单、状态、取送拍照 |
|
||||
| TECH-014 | 微信能力 | 手机号、定位、客服、分享 |
|
||||
| TECH-015 | 微信商品分享 | 分享归因 |
|
||||
| TECH-016 | 腾讯位置服务 | 总部热力图 |
|
||||
|
||||
### 6.3 金额落库口径
|
||||
|
||||
- 权益入账 = 订单实付 × 1:1
|
||||
- 订单佣金 = 订单金额 × 归属合伙人 order_commission_rate(支付成功快照)
|
||||
- 核销佣金 = 按核销金额占比从 verify_commission_rate 池释放(核销时快照)
|
||||
- 门店结算 = 核销金额 × 60%
|
||||
- 物流费 = 按承运商计价标准对单票瓶数计价(小飞侠默认见 §3.7.1)
|
||||
- 核销码 TTL = **3 分钟**;试核销固定 **100 元**
|
||||
|
||||
---
|
||||
|
||||
## 7. UI 规范(§8 摘要)
|
||||
|
||||
- **主色** 杜康红 `#8B1E1E`~`#A12828`;**权益金** `#C4A35A`;**价格** `#C62828`
|
||||
- C 端:品牌优先、商品大图;B 端:核销效率 / 经营看板 / 中控后台
|
||||
- 四端统一「好客权益」图标与权益数字样式
|
||||
- 空态:一句人话 + 可执行下一步
|
||||
|
||||
---
|
||||
|
||||
## 8. 验收标准(ACC 索引)
|
||||
|
||||
> 完整 ACC 见 [`.cursor/skills/dukang-v3/reference-acc.md`](./.cursor/skills/dukang-v3/reference-acc.md)
|
||||
|
||||
| 类别 | 代表 ACC |
|
||||
|------|----------|
|
||||
| 交易 | ACC-001~004 下单/同城/现场/跨城 |
|
||||
| 核销 | ACC-005、ACC-017 两通道+弱网 |
|
||||
| 组织 | ACC-006~007 一号多店/拓店 |
|
||||
| 资金 | ACC-008~009 提现/佣金 |
|
||||
| 售后增长 | ACC-010~014 工单/发票/代下单/问卷/推广码 |
|
||||
| 多合伙人 | ACC-P21~P28 管辖/快照/隔离 |
|
||||
| 多仓弱网 | ACC-W01~W03、ACC-017a |
|
||||
|
||||
---
|
||||
|
||||
## 9. 三波交付计划
|
||||
|
||||
| 波次 | 日期 | 门禁 | 包含 | 不含 |
|
||||
|------|------|------|------|------|
|
||||
| **Wave 1** | 7.10 | W1-S1~S4 | 登录、商品、同城支付、小飞侠、核销、拓店、试核销、合伙人子账号 | 提现、推广码、现场提货、跨城、工单、发票、门店子账号、多合伙人 |
|
||||
| **Wave 2** | 7.15 | W2-S1~S5 | 未出账提现+FIN、打款、推广码、现场提货、门店子账号、多合伙人 | 代下单、弱网拍照、多仓、跨城、工单、发票、问卷、热力图 |
|
||||
| **Wave 3** | 7.22 | 全量 | 代下单、弱网5次拍照、多仓、跨城、工单、发票、月账、问卷/评价/热力图 | — |
|
||||
|
||||
### 9.1 阶段映射(DLV)
|
||||
|
||||
| ID | 波次 | 内容 |
|
||||
|----|------|------|
|
||||
| DLV-W1-M1 | W1 | 四端登录、权限、UI Token、商品 |
|
||||
| DLV-W1-M2 | W1 | 支付、锁单、同城小飞侠、权益 1:1 |
|
||||
| DLV-W1-M3 | W1 | 出码/扫码/手机号核销、规则弹窗 |
|
||||
| DLV-W1-M4 | W1 | 合伙人子账号、门店三步、审核、试核销 |
|
||||
| DLV-W2-M1 | W2 | T+1、未出账提现、总部审打款 |
|
||||
| DLV-W2-M2 | W2 | 现场提货、推广码 |
|
||||
| DLV-W2-M3 | W2 | 多合伙人管辖/佣金 |
|
||||
| DLV-W2-M4 | W2 | 门店子账号、多店选店 |
|
||||
| DLV-W3-M1 | W3 | 代下单 |
|
||||
| DLV-W3-M2 | W3 | 弱网拍照兜底 |
|
||||
| DLV-W3-M3 | W3 | 多仓、跨城、工单、发票 |
|
||||
| DLV-W3-M4 | W3 | 月账、问卷/评价/热力图 |
|
||||
| DLV-W3-M5 | W3 | ACC 全量回归 |
|
||||
|
||||
### 9.2 OPT 波次口径
|
||||
|
||||
| OPT | 波次 | 说明 |
|
||||
|-----|------|------|
|
||||
| OPT-002 代下单 | W3 | 合伙人主账号:选品/履约(配送须勾选自动收货或现场提货)→创建待支付订单→收款码或微信代付(合伙人 openId)→支付成功发权益;配送单 PENDING_SHIP 由总部发货,合伙人可看物流;现场提货支付后自提闭环;新用户 sourceType=PARTNER_PROXY;总部代下单同为在线收款码支付 |
|
||||
| OPT-006 弱网 | W3 | W1~2 重试+人工补核销 |
|
||||
| OPT-010 未出账提现 | W2 | 含 FIN-001~003 |
|
||||
| OPT-005 现场提货 | W2 | — |
|
||||
| OPT-001 发票 | W3 | — |
|
||||
| OPT-004 问卷 | W3 | 无权益激励 |
|
||||
| OPT-012 话术 | W3 | 上线后 3 日内对齐 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 风险(R1~R8)
|
||||
|
||||
| ID | 风险 | 处置 |
|
||||
|----|------|------|
|
||||
| R1 | 小飞侠对接延期 | 并行联调;备选手动改状态 |
|
||||
| R2 | 弱网重复扣款 | 幂等键 + OPT-006 |
|
||||
| R3 | 三波排期 | 严格 W1→W2→W3;人工补核销/代下单 SOP |
|
||||
| R4 | 发票 SLA | 看板预警 |
|
||||
| R5 | 话术不同步 | OPT-012 3 日内更新 |
|
||||
| R6 | 腾讯位置服务 | P2 不阻塞 L1 |
|
||||
| R7 | 未出账提现资金 | FIN 护栏 |
|
||||
| R8 | 多合伙人管辖冲突 | 区县互斥 + 快照 + 全覆盖校验 |
|
||||
|
||||
---
|
||||
|
||||
## 11. 变更记录
|
||||
|
||||
| 版本 | 日期 | 说明 |
|
||||
|------|------|------|
|
||||
| **3.4.10** | 2026-08-03 | 新增 §3.9 门店套餐;REQ-P-027 / REQ-S-021 / REQ-H-025 / REQ-U-027;套餐异议工单类型 |
|
||||
| v3.0.2 | 2026-08-02 | OPT-002/SC-07:代下单改为在线支付(收款码/合伙人微信代付);去掉线下已收款直完成;配送单总部履约、合伙人可看物流 |
|
||||
| v3.0.1 | 2026-07-27 | ACC-012/OPT-002/SC-07:明确合伙人代下单双短信、线下完成、来源 PARTNER_PROXY、C 端展示代下单人 |
|
||||
| v3.0 | 2026-07-11 | 由产品 PRD v1.3 整理为工程 V3.0 事实源;配套 `@dukang-v3` skill 与 `v3-delivery-lead` agent |
|
||||
|
||||
---
|
||||
|
||||
## 附录 A · 需求变更流程
|
||||
|
||||
1. 业务规则变更 → **先改本文件** → 同步 `杜康好客-v3编码手册.md` + `shared-types`/`domain`
|
||||
2. 编码 Agent **不得**边写代码边改业务规则
|
||||
3. 跨模块改动 → 相关双 Owner Review;Prisma 迁移 jacy-dukang 主 Review(store/redeem 需刘景尧)
|
||||
DLV 任务卡:`.cursor/skills/dukang-task-card/`
|
||||
|
||||
+22
-70
@@ -1,86 +1,38 @@
|
||||
# 杜康好客 · V3 埋点规范
|
||||
|
||||
> **版本**:2026-08-03
|
||||
> **存储**:C 端 → `log_user_analytics`;门店 → `log_store_analytics`;合伙人 → `log_partner_analytics`
|
||||
> **契约**:[`packages/shared-types/src/*-log.ts`](../packages/shared-types/src/)
|
||||
> **查询**:admin-web `/logs/users` · `/logs/stores` · `/logs/partners`
|
||||
> 存储:C→`log_user_analytics` · 门店→`log_store_analytics` · 合伙人→`log_partner_analytics`
|
||||
> 契约:`packages/shared-types/src/*-log.ts` · HQ:`/logs/users|stores|partners`
|
||||
|
||||
## 原则
|
||||
|
||||
1. 端侧 **page_view / 点击** 走客户端 `track()`;业务结果(支付成功、核销确认)走后端 `AnalyticsService.track*Safe`
|
||||
2. 所有客户端事件携带 `sessionId`(localStorage `dukang_session_id`),支持匿名漏斗
|
||||
3. `extraJson` 不含密码/令牌;手机号脱敏
|
||||
4. eventName 必须先登记在 shared-types taxonomy,再 emit
|
||||
1. 端侧 page_view/点击 → `track()`;支付/核销结果 → 后端 `AnalyticsService.track*Safe`
|
||||
2. 携带 `sessionId`(`dukang_session_id`)
|
||||
3. `extraJson` 无密码/令牌;手机号脱敏
|
||||
4. eventName 先登记 taxonomy 再 emit
|
||||
|
||||
## API
|
||||
|
||||
| 端 | 路由 | 鉴权 |
|
||||
|----|------|------|
|
||||
| C 端 | `POST /api/v1/analytics/events` | OptionalJwt |
|
||||
| 门店 | `POST /api/v1/analytics/store-events` | Jwt + STORE |
|
||||
| 合伙人 | `POST /api/v1/analytics/partner-events` | Jwt + PARTNER |
|
||||
| 推广 | `POST /api/v1/promo/touch` | OptionalJwt(双写 `promo_touch` 埋点) |
|
||||
| C | `POST /analytics/events` | OptionalJwt |
|
||||
| 门店 | `POST /analytics/store-events` | Jwt+STORE |
|
||||
| 合伙人 | `POST /analytics/partner-events` | Jwt+PARTNER |
|
||||
| 推广 | `POST /promo/touch` | OptionalJwt(双写 touch) |
|
||||
|
||||
## 完整 eventName 清单
|
||||
## eventName
|
||||
|
||||
见 shared-types:
|
||||
完整清单 → `user-log.ts` / `store-log.ts` / `partner-log.ts`
|
||||
|
||||
- [`user-log.ts`](../packages/shared-types/src/user-log.ts) — C 端 13 个 category
|
||||
- [`store-log.ts`](../packages/shared-types/src/store-log.ts) — 门店 8 个 category
|
||||
- [`partner-log.ts`](../packages/shared-types/src/partner-log.ts) — 合伙人 9 个 category
|
||||
## extraJson 要点
|
||||
|
||||
## extraJson 字段约定
|
||||
| 端 | 常用字段 |
|
||||
|----|----------|
|
||||
| C | sessionId, cityCode, productId, storeId, orderId, amount, pagePath, failReason |
|
||||
| 门店 | redeemChannel, amount, durationMs, failReason |
|
||||
| 合伙人 | storeId, orderId, billId, cityCode |
|
||||
|
||||
### C 端(UserAnalyticsExtra)
|
||||
## 漏斗(摘要)
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| sessionId | 会话 ID,漏斗串联 |
|
||||
| cityCode | 开城/地域 |
|
||||
| productId / skuId | 商品偏好 |
|
||||
| storeId | 门店偏好 |
|
||||
| orderId | 订单归因 |
|
||||
| amount / quantity | 客单价 |
|
||||
| sourceType / sourceRefId | 获客渠道 |
|
||||
| failReason | 失败原因 |
|
||||
| pagePath | 页面路径 |
|
||||
|
||||
### 门店(StoreAnalyticsExtra)
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| redeemChannel | scan / phone / pending |
|
||||
| amount | 核销金额 |
|
||||
| durationMs | 耗时 |
|
||||
| failReason | 失败原因 |
|
||||
|
||||
### 合伙人(PartnerAnalyticsExtra)
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| storeId / orderId / billId | 业务关联 |
|
||||
| cityCode | 城市维度 |
|
||||
|
||||
## 转化漏斗(C 端)
|
||||
|
||||
```
|
||||
session_start → home_view → product_detail_view → order_confirm_view
|
||||
→ order_submit → pay_page_view → pay_success
|
||||
→ benefit_redeem_start → redeem_code_view → benefit_redeem_success
|
||||
```
|
||||
|
||||
## 核销漏斗(门店)
|
||||
|
||||
```
|
||||
store_home_view → store_redeem_scan_start → store_redeem_preview
|
||||
→ store_redeem_confirm | store_redeem_confirm_fail
|
||||
```
|
||||
|
||||
## 合伙人经营漏斗
|
||||
|
||||
```
|
||||
partner_home_view → partner_store_create_view → partner_store_create
|
||||
→ partner_store_audit_approved
|
||||
partner_order_list_view → partner_order_ship
|
||||
partner_bills_view → partner_bill_detail_view → partner_bill_confirm
|
||||
```
|
||||
- **C**:session→home→detail→confirm→submit→pay→redeem
|
||||
- **门店**:home→scan→preview→confirm
|
||||
- **合伙人**:home→store_create→audit / orders→ship / bills→confirm
|
||||
|
||||
+23
-110
@@ -1,124 +1,37 @@
|
||||
# 杜康好客 · V3 城市 / 仓库 / 日志架构
|
||||
# 杜康好客 · 城市 / 仓库 / 日志架构
|
||||
|
||||
> **版本**:2026-07-12
|
||||
> **状态**:P1 城市多合伙 + P2 仓库 **已实现**(2026-07-12);合伙人端管仓日志仍待 Wave 3
|
||||
> **分工**:刘景尧任务暂由 `jacy-dukang` 代管(见 `AGENTS.md`)
|
||||
> **2026-07-12** P1 多合伙 + P2 仓库 ✅ · 合伙人管仓日志 ⏳ Wave 3
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标数据模型(城市顶层)
|
||||
## 数据模型(摘要)
|
||||
|
||||
```
|
||||
CommonCity (开城)
|
||||
├── PartnerAccount[] 1:N 主账号(isPrimary=1,含城市绑定 + 购酒/核销佣金)
|
||||
├── CityWarehouse[] 1:N 城市仓库(HQ 管 / 合伙人管)
|
||||
└── CatalogProduct[] 按城市上架
|
||||
|
||||
PartnerAccount (主账号 = 城市合伙人主体)
|
||||
├── cityId / scopeType / districtCodes / bindingStatus
|
||||
├── orderCommissionRate / redeemCommissionRate
|
||||
├── companyName / 银行 / 合同等主体信息
|
||||
├── managedWarehouseId? 可选管仓
|
||||
└── PartnerAccount[] 子账号(parentAccountId,permissions JSON)
|
||||
|
||||
CityWarehouse
|
||||
├── cityId
|
||||
├── managerType: HQ | PARTNER
|
||||
└── partnerAccountId? 合伙人管仓时绑定主账号
|
||||
|
||||
Store
|
||||
├── partnerAccountId FK → 主账号
|
||||
└── settlementRate 门店核销结算比例(默认 0.60)
|
||||
CommonCity → PartnerAccount(主) → Store(partnerAccountId, settlementRate)
|
||||
→ CityWarehouse(HQ|PARTNER 管仓)
|
||||
→ CatalogProduct
|
||||
PartnerAccount → 子账号(parentAccountId, permissions JSON)
|
||||
```
|
||||
|
||||
**迁移要点(2026-07)**:删除 `partner_partner`、`common_city_partner`、`common_city_commission_rule`;城市绑定与购酒分佣合并至 `partner_account` 主账号;门店结算比例下沉至 `store_store.settlementRate`;订单快照字段为 `partnerAccountIdAtPay` + `orderCommissionRateAtPay`。
|
||||
迁移:城市绑定/购酒分佣合并至 `partner_account`;订单快照 `partnerAccountIdAtPay` + `orderCommissionRateAtPay`。
|
||||
|
||||
---
|
||||
## 日志职责
|
||||
|
||||
## 2. 日志表与职责划分
|
||||
| 主体 | 表 | 入口 |
|
||||
|------|-----|------|
|
||||
| HQ 写操作 | `common_event` HQ_OPERATION | `@HqOperation` → `/logs/hq` |
|
||||
| 合伙人 | `log_partner_analytics` | `/logs/partners` |
|
||||
| 门店 | `log_store_analytics` | `/logs/stores` |
|
||||
| C 端 | `log_user_analytics` | `/logs/users` |
|
||||
| 权益/订单 | `common_event` BENEFIT_LEDGER / ORDER_STATUS | 领域查询 |
|
||||
|
||||
| 操作主体 | 日志表 | eventType / eventName | 查询入口 |
|
||||
|----------|--------|----------------------|----------|
|
||||
| **HQ WebAdmin** 写操作 | `common_event` | `HQ_OPERATION` + `param1=action` | admin-web `/logs/hq` |
|
||||
| **合伙人端** 行为 | `log_partner_analytics` | `partner_*` eventName | admin-web `/logs/partner` |
|
||||
| **门店端** 行为 | `log_store_analytics` | `store_*` | admin-web `/logs/store` |
|
||||
| **C 端** 行为 | `log_user_analytics` | 埋点 eventName | admin-web `/logs/user` |
|
||||
| 权益/订单状态机 | `common_event` | `BENEFIT_LEDGER` / `ORDER_STATUS` | 领域查询 |
|
||||
## HQ action(新业务)
|
||||
|
||||
**原则**:HQ 侧 **CRUD / 审核 / 结算确认** 一律 `@HqOperation` → `common_event`;端侧 **登录 / 业务操作** 走对应 `log_*_analytics`。
|
||||
`CITY_*` · `WAREHOUSE_*` · `PARTNER_*` · `PARTNER_ACCOUNT_*` · `HQ_ACCOUNT_*` · `HQ_PERMISSION_*`
|
||||
常量:`hq-operation.constants.ts` · 筛选项:`apps/admin-web/src/lib/hq-log.ts`
|
||||
|
||||
---
|
||||
## 合伙人子账号 eventName
|
||||
|
||||
## 3. CRUD → 日志映射(新业务)
|
||||
`partner_staff_create|update|permission_update|delete` ✅ · `partner_warehouse_*` ⏳
|
||||
|
||||
### 3.1 HQ 侧(`common_event.HQ_OPERATION`)
|
||||
## DoD
|
||||
|
||||
| 实体 | refType | action 常量 | 装饰器状态 |
|
||||
|------|---------|-------------|------------|
|
||||
| 开城城市 | `CITY` | `CITY_CREATE` / `CITY_UPDATE` | ✅ 已接入 |
|
||||
| 开城城市 | `CITY` | `CITY_DELETE` | ⏳ 待 API |
|
||||
| 城市仓库 | `WAREHOUSE` | `WAREHOUSE_CREATE` / `UPDATE` / `DELETE` | ✅ 已接入 |
|
||||
| 城市合伙人(主账号) | `PARTNER` | `PARTNER_CREATE` / `PARTNER_UPDATE` | ✅ 已接入 |
|
||||
| 合伙人账号(HQ) | `PARTNER_ACCOUNT` | `PARTNER_ACCOUNT_CREATE` / `UPDATE` / `DELETE` | ✅ 已接入 |
|
||||
| HQ 管理员 | `HQ_ACCOUNT` | `HQ_ACCOUNT_CREATE` / `UPDATE` | ✅ 已接入 |
|
||||
| HQ 权限 | `HQ_PERMISSION` | `HQ_PERMISSION_UPDATE` | ✅ 已接入 |
|
||||
|
||||
实现约定:
|
||||
|
||||
- Controller 方法加 `@HqOperation({ action, refType, refIdField|refIdParam, includeBody })`
|
||||
- `extraJson` 自动写入 `requestBody`(脱敏 password)+ `response` 摘要
|
||||
- 常量定义:`server/.../hq-operation.constants.ts`;admin 筛选项:`apps/admin-web/src/lib/hq-log.ts`
|
||||
|
||||
### 3.2 合伙人端子账号(`log_partner_analytics`)
|
||||
|
||||
| 操作 | eventName | refType | 状态 |
|
||||
|------|-----------|---------|------|
|
||||
| 主账号新增子账号 | `partner_staff_create` | `PARTNER_ACCOUNT` | ✅ `PartnerStaffService` |
|
||||
| 编辑子账号 | `partner_staff_update` | `PARTNER_ACCOUNT` | ✅ |
|
||||
| 仅改角色/权限 | `partner_staff_permission_update` | `PARTNER_ACCOUNT` | ✅ |
|
||||
| 删除子账号 | `partner_staff_delete` | `PARTNER_ACCOUNT` | ✅ |
|
||||
| 仓库查看/维护(合伙人管仓) | `partner_warehouse_view` / `partner_warehouse_update` | `WAREHOUSE` | ⏳ Wave 3 |
|
||||
|
||||
分类:`packages/shared-types/src/partner-log.ts` → `account_ops` / `warehouse_ops`。
|
||||
|
||||
### 3.3 门店子账号
|
||||
|
||||
| 操作 | 日志表 | action / eventName | 状态 |
|
||||
|------|--------|-------------------|------|
|
||||
| HQ 创建/编辑门店账号 | `common_event` | `STORE_ACCOUNT_CREATE` / `UPDATE` | ✅ |
|
||||
| 门店端自助(若有) | `log_store_analytics` | 待定义 `store_staff_*` | ⏳ |
|
||||
|
||||
---
|
||||
|
||||
## 4. 实现检查清单(DoD)
|
||||
|
||||
每条 CRUD 合并前确认:
|
||||
|
||||
- [ ] HQ 写接口有 `@HqOperation`,且 `HqOperationAction` + `hq-log.ts` 标签已同步
|
||||
- [ ] 合伙人端写接口调用 `AnalyticsService.trackPartnerOneSafe`,eventName 已登记在 `partner-log.ts`
|
||||
- [ ] `extraJson` 不含明文密码/令牌;手机号脱敏
|
||||
- [ ] admin-web 日志页可按 action / category 筛选到新事件
|
||||
- [ ] 跨模块不直写他人日志表(经 Analytics / HqOperationLogService)
|
||||
|
||||
---
|
||||
|
||||
## 5. 分阶段交付
|
||||
|
||||
| 阶段 | 内容 | 依赖 |
|
||||
|------|------|------|
|
||||
| **P0 日志补全** | 子账号 CRUD 落 `log_partner_analytics`;扩展 HQ action 常量 | 无 |
|
||||
| **P1 城市架构** | `city_partner` 表、迁移 `partner_id`、HQ CRUD + `@HqOperation` | ✅ 2026-07-12 |
|
||||
| **P2 仓库** | `city_warehouse` 表、HQ CRUD + 合伙人管仓校验 | ✅ 2026-07-12 |
|
||||
| **P3 权限 JSON** | `PartnerAccount.permissions` 替代纯 `staffRole`;权限变更双写 HQ/合伙人日志 | P1 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 相关文件
|
||||
|
||||
| 路径 | 说明 |
|
||||
|------|------|
|
||||
| `server/dukang-api/src/common/hq-operation/` | HQ 审计装饰器 + 拦截器 |
|
||||
| `server/dukang-api/src/modules/iam/partner-staff.service.ts` | 合伙人子账号 + 日志 |
|
||||
| `packages/shared-types/src/partner-log.ts` | 合伙人日志分类 |
|
||||
| `apps/admin-web/src/pages/HqLogsPage.tsx` | HQ 操作日志 |
|
||||
| `apps/admin-web/src/pages/PartnerLogsPage.tsx` | 合伙人日志 |
|
||||
HQ 写接口有 `@HqOperation`;合伙人写走 `trackPartnerOneSafe`;extraJson 脱敏;不跨模块直写他人日志表。
|
||||
|
||||
+51
-340
@@ -1,359 +1,70 @@
|
||||
# 杜康好客 · V3.0 现状对照表
|
||||
# 杜康好客 · V3.0 现状对照
|
||||
|
||||
> **对照基准**:仅 [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md)(V3.0 产品事实源)
|
||||
> **审计日期**:2026-07-11
|
||||
> **说明**:V2 / preV1 手册**不再作为需求依据**;本表只回答「相对 V3.0 PRD,当前代码处于什么状态」。
|
||||
> **工程口径**:总部端 **保持 `apps/admin-web`(WebAdmin)**,不改为 H5;PRD 中「总部 H5」按 **REQ-H 功能在 admin-web 实现** 验收。
|
||||
> **图例**:✅ 已完成 · 🔶 部分完成 · ⚠️ 与 V3.0 冲突(需改造) · ❌ 未实现
|
||||
> 基准:仅 [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md) · 总部 = **`admin-web`**(非 H5)
|
||||
> 图例:✅ 完成 · 🔶 部分 · ❌ 未做 · ⚠️ 曾冲突已修
|
||||
|
||||
---
|
||||
|
||||
## 0. 总览
|
||||
## 0. 总览(2026-08-06)
|
||||
|
||||
| 维度 | 结论 |
|
||||
|------|------|
|
||||
| **整体完成度(粗估)** | Wave 1 约 **45%**;Wave 2 约 **10%**;Wave 3 约 **5%** |
|
||||
| **可跑通的主链路** | 登录 → 浏览商品 → 同城下单 Mock/微信支付 → 权益 1:1 → **扫码核销** → T+1 门店 payout 记录 → 总部确认打款 |
|
||||
| **最大阻塞** | C 端小程序迁移(暂缓)、Wave 1 缺口(手机号核销/规则弹窗/试核销/自动推小飞侠) |
|
||||
| **P0 冲突** | C2~C7、C14 **已修复**(2026-07-12) |
|
||||
| **冒烟覆盖** | `scripts/smoke-v3.mjs` 仅覆盖窄 W1 切片,**不等于** V3.0 ACC 全量 |
|
||||
| 主链路 | 登录→下单支付→权益→扫码核销→payout→HQ 打款 **可跑通** |
|
||||
| C 端 | `mini-user` 小程序 + h5-user;v3.4.13 版本门控/门店/物流已上 |
|
||||
| 近期版本 | v3.4.10 套餐 · v3.4.11 开发计划/企微 · v3.4.12 工单迭代 · v3.4.13 体验优化 **已发生产** |
|
||||
| 冒烟 | `scripts/smoke-v3.mjs` 窄路径 ≠ 全量 ACC |
|
||||
| REQ 明细 | PRD §4 + `.cursor/skills/dukang-v3/reference-req-index.md` |
|
||||
|
||||
### Wave 门禁状态
|
||||
## 1. P0 冲突(2026-07-12 ✅)
|
||||
|
||||
| 门禁 | 状态 | 一句话 |
|
||||
|------|------|--------|
|
||||
| **W1-S1** 下单+支付+权益 | 🔶 | TTL/Tab/SKU/佣金已对齐;仍缺小程序形态 |
|
||||
| **W1-S2** 门店核销双通道 | 🔶 | 扫码 ✅;手机号核销 ❌ |
|
||||
| **W1-S3** 合伙人子账号 | ✅ | CRUD + 推广员菜单裁剪已有 |
|
||||
| **W1-S4** 拓店→审核→C 端可见 | 🔶 | 三步录入有;试核销100/负责人复核/SOP 缺失 |
|
||||
| **W2-S1~S5** | ❌ | 提现/FIN/现场提货/多合伙人/门店子账号 基本未做 |
|
||||
| **W3-S1~S4** | ❌ | 代下单/弱网拍照/多仓/跨城履约/四类型工单/发票/问卷/热力图 基本未做 |
|
||||
| # | 项 | 状态 |
|
||||
|---|-----|------|
|
||||
| C2~C3 | 核销/短信 TTL **3min** | ✅ |
|
||||
| C4~C5 | 订单 Tab/列表 **三态** | ✅ |
|
||||
| C6~C7 | 4 SKU + 佣金 **0%+3%** | ✅ |
|
||||
| C13 | 多合伙人主账号模型 | ✅ |
|
||||
| C14 | 文档旧口径清理 | ✅ |
|
||||
| C1 | C 端小程序 | 🔶 mini-user 进行中 |
|
||||
| C8~C12 | 一号多店/自动推单/提现/四类型工单/现场提货 | 🔶~❌ 见 PRD Wave |
|
||||
|
||||
---
|
||||
## 2. 按域快照
|
||||
|
||||
## 0.1 已确认的工程口径(非冲突)
|
||||
| 域 | ✅ 已有 | 🔶/❌ 主要缺口 |
|
||||
|----|---------|----------------|
|
||||
| **mini-user** | 购酒/权益/核销/门店/物流/版本门控 | 规则弹窗、发票、四类型工单、问卷 |
|
||||
| **h5-shop** | 扫码核销、记录、营业、iOS 扫码 OAuth | 手机号核销、提现、子账号、弱网兜底 |
|
||||
| **h5-partner** | 子账号、拓店、订单/账单、套餐 | 试核销100、负责人复核、代下单 |
|
||||
| **admin-web** | 商品/开城/门店/订单/权益/核销/结算/推广码 metrics/开发计划/技术支持 | 完整 SOP 审核 UI、发票、热力图 |
|
||||
| **后端** | 主模块、支付、权益、核销、payout、Courier 适配 | 30min 取消 job、部分 Wave3 |
|
||||
|
||||
| 项 | PRD 原文 | V3 工程决策 |
|
||||
|----|----------|-------------|
|
||||
| 总部端形态 | 总部 H5 | **`apps/admin-web`(WebAdmin)为唯一交付端,不改为 H5** |
|
||||
| 验收方式 | REQ-H-* | 功能在 admin-web 实现即可;不要求微信内 H5 或小程序总部端 |
|
||||
| `mini-hq` | — | 非主交付;与 admin-web 重叠能力以 **admin-web 补齐** 为准 |
|
||||
## 3. 场景 SC-01~09
|
||||
|
||||
---
|
||||
|
||||
## 1. ⚠️ 冲突项与改造计划
|
||||
|
||||
> 代码已存在但与 V3.0 PRD 直接矛盾。**P0 已于 2026-07-12 执行**;P1/P2 排后续迭代。
|
||||
|
||||
### 1.1 改造计划总览
|
||||
|
||||
| 阶段 | 冲突项 | 内容 | 状态 |
|
||||
|------|--------|------|------|
|
||||
| **P0** | C2~C7、C14 | TTL、订单三 Tab、SKU seed、佣金默认、文档规则 | ✅ **已执行** |
|
||||
| **P1** | C1 | C 端 H5 过渡;目标微信小程序另立项 | 📋 已决策暂缓 |
|
||||
| **P1** | C9 | 支付后自动推小飞侠 | 📋 待做(Wave 1) |
|
||||
| **P2** | C8 | 一号多店(schema + 拓店确认) | 📋 待做(Wave 2) |
|
||||
| **P2** | C10 | 门店主动提现 + FIN 护栏 | 📋 待做(Wave 2) |
|
||||
| **P2** | C12 | 现场提货 `ON_SITE_PICKUP` | 📋 待做(Wave 2) |
|
||||
| **P3** | C11 | 工单四类型 | 📋 待做(Wave 3) |
|
||||
| **P3** | C13 | 多合伙人管辖/佣金快照 | 📋 待做(Wave 2~3) |
|
||||
|
||||
### 1.2 冲突明细
|
||||
|
||||
| # | V3.0 要求 | 改造前 | 状态 | 改造记录(2026-07-12) |
|
||||
|---|-----------|--------|------|------------------------|
|
||||
| C1 | C 端微信小程序 | `h5-user` H5 | 📋 暂缓 | 工程决策:H5 过渡,小程序另立项 |
|
||||
| C2 | 核销码 TTL **3 分钟** | 300s | ✅ 已修复 | `REDEEM_TOKEN_TTL_SECONDS=180`;C 端文案 |
|
||||
| C3 | 短信验证码 **3 分钟** | 300s | ✅ 已修复 | `SMS_CODE_TTL_SECONDS=180` |
|
||||
| C4 | 订单 Tab 三态 | 5 Tab | ✅ 已修复 | `pending_pay` / `paid` / `completed` |
|
||||
| C5 | 列表展示三态 | 9 态直出 | ✅ 已修复 | 内部状态保留;Tab/列表映射三态 |
|
||||
| C6 | 4 款酒祖杜康 + 锚点价 | preV1 SKU | ✅ 已修复 | `seed-v31.ts` 四 SKU ¥128~498 |
|
||||
| C7 | 佣金 0%+3% | seed 5% 订单 | ✅ 已修复 | seed + `admin-cities` + settlement 默认 |
|
||||
| C8 | 一号多店 | phone @unique | 📋 P2 | 待 schema + `partnerCheckStorePhone` |
|
||||
| C9 | 支付后推小飞侠 | MANUAL delivery | 📋 P1 | 待 `trade.service` 支付回调 |
|
||||
| C10 | 门店主动提现 | 自动 StorePayout | 📋 P2 | 待提现申请流 |
|
||||
| C11 | 工单四类型 | 3 enum | 📋 P3 | 待扩展 `TicketType` |
|
||||
| C12 | 现场提货 | 无 DeliveryType | 📋 P2 | 待 `ON_SITE_PICKUP` |
|
||||
| C13 | 多合伙人 | 单 partnerId | ✅ 已修复 | 主账号 `partner_account` 合并城市绑定;`partner_partner`/`city_partner` 已删 |
|
||||
| C14 | 文档残留旧口径 | 5min/5 Tab | ✅ 已修复 | `apps/AGENTS.md`、rules、agents、skills |
|
||||
|
||||
### 1.3 P0 已改文件清单
|
||||
|
||||
| 模块 | 文件 |
|
||||
| 场景 | 状态 |
|
||||
|------|------|
|
||||
| 契约 | `packages/shared-types/src/enums.ts` |
|
||||
| 规则 | `packages/domain/src/index.ts`、`index.test.ts` |
|
||||
| 短信 | `server/.../sms/sms-code.store.ts` |
|
||||
| 佣金 | `seed-v31.ts`、`admin-cities.service.ts`、`settlement.service.ts` |
|
||||
| C 端 | `OrderListPage.tsx`、`MinePage.tsx`、`PayPage.tsx`、`RedeemPage.tsx`、`RedeemCodePage.tsx` |
|
||||
| 文档 | `apps/AGENTS.md`、`.cursor/rules/*`、`.cursor/agents/owner-d-shop-redeem.md` |
|
||||
| SC-01 同城 | 🔶 支付权益✅;自动推单/24h 完成 部分 |
|
||||
| SC-02 现场提货 | 🔶 |
|
||||
| SC-03 跨城 | 🔶 |
|
||||
| SC-04 核销 | 🔶 扫码✅;手机号通道❌ |
|
||||
| SC-05 拓店 | 🔶 三步✅;试核销/SOP 部分 |
|
||||
| SC-06 售后 | 🔶 REFUND 有;四类型 部分 |
|
||||
| SC-07~09 | 代下单❌ · 问卷🔶 · 推广🔶 |
|
||||
|
||||
> **重跑 seed**:本地需 `cd server/dukang-api && pnpm prisma:seed` 后商品/佣金数据才与 C6/C7 一致。
|
||||
## 4. 版本交付索引
|
||||
|
||||
---
|
||||
|
||||
## 2. ✅ 已完成(与 V3.0 对齐或基本对齐)
|
||||
|
||||
### 2.1 用户端 `apps/h5-user`
|
||||
|
||||
| REQ | 功能 | 证据 | 备注 |
|
||||
|-----|------|------|------|
|
||||
| REQ-U-001 | 四 Tab 导航 | `src/layouts/TabLayout.tsx` | 形态为 H5 非小程序 |
|
||||
| REQ-U-002 | 登录 + 下单提示手机号(可选) | `OrderConfirmPage` / `order-confirm`、微信登录签发会话 | ✅ |
|
||||
| REQ-U-003 | 微信支付 | `pay-wechat.ts` + 后端 callback | Mock/真实均有 |
|
||||
| REQ-U-004 | 定位/开城 | `HomePage.tsx`、`wechat-location.ts` | 🔶 H5 定位 API |
|
||||
| REQ-U-005~006 | 商品列表/详情 | `HomePage.tsx`、`ProductDetailPage.tsx` | ✅ seed 已对齐酒祖杜康 |
|
||||
| REQ-U-007 | 同城 ≥2 瓶 | `packages/domain` + `trade.service.ts` | ✅ |
|
||||
| REQ-U-010 | 锁单写入 30min | `trade.service.ts` `payExpireAt` | 🔶 无自动取消 job |
|
||||
| REQ-U-011 | 权益 1:1 | `benefit.service.ts` | ✅ |
|
||||
| REQ-U-012 | 客服入口 | `CustomerServicePage.tsx` | 🔶 Mock 对话 |
|
||||
| REQ-U-013~014 | 门店列表/详情 | `StoreListPage.tsx` 等 | ✅ 仅 OPEN |
|
||||
| REQ-U-015 | 出码核销 | `RedeemPage.tsx` | ✅ TTL 3 分钟 |
|
||||
| REQ-U-017 | 核销后评价 | `RedeemSuccessPage.tsx` | ✅ |
|
||||
| REQ-U-018 | 权益页 Tab | `BenefitPage.tsx` | ✅ |
|
||||
| REQ-U-019 | 个人中心 | `MinePage.tsx` | ✅ |
|
||||
| REQ-U-024~025 | 推广码/分享(部分) | `lib/promo.ts`、`WechatShareBootstrap.tsx` | 🔶 Wave 2 完整归因 |
|
||||
|
||||
### 2.2 门店端 `apps/h5-shop`
|
||||
|
||||
| REQ | 功能 | 证据 |
|
||||
|-----|------|------|
|
||||
| REQ-S-001 | 三/四 Tab 导航 | `App.tsx` |
|
||||
| REQ-S-002 | 登录 + 7 天 session | `lib/api.ts` |
|
||||
| REQ-S-004 | 扫码核销 | `HomePage.tsx` + JSSDK |
|
||||
| REQ-S-007 | 今日汇总 | `HomePage.tsx` dashboard |
|
||||
| REQ-S-009 | 核销记录 + ×60% | `RecordsPage.tsx` |
|
||||
| REQ-S-010~011 | 营业状态/门店信息 | `StatusPage.tsx`、`MinePage.tsx` |
|
||||
|
||||
### 2.3 合伙人端 `apps/h5-partner`
|
||||
|
||||
| REQ | 功能 | 证据 |
|
||||
|-----|------|------|
|
||||
| REQ-P-001 | 导航 | `PartnerAppRoutes.tsx` |
|
||||
| REQ-P-002 | 7 天免登 | `auth.service.ts` refresh 7d |
|
||||
| REQ-P-003 | 推广员菜单裁剪 | `partnerAccess.ts`、`SubAccountLayout` |
|
||||
| REQ-P-005 | 合伙人子账号 | `StaffListPage.tsx`、`partner-staff.service.ts` |
|
||||
| REQ-P-009 | 拓店三步(简化版) | `StoreCreatePage.tsx`、`storeDraft.ts` |
|
||||
| REQ-P-013~017 | 首页/排行/订单 | `HomePage.tsx`、`OrderListPage.tsx` 等 |
|
||||
| REQ-P-020 | 合伙人账单列表 | `SettlementPage.tsx` + settlement API |
|
||||
|
||||
### 2.4 总部端 `apps/admin-web`(WebAdmin)
|
||||
|
||||
> **交付口径**:总部 = WebAdmin,**不改为 H5**。REQ-H 功能在本端验收;`mini-hq` 仅作参考或非阻塞补充。
|
||||
|
||||
| REQ | 功能 | 证据 | 备注 |
|
||||
|-----|------|------|------|
|
||||
| REQ-H-001 | 登录 | `admin-web/LoginPage.tsx` | ✅ |
|
||||
| REQ-H-002 | 商品 CRUD | `ProductsPage.tsx` | ✅ |
|
||||
| REQ-H-004 | 开城 | `CitiesPage.tsx` | ✅ |
|
||||
| REQ-H-005~006 | 门店管理/状态 | `StoresPage.tsx` | 🔶 非完整 SOP 审核 UI |
|
||||
| REQ-H-008 | 全量订单 | `OrdersPage.tsx` | ✅ |
|
||||
| REQ-H-009 | 权益 | `BenefitCouponsPage.tsx` | ✅ |
|
||||
| REQ-H-010 | 工单(部分) | `TicketsPage.tsx` | 🔶 仅 REFUND/RESHIPMENT |
|
||||
| REQ-H-013~014 | 门店结算打款 | `StoreBillsPage.tsx` | 🔶 非 PRD 提现流 |
|
||||
| REQ-H-015 | 合伙人账单 | `PartnerBillsPage.tsx` | ✅ |
|
||||
| REQ-H-017 | 推广码 | `mini-hq/pages/promo/`(参考) | ❌ **admin-web 缺页**,需在 WebAdmin 补齐 |
|
||||
|
||||
### 2.5 后端与集成
|
||||
|
||||
| 项 | 证据 | 备注 |
|
||||
|----|------|------|
|
||||
| 模块骨架 iam/trade/benefit/catalog/store/redeem/settlement/ops/analytics | `server/dukang-api/src/modules/` | ✅ |
|
||||
| 核销 → 自动创建 StorePayout(×60%) | `redeem.service.ts`、`settlement.service.ts` | ⚠️ 模式见 C11 |
|
||||
| Prisma v3.1 schema(28+ 模型) | `prisma/schema.prisma` | ⚠️ 缺多合伙人/仓库/提现等 |
|
||||
| domain 起购/权益规则 | `packages/domain/src/index.ts` | ✅ |
|
||||
| 微信支付 mock + 真实 | `integrations/pay/`、`callbacks/wechat-pay` | ✅ |
|
||||
| 短信 mock + 阿里云 | `integrations/sms/` | ⚠️ TTL 见 C4 |
|
||||
| 小飞侠集成层 | `integrations/courier/xiaofeixia/` | 🔶 未支付后自动推单 |
|
||||
| 腾讯逆地理 | `integrations/map/tencent-lbs` | 🔶 无热力图 |
|
||||
| JWT 7 天 | `iam.module.ts` | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 3. ❌ 未实现 / 重大缺口
|
||||
|
||||
### 3.1 Wave 1 缺口(7.10 门禁相关)
|
||||
|
||||
| REQ / 能力 | 说明 |
|
||||
|------------|------|
|
||||
| **REQ-S-005 OPT-007** 手机号核销通道 | 门店端仅扫码;无「查余额→用户收验证码→门店输入」全流程 |
|
||||
| **REQ-U-016 OPT-011** 核销规则弹窗 | 出码前无附件一/规则弹窗 |
|
||||
| **REQ-P-011 OPT-009** 试核销 100 元 | 无「待试核销」状态与固定 100 元试核逻辑 |
|
||||
| **REQ-P-024** 负责人复核 | 创建后直接 `PENDING` 或 auto-approve,无「待负责人复核」 |
|
||||
| **REQ-P-023 / REQ-U-026** 附件一结构化 | 无菜品/酒水/服务费等可核销勾选 |
|
||||
| **REQ-P-012 OPT-008** 入驻成功短信 | 未见正式入驻短信模板发送 |
|
||||
| **支付后自动推小飞侠** | 见冲突 C10 |
|
||||
| **待付款 30min 自动取消** | 仅写 `payExpireAt`,无 cancel job |
|
||||
| **核销成功短信 NFR-003** | `redeem.service` 无 SMS 通知 |
|
||||
| **NFR-009 送达 24h 自动完成** | Mock delivery 快速推进,非 24h 逻辑 |
|
||||
|
||||
### 3.2 Wave 2 缺口(7.15)
|
||||
|
||||
| REQ / 能力 | 说明 |
|
||||
|------------|------|
|
||||
| **REQ-U-008 OPT-005** 现场提货 | 无隐藏入口、无支付即完成 |
|
||||
| **REQ-S-012~016 OPT-010** 未出账提现 + FIN-001~003 | 无提现申请、白名单、单日上限 |
|
||||
| **REQ-H-014a** 提现白名单配置 | 无 |
|
||||
| **REQ-S-017~019** 门店子账号 + 多店选店 | 无店员角色、无选店列表 |
|
||||
| **REQ-P-008** 一号多店确认弹窗 | 当前直接拒绝(见 C9) |
|
||||
| **REQ-H-004a / REQ-H-007** 多合伙人管辖与佣金 | 主账号 `partner_account` + HQ 合伙人页;支付写 `partnerAccountIdAtPay` 快照 | 🔶 基础已做;ACC-P21~P28 全套验收待补 |
|
||||
| **ACC-P21~P28** 多合伙人全套 | 主账号模型 + HQ UI + 域规则单测 | 🔶 部分完成 |
|
||||
| **推广码完整归因(W2 门禁)** | 后端有部分 API;**admin-web 缺推广码管理页** |
|
||||
| **合伙人 T+30 独立确认打款** | 有 bill generate;合伙人确认/打款流不完整 |
|
||||
|
||||
### 3.3 Wave 3 缺口(7.22)
|
||||
|
||||
| REQ / 能力 | 说明 |
|
||||
|------------|------|
|
||||
| **REQ-P-021 / REQ-H-012** 代下单 | 无 |
|
||||
| **REQ-S-008a / REQ-S-020 / REQ-H-020a** 弱网 5 次拍照兜底 | 无待处理核销单模型与 UI |
|
||||
| **REQ-H-004b / REQ-P-026** 一城多仓 + 管仓合伙人 | `city_warehouse` + admin 仓库 Tab 已做;工单协同待 Wave 3 |
|
||||
| **REQ-U-009 + SC-03** 跨城完整履约 | 后端有 `CROSS_CITY` 检测;总部物流 UX/佣金归总部未闭环 |
|
||||
| **REQ-U-021 OPT-001** 发票 | 无模块 |
|
||||
| **REQ-U-022** 四类型工单用户端 | 仅 refund-request |
|
||||
| **REQ-U-023 OPT-004** 成交问卷 | 无 |
|
||||
| **REQ-H-016 / REQ-H-023** 热力图 | 无看板 |
|
||||
| **REQ-H-018~019** 问卷/评价总部看板 | 评价有 DB;总部看板无 |
|
||||
| **REQ-H-021 OPT-012** 话术对齐 | 无运营文档交付机制 |
|
||||
|
||||
### 3.4 门店套餐(目标版本 **3.4.10**,未实现)
|
||||
|
||||
> 需求:[`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md) §3.9 · 开发设计:[`杜康好客-门店套餐功能开发文档-v3.4.10.md`](./杜康好客-门店套餐功能开发文档-v3.4.10.md)
|
||||
|
||||
| REQ / 能力 | 说明 |
|
||||
|------------|------|
|
||||
| **REQ-P-027** 合伙人拓店套餐页 + 门店套餐编辑提审 | 拓店仍三步;无套餐页;无 `StorePackage` 实体 |
|
||||
| **REQ-S-021** 门店端套餐列表/编辑/提审 | 门店端仅营业状态;无资料编辑 API |
|
||||
| **REQ-H-025** 总部套餐 Tab 直存 + 套餐变更审核 | 门店 Drawer 无套餐 Tab;无独立套餐审核流 |
|
||||
| **REQ-U-027** C 端展示生效套餐 + 套餐异议 | 门店详情无套餐区块;工单无 `PACKAGE_DISPUTE` |
|
||||
| **数据模型** | 无 `store_package` / `store_package_change_request` 表 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 按端汇总矩阵
|
||||
|
||||
| 端 | ✅ 已完成 | ⚠️ 冲突 | ❌ 未实现 | 粗估完成率 |
|
||||
|----|----------|---------|----------|------------|
|
||||
| **用户端** | 登录、商品、下单、权益、扫码、门店、评价 | 端形态、Tab、TTL、SKU | 现场提货、规则弹窗、发票、四类型工单、问卷 | ~50% |
|
||||
| **门店端** | 扫码核销、记录、营业状态 | 结算模式 | 手机号核销、提现、子账号、多店、弱网兜底 | ~35% |
|
||||
| **合伙人端** | 子账号、拓店三步、订单/账单 | 一号多店、佣金模型 | 试核销、负责人复核、附件一、代下单、管仓 | ~40% |
|
||||
| **总部端(WebAdmin)** | 商品、开城、订单、部分工单/结算 | 佣金默认 | 多合伙人、提现审、发票、热力图、推广码页、补核销审 | ~35% |
|
||||
| **后端** | 主模块、支付、权益、扫码核销、payout | TTL、状态机、佣金、账号模型 | 自动取消、自动推单、提现、多仓、快照 | ~40% |
|
||||
| **集成** | 微信/短信/xiaofeixia 骨架 | 验证码 TTL | 支付后自动推单、热力图、核销短信 | ~35% |
|
||||
|
||||
---
|
||||
|
||||
## 5. 场景闭环 SC-01 ~ SC-09
|
||||
|
||||
| 场景 | 状态 | 说明 |
|
||||
| 版本 | 文档 | 状态 |
|
||||
|------|------|------|
|
||||
| SC-01 同城购酒 | 🔶 | 下单支付权益 ✅;小飞侠自动推单 ❌;24h 自动完成 ❌ |
|
||||
| SC-02 现场提货 | ❌ | 全链路未做 |
|
||||
| SC-03 跨城购酒 | 🔶 | 起购校验有;总部物流到付履约 ❌ |
|
||||
| SC-04 门店核销 | 🔶 | 扫码 ✅;手机号 ❌;短信通知 ❌ |
|
||||
| SC-05 拓店入驻 | 🔶 | 三步录入 ✅;复核/试核销100/SOP ❌ |
|
||||
| SC-06 售后工单 | 🔶 | 总部处理 REFUND 有;四类型 ❌ |
|
||||
| SC-07 代下单 | ❌ | Wave 3 |
|
||||
| SC-08 问卷+评价 | 🔶 | 评价 ✅;问卷 ❌ |
|
||||
| SC-09 推广归因 | 🔶 | touch API 有;现场提货归码 ❌ |
|
||||
| 3.4.10 | [`门店套餐`](./杜康好客-门店套餐功能开发文档-v3.4.10.md) | ✅ |
|
||||
| 3.4.11 | [`开发计划`](./杜康好客-开发计划功能开发文档-v3.4.11.md) | ✅ |
|
||||
| 3.4.12 | [`工单迭代`](./杜康好客-v3.4.12-工单迭代开发文档.md) | ✅ |
|
||||
| 3.4.13 | [`体验优化`](./杜康好客-v3.4.13-体验优化开发文档.md) | ✅ 生产 |
|
||||
| 3.4.14 | [`mini-user 门店体验 + 小程序可配置 + 测试白名单`](./杜康好客-v3.4.14-mini-user门店体验开发文档.md) | 🔶 开发中 |
|
||||
| 3.4.15 | [`mini-user 门店列表优化`](./杜康好客-v3.4.15-mini-user门店列表优化.md) | 🔶 开发中 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 建议改造顺序(仅 V3.0 视角)
|
||||
|
||||
### P0 — 消除冲突 ✅ 已完成(2026-07-12)
|
||||
|
||||
C2~C7、C14 见 §1.3。
|
||||
|
||||
### P1 — 补齐 Wave 1 门禁(3~5 天)
|
||||
|
||||
1. REQ-S-005 手机号核销全链路
|
||||
2. REQ-U-016 核销规则弹窗
|
||||
3. REQ-P-011 试核销 100 + REQ-P-024 负责人复核
|
||||
4. C9 支付后自动推小飞侠
|
||||
5. 待付款 30min 自动取消 job
|
||||
6. 核销成功短信
|
||||
|
||||
### P2 — Wave 2(按 PRD 7.15)
|
||||
|
||||
提现+FIN、现场提货、推广码闭环、多合伙人 schema、门店子账号/多店
|
||||
|
||||
### P3 — Wave 3(按 PRD 7.22)
|
||||
|
||||
代下单、弱网拍照、多仓、跨城履约、四类型工单、发票、问卷、热力图
|
||||
|
||||
### 平台决策(需产品确认)
|
||||
|
||||
- **C1**:`h5-user` 过渡 vs 立即建 `mini-user`
|
||||
- **总部端**:已确认保持 **WebAdmin**,不改为 H5
|
||||
|
||||
---
|
||||
|
||||
## 7. 审计方法
|
||||
|
||||
- 需求源:仅 `杜康好客-v3-PRD.md` + `@dukang-v3` reference
|
||||
- 代码:`apps/*`、`server/dukang-api/src/modules/*`、`packages/*`、`prisma/schema.prisma`、`seed-v31.ts`
|
||||
- 验证脚本:`scripts/smoke-v3.mjs`(窄路径)
|
||||
- **未使用** V2 / preV1 手册作为需求对照
|
||||
|
||||
---
|
||||
|
||||
## 8. v3.4.11 开发计划(2026-08-04)
|
||||
|
||||
| 项 | 状态 | 说明 |
|
||||
|----|------|------|
|
||||
| Prisma 六表 + shared-types | ✅ | `dev_plan_*` |
|
||||
| DevPlanModule API | ✅ | `/admin/dev-plan/*` |
|
||||
| 技术支持 review + 批量 AI | ✅ | `SupportTicketReviewAiService` |
|
||||
| admin-web 三页面 + 菜单 | ✅ | `/dev-plan/versions|tasks|settings` |
|
||||
| 企微机器人角色/权限重构 | ✅ | 四类角色、模块化权限、`log_wecom_bot`、Capability 门面 |
|
||||
| 企微菜单重组 | ✅ | 智能机器人 `/wecom/bots`、消息推送 `/wecom/pushes`、日志 |
|
||||
| 消息推送多实例 | ✅ | `wecom_message_push`、eventKey 分发、迁移 env/旧派发配置 |
|
||||
| 文档 | ✅ | PRD §3.10 + §3.11 + 开发文档 v3.4.11 |
|
||||
|
||||
---
|
||||
|
||||
## 9. v3.4.12 工单迭代(2026-08-04)
|
||||
|
||||
| 项 | 状态 | 说明 |
|
||||
|----|------|------|
|
||||
| P0 售后退款回滚 | ✅ | `initiateRefund` 微信失败恢复 PAID |
|
||||
| mini-user 门店详情 | ✅ | 门头轮播 preview + 环境图双列 |
|
||||
| 酒厂 T+3 | ✅ | `WINERY_SETTLEMENT_LAG_DAYS=3` |
|
||||
| 门店多笔提现 | ✅ | 移除单 pending 限制 |
|
||||
| 审批企微派发 | ✅ | review `dispatchToWecom` |
|
||||
| 开发任务批量编辑 | ✅ | `POST /admin/dev-plan/tasks/batch-update` |
|
||||
| 技术支持编辑/附件 | ✅ | `PATCH /admin/support-tickets/:id` + `attachmentUrls` |
|
||||
| 技术支持批量改状态 | ✅ | domain 状态机 + batch API |
|
||||
| 套餐 imageUrl | ✅ | 四端 + Prisma |
|
||||
| 文档 | ✅ | PRD §0.4 + v3.4.12 开发文档 |
|
||||
|
||||
---
|
||||
|
||||
## 10. v3.4.13 体验优化(2026-08-05)
|
||||
|
||||
| 项 | 状态 | 说明 |
|
||||
|----|------|------|
|
||||
| 推广码 attributionCount | ✅ | HQ 详情统计卡 + **log_promo_event 事件日志 + metrics/timeline ECharts** |
|
||||
| 核销用户信息 | ✅ | RedeemRecordsPage 列表+详情 |
|
||||
| 技术支持优先级 | ✅ | Prisma + shared-types + HQ UI |
|
||||
| 合伙人微信暂停禁登 | ✅ | loginPartnerWechat |
|
||||
| H5 登录前端校验 | ✅ | partner/shop LoginPage |
|
||||
| mini-user 门店体验 | ✅ | 电话脱敏/埋点、**列表两段营业时间**、门头 aspectFill 铺满、**套餐页签切换** |
|
||||
| mini-user 商品/提货 | ✅ | 去分享、首图 preview、提货确认弹框 |
|
||||
| mini-user 版本/物流 | ✅ | 摘要 + 签收照 + 拨号 + 时间线 + ETA;回调签收→**COMPLETED** |
|
||||
| OSS 大图压缩 | ✅ | shared-ui compressImage;四端 upload + mini 头像 |
|
||||
| 文档 | ✅ | PRD §0.5 + v3.4.13 开发文档 |
|
||||
|
||||
---
|
||||
|
||||
## 11. 变更记录
|
||||
## 5. 变更记录
|
||||
|
||||
| 日期 | 说明 |
|
||||
|------|------|
|
||||
| 2026-08-05 | v3.4.13 体验优化(20 条 ST) |
|
||||
| 2026-08-04 | v3.4.12 工单迭代(退款/财务/C端/开发计划/技术支持/套餐) |
|
||||
| 2026-08-04 | v3.4.11 开发计划 + 企微智能机器人/消息推送 + 角色权限重构 |
|
||||
| 2026-07-12 | **P0 已执行**:C2~C7、C14 代码与文档对齐;§1 改为计划+状态表 |
|
||||
| 2026-07-12 | 明确总部端保持 WebAdmin,不改为 H5 |
|
||||
| 2026-07-11 | 首版:V3.0 PRD 全量对照当前 monorepo |
|
||||
| 2026-08-07 | v3.4.14 增补统一测试白名单(不计账 / 限测可见 / Mock 旁路) |
|
||||
| 2026-08-06 | v3.4.15 mini-user 门店列表卡片优化(开发中) |
|
||||
| 2026-08-06 | v3.4.14 mini-user 门头/套餐详情(开发中) |
|
||||
| 2026-08-05 | v3.4.13 |
|
||||
| 2026-08-04 | v3.4.11 / v3.4.12 |
|
||||
| 2026-07-11 | 首版对照表 |
|
||||
|
||||
+13
-37
@@ -1,44 +1,20 @@
|
||||
# 杜康好客 · v3.4.12 工单迭代开发文档
|
||||
# 杜康好客 · v3.4.12 工单迭代
|
||||
|
||||
> 版本:**v3.4.12** · 日期:2026-08-04
|
||||
> 需求源:ST 工单 + 开发计划/技术支持批量能力补充
|
||||
> **2026-08-04** · 已上线 · PRD §0.4
|
||||
|
||||
## 1. 范围
|
||||
|
||||
| 模块 | 内容 |
|
||||
|------|------|
|
||||
| 域 | 交付 |
|
||||
|----|------|
|
||||
| P0 售后 | `initiateRefund` 微信失败回滚订单状态 |
|
||||
| mini-user | 门店详情门头 preview、环境图双列 |
|
||||
| 财务 | 酒厂账单 T+3;门店可多笔 pending 提现 |
|
||||
| 开发计划 | 审批创建任务可选企微派发;任务批量改状态/关联版本 |
|
||||
| 技术支持 | 单条编辑/附件;批量改状态(状态机) |
|
||||
| 套餐 | `StorePackage.imageUrl` 四端;HQ 删除至 0 条 UX |
|
||||
| mini-user | 门头 preview、环境图双列 |
|
||||
| 财务 | 酒厂 T+3;门店可多笔 pending 提现 |
|
||||
| 开发计划 | 审批可选企微派发;`POST .../tasks/batch-update` |
|
||||
| 技术支持 | PATCH 编辑/附件;`batch-update-status` |
|
||||
| 套餐 | `StorePackage.imageUrl` 四端 |
|
||||
|
||||
**不在本版**:ST1785812832352437(门店列表,已完成)
|
||||
## API
|
||||
|
||||
## 2. 后端 API
|
||||
`POST /admin/dev-plan/tasks/batch-update` · `PATCH /admin/support-tickets/:id` · `POST .../batch-update-status` · review 扩展 `dispatchToWecom`
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| — | `trade.initiateRefund` | 失败时 `REFUNDING→原状态` + 事件 |
|
||||
| — | `settlement.generateWineryBillForDay` | `wineryDayWindow(lag=3)` |
|
||||
| — | `validateStoreWithdraw` | 移除 `hasPendingRequest` |
|
||||
| POST | `/admin/dev-plan/tasks/batch-update` | `{ taskIds, status?, versionId? }` |
|
||||
| POST | `/admin/support-tickets/:id/review` | 扩展 `dispatchToWecom`, `dispatchSupplement` |
|
||||
| PATCH | `/admin/support-tickets/:id` | 待评审可编辑 |
|
||||
| POST | `/admin/support-tickets/batch-update-status` | 批量改状态 |
|
||||
## ACC
|
||||
|
||||
## 3. 数据表
|
||||
|
||||
- `common_support_ticket.attachment_urls` JSON
|
||||
- `store_package.image_url` VARCHAR(512)
|
||||
|
||||
## 4. 验收
|
||||
|
||||
- [ ] 售后 REFUND 审批后 Mock 退款成功
|
||||
- [ ] mini-user 门头点击大图、环境图双列
|
||||
- [ ] 酒厂账单滞后 3 天;门店第二笔提现可提交
|
||||
- [ ] 审批勾选企微后开发群收到任务
|
||||
- [ ] 任务多选批量改状态/关联版本
|
||||
- [ ] 技术支持待评审可编辑附件;批量 TESTING→PASSED
|
||||
- [ ] 套餐 imageUrl 四端展示;HQ 可删至 0 条
|
||||
退款 Mock 成功 · 门头大图 · T+3/多笔提现 · 企微派发 · 批量改任务/工单状态 · 套餐 imageUrl
|
||||
|
||||
+48
-152
@@ -1,162 +1,58 @@
|
||||
# 杜康好客 · v3.4.13 体验优化开发文档
|
||||
# 杜康好客 · v3.4.13 体验优化
|
||||
|
||||
> 版本:**v3.4.13** · 日期:2026-08-05
|
||||
> 需求源:20 条 ST 工单(体验优化 / Bug / 客户端验证)
|
||||
> **2026-08-05** · 20 ST · 已发生产 `0181af0` · PRD §0.5 · 现状对照 §10
|
||||
|
||||
## 1. 范围
|
||||
## ST 交付一览
|
||||
|
||||
| 模块 | 内容 |
|
||||
| ST | 交付要点 |
|
||||
|----|----------|
|
||||
| ST1785925037781309 | 推广码 attributionCount + `log_promo_event` + metrics/timeline/events + HQ ECharts |
|
||||
| ST1785924286682833 | 门店电话 maskPhone + `store_phone_call` 埋点 |
|
||||
| ST1785921693982470 | 工单 `priority` + HQ 筛选/编辑 |
|
||||
| ST1785921585900725 | 门店 H5:OAuth 单例回调 + iOS JSSDK 预热/重试 + 授权后续扫 |
|
||||
| ST1785907536648201 | 我的页 `v3.4.x`;`minClientVersion` 过低 → UpdateManager 或 `exitMiniProgram` |
|
||||
| ST1785906800359657 | 合伙人暂停:发码前 `phone/check`,微信/SMS 均拒 |
|
||||
| ST1785906592340255 / ST1785901711627824 | partner/shop LoginPage 空字段前端拦截 |
|
||||
| ST1785906390321653 | 现场提货提交 `showModal` 确认 |
|
||||
| ST1785905773501871 | 核销列表 userNo/nickname/phone;详情含门店/券分摊/结算/评价 |
|
||||
| ST1785904849234806 | 工单中心(已有) |
|
||||
| ST1785904076632841 | 门店列表「开城合伙人」= `partnerOptionLabel`(company/name/phone) |
|
||||
| ST1785902173093977 / ST1785902141731113 | 商品去分享;首图 preview |
|
||||
| ST1785901870913349 | 门店列表展示两段营业时间 |
|
||||
| ST1785901838948572 | SHOP 未绑定门店(已有) |
|
||||
| ST1785901775231811 | 门店套餐页签横滑切换 |
|
||||
| ST1785901314893145 | 门头 aspectFill 铺满 + preview |
|
||||
| ST1785939375449985 | 物流:签收照/拨号/时间线/ETA;小飞侠回调签收→`COMPLETED` |
|
||||
| — | 图片 >10MB:`@dukang/shared-ui/compressImage`(各端 upload + mini 头像) |
|
||||
| — | 权益券详情抽屉 980px;核销详情对齐权益券内抽屉 |
|
||||
| — | 版本 `RELEASED` → 关联任务 `RELEASED`;关联工单 `PUBLISHED` + `releasedVersionNo` |
|
||||
|
||||
## 契约摘要
|
||||
|
||||
**API**
|
||||
|
||||
| 路径 | 说明 |
|
||||
|------|------|
|
||||
| admin-web | 推广码 attributionCount + **指标事件日志/ECharts 趋势**;**权益券详情加宽**;**核销记录详情增强(对齐权益券内核销详情)**;技术支持工单优先级;**版本发布联动任务/工单已发布**;**大图上传前压缩** |
|
||||
| mini-user | 门店电话脱敏+拨打埋点;**门店列表展示两段营业时间**;**门店详情门头 aspectFill 铺满无留白**;**门店套餐页签切换**;商品去分享+首图 preview;提货确认弹框;**我的页版本号 v3.4.x**;**低于 minClientVersion 强制更新/点我知道了退出**;**物流增强(签收照/拨号/时间线/ETA)**;**头像超 10MB 压缩** |
|
||||
| h5-partner / h5-shop / h5-user | 登录 phone/code 前端校验;**门店端 iOS 首次扫码 OAuth 单例 + JSSDK 预热重试**;**OSS 图片超 10MB 自动 canvas 压缩后上传** |
|
||||
| 后端 | 工单 priority;**工单 PUBLISHED + releasedVersionNo**;**版本 RELEASED 联动任务/工单**;client-config minClientVersion;合伙人微信暂停禁登;**Courier 适配器**;**log_promo_event 推广码指标日志** |
|
||||
| `GET /common/client-config` | `minClientVersion` ← `MINI_USER_MIN_VERSION` |
|
||||
| `GET /trade/orders/:id/track` | nodes、signPhotoUrls、estimatedArrival(Courier 100102/108/301) |
|
||||
| `POST /callbacks/courier/xfx/track` | 小飞侠路由回调;生产 `api.dukanghaoke.com`,测试 `api-test.dukanghaoke.com` |
|
||||
| `GET /admin/promo-codes/:id/metrics/{timeline,events}` | 推广码四指标时序 + 事件分页 |
|
||||
|
||||
## 2. ST 映射
|
||||
**表/枚举**
|
||||
|
||||
| ST | 标题 | 状态 |
|
||||
|----|------|------|
|
||||
| ST1785925037781309 | 总部端-推广码数据跟踪优化 | ✅ attributionCount + **事件日志 + ECharts 趋势** |
|
||||
| ST1785924286682833 | 用户端-门店电话加密+拨打埋点 | ✅ maskPhone + store_phone_call |
|
||||
| ST1785921693982470 | 技术支持-工单优先级 | ✅ priority 枚举 + HQ UI |
|
||||
| ST1785921585900725 | 门店端扫一扫授权异常 | ✅ **OAuth 单例回调 + iOS JSSDK 预热/重试 + 授权后续扫** |
|
||||
| ST1785907536648201 | 版本不对提示更新 | ✅ 我的页 v3.4.x + UpdateManager + **低于 min 强制更新/退出** |
|
||||
| ST1785906800359657 | 合伙人暂停后禁登 | ✅ **发码前 phone/check + 微信/SMS 登录均拒 DISABLED** |
|
||||
| ST1785906592340255 | PARTNER_H5 login 校验 | ✅ 前端空字段拦截 |
|
||||
| ST1785906390321653 | 现场提货提交确认弹框 | ✅ showModal |
|
||||
| ST1785905773501871 | 核销记录用户信息 | ✅ 列表+详情;**详情含门店/合伙人/券分摊/结算/评价** |
|
||||
| ST1785904849234806 | 工单中心 | ✅ 已有 |
|
||||
| ST1785904076632841 | 门店列表开城合伙人 | ✅ **列表展示 companyName/name/phone(同 partnerOptionLabel)** |
|
||||
| ST1785902173093977 | 去掉商品详情分享按钮 | ✅ 移除 ShareNavButton |
|
||||
| ST1785902141731113 | 商品首图大图 | ✅ previewable |
|
||||
| ST1785901870913349 | 门店列表营业时间 | ✅ **列表展示两段营业时间** |
|
||||
| ST1785901838948572 | SHOP 未绑定门店 | ✅ 已有 |
|
||||
| ST1785901775231811 | 门店套餐遮挡 | ✅ **页签切换(可横滑)+ 紧凑内容区** |
|
||||
| ST1785901711627824 | SHOP login 校验 | ✅ 前端空字段拦截 |
|
||||
| ST1785901314893145 | 门头照裁剪 | ✅ aspectFill 铺满无留白 + preview |
|
||||
| ST1785939375449985 | 订单物流追踪页 | ✅ 签收照 + 拨号 + 时间线 + ETA + 路由回调 |
|
||||
| — | 上传图片超 10MB 先压缩 | ✅ `@dukang/shared-ui/compressImage` + 各端 upload |
|
||||
- `log_promo_event`:SCAN / ATTRIBUTION / REGISTER / ORDER(含 IP/地点;仅统计上线后事件)
|
||||
- `common_support_ticket`:`priority`;`status` 增 `PUBLISHED`;`released_version_no`、`published_at`
|
||||
- 小飞侠回调:status **5** 或 statusName 含「签收」→ 订单 **COMPLETED**(幂等);7 取消仅日志
|
||||
|
||||
## 3. 后端 API / 配置
|
||||
**HQ 开发计划**
|
||||
|
||||
| 方法 | 路径 / 配置 | 说明 |
|
||||
|------|-------------|------|
|
||||
| GET | `/common/client-config` | `minClientVersion`(`MINI_USER_MIN_VERSION`);客户端 semver 比对,低于则强制更新或退出 |
|
||||
| — | `CommonSupportTicket.priority` | `LOW \| NORMAL \| HIGH \| URGENT`,默认 NORMAL |
|
||||
| — | `CommonSupportTicket.status` | 新增 `PUBLISHED`(已发布);`releasedVersionNo` + `publishedAt` |
|
||||
| — | `DevPlanVersion` → `RELEASED` | 关联任务 → `RELEASED`;关联工单 → `PUBLISHED` 并写入版本号 |
|
||||
| — | `loginPartnerWechat` | 非 ACTIVE 账号抛出「合伙人账号已停用」 |
|
||||
| GET | `/trade/orders/:id/track` | 聚合:`nodes`(旧→新)、`signPhotoUrls`、`estimatedArrival`;经 `CourierService` 适配小飞侠 cmd 100102/100108/100301 |
|
||||
| POST | `/callbacks/courier/xfx/track` | 小飞侠路由变化回调(适配器入口) |
|
||||
| POST | `/callbacks/courier/logistics/track` | 跨城物流回调占位(记录日志,后续接入) |
|
||||
| POST | `/callbacks/delivery/track` | 兼容旧路径,等同 `xfx` |
|
||||
| GET | `/admin/promo-codes/:id/metrics/timeline` | 推广码四指标时间序列(按日/按时 + peak) |
|
||||
| GET | `/admin/promo-codes/:id/metrics/events` | 推广码指标事件分页日志(时间/ID/IP/地点) |
|
||||
创建版本 `v3.4.13` 并关联 ST 任务;标记 **已发布** 时自动联动任务/工单(见上表最后一行)。
|
||||
|
||||
### 3.1 推广码指标日志(ST1785925037781309)
|
||||
## 验收抽样
|
||||
|
||||
**表** `log_promo_event`
|
||||
|
||||
| event_type | 统计卡 | 写入时机 | ID |
|
||||
|------------|--------|----------|-----|
|
||||
| `SCAN` | 扫码进入数 | `POST /promo/touch` 且 `countScan !== false` | userId / sessionId |
|
||||
| `ATTRIBUTION` | 归因用户数 | 首次写入 `user_promo_attribution` | userId |
|
||||
| `REGISTER` | 扫码注册用户数 | `applyPromoSourceToUser` 成功 | userId |
|
||||
| `ORDER` | 订单数 | 带推广码下单 `orderCount++` | orderId + userId |
|
||||
|
||||
每条日志含 `created_at`、可选 ID、`client_ip`、`ip_province`/`ip_city`。仅统计**上线后**新事件;累计 Statistic 卡逻辑不变。
|
||||
|
||||
**HQ UI**:推广码详情页 → 数据趋势 Card(DatePicker + 按日/按时 + ECharts 四曲线 + 事件 Table)
|
||||
|
||||
### 3.2 mini-user 版本门控(ST1785907536648201)
|
||||
|
||||
| 项 | 行为 |
|
||||
|----|------|
|
||||
| 我的页 | 左下角展示 `杜康好客 v3.4.x`(与 `APP_VERSION` / package.json 同步) |
|
||||
| 启动校验 | `GET /common/client-config` → `minClientVersion`;`APP_VERSION` 低于最低版本时拦截 |
|
||||
| 微信有新包 | `UpdateManager` 弹「立即更新」→ `applyUpdate()` |
|
||||
| 无新包 / 仍过低 | 弹「版本过低」→ 点「我知道了」→ `Taro.exitMiniProgram()` 退出小程序 |
|
||||
|
||||
### 4. mini-user 物流(ST1785939375449985)
|
||||
|
||||
| 页面 | 行为 |
|
||||
|------|------|
|
||||
| 订单详情 | **配送中**展示最新路由摘要 + 「物流详情」;物流未到时展示 **预估送达**(100301) |
|
||||
| 物流详情 | 物流信息;**签收照片**(有则展示,`previewImage` 放大);**物流动态**旧→新、节点全红点亮、最新在底部 |
|
||||
| 电话拨号 | 物流文案中手机号可点击 `makePhoneCall` |
|
||||
| 接口 | `GET /trade/orders/:id/track`(`OrderTrackDto`);C 端禁止直连小飞侠 |
|
||||
| 公共模块 | `order-logistics.ts` + `LogisticsRichText` 组件 |
|
||||
|
||||
#### 4.1 对外回调 URL(提供给小飞侠)
|
||||
|
||||
**生产**
|
||||
|
||||
```
|
||||
POST https://api.dukanghaoke.com/api/v1/callbacks/courier/xfx/track
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**测试**
|
||||
|
||||
```
|
||||
POST https://api-test.dukanghaoke.com/api/v1/callbacks/courier/xfx/track
|
||||
```
|
||||
|
||||
请求体:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| outNumber | 商户单号 |
|
||||
| number | 运单号 |
|
||||
| status | 1下单 2取件 3中转 4派件 5签收 7取消 |
|
||||
| statusName | 中文状态 |
|
||||
| trackInfo | 路由描述 |
|
||||
| createTime | 路由时间 |
|
||||
|
||||
成功响应:`{"code":"100000","message":"success"}`
|
||||
|
||||
#### 4.2 Courier 适配器(`integrations/courier`)
|
||||
|
||||
| cmd | 能力 | Provider 方法 |
|
||||
|-----|------|---------------|
|
||||
| 100102 | 路由查询 | `getTrack` |
|
||||
| 100108 | 签收照片 | `getSignPhotos` → OSS → `signPhotoUrls` |
|
||||
| 100301 | 预估送达 | `checkDeliveryCoverage` → `estimatedArrival` |
|
||||
| 回调 | 路由推送 | `parseTrackCallback` + `mapTrackStatus`(**status=5 / statusName 含「签收」→ 订单 `COMPLETED`**) |
|
||||
|
||||
**路由回调状态映射(小飞侠)**
|
||||
|
||||
| 小飞侠 status / statusName | 系统订单状态 |
|
||||
|---------------------------|--------------|
|
||||
| 1 下单 | `OUT_WAREHOUSE` |
|
||||
| 2 取件 / 3 中转 | `SHIPPING` |
|
||||
| 4 派件 | `SHIPPING` |
|
||||
| **5 签收** / statusName **已签收** | **`COMPLETED`(已完成)** |
|
||||
| 7 取消 | 仅记录日志,不改订单 |
|
||||
|
||||
## 5. 数据表
|
||||
|
||||
- `common_support_ticket.priority` ENUM,默认 `NORMAL`
|
||||
- `common_support_ticket.status` 新增 `PUBLISHED`;`released_version_no`、`published_at`
|
||||
- `log_promo_event`:推广码指标事件(`promo_code_id`, `event_type`, `user_id`, `order_id`, `session_id`, `client_ip`, 地点, `created_at`)
|
||||
|
||||
## 6. HQ 开发计划
|
||||
|
||||
在 admin-web **开发计划 → 版本列表** 创建 `v3.4.13`(状态 `IN_PROGRESS`),审批 ST 后关联 `dev_plan_task`。**版本标记为「已发布」后**,该版本下任务自动 `RELEASED`,关联技术支持工单自动 `PUBLISHED` 并写入 `releasedVersionNo`。
|
||||
|
||||
## 7. 验收 ACC
|
||||
|
||||
- [ ] 推广码详情展示 attributionCount;**四指标事件日志 + ECharts 按日/按时趋势 + 高峰标注**
|
||||
- [ ] 推广码 SCAN/ATTRIBUTION/REGISTER/ORDER 事件含 time + IP;幂等(重复 touch 不计 SCAN)
|
||||
- [ ] 核销记录含 userNo/nickname/phone;**详情抽屉展示门店/地址/合伙人/权益券号/关联订单/券分摊/结算单/评价**
|
||||
- [ ] **权益券详情抽屉加宽(980px),内嵌核销列表无横向滚动条**
|
||||
- [ ] 技术支持可创建/筛选/编辑优先级;**版本 RELEASED 后关联工单为「已发布」并带版本号**
|
||||
- [ ] **门店列表「开城合伙人」列展示 companyName / name / phone(与下拉选项一致)**
|
||||
- [ ] 合伙人/门店登录空字段前端提示;**暂停合伙人发码前即拦截,短信/微信登录均禁止**
|
||||
- [ ] mini-user:我的页左下角显示 `杜康好客 v3.4.x`;低于 `minClientVersion` 时强制更新,无新包则点「我知道了」退出小程序
|
||||
- [ ] mini-user:电话脱敏、拨打埋点、提货确认、无分享按钮、首图 preview、**门店列表两段营业时间**、**门店详情门头铺满无留白**、**门店套餐页签切换**
|
||||
- [ ] mini-user:配送中物流摘要 + 签收照预览 + 电话拨号 + 时间线旧→新全点亮 + 预估送达
|
||||
- [ ] 小飞侠路由回调 `POST /callbacks/courier/xfx/track`:status=5 或 statusName 已签收 → 订单 **COMPLETED**;重复回调幂等
|
||||
- [ ] 各端上传 >10MB 图片自动压缩后可上传;仍超限有明确报错
|
||||
- [ ] **门店 H5(iPhone 微信)**:首次扫码 OAuth 后自动续扫;offline verifying 时提示再点一次扫码
|
||||
- [ ] `pnpm lint` 无新增错误
|
||||
- [ ] 推广码四指标趋势 + 事件日志;重复 touch 不计 SCAN
|
||||
- [ ] 核销/权益券详情抽屉字段完整、980px 无横滚
|
||||
- [ ] 版本 RELEASED 后工单「已发布」带版本号;门店列表合伙人列非空
|
||||
- [ ] mini-user:版本门控、门店/商品/物流/压缩项
|
||||
- [ ] 门店 H5 iPhone 微信:OAuth 后续扫可用
|
||||
- [ ] 小飞侠回调 status=5 → 订单 COMPLETED
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# 杜康好客 · v3.4.14 mini-user 门店体验 + 小程序可配置 + 测试白名单
|
||||
|
||||
> **2026-08-06** · **开发中** · PRD §0.6 · mini-user `3.4.14` · **未发版**
|
||||
|
||||
## 范围
|
||||
|
||||
| 项 | 交付 |
|
||||
|----|------|
|
||||
| 门头照 | **固定 4:3 区域**;`aspectFit` 缩放完整显示(不裁剪);标题信息卡固定接在门头下方 |
|
||||
| 门店详情·套餐 | 仅完整标题纵向列表;点击进入详情 |
|
||||
| 套餐详情页 | 实底导航让出胶囊区;内容区顶/左右留白;有图在门店名下;无图直接菜品 |
|
||||
| **系统设置·微信小程序** | Logo / 资质图 / 客服电话 / C 端 H5 / Mock 验证码 **可配置**,经 `client-config` 下发 |
|
||||
| **测试白名单** | 全局手机号名单;账号/门店/订单/核销打标;不计四条结算;合并商品/门店可见性手机号;HQ 独立管理模块 |
|
||||
|
||||
## 页面路由
|
||||
|
||||
| 路径 | 参数 |
|
||||
|------|------|
|
||||
| `pages/store-detail/index` | `id` 门店 ID |
|
||||
| `pages/store-package-detail/index` | `storeId` · `index` 套餐序号(0-based) |
|
||||
|
||||
数据:复用 `GET /stores/:id` 内嵌 `packages[]`,详情页按 index 取项。
|
||||
|
||||
## 系统设置(微信小程序配置)
|
||||
|
||||
HQ → 系统设置 → **微信小程序配置**。启动时空缺键用 shared-types 默认值补种。
|
||||
|
||||
| 配置键 | 说明 |
|
||||
|--------|------|
|
||||
| `USER_H5_URL` | C 端 H5 落地页(推广码等) |
|
||||
| `BRAND_LOGO_OSS_BASE` | Logo OSS 根路径(说明用) |
|
||||
| `BRAND_LOGO_URL` / `_WIDE_` / `_MARK_` | 方形 / 长方形 / 图标 Logo |
|
||||
| `MINI_USER_STATIC_OSS_BASE` | 小程序静态资源根路径 |
|
||||
| `QUALIFICATION_DISCLOSURE_URL` | 资质公示长图 |
|
||||
| `CUSTOMER_SERVICE_PHONE` | 总部客服电话 |
|
||||
| `MOCK_SMS_FIXED_CODE` | Mock 短信固定验证码(仅 MOCK_SMS 开启) |
|
||||
|
||||
**下发**:`GET /common/client-config` 增加 `userH5Url`、`brandLogoUrl`、`brandLogoWideUrl`、`brandLogoMarkUrl`、`qualificationDisclosureUrl`、`customerServicePhone`。
|
||||
`MOCK_SMS_FIXED_CODE` 仅服务端 Mock 短信读取,不下发客户端。
|
||||
|
||||
## 测试白名单(统一)
|
||||
|
||||
### 规则
|
||||
|
||||
| 规则 | 说明 |
|
||||
|------|------|
|
||||
| 源真相 | HQ「白名单管理」维护 `common_test_whitelist_phone`;名单内手机号 = 测试账号 |
|
||||
| 可见性 | 商品/门店 `visibilityWhitelistEnabled` 开启后,C 端仅当观众手机号 ∈ 全局名单可见/可购(不再用分实体 `*_visibility_phone`) |
|
||||
| 打标 | `User` / `StoreAccount` / `PartnerAccount` / `Store` / `Order` / `RedeemRecord` 的 `isTest` |
|
||||
| 不计账 | 测试核销不产生 `StorePayout`;酒厂/物流/合伙人账单排除 `isTest` 流水 |
|
||||
| 验证旁路 | 页顶 Checkbox ↔ `MOCK_SMS` / `MOCK_WECHAT` / `MOCK_PAY`(勾选 = 不做真实验证) |
|
||||
|
||||
### API
|
||||
|
||||
| 路径 | 说明 |
|
||||
|------|------|
|
||||
| `GET/POST /admin/test-whitelist/phones` | 名单列表 / 新增 |
|
||||
| `PATCH/DELETE /admin/test-whitelist/phones/:id` | 改备注 / 删除 |
|
||||
| `GET /admin/test-whitelist/accounts` | 测试账号记录(type=user\|store_account\|partner\|store\|order) |
|
||||
| `GET /admin/test-whitelist/phones/:id/linked` | 单号关联实体 |
|
||||
| `POST /admin/test-whitelist/migrate-visibility` | 旧可见性手机号导入 |
|
||||
|
||||
业务列表查询参数:`excludeTest=true` 排除测试数据。
|
||||
|
||||
### HQ
|
||||
|
||||
- 路由 `/test-whitelist`:验证旁路 + 手机号名单 + 测试账号记录
|
||||
- 用户/订单/门店/门店账号/合伙人/核销:「过滤测试账号」复选框;列表「测试」Tag
|
||||
- 商品/门店:保留「仅白名单可见」开关,去掉分实体手机号编辑
|
||||
|
||||
## ACC
|
||||
|
||||
- [ ] 门头区高度固定(4:3);图片 aspectFit 完整缩放;标题 section 位置不随图高变化
|
||||
- [ ] 门店详情套餐区仅标题列表,标题完整展示、可换行
|
||||
- [ ] 点击套餐进入详情页,字段完整;返回回到门店详情
|
||||
- [ ] 无套餐时不展示区块;index 非法时友好提示
|
||||
- [ ] HQ 微信小程序配置可改 Logo/电话/落地页/Mock 码;保存后 client-config 立即生效(无需重启)
|
||||
- [ ] mini-user 登录/我的/客服/分享图读取配置;未配置时回退代码默认常量
|
||||
- [ ] HQ「白名单管理」可增删手机号;可查看测试账号记录
|
||||
- [ ] 页顶三 Checkbox 控制短信/微信/支付跳过真实验证,与 `MOCK_*` 同源立即生效
|
||||
- [ ] 业务列表「过滤测试账号」勾选后不含测试数据
|
||||
- [ ] 旧可见性手机号已导入;限测商品/门店仅全局名单可见
|
||||
- [ ] 测试流水不进四条账单与门店打款
|
||||
|
||||
## HQ 开发计划
|
||||
|
||||
创建版本 `v3.4.14`(`IN_PROGRESS`),关联本迭代任务。发版前再合并发布。
|
||||
@@ -0,0 +1,26 @@
|
||||
# 杜康好客 · v3.4.15 mini-user 门店列表卡片优化
|
||||
|
||||
> **2026-08-06** · **开发中** · mini-user `3.4.15` · **未发版**
|
||||
|
||||
## 更新内容
|
||||
|
||||
- 门店列表卡片去掉「去核销」按钮,整卡最右侧改为小箭头(进入详情)
|
||||
- 第 1 行:门店名单行截断(不显示省略号),宽度顶到文案区最右
|
||||
- 第 2 行:地址最多两行;右侧显示距离
|
||||
- 第 3 行:营业时间同行展示(多段时段空格拼接)
|
||||
- 店铺封面右上角叠加斜角「营业中」标签图
|
||||
|
||||
## 范围
|
||||
|
||||
| 项 | 交付 |
|
||||
|----|------|
|
||||
| 门店列表卡片 | 布局与交互如上 |
|
||||
| 版本号 | `mini-user` `3.4.15`(`APP_VERSION` / package.json) |
|
||||
|
||||
## ACC
|
||||
|
||||
- [ ] 列表项无「去核销」按钮;右侧有 › 箭头;点击整卡进详情
|
||||
- [ ] 长店名单行截断且无 `…`;标题行无距离
|
||||
- [ ] 地址 ≤2 行,距离在第二行右侧
|
||||
- [ ] 营业时间单行;双时段同行显示
|
||||
- [ ] 封面右上角可见「营业中」斜角标签
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user