refactor: 将路由层同步 DB 操作移至线程池执行,统一认证工具函数

- 管理端和用户端路由中的同步数据库操作提取为独立函数,通过 run_in_threadpool
  在线程池中执行,避免阻塞事件循环(涉及 api_keys、payments、users、wallets、
  provider_oauth、system、user_me、wallet 等模块)
- 抽取 authenticate_user_from_bearer_token 统一 token 验证逻辑,支持
  ManagementToken 和 JWT 两种认证方式,消除多处重复代码
- key_command_service 的 CRUD 操作改为线程池执行
- maintenance_scheduler 定时任务中的数据库操作改用 asyncio.to_thread
- Dockerfile 中 aether-hub 增加 --worker-idle-timeout 0 防止空闲断连
- 新增 test_api_auth_conventions 和 test_auth_utils 单元测试
This commit is contained in:
fawney19
2026-03-12 09:33:24 +08:00
parent 8b49a3d264
commit 3f048d373f
18 changed files with 2239 additions and 1729 deletions

View File

@@ -202,7 +202,7 @@ RUN printf '%s\n' \
'environment=PYTHONUNBUFFERED=1,PYTHONIOENCODING=utf-8,LANG=C.UTF-8,LC_ALL=C.UTF-8,DOCKER_CONTAINER=true' \ 'environment=PYTHONUNBUFFERED=1,PYTHONIOENCODING=utf-8,LANG=C.UTF-8,LC_ALL=C.UTF-8,DOCKER_CONTAINER=true' \
'' \ '' \
'[program:tunnel-hub]' \ '[program:tunnel-hub]' \
'command=/usr/local/bin/aether-hub --bind 0.0.0.0:8085' \ 'command=/usr/local/bin/aether-hub --bind 0.0.0.0:8085 --worker-idle-timeout 0' \
'autostart=true' \ 'autostart=true' \
'autorestart=true' \ 'autorestart=true' \
'stdout_logfile=/dev/stdout' \ 'stdout_logfile=/dev/stdout' \

View File

@@ -213,7 +213,7 @@ RUN printf '%s\n' \
'environment=PYTHONUNBUFFERED=1,PYTHONIOENCODING=utf-8,LANG=C.UTF-8,LC_ALL=C.UTF-8,DOCKER_CONTAINER=true' \ 'environment=PYTHONUNBUFFERED=1,PYTHONIOENCODING=utf-8,LANG=C.UTF-8,LC_ALL=C.UTF-8,DOCKER_CONTAINER=true' \
'' \ '' \
'[program:tunnel-hub]' \ '[program:tunnel-hub]' \
'command=/usr/local/bin/aether-hub --bind 0.0.0.0:8085' \ 'command=/usr/local/bin/aether-hub --bind 0.0.0.0:8085 --worker-idle-timeout 0' \
'autostart=true' \ 'autostart=true' \
'autorestart=true' \ 'autorestart=true' \
'stdout_logfile=/dev/stdout' \ 'stdout_logfile=/dev/stdout' \

View File

@@ -11,6 +11,7 @@ from typing import Any, Literal
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.concurrency import run_in_threadpool
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -19,7 +20,7 @@ from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import ApiRequestPipeline from src.api.base.pipeline import ApiRequestPipeline
from src.core.exceptions import InvalidRequestException, NotFoundException from src.core.exceptions import InvalidRequestException, NotFoundException
from src.core.logger import logger from src.core.logger import logger
from src.database import get_db from src.database import get_db, get_db_context
from src.models.api import CreateApiKeyRequest from src.models.api import CreateApiKeyRequest
from src.models.database import ApiKey, Wallet from src.models.database import ApiKey, Wallet
from src.services.user.apikey import ApiKeyService from src.services.user.apikey import ApiKeyService
@@ -70,6 +71,256 @@ router = APIRouter(prefix="/api/admin/api-keys", tags=["Admin - API Keys (Standa
pipeline = ApiRequestPipeline() pipeline = ApiRequestPipeline()
def _serialize_standalone_key_item(api_key: ApiKey) -> dict[str, Any]:
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,
"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,
}
def _list_standalone_api_keys_sync(
skip: int,
limit: int,
is_active: bool | None,
) -> dict[str, Any]:
with get_db_context() as db:
query = db.query(ApiKey).filter(ApiKey.is_standalone == True)
if is_active is not None:
query = query.filter(ApiKey.is_active == is_active)
total = int(query.with_entities(func.count(ApiKey.id)).scalar() or 0)
api_keys = query.order_by(ApiKey.created_at.desc()).offset(skip).limit(limit).all()
wallet_initialized = False
for api_key in api_keys:
wallet = WalletService.get_wallet(db, api_key_id=api_key.id)
if wallet is None:
_ensure_standalone_wallet(db, api_key)
wallet_initialized = True
if wallet_initialized:
db.commit()
for api_key in api_keys:
db.refresh(api_key)
return {
"api_keys": [_serialize_standalone_key_item(api_key) for api_key in api_keys],
"total": total,
"limit": limit,
"skip": skip,
}
def _create_standalone_api_key_sync(
admin_user_id: str,
key_data: CreateApiKeyRequest,
) -> tuple[dict[str, Any], dict[str, Any]]:
with get_db_context() as db:
if key_data.initial_balance_usd is not None and key_data.initial_balance_usd <= 0:
raise HTTPException(
status_code=400,
detail="创建独立余额Key时初始余额必须大于 0或设置为 null 表示无限制)",
)
expires_at_dt = parse_expiry_date(key_data.expires_at)
api_key, plain_key = ApiKeyService.create_api_key(
db=db,
user_id=admin_user_id,
name=key_data.name,
allowed_providers=key_data.allowed_providers,
allowed_api_formats=key_data.allowed_api_formats,
allowed_models=key_data.allowed_models,
rate_limit=key_data.rate_limit,
expire_days=key_data.expire_days,
expires_at=expires_at_dt,
is_standalone=True,
auto_delete_on_expiry=key_data.auto_delete_on_expiry,
)
wallet = WalletService.initialize_api_key_wallet(
db,
api_key=api_key,
initial_balance_usd=key_data.initial_balance_usd,
unlimited=key_data.initial_balance_usd is None,
operator_id=admin_user_id,
description="独立密钥初始调账",
)
if wallet is None:
raise InvalidRequestException("独立密钥钱包初始化失败")
db.commit()
db.refresh(api_key)
wallet_summary = WalletService.serialize_wallet_summary(wallet)
return (
{
"id": api_key.id,
"key": plain_key,
"name": api_key.name,
"key_display": api_key.get_display_key(),
"is_standalone": True,
"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(),
"wallet": wallet_summary,
"message": "独立余额Key创建成功请妥善保存完整密钥后续将无法查看",
},
{
"action": "create_standalone_api_key",
"key_id": api_key.id,
"initial_balance_usd": key_data.initial_balance_usd,
},
)
def _update_standalone_api_key_sync(
key_id: str,
key_data: CreateApiKeyRequest,
) -> tuple[dict[str, Any], list[str]]:
with get_db_context() as db:
api_key = db.query(ApiKey).filter(ApiKey.id == key_id).first()
if not api_key:
raise NotFoundException("API密钥不存在", "api_key")
if not api_key.is_standalone:
raise InvalidRequestException("仅支持更新独立密钥")
update_data: dict[str, Any] = {}
if key_data.name is not None:
update_data["name"] = key_data.name
if "rate_limit" in key_data.model_fields_set:
update_data["rate_limit"] = key_data.rate_limit
if (
hasattr(key_data, "auto_delete_on_expiry")
and key_data.auto_delete_on_expiry is not None
):
update_data["auto_delete_on_expiry"] = key_data.auto_delete_on_expiry
if hasattr(key_data, "allowed_providers"):
update_data["allowed_providers"] = key_data.allowed_providers
if hasattr(key_data, "allowed_api_formats"):
update_data["allowed_api_formats"] = key_data.allowed_api_formats
if hasattr(key_data, "allowed_models"):
update_data["allowed_models"] = key_data.allowed_models
if key_data.expires_at and key_data.expires_at.strip():
update_data["expires_at"] = parse_expiry_date(key_data.expires_at)
elif "expires_at" in key_data.model_fields_set:
update_data["expires_at"] = None
elif "expire_days" in key_data.model_fields_set:
if key_data.expire_days is not None and key_data.expire_days > 0:
update_data["expires_at"] = datetime.now(timezone.utc) + timedelta(
days=key_data.expire_days
)
else:
update_data["expires_at"] = None
changed_fields = list(update_data.keys())
if "initial_balance_usd" in key_data.model_fields_set:
raise InvalidRequestException("编辑独立密钥不支持修改余额,请使用钱包操作")
if (
"unlimited_balance" in key_data.model_fields_set
and key_data.unlimited_balance is not None
):
wallet = _ensure_standalone_wallet(db, api_key)
desired_mode: Literal["finite", "unlimited"] = (
"unlimited" if key_data.unlimited_balance else "finite"
)
if wallet.limit_mode != desired_mode:
WalletService.set_wallet_limit_mode(db, wallet=wallet, limit_mode=desired_mode)
changed_fields.append("unlimited_balance")
updated_key = ApiKeyService.update_api_key(db, key_id, **update_data)
if not updated_key:
raise NotFoundException("更新失败", "api_key")
wallet = _ensure_standalone_wallet(db, updated_key)
wallet_summary = WalletService.serialize_wallet_summary(wallet)
return (
{
"id": updated_key.id,
"name": updated_key.name,
"key_display": updated_key.get_display_key(),
"is_active": updated_key.is_active,
"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
),
"wallet": wallet_summary,
"message": "API密钥已更新",
},
changed_fields,
)
def _toggle_standalone_api_key_sync(key_id: str) -> tuple[dict[str, Any], dict[str, Any]]:
with get_db_context() as db:
api_key = db.query(ApiKey).filter(ApiKey.id == key_id).first()
if not api_key:
raise NotFoundException("API密钥不存在", "api_key")
if not api_key.is_standalone:
raise InvalidRequestException("仅支持操作独立密钥")
api_key.is_active = not api_key.is_active
api_key.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(api_key)
return (
{
"id": api_key.id,
"is_active": api_key.is_active,
"message": f"API密钥已{'启用' if api_key.is_active else '禁用'}",
},
{
"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",
},
)
def _delete_standalone_api_key_sync(
key_id: str,
) -> tuple[dict[str, str], dict[str, Any], str | None]:
with get_db_context() as db:
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 InvalidRequestException("仅支持删除独立密钥")
user = api_key.user
pre_clean_api_key(db, api_key.id)
db.delete(api_key)
return (
{"message": "API密钥已删除"},
{
"action": "delete_api_key",
"target_key_id": key_id,
"user_id": user.id if user else None,
"user_email": user.email if user else None,
},
user.email if user else None,
)
def _ensure_standalone_wallet( def _ensure_standalone_wallet(
db: Session, db: Session,
api_key: ApiKey, api_key: ApiKey,
@@ -277,68 +528,20 @@ class AdminListStandaloneKeysAdapter(AdminApiAdapter):
self.is_active = is_active self.is_active = is_active
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db result = await run_in_threadpool(
# 只查询独立余额Keys _list_standalone_api_keys_sync,
query = db.query(ApiKey).filter(ApiKey.is_standalone == True) self.skip,
self.limit,
if self.is_active is not None: self.is_active,
query = query.filter(ApiKey.is_active == self.is_active)
total = int(query.with_entities(func.count(ApiKey.id)).scalar() or 0)
api_keys = (
query.order_by(ApiKey.created_at.desc()).offset(self.skip).limit(self.limit).all()
) )
# 保证返回的独立 Key 都已完成钱包初始化。
wallet_initialized = False
for api_key in api_keys:
wallet = WalletService.get_wallet(db, api_key_id=api_key.id)
if wallet is None:
_ensure_standalone_wallet(db, api_key)
wallet_initialized = True
if wallet_initialized:
db.commit()
context.request.state.tx_committed_by_route = True
for api_key in api_keys:
db.refresh(api_key)
context.add_audit_metadata( context.add_audit_metadata(
action="list_standalone_api_keys", action="list_standalone_api_keys",
filter_is_active=self.is_active, filter_is_active=self.is_active,
limit=self.limit, limit=self.limit,
skip=self.skip, skip=self.skip,
total=total, total=result["total"],
) )
return result
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,
"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): class AdminCreateStandaloneKeyAdapter(AdminApiAdapter):
@@ -348,75 +551,16 @@ class AdminCreateStandaloneKeyAdapter(AdminApiAdapter):
self.key_data = key_data self.key_data = key_data
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db result, audit_meta = await run_in_threadpool(
_create_standalone_api_key_sync,
# 独立Key支持无限制额度initial_balance_usd = null context.user.id,
if self.key_data.initial_balance_usd is not None and self.key_data.initial_balance_usd <= 0: self.key_data,
raise HTTPException(
status_code=400,
detail="创建独立余额Key时初始余额必须大于 0或设置为 null 表示无限制)",
)
# 独立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, # 优先使用
is_standalone=True, # 标记为独立Key
auto_delete_on_expiry=self.key_data.auto_delete_on_expiry,
) )
# 钱包体系:独立 Key 初始化与用户钱包初始化统一走 WalletService。
# 独立 Key 不支持充值,初始余额通过系统调账入账(仅资金流水,无充值订单)。
wallet = WalletService.initialize_api_key_wallet(
db,
api_key=api_key,
initial_balance_usd=self.key_data.initial_balance_usd,
unlimited=self.key_data.initial_balance_usd is None,
operator_id=context.user.id if context.user else None,
description="独立密钥初始调账",
)
if wallet is None:
raise InvalidRequestException("独立密钥钱包初始化失败")
db.commit()
context.request.state.tx_committed_by_route = True
db.refresh(api_key)
wallet_summary = WalletService.serialize_wallet_summary(wallet)
logger.info( logger.info(
f"管理员创建独立余额Key: ID {api_key.id}, 初始余额 ${self.key_data.initial_balance_usd}" f"管理员创建独立余额Key: ID {result['id']}, 初始余额 ${self.key_data.initial_balance_usd}"
) )
context.add_audit_metadata(**audit_meta)
context.add_audit_metadata( return result
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,
"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(),
"wallet": wallet_summary,
"message": "独立余额Key创建成功请妥善保存完整密钥后续将无法查看",
}
class AdminUpdateApiKeyAdapter(AdminApiAdapter): class AdminUpdateApiKeyAdapter(AdminApiAdapter):
@@ -427,97 +571,18 @@ class AdminUpdateApiKeyAdapter(AdminApiAdapter):
self.key_data = key_data self.key_data = key_data
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db result, changed_fields = await run_in_threadpool(
api_key = db.query(ApiKey).filter(ApiKey.id == self.key_id).first() _update_standalone_api_key_sync,
if not api_key: self.key_id,
raise NotFoundException("API密钥不存在", "api_key") self.key_data,
if not api_key.is_standalone: )
raise InvalidRequestException("仅支持更新独立密钥")
# 构建更新数据
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
# 访问限制配置NULL=不限制,空数组=[]=全部禁用)
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
changed_fields = list(update_data.keys())
# 编辑独立 Key 不允许通过此接口改余额,统一走钱包操作接口。
if "initial_balance_usd" in self.key_data.model_fields_set:
raise InvalidRequestException("编辑独立密钥不支持修改余额,请使用钱包操作")
# 允许编辑独立 Key 的额度模式(不修改余额数值)。
if (
"unlimited_balance" in self.key_data.model_fields_set
and self.key_data.unlimited_balance is not None
):
wallet = _ensure_standalone_wallet(db, api_key)
desired_mode: Literal["finite", "unlimited"] = (
"unlimited" if self.key_data.unlimited_balance else "finite"
)
if wallet.limit_mode != desired_mode:
WalletService.set_wallet_limit_mode(db, wallet=wallet, limit_mode=desired_mode)
changed_fields.append("unlimited_balance")
# 使用 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}, 更新字段 {changed_fields}") logger.info(f"管理员更新独立余额Key: ID {self.key_id}, 更新字段 {changed_fields}")
context.add_audit_metadata( context.add_audit_metadata(
action="update_standalone_api_key", action="update_standalone_api_key",
key_id=self.key_id, key_id=self.key_id,
updated_fields=changed_fields, updated_fields=changed_fields,
) )
return result
wallet = _ensure_standalone_wallet(db, updated_key)
wallet_summary = WalletService.serialize_wallet_summary(wallet)
return {
"id": updated_key.id,
"name": updated_key.name,
"key_display": updated_key.get_display_key(),
"is_active": updated_key.is_active,
"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,
"wallet": wallet_summary,
"message": "API密钥已更新",
}
class AdminToggleApiKeyAdapter(AdminApiAdapter): class AdminToggleApiKeyAdapter(AdminApiAdapter):
@@ -525,35 +590,12 @@ class AdminToggleApiKeyAdapter(AdminApiAdapter):
self.key_id = key_id self.key_id = key_id
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db result, audit_meta = await run_in_threadpool(_toggle_standalone_api_key_sync, self.key_id)
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.is_standalone:
raise InvalidRequestException("仅支持操作独立密钥")
api_key.is_active = not api_key.is_active
api_key.updated_at = datetime.now(timezone.utc)
db.commit()
context.request.state.tx_committed_by_route = True
db.refresh(api_key)
logger.info( logger.info(
f"管理员切换API密钥状态: Key ID {self.key_id}, 新状态 {'启用' if api_key.is_active else '禁用'}" f"管理员切换API密钥状态: Key ID {self.key_id}, 新状态 {'启用' if result['is_active'] else '禁用'}"
) )
context.add_audit_metadata(**audit_meta)
context.add_audit_metadata( return result
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): class AdminDeleteApiKeyAdapter(AdminApiAdapter):
@@ -561,30 +603,13 @@ class AdminDeleteApiKeyAdapter(AdminApiAdapter):
self.key_id = key_id self.key_id = key_id
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db result, audit_meta, user_email = await run_in_threadpool(
api_key = db.query(ApiKey).filter(ApiKey.id == self.key_id).first() _delete_standalone_api_key_sync,
if not api_key: self.key_id,
raise HTTPException(status_code=404, detail="API密钥不存在")
if not api_key.is_standalone:
raise InvalidRequestException("仅支持删除独立密钥")
user = api_key.user
pre_clean_api_key(db, api_key.id)
db.delete(api_key)
db.commit()
context.request.state.tx_committed_by_route = True
logger.info(
f"管理员删除API密钥: Key ID {self.key_id}, 用户 {user.email if user else '未知'}"
) )
logger.info(f"管理员删除API密钥: Key ID {self.key_id}, 用户 {user_email or '未知'}")
context.add_audit_metadata( context.add_audit_metadata(**audit_meta)
action="delete_api_key", return result
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 AdminGetFullKeyAdapter(AdminApiAdapter): class AdminGetFullKeyAdapter(AdminApiAdapter):

View File

@@ -6,6 +6,7 @@ from dataclasses import dataclass
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, Query, Request from fastapi import APIRouter, Depends, Query, Request
from fastapi.concurrency import run_in_threadpool
from pydantic import BaseModel, Field, ValidationError from pydantic import BaseModel, Field, ValidationError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -14,7 +15,7 @@ from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import ApiRequestPipeline from src.api.base.pipeline import ApiRequestPipeline
from src.api.serializers import serialize_payment_callback, serialize_payment_order from src.api.serializers import serialize_payment_callback, serialize_payment_order
from src.core.exceptions import InvalidRequestException, NotFoundException, translate_pydantic_error from src.core.exceptions import InvalidRequestException, NotFoundException, translate_pydantic_error
from src.database import get_db from src.database import get_db, get_db_context
from src.services.payment import PaymentService from src.services.payment import PaymentService
router = APIRouter(prefix="/api/admin/payments", tags=["Admin - Payments"]) router = APIRouter(prefix="/api/admin/payments", tags=["Admin - Payments"])
@@ -39,6 +40,100 @@ def _parse_payload(model_cls: type[BaseModel], payload: dict[str, Any]) -> BaseM
raise InvalidRequestException("请求数据验证失败") raise InvalidRequestException("请求数据验证失败")
def _list_payment_orders_sync(
status: str | None,
payment_method: str | None,
limit: int,
offset: int,
) -> dict[str, Any]:
with get_db_context() as db:
items, total, _changed = PaymentService.list_orders(
db,
status=status,
payment_method=payment_method,
limit=limit,
offset=offset,
)
return {
"items": [serialize_payment_order(item) for item in items],
"total": total,
"limit": limit,
"offset": offset,
}
def _get_payment_order_sync(order_id: str) -> dict[str, Any]:
with get_db_context() as db:
order = PaymentService.get_order(db, order_id=order_id)
if order is None:
raise NotFoundException("Payment order not found")
PaymentService.refresh_order_status(order)
return {"order": serialize_payment_order(order)}
def _expire_payment_order_sync(order_id: str) -> dict[str, Any]:
with get_db_context() as db:
order = PaymentService.get_order(db, order_id=order_id)
if order is None:
raise NotFoundException("Payment order not found")
try:
updated, expired = PaymentService.expire_order(
db,
order=order,
reason="admin_mark_expired",
)
except ValueError as exc:
raise InvalidRequestException(str(exc)) from exc
return {"order": serialize_payment_order(updated), "expired": expired}
def _credit_payment_order_sync(
order_id: str,
payload: AdminPaymentOrderCreditPayload,
operator_id: str | None,
) -> dict[str, Any]:
with get_db_context() as db:
order = PaymentService.get_order(db, order_id=order_id)
if order is None:
raise NotFoundException("Payment order not found")
gateway_response = dict(order.gateway_response or {})
if payload.gateway_response:
gateway_response.update(payload.gateway_response)
gateway_response["manual_credit"] = True
gateway_response["credited_by"] = operator_id
try:
updated, credited = PaymentService.credit_order(
db,
order=order,
gateway_order_id=payload.gateway_order_id,
gateway_response=gateway_response,
pay_amount=payload.pay_amount,
pay_currency=payload.pay_currency,
exchange_rate=payload.exchange_rate,
)
except ValueError as exc:
raise InvalidRequestException(str(exc)) from exc
return {"order": serialize_payment_order(updated), "credited": credited}
def _fail_payment_order_sync(order_id: str) -> dict[str, Any]:
with get_db_context() as db:
order = PaymentService.get_order(db, order_id=order_id)
if order is None:
raise NotFoundException("Payment order not found")
try:
updated = PaymentService.fail_order(
db,
order=order,
reason="admin_mark_failed",
)
except ValueError as exc:
raise InvalidRequestException(str(exc)) from exc
return {"order": serialize_payment_order(updated)}
@router.get("/orders") @router.get("/orders")
async def list_payment_orders( async def list_payment_orders(
request: Request, request: Request,
@@ -121,21 +216,13 @@ class AdminPaymentOrderListAdapter(AdminApiAdapter):
offset: int offset: int
async def handle(self, context: ApiRequestContext) -> dict[str, Any]: async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
items, total, changed = PaymentService.list_orders( return await run_in_threadpool(
context.db, _list_payment_orders_sync,
status=self.status, self.status,
payment_method=self.payment_method, self.payment_method,
limit=self.limit, self.limit,
offset=self.offset, self.offset,
) )
if changed:
context.db.commit()
return {
"items": [serialize_payment_order(item) for item in items],
"total": total,
"limit": self.limit,
"offset": self.offset,
}
@dataclass @dataclass
@@ -143,12 +230,7 @@ class AdminPaymentOrderDetailAdapter(AdminApiAdapter):
order_id: str order_id: str
async def handle(self, context: ApiRequestContext) -> dict[str, Any]: async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
order = PaymentService.get_order(context.db, order_id=self.order_id) return await run_in_threadpool(_get_payment_order_sync, self.order_id)
if order is None:
raise NotFoundException("Payment order not found")
if PaymentService.refresh_order_status(order):
context.db.commit()
return {"order": serialize_payment_order(order)}
@dataclass @dataclass
@@ -156,19 +238,7 @@ class AdminPaymentOrderExpireAdapter(AdminApiAdapter):
order_id: str order_id: str
async def handle(self, context: ApiRequestContext) -> dict[str, Any]: async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
order = PaymentService.get_order(context.db, order_id=self.order_id) return await run_in_threadpool(_expire_payment_order_sync, self.order_id)
if order is None:
raise NotFoundException("Payment order not found")
try:
updated, expired = PaymentService.expire_order(
context.db,
order=order,
reason="admin_mark_expired",
)
except ValueError as exc:
raise InvalidRequestException(str(exc))
context.db.commit()
return {"order": serialize_payment_order(updated), "expired": expired}
@dataclass @dataclass
@@ -176,33 +246,15 @@ class AdminPaymentOrderCreditAdapter(AdminApiAdapter):
order_id: str order_id: str
async def handle(self, context: ApiRequestContext) -> dict[str, Any]: async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
order = PaymentService.get_order(context.db, order_id=self.order_id)
if order is None:
raise NotFoundException("Payment order not found")
raw_payload = context.ensure_json_body() if context.raw_body else {} raw_payload = context.ensure_json_body() if context.raw_body else {}
req = _parse_payload(AdminPaymentOrderCreditPayload, raw_payload) req = _parse_payload(AdminPaymentOrderCreditPayload, raw_payload)
gateway_response = dict(order.gateway_response or {}) return await run_in_threadpool(
if req.gateway_response: _credit_payment_order_sync,
gateway_response.update(req.gateway_response) self.order_id,
gateway_response["manual_credit"] = True req,
gateway_response["credited_by"] = context.user.id if context.user else None context.user.id if context.user else None,
)
try:
updated, credited = PaymentService.credit_order(
context.db,
order=order,
gateway_order_id=req.gateway_order_id,
gateway_response=gateway_response,
pay_amount=req.pay_amount,
pay_currency=req.pay_currency,
exchange_rate=req.exchange_rate,
)
except ValueError as exc:
raise InvalidRequestException(str(exc))
context.db.commit()
return {"order": serialize_payment_order(updated), "credited": credited}
@dataclass @dataclass
@@ -210,19 +262,7 @@ class AdminPaymentOrderFailAdapter(AdminApiAdapter):
order_id: str order_id: str
async def handle(self, context: ApiRequestContext) -> dict[str, Any]: async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
order = PaymentService.get_order(context.db, order_id=self.order_id) return await run_in_threadpool(_fail_payment_order_sync, self.order_id)
if order is None:
raise NotFoundException("Payment order not found")
try:
updated = PaymentService.fail_order(
context.db,
order=order,
reason="admin_mark_failed",
)
except ValueError as exc:
raise InvalidRequestException(str(exc))
context.db.commit()
return {"order": serialize_payment_order(updated)}
@dataclass @dataclass

View File

@@ -21,11 +21,13 @@ import time
import uuid import uuid
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Literal from typing import Any, Literal
from urllib.parse import parse_qsl, urlencode, urlparse from urllib.parse import parse_qsl, urlencode, urlparse
import httpx import httpx
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from fastapi.concurrency import run_in_threadpool
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from redis.asyncio import Redis from redis.asyncio import Redis
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -37,8 +39,8 @@ from src.core.logger import logger
from src.core.provider_oauth_utils import enrich_auth_config, post_oauth_token from src.core.provider_oauth_utils import enrich_auth_config, post_oauth_token
from src.core.provider_templates.fixed_providers import FIXED_PROVIDERS from src.core.provider_templates.fixed_providers import FIXED_PROVIDERS
from src.core.provider_templates.types import ProviderType from src.core.provider_templates.types import ProviderType
from src.database import create_session from src.database import get_db_context
from src.database.database import get_db from src.database.database import create_session, get_db
from src.models.database import Provider, ProviderAPIKey, User from src.models.database import Provider, ProviderAPIKey, User
from src.services.provider.pool.config import parse_pool_config from src.services.provider.pool.config import parse_pool_config
from src.services.provider_keys.auth_type import OAUTH_AUTH_TYPES from src.services.provider_keys.auth_type import OAUTH_AUTH_TYPES
@@ -49,6 +51,49 @@ from src.utils.auth_utils import require_admin
router = APIRouter(prefix="/api/admin/provider-oauth", tags=["Provider OAuth"]) router = APIRouter(prefix="/api/admin/provider-oauth", tags=["Provider OAuth"])
def _store_completed_oauth_sync(
key_id: str,
provider_type: str,
access_token: str,
auth_config: dict[str, Any],
) -> None:
with get_db_context() as db:
key = db.query(ProviderAPIKey).filter(ProviderAPIKey.id == key_id).first()
if not key:
raise NotFoundException("Key 不存在", "key")
key.api_key = crypto_service.encrypt(access_token)
key.auth_config = crypto_service.encrypt(json.dumps(auth_config))
def _mark_refresh_failed_sync(key_id: str, reason: str) -> None:
with get_db_context() as db:
key = db.query(ProviderAPIKey).filter(ProviderAPIKey.id == key_id).first()
if not key:
raise NotFoundException("Key 不存在", "key")
key.oauth_invalid_at = datetime.now(timezone.utc)
key.oauth_invalid_reason = reason
def _store_refreshed_oauth_sync(
key_id: str,
access_token: str,
parsed_auth_config: dict[str, Any],
) -> None:
from src.services.provider.oauth_token import is_account_level_block
with get_db_context() as db:
key = db.query(ProviderAPIKey).filter(ProviderAPIKey.id == key_id).first()
if not key:
raise NotFoundException("Key 不存在", "key")
key.api_key = crypto_service.encrypt(access_token)
key.auth_config = crypto_service.encrypt(json.dumps(parsed_auth_config))
if not is_account_level_block(getattr(key, "oauth_invalid_reason", None)):
key.oauth_invalid_at = None
key.oauth_invalid_reason = None
key.is_active = True
# ============================================================================== # ==============================================================================
# Redis state storage # Redis state storage
# ============================================================================== # ==============================================================================
@@ -916,8 +961,6 @@ async def complete_oauth(
if not access_token: if not access_token:
raise InvalidRequestException("token exchange 返回缺少 access_token") raise InvalidRequestException("token exchange 返回缺少 access_token")
# store
key.api_key = crypto_service.encrypt(access_token)
auth_config: dict[str, Any] = { auth_config: dict[str, Any] = {
"provider_type": provider_type, "provider_type": provider_type,
"token_type": token.get("token_type"), "token_type": token.get("token_type"),
@@ -935,8 +978,13 @@ async def complete_oauth(
proxy_config=proxy_config, proxy_config=proxy_config,
) )
key.auth_config = crypto_service.encrypt(json.dumps(auth_config)) await run_in_threadpool(
db.commit() _store_completed_oauth_sync,
key_id,
provider_type,
access_token,
auth_config,
)
return CompleteOAuthResponse( return CompleteOAuthResponse(
provider_type=provider_type, provider_type=provider_type,
@@ -996,18 +1044,20 @@ async def refresh_oauth(
try: try:
access_token, new_cfg = await refresh_access_token(cfg, proxy_config=proxy_config) access_token, new_cfg = await refresh_access_token(cfg, proxy_config=proxy_config)
except Exception as e: except Exception as e:
key.oauth_invalid_at = datetime.now(timezone.utc) await run_in_threadpool(
key.oauth_invalid_reason = f"[REFRESH_FAILED] Token 续期失败: {e}" _mark_refresh_failed_sync,
db.commit() key_id,
f"[REFRESH_FAILED] Token 续期失败: {e}",
)
logger.warning("Kiro Key {} token 刷新失败,已标记为刷新失效: {}", key_id, e) logger.warning("Kiro Key {} token 刷新失败,已标记为刷新失效: {}", key_id, e)
raise InvalidRequestException("Kiro token refresh 失败,请检查凭据是否有效") raise InvalidRequestException("Kiro token refresh 失败,请检查凭据是否有效")
key.api_key = crypto_service.encrypt(access_token) await run_in_threadpool(
key.auth_config = crypto_service.encrypt(json.dumps(new_cfg.to_dict())) _store_refreshed_oauth_sync,
key.oauth_invalid_at = None key_id,
key.oauth_invalid_reason = None access_token,
key.is_active = True new_cfg.to_dict(),
db.commit() )
return CompleteOAuthResponse( return CompleteOAuthResponse(
provider_type=provider_type, provider_type=provider_type,
@@ -1088,13 +1138,11 @@ async def refresh_oauth(
error_reason = resp.text[:100] if resp.text else f"HTTP {resp.status_code}" error_reason = resp.text[:100] if resp.text else f"HTTP {resp.status_code}"
if resp.status_code in (400, 401, 403): if resp.status_code in (400, 401, 403):
from datetime import datetime, timezone await run_in_threadpool(
_mark_refresh_failed_sync,
key.oauth_invalid_at = datetime.now(timezone.utc) key_id,
key.oauth_invalid_reason = ( f"[REFRESH_FAILED] Token 续期失败 ({resp.status_code}): {error_reason}",
f"[REFRESH_FAILED] Token 续期失败 ({resp.status_code}): {error_reason}"
) )
db.commit()
logger.warning( logger.warning(
"Key {} OAuth token 刷新失败,已标记为刷新失效: {}", key_id, error_reason "Key {} OAuth token 刷新失败,已标记为刷新失效: {}", key_id, error_reason
) )
@@ -1115,7 +1163,6 @@ async def refresh_oauth(
if not access_token: if not access_token:
raise InvalidRequestException("token refresh 返回缺少 access_token") raise InvalidRequestException("token refresh 返回缺少 access_token")
key.api_key = crypto_service.encrypt(access_token)
parsed["token_type"] = token.get("token_type") parsed["token_type"] = token.get("token_type")
if new_refresh_token: if new_refresh_token:
parsed["refresh_token"] = new_refresh_token parsed["refresh_token"] = new_refresh_token
@@ -1138,14 +1185,7 @@ async def refresh_oauth(
key_id, key_id,
) )
key.auth_config = crypto_service.encrypt(json.dumps(parsed)) await run_in_threadpool(_store_refreshed_oauth_sync, key_id, access_token, parsed)
from src.services.provider.oauth_token import is_account_level_block
if not is_account_level_block(getattr(key, "oauth_invalid_reason", None)):
key.oauth_invalid_at = None
key.oauth_invalid_reason = None
key.is_active = True
db.commit()
return CompleteOAuthResponse( return CompleteOAuthResponse(
provider_type=provider_type, provider_type=provider_type,

View File

@@ -8,6 +8,7 @@ from dataclasses import dataclass
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.concurrency import run_in_threadpool
from pydantic import ValidationError from pydantic import ValidationError
from sqlalchemy import case, func from sqlalchemy import case, func
from sqlalchemy.orm import Session, selectinload from sqlalchemy.orm import Session, selectinload
@@ -18,7 +19,7 @@ from src.api.base.pipeline import ApiRequestPipeline
from src.config.constants import CacheTTL from src.config.constants import CacheTTL
from src.core.exceptions import InvalidRequestException, NotFoundException, translate_pydantic_error from src.core.exceptions import InvalidRequestException, NotFoundException, translate_pydantic_error
from src.core.logger import logger from src.core.logger import logger
from src.database import get_db from src.database import get_db, get_db_context
from src.models.api import SystemSettingsRequest, SystemSettingsResponse from src.models.api import SystemSettingsRequest, SystemSettingsResponse
from src.models.database import ApiKey, Provider, Usage, User from src.models.database import ApiKey, Provider, Usage, User
from src.services.email.email_template import EmailTemplate from src.services.email.email_template import EmailTemplate
@@ -2726,30 +2727,25 @@ class AdminResetEmailTemplateAdapter(AdminApiAdapter):
# -------- 数据清空适配器 -------- # -------- 数据清空适配器 --------
class AdminPurgeConfigAdapter(AdminApiAdapter): def _purge_config_sync() -> dict[str, Any]:
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] from src.models.database import (
"""清空所有提供商配置Provider、Endpoint、API Key、Model、GlobalModel""" GeminiFileMapping,
from src.models.database import ( GlobalModel,
GeminiFileMapping, Model,
GlobalModel, ProviderAPIKey,
Model, ProviderEndpoint,
ProviderAPIKey, UserPreference,
ProviderEndpoint, VideoTask,
UserPreference, )
VideoTask, from src.models.database_extensions import ApiKeyProviderMapping, ProviderUsageTracking
)
from src.models.database_extensions import ApiKeyProviderMapping, ProviderUsageTracking
db = context.db with get_db_context() as db:
# 统计
providers_count = int(db.query(func.count(Provider.id)).scalar() or 0) providers_count = int(db.query(func.count(Provider.id)).scalar() or 0)
endpoints_count = int(db.query(func.count(ProviderEndpoint.id)).scalar() or 0) endpoints_count = int(db.query(func.count(ProviderEndpoint.id)).scalar() or 0)
keys_count = int(db.query(func.count(ProviderAPIKey.id)).scalar() or 0) keys_count = int(db.query(func.count(ProviderAPIKey.id)).scalar() or 0)
models_count = int(db.query(func.count(Model.id)).scalar() or 0) models_count = int(db.query(func.count(Model.id)).scalar() or 0)
global_models_count = int(db.query(func.count(GlobalModel.id)).scalar() or 0) global_models_count = int(db.query(func.count(GlobalModel.id)).scalar() or 0)
# VideoTask 的 provider_id/endpoint_id/key_id 无 ondelete置 NULL 保留任务记录
db.query(VideoTask).filter( db.query(VideoTask).filter(
(VideoTask.provider_id.isnot(None)) (VideoTask.provider_id.isnot(None))
| (VideoTask.endpoint_id.isnot(None)) | (VideoTask.endpoint_id.isnot(None))
@@ -2763,23 +2759,19 @@ class AdminPurgeConfigAdapter(AdminApiAdapter):
synchronize_session=False, synchronize_session=False,
) )
# 先清理有外键引用的关联表
db.query(GeminiFileMapping).delete() db.query(GeminiFileMapping).delete()
db.query(ApiKeyProviderMapping).delete() db.query(ApiKeyProviderMapping).delete()
db.query(ProviderUsageTracking).delete() db.query(ProviderUsageTracking).delete()
# 清空 UserPreference 中的 default_provider_id无 ondelete 设置)
db.query(UserPreference).filter(UserPreference.default_provider_id.isnot(None)).update( db.query(UserPreference).filter(UserPreference.default_provider_id.isnot(None)).update(
{UserPreference.default_provider_id: None}, synchronize_session=False {UserPreference.default_provider_id: None}, synchronize_session=False
) )
# 按依赖顺序删除配置
db.query(Model).delete() db.query(Model).delete()
db.query(ProviderAPIKey).delete() db.query(ProviderAPIKey).delete()
db.query(ProviderEndpoint).delete() db.query(ProviderEndpoint).delete()
db.query(Provider).delete() db.query(Provider).delete()
db.query(GlobalModel).delete() db.query(GlobalModel).delete()
db.commit()
return { return {
"message": "配置已清空", "message": "配置已清空",
@@ -2793,37 +2785,27 @@ class AdminPurgeConfigAdapter(AdminApiAdapter):
} }
class AdminPurgeUsersAdapter(AdminApiAdapter): def _purge_users_sync() -> dict[str, Any]:
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] from src.core.enums import UserRole
"""清空所有非管理员用户及其关联数据""" from src.models.database import VideoTask
from src.core.enums import UserRole
db = context.db
with get_db_context() as db:
user_ids = [uid for (uid,) in db.query(User.id).filter(User.role != UserRole.ADMIN).all()] user_ids = [uid for (uid,) in db.query(User.id).filter(User.role != UserRole.ADMIN).all()]
users_count = len(user_ids) users_count = len(user_ids)
if user_ids: if user_ids:
from src.models.database import VideoTask
# 删除关联的 VideoTaskuser_id 无 ondelete 设置)
db.query(VideoTask).filter(VideoTask.user_id.in_(user_ids)).delete( db.query(VideoTask).filter(VideoTask.user_id.in_(user_ids)).delete(
synchronize_session=False synchronize_session=False
) )
# 统计关联 API Keys 数量DB 级别 CASCADE 会随 User 自动删除)
keys_count = int( keys_count = int(
db.query(func.count(ApiKey.id)).filter(ApiKey.user_id.in_(user_ids)).scalar() or 0 db.query(func.count(ApiKey.id)).filter(ApiKey.user_id.in_(user_ids)).scalar() or 0
) )
# 将使用记录的 user_id 置空(保留记录)
db.query(Usage).filter(Usage.user_id.in_(user_ids)).update( db.query(Usage).filter(Usage.user_id.in_(user_ids)).update(
{Usage.user_id: None}, synchronize_session=False {Usage.user_id: None}, synchronize_session=False
) )
# 删除用户
db.query(User).filter(User.id.in_(user_ids)).delete(synchronize_session=False) db.query(User).filter(User.id.in_(user_ids)).delete(synchronize_session=False)
db.commit()
else: else:
keys_count = 0 keys_count = 0
@@ -2836,6 +2818,105 @@ class AdminPurgeUsersAdapter(AdminApiAdapter):
} }
def _purge_usage_sync() -> dict[str, Any]:
from src.models.database import RequestCandidate, UserModelUsageCount
with get_db_context() as db:
usage_count = int(db.query(func.count(Usage.id)).scalar() or 0)
candidates_count = int(db.query(func.count(RequestCandidate.id)).scalar() or 0)
usage_counts_count = int(db.query(func.count(UserModelUsageCount.id)).scalar() or 0)
db.query(RequestCandidate).delete()
db.query(Usage).delete()
db.query(UserModelUsageCount).delete()
_purge_stats_and_reset_counters(db)
return {
"message": "使用记录已清空",
"deleted": {
"usage_records": usage_count,
"request_candidates": candidates_count,
"user_model_usage_counts": usage_counts_count,
},
}
def _purge_audit_logs_sync() -> dict[str, Any]:
from src.models.database import AuditLog
with get_db_context() as db:
count = int(db.query(func.count(AuditLog.id)).scalar() or 0)
db.query(AuditLog).delete()
return {
"message": "审计日志已清空",
"deleted": {
"audit_logs": count,
},
}
def _purge_request_bodies_sync() -> dict[str, Any]:
with get_db_context() as db:
with_body = int(
db.query(func.count(Usage.id))
.filter(
(Usage.request_body.isnot(None))
| (Usage.response_body.isnot(None))
| (Usage.provider_request_body.isnot(None))
| (Usage.client_response_body.isnot(None))
| (Usage.request_body_compressed.isnot(None))
| (Usage.response_body_compressed.isnot(None))
| (Usage.provider_request_body_compressed.isnot(None))
| (Usage.client_response_body_compressed.isnot(None))
)
.scalar()
or 0
)
db.query(Usage).update(
{
Usage.request_body: None,
Usage.response_body: None,
Usage.provider_request_body: None,
Usage.client_response_body: None,
Usage.request_body_compressed: None,
Usage.response_body_compressed: None,
Usage.provider_request_body_compressed: None,
Usage.client_response_body_compressed: None,
Usage.request_headers: None,
Usage.response_headers: None,
Usage.provider_request_headers: None,
Usage.client_response_headers: None,
},
synchronize_session=False,
)
return {
"message": "请求体已清空",
"cleaned": {
"records_with_body": with_body,
},
}
def _purge_stats_sync() -> dict[str, Any]:
with get_db_context() as db:
_purge_stats_and_reset_counters(db)
return {"message": "聚合统计数据已清空"}
class AdminPurgeConfigAdapter(AdminApiAdapter):
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
"""清空所有提供商配置Provider、Endpoint、API Key、Model、GlobalModel"""
return await run_in_threadpool(_purge_config_sync)
class AdminPurgeUsersAdapter(AdminApiAdapter):
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
"""清空所有非管理员用户及其关联数据"""
return await run_in_threadpool(_purge_users_sync)
def _purge_stats_and_reset_counters(db: Session) -> None: def _purge_stats_and_reset_counters(db: Session) -> None:
"""清空预聚合统计表、重置累计计数字段、清除缓存。""" """清空预聚合统计表、重置累计计数字段、清除缓存。"""
from src.models.database import ( from src.models.database import (
@@ -2902,112 +2983,25 @@ def _purge_stats_and_reset_counters(db: Session) -> None:
class AdminPurgeUsageAdapter(AdminApiAdapter): class AdminPurgeUsageAdapter(AdminApiAdapter):
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
"""清空全部使用记录及相关统计数据""" """清空全部使用记录及相关统计数据"""
from src.models.database import RequestCandidate, UserModelUsageCount return await run_in_threadpool(_purge_usage_sync)
db = context.db
usage_count = int(db.query(func.count(Usage.id)).scalar() or 0)
candidates_count = int(db.query(func.count(RequestCandidate.id)).scalar() or 0)
usage_counts_count = int(db.query(func.count(UserModelUsageCount.id)).scalar() or 0)
# 清空使用记录
db.query(RequestCandidate).delete()
db.query(Usage).delete()
db.query(UserModelUsageCount).delete()
_purge_stats_and_reset_counters(db)
db.commit()
return {
"message": "使用记录已清空",
"deleted": {
"usage_records": usage_count,
"request_candidates": candidates_count,
"user_model_usage_counts": usage_counts_count,
},
}
class AdminPurgeAuditLogsAdapter(AdminApiAdapter): class AdminPurgeAuditLogsAdapter(AdminApiAdapter):
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
"""清空全部审计日志""" """清空全部审计日志"""
from src.models.database import AuditLog return await run_in_threadpool(_purge_audit_logs_sync)
db = context.db
count = int(db.query(func.count(AuditLog.id)).scalar() or 0)
db.query(AuditLog).delete()
db.commit()
return {
"message": "审计日志已清空",
"deleted": {
"audit_logs": count,
},
}
class AdminPurgeRequestBodiesAdapter(AdminApiAdapter): class AdminPurgeRequestBodiesAdapter(AdminApiAdapter):
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
"""清空全部请求体/响应体(保留使用记录的统计信息)""" """清空全部请求体/响应体(保留使用记录的统计信息)"""
db = context.db return await run_in_threadpool(_purge_request_bodies_sync)
# 统计有 body 的记录数
with_body = int(
db.query(func.count(Usage.id))
.filter(
(Usage.request_body.isnot(None))
| (Usage.response_body.isnot(None))
| (Usage.provider_request_body.isnot(None))
| (Usage.client_response_body.isnot(None))
| (Usage.request_body_compressed.isnot(None))
| (Usage.response_body_compressed.isnot(None))
| (Usage.provider_request_body_compressed.isnot(None))
| (Usage.client_response_body_compressed.isnot(None))
)
.scalar()
or 0
)
# 批量清空所有 body 字段
db.query(Usage).update(
{
Usage.request_body: None,
Usage.response_body: None,
Usage.provider_request_body: None,
Usage.client_response_body: None,
Usage.request_body_compressed: None,
Usage.response_body_compressed: None,
Usage.provider_request_body_compressed: None,
Usage.client_response_body_compressed: None,
Usage.request_headers: None,
Usage.response_headers: None,
Usage.provider_request_headers: None,
Usage.client_response_headers: None,
},
synchronize_session=False,
)
db.commit()
return {
"message": "请求体已清空",
"cleaned": {
"records_with_body": with_body,
},
}
class AdminPurgeStatsAdapter(AdminApiAdapter): class AdminPurgeStatsAdapter(AdminApiAdapter):
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
"""清空全部聚合统计数据(保留原始使用记录)""" """清空全部聚合统计数据(保留原始使用记录)"""
db = context.db return await run_in_threadpool(_purge_stats_sync)
_purge_stats_and_reset_counters(db)
db.commit()
return {
"message": "聚合统计数据已清空",
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from fastapi.concurrency import run_in_threadpool
from pydantic import ValidationError from pydantic import ValidationError
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -15,10 +16,11 @@ from src.api.base.pipeline import ApiRequestPipeline
from src.config.constants import CacheTTL from src.config.constants import CacheTTL
from src.core.exceptions import InvalidRequestException, NotFoundException, translate_pydantic_error from src.core.exceptions import InvalidRequestException, NotFoundException, translate_pydantic_error
from src.core.logger import logger from src.core.logger import logger
from src.database import get_db from src.database import get_db, get_db_context
from src.models.admin_requests import UpdateUserRequest from src.models.admin_requests import UpdateUserRequest
from src.models.api import CreateApiKeyRequest, CreateUserRequest from src.models.api import CreateApiKeyRequest, CreateUserRequest
from src.models.database import ApiKey, User, UserRole, Wallet from src.models.database import ApiKey, User, UserRole, Wallet
from src.services.cache.user_cache import UserCacheService
from src.services.system.config import SystemConfigService from src.services.system.config import SystemConfigService
from src.services.user.apikey import ApiKeyService from src.services.user.apikey import ApiKeyService
from src.services.user.bulk_cleanup import pre_clean_api_key from src.services.user.bulk_cleanup import pre_clean_api_key
@@ -63,6 +65,232 @@ def _serialize_user(
} }
def _create_user_sync(
request: CreateUserRequest, role: UserRole
) -> tuple[dict[str, Any], dict[str, Any]]:
with get_db_context() as db:
if request.unlimited:
initial_gift_usd = None
elif request.initial_gift_usd is not None:
initial_gift_usd = request.initial_gift_usd
else:
initial_gift_usd = SystemConfigService.get_config(
db, "default_user_initial_gift_usd", default=None
)
user = UserService.create_user(
db=db,
email=request.email,
username=request.username,
password=request.password,
role=role,
initial_gift_usd=initial_gift_usd,
unlimited=request.unlimited,
allowed_providers=request.allowed_providers,
allowed_api_formats=request.allowed_api_formats,
allowed_models=request.allowed_models,
)
return _serialize_user(db, user), {
"action": "create_user",
"target_user_id": user.id,
"target_email": user.email,
"target_username": user.username,
"target_role": user.role.value,
"initial_gift_usd": initial_gift_usd,
"unlimited": request.unlimited,
"is_active": user.is_active,
}
def _update_user_sync(
user_id: str,
request: UpdateUserRequest,
) -> tuple[dict[str, Any], dict[str, Any], bool, str | None]:
with get_db_context() as db:
existing_user = UserService.get_user(db, user_id)
if not existing_user:
raise NotFoundException("用户不存在", "user")
update_data = request.model_dump(exclude_unset=True)
old_role = existing_user.role
existing_wallet = WalletService.get_or_create_wallet(db, user=existing_user)
unlimited_before = WalletService.is_unlimited_wallet(existing_wallet)
requested_unlimited = update_data.pop("unlimited", None)
if "role" in update_data and update_data["role"]:
if not hasattr(update_data["role"], "value"):
update_data["role"] = UserRole[update_data["role"].upper()]
user = UserService.update_user(db, user_id, **update_data)
if not user:
raise NotFoundException("用户不存在", "user")
changed_fields = list(update_data.keys())
if requested_unlimited is not None:
wallet = WalletService.get_or_create_wallet(db, user=user)
if wallet is not None:
WalletService.set_wallet_limit_mode(
db,
wallet=wallet,
limit_mode="unlimited" if requested_unlimited else "finite",
)
changed_fields.append("unlimited")
role_changed = "role" in update_data and update_data["role"] != old_role
response = _serialize_user(db, user)
return (
response,
{
"action": "update_user",
"target_user_id": user.id,
"updated_fields": changed_fields,
"role_before": old_role.value if old_role else None,
"role_after": user.role.value,
"unlimited_before": unlimited_before,
"unlimited_after": (
requested_unlimited if requested_unlimited is not None else unlimited_before
),
"is_active": user.is_active,
},
role_changed,
user.email,
)
def _delete_user_sync(user_id: str) -> tuple[dict[str, Any], dict[str, Any], str | None]:
with get_db_context() as db:
user = UserService.get_user(db, user_id)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
if user.role == UserRole.ADMIN:
admin_count = int(
db.query(func.count(User.id)).filter(User.role == UserRole.ADMIN).scalar() or 0
)
if admin_count <= 1:
raise InvalidRequestException("不能删除最后一个管理员账户")
try:
success = UserService.delete_user(db, user_id)
except ValueError as exc:
raise InvalidRequestException(str(exc)) from exc
if not success:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
return (
{"message": "用户删除成功"},
{
"action": "delete_user",
"target_user_id": user.id,
"target_email": user.email,
"target_role": user.role.value,
},
user.email,
)
def _create_user_key_sync(
user_id: str,
key_data: CreateApiKeyRequest,
) -> tuple[dict[str, Any], dict[str, Any], str | None]:
with get_db_context() as db:
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise NotFoundException("用户不存在", "user")
api_key, plain_key = ApiKeyService.create_api_key(
db=db,
user_id=user_id,
name=key_data.name,
allowed_providers=key_data.allowed_providers,
allowed_models=key_data.allowed_models,
rate_limit=key_data.rate_limit,
expire_days=key_data.expire_days,
is_standalone=False,
)
return (
{
"id": api_key.id,
"key": plain_key,
"name": api_key.name,
"key_display": api_key.get_display_key(),
"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": "API Key创建成功请妥善保存完整密钥",
},
{
"action": "create_user_api_key",
"target_user_id": user_id,
"key_id": api_key.id,
},
user.email,
)
def _delete_user_key_sync(user_id: str, key_id: str) -> tuple[dict[str, Any], dict[str, Any]]:
with get_db_context() as db:
api_key = (
db.query(ApiKey)
.filter(
ApiKey.id == key_id,
ApiKey.user_id == user_id,
ApiKey.is_standalone == False,
)
.first()
)
if not api_key:
raise NotFoundException("API Key不存在或不属于该用户", "api_key")
pre_clean_api_key(db, api_key.id)
db.delete(api_key)
return {"message": "API Key已删除"}, {
"action": "delete_user_api_key",
"target_user_id": user_id,
"key_id": key_id,
}
def _toggle_user_key_lock_sync(
user_id: str,
key_id: str,
) -> tuple[dict[str, Any], dict[str, Any]]:
with get_db_context() as db:
api_key = (
db.query(ApiKey)
.filter(
ApiKey.id == key_id,
ApiKey.user_id == user_id,
ApiKey.is_standalone == False,
)
.first()
)
if not api_key:
raise NotFoundException("API Key不存在或不属于该用户", "api_key")
api_key.is_locked = not api_key.is_locked
db.commit()
db.refresh(api_key)
return (
{
"id": api_key.id,
"is_locked": api_key.is_locked,
"message": f"API密钥已{'锁定' if api_key.is_locked else '解锁'}",
},
{
"action": "toggle_user_api_key_lock",
"target_user_id": user_id,
"key_id": key_id,
"new_lock_status": "locked" if api_key.is_locked else "unlocked",
},
)
# 管理员端点 # 管理员端点
@router.post("") @router.post("")
async def create_user_endpoint(request: Request, db: Session = Depends(get_db)) -> Any: async def create_user_endpoint(request: Request, db: Session = Depends(get_db)) -> Any:
@@ -269,7 +497,6 @@ async def get_user_api_key_full_key(
class AdminCreateUserAdapter(AdminApiAdapter): class AdminCreateUserAdapter(AdminApiAdapter):
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
payload = context.ensure_json_body() payload = context.ensure_json_body()
try: try:
request = CreateUserRequest.model_validate(payload) request = CreateUserRequest.model_validate(payload)
@@ -285,48 +512,12 @@ class AdminCreateUserAdapter(AdminApiAdapter):
except (KeyError, AttributeError): except (KeyError, AttributeError):
raise InvalidRequestException("角色参数不合法") raise InvalidRequestException("角色参数不合法")
# 确定初始赠款:仅有限制用户才会发放初始赠款
if request.unlimited:
initial_gift_usd = None
elif request.initial_gift_usd is not None:
initial_gift_usd = request.initial_gift_usd
else:
initial_gift_usd = SystemConfigService.get_config(
db, "default_user_initial_gift_usd", default=None
)
# 访问限制语义NULL=不限制,空数组=[]=全部禁用
allowed_providers = request.allowed_providers
allowed_api_formats = request.allowed_api_formats
allowed_models = request.allowed_models
try: try:
user = UserService.create_user( response, audit_meta = await run_in_threadpool(_create_user_sync, request, role)
db=db,
email=request.email,
username=request.username,
password=request.password,
role=role,
initial_gift_usd=initial_gift_usd,
unlimited=request.unlimited,
allowed_providers=allowed_providers,
allowed_api_formats=allowed_api_formats,
allowed_models=allowed_models,
)
except ValueError as exc: except ValueError as exc:
raise InvalidRequestException(str(exc)) raise InvalidRequestException(str(exc)) from exc
context.add_audit_metadata(**audit_meta)
context.add_audit_metadata( return response
action="create_user",
target_user_id=user.id,
target_email=user.email,
target_username=user.username,
target_role=user.role.value,
initial_gift_usd=initial_gift_usd,
unlimited=request.unlimited,
is_active=user.is_active,
)
return _serialize_user(db, user)
class AdminListUsersAdapter(AdminApiAdapter): class AdminListUsersAdapter(AdminApiAdapter):
@@ -378,11 +569,6 @@ class AdminUpdateUserAdapter(AdminApiAdapter):
self.user_id = user_id self.user_id = user_id
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
existing_user = UserService.get_user(db, self.user_id)
if not existing_user:
raise NotFoundException("用户不存在", "user")
payload = context.ensure_json_body() payload = context.ensure_json_body()
try: try:
request = UpdateUserRequest.model_validate(payload) request = UpdateUserRequest.model_validate(payload)
@@ -392,52 +578,19 @@ class AdminUpdateUserAdapter(AdminApiAdapter):
raise InvalidRequestException(translate_pydantic_error(errors[0])) raise InvalidRequestException(translate_pydantic_error(errors[0]))
raise InvalidRequestException("请求数据验证失败") raise InvalidRequestException("请求数据验证失败")
update_data = request.model_dump(exclude_unset=True) response, audit_meta, role_changed, user_email = await run_in_threadpool(
old_role = existing_user.role _update_user_sync,
existing_wallet = WalletService.get_or_create_wallet(db, user=existing_user) self.user_id,
unlimited_before = WalletService.is_unlimited_wallet(existing_wallet) request,
)
requested_unlimited = update_data.pop("unlimited", None) if user_email:
await UserCacheService.invalidate_user_cache(self.user_id, user_email)
if "role" in update_data and update_data["role"]: if role_changed:
if hasattr(update_data["role"], "value"):
update_data["role"] = update_data["role"]
else:
update_data["role"] = UserRole[update_data["role"].upper()]
user = UserService.update_user(db, self.user_id, **update_data)
if not user:
raise NotFoundException("用户不存在", "user")
# 角色变更时清除热力图缓存(影响 include_actual_cost 权限)
if "role" in update_data and update_data["role"] != old_role:
from src.services.usage.service import UsageService from src.services.usage.service import UsageService
await UsageService.clear_user_heatmap_cache(self.user_id) await UsageService.clear_user_heatmap_cache(self.user_id)
context.add_audit_metadata(**audit_meta)
changed_fields = list(update_data.keys()) return response
if requested_unlimited is not None:
wallet = WalletService.get_or_create_wallet(db, user=user)
if wallet is not None:
WalletService.set_wallet_limit_mode(
db,
wallet=wallet,
limit_mode="unlimited" if requested_unlimited else "finite",
)
changed_fields.append("unlimited")
context.add_audit_metadata(
action="update_user",
target_user_id=user.id,
updated_fields=changed_fields,
role_before=existing_user.role.value if existing_user.role else None,
role_after=user.role.value,
unlimited_before=unlimited_before,
unlimited_after=(
requested_unlimited if requested_unlimited is not None else unlimited_before
),
is_active=user.is_active,
)
return _serialize_user(db, user)
class AdminDeleteUserAdapter(AdminApiAdapter): class AdminDeleteUserAdapter(AdminApiAdapter):
@@ -445,33 +598,11 @@ class AdminDeleteUserAdapter(AdminApiAdapter):
self.user_id = user_id self.user_id = user_id
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db response, audit_meta, user_email = await run_in_threadpool(_delete_user_sync, self.user_id)
user = UserService.get_user(db, self.user_id) if user_email:
if not user: await UserCacheService.invalidate_user_cache(self.user_id, user_email)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在") context.add_audit_metadata(**audit_meta)
return response
if user.role == UserRole.ADMIN:
admin_count = int(
db.query(func.count(User.id)).filter(User.role == UserRole.ADMIN).scalar() or 0
)
if admin_count <= 1:
raise InvalidRequestException("不能删除最后一个管理员账户")
try:
success = UserService.delete_user(db, self.user_id)
except ValueError as exc:
raise InvalidRequestException(str(exc))
if not success:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
context.add_audit_metadata(
action="delete_user",
target_user_id=user.id,
target_email=user.email,
target_role=user.role.value,
)
return {"message": "用户删除成功"}
class AdminGetUserKeysAdapter(AdminApiAdapter): class AdminGetUserKeysAdapter(AdminApiAdapter):
@@ -530,7 +661,6 @@ class AdminCreateUserKeyAdapter(AdminApiAdapter):
self.user_id = user_id self.user_id = user_id
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
payload = context.ensure_json_body() payload = context.ensure_json_body()
try: try:
key_data = CreateApiKeyRequest.model_validate(payload) key_data = CreateApiKeyRequest.model_validate(payload)
@@ -540,41 +670,17 @@ class AdminCreateUserKeyAdapter(AdminApiAdapter):
raise InvalidRequestException(translate_pydantic_error(errors[0])) raise InvalidRequestException(translate_pydantic_error(errors[0]))
raise InvalidRequestException("请求数据验证失败") raise InvalidRequestException("请求数据验证失败")
# 验证用户存在 response, audit_meta, user_email = await run_in_threadpool(
user = db.query(User).filter(User.id == self.user_id).first() _create_user_key_sync,
if not user: self.user_id,
raise NotFoundException("用户不存在", "user") key_data,
# 为用户创建Key不是独立Key
api_key, plain_key = ApiKeyService.create_api_key(
db=db,
user_id=self.user_id,
name=key_data.name,
allowed_providers=key_data.allowed_providers,
allowed_models=key_data.allowed_models,
rate_limit=key_data.rate_limit, # None = 无限制
expire_days=key_data.expire_days,
is_standalone=False, # 不是独立Key
) )
if user_email:
logger.info(f"管理员为用户创建API Key: 用户 {user.email}, Key ID {api_key.id}") logger.info(
"管理员为用户创建API Key: 用户 {}, Key ID {}", user_email, audit_meta["key_id"]
context.add_audit_metadata( )
action="create_user_api_key", context.add_audit_metadata(**audit_meta)
target_user_id=self.user_id, return response
key_id=api_key.id,
)
return {
"id": api_key.id,
"key": plain_key, # 只在创建时返回
"name": api_key.name,
"key_display": api_key.get_display_key(),
"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": "API Key创建成功请妥善保存完整密钥",
}
class AdminDeleteUserKeyAdapter(AdminApiAdapter): class AdminDeleteUserKeyAdapter(AdminApiAdapter):
@@ -585,36 +691,13 @@ class AdminDeleteUserKeyAdapter(AdminApiAdapter):
self.key_id = key_id self.key_id = key_id
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db response, audit_meta = await run_in_threadpool(
_delete_user_key_sync,
# 验证Key存在且属于该用户 self.user_id,
api_key = ( self.key_id,
db.query(ApiKey)
.filter(
ApiKey.id == self.key_id,
ApiKey.user_id == self.user_id,
ApiKey.is_standalone == False, # 只能删除普通Key
)
.first()
) )
context.add_audit_metadata(**audit_meta)
if not api_key: return response
raise NotFoundException("API Key不存在或不属于该用户", "api_key")
pre_clean_api_key(db, api_key.id)
db.delete(api_key)
db.commit()
context.request.state.tx_committed_by_route = True
logger.info(f"管理员删除用户API Key: 用户ID {self.user_id}, Key ID {self.key_id}")
context.add_audit_metadata(
action="delete_user_api_key",
target_user_id=self.user_id,
key_id=self.key_id,
)
return {"message": "API Key已删除"}
class AdminToggleUserKeyLockAdapter(AdminApiAdapter): class AdminToggleUserKeyLockAdapter(AdminApiAdapter):
@@ -625,42 +708,13 @@ class AdminToggleUserKeyLockAdapter(AdminApiAdapter):
self.key_id = key_id self.key_id = key_id
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db response, audit_meta = await run_in_threadpool(
_toggle_user_key_lock_sync,
api_key = ( self.user_id,
db.query(ApiKey) self.key_id,
.filter(
ApiKey.id == self.key_id,
ApiKey.user_id == self.user_id,
ApiKey.is_standalone == False, # 只能锁定普通Key
)
.first()
) )
if not api_key: context.add_audit_metadata(**audit_meta)
raise NotFoundException("API Key不存在或不属于该用户", "api_key") return response
api_key.is_locked = not api_key.is_locked
db.commit()
context.request.state.tx_committed_by_route = True
db.refresh(api_key)
logger.info(
f"管理员切换用户API Key锁定状态: 用户ID {self.user_id}, Key ID {self.key_id}, "
f"新状态 {'锁定' if api_key.is_locked else '解锁'}"
)
context.add_audit_metadata(
action="toggle_user_api_key_lock",
target_user_id=self.user_id,
key_id=self.key_id,
new_lock_status="locked" if api_key.is_locked else "unlocked",
)
return {
"id": api_key.id,
"is_locked": api_key.is_locked,
"message": f"API密钥已{'锁定' if api_key.is_locked else '解锁'}",
}
class AdminGetUserKeyFullKeyAdapter(AdminApiAdapter): class AdminGetUserKeyFullKeyAdapter(AdminApiAdapter):

View File

@@ -144,7 +144,7 @@ async def proxy_video_stream(
**返回**: **返回**:
- 视频流 - 视频流
""" """
from src.services.auth.service import AuthService from src.utils.auth_utils import authenticate_user_from_bearer_token
# 尝试从多个来源获取 tokenquery param > cookie > header # 尝试从多个来源获取 tokenquery param > cookie > header
auth_token = token auth_token = token
@@ -159,15 +159,7 @@ async def proxy_video_stream(
raise HTTPException(status_code=401, detail="Unauthorized") raise HTTPException(status_code=401, detail="Unauthorized")
try: try:
# 验证 token 并获取 payload user = await authenticate_user_from_bearer_token(auth_token, db, request)
payload = await AuthService.verify_token(auth_token, token_type="access")
user_id = payload.get("user_id") or payload.get("sub")
if not user_id:
raise HTTPException(status_code=401, detail="Unauthorized")
# 查询用户
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=401, detail="Unauthorized")
except HTTPException: except HTTPException:
raise raise
except Exception: except Exception:

View File

@@ -6,6 +6,7 @@ from dataclasses import dataclass
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, Query, Request from fastapi import APIRouter, Depends, Query, Request
from fastapi.concurrency import run_in_threadpool
from pydantic import BaseModel, Field, ValidationError from pydantic import BaseModel, Field, ValidationError
from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm import Session, joinedload
@@ -18,7 +19,7 @@ from src.api.serializers import (
serialize_admin_wallet_transaction, serialize_admin_wallet_transaction,
) )
from src.core.exceptions import InvalidRequestException, NotFoundException, translate_pydantic_error from src.core.exceptions import InvalidRequestException, NotFoundException, translate_pydantic_error
from src.database import get_db from src.database import get_db, get_db_context
from src.models.database import RefundRequest, Wallet, WalletTransaction from src.models.database import RefundRequest, Wallet, WalletTransaction
from src.services.wallet import WalletService from src.services.wallet import WalletService
@@ -91,6 +92,152 @@ def _parse_payload(model_cls: type[BaseModel], payload: dict[str, Any]) -> BaseM
raise InvalidRequestException("请求数据验证失败") raise InvalidRequestException("请求数据验证失败")
def _recharge_wallet_sync(
wallet_id: str,
payload: ManualRechargePayload,
operator_id: str | None,
) -> dict[str, Any]:
with get_db_context() as db:
wallet = _get_wallet_or_raise(db, wallet_id)
_ensure_api_key_wallet_manual_recharge(wallet, payload.payment_method)
try:
order = WalletService.create_manual_recharge_order(
db,
wallet=wallet,
amount_usd=payload.amount_usd,
payment_method=payload.payment_method,
operator_id=operator_id,
description=payload.description,
)
except ValueError as exc:
raise InvalidRequestException(str(exc)) from exc
db.commit()
db.refresh(wallet)
return {
"wallet": serialize_admin_wallet(wallet),
"payment_order": {
"id": order.id,
"order_no": order.order_no,
"amount_usd": float(order.amount_usd or 0),
"payment_method": order.payment_method,
"status": order.status,
"created_at": order.created_at,
"credited_at": order.credited_at,
},
}
def _adjust_wallet_sync(
wallet_id: str,
payload: WalletAdjustPayload,
operator_id: str | None,
) -> dict[str, Any]:
with get_db_context() as db:
wallet = _get_wallet_or_raise(db, wallet_id)
if wallet.api_key_id is not None and payload.balance_type == "gift":
raise InvalidRequestException("独立密钥钱包不支持赠款调账")
try:
tx = WalletService.admin_adjust_balance(
db,
wallet=wallet,
amount_usd=payload.amount_usd,
balance_type=payload.balance_type,
operator_id=operator_id,
description=payload.description,
)
except ValueError as exc:
raise InvalidRequestException(str(exc)) from exc
db.commit()
db.refresh(wallet)
return {
"wallet": serialize_admin_wallet(wallet),
"transaction": serialize_admin_wallet_transaction(tx),
}
def _process_refund_sync(
wallet_id: str,
refund_id: str,
operator_id: str | None,
) -> dict[str, Any]:
with get_db_context() as db:
wallet = _get_wallet_or_raise(db, wallet_id)
_ensure_user_wallet_for_refund(wallet)
refund = _get_refund_or_raise(db, wallet_id, refund_id)
try:
tx = WalletService.move_refund_to_processing(
db,
refund=refund,
operator_id=operator_id,
)
except ValueError as exc:
raise InvalidRequestException(str(exc)) from exc
db.commit()
db.refresh(wallet)
db.refresh(refund)
return {
"wallet": serialize_admin_wallet(wallet),
"refund": serialize_admin_wallet_refund(refund),
"transaction": serialize_admin_wallet_transaction(tx),
}
def _fail_refund_sync(
wallet_id: str,
refund_id: str,
payload: RefundFailPayload,
operator_id: str | None,
) -> dict[str, Any]:
with get_db_context() as db:
wallet = _get_wallet_or_raise(db, wallet_id)
_ensure_user_wallet_for_refund(wallet)
refund = _get_refund_or_raise(db, wallet_id, refund_id)
try:
tx = WalletService.fail_refund(
db,
refund=refund,
reason=payload.reason,
operator_id=operator_id,
)
except ValueError as exc:
raise InvalidRequestException(str(exc)) from exc
db.commit()
db.refresh(wallet)
db.refresh(refund)
return {
"wallet": serialize_admin_wallet(wallet),
"refund": serialize_admin_wallet_refund(refund),
"transaction": serialize_admin_wallet_transaction(tx) if tx is not None else None,
}
def _complete_refund_sync(
wallet_id: str,
refund_id: str,
payload: RefundCompletePayload,
) -> dict[str, Any]:
with get_db_context() as db:
wallet = _get_wallet_or_raise(db, wallet_id)
_ensure_user_wallet_for_refund(wallet)
refund = _get_refund_or_raise(db, wallet_id, refund_id)
if refund.status != "processing":
raise InvalidRequestException("只有 processing 状态的退款可以标记完成")
try:
updated = WalletService.complete_refund(
db,
refund=refund,
gateway_refund_id=payload.gateway_refund_id,
payout_reference=payload.payout_reference,
payout_proof=payload.payout_proof,
)
except ValueError as exc:
raise InvalidRequestException(str(exc)) from exc
db.commit()
db.refresh(updated)
return {"refund": serialize_admin_wallet_refund(updated)}
@router.get("") @router.get("")
async def list_wallets( async def list_wallets(
request: Request, request: Request,
@@ -397,33 +544,12 @@ class AdminWalletRechargeAdapter(AdminApiAdapter):
payload = _parse_payload(ManualRechargePayload, context.ensure_json_body()) payload = _parse_payload(ManualRechargePayload, context.ensure_json_body())
assert isinstance(payload, ManualRechargePayload) assert isinstance(payload, ManualRechargePayload)
wallet = _get_wallet_or_raise(context.db, self.wallet_id) return await run_in_threadpool(
_ensure_api_key_wallet_manual_recharge(wallet, payload.payment_method) _recharge_wallet_sync,
try: self.wallet_id,
order = WalletService.create_manual_recharge_order( payload,
context.db, context.user.id if context.user else None,
wallet=wallet, )
amount_usd=payload.amount_usd,
payment_method=payload.payment_method,
operator_id=context.user.id if context.user else None,
description=payload.description,
)
except ValueError as exc:
raise InvalidRequestException(str(exc))
context.db.commit()
context.db.refresh(wallet)
return {
"wallet": serialize_admin_wallet(wallet),
"payment_order": {
"id": order.id,
"order_no": order.order_no,
"amount_usd": float(order.amount_usd or 0),
"payment_method": order.payment_method,
"status": order.status,
"created_at": order.created_at,
"credited_at": order.credited_at,
},
}
@dataclass @dataclass
@@ -434,27 +560,12 @@ class AdminWalletAdjustAdapter(AdminApiAdapter):
payload = _parse_payload(WalletAdjustPayload, context.ensure_json_body()) payload = _parse_payload(WalletAdjustPayload, context.ensure_json_body())
assert isinstance(payload, WalletAdjustPayload) assert isinstance(payload, WalletAdjustPayload)
wallet = _get_wallet_or_raise(context.db, self.wallet_id) return await run_in_threadpool(
if wallet.api_key_id is not None and payload.balance_type == "gift": _adjust_wallet_sync,
raise InvalidRequestException("独立密钥钱包不支持赠款调账") self.wallet_id,
try: payload,
tx = WalletService.admin_adjust_balance( context.user.id if context.user else None,
context.db, )
wallet=wallet,
amount_usd=payload.amount_usd,
balance_type=payload.balance_type, # recharge | gift
operator_id=context.user.id if context.user else None,
description=payload.description,
)
except ValueError as exc:
raise InvalidRequestException(str(exc))
context.db.commit()
context.db.refresh(wallet)
return {
"wallet": serialize_admin_wallet(wallet),
"transaction": serialize_admin_wallet_transaction(tx),
}
@dataclass @dataclass
@@ -463,26 +574,12 @@ class AdminWalletRefundProcessAdapter(AdminApiAdapter):
refund_id: str refund_id: str
async def handle(self, context: ApiRequestContext) -> dict[str, Any]: async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
wallet = _get_wallet_or_raise(context.db, self.wallet_id) return await run_in_threadpool(
_ensure_user_wallet_for_refund(wallet) _process_refund_sync,
refund = _get_refund_or_raise(context.db, self.wallet_id, self.refund_id) self.wallet_id,
try: self.refund_id,
tx = WalletService.move_refund_to_processing( context.user.id if context.user else None,
context.db, )
refund=refund,
operator_id=context.user.id if context.user else None,
)
except ValueError as exc:
raise InvalidRequestException(str(exc))
context.db.commit()
context.db.refresh(wallet)
context.db.refresh(refund)
return {
"wallet": serialize_admin_wallet(wallet),
"refund": serialize_admin_wallet_refund(refund),
"transaction": serialize_admin_wallet_transaction(tx),
}
@dataclass @dataclass
@@ -494,26 +591,13 @@ class AdminWalletRefundFailAdapter(AdminApiAdapter):
payload = _parse_payload(RefundFailPayload, context.ensure_json_body()) payload = _parse_payload(RefundFailPayload, context.ensure_json_body())
assert isinstance(payload, RefundFailPayload) assert isinstance(payload, RefundFailPayload)
wallet = _get_wallet_or_raise(context.db, self.wallet_id) return await run_in_threadpool(
_ensure_user_wallet_for_refund(wallet) _fail_refund_sync,
refund = _get_refund_or_raise(context.db, self.wallet_id, self.refund_id) self.wallet_id,
try: self.refund_id,
tx = WalletService.fail_refund( payload,
context.db, context.user.id if context.user else None,
refund=refund, )
reason=payload.reason,
operator_id=context.user.id if context.user else None,
)
except ValueError as exc:
raise InvalidRequestException(str(exc))
context.db.commit()
context.db.refresh(wallet)
context.db.refresh(refund)
return {
"wallet": serialize_admin_wallet(wallet),
"refund": serialize_admin_wallet_refund(refund),
"transaction": serialize_admin_wallet_transaction(tx) if tx is not None else None,
}
@dataclass @dataclass
@@ -525,22 +609,9 @@ class AdminWalletRefundCompleteAdapter(AdminApiAdapter):
payload = _parse_payload(RefundCompletePayload, context.ensure_json_body()) payload = _parse_payload(RefundCompletePayload, context.ensure_json_body())
assert isinstance(payload, RefundCompletePayload) assert isinstance(payload, RefundCompletePayload)
wallet = _get_wallet_or_raise(context.db, self.wallet_id) return await run_in_threadpool(
_ensure_user_wallet_for_refund(wallet) _complete_refund_sync,
refund = _get_refund_or_raise(context.db, self.wallet_id, self.refund_id) self.wallet_id,
if refund.status != "processing": self.refund_id,
raise InvalidRequestException("只有 processing 状态的退款可以标记完成") payload,
)
try:
updated = WalletService.complete_refund(
context.db,
refund=refund,
gateway_refund_id=payload.gateway_refund_id,
payout_reference=payload.payout_reference,
payout_proof=payload.payout_proof,
)
except ValueError as exc:
raise InvalidRequestException(str(exc))
context.db.commit()
context.db.refresh(updated)
return {"refund": serialize_admin_wallet_refund(updated)}

View File

@@ -18,8 +18,8 @@ from src.core.exceptions import InvalidRequestException, translate_pydantic_erro
from src.database import get_db from src.database import get_db
from src.models.api import CreateAnnouncementRequest, UpdateAnnouncementRequest from src.models.api import CreateAnnouncementRequest, UpdateAnnouncementRequest
from src.models.database import User from src.models.database import User
from src.services.auth.service import AuthService
from src.services.system.announcement import AnnouncementService from src.services.system.announcement import AnnouncementService
from src.utils.auth_utils import authenticate_user_from_bearer_token
router = APIRouter(prefix="/api/announcements", tags=["Announcements"]) router = APIRouter(prefix="/api/announcements", tags=["Announcements"])
pipeline = ApiRequestPipeline() pipeline = ApiRequestPipeline()
@@ -262,27 +262,7 @@ class AnnouncementOptionalAuthAdapter(ApiAdapter):
token = authorization[7:].strip() token = authorization[7:].strip()
try: try:
payload = await AuthService.verify_token(token, token_type="access") return await authenticate_user_from_bearer_token(token, context.db, context.request)
user_id = payload.get("user_id")
if not user_id:
return None
user = (
context.db.query(User)
.filter(
User.id == user_id,
User.is_active.is_(True),
User.is_deleted.is_(False),
)
.first()
)
if not user:
return None
if not AuthService.token_identity_matches_user(payload, user):
return None
return user
except Exception: except Exception:
return None return None

View File

@@ -7,6 +7,7 @@ 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 fastapi.concurrency import run_in_threadpool
from pydantic import ValidationError from pydantic import ValidationError
from sqlalchemy import and_, func from sqlalchemy import and_, func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -25,7 +26,7 @@ from src.core.exceptions import (
) )
from src.core.logger import logger from src.core.logger import logger
from src.core.validators import PasswordValidator from src.core.validators import PasswordValidator
from src.database import get_db from src.database import get_db, get_db_context
from src.models.api import ( from src.models.api import (
ChangePasswordRequest, ChangePasswordRequest,
CreateMyApiKeyRequest, CreateMyApiKeyRequest,
@@ -44,6 +45,7 @@ from src.models.database import (
User, User,
UserModelUsageCount, UserModelUsageCount,
) )
from src.services.cache.user_cache import UserCacheService
from src.services.system.config import SystemConfigService from src.services.system.config import SystemConfigService
from src.services.system.time_range import TimeRangeParams from src.services.system.time_range import TimeRangeParams
from src.services.usage.service import UsageService from src.services.usage.service import UsageService
@@ -57,6 +59,280 @@ router = APIRouter(prefix="/api/users/me", tags=["User Profile"])
pipeline = ApiRequestPipeline() pipeline = ApiRequestPipeline()
def _update_profile_sync(
user_id: str,
request: UpdateProfileRequest,
) -> tuple[dict[str, Any], str | None, str | None]:
with get_db_context() as db:
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise NotFoundException("用户不存在", "user")
old_email = user.email
new_email = old_email
if request.email:
existing = (
db.query(User).filter(User.email == request.email, User.id != user.id).first()
)
if existing:
raise InvalidRequestException("邮箱已被使用")
user.email = request.email
new_email = request.email
if request.username:
existing = (
db.query(User).filter(User.username == request.username, User.id != user.id).first()
)
if existing:
raise InvalidRequestException("用户名已被使用")
user.username = request.username
user.updated_at = datetime.now(timezone.utc)
return {"message": "个人信息更新成功"}, old_email, new_email
def _change_password_sync(
user_id: str,
request: ChangePasswordRequest,
) -> tuple[dict[str, Any], str | None, str]:
from src.core.enums import AuthSource
with get_db_context() as db:
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise NotFoundException("用户不存在", "user")
if user.auth_source == AuthSource.LDAP:
raise ForbiddenException("LDAP 用户不能在此修改密码")
has_password = bool(user.password_hash)
if has_password:
if not request.old_password:
raise InvalidRequestException("请输入当前密码")
if not user.verify_password(request.old_password):
raise InvalidRequestException("旧密码错误")
policy_level = SystemConfigService.get_password_policy_level(db)
valid, error_msg = PasswordValidator.validate(request.new_password, policy=policy_level)
if not valid:
raise InvalidRequestException(error_msg or "密码格式无效")
user.set_password(request.new_password)
user.updated_at = datetime.now(timezone.utc)
action = "修改" if has_password else "设置"
return {"message": f"密码{action}成功"}, user.email, action
def _create_my_api_key_sync(user_id: str, request: CreateMyApiKeyRequest) -> dict[str, Any]:
with get_db_context() as db:
try:
api_key, plain_key = ApiKeyService.create_api_key(
db=db,
user_id=user_id,
name=request.name,
)
except ValueError as exc:
raise InvalidRequestException(str(exc)) from exc
return {
"id": api_key.id,
"name": api_key.name,
"key": plain_key,
"key_display": api_key.get_display_key(),
"message": "API密钥创建成功",
}
def _delete_my_api_key_sync(user_id: str, key_id: str) -> dict[str, str]:
with get_db_context() as db:
api_key = db.query(ApiKey).filter(ApiKey.id == key_id, ApiKey.user_id == user_id).first()
if not api_key:
raise NotFoundException("API密钥不存在", "api_key")
if api_key.is_locked:
raise ForbiddenException("该密钥已被管理员锁定,无法删除")
pre_clean_api_key(db, api_key.id)
db.delete(api_key)
return {"message": "API密钥已删除"}
def _toggle_my_api_key_sync(user_id: str, key_id: str) -> dict[str, Any]:
with get_db_context() as db:
api_key = db.query(ApiKey).filter(ApiKey.id == key_id, ApiKey.user_id == user_id).first()
if not api_key:
raise NotFoundException("API密钥不存在", "api_key")
if api_key.is_locked:
raise ForbiddenException("该密钥已被管理员锁定,无法修改状态")
api_key.is_active = not api_key.is_active
db.commit()
db.refresh(api_key)
return {
"id": api_key.id,
"is_active": api_key.is_active,
"message": f"API密钥已{'启用' if api_key.is_active else '禁用'}",
}
def _update_api_key_providers_sync(
user_id: str,
api_key_id: str,
request: UpdateApiKeyProvidersRequest,
) -> dict[str, str]:
with get_db_context() as db:
api_key = (
db.query(ApiKey).filter(ApiKey.id == api_key_id, ApiKey.user_id == user_id).first()
)
if not api_key:
raise NotFoundException("API密钥不存在")
if api_key.is_locked:
raise ForbiddenException("该密钥已被管理员锁定,无法修改")
if request.allowed_providers is not None and len(request.allowed_providers) > 0:
provider_ids = [cfg.provider_id for cfg in request.allowed_providers]
valid = (
db.query(Provider.id)
.filter(Provider.id.in_(provider_ids), Provider.is_active.is_(True))
.all()
)
valid_ids = {p.id for p in valid}
invalid = set(provider_ids) - valid_ids
if invalid:
raise InvalidRequestException(f"无效的提供商ID: {', '.join(invalid)}")
api_key.allowed_providers = (
[cfg.provider_id for cfg in request.allowed_providers]
if request.allowed_providers is not None
else None
)
api_key.updated_at = datetime.now(timezone.utc)
return {"message": "API密钥可用提供商已更新"}
def _update_api_key_capabilities_sync(
user_id: str,
api_key_id: str,
payload: dict[str, Any],
) -> dict[str, Any]:
from src.core.key_capabilities import CAPABILITY_DEFINITIONS, CapabilityConfigMode
from src.models.database import AuditEventType
from src.services.system.audit import audit_service
with get_db_context() as db:
api_key = (
db.query(ApiKey).filter(ApiKey.id == api_key_id, ApiKey.user_id == user_id).first()
)
if not api_key:
raise NotFoundException("API密钥不存在")
if api_key.is_locked:
raise ForbiddenException("该密钥已被管理员锁定,无法修改")
old_capabilities = api_key.force_capabilities
force_capabilities = payload.get("force_capabilities")
if force_capabilities is not None:
if not isinstance(force_capabilities, dict):
raise InvalidRequestException("force_capabilities 必须是对象类型")
for cap_name, cap_value in force_capabilities.items():
cap_def = CAPABILITY_DEFINITIONS.get(cap_name)
if not cap_def:
raise InvalidRequestException(f"未知的能力类型: {cap_name}")
if cap_def.config_mode != CapabilityConfigMode.USER_CONFIGURABLE:
raise InvalidRequestException(f"能力 {cap_name} 不支持用户配置")
if not isinstance(cap_value, bool):
raise InvalidRequestException(f"能力 {cap_name} 的值必须是布尔类型")
api_key.force_capabilities = force_capabilities
api_key.updated_at = datetime.now(timezone.utc)
audit_service.log_event(
db=db,
event_type=AuditEventType.CONFIG_CHANGED,
description="用户更新 API Key 能力配置",
user_id=user_id,
api_key_id=api_key.id,
metadata={
"action": "update_api_key_capabilities",
"old_capabilities": old_capabilities,
"new_capabilities": force_capabilities,
},
)
return {
"message": "API密钥能力配置已更新",
"force_capabilities": api_key.force_capabilities,
}
def _update_preferences_sync(user_id: str, request: UpdatePreferencesRequest) -> dict[str, str]:
with get_db_context() as db:
PreferenceService.update_preferences(
db=db,
user_id=user_id,
avatar_url=request.avatar_url,
bio=request.bio,
default_provider_id=request.default_provider_id,
theme=request.theme,
language=request.language,
timezone=request.timezone,
email_notifications=request.email_notifications,
usage_alerts=request.usage_alerts,
announcement_notifications=request.announcement_notifications,
)
return {"message": "偏好设置更新成功"}
def _update_model_capability_settings_sync(
user_id: str,
payload: dict[str, Any],
) -> tuple[dict[str, Any], str | None]:
from src.core.key_capabilities import CAPABILITY_DEFINITIONS, CapabilityConfigMode
from src.models.database import AuditEventType
from src.services.system.audit import audit_service
with get_db_context() as db:
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise NotFoundException("用户不存在")
old_settings = user.model_capability_settings
settings = payload.get("model_capability_settings")
if settings is not None:
if not isinstance(settings, dict):
raise InvalidRequestException("model_capability_settings 必须是对象类型")
for model_name, capabilities in settings.items():
if not isinstance(model_name, str):
raise InvalidRequestException("模型名称必须是字符串")
if not isinstance(capabilities, dict):
raise InvalidRequestException(f"模型 {model_name} 的能力配置必须是对象类型")
for cap_name, cap_value in capabilities.items():
cap_def = CAPABILITY_DEFINITIONS.get(cap_name)
if not cap_def:
raise InvalidRequestException(f"未知的能力类型: {cap_name}")
if cap_def.config_mode != CapabilityConfigMode.USER_CONFIGURABLE:
raise InvalidRequestException(f"能力 {cap_name} 不支持用户配置")
if not isinstance(cap_value, bool):
raise InvalidRequestException(f"能力 {cap_name} 的值必须是布尔类型")
user.model_capability_settings = settings
user.updated_at = datetime.now(timezone.utc)
audit_service.log_event(
db=db,
event_type=AuditEventType.CONFIG_CHANGED,
description="用户更新模型能力配置",
user_id=user.id,
metadata={
"action": "update_model_capability_settings",
"old_settings": old_settings,
"new_settings": settings,
},
)
return {
"message": "模型能力配置已更新",
"model_capability_settings": user.model_capability_settings,
}, user.email
def _build_time_range_params( def _build_time_range_params(
start_date: date | None, start_date: date | None,
end_date: date | None, end_date: date | None,
@@ -486,27 +762,15 @@ class UpdateProfileAdapter(AuthenticatedApiAdapter):
raise InvalidRequestException(translate_pydantic_error(errors[0])) raise InvalidRequestException(translate_pydantic_error(errors[0]))
raise InvalidRequestException("请求数据验证失败") raise InvalidRequestException("请求数据验证失败")
if request.email: result, old_email, new_email = await run_in_threadpool(
existing = ( _update_profile_sync,
db.query(User).filter(User.email == request.email, User.id != user.id).first() user.id,
) request,
if existing: )
raise InvalidRequestException("邮箱已被使用") await UserCacheService.invalidate_user_cache(user.id, old_email)
user.email = request.email if new_email and new_email != old_email:
await UserCacheService.invalidate_user_cache(user.id, new_email)
if request.username: return result
existing = (
db.query(User).filter(User.username == request.username, User.id != user.id).first()
)
if existing:
raise InvalidRequestException("用户名已被使用")
user.username = request.username
user.updated_at = datetime.now(timezone.utc)
db.commit()
context.request.state.tx_committed_by_route = True
db.refresh(user)
return {"message": "个人信息更新成功"}
class ChangePasswordAdapter(AuthenticatedApiAdapter): class ChangePasswordAdapter(AuthenticatedApiAdapter):
@@ -524,35 +788,13 @@ class ChangePasswordAdapter(AuthenticatedApiAdapter):
raise InvalidRequestException(translate_pydantic_error(errors[0])) raise InvalidRequestException(translate_pydantic_error(errors[0]))
raise InvalidRequestException("请求数据验证失败") raise InvalidRequestException("请求数据验证失败")
# LDAP 用户不能修改密码 result, email, action = await run_in_threadpool(
from src.core.enums import AuthSource _change_password_sync,
user.id,
if user.auth_source == AuthSource.LDAP: request,
raise ForbiddenException("LDAP 用户不能在此修改密码") )
logger.info(f"用户{action}密码: {email}")
# 判断用户是否已有密码 return result
has_password = bool(user.password_hash)
if has_password:
# 已有密码:需要验证旧密码
if not request.old_password:
raise InvalidRequestException("请输入当前密码")
if not user.verify_password(request.old_password):
raise InvalidRequestException("旧密码错误")
# 无密码(如 OAuth 用户首次设置):无需旧密码
policy_level = SystemConfigService.get_password_policy_level(db)
valid, error_msg = PasswordValidator.validate(request.new_password, policy=policy_level)
if not valid:
raise InvalidRequestException(error_msg or "密码格式无效")
user.set_password(request.new_password)
user.updated_at = datetime.now(timezone.utc)
db.commit()
context.request.state.tx_committed_by_route = True
action = "修改" if has_password else "设置"
logger.info(f"用户{action}密码: {user.email}")
return {"message": f"密码{action}成功"}
class ListMyApiKeysAdapter(AuthenticatedApiAdapter): class ListMyApiKeysAdapter(AuthenticatedApiAdapter):
@@ -639,22 +881,8 @@ class CreateMyApiKeyAdapter(AuthenticatedApiAdapter):
if errors: if errors:
raise InvalidRequestException(translate_pydantic_error(errors[0])) raise InvalidRequestException(translate_pydantic_error(errors[0]))
raise InvalidRequestException("请求数据验证失败") raise InvalidRequestException("请求数据验证失败")
try:
api_key, plain_key = ApiKeyService.create_api_key(
db=context.db,
user_id=context.user.id,
name=request.name,
)
except ValueError as exc:
raise InvalidRequestException(str(exc))
return { return await run_in_threadpool(_create_my_api_key_sync, context.user.id, request)
"id": api_key.id,
"name": api_key.name,
"key": plain_key,
"key_display": api_key.get_display_key(),
"message": "API密钥创建成功",
}
@dataclass @dataclass
@@ -729,20 +957,7 @@ class DeleteMyApiKeyAdapter(AuthenticatedApiAdapter):
key_id: str key_id: str
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
api_key = ( return await run_in_threadpool(_delete_my_api_key_sync, context.user.id, self.key_id)
context.db.query(ApiKey)
.filter(ApiKey.id == self.key_id, ApiKey.user_id == context.user.id)
.first()
)
if not api_key:
raise NotFoundException("API密钥不存在", "api_key")
if api_key.is_locked:
raise ForbiddenException("该密钥已被管理员锁定,无法删除")
pre_clean_api_key(context.db, api_key.id)
context.db.delete(api_key)
context.db.commit()
context.request.state.tx_committed_by_route = True
return {"message": "API密钥已删除"}
@dataclass @dataclass
@@ -752,24 +967,7 @@ class ToggleMyApiKeyAdapter(AuthenticatedApiAdapter):
key_id: str key_id: str
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
api_key = ( return await run_in_threadpool(_toggle_my_api_key_sync, context.user.id, self.key_id)
context.db.query(ApiKey)
.filter(ApiKey.id == self.key_id, ApiKey.user_id == context.user.id)
.first()
)
if not api_key:
raise NotFoundException("API密钥不存在", "api_key")
if api_key.is_locked:
raise ForbiddenException("该密钥已被管理员锁定,无法修改状态")
api_key.is_active = not api_key.is_active
context.db.commit()
context.request.state.tx_committed_by_route = True
context.db.refresh(api_key)
return {
"id": api_key.id,
"is_active": api_key.is_active,
"message": f"API密钥已{'启用' if api_key.is_active else '禁用'}",
}
@dataclass @dataclass
@@ -1460,38 +1658,14 @@ class UpdateApiKeyProvidersAdapter(AuthenticatedApiAdapter):
raise InvalidRequestException(translate_pydantic_error(errors[0])) raise InvalidRequestException(translate_pydantic_error(errors[0]))
raise InvalidRequestException("请求数据验证失败") raise InvalidRequestException("请求数据验证失败")
api_key = ( result = await run_in_threadpool(
db.query(ApiKey).filter(ApiKey.id == self.api_key_id, ApiKey.user_id == user.id).first() _update_api_key_providers_sync,
user.id,
self.api_key_id,
request,
) )
if not api_key:
raise NotFoundException("API密钥不存在")
if api_key.is_locked:
raise ForbiddenException("该密钥已被管理员锁定,无法修改")
if request.allowed_providers is not None and len(request.allowed_providers) > 0:
provider_ids = [cfg.provider_id for cfg in request.allowed_providers]
valid = (
db.query(Provider.id)
.filter(Provider.id.in_(provider_ids), Provider.is_active.is_(True))
.all()
)
valid_ids = {p.id for p in valid}
invalid = set(provider_ids) - valid_ids
if invalid:
raise InvalidRequestException(f"无效的提供商ID: {', '.join(invalid)}")
# 只存储 provider_id 列表,而不是完整的 ProviderConfig 字典
# 因为 allowed_providers 字段设计为存储 provider ID 字符串列表
api_key.allowed_providers = (
[cfg.provider_id for cfg in request.allowed_providers]
if request.allowed_providers is not None
else None
)
api_key.updated_at = datetime.now(timezone.utc)
db.commit()
context.request.state.tx_committed_by_route = True
logger.debug(f"用户 {user.id} 更新API密钥 {self.api_key_id} 的可用提供商") logger.debug(f"用户 {user.id} 更新API密钥 {self.api_key_id} 的可用提供商")
return {"message": "API密钥可用提供商已更新"} return result
@dataclass @dataclass
@@ -1509,59 +1683,16 @@ class UpdateApiKeyCapabilitiesAdapter(AuthenticatedApiAdapter):
user = context.user user = context.user
payload = context.ensure_json_body() payload = context.ensure_json_body()
api_key = ( result = await run_in_threadpool(
db.query(ApiKey).filter(ApiKey.id == self.api_key_id, ApiKey.user_id == user.id).first() _update_api_key_capabilities_sync,
user.id,
self.api_key_id,
payload,
) )
if not api_key:
raise NotFoundException("API密钥不存在")
if api_key.is_locked:
raise ForbiddenException("该密钥已被管理员锁定,无法修改")
# 保存旧值用于审计
old_capabilities = api_key.force_capabilities
# 验证 force_capabilities 字段
force_capabilities = payload.get("force_capabilities")
if force_capabilities is not None:
if not isinstance(force_capabilities, dict):
raise InvalidRequestException("force_capabilities 必须是对象类型")
# 验证只允许用户可配置的能力
for cap_name, cap_value in force_capabilities.items():
cap_def = CAPABILITY_DEFINITIONS.get(cap_name)
if not cap_def:
raise InvalidRequestException(f"未知的能力类型: {cap_name}")
if cap_def.config_mode != CapabilityConfigMode.USER_CONFIGURABLE:
raise InvalidRequestException(f"能力 {cap_name} 不支持用户配置")
if not isinstance(cap_value, bool):
raise InvalidRequestException(f"能力 {cap_name} 的值必须是布尔类型")
api_key.force_capabilities = force_capabilities
api_key.updated_at = datetime.now(timezone.utc)
db.commit()
context.request.state.tx_committed_by_route = True
# 记录审计日志
audit_service.log_event(
db=db,
event_type=AuditEventType.CONFIG_CHANGED,
description=f"用户更新 API Key 能力配置",
user_id=user.id,
api_key_id=api_key.id,
metadata={
"action": "update_api_key_capabilities",
"old_capabilities": old_capabilities,
"new_capabilities": force_capabilities,
},
)
logger.debug( logger.debug(
f"用户 {user.id} 更新API密钥 {self.api_key_id} 的强制能力配置: {force_capabilities}" f"用户 {user.id} 更新API密钥 {self.api_key_id} 的强制能力配置: {result['force_capabilities']}"
) )
return { return result
"message": "API密钥能力配置已更新",
"force_capabilities": api_key.force_capabilities,
}
class GetPreferencesAdapter(AuthenticatedApiAdapter): class GetPreferencesAdapter(AuthenticatedApiAdapter):
@@ -1600,20 +1731,7 @@ class UpdatePreferencesAdapter(AuthenticatedApiAdapter):
raise InvalidRequestException(translate_pydantic_error(errors[0])) raise InvalidRequestException(translate_pydantic_error(errors[0]))
raise InvalidRequestException("请求数据验证失败") raise InvalidRequestException("请求数据验证失败")
PreferenceService.update_preferences( return await run_in_threadpool(_update_preferences_sync, context.user.id, request)
db=context.db,
user_id=context.user.id,
avatar_url=request.avatar_url,
bio=request.bio,
default_provider_id=request.default_provider_id,
theme=request.theme,
language=request.language,
timezone=request.timezone,
email_notifications=request.email_notifications,
usage_alerts=request.usage_alerts,
announcement_notifications=request.announcement_notifications,
)
return {"message": "偏好设置更新成功"}
class GetModelCapabilitySettingsAdapter(AuthenticatedApiAdapter): class GetModelCapabilitySettingsAdapter(AuthenticatedApiAdapter):
@@ -1632,68 +1750,19 @@ class UpdateModelCapabilitySettingsAdapter(AuthenticatedApiAdapter):
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
from src.core.key_capabilities import CAPABILITY_DEFINITIONS, CapabilityConfigMode from src.core.key_capabilities import CAPABILITY_DEFINITIONS, CapabilityConfigMode
from src.models.database import AuditEventType from src.models.database import AuditEventType
from src.services.cache.user_cache import UserCacheService
from src.services.system.audit import audit_service from src.services.system.audit import audit_service
db = context.db
# 重新从数据库查询用户,确保在 session 中context.user 可能来自缓存,是分离对象)
user = db.query(User).filter(User.id == context.user.id).first()
if not user:
raise NotFoundException("用户不存在")
payload = context.ensure_json_body() payload = context.ensure_json_body()
result, email = await run_in_threadpool(
# 保存旧值用于审计 _update_model_capability_settings_sync,
old_settings = user.model_capability_settings context.user.id,
payload,
# 验证 model_capability_settings 字段
settings = payload.get("model_capability_settings")
if settings is not None:
if not isinstance(settings, dict):
raise InvalidRequestException("model_capability_settings 必须是对象类型")
# 验证每个模型的能力配置
for model_name, capabilities in settings.items():
if not isinstance(model_name, str):
raise InvalidRequestException("模型名称必须是字符串")
if not isinstance(capabilities, dict):
raise InvalidRequestException(f"模型 {model_name} 的能力配置必须是对象类型")
# 验证只允许用户可配置的能力
for cap_name, cap_value in capabilities.items():
cap_def = CAPABILITY_DEFINITIONS.get(cap_name)
if not cap_def:
raise InvalidRequestException(f"未知的能力类型: {cap_name}")
if cap_def.config_mode != CapabilityConfigMode.USER_CONFIGURABLE:
raise InvalidRequestException(f"能力 {cap_name} 不支持用户配置")
if not isinstance(cap_value, bool):
raise InvalidRequestException(f"能力 {cap_name} 的值必须是布尔类型")
user.model_capability_settings = settings
user.updated_at = datetime.now(timezone.utc)
db.commit()
context.request.state.tx_committed_by_route = True
# 清除用户缓存,确保下次读取时获取最新数据
await UserCacheService.invalidate_user_cache(user.id, user.email)
# 记录审计日志
audit_service.log_event(
db=db,
event_type=AuditEventType.CONFIG_CHANGED,
description=f"用户更新模型能力配置",
user_id=user.id,
metadata={
"action": "update_model_capability_settings",
"old_settings": old_settings,
"new_settings": settings,
},
) )
await UserCacheService.invalidate_user_cache(context.user.id, email)
logger.debug(f"用户 {user.id} 更新模型能力配置: {settings}") logger.debug(
return { f"用户 {context.user.id} 更新模型能力配置: {result['model_capability_settings']}"
"message": "模型能力配置已更新", )
"model_capability_settings": user.model_capability_settings, return result
}
class GetEndpointStatusAdapter(AuthenticatedApiAdapter): class GetEndpointStatusAdapter(AuthenticatedApiAdapter):

View File

@@ -9,6 +9,7 @@ from uuid import uuid4
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, Query, Request from fastapi import APIRouter, Depends, Query, Request
from fastapi.concurrency import run_in_threadpool
from pydantic import BaseModel, Field, ValidationError from pydantic import BaseModel, Field, ValidationError
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -25,10 +26,11 @@ from src.api.serializers import (
serialize_wallet_transaction, serialize_wallet_transaction,
) )
from src.core.exceptions import InvalidRequestException, NotFoundException, translate_pydantic_error from src.core.exceptions import InvalidRequestException, NotFoundException, translate_pydantic_error
from src.database import get_db from src.database import get_db, get_db_context
from src.models.database import ( from src.models.database import (
PaymentOrder, PaymentOrder,
RefundRequest, RefundRequest,
User,
Wallet, Wallet,
WalletDailyUsageLedger, WalletDailyUsageLedger,
WalletTransaction, WalletTransaction,
@@ -40,6 +42,154 @@ router = APIRouter(prefix="/api/wallet", tags=["Wallet"])
pipeline = ApiRequestPipeline() pipeline = ApiRequestPipeline()
def _create_recharge_order_sync(user_id: str, req: CreateRechargePayload) -> dict[str, Any]:
with get_db_context() as db:
user = db.query(User).filter(User.id == user_id).first()
if user is None:
raise InvalidRequestException("未登录")
try:
order = PaymentService.create_recharge_order(
db,
user=user,
amount_usd=req.amount_usd,
payment_method=req.payment_method,
pay_amount=req.pay_amount,
pay_currency=req.pay_currency,
exchange_rate=req.exchange_rate,
)
except ValueError as exc:
raise InvalidRequestException(str(exc)) from exc
db.commit()
db.refresh(order)
return {
"order": serialize_payment_order(order, sanitize_gateway_response=True),
"payment_instructions": safe_gateway_response(order.gateway_response),
}
def _list_recharge_orders_sync(user_id: str, limit: int, offset: int) -> dict[str, Any]:
with get_db_context() as db:
items, total, _changed = PaymentService.list_user_orders(
db,
user_id=user_id,
limit=limit,
offset=offset,
)
return {
"items": [
serialize_payment_order(item, sanitize_gateway_response=True) for item in items
],
"total": total,
"limit": limit,
"offset": offset,
}
def _get_recharge_order_sync(user_id: str, order_id: str) -> dict[str, Any]:
with get_db_context() as db:
order = PaymentService.get_user_order(db, user_id=user_id, order_id=order_id)
if order is None:
raise NotFoundException("Payment order not found")
PaymentService.refresh_order_status(order)
return {"order": serialize_payment_order(order, sanitize_gateway_response=True)}
def _list_refunds_sync(user_id: str, limit: int, offset: int) -> dict[str, Any]:
with get_db_context() as db:
user = db.query(User).filter(User.id == user_id).first()
if user is None:
raise InvalidRequestException("未登录")
existing_wallet = WalletService.get_wallet(db, user_id=user.id)
wallet = existing_wallet or WalletService.get_or_create_wallet(db, user=user)
if wallet is None:
return {"items": [], "total": 0, "limit": limit, "offset": offset}
if existing_wallet is None:
db.commit()
db.refresh(wallet)
base_query = db.query(RefundRequest).filter(RefundRequest.wallet_id == wallet.id)
total = base_query.count()
items = (
base_query.order_by(RefundRequest.created_at.desc()).offset(offset).limit(limit).all()
)
return {
"items": [serialize_wallet_refund(item) for item in items],
"total": total,
"limit": limit,
"offset": offset,
}
def _create_refund_sync(user_id: str, req: CreateRefundPayload) -> dict[str, Any]:
with get_db_context() as db:
user = db.query(User).filter(User.id == user_id).first()
if user is None:
raise InvalidRequestException("未登录")
wallet = WalletService.get_or_create_wallet(db, user=user)
if wallet is None:
raise InvalidRequestException("当前账户尚未开通钱包,无法申请退款")
payment_order = None
source_type = req.source_type or "wallet_balance"
source_id = req.source_id
refund_mode = req.refund_mode or "offline_payout"
if req.payment_order_id:
payment_order = (
db.query(PaymentOrder)
.filter(
PaymentOrder.id == req.payment_order_id, PaymentOrder.wallet_id == wallet.id
)
.first()
)
if payment_order is None:
raise NotFoundException("Payment order not found")
source_type = "payment_order"
source_id = payment_order.id
refund_mode = req.refund_mode or _default_refund_mode_for_order(payment_order)
try:
refund = WalletService.create_refund_request(
db,
wallet=wallet,
user_id=user.id,
amount_usd=req.amount_usd,
refund_no=_build_refund_no(),
source_type=source_type,
source_id=source_id,
refund_mode=refund_mode,
payment_order=payment_order,
reason=req.reason,
requested_by=user.id,
idempotency_key=req.idempotency_key,
)
db.commit()
db.refresh(refund)
return serialize_wallet_refund(refund)
except ValueError as exc:
db.rollback()
raise InvalidRequestException(str(exc)) from exc
except IntegrityError:
db.rollback()
if req.idempotency_key:
existing = (
db.query(RefundRequest)
.filter(
RefundRequest.idempotency_key == req.idempotency_key,
RefundRequest.user_id == user.id,
)
.first()
)
if existing is not None:
return serialize_wallet_refund(existing)
raise InvalidRequestException("退款申请重复,请勿重复提交")
class CreateRefundPayload(BaseModel): class CreateRefundPayload(BaseModel):
amount_usd: float = Field(..., gt=0, allow_inf_nan=False) amount_usd: float = Field(..., gt=0, allow_inf_nan=False)
payment_order_id: str | None = None payment_order_id: str | None = None
@@ -340,25 +490,7 @@ class WalletRechargeCreateAdapter(AuthenticatedApiAdapter):
raise InvalidRequestException(translate_pydantic_error(errors[0])) raise InvalidRequestException(translate_pydantic_error(errors[0]))
raise InvalidRequestException("请求数据验证失败") raise InvalidRequestException("请求数据验证失败")
try: return await run_in_threadpool(_create_recharge_order_sync, user.id, req)
order = PaymentService.create_recharge_order(
db,
user=user,
amount_usd=req.amount_usd,
payment_method=req.payment_method,
pay_amount=req.pay_amount,
pay_currency=req.pay_currency,
exchange_rate=req.exchange_rate,
)
except ValueError as exc:
raise InvalidRequestException(str(exc))
db.commit()
db.refresh(order)
return {
"order": serialize_payment_order(order, sanitize_gateway_response=True),
"payment_instructions": safe_gateway_response(order.gateway_response),
}
@dataclass @dataclass
@@ -371,22 +503,12 @@ class WalletRechargeListAdapter(AuthenticatedApiAdapter):
if user is None: if user is None:
raise InvalidRequestException("未登录") raise InvalidRequestException("未登录")
items, total, changed = PaymentService.list_user_orders( return await run_in_threadpool(
context.db, _list_recharge_orders_sync,
user_id=user.id, user.id,
limit=self.limit, self.limit,
offset=self.offset, self.offset,
) )
if changed:
context.db.commit()
return {
"items": [
serialize_payment_order(item, sanitize_gateway_response=True) for item in items
],
"total": total,
"limit": self.limit,
"offset": self.offset,
}
@dataclass @dataclass
@@ -398,12 +520,7 @@ class WalletRechargeDetailAdapter(AuthenticatedApiAdapter):
if user is None: if user is None:
raise InvalidRequestException("未登录") raise InvalidRequestException("未登录")
order = PaymentService.get_user_order(context.db, user_id=user.id, order_id=self.order_id) return await run_in_threadpool(_get_recharge_order_sync, user.id, self.order_id)
if order is None:
raise NotFoundException("Payment order not found")
if PaymentService.refresh_order_status(order):
context.db.commit()
return {"order": serialize_payment_order(order, sanitize_gateway_response=True)}
@dataclass @dataclass
@@ -417,29 +534,7 @@ class WalletRefundListAdapter(AuthenticatedApiAdapter):
if user is None: if user is None:
raise InvalidRequestException("未登录") raise InvalidRequestException("未登录")
existing_wallet = WalletService.get_wallet(db, user_id=user.id) return await run_in_threadpool(_list_refunds_sync, user.id, self.limit, self.offset)
wallet = existing_wallet or WalletService.get_or_create_wallet(db, user=user)
if wallet is None:
return {"items": [], "total": 0, "limit": self.limit, "offset": self.offset}
if existing_wallet is None:
db.commit()
db.refresh(wallet)
base_query = db.query(RefundRequest).filter(RefundRequest.wallet_id == wallet.id)
total = base_query.count()
items = (
base_query.order_by(RefundRequest.created_at.desc())
.offset(self.offset)
.limit(self.limit)
.all()
)
return {
"items": [serialize_wallet_refund(item) for item in items],
"total": total,
"limit": self.limit,
"offset": self.offset,
}
@dataclass @dataclass
@@ -478,65 +573,4 @@ class WalletRefundCreateAdapter(AuthenticatedApiAdapter):
raise InvalidRequestException(translate_pydantic_error(errors[0])) raise InvalidRequestException(translate_pydantic_error(errors[0]))
raise InvalidRequestException("请求数据验证失败") raise InvalidRequestException("请求数据验证失败")
wallet = WalletService.get_or_create_wallet(db, user=user) return await run_in_threadpool(_create_refund_sync, user.id, req)
if wallet is None:
raise InvalidRequestException("当前账户尚未开通钱包,无法申请退款")
payment_order = None
source_type = req.source_type or "wallet_balance"
source_id = req.source_id
refund_mode = req.refund_mode or "offline_payout"
if req.payment_order_id:
payment_order = (
db.query(PaymentOrder)
.filter(
PaymentOrder.id == req.payment_order_id,
PaymentOrder.wallet_id == wallet.id,
)
.first()
)
if payment_order is None:
raise NotFoundException("Payment order not found")
source_type = "payment_order"
source_id = payment_order.id
refund_mode = req.refund_mode or _default_refund_mode_for_order(payment_order)
try:
refund = WalletService.create_refund_request(
db,
wallet=wallet,
user_id=user.id,
amount_usd=req.amount_usd,
refund_no=_build_refund_no(),
source_type=source_type,
source_id=source_id,
refund_mode=refund_mode,
payment_order=payment_order,
reason=req.reason,
requested_by=user.id,
idempotency_key=req.idempotency_key,
)
db.commit()
db.refresh(refund)
return serialize_wallet_refund(refund)
except ValueError as exc:
db.rollback()
raise InvalidRequestException(str(exc)) from exc
except IntegrityError:
db.rollback()
if req.idempotency_key:
existing = (
db.query(RefundRequest)
.filter(
RefundRequest.idempotency_key == req.idempotency_key,
RefundRequest.user_id == user.id,
)
.first()
)
if existing is not None:
return serialize_wallet_refund(existing)
raise InvalidRequestException("退款申请重复,请勿重复提交")
except ValueError as exc:
db.rollback()
raise InvalidRequestException(str(exc))

View File

@@ -6,6 +6,7 @@ from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
import httpx import httpx
from fastapi import HTTPException, status from fastapi import HTTPException, status
from fastapi.concurrency import run_in_threadpool
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -15,6 +16,7 @@ from src.core.enums import AuthSource, UserRole
from src.core.exceptions import ConfirmationRequiredException, InvalidRequestException from src.core.exceptions import ConfirmationRequiredException, InvalidRequestException
from src.core.logger import logger from src.core.logger import logger
from src.core.modules import get_module_registry from src.core.modules import get_module_registry
from src.database import get_db_context
from src.models.database import OAuthProvider, User, UserOAuthLink from src.models.database import OAuthProvider, User, UserOAuthLink
from src.services.auth.oauth.base import OAuthProviderBase from src.services.auth.oauth.base import OAuthProviderBase
from src.services.auth.oauth.models import OAuthFlowError, OAuthUserInfo from src.services.auth.oauth.models import OAuthFlowError, OAuthUserInfo
@@ -39,6 +41,322 @@ def _build_oauth_client_kwargs(
class OAuthService: class OAuthService:
"""OAuth 核心业务服务v1""" """OAuth 核心业务服务v1"""
@staticmethod
def _handle_login_sync(provider_type: str, oauth_user: OAuthUserInfo) -> User:
now = datetime.now(timezone.utc)
with get_db_context() as db:
existing_link = (
db.query(UserOAuthLink)
.filter(
UserOAuthLink.provider_type == provider_type,
UserOAuthLink.provider_user_id == oauth_user.id,
)
.first()
)
if existing_link:
linked_user = db.query(User).filter(User.id == existing_link.user_id).first()
if not linked_user or not linked_user.is_active or linked_user.is_deleted:
raise OAuthFlowError("account_disabled", "用户不存在或已禁用")
linked_user.last_login_at = now
existing_link.last_login_at = now
db.commit()
db.expunge(linked_user)
return linked_user
enable_registration = SystemConfigService.get_config(
db, "enable_registration", default=False
)
if not enable_registration:
raise OAuthFlowError("registration_disabled")
email = oauth_user.email
if email:
if not OAuthService._validate_email_suffix(db, email):
raise OAuthFlowError("email_suffix_denied")
existing_user = db.query(User).filter(User.email == email).first()
if existing_user and not existing_user.is_deleted:
if existing_user.auth_source == AuthSource.LOCAL:
raise OAuthFlowError("email_exists_local")
if existing_user.auth_source == AuthSource.LDAP:
raise OAuthFlowError("email_is_ldap")
raise OAuthFlowError("email_is_oauth")
base_username = (
oauth_user.username
or (email.split("@", 1)[0] if email else None)
or f"user_{uuid.uuid4().hex[:8]}"
)
default_initial_gift = SystemConfigService.get_config(
db, "default_user_initial_gift_usd", default=None
)
user: User | None = None
last_error: Exception | None = None
for _ in range(3):
try:
username = OAuthService._generate_unique_username(db, base_username)
user = User(
email=email,
email_verified=bool(oauth_user.email_verified) if email else False,
username=username,
password_hash=None,
auth_source=AuthSource.OAUTH,
role=UserRole.USER,
is_active=True,
last_login_at=now,
)
db.add(user)
db.flush()
from src.services.wallet import WalletService
WalletService.initialize_user_wallet(
db,
user=user,
initial_gift_usd=default_initial_gift,
unlimited=False,
description="OAuth 注册初始赠款",
)
db.commit()
db.refresh(user)
last_error = None
break
except IntegrityError as e:
db.rollback()
last_error = e
except Exception as e:
db.rollback()
last_error = e
if last_error is not None or user is None:
raise OAuthFlowError("provider_error", "user_create_failed")
assert user.id is not None
try:
link = UserOAuthLink(
user_id=user.id,
provider_type=provider_type,
provider_user_id=oauth_user.id,
provider_username=oauth_user.username,
provider_email=email,
extra_data=oauth_user.raw,
linked_at=now,
last_login_at=now,
)
db.add(link)
db.commit()
except IntegrityError as e:
db.rollback()
constraint = OAuthService._get_constraint_name(e)
if constraint == "uq_oauth_provider_user":
existing_link = (
db.query(UserOAuthLink)
.filter(
UserOAuthLink.provider_type == provider_type,
UserOAuthLink.provider_user_id == oauth_user.id,
)
.first()
)
if existing_link:
existing_user = (
db.query(User).filter(User.id == existing_link.user_id).first()
)
if (
existing_user
and existing_user.is_active
and not existing_user.is_deleted
):
existing_user.last_login_at = now
existing_link.last_login_at = now
db.commit()
db.expunge(existing_user)
return existing_user
raise OAuthFlowError("oauth_already_bound")
raise OAuthFlowError("provider_error", "link_create_failed")
db.expunge(user)
return user
@staticmethod
def _handle_bind_sync(
user_id: str, provider_type: str, oauth_user: OAuthUserInfo
) -> UserOAuthLink:
now = datetime.now(timezone.utc)
with get_db_context() as db:
user = db.query(User).filter(User.id == user_id).first()
if not user or not user.is_active or user.is_deleted:
raise OAuthFlowError("user_not_found")
if user.auth_source == AuthSource.LDAP:
raise OAuthFlowError("ldap_no_oauth")
link = UserOAuthLink(
user_id=user.id,
provider_type=provider_type,
provider_user_id=oauth_user.id,
provider_username=oauth_user.username,
provider_email=oauth_user.email,
extra_data=oauth_user.raw,
linked_at=now,
)
try:
db.add(link)
db.commit()
db.refresh(link)
db.expunge(link)
return link
except IntegrityError as e:
db.rollback()
constraint = OAuthService._get_constraint_name(e)
if constraint == "uq_oauth_provider_user":
existing = (
db.query(UserOAuthLink)
.filter(
UserOAuthLink.provider_type == provider_type,
UserOAuthLink.provider_user_id == oauth_user.id,
)
.first()
)
if existing and existing.user_id == user.id:
db.expunge(existing)
return existing
raise OAuthFlowError("oauth_already_bound")
if constraint == "uq_user_oauth_provider":
raise OAuthFlowError("already_bound_provider")
raise OAuthFlowError("provider_error", "bind_failed")
@staticmethod
def _upsert_provider_config_sync(provider_type: str, data: Any) -> OAuthProvider:
with get_db_context() as db:
provider = OAuthService._get_provider_impl(provider_type)
if not provider:
raise InvalidRequestException("不支持的 provider_type")
OAuthService._validate_provider_config(provider, data)
row = (
db.query(OAuthProvider).filter(OAuthProvider.provider_type == provider_type).first()
)
creating = row is None
if not row:
row = OAuthProvider(provider_type=provider_type)
db.add(row)
if row.is_enabled and data.is_enabled is False:
affected = OAuthService._check_provider_disable_safety(db, provider_type)
if affected and not getattr(data, "force", False):
raise ConfirmationRequiredException(
message=f"禁用该 Provider 会导致 {len(affected)} 个用户无法登录",
affected_count=len(affected),
action="disable_oauth_provider",
)
row.display_name = data.display_name
row.client_id = data.client_id
row.authorization_url_override = data.authorization_url_override
row.token_url_override = data.token_url_override
row.userinfo_url_override = data.userinfo_url_override
row.scopes = data.scopes
row.redirect_uri = data.redirect_uri
row.frontend_callback_url = data.frontend_callback_url
row.attribute_mapping = data.attribute_mapping
row.extra_config = data.extra_config
row.is_enabled = data.is_enabled
if data.client_secret is not None:
secret_value = data.client_secret.strip()
if secret_value == "__CLEAR__":
row.client_secret_encrypted = None
elif secret_value:
row.set_client_secret(secret_value)
try:
db.commit()
db.refresh(row)
except Exception:
db.rollback()
raise
db.expunge(row)
if creating:
logger.info("OAuth provider 配置已创建: {}", provider_type)
else:
logger.info("OAuth provider 配置已更新: {}", provider_type)
return row
@staticmethod
def _delete_provider_config_sync(provider_type: str) -> None:
with get_db_context() as db:
row = (
db.query(OAuthProvider).filter(OAuthProvider.provider_type == provider_type).first()
)
if not row:
raise InvalidRequestException("Provider 配置不存在")
if row.is_enabled:
affected = OAuthService._check_provider_disable_safety(db, provider_type)
if affected:
raise InvalidRequestException(
f"删除该 Provider 会导致部分用户无法登录(数量: {len(affected)}),已阻止操作"
)
db.delete(row)
@staticmethod
def _unbind_provider_sync(user_id: str, provider_type: str) -> None:
with get_db_context() as db:
OAuthService._require_module_active(db)
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise InvalidRequestException("用户不存在")
if user.auth_source == AuthSource.LDAP:
raise HTTPException(status_code=403, detail="LDAP 用户不允许解绑 OAuth")
link = (
db.query(UserOAuthLink)
.filter(
UserOAuthLink.user_id == user.id, UserOAuthLink.provider_type == provider_type
)
.first()
)
if not link:
raise InvalidRequestException("未绑定该 Provider")
total_links = (
db.query(func.count(UserOAuthLink.id))
.filter(UserOAuthLink.user_id == user.id)
.scalar()
or 0
)
if user.auth_source == AuthSource.OAUTH and total_links <= 1:
raise InvalidRequestException("OAUTH 用户必须至少保留一个 OAuth 绑定")
if user.auth_source == AuthSource.LOCAL and not user.password_hash and total_links <= 1:
raise InvalidRequestException("请先设置密码后再解绑")
from src.core.modules.hooks import AUTH_CHECK_EXCLUSIVE_MODE, get_hook_dispatcher
is_exclusive = get_hook_dispatcher().dispatch_sync(AUTH_CHECK_EXCLUSIVE_MODE, db=db)
if (
is_exclusive
and user.auth_source == AuthSource.LOCAL
and user.role != UserRole.ADMIN
):
if total_links <= 1:
raise InvalidRequestException("当前处于 LDAP 专属模式,解绑后将无法登录")
db.delete(link)
@staticmethod @staticmethod
def _require_module_active(db: Session) -> None: def _require_module_active(db: Session) -> None:
registry = get_module_registry() registry = get_module_registry()
@@ -379,144 +697,11 @@ class OAuthService:
async def _handle_login( async def _handle_login(
db: Session, *, config: OAuthProvider, oauth_user: OAuthUserInfo db: Session, *, config: OAuthProvider, oauth_user: OAuthUserInfo
) -> User: ) -> User:
now = datetime.now(timezone.utc) user = await run_in_threadpool(
OAuthService._handle_login_sync,
# 1) 已绑定账号:直接登录 config.provider_type,
existing_link = ( oauth_user,
db.query(UserOAuthLink)
.filter(
UserOAuthLink.provider_type == config.provider_type,
UserOAuthLink.provider_user_id == oauth_user.id,
)
.first()
) )
if existing_link:
linked_user = db.query(User).filter(User.id == existing_link.user_id).first()
if not linked_user or not linked_user.is_active or linked_user.is_deleted:
raise OAuthFlowError("account_disabled", "用户不存在或已禁用")
linked_user.last_login_at = now
existing_link.last_login_at = now
db.commit()
assert linked_user.id is not None
await UserCacheService.invalidate_user_cache(linked_user.id, linked_user.email)
return linked_user
# 2) 未绑定账号:可能需要新建用户(受注册开关控制)
enable_registration = SystemConfigService.get_config(
db, "enable_registration", default=False
)
if not enable_registration:
raise OAuthFlowError("registration_disabled")
email = oauth_user.email
if email:
if not OAuthService._validate_email_suffix(db, email):
raise OAuthFlowError("email_suffix_denied")
existing_user = db.query(User).filter(User.email == email).first()
# 已删除用户不阻塞新建(邮箱可复用)
if existing_user and not existing_user.is_deleted:
if existing_user.auth_source == AuthSource.LOCAL:
raise OAuthFlowError("email_exists_local")
if existing_user.auth_source == AuthSource.LDAP:
raise OAuthFlowError("email_is_ldap")
raise OAuthFlowError("email_is_oauth")
base_username = (
oauth_user.username
or (email.split("@", 1)[0] if email else None)
or f"user_{uuid.uuid4().hex[:8]}"
)
default_initial_gift = SystemConfigService.get_config(
db, "default_user_initial_gift_usd", default=None
)
# 生成唯一用户名 + 创建用户(简单重试)
user: User | None = None
last_error: Exception | None = None
for _ in range(3):
try:
username = OAuthService._generate_unique_username(db, base_username)
user = User(
email=email,
email_verified=bool(oauth_user.email_verified) if email else False,
username=username,
password_hash=None,
auth_source=AuthSource.OAUTH,
role=UserRole.USER,
is_active=True,
last_login_at=now,
)
db.add(user)
db.flush()
from src.services.wallet import WalletService
WalletService.initialize_user_wallet(
db,
user=user,
initial_gift_usd=default_initial_gift,
unlimited=False,
description="OAuth 注册初始赠款",
)
db.commit()
db.refresh(user)
last_error = None
break
except IntegrityError as e:
db.rollback()
last_error = e
except Exception as e:
db.rollback()
last_error = e
if last_error is not None or user is None:
raise OAuthFlowError("provider_error", "user_create_failed")
# 创建绑定关系
assert user.id is not None
try:
link = UserOAuthLink(
user_id=user.id,
provider_type=config.provider_type,
provider_user_id=oauth_user.id,
provider_username=oauth_user.username,
provider_email=email,
extra_data=oauth_user.raw,
linked_at=now,
last_login_at=now,
)
db.add(link)
db.commit()
except IntegrityError as e:
db.rollback()
constraint = OAuthService._get_constraint_name(e)
if constraint == "uq_oauth_provider_user":
# 并发:该第三方账号已先被绑定,尝试读取并登录
existing_link = (
db.query(UserOAuthLink)
.filter(
UserOAuthLink.provider_type == config.provider_type,
UserOAuthLink.provider_user_id == oauth_user.id,
)
.first()
)
if existing_link:
existing_user = db.query(User).filter(User.id == existing_link.user_id).first()
if existing_user and existing_user.is_active and not existing_user.is_deleted:
existing_user.last_login_at = now
existing_link.last_login_at = now
db.commit()
assert existing_user.id is not None
await UserCacheService.invalidate_user_cache(
existing_user.id, existing_user.email
)
return existing_user
raise OAuthFlowError("oauth_already_bound")
raise OAuthFlowError("provider_error", "link_create_failed")
assert user.id is not None assert user.id is not None
await UserCacheService.invalidate_user_cache(user.id, user.email) await UserCacheService.invalidate_user_cache(user.id, user.email)
return user return user
@@ -525,50 +710,13 @@ class OAuthService:
async def _handle_bind( async def _handle_bind(
db: Session, *, user_id: str, config: OAuthProvider, oauth_user: OAuthUserInfo db: Session, *, user_id: str, config: OAuthProvider, oauth_user: OAuthUserInfo
) -> UserOAuthLink: ) -> UserOAuthLink:
now = datetime.now(timezone.utc) return await run_in_threadpool(
user = db.query(User).filter(User.id == user_id).first() OAuthService._handle_bind_sync,
if not user or not user.is_active or user.is_deleted: user_id,
raise OAuthFlowError("user_not_found") config.provider_type,
if user.auth_source == AuthSource.LDAP: oauth_user,
raise OAuthFlowError("ldap_no_oauth")
link = UserOAuthLink(
user_id=user.id,
provider_type=config.provider_type,
provider_user_id=oauth_user.id,
provider_username=oauth_user.username,
provider_email=oauth_user.email,
extra_data=oauth_user.raw,
linked_at=now,
) )
try:
db.add(link)
db.commit()
db.refresh(link)
return link
except IntegrityError as e:
db.rollback()
constraint = OAuthService._get_constraint_name(e)
if constraint == "uq_oauth_provider_user":
existing = (
db.query(UserOAuthLink)
.filter(
UserOAuthLink.provider_type == config.provider_type,
UserOAuthLink.provider_user_id == oauth_user.id,
)
.first()
)
if existing and existing.user_id == user.id:
return existing
raise OAuthFlowError("oauth_already_bound")
if constraint == "uq_user_oauth_provider":
raise OAuthFlowError("already_bound_provider")
raise OAuthFlowError("provider_error", "bind_failed")
@staticmethod @staticmethod
async def list_bindable_providers(db: Session, user: User) -> list[dict[str, str]]: async def list_bindable_providers(db: Session, user: User) -> list[dict[str, str]]:
OAuthService._require_module_active(db) OAuthService._require_module_active(db)
@@ -686,79 +834,13 @@ class OAuthService:
@staticmethod @staticmethod
async def upsert_provider_config(db: Session, provider_type: str, data: Any) -> OAuthProvider: async def upsert_provider_config(db: Session, provider_type: str, data: Any) -> OAuthProvider:
provider = OAuthService._get_provider_impl(provider_type) return await run_in_threadpool(
if not provider: OAuthService._upsert_provider_config_sync, provider_type, data
raise InvalidRequestException("不支持的 provider_type") )
OAuthService._validate_provider_config(provider, data)
row = db.query(OAuthProvider).filter(OAuthProvider.provider_type == provider_type).first()
creating = row is None
if not row:
row = OAuthProvider(provider_type=provider_type)
db.add(row)
# 禁用前防锁号检查(仅在从 enabled -> disabled 时触发)
if row.is_enabled and data.is_enabled is False:
affected = OAuthService._check_provider_disable_safety(db, provider_type)
if affected and not getattr(data, "force", False):
raise ConfirmationRequiredException(
message=f"禁用该 Provider 会导致 {len(affected)} 个用户无法登录",
affected_count=len(affected),
action="disable_oauth_provider",
)
row.display_name = data.display_name
row.client_id = data.client_id
row.authorization_url_override = data.authorization_url_override
row.token_url_override = data.token_url_override
row.userinfo_url_override = data.userinfo_url_override
row.scopes = data.scopes
row.redirect_uri = data.redirect_uri
row.frontend_callback_url = data.frontend_callback_url
row.attribute_mapping = data.attribute_mapping
row.extra_config = data.extra_config
row.is_enabled = data.is_enabled
# client_secret 处理逻辑:
# - None 或空字符串:保持不变
# - "__CLEAR__":清空 secret
# - 其他值:设置新 secret
if data.client_secret is not None:
secret_value = data.client_secret.strip()
if secret_value == "__CLEAR__":
row.client_secret_encrypted = None
elif secret_value:
row.set_client_secret(secret_value)
try:
db.commit()
db.refresh(row)
except Exception:
db.rollback()
raise
if creating:
logger.info("OAuth provider 配置已创建: {}", provider_type)
else:
logger.info("OAuth provider 配置已更新: {}", provider_type)
return row
@staticmethod @staticmethod
async def delete_provider_config(db: Session, provider_type: str) -> None: async def delete_provider_config(db: Session, provider_type: str) -> None:
row = db.query(OAuthProvider).filter(OAuthProvider.provider_type == provider_type).first() await run_in_threadpool(OAuthService._delete_provider_config_sync, provider_type)
if not row:
raise InvalidRequestException("Provider 配置不存在")
if row.is_enabled:
affected = OAuthService._check_provider_disable_safety(db, provider_type)
if affected:
raise InvalidRequestException(
f"删除该 Provider 会导致部分用户无法登录(数量: {len(affected)}),已阻止操作"
)
db.delete(row)
db.commit()
@staticmethod @staticmethod
def _validate_provider_config(provider: OAuthProviderBase, data: Any) -> None: def _validate_provider_config(provider: OAuthProviderBase, data: Any) -> None:
@@ -991,38 +1073,6 @@ class OAuthService:
@staticmethod @staticmethod
async def unbind_provider(db: Session, user: User, provider_type: str) -> None: async def unbind_provider(db: Session, user: User, provider_type: str) -> None:
OAuthService._require_module_active(db) await run_in_threadpool(OAuthService._unbind_provider_sync, user.id, provider_type)
if user.id is not None:
if user.auth_source == AuthSource.LDAP: await UserCacheService.invalidate_user_cache(user.id, user.email)
raise HTTPException(status_code=403, detail="LDAP 用户不允许解绑 OAuth")
link = (
db.query(UserOAuthLink)
.filter(UserOAuthLink.user_id == user.id, UserOAuthLink.provider_type == provider_type)
.first()
)
if not link:
raise InvalidRequestException("未绑定该 Provider")
total_links = (
db.query(func.count(UserOAuthLink.id)).filter(UserOAuthLink.user_id == user.id).scalar()
or 0
)
if user.auth_source == AuthSource.OAUTH and total_links <= 1:
raise InvalidRequestException("OAUTH 用户必须至少保留一个 OAuth 绑定")
# 本地用户无密码时,解绑最后一个 OAuth 会导致无法登录
if user.auth_source == AuthSource.LOCAL and not user.password_hash and total_links <= 1:
raise InvalidRequestException("请先设置密码后再解绑")
from src.core.modules.hooks import AUTH_CHECK_EXCLUSIVE_MODE, get_hook_dispatcher
is_exclusive = get_hook_dispatcher().dispatch_sync(AUTH_CHECK_EXCLUSIVE_MODE, db=db)
if is_exclusive and user.auth_source == AuthSource.LOCAL and user.role != UserRole.ADMIN:
# ldap_exclusive=true 时,普通本地用户解绑最后一个 OAuth 会锁死(密码登录被禁用)
if total_links <= 1:
raise InvalidRequestException("当前处于 LDAP 专属模式,解绑后将无法登录")
db.delete(link)
db.commit()

View File

@@ -11,6 +11,7 @@ from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
from fastapi.concurrency import run_in_threadpool
from sqlalchemy import delete as sa_delete from sqlalchemy import delete as sa_delete
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -18,6 +19,7 @@ from src.core.crypto import crypto_service
from src.core.exceptions import InvalidRequestException, NotFoundException from src.core.exceptions import InvalidRequestException, NotFoundException
from src.core.logger import logger from src.core.logger import logger
from src.core.provider_types import ProviderType from src.core.provider_types import ProviderType
from src.database import get_db_context
from src.models.database import ( from src.models.database import (
Provider, Provider,
ProviderAPIKey, ProviderAPIKey,
@@ -90,6 +92,96 @@ class _DeleteKeyResult:
deleted_key_allowed_models: list[str] | None deleted_key_allowed_models: list[str] | None
def _update_endpoint_key_core_sync(
key_id: str,
key_data: EndpointAPIKeyUpdate,
) -> _UpdateKeyPreparation:
with get_db_context() as db:
key = db.query(ProviderAPIKey).filter(ProviderAPIKey.id == key_id).first()
if not key:
raise NotFoundException(f"Key {key_id} 不存在")
prepared = _prepare_update_key_payload(
db=db,
key=key,
key_id=key_id,
key_data=key_data,
)
for field, value in prepared.update_data.items():
setattr(key, field, value)
key.updated_at = datetime.now(timezone.utc)
return prepared
def _create_provider_key_core_sync(
provider_id: str,
key_data: EndpointAPIKeyCreate,
) -> str:
with get_db_context() as db:
provider = db.query(Provider).filter(Provider.id == provider_id).first()
if not provider:
raise NotFoundException(f"Provider {provider_id} 不存在")
if not key_data.api_formats:
raise InvalidRequestException("api_formats 为必填字段")
auth_type, new_key = _prepare_create_key_payload(
db=db,
provider_id=provider_id,
key_data=key_data,
)
_validate_vertex_api_formats(
getattr(provider, "provider_type", None),
auth_type,
key_data.api_formats,
)
db.add(new_key)
db.flush()
return str(new_key.id)
def _delete_endpoint_key_core_sync(key_id: str) -> _DeleteKeyResult:
with get_db_context() as db:
return _delete_endpoint_key(db, key_id)
def _batch_delete_endpoint_keys_core_sync(key_ids: list[str]) -> dict[str, Any]:
with get_db_context() as db:
keys = db.query(ProviderAPIKey).filter(ProviderAPIKey.id.in_(key_ids)).all()
found_ids = {key.id for key in keys}
not_found_ids = [kid for kid in key_ids if kid not in found_ids]
failed: list[dict[str, str]] = [{"id": kid, "error": "not found"} for kid in not_found_ids]
affected_provider_ids = {key.provider_id for key in keys if key.provider_id}
success_count = 0
try:
found_id_list = list(found_ids)
cleanup_key_references(db, found_id_list)
db.execute(sa_delete(ProviderAPIKey).where(ProviderAPIKey.id.in_(found_id_list)))
db.commit()
success_count = len(found_ids)
except Exception as exc:
db.rollback()
logger.error("批量删除 Key 提交失败: {}", exc)
failed.extend({"id": kid, "error": str(exc)} for kid in found_ids)
return {
"success_count": 0,
"failed": failed,
"affected_provider_ids": set(),
}
return {
"success_count": success_count,
"failed": failed,
"affected_provider_ids": affected_provider_ids,
}
def _run_async_with_fallback(coro: Any) -> None: def _run_async_with_fallback(coro: Any) -> None:
"""在同步上下文中执行异步任务(有事件循环则调度,无则阻塞执行)。""" """在同步上下文中执行异步任务(有事件循环则调度,无则阻塞执行)。"""
try: try:
@@ -388,24 +480,13 @@ async def update_endpoint_key_response(
key_data: EndpointAPIKeyUpdate, key_data: EndpointAPIKeyUpdate,
) -> EndpointAPIKeyResponse: ) -> EndpointAPIKeyResponse:
"""更新 Key 并返回响应对象。""" """更新 Key 并返回响应对象。"""
prepared = await run_in_threadpool(_update_endpoint_key_core_sync, key_id, key_data)
db.expire_all()
key = db.query(ProviderAPIKey).filter(ProviderAPIKey.id == key_id).first() key = db.query(ProviderAPIKey).filter(ProviderAPIKey.id == key_id).first()
if not key: if not key:
raise NotFoundException(f"Key {key_id} 不存在") raise NotFoundException(f"Key {key_id} 不存在")
prepared = _prepare_update_key_payload(
db=db,
key=key,
key_id=key_id,
key_data=key_data,
)
for field, value in prepared.update_data.items():
setattr(key, field, value)
key.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(key)
await run_update_key_side_effects( await run_update_key_side_effects(
db=db, db=db,
key=key, key=key,
@@ -427,29 +508,12 @@ async def create_provider_key_response(
key_data: EndpointAPIKeyCreate, key_data: EndpointAPIKeyCreate,
) -> EndpointAPIKeyResponse: ) -> EndpointAPIKeyResponse:
"""创建 Provider Key 并返回响应对象。""" """创建 Provider Key 并返回响应对象。"""
provider = db.query(Provider).filter(Provider.id == provider_id).first() key_id = await run_in_threadpool(_create_provider_key_core_sync, provider_id, key_data)
if not provider:
raise NotFoundException(f"Provider {provider_id} 不存在")
if not key_data.api_formats: db.expire_all()
raise InvalidRequestException("api_formats 为必填字段") new_key = db.query(ProviderAPIKey).filter(ProviderAPIKey.id == key_id).first()
if not new_key:
auth_type, new_key = _prepare_create_key_payload( raise NotFoundException(f"Key {key_id} 不存在")
db=db,
provider_id=provider_id,
key_data=key_data,
)
# Vertex Provider: auth_type 与 api_formats 的组合必须合法
_validate_vertex_api_formats(
getattr(provider, "provider_type", None),
auth_type,
key_data.api_formats,
)
db.add(new_key)
db.commit()
db.refresh(new_key)
key_tail = (key_data.api_key or "")[-4:] key_tail = (key_data.api_key or "")[-4:]
logger.info( logger.info(
@@ -489,7 +553,8 @@ def _delete_endpoint_key(db: Session, key_id: str) -> _DeleteKeyResult:
async def delete_endpoint_key_response(db: Session, key_id: str) -> dict[str, str]: async def delete_endpoint_key_response(db: Session, key_id: str) -> dict[str, str]:
"""删除 Key执行副作用并返回统一响应。""" """删除 Key执行副作用并返回统一响应。"""
delete_result = _delete_endpoint_key(db, key_id) delete_result = await run_in_threadpool(_delete_endpoint_key_core_sync, key_id)
await run_delete_key_side_effects( await run_delete_key_side_effects(
db=db, db=db,
provider_id=delete_result.provider_id, provider_id=delete_result.provider_id,
@@ -504,31 +569,11 @@ async def batch_delete_endpoint_keys_response(db: Session, key_ids: list[str]) -
if not key_ids: if not key_ids:
return {"success_count": 0, "failed_count": 0, "failed": []} return {"success_count": 0, "failed_count": 0, "failed": []}
# 一次查询所有 Key result = await run_in_threadpool(_batch_delete_endpoint_keys_core_sync, key_ids)
keys = db.query(ProviderAPIKey).filter(ProviderAPIKey.id.in_(key_ids)).all() affected_provider_ids = result["affected_provider_ids"]
found_ids = {key.id for key in keys} failed = result["failed"]
not_found_ids = [kid for kid in key_ids if kid not in found_ids] success_count = result["success_count"]
failed: list[dict[str, str]] = [{"id": kid, "error": "not found"} for kid in not_found_ids]
# 收集受影响的 provider_id
affected_provider_ids = {key.provider_id for key in keys if key.provider_id}
# 批量 SQL DELETE 前先显式处理关联表,降低大批量删除时的级联成本
success_count = 0
try:
found_id_list = list(found_ids)
cleanup_key_references(db, found_id_list)
db.execute(sa_delete(ProviderAPIKey).where(ProviderAPIKey.id.in_(found_id_list)))
db.commit()
success_count = len(found_ids)
except Exception as exc:
db.rollback()
logger.error("批量删除 Key 提交失败: {}", exc)
failed.extend({"id": kid, "error": str(exc)} for kid in found_ids)
return {"success_count": 0, "failed_count": len(failed), "failed": failed}
# 按 provider_id 聚合,每个 provider 仅执行一次副作用
for provider_id in affected_provider_ids: for provider_id in affected_provider_ids:
try: try:
await run_delete_key_side_effects( await run_delete_key_side_effects(

View File

@@ -518,19 +518,46 @@ class MaintenanceScheduler:
return return
async with self._wallet_daily_usage_lock: async with self._wallet_daily_usage_lock:
def _do() -> None:
db = create_session()
try:
logger.info("开始执行钱包每日消费汇总...")
billing_today = WalletDailyUsageLedgerService.get_today_billing_date()
billing_yesterday = billing_today - timedelta(days=1)
affected = WalletDailyUsageLedgerService.aggregate_day(db, billing_yesterday)
logger.info(
"钱包每日消费汇总完成: date={}, wallets={}",
billing_yesterday.isoformat(),
affected,
)
except Exception as e:
logger.exception("钱包每日消费汇总任务执行失败: {}", e)
try:
db.rollback()
except Exception:
pass
finally:
db.close()
await asyncio.to_thread(_do)
async def _perform_hourly_stats_aggregation(self) -> None:
"""执行小时统计聚合任务"""
def _do() -> None:
db = create_session() db = create_session()
try: try:
logger.info("开始执行钱包每日消费汇总...") if not SystemConfigService.get_config(db, "enable_stats_aggregation", True):
billing_today = WalletDailyUsageLedgerService.get_today_billing_date() logger.info("统计聚合已禁用,跳过小时聚合任务")
billing_yesterday = billing_today - timedelta(days=1) return
affected = WalletDailyUsageLedgerService.aggregate_day(db, billing_yesterday)
logger.info( now_utc = datetime.now(timezone.utc)
"钱包每日消费汇总完成: date={}, wallets={}", last_hour = now_utc.replace(minute=0, second=0, microsecond=0) - timedelta(hours=1)
billing_yesterday.isoformat(), StatsAggregatorService.aggregate_hourly_stats_bundle(db, last_hour)
affected, logger.info("小时统计聚合完成: {}", last_hour.isoformat())
)
except Exception as e: except Exception as e:
logger.exception("钱包每日消费汇总任务执行失败: {}", e) logger.exception("小时统计聚合任务执行失败: {}", e)
try: try:
db.rollback() db.rollback()
except Exception: except Exception:
@@ -538,26 +565,7 @@ class MaintenanceScheduler:
finally: finally:
db.close() db.close()
async def _perform_hourly_stats_aggregation(self) -> None: await asyncio.to_thread(_do)
"""执行小时统计聚合任务"""
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 状态清理"""
@@ -664,23 +672,27 @@ class MaintenanceScheduler:
async def _perform_gemini_file_mapping_cleanup(self) -> None: async def _perform_gemini_file_mapping_cleanup(self) -> None:
"""清理过期的 Gemini 文件映射记录""" """清理过期的 Gemini 文件映射记录"""
db = create_session()
try:
from src.services.gemini_files_mapping import cleanup_expired_mappings
deleted_count = cleanup_expired_mappings(db) def _do() -> None:
db = create_session()
if deleted_count > 0:
logger.info(f"清理了 {deleted_count} 条过期的 Gemini 文件映射")
except Exception as e:
logger.exception(f"Gemini 文件映射清理失败: {e}")
try: try:
db.rollback() from src.services.gemini_files_mapping import cleanup_expired_mappings
except Exception:
pass deleted_count = cleanup_expired_mappings(db)
finally:
db.close() if deleted_count > 0:
logger.info(f"清理了 {deleted_count} 条过期的 Gemini 文件映射")
except Exception as e:
logger.exception(f"Gemini 文件映射清理失败: {e}")
try:
db.rollback()
except Exception:
pass
finally:
db.close()
await asyncio.to_thread(_do)
async def _perform_provider_checkin(self) -> None: async def _perform_provider_checkin(self) -> None:
"""执行 Provider 签到任务 """执行 Provider 签到任务
@@ -688,41 +700,29 @@ class MaintenanceScheduler:
遍历所有已配置 provider_ops 的 Provider触发签到。 遍历所有已配置 provider_ops 的 Provider触发签到。
签到会在余额查询时一起执行(先签到再查询余额)。 签到会在余额查询时一起执行(先签到再查询余额)。
""" """
db = create_session()
def _load_provider_ids() -> list[str]:
db = create_session()
try:
if not SystemConfigService.get_config(db, "enable_provider_checkin", True):
return []
providers = (
db.query(Provider.id, Provider.config)
.filter(Provider.is_active.is_(True))
.all()
)
return [p.id for p in providers if p.config and p.config.get("provider_ops")]
finally:
db.close()
try: try:
# 检查是否启用签到任务 provider_ids = await asyncio.to_thread(_load_provider_ids)
if not SystemConfigService.get_config(db, "enable_provider_checkin", True):
logger.info("Provider 签到已禁用,跳过签到任务")
return
# 获取所有已配置 provider_ops 的活跃 Provider只查询需要的字段
providers = (
db.query(Provider.id, Provider.config).filter(Provider.is_active.is_(True)).all()
)
provider_ids = [p.id for p in providers if p.config and p.config.get("provider_ops")]
if not provider_ids: if not provider_ids:
logger.info("无已配置的 Provider跳过签到任务") logger.info("无已配置的 Provider跳过签到任务")
return return
logger.info(f"开始执行 Provider 签到,共 {len(provider_ids)} 个...") logger.info(f"开始执行 Provider 签到,共 {len(provider_ids)} 个...")
# 释放主 session 的连接,避免在整个签到期间占用连接池
# (后续每个 provider 将使用独立短生命周期 session
try:
if db.in_transaction():
db.commit()
except Exception:
try:
db.rollback()
except Exception:
pass
try:
db.close()
except Exception:
pass
db = None
# 使用信号量限制并发,避免同时发起过多请求 # 使用信号量限制并发,避免同时发起过多请求
concurrency = 3 # 签到任务并发数 concurrency = 3 # 签到任务并发数
semaphore = asyncio.Semaphore(concurrency) semaphore = asyncio.Semaphore(concurrency)
@@ -774,9 +774,6 @@ class MaintenanceScheduler:
except Exception as e: except Exception as e:
logger.exception(f"Provider 签到任务执行失败: {e}") logger.exception(f"Provider 签到任务执行失败: {e}")
finally:
if db is not None:
db.close()
async def _perform_candidate_cleanup(self) -> None: async def _perform_candidate_cleanup(self) -> None:
"""清理过期的 request_candidates 记录""" """清理过期的 request_candidates 记录"""

View File

@@ -8,12 +8,14 @@ from __future__ import annotations
import hashlib import hashlib
from typing import Any from typing import Any
from fastapi import Depends, Header, HTTPException, status from fastapi import Depends, Header, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from src.core.logger import logger from src.core.logger import logger
from src.models.database import ManagementToken
from src.services.auth.service import AuthService from src.services.auth.service import AuthService
from src.utils.request_utils import get_client_ip
from ..core.exceptions import ForbiddenException from ..core.exceptions import ForbiddenException
from ..database import get_db from ..database import get_db
@@ -22,8 +24,91 @@ from ..models.database import User, UserRole
security = HTTPBearer() security = HTTPBearer()
async def authenticate_user_from_bearer_token(
token: str,
db: Session,
request: Request | None = None,
) -> User:
if token.startswith(ManagementToken.TOKEN_PREFIX):
client_ip = get_client_ip(request) if request is not None else "unknown"
result = await AuthService.authenticate_management_token(db, token, client_ip)
if not result:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的Token")
user, management_token = result
if request is not None:
request.state.user_id = user.id
request.state.management_token_id = management_token.id
return user
# 验证Token格式和签名
try:
payload = await AuthService.verify_token(token, token_type="access")
except HTTPException as token_error:
token_fp = hashlib.sha256(token.encode()).hexdigest()[:12]
logger.error(
"Token验证失败: {}: {}, token_fp={}",
token_error.status_code,
token_error.detail,
token_fp,
)
raise
except Exception as token_error:
token_fp = hashlib.sha256(token.encode()).hexdigest()[:12]
logger.error("Token验证失败: {}, token_fp={}", token_error, token_fp)
raise ForbiddenException("无效的Token")
user_id = payload.get("user_id")
if not user_id:
logger.error("Token缺少user_id字段: payload={}", payload)
raise ForbiddenException("无效的认证凭据")
token_fp = hashlib.sha256(token.encode()).hexdigest()[:12]
if not isinstance(user_id, str):
logger.error("Token中user_id格式错误: {} - {}", type(user_id), user_id)
raise ForbiddenException("认证信息格式错误,请重新登录")
try:
from src.services.user.service import UserService
user = UserService.get_user(db, user_id)
except Exception as db_error:
logger.error("数据库查询失败: user_id={}, error={}", user_id, db_error)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="数据库查询失败,请稍后重试",
)
if not user:
logger.error("用户不存在: user_id={}", user_id)
raise ForbiddenException("用户不存在或已禁用")
if not user.is_active:
logger.error("用户已禁用: user_id={}", user_id)
raise ForbiddenException("用户不存在或已禁用")
if user.is_deleted:
logger.error("用户已删除: user_id={}", user_id)
raise ForbiddenException("用户不存在或已禁用")
if not AuthService.token_identity_matches_user(payload, user):
logger.error("Token身份校验失败: user_id={}, token_fp={}", user_id, token_fp)
raise ForbiddenException("身份验证失败")
if request is not None:
request.state.user_id = user.id
if hasattr(request.state, "management_token_id"):
request.state.management_token_id = None
return user
async def get_current_user( async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security), db: Session = Depends(get_db) request: Request,
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db),
) -> User: ) -> User:
""" """
获取当前登录用户 获取当前登录用户
@@ -39,71 +124,8 @@ async def get_current_user(
Raises: Raises:
HTTPException: 认证失败时抛出 HTTPException: 认证失败时抛出
""" """
token = credentials.credentials
try: try:
# 验证Token格式和签名 return await authenticate_user_from_bearer_token(credentials.credentials, db, request)
try:
payload = await AuthService.verify_token(token, token_type="access")
except HTTPException as token_error:
# 保持原始的HTTP状态码如401 Unauthorized不要转换为403
token_fp = hashlib.sha256(token.encode()).hexdigest()[:12]
logger.error(
"Token验证失败: {}: {}, token_fp={}",
token_error.status_code,
token_error.detail,
token_fp,
)
raise # 重新抛出原始异常,保持状态码
except Exception as token_error:
token_fp = hashlib.sha256(token.encode()).hexdigest()[:12]
logger.error("Token验证失败: {}, token_fp={}", token_error, token_fp)
raise ForbiddenException("无效的Token")
user_id = payload.get("user_id")
if not user_id:
logger.error("Token缺少user_id字段: payload={}", payload)
raise ForbiddenException("无效的认证凭据")
# 兼容旧 tokenemail 字段可能存在;新 token 不再包含 email支持无邮箱用户
token_fp = hashlib.sha256(token.encode()).hexdigest()[:12]
# 确保user_id是字符串格式UUID
if not isinstance(user_id, str):
logger.error("Token中user_id格式错误: {} - {}", type(user_id), user_id)
raise ForbiddenException("认证信息格式错误,请重新登录")
# 使用新的数据库会话获取用户,避免会话状态问题
try:
from src.services.user.service import UserService
user = UserService.get_user(db, user_id)
except Exception as db_error:
logger.error("数据库查询失败: user_id={}, error={}", user_id, db_error)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="数据库查询失败,请稍后重试",
)
if not user:
logger.error("用户不存在: user_id={}", user_id)
raise ForbiddenException("用户不存在或已禁用")
if not user.is_active:
logger.error("用户已禁用: user_id={}", user_id)
raise ForbiddenException("用户不存在或已禁用")
if user.is_deleted:
logger.error("用户已删除: user_id={}", user_id)
raise ForbiddenException("用户不存在或已禁用")
if not AuthService.token_identity_matches_user(payload, user):
logger.error("Token身份校验失败: user_id={}, token_fp={}", user_id, token_fp)
raise ForbiddenException("身份验证失败")
return user
except HTTPException: except HTTPException:
raise raise
@@ -116,7 +138,9 @@ async def get_current_user(
async def get_current_user_from_header( async def get_current_user_from_header(
authorization: str | None = Header(None), db: Session = Depends(get_db) request: Request,
authorization: str | None = Header(None),
db: Session = Depends(get_db),
) -> User: ) -> User:
""" """
从Header中获取当前用户兼容性函数 从Header中获取当前用户兼容性函数
@@ -134,29 +158,12 @@ async def get_current_user_from_header(
if not authorization or not authorization.startswith("Bearer "): if not authorization or not authorization.startswith("Bearer "):
raise ForbiddenException("未提供认证令牌") raise ForbiddenException("未提供认证令牌")
token = authorization.replace("Bearer ", "")
try: try:
payload = await AuthService.verify_token(token, token_type="access") return await authenticate_user_from_bearer_token(
user_id = payload.get("user_id") authorization.replace("Bearer ", ""),
db,
if not user_id: request,
raise ForbiddenException("无效的认证凭据") )
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise ForbiddenException("用户不存在")
if not user.is_active:
raise ForbiddenException("用户已被禁用")
if user.is_deleted:
raise ForbiddenException("用户不存在或已禁用")
if not AuthService.token_identity_matches_user(payload, user):
raise ForbiddenException("身份验证失败")
return user
except HTTPException: except HTTPException:
# 保持原始的HTTPException (包括401) # 保持原始的HTTPException (包括401)
raise raise

View File

@@ -0,0 +1,25 @@
from __future__ import annotations
from pathlib import Path
def test_no_direct_access_token_verification_outside_pipeline() -> None:
api_root = Path("src/api")
allowed = {
Path("src/api/base/pipeline.py"),
Path("src/api/auth/routes.py"),
}
offenders: list[str] = []
for path in api_root.rglob("*.py"):
if path in allowed:
continue
text = path.read_text(encoding="utf-8")
if "verify_token(" in text and 'token_type="access"' in text:
offenders.append(str(path))
assert (
offenders == []
), "这些 API 文件仍在手写 access token 校验,应改为走 pipeline 或 auth_utils 统一入口: " + ", ".join(
sorted(offenders)
)

View File

@@ -0,0 +1,87 @@
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import HTTPException
from fastapi.security import HTTPAuthorizationCredentials
from src.models.database import UserRole
from src.utils.auth_utils import get_current_user, get_current_user_from_header
class TestAuthUtilsManagementToken:
@staticmethod
def _make_request() -> Any:
return MagicMock(
headers={},
client=MagicMock(host="127.0.0.1"),
state=MagicMock(spec=[]),
)
@pytest.mark.asyncio
async def test_get_current_user_accepts_management_token(self) -> None:
request = self._make_request()
credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials="ae_valid_token")
db = MagicMock()
user = MagicMock()
user.id = "admin-123"
user.role = UserRole.ADMIN
management_token = MagicMock()
management_token.id = "mt-123"
with patch(
"src.utils.auth_utils.AuthService.authenticate_management_token",
new_callable=AsyncMock,
return_value=(user, management_token),
) as mock_authenticate:
result = await get_current_user(request, credentials, db)
assert result == user
assert request.state.user_id == "admin-123"
assert request.state.management_token_id == "mt-123"
mock_authenticate.assert_awaited_once_with(db, "ae_valid_token", "127.0.0.1")
@pytest.mark.asyncio
async def test_get_current_user_from_header_accepts_management_token(self) -> None:
request = self._make_request()
db = MagicMock()
user = MagicMock()
user.id = "admin-123"
user.role = UserRole.ADMIN
management_token = MagicMock()
management_token.id = "mt-123"
with patch(
"src.utils.auth_utils.AuthService.authenticate_management_token",
new_callable=AsyncMock,
return_value=(user, management_token),
) as mock_authenticate:
result = await get_current_user_from_header(
request,
authorization="Bearer ae_valid_token",
db=db,
)
assert result == user
assert request.state.user_id == "admin-123"
assert request.state.management_token_id == "mt-123"
mock_authenticate.assert_awaited_once_with(db, "ae_valid_token", "127.0.0.1")
@pytest.mark.asyncio
async def test_get_current_user_invalid_management_token_raises_401(self) -> None:
request = self._make_request()
credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials="ae_invalid_token")
db = MagicMock()
with patch(
"src.utils.auth_utils.AuthService.authenticate_management_token",
new_callable=AsyncMock,
return_value=None,
):
with pytest.raises(HTTPException) as exc_info:
await get_current_user(request, credentials, db)
assert exc_info.value.status_code == 401
assert exc_info.value.detail == "无效的Token"