- 风控:worker_risk_bans 表 + 前置封禁中间件(登录一并拦截)+ 后台列表/新增/解封 - 财务:成本快照补商品维度收口到本批 order_item_id,不再整表扫 899MB 的 order_items - 性能:order_items 覆盖索引(迁移 110)、大厅 pageSize 上限 20、已读/上传接口限流
171 lines
5.7 KiB
TypeScript
171 lines
5.7 KiB
TypeScript
import type { NextFunction, Request, Response } from 'express'
|
|
|
|
import { listActiveWorkerRiskBans } from '../repositories/worker-platform/worker-risk-ban-repo.js'
|
|
import { buildErrorPayload, createHttpError } from '../utils/http.js'
|
|
import { logError, logWarn } from '../utils/logger.js'
|
|
import { extractFastWorkerId, resolveClientIp } from './worker-risk-control.js'
|
|
|
|
/**
|
|
* 手动封禁(IP / 账号)拦截中间件。
|
|
*
|
|
* 与 worker-risk-control 的分工:
|
|
* · worker-risk-control:按**行为**自动升级(限流 → 惩罚 → 自动封号),状态在进程内存;
|
|
* · 本中间件:按**人工决定**拦截,事实存在 worker_risk_bans 表,进程重启不丢。
|
|
*
|
|
* 性能约束(这是被刷场景下最热的代码):
|
|
* · 每个请求只做两次 Set/Map 命中,绝不查库;
|
|
* · 全量封禁列表进程内缓存 30 秒,后台增删封禁时立即失效(同进程);
|
|
* · 加载失败 **fail-open**:宁可短暂漏挡,也不能因为一次数据库抖动把整个接单端挡死。
|
|
*
|
|
* 拦截范围:挂在整个 /api/v1/worker 路由之前,因此登录、大厅、订单、上传都会被挡;
|
|
* 被封 IP 连登录都进不来——这正是运营想要的"现在就挡住它"。
|
|
*/
|
|
|
|
type WorkerBanSnapshot = {
|
|
ips: Set<string>
|
|
workers: Set<number>
|
|
expiresAt: number
|
|
}
|
|
|
|
const CACHE_TTL_MS = 30_000
|
|
|
|
/**
|
|
* 归一化 IP:去掉 IPv6-mapped 前缀与方括号、统一小写。
|
|
* 反向代理(Caddy)对 IPv4 请求可能给出 `::ffff:1.2.3.4`,不归一化会导致封了却挡不住。
|
|
*/
|
|
export function normalizeWorkerIp(value: unknown): string {
|
|
return String(value || '')
|
|
.trim()
|
|
.replace(/^\[|\]$/g, '')
|
|
.replace(/^::ffff:/i, '')
|
|
.toLowerCase()
|
|
}
|
|
|
|
let snapshot: WorkerBanSnapshot | null = null
|
|
let inflightLoad: Promise<void> | null = null
|
|
|
|
/** 后台增删封禁后调用,让本进程立即生效,不必等 TTL。 */
|
|
export function invalidateWorkerBanCache(): void {
|
|
snapshot = null
|
|
}
|
|
|
|
async function loadSnapshot(): Promise<void> {
|
|
const rows = await listActiveWorkerRiskBans()
|
|
const ips = new Set<string>()
|
|
const workers = new Set<number>()
|
|
for (const row of rows) {
|
|
if (row.target_type === 'ip') {
|
|
const ip = normalizeWorkerIp(row.target_value)
|
|
if (ip) ips.add(ip)
|
|
continue
|
|
}
|
|
const workerId = Number(row.worker_id || row.target_value)
|
|
if (Number.isInteger(workerId) && workerId > 0) workers.add(workerId)
|
|
}
|
|
snapshot = { ips, workers, expiresAt: Date.now() + CACHE_TTL_MS }
|
|
}
|
|
|
|
async function ensureSnapshot(): Promise<WorkerBanSnapshot | null> {
|
|
const now = Date.now()
|
|
if (snapshot && snapshot.expiresAt > now) return snapshot
|
|
if (!inflightLoad) {
|
|
inflightLoad = loadSnapshot()
|
|
.catch((error) => {
|
|
// fail-open:记录一次,保留旧的快照(如果有),不要阻塞请求
|
|
logError('[worker/ban-guard]', '加载手动封禁列表失败,本进程暂时放行', {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
})
|
|
if (snapshot) snapshot.expiresAt = Date.now() + CACHE_TTL_MS
|
|
})
|
|
.finally(() => {
|
|
inflightLoad = null
|
|
})
|
|
}
|
|
await inflightLoad
|
|
return snapshot
|
|
}
|
|
|
|
/** 同一目标 60 秒内只打一条日志,避免被封的脚本刷爆日志盘。 */
|
|
const lastLogAt = new Map<string, number>()
|
|
const LOG_INTERVAL_MS = 60_000
|
|
|
|
function shouldLog(key: string): boolean {
|
|
const now = Date.now()
|
|
const last = lastLogAt.get(key) || 0
|
|
if (now - last < LOG_INTERVAL_MS) return false
|
|
lastLogAt.set(key, now)
|
|
if (lastLogAt.size > 5_000) lastLogAt.clear()
|
|
return true
|
|
}
|
|
|
|
export type WorkerBanLoader = () => Promise<
|
|
Array<{ target_type: 'ip' | 'worker'; target_value: string; worker_id?: string | null }>
|
|
>
|
|
|
|
export function createWorkerBanGuardMiddleware(deps: { loadBans?: WorkerBanLoader } = {}) {
|
|
// 测试注入用:不传则走真实仓储 + 30 秒缓存
|
|
const injected = deps.loadBans
|
|
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
|
const ip = normalizeWorkerIp(resolveClientIp(req))
|
|
const workerId = extractFastWorkerId(req)
|
|
|
|
let bannedIp = false
|
|
let bannedWorker = false
|
|
|
|
if (injected) {
|
|
try {
|
|
const rows = await injected()
|
|
bannedIp = rows.some(
|
|
(row) => row.target_type === 'ip' && normalizeWorkerIp(row.target_value) === ip,
|
|
)
|
|
bannedWorker =
|
|
workerId !== null &&
|
|
rows.some(
|
|
(row) =>
|
|
row.target_type === 'worker' &&
|
|
Number(row.worker_id || row.target_value) === workerId,
|
|
)
|
|
} catch {
|
|
bannedIp = false
|
|
bannedWorker = false
|
|
}
|
|
} else {
|
|
const current = await ensureSnapshot()
|
|
bannedIp = Boolean(current?.ips.has(ip))
|
|
bannedWorker = workerId !== null && Boolean(current?.workers.has(workerId))
|
|
}
|
|
|
|
if (!bannedIp && !bannedWorker) {
|
|
next()
|
|
return
|
|
}
|
|
|
|
const targetKey = bannedIp ? `ip:${ip}` : `worker:${workerId}`
|
|
if (shouldLog(targetKey)) {
|
|
logWarn('[worker/ban-guard]', '命中手动封禁,拒绝请求', {
|
|
targetKey,
|
|
ip,
|
|
workerId,
|
|
method: req.method,
|
|
originalUrl: req.originalUrl,
|
|
})
|
|
}
|
|
|
|
const error = createHttpError(
|
|
bannedIp ? '该网络出口已被后台封禁,无法访问接单端' : '该接单账号已被后台手动封禁',
|
|
{
|
|
statusCode: 403,
|
|
errorCode: bannedIp ? 'worker_ip_banned' : 'worker_account_banned',
|
|
},
|
|
)
|
|
res.status(403).json(buildErrorPayload(error, '已被后台手动封禁'))
|
|
}
|
|
}
|
|
|
|
/** 测试辅助:清空缓存与日志节流表。 */
|
|
export function resetWorkerBanGuardForTest(): void {
|
|
snapshot = null
|
|
inflightLoad = null
|
|
lastLogAt.clear()
|
|
}
|