Files
Aether/src/api/dashboard/routes.py

969 lines
42 KiB
Python
Raw Normal View History

2025-12-10 20:52:44 +08:00
"""仪表盘统计 API 端点。"""
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import List
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import and_, func
from sqlalchemy.orm import Session
from src.api.base.adapter import ApiAdapter, ApiMode
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.pipeline import ApiRequestPipeline
from src.core.enums import UserRole
from src.database import get_db
from src.models.database import ApiKey, Provider, RequestCandidate, StatsDaily, Usage
from src.models.database import User as DBUser
from src.services.system.stats_aggregator import StatsAggregatorService
from src.utils.cache_decorator import cache_result
router = APIRouter(prefix="/api/dashboard", tags=["Dashboard"])
pipeline = ApiRequestPipeline()
def format_tokens(num: int) -> str:
"""格式化 Token 数量,自动转换 K/M 单位"""
if num < 1000:
return str(num)
if num < 1000000:
thousands = num / 1000
if thousands >= 100:
return f"{round(thousands)}K"
elif thousands >= 10:
return f"{thousands:.1f}K"
else:
return f"{thousands:.2f}K"
millions = num / 1000000
if millions >= 100:
return f"{round(millions)}M"
elif millions >= 10:
return f"{millions:.1f}M"
else:
return f"{millions:.2f}M"
@router.get("/stats")
async def get_dashboard_stats(request: Request, db: Session = Depends(get_db)):
adapter = DashboardStatsAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/recent-requests")
async def get_recent_requests(
request: Request,
limit: int = Query(10, ge=1, le=100),
db: Session = Depends(get_db),
):
adapter = DashboardRecentRequestsAdapter(limit=limit)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
# NOTE: /request-detail/{request_id} has been moved to /api/admin/usage/{id}
# The old route is removed. Use dashboardApi.getRequestDetail() which now calls the new API.
@router.get("/provider-status")
async def get_provider_status(request: Request, db: Session = Depends(get_db)):
adapter = DashboardProviderStatusAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/daily-stats")
async def get_daily_stats(
request: Request,
days: int = Query(7, ge=1, le=30),
db: Session = Depends(get_db),
):
adapter = DashboardDailyStatsAdapter(days=days)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
class DashboardAdapter(ApiAdapter):
"""需要登录的仪表盘适配器基类。"""
mode = ApiMode.ADMIN
def authorize(self, context): # type: ignore[override]
if not context.user:
raise HTTPException(status_code=401, detail="未登录")
class DashboardStatsAdapter(DashboardAdapter):
async def handle(self, context): # type: ignore[override]
user = context.user
if not user:
raise HTTPException(status_code=401, detail="未登录")
adapter = (
AdminDashboardStatsAdapter()
if user.role == UserRole.ADMIN
else UserDashboardStatsAdapter()
)
return await adapter.handle(context)
class AdminDashboardStatsAdapter(AdminApiAdapter):
@cache_result(key_prefix="dashboard:admin:stats", ttl=60, user_specific=False)
async def handle(self, context): # type: ignore[override]
"""管理员仪表盘统计 - 使用预聚合数据优化性能"""
from zoneinfo import ZoneInfo
from src.services.system.stats_aggregator import APP_TIMEZONE
2025-12-10 20:52:44 +08:00
db = context.db
# 使用业务时区计算日期,与 stats_daily 表保持一致
app_tz = ZoneInfo(APP_TIMEZONE)
now_local = datetime.now(app_tz)
today_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
# 转换为 UTC 用于与 stats_daily.date 比较(存储的是业务日期对应的 UTC 开始时间)
today = today_local.astimezone(timezone.utc)
yesterday = (today_local - timedelta(days=1)).astimezone(timezone.utc)
last_month = (today_local - timedelta(days=30)).astimezone(timezone.utc)
2025-12-10 20:52:44 +08:00
# ==================== 使用预聚合数据 ====================
# 从 stats_summary + 今日实时数据获取全局统计
combined_stats = StatsAggregatorService.get_combined_stats(db)
all_time_requests = combined_stats["total_requests"]
all_time_success_requests = combined_stats["success_requests"]
all_time_error_requests = combined_stats["error_requests"]
all_time_input_tokens = combined_stats["input_tokens"]
all_time_output_tokens = combined_stats["output_tokens"]
all_time_cache_creation = combined_stats["cache_creation_tokens"]
all_time_cache_read = combined_stats["cache_read_tokens"]
all_time_cost = combined_stats["total_cost"]
all_time_actual_cost = combined_stats["actual_total_cost"]
# 用户/API Key 统计
total_users = combined_stats.get("total_users") or db.query(func.count(DBUser.id)).scalar()
active_users = combined_stats.get("active_users") or (
db.query(func.count(DBUser.id)).filter(DBUser.is_active.is_(True)).scalar()
)
total_api_keys = combined_stats.get("total_api_keys") or db.query(func.count(ApiKey.id)).scalar()
active_api_keys = combined_stats.get("active_api_keys") or (
db.query(func.count(ApiKey.id)).filter(ApiKey.is_active.is_(True)).scalar()
)
# ==================== 今日实时统计 ====================
today_stats = StatsAggregatorService.get_today_realtime_stats(db)
requests_today = today_stats["total_requests"]
cost_today = today_stats["total_cost"]
actual_cost_today = today_stats["actual_total_cost"]
input_tokens_today = today_stats["input_tokens"]
output_tokens_today = today_stats["output_tokens"]
cache_creation_today = today_stats["cache_creation_tokens"]
cache_read_today = today_stats["cache_read_tokens"]
tokens_today = (
input_tokens_today + output_tokens_today + cache_creation_today + cache_read_today
)
# ==================== 昨日统计(从预聚合表获取)====================
yesterday_stats = db.query(StatsDaily).filter(StatsDaily.date == yesterday).first()
if yesterday_stats:
requests_yesterday = yesterday_stats.total_requests
cost_yesterday = yesterday_stats.total_cost
input_tokens_yesterday = yesterday_stats.input_tokens
output_tokens_yesterday = yesterday_stats.output_tokens
cache_creation_yesterday = yesterday_stats.cache_creation_tokens
cache_read_yesterday = yesterday_stats.cache_read_tokens
else:
# 如果没有预聚合数据,回退到实时查询
requests_yesterday = (
db.query(func.count(Usage.id))
.filter(Usage.created_at >= yesterday, Usage.created_at < today)
.scalar() or 0
)
cost_yesterday = (
db.query(func.sum(Usage.total_cost_usd))
.filter(Usage.created_at >= yesterday, Usage.created_at < today)
.scalar() or 0
)
yesterday_token_stats = (
db.query(
func.sum(Usage.input_tokens).label("input_tokens"),
func.sum(Usage.output_tokens).label("output_tokens"),
func.sum(Usage.cache_creation_input_tokens).label("cache_creation_tokens"),
func.sum(Usage.cache_read_input_tokens).label("cache_read_tokens"),
)
.filter(Usage.created_at >= yesterday, Usage.created_at < today)
.first()
)
input_tokens_yesterday = int(yesterday_token_stats.input_tokens or 0) if yesterday_token_stats else 0
output_tokens_yesterday = int(yesterday_token_stats.output_tokens or 0) if yesterday_token_stats else 0
cache_creation_yesterday = int(yesterday_token_stats.cache_creation_tokens or 0) if yesterday_token_stats else 0
cache_read_yesterday = int(yesterday_token_stats.cache_read_tokens or 0) if yesterday_token_stats else 0
# ==================== 本月统计(从预聚合表聚合)====================
monthly_stats = (
db.query(
func.sum(StatsDaily.total_requests).label("total_requests"),
func.sum(StatsDaily.error_requests).label("error_requests"),
func.sum(StatsDaily.total_cost).label("total_cost"),
func.sum(StatsDaily.actual_total_cost).label("actual_total_cost"),
func.sum(StatsDaily.input_tokens + StatsDaily.output_tokens +
StatsDaily.cache_creation_tokens + StatsDaily.cache_read_tokens).label("total_tokens"),
func.sum(StatsDaily.cache_creation_tokens).label("cache_creation_tokens"),
func.sum(StatsDaily.cache_read_tokens).label("cache_read_tokens"),
func.sum(StatsDaily.cache_creation_cost).label("cache_creation_cost"),
func.sum(StatsDaily.cache_read_cost).label("cache_read_cost"),
func.sum(StatsDaily.fallback_count).label("fallback_count"),
)
.filter(StatsDaily.date >= last_month, StatsDaily.date < today)
.first()
)
# 本月数据 = 预聚合月数据 + 今日实时数据
if monthly_stats and monthly_stats.total_requests:
total_requests = int(monthly_stats.total_requests or 0) + requests_today
error_requests = int(monthly_stats.error_requests or 0) + today_stats["error_requests"]
total_cost = float(monthly_stats.total_cost or 0) + cost_today
total_actual_cost = float(monthly_stats.actual_total_cost or 0) + actual_cost_today
total_tokens = int(monthly_stats.total_tokens or 0) + tokens_today
cache_creation_tokens = int(monthly_stats.cache_creation_tokens or 0) + cache_creation_today
cache_read_tokens = int(monthly_stats.cache_read_tokens or 0) + cache_read_today
cache_creation_cost = float(monthly_stats.cache_creation_cost or 0)
cache_read_cost = float(monthly_stats.cache_read_cost or 0)
fallback_count = int(monthly_stats.fallback_count or 0)
else:
# 回退到实时查询(没有预聚合数据时)
total_requests = (
db.query(func.count(Usage.id)).filter(Usage.created_at >= last_month).scalar() or 0
)
total_cost = (
db.query(func.sum(Usage.total_cost_usd)).filter(Usage.created_at >= last_month).scalar() or 0
)
total_actual_cost = (
db.query(func.sum(Usage.actual_total_cost_usd))
.filter(Usage.created_at >= last_month).scalar() or 0
)
error_requests = (
db.query(func.count(Usage.id))
.filter(
Usage.created_at >= last_month,
(Usage.status_code >= 400) | (Usage.error_message.isnot(None)),
).scalar() or 0
)
total_tokens = (
db.query(func.sum(Usage.total_tokens)).filter(Usage.created_at >= last_month).scalar() or 0
)
cache_stats = (
db.query(
func.sum(Usage.cache_creation_input_tokens).label("cache_creation_tokens"),
func.sum(Usage.cache_read_input_tokens).label("cache_read_tokens"),
func.sum(Usage.cache_creation_cost_usd).label("cache_creation_cost"),
func.sum(Usage.cache_read_cost_usd).label("cache_read_cost"),
)
.filter(Usage.created_at >= last_month)
.first()
)
cache_creation_tokens = int(cache_stats.cache_creation_tokens or 0) if cache_stats else 0
cache_read_tokens = int(cache_stats.cache_read_tokens or 0) if cache_stats else 0
cache_creation_cost = float(cache_stats.cache_creation_cost or 0) if cache_stats else 0
cache_read_cost = float(cache_stats.cache_read_cost or 0) if cache_stats else 0
# Fallback 统计
fallback_subquery = (
db.query(
RequestCandidate.request_id, func.count(RequestCandidate.id).label("executed_count")
)
.filter(
RequestCandidate.created_at >= last_month,
RequestCandidate.status.in_(["success", "failed"]),
)
.group_by(RequestCandidate.request_id)
.subquery()
)
fallback_count = (
db.query(func.count())
.select_from(fallback_subquery)
.filter(fallback_subquery.c.executed_count > 1)
.scalar() or 0
)
# ==================== 系统健康指标 ====================
error_rate = round((error_requests / total_requests) * 100, 2) if total_requests > 0 else 0
# 平均响应时间(仅查询今日数据,降低查询成本)
avg_response_time = (
db.query(func.avg(Usage.response_time_ms))
.filter(
Usage.created_at >= today,
Usage.status_code == 200,
Usage.response_time_ms.isnot(None),
)
.scalar() or 0
)
avg_response_time_seconds = float(avg_response_time) / 1000.0
# 缓存命中率
total_input_with_cache = all_time_input_tokens + all_time_cache_read
cache_hit_rate = (
round((all_time_cache_read / total_input_with_cache) * 100, 1)
if total_input_with_cache > 0
else 0
)
return {
"stats": [
{
"name": "总请求",
"value": f"{all_time_requests:,}",
"subValue": f"有效 {all_time_success_requests:,} / 异常 {all_time_error_requests:,}",
"change": (
f"+{requests_today}"
if requests_today > requests_yesterday
else str(requests_today)
),
"changeType": (
"increase"
if requests_today > requests_yesterday
else ("decrease" if requests_today < requests_yesterday else "neutral")
),
"icon": "Activity",
},
{
"name": "总费用",
"value": f"${all_time_cost:.2f}",
"subValue": f"倍率后 ${all_time_actual_cost:.2f}",
"change": (
f"+${cost_today:.2f}"
if cost_today > cost_yesterday
else f"${cost_today:.2f}"
),
"changeType": (
"increase"
if cost_today > cost_yesterday
else ("decrease" if cost_today < cost_yesterday else "neutral")
),
"icon": "DollarSign",
},
{
"name": "总Token",
"value": format_tokens(
all_time_input_tokens
+ all_time_output_tokens
+ all_time_cache_creation
+ all_time_cache_read
),
"subValue": f"输入 {format_tokens(all_time_input_tokens)} / 输出 {format_tokens(all_time_output_tokens)}",
"change": (
f"+{format_tokens(input_tokens_today + output_tokens_today + cache_creation_today + cache_read_today)}"
if (input_tokens_today + output_tokens_today + cache_creation_today + cache_read_today)
> (input_tokens_yesterday + output_tokens_yesterday + cache_creation_yesterday + cache_read_yesterday)
else format_tokens(input_tokens_today + output_tokens_today + cache_creation_today + cache_read_today)
),
"changeType": (
"increase"
if (input_tokens_today + output_tokens_today + cache_creation_today + cache_read_today)
> (input_tokens_yesterday + output_tokens_yesterday + cache_creation_yesterday + cache_read_yesterday)
else (
"decrease"
if (input_tokens_today + output_tokens_today + cache_creation_today + cache_read_today)
< (input_tokens_yesterday + output_tokens_yesterday + cache_creation_yesterday + cache_read_yesterday)
else "neutral"
)
),
"icon": "Hash",
},
{
"name": "总缓存",
"value": format_tokens(all_time_cache_creation + all_time_cache_read),
"subValue": f"创建 {format_tokens(all_time_cache_creation)} / 读取 {format_tokens(all_time_cache_read)}",
"change": (
f"+{format_tokens(cache_creation_today + cache_read_today)}"
if (cache_creation_today + cache_read_today)
> (cache_creation_yesterday + cache_read_yesterday)
else format_tokens(cache_creation_today + cache_read_today)
),
"changeType": (
"increase"
if (cache_creation_today + cache_read_today)
> (cache_creation_yesterday + cache_read_yesterday)
else (
"decrease"
if (cache_creation_today + cache_read_today)
< (cache_creation_yesterday + cache_read_yesterday)
else "neutral"
)
),
"extraBadge": f"命中率 {cache_hit_rate}%",
"icon": "Database",
},
],
"today": {
"requests": requests_today,
"cost": cost_today,
"actual_cost": actual_cost_today,
"tokens": tokens_today,
"cache_creation_tokens": cache_creation_today,
"cache_read_tokens": cache_read_today,
},
"api_keys": {"total": total_api_keys, "active": active_api_keys},
"tokens": {"month": total_tokens},
"token_breakdown": {
"input": all_time_input_tokens,
"output": all_time_output_tokens,
"cache_creation": all_time_cache_creation,
"cache_read": all_time_cache_read,
},
"system_health": {
"avg_response_time": round(avg_response_time_seconds, 2),
"error_rate": error_rate,
"error_requests": error_requests,
"fallback_count": fallback_count,
"total_requests": total_requests,
},
"cost_stats": {
"total_cost": round(total_cost, 4),
"total_actual_cost": round(total_actual_cost, 4),
"cost_savings": round(total_cost - total_actual_cost, 4),
},
"cache_stats": {
"cache_creation_tokens": cache_creation_tokens,
"cache_read_tokens": cache_read_tokens,
"cache_creation_cost": round(cache_creation_cost, 4),
"cache_read_cost": round(cache_read_cost, 4),
"total_cache_tokens": cache_creation_tokens + cache_read_tokens,
},
"users": {
"total": total_users,
"active": active_users,
},
}
class UserDashboardStatsAdapter(DashboardAdapter):
@cache_result(key_prefix="dashboard:user:stats", ttl=30, user_specific=True)
async def handle(self, context): # type: ignore[override]
from zoneinfo import ZoneInfo
from src.services.system.stats_aggregator import APP_TIMEZONE
2025-12-10 20:52:44 +08:00
db = context.db
user = context.user
# 使用业务时区计算日期,确保与用户感知的"今天"一致
app_tz = ZoneInfo(APP_TIMEZONE)
now_local = datetime.now(app_tz)
today_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
# 转换为 UTC 用于数据库查询
today = today_local.astimezone(timezone.utc)
yesterday = (today_local - timedelta(days=1)).astimezone(timezone.utc)
last_month = (today_local - timedelta(days=30)).astimezone(timezone.utc)
2025-12-10 20:52:44 +08:00
user_api_keys = db.query(func.count(ApiKey.id)).filter(ApiKey.user_id == user.id).scalar()
active_keys = (
db.query(func.count(ApiKey.id))
.filter(and_(ApiKey.user_id == user.id, ApiKey.is_active.is_(True)))
.scalar()
)
# 全局 Token 统计
all_time_token_stats = (
db.query(
func.sum(Usage.input_tokens).label("input_tokens"),
func.sum(Usage.output_tokens).label("output_tokens"),
func.sum(Usage.cache_creation_input_tokens).label("cache_creation_tokens"),
func.sum(Usage.cache_read_input_tokens).label("cache_read_tokens"),
)
.filter(Usage.user_id == user.id)
.first()
)
all_time_input_tokens = (
int(all_time_token_stats.input_tokens or 0) if all_time_token_stats else 0
)
all_time_output_tokens = (
int(all_time_token_stats.output_tokens or 0) if all_time_token_stats else 0
)
all_time_cache_creation = (
int(all_time_token_stats.cache_creation_tokens or 0) if all_time_token_stats else 0
)
all_time_cache_read = (
int(all_time_token_stats.cache_read_tokens or 0) if all_time_token_stats else 0
)
# 本月请求统计
user_requests = (
db.query(func.count(Usage.id))
.filter(and_(Usage.user_id == user.id, Usage.created_at >= last_month))
.scalar()
)
user_cost = (
db.query(func.sum(Usage.total_cost_usd))
.filter(and_(Usage.user_id == user.id, Usage.created_at >= last_month))
.scalar()
or 0
)
# 今日统计
requests_today = (
db.query(func.count(Usage.id))
.filter(and_(Usage.user_id == user.id, Usage.created_at >= today))
.scalar()
)
cost_today = (
db.query(func.sum(Usage.total_cost_usd))
.filter(and_(Usage.user_id == user.id, Usage.created_at >= today))
.scalar()
or 0
)
tokens_today = (
db.query(func.sum(Usage.total_tokens))
.filter(and_(Usage.user_id == user.id, Usage.created_at >= today))
.scalar()
or 0
)
# 昨日统计(用于计算变化)
requests_yesterday = (
db.query(func.count(Usage.id))
.filter(
and_(
Usage.user_id == user.id,
Usage.created_at >= yesterday,
Usage.created_at < today,
)
)
.scalar()
)
# 缓存统计(本月)
cache_stats = (
db.query(
func.sum(Usage.cache_creation_input_tokens).label("cache_creation_tokens"),
func.sum(Usage.cache_read_input_tokens).label("cache_read_tokens"),
func.sum(Usage.input_tokens).label("total_input_tokens"),
)
.filter(and_(Usage.user_id == user.id, Usage.created_at >= last_month))
.first()
)
cache_creation_tokens = int(cache_stats.cache_creation_tokens or 0) if cache_stats else 0
cache_read_tokens = int(cache_stats.cache_read_tokens or 0) if cache_stats else 0
# 计算缓存命中率cache_read / (input_tokens + cache_read)
# input_tokens 是实际发送给模型的输入不含缓存读取cache_read 是从缓存读取的
# 总输入 = input_tokens + cache_read缓存命中率 = cache_read / 总输入
total_input_with_cache = all_time_input_tokens + all_time_cache_read
cache_hit_rate = (
round((all_time_cache_read / total_input_with_cache) * 100, 1)
if total_input_with_cache > 0
else 0
)
# 今日缓存统计
cache_stats_today = (
db.query(
func.sum(Usage.cache_creation_input_tokens).label("cache_creation_tokens"),
func.sum(Usage.cache_read_input_tokens).label("cache_read_tokens"),
)
.filter(and_(Usage.user_id == user.id, Usage.created_at >= today))
.first()
)
cache_creation_tokens_today = (
int(cache_stats_today.cache_creation_tokens or 0) if cache_stats_today else 0
)
cache_read_tokens_today = (
int(cache_stats_today.cache_read_tokens or 0) if cache_stats_today else 0
)
# 配额状态
if user.quota_usd is None:
quota_value = "无限制"
quota_change = f"已用 ${user.used_usd:.2f}"
quota_high = False
elif user.quota_usd and user.quota_usd > 0:
percent = min(100, int((user.used_usd / user.quota_usd) * 100))
quota_value = "无限制"
quota_change = f"已用 ${user.used_usd:.2f}"
quota_high = percent > 80
else:
quota_value = "0%"
quota_change = f"已用 ${user.used_usd:.2f}"
quota_high = False
return {
"stats": [
{
"name": "API 密钥",
"value": f"{active_keys}/{user_api_keys}",
"icon": "Key",
},
{
"name": "本月请求",
"value": f"{user_requests:,}",
"change": f"今日 {requests_today}",
"changeType": (
"increase"
if requests_today > requests_yesterday
else ("decrease" if requests_today < requests_yesterday else "neutral")
),
"icon": "Activity",
},
{
"name": "配额使用",
"value": quota_value,
"change": quota_change,
"changeType": "increase" if quota_high else "neutral",
"icon": "TrendingUp",
},
{
"name": "本月费用",
"value": f"${user_cost:.2f}",
"icon": "DollarSign",
},
],
"today": {
"requests": requests_today,
"cost": cost_today,
"tokens": tokens_today,
"cache_creation_tokens": cache_creation_tokens_today,
"cache_read_tokens": cache_read_tokens_today,
},
# 全局 Token 详细分类(与管理员端对齐)
"token_breakdown": {
"input": all_time_input_tokens,
"output": all_time_output_tokens,
"cache_creation": all_time_cache_creation,
"cache_read": all_time_cache_read,
},
# 用户视角:缓存使用情况
"cache_stats": {
"cache_creation_tokens": cache_creation_tokens,
"cache_read_tokens": cache_read_tokens,
"cache_hit_rate": cache_hit_rate,
"total_cache_tokens": cache_creation_tokens + cache_read_tokens,
},
}
@dataclass
class DashboardRecentRequestsAdapter(DashboardAdapter):
limit: int
async def handle(self, context): # type: ignore[override]
db = context.db
user = context.user
query = db.query(Usage)
if user.role != UserRole.ADMIN:
query = query.filter(Usage.user_id == user.id)
recent_requests = query.order_by(Usage.created_at.desc()).limit(self.limit).all()
results = []
for req in recent_requests:
owner = db.query(DBUser).filter(DBUser.id == req.user_id).first()
results.append(
{
"id": req.id,
"user": owner.username if owner else "Unknown",
"model": req.model or "N/A",
"tokens": req.total_tokens,
"time": req.created_at.strftime("%H:%M") if req.created_at else None,
"is_stream": req.is_stream,
}
)
return {"requests": results}
# NOTE: DashboardRequestDetailAdapter has been moved to AdminUsageDetailAdapter
# in src/api/admin/usage/routes.py
class DashboardProviderStatusAdapter(DashboardAdapter):
@cache_result(key_prefix="dashboard:provider:status", ttl=60, user_specific=False)
async def handle(self, context): # type: ignore[override]
db = context.db
user = context.user
providers = db.query(Provider).filter(Provider.is_active.is_(True)).all()
since = datetime.now(timezone.utc) - timedelta(days=1)
entries = []
for provider in providers:
count = (
db.query(func.count(Usage.id))
.filter(and_(Usage.provider == provider.name, Usage.created_at >= since))
.scalar()
)
entries.append(
{
"name": provider.name,
"status": "active" if provider.is_active else "inactive",
"requests": count,
}
)
entries.sort(key=lambda x: x["requests"], reverse=True)
limit = 10 if user.role == UserRole.ADMIN else 5
return {"providers": entries[:limit]}
@dataclass
class DashboardDailyStatsAdapter(DashboardAdapter):
days: int
@cache_result(key_prefix="dashboard:daily:stats", ttl=300, user_specific=True)
async def handle(self, context): # type: ignore[override]
from zoneinfo import ZoneInfo
from src.services.system.stats_aggregator import APP_TIMEZONE
2025-12-10 20:52:44 +08:00
db = context.db
user = context.user
is_admin = user.role == UserRole.ADMIN
# 使用业务时区计算日期,确保每日统计与业务日期一致
app_tz = ZoneInfo(APP_TIMEZONE)
now_local = datetime.now(app_tz)
today_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
# 转换为 UTC 用于数据库查询
today = today_local.astimezone(timezone.utc)
end_date_local = now_local.replace(hour=23, minute=59, second=59, microsecond=999999)
end_date = end_date_local.astimezone(timezone.utc)
start_date_local = (today_local - timedelta(days=self.days - 1))
start_date = start_date_local.astimezone(timezone.utc)
2025-12-10 20:52:44 +08:00
# ==================== 使用预聚合数据优化 ====================
if is_admin:
# 管理员:从 stats_daily 获取历史数据
daily_stats = (
db.query(StatsDaily)
.filter(and_(StatsDaily.date >= start_date, StatsDaily.date < today))
.order_by(StatsDaily.date.asc())
.all()
)
# stats_daily.date 存储的是业务日期对应的 UTC 开始时间
# 需要转回业务时区再取日期,才能与日期序列匹配
def _to_business_date_str(value: datetime) -> str:
if value.tzinfo is None:
value_utc = value.replace(tzinfo=timezone.utc)
else:
value_utc = value.astimezone(timezone.utc)
return value_utc.astimezone(app_tz).date().isoformat()
2025-12-10 20:52:44 +08:00
stats_map = {
_to_business_date_str(stat.date): {
2025-12-10 20:52:44 +08:00
"requests": stat.total_requests,
"tokens": stat.input_tokens + stat.output_tokens + stat.cache_creation_tokens + stat.cache_read_tokens,
"cost": stat.total_cost,
"avg_response_time": stat.avg_response_time_ms / 1000.0 if stat.avg_response_time_ms else 0,
"unique_models": getattr(stat, 'unique_models', 0) or 0,
"unique_providers": getattr(stat, 'unique_providers', 0) or 0,
"fallback_count": stat.fallback_count or 0,
}
for stat in daily_stats
}
# 今日实时数据
today_stats = StatsAggregatorService.get_today_realtime_stats(db)
today_str = today_local.date().isoformat()
2025-12-10 20:52:44 +08:00
if today_stats["total_requests"] > 0:
# 今日平均响应时间需要单独查询
today_avg_rt = (
db.query(func.avg(Usage.response_time_ms))
.filter(Usage.created_at >= today, Usage.response_time_ms.isnot(None))
.scalar() or 0
)
# 今日 unique_models 和 unique_providers
today_unique_models = (
db.query(func.count(func.distinct(Usage.model)))
.filter(Usage.created_at >= today)
.scalar() or 0
)
today_unique_providers = (
db.query(func.count(func.distinct(Usage.provider)))
.filter(Usage.created_at >= today)
.scalar() or 0
)
# 今日 fallback_count
today_fallback_count = (
db.query(func.count())
.select_from(
db.query(RequestCandidate.request_id)
.filter(
RequestCandidate.created_at >= today,
RequestCandidate.status.in_(["success", "failed"]),
)
.group_by(RequestCandidate.request_id)
.having(func.count(RequestCandidate.id) > 1)
.subquery()
)
.scalar() or 0
)
stats_map[today_str] = {
"requests": today_stats["total_requests"],
"tokens": (today_stats["input_tokens"] + today_stats["output_tokens"] +
today_stats["cache_creation_tokens"] + today_stats["cache_read_tokens"]),
"cost": today_stats["total_cost"],
"avg_response_time": float(today_avg_rt) / 1000.0 if today_avg_rt else 0,
"unique_models": today_unique_models,
"unique_providers": today_unique_providers,
"fallback_count": today_fallback_count,
}
# 历史预聚合缺失时兜底:按业务日范围实时计算(仅补最近少量缺失,避免全表扫描)
yesterday_date = today_local.date() - timedelta(days=1)
historical_end = min(end_date_local.date(), yesterday_date)
missing_dates: list[str] = []
cursor = start_date_local.date()
while cursor <= historical_end:
date_str = cursor.isoformat()
if date_str not in stats_map:
missing_dates.append(date_str)
cursor += timedelta(days=1)
if missing_dates:
for date_str in missing_dates[-7:]:
target_local = datetime.fromisoformat(date_str).replace(tzinfo=app_tz)
computed = StatsAggregatorService.compute_daily_stats(db, target_local)
stats_map[date_str] = {
"requests": computed["total_requests"],
"tokens": (
computed["input_tokens"]
+ computed["output_tokens"]
+ computed["cache_creation_tokens"]
+ computed["cache_read_tokens"]
),
"cost": computed["total_cost"],
"avg_response_time": computed["avg_response_time_ms"] / 1000.0
if computed["avg_response_time_ms"]
else 0,
"unique_models": computed["unique_models"],
"unique_providers": computed["unique_providers"],
"fallback_count": computed["fallback_count"],
}
2025-12-10 20:52:44 +08:00
else:
# 普通用户:仍需实时查询(用户级预聚合可选)
query = db.query(Usage).filter(
and_(
Usage.user_id == user.id,
Usage.created_at >= start_date,
Usage.created_at <= end_date,
)
)
user_daily_stats = (
query.with_entities(
func.date(Usage.created_at).label("date"),
func.count(Usage.id).label("requests"),
func.sum(Usage.total_tokens).label("tokens"),
func.sum(Usage.total_cost_usd).label("cost"),
func.avg(Usage.response_time_ms).label("avg_response_time"),
)
.group_by(func.date(Usage.created_at))
.order_by(func.date(Usage.created_at).asc())
.all()
)
stats_map = {
stat.date.isoformat(): {
"requests": stat.requests or 0,
"tokens": int(stat.tokens or 0),
"cost": float(stat.cost or 0),
"avg_response_time": float(stat.avg_response_time or 0) / 1000.0 if stat.avg_response_time else 0,
}
for stat in user_daily_stats
}
# 构建完整日期序列(使用业务时区日期)
current_date = start_date_local.date()
end_date_date = end_date_local.date()
2025-12-10 20:52:44 +08:00
formatted: List[dict] = []
while current_date <= end_date_date:
2025-12-10 20:52:44 +08:00
date_str = current_date.isoformat()
stat = stats_map.get(date_str)
if stat:
formatted.append({
"date": date_str,
"requests": stat["requests"],
"tokens": stat["tokens"],
"cost": stat["cost"],
"avg_response_time": stat["avg_response_time"],
"unique_models": stat.get("unique_models", 0),
"unique_providers": stat.get("unique_providers", 0),
"fallback_count": stat.get("fallback_count", 0),
})
else:
formatted.append({
"date": date_str,
"requests": 0,
"tokens": 0,
"cost": 0.0,
"avg_response_time": 0.0,
"unique_models": 0,
"unique_providers": 0,
"fallback_count": 0,
})
current_date += timedelta(days=1)
# ==================== 模型统计(仍需实时查询)====================
model_query = db.query(Usage)
if not is_admin:
model_query = model_query.filter(Usage.user_id == user.id)
model_query = model_query.filter(
and_(Usage.created_at >= start_date, Usage.created_at <= end_date)
)
model_stats = (
model_query.with_entities(
Usage.model,
func.count(Usage.id).label("requests"),
func.sum(Usage.total_tokens).label("tokens"),
func.sum(Usage.total_cost_usd).label("cost"),
func.avg(Usage.response_time_ms).label("avg_response_time"),
)
.group_by(Usage.model)
.order_by(func.sum(Usage.total_cost_usd).desc())
.all()
)
model_summary = [
{
"model": stat.model,
"requests": stat.requests or 0,
"tokens": int(stat.tokens or 0),
"cost": float(stat.cost or 0),
"avg_response_time": (
float(stat.avg_response_time or 0) / 1000.0 if stat.avg_response_time else 0
),
"cost_per_request": float(stat.cost or 0) / max(stat.requests or 1, 1),
"tokens_per_request": int(stat.tokens or 0) / max(stat.requests or 1, 1),
}
for stat in model_stats
]
daily_model_stats = (
model_query.with_entities(
func.date(Usage.created_at).label("date"),
Usage.model,
func.count(Usage.id).label("requests"),
func.sum(Usage.total_tokens).label("tokens"),
func.sum(Usage.total_cost_usd).label("cost"),
)
.group_by(func.date(Usage.created_at), Usage.model)
.order_by(func.date(Usage.created_at).desc(), func.sum(Usage.total_cost_usd).desc())
.all()
)
breakdown = {}
for stat in daily_model_stats:
date_str = stat.date.isoformat()
breakdown.setdefault(date_str, []).append(
{
"model": stat.model,
"requests": stat.requests or 0,
"tokens": int(stat.tokens or 0),
"cost": float(stat.cost or 0),
}
)
for item in formatted:
item["model_breakdown"] = breakdown.get(item["date"], [])
return {
"daily_stats": formatted,
"model_summary": model_summary,
"period": {
"start_date": start_date.date().isoformat(),
"end_date": end_date.date().isoformat(),
"days": self.days,
},
}