fix(worker-risk): 修掉自动封禁被对账撤销的竞态,以及账号不存在时写假留痕

## 一、刚下的封禁会被对账撤销(生产实测)

Tier 3 是「先把 tracker.banned 置 true,再 await 写库」。在写库提交前的窗口里,
并发的另一个请求会走对账分支、读到 status 仍是 active,于是把刚下的封禁撤销掉。

生产日志(worker 28):
  17:20:11.361 WARN  封禁标记与数据库不一致,已自动解除并放行   ← 对账把刚下的封禁撤了
  17:20:11.388 ERROR 打手账号已被系统自动永久封禁! count=30    ← 27ms 后才写出封禁

后果:刷子能漏进来一个请求;日志还会让运营以为"数据库里已经解封了"。
修法:新增 bannedAt,刚下的封禁在 BAN_GRACE_MS(60s) 内不与数据库对账。
人工解封走 releaseWorkerRiskTracker() 直接删 tracker,不受宽限期影响,处置不会变慢。

## 二、账号不存在时会写下"已永久封禁"的假记录

updateWorkerUser 在账号不存在时返回 null 而**不抛错**,原代码直接把返回值丢弃,
于是照样打出"已被系统自动永久封禁"的日志并写 tier3_autofrozen 留痕。
外挂伪造成随机 uid 时就会命中这条路径,留下一批查不到的假封禁记录。
修法:显式判断返回值,为空则记明确错误并跳过留痕。

## 测试

新增 2 个用例:
  - 刚下的封禁在宽限期内不得被对账撤销(锁住上面的竞态)
  - 账号不存在时不写"已封禁"的假日志
并更新 2 个原有用例:宽限期内本就不发起对账,原断言(bannedVerifiedAt > 0)已过时。

风控测试 11/11;后端全量 559 测试 556 通过 0 失败。
This commit is contained in:
yml2213
2026-10-02 17:59:06 +08:00
parent 56e718a65d
commit f0d745ea50
2 changed files with 80 additions and 5 deletions
@@ -33,6 +33,9 @@ interface WorkerRiskTracker {
penalizedUntil: number
// 是否已被 Tier 3 自动封禁
banned: boolean
// 本进程把 banned 置为 true 的时刻。用于给「刚下的封禁」一个宽限期,
// 避免对账读未提交把刚写的封禁撤销(见 isWorkerStillFrozen)。
bannedAt: number
// 上次与数据库对账「是否仍处于 frozen」的时间戳,0 表示尚未对账
bannedVerifiedAt: number
// 便于后台展示的追溯信息(最近一次见到的身份/IP 与时间)
@@ -59,6 +62,19 @@ let lastCleanupAt = 0
*/
const BAN_RECHECK_INTERVAL_MS = 30_000
/**
* 刚下的封禁在多久内不与数据库对账。
*
* 必需:Tier 3 是「先把 tracker.banned 置 true,再 await 写库」。在写库提交之前的这个窗口里,
* 并发的另一个请求会走对账分支,读到 status 仍是 active,于是把刚刚下的封禁撤销掉
* ——生产实测出现过 27ms 的窗口(17:20:11.361 判定"不一致"、.388 才写出封禁),
* 结果是刷子能漏进来一个请求,日志还会误导运营。
*
* 前台解封(后台复核 / 风控页解封)走的是 releaseWorkerRiskTracker(),直接删 tracker,
* 不受这个宽限期影响,所以不会拖慢人工处置。
*/
const BAN_GRACE_MS = 60_000
/**
* 查账号状态的函数签名。抽成类型 + 可注入,是为了让「数据库已解封、内存仍封禁」
* 这条修复路径能被测试覆盖(测试环境没有 Postgres)。生产不传即用真实仓储。
@@ -119,6 +135,7 @@ function getOrCreateTracker(key: string, now: number): WorkerRiskTracker {
violationResetAt: now + config.violationWindowMs,
penalizedUntil: 0,
banned: false,
bannedAt: 0,
bannedVerifiedAt: 0,
lastWorkerId: null,
lastIp: '',
@@ -143,6 +160,12 @@ async function isWorkerStillFrozen(
now: number,
lookupWorker: WorkerRiskWorkerLookup,
): Promise<boolean> {
// 刚下的封禁先给一段宽限期:此刻数据库写入可能还没提交,
// 立刻对账会把刚封的账号又放出去(读未提交竞态)。
if (tracker.bannedAt && now - tracker.bannedAt < BAN_GRACE_MS) {
return true
}
if (tracker.bannedVerifiedAt && now - tracker.bannedVerifiedAt < BAN_RECHECK_INTERVAL_MS) {
return true
}
@@ -158,6 +181,7 @@ async function isWorkerStillFrozen(
if (!worker) {
// 账号已被删除,封禁自然失效
tracker.banned = false
tracker.bannedAt = 0
return false
}
if (worker.status === 'frozen') return true
@@ -166,6 +190,7 @@ async function isWorkerStillFrozen(
// 上一轮刷出来的 windowCount / violationCount 仍在窗口内,账号刚恢复就会立刻
// 被 Tier 1 限流、并在 60 秒内再次凑满 Tier 2 触发条件而二次封禁。
tracker.banned = false
tracker.bannedAt = 0
tracker.penalizedUntil = 0
tracker.violationCount = 0
tracker.violationResetAt = 0
@@ -229,6 +254,7 @@ export function createWorkerHallRiskMiddleware(
// 检查是否升级到 Tier 3 自动封号
if (tracker.violationCount >= config.tier3TriggerViolations && workerId && !tracker.banned) {
tracker.banned = true
tracker.bannedAt = now
await executeAutoFreezeWorker(workerId, ip, tracker.violationCount)
const error = createHttpError('恶意高频刷接口多次不改,接单账号已被系统自动永久封禁', {
statusCode: 403,
@@ -336,12 +362,22 @@ async function executeAutoFreezeWorker(workerId: number, ip: string, violationCo
try {
const now = nowIso()
const reason = `系统风控:检测到高频自动化刷接口(违规超 ${violationCount} 次),多次警告后未停止,已被系统自动封禁`
await updateWorkerUser(workerId, {
const updated = await updateWorkerUser(workerId, {
status: 'frozen',
frozen_reason: reason,
frozen_at: now,
updated_at: now,
})
// updateWorkerUser 在账号不存在时返回 null 而**不抛错**。必须显式判断,
// 否则会为一次根本没生效的封禁写下"已永久封禁"的日志与留痕,误导事后追溯。
if (!updated) {
logError('[worker/risk-control]', '自动封禁未生效:账号不存在,已跳过留痕', {
workerId,
ip,
violationCount,
})
return
}
// 强制踢掉该打手所有现存登录设备
await revokeAllWorkerSessions(workerId, now)
logError('[worker/risk-control]', '打手账号已被系统自动永久封禁!', {
@@ -444,6 +480,7 @@ export function markWorkerRiskBanned(workerId: number): void {
const now = Date.now()
const tracker = getOrCreateTracker(`worker:${id}`, now)
tracker.banned = true
tracker.bannedAt = now
tracker.bannedVerifiedAt = now
tracker.lastWorkerId = id
tracker.lastSeenAt = now