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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user