feat: strengthen risk controls and review workflows
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Pencil, Plus, RefreshCw } from 'lucide-react';
|
||||
import {
|
||||
adminApi,
|
||||
type EnterpriseApplication,
|
||||
type RiskRuleItem,
|
||||
} from '@/api/adminApi';
|
||||
import {
|
||||
Breadcrumb,
|
||||
Button,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
Table,
|
||||
Tag,
|
||||
type TableColumn,
|
||||
} from '@/components/ui';
|
||||
|
||||
const definitions: Array<{ code: RiskRuleItem['code']; label: string; unit: string }> = [
|
||||
{ code: 'MAX_PHONES_PER_TASK', label: '单任务最大号码数', unit: '个号码' },
|
||||
{ code: 'NON_WORKING_MARKETING_BULK', label: '非工作时间大批量营销发送', unit: '个号码' },
|
||||
{ code: 'TASK_CREATE_FREQUENCY', label: '10分钟客户端任务创建频控', unit: '个任务' },
|
||||
];
|
||||
|
||||
type EditorState = {
|
||||
id?: string;
|
||||
applicationId: string;
|
||||
code: RiskRuleItem['code'];
|
||||
thresholdValue: string;
|
||||
action: RiskRuleItem['action'];
|
||||
status: RiskRuleItem['status'];
|
||||
priority: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
};
|
||||
|
||||
function editorFromRule(rule?: RiskRuleItem): EditorState {
|
||||
return {
|
||||
id: rule?.id,
|
||||
applicationId: rule?.applicationId ?? '',
|
||||
code: rule?.code ?? 'MAX_PHONES_PER_TASK',
|
||||
thresholdValue: String(rule?.thresholdValue ?? 100000),
|
||||
action: rule?.action ?? 'block',
|
||||
status: rule?.status ?? 'active',
|
||||
priority: String(rule?.priority ?? 100),
|
||||
startTime: rule?.config?.startTime ?? '21:00',
|
||||
endTime: rule?.config?.endTime ?? '08:00',
|
||||
};
|
||||
}
|
||||
|
||||
export function AdminRiskRulesPage() {
|
||||
const [rules, setRules] = useState<RiskRuleItem[]>([]);
|
||||
const [applications, setApplications] = useState<EnterpriseApplication[]>([]);
|
||||
const [applicationId, setApplicationId] = useState('');
|
||||
const [editor, setEditor] = useState<EditorState | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
function load() {
|
||||
Promise.all([
|
||||
adminApi.listRiskRules(applicationId || undefined),
|
||||
applications.length === 0 ? adminApi.listEnterpriseApplications() : Promise.resolve(applications),
|
||||
]).then(([nextRules, nextApplications]) => {
|
||||
setRules(nextRules);
|
||||
setApplications(nextApplications);
|
||||
setError('');
|
||||
}).catch((failure: Error) => setError(failure.message || '风控规则加载失败'));
|
||||
}
|
||||
|
||||
useEffect(load, [applicationId]);
|
||||
|
||||
const existingCodes = useMemo(
|
||||
() => new Set(rules.filter((rule) => rule.applicationId === editor?.applicationId).map((rule) => rule.code)),
|
||||
[editor?.applicationId, rules],
|
||||
);
|
||||
|
||||
async function save() {
|
||||
if (!editor) return;
|
||||
if (!editor.id && !editor.applicationId) {
|
||||
setError('请选择企业应用');
|
||||
return;
|
||||
}
|
||||
const thresholdValue = Number(editor.thresholdValue);
|
||||
if (!Number.isFinite(thresholdValue) || thresholdValue < 0) {
|
||||
setError('阈值必须是大于等于0的数字');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError('');
|
||||
const body = {
|
||||
thresholdValue,
|
||||
action: editor.action,
|
||||
status: editor.status,
|
||||
priority: Number(editor.priority) || 100,
|
||||
config: editor.code === 'NON_WORKING_MARKETING_BULK'
|
||||
? { startTime: editor.startTime, endTime: editor.endTime, timeZone: 'Asia/Shanghai' }
|
||||
: undefined,
|
||||
};
|
||||
try {
|
||||
if (editor.id) {
|
||||
await adminApi.updateRiskRule(editor.id, body);
|
||||
} else {
|
||||
await adminApi.createRiskRule({
|
||||
...body,
|
||||
applicationId: editor.applicationId || undefined,
|
||||
code: editor.code,
|
||||
});
|
||||
}
|
||||
setEditor(null);
|
||||
load();
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '风控规则保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: Array<TableColumn<RiskRuleItem>> = [
|
||||
{ key: 'name', title: '规则名称', render: (rule) => <div><strong>{rule.name}</strong><small className="table-subline">{rule.description}</small></div> },
|
||||
{ key: 'scope', title: '适用范围', render: (rule) => rule.application ? <div><strong>{rule.application.name}</strong><small className="table-subline">{rule.application.tenant?.name ?? '-'}</small></div> : <Tag tone="info">全局默认</Tag> },
|
||||
{ key: 'threshold', title: '阈值', width: '150px', render: (rule) => `${rule.thresholdValue.toLocaleString('zh-CN')} ${definitions.find((item) => item.code === rule.code)?.unit ?? ''}` },
|
||||
{ key: 'time', title: '生效时间', width: '180px', render: (rule) => {
|
||||
if (rule.code !== 'NON_WORKING_MARKETING_BULK') return '-';
|
||||
const start = rule.config?.startTime ?? '21:00';
|
||||
const end = rule.config?.endTime ?? '08:00';
|
||||
return `${start}–${start > end ? '次日' : ''}${end}`;
|
||||
} },
|
||||
{ key: 'action', title: '处理动作', width: '120px', render: (rule) => <Tag tone={rule.action === 'block' ? 'danger' : 'warning'}>{rule.action === 'block' ? '直接拒绝' : '人工审核'}</Tag> },
|
||||
{ key: 'status', title: '状态', width: '100px', render: (rule) => <Tag tone={rule.status === 'active' ? 'success' : 'neutral'}>{rule.status === 'active' ? '启用' : '停用'}</Tag> },
|
||||
{ key: 'priority', title: '优先级', width: '90px', render: (rule) => rule.priority },
|
||||
{ key: 'actions', title: '操作', width: '100px', align: 'right', render: (rule) => <Button icon={<Pencil size={15} />} onClick={() => setEditor(editorFromRule(rule))} size="sm" variant="ghost">编辑</Button> },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<div className="page-heading">
|
||||
<div><Breadcrumb items={['风控管理', '风控规则']} /><h1>风控规则</h1><p>维护全局阈值,并按企业应用覆盖;确定性号码校验和黑名单拦截不在此配置。</p></div>
|
||||
<div className="page-heading__actions">
|
||||
<Button icon={<RefreshCw size={16} />} onClick={load} variant="ghost">刷新</Button>
|
||||
<Button icon={<Plus size={16} />} onClick={() => setEditor(editorFromRule())}>新增应用覆盖</Button>
|
||||
</div>
|
||||
</div>
|
||||
{error ? <p className="form-error" role="alert">{error}</p> : null}
|
||||
<div className="surface sms-audit-filter">
|
||||
<Select
|
||||
label="查看范围"
|
||||
onChange={(event) => setApplicationId(event.target.value)}
|
||||
options={[
|
||||
{ label: '全部全局规则', value: '' },
|
||||
...applications.map((application) => ({
|
||||
label: `${application.tenant?.name ?? '未命名企业'} · ${application.name}`,
|
||||
value: application.id,
|
||||
})),
|
||||
]}
|
||||
value={applicationId}
|
||||
/>
|
||||
</div>
|
||||
<div className="surface"><Table columns={columns} data={rules} emptyText="暂无风控规则" rowKey="id" /></div>
|
||||
{editor ? <Modal
|
||||
footer={<><Button disabled={saving} onClick={() => setEditor(null)} variant="ghost">取消</Button><Button disabled={saving} onClick={() => void save()}>{saving ? '保存中…' : '保存'}</Button></>}
|
||||
onClose={() => setEditor(null)}
|
||||
open
|
||||
size="xl"
|
||||
title={editor.id ? '编辑风控规则' : '新增企业应用级覆盖'}
|
||||
>
|
||||
<div className="form-grid">
|
||||
{!editor.id ? <Select
|
||||
label="企业应用"
|
||||
onChange={(event) => setEditor({ ...editor, applicationId: event.target.value })}
|
||||
options={[{ label: '请选择企业应用', value: '' }, ...applications.map((application) => ({ label: `${application.tenant?.name ?? '未命名企业'} · ${application.name}`, value: application.id }))]}
|
||||
value={editor.applicationId}
|
||||
/> : null}
|
||||
{!editor.id ? <Select
|
||||
label="规则"
|
||||
onChange={(event) => {
|
||||
const code = event.target.value as RiskRuleItem['code'];
|
||||
const globalRule = rules.find((rule) => !rule.applicationId && rule.code === code);
|
||||
setEditor({ ...editor, code, thresholdValue: String(globalRule?.thresholdValue ?? editor.thresholdValue), action: globalRule?.action ?? editor.action });
|
||||
}}
|
||||
options={definitions.filter((item) => !existingCodes.has(item.code) || item.code === editor.code).map((item) => ({ label: item.label, value: item.code }))}
|
||||
value={editor.code}
|
||||
/> : <Input disabled label="规则" value={definitions.find((item) => item.code === editor.code)?.label ?? editor.code} />}
|
||||
<Input label={`阈值(${definitions.find((item) => item.code === editor.code)?.unit ?? ''})`} min="0" onChange={(event) => setEditor({ ...editor, thresholdValue: event.target.value })} type="number" value={editor.thresholdValue} />
|
||||
<Select label="处理动作" onChange={(event) => setEditor({ ...editor, action: event.target.value as RiskRuleItem['action'] })} options={[{ label: '直接拒绝', value: 'block' }, { label: '进入人工审核', value: 'manual_review' }]} value={editor.action} />
|
||||
<Select label="状态" onChange={(event) => setEditor({ ...editor, status: event.target.value as RiskRuleItem['status'] })} options={[{ label: '启用', value: 'active' }, { label: '停用', value: 'inactive' }]} value={editor.status} />
|
||||
<Input label="优先级" min="1" onChange={(event) => setEditor({ ...editor, priority: event.target.value })} type="number" value={editor.priority} />
|
||||
{editor.code === 'NON_WORKING_MARKETING_BULK' ? <>
|
||||
<Input label="非工作时间开始" onChange={(event) => setEditor({ ...editor, startTime: event.target.value })} type="time" value={editor.startTime} />
|
||||
<Input label="非工作时间结束" onChange={(event) => setEditor({ ...editor, endTime: event.target.value })} type="time" value={editor.endTime} />
|
||||
</> : null}
|
||||
</div>
|
||||
</Modal> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user