Files
Aether/src/api/admin/api_keys/routes.py
fawney19 0061fc04b7 feat: 添加访问令牌管理功能并升级至 0.2.4
- 新增 Management Token(访问令牌)功能,支持创建、更新、删除和管理
- 前端添加访问令牌管理页面,支持普通用户和管理员
- 后端实现完整的令牌生命周期管理 API
- 添加数据库迁移脚本创建 management_tokens 表
- Nginx 配置添加 gzip 压缩,优化响应传输
- Dialog 组件添加 persistent 属性,防止意外关闭
- 为管理后台 API 添加详细的中文文档注释
- 简化多处类型注解,统一代码风格
2026-01-07 14:55:07 +08:00

680 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""管理员独立余额 API Key 管理路由。
独立余额Key不关联用户配额有独立余额限制用于给非注册用户使用。
"""
import os
from datetime import datetime, timedelta, timezone
from typing import Optional
from zoneinfo import ZoneInfo
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.pipeline import ApiRequestPipeline
from src.core.exceptions import InvalidRequestException, NotFoundException
from src.core.logger import logger
from src.database import get_db
from src.models.api import CreateApiKeyRequest
from src.models.database import ApiKey
from src.services.user.apikey import ApiKeyService
# 应用时区配置,默认为 Asia/Shanghai
APP_TIMEZONE = ZoneInfo(os.getenv("APP_TIMEZONE", "Asia/Shanghai"))
def parse_expiry_date(date_str: Optional[str]) -> Optional[datetime]:
"""解析过期日期字符串为 datetime 对象。
Args:
date_str: 日期字符串,支持 "YYYY-MM-DD" 或 ISO 格式
Returns:
datetime 对象(当天 23:59:59.999999,应用时区),或 None 如果输入为空
Raises:
BadRequestException: 日期格式无效
"""
if not date_str or not date_str.strip():
return None
date_str = date_str.strip()
# 尝试 YYYY-MM-DD 格式
try:
parsed_date = datetime.strptime(date_str, "%Y-%m-%d")
# 设置为当天结束时间 (23:59:59.999999,应用时区)
return parsed_date.replace(
hour=23, minute=59, second=59, microsecond=999999, tzinfo=APP_TIMEZONE
)
except ValueError:
pass
# 尝试完整 ISO 格式
try:
return datetime.fromisoformat(date_str.replace("Z", "+00:00"))
except ValueError:
pass
raise InvalidRequestException(f"无效的日期格式: {date_str},请使用 YYYY-MM-DD 格式")
router = APIRouter(prefix="/api/admin/api-keys", tags=["Admin - API Keys (Standalone)"])
pipeline = ApiRequestPipeline()
@router.get("")
async def list_standalone_api_keys(
request: Request,
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=500),
is_active: Optional[bool] = None,
db: Session = Depends(get_db),
):
"""
列出所有独立余额 API Keys
获取系统中所有独立余额 API Key 的列表。独立余额 Key 不关联用户配额,
有独立的余额限制,主要用于给非注册用户使用。
**查询参数**:
- `skip`: 跳过的记录数(分页偏移量),默认 0
- `limit`: 返回的记录数(分页限制),默认 100最大 500
- `is_active`: 可选根据启用状态筛选true/false
**返回字段**:
- `api_keys`: API Key 列表,包含 id, name, key_display, is_active, current_balance_usd,
balance_used_usd, total_requests, total_cost_usd, rate_limit, allowed_providers,
allowed_api_formats, allowed_models, last_used_at, expires_at, created_at, updated_at,
auto_delete_on_expiry 等字段
- `total`: 符合条件的总记录数
- `limit`: 当前分页限制
- `skip`: 当前分页偏移量
"""
adapter = AdminListStandaloneKeysAdapter(skip=skip, limit=limit, is_active=is_active)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("")
async def create_standalone_api_key(
request: Request,
key_data: CreateApiKeyRequest,
db: Session = Depends(get_db),
):
"""
创建独立余额 API Key
创建一个新的独立余额 API Key。独立余额 Key 必须设置初始余额限制。
**请求体字段**:
- `name`: API Key 的名称
- `initial_balance_usd`: 必需,初始余额(美元),必须大于 0
- `allowed_providers`: 可选,允许使用的提供商列表
- `allowed_api_formats`: 可选,允许使用的 API 格式列表
- `allowed_models`: 可选,允许使用的模型列表
- `rate_limit`: 可选,速率限制配置(请求数/秒)
- `expire_days`: 可选,过期天数(兼容旧版)
- `expires_at`: 可选过期时间ISO 格式或 YYYY-MM-DD 格式,优先级高于 expire_days
- `auto_delete_on_expiry`: 可选,过期后是否自动删除
**返回字段**:
- `id`: API Key ID
- `key`: 完整的 API Key仅在创建时返回一次
- `name`: API Key 名称
- `key_display`: 脱敏显示的 Key
- `is_standalone`: 是否为独立余额 Key始终为 true
- `current_balance_usd`: 当前余额
- `balance_used_usd`: 已使用余额
- `rate_limit`: 速率限制配置
- `expires_at`: 过期时间
- `created_at`: 创建时间
- `message`: 提示信息
"""
adapter = AdminCreateStandaloneKeyAdapter(key_data=key_data)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.put("/{key_id}")
async def update_api_key(
key_id: str, request: Request, key_data: CreateApiKeyRequest, db: Session = Depends(get_db)
):
"""
更新独立余额 API Key
更新指定 ID 的独立余额 API Key 的配置信息。
**路径参数**:
- `key_id`: API Key ID
**请求体字段**:
- `name`: 可选API Key 的名称
- `rate_limit`: 可选速率限制配置null 表示无限制)
- `allowed_providers`: 可选,允许使用的提供商列表
- `allowed_api_formats`: 可选,允许使用的 API 格式列表
- `allowed_models`: 可选,允许使用的模型列表
- `expire_days`: 可选,过期天数(兼容旧版)
- `expires_at`: 可选过期时间ISO 格式或 YYYY-MM-DD 格式,优先级高于 expire_daysnull 或空字符串表示永不过期)
- `auto_delete_on_expiry`: 可选,过期后是否自动删除
**返回字段**:
- `id`: API Key ID
- `name`: API Key 名称
- `key_display`: 脱敏显示的 Key
- `is_active`: 是否启用
- `current_balance_usd`: 当前余额
- `balance_used_usd`: 已使用余额
- `rate_limit`: 速率限制配置
- `expires_at`: 过期时间
- `updated_at`: 更新时间
- `message`: 提示信息
"""
adapter = AdminUpdateApiKeyAdapter(key_id=key_id, key_data=key_data)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.patch("/{key_id}")
async def toggle_api_key(key_id: str, request: Request, db: Session = Depends(get_db)):
"""
切换 API Key 启用状态
切换指定 API Key 的启用/禁用状态。
**路径参数**:
- `key_id`: API Key ID
**返回字段**:
- `id`: API Key ID
- `is_active`: 新的启用状态
- `message`: 提示信息
"""
adapter = AdminToggleApiKeyAdapter(key_id=key_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.delete("/{key_id}")
async def delete_api_key(key_id: str, request: Request, db: Session = Depends(get_db)):
"""
删除 API Key
删除指定的 API Key。此操作不可逆。
**路径参数**:
- `key_id`: API Key ID
**返回字段**:
- `message`: 提示信息
"""
adapter = AdminDeleteApiKeyAdapter(key_id=key_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.patch("/{key_id}/balance")
async def add_balance_to_key(
key_id: str,
request: Request,
db: Session = Depends(get_db),
):
"""
调整独立余额 API Key 的余额
为指定的独立余额 API Key 增加或扣除余额。
**路径参数**:
- `key_id`: API Key ID
**请求体字段**:
- `amount_usd`: 调整金额(美元),正数为充值,负数为扣除
**返回字段**:
- `id`: API Key ID
- `name`: API Key 名称
- `current_balance_usd`: 调整后的当前余额
- `balance_used_usd`: 已使用余额
- `message`: 提示信息
"""
# 从请求体获取调整金额
body = await request.json()
amount_usd = body.get("amount_usd")
# 参数校验
if amount_usd is None:
raise HTTPException(status_code=400, detail="缺少必需参数: amount_usd")
if amount_usd == 0:
raise HTTPException(status_code=400, detail="调整金额不能为 0")
# 类型校验
try:
amount_usd = float(amount_usd)
except (ValueError, TypeError):
raise HTTPException(status_code=400, detail="调整金额必须是有效数字")
# 如果是扣除操作,检查Key是否存在以及余额是否充足
if amount_usd < 0:
api_key = db.query(ApiKey).filter(ApiKey.id == key_id).first()
if not api_key:
raise HTTPException(status_code=404, detail="API密钥不存在")
if not api_key.is_standalone:
raise HTTPException(status_code=400, detail="只能为独立余额Key调整余额")
if api_key.current_balance_usd is not None:
if abs(amount_usd) > api_key.current_balance_usd:
raise HTTPException(
status_code=400,
detail=f"扣除金额 ${abs(amount_usd):.2f} 超过当前余额 ${api_key.current_balance_usd:.2f}",
)
adapter = AdminAddBalanceAdapter(key_id=key_id, amount_usd=amount_usd)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/{key_id}")
async def get_api_key_detail(
key_id: str,
request: Request,
include_key: bool = Query(False, description="Include full decrypted key in response"),
db: Session = Depends(get_db),
):
"""
获取 API Key 详情
获取指定 API Key 的详细信息。可选择是否返回完整的解密密钥。
**路径参数**:
- `key_id`: API Key ID
**查询参数**:
- `include_key`: 是否包含完整的解密密钥,默认 false
**返回字段**:
- 当 include_key=false 时返回基本信息id, user_id, name, key_display, is_active,
is_standalone, current_balance_usd, balance_used_usd, total_requests, total_cost_usd,
rate_limit, allowed_providers, allowed_api_formats, allowed_models, last_used_at,
expires_at, created_at, updated_at
- 当 include_key=true 时返回完整密钥key
"""
if include_key:
adapter = AdminGetFullKeyAdapter(key_id=key_id)
else:
# Return basic key info without full key
adapter = AdminGetKeyDetailAdapter(key_id=key_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
class AdminListStandaloneKeysAdapter(AdminApiAdapter):
"""列出独立余额Keys"""
def __init__(
self,
skip: int,
limit: int,
is_active: Optional[bool],
):
self.skip = skip
self.limit = limit
self.is_active = is_active
async def handle(self, context): # type: ignore[override]
db = context.db
# 只查询独立余额Keys
query = db.query(ApiKey).filter(ApiKey.is_standalone == True)
if self.is_active is not None:
query = query.filter(ApiKey.is_active == self.is_active)
total = query.count()
api_keys = (
query.order_by(ApiKey.created_at.desc()).offset(self.skip).limit(self.limit).all()
)
context.add_audit_metadata(
action="list_standalone_api_keys",
filter_is_active=self.is_active,
limit=self.limit,
skip=self.skip,
total=total,
)
return {
"api_keys": [
{
"id": api_key.id,
"user_id": api_key.user_id, # 创建者ID
"name": api_key.name,
"key_display": api_key.get_display_key(),
"is_active": api_key.is_active,
"is_standalone": api_key.is_standalone,
"current_balance_usd": api_key.current_balance_usd,
"balance_used_usd": float(api_key.balance_used_usd or 0),
"total_requests": api_key.total_requests,
"total_cost_usd": float(api_key.total_cost_usd or 0),
"rate_limit": api_key.rate_limit,
"allowed_providers": api_key.allowed_providers,
"allowed_api_formats": api_key.allowed_api_formats,
"allowed_models": api_key.allowed_models,
"last_used_at": (
api_key.last_used_at.isoformat() if api_key.last_used_at else None
),
"expires_at": api_key.expires_at.isoformat() if api_key.expires_at else None,
"created_at": api_key.created_at.isoformat(),
"updated_at": api_key.updated_at.isoformat() if api_key.updated_at else None,
"auto_delete_on_expiry": api_key.auto_delete_on_expiry,
}
for api_key in api_keys
],
"total": total,
"limit": self.limit,
"skip": self.skip,
}
class AdminCreateStandaloneKeyAdapter(AdminApiAdapter):
"""创建独立余额Key"""
def __init__(self, key_data: CreateApiKeyRequest):
self.key_data = key_data
async def handle(self, context): # type: ignore[override]
db = context.db
# 独立Key必须设置初始余额
if not self.key_data.initial_balance_usd or self.key_data.initial_balance_usd <= 0:
raise HTTPException(
status_code=400,
detail="创建独立余额Key必须设置有效的初始余额initial_balance_usd > 0",
)
# 独立Key需要关联到管理员用户从context获取
admin_user_id = context.user.id
# 解析过期时间(优先使用 expires_at其次使用 expire_days
expires_at_dt = parse_expiry_date(self.key_data.expires_at)
# 创建独立Key
api_key, plain_key = ApiKeyService.create_api_key(
db=db,
user_id=admin_user_id, # 关联到创建者
name=self.key_data.name,
allowed_providers=self.key_data.allowed_providers,
allowed_api_formats=self.key_data.allowed_api_formats,
allowed_models=self.key_data.allowed_models,
rate_limit=self.key_data.rate_limit, # None 表示不限制
expire_days=self.key_data.expire_days, # 兼容旧版
expires_at=expires_at_dt, # 优先使用
initial_balance_usd=self.key_data.initial_balance_usd,
is_standalone=True, # 标记为独立Key
auto_delete_on_expiry=self.key_data.auto_delete_on_expiry,
)
logger.info(f"管理员创建独立余额Key: ID {api_key.id}, 初始余额 ${self.key_data.initial_balance_usd}")
context.add_audit_metadata(
action="create_standalone_api_key",
key_id=api_key.id,
initial_balance_usd=self.key_data.initial_balance_usd,
)
return {
"id": api_key.id,
"key": plain_key, # 只在创建时返回完整密钥
"name": api_key.name,
"key_display": api_key.get_display_key(),
"is_standalone": True,
"current_balance_usd": api_key.current_balance_usd,
"balance_used_usd": 0.0,
"rate_limit": api_key.rate_limit,
"expires_at": api_key.expires_at.isoformat() if api_key.expires_at else None,
"created_at": api_key.created_at.isoformat(),
"message": "独立余额Key创建成功请妥善保存完整密钥后续将无法查看",
}
class AdminUpdateApiKeyAdapter(AdminApiAdapter):
"""更新独立余额Key"""
def __init__(self, key_id: str, key_data: CreateApiKeyRequest):
self.key_id = key_id
self.key_data = key_data
async def handle(self, context): # type: ignore[override]
db = context.db
api_key = db.query(ApiKey).filter(ApiKey.id == self.key_id).first()
if not api_key:
raise NotFoundException("API密钥不存在", "api_key")
# 构建更新数据
update_data = {}
if self.key_data.name is not None:
update_data["name"] = self.key_data.name
# rate_limit: 显式传递时更新(包括 null 表示无限制)
if "rate_limit" in self.key_data.model_fields_set:
update_data["rate_limit"] = self.key_data.rate_limit
if (
hasattr(self.key_data, "auto_delete_on_expiry")
and self.key_data.auto_delete_on_expiry is not None
):
update_data["auto_delete_on_expiry"] = self.key_data.auto_delete_on_expiry
# 访问限制配置(允许设置为空数组来清除限制)
if hasattr(self.key_data, "allowed_providers"):
update_data["allowed_providers"] = self.key_data.allowed_providers
if hasattr(self.key_data, "allowed_api_formats"):
update_data["allowed_api_formats"] = self.key_data.allowed_api_formats
if hasattr(self.key_data, "allowed_models"):
update_data["allowed_models"] = self.key_data.allowed_models
# 处理过期时间
# 优先使用 expires_at如果显式传递且有值
if self.key_data.expires_at and self.key_data.expires_at.strip():
update_data["expires_at"] = parse_expiry_date(self.key_data.expires_at)
elif "expires_at" in self.key_data.model_fields_set:
# expires_at 明确传递为 null 或空字符串,设为永不过期
update_data["expires_at"] = None
# 兼容旧版 expire_days
elif "expire_days" in self.key_data.model_fields_set:
if self.key_data.expire_days is not None and self.key_data.expire_days > 0:
update_data["expires_at"] = datetime.now(timezone.utc) + timedelta(
days=self.key_data.expire_days
)
else:
# expire_days = None/0/负数 表示永不过期
update_data["expires_at"] = None
# 使用 ApiKeyService 更新
updated_key = ApiKeyService.update_api_key(db, self.key_id, **update_data)
if not updated_key:
raise NotFoundException("更新失败", "api_key")
logger.info(f"管理员更新独立余额Key: ID {self.key_id}, 更新字段 {list(update_data.keys())}")
context.add_audit_metadata(
action="update_standalone_api_key",
key_id=self.key_id,
updated_fields=list(update_data.keys()),
)
return {
"id": updated_key.id,
"name": updated_key.name,
"key_display": updated_key.get_display_key(),
"is_active": updated_key.is_active,
"current_balance_usd": updated_key.current_balance_usd,
"balance_used_usd": float(updated_key.balance_used_usd or 0),
"rate_limit": updated_key.rate_limit,
"expires_at": updated_key.expires_at.isoformat() if updated_key.expires_at else None,
"updated_at": updated_key.updated_at.isoformat() if updated_key.updated_at else None,
"message": "API密钥已更新",
}
class AdminToggleApiKeyAdapter(AdminApiAdapter):
def __init__(self, key_id: str):
self.key_id = key_id
async def handle(self, context): # type: ignore[override]
db = context.db
api_key = db.query(ApiKey).filter(ApiKey.id == self.key_id).first()
if not api_key:
raise NotFoundException("API密钥不存在", "api_key")
api_key.is_active = not api_key.is_active
api_key.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(api_key)
logger.info(f"管理员切换API密钥状态: Key ID {self.key_id}, 新状态 {'启用' if api_key.is_active else '禁用'}")
context.add_audit_metadata(
action="toggle_api_key",
target_key_id=api_key.id,
user_id=api_key.user_id,
new_status="enabled" if api_key.is_active else "disabled",
)
return {
"id": api_key.id,
"is_active": api_key.is_active,
"message": f"API密钥已{'启用' if api_key.is_active else '禁用'}",
}
class AdminDeleteApiKeyAdapter(AdminApiAdapter):
def __init__(self, key_id: str):
self.key_id = key_id
async def handle(self, context): # type: ignore[override]
db = context.db
api_key = db.query(ApiKey).filter(ApiKey.id == self.key_id).first()
if not api_key:
raise HTTPException(status_code=404, detail="API密钥不存在")
user = api_key.user
db.delete(api_key)
db.commit()
logger.info(f"管理员删除API密钥: Key ID {self.key_id}, 用户 {user.email if user else '未知'}")
context.add_audit_metadata(
action="delete_api_key",
target_key_id=self.key_id,
user_id=user.id if user else None,
user_email=user.email if user else None,
)
return {"message": "API密钥已删除"}
class AdminAddBalanceAdapter(AdminApiAdapter):
"""为独立余额Key增加余额"""
def __init__(self, key_id: str, amount_usd: float):
self.key_id = key_id
self.amount_usd = amount_usd
async def handle(self, context): # type: ignore[override]
db = context.db
# 使用 ApiKeyService 增加余额
updated_key = ApiKeyService.add_balance(db, self.key_id, self.amount_usd)
if not updated_key:
raise NotFoundException("余额充值失败Key不存在或不是独立余额Key", "api_key")
logger.info(f"管理员为独立余额Key充值: ID {self.key_id}, 充值 ${self.amount_usd:.4f}")
context.add_audit_metadata(
action="add_balance_to_key",
key_id=self.key_id,
amount_usd=self.amount_usd,
new_current_balance=updated_key.current_balance_usd,
)
return {
"id": updated_key.id,
"name": updated_key.name,
"current_balance_usd": updated_key.current_balance_usd,
"balance_used_usd": float(updated_key.balance_used_usd or 0),
"message": f"余额充值成功,充值 ${self.amount_usd:.2f},当前余额 ${updated_key.current_balance_usd:.2f}",
}
class AdminGetFullKeyAdapter(AdminApiAdapter):
"""获取完整的API密钥"""
def __init__(self, key_id: str):
self.key_id = key_id
async def handle(self, context): # type: ignore[override]
from src.core.crypto import crypto_service
db = context.db
# 查找API密钥
api_key = db.query(ApiKey).filter(ApiKey.id == self.key_id).first()
if not api_key:
raise NotFoundException("API密钥不存在", "api_key")
# 解密完整密钥
if not api_key.key_encrypted:
raise HTTPException(status_code=400, detail="该密钥没有存储完整密钥信息")
try:
full_key = crypto_service.decrypt(api_key.key_encrypted)
except Exception as e:
logger.error(f"解密API密钥失败: Key ID {self.key_id}, 错误: {e}")
raise HTTPException(status_code=500, detail="解密密钥失败")
logger.info(f"管理员查看完整API密钥: Key ID {self.key_id}")
context.add_audit_metadata(
action="view_full_api_key",
key_id=self.key_id,
key_name=api_key.name,
)
return {
"key": full_key,
}
class AdminGetKeyDetailAdapter(AdminApiAdapter):
"""Get API key detail without full key"""
def __init__(self, key_id: str):
self.key_id = key_id
async def handle(self, context): # type: ignore[override]
db = context.db
api_key = db.query(ApiKey).filter(ApiKey.id == self.key_id).first()
if not api_key:
raise NotFoundException("API密钥不存在", "api_key")
context.add_audit_metadata(
action="get_api_key_detail",
key_id=self.key_id,
)
return {
"id": api_key.id,
"user_id": api_key.user_id,
"name": api_key.name,
"key_display": api_key.get_display_key(),
"is_active": api_key.is_active,
"is_standalone": api_key.is_standalone,
"current_balance_usd": api_key.current_balance_usd,
"balance_used_usd": float(api_key.balance_used_usd or 0),
"total_requests": api_key.total_requests,
"total_cost_usd": float(api_key.total_cost_usd or 0),
"rate_limit": api_key.rate_limit,
"allowed_providers": api_key.allowed_providers,
"allowed_api_formats": api_key.allowed_api_formats,
"allowed_models": api_key.allowed_models,
"last_used_at": api_key.last_used_at.isoformat() if api_key.last_used_at else None,
"expires_at": api_key.expires_at.isoformat() if api_key.expires_at else None,
"created_at": api_key.created_at.isoformat(),
"updated_at": api_key.updated_at.isoformat() if api_key.updated_at else None,
}