238 lines
8.6 KiB
Python
238 lines
8.6 KiB
Python
"""risk_repository 单测(A3 · sqlite 内存库;JSON/ENUM 列用 TEXT 兼容)。
|
|
|
|
upsert_l3 的 ON DUPLICATE KEY UPDATE 更新分支为 MySQL 方言,sqlite 仅测插入路径,
|
|
更新分支由 B9b 演示走查做手工 SQL 对照。
|
|
"""
|
|
|
|
import json
|
|
from datetime import datetime
|
|
from decimal import Decimal
|
|
|
|
import pytest
|
|
from sqlalchemy import create_engine, text
|
|
|
|
from app.repository.risk_repository import RiskRepository
|
|
|
|
|
|
@pytest.fixture()
|
|
def repo():
|
|
engine = create_engine("sqlite:///:memory:")
|
|
with engine.begin() as conn:
|
|
conn.execute(
|
|
text(
|
|
"""
|
|
CREATE TABLE risk_alert (
|
|
alert_id VARCHAR(64) PRIMARY KEY,
|
|
trace_id VARCHAR(64),
|
|
customer_id VARCHAR(64),
|
|
trade_id VARCHAR(64),
|
|
alert_type VARCHAR(16),
|
|
triggered_rules TEXT,
|
|
risk_score INTEGER,
|
|
status VARCHAR(24) DEFAULT 'pending_review',
|
|
payload TEXT,
|
|
handler_id VARCHAR(64),
|
|
handler_result VARCHAR(64),
|
|
handler_comment VARCHAR(512),
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
handled_at TIMESTAMP
|
|
)
|
|
"""
|
|
)
|
|
)
|
|
conn.execute(
|
|
text(
|
|
"""
|
|
CREATE TABLE customer_profile_l3 (
|
|
customer_id VARCHAR(64) PRIMARY KEY,
|
|
monitor_tier VARCHAR(8) DEFAULT 'normal',
|
|
risk_score INTEGER,
|
|
score_dimensions TEXT,
|
|
monitor_tags TEXT,
|
|
last_alert_id VARCHAR(64),
|
|
computed_at TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
"""
|
|
)
|
|
)
|
|
conn.execute(
|
|
text(
|
|
"""
|
|
CREATE TABLE risk_aml_list (
|
|
id INTEGER PRIMARY KEY,
|
|
list_id VARCHAR(64),
|
|
list_type VARCHAR(8),
|
|
full_name VARCHAR(128),
|
|
match_threshold NUMERIC(3,2) DEFAULT 0.85,
|
|
is_active INTEGER DEFAULT 1
|
|
)
|
|
"""
|
|
)
|
|
)
|
|
|
|
def make_alert(alert_id, alert_type="large_amount", cid="C1", rules=None, score=70):
|
|
return {
|
|
"alert_id": alert_id,
|
|
"trace_id": "trc-test",
|
|
"customer_id": cid,
|
|
"trade_id": "TRD-1",
|
|
"alert_type": alert_type,
|
|
"triggered_rules": rules or ["RISK-001"],
|
|
"risk_score": score,
|
|
"status": "pending_review",
|
|
"payload": {"product_id": "P1", "events": []},
|
|
}
|
|
|
|
yield RiskRepository(engine=engine), engine, make_alert
|
|
engine.dispose()
|
|
|
|
|
|
def test_insert_and_get_alert(repo):
|
|
r, _, make_alert = repo
|
|
r.insert_alert(make_alert("ALT-1"))
|
|
got = r.get_alert("ALT-1")
|
|
assert got["alert_type"] == "large_amount"
|
|
assert got["triggered_rules"] == ["RISK-001"]
|
|
assert got["payload"]["product_id"] == "P1"
|
|
assert got["status"] == "pending_review"
|
|
|
|
|
|
def test_find_pending_event_alert(repo):
|
|
r, _, make_alert = repo
|
|
r.insert_alert(make_alert("ALT-E", alert_type="large_amount"))
|
|
found = r.find_pending_event_alert("C1", datetime(2026, 1, 1))
|
|
assert found is not None and found["alert_id"] == "ALT-E"
|
|
# suitability 类型不在事件类查询范围
|
|
r.insert_alert(make_alert("ALT-S", alert_type="suitability"))
|
|
assert r.find_pending_event_alert("C1", datetime(2026, 1, 1))["alert_id"] == "ALT-E"
|
|
|
|
|
|
def test_find_pending_suitability_alert_matches_product(repo):
|
|
r, _, make_alert = repo
|
|
r.insert_alert(make_alert("ALT-S", alert_type="suitability"))
|
|
assert r.find_pending_suitability_alert("C1", "P1", datetime(2026, 1, 1)) is not None
|
|
assert r.find_pending_suitability_alert("C1", "P2", datetime(2026, 1, 1)) is None
|
|
|
|
|
|
def test_append_alert_event_merges(repo):
|
|
r, _, make_alert = repo
|
|
r.insert_alert(make_alert("ALT-1", rules=["RISK-001"], score=70))
|
|
r.append_alert_event(
|
|
"ALT-1",
|
|
event={"trade_id": "TRD-2", "amount": "600000"},
|
|
triggered_rules=["RISK-002", "RISK-001"],
|
|
risk_score=50,
|
|
alert_type="large_amount",
|
|
)
|
|
got = r.get_alert("ALT-1")
|
|
assert got["triggered_rules"] == ["RISK-001", "RISK-002"] # 合并未重复
|
|
assert got["risk_score"] == 70 # 取 max,不被 50 降
|
|
assert len(got["payload"]["events"]) == 1
|
|
assert got["payload"]["events"][0]["trade_id"] == "TRD-2"
|
|
# 再追加一笔,score 升到 90
|
|
r.append_alert_event("ALT-1", {"trade_id": "TRD-3"}, ["RISK-005"], 80, "pattern")
|
|
got = r.get_alert("ALT-1")
|
|
assert got["risk_score"] == 80
|
|
assert got["alert_type"] == "pattern" # 类型随最高分规则更新
|
|
assert len(got["payload"]["events"]) == 2
|
|
|
|
|
|
def test_append_missing_alert_raises(repo):
|
|
r, _, _ = repo
|
|
with pytest.raises(ValueError):
|
|
r.append_alert_event("NOPE", {}, ["RISK-001"], 70, "large_amount")
|
|
|
|
|
|
def test_update_alert_status_state_machine(repo):
|
|
r, _, make_alert = repo
|
|
r.insert_alert(make_alert("ALT-1"))
|
|
assert r.update_alert_status("ALT-1", "confirmed_suspicious", "STAFF-30001", "确认可疑") is True
|
|
got = r.get_alert("ALT-1")
|
|
assert got["status"] == "confirmed_suspicious"
|
|
assert got["handler_id"] == "STAFF-30001"
|
|
assert got["handled_at"] is not None
|
|
# 已处置不可再改(状态机只允许 pending_review → 三态)
|
|
assert r.update_alert_status("ALT-1", "confirmed_normal", "STAFF-30002", None) is False
|
|
|
|
|
|
def test_list_alerts_filters_and_pages(repo):
|
|
r, _, make_alert = repo
|
|
for i in range(1, 4):
|
|
r.insert_alert(make_alert(f"ALT-{i}", alert_type="large_amount" if i < 3 else "aml"))
|
|
items, total = r.list_alerts(alert_type="large_amount", page=1, page_size=20)
|
|
assert total == 2 and len(items) == 2
|
|
items, total = r.list_alerts(alert_type="aml")
|
|
assert total == 1 and items[0]["alert_id"] == "ALT-3"
|
|
items, total = r.list_alerts(customer_id="C1")
|
|
assert total == 3
|
|
|
|
|
|
def test_insert_suitability_log(repo):
|
|
r, engine, _ = repo
|
|
with engine.begin() as conn:
|
|
conn.execute(
|
|
text(
|
|
"CREATE TABLE risk_suitability_log ("
|
|
"id INTEGER PRIMARY KEY, trace_id VARCHAR(64), customer_id VARCHAR(64),"
|
|
" product_id VARCHAR(64), customer_risk_level VARCHAR(2),"
|
|
" product_risk_level VARCHAR(2), is_matched INTEGER, is_blocked INTEGER,"
|
|
" block_reason VARCHAR(512), request_ref VARCHAR(64), profile_l1_version INTEGER,"
|
|
" created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)"
|
|
)
|
|
)
|
|
log = {
|
|
"trace_id": "trc-x",
|
|
"customer_id": "C1",
|
|
"product_id": "P1",
|
|
"customer_risk_level": "C1",
|
|
"product_risk_level": "R4",
|
|
"is_matched": 0,
|
|
"is_blocked": 1,
|
|
"block_reason": "SUIT-001",
|
|
"request_ref": None,
|
|
"profile_l1_version": None,
|
|
}
|
|
r.insert_suitability_log(log)
|
|
row = engine.connect().execute(
|
|
text("SELECT customer_risk_level, is_blocked FROM risk_suitability_log")
|
|
).first()
|
|
assert row[0] == "C1" and row[1] == 1
|
|
|
|
|
|
def test_l3_insert_and_get(repo):
|
|
r, _, _ = repo
|
|
r.insert_l3(
|
|
"C1", "high", 95, {"aml": True}, ["aml_hit_pending_review"], "ALT-AML-1", datetime(2026, 9, 6)
|
|
)
|
|
got = r.get_l3("C1")
|
|
assert got["monitor_tier"] == "high"
|
|
assert got["monitor_tags"] == ["aml_hit_pending_review"]
|
|
assert got["score_dimensions"] == {"aml": True}
|
|
|
|
|
|
def test_l3_update_after_insert(repo):
|
|
r, _, _ = repo
|
|
r.insert_l3("C1", "watch", 80, {}, ["pattern"], "ALT-1", datetime(2026, 9, 6))
|
|
r.update_l3("C1", "high", 95, {"aml": True}, ["pattern", "aml_hit_pending_review"], "ALT-2",
|
|
datetime(2026, 9, 6, 12))
|
|
got = r.get_l3("C1")
|
|
assert got["monitor_tier"] == "high"
|
|
assert got["monitor_tags"] == ["pattern", "aml_hit_pending_review"]
|
|
assert got["last_alert_id"] == "ALT-2"
|
|
|
|
|
|
def test_list_active_aml_entries(repo):
|
|
r, engine, _ = repo
|
|
with engine.begin() as conn:
|
|
conn.execute(
|
|
text(
|
|
"INSERT INTO risk_aml_list (list_id, list_type, full_name, is_active) VALUES"
|
|
"('AML-1', 'sanction', '客户·赵**', 1),"
|
|
"('AML-2', 'pep', '客户·已停**', 0)"
|
|
)
|
|
)
|
|
entries = r.list_active_aml_entries()
|
|
assert len(entries) == 1
|
|
assert entries[0]["full_name"] == "客户·赵**"
|