mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
feat: 统计数据优化 - 支持细粒度时间范围和多维度分析
- 新增 StatsHourly/StatsDaily 预聚合表,支持任意时区的精确统计 - 实现 UTC datetime 范围查询策略,边界数据实时聚合 - 新增统计 API:用户/API Key 维度、成本分析、性能百分位、错误分类 - 新增前端页面:成本分析、性能分析、用户统计 - 新增 TimeRangePicker 组件和统计可视化组件 - 优化 Dashboard 和 Usage 页面支持时间范围筛选 Close #135
This commit is contained in:
@@ -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")
|
||||||
@@ -340,6 +340,102 @@ export interface AdminApiKeysResponse {
|
|||||||
skip: number
|
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 {
|
export interface ApiKeyToggleResponse {
|
||||||
id: string // UUID
|
id: string // UUID
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
@@ -617,4 +713,156 @@ export const adminApi = {
|
|||||||
const response = await apiClient.post<LdapTestResponse>('/api/admin/ldap/test', config)
|
const response = await apiClient.post<LdapTestResponse>('/api/admin/ldap/test', config)
|
||||||
return response.data
|
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
|
||||||
|
},
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = {
|
export const dashboardApi = {
|
||||||
// 获取仪表盘统计数据
|
// 获取仪表盘统计数据
|
||||||
async getStats(): Promise<DashboardStatsResponse> {
|
async getStats(params?: TimeRangeParams): Promise<DashboardStatsResponse> {
|
||||||
const response = await apiClient.get<DashboardStatsResponse>('/api/dashboard/stats')
|
const response = await apiClient.get<DashboardStatsResponse>('/api/dashboard/stats', { params })
|
||||||
return response.data
|
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', {
|
const response = await apiClient.get<DailyStatsResponse>('/api/dashboard/daily-stats', {
|
||||||
params: { days }
|
params
|
||||||
})
|
})
|
||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -201,6 +201,9 @@ export const meApi = {
|
|||||||
async getUsage(params?: {
|
async getUsage(params?: {
|
||||||
start_date?: string
|
start_date?: string
|
||||||
end_date?: string
|
end_date?: string
|
||||||
|
preset?: string
|
||||||
|
timezone?: string
|
||||||
|
tz_offset_minutes?: number
|
||||||
search?: string // 通用搜索:密钥名、模型名
|
search?: string // 通用搜索:密钥名、模型名
|
||||||
limit?: number
|
limit?: number
|
||||||
offset?: number
|
offset?: number
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ export interface UsageFilters {
|
|||||||
model?: string
|
model?: string
|
||||||
start_date?: string
|
start_date?: string
|
||||||
end_date?: string
|
end_date?: string
|
||||||
|
preset?: string
|
||||||
|
granularity?: 'hour' | 'day' | 'week' | 'month'
|
||||||
|
timezone?: string
|
||||||
|
tz_offset_minutes?: number
|
||||||
page?: number
|
page?: number
|
||||||
page_size?: number
|
page_size?: number
|
||||||
}
|
}
|
||||||
@@ -164,11 +168,16 @@ export const usageApi = {
|
|||||||
async getAllUsageRecords(params?: {
|
async getAllUsageRecords(params?: {
|
||||||
start_date?: string
|
start_date?: string
|
||||||
end_date?: string
|
end_date?: string
|
||||||
|
preset?: string
|
||||||
|
granularity?: 'hour' | 'day' | 'week' | 'month'
|
||||||
|
timezone?: string
|
||||||
|
tz_offset_minutes?: number
|
||||||
search?: string // 通用搜索:用户名、密钥名、模型名、提供商名
|
search?: string // 通用搜索:用户名、密钥名、模型名、提供商名
|
||||||
user_id?: string // UUID
|
user_id?: string // UUID
|
||||||
username?: string
|
username?: string
|
||||||
model?: string
|
model?: string
|
||||||
provider?: string
|
provider?: string
|
||||||
|
api_format?: string // API 格式筛选(如 openai:chat, claude:chat)
|
||||||
status?: string // 'stream' | 'standard' | 'error'
|
status?: string // 'stream' | 'standard' | 'error'
|
||||||
limit?: number
|
limit?: number
|
||||||
offset?: number
|
offset?: number
|
||||||
|
|||||||
131
frontend/src/components/common/TimeRangePicker.vue
Normal file
131
frontend/src/components/common/TimeRangePicker.vue
Normal 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>
|
||||||
@@ -10,3 +10,4 @@ export { default as LoadingState } from './LoadingState.vue'
|
|||||||
|
|
||||||
// 表单组件
|
// 表单组件
|
||||||
export { default as ModelMultiSelect } from './ModelMultiSelect.vue'
|
export { default as ModelMultiSelect } from './ModelMultiSelect.vue'
|
||||||
|
export { default as TimeRangePicker } from './TimeRangePicker.vue'
|
||||||
|
|||||||
31
frontend/src/components/stats/ComparisonCard.vue
Normal file
31
frontend/src/components/stats/ComparisonCard.vue
Normal 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>
|
||||||
78
frontend/src/components/stats/CostForecastChart.vue
Normal file
78
frontend/src/components/stats/CostForecastChart.vue
Normal 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>
|
||||||
57
frontend/src/components/stats/ErrorDistributionChart.vue
Normal file
57
frontend/src/components/stats/ErrorDistributionChart.vue
Normal 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>
|
||||||
95
frontend/src/components/stats/LeaderboardTable.vue
Normal file
95
frontend/src/components/stats/LeaderboardTable.vue
Normal 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>
|
||||||
84
frontend/src/components/stats/PercentileChart.vue
Normal file
84
frontend/src/components/stats/PercentileChart.vue
Normal 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>
|
||||||
59
frontend/src/components/stats/QuotaProgressCard.vue
Normal file
59
frontend/src/components/stats/QuotaProgressCard.vue
Normal 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>
|
||||||
7
frontend/src/components/stats/index.ts
Normal file
7
frontend/src/components/stats/index.ts
Normal 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'
|
||||||
@@ -37,17 +37,20 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<!-- 格式转换按钮 -->
|
<!-- 格式转换按钮 -->
|
||||||
<Button
|
<span
|
||||||
variant="ghost"
|
class="mr-1"
|
||||||
size="icon"
|
:title="isEndpointFormatConversionDisabled ? formatConversionDisabledTooltip : (endpoint.format_acceptance_config?.enabled ? '已启用格式转换(点击关闭)' : '启用格式转换')"
|
||||||
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)"
|
|
||||||
>
|
>
|
||||||
<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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -63,7 +66,7 @@
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="h-7 w-7 text-destructive hover:text-destructive"
|
class="h-7 w-7 hover:text-destructive"
|
||||||
title="删除"
|
title="删除"
|
||||||
:disabled="deletingEndpointId === endpoint.id"
|
:disabled="deletingEndpointId === endpoint.id"
|
||||||
@click="handleDeleteEndpoint(endpoint)"
|
@click="handleDeleteEndpoint(endpoint)"
|
||||||
@@ -369,55 +372,62 @@
|
|||||||
<!-- 添加新端点 -->
|
<!-- 添加新端点 -->
|
||||||
<div
|
<div
|
||||||
v-if="availableFormats.length > 0"
|
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">
|
<!-- 卡片头部:API 格式选择 + 添加按钮 -->
|
||||||
<div class="w-32 shrink-0 space-y-1">
|
<div class="flex items-center justify-between px-4 py-2.5 bg-muted/30 border-b border-dashed">
|
||||||
<Label class="text-xs text-muted-foreground">API 格式</Label>
|
<Select
|
||||||
<Select
|
v-model="newEndpoint.api_format"
|
||||||
v-model="newEndpoint.api_format"
|
:open="formatSelectOpen"
|
||||||
:open="formatSelectOpen"
|
@update:open="handleFormatSelectOpen"
|
||||||
@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">
|
||||||
<SelectTrigger class="h-8">
|
<SelectValue placeholder="选择格式..." />
|
||||||
<SelectValue placeholder="选择格式" />
|
</SelectTrigger>
|
||||||
</SelectTrigger>
|
<SelectContent>
|
||||||
<SelectContent>
|
<SelectItem
|
||||||
<SelectItem
|
v-for="format in availableFormats"
|
||||||
v-for="format in availableFormats"
|
:key="format.value"
|
||||||
:key="format.value"
|
:value="format.value"
|
||||||
:value="format.value"
|
>
|
||||||
>
|
{{ format.label }}
|
||||||
{{ format.label }}
|
</SelectItem>
|
||||||
</SelectItem>
|
</SelectContent>
|
||||||
</SelectContent>
|
</Select>
|
||||||
</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>
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
class="shrink-0 h-8"
|
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"
|
:disabled="!newEndpoint.api_format || (!newEndpoint.base_url?.trim() && !provider?.website?.trim()) || addingEndpoint"
|
||||||
@click="handleAddEndpoint"
|
@click="handleAddEndpoint"
|
||||||
>
|
>
|
||||||
{{ addingEndpoint ? '添加中...' : '添加' }}
|
<Plus class="w-3.5 h-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- 空状态 -->
|
<!-- 空状态 -->
|
||||||
@@ -516,8 +526,26 @@ const props = defineProps<{
|
|||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
provider: ProviderWithEndpointsSummary | null
|
provider: ProviderWithEndpointsSummary | null
|
||||||
endpoints?: ProviderEndpoint[]
|
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<{
|
const emit = defineEmits<{
|
||||||
'update:modelValue': [value: boolean]
|
'update:modelValue': [value: boolean]
|
||||||
'endpointCreated': []
|
'endpointCreated': []
|
||||||
|
|||||||
@@ -104,7 +104,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
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)"
|
@click="removeAlias(index)"
|
||||||
>
|
>
|
||||||
<X class="w-4 h-4" />
|
<X class="w-4 h-4" />
|
||||||
|
|||||||
@@ -55,15 +55,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1 shrink-0">
|
<div class="flex items-center gap-1 shrink-0">
|
||||||
<Button
|
<span :title="systemFormatConversionEnabled ? '请先关闭系统级开关' : (provider.enable_format_conversion ? '已启用格式转换(点击关闭)' : '启用格式转换')">
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon"
|
variant="ghost"
|
||||||
:title="provider.enable_format_conversion ? '已启用格式转换(点击关闭)' : '启用格式转换'"
|
size="icon"
|
||||||
:class="provider.enable_format_conversion ? 'text-primary' : ''"
|
:class="`${provider.enable_format_conversion ? 'text-primary' : ''} ${systemFormatConversionEnabled ? 'opacity-50' : ''}`"
|
||||||
@click="toggleFormatConversion"
|
:disabled="systemFormatConversionEnabled"
|
||||||
>
|
@click="toggleFormatConversion"
|
||||||
<Shuffle class="w-4 h-4" />
|
>
|
||||||
</Button>
|
<Shuffle class="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -437,6 +439,8 @@
|
|||||||
v-model="endpointDialogOpen"
|
v-model="endpointDialogOpen"
|
||||||
:provider="provider"
|
:provider="provider"
|
||||||
:endpoints="endpoints"
|
:endpoints="endpoints"
|
||||||
|
:system-format-conversion-enabled="systemFormatConversionEnabled"
|
||||||
|
:provider-format-conversion-enabled="provider.enable_format_conversion"
|
||||||
@endpoint-created="handleEndpointChanged"
|
@endpoint-created="handleEndpointChanged"
|
||||||
@endpoint-updated="handleEndpointChanged"
|
@endpoint-updated="handleEndpointChanged"
|
||||||
/>
|
/>
|
||||||
@@ -522,6 +526,7 @@ import { useToast } from '@/composables/useToast'
|
|||||||
import { useClipboard } from '@/composables/useClipboard'
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
import { useCountdownTimer, formatCountdown } from '@/composables/useCountdownTimer'
|
import { useCountdownTimer, formatCountdown } from '@/composables/useCountdownTimer'
|
||||||
import { getProvider, getProviderEndpoints, updateProvider } from '@/api/endpoints'
|
import { getProvider, getProviderEndpoints, updateProvider } from '@/api/endpoints'
|
||||||
|
import { adminApi } from '@/api/admin'
|
||||||
import {
|
import {
|
||||||
KeyFormDialog,
|
KeyFormDialog,
|
||||||
KeyAllowedModelsEditDialog,
|
KeyAllowedModelsEditDialog,
|
||||||
@@ -575,6 +580,9 @@ const provider = ref<any>(null)
|
|||||||
const endpoints = ref<ProviderEndpointWithKeys[]>([])
|
const endpoints = ref<ProviderEndpointWithKeys[]>([])
|
||||||
const providerKeys = ref<EndpointAPIKey[]>([]) // Provider 级别的 keys
|
const providerKeys = ref<EndpointAPIKey[]>([]) // Provider 级别的 keys
|
||||||
|
|
||||||
|
// 系统级格式转换配置
|
||||||
|
const systemFormatConversionEnabled = ref(false)
|
||||||
|
|
||||||
// 端点相关状态
|
// 端点相关状态
|
||||||
const endpointDialogOpen = ref(false)
|
const endpointDialogOpen = ref(false)
|
||||||
|
|
||||||
@@ -1256,13 +1264,29 @@ function getFormatProbeCountdown(key: EndpointAPIKey, format: string): string {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 加载系统级格式转换配置
|
||||||
|
async function loadSystemFormatConversionConfig() {
|
||||||
|
try {
|
||||||
|
const result = await adminApi.getSystemConfig('enable_format_conversion')
|
||||||
|
systemFormatConversionEnabled.value = result.value === true
|
||||||
|
} catch {
|
||||||
|
// 获取失败时默认为关闭
|
||||||
|
systemFormatConversionEnabled.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 加载 Provider 信息
|
// 加载 Provider 信息
|
||||||
async function loadProvider() {
|
async function loadProvider() {
|
||||||
if (!props.providerId) return
|
if (!props.providerId) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
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) {
|
if (!provider.value) {
|
||||||
throw new Error('Provider 不存在')
|
throw new Error('Provider 不存在')
|
||||||
|
|||||||
@@ -92,7 +92,7 @@
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="h-8 w-8 text-destructive hover:text-destructive"
|
class="h-8 w-8 hover:text-destructive"
|
||||||
title="删除映射组"
|
title="删除映射组"
|
||||||
@click="deleteGroup(group)"
|
@click="deleteGroup(group)"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -123,7 +123,7 @@
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="h-8 w-8 text-destructive hover:text-destructive"
|
class="h-8 w-8 hover:text-destructive"
|
||||||
title="删除映射"
|
title="删除映射"
|
||||||
@click="deleteGroup(item.group!)"
|
@click="deleteGroup(item.group!)"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,33 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<TableCard title="使用记录">
|
<TableCard title="使用记录">
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<!-- 时间段筛选 -->
|
<!-- 时间范围筛选 -->
|
||||||
<Select
|
<TimeRangePicker
|
||||||
v-model:open="periodSelectOpen"
|
v-model="timeRangeModel"
|
||||||
:model-value="selectedPeriod"
|
:show-granularity="false"
|
||||||
@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>
|
|
||||||
|
|
||||||
<!-- 分隔线 -->
|
<!-- 分隔线 -->
|
||||||
<div class="hidden sm:block h-4 w-px bg-border" />
|
<div class="hidden sm:block h-4 w-px bg-border" />
|
||||||
@@ -114,6 +92,29 @@
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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
|
<Select
|
||||||
v-model:open="filterStatusSelectOpen"
|
v-model:open="filterStatusSelectOpen"
|
||||||
@@ -127,20 +128,23 @@
|
|||||||
<SelectItem value="__all__">
|
<SelectItem value="__all__">
|
||||||
全部状态
|
全部状态
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="active">
|
<SelectItem value="stream">
|
||||||
进行中
|
流式
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="standard">
|
||||||
|
标准
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="pending">
|
<SelectItem value="pending">
|
||||||
等待中
|
等待中
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="streaming">
|
<SelectItem value="streaming">
|
||||||
流式传输
|
传输中
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="completed">
|
<SelectItem value="completed">
|
||||||
已完成
|
完成
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="failed">
|
<SelectItem value="failed">
|
||||||
已失败
|
失败
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -520,7 +524,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onUnmounted, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { useDebounceFn, useIntervalFn } from '@vueuse/core'
|
||||||
import {
|
import {
|
||||||
TableCard,
|
TableCard,
|
||||||
Badge,
|
Badge,
|
||||||
@@ -544,7 +549,8 @@ import { formatTokens, formatCurrency } from '@/utils/format'
|
|||||||
import { formatDateTime } from '../composables'
|
import { formatDateTime } from '../composables'
|
||||||
import { useRowClick } from '@/composables/useRowClick'
|
import { useRowClick } from '@/composables/useRowClick'
|
||||||
import { API_FORMAT_LABELS } from '@/api/endpoints/types'
|
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 {
|
export interface UserOption {
|
||||||
id: string
|
id: string
|
||||||
@@ -557,13 +563,14 @@ const props = defineProps<{
|
|||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
showActualCost: boolean
|
showActualCost: boolean
|
||||||
loading: boolean
|
loading: boolean
|
||||||
// 时间段
|
// 时间范围
|
||||||
selectedPeriod: string
|
timeRange: DateRangeParams
|
||||||
// 筛选
|
// 筛选
|
||||||
filterSearch: string
|
filterSearch: string
|
||||||
filterUser: string
|
filterUser: string
|
||||||
filterModel: string
|
filterModel: string
|
||||||
filterProvider: string
|
filterProvider: string
|
||||||
|
filterApiFormat: string
|
||||||
filterStatus: string
|
filterStatus: string
|
||||||
availableUsers: UserOption[]
|
availableUsers: UserOption[]
|
||||||
availableModels: string[]
|
availableModels: string[]
|
||||||
@@ -578,11 +585,12 @@ const props = defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:selectedPeriod': [value: string]
|
'update:timeRange': [value: DateRangeParams]
|
||||||
'update:filterSearch': [value: string]
|
'update:filterSearch': [value: string]
|
||||||
'update:filterUser': [value: string]
|
'update:filterUser': [value: string]
|
||||||
'update:filterModel': [value: string]
|
'update:filterModel': [value: string]
|
||||||
'update:filterProvider': [value: string]
|
'update:filterProvider': [value: string]
|
||||||
|
'update:filterApiFormat': [value: string]
|
||||||
'update:filterStatus': [value: string]
|
'update:filterStatus': [value: string]
|
||||||
'update:currentPage': [value: number]
|
'update:currentPage': [value: number]
|
||||||
'update:pageSize': [value: number]
|
'update:pageSize': [value: number]
|
||||||
@@ -591,16 +599,38 @@ const emit = defineEmits<{
|
|||||||
'showDetail': [id: string]
|
'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 打开状态
|
// Select 打开状态
|
||||||
const periodSelectOpen = ref(false)
|
|
||||||
const filterUserSelectOpen = ref(false)
|
const filterUserSelectOpen = ref(false)
|
||||||
const filterModelSelectOpen = ref(false)
|
const filterModelSelectOpen = ref(false)
|
||||||
const filterProviderSelectOpen = ref(false)
|
const filterProviderSelectOpen = ref(false)
|
||||||
|
const filterApiFormatSelectOpen = ref(false)
|
||||||
const filterStatusSelectOpen = 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)
|
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) => {
|
watch(() => props.filterSearch, (value) => {
|
||||||
if (value !== localSearch.value) {
|
if (value !== localSearch.value) {
|
||||||
@@ -609,36 +639,23 @@ watch(() => props.filterSearch, (value) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
watch(localSearch, (value) => {
|
watch(localSearch, (value) => {
|
||||||
if (searchDebounceTimer) clearTimeout(searchDebounceTimer)
|
emitSearchDebounced(value)
|
||||||
searchDebounceTimer = setTimeout(() => {
|
|
||||||
emit('update:filterSearch', value)
|
|
||||||
}, 300)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 动态计时器相关
|
// 动态计时器相关
|
||||||
const now = ref(Date.now())
|
const now = ref(Date.now())
|
||||||
let timerInterval: ReturnType<typeof setInterval> | null = null
|
|
||||||
|
|
||||||
// 检查是否有活跃请求
|
// 检查是否有活跃请求
|
||||||
const hasActiveRecords = computed(() => {
|
const hasActiveRecords = computed(() => {
|
||||||
return props.records.some(r => r.status === 'pending' || r.status === 'streaming')
|
return props.records.some(r => r.status === 'pending' || r.status === 'streaming')
|
||||||
})
|
})
|
||||||
|
|
||||||
// 启动计时器
|
// 使用 VueUse 的 useIntervalFn 管理计时器(自动清理)
|
||||||
function startTimer() {
|
const { pause: stopTimer, resume: startTimer } = useIntervalFn(
|
||||||
if (timerInterval) return
|
() => { now.value = Date.now() },
|
||||||
timerInterval = setInterval(() => {
|
100,
|
||||||
now.value = Date.now()
|
{ immediate: false }
|
||||||
}, 100) // 每 100ms 更新一次
|
)
|
||||||
}
|
|
||||||
|
|
||||||
// 停止计时器
|
|
||||||
function stopTimer() {
|
|
||||||
if (timerInterval) {
|
|
||||||
clearInterval(timerInterval)
|
|
||||||
timerInterval = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算活跃请求的实时耗时
|
// 计算活跃请求的实时耗时
|
||||||
function getElapsedTime(record: UsageRecord): string {
|
function getElapsedTime(record: UsageRecord): string {
|
||||||
@@ -679,14 +696,7 @@ function handleRowClick(event: MouseEvent, id: string) {
|
|||||||
emit('showDetail', id)
|
emit('showDetail', id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 组件卸载时清理
|
// useIntervalFn 和 useDebounceFn 自动处理清理,无需 onUnmounted
|
||||||
onUnmounted(() => {
|
|
||||||
stopTimer()
|
|
||||||
if (searchDebounceTimer) {
|
|
||||||
clearTimeout(searchDebounceTimer)
|
|
||||||
searchDebounceTimer = null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 格式化 API 格式显示名称
|
// 格式化 API 格式显示名称
|
||||||
function formatApiFormat(format: string): string {
|
function formatApiFormat(format: string): string {
|
||||||
|
|||||||
@@ -4,7 +4,16 @@ import type { PeriodValue, DateRangeParams } from '../types'
|
|||||||
* 格式化日期为 ISO 格式(不带毫秒,兼容 FastAPI datetime 解析)
|
* 格式化日期为 ISO 格式(不带毫秒,兼容 FastAPI datetime 解析)
|
||||||
*/
|
*/
|
||||||
function formatDateForApi(date: Date): string {
|
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 {
|
return {
|
||||||
start_date: formatDateForApi(startDate),
|
start_date: formatDateForApi(startDate),
|
||||||
end_date: formatDateForApi(endDate)
|
end_date: formatDateForApi(endDate),
|
||||||
|
preset: period,
|
||||||
|
...getTimezoneParams()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export interface FilterParams {
|
|||||||
user_id?: string
|
user_id?: string
|
||||||
model?: string
|
model?: string
|
||||||
provider?: string
|
provider?: string
|
||||||
|
api_format?: string
|
||||||
status?: string
|
status?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,6 +264,9 @@ export function useUsageData(options: UseUsageDataOptions) {
|
|||||||
if (filters?.provider) {
|
if (filters?.provider) {
|
||||||
params.provider = filters.provider
|
params.provider = filters.provider
|
||||||
}
|
}
|
||||||
|
if (filters?.api_format) {
|
||||||
|
params.api_format = filters.api_format
|
||||||
|
}
|
||||||
if (filters?.status) {
|
if (filters?.status) {
|
||||||
params.status = filters.status
|
params.status = filters.status
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,6 +100,10 @@ export interface UsageRecord {
|
|||||||
export interface DateRangeParams {
|
export interface DateRangeParams {
|
||||||
start_date?: string
|
start_date?: string
|
||||||
end_date?: string
|
end_date?: string
|
||||||
|
preset?: string
|
||||||
|
granularity?: 'hour' | 'day' | 'week' | 'month'
|
||||||
|
timezone?: string
|
||||||
|
tz_offset_minutes?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// 时间段选项
|
// 时间段选项
|
||||||
|
|||||||
@@ -544,6 +544,9 @@ const navigation = computed(() => {
|
|||||||
items: [
|
items: [
|
||||||
{ name: '仪表盘', href: '/admin/dashboard', icon: Home },
|
{ name: '仪表盘', href: '/admin/dashboard', icon: Home },
|
||||||
{ name: '健康监控', href: '/admin/health-monitor', icon: Activity },
|
{ 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 },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2559,3 +2559,185 @@ mockHandlers['GET /api/admin/usage/cache-affinity/hit-analysis'] = async (config
|
|||||||
const data = generateCacheHitAnalysisData(hours)
|
const data = generateCacheHitAnalysisData(hours)
|
||||||
return createMockResponse(data)
|
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 }
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|||||||
@@ -161,6 +161,21 @@ const routes: RouteRecordRaw[] = [
|
|||||||
name: 'Usage',
|
name: 'Usage',
|
||||||
component: () => importWithRetry(() => import('@/views/shared/Usage.vue'))
|
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',
|
path: 'system',
|
||||||
name: 'SystemSettings',
|
name: 'SystemSettings',
|
||||||
|
|||||||
@@ -20,13 +20,6 @@
|
|||||||
<h3 class="text-sm sm:text-base font-semibold">
|
<h3 class="text-sm sm:text-base font-semibold">
|
||||||
独立余额 API Keys
|
独立余额 API Keys
|
||||||
</h3>
|
</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>
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<!-- 搜索框 -->
|
<!-- 搜索框 -->
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
<h3 class="text-base font-semibold">异步任务</h3>
|
<h3 class="text-base font-semibold">异步任务</h3>
|
||||||
<div class="flex items-center gap-2">
|
<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">
|
<SelectTrigger class="w-28 h-8 text-xs border-border/60">
|
||||||
<SelectValue placeholder="状态" />
|
<SelectValue placeholder="状态" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -690,6 +690,7 @@ const total = ref(0)
|
|||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
const pageSize = ref(20)
|
const pageSize = ref(20)
|
||||||
const filterStatus = ref('all')
|
const filterStatus = ref('all')
|
||||||
|
const statusSelectOpen = ref(false)
|
||||||
const filterModel = ref('')
|
const filterModel = ref('')
|
||||||
const showDetail = ref(false)
|
const showDetail = ref(false)
|
||||||
const selectedTask = ref<AsyncTaskDetail | null>(null)
|
const selectedTask = ref<AsyncTaskDetail | null>(null)
|
||||||
|
|||||||
129
frontend/src/views/admin/CostAnalysis.vue
Normal file
129
frontend/src/views/admin/CostAnalysis.vue
Normal 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>
|
||||||
155
frontend/src/views/admin/PerformanceAnalysis.vue
Normal file
155
frontend/src/views/admin/PerformanceAnalysis.vue
Normal 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>
|
||||||
239
frontend/src/views/admin/UserStats.vue
Normal file
239
frontend/src/views/admin/UserStats.vue
Normal 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>
|
||||||
@@ -393,6 +393,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<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 { ref, onMounted, computed, onBeforeUnmount, nextTick, watch } from 'vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { dashboardApi, type DashboardStat, type DailyStat, type ProviderSummary } from '@/api/dashboard'
|
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 { announcementApi, type Announcement } from '@/api/announcements'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -798,6 +808,7 @@ import {
|
|||||||
TableHead,
|
TableHead,
|
||||||
TableCell,
|
TableCell,
|
||||||
} from '@/components/ui'
|
} from '@/components/ui'
|
||||||
|
import { TimeRangePicker } from '@/components/common'
|
||||||
import BarChart from '@/components/charts/BarChart.vue'
|
import BarChart from '@/components/charts/BarChart.vue'
|
||||||
import DoughnutChart from '@/components/charts/DoughnutChart.vue'
|
import DoughnutChart from '@/components/charts/DoughnutChart.vue'
|
||||||
import LineChart from '@/components/charts/LineChart.vue'
|
import LineChart from '@/components/charts/LineChart.vue'
|
||||||
@@ -972,7 +983,8 @@ const tokenBreakdown = ref<{
|
|||||||
const activeUsers = ref(0)
|
const activeUsers = ref(0)
|
||||||
const dailyStats = ref<DailyStat[]>([])
|
const dailyStats = ref<DailyStat[]>([])
|
||||||
const providerSummary = ref<ProviderSummary[]>([])
|
const providerSummary = ref<ProviderSummary[]>([])
|
||||||
const selectedDays = ref(7)
|
const dailyTimeRange = ref<DateRangeParams>(getDateRangeFromPeriod('last7days'))
|
||||||
|
// 统计周期
|
||||||
const loadingDaily = ref(false)
|
const loadingDaily = ref(false)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
@@ -1317,7 +1329,7 @@ async function loadDashboardData() {
|
|||||||
async function loadDailyStats() {
|
async function loadDailyStats() {
|
||||||
loadingDaily.value = true
|
loadingDaily.value = true
|
||||||
try {
|
try {
|
||||||
const response = await dashboardApi.getDailyStats(selectedDays.value)
|
const response = await dashboardApi.getDailyStats(dailyTimeRange.value)
|
||||||
dailyStats.value = response.daily_stats
|
dailyStats.value = response.daily_stats
|
||||||
providerSummary.value = response.provider_summary || []
|
providerSummary.value = response.provider_summary || []
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1328,6 +1340,10 @@ async function loadDailyStats() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(dailyTimeRange, async () => {
|
||||||
|
await loadDailyStats()
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
function formatDate(dateString: string): string {
|
function formatDate(dateString: string): string {
|
||||||
const date = new Date(dateString)
|
const date = new Date(dateString)
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
|
|||||||
@@ -55,11 +55,12 @@
|
|||||||
:is-admin="isAdminPage"
|
:is-admin="isAdminPage"
|
||||||
:show-actual-cost="authStore.isAdmin"
|
:show-actual-cost="authStore.isAdmin"
|
||||||
:loading="isLoadingRecords"
|
:loading="isLoadingRecords"
|
||||||
:selected-period="selectedPeriod"
|
:time-range="timeRange"
|
||||||
:filter-search="filterSearch"
|
:filter-search="filterSearch"
|
||||||
:filter-user="filterUser"
|
:filter-user="filterUser"
|
||||||
:filter-model="filterModel"
|
:filter-model="filterModel"
|
||||||
:filter-provider="filterProvider"
|
:filter-provider="filterProvider"
|
||||||
|
:filter-api-format="filterApiFormat"
|
||||||
:filter-status="filterStatus"
|
:filter-status="filterStatus"
|
||||||
:available-users="availableUsers"
|
:available-users="availableUsers"
|
||||||
:available-models="availableModels"
|
:available-models="availableModels"
|
||||||
@@ -69,17 +70,17 @@
|
|||||||
:total-records="totalRecords"
|
:total-records="totalRecords"
|
||||||
:page-size-options="pageSizeOptions"
|
:page-size-options="pageSizeOptions"
|
||||||
:auto-refresh="globalAutoRefresh"
|
:auto-refresh="globalAutoRefresh"
|
||||||
@update:selected-period="handlePeriodChange"
|
@update:time-range="handleTimeRangeChange"
|
||||||
@update:filter-search="handleFilterSearchChange"
|
@update:filter-search="handleFilterSearchChange"
|
||||||
@update:filter-user="handleFilterUserChange"
|
@update:filter-user="handleFilterUserChange"
|
||||||
@update:filter-model="handleFilterModelChange"
|
@update:filter-model="handleFilterModelChange"
|
||||||
@update:filter-provider="handleFilterProviderChange"
|
@update:filter-provider="handleFilterProviderChange"
|
||||||
|
@update:filter-api-format="handleFilterApiFormatChange"
|
||||||
@update:filter-status="handleFilterStatusChange"
|
@update:filter-status="handleFilterStatusChange"
|
||||||
@update:current-page="handlePageChange"
|
@update:current-page="handlePageChange"
|
||||||
@update:page-size="handlePageSizeChange"
|
@update:page-size="handlePageSizeChange"
|
||||||
@update:auto-refresh="handleAutoRefreshChange"
|
@update:auto-refresh="handleAutoRefreshChange"
|
||||||
@refresh="refreshData"
|
@refresh="refreshData"
|
||||||
@export="exportData"
|
|
||||||
@show-detail="showRequestDetail"
|
@show-detail="showRequestDetail"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -113,7 +114,7 @@ import {
|
|||||||
useUsageData,
|
useUsageData,
|
||||||
getDateRangeFromPeriod
|
getDateRangeFromPeriod
|
||||||
} from '@/features/usage/composables'
|
} 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 type { UserOption } from '@/features/usage/components/UsageRecordsTable.vue'
|
||||||
import { log } from '@/utils/logger'
|
import { log } from '@/utils/logger'
|
||||||
import type { ActivityHeatmap } from '@/types/activity'
|
import type { ActivityHeatmap } from '@/types/activity'
|
||||||
@@ -126,8 +127,8 @@ const authStore = useAuthStore()
|
|||||||
// 判断是否是管理员页面
|
// 判断是否是管理员页面
|
||||||
const isAdminPage = computed(() => route.path.startsWith('/admin'))
|
const isAdminPage = computed(() => route.path.startsWith('/admin'))
|
||||||
|
|
||||||
// 时间段选择
|
// 时间范围选择
|
||||||
const selectedPeriod = ref<PeriodValue>('today')
|
const timeRange = ref<DateRangeParams>(getDateRangeFromPeriod('today'))
|
||||||
|
|
||||||
// 分页状态
|
// 分页状态
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
@@ -139,6 +140,7 @@ const filterSearch = ref('')
|
|||||||
const filterUser = ref('__all__')
|
const filterUser = ref('__all__')
|
||||||
const filterModel = ref('__all__')
|
const filterModel = ref('__all__')
|
||||||
const filterProvider = ref('__all__')
|
const filterProvider = ref('__all__')
|
||||||
|
const filterApiFormat = ref('__all__')
|
||||||
const filterStatus = ref<FilterStatusValue>('__all__')
|
const filterStatus = ref<FilterStatusValue>('__all__')
|
||||||
|
|
||||||
// 用户列表(仅管理员页面使用)
|
// 用户列表(仅管理员页面使用)
|
||||||
@@ -194,6 +196,12 @@ const filteredRecords = computed(() => {
|
|||||||
records = records.filter(record => record.provider === filterProvider.value)
|
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 !== '__all__') {
|
||||||
if (filterStatus.value === 'stream') {
|
if (filterStatus.value === 'stream') {
|
||||||
records = records.filter(record =>
|
records = records.filter(record =>
|
||||||
@@ -386,11 +394,9 @@ const selectedRequestId = ref<string | null>(null)
|
|||||||
|
|
||||||
// 初始化加载
|
// 初始化加载
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const dateRange = getDateRangeFromPeriod(selectedPeriod.value)
|
|
||||||
|
|
||||||
// 并行加载统计数据和热力图(使用 allSettled 避免其中一个失败影响另一个)
|
// 并行加载统计数据和热力图(使用 allSettled 避免其中一个失败影响另一个)
|
||||||
const [statsResult, heatmapResult] = await Promise.allSettled([
|
const [statsResult, heatmapResult] = await Promise.allSettled([
|
||||||
loadStats(dateRange),
|
loadStats(timeRange.value),
|
||||||
loadHeatmapData()
|
loadHeatmapData()
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -418,13 +424,11 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 处理时间段变化
|
// 处理时间范围变化
|
||||||
async function handlePeriodChange(value: string) {
|
async function handleTimeRangeChange(value: DateRangeParams) {
|
||||||
selectedPeriod.value = value as PeriodValue
|
timeRange.value = value
|
||||||
currentPage.value = 1 // 重置到第一页
|
currentPage.value = 1 // 重置到第一页
|
||||||
|
await loadStats(timeRange.value)
|
||||||
const dateRange = getDateRangeFromPeriod(selectedPeriod.value)
|
|
||||||
await loadStats(dateRange)
|
|
||||||
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
|
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,6 +452,7 @@ function getCurrentFilters() {
|
|||||||
user_id: filterUser.value !== '__all__' ? filterUser.value : undefined,
|
user_id: filterUser.value !== '__all__' ? filterUser.value : undefined,
|
||||||
model: filterModel.value !== '__all__' ? filterModel.value : undefined,
|
model: filterModel.value !== '__all__' ? filterModel.value : undefined,
|
||||||
provider: filterProvider.value !== '__all__' ? filterProvider.value : undefined,
|
provider: filterProvider.value !== '__all__' ? filterProvider.value : undefined,
|
||||||
|
api_format: filterApiFormat.value !== '__all__' ? filterApiFormat.value : undefined,
|
||||||
status: filterStatus.value !== '__all__' ? filterStatus.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) {
|
async function handleFilterStatusChange(value: string) {
|
||||||
filterStatus.value = value as FilterStatusValue
|
filterStatus.value = value as FilterStatusValue
|
||||||
currentPage.value = 1
|
currentPage.value = 1
|
||||||
@@ -498,8 +512,7 @@ async function handleFilterStatusChange(value: string) {
|
|||||||
|
|
||||||
// 刷新数据
|
// 刷新数据
|
||||||
async function refreshData() {
|
async function refreshData() {
|
||||||
const dateRange = getDateRangeFromPeriod(selectedPeriod.value)
|
await loadStats(timeRange.value)
|
||||||
await loadStats(dateRange)
|
|
||||||
await loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters())
|
await loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -510,20 +523,6 @@ function showRequestDetail(id: string) {
|
|||||||
detailModalOpen.value = true
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ dependencies = [
|
|||||||
"aiosqlite>=0.22.1",
|
"aiosqlite>=0.22.1",
|
||||||
"loguru>=0.7.3",
|
"loguru>=0.7.3",
|
||||||
"tiktoken>=0.12.0",
|
"tiktoken>=0.12.0",
|
||||||
"regex>=2026.1.15", # 支持超时的正则库,用于 ReDoS 防护
|
"regex>=2026.1.15", # 支持超时的正则库,用于 ReDoS 防护
|
||||||
"aiofiles>=25.1.0",
|
"aiofiles>=25.1.0",
|
||||||
"aiohttp>=3.13.3",
|
"aiohttp>=3.13.3",
|
||||||
"aiosmtplib>=5.1.0",
|
"aiosmtplib>=5.1.0",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from .provider_query import router as provider_query_router
|
|||||||
from .provider_strategy import router as provider_strategy_router
|
from .provider_strategy import router as provider_strategy_router
|
||||||
from .providers import router as providers_router
|
from .providers import router as providers_router
|
||||||
from .security import router as security_router
|
from .security import router as security_router
|
||||||
|
from .stats import router as stats_router
|
||||||
from .system import router as system_router
|
from .system import router as system_router
|
||||||
from .usage import router as usage_router
|
from .usage import router as usage_router
|
||||||
from .users import router as users_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(adaptive_router)
|
||||||
router.include_router(models_router)
|
router.include_router(models_router)
|
||||||
router.include_router(security_router)
|
router.include_router(security_router)
|
||||||
|
router.include_router(stats_router)
|
||||||
router.include_router(provider_query_router)
|
router.include_router(provider_query_router)
|
||||||
router.include_router(management_tokens_router)
|
router.include_router(management_tokens_router)
|
||||||
router.include_router(modules_router)
|
router.include_router(modules_router)
|
||||||
|
|||||||
22
src/api/admin/stats/__init__.py
Normal file
22
src/api/admin/stats/__init__.py
Normal 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"]
|
||||||
196
src/api/admin/stats/common.py
Normal file
196
src/api/admin/stats/common.py
Normal 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()
|
||||||
128
src/api/admin/stats/comparison.py
Normal file
128
src/api/admin/stats/comparison.py
Normal 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
183
src/api/admin/stats/cost.py
Normal 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)
|
||||||
111
src/api/admin/stats/errors.py
Normal file
111
src/api/admin/stats/errors.py
Normal 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)
|
||||||
759
src/api/admin/stats/leaderboard.py
Normal file
759
src/api/admin/stats/leaderboard.py
Normal 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)
|
||||||
85
src/api/admin/stats/performance.py
Normal file
85
src/api/admin/stats/performance.py
Normal 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)
|
||||||
83
src/api/admin/stats/quota.py
Normal file
83
src/api/admin/stats/quota.py
Normal 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)
|
||||||
5
src/api/admin/stats/routes.py
Normal file
5
src/api/admin/stats/routes.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Admin stats routes (compat export)."""
|
||||||
|
|
||||||
|
from . import router
|
||||||
|
|
||||||
|
__all__ = ["router"]
|
||||||
75
src/api/admin/stats/time_series.py
Normal file
75
src/api/admin/stats/time_series.py
Normal 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)
|
||||||
@@ -4,11 +4,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import date, datetime, timedelta, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
from sqlalchemy import func
|
from sqlalchemy import case, func
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from src.api.base.admin_adapter import AdminApiAdapter
|
from src.api.base.admin_adapter import AdminApiAdapter
|
||||||
@@ -26,6 +26,8 @@ from src.models.database import (
|
|||||||
Usage,
|
Usage,
|
||||||
User,
|
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.services.usage.service import UsageService
|
||||||
from src.utils.cache_decorator import cache_result
|
from src.utils.cache_decorator import cache_result
|
||||||
|
|
||||||
@@ -34,22 +36,45 @@ pipeline = ApiRequestPipeline()
|
|||||||
|
|
||||||
|
|
||||||
def _apply_admin_default_range(
|
def _apply_admin_default_range(
|
||||||
start_date: datetime | None, end_date: datetime | None
|
params: TimeRangeParams | None,
|
||||||
) -> tuple[datetime | None, datetime | None]:
|
) -> TimeRangeParams | None:
|
||||||
"""
|
"""Apply a default range to avoid unbounded scans."""
|
||||||
Apply a default time range for admin usage endpoints to protect DB from unbounded scans.
|
if params is not None:
|
||||||
|
return params
|
||||||
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
|
|
||||||
|
|
||||||
days = int(getattr(config, "admin_usage_default_days", 0) or 0)
|
days = int(getattr(config, "admin_usage_default_days", 0) or 0)
|
||||||
if days <= 0:
|
if days <= 0:
|
||||||
return start_date, end_date
|
return None
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
today = datetime.now(timezone.utc).date()
|
||||||
return now - timedelta(days=days), now
|
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 ====================
|
# ==================== RESTful Routes ====================
|
||||||
@@ -61,8 +86,11 @@ async def get_usage_aggregation(
|
|||||||
group_by: str = Query(
|
group_by: str = Query(
|
||||||
..., description="Aggregation dimension: model, user, provider, or api_format"
|
..., description="Aggregation dimension: model, user, provider, or api_format"
|
||||||
),
|
),
|
||||||
start_date: datetime | None = None,
|
start_date: date | None = None,
|
||||||
end_date: datetime | 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),
|
limit: int = Query(20, ge=1, le=100),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> Any:
|
) -> 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
|
- 按提供商聚合时: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
|
- 按 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":
|
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":
|
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":
|
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":
|
elif group_by == "api_format":
|
||||||
adapter = AdminUsageByApiFormatAdapter(
|
adapter = AdminUsageByApiFormatAdapter(time_range=time_range, limit=limit)
|
||||||
start_date=start_date, end_date=end_date, limit=limit
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
@@ -104,8 +134,11 @@ async def get_usage_aggregation(
|
|||||||
@router.get("/stats")
|
@router.get("/stats")
|
||||||
async def get_usage_stats(
|
async def get_usage_stats(
|
||||||
request: Request,
|
request: Request,
|
||||||
start_date: datetime | None = None,
|
start_date: date | None = None,
|
||||||
end_date: datetime | 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),
|
db: Session = Depends(get_db),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
@@ -127,7 +160,10 @@ async def get_usage_stats(
|
|||||||
- `error_rate`: 错误率(百分比)
|
- `error_rate`: 错误率(百分比)
|
||||||
- `cache_stats`: 缓存统计信息(cache_creation_tokens, cache_read_tokens, cache_creation_cost, cache_read_cost)
|
- `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)
|
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")
|
@router.get("/records")
|
||||||
async def get_usage_records(
|
async def get_usage_records(
|
||||||
request: Request,
|
request: Request,
|
||||||
start_date: datetime | None = None,
|
start_date: date | None = None,
|
||||||
end_date: datetime | 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, # 通用搜索:用户名、密钥名、模型名、提供商名
|
search: str | None = None, # 通用搜索:用户名、密钥名、模型名、提供商名
|
||||||
user_id: str | None = None,
|
user_id: str | None = None,
|
||||||
username: str | None = None,
|
username: str | None = None,
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
provider: str | None = None,
|
provider: str | None = None,
|
||||||
|
api_format: str | None = None, # API 格式筛选(如 openai:chat, claude:chat)
|
||||||
status: str | None = None, # stream, standard, error
|
status: str | None = None, # stream, standard, error
|
||||||
limit: int = Query(100, ge=1, le=500),
|
limit: int = Query(100, ge=1, le=500),
|
||||||
offset: int = Query(0, ge=0),
|
offset: int = Query(0, ge=0),
|
||||||
@@ -176,6 +216,7 @@ async def get_usage_records(
|
|||||||
- `username`: 可选,用户名模糊搜索
|
- `username`: 可选,用户名模糊搜索
|
||||||
- `model`: 可选,模型名模糊搜索
|
- `model`: 可选,模型名模糊搜索
|
||||||
- `provider`: 可选,提供商名称搜索
|
- `provider`: 可选,提供商名称搜索
|
||||||
|
- `api_format`: 可选,API 格式筛选(如 openai:chat, claude:chat)
|
||||||
- `status`: 可选,状态筛选(stream: 流式请求,standard: 标准请求,error: 错误请求,pending: 等待中,streaming: 流式中,completed: 已完成,failed: 失败,active: 活跃请求)
|
- `status`: 可选,状态筛选(stream: 流式请求,standard: 标准请求,error: 错误请求,pending: 等待中,streaming: 流式中,completed: 已完成,failed: 失败,active: 活跃请求)
|
||||||
- `limit`: 返回数量限制,默认 100,最大 500
|
- `limit`: 返回数量限制,默认 100,最大 500
|
||||||
- `offset`: 分页偏移量,默认 0
|
- `offset`: 分页偏移量,默认 0
|
||||||
@@ -190,14 +231,17 @@ async def get_usage_records(
|
|||||||
- `limit`: 当前分页限制
|
- `limit`: 当前分页限制
|
||||||
- `offset`: 当前分页偏移量
|
- `offset`: 当前分页偏移量
|
||||||
"""
|
"""
|
||||||
|
time_range = _apply_admin_default_range(
|
||||||
|
_build_time_range_params(start_date, end_date, preset, timezone_name, tz_offset_minutes)
|
||||||
|
)
|
||||||
adapter = AdminUsageRecordsAdapter(
|
adapter = AdminUsageRecordsAdapter(
|
||||||
start_date=start_date,
|
time_range=time_range,
|
||||||
end_date=end_date,
|
|
||||||
search=search,
|
search=search,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
username=username,
|
username=username,
|
||||||
model=model,
|
model=model,
|
||||||
provider=provider,
|
provider=provider,
|
||||||
|
api_format=api_format,
|
||||||
status=status,
|
status=status,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
offset=offset,
|
offset=offset,
|
||||||
@@ -288,73 +332,87 @@ async def get_usage_detail(
|
|||||||
|
|
||||||
|
|
||||||
class AdminUsageStatsAdapter(AdminApiAdapter):
|
class AdminUsageStatsAdapter(AdminApiAdapter):
|
||||||
def __init__(self, start_date: datetime | None, end_date: datetime | None):
|
def __init__(self, time_range: TimeRangeParams | None):
|
||||||
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
|
||||||
|
|
||||||
@cache_result(
|
@cache_result(
|
||||||
key_prefix="admin:usage:stats",
|
key_prefix="admin:usage:stats",
|
||||||
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
|
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
|
||||||
user_specific=False,
|
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]
|
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
|
db = context.db
|
||||||
query = db.query(Usage)
|
if self.time_range:
|
||||||
if self.start_date:
|
stats = query_stats_hybrid(db, self.time_range, filters=StatsFilter())
|
||||||
query = query.filter(Usage.created_at >= self.start_date)
|
else:
|
||||||
if self.end_date:
|
error_cond = (Usage.status_code >= 400) | (Usage.error_message.isnot(None))
|
||||||
query = query.filter(Usage.created_at <= self.end_date)
|
row = db.query(
|
||||||
|
func.count(Usage.id).label("total_requests"),
|
||||||
stats = query.with_entities(
|
func.sum(case((error_cond, 1), else_=0)).label("error_requests"),
|
||||||
func.count(Usage.id).label("total_requests"),
|
func.sum(Usage.input_tokens).label("input_tokens"),
|
||||||
func.sum(Usage.total_tokens).label("total_tokens"),
|
func.sum(Usage.output_tokens).label("output_tokens"),
|
||||||
func.sum(Usage.total_cost_usd).label("total_cost"),
|
func.sum(Usage.cache_creation_input_tokens).label("cache_creation_tokens"),
|
||||||
func.sum(Usage.actual_total_cost_usd).label("total_actual_cost"),
|
func.sum(Usage.cache_read_input_tokens).label("cache_read_tokens"),
|
||||||
func.avg(Usage.response_time_ms).label("avg_response_time_ms"),
|
func.sum(Usage.cache_creation_cost_usd).label("cache_creation_cost"),
|
||||||
func.sum(Usage.cache_creation_input_tokens).label("cache_creation_tokens"),
|
func.sum(Usage.cache_read_cost_usd).label("cache_read_cost"),
|
||||||
func.sum(Usage.cache_read_input_tokens).label("cache_read_tokens"),
|
func.sum(Usage.total_cost_usd).label("total_cost"),
|
||||||
func.sum(Usage.cache_creation_cost_usd).label("cache_creation_cost"),
|
func.sum(Usage.actual_total_cost_usd).label("actual_total_cost"),
|
||||||
func.sum(Usage.cache_read_cost_usd).label("cache_read_cost"),
|
func.sum(Usage.response_time_ms).label("total_response_time_ms"),
|
||||||
func.sum(
|
).first()
|
||||||
case(
|
total_requests = int(getattr(row, "total_requests", 0) or 0)
|
||||||
(
|
error_requests = int(getattr(row, "error_requests", 0) or 0)
|
||||||
(Usage.status_code >= 400) | (Usage.error_message.isnot(None)),
|
stats = AggregatedStats(
|
||||||
1,
|
total_requests=total_requests,
|
||||||
),
|
success_requests=total_requests - error_requests,
|
||||||
else_=0,
|
error_requests=error_requests,
|
||||||
)
|
input_tokens=int(getattr(row, "input_tokens", 0) or 0),
|
||||||
).label("error_count"),
|
output_tokens=int(getattr(row, "output_tokens", 0) or 0),
|
||||||
).first()
|
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(
|
context.add_audit_metadata(
|
||||||
action="usage_stats",
|
action="usage_stats",
|
||||||
start_date=self.start_date.isoformat() if self.start_date else None,
|
start_date=self.start_date.isoformat() if self.start_date else None,
|
||||||
end_date=self.end_date.isoformat() if self.end_date else None,
|
end_date=self.end_date.isoformat() if self.end_date else None,
|
||||||
|
preset=self.preset,
|
||||||
|
timezone=self.timezone,
|
||||||
)
|
)
|
||||||
|
total_requests = stats.total_requests
|
||||||
total_requests = int(stats.total_requests or 0) if stats else 0
|
avg_response_time = stats.avg_response_time_ms / 1000.0
|
||||||
avg_response_time_ms = float(stats.avg_response_time_ms or 0) if stats else 0
|
error_count = stats.error_requests
|
||||||
avg_response_time = avg_response_time_ms / 1000.0
|
|
||||||
error_count = int(stats.error_count or 0) if stats else 0
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total_requests": total_requests,
|
"total_requests": total_requests,
|
||||||
"total_tokens": int(stats.total_tokens or 0) if stats else 0,
|
"total_tokens": int(
|
||||||
"total_cost": float(stats.total_cost or 0) if stats else 0,
|
stats.input_tokens
|
||||||
"total_actual_cost": float(stats.total_actual_cost or 0) if stats else 0,
|
+ 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),
|
"avg_response_time": round(avg_response_time, 2),
|
||||||
"error_count": error_count,
|
"error_count": error_count,
|
||||||
"error_rate": (
|
"error_rate": (
|
||||||
round((error_count / total_requests) * 100, 2) if total_requests > 0 else 0
|
round((error_count / total_requests) * 100, 2) if total_requests > 0 else 0
|
||||||
),
|
),
|
||||||
"cache_stats": {
|
"cache_stats": {
|
||||||
"cache_creation_tokens": (int(stats.cache_creation_tokens or 0) if stats else 0),
|
"cache_creation_tokens": int(stats.cache_creation_tokens),
|
||||||
"cache_read_tokens": int(stats.cache_read_tokens or 0) if stats else 0,
|
"cache_read_tokens": int(stats.cache_read_tokens),
|
||||||
"cache_creation_cost": (float(stats.cache_creation_cost or 0) if stats else 0),
|
"cache_creation_cost": float(stats.cache_creation_cost),
|
||||||
"cache_read_cost": float(stats.cache_read_cost or 0) if stats else 0,
|
"cache_read_cost": float(stats.cache_read_cost),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,15 +431,20 @@ class AdminActivityHeatmapAdapter(AdminApiAdapter):
|
|||||||
|
|
||||||
|
|
||||||
class AdminUsageByModelAdapter(AdminApiAdapter):
|
class AdminUsageByModelAdapter(AdminApiAdapter):
|
||||||
def __init__(self, start_date: datetime | None, end_date: datetime | None, limit: int):
|
def __init__(self, time_range: TimeRangeParams | None, limit: 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.limit = limit
|
self.limit = limit
|
||||||
|
|
||||||
@cache_result(
|
@cache_result(
|
||||||
key_prefix="admin:usage:agg:model",
|
key_prefix="admin:usage:agg:model",
|
||||||
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
|
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
|
||||||
user_specific=False,
|
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]
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
db = context.db
|
db = context.db
|
||||||
@@ -397,10 +460,9 @@ class AdminUsageByModelAdapter(AdminApiAdapter):
|
|||||||
# 过滤掉 unknown/pending provider_name(请求未到达任何提供商)
|
# 过滤掉 unknown/pending provider_name(请求未到达任何提供商)
|
||||||
query = query.filter(Usage.provider_name.notin_(["unknown", "pending"]))
|
query = query.filter(Usage.provider_name.notin_(["unknown", "pending"]))
|
||||||
|
|
||||||
if self.start_date:
|
if self.time_range:
|
||||||
query = query.filter(Usage.created_at >= self.start_date)
|
start_utc, end_utc = self.time_range.to_utc_datetime_range()
|
||||||
if self.end_date:
|
query = query.filter(Usage.created_at >= start_utc, Usage.created_at < end_utc)
|
||||||
query = query.filter(Usage.created_at <= self.end_date)
|
|
||||||
|
|
||||||
query = query.group_by(Usage.model).order_by(func.count(Usage.id).desc()).limit(self.limit)
|
query = query.group_by(Usage.model).order_by(func.count(Usage.id).desc()).limit(self.limit)
|
||||||
stats = query.all()
|
stats = query.all()
|
||||||
@@ -408,6 +470,8 @@ class AdminUsageByModelAdapter(AdminApiAdapter):
|
|||||||
action="usage_by_model",
|
action="usage_by_model",
|
||||||
start_date=self.start_date.isoformat() if self.start_date else None,
|
start_date=self.start_date.isoformat() if self.start_date else None,
|
||||||
end_date=self.end_date.isoformat() if self.end_date else None,
|
end_date=self.end_date.isoformat() if self.end_date else None,
|
||||||
|
preset=self.preset,
|
||||||
|
timezone=self.timezone,
|
||||||
limit=self.limit,
|
limit=self.limit,
|
||||||
result_count=len(stats),
|
result_count=len(stats),
|
||||||
)
|
)
|
||||||
@@ -425,15 +489,20 @@ class AdminUsageByModelAdapter(AdminApiAdapter):
|
|||||||
|
|
||||||
|
|
||||||
class AdminUsageByUserAdapter(AdminApiAdapter):
|
class AdminUsageByUserAdapter(AdminApiAdapter):
|
||||||
def __init__(self, start_date: datetime | None, end_date: datetime | None, limit: int):
|
def __init__(self, time_range: TimeRangeParams | None, limit: 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.limit = limit
|
self.limit = limit
|
||||||
|
|
||||||
@cache_result(
|
@cache_result(
|
||||||
key_prefix="admin:usage:agg:user",
|
key_prefix="admin:usage:agg:user",
|
||||||
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
|
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
|
||||||
user_specific=False,
|
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]
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
db = context.db
|
db = context.db
|
||||||
@@ -450,10 +519,9 @@ class AdminUsageByUserAdapter(AdminApiAdapter):
|
|||||||
.group_by(User.id, User.email, User.username)
|
.group_by(User.id, User.email, User.username)
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.start_date:
|
if self.time_range:
|
||||||
query = query.filter(Usage.created_at >= self.start_date)
|
start_utc, end_utc = self.time_range.to_utc_datetime_range()
|
||||||
if self.end_date:
|
query = query.filter(Usage.created_at >= start_utc, Usage.created_at < end_utc)
|
||||||
query = query.filter(Usage.created_at <= self.end_date)
|
|
||||||
|
|
||||||
query = query.order_by(func.count(Usage.id).desc()).limit(self.limit)
|
query = query.order_by(func.count(Usage.id).desc()).limit(self.limit)
|
||||||
stats = query.all()
|
stats = query.all()
|
||||||
@@ -462,6 +530,8 @@ class AdminUsageByUserAdapter(AdminApiAdapter):
|
|||||||
action="usage_by_user",
|
action="usage_by_user",
|
||||||
start_date=self.start_date.isoformat() if self.start_date else None,
|
start_date=self.start_date.isoformat() if self.start_date else None,
|
||||||
end_date=self.end_date.isoformat() if self.end_date else None,
|
end_date=self.end_date.isoformat() if self.end_date else None,
|
||||||
|
preset=self.preset,
|
||||||
|
timezone=self.timezone,
|
||||||
limit=self.limit,
|
limit=self.limit,
|
||||||
result_count=len(stats),
|
result_count=len(stats),
|
||||||
)
|
)
|
||||||
@@ -480,15 +550,20 @@ class AdminUsageByUserAdapter(AdminApiAdapter):
|
|||||||
|
|
||||||
|
|
||||||
class AdminUsageByProviderAdapter(AdminApiAdapter):
|
class AdminUsageByProviderAdapter(AdminApiAdapter):
|
||||||
def __init__(self, start_date: datetime | None, end_date: datetime | None, limit: int):
|
def __init__(self, time_range: TimeRangeParams | None, limit: 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.limit = limit
|
self.limit = limit
|
||||||
|
|
||||||
@cache_result(
|
@cache_result(
|
||||||
key_prefix="admin:usage:agg:provider",
|
key_prefix="admin:usage:agg:provider",
|
||||||
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
|
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
|
||||||
user_specific=False,
|
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]
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
db = context.db
|
db = context.db
|
||||||
@@ -511,10 +586,12 @@ class AdminUsageByProviderAdapter(AdminApiAdapter):
|
|||||||
RequestCandidate.status.in_(["success", "failed"]),
|
RequestCandidate.status.in_(["success", "failed"]),
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.start_date:
|
if self.time_range:
|
||||||
attempt_query = attempt_query.filter(RequestCandidate.created_at >= self.start_date)
|
start_utc, end_utc = self.time_range.to_utc_datetime_range()
|
||||||
if self.end_date:
|
attempt_query = attempt_query.filter(
|
||||||
attempt_query = attempt_query.filter(RequestCandidate.created_at <= self.end_date)
|
RequestCandidate.created_at >= start_utc,
|
||||||
|
RequestCandidate.created_at < end_utc,
|
||||||
|
)
|
||||||
|
|
||||||
attempt_stats = (
|
attempt_stats = (
|
||||||
attempt_query.group_by(RequestCandidate.provider_id)
|
attempt_query.group_by(RequestCandidate.provider_id)
|
||||||
@@ -537,10 +614,11 @@ class AdminUsageByProviderAdapter(AdminApiAdapter):
|
|||||||
Usage.status.notin_(["pending", "streaming"]),
|
Usage.status.notin_(["pending", "streaming"]),
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.start_date:
|
if self.time_range:
|
||||||
usage_query = usage_query.filter(Usage.created_at >= self.start_date)
|
start_utc, end_utc = self.time_range.to_utc_datetime_range()
|
||||||
if self.end_date:
|
usage_query = usage_query.filter(
|
||||||
usage_query = usage_query.filter(Usage.created_at <= self.end_date)
|
Usage.created_at >= start_utc, Usage.created_at < end_utc
|
||||||
|
)
|
||||||
|
|
||||||
usage_stats = usage_query.group_by(Usage.provider_id).all()
|
usage_stats = usage_query.group_by(Usage.provider_id).all()
|
||||||
usage_map = {str(u.provider_id): u for u in usage_stats}
|
usage_map = {str(u.provider_id): u for u in usage_stats}
|
||||||
@@ -566,6 +644,8 @@ class AdminUsageByProviderAdapter(AdminApiAdapter):
|
|||||||
action="usage_by_provider",
|
action="usage_by_provider",
|
||||||
start_date=self.start_date.isoformat() if self.start_date else None,
|
start_date=self.start_date.isoformat() if self.start_date else None,
|
||||||
end_date=self.end_date.isoformat() if self.end_date else None,
|
end_date=self.end_date.isoformat() if self.end_date else None,
|
||||||
|
preset=self.preset,
|
||||||
|
timezone=self.timezone,
|
||||||
limit=self.limit,
|
limit=self.limit,
|
||||||
result_count=len(attempt_stats),
|
result_count=len(attempt_stats),
|
||||||
)
|
)
|
||||||
@@ -599,15 +679,20 @@ class AdminUsageByProviderAdapter(AdminApiAdapter):
|
|||||||
|
|
||||||
|
|
||||||
class AdminUsageByApiFormatAdapter(AdminApiAdapter):
|
class AdminUsageByApiFormatAdapter(AdminApiAdapter):
|
||||||
def __init__(self, start_date: datetime | None, end_date: datetime | None, limit: int):
|
def __init__(self, time_range: TimeRangeParams | None, limit: 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.limit = limit
|
self.limit = limit
|
||||||
|
|
||||||
@cache_result(
|
@cache_result(
|
||||||
key_prefix="admin:usage:agg:api_format",
|
key_prefix="admin:usage:agg:api_format",
|
||||||
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
|
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
|
||||||
user_specific=False,
|
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]
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
db = context.db
|
db = context.db
|
||||||
@@ -626,10 +711,9 @@ class AdminUsageByApiFormatAdapter(AdminApiAdapter):
|
|||||||
# 只统计有 api_format 的记录
|
# 只统计有 api_format 的记录
|
||||||
query = query.filter(Usage.api_format.isnot(None))
|
query = query.filter(Usage.api_format.isnot(None))
|
||||||
|
|
||||||
if self.start_date:
|
if self.time_range:
|
||||||
query = query.filter(Usage.created_at >= self.start_date)
|
start_utc, end_utc = self.time_range.to_utc_datetime_range()
|
||||||
if self.end_date:
|
query = query.filter(Usage.created_at >= start_utc, Usage.created_at < end_utc)
|
||||||
query = query.filter(Usage.created_at <= self.end_date)
|
|
||||||
|
|
||||||
query = (
|
query = (
|
||||||
query.group_by(Usage.api_format).order_by(func.count(Usage.id).desc()).limit(self.limit)
|
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",
|
action="usage_by_api_format",
|
||||||
start_date=self.start_date.isoformat() if self.start_date else None,
|
start_date=self.start_date.isoformat() if self.start_date else None,
|
||||||
end_date=self.end_date.isoformat() if self.end_date else None,
|
end_date=self.end_date.isoformat() if self.end_date else None,
|
||||||
|
preset=self.preset,
|
||||||
|
timezone=self.timezone,
|
||||||
limit=self.limit,
|
limit=self.limit,
|
||||||
result_count=len(stats),
|
result_count=len(stats),
|
||||||
)
|
)
|
||||||
@@ -660,23 +746,29 @@ class AdminUsageByApiFormatAdapter(AdminApiAdapter):
|
|||||||
class AdminUsageRecordsAdapter(AdminApiAdapter):
|
class AdminUsageRecordsAdapter(AdminApiAdapter):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
start_date: datetime | None,
|
time_range: TimeRangeParams | None,
|
||||||
end_date: datetime | None,
|
|
||||||
search: str | None,
|
search: str | None,
|
||||||
user_id: str | None,
|
user_id: str | None,
|
||||||
username: str | None,
|
username: str | None,
|
||||||
model: str | None,
|
model: str | None,
|
||||||
provider: str | None,
|
provider: str | None,
|
||||||
|
api_format: str | None,
|
||||||
status: str | None,
|
status: str | None,
|
||||||
limit: int,
|
limit: int,
|
||||||
offset: 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.search = search
|
||||||
self.user_id = user_id
|
self.user_id = user_id
|
||||||
self.username = username
|
self.username = username
|
||||||
self.model = model
|
self.model = model
|
||||||
self.provider = provider
|
self.provider = provider
|
||||||
|
self.api_format = api_format
|
||||||
self.status = status
|
self.status = status
|
||||||
self.limit = limit
|
self.limit = limit
|
||||||
self.offset = offset
|
self.offset = offset
|
||||||
@@ -688,11 +780,15 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
|
|||||||
vary_by=[
|
vary_by=[
|
||||||
"start_date",
|
"start_date",
|
||||||
"end_date",
|
"end_date",
|
||||||
|
"preset",
|
||||||
|
"timezone",
|
||||||
|
"tz_offset_minutes",
|
||||||
"search",
|
"search",
|
||||||
"user_id",
|
"user_id",
|
||||||
"username",
|
"username",
|
||||||
"model",
|
"model",
|
||||||
"provider",
|
"provider",
|
||||||
|
"api_format",
|
||||||
"status",
|
"status",
|
||||||
"limit",
|
"limit",
|
||||||
"offset",
|
"offset",
|
||||||
@@ -748,6 +844,9 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
|
|||||||
# 提供商筛选:前端为下拉框精确值,使用精确匹配以启用索引
|
# 提供商筛选:前端为下拉框精确值,使用精确匹配以启用索引
|
||||||
# 如需模糊搜索,请使用 search 参数。
|
# 如需模糊搜索,请使用 search 参数。
|
||||||
query = query.filter(Provider.name == self.provider)
|
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:
|
if self.status:
|
||||||
# 状态筛选
|
# 状态筛选
|
||||||
# 旧的筛选值(基于 is_stream 和 status_code):stream, standard, error
|
# 旧的筛选值(基于 is_stream 和 status_code):stream, standard, error
|
||||||
@@ -773,10 +872,9 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
|
|||||||
elif self.status == "active":
|
elif self.status == "active":
|
||||||
# 活跃请求:pending 或 streaming 状态
|
# 活跃请求:pending 或 streaming 状态
|
||||||
query = query.filter(Usage.status.in_(["pending", "streaming"]))
|
query = query.filter(Usage.status.in_(["pending", "streaming"]))
|
||||||
if self.start_date:
|
if self.time_range:
|
||||||
query = query.filter(Usage.created_at >= self.start_date)
|
start_utc, end_utc = self.time_range.to_utc_datetime_range()
|
||||||
if self.end_date:
|
query = query.filter(Usage.created_at >= start_utc, Usage.created_at < end_utc)
|
||||||
query = query.filter(Usage.created_at <= self.end_date)
|
|
||||||
|
|
||||||
# Perf: avoid Query.count() building a subquery selecting many columns
|
# Perf: avoid Query.count() building a subquery selecting many columns
|
||||||
total = int(query.with_entities(func.count(Usage.id)).scalar() or 0)
|
total = int(query.with_entities(func.count(Usage.id)).scalar() or 0)
|
||||||
@@ -875,6 +973,8 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
|
|||||||
action="usage_records",
|
action="usage_records",
|
||||||
start_date=self.start_date.isoformat() if self.start_date else None,
|
start_date=self.start_date.isoformat() if self.start_date else None,
|
||||||
end_date=self.end_date.isoformat() if self.end_date else None,
|
end_date=self.end_date.isoformat() if self.end_date else None,
|
||||||
|
preset=self.preset,
|
||||||
|
timezone=self.timezone,
|
||||||
search=self.search,
|
search=self.search,
|
||||||
user_id=self.user_id,
|
user_id=self.user_id,
|
||||||
username=self.username,
|
username=self.username,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import date, datetime, timedelta, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
@@ -27,7 +27,12 @@ from src.models.database import (
|
|||||||
Usage,
|
Usage,
|
||||||
)
|
)
|
||||||
from src.models.database import User as DBUser
|
from src.models.database import User as DBUser
|
||||||
from src.services.system.stats_aggregator import StatsAggregatorService
|
from src.services.system.stats_aggregator import (
|
||||||
|
StatsAggregatorService,
|
||||||
|
TimeSeriesFilter,
|
||||||
|
query_time_series,
|
||||||
|
)
|
||||||
|
from src.services.system.time_range import TimeRangeParams
|
||||||
from src.utils.cache_decorator import cache_result
|
from src.utils.cache_decorator import cache_result
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/dashboard", tags=["Dashboard"])
|
router = APIRouter(prefix="/api/dashboard", tags=["Dashboard"])
|
||||||
@@ -55,6 +60,29 @@ def format_tokens(num: int) -> str:
|
|||||||
return f"{millions:.2f}M"
|
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")
|
@router.get("/stats")
|
||||||
async def get_dashboard_stats(request: Request, db: Session = Depends(get_db)) -> Any:
|
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(
|
async def get_daily_stats(
|
||||||
request: Request,
|
request: Request,
|
||||||
days: int = Query(7, ge=1, le=30),
|
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),
|
db: Session = Depends(get_db),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
@@ -160,7 +194,30 @@ async def get_daily_stats(
|
|||||||
- `model_summary`: 模型使用汇总,按费用排序
|
- `model_summary`: 模型使用汇总,按费用排序
|
||||||
- `period`: 统计周期信息(start_date, end_date, days)
|
- `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)
|
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]
|
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
|
db = context.db
|
||||||
# 使用业务时区计算日期,与 stats_daily 表保持一致
|
# 使用 UTC 日期,与 stats_daily.date 一致
|
||||||
app_tz = ZoneInfo(APP_TIMEZONE)
|
now_utc = datetime.now(timezone.utc)
|
||||||
now_local = datetime.now(app_tz)
|
today = now_utc.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
today_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
|
yesterday = today - timedelta(days=1)
|
||||||
# 转换为 UTC 用于与 stats_daily.date 比较(存储的是业务日期对应的 UTC 开始时间)
|
month_start = today.replace(day=1)
|
||||||
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)
|
|
||||||
|
|
||||||
# ==================== 使用预聚合数据 ====================
|
# ==================== 使用预聚合数据 ====================
|
||||||
# 今日实时数据只查询一次,避免重复扫描 Usage 表
|
# 今日实时数据只查询一次,避免重复扫描 Usage 表
|
||||||
@@ -887,20 +935,169 @@ class DashboardProviderStatusAdapter(DashboardAdapter):
|
|||||||
@dataclass
|
@dataclass
|
||||||
class DashboardDailyStatsAdapter(DashboardAdapter):
|
class DashboardDailyStatsAdapter(DashboardAdapter):
|
||||||
days: int
|
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(
|
@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]
|
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
|
db = context.db
|
||||||
user = context.user
|
user = context.user
|
||||||
is_admin = user.role == UserRole.ADMIN
|
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)
|
app_tz = ZoneInfo(APP_TIMEZONE)
|
||||||
now_local = datetime.now(app_tz)
|
now_local = datetime.now(app_tz)
|
||||||
today_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
|
today_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import date, datetime, timedelta, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
@@ -33,6 +33,7 @@ from src.models.api import (
|
|||||||
UpdateProfileRequest,
|
UpdateProfileRequest,
|
||||||
)
|
)
|
||||||
from src.models.database import ApiKey, GlobalModel, Model, Provider, Usage, User
|
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.usage.service import UsageService
|
||||||
from src.services.user.apikey import ApiKeyService
|
from src.services.user.apikey import ApiKeyService
|
||||||
from src.services.user.preference import PreferenceService
|
from src.services.user.preference import PreferenceService
|
||||||
@@ -41,6 +42,27 @@ router = APIRouter(prefix="/api/users/me", tags=["User Profile"])
|
|||||||
pipeline = ApiRequestPipeline()
|
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("")
|
@router.get("")
|
||||||
async def get_my_profile(request: Request, db: Session = Depends(get_db)) -> Any:
|
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")
|
@router.get("/usage")
|
||||||
async def get_my_usage(
|
async def get_my_usage(
|
||||||
request: Request,
|
request: Request,
|
||||||
start_date: datetime | None = Query(None, description="开始时间(ISO 格式)"),
|
start_date: date | None = Query(None, description="开始日期(YYYY-MM-DD)"),
|
||||||
end_date: datetime | None = Query(None, description="结束时间(ISO 格式)"),
|
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="搜索关键词(密钥名、模型名)"),
|
search: str | None = Query(None, description="搜索关键词(密钥名、模型名)"),
|
||||||
limit: int = Query(100, ge=1, le=200, description="每页记录数,默认100,最大200"),
|
limit: int = Query(100, ge=1, le=200, description="每页记录数,默认100,最大200"),
|
||||||
offset: int = Query(0, ge=0, le=2000, description="偏移量,用于分页,最大2000"),
|
offset: int = Query(0, ge=0, le=2000, description="偏移量,用于分页,最大2000"),
|
||||||
@@ -197,9 +222,10 @@ async def get_my_usage(
|
|||||||
- `records`: 详细使用记录列表
|
- `records`: 详细使用记录列表
|
||||||
- `pagination`: 分页信息
|
- `pagination`: 分页信息
|
||||||
"""
|
"""
|
||||||
adapter = GetUsageAdapter(
|
time_range = _build_time_range_params(
|
||||||
start_date=start_date, end_date=end_date, search=search, limit=limit, offset=offset
|
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)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
@@ -728,8 +754,7 @@ class ToggleMyApiKeyAdapter(AuthenticatedApiAdapter):
|
|||||||
class GetUsageAdapter(AuthenticatedApiAdapter):
|
class GetUsageAdapter(AuthenticatedApiAdapter):
|
||||||
"""获取用户使用统计的适配器"""
|
"""获取用户使用统计的适配器"""
|
||||||
|
|
||||||
start_date: datetime | None
|
time_range: TimeRangeParams | None
|
||||||
end_date: datetime | None
|
|
||||||
search: str | None = None
|
search: str | None = None
|
||||||
limit: int = 100
|
limit: int = 100
|
||||||
offset: int = 0
|
offset: int = 0
|
||||||
@@ -743,11 +768,14 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
|
|||||||
|
|
||||||
db = context.db
|
db = context.db
|
||||||
user = context.user
|
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(
|
summary_list = UsageService.get_usage_summary(
|
||||||
db=db,
|
db=db,
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
start_date=self.start_date,
|
start_date=start_utc,
|
||||||
end_date=self.end_date,
|
end_date=end_utc,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 过滤掉 unknown/pending provider 的记录(请求未到达任何提供商)
|
# 过滤掉 unknown/pending provider 的记录(请求未到达任何提供商)
|
||||||
@@ -858,10 +886,8 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
|
|||||||
.outerjoin(ProviderEndpoint, Usage.provider_endpoint_id == ProviderEndpoint.id)
|
.outerjoin(ProviderEndpoint, Usage.provider_endpoint_id == ProviderEndpoint.id)
|
||||||
.filter(Usage.user_id == user.id)
|
.filter(Usage.user_id == user.id)
|
||||||
)
|
)
|
||||||
if self.start_date:
|
if start_utc and end_utc:
|
||||||
query = query.filter(Usage.created_at >= self.start_date)
|
query = query.filter(Usage.created_at >= start_utc, Usage.created_at < end_utc)
|
||||||
if self.end_date:
|
|
||||||
query = query.filter(Usage.created_at <= self.end_date)
|
|
||||||
|
|
||||||
# 通用搜索:密钥名、模型名
|
# 通用搜索:密钥名、模型名
|
||||||
# 支持空格分隔的组合搜索,多个关键词之间是 AND 关系
|
# 支持空格分隔的组合搜索,多个关键词之间是 AND 关系
|
||||||
@@ -925,10 +951,10 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
|
|||||||
Usage.status_code == 200,
|
Usage.status_code == 200,
|
||||||
Usage.response_time_ms.isnot(None),
|
Usage.response_time_ms.isnot(None),
|
||||||
)
|
)
|
||||||
if self.start_date:
|
if start_utc and end_utc:
|
||||||
avg_resp_query = avg_resp_query.filter(Usage.created_at >= self.start_date)
|
avg_resp_query = avg_resp_query.filter(
|
||||||
if self.end_date:
|
Usage.created_at >= start_utc, Usage.created_at < end_utc
|
||||||
avg_resp_query = avg_resp_query.filter(Usage.created_at <= self.end_date)
|
)
|
||||||
avg_response_ms = avg_resp_query.scalar() or 0
|
avg_response_ms = avg_resp_query.scalar() or 0
|
||||||
avg_response_time = float(avg_response_ms) / 1000.0 if avg_response_ms else 0
|
avg_response_time = float(avg_response_ms) / 1000.0 if avg_response_ms else 0
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ class CacheTTL:
|
|||||||
ADMIN_USAGE_AGGREGATION = 30 # 30秒
|
ADMIN_USAGE_AGGREGATION = 30 # 30秒
|
||||||
ADMIN_USAGE_RECORDS = 10 # 10秒(列表页短缓存,避免轮询/重复刷新打爆 DB)
|
ADMIN_USAGE_RECORDS = 10 # 10秒(列表页短缓存,避免轮询/重复刷新打爆 DB)
|
||||||
|
|
||||||
|
# Admin leaderboard (heavier, slower moving)
|
||||||
|
ADMIN_LEADERBOARD = 300 # 5分钟
|
||||||
|
|
||||||
# 并发锁 TTL - 防止死锁
|
# 并发锁 TTL - 防止死锁
|
||||||
CONCURRENCY_LOCK = 600 # 10分钟
|
CONCURRENCY_LOCK = 600 # 10分钟
|
||||||
|
|
||||||
|
|||||||
@@ -29,3 +29,19 @@ class AuthSource(str, Enum):
|
|||||||
LOCAL = "local" # 本地认证
|
LOCAL = "local" # 本地认证
|
||||||
LDAP = "ldap" # LDAP 认证
|
LDAP = "ldap" # LDAP 认证
|
||||||
OAUTH = "oauth" # OAuth 认证(账号首创来源)
|
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"
|
||||||
|
|||||||
@@ -344,6 +344,7 @@ class Usage(Base):
|
|||||||
is_stream = Column(Boolean, default=False) # 是否为流式请求
|
is_stream = Column(Boolean, default=False) # 是否为流式请求
|
||||||
status_code = Column(Integer)
|
status_code = Column(Integer)
|
||||||
error_message = Column(Text, nullable=True)
|
error_message = Column(Text, nullable=True)
|
||||||
|
error_category = Column(String(50), nullable=True, index=True)
|
||||||
response_time_ms = Column(Integer) # 总响应时间(毫秒)
|
response_time_ms = Column(Integer) # 总响应时间(毫秒)
|
||||||
first_byte_time_ms = Column(Integer, nullable=True) # 首字时间/TTFB(毫秒)
|
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):
|
class StatsDaily(Base):
|
||||||
"""每日统计快照 - 用于快速查询历史数据"""
|
"""每日统计快照 - 用于快速查询历史数据"""
|
||||||
|
|
||||||
@@ -1975,12 +2117,22 @@ class StatsDaily(Base):
|
|||||||
|
|
||||||
# 性能统计
|
# 性能统计
|
||||||
avg_response_time_ms = Column(Float, default=0.0, nullable=False)
|
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 切换次数
|
fallback_count = Column(Integer, default=0, nullable=False) # Provider 切换次数
|
||||||
|
|
||||||
# 使用维度统计
|
# 使用维度统计
|
||||||
unique_models = Column(Integer, default=0, server_default="0", nullable=False)
|
unique_models = Column(Integer, default=0, server_default="0", nullable=False)
|
||||||
unique_providers = 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(
|
created_at = Column(
|
||||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
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):
|
class StatsSummary(Base):
|
||||||
"""全局统计汇总 - 单行记录,存储截止到昨天的累计数据"""
|
"""全局统计汇总 - 单行记录,存储截止到昨天的累计数据"""
|
||||||
|
|
||||||
|
|||||||
@@ -123,14 +123,24 @@ class MaintenanceScheduler:
|
|||||||
|
|
||||||
scheduler = get_scheduler()
|
scheduler = get_scheduler()
|
||||||
|
|
||||||
# 注册定时任务(使用业务时区)
|
# 注册定时任务
|
||||||
# 统计聚合任务 - 凌晨 1 点执行
|
# 统计聚合任务 - UTC 00:05 执行
|
||||||
scheduler.add_cron_job(
|
scheduler.add_cron_job(
|
||||||
self._scheduled_stats_aggregation,
|
self._scheduled_stats_aggregation,
|
||||||
hour=1,
|
hour=0,
|
||||||
minute=0,
|
minute=5,
|
||||||
job_id="stats_aggregation",
|
job_id="stats_aggregation",
|
||||||
name="统计数据聚合",
|
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 分钟检查缺失并回填
|
# 统计聚合补偿任务 - 每 30 分钟检查缺失并回填
|
||||||
scheduler.add_interval_job(
|
scheduler.add_interval_job(
|
||||||
@@ -231,6 +241,10 @@ class MaintenanceScheduler:
|
|||||||
"""统计聚合任务(定时调用)"""
|
"""统计聚合任务(定时调用)"""
|
||||||
await self._perform_stats_aggregation(backfill=backfill)
|
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:
|
async def _scheduled_cleanup(self) -> None:
|
||||||
"""清理任务(定时调用)"""
|
"""清理任务(定时调用)"""
|
||||||
await self._perform_cleanup()
|
await self._perform_cleanup()
|
||||||
@@ -282,17 +296,12 @@ class MaintenanceScheduler:
|
|||||||
|
|
||||||
logger.info("开始执行统计数据聚合...")
|
logger.info("开始执行统计数据聚合...")
|
||||||
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
from src.models.database import StatsDaily
|
from src.models.database import StatsDaily
|
||||||
from src.models.database import User as DBUser
|
from src.models.database import User as DBUser
|
||||||
from src.services.system.scheduler import APP_TIMEZONE
|
|
||||||
|
|
||||||
# 使用业务时区计算日期,确保与定时任务触发时间一致
|
# 使用 UTC 日期,定时任务在 UTC 00:05 触发,聚合 UTC 昨天
|
||||||
# 定时任务在 Asia/Shanghai 凌晨 1 点触发,此时应聚合 Asia/Shanghai 的"昨天"
|
now_utc = datetime.now(timezone.utc)
|
||||||
app_tz = ZoneInfo(APP_TIMEZONE)
|
today_utc = now_utc.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
now_local = datetime.now(app_tz)
|
|
||||||
today_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
||||||
|
|
||||||
if backfill:
|
if backfill:
|
||||||
# 启动时检查并回填缺失的日期
|
# 启动时检查并回填缺失的日期
|
||||||
@@ -314,14 +323,15 @@ class MaintenanceScheduler:
|
|||||||
# 非首次运行,检查最近是否有缺失的日期需要回填
|
# 非首次运行,检查最近是否有缺失的日期需要回填
|
||||||
from src.models.database import StatsDailyModel, StatsDailyProvider
|
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 = (
|
max_backfill_days: int = (
|
||||||
SystemConfigService.get_config(db, "max_stats_backfill_days", 30) or 30
|
SystemConfigService.get_config(db, "max_stats_backfill_days", 30) or 30
|
||||||
)
|
)
|
||||||
|
|
||||||
# 计算回填检查的起始日期
|
# 计算回填检查的起始日期
|
||||||
check_start_date = yesterday_business_date - timedelta(
|
check_start_date = yesterday_utc_date - timedelta(days=max_backfill_days - 1)
|
||||||
days=max_backfill_days - 1
|
check_start_dt = datetime.combine(
|
||||||
|
check_start_date, datetime.min.time(), tzinfo=timezone.utc
|
||||||
)
|
)
|
||||||
|
|
||||||
# 获取 StatsDaily 和 StatsDailyModel 中已有数据的日期集合
|
# 获取 StatsDaily 和 StatsDailyModel 中已有数据的日期集合
|
||||||
@@ -330,41 +340,39 @@ class MaintenanceScheduler:
|
|||||||
existing_provider_dates = set()
|
existing_provider_dates = set()
|
||||||
|
|
||||||
daily_stats = (
|
daily_stats = (
|
||||||
db.query(StatsDaily.date)
|
db.query(StatsDaily.date).filter(StatsDaily.date >= check_start_dt).all()
|
||||||
.filter(StatsDaily.date >= check_start_date.isoformat())
|
|
||||||
.all()
|
|
||||||
)
|
)
|
||||||
for (stat_date,) in daily_stats:
|
for (stat_date,) in daily_stats:
|
||||||
if stat_date.tzinfo is None:
|
if stat_date.tzinfo is None:
|
||||||
stat_date = stat_date.replace(tzinfo=timezone.utc)
|
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 = (
|
model_stats = (
|
||||||
db.query(StatsDailyModel.date)
|
db.query(StatsDailyModel.date)
|
||||||
.filter(StatsDailyModel.date >= check_start_date.isoformat())
|
.filter(StatsDailyModel.date >= check_start_dt)
|
||||||
.distinct()
|
.distinct()
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
for (stat_date,) in model_stats:
|
for (stat_date,) in model_stats:
|
||||||
if stat_date.tzinfo is None:
|
if stat_date.tzinfo is None:
|
||||||
stat_date = stat_date.replace(tzinfo=timezone.utc)
|
stat_date = stat_date.replace(tzinfo=timezone.utc)
|
||||||
existing_model_dates.add(stat_date.astimezone(app_tz).date())
|
existing_model_dates.add(stat_date.date())
|
||||||
|
|
||||||
provider_stats = (
|
provider_stats = (
|
||||||
db.query(StatsDailyProvider.date)
|
db.query(StatsDailyProvider.date)
|
||||||
.filter(StatsDailyProvider.date >= check_start_date.isoformat())
|
.filter(StatsDailyProvider.date >= check_start_dt)
|
||||||
.distinct()
|
.distinct()
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
for (stat_date,) in provider_stats:
|
for (stat_date,) in provider_stats:
|
||||||
if stat_date.tzinfo is None:
|
if stat_date.tzinfo is None:
|
||||||
stat_date = stat_date.replace(tzinfo=timezone.utc)
|
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()
|
all_dates = set()
|
||||||
current = check_start_date
|
current = check_start_date
|
||||||
while current <= yesterday_business_date:
|
while current <= yesterday_utc_date:
|
||||||
all_dates.add(current)
|
all_dates.add(current)
|
||||||
current += timedelta(days=1)
|
current += timedelta(days=1)
|
||||||
|
|
||||||
@@ -389,43 +397,17 @@ class MaintenanceScheduler:
|
|||||||
)
|
)
|
||||||
|
|
||||||
users = db.query(DBUser.id).filter(DBUser.is_active.is_(True)).all()
|
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_dates = 0
|
||||||
failed_users = 0
|
|
||||||
|
|
||||||
for current_date in sorted_dates:
|
for current_date in sorted_dates:
|
||||||
try:
|
try:
|
||||||
current_date_local = datetime.combine(
|
current_date_utc = datetime.combine(
|
||||||
current_date, datetime.min.time(), tzinfo=app_tz
|
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:
|
except Exception as e:
|
||||||
failed_dates += 1
|
failed_dates += 1
|
||||||
logger.warning(f"回填日期 {current_date} 失败: {e}")
|
logger.warning(f"回填日期 {current_date} 失败: {e}")
|
||||||
@@ -436,10 +418,10 @@ class MaintenanceScheduler:
|
|||||||
|
|
||||||
StatsAggregatorService.update_summary(db)
|
StatsAggregatorService.update_summary(db)
|
||||||
|
|
||||||
if failed_dates > 0 or failed_users > 0:
|
if failed_dates > 0:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"回填完成,共处理 {len(dates_to_process)} 天,"
|
f"回填完成,共处理 {len(dates_to_process)} 天,"
|
||||||
f"失败: {failed_dates} 天, {failed_users} 个用户记录"
|
f"失败: {failed_dates} 天"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.info(f"缺失数据回填完成,共处理 {len(dates_to_process)} 天")
|
logger.info(f"缺失数据回填完成,共处理 {len(dates_to_process)} 天")
|
||||||
@@ -447,25 +429,14 @@ class MaintenanceScheduler:
|
|||||||
logger.info("统计数据已是最新,无需回填")
|
logger.info("统计数据已是最新,无需回填")
|
||||||
return
|
return
|
||||||
|
|
||||||
# 定时任务:聚合昨天的数据
|
# 定时任务:聚合昨天 (UTC) 的数据
|
||||||
yesterday_local = today_local - timedelta(days=1)
|
yesterday_utc = today_utc - 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)
|
|
||||||
|
|
||||||
users = db.query(DBUser.id).filter(DBUser.is_active.is_(True)).all()
|
users = db.query(DBUser.id).filter(DBUser.is_active.is_(True)).all()
|
||||||
for (user_id,) in users:
|
user_ids = [user_id for (user_id,) in users]
|
||||||
try:
|
|
||||||
StatsAggregatorService.aggregate_user_daily_stats(
|
StatsAggregatorService.aggregate_daily_stats_bundle(
|
||||||
db, user_id, yesterday_local
|
db, yesterday_utc, user_ids=user_ids
|
||||||
)
|
)
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"聚合用户 {user_id} 统计数据失败: {e}")
|
|
||||||
try:
|
|
||||||
db.rollback()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
StatsAggregatorService.update_summary(db)
|
StatsAggregatorService.update_summary(db)
|
||||||
|
|
||||||
@@ -480,6 +451,27 @@ class MaintenanceScheduler:
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
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:
|
async def _perform_pending_cleanup(self) -> None:
|
||||||
"""执行 pending 状态清理"""
|
"""执行 pending 状态清理"""
|
||||||
db = create_session()
|
db = create_session()
|
||||||
|
|||||||
@@ -41,10 +41,11 @@ class TaskScheduler:
|
|||||||
def add_cron_job(
|
def add_cron_job(
|
||||||
self,
|
self,
|
||||||
func: Callable[..., Any],
|
func: Callable[..., Any],
|
||||||
hour: int,
|
hour: int | str,
|
||||||
minute: int = 0,
|
minute: int = 0,
|
||||||
job_id: str | None = None,
|
job_id: str | None = None,
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
|
timezone: str | None = None,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
@@ -58,7 +59,8 @@ class TaskScheduler:
|
|||||||
name: 任务名称(用于日志)
|
name: 任务名称(用于日志)
|
||||||
**kwargs: 传递给任务函数的参数
|
**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__
|
job_id = job_id or func.__name__
|
||||||
display_name = name or job_id
|
display_name = name or job_id
|
||||||
@@ -74,7 +76,7 @@ class TaskScheduler:
|
|||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"已注册定时任务: {display_name}, "
|
f"已注册定时任务: {display_name}, "
|
||||||
f"执行时间: {hour:02d}:{minute:02d} ({APP_TIMEZONE})"
|
f"执行时间: {hour}:{minute:02d} ({trigger_timezone})"
|
||||||
)
|
)
|
||||||
|
|
||||||
def add_interval_job(
|
def add_interval_job(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
241
src/services/system/time_range.py
Normal file
241
src/services/system/time_range.py
Normal 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
|
||||||
70
src/services/usage/error_classifier.py
Normal file
70
src/services/usage/error_classifier.py
Normal 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
|
||||||
@@ -27,6 +27,7 @@ from src.models.database import (
|
|||||||
)
|
)
|
||||||
from src.services.model.cost import ModelCostService
|
from src.services.model.cost import ModelCostService
|
||||||
from src.services.system.config import SystemConfigService
|
from src.services.system.config import SystemConfigService
|
||||||
|
from src.services.usage.error_classifier import classify_error
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -335,6 +336,10 @@ class UsageService:
|
|||||||
actual_request_cost = request_cost * actual_rate_multiplier
|
actual_request_cost = request_cost * actual_rate_multiplier
|
||||||
actual_total_cost = total_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 {
|
return {
|
||||||
"user_id": user.id if user else None,
|
"user_id": user.id if user else None,
|
||||||
"api_key_id": api_key.id if api_key else None,
|
"api_key_id": api_key.id if api_key else None,
|
||||||
@@ -376,6 +381,7 @@ class UsageService:
|
|||||||
"is_stream": is_stream,
|
"is_stream": is_stream,
|
||||||
"status_code": status_code,
|
"status_code": status_code,
|
||||||
"error_message": error_message,
|
"error_message": error_message,
|
||||||
|
"error_category": error_category,
|
||||||
"response_time_ms": response_time_ms,
|
"response_time_ms": response_time_ms,
|
||||||
"first_byte_time_ms": first_byte_time_ms,
|
"first_byte_time_ms": first_byte_time_ms,
|
||||||
"status": status,
|
"status": status,
|
||||||
@@ -586,6 +592,7 @@ class UsageService:
|
|||||||
existing_usage.status = usage_params["status"]
|
existing_usage.status = usage_params["status"]
|
||||||
existing_usage.status_code = usage_params["status_code"]
|
existing_usage.status_code = usage_params["status_code"]
|
||||||
existing_usage.error_message = usage_params["error_message"]
|
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.response_time_ms = usage_params["response_time_ms"]
|
||||||
existing_usage.first_byte_time_ms = usage_params["first_byte_time_ms"]
|
existing_usage.first_byte_time_ms = usage_params["first_byte_time_ms"]
|
||||||
|
|
||||||
@@ -2068,7 +2075,7 @@ class UsageService:
|
|||||||
if start_date:
|
if start_date:
|
||||||
query = query.filter(Usage.created_at >= start_date)
|
query = query.filter(Usage.created_at >= start_date)
|
||||||
if end_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
|
from src.utils.database_helpers import date_trunc_portable
|
||||||
@@ -2108,7 +2115,7 @@ class UsageService:
|
|||||||
if start_date:
|
if start_date:
|
||||||
summary = summary.filter(Usage.created_at >= start_date)
|
summary = summary.filter(Usage.created_at >= start_date)
|
||||||
if end_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()
|
summary = summary.group_by(date_func, Usage.provider_name, Usage.model).all()
|
||||||
|
|
||||||
|
|||||||
27
tests/services/usage/test_error_classifier.py
Normal file
27
tests/services/usage/test_error_classifier.py
Normal 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
|
||||||
274
tests/unit/test_time_range_params.py
Normal file
274
tests/unit/test_time_range_params.py
Normal 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 是完整的
|
||||||
Reference in New Issue
Block a user