refactor: 将访问令牌功能迁移至模块系统

- 新增 management_tokens 模块定义,支持通过环境变量控制可用性
- 从静态路由中移除 management-tokens,改由模块动态注册
- 前端路由添加模块激活检查,未激活时重定向到仪表盘
- 认证服务添加模块激活检查,未激活时禁止令牌认证
- 导航菜单中移除访问令牌入口(保留直接 URL 访问)
- 修复 alembic 迁移脚本格式和错误处理
This commit is contained in:
fawney19
2026-02-05 16:32:27 +08:00
parent 440721368f
commit 5be7813ab8
8 changed files with 210 additions and 77 deletions

View File

@@ -21,11 +21,13 @@ This migration consolidates all schema changes from 2026-01-08 to 2026-01-10:
""" """
import logging import logging
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from sqlalchemy import inspect
import sqlalchemy as sa
from sqlalchemy import inspect
from sqlalchemy.dialects import postgresql
from sqlalchemy.exc import ProgrammingError
from alembic import op
# 配置日志 # 配置日志
alembic_logger = logging.getLogger("alembic.runtime.migration") alembic_logger = logging.getLogger("alembic.runtime.migration")
@@ -66,9 +68,18 @@ def upgrade() -> None:
# ========== 1. provider_api_keys: 添加 provider_id 和 api_formats ========== # ========== 1. provider_api_keys: 添加 provider_id 和 api_formats ==========
if not _column_exists("provider_api_keys", "provider_id"): if not _column_exists("provider_api_keys", "provider_id"):
op.add_column("provider_api_keys", sa.Column("provider_id", sa.String(36), nullable=True)) try:
op.add_column(
"provider_api_keys", sa.Column("provider_id", sa.String(36), nullable=True)
)
except ProgrammingError as exc:
if getattr(getattr(exc, "orig", None), "pgcode", None) == "42701":
alembic_logger.warning("provider_api_keys.provider_id already exists; skipping add")
else:
raise
# 数据迁移:从 endpoint 获取 provider_id # 数据迁移:从 endpoint 获取 provider_id(如果 endpoint_id 仍存在)
if _column_exists("provider_api_keys", "endpoint_id"):
op.execute(""" op.execute("""
UPDATE provider_api_keys k UPDATE provider_api_keys k
SET provider_id = e.provider_id SET provider_id = e.provider_id
@@ -76,63 +87,65 @@ def upgrade() -> None:
WHERE k.endpoint_id = e.id AND k.provider_id IS NULL WHERE k.endpoint_id = e.id AND k.provider_id IS NULL
""") """)
# 检查无法关联的孤儿 Key # 检查无法关联的孤儿 Key
result = bind.execute(sa.text( result = bind.execute(
"SELECT COUNT(*) FROM provider_api_keys WHERE provider_id IS NULL" sa.text("SELECT COUNT(*) FROM provider_api_keys WHERE provider_id IS NULL")
)) )
orphan_count = result.scalar() or 0 orphan_count = result.scalar() or 0
if orphan_count > 0: if orphan_count > 0:
# 使用 logger 记录更明显的告警 # 使用 logger 记录更明显的告警
alembic_logger.warning("=" * 60) alembic_logger.warning("=" * 60)
alembic_logger.warning(f"[MIGRATION WARNING] 发现 {orphan_count} 个无法关联 Provider 的孤儿 Key") alembic_logger.warning(
alembic_logger.warning("=" * 60) f"[MIGRATION WARNING] 发现 {orphan_count} 个无法关联 Provider 的孤儿 Key"
alembic_logger.info("正在备份孤儿 Key 到 _orphan_api_keys_backup 表...") )
alembic_logger.warning("=" * 60)
alembic_logger.info("正在备份孤儿 Key 到 _orphan_api_keys_backup 表...")
# 先备份孤儿数据到临时表,避免数据丢失 # 先备份孤儿数据到临时表,避免数据丢失
op.execute(""" op.execute("""
CREATE TABLE IF NOT EXISTS _orphan_api_keys_backup AS CREATE TABLE IF NOT EXISTS _orphan_api_keys_backup AS
SELECT *, NOW() as backup_at SELECT *, NOW() as backup_at
FROM provider_api_keys FROM provider_api_keys
WHERE provider_id IS NULL WHERE provider_id IS NULL
""") """)
# 记录备份的 Key ID # 记录备份的 Key ID
orphan_ids = bind.execute(sa.text( orphan_ids = bind.execute(
"SELECT id, name FROM provider_api_keys WHERE provider_id IS NULL" sa.text("SELECT id, name FROM provider_api_keys WHERE provider_id IS NULL")
)).fetchall() ).fetchall()
alembic_logger.info("备份的孤儿 Key 列表:") alembic_logger.info("备份的孤儿 Key 列表:")
for key_id, key_name in orphan_ids: for key_id, key_name in orphan_ids:
alembic_logger.info(f" - Key: {key_name} (ID: {key_id})") alembic_logger.info(f" - Key: {key_name} (ID: {key_id})")
# 删除孤儿数据 # 删除孤儿数据
op.execute("DELETE FROM provider_api_keys WHERE provider_id IS NULL") op.execute("DELETE FROM provider_api_keys WHERE provider_id IS NULL")
alembic_logger.info(f"已备份并删除 {orphan_count} 个孤儿 Key") alembic_logger.info(f"已备份并删除 {orphan_count} 个孤儿 Key")
# 提供恢复指南 # 提供恢复指南
alembic_logger.warning("-" * 60) alembic_logger.warning("-" * 60)
alembic_logger.warning("[恢复指南] 如需恢复孤儿 Key") alembic_logger.warning("[恢复指南] 如需恢复孤儿 Key")
alembic_logger.warning(" 1. 查询备份表: SELECT * FROM _orphan_api_keys_backup;") alembic_logger.warning(" 1. 查询备份表: SELECT * FROM _orphan_api_keys_backup;")
alembic_logger.warning(" 2. 确定正确的 provider_id") alembic_logger.warning(" 2. 确定正确的 provider_id")
alembic_logger.warning(" 3. 执行恢复:") alembic_logger.warning(" 3. 执行恢复:")
alembic_logger.warning(" INSERT INTO provider_api_keys (...)") alembic_logger.warning(" INSERT INTO provider_api_keys (...)")
alembic_logger.warning(" SELECT ... FROM _orphan_api_keys_backup WHERE ...;") alembic_logger.warning(" SELECT ... FROM _orphan_api_keys_backup WHERE ...;")
alembic_logger.warning("-" * 60) alembic_logger.warning("-" * 60)
# 设置 NOT NULL 并创建外键 # 设置 NOT NULL 并创建外键
op.alter_column("provider_api_keys", "provider_id", nullable=False) op.alter_column("provider_api_keys", "provider_id", nullable=False)
if not _constraint_exists("provider_api_keys", "fk_provider_api_keys_provider"): if not _constraint_exists("provider_api_keys", "fk_provider_api_keys_provider"):
op.create_foreign_key( op.create_foreign_key(
"fk_provider_api_keys_provider", "fk_provider_api_keys_provider",
"provider_api_keys", "provider_api_keys",
"providers", "providers",
["provider_id"], ["provider_id"],
["id"], ["id"],
ondelete="CASCADE", ondelete="CASCADE",
) )
if not _index_exists("provider_api_keys", "idx_provider_api_keys_provider_id"): if not _index_exists("provider_api_keys", "idx_provider_api_keys_provider_id"):
op.create_index("idx_provider_api_keys_provider_id", "provider_api_keys", ["provider_id"]) op.create_index("idx_provider_api_keys_provider_id", "provider_api_keys", ["provider_id"])
if not _column_exists("provider_api_keys", "api_formats"): if not _column_exists("provider_api_keys", "api_formats"):
op.add_column("provider_api_keys", sa.Column("api_formats", sa.JSON(), nullable=True)) op.add_column("provider_api_keys", sa.Column("api_formats", sa.JSON(), nullable=True))
@@ -149,7 +162,9 @@ def upgrade() -> None:
# 修改 endpoint_id 为可空,外键改为 SET NULL # 修改 endpoint_id 为可空,外键改为 SET NULL
if _constraint_exists("provider_api_keys", "provider_api_keys_endpoint_id_fkey"): if _constraint_exists("provider_api_keys", "provider_api_keys_endpoint_id_fkey"):
op.drop_constraint("provider_api_keys_endpoint_id_fkey", "provider_api_keys", type_="foreignkey") op.drop_constraint(
"provider_api_keys_endpoint_id_fkey", "provider_api_keys", type_="foreignkey"
)
op.alter_column("provider_api_keys", "endpoint_id", nullable=True) op.alter_column("provider_api_keys", "endpoint_id", nullable=True)
# 不再重建外键,因为后面会删除这个字段 # 不再重建外键,因为后面会删除这个字段
@@ -239,10 +254,14 @@ def upgrade() -> None:
op.alter_column("provider_api_keys", "max_concurrent", new_column_name="rpm_limit") op.alter_column("provider_api_keys", "max_concurrent", new_column_name="rpm_limit")
if _column_exists("provider_api_keys", "learned_max_concurrent"): if _column_exists("provider_api_keys", "learned_max_concurrent"):
op.alter_column("provider_api_keys", "learned_max_concurrent", new_column_name="learned_rpm_limit") op.alter_column(
"provider_api_keys", "learned_max_concurrent", new_column_name="learned_rpm_limit"
)
if _column_exists("provider_api_keys", "last_concurrent_peak"): if _column_exists("provider_api_keys", "last_concurrent_peak"):
op.alter_column("provider_api_keys", "last_concurrent_peak", new_column_name="last_rpm_peak") op.alter_column(
"provider_api_keys", "last_concurrent_peak", new_column_name="last_rpm_peak"
)
# 删除废弃字段 # 删除废弃字段
for col in ["rate_limit", "daily_limit", "monthly_limit"]: for col in ["rate_limit", "daily_limit", "monthly_limit"]:
@@ -431,7 +450,9 @@ def downgrade() -> None:
# 11. 恢复 provider_endpoints.max_concurrent # 11. 恢复 provider_endpoints.max_concurrent
if not _column_exists("provider_endpoints", "max_concurrent"): if not _column_exists("provider_endpoints", "max_concurrent"):
op.add_column("provider_endpoints", sa.Column("max_concurrent", sa.Integer(), nullable=True)) op.add_column(
"provider_endpoints", sa.Column("max_concurrent", sa.Integer(), nullable=True)
)
# 10. 恢复 endpoint_id # 10. 恢复 endpoint_id
if not _column_exists("provider_api_keys", "endpoint_id"): if not _column_exists("provider_api_keys", "endpoint_id"):
@@ -457,9 +478,13 @@ def downgrade() -> None:
if _column_exists("provider_api_keys", "rpm_limit"): if _column_exists("provider_api_keys", "rpm_limit"):
op.alter_column("provider_api_keys", "rpm_limit", new_column_name="max_concurrent") op.alter_column("provider_api_keys", "rpm_limit", new_column_name="max_concurrent")
if _column_exists("provider_api_keys", "learned_rpm_limit"): if _column_exists("provider_api_keys", "learned_rpm_limit"):
op.alter_column("provider_api_keys", "learned_rpm_limit", new_column_name="learned_max_concurrent") op.alter_column(
"provider_api_keys", "learned_rpm_limit", new_column_name="learned_max_concurrent"
)
if _column_exists("provider_api_keys", "last_rpm_peak"): if _column_exists("provider_api_keys", "last_rpm_peak"):
op.alter_column("provider_api_keys", "last_rpm_peak", new_column_name="last_concurrent_peak") op.alter_column(
"provider_api_keys", "last_rpm_peak", new_column_name="last_concurrent_peak"
)
# 恢复已删除的字段 # 恢复已删除的字段
if not _column_exists("provider_api_keys", "rate_limit"): if not _column_exists("provider_api_keys", "rate_limit"):
@@ -491,12 +516,12 @@ def downgrade() -> None:
op.drop_column("providers", "timeout") op.drop_column("providers", "timeout")
# 3. models: global_model_id 改回 NOT NULL # 3. models: global_model_id 改回 NOT NULL
result = bind.execute(sa.text( result = bind.execute(sa.text("SELECT COUNT(*) FROM models WHERE global_model_id IS NULL"))
"SELECT COUNT(*) FROM models WHERE global_model_id IS NULL"
))
orphan_model_count = result.scalar() or 0 orphan_model_count = result.scalar() or 0
if orphan_model_count > 0: if orphan_model_count > 0:
alembic_logger.warning(f"[WARN] 发现 {orphan_model_count} 个无 global_model_id 的独立模型,将被删除") alembic_logger.warning(
f"[WARN] 发现 {orphan_model_count} 个无 global_model_id 的独立模型,将被删除"
)
op.execute("DELETE FROM models WHERE global_model_id IS NULL") op.execute("DELETE FROM models WHERE global_model_id IS NULL")
alembic_logger.info(f"已删除 {orphan_model_count} 个独立模型") alembic_logger.info(f"已删除 {orphan_model_count} 个独立模型")
op.alter_column("models", "global_model_id", nullable=False) op.alter_column("models", "global_model_id", nullable=False)

View File

@@ -493,7 +493,6 @@ const navigation = computed(() => {
items: [ items: [
{ name: '模型目录', href: '/dashboard/models', icon: Box }, { name: '模型目录', href: '/dashboard/models', icon: Box },
{ name: 'API 密钥', href: '/dashboard/api-keys', icon: Key }, { name: 'API 密钥', href: '/dashboard/api-keys', icon: Key },
{ name: '访问令牌', href: '/dashboard/management-tokens', icon: KeyRound },
] ]
}, },
{ {
@@ -517,6 +516,7 @@ const navigation = computed(() => {
// 图标映射 // 图标映射
const iconMap: Record<string, LucideIcon> = { const iconMap: Record<string, LucideIcon> = {
Key, Key,
KeyRound,
FileUp, FileUp,
Shield, Shield,
Puzzle, Puzzle,
@@ -556,7 +556,6 @@ const navigation = computed(() => {
{ name: '提供商', href: '/admin/providers', icon: FolderTree }, { name: '提供商', href: '/admin/providers', icon: FolderTree },
{ name: '模型管理', href: '/admin/models', icon: Layers }, { name: '模型管理', href: '/admin/models', icon: Layers },
{ name: '独立密钥', href: '/admin/keys', icon: Key }, { name: '独立密钥', href: '/admin/keys', icon: Key },
{ name: '访问令牌', href: '/admin/management-tokens', icon: KeyRound },
{ name: '异步任务', href: '/admin/async-tasks', icon: Zap }, { name: '异步任务', href: '/admin/async-tasks', icon: Zap },
{ name: '使用记录', href: '/admin/usage', icon: BarChart3 }, { name: '使用记录', href: '/admin/usage', icon: BarChart3 },
] ]

View File

@@ -87,7 +87,8 @@ const routes: RouteRecordRaw[] = [
{ {
path: 'management-tokens', path: 'management-tokens',
name: 'ManagementTokens', name: 'ManagementTokens',
component: () => importWithRetry(() => import('@/views/user/ManagementTokens.vue')) component: () => importWithRetry(() => import('@/views/user/ManagementTokens.vue')),
meta: { module: 'management_tokens' }
}, },
{ {
path: 'announcements', path: 'announcements',
@@ -139,7 +140,8 @@ const routes: RouteRecordRaw[] = [
{ {
path: 'management-tokens', path: 'management-tokens',
name: 'AdminManagementTokens', name: 'AdminManagementTokens',
component: () => importWithRetry(() => import('@/views/user/ManagementTokens.vue')) component: () => importWithRetry(() => import('@/views/user/ManagementTokens.vue')),
meta: { module: 'management_tokens' }
}, },
{ {
path: 'providers', path: 'providers',
@@ -329,6 +331,26 @@ router.beforeEach(async (to, from, next) => {
} }
next() next()
} }
} else if (moduleName) {
// 非管理员页面但需要模块的路由(如用户侧访问令牌)
// 确保模块状态已加载
if (!moduleStore.loaded) {
try {
await moduleStore.fetchModules()
} catch (error) {
// fail-close: 获取模块状态失败时拒绝访问
log.warn('Failed to fetch modules status, denying access', { error })
next('/dashboard')
return
}
}
// 用户侧需要检查模块是否激活active而不仅仅是可用available
if (!moduleStore.isActive(moduleName)) {
log.warn(`Module ${moduleName} is not active, redirecting to user dashboard`)
next('/dashboard')
return
}
next()
} else { } else {
next() next()
} }

View File

@@ -6,14 +6,13 @@ from .adaptive import router as adaptive_router
from .api_keys import router as api_keys_router from .api_keys import router as api_keys_router
from .billing import router as billing_router from .billing import router as billing_router
from .endpoints import router as endpoints_router from .endpoints import router as endpoints_router
from .management_tokens import router as management_tokens_router
from .models import router as models_router from .models import router as models_router
from .modules import router as modules_router from .modules import router as modules_router
from .monitoring import router as monitoring_router from .monitoring import router as monitoring_router
from .provider_oauth import router as provider_oauth_router
from .provider_ops import router as provider_ops_router from .provider_ops import router as provider_ops_router
from .provider_query import router as provider_query_router 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 .provider_oauth import router as provider_oauth_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 .stats import router as stats_router
@@ -38,12 +37,12 @@ router.include_router(models_router)
router.include_router(security_router) router.include_router(security_router)
router.include_router(stats_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(modules_router) router.include_router(modules_router)
router.include_router(provider_ops_router) router.include_router(provider_ops_router)
router.include_router(video_tasks_router) router.include_router(video_tasks_router)
# 注意:ldap_router 已迁移到模块系统,由 ModuleRegistry 动态注册 # 注意:以下路由已迁移到模块系统,由 ModuleRegistry 动态注册
# 当 LDAP_AVAILABLE=true 时才会注册路由 # - ldap_router: 当 LDAP_AVAILABLE=true 时注册
# - management_tokens_router: 当 MANAGEMENT_TOKENS_AVAILABLE=true 时注册
__all__ = ["router"] __all__ = ["router"]

View File

@@ -2,11 +2,12 @@
from fastapi import APIRouter from fastapi import APIRouter
from .management_tokens import router as management_tokens_router
from .routes import router as me_router from .routes import router as me_router
router = APIRouter() router = APIRouter()
router.include_router(me_router) router.include_router(me_router)
router.include_router(management_tokens_router)
# 注意management_tokens_router 已迁移到模块系统,由 ModuleRegistry 动态注册
# 当 MANAGEMENT_TOKENS_AVAILABLE=true 时注册
__all__ = ["router"] __all__ = ["router"]

View File

@@ -9,6 +9,7 @@ from src.core.modules.base import ModuleDefinition
# 导入所有模块定义 # 导入所有模块定义
from src.modules.gemini_files import gemini_files_module from src.modules.gemini_files import gemini_files_module
from src.modules.ldap import ldap_module from src.modules.ldap import ldap_module
from src.modules.management_tokens import management_tokens_module
from src.modules.oauth import oauth_module from src.modules.oauth import oauth_module
# 所有模块列表 # 所有模块列表
@@ -16,6 +17,7 @@ ALL_MODULES: list[ModuleDefinition] = [
ldap_module, ldap_module,
oauth_module, oauth_module,
gemini_files_module, gemini_files_module,
management_tokens_module,
] ]
__all__ = ["ALL_MODULES"] __all__ = ["ALL_MODULES"]

View File

@@ -0,0 +1,78 @@
"""
访问令牌模块
提供管理 API 访问令牌的功能
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from fastapi import APIRouter
from src.core.modules.base import (
ModuleCategory,
ModuleDefinition,
ModuleHealth,
ModuleMetadata,
)
if TYPE_CHECKING:
from sqlalchemy.orm import Session
def _get_router() -> Any:
"""延迟导入路由(避免启动时加载重依赖)
返回一个合并了管理员和用户两个路由的 APIRouter
- /api/admin/management-tokens: 管理员管理所有用户的令牌
- /api/me/management-tokens: 用户管理自己的令牌
"""
from src.api.admin.management_tokens import router as admin_router
from src.api.user_me.management_tokens import router as user_router
# 创建一个组合路由器
combined_router = APIRouter()
combined_router.include_router(admin_router)
combined_router.include_router(user_router)
return combined_router
async def _health_check() -> ModuleHealth:
"""健康检查 - 简化版,不依赖数据库连接"""
return ModuleHealth.HEALTHY
def _validate_config(db: Session) -> tuple[bool, str]:
"""
验证配置是否可以启用模块
访问令牌模块没有特殊配置要求,始终可用
"""
return True, ""
# 访问令牌模块定义
management_tokens_module = ModuleDefinition(
metadata=ModuleMetadata(
name="management_tokens",
display_name="访问令牌",
description="管理 API 访问令牌,支持细粒度权限控制和 IP 白名单",
category=ModuleCategory.SECURITY,
# 可用性控制
env_key="MANAGEMENT_TOKENS_AVAILABLE",
default_available=True,
required_packages=[],
# 路由配置
api_prefix="/api/admin/management-tokens, /api/me/management-tokens",
# 前端配置 - 不在导航栏显示,但保留路由用于直接访问
admin_route="/admin/management-tokens",
admin_menu_icon=None,
admin_menu_group=None, # 不显示在导航菜单中
admin_menu_order=0,
),
router_factory=_get_router,
health_check=_health_check,
validate_config=_validate_config,
)

View File

@@ -614,10 +614,17 @@ class AuthService:
RateLimitException: 超过速率限制时抛出(用于返回 429 RateLimitException: 超过速率限制时抛出(用于返回 429
""" """
from src.core.exceptions import RateLimitException from src.core.exceptions import RateLimitException
from src.core.modules import get_module_registry
from src.models.database import AuditEventType, ManagementToken from src.models.database import AuditEventType, ManagementToken
from src.services.rate_limit.ip_limiter import IPRateLimiter from src.services.rate_limit.ip_limiter import IPRateLimiter
from src.services.system.audit import AuditService from src.services.system.audit import AuditService
# 检查访问令牌模块是否激活
module_registry = get_module_registry()
if not module_registry.is_active("management_tokens", db):
logger.warning("Management Token 认证失败 - 访问令牌模块未激活")
return None
# 速率限制检查(防止暴力破解) # 速率限制检查(防止暴力破解)
allowed, remaining, ttl = await IPRateLimiter.check_limit( allowed, remaining, ttl = await IPRateLimiter.check_limit(
client_ip, client_ip,