"""risk engine 集成冒烟(B4 · 规则编排 + AML 组合 + L3/审计/推送贯通)。 引擎输入为已落库交易;当日流水经 sqlite core_trade 提供给规则层。 RISK-004 窗口以 trade["traded_at"] 为事件时点(幂等重放口径)。 """ from datetime import datetime from decimal import Decimal import pytest from sqlalchemy import text from _ddl import create_sqlite_engine from app.repository.core_ro import CoreReadOnlyRepository from app.repository.risk_repository import RiskRepository from app.service.risk import alert_service from app.service.risk.engine import on_customer_created, on_customer_updated, process_trade_event from app.service.risk.profile_l3 import AML_PENDING_TAG from app.service.risk.scoring import recompute_customer_score class FakePublisher: def __init__(self): self.messages = [] self.deletes = [] def publish(self, channel, payload): self.messages.append((channel, payload)) def delete(self, *keys): self.deletes.append(keys) @pytest.fixture() def env(): engine = create_sqlite_engine() # DDL 单一事实源(B4 评审 P3-12) with engine.begin() as conn: conn.execute( text( """ INSERT INTO core_customer (customer_id, display_name, age, is_active) VALUES ('C1', '张某某', 40, 1), ('C2', '李四', 35, 1) """ ) ) conn.execute( text( "INSERT INTO core_product (product_id, product_name, min_risk_code, product_type)" " VALUES ('P1', '测试混合基金', 'R3', 'mixed')" ) ) conn.execute( text( "INSERT INTO risk_aml_list (list_id, list_type, full_name, match_threshold, source, list_version, is_active)" " VALUES ('PEP-1', 'pep', '李四', 0.85, 'mock', 'v1', 1)" ) ) core = CoreReadOnlyRepository(engine=engine) repo = RiskRepository(engine=engine) pub = FakePublisher() alert_service.set_publisher(pub) yield core, repo, pub, engine alert_service.set_publisher(None) engine.dispose() def _trade(trade_id, amount="600000", customer="C1", ttype="subscribe", at=datetime(2026, 9, 6, 14, 0, 0)): return { "trade_id": trade_id, "customer_id": customer, "product_id": "P1", "trade_type": ttype, "amount": Decimal(amount), "trade_status": "confirmed", "traded_at": at, } def _seed_trade(conn, trade_id, amount, customer="C1", ttype="subscribe", at=datetime(2026, 9, 6, 14, 0, 0)): conn.execute( text( "INSERT INTO core_trade (trade_id, customer_id, product_id, trade_type, amount," " trade_status, traded_at) VALUES (:tid, :cid, 'P1', :tt, :amt, 'confirmed', :at)" ), {"tid": trade_id, "cid": customer, "tt": ttype, "amt": amount, "at": at}, ) def _counts(engine, table, where="1=1"): with engine.connect() as conn: return conn.execute(text(f"SELECT COUNT(*) FROM {table} WHERE {where}")).scalar_one() def test_large_amount_triggers_alert_l3_and_push(env): core, repo, pub, engine = env with engine.begin() as conn: _seed_trade(conn, "T1", "600000") result = process_trade_event(_trade("T1", "600000"), core_ro=core, risk_repo=repo) # 单笔 60 万同时命中 RISK-001(单笔)与 RISK-002(当日累计含本笔),聚合一张单 assert result["triggered_rules"] == ["RISK-001", "RISK-002"] assert len(result["alert_ids"]) == 1 and result["aml_hit"] is False alert = repo.get_alert(result["alert_ids"][0]) assert alert["alert_type"] == "large_amount" and alert["risk_score"] == 70 assert _counts(engine, "customer_profile_l3", "customer_id='C1' AND monitor_tier='watch'") == 1 assert _counts(engine, "audit_log", "decision='alert_created'") == 1 (channel, payload), = pub.messages assert channel == "risk:pub:alert" and payload["risk_score"] == 70 def test_small_trade_passes_without_alert(env): core, repo, pub, engine = env with engine.begin() as conn: _seed_trade(conn, "T1", "1000") result = process_trade_event(_trade("T1", "1000"), core_ro=core, risk_repo=repo) assert result == {"triggered_rules": [], "alert_ids": [], "aml_hit": False} assert _counts(engine, "risk_alert") == 0 assert _counts(engine, "audit_log", "decision='pass'") == 1 assert _counts(engine, "customer_profile_l3") == 0 assert pub.messages == [] def test_aml_and_event_rule_both_fire(env): """命中名单客户发起大额 → 事件单 + aml 独立单、L3 high、紧急推送含 compliance。""" core, repo, pub, engine = env with engine.begin() as conn: _seed_trade(conn, "T1", "600000", customer="C2") result = process_trade_event(_trade("T1", "600000", customer="C2"), core_ro=core, risk_repo=repo) assert result["aml_hit"] is True assert result["triggered_rules"] == ["RISK-001", "RISK-002"] # 含本笔累计 assert len(result["alert_ids"]) == 2 types = {a["alert_type"] for a in (repo.get_alert(aid) for aid in result["alert_ids"])} assert types == {"large_amount", "aml"} l3 = repo.get_l3("C2") assert l3["monitor_tier"] == "high" and AML_PENDING_TAG in l3["monitor_tags"] assert l3["risk_score"] is None # score 一期不写(P3-4 口径) assert len(pub.messages) == 2 (_, aml_payload), = [m for m in pub.messages if m[1]["alert_type"] == "aml"] assert "compliance" in aml_payload["notify_role"] # FR-4 payload 完整性(B4 评审 P1-1):客户上下文 = L0 摘要 + 近 30 天统计 ev_alert = next( a for a in (repo.get_alert(aid) for aid in result["alert_ids"]) if a["alert_type"] == "large_amount" ) ctx = ev_alert["payload"]["customer_context"] assert ctx["l0"]["display_name"] == "李四" and ctx["l0"]["age"] == 35 assert ctx["trades_30d"]["count"] >= 1 def test_second_trade_same_day_appends_to_same_alert(env): """B4 评审 P2-3:同日第二笔 → 追加同一事件单,triggered_rules/risk_score/L3 联动。""" core, repo, _, engine = env with engine.begin() as conn: _seed_trade(conn, "T1", "600000") _seed_trade(conn, "T2", "200000", at=datetime(2026, 9, 6, 14, 1, 0)) r1 = process_trade_event(_trade("T1", "600000"), core_ro=core, risk_repo=repo) r2 = process_trade_event( _trade("T2", "200000", at=datetime(2026, 9, 6, 14, 1, 0)), core_ro=core, risk_repo=repo ) assert r2["alert_ids"] == [r1["alert_ids"][0]] # 同日仅一张事件类单 alert = repo.get_alert(r1["alert_ids"][0]) assert len(alert["payload"]["events"]) == 2 assert alert["triggered_rules"] == ["RISK-001", "RISK-002"] # T2 仅命中累计,追加合并 assert alert["risk_score"] == 70 # 取 max assert _counts(engine, "audit_log", "decision='alert_appended'") == 1 assert repo.get_l3("C1")["last_alert_id"] == alert["alert_id"] # L3 联动最新 def test_probe_window_uses_trade_time_not_wall_clock(env): """RISK-004 窗口以 traded_at 为事件时点:流水时间集中即可命中,与执行时刻无关。""" core, repo, _, engine = env with engine.begin() as conn: _seed_trade(conn, "T1", "450000", at=datetime(2026, 9, 6, 13, 59, 30)) _seed_trade(conn, "T2", "450000", at=datetime(2026, 9, 6, 13, 59, 50)) _seed_trade(conn, "T3", "450000", at=datetime(2026, 9, 6, 14, 0, 10)) # 引擎契约:网关先落库再调用,本笔 T3 已在流水内(FR-1 ②③b→④) result = process_trade_event( _trade("T3", "450000", at=datetime(2026, 9, 6, 14, 0, 10)), core_ro=core, risk_repo=repo, ) assert "RISK-004" in result["triggered_rules"] alert = repo.get_alert(result["alert_ids"][0]) assert alert["alert_type"] == "pattern" and alert["risk_score"] == 80 def test_convert_type_does_not_trigger_rules(env): """convert 不落库也不会进事件线(引擎防御过滤,PRD FR-1)。""" core, repo, pub, engine = env with engine.begin() as conn: _seed_trade(conn, "T0", "600000", ttype="convert") result = process_trade_event(_trade("T1", "600000", ttype="convert"), core_ro=core, risk_repo=repo) assert result["triggered_rules"] == [] and result["aml_hit"] is False assert _counts(engine, "risk_alert") == 0 assert _counts(engine, "audit_log", "decision='pass'") == 1 assert pub.messages == [] def test_customer_event_hooks_noop(): assert on_customer_created("C1") is None assert on_customer_updated("C1") is None def test_scoring_placeholder_raises(): with pytest.raises(NotImplementedError): recompute_customer_score("C1")