diff --git a/app/service/risk_scan_service.py b/app/service/risk_scan_service.py index 11ec32c..a11197c 100644 --- a/app/service/risk_scan_service.py +++ b/app/service/risk_scan_service.py @@ -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: diff --git a/tests/unit/service/test_risk_scan_notification.py b/tests/unit/service/test_risk_scan_notification.py new file mode 100644 index 0000000..ec3a1fe --- /dev/null +++ b/tests/unit/service/test_risk_scan_notification.py @@ -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 != "", "失败必须能与『无需通知』区分开"