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,32 @@
|
||||
-- 110_order_items_fulfillment_product_index.sql
|
||||
--
|
||||
-- 背景(2026-10-30 生产实测):
|
||||
-- cost-finance-sync 每 15 分钟执行一次「人工单补商品维度」的更新,原写法是无条件:
|
||||
-- UPDATE cost_snapshots cs SET canonical_product_id = oi.canonical_product_id
|
||||
-- FROM order_items oi
|
||||
-- WHERE cs.order_item_id = oi.id
|
||||
-- AND cs.canonical_product_id IS NULL
|
||||
-- AND oi.fulfillment_mode = 'manual'
|
||||
-- AND oi.canonical_product_id IS NOT NULL
|
||||
-- 规划器只能走 BitmapAnd(idx_order_items_canonical_product, idx_order_items_fulfillment_mode)
|
||||
-- 再回表,而 order_items 堆已经 899MB(43 万行),每次要在其中随机读上百 MB:
|
||||
-- 连续两次实测 15.2s / 16.5s,直接撞 statement_timeout,后面的销售事实重算与
|
||||
-- 影子总账同步整条被跳过。
|
||||
--
|
||||
-- 两条修复并行:
|
||||
-- 1. 语句本身收口到「本批刚归一过的 order_item_id」(见
|
||||
-- manual-product-normalization-service.ts),彻底不再全表扫;
|
||||
-- 2. 本迁移补一个覆盖索引,让所有「按履约方式 + 有无商品」筛选的查询都能 index-only scan。
|
||||
-- order_items 的其它读路径(人工渠道回填、成本中心商品维度核对)同样吃这几个条件。
|
||||
--
|
||||
-- 迁移在 singleTransaction 里执行(见 migrate.ts),因此这里用普通 CREATE INDEX;
|
||||
-- 生产按 086/107/108 的惯例先手工执行 CREATE INDEX CONCURRENTLY 预建,
|
||||
-- 部署时 IF NOT EXISTS 直接跳过,不锁表。
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_order_items_fulfillment_product
|
||||
ON order_items (fulfillment_mode, canonical_product_id)
|
||||
INCLUDE (id)
|
||||
WHERE canonical_product_id IS NOT NULL;
|
||||
|
||||
COMMENT ON INDEX idx_order_items_fulfillment_product IS
|
||||
'按履约方式 + 商品维度筛选的覆盖索引(2026-10-30 财务同步 15s 超时修复)';
|
||||
@@ -0,0 +1,51 @@
|
||||
-- 111_worker_risk_bans.sql
|
||||
--
|
||||
-- 后台「接单平台 → 风控」新增**手动封禁**能力(IP / 账号)。
|
||||
--
|
||||
-- 背景(2026-10-30):大厅被一个无 UA、无 referer 的脚本按 pageSize=100 轮询,
|
||||
-- 10 分钟 238 次请求占全站 21%,全部 401 + 324 次 404 探测。原有风控只会按阈值
|
||||
-- 自动升级(二级惩罚 → 三级封号),运营没有任何"现在就把它挡住"的手段;
|
||||
-- 而内存里的惩罚状态进程重启即清零,关不住跨容器重建的刷子。
|
||||
--
|
||||
-- 设计取舍:
|
||||
-- · 一行 = 一条生效中的封禁;撤销走 revoked_at,不物理删除,便于事后追溯。
|
||||
-- · expires_at 为 NULL 表示永久;中间件每次只加载「未撤销且未过期」的条目。
|
||||
-- · target_type='ip' 时 target_value 就是 IP;'worker' 时 target_value 是账号 id 字符串,
|
||||
-- worker_id 冗余存引用,账号被删时整条封禁一起删除(ON DELETE CASCADE)。
|
||||
-- · 同类型同目标只允许一条生效中的记录(部分唯一索引),避免列表里出现重复行。
|
||||
-- · 被拒请求不落库,拦截只读进程内缓存;写入时才落这张表(低频)。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS worker_risk_bans (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
target_type TEXT NOT NULL CHECK (target_type IN ('ip', 'worker')),
|
||||
target_value TEXT NOT NULL,
|
||||
worker_id BIGINT REFERENCES worker_users(id) ON DELETE CASCADE,
|
||||
worker_username TEXT NOT NULL DEFAULT '',
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
-- NULL = 永久封禁
|
||||
expires_at TIMESTAMPTZ,
|
||||
created_by BIGINT,
|
||||
created_by_username TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
revoked_at TIMESTAMPTZ,
|
||||
revoked_by_username TEXT NOT NULL DEFAULT '',
|
||||
revoke_reason TEXT NOT NULL DEFAULT '',
|
||||
CONSTRAINT worker_risk_bans_target_check CHECK (
|
||||
(target_type = 'worker' AND worker_id IS NOT NULL)
|
||||
OR (target_type = 'ip' AND worker_id IS NULL)
|
||||
)
|
||||
);
|
||||
|
||||
-- 同一目标只允许一条生效中的封禁
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_worker_risk_bans_active
|
||||
ON worker_risk_bans (target_type, target_value)
|
||||
WHERE revoked_at IS NULL;
|
||||
|
||||
-- 列表按时间倒序分页
|
||||
CREATE INDEX IF NOT EXISTS idx_worker_risk_bans_created
|
||||
ON worker_risk_bans (created_at DESC, id DESC);
|
||||
|
||||
-- 中间件冷启动时只捞「未撤销」的条目,再过一遍过期时间
|
||||
CREATE INDEX IF NOT EXISTS idx_worker_risk_bans_active_expiry
|
||||
ON worker_risk_bans (expires_at)
|
||||
WHERE revoked_at IS NULL;
|
||||
Reference in New Issue
Block a user