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

@@ -0,0 +1,65 @@
"""add_stats_daily_provider_table
Revision ID: c868729753ad
Revises: 33e347f97c0c
Create Date: 2026-01-19 05:19:49.634662+00:00
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision = 'c868729753ad'
down_revision = '33e347f97c0c'
branch_labels = None
depends_on = None
def table_exists(table_name: str) -> bool:
"""检查表是否存在"""
bind = op.get_bind()
inspector = inspect(bind)
return table_name in inspector.get_table_names()
def index_exists(table_name: str, index_name: str) -> bool:
"""检查索引是否存在"""
bind = op.get_bind()
inspector = inspect(bind)
indexes = [idx['name'] for idx in inspector.get_indexes(table_name)]
return index_name in indexes
def upgrade() -> None:
"""应用迁移:升级到新版本"""
if not table_exists('stats_daily_provider'):
op.create_table(
'stats_daily_provider',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('date', sa.DateTime(timezone=True), nullable=False),
sa.Column('provider_name', sa.String(length=100), nullable=False),
sa.Column('total_requests', sa.Integer(), nullable=False),
sa.Column('input_tokens', sa.BigInteger(), nullable=False),
sa.Column('output_tokens', sa.BigInteger(), nullable=False),
sa.Column('cache_creation_tokens', sa.BigInteger(), nullable=False),
sa.Column('cache_read_tokens', sa.BigInteger(), nullable=False),
sa.Column('total_cost', sa.Float(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('date', 'provider_name', name='uq_stats_daily_provider')
)
op.create_index('idx_stats_daily_provider_date', 'stats_daily_provider', ['date'], unique=False)
op.create_index('idx_stats_daily_provider_date_provider', 'stats_daily_provider', ['date', 'provider_name'], unique=False)
def downgrade() -> None:
"""回滚迁移:降级到旧版本"""
if table_exists('stats_daily_provider'):
if index_exists('stats_daily_provider', 'idx_stats_daily_provider_date_provider'):
op.drop_index('idx_stats_daily_provider_date_provider', table_name='stats_daily_provider')
if index_exists('stats_daily_provider', 'idx_stats_daily_provider_date'):
op.drop_index('idx_stats_daily_provider_date', table_name='stats_daily_provider')
op.drop_table('stats_daily_provider')

View File

@@ -206,6 +206,13 @@ export interface ModelSummary {
tokens_per_request: number tokens_per_request: number
} }
export interface ProviderSummary {
provider: string
requests: number
tokens: number
cost: number
}
export interface DailyStat { export interface DailyStat {
date: string // ISO date string date: string // ISO date string
requests: number requests: number
@@ -220,6 +227,7 @@ export interface DailyStat {
export interface DailyStatsResponse { export interface DailyStatsResponse {
daily_stats: DailyStat[] daily_stats: DailyStat[]
model_summary: ModelSummary[] model_summary: ModelSummary[]
provider_summary: ProviderSummary[]
period: { period: {
start_date: string start_date: string
end_date: string end_date: string

View File

@@ -0,0 +1,113 @@
<template>
<div class="w-full h-full">
<canvas ref="chartRef" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import {
Chart as ChartJS,
ArcElement,
DoughnutController,
Title,
Tooltip,
Legend,
type ChartData,
type ChartOptions
} from 'chart.js'
ChartJS.register(
ArcElement,
DoughnutController,
Title,
Tooltip,
Legend
)
interface Props {
data: ChartData<'doughnut'>
options?: ChartOptions<'doughnut'>
height?: number
}
const props = withDefaults(defineProps<Props>(), {
height: 300,
options: undefined
})
const chartRef = ref<HTMLCanvasElement>()
let chart: ChartJS<'doughnut'> | null = null
const defaultOptions: ChartOptions<'doughnut'> = {
responsive: true,
maintainAspectRatio: false,
cutout: '60%',
plugins: {
legend: {
position: 'right',
labels: {
color: 'rgb(107, 114, 128)',
usePointStyle: true,
padding: 16,
font: { size: 11 }
}
},
tooltip: {
backgroundColor: 'rgb(31, 41, 55)',
titleColor: 'rgb(243, 244, 246)',
bodyColor: 'rgb(243, 244, 246)',
borderColor: 'rgb(75, 85, 99)',
borderWidth: 1,
callbacks: {
label: (context) => {
const value = context.raw as number
const total = (context.dataset.data as number[]).reduce((a, b) => a + b, 0)
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : '0'
return `${context.label}: $${value.toFixed(4)} (${percentage}%)`
}
}
}
}
}
function createChart() {
if (!chartRef.value) return
chart = new ChartJS(chartRef.value, {
type: 'doughnut',
data: props.data,
options: {
...defaultOptions,
...props.options
}
})
}
function updateChart() {
if (chart) {
chart.data = props.data
chart.update('none')
}
}
onMounted(async () => {
await nextTick()
createChart()
})
onUnmounted(() => {
if (chart) {
chart.destroy()
chart = null
}
})
watch(() => props.data, updateChart, { deep: true })
watch(() => props.options, () => {
if (chart) {
chart.options = { ...defaultOptions, ...props.options }
chart.update()
}
}, { deep: true })
</script>

View File

@@ -395,35 +395,6 @@
<!-- 趋势图表区域 --> <!-- 趋势图表区域 -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2"> <div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<!-- 请求次数和费用趋势 -->
<Card class="p-5">
<h4 class="mb-3 text-xs font-semibold text-foreground uppercase tracking-wider">
请求次数 / 费用趋势
</h4>
<div
v-if="loadingDaily"
class="flex items-center justify-center h-[280px]"
>
<Skeleton class="h-full w-full" />
</div>
<div
v-else
style="height: 280px;"
>
<LineChart
v-if="chartData.requests"
:data="chartData.requests"
:options="chartOptions.requests"
/>
<div
v-else
class="flex h-full items-center justify-center text-xs text-muted-foreground"
>
暂无数据
</div>
</div>
</Card>
<!-- 每日模型成本堆叠柱状图 --> <!-- 每日模型成本堆叠柱状图 -->
<Card class="p-5"> <Card class="p-5">
<h4 class="mb-3 text-xs font-semibold text-foreground uppercase tracking-wider"> <h4 class="mb-3 text-xs font-semibold text-foreground uppercase tracking-wider">
@@ -452,6 +423,35 @@
</div> </div>
</div> </div>
</Card> </Card>
<!-- 提供商成本分布环形图 -->
<Card class="p-5">
<h4 class="mb-3 text-xs font-semibold text-foreground uppercase tracking-wider">
提供商成本分布
</h4>
<div
v-if="loadingDaily"
class="flex items-center justify-center h-[280px]"
>
<Skeleton class="h-full w-full" />
</div>
<div
v-else
style="height: 280px;"
>
<DoughnutChart
v-if="providerCostChartData.labels && providerCostChartData.labels.length > 0"
:data="providerCostChartData"
:options="providerCostChartOptions"
/>
<div
v-else
class="flex h-full items-center justify-center text-xs text-muted-foreground"
>
暂无数据
</div>
</div>
</Card>
</div> </div>
<!-- 每日统计 --> <!-- 每日统计 -->
@@ -707,7 +707,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed, onBeforeUnmount, nextTick, watch } from 'vue' import { ref, onMounted, computed, onBeforeUnmount, nextTick, watch } from 'vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { dashboardApi, type DashboardStat, type DailyStat } from '@/api/dashboard' import { dashboardApi, type DashboardStat, type DailyStat, type ProviderSummary } from '@/api/dashboard'
import { announcementApi, type Announcement } from '@/api/announcements' import { announcementApi, type Announcement } from '@/api/announcements'
import { import {
Card, Card,
@@ -722,8 +722,8 @@ import {
TableHead, TableHead,
TableCell, TableCell,
} from '@/components/ui' } from '@/components/ui'
import LineChart from '@/components/charts/LineChart.vue'
import BarChart from '@/components/charts/BarChart.vue' import BarChart from '@/components/charts/BarChart.vue'
import DoughnutChart from '@/components/charts/DoughnutChart.vue'
import { import {
Users, Users,
Activity, Activity,
@@ -894,6 +894,7 @@ const tokenBreakdown = ref<{
const activeUsers = ref(0) const activeUsers = ref(0)
const dailyStats = ref<DailyStat[]>([]) const dailyStats = ref<DailyStat[]>([])
const providerSummary = ref<ProviderSummary[]>([])
const selectedDays = ref(7) const selectedDays = ref(7)
const loadingDaily = ref(false) const loadingDaily = ref(false)
const loading = ref(false) const loading = ref(false)
@@ -946,41 +947,6 @@ const totalStats = computed(() => {
} }
}) })
// 图表数据
const chartData = computed(() => {
if (dailyStats.value.length === 0) {
return { requests: null }
}
const labels = dailyStats.value.map(stat => formatDateForChart(stat.date))
const requests = dailyStats.value.map(stat => stat.requests)
const costs = dailyStats.value.map(stat => stat.cost)
return {
requests: {
labels,
datasets: [
{
label: '请求次数',
data: requests,
borderColor: 'rgb(59, 130, 246)',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
tension: 0.4,
yAxisID: 'y'
},
{
label: '费用 ($)',
data: costs,
borderColor: 'rgb(34, 197, 94)',
backgroundColor: 'rgba(34, 197, 94, 0.1)',
tension: 0.4,
yAxisID: 'y1'
}
]
} as ChartData<'line'>
}
})
// 每日模型成本(堆叠柱状图) // 每日模型成本(堆叠柱状图)
const MODEL_COLORS = [ const MODEL_COLORS = [
'rgba(59, 130, 246, 0.8)', // blue 'rgba(59, 130, 246, 0.8)', // blue
@@ -1077,37 +1043,58 @@ const dailyModelCostChartOptions = computed<ChartOptions<'bar'>>(() => ({
} }
})) }))
const chartOptions = computed(() => ({ // 提供商成本分布(环形图)
requests: { const PROVIDER_COLORS = [
scales: { 'rgba(59, 130, 246, 0.8)', // blue
y: { 'rgba(239, 68, 68, 0.8)', // red
type: 'linear', 'rgba(16, 185, 129, 0.8)', // green
display: true, 'rgba(245, 158, 11, 0.8)', // amber
position: 'left', 'rgba(139, 92, 246, 0.8)', // purple
title: { display: true, text: '请求次数', color: 'rgb(107, 114, 128)', font: { size: 10 } } 'rgba(6, 182, 212, 0.8)', // cyan
}, 'rgba(132, 204, 22, 0.8)', // lime
y1: { 'rgba(249, 115, 22, 0.8)' // orange
type: 'linear', ]
display: true,
position: 'right', const providerCostChartData = computed<ChartData<'doughnut'>>(() => {
title: { display: true, text: '费用 ($)', color: 'rgb(107, 114, 128)', font: { size: 10 } }, if (providerSummary.value.length === 0) {
grid: { drawOnChartArea: false } return { labels: [], datasets: [] }
}
return {
labels: providerSummary.value.map(p => p.provider),
datasets: [{
data: providerSummary.value.map(p => p.cost),
backgroundColor: providerSummary.value.map((_, i) => PROVIDER_COLORS[i % PROVIDER_COLORS.length]),
borderWidth: 2,
borderColor: 'rgba(255, 255, 255, 0.1)'
}]
}
})
const providerCostChartOptions = computed<ChartOptions<'doughnut'>>(() => ({
responsive: true,
maintainAspectRatio: false,
cutout: '60%',
plugins: {
legend: {
position: 'right',
labels: {
font: { size: 10 },
boxWidth: 12,
padding: 8
} }
}, },
plugins: { tooltip: {
legend: { labels: { font: { size: 11 } } }, callbacks: {
tooltip: { label: (context) => {
callbacks: { const value = context.raw as number
label: (context: any) => { const total = (context.dataset.data as number[]).reduce((a, b) => a + b, 0)
const label = context.dataset.label || '' const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : '0'
const value = context.parsed.y return `${context.label}: $${value.toFixed(4)} (${percentage}%)`
if (label.includes('费用')) return `${label}: $${value.toFixed(4)}`
return `${label}: ${value.toLocaleString()}`
}
} }
} }
} }
} as ChartOptions<'line'> }
})) }))
onMounted(async () => { onMounted(async () => {
@@ -1170,8 +1157,10 @@ async function loadDailyStats() {
try { try {
const response = await dashboardApi.getDailyStats(selectedDays.value) const response = await dashboardApi.getDailyStats(selectedDays.value)
dailyStats.value = response.daily_stats dailyStats.value = response.daily_stats
providerSummary.value = response.provider_summary || []
} catch { } catch {
dailyStats.value = [] dailyStats.value = []
providerSummary.value = []
} finally { } finally {
loadingDaily.value = false loadingDaily.value = false
} }

View File

@@ -14,7 +14,7 @@ from src.api.base.pipeline import ApiRequestPipeline
from src.config.constants import CacheTTL from src.config.constants import CacheTTL
from src.core.enums import UserRole from src.core.enums import UserRole
from src.database import get_db from src.database import get_db
from src.models.database import ApiKey, Provider, RequestCandidate, StatsDaily, StatsDailyModel, Usage from src.models.database import ApiKey, Provider, RequestCandidate, StatsDaily, StatsDailyModel, StatsDailyProvider, Usage
from src.models.database import User as DBUser from src.models.database import User as DBUser
from src.services.system.stats_aggregator import StatsAggregatorService from src.services.system.stats_aggregator import StatsAggregatorService
from src.utils.cache_decorator import cache_result from src.utils.cache_decorator import cache_result
@@ -1147,9 +1147,97 @@ class DashboardDailyStatsAdapter(DashboardAdapter):
for item in formatted: for item in formatted:
item["model_breakdown"] = breakdown.get(item["date"], []) item["model_breakdown"] = breakdown.get(item["date"], [])
# ==================== 供应商统计 ====================
if is_admin:
# 管理员:使用预聚合数据 + 今日实时数据
# 历史数据从 stats_daily_provider 获取
historical_provider_stats = (
db.query(StatsDailyProvider)
.filter(and_(StatsDailyProvider.date >= start_date, StatsDailyProvider.date < today))
.all()
)
# 按供应商汇总历史数据
provider_agg: dict[str, dict[str, int | float]] = {}
for stat in historical_provider_stats:
provider = stat.provider_name or "Unknown"
if provider not in provider_agg:
provider_agg[provider] = {"requests": 0, "tokens": 0, "cost": 0.0}
provider_agg[provider]["requests"] += stat.total_requests
tokens = (stat.input_tokens + stat.output_tokens +
stat.cache_creation_tokens + stat.cache_read_tokens)
provider_agg[provider]["tokens"] += tokens
provider_agg[provider]["cost"] += stat.total_cost
# 今日实时供应商统计
today_provider_stats = (
db.query(
Usage.provider_name,
func.count(Usage.id).label("requests"),
func.sum(Usage.total_tokens).label("tokens"),
func.sum(Usage.total_cost_usd).label("cost"),
)
.filter(Usage.created_at >= today)
.group_by(Usage.provider_name)
.all()
)
for stat in today_provider_stats:
provider = stat.provider_name or "Unknown"
if provider not in provider_agg:
provider_agg[provider] = {"requests": 0, "tokens": 0, "cost": 0.0}
provider_agg[provider]["requests"] += stat.requests or 0
provider_agg[provider]["tokens"] += int(stat.tokens or 0)
provider_agg[provider]["cost"] += float(stat.cost or 0)
# 构建 provider_summary
provider_summary = [
{
"provider": provider,
"requests": agg["requests"],
"tokens": agg["tokens"],
"cost": agg["cost"],
}
for provider, agg in provider_agg.items()
]
provider_summary.sort(key=lambda x: x["cost"], reverse=True)
else:
# 普通用户:实时查询
provider_stats = (
db.query(
Usage.provider_name,
func.count(Usage.id).label("requests"),
func.sum(Usage.total_tokens).label("tokens"),
func.sum(Usage.total_cost_usd).label("cost"),
)
.filter(
and_(
Usage.user_id == user.id,
Usage.created_at >= start_date,
Usage.created_at <= end_date
)
)
.group_by(Usage.provider_name)
.order_by(func.sum(Usage.total_cost_usd).desc())
.all()
)
provider_summary = [
{
"provider": stat.provider_name or "Unknown",
"requests": stat.requests or 0,
"tokens": int(stat.tokens or 0),
"cost": float(stat.cost or 0),
}
for stat in provider_stats
]
return { return {
"daily_stats": formatted, "daily_stats": formatted,
"model_summary": model_summary, "model_summary": model_summary,
"provider_summary": provider_summary,
"period": { "period": {
"start_date": start_date.date().isoformat(), "start_date": start_date.date().isoformat(),
"end_date": end_date.date().isoformat(), "end_date": end_date.date().isoformat(),

View File

@@ -1706,6 +1706,50 @@ class StatsDailyModel(Base):
) )
class StatsDailyProvider(Base):
"""每日供应商统计快照 - 用于快速查询每日供应商维度数据"""
__tablename__ = "stats_daily_provider"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
# 统计日期 (UTC)
date = Column(DateTime(timezone=True), nullable=False, index=True)
# 供应商名称
provider_name = Column(String(100), nullable=False)
# 请求统计
total_requests = Column(Integer, default=0, nullable=False)
# Token 统计
input_tokens = Column(BigInteger, default=0, nullable=False)
output_tokens = Column(BigInteger, default=0, nullable=False)
cache_creation_tokens = Column(BigInteger, default=0, nullable=False)
cache_read_tokens = Column(BigInteger, default=0, nullable=False)
# 成本统计 (USD)
total_cost = Column(Float, default=0.0, nullable=False)
# 时间戳
created_at = Column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
)
updated_at = Column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
nullable=False,
)
# 唯一约束:每个供应商每天只有一条记录
__table_args__ = (
UniqueConstraint("date", "provider_name", name="uq_stats_daily_provider"),
Index("idx_stats_daily_provider_date", "date"),
Index("idx_stats_daily_provider_date_provider", "date", "provider_name"),
)
class StatsSummary(Base): class StatsSummary(Base):
"""全局统计汇总 - 单行记录,存储截止到昨天的累计数据""" """全局统计汇总 - 单行记录,存储截止到昨天的累计数据"""

View File

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

View File

@@ -17,6 +17,7 @@ from src.models.database import (
RequestCandidate, RequestCandidate,
StatsDaily, StatsDaily,
StatsDailyModel, StatsDailyModel,
StatsDailyProvider,
StatsSummary, StatsSummary,
StatsUserDaily, StatsUserDaily,
Usage, Usage,
@@ -286,6 +287,68 @@ class StatsAggregatorService:
) )
return results 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 @staticmethod
def get_daily_model_stats(db: Session, start_date: datetime, end_date: datetime) -> list[dict]: 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: while current_date < today_local:
StatsAggregatorService.aggregate_daily_stats(db, current_date) StatsAggregatorService.aggregate_daily_stats(db, current_date)
StatsAggregatorService.aggregate_daily_model_stats(db, current_date) StatsAggregatorService.aggregate_daily_model_stats(db, current_date)
StatsAggregatorService.aggregate_daily_provider_stats(db, current_date)
count += 1 count += 1
current_date += timedelta(days=1) current_date += timedelta(days=1)