fix(api): scope stats report sources by project
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
70
apps/api/tests/test_stats_service_tenant_scope.py
Normal file
70
apps/api/tests/test_stats_service_tenant_scope.py
Normal file
@@ -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"]
|
||||
Reference in New Issue
Block a user