feat(risk-control): 新增 IP/账号手动封禁,并收口财务同步的全表更新

- 风控:worker_risk_bans 表 + 前置封禁中间件(登录一并拦截)+ 后台列表/新增/解封
- 财务:成本快照补商品维度收口到本批 order_item_id,不再整表扫 899MB 的 order_items
- 性能:order_items 覆盖索引(迁移 110)、大厅 pageSize 上限 20、已读/上传接口限流
This commit is contained in:
yml2213
2026-10-30 20:15:14 +08:00
parent 1b170d3f45
commit fb485f7e36
17 changed files with 1373 additions and 18 deletions
@@ -0,0 +1,186 @@
import { query } from '../../db/client.js'
/**
* 风控「手动封禁」(IP / 账号)的数据访问层。
*
* 与 worker_risk_events 的分工:
* · worker_risk_bans = **生效中的封禁事实**,中间件据此拦截请求;
* · worker_risk_events = **留痕**,每次人工封禁/解封都追加一条,便于事后追溯。
*
* 读取侧只有「加载全部生效封禁」是热路径(进程内缓存 30 秒兜底),其余都是后台低频分页查询。
*/
export type WorkerRiskBanTargetType = 'ip' | 'worker'
export type WorkerRiskBanRow = {
id: string
target_type: WorkerRiskBanTargetType
target_value: string
worker_id: string | null
worker_username: string
reason: string
expires_at: Date | null
created_by: string | null
created_by_username: string
created_at: Date
revoked_at: Date | null
revoked_by_username: string
revoke_reason: string
}
/** 中间件冷启动/缓存过期时加载:只返回「未撤销且未过期」的封禁。 */
export async function listActiveWorkerRiskBans(): Promise<WorkerRiskBanRow[]> {
const result = await query<WorkerRiskBanRow>(
`
SELECT *
FROM worker_risk_bans
WHERE revoked_at IS NULL
AND (expires_at IS NULL OR expires_at > NOW())
ORDER BY id DESC
LIMIT 5000
`,
)
return result.rows
}
export type ListWorkerRiskBansInput = {
limit: number
offset?: number | undefined
targetType?: string | undefined
keyword?: string | undefined
/** true = 只看生效中;false = 只看已撤销/已过期;不传 = 全部 */
activeOnly?: boolean | undefined
}
export async function listWorkerRiskBans(
input: ListWorkerRiskBansInput,
): Promise<WorkerRiskBanRow[]> {
const { where, params } = buildBanFilters(input)
const limit = Math.min(200, Math.max(1, Math.trunc(Number(input.limit) || 20)))
const offset = Math.max(0, Math.trunc(Number(input.offset) || 0))
const result = await query<WorkerRiskBanRow>(
`
SELECT *
FROM worker_risk_bans
${where}
ORDER BY revoked_at NULLS FIRST, id DESC
LIMIT $${params.length + 1} OFFSET $${params.length + 2}
`,
[...params, limit, offset],
)
return result.rows
}
export async function countWorkerRiskBans(
input: Omit<ListWorkerRiskBansInput, 'limit' | 'offset'>,
): Promise<number> {
const { where, params } = buildBanFilters(input)
const result = await query<{ total: string }>(
`SELECT COUNT(*)::text AS total FROM worker_risk_bans ${where}`,
params,
)
return Number(result.rows[0]?.total || 0)
}
/**
* 新增一条封禁。
*
* 同一目标已有生效中的封禁时返回 null(依赖部分唯一索引 uq_worker_risk_bans_active),
* 由服务层翻译成 409,避免同一 IP 在列表里重复出现多条。
*/
export async function insertWorkerRiskBan(input: {
targetType: WorkerRiskBanTargetType
targetValue: string
workerId?: number | null
workerUsername?: string
reason?: string
expiresAt?: string | null
createdBy?: number | null
createdByUsername?: string
}): Promise<WorkerRiskBanRow | null> {
const result = await query<WorkerRiskBanRow>(
`
INSERT INTO worker_risk_bans (
target_type, target_value, worker_id, worker_username,
reason, expires_at, created_by, created_by_username, created_at
) VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7, $8, NOW())
ON CONFLICT (target_type, target_value) WHERE revoked_at IS NULL DO NOTHING
RETURNING *
`,
[
input.targetType,
String(input.targetValue || '').slice(0, 200),
input.workerId ?? null,
String(input.workerUsername || '').slice(0, 100),
String(input.reason || '').slice(0, 500),
input.expiresAt ?? null,
input.createdBy ?? null,
String(input.createdByUsername || '').slice(0, 100),
],
)
return result.rows[0] ?? null
}
/** 撤销一条封禁;已撤销/不存在返回 null。 */
export async function revokeWorkerRiskBanById(
id: number | string,
input: { revokedByUsername?: string; reason?: string } = {},
): Promise<WorkerRiskBanRow | null> {
const result = await query<WorkerRiskBanRow>(
`
UPDATE worker_risk_bans
SET revoked_at = NOW(),
revoked_by_username = $2,
revoke_reason = $3
WHERE id = $1 AND revoked_at IS NULL
RETURNING *
`,
[
Number(id),
String(input.revokedByUsername || '').slice(0, 100),
String(input.reason || '').slice(0, 500),
],
)
return result.rows[0] ?? null
}
export async function getWorkerRiskBanById(id: number | string): Promise<WorkerRiskBanRow | null> {
const result = await query<WorkerRiskBanRow>(`SELECT * FROM worker_risk_bans WHERE id = $1`, [
Number(id),
])
return result.rows[0] ?? null
}
function buildBanFilters(input: {
targetType?: string | undefined
keyword?: string | undefined
activeOnly?: boolean | undefined
}): { where: string; params: unknown[] } {
const conditions: string[] = []
const params: unknown[] = []
const targetType = String(input.targetType || '').trim()
if (targetType === 'ip' || targetType === 'worker') {
params.push(targetType)
conditions.push(`target_type = $${params.length}`)
}
const keyword = String(input.keyword || '').trim()
if (keyword) {
params.push(`%${keyword}%`)
conditions.push(
`(target_value ILIKE $${params.length} OR worker_username ILIKE $${params.length})`,
)
}
if (input.activeOnly === true) {
conditions.push(`revoked_at IS NULL AND (expires_at IS NULL OR expires_at > NOW())`)
} else if (input.activeOnly === false) {
conditions.push(`(revoked_at IS NOT NULL OR (expires_at IS NOT NULL AND expires_at <= NOW()))`)
}
return {
where: conditions.length ? `WHERE ${conditions.join(' AND ')}` : '',
params,
}
}