feat: 统计数据优化 - 支持细粒度时间范围和多维度分析

- 新增 StatsHourly/StatsDaily 预聚合表,支持任意时区的精确统计
- 实现 UTC datetime 范围查询策略,边界数据实时聚合
- 新增统计 API:用户/API Key 维度、成本分析、性能百分位、错误分类
- 新增前端页面:成本分析、性能分析、用户统计
- 新增 TimeRangePicker 组件和统计可视化组件
- 优化 Dashboard 和 Usage 页面支持时间范围筛选

Close #135
This commit is contained in:
fawney19
2026-02-04 02:04:54 +08:00
parent 6f363a7703
commit f3e2f84b38
59 changed files with 6392 additions and 500 deletions

View File

@@ -0,0 +1,339 @@
"""add_stats_hourly_and_daily_complete_flag
Revision ID: c4e8f9a1b2c3
Revises: b3c4d5e6f7a8
Create Date: 2026-02-04 12:00:00.000000
"""
from __future__ import annotations
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "c4e8f9a1b2c3"
down_revision: Union[str, None] = "b3c4d5e6f7a8"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = 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 _column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
columns = [col["name"] for col in inspector.get_columns(table_name)]
return column_name in columns
def upgrade() -> None:
if _table_exists("stats_daily"):
if not _column_exists("stats_daily", "is_complete"):
op.add_column(
"stats_daily",
sa.Column("is_complete", sa.Boolean(), nullable=False, server_default=sa.false()),
)
op.execute("UPDATE stats_daily SET is_complete = true")
if not _column_exists("stats_daily", "aggregated_at"):
op.add_column(
"stats_daily",
sa.Column("aggregated_at", sa.DateTime(timezone=True), nullable=True),
)
if not _table_exists("stats_hourly"):
op.create_table(
"stats_hourly",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("hour_utc", sa.DateTime(timezone=True), nullable=False),
sa.Column("total_requests", sa.Integer(), nullable=False),
sa.Column("success_requests", sa.Integer(), nullable=False),
sa.Column("error_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("actual_total_cost", sa.Float(), nullable=False),
sa.Column("avg_response_time_ms", sa.Float(), nullable=False),
sa.Column("is_complete", sa.Boolean(), nullable=False),
sa.Column("aggregated_at", sa.DateTime(timezone=True), nullable=True),
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("hour_utc", name="uq_stats_hourly_hour"),
)
op.create_index("idx_stats_hourly_hour", "stats_hourly", ["hour_utc"], unique=False)
if not _table_exists("stats_hourly_user"):
op.create_table(
"stats_hourly_user",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("hour_utc", sa.DateTime(timezone=True), nullable=False),
sa.Column("user_id", sa.String(length=36), nullable=False),
sa.Column("total_requests", sa.Integer(), nullable=False),
sa.Column("success_requests", sa.Integer(), nullable=False),
sa.Column("error_requests", sa.Integer(), nullable=False),
sa.Column("input_tokens", sa.BigInteger(), nullable=False),
sa.Column("output_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("hour_utc", "user_id", name="uq_stats_hourly_user"),
)
op.create_index(
"idx_stats_hourly_user_hour", "stats_hourly_user", ["hour_utc"], unique=False
)
op.create_index(
"idx_stats_hourly_user_user_hour",
"stats_hourly_user",
["user_id", "hour_utc"],
unique=False,
)
if not _table_exists("stats_hourly_model"):
op.create_table(
"stats_hourly_model",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("hour_utc", sa.DateTime(timezone=True), nullable=False),
sa.Column("model", 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("total_cost", sa.Float(), nullable=False),
sa.Column("avg_response_time_ms", 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("hour_utc", "model", name="uq_stats_hourly_model"),
)
op.create_index(
"idx_stats_hourly_model_hour", "stats_hourly_model", ["hour_utc"], unique=False
)
op.create_index(
"idx_stats_hourly_model_model_hour",
"stats_hourly_model",
["model", "hour_utc"],
unique=False,
)
if not _table_exists("stats_hourly_provider"):
op.create_table(
"stats_hourly_provider",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("hour_utc", 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("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("hour_utc", "provider_name", name="uq_stats_hourly_provider"),
)
op.create_index(
"idx_stats_hourly_provider_hour",
"stats_hourly_provider",
["hour_utc"],
unique=False,
)
if not _table_exists("stats_daily_api_key"):
op.create_table(
"stats_daily_api_key",
sa.Column("id", sa.String(length=36), primary_key=True),
sa.Column(
"api_key_id",
sa.String(length=36),
sa.ForeignKey("api_keys.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("date", sa.DateTime(timezone=True), nullable=False),
sa.Column("total_requests", sa.Integer(), nullable=False, server_default="0"),
sa.Column("success_requests", sa.Integer(), nullable=False, server_default="0"),
sa.Column("error_requests", sa.Integer(), nullable=False, server_default="0"),
sa.Column("input_tokens", sa.BigInteger(), nullable=False, server_default="0"),
sa.Column("output_tokens", sa.BigInteger(), nullable=False, server_default="0"),
sa.Column("cache_creation_tokens", sa.BigInteger(), nullable=False, server_default="0"),
sa.Column("cache_read_tokens", sa.BigInteger(), nullable=False, server_default="0"),
sa.Column("total_cost", sa.Float(), nullable=False, server_default="0"),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.UniqueConstraint("api_key_id", "date", name="uq_stats_daily_api_key"),
)
if _table_exists("stats_daily_api_key"):
if not _index_exists("stats_daily_api_key", "idx_stats_daily_api_key_date"):
op.create_index("idx_stats_daily_api_key_date", "stats_daily_api_key", ["date"])
if not _index_exists("stats_daily_api_key", "idx_stats_daily_api_key_key_date"):
op.create_index(
"idx_stats_daily_api_key_key_date",
"stats_daily_api_key",
["api_key_id", "date"],
)
if not _index_exists("stats_daily_api_key", "idx_stats_daily_api_key_date_requests"):
op.create_index(
"idx_stats_daily_api_key_date_requests",
"stats_daily_api_key",
["date", "total_requests"],
)
if not _index_exists("stats_daily_api_key", "idx_stats_daily_api_key_date_cost"):
op.create_index(
"idx_stats_daily_api_key_date_cost",
"stats_daily_api_key",
["date", "total_cost"],
)
if _table_exists("usage"):
if not _column_exists("usage", "error_category"):
op.add_column(
"usage",
sa.Column("error_category", sa.String(length=50), nullable=True),
)
op.create_index("idx_usage_error_category", "usage", ["error_category"], unique=False)
if _table_exists("stats_daily"):
for name in (
"p50_response_time_ms",
"p90_response_time_ms",
"p99_response_time_ms",
"p50_first_byte_time_ms",
"p90_first_byte_time_ms",
"p99_first_byte_time_ms",
):
if not _column_exists("stats_daily", name):
op.add_column("stats_daily", sa.Column(name, sa.Integer(), nullable=True))
if not _table_exists("stats_daily_error"):
op.create_table(
"stats_daily_error",
sa.Column("id", sa.String(length=36), primary_key=True),
sa.Column("date", sa.DateTime(timezone=True), nullable=False),
sa.Column("error_category", sa.String(length=50), nullable=False),
sa.Column("provider_name", sa.String(length=100), nullable=True),
sa.Column("model", sa.String(length=100), nullable=True),
sa.Column("count", sa.Integer(), nullable=False, server_default="0"),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.UniqueConstraint(
"date",
"error_category",
"provider_name",
"model",
name="uq_stats_daily_error",
),
)
if _table_exists("stats_daily_error"):
if not _index_exists("stats_daily_error", "idx_stats_daily_error_date"):
op.create_index("idx_stats_daily_error_date", "stats_daily_error", ["date"])
if not _index_exists("stats_daily_error", "idx_stats_daily_error_category"):
op.create_index(
"idx_stats_daily_error_category",
"stats_daily_error",
["date", "error_category"],
)
def downgrade() -> None:
if _table_exists("stats_daily_error"):
if _index_exists("stats_daily_error", "idx_stats_daily_error_category"):
op.drop_index("idx_stats_daily_error_category", table_name="stats_daily_error")
if _index_exists("stats_daily_error", "idx_stats_daily_error_date"):
op.drop_index("idx_stats_daily_error_date", table_name="stats_daily_error")
op.drop_table("stats_daily_error")
if _table_exists("stats_daily"):
for name in (
"p50_response_time_ms",
"p90_response_time_ms",
"p99_response_time_ms",
"p50_first_byte_time_ms",
"p90_first_byte_time_ms",
"p99_first_byte_time_ms",
):
if _column_exists("stats_daily", name):
op.drop_column("stats_daily", name)
if _table_exists("usage") and _column_exists("usage", "error_category"):
if _index_exists("usage", "idx_usage_error_category"):
op.drop_index("idx_usage_error_category", table_name="usage")
op.drop_column("usage", "error_category")
if _table_exists("stats_daily_api_key"):
if _index_exists("stats_daily_api_key", "idx_stats_daily_api_key_date_cost"):
op.drop_index("idx_stats_daily_api_key_date_cost", table_name="stats_daily_api_key")
if _index_exists("stats_daily_api_key", "idx_stats_daily_api_key_date_requests"):
op.drop_index("idx_stats_daily_api_key_date_requests", table_name="stats_daily_api_key")
if _index_exists("stats_daily_api_key", "idx_stats_daily_api_key_key_date"):
op.drop_index("idx_stats_daily_api_key_key_date", table_name="stats_daily_api_key")
if _index_exists("stats_daily_api_key", "idx_stats_daily_api_key_date"):
op.drop_index("idx_stats_daily_api_key_date", table_name="stats_daily_api_key")
op.drop_table("stats_daily_api_key")
if _table_exists("stats_hourly_provider"):
if _index_exists("stats_hourly_provider", "idx_stats_hourly_provider_hour"):
op.drop_index("idx_stats_hourly_provider_hour", table_name="stats_hourly_provider")
op.drop_table("stats_hourly_provider")
if _table_exists("stats_hourly_model"):
if _index_exists("stats_hourly_model", "idx_stats_hourly_model_model_hour"):
op.drop_index("idx_stats_hourly_model_model_hour", table_name="stats_hourly_model")
if _index_exists("stats_hourly_model", "idx_stats_hourly_model_hour"):
op.drop_index("idx_stats_hourly_model_hour", table_name="stats_hourly_model")
op.drop_table("stats_hourly_model")
if _table_exists("stats_hourly_user"):
if _index_exists("stats_hourly_user", "idx_stats_hourly_user_user_hour"):
op.drop_index("idx_stats_hourly_user_user_hour", table_name="stats_hourly_user")
if _index_exists("stats_hourly_user", "idx_stats_hourly_user_hour"):
op.drop_index("idx_stats_hourly_user_hour", table_name="stats_hourly_user")
op.drop_table("stats_hourly_user")
if _table_exists("stats_hourly"):
if _index_exists("stats_hourly", "idx_stats_hourly_hour"):
op.drop_index("idx_stats_hourly_hour", table_name="stats_hourly")
op.drop_table("stats_hourly")
if _table_exists("stats_daily"):
if _column_exists("stats_daily", "aggregated_at"):
op.drop_column("stats_daily", "aggregated_at")
if _column_exists("stats_daily", "is_complete"):
op.drop_column("stats_daily", "is_complete")

View File

@@ -340,6 +340,102 @@ export interface AdminApiKeysResponse {
skip: number
}
export interface LeaderboardItem {
rank: number
id: string
name: string
value: number
requests: number
tokens: number
cost: number
}
export interface LeaderboardResponse {
items: LeaderboardItem[]
total: number
metric: string
start_date?: string | null
end_date?: string | null
}
export interface CostForecastResponse {
history: Array<{ date: string; total_cost: number }>
forecast: Array<{ date: string; total_cost: number }>
slope: number
intercept: number
start_date: string
end_date: string
}
export interface CostSavingsResponse {
cache_read_tokens: number
cache_read_cost: number
cache_creation_cost: number
estimated_full_cost: number
cache_savings: number
}
export interface QuotaUsageProvider {
id: string
name: string
quota_usd: number
used_usd: number
remaining_usd: number
usage_percent: number
quota_expires_at?: string | null
estimated_exhaust_at?: string | null
}
export interface QuotaUsageResponse {
providers: QuotaUsageProvider[]
}
export interface PercentileItem {
date: string
p50_response_time_ms?: number | null
p90_response_time_ms?: number | null
p99_response_time_ms?: number | null
p50_first_byte_time_ms?: number | null
p90_first_byte_time_ms?: number | null
p99_first_byte_time_ms?: number | null
}
export interface ErrorDistributionItem {
category: string
count: number
}
export interface ErrorTrendItem {
date: string
total: number
categories: Record<string, number>
}
export interface ErrorDistributionResponse {
distribution: ErrorDistributionItem[]
trend: ErrorTrendItem[]
}
export interface ComparisonMetric {
total_requests: number
total_tokens: number
total_cost: number
actual_total_cost: number
avg_response_time_ms: number
error_requests: number
}
export interface ComparisonResponse {
current: ComparisonMetric
comparison: ComparisonMetric
change_percent: Record<string, number | null>
current_start: string
current_end: string
comparison_start: string
comparison_end: string
}
export interface ApiKeyToggleResponse {
id: string // UUID
is_active: boolean
@@ -617,4 +713,156 @@ export const adminApi = {
const response = await apiClient.post<LdapTestResponse>('/api/admin/ldap/test', config)
return response.data
},
// Stats / Leaderboards
async getLeaderboardUsers(params?: {
start_date?: string
end_date?: string
preset?: string
timezone?: string
tz_offset_minutes?: number
metric?: 'requests' | 'tokens' | 'cost'
order?: 'asc' | 'desc'
limit?: number
offset?: number
provider_name?: string
model?: string
include_inactive?: boolean
exclude_admin?: boolean
}): Promise<LeaderboardResponse> {
const response = await apiClient.get<LeaderboardResponse>('/api/admin/stats/leaderboard/users', {
params
})
return response.data
},
async getLeaderboardApiKeys(params?: {
start_date?: string
end_date?: string
preset?: string
timezone?: string
tz_offset_minutes?: number
metric?: 'requests' | 'tokens' | 'cost'
order?: 'asc' | 'desc'
limit?: number
offset?: number
provider_name?: string
model?: string
include_inactive?: boolean
exclude_admin?: boolean
}): Promise<LeaderboardResponse> {
const response = await apiClient.get<LeaderboardResponse>('/api/admin/stats/leaderboard/api-keys', {
params
})
return response.data
},
async getLeaderboardModels(params?: {
start_date?: string
end_date?: string
preset?: string
timezone?: string
tz_offset_minutes?: number
metric?: 'requests' | 'tokens' | 'cost'
order?: 'asc' | 'desc'
limit?: number
offset?: number
provider_name?: string
model?: string
}): Promise<LeaderboardResponse> {
const response = await apiClient.get<LeaderboardResponse>('/api/admin/stats/leaderboard/models', {
params
})
return response.data
},
async getCostForecast(params?: {
start_date?: string
end_date?: string
preset?: string
timezone?: string
tz_offset_minutes?: number
days?: number
forecast_days?: number
}): Promise<CostForecastResponse> {
const response = await apiClient.get<CostForecastResponse>('/api/admin/stats/cost/forecast', {
params
})
return response.data
},
async getCostSavings(params?: {
start_date?: string
end_date?: string
preset?: string
timezone?: string
tz_offset_minutes?: number
provider_name?: string
model?: string
}): Promise<CostSavingsResponse> {
const response = await apiClient.get<CostSavingsResponse>('/api/admin/stats/cost/savings', {
params
})
return response.data
},
async getQuotaUsage(): Promise<QuotaUsageResponse> {
const response = await apiClient.get<QuotaUsageResponse>('/api/admin/stats/providers/quota-usage')
return response.data
},
async getPercentiles(params?: {
start_date?: string
end_date?: string
preset?: string
timezone?: string
tz_offset_minutes?: number
}): Promise<PercentileItem[]> {
const response = await apiClient.get<PercentileItem[]>('/api/admin/stats/performance/percentiles', {
params
})
return response.data
},
async getErrorDistribution(params?: {
start_date?: string
end_date?: string
preset?: string
timezone?: string
tz_offset_minutes?: number
}): Promise<ErrorDistributionResponse> {
const response = await apiClient.get<ErrorDistributionResponse>('/api/admin/stats/errors/distribution', {
params
})
return response.data
},
async getComparison(params: {
current_start: string
current_end: string
comparison_type?: 'period' | 'year'
timezone?: string
tz_offset_minutes?: number
}): Promise<ComparisonResponse> {
const response = await apiClient.get<ComparisonResponse>('/api/admin/stats/comparison', {
params
})
return response.data
},
async getTimeSeries(params?: {
start_date?: string
end_date?: string
preset?: string
granularity?: 'hour' | 'day' | 'week' | 'month'
timezone?: string
tz_offset_minutes?: number
user_id?: string
model?: string
provider_name?: string
}): Promise<any[]> {
const response = await apiClient.get<any[]>('/api/admin/stats/time-series', { params })
return response.data
},
}

View File

@@ -250,10 +250,19 @@ export interface DailyStatsResponse {
}
}
export interface TimeRangeParams {
start_date?: string
end_date?: string
preset?: string
granularity?: 'hour' | 'day' | 'week' | 'month'
timezone?: string
tz_offset_minutes?: number
}
export const dashboardApi = {
// 获取仪表盘统计数据
async getStats(): Promise<DashboardStatsResponse> {
const response = await apiClient.get<DashboardStatsResponse>('/api/dashboard/stats')
async getStats(params?: TimeRangeParams): Promise<DashboardStatsResponse> {
const response = await apiClient.get<DashboardStatsResponse>('/api/dashboard/stats', { params })
return response.data
},
@@ -279,9 +288,9 @@ export const dashboardApi = {
},
// 获取每日统计数据
async getDailyStats(days: number = 7): Promise<DailyStatsResponse> {
async getDailyStats(params?: TimeRangeParams & { days?: number }): Promise<DailyStatsResponse> {
const response = await apiClient.get<DailyStatsResponse>('/api/dashboard/daily-stats', {
params: { days }
params
})
return response.data
}

View File

@@ -201,6 +201,9 @@ export const meApi = {
async getUsage(params?: {
start_date?: string
end_date?: string
preset?: string
timezone?: string
tz_offset_minutes?: number
search?: string // 通用搜索:密钥名、模型名
limit?: number
offset?: number

View File

@@ -78,6 +78,10 @@ export interface UsageFilters {
model?: string
start_date?: string
end_date?: string
preset?: string
granularity?: 'hour' | 'day' | 'week' | 'month'
timezone?: string
tz_offset_minutes?: number
page?: number
page_size?: number
}
@@ -164,11 +168,16 @@ export const usageApi = {
async getAllUsageRecords(params?: {
start_date?: string
end_date?: string
preset?: string
granularity?: 'hour' | 'day' | 'week' | 'month'
timezone?: string
tz_offset_minutes?: number
search?: string // 通用搜索:用户名、密钥名、模型名、提供商名
user_id?: string // UUID
username?: string
model?: string
provider?: string
api_format?: string // API 格式筛选(如 openai:chat, claude:chat
status?: string // 'stream' | 'standard' | 'error'
limit?: number
offset?: number

View File

@@ -0,0 +1,131 @@
<template>
<div class="flex flex-wrap items-center gap-2">
<Select v-model:open="presetSelectOpen" v-model="selectedPreset">
<SelectTrigger class="h-8 w-32 text-xs border-border/60">
<SelectValue placeholder="选择时间段" />
</SelectTrigger>
<SelectContent>
<SelectItem value="today">今天</SelectItem>
<SelectItem value="yesterday">昨天</SelectItem>
<SelectItem value="last7days">最近7天</SelectItem>
<SelectItem value="last30days">最近30天</SelectItem>
<SelectItem value="last90days">最近90天</SelectItem>
<SelectItem value="this_week">本周</SelectItem>
<SelectItem value="last_week">上周</SelectItem>
<SelectItem value="this_month">本月</SelectItem>
<SelectItem value="last_month">上月</SelectItem>
<SelectItem value="this_year">今年</SelectItem>
<SelectItem value="custom">自定义</SelectItem>
</SelectContent>
</Select>
<div
v-if="selectedPreset === 'custom'"
class="flex items-center gap-2"
>
<Input
v-model="startDate"
type="date"
class="h-8 w-36 text-xs border-border/60"
/>
<span class="text-xs text-muted-foreground"></span>
<Input
v-model="endDate"
type="date"
class="h-8 w-36 text-xs border-border/60"
/>
</div>
<Select v-if="showGranularity" v-model:open="granularitySelectOpen" v-model="selectedGranularity">
<SelectTrigger class="h-8 w-24 text-xs border-border/60">
<SelectValue placeholder="粒度" />
</SelectTrigger>
<SelectContent>
<SelectItem v-if="allowHourly && canUseHourly" value="hour">小时</SelectItem>
<SelectItem value="day"></SelectItem>
<SelectItem value="week"></SelectItem>
<SelectItem value="month"></SelectItem>
</SelectContent>
</Select>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Input
} from '@/components/ui'
import type { DateRangeParams } from '@/features/usage/types'
const props = defineProps<{
modelValue: DateRangeParams
showGranularity?: boolean
allowHourly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: DateRangeParams]
}>()
const selectedPreset = ref(props.modelValue.preset || 'last7days')
const startDate = ref(props.modelValue.start_date || '')
const endDate = ref(props.modelValue.end_date || '')
const selectedGranularity = ref(props.modelValue.granularity || 'day')
const presetSelectOpen = ref(false)
const granularitySelectOpen = ref(false)
const showGranularity = computed(() => props.showGranularity !== false)
const allowHourly = computed(() => props.allowHourly === true)
const canUseHourly = computed(() => {
if (selectedPreset.value === 'today' || selectedPreset.value === 'yesterday') return true
if (selectedPreset.value === 'custom' && startDate.value && endDate.value) {
return startDate.value === endDate.value
}
return false
})
watch(() => props.modelValue, (value) => {
if (value.preset) selectedPreset.value = value.preset
if (value.start_date !== undefined) startDate.value = value.start_date || ''
if (value.end_date !== undefined) endDate.value = value.end_date || ''
if (value.granularity) selectedGranularity.value = value.granularity
}, { deep: true })
watch([selectedPreset, startDate, endDate, selectedGranularity], () => {
if (!allowHourly.value || !canUseHourly.value) {
if (selectedGranularity.value === 'hour') {
selectedGranularity.value = 'day'
}
}
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
const tz_offset_minutes = -new Date().getTimezoneOffset()
if (selectedPreset.value === 'custom') {
if (!startDate.value || !endDate.value) return
const start = startDate.value <= endDate.value ? startDate.value : endDate.value
const end = endDate.value >= startDate.value ? endDate.value : startDate.value
emit('update:modelValue', {
start_date: start,
end_date: end,
granularity: selectedGranularity.value,
timezone,
tz_offset_minutes
})
return
}
emit('update:modelValue', {
preset: selectedPreset.value,
granularity: selectedGranularity.value,
timezone,
tz_offset_minutes
})
}, { immediate: true })
</script>

View File

@@ -10,3 +10,4 @@ export { default as LoadingState } from './LoadingState.vue'
// 表单组件
export { default as ModelMultiSelect } from './ModelMultiSelect.vue'
export { default as TimeRangePicker } from './TimeRangePicker.vue'

View File

@@ -0,0 +1,31 @@
<template>
<Card class="p-4 space-y-2">
<div class="text-xs text-muted-foreground">{{ label }}</div>
<div class="text-lg font-semibold">{{ value }}</div>
<div class="text-xs" :class="changeClass">
<span v-if="changePercent !== null">{{ changePercent }}%</span>
<span v-else>--</span>
<span class="ml-1 text-muted-foreground">vs 对比期</span>
</div>
</Card>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Card } from '@/components/ui'
interface Props {
label: string
value: string
changePercent: number | null
}
const props = defineProps<Props>()
const changeClass = computed(() => {
if (props.changePercent === null) return 'text-muted-foreground'
if (props.changePercent > 0) return 'text-emerald-500'
if (props.changePercent < 0) return 'text-rose-500'
return 'text-muted-foreground'
})
</script>

View File

@@ -0,0 +1,78 @@
<template>
<div class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold">{{ title }}</h3>
<span class="text-xs text-muted-foreground" v-if="subtitle">{{ subtitle }}</span>
</div>
<div v-if="loading" class="p-6">
<LoadingState />
</div>
<div v-else class="h-[280px]">
<LineChart :data="chartData" :options="chartOptions" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import LineChart from '@/components/charts/LineChart.vue'
import { LoadingState } from '@/components/common'
import { formatCurrency } from '@/utils/format'
interface Props {
title: string
subtitle?: string
history: Array<{ date: string; total_cost: number }>
forecast: Array<{ date: string; total_cost: number }>
loading?: boolean
}
const props = withDefaults(defineProps<Props>(), {
loading: false
})
const labels = computed(() => [
...props.history.map(item => item.date),
...props.forecast.map(item => item.date)
])
const chartData = computed(() => {
const historyValues = props.history.map(item => item.total_cost)
const forecastValues = props.forecast.map(item => item.total_cost)
return {
labels: labels.value,
datasets: [
{
label: '实际成本',
data: historyValues.concat(new Array(forecastValues.length).fill(null)),
borderColor: 'rgb(59, 130, 246)',
backgroundColor: 'rgba(59, 130, 246, 0.15)',
tension: 0.25,
pointRadius: 2
},
{
label: '预测成本',
data: new Array(historyValues.length).fill(null).concat(forecastValues),
borderColor: 'rgb(234, 179, 8)',
backgroundColor: 'rgba(234, 179, 8, 0.15)',
borderDash: [6, 4],
tension: 0.25,
pointRadius: 2
}
]
}
})
const chartOptions = computed(() => ({
plugins: {
tooltip: {
callbacks: {
label: (context: any) => {
const value = context.parsed?.y ?? 0
return `${context.dataset.label}: ${formatCurrency(value)}`
}
}
}
}
}))
</script>

View File

@@ -0,0 +1,57 @@
<template>
<div class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold">{{ title }}</h3>
<span class="text-xs text-muted-foreground" v-if="subtitle">{{ subtitle }}</span>
</div>
<div v-if="loading" class="p-6">
<LoadingState />
</div>
<div v-else class="h-[260px]">
<DoughnutChart :data="chartData" :options="chartOptions" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import DoughnutChart from '@/components/charts/DoughnutChart.vue'
import { LoadingState } from '@/components/common'
import type { ErrorDistributionItem } from '@/api/admin'
interface Props {
title: string
subtitle?: string
distribution: ErrorDistributionItem[]
loading?: boolean
}
const props = withDefaults(defineProps<Props>(), {
loading: false
})
const chartData = computed(() => ({
labels: props.distribution.map(item => item.category),
datasets: [
{
data: props.distribution.map(item => item.count),
backgroundColor: [
'rgba(239, 68, 68, 0.7)',
'rgba(59, 130, 246, 0.7)',
'rgba(234, 179, 8, 0.7)',
'rgba(34, 197, 94, 0.7)',
'rgba(148, 163, 184, 0.7)'
],
borderWidth: 0
}
]
}))
const chartOptions = computed(() => ({
plugins: {
legend: {
position: 'bottom' as const
}
}
}))
</script>

View File

@@ -0,0 +1,95 @@
<template>
<TableCard :title="title">
<template #actions>
<Select
v-if="showMetricSelect"
v-model:open="metricSelectOpen"
:model-value="metric"
@update:model-value="emitMetric"
>
<SelectTrigger class="h-8 text-xs w-28">
<SelectValue placeholder="指标" />
</SelectTrigger>
<SelectContent>
<SelectItem value="requests">请求数</SelectItem>
<SelectItem value="tokens">Tokens</SelectItem>
<SelectItem value="cost">成本</SelectItem>
</SelectContent>
</Select>
</template>
<div v-if="loading" class="p-6">
<LoadingState />
</div>
<div v-else-if="items.length === 0" class="p-6">
<EmptyState title="暂无数据" description="当前时间范围内没有统计结果" />
</div>
<Table v-else>
<TableHeader>
<TableRow>
<TableHead class="w-16">排名</TableHead>
<TableHead>名称</TableHead>
<TableHead class="text-right">请求数</TableHead>
<TableHead class="text-right">Tokens</TableHead>
<TableHead class="text-right">成本</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="item in items" :key="item.id">
<TableCell class="font-medium">{{ item.rank }}</TableCell>
<TableCell>{{ item.name }}</TableCell>
<TableCell class="text-right">{{ item.requests }}</TableCell>
<TableCell class="text-right">{{ formatTokens(item.tokens) }}</TableCell>
<TableCell class="text-right">{{ formatCurrency(item.cost) }}</TableCell>
</TableRow>
</TableBody>
</Table>
</TableCard>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { EmptyState, LoadingState } from '@/components/common'
import { TableCard } from '@/components/ui'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui'
import { formatCurrency, formatTokens } from '@/utils/format'
import type { LeaderboardItem } from '@/api/admin'
interface Props {
title: string
items: LeaderboardItem[]
metric: 'requests' | 'tokens' | 'cost'
loading?: boolean
showMetricSelect?: boolean
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
showMetricSelect: true
})
const emit = defineEmits<{
(e: 'update:metric', value: 'requests' | 'tokens' | 'cost'): void
}>()
const metric = computed(() => props.metric)
const metricSelectOpen = ref(false)
function emitMetric(value: string) {
if (value === 'requests' || value === 'tokens' || value === 'cost') {
emit('update:metric', value)
}
}
</script>

View File

@@ -0,0 +1,84 @@
<template>
<div class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold">{{ title }}</h3>
<span class="text-xs text-muted-foreground" v-if="subtitle">{{ subtitle }}</span>
</div>
<div v-if="loading" class="p-6">
<LoadingState />
</div>
<div v-else class="h-[260px]">
<LineChart :data="chartData" :options="chartOptions" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import LineChart from '@/components/charts/LineChart.vue'
import { LoadingState } from '@/components/common'
import type { PercentileItem } from '@/api/admin'
interface Props {
title: string
subtitle?: string
series: PercentileItem[]
mode: 'response' | 'ttfb'
loading?: boolean
}
const props = withDefaults(defineProps<Props>(), {
loading: false
})
const labels = computed(() => props.series.map(item => item.date))
// 毫秒转秒
function msToSeconds(ms: number | null | undefined): number | null {
if (ms == null) return null
return ms / 1000
}
const chartData = computed(() => {
const p50Key = props.mode === 'response' ? 'p50_response_time_ms' : 'p50_first_byte_time_ms'
const p90Key = props.mode === 'response' ? 'p90_response_time_ms' : 'p90_first_byte_time_ms'
const p99Key = props.mode === 'response' ? 'p99_response_time_ms' : 'p99_first_byte_time_ms'
return {
labels: labels.value,
datasets: [
{
label: 'P50',
data: props.series.map(item => msToSeconds(item[p50Key])),
borderColor: 'rgb(59, 130, 246)',
tension: 0.25,
pointRadius: 2
},
{
label: 'P90',
data: props.series.map(item => msToSeconds(item[p90Key])),
borderColor: 'rgb(234, 179, 8)',
tension: 0.25,
pointRadius: 2
},
{
label: 'P99',
data: props.series.map(item => msToSeconds(item[p99Key])),
borderColor: 'rgb(239, 68, 68)',
tension: 0.25,
pointRadius: 2
}
]
}
})
const chartOptions = computed(() => ({
scales: {
y: {
ticks: {
callback: (value: number) => `${value}s`
}
}
}
}))
</script>

View File

@@ -0,0 +1,59 @@
<template>
<Card class="p-4 space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold">{{ title }}</h3>
<span class="text-xs text-muted-foreground" v-if="subtitle">{{ subtitle }}</span>
</div>
<div v-if="loading" class="p-4">
<LoadingState />
</div>
<div v-else-if="providers.length === 0" class="p-4">
<EmptyState title="暂无数据" description="暂无月卡配额数据" />
</div>
<div v-else class="space-y-4">
<div v-for="provider in providers" :key="provider.id" class="space-y-2">
<div class="flex items-center justify-between text-xs">
<span class="font-medium">{{ provider.name }}</span>
<span class="text-muted-foreground">
{{ formatCurrency(provider.used_usd) }} / {{ formatCurrency(provider.quota_usd) }}
</span>
</div>
<div class="h-2 rounded-full bg-muted">
<div
class="h-2 rounded-full bg-primary"
:style="{ width: `${Math.min(provider.usage_percent, 100)}%` }"
/>
</div>
<div class="flex items-center justify-between text-[11px] text-muted-foreground">
<span>剩余 {{ formatCurrency(provider.remaining_usd) }}</span>
<span v-if="provider.estimated_exhaust_at">
预计耗尽 {{ formatDate(provider.estimated_exhaust_at) }}
</span>
</div>
</div>
</div>
</Card>
</template>
<script setup lang="ts">
import { Card } from '@/components/ui'
import { EmptyState, LoadingState } from '@/components/common'
import { formatCurrency } from '@/utils/format'
import type { QuotaUsageProvider } from '@/api/admin'
interface Props {
title: string
subtitle?: string
providers: QuotaUsageProvider[]
loading?: boolean
}
const props = withDefaults(defineProps<Props>(), {
loading: false
})
function formatDate(value: string) {
return new Date(value).toLocaleDateString()
}
</script>

View File

@@ -0,0 +1,7 @@
export { default as ActivityHeatmap } from './ActivityHeatmap.vue'
export { default as LeaderboardTable } from './LeaderboardTable.vue'
export { default as CostForecastChart } from './CostForecastChart.vue'
export { default as QuotaProgressCard } from './QuotaProgressCard.vue'
export { default as PercentileChart } from './PercentileChart.vue'
export { default as ErrorDistributionChart } from './ErrorDistributionChart.vue'
export { default as ComparisonCard } from './ComparisonCard.vue'

View File

@@ -37,17 +37,20 @@
</div>
<div class="flex items-center gap-1.5">
<!-- 格式转换按钮 -->
<Button
variant="ghost"
size="icon"
class="h-7 w-7 mr-1"
:class="endpoint.format_acceptance_config?.enabled ? 'text-primary' : ''"
:title="endpoint.format_acceptance_config?.enabled ? '已启用格式转换(点击关闭)' : '启用格式转换'"
:disabled="togglingFormatEndpointId === endpoint.id"
@click="handleToggleFormatConversion(endpoint)"
<span
class="mr-1"
:title="isEndpointFormatConversionDisabled ? formatConversionDisabledTooltip : (endpoint.format_acceptance_config?.enabled ? '已启用格式转换(点击关闭)' : '启用格式转换')"
>
<Shuffle class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
:class="`h-7 w-7 ${endpoint.format_acceptance_config?.enabled ? 'text-primary' : ''} ${isEndpointFormatConversionDisabled ? 'opacity-50' : ''}`"
:disabled="togglingFormatEndpointId === endpoint.id || isEndpointFormatConversionDisabled"
@click="handleToggleFormatConversion(endpoint)"
>
<Shuffle class="w-3.5 h-3.5" />
</Button>
</span>
<!-- 启用/停用 -->
<Button
variant="ghost"
@@ -63,7 +66,7 @@
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-destructive hover:text-destructive"
class="h-7 w-7 hover:text-destructive"
title="删除"
:disabled="deletingEndpointId === endpoint.id"
@click="handleDeleteEndpoint(endpoint)"
@@ -369,55 +372,62 @@
<!-- 添加新端点 -->
<div
v-if="availableFormats.length > 0"
class="rounded-lg border border-dashed p-3"
class="rounded-lg border border-dashed"
>
<div class="flex items-end gap-3">
<div class="w-32 shrink-0 space-y-1">
<Label class="text-xs text-muted-foreground">API 格式</Label>
<Select
v-model="newEndpoint.api_format"
:open="formatSelectOpen"
@update:open="handleFormatSelectOpen"
>
<SelectTrigger class="h-8">
<SelectValue placeholder="选择格式" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="format in availableFormats"
:key="format.value"
:value="format.value"
>
{{ format.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex-1 min-w-0 space-y-1">
<Label class="text-xs text-muted-foreground">Base URL</Label>
<Input
v-model="newEndpoint.base_url"
size="sm"
:placeholder="provider?.website || 'https://api.example.com'"
/>
</div>
<div class="w-36 shrink-0 space-y-1">
<Label class="text-xs text-muted-foreground">自定义路径</Label>
<Input
v-model="newEndpoint.custom_path"
size="sm"
:placeholder="newEndpointDefaultPath || '留空使用默认'"
/>
</div>
<!-- 卡片头部API 格式选择 + 添加按钮 -->
<div class="flex items-center justify-between px-4 py-2.5 bg-muted/30 border-b border-dashed">
<Select
v-model="newEndpoint.api_format"
:open="formatSelectOpen"
@update:open="handleFormatSelectOpen"
>
<SelectTrigger class="h-auto w-auto gap-1.5 !border-0 bg-transparent !shadow-none p-0 font-medium rounded-none flex-row-reverse !ring-0 !ring-offset-0 !outline-none [&>svg]:h-4 [&>svg]:w-4 [&>svg]:opacity-70">
<SelectValue placeholder="选择格式..." />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="format in availableFormats"
:key="format.value"
:value="format.value"
>
{{ format.label }}
</SelectItem>
</SelectContent>
</Select>
<Button
size="sm"
class="shrink-0 h-8"
variant="ghost"
size="icon"
class="h-7 w-7 text-primary hover:text-primary"
title="添加"
:disabled="!newEndpoint.api_format || (!newEndpoint.base_url?.trim() && !provider?.website?.trim()) || addingEndpoint"
@click="handleAddEndpoint"
>
{{ addingEndpoint ? '添加中...' : '添加' }}
<Plus class="w-3.5 h-3.5" />
</Button>
</div>
<!-- 卡片内容URL 配置 -->
<div class="p-4">
<div class="flex items-end gap-3">
<div class="flex-1 min-w-0 grid grid-cols-3 gap-3">
<div class="col-span-2 space-y-1.5">
<Label class="text-xs text-muted-foreground">Base URL</Label>
<Input
v-model="newEndpoint.base_url"
size="sm"
:placeholder="provider?.website || 'https://api.example.com'"
/>
</div>
<div class="space-y-1.5">
<Label class="text-xs text-muted-foreground">自定义路径</Label>
<Input
v-model="newEndpoint.custom_path"
size="sm"
:placeholder="newEndpointDefaultPath || '留空使用默认'"
/>
</div>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
@@ -516,8 +526,26 @@ const props = defineProps<{
modelValue: boolean
provider: ProviderWithEndpointsSummary | null
endpoints?: ProviderEndpoint[]
systemFormatConversionEnabled?: boolean
providerFormatConversionEnabled?: boolean
}>()
// 计算端点级格式转换是否应该被禁用
const isEndpointFormatConversionDisabled = computed(() => {
return props.systemFormatConversionEnabled || props.providerFormatConversionEnabled
})
// 获取禁用提示
const formatConversionDisabledTooltip = computed(() => {
if (props.systemFormatConversionEnabled) {
return '请先关闭系统级开关'
}
if (props.providerFormatConversionEnabled) {
return '请先关闭提供商级开关'
}
return ''
})
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'endpointCreated': []

View File

@@ -104,7 +104,7 @@
type="button"
variant="ghost"
size="icon"
class="shrink-0 text-destructive hover:text-destructive h-8 w-8"
class="shrink-0 hover:text-destructive h-8 w-8"
@click="removeAlias(index)"
>
<X class="w-4 h-4" />

View File

@@ -55,15 +55,17 @@
</div>
</div>
<div class="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
:title="provider.enable_format_conversion ? '已启用格式转换(点击关闭)' : '启用格式转换'"
:class="provider.enable_format_conversion ? 'text-primary' : ''"
@click="toggleFormatConversion"
>
<Shuffle class="w-4 h-4" />
</Button>
<span :title="systemFormatConversionEnabled ? '请先关闭系统级开关' : (provider.enable_format_conversion ? '已启用格式转换(点击关闭)' : '启用格式转换')">
<Button
variant="ghost"
size="icon"
:class="`${provider.enable_format_conversion ? 'text-primary' : ''} ${systemFormatConversionEnabled ? 'opacity-50' : ''}`"
:disabled="systemFormatConversionEnabled"
@click="toggleFormatConversion"
>
<Shuffle class="w-4 h-4" />
</Button>
</span>
<Button
variant="ghost"
size="icon"
@@ -437,6 +439,8 @@
v-model="endpointDialogOpen"
:provider="provider"
:endpoints="endpoints"
:system-format-conversion-enabled="systemFormatConversionEnabled"
:provider-format-conversion-enabled="provider.enable_format_conversion"
@endpoint-created="handleEndpointChanged"
@endpoint-updated="handleEndpointChanged"
/>
@@ -522,6 +526,7 @@ import { useToast } from '@/composables/useToast'
import { useClipboard } from '@/composables/useClipboard'
import { useCountdownTimer, formatCountdown } from '@/composables/useCountdownTimer'
import { getProvider, getProviderEndpoints, updateProvider } from '@/api/endpoints'
import { adminApi } from '@/api/admin'
import {
KeyFormDialog,
KeyAllowedModelsEditDialog,
@@ -575,6 +580,9 @@ const provider = ref<any>(null)
const endpoints = ref<ProviderEndpointWithKeys[]>([])
const providerKeys = ref<EndpointAPIKey[]>([]) // Provider 级别的 keys
// 系统级格式转换配置
const systemFormatConversionEnabled = ref(false)
// 端点相关状态
const endpointDialogOpen = ref(false)
@@ -1256,13 +1264,29 @@ function getFormatProbeCountdown(key: EndpointAPIKey, format: string): string {
return ''
}
// 加载系统级格式转换配置
async function loadSystemFormatConversionConfig() {
try {
const result = await adminApi.getSystemConfig('enable_format_conversion')
systemFormatConversionEnabled.value = result.value === true
} catch {
// 获取失败时默认为关闭
systemFormatConversionEnabled.value = false
}
}
// 加载 Provider 信息
async function loadProvider() {
if (!props.providerId) return
try {
loading.value = true
provider.value = await getProvider(props.providerId)
// 并行加载 Provider 信息和系统级格式转换配置
const [providerData] = await Promise.all([
getProvider(props.providerId),
loadSystemFormatConversionConfig(),
])
provider.value = providerData
if (!provider.value) {
throw new Error('Provider 不存在')

View File

@@ -92,7 +92,7 @@
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-destructive hover:text-destructive"
class="h-8 w-8 hover:text-destructive"
title="删除映射组"
@click="deleteGroup(group)"
>

View File

@@ -123,7 +123,7 @@
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-destructive hover:text-destructive"
class="h-8 w-8 hover:text-destructive"
title="删除映射"
@click="deleteGroup(item.group!)"
>

View File

@@ -1,33 +1,11 @@
<template>
<TableCard title="使用记录">
<template #actions>
<!-- 时间筛选 -->
<Select
v-model:open="periodSelectOpen"
:model-value="selectedPeriod"
@update:model-value="$emit('update:selectedPeriod', $event)"
>
<SelectTrigger class="w-24 sm:w-32 h-8 text-xs border-border/60">
<SelectValue placeholder="选择时间段" />
</SelectTrigger>
<SelectContent>
<SelectItem value="today">
今天
</SelectItem>
<SelectItem value="yesterday">
昨天
</SelectItem>
<SelectItem value="last7days">
最近7天
</SelectItem>
<SelectItem value="last30days">
最近30天
</SelectItem>
<SelectItem value="last90days">
最近90天
</SelectItem>
</SelectContent>
</Select>
<!-- 时间范围筛选 -->
<TimeRangePicker
v-model="timeRangeModel"
:show-granularity="false"
/>
<!-- 分隔线 -->
<div class="hidden sm:block h-4 w-px bg-border" />
@@ -114,6 +92,29 @@
</SelectContent>
</Select>
<!-- API格式筛选 -->
<Select
v-model:open="filterApiFormatSelectOpen"
:model-value="filterApiFormat"
@update:model-value="$emit('update:filterApiFormat', $event)"
>
<SelectTrigger class="w-24 sm:w-32 h-8 text-xs border-border/60">
<SelectValue placeholder="全部格式" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">
全部格式
</SelectItem>
<SelectItem
v-for="format in availableApiFormats"
:key="format.value"
:value="format.value"
>
{{ format.label }}
</SelectItem>
</SelectContent>
</Select>
<!-- 状态筛选 -->
<Select
v-model:open="filterStatusSelectOpen"
@@ -127,20 +128,23 @@
<SelectItem value="__all__">
全部状态
</SelectItem>
<SelectItem value="active">
进行中
<SelectItem value="stream">
流式
</SelectItem>
<SelectItem value="standard">
标准
</SelectItem>
<SelectItem value="pending">
等待中
</SelectItem>
<SelectItem value="streaming">
流式传输
传输
</SelectItem>
<SelectItem value="completed">
完成
完成
</SelectItem>
<SelectItem value="failed">
失败
失败
</SelectItem>
</SelectContent>
</Select>
@@ -520,7 +524,8 @@
</template>
<script setup lang="ts">
import { ref, computed, onUnmounted, watch } from 'vue'
import { ref, computed, watch } from 'vue'
import { useDebounceFn, useIntervalFn } from '@vueuse/core'
import {
TableCard,
Badge,
@@ -544,7 +549,8 @@ import { formatTokens, formatCurrency } from '@/utils/format'
import { formatDateTime } from '../composables'
import { useRowClick } from '@/composables/useRowClick'
import { API_FORMAT_LABELS } from '@/api/endpoints/types'
import type { UsageRecord } from '../types'
import type { DateRangeParams, UsageRecord } from '../types'
import { TimeRangePicker } from '@/components/common'
export interface UserOption {
id: string
@@ -557,13 +563,14 @@ const props = defineProps<{
isAdmin: boolean
showActualCost: boolean
loading: boolean
// 时间
selectedPeriod: string
// 时间范围
timeRange: DateRangeParams
// 筛选
filterSearch: string
filterUser: string
filterModel: string
filterProvider: string
filterApiFormat: string
filterStatus: string
availableUsers: UserOption[]
availableModels: string[]
@@ -578,11 +585,12 @@ const props = defineProps<{
}>()
const emit = defineEmits<{
'update:selectedPeriod': [value: string]
'update:timeRange': [value: DateRangeParams]
'update:filterSearch': [value: string]
'update:filterUser': [value: string]
'update:filterModel': [value: string]
'update:filterProvider': [value: string]
'update:filterApiFormat': [value: string]
'update:filterStatus': [value: string]
'update:currentPage': [value: number]
'update:pageSize': [value: number]
@@ -591,16 +599,38 @@ const emit = defineEmits<{
'showDetail': [id: string]
}>()
// 静态常量(放在 defineProps/defineEmits 之后)
const AVAILABLE_API_FORMATS = [
{ value: 'openai:chat', label: 'OpenAI Chat' },
{ value: 'openai:cli', label: 'OpenAI CLI' },
{ value: 'openai:video', label: 'OpenAI Video' },
{ value: 'claude:chat', label: 'Claude Chat' },
{ value: 'claude:cli', label: 'Claude CLI' },
{ value: 'gemini:chat', label: 'Gemini Chat' },
{ value: 'gemini:cli', label: 'Gemini CLI' },
{ value: 'gemini:video', label: 'Gemini Video' },
] as const
// Select 打开状态
const periodSelectOpen = ref(false)
const filterUserSelectOpen = ref(false)
const filterModelSelectOpen = ref(false)
const filterProviderSelectOpen = ref(false)
const filterApiFormatSelectOpen = ref(false)
const filterStatusSelectOpen = ref(false)
// 使用模块级常量
const availableApiFormats = AVAILABLE_API_FORMATS
const timeRangeModel = computed({
get: () => props.timeRange,
set: (value: DateRangeParams) => emit('update:timeRange', value)
})
// 通用搜索(输入防抖)
const localSearch = ref(props.filterSearch)
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
const emitSearchDebounced = useDebounceFn((value: string) => {
emit('update:filterSearch', value)
}, 300)
watch(() => props.filterSearch, (value) => {
if (value !== localSearch.value) {
@@ -609,36 +639,23 @@ watch(() => props.filterSearch, (value) => {
})
watch(localSearch, (value) => {
if (searchDebounceTimer) clearTimeout(searchDebounceTimer)
searchDebounceTimer = setTimeout(() => {
emit('update:filterSearch', value)
}, 300)
emitSearchDebounced(value)
})
// 动态计时器相关
const now = ref(Date.now())
let timerInterval: ReturnType<typeof setInterval> | null = null
// 检查是否有活跃请求
const hasActiveRecords = computed(() => {
return props.records.some(r => r.status === 'pending' || r.status === 'streaming')
})
// 启动计时器
function startTimer() {
if (timerInterval) return
timerInterval = setInterval(() => {
now.value = Date.now()
}, 100) // 每 100ms 更新一次
}
// 停止计时器
function stopTimer() {
if (timerInterval) {
clearInterval(timerInterval)
timerInterval = null
}
}
// 使用 VueUse 的 useIntervalFn 管理计时器(自动清理)
const { pause: stopTimer, resume: startTimer } = useIntervalFn(
() => { now.value = Date.now() },
100,
{ immediate: false }
)
// 计算活跃请求的实时耗时
function getElapsedTime(record: UsageRecord): string {
@@ -679,14 +696,7 @@ function handleRowClick(event: MouseEvent, id: string) {
emit('showDetail', id)
}
// 组件卸载时清理
onUnmounted(() => {
stopTimer()
if (searchDebounceTimer) {
clearTimeout(searchDebounceTimer)
searchDebounceTimer = null
}
})
// useIntervalFn 和 useDebounceFn 自动处理清理,无需 onUnmounted
// 格式化 API 格式显示名称
function formatApiFormat(format: string): string {

View File

@@ -4,7 +4,16 @@ import type { PeriodValue, DateRangeParams } from '../types'
* 格式化日期为 ISO 格式(不带毫秒,兼容 FastAPI datetime 解析)
*/
function formatDateForApi(date: Date): string {
return date.toISOString().replace(/\.\d{3}Z$/, 'Z')
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function getTimezoneParams() {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
const tz_offset_minutes = -new Date().getTimezoneOffset()
return { timezone, tz_offset_minutes }
}
/**
@@ -38,7 +47,9 @@ export function getDateRangeFromPeriod(period: PeriodValue): DateRangeParams {
return {
start_date: formatDateForApi(startDate),
end_date: formatDateForApi(endDate)
end_date: formatDateForApi(endDate),
preset: period,
...getTimezoneParams()
}
}

View File

@@ -27,6 +27,7 @@ export interface FilterParams {
user_id?: string
model?: string
provider?: string
api_format?: string
status?: string
}
@@ -263,6 +264,9 @@ export function useUsageData(options: UseUsageDataOptions) {
if (filters?.provider) {
params.provider = filters.provider
}
if (filters?.api_format) {
params.api_format = filters.api_format
}
if (filters?.status) {
params.status = filters.status
}

View File

@@ -100,6 +100,10 @@ export interface UsageRecord {
export interface DateRangeParams {
start_date?: string
end_date?: string
preset?: string
granularity?: 'hour' | 'day' | 'week' | 'month'
timezone?: string
tz_offset_minutes?: number
}
// 时间段选项

View File

@@ -544,6 +544,9 @@ const navigation = computed(() => {
items: [
{ name: '仪表盘', href: '/admin/dashboard', icon: Home },
{ name: '健康监控', href: '/admin/health-monitor', icon: Activity },
{ name: '用户统计', href: '/admin/user-stats', icon: BarChart3 },
{ name: '成本分析', href: '/admin/cost-analysis', icon: Gauge },
{ name: '性能分析', href: '/admin/performance-analysis', icon: Activity },
]
},
{

View File

@@ -2559,3 +2559,185 @@ mockHandlers['GET /api/admin/usage/cache-affinity/hit-analysis'] = async (config
const data = generateCacheHitAnalysisData(hours)
return createMockResponse(data)
}
// ========== Admin: Stats / Leaderboard ==========
mockHandlers['GET /api/admin/stats/leaderboard/users'] = async () => {
await delay()
requireAdmin()
return createMockResponse({
items: [
{ rank: 1, id: 'user-1', name: 'Demo Admin', value: 1200, requests: 1200, tokens: 240000, cost: 123.4 },
{ rank: 2, id: 'user-2', name: 'Demo User', value: 980, requests: 980, tokens: 180000, cost: 98.7 }
],
total: 2,
metric: 'requests',
start_date: '2026-02-01',
end_date: '2026-02-07'
})
}
mockHandlers['GET /api/admin/stats/leaderboard/api-keys'] = async () => {
await delay()
requireAdmin()
return createMockResponse({
items: [
{ rank: 1, id: 'key-1', name: 'Key A', value: 800, requests: 800, tokens: 160000, cost: 76.2 },
{ rank: 2, id: 'key-2', name: 'Key B', value: 620, requests: 620, tokens: 120000, cost: 55.1 }
],
total: 2,
metric: 'requests',
start_date: '2026-02-01',
end_date: '2026-02-07'
})
}
mockHandlers['GET /api/admin/stats/leaderboard/models'] = async () => {
await delay()
requireAdmin()
return createMockResponse({
items: [
{ rank: 1, id: 'gpt-4', name: 'gpt-4', value: 500, requests: 500, tokens: 100000, cost: 44.2 },
{ rank: 2, id: 'claude-3', name: 'claude-3', value: 420, requests: 420, tokens: 90000, cost: 40.1 }
],
total: 2,
metric: 'requests',
start_date: '2026-02-01',
end_date: '2026-02-07'
})
}
mockHandlers['GET /api/admin/stats/cost/forecast'] = async () => {
await delay()
requireAdmin()
return createMockResponse({
history: [
{ date: '2026-02-01', total_cost: 120 },
{ date: '2026-02-02', total_cost: 132 },
{ date: '2026-02-03', total_cost: 140 }
],
forecast: [
{ date: '2026-02-04', total_cost: 150 },
{ date: '2026-02-05', total_cost: 158 }
],
slope: 5.2,
intercept: 110.5,
start_date: '2026-02-01',
end_date: '2026-02-03'
})
}
mockHandlers['GET /api/admin/stats/cost/savings'] = async () => {
await delay()
requireAdmin()
return createMockResponse({
cache_read_tokens: 120000,
cache_read_cost: 8.2,
cache_creation_cost: 3.4,
estimated_full_cost: 82.0,
cache_savings: 73.8
})
}
mockHandlers['GET /api/admin/stats/providers/quota-usage'] = async () => {
await delay()
requireAdmin()
return createMockResponse({
providers: [
{
id: 'prov-1',
name: 'Provider A',
quota_usd: 500,
used_usd: 320,
remaining_usd: 180,
usage_percent: 64,
quota_expires_at: null,
estimated_exhaust_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString()
}
]
})
}
mockHandlers['GET /api/admin/stats/performance/percentiles'] = async () => {
await delay()
requireAdmin()
return createMockResponse([
{
date: '2026-02-01',
p50_response_time_ms: 320,
p90_response_time_ms: 560,
p99_response_time_ms: 860,
p50_first_byte_time_ms: 120,
p90_first_byte_time_ms: 210,
p99_first_byte_time_ms: 400
},
{
date: '2026-02-02',
p50_response_time_ms: 300,
p90_response_time_ms: 540,
p99_response_time_ms: 820,
p50_first_byte_time_ms: 110,
p90_first_byte_time_ms: 200,
p99_first_byte_time_ms: 380
}
])
}
mockHandlers['GET /api/admin/stats/errors/distribution'] = async () => {
await delay()
requireAdmin()
return createMockResponse({
distribution: [
{ category: 'rate_limit', count: 24 },
{ category: 'server_error', count: 12 },
{ category: 'timeout', count: 6 }
],
trend: [
{ date: '2026-02-01', total: 8, categories: { rate_limit: 5, server_error: 3 } },
{ date: '2026-02-02', total: 6, categories: { rate_limit: 4, timeout: 2 } }
]
})
}
mockHandlers['GET /api/admin/stats/comparison'] = async () => {
await delay()
requireAdmin()
return createMockResponse({
current: {
total_requests: 1200,
total_tokens: 320000,
total_cost: 180,
actual_total_cost: 190,
avg_response_time_ms: 350,
error_requests: 42
},
comparison: {
total_requests: 900,
total_tokens: 260000,
total_cost: 150,
actual_total_cost: 160,
avg_response_time_ms: 370,
error_requests: 38
},
change_percent: {
total_requests: 33.3,
total_tokens: 23.1,
total_cost: 20.0,
actual_total_cost: 18.8,
avg_response_time_ms: -5.4,
error_requests: 10.5
},
current_start: '2026-02-01',
current_end: '2026-02-07',
comparison_start: '2026-01-25',
comparison_end: '2026-01-31'
})
}
mockHandlers['GET /api/admin/stats/time-series'] = async () => {
await delay()
requireAdmin()
return createMockResponse([
{ date: '2026-02-01', total_requests: 120, input_tokens: 20000, output_tokens: 30000, total_cost: 12.3 },
{ date: '2026-02-02', total_requests: 140, input_tokens: 22000, output_tokens: 32000, total_cost: 13.8 }
])
}

View File

@@ -161,6 +161,21 @@ const routes: RouteRecordRaw[] = [
name: 'Usage',
component: () => importWithRetry(() => import('@/views/shared/Usage.vue'))
},
{
path: 'user-stats',
name: 'UserStats',
component: () => importWithRetry(() => import('@/views/admin/UserStats.vue'))
},
{
path: 'cost-analysis',
name: 'CostAnalysis',
component: () => importWithRetry(() => import('@/views/admin/CostAnalysis.vue'))
},
{
path: 'performance-analysis',
name: 'PerformanceAnalysis',
component: () => importWithRetry(() => import('@/views/admin/PerformanceAnalysis.vue'))
},
{
path: 'system',
name: 'SystemSettings',

View File

@@ -20,13 +20,6 @@
<h3 class="text-sm sm:text-base font-semibold">
独立余额 API Keys
</h3>
<p class="text-xs text-muted-foreground mt-0.5">
活跃 {{ activeKeyCount }} · 禁用 {{ inactiveKeyCount }} · 无限 Key {{ unlimitedKeyCount }}
<span
v-if="expiringSoonCount > 0"
class="text-amber-600"
> · 即将到期 {{ expiringSoonCount }}</span>
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<!-- 搜索框 -->

View File

@@ -56,7 +56,7 @@
<h3 class="text-base font-semibold">异步任务</h3>
<div class="flex items-center gap-2">
<!-- 状态筛选 -->
<Select v-model="filterStatus">
<Select v-model:open="statusSelectOpen" v-model="filterStatus">
<SelectTrigger class="w-28 h-8 text-xs border-border/60">
<SelectValue placeholder="状态" />
</SelectTrigger>
@@ -690,6 +690,7 @@ const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(20)
const filterStatus = ref('all')
const statusSelectOpen = ref(false)
const filterModel = ref('')
const showDetail = ref(false)
const selectedTask = ref<AsyncTaskDetail | null>(null)

View File

@@ -0,0 +1,129 @@
<template>
<div class="space-y-6 px-4 sm:px-6 lg:px-0">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 class="text-lg font-semibold">成本分析</h1>
<p class="text-xs text-muted-foreground">成本趋势预测与节省统计</p>
</div>
<TimeRangePicker v-model="timeRange" />
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<Card class="p-4 space-y-2">
<div class="text-xs text-muted-foreground">缓存节省</div>
<div class="text-lg font-semibold">{{ formatCurrency(costSavings?.cache_savings ?? 0) }}</div>
<div class="text-xs text-muted-foreground">
读取成本 {{ formatCurrency(costSavings?.cache_read_cost ?? 0) }}
</div>
</Card>
<Card class="p-4 space-y-2">
<div class="text-xs text-muted-foreground">缓存读取 Tokens</div>
<div class="text-lg font-semibold">{{ formatTokens(costSavings?.cache_read_tokens ?? 0) }}</div>
<div class="text-xs text-muted-foreground">
预计全额成本 {{ formatCurrency(costSavings?.estimated_full_cost ?? 0) }}
</div>
</Card>
<Card class="p-4 space-y-2">
<div class="text-xs text-muted-foreground">缓存创建成本</div>
<div class="text-lg font-semibold">{{ formatCurrency(costSavings?.cache_creation_cost ?? 0) }}</div>
<div class="text-xs text-muted-foreground">基于当前时间范围</div>
</Card>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card class="p-4">
<CostForecastChart
title="成本趋势预测"
:history="forecastHistory"
:forecast="forecastFuture"
:loading="forecastLoading"
/>
</Card>
<QuotaProgressCard
title="月卡消耗进度"
:providers="quotaProviders"
:loading="quotaLoading"
/>
</div>
<UsageProviderTable
:data="providerStats"
:is-admin="true"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import Card from '@/components/ui/card.vue'
import { TimeRangePicker } from '@/components/common'
import { CostForecastChart, QuotaProgressCard } from '@/components/stats'
import { UsageProviderTable } from '@/features/usage/components'
import { adminApi, type CostForecastResponse, type CostSavingsResponse, type QuotaUsageProvider } from '@/api/admin'
import { usageApi } from '@/api/usage'
import { formatCurrency, formatTokens } from '@/utils/format'
import { getDateRangeFromPeriod } from '@/features/usage/composables'
import type { DateRangeParams } from '@/features/usage/types'
import type { ProviderStatsItem } from '@/features/usage/types'
const timeRange = ref<DateRangeParams>(getDateRangeFromPeriod('last30days'))
const forecast = ref<CostForecastResponse | null>(null)
const costSavings = ref<CostSavingsResponse | null>(null)
const quotaProviders = ref<QuotaUsageProvider[]>([])
const providerStats = ref<ProviderStatsItem[]>([])
const forecastLoading = ref(false)
const quotaLoading = ref(false)
const forecastHistory = computed(() => forecast.value?.history || [])
const forecastFuture = computed(() => forecast.value?.forecast || [])
function buildTimeRangeParams() {
return {
start_date: timeRange.value.start_date,
end_date: timeRange.value.end_date,
preset: timeRange.value.preset,
timezone: timeRange.value.timezone,
tz_offset_minutes: timeRange.value.tz_offset_minutes
}
}
async function loadForecast() {
forecastLoading.value = true
try {
forecast.value = await adminApi.getCostForecast(buildTimeRangeParams())
} finally {
forecastLoading.value = false
}
}
async function loadSavings() {
costSavings.value = await adminApi.getCostSavings(buildTimeRangeParams())
}
async function loadQuotaUsage() {
quotaLoading.value = true
try {
const response = await adminApi.getQuotaUsage()
quotaProviders.value = response.providers
} finally {
quotaLoading.value = false
}
}
async function loadProviderStats() {
providerStats.value = await usageApi.getUsageByProvider({
...buildTimeRangeParams(),
limit: 8
})
}
async function loadAll() {
await Promise.all([loadForecast(), loadSavings(), loadQuotaUsage(), loadProviderStats()])
}
watch(timeRange, loadAll, { deep: true })
onMounted(loadAll)
</script>

View File

@@ -0,0 +1,155 @@
<template>
<div class="space-y-6 px-4 sm:px-6 lg:px-0">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 class="text-lg font-semibold">性能分析</h1>
<p class="text-xs text-muted-foreground">延迟分布与错误统计</p>
</div>
<TimeRangePicker v-model="timeRange" />
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card class="p-4">
<PercentileChart
title="响应延迟百分位"
:series="percentiles"
mode="response"
:loading="percentileLoading"
/>
</Card>
<Card class="p-4">
<PercentileChart
title="首字节延迟百分位"
:series="percentiles"
mode="ttfb"
:loading="percentileLoading"
/>
</Card>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card class="p-4">
<ErrorDistributionChart
title="错误分布"
:distribution="errorDistribution"
:loading="errorLoading"
/>
</Card>
<Card class="p-4 space-y-3">
<h3 class="text-sm font-semibold">错误趋势</h3>
<div v-if="errorLoading" class="p-6">
<LoadingState />
</div>
<div v-else class="h-[260px]">
<LineChart :data="errorTrendChartData" />
</div>
</Card>
</div>
<Card class="p-4 space-y-3">
<h3 class="text-sm font-semibold">提供商健康度</h3>
<div v-if="providerLoading" class="p-4">
<LoadingState />
</div>
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 text-sm">
<div
v-for="provider in providerStatus"
:key="provider.name"
class="p-3 border rounded-lg"
>
<div class="flex items-center justify-between">
<span class="font-medium">{{ provider.name }}</span>
<span class="text-xs text-muted-foreground">{{ provider.requests }} 请求</span>
</div>
<div class="text-xs text-muted-foreground mt-1">
状态: {{ provider.status }}
</div>
</div>
</div>
</Card>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import Card from '@/components/ui/card.vue'
import { LoadingState, TimeRangePicker } from '@/components/common'
import { ErrorDistributionChart, PercentileChart } from '@/components/stats'
import LineChart from '@/components/charts/LineChart.vue'
import { adminApi, type ErrorDistributionResponse, type PercentileItem } from '@/api/admin'
import { dashboardApi, type ProviderStatus } from '@/api/dashboard'
import { getDateRangeFromPeriod } from '@/features/usage/composables'
import type { DateRangeParams } from '@/features/usage/types'
const timeRange = ref<DateRangeParams>(getDateRangeFromPeriod('last30days'))
const percentiles = ref<PercentileItem[]>([])
const percentileLoading = ref(false)
const errorDistribution = ref<ErrorDistributionResponse['distribution']>([])
const errorTrend = ref<ErrorDistributionResponse['trend']>([])
const errorLoading = ref(false)
const providerStatus = ref<ProviderStatus[]>([])
const providerLoading = ref(false)
function buildTimeRangeParams() {
return {
start_date: timeRange.value.start_date,
end_date: timeRange.value.end_date,
preset: timeRange.value.preset,
timezone: timeRange.value.timezone,
tz_offset_minutes: timeRange.value.tz_offset_minutes
}
}
async function loadPercentiles() {
percentileLoading.value = true
try {
percentiles.value = await adminApi.getPercentiles(buildTimeRangeParams())
} finally {
percentileLoading.value = false
}
}
async function loadErrors() {
errorLoading.value = true
try {
const response = await adminApi.getErrorDistribution(buildTimeRangeParams())
errorDistribution.value = response.distribution
errorTrend.value = response.trend
} finally {
errorLoading.value = false
}
}
async function loadProviders() {
providerLoading.value = true
try {
providerStatus.value = await dashboardApi.getProviderStatus()
} finally {
providerLoading.value = false
}
}
const errorTrendChartData = computed(() => ({
labels: errorTrend.value.map(item => item.date),
datasets: [
{
label: '错误数',
data: errorTrend.value.map(item => item.total),
borderColor: 'rgb(239, 68, 68)',
tension: 0.25,
pointRadius: 2
}
]
}))
async function loadAll() {
await Promise.all([loadPercentiles(), loadErrors(), loadProviders()])
}
watch(timeRange, loadAll, { deep: true })
onMounted(loadAll)
</script>

View File

@@ -0,0 +1,239 @@
<template>
<div class="space-y-6 px-4 sm:px-6 lg:px-0">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 class="text-lg font-semibold">用户统计</h1>
<p class="text-xs text-muted-foreground">查看用户排行榜与使用趋势</p>
</div>
<div class="flex flex-wrap items-center gap-3">
<TimeRangePicker v-model="timeRange" :allow-hourly="true" />
<Select v-model:open="userSelectOpen" v-model="selectedUserId">
<SelectTrigger class="h-8 text-xs w-52">
<SelectValue placeholder="选择用户" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="user in users"
:key="user.id"
:value="user.id"
>
{{ user.username || user.email }}
</SelectItem>
</SelectContent>
</Select>
<Select v-model:open="compareUserSelectOpen" v-model="compareUserId">
<SelectTrigger class="h-8 text-xs w-52">
<SelectValue placeholder="对比用户(可选)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">不对比</SelectItem>
<SelectItem
v-for="user in users"
:key="`compare-${user.id}`"
:value="user.id"
>
{{ user.username || user.email }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<LeaderboardTable
title="用户排行榜"
:items="leaderboard"
:metric="metric"
:loading="leaderboardLoading"
@update:metric="metric = $event"
/>
<Card class="p-4 space-y-3">
<h3 class="text-sm font-semibold">用户摘要</h3>
<div v-if="summaryLoading" class="p-6">
<LoadingState />
</div>
<div v-else class="grid grid-cols-2 gap-3 text-sm">
<div>
<div class="text-xs text-muted-foreground">请求数</div>
<div class="font-semibold">{{ userSummary?.total_requests ?? 0 }}</div>
</div>
<div>
<div class="text-xs text-muted-foreground">Tokens</div>
<div class="font-semibold">{{ formatTokens(userSummary?.total_tokens ?? 0) }}</div>
</div>
<div>
<div class="text-xs text-muted-foreground">成本</div>
<div class="font-semibold">{{ formatCurrency(userSummary?.total_cost ?? 0) }}</div>
</div>
<div>
<div class="text-xs text-muted-foreground">错误率</div>
<div class="font-semibold">{{ userSummary?.error_rate ?? 0 }}%</div>
</div>
</div>
</Card>
</div>
<Card class="p-4 space-y-4">
<h3 class="text-sm font-semibold">用户使用趋势</h3>
<div v-if="seriesLoading" class="p-6">
<LoadingState />
</div>
<div v-else class="h-[280px]">
<LineChart :data="seriesChartData" />
</div>
</Card>
<Card v-if="comparisonSeries.length > 0" class="p-4 space-y-4">
<h3 class="text-sm font-semibold">用户对比趋势</h3>
<div class="h-[280px]">
<LineChart :data="comparisonChartData" />
</div>
</Card>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { Card, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
import LineChart from '@/components/charts/LineChart.vue'
import { LoadingState, TimeRangePicker } from '@/components/common'
import { LeaderboardTable } from '@/components/stats'
import { adminApi, type LeaderboardItem } from '@/api/admin'
import { usersApi, type User } from '@/api/users'
import { usageApi } from '@/api/usage'
import { formatCurrency, formatTokens } from '@/utils/format'
import { getDateRangeFromPeriod } from '@/features/usage/composables'
import type { DateRangeParams } from '@/features/usage/types'
const timeRange = ref<DateRangeParams>(getDateRangeFromPeriod('last7days'))
const metric = ref<'requests' | 'tokens' | 'cost'>('requests')
const users = ref<User[]>([])
const selectedUserId = ref<string | null>(null)
const compareUserId = ref<string>('__none__')
const userSelectOpen = ref(false)
const compareUserSelectOpen = ref(false)
const leaderboard = ref<LeaderboardItem[]>([])
const leaderboardLoading = ref(false)
const userSummary = ref<any | null>(null)
const summaryLoading = ref(false)
const series = ref<any[]>([])
const comparisonSeries = ref<any[]>([])
const seriesLoading = ref(false)
function buildTimeRangeParams() {
return {
start_date: timeRange.value.start_date,
end_date: timeRange.value.end_date,
preset: timeRange.value.preset,
timezone: timeRange.value.timezone,
tz_offset_minutes: timeRange.value.tz_offset_minutes,
granularity: timeRange.value.granularity || 'day'
}
}
async function loadUsers() {
users.value = await usersApi.getAllUsers()
if (!selectedUserId.value && users.value.length > 0) {
selectedUserId.value = users.value[0].id
}
}
async function loadLeaderboard() {
leaderboardLoading.value = true
try {
const response = await adminApi.getLeaderboardUsers({
...buildTimeRangeParams(),
metric: metric.value,
limit: 10
})
leaderboard.value = response.items
} finally {
leaderboardLoading.value = false
}
}
async function loadSummary() {
if (!selectedUserId.value) return
summaryLoading.value = true
try {
userSummary.value = await usageApi.getUsageStats({
...buildTimeRangeParams(),
user_id: selectedUserId.value
})
} finally {
summaryLoading.value = false
}
}
async function loadSeries() {
if (!selectedUserId.value) return
seriesLoading.value = true
try {
series.value = await adminApi.getTimeSeries({
...buildTimeRangeParams(),
user_id: selectedUserId.value
})
comparisonSeries.value = []
if (compareUserId.value && compareUserId.value !== '__none__') {
comparisonSeries.value = await adminApi.getTimeSeries({
...buildTimeRangeParams(),
user_id: compareUserId.value
})
}
} finally {
seriesLoading.value = false
}
}
const seriesChartData = computed(() => ({
labels: series.value.map(item => item.date),
datasets: [
{
label: '成本',
data: series.value.map(item => item.total_cost),
borderColor: 'rgb(59, 130, 246)',
tension: 0.25,
pointRadius: 2
}
]
}))
const comparisonChartData = computed(() => ({
labels: series.value.map(item => item.date),
datasets: [
{
label: '当前用户',
data: series.value.map(item => item.total_cost),
borderColor: 'rgb(59, 130, 246)',
tension: 0.25,
pointRadius: 2
},
{
label: '对比用户',
data: comparisonSeries.value.map(item => item.total_cost),
borderColor: 'rgb(234, 179, 8)',
tension: 0.25,
pointRadius: 2
}
]
}))
watch([timeRange, metric], loadLeaderboard, { deep: true })
watch([timeRange, selectedUserId, compareUserId], () => {
loadSummary()
loadSeries()
}, { deep: true })
onMounted(async () => {
await loadUsers()
await loadLeaderboard()
await loadSummary()
await loadSeries()
})
</script>

View File

@@ -393,6 +393,14 @@
</div>
</div>
<!-- 趋势图表筛选 -->
<div class="flex flex-wrap items-center justify-between gap-3">
<h3 class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
统计周期
</h3>
<TimeRangePicker v-model="dailyTimeRange" :allow-hourly="true" />
</div>
<!-- 趋势图表区域 -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<!-- 每日使用趋势折线图- 普通用户可见 -->
@@ -784,6 +792,8 @@
import { ref, onMounted, computed, onBeforeUnmount, nextTick, watch } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { dashboardApi, type DashboardStat, type DailyStat, type ProviderSummary } from '@/api/dashboard'
import { getDateRangeFromPeriod } from '@/features/usage/composables'
import type { DateRangeParams } from '@/features/usage/types'
import { announcementApi, type Announcement } from '@/api/announcements'
import {
Card,
@@ -798,6 +808,7 @@ import {
TableHead,
TableCell,
} from '@/components/ui'
import { TimeRangePicker } from '@/components/common'
import BarChart from '@/components/charts/BarChart.vue'
import DoughnutChart from '@/components/charts/DoughnutChart.vue'
import LineChart from '@/components/charts/LineChart.vue'
@@ -972,7 +983,8 @@ const tokenBreakdown = ref<{
const activeUsers = ref(0)
const dailyStats = ref<DailyStat[]>([])
const providerSummary = ref<ProviderSummary[]>([])
const selectedDays = ref(7)
const dailyTimeRange = ref<DateRangeParams>(getDateRangeFromPeriod('last7days'))
// 统计周期
const loadingDaily = ref(false)
const loading = ref(false)
@@ -1317,7 +1329,7 @@ async function loadDashboardData() {
async function loadDailyStats() {
loadingDaily.value = true
try {
const response = await dashboardApi.getDailyStats(selectedDays.value)
const response = await dashboardApi.getDailyStats(dailyTimeRange.value)
dailyStats.value = response.daily_stats
providerSummary.value = response.provider_summary || []
} catch {
@@ -1328,6 +1340,10 @@ async function loadDailyStats() {
}
}
watch(dailyTimeRange, async () => {
await loadDailyStats()
}, { deep: true })
function formatDate(dateString: string): string {
const date = new Date(dateString)
const today = new Date()

View File

@@ -55,11 +55,12 @@
:is-admin="isAdminPage"
:show-actual-cost="authStore.isAdmin"
:loading="isLoadingRecords"
:selected-period="selectedPeriod"
:time-range="timeRange"
:filter-search="filterSearch"
:filter-user="filterUser"
:filter-model="filterModel"
:filter-provider="filterProvider"
:filter-api-format="filterApiFormat"
:filter-status="filterStatus"
:available-users="availableUsers"
:available-models="availableModels"
@@ -69,17 +70,17 @@
:total-records="totalRecords"
:page-size-options="pageSizeOptions"
:auto-refresh="globalAutoRefresh"
@update:selected-period="handlePeriodChange"
@update:time-range="handleTimeRangeChange"
@update:filter-search="handleFilterSearchChange"
@update:filter-user="handleFilterUserChange"
@update:filter-model="handleFilterModelChange"
@update:filter-provider="handleFilterProviderChange"
@update:filter-api-format="handleFilterApiFormatChange"
@update:filter-status="handleFilterStatusChange"
@update:current-page="handlePageChange"
@update:page-size="handlePageSizeChange"
@update:auto-refresh="handleAutoRefreshChange"
@refresh="refreshData"
@export="exportData"
@show-detail="showRequestDetail"
/>
@@ -113,7 +114,7 @@ import {
useUsageData,
getDateRangeFromPeriod
} from '@/features/usage/composables'
import type { PeriodValue, FilterStatusValue } from '@/features/usage/types'
import type { DateRangeParams, FilterStatusValue } from '@/features/usage/types'
import type { UserOption } from '@/features/usage/components/UsageRecordsTable.vue'
import { log } from '@/utils/logger'
import type { ActivityHeatmap } from '@/types/activity'
@@ -126,8 +127,8 @@ const authStore = useAuthStore()
// 判断是否是管理员页面
const isAdminPage = computed(() => route.path.startsWith('/admin'))
// 时间选择
const selectedPeriod = ref<PeriodValue>('today')
// 时间范围选择
const timeRange = ref<DateRangeParams>(getDateRangeFromPeriod('today'))
// 分页状态
const currentPage = ref(1)
@@ -139,6 +140,7 @@ const filterSearch = ref('')
const filterUser = ref('__all__')
const filterModel = ref('__all__')
const filterProvider = ref('__all__')
const filterApiFormat = ref('__all__')
const filterStatus = ref<FilterStatusValue>('__all__')
// 用户列表(仅管理员页面使用)
@@ -194,6 +196,12 @@ const filteredRecords = computed(() => {
records = records.filter(record => record.provider === filterProvider.value)
}
if (filterApiFormat.value !== '__all__') {
records = records.filter(record =>
record.api_format?.toUpperCase() === filterApiFormat.value.toUpperCase()
)
}
if (filterStatus.value !== '__all__') {
if (filterStatus.value === 'stream') {
records = records.filter(record =>
@@ -386,11 +394,9 @@ const selectedRequestId = ref<string | null>(null)
// 初始化加载
onMounted(async () => {
const dateRange = getDateRangeFromPeriod(selectedPeriod.value)
// 并行加载统计数据和热力图(使用 allSettled 避免其中一个失败影响另一个)
const [statsResult, heatmapResult] = await Promise.allSettled([
loadStats(dateRange),
loadStats(timeRange.value),
loadHeatmapData()
])
@@ -418,13 +424,11 @@ onMounted(async () => {
}
})
// 处理时间变化
async function handlePeriodChange(value: string) {
selectedPeriod.value = value as PeriodValue
currentPage.value = 1 // 重置到第一页
const dateRange = getDateRangeFromPeriod(selectedPeriod.value)
await loadStats(dateRange)
// 处理时间范围变化
async function handleTimeRangeChange(value: DateRangeParams) {
timeRange.value = value
currentPage.value = 1 // 重置到第一页
await loadStats(timeRange.value)
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
}
@@ -448,6 +452,7 @@ function getCurrentFilters() {
user_id: filterUser.value !== '__all__' ? filterUser.value : undefined,
model: filterModel.value !== '__all__' ? filterModel.value : undefined,
provider: filterProvider.value !== '__all__' ? filterProvider.value : undefined,
api_format: filterApiFormat.value !== '__all__' ? filterApiFormat.value : undefined,
status: filterStatus.value !== '__all__' ? filterStatus.value : undefined
}
}
@@ -487,6 +492,15 @@ async function handleFilterProviderChange(value: string) {
}
}
async function handleFilterApiFormatChange(value: string) {
filterApiFormat.value = value
currentPage.value = 1
if (isAdminPage.value) {
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
}
}
async function handleFilterStatusChange(value: string) {
filterStatus.value = value as FilterStatusValue
currentPage.value = 1
@@ -498,8 +512,7 @@ async function handleFilterStatusChange(value: string) {
// 刷新数据
async function refreshData() {
const dateRange = getDateRangeFromPeriod(selectedPeriod.value)
await loadStats(dateRange)
await loadStats(timeRange.value)
await loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters())
}
@@ -510,20 +523,6 @@ function showRequestDetail(id: string) {
detailModalOpen.value = true
}
// 导出数据
async function exportData(format: 'csv' | 'json') {
try {
const blob = await usageApi.exportUsage(format)
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `usage-stats.${format}`
a.click()
window.URL.revokeObjectURL(url)
} catch (error) {
log.error('导出失败:', error)
}
}
</script>
<style scoped>

View File

@@ -39,7 +39,7 @@ dependencies = [
"aiosqlite>=0.22.1",
"loguru>=0.7.3",
"tiktoken>=0.12.0",
"regex>=2026.1.15", # 支持超时的正则库,用于 ReDoS 防护
"regex>=2026.1.15", # 支持超时的正则库,用于 ReDoS 防护
"aiofiles>=25.1.0",
"aiohttp>=3.13.3",
"aiosmtplib>=5.1.0",

View File

@@ -15,6 +15,7 @@ from .provider_query import router as provider_query_router
from .provider_strategy import router as provider_strategy_router
from .providers import router as providers_router
from .security import router as security_router
from .stats import router as stats_router
from .system import router as system_router
from .usage import router as usage_router
from .users import router as users_router
@@ -33,6 +34,7 @@ router.include_router(provider_strategy_router)
router.include_router(adaptive_router)
router.include_router(models_router)
router.include_router(security_router)
router.include_router(stats_router)
router.include_router(provider_query_router)
router.include_router(management_tokens_router)
router.include_router(modules_router)

View File

@@ -0,0 +1,22 @@
"""Stats admin routes export."""
from fastapi import APIRouter
from .comparison import router as comparison_router
from .cost import router as cost_router
from .errors import router as errors_router
from .leaderboard import router as leaderboard_router
from .performance import router as performance_router
from .quota import router as quota_router
from .time_series import router as time_series_router
router = APIRouter(prefix="/api/admin/stats", tags=["Admin - Stats"])
router.include_router(leaderboard_router)
router.include_router(time_series_router)
router.include_router(cost_router)
router.include_router(quota_router)
router.include_router(performance_router)
router.include_router(errors_router)
router.include_router(comparison_router)
__all__ = ["router"]

View File

@@ -0,0 +1,196 @@
"""Shared helpers for admin stats routes."""
from __future__ import annotations
import hashlib
import json
from datetime import date, datetime, time, timedelta, timezone
from typing import Any, Literal
from fastapi import HTTPException
from sqlalchemy import and_, or_
from src.api.base.pipeline import ApiRequestPipeline
from src.config.settings import config
from src.models.database import Usage
from src.services.system.time_range import TimeRangeParams
pipeline = ApiRequestPipeline()
def _apply_admin_default_range(
params: TimeRangeParams | None,
) -> TimeRangeParams | None:
"""Apply a default range to avoid unbounded scans."""
if params is not None:
return params
days = int(getattr(config, "admin_usage_default_days", 0) or 0)
if days <= 0:
return None
today = datetime.now(timezone.utc).date()
start_date = today - timedelta(days=days - 1)
return TimeRangeParams(
start_date=start_date,
end_date=today,
timezone="UTC",
tz_offset_minutes=0,
).validate_and_resolve()
def _build_time_range_params(
start_date: date | None,
end_date: date | None,
preset: str | None,
timezone_name: str | None,
tz_offset_minutes: int | None,
) -> TimeRangeParams | None:
if not preset and start_date is None and end_date is None:
return None
try:
return TimeRangeParams(
start_date=start_date,
end_date=end_date,
preset=preset,
timezone=timezone_name,
tz_offset_minutes=tz_offset_minutes or 0,
).validate_and_resolve()
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
def _hash_filters(filters: dict[str, Any]) -> str:
raw = json.dumps(filters, sort_keys=True, ensure_ascii=False, default=str)
return hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16]
def _build_time_range_from_days(
days: int, timezone_name: str | None, tz_offset_minutes: int | None
) -> TimeRangeParams:
base = TimeRangeParams(
preset="today",
timezone=timezone_name,
tz_offset_minutes=tz_offset_minutes or 0,
).validate_and_resolve()
user_today = base.start_date
start_date = user_today - timedelta(days=days - 1)
return TimeRangeParams(
start_date=start_date,
end_date=user_today,
timezone=timezone_name,
tz_offset_minutes=tz_offset_minutes or 0,
).validate_and_resolve()
def _linear_regression(values: list[float]) -> tuple[float, float]:
n = len(values)
if n <= 1:
return 0.0, values[0] if values else 0.0
xs = list(range(n))
sum_x = sum(xs)
sum_y = sum(values)
sum_x2 = sum(x * x for x in xs)
sum_xy = sum(x * y for x, y in zip(xs, values))
denom = n * sum_x2 - sum_x * sum_x
if denom == 0:
return 0.0, values[-1]
slope = (n * sum_xy - sum_x * sum_y) / denom
intercept = (sum_y - slope * sum_x) / n
return slope, intercept
def _build_cache_key(
leaderboard_type: str,
metric: str,
time_range: TimeRangeParams | None,
filters: dict[str, Any],
) -> str:
start_value = time_range.start_date.isoformat() if time_range else "all"
end_value = time_range.end_date.isoformat() if time_range else "all"
tz_value = time_range.timezone if time_range else "utc"
offset_value = time_range.tz_offset_minutes if time_range else 0
return (
f"leaderboard:{leaderboard_type}:{metric}:{start_value}:{end_value}:"
f"{tz_value}:{offset_value}:{_hash_filters(filters)}"
)
def _is_today_range(time_range: TimeRangeParams | None) -> bool:
if not time_range:
return False
try:
user_today = time_range._get_user_today()
except Exception:
return False
return time_range.end_date == user_today
def _split_daily_and_usage_segments(
time_range: TimeRangeParams | None,
use_daily: bool,
) -> tuple[tuple[datetime, datetime] | None, list[tuple[datetime, datetime]] | None]:
if not time_range:
return None, None
start_utc, end_utc = time_range.to_utc_datetime_range()
if not use_daily:
return None, [(start_utc, end_utc)]
complete_dates, head_boundary, tail_boundary = time_range.get_complete_utc_dates()
daily_range = None
if complete_dates:
daily_start = datetime.combine(complete_dates[0], time.min, tzinfo=timezone.utc)
daily_end = datetime.combine(
complete_dates[-1] + timedelta(days=1), time.min, tzinfo=timezone.utc
)
daily_range = (daily_start, daily_end)
usage_segments: list[tuple[datetime, datetime]] = []
if head_boundary:
usage_segments.append(head_boundary)
if tail_boundary:
usage_segments.append(tail_boundary)
if not daily_range and not usage_segments:
usage_segments = [(start_utc, end_utc)]
return daily_range, usage_segments
def _apply_usage_time_segments(
query: Any, segments: list[tuple[datetime, datetime]] | None
) -> Any | None:
if segments is None:
return query
if not segments:
return None
conditions = []
for start_utc, end_utc in segments:
if start_utc >= end_utc:
continue
conditions.append(and_(Usage.created_at >= start_utc, Usage.created_at < end_utc))
if not conditions:
return None
return query.filter(or_(*conditions))
def _union_queries(queries: list[Any]) -> Any | None:
base = None
for query in queries:
if query is None:
continue
if base is None:
base = query
else:
base = base.union_all(query)
return base
def _metric_order(
metric: Literal["requests", "tokens", "cost"], order: Literal["asc", "desc"], expr: Any
) -> Any:
return expr.asc() if order == "asc" else expr.desc()

View File

@@ -0,0 +1,128 @@
"""Admin comparison stats routes."""
from __future__ import annotations
from datetime import date, timedelta
from typing import Any, Literal
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
from src.database import get_db
from src.services.system.stats_aggregator import AggregatedStats, StatsFilter, query_stats_hybrid
from src.services.system.time_range import TimeRangeParams
from .common import pipeline
router = APIRouter()
class AdminComparisonAdapter(AdminApiAdapter):
def __init__(
self,
current_start: date,
current_end: date,
comparison_type: Literal["period", "year"],
timezone_name: str | None,
tz_offset_minutes: int | None,
) -> None:
self.current_start = current_start
self.current_end = current_end
self.comparison_type = comparison_type
self.timezone_name = timezone_name
self.tz_offset_minutes = tz_offset_minutes
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
if self.current_start > self.current_end:
raise HTTPException(status_code=400, detail="current_start must be <= current_end")
days = (self.current_end - self.current_start).days + 1
def _safe_year_shift(value: date) -> date:
try:
return value.replace(year=value.year - 1)
except ValueError:
return value.replace(year=value.year - 1, day=28)
if self.comparison_type == "period":
comparison_end = self.current_start - timedelta(days=1)
comparison_start = comparison_end - timedelta(days=days - 1)
else:
comparison_start = _safe_year_shift(self.current_start)
comparison_end = _safe_year_shift(self.current_end)
current_range = TimeRangeParams(
start_date=self.current_start,
end_date=self.current_end,
timezone=self.timezone_name,
tz_offset_minutes=self.tz_offset_minutes or 0,
).validate_and_resolve()
comparison_range = TimeRangeParams(
start_date=comparison_start,
end_date=comparison_end,
timezone=self.timezone_name,
tz_offset_minutes=self.tz_offset_minutes or 0,
).validate_and_resolve()
current_stats = query_stats_hybrid(context.db, current_range, filters=StatsFilter())
comparison_stats = query_stats_hybrid(context.db, comparison_range, filters=StatsFilter())
def _stats_payload(stats: AggregatedStats) -> dict[str, Any]:
total_tokens = (
stats.input_tokens
+ stats.output_tokens
+ stats.cache_creation_tokens
+ stats.cache_read_tokens
)
return {
"total_requests": stats.total_requests,
"total_tokens": total_tokens,
"total_cost": float(stats.total_cost),
"actual_total_cost": float(stats.actual_total_cost),
"avg_response_time_ms": float(stats.avg_response_time_ms),
"error_requests": stats.error_requests,
}
def _pct_change(current: float, previous: float) -> float | None:
if previous == 0:
return None if current != 0 else 0.0
return round((current - previous) / previous * 100, 2)
current_payload = _stats_payload(current_stats)
comparison_payload = _stats_payload(comparison_stats)
changes = {
key: _pct_change(float(current_payload[key]), float(comparison_payload[key]))
for key in current_payload.keys()
}
return {
"current": current_payload,
"comparison": comparison_payload,
"change_percent": changes,
"current_start": self.current_start.isoformat(),
"current_end": self.current_end.isoformat(),
"comparison_start": comparison_start.isoformat(),
"comparison_end": comparison_end.isoformat(),
}
@router.get("/comparison")
async def get_comparison(
request: Request,
db: Session = Depends(get_db),
current_start: date = Query(...),
current_end: date = Query(...),
comparison_type: Literal["period", "year"] = Query("period"),
timezone_name: str | None = Query(None, alias="timezone"),
tz_offset_minutes: int | None = Query(0),
) -> Any:
adapter = AdminComparisonAdapter(
current_start=current_start,
current_end=current_end,
comparison_type=comparison_type,
timezone_name=timezone_name,
tz_offset_minutes=tz_offset_minutes,
)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)

183
src/api/admin/stats/cost.py Normal file
View File

@@ -0,0 +1,183 @@
"""Admin cost stats routes."""
from __future__ import annotations
from datetime import date, timedelta
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import func
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
from src.database import get_db
from src.models.database import Usage
from src.services.system.stats_aggregator import query_time_series
from src.services.system.time_range import TimeRangeParams
from .common import (
_apply_admin_default_range,
_build_time_range_from_days,
_build_time_range_params,
_linear_regression,
pipeline,
)
router = APIRouter()
class AdminCostForecastAdapter(AdminApiAdapter):
def __init__(
self,
time_range: TimeRangeParams | None,
days: int,
forecast_days: int,
timezone_name: str | None,
tz_offset_minutes: int | None,
) -> None:
self.time_range = time_range
self.days = days
self.forecast_days = forecast_days
self.timezone_name = timezone_name
self.tz_offset_minutes = tz_offset_minutes
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
time_range = self.time_range or _build_time_range_from_days(
self.days, self.timezone_name, self.tz_offset_minutes
)
time_range.granularity = "day"
try:
series = query_time_series(context.db, time_range)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
history = [
{"date": item["date"], "total_cost": float(item.get("total_cost", 0.0))}
for item in series
]
values = [item["total_cost"] for item in history]
slope, intercept = _linear_regression(values)
forecast = []
if history:
last_date = date.fromisoformat(history[-1]["date"])
else:
last_date = time_range.end_date
for i in range(self.forecast_days):
idx = len(values) + i
predicted = max(0.0, slope * idx + intercept)
forecast.append(
{
"date": (last_date + timedelta(days=i + 1)).isoformat(),
"total_cost": round(predicted, 4),
}
)
return {
"history": history,
"forecast": forecast,
"slope": round(slope, 6),
"intercept": round(intercept, 6),
"start_date": time_range.start_date.isoformat(),
"end_date": time_range.end_date.isoformat(),
}
@router.get("/cost/forecast")
async def get_cost_forecast(
request: Request,
db: Session = Depends(get_db),
start_date: date | None = Query(None),
end_date: date | None = Query(None),
preset: str | None = Query(None),
timezone_name: str | None = Query(None, alias="timezone"),
tz_offset_minutes: int | None = Query(0),
days: int = Query(30, ge=7, le=365),
forecast_days: int = Query(7, ge=1, le=90),
) -> Any:
time_range = _build_time_range_params(
start_date, end_date, preset, timezone_name, tz_offset_minutes
)
adapter = AdminCostForecastAdapter(
time_range=time_range,
days=days,
forecast_days=forecast_days,
timezone_name=timezone_name,
tz_offset_minutes=tz_offset_minutes,
)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
class AdminCostSavingsAdapter(AdminApiAdapter):
def __init__(
self,
time_range: TimeRangeParams | None,
provider_name: str | None,
model: str | None,
) -> None:
self.time_range = _apply_admin_default_range(time_range)
self.provider_name = provider_name
self.model = model
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
if not self.time_range:
return {
"cache_read_tokens": 0,
"cache_read_cost": 0.0,
"cache_creation_cost": 0.0,
"estimated_full_cost": 0.0,
"cache_savings": 0.0,
}
start_utc, end_utc = self.time_range.to_utc_datetime_range()
query = context.db.query(
func.sum(Usage.cache_read_input_tokens).label("cache_read_tokens"),
func.sum(Usage.cache_read_cost_usd).label("cache_read_cost"),
func.sum(Usage.cache_creation_cost_usd).label("cache_creation_cost"),
func.sum(
func.coalesce(Usage.output_price_per_1m, 0) * Usage.cache_read_input_tokens
).label("estimated_full_cost_raw"),
).filter(Usage.created_at >= start_utc, Usage.created_at < end_utc)
if self.provider_name:
query = query.filter(Usage.provider_name == self.provider_name)
if self.model:
query = query.filter(Usage.model == self.model)
row = query.first()
cache_read_tokens = int(getattr(row, "cache_read_tokens", 0) or 0)
cache_read_cost = float(getattr(row, "cache_read_cost", 0) or 0.0)
cache_creation_cost = float(getattr(row, "cache_creation_cost", 0) or 0.0)
estimated_full_cost = float(getattr(row, "estimated_full_cost_raw", 0) or 0.0) / 1_000_000
if estimated_full_cost <= 0 and cache_read_cost > 0:
estimated_full_cost = cache_read_cost * 10
cache_savings = estimated_full_cost - cache_read_cost
return {
"cache_read_tokens": cache_read_tokens,
"cache_read_cost": round(cache_read_cost, 6),
"cache_creation_cost": round(cache_creation_cost, 6),
"estimated_full_cost": round(estimated_full_cost, 6),
"cache_savings": round(cache_savings, 6),
}
@router.get("/cost/savings")
async def get_cost_savings(
request: Request,
db: Session = Depends(get_db),
start_date: date | None = Query(None),
end_date: date | None = Query(None),
preset: str | None = Query(None),
timezone_name: str | None = Query(None, alias="timezone"),
tz_offset_minutes: int | None = Query(0),
provider_name: str | None = Query(None),
model: str | None = Query(None),
) -> Any:
time_range = _build_time_range_params(
start_date, end_date, preset, timezone_name, tz_offset_minutes
)
adapter = AdminCostSavingsAdapter(
time_range=time_range, provider_name=provider_name, model=model
)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)

View File

@@ -0,0 +1,111 @@
"""Admin error stats routes."""
from __future__ import annotations
from datetime import date, timezone
from typing import Any
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import func
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
from src.database import get_db
from src.models.database import StatsDailyError, Usage
from src.services.system.time_range import TimeRangeParams
from .common import _apply_admin_default_range, _build_time_range_params, pipeline
router = APIRouter()
class AdminErrorDistributionAdapter(AdminApiAdapter):
def __init__(self, time_range: TimeRangeParams | None) -> None:
self.time_range = _apply_admin_default_range(time_range)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
if not self.time_range:
return {"distribution": [], "trend": []}
time_range = self.time_range
is_utc = (time_range.timezone in {None, "UTC"}) and time_range.tz_offset_minutes == 0
distribution: dict[str, int] = {}
trend: dict[str, dict[str, int]] = {}
if is_utc:
start_utc, end_utc = time_range.to_utc_datetime_range()
rows = (
context.db.query(StatsDailyError)
.filter(StatsDailyError.date >= start_utc, StatsDailyError.date < end_utc)
.all()
)
for row in rows:
date_str = (
row.date.astimezone(timezone.utc).date().isoformat()
if row.date.tzinfo
else row.date.date().isoformat()
)
distribution[row.error_category] = distribution.get(row.error_category, 0) + int(
row.count or 0
)
trend.setdefault(date_str, {})
trend[date_str][row.error_category] = trend[date_str].get(
row.error_category, 0
) + int(row.count or 0)
else:
for local_date, day_start_utc, day_end_utc in time_range.get_local_day_hours():
rows = (
context.db.query(
Usage.error_category,
func.count(Usage.id).label("count"),
)
.filter(
Usage.created_at >= day_start_utc,
Usage.created_at < day_end_utc,
Usage.error_category.isnot(None),
)
.group_by(Usage.error_category)
.all()
)
date_str = local_date.isoformat()
for row in rows:
distribution[row.error_category] = distribution.get(
row.error_category, 0
) + int(row.count or 0)
trend.setdefault(date_str, {})
trend[date_str][row.error_category] = trend[date_str].get(
row.error_category, 0
) + int(row.count or 0)
trend_items = []
for day in sorted(trend.keys()):
counts = trend[day]
total = sum(counts.values())
trend_items.append({"date": day, "total": total, "categories": counts})
distribution_items = [
{"category": category, "count": count}
for category, count in sorted(
distribution.items(), key=lambda item: item[1], reverse=True
)
]
return {"distribution": distribution_items, "trend": trend_items}
@router.get("/errors/distribution")
async def get_error_distribution(
request: Request,
db: Session = Depends(get_db),
start_date: date | None = Query(None),
end_date: date | None = Query(None),
preset: str | None = Query(None),
timezone_name: str | None = Query(None, alias="timezone"),
tz_offset_minutes: int | None = Query(0),
) -> Any:
time_range = _build_time_range_params(
start_date, end_date, preset, timezone_name, tz_offset_minutes
)
adapter = AdminErrorDistributionAdapter(time_range=time_range)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)

View File

@@ -0,0 +1,759 @@
"""Admin leaderboard stats routes."""
from __future__ import annotations
import json
from datetime import date
from typing import Any, Literal
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import func
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
from src.clients.redis_client import get_redis_client_sync
from src.config.constants import CacheTTL
from src.core.enums import UserRole
from src.database import get_db
from src.models.database import (
ApiKey,
StatsDailyApiKey,
StatsDailyModel,
StatsUserDaily,
Usage,
User,
)
from src.services.system.time_range import TimeRangeParams
from .common import (
_apply_admin_default_range,
_apply_usage_time_segments,
_build_cache_key,
_build_time_range_params,
_is_today_range,
_metric_order,
_split_daily_and_usage_segments,
_union_queries,
pipeline,
)
router = APIRouter()
class AdminUserLeaderboardAdapter(AdminApiAdapter):
def __init__(
self,
time_range: TimeRangeParams | None,
metric: Literal["requests", "tokens", "cost"],
order: Literal["asc", "desc"],
limit: int,
offset: int,
provider_name: str | None,
model: str | None,
include_inactive: bool,
exclude_admin: bool,
) -> None:
self.time_range = _apply_admin_default_range(time_range)
self.metric = metric
self.order = order
self.limit = limit
self.offset = offset
self.provider_name = provider_name
self.model = model
self.include_inactive = include_inactive
self.exclude_admin = exclude_admin
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
cacheable = not _is_today_range(self.time_range)
redis_client = get_redis_client_sync()
cache_key = None
if cacheable and redis_client:
cache_key = _build_cache_key(
"users",
self.metric,
self.time_range,
{
"order": self.order,
"limit": self.limit,
"offset": self.offset,
"provider_name": self.provider_name,
"model": self.model,
"include_inactive": self.include_inactive,
"exclude_admin": self.exclude_admin,
},
)
cached = await redis_client.get(cache_key)
if cached:
try:
return json.loads(cached)
except Exception:
pass
use_daily = self.time_range is not None and not self.provider_name and not self.model
daily_range, usage_segments = _split_daily_and_usage_segments(self.time_range, use_daily)
daily_query = None
if daily_range:
daily_query = (
db.query(
StatsUserDaily.user_id.label("entity_id"),
func.sum(StatsUserDaily.total_requests).label("requests"),
func.sum(
StatsUserDaily.input_tokens
+ StatsUserDaily.output_tokens
+ StatsUserDaily.cache_creation_tokens
+ StatsUserDaily.cache_read_tokens
).label("tokens"),
func.sum(StatsUserDaily.total_cost).label("cost"),
)
.filter(StatsUserDaily.date >= daily_range[0], StatsUserDaily.date < daily_range[1])
.group_by(StatsUserDaily.user_id)
)
usage_query = db.query(
Usage.user_id.label("entity_id"),
func.count(Usage.id).label("requests"),
func.sum(
Usage.input_tokens
+ Usage.output_tokens
+ Usage.cache_creation_input_tokens
+ Usage.cache_read_input_tokens
).label("tokens"),
func.sum(Usage.total_cost_usd).label("cost"),
).filter(
Usage.user_id.isnot(None),
Usage.status.notin_(["pending", "streaming"]),
Usage.provider_name.notin_(["unknown", "pending"]),
)
if self.provider_name:
usage_query = usage_query.filter(Usage.provider_name == self.provider_name)
if self.model:
usage_query = usage_query.filter(Usage.model == self.model)
usage_query = _apply_usage_time_segments(usage_query, usage_segments)
if usage_query is not None:
usage_query = usage_query.group_by(Usage.user_id)
union_query = _union_queries([daily_query, usage_query])
if union_query is None:
return {
"items": [],
"total": 0,
"metric": self.metric,
"start_date": self.time_range.start_date.isoformat() if self.time_range else None,
"end_date": self.time_range.end_date.isoformat() if self.time_range else None,
}
union_subq = union_query.subquery()
agg_subq = (
db.query(
union_subq.c.entity_id.label("entity_id"),
func.sum(union_subq.c.requests).label("requests"),
func.sum(union_subq.c.tokens).label("tokens"),
func.sum(union_subq.c.cost).label("cost"),
)
.group_by(union_subq.c.entity_id)
.subquery()
)
base_query = (
db.query(
User.id.label("id"),
User.username,
User.email,
agg_subq.c.requests,
agg_subq.c.tokens,
agg_subq.c.cost,
)
.join(agg_subq, agg_subq.c.entity_id == User.id)
.filter(User.is_deleted.is_(False))
)
if not self.include_inactive:
base_query = base_query.filter(User.is_active.is_(True))
if self.exclude_admin:
base_query = base_query.filter(User.role != UserRole.ADMIN)
metric_expr = {
"requests": agg_subq.c.requests,
"tokens": agg_subq.c.tokens,
"cost": agg_subq.c.cost,
}[self.metric]
order_expr = _metric_order(self.metric, self.order, metric_expr)
rank_expr = func.dense_rank().over(order_by=order_expr).label("rank")
total = db.query(func.count()).select_from(base_query.subquery()).scalar() or 0
rows = (
base_query.add_columns(rank_expr, metric_expr.label("metric_value"))
.order_by(order_expr)
.offset(self.offset)
.limit(self.limit)
.all()
)
items = []
for row in rows:
name = row.username or row.email or str(row.id)
value = row.metric_value or 0
if self.metric in {"requests", "tokens"}:
value = int(value)
else:
value = float(value)
items.append(
{
"rank": int(row.rank),
"id": row.id,
"name": name,
"value": value,
"requests": int(row.requests or 0),
"tokens": int(row.tokens or 0),
"cost": float(row.cost or 0.0),
}
)
context.add_audit_metadata(
action="leaderboard_users",
start_date=self.time_range.start_date.isoformat() if self.time_range else None,
end_date=self.time_range.end_date.isoformat() if self.time_range else None,
preset=self.time_range.preset if self.time_range else None,
timezone=self.time_range.timezone if self.time_range else None,
metric=self.metric,
order=self.order,
limit=self.limit,
offset=self.offset,
provider_name=self.provider_name,
model=self.model,
include_inactive=self.include_inactive,
exclude_admin=self.exclude_admin,
result_count=len(items),
total=total,
)
result = {
"items": items,
"total": total,
"metric": self.metric,
"start_date": self.time_range.start_date.isoformat() if self.time_range else None,
"end_date": self.time_range.end_date.isoformat() if self.time_range else None,
}
if cacheable and redis_client and cache_key:
try:
await redis_client.setex(
cache_key, CacheTTL.ADMIN_LEADERBOARD, json.dumps(result, ensure_ascii=False)
)
except Exception:
pass
return result
class AdminApiKeyLeaderboardAdapter(AdminApiAdapter):
def __init__(
self,
time_range: TimeRangeParams | None,
metric: Literal["requests", "tokens", "cost"],
order: Literal["asc", "desc"],
limit: int,
offset: int,
provider_name: str | None,
model: str | None,
include_inactive: bool,
exclude_admin: bool,
) -> None:
self.time_range = _apply_admin_default_range(time_range)
self.metric = metric
self.order = order
self.limit = limit
self.offset = offset
self.provider_name = provider_name
self.model = model
self.include_inactive = include_inactive
self.exclude_admin = exclude_admin
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
cacheable = not _is_today_range(self.time_range)
redis_client = get_redis_client_sync()
cache_key = None
if cacheable and redis_client:
cache_key = _build_cache_key(
"api_keys",
self.metric,
self.time_range,
{
"order": self.order,
"limit": self.limit,
"offset": self.offset,
"provider_name": self.provider_name,
"model": self.model,
"include_inactive": self.include_inactive,
"exclude_admin": self.exclude_admin,
},
)
cached = await redis_client.get(cache_key)
if cached:
try:
return json.loads(cached)
except Exception:
pass
use_daily = self.time_range is not None and not self.provider_name and not self.model
daily_range, usage_segments = _split_daily_and_usage_segments(self.time_range, use_daily)
daily_query = None
if daily_range:
daily_query = (
db.query(
StatsDailyApiKey.api_key_id.label("entity_id"),
func.sum(StatsDailyApiKey.total_requests).label("requests"),
func.sum(
StatsDailyApiKey.input_tokens
+ StatsDailyApiKey.output_tokens
+ StatsDailyApiKey.cache_creation_tokens
+ StatsDailyApiKey.cache_read_tokens
).label("tokens"),
func.sum(StatsDailyApiKey.total_cost).label("cost"),
)
.filter(
StatsDailyApiKey.date >= daily_range[0], StatsDailyApiKey.date < daily_range[1]
)
.group_by(StatsDailyApiKey.api_key_id)
)
usage_query = db.query(
Usage.api_key_id.label("entity_id"),
func.count(Usage.id).label("requests"),
func.sum(
Usage.input_tokens
+ Usage.output_tokens
+ Usage.cache_creation_input_tokens
+ Usage.cache_read_input_tokens
).label("tokens"),
func.sum(Usage.total_cost_usd).label("cost"),
).filter(
Usage.api_key_id.isnot(None),
Usage.status.notin_(["pending", "streaming"]),
Usage.provider_name.notin_(["unknown", "pending"]),
)
if self.provider_name:
usage_query = usage_query.filter(Usage.provider_name == self.provider_name)
if self.model:
usage_query = usage_query.filter(Usage.model == self.model)
usage_query = _apply_usage_time_segments(usage_query, usage_segments)
if usage_query is None:
return {
"items": [],
"total": 0,
"metric": self.metric,
"start_date": self.time_range.start_date.isoformat() if self.time_range else None,
"end_date": self.time_range.end_date.isoformat() if self.time_range else None,
}
usage_query = usage_query.group_by(Usage.api_key_id)
union_query = _union_queries([daily_query, usage_query])
if union_query is None:
return {
"items": [],
"total": 0,
"metric": self.metric,
"start_date": self.time_range.start_date.isoformat() if self.time_range else None,
"end_date": self.time_range.end_date.isoformat() if self.time_range else None,
}
union_subq = union_query.subquery()
agg_subq = (
db.query(
union_subq.c.entity_id.label("entity_id"),
func.sum(union_subq.c.requests).label("requests"),
func.sum(union_subq.c.tokens).label("tokens"),
func.sum(union_subq.c.cost).label("cost"),
)
.group_by(union_subq.c.entity_id)
.subquery()
)
base_query = (
db.query(
ApiKey,
User,
agg_subq.c.requests,
agg_subq.c.tokens,
agg_subq.c.cost,
)
.join(agg_subq, agg_subq.c.entity_id == ApiKey.id)
.join(User, User.id == ApiKey.user_id)
.filter(User.is_deleted.is_(False))
)
if not self.include_inactive:
base_query = base_query.filter(ApiKey.is_active.is_(True))
if self.exclude_admin:
base_query = base_query.filter(User.role != UserRole.ADMIN)
metric_expr = {
"requests": agg_subq.c.requests,
"tokens": agg_subq.c.tokens,
"cost": agg_subq.c.cost,
}[self.metric]
order_expr = _metric_order(self.metric, self.order, metric_expr)
rank_expr = func.dense_rank().over(order_by=order_expr).label("rank")
total = db.query(func.count()).select_from(base_query.subquery()).scalar() or 0
rows = (
base_query.add_columns(rank_expr, metric_expr.label("metric_value"))
.order_by(order_expr)
.offset(self.offset)
.limit(self.limit)
.all()
)
items = []
for row in rows:
api_key = row.ApiKey
name = api_key.name or api_key.get_display_key()
value = row.metric_value or 0
if self.metric in {"requests", "tokens"}:
value = int(value)
else:
value = float(value)
items.append(
{
"rank": int(row.rank),
"id": api_key.id,
"name": name,
"value": value,
"requests": int(row.requests or 0),
"tokens": int(row.tokens or 0),
"cost": float(row.cost or 0.0),
}
)
context.add_audit_metadata(
action="leaderboard_api_keys",
start_date=self.time_range.start_date.isoformat() if self.time_range else None,
end_date=self.time_range.end_date.isoformat() if self.time_range else None,
preset=self.time_range.preset if self.time_range else None,
timezone=self.time_range.timezone if self.time_range else None,
metric=self.metric,
order=self.order,
limit=self.limit,
offset=self.offset,
provider_name=self.provider_name,
model=self.model,
include_inactive=self.include_inactive,
exclude_admin=self.exclude_admin,
result_count=len(items),
total=total,
)
result = {
"items": items,
"total": total,
"metric": self.metric,
"start_date": self.time_range.start_date.isoformat() if self.time_range else None,
"end_date": self.time_range.end_date.isoformat() if self.time_range else None,
}
if cacheable and redis_client and cache_key:
try:
await redis_client.setex(
cache_key, CacheTTL.ADMIN_LEADERBOARD, json.dumps(result, ensure_ascii=False)
)
except Exception:
pass
return result
class AdminModelLeaderboardAdapter(AdminApiAdapter):
def __init__(
self,
time_range: TimeRangeParams | None,
metric: Literal["requests", "tokens", "cost"],
order: Literal["asc", "desc"],
limit: int,
offset: int,
provider_name: str | None,
model: str | None,
) -> None:
self.time_range = _apply_admin_default_range(time_range)
self.metric = metric
self.order = order
self.limit = limit
self.offset = offset
self.provider_name = provider_name
self.model = model
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
cacheable = not _is_today_range(self.time_range)
redis_client = get_redis_client_sync()
cache_key = None
if cacheable and redis_client:
cache_key = _build_cache_key(
"models",
self.metric,
self.time_range,
{
"order": self.order,
"limit": self.limit,
"offset": self.offset,
"provider_name": self.provider_name,
"model": self.model,
},
)
cached = await redis_client.get(cache_key)
if cached:
try:
return json.loads(cached)
except Exception:
pass
use_daily = self.time_range is not None and not self.provider_name
daily_range, usage_segments = _split_daily_and_usage_segments(self.time_range, use_daily)
daily_query = None
if daily_range:
daily_query = (
db.query(
StatsDailyModel.model.label("entity_id"),
func.sum(StatsDailyModel.total_requests).label("requests"),
func.sum(
StatsDailyModel.input_tokens
+ StatsDailyModel.output_tokens
+ StatsDailyModel.cache_creation_tokens
+ StatsDailyModel.cache_read_tokens
).label("tokens"),
func.sum(StatsDailyModel.total_cost).label("cost"),
)
.filter(
StatsDailyModel.date >= daily_range[0], StatsDailyModel.date < daily_range[1]
)
.group_by(StatsDailyModel.model)
)
if self.model:
daily_query = daily_query.filter(StatsDailyModel.model == self.model)
usage_query = db.query(
Usage.model.label("entity_id"),
func.count(Usage.id).label("requests"),
func.sum(
Usage.input_tokens
+ Usage.output_tokens
+ Usage.cache_creation_input_tokens
+ Usage.cache_read_input_tokens
).label("tokens"),
func.sum(Usage.total_cost_usd).label("cost"),
).filter(
Usage.status.notin_(["pending", "streaming"]),
Usage.provider_name.notin_(["unknown", "pending"]),
)
if self.provider_name:
usage_query = usage_query.filter(Usage.provider_name == self.provider_name)
if self.model:
usage_query = usage_query.filter(Usage.model == self.model)
usage_query = _apply_usage_time_segments(usage_query, usage_segments)
if usage_query is not None:
usage_query = usage_query.group_by(Usage.model)
union_query = _union_queries([daily_query, usage_query])
if union_query is None:
return {
"items": [],
"total": 0,
"metric": self.metric,
"start_date": self.time_range.start_date.isoformat() if self.time_range else None,
"end_date": self.time_range.end_date.isoformat() if self.time_range else None,
}
union_subq = union_query.subquery()
agg_subq = (
db.query(
union_subq.c.entity_id.label("entity_id"),
func.sum(union_subq.c.requests).label("requests"),
func.sum(union_subq.c.tokens).label("tokens"),
func.sum(union_subq.c.cost).label("cost"),
)
.group_by(union_subq.c.entity_id)
.subquery()
)
base_query = db.query(
agg_subq.c.entity_id.label("id"),
agg_subq.c.entity_id.label("name"),
agg_subq.c.requests,
agg_subq.c.tokens,
agg_subq.c.cost,
)
metric_expr = {
"requests": agg_subq.c.requests,
"tokens": agg_subq.c.tokens,
"cost": agg_subq.c.cost,
}[self.metric]
order_expr = _metric_order(self.metric, self.order, metric_expr)
rank_expr = func.dense_rank().over(order_by=order_expr).label("rank")
total = db.query(func.count()).select_from(base_query.subquery()).scalar() or 0
rows = (
base_query.add_columns(rank_expr, metric_expr.label("metric_value"))
.order_by(order_expr)
.offset(self.offset)
.limit(self.limit)
.all()
)
items = []
for row in rows:
value = row.metric_value or 0
if self.metric in {"requests", "tokens"}:
value = int(value)
else:
value = float(value)
items.append(
{
"rank": int(row.rank),
"id": row.id,
"name": row.name,
"value": value,
"requests": int(row.requests or 0),
"tokens": int(row.tokens or 0),
"cost": float(row.cost or 0.0),
}
)
context.add_audit_metadata(
action="leaderboard_models",
start_date=self.time_range.start_date.isoformat() if self.time_range else None,
end_date=self.time_range.end_date.isoformat() if self.time_range else None,
preset=self.time_range.preset if self.time_range else None,
timezone=self.time_range.timezone if self.time_range else None,
metric=self.metric,
order=self.order,
limit=self.limit,
offset=self.offset,
provider_name=self.provider_name,
model=self.model,
result_count=len(items),
total=total,
)
result = {
"items": items,
"total": total,
"metric": self.metric,
"start_date": self.time_range.start_date.isoformat() if self.time_range else None,
"end_date": self.time_range.end_date.isoformat() if self.time_range else None,
}
if cacheable and redis_client and cache_key:
try:
await redis_client.setex(
cache_key, CacheTTL.ADMIN_LEADERBOARD, json.dumps(result, ensure_ascii=False)
)
except Exception:
pass
return result
@router.get("/leaderboard/users")
async def get_user_leaderboard(
request: Request,
db: Session = Depends(get_db),
start_date: date | None = Query(None),
end_date: date | None = Query(None),
preset: str | None = Query(None),
timezone_name: str | None = Query(None, alias="timezone"),
tz_offset_minutes: int | None = Query(0),
metric: Literal["requests", "tokens", "cost"] = Query("requests"),
order: Literal["desc", "asc"] = Query("desc"),
limit: int = Query(10, ge=1, le=100),
offset: int = Query(0, ge=0),
provider_name: str | None = Query(None),
model: str | None = Query(None),
include_inactive: bool = Query(False),
exclude_admin: bool = Query(False),
) -> Any:
time_range = _build_time_range_params(
start_date, end_date, preset, timezone_name, tz_offset_minutes
)
adapter = AdminUserLeaderboardAdapter(
time_range=time_range,
metric=metric,
order=order,
limit=limit,
offset=offset,
provider_name=provider_name,
model=model,
include_inactive=include_inactive,
exclude_admin=exclude_admin,
)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/leaderboard/api-keys")
async def get_api_key_leaderboard(
request: Request,
db: Session = Depends(get_db),
start_date: date | None = Query(None),
end_date: date | None = Query(None),
preset: str | None = Query(None),
timezone_name: str | None = Query(None, alias="timezone"),
tz_offset_minutes: int | None = Query(0),
metric: Literal["requests", "tokens", "cost"] = Query("requests"),
order: Literal["desc", "asc"] = Query("desc"),
limit: int = Query(10, ge=1, le=100),
offset: int = Query(0, ge=0),
provider_name: str | None = Query(None),
model: str | None = Query(None),
include_inactive: bool = Query(False),
exclude_admin: bool = Query(False),
) -> Any:
time_range = _build_time_range_params(
start_date, end_date, preset, timezone_name, tz_offset_minutes
)
adapter = AdminApiKeyLeaderboardAdapter(
time_range=time_range,
metric=metric,
order=order,
limit=limit,
offset=offset,
provider_name=provider_name,
model=model,
include_inactive=include_inactive,
exclude_admin=exclude_admin,
)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/leaderboard/models")
async def get_model_leaderboard(
request: Request,
db: Session = Depends(get_db),
start_date: date | None = Query(None),
end_date: date | None = Query(None),
preset: str | None = Query(None),
timezone_name: str | None = Query(None, alias="timezone"),
tz_offset_minutes: int | None = Query(0),
metric: Literal["requests", "tokens", "cost"] = Query("requests"),
order: Literal["desc", "asc"] = Query("desc"),
limit: int = Query(10, ge=1, le=100),
offset: int = Query(0, ge=0),
provider_name: str | None = Query(None),
model: str | None = Query(None),
) -> Any:
time_range = _build_time_range_params(
start_date, end_date, preset, timezone_name, tz_offset_minutes
)
adapter = AdminModelLeaderboardAdapter(
time_range=time_range,
metric=metric,
order=order,
limit=limit,
offset=offset,
provider_name=provider_name,
model=model,
)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)

View File

@@ -0,0 +1,85 @@
"""Admin performance stats routes."""
from __future__ import annotations
from datetime import date, timezone
from typing import Any
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
from src.database import get_db
from src.models.database import StatsDaily
from src.services.system.stats_aggregator import StatsAggregatorService
from src.services.system.time_range import TimeRangeParams
from .common import _apply_admin_default_range, _build_time_range_params, pipeline
router = APIRouter()
class AdminPercentilesAdapter(AdminApiAdapter):
def __init__(self, time_range: TimeRangeParams | None) -> None:
self.time_range = _apply_admin_default_range(time_range)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
if not self.time_range:
return []
time_range = self.time_range
is_utc = (time_range.timezone in {None, "UTC"}) and time_range.tz_offset_minutes == 0
if is_utc:
start_utc, end_utc = time_range.to_utc_datetime_range()
rows = (
context.db.query(StatsDaily)
.filter(StatsDaily.date >= start_utc, StatsDaily.date < end_utc)
.order_by(StatsDaily.date.asc())
.all()
)
result = []
for row in rows:
date_str = (
row.date.astimezone(timezone.utc).date().isoformat()
if row.date.tzinfo
else row.date.date().isoformat()
)
result.append(
{
"date": date_str,
"p50_response_time_ms": row.p50_response_time_ms,
"p90_response_time_ms": row.p90_response_time_ms,
"p99_response_time_ms": row.p99_response_time_ms,
"p50_first_byte_time_ms": row.p50_first_byte_time_ms,
"p90_first_byte_time_ms": row.p90_first_byte_time_ms,
"p99_first_byte_time_ms": row.p99_first_byte_time_ms,
}
)
return result
result = []
for local_date, day_start_utc, day_end_utc in time_range.get_local_day_hours():
percentiles = StatsAggregatorService.compute_daily_percentiles(
context.db, day_start_utc, day_end_utc
)
result.append({"date": local_date.isoformat(), **percentiles})
return result
@router.get("/performance/percentiles")
async def get_percentiles(
request: Request,
db: Session = Depends(get_db),
start_date: date | None = Query(None),
end_date: date | None = Query(None),
preset: str | None = Query(None),
timezone_name: str | None = Query(None, alias="timezone"),
tz_offset_minutes: int | None = Query(0),
) -> Any:
time_range = _build_time_range_params(
start_date, end_date, preset, timezone_name, tz_offset_minutes
)
adapter = AdminPercentilesAdapter(time_range=time_range)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)

View File

@@ -0,0 +1,83 @@
"""Admin quota usage stats routes."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
from src.core.enums import ProviderBillingType
from src.database import get_db
from src.models.database import Provider
from .common import pipeline
router = APIRouter()
class AdminQuotaUsageAdapter(AdminApiAdapter):
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
providers = (
db.query(Provider)
.filter(
(Provider.billing_type == ProviderBillingType.MONTHLY_QUOTA)
| (Provider.monthly_quota_usd.isnot(None))
)
.all()
)
now = datetime.now(timezone.utc)
result = []
for provider in providers:
quota = provider.monthly_quota_usd or 0.0
used = float(provider.monthly_used_usd or 0.0)
remaining = max(quota - used, 0.0)
usage_percent = round((used / quota) * 100, 2) if quota > 0 else 0.0
reset_at = provider.quota_last_reset_at
if reset_at:
days_elapsed = max(1, (now - reset_at).days)
else:
days_elapsed = max(1, now.day - 1)
daily_rate = used / days_elapsed if used > 0 else 0.0
estimated_exhaust_at = None
if daily_rate > 0 and remaining > 0:
estimated_exhaust_at = now + timedelta(days=remaining / daily_rate)
if provider.quota_expires_at:
if not estimated_exhaust_at or provider.quota_expires_at < estimated_exhaust_at:
estimated_exhaust_at = provider.quota_expires_at
result.append(
{
"id": provider.id,
"name": provider.name,
"quota_usd": float(quota),
"used_usd": float(used),
"remaining_usd": float(remaining),
"usage_percent": usage_percent,
"quota_expires_at": (
provider.quota_expires_at.isoformat() if provider.quota_expires_at else None
),
"estimated_exhaust_at": (
estimated_exhaust_at.isoformat() if estimated_exhaust_at else None
),
}
)
result.sort(key=lambda x: x["usage_percent"], reverse=True)
return {"providers": result}
@router.get("/providers/quota-usage")
async def get_quota_usage(
request: Request,
db: Session = Depends(get_db),
) -> Any:
adapter = AdminQuotaUsageAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)

View File

@@ -0,0 +1,5 @@
"""Admin stats routes (compat export)."""
from . import router
__all__ = ["router"]

View File

@@ -0,0 +1,75 @@
"""Admin time series stats routes."""
from __future__ import annotations
from datetime import date
from typing import Any, Literal
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
from src.database import get_db
from src.services.system.stats_aggregator import TimeSeriesFilter, query_time_series
from src.services.system.time_range import TimeRangeParams
from .common import _apply_admin_default_range, _build_time_range_params, pipeline
router = APIRouter()
class AdminTimeSeriesAdapter(AdminApiAdapter):
def __init__(
self,
time_range: TimeRangeParams | None,
user_id: str | None,
model: str | None,
provider_name: str | None,
) -> None:
self.time_range = _apply_admin_default_range(time_range)
self.user_id = user_id
self.model = model
self.provider_name = provider_name
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
if not self.time_range:
return []
try:
return query_time_series(
context.db,
self.time_range,
filters=TimeSeriesFilter(
user_id=self.user_id, model=self.model, provider_name=self.provider_name
),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/time-series")
async def get_time_series(
request: Request,
db: Session = Depends(get_db),
start_date: date | None = Query(None),
end_date: date | None = Query(None),
preset: str | None = Query(None),
granularity: Literal["hour", "day", "week", "month"] = Query("day"),
timezone_name: str | None = Query(None, alias="timezone"),
tz_offset_minutes: int | None = Query(0),
user_id: str | None = Query(None),
model: str | None = Query(None),
provider_name: str | None = Query(None),
) -> Any:
time_range = _build_time_range_params(
start_date, end_date, preset, timezone_name, tz_offset_minutes
)
if time_range:
time_range.granularity = granularity
adapter = AdminTimeSeriesAdapter(
time_range=time_range,
user_id=user_id,
model=model,
provider_name=provider_name,
)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)

View File

@@ -4,11 +4,11 @@ from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from datetime import date, datetime, timedelta, timezone
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import func
from sqlalchemy import case, func
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
@@ -26,6 +26,8 @@ from src.models.database import (
Usage,
User,
)
from src.services.system.stats_aggregator import AggregatedStats, StatsFilter, query_stats_hybrid
from src.services.system.time_range import TimeRangeParams
from src.services.usage.service import UsageService
from src.utils.cache_decorator import cache_result
@@ -34,22 +36,45 @@ pipeline = ApiRequestPipeline()
def _apply_admin_default_range(
start_date: datetime | None, end_date: datetime | None
) -> tuple[datetime | None, datetime | None]:
"""
Apply a default time range for admin usage endpoints to protect DB from unbounded scans.
Enabled by setting ADMIN_USAGE_DEFAULT_DAYS>0.
"""
if start_date is not None or end_date is not None:
return start_date, end_date
params: TimeRangeParams | None,
) -> TimeRangeParams | None:
"""Apply a default range to avoid unbounded scans."""
if params is not None:
return params
days = int(getattr(config, "admin_usage_default_days", 0) or 0)
if days <= 0:
return start_date, end_date
return None
now = datetime.now(timezone.utc)
return now - timedelta(days=days), now
today = datetime.now(timezone.utc).date()
start_date = today - timedelta(days=days - 1)
return TimeRangeParams(
start_date=start_date,
end_date=today,
timezone="UTC",
tz_offset_minutes=0,
).validate_and_resolve()
def _build_time_range_params(
start_date: date | None,
end_date: date | None,
preset: str | None,
timezone_name: str | None,
tz_offset_minutes: int | None,
) -> TimeRangeParams | None:
if not preset and start_date is None and end_date is None:
return None
try:
return TimeRangeParams(
start_date=start_date,
end_date=end_date,
preset=preset,
timezone=timezone_name,
tz_offset_minutes=tz_offset_minutes or 0,
).validate_and_resolve()
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
# ==================== RESTful Routes ====================
@@ -61,8 +86,11 @@ async def get_usage_aggregation(
group_by: str = Query(
..., description="Aggregation dimension: model, user, provider, or api_format"
),
start_date: datetime | None = None,
end_date: datetime | None = None,
start_date: date | None = None,
end_date: date | None = None,
preset: str | None = None,
timezone_name: str | None = Query(None, alias="timezone"),
tz_offset_minutes: int | None = None,
limit: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db),
) -> Any:
@@ -83,16 +111,18 @@ async def get_usage_aggregation(
- 按提供商聚合时provider_id, provider, request_count, total_tokens, total_cost, actual_cost, avg_response_time_ms, success_rate, error_count
- 按 API 格式聚合时api_format, request_count, total_tokens, total_cost, actual_cost, avg_response_time_ms
"""
time_range = _apply_admin_default_range(
_build_time_range_params(start_date, end_date, preset, timezone_name, tz_offset_minutes)
)
if group_by == "model":
adapter = AdminUsageByModelAdapter(start_date=start_date, end_date=end_date, limit=limit)
adapter = AdminUsageByModelAdapter(time_range=time_range, limit=limit)
elif group_by == "user":
adapter = AdminUsageByUserAdapter(start_date=start_date, end_date=end_date, limit=limit)
adapter = AdminUsageByUserAdapter(time_range=time_range, limit=limit)
elif group_by == "provider":
adapter = AdminUsageByProviderAdapter(start_date=start_date, end_date=end_date, limit=limit)
adapter = AdminUsageByProviderAdapter(time_range=time_range, limit=limit)
elif group_by == "api_format":
adapter = AdminUsageByApiFormatAdapter(
start_date=start_date, end_date=end_date, limit=limit
)
adapter = AdminUsageByApiFormatAdapter(time_range=time_range, limit=limit)
else:
raise HTTPException(
status_code=400,
@@ -104,8 +134,11 @@ async def get_usage_aggregation(
@router.get("/stats")
async def get_usage_stats(
request: Request,
start_date: datetime | None = None,
end_date: datetime | None = None,
start_date: date | None = None,
end_date: date | None = None,
preset: str | None = None,
timezone_name: str | None = Query(None, alias="timezone"),
tz_offset_minutes: int | None = None,
db: Session = Depends(get_db),
) -> Any:
"""
@@ -127,7 +160,10 @@ async def get_usage_stats(
- `error_rate`: 错误率(百分比)
- `cache_stats`: 缓存统计信息cache_creation_tokens, cache_read_tokens, cache_creation_cost, cache_read_cost
"""
adapter = AdminUsageStatsAdapter(start_date=start_date, end_date=end_date)
time_range = _apply_admin_default_range(
_build_time_range_params(start_date, end_date, preset, timezone_name, tz_offset_minutes)
)
adapter = AdminUsageStatsAdapter(time_range=time_range)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -151,13 +187,17 @@ async def get_activity_heatmap(
@router.get("/records")
async def get_usage_records(
request: Request,
start_date: datetime | None = None,
end_date: datetime | None = None,
start_date: date | None = None,
end_date: date | None = None,
preset: str | None = None,
timezone_name: str | None = Query(None, alias="timezone"),
tz_offset_minutes: int | None = None,
search: str | None = None, # 通用搜索:用户名、密钥名、模型名、提供商名
user_id: str | None = None,
username: str | None = None,
model: str | None = None,
provider: str | None = None,
api_format: str | None = None, # API 格式筛选(如 openai:chat, claude:chat
status: str | None = None, # stream, standard, error
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
@@ -176,6 +216,7 @@ async def get_usage_records(
- `username`: 可选,用户名模糊搜索
- `model`: 可选,模型名模糊搜索
- `provider`: 可选,提供商名称搜索
- `api_format`: 可选API 格式筛选(如 openai:chat, claude:chat
- `status`: 可选状态筛选stream: 流式请求standard: 标准请求error: 错误请求pending: 等待中streaming: 流式中completed: 已完成failed: 失败active: 活跃请求)
- `limit`: 返回数量限制,默认 100最大 500
- `offset`: 分页偏移量,默认 0
@@ -190,14 +231,17 @@ async def get_usage_records(
- `limit`: 当前分页限制
- `offset`: 当前分页偏移量
"""
time_range = _apply_admin_default_range(
_build_time_range_params(start_date, end_date, preset, timezone_name, tz_offset_minutes)
)
adapter = AdminUsageRecordsAdapter(
start_date=start_date,
end_date=end_date,
time_range=time_range,
search=search,
user_id=user_id,
username=username,
model=model,
provider=provider,
api_format=api_format,
status=status,
limit=limit,
offset=offset,
@@ -288,73 +332,87 @@ async def get_usage_detail(
class AdminUsageStatsAdapter(AdminApiAdapter):
def __init__(self, start_date: datetime | None, end_date: datetime | None):
self.start_date, self.end_date = _apply_admin_default_range(start_date, end_date)
def __init__(self, time_range: TimeRangeParams | None):
self.time_range = _apply_admin_default_range(time_range)
self.start_date = self.time_range.start_date if self.time_range else None
self.end_date = self.time_range.end_date if self.time_range else None
self.preset = self.time_range.preset if self.time_range else None
self.timezone = self.time_range.timezone if self.time_range else None
self.tz_offset_minutes = self.time_range.tz_offset_minutes if self.time_range else None
@cache_result(
key_prefix="admin:usage:stats",
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
user_specific=False,
vary_by=["start_date", "end_date"],
vary_by=["start_date", "end_date", "preset", "timezone", "tz_offset_minutes"],
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
# Perf: use a single aggregate query (avoid 3 full scans).
from sqlalchemy import case
db = context.db
query = db.query(Usage)
if self.start_date:
query = query.filter(Usage.created_at >= self.start_date)
if self.end_date:
query = query.filter(Usage.created_at <= self.end_date)
stats = query.with_entities(
func.count(Usage.id).label("total_requests"),
func.sum(Usage.total_tokens).label("total_tokens"),
func.sum(Usage.total_cost_usd).label("total_cost"),
func.sum(Usage.actual_total_cost_usd).label("total_actual_cost"),
func.avg(Usage.response_time_ms).label("avg_response_time_ms"),
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"),
func.sum(
case(
(
(Usage.status_code >= 400) | (Usage.error_message.isnot(None)),
1,
),
else_=0,
)
).label("error_count"),
).first()
if self.time_range:
stats = query_stats_hybrid(db, self.time_range, filters=StatsFilter())
else:
error_cond = (Usage.status_code >= 400) | (Usage.error_message.isnot(None))
row = db.query(
func.count(Usage.id).label("total_requests"),
func.sum(case((error_cond, 1), else_=0)).label("error_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.cache_creation_cost_usd).label("cache_creation_cost"),
func.sum(Usage.cache_read_cost_usd).label("cache_read_cost"),
func.sum(Usage.total_cost_usd).label("total_cost"),
func.sum(Usage.actual_total_cost_usd).label("actual_total_cost"),
func.sum(Usage.response_time_ms).label("total_response_time_ms"),
).first()
total_requests = int(getattr(row, "total_requests", 0) or 0)
error_requests = int(getattr(row, "error_requests", 0) or 0)
stats = AggregatedStats(
total_requests=total_requests,
success_requests=total_requests - error_requests,
error_requests=error_requests,
input_tokens=int(getattr(row, "input_tokens", 0) or 0),
output_tokens=int(getattr(row, "output_tokens", 0) or 0),
cache_creation_tokens=int(getattr(row, "cache_creation_tokens", 0) or 0),
cache_read_tokens=int(getattr(row, "cache_read_tokens", 0) or 0),
cache_creation_cost=float(getattr(row, "cache_creation_cost", 0) or 0.0),
cache_read_cost=float(getattr(row, "cache_read_cost", 0) or 0.0),
total_cost=float(getattr(row, "total_cost", 0) or 0.0),
actual_total_cost=float(getattr(row, "actual_total_cost", 0) or 0.0),
total_response_time_ms=float(getattr(row, "total_response_time_ms", 0) or 0.0),
)
context.add_audit_metadata(
action="usage_stats",
start_date=self.start_date.isoformat() if self.start_date else None,
end_date=self.end_date.isoformat() if self.end_date else None,
preset=self.preset,
timezone=self.timezone,
)
total_requests = int(stats.total_requests or 0) if stats else 0
avg_response_time_ms = float(stats.avg_response_time_ms or 0) if stats else 0
avg_response_time = avg_response_time_ms / 1000.0
error_count = int(stats.error_count or 0) if stats else 0
total_requests = stats.total_requests
avg_response_time = stats.avg_response_time_ms / 1000.0
error_count = stats.error_requests
return {
"total_requests": total_requests,
"total_tokens": int(stats.total_tokens or 0) if stats else 0,
"total_cost": float(stats.total_cost or 0) if stats else 0,
"total_actual_cost": float(stats.total_actual_cost or 0) if stats else 0,
"total_tokens": int(
stats.input_tokens
+ stats.output_tokens
+ stats.cache_creation_tokens
+ stats.cache_read_tokens
),
"total_cost": float(stats.total_cost),
"total_actual_cost": float(stats.actual_total_cost),
"avg_response_time": round(avg_response_time, 2),
"error_count": error_count,
"error_rate": (
round((error_count / total_requests) * 100, 2) if total_requests > 0 else 0
),
"cache_stats": {
"cache_creation_tokens": (int(stats.cache_creation_tokens or 0) if stats else 0),
"cache_read_tokens": int(stats.cache_read_tokens or 0) if stats else 0,
"cache_creation_cost": (float(stats.cache_creation_cost or 0) if stats else 0),
"cache_read_cost": float(stats.cache_read_cost or 0) if stats else 0,
"cache_creation_tokens": int(stats.cache_creation_tokens),
"cache_read_tokens": int(stats.cache_read_tokens),
"cache_creation_cost": float(stats.cache_creation_cost),
"cache_read_cost": float(stats.cache_read_cost),
},
}
@@ -373,15 +431,20 @@ class AdminActivityHeatmapAdapter(AdminApiAdapter):
class AdminUsageByModelAdapter(AdminApiAdapter):
def __init__(self, start_date: datetime | None, end_date: datetime | None, limit: int):
self.start_date, self.end_date = _apply_admin_default_range(start_date, end_date)
def __init__(self, time_range: TimeRangeParams | None, limit: int):
self.time_range = _apply_admin_default_range(time_range)
self.start_date = self.time_range.start_date if self.time_range else None
self.end_date = self.time_range.end_date if self.time_range else None
self.preset = self.time_range.preset if self.time_range else None
self.timezone = self.time_range.timezone if self.time_range else None
self.tz_offset_minutes = self.time_range.tz_offset_minutes if self.time_range else None
self.limit = limit
@cache_result(
key_prefix="admin:usage:agg:model",
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
user_specific=False,
vary_by=["start_date", "end_date", "limit"],
vary_by=["start_date", "end_date", "preset", "timezone", "tz_offset_minutes", "limit"],
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
@@ -397,10 +460,9 @@ class AdminUsageByModelAdapter(AdminApiAdapter):
# 过滤掉 unknown/pending provider_name请求未到达任何提供商
query = query.filter(Usage.provider_name.notin_(["unknown", "pending"]))
if self.start_date:
query = query.filter(Usage.created_at >= self.start_date)
if self.end_date:
query = query.filter(Usage.created_at <= self.end_date)
if self.time_range:
start_utc, end_utc = self.time_range.to_utc_datetime_range()
query = query.filter(Usage.created_at >= start_utc, Usage.created_at < end_utc)
query = query.group_by(Usage.model).order_by(func.count(Usage.id).desc()).limit(self.limit)
stats = query.all()
@@ -408,6 +470,8 @@ class AdminUsageByModelAdapter(AdminApiAdapter):
action="usage_by_model",
start_date=self.start_date.isoformat() if self.start_date else None,
end_date=self.end_date.isoformat() if self.end_date else None,
preset=self.preset,
timezone=self.timezone,
limit=self.limit,
result_count=len(stats),
)
@@ -425,15 +489,20 @@ class AdminUsageByModelAdapter(AdminApiAdapter):
class AdminUsageByUserAdapter(AdminApiAdapter):
def __init__(self, start_date: datetime | None, end_date: datetime | None, limit: int):
self.start_date, self.end_date = _apply_admin_default_range(start_date, end_date)
def __init__(self, time_range: TimeRangeParams | None, limit: int):
self.time_range = _apply_admin_default_range(time_range)
self.start_date = self.time_range.start_date if self.time_range else None
self.end_date = self.time_range.end_date if self.time_range else None
self.preset = self.time_range.preset if self.time_range else None
self.timezone = self.time_range.timezone if self.time_range else None
self.tz_offset_minutes = self.time_range.tz_offset_minutes if self.time_range else None
self.limit = limit
@cache_result(
key_prefix="admin:usage:agg:user",
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
user_specific=False,
vary_by=["start_date", "end_date", "limit"],
vary_by=["start_date", "end_date", "preset", "timezone", "tz_offset_minutes", "limit"],
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
@@ -450,10 +519,9 @@ class AdminUsageByUserAdapter(AdminApiAdapter):
.group_by(User.id, User.email, User.username)
)
if self.start_date:
query = query.filter(Usage.created_at >= self.start_date)
if self.end_date:
query = query.filter(Usage.created_at <= self.end_date)
if self.time_range:
start_utc, end_utc = self.time_range.to_utc_datetime_range()
query = query.filter(Usage.created_at >= start_utc, Usage.created_at < end_utc)
query = query.order_by(func.count(Usage.id).desc()).limit(self.limit)
stats = query.all()
@@ -462,6 +530,8 @@ class AdminUsageByUserAdapter(AdminApiAdapter):
action="usage_by_user",
start_date=self.start_date.isoformat() if self.start_date else None,
end_date=self.end_date.isoformat() if self.end_date else None,
preset=self.preset,
timezone=self.timezone,
limit=self.limit,
result_count=len(stats),
)
@@ -480,15 +550,20 @@ class AdminUsageByUserAdapter(AdminApiAdapter):
class AdminUsageByProviderAdapter(AdminApiAdapter):
def __init__(self, start_date: datetime | None, end_date: datetime | None, limit: int):
self.start_date, self.end_date = _apply_admin_default_range(start_date, end_date)
def __init__(self, time_range: TimeRangeParams | None, limit: int):
self.time_range = _apply_admin_default_range(time_range)
self.start_date = self.time_range.start_date if self.time_range else None
self.end_date = self.time_range.end_date if self.time_range else None
self.preset = self.time_range.preset if self.time_range else None
self.timezone = self.time_range.timezone if self.time_range else None
self.tz_offset_minutes = self.time_range.tz_offset_minutes if self.time_range else None
self.limit = limit
@cache_result(
key_prefix="admin:usage:agg:provider",
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
user_specific=False,
vary_by=["start_date", "end_date", "limit"],
vary_by=["start_date", "end_date", "preset", "timezone", "tz_offset_minutes", "limit"],
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
@@ -511,10 +586,12 @@ class AdminUsageByProviderAdapter(AdminApiAdapter):
RequestCandidate.status.in_(["success", "failed"]),
)
if self.start_date:
attempt_query = attempt_query.filter(RequestCandidate.created_at >= self.start_date)
if self.end_date:
attempt_query = attempt_query.filter(RequestCandidate.created_at <= self.end_date)
if self.time_range:
start_utc, end_utc = self.time_range.to_utc_datetime_range()
attempt_query = attempt_query.filter(
RequestCandidate.created_at >= start_utc,
RequestCandidate.created_at < end_utc,
)
attempt_stats = (
attempt_query.group_by(RequestCandidate.provider_id)
@@ -537,10 +614,11 @@ class AdminUsageByProviderAdapter(AdminApiAdapter):
Usage.status.notin_(["pending", "streaming"]),
)
if self.start_date:
usage_query = usage_query.filter(Usage.created_at >= self.start_date)
if self.end_date:
usage_query = usage_query.filter(Usage.created_at <= self.end_date)
if self.time_range:
start_utc, end_utc = self.time_range.to_utc_datetime_range()
usage_query = usage_query.filter(
Usage.created_at >= start_utc, Usage.created_at < end_utc
)
usage_stats = usage_query.group_by(Usage.provider_id).all()
usage_map = {str(u.provider_id): u for u in usage_stats}
@@ -566,6 +644,8 @@ class AdminUsageByProviderAdapter(AdminApiAdapter):
action="usage_by_provider",
start_date=self.start_date.isoformat() if self.start_date else None,
end_date=self.end_date.isoformat() if self.end_date else None,
preset=self.preset,
timezone=self.timezone,
limit=self.limit,
result_count=len(attempt_stats),
)
@@ -599,15 +679,20 @@ class AdminUsageByProviderAdapter(AdminApiAdapter):
class AdminUsageByApiFormatAdapter(AdminApiAdapter):
def __init__(self, start_date: datetime | None, end_date: datetime | None, limit: int):
self.start_date, self.end_date = _apply_admin_default_range(start_date, end_date)
def __init__(self, time_range: TimeRangeParams | None, limit: int):
self.time_range = _apply_admin_default_range(time_range)
self.start_date = self.time_range.start_date if self.time_range else None
self.end_date = self.time_range.end_date if self.time_range else None
self.preset = self.time_range.preset if self.time_range else None
self.timezone = self.time_range.timezone if self.time_range else None
self.tz_offset_minutes = self.time_range.tz_offset_minutes if self.time_range else None
self.limit = limit
@cache_result(
key_prefix="admin:usage:agg:api_format",
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
user_specific=False,
vary_by=["start_date", "end_date", "limit"],
vary_by=["start_date", "end_date", "preset", "timezone", "tz_offset_minutes", "limit"],
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
@@ -626,10 +711,9 @@ class AdminUsageByApiFormatAdapter(AdminApiAdapter):
# 只统计有 api_format 的记录
query = query.filter(Usage.api_format.isnot(None))
if self.start_date:
query = query.filter(Usage.created_at >= self.start_date)
if self.end_date:
query = query.filter(Usage.created_at <= self.end_date)
if self.time_range:
start_utc, end_utc = self.time_range.to_utc_datetime_range()
query = query.filter(Usage.created_at >= start_utc, Usage.created_at < end_utc)
query = (
query.group_by(Usage.api_format).order_by(func.count(Usage.id).desc()).limit(self.limit)
@@ -640,6 +724,8 @@ class AdminUsageByApiFormatAdapter(AdminApiAdapter):
action="usage_by_api_format",
start_date=self.start_date.isoformat() if self.start_date else None,
end_date=self.end_date.isoformat() if self.end_date else None,
preset=self.preset,
timezone=self.timezone,
limit=self.limit,
result_count=len(stats),
)
@@ -660,23 +746,29 @@ class AdminUsageByApiFormatAdapter(AdminApiAdapter):
class AdminUsageRecordsAdapter(AdminApiAdapter):
def __init__(
self,
start_date: datetime | None,
end_date: datetime | None,
time_range: TimeRangeParams | None,
search: str | None,
user_id: str | None,
username: str | None,
model: str | None,
provider: str | None,
api_format: str | None,
status: str | None,
limit: int,
offset: int,
):
self.start_date, self.end_date = _apply_admin_default_range(start_date, end_date)
self.time_range = _apply_admin_default_range(time_range)
self.start_date = self.time_range.start_date if self.time_range else None
self.end_date = self.time_range.end_date if self.time_range else None
self.preset = self.time_range.preset if self.time_range else None
self.timezone = self.time_range.timezone if self.time_range else None
self.tz_offset_minutes = self.time_range.tz_offset_minutes if self.time_range else None
self.search = search
self.user_id = user_id
self.username = username
self.model = model
self.provider = provider
self.api_format = api_format
self.status = status
self.limit = limit
self.offset = offset
@@ -688,11 +780,15 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
vary_by=[
"start_date",
"end_date",
"preset",
"timezone",
"tz_offset_minutes",
"search",
"user_id",
"username",
"model",
"provider",
"api_format",
"status",
"limit",
"offset",
@@ -748,6 +844,9 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
# 提供商筛选:前端为下拉框精确值,使用精确匹配以启用索引
# 如需模糊搜索,请使用 search 参数。
query = query.filter(Provider.name == self.provider)
if self.api_format:
# API 格式筛选:精确匹配(大小写不敏感)
query = query.filter(func.lower(Usage.api_format) == self.api_format.lower())
if self.status:
# 状态筛选
# 旧的筛选值(基于 is_stream 和 status_codestream, standard, error
@@ -773,10 +872,9 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
elif self.status == "active":
# 活跃请求pending 或 streaming 状态
query = query.filter(Usage.status.in_(["pending", "streaming"]))
if self.start_date:
query = query.filter(Usage.created_at >= self.start_date)
if self.end_date:
query = query.filter(Usage.created_at <= self.end_date)
if self.time_range:
start_utc, end_utc = self.time_range.to_utc_datetime_range()
query = query.filter(Usage.created_at >= start_utc, Usage.created_at < end_utc)
# Perf: avoid Query.count() building a subquery selecting many columns
total = int(query.with_entities(func.count(Usage.id)).scalar() or 0)
@@ -875,6 +973,8 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
action="usage_records",
start_date=self.start_date.isoformat() if self.start_date else None,
end_date=self.end_date.isoformat() if self.end_date else None,
preset=self.preset,
timezone=self.timezone,
search=self.search,
user_id=self.user_id,
username=self.username,

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from datetime import date, datetime, timedelta, timezone
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request
@@ -27,7 +27,12 @@ from src.models.database import (
Usage,
)
from src.models.database import User as DBUser
from src.services.system.stats_aggregator import StatsAggregatorService
from src.services.system.stats_aggregator import (
StatsAggregatorService,
TimeSeriesFilter,
query_time_series,
)
from src.services.system.time_range import TimeRangeParams
from src.utils.cache_decorator import cache_result
router = APIRouter(prefix="/api/dashboard", tags=["Dashboard"])
@@ -55,6 +60,29 @@ def format_tokens(num: int) -> str:
return f"{millions:.2f}M"
def _build_time_range_params(
start_date: date | None,
end_date: date | None,
preset: str | None,
timezone_name: str | None,
tz_offset_minutes: int | None,
granularity: str | None = None,
) -> TimeRangeParams | None:
if not preset and start_date is None and end_date is None:
return None
try:
return TimeRangeParams(
start_date=start_date,
end_date=end_date,
preset=preset,
granularity=granularity or "day",
timezone=timezone_name,
tz_offset_minutes=tz_offset_minutes or 0,
).validate_and_resolve()
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/stats")
async def get_dashboard_stats(request: Request, db: Session = Depends(get_db)) -> Any:
"""
@@ -136,6 +164,12 @@ async def get_provider_status(request: Request, db: Session = Depends(get_db)) -
async def get_daily_stats(
request: Request,
days: int = Query(7, ge=1, le=30),
start_date: date | None = Query(None, description="开始日期YYYY-MM-DD"),
end_date: date | None = Query(None, description="结束日期YYYY-MM-DD"),
preset: str | None = Query(None, description="时间预设today/last7days 等)"),
granularity: str = Query("day", description="时间粒度: hour/day/week/month"),
timezone_name: str | None = Query(None, alias="timezone"),
tz_offset_minutes: int | None = Query(None, description="时区偏移(分钟)"),
db: Session = Depends(get_db),
) -> Any:
"""
@@ -160,7 +194,30 @@ async def get_daily_stats(
- `model_summary`: 模型使用汇总,按费用排序
- `period`: 统计周期信息start_date, end_date, days
"""
adapter = DashboardDailyStatsAdapter(days=days)
time_range = _build_time_range_params(
start_date, end_date, preset, timezone_name, tz_offset_minutes, granularity
)
if time_range is None:
# fallback to days
tmp = TimeRangeParams(
start_date=None,
end_date=None,
preset="today",
granularity=granularity,
timezone=timezone_name,
tz_offset_minutes=tz_offset_minutes or 0,
)
user_today = tmp._get_user_today()
start = user_today - timedelta(days=days - 1)
time_range = TimeRangeParams(
start_date=start,
end_date=user_today,
granularity=granularity,
timezone=timezone_name,
tz_offset_minutes=tz_offset_minutes or 0,
).validate_and_resolve()
adapter = DashboardDailyStatsAdapter(time_range=time_range, days=days)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -194,21 +251,12 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
)
async def handle(self, context: ApiRequestContext) -> Any: # 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)
# 使用 UTC 日期,与 stats_daily.date 一致
now_utc = datetime.now(timezone.utc)
today = now_utc.replace(hour=0, minute=0, second=0, microsecond=0)
yesterday = today - timedelta(days=1)
month_start = today.replace(day=1)
# ==================== 使用预聚合数据 ====================
# 今日实时数据只查询一次,避免重复扫描 Usage 表
@@ -887,20 +935,169 @@ class DashboardProviderStatusAdapter(DashboardAdapter):
@dataclass
class DashboardDailyStatsAdapter(DashboardAdapter):
days: int
time_range: TimeRangeParams | None = None
start_date: date | None = None
end_date: date | None = None
preset: str | None = None
granularity: str | None = None
timezone: str | None = None
tz_offset_minutes: int | None = None
def __post_init__(self) -> None:
if self.time_range:
self.start_date = self.time_range.start_date
self.end_date = self.time_range.end_date
self.preset = self.time_range.preset
self.granularity = self.time_range.granularity
self.timezone = self.time_range.timezone
self.tz_offset_minutes = self.time_range.tz_offset_minutes
@cache_result(
key_prefix="dashboard:daily:stats", ttl=CacheTTL.DASHBOARD_DAILY, user_specific=True
key_prefix="dashboard:daily:stats",
ttl=CacheTTL.DASHBOARD_DAILY,
user_specific=True,
vary_by=[
"start_date",
"end_date",
"preset",
"granularity",
"timezone",
"tz_offset_minutes",
],
)
async def handle(self, context: ApiRequestContext) -> Any: # 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
if self.time_range:
try:
series = query_time_series(
db,
self.time_range,
filters=TimeSeriesFilter(user_id=user.id) if not is_admin else None,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
formatted = []
for item in series:
total_tokens = (
item["input_tokens"]
+ item["output_tokens"]
+ item.get("cache_creation_tokens", 0)
+ item.get("cache_read_tokens", 0)
)
formatted.append(
{
"date": item["date"],
"requests": item["total_requests"],
"tokens": total_tokens,
"cost": item["total_cost"],
"avg_response_time": (item.get("avg_response_time_ms", 0.0) / 1000.0),
"unique_models": 0,
"unique_providers": 0,
"fallback_count": 0,
}
)
# Model summary (use Usage directly for now)
start_utc, end_utc = self.time_range.to_utc_datetime_range()
model_query = 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"),
).filter(Usage.created_at >= start_utc, Usage.created_at < end_utc)
if not is_admin:
model_query = model_query.filter(Usage.user_id == user.id)
model_stats = (
model_query.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": 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 breakdown (aligned to local days)
breakdown_map: dict[str, list[dict]] = {}
for local_date, day_start, day_end in self.time_range.get_local_day_hours():
day_query = 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"),
).filter(Usage.created_at >= day_start, Usage.created_at < day_end)
if not is_admin:
day_query = day_query.filter(Usage.user_id == user.id)
day_stats = day_query.group_by(Usage.model).all()
breakdown_map[local_date.isoformat()] = [
{
"model": stat.model,
"requests": stat.requests or 0,
"tokens": int(stat.tokens or 0),
"cost": float(stat.cost or 0),
}
for stat in day_stats
if stat.model
]
for item in formatted:
item["model_breakdown"] = breakdown_map.get(item["date"], [])
provider_summary = None
if is_admin:
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 >= start_utc, Usage.created_at < end_utc)
.group_by(Usage.provider_name)
.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
if (stat.provider_name or "").lower() != "unknown"
]
provider_summary.sort(key=lambda x: x["cost"], reverse=True)
result = {
"daily_stats": formatted,
"model_summary": model_summary,
"period": {
"start_date": self.time_range.start_date.isoformat(),
"end_date": self.time_range.end_date.isoformat(),
"days": (self.time_range.end_date - self.time_range.start_date).days + 1,
},
}
if is_admin and provider_summary is not None:
result["provider_summary"] = provider_summary
return result
# 使用业务时区计算日期,确保每日统计与业务日期一致
from zoneinfo import ZoneInfo
from src.services.system.stats_aggregator import APP_TIMEZONE
app_tz = ZoneInfo(APP_TIMEZONE)
now_local = datetime.now(app_tz)
today_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0)

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from datetime import date, datetime, timedelta, timezone
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request
@@ -33,6 +33,7 @@ from src.models.api import (
UpdateProfileRequest,
)
from src.models.database import ApiKey, GlobalModel, Model, Provider, Usage, User
from src.services.system.time_range import TimeRangeParams
from src.services.usage.service import UsageService
from src.services.user.apikey import ApiKeyService
from src.services.user.preference import PreferenceService
@@ -41,6 +42,27 @@ router = APIRouter(prefix="/api/users/me", tags=["User Profile"])
pipeline = ApiRequestPipeline()
def _build_time_range_params(
start_date: date | None,
end_date: date | None,
preset: str | None,
timezone_name: str | None,
tz_offset_minutes: int | None,
) -> TimeRangeParams | None:
if not preset and start_date is None and end_date is None:
return None
try:
return TimeRangeParams(
start_date=start_date,
end_date=end_date,
preset=preset,
timezone=timezone_name,
tz_offset_minutes=tz_offset_minutes or 0,
).validate_and_resolve()
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("")
async def get_my_profile(request: Request, db: Session = Depends(get_db)) -> Any:
"""
@@ -176,8 +198,11 @@ async def toggle_my_api_key(key_id: str, request: Request, db: Session = Depends
@router.get("/usage")
async def get_my_usage(
request: Request,
start_date: datetime | None = Query(None, description="开始时间ISO 格式"),
end_date: datetime | None = Query(None, description="结束时间ISO 格式"),
start_date: date | None = Query(None, description="开始日期YYYY-MM-DD"),
end_date: date | None = Query(None, description="结束日期YYYY-MM-DD"),
preset: str | None = Query(None, description="时间预设today/last7days 等)"),
timezone_name: str | None = Query(None, alias="timezone"),
tz_offset_minutes: int | None = Query(None, description="时区偏移(分钟)"),
search: str | None = Query(None, description="搜索关键词(密钥名、模型名)"),
limit: int = Query(100, ge=1, le=200, description="每页记录数默认100最大200"),
offset: int = Query(0, ge=0, le=2000, description="偏移量用于分页最大2000"),
@@ -197,9 +222,10 @@ async def get_my_usage(
- `records`: 详细使用记录列表
- `pagination`: 分页信息
"""
adapter = GetUsageAdapter(
start_date=start_date, end_date=end_date, search=search, limit=limit, offset=offset
time_range = _build_time_range_params(
start_date, end_date, preset, timezone_name, tz_offset_minutes
)
adapter = GetUsageAdapter(time_range=time_range, search=search, limit=limit, offset=offset)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -728,8 +754,7 @@ class ToggleMyApiKeyAdapter(AuthenticatedApiAdapter):
class GetUsageAdapter(AuthenticatedApiAdapter):
"""获取用户使用统计的适配器"""
start_date: datetime | None
end_date: datetime | None
time_range: TimeRangeParams | None
search: str | None = None
limit: int = 100
offset: int = 0
@@ -743,11 +768,14 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
db = context.db
user = context.user
start_utc = end_utc = None
if self.time_range:
start_utc, end_utc = self.time_range.to_utc_datetime_range()
summary_list = UsageService.get_usage_summary(
db=db,
user_id=user.id,
start_date=self.start_date,
end_date=self.end_date,
start_date=start_utc,
end_date=end_utc,
)
# 过滤掉 unknown/pending provider 的记录(请求未到达任何提供商)
@@ -858,10 +886,8 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
.outerjoin(ProviderEndpoint, Usage.provider_endpoint_id == ProviderEndpoint.id)
.filter(Usage.user_id == user.id)
)
if self.start_date:
query = query.filter(Usage.created_at >= self.start_date)
if self.end_date:
query = query.filter(Usage.created_at <= self.end_date)
if start_utc and end_utc:
query = query.filter(Usage.created_at >= start_utc, Usage.created_at < end_utc)
# 通用搜索:密钥名、模型名
# 支持空格分隔的组合搜索,多个关键词之间是 AND 关系
@@ -925,10 +951,10 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
Usage.status_code == 200,
Usage.response_time_ms.isnot(None),
)
if self.start_date:
avg_resp_query = avg_resp_query.filter(Usage.created_at >= self.start_date)
if self.end_date:
avg_resp_query = avg_resp_query.filter(Usage.created_at <= self.end_date)
if start_utc and end_utc:
avg_resp_query = avg_resp_query.filter(
Usage.created_at >= start_utc, Usage.created_at < end_utc
)
avg_response_ms = avg_resp_query.scalar() or 0
avg_response_time = float(avg_response_ms) / 1000.0 if avg_response_ms else 0

View File

@@ -32,6 +32,9 @@ class CacheTTL:
ADMIN_USAGE_AGGREGATION = 30 # 30秒
ADMIN_USAGE_RECORDS = 10 # 10秒列表页短缓存避免轮询/重复刷新打爆 DB
# Admin leaderboard (heavier, slower moving)
ADMIN_LEADERBOARD = 300 # 5分钟
# 并发锁 TTL - 防止死锁
CONCURRENCY_LOCK = 600 # 10分钟

View File

@@ -29,3 +29,19 @@ class AuthSource(str, Enum):
LOCAL = "local" # 本地认证
LDAP = "ldap" # LDAP 认证
OAUTH = "oauth" # OAuth 认证(账号首创来源)
class ErrorCategory(str, Enum):
"""错误分类枚举"""
RATE_LIMIT = "rate_limit"
AUTH = "auth"
INVALID_REQUEST = "invalid_request"
NOT_FOUND = "not_found"
CONTENT_FILTER = "content_filter"
CONTEXT_LENGTH = "context_length"
SERVER_ERROR = "server_error"
TIMEOUT = "timeout"
NETWORK = "network"
CANCELLED = "cancelled"
UNKNOWN = "unknown"

View File

@@ -344,6 +344,7 @@ class Usage(Base):
is_stream = Column(Boolean, default=False) # 是否为流式请求
status_code = Column(Integer)
error_message = Column(Text, nullable=True)
error_category = Column(String(50), nullable=True, index=True)
response_time_ms = Column(Integer) # 总响应时间(毫秒)
first_byte_time_ms = Column(Integer, nullable=True) # 首字时间/TTFB毫秒
@@ -1944,6 +1945,147 @@ class RequestCandidate(Base):
# ==================== 统计数据模型 ====================
class StatsHourly(Base):
"""小时级统计快照 - 用于时间序列查询"""
__tablename__ = "stats_hourly"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
# 小时起点 (UTC)
hour_utc = Column(DateTime(timezone=True), nullable=False, unique=True, index=True)
# 请求统计
total_requests = Column(Integer, default=0, nullable=False)
success_requests = Column(Integer, default=0, nullable=False)
error_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)
actual_total_cost = Column(Float, default=0.0, nullable=False)
# 性能统计
avg_response_time_ms = Column(Float, default=0.0, nullable=False)
# 完成标记
is_complete = Column(Boolean, default=False, nullable=False)
aggregated_at = Column(DateTime(timezone=True), nullable=True)
# 时间戳
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__ = (Index("idx_stats_hourly_hour", "hour_utc"),)
class StatsHourlyUser(Base):
"""小时级用户维度统计"""
__tablename__ = "stats_hourly_user"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
hour_utc = Column(DateTime(timezone=True), nullable=False, index=True)
user_id = Column(String(36), nullable=False, index=True)
total_requests = Column(Integer, default=0, nullable=False)
success_requests = Column(Integer, default=0, nullable=False)
error_requests = Column(Integer, default=0, nullable=False)
input_tokens = Column(BigInteger, default=0, nullable=False)
output_tokens = Column(BigInteger, default=0, nullable=False)
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("hour_utc", "user_id", name="uq_stats_hourly_user"),
Index("idx_stats_hourly_user_hour", "hour_utc"),
Index("idx_stats_hourly_user_user_hour", "user_id", "hour_utc"),
)
class StatsHourlyModel(Base):
"""小时级模型维度统计"""
__tablename__ = "stats_hourly_model"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
hour_utc = Column(DateTime(timezone=True), nullable=False, index=True)
model = Column(String(100), nullable=False, index=True)
total_requests = Column(Integer, default=0, nullable=False)
input_tokens = Column(BigInteger, default=0, nullable=False)
output_tokens = Column(BigInteger, default=0, nullable=False)
total_cost = Column(Float, default=0.0, nullable=False)
avg_response_time_ms = 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("hour_utc", "model", name="uq_stats_hourly_model"),
Index("idx_stats_hourly_model_hour", "hour_utc"),
Index("idx_stats_hourly_model_model_hour", "model", "hour_utc"),
)
class StatsHourlyProvider(Base):
"""小时级提供商维度统计"""
__tablename__ = "stats_hourly_provider"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
hour_utc = Column(DateTime(timezone=True), nullable=False, index=True)
provider_name = Column(String(100), nullable=False, index=True)
total_requests = Column(Integer, default=0, nullable=False)
input_tokens = Column(BigInteger, default=0, nullable=False)
output_tokens = Column(BigInteger, default=0, nullable=False)
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("hour_utc", "provider_name", name="uq_stats_hourly_provider"),
Index("idx_stats_hourly_provider_hour", "hour_utc"),
)
class StatsDaily(Base):
"""每日统计快照 - 用于快速查询历史数据"""
@@ -1975,12 +2117,22 @@ class StatsDaily(Base):
# 性能统计
avg_response_time_ms = Column(Float, default=0.0, nullable=False)
p50_response_time_ms = Column(Integer, nullable=True)
p90_response_time_ms = Column(Integer, nullable=True)
p99_response_time_ms = Column(Integer, nullable=True)
p50_first_byte_time_ms = Column(Integer, nullable=True)
p90_first_byte_time_ms = Column(Integer, nullable=True)
p99_first_byte_time_ms = Column(Integer, nullable=True)
fallback_count = Column(Integer, default=0, nullable=False) # Provider 切换次数
# 使用维度统计
unique_models = Column(Integer, default=0, server_default="0", nullable=False)
unique_providers = Column(Integer, default=0, server_default="0", nullable=False)
# 完成标记
is_complete = Column(Boolean, default=False, nullable=False)
aggregated_at = Column(DateTime(timezone=True), nullable=True)
# 时间戳
created_at = Column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
@@ -2084,6 +2236,82 @@ class StatsDailyProvider(Base):
)
class StatsDailyApiKey(Base):
"""API Key 每日统计"""
__tablename__ = "stats_daily_api_key"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
api_key_id = Column(String(36), ForeignKey("api_keys.id", ondelete="CASCADE"), nullable=False)
date = Column(DateTime(timezone=True), nullable=False, index=True)
total_requests = Column(Integer, default=0, nullable=False)
success_requests = Column(Integer, default=0, nullable=False)
error_requests = Column(Integer, default=0, nullable=False)
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)
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("api_key_id", "date", name="uq_stats_daily_api_key"),
Index("idx_stats_daily_api_key_date", "date"),
Index("idx_stats_daily_api_key_key_date", "api_key_id", "date"),
Index("idx_stats_daily_api_key_date_requests", "date", "total_requests"),
Index("idx_stats_daily_api_key_date_cost", "date", "total_cost"),
)
api_key = relationship("ApiKey")
class StatsDailyError(Base):
"""每日错误统计"""
__tablename__ = "stats_daily_error"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
date = Column(DateTime(timezone=True), nullable=False, index=True)
error_category = Column(String(50), nullable=False)
provider_name = Column(String(100), nullable=True)
model = Column(String(100), nullable=True)
count = Column(Integer, default=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",
"error_category",
"provider_name",
"model",
name="uq_stats_daily_error",
),
Index("idx_stats_daily_error_date", "date"),
Index("idx_stats_daily_error_category", "date", "error_category"),
)
class StatsSummary(Base):
"""全局统计汇总 - 单行记录,存储截止到昨天的累计数据"""

View File

@@ -123,14 +123,24 @@ class MaintenanceScheduler:
scheduler = get_scheduler()
# 注册定时任务(使用业务时区)
# 统计聚合任务 - 凌晨 1 点执行
# 注册定时任务
# 统计聚合任务 - UTC 00:05 执行
scheduler.add_cron_job(
self._scheduled_stats_aggregation,
hour=1,
minute=0,
hour=0,
minute=5,
job_id="stats_aggregation",
name="统计数据聚合",
timezone="UTC",
)
# 小时统计聚合任务 - 每小时 05 分执行UTC
scheduler.add_cron_job(
self._scheduled_hourly_stats_aggregation,
hour="*",
minute=5,
job_id="stats_hourly_aggregation",
name="统计小时数据聚合",
timezone="UTC",
)
# 统计聚合补偿任务 - 每 30 分钟检查缺失并回填
scheduler.add_interval_job(
@@ -231,6 +241,10 @@ class MaintenanceScheduler:
"""统计聚合任务(定时调用)"""
await self._perform_stats_aggregation(backfill=backfill)
async def _scheduled_hourly_stats_aggregation(self) -> None:
"""小时统计聚合任务(定时调用)"""
await self._perform_hourly_stats_aggregation()
async def _scheduled_cleanup(self) -> None:
"""清理任务(定时调用)"""
await self._perform_cleanup()
@@ -282,17 +296,12 @@ class MaintenanceScheduler:
logger.info("开始执行统计数据聚合...")
from zoneinfo import ZoneInfo
from src.models.database import StatsDaily
from src.models.database import User as DBUser
from src.services.system.scheduler import APP_TIMEZONE
# 使用业务时区计算日期,确保与定时任务触发时间一致
# 定时任务在 Asia/Shanghai 凌晨 1 点触发,此时应聚合 Asia/Shanghai 的"昨天"
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 日期,定时任务在 UTC 00:05 触发,聚合 UTC 昨天
now_utc = datetime.now(timezone.utc)
today_utc = now_utc.replace(hour=0, minute=0, second=0, microsecond=0)
if backfill:
# 启动时检查并回填缺失的日期
@@ -314,14 +323,15 @@ class MaintenanceScheduler:
# 非首次运行,检查最近是否有缺失的日期需要回填
from src.models.database import StatsDailyModel, StatsDailyProvider
yesterday_business_date = today_local.date() - timedelta(days=1)
yesterday_utc_date = today_utc.date() - timedelta(days=1)
max_backfill_days: int = (
SystemConfigService.get_config(db, "max_stats_backfill_days", 30) or 30
)
# 计算回填检查的起始日期
check_start_date = yesterday_business_date - timedelta(
days=max_backfill_days - 1
check_start_date = yesterday_utc_date - timedelta(days=max_backfill_days - 1)
check_start_dt = datetime.combine(
check_start_date, datetime.min.time(), tzinfo=timezone.utc
)
# 获取 StatsDaily 和 StatsDailyModel 中已有数据的日期集合
@@ -330,41 +340,39 @@ class MaintenanceScheduler:
existing_provider_dates = set()
daily_stats = (
db.query(StatsDaily.date)
.filter(StatsDaily.date >= check_start_date.isoformat())
.all()
db.query(StatsDaily.date).filter(StatsDaily.date >= check_start_dt).all()
)
for (stat_date,) in daily_stats:
if stat_date.tzinfo is None:
stat_date = stat_date.replace(tzinfo=timezone.utc)
existing_daily_dates.add(stat_date.astimezone(app_tz).date())
existing_daily_dates.add(stat_date.date())
model_stats = (
db.query(StatsDailyModel.date)
.filter(StatsDailyModel.date >= check_start_date.isoformat())
.filter(StatsDailyModel.date >= check_start_dt)
.distinct()
.all()
)
for (stat_date,) in model_stats:
if stat_date.tzinfo is None:
stat_date = stat_date.replace(tzinfo=timezone.utc)
existing_model_dates.add(stat_date.astimezone(app_tz).date())
existing_model_dates.add(stat_date.date())
provider_stats = (
db.query(StatsDailyProvider.date)
.filter(StatsDailyProvider.date >= check_start_date.isoformat())
.filter(StatsDailyProvider.date >= check_start_dt)
.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())
existing_provider_dates.add(stat_date.date())
# 找出需要回填的日期
all_dates = set()
current = check_start_date
while current <= yesterday_business_date:
while current <= yesterday_utc_date:
all_dates.add(current)
current += timedelta(days=1)
@@ -389,43 +397,17 @@ class MaintenanceScheduler:
)
users = db.query(DBUser.id).filter(DBUser.is_active.is_(True)).all()
user_ids = [user_id for (user_id,) in users]
failed_dates = 0
failed_users = 0
for current_date in sorted_dates:
try:
current_date_local = datetime.combine(
current_date, datetime.min.time(), tzinfo=app_tz
current_date_utc = datetime.combine(
current_date, datetime.min.time(), tzinfo=timezone.utc
)
StatsAggregatorService.aggregate_daily_stats_bundle(
db, current_date_utc, user_ids=user_ids
)
# 只在缺失时才聚合对应的表
if current_date in missing_daily_dates:
StatsAggregatorService.aggregate_daily_stats(
db, current_date_local
)
if current_date in missing_model_dates:
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:
StatsAggregatorService.aggregate_user_daily_stats(
db, user_id, current_date_local
)
except Exception as e:
failed_users += 1
logger.warning(
f"回填用户 {user_id} 日期 {current_date} 失败: {e}"
)
try:
db.rollback()
except Exception as rollback_err:
logger.error(f"回滚失败: {rollback_err}")
except Exception as e:
failed_dates += 1
logger.warning(f"回填日期 {current_date} 失败: {e}")
@@ -436,10 +418,10 @@ class MaintenanceScheduler:
StatsAggregatorService.update_summary(db)
if failed_dates > 0 or failed_users > 0:
if failed_dates > 0:
logger.warning(
f"回填完成,共处理 {len(dates_to_process)} 天,"
f"失败: {failed_dates}, {failed_users} 个用户记录"
f"失败: {failed_dates}"
)
else:
logger.info(f"缺失数据回填完成,共处理 {len(dates_to_process)}")
@@ -447,25 +429,14 @@ class MaintenanceScheduler:
logger.info("统计数据已是最新,无需回填")
return
# 定时任务:聚合昨天的数据
yesterday_local = today_local - timedelta(days=1)
StatsAggregatorService.aggregate_daily_stats(db, yesterday_local)
StatsAggregatorService.aggregate_daily_model_stats(db, yesterday_local)
StatsAggregatorService.aggregate_daily_provider_stats(db, yesterday_local)
# 定时任务:聚合昨天 (UTC) 的数据
yesterday_utc = today_utc - timedelta(days=1)
users = db.query(DBUser.id).filter(DBUser.is_active.is_(True)).all()
for (user_id,) in users:
try:
StatsAggregatorService.aggregate_user_daily_stats(
db, user_id, yesterday_local
)
except Exception as e:
logger.warning(f"聚合用户 {user_id} 统计数据失败: {e}")
try:
db.rollback()
except Exception:
pass
user_ids = [user_id for (user_id,) in users]
StatsAggregatorService.aggregate_daily_stats_bundle(
db, yesterday_utc, user_ids=user_ids
)
StatsAggregatorService.update_summary(db)
@@ -480,6 +451,27 @@ class MaintenanceScheduler:
finally:
db.close()
async def _perform_hourly_stats_aggregation(self) -> None:
"""执行小时统计聚合任务"""
db = create_session()
try:
if not SystemConfigService.get_config(db, "enable_stats_aggregation", True):
logger.info("统计聚合已禁用,跳过小时聚合任务")
return
now_utc = datetime.now(timezone.utc)
last_hour = now_utc.replace(minute=0, second=0, microsecond=0) - timedelta(hours=1)
StatsAggregatorService.aggregate_hourly_stats_bundle(db, last_hour)
logger.info(f"小时统计聚合完成: {last_hour.isoformat()}")
except Exception as e:
logger.exception(f"小时统计聚合任务执行失败: {e}")
try:
db.rollback()
except Exception:
pass
finally:
db.close()
async def _perform_pending_cleanup(self) -> None:
"""执行 pending 状态清理"""
db = create_session()

View File

@@ -41,10 +41,11 @@ class TaskScheduler:
def add_cron_job(
self,
func: Callable[..., Any],
hour: int,
hour: int | str,
minute: int = 0,
job_id: str | None = None,
name: str | None = None,
timezone: str | None = None,
**kwargs: Any,
) -> Any:
"""
@@ -58,7 +59,8 @@ class TaskScheduler:
name: 任务名称(用于日志)
**kwargs: 传递给任务函数的参数
"""
trigger = CronTrigger(hour=hour, minute=minute, timezone=APP_TIMEZONE)
trigger_timezone = timezone or APP_TIMEZONE
trigger = CronTrigger(hour=hour, minute=minute, timezone=trigger_timezone)
job_id = job_id or func.__name__
display_name = name or job_id
@@ -74,7 +76,7 @@ class TaskScheduler:
logger.info(
f"已注册定时任务: {display_name}, "
f"执行时间: {hour:02d}:{minute:02d} ({APP_TIMEZONE})"
f"执行时间: {hour}:{minute:02d} ({trigger_timezone})"
)
def add_interval_job(

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,241 @@
"""Time range utilities for stats queries."""
from __future__ import annotations
from datetime import date, datetime, time, timedelta, timezone
from typing import Literal
from pydantic import BaseModel, model_validator
class TimeRangeParams(BaseModel):
"""
Time range parameters (local-date semantics).
Rules:
1) Inputs are user-local dates, backend converts to UTC datetime range.
2) Range is half-open: [start, end).
"""
start_date: date | None = None
end_date: date | None = None
preset: (
Literal[
"today",
"yesterday",
"last7days",
"last30days",
"last90days",
"this_week",
"last_week",
"this_month",
"last_month",
"this_year",
]
| None
) = None
granularity: Literal["hour", "day", "week", "month"] = "day"
timezone: str | None = None
tz_offset_minutes: int = 0
@model_validator(mode="after")
def validate_and_resolve(self) -> "TimeRangeParams":
"""Validate and resolve preset to concrete dates."""
if self.preset:
user_today = self._get_user_today()
match self.preset:
case "today":
self.start_date = self.end_date = user_today
case "yesterday":
self.start_date = self.end_date = user_today - timedelta(days=1)
case "last7days":
self.start_date = user_today - timedelta(days=6)
self.end_date = user_today
case "last30days":
self.start_date = user_today - timedelta(days=29)
self.end_date = user_today
case "last90days":
self.start_date = user_today - timedelta(days=89)
self.end_date = user_today
case "this_week":
self.start_date = user_today - timedelta(days=user_today.weekday())
self.end_date = user_today
case "last_week":
week_start = user_today - timedelta(days=user_today.weekday())
self.start_date = week_start - timedelta(days=7)
self.end_date = week_start - timedelta(days=1)
case "this_month":
self.start_date = user_today.replace(day=1)
self.end_date = user_today
case "last_month":
first_of_this_month = user_today.replace(day=1)
self.end_date = first_of_this_month - timedelta(days=1)
self.start_date = self.end_date.replace(day=1)
case "this_year":
self.start_date = user_today.replace(month=1, day=1)
self.end_date = user_today
if not self.preset and (self.start_date is None or self.end_date is None):
raise ValueError("Either preset or both start_date and end_date must be provided")
if self.start_date and self.end_date and self.start_date > self.end_date:
raise ValueError("start_date must be <= end_date")
if self.start_date and self.end_date:
max_days = 365
days = (self.end_date - self.start_date).days
if days > max_days:
raise ValueError(f"Query range cannot exceed {max_days} days")
if self.granularity == "hour":
if self.start_date != self.end_date:
raise ValueError("Hour granularity only supports single day query")
return self
def validate_for_time_series(self) -> "TimeRangeParams":
"""Extra validation for time series queries."""
if self.granularity == "hour" and self.start_date != self.end_date:
raise ValueError("Hour granularity only supports single day query")
if self.start_date and self.end_date:
days_inclusive = (self.end_date - self.start_date).days + 1
max_days_for_time_series = 90
if days_inclusive > max_days_for_time_series:
raise ValueError(
f"Time series query range cannot exceed {max_days_for_time_series} days "
f"(requested {days_inclusive} days). "
"For longer ranges, use aggregated statistics instead."
)
return self
def _get_user_today(self) -> date:
"""Get user-local 'today'."""
if self.timezone:
try:
from zoneinfo import ZoneInfo
user_tz = ZoneInfo(self.timezone)
return datetime.now(user_tz).date()
except Exception:
pass
user_now = datetime.now(timezone.utc) + timedelta(minutes=self.tz_offset_minutes)
return user_now.date()
def _get_tz_offset_for_date(self, local_date: date) -> timedelta:
"""Get timezone offset for a local date (DST-aware if timezone is provided)."""
if self.timezone:
try:
from zoneinfo import ZoneInfo
user_tz = ZoneInfo(self.timezone)
local_midnight = datetime.combine(local_date, time.min)
local_aware = local_midnight.replace(tzinfo=user_tz)
return local_aware.utcoffset() or timedelta(0)
except Exception:
pass
return timedelta(minutes=self.tz_offset_minutes)
def to_utc_datetime_range(self) -> tuple[datetime, datetime]:
"""Convert to UTC datetime range (half-open)."""
start_offset = self._get_tz_offset_for_date(self.start_date)
end_offset = self._get_tz_offset_for_date(self.end_date + timedelta(days=1))
local_start = datetime.combine(self.start_date, time.min)
local_end = datetime.combine(self.end_date + timedelta(days=1), time.min)
start_utc = (local_start - start_offset).replace(tzinfo=timezone.utc)
end_utc = (local_end - end_offset).replace(tzinfo=timezone.utc)
return start_utc, end_utc
def get_complete_utc_dates(
self,
) -> tuple[list[date], tuple[datetime, datetime] | None, tuple[datetime, datetime] | None]:
"""Split into complete UTC days + head/tail boundaries."""
start_utc, end_utc = self.to_utc_datetime_range()
if (
start_utc.hour == 0
and start_utc.minute == 0
and start_utc.second == 0
and start_utc.microsecond == 0
):
first_complete_date = start_utc.date()
head_boundary = None
else:
first_complete_date = start_utc.date() + timedelta(days=1)
head_boundary = (
start_utc,
datetime.combine(first_complete_date, time.min, tzinfo=timezone.utc),
)
if (
end_utc.hour == 0
and end_utc.minute == 0
and end_utc.second == 0
and end_utc.microsecond == 0
):
last_complete_date = end_utc.date() - timedelta(days=1)
tail_boundary = None
else:
last_complete_date = end_utc.date() - timedelta(days=1)
tail_start = datetime.combine(end_utc.date(), time.min, tzinfo=timezone.utc)
tail_boundary = (tail_start, end_utc)
complete_dates = []
if first_complete_date <= last_complete_date:
current = first_complete_date
while current <= last_complete_date:
complete_dates.append(current)
current += timedelta(days=1)
return complete_dates, head_boundary, tail_boundary
def get_local_day_hours(self) -> list[tuple[date, datetime, datetime]]:
"""Return local-day mapped UTC ranges for time-series."""
result = []
current_date = self.start_date
while current_date <= self.end_date:
offset = self._get_tz_offset_for_date(current_date)
local_start = datetime.combine(current_date, time.min)
local_end = datetime.combine(current_date + timedelta(days=1), time.min)
day_start_utc = (local_start - offset).replace(tzinfo=timezone.utc)
day_end_utc = (
local_end - self._get_tz_offset_for_date(current_date + timedelta(days=1))
).replace(tzinfo=timezone.utc)
result.append((current_date, day_start_utc, day_end_utc))
current_date += timedelta(days=1)
return result
def split_time_range_for_hourly(start_utc: datetime, end_utc: datetime) -> tuple[
tuple[datetime, datetime] | None,
list[datetime],
tuple[datetime, datetime] | None,
]:
"""Split into head fragment, complete hours, tail fragment."""
first_hour = start_utc.replace(minute=0, second=0, microsecond=0)
if start_utc > first_hour:
first_hour += timedelta(hours=1)
head_fragment = (start_utc, first_hour) if first_hour <= end_utc else None
else:
head_fragment = None
first_hour = start_utc
last_hour = end_utc.replace(minute=0, second=0, microsecond=0)
if end_utc > last_hour:
tail_fragment = (last_hour, end_utc) if last_hour >= first_hour else None
else:
tail_fragment = None
last_hour = end_utc
complete_hours = []
current = first_hour
while current < last_hour:
complete_hours.append(current)
current += timedelta(hours=1)
return head_fragment, complete_hours, tail_fragment

View File

@@ -0,0 +1,70 @@
"""Error classification helpers for Usage records."""
from __future__ import annotations
from src.core.enums import ErrorCategory
_STATUS_CODE_MAP: dict[int, ErrorCategory] = {
400: ErrorCategory.INVALID_REQUEST,
401: ErrorCategory.AUTH,
403: ErrorCategory.AUTH,
404: ErrorCategory.NOT_FOUND,
408: ErrorCategory.TIMEOUT,
429: ErrorCategory.RATE_LIMIT,
500: ErrorCategory.SERVER_ERROR,
502: ErrorCategory.SERVER_ERROR,
503: ErrorCategory.SERVER_ERROR,
504: ErrorCategory.TIMEOUT,
}
_CONTEXT_LENGTH_PATTERNS = (
"context_length_exceeded",
"maximum context length",
"too many tokens",
"input is too long",
)
_CONTENT_FILTER_PATTERNS = (
"content_filter",
"content_policy",
"safety_block",
"blocked by content",
)
_NETWORK_PATTERNS = ("connection", "network", "dns", "socket")
def classify_error(
status_code: int | None,
error_message: str | None,
status: str | None = None,
) -> ErrorCategory:
"""Map provider errors to ErrorCategory."""
if status and status.lower() == "cancelled":
return ErrorCategory.CANCELLED
if status_code is not None:
mapped = _STATUS_CODE_MAP.get(status_code)
if mapped:
return mapped
if error_message:
msg_lower = error_message.lower()
if any(p in msg_lower for p in _CONTEXT_LENGTH_PATTERNS):
return ErrorCategory.CONTEXT_LENGTH
if any(p in msg_lower for p in _CONTENT_FILTER_PATTERNS):
return ErrorCategory.CONTENT_FILTER
if "rate limit" in msg_lower or "rate_limit" in msg_lower:
return ErrorCategory.RATE_LIMIT
if "timeout" in msg_lower or "timed out" in msg_lower:
return ErrorCategory.TIMEOUT
if any(p in msg_lower for p in _NETWORK_PATTERNS):
return ErrorCategory.NETWORK
if status_code is not None:
if status_code >= 500:
return ErrorCategory.SERVER_ERROR
if status_code >= 400:
return ErrorCategory.INVALID_REQUEST
return ErrorCategory.UNKNOWN

View File

@@ -27,6 +27,7 @@ from src.models.database import (
)
from src.services.model.cost import ModelCostService
from src.services.system.config import SystemConfigService
from src.services.usage.error_classifier import classify_error
@dataclass
@@ -335,6 +336,10 @@ class UsageService:
actual_request_cost = request_cost * actual_rate_multiplier
actual_total_cost = total_cost * actual_rate_multiplier
error_category = None
if status_code >= 400 or error_message or status in {"failed", "cancelled"}:
error_category = classify_error(status_code, error_message, status).value
return {
"user_id": user.id if user else None,
"api_key_id": api_key.id if api_key else None,
@@ -376,6 +381,7 @@ class UsageService:
"is_stream": is_stream,
"status_code": status_code,
"error_message": error_message,
"error_category": error_category,
"response_time_ms": response_time_ms,
"first_byte_time_ms": first_byte_time_ms,
"status": status,
@@ -586,6 +592,7 @@ class UsageService:
existing_usage.status = usage_params["status"]
existing_usage.status_code = usage_params["status_code"]
existing_usage.error_message = usage_params["error_message"]
existing_usage.error_category = usage_params.get("error_category")
existing_usage.response_time_ms = usage_params["response_time_ms"]
existing_usage.first_byte_time_ms = usage_params["first_byte_time_ms"]
@@ -2068,7 +2075,7 @@ class UsageService:
if start_date:
query = query.filter(Usage.created_at >= start_date)
if end_date:
query = query.filter(Usage.created_at <= end_date)
query = query.filter(Usage.created_at < end_date)
# 使用跨数据库兼容的日期函数
from src.utils.database_helpers import date_trunc_portable
@@ -2108,7 +2115,7 @@ class UsageService:
if start_date:
summary = summary.filter(Usage.created_at >= start_date)
if end_date:
summary = summary.filter(Usage.created_at <= end_date)
summary = summary.filter(Usage.created_at < end_date)
summary = summary.group_by(date_func, Usage.provider_name, Usage.model).all()

View File

@@ -0,0 +1,27 @@
import pytest
from src.core.enums import ErrorCategory
from src.services.usage.error_classifier import classify_error
@pytest.mark.parametrize(
"status_code,error_message,status,expected",
[
(429, None, None, ErrorCategory.RATE_LIMIT),
(401, None, None, ErrorCategory.AUTH),
(404, None, None, ErrorCategory.NOT_FOUND),
(None, "maximum context length exceeded", None, ErrorCategory.CONTEXT_LENGTH),
(None, "content_filter triggered", None, ErrorCategory.CONTENT_FILTER),
(None, "rate limit reached", None, ErrorCategory.RATE_LIMIT),
(None, "request timeout", None, ErrorCategory.TIMEOUT),
(None, "connection reset by peer", None, ErrorCategory.NETWORK),
(500, None, None, ErrorCategory.SERVER_ERROR),
(400, None, None, ErrorCategory.INVALID_REQUEST),
(200, None, "cancelled", ErrorCategory.CANCELLED),
(None, None, None, ErrorCategory.UNKNOWN),
],
)
def test_classify_error(
status_code: int | None, error_message: str | None, status: str | None, expected: ErrorCategory
) -> None:
assert classify_error(status_code, error_message, status) == expected

View File

@@ -0,0 +1,274 @@
from datetime import date, datetime, timedelta, timezone
import pytest
from src.services.system.time_range import TimeRangeParams, split_time_range_for_hourly
class TestTimeRangeParams:
# ==================== UTC 转换测试 ====================
def test_to_utc_datetime_range_with_offset(self) -> None:
"""测试带时区偏移的 UTC 转换"""
params = TimeRangeParams(
start_date=date(2026, 2, 1),
end_date=date(2026, 2, 1),
tz_offset_minutes=480, # UTC+8
)
start_utc, end_utc = params.to_utc_datetime_range()
assert start_utc == datetime(2026, 1, 31, 16, 0, tzinfo=timezone.utc)
assert end_utc == datetime(2026, 2, 1, 16, 0, tzinfo=timezone.utc)
def test_to_utc_datetime_range_negative_offset(self) -> None:
"""测试负时区偏移(如 UTC-5"""
params = TimeRangeParams(
start_date=date(2026, 2, 1),
end_date=date(2026, 2, 1),
tz_offset_minutes=-300, # UTC-5
)
start_utc, end_utc = params.to_utc_datetime_range()
assert start_utc == datetime(2026, 2, 1, 5, 0, tzinfo=timezone.utc)
assert end_utc == datetime(2026, 2, 2, 5, 0, tzinfo=timezone.utc)
def test_to_utc_datetime_range_utc(self) -> None:
"""测试 UTC 时区(无偏移)"""
params = TimeRangeParams(
start_date=date(2026, 2, 1),
end_date=date(2026, 2, 3),
tz_offset_minutes=0,
)
start_utc, end_utc = params.to_utc_datetime_range()
assert start_utc == datetime(2026, 2, 1, 0, 0, tzinfo=timezone.utc)
assert end_utc == datetime(2026, 2, 4, 0, 0, tzinfo=timezone.utc) # end_date + 1 day
# ==================== 预设解析测试 ====================
def test_preset_today(self) -> None:
"""测试 today 预设"""
params = TimeRangeParams(preset="today", tz_offset_minutes=0)
assert params.start_date is not None
assert params.end_date is not None
assert params.start_date == params.end_date
def test_preset_yesterday(self) -> None:
"""测试 yesterday 预设"""
params = TimeRangeParams(preset="yesterday", tz_offset_minutes=0)
assert params.start_date is not None
assert params.end_date is not None
assert params.start_date == params.end_date
today_params = TimeRangeParams(preset="today", tz_offset_minutes=0)
assert today_params.start_date is not None
assert params.start_date == today_params.start_date - timedelta(days=1)
def test_preset_last7days(self) -> None:
"""测试 last7days 预设(包含今天共 7 天)"""
params = TimeRangeParams(preset="last7days", tz_offset_minutes=0)
assert params.start_date is not None
assert params.end_date is not None
days_diff = (params.end_date - params.start_date).days
assert days_diff == 6 # start 到 end 共 7 天
def test_preset_last30days(self) -> None:
"""测试 last30days 预设"""
params = TimeRangeParams(preset="last30days", tz_offset_minutes=0)
assert params.start_date is not None
assert params.end_date is not None
days_diff = (params.end_date - params.start_date).days
assert days_diff == 29 # start 到 end 共 30 天
def test_preset_this_month(self) -> None:
"""测试 this_month 预设"""
params = TimeRangeParams(preset="this_month", tz_offset_minutes=0)
assert params.start_date is not None
assert params.end_date is not None
assert params.start_date.day == 1 # 月初
assert params.start_date.month == params.end_date.month
def test_preset_last_month(self) -> None:
"""测试 last_month 预设"""
params = TimeRangeParams(preset="last_month", tz_offset_minutes=0)
assert params.start_date is not None
assert params.end_date is not None
assert params.start_date.day == 1 # 上月初
# end_date 应该是上月最后一天
next_day = params.end_date + timedelta(days=1)
assert next_day.day == 1 # 下一天是本月初
# ==================== 跨年/闰年测试 ====================
def test_cross_year_range(self) -> None:
"""测试跨年日期范围"""
params = TimeRangeParams(
start_date=date(2025, 12, 28),
end_date=date(2026, 1, 3),
tz_offset_minutes=0,
)
assert params.start_date is not None
assert params.end_date is not None
start_utc, end_utc = params.to_utc_datetime_range()
assert start_utc.year == 2025
assert end_utc.year == 2026
days = (params.end_date - params.start_date).days + 1
assert days == 7
def test_leap_year_february(self) -> None:
"""测试闰年 2 月2024 是闰年)"""
params = TimeRangeParams(
start_date=date(2024, 2, 28),
end_date=date(2024, 3, 1),
tz_offset_minutes=0,
)
assert params.start_date is not None
assert params.end_date is not None
start_utc, end_utc = params.to_utc_datetime_range()
days = (params.end_date - params.start_date).days + 1
assert days == 3 # 28, 29, 1
def test_non_leap_year_february(self) -> None:
"""测试非闰年 2 月"""
params = TimeRangeParams(
start_date=date(2025, 2, 28),
end_date=date(2025, 3, 1),
tz_offset_minutes=0,
)
assert params.start_date is not None
assert params.end_date is not None
days = (params.end_date - params.start_date).days + 1
assert days == 2 # 28, 1
# ==================== 验证错误测试 ====================
def test_validate_for_time_series_limit(self) -> None:
"""测试时间序列超过 90 天限制"""
params = TimeRangeParams(
start_date=date(2026, 1, 1),
end_date=date(2026, 4, 5), # 95 days inclusive
tz_offset_minutes=0,
)
with pytest.raises(ValueError):
params.validate_for_time_series()
def test_validate_start_after_end(self) -> None:
"""测试 start_date > end_date 应报错"""
with pytest.raises(ValueError):
TimeRangeParams(
start_date=date(2026, 2, 10),
end_date=date(2026, 2, 1),
tz_offset_minutes=0,
)
def test_validate_max_days_exceeded(self) -> None:
"""测试超过 365 天限制"""
with pytest.raises(ValueError):
TimeRangeParams(
start_date=date(2025, 1, 1),
end_date=date(2026, 2, 1), # > 365 days
tz_offset_minutes=0,
)
def test_validate_hour_granularity_multi_day(self) -> None:
"""测试小时粒度不支持多天"""
with pytest.raises(ValueError):
TimeRangeParams(
start_date=date(2026, 2, 1),
end_date=date(2026, 2, 2),
granularity="hour",
tz_offset_minutes=0,
)
def test_validate_missing_dates_and_preset(self) -> None:
"""测试缺少日期和预设应报错"""
with pytest.raises(ValueError):
TimeRangeParams(tz_offset_minutes=0)
# ==================== 完整 UTC 日期分割测试 ====================
def test_get_complete_utc_dates_aligned(self) -> None:
"""测试 UTC 对齐时无头尾边界"""
params = TimeRangeParams(
start_date=date(2026, 2, 1),
end_date=date(2026, 2, 3),
tz_offset_minutes=0, # UTC 对齐
)
assert params.start_date is not None
assert params.end_date is not None
complete_dates, head, tail = params.get_complete_utc_dates()
assert len(complete_dates) == 3
assert head is None
assert tail is None
def test_get_complete_utc_dates_with_offset(self) -> None:
"""测试带偏移时产生头尾边界"""
params = TimeRangeParams(
start_date=date(2026, 2, 1),
end_date=date(2026, 2, 3),
tz_offset_minutes=480, # UTC+8
)
assert params.start_date is not None
assert params.end_date is not None
complete_dates, head, tail = params.get_complete_utc_dates()
# UTC+8 的 2/1 00:00 = UTC 1/31 16:00会产生头边界
assert head is not None or tail is not None or len(complete_dates) < 3
# ==================== 本地日期小时映射测试 ====================
def test_get_local_day_hours(self) -> None:
"""测试本地日期到 UTC 小时映射"""
params = TimeRangeParams(
start_date=date(2026, 2, 1),
end_date=date(2026, 2, 2),
tz_offset_minutes=480, # UTC+8
)
assert params.start_date is not None
assert params.end_date is not None
local_days = params.get_local_day_hours()
assert len(local_days) == 2
for local_date, start_utc, end_utc in local_days:
assert isinstance(local_date, date)
assert isinstance(start_utc, datetime)
assert isinstance(end_utc, datetime)
# 每天应该正好是 24 小时
assert (end_utc - start_utc).total_seconds() == 24 * 3600
class TestSplitTimeRangeForHourly:
"""测试小时级时间范围分割"""
def test_aligned_hours(self) -> None:
"""测试整点对齐的时间范围"""
start = datetime(2026, 2, 1, 0, 0, tzinfo=timezone.utc)
end = datetime(2026, 2, 1, 3, 0, tzinfo=timezone.utc)
head, complete, tail = split_time_range_for_hourly(start, end)
assert head is None
assert tail is None
assert len(complete) == 3
def test_with_head_fragment(self) -> None:
"""测试带头部碎片"""
start = datetime(2026, 2, 1, 0, 30, tzinfo=timezone.utc)
end = datetime(2026, 2, 1, 3, 0, tzinfo=timezone.utc)
head, complete, tail = split_time_range_for_hourly(start, end)
assert head is not None
assert head[0] == start
assert head[1] == datetime(2026, 2, 1, 1, 0, tzinfo=timezone.utc)
assert len(complete) == 2 # 1:00, 2:00
def test_with_tail_fragment(self) -> None:
"""测试带尾部碎片"""
start = datetime(2026, 2, 1, 0, 0, tzinfo=timezone.utc)
end = datetime(2026, 2, 1, 2, 30, tzinfo=timezone.utc)
head, complete, tail = split_time_range_for_hourly(start, end)
assert head is None
assert tail is not None
assert tail[0] == datetime(2026, 2, 1, 2, 0, tzinfo=timezone.utc)
assert tail[1] == end
assert len(complete) == 2 # 0:00, 1:00
def test_with_both_fragments(self) -> None:
"""测试同时带头尾碎片"""
start = datetime(2026, 2, 1, 0, 15, tzinfo=timezone.utc)
end = datetime(2026, 2, 1, 2, 45, tzinfo=timezone.utc)
head, complete, tail = split_time_range_for_hourly(start, end)
assert head is not None
assert tail is not None
assert len(complete) == 1 # 只有 1:00-2:00 是完整的