"""B9a 演示/运维脚本单测(开发计划 B9a · sqlite)。 rebuild_alerts:补偿重放出单并推送、幂等跳过(防重复 append/aml 重复出单)、 missing 不落库;subscribe_alerts:payload → 单行可读文本。 脚本目录非包,动态入 sys.path 后按模块名导入。 """ import sys from datetime import datetime from decimal import Decimal from pathlib import Path import pytest from sqlalchemy import text from _ddl import create_sqlite_engine DEMO_DIR = Path(__file__).resolve().parents[1] / "scripts" / "demo" sys.path.insert(0, str(DEMO_DIR)) from rebuild_alerts import rebuild_trade # noqa: E402 from subscribe_alerts import format_alert # noqa: E402 from app.repository.core_ro import CoreReadOnlyRepository from app.repository.risk_repository import RiskRepository from app.service.risk import alert_service class FakePublisher: def __init__(self): self.messages = [] def publish(self, channel, payload): self.messages.append((channel, payload)) @pytest.fixture() def env(): engine = create_sqlite_engine() 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 _seed_trade(conn, trade_id, amount, customer="C1", at=datetime(2026, 9, 6, 14, 0, 0)): """直接落 core_trade 不调引擎(engine_error 补偿场景:交易已成立、预警缺失)。""" conn.execute( text( "INSERT INTO core_trade (trade_id, customer_id, product_id, trade_type, amount," " trade_status, traded_at) VALUES (:tid, :cid, 'P1', 'subscribe', :amt, 'confirmed', :at)" ), {"tid": trade_id, "cid": customer, "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_rebuild_creates_alert_and_publishes(env): core, repo, pub, engine = env with engine.begin() as conn: _seed_trade(conn, "TRD-TEST-RB1", "600000") out = rebuild_trade("TRD-TEST-RB1", core, repo) assert out["state"] == "rebuilt" assert out["triggered_rules"] == ["RISK-001", "RISK-002"] assert len(out["alert_ids"]) == 1 and out["aml_hit"] is False alert = repo.get_alert(out["alert_ids"][0]) assert alert["status"] == "pending_review" and alert["risk_score"] == 70 (channel, _), = pub.messages assert channel == "risk:pub:alert" def test_rebuild_idempotent_skips_second_run(env): core, repo, pub, engine = env with engine.begin() as conn: _seed_trade(conn, "TRD-TEST-RB2", "600000") first = rebuild_trade("TRD-TEST-RB2", core, repo) second = rebuild_trade("TRD-TEST-RB2", core, repo) assert first["state"] == "rebuilt" and second["state"] == "skipped" assert second["alert_ids"] == first["alert_ids"] assert _counts(engine, "risk_alert") == 1 assert len(repo.get_alert(first["alert_ids"][0])["payload"]["events"]) == 1 assert len(pub.messages) == 1 # 重放不重复推送 def test_rebuild_missing_trade_touches_nothing(env): core, repo, pub, engine = env out = rebuild_trade("TRD-NO-SUCH", core, repo) assert out["state"] == "missing" and out["alert_ids"] == [] assert _counts(engine, "risk_alert") == 0 assert _counts(engine, "audit_log") == 0 assert pub.messages == [] def test_rebuild_aml_hit_then_idempotent(env): """aml 单幂等是 LIKE 检查的关键价值:record_aml_alert 本身无去重,重放防二次出单。""" core, repo, pub, engine = env with engine.begin() as conn: _seed_trade(conn, "TRD-TEST-RB3", "1000", customer="C2") first = rebuild_trade("TRD-TEST-RB3", core, repo) assert first["state"] == "rebuilt" and first["aml_hit"] is True aml_ids = [ aid for aid in first["alert_ids"] if repo.get_alert(aid)["alert_type"] == "aml" ] assert aml_ids second = rebuild_trade("TRD-TEST-RB3", core, repo) assert second["state"] == "skipped" and second["alert_ids"] == first["alert_ids"] assert _counts(engine, "risk_alert", "alert_type='aml'") == 1 def test_format_alert_renders_payload_fields(): line = format_alert( { "alert_id": "ALT-20260906-ABC", "alert_type": "aml", "customer_id_mask": "CUST-9**", "risk_score": 95, "trace_id": "tr-1", "notify_role": ["risk_officer", "compliance"], } ) for frag in ("aml", "ALT-20260906-ABC", "score=95", "CUST-9**", "tr-1", "risk_officer,compliance"): assert frag in line assert "CUST-9527" not in line # payload 只有脱敏掩码,无原始 id