- 仓储:新增 list_pending_alerts_all / update_alert_escalation(统一读改写 payload, 升级标记独占写入,status/handler_* 列不碰;_parse_alert 补 created_at/handled_at 字符串→datetime 解析,兼容 sqlite 原生 DDL) - escalation_service.scan_and_escalate:扫描判级(普通 4h/24h、AML 1h/4h 短通道)、 幂等闸门(仅升不降)、先持久化再推送、按 (customer_id,level) 降噪合并一次推送、 通知链累积(L1 含 risk_manager / L2 含 compliance)、逐单+任务级审计 - scripts/cron/escalation_scan.py:15min 定时扫描壳(sys.path 引导 + new_trace + JSON 摘要) - 对话线 query_overdue_alerts Tool + 注册表 + 意图词(置于 alert_query 之前)+ summarize 分支 - C5 前置:seed STAFF-31001/31002(risk_manager) + deps 矩阵放行 + chat.py 显式 deny + risk.py 台账全量只读分支;JWT 手册 §5.3/§5.4/§6.1 增补 risk_manager - 测试:conftest 回拨 fixture + test_escalation_service(9) + risk_api/manager(5) + chat deny(1) + chat_tools overdue(2);全量 470 绿(453+17)
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
|