mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
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:
@@ -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')
|
||||||
@@ -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
|
||||||
|
|||||||
113
frontend/src/components/charts/DoughnutChart.vue
Normal file
113
frontend/src/components/charts/DoughnutChart.vue
Normal 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>
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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):
|
||||||
"""全局统计汇总 - 单行记录,存储截止到昨天的累计数据"""
|
"""全局统计汇总 - 单行记录,存储截止到昨天的累计数据"""
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user