diff --git a/apps/api/src/api/v1/stats.py b/apps/api/src/api/v1/stats.py index ff758a0f..b78e14cb 100644 --- a/apps/api/src/api/v1/stats.py +++ b/apps/api/src/api/v1/stats.py @@ -43,6 +43,7 @@ from src.services.weekly_report_service import ( ) router = APIRouter(prefix="/stats", tags=["Statistics"]) +DEFAULT_STATS_PROJECT_ID = "awoooi" # ============================================================================= @@ -153,6 +154,7 @@ class FeedbackSummary(BaseModel): ) async def get_incident_summary( days: int = Query(30, ge=1, le=365, description="統計區間 (天)"), + project_id: str = Query(DEFAULT_STATS_PROJECT_ID, min_length=1, description="專案 ID"), service: StatsServiceDep = None, ) -> IncidentSummary: """ @@ -164,7 +166,7 @@ async def get_incident_summary( - 嚴重度分佈 - 解決率 """ - result = await service.get_incident_summary(days) + result = await service.get_incident_summary(days, project_id=project_id) return IncidentSummary( total_incidents=result["total_incidents"], status_distribution=[ @@ -185,6 +187,7 @@ async def get_incident_summary( ) async def get_resolution_stats( days: int = Query(30, ge=1, le=365, description="統計區間 (天)"), + project_id: str = Query(DEFAULT_STATS_PROJECT_ID, min_length=1, description="專案 ID"), service: StatsServiceDep = None, ) -> ResolutionStats: """ @@ -195,7 +198,7 @@ async def get_resolution_stats( - P50/P95 解決時間 - 最快/最慢解決時間 """ - result = await service.get_resolution_stats(days) + result = await service.get_resolution_stats(days, project_id=project_id) return ResolutionStats(**result) @@ -206,6 +209,7 @@ async def get_resolution_stats( ) async def get_ai_performance( days: int = Query(30, ge=1, le=365, description="統計區間 (天)"), + project_id: str = Query(DEFAULT_STATS_PROJECT_ID, min_length=1, description="專案 ID"), service: StatsServiceDep = None, ) -> AIPerformance: """ @@ -216,7 +220,7 @@ async def get_ai_performance( - 執行成功率 - 有效性評分分佈 """ - result = await service.get_ai_performance(days) + result = await service.get_ai_performance(days, project_id=project_id) return AIPerformance(**result) @@ -228,6 +232,7 @@ async def get_ai_performance( async def get_affected_services( days: int = Query(30, ge=1, le=365, description="統計區間 (天)"), limit: int = Query(10, ge=1, le=50, description="返回數量"), + project_id: str = Query(DEFAULT_STATS_PROJECT_ID, min_length=1, description="專案 ID"), service: StatsServiceDep = None, ) -> list[ServiceImpact]: """ @@ -237,7 +242,7 @@ async def get_affected_services( - 事件計數 - 嚴重度分佈 """ - results = await service.get_affected_services(days, limit) + results = await service.get_affected_services(days, limit, project_id=project_id) return [ServiceImpact(**r) for r in results] @@ -249,6 +254,7 @@ async def get_affected_services( async def get_incident_trends( days: int = Query(30, ge=7, le=365, description="統計區間 (天)"), period: str = Query("daily", description="週期: daily/weekly/monthly"), + project_id: str = Query(DEFAULT_STATS_PROJECT_ID, min_length=1, description="專案 ID"), service: StatsServiceDep = None, ) -> IncidentTrends: """ @@ -259,7 +265,7 @@ async def get_incident_trends( - weekly: 每週事件數 - monthly: 每月事件數 """ - result = await service.get_incident_trends(days, period) + result = await service.get_incident_trends(days, period, project_id=project_id) return IncidentTrends( period=result["period"], data=[TrendPoint(**p) for p in result["data"]], @@ -273,6 +279,7 @@ async def get_incident_trends( ) async def get_feedback_summary( days: int = Query(30, ge=1, le=365, description="統計區間 (天)"), + project_id: str = Query(DEFAULT_STATS_PROJECT_ID, min_length=1, description="專案 ID"), service: StatsServiceDep = None, ) -> FeedbackSummary: """ @@ -282,7 +289,7 @@ async def get_feedback_summary( - 正面/中性/負面回饋比例 - 常見主題 (從 learning_notes 萃取) """ - result = await service.get_feedback_summary(days) + result = await service.get_feedback_summary(days, project_id=project_id) return FeedbackSummary(**result) diff --git a/apps/api/src/services/stats_service.py b/apps/api/src/services/stats_service.py index 6d4aebc6..4eba53fc 100644 --- a/apps/api/src/services/stats_service.py +++ b/apps/api/src/services/stats_service.py @@ -34,6 +34,7 @@ logger = structlog.get_logger(__name__) # 快取 TTL (秒) STATS_CACHE_TTL = 300 # 5 分鐘 +DEFAULT_STATS_PROJECT_ID = "awoooi" # ============================================================================= @@ -50,37 +51,37 @@ class IStatsService(Protocol): """ async def get_incident_summary( - self, days: int = 30 + self, days: int = 30, project_id: str | None = None ) -> dict[str, Any]: """取得事件總覽統計""" ... async def get_resolution_stats( - self, days: int = 30 + self, days: int = 30, project_id: str | None = None ) -> dict[str, Any]: """取得解決時間統計""" ... async def get_ai_performance( - self, days: int = 30 + self, days: int = 30, project_id: str | None = None ) -> dict[str, Any]: """取得 AI 效能統計""" ... async def get_affected_services( - self, days: int = 30, limit: int = 10 + self, days: int = 30, limit: int = 10, project_id: str | None = None ) -> list[dict[str, Any]]: """取得受影響服務排名""" ... async def get_incident_trends( - self, days: int = 30, period: str = "daily" + self, days: int = 30, period: str = "daily", project_id: str | None = None ) -> dict[str, Any]: """取得事件趨勢""" ... async def get_feedback_summary( - self, days: int = 30 + self, days: int = 30, project_id: str | None = None ) -> dict[str, Any]: """取得人類回饋摘要""" ... @@ -98,6 +99,19 @@ class StatsService: 封裝統計 API 的快取邏輯與資料庫查詢 """ + def __init__(self, project_id: str = DEFAULT_STATS_PROJECT_ID) -> None: + normalized = str(project_id or DEFAULT_STATS_PROJECT_ID).strip() + self.project_id = normalized or DEFAULT_STATS_PROJECT_ID + + def _project_id(self, project_id: str | None = None) -> str: + normalized = str(project_id or self.project_id or DEFAULT_STATS_PROJECT_ID).strip() + return normalized or DEFAULT_STATS_PROJECT_ID + + def _cache_key(self, name: str, *parts: Any, project_id: str | None = None) -> str: + suffix = ":".join(str(part) for part in parts if part is not None) + base = f"stats:{self._project_id(project_id)}:{name}" + return f"{base}:{suffix}" if suffix else base + # ------------------------------------------------------------------------- # 快取相關 # ------------------------------------------------------------------------- @@ -151,16 +165,21 @@ class StatsService: # 統計查詢 (Phase 17 P1: 從 Router 層遷移) # ------------------------------------------------------------------------- - async def get_incident_summary(self, days: int = 30) -> dict[str, Any]: + async def get_incident_summary( + self, + days: int = 30, + project_id: str | None = None, + ) -> dict[str, Any]: """ 取得事件總覽統計 包含: 總事件數、狀態分佈、嚴重度分佈、解決率 """ - cache_key = f"stats:incident_summary:{days}" + effective_project_id = self._project_id(project_id) + cache_key = self._cache_key("incident_summary", days, project_id=effective_project_id) async def compute() -> dict[str, Any]: - async with get_db_context() as db: + async with get_db_context(effective_project_id) as db: since = datetime.utcnow() - timedelta(days=days) # 總數 @@ -215,6 +234,7 @@ class StatsService: logger.info( "stats_incident_summary", + project_id=effective_project_id, total=total, resolved_rate=resolved_rate, days=days, @@ -230,16 +250,21 @@ class StatsService: return await self.get_cached_or_compute(cache_key, compute) - async def get_resolution_stats(self, days: int = 30) -> dict[str, Any]: + async def get_resolution_stats( + self, + days: int = 30, + project_id: str | None = None, + ) -> dict[str, Any]: """ 取得解決時間統計 計算: 平均、P50、P95、最快、最慢解決時間 """ - cache_key = f"stats:resolution:{days}" + effective_project_id = self._project_id(project_id) + cache_key = self._cache_key("resolution", days, project_id=effective_project_id) async def compute() -> dict[str, Any]: - async with get_db_context() as db: + async with get_db_context(effective_project_id) as db: since = datetime.utcnow() - timedelta(days=days) result = await db.execute( @@ -293,16 +318,21 @@ class StatsService: return await self.get_cached_or_compute(cache_key, compute) - async def get_ai_performance(self, days: int = 30) -> dict[str, Any]: + async def get_ai_performance( + self, + days: int = 30, + project_id: str | None = None, + ) -> dict[str, Any]: """ 取得 AI 提案效能統計 評估: 提案執行率、成功率、有效性評分 """ - cache_key = f"stats:ai_performance:{days}" + effective_project_id = self._project_id(project_id) + cache_key = self._cache_key("ai_performance", days, project_id=effective_project_id) async def compute() -> dict[str, Any]: - async with get_db_context() as db: + async with get_db_context(effective_project_id) as db: since = datetime.utcnow() - timedelta(days=days) result = await db.execute( @@ -342,15 +372,24 @@ class StatsService: return await self.get_cached_or_compute(cache_key, compute) async def get_affected_services( - self, days: int = 30, limit: int = 10 + self, + days: int = 30, + limit: int = 10, + project_id: str | None = None, ) -> list[dict[str, Any]]: """ 取得最常受影響的服務排名 """ - cache_key = f"stats:affected_services:{days}:{limit}" + effective_project_id = self._project_id(project_id) + cache_key = self._cache_key( + "affected_services", + days, + limit, + project_id=effective_project_id, + ) async def compute() -> dict[str, Any]: - async with get_db_context() as db: + async with get_db_context(effective_project_id) as db: since = datetime.utcnow() - timedelta(days=days) result = await db.execute( @@ -391,15 +430,19 @@ class StatsService: return result.get("services", []) async def get_incident_trends( - self, days: int = 30, period: str = "daily" + self, + days: int = 30, + period: str = "daily", + project_id: str | None = None, ) -> dict[str, Any]: """ 取得事件趨勢數據 (SQL GROUP BY 優化版) """ - cache_key = f"stats:incident_trends:{days}:{period}" + effective_project_id = self._project_id(project_id) + cache_key = self._cache_key("incident_trends", days, period, project_id=effective_project_id) async def compute() -> dict[str, Any]: - async with get_db_context() as db: + async with get_db_context(effective_project_id) as db: since = datetime.utcnow() - timedelta(days=days) trunc_unit = {"daily": "day", "weekly": "week", "monthly": "month"}.get( @@ -430,6 +473,7 @@ class StatsService: logger.info( "stats_incident_trends", + project_id=effective_project_id, period=period, days=days, data_points=len(trend_data), @@ -439,14 +483,19 @@ class StatsService: return await self.get_cached_or_compute(cache_key, compute) - async def get_feedback_summary(self, days: int = 30) -> dict[str, Any]: + async def get_feedback_summary( + self, + days: int = 30, + project_id: str | None = None, + ) -> dict[str, Any]: """ 取得人類回饋統計 """ - cache_key = f"stats:feedback_summary:{days}" + effective_project_id = self._project_id(project_id) + cache_key = self._cache_key("feedback_summary", days, project_id=effective_project_id) async def compute() -> dict[str, Any]: - async with get_db_context() as db: + async with get_db_context(effective_project_id) as db: since = datetime.utcnow() - timedelta(days=days) result = await db.execute( @@ -500,6 +549,7 @@ class StatsService: logger.info( "stats_feedback_summary", + project_id=effective_project_id, total=total, positive=positive, negative=negative, diff --git a/apps/api/tests/test_stats_service_tenant_scope.py b/apps/api/tests/test_stats_service_tenant_scope.py new file mode 100644 index 00000000..d2e7ed93 --- /dev/null +++ b/apps/api/tests/test_stats_service_tenant_scope.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager + +import pytest + +from src.services import stats_service as stats_service_module +from src.services.stats_service import StatsService + + +class _NoopRedis: + async def get(self, key: str) -> None: + return None + + async def set(self, key: str, value: str, ex: int) -> None: + return None + + +class _EmptyResult: + def all(self) -> list: + return [] + + +class _FakeDb: + async def execute(self, statement) -> _EmptyResult: + return _EmptyResult() + + +@pytest.mark.asyncio +async def test_stats_service_uses_default_project_for_db_context(monkeypatch) -> None: + seen_project_ids: list[str | None] = [] + + @asynccontextmanager + async def fake_db_context(project_id: str | None = None) -> AsyncGenerator[_FakeDb, None]: + seen_project_ids.append(project_id) + yield _FakeDb() + + monkeypatch.setattr(stats_service_module, "get_db_context", fake_db_context) + monkeypatch.setattr(stats_service_module, "get_redis", lambda: _NoopRedis()) + + result = await StatsService().get_resolution_stats(days=7) + + assert result["sample_size"] == 0 + assert seen_project_ids == ["awoooi"] + + +@pytest.mark.asyncio +async def test_stats_service_allows_explicit_project_for_db_context(monkeypatch) -> None: + seen_project_ids: list[str | None] = [] + seen_cache_keys: list[str] = [] + + @asynccontextmanager + async def fake_db_context(project_id: str | None = None) -> AsyncGenerator[_FakeDb, None]: + seen_project_ids.append(project_id) + yield _FakeDb() + + class TrackingRedis(_NoopRedis): + async def get(self, key: str) -> None: + seen_cache_keys.append(key) + return None + + monkeypatch.setattr(stats_service_module, "get_db_context", fake_db_context) + monkeypatch.setattr(stats_service_module, "get_redis", lambda: TrackingRedis()) + + result = await StatsService().get_ai_performance(days=7, project_id="vibework") + + assert result["total_proposals"] == 0 + assert seen_project_ids == ["vibework"] + assert seen_cache_keys == ["stats:vibework:ai_performance:7"]