feat(ops): 总部可按百分比限制接口放行并开关企微通知

线上需要按账户、用户和功能控制登录与加载成功率,同时单独停发订单、核销和账单通知。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-26 12:04:16 +08:00
parent 674b6ac31b
commit cb63d382ad
29 changed files with 1888 additions and 18 deletions
+2
View File
@@ -49,6 +49,7 @@ import PartnerLogsPage from './pages/PartnerLogsPage';
import WechatBindingsPage from './pages/WechatBindingsPage';
import HqPermissionsPage from './pages/HqPermissionsPage';
import SystemSettingsPage from './pages/SystemSettingsPage';
import ApiAccessPage from './pages/ApiAccessPage';
import TestWhitelistPage from './pages/TestWhitelistPage';
import WecomBotsPage from './pages/WecomBotsPage';
import WecomApiPluginsPage from './pages/WecomApiPluginsPage';
@@ -155,6 +156,7 @@ export default function App() {
<Route path="/deliveries/xiaofeixia" element={<XiaofeixiaTestPage />} />
<Route path="/hq-permissions" element={<HqPermissionsPage />} />
<Route path="/test-whitelist" element={<TestWhitelistPage />} />
<Route path="/api-access" element={<ApiAccessPage />} />
<Route path="/system-settings" element={<SystemSettingsPage />} />
<Route path="/hq-accounts" element={<HqAccountsPage />} />
</Route>
@@ -17,6 +17,7 @@ import {
FileTextOutlined,
LockOutlined,
SettingOutlined,
ApiOutlined,
AccountBookOutlined,
ProjectOutlined,
PictureOutlined,
@@ -164,6 +165,7 @@ const MENU_ITEMS: MenuProps['items'] = [
{ key: '/hq-permissions', icon: <LockOutlined />, label: '权限分配' },
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
{ key: '/test-whitelist', icon: <SafetyOutlined />, label: '白名单管理' },
{ key: '/api-access', icon: <ApiOutlined />, label: '接口访问' },
{ key: '/system-settings', icon: <SettingOutlined />, label: '系统设置' },
];
@@ -240,6 +242,7 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
'/logs/domain-events': 'logs',
'/hq-permissions': 'hq_permissions',
'/test-whitelist': 'test_whitelist',
'/api-access': 'api_access',
'/system-settings': 'system_settings_any',
'/hq-accounts': 'hq_accounts',
};
+426
View File
@@ -0,0 +1,426 @@
import { useCallback, useEffect, useState } from 'react';
import {
Button,
Card,
Form,
InputNumber,
Modal,
Popconfirm,
Select,
Space,
Switch,
Table,
Typography,
message,
} from 'antd';
import {
API_ACCESS_ERROR_KINDS,
API_ACCESS_ERROR_LABELS,
API_ACCESS_PERCENT_FEATURES,
API_ACCESS_PERCENT_FEATURE_LABELS,
type ApiAccessActorHit,
type ApiAccessErrorKind,
type ApiAccessFormResponse,
type ApiAccessPercentFeature,
type ApiAccessPolicyDto,
} from '@dukang/shared-types';
import { request } from '../lib/api';
const ERROR_OPTIONS = API_ACCESS_ERROR_KINDS.map((value) => ({
value,
label: API_ACCESS_ERROR_LABELS[value],
}));
const FEATURE_OPTIONS = [
{ value: '', label: '全部功能' },
...API_ACCESS_PERCENT_FEATURES.map((value) => ({
value,
label: API_ACCESS_PERCENT_FEATURE_LABELS[value],
})),
];
type PercentDraft = {
featureKey: ApiAccessPercentFeature;
featureLabel: string;
successPercent: number;
errorKind: ApiAccessErrorKind;
};
export default function ApiAccessPage() {
const [loading, setLoading] = useState(false);
const [savingGlobals, setSavingGlobals] = useState(false);
const [savingNotifies, setSavingNotifies] = useState(false);
const [percents, setPercents] = useState<PercentDraft[]>([]);
const [overrides, setOverrides] = useState<ApiAccessPolicyDto[]>([]);
const [notifies, setNotifies] = useState<ApiAccessPolicyDto[]>([]);
const [open, setOpen] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const data = await request<ApiAccessFormResponse>('/admin/api-access');
setPercents(
data.percents.map((row) => ({
featureKey: row.featureKey as ApiAccessPercentFeature,
featureLabel: row.featureLabel,
successPercent: row.successPercent ?? 100,
errorKind: row.errorKind ?? 'request_error',
})),
);
setOverrides(data.overrides);
setNotifies(data.notifies);
} catch (error) {
message.error(error instanceof Error ? error.message : '加载失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
async function saveGlobals() {
setSavingGlobals(true);
try {
const data = await request<ApiAccessFormResponse>('/admin/api-access/globals', {
method: 'PUT',
body: JSON.stringify({
items: percents.map((row) => ({
featureKey: row.featureKey,
successPercent: row.successPercent,
errorKind: row.errorKind,
})),
}),
});
setOverrides(data.overrides);
message.success('已保存成功百分比');
} catch (error) {
message.error(error instanceof Error ? error.message : '保存失败');
} finally {
setSavingGlobals(false);
}
}
async function saveNotifies() {
setSavingNotifies(true);
try {
await request<ApiAccessFormResponse>('/admin/api-access/notifies', {
method: 'PUT',
body: JSON.stringify({
items: notifies.map((row) => ({ featureKey: row.featureKey, enabled: row.enabled })),
}),
});
message.success('已保存通知开关');
} catch (error) {
message.error(error instanceof Error ? error.message : '保存失败');
} finally {
setSavingNotifies(false);
}
}
async function removeOverride(id: string) {
try {
const data = await request<ApiAccessFormResponse>(`/admin/api-access/overrides/${id}`, {
method: 'DELETE',
});
setOverrides(data.overrides);
message.success('已删除');
} catch (error) {
message.error(error instanceof Error ? error.message : '删除失败');
}
}
return (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<div>
<Typography.Title level={4} style={{ marginBottom: 8 }}>
接口访问
</Typography.Title>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0 }}>
成功百分比是放行比例,默认 100。0 为全部拒绝,每次请求单独计算。可按用户或账户覆盖。总部登录不受登录百分比影响。被拒绝的请求只返回所选文案,不发企微。通知开关只停止对应企微消息,不拦截下单、核销、出账、审核和改套餐。
</Typography.Paragraph>
</div>
<Card
title="功能成功百分比"
extra={
<Button type="primary" loading={savingGlobals} onClick={() => void saveGlobals()}>
保存
</Button>
}
>
<Table
rowKey="featureKey"
loading={loading}
pagination={false}
dataSource={percents}
columns={[
{ title: '功能', dataIndex: 'featureLabel', width: 140 },
{
title: '成功百分比',
width: 180,
render: (_, row) => (
<InputNumber
min={0}
max={100}
precision={0}
value={row.successPercent}
onChange={(value) =>
setPercents((list) =>
list.map((item) =>
item.featureKey === row.featureKey
? { ...item, successPercent: typeof value === 'number' ? value : 100 }
: item,
),
)
}
/>
),
},
{
title: '失败文案',
render: (_, row) => (
<Select
style={{ width: 180 }}
value={row.errorKind}
options={ERROR_OPTIONS}
onChange={(errorKind: ApiAccessErrorKind) =>
setPercents((list) =>
list.map((item) => (item.featureKey === row.featureKey ? { ...item, errorKind } : item)),
)
}
/>
),
},
{
title: '说明',
render: (_, row) =>
row.featureKey === 'audit_notify'
? '不拦截审核,只按比例丢弃门店审核通知'
: row.featureKey === 'login'
? 'C 端、门店、合伙人登录;总部登录始终放行'
: '命中后直接返回失败文案',
},
]}
/>
</Card>
<Card
title="账户 / 用户覆盖"
extra={
<Button type="primary" onClick={() => setOpen(true)}>
添加
</Button>
}
>
<Table
rowKey="id"
loading={loading}
pagination={false}
dataSource={overrides}
locale={{ emptyText: '未设置覆盖,全部走上方的功能百分比' }}
columns={[
{
title: '范围',
width: 100,
render: (_, row) => (row.scopeType === 'user' ? '用户' : '账户'),
},
{
title: '对象',
render: (_, row) => row.actorLabel || `${row.actorType} ${row.actorId}`,
},
{ title: '功能', dataIndex: 'featureLabel', width: 140 },
{ title: '成功百分比', dataIndex: 'successPercent', width: 120 },
{
title: '失败文案',
width: 160,
render: (_, row) => (row.errorKind ? API_ACCESS_ERROR_LABELS[row.errorKind] : '请求异常'),
},
{
title: '操作',
width: 100,
render: (_, row) => (
<Popconfirm title="删除这条覆盖?" onConfirm={() => void removeOverride(row.id)}>
<Button type="link" danger>
删除
</Button>
</Popconfirm>
),
},
]}
/>
</Card>
<Card
title="通知开关"
extra={
<Button type="primary" loading={savingNotifies} onClick={() => void saveNotifies()}>
保存
</Button>
}
>
<Table
rowKey="featureKey"
loading={loading}
pagination={false}
dataSource={notifies}
columns={[
{ title: '通知', dataIndex: 'featureLabel' },
{
title: '发送企微',
width: 160,
render: (_, row) => (
<Switch
checked={row.enabled}
checkedChildren="开"
unCheckedChildren="关"
onChange={(enabled) =>
setNotifies((list) =>
list.map((item) => (item.featureKey === row.featureKey ? { ...item, enabled } : item)),
)
}
/>
),
},
]}
/>
</Card>
<OverrideModal
open={open}
onClose={() => setOpen(false)}
onCreated={(data) => {
setOverrides(data.overrides);
setOpen(false);
}}
/>
</Space>
);
}
function OverrideModal({
open,
onClose,
onCreated,
}: {
open: boolean;
onClose: () => void;
onCreated: (data: ApiAccessFormResponse) => void;
}) {
const [form] = Form.useForm();
const [scope, setScope] = useState<'user' | 'account'>('user');
const [hits, setHits] = useState<ApiAccessActorHit[]>([]);
const [searching, setSearching] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) return;
form.resetFields();
form.setFieldsValue({
scopeType: 'user',
featureKey: '',
successPercent: 100,
errorKind: 'request_error',
});
setScope('user');
setHits([]);
}, [open, form]);
async function onSearch(q: string) {
const keyword = q.trim();
if (!keyword) {
setHits([]);
return;
}
setSearching(true);
try {
const rows = await request<ApiAccessActorHit[]>(
`/admin/api-access/actors?scope=${scope}&q=${encodeURIComponent(keyword)}`,
);
setHits(rows);
} catch (error) {
message.error(error instanceof Error ? error.message : '搜索失败');
} finally {
setSearching(false);
}
}
async function submit() {
const values = await form.validateFields();
const hit = hits.find((item) => `${item.actorType}:${item.actorId}` === values.actor);
if (!hit) {
message.error('请选择对象');
return;
}
setSaving(true);
try {
const data = await request<ApiAccessFormResponse>('/admin/api-access/overrides', {
method: 'POST',
body: JSON.stringify({
scopeType: scope,
actorType: hit.actorType,
actorId: hit.actorId,
featureKey: values.featureKey || null,
successPercent: values.successPercent,
errorKind: values.errorKind,
}),
});
message.success('已添加');
onCreated(data);
} catch (error) {
message.error(error instanceof Error ? error.message : '保存失败');
} finally {
setSaving(false);
}
}
return (
<Modal
title="添加覆盖"
open={open}
onCancel={onClose}
onOk={() => void submit()}
confirmLoading={saving}
destroyOnClose
>
<Form form={form} layout="vertical">
<Form.Item name="scopeType" label="范围" rules={[{ required: true }]}>
<Select
options={[
{ value: 'user', label: '用户' },
{ value: 'account', label: '账户' },
]}
onChange={(value: 'user' | 'account') => {
setScope(value);
setHits([]);
form.setFieldValue('actor', undefined);
}}
/>
</Form.Item>
<Form.Item name="actor" label="对象" rules={[{ required: true, message: '请选择对象' }]}>
<Select
showSearch
filterOption={false}
placeholder={scope === 'user' ? '手机号、昵称或用户编号' : '手机号或名称'}
loading={searching}
onSearch={(q) => void onSearch(q)}
options={hits.map((hit) => ({
value: `${hit.actorType}:${hit.actorId}`,
label: hit.label,
}))}
/>
</Form.Item>
<Form.Item name="featureKey" label="功能">
<Select options={FEATURE_OPTIONS} />
</Form.Item>
<Form.Item name="successPercent" label="成功百分比" rules={[{ required: true }]}>
<InputNumber min={0} max={100} precision={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="errorKind" label="失败文案" rules={[{ required: true }]}>
<Select options={ERROR_OPTIONS} />
</Form.Item>
</Form>
</Modal>
);
}