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
@@ -9,6 +9,7 @@ import adminWorkerPlatformAcceptanceRoutes from './worker-platform/acceptance.js
import adminWorkerPlatformAfterSalesRoutes from './worker-platform/after-sales.js'
import adminWorkerPlatformLeaderboardRoutes from './worker-platform/leaderboard.js'
import adminWorkerPlatformConsignmentRoutes from './worker-platform/consignment.js'
import adminWorkerPlatformRiskRoutes from './worker-platform/risk.js'
const router = Router()
@@ -21,5 +22,6 @@ router.use(adminWorkerPlatformAfterSalesRoutes)
router.use(adminWorkerPlatformAcceptanceRoutes)
router.use(adminWorkerPlatformLeaderboardRoutes)
router.use(adminWorkerPlatformConsignmentRoutes)
router.use(adminWorkerPlatformRiskRoutes)
export default router
@@ -0,0 +1,145 @@
import { Router } from 'express'
import type { Request } from 'express'
import {
banAdminWorkerRisk,
getAdminWorkerRiskOverview,
listAdminWorkerRiskEvents,
releaseAdminWorkerRiskTracker,
saveAdminWorkerRiskConfig,
unbanAdminWorkerRisk,
} from '../../../services/worker-platform/index.js'
import type { JsonRecord } from '../../../types/json.js'
import { createJsonHandler, requireAdminRoles } from '../session.js'
const router = Router()
function actorOf(req: Request) {
return {
userId: req.adminSession?.userId ?? null,
username: req.adminSession?.username || '',
}
}
/** Express 5 的 params 值类型是 string | string[],统一收敛成 string。 */
function paramOf(value: string | string[] | undefined): string {
if (Array.isArray(value)) return value[0] ?? ''
return value ?? ''
}
router.get(
'/worker-platform/risk-control',
requireAdminRoles(['admin', 'operator', 'support']),
createJsonHandler(() => getAdminWorkerRiskOverview(), {
successMessage: 'ok',
errorMessage: '读取风控概览失败',
scope: '[admin/worker-platform/risk-control]',
}),
)
router.post(
'/worker-platform/risk-control',
requireAdminRoles(['admin']),
createJsonHandler((req) => saveAdminWorkerRiskConfig((req.body || {}) as JsonRecord), {
successMessage: '风控参数已保存',
errorMessage: '保存风控参数失败',
scope: '[admin/worker-platform/risk-control]',
audit: (_req, data) => {
const result = data as JsonRecord
const config = (result.config || {}) as JsonRecord
return {
action: 'worker_risk_config_updated',
targetType: 'worker_risk_config',
targetId: 'worker_risk_control',
data: {
enabled: config.enabled !== false,
tier1MaxRequests: config.tier1MaxRequests,
tier2TriggerViolations: config.tier2TriggerViolations,
tier3TriggerViolations: config.tier3TriggerViolations,
},
}
},
}),
)
router.get(
'/worker-platform/risk-control/events',
requireAdminRoles(['admin', 'operator', 'support']),
createJsonHandler((req) => listAdminWorkerRiskEvents((req.query || {}) as JsonRecord), {
successMessage: 'ok',
errorMessage: '读取风控事件失败',
scope: '[admin/worker-platform/risk-control/events]',
}),
)
router.post(
'/worker-platform/risk-control/workers/:workerId/ban',
requireAdminRoles(['admin']),
createJsonHandler(
(req) =>
banAdminWorkerRisk(
paramOf(req.params.workerId),
actorOf(req),
(req.body || {}) as JsonRecord,
),
{
successMessage: '账号已封禁',
errorMessage: '封禁账号失败',
scope: '[admin/worker-platform/risk-control/ban]',
audit: (req, data) => ({
action: 'worker_risk_manual_ban',
targetType: 'worker',
targetId: String(paramOf(req.params.workerId)),
data: { result: data } as Record<string, unknown>,
}),
},
),
)
router.post(
'/worker-platform/risk-control/workers/:workerId/unban',
requireAdminRoles(['admin']),
createJsonHandler(
(req) =>
unbanAdminWorkerRisk(
paramOf(req.params.workerId),
actorOf(req),
(req.body || {}) as JsonRecord,
),
{
successMessage: '账号已解封',
errorMessage: '解封账号失败',
scope: '[admin/worker-platform/risk-control/unban]',
audit: (req, data) => ({
action: 'worker_risk_manual_unban',
targetType: 'worker',
targetId: String(paramOf(req.params.workerId)),
data: { result: data } as Record<string, unknown>,
}),
},
),
)
/**
* 只清内存风控状态、不改数据库账号状态。
* 用于「账号本身正常,但内存里还留着旧的封禁/惩罚标记」这类不一致场景。
*/
router.post(
'/worker-platform/risk-control/workers/:workerId/release',
requireAdminRoles(['admin']),
createJsonHandler(
(req) => releaseAdminWorkerRiskTracker(paramOf(req.params.workerId), actorOf(req)),
{
successMessage: '已释放内存风控状态',
errorMessage: '释放风控状态失败',
scope: '[admin/worker-platform/risk-control/release]',
audit: (req) => ({
action: 'worker_risk_tracker_released',
targetType: 'worker',
targetId: String(paramOf(req.params.workerId)),
}),
},
),
)
export default router