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:
@@ -2,16 +2,24 @@ import type { NextFunction, Request, Response } from 'express'
|
||||
import { buildErrorPayload, createHttpError } from '../utils/http.js'
|
||||
import { logError, logWarn } from '../utils/logger.js'
|
||||
import { nowIso } from '../utils/time.js'
|
||||
import { updateWorkerUser } from '../repositories/worker-platform/worker-repo.js'
|
||||
import { getWorkerUserById, updateWorkerUser } from '../repositories/worker-platform/worker-repo.js'
|
||||
import { revokeAllWorkerSessions } from '../repositories/worker-platform/worker-session-repo.js'
|
||||
import { insertWorkerRiskEvent } from '../repositories/worker-platform/worker-risk-repo.js'
|
||||
import { getWorkerRiskConfig } from '../services/worker-platform/worker-risk-config-service.js'
|
||||
|
||||
/**
|
||||
* 打手端阶梯式风控配置与状态机
|
||||
*
|
||||
* 针对外挂/脚本毫秒级死循环狂刷接单大厅、挤占单线程 Node 事件循环与数据库连接池的场景:
|
||||
* Tier 1 (平滑限流): 1 秒内超过 3 次请求 -> 429 快速拒绝,避免穿透后续 DB
|
||||
* Tier 2 (风控强化): 1 分钟内累计触发 10 次 429 -> 进入 5 分钟静默惩罚期,秒拒且 Retry-After: 300
|
||||
* Tier 3 (恶意封号): 在惩罚期内仍不停止、累计违规达 30 次 -> 判定恶意不改,自动更新数据库 status='frozen' 并作废 session
|
||||
* Tier 1 (平滑限流): 窗口内超过 N 次请求 -> 429 快速拒绝,避免穿透后续 DB
|
||||
* Tier 2 (风控强化): 违规窗口内累计触发 M 次 -> 进入静默惩罚期,秒拒且带 Retry-After
|
||||
* Tier 3 (恶意封号): 在惩罚期内仍不停止、累计违规达 K 次 -> 自动封禁账号并作废 session
|
||||
*
|
||||
* 阈值全部来自 worker-risk-config-service(后台可调,保存即热生效),不再硬编码。
|
||||
*
|
||||
* 状态仍然是**进程内存**(重启清零),这是刻意的:按请求更新内存计数是纳秒级操作,
|
||||
* 落库会瞬间打爆数据库。内存状态通过 listActiveWorkerRiskTrackers() 暴露给后台实时展示;
|
||||
* 只有「显著事件」(进入二级惩罚、三级封号、人工处置)才异步写 worker_risk_events 留痕。
|
||||
*/
|
||||
|
||||
interface WorkerRiskTracker {
|
||||
@@ -25,20 +33,40 @@ interface WorkerRiskTracker {
|
||||
penalizedUntil: number
|
||||
// 是否已被 Tier 3 自动封禁
|
||||
banned: boolean
|
||||
// 上次与数据库对账「是否仍处于 frozen」的时间戳,0 表示尚未对账
|
||||
bannedVerifiedAt: number
|
||||
// 便于后台展示的追溯信息(最近一次见到的身份/IP 与时间)
|
||||
lastWorkerId: number | null
|
||||
lastIp: string
|
||||
lastSeenAt: number
|
||||
// 累计被拒请求数(仅用于展示,不落库)
|
||||
rejectedCount: number
|
||||
}
|
||||
|
||||
const riskTrackers = new Map<string, WorkerRiskTracker>()
|
||||
let lastCleanupAt = 0
|
||||
|
||||
// 常量配置
|
||||
const TIER1_WINDOW_MS = 1_000
|
||||
const TIER1_MAX_REQUESTS = 3 // 正常人手点击每秒不会超过 3 次
|
||||
/**
|
||||
* 内存封禁标记与数据库的对账间隔。
|
||||
*
|
||||
* 为什么必须有:`banned` 只是数据库 `worker_users.status === 'frozen'` 的缓存。
|
||||
* 若把它当成永久事实,后台把账号改回 active 后,中间件仍会在鉴权之前直接 403
|
||||
* ——数据库里已经是正常账号,人却永远接不了单,只能等容器重启。
|
||||
* 因此这里按 TTL 回查数据库;数据库是唯一事实来源。
|
||||
*
|
||||
* 代价可控:只有被判定为 banned 的 key 才会走到这里,且每个 key 最多每 30 秒查一次。
|
||||
* 即便该账号正以 65 QPS 持续刷接口,对数据库也只是每 30 秒一次查询。
|
||||
*/
|
||||
const BAN_RECHECK_INTERVAL_MS = 30_000
|
||||
|
||||
const VIOLATION_WINDOW_MS = 60_000
|
||||
const TIER2_TRIGGER_VIOLATIONS = 10 // 1 分钟触发 10 次 429 则进入 Tier 2
|
||||
const TIER2_PENALTY_MS = 300_000 // Tier 2 惩罚 5 分钟 (300 秒)
|
||||
/**
|
||||
* 查账号状态的函数签名。抽成类型 + 可注入,是为了让「数据库已解封、内存仍封禁」
|
||||
* 这条修复路径能被测试覆盖(测试环境没有 Postgres)。生产不传即用真实仓储。
|
||||
*/
|
||||
export type WorkerRiskWorkerLookup = (workerId: number) => Promise<{ status?: string } | null>
|
||||
|
||||
const TIER3_TRIGGER_VIOLATIONS = 30 // 累计触发 30 次且多次警告无效 -> 直接封号
|
||||
/** 后台展示用的 tracker 快照上限,避免极端情况下返回超大数组 */
|
||||
const TRACKER_SNAPSHOT_LIMIT = 200
|
||||
|
||||
/** 从 Authorization 请求头极速无 DB 提取 workerId (JWT payload base64 解码,微秒级) */
|
||||
export function extractFastWorkerId(req: Request): number | null {
|
||||
@@ -83,46 +111,123 @@ function getTargetKeys(req: Request): { primaryKey: string; workerId: number | n
|
||||
function getOrCreateTracker(key: string, now: number): WorkerRiskTracker {
|
||||
let tracker = riskTrackers.get(key)
|
||||
if (!tracker) {
|
||||
const config = getWorkerRiskConfig()
|
||||
tracker = {
|
||||
windowCount: 0,
|
||||
windowResetAt: now + TIER1_WINDOW_MS,
|
||||
windowResetAt: now + config.tier1WindowMs,
|
||||
violationCount: 0,
|
||||
violationResetAt: now + VIOLATION_WINDOW_MS,
|
||||
violationResetAt: now + config.violationWindowMs,
|
||||
penalizedUntil: 0,
|
||||
banned: false,
|
||||
bannedVerifiedAt: 0,
|
||||
lastWorkerId: null,
|
||||
lastIp: '',
|
||||
lastSeenAt: now,
|
||||
rejectedCount: 0,
|
||||
}
|
||||
riskTrackers.set(key, tracker)
|
||||
}
|
||||
return tracker
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断仍被封禁的 key 是否真的还该被封。
|
||||
*
|
||||
* 返回 true 表示维持封禁;返回 false 表示数据库侧已解封(或账号已不存在),
|
||||
* 调用方应清掉内存标记并放行。
|
||||
*
|
||||
* 查询失败时**保守维持封禁**——风控宁可误挡,也不能因为一次数据库抖动把刷子放进来。
|
||||
*/
|
||||
async function isWorkerStillFrozen(
|
||||
tracker: WorkerRiskTracker,
|
||||
now: number,
|
||||
lookupWorker: WorkerRiskWorkerLookup,
|
||||
): Promise<boolean> {
|
||||
if (tracker.bannedVerifiedAt && now - tracker.bannedVerifiedAt < BAN_RECHECK_INTERVAL_MS) {
|
||||
return true
|
||||
}
|
||||
tracker.bannedVerifiedAt = now
|
||||
|
||||
// banned 只会由 Tier 3 设置,而 Tier 3 要求能解出 workerId,因此这里必然有 workerId;
|
||||
// 兜底返回 true 以免出现无法校验却放行的情况。
|
||||
const workerId = tracker.lastWorkerId
|
||||
if (!workerId) return true
|
||||
|
||||
try {
|
||||
const worker = await lookupWorker(workerId)
|
||||
if (!worker) {
|
||||
// 账号已被删除,封禁自然失效
|
||||
tracker.banned = false
|
||||
return false
|
||||
}
|
||||
if (worker.status === 'frozen') return true
|
||||
|
||||
// 解封后必须把整轮窗口状态一起清零,只清 banned 是不够的:
|
||||
// 上一轮刷出来的 windowCount / violationCount 仍在窗口内,账号刚恢复就会立刻
|
||||
// 被 Tier 1 限流、并在 60 秒内再次凑满 Tier 2 触发条件而二次封禁。
|
||||
tracker.banned = false
|
||||
tracker.penalizedUntil = 0
|
||||
tracker.violationCount = 0
|
||||
tracker.violationResetAt = 0
|
||||
tracker.windowCount = 0
|
||||
tracker.windowResetAt = 0
|
||||
tracker.rejectedCount = 0
|
||||
return false
|
||||
} catch (err) {
|
||||
logError('[worker/risk-control]', '封禁状态对账失败,暂时维持封禁', { workerId, err })
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/** 接单大厅与高频打手接口的风控拦截中间件 */
|
||||
export function createWorkerHallRiskMiddleware() {
|
||||
export function createWorkerHallRiskMiddleware(
|
||||
deps: { lookupWorker?: WorkerRiskWorkerLookup } = {},
|
||||
) {
|
||||
const lookupWorker: WorkerRiskWorkerLookup = deps.lookupWorker ?? getWorkerUserById
|
||||
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
const config = getWorkerRiskConfig()
|
||||
if (!config.enabled) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
cleanupExpiredTrackers(now)
|
||||
|
||||
const { primaryKey, workerId, ip } = getTargetKeys(req)
|
||||
const tracker = getOrCreateTracker(primaryKey, now)
|
||||
if (workerId) tracker.lastWorkerId = workerId
|
||||
if (ip) tracker.lastIp = ip
|
||||
tracker.lastSeenAt = now
|
||||
|
||||
// 1. 已被封号直接拦截
|
||||
// 1. 被封号则拦截。但必须先与数据库对账:`banned` 只是数据库 status='frozen' 的缓存,
|
||||
// 后台解封(或直接改库)后内存标记若不失效,账号在数据库里已是 active、
|
||||
// 人却仍然接不了单——生产上已实际发生,只能等容器重启才恢复。
|
||||
if (tracker.banned) {
|
||||
const error = createHttpError('接单账号已被系统安全风控封禁', {
|
||||
statusCode: 403,
|
||||
errorCode: 'worker_account_frozen',
|
||||
if (await isWorkerStillFrozen(tracker, now, lookupWorker)) {
|
||||
tracker.rejectedCount += 1
|
||||
const error = createHttpError('接单账号已被系统安全风控封禁', {
|
||||
statusCode: 403,
|
||||
errorCode: 'worker_account_frozen',
|
||||
})
|
||||
res.status(403).json(buildErrorPayload(error, '接单账号已被封禁'))
|
||||
return
|
||||
}
|
||||
logWarn('[worker/risk-control]', '封禁标记与数据库不一致,已自动解除并放行', {
|
||||
primaryKey,
|
||||
workerId,
|
||||
})
|
||||
res.status(403).json(buildErrorPayload(error, '接单账号已被封禁'))
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 检查是否处于 Tier 2 惩罚期
|
||||
if (tracker.penalizedUntil > now) {
|
||||
tracker.violationCount += 1
|
||||
tracker.rejectedCount += 1
|
||||
const retryAfterSeconds = Math.max(1, Math.ceil((tracker.penalizedUntil - now) / 1000))
|
||||
res.setHeader('Retry-After', String(retryAfterSeconds))
|
||||
|
||||
// 检查是否升级到 Tier 3 自动封号
|
||||
if (tracker.violationCount >= TIER3_TRIGGER_VIOLATIONS && workerId && !tracker.banned) {
|
||||
if (tracker.violationCount >= config.tier3TriggerViolations && workerId && !tracker.banned) {
|
||||
tracker.banned = true
|
||||
await executeAutoFreezeWorker(workerId, ip, tracker.violationCount)
|
||||
const error = createHttpError('恶意高频刷接口多次不改,接单账号已被系统自动永久封禁', {
|
||||
@@ -152,37 +257,53 @@ export function createWorkerHallRiskMiddleware() {
|
||||
// 3. 正常滑动窗口限流检查 (Tier 1)
|
||||
if (now > tracker.windowResetAt) {
|
||||
tracker.windowCount = 0
|
||||
tracker.windowResetAt = now + TIER1_WINDOW_MS
|
||||
tracker.windowResetAt = now + config.tier1WindowMs
|
||||
}
|
||||
tracker.windowCount += 1
|
||||
|
||||
if (tracker.windowCount <= TIER1_MAX_REQUESTS) {
|
||||
if (tracker.windowCount <= config.tier1MaxRequests) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 触发 Tier 1 超限:记录违规
|
||||
tracker.rejectedCount += 1
|
||||
if (now > tracker.violationResetAt) {
|
||||
tracker.violationCount = 0
|
||||
tracker.violationResetAt = now + VIOLATION_WINDOW_MS
|
||||
tracker.violationResetAt = now + config.violationWindowMs
|
||||
}
|
||||
tracker.violationCount += 1
|
||||
|
||||
// 5. 检查是否升级为 Tier 2 惩罚
|
||||
if (tracker.violationCount >= TIER2_TRIGGER_VIOLATIONS) {
|
||||
tracker.penalizedUntil = now + TIER2_PENALTY_MS
|
||||
const retryAfter = Math.ceil(TIER2_PENALTY_MS / 1000)
|
||||
if (tracker.violationCount >= config.tier2TriggerViolations) {
|
||||
tracker.penalizedUntil = now + config.tier2PenaltyMs
|
||||
const retryAfter = Math.ceil(config.tier2PenaltyMs / 1000)
|
||||
res.setHeader('Retry-After', String(retryAfter))
|
||||
|
||||
logWarn('[worker/risk-control]', '打手频繁触发限流,进入二级风控惩罚锁定 (5分钟)', {
|
||||
const penaltyMinutes = Math.round(config.tier2PenaltyMs / 60_000)
|
||||
logWarn('[worker/risk-control]', '打手频繁触发限流,进入二级风控惩罚锁定', {
|
||||
primaryKey,
|
||||
workerId,
|
||||
ip,
|
||||
violationCount: tracker.violationCount,
|
||||
penaltyMinutes,
|
||||
})
|
||||
|
||||
// 显著事件留痕(低频;被拒请求本身不落库)
|
||||
recordWorkerRiskEvent({
|
||||
eventType: 'tier2_penalized',
|
||||
targetKey: primaryKey,
|
||||
workerId,
|
||||
ip,
|
||||
violationCount: tracker.violationCount,
|
||||
windowCount: tracker.windowCount,
|
||||
penaltyUntil: new Date(tracker.penalizedUntil).toISOString(),
|
||||
reason: `违规 ${tracker.violationCount} 次,进入 ${penaltyMinutes} 分钟静默惩罚期`,
|
||||
detail: { penaltyMinutes, config },
|
||||
})
|
||||
|
||||
const error = createHttpError(
|
||||
'检测到连续异常高频请求,已触发 5 分钟安全风控限制,请停止刷单脚本',
|
||||
`检测到连续异常高频请求,已触发 ${penaltyMinutes} 分钟安全风控限制,请停止刷单脚本`,
|
||||
{
|
||||
statusCode: 429,
|
||||
errorCode: 'risk_penalized',
|
||||
@@ -229,11 +350,105 @@ async function executeAutoFreezeWorker(workerId: number, ip: string, violationCo
|
||||
violationCount,
|
||||
reason,
|
||||
})
|
||||
|
||||
recordWorkerRiskEvent({
|
||||
eventType: 'tier3_autofrozen',
|
||||
targetKey: `worker:${workerId}`,
|
||||
workerId,
|
||||
ip,
|
||||
violationCount,
|
||||
reason,
|
||||
})
|
||||
} catch (err) {
|
||||
logError('[worker/risk-control]', '自动封禁打手账号执行异常', { workerId, ip, err })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 风控事件留痕。**fire-and-forget**:留痕失败绝不能影响请求结果,
|
||||
* 更不允许因为一次数据库抖动把大厅请求变成 500。
|
||||
*/
|
||||
function recordWorkerRiskEvent(input: Parameters<typeof insertWorkerRiskEvent>[0]): void {
|
||||
void insertWorkerRiskEvent(input).catch((err) => {
|
||||
logError('[worker/risk-control]', '风控事件留痕失败', { err, eventType: input.eventType })
|
||||
})
|
||||
}
|
||||
|
||||
export type WorkerRiskTrackerSnapshot = {
|
||||
key: string
|
||||
workerId: number | null
|
||||
ip: string
|
||||
banned: boolean
|
||||
penalizedUntil: number
|
||||
violationCount: number
|
||||
windowCount: number
|
||||
rejectedCount: number
|
||||
lastSeenAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台展示用:当前仍在生效(封禁 / 惩罚中 / 本轮有违规)的内存风控项。
|
||||
* 这些数据不落库,进程重启即清零——后台需如实标注,避免被误读为历史累计。
|
||||
*/
|
||||
export function listActiveWorkerRiskTrackers(): WorkerRiskTrackerSnapshot[] {
|
||||
const now = Date.now()
|
||||
const items: WorkerRiskTrackerSnapshot[] = []
|
||||
|
||||
for (const [key, tracker] of riskTrackers.entries()) {
|
||||
if (!tracker.banned && tracker.penalizedUntil <= now && tracker.violationCount <= 0) {
|
||||
continue
|
||||
}
|
||||
items.push({
|
||||
key,
|
||||
workerId: tracker.lastWorkerId,
|
||||
ip: tracker.lastIp,
|
||||
banned: tracker.banned,
|
||||
penalizedUntil: tracker.penalizedUntil,
|
||||
violationCount: tracker.violationCount,
|
||||
windowCount: tracker.windowCount,
|
||||
rejectedCount: tracker.rejectedCount,
|
||||
lastSeenAt: tracker.lastSeenAt,
|
||||
})
|
||||
}
|
||||
|
||||
return items.sort((a, b) => b.lastSeenAt - a.lastSeenAt).slice(0, TRACKER_SNAPSHOT_LIMIT)
|
||||
}
|
||||
|
||||
/**
|
||||
* 解除某个打手(及可选 IP)的内存风控状态。
|
||||
*
|
||||
* 后台解封账号时**必须**连同调用:把数据库改回 active 只解决鉴权层,
|
||||
* 内存里的 banned / 惩罚标记不会自己消失(30 秒对账虽能兜底恢复,但后台点击后应立即生效)。
|
||||
*/
|
||||
export function releaseWorkerRiskTracker(input: {
|
||||
workerId?: number | null
|
||||
ip?: string | null
|
||||
}): void {
|
||||
const workerId = Number(input.workerId)
|
||||
if (Number.isInteger(workerId) && workerId > 0) {
|
||||
riskTrackers.delete(`worker:${workerId}`)
|
||||
}
|
||||
const ip = String(input.ip || '').trim()
|
||||
if (ip) {
|
||||
riskTrackers.delete(`ip:${ip}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台人工封禁:同时置位内存标记,避免出现「数据库已 frozen、内存却仍放行」的反向不一致。
|
||||
* 仅支持按账号封禁——按 IP 封禁没有可对账的事实来源,会永久生效,因此不开放。
|
||||
*/
|
||||
export function markWorkerRiskBanned(workerId: number): void {
|
||||
const id = Number(workerId)
|
||||
if (!Number.isInteger(id) || id <= 0) return
|
||||
const now = Date.now()
|
||||
const tracker = getOrCreateTracker(`worker:${id}`, now)
|
||||
tracker.banned = true
|
||||
tracker.bannedVerifiedAt = now
|
||||
tracker.lastWorkerId = id
|
||||
tracker.lastSeenAt = now
|
||||
}
|
||||
|
||||
/** 测试辅助:清空风控记录 */
|
||||
export function resetWorkerRiskTrackersForTest(): void {
|
||||
riskTrackers.clear()
|
||||
|
||||
Reference in New Issue
Block a user