feat: add phone frequency controls and modularize codebase

This commit is contained in:
hectorzhao
2026-07-31 22:25:23 +08:00
parent 0af671b4ed
commit ca4f591a13
216 changed files with 41579 additions and 23694 deletions
+291 -4
View File
@@ -1,8 +1,10 @@
import { useEffect, useMemo, useState } from 'react';
import { Pencil, Plus, RefreshCw } from 'lucide-react';
import { Pencil, Plus, RefreshCw, Search, Trash2, Unlock } from 'lucide-react';
import {
adminApi,
type EnterpriseApplication,
type PhoneFrequencyHit,
type PhoneFrequencyWhitelistItem,
type RiskRuleItem,
} from '@/api/adminApi';
import {
@@ -10,18 +12,27 @@ import {
Button,
Input,
Modal,
Pagination,
Select,
Table,
Tag,
Textarea,
type TableColumn,
} from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
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: '个任务' },
{ code: 'PHONE_FREQUENCY_24H', label: '单号码24小时发送频次', unit: '条业务短信' },
{ code: 'PHONE_FREQUENCY_5M', label: '单号码5分钟发送频次', unit: '条业务短信' },
];
function isPhoneFrequencyRule(code: RiskRuleItem['code']) {
return code === 'PHONE_FREQUENCY_24H' || code === 'PHONE_FREQUENCY_5M';
}
type EditorState = {
id?: string;
applicationId: string;
@@ -34,6 +45,14 @@ type EditorState = {
endTime: string;
};
type WhitelistEditorState = {
id?: string;
phoneNumber: string;
reason: string;
remark: string;
status: 'active' | 'inactive';
};
function editorFromRule(rule?: RiskRuleItem): EditorState {
return {
id: rule?.id,
@@ -55,6 +74,23 @@ export function AdminRiskRulesPage() {
const [editor, setEditor] = useState<EditorState | null>(null);
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
const [frequencyHits, setFrequencyHits] = useState<PhoneFrequencyHit[]>([]);
const [hitPhone, setHitPhone] = useState('');
const [hitStatus, setHitStatus] = useState<'active' | 'expired' | 'released' | ''>('active');
const [hitPage, setHitPage] = useState(1);
const [hitTotal, setHitTotal] = useState(0);
const [releaseHit, setReleaseHit] = useState<PhoneFrequencyHit | null>(null);
const [releaseReason, setReleaseReason] = useState('');
const [releasing, setReleasing] = useState(false);
const [whitelist, setWhitelist] = useState<PhoneFrequencyWhitelistItem[]>([]);
const [whitelistPhone, setWhitelistPhone] = useState('');
const [whitelistStatus, setWhitelistStatus] = useState<'active' | 'inactive' | 'deleted' | ''>('');
const [whitelistPage, setWhitelistPage] = useState(1);
const [whitelistTotal, setWhitelistTotal] = useState(0);
const [whitelistEditor, setWhitelistEditor] = useState<WhitelistEditorState | null>(null);
const [whitelistSaving, setWhitelistSaving] = useState(false);
const [deletingWhitelist, setDeletingWhitelist] = useState<PhoneFrequencyWhitelistItem | null>(null);
const [deleteWhitelistReason, setDeleteWhitelistReason] = useState('');
function load() {
Promise.all([
@@ -69,6 +105,42 @@ export function AdminRiskRulesPage() {
useEffect(load, [applicationId]);
function loadFrequencyHits(page = hitPage) {
adminApi.listPhoneFrequencyHits({
applicationId: applicationId || undefined,
phoneNumber: hitPhone.trim() || undefined,
status: hitStatus || undefined,
page,
pageSize: 20,
}).then((result) => {
setFrequencyHits(result.items);
setHitTotal(result.total);
setHitPage(result.page);
}).catch((failure: Error) => setError(failure.message || '号码频次触发记录加载失败'));
}
useEffect(() => {
setHitPage(1);
loadFrequencyHits(1);
}, [applicationId]);
function loadWhitelist(page = whitelistPage) {
adminApi.listPhoneFrequencyWhitelist({
phoneNumber: whitelistPhone.trim() || undefined,
status: whitelistStatus || undefined,
page,
pageSize: 20,
}).then((result) => {
setWhitelist(result.items);
setWhitelistTotal(result.total);
setWhitelistPage(result.page);
}).catch((failure: Error) => setError(failure.message || '号码频控白名单加载失败'));
}
useEffect(() => {
loadWhitelist(1);
}, []);
const existingCodes = useMemo(
() => new Set(rules.filter((rule) => rule.applicationId === editor?.applicationId).map((rule) => rule.code)),
[editor?.applicationId, rules],
@@ -87,9 +159,10 @@ export function AdminRiskRulesPage() {
}
setSaving(true);
setError('');
const phoneFrequencyRule = isPhoneFrequencyRule(editor.code);
const body = {
thresholdValue,
action: editor.action,
action: phoneFrequencyRule ? 'block' as const : editor.action,
status: editor.status,
priority: Number(editor.priority) || 100,
config: editor.code === 'NON_WORKING_MARKETING_BULK'
@@ -115,6 +188,78 @@ export function AdminRiskRulesPage() {
}
}
async function confirmRelease() {
if (!releaseHit) return;
if (!releaseReason.trim()) {
setError('解除并清零时必须填写原因');
return;
}
setReleasing(true);
try {
await adminApi.releasePhoneFrequencyHit(releaseHit.id, releaseReason.trim());
setReleaseHit(null);
setReleaseReason('');
loadFrequencyHits();
} catch (failure) {
setError(failure instanceof Error ? failure.message : '解除号码频控失败');
} finally {
setReleasing(false);
}
}
async function saveWhitelist() {
if (!whitelistEditor) return;
if (!/^(\+?86)?1\d{10}$/.test(whitelistEditor.phoneNumber.replace(/[\s-]/g, ''))) {
setError('请输入有效的中国大陆11位手机号码');
return;
}
if (!whitelistEditor.reason.trim()) {
setError('白名单用途说明不能为空');
return;
}
setWhitelistSaving(true);
setError('');
try {
const body = {
phoneNumber: whitelistEditor.phoneNumber.trim(),
reason: whitelistEditor.reason.trim(),
remark: whitelistEditor.remark.trim(),
status: whitelistEditor.status,
};
if (whitelistEditor.id) {
await adminApi.updatePhoneFrequencyWhitelist(whitelistEditor.id, body);
} else {
await adminApi.createPhoneFrequencyWhitelist(body);
}
setWhitelistEditor(null);
loadWhitelist(1);
} catch (failure) {
setError(failure instanceof Error ? failure.message : '号码频控白名单保存失败');
} finally {
setWhitelistSaving(false);
}
}
async function confirmDeleteWhitelist() {
if (!deletingWhitelist) return;
if (!deleteWhitelistReason.trim()) {
setError('删除白名单时必须填写原因');
return;
}
setWhitelistSaving(true);
setError('');
try {
await adminApi.deletePhoneFrequencyWhitelist(deletingWhitelist.id, deleteWhitelistReason.trim());
setDeletingWhitelist(null);
setDeleteWhitelistReason('');
loadWhitelist(1);
} catch (failure) {
setError(failure instanceof Error ? failure.message : '号码频控白名单删除失败');
} finally {
setWhitelistSaving(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> },
@@ -131,6 +276,45 @@ export function AdminRiskRulesPage() {
{ key: 'actions', title: '操作', width: '100px', align: 'right', render: (rule) => <Button icon={<Pencil size={15} />} onClick={() => setEditor(editorFromRule(rule))} size="sm" variant="ghost"></Button> },
];
const hitColumns: Array<TableColumn<PhoneFrequencyHit>> = [
{ key: 'phone', title: '号码', width: '140px', render: (hit) => <strong>{hit.phoneNumber}</strong> },
{ key: 'scope', title: '企业 / 应用', render: (hit) => <div><strong>{hit.tenant.name}</strong><small className="table-subline">{hit.application.name}</small></div> },
{ key: 'rule', title: '命中规则', render: (hit) => <div><strong>{hit.ruleName}</strong><small className="table-subline"> {hit.thresholdValue} {hit.actualValue} </small></div> },
{ key: 'window', title: '计数周期', width: '250px', render: (hit) => `${formatDateTime(hit.windowStartedAt)}${formatDateTime(hit.windowEndsAt)}` },
{ key: 'status', title: '状态', width: '100px', render: (hit) => hit.releasedAt
? <Tag tone="neutral"></Tag>
: new Date(hit.windowEndsAt).getTime() <= Date.now()
? <Tag tone="warning"></Tag>
: <Tag tone="danger"></Tag> },
{ key: 'createdAt', title: '触发时间', width: '170px', render: (hit) => formatDateTime(hit.createdAt) },
{ key: 'actions', title: '操作', width: '110px', align: 'right', render: (hit) => hit.releasedAt
? <span className="muted"></span>
: <Button icon={<Unlock size={15} />} onClick={() => { setReleaseHit(hit); setReleaseReason(''); }} size="sm" variant="ghost"></Button> },
];
const whitelistColumns: Array<TableColumn<PhoneFrequencyWhitelistItem>> = [
{ key: 'phone', title: '手机号码', width: '145px', render: (item) => <strong>{item.phoneNumber}</strong> },
{ key: 'reason', title: '用途说明', render: (item) => <div><strong>{item.reason}</strong>{item.remark ? <small className="table-subline">{item.remark}</small> : null}</div> },
{ key: 'status', title: '状态', width: '90px', render: (item) => <Tag tone={item.status === 'active' ? 'success' : item.status === 'deleted' ? 'danger' : 'neutral'}>{item.status === 'active' ? '启用' : item.status === 'deleted' ? '已删除' : '停用'}</Tag> },
{ key: 'operator', title: '最后操作人', width: '150px', render: (item) => item.updatedBy.displayName || item.updatedBy.username },
{ key: 'updatedAt', title: '更新时间', width: '170px', render: (item) => formatDateTime(item.updatedAt) },
{ key: 'actions', title: '操作', width: '175px', align: 'right', render: (item) => item.status === 'deleted'
? <span className="muted"></span>
: <div className="table-actions">
<Button icon={<Pencil size={15} />} onClick={() => setWhitelistEditor({
id: item.id,
phoneNumber: item.phoneNumber,
reason: item.reason,
remark: item.remark ?? '',
status: item.status === 'active' ? 'active' : 'inactive',
})} size="sm" variant="ghost"></Button>
<Button icon={<Trash2 size={15} />} onClick={() => { setDeletingWhitelist(item); setDeleteWhitelistReason(''); }} size="sm" variant="ghost"></Button>
</div> },
];
const hitTotalPages = Math.max(1, Math.ceil(hitTotal / 20));
const whitelistTotalPages = Math.max(1, Math.ceil(whitelistTotal / 20));
return (
<section className="page-stack">
<div className="page-heading">
@@ -156,6 +340,77 @@ export function AdminRiskRulesPage() {
/>
</div>
<div className="surface"><Table columns={columns} data={rules} emptyText="暂无风控规则" rowKey="id" /></div>
<div className="surface">
<div className="section-heading">
<div><h2></h2><p>245</p></div>
<div className="page-heading__actions">
<Tag tone="info">{whitelistTotal} </Tag>
<Button icon={<Plus size={16} />} onClick={() => setWhitelistEditor({ phoneNumber: '', reason: '', remark: '', status: 'active' })}></Button>
</div>
</div>
<div className="sms-audit-filter">
<Input label="手机号码" onChange={(event) => setWhitelistPhone(event.target.value)} placeholder="输入完整或部分号码" value={whitelistPhone} />
<Select
label="白名单状态"
onChange={(event) => setWhitelistStatus(event.target.value as typeof whitelistStatus)}
options={[
{ label: '全部有效记录', value: '' },
{ label: '启用', value: 'active' },
{ label: '停用', value: 'inactive' },
{ label: '已删除', value: 'deleted' },
]}
value={whitelistStatus}
/>
<div className="admin-task-filter__actions">
<Button icon={<Search size={16} />} onClick={() => loadWhitelist(1)}></Button>
</div>
</div>
<Table columns={whitelistColumns} data={whitelist} emptyText="暂无号码频控白名单" pagination={false} rowKey="id" />
<Pagination
nextDisabled={whitelistPage >= whitelistTotalPages}
onNext={() => loadWhitelist(Math.min(whitelistTotalPages, whitelistPage + 1))}
onPageChange={(page) => loadWhitelist(page)}
onPrevious={() => loadWhitelist(Math.max(1, whitelistPage - 1))}
page={whitelistPage}
previousDisabled={whitelistPage <= 1}
total={whitelistTotal}
totalPages={whitelistTotalPages}
/>
</div>
<div className="surface">
<div className="section-heading">
<div><h2></h2><p></p></div>
<Tag tone="warning">{hitTotal} </Tag>
</div>
<div className="sms-audit-filter">
<Input label="手机号码" onChange={(event) => setHitPhone(event.target.value)} placeholder="输入完整或部分号码" value={hitPhone} />
<Select
label="记录状态"
onChange={(event) => setHitStatus(event.target.value as typeof hitStatus)}
options={[
{ label: '全部状态', value: '' },
{ label: '拦截中', value: 'active' },
{ label: '周期已到期', value: 'expired' },
{ label: '已人工解除', value: 'released' },
]}
value={hitStatus}
/>
<div className="admin-task-filter__actions">
<Button icon={<Search size={16} />} onClick={() => { setHitPage(1); loadFrequencyHits(1); }}></Button>
</div>
</div>
<Table columns={hitColumns} data={frequencyHits} emptyText="暂无号码频次触发记录" pagination={false} rowKey="id" />
<Pagination
nextDisabled={hitPage >= hitTotalPages}
onNext={() => loadFrequencyHits(Math.min(hitTotalPages, hitPage + 1))}
onPageChange={(page) => loadFrequencyHits(page)}
onPrevious={() => loadFrequencyHits(Math.max(1, hitPage - 1))}
page={hitPage}
previousDisabled={hitPage <= 1}
total={hitTotal}
totalPages={hitTotalPages}
/>
</div>
{editor ? <Modal
footer={<><Button disabled={saving} onClick={() => setEditor(null)} variant="ghost"></Button><Button disabled={saving} onClick={() => void save()}>{saving ? '保存中…' : '保存'}</Button></>}
onClose={() => setEditor(null)}
@@ -180,8 +435,8 @@ export function AdminRiskRulesPage() {
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} />
<Input label={`阈值(${definitions.find((item) => item.code === editor.code)?.unit ?? ''}`} min={isPhoneFrequencyRule(editor.code) ? '1' : '0'} onChange={(event) => setEditor({ ...editor, thresholdValue: event.target.value })} type="number" value={editor.thresholdValue} />
<Select disabled={isPhoneFrequencyRule(editor.code)} label="处理动作" onChange={(event) => setEditor({ ...editor, action: event.target.value as RiskRuleItem['action'] })} options={isPhoneFrequencyRule(editor.code) ? [{ label: '直接拒绝(首版固定)', value: 'block' }] : [{ label: '直接拒绝', value: 'block' }, { label: '进入人工审核', value: 'manual_review' }]} value={isPhoneFrequencyRule(editor.code) ? 'block' : 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' ? <>
@@ -190,6 +445,38 @@ export function AdminRiskRulesPage() {
</> : null}
</div>
</Modal> : null}
{whitelistEditor ? <Modal
footer={<><Button disabled={whitelistSaving} onClick={() => setWhitelistEditor(null)} variant="ghost"></Button><Button disabled={whitelistSaving} onClick={() => void saveWhitelist()}>{whitelistSaving ? '保存中…' : '保存'}</Button></>}
onClose={() => setWhitelistEditor(null)}
open
title={whitelistEditor.id ? '编辑号码频控白名单' : '新增号码频控白名单'}
>
<div className="form-grid">
<Input label="手机号码" onChange={(event) => setWhitelistEditor({ ...whitelistEditor, phoneNumber: event.target.value })} placeholder="中国大陆11位手机号码" value={whitelistEditor.phoneNumber} />
<Select label="状态" onChange={(event) => setWhitelistEditor({ ...whitelistEditor, status: event.target.value as WhitelistEditorState['status'] })} options={[{ label: '启用', value: 'active' }, { label: '停用', value: 'inactive' }]} value={whitelistEditor.status} />
</div>
<Textarea label="用途说明" maxLength={200} onChange={(event) => setWhitelistEditor({ ...whitelistEditor, reason: event.target.value })} placeholder="必填,说明该号码为何需要豁免频控" rows={3} value={whitelistEditor.reason} />
<Textarea label="备注" maxLength={500} onChange={(event) => setWhitelistEditor({ ...whitelistEditor, remark: event.target.value })} placeholder="选填" rows={3} value={whitelistEditor.remark} />
<p className="muted"></p>
</Modal> : null}
{deletingWhitelist ? <Modal
footer={<><Button disabled={whitelistSaving} onClick={() => setDeletingWhitelist(null)} variant="ghost"></Button><Button disabled={whitelistSaving} onClick={() => void confirmDeleteWhitelist()}>{whitelistSaving ? '处理中…' : '删除并清零'}</Button></>}
onClose={() => setDeletingWhitelist(null)}
open
title="删除号码频控白名单"
>
<p> <strong>{deletingWhitelist.phoneNumber}</strong> </p>
<Textarea label="删除原因" maxLength={500} onChange={(event) => setDeleteWhitelistReason(event.target.value)} placeholder="请填写删除原因" rows={4} value={deleteWhitelistReason} />
</Modal> : null}
{releaseHit ? <Modal
footer={<><Button disabled={releasing} onClick={() => setReleaseHit(null)} variant="ghost"></Button><Button disabled={releasing} onClick={() => void confirmRelease()}>{releasing ? '处理中…' : '解除并清零'}</Button></>}
onClose={() => setReleaseHit(null)}
open
title="解除号码频控"
>
<p> <strong>{releaseHit.phoneNumber}</strong> {releaseHit.application.name}</p>
<Textarea label="解除原因" onChange={(event) => setReleaseReason(event.target.value)} placeholder="请填写人工解除原因" rows={4} value={releaseReason} />
</Modal> : null}
</section>
);
}