merge(dev): v3.4.14 test whitelist and SUPER_ADMIN fix
CI / verify (push) Has been cancelled

This commit is contained in:
2026-08-07 15:46:47 +08:00
47 changed files with 1823 additions and 308 deletions
+3
View File
@@ -39,6 +39,9 @@ src/
## UI 约束
- 使用 shared-ui CSS 变量与组件,勿各端自造设计 token
- **mini-user 微信 `openType` Button**:祖先禁止 `e.stopPropagation()`Taro→`catchtap`,选头像/手机号等会静默失效);遮罩与 sheet 拆开绑关闭。见 `.cursor/rules/mini-user-weapp-opentype.mdc`
- C 端订单 **5 Tab**(含 pending_ship
- 门店列表仅 `OPEN` 状态
- 原型 `pages/` 只读;路由对照 `pages/ROUTE_MAP.md`
@@ -0,0 +1,46 @@
---
description: 微信小程序 open-type 按钮踩坑 — 禁止祖先 catchtap / stopPropagation
globs: apps/mini-user/**/*.{tsx,ts,css,scss}
alwaysApply: false
---
# mini-user · 微信 open-type 硬规则
## 禁止(必踩坑)
**现象**`Button openType="chooseAvatar" | getPhoneNumber | getUserInfo | share | contact"` 点击无反应、无回调。
**根因**:祖先节点上的 `onClick={(e) => e.stopPropagation()}` 在 Taro 微信端会编译成 **`catchtap`**,拦截子级 `button` 的原生 open-type 能力。
```tsx
// ❌ 弹层内容上 stopPropagation — 内部 chooseAvatar 会失效
<View className="mask" onClick={close}>
<View className="sheet" onClick={(e) => e.stopPropagation()}>
<Button openType="chooseAvatar" onChooseAvatar={...}>选头像</Button>
</View>
</View>
```
## 正确写法
遮罩与内容拆开:只在 **backdrop** 上关弹层,**sheet 不要**绑 stopPropagation / catchtap。
```tsx
// ✅
<View className="mask">
<View className="backdrop" onClick={close} />
<View className="sheet">
<Button openType="chooseAvatar" plain hoverClass="none" onChooseAvatar={...}>
...
</Button>
</View>
</View>
```
## 附加
- `Button` 内 `Image` / 文案加 `pointer-events: none`(或父级 `> * { pointer-events: none }`),避免抢触摸
- 同类能力:`getPhoneNumber`、`contact`、`share` 同样忌祖先 `catchtap`
- 详情见知识库「C 端 · 踩坑 · chooseAvatar」
参照实现:`apps/mini-user/src/pages/mine/index.tsx` 资料弹层。
+1
View File
@@ -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`
## 环境与发版
+1
View File
@@ -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
+2
View File
@@ -46,6 +46,7 @@ import PartnerLogsPage from './pages/PartnerLogsPage';
import WechatBindingsPage from './pages/WechatBindingsPage';
import HqPermissionsPage from './pages/HqPermissionsPage';
import SystemSettingsPage from './pages/SystemSettingsPage';
import TestWhitelistPage from './pages/TestWhitelistPage';
import WecomBotsPage from './pages/WecomBotsPage';
import WecomMessagePushesPage from './pages/WecomMessagePushesPage';
import WecomBotLogsPage from './pages/WecomBotLogsPage';
@@ -136,6 +137,7 @@ export default function App() {
<Route path="/deliveries" element={<DeliveriesPage />} />
<Route path="/deliveries/xiaofeixia" element={<XiaofeixiaTestPage />} />
<Route path="/hq-permissions" element={<HqPermissionsPage />} />
<Route path="/test-whitelist" element={<TestWhitelistPage />} />
<Route path="/system-settings" element={<SystemSettingsPage />} />
<Route path="/hq-accounts" element={<HqAccountsPage />} />
</Route>
@@ -146,6 +146,7 @@ const MENU_ITEMS: MenuProps['items'] = [
],
},
{ key: '/hq-permissions', icon: <LockOutlined />, label: '权限分配' },
{ key: '/test-whitelist', icon: <SafetyOutlined />, label: '白名单管理' },
{ key: '/system-settings', icon: <SettingOutlined />, label: '系统设置' },
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
];
@@ -215,6 +216,7 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
'/logs/third-party': 'logs',
'/logs/domain-events': 'logs',
'/hq-permissions': 'hq_permissions',
'/test-whitelist': 'test_whitelist',
'/system-settings': 'system_settings_any',
'/hq-accounts': 'hq_accounts',
};
+2
View File
@@ -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;
+21 -5
View File
@@ -59,6 +59,7 @@ type Row = {
accountCount: number;
subAccounts?: SubRow[];
createdAt: string;
isTest?: boolean;
};
type PartnerDetail = Row & {
@@ -124,14 +125,15 @@ export default function CityPartnersPage() {
const [createForm] = Form.useForm();
const [subForm] = Form.useForm();
const [subEditForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const [filters, setFilters] = useState<Record<string, string | boolean>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/partners',
() => {
const qs = new URLSearchParams();
if (filters.companyName) qs.set('companyName', filters.companyName);
if (filters.phone) qs.set('phone', filters.phone);
if (filters.cityId) qs.set('cityId', filters.cityId);
if (filters.companyName) qs.set('companyName', String(filters.companyName));
if (filters.phone) qs.set('phone', String(filters.phone));
if (filters.cityId) qs.set('cityId', String(filters.cityId));
if (filters.excludeTest) qs.set('excludeTest', 'true');
return qs;
},
[filters],
@@ -270,7 +272,18 @@ export default function CityPartnersPage() {
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
},
{ title: '公司名', dataIndex: 'companyName', ellipsis: true, width: 140 },
{ title: '主账号姓名', dataIndex: 'name', width: 100, ellipsis: true },
{
title: '主账号姓名',
dataIndex: 'name',
width: 120,
ellipsis: true,
render: (v, row) => (
<Space size={4}>
<span>{v}</span>
{row.isTest ? <Tag color="orange"></Tag> : null}
</Space>
),
},
{ title: '登录手机', dataIndex: 'phone', width: 120 },
{
title: '管辖',
@@ -387,6 +400,9 @@ export default function CityPartnersPage() {
<Form.Item name="phone" label="手机">
<Input allowClear placeholder="登录手机" />
</Form.Item>
<Form.Item name="excludeTest" valuePropName="checked">
<Checkbox></Checkbox>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
+16 -1
View File
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
import {
Alert,
Button,
Checkbox,
Collapse,
Descriptions,
Drawer,
@@ -225,6 +226,7 @@ export default function OrdersPage() {
if (values.cityId) qs.set('cityId', values.cityId);
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
if (values.fulfillmentHold) qs.set('fulfillmentHold', 'true');
if (values.excludeTest) qs.set('excludeTest', 'true');
const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`);
setData(res);
} finally {
@@ -432,7 +434,17 @@ export default function OrdersPage() {
}
const columns: ColumnsType<AdminOrderRow> = [
{ title: '订单号', dataIndex: 'orderNo', width: 180 },
{
title: '订单号',
dataIndex: 'orderNo',
width: 200,
render: (v, row) => (
<Space size={4}>
<span>{v}</span>
{row.isTest ? <Tag color="orange"></Tag> : null}
</Space>
),
},
{
title: '城市',
width: 90,
@@ -600,6 +612,9 @@ export default function OrdersPage() {
options={[{ value: true, label: '仅待确认大单' }]}
/>
</Form.Item>
<Form.Item name="excludeTest" valuePropName="checked">
<Checkbox></Checkbox>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit"></Button>
+7 -78
View File
@@ -1,4 +1,4 @@
import { useMemo, useRef, useState } from 'react';
import { useMemo, useState } from 'react';
import {
Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Popconfirm, Select, Space,
Switch, Table, Tabs, Tag, Typography, message,
@@ -67,13 +67,6 @@ type ProductFormValues = {
features?: Array<{ icon?: string; title?: string; desc?: string }>;
};
type UserPickRow = {
id: string;
phone?: string | null;
nickname?: string | null;
userNo?: string;
};
function mapDetailToForm(d: Record<string, unknown>) {
const detail = (d.detailContent ?? {}) as ProductDetailContentDto;
const row = d as Row;
@@ -112,10 +105,6 @@ function buildProductPayload(v: ProductFormValues) {
features: features.length ? features : undefined,
};
const visibilityPhones = (v.visibilityPhones ?? [])
.map((p) => String(p || '').replace(/\D/g, '').trim())
.filter(Boolean);
return {
barcode69: v.barcode69,
name: v.name,
@@ -131,7 +120,6 @@ function buildProductPayload(v: ProductFormValues) {
allowCrossCityDelivery:
v.allowOnlinePurchase === false ? false : v.allowCrossCityDelivery !== false,
visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled,
visibilityPhones,
coverUrl: v.coverUrl,
carouselUrls,
detailImageUrls,
@@ -213,33 +201,6 @@ function ProductDetailFields({ form, aromaType }: { form: FormInstance; aromaTyp
}
function VisibilityWhitelistFields({ form }: { form: FormInstance }) {
const [userOptions, setUserOptions] = useState<UserPickRow[]>([]);
const [userSearching, setUserSearching] = useState(false);
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
async function searchUsers(keyword: string) {
const q = keyword.trim();
if (searchTimer.current) clearTimeout(searchTimer.current);
if (!q) {
setUserOptions([]);
return;
}
searchTimer.current = setTimeout(() => {
void (async () => {
setUserSearching(true);
try {
const qs = new URLSearchParams({ page: '1', pageSize: '20', phone: q });
const res = await request<{ items: UserPickRow[] }>(`/admin/users?${qs}`);
setUserOptions((res.items ?? []).filter((u) => !!u.phone));
} catch {
setUserOptions([]);
} finally {
setUserSearching(false);
}
})();
}, 300);
}
const enabled = Form.useWatch('visibilityWhitelistEnabled', form);
return (
@@ -248,46 +209,14 @@ function VisibilityWhitelistFields({ form }: { form: FormInstance }) {
name="visibilityWhitelistEnabled"
label="可见白名单"
valuePropName="checked"
extra="开启后仅名单内手机号在 C 端可见/可购,用于在线测试"
extra="开启后仅全局测试白名单内手机号在 C 端可见/可购,用于在线测试"
>
<Switch checkedChildren="开" unCheckedChildren="关" />
</Form.Item>
{enabled ? (
<>
<Form.Item
name="visibilityPhones"
label="白名单手机号"
rules={[{ required: true, message: '请至少添加一个手机号' }]}
extra="可直接输入多个手机号回车添加,或从下方用户库选择"
>
<Select
mode="tags"
tokenSeparators={[',', ' ', '', ';', '']}
placeholder="输入手机号后回车"
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item label="从用户库添加">
<Select
showSearch
filterOption={false}
placeholder="按手机号搜索用户"
loading={userSearching}
options={userOptions.map((u) => ({
value: u.phone!,
label: `${u.phone}${u.nickname ? ` · ${u.nickname}` : ''}${u.userNo ? `${u.userNo}` : ''}`,
}))}
onSearch={searchUsers}
onSelect={(phone: string) => {
const cur = (form.getFieldValue('visibilityPhones') as string[] | undefined) ?? [];
if (!cur.includes(phone)) {
form.setFieldsValue({ visibilityPhones: [...cur, phone] });
}
}}
notFoundContent={userSearching ? '搜索中…' : '输入手机号搜索'}
/>
</Form.Item>
</>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
</Typography.Text>
) : null}
</>
);
@@ -419,8 +348,8 @@ export default function ProductsPage() {
title: '白名单',
dataIndex: 'visibilityWhitelistEnabled',
width: 90,
render: (v: boolean, row) =>
v ? <Tag color="orange">{row.visibilityPhones?.length ?? 0}</Tag> : <Tag></Tag>,
render: (v: boolean) =>
v ? <Tag color="orange"></Tag> : <Tag></Tag>,
},
{
title: '履约',
+25 -6
View File
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { Button, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd';
import { Button, Checkbox, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
import RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDescriptions';
@@ -14,6 +14,7 @@ type Row = {
settleAmount: number;
channel?: RedeemChannel;
createdAt: string;
isTest?: boolean;
user?: { userNo: string; phone: string | null; nickname?: string | null };
store?: { name: string; cityName: string };
coupon?: { couponNo: string };
@@ -26,14 +27,15 @@ function maskPhone(phone: string | null | undefined) {
export default function RedeemRecordsPage() {
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const [filters, setFilters] = useState<Record<string, string | boolean>>({});
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
'/admin/redeem-records',
() => {
const qs = new URLSearchParams();
if (filters.redeemNo) qs.set('redeemNo', filters.redeemNo);
if (filters.storeId) qs.set('storeId', filters.storeId);
if (filters.channel) qs.set('channel', filters.channel);
if (filters.redeemNo) qs.set('redeemNo', String(filters.redeemNo));
if (filters.storeId) qs.set('storeId', String(filters.storeId));
if (filters.channel) qs.set('channel', String(filters.channel));
if (filters.excludeTest) qs.set('excludeTest', 'true');
return qs;
},
[filters],
@@ -53,7 +55,21 @@ export default function RedeemRecordsPage() {
}
const columns: ColumnsType<Row> = [
{ title: '核销号', dataIndex: 'redeemNo', width: 180 },
{
title: '核销号',
dataIndex: 'redeemNo',
width: 200,
render: (v, row) => (
<span>
{v}
{row.isTest ? (
<Tag color="orange" style={{ marginLeft: 6 }}>
</Tag>
) : null}
</span>
),
},
{
title: '方式',
dataIndex: 'channel',
@@ -124,6 +140,9 @@ export default function RedeemRecordsPage() {
]}
/>
</Form.Item>
<Form.Item name="excludeTest" valuePropName="checked">
<Checkbox></Checkbox>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
+20 -5
View File
@@ -1,6 +1,6 @@
import { useState } from 'react';
import {
Button, Descriptions, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
Button, Checkbox, Descriptions, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request, type Paginated } from '../lib/api';
@@ -15,6 +15,7 @@ type Row = {
name: string;
status: string;
createdAt: string;
isTest?: boolean;
storeCount?: number;
staffCount?: number;
bankAccountName?: string | null;
@@ -30,13 +31,14 @@ type StoreOption = { id: string; name: string };
export default function StoreAccountsPage() {
const [form] = Form.useForm();
const [createForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const [filters, setFilters] = useState<Record<string, string | boolean>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/store-accounts',
() => {
const qs = new URLSearchParams();
if (filters.phone) qs.set('phone', filters.phone);
if (filters.status) qs.set('status', filters.status);
if (filters.phone) qs.set('phone', String(filters.phone));
if (filters.status) qs.set('status', String(filters.status));
if (filters.excludeTest) qs.set('excludeTest', 'true');
return qs;
},
[filters],
@@ -73,7 +75,17 @@ export default function StoreAccountsPage() {
}
const columns: ColumnsType<Row> = [
{ title: '姓名', dataIndex: 'name', width: 100 },
{
title: '姓名',
dataIndex: 'name',
width: 120,
render: (v, row) => (
<Space size={4}>
<span>{v}</span>
{row.isTest ? <Tag color="orange"></Tag> : null}
</Space>
),
},
{ title: '手机', dataIndex: 'phone', width: 120 },
{
title: '绑定门店',
@@ -162,6 +174,9 @@ export default function StoreAccountsPage() {
options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item name="excludeTest" valuePropName="checked">
<Checkbox></Checkbox>
</Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</Form>
<Table
+37 -83
View File
@@ -1,8 +1,9 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
Alert,
Button,
Checkbox,
Descriptions,
Drawer,
Form,
@@ -277,36 +278,7 @@ function StoreAuditMediaSection({ detail }: { detail: Record<string, unknown> })
);
}
type UserPickRow = { id: string; phone?: string | null; nickname?: string | null; userNo?: string | null };
function StoreVisibilityWhitelistFields({ form }: { form: FormInstance }) {
const [userOptions, setUserOptions] = useState<UserPickRow[]>([]);
const [userSearching, setUserSearching] = useState(false);
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
async function searchUsers(keyword: string) {
const q = keyword.trim();
if (searchTimer.current) clearTimeout(searchTimer.current);
if (!q) {
setUserOptions([]);
return;
}
searchTimer.current = setTimeout(() => {
void (async () => {
setUserSearching(true);
try {
const qs = new URLSearchParams({ page: '1', pageSize: '20', phone: q });
const res = await request<{ items: UserPickRow[] }>(`/admin/users?${qs}`);
setUserOptions((res.items ?? []).filter((u) => !!u.phone));
} catch {
setUserOptions([]);
} finally {
setUserSearching(false);
}
})();
}, 300);
}
const enabled = Form.useWatch('visibilityWhitelistEnabled', form);
return (
@@ -315,46 +287,14 @@ function StoreVisibilityWhitelistFields({ form }: { form: FormInstance }) {
name="visibilityWhitelistEnabled"
label="可见白名单"
valuePropName="checked"
extra="开启后仅名单内手机号在 C 端可见,用于在线测试"
extra="开启后仅全局测试白名单内手机号在 C 端可见,用于在线测试"
>
<Switch checkedChildren="开" unCheckedChildren="关" />
</Form.Item>
{enabled ? (
<>
<Form.Item
name="visibilityPhones"
label="白名单手机号"
rules={[{ required: true, message: '请至少添加一个手机号' }]}
extra="可直接输入多个手机号回车添加,或从下方用户库选择"
>
<Select
mode="tags"
tokenSeparators={[',', ' ', '', ';', '']}
placeholder="输入手机号后回车"
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item label="从用户库添加">
<Select
showSearch
filterOption={false}
placeholder="按手机号搜索用户"
loading={userSearching}
options={userOptions.map((u) => ({
value: u.phone!,
label: `${u.phone}${u.nickname ? ` · ${u.nickname}` : ''}${u.userNo ? `${u.userNo}` : ''}`,
}))}
onSearch={searchUsers}
onSelect={(phone: string) => {
const cur = (form.getFieldValue('visibilityPhones') as string[] | undefined) ?? [];
if (!cur.includes(phone)) {
form.setFieldsValue({ visibilityPhones: [...cur, phone] });
}
}}
notFoundContent={userSearching ? '搜索中…' : '输入手机号搜索'}
/>
</Form.Item>
</>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
</Typography.Text>
) : null}
</>
);
@@ -375,6 +315,7 @@ type StoreRow = {
createdAt: string;
visibilityWhitelistEnabled?: boolean;
visibilityPhones?: string[];
isTest?: boolean;
cityRef?: { name: string; code: string };
partner?: { id?: string; companyName?: string | null; name?: string | null; phone?: string | null };
account?: {
@@ -424,8 +365,8 @@ export default function StoresPage() {
const [form] = Form.useForm();
const [editForm] = Form.useForm();
const [createForm] = Form.useForm<StoreCreateForm>();
const [filters, setFilters] = useState<Record<string, string>>(() => {
const init: Record<string, string> = {};
const [filters, setFilters] = useState<Record<string, string | boolean>>(() => {
const init: Record<string, string | boolean> = {};
if (initialCityId) init.cityId = initialCityId;
if (initialPartnerId) init.partnerId = initialPartnerId;
return init;
@@ -434,12 +375,13 @@ export default function StoresPage() {
'/admin/stores',
() => {
const qs = new URLSearchParams();
if (filters.name) qs.set('name', filters.name);
if (filters.status) qs.set('status', filters.status);
if (filters.auditStatus) qs.set('auditStatus', filters.auditStatus);
if (filters.phone) qs.set('phone', filters.phone);
if (filters.cityId) qs.set('cityId', filters.cityId);
if (filters.partnerId) qs.set('partnerId', filters.partnerId);
if (filters.name) qs.set('name', String(filters.name));
if (filters.status) qs.set('status', String(filters.status));
if (filters.auditStatus) qs.set('auditStatus', String(filters.auditStatus));
if (filters.phone) qs.set('phone', String(filters.phone));
if (filters.cityId) qs.set('cityId', String(filters.cityId));
if (filters.partnerId) qs.set('partnerId', String(filters.partnerId));
if (filters.excludeTest) qs.set('excludeTest', 'true');
return qs;
},
[filters],
@@ -597,6 +539,7 @@ export default function StoresPage() {
visibilityPhones: Array.isArray(d.visibilityPhones)
? (d.visibilityPhones as string[])
: [],
isTest: !!d.isTest,
});
setDrawerOpen(true);
}
@@ -637,9 +580,7 @@ export default function StoresPage() {
bankAccountNo: v.bankAccountNo ?? null,
bankBranch: v.bankBranch ?? null,
visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled,
visibilityPhones: ((v.visibilityPhones as string[] | undefined) ?? [])
.map((p) => String(p || '').replace(/\D/g, '').trim())
.filter(Boolean),
isTest: !!v.isTest,
...(hasCoords
? { latitude: Number(v.latitude), longitude: Number(v.longitude) }
: {}),
@@ -813,9 +754,6 @@ export default function StoresPage() {
bankBranch: values.bankBranch.trim(),
settlementRate: Number(values.settlementRate ?? 60) / 100,
visibilityWhitelistEnabled: !!values.visibilityWhitelistEnabled,
visibilityPhones: (values.visibilityPhones ?? [])
.map((p: string) => String(p || '').replace(/\D/g, '').trim())
.filter(Boolean),
}),
});
message.success('门店已创建');
@@ -839,7 +777,12 @@ export default function StoresPage() {
title: '封面', dataIndex: 'coverUrl', width: 72,
render: (url) => url ? <Image src={url} width={48} height={48} style={{ objectFit: 'cover', borderRadius: 4 }} /> : '—',
},
{ title: '门店名', dataIndex: 'name', width: 140 },
{ title: '门店名', dataIndex: 'name', width: 160, render: (v, row) => (
<Space size={4}>
<span>{v}</span>
{row.isTest ? <Tag color="orange"></Tag> : null}
</Space>
) },
{
title: '分类',
width: 100,
@@ -879,8 +822,8 @@ export default function StoresPage() {
title: '可见',
dataIndex: 'visibilityWhitelistEnabled',
width: 90,
render: (v, row) =>
v ? <Tag color="orange">{row.visibilityPhones?.length ?? 0}</Tag> : <Tag></Tag>,
render: (v) =>
v ? <Tag color="orange"></Tag> : <Tag></Tag>,
},
{ title: '介绍', dataIndex: 'intro', width: 160, ellipsis: true, render: (v) => v || '—' },
{ title: '店长', dataIndex: ['account', 'name'], width: 90, render: (v) => v || '—' },
@@ -939,6 +882,9 @@ export default function StoresPage() {
options={Object.entries(STORE_AUDIT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item name="excludeTest" valuePropName="checked">
<Checkbox></Checkbox>
</Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
<Form.Item>
<Button
@@ -1176,6 +1122,14 @@ export default function StoresPage() {
</Form.Item>
</Space>
<StoreVisibilityWhitelistFields form={editForm} />
<Form.Item
name="isTest"
label="测试门店"
valuePropName="checked"
extra="测试门店核销不计入结算账单;联系电话命中全局白名单时会自动标记"
>
<Switch checkedChildren="是" unCheckedChildren="否" />
</Form.Item>
</>
),
},
@@ -0,0 +1,687 @@
import { useCallback, useEffect, useState } from 'react';
import {
Button,
Card,
Checkbox,
Descriptions,
Drawer,
Form,
Input,
Modal,
Popconfirm,
Select,
Space,
Table,
Tabs,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request, type Paginated } from '../lib/api';
import { fmtTime } from '../lib/constants';
const MOCK_KEYS = ['MOCK_SMS', 'MOCK_WECHAT', 'MOCK_PAY'] as const;
type PhoneRow = {
id: string;
phone: string;
note: string | null;
createdByHqId: string | null;
createdAt: string;
updatedAt: string;
};
type AccountType = 'user' | 'store_account' | 'partner' | 'store' | 'order';
type LinkedPayload = {
phone: PhoneRow;
users: Array<{ id: string; userNo: string; phone: string | null; nickname: string | null; isTest: boolean; status: number }>;
storeAccounts: Array<{ id: string; phone: string; name: string; isTest: boolean; status: string }>;
partners: Array<{ id: string; phone: string; name: string; companyName: string | null; isTest: boolean; status: string }>;
stores: Array<{ id: string; name: string; phone: string; isTest: boolean; status: string }>;
};
const ACCOUNT_TYPE_OPTIONS: { value: AccountType; label: string }[] = [
{ value: 'user', label: 'C 端用户' },
{ value: 'store_account', label: '门店账号' },
{ value: 'partner', label: '合伙人' },
{ value: 'store', label: '门店' },
{ value: 'order', label: '订单' },
];
export default function TestWhitelistPage() {
const [mockSms, setMockSms] = useState(false);
const [mockWechat, setMockWechat] = useState(false);
const [mockPay, setMockPay] = useState(false);
const [mockLoading, setMockLoading] = useState(true);
const [mockSaving, setMockSaving] = useState(false);
const [phoneForm] = Form.useForm();
const [phones, setPhones] = useState<Paginated<PhoneRow> | null>(null);
const [phonesLoading, setPhonesLoading] = useState(false);
const [phonePage, setPhonePage] = useState(1);
const [phonePageSize, setPhonePageSize] = useState(20);
const [phoneFilters, setPhoneFilters] = useState<{ phone?: string }>({});
const [addOpen, setAddOpen] = useState(false);
const [addForm] = Form.useForm();
const [editOpen, setEditOpen] = useState(false);
const [editRow, setEditRow] = useState<PhoneRow | null>(null);
const [editForm] = Form.useForm();
const [migrating, setMigrating] = useState(false);
const [accountType, setAccountType] = useState<AccountType>('user');
const [accountPhone, setAccountPhone] = useState('');
const [accounts, setAccounts] = useState<Paginated<Record<string, unknown>> | null>(null);
const [accountsLoading, setAccountsLoading] = useState(false);
const [accountPage, setAccountPage] = useState(1);
const [accountPageSize, setAccountPageSize] = useState(20);
const [linkedOpen, setLinkedOpen] = useState(false);
const [linkedLoading, setLinkedLoading] = useState(false);
const [linked, setLinked] = useState<LinkedPayload | null>(null);
async function loadMockFlags() {
setMockLoading(true);
try {
const cfg = await request<{ mockSms: boolean; mockWechat: boolean; mockPay: boolean }>(
'/admin/test-whitelist/mock-flags',
);
setMockSms(!!cfg.mockSms);
setMockWechat(!!cfg.mockWechat);
setMockPay(!!cfg.mockPay);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载 Mock 配置失败');
} finally {
setMockLoading(false);
}
}
async function saveMockFlags(next: { MOCK_SMS?: boolean; MOCK_WECHAT?: boolean; MOCK_PAY?: boolean }) {
const body: { mockSms?: boolean; mockWechat?: boolean; mockPay?: boolean } = {};
if (next.MOCK_SMS !== undefined) body.mockSms = next.MOCK_SMS;
if (next.MOCK_WECHAT !== undefined) body.mockWechat = next.MOCK_WECHAT;
if (next.MOCK_PAY !== undefined) body.mockPay = next.MOCK_PAY;
setMockSaving(true);
try {
const cfg = await request<{ mockSms: boolean; mockWechat: boolean; mockPay: boolean }>(
'/admin/test-whitelist/mock-flags',
{
method: 'PUT',
body: JSON.stringify(body),
},
);
setMockSms(!!cfg.mockSms);
setMockWechat(!!cfg.mockWechat);
setMockPay(!!cfg.mockPay);
message.success('已保存');
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
await loadMockFlags();
} finally {
setMockSaving(false);
}
}
const loadPhones = useCallback(async () => {
setPhonesLoading(true);
try {
const qs = new URLSearchParams({
page: String(phonePage),
pageSize: String(phonePageSize),
});
if (phoneFilters.phone) qs.set('phone', phoneFilters.phone);
const res = await request<Paginated<PhoneRow>>(`/admin/test-whitelist/phones?${qs}`);
setPhones(res);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载手机号名单失败');
} finally {
setPhonesLoading(false);
}
}, [phonePage, phonePageSize, phoneFilters]);
const loadAccounts = useCallback(async () => {
setAccountsLoading(true);
try {
const qs = new URLSearchParams({
type: accountType,
page: String(accountPage),
pageSize: String(accountPageSize),
});
if (accountPhone.trim()) qs.set('phone', accountPhone.trim());
const res = await request<Paginated<Record<string, unknown>>>(
`/admin/test-whitelist/accounts?${qs}`,
);
setAccounts(res);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载测试账号失败');
} finally {
setAccountsLoading(false);
}
}, [accountType, accountPage, accountPageSize, accountPhone]);
useEffect(() => {
void loadMockFlags();
}, []);
useEffect(() => {
void loadPhones();
}, [loadPhones]);
useEffect(() => {
void loadAccounts();
}, [loadAccounts]);
async function onAddPhone() {
const v = await addForm.validateFields();
try {
await request('/admin/test-whitelist/phones', {
method: 'POST',
body: JSON.stringify({ phone: v.phone, note: v.note || undefined }),
});
message.success('已添加');
setAddOpen(false);
addForm.resetFields();
setPhonePage(1);
void loadPhones();
void loadAccounts();
} catch (e) {
message.error(e instanceof Error ? e.message : '添加失败');
}
}
async function onEditPhone() {
if (!editRow) return;
const v = await editForm.validateFields();
try {
await request(`/admin/test-whitelist/phones/${editRow.id}`, {
method: 'PATCH',
body: JSON.stringify({ note: v.note ?? null }),
});
message.success('已更新');
setEditOpen(false);
setEditRow(null);
void loadPhones();
} catch (e) {
message.error(e instanceof Error ? e.message : '更新失败');
}
}
async function onDeletePhone(id: string) {
try {
await request(`/admin/test-whitelist/phones/${id}`, { method: 'DELETE' });
message.success('已删除');
void loadPhones();
void loadAccounts();
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败');
}
}
async function onMigrate() {
setMigrating(true);
try {
const res = await request<{ importedCandidates: number; added: number }>(
'/admin/test-whitelist/migrate-visibility',
{ method: 'POST' },
);
message.success(
`导入完成:候选 ${res.importedCandidates} 个,新增 ${res.added}`,
);
void loadPhones();
void loadAccounts();
} catch (e) {
message.error(e instanceof Error ? e.message : '导入失败');
} finally {
setMigrating(false);
}
}
async function openLinked(row: PhoneRow) {
setLinkedOpen(true);
setLinkedLoading(true);
setLinked(null);
try {
const res = await request<LinkedPayload>(`/admin/test-whitelist/phones/${row.id}/linked`);
setLinked(res);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载关联失败');
setLinkedOpen(false);
} finally {
setLinkedLoading(false);
}
}
const phoneColumns: ColumnsType<PhoneRow> = [
{ title: '手机号', dataIndex: 'phone', width: 140 },
{
title: '备注',
dataIndex: 'note',
ellipsis: true,
render: (v) => v || '—',
},
{
title: '创建时间',
dataIndex: 'createdAt',
width: 170,
render: fmtTime,
},
{
title: '操作',
width: 220,
render: (_, row) => (
<Space size={0}>
<Button type="link" size="small" onClick={() => void openLinked(row)}>
</Button>
<Button
type="link"
size="small"
onClick={() => {
setEditRow(row);
editForm.setFieldsValue({ note: row.note ?? '' });
setEditOpen(true);
}}
>
</Button>
<Popconfirm
title="确认移出白名单?"
description="将同步清除该手机号关联账号的测试标记"
okText="确认"
cancelText="取消"
okButtonProps={{ danger: true }}
onConfirm={() => void onDeletePhone(row.id)}
>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
</Space>
),
},
];
function accountColumns(): ColumnsType<Record<string, unknown>> {
if (accountType === 'user') {
return [
{ title: '用户编号', dataIndex: 'userNo', width: 120 },
{ title: '昵称', dataIndex: 'nickname', width: 100, render: (v) => (v as string) || '—' },
{ title: '手机', dataIndex: 'phone', width: 120 },
{
title: '标记',
dataIndex: 'isTest',
width: 80,
render: (v) => (v ? <Tag color="orange"></Tag> : '—'),
},
{ title: '注册', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
];
}
if (accountType === 'store_account') {
return [
{ title: '姓名', dataIndex: 'name', width: 100 },
{ title: '手机', dataIndex: 'phone', width: 120 },
{ title: '状态', dataIndex: 'status', width: 90 },
{
title: '标记',
dataIndex: 'isTest',
width: 80,
render: (v) => (v ? <Tag color="orange"></Tag> : '—'),
},
{ title: '创建', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
];
}
if (accountType === 'partner') {
return [
{ title: '姓名', dataIndex: 'name', width: 100 },
{ title: '公司', dataIndex: 'companyName', ellipsis: true, render: (v) => (v as string) || '—' },
{ title: '手机', dataIndex: 'phone', width: 120 },
{
title: '标记',
dataIndex: 'isTest',
width: 80,
render: (v) => (v ? <Tag color="orange"></Tag> : '—'),
},
{ title: '创建', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
];
}
if (accountType === 'store') {
return [
{ title: '门店名', dataIndex: 'name', width: 160, ellipsis: true },
{ title: '城市', dataIndex: 'cityName', width: 90 },
{ title: '电话', dataIndex: 'phone', width: 120 },
{ title: '状态', dataIndex: 'status', width: 90 },
{
title: '标记',
dataIndex: 'isTest',
width: 80,
render: (v) => (v ? <Tag color="orange"></Tag> : '—'),
},
{ title: '创建', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
];
}
return [
{ title: '订单号', dataIndex: 'orderNo', width: 180 },
{ title: '状态', dataIndex: 'status', width: 110 },
{
title: '实付',
dataIndex: 'payAmount',
width: 90,
render: (v) => `¥${v}`,
},
{ title: '收货手机', dataIndex: 'receiverPhone', width: 120 },
{
title: '用户手机',
width: 120,
render: (_, row) =>
(row.user as { phone?: string | null } | undefined)?.phone || '—',
},
{
title: '标记',
dataIndex: 'isTest',
width: 80,
render: (v) => (v ? <Tag color="orange"></Tag> : '—'),
},
{ title: '下单', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
];
}
return (
<div>
<Typography.Title level={4} style={{ marginTop: 0 }}>
</Typography.Title>
<Card size="small" loading={mockLoading} style={{ marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
Mock =
</Typography.Text>
<Space wrap>
<Checkbox
checked={mockSms}
disabled={mockSaving}
onChange={(e) => void saveMockFlags({ MOCK_SMS: e.target.checked })}
>
</Checkbox>
<Checkbox
checked={mockWechat}
disabled={mockSaving}
onChange={(e) => void saveMockFlags({ MOCK_WECHAT: e.target.checked })}
>
</Checkbox>
<Checkbox
checked={mockPay}
disabled={mockSaving}
onChange={(e) => void saveMockFlags({ MOCK_PAY: e.target.checked })}
>
</Checkbox>
</Space>
<Typography.Text type="secondary" style={{ display: 'block', marginTop: 8, fontSize: 12 }}>
{MOCK_KEYS.join(' / ')}
</Typography.Text>
</Card>
<Tabs
items={[
{
key: 'phones',
label: '手机号名单',
children: (
<>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }} wrap>
<Form
form={phoneForm}
layout="inline"
onFinish={(v) => {
setPhoneFilters({ phone: v.phone || undefined });
setPhonePage(1);
}}
>
<Form.Item name="phone" label="手机号">
<Input allowClear placeholder="模糊搜索" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
</Form>
<Space>
<Button
onClick={() => {
Modal.confirm({
title: '从可见性白名单导入',
content: '将商品/门店旧可见性手机号合并入全局名单(幂等),并同步测试标记。',
okText: '开始导入',
cancelText: '取消',
onOk: () => onMigrate(),
});
}}
loading={migrating}
>
</Button>
<Button type="primary" onClick={() => setAddOpen(true)}>
</Button>
</Space>
</Space>
<Table
rowKey="id"
loading={phonesLoading}
columns={phoneColumns}
dataSource={phones?.items ?? []}
pagination={{
current: phonePage,
pageSize: phonePageSize,
total: phones?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPhonePage(p);
setPhonePageSize(ps);
},
}}
/>
</>
),
},
{
key: 'accounts',
label: '测试账号记录',
children: (
<>
<Space style={{ marginBottom: 16 }} wrap>
<Select
style={{ width: 140 }}
value={accountType}
options={ACCOUNT_TYPE_OPTIONS}
onChange={(v: AccountType) => {
setAccountType(v);
setAccountPage(1);
}}
/>
<Input
allowClear
placeholder="按手机号筛选"
style={{ width: 160 }}
value={accountPhone}
onChange={(e) => setAccountPhone(e.target.value)}
onPressEnter={() => {
setAccountPage(1);
void loadAccounts();
}}
/>
<Button
type="primary"
onClick={() => {
if (accountPage !== 1) setAccountPage(1);
else void loadAccounts();
}}
>
</Button>
</Space>
<Table
rowKey="id"
loading={accountsLoading}
columns={accountColumns()}
dataSource={accounts?.items ?? []}
scroll={{ x: 900 }}
pagination={{
current: accountPage,
pageSize: accountPageSize,
total: accounts?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setAccountPage(p);
setAccountPageSize(ps);
},
}}
/>
</>
),
},
]}
/>
<Modal
title="添加白名单手机号"
open={addOpen}
onCancel={() => setAddOpen(false)}
onOk={() => void onAddPhone()}
destroyOnClose
>
<Form form={addForm} layout="vertical">
<Form.Item
name="phone"
label="手机号"
rules={[
{ required: true, message: '请输入手机号' },
{ pattern: /^1\d{10}$/, message: '请输入 11 位手机号' },
]}
>
<Input placeholder="1xxxxxxxxxx" maxLength={11} />
</Form.Item>
<Form.Item name="note" label="备注">
<Input.TextArea rows={2} maxLength={256} showCount />
</Form.Item>
</Form>
</Modal>
<Modal
title={`编辑备注 · ${editRow?.phone ?? ''}`}
open={editOpen}
onCancel={() => {
setEditOpen(false);
setEditRow(null);
}}
onOk={() => void onEditPhone()}
destroyOnClose
>
<Form form={editForm} layout="vertical">
<Form.Item name="note" label="备注">
<Input.TextArea rows={2} maxLength={256} showCount />
</Form.Item>
</Form>
</Modal>
<Drawer
title={linked ? `关联账号 · ${linked.phone.phone}` : '关联账号'}
open={linkedOpen}
onClose={() => setLinkedOpen(false)}
width={560}
destroyOnClose
>
{linkedLoading ? (
<Typography.Text type="secondary"></Typography.Text>
) : linked ? (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Descriptions size="small" column={1} bordered>
<Descriptions.Item label="手机号">{linked.phone.phone}</Descriptions.Item>
<Descriptions.Item label="备注">{linked.phone.note || '—'}</Descriptions.Item>
</Descriptions>
<div>
<Typography.Title level={5}>C {linked.users.length}</Typography.Title>
<Table
size="small"
rowKey="id"
pagination={false}
dataSource={linked.users}
columns={[
{ title: '编号', dataIndex: 'userNo' },
{ title: '昵称', dataIndex: 'nickname', render: (v) => v || '—' },
{
title: '测试',
dataIndex: 'isTest',
width: 70,
render: (v) => (v ? <Tag color="orange"></Tag> : '—'),
},
]}
/>
</div>
<div>
<Typography.Title level={5}>{linked.storeAccounts.length}</Typography.Title>
<Table
size="small"
rowKey="id"
pagination={false}
dataSource={linked.storeAccounts}
columns={[
{ title: '姓名', dataIndex: 'name' },
{ title: '状态', dataIndex: 'status', width: 90 },
{
title: '测试',
dataIndex: 'isTest',
width: 70,
render: (v) => (v ? <Tag color="orange"></Tag> : '—'),
},
]}
/>
</div>
<div>
<Typography.Title level={5}>{linked.partners.length}</Typography.Title>
<Table
size="small"
rowKey="id"
pagination={false}
dataSource={linked.partners}
columns={[
{ title: '姓名', dataIndex: 'name' },
{ title: '公司', dataIndex: 'companyName', render: (v) => v || '—' },
{
title: '测试',
dataIndex: 'isTest',
width: 70,
render: (v) => (v ? <Tag color="orange"></Tag> : '—'),
},
]}
/>
</div>
<div>
<Typography.Title level={5}>{linked.stores.length}</Typography.Title>
<Table
size="small"
rowKey="id"
pagination={false}
dataSource={linked.stores}
columns={[
{ title: '名称', dataIndex: 'name' },
{ title: '状态', dataIndex: 'status', width: 90 },
{
title: '测试',
dataIndex: 'isTest',
width: 70,
render: (v) => (v ? <Tag color="orange"></Tag> : '—'),
},
]}
/>
</div>
</Space>
) : null}
</Drawer>
</div>
);
}
+15 -1
View File
@@ -112,6 +112,7 @@ export default function UsersPage() {
if (values.status !== undefined && values.status !== '') {
qs.set('status', String(values.status));
}
if (values.excludeTest) qs.set('excludeTest', 'true');
const res = await request<Paginated<AdminUserRow>>(`/admin/users?${qs}`);
setData(res);
} finally {
@@ -237,7 +238,17 @@ export default function UsersPage() {
];
const columns: ColumnsType<AdminUserRow> = [
{ title: '用户编号', dataIndex: 'userNo', width: 120 },
{
title: '用户编号',
dataIndex: 'userNo',
width: 140,
render: (v, row) => (
<Space size={4}>
<span>{v}</span>
{row.isTest ? <Tag color="orange"></Tag> : null}
</Space>
),
},
{ title: '昵称', dataIndex: 'nickname', width: 100 },
{
title: '手机',
@@ -362,6 +373,9 @@ export default function UsersPage() {
{ value: 0, label: '停用' },
]} />
</Form.Item>
<Form.Item name="excludeTest" valuePropName="checked">
<Checkbox></Checkbox>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit"></Button>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 16 KiB

+8 -2
View File
@@ -524,8 +524,13 @@ export default function MinePage() {
{shouldRenderPageTabBar() ? <UserTabBar selected={3} /> : null}
{profileSheetOpen ? (
<View className="mine-profile-sheet-mask" onClick={() => !savingProfile && setProfileSheetOpen(false)}>
<View className="mine-profile-sheet" onClick={(e) => e.stopPropagation()}>
<View className="mine-profile-sheet-mask">
{/* 遮罩单独绑 tap,勿在含 chooseAvatar 的祖先上用 stopPropagation(会编译成 catchtap 导致选头像无反应) */}
<View
className="mine-profile-sheet-backdrop"
onClick={() => !savingProfile && setProfileSheetOpen(false)}
/>
<View className="mine-profile-sheet">
<Text className="mine-profile-sheet-title"></Text>
<Text className="mine-profile-sheet-hint">
@@ -534,6 +539,7 @@ export default function MinePage() {
className="mine-profile-avatar-btn"
openType="chooseAvatar"
hoverClass="none"
plain
onChooseAvatar={onChooseAvatar}
>
<View className="mine-profile-avatar-preview">
@@ -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: '再想想',
});
+16 -3
View File
@@ -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;
@@ -18,6 +18,7 @@ export const HQ_PERMISSION_CATALOG = [
{ key: 'llm_configs', label: '语言模型配置', group: '业务' },
{ key: 'knowledge_bases', label: '知识库', group: '业务' },
{ key: 'dev_plan', label: '开发计划', group: '业务' },
{ key: 'test_whitelist', label: '白名单管理', group: '业务' },
{ key: 'resources', label: 'OSS 资源库', group: '业务' },
{ key: 'logs', label: '日志', group: '业务' },
{ key: 'users_delete', label: '删除用户', group: '危险操作' },
@@ -116,6 +117,7 @@ export const HQ_ROLE_DEFAULT_PERMISSIONS: Record<string, HqPermissionKey[]> = {
'llm_configs',
'knowledge_bases',
'dev_plan',
'test_whitelist',
'resources',
'logs',
'system_settings_wechat_mini',
+34
View File
@@ -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")
}
+44
View File
@@ -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',
+2
View File
@@ -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,
@@ -7,11 +7,10 @@ import {
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import {
HQ_DANGEROUS_PERMISSION_KEYS,
HQ_PERMISSION_CATALOG,
HQ_ROLE_DEFAULT_PERMISSIONS,
expandHqPermissionKeys,
hasAnySystemSettingsPermission,
hqBasePermissionKeys,
type HqPermissionKey,
} from '@dukang/shared-types';
import { PrismaService } from '../prisma/prisma.module';
@@ -28,7 +27,7 @@ export const RequireAnySystemSettings = () =>
export class HqPermissionsResolver {
constructor(private readonly prisma: PrismaService) {}
async resolveEffectiveKeys(actorId: bigint): Promise<HqPermissionKey[]> {
private async loadActiveAccount(actorId: bigint) {
const account = await this.prisma.hqAccount.findUnique({
where: { id: actorId },
select: { adminRole: true, status: true },
@@ -36,6 +35,14 @@ export class HqPermissionsResolver {
if (!account || account.status !== 'ACTIVE') {
throw new ForbiddenException('HQ 账号不可用');
}
return account;
}
async resolveAccess(actorId: bigint): Promise<{
keys: HqPermissionKey[];
isSuperAdmin: boolean;
}> {
const account = await this.loadActiveAccount(actorId);
const userRows = await this.prisma.hqAccountPermission.findMany({
where: { hqAccountId: actorId },
@@ -44,12 +51,14 @@ export class HqPermissionsResolver {
const userKeys = userRows.map((r) => r.permissionKey);
if (account.adminRole === 'SUPER_ADMIN') {
// 超管含危险操作(删用户/订单/城市);其他角色仍需在权限分配中显式勾选
return expandHqPermissionKeys([
...hqBasePermissionKeys(),
...HQ_DANGEROUS_PERMISSION_KEYS,
...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<HqPermissionKey[]> {
const { keys } = await this.resolveAccess(actorId);
return keys;
}
}
@@ -79,7 +96,7 @@ export class HqPermissionGuard implements CanActivate {
if (!user || user.actorType !== 'HQ') {
throw new ForbiddenException('需要 HQ 权限');
}
const keys = await this.resolver.resolveEffectiveKeys(user.actorId);
const { keys, isSuperAdmin } = await this.resolver.resolveAccess(user.actorId);
req.hqPermissionKeys = keys;
const required =
@@ -89,6 +106,7 @@ export class HqPermissionGuard implements CanActivate {
]) ?? [];
if (!required.length) return true;
if (isSuperAdmin) return true;
if (required.includes('__any_system_settings__')) {
if (!hasAnySystemSettingsPermission(keys)) {
throw new ForbiddenException('无系统设置权限');
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { TestWhitelistService } from './test-whitelist.service';
@Global()
@Module({
providers: [TestWhitelistService],
exports: [TestWhitelistService],
})
export class TestWhitelistModule {}
@@ -0,0 +1,335 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.module';
import { serializeBigInt } from '../decorators/current-user.decorator';
export function normalizeTestPhone(phone: string | null | undefined): string {
return (phone || '').replace(/\D/g, '').trim();
}
export function assertMobilePhone(phone: string): string {
const p = normalizeTestPhone(phone);
if (!/^1\d{10}$/.test(p)) {
throw new BadRequestException(`手机号格式无效:${phone}`);
}
return p;
}
@Injectable()
export class TestWhitelistService {
constructor(private readonly prisma: PrismaService) {}
async isPhoneInWhitelist(phone: string | null | undefined): Promise<boolean> {
const p = normalizeTestPhone(phone);
if (!p) return false;
const row = await this.prisma.commonTestWhitelistPhone.findUnique({
where: { phone: p },
select: { id: true },
});
return !!row;
}
async assertGlobalWhitelistNotEmpty() {
const count = await this.prisma.commonTestWhitelistPhone.count();
if (count === 0) {
throw new BadRequestException('全局测试白名单为空,请先在「白名单管理」添加手机号');
}
}
async listPhones(query: { phone?: string; page?: number; pageSize?: number }) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.CommonTestWhitelistPhoneWhereInput = {};
if (query.phone) {
where.phone = { contains: normalizeTestPhone(query.phone) || query.phone };
}
const [items, total] = await Promise.all([
this.prisma.commonTestWhitelistPhone.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.commonTestWhitelistPhone.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize });
}
async addPhone(input: { phone: string; note?: string; createdByHqId?: bigint }) {
const phone = assertMobilePhone(input.phone);
const existing = await this.prisma.commonTestWhitelistPhone.findUnique({ where: { phone } });
if (existing) {
throw new BadRequestException('该手机号已在白名单中');
}
const row = await this.prisma.commonTestWhitelistPhone.create({
data: {
phone,
note: input.note?.trim() || null,
createdByHqId: input.createdByHqId ?? null,
},
});
await this.syncTestFlagsForPhone(phone, true);
return serializeBigInt(row);
}
async updatePhone(id: bigint, input: { note?: string | null }) {
const row = await this.prisma.commonTestWhitelistPhone.findUnique({ where: { id } });
if (!row) throw new NotFoundException('白名单记录不存在');
const updated = await this.prisma.commonTestWhitelistPhone.update({
where: { id },
data: { note: input.note === undefined ? undefined : input.note?.trim() || null },
});
return serializeBigInt(updated);
}
async removePhone(id: bigint) {
const row = await this.prisma.commonTestWhitelistPhone.findUnique({ where: { id } });
if (!row) throw new NotFoundException('白名单记录不存在');
await this.prisma.commonTestWhitelistPhone.delete({ where: { id } });
await this.syncTestFlagsForPhone(row.phone, false);
return { ok: true };
}
/** 同步账号/门店 isTest,并回填订单/核销快照 */
async syncTestFlagsForPhone(phone: string, isTest: boolean) {
const p = normalizeTestPhone(phone);
if (!p) return;
await this.prisma.user.updateMany({ where: { phone: p }, data: { isTest } });
await this.prisma.storeAccount.updateMany({ where: { phone: p }, data: { isTest } });
await this.prisma.partnerAccount.updateMany({ where: { phone: p }, data: { isTest } });
if (isTest) {
await this.prisma.store.updateMany({ where: { phone: p }, data: { isTest: true } });
} else {
// 仅清除「联系电话命中且当前不在白名单」的自动标;手动标的门店若电话已不在名单则保持 isTest(运营可再关)
// 简化:电话命中且移出名单时置 false;手动标的非该电话门店不受影响
await this.prisma.store.updateMany({ where: { phone: p }, data: { isTest: false } });
}
const users = await this.prisma.user.findMany({
where: { phone: p },
select: { id: true },
});
const userIds = users.map((u) => u.id);
if (userIds.length) {
await this.prisma.order.updateMany({
where: { userId: { in: userIds } },
data: { isTest },
});
}
const stores = await this.prisma.store.findMany({
where: { phone: p },
select: { id: true },
});
const storeIds = stores.map((s) => s.id);
if (userIds.length || storeIds.length) {
const or: Prisma.RedeemRecordWhereInput[] = [];
if (userIds.length) or.push({ userId: { in: userIds } });
if (storeIds.length) or.push({ storeId: { in: storeIds } });
await this.prisma.redeemRecord.updateMany({
where: { OR: or },
data: { isTest },
});
}
}
async listAccounts(query: {
type: 'user' | 'store_account' | 'partner' | 'store' | 'order';
phone?: string;
page?: number;
pageSize?: number;
}) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const phone = query.phone ? normalizeTestPhone(query.phone) : '';
if (query.type === 'user') {
const where: Prisma.UserWhereInput = { isTest: true };
if (phone) where.phone = { contains: phone };
const [items, total] = await Promise.all([
this.prisma.user.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
select: {
id: true,
userNo: true,
phone: true,
nickname: true,
status: true,
createdAt: true,
isTest: true,
},
}),
this.prisma.user.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize, type: query.type });
}
if (query.type === 'store_account') {
const where: Prisma.StoreAccountWhereInput = { isTest: true };
if (phone) where.phone = { contains: phone };
const [items, total] = await Promise.all([
this.prisma.storeAccount.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
select: {
id: true,
phone: true,
name: true,
status: true,
createdAt: true,
isTest: true,
},
}),
this.prisma.storeAccount.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize, type: query.type });
}
if (query.type === 'partner') {
const where: Prisma.PartnerAccountWhereInput = { isTest: true };
if (phone) where.phone = { contains: phone };
const [items, total] = await Promise.all([
this.prisma.partnerAccount.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
select: {
id: true,
phone: true,
name: true,
companyName: true,
status: true,
createdAt: true,
isTest: true,
},
}),
this.prisma.partnerAccount.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize, type: query.type });
}
if (query.type === 'store') {
const where: Prisma.StoreWhereInput = { isTest: true };
if (phone) where.phone = { contains: phone };
const [items, total] = await Promise.all([
this.prisma.store.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
select: {
id: true,
name: true,
phone: true,
status: true,
cityName: true,
createdAt: true,
isTest: true,
},
}),
this.prisma.store.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize, type: query.type });
}
const where: Prisma.OrderWhereInput = { isTest: true };
if (phone) {
where.OR = [
{ receiverPhone: { contains: phone } },
{ user: { phone: { contains: phone } } },
];
}
const [items, total] = await Promise.all([
this.prisma.order.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
select: {
id: true,
orderNo: true,
status: true,
payStatus: true,
payAmount: true,
receiverPhone: true,
createdAt: true,
isTest: true,
user: { select: { id: true, phone: true, userNo: true } },
},
}),
this.prisma.order.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize, type: query.type });
}
async linkedForPhoneId(id: bigint) {
const row = await this.prisma.commonTestWhitelistPhone.findUnique({ where: { id } });
if (!row) throw new NotFoundException('白名单记录不存在');
const phone = row.phone;
const [users, storeAccounts, partners, stores] = await Promise.all([
this.prisma.user.findMany({
where: { phone },
select: { id: true, userNo: true, phone: true, nickname: true, isTest: true, status: true },
}),
this.prisma.storeAccount.findMany({
where: { phone },
select: { id: true, phone: true, name: true, isTest: true, status: true },
}),
this.prisma.partnerAccount.findMany({
where: { phone },
select: { id: true, phone: true, name: true, companyName: true, isTest: true, status: true },
}),
this.prisma.store.findMany({
where: { phone },
select: { id: true, name: true, phone: true, isTest: true, status: true },
}),
]);
return serializeBigInt({
phone: row,
users,
storeAccounts,
partners,
stores,
});
}
/** 从旧商品/门店可见性子表导入全局名单(幂等) */
async migrateVisibilityPhones(createdByHqId?: bigint) {
const [productPhones, storePhones] = await Promise.all([
this.prisma.commonProductVisibilityPhone.findMany({ select: { phone: true } }),
this.prisma.storeVisibilityPhone.findMany({ select: { phone: true } }),
]);
const set = new Set<string>();
for (const row of [...productPhones, ...storePhones]) {
const p = normalizeTestPhone(row.phone);
if (/^1\d{10}$/.test(p)) set.add(p);
}
let added = 0;
for (const phone of set) {
const exists = await this.prisma.commonTestWhitelistPhone.findUnique({ where: { phone } });
if (exists) {
await this.syncTestFlagsForPhone(phone, true);
continue;
}
await this.prisma.commonTestWhitelistPhone.create({
data: {
phone,
note: '自可见性白名单迁移',
createdByHqId: createdByHqId ?? null,
},
});
await this.syncTestFlagsForPhone(phone, true);
added += 1;
}
return { importedCandidates: set.size, added };
}
}
@@ -1,6 +1,10 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import {
TestWhitelistService,
normalizeTestPhone,
} from '../../common/test-whitelist/test-whitelist.service';
import { groupResourcesByProductId, mapProductMedia } from './catalog.mapper';
export type CatalogViewer = {
@@ -10,13 +14,32 @@ export type CatalogViewer = {
bypassWhitelist?: boolean;
};
function normalizePhone(phone: string | null | undefined): string {
return (phone || '').replace(/\D/g, '').trim();
}
@Injectable()
export class CatalogService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly testWhitelist: TestWhitelistService,
) {}
private async whitelistPhoneSet(): Promise<Set<string>> {
const rows = await this.prisma.commonTestWhitelistPhone.findMany({
select: { phone: true },
});
return new Set(rows.map((r) => normalizeTestPhone(r.phone)).filter(Boolean));
}
isVisibleToViewer(
product: { visibilityWhitelistEnabled: boolean },
viewer?: CatalogViewer,
whitelistPhones?: Set<string>,
): boolean {
if (viewer?.bypassWhitelist) return true;
if (!product.visibilityWhitelistEnabled) return true;
const phone = normalizeTestPhone(viewer?.phone);
if (!phone) return false;
if (whitelistPhones) return whitelistPhones.has(phone);
return false;
}
async listCities() {
const cities = await this.prisma.commonCity.findMany({
@@ -58,11 +81,13 @@ export class CatalogService {
orderBy: { sortOrder: 'asc' },
include: {
coverResource: true,
visibilityPhones: { select: { phone: true } },
},
});
const visible = products.filter((p) => this.isVisibleToViewer(p, viewer));
const whitelistPhones = products.some((p) => p.visibilityWhitelistEnabled)
? await this.whitelistPhoneSet()
: new Set<string>();
const visible = products.filter((p) => this.isVisibleToViewer(p, viewer, whitelistPhones));
const productIds = visible.map((p) => p.id);
const resources = productIds.length
@@ -81,7 +106,7 @@ export class CatalogService {
return serializeBigInt(
visible.map((p) => {
const media = mapProductMedia(p, resourceMap.get(p.id.toString()) ?? []);
const { visibilityPhones: _phones, visibilityWhitelistEnabled: _wl, ...rest } = p;
const { visibilityWhitelistEnabled: _wl, ...rest } = p;
return {
...rest,
benefitAmount: p.benefitAmount ?? p.price,
@@ -98,11 +123,13 @@ export class CatalogService {
where: { id },
include: {
coverResource: true,
visibilityPhones: { select: { phone: true } },
},
});
if (!product) return null;
if (!this.isVisibleToViewer(product, viewer)) {
const whitelistPhones = product.visibilityWhitelistEnabled
? await this.whitelistPhoneSet()
: new Set<string>();
if (!this.isVisibleToViewer(product, viewer, whitelistPhones)) {
return null;
}
@@ -117,7 +144,7 @@ export class CatalogService {
});
const media = mapProductMedia(product, resources);
const { visibilityPhones: _phones, visibilityWhitelistEnabled: _wl, ...rest } = product;
const { visibilityWhitelistEnabled: _wl, ...rest } = product;
return serializeBigInt({
...rest,
benefitAmount: product.benefitAmount ?? product.price,
@@ -126,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);
}
}
@@ -25,6 +25,7 @@ import { AnalyticsService } from '../analytics/analytics.service';
import { UserAddressService } from './user-address.service';
import { ResourceService } from '../common/resource.service';
import { HqPermissionsResolver } from '../../common/guards/hq-permission.guard';
import { TestWhitelistService } from '../../common/test-whitelist/test-whitelist.service';
import type { User } from '@prisma/client';
@@ -67,8 +68,21 @@ export class AuthService {
private readonly userAddressService: UserAddressService,
@Inject(forwardRef(() => ResourceService)) private readonly resourceService: ResourceService,
private readonly hqPermissions: HqPermissionsResolver,
private readonly testWhitelist: TestWhitelistService,
) {}
/** 登录/绑号后按全局白名单同步 isTest */
private async syncTestFlagByPhone(phone: string) {
const isTest = await this.testWhitelist.isPhoneInWhitelist(phone);
await Promise.all([
this.prisma.user.updateMany({ where: { phone }, data: { isTest } }),
this.prisma.storeAccount.updateMany({ where: { phone }, data: { isTest } }),
this.prisma.partnerAccount.updateMany({ where: { phone }, data: { isTest } }),
this.prisma.store.updateMany({ where: { phone }, data: { isTest } }),
]);
return isTest;
}
private assertMobilePhone(phone: string) {
const trimmed = phone.trim();
if (!/^1[3-9]\d{9}$/.test(trimmed)) {
@@ -375,12 +389,14 @@ export class AuthService {
});
if (!user) {
const isTest = await this.testWhitelist.isPhoneInWhitelist(normalizedPhone);
user = await this.prisma.user.create({
data: {
phone: normalizedPhone,
phoneVerifiedAt: new Date(),
userNo: generateUserNo(),
nickname: `用户${normalizedPhone.slice(-4)}`,
isTest,
sourceType: source?.sourceType ?? 'ORGANIC',
sourceRefId: source?.sourceRefId,
sourceLabel: source?.sourceLabel,
@@ -401,6 +417,11 @@ export class AuthService {
include: { avatar: true },
});
}
await this.syncTestFlagByPhone(normalizedPhone);
user = await this.prisma.user.findUniqueOrThrow({
where: { id: user.id },
include: { avatar: true },
});
await this.assertActiveUser(user.id);
}
@@ -810,12 +831,14 @@ export class AuthService {
}
}
if (!user) {
const isTest = await this.testWhitelist.isPhoneInWhitelist(normalizedPhone);
user = await this.prisma.user.create({
data: {
phone: normalizedPhone,
phoneVerifiedAt: new Date(),
userNo: generateUserNo(),
nickname: `用户${normalizedPhone.slice(-4)}`,
isTest,
cityPreference: {
create: {
selectedCityCode: '410100',
@@ -852,6 +875,12 @@ export class AuthService {
if (!user) throw new BadRequestException('登录失败');
await this.syncTestFlagByPhone(normalizedPhone);
user = await this.prisma.user.findUniqueOrThrow({
where: { id: user.id },
include: { avatar: true },
});
this.analyticsService.trackOneSafe(user.id, clientApp, {
eventName: method === 'sms' ? 'sms_login' : 'wechat_phone_login',
extraJson: { method },
@@ -998,6 +1027,7 @@ export class AuthService {
if (!account) throw new BadRequestException('该手机号未绑定门店');
if (account.status !== 'ACTIVE') throw new BadRequestException('门店账号已停用');
if (!account.bindings.length) throw new BadRequestException('该账号未绑定任何门店');
await this.syncTestFlagByPhone(normalizedPhone);
await this.prisma.storeAccount.update({
where: { id: account.id },
data: { lastLoginAt: new Date() },
@@ -1032,6 +1062,7 @@ export class AuthService {
});
if (!account) throw new BadRequestException('未找到合伙人账号');
if (account.status !== 'ACTIVE') throw new BadRequestException('合伙人账号已停用');
await this.syncTestFlagByPhone(normalizedPhone);
const primary = await this.resolvePrimaryAccount(account.id);
await this.prisma.partnerAccount.update({
where: { id: account.id },
@@ -1,11 +1,9 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import {
HQ_DANGEROUS_PERMISSION_KEYS,
HQ_PERMISSION_CATALOG,
HQ_ROLE_DEFAULT_PERMISSIONS,
LEGACY_SYSTEM_SETTINGS_KEY,
expandHqPermissionKeys,
hqBasePermissionKeys,
type HqPermissionKey,
} from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
@@ -83,10 +81,9 @@ export class AdminHqPermissionsService {
const userPermissionKeys = expandHqPermissionKeys(userPerms.map((p) => p.permissionKey));
if (account.adminRole === 'SUPER_ADMIN') {
const rolePermissionKeys = [
...hqBasePermissionKeys(),
...HQ_DANGEROUS_PERMISSION_KEYS,
] as HqPermissionKey[];
const rolePermissionKeys = HQ_PERMISSION_CATALOG.map(
(p) => p.key,
) as HqPermissionKey[];
const effectivePermissionKeys = [
...new Set([...rolePermissionKeys, ...userPermissionKeys]),
] as HqPermissionKey[];
@@ -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({
@@ -42,6 +42,7 @@ export class AdminPartnersService {
if (query.phone) where.phone = { contains: query.phone };
if (query.cityId) where.cityId = BigInt(query.cityId);
if (query.partnerId) where.id = BigInt(query.partnerId);
if (query.excludeTest) where.isTest = false;
const [items, total] = await Promise.all([
this.prisma.partnerAccount.findMany({
@@ -5,6 +5,7 @@ import { serializeBigInt } from '../../common/decorators/current-user.decorator'
import { groupResourcesByProductId, mapProductMedia } from '../catalog/catalog.mapper';
import type { AdminProductsQueryDto } from './dto/admin-query.dto';
import type { CreateProductDto, UpdateProductDto } from './dto/admin-mutate.dto';
import { TestWhitelistService } from '../../common/test-whitelist/test-whitelist.service';
function normalizePhones(phones?: string[]): string[] {
if (!phones?.length) return [];
@@ -57,7 +58,10 @@ function resolveFulfillmentFlags(input: {
@Injectable()
export class AdminProductsService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly testWhitelist: TestWhitelistService,
) {}
async list(query: AdminProductsQueryDto) {
const page = query.page ?? 1;
@@ -140,6 +144,11 @@ export class AdminProductsService {
const phones = normalizePhones(dto.visibilityPhones);
const whitelistEnabled = !!dto.visibilityWhitelistEnabled;
if (whitelistEnabled) {
await this.testWhitelist.assertGlobalWhitelistNotEmpty();
}
// 手机号统一在「白名单管理」维护;此处忽略分实体 phones(兼容旧客户端传参)
void phones;
const product = await this.createWithGeneratedSku({
barcode69: dto.barcode69,
@@ -158,13 +167,6 @@ export class AdminProductsService {
...(dto.detailContent !== undefined
? { detailContent: dto.detailContent as Prisma.InputJsonValue }
: {}),
...(phones.length
? {
visibilityPhones: {
create: phones.map((phone) => ({ phone })),
},
}
: {}),
});
if (dto.coverUrl) {
@@ -225,9 +227,10 @@ export class AdminProductsService {
},
});
if (dto.visibilityPhones !== undefined) {
await this.syncVisibilityPhones(id, normalizePhones(dto.visibilityPhones));
if (dto.visibilityWhitelistEnabled) {
await this.testWhitelist.assertGlobalWhitelistNotEmpty();
}
// 分实体手机号已废弃;忽略 dto.visibilityPhones
if (dto.coverUrl) {
await this.syncCover(id, dto.coverUrl);
@@ -42,6 +42,7 @@ export class AdminRedeemService {
if (query.channel === 'SCAN' || query.channel === 'PHONE') {
where.channel = query.channel;
}
if (query.excludeTest) where.isTest = false;
const [items, total] = await Promise.all([
this.prisma.redeemRecord.findMany({
@@ -17,6 +17,7 @@ import type {
UpdateStoreMediaDto,
UpdateStoreStatusDto,
} from './dto/admin-mutate.dto';
import { TestWhitelistService } from '../../common/test-whitelist/test-whitelist.service';
/** 选填文案:空 / null / "null" 一律存库为 null,避免 String(null)==="null" */
function normalizeStoreOptionalText(value: unknown): string | null {
@@ -51,6 +52,7 @@ export class AdminStoresService {
private readonly partnerCityService: PartnerCityService,
private readonly storeCategoryService: StoreCategoryService,
private readonly analyticsService: AnalyticsService,
private readonly testWhitelist: TestWhitelistService,
) {}
async listStores(query: AdminStoresQueryDto) {
@@ -65,6 +67,7 @@ export class AdminStoresService {
if (query.cityId) where.cityId = BigInt(query.cityId);
if (query.partnerId) where.partnerAccountId = BigInt(query.partnerId);
if (query.phone) where.phone = { contains: query.phone };
if (query.excludeTest) where.isTest = false;
const [items, total] = await Promise.all([
this.prisma.store.findMany({
@@ -283,18 +286,7 @@ export class AdminStoresService {
? !!dto.visibilityWhitelistEnabled
: current.visibilityWhitelistEnabled;
if (nextEnabled) {
const phones =
dto.visibilityPhones !== undefined
? normalizeVisibilityPhones(dto.visibilityPhones)
: (
await this.prisma.storeVisibilityPhone.findMany({
where: { storeId: id },
select: { phone: true },
})
).map((p) => p.phone);
if (!phones.length) {
throw new BadRequestException('开启白名单时请至少添加一个手机号');
}
await this.testWhitelist.assertGlobalWhitelistNotEmpty();
}
}
const bankTouched =
@@ -372,18 +364,13 @@ export class AdminStoresService {
...(dto.visibilityWhitelistEnabled !== undefined
? { visibilityWhitelistEnabled: !!dto.visibilityWhitelistEnabled }
: {}),
...(dto.isTest !== undefined ? { isTest: !!dto.isTest } : {}),
...(latitude != null && longitude != null ? { latitude, longitude } : {}),
},
});
if (dto.visibilityPhones !== undefined) {
const phones = normalizeVisibilityPhones(dto.visibilityPhones);
await tx.storeVisibilityPhone.deleteMany({ where: { storeId: id } });
if (phones.length) {
await tx.storeVisibilityPhone.createMany({
data: phones.map((phone) => ({ storeId: id, phone })),
});
}
// 分实体手机号已废弃,忽略写入
}
if (dto.coverUrl) {
@@ -505,11 +492,14 @@ export class AdminStoresService {
throw new BadRequestException('好客权益券使用规则最多 1000 字');
}
const visibilityPhones = normalizeVisibilityPhones(dto.visibilityPhones);
const whitelistEnabled = !!dto.visibilityWhitelistEnabled;
if (whitelistEnabled && !visibilityPhones.length) {
throw new BadRequestException('开启白名单时请至少添加一个手机号');
if (whitelistEnabled) {
await this.testWhitelist.assertGlobalWhitelistNotEmpty();
}
const isTest =
dto.isTest !== undefined
? !!dto.isTest
: await this.testWhitelist.isPhoneInWhitelist(normalizedPhone);
const store = await this.prisma.store.create({
data: {
@@ -531,18 +521,12 @@ export class AdminStoresService {
openTime2: openTime2 || null,
closeTime2: closeTime2 || null,
visibilityWhitelistEnabled: whitelistEnabled,
isTest,
...(latitude != null && longitude != null ? { latitude, longitude } : {}),
status: 'OPEN',
auditStatus: 'APPROVED',
auditedAt: new Date(),
rejectReason: null,
...(visibilityPhones.length
? {
visibilityPhones: {
create: visibilityPhones.map((phone) => ({ phone })),
},
}
: {}),
},
});
@@ -746,6 +730,7 @@ export class AdminStoresService {
where.bindings = { some: { storeId: BigInt(query.storeId) } };
}
if (query.status) where.status = query.status as Prisma.EnumAccountStatusFilter['equals'];
if (query.excludeTest) where.isTest = false;
const [items, total] = await Promise.all([
this.prisma.storeAccount.findMany({
@@ -0,0 +1,167 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Put,
Query,
UseGuards,
} from '@nestjs/common';
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
import { Type } from 'class-transformer';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import {
HqPermissionGuard,
RequireHqPermissions,
} from '../../common/guards/hq-permission.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
import { TestWhitelistService } from '../../common/test-whitelist/test-whitelist.service';
import { SystemConfigService } from '../../common/system-config/system-config.service';
class ListPhonesQueryDto {
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize?: number = 20;
}
class AddPhoneDto {
@IsString()
phone!: string;
@IsOptional()
@IsString()
@MaxLength(256)
note?: string;
}
class UpdatePhoneDto {
@IsOptional()
@IsString()
@MaxLength(256)
note?: string | null;
}
class ListAccountsQueryDto {
@IsIn(['user', 'store_account', 'partner', 'store', 'order'])
type!: 'user' | 'store_account' | 'partner' | 'store' | 'order';
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize?: number = 20;
}
class UpdateMockFlagsDto {
@IsOptional()
@IsBoolean()
mockSms?: boolean;
@IsOptional()
@IsBoolean()
mockWechat?: boolean;
@IsOptional()
@IsBoolean()
mockPay?: boolean;
}
@Controller('admin/test-whitelist')
@UseGuards(HqAuthGuard, HqPermissionGuard)
@RequireHqPermissions('test_whitelist')
export class AdminTestWhitelistController {
constructor(
private readonly testWhitelist: TestWhitelistService,
private readonly systemConfig: SystemConfigService,
) {}
@Get('mock-flags')
async getMockFlags() {
const form = await this.systemConfig.getForm(['feature']);
const v = form.values;
return {
mockSms: v.MOCK_SMS === 'true' || v.MOCK_SMS === '1',
mockWechat: v.MOCK_WECHAT === 'true' || v.MOCK_WECHAT === '1',
mockPay: v.MOCK_PAY === 'true' || v.MOCK_PAY === '1',
};
}
@Put('mock-flags')
async updateMockFlags(@Body() dto: UpdateMockFlagsDto) {
const values: Record<string, string> = {};
if (dto.mockSms !== undefined) values.MOCK_SMS = String(dto.mockSms);
if (dto.mockWechat !== undefined) values.MOCK_WECHAT = String(dto.mockWechat);
if (dto.mockPay !== undefined) values.MOCK_PAY = String(dto.mockPay);
if (Object.keys(values).length) {
await this.systemConfig.update({ values }, ['feature']);
}
return this.getMockFlags();
}
@Get('phones')
listPhones(@Query() query: ListPhonesQueryDto) {
return this.testWhitelist.listPhones(query);
}
@Post('phones')
addPhone(@Body() dto: AddPhoneDto, @CurrentUser() actor: AuthUser) {
return this.testWhitelist.addPhone({
phone: dto.phone,
note: dto.note,
createdByHqId: actor.actorId,
});
}
@Patch('phones/:id')
updatePhone(@Param('id') id: string, @Body() dto: UpdatePhoneDto) {
return this.testWhitelist.updatePhone(BigInt(id), { note: dto.note });
}
@Delete('phones/:id')
removePhone(@Param('id') id: string) {
return this.testWhitelist.removePhone(BigInt(id));
}
@Get('accounts')
listAccounts(@Query() query: ListAccountsQueryDto) {
return this.testWhitelist.listAccounts(query);
}
@Get('phones/:id/linked')
linked(@Param('id') id: string) {
return this.testWhitelist.linkedForPhoneId(BigInt(id));
}
@Post('migrate-visibility')
migrate(@CurrentUser() actor: AuthUser) {
return this.testWhitelist.migrateVisibilityPhones(actor.actorId);
}
}
@@ -19,6 +19,7 @@ function mapAdminUserRow(u: {
sourceType: string;
sourceRefId: bigint | null;
sourceLabel: string | null;
isTest?: boolean;
createdAt: Date;
updatedAt: Date;
_count: { orders: number };
@@ -37,6 +38,7 @@ function mapAdminUserRow(u: {
sourceType: u.sourceType,
sourceRefId: u.sourceRefId,
sourceLabel: u.sourceLabel,
isTest: !!u.isTest,
createdAt: u.createdAt,
updatedAt: u.updatedAt,
orderCount: u._count.orders,
@@ -58,6 +60,7 @@ export class AdminUsersService {
if (query.status !== undefined) where.status = query.status;
if (query.phoneVerified === '1') where.phoneVerifiedAt = { not: null };
if (query.phoneVerified === '0') where.phoneVerifiedAt = null;
if (query.excludeTest) where.isTest = false;
const [items, total] = await Promise.all([
this.prisma.user.findMany({
@@ -78,6 +81,7 @@ export class AdminUsersService {
sourceType: true,
sourceRefId: true,
sourceLabel: true,
isTest: true,
createdAt: true,
updatedAt: true,
_count: { select: { orders: true } },
@@ -128,16 +128,21 @@ export class CreateStoreDto {
@Min(0)
avgPrice?: number;
/** 开启后仅白名单手机号在 C 端可见 */
/** 开启后仅全局测试白名单手机号在 C 端可见 */
@IsOptional()
@IsBoolean()
visibilityWhitelistEnabled?: boolean;
/** 可见白名单手机号列表 */
/** @deprecated 已并入全局白名单,忽略 */
@IsOptional()
@IsArray()
@IsString({ each: true })
visibilityPhones?: string[];
/** 测试门店标记 */
@IsOptional()
@IsBoolean()
isTest?: boolean;
}
export class UpdateStoreDto {
@@ -227,16 +232,21 @@ export class UpdateStoreDto {
@IsString()
bankBranch?: string | null;
/** 开启后仅白名单手机号在 C 端可见 */
/** 开启后仅全局测试白名单手机号在 C 端可见 */
@IsOptional()
@IsBoolean()
visibilityWhitelistEnabled?: boolean;
/** 可见白名单手机号列表 */
/** @deprecated 已并入全局白名单,忽略 */
@IsOptional()
@IsArray()
@IsString({ each: true })
visibilityPhones?: string[];
/** 测试门店标记 */
@IsOptional()
@IsBoolean()
isTest?: boolean;
}
export class CreateStoreAccountDto {
@@ -1,5 +1,12 @@
import { Type } from 'class-transformer';
import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
import { Type, Transform } from 'class-transformer';
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
function toOptionalBoolean(value: unknown): boolean | undefined {
if (value === undefined || value === null || value === '') return undefined;
if (value === true || value === 'true' || value === '1' || value === 1) return true;
if (value === false || value === 'false' || value === '0' || value === 0) return false;
return undefined;
}
export class PaginationQueryDto {
@IsOptional()
@@ -37,6 +44,12 @@ export class AdminUsersQueryDto extends PaginationQueryDto {
@Type(() => Number)
@IsIn([0, 1])
status?: number;
/** 勾选「过滤测试账号」时传 true */
@IsOptional()
@Transform(({ value }) => toOptionalBoolean(value))
@IsBoolean()
excludeTest?: boolean;
}
export class AdminOrdersQueryDto extends PaginationQueryDto {
@@ -75,6 +88,11 @@ export class AdminOrdersQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
createdTo?: string;
@IsOptional()
@Transform(({ value }) => toOptionalBoolean(value))
@IsBoolean()
excludeTest?: boolean;
}
/** 概览页用户/订单 ECharts 聚合筛选 */
@@ -128,6 +146,11 @@ export class AdminStoresQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
auditStatus?: string;
@IsOptional()
@Transform(({ value }) => toOptionalBoolean(value))
@IsBoolean()
excludeTest?: boolean;
}
export class AdminStoreAccountsQueryDto extends PaginationQueryDto {
@@ -142,6 +165,11 @@ export class AdminStoreAccountsQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@Transform(({ value }) => toOptionalBoolean(value))
@IsBoolean()
excludeTest?: boolean;
}
export class AdminPartnersQueryDto extends PaginationQueryDto {
@@ -164,6 +192,11 @@ export class AdminPartnersQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
partnerId?: string;
@IsOptional()
@Transform(({ value }) => toOptionalBoolean(value))
@IsBoolean()
excludeTest?: boolean;
}
export class AdminCityWarehousesQueryDto extends PaginationQueryDto {
@@ -247,6 +280,11 @@ export class AdminRedeemRecordsQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
channel?: string;
@IsOptional()
@Transform(({ value }) => toOptionalBoolean(value))
@IsBoolean()
excludeTest?: boolean;
}
export class AdminStoreRatingsQueryDto extends PaginationQueryDto {
@@ -80,6 +80,7 @@ import { AdminDevPlanController } from '../dev-plan/admin-dev-plan.controller';
import { AdminFulfillmentProvidersController } from './admin-fulfillment-providers.controller';
import { AdminDomainEventsController } from './admin-domain-events.controller';
import { AdminDomainEventsService } from './admin-domain-events.service';
import { AdminTestWhitelistController } from './admin-test-whitelist.controller';
@Module({
imports: [CityScopeModule, IamModule, TradeModule, AnalyticsModule, FulfillmentModule, BenefitModule, CommonModule, IntegrationsModule, WecomModule, LlmModule, RedeemModule, StoreModule, DevPlanModule],
@@ -128,6 +129,7 @@ import { AdminDomainEventsService } from './admin-domain-events.service';
AdminKnowledgeBasesController,
AdminDevPlanController,
AdminFulfillmentProvidersController,
AdminTestWhitelistController,
],
providers: [
AdminDashboardService,
@@ -180,6 +180,15 @@ export class RedeemService {
const settleAmount = calcRedeemSettleAmount(amount, settlementRate);
const redeemChannel = analyticsExtra?.channel === 'phone' ? 'PHONE' : 'SCAN';
const [userRow, storeRow] = await Promise.all([
this.prisma.user.findUnique({ where: { id: userId }, select: { isTest: true } }),
this.prisma.store.findUnique({
where: { id: account.storeId },
select: { isTest: true },
}),
]);
const isTest = !!(userRow?.isTest || storeRow?.isTest);
let record;
try {
record = await this.prisma.$transaction(async (tx) => {
@@ -194,6 +203,7 @@ export class RedeemService {
amount,
settleAmount,
channel: redeemChannel,
isTest,
allocations: {
create: normalizedAllocations.map((item, index) => ({
couponId: BigInt(item.couponId),
@@ -204,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;
});
@@ -1322,9 +1322,12 @@ export class SettlementService {
cityId: primary.cityId,
payStatus: 'PAID',
paidAt: { gte: periodStart, lte: periodEnd },
isTest: false,
},
});
const orderCommission = orders.reduce((sum, o) => {
const orderCommission = primary.isTest
? 0
: orders.reduce((sum, o) => {
if (o.partnerAccountIdAtPay) {
if (o.partnerAccountIdAtPay !== primary.id) return sum;
const rate = o.orderCommissionRateAtPay != null ? Number(o.orderCommissionRateAtPay) : 0;
@@ -1338,14 +1341,16 @@ export class SettlementService {
select: { id: true },
});
const storeIds = stores.map((s) => s.id);
const redeems = storeIds.length
? await this.prisma.redeemRecord.findMany({
const redeems =
primary.isTest || !storeIds.length
? []
: await this.prisma.redeemRecord.findMany({
where: {
storeId: { in: storeIds },
createdAt: { gte: periodStart, lte: periodEnd },
isTest: false,
},
})
: [];
});
const redeemCommission = redeems.reduce(
(sum, r) => sum + Number(r.amount) * redeemCommissionRate,
0,
@@ -1588,6 +1593,7 @@ export class SettlementService {
payStatus: 'PAID',
deliveryType: { in: ['LOCAL', 'CROSS_CITY'] },
completedAt: { gte: start, lt: end },
isTest: false,
},
orderBy: { completedAt: 'asc' },
});
@@ -1921,6 +1927,7 @@ export class SettlementService {
quantity: true,
deliveryType: true,
payStatus: true,
isTest: true,
},
},
},
@@ -1929,6 +1936,7 @@ export class SettlementService {
const eligible = deliveries.filter(
(d) =>
!d.order.isTest &&
d.order.payStatus === 'PAID' &&
(d.order.deliveryType === 'LOCAL' || d.order.deliveryType === 'CROSS_CITY'),
);
@@ -16,6 +16,10 @@ import { PartnerCityService } from '../city-scope/partner-city.service';
import { AuthService } from '../iam/auth.service';
import { StoreCategoryService } from './store-category.service';
import { TencentLbsProvider } from '../../integrations/map/tencent-lbs.provider';
import {
TestWhitelistService,
normalizeTestPhone,
} from '../../common/test-whitelist/test-whitelist.service';
function haversineMeters(lat1: number, lng1: number, lat2: number, lng2: number): number {
const toRad = (d: number) => (d * Math.PI) / 180;
@@ -43,10 +47,6 @@ export type StoreViewer = {
bypassWhitelist?: boolean;
};
function normalizePhone(phone: string | null | undefined): string {
return (phone || '').replace(/\D/g, '').trim();
}
function parseOptionalCoord(value: unknown, kind: 'lat' | 'lng' = 'lng'): number | null {
if (value == null || value === '') return null;
const n = typeof value === 'number' ? value : Number(value);
@@ -67,8 +67,16 @@ export class StoreService {
private readonly authService: AuthService,
private readonly storeCategoryService: StoreCategoryService,
private readonly tencentLbs: TencentLbsProvider,
private readonly testWhitelist: TestWhitelistService,
) {}
private async whitelistPhoneSet(): Promise<Set<string>> {
const rows = await this.prisma.commonTestWhitelistPhone.findMany({
select: { phone: true },
});
return new Set(rows.map((r) => normalizeTestPhone(r.phone)).filter(Boolean));
}
private storeAddressText(store: {
province?: string | null;
cityName?: string | null;
@@ -116,17 +124,16 @@ export class StoreService {
}
isVisibleToViewer(
store: {
visibilityWhitelistEnabled: boolean;
visibilityPhones: Array<{ phone: string }>;
},
store: { visibilityWhitelistEnabled: boolean },
viewer?: StoreViewer,
whitelistPhones?: Set<string>,
): boolean {
if (viewer?.bypassWhitelist) return true;
if (!store.visibilityWhitelistEnabled) return true;
const phone = normalizePhone(viewer?.phone);
const phone = normalizeTestPhone(viewer?.phone);
if (!phone) return false;
return store.visibilityPhones.some((row) => normalizePhone(row.phone) === phone);
if (whitelistPhones) return whitelistPhones.has(phone);
return false;
}
async listOpenStores(
@@ -145,12 +152,14 @@ export class StoreService {
include: {
category: true,
coverResource: true,
visibilityPhones: { select: { phone: true } },
},
orderBy: { createdAt: 'desc' },
});
const visible = stores.filter((s) => this.isVisibleToViewer(s, viewer));
const whitelistPhones = stores.some((s) => s.visibilityWhitelistEnabled)
? await this.whitelistPhoneSet()
: new Set<string>();
const visible = stores.filter((s) => this.isVisibleToViewer(s, viewer, whitelistPhones));
const hasUser =
userLat != null &&
@@ -167,7 +176,7 @@ export class StoreService {
const items: StoreListItem[] = [];
for (const store of visible) {
const coords = await this.ensureStoreCoordinates(store);
const { visibilityPhones: _phones, visibilityWhitelistEnabled: _wl, ...rest } = store;
const { visibilityWhitelistEnabled: _wl, ...rest } = store;
const mapped = mapStoreCompat({
...rest,
latitude: coords?.latitude ?? store.latitude,
@@ -197,10 +206,12 @@ export class StoreService {
include: {
category: true,
coverResource: true,
visibilityPhones: { select: { phone: true } },
},
});
if (!store || !this.isVisibleToViewer(store, viewer)) {
const whitelistPhones = store?.visibilityWhitelistEnabled
? await this.whitelistPhoneSet()
: new Set<string>();
if (!store || !this.isVisibleToViewer(store, viewer, whitelistPhones)) {
throw new NotFoundException('门店不存在');
}
const coords = await this.ensureStoreCoordinates(store);
@@ -212,7 +223,7 @@ export class StoreService {
where: { storeId: id },
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
});
const { visibilityPhones: _phones, visibilityWhitelistEnabled: _wl, ...rest } = store;
const { visibilityWhitelistEnabled: _wl, ...rest } = store;
return serializeBigInt(
mapStoreCompat({
...rest,
@@ -218,6 +218,11 @@ export class TradeService {
const promoCodeId =
attribution?.promoCode?.status === 'ACTIVE' ? attribution.promoCodeId : undefined;
const buyer = await this.prisma.user.findUnique({
where: { id: userId },
select: { isTest: true },
});
const order = await this.prisma.$transaction(async (tx) => {
const created = await tx.order.create({
data: {
@@ -258,6 +263,7 @@ export class TradeService {
benefitAmount: preview.benefitAmount,
payExpireAt,
promoCodeId,
isTest: !!buyer?.isTest,
},
include: { product: true, imageResource: true },
});
+1 -1
View File
@@ -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) |
---
+2 -1
View File
@@ -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 |
@@ -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 开发计划
+17
View File
@@ -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