Initial commit

This commit is contained in:
fawney19
2025-12-10 20:52:44 +08:00
commit f784106826
485 changed files with 110993 additions and 0 deletions

101
alembic/env.py Normal file
View File

@@ -0,0 +1,101 @@
"""
Alembic 环境配置
用于数据库迁移的运行时环境设置
"""
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
import os
import sys
from pathlib import Path
# 添加项目根目录到 Python 路径
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
# 加载 .env 文件(本地开发时需要)
try:
from dotenv import load_dotenv
env_file = Path(__file__).parent.parent / ".env"
if env_file.exists():
load_dotenv(env_file)
except ImportError:
pass
# 导入所有数据库模型(确保 Alembic 能检测到所有表)
from src.models.database import Base
# Alembic Config 对象
config = context.config
# 从环境变量获取数据库 URL
# 优先使用 DATABASE_URL否则从 DB_PASSWORD 自动构建(与 docker-compose 保持一致)
database_url = os.getenv("DATABASE_URL")
if not database_url:
db_password = os.getenv("DB_PASSWORD", "")
db_host = os.getenv("DB_HOST", "localhost")
db_port = os.getenv("DB_PORT", "5432")
db_name = os.getenv("DB_NAME", "aether")
db_user = os.getenv("DB_USER", "postgres")
database_url = f"postgresql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}"
config.set_main_option("sqlalchemy.url", database_url)
# 配置日志
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# 目标元数据(包含所有表定义)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""
离线模式运行迁移
在离线模式下,不需要连接数据库,
只生成 SQL 脚本
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True, # 比较列类型变更
compare_server_default=True, # 比较默认值变更
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""
在线模式运行迁移
在线模式下,直接连接数据库执行迁移
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True, # 比较列类型变更
compare_server_default=True, # 比较默认值变更
)
with context.begin_transaction():
context.run_migrations()
# 根据模式选择运行方式
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

26
alembic/script.py.mako Normal file
View File

@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
"""应用迁移:升级到新版本"""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""回滚迁移:降级到旧版本"""
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,771 @@
"""Baseline migration - all tables consolidated
Revision ID: 20251210_baseline
Revises:
Create Date: 2024-12-10
This is the consolidated baseline migration that creates all tables from scratch.
Includes all schema changes up to circuit breaker v2.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers
revision = "20251210_baseline"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# Create ENUM types
op.execute("CREATE TYPE userrole AS ENUM ('admin', 'user')")
op.execute(
"CREATE TYPE providerbillingtype AS ENUM ('monthly_quota', 'pay_as_you_go', 'free_tier')"
)
# ==================== users ====================
op.create_table(
"users",
sa.Column("id", sa.String(36), primary_key=True, index=True),
sa.Column("email", sa.String(255), unique=True, index=True, nullable=False),
sa.Column("username", sa.String(100), unique=True, index=True, nullable=False),
sa.Column("password_hash", sa.String(255), nullable=False),
sa.Column(
"role",
sa.Enum("admin", "user", name="userrole", create_type=False),
nullable=False,
server_default="user",
),
sa.Column("allowed_providers", sa.JSON, nullable=True),
sa.Column("allowed_endpoints", sa.JSON, nullable=True),
sa.Column("allowed_models", sa.JSON, nullable=True),
sa.Column("model_capability_settings", sa.JSON, nullable=True),
sa.Column("quota_usd", sa.Float, nullable=True),
sa.Column("used_usd", sa.Float, server_default="0.0"),
sa.Column("total_usd", sa.Float, server_default="0.0"),
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
sa.Column("is_deleted", sa.Boolean, server_default="false", nullable=False),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True),
)
# ==================== providers ====================
op.create_table(
"providers",
sa.Column("id", sa.String(36), primary_key=True, index=True),
sa.Column("name", sa.String(100), unique=True, index=True, nullable=False),
sa.Column("display_name", sa.String(100), nullable=False),
sa.Column("description", sa.Text, nullable=True),
sa.Column("website", sa.String(500), nullable=True),
sa.Column(
"billing_type",
sa.Enum(
"monthly_quota", "pay_as_you_go", "free_tier", name="providerbillingtype", create_type=False
),
nullable=False,
server_default="pay_as_you_go",
),
sa.Column("monthly_quota_usd", sa.Float, nullable=True),
sa.Column("monthly_used_usd", sa.Float, server_default="0.0"),
sa.Column("quota_reset_day", sa.Integer, server_default="30"),
sa.Column("quota_last_reset_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("quota_expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("rpm_limit", sa.Integer, nullable=True),
sa.Column("rpm_used", sa.Integer, server_default="0"),
sa.Column("rpm_reset_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("provider_priority", sa.Integer, server_default="100"),
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
sa.Column("rate_limit", sa.Integer, nullable=True),
sa.Column("concurrent_limit", sa.Integer, nullable=True),
sa.Column("config", sa.JSON, nullable=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
)
# ==================== global_models ====================
op.create_table(
"global_models",
sa.Column("id", sa.String(36), primary_key=True, index=True),
sa.Column("name", sa.String(100), unique=True, index=True, nullable=False),
sa.Column("display_name", sa.String(100), nullable=False),
sa.Column("description", sa.Text, nullable=True),
sa.Column("icon_url", sa.String(500), nullable=True),
sa.Column("official_url", sa.String(500), nullable=True),
sa.Column("default_price_per_request", sa.Float, nullable=True),
sa.Column("default_tiered_pricing", sa.JSON, nullable=False),
sa.Column("default_supports_vision", sa.Boolean, server_default="false", nullable=True),
sa.Column("default_supports_function_calling", sa.Boolean, server_default="false", nullable=True),
sa.Column("default_supports_streaming", sa.Boolean, server_default="true", nullable=True),
sa.Column("default_supports_extended_thinking", sa.Boolean, server_default="false", nullable=True),
sa.Column("default_supports_image_generation", sa.Boolean, server_default="false", nullable=True),
sa.Column("supported_capabilities", sa.JSON, nullable=True),
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
sa.Column("usage_count", sa.Integer, server_default="0", nullable=False, index=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
)
# ==================== api_keys ====================
op.create_table(
"api_keys",
sa.Column("id", sa.String(36), primary_key=True, index=True),
sa.Column(
"user_id", sa.String(36), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False
),
sa.Column("key_hash", sa.String(64), unique=True, index=True, nullable=False),
sa.Column("key_encrypted", sa.Text, nullable=True),
sa.Column("name", sa.String(100), nullable=True),
sa.Column("total_requests", sa.Integer, server_default="0"),
sa.Column("total_cost_usd", sa.Float, server_default="0.0"),
sa.Column("balance_used_usd", sa.Float, server_default="0.0"),
sa.Column("current_balance_usd", sa.Float, nullable=True),
sa.Column("is_standalone", sa.Boolean, server_default="false", nullable=False),
sa.Column("allowed_providers", sa.JSON, nullable=True),
sa.Column("allowed_endpoints", sa.JSON, nullable=True),
sa.Column("allowed_api_formats", sa.JSON, nullable=True),
sa.Column("allowed_models", sa.JSON, nullable=True),
sa.Column("rate_limit", sa.Integer, server_default="100"),
sa.Column("concurrent_limit", sa.Integer, server_default="5", nullable=True),
sa.Column("force_capabilities", sa.JSON, nullable=True),
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("auto_delete_on_expiry", sa.Boolean, server_default="false", nullable=False),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
)
# ==================== provider_endpoints ====================
op.create_table(
"provider_endpoints",
sa.Column("id", sa.String(36), primary_key=True, index=True),
sa.Column(
"provider_id",
sa.String(36),
sa.ForeignKey("providers.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("api_format", sa.String(50), nullable=False),
sa.Column("base_url", sa.String(500), nullable=False),
sa.Column("headers", sa.JSON, nullable=True),
sa.Column("timeout", sa.Integer, server_default="300"),
sa.Column("max_retries", sa.Integer, server_default="3"),
sa.Column("max_concurrent", sa.Integer, nullable=True),
sa.Column("rate_limit", sa.Integer, nullable=True),
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
sa.Column("custom_path", sa.String(200), nullable=True),
sa.Column("config", sa.JSON, nullable=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.UniqueConstraint("provider_id", "api_format", name="uq_provider_api_format"),
)
op.create_index(
"idx_endpoint_format_active", "provider_endpoints", ["api_format", "is_active"]
)
# ==================== models ====================
op.create_table(
"models",
sa.Column("id", sa.String(36), primary_key=True, index=True),
sa.Column(
"provider_id", sa.String(36), sa.ForeignKey("providers.id"), nullable=False
),
sa.Column(
"global_model_id",
sa.String(36),
sa.ForeignKey("global_models.id"),
nullable=False,
index=True,
),
sa.Column("provider_model_name", sa.String(200), nullable=False),
sa.Column("price_per_request", sa.Float, nullable=True),
sa.Column("tiered_pricing", sa.JSON, nullable=True),
sa.Column("supports_vision", sa.Boolean, nullable=True),
sa.Column("supports_function_calling", sa.Boolean, nullable=True),
sa.Column("supports_streaming", sa.Boolean, nullable=True),
sa.Column("supports_extended_thinking", sa.Boolean, nullable=True),
sa.Column("supports_image_generation", sa.Boolean, nullable=True),
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
sa.Column("is_available", sa.Boolean, server_default="true"),
sa.Column("config", sa.JSON, nullable=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.UniqueConstraint("provider_id", "provider_model_name", name="uq_provider_model"),
)
# ==================== model_mappings ====================
op.create_table(
"model_mappings",
sa.Column("id", sa.String(36), primary_key=True, index=True),
sa.Column("source_model", sa.String(200), nullable=False, index=True),
sa.Column(
"target_global_model_id",
sa.String(36),
sa.ForeignKey("global_models.id", ondelete="CASCADE"),
nullable=False,
index=True,
),
sa.Column(
"provider_id", sa.String(36), sa.ForeignKey("providers.id"), nullable=True, index=True
),
sa.Column("mapping_type", sa.String(20), nullable=False, server_default="alias", index=True),
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.UniqueConstraint("source_model", "provider_id", name="uq_model_mapping_source_provider"),
)
# ==================== provider_api_keys ====================
op.create_table(
"provider_api_keys",
sa.Column("id", sa.String(36), primary_key=True, index=True),
sa.Column(
"endpoint_id",
sa.String(36),
sa.ForeignKey("provider_endpoints.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("api_key", sa.String(500), nullable=False),
sa.Column("name", sa.String(100), nullable=False),
sa.Column("note", sa.String(500), nullable=True),
sa.Column("rate_multiplier", sa.Float, server_default="1.0", nullable=False),
sa.Column("internal_priority", sa.Integer, server_default="50"),
sa.Column("global_priority", sa.Integer, nullable=True),
sa.Column("max_concurrent", sa.Integer, nullable=True),
sa.Column("rate_limit", sa.Integer, nullable=True),
sa.Column("daily_limit", sa.Integer, nullable=True),
sa.Column("monthly_limit", sa.Integer, nullable=True),
sa.Column("allowed_models", sa.JSON, nullable=True),
sa.Column("capabilities", sa.JSON, nullable=True),
sa.Column("learned_max_concurrent", sa.Integer, nullable=True),
sa.Column("concurrent_429_count", sa.Integer, server_default="0", nullable=False),
sa.Column("rpm_429_count", sa.Integer, server_default="0", nullable=False),
sa.Column("last_429_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_429_type", sa.String(50), nullable=True),
sa.Column("last_concurrent_peak", sa.Integer, nullable=True),
sa.Column("adjustment_history", sa.JSON, nullable=True),
# Sliding window fields (replaces high_utilization_start)
sa.Column("utilization_samples", sa.JSON, nullable=True),
sa.Column("last_probe_increase_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("health_score", sa.Float, server_default="1.0"),
sa.Column("consecutive_failures", sa.Integer, server_default="0"),
sa.Column("last_failure_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("cache_ttl_minutes", sa.Integer, server_default="5", nullable=False),
sa.Column("max_probe_interval_minutes", sa.Integer, server_default="32", nullable=False),
sa.Column("circuit_breaker_open", sa.Boolean, server_default="false", nullable=False),
sa.Column("circuit_breaker_open_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("next_probe_at", sa.DateTime(timezone=True), nullable=True),
# Circuit breaker v2 fields
sa.Column("request_results_window", sa.JSON, nullable=True),
sa.Column("half_open_until", sa.DateTime(timezone=True), nullable=True),
sa.Column("half_open_successes", sa.Integer, server_default="0", nullable=True),
sa.Column("half_open_failures", sa.Integer, server_default="0", nullable=True),
sa.Column("request_count", sa.Integer, server_default="0"),
sa.Column("success_count", sa.Integer, server_default="0"),
sa.Column("error_count", sa.Integer, server_default="0"),
sa.Column("total_response_time_ms", sa.Integer, server_default="0"),
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_error_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_error_msg", sa.Text, nullable=True),
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
)
# ==================== usage ====================
op.create_table(
"usage",
sa.Column("id", sa.String(36), primary_key=True, index=True),
sa.Column(
"user_id",
sa.String(36),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"api_key_id",
sa.String(36),
sa.ForeignKey("api_keys.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("request_id", sa.String(100), unique=True, index=True, nullable=False),
sa.Column("provider", sa.String(100), nullable=False),
sa.Column("model", sa.String(100), nullable=False),
sa.Column("target_model", sa.String(100), nullable=True),
sa.Column(
"provider_id",
sa.String(36),
sa.ForeignKey("providers.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"provider_endpoint_id",
sa.String(36),
sa.ForeignKey("provider_endpoints.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"provider_api_key_id",
sa.String(36),
sa.ForeignKey("provider_api_keys.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("input_tokens", sa.Integer, server_default="0"),
sa.Column("output_tokens", sa.Integer, server_default="0"),
sa.Column("total_tokens", sa.Integer, server_default="0"),
sa.Column("cache_creation_input_tokens", sa.Integer, server_default="0"),
sa.Column("cache_read_input_tokens", sa.Integer, server_default="0"),
sa.Column("input_cost_usd", sa.Float, server_default="0.0"),
sa.Column("output_cost_usd", sa.Float, server_default="0.0"),
sa.Column("cache_cost_usd", sa.Float, server_default="0.0"),
sa.Column("cache_creation_cost_usd", sa.Float, server_default="0.0"),
sa.Column("cache_read_cost_usd", sa.Float, server_default="0.0"),
sa.Column("request_cost_usd", sa.Float, server_default="0.0"),
sa.Column("total_cost_usd", sa.Float, server_default="0.0"),
sa.Column("actual_input_cost_usd", sa.Float, server_default="0.0"),
sa.Column("actual_output_cost_usd", sa.Float, server_default="0.0"),
sa.Column("actual_cache_creation_cost_usd", sa.Float, server_default="0.0"),
sa.Column("actual_cache_read_cost_usd", sa.Float, server_default="0.0"),
sa.Column("actual_request_cost_usd", sa.Float, server_default="0.0"),
sa.Column("actual_total_cost_usd", sa.Float, server_default="0.0"),
sa.Column("rate_multiplier", sa.Float, server_default="1.0"),
sa.Column("input_price_per_1m", sa.Float, nullable=True),
sa.Column("output_price_per_1m", sa.Float, nullable=True),
sa.Column("cache_creation_price_per_1m", sa.Float, nullable=True),
sa.Column("cache_read_price_per_1m", sa.Float, nullable=True),
sa.Column("price_per_request", sa.Float, nullable=True),
sa.Column("request_type", sa.String(50), nullable=True),
sa.Column("api_format", sa.String(50), nullable=True),
sa.Column("is_stream", sa.Boolean, server_default="false"),
sa.Column("status_code", sa.Integer, nullable=True),
sa.Column("error_message", sa.Text, nullable=True),
sa.Column("response_time_ms", sa.Integer, nullable=True),
sa.Column("status", sa.String(20), server_default="completed", nullable=False, index=True),
sa.Column("request_headers", sa.JSON, nullable=True),
sa.Column("request_body", sa.JSON, nullable=True),
sa.Column("provider_request_headers", sa.JSON, nullable=True),
sa.Column("response_headers", sa.JSON, nullable=True),
sa.Column("response_body", sa.JSON, nullable=True),
sa.Column("request_body_compressed", sa.LargeBinary, nullable=True),
sa.Column("response_body_compressed", sa.LargeBinary, nullable=True),
sa.Column("request_metadata", sa.JSON, nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
index=True,
),
)
# ==================== user_quotas ====================
op.create_table(
"user_quotas",
sa.Column("id", sa.String(36), primary_key=True, index=True),
sa.Column(
"user_id", sa.String(36), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False
),
sa.Column("quota_type", sa.String(50), nullable=False),
sa.Column("quota_usd", sa.Float, nullable=False),
sa.Column("period_start", sa.DateTime(timezone=True), nullable=False),
sa.Column("period_end", sa.DateTime(timezone=True), nullable=False),
sa.Column("used_usd", sa.Float, server_default="0.0"),
sa.Column("is_active", sa.Boolean, server_default="true"),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
)
# ==================== system_configs ====================
op.create_table(
"system_configs",
sa.Column("id", sa.String(36), primary_key=True, index=True),
sa.Column("key", sa.String(100), unique=True, nullable=False),
sa.Column("value", sa.JSON, nullable=False),
sa.Column("description", sa.Text, nullable=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
)
# ==================== user_preferences ====================
op.create_table(
"user_preferences",
sa.Column("id", sa.String(36), primary_key=True, index=True),
sa.Column(
"user_id",
sa.String(36),
sa.ForeignKey("users.id", ondelete="CASCADE"),
unique=True,
nullable=False,
),
sa.Column("avatar_url", sa.String(500), nullable=True),
sa.Column("bio", sa.Text, nullable=True),
sa.Column(
"default_provider_id", sa.String(36), sa.ForeignKey("providers.id"), nullable=True
),
sa.Column("theme", sa.String(20), server_default="light"),
sa.Column("language", sa.String(10), server_default="zh-CN"),
sa.Column("timezone", sa.String(50), server_default="Asia/Shanghai"),
sa.Column("email_notifications", sa.Boolean, server_default="true"),
sa.Column("usage_alerts", sa.Boolean, server_default="true"),
sa.Column("announcement_notifications", sa.Boolean, server_default="true"),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
)
# ==================== announcements ====================
op.create_table(
"announcements",
sa.Column("id", sa.String(36), primary_key=True, index=True),
sa.Column("title", sa.String(200), nullable=False),
sa.Column("content", sa.Text, nullable=False),
sa.Column("type", sa.String(20), server_default="info"),
sa.Column("priority", sa.Integer, server_default="0"),
sa.Column(
"author_id",
sa.String(36),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("is_active", sa.Boolean, server_default="true", index=True),
sa.Column("is_pinned", sa.Boolean, server_default="false"),
sa.Column("start_time", sa.DateTime(timezone=True), nullable=True),
sa.Column("end_time", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
index=True,
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
)
# ==================== announcement_reads ====================
op.create_table(
"announcement_reads",
sa.Column("id", sa.String(36), primary_key=True, index=True),
sa.Column(
"user_id", sa.String(36), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False
),
sa.Column(
"announcement_id", sa.String(36), sa.ForeignKey("announcements.id"), nullable=False
),
sa.Column(
"read_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.UniqueConstraint("user_id", "announcement_id", name="uq_user_announcement"),
)
# ==================== audit_logs ====================
op.create_table(
"audit_logs",
sa.Column("id", sa.String(36), primary_key=True, index=True),
sa.Column("event_type", sa.String(50), nullable=False, index=True),
sa.Column(
"user_id",
sa.String(36),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
index=True,
),
sa.Column("api_key_id", sa.String(36), nullable=True),
sa.Column("description", sa.Text, nullable=False),
sa.Column("ip_address", sa.String(45), nullable=True),
sa.Column("user_agent", sa.String(500), nullable=True),
sa.Column("request_id", sa.String(100), nullable=True, index=True),
sa.Column("event_metadata", sa.JSON, nullable=True),
sa.Column("status_code", sa.Integer, nullable=True),
sa.Column("error_message", sa.Text, nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
index=True,
),
)
# ==================== request_candidates ====================
op.create_table(
"request_candidates",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("request_id", sa.String(100), nullable=False, index=True),
sa.Column(
"user_id", sa.String(36), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=True
),
sa.Column(
"api_key_id",
sa.String(36),
sa.ForeignKey("api_keys.id", ondelete="CASCADE"),
nullable=True,
),
sa.Column("candidate_index", sa.Integer, nullable=False),
sa.Column("retry_index", sa.Integer, nullable=False, server_default="0"),
sa.Column(
"provider_id",
sa.String(36),
sa.ForeignKey("providers.id", ondelete="CASCADE"),
nullable=True,
),
sa.Column(
"endpoint_id",
sa.String(36),
sa.ForeignKey("provider_endpoints.id", ondelete="CASCADE"),
nullable=True,
),
sa.Column(
"key_id",
sa.String(36),
sa.ForeignKey("provider_api_keys.id", ondelete="CASCADE"),
nullable=True,
),
sa.Column("status", sa.String(20), nullable=False),
sa.Column("skip_reason", sa.Text, nullable=True),
sa.Column("is_cached", sa.Boolean, server_default="false"),
sa.Column("status_code", sa.Integer, nullable=True),
sa.Column("error_type", sa.String(50), nullable=True),
sa.Column("error_message", sa.Text, nullable=True),
sa.Column("latency_ms", sa.Integer, nullable=True),
sa.Column("concurrent_requests", sa.Integer, nullable=True),
sa.Column("extra_data", sa.JSON, nullable=True),
sa.Column("required_capabilities", sa.JSON, nullable=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.UniqueConstraint(
"request_id", "candidate_index", "retry_index", name="uq_request_candidate_with_retry"
),
)
op.create_index("idx_request_candidates_request_id", "request_candidates", ["request_id"])
op.create_index("idx_request_candidates_status", "request_candidates", ["status"])
op.create_index("idx_request_candidates_provider_id", "request_candidates", ["provider_id"])
# ==================== stats_daily ====================
op.create_table(
"stats_daily",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("date", sa.DateTime(timezone=True), nullable=False, unique=True, index=True),
sa.Column("total_requests", sa.Integer, server_default="0", nullable=False),
sa.Column("success_requests", sa.Integer, server_default="0", nullable=False),
sa.Column("error_requests", sa.Integer, server_default="0", nullable=False),
sa.Column("input_tokens", sa.BigInteger, server_default="0", nullable=False),
sa.Column("output_tokens", sa.BigInteger, server_default="0", nullable=False),
sa.Column("cache_creation_tokens", sa.BigInteger, server_default="0", nullable=False),
sa.Column("cache_read_tokens", sa.BigInteger, server_default="0", nullable=False),
sa.Column("total_cost", sa.Float, server_default="0.0", nullable=False),
sa.Column("actual_total_cost", sa.Float, server_default="0.0", nullable=False),
sa.Column("input_cost", sa.Float, server_default="0.0", nullable=False),
sa.Column("output_cost", sa.Float, server_default="0.0", nullable=False),
sa.Column("cache_creation_cost", sa.Float, server_default="0.0", nullable=False),
sa.Column("cache_read_cost", sa.Float, server_default="0.0", nullable=False),
sa.Column("avg_response_time_ms", sa.Float, server_default="0.0", nullable=False),
sa.Column("fallback_count", sa.Integer, server_default="0", nullable=False),
sa.Column("unique_models", sa.Integer, server_default="0", nullable=False),
sa.Column("unique_providers", sa.Integer, server_default="0", nullable=False),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
)
# ==================== stats_summary ====================
op.create_table(
"stats_summary",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("cutoff_date", sa.DateTime(timezone=True), nullable=False),
sa.Column("all_time_requests", sa.Integer, server_default="0", nullable=False),
sa.Column("all_time_success_requests", sa.Integer, server_default="0", nullable=False),
sa.Column("all_time_error_requests", sa.Integer, server_default="0", nullable=False),
sa.Column("all_time_input_tokens", sa.BigInteger, server_default="0", nullable=False),
sa.Column("all_time_output_tokens", sa.BigInteger, server_default="0", nullable=False),
sa.Column(
"all_time_cache_creation_tokens", sa.BigInteger, server_default="0", nullable=False
),
sa.Column("all_time_cache_read_tokens", sa.BigInteger, server_default="0", nullable=False),
sa.Column("all_time_cost", sa.Float, server_default="0.0", nullable=False),
sa.Column("all_time_actual_cost", sa.Float, server_default="0.0", nullable=False),
sa.Column("total_users", sa.Integer, server_default="0", nullable=False),
sa.Column("active_users", sa.Integer, server_default="0", nullable=False),
sa.Column("total_api_keys", sa.Integer, server_default="0", nullable=False),
sa.Column("active_api_keys", sa.Integer, server_default="0", nullable=False),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
)
# ==================== stats_user_daily ====================
op.create_table(
"stats_user_daily",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column(
"user_id", sa.String(36), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False
),
sa.Column("date", sa.DateTime(timezone=True), nullable=False, index=True),
sa.Column("total_requests", sa.Integer, server_default="0", nullable=False),
sa.Column("success_requests", sa.Integer, server_default="0", nullable=False),
sa.Column("error_requests", sa.Integer, server_default="0", nullable=False),
sa.Column("input_tokens", sa.BigInteger, server_default="0", nullable=False),
sa.Column("output_tokens", sa.BigInteger, server_default="0", nullable=False),
sa.Column("cache_creation_tokens", sa.BigInteger, server_default="0", nullable=False),
sa.Column("cache_read_tokens", sa.BigInteger, server_default="0", nullable=False),
sa.Column("total_cost", sa.Float, server_default="0.0", nullable=False),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.UniqueConstraint("user_id", "date", name="uq_stats_user_daily"),
)
op.create_index("idx_stats_user_daily_user_date", "stats_user_daily", ["user_id", "date"])
# ==================== api_key_provider_mappings ====================
op.create_table(
"api_key_provider_mappings",
sa.Column("id", sa.String(36), primary_key=True, index=True),
sa.Column(
"api_key_id",
sa.String(36),
sa.ForeignKey("api_keys.id", ondelete="CASCADE"),
nullable=False,
index=True,
),
sa.Column(
"provider_id",
sa.String(36),
sa.ForeignKey("providers.id", ondelete="CASCADE"),
nullable=False,
index=True,
),
sa.Column("priority_adjustment", sa.Integer, server_default="0"),
sa.Column("weight_multiplier", sa.Float, server_default="1.0"),
sa.Column("is_enabled", sa.Boolean, server_default="true", nullable=False),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.UniqueConstraint("api_key_id", "provider_id", name="uq_apikey_provider"),
)
op.create_index(
"idx_apikey_provider_enabled", "api_key_provider_mappings", ["api_key_id", "is_enabled"]
)
# ==================== provider_usage_tracking ====================
op.create_table(
"provider_usage_tracking",
sa.Column("id", sa.String(36), primary_key=True, index=True),
sa.Column(
"provider_id",
sa.String(36),
sa.ForeignKey("providers.id", ondelete="CASCADE"),
nullable=False,
index=True,
),
sa.Column("window_start", sa.DateTime(timezone=True), nullable=False, index=True),
sa.Column("window_end", sa.DateTime(timezone=True), nullable=False),
sa.Column("total_requests", sa.Integer, server_default="0"),
sa.Column("successful_requests", sa.Integer, server_default="0"),
sa.Column("failed_requests", sa.Integer, server_default="0"),
sa.Column("avg_response_time_ms", sa.Float, server_default="0.0"),
sa.Column("total_response_time_ms", sa.Float, server_default="0.0"),
sa.Column("total_cost_usd", sa.Float, server_default="0.0"),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
)
op.create_index(
"idx_provider_window", "provider_usage_tracking", ["provider_id", "window_start"]
)
op.create_index("idx_window_time", "provider_usage_tracking", ["window_start", "window_end"])
def downgrade() -> None:
# Drop tables in reverse order (respecting foreign key dependencies)
op.drop_table("provider_usage_tracking")
op.drop_table("api_key_provider_mappings")
op.drop_table("stats_user_daily")
op.drop_table("stats_summary")
op.drop_table("stats_daily")
op.drop_table("request_candidates")
op.drop_table("audit_logs")
op.drop_table("announcement_reads")
op.drop_table("announcements")
op.drop_table("user_preferences")
op.drop_table("system_configs")
op.drop_table("user_quotas")
op.drop_table("usage")
op.drop_table("provider_api_keys")
op.drop_table("model_mappings")
op.drop_table("models")
op.drop_table("provider_endpoints")
op.drop_table("api_keys")
op.drop_table("global_models")
op.drop_table("providers")
op.drop_table("users")
# Drop ENUM types
op.execute("DROP TYPE IF EXISTS providerbillingtype")
op.execute("DROP TYPE IF EXISTS userrole")

View File

@@ -0,0 +1,85 @@
# Aether - 数据库迁移说明
## 当前版本
- **Revision ID**: `aether_baseline`
- **创建日期**: 2025-12-06
- **状态**: 全新基线
## 迁移历史
所有历史增量迁移已清理,当前以完整 schema 作为新起点。
## 核心数据库结构
### 用户系统
- **users**: 用户账户管理
- **api_keys**: API 密钥管理
- **user_quotas**: 用户配额管理
- **user_preferences**: 用户偏好设置
### Provider 三层架构
- **providers**: LLM 提供商配置
- **provider_endpoints**: Provider 的 API 端点配置
- **provider_api_keys**: Endpoint 的具体 API 密钥
- **api_key_provider_mappings**: 用户 API Key 到 Provider 的映射关系
### 模型系统
- **global_models**: 统一模型定义GlobalModel
- **models**: Provider 的模型实现和价格配置
- **model_mappings**: 统一的别名与降级映射表
### 监控和追踪
- **usage**: API 使用记录
- **request_candidates**: 请求候选记录
- **provider_usage_tracking**: Provider 使用统计
- **audit_logs**: 系统审计日志
### 系统功能
- **announcements**: 系统公告
- **announcement_reads**: 公告阅读记录
- **system_configs**: 系统配置
## 从旧数据库迁移
如需从旧数据库迁移数据,请使用迁移脚本:
```bash
# 设置环境变量
export OLD_DATABASE_URL="postgresql://user:pass@old-host:5432/old_db"
export NEW_DATABASE_URL="postgresql://user:pass@new-host:5432/aether"
# 干运行(查看迁移量)
python scripts/migrate_data.py --dry-run
# 执行迁移
python scripts/migrate_data.py
# 只迁移特定表
python scripts/migrate_data.py --tables users,providers,api_keys
# 跳过大表
python scripts/migrate_data.py --skip usage,audit_logs
```
## 新数据库初始化
```bash
# 1. 运行迁移创建表结构
DATABASE_URL="postgresql://user:pass@host:5432/aether" uv run alembic upgrade head
# 2. 初始化管理员账户
python -m src.database.init_db
```
## 未来迁移
基于 `aether_baseline` 创建增量迁移:
```bash
# 修改模型后,生成新的迁移
DATABASE_URL="..." uv run alembic revision --autogenerate -m "描述变更"
# 应用迁移
DATABASE_URL="..." uv run alembic upgrade head
```