merge(dev_jacy): v3.4.14 test whitelist and SUPER_ADMIN fix
This commit is contained in:
@@ -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` 资料弹层。
|
||||
@@ -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`。
|
||||
|
||||
## 环境与发版
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ V2 规划中的 `mini-partner` / `mini-hq` 非 V3 主交付。
|
||||
- 禁止 import `server/` 或另一个 `apps/*` 的源码
|
||||
- UI 共享组件优先 `@dukang/shared-ui`
|
||||
- C 端订单列表 **3 Tab**:`待付款 | 已付款 | 已完成`(Tab key: `pending_pay` / `paid` / `completed`)
|
||||
- **mini-user 微信 open-type**:含 `chooseAvatar` / `getPhoneNumber` 等的 `Button`,祖先禁止 `stopPropagation`(会编成 `catchtap` 导致点击无反应);见知识库 C 端踩坑、`.cursor/rules/mini-user-weapp-opentype.mdc`
|
||||
|
||||
## 新页面 workflow
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ import PartnerLogsPage from './pages/PartnerLogsPage';
|
||||
import WechatBindingsPage from './pages/WechatBindingsPage';
|
||||
import HqPermissionsPage from './pages/HqPermissionsPage';
|
||||
import SystemSettingsPage from './pages/SystemSettingsPage';
|
||||
import TestWhitelistPage from './pages/TestWhitelistPage';
|
||||
import WecomBotsPage from './pages/WecomBotsPage';
|
||||
import WecomMessagePushesPage from './pages/WecomMessagePushesPage';
|
||||
import WecomBotLogsPage from './pages/WecomBotLogsPage';
|
||||
@@ -136,6 +137,7 @@ export default function App() {
|
||||
<Route path="/deliveries" element={<DeliveriesPage />} />
|
||||
<Route path="/deliveries/xiaofeixia" element={<XiaofeixiaTestPage />} />
|
||||
<Route path="/hq-permissions" element={<HqPermissionsPage />} />
|
||||
<Route path="/test-whitelist" element={<TestWhitelistPage />} />
|
||||
<Route path="/system-settings" element={<SystemSettingsPage />} />
|
||||
<Route path="/hq-accounts" element={<HqAccountsPage />} />
|
||||
</Route>
|
||||
|
||||
@@ -146,6 +146,7 @@ const MENU_ITEMS: MenuProps['items'] = [
|
||||
],
|
||||
},
|
||||
{ key: '/hq-permissions', icon: <LockOutlined />, label: '权限分配' },
|
||||
{ key: '/test-whitelist', icon: <SafetyOutlined />, label: '白名单管理' },
|
||||
{ key: '/system-settings', icon: <SettingOutlined />, label: '系统设置' },
|
||||
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
|
||||
];
|
||||
@@ -215,6 +216,7 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
|
||||
'/logs/third-party': 'logs',
|
||||
'/logs/domain-events': 'logs',
|
||||
'/hq-permissions': 'hq_permissions',
|
||||
'/test-whitelist': 'test_whitelist',
|
||||
'/system-settings': 'system_settings_any',
|
||||
'/hq-accounts': 'hq_accounts',
|
||||
};
|
||||
|
||||
@@ -167,6 +167,7 @@ export type AdminUserRow = {
|
||||
sourceLabel: string | null;
|
||||
createdAt: string;
|
||||
orderCount: number;
|
||||
isTest?: boolean;
|
||||
};
|
||||
|
||||
export type AdminOrderItem = {
|
||||
@@ -199,6 +200,7 @@ export type AdminOrderRow = {
|
||||
isProxyOrder?: boolean;
|
||||
proxyPartnerName?: string | null;
|
||||
proxyPartnerPhone?: string | null;
|
||||
isTest?: boolean;
|
||||
user?: { id: string; userNo: string; phone: string | null; nickname: string | null };
|
||||
delivery?: {
|
||||
provider: string;
|
||||
|
||||
@@ -59,6 +59,7 @@ type Row = {
|
||||
accountCount: number;
|
||||
subAccounts?: SubRow[];
|
||||
createdAt: string;
|
||||
isTest?: boolean;
|
||||
};
|
||||
|
||||
type PartnerDetail = Row & {
|
||||
@@ -124,14 +125,15 @@ export default function CityPartnersPage() {
|
||||
const [createForm] = Form.useForm();
|
||||
const [subForm] = Form.useForm();
|
||||
const [subEditForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const [filters, setFilters] = useState<Record<string, string | boolean>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/partners',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.companyName) qs.set('companyName', filters.companyName);
|
||||
if (filters.phone) qs.set('phone', filters.phone);
|
||||
if (filters.cityId) qs.set('cityId', filters.cityId);
|
||||
if (filters.companyName) qs.set('companyName', String(filters.companyName));
|
||||
if (filters.phone) qs.set('phone', String(filters.phone));
|
||||
if (filters.cityId) qs.set('cityId', String(filters.cityId));
|
||||
if (filters.excludeTest) qs.set('excludeTest', 'true');
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
@@ -270,7 +272,18 @@ export default function CityPartnersPage() {
|
||||
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
|
||||
},
|
||||
{ title: '公司名', dataIndex: 'companyName', ellipsis: true, width: 140 },
|
||||
{ title: '主账号姓名', dataIndex: 'name', width: 100, ellipsis: true },
|
||||
{
|
||||
title: '主账号姓名',
|
||||
dataIndex: 'name',
|
||||
width: 120,
|
||||
ellipsis: true,
|
||||
render: (v, row) => (
|
||||
<Space size={4}>
|
||||
<span>{v}</span>
|
||||
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ title: '登录手机', dataIndex: 'phone', width: 120 },
|
||||
{
|
||||
title: '管辖',
|
||||
@@ -387,6 +400,9 @@ export default function CityPartnersPage() {
|
||||
<Form.Item name="phone" label="手机">
|
||||
<Input allowClear placeholder="登录手机" />
|
||||
</Form.Item>
|
||||
<Form.Item name="excludeTest" valuePropName="checked">
|
||||
<Checkbox>过滤测试账号</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">
|
||||
|
||||
@@ -3,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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Popconfirm, Select, Space,
|
||||
Switch, Table, Tabs, Tag, Typography, message,
|
||||
@@ -67,13 +67,6 @@ type ProductFormValues = {
|
||||
features?: Array<{ icon?: string; title?: string; desc?: string }>;
|
||||
};
|
||||
|
||||
type UserPickRow = {
|
||||
id: string;
|
||||
phone?: string | null;
|
||||
nickname?: string | null;
|
||||
userNo?: string;
|
||||
};
|
||||
|
||||
function mapDetailToForm(d: Record<string, unknown>) {
|
||||
const detail = (d.detailContent ?? {}) as ProductDetailContentDto;
|
||||
const row = d as Row;
|
||||
@@ -112,10 +105,6 @@ function buildProductPayload(v: ProductFormValues) {
|
||||
features: features.length ? features : undefined,
|
||||
};
|
||||
|
||||
const visibilityPhones = (v.visibilityPhones ?? [])
|
||||
.map((p) => String(p || '').replace(/\D/g, '').trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
barcode69: v.barcode69,
|
||||
name: v.name,
|
||||
@@ -131,7 +120,6 @@ function buildProductPayload(v: ProductFormValues) {
|
||||
allowCrossCityDelivery:
|
||||
v.allowOnlinePurchase === false ? false : v.allowCrossCityDelivery !== false,
|
||||
visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled,
|
||||
visibilityPhones,
|
||||
coverUrl: v.coverUrl,
|
||||
carouselUrls,
|
||||
detailImageUrls,
|
||||
@@ -213,33 +201,6 @@ function ProductDetailFields({ form, aromaType }: { form: FormInstance; aromaTyp
|
||||
}
|
||||
|
||||
function VisibilityWhitelistFields({ form }: { form: FormInstance }) {
|
||||
const [userOptions, setUserOptions] = useState<UserPickRow[]>([]);
|
||||
const [userSearching, setUserSearching] = useState(false);
|
||||
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
async function searchUsers(keyword: string) {
|
||||
const q = keyword.trim();
|
||||
if (searchTimer.current) clearTimeout(searchTimer.current);
|
||||
if (!q) {
|
||||
setUserOptions([]);
|
||||
return;
|
||||
}
|
||||
searchTimer.current = setTimeout(() => {
|
||||
void (async () => {
|
||||
setUserSearching(true);
|
||||
try {
|
||||
const qs = new URLSearchParams({ page: '1', pageSize: '20', phone: q });
|
||||
const res = await request<{ items: UserPickRow[] }>(`/admin/users?${qs}`);
|
||||
setUserOptions((res.items ?? []).filter((u) => !!u.phone));
|
||||
} catch {
|
||||
setUserOptions([]);
|
||||
} finally {
|
||||
setUserSearching(false);
|
||||
}
|
||||
})();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
const enabled = Form.useWatch('visibilityWhitelistEnabled', form);
|
||||
|
||||
return (
|
||||
@@ -248,46 +209,14 @@ function VisibilityWhitelistFields({ form }: { form: FormInstance }) {
|
||||
name="visibilityWhitelistEnabled"
|
||||
label="可见白名单"
|
||||
valuePropName="checked"
|
||||
extra="开启后仅名单内手机号在 C 端可见/可购,用于在线测试"
|
||||
extra="开启后仅全局测试白名单内手机号在 C 端可见/可购,用于在线测试"
|
||||
>
|
||||
<Switch checkedChildren="开" unCheckedChildren="关" />
|
||||
</Form.Item>
|
||||
{enabled ? (
|
||||
<>
|
||||
<Form.Item
|
||||
name="visibilityPhones"
|
||||
label="白名单手机号"
|
||||
rules={[{ required: true, message: '请至少添加一个手机号' }]}
|
||||
extra="可直接输入多个手机号回车添加,或从下方用户库选择"
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tokenSeparators={[',', ' ', ',', ';', ';']}
|
||||
placeholder="输入手机号后回车"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="从用户库添加">
|
||||
<Select
|
||||
showSearch
|
||||
filterOption={false}
|
||||
placeholder="按手机号搜索用户"
|
||||
loading={userSearching}
|
||||
options={userOptions.map((u) => ({
|
||||
value: u.phone!,
|
||||
label: `${u.phone}${u.nickname ? ` · ${u.nickname}` : ''}${u.userNo ? `(${u.userNo})` : ''}`,
|
||||
}))}
|
||||
onSearch={searchUsers}
|
||||
onSelect={(phone: string) => {
|
||||
const cur = (form.getFieldValue('visibilityPhones') as string[] | undefined) ?? [];
|
||||
if (!cur.includes(phone)) {
|
||||
form.setFieldsValue({ visibilityPhones: [...cur, phone] });
|
||||
}
|
||||
}}
|
||||
notFoundContent={userSearching ? '搜索中…' : '输入手机号搜索'}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
|
||||
可见手机号见白名单管理
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
@@ -419,8 +348,8 @@ export default function ProductsPage() {
|
||||
title: '白名单',
|
||||
dataIndex: 'visibilityWhitelistEnabled',
|
||||
width: 90,
|
||||
render: (v: boolean, row) =>
|
||||
v ? <Tag color="orange">限{row.visibilityPhones?.length ?? 0}人</Tag> : <Tag>公开</Tag>,
|
||||
render: (v: boolean) =>
|
||||
v ? <Tag color="orange">限测</Tag> : <Tag>公开</Tag>,
|
||||
},
|
||||
{
|
||||
title: '履约',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd';
|
||||
import { Button, Checkbox, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
|
||||
import RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDescriptions';
|
||||
@@ -14,6 +14,7 @@ type Row = {
|
||||
settleAmount: number;
|
||||
channel?: RedeemChannel;
|
||||
createdAt: string;
|
||||
isTest?: boolean;
|
||||
user?: { userNo: string; phone: string | null; nickname?: string | null };
|
||||
store?: { name: string; cityName: string };
|
||||
coupon?: { couponNo: string };
|
||||
@@ -26,14 +27,15 @@ function maskPhone(phone: string | null | undefined) {
|
||||
|
||||
export default function RedeemRecordsPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const [filters, setFilters] = useState<Record<string, string | boolean>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
|
||||
'/admin/redeem-records',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.redeemNo) qs.set('redeemNo', filters.redeemNo);
|
||||
if (filters.storeId) qs.set('storeId', filters.storeId);
|
||||
if (filters.channel) qs.set('channel', filters.channel);
|
||||
if (filters.redeemNo) qs.set('redeemNo', String(filters.redeemNo));
|
||||
if (filters.storeId) qs.set('storeId', String(filters.storeId));
|
||||
if (filters.channel) qs.set('channel', String(filters.channel));
|
||||
if (filters.excludeTest) qs.set('excludeTest', 'true');
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
@@ -53,7 +55,21 @@ export default function RedeemRecordsPage() {
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '核销号', dataIndex: 'redeemNo', width: 180 },
|
||||
{
|
||||
title: '核销号',
|
||||
dataIndex: 'redeemNo',
|
||||
width: 200,
|
||||
render: (v, row) => (
|
||||
<span>
|
||||
{v}
|
||||
{row.isTest ? (
|
||||
<Tag color="orange" style={{ marginLeft: 6 }}>
|
||||
测试
|
||||
</Tag>
|
||||
) : null}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '方式',
|
||||
dataIndex: 'channel',
|
||||
@@ -124,6 +140,9 @@ export default function RedeemRecordsPage() {
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="excludeTest" valuePropName="checked">
|
||||
<Checkbox>过滤测试账号</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button, Descriptions, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
|
||||
Button, Checkbox, Descriptions, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
@@ -15,6 +15,7 @@ type Row = {
|
||||
name: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
isTest?: boolean;
|
||||
storeCount?: number;
|
||||
staffCount?: number;
|
||||
bankAccountName?: string | null;
|
||||
@@ -30,13 +31,14 @@ type StoreOption = { id: string; name: string };
|
||||
export default function StoreAccountsPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const [filters, setFilters] = useState<Record<string, string | boolean>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/store-accounts',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.phone) qs.set('phone', filters.phone);
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.phone) qs.set('phone', String(filters.phone));
|
||||
if (filters.status) qs.set('status', String(filters.status));
|
||||
if (filters.excludeTest) qs.set('excludeTest', 'true');
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
@@ -73,7 +75,17 @@ export default function StoreAccountsPage() {
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '姓名', dataIndex: 'name', width: 100 },
|
||||
{
|
||||
title: '姓名',
|
||||
dataIndex: 'name',
|
||||
width: 120,
|
||||
render: (v, row) => (
|
||||
<Space size={4}>
|
||||
<span>{v}</span>
|
||||
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||
{
|
||||
title: '绑定门店',
|
||||
@@ -162,6 +174,9 @@ export default function StoreAccountsPage() {
|
||||
options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="excludeTest" valuePropName="checked">
|
||||
<Checkbox>过滤测试账号</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||
</Form>
|
||||
<Table
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
@@ -277,36 +278,7 @@ function StoreAuditMediaSection({ detail }: { detail: Record<string, unknown> })
|
||||
);
|
||||
}
|
||||
|
||||
type UserPickRow = { id: string; phone?: string | null; nickname?: string | null; userNo?: string | null };
|
||||
|
||||
function StoreVisibilityWhitelistFields({ form }: { form: FormInstance }) {
|
||||
const [userOptions, setUserOptions] = useState<UserPickRow[]>([]);
|
||||
const [userSearching, setUserSearching] = useState(false);
|
||||
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
async function searchUsers(keyword: string) {
|
||||
const q = keyword.trim();
|
||||
if (searchTimer.current) clearTimeout(searchTimer.current);
|
||||
if (!q) {
|
||||
setUserOptions([]);
|
||||
return;
|
||||
}
|
||||
searchTimer.current = setTimeout(() => {
|
||||
void (async () => {
|
||||
setUserSearching(true);
|
||||
try {
|
||||
const qs = new URLSearchParams({ page: '1', pageSize: '20', phone: q });
|
||||
const res = await request<{ items: UserPickRow[] }>(`/admin/users?${qs}`);
|
||||
setUserOptions((res.items ?? []).filter((u) => !!u.phone));
|
||||
} catch {
|
||||
setUserOptions([]);
|
||||
} finally {
|
||||
setUserSearching(false);
|
||||
}
|
||||
})();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
const enabled = Form.useWatch('visibilityWhitelistEnabled', form);
|
||||
|
||||
return (
|
||||
@@ -315,46 +287,14 @@ function StoreVisibilityWhitelistFields({ form }: { form: FormInstance }) {
|
||||
name="visibilityWhitelistEnabled"
|
||||
label="可见白名单"
|
||||
valuePropName="checked"
|
||||
extra="开启后仅名单内手机号在 C 端可见,用于在线测试"
|
||||
extra="开启后仅全局测试白名单内手机号在 C 端可见,用于在线测试"
|
||||
>
|
||||
<Switch checkedChildren="开" unCheckedChildren="关" />
|
||||
</Form.Item>
|
||||
{enabled ? (
|
||||
<>
|
||||
<Form.Item
|
||||
name="visibilityPhones"
|
||||
label="白名单手机号"
|
||||
rules={[{ required: true, message: '请至少添加一个手机号' }]}
|
||||
extra="可直接输入多个手机号回车添加,或从下方用户库选择"
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tokenSeparators={[',', ' ', ',', ';', ';']}
|
||||
placeholder="输入手机号后回车"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="从用户库添加">
|
||||
<Select
|
||||
showSearch
|
||||
filterOption={false}
|
||||
placeholder="按手机号搜索用户"
|
||||
loading={userSearching}
|
||||
options={userOptions.map((u) => ({
|
||||
value: u.phone!,
|
||||
label: `${u.phone}${u.nickname ? ` · ${u.nickname}` : ''}${u.userNo ? `(${u.userNo})` : ''}`,
|
||||
}))}
|
||||
onSearch={searchUsers}
|
||||
onSelect={(phone: string) => {
|
||||
const cur = (form.getFieldValue('visibilityPhones') as string[] | undefined) ?? [];
|
||||
if (!cur.includes(phone)) {
|
||||
form.setFieldsValue({ visibilityPhones: [...cur, phone] });
|
||||
}
|
||||
}}
|
||||
notFoundContent={userSearching ? '搜索中…' : '输入手机号搜索'}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
|
||||
可见手机号见白名单管理
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
@@ -375,6 +315,7 @@ type StoreRow = {
|
||||
createdAt: string;
|
||||
visibilityWhitelistEnabled?: boolean;
|
||||
visibilityPhones?: string[];
|
||||
isTest?: boolean;
|
||||
cityRef?: { name: string; code: string };
|
||||
partner?: { id?: string; companyName?: string | null; name?: string | null; phone?: string | null };
|
||||
account?: {
|
||||
@@ -424,8 +365,8 @@ export default function StoresPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm<StoreCreateForm>();
|
||||
const [filters, setFilters] = useState<Record<string, string>>(() => {
|
||||
const init: Record<string, string> = {};
|
||||
const [filters, setFilters] = useState<Record<string, string | boolean>>(() => {
|
||||
const init: Record<string, string | boolean> = {};
|
||||
if (initialCityId) init.cityId = initialCityId;
|
||||
if (initialPartnerId) init.partnerId = initialPartnerId;
|
||||
return init;
|
||||
@@ -434,12 +375,13 @@ export default function StoresPage() {
|
||||
'/admin/stores',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.name) qs.set('name', filters.name);
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.auditStatus) qs.set('auditStatus', filters.auditStatus);
|
||||
if (filters.phone) qs.set('phone', filters.phone);
|
||||
if (filters.cityId) qs.set('cityId', filters.cityId);
|
||||
if (filters.partnerId) qs.set('partnerId', filters.partnerId);
|
||||
if (filters.name) qs.set('name', String(filters.name));
|
||||
if (filters.status) qs.set('status', String(filters.status));
|
||||
if (filters.auditStatus) qs.set('auditStatus', String(filters.auditStatus));
|
||||
if (filters.phone) qs.set('phone', String(filters.phone));
|
||||
if (filters.cityId) qs.set('cityId', String(filters.cityId));
|
||||
if (filters.partnerId) qs.set('partnerId', String(filters.partnerId));
|
||||
if (filters.excludeTest) qs.set('excludeTest', 'true');
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
@@ -597,6 +539,7 @@ export default function StoresPage() {
|
||||
visibilityPhones: Array.isArray(d.visibilityPhones)
|
||||
? (d.visibilityPhones as string[])
|
||||
: [],
|
||||
isTest: !!d.isTest,
|
||||
});
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
@@ -637,9 +580,7 @@ export default function StoresPage() {
|
||||
bankAccountNo: v.bankAccountNo ?? null,
|
||||
bankBranch: v.bankBranch ?? null,
|
||||
visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled,
|
||||
visibilityPhones: ((v.visibilityPhones as string[] | undefined) ?? [])
|
||||
.map((p) => String(p || '').replace(/\D/g, '').trim())
|
||||
.filter(Boolean),
|
||||
isTest: !!v.isTest,
|
||||
...(hasCoords
|
||||
? { latitude: Number(v.latitude), longitude: Number(v.longitude) }
|
||||
: {}),
|
||||
@@ -813,9 +754,6 @@ export default function StoresPage() {
|
||||
bankBranch: values.bankBranch.trim(),
|
||||
settlementRate: Number(values.settlementRate ?? 60) / 100,
|
||||
visibilityWhitelistEnabled: !!values.visibilityWhitelistEnabled,
|
||||
visibilityPhones: (values.visibilityPhones ?? [])
|
||||
.map((p: string) => String(p || '').replace(/\D/g, '').trim())
|
||||
.filter(Boolean),
|
||||
}),
|
||||
});
|
||||
message.success('门店已创建');
|
||||
@@ -839,7 +777,12 @@ export default function StoresPage() {
|
||||
title: '封面', dataIndex: 'coverUrl', width: 72,
|
||||
render: (url) => url ? <Image src={url} width={48} height={48} style={{ objectFit: 'cover', borderRadius: 4 }} /> : '—',
|
||||
},
|
||||
{ title: '门店名', dataIndex: 'name', width: 140 },
|
||||
{ title: '门店名', dataIndex: 'name', width: 160, render: (v, row) => (
|
||||
<Space size={4}>
|
||||
<span>{v}</span>
|
||||
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
||||
</Space>
|
||||
) },
|
||||
{
|
||||
title: '分类',
|
||||
width: 100,
|
||||
@@ -879,8 +822,8 @@ export default function StoresPage() {
|
||||
title: '可见',
|
||||
dataIndex: 'visibilityWhitelistEnabled',
|
||||
width: 90,
|
||||
render: (v, row) =>
|
||||
v ? <Tag color="orange">限{row.visibilityPhones?.length ?? 0}人</Tag> : <Tag>公开</Tag>,
|
||||
render: (v) =>
|
||||
v ? <Tag color="orange">限测</Tag> : <Tag>公开</Tag>,
|
||||
},
|
||||
{ title: '介绍', dataIndex: 'intro', width: 160, ellipsis: true, render: (v) => v || '—' },
|
||||
{ title: '店长', dataIndex: ['account', 'name'], width: 90, render: (v) => v || '—' },
|
||||
@@ -939,6 +882,9 @@ export default function StoresPage() {
|
||||
options={Object.entries(STORE_AUDIT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="excludeTest" valuePropName="checked">
|
||||
<Checkbox>过滤测试账号</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||
<Form.Item>
|
||||
<Button
|
||||
@@ -1176,6 +1122,14 @@ export default function StoresPage() {
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<StoreVisibilityWhitelistFields form={editForm} />
|
||||
<Form.Item
|
||||
name="isTest"
|
||||
label="测试门店"
|
||||
valuePropName="checked"
|
||||
extra="测试门店核销不计入结算账单;联系电话命中全局白名单时会自动标记"
|
||||
>
|
||||
<Switch checkedChildren="是" unCheckedChildren="否" />
|
||||
</Form.Item>
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,687 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
|
||||
const MOCK_KEYS = ['MOCK_SMS', 'MOCK_WECHAT', 'MOCK_PAY'] as const;
|
||||
|
||||
type PhoneRow = {
|
||||
id: string;
|
||||
phone: string;
|
||||
note: string | null;
|
||||
createdByHqId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type AccountType = 'user' | 'store_account' | 'partner' | 'store' | 'order';
|
||||
|
||||
type LinkedPayload = {
|
||||
phone: PhoneRow;
|
||||
users: Array<{ id: string; userNo: string; phone: string | null; nickname: string | null; isTest: boolean; status: number }>;
|
||||
storeAccounts: Array<{ id: string; phone: string; name: string; isTest: boolean; status: string }>;
|
||||
partners: Array<{ id: string; phone: string; name: string; companyName: string | null; isTest: boolean; status: string }>;
|
||||
stores: Array<{ id: string; name: string; phone: string; isTest: boolean; status: string }>;
|
||||
};
|
||||
|
||||
const ACCOUNT_TYPE_OPTIONS: { value: AccountType; label: string }[] = [
|
||||
{ value: 'user', label: 'C 端用户' },
|
||||
{ value: 'store_account', label: '门店账号' },
|
||||
{ value: 'partner', label: '合伙人' },
|
||||
{ value: 'store', label: '门店' },
|
||||
{ value: 'order', label: '订单' },
|
||||
];
|
||||
|
||||
export default function TestWhitelistPage() {
|
||||
const [mockSms, setMockSms] = useState(false);
|
||||
const [mockWechat, setMockWechat] = useState(false);
|
||||
const [mockPay, setMockPay] = useState(false);
|
||||
const [mockLoading, setMockLoading] = useState(true);
|
||||
const [mockSaving, setMockSaving] = useState(false);
|
||||
|
||||
const [phoneForm] = Form.useForm();
|
||||
const [phones, setPhones] = useState<Paginated<PhoneRow> | null>(null);
|
||||
const [phonesLoading, setPhonesLoading] = useState(false);
|
||||
const [phonePage, setPhonePage] = useState(1);
|
||||
const [phonePageSize, setPhonePageSize] = useState(20);
|
||||
const [phoneFilters, setPhoneFilters] = useState<{ phone?: string }>({});
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [addForm] = Form.useForm();
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editRow, setEditRow] = useState<PhoneRow | null>(null);
|
||||
const [editForm] = Form.useForm();
|
||||
const [migrating, setMigrating] = useState(false);
|
||||
|
||||
const [accountType, setAccountType] = useState<AccountType>('user');
|
||||
const [accountPhone, setAccountPhone] = useState('');
|
||||
const [accounts, setAccounts] = useState<Paginated<Record<string, unknown>> | null>(null);
|
||||
const [accountsLoading, setAccountsLoading] = useState(false);
|
||||
const [accountPage, setAccountPage] = useState(1);
|
||||
const [accountPageSize, setAccountPageSize] = useState(20);
|
||||
|
||||
const [linkedOpen, setLinkedOpen] = useState(false);
|
||||
const [linkedLoading, setLinkedLoading] = useState(false);
|
||||
const [linked, setLinked] = useState<LinkedPayload | null>(null);
|
||||
|
||||
async function loadMockFlags() {
|
||||
setMockLoading(true);
|
||||
try {
|
||||
const cfg = await request<{ mockSms: boolean; mockWechat: boolean; mockPay: boolean }>(
|
||||
'/admin/test-whitelist/mock-flags',
|
||||
);
|
||||
setMockSms(!!cfg.mockSms);
|
||||
setMockWechat(!!cfg.mockWechat);
|
||||
setMockPay(!!cfg.mockPay);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载 Mock 配置失败');
|
||||
} finally {
|
||||
setMockLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMockFlags(next: { MOCK_SMS?: boolean; MOCK_WECHAT?: boolean; MOCK_PAY?: boolean }) {
|
||||
const body: { mockSms?: boolean; mockWechat?: boolean; mockPay?: boolean } = {};
|
||||
if (next.MOCK_SMS !== undefined) body.mockSms = next.MOCK_SMS;
|
||||
if (next.MOCK_WECHAT !== undefined) body.mockWechat = next.MOCK_WECHAT;
|
||||
if (next.MOCK_PAY !== undefined) body.mockPay = next.MOCK_PAY;
|
||||
setMockSaving(true);
|
||||
try {
|
||||
const cfg = await request<{ mockSms: boolean; mockWechat: boolean; mockPay: boolean }>(
|
||||
'/admin/test-whitelist/mock-flags',
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
setMockSms(!!cfg.mockSms);
|
||||
setMockWechat(!!cfg.mockWechat);
|
||||
setMockPay(!!cfg.mockPay);
|
||||
message.success('已保存');
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败');
|
||||
await loadMockFlags();
|
||||
} finally {
|
||||
setMockSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const loadPhones = useCallback(async () => {
|
||||
setPhonesLoading(true);
|
||||
try {
|
||||
const qs = new URLSearchParams({
|
||||
page: String(phonePage),
|
||||
pageSize: String(phonePageSize),
|
||||
});
|
||||
if (phoneFilters.phone) qs.set('phone', phoneFilters.phone);
|
||||
const res = await request<Paginated<PhoneRow>>(`/admin/test-whitelist/phones?${qs}`);
|
||||
setPhones(res);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载手机号名单失败');
|
||||
} finally {
|
||||
setPhonesLoading(false);
|
||||
}
|
||||
}, [phonePage, phonePageSize, phoneFilters]);
|
||||
|
||||
const loadAccounts = useCallback(async () => {
|
||||
setAccountsLoading(true);
|
||||
try {
|
||||
const qs = new URLSearchParams({
|
||||
type: accountType,
|
||||
page: String(accountPage),
|
||||
pageSize: String(accountPageSize),
|
||||
});
|
||||
if (accountPhone.trim()) qs.set('phone', accountPhone.trim());
|
||||
const res = await request<Paginated<Record<string, unknown>>>(
|
||||
`/admin/test-whitelist/accounts?${qs}`,
|
||||
);
|
||||
setAccounts(res);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载测试账号失败');
|
||||
} finally {
|
||||
setAccountsLoading(false);
|
||||
}
|
||||
}, [accountType, accountPage, accountPageSize, accountPhone]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadMockFlags();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadPhones();
|
||||
}, [loadPhones]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadAccounts();
|
||||
}, [loadAccounts]);
|
||||
|
||||
async function onAddPhone() {
|
||||
const v = await addForm.validateFields();
|
||||
try {
|
||||
await request('/admin/test-whitelist/phones', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone: v.phone, note: v.note || undefined }),
|
||||
});
|
||||
message.success('已添加');
|
||||
setAddOpen(false);
|
||||
addForm.resetFields();
|
||||
setPhonePage(1);
|
||||
void loadPhones();
|
||||
void loadAccounts();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '添加失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function onEditPhone() {
|
||||
if (!editRow) return;
|
||||
const v = await editForm.validateFields();
|
||||
try {
|
||||
await request(`/admin/test-whitelist/phones/${editRow.id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ note: v.note ?? null }),
|
||||
});
|
||||
message.success('已更新');
|
||||
setEditOpen(false);
|
||||
setEditRow(null);
|
||||
void loadPhones();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '更新失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function onDeletePhone(id: string) {
|
||||
try {
|
||||
await request(`/admin/test-whitelist/phones/${id}`, { method: 'DELETE' });
|
||||
message.success('已删除');
|
||||
void loadPhones();
|
||||
void loadAccounts();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function onMigrate() {
|
||||
setMigrating(true);
|
||||
try {
|
||||
const res = await request<{ importedCandidates: number; added: number }>(
|
||||
'/admin/test-whitelist/migrate-visibility',
|
||||
{ method: 'POST' },
|
||||
);
|
||||
message.success(
|
||||
`导入完成:候选 ${res.importedCandidates} 个,新增 ${res.added} 个`,
|
||||
);
|
||||
void loadPhones();
|
||||
void loadAccounts();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '导入失败');
|
||||
} finally {
|
||||
setMigrating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function openLinked(row: PhoneRow) {
|
||||
setLinkedOpen(true);
|
||||
setLinkedLoading(true);
|
||||
setLinked(null);
|
||||
try {
|
||||
const res = await request<LinkedPayload>(`/admin/test-whitelist/phones/${row.id}/linked`);
|
||||
setLinked(res);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载关联失败');
|
||||
setLinkedOpen(false);
|
||||
} finally {
|
||||
setLinkedLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const phoneColumns: ColumnsType<PhoneRow> = [
|
||||
{ title: '手机号', dataIndex: 'phone', width: 140 },
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'note',
|
||||
ellipsis: true,
|
||||
render: (v) => v || '—',
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 170,
|
||||
render: fmtTime,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 220,
|
||||
render: (_, row) => (
|
||||
<Space size={0}>
|
||||
<Button type="link" size="small" onClick={() => void openLinked(row)}>
|
||||
关联账号
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setEditRow(row);
|
||||
editForm.setFieldsValue({ note: row.note ?? '' });
|
||||
setEditOpen(true);
|
||||
}}
|
||||
>
|
||||
编辑备注
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确认移出白名单?"
|
||||
description="将同步清除该手机号关联账号的测试标记"
|
||||
okText="确认"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => void onDeletePhone(row.id)}
|
||||
>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
function accountColumns(): ColumnsType<Record<string, unknown>> {
|
||||
if (accountType === 'user') {
|
||||
return [
|
||||
{ title: '用户编号', dataIndex: 'userNo', width: 120 },
|
||||
{ title: '昵称', dataIndex: 'nickname', width: 100, render: (v) => (v as string) || '—' },
|
||||
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||
{
|
||||
title: '标记',
|
||||
dataIndex: 'isTest',
|
||||
width: 80,
|
||||
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
|
||||
},
|
||||
{ title: '注册', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
|
||||
];
|
||||
}
|
||||
if (accountType === 'store_account') {
|
||||
return [
|
||||
{ title: '姓名', dataIndex: 'name', width: 100 },
|
||||
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||
{ title: '状态', dataIndex: 'status', width: 90 },
|
||||
{
|
||||
title: '标记',
|
||||
dataIndex: 'isTest',
|
||||
width: 80,
|
||||
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
|
||||
},
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
|
||||
];
|
||||
}
|
||||
if (accountType === 'partner') {
|
||||
return [
|
||||
{ title: '姓名', dataIndex: 'name', width: 100 },
|
||||
{ title: '公司', dataIndex: 'companyName', ellipsis: true, render: (v) => (v as string) || '—' },
|
||||
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||
{
|
||||
title: '标记',
|
||||
dataIndex: 'isTest',
|
||||
width: 80,
|
||||
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
|
||||
},
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
|
||||
];
|
||||
}
|
||||
if (accountType === 'store') {
|
||||
return [
|
||||
{ title: '门店名', dataIndex: 'name', width: 160, ellipsis: true },
|
||||
{ title: '城市', dataIndex: 'cityName', width: 90 },
|
||||
{ title: '电话', dataIndex: 'phone', width: 120 },
|
||||
{ title: '状态', dataIndex: 'status', width: 90 },
|
||||
{
|
||||
title: '标记',
|
||||
dataIndex: 'isTest',
|
||||
width: 80,
|
||||
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
|
||||
},
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
|
||||
];
|
||||
}
|
||||
return [
|
||||
{ title: '订单号', dataIndex: 'orderNo', width: 180 },
|
||||
{ title: '状态', dataIndex: 'status', width: 110 },
|
||||
{
|
||||
title: '实付',
|
||||
dataIndex: 'payAmount',
|
||||
width: 90,
|
||||
render: (v) => `¥${v}`,
|
||||
},
|
||||
{ title: '收货手机', dataIndex: 'receiverPhone', width: 120 },
|
||||
{
|
||||
title: '用户手机',
|
||||
width: 120,
|
||||
render: (_, row) =>
|
||||
(row.user as { phone?: string | null } | undefined)?.phone || '—',
|
||||
},
|
||||
{
|
||||
title: '标记',
|
||||
dataIndex: 'isTest',
|
||||
width: 80,
|
||||
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
|
||||
},
|
||||
{ title: '下单', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
|
||||
];
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ marginTop: 0 }}>
|
||||
白名单管理
|
||||
</Typography.Title>
|
||||
|
||||
<Card size="small" loading={mockLoading} style={{ marginBottom: 16 }}>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
||||
Mock 开关(与系统设置同源,勾选 = 不做真实验证)
|
||||
</Typography.Text>
|
||||
<Space wrap>
|
||||
<Checkbox
|
||||
checked={mockSms}
|
||||
disabled={mockSaving}
|
||||
onChange={(e) => void saveMockFlags({ MOCK_SMS: e.target.checked })}
|
||||
>
|
||||
短信不做真实验证
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
checked={mockWechat}
|
||||
disabled={mockSaving}
|
||||
onChange={(e) => void saveMockFlags({ MOCK_WECHAT: e.target.checked })}
|
||||
>
|
||||
微信不做真实验证
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
checked={mockPay}
|
||||
disabled={mockSaving}
|
||||
onChange={(e) => void saveMockFlags({ MOCK_PAY: e.target.checked })}
|
||||
>
|
||||
支付不做真实验证
|
||||
</Checkbox>
|
||||
</Space>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginTop: 8, fontSize: 12 }}>
|
||||
配置键:{MOCK_KEYS.join(' / ')}
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'phones',
|
||||
label: '手机号名单',
|
||||
children: (
|
||||
<>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }} wrap>
|
||||
<Form
|
||||
form={phoneForm}
|
||||
layout="inline"
|
||||
onFinish={(v) => {
|
||||
setPhoneFilters({ phone: v.phone || undefined });
|
||||
setPhonePage(1);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="phone" label="手机号">
|
||||
<Input allowClear placeholder="模糊搜索" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Space>
|
||||
<Button
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: '从可见性白名单导入',
|
||||
content: '将商品/门店旧可见性手机号合并入全局名单(幂等),并同步测试标记。',
|
||||
okText: '开始导入',
|
||||
cancelText: '取消',
|
||||
onOk: () => onMigrate(),
|
||||
});
|
||||
}}
|
||||
loading={migrating}
|
||||
>
|
||||
从可见性白名单导入
|
||||
</Button>
|
||||
<Button type="primary" onClick={() => setAddOpen(true)}>
|
||||
添加手机号
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={phonesLoading}
|
||||
columns={phoneColumns}
|
||||
dataSource={phones?.items ?? []}
|
||||
pagination={{
|
||||
current: phonePage,
|
||||
pageSize: phonePageSize,
|
||||
total: phones?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPhonePage(p);
|
||||
setPhonePageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'accounts',
|
||||
label: '测试账号记录',
|
||||
children: (
|
||||
<>
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Select
|
||||
style={{ width: 140 }}
|
||||
value={accountType}
|
||||
options={ACCOUNT_TYPE_OPTIONS}
|
||||
onChange={(v: AccountType) => {
|
||||
setAccountType(v);
|
||||
setAccountPage(1);
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="按手机号筛选"
|
||||
style={{ width: 160 }}
|
||||
value={accountPhone}
|
||||
onChange={(e) => setAccountPhone(e.target.value)}
|
||||
onPressEnter={() => {
|
||||
setAccountPage(1);
|
||||
void loadAccounts();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
if (accountPage !== 1) setAccountPage(1);
|
||||
else void loadAccounts();
|
||||
}}
|
||||
>
|
||||
查询
|
||||
</Button>
|
||||
</Space>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={accountsLoading}
|
||||
columns={accountColumns()}
|
||||
dataSource={accounts?.items ?? []}
|
||||
scroll={{ x: 900 }}
|
||||
pagination={{
|
||||
current: accountPage,
|
||||
pageSize: accountPageSize,
|
||||
total: accounts?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setAccountPage(p);
|
||||
setAccountPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="添加白名单手机号"
|
||||
open={addOpen}
|
||||
onCancel={() => setAddOpen(false)}
|
||||
onOk={() => void onAddPhone()}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={addForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="phone"
|
||||
label="手机号"
|
||||
rules={[
|
||||
{ required: true, message: '请输入手机号' },
|
||||
{ pattern: /^1\d{10}$/, message: '请输入 11 位手机号' },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="1xxxxxxxxxx" maxLength={11} />
|
||||
</Form.Item>
|
||||
<Form.Item name="note" label="备注">
|
||||
<Input.TextArea rows={2} maxLength={256} showCount />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`编辑备注 · ${editRow?.phone ?? ''}`}
|
||||
open={editOpen}
|
||||
onCancel={() => {
|
||||
setEditOpen(false);
|
||||
setEditRow(null);
|
||||
}}
|
||||
onOk={() => void onEditPhone()}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="note" label="备注">
|
||||
<Input.TextArea rows={2} maxLength={256} showCount />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Drawer
|
||||
title={linked ? `关联账号 · ${linked.phone.phone}` : '关联账号'}
|
||||
open={linkedOpen}
|
||||
onClose={() => setLinkedOpen(false)}
|
||||
width={560}
|
||||
destroyOnClose
|
||||
>
|
||||
{linkedLoading ? (
|
||||
<Typography.Text type="secondary">加载中…</Typography.Text>
|
||||
) : linked ? (
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Descriptions size="small" column={1} bordered>
|
||||
<Descriptions.Item label="手机号">{linked.phone.phone}</Descriptions.Item>
|
||||
<Descriptions.Item label="备注">{linked.phone.note || '—'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<div>
|
||||
<Typography.Title level={5}>C 端用户({linked.users.length})</Typography.Title>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
dataSource={linked.users}
|
||||
columns={[
|
||||
{ title: '编号', dataIndex: 'userNo' },
|
||||
{ title: '昵称', dataIndex: 'nickname', render: (v) => v || '—' },
|
||||
{
|
||||
title: '测试',
|
||||
dataIndex: 'isTest',
|
||||
width: 70,
|
||||
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Title level={5}>门店账号({linked.storeAccounts.length})</Typography.Title>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
dataSource={linked.storeAccounts}
|
||||
columns={[
|
||||
{ title: '姓名', dataIndex: 'name' },
|
||||
{ title: '状态', dataIndex: 'status', width: 90 },
|
||||
{
|
||||
title: '测试',
|
||||
dataIndex: 'isTest',
|
||||
width: 70,
|
||||
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Title level={5}>合伙人({linked.partners.length})</Typography.Title>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
dataSource={linked.partners}
|
||||
columns={[
|
||||
{ title: '姓名', dataIndex: 'name' },
|
||||
{ title: '公司', dataIndex: 'companyName', render: (v) => v || '—' },
|
||||
{
|
||||
title: '测试',
|
||||
dataIndex: 'isTest',
|
||||
width: 70,
|
||||
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Title level={5}>门店({linked.stores.length})</Typography.Title>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
dataSource={linked.stores}
|
||||
columns={[
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{ title: '状态', dataIndex: 'status', width: 90 },
|
||||
{
|
||||
title: '测试',
|
||||
dataIndex: 'isTest',
|
||||
width: 70,
|
||||
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Space>
|
||||
) : null}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -112,6 +112,7 @@ export default function UsersPage() {
|
||||
if (values.status !== undefined && values.status !== '') {
|
||||
qs.set('status', String(values.status));
|
||||
}
|
||||
if (values.excludeTest) qs.set('excludeTest', 'true');
|
||||
const res = await request<Paginated<AdminUserRow>>(`/admin/users?${qs}`);
|
||||
setData(res);
|
||||
} finally {
|
||||
@@ -237,7 +238,17 @@ export default function UsersPage() {
|
||||
];
|
||||
|
||||
const columns: ColumnsType<AdminUserRow> = [
|
||||
{ title: '用户编号', dataIndex: 'userNo', width: 120 },
|
||||
{
|
||||
title: '用户编号',
|
||||
dataIndex: 'userNo',
|
||||
width: 140,
|
||||
render: (v, row) => (
|
||||
<Space size={4}>
|
||||
<span>{v}</span>
|
||||
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ title: '昵称', dataIndex: 'nickname', width: 100 },
|
||||
{
|
||||
title: '手机',
|
||||
@@ -362,6 +373,9 @@ export default function UsersPage() {
|
||||
{ value: 0, label: '停用' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="excludeTest" valuePropName="checked">
|
||||
<Checkbox>过滤测试账号</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">查询</Button>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 16 KiB |
@@ -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: '再想想',
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -812,6 +812,21 @@ model CommonProductVisibilityPhone {
|
||||
@@map("common_product_visibility_phone")
|
||||
}
|
||||
|
||||
/// 全局测试白名单手机号(测试账号 + 限测商品/门店可见)
|
||||
model CommonTestWhitelistPhone {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
phone String @unique @db.VarChar(20)
|
||||
note String? @db.VarChar(256)
|
||||
createdByHqId BigInt? @map("created_by_hq_id") @db.UnsignedBigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
createdBy HqAccount? @relation("TestWhitelistCreatedBy", fields: [createdByHqId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([createdAt])
|
||||
@@map("common_test_whitelist_phone")
|
||||
}
|
||||
|
||||
model CommonProductDetailTemplate {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
code String @unique @db.VarChar(32)
|
||||
@@ -1043,6 +1058,8 @@ model PartnerAccount {
|
||||
bankBranch String? @map("bank_branch") @db.VarChar(128)
|
||||
weeklyStoreTarget Int? @default(20) @map("weekly_store_target")
|
||||
managedWarehouseId BigInt? @unique @map("managed_warehouse_id") @db.UnsignedBigInt
|
||||
/// 测试合伙人账号
|
||||
isTest Boolean @default(false) @map("is_test")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
@@ -1059,6 +1076,7 @@ model PartnerAccount {
|
||||
@@index([parentAccountId])
|
||||
@@index([wxOpenId])
|
||||
@@index([contactPhone])
|
||||
@@index([isTest])
|
||||
@@map("partner_account")
|
||||
}
|
||||
|
||||
@@ -1102,6 +1120,7 @@ model HqAccount {
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
permissions HqAccountPermission[]
|
||||
testWhitelistPhones CommonTestWhitelistPhone[] @relation("TestWhitelistCreatedBy")
|
||||
|
||||
@@map("hq_account")
|
||||
}
|
||||
@@ -1140,6 +1159,8 @@ model User {
|
||||
nickname String? @db.VarChar(64)
|
||||
avatarResourceId BigInt? @map("avatar_resource_id") @db.UnsignedBigInt
|
||||
status Int @default(1) @db.TinyInt
|
||||
/// 测试账号:命中全局测试白名单手机号
|
||||
isTest Boolean @default(false) @map("is_test")
|
||||
sourceType UserSourceType @default(ORGANIC) @map("source_type")
|
||||
sourceRefId BigInt? @map("source_ref_id") @db.UnsignedBigInt
|
||||
sourceLabel String? @map("source_label") @db.VarChar(128)
|
||||
@@ -1166,6 +1187,7 @@ model User {
|
||||
@@index([referrerUserId])
|
||||
@@index([mergedIntoUserId])
|
||||
@@index([wxOpenId])
|
||||
@@index([isTest])
|
||||
@@map("user_user")
|
||||
}
|
||||
|
||||
@@ -1253,6 +1275,8 @@ model Store {
|
||||
visibilityWhitelistEnabled Boolean @default(false) @map("visibility_whitelist_enabled")
|
||||
/// FIN-001:允许未出账手动提现的白名单门店
|
||||
withdrawWhitelistEnabled Boolean @default(false) @map("withdraw_whitelist_enabled")
|
||||
/// 测试门店:不计结算 / HQ 可手动标记
|
||||
isTest Boolean @default(false) @map("is_test")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
@@ -1274,6 +1298,7 @@ model Store {
|
||||
@@index([cityId, status])
|
||||
@@index([partnerAccountId])
|
||||
@@index([auditStatus, createdAt])
|
||||
@@index([isTest])
|
||||
@@map("store_store")
|
||||
}
|
||||
|
||||
@@ -1344,6 +1369,8 @@ model StoreAccount {
|
||||
bankBranch String? @map("bank_branch") @db.VarChar(128)
|
||||
status AccountStatus @default(ACTIVE)
|
||||
lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3)
|
||||
/// 测试门店账号(商户)
|
||||
isTest Boolean @default(false) @map("is_test")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
@@ -1354,6 +1381,7 @@ model StoreAccount {
|
||||
withdrawRequests StoreWithdrawRequest[]
|
||||
|
||||
@@index([parentAccountId])
|
||||
@@index([isTest])
|
||||
@@map("store_account")
|
||||
}
|
||||
|
||||
@@ -1432,6 +1460,8 @@ model Order {
|
||||
fulfillmentHold Boolean @default(false) @map("fulfillment_hold")
|
||||
fulfillmentHoldReason String? @map("fulfillment_hold_reason") @db.VarChar(64)
|
||||
remark String? @db.VarChar(512)
|
||||
/// 测试订单快照(下单时取自 User.isTest)
|
||||
isTest Boolean @default(false) @map("is_test")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
@@ -1456,6 +1486,7 @@ model Order {
|
||||
@@index([gpsCity])
|
||||
@@index([fulfillmentWarehouseId])
|
||||
@@index([proxyPartnerAccountId])
|
||||
@@index([isTest])
|
||||
@@map("user_order")
|
||||
}
|
||||
|
||||
@@ -1548,6 +1579,8 @@ model RedeemRecord {
|
||||
settleAmount Decimal @map("settle_amount") @db.Decimal(10, 2)
|
||||
/// SCAN=qrcode, PHONE=phone
|
||||
channel RedeemChannel @default(SCAN)
|
||||
/// 测试核销快照(User.isTest || Store.isTest)
|
||||
isTest Boolean @default(false) @map("is_test")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
|
||||
@@ -1560,6 +1593,7 @@ model RedeemRecord {
|
||||
|
||||
@@index([storeId, createdAt])
|
||||
@@index([storeId, channel, createdAt])
|
||||
@@index([isTest])
|
||||
@@map("user_redeem_record")
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,16 @@ async function main() {
|
||||
|
||||
await prisma.storePayout.deleteMany();
|
||||
|
||||
await prisma.storeWithdrawPayoutItem.deleteMany();
|
||||
|
||||
await prisma.storeWithdrawRequest.deleteMany();
|
||||
|
||||
await prisma.storeBill.deleteMany();
|
||||
|
||||
await prisma.redeemPendingRecord.deleteMany();
|
||||
|
||||
await prisma.logStoreAnalytics.deleteMany();
|
||||
|
||||
await prisma.storeRating.deleteMany();
|
||||
|
||||
await prisma.redeemRecord.deleteMany();
|
||||
@@ -83,16 +93,28 @@ async function main() {
|
||||
|
||||
await prisma.user.deleteMany();
|
||||
|
||||
await prisma.storeAccount.updateMany({ data: { parentAccountId: null } });
|
||||
await prisma.storeAccount.deleteMany();
|
||||
|
||||
await prisma.store.deleteMany();
|
||||
|
||||
await prisma.partnerBill.deleteMany();
|
||||
|
||||
await prisma.logisticsBillItem.deleteMany();
|
||||
|
||||
await prisma.logisticsPrepaidLedger.deleteMany();
|
||||
|
||||
await prisma.logisticsBill.deleteMany();
|
||||
|
||||
await prisma.wineryBillItem.deleteMany();
|
||||
|
||||
await prisma.wineryBill.deleteMany();
|
||||
|
||||
await prisma.cityWarehouse.deleteMany();
|
||||
|
||||
await prisma.fulfillmentProvider.deleteMany();
|
||||
|
||||
await prisma.partnerAccount.updateMany({ data: { parentAccountId: null } });
|
||||
await prisma.partnerAccount.deleteMany();
|
||||
|
||||
await prisma.commonCity.deleteMany();
|
||||
@@ -101,6 +123,7 @@ async function main() {
|
||||
|
||||
await prisma.commonProductDetailTemplate.deleteMany();
|
||||
|
||||
await prisma.commonStoreCategory.updateMany({ data: { parentId: null } });
|
||||
await prisma.commonStoreCategory.deleteMany();
|
||||
|
||||
await prisma.commonPromoCode.deleteMany();
|
||||
@@ -914,6 +937,25 @@ async function main() {
|
||||
|
||||
|
||||
|
||||
const testWhitelistPhones = [
|
||||
'13800000001',
|
||||
'13700000001',
|
||||
'13700000002',
|
||||
'13910000001',
|
||||
'13910000002',
|
||||
];
|
||||
for (const phone of testWhitelistPhones) {
|
||||
await prisma.commonTestWhitelistPhone.upsert({
|
||||
where: { phone },
|
||||
create: { phone, note: 'seed 测试账号' },
|
||||
update: {},
|
||||
});
|
||||
await prisma.user.updateMany({ where: { phone }, data: { isTest: true } });
|
||||
await prisma.storeAccount.updateMany({ where: { phone }, data: { isTest: true } });
|
||||
await prisma.partnerAccount.updateMany({ where: { phone }, data: { isTest: true } });
|
||||
await prisma.store.updateMany({ where: { phone }, data: { isTest: true } });
|
||||
}
|
||||
|
||||
console.log('Seed complete:', {
|
||||
|
||||
city: city.name,
|
||||
@@ -928,6 +970,8 @@ async function main() {
|
||||
|
||||
stores: createdStores.length,
|
||||
|
||||
testWhitelistPhones,
|
||||
|
||||
testPhones: {
|
||||
|
||||
user: '13800000001',
|
||||
|
||||
@@ -21,6 +21,7 @@ import { CityScopeModule } from './modules/city-scope/city-scope.module';
|
||||
import { CommonModule } from './modules/common/common.module';
|
||||
import { HqOperationModule } from './common/hq-operation/hq-operation.module';
|
||||
import { SystemConfigModule } from './common/system-config/system-config.module';
|
||||
import { TestWhitelistModule } from './common/test-whitelist/test-whitelist.module';
|
||||
import { DevPlanModule } from './modules/dev-plan/dev-plan.module';
|
||||
import { CallbacksModule } from './callbacks/callbacks.module';
|
||||
import { WecomModule } from './integrations/wecom/wecom.module';
|
||||
@@ -37,6 +38,7 @@ import { RequestIdMiddleware } from './common/logging/request-id.middleware';
|
||||
}),
|
||||
PrismaModule,
|
||||
SystemConfigModule,
|
||||
TestWhitelistModule,
|
||||
GeoModule,
|
||||
RedisModule,
|
||||
AlertModule,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user