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
@@ -0,0 +1,58 @@
-- 089_worker_risk_events.sql —— 风控事件留痕(只记显著事件,不按请求写库)。
--
-- 背景:大厅阶梯式风控(middleware/worker-risk-control.ts)的状态原本只存在进程内存里,
-- 容器一重建就全部清零——生产上 10/02 一天重建过 3 次,被封的脚本可以重新从头开始;
-- 后台也没有任何入口可看,只能翻日志文件。
--
-- 关键取舍:**只记录显著事件**(进入二级惩罚、三级自动封禁、人工处置),
-- 不记录每一次被拒请求。原因是被刷时实测可达 45~65 QPS,一请求一写会把数据库重新拖垮
-- (而这正是最初要解决的问题)。被拒请求的实时计数仍来自进程内存的 tracker,
-- 由管理接口直接暴露,不落库。
--
-- 量级预期:每天几条到几百条,可长期保留。
CREATE TABLE IF NOT EXISTS worker_risk_events (
id BIGSERIAL PRIMARY KEY,
event_type TEXT NOT NULL
CHECK (event_type IN (
'tier2_penalized',
'tier3_autofrozen',
'manual_banned',
'manual_unbanned',
'manual_ban_failed'
)),
-- 与中间件的跟踪维度一致:worker:<id>(能解出 token)或 ip:<addr>(未登录/无 token)
target_key TEXT NOT NULL DEFAULT '',
worker_id BIGINT REFERENCES worker_users(id) ON DELETE SET NULL,
-- 冗余留存账号名:账号被删或被改名后仍要能追溯
worker_username TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
-- 触发时的现场快照,便于事后判断阈值是否合理
violation_count INTEGER NOT NULL DEFAULT 0,
window_count INTEGER NOT NULL DEFAULT 0,
penalty_until TIMESTAMPTZ,
action TEXT NOT NULL DEFAULT 'auto' CHECK (action IN ('auto', 'manual')),
actor_user_id BIGINT,
actor_username TEXT NOT NULL DEFAULT '',
reason TEXT NOT NULL DEFAULT '',
detail_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 事件列表按时间倒序分页
CREATE INDEX IF NOT EXISTS idx_worker_risk_events_created_at
ON worker_risk_events (created_at DESC, id DESC);
-- 按 IP 追溯:刷子最常轮换账号,IP 是更稳定的维度
CREATE INDEX IF NOT EXISTS idx_worker_risk_events_ip
ON worker_risk_events (ip, created_at DESC)
WHERE ip <> '';
-- 按账号追溯
CREATE INDEX IF NOT EXISTS idx_worker_risk_events_worker
ON worker_risk_events (worker_id, created_at DESC)
WHERE worker_id IS NOT NULL;
-- 按事件类型筛选
CREATE INDEX IF NOT EXISTS idx_worker_risk_events_type
ON worker_risk_events (event_type, created_at DESC);