Files
group_xinghuo_jinrong/tests/test_escalation_service.py
T
GaoYiYuan_0626 90d0660b44 feat: C5 FR-9 预警处置时效升级(RISK-007,P0)
- 仓储:新增 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)
2026-09-07 19:28:04 +08:00

187 lines
7.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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