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