Files
XingHuo/app/service/risk/engine.py
T

101 lines
4.1 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.
"""风控事件引擎(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)。"""