This commit is contained in:
@@ -39,6 +39,9 @@ src/
|
|||||||
|
|
||||||
## UI 约束
|
## UI 约束
|
||||||
|
|
||||||
|
- 使用 shared-ui CSS 变量与组件,勿各端自造设计 token
|
||||||
|
- **mini-user 微信 `openType` Button**:祖先禁止 `e.stopPropagation()`(Taro→`catchtap`,选头像/手机号等会静默失效);遮罩与 sheet 拆开绑关闭。见 `.cursor/rules/mini-user-weapp-opentype.mdc`
|
||||||
|
|
||||||
- C 端订单 **5 Tab**(含 pending_ship)
|
- C 端订单 **5 Tab**(含 pending_ship)
|
||||||
- 门店列表仅 `OPEN` 状态
|
- 门店列表仅 `OPEN` 状态
|
||||||
- 原型 `pages/` 只读;路由对照 `pages/ROUTE_MAP.md`
|
- 原型 `pages/` 只读;路由对照 `pages/ROUTE_MAP.md`
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
description: 微信小程序 open-type 按钮踩坑 — 禁止祖先 catchtap / stopPropagation
|
||||||
|
globs: apps/mini-user/**/*.{tsx,ts,css,scss}
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# mini-user · 微信 open-type 硬规则
|
||||||
|
|
||||||
|
## 禁止(必踩坑)
|
||||||
|
|
||||||
|
**现象**:`Button openType="chooseAvatar" | getPhoneNumber | getUserInfo | share | contact"` 点击无反应、无回调。
|
||||||
|
|
||||||
|
**根因**:祖先节点上的 `onClick={(e) => e.stopPropagation()}` 在 Taro 微信端会编译成 **`catchtap`**,拦截子级 `button` 的原生 open-type 能力。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ 弹层内容上 stopPropagation — 内部 chooseAvatar 会失效
|
||||||
|
<View className="mask" onClick={close}>
|
||||||
|
<View className="sheet" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Button openType="chooseAvatar" onChooseAvatar={...}>选头像</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 正确写法
|
||||||
|
|
||||||
|
遮罩与内容拆开:只在 **backdrop** 上关弹层,**sheet 不要**绑 stopPropagation / catchtap。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ✅
|
||||||
|
<View className="mask">
|
||||||
|
<View className="backdrop" onClick={close} />
|
||||||
|
<View className="sheet">
|
||||||
|
<Button openType="chooseAvatar" plain hoverClass="none" onChooseAvatar={...}>
|
||||||
|
...
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 附加
|
||||||
|
|
||||||
|
- `Button` 内 `Image` / 文案加 `pointer-events: none`(或父级 `> * { pointer-events: none }`),避免抢触摸
|
||||||
|
- 同类能力:`getPhoneNumber`、`contact`、`share` 同样忌祖先 `catchtap`
|
||||||
|
- 详情见知识库「C 端 · 踩坑 · chooseAvatar」
|
||||||
|
|
||||||
|
参照实现:`apps/mini-user/src/pages/mine/index.tsx` 资料弹层。
|
||||||
@@ -138,6 +138,7 @@ C 端门店仅 status=OPEN
|
|||||||
```
|
```
|
||||||
|
|
||||||
**iOS 微信 H5 JSSDK**:登录/OAuth 后禁止仅 SPA 跳转再调扫码;见 `packages/weixin-sdk/GOTCHAS.md`、知识库「门店端 · 踩坑」。
|
**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/*` 的源码
|
- 禁止 import `server/` 或另一个 `apps/*` 的源码
|
||||||
- UI 共享组件优先 `@dukang/shared-ui`
|
- UI 共享组件优先 `@dukang/shared-ui`
|
||||||
- C 端订单列表 **3 Tab**:`待付款 | 已付款 | 已完成`(Tab key: `pending_pay` / `paid` / `completed`)
|
- C 端订单列表 **3 Tab**:`待付款 | 已付款 | 已完成`(Tab key: `pending_pay` / `paid` / `completed`)
|
||||||
|
- **mini-user 微信 open-type**:含 `chooseAvatar` / `getPhoneNumber` 等的 `Button`,祖先禁止 `stopPropagation`(会编成 `catchtap` 导致点击无反应);见知识库 C 端踩坑、`.cursor/rules/mini-user-weapp-opentype.mdc`
|
||||||
|
|
||||||
## 新页面 workflow
|
## 新页面 workflow
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import PartnerLogsPage from './pages/PartnerLogsPage';
|
|||||||
import WechatBindingsPage from './pages/WechatBindingsPage';
|
import WechatBindingsPage from './pages/WechatBindingsPage';
|
||||||
import HqPermissionsPage from './pages/HqPermissionsPage';
|
import HqPermissionsPage from './pages/HqPermissionsPage';
|
||||||
import SystemSettingsPage from './pages/SystemSettingsPage';
|
import SystemSettingsPage from './pages/SystemSettingsPage';
|
||||||
|
import TestWhitelistPage from './pages/TestWhitelistPage';
|
||||||
import WecomBotsPage from './pages/WecomBotsPage';
|
import WecomBotsPage from './pages/WecomBotsPage';
|
||||||
import WecomMessagePushesPage from './pages/WecomMessagePushesPage';
|
import WecomMessagePushesPage from './pages/WecomMessagePushesPage';
|
||||||
import WecomBotLogsPage from './pages/WecomBotLogsPage';
|
import WecomBotLogsPage from './pages/WecomBotLogsPage';
|
||||||
@@ -136,6 +137,7 @@ export default function App() {
|
|||||||
<Route path="/deliveries" element={<DeliveriesPage />} />
|
<Route path="/deliveries" element={<DeliveriesPage />} />
|
||||||
<Route path="/deliveries/xiaofeixia" element={<XiaofeixiaTestPage />} />
|
<Route path="/deliveries/xiaofeixia" element={<XiaofeixiaTestPage />} />
|
||||||
<Route path="/hq-permissions" element={<HqPermissionsPage />} />
|
<Route path="/hq-permissions" element={<HqPermissionsPage />} />
|
||||||
|
<Route path="/test-whitelist" element={<TestWhitelistPage />} />
|
||||||
<Route path="/system-settings" element={<SystemSettingsPage />} />
|
<Route path="/system-settings" element={<SystemSettingsPage />} />
|
||||||
<Route path="/hq-accounts" element={<HqAccountsPage />} />
|
<Route path="/hq-accounts" element={<HqAccountsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ const MENU_ITEMS: MenuProps['items'] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ key: '/hq-permissions', icon: <LockOutlined />, label: '权限分配' },
|
{ key: '/hq-permissions', icon: <LockOutlined />, label: '权限分配' },
|
||||||
|
{ key: '/test-whitelist', icon: <SafetyOutlined />, label: '白名单管理' },
|
||||||
{ key: '/system-settings', icon: <SettingOutlined />, label: '系统设置' },
|
{ key: '/system-settings', icon: <SettingOutlined />, label: '系统设置' },
|
||||||
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
|
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
|
||||||
];
|
];
|
||||||
@@ -215,6 +216,7 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
|
|||||||
'/logs/third-party': 'logs',
|
'/logs/third-party': 'logs',
|
||||||
'/logs/domain-events': 'logs',
|
'/logs/domain-events': 'logs',
|
||||||
'/hq-permissions': 'hq_permissions',
|
'/hq-permissions': 'hq_permissions',
|
||||||
|
'/test-whitelist': 'test_whitelist',
|
||||||
'/system-settings': 'system_settings_any',
|
'/system-settings': 'system_settings_any',
|
||||||
'/hq-accounts': 'hq_accounts',
|
'/hq-accounts': 'hq_accounts',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ export type AdminUserRow = {
|
|||||||
sourceLabel: string | null;
|
sourceLabel: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
orderCount: number;
|
orderCount: number;
|
||||||
|
isTest?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AdminOrderItem = {
|
export type AdminOrderItem = {
|
||||||
@@ -199,6 +200,7 @@ export type AdminOrderRow = {
|
|||||||
isProxyOrder?: boolean;
|
isProxyOrder?: boolean;
|
||||||
proxyPartnerName?: string | null;
|
proxyPartnerName?: string | null;
|
||||||
proxyPartnerPhone?: string | null;
|
proxyPartnerPhone?: string | null;
|
||||||
|
isTest?: boolean;
|
||||||
user?: { id: string; userNo: string; phone: string | null; nickname: string | null };
|
user?: { id: string; userNo: string; phone: string | null; nickname: string | null };
|
||||||
delivery?: {
|
delivery?: {
|
||||||
provider: string;
|
provider: string;
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ type Row = {
|
|||||||
accountCount: number;
|
accountCount: number;
|
||||||
subAccounts?: SubRow[];
|
subAccounts?: SubRow[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
isTest?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PartnerDetail = Row & {
|
type PartnerDetail = Row & {
|
||||||
@@ -124,14 +125,15 @@ export default function CityPartnersPage() {
|
|||||||
const [createForm] = Form.useForm();
|
const [createForm] = Form.useForm();
|
||||||
const [subForm] = Form.useForm();
|
const [subForm] = Form.useForm();
|
||||||
const [subEditForm] = Form.useForm();
|
const [subEditForm] = Form.useForm();
|
||||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
const [filters, setFilters] = useState<Record<string, string | boolean>>({});
|
||||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||||
'/admin/partners',
|
'/admin/partners',
|
||||||
() => {
|
() => {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (filters.companyName) qs.set('companyName', filters.companyName);
|
if (filters.companyName) qs.set('companyName', String(filters.companyName));
|
||||||
if (filters.phone) qs.set('phone', filters.phone);
|
if (filters.phone) qs.set('phone', String(filters.phone));
|
||||||
if (filters.cityId) qs.set('cityId', filters.cityId);
|
if (filters.cityId) qs.set('cityId', String(filters.cityId));
|
||||||
|
if (filters.excludeTest) qs.set('excludeTest', 'true');
|
||||||
return qs;
|
return qs;
|
||||||
},
|
},
|
||||||
[filters],
|
[filters],
|
||||||
@@ -270,7 +272,18 @@ export default function CityPartnersPage() {
|
|||||||
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
|
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
|
||||||
},
|
},
|
||||||
{ title: '公司名', dataIndex: 'companyName', ellipsis: true, width: 140 },
|
{ title: '公司名', dataIndex: 'companyName', ellipsis: true, width: 140 },
|
||||||
{ title: '主账号姓名', dataIndex: 'name', width: 100, ellipsis: true },
|
{
|
||||||
|
title: '主账号姓名',
|
||||||
|
dataIndex: 'name',
|
||||||
|
width: 120,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v, row) => (
|
||||||
|
<Space size={4}>
|
||||||
|
<span>{v}</span>
|
||||||
|
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
{ title: '登录手机', dataIndex: 'phone', width: 120 },
|
{ title: '登录手机', dataIndex: 'phone', width: 120 },
|
||||||
{
|
{
|
||||||
title: '管辖',
|
title: '管辖',
|
||||||
@@ -387,6 +400,9 @@ export default function CityPartnersPage() {
|
|||||||
<Form.Item name="phone" label="手机">
|
<Form.Item name="phone" label="手机">
|
||||||
<Input allowClear placeholder="登录手机" />
|
<Input allowClear placeholder="登录手机" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item name="excludeTest" valuePropName="checked">
|
||||||
|
<Checkbox>过滤测试账号</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Space>
|
<Space>
|
||||||
<Button type="primary" htmlType="submit">
|
<Button type="primary" htmlType="submit">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
|
|||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
|
Checkbox,
|
||||||
Collapse,
|
Collapse,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
Drawer,
|
Drawer,
|
||||||
@@ -225,6 +226,7 @@ export default function OrdersPage() {
|
|||||||
if (values.cityId) qs.set('cityId', values.cityId);
|
if (values.cityId) qs.set('cityId', values.cityId);
|
||||||
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
|
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
|
||||||
if (values.fulfillmentHold) qs.set('fulfillmentHold', 'true');
|
if (values.fulfillmentHold) qs.set('fulfillmentHold', 'true');
|
||||||
|
if (values.excludeTest) qs.set('excludeTest', 'true');
|
||||||
const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`);
|
const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`);
|
||||||
setData(res);
|
setData(res);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -432,7 +434,17 @@ export default function OrdersPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<AdminOrderRow> = [
|
const columns: ColumnsType<AdminOrderRow> = [
|
||||||
{ title: '订单号', dataIndex: 'orderNo', width: 180 },
|
{
|
||||||
|
title: '订单号',
|
||||||
|
dataIndex: 'orderNo',
|
||||||
|
width: 200,
|
||||||
|
render: (v, row) => (
|
||||||
|
<Space size={4}>
|
||||||
|
<span>{v}</span>
|
||||||
|
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '城市',
|
title: '城市',
|
||||||
width: 90,
|
width: 90,
|
||||||
@@ -600,6 +612,9 @@ export default function OrdersPage() {
|
|||||||
options={[{ value: true, label: '仅待确认大单' }]}
|
options={[{ value: true, label: '仅待确认大单' }]}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item name="excludeTest" valuePropName="checked">
|
||||||
|
<Checkbox>过滤测试账号</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Space>
|
<Space>
|
||||||
<Button type="primary" htmlType="submit">查询</Button>
|
<Button type="primary" htmlType="submit">查询</Button>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useRef, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Popconfirm, Select, Space,
|
Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Popconfirm, Select, Space,
|
||||||
Switch, Table, Tabs, Tag, Typography, message,
|
Switch, Table, Tabs, Tag, Typography, message,
|
||||||
@@ -67,13 +67,6 @@ type ProductFormValues = {
|
|||||||
features?: Array<{ icon?: string; title?: string; desc?: string }>;
|
features?: Array<{ icon?: string; title?: string; desc?: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type UserPickRow = {
|
|
||||||
id: string;
|
|
||||||
phone?: string | null;
|
|
||||||
nickname?: string | null;
|
|
||||||
userNo?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function mapDetailToForm(d: Record<string, unknown>) {
|
function mapDetailToForm(d: Record<string, unknown>) {
|
||||||
const detail = (d.detailContent ?? {}) as ProductDetailContentDto;
|
const detail = (d.detailContent ?? {}) as ProductDetailContentDto;
|
||||||
const row = d as Row;
|
const row = d as Row;
|
||||||
@@ -112,10 +105,6 @@ function buildProductPayload(v: ProductFormValues) {
|
|||||||
features: features.length ? features : undefined,
|
features: features.length ? features : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const visibilityPhones = (v.visibilityPhones ?? [])
|
|
||||||
.map((p) => String(p || '').replace(/\D/g, '').trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
barcode69: v.barcode69,
|
barcode69: v.barcode69,
|
||||||
name: v.name,
|
name: v.name,
|
||||||
@@ -131,7 +120,6 @@ function buildProductPayload(v: ProductFormValues) {
|
|||||||
allowCrossCityDelivery:
|
allowCrossCityDelivery:
|
||||||
v.allowOnlinePurchase === false ? false : v.allowCrossCityDelivery !== false,
|
v.allowOnlinePurchase === false ? false : v.allowCrossCityDelivery !== false,
|
||||||
visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled,
|
visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled,
|
||||||
visibilityPhones,
|
|
||||||
coverUrl: v.coverUrl,
|
coverUrl: v.coverUrl,
|
||||||
carouselUrls,
|
carouselUrls,
|
||||||
detailImageUrls,
|
detailImageUrls,
|
||||||
@@ -213,33 +201,6 @@ function ProductDetailFields({ form, aromaType }: { form: FormInstance; aromaTyp
|
|||||||
}
|
}
|
||||||
|
|
||||||
function VisibilityWhitelistFields({ form }: { form: FormInstance }) {
|
function VisibilityWhitelistFields({ form }: { form: FormInstance }) {
|
||||||
const [userOptions, setUserOptions] = useState<UserPickRow[]>([]);
|
|
||||||
const [userSearching, setUserSearching] = useState(false);
|
|
||||||
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
async function searchUsers(keyword: string) {
|
|
||||||
const q = keyword.trim();
|
|
||||||
if (searchTimer.current) clearTimeout(searchTimer.current);
|
|
||||||
if (!q) {
|
|
||||||
setUserOptions([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
searchTimer.current = setTimeout(() => {
|
|
||||||
void (async () => {
|
|
||||||
setUserSearching(true);
|
|
||||||
try {
|
|
||||||
const qs = new URLSearchParams({ page: '1', pageSize: '20', phone: q });
|
|
||||||
const res = await request<{ items: UserPickRow[] }>(`/admin/users?${qs}`);
|
|
||||||
setUserOptions((res.items ?? []).filter((u) => !!u.phone));
|
|
||||||
} catch {
|
|
||||||
setUserOptions([]);
|
|
||||||
} finally {
|
|
||||||
setUserSearching(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
|
|
||||||
const enabled = Form.useWatch('visibilityWhitelistEnabled', form);
|
const enabled = Form.useWatch('visibilityWhitelistEnabled', form);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -248,46 +209,14 @@ function VisibilityWhitelistFields({ form }: { form: FormInstance }) {
|
|||||||
name="visibilityWhitelistEnabled"
|
name="visibilityWhitelistEnabled"
|
||||||
label="可见白名单"
|
label="可见白名单"
|
||||||
valuePropName="checked"
|
valuePropName="checked"
|
||||||
extra="开启后仅名单内手机号在 C 端可见/可购,用于在线测试"
|
extra="开启后仅全局测试白名单内手机号在 C 端可见/可购,用于在线测试"
|
||||||
>
|
>
|
||||||
<Switch checkedChildren="开" unCheckedChildren="关" />
|
<Switch checkedChildren="开" unCheckedChildren="关" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
{enabled ? (
|
{enabled ? (
|
||||||
<>
|
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
|
||||||
<Form.Item
|
可见手机号见白名单管理
|
||||||
name="visibilityPhones"
|
</Typography.Text>
|
||||||
label="白名单手机号"
|
|
||||||
rules={[{ required: true, message: '请至少添加一个手机号' }]}
|
|
||||||
extra="可直接输入多个手机号回车添加,或从下方用户库选择"
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
mode="tags"
|
|
||||||
tokenSeparators={[',', ' ', ',', ';', ';']}
|
|
||||||
placeholder="输入手机号后回车"
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item label="从用户库添加">
|
|
||||||
<Select
|
|
||||||
showSearch
|
|
||||||
filterOption={false}
|
|
||||||
placeholder="按手机号搜索用户"
|
|
||||||
loading={userSearching}
|
|
||||||
options={userOptions.map((u) => ({
|
|
||||||
value: u.phone!,
|
|
||||||
label: `${u.phone}${u.nickname ? ` · ${u.nickname}` : ''}${u.userNo ? `(${u.userNo})` : ''}`,
|
|
||||||
}))}
|
|
||||||
onSearch={searchUsers}
|
|
||||||
onSelect={(phone: string) => {
|
|
||||||
const cur = (form.getFieldValue('visibilityPhones') as string[] | undefined) ?? [];
|
|
||||||
if (!cur.includes(phone)) {
|
|
||||||
form.setFieldsValue({ visibilityPhones: [...cur, phone] });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
notFoundContent={userSearching ? '搜索中…' : '输入手机号搜索'}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -419,8 +348,8 @@ export default function ProductsPage() {
|
|||||||
title: '白名单',
|
title: '白名单',
|
||||||
dataIndex: 'visibilityWhitelistEnabled',
|
dataIndex: 'visibilityWhitelistEnabled',
|
||||||
width: 90,
|
width: 90,
|
||||||
render: (v: boolean, row) =>
|
render: (v: boolean) =>
|
||||||
v ? <Tag color="orange">限{row.visibilityPhones?.length ?? 0}人</Tag> : <Tag>公开</Tag>,
|
v ? <Tag color="orange">限测</Tag> : <Tag>公开</Tag>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '履约',
|
title: '履约',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Button, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd';
|
import { Button, Checkbox, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
|
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
|
||||||
import RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDescriptions';
|
import RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDescriptions';
|
||||||
@@ -14,6 +14,7 @@ type Row = {
|
|||||||
settleAmount: number;
|
settleAmount: number;
|
||||||
channel?: RedeemChannel;
|
channel?: RedeemChannel;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
isTest?: boolean;
|
||||||
user?: { userNo: string; phone: string | null; nickname?: string | null };
|
user?: { userNo: string; phone: string | null; nickname?: string | null };
|
||||||
store?: { name: string; cityName: string };
|
store?: { name: string; cityName: string };
|
||||||
coupon?: { couponNo: string };
|
coupon?: { couponNo: string };
|
||||||
@@ -26,14 +27,15 @@ function maskPhone(phone: string | null | undefined) {
|
|||||||
|
|
||||||
export default function RedeemRecordsPage() {
|
export default function RedeemRecordsPage() {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
const [filters, setFilters] = useState<Record<string, string | boolean>>({});
|
||||||
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
|
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
|
||||||
'/admin/redeem-records',
|
'/admin/redeem-records',
|
||||||
() => {
|
() => {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (filters.redeemNo) qs.set('redeemNo', filters.redeemNo);
|
if (filters.redeemNo) qs.set('redeemNo', String(filters.redeemNo));
|
||||||
if (filters.storeId) qs.set('storeId', filters.storeId);
|
if (filters.storeId) qs.set('storeId', String(filters.storeId));
|
||||||
if (filters.channel) qs.set('channel', filters.channel);
|
if (filters.channel) qs.set('channel', String(filters.channel));
|
||||||
|
if (filters.excludeTest) qs.set('excludeTest', 'true');
|
||||||
return qs;
|
return qs;
|
||||||
},
|
},
|
||||||
[filters],
|
[filters],
|
||||||
@@ -53,7 +55,21 @@ export default function RedeemRecordsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
const columns: ColumnsType<Row> = [
|
||||||
{ title: '核销号', dataIndex: 'redeemNo', width: 180 },
|
{
|
||||||
|
title: '核销号',
|
||||||
|
dataIndex: 'redeemNo',
|
||||||
|
width: 200,
|
||||||
|
render: (v, row) => (
|
||||||
|
<span>
|
||||||
|
{v}
|
||||||
|
{row.isTest ? (
|
||||||
|
<Tag color="orange" style={{ marginLeft: 6 }}>
|
||||||
|
测试
|
||||||
|
</Tag>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '方式',
|
title: '方式',
|
||||||
dataIndex: 'channel',
|
dataIndex: 'channel',
|
||||||
@@ -124,6 +140,9 @@ export default function RedeemRecordsPage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item name="excludeTest" valuePropName="checked">
|
||||||
|
<Checkbox>过滤测试账号</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Button type="primary" htmlType="submit">
|
<Button type="primary" htmlType="submit">
|
||||||
查询
|
查询
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Button, Descriptions, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
|
Button, Checkbox, Descriptions, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { request, type Paginated } from '../lib/api';
|
import { request, type Paginated } from '../lib/api';
|
||||||
@@ -15,6 +15,7 @@ type Row = {
|
|||||||
name: string;
|
name: string;
|
||||||
status: string;
|
status: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
isTest?: boolean;
|
||||||
storeCount?: number;
|
storeCount?: number;
|
||||||
staffCount?: number;
|
staffCount?: number;
|
||||||
bankAccountName?: string | null;
|
bankAccountName?: string | null;
|
||||||
@@ -30,13 +31,14 @@ type StoreOption = { id: string; name: string };
|
|||||||
export default function StoreAccountsPage() {
|
export default function StoreAccountsPage() {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [createForm] = Form.useForm();
|
const [createForm] = Form.useForm();
|
||||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
const [filters, setFilters] = useState<Record<string, string | boolean>>({});
|
||||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||||
'/admin/store-accounts',
|
'/admin/store-accounts',
|
||||||
() => {
|
() => {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (filters.phone) qs.set('phone', filters.phone);
|
if (filters.phone) qs.set('phone', String(filters.phone));
|
||||||
if (filters.status) qs.set('status', filters.status);
|
if (filters.status) qs.set('status', String(filters.status));
|
||||||
|
if (filters.excludeTest) qs.set('excludeTest', 'true');
|
||||||
return qs;
|
return qs;
|
||||||
},
|
},
|
||||||
[filters],
|
[filters],
|
||||||
@@ -73,7 +75,17 @@ export default function StoreAccountsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
const columns: ColumnsType<Row> = [
|
||||||
{ title: '姓名', dataIndex: 'name', width: 100 },
|
{
|
||||||
|
title: '姓名',
|
||||||
|
dataIndex: 'name',
|
||||||
|
width: 120,
|
||||||
|
render: (v, row) => (
|
||||||
|
<Space size={4}>
|
||||||
|
<span>{v}</span>
|
||||||
|
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
{ title: '手机', dataIndex: 'phone', width: 120 },
|
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||||
{
|
{
|
||||||
title: '绑定门店',
|
title: '绑定门店',
|
||||||
@@ -162,6 +174,9 @@ export default function StoreAccountsPage() {
|
|||||||
options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item name="excludeTest" valuePropName="checked">
|
||||||
|
<Checkbox>过滤测试账号</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
<Table
|
<Table
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
|
Checkbox,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
Drawer,
|
Drawer,
|
||||||
Form,
|
Form,
|
||||||
@@ -277,36 +278,7 @@ function StoreAuditMediaSection({ detail }: { detail: Record<string, unknown> })
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserPickRow = { id: string; phone?: string | null; nickname?: string | null; userNo?: string | null };
|
|
||||||
|
|
||||||
function StoreVisibilityWhitelistFields({ form }: { form: FormInstance }) {
|
function StoreVisibilityWhitelistFields({ form }: { form: FormInstance }) {
|
||||||
const [userOptions, setUserOptions] = useState<UserPickRow[]>([]);
|
|
||||||
const [userSearching, setUserSearching] = useState(false);
|
|
||||||
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
async function searchUsers(keyword: string) {
|
|
||||||
const q = keyword.trim();
|
|
||||||
if (searchTimer.current) clearTimeout(searchTimer.current);
|
|
||||||
if (!q) {
|
|
||||||
setUserOptions([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
searchTimer.current = setTimeout(() => {
|
|
||||||
void (async () => {
|
|
||||||
setUserSearching(true);
|
|
||||||
try {
|
|
||||||
const qs = new URLSearchParams({ page: '1', pageSize: '20', phone: q });
|
|
||||||
const res = await request<{ items: UserPickRow[] }>(`/admin/users?${qs}`);
|
|
||||||
setUserOptions((res.items ?? []).filter((u) => !!u.phone));
|
|
||||||
} catch {
|
|
||||||
setUserOptions([]);
|
|
||||||
} finally {
|
|
||||||
setUserSearching(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
|
|
||||||
const enabled = Form.useWatch('visibilityWhitelistEnabled', form);
|
const enabled = Form.useWatch('visibilityWhitelistEnabled', form);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -315,46 +287,14 @@ function StoreVisibilityWhitelistFields({ form }: { form: FormInstance }) {
|
|||||||
name="visibilityWhitelistEnabled"
|
name="visibilityWhitelistEnabled"
|
||||||
label="可见白名单"
|
label="可见白名单"
|
||||||
valuePropName="checked"
|
valuePropName="checked"
|
||||||
extra="开启后仅名单内手机号在 C 端可见,用于在线测试"
|
extra="开启后仅全局测试白名单内手机号在 C 端可见,用于在线测试"
|
||||||
>
|
>
|
||||||
<Switch checkedChildren="开" unCheckedChildren="关" />
|
<Switch checkedChildren="开" unCheckedChildren="关" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
{enabled ? (
|
{enabled ? (
|
||||||
<>
|
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
|
||||||
<Form.Item
|
可见手机号见白名单管理
|
||||||
name="visibilityPhones"
|
</Typography.Text>
|
||||||
label="白名单手机号"
|
|
||||||
rules={[{ required: true, message: '请至少添加一个手机号' }]}
|
|
||||||
extra="可直接输入多个手机号回车添加,或从下方用户库选择"
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
mode="tags"
|
|
||||||
tokenSeparators={[',', ' ', ',', ';', ';']}
|
|
||||||
placeholder="输入手机号后回车"
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item label="从用户库添加">
|
|
||||||
<Select
|
|
||||||
showSearch
|
|
||||||
filterOption={false}
|
|
||||||
placeholder="按手机号搜索用户"
|
|
||||||
loading={userSearching}
|
|
||||||
options={userOptions.map((u) => ({
|
|
||||||
value: u.phone!,
|
|
||||||
label: `${u.phone}${u.nickname ? ` · ${u.nickname}` : ''}${u.userNo ? `(${u.userNo})` : ''}`,
|
|
||||||
}))}
|
|
||||||
onSearch={searchUsers}
|
|
||||||
onSelect={(phone: string) => {
|
|
||||||
const cur = (form.getFieldValue('visibilityPhones') as string[] | undefined) ?? [];
|
|
||||||
if (!cur.includes(phone)) {
|
|
||||||
form.setFieldsValue({ visibilityPhones: [...cur, phone] });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
notFoundContent={userSearching ? '搜索中…' : '输入手机号搜索'}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -375,6 +315,7 @@ type StoreRow = {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
visibilityWhitelistEnabled?: boolean;
|
visibilityWhitelistEnabled?: boolean;
|
||||||
visibilityPhones?: string[];
|
visibilityPhones?: string[];
|
||||||
|
isTest?: boolean;
|
||||||
cityRef?: { name: string; code: string };
|
cityRef?: { name: string; code: string };
|
||||||
partner?: { id?: string; companyName?: string | null; name?: string | null; phone?: string | null };
|
partner?: { id?: string; companyName?: string | null; name?: string | null; phone?: string | null };
|
||||||
account?: {
|
account?: {
|
||||||
@@ -424,8 +365,8 @@ export default function StoresPage() {
|
|||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [editForm] = Form.useForm();
|
const [editForm] = Form.useForm();
|
||||||
const [createForm] = Form.useForm<StoreCreateForm>();
|
const [createForm] = Form.useForm<StoreCreateForm>();
|
||||||
const [filters, setFilters] = useState<Record<string, string>>(() => {
|
const [filters, setFilters] = useState<Record<string, string | boolean>>(() => {
|
||||||
const init: Record<string, string> = {};
|
const init: Record<string, string | boolean> = {};
|
||||||
if (initialCityId) init.cityId = initialCityId;
|
if (initialCityId) init.cityId = initialCityId;
|
||||||
if (initialPartnerId) init.partnerId = initialPartnerId;
|
if (initialPartnerId) init.partnerId = initialPartnerId;
|
||||||
return init;
|
return init;
|
||||||
@@ -434,12 +375,13 @@ export default function StoresPage() {
|
|||||||
'/admin/stores',
|
'/admin/stores',
|
||||||
() => {
|
() => {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (filters.name) qs.set('name', filters.name);
|
if (filters.name) qs.set('name', String(filters.name));
|
||||||
if (filters.status) qs.set('status', filters.status);
|
if (filters.status) qs.set('status', String(filters.status));
|
||||||
if (filters.auditStatus) qs.set('auditStatus', filters.auditStatus);
|
if (filters.auditStatus) qs.set('auditStatus', String(filters.auditStatus));
|
||||||
if (filters.phone) qs.set('phone', filters.phone);
|
if (filters.phone) qs.set('phone', String(filters.phone));
|
||||||
if (filters.cityId) qs.set('cityId', filters.cityId);
|
if (filters.cityId) qs.set('cityId', String(filters.cityId));
|
||||||
if (filters.partnerId) qs.set('partnerId', filters.partnerId);
|
if (filters.partnerId) qs.set('partnerId', String(filters.partnerId));
|
||||||
|
if (filters.excludeTest) qs.set('excludeTest', 'true');
|
||||||
return qs;
|
return qs;
|
||||||
},
|
},
|
||||||
[filters],
|
[filters],
|
||||||
@@ -597,6 +539,7 @@ export default function StoresPage() {
|
|||||||
visibilityPhones: Array.isArray(d.visibilityPhones)
|
visibilityPhones: Array.isArray(d.visibilityPhones)
|
||||||
? (d.visibilityPhones as string[])
|
? (d.visibilityPhones as string[])
|
||||||
: [],
|
: [],
|
||||||
|
isTest: !!d.isTest,
|
||||||
});
|
});
|
||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
}
|
}
|
||||||
@@ -637,9 +580,7 @@ export default function StoresPage() {
|
|||||||
bankAccountNo: v.bankAccountNo ?? null,
|
bankAccountNo: v.bankAccountNo ?? null,
|
||||||
bankBranch: v.bankBranch ?? null,
|
bankBranch: v.bankBranch ?? null,
|
||||||
visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled,
|
visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled,
|
||||||
visibilityPhones: ((v.visibilityPhones as string[] | undefined) ?? [])
|
isTest: !!v.isTest,
|
||||||
.map((p) => String(p || '').replace(/\D/g, '').trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
...(hasCoords
|
...(hasCoords
|
||||||
? { latitude: Number(v.latitude), longitude: Number(v.longitude) }
|
? { latitude: Number(v.latitude), longitude: Number(v.longitude) }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -813,9 +754,6 @@ export default function StoresPage() {
|
|||||||
bankBranch: values.bankBranch.trim(),
|
bankBranch: values.bankBranch.trim(),
|
||||||
settlementRate: Number(values.settlementRate ?? 60) / 100,
|
settlementRate: Number(values.settlementRate ?? 60) / 100,
|
||||||
visibilityWhitelistEnabled: !!values.visibilityWhitelistEnabled,
|
visibilityWhitelistEnabled: !!values.visibilityWhitelistEnabled,
|
||||||
visibilityPhones: (values.visibilityPhones ?? [])
|
|
||||||
.map((p: string) => String(p || '').replace(/\D/g, '').trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
message.success('门店已创建');
|
message.success('门店已创建');
|
||||||
@@ -839,7 +777,12 @@ export default function StoresPage() {
|
|||||||
title: '封面', dataIndex: 'coverUrl', width: 72,
|
title: '封面', dataIndex: 'coverUrl', width: 72,
|
||||||
render: (url) => url ? <Image src={url} width={48} height={48} style={{ objectFit: 'cover', borderRadius: 4 }} /> : '—',
|
render: (url) => url ? <Image src={url} width={48} height={48} style={{ objectFit: 'cover', borderRadius: 4 }} /> : '—',
|
||||||
},
|
},
|
||||||
{ title: '门店名', dataIndex: 'name', width: 140 },
|
{ title: '门店名', dataIndex: 'name', width: 160, render: (v, row) => (
|
||||||
|
<Space size={4}>
|
||||||
|
<span>{v}</span>
|
||||||
|
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
||||||
|
</Space>
|
||||||
|
) },
|
||||||
{
|
{
|
||||||
title: '分类',
|
title: '分类',
|
||||||
width: 100,
|
width: 100,
|
||||||
@@ -879,8 +822,8 @@ export default function StoresPage() {
|
|||||||
title: '可见',
|
title: '可见',
|
||||||
dataIndex: 'visibilityWhitelistEnabled',
|
dataIndex: 'visibilityWhitelistEnabled',
|
||||||
width: 90,
|
width: 90,
|
||||||
render: (v, row) =>
|
render: (v) =>
|
||||||
v ? <Tag color="orange">限{row.visibilityPhones?.length ?? 0}人</Tag> : <Tag>公开</Tag>,
|
v ? <Tag color="orange">限测</Tag> : <Tag>公开</Tag>,
|
||||||
},
|
},
|
||||||
{ title: '介绍', dataIndex: 'intro', width: 160, ellipsis: true, render: (v) => v || '—' },
|
{ title: '介绍', dataIndex: 'intro', width: 160, ellipsis: true, render: (v) => v || '—' },
|
||||||
{ title: '店长', dataIndex: ['account', 'name'], width: 90, render: (v) => v || '—' },
|
{ title: '店长', dataIndex: ['account', 'name'], width: 90, render: (v) => v || '—' },
|
||||||
@@ -939,6 +882,9 @@ export default function StoresPage() {
|
|||||||
options={Object.entries(STORE_AUDIT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
options={Object.entries(STORE_AUDIT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item name="excludeTest" valuePropName="checked">
|
||||||
|
<Checkbox>过滤测试账号</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Button
|
<Button
|
||||||
@@ -1176,6 +1122,14 @@ export default function StoresPage() {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Space>
|
</Space>
|
||||||
<StoreVisibilityWhitelistFields form={editForm} />
|
<StoreVisibilityWhitelistFields form={editForm} />
|
||||||
|
<Form.Item
|
||||||
|
name="isTest"
|
||||||
|
label="测试门店"
|
||||||
|
valuePropName="checked"
|
||||||
|
extra="测试门店核销不计入结算账单;联系电话命中全局白名单时会自动标记"
|
||||||
|
>
|
||||||
|
<Switch checkedChildren="是" unCheckedChildren="否" />
|
||||||
|
</Form.Item>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,687 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Checkbox,
|
||||||
|
Descriptions,
|
||||||
|
Drawer,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
Popconfirm,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Table,
|
||||||
|
Tabs,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import { request, type Paginated } from '../lib/api';
|
||||||
|
import { fmtTime } from '../lib/constants';
|
||||||
|
|
||||||
|
const MOCK_KEYS = ['MOCK_SMS', 'MOCK_WECHAT', 'MOCK_PAY'] as const;
|
||||||
|
|
||||||
|
type PhoneRow = {
|
||||||
|
id: string;
|
||||||
|
phone: string;
|
||||||
|
note: string | null;
|
||||||
|
createdByHqId: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AccountType = 'user' | 'store_account' | 'partner' | 'store' | 'order';
|
||||||
|
|
||||||
|
type LinkedPayload = {
|
||||||
|
phone: PhoneRow;
|
||||||
|
users: Array<{ id: string; userNo: string; phone: string | null; nickname: string | null; isTest: boolean; status: number }>;
|
||||||
|
storeAccounts: Array<{ id: string; phone: string; name: string; isTest: boolean; status: string }>;
|
||||||
|
partners: Array<{ id: string; phone: string; name: string; companyName: string | null; isTest: boolean; status: string }>;
|
||||||
|
stores: Array<{ id: string; name: string; phone: string; isTest: boolean; status: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACCOUNT_TYPE_OPTIONS: { value: AccountType; label: string }[] = [
|
||||||
|
{ value: 'user', label: 'C 端用户' },
|
||||||
|
{ value: 'store_account', label: '门店账号' },
|
||||||
|
{ value: 'partner', label: '合伙人' },
|
||||||
|
{ value: 'store', label: '门店' },
|
||||||
|
{ value: 'order', label: '订单' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function TestWhitelistPage() {
|
||||||
|
const [mockSms, setMockSms] = useState(false);
|
||||||
|
const [mockWechat, setMockWechat] = useState(false);
|
||||||
|
const [mockPay, setMockPay] = useState(false);
|
||||||
|
const [mockLoading, setMockLoading] = useState(true);
|
||||||
|
const [mockSaving, setMockSaving] = useState(false);
|
||||||
|
|
||||||
|
const [phoneForm] = Form.useForm();
|
||||||
|
const [phones, setPhones] = useState<Paginated<PhoneRow> | null>(null);
|
||||||
|
const [phonesLoading, setPhonesLoading] = useState(false);
|
||||||
|
const [phonePage, setPhonePage] = useState(1);
|
||||||
|
const [phonePageSize, setPhonePageSize] = useState(20);
|
||||||
|
const [phoneFilters, setPhoneFilters] = useState<{ phone?: string }>({});
|
||||||
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
|
const [addForm] = Form.useForm();
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [editRow, setEditRow] = useState<PhoneRow | null>(null);
|
||||||
|
const [editForm] = Form.useForm();
|
||||||
|
const [migrating, setMigrating] = useState(false);
|
||||||
|
|
||||||
|
const [accountType, setAccountType] = useState<AccountType>('user');
|
||||||
|
const [accountPhone, setAccountPhone] = useState('');
|
||||||
|
const [accounts, setAccounts] = useState<Paginated<Record<string, unknown>> | null>(null);
|
||||||
|
const [accountsLoading, setAccountsLoading] = useState(false);
|
||||||
|
const [accountPage, setAccountPage] = useState(1);
|
||||||
|
const [accountPageSize, setAccountPageSize] = useState(20);
|
||||||
|
|
||||||
|
const [linkedOpen, setLinkedOpen] = useState(false);
|
||||||
|
const [linkedLoading, setLinkedLoading] = useState(false);
|
||||||
|
const [linked, setLinked] = useState<LinkedPayload | null>(null);
|
||||||
|
|
||||||
|
async function loadMockFlags() {
|
||||||
|
setMockLoading(true);
|
||||||
|
try {
|
||||||
|
const cfg = await request<{ mockSms: boolean; mockWechat: boolean; mockPay: boolean }>(
|
||||||
|
'/admin/test-whitelist/mock-flags',
|
||||||
|
);
|
||||||
|
setMockSms(!!cfg.mockSms);
|
||||||
|
setMockWechat(!!cfg.mockWechat);
|
||||||
|
setMockPay(!!cfg.mockPay);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '加载 Mock 配置失败');
|
||||||
|
} finally {
|
||||||
|
setMockLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveMockFlags(next: { MOCK_SMS?: boolean; MOCK_WECHAT?: boolean; MOCK_PAY?: boolean }) {
|
||||||
|
const body: { mockSms?: boolean; mockWechat?: boolean; mockPay?: boolean } = {};
|
||||||
|
if (next.MOCK_SMS !== undefined) body.mockSms = next.MOCK_SMS;
|
||||||
|
if (next.MOCK_WECHAT !== undefined) body.mockWechat = next.MOCK_WECHAT;
|
||||||
|
if (next.MOCK_PAY !== undefined) body.mockPay = next.MOCK_PAY;
|
||||||
|
setMockSaving(true);
|
||||||
|
try {
|
||||||
|
const cfg = await request<{ mockSms: boolean; mockWechat: boolean; mockPay: boolean }>(
|
||||||
|
'/admin/test-whitelist/mock-flags',
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
setMockSms(!!cfg.mockSms);
|
||||||
|
setMockWechat(!!cfg.mockWechat);
|
||||||
|
setMockPay(!!cfg.mockPay);
|
||||||
|
message.success('已保存');
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '保存失败');
|
||||||
|
await loadMockFlags();
|
||||||
|
} finally {
|
||||||
|
setMockSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadPhones = useCallback(async () => {
|
||||||
|
setPhonesLoading(true);
|
||||||
|
try {
|
||||||
|
const qs = new URLSearchParams({
|
||||||
|
page: String(phonePage),
|
||||||
|
pageSize: String(phonePageSize),
|
||||||
|
});
|
||||||
|
if (phoneFilters.phone) qs.set('phone', phoneFilters.phone);
|
||||||
|
const res = await request<Paginated<PhoneRow>>(`/admin/test-whitelist/phones?${qs}`);
|
||||||
|
setPhones(res);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '加载手机号名单失败');
|
||||||
|
} finally {
|
||||||
|
setPhonesLoading(false);
|
||||||
|
}
|
||||||
|
}, [phonePage, phonePageSize, phoneFilters]);
|
||||||
|
|
||||||
|
const loadAccounts = useCallback(async () => {
|
||||||
|
setAccountsLoading(true);
|
||||||
|
try {
|
||||||
|
const qs = new URLSearchParams({
|
||||||
|
type: accountType,
|
||||||
|
page: String(accountPage),
|
||||||
|
pageSize: String(accountPageSize),
|
||||||
|
});
|
||||||
|
if (accountPhone.trim()) qs.set('phone', accountPhone.trim());
|
||||||
|
const res = await request<Paginated<Record<string, unknown>>>(
|
||||||
|
`/admin/test-whitelist/accounts?${qs}`,
|
||||||
|
);
|
||||||
|
setAccounts(res);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '加载测试账号失败');
|
||||||
|
} finally {
|
||||||
|
setAccountsLoading(false);
|
||||||
|
}
|
||||||
|
}, [accountType, accountPage, accountPageSize, accountPhone]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadMockFlags();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadPhones();
|
||||||
|
}, [loadPhones]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadAccounts();
|
||||||
|
}, [loadAccounts]);
|
||||||
|
|
||||||
|
async function onAddPhone() {
|
||||||
|
const v = await addForm.validateFields();
|
||||||
|
try {
|
||||||
|
await request('/admin/test-whitelist/phones', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ phone: v.phone, note: v.note || undefined }),
|
||||||
|
});
|
||||||
|
message.success('已添加');
|
||||||
|
setAddOpen(false);
|
||||||
|
addForm.resetFields();
|
||||||
|
setPhonePage(1);
|
||||||
|
void loadPhones();
|
||||||
|
void loadAccounts();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '添加失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onEditPhone() {
|
||||||
|
if (!editRow) return;
|
||||||
|
const v = await editForm.validateFields();
|
||||||
|
try {
|
||||||
|
await request(`/admin/test-whitelist/phones/${editRow.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ note: v.note ?? null }),
|
||||||
|
});
|
||||||
|
message.success('已更新');
|
||||||
|
setEditOpen(false);
|
||||||
|
setEditRow(null);
|
||||||
|
void loadPhones();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '更新失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDeletePhone(id: string) {
|
||||||
|
try {
|
||||||
|
await request(`/admin/test-whitelist/phones/${id}`, { method: 'DELETE' });
|
||||||
|
message.success('已删除');
|
||||||
|
void loadPhones();
|
||||||
|
void loadAccounts();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '删除失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onMigrate() {
|
||||||
|
setMigrating(true);
|
||||||
|
try {
|
||||||
|
const res = await request<{ importedCandidates: number; added: number }>(
|
||||||
|
'/admin/test-whitelist/migrate-visibility',
|
||||||
|
{ method: 'POST' },
|
||||||
|
);
|
||||||
|
message.success(
|
||||||
|
`导入完成:候选 ${res.importedCandidates} 个,新增 ${res.added} 个`,
|
||||||
|
);
|
||||||
|
void loadPhones();
|
||||||
|
void loadAccounts();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '导入失败');
|
||||||
|
} finally {
|
||||||
|
setMigrating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openLinked(row: PhoneRow) {
|
||||||
|
setLinkedOpen(true);
|
||||||
|
setLinkedLoading(true);
|
||||||
|
setLinked(null);
|
||||||
|
try {
|
||||||
|
const res = await request<LinkedPayload>(`/admin/test-whitelist/phones/${row.id}/linked`);
|
||||||
|
setLinked(res);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '加载关联失败');
|
||||||
|
setLinkedOpen(false);
|
||||||
|
} finally {
|
||||||
|
setLinkedLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const phoneColumns: ColumnsType<PhoneRow> = [
|
||||||
|
{ title: '手机号', dataIndex: 'phone', width: 140 },
|
||||||
|
{
|
||||||
|
title: '备注',
|
||||||
|
dataIndex: 'note',
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v) => v || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
dataIndex: 'createdAt',
|
||||||
|
width: 170,
|
||||||
|
render: fmtTime,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 220,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Space size={0}>
|
||||||
|
<Button type="link" size="small" onClick={() => void openLinked(row)}>
|
||||||
|
关联账号
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
onClick={() => {
|
||||||
|
setEditRow(row);
|
||||||
|
editForm.setFieldsValue({ note: row.note ?? '' });
|
||||||
|
setEditOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
编辑备注
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="确认移出白名单?"
|
||||||
|
description="将同步清除该手机号关联账号的测试标记"
|
||||||
|
okText="确认"
|
||||||
|
cancelText="取消"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
onConfirm={() => void onDeletePhone(row.id)}
|
||||||
|
>
|
||||||
|
<Button type="link" size="small" danger>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function accountColumns(): ColumnsType<Record<string, unknown>> {
|
||||||
|
if (accountType === 'user') {
|
||||||
|
return [
|
||||||
|
{ title: '用户编号', dataIndex: 'userNo', width: 120 },
|
||||||
|
{ title: '昵称', dataIndex: 'nickname', width: 100, render: (v) => (v as string) || '—' },
|
||||||
|
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||||
|
{
|
||||||
|
title: '标记',
|
||||||
|
dataIndex: 'isTest',
|
||||||
|
width: 80,
|
||||||
|
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
|
||||||
|
},
|
||||||
|
{ title: '注册', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (accountType === 'store_account') {
|
||||||
|
return [
|
||||||
|
{ title: '姓名', dataIndex: 'name', width: 100 },
|
||||||
|
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||||
|
{ title: '状态', dataIndex: 'status', width: 90 },
|
||||||
|
{
|
||||||
|
title: '标记',
|
||||||
|
dataIndex: 'isTest',
|
||||||
|
width: 80,
|
||||||
|
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
|
||||||
|
},
|
||||||
|
{ title: '创建', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (accountType === 'partner') {
|
||||||
|
return [
|
||||||
|
{ title: '姓名', dataIndex: 'name', width: 100 },
|
||||||
|
{ title: '公司', dataIndex: 'companyName', ellipsis: true, render: (v) => (v as string) || '—' },
|
||||||
|
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||||
|
{
|
||||||
|
title: '标记',
|
||||||
|
dataIndex: 'isTest',
|
||||||
|
width: 80,
|
||||||
|
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
|
||||||
|
},
|
||||||
|
{ title: '创建', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (accountType === 'store') {
|
||||||
|
return [
|
||||||
|
{ title: '门店名', dataIndex: 'name', width: 160, ellipsis: true },
|
||||||
|
{ title: '城市', dataIndex: 'cityName', width: 90 },
|
||||||
|
{ title: '电话', dataIndex: 'phone', width: 120 },
|
||||||
|
{ title: '状态', dataIndex: 'status', width: 90 },
|
||||||
|
{
|
||||||
|
title: '标记',
|
||||||
|
dataIndex: 'isTest',
|
||||||
|
width: 80,
|
||||||
|
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
|
||||||
|
},
|
||||||
|
{ title: '创建', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{ title: '订单号', dataIndex: 'orderNo', width: 180 },
|
||||||
|
{ title: '状态', dataIndex: 'status', width: 110 },
|
||||||
|
{
|
||||||
|
title: '实付',
|
||||||
|
dataIndex: 'payAmount',
|
||||||
|
width: 90,
|
||||||
|
render: (v) => `¥${v}`,
|
||||||
|
},
|
||||||
|
{ title: '收货手机', dataIndex: 'receiverPhone', width: 120 },
|
||||||
|
{
|
||||||
|
title: '用户手机',
|
||||||
|
width: 120,
|
||||||
|
render: (_, row) =>
|
||||||
|
(row.user as { phone?: string | null } | undefined)?.phone || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '标记',
|
||||||
|
dataIndex: 'isTest',
|
||||||
|
width: 80,
|
||||||
|
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
|
||||||
|
},
|
||||||
|
{ title: '下单', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={4} style={{ marginTop: 0 }}>
|
||||||
|
白名单管理
|
||||||
|
</Typography.Title>
|
||||||
|
|
||||||
|
<Card size="small" loading={mockLoading} style={{ marginBottom: 16 }}>
|
||||||
|
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
||||||
|
Mock 开关(与系统设置同源,勾选 = 不做真实验证)
|
||||||
|
</Typography.Text>
|
||||||
|
<Space wrap>
|
||||||
|
<Checkbox
|
||||||
|
checked={mockSms}
|
||||||
|
disabled={mockSaving}
|
||||||
|
onChange={(e) => void saveMockFlags({ MOCK_SMS: e.target.checked })}
|
||||||
|
>
|
||||||
|
短信不做真实验证
|
||||||
|
</Checkbox>
|
||||||
|
<Checkbox
|
||||||
|
checked={mockWechat}
|
||||||
|
disabled={mockSaving}
|
||||||
|
onChange={(e) => void saveMockFlags({ MOCK_WECHAT: e.target.checked })}
|
||||||
|
>
|
||||||
|
微信不做真实验证
|
||||||
|
</Checkbox>
|
||||||
|
<Checkbox
|
||||||
|
checked={mockPay}
|
||||||
|
disabled={mockSaving}
|
||||||
|
onChange={(e) => void saveMockFlags({ MOCK_PAY: e.target.checked })}
|
||||||
|
>
|
||||||
|
支付不做真实验证
|
||||||
|
</Checkbox>
|
||||||
|
</Space>
|
||||||
|
<Typography.Text type="secondary" style={{ display: 'block', marginTop: 8, fontSize: 12 }}>
|
||||||
|
配置键:{MOCK_KEYS.join(' / ')}
|
||||||
|
</Typography.Text>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'phones',
|
||||||
|
label: '手机号名单',
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }} wrap>
|
||||||
|
<Form
|
||||||
|
form={phoneForm}
|
||||||
|
layout="inline"
|
||||||
|
onFinish={(v) => {
|
||||||
|
setPhoneFilters({ phone: v.phone || undefined });
|
||||||
|
setPhonePage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item name="phone" label="手机号">
|
||||||
|
<Input allowClear placeholder="模糊搜索" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit">
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '从可见性白名单导入',
|
||||||
|
content: '将商品/门店旧可见性手机号合并入全局名单(幂等),并同步测试标记。',
|
||||||
|
okText: '开始导入',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: () => onMigrate(),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
loading={migrating}
|
||||||
|
>
|
||||||
|
从可见性白名单导入
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" onClick={() => setAddOpen(true)}>
|
||||||
|
添加手机号
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={phonesLoading}
|
||||||
|
columns={phoneColumns}
|
||||||
|
dataSource={phones?.items ?? []}
|
||||||
|
pagination={{
|
||||||
|
current: phonePage,
|
||||||
|
pageSize: phonePageSize,
|
||||||
|
total: phones?.total ?? 0,
|
||||||
|
showSizeChanger: true,
|
||||||
|
onChange: (p, ps) => {
|
||||||
|
setPhonePage(p);
|
||||||
|
setPhonePageSize(ps);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'accounts',
|
||||||
|
label: '测试账号记录',
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<Space style={{ marginBottom: 16 }} wrap>
|
||||||
|
<Select
|
||||||
|
style={{ width: 140 }}
|
||||||
|
value={accountType}
|
||||||
|
options={ACCOUNT_TYPE_OPTIONS}
|
||||||
|
onChange={(v: AccountType) => {
|
||||||
|
setAccountType(v);
|
||||||
|
setAccountPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
allowClear
|
||||||
|
placeholder="按手机号筛选"
|
||||||
|
style={{ width: 160 }}
|
||||||
|
value={accountPhone}
|
||||||
|
onChange={(e) => setAccountPhone(e.target.value)}
|
||||||
|
onPressEnter={() => {
|
||||||
|
setAccountPage(1);
|
||||||
|
void loadAccounts();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={() => {
|
||||||
|
if (accountPage !== 1) setAccountPage(1);
|
||||||
|
else void loadAccounts();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={accountsLoading}
|
||||||
|
columns={accountColumns()}
|
||||||
|
dataSource={accounts?.items ?? []}
|
||||||
|
scroll={{ x: 900 }}
|
||||||
|
pagination={{
|
||||||
|
current: accountPage,
|
||||||
|
pageSize: accountPageSize,
|
||||||
|
total: accounts?.total ?? 0,
|
||||||
|
showSizeChanger: true,
|
||||||
|
onChange: (p, ps) => {
|
||||||
|
setAccountPage(p);
|
||||||
|
setAccountPageSize(ps);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="添加白名单手机号"
|
||||||
|
open={addOpen}
|
||||||
|
onCancel={() => setAddOpen(false)}
|
||||||
|
onOk={() => void onAddPhone()}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Form form={addForm} layout="vertical">
|
||||||
|
<Form.Item
|
||||||
|
name="phone"
|
||||||
|
label="手机号"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请输入手机号' },
|
||||||
|
{ pattern: /^1\d{10}$/, message: '请输入 11 位手机号' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input placeholder="1xxxxxxxxxx" maxLength={11} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="note" label="备注">
|
||||||
|
<Input.TextArea rows={2} maxLength={256} showCount />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={`编辑备注 · ${editRow?.phone ?? ''}`}
|
||||||
|
open={editOpen}
|
||||||
|
onCancel={() => {
|
||||||
|
setEditOpen(false);
|
||||||
|
setEditRow(null);
|
||||||
|
}}
|
||||||
|
onOk={() => void onEditPhone()}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Form form={editForm} layout="vertical">
|
||||||
|
<Form.Item name="note" label="备注">
|
||||||
|
<Input.TextArea rows={2} maxLength={256} showCount />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
title={linked ? `关联账号 · ${linked.phone.phone}` : '关联账号'}
|
||||||
|
open={linkedOpen}
|
||||||
|
onClose={() => setLinkedOpen(false)}
|
||||||
|
width={560}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
{linkedLoading ? (
|
||||||
|
<Typography.Text type="secondary">加载中…</Typography.Text>
|
||||||
|
) : linked ? (
|
||||||
|
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||||
|
<Descriptions size="small" column={1} bordered>
|
||||||
|
<Descriptions.Item label="手机号">{linked.phone.phone}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="备注">{linked.phone.note || '—'}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={5}>C 端用户({linked.users.length})</Typography.Title>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
dataSource={linked.users}
|
||||||
|
columns={[
|
||||||
|
{ title: '编号', dataIndex: 'userNo' },
|
||||||
|
{ title: '昵称', dataIndex: 'nickname', render: (v) => v || '—' },
|
||||||
|
{
|
||||||
|
title: '测试',
|
||||||
|
dataIndex: 'isTest',
|
||||||
|
width: 70,
|
||||||
|
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={5}>门店账号({linked.storeAccounts.length})</Typography.Title>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
dataSource={linked.storeAccounts}
|
||||||
|
columns={[
|
||||||
|
{ title: '姓名', dataIndex: 'name' },
|
||||||
|
{ title: '状态', dataIndex: 'status', width: 90 },
|
||||||
|
{
|
||||||
|
title: '测试',
|
||||||
|
dataIndex: 'isTest',
|
||||||
|
width: 70,
|
||||||
|
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={5}>合伙人({linked.partners.length})</Typography.Title>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
dataSource={linked.partners}
|
||||||
|
columns={[
|
||||||
|
{ title: '姓名', dataIndex: 'name' },
|
||||||
|
{ title: '公司', dataIndex: 'companyName', render: (v) => v || '—' },
|
||||||
|
{
|
||||||
|
title: '测试',
|
||||||
|
dataIndex: 'isTest',
|
||||||
|
width: 70,
|
||||||
|
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={5}>门店({linked.stores.length})</Typography.Title>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
dataSource={linked.stores}
|
||||||
|
columns={[
|
||||||
|
{ title: '名称', dataIndex: 'name' },
|
||||||
|
{ title: '状态', dataIndex: 'status', width: 90 },
|
||||||
|
{
|
||||||
|
title: '测试',
|
||||||
|
dataIndex: 'isTest',
|
||||||
|
width: 70,
|
||||||
|
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
) : null}
|
||||||
|
</Drawer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -112,6 +112,7 @@ export default function UsersPage() {
|
|||||||
if (values.status !== undefined && values.status !== '') {
|
if (values.status !== undefined && values.status !== '') {
|
||||||
qs.set('status', String(values.status));
|
qs.set('status', String(values.status));
|
||||||
}
|
}
|
||||||
|
if (values.excludeTest) qs.set('excludeTest', 'true');
|
||||||
const res = await request<Paginated<AdminUserRow>>(`/admin/users?${qs}`);
|
const res = await request<Paginated<AdminUserRow>>(`/admin/users?${qs}`);
|
||||||
setData(res);
|
setData(res);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -237,7 +238,17 @@ export default function UsersPage() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const columns: ColumnsType<AdminUserRow> = [
|
const columns: ColumnsType<AdminUserRow> = [
|
||||||
{ title: '用户编号', dataIndex: 'userNo', width: 120 },
|
{
|
||||||
|
title: '用户编号',
|
||||||
|
dataIndex: 'userNo',
|
||||||
|
width: 140,
|
||||||
|
render: (v, row) => (
|
||||||
|
<Space size={4}>
|
||||||
|
<span>{v}</span>
|
||||||
|
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
{ title: '昵称', dataIndex: 'nickname', width: 100 },
|
{ title: '昵称', dataIndex: 'nickname', width: 100 },
|
||||||
{
|
{
|
||||||
title: '手机',
|
title: '手机',
|
||||||
@@ -362,6 +373,9 @@ export default function UsersPage() {
|
|||||||
{ value: 0, label: '停用' },
|
{ value: 0, label: '停用' },
|
||||||
]} />
|
]} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item name="excludeTest" valuePropName="checked">
|
||||||
|
<Checkbox>过滤测试账号</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Space>
|
<Space>
|
||||||
<Button type="primary" htmlType="submit">查询</Button>
|
<Button type="primary" htmlType="submit">查询</Button>
|
||||||
|
|||||||
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}
|
{shouldRenderPageTabBar() ? <UserTabBar selected={3} /> : null}
|
||||||
|
|
||||||
{profileSheetOpen ? (
|
{profileSheetOpen ? (
|
||||||
<View className="mine-profile-sheet-mask" onClick={() => !savingProfile && setProfileSheetOpen(false)}>
|
<View className="mine-profile-sheet-mask">
|
||||||
<View className="mine-profile-sheet" onClick={(e) => e.stopPropagation()}>
|
{/* 遮罩单独绑 tap,勿在含 chooseAvatar 的祖先上用 stopPropagation(会编译成 catchtap 导致选头像无反应) */}
|
||||||
|
<View
|
||||||
|
className="mine-profile-sheet-backdrop"
|
||||||
|
onClick={() => !savingProfile && setProfileSheetOpen(false)}
|
||||||
|
/>
|
||||||
|
<View className="mine-profile-sheet">
|
||||||
<Text className="mine-profile-sheet-title">完善头像与昵称</Text>
|
<Text className="mine-profile-sheet-title">完善头像与昵称</Text>
|
||||||
<Text className="mine-profile-sheet-hint">
|
<Text className="mine-profile-sheet-hint">
|
||||||
点击头像选择,并填写昵称后保存(用于个人中心展示)
|
点击头像选择,并填写昵称后保存(用于个人中心展示)
|
||||||
@@ -534,6 +539,7 @@ export default function MinePage() {
|
|||||||
className="mine-profile-avatar-btn"
|
className="mine-profile-avatar-btn"
|
||||||
openType="chooseAvatar"
|
openType="chooseAvatar"
|
||||||
hoverClass="none"
|
hoverClass="none"
|
||||||
|
plain
|
||||||
onChooseAvatar={onChooseAvatar}
|
onChooseAvatar={onChooseAvatar}
|
||||||
>
|
>
|
||||||
<View className="mine-profile-avatar-preview">
|
<View className="mine-profile-avatar-preview">
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export default function OrderConfirmPickupPage() {
|
|||||||
|
|
||||||
const { confirm } = await Taro.showModal({
|
const { confirm } = await Taro.showModal({
|
||||||
title: '确认提交订单',
|
title: '确认提交订单',
|
||||||
content: `确认提交现场提货订单?共 ${quantity} 瓶,应付 ¥${Number(preview?.payAmount ?? 0).toFixed(2)}。`,
|
content: `请确保您已拿到货品,货款将直接打给商家,如不是现场交易请选择立即购买方式下单,我们会为您安排配送到家。`,
|
||||||
confirmText: '确认提交',
|
confirmText: '确认提交',
|
||||||
cancelText: '再想想',
|
cancelText: '再想想',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -386,13 +386,20 @@
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
background: rgba(20, 16, 14, 0.45);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mine-profile-sheet-backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(20, 16, 14, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
.mine-profile-sheet {
|
.mine-profile-sheet {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 480px;
|
max-width: 480px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -422,8 +429,9 @@
|
|||||||
margin: 20px auto 0;
|
margin: 20px auto 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
width: auto;
|
width: auto;
|
||||||
background: transparent;
|
height: auto;
|
||||||
border: none;
|
background: transparent !important;
|
||||||
|
border: none !important;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -434,6 +442,11 @@
|
|||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 子节点不抢触摸,保证 open-type=chooseAvatar 由 Button 本人响应 */
|
||||||
|
.mine-profile-avatar-btn > * {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.mine-profile-avatar-preview {
|
.mine-profile-avatar-preview {
|
||||||
width: 88px;
|
width: 88px;
|
||||||
height: 88px;
|
height: 88px;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export const HQ_PERMISSION_CATALOG = [
|
|||||||
{ key: 'llm_configs', label: '语言模型配置', group: '业务' },
|
{ key: 'llm_configs', label: '语言模型配置', group: '业务' },
|
||||||
{ key: 'knowledge_bases', label: '知识库', group: '业务' },
|
{ key: 'knowledge_bases', label: '知识库', group: '业务' },
|
||||||
{ key: 'dev_plan', label: '开发计划', group: '业务' },
|
{ key: 'dev_plan', label: '开发计划', group: '业务' },
|
||||||
|
{ key: 'test_whitelist', label: '白名单管理', group: '业务' },
|
||||||
{ key: 'resources', label: 'OSS 资源库', group: '业务' },
|
{ key: 'resources', label: 'OSS 资源库', group: '业务' },
|
||||||
{ key: 'logs', label: '日志', group: '业务' },
|
{ key: 'logs', label: '日志', group: '业务' },
|
||||||
{ key: 'users_delete', label: '删除用户', group: '危险操作' },
|
{ key: 'users_delete', label: '删除用户', group: '危险操作' },
|
||||||
@@ -116,6 +117,7 @@ export const HQ_ROLE_DEFAULT_PERMISSIONS: Record<string, HqPermissionKey[]> = {
|
|||||||
'llm_configs',
|
'llm_configs',
|
||||||
'knowledge_bases',
|
'knowledge_bases',
|
||||||
'dev_plan',
|
'dev_plan',
|
||||||
|
'test_whitelist',
|
||||||
'resources',
|
'resources',
|
||||||
'logs',
|
'logs',
|
||||||
'system_settings_wechat_mini',
|
'system_settings_wechat_mini',
|
||||||
|
|||||||
@@ -812,6 +812,21 @@ model CommonProductVisibilityPhone {
|
|||||||
@@map("common_product_visibility_phone")
|
@@map("common_product_visibility_phone")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 全局测试白名单手机号(测试账号 + 限测商品/门店可见)
|
||||||
|
model CommonTestWhitelistPhone {
|
||||||
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
|
phone String @unique @db.VarChar(20)
|
||||||
|
note String? @db.VarChar(256)
|
||||||
|
createdByHqId BigInt? @map("created_by_hq_id") @db.UnsignedBigInt
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||||
|
|
||||||
|
createdBy HqAccount? @relation("TestWhitelistCreatedBy", fields: [createdByHqId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([createdAt])
|
||||||
|
@@map("common_test_whitelist_phone")
|
||||||
|
}
|
||||||
|
|
||||||
model CommonProductDetailTemplate {
|
model CommonProductDetailTemplate {
|
||||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
code String @unique @db.VarChar(32)
|
code String @unique @db.VarChar(32)
|
||||||
@@ -1043,6 +1058,8 @@ model PartnerAccount {
|
|||||||
bankBranch String? @map("bank_branch") @db.VarChar(128)
|
bankBranch String? @map("bank_branch") @db.VarChar(128)
|
||||||
weeklyStoreTarget Int? @default(20) @map("weekly_store_target")
|
weeklyStoreTarget Int? @default(20) @map("weekly_store_target")
|
||||||
managedWarehouseId BigInt? @unique @map("managed_warehouse_id") @db.UnsignedBigInt
|
managedWarehouseId BigInt? @unique @map("managed_warehouse_id") @db.UnsignedBigInt
|
||||||
|
/// 测试合伙人账号
|
||||||
|
isTest Boolean @default(false) @map("is_test")
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||||
|
|
||||||
@@ -1059,6 +1076,7 @@ model PartnerAccount {
|
|||||||
@@index([parentAccountId])
|
@@index([parentAccountId])
|
||||||
@@index([wxOpenId])
|
@@index([wxOpenId])
|
||||||
@@index([contactPhone])
|
@@index([contactPhone])
|
||||||
|
@@index([isTest])
|
||||||
@@map("partner_account")
|
@@map("partner_account")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1102,6 +1120,7 @@ model HqAccount {
|
|||||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||||
|
|
||||||
permissions HqAccountPermission[]
|
permissions HqAccountPermission[]
|
||||||
|
testWhitelistPhones CommonTestWhitelistPhone[] @relation("TestWhitelistCreatedBy")
|
||||||
|
|
||||||
@@map("hq_account")
|
@@map("hq_account")
|
||||||
}
|
}
|
||||||
@@ -1140,6 +1159,8 @@ model User {
|
|||||||
nickname String? @db.VarChar(64)
|
nickname String? @db.VarChar(64)
|
||||||
avatarResourceId BigInt? @map("avatar_resource_id") @db.UnsignedBigInt
|
avatarResourceId BigInt? @map("avatar_resource_id") @db.UnsignedBigInt
|
||||||
status Int @default(1) @db.TinyInt
|
status Int @default(1) @db.TinyInt
|
||||||
|
/// 测试账号:命中全局测试白名单手机号
|
||||||
|
isTest Boolean @default(false) @map("is_test")
|
||||||
sourceType UserSourceType @default(ORGANIC) @map("source_type")
|
sourceType UserSourceType @default(ORGANIC) @map("source_type")
|
||||||
sourceRefId BigInt? @map("source_ref_id") @db.UnsignedBigInt
|
sourceRefId BigInt? @map("source_ref_id") @db.UnsignedBigInt
|
||||||
sourceLabel String? @map("source_label") @db.VarChar(128)
|
sourceLabel String? @map("source_label") @db.VarChar(128)
|
||||||
@@ -1166,6 +1187,7 @@ model User {
|
|||||||
@@index([referrerUserId])
|
@@index([referrerUserId])
|
||||||
@@index([mergedIntoUserId])
|
@@index([mergedIntoUserId])
|
||||||
@@index([wxOpenId])
|
@@index([wxOpenId])
|
||||||
|
@@index([isTest])
|
||||||
@@map("user_user")
|
@@map("user_user")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1253,6 +1275,8 @@ model Store {
|
|||||||
visibilityWhitelistEnabled Boolean @default(false) @map("visibility_whitelist_enabled")
|
visibilityWhitelistEnabled Boolean @default(false) @map("visibility_whitelist_enabled")
|
||||||
/// FIN-001:允许未出账手动提现的白名单门店
|
/// FIN-001:允许未出账手动提现的白名单门店
|
||||||
withdrawWhitelistEnabled Boolean @default(false) @map("withdraw_whitelist_enabled")
|
withdrawWhitelistEnabled Boolean @default(false) @map("withdraw_whitelist_enabled")
|
||||||
|
/// 测试门店:不计结算 / HQ 可手动标记
|
||||||
|
isTest Boolean @default(false) @map("is_test")
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||||
|
|
||||||
@@ -1274,6 +1298,7 @@ model Store {
|
|||||||
@@index([cityId, status])
|
@@index([cityId, status])
|
||||||
@@index([partnerAccountId])
|
@@index([partnerAccountId])
|
||||||
@@index([auditStatus, createdAt])
|
@@index([auditStatus, createdAt])
|
||||||
|
@@index([isTest])
|
||||||
@@map("store_store")
|
@@map("store_store")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1344,6 +1369,8 @@ model StoreAccount {
|
|||||||
bankBranch String? @map("bank_branch") @db.VarChar(128)
|
bankBranch String? @map("bank_branch") @db.VarChar(128)
|
||||||
status AccountStatus @default(ACTIVE)
|
status AccountStatus @default(ACTIVE)
|
||||||
lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3)
|
lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3)
|
||||||
|
/// 测试门店账号(商户)
|
||||||
|
isTest Boolean @default(false) @map("is_test")
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||||
|
|
||||||
@@ -1354,6 +1381,7 @@ model StoreAccount {
|
|||||||
withdrawRequests StoreWithdrawRequest[]
|
withdrawRequests StoreWithdrawRequest[]
|
||||||
|
|
||||||
@@index([parentAccountId])
|
@@index([parentAccountId])
|
||||||
|
@@index([isTest])
|
||||||
@@map("store_account")
|
@@map("store_account")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1432,6 +1460,8 @@ model Order {
|
|||||||
fulfillmentHold Boolean @default(false) @map("fulfillment_hold")
|
fulfillmentHold Boolean @default(false) @map("fulfillment_hold")
|
||||||
fulfillmentHoldReason String? @map("fulfillment_hold_reason") @db.VarChar(64)
|
fulfillmentHoldReason String? @map("fulfillment_hold_reason") @db.VarChar(64)
|
||||||
remark String? @db.VarChar(512)
|
remark String? @db.VarChar(512)
|
||||||
|
/// 测试订单快照(下单时取自 User.isTest)
|
||||||
|
isTest Boolean @default(false) @map("is_test")
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||||
|
|
||||||
@@ -1456,6 +1486,7 @@ model Order {
|
|||||||
@@index([gpsCity])
|
@@index([gpsCity])
|
||||||
@@index([fulfillmentWarehouseId])
|
@@index([fulfillmentWarehouseId])
|
||||||
@@index([proxyPartnerAccountId])
|
@@index([proxyPartnerAccountId])
|
||||||
|
@@index([isTest])
|
||||||
@@map("user_order")
|
@@map("user_order")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1548,6 +1579,8 @@ model RedeemRecord {
|
|||||||
settleAmount Decimal @map("settle_amount") @db.Decimal(10, 2)
|
settleAmount Decimal @map("settle_amount") @db.Decimal(10, 2)
|
||||||
/// SCAN=qrcode, PHONE=phone
|
/// SCAN=qrcode, PHONE=phone
|
||||||
channel RedeemChannel @default(SCAN)
|
channel RedeemChannel @default(SCAN)
|
||||||
|
/// 测试核销快照(User.isTest || Store.isTest)
|
||||||
|
isTest Boolean @default(false) @map("is_test")
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
|
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
|
||||||
@@ -1560,6 +1593,7 @@ model RedeemRecord {
|
|||||||
|
|
||||||
@@index([storeId, createdAt])
|
@@index([storeId, createdAt])
|
||||||
@@index([storeId, channel, createdAt])
|
@@index([storeId, channel, createdAt])
|
||||||
|
@@index([isTest])
|
||||||
@@map("user_redeem_record")
|
@@map("user_redeem_record")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,16 @@ async function main() {
|
|||||||
|
|
||||||
await prisma.storePayout.deleteMany();
|
await prisma.storePayout.deleteMany();
|
||||||
|
|
||||||
|
await prisma.storeWithdrawPayoutItem.deleteMany();
|
||||||
|
|
||||||
|
await prisma.storeWithdrawRequest.deleteMany();
|
||||||
|
|
||||||
|
await prisma.storeBill.deleteMany();
|
||||||
|
|
||||||
|
await prisma.redeemPendingRecord.deleteMany();
|
||||||
|
|
||||||
|
await prisma.logStoreAnalytics.deleteMany();
|
||||||
|
|
||||||
await prisma.storeRating.deleteMany();
|
await prisma.storeRating.deleteMany();
|
||||||
|
|
||||||
await prisma.redeemRecord.deleteMany();
|
await prisma.redeemRecord.deleteMany();
|
||||||
@@ -83,16 +93,28 @@ async function main() {
|
|||||||
|
|
||||||
await prisma.user.deleteMany();
|
await prisma.user.deleteMany();
|
||||||
|
|
||||||
|
await prisma.storeAccount.updateMany({ data: { parentAccountId: null } });
|
||||||
await prisma.storeAccount.deleteMany();
|
await prisma.storeAccount.deleteMany();
|
||||||
|
|
||||||
await prisma.store.deleteMany();
|
await prisma.store.deleteMany();
|
||||||
|
|
||||||
await prisma.partnerBill.deleteMany();
|
await prisma.partnerBill.deleteMany();
|
||||||
|
|
||||||
|
await prisma.logisticsBillItem.deleteMany();
|
||||||
|
|
||||||
|
await prisma.logisticsPrepaidLedger.deleteMany();
|
||||||
|
|
||||||
|
await prisma.logisticsBill.deleteMany();
|
||||||
|
|
||||||
|
await prisma.wineryBillItem.deleteMany();
|
||||||
|
|
||||||
|
await prisma.wineryBill.deleteMany();
|
||||||
|
|
||||||
await prisma.cityWarehouse.deleteMany();
|
await prisma.cityWarehouse.deleteMany();
|
||||||
|
|
||||||
await prisma.fulfillmentProvider.deleteMany();
|
await prisma.fulfillmentProvider.deleteMany();
|
||||||
|
|
||||||
|
await prisma.partnerAccount.updateMany({ data: { parentAccountId: null } });
|
||||||
await prisma.partnerAccount.deleteMany();
|
await prisma.partnerAccount.deleteMany();
|
||||||
|
|
||||||
await prisma.commonCity.deleteMany();
|
await prisma.commonCity.deleteMany();
|
||||||
@@ -101,6 +123,7 @@ async function main() {
|
|||||||
|
|
||||||
await prisma.commonProductDetailTemplate.deleteMany();
|
await prisma.commonProductDetailTemplate.deleteMany();
|
||||||
|
|
||||||
|
await prisma.commonStoreCategory.updateMany({ data: { parentId: null } });
|
||||||
await prisma.commonStoreCategory.deleteMany();
|
await prisma.commonStoreCategory.deleteMany();
|
||||||
|
|
||||||
await prisma.commonPromoCode.deleteMany();
|
await prisma.commonPromoCode.deleteMany();
|
||||||
@@ -914,6 +937,25 @@ async function main() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const testWhitelistPhones = [
|
||||||
|
'13800000001',
|
||||||
|
'13700000001',
|
||||||
|
'13700000002',
|
||||||
|
'13910000001',
|
||||||
|
'13910000002',
|
||||||
|
];
|
||||||
|
for (const phone of testWhitelistPhones) {
|
||||||
|
await prisma.commonTestWhitelistPhone.upsert({
|
||||||
|
where: { phone },
|
||||||
|
create: { phone, note: 'seed 测试账号' },
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
await prisma.user.updateMany({ where: { phone }, data: { isTest: true } });
|
||||||
|
await prisma.storeAccount.updateMany({ where: { phone }, data: { isTest: true } });
|
||||||
|
await prisma.partnerAccount.updateMany({ where: { phone }, data: { isTest: true } });
|
||||||
|
await prisma.store.updateMany({ where: { phone }, data: { isTest: true } });
|
||||||
|
}
|
||||||
|
|
||||||
console.log('Seed complete:', {
|
console.log('Seed complete:', {
|
||||||
|
|
||||||
city: city.name,
|
city: city.name,
|
||||||
@@ -928,6 +970,8 @@ async function main() {
|
|||||||
|
|
||||||
stores: createdStores.length,
|
stores: createdStores.length,
|
||||||
|
|
||||||
|
testWhitelistPhones,
|
||||||
|
|
||||||
testPhones: {
|
testPhones: {
|
||||||
|
|
||||||
user: '13800000001',
|
user: '13800000001',
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { CityScopeModule } from './modules/city-scope/city-scope.module';
|
|||||||
import { CommonModule } from './modules/common/common.module';
|
import { CommonModule } from './modules/common/common.module';
|
||||||
import { HqOperationModule } from './common/hq-operation/hq-operation.module';
|
import { HqOperationModule } from './common/hq-operation/hq-operation.module';
|
||||||
import { SystemConfigModule } from './common/system-config/system-config.module';
|
import { SystemConfigModule } from './common/system-config/system-config.module';
|
||||||
|
import { TestWhitelistModule } from './common/test-whitelist/test-whitelist.module';
|
||||||
import { DevPlanModule } from './modules/dev-plan/dev-plan.module';
|
import { DevPlanModule } from './modules/dev-plan/dev-plan.module';
|
||||||
import { CallbacksModule } from './callbacks/callbacks.module';
|
import { CallbacksModule } from './callbacks/callbacks.module';
|
||||||
import { WecomModule } from './integrations/wecom/wecom.module';
|
import { WecomModule } from './integrations/wecom/wecom.module';
|
||||||
@@ -37,6 +38,7 @@ import { RequestIdMiddleware } from './common/logging/request-id.middleware';
|
|||||||
}),
|
}),
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
SystemConfigModule,
|
SystemConfigModule,
|
||||||
|
TestWhitelistModule,
|
||||||
GeoModule,
|
GeoModule,
|
||||||
RedisModule,
|
RedisModule,
|
||||||
AlertModule,
|
AlertModule,
|
||||||
|
|||||||
@@ -7,11 +7,10 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Reflector } from '@nestjs/core';
|
import { Reflector } from '@nestjs/core';
|
||||||
import {
|
import {
|
||||||
HQ_DANGEROUS_PERMISSION_KEYS,
|
HQ_PERMISSION_CATALOG,
|
||||||
HQ_ROLE_DEFAULT_PERMISSIONS,
|
HQ_ROLE_DEFAULT_PERMISSIONS,
|
||||||
expandHqPermissionKeys,
|
expandHqPermissionKeys,
|
||||||
hasAnySystemSettingsPermission,
|
hasAnySystemSettingsPermission,
|
||||||
hqBasePermissionKeys,
|
|
||||||
type HqPermissionKey,
|
type HqPermissionKey,
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
import { PrismaService } from '../prisma/prisma.module';
|
import { PrismaService } from '../prisma/prisma.module';
|
||||||
@@ -28,7 +27,7 @@ export const RequireAnySystemSettings = () =>
|
|||||||
export class HqPermissionsResolver {
|
export class HqPermissionsResolver {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
async resolveEffectiveKeys(actorId: bigint): Promise<HqPermissionKey[]> {
|
private async loadActiveAccount(actorId: bigint) {
|
||||||
const account = await this.prisma.hqAccount.findUnique({
|
const account = await this.prisma.hqAccount.findUnique({
|
||||||
where: { id: actorId },
|
where: { id: actorId },
|
||||||
select: { adminRole: true, status: true },
|
select: { adminRole: true, status: true },
|
||||||
@@ -36,6 +35,14 @@ export class HqPermissionsResolver {
|
|||||||
if (!account || account.status !== 'ACTIVE') {
|
if (!account || account.status !== 'ACTIVE') {
|
||||||
throw new ForbiddenException('HQ 账号不可用');
|
throw new ForbiddenException('HQ 账号不可用');
|
||||||
}
|
}
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveAccess(actorId: bigint): Promise<{
|
||||||
|
keys: HqPermissionKey[];
|
||||||
|
isSuperAdmin: boolean;
|
||||||
|
}> {
|
||||||
|
const account = await this.loadActiveAccount(actorId);
|
||||||
|
|
||||||
const userRows = await this.prisma.hqAccountPermission.findMany({
|
const userRows = await this.prisma.hqAccountPermission.findMany({
|
||||||
where: { hqAccountId: actorId },
|
where: { hqAccountId: actorId },
|
||||||
@@ -44,12 +51,14 @@ export class HqPermissionsResolver {
|
|||||||
const userKeys = userRows.map((r) => r.permissionKey);
|
const userKeys = userRows.map((r) => r.permissionKey);
|
||||||
|
|
||||||
if (account.adminRole === 'SUPER_ADMIN') {
|
if (account.adminRole === 'SUPER_ADMIN') {
|
||||||
// 超管含危险操作(删用户/订单/城市);其他角色仍需在权限分配中显式勾选
|
// 超管拥有权限目录内全部项(含后续新增),另含危险操作与用户级附加项
|
||||||
return expandHqPermissionKeys([
|
return {
|
||||||
...hqBasePermissionKeys(),
|
isSuperAdmin: true,
|
||||||
...HQ_DANGEROUS_PERMISSION_KEYS,
|
keys: expandHqPermissionKeys([
|
||||||
...userKeys,
|
...HQ_PERMISSION_CATALOG.map((p) => p.key),
|
||||||
]);
|
...userKeys,
|
||||||
|
]),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const roleRows = await this.prisma.hqRolePermission.findMany({
|
const roleRows = await this.prisma.hqRolePermission.findMany({
|
||||||
@@ -62,7 +71,15 @@ export class HqPermissionsResolver {
|
|||||||
? roleRows.map((r) => r.permissionKey)
|
? roleRows.map((r) => r.permissionKey)
|
||||||
: [...(HQ_ROLE_DEFAULT_PERMISSIONS[account.adminRole] ?? [])];
|
: [...(HQ_ROLE_DEFAULT_PERMISSIONS[account.adminRole] ?? [])];
|
||||||
|
|
||||||
return expandHqPermissionKeys([...roleKeys, ...userKeys]);
|
return {
|
||||||
|
isSuperAdmin: false,
|
||||||
|
keys: expandHqPermissionKeys([...roleKeys, ...userKeys]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveEffectiveKeys(actorId: bigint): Promise<HqPermissionKey[]> {
|
||||||
|
const { keys } = await this.resolveAccess(actorId);
|
||||||
|
return keys;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +96,7 @@ export class HqPermissionGuard implements CanActivate {
|
|||||||
if (!user || user.actorType !== 'HQ') {
|
if (!user || user.actorType !== 'HQ') {
|
||||||
throw new ForbiddenException('需要 HQ 权限');
|
throw new ForbiddenException('需要 HQ 权限');
|
||||||
}
|
}
|
||||||
const keys = await this.resolver.resolveEffectiveKeys(user.actorId);
|
const { keys, isSuperAdmin } = await this.resolver.resolveAccess(user.actorId);
|
||||||
req.hqPermissionKeys = keys;
|
req.hqPermissionKeys = keys;
|
||||||
|
|
||||||
const required =
|
const required =
|
||||||
@@ -89,6 +106,7 @@ export class HqPermissionGuard implements CanActivate {
|
|||||||
]) ?? [];
|
]) ?? [];
|
||||||
|
|
||||||
if (!required.length) return true;
|
if (!required.length) return true;
|
||||||
|
if (isSuperAdmin) return true;
|
||||||
if (required.includes('__any_system_settings__')) {
|
if (required.includes('__any_system_settings__')) {
|
||||||
if (!hasAnySystemSettingsPermission(keys)) {
|
if (!hasAnySystemSettingsPermission(keys)) {
|
||||||
throw new ForbiddenException('无系统设置权限');
|
throw new ForbiddenException('无系统设置权限');
|
||||||
|
|||||||
@@ -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 { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
|
import {
|
||||||
|
TestWhitelistService,
|
||||||
|
normalizeTestPhone,
|
||||||
|
} from '../../common/test-whitelist/test-whitelist.service';
|
||||||
import { groupResourcesByProductId, mapProductMedia } from './catalog.mapper';
|
import { groupResourcesByProductId, mapProductMedia } from './catalog.mapper';
|
||||||
|
|
||||||
export type CatalogViewer = {
|
export type CatalogViewer = {
|
||||||
@@ -10,13 +14,32 @@ export type CatalogViewer = {
|
|||||||
bypassWhitelist?: boolean;
|
bypassWhitelist?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function normalizePhone(phone: string | null | undefined): string {
|
|
||||||
return (phone || '').replace(/\D/g, '').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CatalogService {
|
export class CatalogService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly testWhitelist: TestWhitelistService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private async whitelistPhoneSet(): Promise<Set<string>> {
|
||||||
|
const rows = await this.prisma.commonTestWhitelistPhone.findMany({
|
||||||
|
select: { phone: true },
|
||||||
|
});
|
||||||
|
return new Set(rows.map((r) => normalizeTestPhone(r.phone)).filter(Boolean));
|
||||||
|
}
|
||||||
|
|
||||||
|
isVisibleToViewer(
|
||||||
|
product: { visibilityWhitelistEnabled: boolean },
|
||||||
|
viewer?: CatalogViewer,
|
||||||
|
whitelistPhones?: Set<string>,
|
||||||
|
): boolean {
|
||||||
|
if (viewer?.bypassWhitelist) return true;
|
||||||
|
if (!product.visibilityWhitelistEnabled) return true;
|
||||||
|
const phone = normalizeTestPhone(viewer?.phone);
|
||||||
|
if (!phone) return false;
|
||||||
|
if (whitelistPhones) return whitelistPhones.has(phone);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
async listCities() {
|
async listCities() {
|
||||||
const cities = await this.prisma.commonCity.findMany({
|
const cities = await this.prisma.commonCity.findMany({
|
||||||
@@ -58,11 +81,13 @@ export class CatalogService {
|
|||||||
orderBy: { sortOrder: 'asc' },
|
orderBy: { sortOrder: 'asc' },
|
||||||
include: {
|
include: {
|
||||||
coverResource: true,
|
coverResource: true,
|
||||||
visibilityPhones: { select: { phone: true } },
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const visible = products.filter((p) => this.isVisibleToViewer(p, viewer));
|
const whitelistPhones = products.some((p) => p.visibilityWhitelistEnabled)
|
||||||
|
? await this.whitelistPhoneSet()
|
||||||
|
: new Set<string>();
|
||||||
|
const visible = products.filter((p) => this.isVisibleToViewer(p, viewer, whitelistPhones));
|
||||||
|
|
||||||
const productIds = visible.map((p) => p.id);
|
const productIds = visible.map((p) => p.id);
|
||||||
const resources = productIds.length
|
const resources = productIds.length
|
||||||
@@ -81,7 +106,7 @@ export class CatalogService {
|
|||||||
return serializeBigInt(
|
return serializeBigInt(
|
||||||
visible.map((p) => {
|
visible.map((p) => {
|
||||||
const media = mapProductMedia(p, resourceMap.get(p.id.toString()) ?? []);
|
const media = mapProductMedia(p, resourceMap.get(p.id.toString()) ?? []);
|
||||||
const { visibilityPhones: _phones, visibilityWhitelistEnabled: _wl, ...rest } = p;
|
const { visibilityWhitelistEnabled: _wl, ...rest } = p;
|
||||||
return {
|
return {
|
||||||
...rest,
|
...rest,
|
||||||
benefitAmount: p.benefitAmount ?? p.price,
|
benefitAmount: p.benefitAmount ?? p.price,
|
||||||
@@ -98,11 +123,13 @@ export class CatalogService {
|
|||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
coverResource: true,
|
coverResource: true,
|
||||||
visibilityPhones: { select: { phone: true } },
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!product) return null;
|
if (!product) return null;
|
||||||
if (!this.isVisibleToViewer(product, viewer)) {
|
const whitelistPhones = product.visibilityWhitelistEnabled
|
||||||
|
? await this.whitelistPhoneSet()
|
||||||
|
: new Set<string>();
|
||||||
|
if (!this.isVisibleToViewer(product, viewer, whitelistPhones)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +144,7 @@ export class CatalogService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const media = mapProductMedia(product, resources);
|
const media = mapProductMedia(product, resources);
|
||||||
const { visibilityPhones: _phones, visibilityWhitelistEnabled: _wl, ...rest } = product;
|
const { visibilityWhitelistEnabled: _wl, ...rest } = product;
|
||||||
return serializeBigInt({
|
return serializeBigInt({
|
||||||
...rest,
|
...rest,
|
||||||
benefitAmount: product.benefitAmount ?? product.price,
|
benefitAmount: product.benefitAmount ?? product.price,
|
||||||
@@ -126,17 +153,19 @@ export class CatalogService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 下单前校验:白名单商品仅白名单手机号可买 */
|
/** 下单前校验:白名单商品仅全局测试白名单手机号可买 */
|
||||||
async assertPurchasable(productId: bigint, viewerPhone?: string | null) {
|
async assertPurchasable(productId: bigint, viewerPhone?: string | null) {
|
||||||
const product = await this.prisma.commonProductItem.findUnique({
|
const product = await this.prisma.commonProductItem.findUnique({
|
||||||
where: { id: productId },
|
where: { id: productId },
|
||||||
include: { visibilityPhones: { select: { phone: true } } },
|
|
||||||
});
|
});
|
||||||
if (!product || product.status !== 'ON_SALE') {
|
if (!product || product.status !== 'ON_SALE') {
|
||||||
throw new BadRequestException('商品不可购买');
|
throw new BadRequestException('商品不可购买');
|
||||||
}
|
}
|
||||||
if (!this.isVisibleToViewer(product, { phone: viewerPhone })) {
|
if (product.visibilityWhitelistEnabled) {
|
||||||
throw new BadRequestException('该商品暂不对当前账号开放');
|
const ok = await this.testWhitelist.isPhoneInWhitelist(viewerPhone);
|
||||||
|
if (!ok) {
|
||||||
|
throw new BadRequestException('该商品暂不对当前账号开放');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return product;
|
return product;
|
||||||
}
|
}
|
||||||
@@ -148,18 +177,4 @@ export class CatalogService {
|
|||||||
});
|
});
|
||||||
return user?.phone ?? null;
|
return user?.phone ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
isVisibleToViewer(
|
|
||||||
product: {
|
|
||||||
visibilityWhitelistEnabled: boolean;
|
|
||||||
visibilityPhones: Array<{ phone: string }>;
|
|
||||||
},
|
|
||||||
viewer?: CatalogViewer,
|
|
||||||
): boolean {
|
|
||||||
if (viewer?.bypassWhitelist) return true;
|
|
||||||
if (!product.visibilityWhitelistEnabled) return true;
|
|
||||||
const phone = normalizePhone(viewer?.phone);
|
|
||||||
if (!phone) return false;
|
|
||||||
return product.visibilityPhones.some((row) => normalizePhone(row.phone) === phone);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { AnalyticsService } from '../analytics/analytics.service';
|
|||||||
import { UserAddressService } from './user-address.service';
|
import { UserAddressService } from './user-address.service';
|
||||||
import { ResourceService } from '../common/resource.service';
|
import { ResourceService } from '../common/resource.service';
|
||||||
import { HqPermissionsResolver } from '../../common/guards/hq-permission.guard';
|
import { HqPermissionsResolver } from '../../common/guards/hq-permission.guard';
|
||||||
|
import { TestWhitelistService } from '../../common/test-whitelist/test-whitelist.service';
|
||||||
|
|
||||||
import type { User } from '@prisma/client';
|
import type { User } from '@prisma/client';
|
||||||
|
|
||||||
@@ -67,8 +68,21 @@ export class AuthService {
|
|||||||
private readonly userAddressService: UserAddressService,
|
private readonly userAddressService: UserAddressService,
|
||||||
@Inject(forwardRef(() => ResourceService)) private readonly resourceService: ResourceService,
|
@Inject(forwardRef(() => ResourceService)) private readonly resourceService: ResourceService,
|
||||||
private readonly hqPermissions: HqPermissionsResolver,
|
private readonly hqPermissions: HqPermissionsResolver,
|
||||||
|
private readonly testWhitelist: TestWhitelistService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/** 登录/绑号后按全局白名单同步 isTest */
|
||||||
|
private async syncTestFlagByPhone(phone: string) {
|
||||||
|
const isTest = await this.testWhitelist.isPhoneInWhitelist(phone);
|
||||||
|
await Promise.all([
|
||||||
|
this.prisma.user.updateMany({ where: { phone }, data: { isTest } }),
|
||||||
|
this.prisma.storeAccount.updateMany({ where: { phone }, data: { isTest } }),
|
||||||
|
this.prisma.partnerAccount.updateMany({ where: { phone }, data: { isTest } }),
|
||||||
|
this.prisma.store.updateMany({ where: { phone }, data: { isTest } }),
|
||||||
|
]);
|
||||||
|
return isTest;
|
||||||
|
}
|
||||||
|
|
||||||
private assertMobilePhone(phone: string) {
|
private assertMobilePhone(phone: string) {
|
||||||
const trimmed = phone.trim();
|
const trimmed = phone.trim();
|
||||||
if (!/^1[3-9]\d{9}$/.test(trimmed)) {
|
if (!/^1[3-9]\d{9}$/.test(trimmed)) {
|
||||||
@@ -375,12 +389,14 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
const isTest = await this.testWhitelist.isPhoneInWhitelist(normalizedPhone);
|
||||||
user = await this.prisma.user.create({
|
user = await this.prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
phone: normalizedPhone,
|
phone: normalizedPhone,
|
||||||
phoneVerifiedAt: new Date(),
|
phoneVerifiedAt: new Date(),
|
||||||
userNo: generateUserNo(),
|
userNo: generateUserNo(),
|
||||||
nickname: `用户${normalizedPhone.slice(-4)}`,
|
nickname: `用户${normalizedPhone.slice(-4)}`,
|
||||||
|
isTest,
|
||||||
sourceType: source?.sourceType ?? 'ORGANIC',
|
sourceType: source?.sourceType ?? 'ORGANIC',
|
||||||
sourceRefId: source?.sourceRefId,
|
sourceRefId: source?.sourceRefId,
|
||||||
sourceLabel: source?.sourceLabel,
|
sourceLabel: source?.sourceLabel,
|
||||||
@@ -401,6 +417,11 @@ export class AuthService {
|
|||||||
include: { avatar: true },
|
include: { avatar: true },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
await this.syncTestFlagByPhone(normalizedPhone);
|
||||||
|
user = await this.prisma.user.findUniqueOrThrow({
|
||||||
|
where: { id: user.id },
|
||||||
|
include: { avatar: true },
|
||||||
|
});
|
||||||
await this.assertActiveUser(user.id);
|
await this.assertActiveUser(user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -810,12 +831,14 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
const isTest = await this.testWhitelist.isPhoneInWhitelist(normalizedPhone);
|
||||||
user = await this.prisma.user.create({
|
user = await this.prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
phone: normalizedPhone,
|
phone: normalizedPhone,
|
||||||
phoneVerifiedAt: new Date(),
|
phoneVerifiedAt: new Date(),
|
||||||
userNo: generateUserNo(),
|
userNo: generateUserNo(),
|
||||||
nickname: `用户${normalizedPhone.slice(-4)}`,
|
nickname: `用户${normalizedPhone.slice(-4)}`,
|
||||||
|
isTest,
|
||||||
cityPreference: {
|
cityPreference: {
|
||||||
create: {
|
create: {
|
||||||
selectedCityCode: '410100',
|
selectedCityCode: '410100',
|
||||||
@@ -852,6 +875,12 @@ export class AuthService {
|
|||||||
|
|
||||||
if (!user) throw new BadRequestException('登录失败');
|
if (!user) throw new BadRequestException('登录失败');
|
||||||
|
|
||||||
|
await this.syncTestFlagByPhone(normalizedPhone);
|
||||||
|
user = await this.prisma.user.findUniqueOrThrow({
|
||||||
|
where: { id: user.id },
|
||||||
|
include: { avatar: true },
|
||||||
|
});
|
||||||
|
|
||||||
this.analyticsService.trackOneSafe(user.id, clientApp, {
|
this.analyticsService.trackOneSafe(user.id, clientApp, {
|
||||||
eventName: method === 'sms' ? 'sms_login' : 'wechat_phone_login',
|
eventName: method === 'sms' ? 'sms_login' : 'wechat_phone_login',
|
||||||
extraJson: { method },
|
extraJson: { method },
|
||||||
@@ -998,6 +1027,7 @@ export class AuthService {
|
|||||||
if (!account) throw new BadRequestException('该手机号未绑定门店');
|
if (!account) throw new BadRequestException('该手机号未绑定门店');
|
||||||
if (account.status !== 'ACTIVE') throw new BadRequestException('门店账号已停用');
|
if (account.status !== 'ACTIVE') throw new BadRequestException('门店账号已停用');
|
||||||
if (!account.bindings.length) throw new BadRequestException('该账号未绑定任何门店');
|
if (!account.bindings.length) throw new BadRequestException('该账号未绑定任何门店');
|
||||||
|
await this.syncTestFlagByPhone(normalizedPhone);
|
||||||
await this.prisma.storeAccount.update({
|
await this.prisma.storeAccount.update({
|
||||||
where: { id: account.id },
|
where: { id: account.id },
|
||||||
data: { lastLoginAt: new Date() },
|
data: { lastLoginAt: new Date() },
|
||||||
@@ -1032,6 +1062,7 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
if (!account) throw new BadRequestException('未找到合伙人账号');
|
if (!account) throw new BadRequestException('未找到合伙人账号');
|
||||||
if (account.status !== 'ACTIVE') throw new BadRequestException('合伙人账号已停用');
|
if (account.status !== 'ACTIVE') throw new BadRequestException('合伙人账号已停用');
|
||||||
|
await this.syncTestFlagByPhone(normalizedPhone);
|
||||||
const primary = await this.resolvePrimaryAccount(account.id);
|
const primary = await this.resolvePrimaryAccount(account.id);
|
||||||
await this.prisma.partnerAccount.update({
|
await this.prisma.partnerAccount.update({
|
||||||
where: { id: account.id },
|
where: { id: account.id },
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
HQ_DANGEROUS_PERMISSION_KEYS,
|
|
||||||
HQ_PERMISSION_CATALOG,
|
HQ_PERMISSION_CATALOG,
|
||||||
HQ_ROLE_DEFAULT_PERMISSIONS,
|
HQ_ROLE_DEFAULT_PERMISSIONS,
|
||||||
LEGACY_SYSTEM_SETTINGS_KEY,
|
LEGACY_SYSTEM_SETTINGS_KEY,
|
||||||
expandHqPermissionKeys,
|
expandHqPermissionKeys,
|
||||||
hqBasePermissionKeys,
|
|
||||||
type HqPermissionKey,
|
type HqPermissionKey,
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
@@ -83,10 +81,9 @@ export class AdminHqPermissionsService {
|
|||||||
const userPermissionKeys = expandHqPermissionKeys(userPerms.map((p) => p.permissionKey));
|
const userPermissionKeys = expandHqPermissionKeys(userPerms.map((p) => p.permissionKey));
|
||||||
|
|
||||||
if (account.adminRole === 'SUPER_ADMIN') {
|
if (account.adminRole === 'SUPER_ADMIN') {
|
||||||
const rolePermissionKeys = [
|
const rolePermissionKeys = HQ_PERMISSION_CATALOG.map(
|
||||||
...hqBasePermissionKeys(),
|
(p) => p.key,
|
||||||
...HQ_DANGEROUS_PERMISSION_KEYS,
|
) as HqPermissionKey[];
|
||||||
] as HqPermissionKey[];
|
|
||||||
const effectivePermissionKeys = [
|
const effectivePermissionKeys = [
|
||||||
...new Set([...rolePermissionKeys, ...userPermissionKeys]),
|
...new Set([...rolePermissionKeys, ...userPermissionKeys]),
|
||||||
] as HqPermissionKey[];
|
] as HqPermissionKey[];
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export class AdminOrdersService {
|
|||||||
if (query.createdFrom) where.createdAt.gte = new Date(query.createdFrom);
|
if (query.createdFrom) where.createdAt.gte = new Date(query.createdFrom);
|
||||||
if (query.createdTo) where.createdAt.lte = new Date(query.createdTo);
|
if (query.createdTo) where.createdAt.lte = new Date(query.createdTo);
|
||||||
}
|
}
|
||||||
|
if (query.excludeTest) where.isTest = false;
|
||||||
|
|
||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
this.prisma.order.findMany({
|
this.prisma.order.findMany({
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export class AdminPartnersService {
|
|||||||
if (query.phone) where.phone = { contains: query.phone };
|
if (query.phone) where.phone = { contains: query.phone };
|
||||||
if (query.cityId) where.cityId = BigInt(query.cityId);
|
if (query.cityId) where.cityId = BigInt(query.cityId);
|
||||||
if (query.partnerId) where.id = BigInt(query.partnerId);
|
if (query.partnerId) where.id = BigInt(query.partnerId);
|
||||||
|
if (query.excludeTest) where.isTest = false;
|
||||||
|
|
||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
this.prisma.partnerAccount.findMany({
|
this.prisma.partnerAccount.findMany({
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { serializeBigInt } from '../../common/decorators/current-user.decorator'
|
|||||||
import { groupResourcesByProductId, mapProductMedia } from '../catalog/catalog.mapper';
|
import { groupResourcesByProductId, mapProductMedia } from '../catalog/catalog.mapper';
|
||||||
import type { AdminProductsQueryDto } from './dto/admin-query.dto';
|
import type { AdminProductsQueryDto } from './dto/admin-query.dto';
|
||||||
import type { CreateProductDto, UpdateProductDto } from './dto/admin-mutate.dto';
|
import type { CreateProductDto, UpdateProductDto } from './dto/admin-mutate.dto';
|
||||||
|
import { TestWhitelistService } from '../../common/test-whitelist/test-whitelist.service';
|
||||||
|
|
||||||
function normalizePhones(phones?: string[]): string[] {
|
function normalizePhones(phones?: string[]): string[] {
|
||||||
if (!phones?.length) return [];
|
if (!phones?.length) return [];
|
||||||
@@ -57,7 +58,10 @@ function resolveFulfillmentFlags(input: {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminProductsService {
|
export class AdminProductsService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly testWhitelist: TestWhitelistService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async list(query: AdminProductsQueryDto) {
|
async list(query: AdminProductsQueryDto) {
|
||||||
const page = query.page ?? 1;
|
const page = query.page ?? 1;
|
||||||
@@ -140,6 +144,11 @@ export class AdminProductsService {
|
|||||||
|
|
||||||
const phones = normalizePhones(dto.visibilityPhones);
|
const phones = normalizePhones(dto.visibilityPhones);
|
||||||
const whitelistEnabled = !!dto.visibilityWhitelistEnabled;
|
const whitelistEnabled = !!dto.visibilityWhitelistEnabled;
|
||||||
|
if (whitelistEnabled) {
|
||||||
|
await this.testWhitelist.assertGlobalWhitelistNotEmpty();
|
||||||
|
}
|
||||||
|
// 手机号统一在「白名单管理」维护;此处忽略分实体 phones(兼容旧客户端传参)
|
||||||
|
void phones;
|
||||||
|
|
||||||
const product = await this.createWithGeneratedSku({
|
const product = await this.createWithGeneratedSku({
|
||||||
barcode69: dto.barcode69,
|
barcode69: dto.barcode69,
|
||||||
@@ -158,13 +167,6 @@ export class AdminProductsService {
|
|||||||
...(dto.detailContent !== undefined
|
...(dto.detailContent !== undefined
|
||||||
? { detailContent: dto.detailContent as Prisma.InputJsonValue }
|
? { detailContent: dto.detailContent as Prisma.InputJsonValue }
|
||||||
: {}),
|
: {}),
|
||||||
...(phones.length
|
|
||||||
? {
|
|
||||||
visibilityPhones: {
|
|
||||||
create: phones.map((phone) => ({ phone })),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (dto.coverUrl) {
|
if (dto.coverUrl) {
|
||||||
@@ -225,9 +227,10 @@ export class AdminProductsService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (dto.visibilityPhones !== undefined) {
|
if (dto.visibilityWhitelistEnabled) {
|
||||||
await this.syncVisibilityPhones(id, normalizePhones(dto.visibilityPhones));
|
await this.testWhitelist.assertGlobalWhitelistNotEmpty();
|
||||||
}
|
}
|
||||||
|
// 分实体手机号已废弃;忽略 dto.visibilityPhones
|
||||||
|
|
||||||
if (dto.coverUrl) {
|
if (dto.coverUrl) {
|
||||||
await this.syncCover(id, dto.coverUrl);
|
await this.syncCover(id, dto.coverUrl);
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export class AdminRedeemService {
|
|||||||
if (query.channel === 'SCAN' || query.channel === 'PHONE') {
|
if (query.channel === 'SCAN' || query.channel === 'PHONE') {
|
||||||
where.channel = query.channel;
|
where.channel = query.channel;
|
||||||
}
|
}
|
||||||
|
if (query.excludeTest) where.isTest = false;
|
||||||
|
|
||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
this.prisma.redeemRecord.findMany({
|
this.prisma.redeemRecord.findMany({
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type {
|
|||||||
UpdateStoreMediaDto,
|
UpdateStoreMediaDto,
|
||||||
UpdateStoreStatusDto,
|
UpdateStoreStatusDto,
|
||||||
} from './dto/admin-mutate.dto';
|
} from './dto/admin-mutate.dto';
|
||||||
|
import { TestWhitelistService } from '../../common/test-whitelist/test-whitelist.service';
|
||||||
|
|
||||||
/** 选填文案:空 / null / "null" 一律存库为 null,避免 String(null)==="null" */
|
/** 选填文案:空 / null / "null" 一律存库为 null,避免 String(null)==="null" */
|
||||||
function normalizeStoreOptionalText(value: unknown): string | null {
|
function normalizeStoreOptionalText(value: unknown): string | null {
|
||||||
@@ -51,6 +52,7 @@ export class AdminStoresService {
|
|||||||
private readonly partnerCityService: PartnerCityService,
|
private readonly partnerCityService: PartnerCityService,
|
||||||
private readonly storeCategoryService: StoreCategoryService,
|
private readonly storeCategoryService: StoreCategoryService,
|
||||||
private readonly analyticsService: AnalyticsService,
|
private readonly analyticsService: AnalyticsService,
|
||||||
|
private readonly testWhitelist: TestWhitelistService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async listStores(query: AdminStoresQueryDto) {
|
async listStores(query: AdminStoresQueryDto) {
|
||||||
@@ -65,6 +67,7 @@ export class AdminStoresService {
|
|||||||
if (query.cityId) where.cityId = BigInt(query.cityId);
|
if (query.cityId) where.cityId = BigInt(query.cityId);
|
||||||
if (query.partnerId) where.partnerAccountId = BigInt(query.partnerId);
|
if (query.partnerId) where.partnerAccountId = BigInt(query.partnerId);
|
||||||
if (query.phone) where.phone = { contains: query.phone };
|
if (query.phone) where.phone = { contains: query.phone };
|
||||||
|
if (query.excludeTest) where.isTest = false;
|
||||||
|
|
||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
this.prisma.store.findMany({
|
this.prisma.store.findMany({
|
||||||
@@ -283,18 +286,7 @@ export class AdminStoresService {
|
|||||||
? !!dto.visibilityWhitelistEnabled
|
? !!dto.visibilityWhitelistEnabled
|
||||||
: current.visibilityWhitelistEnabled;
|
: current.visibilityWhitelistEnabled;
|
||||||
if (nextEnabled) {
|
if (nextEnabled) {
|
||||||
const phones =
|
await this.testWhitelist.assertGlobalWhitelistNotEmpty();
|
||||||
dto.visibilityPhones !== undefined
|
|
||||||
? normalizeVisibilityPhones(dto.visibilityPhones)
|
|
||||||
: (
|
|
||||||
await this.prisma.storeVisibilityPhone.findMany({
|
|
||||||
where: { storeId: id },
|
|
||||||
select: { phone: true },
|
|
||||||
})
|
|
||||||
).map((p) => p.phone);
|
|
||||||
if (!phones.length) {
|
|
||||||
throw new BadRequestException('开启白名单时请至少添加一个手机号');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const bankTouched =
|
const bankTouched =
|
||||||
@@ -372,18 +364,13 @@ export class AdminStoresService {
|
|||||||
...(dto.visibilityWhitelistEnabled !== undefined
|
...(dto.visibilityWhitelistEnabled !== undefined
|
||||||
? { visibilityWhitelistEnabled: !!dto.visibilityWhitelistEnabled }
|
? { visibilityWhitelistEnabled: !!dto.visibilityWhitelistEnabled }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(dto.isTest !== undefined ? { isTest: !!dto.isTest } : {}),
|
||||||
...(latitude != null && longitude != null ? { latitude, longitude } : {}),
|
...(latitude != null && longitude != null ? { latitude, longitude } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (dto.visibilityPhones !== undefined) {
|
if (dto.visibilityPhones !== undefined) {
|
||||||
const phones = normalizeVisibilityPhones(dto.visibilityPhones);
|
// 分实体手机号已废弃,忽略写入
|
||||||
await tx.storeVisibilityPhone.deleteMany({ where: { storeId: id } });
|
|
||||||
if (phones.length) {
|
|
||||||
await tx.storeVisibilityPhone.createMany({
|
|
||||||
data: phones.map((phone) => ({ storeId: id, phone })),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dto.coverUrl) {
|
if (dto.coverUrl) {
|
||||||
@@ -505,11 +492,14 @@ export class AdminStoresService {
|
|||||||
throw new BadRequestException('好客权益券使用规则最多 1000 字');
|
throw new BadRequestException('好客权益券使用规则最多 1000 字');
|
||||||
}
|
}
|
||||||
|
|
||||||
const visibilityPhones = normalizeVisibilityPhones(dto.visibilityPhones);
|
|
||||||
const whitelistEnabled = !!dto.visibilityWhitelistEnabled;
|
const whitelistEnabled = !!dto.visibilityWhitelistEnabled;
|
||||||
if (whitelistEnabled && !visibilityPhones.length) {
|
if (whitelistEnabled) {
|
||||||
throw new BadRequestException('开启白名单时请至少添加一个手机号');
|
await this.testWhitelist.assertGlobalWhitelistNotEmpty();
|
||||||
}
|
}
|
||||||
|
const isTest =
|
||||||
|
dto.isTest !== undefined
|
||||||
|
? !!dto.isTest
|
||||||
|
: await this.testWhitelist.isPhoneInWhitelist(normalizedPhone);
|
||||||
|
|
||||||
const store = await this.prisma.store.create({
|
const store = await this.prisma.store.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -531,18 +521,12 @@ export class AdminStoresService {
|
|||||||
openTime2: openTime2 || null,
|
openTime2: openTime2 || null,
|
||||||
closeTime2: closeTime2 || null,
|
closeTime2: closeTime2 || null,
|
||||||
visibilityWhitelistEnabled: whitelistEnabled,
|
visibilityWhitelistEnabled: whitelistEnabled,
|
||||||
|
isTest,
|
||||||
...(latitude != null && longitude != null ? { latitude, longitude } : {}),
|
...(latitude != null && longitude != null ? { latitude, longitude } : {}),
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
auditStatus: 'APPROVED',
|
auditStatus: 'APPROVED',
|
||||||
auditedAt: new Date(),
|
auditedAt: new Date(),
|
||||||
rejectReason: null,
|
rejectReason: null,
|
||||||
...(visibilityPhones.length
|
|
||||||
? {
|
|
||||||
visibilityPhones: {
|
|
||||||
create: visibilityPhones.map((phone) => ({ phone })),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -746,6 +730,7 @@ export class AdminStoresService {
|
|||||||
where.bindings = { some: { storeId: BigInt(query.storeId) } };
|
where.bindings = { some: { storeId: BigInt(query.storeId) } };
|
||||||
}
|
}
|
||||||
if (query.status) where.status = query.status as Prisma.EnumAccountStatusFilter['equals'];
|
if (query.status) where.status = query.status as Prisma.EnumAccountStatusFilter['equals'];
|
||||||
|
if (query.excludeTest) where.isTest = false;
|
||||||
|
|
||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
this.prisma.storeAccount.findMany({
|
this.prisma.storeAccount.findMany({
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||||
|
import {
|
||||||
|
HqPermissionGuard,
|
||||||
|
RequireHqPermissions,
|
||||||
|
} from '../../common/guards/hq-permission.guard';
|
||||||
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
|
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { TestWhitelistService } from '../../common/test-whitelist/test-whitelist.service';
|
||||||
|
import { SystemConfigService } from '../../common/system-config/system-config.service';
|
||||||
|
|
||||||
|
class ListPhonesQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
phone?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number = 1;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
pageSize?: number = 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AddPhoneDto {
|
||||||
|
@IsString()
|
||||||
|
phone!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(256)
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class UpdatePhoneDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(256)
|
||||||
|
note?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ListAccountsQueryDto {
|
||||||
|
@IsIn(['user', 'store_account', 'partner', 'store', 'order'])
|
||||||
|
type!: 'user' | 'store_account' | 'partner' | 'store' | 'order';
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
phone?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number = 1;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
pageSize?: number = 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
class UpdateMockFlagsDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
mockSms?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
mockWechat?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
mockPay?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('admin/test-whitelist')
|
||||||
|
@UseGuards(HqAuthGuard, HqPermissionGuard)
|
||||||
|
@RequireHqPermissions('test_whitelist')
|
||||||
|
export class AdminTestWhitelistController {
|
||||||
|
constructor(
|
||||||
|
private readonly testWhitelist: TestWhitelistService,
|
||||||
|
private readonly systemConfig: SystemConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get('mock-flags')
|
||||||
|
async getMockFlags() {
|
||||||
|
const form = await this.systemConfig.getForm(['feature']);
|
||||||
|
const v = form.values;
|
||||||
|
return {
|
||||||
|
mockSms: v.MOCK_SMS === 'true' || v.MOCK_SMS === '1',
|
||||||
|
mockWechat: v.MOCK_WECHAT === 'true' || v.MOCK_WECHAT === '1',
|
||||||
|
mockPay: v.MOCK_PAY === 'true' || v.MOCK_PAY === '1',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('mock-flags')
|
||||||
|
async updateMockFlags(@Body() dto: UpdateMockFlagsDto) {
|
||||||
|
const values: Record<string, string> = {};
|
||||||
|
if (dto.mockSms !== undefined) values.MOCK_SMS = String(dto.mockSms);
|
||||||
|
if (dto.mockWechat !== undefined) values.MOCK_WECHAT = String(dto.mockWechat);
|
||||||
|
if (dto.mockPay !== undefined) values.MOCK_PAY = String(dto.mockPay);
|
||||||
|
if (Object.keys(values).length) {
|
||||||
|
await this.systemConfig.update({ values }, ['feature']);
|
||||||
|
}
|
||||||
|
return this.getMockFlags();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('phones')
|
||||||
|
listPhones(@Query() query: ListPhonesQueryDto) {
|
||||||
|
return this.testWhitelist.listPhones(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('phones')
|
||||||
|
addPhone(@Body() dto: AddPhoneDto, @CurrentUser() actor: AuthUser) {
|
||||||
|
return this.testWhitelist.addPhone({
|
||||||
|
phone: dto.phone,
|
||||||
|
note: dto.note,
|
||||||
|
createdByHqId: actor.actorId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('phones/:id')
|
||||||
|
updatePhone(@Param('id') id: string, @Body() dto: UpdatePhoneDto) {
|
||||||
|
return this.testWhitelist.updatePhone(BigInt(id), { note: dto.note });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('phones/:id')
|
||||||
|
removePhone(@Param('id') id: string) {
|
||||||
|
return this.testWhitelist.removePhone(BigInt(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('accounts')
|
||||||
|
listAccounts(@Query() query: ListAccountsQueryDto) {
|
||||||
|
return this.testWhitelist.listAccounts(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('phones/:id/linked')
|
||||||
|
linked(@Param('id') id: string) {
|
||||||
|
return this.testWhitelist.linkedForPhoneId(BigInt(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('migrate-visibility')
|
||||||
|
migrate(@CurrentUser() actor: AuthUser) {
|
||||||
|
return this.testWhitelist.migrateVisibilityPhones(actor.actorId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ function mapAdminUserRow(u: {
|
|||||||
sourceType: string;
|
sourceType: string;
|
||||||
sourceRefId: bigint | null;
|
sourceRefId: bigint | null;
|
||||||
sourceLabel: string | null;
|
sourceLabel: string | null;
|
||||||
|
isTest?: boolean;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
_count: { orders: number };
|
_count: { orders: number };
|
||||||
@@ -37,6 +38,7 @@ function mapAdminUserRow(u: {
|
|||||||
sourceType: u.sourceType,
|
sourceType: u.sourceType,
|
||||||
sourceRefId: u.sourceRefId,
|
sourceRefId: u.sourceRefId,
|
||||||
sourceLabel: u.sourceLabel,
|
sourceLabel: u.sourceLabel,
|
||||||
|
isTest: !!u.isTest,
|
||||||
createdAt: u.createdAt,
|
createdAt: u.createdAt,
|
||||||
updatedAt: u.updatedAt,
|
updatedAt: u.updatedAt,
|
||||||
orderCount: u._count.orders,
|
orderCount: u._count.orders,
|
||||||
@@ -58,6 +60,7 @@ export class AdminUsersService {
|
|||||||
if (query.status !== undefined) where.status = query.status;
|
if (query.status !== undefined) where.status = query.status;
|
||||||
if (query.phoneVerified === '1') where.phoneVerifiedAt = { not: null };
|
if (query.phoneVerified === '1') where.phoneVerifiedAt = { not: null };
|
||||||
if (query.phoneVerified === '0') where.phoneVerifiedAt = null;
|
if (query.phoneVerified === '0') where.phoneVerifiedAt = null;
|
||||||
|
if (query.excludeTest) where.isTest = false;
|
||||||
|
|
||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
this.prisma.user.findMany({
|
this.prisma.user.findMany({
|
||||||
@@ -78,6 +81,7 @@ export class AdminUsersService {
|
|||||||
sourceType: true,
|
sourceType: true,
|
||||||
sourceRefId: true,
|
sourceRefId: true,
|
||||||
sourceLabel: true,
|
sourceLabel: true,
|
||||||
|
isTest: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
_count: { select: { orders: true } },
|
_count: { select: { orders: true } },
|
||||||
|
|||||||
@@ -128,16 +128,21 @@ export class CreateStoreDto {
|
|||||||
@Min(0)
|
@Min(0)
|
||||||
avgPrice?: number;
|
avgPrice?: number;
|
||||||
|
|
||||||
/** 开启后仅白名单手机号在 C 端可见 */
|
/** 开启后仅全局测试白名单手机号在 C 端可见 */
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
visibilityWhitelistEnabled?: boolean;
|
visibilityWhitelistEnabled?: boolean;
|
||||||
|
|
||||||
/** 可见白名单手机号列表 */
|
/** @deprecated 已并入全局白名单,忽略 */
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
visibilityPhones?: string[];
|
visibilityPhones?: string[];
|
||||||
|
|
||||||
|
/** 测试门店标记 */
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isTest?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateStoreDto {
|
export class UpdateStoreDto {
|
||||||
@@ -227,16 +232,21 @@ export class UpdateStoreDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
bankBranch?: string | null;
|
bankBranch?: string | null;
|
||||||
|
|
||||||
/** 开启后仅白名单手机号在 C 端可见 */
|
/** 开启后仅全局测试白名单手机号在 C 端可见 */
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
visibilityWhitelistEnabled?: boolean;
|
visibilityWhitelistEnabled?: boolean;
|
||||||
|
|
||||||
/** 可见白名单手机号列表 */
|
/** @deprecated 已并入全局白名单,忽略 */
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
visibilityPhones?: string[];
|
visibilityPhones?: string[];
|
||||||
|
|
||||||
|
/** 测试门店标记 */
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isTest?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CreateStoreAccountDto {
|
export class CreateStoreAccountDto {
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { Type } from 'class-transformer';
|
import { Type, Transform } from 'class-transformer';
|
||||||
import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
|
function toOptionalBoolean(value: unknown): boolean | undefined {
|
||||||
|
if (value === undefined || value === null || value === '') return undefined;
|
||||||
|
if (value === true || value === 'true' || value === '1' || value === 1) return true;
|
||||||
|
if (value === false || value === 'false' || value === '0' || value === 0) return false;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export class PaginationQueryDto {
|
export class PaginationQueryDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -37,6 +44,12 @@ export class AdminUsersQueryDto extends PaginationQueryDto {
|
|||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsIn([0, 1])
|
@IsIn([0, 1])
|
||||||
status?: number;
|
status?: number;
|
||||||
|
|
||||||
|
/** 勾选「过滤测试账号」时传 true */
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => toOptionalBoolean(value))
|
||||||
|
@IsBoolean()
|
||||||
|
excludeTest?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AdminOrdersQueryDto extends PaginationQueryDto {
|
export class AdminOrdersQueryDto extends PaginationQueryDto {
|
||||||
@@ -75,6 +88,11 @@ export class AdminOrdersQueryDto extends PaginationQueryDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
createdTo?: string;
|
createdTo?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => toOptionalBoolean(value))
|
||||||
|
@IsBoolean()
|
||||||
|
excludeTest?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 概览页用户/订单 ECharts 聚合筛选 */
|
/** 概览页用户/订单 ECharts 聚合筛选 */
|
||||||
@@ -128,6 +146,11 @@ export class AdminStoresQueryDto extends PaginationQueryDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
auditStatus?: string;
|
auditStatus?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => toOptionalBoolean(value))
|
||||||
|
@IsBoolean()
|
||||||
|
excludeTest?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AdminStoreAccountsQueryDto extends PaginationQueryDto {
|
export class AdminStoreAccountsQueryDto extends PaginationQueryDto {
|
||||||
@@ -142,6 +165,11 @@ export class AdminStoreAccountsQueryDto extends PaginationQueryDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
status?: string;
|
status?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => toOptionalBoolean(value))
|
||||||
|
@IsBoolean()
|
||||||
|
excludeTest?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AdminPartnersQueryDto extends PaginationQueryDto {
|
export class AdminPartnersQueryDto extends PaginationQueryDto {
|
||||||
@@ -164,6 +192,11 @@ export class AdminPartnersQueryDto extends PaginationQueryDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
partnerId?: string;
|
partnerId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => toOptionalBoolean(value))
|
||||||
|
@IsBoolean()
|
||||||
|
excludeTest?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AdminCityWarehousesQueryDto extends PaginationQueryDto {
|
export class AdminCityWarehousesQueryDto extends PaginationQueryDto {
|
||||||
@@ -247,6 +280,11 @@ export class AdminRedeemRecordsQueryDto extends PaginationQueryDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
channel?: string;
|
channel?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => toOptionalBoolean(value))
|
||||||
|
@IsBoolean()
|
||||||
|
excludeTest?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AdminStoreRatingsQueryDto extends PaginationQueryDto {
|
export class AdminStoreRatingsQueryDto extends PaginationQueryDto {
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ import { AdminDevPlanController } from '../dev-plan/admin-dev-plan.controller';
|
|||||||
import { AdminFulfillmentProvidersController } from './admin-fulfillment-providers.controller';
|
import { AdminFulfillmentProvidersController } from './admin-fulfillment-providers.controller';
|
||||||
import { AdminDomainEventsController } from './admin-domain-events.controller';
|
import { AdminDomainEventsController } from './admin-domain-events.controller';
|
||||||
import { AdminDomainEventsService } from './admin-domain-events.service';
|
import { AdminDomainEventsService } from './admin-domain-events.service';
|
||||||
|
import { AdminTestWhitelistController } from './admin-test-whitelist.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CityScopeModule, IamModule, TradeModule, AnalyticsModule, FulfillmentModule, BenefitModule, CommonModule, IntegrationsModule, WecomModule, LlmModule, RedeemModule, StoreModule, DevPlanModule],
|
imports: [CityScopeModule, IamModule, TradeModule, AnalyticsModule, FulfillmentModule, BenefitModule, CommonModule, IntegrationsModule, WecomModule, LlmModule, RedeemModule, StoreModule, DevPlanModule],
|
||||||
@@ -128,6 +129,7 @@ import { AdminDomainEventsService } from './admin-domain-events.service';
|
|||||||
AdminKnowledgeBasesController,
|
AdminKnowledgeBasesController,
|
||||||
AdminDevPlanController,
|
AdminDevPlanController,
|
||||||
AdminFulfillmentProvidersController,
|
AdminFulfillmentProvidersController,
|
||||||
|
AdminTestWhitelistController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
AdminDashboardService,
|
AdminDashboardService,
|
||||||
|
|||||||
@@ -180,6 +180,15 @@ export class RedeemService {
|
|||||||
const settleAmount = calcRedeemSettleAmount(amount, settlementRate);
|
const settleAmount = calcRedeemSettleAmount(amount, settlementRate);
|
||||||
const redeemChannel = analyticsExtra?.channel === 'phone' ? 'PHONE' : 'SCAN';
|
const redeemChannel = analyticsExtra?.channel === 'phone' ? 'PHONE' : 'SCAN';
|
||||||
|
|
||||||
|
const [userRow, storeRow] = await Promise.all([
|
||||||
|
this.prisma.user.findUnique({ where: { id: userId }, select: { isTest: true } }),
|
||||||
|
this.prisma.store.findUnique({
|
||||||
|
where: { id: account.storeId },
|
||||||
|
select: { isTest: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const isTest = !!(userRow?.isTest || storeRow?.isTest);
|
||||||
|
|
||||||
let record;
|
let record;
|
||||||
try {
|
try {
|
||||||
record = await this.prisma.$transaction(async (tx) => {
|
record = await this.prisma.$transaction(async (tx) => {
|
||||||
@@ -194,6 +203,7 @@ export class RedeemService {
|
|||||||
amount,
|
amount,
|
||||||
settleAmount,
|
settleAmount,
|
||||||
channel: redeemChannel,
|
channel: redeemChannel,
|
||||||
|
isTest,
|
||||||
allocations: {
|
allocations: {
|
||||||
create: normalizedAllocations.map((item, index) => ({
|
create: normalizedAllocations.map((item, index) => ({
|
||||||
couponId: BigInt(item.couponId),
|
couponId: BigInt(item.couponId),
|
||||||
@@ -204,14 +214,16 @@ export class RedeemService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.settlementService.createStorePayout(
|
if (!isTest) {
|
||||||
redeemRecord.id,
|
await this.settlementService.createStorePayout(
|
||||||
account.storeId,
|
redeemRecord.id,
|
||||||
amount,
|
account.storeId,
|
||||||
settleAmount,
|
amount,
|
||||||
settlementRate,
|
settleAmount,
|
||||||
tx,
|
settlementRate,
|
||||||
);
|
tx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return redeemRecord;
|
return redeemRecord;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1322,9 +1322,12 @@ export class SettlementService {
|
|||||||
cityId: primary.cityId,
|
cityId: primary.cityId,
|
||||||
payStatus: 'PAID',
|
payStatus: 'PAID',
|
||||||
paidAt: { gte: periodStart, lte: periodEnd },
|
paidAt: { gte: periodStart, lte: periodEnd },
|
||||||
|
isTest: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const orderCommission = orders.reduce((sum, o) => {
|
const orderCommission = primary.isTest
|
||||||
|
? 0
|
||||||
|
: orders.reduce((sum, o) => {
|
||||||
if (o.partnerAccountIdAtPay) {
|
if (o.partnerAccountIdAtPay) {
|
||||||
if (o.partnerAccountIdAtPay !== primary.id) return sum;
|
if (o.partnerAccountIdAtPay !== primary.id) return sum;
|
||||||
const rate = o.orderCommissionRateAtPay != null ? Number(o.orderCommissionRateAtPay) : 0;
|
const rate = o.orderCommissionRateAtPay != null ? Number(o.orderCommissionRateAtPay) : 0;
|
||||||
@@ -1338,14 +1341,16 @@ export class SettlementService {
|
|||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
const storeIds = stores.map((s) => s.id);
|
const storeIds = stores.map((s) => s.id);
|
||||||
const redeems = storeIds.length
|
const redeems =
|
||||||
? await this.prisma.redeemRecord.findMany({
|
primary.isTest || !storeIds.length
|
||||||
|
? []
|
||||||
|
: await this.prisma.redeemRecord.findMany({
|
||||||
where: {
|
where: {
|
||||||
storeId: { in: storeIds },
|
storeId: { in: storeIds },
|
||||||
createdAt: { gte: periodStart, lte: periodEnd },
|
createdAt: { gte: periodStart, lte: periodEnd },
|
||||||
|
isTest: false,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
: [];
|
|
||||||
const redeemCommission = redeems.reduce(
|
const redeemCommission = redeems.reduce(
|
||||||
(sum, r) => sum + Number(r.amount) * redeemCommissionRate,
|
(sum, r) => sum + Number(r.amount) * redeemCommissionRate,
|
||||||
0,
|
0,
|
||||||
@@ -1588,6 +1593,7 @@ export class SettlementService {
|
|||||||
payStatus: 'PAID',
|
payStatus: 'PAID',
|
||||||
deliveryType: { in: ['LOCAL', 'CROSS_CITY'] },
|
deliveryType: { in: ['LOCAL', 'CROSS_CITY'] },
|
||||||
completedAt: { gte: start, lt: end },
|
completedAt: { gte: start, lt: end },
|
||||||
|
isTest: false,
|
||||||
},
|
},
|
||||||
orderBy: { completedAt: 'asc' },
|
orderBy: { completedAt: 'asc' },
|
||||||
});
|
});
|
||||||
@@ -1921,6 +1927,7 @@ export class SettlementService {
|
|||||||
quantity: true,
|
quantity: true,
|
||||||
deliveryType: true,
|
deliveryType: true,
|
||||||
payStatus: true,
|
payStatus: true,
|
||||||
|
isTest: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1929,6 +1936,7 @@ export class SettlementService {
|
|||||||
|
|
||||||
const eligible = deliveries.filter(
|
const eligible = deliveries.filter(
|
||||||
(d) =>
|
(d) =>
|
||||||
|
!d.order.isTest &&
|
||||||
d.order.payStatus === 'PAID' &&
|
d.order.payStatus === 'PAID' &&
|
||||||
(d.order.deliveryType === 'LOCAL' || d.order.deliveryType === 'CROSS_CITY'),
|
(d.order.deliveryType === 'LOCAL' || d.order.deliveryType === 'CROSS_CITY'),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ import { PartnerCityService } from '../city-scope/partner-city.service';
|
|||||||
import { AuthService } from '../iam/auth.service';
|
import { AuthService } from '../iam/auth.service';
|
||||||
import { StoreCategoryService } from './store-category.service';
|
import { StoreCategoryService } from './store-category.service';
|
||||||
import { TencentLbsProvider } from '../../integrations/map/tencent-lbs.provider';
|
import { TencentLbsProvider } from '../../integrations/map/tencent-lbs.provider';
|
||||||
|
import {
|
||||||
|
TestWhitelistService,
|
||||||
|
normalizeTestPhone,
|
||||||
|
} from '../../common/test-whitelist/test-whitelist.service';
|
||||||
|
|
||||||
function haversineMeters(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
function haversineMeters(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
||||||
const toRad = (d: number) => (d * Math.PI) / 180;
|
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||||
@@ -43,10 +47,6 @@ export type StoreViewer = {
|
|||||||
bypassWhitelist?: boolean;
|
bypassWhitelist?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function normalizePhone(phone: string | null | undefined): string {
|
|
||||||
return (phone || '').replace(/\D/g, '').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseOptionalCoord(value: unknown, kind: 'lat' | 'lng' = 'lng'): number | null {
|
function parseOptionalCoord(value: unknown, kind: 'lat' | 'lng' = 'lng'): number | null {
|
||||||
if (value == null || value === '') return null;
|
if (value == null || value === '') return null;
|
||||||
const n = typeof value === 'number' ? value : Number(value);
|
const n = typeof value === 'number' ? value : Number(value);
|
||||||
@@ -67,8 +67,16 @@ export class StoreService {
|
|||||||
private readonly authService: AuthService,
|
private readonly authService: AuthService,
|
||||||
private readonly storeCategoryService: StoreCategoryService,
|
private readonly storeCategoryService: StoreCategoryService,
|
||||||
private readonly tencentLbs: TencentLbsProvider,
|
private readonly tencentLbs: TencentLbsProvider,
|
||||||
|
private readonly testWhitelist: TestWhitelistService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
private async whitelistPhoneSet(): Promise<Set<string>> {
|
||||||
|
const rows = await this.prisma.commonTestWhitelistPhone.findMany({
|
||||||
|
select: { phone: true },
|
||||||
|
});
|
||||||
|
return new Set(rows.map((r) => normalizeTestPhone(r.phone)).filter(Boolean));
|
||||||
|
}
|
||||||
|
|
||||||
private storeAddressText(store: {
|
private storeAddressText(store: {
|
||||||
province?: string | null;
|
province?: string | null;
|
||||||
cityName?: string | null;
|
cityName?: string | null;
|
||||||
@@ -116,17 +124,16 @@ export class StoreService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isVisibleToViewer(
|
isVisibleToViewer(
|
||||||
store: {
|
store: { visibilityWhitelistEnabled: boolean },
|
||||||
visibilityWhitelistEnabled: boolean;
|
|
||||||
visibilityPhones: Array<{ phone: string }>;
|
|
||||||
},
|
|
||||||
viewer?: StoreViewer,
|
viewer?: StoreViewer,
|
||||||
|
whitelistPhones?: Set<string>,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (viewer?.bypassWhitelist) return true;
|
if (viewer?.bypassWhitelist) return true;
|
||||||
if (!store.visibilityWhitelistEnabled) return true;
|
if (!store.visibilityWhitelistEnabled) return true;
|
||||||
const phone = normalizePhone(viewer?.phone);
|
const phone = normalizeTestPhone(viewer?.phone);
|
||||||
if (!phone) return false;
|
if (!phone) return false;
|
||||||
return store.visibilityPhones.some((row) => normalizePhone(row.phone) === phone);
|
if (whitelistPhones) return whitelistPhones.has(phone);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async listOpenStores(
|
async listOpenStores(
|
||||||
@@ -145,12 +152,14 @@ export class StoreService {
|
|||||||
include: {
|
include: {
|
||||||
category: true,
|
category: true,
|
||||||
coverResource: true,
|
coverResource: true,
|
||||||
visibilityPhones: { select: { phone: true } },
|
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const visible = stores.filter((s) => this.isVisibleToViewer(s, viewer));
|
const whitelistPhones = stores.some((s) => s.visibilityWhitelistEnabled)
|
||||||
|
? await this.whitelistPhoneSet()
|
||||||
|
: new Set<string>();
|
||||||
|
const visible = stores.filter((s) => this.isVisibleToViewer(s, viewer, whitelistPhones));
|
||||||
|
|
||||||
const hasUser =
|
const hasUser =
|
||||||
userLat != null &&
|
userLat != null &&
|
||||||
@@ -167,7 +176,7 @@ export class StoreService {
|
|||||||
const items: StoreListItem[] = [];
|
const items: StoreListItem[] = [];
|
||||||
for (const store of visible) {
|
for (const store of visible) {
|
||||||
const coords = await this.ensureStoreCoordinates(store);
|
const coords = await this.ensureStoreCoordinates(store);
|
||||||
const { visibilityPhones: _phones, visibilityWhitelistEnabled: _wl, ...rest } = store;
|
const { visibilityWhitelistEnabled: _wl, ...rest } = store;
|
||||||
const mapped = mapStoreCompat({
|
const mapped = mapStoreCompat({
|
||||||
...rest,
|
...rest,
|
||||||
latitude: coords?.latitude ?? store.latitude,
|
latitude: coords?.latitude ?? store.latitude,
|
||||||
@@ -197,10 +206,12 @@ export class StoreService {
|
|||||||
include: {
|
include: {
|
||||||
category: true,
|
category: true,
|
||||||
coverResource: true,
|
coverResource: true,
|
||||||
visibilityPhones: { select: { phone: true } },
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!store || !this.isVisibleToViewer(store, viewer)) {
|
const whitelistPhones = store?.visibilityWhitelistEnabled
|
||||||
|
? await this.whitelistPhoneSet()
|
||||||
|
: new Set<string>();
|
||||||
|
if (!store || !this.isVisibleToViewer(store, viewer, whitelistPhones)) {
|
||||||
throw new NotFoundException('门店不存在');
|
throw new NotFoundException('门店不存在');
|
||||||
}
|
}
|
||||||
const coords = await this.ensureStoreCoordinates(store);
|
const coords = await this.ensureStoreCoordinates(store);
|
||||||
@@ -212,7 +223,7 @@ export class StoreService {
|
|||||||
where: { storeId: id },
|
where: { storeId: id },
|
||||||
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||||
});
|
});
|
||||||
const { visibilityPhones: _phones, visibilityWhitelistEnabled: _wl, ...rest } = store;
|
const { visibilityWhitelistEnabled: _wl, ...rest } = store;
|
||||||
return serializeBigInt(
|
return serializeBigInt(
|
||||||
mapStoreCompat({
|
mapStoreCompat({
|
||||||
...rest,
|
...rest,
|
||||||
|
|||||||
@@ -218,6 +218,11 @@ export class TradeService {
|
|||||||
const promoCodeId =
|
const promoCodeId =
|
||||||
attribution?.promoCode?.status === 'ACTIVE' ? attribution.promoCodeId : undefined;
|
attribution?.promoCode?.status === 'ACTIVE' ? attribution.promoCodeId : undefined;
|
||||||
|
|
||||||
|
const buyer = await this.prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { isTest: true },
|
||||||
|
});
|
||||||
|
|
||||||
const order = await this.prisma.$transaction(async (tx) => {
|
const order = await this.prisma.$transaction(async (tx) => {
|
||||||
const created = await tx.order.create({
|
const created = await tx.order.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -258,6 +263,7 @@ export class TradeService {
|
|||||||
benefitAmount: preview.benefitAmount,
|
benefitAmount: preview.benefitAmount,
|
||||||
payExpireAt,
|
payExpireAt,
|
||||||
promoCodeId,
|
promoCodeId,
|
||||||
|
isTest: !!buyer?.isTest,
|
||||||
},
|
},
|
||||||
include: { product: true, imageResource: true },
|
include: { product: true, imageResource: true },
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@
|
|||||||
| 3.4.11 | 08-04 | 开发计划 + 企微机器人/消息推送 | [`开发计划`](./杜康好客-开发计划功能开发文档-v3.4.11.md) |
|
| 3.4.11 | 08-04 | 开发计划 + 企微机器人/消息推送 | [`开发计划`](./杜康好客-开发计划功能开发文档-v3.4.11.md) |
|
||||||
| 3.4.12 | 08-04 | 退款回滚/财务/批量任务·工单/套餐 imageUrl | [`v3.4.12`](./杜康好客-v3.4.12-工单迭代开发文档.md) |
|
| 3.4.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.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) |
|
| 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.11 | [`开发计划`](./杜康好客-开发计划功能开发文档-v3.4.11.md) | ✅ |
|
||||||
| 3.4.12 | [`工单迭代`](./杜康好客-v3.4.12-工单迭代开发文档.md) | ✅ |
|
| 3.4.12 | [`工单迭代`](./杜康好客-v3.4.12-工单迭代开发文档.md) | ✅ |
|
||||||
| 3.4.13 | [`体验优化`](./杜康好客-v3.4.13-体验优化开发文档.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) | 🔶 开发中 |
|
| 3.4.15 | [`mini-user 门店列表优化`](./杜康好客-v3.4.15-mini-user门店列表优化.md) | 🔶 开发中 |
|
||||||
|
|
||||||
## 5. 变更记录
|
## 5. 变更记录
|
||||||
|
|
||||||
| 日期 | 说明 |
|
| 日期 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
|
| 2026-08-07 | v3.4.14 增补统一测试白名单(不计账 / 限测可见 / Mock 旁路) |
|
||||||
| 2026-08-06 | v3.4.15 mini-user 门店列表卡片优化(开发中) |
|
| 2026-08-06 | v3.4.15 mini-user 门店列表卡片优化(开发中) |
|
||||||
| 2026-08-06 | v3.4.14 mini-user 门头/套餐详情(开发中) |
|
| 2026-08-06 | v3.4.14 mini-user 门头/套餐详情(开发中) |
|
||||||
| 2026-08-05 | v3.4.13 |
|
| 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` · **未发版**
|
> **2026-08-06** · **开发中** · PRD §0.6 · mini-user `3.4.14` · **未发版**
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
| 门店详情·套餐 | 仅完整标题纵向列表;点击进入详情 |
|
| 门店详情·套餐 | 仅完整标题纵向列表;点击进入详情 |
|
||||||
| 套餐详情页 | 实底导航让出胶囊区;内容区顶/左右留白;有图在门店名下;无图直接菜品 |
|
| 套餐详情页 | 实底导航让出胶囊区;内容区顶/左右留白;有图在门店名下;无图直接菜品 |
|
||||||
| **系统设置·微信小程序** | Logo / 资质图 / 客服电话 / C 端 H5 / Mock 验证码 **可配置**,经 `client-config` 下发 |
|
| **系统设置·微信小程序** | Logo / 资质图 / 客服电话 / C 端 H5 / Mock 验证码 **可配置**,经 `client-config` 下发 |
|
||||||
|
| **测试白名单** | 全局手机号名单;账号/门店/订单/核销打标;不计四条结算;合并商品/门店可见性手机号;HQ 独立管理模块 |
|
||||||
|
|
||||||
## 页面路由
|
## 页面路由
|
||||||
|
|
||||||
@@ -37,6 +38,36 @@ HQ → 系统设置 → **微信小程序配置**。启动时空缺键用 shared
|
|||||||
**下发**:`GET /common/client-config` 增加 `userH5Url`、`brandLogoUrl`、`brandLogoWideUrl`、`brandLogoMarkUrl`、`qualificationDisclosureUrl`、`customerServicePhone`。
|
**下发**:`GET /common/client-config` 增加 `userH5Url`、`brandLogoUrl`、`brandLogoWideUrl`、`brandLogoMarkUrl`、`qualificationDisclosureUrl`、`customerServicePhone`。
|
||||||
`MOCK_SMS_FIXED_CODE` 仅服务端 Mock 短信读取,不下发客户端。
|
`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
|
## ACC
|
||||||
|
|
||||||
- [ ] 门头区高度固定(4:3);图片 aspectFit 完整缩放;标题 section 位置不随图高变化
|
- [ ] 门头区高度固定(4:3);图片 aspectFit 完整缩放;标题 section 位置不随图高变化
|
||||||
@@ -45,6 +76,11 @@ HQ → 系统设置 → **微信小程序配置**。启动时空缺键用 shared
|
|||||||
- [ ] 无套餐时不展示区块;index 非法时友好提示
|
- [ ] 无套餐时不展示区块;index 非法时友好提示
|
||||||
- [ ] HQ 微信小程序配置可改 Logo/电话/落地页/Mock 码;保存后 client-config 立即生效(无需重启)
|
- [ ] HQ 微信小程序配置可改 Logo/电话/落地页/Mock 码;保存后 client-config 立即生效(无需重启)
|
||||||
- [ ] mini-user 登录/我的/客服/分享图读取配置;未配置时回退代码默认常量
|
- [ ] mini-user 登录/我的/客服/分享图读取配置;未配置时回退代码默认常量
|
||||||
|
- [ ] HQ「白名单管理」可增删手机号;可查看测试账号记录
|
||||||
|
- [ ] 页顶三 Checkbox 控制短信/微信/支付跳过真实验证,与 `MOCK_*` 同源立即生效
|
||||||
|
- [ ] 业务列表「过滤测试账号」勾选后不含测试数据
|
||||||
|
- [ ] 旧可见性手机号已导入;限测商品/门店仅全局名单可见
|
||||||
|
- [ ] 测试流水不进四条账单与门店打款
|
||||||
|
|
||||||
## HQ 开发计划
|
## HQ 开发计划
|
||||||
|
|
||||||
|
|||||||
+17
@@ -27,6 +27,23 @@
|
|||||||
- 订单 Tab:待付款/已付款/已完成;物流详情(签收照/拨号/ETA)
|
- 订单 Tab:待付款/已付款/已完成;物流详情(签收照/拨号/ETA)
|
||||||
- 售后:客服入口;发票/四类型工单按 PRD Wave 进度
|
- 售后:客服入口;发票/四类型工单按 PRD Wave 进度
|
||||||
- 版本:`minClientVersion` 过低强制更新或退出
|
- 版本:`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)
|
## 3. 门店端(h5-shop)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user