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,215 @@
import type { JsonObject } from '../../types/json.js'
import { createHttpError } from '../../utils/http.js'
import { nowIso } from '../../utils/time.js'
import {
listActiveWorkerRiskTrackers,
markWorkerRiskBanned,
releaseWorkerRiskTracker,
} from '../../middleware/worker-risk-control.js'
import {
countWorkerRiskEvents,
insertWorkerRiskEvent,
listWorkerRiskEvents,
summarizeWorkerRiskEvents,
type WorkerRiskEventRow,
} from '../../repositories/worker-platform/worker-risk-repo.js'
import {
getWorkerUserById,
updateWorkerUser,
} from '../../repositories/worker-platform/worker-repo.js'
import { revokeAllWorkerSessions } from '../../repositories/worker-platform/worker-session-repo.js'
import { normalizePage, normalizePageSize } from '../admin/admin-query-utils.js'
import { getWorkerRiskConfig, saveWorkerRiskConfig } from './worker-risk-config-service.js'
/**
* 后台「接单平台 → 风控」的读写服务。
*
* 数据来源分两层,界面上必须让运营能分清楚:
* - **实时层**:风控中间件的进程内存 tracker(封禁/惩罚中的 key)。重启即清零,不是历史累计。
* - **留痕层**:worker_risk_events(只记显著事件)。持久、可追溯。
*/
export type WorkerRiskActor = { userId?: number | null; username?: string }
const OVERVIEW_SUMMARY_WINDOW_MS = 24 * 60 * 60 * 1000
export async function getAdminWorkerRiskOverview() {
const config = getWorkerRiskConfig()
const sinceIso = new Date(Date.now() - OVERVIEW_SUMMARY_WINDOW_MS).toISOString()
const [summary, totalEvents] = await Promise.all([
summarizeWorkerRiskEvents(sinceIso),
countWorkerRiskEvents({}),
])
return {
config,
/** 进程内存中的实时状态;重启会清空,界面需如实标注 */
activeTrackers: listActiveWorkerRiskTrackers().map((item) => ({
...item,
penalizedUntilIso: item.penalizedUntil ? new Date(item.penalizedUntil).toISOString() : null,
lastSeenAtIso: new Date(item.lastSeenAt).toISOString(),
})),
summary24h: summary,
totalEvents,
serverTimeIso: new Date().toISOString(),
}
}
export async function saveAdminWorkerRiskConfig(payload: JsonObject = {}) {
const config = await saveWorkerRiskConfig(payload)
return { config }
}
export async function listAdminWorkerRiskEvents(query: JsonObject = {}) {
const page = normalizePage(query.page)
const pageSize = normalizePageSize(query.pageSize)
const sinceHours = Number(query.sinceHours ?? query.since_hours)
const filters = {
eventType: String(query.eventType ?? query.event_type ?? '').trim(),
ip: String(query.ip || '').trim(),
workerId: Number(query.workerId ?? query.worker_id) || null,
targetKey: String(query.targetKey ?? query.target_key ?? '').trim(),
sinceIso:
Number.isFinite(sinceHours) && sinceHours > 0
? new Date(Date.now() - sinceHours * 60 * 60 * 1000).toISOString()
: undefined,
}
const [rows, total] = await Promise.all([
listWorkerRiskEvents({ ...filters, limit: pageSize, offset: (page - 1) * pageSize }),
countWorkerRiskEvents(filters),
])
return {
items: rows.map(mapWorkerRiskEvent),
total,
page,
pageSize,
}
}
/** 人工封禁打手账号(与自动封禁等价,只是留痕标记为人工操作)。 */
export async function banAdminWorkerRisk(
workerId: number | string,
actor: WorkerRiskActor = {},
payload: JsonObject = {},
) {
const id = normalizeWorkerId(workerId)
const worker = await getWorkerUserById(id)
if (!worker) {
throw createHttpError('接单账号不存在', { statusCode: 404, errorCode: 'worker_not_found' })
}
const now = nowIso()
const reason = String(payload.reason || '').trim() || '后台人工封禁:疑似外挂刷单或异常高频请求'
await updateWorkerUser(id, {
status: 'frozen',
frozen_reason: reason,
frozen_at: now,
session_version: Number(worker.session_version || 1) + 1,
updated_at: now,
})
await revokeAllWorkerSessions(id, now)
// 内存标记必须同步置位,否则会出现「数据库已 frozen、内存却仍放行」的反向不一致
markWorkerRiskBanned(id)
await insertWorkerRiskEvent({
eventType: 'manual_banned',
targetKey: `worker:${id}`,
workerId: id,
ip: String(payload.ip || '').trim(),
reason,
action: 'manual',
actorUserId: actor.userId ?? null,
actorUsername: actor.username || '',
})
return { workerId: id, status: 'frozen', reason }
}
/** 人工解封:恢复账号可用,并释放内存里的风控状态(否则仍会被 403 挡住)。 */
export async function unbanAdminWorkerRisk(
workerId: number | string,
actor: WorkerRiskActor = {},
payload: JsonObject = {},
) {
const id = normalizeWorkerId(workerId)
const worker = await getWorkerUserById(id)
if (!worker) {
throw createHttpError('接单账号不存在', { statusCode: 404, errorCode: 'worker_not_found' })
}
const now = nowIso()
const reason = String(payload.reason || '').trim() || '后台人工解封'
await updateWorkerUser(id, {
status: 'active',
frozen_reason: '',
frozen_at: null,
session_version: Number(worker.session_version || 1) + 1,
updated_at: now,
})
await revokeAllWorkerSessions(id, now)
releaseWorkerRiskTracker({ workerId: id })
await insertWorkerRiskEvent({
eventType: 'manual_unbanned',
targetKey: `worker:${id}`,
workerId: id,
ip: String(payload.ip || '').trim(),
reason,
action: 'manual',
actorUserId: actor.userId ?? null,
actorUsername: actor.username || '',
})
return { workerId: id, status: 'active', reason }
}
/** 仅释放内存风控状态,不改数据库状态。用于处理「内存误封但账号本身正常」的情况。 */
export async function releaseAdminWorkerRiskTracker(
workerId: number | string,
actor: WorkerRiskActor = {},
) {
const id = normalizeWorkerId(workerId)
releaseWorkerRiskTracker({ workerId: id })
await insertWorkerRiskEvent({
eventType: 'manual_unbanned',
targetKey: `worker:${id}`,
workerId: id,
reason: '后台仅释放内存风控状态(账号状态未变更)',
action: 'manual',
actorUserId: actor.userId ?? null,
actorUsername: actor.username || '',
})
return { workerId: id }
}
function normalizeWorkerId(value: number | string): number {
const id = Number(value)
if (!Number.isInteger(id) || id <= 0) {
throw createHttpError('接单账号 ID 无效', { statusCode: 400, errorCode: 'worker_id_invalid' })
}
return id
}
function mapWorkerRiskEvent(row: WorkerRiskEventRow) {
return {
id: String(row.id),
eventType: row.event_type,
targetKey: row.target_key,
workerId: row.worker_id === null ? null : Number(row.worker_id),
workerUsername: row.worker_username,
ip: row.ip,
violationCount: Number(row.violation_count) || 0,
windowCount: Number(row.window_count) || 0,
penaltyUntilIso: row.penalty_until ? new Date(row.penalty_until).toISOString() : null,
action: row.action,
actorUserId: row.actor_user_id === null ? null : Number(row.actor_user_id),
actorUsername: row.actor_username,
reason: row.reason,
createdAtIso: new Date(row.created_at).toISOString(),
}
}
@@ -12,6 +12,7 @@ import {
import type { JsonObject } from '../../types/json.js'
import { createHttpError } from '../../utils/http.js'
import { nowIso } from '../../utils/time.js'
import { releaseWorkerRiskTracker } from '../../middleware/worker-risk-control.js'
import { normalizePage, normalizePageSize } from '../admin/admin-query-utils.js'
import {
hashWorkerPassword,
@@ -108,6 +109,14 @@ export async function reviewAdminWorkerUser(workerId: number | string, payload:
updated_at: now,
})
await revokeAllWorkerSessions(worker.id, now)
// 必须同步释放内存里的风控状态。
// review 只允许把状态改成 active/rejected/disabled/pending_review(没有 frozen),
// 所以走到这里就意味着「不再由风控冻结」。而风控中间件的 banned 检查发生在鉴权之前,
// 只改数据库的话,账号在库里已经恢复、请求却仍然被 403 挡在门外,
// 只能等容器重启 —— 这是生产上实际发生过的故障。
releaseWorkerRiskTracker({ workerId: worker.id })
return { worker: mapWorkerUser(updated || worker) }
}
@@ -28,6 +28,7 @@ export * from './admin-worker-leaderboard-service.js'
export * from './admin-kuaishou-match-service.js'
export * from './admin-worker-config-service.js'
export * from './admin-worker-user-service.js'
export * from './admin-worker-risk-service.js'
export * from './admin-worker-detail-service.js'
export * from './admin-work-order-query-service.js'
export * from './admin-worker-feedback-service.js'
@@ -0,0 +1,128 @@
import { APP_CONFIG_KEYS } from '../../config/app-config-keys.js'
import type { JsonObject } from '../../types/json.js'
import { readAppConfigEntry, saveAppConfigEntry } from '../config/app-config-store.js'
/**
* 大厅风控阈值配置(可在后台「接单平台 → 风控」页调整)。
*
* 这些值原先硬编码在 middleware/worker-risk-control.ts 里,改一次要发版;
* 现在落到 app_config_entries,保存后热生效,无需重启。
*
* 为什么需要本层的 memo:中间件每个大厅请求都要读一次配置,而 readAppConfigEntry
* 内部会对整个 JSON 做一次深拷贝 + normalize。被刷时实测 45~65 QPS,
* 每请求一次深拷贝是纯浪费,因此这里加一个极短 TTL 的进程内缓存。
* 后台保存时会显式失效,所以正常情况下改完立即生效(TTL 只是兜底)。
*/
export type WorkerRiskConfig = {
/** 总开关。关闭后大厅不再限流(仅用于排障,正常应保持开启) */
enabled: boolean
/** 一级限流:统计窗口(毫秒) */
tier1WindowMs: number
/** 一级限流:窗口内允许的请求数,超过即 429 */
tier1MaxRequests: number
/** 违规计数窗口(毫秒),在此窗口内累计的 429 次数用于升级判定 */
violationWindowMs: number
/** 二级惩罚:违规次数达到该值即进入静默惩罚期 */
tier2TriggerViolations: number
/** 二级惩罚时长(毫秒) */
tier2PenaltyMs: number
/** 三级封号:惩罚期内仍继续请求,累计违规达到该值即自动封号 */
tier3TriggerViolations: number
}
const CONFIG_MEMO_TTL_MS = 5_000
let configMemo: { value: WorkerRiskConfig; expiresAt: number } | null = null
export function createDefaultWorkerRiskConfig(): WorkerRiskConfig {
return {
enabled: true,
tier1WindowMs: 1_000,
tier1MaxRequests: 3,
violationWindowMs: 60_000,
tier2TriggerViolations: 10,
tier2PenaltyMs: 300_000,
tier3TriggerViolations: 30,
}
}
export function getWorkerRiskConfig(): WorkerRiskConfig {
const now = Date.now()
if (configMemo && configMemo.expiresAt > now) {
return configMemo.value
}
const value = readAppConfigEntry({
configKey: APP_CONFIG_KEYS.workerRiskControl,
fallback: createDefaultWorkerRiskConfig,
normalize: normalizeWorkerRiskConfig,
})
configMemo = { value, expiresAt: now + CONFIG_MEMO_TTL_MS }
return value
}
export async function saveWorkerRiskConfig(rawValue: unknown): Promise<WorkerRiskConfig> {
const saved = await saveAppConfigEntry({
configKey: APP_CONFIG_KEYS.workerRiskControl,
value: rawValue,
normalize: normalizeWorkerRiskConfig,
})
// 同进程内立即生效,不等 TTL 过期
configMemo = { value: saved, expiresAt: Date.now() + CONFIG_MEMO_TTL_MS }
return saved
}
/** 仅测试使用:清掉进程内缓存,让下次读取回到 app-config-store。 */
export function clearWorkerRiskConfigMemoForTest(): void {
configMemo = null
}
export function normalizeWorkerRiskConfig(rawValue: unknown): WorkerRiskConfig {
const fallback = createDefaultWorkerRiskConfig()
const source = isPlainObject(rawValue) ? rawValue : {}
const enabled =
source.enabled === undefined
? fallback.enabled
: source.enabled !== false && String(source.enabled) !== 'false'
return {
enabled,
tier1WindowMs: pickInt(source.tier1WindowMs, 100, 60_000, fallback.tier1WindowMs),
// 上限 600/窗口:再高就失去限流意义(1 秒 600 次已属异常),避免误配把风控关成摆设
tier1MaxRequests: pickInt(source.tier1MaxRequests, 1, 600, fallback.tier1MaxRequests),
violationWindowMs: pickInt(
source.violationWindowMs,
1_000,
3_600_000,
fallback.violationWindowMs,
),
tier2TriggerViolations: pickInt(
source.tier2TriggerViolations,
1,
10_000,
fallback.tier2TriggerViolations,
),
tier2PenaltyMs: pickInt(source.tier2PenaltyMs, 1_000, 86_400_000, fallback.tier2PenaltyMs),
tier3TriggerViolations: pickInt(
source.tier3TriggerViolations,
1,
100_000,
fallback.tier3TriggerViolations,
),
}
}
function pickInt(value: unknown, min: number, max: number, fallback: number): number {
const parsed = Number(value)
if (!Number.isFinite(parsed)) return fallback
const truncated = Math.trunc(parsed)
if (truncated < min || truncated > max) return fallback
return truncated
}
function isPlainObject(value: unknown): value is JsonObject {
return Object.prototype.toString.call(value) === '[object Object]'
}