28 Commits

Author SHA1 Message Date
jacy b19f875e7b merge(dev): fix XFX callback build types
CI / verify (push) Has been cancelled
2026-08-07 18:58:56 +08:00
jacy 090002b6c0 merge(dev_jacy): fix XFX callback build types 2026-08-07 18:58:54 +08:00
jacy 8726cf14e3 fix(courier): cast IncomingMessage when skipping XFX body parsers
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 18:58:45 +08:00
jacy 7298b1a5d5 merge(dev): log XFX callback rawBody to third-party logs
CI / verify (push) Has been cancelled
2026-08-07 18:57:43 +08:00
jacy 1401f1dcd2 merge(dev_jacy): log XFX callback rawBody to third-party logs 2026-08-07 18:57:41 +08:00
jacy 155426b669 fix(courier): log XFX track callback rawBody and Content-Type
Capture raw request for courier track callbacks regardless of Content-Type and persist contentType/rawBody/query into log_third_party for empty-body diagnosis.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 18:57:31 +08:00
jacy b4d6f2bd91 merge(dev): v3.4.14 test whitelist and SUPER_ADMIN fix
CI / verify (push) Has been cancelled
2026-08-07 15:46:47 +08:00
jacy 029c17347e merge(dev_jacy): v3.4.14 test whitelist and SUPER_ADMIN fix 2026-08-07 15:46:40 +08:00
jacy b626db5d84 feat(ops): add global test whitelist and exclude test accounts from settlement
Unify product/store visibility on HQ whitelist, mark isTest snapshots, and fix SUPER_ADMIN access for the new module.
2026-08-07 15:46:23 +08:00
jacy d98ef6b808 merge(dev): v3.4.15 admin track and store list
CI / verify (push) Has been cancelled
2026-08-07 13:26:05 +08:00
jacy d13a6ee3cd merge(dev_jacy): v3.4.15 admin track and store list 2026-08-07 13:26:03 +08:00
jacy 10fa361983 feat(v3.4.15): admin delivery track drawer and mini-user store list polish
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 13:25:55 +08:00
jacy 1a615ffe93 merge(dev): harden iOS WeChat shop scan
CI / verify (push) Has been cancelled
2026-08-07 13:08:22 +08:00
jacy 0269802e59 merge(dev_jacy): harden iOS WeChat shop scan 2026-08-07 13:08:20 +08:00
jacy e4e9eb2169 fix(h5-shop): harden iOS WeChat scan after login with hard nav and recover UI
Root cause is JSSDK entry-URL mismatch after SPA post-OAuth, not camera permission. Hard-navigate on iOS, keep OAuth query in sign URL, skip redundant bind OAuth, and prompt refresh/re-auth on failure.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 13:08:13 +08:00
jacy b5fb9a0bb2 merge(dev): fix iOS shop scan JSSDK entry-URL signing
CI / verify (push) Has been cancelled
2026-08-07 12:54:01 +08:00
jacy 9d800a4cbd merge(dev_jacy): fix iOS shop scan JSSDK entry-URL signing 2026-08-07 12:53:59 +08:00
jacy 2a9493165b fix(weixin-sdk): restore iOS JSSDK entry-URL signing for shop scan
Phone re-login + OAuth on iOS left SPA on a different path than the WebView entry URL, so scanQRCode failed until a full reopen. Sign with the document entry URL again and warm up after auth.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 12:53:52 +08:00
jacy b32c68d56b merge(dev): fix XFX track callback body parse
CI / verify (push) Has been cancelled
2026-08-06 17:51:04 +08:00
jacy 6816835fb8 merge(dev_jacy): fix XFX track callback body parse 2026-08-06 17:51:02 +08:00
jacy 571959900b fix(courier): parse XFX form-urlencoded track callbacks and raw ACK
Enable urlencoded body parser, return XFX {code,message} without API envelope, and harden callback payload parsing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 17:51:00 +08:00
jacy 7bc710f139 merge(dev): mini-user share config in system settings
CI / verify (push) Has been cancelled
2026-08-06 15:46:07 +08:00
jacy 397bdad73f merge(dev_jacy): mini-user share config in system settings 2026-08-06 15:46:05 +08:00
jacy 83b1b0e5f6 feat(mini-user): configurable WeChat mini share scenes in system settings
Add HQ group for default and per-page share title/desc/image; expose via client-config and wire all mini-user share entry points.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 15:45:57 +08:00
jacy 91fed31b10 merge(dev): v3.4.14 mini-user store UX and brand config
CI / verify (push) Has been cancelled
2026-08-06 15:22:15 +08:00
jacy 1933b9b608 merge(dev_jacy): v3.4.14 mini-user store UX and brand config 2026-08-06 15:22:12 +08:00
jacy a2abbb6169 feat(v3.4.14): mini-user store UX, brand config, client settings
Store list/detail package flow, configurable WeChat mini brand assets and customer service, shop H5 home refresh.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 15:21:59 +08:00
jacy 53a79d1255 压缩文档 2026-08-06 01:09:19 +08:00
104 changed files with 5009 additions and 8276 deletions
+3
View File
@@ -39,6 +39,9 @@ src/
## UI 约束 ## 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 - C 端订单 **5 Tab**(含 pending_ship
- 门店列表仅 `OPEN` 状态 - 门店列表仅 `OPEN` 状态
- 原型 `pages/` 只读;路由对照 `pages/ROUTE_MAP.md` - 原型 `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` 资料弹层。
+4 -1
View File
@@ -11,7 +11,7 @@
| **V3.0 唯一需求源** | [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md) | 产品需求与三波交付(**只看这个**) | | **V3.0 唯一需求源** | [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md) | 产品需求与三波交付(**只看这个**) |
| **现状对照** | [`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md) | 已完成 / 冲突 / 缺口审计 | | **现状对照** | [`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md) | 已完成 / 冲突 / 缺口审计 |
| **V3 实现验收** | [`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md) | 核销规则、分工、闭环 | | **V3 实现验收** | [`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md) | 核销规则、分工、闭环 |
| ~~V2 / preV1~~ | 历史参考 | **不再作为需求依据** | | ~~V2 / preV1~~ | 历史参考**已压缩**V2 全文见 git 2026-08-06 前) | **不再作为需求依据** |
**禁止**:臆造 PRD 未定义规则;依赖 `doc/` 下过时文档;跨 OWNER 直写他人 Prisma 表。 **禁止**:臆造 PRD 未定义规则;依赖 `doc/` 下过时文档;跨 OWNER 直写他人 Prisma 表。
@@ -137,6 +137,9 @@ C 端门店仅 status=OPEN
订单 Tab:待付款 | 已付款 | 已完成 订单 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`
## 环境与发版 ## 环境与发版
| 环境 | 分支 | 目录 | 端口 | 域名 | | 环境 | 分支 | 目录 | 端口 | 域名 |
+1
View File
@@ -22,6 +22,7 @@ V2 规划中的 `mini-partner` / `mini-hq` 非 V3 主交付。
- 禁止 import `server/` 或另一个 `apps/*` 的源码 - 禁止 import `server/` 或另一个 `apps/*` 的源码
- UI 共享组件优先 `@dukang/shared-ui` - UI 共享组件优先 `@dukang/shared-ui`
- C 端订单列表 **3 Tab**`待付款 | 已付款 | 已完成`Tab key: `pending_pay` / `paid` / `completed` - 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 ## 新页面 workflow
+2
View File
@@ -46,6 +46,7 @@ import PartnerLogsPage from './pages/PartnerLogsPage';
import WechatBindingsPage from './pages/WechatBindingsPage'; import WechatBindingsPage from './pages/WechatBindingsPage';
import HqPermissionsPage from './pages/HqPermissionsPage'; import HqPermissionsPage from './pages/HqPermissionsPage';
import SystemSettingsPage from './pages/SystemSettingsPage'; import SystemSettingsPage from './pages/SystemSettingsPage';
import TestWhitelistPage from './pages/TestWhitelistPage';
import WecomBotsPage from './pages/WecomBotsPage'; import WecomBotsPage from './pages/WecomBotsPage';
import WecomMessagePushesPage from './pages/WecomMessagePushesPage'; import WecomMessagePushesPage from './pages/WecomMessagePushesPage';
import WecomBotLogsPage from './pages/WecomBotLogsPage'; import WecomBotLogsPage from './pages/WecomBotLogsPage';
@@ -136,6 +137,7 @@ export default function App() {
<Route path="/deliveries" element={<DeliveriesPage />} /> <Route path="/deliveries" element={<DeliveriesPage />} />
<Route path="/deliveries/xiaofeixia" element={<XiaofeixiaTestPage />} /> <Route path="/deliveries/xiaofeixia" element={<XiaofeixiaTestPage />} />
<Route path="/hq-permissions" element={<HqPermissionsPage />} /> <Route path="/hq-permissions" element={<HqPermissionsPage />} />
<Route path="/test-whitelist" element={<TestWhitelistPage />} />
<Route path="/system-settings" element={<SystemSettingsPage />} /> <Route path="/system-settings" element={<SystemSettingsPage />} />
<Route path="/hq-accounts" element={<HqAccountsPage />} /> <Route path="/hq-accounts" element={<HqAccountsPage />} />
</Route> </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: '/hq-permissions', icon: <LockOutlined />, label: '权限分配' },
{ key: '/test-whitelist', icon: <SafetyOutlined />, label: '白名单管理' },
{ key: '/system-settings', icon: <SettingOutlined />, label: '系统设置' }, { key: '/system-settings', icon: <SettingOutlined />, label: '系统设置' },
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' }, { key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
]; ];
@@ -215,6 +216,7 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
'/logs/third-party': 'logs', '/logs/third-party': 'logs',
'/logs/domain-events': 'logs', '/logs/domain-events': 'logs',
'/hq-permissions': 'hq_permissions', '/hq-permissions': 'hq_permissions',
'/test-whitelist': 'test_whitelist',
'/system-settings': 'system_settings_any', '/system-settings': 'system_settings_any',
'/hq-accounts': 'hq_accounts', '/hq-accounts': 'hq_accounts',
}; };
+2
View File
@@ -167,6 +167,7 @@ export type AdminUserRow = {
sourceLabel: string | null; sourceLabel: string | null;
createdAt: string; createdAt: string;
orderCount: number; orderCount: number;
isTest?: boolean;
}; };
export type AdminOrderItem = { export type AdminOrderItem = {
@@ -199,6 +200,7 @@ export type AdminOrderRow = {
isProxyOrder?: boolean; isProxyOrder?: boolean;
proxyPartnerName?: string | null; proxyPartnerName?: string | null;
proxyPartnerPhone?: string | null; proxyPartnerPhone?: string | null;
isTest?: boolean;
user?: { id: string; userNo: string; phone: string | null; nickname: string | null }; user?: { id: string; userNo: string; phone: string | null; nickname: string | null };
delivery?: { delivery?: {
provider: string; provider: string;
+21 -5
View File
@@ -59,6 +59,7 @@ type Row = {
accountCount: number; accountCount: number;
subAccounts?: SubRow[]; subAccounts?: SubRow[];
createdAt: string; createdAt: string;
isTest?: boolean;
}; };
type PartnerDetail = Row & { type PartnerDetail = Row & {
@@ -124,14 +125,15 @@ export default function CityPartnersPage() {
const [createForm] = Form.useForm(); const [createForm] = Form.useForm();
const [subForm] = Form.useForm(); const [subForm] = Form.useForm();
const [subEditForm] = 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>( const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/partners', '/admin/partners',
() => { () => {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (filters.companyName) qs.set('companyName', filters.companyName); if (filters.companyName) qs.set('companyName', String(filters.companyName));
if (filters.phone) qs.set('phone', filters.phone); if (filters.phone) qs.set('phone', String(filters.phone));
if (filters.cityId) qs.set('cityId', filters.cityId); if (filters.cityId) qs.set('cityId', String(filters.cityId));
if (filters.excludeTest) qs.set('excludeTest', 'true');
return qs; return qs;
}, },
[filters], [filters],
@@ -270,7 +272,18 @@ export default function CityPartnersPage() {
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes), row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
}, },
{ title: '公司名', dataIndex: 'companyName', ellipsis: true, width: 140 }, { 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: '登录手机', dataIndex: 'phone', width: 120 },
{ {
title: '管辖', title: '管辖',
@@ -387,6 +400,9 @@ export default function CityPartnersPage() {
<Form.Item name="phone" label="手机"> <Form.Item name="phone" label="手机">
<Input allowClear placeholder="登录手机" /> <Input allowClear placeholder="登录手机" />
</Form.Item> </Form.Item>
<Form.Item name="excludeTest" valuePropName="checked">
<Checkbox></Checkbox>
</Form.Item>
<Form.Item> <Form.Item>
<Space> <Space>
<Button type="primary" htmlType="submit"> <Button type="primary" htmlType="submit">
+48 -3
View File
@@ -3,13 +3,26 @@ import {
Button, Descriptions, Drawer, Form, Input, Space, Table, Typography, message, Button, Descriptions, Drawer, Form, Input, Space, Table, Typography, message,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import OrderTrackDrawer from '../components/OrderTrackDrawer';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { ORDER_STATUS_LABELS, fmtTime } from '../lib/constants'; import { ORDER_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
type Row = { type Row = {
id: string; provider: string; trackingNo: string | null; providerOrderNo: string | null; updatedAt: string; id: string;
order?: { orderNo: string; status: string; receiverName: string; receiverPhone: string; deliveryType: 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() { export default function DeliveriesPage() {
@@ -29,6 +42,22 @@ export default function DeliveriesPage() {
); );
const [detail, setDetail] = useState<Row | null>(null); const [detail, setDetail] = useState<Row | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false); 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> = [ const columns: ColumnsType<Row> = [
{ title: '订单号', dataIndex: ['order', 'orderNo'], width: 170 }, { title: '订单号', dataIndex: ['order', 'orderNo'], width: 170 },
@@ -39,14 +68,17 @@ export default function DeliveriesPage() {
{ title: '收货人', dataIndex: ['order', 'receiverName'], width: 90 }, { title: '收货人', dataIndex: ['order', 'receiverName'], width: 90 },
{ title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime }, { title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime },
{ {
title: '操作', width: 80, title: '操作', width: 140,
render: (_, row) => ( render: (_, row) => (
<Space size={0}>
<Button type="link" size="small" onClick={() => openTrack(row)}></Button>
<Button type="link" size="small" onClick={async () => { <Button type="link" size="small" onClick={async () => {
const d = await request<Row>(`/admin/deliveries/${row.id}`); const d = await request<Row>(`/admin/deliveries/${row.id}`);
setDetail(d); setDetail(d);
editForm.setFieldsValue({ provider: d.provider, trackingNo: d.trackingNo, providerOrderNo: d.providerOrderNo }); editForm.setFieldsValue({ provider: d.provider, trackingNo: d.trackingNo, providerOrderNo: d.providerOrderNo });
setDrawerOpen(true); setDrawerOpen(true);
}}></Button> }}></Button>
</Space>
), ),
}, },
]; ];
@@ -87,9 +119,22 @@ export default function DeliveriesPage() {
<Form.Item name="providerOrderNo" label="第三方单号"><Input /></Form.Item> <Form.Item name="providerOrderNo" label="第三方单号"><Input /></Form.Item>
<Form.Item name="trackingNo" label="运单号"><Input /></Form.Item> <Form.Item name="trackingNo" label="运单号"><Input /></Form.Item>
</Form> </Form>
<Button
style={{ marginTop: 8 }}
onClick={() => openTrack(detail)}
>
</Button>
</> </>
)} )}
</Drawer> </Drawer>
<OrderTrackDrawer
open={trackOpen}
orderId={trackOrderId}
orderNo={trackOrderNo}
orderStatus={trackOrderStatus}
onClose={() => setTrackOpen(false)}
/>
</div> </div>
); );
} }
+40 -2
View File
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
import { import {
Alert, Alert,
Button, Button,
Checkbox,
Collapse, Collapse,
Descriptions, Descriptions,
Drawer, Drawer,
@@ -30,6 +31,7 @@ import {
} from '../lib/constants'; } from '../lib/constants';
import { FULFILLMENT_HOLD_REASON_LABELS } from '@dukang/shared-types'; import { FULFILLMENT_HOLD_REASON_LABELS } from '@dukang/shared-types';
import ProxyOrderModal from '../components/ProxyOrderModal'; import ProxyOrderModal from '../components/ProxyOrderModal';
import OrderTrackDrawer from '../components/OrderTrackDrawer';
type ShipDefaults = { type ShipDefaults = {
provider: string; provider: string;
@@ -186,6 +188,7 @@ export default function OrdersPage() {
const [shipMode, setShipMode] = useState<ShipMode>('EXPRESS'); const [shipMode, setShipMode] = useState<ShipMode>('EXPRESS');
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]); const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
const [proxyOpen, setProxyOpen] = useState(false); const [proxyOpen, setProxyOpen] = useState(false);
const [trackOpen, setTrackOpen] = useState(false);
const canDeleteOrders = (profile?.permissionKeys ?? []).includes('orders_delete'); const canDeleteOrders = (profile?.permissionKeys ?? []).includes('orders_delete');
const canProxyOrder = (profile?.permissionKeys ?? []).includes('orders'); const canProxyOrder = (profile?.permissionKeys ?? []).includes('orders');
@@ -223,6 +226,7 @@ export default function OrdersPage() {
if (values.cityId) qs.set('cityId', values.cityId); if (values.cityId) qs.set('cityId', values.cityId);
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone); if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
if (values.fulfillmentHold) qs.set('fulfillmentHold', 'true'); if (values.fulfillmentHold) qs.set('fulfillmentHold', 'true');
if (values.excludeTest) qs.set('excludeTest', 'true');
const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`); const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`);
setData(res); setData(res);
} finally { } finally {
@@ -430,7 +434,17 @@ export default function OrdersPage() {
} }
const columns: ColumnsType<AdminOrderRow> = [ 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: '城市', title: '城市',
width: 90, width: 90,
@@ -598,6 +612,9 @@ export default function OrdersPage() {
options={[{ value: true, label: '仅待确认大单' }]} options={[{ value: true, label: '仅待确认大单' }]}
/> />
</Form.Item> </Form.Item>
<Form.Item name="excludeTest" valuePropName="checked">
<Checkbox></Checkbox>
</Form.Item>
<Form.Item> <Form.Item>
<Space> <Space>
<Button type="primary" htmlType="submit"></Button> <Button type="primary" htmlType="submit"></Button>
@@ -859,7 +876,20 @@ export default function OrdersPage() {
</Descriptions> </Descriptions>
{detail.delivery && ( {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="快递公司"> <Descriptions.Item label="快递公司">
{detail.delivery.logisticsCompany || {detail.delivery.logisticsCompany ||
DELIVERY_PROVIDER_LABELS[detail.delivery.provider] || DELIVERY_PROVIDER_LABELS[detail.delivery.provider] ||
@@ -1232,6 +1262,14 @@ export default function OrdersPage() {
void openDetail(order.id); void openDetail(order.id);
}} }}
/> />
<OrderTrackDrawer
open={trackOpen}
orderId={detail?.id ?? null}
orderNo={detail?.orderNo}
orderStatus={detail?.status}
onClose={() => setTrackOpen(false)}
/>
</div> </div>
); );
} }
+7 -78
View File
@@ -1,4 +1,4 @@
import { useMemo, useRef, useState } from 'react'; import { useMemo, useState } from 'react';
import { import {
Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Popconfirm, Select, Space,
Switch, Table, Tabs, Tag, Typography, message, Switch, Table, Tabs, Tag, Typography, message,
@@ -67,13 +67,6 @@ type ProductFormValues = {
features?: Array<{ icon?: string; title?: string; desc?: string }>; 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>) { function mapDetailToForm(d: Record<string, unknown>) {
const detail = (d.detailContent ?? {}) as ProductDetailContentDto; const detail = (d.detailContent ?? {}) as ProductDetailContentDto;
const row = d as Row; const row = d as Row;
@@ -112,10 +105,6 @@ function buildProductPayload(v: ProductFormValues) {
features: features.length ? features : undefined, features: features.length ? features : undefined,
}; };
const visibilityPhones = (v.visibilityPhones ?? [])
.map((p) => String(p || '').replace(/\D/g, '').trim())
.filter(Boolean);
return { return {
barcode69: v.barcode69, barcode69: v.barcode69,
name: v.name, name: v.name,
@@ -131,7 +120,6 @@ function buildProductPayload(v: ProductFormValues) {
allowCrossCityDelivery: allowCrossCityDelivery:
v.allowOnlinePurchase === false ? false : v.allowCrossCityDelivery !== false, v.allowOnlinePurchase === false ? false : v.allowCrossCityDelivery !== false,
visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled, visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled,
visibilityPhones,
coverUrl: v.coverUrl, coverUrl: v.coverUrl,
carouselUrls, carouselUrls,
detailImageUrls, detailImageUrls,
@@ -213,33 +201,6 @@ function ProductDetailFields({ form, aromaType }: { form: FormInstance; aromaTyp
} }
function VisibilityWhitelistFields({ form }: { form: FormInstance }) { 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); const enabled = Form.useWatch('visibilityWhitelistEnabled', form);
return ( return (
@@ -248,46 +209,14 @@ function VisibilityWhitelistFields({ form }: { form: FormInstance }) {
name="visibilityWhitelistEnabled" name="visibilityWhitelistEnabled"
label="可见白名单" label="可见白名单"
valuePropName="checked" valuePropName="checked"
extra="开启后仅名单内手机号在 C 端可见/可购,用于在线测试" extra="开启后仅全局测试白名单内手机号在 C 端可见/可购,用于在线测试"
> >
<Switch checkedChildren="开" unCheckedChildren="关" /> <Switch checkedChildren="开" unCheckedChildren="关" />
</Form.Item> </Form.Item>
{enabled ? ( {enabled ? (
<> <Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
<Form.Item
name="visibilityPhones" </Typography.Text>
label="白名单手机号"
rules={[{ required: true, message: '请至少添加一个手机号' }]}
extra="可直接输入多个手机号回车添加,或从下方用户库选择"
>
<Select
mode="tags"
tokenSeparators={[',', ' ', '', ';', '']}
placeholder="输入手机号后回车"
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item label="从用户库添加">
<Select
showSearch
filterOption={false}
placeholder="按手机号搜索用户"
loading={userSearching}
options={userOptions.map((u) => ({
value: u.phone!,
label: `${u.phone}${u.nickname ? ` · ${u.nickname}` : ''}${u.userNo ? `${u.userNo}` : ''}`,
}))}
onSearch={searchUsers}
onSelect={(phone: string) => {
const cur = (form.getFieldValue('visibilityPhones') as string[] | undefined) ?? [];
if (!cur.includes(phone)) {
form.setFieldsValue({ visibilityPhones: [...cur, phone] });
}
}}
notFoundContent={userSearching ? '搜索中…' : '输入手机号搜索'}
/>
</Form.Item>
</>
) : null} ) : null}
</> </>
); );
@@ -419,8 +348,8 @@ export default function ProductsPage() {
title: '白名单', title: '白名单',
dataIndex: 'visibilityWhitelistEnabled', dataIndex: 'visibilityWhitelistEnabled',
width: 90, width: 90,
render: (v: boolean, row) => render: (v: boolean) =>
v ? <Tag color="orange">{row.visibilityPhones?.length ?? 0}</Tag> : <Tag></Tag>, v ? <Tag color="orange"></Tag> : <Tag></Tag>,
}, },
{ {
title: '履约', title: '履约',
+25 -6
View File
@@ -1,5 +1,5 @@
import { useState } from 'react'; 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 type { ColumnsType } from 'antd/es/table';
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types'; import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
import RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDescriptions'; import RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDescriptions';
@@ -14,6 +14,7 @@ type Row = {
settleAmount: number; settleAmount: number;
channel?: RedeemChannel; channel?: RedeemChannel;
createdAt: string; createdAt: string;
isTest?: boolean;
user?: { userNo: string; phone: string | null; nickname?: string | null }; user?: { userNo: string; phone: string | null; nickname?: string | null };
store?: { name: string; cityName: string }; store?: { name: string; cityName: string };
coupon?: { couponNo: string }; coupon?: { couponNo: string };
@@ -26,14 +27,15 @@ function maskPhone(phone: string | null | undefined) {
export default function RedeemRecordsPage() { export default function RedeemRecordsPage() {
const [form] = Form.useForm(); 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>( const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
'/admin/redeem-records', '/admin/redeem-records',
() => { () => {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (filters.redeemNo) qs.set('redeemNo', filters.redeemNo); if (filters.redeemNo) qs.set('redeemNo', String(filters.redeemNo));
if (filters.storeId) qs.set('storeId', filters.storeId); if (filters.storeId) qs.set('storeId', String(filters.storeId));
if (filters.channel) qs.set('channel', filters.channel); if (filters.channel) qs.set('channel', String(filters.channel));
if (filters.excludeTest) qs.set('excludeTest', 'true');
return qs; return qs;
}, },
[filters], [filters],
@@ -53,7 +55,21 @@ export default function RedeemRecordsPage() {
} }
const columns: ColumnsType<Row> = [ 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: '方式', title: '方式',
dataIndex: 'channel', dataIndex: 'channel',
@@ -124,6 +140,9 @@ export default function RedeemRecordsPage() {
]} ]}
/> />
</Form.Item> </Form.Item>
<Form.Item name="excludeTest" valuePropName="checked">
<Checkbox></Checkbox>
</Form.Item>
<Form.Item> <Form.Item>
<Button type="primary" htmlType="submit"> <Button type="primary" htmlType="submit">
+20 -5
View File
@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { 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'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { request, type Paginated } from '../lib/api'; import { request, type Paginated } from '../lib/api';
@@ -15,6 +15,7 @@ type Row = {
name: string; name: string;
status: string; status: string;
createdAt: string; createdAt: string;
isTest?: boolean;
storeCount?: number; storeCount?: number;
staffCount?: number; staffCount?: number;
bankAccountName?: string | null; bankAccountName?: string | null;
@@ -30,13 +31,14 @@ type StoreOption = { id: string; name: string };
export default function StoreAccountsPage() { export default function StoreAccountsPage() {
const [form] = Form.useForm(); const [form] = Form.useForm();
const [createForm] = 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>( const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/store-accounts', '/admin/store-accounts',
() => { () => {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (filters.phone) qs.set('phone', filters.phone); if (filters.phone) qs.set('phone', String(filters.phone));
if (filters.status) qs.set('status', filters.status); if (filters.status) qs.set('status', String(filters.status));
if (filters.excludeTest) qs.set('excludeTest', 'true');
return qs; return qs;
}, },
[filters], [filters],
@@ -73,7 +75,17 @@ export default function StoreAccountsPage() {
} }
const columns: ColumnsType<Row> = [ 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: '手机', dataIndex: 'phone', width: 120 },
{ {
title: '绑定门店', title: '绑定门店',
@@ -162,6 +174,9 @@ export default function StoreAccountsPage() {
options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/> />
</Form.Item> </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 type="primary" htmlType="submit"></Button></Form.Item>
</Form> </Form>
<Table <Table
+37 -83
View File
@@ -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 { useNavigate, useSearchParams } from 'react-router-dom';
import { import {
Alert, Alert,
Button, Button,
Checkbox,
Descriptions, Descriptions,
Drawer, Drawer,
Form, 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 }) { 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); const enabled = Form.useWatch('visibilityWhitelistEnabled', form);
return ( return (
@@ -315,46 +287,14 @@ function StoreVisibilityWhitelistFields({ form }: { form: FormInstance }) {
name="visibilityWhitelistEnabled" name="visibilityWhitelistEnabled"
label="可见白名单" label="可见白名单"
valuePropName="checked" valuePropName="checked"
extra="开启后仅名单内手机号在 C 端可见,用于在线测试" extra="开启后仅全局测试白名单内手机号在 C 端可见,用于在线测试"
> >
<Switch checkedChildren="开" unCheckedChildren="关" /> <Switch checkedChildren="开" unCheckedChildren="关" />
</Form.Item> </Form.Item>
{enabled ? ( {enabled ? (
<> <Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
<Form.Item
name="visibilityPhones" </Typography.Text>
label="白名单手机号"
rules={[{ required: true, message: '请至少添加一个手机号' }]}
extra="可直接输入多个手机号回车添加,或从下方用户库选择"
>
<Select
mode="tags"
tokenSeparators={[',', ' ', '', ';', '']}
placeholder="输入手机号后回车"
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item label="从用户库添加">
<Select
showSearch
filterOption={false}
placeholder="按手机号搜索用户"
loading={userSearching}
options={userOptions.map((u) => ({
value: u.phone!,
label: `${u.phone}${u.nickname ? ` · ${u.nickname}` : ''}${u.userNo ? `${u.userNo}` : ''}`,
}))}
onSearch={searchUsers}
onSelect={(phone: string) => {
const cur = (form.getFieldValue('visibilityPhones') as string[] | undefined) ?? [];
if (!cur.includes(phone)) {
form.setFieldsValue({ visibilityPhones: [...cur, phone] });
}
}}
notFoundContent={userSearching ? '搜索中…' : '输入手机号搜索'}
/>
</Form.Item>
</>
) : null} ) : null}
</> </>
); );
@@ -375,6 +315,7 @@ type StoreRow = {
createdAt: string; createdAt: string;
visibilityWhitelistEnabled?: boolean; visibilityWhitelistEnabled?: boolean;
visibilityPhones?: string[]; visibilityPhones?: string[];
isTest?: boolean;
cityRef?: { name: string; code: string }; cityRef?: { name: string; code: string };
partner?: { id?: string; companyName?: string | null; name?: string | null; phone?: string | null }; partner?: { id?: string; companyName?: string | null; name?: string | null; phone?: string | null };
account?: { account?: {
@@ -424,8 +365,8 @@ export default function StoresPage() {
const [form] = Form.useForm(); const [form] = Form.useForm();
const [editForm] = Form.useForm(); const [editForm] = Form.useForm();
const [createForm] = Form.useForm<StoreCreateForm>(); const [createForm] = Form.useForm<StoreCreateForm>();
const [filters, setFilters] = useState<Record<string, string>>(() => { const [filters, setFilters] = useState<Record<string, string | boolean>>(() => {
const init: Record<string, string> = {}; const init: Record<string, string | boolean> = {};
if (initialCityId) init.cityId = initialCityId; if (initialCityId) init.cityId = initialCityId;
if (initialPartnerId) init.partnerId = initialPartnerId; if (initialPartnerId) init.partnerId = initialPartnerId;
return init; return init;
@@ -434,12 +375,13 @@ export default function StoresPage() {
'/admin/stores', '/admin/stores',
() => { () => {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (filters.name) qs.set('name', filters.name); if (filters.name) qs.set('name', String(filters.name));
if (filters.status) qs.set('status', filters.status); if (filters.status) qs.set('status', String(filters.status));
if (filters.auditStatus) qs.set('auditStatus', filters.auditStatus); if (filters.auditStatus) qs.set('auditStatus', String(filters.auditStatus));
if (filters.phone) qs.set('phone', filters.phone); if (filters.phone) qs.set('phone', String(filters.phone));
if (filters.cityId) qs.set('cityId', filters.cityId); if (filters.cityId) qs.set('cityId', String(filters.cityId));
if (filters.partnerId) qs.set('partnerId', filters.partnerId); if (filters.partnerId) qs.set('partnerId', String(filters.partnerId));
if (filters.excludeTest) qs.set('excludeTest', 'true');
return qs; return qs;
}, },
[filters], [filters],
@@ -597,6 +539,7 @@ export default function StoresPage() {
visibilityPhones: Array.isArray(d.visibilityPhones) visibilityPhones: Array.isArray(d.visibilityPhones)
? (d.visibilityPhones as string[]) ? (d.visibilityPhones as string[])
: [], : [],
isTest: !!d.isTest,
}); });
setDrawerOpen(true); setDrawerOpen(true);
} }
@@ -637,9 +580,7 @@ export default function StoresPage() {
bankAccountNo: v.bankAccountNo ?? null, bankAccountNo: v.bankAccountNo ?? null,
bankBranch: v.bankBranch ?? null, bankBranch: v.bankBranch ?? null,
visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled, visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled,
visibilityPhones: ((v.visibilityPhones as string[] | undefined) ?? []) isTest: !!v.isTest,
.map((p) => String(p || '').replace(/\D/g, '').trim())
.filter(Boolean),
...(hasCoords ...(hasCoords
? { latitude: Number(v.latitude), longitude: Number(v.longitude) } ? { latitude: Number(v.latitude), longitude: Number(v.longitude) }
: {}), : {}),
@@ -813,9 +754,6 @@ export default function StoresPage() {
bankBranch: values.bankBranch.trim(), bankBranch: values.bankBranch.trim(),
settlementRate: Number(values.settlementRate ?? 60) / 100, settlementRate: Number(values.settlementRate ?? 60) / 100,
visibilityWhitelistEnabled: !!values.visibilityWhitelistEnabled, visibilityWhitelistEnabled: !!values.visibilityWhitelistEnabled,
visibilityPhones: (values.visibilityPhones ?? [])
.map((p: string) => String(p || '').replace(/\D/g, '').trim())
.filter(Boolean),
}), }),
}); });
message.success('门店已创建'); message.success('门店已创建');
@@ -839,7 +777,12 @@ export default function StoresPage() {
title: '封面', dataIndex: 'coverUrl', width: 72, title: '封面', dataIndex: 'coverUrl', width: 72,
render: (url) => url ? <Image src={url} width={48} height={48} style={{ objectFit: 'cover', borderRadius: 4 }} /> : '—', 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: '分类', title: '分类',
width: 100, width: 100,
@@ -879,8 +822,8 @@ export default function StoresPage() {
title: '可见', title: '可见',
dataIndex: 'visibilityWhitelistEnabled', dataIndex: 'visibilityWhitelistEnabled',
width: 90, width: 90,
render: (v, row) => render: (v) =>
v ? <Tag color="orange">{row.visibilityPhones?.length ?? 0}</Tag> : <Tag></Tag>, v ? <Tag color="orange"></Tag> : <Tag></Tag>,
}, },
{ title: '介绍', dataIndex: 'intro', width: 160, ellipsis: true, render: (v) => v || '—' }, { title: '介绍', dataIndex: 'intro', width: 160, ellipsis: true, render: (v) => v || '—' },
{ title: '店长', dataIndex: ['account', 'name'], width: 90, 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 }))} options={Object.entries(STORE_AUDIT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/> />
</Form.Item> </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 type="primary" htmlType="submit"></Button></Form.Item>
<Form.Item> <Form.Item>
<Button <Button
@@ -1176,6 +1122,14 @@ export default function StoresPage() {
</Form.Item> </Form.Item>
</Space> </Space>
<StoreVisibilityWhitelistFields form={editForm} /> <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>
);
}
+15 -1
View File
@@ -112,6 +112,7 @@ export default function UsersPage() {
if (values.status !== undefined && values.status !== '') { if (values.status !== undefined && values.status !== '') {
qs.set('status', String(values.status)); qs.set('status', String(values.status));
} }
if (values.excludeTest) qs.set('excludeTest', 'true');
const res = await request<Paginated<AdminUserRow>>(`/admin/users?${qs}`); const res = await request<Paginated<AdminUserRow>>(`/admin/users?${qs}`);
setData(res); setData(res);
} finally { } finally {
@@ -237,7 +238,17 @@ export default function UsersPage() {
]; ];
const columns: ColumnsType<AdminUserRow> = [ 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: '昵称', dataIndex: 'nickname', width: 100 },
{ {
title: '手机', title: '手机',
@@ -362,6 +373,9 @@ export default function UsersPage() {
{ value: 0, label: '停用' }, { value: 0, label: '停用' },
]} /> ]} />
</Form.Item> </Form.Item>
<Form.Item name="excludeTest" valuePropName="checked">
<Checkbox></Checkbox>
</Form.Item>
<Form.Item> <Form.Item>
<Space> <Space>
<Button type="primary" htmlType="submit"></Button> <Button type="primary" htmlType="submit"></Button>
@@ -2,7 +2,10 @@ type WechatScanAuthModalProps = {
open: boolean; open: boolean;
loading?: boolean; loading?: boolean;
error?: string; error?: string;
/** bind=首次绑定;recover=扫码 JSSDK 失败后的恢复引导 */
mode?: 'bind' | 'recover';
onAuthorize: () => void; onAuthorize: () => void;
onRefresh?: () => void;
onCancel: () => void; onCancel: () => void;
}; };
@@ -10,28 +13,43 @@ export default function WechatScanAuthModal({
open, open,
loading, loading,
error, error,
mode = 'bind',
onAuthorize, onAuthorize,
onRefresh,
onCancel, onCancel,
}: WechatScanAuthModalProps) { }: WechatScanAuthModalProps) {
if (!open) return null; if (!open) return null;
const isRecover = mode === 'recover';
return ( return (
<div className="shop-scan-auth-overlay" role="dialog" aria-modal="true" aria-labelledby="shop-scan-auth-title"> <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-card">
<div className="shop-scan-auth-icon"> <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> </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"> <p className="shop-scan-auth-desc">
使使 {isRecover
? '微信扫码接口校验失败(常见于 iPhone 登录/授权后)。请先刷新页面;仍失败再重新授权微信。'
: '扫码核销需使用微信相机扫描用户核销码。请先完成微信授权并允许使用摄像头。'}
</p> </p>
{error && <p className="shop-scan-auth-error" role="alert">{error}</p>} {error && <p className="shop-scan-auth-error" role="alert">{error}</p>}
<div className="shop-scan-auth-actions"> <div className="shop-scan-auth-actions">
<button type="button" className="shop-scan-auth-cancel" onClick={onCancel} disabled={loading}> <button type="button" className="shop-scan-auth-cancel" onClick={onCancel} disabled={loading}>
</button> </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}> <button type="button" className="shop-scan-auth-confirm" onClick={onAuthorize} disabled={loading}>
{loading ? '跳转授权中…' : '微信授权'} {loading ? '跳转授权中…' : isRecover ? '重新授权微信' : '微信授权'}
</button> </button>
</div> </div>
</div> </div>
+22
View File
@@ -3,6 +3,9 @@ import { isIosDevice } from '@dukang/weixin-sdk';
/** 扫码前发起 OAuth 时标记,回跳后在首页续扫 */ /** 扫码前发起 OAuth 时标记,回跳后在首页续扫 */
export const SHOP_PENDING_SCAN_KEY = 'shop_pending_scan'; export const SHOP_PENDING_SCAN_KEY = 'shop_pending_scan';
/** 短信登录后绑定微信等 OAuth 回跳:下次手动扫码加长预热(不自动打开相机) */
export const SHOP_SCAN_WARMUP_KEY = 'shop_scan_warmup';
export function markPendingScanAfterAuth(): void { export function markPendingScanAfterAuth(): void {
try { try {
sessionStorage.setItem(SHOP_PENDING_SCAN_KEY, '1'); 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 回跳后延迟再调 scanQRCodeiOS JSSDK 离线校验更慢) */ /** OAuth 回跳后延迟再调 scanQRCodeiOS JSSDK 离线校验更慢) */
export function getPostAuthScanDelayMs(): number { export function getPostAuthScanDelayMs(): number {
return isIosDevice() ? 1200 : 600; return isIosDevice() ? 1200 : 600;
+9 -3
View File
@@ -2,6 +2,7 @@ import type { ClientRuntimeConfig, WechatLoginResult } from '@dukang/shared-type
import { isWxAuthorizeEnabled } from '@dukang/shared-types'; import { isWxAuthorizeEnabled } from '@dukang/shared-types';
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk'; import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
import { isWechatEnv, weixinSdk } from './weixin'; import { isWechatEnv, weixinSdk } from './weixin';
import { markScanWarmupAfterAuth } from './shop-scan-auth';
import { request, saveWechatSession, type ShopSessionPayload } from './api'; import { request, saveWechatSession, type ShopSessionPayload } from './api';
export type ShopAccountProfile = { export type ShopAccountProfile = {
@@ -162,15 +163,19 @@ export async function loginShopWithWechat(): Promise<ShopSessionPayload | null |
if (!isWechatEnv()) { if (!isWechatEnv()) {
throw new Error(WECHAT_INAPP_REQUIRED_MSG); throw new Error(WECHAT_INAPP_REQUIRED_MSG);
} }
markScanWarmupAfterAuth();
const result = await weixinSdk.login(); const result = await weixinSdk.login();
if (result) return handleShopWechatLoginResult(result); if (result) return handleShopWechatLoginResult(result);
} }
/** 短信登录成功后于微信内自动发起 OAuth,绑定 openId 便于后续免登 */ /** 短信登录成功后于微信内自动发起 OAuth,绑定 openId 便于后续免登 */
export async function bindShopWechatAfterSmsLogin(): Promise<void> { export async function bindShopWechatAfterSmsLogin(session?: ShopSessionPayload): Promise<'skipped' | void> {
const config = await fetchClientConfig(); const config = await fetchClientConfig();
if (!isWxAuthorizeEnabled(config)) return; if (!isWxAuthorizeEnabled(config)) return 'skipped';
if (!isWechatEnv()) return; if (!isWechatEnv()) return 'skipped';
// 已绑定则勿再 OAuth:每次 OAuth 回跳都会重置 iOS JSSDK 入场 URL,易导致扫码失败
if (session?.account?.hasWechat) return 'skipped';
markScanWarmupAfterAuth();
await weixinSdk.login(); await weixinSdk.login();
} }
@@ -180,5 +185,6 @@ export async function authorizeShopWechat(): Promise<WechatLoginResult | void> {
if (!isWechatEnv()) { if (!isWechatEnv()) {
throw new Error(WECHAT_INAPP_REQUIRED_MSG); throw new Error(WECHAT_INAPP_REQUIRED_MSG);
} }
markScanWarmupAfterAuth();
return weixinSdk.login(); return weixinSdk.login();
} }
+363 -32
View File
@@ -1,293 +1,624 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh'; import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { isIosDevice, isScanPermissionWarmupError } from '@dukang/weixin-sdk';
import { useStoreSession } from '../contexts/StoreSessionContext'; import { useStoreSession } from '../contexts/StoreSessionContext';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { parseRedeemTokenFromScan } from '../lib/redeem-scan'; import { parseRedeemTokenFromScan } from '../lib/redeem-scan';
import { import {
authorizeShopWechat, authorizeShopWechat,
checkNeedsWechatAuth, checkNeedsWechatAuth,
fetchShopAccount, fetchShopAccount,
} from '../lib/wechat-auth'; } from '../lib/wechat-auth';
import { isWechatEnv, weixinSdk } from '../lib/weixin'; import { isWechatEnv, weixinSdk } from '../lib/weixin';
import { import {
clearPendingScanAfterAuth, clearPendingScanAfterAuth,
consumeScanWarmupAfterAuth,
getPostAuthScanDelayMs, getPostAuthScanDelayMs,
markPendingScanAfterAuth, markPendingScanAfterAuth,
peekPendingScanAfterAuth, peekPendingScanAfterAuth,
} from '../lib/shop-scan-auth'; } from '../lib/shop-scan-auth';
import WechatScanAuthModal from '../components/WechatScanAuthModal'; import WechatScanAuthModal from '../components/WechatScanAuthModal';
import { useStorePageView } from '../lib/usePageView'; import { useStorePageView } from '../lib/usePageView';
import { trackStore } from '../lib/analytics'; import { trackStore } from '../lib/analytics';
function formatMoney(n: number) { function formatMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
} }
function formatScanError(e: unknown, opts?: { afterAuth?: boolean }): string { function formatScanError(e: unknown, opts?: { afterAuth?: boolean }): string {
const msg = e instanceof Error ? e.message : '扫码失败,请重试'; const msg = e instanceof Error ? e.message : '扫码失败,请重试';
getPostAuthScanDelayMs, if (/invalid signature|config:fail|signature/i.test(msg)) {
return '微信扫码签名校验失败,请刷新页面或重新授权微信后重试';
} }
if (isScanPermissionWarmupError(msg)) { if (isScanPermissionWarmupError(msg)) {
if (opts?.afterAuth) { if (opts?.afterAuth) {
peekPendingScanAfterAuth,
return '微信授权后扫码仍未就绪,请刷新页面或重新授权微信';
} }
} from '../lib/shop-scan-auth';
return '微信扫码能力未就绪,请刷新页面或重新授权微信';
} }
return msg; return msg;
} }
function isScanRecoverableError(msg: string): boolean {
return isScanPermissionWarmupError(msg) || /签名校验失败|扫码能力未就绪|请刷新页面/i.test(msg);
}
export default function HomePage() { export default function HomePage() {
useStorePageView('store_home_view'); useStorePageView('store_home_view');
const navigate = useNavigate(); const navigate = useNavigate();
const { ready, authenticated } = useStoreSession(); const { ready, authenticated } = useStoreSession();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const [dash, setDash] = useState<Record<string, unknown> | null>(null); const [dash, setDash] = useState<Record<string, unknown> | null>(null);
const [scanMsg, setScanMsg] = useState(''); const [scanMsg, setScanMsg] = useState('');
const [scanning, setScanning] = useState(false); const [scanning, setScanning] = useState(false);
const [authModalOpen, setAuthModalOpen] = useState(false); const [authModalOpen, setAuthModalOpen] = useState(false);
const [authModalMode, setAuthModalMode] = useState<'bind' | 'recover'>('bind');
const [authLoading, setAuthLoading] = useState(false); const [authLoading, setAuthLoading] = useState(false);
const [authError, setAuthError] = useState(''); const [authError, setAuthError] = useState('');
const pendingScanStartedRef = useRef(false); const pendingScanStartedRef = useRef(false);
const loadDashboard = useCallback(() => { const loadDashboard = useCallback(() => {
return request<Record<string, unknown>>('SHOP_H5', '/shop/dashboard') return request<Record<string, unknown>>('SHOP_H5', '/shop/dashboard')
.then((d) => { .then((d) => {
setDash(d); setDash(d);
}) })
.catch(() => {}); .catch(() => {});
}, []); }, []);
}
if (isScanPermissionWarmupError(msg)) {
useEffect(() => { useEffect(() => {
return '微信授权已完成,扫码权限仍在准备中,请再点一次「扫码核销」';
void loadDashboard(); void loadDashboard();
}
return '微信权限校验尚未完成,请等待 1~2 秒后再次点击扫码(首次绑定微信时较常见)';
}
return msg;
}
}, [loadDashboard]); }, [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( const runScan = useCallback(
async (opts?: { postAuthWarmup?: boolean }) => { async (opts?: { postAuthWarmup?: boolean }) => {
trackStore('store_redeem_scan_start'); trackStore('store_redeem_scan_start');
if (!isWechatEnv()) { if (!isWechatEnv()) {
setScanMsg('请在微信内打开门店端进行扫码核销'); setScanMsg('请在微信内打开门店端进行扫码核销');
return; return;
} }
setScanning(true); setScanning(true);
if (!opts?.postAuthWarmup) { if (!opts?.postAuthWarmup) {
setScanMsg(''); setScanMsg('');
} }
try { try {
const [scanning, setScanning] = useState(false);
// iOS / OAuth 回跳后须重新 wx.config(签名用入场 URL
if (opts?.postAuthWarmup || isIosDevice()) {
weixinSdk.reset(); weixinSdk.reset();
} }
await weixinSdk.init(); await weixinSdk.init();
const raw = await weixinSdk.scanQrCode( const raw = await weixinSdk.scanQrCode(
opts?.postAuthWarmup ? { postAuthWarmup: true } : undefined, opts?.postAuthWarmup ? { postAuthWarmup: true } : undefined,
); );
if (!raw) { if (!raw) {
void loadDashboard(); void loadDashboard();
return; return;
} }
const token = parseRedeemTokenFromScan(raw); const token = parseRedeemTokenFromScan(raw);
if (!token) { if (!token) {
setScanMsg('无法识别核销码,请扫描用户出示的核销二维码'); setScanMsg('无法识别核销码,请扫描用户出示的核销二维码');
return; return;
} }
navigate(`/redeem?token=${encodeURIComponent(token)}`); navigate(`/redeem?token=${encodeURIComponent(token)}`);
} catch (e) { } 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], [loadDashboard, navigate],
); );
// OAuth 由 StoreSessionContext 单例处理;此处仅在授权完成后续扫 // OAuth 由 StoreSessionContext 单例处理;此处仅在授权完成后续扫
useEffect(() => { useEffect(() => {
if (!ready || !authenticated || !isWechatEnv()) return; if (!ready || !authenticated || !isWechatEnv()) return;
if (searchParams.get('code')) return; if (searchParams.get('code')) return;
if (!peekPendingScanAfterAuth() || pendingScanStartedRef.current) return; if (!peekPendingScanAfterAuth() || pendingScanStartedRef.current) return;
pendingScanStartedRef.current = true; pendingScanStartedRef.current = true;
clearPendingScanAfterAuth(); clearPendingScanAfterAuth();
setAuthModalOpen(false); setAuthModalOpen(false);
setAuthLoading(false); setAuthLoading(false);
setAuthError(''); setAuthError('');
setScanMsg('微信授权成功,正在准备扫码…'); setScanMsg('微信授权成功,正在准备扫码…');
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
void runScan({ postAuthWarmup: true }); void runScan({ postAuthWarmup: true });
}, getPostAuthScanDelayMs()); }, getPostAuthScanDelayMs());
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [ready, authenticated, searchParams, runScan]); }, [ready, authenticated, searchParams, runScan]);
async function handleScan() { async function handleScan() {
setScanMsg(''); setScanMsg('');
if (!isWechatEnv()) { if (!isWechatEnv()) {
setScanMsg('请在微信内打开门店端进行扫码核销'); setScanMsg('请在微信内打开门店端进行扫码核销');
return; return;
} }
try { try {
const profile = await fetchShopAccount(); const profile = await fetchShopAccount();
if (await checkNeedsWechatAuth(profile)) { if (await checkNeedsWechatAuth(profile)) {
pendingScanStartedRef.current = false; pendingScanStartedRef.current = false;
setAuthModalMode('bind');
setAuthError('');
setAuthModalOpen(true); setAuthModalOpen(true);
return; return;
} }
const needWarmup = consumeScanWarmupAfterAuth();
await runScan(needWarmup ? { postAuthWarmup: true } : undefined);
} catch (e) { } catch (e) {
setScanMsg(e instanceof Error ? e.message : '无法发起扫码'); setScanMsg(e instanceof Error ? e.message : '无法发起扫码');
} }
} }
async function startWechatAuth() { async function startWechatAuth() {
setAuthLoading(true); setAuthLoading(true);
setAuthError(''); setAuthError('');
try { try {
pendingScanStartedRef.current = false; pendingScanStartedRef.current = false;
markPendingScanAfterAuth(); markPendingScanAfterAuth();
await authorizeShopWechat(); await authorizeShopWechat();
} catch (e) { } catch (e) {
clearPendingScanAfterAuth(); clearPendingScanAfterAuth();
setAuthError(e instanceof Error ? e.message : '微信授权失败'); setAuthError(e instanceof Error ? e.message : '微信授权失败');
setAuthLoading(false); setAuthLoading(false);
} }
} }
const store = dash?.store as Record<string, unknown> | undefined; const store = dash?.store as Record<string, unknown> | undefined;
const recent = (dash?.recentRecords as Array<Record<string, unknown>>) || []; const recent = (dash?.recentRecords as Array<Record<string, unknown>>) || [];
const status = String(store?.status || ''); const status = String(store?.status || '');
const open = status === 'OPEN'; const open = status === 'OPEN';
const hoursParts: string[] = []; const hoursParts: string[] = [];
if (store?.openTime && store?.closeTime) hoursParts.push(`${store.openTime} - ${store.closeTime}`); if (store?.openTime && store?.closeTime) hoursParts.push(`${store.openTime} - ${store.closeTime}`);
if (store?.openTime2 && store?.closeTime2) hoursParts.push(`${store.openTime2} - ${store.closeTime2}`); if (store?.openTime2 && store?.closeTime2) hoursParts.push(`${store.openTime2} - ${store.closeTime2}`);
const hoursText = hoursParts.length ? hoursParts.join('') : '10:00 - 22:00'; const hoursText = hoursParts.length ? hoursParts.join('') : '10:00 - 22:00';
const statusText = const statusText =
status === 'CLOSED' ? '永久关闭' : open ? '当前正在营业中' : '当前临时闭店'; status === 'CLOSED' ? '永久关闭' : open ? '当前正在营业中' : '当前临时闭店';
return ( return (
<PullToRefresh onRefresh={loadDashboard} className="shop-home-page"> <PullToRefresh onRefresh={loadDashboard} className="shop-home-page">
<header className="shop-home-header"> <header className="shop-home-header">
<h1 className="app-page-title"></h1> <h1 className="app-page-title"></h1>
</header> </header>
<div className="shop-home-content"> <div className="shop-home-content">
<section className="shop-home-hero"> <section className="shop-home-hero">
<div className="shop-home-hero-store"> <div className="shop-home-hero-store">
<span className="material-symbols-outlined shop-fill-icon">store</span> <span className="material-symbols-outlined shop-fill-icon">store</span>
<h2>{String(store?.name || '门店')}</h2> <h2>{String(store?.name || '门店')}</h2>
</div> </div>
<div className="shop-home-stats"> <div className="shop-home-stats">
<div className="shop-home-stat"> <div className="shop-home-stat">
<p className="shop-home-stat-label"></p> <p className="shop-home-stat-label"></p>
<p className="shop-home-stat-value">{Number(dash?.todayCount || 0)}</p> <p className="shop-home-stat-value">{Number(dash?.todayCount || 0)}</p>
<p className="shop-home-stat-sub"> <p className="shop-home-stat-sub">
{Number(dash?.todayScanCount || 0)} · {Number(dash?.todayPhoneCount || 0)} {Number(dash?.todayScanCount || 0)} · {Number(dash?.todayPhoneCount || 0)}
</p> </p>
</div> </div>
<div className="shop-home-stat"> <div className="shop-home-stat">
<p className="shop-home-stat-label"></p> <p className="shop-home-stat-label"></p>
<p className="shop-home-stat-value"> <p className="shop-home-stat-value">
<span style={{ fontSize: 18 }}>¥</span> <span style={{ fontSize: 18 }}>¥</span>
{formatMoney(Number(dash?.todayAmount || 0))} {formatMoney(Number(dash?.todayAmount || 0))}
</p> </p>
</div> </div>
</div> </div>
</section> </section>
<section className="shop-home-scan"> <section className="shop-home-scan">
<button <button
type="button" type="button"
className="shop-home-scan-btn" className="shop-home-scan-btn"
disabled={scanning} disabled={scanning}
onClick={() => void handleScan()} onClick={() => void handleScan()}
> >
<span className="material-symbols-outlined">qr_code_scanner</span> <span className="material-symbols-outlined">qr_code_scanner</span>
</button> </button>
<p className="shop-home-scan-label">{scanning ? '正在打开相机…' : '扫码核销'}</p> <p className="shop-home-scan-label">{scanning ? '正在打开相机…' : '扫码核销'}</p>
{scanMsg && <p className="shop-home-scan-msg" role="alert">{scanMsg}</p>} {scanMsg && <p className="shop-home-scan-msg" role="alert">{scanMsg}</p>}
<Link to="/redeem/phone" className="shop-home-phone-link"> <Link to="/redeem/phone" className="shop-home-phone-link">
<span className="material-symbols-outlined">smartphone</span> <span className="material-symbols-outlined">smartphone</span>
</Link> </Link>
</section> </section>
<section className="shop-home-status"> <section className="shop-home-status">
<div className="shop-home-status-left"> <div className="shop-home-status-left">
<div className={`shop-home-status-icon${open ? '' : ' closed'}`}> <div className={`shop-home-status-icon${open ? '' : ' closed'}`}>
<span className="material-symbols-outlined shop-fill-icon">schedule</span> <span className="material-symbols-outlined shop-fill-icon">schedule</span>
</div> </div>
<div> <div>
<p className="shop-home-status-title"></p> <p className="shop-home-status-title"></p>
<p className="shop-home-status-sub">{statusText}</p> <p className="shop-home-status-sub">{statusText}</p>
<p className="shop-home-status-sub">: {hoursText}</p> <p className="shop-home-status-sub">: {hoursText}</p>
</div> </div>
</div> </div>
<label className="shop-home-switch" onClick={() => navigate('/status')}> <label className="shop-home-switch" onClick={() => navigate('/status')}>
<input type="checkbox" checked={open && status !== 'CLOSED'} readOnly tabIndex={-1} /> <input type="checkbox" checked={open && status !== 'CLOSED'} readOnly tabIndex={-1} />
<span className="shop-home-switch-track" /> <span className="shop-home-switch-track" />
</label> </label>
</section> </section>
<section> <section>
<div className="shop-home-records-head"> <div className="shop-home-records-head">
<h3 className="shop-home-records-title"></h3> <h3 className="shop-home-records-title"></h3>
<Link to="/records" className="shop-home-records-link"> <Link to="/records" className="shop-home-records-link">
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>chevron_right</span> <span className="material-symbols-outlined" style={{ fontSize: 16 }}>chevron_right</span>
</Link> </Link>
</div> </div>
<div className="shop-home-record-list"> <div className="shop-home-record-list">
{recent.length === 0 && ( {recent.length === 0 && (
<p className="shop-home-status-sub" style={{ textAlign: 'center', padding: '16px 0' }}></p> <p className="shop-home-status-sub" style={{ textAlign: 'center', padding: '16px 0' }}></p>
)} )}
{recent.map((r) => ( {recent.map((r) => (
<div key={String(r.id)} className="shop-home-record-item"> <div key={String(r.id)} className="shop-home-record-item">
<div> <div>
<p className="shop-home-record-time"></p> <p className="shop-home-record-time"></p>
<p className="shop-home-record-value"> <p className="shop-home-record-value">
{new Date(String(r.createdAt)).toLocaleString('zh-CN')} {new Date(String(r.createdAt)).toLocaleString('zh-CN')}
</p> </p>
</div> </div>
<p className="shop-home-record-amount">¥{formatMoney(Number(r.amount))}</p> <p className="shop-home-record-amount">¥{formatMoney(Number(r.amount))}</p>
</div> </div>
))} ))}
</div> </div>
</section> </section>
</div> </div>
<WechatScanAuthModal <WechatScanAuthModal
open={authModalOpen} open={authModalOpen}
mode={authModalMode}
loading={authLoading} loading={authLoading}
error={authError} error={authError}
onAuthorize={() => void startWechatAuth()} onAuthorize={() => void startWechatAuth()}
onRefresh={() => {
async function handleScan() {
window.location.reload();
setScanMsg('');
}} }}
onCancel={() => {
setAuthModalOpen(false);
setAuthModalMode('bind');
setAuthError('');
clearPendingScanAfterAuth();
pendingScanStartedRef.current = false;
}}
/> />
</PullToRefresh> </PullToRefresh>
); );
} }
+2 -2
View File
@@ -159,9 +159,9 @@ export default function LoginPage() {
}); });
saveRememberedSession(data); saveRememberedSession(data);
applySession(data); applySession(data);
if (isWechatEnv() && wxAuthorize) { if (isWechatEnv() && wxAuthorize && !data.account?.hasWechat) {
setMsg('登录成功,正在关联微信…'); setMsg('登录成功,正在关联微信…');
await bindShopWechatAfterSmsLogin(); await bindShopWechatAfterSmsLogin(data);
return; return;
} }
routeAfterShopLogin(data, navigate); routeAfterShopLogin(data, navigate);
+16 -5
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh'; import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { hardNavigateInWechat, shouldHardNavigateForJssdk } from '@dukang/weixin-sdk';
import { useStoreSession } from '../contexts/StoreSessionContext'; import { useStoreSession } from '../contexts/StoreSessionContext';
import { import {
needsStoreSelection, needsStoreSelection,
@@ -10,6 +11,14 @@ import {
type ShopStoreOption, type ShopStoreOption,
} from '../lib/api'; } from '../lib/api';
function goShopHome(navigate: (path: string, opts?: { replace?: boolean }) => void) {
if (shouldHardNavigateForJssdk()) {
hardNavigateInWechat('/');
return;
}
navigate('/', { replace: true });
}
export default function SelectStorePage() { export default function SelectStorePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { applySession, store, authenticated } = useStoreSession(); const { applySession, store, authenticated } = useStoreSession();
@@ -37,7 +46,7 @@ export default function SelectStorePage() {
async function onSelect(storeId: string) { async function onSelect(storeId: string) {
if (loadingId) return; if (loadingId) return;
if (storeId === currentStoreId) { if (storeId === currentStoreId) {
navigate('/', { replace: true }); goShopHome(navigate);
return; return;
} }
setLoadingId(storeId); setLoadingId(storeId);
@@ -45,7 +54,7 @@ export default function SelectStorePage() {
try { try {
const session = await selectStore(storeId); const session = await selectStore(storeId);
applySession(session); applySession(session);
navigate('/', { replace: true }); goShopHome(navigate);
} catch (e) { } catch (e) {
setMsg(e instanceof Error ? e.message : '选店失败'); setMsg(e instanceof Error ? e.message : '选店失败');
} finally { } finally {
@@ -166,9 +175,11 @@ export function routeAfterShopLogin(
session: ShopSessionPayload, session: ShopSessionPayload,
navigate: (path: string, opts?: { replace?: boolean }) => void, navigate: (path: string, opts?: { replace?: boolean }) => void,
) { ) {
if (needsStoreSelection(session)) { const path = needsStoreSelection(session) ? '/select-store' : '/';
navigate('/select-store', { replace: true }); // iOS 微信:必须整页跳转,让业务页成为 JSSDK 新入场 URL,否则扫码验签必挂
if (shouldHardNavigateForJssdk()) {
hardNavigateInWechat(path);
return; return;
} }
navigate('/', { replace: true }); navigate(path, { replace: true });
} }
+1
View File
@@ -794,6 +794,7 @@
.shop-scan-auth-actions { .shop-scan-auth-actions {
display: flex; display: flex;
flex-wrap: wrap;
gap: 12px; 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 { track } from '../lib/analytics';
import { openWecomCustomerService } from '../lib/customer-service'; import {
getCustomerServicePhone,
loadCustomerServicePhone,
openWecomCustomerService,
} from '../lib/customer-service';
type ContactCustomerSheetProps = { type ContactCustomerSheetProps = {
orderId?: string; orderId?: string;
@@ -9,7 +13,13 @@ type ContactCustomerSheetProps = {
}; };
export default function ContactCustomerSheet({ orderId, onClose }: 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() { function openPhone() {
track('cs_contact', { type: 'phone', orderId }); track('cs_contact', { type: 'phone', orderId });
@@ -41,7 +51,7 @@ export default function ContactCustomerSheet({ orderId, onClose }: ContactCustom
</div> </div>
<div className="contact-customer-option-body"> <div className="contact-customer-option-body">
<p className="contact-customer-option-title"></p> <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> </div>
<span className="material-symbols-outlined contact-customer-chevron">chevron_right</span> <span className="material-symbols-outlined contact-customer-chevron">chevron_right</span>
</button> </button>
+20
View File
@@ -1,12 +1,31 @@
import { CUSTOMER_SERVICE_PHONE, CUSTOMER_SERVICE_WECOM_URL } from '@dukang/shared-types'; import { CUSTOMER_SERVICE_PHONE, CUSTOMER_SERVICE_WECOM_URL } from '@dukang/shared-types';
import { fetchClientConfig } from './pay-wechat';
import { isWechatEnv } from './weixin'; import { isWechatEnv } from './weixin';
let cachedPhone = CUSTOMER_SERVICE_PHONE;
/** 企微客服链接:优先 Vite env,否则 shared-types 默认 */ /** 企微客服链接:优先 Vite env,否则 shared-types 默认 */
export function getCustomerServiceWecomUrl(): string { export function getCustomerServiceWecomUrl(): string {
const fromEnv = import.meta.env.VITE_CS_WECOM_URL?.trim(); const fromEnv = import.meta.env.VITE_CS_WECOM_URL?.trim();
return fromEnv || CUSTOMER_SERVICE_WECOM_URL; 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 非微信环境已提示 * @returns true 已跳转;false 非微信环境已提示
@@ -20,4 +39,5 @@ export function openWecomCustomerService(): boolean {
return true; return true;
} }
/** @deprecated 请用 getCustomerServicePhone(),保留兼容旧引用 */
export { CUSTOMER_SERVICE_PHONE }; export { CUSTOMER_SERVICE_PHONE };
+14 -3
View File
@@ -1,11 +1,22 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import SubPageHeader from '../components/SubPageHeader'; 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'; import { track } from '../lib/analytics';
export default function CustomerServicePage() { export default function CustomerServicePage() {
const navigate = useNavigate(); 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() { function openOnline() {
track('cs_contact', { type: 'wecom_kf' }); track('cs_contact', { type: 'wecom_kf' });
@@ -27,7 +38,7 @@ export default function CustomerServicePage() {
<a className="customer-service-phone-link" href={`tel:${tel}`}> <a className="customer-service-phone-link" href={`tel:${tel}`}>
<span className="material-symbols-outlined">call</span> <span className="material-symbols-outlined">call</span>
{CUSTOMER_SERVICE_PHONE} {phone}
</a> </a>
</div> </div>
</div> </div>
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@dukang/mini-user", "name": "@dukang/mini-user",
"version": "3.4.13", "version": "3.4.15",
"private": true, "private": true,
"description": "杜康好客 · C 端用户微信小程序(Taro)", "description": "杜康好客 · C 端用户微信小程序(Taro)",
"scripts": { "scripts": {
+1
View File
@@ -6,6 +6,7 @@ export default defineAppConfig({
'pages/mine/index', 'pages/mine/index',
'pages/product-detail/index', 'pages/product-detail/index',
'pages/store-detail/index', 'pages/store-detail/index',
'pages/store-package-detail/index',
'pages/order-confirm/index', 'pages/order-confirm/index',
'pages/order-confirm-pickup/index', 'pages/order-confirm-pickup/index',
'pages/pay/index', 'pages/pay/index',
+2
View File
@@ -6,11 +6,13 @@ import WechatShareBootstrap from './components/WechatShareBootstrap';
import { patchTaroH5Hooks } from './lib/patch-taro-h5-hooks'; import { patchTaroH5Hooks } from './lib/patch-taro-h5-hooks';
import { handleWechatOrderConfirmShow } from './lib/wechat-order-confirm'; import { handleWechatOrderConfirmShow } from './lib/wechat-order-confirm';
import { installClientErrorReporting } from './lib/client-error'; import { installClientErrorReporting } from './lib/client-error';
import { prefetchShareBrandAssets } from './lib/wechat-share';
import './app.css'; import './app.css';
// H5:在首屏 page hooks 执行前,把 Taro.useDidShow 等绑到与 createReactApp 同一份 runtime // H5:在首屏 page hooks 执行前,把 Taro.useDidShow 等绑到与 createReactApp 同一份 runtime
patchTaroH5Hooks(); patchTaroH5Hooks();
installClientErrorReporting(); installClientErrorReporting();
prefetchShareBrandAssets();
function App({ children }: PropsWithChildren) { function App({ children }: PropsWithChildren) {
const handlingRef = useRef(false); const handlingRef = useRef(false);
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -7,6 +7,8 @@ type ProductCarouselProps = {
alt: string; alt: string;
variant?: 'home' | 'detail' | 'store'; variant?: 'home' | 'detail' | 'store';
previewable?: boolean; previewable?: boolean;
/** cover=aspectFill 裁剪铺满;contain=aspectFit 缩放完整显示(门店门头固定区) */
imageFit?: 'cover' | 'contain';
}; };
/** 商品/门店轮播(对齐 h5 ProductCarousel 三变体) */ /** 商品/门店轮播(对齐 h5 ProductCarousel 三变体) */
@@ -15,12 +17,14 @@ export default function ProductCarousel({
alt, alt,
variant = 'detail', variant = 'detail',
previewable = false, previewable = false,
imageFit = 'cover',
}: ProductCarouselProps) { }: ProductCarouselProps) {
const slides = images.length > 0 ? images : ['']; const slides = images.length > 0 ? images : [''];
const [activeIndex, setActiveIndex] = useState(0); const [activeIndex, setActiveIndex] = useState(0);
const prefix = const prefix =
variant === 'store' ? 'store-detail-carousel' : variant === 'home' ? 'home-carousel' : 'detail-carousel'; 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) { function previewAt(index: number) {
const urls = slides.filter(Boolean); const urls = slides.filter(Boolean);
@@ -30,7 +34,7 @@ export default function ProductCarousel({
} }
return ( return (
<View className={`${prefix}-wrap`}> <View className={wrapClass}>
<Swiper <Swiper
className={prefix} className={prefix}
circular={slides.length > 1} circular={slides.length > 1}
@@ -42,7 +46,7 @@ export default function ProductCarousel({
<Image <Image
className={`${prefix}-image`} className={`${prefix}-image`}
src={src} src={src}
mode={imageMode} mode={isContain ? 'aspectFit' : 'aspectFill'}
alt={alt} alt={alt}
onClick={previewable ? () => previewAt(index) : undefined} onClick={previewable ? () => previewAt(index) : undefined}
/> />
+68
View File
@@ -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;
}
+1 -1
View File
@@ -2,7 +2,7 @@ import Taro from '@tarojs/taro';
import { fetchClientConfig } from './pay-wechat'; import { fetchClientConfig } from './pay-wechat';
/** 与 package.json version 同步,供服务端 minClientVersion 比对 */ /** 与 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}`; export const APP_VERSION_LABEL = `v${APP_VERSION}`;
+132 -10
View File
@@ -1,16 +1,126 @@
import Taro from '@tarojs/taro'; import Taro from '@tarojs/taro';
import { BRAND_LOGO_URL } from '@dukang/shared-types';
import type { WechatShareData } from '@dukang/weixin-sdk'; import type { WechatShareData } from '@dukang/weixin-sdk';
import { getWechatShareLink, isWechatBrowser, isMiniProgram } 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 { toast } from './api';
import { getBrandAssetsSync, loadBrandAssets } from './brand-assets';
import { fetchClientConfig } from './pay-wechat';
import { isWechatEnv, weixinSdk } from './weixin'; import { isWechatEnv, weixinSdk } from './weixin';
export const DEFAULT_SHARE_TITLE = '你吃饭,杜康买单'; export type ShareScene =
export const DEFAULT_SHARE_DESC = '杜康好客 · 买美酒,享好礼,全城门店可用'; | 'home'
export const WECHAT_SHARE_HINT = '请点击右上角 ··· 分享给好友'; | '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 { 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( export function buildDefaultShareData(
@@ -24,9 +134,10 @@ export function buildDefaultShareData(
link = ''; link = '';
} }
} }
const def = getShareRuntimeSync().default;
return { return {
title: overrides?.title ?? DEFAULT_SHARE_TITLE, title: overrides?.title ?? def.title,
desc: overrides?.desc ?? DEFAULT_SHARE_DESC, desc: overrides?.desc ?? def.desc,
link, link,
imgUrl: overrides?.imgUrl ?? getDefaultShareImageUrl(), imgUrl: overrides?.imgUrl ?? getDefaultShareImageUrl(),
}; };
@@ -68,7 +179,7 @@ export async function handleShareButtonClick(
/* ignore */ /* ignore */
} }
} }
toast(WECHAT_SHARE_HINT); toast(getShareHint());
return { showGuide: false }; return { showGuide: false };
} }
@@ -89,7 +200,7 @@ export async function handleShareButtonClick(
if (result.invoked) { if (result.invoked) {
return { showGuide: false }; return { showGuide: false };
} }
toast(WECHAT_SHARE_HINT); toast(getShareHint());
return { showGuide: true }; return { showGuide: true };
} catch (e) { } catch (e) {
toast(e instanceof Error ? e.message : '分享配置失败,请刷新后重试'); toast(e instanceof Error ? e.message : '分享配置失败,请刷新后重试');
@@ -99,9 +210,20 @@ export async function handleShareButtonClick(
/** 供 useShareAppMessage 使用的标题/路径/图 */ /** 供 useShareAppMessage 使用的标题/路径/图 */
export function toWeappShareMessage(payload?: PageSharePayload) { export function toWeappShareMessage(payload?: PageSharePayload) {
const def = getShareRuntimeSync().default;
return { return {
title: payload?.title || DEFAULT_SHARE_TITLE, title: payload?.title || def.title,
path: payload?.path || '/pages/home/index', path: payload?.path || '/pages/home/index',
imageUrl: payload?.imgUrl || getDefaultShareImageUrl(), 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(),
};
}
+5 -10
View File
@@ -8,9 +8,9 @@ import { goLogin } from '../../lib/auth-nav';
import { isLoggedIn, request, toast } from '../../lib/api'; import { isLoggedIn, request, toast } from '../../lib/api';
import { navBarStyle, tabNavContentStyle, useNavBarMetrics } from '../../lib/nav-bar'; import { navBarStyle, tabNavContentStyle, useNavBarMetrics } from '../../lib/nav-bar';
import { import {
DEFAULT_SHARE_DESC, buildSceneSharePayload,
DEFAULT_SHARE_TITLE,
toWeappShareMessage, toWeappShareMessage,
toWeappShareTimeline,
} from '../../lib/wechat-share'; } from '../../lib/wechat-share';
import { formatMoney } from '../../lib/money'; import { formatMoney } from '../../lib/money';
import iconBenefit from '../../assets/tabbar/benefit-active.png'; 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 available = coupons.filter((c) => c.status === 'ACTIVE' && c.balance > 0);
const sharePayload = useMemo( const sharePayload = useMemo(
() => ({ () =>
title: '好客权益 · 杜康好客', buildSceneSharePayload('benefit', {
desc: DEFAULT_SHARE_DESC,
path: '/pages/benefit/index', path: '/pages/benefit/index',
}), }),
[], [],
); );
useShareAppMessage(() => toWeappShareMessage(sharePayload)); useShareAppMessage(() => toWeappShareMessage(sharePayload));
useShareTimeline(() => ({ useShareTimeline(() => toWeappShareTimeline(sharePayload));
title: sharePayload.title || DEFAULT_SHARE_TITLE,
query: '',
imageUrl: sharePayload.imgUrl,
}));
return ( return (
<PageShell variant="tab" className="benefit-page"> <PageShell variant="tab" className="benefit-page">
@@ -1,19 +1,26 @@
import { useEffect, useState } from 'react';
import { View, Text } from '@tarojs/components'; import { View, Text } from '@tarojs/components';
import Taro from '@tarojs/taro'; import Taro from '@tarojs/taro';
import { CUSTOMER_SERVICE_PHONE } from '@dukang/shared-types';
import PageShell from '../../components/PageShell'; import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader'; import SubPageHeader from '../../components/SubPageHeader';
import ContactCsButton from '../../components/ContactCsButton'; import ContactCsButton from '../../components/ContactCsButton';
import { toast } from '../../lib/api'; import { toast } from '../../lib/api';
import { getBrandAssetsSync, loadBrandAssets } from '../../lib/brand-assets';
const isWeapp = process.env.TARO_ENV === 'weapp'; const isWeapp = process.env.TARO_ENV === 'weapp';
const DIAL_PHONE = CUSTOMER_SERVICE_PHONE.replace(/-/g, '');
function dialPhone() { function dialPhone(phone: string) {
Taro.makePhoneCall({ phoneNumber: DIAL_PHONE }).catch(() => toast('无法拨打电话')); const tel = phone.replace(/-/g, '');
Taro.makePhoneCall({ phoneNumber: tel }).catch(() => toast('无法拨打电话'));
} }
export default function CustomerServicePage() { export default function CustomerServicePage() {
const [phone, setPhone] = useState(() => getBrandAssetsSync().customerServicePhone);
useEffect(() => {
void loadBrandAssets().then((b) => setPhone(b.customerServicePhone));
}, []);
return ( return (
<PageShell variant="sub" className="cs-page"> <PageShell variant="sub" className="cs-page">
<SubPageHeader title="联系客服" /> <SubPageHeader title="联系客服" />
@@ -30,14 +37,14 @@ export default function CustomerServicePage() {
<ContactCsButton className="cs-online-btn" session={{ from: 'customer-service' }} /> <ContactCsButton className="cs-online-btn" session={{ from: 'customer-service' }} />
) : null} ) : 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> <Text>
{isWeapp ? `或拨打客服电话 ${CUSTOMER_SERVICE_PHONE}` : '拨打客服电话'} {isWeapp ? `或拨打客服电话 ${phone}` : '拨打客服电话'}
</Text> </Text>
</View> </View>
{!isWeapp ? ( {!isWeapp ? (
<Text className="cs-phone-display">{CUSTOMER_SERVICE_PHONE}</Text> <Text className="cs-phone-display">{phone}</Text>
) : null} ) : null}
</View> </View>
</PageShell> </PageShell>
+10 -12
View File
@@ -28,10 +28,12 @@ import {
normalizeFulfillmentFlags, normalizeFulfillmentFlags,
} from '../../lib/product-fulfillment'; } from '../../lib/product-fulfillment';
import { getCityCodeForCatalog, resolveUserCity } from '../../lib/user-location'; import { getCityCodeForCatalog, resolveUserCity } from '../../lib/user-location';
import type { ClientRuntimeConfig } from '@dukang/shared-types';
import { import {
DEFAULT_SHARE_DESC, applyShareFromClientConfig,
DEFAULT_SHARE_TITLE, buildSceneSharePayload,
toWeappShareMessage, toWeappShareMessage,
toWeappShareTimeline,
} from '../../lib/wechat-share'; } from '../../lib/wechat-share';
import { trackPageView } from '../../lib/analytics'; import { trackPageView } from '../../lib/analytics';
type Product = { type Product = {
@@ -85,8 +87,9 @@ export default function HomePage() {
}, [cityCode]); }, [cityCode]);
const loadMiniHome = useCallback(() => { const loadMiniHome = useCallback(() => {
return request<{ miniHome?: MiniHomeConfig }>('/common/client-config') return request<ClientRuntimeConfig>('/common/client-config')
.then((cfg) => { .then((cfg) => {
applyShareFromClientConfig(cfg);
const banners = Array.isArray(cfg.miniHome?.banners) const banners = Array.isArray(cfg.miniHome?.banners)
? cfg.miniHome!.banners.filter((u) => typeof u === 'string' && !!u.trim()) ? cfg.miniHome!.banners.filter((u) => typeof u === 'string' && !!u.trim())
: []; : [];
@@ -221,21 +224,16 @@ export default function HomePage() {
const footerUrl = miniHome.footerUrl; const footerUrl = miniHome.footerUrl;
const sharePayload = useMemo( const sharePayload = useMemo(
() => ({ () =>
title: DEFAULT_SHARE_TITLE, buildSceneSharePayload('home', {
desc: DEFAULT_SHARE_DESC,
path: '/pages/home/index', path: '/pages/home/index',
imgUrl: banners[0] || undefined, dynamicImageUrl: banners[0] || undefined,
}), }),
[banners], [banners],
); );
useShareAppMessage(() => toWeappShareMessage(sharePayload)); useShareAppMessage(() => toWeappShareMessage(sharePayload));
useShareTimeline(() => ({ useShareTimeline(() => toWeappShareTimeline(sharePayload));
title: sharePayload.title || DEFAULT_SHARE_TITLE,
query: '',
imageUrl: sharePayload.imgUrl,
}));
function scrollToAroma(key: AromaKey) { function scrollToAroma(key: AromaKey) {
setActiveAroma(key); setActiveAroma(key);
+15 -4
View File
@@ -10,7 +10,11 @@ import {
import PageShell from '../../components/PageShell'; import PageShell from '../../components/PageShell';
import WechatLoginButton from '../../components/WechatLoginButton'; import WechatLoginButton from '../../components/WechatLoginButton';
import PhoneQuickLoginButton from '../../components/PhoneQuickLoginButton'; 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 { finishLoginNavigate, forceReloadAfterAccountMerge } from '../../lib/auth-nav';
import { import {
bindWechatForUser, bindWechatForUser,
@@ -98,11 +102,18 @@ export default function LoginPage() {
const [wxAuthorize, setWxAuthorize] = useState(true); const [wxAuthorize, setWxAuthorize] = useState(true);
const [completeMode, setCompleteMode] = useState<'phone' | 'wechat' | null>(null); const [completeMode, setCompleteMode] = useState<'phone' | 'wechat' | null>(null);
const [showSmsForm, setShowSmsForm] = useState(!IS_WEAPP); const [showSmsForm, setShowSmsForm] = useState(!IS_WEAPP);
const [logoWideUrl, setLogoWideUrl] = useState(() => getBrandAssetsSync().brandLogoWideUrl);
useEffect(() => { useEffect(() => {
request<ClientRuntimeConfig>('/common/client-config') request<ClientRuntimeConfig>('/common/client-config')
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config))) .then((config) => {
.catch(() => setWxAuthorize(true)); setWxAuthorize(isWxAuthorizeEnabled(config));
setLogoWideUrl(applyBrandFromClientConfig(config).brandLogoWideUrl);
})
.catch(() => {
setWxAuthorize(true);
void loadBrandAssets().then((b) => setLogoWideUrl(b.brandLogoWideUrl));
});
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -394,7 +405,7 @@ export default function LoginPage() {
<View className="login-header"> <View className="login-header">
<View className="login-logo-wrap"> <View className="login-logo-wrap">
<View className="login-logo"> <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> </View>
<Text className="login-logo-badge"></Text> <Text className="login-logo-badge"></Text>
</View> </View>
+41 -21
View File
@@ -2,8 +2,6 @@ import { useEffect, useMemo, useState } from 'react';
import { View, Text, Image, Button, Input, ScrollView } from '@tarojs/components'; import { View, Text, Image, Button, Input, ScrollView } from '@tarojs/components';
import Taro, { useDidShow, usePullDownRefresh, useShareAppMessage, useShareTimeline } from '@tarojs/taro'; import Taro, { useDidShow, usePullDownRefresh, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
import { import {
BRAND_LOGO_MARK_URL,
QUALIFICATION_DISCLOSURE_URL,
isWxAuthorizeEnabled, isWxAuthorizeEnabled,
type ClientRuntimeConfig, type ClientRuntimeConfig,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
@@ -13,6 +11,11 @@ import WechatShareReady from '../../components/WechatShareReady';
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar'; import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
import { goLogin } from '../../lib/auth-nav'; import { goLogin } from '../../lib/auth-nav';
import { bindWechatForUser } from '../../lib/wechat-auth'; import { bindWechatForUser } from '../../lib/wechat-auth';
import {
applyBrandFromClientConfig,
getBrandAssetsSync,
loadBrandAssets,
} from '../../lib/brand-assets';
import { import {
fetchMiniWechatUserInfo, fetchMiniWechatUserInfo,
isDefaultMiniNickname, isDefaultMiniNickname,
@@ -26,9 +29,10 @@ import { isWechatEnv } from '../../lib/weixin';
import { APP_VERSION_LABEL } from '../../lib/client-version'; import { APP_VERSION_LABEL } from '../../lib/client-version';
import { maskPhone } from '../../lib/phone'; import { maskPhone } from '../../lib/phone';
import { import {
DEFAULT_SHARE_DESC, applyShareFromClientConfig,
DEFAULT_SHARE_TITLE, buildSceneSharePayload,
toWeappShareMessage, toWeappShareMessage,
toWeappShareTimeline,
} from '../../lib/wechat-share'; } from '../../lib/wechat-share';
import iconPendingPay from '../../assets/icons/待付款.png'; import iconPendingPay from '../../assets/icons/待付款.png';
import iconPaid from '../../assets/icons/已付款.png'; import iconPaid from '../../assets/icons/已付款.png';
@@ -70,7 +74,11 @@ export default function MinePage() {
const [savingProfile, setSavingProfile] = useState(false); const [savingProfile, setSavingProfile] = useState(false);
const [profileLoadError, setProfileLoadError] = useState(''); const [profileLoadError, setProfileLoadError] = useState('');
const [qualificationOpen, setQualificationOpen] = useState(false); const [qualificationOpen, setQualificationOpen] = useState(false);
const [brandMarkUrl, setBrandMarkUrl] = useState(() => getBrandAssetsSync().brandLogoMarkUrl);
const [qualificationUrl, setQualificationUrl] = useState(
() => getBrandAssetsSync().qualificationDisclosureUrl,
);
const [shareTick, setShareTick] = useState(0);
function resetGuestState() { function resetGuestState() {
setProfile(null); setProfile(null);
setBenefitBalance(0); setBenefitBalance(0);
@@ -141,24 +149,30 @@ export default function MinePage() {
useEffect(() => { useEffect(() => {
request<ClientRuntimeConfig>('/common/client-config') request<ClientRuntimeConfig>('/common/client-config')
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config))) .then((config) => {
.catch(() => setWxAuthorize(true)); 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( const sharePayload = useMemo(
() => ({ () =>
title: '杜康好客 · 我的', buildSceneSharePayload('mine', {
desc: DEFAULT_SHARE_DESC,
path: '/pages/mine/index', path: '/pages/mine/index',
}), }),
[], [shareTick],
); );
useShareAppMessage(() => toWeappShareMessage(sharePayload)); useShareAppMessage(() => toWeappShareMessage(sharePayload));
useShareTimeline(() => ({ useShareTimeline(() => toWeappShareTimeline(sharePayload));
title: sharePayload.title || DEFAULT_SHARE_TITLE,
query: '',
}));
async function ensureWechatBound(): Promise<boolean> { async function ensureWechatBound(): Promise<boolean> {
if (profile?.hasWechat) return true; if (profile?.hasWechat) return true;
@@ -304,7 +318,7 @@ export default function MinePage() {
if (displayAvatarUrl) { if (displayAvatarUrl) {
return <Image className="mine-avatar-img" src={displayAvatarUrl} mode="aspectFill" />; 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) { if (!authed) {
@@ -317,7 +331,7 @@ export default function MinePage() {
<View className="mine-profile"> <View className="mine-profile">
<View className="mine-avatar-wrap mine-avatar-wrap--action" onClick={() => goLogin('/pages/mine/index')}> <View className="mine-avatar-wrap mine-avatar-wrap--action" onClick={() => goLogin('/pages/mine/index')}>
<View className="mine-avatar mine-avatar--wx-pending"> <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> </View>
<View> <View>
@@ -510,8 +524,13 @@ export default function MinePage() {
{shouldRenderPageTabBar() ? <UserTabBar selected={3} /> : null} {shouldRenderPageTabBar() ? <UserTabBar selected={3} /> : null}
{profileSheetOpen ? ( {profileSheetOpen ? (
<View className="mine-profile-sheet-mask" onClick={() => !savingProfile && setProfileSheetOpen(false)}> <View className="mine-profile-sheet-mask">
<View className="mine-profile-sheet" onClick={(e) => e.stopPropagation()}> {/* 遮罩单独绑 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-title"></Text>
<Text className="mine-profile-sheet-hint"> <Text className="mine-profile-sheet-hint">
@@ -520,13 +539,14 @@ export default function MinePage() {
className="mine-profile-avatar-btn" className="mine-profile-avatar-btn"
openType="chooseAvatar" openType="chooseAvatar"
hoverClass="none" hoverClass="none"
plain
onChooseAvatar={onChooseAvatar} onChooseAvatar={onChooseAvatar}
> >
<View className="mine-profile-avatar-preview"> <View className="mine-profile-avatar-preview">
{previewAvatar ? ( {previewAvatar ? (
<Image className="mine-avatar-img" src={previewAvatar} mode="aspectFill" /> <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> </View>
<Text className="mine-profile-avatar-tip"></Text> <Text className="mine-profile-avatar-tip"></Text>
@@ -579,7 +599,7 @@ export default function MinePage() {
<View className="mine-qualification-body"> <View className="mine-qualification-body">
<Image <Image
className="mine-qualification-img" className="mine-qualification-img"
src={QUALIFICATION_DISCLOSURE_URL} src={qualificationUrl}
mode="widthFix" mode="widthFix"
/> />
</View> </View>
@@ -143,7 +143,7 @@ export default function OrderConfirmPickupPage() {
const { confirm } = await Taro.showModal({ const { confirm } = await Taro.showModal({
title: '确认提交订单', title: '确认提交订单',
content: `确认提交现场提货订单?共 ${quantity} 瓶,应付 ¥${Number(preview?.payAmount ?? 0).toFixed(2)}`, content: `请确保您已拿到货品,货款将直接打给商家,如不是现场交易请选择立即购买方式下单,我们会为您安排配送到家`,
confirmText: '确认提交', confirmText: '确认提交',
cancelText: '再想想', cancelText: '再想想',
}); });
@@ -22,9 +22,9 @@ import { fetchOrderTrack } from '../../lib/order-logistics';
import { maskPhone } from '../../lib/phone'; import { maskPhone } from '../../lib/phone';
import { confirmOrderReceive, type WechatConfirmPayload } from '../../lib/wechat-order-confirm'; import { confirmOrderReceive, type WechatConfirmPayload } from '../../lib/wechat-order-confirm';
import { import {
DEFAULT_SHARE_DESC, buildSceneSharePayload,
DEFAULT_SHARE_TITLE,
toWeappShareMessage, toWeappShareMessage,
toWeappShareTimeline,
} from '../../lib/wechat-share'; } from '../../lib/wechat-share';
import { usePageView } from '../../lib/usePageView'; import { usePageView } from '../../lib/usePageView';
@@ -174,19 +174,20 @@ export default function OrderDetailPage() {
: ''; : '';
const sharePayload = useMemo( const sharePayload = useMemo(
() => ({ () =>
title: productName !== '杜康商品' ? `我买了${productName} · 杜康好客` : DEFAULT_SHARE_TITLE, buildSceneSharePayload('orderDetail', {
desc: DEFAULT_SHARE_DESC,
path: orderId ? `/pages/order-detail/index?id=${orderId}` : '/pages/home/index', path: orderId ? `/pages/order-detail/index?id=${orderId}` : '/pages/home/index',
titleVars: {
productName: productName && productName !== '杜康商品' ? productName : '',
},
}), }),
[productName, orderId], [productName, orderId],
); );
useShareAppMessage(() => toWeappShareMessage(sharePayload)); useShareAppMessage(() => toWeappShareMessage(sharePayload));
useShareTimeline(() => ({ useShareTimeline(() =>
title: sharePayload.title || DEFAULT_SHARE_TITLE, toWeappShareTimeline(sharePayload, orderId ? `id=${orderId}` : ''),
query: orderId ? `id=${orderId}` : '', );
}));
function goPay() { function goPay() {
if (!order) return; if (!order) return;
@@ -27,9 +27,9 @@ import {
normalizeFulfillmentFlags, normalizeFulfillmentFlags,
} from '../../lib/product-fulfillment'; } from '../../lib/product-fulfillment';
import { import {
DEFAULT_SHARE_DESC, buildSceneSharePayload,
DEFAULT_SHARE_TITLE,
toWeappShareMessage, toWeappShareMessage,
toWeappShareTimeline,
} from '../../lib/wechat-share'; } from '../../lib/wechat-share';
import iconHome from '../../assets/tabbar/home.png'; import iconHome from '../../assets/tabbar/home.png';
import { usePageView } from '../../lib/usePageView'; import { usePageView } from '../../lib/usePageView';
@@ -88,21 +88,20 @@ export default function ProductDetailPage() {
}); });
const sharePayload = useMemo( const sharePayload = useMemo(
() => ({ () =>
title: product?.name || DEFAULT_SHARE_TITLE, buildSceneSharePayload('productDetail', {
desc: product?.subtitle || DEFAULT_SHARE_DESC,
path: `/pages/product-detail/index?id=${productId}`, 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], [product, productId],
); );
useShareAppMessage(() => toWeappShareMessage(sharePayload)); useShareAppMessage(() => toWeappShareMessage(sharePayload));
useShareTimeline(() => ({ useShareTimeline(() =>
title: sharePayload.title || DEFAULT_SHARE_TITLE, toWeappShareTimeline(sharePayload, productId ? `id=${productId}` : ''),
query: productId ? `id=${productId}` : '', );
imageUrl: sharePayload.imgUrl,
}));
function goBack() { function goBack() {
const pages = Taro.getCurrentPages(); const pages = Taro.getCurrentPages();
+31 -59
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; 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, { import Taro, {
useDidShow, useDidShow,
useLoad, useLoad,
@@ -19,9 +19,9 @@ import { maskPhone } from '../../lib/phone';
import { track } from '../../lib/analytics'; import { track } from '../../lib/analytics';
import { formatShanghaiDateTime } from '../../lib/datetime'; import { formatShanghaiDateTime } from '../../lib/datetime';
import { import {
DEFAULT_SHARE_DESC, buildSceneSharePayload,
DEFAULT_SHARE_TITLE,
toWeappShareMessage, toWeappShareMessage,
toWeappShareTimeline,
} from '../../lib/wechat-share'; } from '../../lib/wechat-share';
type StoreMedia = { type StoreMedia = {
@@ -156,14 +156,9 @@ export default function StoreDetailPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(''); const [loadError, setLoadError] = useState('');
const [headerSolid, setHeaderSolid] = useState(false); const [headerSolid, setHeaderSolid] = useState(false);
const [activePackageIndex, setActivePackageIndex] = useState(0);
const storeRef = useRef<Store | null>(null); const storeRef = useRef<Store | null>(null);
storeRef.current = store; storeRef.current = store;
useEffect(() => {
setActivePackageIndex(0);
}, [store?.id]);
usePageScroll(({ scrollTop }) => { usePageScroll(({ scrollTop }) => {
setHeaderSolid(scrollTop > 100); setHeaderSolid(scrollTop > 100);
}); });
@@ -242,18 +237,15 @@ export default function StoreDetailPage() {
void loadRecentRedeems(id); void loadRecentRedeems(id);
}); });
const sharePayload = useMemo( const sharePayload = useMemo(() => {
() => {
const envFirst = store ? envPhotoUrls(store)[0] : undefined; const envFirst = store ? envPhotoUrls(store)[0] : undefined;
return { return buildSceneSharePayload('storeDetail', {
title: store?.name || DEFAULT_SHARE_TITLE,
desc: store?.intro?.trim() || store?.address || DEFAULT_SHARE_DESC,
path: `/pages/store-detail/index?id=${storeId}`, path: `/pages/store-detail/index?id=${storeId}`,
imgUrl: store?.coverUrl || envFirst || store?.carouselUrls?.[0] || undefined, dynamicTitle: store?.name,
}; dynamicDesc: store?.intro?.trim() || store?.address,
}, dynamicImageUrl: store?.coverUrl || envFirst || store?.carouselUrls?.[0],
[store, storeId], });
); }, [store, storeId]);
const marqueeLines = useMemo( const marqueeLines = useMemo(
() => recentRedeems.map(formatRecentRedeemLine).filter(Boolean), () => recentRedeems.map(formatRecentRedeemLine).filter(Boolean),
@@ -261,11 +253,9 @@ export default function StoreDetailPage() {
); );
useShareAppMessage(() => toWeappShareMessage(sharePayload)); useShareAppMessage(() => toWeappShareMessage(sharePayload));
useShareTimeline(() => ({ useShareTimeline(() =>
title: sharePayload.title || DEFAULT_SHARE_TITLE, toWeappShareTimeline(sharePayload, storeId ? `id=${storeId}` : ''),
query: storeId ? `id=${storeId}` : '', );
imageUrl: sharePayload.imgUrl,
}));
function goBack() { function goBack() {
const pages = Taro.getCurrentPages(); const pages = Taro.getCurrentPages();
@@ -326,13 +316,18 @@ export default function StoreDetailPage() {
const envPhotos = envPhotoUrls(store); const envPhotos = envPhotoUrls(store);
const heroImages = uniqueUrls([store.coverUrl, ...(store.carouselUrls || [])]); const heroImages = uniqueUrls([store.coverUrl, ...(store.carouselUrls || [])]);
const packages = store.packages ?? []; const packages = store.packages ?? [];
const activePackage = packages[activePackageIndex] ?? packages[0];
const intro = store.intro?.trim() || ''; const intro = store.intro?.trim() || '';
const benefitRuleRaw = store.benefitUsageRule?.trim() || ''; const benefitRuleRaw = store.benefitUsageRule?.trim() || '';
const benefitRule = const benefitRule =
benefitRuleRaw && !/^null$/i.test(benefitRuleRaw) ? benefitRuleRaw : ''; 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) { function previewEnv(index: number) {
if (!envPhotos.length) return; if (!envPhotos.length) return;
Taro.previewImage({ Taro.previewImage({
@@ -353,7 +348,13 @@ export default function StoreDetailPage() {
/> />
<View className="store-detail-hero full-bleed"> <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>
<View className="store-detail-info-card"> <View className="store-detail-info-card">
@@ -408,49 +409,20 @@ export default function StoreDetailPage() {
</View> </View>
) : null} ) : null}
{packages.length > 0 && activePackage ? ( {packages.length > 0 ? (
<View className="store-detail-section store-detail-section--packages"> <View className="store-detail-section store-detail-section--packages">
<Text className="store-detail-section-title"></Text> <Text className="store-detail-section-title"></Text>
{packages.length > 1 ? ( <View className="store-detail-package-list">
<ScrollView className="store-detail-package-tabs" scrollX showScrollbar={false} enhanced>
<View className="store-detail-package-tabs-inner">
{packages.map((pkg, index) => ( {packages.map((pkg, index) => (
<View <View
key={`${pkg.name}-${index}`} key={`${pkg.name}-${index}`}
className={`store-detail-package-tab${ className="store-detail-package-list-item"
index === activePackageIndex ? ' store-detail-package-tab--active' : '' onClick={() => openPackageDetail(index)}
}`}
onClick={() => setActivePackageIndex(index)}
> >
<Text className="store-detail-package-tab-text">{pkg.name}</Text> <Text className="store-detail-package-list-title">{pkg.name}</Text>
</View> </View>
))} ))}
</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> </View>
) : null} ) : 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>
);
}
+27 -40
View File
@@ -1,7 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { View, Text, Image, Input } from '@tarojs/components'; import { View, Text, Image, Input } from '@tarojs/components';
import Taro, { useDidShow, usePullDownRefresh, useShareAppMessage, useShareTimeline } from '@tarojs/taro'; import Taro, { useDidShow, usePullDownRefresh, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
import { StoreStatus, STORE_STATUS_LABELS } from '@dukang/shared-types';
import PageShell from '../../components/PageShell'; import PageShell from '../../components/PageShell';
import TabMainHeader from '../../components/TabMainHeader'; import TabMainHeader from '../../components/TabMainHeader';
import WechatShareReady from '../../components/WechatShareReady'; import WechatShareReady from '../../components/WechatShareReady';
@@ -37,10 +36,11 @@ import {
setStoresListCache, setStoresListCache,
} from '../../lib/stores-session'; } from '../../lib/stores-session';
import { import {
DEFAULT_SHARE_DESC, buildSceneSharePayload,
DEFAULT_SHARE_TITLE,
toWeappShareMessage, toWeappShareMessage,
toWeappShareTimeline,
} from '../../lib/wechat-share'; } from '../../lib/wechat-share';
import openBadgeImg from '../../assets/icons/store-open-badge.png';
type Store = { type Store = {
id: string; id: string;
@@ -342,33 +342,24 @@ export default function StoresPage() {
} }
} }
function formatHours(store: Store) { function hoursText(store: Store): string {
const parts: string[] = []; const parts: string[] = [];
if (store.openTime && store.closeTime) parts.push(`${store.openTime}-${store.closeTime}`); if (store.openTime && store.closeTime) parts.push(`${store.openTime}-${store.closeTime}`);
if (store.openTime2 && store.closeTime2) parts.push(`${store.openTime2}-${store.closeTime2}`); if (store.openTime2 && store.closeTime2) parts.push(`${store.openTime2}-${store.closeTime2}`);
return parts.length ? `营业时间: ${parts.join('')}` : '营业时间: 10:00-22:00'; if (!parts.length) parts.push('10:00-22:00');
} return `营业时间: ${parts.join(' ')}`;
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];
} }
const sharePayload = useMemo( const sharePayload = useMemo(
() => ({ () =>
title: '杜康好客门店', buildSceneSharePayload('stores', {
desc: DEFAULT_SHARE_DESC,
path: '/pages/stores/index', path: '/pages/stores/index',
}), }),
[], [],
); );
useShareAppMessage(() => toWeappShareMessage(sharePayload)); useShareAppMessage(() => toWeappShareMessage(sharePayload));
useShareTimeline(() => ({ useShareTimeline(() => toWeappShareTimeline(sharePayload));
title: sharePayload.title || DEFAULT_SHARE_TITLE,
query: '',
}));
return ( return (
<PageShell variant="tab" className="store-page no-tab-header"> <PageShell variant="tab" className="store-page no-tab-header">
@@ -431,42 +422,38 @@ export default function StoresPage() {
className="store-card" className="store-card"
onClick={() => Taro.navigateTo({ url: `/pages/store-detail/index?id=${s.id}` })} onClick={() => Taro.navigateTo({ url: `/pages/store-detail/index?id=${s.id}` })}
> >
<View className="store-card-cover-wrap">
{s.coverUrl ? ( {s.coverUrl ? (
<Image className="store-card-cover" src={s.coverUrl} mode="aspectFill" /> <Image className="store-card-cover" src={s.coverUrl} mode="aspectFill" />
) : ( ) : (
<View className="store-card-cover store-card-cover--empty" /> <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"> <View className="store-card-body">
{/* 第1行:标题 + 距离 */} {/* 第1行:标题(截断无省略号,顶到最右) */}
<View className="store-card-row store-card-row--head"> <View className="store-card-row store-card-row--head">
<Text className="store-card-name" numberOfLines={1}> <Text className="store-card-name">{s.name}</Text>
{s.name} </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>
<Text className="store-card-distance"> <Text className="store-card-distance">
{formatDistanceMeters(s.distanceMeters)} {formatDistanceMeters(s.distanceMeters)}
</Text> </Text>
</View> </View>
{/* 第2行:状态 + 营业时间(含第二段 */} {/* 第3行:营业时间(同行 */}
<View className="store-card-row store-card-row--meta"> <View className="store-card-row store-card-row--hours">
<Text className="store-card-status">{formatStatus(s)}</Text> <Text className="store-card-hours">{hoursText(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>
</View> </View>
</View> </View>
<Text className="store-card-arrow"></Text>
</View> </View>
))} ))}
</View> </View>
+16 -3
View File
@@ -386,13 +386,20 @@
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 1000; z-index: 1000;
background: rgba(20, 16, 14, 0.45);
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
justify-content: center; justify-content: center;
} }
.mine-profile-sheet-backdrop {
position: absolute;
inset: 0;
background: rgba(20, 16, 14, 0.45);
}
.mine-profile-sheet { .mine-profile-sheet {
position: relative;
z-index: 1;
width: 100%; width: 100%;
max-width: 480px; max-width: 480px;
box-sizing: border-box; box-sizing: border-box;
@@ -422,8 +429,9 @@
margin: 20px auto 0; margin: 20px auto 0;
padding: 0; padding: 0;
width: auto; width: auto;
background: transparent; height: auto;
border: none; background: transparent !important;
border: none !important;
line-height: 1.2; line-height: 1.2;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -434,6 +442,11 @@
border: none; border: none;
} }
/* 子节点不抢触摸,保证 open-type=chooseAvatar 由 Button 本人响应 */
.mine-profile-avatar-btn > * {
pointer-events: none;
}
.mine-profile-avatar-preview { .mine-profile-avatar-preview {
width: 88px; width: 88px;
height: 88px; height: 88px;
+137 -79
View File
@@ -12,7 +12,22 @@
width: 100%; width: 100%;
aspect-ratio: 4 / 3; aspect-ratio: 4 / 3;
overflow: hidden; 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 { .store-detail-carousel {
@@ -29,6 +44,7 @@
.store-detail-carousel-image { .store-detail-carousel-image {
object-fit: cover; object-fit: cover;
object-position: center center;
display: block; display: block;
} }
@@ -59,7 +75,7 @@
} }
.store-detail-info-card { .store-detail-info-card {
margin: -40px var(--space-page) 16px; margin: 0 var(--space-page) 16px;
position: relative; position: relative;
z-index: 2; z-index: 2;
background: var(--color-card); background: var(--color-card);
@@ -246,100 +262,142 @@
overflow: hidden; overflow: hidden;
} }
.store-detail-package-tabs { .store-detail-package-list {
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 {
display: flex; display: flex;
align-items: flex-start; flex-direction: column;
gap: 10px;
padding: 10px;
border-radius: var(--radius-md, 8px);
background: var(--color-surface-container, #f7f7f7);
box-sizing: border-box;
} }
.store-detail-package-thumb { .store-detail-package-list-item {
width: 72px; padding: 14px 0;
height: 72px; border-bottom: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 6px;
flex-shrink: 0;
background: rgba(0, 0, 0, 0.04);
} }
.store-detail-package-panel-body { .store-detail-package-list-item:first-child {
flex: 1; padding-top: 4px;
min-width: 0;
} }
.store-detail-package-body, .store-detail-package-list-item:last-child {
.store-detail-package-meta { border-bottom: none;
display: -webkit-box; padding-bottom: 0;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
} }
.store-detail-package-name { .store-detail-package-list-title {
display: block; display: block;
font-size: 15px; 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; font-weight: 700;
color: var(--color-text-primary, #1a1a1a); line-height: 1.4;
margin-bottom: 4px; color: var(--color-ink-black);
overflow: hidden; word-break: break-word;
text-overflow: ellipsis;
white-space: nowrap;
} }
.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; font-size: 13px;
color: var(--color-text-secondary, #666); line-height: 1.5;
line-height: 1.45; color: var(--color-on-surface-variant);
padding-left: 4px;
} }
.store-detail-package-meta { .store-package-detail-photo {
font-size: 12px; margin: 14px 0 0;
color: var(--color-text-tertiary, #999); border-radius: var(--radius-lg);
margin-top: 4px; 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 { .store-detail-package-dispute {
+78 -73
View File
@@ -144,12 +144,12 @@
padding: 4px var(--space-page) 16px; padding: 4px var(--space-page) 16px;
} }
/* 左图右文,卡片等高 */ /* 左图 + 中间文案 + 右侧箭头 */
.store-card { .store-card {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: stretch; align-items: center;
gap: 12px; gap: 10px;
padding: 12px; padding: 12px;
box-sizing: border-box; box-sizing: border-box;
background: var(--color-card); background: var(--color-card);
@@ -158,11 +158,19 @@
margin-bottom: 12px; margin-bottom: 12px;
} }
.store-card-cover { .store-card-cover-wrap {
position: relative;
flex-shrink: 0; flex-shrink: 0;
width: 96px; width: 96px;
height: 96px; height: 96px;
border-radius: 8px; border-radius: 8px;
overflow: hidden;
}
.store-card-cover {
width: 100%;
height: 100%;
border-radius: 8px;
background: var(--color-surface-container); background: var(--color-surface-container);
display: block; display: block;
} }
@@ -171,31 +179,42 @@
background: var(--color-surface-container); 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 { .store-card-body {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
height: 96px; min-height: 96px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
gap: 4px;
box-sizing: border-box; box-sizing: border-box;
} }
.store-card-row { .store-card-row {
display: flex; display: flex;
align-items: center; align-items: flex-start;
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
} }
/* 第1行:加粗标题(单行截断+ 右对齐距离 */ /* 第1行:加粗标题(单行截断,不显示 …,宽度顶到最右) */
.store-card-row--head { .store-card-row--head {
gap: 8px;
height: 22px; height: 22px;
align-items: center;
} }
.store-card-name { .store-card-name {
flex: 1; width: 100%;
min-width: 0; min-width: 0;
font-family: var(--font-headline); font-family: var(--font-headline);
font-size: 15px; font-size: 15px;
@@ -203,84 +222,70 @@
line-height: 22px; line-height: 22px;
color: #1a1a1a; color: #1a1a1a;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: clip;
white-space: nowrap; white-space: nowrap;
} }
.store-card-distance { /* 第2行:地址最多两行 + 右对齐距离 */
flex-shrink: 0; .store-card-row--mid {
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 {
gap: 8px; gap: 8px;
height: 28px; align-items: flex-start;
} }
.store-card-address { .store-card-address {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
font-size: 9px; font-size: 11px;
font-weight: 400; 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; color: #999;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: clip;
white-space: nowrap; white-space: nowrap;
} }
.store-card-cta { .store-card-arrow {
flex-shrink: 0; flex-shrink: 0;
display: flex; align-self: center;
align-items: center; width: 16px;
justify-content: center; font-size: 20px;
padding: 0 14px; font-weight: 300;
height: 26px; line-height: 1;
border-radius: 999px; color: #ccc;
background: var(--color-heritage-red, #a61d24); text-align: center;
box-sizing: border-box;
}
.store-card-cta-text {
font-size: 12px;
font-weight: 600;
line-height: 26px;
color: #fff;
white-space: nowrap;
} }
+140 -9
View File
@@ -32,29 +32,29 @@ export interface AppConfig {
userH5Url: string; userH5Url: string;
} }
/** 推广码 / C 端 H5 默认落地页(未配置 USER_H5_URL 时使用 */ /** 推广码 / C 端 H5 默认落地页(系统设置 USER_H5_URL 未配时回退;HQ 可改 */
export const DEFAULT_USER_H5_URL = 'https://user.runxian.top/user'; 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/'; 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`; 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`; 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`; export const BRAND_LOGO_MARK_URL = `${BRAND_LOGO_OSS_BASE}logo2.png`;
/** 小程序静态资源(资质公示等 */ /** 小程序静态资源根路径(默认;系统设置 MINI_USER_STATIC_OSS_BASE 可覆盖 */
export const MINI_USER_STATIC_OSS_BASE = export const MINI_USER_STATIC_OSS_BASE =
'https://dukang-dev.oss-cn-beijing.aliyuncs.com/static/mini-user/'; '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`; export const QUALIFICATION_DISCLOSURE_URL = `${MINI_USER_STATIC_OSS_BASE}qualification-disclosure.png`;
/** 总部客服电话(C 端联系客服 */ /** 总部客服电话(默认;系统设置 CUSTOMER_SERVICE_PHONE 可覆盖 */
export const CUSTOMER_SERVICE_PHONE = '13203801799'; export const CUSTOMER_SERVICE_PHONE = '13203801799';
/** /**
@@ -64,6 +64,137 @@ export const CUSTOMER_SERVICE_PHONE = '13203801799';
export const CUSTOMER_SERVICE_WECOM_URL = export const CUSTOMER_SERVICE_WECOM_URL =
'https://work.weixin.qq.com/kfid/kfc8b88659a1dffa8cd'; '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>) { function readEnv(env?: Record<string, string | undefined>) {
return ( return (
env ?? 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'; export const MOCK_SMS_FIXED_CODE = '999888';
@@ -18,6 +18,7 @@ export const HQ_PERMISSION_CATALOG = [
{ key: 'llm_configs', label: '语言模型配置', group: '业务' }, { key: 'llm_configs', label: '语言模型配置', group: '业务' },
{ key: 'knowledge_bases', label: '知识库', group: '业务' }, { key: 'knowledge_bases', label: '知识库', group: '业务' },
{ key: 'dev_plan', label: '开发计划', group: '业务' }, { key: 'dev_plan', label: '开发计划', group: '业务' },
{ key: 'test_whitelist', label: '白名单管理', group: '业务' },
{ key: 'resources', label: 'OSS 资源库', group: '业务' }, { key: 'resources', label: 'OSS 资源库', group: '业务' },
{ key: 'logs', label: '日志', group: '业务' }, { key: 'logs', label: '日志', group: '业务' },
{ key: 'users_delete', label: '删除用户', group: '危险操作' }, { key: 'users_delete', label: '删除用户', group: '危险操作' },
@@ -116,6 +117,7 @@ export const HQ_ROLE_DEFAULT_PERMISSIONS: Record<string, HqPermissionKey[]> = {
'llm_configs', 'llm_configs',
'knowledge_bases', 'knowledge_bases',
'dev_plan', 'dev_plan',
'test_whitelist',
'resources', 'resources',
'logs', 'logs',
'system_settings_wechat_mini', 'system_settings_wechat_mini',
+16
View File
@@ -1,3 +1,5 @@
import type { MiniShareRuntime } from './config';
/** 微信 JSSDK 初始化参数(后端签名下发) */ /** 微信 JSSDK 初始化参数(后端签名下发) */
export interface WechatJssdkConfig { export interface WechatJssdkConfig {
appId: string; appId: string;
@@ -50,6 +52,20 @@ export type ClientRuntimeConfig = {
}; };
/** 小程序最低兼容版本(semver,如 3.4.13);客户端低于此值时提示更新 */ /** 小程序最低兼容版本(semver,如 3.4.13);客户端低于此值时提示更新 */
minClientVersion?: string | null; minClientVersion?: string | null;
/** C 端 H5 落地页(推广码等) */
userH5Url?: string;
/** 方形品牌 Logo */
brandLogoUrl?: string;
/** 长方形品牌 Logo(登录) */
brandLogoWideUrl?: string;
/** 图标 Logo(默认头像) */
brandLogoMarkUrl?: string;
/** 「我的」资质公示长图 */
qualificationDisclosureUrl?: string;
/** 总部客服电话 */
customerServicePhone?: string;
/** 小程序各场景分享文案/图 */
share?: MiniShareRuntime;
}; };
/** 是否展示微信授权入口 */ /** 是否展示微信授权入口 */
+34
View File
@@ -0,0 +1,34 @@
# @dukang/weixin-sdk · 踩坑
## iOS 微信 H5JSSDK 入场 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 上返回入场 URLOAuth 的 `code/state` **必须保留**参与签名;后端 `jssdk-config` 只去 `#`,不要删 query。
3. **先 `captureIosJssdkEntryUrl()`,再 `stripOAuthParamsFromLocation()`**
4. 失败恢复:引导用户刷新页面或重新走 OAuth,而不是无限「再点一次」。
### 相关 API
- `captureIosJssdkEntryUrl` / `getJssdkSignUrl`
- `shouldHardNavigateForJssdk` / `hardNavigateInWechat`
- `stripOAuthParamsFromLocation`
+3
View File
@@ -5,10 +5,13 @@ export {
ensureJssdkReady, ensureJssdkReady,
isJssdkReady, isJssdkReady,
normalizeJssdkPageUrl, normalizeJssdkPageUrl,
jssdkUrlWithoutHash,
getJssdkSignUrl, getJssdkSignUrl,
captureIosJssdkEntryUrl, captureIosJssdkEntryUrl,
resetJssdkConfig, resetJssdkConfig,
stripOAuthParamsFromLocation, stripOAuthParamsFromLocation,
hardNavigateInWechat,
shouldHardNavigateForJssdk,
} from './jssdk'; } from './jssdk';
export { formatScanFailMessage, isScanPermissionWarmupError } from './scan'; export { formatScanFailMessage, isScanPermissionWarmupError } from './scan';
export { export {
+69 -22
View File
@@ -1,31 +1,53 @@
import type { WechatJssdkConfig } from '@dukang/shared-types'; 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'; import { DEFAULT_JS_API_LIST } from './types';
const JSSDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js'; 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 scriptPromise: Promise<void> | null = null;
let configured = false; let configured = false;
let configuredUrl: string | null = null; let configuredUrl: string | null = null;
/**
* iOS 微信 WebViewJSSDK 签名校验用的是「本次 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) */ /** 清除 JSSDK 配置缓存(路由切换后须重新 wx.config) */
export function resetJssdkConfig(): void { export function resetJssdkConfig(): void {
configured = false; configured = false;
configuredUrl = null; configuredUrl = null;
} }
/** 参与 JSSDK 签名的页面 URL:与微信文档一致,取 location.href 去掉 # 后的部分;剔除 OAuth 回调参数 */ /** 仅去 hash,保留全部 query(含 OAuth code/state)— iOS 入场签名必须如此 */
export function normalizeJssdkPageUrl(rawUrl: string): string { 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 { try {
const url = new URL(rawUrl); const url = new URL(rawUrl);
url.hash = ''; url.hash = '';
if (!keepOAuth) {
url.searchParams.delete('code'); url.searchParams.delete('code');
url.searchParams.delete('state'); url.searchParams.delete('state');
}
const query = url.searchParams.toString(); const query = url.searchParams.toString();
return `${url.origin}${url.pathname}${query ? `?${query}` : ''}`; return `${url.origin}${url.pathname}${query ? `?${query}` : ''}`;
} catch { } catch {
const noHash = rawUrl.split('#')[0]; const noHash = jssdkUrlWithoutHash(rawUrl);
if (keepOAuth) return noHash;
try { try {
const url = new URL(noHash, typeof window !== 'undefined' ? window.location.origin : 'https://localhost'); const url = new URL(noHash, typeof window !== 'undefined' ? window.location.origin : 'https://localhost');
url.searchParams.delete('code'); url.searchParams.delete('code');
@@ -42,25 +64,48 @@ function signUrlChanged(prev: string | null, current: string): boolean {
return prev !== current; return prev !== current;
} }
/** 记录最近一次签名 URL;SPA 路由或 ?step= 变化时须重新 wx.config */ function clearLegacySignUrlCache(): void {
export function captureIosJssdkEntryUrl(): void { try {
if (typeof window === 'undefined') return; sessionStorage.removeItem(LEGACY_SIGN_URL_CACHE_KEY);
if (!isWechatBrowser() || isWechatDevTools()) return; } catch {
const current = normalizeJssdkPageUrl(window.location.href); /* ignore */
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();
} }
} }
/** 获取参与 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 { 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 { function isJssdkDebugEnabled(): boolean {
@@ -76,6 +121,8 @@ export function stripOAuthParamsFromLocation(): void {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
const url = new URL(window.location.href); const url = new URL(window.location.href);
if (!url.searchParams.has('code') && !url.searchParams.has('state')) return; if (!url.searchParams.has('code') && !url.searchParams.has('state')) return;
// iOS:须先锁定入场 URL(含 code/state),再 replaceState
captureIosJssdkEntryUrl();
url.searchParams.delete('code'); url.searchParams.delete('code');
url.searchParams.delete('state'); url.searchParams.delete('state');
const query = url.searchParams.toString(); const query = url.searchParams.toString();
@@ -126,7 +173,7 @@ export async function initWechatJssdk(options: {
}): Promise<void> { }): Promise<void> {
captureIosJssdkEntryUrl(); captureIosJssdkEntryUrl();
const { apiBase, clientApp, getAccessToken } = options; const { apiBase, clientApp, getAccessToken } = options;
const pageUrl = options.url ?? getJssdkSignUrl(); const pageUrl = getJssdkSignUrl(options.url);
await loadScript(); await loadScript();
if (!window.wx) throw new Error('微信 JSSDK 不可用'); if (!window.wx) throw new Error('微信 JSSDK 不可用');
@@ -164,7 +211,7 @@ export async function ensureJssdkReady(options: {
jsApiList?: string[]; jsApiList?: string[];
}): Promise<void> { }): Promise<void> {
captureIosJssdkEntryUrl(); captureIosJssdkEntryUrl();
const pageUrl = options.url ?? getJssdkSignUrl(); const pageUrl = getJssdkSignUrl(options.url);
if (configuredUrl && signUrlChanged(configuredUrl, pageUrl)) { if (configuredUrl && signUrlChanged(configuredUrl, pageUrl)) {
resetJssdkConfig(); resetJssdkConfig();
} }
+34
View File
@@ -812,6 +812,21 @@ model CommonProductVisibilityPhone {
@@map("common_product_visibility_phone") @@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 { model CommonProductDetailTemplate {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
code String @unique @db.VarChar(32) code String @unique @db.VarChar(32)
@@ -1043,6 +1058,8 @@ model PartnerAccount {
bankBranch String? @map("bank_branch") @db.VarChar(128) bankBranch String? @map("bank_branch") @db.VarChar(128)
weeklyStoreTarget Int? @default(20) @map("weekly_store_target") weeklyStoreTarget Int? @default(20) @map("weekly_store_target")
managedWarehouseId BigInt? @unique @map("managed_warehouse_id") @db.UnsignedBigInt 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) createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
@@ -1059,6 +1076,7 @@ model PartnerAccount {
@@index([parentAccountId]) @@index([parentAccountId])
@@index([wxOpenId]) @@index([wxOpenId])
@@index([contactPhone]) @@index([contactPhone])
@@index([isTest])
@@map("partner_account") @@map("partner_account")
} }
@@ -1102,6 +1120,7 @@ model HqAccount {
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
permissions HqAccountPermission[] permissions HqAccountPermission[]
testWhitelistPhones CommonTestWhitelistPhone[] @relation("TestWhitelistCreatedBy")
@@map("hq_account") @@map("hq_account")
} }
@@ -1140,6 +1159,8 @@ model User {
nickname String? @db.VarChar(64) nickname String? @db.VarChar(64)
avatarResourceId BigInt? @map("avatar_resource_id") @db.UnsignedBigInt avatarResourceId BigInt? @map("avatar_resource_id") @db.UnsignedBigInt
status Int @default(1) @db.TinyInt status Int @default(1) @db.TinyInt
/// 测试账号:命中全局测试白名单手机号
isTest Boolean @default(false) @map("is_test")
sourceType UserSourceType @default(ORGANIC) @map("source_type") sourceType UserSourceType @default(ORGANIC) @map("source_type")
sourceRefId BigInt? @map("source_ref_id") @db.UnsignedBigInt sourceRefId BigInt? @map("source_ref_id") @db.UnsignedBigInt
sourceLabel String? @map("source_label") @db.VarChar(128) sourceLabel String? @map("source_label") @db.VarChar(128)
@@ -1166,6 +1187,7 @@ model User {
@@index([referrerUserId]) @@index([referrerUserId])
@@index([mergedIntoUserId]) @@index([mergedIntoUserId])
@@index([wxOpenId]) @@index([wxOpenId])
@@index([isTest])
@@map("user_user") @@map("user_user")
} }
@@ -1253,6 +1275,8 @@ model Store {
visibilityWhitelistEnabled Boolean @default(false) @map("visibility_whitelist_enabled") visibilityWhitelistEnabled Boolean @default(false) @map("visibility_whitelist_enabled")
/// FIN-001:允许未出账手动提现的白名单门店 /// FIN-001:允许未出账手动提现的白名单门店
withdrawWhitelistEnabled Boolean @default(false) @map("withdraw_whitelist_enabled") 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) createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
@@ -1274,6 +1298,7 @@ model Store {
@@index([cityId, status]) @@index([cityId, status])
@@index([partnerAccountId]) @@index([partnerAccountId])
@@index([auditStatus, createdAt]) @@index([auditStatus, createdAt])
@@index([isTest])
@@map("store_store") @@map("store_store")
} }
@@ -1344,6 +1369,8 @@ model StoreAccount {
bankBranch String? @map("bank_branch") @db.VarChar(128) bankBranch String? @map("bank_branch") @db.VarChar(128)
status AccountStatus @default(ACTIVE) status AccountStatus @default(ACTIVE)
lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3) 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) createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
@@ -1354,6 +1381,7 @@ model StoreAccount {
withdrawRequests StoreWithdrawRequest[] withdrawRequests StoreWithdrawRequest[]
@@index([parentAccountId]) @@index([parentAccountId])
@@index([isTest])
@@map("store_account") @@map("store_account")
} }
@@ -1432,6 +1460,8 @@ model Order {
fulfillmentHold Boolean @default(false) @map("fulfillment_hold") fulfillmentHold Boolean @default(false) @map("fulfillment_hold")
fulfillmentHoldReason String? @map("fulfillment_hold_reason") @db.VarChar(64) fulfillmentHoldReason String? @map("fulfillment_hold_reason") @db.VarChar(64)
remark String? @db.VarChar(512) remark String? @db.VarChar(512)
/// 测试订单快照(下单时取自 User.isTest
isTest Boolean @default(false) @map("is_test")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
@@ -1456,6 +1486,7 @@ model Order {
@@index([gpsCity]) @@index([gpsCity])
@@index([fulfillmentWarehouseId]) @@index([fulfillmentWarehouseId])
@@index([proxyPartnerAccountId]) @@index([proxyPartnerAccountId])
@@index([isTest])
@@map("user_order") @@map("user_order")
} }
@@ -1548,6 +1579,8 @@ model RedeemRecord {
settleAmount Decimal @map("settle_amount") @db.Decimal(10, 2) settleAmount Decimal @map("settle_amount") @db.Decimal(10, 2)
/// SCAN=qrcode, PHONE=phone /// SCAN=qrcode, PHONE=phone
channel RedeemChannel @default(SCAN) 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) createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
user User @relation(fields: [userId], references: [id], onDelete: Restrict) user User @relation(fields: [userId], references: [id], onDelete: Restrict)
@@ -1560,6 +1593,7 @@ model RedeemRecord {
@@index([storeId, createdAt]) @@index([storeId, createdAt])
@@index([storeId, channel, createdAt]) @@index([storeId, channel, createdAt])
@@index([isTest])
@@map("user_redeem_record") @@map("user_redeem_record")
} }
+44
View File
@@ -61,6 +61,16 @@ async function main() {
await prisma.storePayout.deleteMany(); 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.storeRating.deleteMany();
await prisma.redeemRecord.deleteMany(); await prisma.redeemRecord.deleteMany();
@@ -83,16 +93,28 @@ async function main() {
await prisma.user.deleteMany(); await prisma.user.deleteMany();
await prisma.storeAccount.updateMany({ data: { parentAccountId: null } });
await prisma.storeAccount.deleteMany(); await prisma.storeAccount.deleteMany();
await prisma.store.deleteMany(); await prisma.store.deleteMany();
await prisma.partnerBill.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.cityWarehouse.deleteMany();
await prisma.fulfillmentProvider.deleteMany(); await prisma.fulfillmentProvider.deleteMany();
await prisma.partnerAccount.updateMany({ data: { parentAccountId: null } });
await prisma.partnerAccount.deleteMany(); await prisma.partnerAccount.deleteMany();
await prisma.commonCity.deleteMany(); await prisma.commonCity.deleteMany();
@@ -101,6 +123,7 @@ async function main() {
await prisma.commonProductDetailTemplate.deleteMany(); await prisma.commonProductDetailTemplate.deleteMany();
await prisma.commonStoreCategory.updateMany({ data: { parentId: null } });
await prisma.commonStoreCategory.deleteMany(); await prisma.commonStoreCategory.deleteMany();
await prisma.commonPromoCode.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:', { console.log('Seed complete:', {
city: city.name, city: city.name,
@@ -928,6 +970,8 @@ async function main() {
stores: createdStores.length, stores: createdStores.length,
testWhitelistPhones,
testPhones: { testPhones: {
user: '13800000001', user: '13800000001',
+2
View File
@@ -21,6 +21,7 @@ import { CityScopeModule } from './modules/city-scope/city-scope.module';
import { CommonModule } from './modules/common/common.module'; import { CommonModule } from './modules/common/common.module';
import { HqOperationModule } from './common/hq-operation/hq-operation.module'; import { HqOperationModule } from './common/hq-operation/hq-operation.module';
import { SystemConfigModule } from './common/system-config/system-config.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 { DevPlanModule } from './modules/dev-plan/dev-plan.module';
import { CallbacksModule } from './callbacks/callbacks.module'; import { CallbacksModule } from './callbacks/callbacks.module';
import { WecomModule } from './integrations/wecom/wecom.module'; import { WecomModule } from './integrations/wecom/wecom.module';
@@ -37,6 +38,7 @@ import { RequestIdMiddleware } from './common/logging/request-id.middleware';
}), }),
PrismaModule, PrismaModule,
SystemConfigModule, SystemConfigModule,
TestWhitelistModule,
GeoModule, GeoModule,
RedisModule, RedisModule,
AlertModule, AlertModule,
@@ -6,6 +6,13 @@ import { logCourierCall } from '../integrations/courier/courier-log.util';
import { TradeService } from '../modules/trade/trade.service'; import { TradeService } from '../modules/trade/trade.service';
const XFX_PROVIDER_ALIASES = new Set(['xfx', 'xiaofeixia']); const XFX_PROVIDER_ALIASES = new Set(['xfx', 'xiaofeixia']);
const RAW_BODY_LOG_LIMIT = 4000;
export type TrackCallbackRequestMeta = {
contentType?: string | null;
rawBody?: string | null;
query?: Record<string, unknown>;
};
@Injectable() @Injectable()
export class DeliveryCallbackService { export class DeliveryCallbackService {
@@ -16,15 +23,17 @@ export class DeliveryCallbackService {
private readonly tradeService: TradeService, private readonly tradeService: TradeService,
) {} ) {}
async handleTrackCallback(providerKey: string, body: unknown, requestUrl: string) { async handleTrackCallback(
providerKey: string,
body: unknown,
requestUrl: string,
meta?: TrackCallbackRequestMeta,
) {
const normalized = providerKey.trim().toLowerCase(); const normalized = providerKey.trim().toLowerCase();
const baseLog = { const baseLog = {
scene: 'TRACK_CALLBACK', scene: 'TRACK_CALLBACK',
requestUrl, requestUrl,
requestBody: (body && typeof body === 'object' ? body : { value: body }) as Record< requestBody: this.buildRequestBodyForLog(body, meta),
string,
unknown
>,
}; };
if (!XFX_PROVIDER_ALIASES.has(normalized) && normalized !== 'logistics') { if (!XFX_PROVIDER_ALIASES.has(normalized) && normalized !== 'logistics') {
@@ -47,12 +56,14 @@ export class DeliveryCallbackService {
const payload = this.xiaofeixiaProvider.parseTrackCallback(body); const payload = this.xiaofeixiaProvider.parseTrackCallback(body);
if (!payload) { if (!payload) {
const response = this.courier.buildTrackCallbackResponse(false);
await logCourierCall(this.prisma, { await logCourierCall(this.prisma, {
...baseLog, ...baseLog,
responseBody: response,
status: 'FAILED', status: 'FAILED',
errorMessage: '回调体解析失败', errorMessage: '回调体解析失败',
}); });
return this.courier.buildTrackCallbackResponse(false); return response;
} }
const order = payload.outNumber const order = payload.outNumber
@@ -64,13 +75,15 @@ export class DeliveryCallbackService {
: null; : null;
if (!order) { if (!order) {
const response = this.courier.buildTrackCallbackResponse(false);
await logCourierCall(this.prisma, { await logCourierCall(this.prisma, {
...baseLog, ...baseLog,
responseBody: response,
status: 'FAILED', status: 'FAILED',
errorMessage: '订单不存在', errorMessage: '订单不存在',
externalNo: payload.outNumber || payload.trackingNumber, externalNo: payload.outNumber || payload.trackingNumber,
}); });
return this.courier.buildTrackCallbackResponse(false); return response;
} }
const targetStatus = this.xiaofeixiaProvider.mapTrackStatus( const targetStatus = this.xiaofeixiaProvider.mapTrackStatus(
@@ -101,4 +114,58 @@ export class DeliveryCallbackService {
return response; return response;
} }
/** 第三方日志:保留解析后字段,并附带 Content-Type / rawBody / query 便于排查空 body */
private buildRequestBodyForLog(
body: unknown,
meta?: TrackCallbackRequestMeta,
): Record<string, unknown> {
const parsed =
body && typeof body === 'object'
? ({ ...(body as Record<string, unknown>) } as Record<string, unknown>)
: body === undefined || body === null
? {}
: { value: body };
const rawBody = meta?.rawBody != null ? this.redactSecrets(String(meta.rawBody)) : null;
const query =
meta?.query && Object.keys(meta.query).length > 0
? this.redactSecretFields({ ...meta.query })
: undefined;
return {
...this.redactSecretFields(parsed),
_meta: {
contentType: meta?.contentType ?? null,
rawBody:
rawBody && rawBody.length > RAW_BODY_LOG_LIMIT
? `${rawBody.slice(0, RAW_BODY_LOG_LIMIT)}…(truncated)`
: rawBody,
rawBodyLength: meta?.rawBody != null ? Buffer.byteLength(meta.rawBody, 'utf8') : 0,
query,
},
};
}
private redactSecretFields(input: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(input)) {
if (/^sign$/i.test(key) || /api[_-]?key/i.test(key)) {
out[key] = '[REDACTED]';
} else if (key === 'mchId' && value != null) {
const s = String(value);
out[key] = s.length <= 4 ? '****' : `${s.slice(0, 4)}****`;
} else {
out[key] = value;
}
}
return out;
}
private redactSecrets(raw: string): string {
return raw
.replace(/(sign=)[^&\s]*/gi, '$1[REDACTED]')
.replace(/("sign"\s*:\s*")[^"]*/gi, '$1[REDACTED]')
.replace(/(api[_-]?key=)[^&\s]*/gi, '$1[REDACTED]');
}
} }
@@ -1,27 +1,56 @@
import { Body, Controller, Param, Post } from '@nestjs/common'; import { Body, Controller, Param, Post, Req, Res } from '@nestjs/common';
import type { Request, Response } from 'express';
import { DeliveryCallbackService } from './delivery-callback.service'; import { DeliveryCallbackService } from './delivery-callback.service';
type CourierCallbackRequest = Request & { rawBody?: Buffer };
@Controller('callbacks') @Controller('callbacks')
export class DeliveryCallbackController { export class DeliveryCallbackController {
constructor(private readonly deliveryCallbackService: DeliveryCallbackService) {} constructor(private readonly deliveryCallbackService: DeliveryCallbackService) {}
/** 小飞侠/物流路由变化回调(适配器入口) */ /** 小飞侠/物流路由变化回调(适配器入口) */
@Post('courier/:provider/track') @Post('courier/:provider/track')
trackByProvider(@Param('provider') provider: string, @Body() body: unknown) { async trackByProvider(
return this.deliveryCallbackService.handleTrackCallback( @Param('provider') provider: string,
@Body() body: unknown,
@Req() req: CourierCallbackRequest,
@Res() res: Response,
) {
const result = await this.deliveryCallbackService.handleTrackCallback(
provider, provider,
body, body,
`/api/v1/callbacks/courier/${provider}/track`, `/api/v1/callbacks/courier/${provider}/track`,
this.buildRequestMeta(req),
); );
// 直出承运商约定结构,避免被全局 { code:0, data } 包装
return res.status(200).json(result);
} }
/** 兼容旧路径,默认按小飞侠解析 */ /** 兼容旧路径,默认按小飞侠解析 */
@Post('delivery/track') @Post('delivery/track')
trackLegacy(@Body() body: unknown) { async trackLegacy(
return this.deliveryCallbackService.handleTrackCallback( @Body() body: unknown,
@Req() req: CourierCallbackRequest,
@Res() res: Response,
) {
const result = await this.deliveryCallbackService.handleTrackCallback(
'xfx', 'xfx',
body, body,
'/api/v1/callbacks/delivery/track', '/api/v1/callbacks/delivery/track',
this.buildRequestMeta(req),
); );
return res.status(200).json(result);
}
private buildRequestMeta(req: CourierCallbackRequest) {
const contentType = req.headers['content-type'];
return {
contentType: Array.isArray(contentType) ? contentType.join(', ') : contentType || null,
rawBody: req.rawBody?.toString('utf8') ?? null,
query:
req.query && typeof req.query === 'object'
? (req.query as Record<string, unknown>)
: undefined,
};
} }
} }
@@ -7,11 +7,10 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import { import {
HQ_DANGEROUS_PERMISSION_KEYS, HQ_PERMISSION_CATALOG,
HQ_ROLE_DEFAULT_PERMISSIONS, HQ_ROLE_DEFAULT_PERMISSIONS,
expandHqPermissionKeys, expandHqPermissionKeys,
hasAnySystemSettingsPermission, hasAnySystemSettingsPermission,
hqBasePermissionKeys,
type HqPermissionKey, type HqPermissionKey,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { PrismaService } from '../prisma/prisma.module'; import { PrismaService } from '../prisma/prisma.module';
@@ -28,7 +27,7 @@ export const RequireAnySystemSettings = () =>
export class HqPermissionsResolver { export class HqPermissionsResolver {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
async resolveEffectiveKeys(actorId: bigint): Promise<HqPermissionKey[]> { private async loadActiveAccount(actorId: bigint) {
const account = await this.prisma.hqAccount.findUnique({ const account = await this.prisma.hqAccount.findUnique({
where: { id: actorId }, where: { id: actorId },
select: { adminRole: true, status: true }, select: { adminRole: true, status: true },
@@ -36,6 +35,14 @@ export class HqPermissionsResolver {
if (!account || account.status !== 'ACTIVE') { if (!account || account.status !== 'ACTIVE') {
throw new ForbiddenException('HQ 账号不可用'); 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({ const userRows = await this.prisma.hqAccountPermission.findMany({
where: { hqAccountId: actorId }, where: { hqAccountId: actorId },
@@ -44,12 +51,14 @@ export class HqPermissionsResolver {
const userKeys = userRows.map((r) => r.permissionKey); const userKeys = userRows.map((r) => r.permissionKey);
if (account.adminRole === 'SUPER_ADMIN') { if (account.adminRole === 'SUPER_ADMIN') {
// 超管含危险操作(删用户/订单/城市);其他角色仍需在权限分配中显式勾选 // 超管拥有权限目录内全部项(含后续新增),另含危险操作与用户级附加项
return expandHqPermissionKeys([ return {
...hqBasePermissionKeys(), isSuperAdmin: true,
...HQ_DANGEROUS_PERMISSION_KEYS, keys: expandHqPermissionKeys([
...HQ_PERMISSION_CATALOG.map((p) => p.key),
...userKeys, ...userKeys,
]); ]),
};
} }
const roleRows = await this.prisma.hqRolePermission.findMany({ const roleRows = await this.prisma.hqRolePermission.findMany({
@@ -62,7 +71,15 @@ export class HqPermissionsResolver {
? roleRows.map((r) => r.permissionKey) ? roleRows.map((r) => r.permissionKey)
: [...(HQ_ROLE_DEFAULT_PERMISSIONS[account.adminRole] ?? [])]; : [...(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') { if (!user || user.actorType !== 'HQ') {
throw new ForbiddenException('需要 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; req.hqPermissionKeys = keys;
const required = const required =
@@ -89,6 +106,7 @@ export class HqPermissionGuard implements CanActivate {
]) ?? []; ]) ?? [];
if (!required.length) return true; if (!required.length) return true;
if (isSuperAdmin) return true;
if (required.includes('__any_system_settings__')) { if (required.includes('__any_system_settings__')) {
if (!hasAnySystemSettingsPermission(keys)) { if (!hasAnySystemSettingsPermission(keys)) {
throw new ForbiddenException('无系统设置权限'); throw new ForbiddenException('无系统设置权限');
@@ -1,4 +1,22 @@
import type { SystemConfigFieldMeta, SystemConfigGroupMeta } from '@dukang/shared-types'; 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 系统设置中维护 */ /** 运行环境、安全与鉴权仅保留在 .env,不在 HQ 系统设置中维护 */
export const SYSTEM_CONFIG_GROUPS: SystemConfigGroupMeta[] = [ export const SYSTEM_CONFIG_GROUPS: SystemConfigGroupMeta[] = [
@@ -6,6 +24,7 @@ export const SYSTEM_CONFIG_GROUPS: SystemConfigGroupMeta[] = [
{ key: 'sms', label: '短信' }, { key: 'sms', label: '短信' },
{ key: 'wechat', label: '微信' }, { key: 'wechat', label: '微信' },
{ key: 'wechat_mini', label: '微信小程序配置' }, { key: 'wechat_mini', label: '微信小程序配置' },
{ key: 'wechat_mini_share', label: '小程序分享配置' },
{ key: 'oss', label: '对象存储 OSS' }, { key: 'oss', label: '对象存储 OSS' },
{ key: 'app', label: '应用链接' }, { key: 'app', label: '应用链接' },
{ key: 'deploy', label: '发布部署' }, { key: 'deploy', label: '发布部署' },
@@ -18,6 +37,7 @@ const G = {
sms: 'sms', sms: 'sms',
wechat: 'wechat', wechat: 'wechat',
wechat_mini: 'wechat_mini', wechat_mini: 'wechat_mini',
wechat_mini_share: 'wechat_mini_share',
oss: 'oss', oss: 'oss',
app: 'app', app: 'app',
deploy: 'deploy', deploy: 'deploy',
@@ -122,9 +142,282 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
group: G.wechat_mini, group: G.wechat_mini,
type: 'string', type: 'string',
requiresRestart: false, requiresRestart: false,
placeholder: '3.4.13', placeholder: '3.4.15',
description: 'semver 格式;客户端低于此版本时提示更新', 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_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 }, { 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_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: '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', key: 'TENCENT_LBS_KEY',
label: '腾讯位置服务 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)); 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 { export function getSystemConfigField(key: string): SystemConfigFieldMeta | undefined {
return SYSTEM_CONFIG_FIELDS.find((f) => f.key === key); 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 { loadAppConfig, parseMiniHomeBanners, serializeMiniHomeBanners } from '@dukang/shared-types';
import { PrismaService } from '../prisma/prisma.module'; import { PrismaService } from '../prisma/prisma.module';
import { import {
SYSTEM_CONFIG_DEFAULTS,
SYSTEM_CONFIG_FIELDS, SYSTEM_CONFIG_FIELDS,
SYSTEM_CONFIG_GROUPS, SYSTEM_CONFIG_GROUPS,
SYSTEM_CONFIG_KEY_SET, SYSTEM_CONFIG_KEY_SET,
@@ -42,6 +43,7 @@ export class SystemConfigService implements OnModuleInit {
try { try {
await this.purgeRetiredKeys(); await this.purgeRetiredKeys();
await this.seedMissingFromProcessEnv(); await this.seedMissingFromProcessEnv();
await this.seedMissingDefaults();
const rows = await this.prisma.systemConfig.findMany(); const rows = await this.prisma.systemConfig.findMany();
applyEnvOverlay(Object.fromEntries(rows.map((r) => [r.configKey, r.value]))); applyEnvOverlay(Object.fromEntries(rows.map((r) => [r.configKey, r.value])));
this.lastUpdatedAt = rows.reduce<Date | null>( this.lastUpdatedAt = rows.reduce<Date | null>(
@@ -82,7 +84,7 @@ export class SystemConfigService implements OnModuleInit {
for (const field of fields) { for (const field of fields) {
const fromDb = dbMap.get(field.key); const fromDb = dbMap.get(field.key);
const fromEnv = process.env[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 (field.secret) {
if (raw) configuredSecrets.push(field.key); if (raw) configuredSecrets.push(field.key);
values[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 { private normalizeByMeta(meta: { key?: string; type: string }, raw: string): string {
if (meta.type === 'boolean') { if (meta.type === 'boolean') {
return raw === 'true' || raw === '1' ? 'true' : 'false'; 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 { parseTrackCallback(body: unknown): TrackCallbackPayload | null {
if (!body || typeof body !== 'object') return 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 outNumber = String(raw.outNumber ?? '').trim();
const trackingNumber = String(raw.number ?? raw.trackingNumber ?? '').trim(); const trackingNumber = String(raw.number ?? raw.trackingNumber ?? '').trim();
const status = String(raw.status ?? '').trim(); const status = String(raw.status ?? '').trim();
@@ -1,8 +1,9 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { resolveMockSmsFixedCode } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module'; import { PrismaService } from '../../common/prisma/prisma.module';
import { MockSmsCodeService } from '../../common/mock-sms-code/mock-sms-code.service'; import { MockSmsCodeService } from '../../common/mock-sms-code/mock-sms-code.service';
import type { ISmsProvider, SmsActorRef, SmsSendResult } from './sms.interface'; 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) { function maskPhone(phone: string) {
return `${phone.slice(0, 3)}****${phone.slice(-4)}`; 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> { 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); await this.mockSmsCodeService.record(phone, scene, code);
const masked = maskPhone(phone); const masked = maskPhone(phone);
this.logger.log(`Mock SMS → ${masked} scene=${scene} code=${code}`); this.logger.log(`Mock SMS → ${masked} scene=${scene} code=${code}`);
+71 -1
View File
@@ -3,7 +3,8 @@ import { NestFactory } from '@nestjs/core';
import { loadAppConfig } from '@dukang/shared-types'; import { loadAppConfig } from '@dukang/shared-types';
import { NestExpressApplication } from '@nestjs/platform-express'; import { NestExpressApplication } from '@nestjs/platform-express';
import { ValidationPipe } from '@nestjs/common'; import { ValidationPipe } from '@nestjs/common';
import { json } from 'express'; import { json, urlencoded, type NextFunction, type Request, type Response } from 'express';
import { parse as parseQueryString } from 'node:querystring';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { HttpExceptionFilter } from './common/filters/http-exception.filter'; import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { ResponseInterceptor } from './common/interceptors/response.interceptor'; import { ResponseInterceptor } from './common/interceptors/response.interceptor';
@@ -12,6 +13,61 @@ import { preloadSystemConfigEnv } from './common/system-config/system-config.env
import { AlertService } from './common/alert/alert.service'; import { AlertService } from './common/alert/alert.service';
import { initSentryIfConfigured } from './integrations/sentry/sentry.bootstrap'; import { initSentryIfConfigured } from './integrations/sentry/sentry.bootstrap';
function isCourierTrackCallbackUrl(url?: string): boolean {
if (!url) return false;
const path = url.split('?')[0] ?? '';
return (
(path.includes('/callbacks/courier/') && path.endsWith('/track')) ||
path.endsWith('/callbacks/delivery/track')
);
}
function parseCourierCallbackBody(
rawText: string,
contentType: string,
): Record<string, unknown> {
const ct = contentType.toLowerCase();
const trimmed = rawText.trim();
if (!trimmed) return {};
if (ct.includes('application/json') || trimmed.startsWith('{') || trimmed.startsWith('[')) {
try {
const parsed = JSON.parse(trimmed) as unknown;
return parsed && typeof parsed === 'object'
? (parsed as Record<string, unknown>)
: { value: parsed };
} catch {
return { _rawText: rawText.slice(0, 4000) };
}
}
if (ct.includes('application/x-www-form-urlencoded') || /[=&]/.test(trimmed)) {
return parseQueryString(trimmed) as Record<string, unknown>;
}
return { _rawText: rawText.slice(0, 4000) };
}
/** 小飞侠回调:无论 Content-Type,先吃下 rawBody 再尽力解析(便于第三方日志排查) */
function courierTrackRawBodyMiddleware(req: Request, _res: Response, next: NextFunction) {
if (req.method !== 'POST' || !isCourierTrackCallbackUrl(req.originalUrl || req.url)) {
return next();
}
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer | string) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
req.on('error', (err) => next(err));
req.on('end', () => {
const buf = Buffer.concat(chunks);
const rawText = buf.toString('utf8');
(req as Request & { rawBody?: Buffer }).rawBody = buf;
req.body = parseCourierCallbackBody(rawText, String(req.headers['content-type'] || ''));
next();
});
}
async function bootstrap() { async function bootstrap() {
const preloaded = await preloadSystemConfigEnv().catch((e) => { const preloaded = await preloadSystemConfigEnv().catch((e) => {
console.warn('[config] system_config preload skipped:', e instanceof Error ? e.message : e); console.warn('[config] system_config preload skipped:', e instanceof Error ? e.message : e);
@@ -26,8 +82,14 @@ async function bootstrap() {
app.setGlobalPrefix('api/v1'); app.setGlobalPrefix('api/v1');
app.set('trust proxy', true); app.set('trust proxy', true);
app.enableCors({ origin: true, credentials: true }); app.enableCors({ origin: true, credentials: true });
// 小飞侠路由回调:优先捕获 rawBody(Content-Type 异常时也能入第三方日志)
app.use(courierTrackRawBodyMiddleware);
// 微信支付等需 rawBody;其它 JSON / form 请求走常规解析(跳过已由上面吃掉 body 的小飞侠回调)
app.use( app.use(
json({ json({
type: (req) =>
!isCourierTrackCallbackUrl((req as Request).originalUrl || req.url) &&
Boolean(req.headers['content-type']?.includes('json')),
verify: (req, _res, buf) => { verify: (req, _res, buf) => {
if ( if (
req.url?.includes('/callbacks/wechat/pay') || req.url?.includes('/callbacks/wechat/pay') ||
@@ -39,6 +101,14 @@ async function bootstrap() {
}, },
}), }),
); );
app.use(
urlencoded({
extended: true,
type: (req) =>
!isCourierTrackCallbackUrl((req as Request).originalUrl || req.url) &&
Boolean(req.headers['content-type']?.includes('urlencoded')),
}),
);
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true })); app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
app.useGlobalFilters(new HttpExceptionFilter(app.get(AlertService))); app.useGlobalFilters(new HttpExceptionFilter(app.get(AlertService)));
app.useGlobalInterceptors(new ResponseInterceptor(), app.get(LoggingInterceptor)); app.useGlobalInterceptors(new ResponseInterceptor(), app.get(LoggingInterceptor));
@@ -1,6 +1,10 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.module'; import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import {
TestWhitelistService,
normalizeTestPhone,
} from '../../common/test-whitelist/test-whitelist.service';
import { groupResourcesByProductId, mapProductMedia } from './catalog.mapper'; import { groupResourcesByProductId, mapProductMedia } from './catalog.mapper';
export type CatalogViewer = { export type CatalogViewer = {
@@ -10,13 +14,32 @@ export type CatalogViewer = {
bypassWhitelist?: boolean; bypassWhitelist?: boolean;
}; };
function normalizePhone(phone: string | null | undefined): string {
return (phone || '').replace(/\D/g, '').trim();
}
@Injectable() @Injectable()
export class CatalogService { 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() { async listCities() {
const cities = await this.prisma.commonCity.findMany({ const cities = await this.prisma.commonCity.findMany({
@@ -58,11 +81,13 @@ export class CatalogService {
orderBy: { sortOrder: 'asc' }, orderBy: { sortOrder: 'asc' },
include: { include: {
coverResource: true, 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 productIds = visible.map((p) => p.id);
const resources = productIds.length const resources = productIds.length
@@ -81,7 +106,7 @@ export class CatalogService {
return serializeBigInt( return serializeBigInt(
visible.map((p) => { visible.map((p) => {
const media = mapProductMedia(p, resourceMap.get(p.id.toString()) ?? []); const media = mapProductMedia(p, resourceMap.get(p.id.toString()) ?? []);
const { visibilityPhones: _phones, visibilityWhitelistEnabled: _wl, ...rest } = p; const { visibilityWhitelistEnabled: _wl, ...rest } = p;
return { return {
...rest, ...rest,
benefitAmount: p.benefitAmount ?? p.price, benefitAmount: p.benefitAmount ?? p.price,
@@ -98,11 +123,13 @@ export class CatalogService {
where: { id }, where: { id },
include: { include: {
coverResource: true, coverResource: true,
visibilityPhones: { select: { phone: true } },
}, },
}); });
if (!product) return null; 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; return null;
} }
@@ -117,7 +144,7 @@ export class CatalogService {
}); });
const media = mapProductMedia(product, resources); const media = mapProductMedia(product, resources);
const { visibilityPhones: _phones, visibilityWhitelistEnabled: _wl, ...rest } = product; const { visibilityWhitelistEnabled: _wl, ...rest } = product;
return serializeBigInt({ return serializeBigInt({
...rest, ...rest,
benefitAmount: product.benefitAmount ?? product.price, benefitAmount: product.benefitAmount ?? product.price,
@@ -126,18 +153,20 @@ export class CatalogService {
}); });
} }
/** 下单前校验:白名单商品仅白名单手机号可买 */ /** 下单前校验:白名单商品仅全局测试白名单手机号可买 */
async assertPurchasable(productId: bigint, viewerPhone?: string | null) { async assertPurchasable(productId: bigint, viewerPhone?: string | null) {
const product = await this.prisma.commonProductItem.findUnique({ const product = await this.prisma.commonProductItem.findUnique({
where: { id: productId }, where: { id: productId },
include: { visibilityPhones: { select: { phone: true } } },
}); });
if (!product || product.status !== 'ON_SALE') { if (!product || product.status !== 'ON_SALE') {
throw new BadRequestException('商品不可购买'); throw new BadRequestException('商品不可购买');
} }
if (!this.isVisibleToViewer(product, { phone: viewerPhone })) { if (product.visibilityWhitelistEnabled) {
const ok = await this.testWhitelist.isPhoneInWhitelist(viewerPhone);
if (!ok) {
throw new BadRequestException('该商品暂不对当前账号开放'); throw new BadRequestException('该商品暂不对当前账号开放');
} }
}
return product; return product;
} }
@@ -148,18 +177,4 @@ export class CatalogService {
}); });
return user?.phone ?? null; 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 { 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'; import { SystemConfigService } from '../../common/system-config/system-config.service';
@Controller('common') @Controller('common')
@@ -12,6 +16,8 @@ export class ClientConfigController {
const env = this.systemConfig.getMergedEnv(); const env = this.systemConfig.getMergedEnv();
const footer = (env.MINI_HOME_FOOTER_URL ?? '').trim(); const footer = (env.MINI_HOME_FOOTER_URL ?? '').trim();
const minClientVersion = (env.MINI_USER_MIN_VERSION ?? '').trim() || null; const minClientVersion = (env.MINI_USER_MIN_VERSION ?? '').trim() || null;
const brand = resolveClientBrandRuntime(env);
const share = resolveMiniShareRuntime(env);
return { return {
mockPay: cfg.mockPay, mockPay: cfg.mockPay,
wechatPayEnabled: cfg.wechatPayEnabled, wechatPayEnabled: cfg.wechatPayEnabled,
@@ -25,6 +31,13 @@ export class ClientConfigController {
banners: parseMiniHomeBanners(env.MINI_HOME_BANNERS), banners: parseMiniHomeBanners(env.MINI_HOME_BANNERS),
footerUrl: footer || null, 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); return this.wechat.createJssdkConfig(pageUrl, actorRef);
} }
/**
* 仅去 hash。勿剔除 code/state
* iOS 微信用「document 入场 URL」验签,OAuth 回跳页的 query 必须原样参与签名。
*/
private normalizeJssdkUrl(rawUrl: string): string { 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]; return rawUrl.split('#')[0];
} }
}
@Get('oauth-url') @Get('oauth-url')
oauthUrl( oauthUrl(
@@ -25,6 +25,7 @@ import { AnalyticsService } from '../analytics/analytics.service';
import { UserAddressService } from './user-address.service'; import { UserAddressService } from './user-address.service';
import { ResourceService } from '../common/resource.service'; import { ResourceService } from '../common/resource.service';
import { HqPermissionsResolver } from '../../common/guards/hq-permission.guard'; import { HqPermissionsResolver } from '../../common/guards/hq-permission.guard';
import { TestWhitelistService } from '../../common/test-whitelist/test-whitelist.service';
import type { User } from '@prisma/client'; import type { User } from '@prisma/client';
@@ -67,8 +68,21 @@ export class AuthService {
private readonly userAddressService: UserAddressService, private readonly userAddressService: UserAddressService,
@Inject(forwardRef(() => ResourceService)) private readonly resourceService: ResourceService, @Inject(forwardRef(() => ResourceService)) private readonly resourceService: ResourceService,
private readonly hqPermissions: HqPermissionsResolver, 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) { private assertMobilePhone(phone: string) {
const trimmed = phone.trim(); const trimmed = phone.trim();
if (!/^1[3-9]\d{9}$/.test(trimmed)) { if (!/^1[3-9]\d{9}$/.test(trimmed)) {
@@ -375,12 +389,14 @@ export class AuthService {
}); });
if (!user) { if (!user) {
const isTest = await this.testWhitelist.isPhoneInWhitelist(normalizedPhone);
user = await this.prisma.user.create({ user = await this.prisma.user.create({
data: { data: {
phone: normalizedPhone, phone: normalizedPhone,
phoneVerifiedAt: new Date(), phoneVerifiedAt: new Date(),
userNo: generateUserNo(), userNo: generateUserNo(),
nickname: `用户${normalizedPhone.slice(-4)}`, nickname: `用户${normalizedPhone.slice(-4)}`,
isTest,
sourceType: source?.sourceType ?? 'ORGANIC', sourceType: source?.sourceType ?? 'ORGANIC',
sourceRefId: source?.sourceRefId, sourceRefId: source?.sourceRefId,
sourceLabel: source?.sourceLabel, sourceLabel: source?.sourceLabel,
@@ -401,6 +417,11 @@ export class AuthService {
include: { avatar: true }, 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); await this.assertActiveUser(user.id);
} }
@@ -810,12 +831,14 @@ export class AuthService {
} }
} }
if (!user) { if (!user) {
const isTest = await this.testWhitelist.isPhoneInWhitelist(normalizedPhone);
user = await this.prisma.user.create({ user = await this.prisma.user.create({
data: { data: {
phone: normalizedPhone, phone: normalizedPhone,
phoneVerifiedAt: new Date(), phoneVerifiedAt: new Date(),
userNo: generateUserNo(), userNo: generateUserNo(),
nickname: `用户${normalizedPhone.slice(-4)}`, nickname: `用户${normalizedPhone.slice(-4)}`,
isTest,
cityPreference: { cityPreference: {
create: { create: {
selectedCityCode: '410100', selectedCityCode: '410100',
@@ -852,6 +875,12 @@ export class AuthService {
if (!user) throw new BadRequestException('登录失败'); 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, { this.analyticsService.trackOneSafe(user.id, clientApp, {
eventName: method === 'sms' ? 'sms_login' : 'wechat_phone_login', eventName: method === 'sms' ? 'sms_login' : 'wechat_phone_login',
extraJson: { method }, extraJson: { method },
@@ -998,6 +1027,7 @@ export class AuthService {
if (!account) throw new BadRequestException('该手机号未绑定门店'); if (!account) throw new BadRequestException('该手机号未绑定门店');
if (account.status !== 'ACTIVE') throw new BadRequestException('门店账号已停用'); if (account.status !== 'ACTIVE') throw new BadRequestException('门店账号已停用');
if (!account.bindings.length) throw new BadRequestException('该账号未绑定任何门店'); if (!account.bindings.length) throw new BadRequestException('该账号未绑定任何门店');
await this.syncTestFlagByPhone(normalizedPhone);
await this.prisma.storeAccount.update({ await this.prisma.storeAccount.update({
where: { id: account.id }, where: { id: account.id },
data: { lastLoginAt: new Date() }, data: { lastLoginAt: new Date() },
@@ -1032,6 +1062,7 @@ export class AuthService {
}); });
if (!account) throw new BadRequestException('未找到合伙人账号'); if (!account) throw new BadRequestException('未找到合伙人账号');
if (account.status !== 'ACTIVE') throw new BadRequestException('合伙人账号已停用'); if (account.status !== 'ACTIVE') throw new BadRequestException('合伙人账号已停用');
await this.syncTestFlagByPhone(normalizedPhone);
const primary = await this.resolvePrimaryAccount(account.id); const primary = await this.resolvePrimaryAccount(account.id);
await this.prisma.partnerAccount.update({ await this.prisma.partnerAccount.update({
where: { id: account.id }, where: { id: account.id },
@@ -1,11 +1,9 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { import {
HQ_DANGEROUS_PERMISSION_KEYS,
HQ_PERMISSION_CATALOG, HQ_PERMISSION_CATALOG,
HQ_ROLE_DEFAULT_PERMISSIONS, HQ_ROLE_DEFAULT_PERMISSIONS,
LEGACY_SYSTEM_SETTINGS_KEY, LEGACY_SYSTEM_SETTINGS_KEY,
expandHqPermissionKeys, expandHqPermissionKeys,
hqBasePermissionKeys,
type HqPermissionKey, type HqPermissionKey,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module'; import { PrismaService } from '../../common/prisma/prisma.module';
@@ -83,10 +81,9 @@ export class AdminHqPermissionsService {
const userPermissionKeys = expandHqPermissionKeys(userPerms.map((p) => p.permissionKey)); const userPermissionKeys = expandHqPermissionKeys(userPerms.map((p) => p.permissionKey));
if (account.adminRole === 'SUPER_ADMIN') { if (account.adminRole === 'SUPER_ADMIN') {
const rolePermissionKeys = [ const rolePermissionKeys = HQ_PERMISSION_CATALOG.map(
...hqBasePermissionKeys(), (p) => p.key,
...HQ_DANGEROUS_PERMISSION_KEYS, ) as HqPermissionKey[];
] as HqPermissionKey[];
const effectivePermissionKeys = [ const effectivePermissionKeys = [
...new Set([...rolePermissionKeys, ...userPermissionKeys]), ...new Set([...rolePermissionKeys, ...userPermissionKeys]),
] as HqPermissionKey[]; ] as HqPermissionKey[];
@@ -38,6 +38,12 @@ export class AdminOrdersController {
return this.ordersService.getShipDefaults(); 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') @Get(':id')
detail(@Param('id') id: string) { detail(@Param('id') id: string) {
return this.ordersService.detail(BigInt(id)); 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.createdFrom) where.createdAt.gte = new Date(query.createdFrom);
if (query.createdTo) where.createdAt.lte = new Date(query.createdTo); if (query.createdTo) where.createdAt.lte = new Date(query.createdTo);
} }
if (query.excludeTest) where.isTest = false;
const [items, total] = await Promise.all([ const [items, total] = await Promise.all([
this.prisma.order.findMany({ this.prisma.order.findMany({
@@ -63,6 +64,15 @@ export class AdminOrdersService {
return serializeBigInt({ items, total, page, pageSize }); 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) { async detail(id: bigint) {
const order = await this.prisma.order.findUnique({ const order = await this.prisma.order.findUnique({
where: { id }, where: { id },
@@ -42,6 +42,7 @@ export class AdminPartnersService {
if (query.phone) where.phone = { contains: query.phone }; if (query.phone) where.phone = { contains: query.phone };
if (query.cityId) where.cityId = BigInt(query.cityId); if (query.cityId) where.cityId = BigInt(query.cityId);
if (query.partnerId) where.id = BigInt(query.partnerId); if (query.partnerId) where.id = BigInt(query.partnerId);
if (query.excludeTest) where.isTest = false;
const [items, total] = await Promise.all([ const [items, total] = await Promise.all([
this.prisma.partnerAccount.findMany({ this.prisma.partnerAccount.findMany({
@@ -5,6 +5,7 @@ import { serializeBigInt } from '../../common/decorators/current-user.decorator'
import { groupResourcesByProductId, mapProductMedia } from '../catalog/catalog.mapper'; import { groupResourcesByProductId, mapProductMedia } from '../catalog/catalog.mapper';
import type { AdminProductsQueryDto } from './dto/admin-query.dto'; import type { AdminProductsQueryDto } from './dto/admin-query.dto';
import type { CreateProductDto, UpdateProductDto } from './dto/admin-mutate.dto'; import type { CreateProductDto, UpdateProductDto } from './dto/admin-mutate.dto';
import { TestWhitelistService } from '../../common/test-whitelist/test-whitelist.service';
function normalizePhones(phones?: string[]): string[] { function normalizePhones(phones?: string[]): string[] {
if (!phones?.length) return []; if (!phones?.length) return [];
@@ -57,7 +58,10 @@ function resolveFulfillmentFlags(input: {
@Injectable() @Injectable()
export class AdminProductsService { export class AdminProductsService {
constructor(private readonly prisma: PrismaService) {} constructor(
private readonly prisma: PrismaService,
private readonly testWhitelist: TestWhitelistService,
) {}
async list(query: AdminProductsQueryDto) { async list(query: AdminProductsQueryDto) {
const page = query.page ?? 1; const page = query.page ?? 1;
@@ -140,6 +144,11 @@ export class AdminProductsService {
const phones = normalizePhones(dto.visibilityPhones); const phones = normalizePhones(dto.visibilityPhones);
const whitelistEnabled = !!dto.visibilityWhitelistEnabled; const whitelistEnabled = !!dto.visibilityWhitelistEnabled;
if (whitelistEnabled) {
await this.testWhitelist.assertGlobalWhitelistNotEmpty();
}
// 手机号统一在「白名单管理」维护;此处忽略分实体 phones(兼容旧客户端传参)
void phones;
const product = await this.createWithGeneratedSku({ const product = await this.createWithGeneratedSku({
barcode69: dto.barcode69, barcode69: dto.barcode69,
@@ -158,13 +167,6 @@ export class AdminProductsService {
...(dto.detailContent !== undefined ...(dto.detailContent !== undefined
? { detailContent: dto.detailContent as Prisma.InputJsonValue } ? { detailContent: dto.detailContent as Prisma.InputJsonValue }
: {}), : {}),
...(phones.length
? {
visibilityPhones: {
create: phones.map((phone) => ({ phone })),
},
}
: {}),
}); });
if (dto.coverUrl) { if (dto.coverUrl) {
@@ -225,9 +227,10 @@ export class AdminProductsService {
}, },
}); });
if (dto.visibilityPhones !== undefined) { if (dto.visibilityWhitelistEnabled) {
await this.syncVisibilityPhones(id, normalizePhones(dto.visibilityPhones)); await this.testWhitelist.assertGlobalWhitelistNotEmpty();
} }
// 分实体手机号已废弃;忽略 dto.visibilityPhones
if (dto.coverUrl) { if (dto.coverUrl) {
await this.syncCover(id, dto.coverUrl); await this.syncCover(id, dto.coverUrl);
@@ -42,6 +42,7 @@ export class AdminRedeemService {
if (query.channel === 'SCAN' || query.channel === 'PHONE') { if (query.channel === 'SCAN' || query.channel === 'PHONE') {
where.channel = query.channel; where.channel = query.channel;
} }
if (query.excludeTest) where.isTest = false;
const [items, total] = await Promise.all([ const [items, total] = await Promise.all([
this.prisma.redeemRecord.findMany({ this.prisma.redeemRecord.findMany({
@@ -17,6 +17,7 @@ import type {
UpdateStoreMediaDto, UpdateStoreMediaDto,
UpdateStoreStatusDto, UpdateStoreStatusDto,
} from './dto/admin-mutate.dto'; } from './dto/admin-mutate.dto';
import { TestWhitelistService } from '../../common/test-whitelist/test-whitelist.service';
/** 选填文案:空 / null / "null" 一律存库为 null,避免 String(null)==="null" */ /** 选填文案:空 / null / "null" 一律存库为 null,避免 String(null)==="null" */
function normalizeStoreOptionalText(value: unknown): string | null { function normalizeStoreOptionalText(value: unknown): string | null {
@@ -51,6 +52,7 @@ export class AdminStoresService {
private readonly partnerCityService: PartnerCityService, private readonly partnerCityService: PartnerCityService,
private readonly storeCategoryService: StoreCategoryService, private readonly storeCategoryService: StoreCategoryService,
private readonly analyticsService: AnalyticsService, private readonly analyticsService: AnalyticsService,
private readonly testWhitelist: TestWhitelistService,
) {} ) {}
async listStores(query: AdminStoresQueryDto) { async listStores(query: AdminStoresQueryDto) {
@@ -65,6 +67,7 @@ export class AdminStoresService {
if (query.cityId) where.cityId = BigInt(query.cityId); if (query.cityId) where.cityId = BigInt(query.cityId);
if (query.partnerId) where.partnerAccountId = BigInt(query.partnerId); if (query.partnerId) where.partnerAccountId = BigInt(query.partnerId);
if (query.phone) where.phone = { contains: query.phone }; if (query.phone) where.phone = { contains: query.phone };
if (query.excludeTest) where.isTest = false;
const [items, total] = await Promise.all([ const [items, total] = await Promise.all([
this.prisma.store.findMany({ this.prisma.store.findMany({
@@ -283,18 +286,7 @@ export class AdminStoresService {
? !!dto.visibilityWhitelistEnabled ? !!dto.visibilityWhitelistEnabled
: current.visibilityWhitelistEnabled; : current.visibilityWhitelistEnabled;
if (nextEnabled) { if (nextEnabled) {
const phones = await this.testWhitelist.assertGlobalWhitelistNotEmpty();
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('开启白名单时请至少添加一个手机号');
}
} }
} }
const bankTouched = const bankTouched =
@@ -372,18 +364,13 @@ export class AdminStoresService {
...(dto.visibilityWhitelistEnabled !== undefined ...(dto.visibilityWhitelistEnabled !== undefined
? { visibilityWhitelistEnabled: !!dto.visibilityWhitelistEnabled } ? { visibilityWhitelistEnabled: !!dto.visibilityWhitelistEnabled }
: {}), : {}),
...(dto.isTest !== undefined ? { isTest: !!dto.isTest } : {}),
...(latitude != null && longitude != null ? { latitude, longitude } : {}), ...(latitude != null && longitude != null ? { latitude, longitude } : {}),
}, },
}); });
if (dto.visibilityPhones !== undefined) { 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) { if (dto.coverUrl) {
@@ -505,11 +492,14 @@ export class AdminStoresService {
throw new BadRequestException('好客权益券使用规则最多 1000 字'); throw new BadRequestException('好客权益券使用规则最多 1000 字');
} }
const visibilityPhones = normalizeVisibilityPhones(dto.visibilityPhones);
const whitelistEnabled = !!dto.visibilityWhitelistEnabled; const whitelistEnabled = !!dto.visibilityWhitelistEnabled;
if (whitelistEnabled && !visibilityPhones.length) { if (whitelistEnabled) {
throw new BadRequestException('开启白名单时请至少添加一个手机号'); await this.testWhitelist.assertGlobalWhitelistNotEmpty();
} }
const isTest =
dto.isTest !== undefined
? !!dto.isTest
: await this.testWhitelist.isPhoneInWhitelist(normalizedPhone);
const store = await this.prisma.store.create({ const store = await this.prisma.store.create({
data: { data: {
@@ -531,18 +521,12 @@ export class AdminStoresService {
openTime2: openTime2 || null, openTime2: openTime2 || null,
closeTime2: closeTime2 || null, closeTime2: closeTime2 || null,
visibilityWhitelistEnabled: whitelistEnabled, visibilityWhitelistEnabled: whitelistEnabled,
isTest,
...(latitude != null && longitude != null ? { latitude, longitude } : {}), ...(latitude != null && longitude != null ? { latitude, longitude } : {}),
status: 'OPEN', status: 'OPEN',
auditStatus: 'APPROVED', auditStatus: 'APPROVED',
auditedAt: new Date(), auditedAt: new Date(),
rejectReason: null, rejectReason: null,
...(visibilityPhones.length
? {
visibilityPhones: {
create: visibilityPhones.map((phone) => ({ phone })),
},
}
: {}),
}, },
}); });
@@ -746,6 +730,7 @@ export class AdminStoresService {
where.bindings = { some: { storeId: BigInt(query.storeId) } }; where.bindings = { some: { storeId: BigInt(query.storeId) } };
} }
if (query.status) where.status = query.status as Prisma.EnumAccountStatusFilter['equals']; if (query.status) where.status = query.status as Prisma.EnumAccountStatusFilter['equals'];
if (query.excludeTest) where.isTest = false;
const [items, total] = await Promise.all([ const [items, total] = await Promise.all([
this.prisma.storeAccount.findMany({ 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; sourceType: string;
sourceRefId: bigint | null; sourceRefId: bigint | null;
sourceLabel: string | null; sourceLabel: string | null;
isTest?: boolean;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
_count: { orders: number }; _count: { orders: number };
@@ -37,6 +38,7 @@ function mapAdminUserRow(u: {
sourceType: u.sourceType, sourceType: u.sourceType,
sourceRefId: u.sourceRefId, sourceRefId: u.sourceRefId,
sourceLabel: u.sourceLabel, sourceLabel: u.sourceLabel,
isTest: !!u.isTest,
createdAt: u.createdAt, createdAt: u.createdAt,
updatedAt: u.updatedAt, updatedAt: u.updatedAt,
orderCount: u._count.orders, orderCount: u._count.orders,
@@ -58,6 +60,7 @@ export class AdminUsersService {
if (query.status !== undefined) where.status = query.status; if (query.status !== undefined) where.status = query.status;
if (query.phoneVerified === '1') where.phoneVerifiedAt = { not: null }; if (query.phoneVerified === '1') where.phoneVerifiedAt = { not: null };
if (query.phoneVerified === '0') where.phoneVerifiedAt = null; if (query.phoneVerified === '0') where.phoneVerifiedAt = null;
if (query.excludeTest) where.isTest = false;
const [items, total] = await Promise.all([ const [items, total] = await Promise.all([
this.prisma.user.findMany({ this.prisma.user.findMany({
@@ -78,6 +81,7 @@ export class AdminUsersService {
sourceType: true, sourceType: true,
sourceRefId: true, sourceRefId: true,
sourceLabel: true, sourceLabel: true,
isTest: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
_count: { select: { orders: true } }, _count: { select: { orders: true } },
@@ -128,16 +128,21 @@ export class CreateStoreDto {
@Min(0) @Min(0)
avgPrice?: number; avgPrice?: number;
/** 开启后仅白名单手机号在 C 端可见 */ /** 开启后仅全局测试白名单手机号在 C 端可见 */
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
visibilityWhitelistEnabled?: boolean; visibilityWhitelistEnabled?: boolean;
/** 可见白名单手机号列表 */ /** @deprecated 已并入全局白名单,忽略 */
@IsOptional() @IsOptional()
@IsArray() @IsArray()
@IsString({ each: true }) @IsString({ each: true })
visibilityPhones?: string[]; visibilityPhones?: string[];
/** 测试门店标记 */
@IsOptional()
@IsBoolean()
isTest?: boolean;
} }
export class UpdateStoreDto { export class UpdateStoreDto {
@@ -227,16 +232,21 @@ export class UpdateStoreDto {
@IsString() @IsString()
bankBranch?: string | null; bankBranch?: string | null;
/** 开启后仅白名单手机号在 C 端可见 */ /** 开启后仅全局测试白名单手机号在 C 端可见 */
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
visibilityWhitelistEnabled?: boolean; visibilityWhitelistEnabled?: boolean;
/** 可见白名单手机号列表 */ /** @deprecated 已并入全局白名单,忽略 */
@IsOptional() @IsOptional()
@IsArray() @IsArray()
@IsString({ each: true }) @IsString({ each: true })
visibilityPhones?: string[]; visibilityPhones?: string[];
/** 测试门店标记 */
@IsOptional()
@IsBoolean()
isTest?: boolean;
} }
export class CreateStoreAccountDto { export class CreateStoreAccountDto {
@@ -1,5 +1,12 @@
import { Type } from 'class-transformer'; import { Type, Transform } from 'class-transformer';
import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; 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 { export class PaginationQueryDto {
@IsOptional() @IsOptional()
@@ -37,6 +44,12 @@ export class AdminUsersQueryDto extends PaginationQueryDto {
@Type(() => Number) @Type(() => Number)
@IsIn([0, 1]) @IsIn([0, 1])
status?: number; status?: number;
/** 勾选「过滤测试账号」时传 true */
@IsOptional()
@Transform(({ value }) => toOptionalBoolean(value))
@IsBoolean()
excludeTest?: boolean;
} }
export class AdminOrdersQueryDto extends PaginationQueryDto { export class AdminOrdersQueryDto extends PaginationQueryDto {
@@ -75,6 +88,11 @@ export class AdminOrdersQueryDto extends PaginationQueryDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
createdTo?: string; createdTo?: string;
@IsOptional()
@Transform(({ value }) => toOptionalBoolean(value))
@IsBoolean()
excludeTest?: boolean;
} }
/** 概览页用户/订单 ECharts 聚合筛选 */ /** 概览页用户/订单 ECharts 聚合筛选 */
@@ -128,6 +146,11 @@ export class AdminStoresQueryDto extends PaginationQueryDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
auditStatus?: string; auditStatus?: string;
@IsOptional()
@Transform(({ value }) => toOptionalBoolean(value))
@IsBoolean()
excludeTest?: boolean;
} }
export class AdminStoreAccountsQueryDto extends PaginationQueryDto { export class AdminStoreAccountsQueryDto extends PaginationQueryDto {
@@ -142,6 +165,11 @@ export class AdminStoreAccountsQueryDto extends PaginationQueryDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
status?: string; status?: string;
@IsOptional()
@Transform(({ value }) => toOptionalBoolean(value))
@IsBoolean()
excludeTest?: boolean;
} }
export class AdminPartnersQueryDto extends PaginationQueryDto { export class AdminPartnersQueryDto extends PaginationQueryDto {
@@ -164,6 +192,11 @@ export class AdminPartnersQueryDto extends PaginationQueryDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
partnerId?: string; partnerId?: string;
@IsOptional()
@Transform(({ value }) => toOptionalBoolean(value))
@IsBoolean()
excludeTest?: boolean;
} }
export class AdminCityWarehousesQueryDto extends PaginationQueryDto { export class AdminCityWarehousesQueryDto extends PaginationQueryDto {
@@ -247,6 +280,11 @@ export class AdminRedeemRecordsQueryDto extends PaginationQueryDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
channel?: string; channel?: string;
@IsOptional()
@Transform(({ value }) => toOptionalBoolean(value))
@IsBoolean()
excludeTest?: boolean;
} }
export class AdminStoreRatingsQueryDto extends PaginationQueryDto { 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 { AdminFulfillmentProvidersController } from './admin-fulfillment-providers.controller';
import { AdminDomainEventsController } from './admin-domain-events.controller'; import { AdminDomainEventsController } from './admin-domain-events.controller';
import { AdminDomainEventsService } from './admin-domain-events.service'; import { AdminDomainEventsService } from './admin-domain-events.service';
import { AdminTestWhitelistController } from './admin-test-whitelist.controller';
@Module({ @Module({
imports: [CityScopeModule, IamModule, TradeModule, AnalyticsModule, FulfillmentModule, BenefitModule, CommonModule, IntegrationsModule, WecomModule, LlmModule, RedeemModule, StoreModule, DevPlanModule], 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, AdminKnowledgeBasesController,
AdminDevPlanController, AdminDevPlanController,
AdminFulfillmentProvidersController, AdminFulfillmentProvidersController,
AdminTestWhitelistController,
], ],
providers: [ providers: [
AdminDashboardService, AdminDashboardService,
@@ -180,6 +180,15 @@ export class RedeemService {
const settleAmount = calcRedeemSettleAmount(amount, settlementRate); const settleAmount = calcRedeemSettleAmount(amount, settlementRate);
const redeemChannel = analyticsExtra?.channel === 'phone' ? 'PHONE' : 'SCAN'; 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; let record;
try { try {
record = await this.prisma.$transaction(async (tx) => { record = await this.prisma.$transaction(async (tx) => {
@@ -194,6 +203,7 @@ export class RedeemService {
amount, amount,
settleAmount, settleAmount,
channel: redeemChannel, channel: redeemChannel,
isTest,
allocations: { allocations: {
create: normalizedAllocations.map((item, index) => ({ create: normalizedAllocations.map((item, index) => ({
couponId: BigInt(item.couponId), couponId: BigInt(item.couponId),
@@ -204,6 +214,7 @@ export class RedeemService {
}, },
}); });
if (!isTest) {
await this.settlementService.createStorePayout( await this.settlementService.createStorePayout(
redeemRecord.id, redeemRecord.id,
account.storeId, account.storeId,
@@ -212,6 +223,7 @@ export class RedeemService {
settlementRate, settlementRate,
tx, tx,
); );
}
return redeemRecord; return redeemRecord;
}); });
@@ -1322,9 +1322,12 @@ export class SettlementService {
cityId: primary.cityId, cityId: primary.cityId,
payStatus: 'PAID', payStatus: 'PAID',
paidAt: { gte: periodStart, lte: periodEnd }, 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) {
if (o.partnerAccountIdAtPay !== primary.id) return sum; if (o.partnerAccountIdAtPay !== primary.id) return sum;
const rate = o.orderCommissionRateAtPay != null ? Number(o.orderCommissionRateAtPay) : 0; const rate = o.orderCommissionRateAtPay != null ? Number(o.orderCommissionRateAtPay) : 0;
@@ -1338,14 +1341,16 @@ export class SettlementService {
select: { id: true }, select: { id: true },
}); });
const storeIds = stores.map((s) => s.id); const storeIds = stores.map((s) => s.id);
const redeems = storeIds.length const redeems =
? await this.prisma.redeemRecord.findMany({ primary.isTest || !storeIds.length
? []
: await this.prisma.redeemRecord.findMany({
where: { where: {
storeId: { in: storeIds }, storeId: { in: storeIds },
createdAt: { gte: periodStart, lte: periodEnd }, createdAt: { gte: periodStart, lte: periodEnd },
isTest: false,
}, },
}) });
: [];
const redeemCommission = redeems.reduce( const redeemCommission = redeems.reduce(
(sum, r) => sum + Number(r.amount) * redeemCommissionRate, (sum, r) => sum + Number(r.amount) * redeemCommissionRate,
0, 0,
@@ -1588,6 +1593,7 @@ export class SettlementService {
payStatus: 'PAID', payStatus: 'PAID',
deliveryType: { in: ['LOCAL', 'CROSS_CITY'] }, deliveryType: { in: ['LOCAL', 'CROSS_CITY'] },
completedAt: { gte: start, lt: end }, completedAt: { gte: start, lt: end },
isTest: false,
}, },
orderBy: { completedAt: 'asc' }, orderBy: { completedAt: 'asc' },
}); });
@@ -1921,6 +1927,7 @@ export class SettlementService {
quantity: true, quantity: true,
deliveryType: true, deliveryType: true,
payStatus: true, payStatus: true,
isTest: true,
}, },
}, },
}, },
@@ -1929,6 +1936,7 @@ export class SettlementService {
const eligible = deliveries.filter( const eligible = deliveries.filter(
(d) => (d) =>
!d.order.isTest &&
d.order.payStatus === 'PAID' && d.order.payStatus === 'PAID' &&
(d.order.deliveryType === 'LOCAL' || d.order.deliveryType === 'CROSS_CITY'), (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 { AuthService } from '../iam/auth.service';
import { StoreCategoryService } from './store-category.service'; import { StoreCategoryService } from './store-category.service';
import { TencentLbsProvider } from '../../integrations/map/tencent-lbs.provider'; 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 { function haversineMeters(lat1: number, lng1: number, lat2: number, lng2: number): number {
const toRad = (d: number) => (d * Math.PI) / 180; const toRad = (d: number) => (d * Math.PI) / 180;
@@ -43,10 +47,6 @@ export type StoreViewer = {
bypassWhitelist?: boolean; 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 { function parseOptionalCoord(value: unknown, kind: 'lat' | 'lng' = 'lng'): number | null {
if (value == null || value === '') return null; if (value == null || value === '') return null;
const n = typeof value === 'number' ? value : Number(value); const n = typeof value === 'number' ? value : Number(value);
@@ -67,8 +67,16 @@ export class StoreService {
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly storeCategoryService: StoreCategoryService, private readonly storeCategoryService: StoreCategoryService,
private readonly tencentLbs: TencentLbsProvider, 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: { private storeAddressText(store: {
province?: string | null; province?: string | null;
cityName?: string | null; cityName?: string | null;
@@ -116,17 +124,16 @@ export class StoreService {
} }
isVisibleToViewer( isVisibleToViewer(
store: { store: { visibilityWhitelistEnabled: boolean },
visibilityWhitelistEnabled: boolean;
visibilityPhones: Array<{ phone: string }>;
},
viewer?: StoreViewer, viewer?: StoreViewer,
whitelistPhones?: Set<string>,
): boolean { ): boolean {
if (viewer?.bypassWhitelist) return true; if (viewer?.bypassWhitelist) return true;
if (!store.visibilityWhitelistEnabled) return true; if (!store.visibilityWhitelistEnabled) return true;
const phone = normalizePhone(viewer?.phone); const phone = normalizeTestPhone(viewer?.phone);
if (!phone) return false; if (!phone) return false;
return store.visibilityPhones.some((row) => normalizePhone(row.phone) === phone); if (whitelistPhones) return whitelistPhones.has(phone);
return false;
} }
async listOpenStores( async listOpenStores(
@@ -145,12 +152,14 @@ export class StoreService {
include: { include: {
category: true, category: true,
coverResource: true, coverResource: true,
visibilityPhones: { select: { phone: true } },
}, },
orderBy: { createdAt: 'desc' }, 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 = const hasUser =
userLat != null && userLat != null &&
@@ -167,7 +176,7 @@ export class StoreService {
const items: StoreListItem[] = []; const items: StoreListItem[] = [];
for (const store of visible) { for (const store of visible) {
const coords = await this.ensureStoreCoordinates(store); const coords = await this.ensureStoreCoordinates(store);
const { visibilityPhones: _phones, visibilityWhitelistEnabled: _wl, ...rest } = store; const { visibilityWhitelistEnabled: _wl, ...rest } = store;
const mapped = mapStoreCompat({ const mapped = mapStoreCompat({
...rest, ...rest,
latitude: coords?.latitude ?? store.latitude, latitude: coords?.latitude ?? store.latitude,
@@ -197,10 +206,12 @@ export class StoreService {
include: { include: {
category: true, category: true,
coverResource: 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('门店不存在'); throw new NotFoundException('门店不存在');
} }
const coords = await this.ensureStoreCoordinates(store); const coords = await this.ensureStoreCoordinates(store);
@@ -212,7 +223,7 @@ export class StoreService {
where: { storeId: id }, where: { storeId: id },
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }], orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
}); });
const { visibilityPhones: _phones, visibilityWhitelistEnabled: _wl, ...rest } = store; const { visibilityWhitelistEnabled: _wl, ...rest } = store;
return serializeBigInt( return serializeBigInt(
mapStoreCompat({ mapStoreCompat({
...rest, ...rest,
@@ -218,6 +218,11 @@ export class TradeService {
const promoCodeId = const promoCodeId =
attribution?.promoCode?.status === 'ACTIVE' ? attribution.promoCodeId : undefined; 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 order = await this.prisma.$transaction(async (tx) => {
const created = await tx.order.create({ const created = await tx.order.create({
data: { data: {
@@ -258,6 +263,7 @@ export class TradeService {
benefitAmount: preview.benefitAmount, benefitAmount: preview.benefitAmount,
payExpireAt, payExpireAt,
promoCodeId, promoCodeId,
isTest: !!buyer?.isTest,
}, },
include: { product: true, imageResource: true }, include: { product: true, imageResource: true },
}); });
File diff suppressed because it is too large Load Diff
+28 -422
View File
@@ -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 | | `MOCK_SMS` | 验证码 999888 |
| §1 | preV1 范围:六条裁剪规则 | | `MOCK_PAY` | 支付同步成功 |
| §2 | 三端 H5 与仓库结构 | | `MOCK_DELIVERY` | 配送 Mock 推进 |
| §3 | 认证 Mock(无微信) |
| §4 | 支付 Mock |
| §5 | 配送与订单状态 Mock |
| §6 | 无总部端:能力替代 |
| §7 | preV1 功能清单 |
| §8 | Feature Flag 与 V2 切换 |
| §9 | preV1 里程碑与任务卡 |
| §10 | API / 数据行为差异速查 |
--- ## 启动验证
# §0、版本关系
| 维度 | preV1(本文) | V2(完整版) |
|------|---------------|--------------|
| 定位 | 三端 H5 联调,跑通购酒→发券→核销主链路 | 四端小程序/H5 + 真实微信/物流/支付 |
| 客户端 | **用户 / 门店 / 合伙人 均为 H5** | C端+合伙人+总部小程序,门店 H5 |
| 总部 HQ | **无独立端** | `pages/hq/` + AdminAuth |
| 登录 | **仅手机号 + 固定验证码** | 短信 + 微信授权/绑定 |
| 支付 | **Mock 即时成功** | 微信 JSAPI + 回调 |
| 配送 | **Mock 状态推进** | 小飞侠/物流回调 |
| 数据库 | **同 V2 v3.128 表)** | 同左 |
| 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端 H5Taro 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 模式不触发)。
- 退款微信 APIpreV1 **整模块可跳过**(§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 条供未来 V2preV1 无 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 ```bash
# preV1 Mock 开关(true = Mock 模式) pnpm dev:api && pnpm dev:user && pnpm dev:shop && pnpm dev:partner
MOCK_SMS=true node scripts/smoke-prev1.mjs
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
``` ```
### 8.2 切换检查表(preV1 → V2 ## 与 V2 差异速查
| 步骤 | 动作 | | | preV1 | V2/V3 |
|------|------| |----|-------|-------|
| 1 | `MOCK_PAY=false`,配置商户号,启用 `/callbacks/wechat/pay` | | 客户端 | 三 H5 | 小程序 + H5 |
| 2 | `MOCK_SMS=false`,接入短信,`log_third_party(SMS)` | | 总部 | 无 UI | admin-web |
| 3 | `MOCK_DELIVERY_AUTO=false`,对接小飞侠/物流回调 | | 微信 | 无 | JSAPI/OAuth |
| 4 | `AUTO_APPROVE_STORE=false`,上线 `apps/mini-hq` + 门店审核 | | 表结构 | v3.1 同左 | 同左 |
| 5 | 新增 `apps/mini-user``apps/mini-partner``X-Client-App` 改 MINI |
| 6 | `WECHAT_AUTH_ENABLED=true`,实现 wechat 登录/bind-phone |
| 7 | 启用退款、结算 Job、推广码 HQ 页面 |
### 8.3 代码组织(避免 Mock 散落) 升级:逐项关 Mock → 接真实集成 → 补 admin-web / mini-user。
```
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)。*
+2 -168
View File
@@ -1,169 +1,3 @@
# 杜康好客 · V3 编码手册(交付业务版) # 杜康好客 · v2.1 编码手册
> **版本定位**:V3 是基于当前数据库 v3.1 的完整交付版本,目标不是 Mock 联调,而是把「购酒 → 发券 → 到店核销 → 门店打款 → 合伙人结算 → 总部运营」全流程跑通。 > **已合并**。内容与 [`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md) 重复,请以 **v3 编码手册** 为准。本文件保留作历史链接占位。
> **对照文件**: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 后端不变量
- 核销码只存在 RedisTTL = 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 交付范围;如必须加入,先更新本手册与任务表负责人。
+90 -512
View File
@@ -1,571 +1,149 @@
# 杜康好客 · V3.0 产品需求文档(PRD # 杜康好客 · V3.0 PRD
> **版本**v3.0(源自产品 PRD v1.32026-07-10 > **v3.0**2026-07-10)· 产品事实源 · 冲突时 **V3 > V2**
> **作者**:吕丹(产品)· 工程对齐:jacy-dukang > 实现:[`v3编码手册`](./杜康好客-v3编码手册.md) · 审计:[`v3-现状对照`](./杜康好客-v3-现状对照.md)
> **状态**:本期交付事实源(与 V2 蓝图冲突时 **V3.0 优先**
> **关联**[`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md)(实现与验收口径)· [`AGENTS.md`](./AGENTS.md)AI 入口)
--- ## 0. 说明
## 0. 文档说明 | 项 | 内容 |
|----|------|
| 四端 | C 小程序 · 门店/合伙人 H5 · 总部 **WebAdmin**(工程口径,非 H5 |
| 试点 | 郑州 · 同城小飞侠 · 跨城总部物流到付 |
| 工程差异 | C 端现 `mini-user`/h5-user;核销 TTL **3min**;订单 Tab **三态** |
| 字段 | 内容 | ### 版本变更索引
|------|------|
| 项目名称 | 杜康好客 — 四端系统 |
| 覆盖端 | 用户端(**微信小程序**)、门店端(H5)、城市合伙人端(H5)、总部端(H5) |
| 试点城市 | 郑州 |
| 配送 | 同城小飞侠;跨城总部物流到付 |
| 主销场景 | 品鉴会 + 小程序 |
### 0.1 与仓库现状的差异(实现迁移注记) | 版 | 日期 | 要点 | 开发文档 |
|----|------|------|----------|
| PRD v3.0 目标 | 当前 preV1/V3 代码现状 | 处理 | | 3.4.10 | 08-03 | 门店套餐 | [`门店套餐`](./杜康好客-门店套餐功能开发文档-v3.4.10.md) |
|---------------|------------------------|------| | 3.4.11 | 08-04 | 开发计划 + 企微机器人/消息推送 | [`开发计划`](./杜康好客-开发计划功能开发文档-v3.4.11.md) |
| C 端微信小程序 | `apps/h5-user`(H5) | V3.0 以小程序为交付形态;H5 可作联调过渡 | | 3.4.12 | 08-04 | 退款回滚/财务/批量任务·工单/套餐 imageUrl | [`v3.4.12`](./杜康好客-v3.4.12-工单迭代开发文档.md) |
| 总部 H5 | `apps/admin-web`WebAdmin | V3.0 功能对齐总部 H5 职责;形态可暂用 admin-web | | 3.4.13 | 08-05 | metrics/核销详情/版本联动 PUBLISHED/mini 体验/H5 OAuth | [`v3.4.13`](./杜康好客-v3.4.13-体验优化开发文档.md) |
| 核销码 TTL 3 分钟 | 编码手册写 5 分钟 | **以本 PRD 3 分钟为准** | | 3.4.14 | 08-06 | mini-user 门头/套餐详情;小程序可配置;**统一测试白名单(不计账+限测可见+Mock旁路)** | [`v3.4.14`](./杜康好客-v3.4.14-mini-user门店体验开发文档.md) |
| 订单 Tab 三态 | preV1 五 Tab | **以本 PRD 待付款/已付款/已完成为准** | | 3.4.15 | 08-06 | mini-user 门店列表卡片:去核销改箭头、地址/距离/营业时间重排、营业中角标 | [`v3.4.15`](./杜康好客-v3.4.15-mini-user门店列表优化.md) |
### 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) |
--- ---
## 1. 背景与目标 ## 1. 背景与目标
### 1.1 商业模型 **模型**:总部供酒 → 合伙人拓店 → 门店核销 1:1 好客权益(酒+餐)。
总部供酒 → 城市合伙人分销拓店 → 门店核销好客权益 → C 端买酒送 **1:1 等价好客权益**,覆盖「酒 + 餐」。 | 锚点 | 值 |
|------|-----|
| 酒水成本 | ~售价 3 折 |
| 门店结算 | 核销额 × **60%** |
| 合伙人佣金池 | 订单+核销 ≤ 订单额 **5%**(默认 **0%+3%** |
| 权益 | 实付 **1:1**,永久;口径 `benefit_amount ?? price` |
**成本与分润锚点** **G1~G5**:四端闭环 · 购酒履约 · 权益核销结算 · 合伙人拓店对账 · 售后/发票/代下单/推广/评价。
- 酒水成本约售价 **3 折** **门禁 S1~S4**:下单+权益(W1) · 核销(W1) · 拓店(W1) · 门店提现(W2)。
- 门店结算 = 核销金额 × **60%**
- 单合伙人佣金池:订单佣金比例 + 核销佣金比例 ≤ 订单金额 **5%**(默认 **0% + 3%**
- 权益:支付成功赠送订单实付金额 **1:1**,永久有效
### 1.2 本期目标 G1G5
| ID | 目标 |
|----|------|
| G1 | 四端上线并可独立闭环运营 |
| G2 | 购酒履约(同城/跨城/现场提货)跑通 |
| G3 | 好客权益发放 → 门店核销 → 门店 6 折结算 + 手动提现跑通 |
| G4 | 合伙人拓店审核、多合伙人管辖、佣金配置、独立对账打款 |
| G5 | 售后工单、发票、代下单、问卷、门店评价、推广码归因本期交付 |
### 1.3 成功标准
**核心(门禁)**
| ID | 标准 | 波次 |
|----|------|------|
| S1 | 用户可下单(含支付与权益发放) | Wave 1(7.10 |
| S2 | 门店可核销 | Wave 17.10 |
| S3 | 城市合伙人可添加门店 | Wave 1(7.10 |
| S4 | 门店佣金(结算款)可手动提现 | Wave 2(7.15 |
**链路验收**
| ID | 标准 |
|----|------|
| S5 | 郑州用户:浏览→下单支付→履约/提货→权益到账→门店核销 |
| S6 | 跨城推总部物流;现场提货提交即完成 |
| S7 | 门店 T+1 按核销额 6 折出账;各合伙人按配置比例独立对账 |
| S8 | 工单四类型总部可审;发票可申请可回传 |
| S9 | 推广码归因合伙人;订单佣金按履约规则解析(跨城归总部) |
--- ---
## 2. 角色与场景 ## 2. 角色与场景
### 2.1 角色 | 角色 | 端 | 诉求 |
|------|-----|------|
| C 用户 | 小程序 | 买酒、权益、核销、售后 |
| 门店/店员 | H5 | 核销、营业、提现(W2) |
| 合伙人 | H5 | 拓店、订单、账单 |
| 总部 | WebAdmin | 审核、结算、运营 |
| 角色 | 端 | 核心诉求 | **SC-01~09**:同城购酒 · 现场提货 · 跨城 · 核销 · 拓店 · 售后 · 代下单 · 问卷评价 · 推广归因(见 PRD 原文路径摘要)。
|------|-----|----------|
| C 端用户 | 小程序 | 买酒、收权益、门店核销、售后/发票 |
| 门店主账号 | H5 | 核销、子账号管理、营业状态、结算提现 |
| 门店店员子账号 | H5 | 仅核销 + 本店记录(Wave 2) |
| 合伙人管理员 | H5 | 子账号、管辖范围经营数据、代下单等 |
| 合伙人推广员 | H5 | 仅门店入驻 + 看自己提交的店 |
| 总部运营/客服/财务 | H5 | 商品、审核、工单、发票、结算打款、报表 |
### 2.2 主场景 SC-01 ~ SC-09 **权限**:合伙人平级隔离;推广员仅拓店+看自己店;手机号脱敏(门店/合伙人中间4位)。
| 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 位加密;总部仅管理员看全号
--- ---
## 3. 业务闭环与核心规则 ## 3. 核心规则
### 3.1 五条闭环 ### 3.1 五条闭环
| 闭环 | 触发 | 关键节点 | 终点 | 购酒履约 · 权益核销(60%+核销佣金) · 拓店入驻 · 售后工单 · 结算提现(T+1 出账+未出账可提)。
|------|------|----------|------|
| 购酒履约 | 用户下单 | 同城小飞侠/跨城总部物流/现场提货 | 已完成 + 权益 1:1 |
| 权益核销 | 用户出码/报号 | 门店确认 | 权益扣减 + 门店账本×60% + 核销佣金释放 |
| 拓店入驻 | 合伙人录入 | 总部审核 + 试核销 | 营业中 C 端可见 |
| 售后工单 | 用户发起 | 总部审 + 协同 | 补发/退款或驳回 |
| 结算提现 | 核销入账 | T+1 出账 + 未出账可提 | 总部审后打款 |
### 3.2 订单状态机 ### 3.2 订单
``` `待付款 → 已付款 → 已完成`;30min 未付取消。同城自动推小飞侠(有仓+API);**≥10箱(60瓶)** 大单待 HQ 确认。跨城总部物流到付。现场提货支付即完成。
待付款 ──支付成功──► 已付款 ──履约完成/确认收货/24h自动──► 已完成
└─ 30 分钟未付取消
```
- **同城**:仓配履约——有仓且绑 API 承运商则自动推单(首期小飞侠);有仓选自管则管仓方手工填单;**无仓**则总部传统快递填单
- **大单拦截**:同城订单 **≥10 箱(箱规 6 瓶,即 ≥60 瓶)** 不自动推小飞侠;订单打标「大单待确认」,由总部确认后推小飞侠或自配送(填快递单)
- **跨城**:总部传统快递到付填单;订单佣金归总部
- **现场提货**:支付后直接已完成;有现场推广码则订单佣金归码所属合伙人,无码归总部
### 3.3 佣金与结算 ### 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》S1S12。
**三步录入**
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 | | 订单佣金 | 配置(0%) | 支付快照 |
| 价格 | 元 | 198 | | 核销佣金 | 配置(3%) | 核销时按门店归属合伙人 |
| 菜品 | 文本 | 红烧肉、红烧鱼、油焖茄子 | | 门店结算 | 60% | T+1;未出账可提现 |
| 使用时间 | 文本 | 节假日除外 |
| 其他说明 | 文本 | 不可叠加 |
- 可不填;可多条;**同一门店最多 10 条** **多合伙人**:全城/区域管辖;同城订单佣金按收货区县解析;核销佣金归拓店合伙人。
- C 端仅展示**已审核通过(生效中)**的套餐,自上而下标题+内容形式 **FIN-001~003**:白名单未出账提现 · 单店日限 ¥5000 · T+0 审完预警。
合伙人 **T+30** 独立月账/打款。
#### 3.9.2 维护与审核 ### 3.4~3.8 Wave 能力
| 端 | 能力 | 审核 | - **3.4** 门店一号多店、主账号、店员子账号(W2)
|----|------|------| - **3.5** 拓店三步 + SOP + 试核销100
| 合伙人拓店 | 新建流程增加「套餐」页(可跳过) | 随门店或独立提审(见开发文档) | - **3.6** 工单四类型:仅退款/破损补发/破损退货/退货退款
| 合伙人门店详情 | 套餐列表 → 编辑 → **提交审核** | 是 | - **3.7** 多仓、承运商、物流月结对账(小飞侠:2瓶6元 +2/瓶,6瓶箱14元)
| 门店端 | 套餐列表 → 编辑 → **提交审核** | 是 | - **3.8** 弱网5次拍照兜底(W3)
| 总部 | 门店详情新增「套餐」Tab | **直接保存生效,无需审核** |
- 合伙/门店提交后 → 总部收到套餐变更审核 → **通过/驳回** ### 3.9~3.11 增量(详开发文档)
- **通过**:自动替换该店生效套餐;**驳回**:保留上一版生效套餐
- 提审中:C 端仍展示上一版已通过套餐(从未通过则不展示)
#### 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 | 开城/审核/结算/推广/套餐/开发计划 |
| 子模块 | 要点 | **SKU 锚价**128/168/298/498(瓶)· 768/1008/1788/2988(箱6瓶)。
|--------|------|
| 版本 | CRUD;多选关联任务;状态流转自动写时间戳/用时 |
| 任务 | 全局任务池 CRUD;勾选 → **评审派发** → 任务派发助手 Webhook@ 开发者 userid |
| 设置 | 任务派发助手(单例);本地审核 AI 助手 |
| 技术支持 | 单一「审批」入口;通过须 ≥1 开发任务;批量 AI 预审 → 人工确认 |
企微接入:开发计划任务派发、运营告警、技术支持工单通知均走 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 智能机器人(长连接) ---
| 角色 | 默认能力 | ## 6. 数据与集成
|------|----------|
| **客服助手** | 订单/配送/门店/核销/售后工单只读 + 创建售后工单 + 短信验证查用户 |
| **财务助手** | 门店/合伙人/酒厂/物流账单、打款、提现 **只读** |
| **运营助手** | 订单/门店/用户/核销/配送 **只读** |
| **技术支持** | 技术支持工单只读/创建;**审批**(通过/驳回);开发计划只读 |
| **自定义** | HQ 手工勾选模块化权限 |
**权限模型**:按模块划分(如 `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 审批)。 | TECH | 系统 |
**审计**:每条查询/审批写入 `log_wecom_bot`Admin 可筛选 bot、userid、action、时间。
#### 3.11.2 消息推送(Webhook 多实例,v3.4.11
| 字段 | 说明 |
|------|------| |------|------|
| 名称 / 头像 | HQ 列表展示 | | 011~012 | 微信支付/短信 |
| Webhook URL | 企微群机器人 Webhook**运行时仅读 DB,不读 .env** | | 013 | 小飞侠 |
| 启用 | 列表开关 | | 014~016 | 微信能力/分享/腾讯位置 |
| @ userid | 可选,markdown `<@userid>` |
| 推送条件 | 多选 eventKey,可同时匹配多条推送 |
**推送条件(eventKey** **金额**:权益=实付1:1 · 门店结算=核销×60% · 试核销=**100元** · 码TTL=**3min**。
| 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。
--- ---
## 4. 功能需求(REQ 索引) ## 7~8. UI 与 ACC
> 完整 REQ 结论见 [`.cursor/skills/dukang-v3/reference-req-index.md`](./.cursor/skills/dukang-v3/reference-req-index.md) 主色杜康红 `#8B1E1E` · 权益金 `#C4A35A` · 空态人话+下一步。
ACC 全量:`.cursor/skills/dukang-v3/reference-acc.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瓶) |
|------|------|-----------|
| 酒祖杜康(国标特级1053° | ¥128 | ¥768 |
| 酒祖杜康(国标特级1553° | ¥168 | ¥1008 |
| 酒祖杜康(国标特级2053° | ¥298 | ¥1788 |
| 酒祖杜康(国标特级3053° | ¥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** | 看生效套餐 | 编辑提审 | 编辑提审 | ●直存/●审核 |
--- ---
## 5. 非功能需求 ## 9. 三波交付
| ID | 类别 | 结论 | | | 日期 | 门禁 | 含 | 不含 |
|----|------|------| |----|------|------|-----|------|
| NFR-001 | 登录态 | 四端 7 天免登 | | W1 | 7.10 | S1~S4 | 登录商品支付核销拓店子账号 | 提现推广现场跨城工单发票 |
| NFR-002 | 支付 | 仅微信支付;待付款锁单 30 分钟 | | W2 | 7.15 | S1~S5 | 提现FIN推广现场门店子账号多合伙 | 代下单弱网多仓跨城发票问卷 |
| NFR-003 | 短信 | 登录/核销验证码 3 分钟;核销成功必发短信 | | W3 | 7.22 | 全量 | 代下单弱网多仓跨城工单发票看板 | — |
| NFR-004 | 安全 | 按端/角色脱敏与鉴权 |
| NFR-005 | 幂等 | 支付回调、核销、提现、补核销 |
| NFR-006 | 可用性 | Wave 1~2 弱网重试;Wave 3 拍照兜底 |
| NFR-007 | 兼容 | 小程序两主版本;H5 微信内置浏览器 |
| NFR-008 | 审计 | 工单、审核、打款、分账配置留痕 |
| NFR-009 | 履约 | 同城目标 24h;送达未确认 24h 自动完成 |
| NFR-010 | 归因 | 推广码与佣金归属下单时落库 |
--- DLV 任务卡:`.cursor/skills/dukang-task-card/`
## 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 ReviewPrisma 迁移 jacy-dukang 主 Reviewstore/redeem 需刘景尧)
+22 -70
View File
@@ -1,86 +1,38 @@
# 杜康好客 · V3 埋点规范 # 杜康好客 · V3 埋点规范
> **版本**2026-08-03 > 存储:C→`log_user_analytics` · 门店→`log_store_analytics` · 合伙人→`log_partner_analytics`
> **存储**C 端 → `log_user_analytics`;门店 → `log_store_analytics`;合伙人 → `log_partner_analytics` > 契约:`packages/shared-types/src/*-log.ts` · HQ`/logs/users|stores|partners`
> **契约**[`packages/shared-types/src/*-log.ts`](../packages/shared-types/src/)
> **查询**admin-web `/logs/users` · `/logs/stores` · `/logs/partners`
## 原则 ## 原则
1. 端侧 **page_view / 点击** 走客户端 `track()`;业务结果(支付成功、核销确认)走后端 `AnalyticsService.track*Safe` 1. 端侧 page_view/点击 → `track()`;支付/核销结果 → 后端 `AnalyticsService.track*Safe`
2. 所有客户端事件携带 `sessionId`localStorage `dukang_session_id`,支持匿名漏斗 2. 携带 `sessionId``dukang_session_id`
3. `extraJson` 不含密码/令牌;手机号脱敏 3. `extraJson` 密码/令牌;手机号脱敏
4. eventName 必须先登记在 shared-types taxonomy再 emit 4. eventName 先登记 taxonomy 再 emit
## API ## API
| 端 | 路由 | 鉴权 | | 端 | 路由 | 鉴权 |
|----|------|------| |----|------|------|
| C | `POST /api/v1/analytics/events` | OptionalJwt | | C | `POST /analytics/events` | OptionalJwt |
| 门店 | `POST /api/v1/analytics/store-events` | Jwt + STORE | | 门店 | `POST /analytics/store-events` | Jwt+STORE |
| 合伙人 | `POST /api/v1/analytics/partner-events` | Jwt + PARTNER | | 合伙人 | `POST /analytics/partner-events` | Jwt+PARTNER |
| 推广 | `POST /api/v1/promo/touch` | OptionalJwt(双写 `promo_touch` 埋点 | | 推广 | `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 ## extraJson 要点
- [`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 字段约定 | 端 | 常用字段 |
|----|----------|
| C | sessionId, cityCode, productId, storeId, orderId, amount, pagePath, failReason |
| 门店 | redeemChannel, amount, durationMs, failReason |
| 合伙人 | storeId, orderId, billId, cityCode |
### C 端(UserAnalyticsExtra ## 漏斗(摘要
| 字段 | 说明 | - **C**session→home→detail→confirm→submit→pay→redeem
|------|------| - **门店**home→scan→preview→confirm
| sessionId | 会话 ID,漏斗串联 | - **合伙人**home→store_create→audit / orders→ship / bills→confirm
| 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
```
+23 -110
View File
@@ -1,124 +1,37 @@
# 杜康好客 · V3 城市 / 仓库 / 日志架构 # 杜康好客 · 城市 / 仓库 / 日志架构
> **版本**2026-07-12 > **2026-07-12** P1 多合伙 + P2 仓库 ✅ · 合伙人管仓日志 ⏳ Wave 3
> **状态**P1 城市多合伙 + P2 仓库 **已实现**(2026-07-12);合伙人端管仓日志仍待 Wave 3
> **分工**:刘景尧任务暂由 `jacy-dukang` 代管(见 `AGENTS.md`
--- ## 数据模型(摘要)
## 1. 目标数据模型(城市顶层)
``` ```
CommonCity (开城) CommonCity → PartnerAccount(主) → Store(partnerAccountId, settlementRate)
├── PartnerAccount[] 1:N 主账号(isPrimary=1,含城市绑定 + 购酒/核销佣金) → CityWarehouse(HQ|PARTNER 管仓)
├── CityWarehouse[] 1:N 城市仓库(HQ 管 / 合伙人管) → CatalogProduct
└── CatalogProduct[] 按城市上架 PartnerAccount → 子账号(parentAccountId, permissions JSON)
PartnerAccount (主账号 = 城市合伙人主体)
├── cityId / scopeType / districtCodes / bindingStatus
├── orderCommissionRate / redeemCommissionRate
├── companyName / 银行 / 合同等主体信息
├── managedWarehouseId? 可选管仓
└── PartnerAccount[] 子账号(parentAccountIdpermissions JSON
CityWarehouse
├── cityId
├── managerType: HQ | PARTNER
└── partnerAccountId? 合伙人管仓时绑定主账号
Store
├── partnerAccountId FK → 主账号
└── settlementRate 门店核销结算比例(默认 0.60)
``` ```
**迁移要点(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 action(新业务)
|----------|--------|----------------------|----------|
| **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 侧 **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 常量 | 装饰器状态 | HQ 写接口有 `@HqOperation`;合伙人写走 `trackPartnerOneSafe`;extraJson 脱敏;不跨模块直写他人日志表。
|------|---------|-------------|------------|
| 开城城市 | `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` | 合伙人日志 |
+51 -340
View File
@@ -1,359 +1,70 @@
# 杜康好客 · V3.0 现状对照 # 杜康好客 · V3.0 现状对照
> **对照基准**:仅 [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md)V3.0 产品事实源 > 基准:仅 [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md) · 总部 = **`admin-web`**(非 H5
> **审计日期**2026-07-11 > 图例:✅ 完成 · 🔶 部分 · ❌ 未做 · ⚠️ 曾冲突已修
> **说明**V2 / preV1 手册**不再作为需求依据**;本表只回答「相对 V3.0 PRD,当前代码处于什么状态」。
> **工程口径**:总部端 **保持 `apps/admin-web`WebAdmin**,不改为 H5;PRD 中「总部 H5」按 **REQ-H 功能在 admin-web 实现** 验收。
> **图例**:✅ 已完成 · 🔶 部分完成 · ⚠️ 与 V3.0 冲突(需改造) · ❌ 未实现
--- ## 0. 总览(2026-08-06
## 0. 总览
| 维度 | 结论 | | 维度 | 结论 |
|------|------| |------|------|
| **整体完成度(粗估)** | Wave 1 约 **45%**Wave 2 约 **10%**Wave 3 约 **5%** | | 主链路 | 登录→下单支付→权益→扫码核销→payout→HQ 打款 **可跑通** |
| **可跑通的主链路** | 登录 → 浏览商品 → 同城下单 Mock/微信支付 → 权益 1:1 → **扫码核销** → T+1 门店 payout 记录 → 总部确认打款 | | C 端 | `mini-user` 小程序 + h5-userv3.4.13 版本门控/门店/物流已上 |
| **最大阻塞** | C 端小程序迁移(暂缓)、Wave 1 缺口(手机号核销/规则弹窗/试核销/自动推小飞侠) | | 近期版本 | v3.4.10 套餐 · v3.4.11 开发计划/企微 · v3.4.12 工单迭代 · v3.4.13 体验优化 **已发生产** |
| **P0 冲突** | C2~C7、C14 **已修复**2026-07-12 | | 冒烟 | `scripts/smoke-v3.mjs` 窄路径 ≠ 全量 ACC |
| **冒烟覆盖** | `scripts/smoke-v3.mjs` 仅覆盖窄 W1 切片,**不等于** V3.0 ACC 全量 | | REQ 明细 | PRD §4 + `.cursor/skills/dukang-v3/reference-req-index.md` |
### Wave 门禁状态 ## 1. P0 冲突(2026-07-12 ✅)
| 门禁 | 状态 | 一句话 | | # | | 状态 |
|------|------|--------| |---|-----|------|
| **W1-S1** 下单+支付+权益 | 🔶 | TTL/Tab/SKU/佣金已对齐;仍缺小程序形态 | | C2~C3 | 核销/短信 TTL **3min** | ✅ |
| **W1-S2** 门店核销双通道 | 🔶 | 扫码 ✅;手机号核销 ❌ | | C4~C5 | 订单 Tab/列表 **三态** | ✅ |
| **W1-S3** 合伙人子账号 | ✅ | CRUD + 推广员菜单裁剪已有 | | C6~C7 | 4 SKU + 佣金 **0%+3%** | ✅ |
| **W1-S4** 拓店→审核→C 端可见 | 🔶 | 三步录入有;试核销100/负责人复核/SOP 缺失 | | C13 | 多合伙人主账号模型 | ✅ |
| **W2-S1~S5** | ❌ | 提现/FIN/现场提货/多合伙人/门店子账号 基本未做 | | C14 | 文档旧口径清理 | ✅ |
| **W3-S1~S4** | ❌ | 代下单/弱网拍照/多仓/跨城履约/四类型工单/发票/问卷/热力图 基本未做 | | 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 工程决策 | ## 3. 场景 SC-01~09
|----|----------|-------------|
| 总部端形态 | 总部 H5 | **`apps/admin-web`(WebAdmin)为唯一交付端,不改为 H5** |
| 验收方式 | REQ-H-* | 功能在 admin-web 实现即可;不要求微信内 H5 或小程序总部端 |
| `mini-hq` | — | 非主交付;与 admin-web 重叠能力以 **admin-web 补齐** 为准 |
--- | 场景 | 状态 |
## 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` | | SC-01 同城 | 🔶 支付权益✅;自动推单/24h 完成 部分 |
| 规则 | `packages/domain/src/index.ts``index.test.ts` | | SC-02 现场提货 | 🔶 |
| 短信 | `server/.../sms/sms-code.store.ts` | | SC-03 跨城 | 🔶 |
| 佣金 | `seed-v31.ts``admin-cities.service.ts``settlement.service.ts` | | SC-04 核销 | 🔶 扫码✅;手机号通道❌ |
| C 端 | `OrderListPage.tsx``MinePage.tsx``PayPage.tsx``RedeemPage.tsx``RedeemCodePage.tsx` | | SC-05 拓店 | 🔶 三步✅;试核销/SOP 部分 |
| 文档 | `apps/AGENTS.md``.cursor/rules/*``.cursor/agents/owner-d-shop-redeem.md` | | 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 schema28+ 模型) | `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 自动完成 ❌ | | 3.4.10 | [`门店套餐`](./杜康好客-门店套餐功能开发文档-v3.4.10.md) | ✅ |
| SC-02 现场提货 | ❌ | 全链路未做 | | 3.4.11 | [`开发计划`](./杜康好客-开发计划功能开发文档-v3.4.11.md) | ✅ |
| SC-03 跨城购酒 | 🔶 | 起购校验有;总部物流到付履约 ❌ | | 3.4.12 | [`工单迭代`](./杜康好客-v3.4.12-工单迭代开发文档.md) | ✅ |
| SC-04 门店核销 | 🔶 | 扫码 ✅;手机号 ❌;短信通知 ❌ | | 3.4.13 | [`体验优化`](./杜康好客-v3.4.13-体验优化开发文档.md) | ✅ 生产 |
| SC-05 拓店入驻 | 🔶 | 三步录入 ✅;复核/试核销100/SOP ❌ | | 3.4.14 | [`mini-user 门店体验 + 小程序可配置 + 测试白名单`](./杜康好客-v3.4.14-mini-user门店体验开发文档.md) | 🔶 开发中 |
| SC-06 售后工单 | 🔶 | 总部处理 REFUND 有;四类型 ❌ | | 3.4.15 | [`mini-user 门店列表优化`](./杜康好客-v3.4.15-mini-user门店列表优化.md) | 🔶 开发中 |
| SC-07 代下单 | ❌ | Wave 3 |
| SC-08 问卷+评价 | 🔶 | 评价 ✅;问卷 ❌ |
| SC-09 推广归因 | 🔶 | touch API 有;现场提货归码 ❌ |
--- ## 5. 变更记录
## 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. 变更记录
| 日期 | 说明 | | 日期 | 说明 |
|------|------| |------|------|
| 2026-08-05 | v3.4.13 体验优化(20 条 ST | | 2026-08-07 | v3.4.14 增补统一测试白名单(不计账 / 限测可见 / Mock 旁路 |
| 2026-08-04 | v3.4.12 工单迭代(退款/财务/C端/开发计划/技术支持/套餐 | | 2026-08-06 | v3.4.15 mini-user 门店列表卡片优化(开发中 |
| 2026-08-04 | v3.4.11 开发计划 + 企微智能机器人/消息推送 + 角色权限重构 | | 2026-08-06 | v3.4.14 mini-user 门头/套餐详情(开发中) |
| 2026-07-12 | **P0 已执行**:C2~C7、C14 代码与文档对齐;§1 改为计划+状态表 | | 2026-08-05 | v3.4.13 |
| 2026-07-12 | 明确总部端保持 WebAdmin,不改为 H5 | | 2026-08-04 | v3.4.11 / v3.4.12 |
| 2026-07-11 | 首版V3.0 PRD 全量对照当前 monorepo | | 2026-07-11 | 首版对照表 |
@@ -1,44 +1,20 @@
# 杜康好客 · v3.4.12 工单迭代开发文档 # 杜康好客 · v3.4.12 工单迭代
> 版本:**v3.4.12** · 日期:2026-08-04 > **2026-08-04** · 已上线 · PRD §0.4
> 需求源:ST 工单 + 开发计划/技术支持批量能力补充
## 1. 范围 | 域 | 交付 |
|----|------|
| 模块 | 内容 |
|------|------|
| P0 售后 | `initiateRefund` 微信失败回滚订单状态 | | P0 售后 | `initiateRefund` 微信失败回滚订单状态 |
| mini-user | 门店详情门头 preview、环境图双列 | | mini-user | 门头 preview、环境图双列 |
| 财务 | 酒厂账单 T+3;门店可多笔 pending 提现 | | 财务 | 酒厂 T+3;门店可多笔 pending 提现 |
| 开发计划 | 审批创建任务可选企微派发;任务批量改状态/关联版本 | | 开发计划 | 审批可选企微派发;`POST .../tasks/batch-update` |
| 技术支持 | 单条编辑/附件;批量改状态(状态机) | | 技术支持 | PATCH 编辑/附件;`batch-update-status` |
| 套餐 | `StorePackage.imageUrl` 四端HQ 删除至 0 条 UX | | 套餐 | `StorePackage.imageUrl` 四端 |
**不在本版**ST1785812832352437(门店列表,已完成) ## API
## 2. 后端 API `POST /admin/dev-plan/tasks/batch-update` · `PATCH /admin/support-tickets/:id` · `POST .../batch-update-status` · review 扩展 `dispatchToWecom`
| 方法 | 路径 | 说明 | ## ACC
|------|------|------|
| — | `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` | 批量改状态 |
## 3. 数据表 退款 Mock 成功 · 门头大图 · T+3/多笔提现 · 企微派发 · 批量改任务/工单状态 · 套餐 imageUrl
- `common_support_ticket.attachment_urls` JSON
- `store_package.image_url` VARCHAR(512)
## 4. 验收
- [ ] 售后 REFUND 审批后 Mock 退款成功
- [ ] mini-user 门头点击大图、环境图双列
- [ ] 酒厂账单滞后 3 天;门店第二笔提现可提交
- [ ] 审批勾选企微后开发群收到任务
- [ ] 任务多选批量改状态/关联版本
- [ ] 技术支持待评审可编辑附件;批量 TESTING→PASSED
- [ ] 套餐 imageUrl 四端展示;HQ 可删至 0 条
+48 -152
View File
@@ -1,162 +1,58 @@
# 杜康好客 · v3.4.13 体验优化开发文档 # 杜康好客 · v3.4.13 体验优化
> 版本:**v3.4.13** · 日期:2026-08-05 > **2026-08-05** · 20 ST · 已发生产 `0181af0` · PRD §0.5 · 现状对照 §10
> 需求源:20 条 ST 工单(体验优化 / Bug / 客户端验证)
## 1. 范围 ## ST 交付一览
| 模块 | 内容 | | ST | 交付要点 |
|----|----------|
| ST1785925037781309 | 推广码 attributionCount + `log_promo_event` + metrics/timeline/events + HQ ECharts |
| ST1785924286682833 | 门店电话 maskPhone + `store_phone_call` 埋点 |
| ST1785921693982470 | 工单 `priority` + HQ 筛选/编辑 |
| ST1785921585900725 | 门店 H5OAuth 单例回调 + 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 趋势**;**权益券详情加宽**;**核销记录详情增强(对齐权益券内核销详情)**;技术支持工单优先级;**版本发布联动任务/工单已发布**;**大图上传前压缩** | | `GET /common/client-config` | `minClientVersion``MINI_USER_MIN_VERSION` |
| mini-user | 门店电话脱敏+拨打埋点;**门店列表展示两段营业时间**;**门店详情门头 aspectFill 铺满无留白**;**门店套餐页签切换**;商品去分享+首图 preview;提货确认弹框;**我的页版本号 v3.4.x****低于 minClientVersion 强制更新/点我知道了退出**;**物流增强(签收照/拨号/时间线/ETA)**;**头像超 10MB 压缩** | | `GET /trade/orders/:id/track` | nodes、signPhotoUrls、estimatedArrivalCourier 100102/108/301 |
| h5-partner / h5-shop / h5-user | 登录 phone/code 前端校验;**门店端 iOS 首次扫码 OAuth 单例 + JSSDK 预热重试****OSS 图片超 10MB 自动 canvas 压缩后上传** | | `POST /callbacks/courier/xfx/track` | 小飞侠路由回调;生产 `api.dukanghaoke.com`,测试 `api-test.dukanghaoke.com` |
| 后端 | 工单 priority**工单 PUBLISHED + releasedVersionNo****版本 RELEASED 联动任务/工单**client-config minClientVersion;合伙人微信暂停禁登;**Courier 适配器****log_promo_event 推广码指标日志** | | `GET /admin/promo-codes/:id/metrics/{timeline,events}` | 推广码指标时序 + 事件分页 |
## 2. ST 映射 **表/枚举**
| ST | 标题 | 状态 | - `log_promo_event`SCAN / ATTRIBUTION / REGISTER / ORDER(含 IP/地点;仅统计上线后事件)
|----|------|------| - `common_support_ticket``priority``status``PUBLISHED``released_version_no``published_at`
| ST1785925037781309 | 总部端-推广码数据跟踪优化 | ✅ attributionCount + **事件日志 + ECharts 趋势** | - 小飞侠回调:status **5** 或 statusName 含「签收」→ 订单 **COMPLETED**(幂等);7 取消仅日志
| 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 |
## 3. 后端 API / 配置 **HQ 开发计划**
| 方法 | 路径 / 配置 | 说明 | 创建版本 `v3.4.13` 并关联 ST 任务;标记 **已发布** 时自动联动任务/工单(见上表最后一行)。
|------|-------------|------|
| 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/地点) |
### 3.1 推广码指标日志(ST1785925037781309 ## 验收抽样
**表** `log_promo_event` - [ ] 推广码四指标趋势 + 事件日志;重复 touch 不计 SCAN
- [ ] 核销/权益券详情抽屉字段完整、980px 无横滚
| event_type | 统计卡 | 写入时机 | ID | - [ ] 版本 RELEASED 后工单「已发布」带版本号;门店列表合伙人列非空
|------------|--------|----------|-----| - [ ] mini-user:版本门控、门店/商品/物流/压缩项
| `SCAN` | 扫码进入数 | `POST /promo/touch``countScan !== false` | userId / sessionId | - [ ] 门店 H5 iPhone 微信:OAuth 后续扫可用
| `ATTRIBUTION` | 归因用户数 | 首次写入 `user_promo_attribution` | userId | - [ ] 小飞侠回调 status=5 → 订单 COMPLETED
| `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 图片自动压缩后可上传;仍超限有明确报错
- [ ] **门店 H5iPhone 微信)**:首次扫码 OAuth 后自动续扫;offline verifying 时提示再点一次扫码
- [ ] `pnpm lint` 无新增错误
@@ -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