187 lines
7.1 KiB
Python
187 lines
7.1 KiB
Python
"""C5 / FR-9 · RISK-007 处置时效升级:扫描判级 + 幂等 + 降噪合并 + 通知链 + payload 隔离。
|
||||
|
|
|
|||
|
|
覆盖实现方案 §6.2 C5 相关用例(普通 4h/24h、AML 1h/4h 短通道、重复扫描幂等、
|
|||
|
|
同客户多单合并一次推送、处置后退出扫描、status 全程 pending_review、
|
|||
|
|
notify_role 链 L1 含 risk_manager / L2 含 compliance、update_alert_escalation
|
|||
|
|
不碰 handler 列)。
|
|||
|
|
|
|||
|
|
环境:复用 conftest 的 sqlite_engine(StaticPool 单连接共享)+ backdated_alert
|
|||
|
|
回拨注入;RiskRepository 一律 `engine=sqlite_engine` 与注入同源。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
from datetime import datetime, timedelta
|
|||
|
|
|
|||
|
|
import pytest
|
|||
|
|
from sqlalchemy import text
|
|||
|
|
|
|||
|
|
from app.repository.risk_repository import RiskRepository
|
|||
|
|
from app.service.risk import redis_gateway
|
|||
|
|
from app.service.risk.escalation_service import (
|
|||
|
|
EscalationThresholds,
|
|||
|
|
compute_level,
|
|||
|
|
scan_and_escalate,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class FakePublisher:
|
|||
|
|
def __init__(self):
|
|||
|
|
self.messages: list = []
|
|||
|
|
|
|||
|
|
def publish(self, channel, payload):
|
|||
|
|
self.messages.append((channel, payload))
|
|||
|
|
|
|||
|
|
def delete(self, *keys):
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.fixture()
|
|||
|
|
def env(sqlite_engine, monkeypatch):
|
|||
|
|
"""注入 fake 发布器(升级通知不依赖真实 Redis)。"""
|
|||
|
|
repo = RiskRepository(engine=sqlite_engine)
|
|||
|
|
pub = FakePublisher()
|
|||
|
|
redis_gateway.set_gateway(pub)
|
|||
|
|
yield repo, pub, sqlite_engine
|
|||
|
|
redis_gateway.set_gateway(None)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _now_after(alert_created_at: datetime, hours: float) -> datetime:
|
|||
|
|
"""扫描时刻相对回拨注入时刻再后移(避免边界竞态)。"""
|
|||
|
|
return alert_created_at + timedelta(hours=hours)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------- 判级单测(compute_level) ----------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_compute_level_boundary():
|
|||
|
|
th = EscalationThresholds()
|
|||
|
|
base = datetime(2026, 9, 6, 12, 0, 0)
|
|||
|
|
|
|||
|
|
def mk(hours_ago):
|
|||
|
|
return {"alert_type": "pattern", "created_at": base - timedelta(hours=hours_ago)}
|
|||
|
|
|
|||
|
|
assert compute_level(mk(3.9), base, th) == 0
|
|||
|
|
assert compute_level(mk(4.0), base, th) == 1
|
|||
|
|
assert compute_level(mk(23.9), base, th) == 1
|
|||
|
|
assert compute_level(mk(24.0), base, th) == 2
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_compute_level_aml_short_channel():
|
|||
|
|
th = EscalationThresholds()
|
|||
|
|
base = datetime(2026, 9, 6, 12, 0, 0)
|
|||
|
|
|
|||
|
|
def mk(hours_ago):
|
|||
|
|
return {"alert_type": "aml", "created_at": base - timedelta(hours=hours_ago)}
|
|||
|
|
|
|||
|
|
assert compute_level(mk(0.9), base, th) == 0
|
|||
|
|
assert compute_level(mk(1.0), base, th) == 1
|
|||
|
|
assert compute_level(mk(3.9), base, th) == 1
|
|||
|
|
assert compute_level(mk(4.0), base, th) == 2
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------- 扫描集成 ----------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_no_escalation_below_l1(env, backdated_alert):
|
|||
|
|
repo, pub, engine = env
|
|||
|
|
backdated_alert("ALT-E-1", "C1", hours_ago=3.9)
|
|||
|
|
result = scan_and_escalate(now=datetime.now(), risk_repo=repo, thresholds=EscalationThresholds())
|
|||
|
|
assert result["escalated"] == []
|
|||
|
|
assert result["skipped"] >= 1
|
|||
|
|
alert = repo.get_alert("ALT-E-1")
|
|||
|
|
assert alert["payload"].get("escalation_level", 0) == 0
|
|||
|
|
assert alert["status"] == "pending_review"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_escalate_to_l1_at_4h(env, backdated_alert):
|
|||
|
|
repo, pub, engine = env
|
|||
|
|
backdated_alert("ALT-E-2", "C1", hours_ago=4.1)
|
|||
|
|
result = scan_and_escalate(now=datetime.now(), risk_repo=repo, thresholds=EscalationThresholds())
|
|||
|
|
assert len(result["escalated"]) == 1
|
|||
|
|
assert result["escalated"][0]["level"] == 1
|
|||
|
|
alert = repo.get_alert("ALT-E-2")
|
|||
|
|
assert alert["payload"]["escalation_level"] == 1
|
|||
|
|
assert alert["status"] == "pending_review"
|
|||
|
|
# 通知链 L1 含 risk_manager(不含 compliance)
|
|||
|
|
assert len(pub.messages) == 1
|
|||
|
|
notify = pub.messages[0][1]["notify_role"]
|
|||
|
|
assert "risk_officer" in notify and "risk_manager" in notify
|
|||
|
|
assert "compliance" not in notify
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_escalate_to_l2_at_24h(env, backdated_alert):
|
|||
|
|
repo, pub, engine = env
|
|||
|
|
backdated_alert("ALT-E-3", "C1", hours_ago=24.1)
|
|||
|
|
result = scan_and_escalate(now=datetime.now(), risk_repo=repo, thresholds=EscalationThresholds())
|
|||
|
|
assert result["escalated"][0]["level"] == 2
|
|||
|
|
alert = repo.get_alert("ALT-E-3")
|
|||
|
|
assert alert["payload"]["escalation_level"] == 2
|
|||
|
|
notify = pub.messages[0][1]["notify_role"]
|
|||
|
|
assert "compliance" in notify # L2 升级到合规
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_aml_escalate_at_1h(env, backdated_alert):
|
|||
|
|
repo, pub, engine = env
|
|||
|
|
backdated_alert("ALT-E-AML", "C1", hours_ago=1.1, alert_type="aml", risk_score=95)
|
|||
|
|
result = scan_and_escalate(now=datetime.now(), risk_repo=repo, thresholds=EscalationThresholds())
|
|||
|
|
assert result["escalated"][0]["level"] == 1
|
|||
|
|
assert repo.get_alert("ALT-E-AML")["payload"]["escalation_level"] == 1
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_idempotent_repeat_scan(env, backdated_alert):
|
|||
|
|
"""同单重复扫描不重复推送(幂等闸门:仅升不降)。"""
|
|||
|
|
repo, pub, engine = env
|
|||
|
|
backdated_alert("ALT-E-IDEM", "C1", hours_ago=5)
|
|||
|
|
scan_and_escalate(now=datetime.now(), risk_repo=repo, thresholds=EscalationThresholds())
|
|||
|
|
assert len(pub.messages) == 1
|
|||
|
|
# 第二次:已达 L1,computed_level 不大于 current_level → 跳过,不再推送
|
|||
|
|
result2 = scan_and_escalate(now=datetime.now(), risk_repo=repo, thresholds=EscalationThresholds())
|
|||
|
|
assert result2["escalated"] == []
|
|||
|
|
assert len(pub.messages) == 1
|
|||
|
|
# 第三次:即便继续超时(now 后移),仍只升到 L2 一次,不再重复 L1 推送
|
|||
|
|
scan_and_escalate(
|
|||
|
|
now=datetime.now() + timedelta(hours=30),
|
|||
|
|
risk_repo=repo,
|
|||
|
|
thresholds=EscalationThresholds(),
|
|||
|
|
)
|
|||
|
|
# 仅 L1 一次 + L2 一次 = 2 条推送
|
|||
|
|
assert len(pub.messages) == 2
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_merge_same_customer_level(env, backdated_alert):
|
|||
|
|
"""同客户多单同级别合并一次推送(降噪)。"""
|
|||
|
|
repo, pub, engine = env
|
|||
|
|
backdated_alert("ALT-E-M1", "C1", hours_ago=5)
|
|||
|
|
backdated_alert("ALT-E-M2", "C1", hours_ago=6)
|
|||
|
|
result = scan_and_escalate(now=datetime.now(), risk_repo=repo, thresholds=EscalationThresholds())
|
|||
|
|
assert len(result["escalated"]) == 2 # 两张单都升级
|
|||
|
|
assert result["merged_notices"] == 1 # 但只合并推送一次
|
|||
|
|
assert len(pub.messages) == 1
|
|||
|
|
assert pub.messages[0][1]["merged_count"] == 2
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_handled_exits_scan(env, backdated_alert):
|
|||
|
|
"""人工处置后 status 不再是 pending_review → 退出扫描范围。"""
|
|||
|
|
repo, pub, engine = env
|
|||
|
|
backdated_alert("ALT-E-H", "C1", hours_ago=10)
|
|||
|
|
# 模拟人工处置(状态机变更)
|
|||
|
|
repo.update_alert_status("ALT-E-H", "confirmed_normal", "STAFF-90001", "已核实")
|
|||
|
|
result = scan_and_escalate(now=datetime.now(), risk_repo=repo, thresholds=EscalationThresholds())
|
|||
|
|
assert result["scanned"] == 0
|
|||
|
|
assert result["escalated"] == []
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_update_alert_escalation_keeps_handler_columns(env, backdated_alert):
|
|||
|
|
"""升级写入只动 payload,不碰 status / handler_id / handler_result / handled_at。"""
|
|||
|
|
repo, pub, engine = env
|
|||
|
|
backdated_alert("ALT-E-HC", "C1", hours_ago=5)
|
|||
|
|
ok = repo.update_alert_escalation("ALT-E-HC", 1, datetime.now(), "TRACE-X")
|
|||
|
|
assert ok is True
|
|||
|
|
alert = repo.get_alert("ALT-E-HC")
|
|||
|
|
assert alert["status"] == "pending_review"
|
|||
|
|
assert alert["handler_id"] is None
|
|||
|
|
assert alert["handler_result"] is None
|
|||
|
|
assert alert["handled_at"] is None
|
|||
|
|
assert alert["payload"]["escalation_level"] == 1
|