fix(risk): 通知创建失败必须能被区分出来,不再静默返回 0

docs/25 P2「通知失败被吞」。核实后要把定性说得更准一点:它**有** logger.exception,
不是完全静默;问题在于 _create_notifications 失败时 
eturn 0,而"无需通知"
(没有高风险预警、或通知功能关闭)也返回 0 —— 调用方只拿到一个 notification_count,
**分不清"本来就不用通知"和"高风险通知创建失败"**。

风控里这个区别很要紧:通知没发出去等于处置链路的第一环断了,而扫描依旧报"完成"。
这与同一批缺陷里的"定时扫描重启后不再执行"是同一性质 —— 看起来在工作、其实有一环没跑。

改动:
- _create_notifications 返回 (条数, 失败原因),失败原因非空即代表出了问题。
- scan() 在有失败时于返回体里带上
otification_failure,并把 message 改成
  "规则扫描完成,但高风险通知创建失败",让上游与运维都能直接看到。
- **失败仍然不回滚预警**:预警已经生成、比通知重要,不该因为通知写失败就丢掉。

新增 tests/unit/service/test_risk_scan_notification.py(4 条),其中一条专门断言
"两种 0 必须可区分" —— 那是本次修复的全部意义。

ruff / mypy(136 文件) / 616 unit+contract 全绿。
This commit is contained in:
2026-09-11 13:33:41 +08:00
parent 8283f6ab69
commit d331c817ac
2 changed files with 134 additions and 8 deletions
+24 -8
View File
@@ -409,24 +409,40 @@ class RiskScanService:
async with _scan_lock:
try:
alerts = await self.rule_engine.refresh_alerts()
notification_count = await self._create_notifications(alerts)
notification_count, notification_failure = await self._create_notifications(alerts)
await self.session.commit()
except Exception as error:
await self.session.rollback()
raise RiskScanError("规则扫描失败") from error
return {
result: dict[str, int | str] = {
"message": "规则扫描完成",
"created_count": len(alerts),
"high_risk_count": sum(alert.alert_level == HIGH_RISK for alert in alerts),
"notification_count": notification_count,
}
if notification_failure:
# 通知失败**不回滚**预警(预警已经生成、比通知重要),但必须让调用方看见。
# 原先这里无处可查:外面只拿到 notification_count=0,分不清"这批预警本来
# 就不用通知"和"高风险通知创建失败了"——而后者意味着处置链路的第一环断了,
# 扫描却报"完成"。
result["notification_failure"] = notification_failure
result["message"] = "规则扫描完成,但高风险通知创建失败"
return result
async def _create_notifications(self, alerts: list[FundRiskAlert]) -> int:
async def _create_notifications(self, alerts: list[FundRiskAlert]) -> tuple[int, str]:
"""为高风险预警创建通知记录,返回 `(创建条数, 失败原因)`。
**失败原因必须交给调用方。** 原先这里失败时 `return 0`,而"无需通知"(没有高风险
预警、或通知功能关闭)也返回 0 —— 外面根本分不清两者。风控里这个区别很要紧:
通知没发出去等于处置链路的第一环断了,而扫描依旧报"完成"。
失败**不回滚**预警本身:预警已经生成、比通知重要,不该因为通知写失败就丢掉。
"""
if not self.notification_enabled:
return 0
return 0, ""
high_risk = [alert for alert in alerts if alert.alert_level == HIGH_RISK]
if not high_risk:
return 0
return 0, ""
try:
async with self.session.begin_nested():
count = 0
@@ -448,10 +464,10 @@ class RiskScanService:
mail_enabled=self.mail_enabled,
)
count += 1
return count
except Exception:
return count, ""
except Exception as error:
logger.exception("高风险通知记录创建失败,预警扫描继续提交")
return 0
return 0, f"{type(error).__name__}: {error}"[:200]
def _new_alert_id() -> int:
@@ -0,0 +1,110 @@
"""扫描的通知环节:失败必须能被区分出来。
**背景**:`_create_notifications` 原先失败时 `return 0`,而"无需通知"(没有高风险预警、
通知功能关闭)也返回 0 —— 调用方只拿到一个 `notification_count`,分不清两者。
这不是洁癖:**通知没发出去等于处置链路的第一环断了,而扫描依旧报"完成"**。
风控系统里这种"看起来在工作、其实有一环没跑"的状态最难发现(同一批缺陷里,
"定时扫描重启后不再执行"也是这个性质)。
所以这里锁住契约:返回 `(条数, 失败原因)`,失败原因非空即代表出了问题。
"""
import logging
from types import SimpleNamespace
from typing import Any, cast
import pytest
from app.service.risk_scan_service import HIGH_RISK, RiskScanService
class _FakeNested:
async def __aenter__(self) -> "_FakeNested":
return self
async def __aexit__(self, *exc: object) -> bool:
return False
class _FakeSession:
"""只提供 `begin_nested()`;被测方法不碰其它会话能力。"""
def begin_nested(self) -> _FakeNested:
return _FakeNested()
class _FakeNotificationService:
def __init__(self, *, fail: bool = False) -> None:
self.fail = fail
self.in_app: list[str] = []
def create_in_app(self, alert: Any, **kwargs: Any) -> None:
if self.fail:
raise RuntimeError("通知写库失败")
self.in_app.append(str(alert.alert_no))
def create_mail_record(self, alert: Any, **kwargs: Any) -> None:
if self.fail:
raise RuntimeError("邮件记录写库失败")
def _alert(level: str, alert_no: str = "ALTEST") -> Any:
return SimpleNamespace(
alert_level=level, alert_no=alert_no, alert_type="大额频繁交易",
evidence_summary="摘要", handler_id=9002,
)
def _service(*, enabled: bool = True, fail: bool = False,
email: str | None = None) -> RiskScanService:
service = object.__new__(RiskScanService)
service.notification_enabled = enabled
service.notification_email = email
service.mail_enabled = False
service.notification_service = cast(Any, _FakeNotificationService(fail=fail))
service.session = cast(Any, _FakeSession())
return service
@pytest.mark.asyncio
async def test_success_reports_count_and_no_failure() -> None:
service = _service()
count, failure = await service._create_notifications([_alert(HIGH_RISK)])
assert count == 1
assert failure == ""
@pytest.mark.asyncio
async def test_nothing_to_notify_is_not_a_failure() -> None:
"""没有高风险预警,或通知功能关闭——都不算失败,失败原因应为空。"""
service = _service()
assert await service._create_notifications([_alert("低")]) == (0, "")
disabled = _service(enabled=False)
assert await disabled._create_notifications([_alert(HIGH_RISK)]) == (0, "")
@pytest.mark.asyncio
async def test_failure_is_reported_not_swallowed(caplog: pytest.LogCaptureFixture) -> None:
"""通知创建失败时必须带出原因——这正是原先丢失的信号。"""
service = _service(fail=True)
with caplog.at_level(logging.ERROR):
count, failure = await service._create_notifications([_alert(HIGH_RISK)])
assert count == 0
assert "RuntimeError" in failure
assert "通知写库失败" in failure
@pytest.mark.asyncio
async def test_failure_reason_differs_from_nothing_to_notify() -> None:
"""两种 0 必须可区分——这是本次修复的全部意义。"""
quiet, _ = await _service()._create_notifications([_alert("低")])
_count, failure = await _service(fail=True)._create_notifications([_alert(HIGH_RISK)])
assert quiet == 0
assert failure != "", "失败必须能与『无需通知』区分开"