Files
XingHuo/tests/test_aml_service.py
T

236 lines
9.2 KiB
Python

"""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"
]