Files
XingHuo/scripts/demo/rebuild_alerts.py
T

114 lines
4.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""按 trade_id 幂等重放风控引擎(B9a 补偿 · trade_gateway engine_error 场景)。
背景(PRD FR-1 / 架构 §5.3):网关 INSERT core_trade 成功后 process_trade_event
异常时交易已成立——响应带 engine_error=true、审计 decision='risk_engine_error',
预警缺失。用本脚本按 trade_id 补放引擎(也可用于人工补放任何一笔已落库交易)。
幂等:trade_id 已存在于任一 risk_alert.payload(含 aml 单与已处置单)即跳过,
不重复出单、不重复 append 事件。**勿并行运行多个本脚本实例**(聚合锁为进程内
锁,跨进程并发重放同一 trade_id 无防护)。
可复现性与局限:
- RISK-004/005 规则窗口以 core_trade.traded_at 为事件时点,不受脚本执行时刻影响;
聚合锚点(同日 pending 单查找)按执行日——跨日补放历史交易时事件并入执行日
活跃单(events 明细含原始 traded_at 可追溯),即时补偿(交易日=执行日)不受影响。
- 引擎编排非原子:若当初 engine_error 发生在首张预警单落库之后(aml 单/审计/L3
部分缺失的中间态),该笔会被幂等跳过,脚本检测到 risk_engine_error 审计时输出
warning 提示人工核对,不做选择性补齐。
用法:
python scripts/demo/rebuild_alerts.py TRD-20260907-AB12CD34 [更多 trade_id ...]
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from app.repository.core_ro import CoreReadOnlyRepository # noqa: E402
from app.repository.risk_repository import RiskRepository # noqa: E402
from app.service.risk.engine import process_trade_event # noqa: E402
def rebuild_trade(
trade_id: str,
core: CoreReadOnlyRepository,
repo: RiskRepository,
) -> dict[str, Any]:
"""重放单笔已落库交易(CLI 与单测共用入口)。
返回 {"state": "rebuilt"|"skipped"|"missing", "trade_id", "alert_ids", ...}:
rebuilt 附 triggered_rules/aml_hit;skipped 附已入的预警单 alert_ids,当初
engine_error 中断过的笔附 warning(部分失败中间态,人工核对);missing 表示
core_trade 无此流水(未做任何写操作)。
"""
trade = core.get_trade_by_id(trade_id)
if trade is None:
return {"state": "missing", "trade_id": trade_id, "alert_ids": []}
existing = repo.find_alerts_by_trade(trade_id)
if existing:
out = {
"state": "skipped",
"trade_id": trade_id,
"alert_ids": [row["alert_id"] for row in existing],
}
if repo.has_engine_error_audit(trade_id):
out["warning"] = (
"该笔曾引擎异常中断(risk_engine_error):已入单可能不完整"
"(aml 单/审计/L3 或缺失),请人工核对预警台账与 audit_log"
)
return out
result = process_trade_event(trade, core_ro=core, risk_repo=repo)
return {
"state": "rebuilt",
"trade_id": trade_id,
"alert_ids": result["alert_ids"],
"triggered_rules": result["triggered_rules"],
"aml_hit": result["aml_hit"],
}
def main() -> None:
parser = argparse.ArgumentParser(description="按 trade_id 幂等重放风控引擎(引擎异常补偿)")
parser.add_argument("trade_ids", nargs="+", help="core_trade 中的 trade_id,可一次多笔")
args = parser.parse_args()
core = CoreReadOnlyRepository()
repo = RiskRepository()
# 连接自检(惰性引擎构造不连库;零写入探针,兼验证双库可达)
try:
core.get_trade_by_id("__connectivity_probe__")
repo.find_alerts_by_trade("__connectivity_probe__")
except Exception as exc:
print(f"MySQL 不可达:{exc}", file=sys.stderr)
print("请确认本机 MySQL 服务已启动(见 FLOW §0 本机状态)。", file=sys.stderr)
raise SystemExit(1)
missing = 0
for tid in args.trade_ids:
out = rebuild_trade(tid, core, repo)
if out["state"] == "rebuilt":
rules = ",".join(out["triggered_rules"]) or "-"
print(f"[rebuilt] {tid}: rules={rules} alerts={out['alert_ids']} aml={out['aml_hit']}")
elif out["state"] == "skipped":
print(f"[skipped] {tid}: 已入预警单 {out['alert_ids']}(幂等跳过)")
if out.get("warning"):
print(f"[warning] {tid}: {out['warning']}", file=sys.stderr)
else:
print(f"[missing] {tid}: core_trade 无此流水", file=sys.stderr)
missing += 1
if missing:
raise SystemExit(f"{missing}/{len(args.trade_ids)} 笔 trade_id 未找到")
if __name__ == "__main__":
main()