feat(worker-risk): 风控后台页面(可调阈值/实时状态/事件留痕/人工封禁解封),并修复解封后仍被 403 的 bug
## 修复的 bug(生产已发生) 风控三级封号把 tracker.banned 置为 true 后,banned 检查发生在鉴权之前, 而它只存在于进程内存。后台把账号改回 active 时数据库变了、内存没变, 于是账号在库里已经正常、请求却永远 403,只能等容器重启才恢复。 修法两道: - **数据库是唯一事实来源**:banned 改为带 30 秒 TTL 的缓存,到点回查 worker_users.status;已解封(或账号已删)则清除标记并放行。 对被判 banned 的 key 最多每 30 秒查一次,即便对方仍以 65 QPS 刷也只是 每 30 秒一次查询。查询失败时**保守维持封禁**,不因数据库抖动放行刷子。 - **后台改状态时主动释放**:review 只允许设 active/rejected/disabled/pending_review (没有 frozen),所以走到那里就意味着不再由风控冻结,直接释放内存状态。 顺带修掉一个连带缺陷:解封时只清了 violationCount,没清滑动窗口计数, 账号刚恢复就会被上一轮残留的 windowCount 立刻限流、并在 60 秒内二次封禁。 现在整轮窗口状态一并清零。 ## 新增后台页面 接单平台 → 风控(/admin/worker-platform?tab=risk-control),三块: - 阈值配置:6 个阈值可视化编辑,落 app_config_entries,保存即热生效,无需重启 - 当前实时风控:进程内存状态(已封禁/惩罚中/仅记录),支持人工解封、封禁、仅释放内存状态 - 风控事件留痕:worker_risk_events 分页查询,可按类型/IP 筛选 ## 关键取舍 只记**显著事件**(二级惩罚、三级封号、人工处置),不记每一次被拒请求。 被刷时实测 45~65 QPS,按请求写库正是最初要解决的问题。实时计数仍走进程内存。 事件写入为 fire-and-forget 并吞异常,留痕失败绝不影响大厅请求。 ## 验证 - 新增 6 个用例覆盖解封立即生效、对账自愈、TTL 不重复查库、对账失败保守维持、阈值可配置 - 迁移 089 已在临时库实测:表结构/4 索引/CHECK 约束/外键均正确,非法 event_type 被拒 - 后端 557 测试 554 通过 0 失败;前后端 typecheck/lint/format/build 全绿
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import {
|
||||
listActiveWorkerRiskTrackers,
|
||||
markWorkerRiskBanned,
|
||||
releaseWorkerRiskTracker,
|
||||
} from '../../middleware/worker-risk-control.js'
|
||||
import {
|
||||
countWorkerRiskEvents,
|
||||
insertWorkerRiskEvent,
|
||||
listWorkerRiskEvents,
|
||||
summarizeWorkerRiskEvents,
|
||||
type WorkerRiskEventRow,
|
||||
} from '../../repositories/worker-platform/worker-risk-repo.js'
|
||||
import {
|
||||
getWorkerUserById,
|
||||
updateWorkerUser,
|
||||
} from '../../repositories/worker-platform/worker-repo.js'
|
||||
import { revokeAllWorkerSessions } from '../../repositories/worker-platform/worker-session-repo.js'
|
||||
import { normalizePage, normalizePageSize } from '../admin/admin-query-utils.js'
|
||||
import { getWorkerRiskConfig, saveWorkerRiskConfig } from './worker-risk-config-service.js'
|
||||
|
||||
/**
|
||||
* 后台「接单平台 → 风控」的读写服务。
|
||||
*
|
||||
* 数据来源分两层,界面上必须让运营能分清楚:
|
||||
* - **实时层**:风控中间件的进程内存 tracker(封禁/惩罚中的 key)。重启即清零,不是历史累计。
|
||||
* - **留痕层**:worker_risk_events(只记显著事件)。持久、可追溯。
|
||||
*/
|
||||
|
||||
export type WorkerRiskActor = { userId?: number | null; username?: string }
|
||||
|
||||
const OVERVIEW_SUMMARY_WINDOW_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
export async function getAdminWorkerRiskOverview() {
|
||||
const config = getWorkerRiskConfig()
|
||||
const sinceIso = new Date(Date.now() - OVERVIEW_SUMMARY_WINDOW_MS).toISOString()
|
||||
const [summary, totalEvents] = await Promise.all([
|
||||
summarizeWorkerRiskEvents(sinceIso),
|
||||
countWorkerRiskEvents({}),
|
||||
])
|
||||
|
||||
return {
|
||||
config,
|
||||
/** 进程内存中的实时状态;重启会清空,界面需如实标注 */
|
||||
activeTrackers: listActiveWorkerRiskTrackers().map((item) => ({
|
||||
...item,
|
||||
penalizedUntilIso: item.penalizedUntil ? new Date(item.penalizedUntil).toISOString() : null,
|
||||
lastSeenAtIso: new Date(item.lastSeenAt).toISOString(),
|
||||
})),
|
||||
summary24h: summary,
|
||||
totalEvents,
|
||||
serverTimeIso: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveAdminWorkerRiskConfig(payload: JsonObject = {}) {
|
||||
const config = await saveWorkerRiskConfig(payload)
|
||||
return { config }
|
||||
}
|
||||
|
||||
export async function listAdminWorkerRiskEvents(query: JsonObject = {}) {
|
||||
const page = normalizePage(query.page)
|
||||
const pageSize = normalizePageSize(query.pageSize)
|
||||
const sinceHours = Number(query.sinceHours ?? query.since_hours)
|
||||
|
||||
const filters = {
|
||||
eventType: String(query.eventType ?? query.event_type ?? '').trim(),
|
||||
ip: String(query.ip || '').trim(),
|
||||
workerId: Number(query.workerId ?? query.worker_id) || null,
|
||||
targetKey: String(query.targetKey ?? query.target_key ?? '').trim(),
|
||||
sinceIso:
|
||||
Number.isFinite(sinceHours) && sinceHours > 0
|
||||
? new Date(Date.now() - sinceHours * 60 * 60 * 1000).toISOString()
|
||||
: undefined,
|
||||
}
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
listWorkerRiskEvents({ ...filters, limit: pageSize, offset: (page - 1) * pageSize }),
|
||||
countWorkerRiskEvents(filters),
|
||||
])
|
||||
|
||||
return {
|
||||
items: rows.map(mapWorkerRiskEvent),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
}
|
||||
}
|
||||
|
||||
/** 人工封禁打手账号(与自动封禁等价,只是留痕标记为人工操作)。 */
|
||||
export async function banAdminWorkerRisk(
|
||||
workerId: number | string,
|
||||
actor: WorkerRiskActor = {},
|
||||
payload: JsonObject = {},
|
||||
) {
|
||||
const id = normalizeWorkerId(workerId)
|
||||
const worker = await getWorkerUserById(id)
|
||||
if (!worker) {
|
||||
throw createHttpError('接单账号不存在', { statusCode: 404, errorCode: 'worker_not_found' })
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
const reason = String(payload.reason || '').trim() || '后台人工封禁:疑似外挂刷单或异常高频请求'
|
||||
|
||||
await updateWorkerUser(id, {
|
||||
status: 'frozen',
|
||||
frozen_reason: reason,
|
||||
frozen_at: now,
|
||||
session_version: Number(worker.session_version || 1) + 1,
|
||||
updated_at: now,
|
||||
})
|
||||
await revokeAllWorkerSessions(id, now)
|
||||
// 内存标记必须同步置位,否则会出现「数据库已 frozen、内存却仍放行」的反向不一致
|
||||
markWorkerRiskBanned(id)
|
||||
|
||||
await insertWorkerRiskEvent({
|
||||
eventType: 'manual_banned',
|
||||
targetKey: `worker:${id}`,
|
||||
workerId: id,
|
||||
ip: String(payload.ip || '').trim(),
|
||||
reason,
|
||||
action: 'manual',
|
||||
actorUserId: actor.userId ?? null,
|
||||
actorUsername: actor.username || '',
|
||||
})
|
||||
|
||||
return { workerId: id, status: 'frozen', reason }
|
||||
}
|
||||
|
||||
/** 人工解封:恢复账号可用,并释放内存里的风控状态(否则仍会被 403 挡住)。 */
|
||||
export async function unbanAdminWorkerRisk(
|
||||
workerId: number | string,
|
||||
actor: WorkerRiskActor = {},
|
||||
payload: JsonObject = {},
|
||||
) {
|
||||
const id = normalizeWorkerId(workerId)
|
||||
const worker = await getWorkerUserById(id)
|
||||
if (!worker) {
|
||||
throw createHttpError('接单账号不存在', { statusCode: 404, errorCode: 'worker_not_found' })
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
const reason = String(payload.reason || '').trim() || '后台人工解封'
|
||||
|
||||
await updateWorkerUser(id, {
|
||||
status: 'active',
|
||||
frozen_reason: '',
|
||||
frozen_at: null,
|
||||
session_version: Number(worker.session_version || 1) + 1,
|
||||
updated_at: now,
|
||||
})
|
||||
await revokeAllWorkerSessions(id, now)
|
||||
releaseWorkerRiskTracker({ workerId: id })
|
||||
|
||||
await insertWorkerRiskEvent({
|
||||
eventType: 'manual_unbanned',
|
||||
targetKey: `worker:${id}`,
|
||||
workerId: id,
|
||||
ip: String(payload.ip || '').trim(),
|
||||
reason,
|
||||
action: 'manual',
|
||||
actorUserId: actor.userId ?? null,
|
||||
actorUsername: actor.username || '',
|
||||
})
|
||||
|
||||
return { workerId: id, status: 'active', reason }
|
||||
}
|
||||
|
||||
/** 仅释放内存风控状态,不改数据库状态。用于处理「内存误封但账号本身正常」的情况。 */
|
||||
export async function releaseAdminWorkerRiskTracker(
|
||||
workerId: number | string,
|
||||
actor: WorkerRiskActor = {},
|
||||
) {
|
||||
const id = normalizeWorkerId(workerId)
|
||||
releaseWorkerRiskTracker({ workerId: id })
|
||||
await insertWorkerRiskEvent({
|
||||
eventType: 'manual_unbanned',
|
||||
targetKey: `worker:${id}`,
|
||||
workerId: id,
|
||||
reason: '后台仅释放内存风控状态(账号状态未变更)',
|
||||
action: 'manual',
|
||||
actorUserId: actor.userId ?? null,
|
||||
actorUsername: actor.username || '',
|
||||
})
|
||||
return { workerId: id }
|
||||
}
|
||||
|
||||
function normalizeWorkerId(value: number | string): number {
|
||||
const id = Number(value)
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
throw createHttpError('接单账号 ID 无效', { statusCode: 400, errorCode: 'worker_id_invalid' })
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
function mapWorkerRiskEvent(row: WorkerRiskEventRow) {
|
||||
return {
|
||||
id: String(row.id),
|
||||
eventType: row.event_type,
|
||||
targetKey: row.target_key,
|
||||
workerId: row.worker_id === null ? null : Number(row.worker_id),
|
||||
workerUsername: row.worker_username,
|
||||
ip: row.ip,
|
||||
violationCount: Number(row.violation_count) || 0,
|
||||
windowCount: Number(row.window_count) || 0,
|
||||
penaltyUntilIso: row.penalty_until ? new Date(row.penalty_until).toISOString() : null,
|
||||
action: row.action,
|
||||
actorUserId: row.actor_user_id === null ? null : Number(row.actor_user_id),
|
||||
actorUsername: row.actor_username,
|
||||
reason: row.reason,
|
||||
createdAtIso: new Date(row.created_at).toISOString(),
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import { releaseWorkerRiskTracker } from '../../middleware/worker-risk-control.js'
|
||||
import { normalizePage, normalizePageSize } from '../admin/admin-query-utils.js'
|
||||
import {
|
||||
hashWorkerPassword,
|
||||
@@ -108,6 +109,14 @@ export async function reviewAdminWorkerUser(workerId: number | string, payload:
|
||||
updated_at: now,
|
||||
})
|
||||
await revokeAllWorkerSessions(worker.id, now)
|
||||
|
||||
// 必须同步释放内存里的风控状态。
|
||||
// review 只允许把状态改成 active/rejected/disabled/pending_review(没有 frozen),
|
||||
// 所以走到这里就意味着「不再由风控冻结」。而风控中间件的 banned 检查发生在鉴权之前,
|
||||
// 只改数据库的话,账号在库里已经恢复、请求却仍然被 403 挡在门外,
|
||||
// 只能等容器重启 —— 这是生产上实际发生过的故障。
|
||||
releaseWorkerRiskTracker({ workerId: worker.id })
|
||||
|
||||
return { worker: mapWorkerUser(updated || worker) }
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ export * from './admin-worker-leaderboard-service.js'
|
||||
export * from './admin-kuaishou-match-service.js'
|
||||
export * from './admin-worker-config-service.js'
|
||||
export * from './admin-worker-user-service.js'
|
||||
export * from './admin-worker-risk-service.js'
|
||||
export * from './admin-worker-detail-service.js'
|
||||
export * from './admin-work-order-query-service.js'
|
||||
export * from './admin-worker-feedback-service.js'
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { APP_CONFIG_KEYS } from '../../config/app-config-keys.js'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
import { readAppConfigEntry, saveAppConfigEntry } from '../config/app-config-store.js'
|
||||
|
||||
/**
|
||||
* 大厅风控阈值配置(可在后台「接单平台 → 风控」页调整)。
|
||||
*
|
||||
* 这些值原先硬编码在 middleware/worker-risk-control.ts 里,改一次要发版;
|
||||
* 现在落到 app_config_entries,保存后热生效,无需重启。
|
||||
*
|
||||
* 为什么需要本层的 memo:中间件每个大厅请求都要读一次配置,而 readAppConfigEntry
|
||||
* 内部会对整个 JSON 做一次深拷贝 + normalize。被刷时实测 45~65 QPS,
|
||||
* 每请求一次深拷贝是纯浪费,因此这里加一个极短 TTL 的进程内缓存。
|
||||
* 后台保存时会显式失效,所以正常情况下改完立即生效(TTL 只是兜底)。
|
||||
*/
|
||||
|
||||
export type WorkerRiskConfig = {
|
||||
/** 总开关。关闭后大厅不再限流(仅用于排障,正常应保持开启) */
|
||||
enabled: boolean
|
||||
/** 一级限流:统计窗口(毫秒) */
|
||||
tier1WindowMs: number
|
||||
/** 一级限流:窗口内允许的请求数,超过即 429 */
|
||||
tier1MaxRequests: number
|
||||
/** 违规计数窗口(毫秒),在此窗口内累计的 429 次数用于升级判定 */
|
||||
violationWindowMs: number
|
||||
/** 二级惩罚:违规次数达到该值即进入静默惩罚期 */
|
||||
tier2TriggerViolations: number
|
||||
/** 二级惩罚时长(毫秒) */
|
||||
tier2PenaltyMs: number
|
||||
/** 三级封号:惩罚期内仍继续请求,累计违规达到该值即自动封号 */
|
||||
tier3TriggerViolations: number
|
||||
}
|
||||
|
||||
const CONFIG_MEMO_TTL_MS = 5_000
|
||||
|
||||
let configMemo: { value: WorkerRiskConfig; expiresAt: number } | null = null
|
||||
|
||||
export function createDefaultWorkerRiskConfig(): WorkerRiskConfig {
|
||||
return {
|
||||
enabled: true,
|
||||
tier1WindowMs: 1_000,
|
||||
tier1MaxRequests: 3,
|
||||
violationWindowMs: 60_000,
|
||||
tier2TriggerViolations: 10,
|
||||
tier2PenaltyMs: 300_000,
|
||||
tier3TriggerViolations: 30,
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkerRiskConfig(): WorkerRiskConfig {
|
||||
const now = Date.now()
|
||||
if (configMemo && configMemo.expiresAt > now) {
|
||||
return configMemo.value
|
||||
}
|
||||
|
||||
const value = readAppConfigEntry({
|
||||
configKey: APP_CONFIG_KEYS.workerRiskControl,
|
||||
fallback: createDefaultWorkerRiskConfig,
|
||||
normalize: normalizeWorkerRiskConfig,
|
||||
})
|
||||
|
||||
configMemo = { value, expiresAt: now + CONFIG_MEMO_TTL_MS }
|
||||
return value
|
||||
}
|
||||
|
||||
export async function saveWorkerRiskConfig(rawValue: unknown): Promise<WorkerRiskConfig> {
|
||||
const saved = await saveAppConfigEntry({
|
||||
configKey: APP_CONFIG_KEYS.workerRiskControl,
|
||||
value: rawValue,
|
||||
normalize: normalizeWorkerRiskConfig,
|
||||
})
|
||||
// 同进程内立即生效,不等 TTL 过期
|
||||
configMemo = { value: saved, expiresAt: Date.now() + CONFIG_MEMO_TTL_MS }
|
||||
return saved
|
||||
}
|
||||
|
||||
/** 仅测试使用:清掉进程内缓存,让下次读取回到 app-config-store。 */
|
||||
export function clearWorkerRiskConfigMemoForTest(): void {
|
||||
configMemo = null
|
||||
}
|
||||
|
||||
export function normalizeWorkerRiskConfig(rawValue: unknown): WorkerRiskConfig {
|
||||
const fallback = createDefaultWorkerRiskConfig()
|
||||
const source = isPlainObject(rawValue) ? rawValue : {}
|
||||
|
||||
const enabled =
|
||||
source.enabled === undefined
|
||||
? fallback.enabled
|
||||
: source.enabled !== false && String(source.enabled) !== 'false'
|
||||
|
||||
return {
|
||||
enabled,
|
||||
tier1WindowMs: pickInt(source.tier1WindowMs, 100, 60_000, fallback.tier1WindowMs),
|
||||
// 上限 600/窗口:再高就失去限流意义(1 秒 600 次已属异常),避免误配把风控关成摆设
|
||||
tier1MaxRequests: pickInt(source.tier1MaxRequests, 1, 600, fallback.tier1MaxRequests),
|
||||
violationWindowMs: pickInt(
|
||||
source.violationWindowMs,
|
||||
1_000,
|
||||
3_600_000,
|
||||
fallback.violationWindowMs,
|
||||
),
|
||||
tier2TriggerViolations: pickInt(
|
||||
source.tier2TriggerViolations,
|
||||
1,
|
||||
10_000,
|
||||
fallback.tier2TriggerViolations,
|
||||
),
|
||||
tier2PenaltyMs: pickInt(source.tier2PenaltyMs, 1_000, 86_400_000, fallback.tier2PenaltyMs),
|
||||
tier3TriggerViolations: pickInt(
|
||||
source.tier3TriggerViolations,
|
||||
1,
|
||||
100_000,
|
||||
fallback.tier3TriggerViolations,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function pickInt(value: unknown, min: number, max: number, fallback: number): number {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed)) return fallback
|
||||
const truncated = Math.trunc(parsed)
|
||||
if (truncated < min || truncated > max) return fallback
|
||||
return truncated
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is JsonObject {
|
||||
return Object.prototype.toString.call(value) === '[object Object]'
|
||||
}
|
||||
Reference in New Issue
Block a user