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:
yml2213
2026-10-02 17:05:25 +08:00
parent adaf4ed240
commit 78c676d2b9
15 changed files with 1706 additions and 29 deletions
@@ -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
}