2025-12-10 20:52:44 +08:00
|
|
|
|
"""
|
|
|
|
|
|
API密钥管理服务
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
2026-01-30 14:30:57 +08:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
from datetime import datetime, timedelta, timezone
|
2026-01-30 03:10:21 +08:00
|
|
|
|
from typing import Any
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
from sqlalchemy import func
|
|
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
|
|
|
|
|
|
|
|
from src.core.crypto import crypto_service
|
|
|
|
|
|
from src.core.logger import logger
|
2026-01-30 03:10:21 +08:00
|
|
|
|
from src.models.database import ApiKey, Usage
|
2026-03-08 14:31:15 +08:00
|
|
|
|
from src.services.user.bulk_cleanup import pre_clean_api_key
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ApiKeyService:
|
|
|
|
|
|
"""API密钥管理服务"""
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def create_api_key(
|
|
|
|
|
|
db: Session,
|
|
|
|
|
|
user_id: str, # UUID
|
2026-01-30 03:10:21 +08:00
|
|
|
|
name: str | None = None,
|
|
|
|
|
|
allowed_providers: list[str] | None = None,
|
|
|
|
|
|
allowed_api_formats: list[str] | None = None,
|
|
|
|
|
|
allowed_models: list[str] | None = None,
|
|
|
|
|
|
rate_limit: int | None = None,
|
2025-12-10 20:52:44 +08:00
|
|
|
|
concurrent_limit: int = 5,
|
2026-01-30 03:10:21 +08:00
|
|
|
|
expire_days: int | None = None,
|
|
|
|
|
|
expires_at: datetime | None = None, # 直接传入过期时间,优先于 expire_days
|
2025-12-10 20:52:44 +08:00
|
|
|
|
is_standalone: bool = False,
|
|
|
|
|
|
auto_delete_on_expiry: bool = False,
|
|
|
|
|
|
) -> tuple[ApiKey, str]:
|
|
|
|
|
|
"""创建新的API密钥,返回密钥对象和明文密钥
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
db: 数据库会话
|
|
|
|
|
|
user_id: 用户ID
|
|
|
|
|
|
name: 密钥名称
|
|
|
|
|
|
allowed_providers: 允许的提供商列表
|
|
|
|
|
|
allowed_api_formats: 允许的 API 格式列表
|
|
|
|
|
|
allowed_models: 允许的模型列表
|
|
|
|
|
|
rate_limit: 速率限制
|
|
|
|
|
|
concurrent_limit: 并发限制
|
|
|
|
|
|
expire_days: 过期天数,None = 永不过期
|
2026-01-05 02:16:16 +08:00
|
|
|
|
expires_at: 直接指定过期时间,优先于 expire_days
|
2025-12-10 20:52:44 +08:00
|
|
|
|
is_standalone: 是否为独立余额Key(仅管理员可创建)
|
|
|
|
|
|
auto_delete_on_expiry: 过期后是否自动删除(True=物理删除,False=仅禁用)
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
# 生成密钥
|
|
|
|
|
|
key = ApiKey.generate_key()
|
|
|
|
|
|
key_hash = ApiKey.hash_key(key)
|
|
|
|
|
|
key_encrypted = crypto_service.encrypt(key) # 加密存储密钥
|
|
|
|
|
|
|
2026-01-05 02:16:16 +08:00
|
|
|
|
# 计算过期时间:优先使用 expires_at,其次使用 expire_days
|
|
|
|
|
|
final_expires_at = expires_at
|
|
|
|
|
|
if final_expires_at is None and expire_days:
|
|
|
|
|
|
final_expires_at = datetime.now(timezone.utc) + timedelta(days=expire_days)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-03-15 14:22:59 +08:00
|
|
|
|
normalized_rate_limit = rate_limit
|
|
|
|
|
|
if not is_standalone and normalized_rate_limit is None:
|
|
|
|
|
|
normalized_rate_limit = 0
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
api_key = ApiKey(
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
key_hash=key_hash,
|
|
|
|
|
|
key_encrypted=key_encrypted,
|
|
|
|
|
|
name=name or f"API Key {datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}",
|
2026-03-12 01:11:35 +08:00
|
|
|
|
allowed_providers=allowed_providers,
|
|
|
|
|
|
allowed_api_formats=allowed_api_formats,
|
|
|
|
|
|
allowed_models=allowed_models,
|
2026-03-15 14:22:59 +08:00
|
|
|
|
rate_limit=normalized_rate_limit,
|
2025-12-10 20:52:44 +08:00
|
|
|
|
concurrent_limit=concurrent_limit,
|
2026-01-05 02:16:16 +08:00
|
|
|
|
expires_at=final_expires_at,
|
2025-12-10 20:52:44 +08:00
|
|
|
|
is_standalone=is_standalone,
|
|
|
|
|
|
auto_delete_on_expiry=auto_delete_on_expiry,
|
|
|
|
|
|
is_active=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
db.add(api_key)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
db.refresh(api_key)
|
|
|
|
|
|
|
2026-02-01 17:28:00 +08:00
|
|
|
|
logger.info(
|
2026-03-08 14:31:15 +08:00
|
|
|
|
f"创建API密钥: 用户ID {user_id}, 密钥名 {api_key.name}, " f"独立Key={is_standalone}"
|
2026-02-01 17:28:00 +08:00
|
|
|
|
)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
return api_key, key # 返回密钥对象和明文密钥
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
2026-01-30 03:10:21 +08:00
|
|
|
|
def get_api_key(db: Session, key_id: str) -> ApiKey | None: # UUID
|
2025-12-10 20:52:44 +08:00
|
|
|
|
"""获取API密钥"""
|
|
|
|
|
|
return db.query(ApiKey).filter(ApiKey.id == key_id).first()
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
2026-01-30 03:10:21 +08:00
|
|
|
|
def get_api_key_by_key(db: Session, key: str) -> ApiKey | None:
|
2025-12-10 20:52:44 +08:00
|
|
|
|
"""通过密钥字符串获取API密钥"""
|
|
|
|
|
|
key_hash = ApiKey.hash_key(key)
|
|
|
|
|
|
return db.query(ApiKey).filter(ApiKey.key_hash == key_hash).first()
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def list_user_api_keys(
|
2026-01-30 03:10:21 +08:00
|
|
|
|
db: Session, user_id: str, is_active: bool | None = None # UUID
|
|
|
|
|
|
) -> list[ApiKey]:
|
2025-12-10 20:52:44 +08:00
|
|
|
|
"""列出用户的所有API密钥(不包括独立Key)"""
|
|
|
|
|
|
query = db.query(ApiKey).filter(
|
|
|
|
|
|
ApiKey.user_id == user_id, ApiKey.is_standalone == False # 排除独立Key
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if is_active is not None:
|
|
|
|
|
|
query = query.filter(ApiKey.is_active == is_active)
|
|
|
|
|
|
|
|
|
|
|
|
return query.order_by(ApiKey.created_at.desc()).all()
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
2026-01-30 03:10:21 +08:00
|
|
|
|
def list_standalone_api_keys(db: Session, is_active: bool | None = None) -> list[ApiKey]:
|
2025-12-10 20:52:44 +08:00
|
|
|
|
"""列出所有独立余额Key(仅管理员可用)"""
|
|
|
|
|
|
query = db.query(ApiKey).filter(ApiKey.is_standalone == True)
|
|
|
|
|
|
|
|
|
|
|
|
if is_active is not None:
|
|
|
|
|
|
query = query.filter(ApiKey.is_active == is_active)
|
|
|
|
|
|
|
|
|
|
|
|
return query.order_by(ApiKey.created_at.desc()).all()
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
2026-01-30 14:30:57 +08:00
|
|
|
|
def update_api_key(db: Session, key_id: str, **kwargs: Any) -> ApiKey | None: # UUID
|
2025-12-10 20:52:44 +08:00
|
|
|
|
"""更新API密钥"""
|
|
|
|
|
|
api_key = db.query(ApiKey).filter(ApiKey.id == key_id).first()
|
|
|
|
|
|
if not api_key:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
# 可更新的字段
|
|
|
|
|
|
updatable_fields = [
|
|
|
|
|
|
"name",
|
|
|
|
|
|
"allowed_providers",
|
|
|
|
|
|
"allowed_api_formats",
|
|
|
|
|
|
"allowed_models",
|
|
|
|
|
|
"rate_limit",
|
|
|
|
|
|
"concurrent_limit",
|
|
|
|
|
|
"is_active",
|
|
|
|
|
|
"expires_at",
|
|
|
|
|
|
"auto_delete_on_expiry",
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-03-12 01:11:35 +08:00
|
|
|
|
# 允许显式设置为空数组/None 的字段(NULL=不限制,[]=全部禁用)
|
2025-12-24 22:35:30 +08:00
|
|
|
|
nullable_list_fields = {"allowed_providers", "allowed_api_formats", "allowed_models"}
|
|
|
|
|
|
|
2026-03-15 14:22:59 +08:00
|
|
|
|
# 允许显式设置为 None 的字段(如 expires_at=None 表示永不过期;
|
|
|
|
|
|
# standalone rate_limit=None 表示继承系统默认)
|
2026-01-05 02:16:16 +08:00
|
|
|
|
nullable_fields = {"expires_at", "rate_limit"}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
for field, value in kwargs.items():
|
2025-12-24 22:35:30 +08:00
|
|
|
|
if field not in updatable_fields:
|
|
|
|
|
|
continue
|
2026-03-12 01:11:35 +08:00
|
|
|
|
# 对于 nullable_list_fields,保留 None/[] 的语义差异
|
2025-12-24 22:35:30 +08:00
|
|
|
|
if field in nullable_list_fields:
|
2026-03-12 01:11:35 +08:00
|
|
|
|
setattr(api_key, field, value)
|
2026-01-05 02:16:16 +08:00
|
|
|
|
elif field in nullable_fields:
|
2026-03-15 14:22:59 +08:00
|
|
|
|
if field == "rate_limit" and not api_key.is_standalone and value is None:
|
|
|
|
|
|
setattr(api_key, field, 0)
|
|
|
|
|
|
continue
|
2026-01-05 02:16:16 +08:00
|
|
|
|
setattr(api_key, field, value)
|
2025-12-24 22:35:30 +08:00
|
|
|
|
elif value is not None:
|
2025-12-10 20:52:44 +08:00
|
|
|
|
setattr(api_key, field, value)
|
|
|
|
|
|
|
|
|
|
|
|
api_key.updated_at = datetime.now(timezone.utc)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
db.refresh(api_key)
|
|
|
|
|
|
|
|
|
|
|
|
logger.debug(f"更新API密钥: ID {key_id}")
|
|
|
|
|
|
return api_key
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def delete_api_key(db: Session, key_id: str) -> bool: # UUID
|
|
|
|
|
|
"""删除API密钥(禁用)"""
|
|
|
|
|
|
api_key = db.query(ApiKey).filter(ApiKey.id == key_id).first()
|
|
|
|
|
|
if not api_key:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
api_key.is_active = False
|
|
|
|
|
|
api_key.updated_at = datetime.now(timezone.utc)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"删除API密钥: ID {key_id}")
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def cleanup_expired_keys(db: Session, auto_delete: bool = False) -> int:
|
|
|
|
|
|
"""清理过期的API密钥
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
db: 数据库会话
|
|
|
|
|
|
auto_delete: 全局默认行为(True=物理删除,False=仅禁用)
|
|
|
|
|
|
单个Key的 auto_delete_on_expiry 字段会覆盖此设置
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
int: 清理的密钥数量
|
|
|
|
|
|
"""
|
|
|
|
|
|
now = datetime.now(timezone.utc)
|
|
|
|
|
|
expired_keys = (
|
|
|
|
|
|
db.query(ApiKey)
|
|
|
|
|
|
.filter(ApiKey.expires_at <= now, ApiKey.is_active == True) # 只处理仍然活跃的
|
|
|
|
|
|
.all()
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
count = 0
|
|
|
|
|
|
for api_key in expired_keys:
|
|
|
|
|
|
# 优先使用Key自身的auto_delete_on_expiry设置,否则使用全局设置
|
|
|
|
|
|
should_delete = (
|
|
|
|
|
|
api_key.auto_delete_on_expiry
|
|
|
|
|
|
if api_key.auto_delete_on_expiry is not None
|
|
|
|
|
|
else auto_delete
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if should_delete:
|
2026-03-08 14:31:15 +08:00
|
|
|
|
# 物理删除(Usage / RequestCandidate / VideoTask 等记录保留)
|
|
|
|
|
|
pre_clean_api_key(db, api_key.id)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
db.delete(api_key)
|
2026-02-01 17:28:00 +08:00
|
|
|
|
logger.info(
|
|
|
|
|
|
f"删除过期API密钥: ID {api_key.id}, 名称 {api_key.name}, "
|
|
|
|
|
|
f"过期时间 {api_key.expires_at}"
|
|
|
|
|
|
)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
else:
|
|
|
|
|
|
# 仅禁用
|
|
|
|
|
|
api_key.is_active = False
|
|
|
|
|
|
api_key.updated_at = now
|
2026-02-01 17:28:00 +08:00
|
|
|
|
logger.info(
|
|
|
|
|
|
f"禁用过期API密钥: ID {api_key.id}, 名称 {api_key.name}, "
|
|
|
|
|
|
f"过期时间 {api_key.expires_at}"
|
|
|
|
|
|
)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
count += 1
|
|
|
|
|
|
|
|
|
|
|
|
if count > 0:
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
return count
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def get_api_key_stats(
|
|
|
|
|
|
db: Session,
|
|
|
|
|
|
key_id: str, # UUID
|
2026-01-30 03:10:21 +08:00
|
|
|
|
start_date: datetime | None = None,
|
|
|
|
|
|
end_date: datetime | None = None,
|
|
|
|
|
|
) -> dict[str, Any]:
|
2025-12-10 20:52:44 +08:00
|
|
|
|
"""获取API密钥使用统计"""
|
|
|
|
|
|
|
|
|
|
|
|
api_key = db.query(ApiKey).filter(ApiKey.id == key_id).first()
|
|
|
|
|
|
if not api_key:
|
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
|
|
query = db.query(Usage).filter(Usage.api_key_id == key_id)
|
|
|
|
|
|
|
|
|
|
|
|
if start_date:
|
|
|
|
|
|
query = query.filter(Usage.created_at >= start_date)
|
|
|
|
|
|
if end_date:
|
|
|
|
|
|
query = query.filter(Usage.created_at <= end_date)
|
|
|
|
|
|
|
|
|
|
|
|
# 统计数据
|
|
|
|
|
|
stats = db.query(
|
|
|
|
|
|
func.count(Usage.id).label("requests"),
|
|
|
|
|
|
func.sum(Usage.total_tokens).label("tokens"),
|
|
|
|
|
|
func.sum(Usage.total_cost_usd).label("cost_usd"),
|
|
|
|
|
|
func.avg(Usage.response_time_ms).label("avg_response_time"),
|
|
|
|
|
|
).filter(Usage.api_key_id == key_id)
|
|
|
|
|
|
|
|
|
|
|
|
if start_date:
|
|
|
|
|
|
stats = stats.filter(Usage.created_at >= start_date)
|
|
|
|
|
|
if end_date:
|
|
|
|
|
|
stats = stats.filter(Usage.created_at <= end_date)
|
|
|
|
|
|
|
|
|
|
|
|
result = stats.first()
|
|
|
|
|
|
|
|
|
|
|
|
# 按天统计
|
|
|
|
|
|
daily_stats = db.query(
|
|
|
|
|
|
func.date(Usage.created_at).label("date"),
|
|
|
|
|
|
func.count(Usage.id).label("requests"),
|
|
|
|
|
|
func.sum(Usage.total_tokens).label("tokens"),
|
|
|
|
|
|
func.sum(Usage.total_cost_usd).label("cost_usd"),
|
|
|
|
|
|
).filter(Usage.api_key_id == key_id)
|
|
|
|
|
|
|
|
|
|
|
|
if start_date:
|
|
|
|
|
|
daily_stats = daily_stats.filter(Usage.created_at >= start_date)
|
|
|
|
|
|
if end_date:
|
|
|
|
|
|
daily_stats = daily_stats.filter(Usage.created_at <= end_date)
|
|
|
|
|
|
|
|
|
|
|
|
daily_stats = daily_stats.group_by(func.date(Usage.created_at)).all()
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"key_id": key_id,
|
|
|
|
|
|
"key_name": api_key.name,
|
|
|
|
|
|
"total_requests": result.requests or 0,
|
|
|
|
|
|
"total_tokens": result.tokens or 0,
|
|
|
|
|
|
"total_cost_usd": float(result.cost_usd or 0),
|
|
|
|
|
|
"avg_response_time_ms": float(result.avg_response_time or 0),
|
|
|
|
|
|
"daily_stats": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"date": stat.date.isoformat() if stat.date else None,
|
|
|
|
|
|
"requests": stat.requests,
|
|
|
|
|
|
"tokens": stat.tokens,
|
|
|
|
|
|
"cost_usd": float(stat.cost_usd),
|
|
|
|
|
|
}
|
|
|
|
|
|
for stat in daily_stats
|
|
|
|
|
|
],
|
|
|
|
|
|
}
|