236 lines
9.2 KiB
Python
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"
|
|
]
|