"""风控事件引擎(B4 · 架构 §3.1 ④ / PRD FR-3)。 编排(网关 B5 在 core_trade 落库后**同步调用**,不用消息队列): 当日流水上下文(core_ro)→ RISK-001~005 纯函数 → AML 姓名匹配 → 预警落库 (alert_service:聚合/去重/审计/推送)→ L3 upsert(profile_l3)。 审计 pass(未命中)与命中审计均由 alert_service 完成,本层不重复落审计。 RISK-004 窗口以 trade["traded_at"] 为事件时点(非墙钟 now):rebuild_alerts 幂等重放可复现窗口判定,演示脚本不受执行时刻影响。 客户事件钩子 on_customer_created/on_customer_updated 为 FR-5 预留(本期 no-op, 模拟环境无开户流程)。 """ from __future__ import annotations from datetime import datetime from typing import Any from app.repository.core_ro import CoreReadOnlyRepository from app.repository.risk_repository import RiskRepository from app.service.risk.alert_service import record_aml_alert, record_trade_alerts from app.service.risk.aml_service import match_customer from app.service.risk.profile_l3 import upsert_profile_l3 from app.service.risk.rules import RiskThresholds, run_rules def _as_datetime(value: Any) -> datetime: if isinstance(value, datetime): return value if isinstance(value, str): return datetime.fromisoformat(value) raise TypeError(f"traded_at must be datetime/str, got {type(value)!r}") def _normalize_trades(trades: list[dict[str, Any]]) -> list[dict[str, Any]]: """驱动差异防御:sqlite text 查询返回 str 时间,统一转 datetime(MySQL 驱动本就返回 datetime)。""" for t in trades: if isinstance(t.get("traded_at"), str): t["traded_at"] = datetime.fromisoformat(t["traded_at"]) return trades def process_trade_event( trade: dict[str, Any], core_ro: CoreReadOnlyRepository | None = None, risk_repo: RiskRepository | None = None, thresholds: RiskThresholds | None = None, ) -> dict[str, Any]: """处理一笔已落库交易(PRD FR-1 ②③b 之后)。 返回 {"triggered_rules": [...], "alert_ids": [...], "aml_hit": bool}, 网关据此拼装响应(FR-1 ⑤:blocked=false + trade_id + 触发规则列表)。 """ core = core_ro or CoreReadOnlyRepository() repo = risk_repo or RiskRepository() th = thresholds or RiskThresholds.from_settings() event_at = _as_datetime(trade["traded_at"]) day_start = event_at.replace(hour=0, minute=0, second=0, microsecond=0) trades = _normalize_trades(core.list_trades(trade["customer_id"], since=day_start, limit=1000)) result: dict[str, Any] = {"triggered_rules": [], "alert_ids": [], "aml_hit": False} hits = run_rules(trades, th, now=event_at) # 无条件走预警编排:空 hits 由 alert_service 落 pass 审计(架构 §3.1 ④ 未命中分支) alert = record_trade_alerts(trade, hits, risk_repo=repo) if hits: result["triggered_rules"] = sorted({h.rule_id for h in hits}) if alert: result["alert_ids"].append(alert["alert_id"]) best = max(hits, key=lambda h: h.risk_score) upsert_profile_l3( trade["customer_id"], best.alert_type, last_alert_id=alert["alert_id"] if alert else None, risk_repo=repo, ) aml_hits = match_customer(trade["customer_id"], core_ro=core, risk_repo=repo) if aml_hits: result["aml_hit"] = True alert = record_aml_alert( trade["customer_id"], {"trigger": "trade", "trade_id": trade.get("trade_id"), "matches": aml_hits}, risk_repo=repo, ) result["alert_ids"].append(alert["alert_id"]) upsert_profile_l3( trade["customer_id"], "aml", last_alert_id=alert["alert_id"], risk_repo=repo ) return result def on_customer_created(customer_id: str) -> None: """AML 开户触发预留(本期 no-op;模拟环境无开户流程,PRD FR-5)。""" def on_customer_updated(customer_id: str) -> None: """客户信息变更触发预留(本期 no-op;PRD FR-5)。"""