feat: 添加 Dashboard 供应商成本统计功能

- 新增 stats_daily_provider 表存储每日供应商统计数据
- 实现供应商维度的数据聚合服务
- Dashboard API 返回 provider_summary 供应商汇总数据
- 前端新增 DoughnutChart 环形图组件
- Dashboard 新增供应商成本分布可视化卡片
- 移除重复的请求次数/费用趋势折线图

Closes #110

Co-authored-by: RWDai <27391645+RWDai@users.noreply.github.com>
This commit is contained in:
fawney19
2026-01-19 20:23:55 +08:00
parent 6ae862980d
commit c29d57622f
8 changed files with 487 additions and 96 deletions

View File

@@ -208,7 +208,7 @@ class CleanupScheduler:
return
# 非首次运行,检查最近是否有缺失的日期需要回填
from src.models.database import StatsDailyModel
from src.models.database import StatsDailyModel, StatsDailyProvider
yesterday_business_date = today_local.date() - timedelta(days=1)
max_backfill_days: int = SystemConfigService.get_config(
@@ -223,6 +223,7 @@ class CleanupScheduler:
# 获取 StatsDaily 和 StatsDailyModel 中已有数据的日期集合
existing_daily_dates = set()
existing_model_dates = set()
existing_provider_dates = set()
daily_stats = (
db.query(StatsDaily.date)
@@ -245,6 +246,17 @@ class CleanupScheduler:
stat_date = stat_date.replace(tzinfo=timezone.utc)
existing_model_dates.add(stat_date.astimezone(app_tz).date())
provider_stats = (
db.query(StatsDailyProvider.date)
.filter(StatsDailyProvider.date >= check_start_date.isoformat())
.distinct()
.all()
)
for (stat_date,) in provider_stats:
if stat_date.tzinfo is None:
stat_date = stat_date.replace(tzinfo=timezone.utc)
existing_provider_dates.add(stat_date.astimezone(app_tz).date())
# 找出需要回填的日期
all_dates = set()
current = check_start_date
@@ -256,15 +268,18 @@ class CleanupScheduler:
missing_daily_dates = all_dates - existing_daily_dates
# 需要回填 StatsDailyModel 的日期
missing_model_dates = all_dates - existing_model_dates
# 需要回填 StatsDailyProvider 的日期
missing_provider_dates = all_dates - existing_provider_dates
# 合并所有需要处理的日期
dates_to_process = missing_daily_dates | missing_model_dates
dates_to_process = missing_daily_dates | missing_model_dates | missing_provider_dates
if dates_to_process:
sorted_dates = sorted(dates_to_process)
logger.info(
f"检测到 {len(dates_to_process)} 天的统计数据需要回填 "
f"(StatsDaily 缺失 {len(missing_daily_dates)} 天, "
f"StatsDailyModel 缺失 {len(missing_model_dates)})"
f"StatsDailyModel 缺失 {len(missing_model_dates)}, "
f"StatsDailyProvider 缺失 {len(missing_provider_dates)} 天)"
)
users = (
@@ -288,6 +303,10 @@ class CleanupScheduler:
StatsAggregatorService.aggregate_daily_model_stats(
db, current_date_local
)
if current_date in missing_provider_dates:
StatsAggregatorService.aggregate_daily_provider_stats(
db, current_date_local
)
# 用户统计在任一缺失时都回填
for (user_id,) in users:
try:
@@ -329,6 +348,7 @@ class CleanupScheduler:
StatsAggregatorService.aggregate_daily_stats(db, yesterday_local)
StatsAggregatorService.aggregate_daily_model_stats(db, yesterday_local)
StatsAggregatorService.aggregate_daily_provider_stats(db, yesterday_local)
users = db.query(DBUser.id).filter(DBUser.is_active.is_(True)).all()
for (user_id,) in users:

View File

@@ -17,6 +17,7 @@ from src.models.database import (
RequestCandidate,
StatsDaily,
StatsDailyModel,
StatsDailyProvider,
StatsSummary,
StatsUserDaily,
Usage,
@@ -286,6 +287,68 @@ class StatsAggregatorService:
)
return results
@staticmethod
def aggregate_daily_provider_stats(db: Session, date: datetime) -> list[StatsDailyProvider]:
"""聚合指定日期的供应商维度统计数据
Args:
db: 数据库会话
date: 要聚合的业务日期
Returns:
StatsDailyProvider 记录列表
"""
day_start, day_end = _get_business_day_range(date)
# 按供应商分组统计
provider_name_expr = func.coalesce(Usage.provider_name, "Unknown")
provider_stats = (
db.query(
provider_name_expr.label("provider_name"),
func.count(Usage.id).label("total_requests"),
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"),
func.sum(Usage.total_cost_usd).label("total_cost"),
)
.filter(and_(Usage.created_at >= day_start, Usage.created_at < day_end))
.group_by(provider_name_expr)
.all()
)
results = []
for stat in provider_stats:
existing = (
db.query(StatsDailyProvider)
.filter(and_(StatsDailyProvider.date == day_start, StatsDailyProvider.provider_name == stat.provider_name))
.first()
)
if existing:
record = existing
else:
record = StatsDailyProvider(
id=str(uuid.uuid4()), date=day_start, provider_name=stat.provider_name
)
record.total_requests = stat.total_requests or 0
record.input_tokens = int(stat.input_tokens or 0)
record.output_tokens = int(stat.output_tokens or 0)
record.cache_creation_tokens = int(stat.cache_creation_tokens or 0)
record.cache_read_tokens = int(stat.cache_read_tokens or 0)
record.total_cost = float(stat.total_cost or 0)
if not existing:
db.add(record)
results.append(record)
db.commit()
logger.info(
f"[StatsAggregator] 聚合日期 {date.date()} 供应商统计完成: {len(results)} 个供应商"
)
return results
@staticmethod
def get_daily_model_stats(db: Session, start_date: datetime, end_date: datetime) -> list[dict]:
"""获取日期范围内的模型统计数据(优先使用预聚合)
@@ -613,6 +676,7 @@ class StatsAggregatorService:
while current_date < today_local:
StatsAggregatorService.aggregate_daily_stats(db, current_date)
StatsAggregatorService.aggregate_daily_model_stats(db, current_date)
StatsAggregatorService.aggregate_daily_provider_stats(db, current_date)
count += 1
current_date += timedelta(days=1)