feat(worker-risk): 风控后台页面(可调阈值/实时状态/事件留痕/人工封禁解封),并修复解封后仍被 403 的 bug
## 修复的 bug(生产已发生) 风控三级封号把 tracker.banned 置为 true 后,banned 检查发生在鉴权之前, 而它只存在于进程内存。后台把账号改回 active 时数据库变了、内存没变, 于是账号在库里已经正常、请求却永远 403,只能等容器重启才恢复。 修法两道: - **数据库是唯一事实来源**:banned 改为带 30 秒 TTL 的缓存,到点回查 worker_users.status;已解封(或账号已删)则清除标记并放行。 对被判 banned 的 key 最多每 30 秒查一次,即便对方仍以 65 QPS 刷也只是 每 30 秒一次查询。查询失败时**保守维持封禁**,不因数据库抖动放行刷子。 - **后台改状态时主动释放**:review 只允许设 active/rejected/disabled/pending_review (没有 frozen),所以走到那里就意味着不再由风控冻结,直接释放内存状态。 顺带修掉一个连带缺陷:解封时只清了 violationCount,没清滑动窗口计数, 账号刚恢复就会被上一轮残留的 windowCount 立刻限流、并在 60 秒内二次封禁。 现在整轮窗口状态一并清零。 ## 新增后台页面 接单平台 → 风控(/admin/worker-platform?tab=risk-control),三块: - 阈值配置:6 个阈值可视化编辑,落 app_config_entries,保存即热生效,无需重启 - 当前实时风控:进程内存状态(已封禁/惩罚中/仅记录),支持人工解封、封禁、仅释放内存状态 - 风控事件留痕:worker_risk_events 分页查询,可按类型/IP 筛选 ## 关键取舍 只记**显著事件**(二级惩罚、三级封号、人工处置),不记每一次被拒请求。 被刷时实测 45~65 QPS,按请求写库正是最初要解决的问题。实时计数仍走进程内存。 事件写入为 fire-and-forget 并吞异常,留痕失败绝不影响大厅请求。 ## 验证 - 新增 6 个用例覆盖解封立即生效、对账自愈、TTL 不重复查库、对账失败保守维持、阈值可配置 - 迁移 089 已在临时库实测:表结构/4 索引/CHECK 约束/外键均正确,非法 event_type 被拒 - 后端 557 测试 554 通过 0 失败;前后端 typecheck/lint/format/build 全绿
This commit is contained in:
@@ -20,6 +20,7 @@ import LevelsPanel from './panels/LevelsPanel'
|
||||
import NotificationsPanel from './panels/NotificationsPanel'
|
||||
import ProductMatchPanel from './panels/ProductMatchPanel'
|
||||
import ProductRulesPanel from './panels/ProductRulesPanel'
|
||||
import RiskControlPanel from './panels/RiskControlPanel'
|
||||
import WorkersPanel from './panels/WorkersPanel'
|
||||
import WorkOrdersPanel from './panels/WorkOrdersPanel'
|
||||
|
||||
@@ -47,6 +48,7 @@ const TAB_QUERY_KEYS: Record<string, string[][]> = {
|
||||
notifications: [['admin-notifications']],
|
||||
announcement: [['admin-announcements']],
|
||||
'hall-config': [['admin-hall-config']],
|
||||
'risk-control': [['admin-worker-risk-overview'], ['admin-worker-risk-events']],
|
||||
}
|
||||
|
||||
export default function AdminWorkerPlatformPage() {
|
||||
@@ -184,6 +186,7 @@ export default function AdminWorkerPlatformPage() {
|
||||
{ key: 'leaderboard', label: '排行榜', children: <LeaderboardPanel /> },
|
||||
{ key: 'finance', label: '资金', children: <FinancePanel /> },
|
||||
{ key: 'levels', label: '等级权限', children: <LevelsPanel /> },
|
||||
{ key: 'risk-control', label: '风控', children: <RiskControlPanel /> },
|
||||
...(isAdmin
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Descriptions,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
} from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import {
|
||||
banAdminWorkerRisk,
|
||||
fetchAdminWorkerRiskEvents,
|
||||
fetchAdminWorkerRiskOverview,
|
||||
releaseAdminWorkerRiskTracker,
|
||||
saveAdminWorkerRiskConfig,
|
||||
unbanAdminWorkerRisk,
|
||||
} from '@/services/admin'
|
||||
import type {
|
||||
WorkerRiskActiveTracker,
|
||||
WorkerRiskConfig,
|
||||
WorkerRiskEvent,
|
||||
WorkerRiskEventType,
|
||||
} from '@/types/worker-platform'
|
||||
import { formatDateTime } from '@/utils/date-time'
|
||||
import { getAdminRole } from '@/utils/admin-auth'
|
||||
|
||||
const EVENT_TYPE_LABEL: Record<WorkerRiskEventType, { text: string; color: string }> = {
|
||||
tier2_penalized: { text: '二级惩罚', color: 'orange' },
|
||||
tier3_autofrozen: { text: '三级自动封号', color: 'red' },
|
||||
manual_banned: { text: '人工封禁', color: 'volcano' },
|
||||
manual_unbanned: { text: '人工解封', color: 'green' },
|
||||
manual_ban_failed: { text: '封禁失败', color: 'default' },
|
||||
}
|
||||
|
||||
function formatRemaining(iso: string | null) {
|
||||
if (!iso) return '-'
|
||||
const remainMs = new Date(iso).getTime() - Date.now()
|
||||
if (!Number.isFinite(remainMs) || remainMs <= 0) return '已结束'
|
||||
const seconds = Math.ceil(remainMs / 1000)
|
||||
if (seconds < 60) return `剩余 ${seconds} 秒`
|
||||
return `剩余 ${Math.ceil(seconds / 60)} 分钟`
|
||||
}
|
||||
|
||||
export default function RiskControlPanel() {
|
||||
const { message, modal } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
const [form] = Form.useForm<WorkerRiskConfig>()
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [acting, setActing] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [eventType, setEventType] = useState<string>('')
|
||||
// 写接口在后端要求 admin 角色,非 admin 只读,避免点了才报错
|
||||
const canManage = getAdminRole() === 'admin'
|
||||
const [ipFilter, setIpFilter] = useState('')
|
||||
|
||||
// 实时层:内存状态,刷新频率高一些;重启会清零
|
||||
const overviewQuery = useQuery({
|
||||
queryKey: ['admin-worker-risk-overview'],
|
||||
queryFn: fetchAdminWorkerRiskOverview,
|
||||
refetchInterval: 15_000,
|
||||
})
|
||||
const overview = overviewQuery.data?.data
|
||||
|
||||
const eventsQuery = useQuery({
|
||||
queryKey: ['admin-worker-risk-events', eventType, ipFilter, page, pageSize],
|
||||
queryFn: () =>
|
||||
fetchAdminWorkerRiskEvents({
|
||||
page,
|
||||
pageSize,
|
||||
eventType: eventType || undefined,
|
||||
ip: ipFilter.trim() || undefined,
|
||||
}),
|
||||
})
|
||||
const events = eventsQuery.data?.data
|
||||
|
||||
useEffect(() => {
|
||||
if (overview?.config) {
|
||||
form.setFieldsValue(overview.config)
|
||||
}
|
||||
}, [overview?.config, form])
|
||||
|
||||
async function refreshAll() {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-worker-risk-overview'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-worker-risk-events'] }),
|
||||
])
|
||||
}
|
||||
|
||||
async function saveConfig(values: WorkerRiskConfig) {
|
||||
setSaving(true)
|
||||
try {
|
||||
await saveAdminWorkerRiskConfig(values)
|
||||
await refreshAll()
|
||||
message.success('风控参数已保存并立即生效')
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '保存风控参数失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function confirmAction(title: string, content: string, run: () => Promise<void>) {
|
||||
modal.confirm({
|
||||
title,
|
||||
content,
|
||||
okButtonProps: { danger: title.includes('封禁') },
|
||||
onOk: async () => {
|
||||
setActing(true)
|
||||
try {
|
||||
await run()
|
||||
await refreshAll()
|
||||
message.success(`${title}成功`)
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : `${title}失败`)
|
||||
} finally {
|
||||
setActing(false)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const trackerColumns: ColumnsType<WorkerRiskActiveTracker> = [
|
||||
{
|
||||
title: '跟踪对象',
|
||||
dataIndex: 'key',
|
||||
render: (_value, row) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<span>{row.workerId ? `账号 #${row.workerId}` : '未登录(按 IP)'}</span>
|
||||
<span style={{ color: '#999', fontSize: 12 }}>{row.ip || '-'}</span>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '当前状态',
|
||||
dataIndex: 'banned',
|
||||
width: 140,
|
||||
render: (_value, row) => {
|
||||
if (row.banned) return <Tag color="red">已封禁</Tag>
|
||||
if (row.penalizedUntilIso) return <Tag color="orange">惩罚中</Tag>
|
||||
return <Tag>仅记录</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '惩罚剩余',
|
||||
dataIndex: 'penalizedUntilIso',
|
||||
width: 120,
|
||||
render: (value: string | null) => formatRemaining(value),
|
||||
},
|
||||
{ title: '违规次数', dataIndex: 'violationCount', width: 100 },
|
||||
{ title: '被拒请求', dataIndex: 'rejectedCount', width: 100 },
|
||||
{
|
||||
title: '最近出现',
|
||||
dataIndex: 'lastSeenAtIso',
|
||||
width: 170,
|
||||
render: (value: string) => formatDateTime(value),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 240,
|
||||
render: (_value, row) => (
|
||||
<Space size="small" wrap>
|
||||
{row.workerId ? (
|
||||
<>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={acting || !canManage}
|
||||
onClick={() =>
|
||||
confirmAction(
|
||||
'解封账号',
|
||||
`确认解封账号 #${row.workerId}?将恢复为 active 并释放内存风控状态。`,
|
||||
async () => {
|
||||
await unbanAdminWorkerRisk(row.workerId as number, '后台风控页人工解封')
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
解封
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={acting || !canManage}
|
||||
onClick={() =>
|
||||
confirmAction(
|
||||
'封禁账号',
|
||||
`确认封禁账号 #${row.workerId}?将冻结账号并作废其登录设备。`,
|
||||
async () => {
|
||||
await banAdminWorkerRisk(row.workerId as number, '后台风控页人工封禁')
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
封禁
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
disabled={acting || !canManage}
|
||||
onClick={() =>
|
||||
confirmAction(
|
||||
'释放内存风控状态',
|
||||
`仅清除账号 #${row.workerId} 在内存中的封禁/惩罚标记,不改数据库账号状态。`,
|
||||
async () => {
|
||||
await releaseAdminWorkerRiskTracker(row.workerId as number)
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
仅释放
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<span style={{ color: '#999', fontSize: 12 }}>无账号维度</span>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const eventColumns: ColumnsType<WorkerRiskEvent> = [
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'createdAtIso',
|
||||
width: 170,
|
||||
render: (value: string) => formatDateTime(value),
|
||||
},
|
||||
{
|
||||
title: '事件',
|
||||
dataIndex: 'eventType',
|
||||
width: 130,
|
||||
render: (value: WorkerRiskEventType) => {
|
||||
const meta = EVENT_TYPE_LABEL[value] || { text: value, color: 'default' }
|
||||
return <Tag color={meta.color}>{meta.text}</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '对象',
|
||||
dataIndex: 'workerId',
|
||||
render: (_value, row) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<span>
|
||||
{row.workerId ? `#${row.workerId} ${row.workerUsername || ''}` : '未登录(按 IP)'}
|
||||
</span>
|
||||
<span style={{ color: '#999', fontSize: 12 }}>{row.ip || '-'}</span>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ title: '违规次数', dataIndex: 'violationCount', width: 100 },
|
||||
{
|
||||
title: '触发方式',
|
||||
dataIndex: 'action',
|
||||
width: 100,
|
||||
render: (value: string) =>
|
||||
value === 'manual' ? <Tag>人工</Tag> : <Tag color="blue">自动</Tag>,
|
||||
},
|
||||
{
|
||||
title: '操作人',
|
||||
dataIndex: 'actorUsername',
|
||||
width: 120,
|
||||
render: (value: string) => value || '-',
|
||||
},
|
||||
{ title: '原因', dataIndex: 'reason', ellipsis: true },
|
||||
]
|
||||
|
||||
const summary = overview?.summary24h || []
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="关于两处数据的区别"
|
||||
description="「当前实时风控」来自服务进程内存,服务重启即清零,不是历史累计;「风控事件留痕」是持久化记录,只记录进入二级惩罚、三级自动封号与人工处置这些显著事件,不记录每一次被拒请求(避免被刷时按请求写库拖垮数据库)。"
|
||||
/>
|
||||
|
||||
<Card
|
||||
title="阈值配置"
|
||||
bordered={false}
|
||||
loading={overviewQuery.isLoading}
|
||||
extra={<span style={{ color: '#999', fontSize: 12 }}>保存后立即生效,无需重启</span>}
|
||||
>
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%', maxWidth: 780 }}>
|
||||
<Descriptions size="small" column={2}>
|
||||
<Descriptions.Item label="近 24 小时事件">
|
||||
{summary.reduce((acc, item) => acc + item.total, 0)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="历史事件总数">{overview?.totalEvents ?? 0}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Form form={form} layout="vertical" onFinish={saveConfig}>
|
||||
<Form.Item label="启用风控" name="enabled" valuePropName="checked">
|
||||
<Switch checkedChildren="启用" unCheckedChildren="停用" />
|
||||
</Form.Item>
|
||||
|
||||
<Space size="large" wrap align="start">
|
||||
<Form.Item
|
||||
label="一级统计窗口 (ms)"
|
||||
name="tier1WindowMs"
|
||||
rules={[{ required: true, message: '请填写' }]}
|
||||
>
|
||||
<InputNumber min={100} max={60000} step={100} style={{ width: 180 }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="窗口内允许请求数"
|
||||
name="tier1MaxRequests"
|
||||
tooltip="正常人手点击每秒不会超过 3 次"
|
||||
rules={[{ required: true, message: '请填写' }]}
|
||||
>
|
||||
<InputNumber min={1} max={600} style={{ width: 180 }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="违规统计窗口 (ms)"
|
||||
name="violationWindowMs"
|
||||
rules={[{ required: true, message: '请填写' }]}
|
||||
>
|
||||
<InputNumber min={1000} max={3600000} step={1000} style={{ width: 180 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
<Space size="large" wrap align="start">
|
||||
<Form.Item
|
||||
label="二级惩罚触发违规数"
|
||||
name="tier2TriggerViolations"
|
||||
rules={[{ required: true, message: '请填写' }]}
|
||||
>
|
||||
<InputNumber min={1} max={10000} style={{ width: 180 }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="二级惩罚时长 (ms)"
|
||||
name="tier2PenaltyMs"
|
||||
tooltip="默认 300000ms = 5 分钟"
|
||||
rules={[{ required: true, message: '请填写' }]}
|
||||
>
|
||||
<InputNumber min={1000} max={86400000} step={60000} style={{ width: 180 }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="三级封号触发违规数"
|
||||
name="tier3TriggerViolations"
|
||||
tooltip="在惩罚期内仍继续请求,累计违规达到该值即自动封号"
|
||||
rules={[{ required: true, message: '请填写' }]}
|
||||
>
|
||||
<InputNumber min={1} max={100000} style={{ width: 180 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={saving} disabled={!canManage}>
|
||||
保存配置
|
||||
</Button>
|
||||
<span style={{ marginLeft: 12, color: '#999', fontSize: 12 }}>
|
||||
{canManage
|
||||
? '阈值调低更严格;调高会放宽限制,请谨慎'
|
||||
: '只有 admin 角色可以修改阈值'}
|
||||
</span>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="当前实时风控"
|
||||
bordered={false}
|
||||
extra={
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => overviewQuery.refetch()}
|
||||
loading={overviewQuery.isFetching}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
rowKey="key"
|
||||
size="small"
|
||||
columns={trackerColumns}
|
||||
dataSource={overview?.activeTrackers || []}
|
||||
pagination={false}
|
||||
locale={{ emptyText: '当前没有处于风控状态的对象' }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="风控事件留痕" bordered={false}>
|
||||
<Space size="middle" wrap style={{ marginBottom: 12 }}>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="事件类型"
|
||||
style={{ width: 180 }}
|
||||
value={eventType || undefined}
|
||||
onChange={(value) => {
|
||||
setEventType(value || '')
|
||||
setPage(1)
|
||||
}}
|
||||
options={Object.entries(EVENT_TYPE_LABEL).map(([value, meta]) => ({
|
||||
value,
|
||||
label: meta.text,
|
||||
}))}
|
||||
/>
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="按 IP 精确筛选"
|
||||
style={{ width: 200 }}
|
||||
value={ipFilter}
|
||||
onChange={(event) => setIpFilter(event.target.value)}
|
||||
onPressEnter={() => setPage(1)}
|
||||
/>
|
||||
<Button onClick={() => setPage(1)}>查询</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEventType('')
|
||||
setIpFilter('')
|
||||
setPage(1)
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
columns={eventColumns}
|
||||
dataSource={events?.items || []}
|
||||
loading={eventsQuery.isLoading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: events?.total || 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setPage(nextPage)
|
||||
setPageSize(nextPageSize)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
@@ -15,6 +15,9 @@ import type {
|
||||
WorkerDepositUnfreeze,
|
||||
WorkerFinanceConfig,
|
||||
WorkerHallConfig,
|
||||
WorkerRiskConfig,
|
||||
WorkerRiskOverview,
|
||||
WorkerRiskEventList,
|
||||
WorkerFinanceRequest,
|
||||
WorkerCancelRequest,
|
||||
WorkerOrderFeedback,
|
||||
@@ -850,3 +853,49 @@ export function deductAdminWorkOrderPendingDeposit(
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 大厅风控(接单平台 → 风控)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function fetchAdminWorkerRiskOverview() {
|
||||
return apiGet<WorkerRiskOverview>('/api/v1/admin/worker-platform/risk-control')
|
||||
}
|
||||
|
||||
export function saveAdminWorkerRiskConfig(payload: WorkerRiskConfig) {
|
||||
return apiPost<{ config: WorkerRiskConfig }>(
|
||||
'/api/v1/admin/worker-platform/risk-control',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchAdminWorkerRiskEvents(params: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
eventType?: string
|
||||
ip?: string
|
||||
workerId?: number
|
||||
sinceHours?: number
|
||||
}) {
|
||||
return apiGet<WorkerRiskEventList>('/api/v1/admin/worker-platform/risk-control/events', params)
|
||||
}
|
||||
|
||||
export function banAdminWorkerRisk(workerId: number, reason?: string) {
|
||||
return apiPost<{ workerId: number; status: string; reason: string }>(
|
||||
`/api/v1/admin/worker-platform/risk-control/workers/${workerId}/ban`,
|
||||
{ reason },
|
||||
)
|
||||
}
|
||||
|
||||
export function unbanAdminWorkerRisk(workerId: number, reason?: string) {
|
||||
return apiPost<{ workerId: number; status: string; reason: string }>(
|
||||
`/api/v1/admin/worker-platform/risk-control/workers/${workerId}/unban`,
|
||||
{ reason },
|
||||
)
|
||||
}
|
||||
|
||||
export function releaseAdminWorkerRiskTracker(workerId: number) {
|
||||
return apiPost<{ workerId: number }>(
|
||||
`/api/v1/admin/worker-platform/risk-control/workers/${workerId}/release`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -789,3 +789,69 @@ export type CollectLookupResponse = {
|
||||
orders: CollectLookupOrder[]
|
||||
pendingCount: number
|
||||
}
|
||||
|
||||
/** 大厅风控阈值(后台可调,保存即热生效) */
|
||||
export type WorkerRiskConfig = {
|
||||
enabled: boolean
|
||||
tier1WindowMs: number
|
||||
tier1MaxRequests: number
|
||||
violationWindowMs: number
|
||||
tier2TriggerViolations: number
|
||||
tier2PenaltyMs: number
|
||||
tier3TriggerViolations: number
|
||||
}
|
||||
|
||||
/** 进程内存中的实时风控项(重启即清零,不是历史累计) */
|
||||
export type WorkerRiskActiveTracker = {
|
||||
key: string
|
||||
workerId: number | null
|
||||
ip: string
|
||||
banned: boolean
|
||||
penalizedUntil: number
|
||||
penalizedUntilIso: string | null
|
||||
violationCount: number
|
||||
windowCount: number
|
||||
rejectedCount: number
|
||||
lastSeenAt: number
|
||||
lastSeenAtIso: string
|
||||
}
|
||||
|
||||
export type WorkerRiskEventType =
|
||||
| 'tier2_penalized'
|
||||
| 'tier3_autofrozen'
|
||||
| 'manual_banned'
|
||||
| 'manual_unbanned'
|
||||
| 'manual_ban_failed'
|
||||
|
||||
/** 风控留痕事件(持久化,只记显著事件) */
|
||||
export type WorkerRiskEvent = {
|
||||
id: string
|
||||
eventType: WorkerRiskEventType
|
||||
targetKey: string
|
||||
workerId: number | null
|
||||
workerUsername: string
|
||||
ip: string
|
||||
violationCount: number
|
||||
windowCount: number
|
||||
penaltyUntilIso: string | null
|
||||
action: 'auto' | 'manual'
|
||||
actorUserId: number | null
|
||||
actorUsername: string
|
||||
reason: string
|
||||
createdAtIso: string
|
||||
}
|
||||
|
||||
export type WorkerRiskOverview = {
|
||||
config: WorkerRiskConfig
|
||||
activeTrackers: WorkerRiskActiveTracker[]
|
||||
summary24h: Array<{ eventType: string; total: number }>
|
||||
totalEvents: number
|
||||
serverTimeIso: string
|
||||
}
|
||||
|
||||
export type WorkerRiskEventList = {
|
||||
items: WorkerRiskEvent[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user