From f0d745ea5025a31ea9e0e58a34a9234289834734 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 2 Oct 2026 17:59:06 +0800 Subject: [PATCH] =?UTF-8?q?fix(worker-risk):=20=E4=BF=AE=E6=8E=89=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=B0=81=E7=A6=81=E8=A2=AB=E5=AF=B9=E8=B4=A6=E6=92=A4?= =?UTF-8?q?=E9=94=80=E7=9A=84=E7=AB=9E=E6=80=81=EF=BC=8C=E4=BB=A5=E5=8F=8A?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E4=B8=8D=E5=AD=98=E5=9C=A8=E6=97=B6=E5=86=99?= =?UTF-8?q?=E5=81=87=E7=95=99=E7=97=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 一、刚下的封禁会被对账撤销(生产实测) 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 失败。 --- .../middleware/worker-risk-control.test.ts | 46 +++++++++++++++++-- .../src/middleware/worker-risk-control.ts | 39 +++++++++++++++- 2 files changed, 80 insertions(+), 5 deletions(-) diff --git a/apps/backend/src/middleware/worker-risk-control.test.ts b/apps/backend/src/middleware/worker-risk-control.test.ts index 08213714..3d2e36a2 100644 --- a/apps/backend/src/middleware/worker-risk-control.test.ts +++ b/apps/backend/src/middleware/worker-risk-control.test.ts @@ -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 的后续请求)') +}) diff --git a/apps/backend/src/middleware/worker-risk-control.ts b/apps/backend/src/middleware/worker-risk-control.ts index a189a1fd..7f8cbd19 100644 --- a/apps/backend/src/middleware/worker-risk-control.ts +++ b/apps/backend/src/middleware/worker-risk-control.ts @@ -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 { + // 刚下的封禁先给一段宽限期:此刻数据库写入可能还没提交, + // 立刻对账会把刚封的账号又放出去(读未提交竞态)。 + 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