feat(ops): 总部可按百分比限制接口放行并开关企微通知
线上需要按账户、用户和功能控制登录与加载成功率,同时单独停发订单、核销和账单通知。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user