fix(api): scope stats report sources by project
All checks were successful
Code Review / ai-code-review (push) Successful in 17s
CD Pipeline / tests (push) Successful in 1m47s
CD Pipeline / build-and-deploy (push) Successful in 5m54s
CD Pipeline / post-deploy-checks (push) Successful in 1m51s

This commit is contained in:
Your Name
2026-06-27 15:51:23 +08:00
parent ee3fb5c005
commit 36951871ca
3 changed files with 157 additions and 30 deletions

View File

@@ -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)

View File

@@ -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,

View 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"]