Files
Aether/src/api/dashboard/routes.py
fawney19 a12b43ce5c refactor: 清理数据库字段命名歧义
- users 表:重命名 allowed_endpoints 为 allowed_api_formats(修正历史命名错误)
- api_keys 表:删除 allowed_endpoints 字段(未使用的功能)
- providers 表:删除 rate_limit 字段(与 rpm_limit 重复)
- usage 表:重命名 provider 为 provider_name(避免与 provider_id 外键混淆)

同步更新前后端所有相关代码
2026-01-07 19:53:32 +08:00

1158 lines
50 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""仪表盘统计 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, StatsDailyModel, 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)):
"""
获取仪表盘统计数据
根据用户角色返回不同的统计数据。管理员可以看到全局数据,普通用户只能看到自己的数据。
**返回字段(管理员)**:
- `stats`: 统计卡片数组包含总请求、总费用、总Token、总缓存等信息
- `today`: 今日统计requests, cost, actual_cost, tokens, cache_creation_tokens, cache_read_tokens
- `api_keys`: API Key 统计total, active
- `tokens`: 本月 Token 统计
- `token_breakdown`: Token 详细分类input, output, cache_creation, cache_read
- `system_health`: 系统健康指标avg_response_time, error_rate, error_requests, fallback_count, total_requests
- `cost_stats`: 成本统计total_cost, total_actual_cost, cost_savings
- `cache_stats`: 缓存统计信息
- `users`: 用户统计total, active
**返回字段(普通用户)**:
- `stats`: 统计卡片数组,包含 API 密钥、本月请求、配额使用、总Token 等信息
- `today`: 今日统计
- `token_breakdown`: Token 详细分类
- `cache_stats`: 缓存统计信息
- `monthly_cost`: 本月费用
"""
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),
):
"""
获取最近请求列表
获取最近的 API 请求记录。管理员可以看到所有用户的请求,普通用户只能看到自己的请求。
**查询参数**:
- `limit`: 返回记录数,默认 10最大 100
**返回字段**:
- `requests`: 请求列表,每条记录包含:
- `id`: 请求 ID
- `user`: 用户名
- `model`: 使用的模型
- `tokens`: Token 数量
- `time`: 请求时间HH:MM 格式)
- `is_stream`: 是否为流式请求
"""
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)):
"""
获取提供商状态
获取所有活跃提供商的状态和最近 24 小时的请求统计。
**返回字段**:
- `providers`: 提供商列表,每个提供商包含:
- `name`: 提供商名称
- `status`: 状态active/inactive
- `requests`: 最近 24 小时的请求数
"""
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),
):
"""
获取每日统计数据
获取指定天数的每日使用统计数据,用于生成图表。
**查询参数**:
- `days`: 统计天数,默认 7 天,最大 30 天
**返回字段**:
- `daily_stats`: 每日统计数组,每天包含:
- `date`: 日期ISO 格式)
- `requests`: 请求数
- `tokens`: Token 数量
- `cost`: 费用USD
- `avg_response_time`: 平均响应时间(秒)
- `unique_models`: 使用的模型数量(仅管理员)
- `unique_providers`: 使用的提供商数量(仅管理员)
- `fallback_count`: 故障转移次数(仅管理员)
- `model_breakdown`: 按模型分解的统计(仅管理员)
- `model_summary`: 模型使用汇总,按费用排序
- `period`: 统计周期信息start_date, end_date, days
"""
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
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)
# 本月第一天(自然月)
month_start_local = today_local.replace(day=1)
month_start = month_start_local.astimezone(timezone.utc)
# ==================== 使用预聚合数据 ====================
# 从 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 >= month_start, 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 >= month_start).scalar() or 0
)
total_cost = (
db.query(func.sum(Usage.total_cost_usd)).filter(Usage.created_at >= month_start).scalar() or 0
)
total_actual_cost = (
db.query(func.sum(Usage.actual_total_cost_usd))
.filter(Usage.created_at >= month_start).scalar() or 0
)
error_requests = (
db.query(func.count(Usage.id))
.filter(
Usage.created_at >= month_start,
(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 >= month_start).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 >= month_start)
.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 >= month_start,
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
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)
# 本月第一天(自然月)
month_start_local = today_local.replace(day=1)
month_start = month_start_local.astimezone(timezone.utc)
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 >= month_start))
.scalar()
)
user_cost = (
db.query(func.sum(Usage.total_cost_usd))
.filter(and_(Usage.user_id == user.id, Usage.created_at >= month_start))
.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 >= month_start))
.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
monthly_input_tokens = int(cache_stats.total_input_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 = monthly_input_tokens + cache_read_tokens
cache_hit_rate = (
round((cache_read_tokens / 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 > 0:
percent = min(100, int((user.used_usd / user.quota_usd) * 100))
quota_value = f"${user.quota_usd:.0f}"
quota_change = f"已用 ${user.used_usd:.2f}"
quota_high = percent > 80
else:
quota_value = "$0"
quota_change = f"已用 ${user.used_usd:.2f}"
quota_high = True
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": "总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)}",
"icon": "Hash",
},
],
"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,
},
# 本月费用(用于下方缓存区域显示)
"monthly_cost": float(user_cost),
}
@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_name == 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
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)
# ==================== 使用预聚合数据优化 ====================
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()
stats_map = {
_to_business_date_str(stat.date): {
"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()
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_name)))
.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"],
}
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()
formatted: List[dict] = []
while current_date <= end_date_date:
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)
# ==================== 模型统计 ====================
if is_admin:
# 管理员:使用预聚合数据 + 今日实时数据
# 历史数据从 stats_daily_model 获取
historical_model_stats = (
db.query(StatsDailyModel)
.filter(and_(StatsDailyModel.date >= start_date, StatsDailyModel.date < today))
.all()
)
# 按模型汇总历史数据
model_agg: dict = {}
daily_breakdown: dict = {}
for stat in historical_model_stats:
model = stat.model
if model not in model_agg:
model_agg[model] = {
"requests": 0, "tokens": 0, "cost": 0.0,
"total_response_time": 0.0, "response_count": 0
}
model_agg[model]["requests"] += stat.total_requests
tokens = (stat.input_tokens + stat.output_tokens +
stat.cache_creation_tokens + stat.cache_read_tokens)
model_agg[model]["tokens"] += tokens
model_agg[model]["cost"] += stat.total_cost
if stat.avg_response_time_ms is not None:
model_agg[model]["total_response_time"] += stat.avg_response_time_ms * stat.total_requests
model_agg[model]["response_count"] += stat.total_requests
# 按日期分组
if stat.date.tzinfo is None:
date_utc = stat.date.replace(tzinfo=timezone.utc)
else:
date_utc = stat.date.astimezone(timezone.utc)
date_str = date_utc.astimezone(app_tz).date().isoformat()
daily_breakdown.setdefault(date_str, []).append({
"model": model,
"requests": stat.total_requests,
"tokens": tokens,
"cost": stat.total_cost,
})
# 今日实时模型统计
today_model_stats = (
db.query(
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"),
)
.filter(Usage.created_at >= today)
.group_by(Usage.model)
.all()
)
today_str = today_local.date().isoformat()
for stat in today_model_stats:
model = stat.model
if model not in model_agg:
model_agg[model] = {
"requests": 0, "tokens": 0, "cost": 0.0,
"total_response_time": 0.0, "response_count": 0
}
model_agg[model]["requests"] += stat.requests or 0
model_agg[model]["tokens"] += int(stat.tokens or 0)
model_agg[model]["cost"] += float(stat.cost or 0)
if stat.avg_response_time is not None:
model_agg[model]["total_response_time"] += float(stat.avg_response_time) * (stat.requests or 0)
model_agg[model]["response_count"] += stat.requests or 0
# 今日 breakdown
daily_breakdown.setdefault(today_str, []).append({
"model": model,
"requests": stat.requests or 0,
"tokens": int(stat.tokens or 0),
"cost": float(stat.cost or 0),
})
# 构建 model_summary
model_summary = []
for model, agg in model_agg.items():
avg_rt = (agg["total_response_time"] / agg["response_count"] / 1000.0
if agg["response_count"] > 0 else 0)
model_summary.append({
"model": model,
"requests": agg["requests"],
"tokens": agg["tokens"],
"cost": agg["cost"],
"avg_response_time": avg_rt,
"cost_per_request": agg["cost"] / max(agg["requests"], 1),
"tokens_per_request": agg["tokens"] / max(agg["requests"], 1),
})
model_summary.sort(key=lambda x: x["cost"], reverse=True)
# 填充 model_breakdown
for item in formatted:
item["model_breakdown"] = daily_breakdown.get(item["date"], [])
else:
# 普通用户:实时查询(数据量较小)
model_query = db.query(Usage).filter(
and_(
Usage.user_id == user.id,
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,
},
}