feat: strengthen risk controls and review workflows

This commit is contained in:
hectorzhao
2026-07-26 13:08:12 +08:00
parent b461532075
commit 2ce682c3fc
34 changed files with 2167 additions and 390 deletions
+195
View File
@@ -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>
);
}