From b626db5d84ccc058060348b7c50f7fa6d12fe834 Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Fri, 7 Aug 2026 15:46:23 +0800 Subject: [PATCH] =?UTF-8?q?=EF=BB=BFfeat(ops):=20add=20global=20test=20whi?= =?UTF-8?q?telist=20and=20exclude=20test=20accounts=20from=20settlement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unify product/store visibility on HQ whitelist, mark isTest snapshots, and fix SUPER_ADMIN access for the new module. --- .cursor/rules/frontend-h5.mdc | 3 + .cursor/rules/mini-user-weapp-opentype.mdc | 46 ++ AGENTS.md | 1 + apps/AGENTS.md | 1 + apps/admin-web/src/App.tsx | 2 + apps/admin-web/src/layouts/AdminLayout.tsx | 2 + apps/admin-web/src/lib/api.ts | 2 + apps/admin-web/src/pages/CityPartnersPage.tsx | 26 +- apps/admin-web/src/pages/OrdersPage.tsx | 17 +- apps/admin-web/src/pages/ProductsPage.tsx | 85 +-- .../admin-web/src/pages/RedeemRecordsPage.tsx | 31 +- .../admin-web/src/pages/StoreAccountsPage.tsx | 25 +- apps/admin-web/src/pages/StoresPage.tsx | 120 +-- .../admin-web/src/pages/TestWhitelistPage.tsx | 687 ++++++++++++++++++ apps/admin-web/src/pages/UsersPage.tsx | 16 +- .../src/assets/icons/store-open-badge.png | Bin 3167 -> 16205 bytes apps/mini-user/src/pages/mine/index.tsx | 10 +- .../src/pages/order-confirm-pickup/index.tsx | 2 +- apps/mini-user/src/styles/mine.css | 19 +- packages/shared-types/src/hq-permissions.ts | 2 + server/dukang-api/prisma/schema.prisma | 34 + server/dukang-api/prisma/seed-v31.ts | 44 ++ server/dukang-api/src/app.module.ts | 2 + .../src/common/guards/hq-permission.guard.ts | 40 +- .../test-whitelist/test-whitelist.module.ts | 9 + .../test-whitelist/test-whitelist.service.ts | 335 +++++++++ .../src/modules/catalog/catalog.service.ts | 73 +- .../src/modules/iam/auth.service.ts | 31 + .../ops/admin-hq-permissions.service.ts | 9 +- .../src/modules/ops/admin-orders.service.ts | 1 + .../src/modules/ops/admin-partners.service.ts | 1 + .../src/modules/ops/admin-products.service.ts | 23 +- .../src/modules/ops/admin-redeem.service.ts | 1 + .../src/modules/ops/admin-stores.service.ts | 43 +- .../ops/admin-test-whitelist.controller.ts | 167 +++++ .../src/modules/ops/admin-users.service.ts | 4 + .../src/modules/ops/dto/admin-mutate.dto.ts | 18 +- .../src/modules/ops/dto/admin-query.dto.ts | 42 +- .../dukang-api/src/modules/ops/ops.module.ts | 2 + .../src/modules/redeem/redeem.service.ts | 28 +- .../modules/settlement/settlement.service.ts | 18 +- .../src/modules/store/store.service.ts | 43 +- .../src/modules/trade/trade.service.ts | 6 + 杜康好客-v3-PRD.md | 2 +- 杜康好客-v3-现状对照.md | 3 +- 杜康好客-v3.4.14-mini-user门店体验开发文档.md | 38 +- 杜康好客-知识库.md | 17 + 47 files changed, 1823 insertions(+), 308 deletions(-) create mode 100644 .cursor/rules/mini-user-weapp-opentype.mdc create mode 100644 apps/admin-web/src/pages/TestWhitelistPage.tsx create mode 100644 server/dukang-api/src/common/test-whitelist/test-whitelist.module.ts create mode 100644 server/dukang-api/src/common/test-whitelist/test-whitelist.service.ts create mode 100644 server/dukang-api/src/modules/ops/admin-test-whitelist.controller.ts diff --git a/.cursor/rules/frontend-h5.mdc b/.cursor/rules/frontend-h5.mdc index 5ddef82..4a3048a 100644 --- a/.cursor/rules/frontend-h5.mdc +++ b/.cursor/rules/frontend-h5.mdc @@ -39,6 +39,9 @@ src/ ## UI 约束 +- 使用 shared-ui CSS 变量与组件,勿各端自造设计 token +- **mini-user 微信 `openType` Button**:祖先禁止 `e.stopPropagation()`(Taro→`catchtap`,选头像/手机号等会静默失效);遮罩与 sheet 拆开绑关闭。见 `.cursor/rules/mini-user-weapp-opentype.mdc` + - C 端订单 **5 Tab**(含 pending_ship) - 门店列表仅 `OPEN` 状态 - 原型 `pages/` 只读;路由对照 `pages/ROUTE_MAP.md` diff --git a/.cursor/rules/mini-user-weapp-opentype.mdc b/.cursor/rules/mini-user-weapp-opentype.mdc new file mode 100644 index 0000000..7dc882b --- /dev/null +++ b/.cursor/rules/mini-user-weapp-opentype.mdc @@ -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 会失效 + + e.stopPropagation()}> + + + +``` + +## 正确写法 + +遮罩与内容拆开:只在 **backdrop** 上关弹层,**sheet 不要**绑 stopPropagation / catchtap。 + +```tsx +// ✅ + + + + + + +``` + +## 附加 + +- `Button` 内 `Image` / 文案加 `pointer-events: none`(或父级 `> * { pointer-events: none }`),避免抢触摸 +- 同类能力:`getPhoneNumber`、`contact`、`share` 同样忌祖先 `catchtap` +- 详情见知识库「C 端 · 踩坑 · chooseAvatar」 + +参照实现:`apps/mini-user/src/pages/mine/index.tsx` 资料弹层。 diff --git a/AGENTS.md b/AGENTS.md index b06cb03..bd34d40 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -138,6 +138,7 @@ C 端门店仅 status=OPEN ``` **iOS 微信 H5 JSSDK**:登录/OAuth 后禁止仅 SPA 跳转再调扫码;见 `packages/weixin-sdk/GOTCHAS.md`、知识库「门店端 · 踩坑」。 +**微信小程序 open-type**:`chooseAvatar` 等 Button 的祖先禁止 `stopPropagation`(会编成 catchtap);见知识库「C 端 · 踩坑」、`.cursor/rules/mini-user-weapp-opentype.mdc`。 ## 环境与发版 diff --git a/apps/AGENTS.md b/apps/AGENTS.md index 61d3ab0..a058c2a 100644 --- a/apps/AGENTS.md +++ b/apps/AGENTS.md @@ -22,6 +22,7 @@ V2 规划中的 `mini-partner` / `mini-hq` 非 V3 主交付。 - 禁止 import `server/` 或另一个 `apps/*` 的源码 - UI 共享组件优先 `@dukang/shared-ui` - C 端订单列表 **3 Tab**:`待付款 | 已付款 | 已完成`(Tab key: `pending_pay` / `paid` / `completed`) +- **mini-user 微信 open-type**:含 `chooseAvatar` / `getPhoneNumber` 等的 `Button`,祖先禁止 `stopPropagation`(会编成 `catchtap` 导致点击无反应);见知识库 C 端踩坑、`.cursor/rules/mini-user-weapp-opentype.mdc` ## 新页面 workflow diff --git a/apps/admin-web/src/App.tsx b/apps/admin-web/src/App.tsx index 5d46930..cbc81b8 100644 --- a/apps/admin-web/src/App.tsx +++ b/apps/admin-web/src/App.tsx @@ -46,6 +46,7 @@ import PartnerLogsPage from './pages/PartnerLogsPage'; import WechatBindingsPage from './pages/WechatBindingsPage'; import HqPermissionsPage from './pages/HqPermissionsPage'; import SystemSettingsPage from './pages/SystemSettingsPage'; +import TestWhitelistPage from './pages/TestWhitelistPage'; import WecomBotsPage from './pages/WecomBotsPage'; import WecomMessagePushesPage from './pages/WecomMessagePushesPage'; import WecomBotLogsPage from './pages/WecomBotLogsPage'; @@ -136,6 +137,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx index 337fecf..1cbf5e5 100644 --- a/apps/admin-web/src/layouts/AdminLayout.tsx +++ b/apps/admin-web/src/layouts/AdminLayout.tsx @@ -146,6 +146,7 @@ const MENU_ITEMS: MenuProps['items'] = [ ], }, { key: '/hq-permissions', icon: , label: '权限分配' }, + { key: '/test-whitelist', icon: , label: '白名单管理' }, { key: '/system-settings', icon: , label: '系统设置' }, { key: '/hq-accounts', icon: , label: 'HQ账户' }, ]; @@ -215,6 +216,7 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean { '/logs/third-party': 'logs', '/logs/domain-events': 'logs', '/hq-permissions': 'hq_permissions', + '/test-whitelist': 'test_whitelist', '/system-settings': 'system_settings_any', '/hq-accounts': 'hq_accounts', }; diff --git a/apps/admin-web/src/lib/api.ts b/apps/admin-web/src/lib/api.ts index 8abd79b..24711e6 100644 --- a/apps/admin-web/src/lib/api.ts +++ b/apps/admin-web/src/lib/api.ts @@ -167,6 +167,7 @@ export type AdminUserRow = { sourceLabel: string | null; createdAt: string; orderCount: number; + isTest?: boolean; }; export type AdminOrderItem = { @@ -199,6 +200,7 @@ export type AdminOrderRow = { isProxyOrder?: boolean; proxyPartnerName?: string | null; proxyPartnerPhone?: string | null; + isTest?: boolean; user?: { id: string; userNo: string; phone: string | null; nickname: string | null }; delivery?: { provider: string; diff --git a/apps/admin-web/src/pages/CityPartnersPage.tsx b/apps/admin-web/src/pages/CityPartnersPage.tsx index bcf7ab1..8c2d6ae 100644 --- a/apps/admin-web/src/pages/CityPartnersPage.tsx +++ b/apps/admin-web/src/pages/CityPartnersPage.tsx @@ -59,6 +59,7 @@ type Row = { accountCount: number; subAccounts?: SubRow[]; createdAt: string; + isTest?: boolean; }; type PartnerDetail = Row & { @@ -124,14 +125,15 @@ export default function CityPartnersPage() { const [createForm] = Form.useForm(); const [subForm] = Form.useForm(); const [subEditForm] = Form.useForm(); - const [filters, setFilters] = useState>({}); + const [filters, setFilters] = useState>({}); const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( '/admin/partners', () => { const qs = new URLSearchParams(); - if (filters.companyName) qs.set('companyName', filters.companyName); - if (filters.phone) qs.set('phone', filters.phone); - if (filters.cityId) qs.set('cityId', filters.cityId); + if (filters.companyName) qs.set('companyName', String(filters.companyName)); + if (filters.phone) qs.set('phone', String(filters.phone)); + if (filters.cityId) qs.set('cityId', String(filters.cityId)); + if (filters.excludeTest) qs.set('excludeTest', 'true'); return qs; }, [filters], @@ -270,7 +272,18 @@ export default function CityPartnersPage() { row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes), }, { title: '公司名', dataIndex: 'companyName', ellipsis: true, width: 140 }, - { title: '主账号姓名', dataIndex: 'name', width: 100, ellipsis: true }, + { + title: '主账号姓名', + dataIndex: 'name', + width: 120, + ellipsis: true, + render: (v, row) => ( + + {v} + {row.isTest ? 测试 : null} + + ), + }, { title: '登录手机', dataIndex: 'phone', width: 120 }, { title: '管辖', @@ -387,6 +400,9 @@ export default function CityPartnersPage() { + + 过滤测试账号 + diff --git a/apps/admin-web/src/pages/ProductsPage.tsx b/apps/admin-web/src/pages/ProductsPage.tsx index abe64ff..899c62f 100644 --- a/apps/admin-web/src/pages/ProductsPage.tsx +++ b/apps/admin-web/src/pages/ProductsPage.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef, useState } from 'react'; +import { useMemo, useState } from 'react'; import { Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Table, Tabs, Tag, Typography, message, @@ -67,13 +67,6 @@ type ProductFormValues = { features?: Array<{ icon?: string; title?: string; desc?: string }>; }; -type UserPickRow = { - id: string; - phone?: string | null; - nickname?: string | null; - userNo?: string; -}; - function mapDetailToForm(d: Record) { const detail = (d.detailContent ?? {}) as ProductDetailContentDto; const row = d as Row; @@ -112,10 +105,6 @@ function buildProductPayload(v: ProductFormValues) { features: features.length ? features : undefined, }; - const visibilityPhones = (v.visibilityPhones ?? []) - .map((p) => String(p || '').replace(/\D/g, '').trim()) - .filter(Boolean); - return { barcode69: v.barcode69, name: v.name, @@ -131,7 +120,6 @@ function buildProductPayload(v: ProductFormValues) { allowCrossCityDelivery: v.allowOnlinePurchase === false ? false : v.allowCrossCityDelivery !== false, visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled, - visibilityPhones, coverUrl: v.coverUrl, carouselUrls, detailImageUrls, @@ -213,33 +201,6 @@ function ProductDetailFields({ form, aromaType }: { form: FormInstance; aromaTyp } function VisibilityWhitelistFields({ form }: { form: FormInstance }) { - const [userOptions, setUserOptions] = useState([]); - const [userSearching, setUserSearching] = useState(false); - const searchTimer = useRef | null>(null); - - async function searchUsers(keyword: string) { - const q = keyword.trim(); - if (searchTimer.current) clearTimeout(searchTimer.current); - if (!q) { - setUserOptions([]); - return; - } - searchTimer.current = setTimeout(() => { - void (async () => { - setUserSearching(true); - try { - const qs = new URLSearchParams({ page: '1', pageSize: '20', phone: q }); - const res = await request<{ items: UserPickRow[] }>(`/admin/users?${qs}`); - setUserOptions((res.items ?? []).filter((u) => !!u.phone)); - } catch { - setUserOptions([]); - } finally { - setUserSearching(false); - } - })(); - }, 300); - } - const enabled = Form.useWatch('visibilityWhitelistEnabled', form); return ( @@ -248,46 +209,14 @@ function VisibilityWhitelistFields({ form }: { form: FormInstance }) { name="visibilityWhitelistEnabled" label="可见白名单" valuePropName="checked" - extra="开启后仅名单内手机号在 C 端可见/可购,用于在线测试" + extra="开启后仅全局测试白名单内手机号在 C 端可见/可购,用于在线测试" > {enabled ? ( - <> - - ({ - 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 ? '搜索中…' : '输入手机号搜索'} - /> - - + + 可见手机号见白名单管理 + ) : null} ); @@ -419,8 +348,8 @@ export default function ProductsPage() { title: '白名单', dataIndex: 'visibilityWhitelistEnabled', width: 90, - render: (v: boolean, row) => - v ? 限{row.visibilityPhones?.length ?? 0}人 : 公开, + render: (v: boolean) => + v ? 限测 : 公开, }, { title: '履约', diff --git a/apps/admin-web/src/pages/RedeemRecordsPage.tsx b/apps/admin-web/src/pages/RedeemRecordsPage.tsx index b70a399..4522dca 100644 --- a/apps/admin-web/src/pages/RedeemRecordsPage.tsx +++ b/apps/admin-web/src/pages/RedeemRecordsPage.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { Button, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd'; +import { Button, Checkbox, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types'; import RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDescriptions'; @@ -14,6 +14,7 @@ type Row = { settleAmount: number; channel?: RedeemChannel; createdAt: string; + isTest?: boolean; user?: { userNo: string; phone: string | null; nickname?: string | null }; store?: { name: string; cityName: string }; coupon?: { couponNo: string }; @@ -26,14 +27,15 @@ function maskPhone(phone: string | null | undefined) { export default function RedeemRecordsPage() { const [form] = Form.useForm(); - const [filters, setFilters] = useState>({}); + const [filters, setFilters] = useState>({}); const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList( '/admin/redeem-records', () => { const qs = new URLSearchParams(); - if (filters.redeemNo) qs.set('redeemNo', filters.redeemNo); - if (filters.storeId) qs.set('storeId', filters.storeId); - if (filters.channel) qs.set('channel', filters.channel); + if (filters.redeemNo) qs.set('redeemNo', String(filters.redeemNo)); + if (filters.storeId) qs.set('storeId', String(filters.storeId)); + if (filters.channel) qs.set('channel', String(filters.channel)); + if (filters.excludeTest) qs.set('excludeTest', 'true'); return qs; }, [filters], @@ -53,7 +55,21 @@ export default function RedeemRecordsPage() { } const columns: ColumnsType = [ - { title: '核销号', dataIndex: 'redeemNo', width: 180 }, + { + title: '核销号', + dataIndex: 'redeemNo', + width: 200, + render: (v, row) => ( + + {v} + {row.isTest ? ( + + 测试 + + ) : null} + + ), + }, { title: '方式', dataIndex: 'channel', @@ -124,6 +140,9 @@ export default function RedeemRecordsPage() { ]} /> + + 过滤测试账号 + }) ); } -type UserPickRow = { id: string; phone?: string | null; nickname?: string | null; userNo?: string | null }; - function StoreVisibilityWhitelistFields({ form }: { form: FormInstance }) { - const [userOptions, setUserOptions] = useState([]); - const [userSearching, setUserSearching] = useState(false); - const searchTimer = useRef | null>(null); - - async function searchUsers(keyword: string) { - const q = keyword.trim(); - if (searchTimer.current) clearTimeout(searchTimer.current); - if (!q) { - setUserOptions([]); - return; - } - searchTimer.current = setTimeout(() => { - void (async () => { - setUserSearching(true); - try { - const qs = new URLSearchParams({ page: '1', pageSize: '20', phone: q }); - const res = await request<{ items: UserPickRow[] }>(`/admin/users?${qs}`); - setUserOptions((res.items ?? []).filter((u) => !!u.phone)); - } catch { - setUserOptions([]); - } finally { - setUserSearching(false); - } - })(); - }, 300); - } - const enabled = Form.useWatch('visibilityWhitelistEnabled', form); return ( @@ -315,46 +287,14 @@ function StoreVisibilityWhitelistFields({ form }: { form: FormInstance }) { name="visibilityWhitelistEnabled" label="可见白名单" valuePropName="checked" - extra="开启后仅名单内手机号在 C 端可见,用于在线测试" + extra="开启后仅全局测试白名单内手机号在 C 端可见,用于在线测试" > {enabled ? ( - <> - - ({ - 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 ? '搜索中…' : '输入手机号搜索'} - /> - - + + 可见手机号见白名单管理 + ) : null} ); @@ -375,6 +315,7 @@ type StoreRow = { createdAt: string; visibilityWhitelistEnabled?: boolean; visibilityPhones?: string[]; + isTest?: boolean; cityRef?: { name: string; code: string }; partner?: { id?: string; companyName?: string | null; name?: string | null; phone?: string | null }; account?: { @@ -424,8 +365,8 @@ export default function StoresPage() { const [form] = Form.useForm(); const [editForm] = Form.useForm(); const [createForm] = Form.useForm(); - const [filters, setFilters] = useState>(() => { - const init: Record = {}; + const [filters, setFilters] = useState>(() => { + const init: Record = {}; if (initialCityId) init.cityId = initialCityId; if (initialPartnerId) init.partnerId = initialPartnerId; return init; @@ -434,12 +375,13 @@ export default function StoresPage() { '/admin/stores', () => { const qs = new URLSearchParams(); - if (filters.name) qs.set('name', filters.name); - if (filters.status) qs.set('status', filters.status); - if (filters.auditStatus) qs.set('auditStatus', filters.auditStatus); - if (filters.phone) qs.set('phone', filters.phone); - if (filters.cityId) qs.set('cityId', filters.cityId); - if (filters.partnerId) qs.set('partnerId', filters.partnerId); + if (filters.name) qs.set('name', String(filters.name)); + if (filters.status) qs.set('status', String(filters.status)); + if (filters.auditStatus) qs.set('auditStatus', String(filters.auditStatus)); + if (filters.phone) qs.set('phone', String(filters.phone)); + if (filters.cityId) qs.set('cityId', String(filters.cityId)); + if (filters.partnerId) qs.set('partnerId', String(filters.partnerId)); + if (filters.excludeTest) qs.set('excludeTest', 'true'); return qs; }, [filters], @@ -597,6 +539,7 @@ export default function StoresPage() { visibilityPhones: Array.isArray(d.visibilityPhones) ? (d.visibilityPhones as string[]) : [], + isTest: !!d.isTest, }); setDrawerOpen(true); } @@ -637,9 +580,7 @@ export default function StoresPage() { bankAccountNo: v.bankAccountNo ?? null, bankBranch: v.bankBranch ?? null, visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled, - visibilityPhones: ((v.visibilityPhones as string[] | undefined) ?? []) - .map((p) => String(p || '').replace(/\D/g, '').trim()) - .filter(Boolean), + isTest: !!v.isTest, ...(hasCoords ? { latitude: Number(v.latitude), longitude: Number(v.longitude) } : {}), @@ -813,9 +754,6 @@ export default function StoresPage() { bankBranch: values.bankBranch.trim(), settlementRate: Number(values.settlementRate ?? 60) / 100, visibilityWhitelistEnabled: !!values.visibilityWhitelistEnabled, - visibilityPhones: (values.visibilityPhones ?? []) - .map((p: string) => String(p || '').replace(/\D/g, '').trim()) - .filter(Boolean), }), }); message.success('门店已创建'); @@ -839,7 +777,12 @@ export default function StoresPage() { title: '封面', dataIndex: 'coverUrl', width: 72, render: (url) => url ? : '—', }, - { title: '门店名', dataIndex: 'name', width: 140 }, + { title: '门店名', dataIndex: 'name', width: 160, render: (v, row) => ( + + {v} + {row.isTest ? 测试 : null} + + ) }, { title: '分类', width: 100, @@ -879,8 +822,8 @@ export default function StoresPage() { title: '可见', dataIndex: 'visibilityWhitelistEnabled', width: 90, - render: (v, row) => - v ? 限{row.visibilityPhones?.length ?? 0}人 : 公开, + render: (v) => + v ? 限测 : 公开, }, { title: '介绍', dataIndex: 'intro', width: 160, ellipsis: true, render: (v) => v || '—' }, { title: '店长', dataIndex: ['account', 'name'], width: 90, render: (v) => v || '—' }, @@ -939,6 +882,9 @@ export default function StoresPage() { options={Object.entries(STORE_AUDIT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} /> + + 过滤测试账号 + + + void onDeletePhone(row.id)} + > + + + + ), + }, + ]; + + function accountColumns(): ColumnsType> { + 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 ? 测试 : '—'), + }, + { 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 ? 测试 : '—'), + }, + { 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 ? 测试 : '—'), + }, + { 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 ? 测试 : '—'), + }, + { 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 ? 测试 : '—'), + }, + { title: '下单', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) }, + ]; + } + + return ( +
+ + 白名单管理 + + + + + Mock 开关(与系统设置同源,勾选 = 不做真实验证) + + + void saveMockFlags({ MOCK_SMS: e.target.checked })} + > + 短信不做真实验证 + + void saveMockFlags({ MOCK_WECHAT: e.target.checked })} + > + 微信不做真实验证 + + void saveMockFlags({ MOCK_PAY: e.target.checked })} + > + 支付不做真实验证 + + + + 配置键:{MOCK_KEYS.join(' / ')} + + + + + +
{ + setPhoneFilters({ phone: v.phone || undefined }); + setPhonePage(1); + }} + > + + + + + + + + + + + +
+
{ + setPhonePage(p); + setPhonePageSize(ps); + }, + }} + /> + + ), + }, + { + key: 'accounts', + label: '测试账号记录', + children: ( + <> + + setAccountPhone(e.target.value)} + onPressEnter={() => { + setAccountPage(1); + void loadAccounts(); + }} + /> + + +
{ + setAccountPage(p); + setAccountPageSize(ps); + }, + }} + /> + + ), + }, + ]} + /> + + setAddOpen(false)} + onOk={() => void onAddPhone()} + destroyOnClose + > +
+ + + + + + + +
+ + { + setEditOpen(false); + setEditRow(null); + }} + onOk={() => void onEditPhone()} + destroyOnClose + > +
+ + + + +
+ + setLinkedOpen(false)} + width={560} + destroyOnClose + > + {linkedLoading ? ( + 加载中… + ) : linked ? ( + + + {linked.phone.phone} + {linked.phone.note || '—'} + +
+ C 端用户({linked.users.length}) +
v || '—' }, + { + title: '测试', + dataIndex: 'isTest', + width: 70, + render: (v) => (v ? 测试 : '—'), + }, + ]} + /> + +
+ 门店账号({linked.storeAccounts.length}) +
(v ? 测试 : '—'), + }, + ]} + /> + +
+ 合伙人({linked.partners.length}) +
v || '—' }, + { + title: '测试', + dataIndex: 'isTest', + width: 70, + render: (v) => (v ? 测试 : '—'), + }, + ]} + /> + +
+ 门店({linked.stores.length}) +
(v ? 测试 : '—'), + }, + ]} + /> + + + ) : null} + + + ); +} diff --git a/apps/admin-web/src/pages/UsersPage.tsx b/apps/admin-web/src/pages/UsersPage.tsx index 9d1e458..1995a73 100644 --- a/apps/admin-web/src/pages/UsersPage.tsx +++ b/apps/admin-web/src/pages/UsersPage.tsx @@ -112,6 +112,7 @@ export default function UsersPage() { if (values.status !== undefined && values.status !== '') { qs.set('status', String(values.status)); } + if (values.excludeTest) qs.set('excludeTest', 'true'); const res = await request>(`/admin/users?${qs}`); setData(res); } finally { @@ -237,7 +238,17 @@ export default function UsersPage() { ]; const columns: ColumnsType = [ - { title: '用户编号', dataIndex: 'userNo', width: 120 }, + { + title: '用户编号', + dataIndex: 'userNo', + width: 140, + render: (v, row) => ( + + {v} + {row.isTest ? 测试 : null} + + ), + }, { title: '昵称', dataIndex: 'nickname', width: 100 }, { title: '手机', @@ -362,6 +373,9 @@ export default function UsersPage() { { value: 0, label: '停用' }, ]} /> + + 过滤测试账号 + diff --git a/apps/mini-user/src/assets/icons/store-open-badge.png b/apps/mini-user/src/assets/icons/store-open-badge.png index b168b142eead5a8709085e9c5424059a6baee59e..79eabd6404edf3c80d3c81da47418bea3461fded 100644 GIT binary patch literal 16205 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 : null} {profileSheetOpen ? ( - !savingProfile && setProfileSheetOpen(false)}> - e.stopPropagation()}> + + {/* 遮罩单独绑 tap,勿在含 chooseAvatar 的祖先上用 stopPropagation(会编译成 catchtap 导致选头像无反应) */} + !savingProfile && setProfileSheetOpen(false)} + /> + 完善头像与昵称 点击头像选择,并填写昵称后保存(用于个人中心展示) @@ -534,6 +539,7 @@ export default function MinePage() { className="mine-profile-avatar-btn" openType="chooseAvatar" hoverClass="none" + plain onChooseAvatar={onChooseAvatar} > diff --git a/apps/mini-user/src/pages/order-confirm-pickup/index.tsx b/apps/mini-user/src/pages/order-confirm-pickup/index.tsx index 74fbd5f..5181df9 100644 --- a/apps/mini-user/src/pages/order-confirm-pickup/index.tsx +++ b/apps/mini-user/src/pages/order-confirm-pickup/index.tsx @@ -143,7 +143,7 @@ export default function OrderConfirmPickupPage() { const { confirm } = await Taro.showModal({ title: '确认提交订单', - content: `确认提交现场提货订单?共 ${quantity} 瓶,应付 ¥${Number(preview?.payAmount ?? 0).toFixed(2)}。`, + content: `请确保您已拿到货品,货款将直接打给商家,如不是现场交易请选择立即购买方式下单,我们会为您安排配送到家。`, confirmText: '确认提交', cancelText: '再想想', }); diff --git a/apps/mini-user/src/styles/mine.css b/apps/mini-user/src/styles/mine.css index 8d12e83..c56821b 100644 --- a/apps/mini-user/src/styles/mine.css +++ b/apps/mini-user/src/styles/mine.css @@ -386,13 +386,20 @@ position: fixed; inset: 0; z-index: 1000; - background: rgba(20, 16, 14, 0.45); display: flex; align-items: flex-end; justify-content: center; } +.mine-profile-sheet-backdrop { + position: absolute; + inset: 0; + background: rgba(20, 16, 14, 0.45); +} + .mine-profile-sheet { + position: relative; + z-index: 1; width: 100%; max-width: 480px; box-sizing: border-box; @@ -422,8 +429,9 @@ margin: 20px auto 0; padding: 0; width: auto; - background: transparent; - border: none; + height: auto; + background: transparent !important; + border: none !important; line-height: 1.2; display: flex; flex-direction: column; @@ -434,6 +442,11 @@ border: none; } +/* 子节点不抢触摸,保证 open-type=chooseAvatar 由 Button 本人响应 */ +.mine-profile-avatar-btn > * { + pointer-events: none; +} + .mine-profile-avatar-preview { width: 88px; height: 88px; diff --git a/packages/shared-types/src/hq-permissions.ts b/packages/shared-types/src/hq-permissions.ts index 2dcaed6..3acd4fc 100644 --- a/packages/shared-types/src/hq-permissions.ts +++ b/packages/shared-types/src/hq-permissions.ts @@ -18,6 +18,7 @@ export const HQ_PERMISSION_CATALOG = [ { key: 'llm_configs', label: '语言模型配置', group: '业务' }, { key: 'knowledge_bases', label: '知识库', group: '业务' }, { key: 'dev_plan', label: '开发计划', group: '业务' }, + { key: 'test_whitelist', label: '白名单管理', group: '业务' }, { key: 'resources', label: 'OSS 资源库', group: '业务' }, { key: 'logs', label: '日志', group: '业务' }, { key: 'users_delete', label: '删除用户', group: '危险操作' }, @@ -116,6 +117,7 @@ export const HQ_ROLE_DEFAULT_PERMISSIONS: Record = { 'llm_configs', 'knowledge_bases', 'dev_plan', + 'test_whitelist', 'resources', 'logs', 'system_settings_wechat_mini', diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma index 4331f73..da86bca 100644 --- a/server/dukang-api/prisma/schema.prisma +++ b/server/dukang-api/prisma/schema.prisma @@ -812,6 +812,21 @@ model CommonProductVisibilityPhone { @@map("common_product_visibility_phone") } +/// 全局测试白名单手机号(测试账号 + 限测商品/门店可见) +model CommonTestWhitelistPhone { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + phone String @unique @db.VarChar(20) + note String? @db.VarChar(256) + createdByHqId BigInt? @map("created_by_hq_id") @db.UnsignedBigInt + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + + createdBy HqAccount? @relation("TestWhitelistCreatedBy", fields: [createdByHqId], references: [id], onDelete: SetNull) + + @@index([createdAt]) + @@map("common_test_whitelist_phone") +} + model CommonProductDetailTemplate { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt code String @unique @db.VarChar(32) @@ -1043,6 +1058,8 @@ model PartnerAccount { bankBranch String? @map("bank_branch") @db.VarChar(128) weeklyStoreTarget Int? @default(20) @map("weekly_store_target") managedWarehouseId BigInt? @unique @map("managed_warehouse_id") @db.UnsignedBigInt + /// 测试合伙人账号 + isTest Boolean @default(false) @map("is_test") createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) @@ -1059,6 +1076,7 @@ model PartnerAccount { @@index([parentAccountId]) @@index([wxOpenId]) @@index([contactPhone]) + @@index([isTest]) @@map("partner_account") } @@ -1102,6 +1120,7 @@ model HqAccount { updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) permissions HqAccountPermission[] + testWhitelistPhones CommonTestWhitelistPhone[] @relation("TestWhitelistCreatedBy") @@map("hq_account") } @@ -1140,6 +1159,8 @@ model User { nickname String? @db.VarChar(64) avatarResourceId BigInt? @map("avatar_resource_id") @db.UnsignedBigInt status Int @default(1) @db.TinyInt + /// 测试账号:命中全局测试白名单手机号 + isTest Boolean @default(false) @map("is_test") sourceType UserSourceType @default(ORGANIC) @map("source_type") sourceRefId BigInt? @map("source_ref_id") @db.UnsignedBigInt sourceLabel String? @map("source_label") @db.VarChar(128) @@ -1166,6 +1187,7 @@ model User { @@index([referrerUserId]) @@index([mergedIntoUserId]) @@index([wxOpenId]) + @@index([isTest]) @@map("user_user") } @@ -1253,6 +1275,8 @@ model Store { visibilityWhitelistEnabled Boolean @default(false) @map("visibility_whitelist_enabled") /// FIN-001:允许未出账手动提现的白名单门店 withdrawWhitelistEnabled Boolean @default(false) @map("withdraw_whitelist_enabled") + /// 测试门店:不计结算 / HQ 可手动标记 + isTest Boolean @default(false) @map("is_test") createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) @@ -1274,6 +1298,7 @@ model Store { @@index([cityId, status]) @@index([partnerAccountId]) @@index([auditStatus, createdAt]) + @@index([isTest]) @@map("store_store") } @@ -1344,6 +1369,8 @@ model StoreAccount { bankBranch String? @map("bank_branch") @db.VarChar(128) status AccountStatus @default(ACTIVE) lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3) + /// 测试门店账号(商户) + isTest Boolean @default(false) @map("is_test") createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) @@ -1354,6 +1381,7 @@ model StoreAccount { withdrawRequests StoreWithdrawRequest[] @@index([parentAccountId]) + @@index([isTest]) @@map("store_account") } @@ -1432,6 +1460,8 @@ model Order { fulfillmentHold Boolean @default(false) @map("fulfillment_hold") fulfillmentHoldReason String? @map("fulfillment_hold_reason") @db.VarChar(64) remark String? @db.VarChar(512) + /// 测试订单快照(下单时取自 User.isTest) + isTest Boolean @default(false) @map("is_test") createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) @@ -1456,6 +1486,7 @@ model Order { @@index([gpsCity]) @@index([fulfillmentWarehouseId]) @@index([proxyPartnerAccountId]) + @@index([isTest]) @@map("user_order") } @@ -1548,6 +1579,8 @@ model RedeemRecord { settleAmount Decimal @map("settle_amount") @db.Decimal(10, 2) /// SCAN=qrcode, PHONE=phone channel RedeemChannel @default(SCAN) + /// 测试核销快照(User.isTest || Store.isTest) + isTest Boolean @default(false) @map("is_test") createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) user User @relation(fields: [userId], references: [id], onDelete: Restrict) @@ -1560,6 +1593,7 @@ model RedeemRecord { @@index([storeId, createdAt]) @@index([storeId, channel, createdAt]) + @@index([isTest]) @@map("user_redeem_record") } diff --git a/server/dukang-api/prisma/seed-v31.ts b/server/dukang-api/prisma/seed-v31.ts index 7e08eda..d8961ca 100644 --- a/server/dukang-api/prisma/seed-v31.ts +++ b/server/dukang-api/prisma/seed-v31.ts @@ -61,6 +61,16 @@ async function main() { await prisma.storePayout.deleteMany(); + await prisma.storeWithdrawPayoutItem.deleteMany(); + + await prisma.storeWithdrawRequest.deleteMany(); + + await prisma.storeBill.deleteMany(); + + await prisma.redeemPendingRecord.deleteMany(); + + await prisma.logStoreAnalytics.deleteMany(); + await prisma.storeRating.deleteMany(); await prisma.redeemRecord.deleteMany(); @@ -83,16 +93,28 @@ async function main() { await prisma.user.deleteMany(); + await prisma.storeAccount.updateMany({ data: { parentAccountId: null } }); await prisma.storeAccount.deleteMany(); await prisma.store.deleteMany(); await prisma.partnerBill.deleteMany(); + await prisma.logisticsBillItem.deleteMany(); + + await prisma.logisticsPrepaidLedger.deleteMany(); + + await prisma.logisticsBill.deleteMany(); + + await prisma.wineryBillItem.deleteMany(); + + await prisma.wineryBill.deleteMany(); + await prisma.cityWarehouse.deleteMany(); await prisma.fulfillmentProvider.deleteMany(); + await prisma.partnerAccount.updateMany({ data: { parentAccountId: null } }); await prisma.partnerAccount.deleteMany(); await prisma.commonCity.deleteMany(); @@ -101,6 +123,7 @@ async function main() { await prisma.commonProductDetailTemplate.deleteMany(); + await prisma.commonStoreCategory.updateMany({ data: { parentId: null } }); await prisma.commonStoreCategory.deleteMany(); await prisma.commonPromoCode.deleteMany(); @@ -914,6 +937,25 @@ async function main() { + const testWhitelistPhones = [ + '13800000001', + '13700000001', + '13700000002', + '13910000001', + '13910000002', + ]; + for (const phone of testWhitelistPhones) { + await prisma.commonTestWhitelistPhone.upsert({ + where: { phone }, + create: { phone, note: 'seed 测试账号' }, + update: {}, + }); + await prisma.user.updateMany({ where: { phone }, data: { isTest: true } }); + await prisma.storeAccount.updateMany({ where: { phone }, data: { isTest: true } }); + await prisma.partnerAccount.updateMany({ where: { phone }, data: { isTest: true } }); + await prisma.store.updateMany({ where: { phone }, data: { isTest: true } }); + } + console.log('Seed complete:', { city: city.name, @@ -928,6 +970,8 @@ async function main() { stores: createdStores.length, + testWhitelistPhones, + testPhones: { user: '13800000001', diff --git a/server/dukang-api/src/app.module.ts b/server/dukang-api/src/app.module.ts index 5f027d4..acbca92 100644 --- a/server/dukang-api/src/app.module.ts +++ b/server/dukang-api/src/app.module.ts @@ -21,6 +21,7 @@ import { CityScopeModule } from './modules/city-scope/city-scope.module'; import { CommonModule } from './modules/common/common.module'; import { HqOperationModule } from './common/hq-operation/hq-operation.module'; import { SystemConfigModule } from './common/system-config/system-config.module'; +import { TestWhitelistModule } from './common/test-whitelist/test-whitelist.module'; import { DevPlanModule } from './modules/dev-plan/dev-plan.module'; import { CallbacksModule } from './callbacks/callbacks.module'; import { WecomModule } from './integrations/wecom/wecom.module'; @@ -37,6 +38,7 @@ import { RequestIdMiddleware } from './common/logging/request-id.middleware'; }), PrismaModule, SystemConfigModule, + TestWhitelistModule, GeoModule, RedisModule, AlertModule, diff --git a/server/dukang-api/src/common/guards/hq-permission.guard.ts b/server/dukang-api/src/common/guards/hq-permission.guard.ts index 11ec0f9..c4751ca 100644 --- a/server/dukang-api/src/common/guards/hq-permission.guard.ts +++ b/server/dukang-api/src/common/guards/hq-permission.guard.ts @@ -7,11 +7,10 @@ import { } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { - HQ_DANGEROUS_PERMISSION_KEYS, + HQ_PERMISSION_CATALOG, HQ_ROLE_DEFAULT_PERMISSIONS, expandHqPermissionKeys, hasAnySystemSettingsPermission, - hqBasePermissionKeys, type HqPermissionKey, } from '@dukang/shared-types'; import { PrismaService } from '../prisma/prisma.module'; @@ -28,7 +27,7 @@ export const RequireAnySystemSettings = () => export class HqPermissionsResolver { constructor(private readonly prisma: PrismaService) {} - async resolveEffectiveKeys(actorId: bigint): Promise { + private async loadActiveAccount(actorId: bigint) { const account = await this.prisma.hqAccount.findUnique({ where: { id: actorId }, select: { adminRole: true, status: true }, @@ -36,6 +35,14 @@ export class HqPermissionsResolver { if (!account || account.status !== 'ACTIVE') { throw new ForbiddenException('HQ 账号不可用'); } + return account; + } + + async resolveAccess(actorId: bigint): Promise<{ + keys: HqPermissionKey[]; + isSuperAdmin: boolean; + }> { + const account = await this.loadActiveAccount(actorId); const userRows = await this.prisma.hqAccountPermission.findMany({ where: { hqAccountId: actorId }, @@ -44,12 +51,14 @@ export class HqPermissionsResolver { const userKeys = userRows.map((r) => r.permissionKey); if (account.adminRole === 'SUPER_ADMIN') { - // 超管含危险操作(删用户/订单/城市);其他角色仍需在权限分配中显式勾选 - return expandHqPermissionKeys([ - ...hqBasePermissionKeys(), - ...HQ_DANGEROUS_PERMISSION_KEYS, - ...userKeys, - ]); + // 超管拥有权限目录内全部项(含后续新增),另含危险操作与用户级附加项 + return { + isSuperAdmin: true, + keys: expandHqPermissionKeys([ + ...HQ_PERMISSION_CATALOG.map((p) => p.key), + ...userKeys, + ]), + }; } const roleRows = await this.prisma.hqRolePermission.findMany({ @@ -62,7 +71,15 @@ export class HqPermissionsResolver { ? roleRows.map((r) => r.permissionKey) : [...(HQ_ROLE_DEFAULT_PERMISSIONS[account.adminRole] ?? [])]; - return expandHqPermissionKeys([...roleKeys, ...userKeys]); + return { + isSuperAdmin: false, + keys: expandHqPermissionKeys([...roleKeys, ...userKeys]), + }; + } + + async resolveEffectiveKeys(actorId: bigint): Promise { + const { keys } = await this.resolveAccess(actorId); + return keys; } } @@ -79,7 +96,7 @@ export class HqPermissionGuard implements CanActivate { if (!user || user.actorType !== 'HQ') { throw new ForbiddenException('需要 HQ 权限'); } - const keys = await this.resolver.resolveEffectiveKeys(user.actorId); + const { keys, isSuperAdmin } = await this.resolver.resolveAccess(user.actorId); req.hqPermissionKeys = keys; const required = @@ -89,6 +106,7 @@ export class HqPermissionGuard implements CanActivate { ]) ?? []; if (!required.length) return true; + if (isSuperAdmin) return true; if (required.includes('__any_system_settings__')) { if (!hasAnySystemSettingsPermission(keys)) { throw new ForbiddenException('无系统设置权限'); diff --git a/server/dukang-api/src/common/test-whitelist/test-whitelist.module.ts b/server/dukang-api/src/common/test-whitelist/test-whitelist.module.ts new file mode 100644 index 0000000..bc13d87 --- /dev/null +++ b/server/dukang-api/src/common/test-whitelist/test-whitelist.module.ts @@ -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 {} diff --git a/server/dukang-api/src/common/test-whitelist/test-whitelist.service.ts b/server/dukang-api/src/common/test-whitelist/test-whitelist.service.ts new file mode 100644 index 0000000..dc5d090 --- /dev/null +++ b/server/dukang-api/src/common/test-whitelist/test-whitelist.service.ts @@ -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 { + 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(); + 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 }; + } +} diff --git a/server/dukang-api/src/modules/catalog/catalog.service.ts b/server/dukang-api/src/modules/catalog/catalog.service.ts index c64b9c7..36d4d2d 100644 --- a/server/dukang-api/src/modules/catalog/catalog.service.ts +++ b/server/dukang-api/src/modules/catalog/catalog.service.ts @@ -1,6 +1,10 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; +import { + TestWhitelistService, + normalizeTestPhone, +} from '../../common/test-whitelist/test-whitelist.service'; import { groupResourcesByProductId, mapProductMedia } from './catalog.mapper'; export type CatalogViewer = { @@ -10,13 +14,32 @@ export type CatalogViewer = { bypassWhitelist?: boolean; }; -function normalizePhone(phone: string | null | undefined): string { - return (phone || '').replace(/\D/g, '').trim(); -} - @Injectable() export class CatalogService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly testWhitelist: TestWhitelistService, + ) {} + + private async whitelistPhoneSet(): Promise> { + 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, + ): boolean { + if (viewer?.bypassWhitelist) return true; + if (!product.visibilityWhitelistEnabled) return true; + const phone = normalizeTestPhone(viewer?.phone); + if (!phone) return false; + if (whitelistPhones) return whitelistPhones.has(phone); + return false; + } async listCities() { const cities = await this.prisma.commonCity.findMany({ @@ -58,11 +81,13 @@ export class CatalogService { orderBy: { sortOrder: 'asc' }, include: { coverResource: true, - visibilityPhones: { select: { phone: true } }, }, }); - const visible = products.filter((p) => this.isVisibleToViewer(p, viewer)); + const whitelistPhones = products.some((p) => p.visibilityWhitelistEnabled) + ? await this.whitelistPhoneSet() + : new Set(); + const visible = products.filter((p) => this.isVisibleToViewer(p, viewer, whitelistPhones)); const productIds = visible.map((p) => p.id); const resources = productIds.length @@ -81,7 +106,7 @@ export class CatalogService { return serializeBigInt( visible.map((p) => { const media = mapProductMedia(p, resourceMap.get(p.id.toString()) ?? []); - const { visibilityPhones: _phones, visibilityWhitelistEnabled: _wl, ...rest } = p; + const { visibilityWhitelistEnabled: _wl, ...rest } = p; return { ...rest, benefitAmount: p.benefitAmount ?? p.price, @@ -98,11 +123,13 @@ export class CatalogService { where: { id }, include: { coverResource: true, - visibilityPhones: { select: { phone: true } }, }, }); if (!product) return null; - if (!this.isVisibleToViewer(product, viewer)) { + const whitelistPhones = product.visibilityWhitelistEnabled + ? await this.whitelistPhoneSet() + : new Set(); + if (!this.isVisibleToViewer(product, viewer, whitelistPhones)) { return null; } @@ -117,7 +144,7 @@ export class CatalogService { }); const media = mapProductMedia(product, resources); - const { visibilityPhones: _phones, visibilityWhitelistEnabled: _wl, ...rest } = product; + const { visibilityWhitelistEnabled: _wl, ...rest } = product; return serializeBigInt({ ...rest, benefitAmount: product.benefitAmount ?? product.price, @@ -126,17 +153,19 @@ export class CatalogService { }); } - /** 下单前校验:白名单商品仅白名单手机号可买 */ + /** 下单前校验:白名单商品仅全局测试白名单手机号可买 */ async assertPurchasable(productId: bigint, viewerPhone?: string | null) { const product = await this.prisma.commonProductItem.findUnique({ where: { id: productId }, - include: { visibilityPhones: { select: { phone: true } } }, }); if (!product || product.status !== 'ON_SALE') { throw new BadRequestException('商品不可购买'); } - if (!this.isVisibleToViewer(product, { phone: viewerPhone })) { - throw new BadRequestException('该商品暂不对当前账号开放'); + if (product.visibilityWhitelistEnabled) { + const ok = await this.testWhitelist.isPhoneInWhitelist(viewerPhone); + if (!ok) { + throw new BadRequestException('该商品暂不对当前账号开放'); + } } return product; } @@ -148,18 +177,4 @@ export class CatalogService { }); return user?.phone ?? null; } - - isVisibleToViewer( - product: { - visibilityWhitelistEnabled: boolean; - visibilityPhones: Array<{ phone: string }>; - }, - viewer?: CatalogViewer, - ): boolean { - if (viewer?.bypassWhitelist) return true; - if (!product.visibilityWhitelistEnabled) return true; - const phone = normalizePhone(viewer?.phone); - if (!phone) return false; - return product.visibilityPhones.some((row) => normalizePhone(row.phone) === phone); - } } diff --git a/server/dukang-api/src/modules/iam/auth.service.ts b/server/dukang-api/src/modules/iam/auth.service.ts index d5d54d0..6e6dd25 100644 --- a/server/dukang-api/src/modules/iam/auth.service.ts +++ b/server/dukang-api/src/modules/iam/auth.service.ts @@ -25,6 +25,7 @@ import { AnalyticsService } from '../analytics/analytics.service'; import { UserAddressService } from './user-address.service'; import { ResourceService } from '../common/resource.service'; import { HqPermissionsResolver } from '../../common/guards/hq-permission.guard'; +import { TestWhitelistService } from '../../common/test-whitelist/test-whitelist.service'; import type { User } from '@prisma/client'; @@ -67,8 +68,21 @@ export class AuthService { private readonly userAddressService: UserAddressService, @Inject(forwardRef(() => ResourceService)) private readonly resourceService: ResourceService, private readonly hqPermissions: HqPermissionsResolver, + private readonly testWhitelist: TestWhitelistService, ) {} + /** 登录/绑号后按全局白名单同步 isTest */ + private async syncTestFlagByPhone(phone: string) { + const isTest = await this.testWhitelist.isPhoneInWhitelist(phone); + await Promise.all([ + this.prisma.user.updateMany({ where: { phone }, data: { isTest } }), + this.prisma.storeAccount.updateMany({ where: { phone }, data: { isTest } }), + this.prisma.partnerAccount.updateMany({ where: { phone }, data: { isTest } }), + this.prisma.store.updateMany({ where: { phone }, data: { isTest } }), + ]); + return isTest; + } + private assertMobilePhone(phone: string) { const trimmed = phone.trim(); if (!/^1[3-9]\d{9}$/.test(trimmed)) { @@ -375,12 +389,14 @@ export class AuthService { }); if (!user) { + const isTest = await this.testWhitelist.isPhoneInWhitelist(normalizedPhone); user = await this.prisma.user.create({ data: { phone: normalizedPhone, phoneVerifiedAt: new Date(), userNo: generateUserNo(), nickname: `用户${normalizedPhone.slice(-4)}`, + isTest, sourceType: source?.sourceType ?? 'ORGANIC', sourceRefId: source?.sourceRefId, sourceLabel: source?.sourceLabel, @@ -401,6 +417,11 @@ export class AuthService { include: { avatar: true }, }); } + await this.syncTestFlagByPhone(normalizedPhone); + user = await this.prisma.user.findUniqueOrThrow({ + where: { id: user.id }, + include: { avatar: true }, + }); await this.assertActiveUser(user.id); } @@ -810,12 +831,14 @@ export class AuthService { } } if (!user) { + const isTest = await this.testWhitelist.isPhoneInWhitelist(normalizedPhone); user = await this.prisma.user.create({ data: { phone: normalizedPhone, phoneVerifiedAt: new Date(), userNo: generateUserNo(), nickname: `用户${normalizedPhone.slice(-4)}`, + isTest, cityPreference: { create: { selectedCityCode: '410100', @@ -852,6 +875,12 @@ export class AuthService { if (!user) throw new BadRequestException('登录失败'); + await this.syncTestFlagByPhone(normalizedPhone); + user = await this.prisma.user.findUniqueOrThrow({ + where: { id: user.id }, + include: { avatar: true }, + }); + this.analyticsService.trackOneSafe(user.id, clientApp, { eventName: method === 'sms' ? 'sms_login' : 'wechat_phone_login', extraJson: { method }, @@ -998,6 +1027,7 @@ export class AuthService { if (!account) throw new BadRequestException('该手机号未绑定门店'); if (account.status !== 'ACTIVE') throw new BadRequestException('门店账号已停用'); if (!account.bindings.length) throw new BadRequestException('该账号未绑定任何门店'); + await this.syncTestFlagByPhone(normalizedPhone); await this.prisma.storeAccount.update({ where: { id: account.id }, data: { lastLoginAt: new Date() }, @@ -1032,6 +1062,7 @@ export class AuthService { }); if (!account) throw new BadRequestException('未找到合伙人账号'); if (account.status !== 'ACTIVE') throw new BadRequestException('合伙人账号已停用'); + await this.syncTestFlagByPhone(normalizedPhone); const primary = await this.resolvePrimaryAccount(account.id); await this.prisma.partnerAccount.update({ where: { id: account.id }, diff --git a/server/dukang-api/src/modules/ops/admin-hq-permissions.service.ts b/server/dukang-api/src/modules/ops/admin-hq-permissions.service.ts index 01522cf..942be9b 100644 --- a/server/dukang-api/src/modules/ops/admin-hq-permissions.service.ts +++ b/server/dukang-api/src/modules/ops/admin-hq-permissions.service.ts @@ -1,11 +1,9 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { - HQ_DANGEROUS_PERMISSION_KEYS, HQ_PERMISSION_CATALOG, HQ_ROLE_DEFAULT_PERMISSIONS, LEGACY_SYSTEM_SETTINGS_KEY, expandHqPermissionKeys, - hqBasePermissionKeys, type HqPermissionKey, } from '@dukang/shared-types'; import { PrismaService } from '../../common/prisma/prisma.module'; @@ -83,10 +81,9 @@ export class AdminHqPermissionsService { const userPermissionKeys = expandHqPermissionKeys(userPerms.map((p) => p.permissionKey)); if (account.adminRole === 'SUPER_ADMIN') { - const rolePermissionKeys = [ - ...hqBasePermissionKeys(), - ...HQ_DANGEROUS_PERMISSION_KEYS, - ] as HqPermissionKey[]; + const rolePermissionKeys = HQ_PERMISSION_CATALOG.map( + (p) => p.key, + ) as HqPermissionKey[]; const effectivePermissionKeys = [ ...new Set([...rolePermissionKeys, ...userPermissionKeys]), ] as HqPermissionKey[]; diff --git a/server/dukang-api/src/modules/ops/admin-orders.service.ts b/server/dukang-api/src/modules/ops/admin-orders.service.ts index f7bc2f9..4137694 100644 --- a/server/dukang-api/src/modules/ops/admin-orders.service.ts +++ b/server/dukang-api/src/modules/ops/admin-orders.service.ts @@ -43,6 +43,7 @@ export class AdminOrdersService { if (query.createdFrom) where.createdAt.gte = new Date(query.createdFrom); if (query.createdTo) where.createdAt.lte = new Date(query.createdTo); } + if (query.excludeTest) where.isTest = false; const [items, total] = await Promise.all([ this.prisma.order.findMany({ diff --git a/server/dukang-api/src/modules/ops/admin-partners.service.ts b/server/dukang-api/src/modules/ops/admin-partners.service.ts index 2da4b9d..ecd6cdd 100644 --- a/server/dukang-api/src/modules/ops/admin-partners.service.ts +++ b/server/dukang-api/src/modules/ops/admin-partners.service.ts @@ -42,6 +42,7 @@ export class AdminPartnersService { if (query.phone) where.phone = { contains: query.phone }; if (query.cityId) where.cityId = BigInt(query.cityId); if (query.partnerId) where.id = BigInt(query.partnerId); + if (query.excludeTest) where.isTest = false; const [items, total] = await Promise.all([ this.prisma.partnerAccount.findMany({ diff --git a/server/dukang-api/src/modules/ops/admin-products.service.ts b/server/dukang-api/src/modules/ops/admin-products.service.ts index a9275d0..856d714 100644 --- a/server/dukang-api/src/modules/ops/admin-products.service.ts +++ b/server/dukang-api/src/modules/ops/admin-products.service.ts @@ -5,6 +5,7 @@ import { serializeBigInt } from '../../common/decorators/current-user.decorator' import { groupResourcesByProductId, mapProductMedia } from '../catalog/catalog.mapper'; import type { AdminProductsQueryDto } from './dto/admin-query.dto'; import type { CreateProductDto, UpdateProductDto } from './dto/admin-mutate.dto'; +import { TestWhitelistService } from '../../common/test-whitelist/test-whitelist.service'; function normalizePhones(phones?: string[]): string[] { if (!phones?.length) return []; @@ -57,7 +58,10 @@ function resolveFulfillmentFlags(input: { @Injectable() export class AdminProductsService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly testWhitelist: TestWhitelistService, + ) {} async list(query: AdminProductsQueryDto) { const page = query.page ?? 1; @@ -140,6 +144,11 @@ export class AdminProductsService { const phones = normalizePhones(dto.visibilityPhones); const whitelistEnabled = !!dto.visibilityWhitelistEnabled; + if (whitelistEnabled) { + await this.testWhitelist.assertGlobalWhitelistNotEmpty(); + } + // 手机号统一在「白名单管理」维护;此处忽略分实体 phones(兼容旧客户端传参) + void phones; const product = await this.createWithGeneratedSku({ barcode69: dto.barcode69, @@ -158,13 +167,6 @@ export class AdminProductsService { ...(dto.detailContent !== undefined ? { detailContent: dto.detailContent as Prisma.InputJsonValue } : {}), - ...(phones.length - ? { - visibilityPhones: { - create: phones.map((phone) => ({ phone })), - }, - } - : {}), }); if (dto.coverUrl) { @@ -225,9 +227,10 @@ export class AdminProductsService { }, }); - if (dto.visibilityPhones !== undefined) { - await this.syncVisibilityPhones(id, normalizePhones(dto.visibilityPhones)); + if (dto.visibilityWhitelistEnabled) { + await this.testWhitelist.assertGlobalWhitelistNotEmpty(); } + // 分实体手机号已废弃;忽略 dto.visibilityPhones if (dto.coverUrl) { await this.syncCover(id, dto.coverUrl); diff --git a/server/dukang-api/src/modules/ops/admin-redeem.service.ts b/server/dukang-api/src/modules/ops/admin-redeem.service.ts index ece2a4a..100b8e4 100644 --- a/server/dukang-api/src/modules/ops/admin-redeem.service.ts +++ b/server/dukang-api/src/modules/ops/admin-redeem.service.ts @@ -42,6 +42,7 @@ export class AdminRedeemService { if (query.channel === 'SCAN' || query.channel === 'PHONE') { where.channel = query.channel; } + if (query.excludeTest) where.isTest = false; const [items, total] = await Promise.all([ this.prisma.redeemRecord.findMany({ diff --git a/server/dukang-api/src/modules/ops/admin-stores.service.ts b/server/dukang-api/src/modules/ops/admin-stores.service.ts index df7b6b9..1c690c6 100644 --- a/server/dukang-api/src/modules/ops/admin-stores.service.ts +++ b/server/dukang-api/src/modules/ops/admin-stores.service.ts @@ -17,6 +17,7 @@ import type { UpdateStoreMediaDto, UpdateStoreStatusDto, } from './dto/admin-mutate.dto'; +import { TestWhitelistService } from '../../common/test-whitelist/test-whitelist.service'; /** 选填文案:空 / null / "null" 一律存库为 null,避免 String(null)==="null" */ function normalizeStoreOptionalText(value: unknown): string | null { @@ -51,6 +52,7 @@ export class AdminStoresService { private readonly partnerCityService: PartnerCityService, private readonly storeCategoryService: StoreCategoryService, private readonly analyticsService: AnalyticsService, + private readonly testWhitelist: TestWhitelistService, ) {} async listStores(query: AdminStoresQueryDto) { @@ -65,6 +67,7 @@ export class AdminStoresService { if (query.cityId) where.cityId = BigInt(query.cityId); if (query.partnerId) where.partnerAccountId = BigInt(query.partnerId); if (query.phone) where.phone = { contains: query.phone }; + if (query.excludeTest) where.isTest = false; const [items, total] = await Promise.all([ this.prisma.store.findMany({ @@ -283,18 +286,7 @@ export class AdminStoresService { ? !!dto.visibilityWhitelistEnabled : current.visibilityWhitelistEnabled; if (nextEnabled) { - const phones = - dto.visibilityPhones !== undefined - ? normalizeVisibilityPhones(dto.visibilityPhones) - : ( - await this.prisma.storeVisibilityPhone.findMany({ - where: { storeId: id }, - select: { phone: true }, - }) - ).map((p) => p.phone); - if (!phones.length) { - throw new BadRequestException('开启白名单时请至少添加一个手机号'); - } + await this.testWhitelist.assertGlobalWhitelistNotEmpty(); } } const bankTouched = @@ -372,18 +364,13 @@ export class AdminStoresService { ...(dto.visibilityWhitelistEnabled !== undefined ? { visibilityWhitelistEnabled: !!dto.visibilityWhitelistEnabled } : {}), + ...(dto.isTest !== undefined ? { isTest: !!dto.isTest } : {}), ...(latitude != null && longitude != null ? { latitude, longitude } : {}), }, }); if (dto.visibilityPhones !== undefined) { - const phones = normalizeVisibilityPhones(dto.visibilityPhones); - await tx.storeVisibilityPhone.deleteMany({ where: { storeId: id } }); - if (phones.length) { - await tx.storeVisibilityPhone.createMany({ - data: phones.map((phone) => ({ storeId: id, phone })), - }); - } + // 分实体手机号已废弃,忽略写入 } if (dto.coverUrl) { @@ -505,11 +492,14 @@ export class AdminStoresService { throw new BadRequestException('好客权益券使用规则最多 1000 字'); } - const visibilityPhones = normalizeVisibilityPhones(dto.visibilityPhones); const whitelistEnabled = !!dto.visibilityWhitelistEnabled; - if (whitelistEnabled && !visibilityPhones.length) { - throw new BadRequestException('开启白名单时请至少添加一个手机号'); + if (whitelistEnabled) { + await this.testWhitelist.assertGlobalWhitelistNotEmpty(); } + const isTest = + dto.isTest !== undefined + ? !!dto.isTest + : await this.testWhitelist.isPhoneInWhitelist(normalizedPhone); const store = await this.prisma.store.create({ data: { @@ -531,18 +521,12 @@ export class AdminStoresService { openTime2: openTime2 || null, closeTime2: closeTime2 || null, visibilityWhitelistEnabled: whitelistEnabled, + isTest, ...(latitude != null && longitude != null ? { latitude, longitude } : {}), status: 'OPEN', auditStatus: 'APPROVED', auditedAt: new Date(), rejectReason: null, - ...(visibilityPhones.length - ? { - visibilityPhones: { - create: visibilityPhones.map((phone) => ({ phone })), - }, - } - : {}), }, }); @@ -746,6 +730,7 @@ export class AdminStoresService { where.bindings = { some: { storeId: BigInt(query.storeId) } }; } if (query.status) where.status = query.status as Prisma.EnumAccountStatusFilter['equals']; + if (query.excludeTest) where.isTest = false; const [items, total] = await Promise.all([ this.prisma.storeAccount.findMany({ diff --git a/server/dukang-api/src/modules/ops/admin-test-whitelist.controller.ts b/server/dukang-api/src/modules/ops/admin-test-whitelist.controller.ts new file mode 100644 index 0000000..60211a1 --- /dev/null +++ b/server/dukang-api/src/modules/ops/admin-test-whitelist.controller.ts @@ -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 = {}; + 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); + } +} diff --git a/server/dukang-api/src/modules/ops/admin-users.service.ts b/server/dukang-api/src/modules/ops/admin-users.service.ts index 01a8ce7..5bad623 100644 --- a/server/dukang-api/src/modules/ops/admin-users.service.ts +++ b/server/dukang-api/src/modules/ops/admin-users.service.ts @@ -19,6 +19,7 @@ function mapAdminUserRow(u: { sourceType: string; sourceRefId: bigint | null; sourceLabel: string | null; + isTest?: boolean; createdAt: Date; updatedAt: Date; _count: { orders: number }; @@ -37,6 +38,7 @@ function mapAdminUserRow(u: { sourceType: u.sourceType, sourceRefId: u.sourceRefId, sourceLabel: u.sourceLabel, + isTest: !!u.isTest, createdAt: u.createdAt, updatedAt: u.updatedAt, orderCount: u._count.orders, @@ -58,6 +60,7 @@ export class AdminUsersService { if (query.status !== undefined) where.status = query.status; if (query.phoneVerified === '1') where.phoneVerifiedAt = { not: null }; if (query.phoneVerified === '0') where.phoneVerifiedAt = null; + if (query.excludeTest) where.isTest = false; const [items, total] = await Promise.all([ this.prisma.user.findMany({ @@ -78,6 +81,7 @@ export class AdminUsersService { sourceType: true, sourceRefId: true, sourceLabel: true, + isTest: true, createdAt: true, updatedAt: true, _count: { select: { orders: true } }, diff --git a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts index 6adcf75..3fcd82a 100644 --- a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts +++ b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts @@ -128,16 +128,21 @@ export class CreateStoreDto { @Min(0) avgPrice?: number; - /** 开启后仅白名单手机号在 C 端可见 */ + /** 开启后仅全局测试白名单手机号在 C 端可见 */ @IsOptional() @IsBoolean() visibilityWhitelistEnabled?: boolean; - /** 可见白名单手机号列表 */ + /** @deprecated 已并入全局白名单,忽略 */ @IsOptional() @IsArray() @IsString({ each: true }) visibilityPhones?: string[]; + + /** 测试门店标记 */ + @IsOptional() + @IsBoolean() + isTest?: boolean; } export class UpdateStoreDto { @@ -227,16 +232,21 @@ export class UpdateStoreDto { @IsString() bankBranch?: string | null; - /** 开启后仅白名单手机号在 C 端可见 */ + /** 开启后仅全局测试白名单手机号在 C 端可见 */ @IsOptional() @IsBoolean() visibilityWhitelistEnabled?: boolean; - /** 可见白名单手机号列表 */ + /** @deprecated 已并入全局白名单,忽略 */ @IsOptional() @IsArray() @IsString({ each: true }) visibilityPhones?: string[]; + + /** 测试门店标记 */ + @IsOptional() + @IsBoolean() + isTest?: boolean; } export class CreateStoreAccountDto { diff --git a/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts index 96d1750..98e19f4 100644 --- a/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts +++ b/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts @@ -1,5 +1,12 @@ -import { Type } from 'class-transformer'; -import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; +import { Type, Transform } from 'class-transformer'; +import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; + +function toOptionalBoolean(value: unknown): boolean | undefined { + if (value === undefined || value === null || value === '') return undefined; + if (value === true || value === 'true' || value === '1' || value === 1) return true; + if (value === false || value === 'false' || value === '0' || value === 0) return false; + return undefined; +} export class PaginationQueryDto { @IsOptional() @@ -37,6 +44,12 @@ export class AdminUsersQueryDto extends PaginationQueryDto { @Type(() => Number) @IsIn([0, 1]) status?: number; + + /** 勾选「过滤测试账号」时传 true */ + @IsOptional() + @Transform(({ value }) => toOptionalBoolean(value)) + @IsBoolean() + excludeTest?: boolean; } export class AdminOrdersQueryDto extends PaginationQueryDto { @@ -75,6 +88,11 @@ export class AdminOrdersQueryDto extends PaginationQueryDto { @IsOptional() @IsString() createdTo?: string; + + @IsOptional() + @Transform(({ value }) => toOptionalBoolean(value)) + @IsBoolean() + excludeTest?: boolean; } /** 概览页用户/订单 ECharts 聚合筛选 */ @@ -128,6 +146,11 @@ export class AdminStoresQueryDto extends PaginationQueryDto { @IsOptional() @IsString() auditStatus?: string; + + @IsOptional() + @Transform(({ value }) => toOptionalBoolean(value)) + @IsBoolean() + excludeTest?: boolean; } export class AdminStoreAccountsQueryDto extends PaginationQueryDto { @@ -142,6 +165,11 @@ export class AdminStoreAccountsQueryDto extends PaginationQueryDto { @IsOptional() @IsString() status?: string; + + @IsOptional() + @Transform(({ value }) => toOptionalBoolean(value)) + @IsBoolean() + excludeTest?: boolean; } export class AdminPartnersQueryDto extends PaginationQueryDto { @@ -164,6 +192,11 @@ export class AdminPartnersQueryDto extends PaginationQueryDto { @IsOptional() @IsString() partnerId?: string; + + @IsOptional() + @Transform(({ value }) => toOptionalBoolean(value)) + @IsBoolean() + excludeTest?: boolean; } export class AdminCityWarehousesQueryDto extends PaginationQueryDto { @@ -247,6 +280,11 @@ export class AdminRedeemRecordsQueryDto extends PaginationQueryDto { @IsOptional() @IsString() channel?: string; + + @IsOptional() + @Transform(({ value }) => toOptionalBoolean(value)) + @IsBoolean() + excludeTest?: boolean; } export class AdminStoreRatingsQueryDto extends PaginationQueryDto { diff --git a/server/dukang-api/src/modules/ops/ops.module.ts b/server/dukang-api/src/modules/ops/ops.module.ts index 4d3c287..d6e50cd 100644 --- a/server/dukang-api/src/modules/ops/ops.module.ts +++ b/server/dukang-api/src/modules/ops/ops.module.ts @@ -80,6 +80,7 @@ import { AdminDevPlanController } from '../dev-plan/admin-dev-plan.controller'; import { AdminFulfillmentProvidersController } from './admin-fulfillment-providers.controller'; import { AdminDomainEventsController } from './admin-domain-events.controller'; import { AdminDomainEventsService } from './admin-domain-events.service'; +import { AdminTestWhitelistController } from './admin-test-whitelist.controller'; @Module({ imports: [CityScopeModule, IamModule, TradeModule, AnalyticsModule, FulfillmentModule, BenefitModule, CommonModule, IntegrationsModule, WecomModule, LlmModule, RedeemModule, StoreModule, DevPlanModule], @@ -128,6 +129,7 @@ import { AdminDomainEventsService } from './admin-domain-events.service'; AdminKnowledgeBasesController, AdminDevPlanController, AdminFulfillmentProvidersController, + AdminTestWhitelistController, ], providers: [ AdminDashboardService, diff --git a/server/dukang-api/src/modules/redeem/redeem.service.ts b/server/dukang-api/src/modules/redeem/redeem.service.ts index 2a16df4..3eb557c 100644 --- a/server/dukang-api/src/modules/redeem/redeem.service.ts +++ b/server/dukang-api/src/modules/redeem/redeem.service.ts @@ -180,6 +180,15 @@ export class RedeemService { const settleAmount = calcRedeemSettleAmount(amount, settlementRate); const redeemChannel = analyticsExtra?.channel === 'phone' ? 'PHONE' : 'SCAN'; + const [userRow, storeRow] = await Promise.all([ + this.prisma.user.findUnique({ where: { id: userId }, select: { isTest: true } }), + this.prisma.store.findUnique({ + where: { id: account.storeId }, + select: { isTest: true }, + }), + ]); + const isTest = !!(userRow?.isTest || storeRow?.isTest); + let record; try { record = await this.prisma.$transaction(async (tx) => { @@ -194,6 +203,7 @@ export class RedeemService { amount, settleAmount, channel: redeemChannel, + isTest, allocations: { create: normalizedAllocations.map((item, index) => ({ couponId: BigInt(item.couponId), @@ -204,14 +214,16 @@ export class RedeemService { }, }); - await this.settlementService.createStorePayout( - redeemRecord.id, - account.storeId, - amount, - settleAmount, - settlementRate, - tx, - ); + if (!isTest) { + await this.settlementService.createStorePayout( + redeemRecord.id, + account.storeId, + amount, + settleAmount, + settlementRate, + tx, + ); + } return redeemRecord; }); diff --git a/server/dukang-api/src/modules/settlement/settlement.service.ts b/server/dukang-api/src/modules/settlement/settlement.service.ts index eace40d..5e1cf11 100644 --- a/server/dukang-api/src/modules/settlement/settlement.service.ts +++ b/server/dukang-api/src/modules/settlement/settlement.service.ts @@ -1322,9 +1322,12 @@ export class SettlementService { cityId: primary.cityId, payStatus: 'PAID', paidAt: { gte: periodStart, lte: periodEnd }, + isTest: false, }, }); - const orderCommission = orders.reduce((sum, o) => { + const orderCommission = primary.isTest + ? 0 + : orders.reduce((sum, o) => { if (o.partnerAccountIdAtPay) { if (o.partnerAccountIdAtPay !== primary.id) return sum; const rate = o.orderCommissionRateAtPay != null ? Number(o.orderCommissionRateAtPay) : 0; @@ -1338,14 +1341,16 @@ export class SettlementService { select: { id: true }, }); const storeIds = stores.map((s) => s.id); - const redeems = storeIds.length - ? await this.prisma.redeemRecord.findMany({ + const redeems = + primary.isTest || !storeIds.length + ? [] + : await this.prisma.redeemRecord.findMany({ where: { storeId: { in: storeIds }, createdAt: { gte: periodStart, lte: periodEnd }, + isTest: false, }, - }) - : []; + }); const redeemCommission = redeems.reduce( (sum, r) => sum + Number(r.amount) * redeemCommissionRate, 0, @@ -1588,6 +1593,7 @@ export class SettlementService { payStatus: 'PAID', deliveryType: { in: ['LOCAL', 'CROSS_CITY'] }, completedAt: { gte: start, lt: end }, + isTest: false, }, orderBy: { completedAt: 'asc' }, }); @@ -1921,6 +1927,7 @@ export class SettlementService { quantity: true, deliveryType: true, payStatus: true, + isTest: true, }, }, }, @@ -1929,6 +1936,7 @@ export class SettlementService { const eligible = deliveries.filter( (d) => + !d.order.isTest && d.order.payStatus === 'PAID' && (d.order.deliveryType === 'LOCAL' || d.order.deliveryType === 'CROSS_CITY'), ); diff --git a/server/dukang-api/src/modules/store/store.service.ts b/server/dukang-api/src/modules/store/store.service.ts index 92cb328..64f3a41 100644 --- a/server/dukang-api/src/modules/store/store.service.ts +++ b/server/dukang-api/src/modules/store/store.service.ts @@ -16,6 +16,10 @@ import { PartnerCityService } from '../city-scope/partner-city.service'; import { AuthService } from '../iam/auth.service'; import { StoreCategoryService } from './store-category.service'; import { TencentLbsProvider } from '../../integrations/map/tencent-lbs.provider'; +import { + TestWhitelistService, + normalizeTestPhone, +} from '../../common/test-whitelist/test-whitelist.service'; function haversineMeters(lat1: number, lng1: number, lat2: number, lng2: number): number { const toRad = (d: number) => (d * Math.PI) / 180; @@ -43,10 +47,6 @@ export type StoreViewer = { bypassWhitelist?: boolean; }; -function normalizePhone(phone: string | null | undefined): string { - return (phone || '').replace(/\D/g, '').trim(); -} - function parseOptionalCoord(value: unknown, kind: 'lat' | 'lng' = 'lng'): number | null { if (value == null || value === '') return null; const n = typeof value === 'number' ? value : Number(value); @@ -67,8 +67,16 @@ export class StoreService { private readonly authService: AuthService, private readonly storeCategoryService: StoreCategoryService, private readonly tencentLbs: TencentLbsProvider, + private readonly testWhitelist: TestWhitelistService, ) {} + private async whitelistPhoneSet(): Promise> { + const rows = await this.prisma.commonTestWhitelistPhone.findMany({ + select: { phone: true }, + }); + return new Set(rows.map((r) => normalizeTestPhone(r.phone)).filter(Boolean)); + } + private storeAddressText(store: { province?: string | null; cityName?: string | null; @@ -116,17 +124,16 @@ export class StoreService { } isVisibleToViewer( - store: { - visibilityWhitelistEnabled: boolean; - visibilityPhones: Array<{ phone: string }>; - }, + store: { visibilityWhitelistEnabled: boolean }, viewer?: StoreViewer, + whitelistPhones?: Set, ): boolean { if (viewer?.bypassWhitelist) return true; if (!store.visibilityWhitelistEnabled) return true; - const phone = normalizePhone(viewer?.phone); + const phone = normalizeTestPhone(viewer?.phone); if (!phone) return false; - return store.visibilityPhones.some((row) => normalizePhone(row.phone) === phone); + if (whitelistPhones) return whitelistPhones.has(phone); + return false; } async listOpenStores( @@ -145,12 +152,14 @@ export class StoreService { include: { category: true, coverResource: true, - visibilityPhones: { select: { phone: true } }, }, orderBy: { createdAt: 'desc' }, }); - const visible = stores.filter((s) => this.isVisibleToViewer(s, viewer)); + const whitelistPhones = stores.some((s) => s.visibilityWhitelistEnabled) + ? await this.whitelistPhoneSet() + : new Set(); + const visible = stores.filter((s) => this.isVisibleToViewer(s, viewer, whitelistPhones)); const hasUser = userLat != null && @@ -167,7 +176,7 @@ export class StoreService { const items: StoreListItem[] = []; for (const store of visible) { const coords = await this.ensureStoreCoordinates(store); - const { visibilityPhones: _phones, visibilityWhitelistEnabled: _wl, ...rest } = store; + const { visibilityWhitelistEnabled: _wl, ...rest } = store; const mapped = mapStoreCompat({ ...rest, latitude: coords?.latitude ?? store.latitude, @@ -197,10 +206,12 @@ export class StoreService { include: { category: true, coverResource: true, - visibilityPhones: { select: { phone: true } }, }, }); - if (!store || !this.isVisibleToViewer(store, viewer)) { + const whitelistPhones = store?.visibilityWhitelistEnabled + ? await this.whitelistPhoneSet() + : new Set(); + if (!store || !this.isVisibleToViewer(store, viewer, whitelistPhones)) { throw new NotFoundException('门店不存在'); } const coords = await this.ensureStoreCoordinates(store); @@ -212,7 +223,7 @@ export class StoreService { where: { storeId: id }, orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }], }); - const { visibilityPhones: _phones, visibilityWhitelistEnabled: _wl, ...rest } = store; + const { visibilityWhitelistEnabled: _wl, ...rest } = store; return serializeBigInt( mapStoreCompat({ ...rest, diff --git a/server/dukang-api/src/modules/trade/trade.service.ts b/server/dukang-api/src/modules/trade/trade.service.ts index 4099d62..dcd47a2 100644 --- a/server/dukang-api/src/modules/trade/trade.service.ts +++ b/server/dukang-api/src/modules/trade/trade.service.ts @@ -218,6 +218,11 @@ export class TradeService { const promoCodeId = attribution?.promoCode?.status === 'ACTIVE' ? attribution.promoCodeId : undefined; + const buyer = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { isTest: true }, + }); + const order = await this.prisma.$transaction(async (tx) => { const created = await tx.order.create({ data: { @@ -258,6 +263,7 @@ export class TradeService { benefitAmount: preview.benefitAmount, payExpireAt, promoCodeId, + isTest: !!buyer?.isTest, }, include: { product: true, imageResource: true }, }); diff --git a/杜康好客-v3-PRD.md b/杜康好客-v3-PRD.md index 557ef51..6c4d23b 100644 --- a/杜康好客-v3-PRD.md +++ b/杜康好客-v3-PRD.md @@ -19,7 +19,7 @@ | 3.4.11 | 08-04 | 开发计划 + 企微机器人/消息推送 | [`开发计划`](./杜康好客-开发计划功能开发文档-v3.4.11.md) | | 3.4.12 | 08-04 | 退款回滚/财务/批量任务·工单/套餐 imageUrl | [`v3.4.12`](./杜康好客-v3.4.12-工单迭代开发文档.md) | | 3.4.13 | 08-05 | metrics/核销详情/版本联动 PUBLISHED/mini 体验/H5 OAuth | [`v3.4.13`](./杜康好客-v3.4.13-体验优化开发文档.md) | -| 3.4.14 | 08-06 | mini-user 门头/套餐详情;**微信小程序配置可配 Logo·客服·H5·Mock码** | [`v3.4.14`](./杜康好客-v3.4.14-mini-user门店体验开发文档.md) | +| 3.4.14 | 08-06 | mini-user 门头/套餐详情;小程序可配置;**统一测试白名单(不计账+限测可见+Mock旁路)** | [`v3.4.14`](./杜康好客-v3.4.14-mini-user门店体验开发文档.md) | | 3.4.15 | 08-06 | mini-user 门店列表卡片:去核销改箭头、地址/距离/营业时间重排、营业中角标 | [`v3.4.15`](./杜康好客-v3.4.15-mini-user门店列表优化.md) | --- diff --git a/杜康好客-v3-现状对照.md b/杜康好客-v3-现状对照.md index 77500a8..fb6c44f 100644 --- a/杜康好客-v3-现状对照.md +++ b/杜康好客-v3-现状对照.md @@ -55,13 +55,14 @@ | 3.4.11 | [`开发计划`](./杜康好客-开发计划功能开发文档-v3.4.11.md) | ✅ | | 3.4.12 | [`工单迭代`](./杜康好客-v3.4.12-工单迭代开发文档.md) | ✅ | | 3.4.13 | [`体验优化`](./杜康好客-v3.4.13-体验优化开发文档.md) | ✅ 生产 | -| 3.4.14 | [`mini-user 门店体验 + 小程序可配置`](./杜康好客-v3.4.14-mini-user门店体验开发文档.md) | 🔶 开发中 | +| 3.4.14 | [`mini-user 门店体验 + 小程序可配置 + 测试白名单`](./杜康好客-v3.4.14-mini-user门店体验开发文档.md) | 🔶 开发中 | | 3.4.15 | [`mini-user 门店列表优化`](./杜康好客-v3.4.15-mini-user门店列表优化.md) | 🔶 开发中 | ## 5. 变更记录 | 日期 | 说明 | |------|------| +| 2026-08-07 | v3.4.14 增补统一测试白名单(不计账 / 限测可见 / Mock 旁路) | | 2026-08-06 | v3.4.15 mini-user 门店列表卡片优化(开发中) | | 2026-08-06 | v3.4.14 mini-user 门头/套餐详情(开发中) | | 2026-08-05 | v3.4.13 | diff --git a/杜康好客-v3.4.14-mini-user门店体验开发文档.md b/杜康好客-v3.4.14-mini-user门店体验开发文档.md index 9165363..ef7ce51 100644 --- a/杜康好客-v3.4.14-mini-user门店体验开发文档.md +++ b/杜康好客-v3.4.14-mini-user门店体验开发文档.md @@ -1,4 +1,4 @@ -# 杜康好客 · v3.4.14 mini-user 门店体验 + 小程序可配置项 +# 杜康好客 · v3.4.14 mini-user 门店体验 + 小程序可配置 + 测试白名单 > **2026-08-06** · **开发中** · PRD §0.6 · mini-user `3.4.14` · **未发版** @@ -10,6 +10,7 @@ | 门店详情·套餐 | 仅完整标题纵向列表;点击进入详情 | | 套餐详情页 | 实底导航让出胶囊区;内容区顶/左右留白;有图在门店名下;无图直接菜品 | | **系统设置·微信小程序** | Logo / 资质图 / 客服电话 / C 端 H5 / Mock 验证码 **可配置**,经 `client-config` 下发 | +| **测试白名单** | 全局手机号名单;账号/门店/订单/核销打标;不计四条结算;合并商品/门店可见性手机号;HQ 独立管理模块 | ## 页面路由 @@ -37,6 +38,36 @@ HQ → 系统设置 → **微信小程序配置**。启动时空缺键用 shared **下发**:`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 位置不随图高变化 @@ -45,6 +76,11 @@ HQ → 系统设置 → **微信小程序配置**。启动时空缺键用 shared - [ ] 无套餐时不展示区块;index 非法时友好提示 - [ ] HQ 微信小程序配置可改 Logo/电话/落地页/Mock 码;保存后 client-config 立即生效(无需重启) - [ ] mini-user 登录/我的/客服/分享图读取配置;未配置时回退代码默认常量 +- [ ] HQ「白名单管理」可增删手机号;可查看测试账号记录 +- [ ] 页顶三 Checkbox 控制短信/微信/支付跳过真实验证,与 `MOCK_*` 同源立即生效 +- [ ] 业务列表「过滤测试账号」勾选后不含测试数据 +- [ ] 旧可见性手机号已导入;限测商品/门店仅全局名单可见 +- [ ] 测试流水不进四条账单与门店打款 ## HQ 开发计划 diff --git a/杜康好客-知识库.md b/杜康好客-知识库.md index f20c768..119b62d 100644 --- a/杜康好客-知识库.md +++ b/杜康好客-知识库.md @@ -27,6 +27,23 @@ - 订单 Tab:待付款/已付款/已完成;物流详情(签收照/拨号/ETA) - 售后:客服入口;发票/四类型工单按 PRD Wave 进度 - 版本:`minClientVersion` 过低强制更新或退出 +- 「我的」头像昵称:`chooseAvatar` + `input type=nickname`(见下「踩坑」) + +### 踩坑 · 小程序 open-type 按钮点击无反应(必读,勿再回归) + +**现象**:「我的」完善资料弹层里点「选择头像」无反应(`open-type=chooseAvatar`);同类还有 `getPhoneNumber` / `contact` / `share`。 + +**根因**:弹层内容上写了 `onClick={(e) => e.stopPropagation()}`,Taro 编译为微信 **`catchtap`**,父级拦截后子级 `Button` 的原生 open-type **静默失效**。 + +**硬规则**: + +| 规则 | 说明 | +|------|------| +| 弹层结构 | 遮罩 backdrop 单独绑关闭;**sheet 上禁止** `stopPropagation` / `catchtap` | +| Button 内子节点 | `Image` 等加 `pointer-events: none`,勿抢触摸 | +| 自检 | 凡含 `openType=` 的 Button,向上检查祖先有无 catch 类事件 | + +实现参照:`apps/mini-user/src/pages/mine/index.tsx`;Cursor 规则:`.cursor/rules/mini-user-weapp-opentype.mdc`。 ## 3. 门店端(h5-shop)