diff --git a/app/service/risk_daily_report_service.py b/app/service/risk_daily_report_service.py index 0c37cb3..199e2be 100644 --- a/app/service/risk_daily_report_service.py +++ b/app/service/risk_daily_report_service.py @@ -8,9 +8,11 @@ from collections import Counter from collections.abc import AsyncIterator from datetime import UTC, datetime, time, timedelta from typing import Any +from zoneinfo import ZoneInfo from sqlalchemy.ext.asyncio import AsyncSession +from app.core.config import get_settings from app.core.contracts import RequestContext from app.model.audit import InteractionAudit from app.repository.fund_query_repository import FundRecord @@ -68,7 +70,7 @@ class RiskDailyReportService: ) -> AsyncIterator[dict[str, Any]]: await AuthorizationService.require(context, "risk:alert:read") generated_at = _utc_naive(now) - yield {"type": "start", "generated_at": generated_at.isoformat()} + yield {"type": "start", "generated_at": _local_datetime_text(generated_at)} yield {"type": "progress", "stage": "statistics", "message": "正在统计日报数据"} report = await self._build(context, generated_at) report["optimization_suggestions"] = "" @@ -115,7 +117,7 @@ class RiskDailyReportService: return { "type": "风控日报", "report_date": generated_at.date().isoformat(), - "generated_at": generated_at.isoformat(), + "generated_at": _local_datetime_text(generated_at), "daily_alert_count": len(daily), "level_distribution": _distribution(daily, "risk_level", LEVEL_ORDER), "key_risk_events": key_items, @@ -163,14 +165,20 @@ class RiskDailyReportService: } async def _suggestions(self, report: dict[str, Any]) -> tuple[str, str]: - if self.model_service is None or self.endpoint_resolver is None: - return self._fallback(report), "规则化模板" + model_service = self.model_service + endpoint_resolver = self.endpoint_resolver + if model_service is None or endpoint_resolver is None: + from app.service.agent.bootstrap import get_model_service + from app.service.model_gateway import DatabaseModelEndpointResolver + + model_service = model_service or get_model_service() + endpoint_resolver = endpoint_resolver or DatabaseModelEndpointResolver() try: - endpoints = await self.endpoint_resolver.resolve( + endpoints = await endpoint_resolver.resolve( agent_type="risk", task_type="daily_report_suggestion", ) - execution = await self.model_service.generate( + execution = await model_service.generate( endpoints, self._suggestion_prompt(report), max_attempts=2, @@ -225,8 +233,8 @@ class RiskDailyReportService: "status": item.get("status"), "ack_status": item.get("ack_status"), "handler_id": item.get("handler_id"), - "created_at": created_at.isoformat() if created_at else None, - "due_time": due_at.isoformat() if due_at else None, + "created_at": _local_datetime_text(created_at) if created_at else None, + "due_time": _local_datetime_text(due_at) if due_at else None, "is_overdue": bool(due_at and due_at <= generated_at), "is_escalated": bool(item.get("is_escalated")), "close_reason": item.get("close_reason"), @@ -402,3 +410,10 @@ def _utc_naive(value: datetime | None) -> datetime: if value.tzinfo is not None: return value.astimezone(UTC).replace(tzinfo=None) return value + + +def _local_datetime_text(value: datetime) -> str: + """把 UTC 时间转换为配置时区的常用展示格式。""" + aware = value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC) + local_value = aware.astimezone(ZoneInfo(get_settings().timezone)) + return local_value.strftime("%Y-%m-%d %H:%M:%S") diff --git a/tests/unit/service/test_risk_daily_report_service.py b/tests/unit/service/test_risk_daily_report_service.py index 440b21d..e6a87e9 100644 --- a/tests/unit/service/test_risk_daily_report_service.py +++ b/tests/unit/service/test_risk_daily_report_service.py @@ -1,5 +1,5 @@ from datetime import datetime -from types import MappingProxyType +from types import MappingProxyType, SimpleNamespace import pytest @@ -30,6 +30,26 @@ class FakeRepository: return self.snapshot +class FailingEndpointResolver: + async def resolve(self, **_kwargs): + raise RuntimeError("模型端点不可用") + + +class FailingModelService: + async def generate(self, *_args, **_kwargs): + raise AssertionError("端点解析失败后不应调用模型") + + +class StubEndpointResolver: + async def resolve(self, **_kwargs): + return [object()] + + +class StubModelService: + async def generate(self, *_args, **_kwargs): + return SimpleNamespace(text="1. 根据模型生成日报建议") + + def record( alert_no: str, *, @@ -97,6 +117,8 @@ async def test_daily_report_contains_nine_sections_and_historical_items() -> Non service = RiskDailyReportService( session, repository=FakeRepository(snapshot), + model_service=FailingModelService(), + endpoint_resolver=FailingEndpointResolver(), ) report = await service.generate( @@ -108,12 +130,46 @@ async def test_daily_report_contains_nine_sections_and_historical_items() -> Non assert report["unresolved_items"]["total"] == 2 assert report["unresolved_items"]["historical"] == 1 assert report["unresolved_items"]["overdue"] == 1 + assert report["generated_at"] == "2026-09-10 18:00:00" + assert report["unresolved_items"]["items"][0]["created_at"] == "2026-09-10 16:00:00" assert report["source"] == "规则化模板" assert all(f"{index}." in report["content"] for index in range(1, 10)) assert session.committed is True assert session.added[0].action_type == "risk_daily_report_generated" +@pytest.mark.asyncio +async def test_daily_report_lazily_initializes_model_service(monkeypatch) -> None: + current = record("ALERT-MODEL") + snapshot = RiskReportSnapshot( + daily=(current,), + unresolved=(current,), + false_positive=(), + dispositions=(current,), + ) + session = FakeSession() + monkeypatch.setattr( + "app.service.agent.bootstrap.get_model_service", + lambda: StubModelService(), + ) + monkeypatch.setattr( + "app.service.model_gateway.DatabaseModelEndpointResolver", + StubEndpointResolver, + ) + service = RiskDailyReportService( + session, + repository=FakeRepository(snapshot), + ) + + report = await service.generate( + context(), + now=datetime(2026, 9, 10, 10, 0), + ) + + assert report["source"] == "模型" + assert report["optimization_suggestions"] == "1. 根据模型生成日报建议" + + def test_mail_service_is_disabled_by_default() -> None: result = RiskDailyReportMailService(environment={}).send( ["risk@example.com"],