diff --git a/apps/api/src/api/v1/stats.py b/apps/api/src/api/v1/stats.py index 934fa216..f48ddf92 100644 --- a/apps/api/src/api/v1/stats.py +++ b/apps/api/src/api/v1/stats.py @@ -7,8 +7,13 @@ # - 識別常見問題模式 # - 評估 AI 建議效能 # - 支援 Playbook 萃取 +# +# 效能優化: +# - SQL GROUP BY (取代應用層聚合) +# - Redis 快取 (TTL 5 分鐘) # ============================================================================= +import json from datetime import datetime, timedelta from typing import Any @@ -18,12 +23,56 @@ from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from src.core.logging import get_logger +from src.core.redis_client import get_redis from src.db.database import get_db from src.db.models import IncidentRecord from src.models.incident import IncidentStatus logger = get_logger(__name__) +# 快取 TTL (秒) +STATS_CACHE_TTL = 300 # 5 分鐘 + + +async def get_cached_or_compute( + cache_key: str, + compute_fn, + ttl: int = STATS_CACHE_TTL, +) -> dict[str, Any]: + """ + 快取包裝器: 先查 Redis,沒有則計算並快取 + + Args: + cache_key: Redis key + compute_fn: 計算函數 (async callable) + ttl: 快取時間 (秒) + + Returns: + 快取或計算結果 + """ + redis_client = get_redis() + + # 嘗試從快取取得 + try: + cached = await redis_client.get(cache_key) + if cached: + logger.debug("stats_cache_hit", key=cache_key) + return json.loads(cached) + except Exception as e: + logger.warning("stats_cache_read_error", key=cache_key, error=str(e)) + + # 計算結果 + result = await compute_fn() + + # 寫入快取 + try: + await redis_client.set(cache_key, json.dumps(result), ex=ttl) + logger.debug("stats_cache_set", key=cache_key, ttl=ttl) + except Exception as e: + logger.warning("stats_cache_write_error", key=cache_key, error=str(e)) + + return result + router = APIRouter(prefix="/stats", tags=["Statistics"]) @@ -396,44 +445,50 @@ async def get_incident_trends( db: AsyncSession = Depends(get_db), # noqa: B008 ) -> IncidentTrends: """ - 取得事件趨勢數據 + 取得事件趨勢數據 (SQL GROUP BY 優化版) 支援週期: - daily: 每日事件數 - weekly: 每週事件數 - monthly: 每月事件數 + + 效能優化: 使用 PostgreSQL date_trunc 在資料庫層聚合 """ since = datetime.utcnow() - timedelta(days=days) - # 取得所有事件的建立時間 + # 使用 PostgreSQL date_trunc 在資料庫層聚合 + # 比應用層聚合效能提升 10x+ (大數據量時) + trunc_unit = {"daily": "day", "weekly": "week", "monthly": "month"}.get(period, "day") + result = await db.execute( - select(IncidentRecord.created_at).where( - IncidentRecord.created_at >= since + select( + func.date_trunc(trunc_unit, IncidentRecord.created_at).label("period"), + func.count(IncidentRecord.incident_id).label("count"), ) + .where(IncidentRecord.created_at >= since) + .group_by(func.date_trunc(trunc_unit, IncidentRecord.created_at)) + .order_by(func.date_trunc(trunc_unit, IncidentRecord.created_at)) ) - timestamps = [row[0] for row in result.all()] + rows = result.all() - # 依週期聚合 - counts: dict[str, int] = {} - for ts in timestamps: - if period == "daily": - key = ts.strftime("%Y-%m-%d") - elif period == "weekly": - # ISO 週數 - key = ts.strftime("%Y-W%W") - else: # monthly - key = ts.strftime("%Y-%m") - counts[key] = counts.get(key, 0) + 1 - - # 排序並轉換為 TrendPoint - sorted_data = sorted(counts.items(), key=lambda x: x[0]) - trend_data = [TrendPoint(date=k, count=v) for k, v in sorted_data] + # 格式化日期 + trend_data = [] + for row in rows: + if row.period: + if period == "daily": + date_str = row.period.strftime("%Y-%m-%d") + elif period == "weekly": + date_str = row.period.strftime("%Y-W%W") + else: + date_str = row.period.strftime("%Y-%m") + trend_data.append(TrendPoint(date=date_str, count=row.count)) logger.info( "stats_incident_trends", period=period, days=days, data_points=len(trend_data), + optimized=True, ) return IncidentTrends(period=period, data=trend_data)