"""aml_service 单测(B4 · 归一化/相似度/行阈值优先/scan_all 编排)。 sqlite StaticPool 内存库;名单阈值边界用可控 threshold 值驱动(1.0 严格、 0.5 宽松),避免依赖 difflib 具体分值。 """ from datetime import datetime 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.aml_service import match_customer, match_name, normalize_name, scan_all, similarity from app.service.risk.profile_l3 import AML_PENDING_TAG class FakePublisher: def __init__(self): self.messages = [] self.deletes = [] def publish(self, channel, payload): assert isinstance(payload, dict) 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: # 名单:默认阈值 0.85 两行、严格 1.0 一行、行阈值覆盖(0.5/0.99 同名对)一停用行 conn.execute( text( """ INSERT INTO risk_aml_list (list_id, list_type, full_name, match_threshold, source, list_version, is_active) VALUES ('SAN-1', 'sanction', '张某某', 0.85, 'mock', 'v1', 1), ('PEP-1', 'pep', '李四', 0.85, 'mock', 'v1', 1), ('EXACT-1', 'sanction', '孙七', 1.0, 'mock', 'v1', 1), ('WIDE-1', 'terror', '张三', 0.5, 'mock', 'v1', 1), ('STRICT-1', 'sanction', '张三', 0.99, 'mock', 'v1', 1), ('OFF-1', 'pep', '王五', 0.85, 'mock', 'v1', 0) """ ) ) conn.execute( text( """ INSERT INTO core_customer (customer_id, display_name, age, is_active) VALUES ('C1', '张某某', 40, 1), ('C2', '李四', 35, 1), ('C3', '赵六六', 28, 1), ('C4', '王五', 50, 1) """ ) ) core = CoreReadOnlyRepository(engine=engine) repo = RiskRepository(engine=engine) pub = FakePublisher() alert_service.set_publisher(pub) yield core, repo, pub alert_service.set_publisher(None) engine.dispose() def test_normalize_name_strips_space_and_case(): assert normalize_name(" Ab C ") == "abc" assert normalize_name("ABC") == normalize_name("abc") def test_similarity_identical_is_one(): assert similarity(normalize_name("张某某"), normalize_name("张某某")) == 1.0 def test_exact_name_hit_with_default_threshold(env): _, repo, _ = env hits = match_name("张某某", repo.list_active_aml_entries()) assert [h["list_id"] for h in hits] == ["SAN-1"] assert hits[0]["similarity"] == 1.0 assert hits[0]["list_type"] == "sanction" assert hits[0]["list_version"] == "v1" def test_threshold_boundary_is_inclusive(env): """ratio ≥ threshold 命中:1.0 严格名单同名命中、近似名不命中。""" _, repo, _ = env entries = repo.list_active_aml_entries() assert [h["list_id"] for h in match_name("孙七", entries)] == ["EXACT-1"] assert match_name("孙七七", entries) == [] # ratio < 1.0 不命中 def test_row_threshold_overrides_default(env): """行阈值优先:0.5 宽松行命中、0.99 严格行不命中(同名对)。""" _, repo, _ = env hits = match_name("张三三", repo.list_active_aml_entries()) assert [h["list_id"] for h in hits] == ["WIDE-1"] def test_inactive_entry_excluded(env): _, repo, _ = env assert match_name("王五", repo.list_active_aml_entries()) == [] # OFF-1 停用 def test_match_customer_missing_returns_empty(env): core, repo, _ = env assert match_customer("C999", core_ro=core, risk_repo=repo) == [] def test_match_customer_by_id(env): core, repo, _ = env hits = match_customer("C2", core_ro=core, risk_repo=repo) assert [h["list_id"] for h in hits] == ["PEP-1"] def test_scan_all_creates_alert_and_l3(env): core, repo, pub = env summary = scan_all(core_ro=core, risk_repo=repo) assert summary["scanned"] == 4 assert summary["hit_customers"] == 2 # C1 张某某、C2 李四(C3 赵六六 不命中任一) assert len(summary["alerts"]) == 2 with core._engine.connect() as conn: types = [ r[0] for r in conn.execute(text("SELECT alert_type FROM risk_alert")).fetchall() ] tiers = dict( conn.execute( text("SELECT customer_id, monitor_tier FROM customer_profile_l3") ).fetchall() ) assert types == ["aml", "aml"] assert tiers == {"C1": "high", "C2": "high"} payload = repo.get_alert(summary["alerts"][0])["payload"] assert payload["events"][0]["trigger"] == "scan" assert payload["events"][0]["matches"][0]["list_id"] in ("SAN-1", "PEP-1") tags = repo.get_l3("C1")["monitor_tags"] assert AML_PENDING_TAG in tags assert len(pub.messages) == 2 # 每个命中客户一次紧急推送 def test_multi_entry_match_merges_single_alert(env): """B4 评审 P2-4:一客户命中多条名单 → 仅一张 aml 单,matches 合并全量进 payload。""" core, repo, pub = env with core._engine.begin() as conn: conn.execute( text( "INSERT INTO risk_aml_list (list_id, list_type, full_name, match_threshold," " source, list_version, is_active) VALUES" " ('DUP-1', 'sanction', '赵六六', 0.85, 'mock', 'v1', 1)," " ('DUP-2', 'pep', '赵六六', 0.85, 'mock', 'v1', 1)" ) ) summary = scan_all(core_ro=core, risk_repo=repo) assert summary["hit_customers"] == 3 # C1、C2(原种子命中)+ C3(双名单) c3_ids = [ aid for aid in summary["alerts"] if repo.get_alert(aid)["customer_id"] == "C3" ] assert len(c3_ids) == 1 # 单事件单张 matches = repo.get_alert(c3_ids[0])["payload"]["events"][0]["matches"] assert len(matches) == 2 assert {m["list_type"] for m in matches} == {"sanction", "pep"} assert len(pub.messages) == 3 # 每命中客户一次推送 def test_scan_all_no_hit_creates_nothing(env): core, repo, pub = env with core._engine.begin() as conn: conn.execute(text("UPDATE risk_aml_list SET is_active = 0")) summary = scan_all(core_ro=core, risk_repo=repo) assert summary == {"scanned": 4, "hit_customers": 0, "alerts": [], "skipped_existing": []} with core._engine.connect() as conn: assert conn.execute(text("SELECT COUNT(*) FROM risk_alert")).scalar_one() == 0 assert pub.messages == [] def test_scan_all_idempotent_same_day(env): """B9b 核查单②(B6 评审 P3-6):同日重扫不重复出单,重复点击防护。""" core, repo, pub = env first = scan_all(core_ro=core, risk_repo=repo) assert len(first["alerts"]) == 2 and first["skipped_existing"] == [] second = scan_all(core_ro=core, risk_repo=repo) assert second["alerts"] == [] assert sorted(second["skipped_existing"]) == sorted(first["alerts"]) assert len(pub.messages) == 2 # 重扫无新推送(出单时的推送各一次) def test_scan_skips_disposed_alert_same_day(env): """幂等不限 status(评审 P3-3):scan 出单→人工处置→再 scan 仍 skip, 防止 SQL 回归成 status='pending_review' 过滤后重扫对已处置客户重复出单。""" from app.service.risk.alert_service import handle_alert core, repo, pub = env first = scan_all(core_ro=core, risk_repo=repo) aid = first["alerts"][0] handle_alert(aid, "confirmed_normal", "STAFF-R1", risk_repo=repo) second = scan_all(core_ro=core, risk_repo=repo) assert second["alerts"] == [] assert sorted(second["skipped_existing"]) == sorted(first["alerts"]) assert len(pub.messages) == 2 def test_scan_skips_customer_already_alerted_by_trade(env): """当日交易触发已出 aml 单的客户,scan 不再重复出单(同日命中留痕一次)。""" from decimal import Decimal from app.service.risk.engine import process_trade_event core, repo, pub = env with core._engine.begin() as conn: conn.execute( text( "INSERT INTO core_trade (trade_id, customer_id, product_id, trade_type," " amount, trade_status, traded_at)" " VALUES ('T-SCAN-1', 'C2', 'P1', 'subscribe', 1000, 'confirmed', :at)" ), {"at": datetime(2026, 9, 7, 10, 0, 0)}, ) result = process_trade_event( { "trade_id": "T-SCAN-1", "customer_id": "C2", "product_id": "P1", "trade_type": "subscribe", "amount": Decimal("1000"), "trade_status": "confirmed", "traded_at": datetime(2026, 9, 7, 10, 0, 0), }, core_ro=core, risk_repo=repo, ) assert result["aml_hit"] is True # 交易触发已出 C2 的 aml 单 summary = scan_all(core_ro=core, risk_repo=repo) assert len(summary["alerts"]) == 1 # 仅 C1(无当日 aml 单) assert summary["skipped_existing"] == [ aid for aid in result["alert_ids"] if repo.get_alert(aid)["alert_type"] == "aml" ]