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
@@ -188,13 +188,16 @@ test('风控修复: 后台解封后必须能立即接单(内存标记需一并
test('风控修复: 数据库已解封时,内存封禁标记应自动失效(对账自愈)', async () => {
const req = await driveToBanned(88802, '10.0.0.2')
// TTL 内不重复查库:driveToBanned 期间已完成一次对账,这里先确认它的短路行为
// 刚封禁时会落进宽限期,此时刻意不去查库(bannedVerifiedAt 仍为 0),
// 以免对账读到未提交的写库把刚下的封禁撤销——见下方「刚下的封禁不得被对账撤销」用例。
const tracker = getWorkerRiskTrackerForTest('worker:88802')
assert.ok(tracker, '应存在 tracker')
assert.ok(tracker.bannedVerifiedAt > 0, '应已记录过一次对账时间')
assert.equal(tracker.bannedVerifiedAt, 0, '宽限期内不应发起对账')
// 模拟「对账间隔已过」:真实环境等 30 秒即可,测试里直接把时间戳归零
// 模拟「对账间隔已过」:真实环境等 30 秒即可,测试里直接把时间戳归零。
// bannedAt 也要归零——否则仍会落进「刚下的封禁宽限期」。
tracker.bannedVerifiedAt = 0
tracker.bannedAt = 0
// 模拟「数据库里已经是正常账号」:不调用 release,只让对账查到 active
let passed = 0
@@ -215,8 +218,9 @@ test('风控修复: 对账间隔内的重复请求不重复查库(TTL 生效
const tracker = getWorkerRiskTrackerForTest('worker:88805')
assert.ok(tracker, '应存在 tracker')
// 归零以模拟「对账间隔已过」,让第一次请求真正触发查库
// 归零以模拟「对账间隔已过」,让第一次请求真正触发查库(越过宽限期)
tracker.bannedVerifiedAt = 0
tracker.bannedAt = 0
let lookupCalls = 0
const middleware = createWorkerHallRiskMiddleware({
@@ -283,3 +287,37 @@ test('风控阈值可后台配置: tier1MaxRequests 生效且保存后无需重
clearWorkerRiskConfigMemoForTest()
}
})
test('风控修复: 刚下的封禁不得被对账撤销(写库未提交的竞态)', async () => {
const req = await driveToBanned(88806, '10.0.0.6')
// 生产实测:Tier3 先把 banned 置 true、再 await 写库;在提交前的窗口里,
// 并发请求的对账读到 status 仍是 active,就把刚下的封禁撤销了(27ms 窗口)。
// 这里模拟「数据库暂时还看不到冻结」——宽限期内必须维持封禁。
let passed = 0
const middleware = createWorkerHallRiskMiddleware({
lookupWorker: async () => ({ status: 'active' }),
})
const mockRes = buildMockRes()
await middleware(req, mockRes.res, () => {
passed++
})
assert.equal(passed, 0, '宽限期内不得放行')
assert.equal(mockRes.status, 403, '宽限期内应维持 403')
assert.equal(getWorkerRiskTrackerForTest('worker:88806')?.banned, true, '封禁标记不得被撤销')
})
test('风控修复: 账号不存在时不写"已封禁"的假日志(updateWorkerUser 返回 null 不抛错)', async () => {
resetWorkerRiskTrackersForTest()
const middleware = createWorkerHallRiskMiddleware({
lookupWorker: async () => null,
})
const req = buildMockReq({ workerId: 99999999, ip: '10.0.0.9' })
for (let i = 0; i < 35; i++) {
const mockRes = buildMockRes()
await middleware(req, mockRes.res, () => {})
}
const tracker = getWorkerRiskTrackerForTest('worker:99999999')
assert.equal(tracker?.banned, true, '内存标记仍会置位(用于挡住该 uid 的后续请求)')
})
@@ -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