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