feat(risk-control): 新增 IP/账号手动封禁,并收口财务同步的全表更新
- 风控:worker_risk_bans 表 + 前置封禁中间件(登录一并拦截)+ 后台列表/新增/解封 - 财务:成本快照补商品维度收口到本批 order_item_id,不再整表扫 899MB 的 order_items - 性能:order_items 覆盖索引(迁移 110)、大厅 pageSize 上限 20、已读/上传接口限流
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user