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

@@ -11,6 +11,7 @@ from typing import Any, Literal
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.concurrency import run_in_threadpool
from sqlalchemy import func
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.core.exceptions import InvalidRequestException, NotFoundException
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.database import ApiKey, Wallet
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()
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(
db: Session,
api_key: ApiKey,
@@ -277,68 +528,20 @@ class AdminListStandaloneKeysAdapter(AdminApiAdapter):
self.is_active = is_active
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
# 只查询独立余额Keys
query = db.query(ApiKey).filter(ApiKey.is_standalone == True)
if self.is_active is not None:
query = query.filter(ApiKey.is_active == self.is_active)
total = 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()
result = await run_in_threadpool(
_list_standalone_api_keys_sync,
self.skip,
self.limit,
self.is_active,
)
# 保证返回的独立 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(
action="list_standalone_api_keys",
filter_is_active=self.is_active,
limit=self.limit,
skip=self.skip,
total=total,
total=result["total"],
)
return {
"api_keys": [
{
"id": api_key.id,
"user_id": api_key.user_id, # 创建者ID
"name": api_key.name,
"key_display": api_key.get_display_key(),
"is_active": api_key.is_active,
"is_standalone": api_key.is_standalone,
"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,
}
return result
class AdminCreateStandaloneKeyAdapter(AdminApiAdapter):
@@ -348,75 +551,16 @@ class AdminCreateStandaloneKeyAdapter(AdminApiAdapter):
self.key_data = key_data
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
# 独立Key支持无限制额度initial_balance_usd = null
if self.key_data.initial_balance_usd is not None and self.key_data.initial_balance_usd <= 0:
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,
result, audit_meta = await run_in_threadpool(
_create_standalone_api_key_sync,
context.user.id,
self.key_data,
)
# 钱包体系:独立 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(
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(
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创建成功请妥善保存完整密钥后续将无法查看",
}
context.add_audit_metadata(**audit_meta)
return result
class AdminUpdateApiKeyAdapter(AdminApiAdapter):
@@ -427,97 +571,18 @@ class AdminUpdateApiKeyAdapter(AdminApiAdapter):
self.key_data = key_data
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
api_key = db.query(ApiKey).filter(ApiKey.id == self.key_id).first()
if not api_key:
raise NotFoundException("API密钥不存在", "api_key")
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")
result, changed_fields = await run_in_threadpool(
_update_standalone_api_key_sync,
self.key_id,
self.key_data,
)
logger.info(f"管理员更新独立余额Key: ID {self.key_id}, 更新字段 {changed_fields}")
context.add_audit_metadata(
action="update_standalone_api_key",
key_id=self.key_id,
updated_fields=changed_fields,
)
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密钥已更新",
}
return result
class AdminToggleApiKeyAdapter(AdminApiAdapter):
@@ -525,35 +590,12 @@ class AdminToggleApiKeyAdapter(AdminApiAdapter):
self.key_id = key_id
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
api_key = db.query(ApiKey).filter(ApiKey.id == self.key_id).first()
if not api_key:
raise NotFoundException("API密钥不存在", "api_key")
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)
result, audit_meta = await run_in_threadpool(_toggle_standalone_api_key_sync, self.key_id)
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(
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 '禁用'}",
}
context.add_audit_metadata(**audit_meta)
return result
class AdminDeleteApiKeyAdapter(AdminApiAdapter):
@@ -561,30 +603,13 @@ class AdminDeleteApiKeyAdapter(AdminApiAdapter):
self.key_id = key_id
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
api_key = db.query(ApiKey).filter(ApiKey.id == self.key_id).first()
if not api_key:
raise HTTPException(status_code=404, detail="API密钥不存在")
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 '未知'}"
result, audit_meta, user_email = await run_in_threadpool(
_delete_standalone_api_key_sync,
self.key_id,
)
context.add_audit_metadata(
action="delete_api_key",
target_key_id=self.key_id,
user_id=user.id if user else None,
user_email=user.email if user else None,
)
return {"message": "API密钥已删除"}
logger.info(f"管理员删除API密钥: Key ID {self.key_id}, 用户 {user_email or '未知'}")
context.add_audit_metadata(**audit_meta)
return result
class AdminGetFullKeyAdapter(AdminApiAdapter):

View File

@@ -6,6 +6,7 @@ from dataclasses import dataclass
from typing import Any
from fastapi import APIRouter, Depends, Query, Request
from fastapi.concurrency import run_in_threadpool
from pydantic import BaseModel, Field, ValidationError
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.serializers import serialize_payment_callback, serialize_payment_order
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
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("请求数据验证失败")
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")
async def list_payment_orders(
request: Request,
@@ -121,21 +216,13 @@ class AdminPaymentOrderListAdapter(AdminApiAdapter):
offset: int
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
items, total, changed = PaymentService.list_orders(
context.db,
status=self.status,
payment_method=self.payment_method,
limit=self.limit,
offset=self.offset,
return await run_in_threadpool(
_list_payment_orders_sync,
self.status,
self.payment_method,
self.limit,
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
@@ -143,12 +230,7 @@ class AdminPaymentOrderDetailAdapter(AdminApiAdapter):
order_id: str
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")
if PaymentService.refresh_order_status(order):
context.db.commit()
return {"order": serialize_payment_order(order)}
return await run_in_threadpool(_get_payment_order_sync, self.order_id)
@dataclass
@@ -156,19 +238,7 @@ class AdminPaymentOrderExpireAdapter(AdminApiAdapter):
order_id: str
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")
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}
return await run_in_threadpool(_expire_payment_order_sync, self.order_id)
@dataclass
@@ -176,33 +246,15 @@ class AdminPaymentOrderCreditAdapter(AdminApiAdapter):
order_id: str
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 {}
req = _parse_payload(AdminPaymentOrderCreditPayload, raw_payload)
gateway_response = dict(order.gateway_response or {})
if req.gateway_response:
gateway_response.update(req.gateway_response)
gateway_response["manual_credit"] = True
gateway_response["credited_by"] = 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}
return await run_in_threadpool(
_credit_payment_order_sync,
self.order_id,
req,
context.user.id if context.user else None,
)
@dataclass
@@ -210,19 +262,7 @@ class AdminPaymentOrderFailAdapter(AdminApiAdapter):
order_id: str
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")
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)}
return await run_in_threadpool(_fail_payment_order_sync, self.order_id)
@dataclass

View File

@@ -21,11 +21,13 @@ import time
import uuid
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Literal
from urllib.parse import parse_qsl, urlencode, urlparse
import httpx
from fastapi import APIRouter, Depends, Request
from fastapi.concurrency import run_in_threadpool
from pydantic import BaseModel, Field
from redis.asyncio import Redis
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_templates.fixed_providers import FIXED_PROVIDERS
from src.core.provider_templates.types import ProviderType
from src.database import create_session
from src.database.database import get_db
from src.database import get_db_context
from src.database.database import create_session, get_db
from src.models.database import Provider, ProviderAPIKey, User
from src.services.provider.pool.config import parse_pool_config
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"])
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
# ==============================================================================
@@ -916,8 +961,6 @@ async def complete_oauth(
if not access_token:
raise InvalidRequestException("token exchange 返回缺少 access_token")
# store
key.api_key = crypto_service.encrypt(access_token)
auth_config: dict[str, Any] = {
"provider_type": provider_type,
"token_type": token.get("token_type"),
@@ -935,8 +978,13 @@ async def complete_oauth(
proxy_config=proxy_config,
)
key.auth_config = crypto_service.encrypt(json.dumps(auth_config))
db.commit()
await run_in_threadpool(
_store_completed_oauth_sync,
key_id,
provider_type,
access_token,
auth_config,
)
return CompleteOAuthResponse(
provider_type=provider_type,
@@ -996,18 +1044,20 @@ async def refresh_oauth(
try:
access_token, new_cfg = await refresh_access_token(cfg, proxy_config=proxy_config)
except Exception as e:
key.oauth_invalid_at = datetime.now(timezone.utc)
key.oauth_invalid_reason = f"[REFRESH_FAILED] Token 续期失败: {e}"
db.commit()
await run_in_threadpool(
_mark_refresh_failed_sync,
key_id,
f"[REFRESH_FAILED] Token 续期失败: {e}",
)
logger.warning("Kiro Key {} token 刷新失败,已标记为刷新失效: {}", key_id, e)
raise InvalidRequestException("Kiro token refresh 失败,请检查凭据是否有效")
key.api_key = crypto_service.encrypt(access_token)
key.auth_config = crypto_service.encrypt(json.dumps(new_cfg.to_dict()))
key.oauth_invalid_at = None
key.oauth_invalid_reason = None
key.is_active = True
db.commit()
await run_in_threadpool(
_store_refreshed_oauth_sync,
key_id,
access_token,
new_cfg.to_dict(),
)
return CompleteOAuthResponse(
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}"
if resp.status_code in (400, 401, 403):
from datetime import datetime, timezone
key.oauth_invalid_at = datetime.now(timezone.utc)
key.oauth_invalid_reason = (
f"[REFRESH_FAILED] Token 续期失败 ({resp.status_code}): {error_reason}"
await run_in_threadpool(
_mark_refresh_failed_sync,
key_id,
f"[REFRESH_FAILED] Token 续期失败 ({resp.status_code}): {error_reason}",
)
db.commit()
logger.warning(
"Key {} OAuth token 刷新失败,已标记为刷新失效: {}", key_id, error_reason
)
@@ -1115,7 +1163,6 @@ async def refresh_oauth(
if not access_token:
raise InvalidRequestException("token refresh 返回缺少 access_token")
key.api_key = crypto_service.encrypt(access_token)
parsed["token_type"] = token.get("token_type")
if new_refresh_token:
parsed["refresh_token"] = new_refresh_token
@@ -1138,14 +1185,7 @@ async def refresh_oauth(
key_id,
)
key.auth_config = crypto_service.encrypt(json.dumps(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()
await run_in_threadpool(_store_refreshed_oauth_sync, key_id, access_token, parsed)
return CompleteOAuthResponse(
provider_type=provider_type,

View File

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

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from fastapi.concurrency import run_in_threadpool
from pydantic import ValidationError
from sqlalchemy import func
from sqlalchemy.orm import Session
@@ -15,10 +16,11 @@ from src.api.base.pipeline import ApiRequestPipeline
from src.config.constants import CacheTTL
from src.core.exceptions import InvalidRequestException, NotFoundException, translate_pydantic_error
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.api import CreateApiKeyRequest, CreateUserRequest
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.user.apikey import ApiKeyService
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("")
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):
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
payload = context.ensure_json_body()
try:
request = CreateUserRequest.model_validate(payload)
@@ -285,48 +512,12 @@ class AdminCreateUserAdapter(AdminApiAdapter):
except (KeyError, AttributeError):
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:
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=allowed_providers,
allowed_api_formats=allowed_api_formats,
allowed_models=allowed_models,
)
response, audit_meta = await run_in_threadpool(_create_user_sync, request, role)
except ValueError as exc:
raise InvalidRequestException(str(exc))
context.add_audit_metadata(
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)
raise InvalidRequestException(str(exc)) from exc
context.add_audit_metadata(**audit_meta)
return response
class AdminListUsersAdapter(AdminApiAdapter):
@@ -378,11 +569,6 @@ class AdminUpdateUserAdapter(AdminApiAdapter):
self.user_id = user_id
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()
try:
request = UpdateUserRequest.model_validate(payload)
@@ -392,52 +578,19 @@ class AdminUpdateUserAdapter(AdminApiAdapter):
raise InvalidRequestException(translate_pydantic_error(errors[0]))
raise InvalidRequestException("请求数据验证失败")
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 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:
response, audit_meta, role_changed, user_email = await run_in_threadpool(
_update_user_sync,
self.user_id,
request,
)
if user_email:
await UserCacheService.invalidate_user_cache(self.user_id, user_email)
if role_changed:
from src.services.usage.service import UsageService
await UsageService.clear_user_heatmap_cache(self.user_id)
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")
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)
context.add_audit_metadata(**audit_meta)
return response
class AdminDeleteUserAdapter(AdminApiAdapter):
@@ -445,33 +598,11 @@ class AdminDeleteUserAdapter(AdminApiAdapter):
self.user_id = user_id
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
user = UserService.get_user(db, self.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, 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": "用户删除成功"}
response, audit_meta, user_email = await run_in_threadpool(_delete_user_sync, self.user_id)
if user_email:
await UserCacheService.invalidate_user_cache(self.user_id, user_email)
context.add_audit_metadata(**audit_meta)
return response
class AdminGetUserKeysAdapter(AdminApiAdapter):
@@ -530,7 +661,6 @@ class AdminCreateUserKeyAdapter(AdminApiAdapter):
self.user_id = user_id
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
payload = context.ensure_json_body()
try:
key_data = CreateApiKeyRequest.model_validate(payload)
@@ -540,41 +670,17 @@ class AdminCreateUserKeyAdapter(AdminApiAdapter):
raise InvalidRequestException(translate_pydantic_error(errors[0]))
raise InvalidRequestException("请求数据验证失败")
# 验证用户存在
user = db.query(User).filter(User.id == self.user_id).first()
if not user:
raise NotFoundException("用户不存在", "user")
# 为用户创建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
response, audit_meta, user_email = await run_in_threadpool(
_create_user_key_sync,
self.user_id,
key_data,
)
logger.info(f"管理员为用户创建API Key: 用户 {user.email}, Key ID {api_key.id}")
context.add_audit_metadata(
action="create_user_api_key",
target_user_id=self.user_id,
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创建成功请妥善保存完整密钥",
}
if user_email:
logger.info(
"管理员为用户创建API Key: 用户 {}, Key ID {}", user_email, audit_meta["key_id"]
)
context.add_audit_metadata(**audit_meta)
return response
class AdminDeleteUserKeyAdapter(AdminApiAdapter):
@@ -585,36 +691,13 @@ class AdminDeleteUserKeyAdapter(AdminApiAdapter):
self.key_id = key_id
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
# 验证Key存在且属于该用户
api_key = (
db.query(ApiKey)
.filter(
ApiKey.id == self.key_id,
ApiKey.user_id == self.user_id,
ApiKey.is_standalone == False, # 只能删除普通Key
)
.first()
response, audit_meta = await run_in_threadpool(
_delete_user_key_sync,
self.user_id,
self.key_id,
)
if not api_key:
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已删除"}
context.add_audit_metadata(**audit_meta)
return response
class AdminToggleUserKeyLockAdapter(AdminApiAdapter):
@@ -625,42 +708,13 @@ class AdminToggleUserKeyLockAdapter(AdminApiAdapter):
self.key_id = key_id
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
api_key = (
db.query(ApiKey)
.filter(
ApiKey.id == self.key_id,
ApiKey.user_id == self.user_id,
ApiKey.is_standalone == False, # 只能锁定普通Key
)
.first()
response, audit_meta = await run_in_threadpool(
_toggle_user_key_lock_sync,
self.user_id,
self.key_id,
)
if not api_key:
raise NotFoundException("API Key不存在或不属于该用户", "api_key")
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 '解锁'}",
}
context.add_audit_metadata(**audit_meta)
return response
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
auth_token = token
@@ -159,15 +159,7 @@ async def proxy_video_stream(
raise HTTPException(status_code=401, detail="Unauthorized")
try:
# 验证 token 并获取 payload
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")
user = await authenticate_user_from_bearer_token(auth_token, db, request)
except HTTPException:
raise
except Exception:

View File

@@ -6,6 +6,7 @@ from dataclasses import dataclass
from typing import Any
from fastapi import APIRouter, Depends, Query, Request
from fastapi.concurrency import run_in_threadpool
from pydantic import BaseModel, Field, ValidationError
from sqlalchemy.orm import Session, joinedload
@@ -18,7 +19,7 @@ from src.api.serializers import (
serialize_admin_wallet_transaction,
)
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.services.wallet import WalletService
@@ -91,6 +92,152 @@ def _parse_payload(model_cls: type[BaseModel], payload: dict[str, Any]) -> BaseM
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("")
async def list_wallets(
request: Request,
@@ -397,33 +544,12 @@ class AdminWalletRechargeAdapter(AdminApiAdapter):
payload = _parse_payload(ManualRechargePayload, context.ensure_json_body())
assert isinstance(payload, ManualRechargePayload)
wallet = _get_wallet_or_raise(context.db, self.wallet_id)
_ensure_api_key_wallet_manual_recharge(wallet, payload.payment_method)
try:
order = WalletService.create_manual_recharge_order(
context.db,
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,
},
}
return await run_in_threadpool(
_recharge_wallet_sync,
self.wallet_id,
payload,
context.user.id if context.user else None,
)
@dataclass
@@ -434,27 +560,12 @@ class AdminWalletAdjustAdapter(AdminApiAdapter):
payload = _parse_payload(WalletAdjustPayload, context.ensure_json_body())
assert isinstance(payload, WalletAdjustPayload)
wallet = _get_wallet_or_raise(context.db, self.wallet_id)
if wallet.api_key_id is not None and payload.balance_type == "gift":
raise InvalidRequestException("独立密钥钱包不支持赠款调账")
try:
tx = WalletService.admin_adjust_balance(
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),
}
return await run_in_threadpool(
_adjust_wallet_sync,
self.wallet_id,
payload,
context.user.id if context.user else None,
)
@dataclass
@@ -463,26 +574,12 @@ class AdminWalletRefundProcessAdapter(AdminApiAdapter):
refund_id: str
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
wallet = _get_wallet_or_raise(context.db, self.wallet_id)
_ensure_user_wallet_for_refund(wallet)
refund = _get_refund_or_raise(context.db, self.wallet_id, self.refund_id)
try:
tx = WalletService.move_refund_to_processing(
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),
}
return await run_in_threadpool(
_process_refund_sync,
self.wallet_id,
self.refund_id,
context.user.id if context.user else None,
)
@dataclass
@@ -494,26 +591,13 @@ class AdminWalletRefundFailAdapter(AdminApiAdapter):
payload = _parse_payload(RefundFailPayload, context.ensure_json_body())
assert isinstance(payload, RefundFailPayload)
wallet = _get_wallet_or_raise(context.db, self.wallet_id)
_ensure_user_wallet_for_refund(wallet)
refund = _get_refund_or_raise(context.db, self.wallet_id, self.refund_id)
try:
tx = WalletService.fail_refund(
context.db,
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,
}
return await run_in_threadpool(
_fail_refund_sync,
self.wallet_id,
self.refund_id,
payload,
context.user.id if context.user else None,
)
@dataclass
@@ -525,22 +609,9 @@ class AdminWalletRefundCompleteAdapter(AdminApiAdapter):
payload = _parse_payload(RefundCompletePayload, context.ensure_json_body())
assert isinstance(payload, RefundCompletePayload)
wallet = _get_wallet_or_raise(context.db, self.wallet_id)
_ensure_user_wallet_for_refund(wallet)
refund = _get_refund_or_raise(context.db, self.wallet_id, self.refund_id)
if refund.status != "processing":
raise InvalidRequestException("只有 processing 状态的退款可以标记完成")
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)}
return await run_in_threadpool(
_complete_refund_sync,
self.wallet_id,
self.refund_id,
payload,
)

View File

@@ -18,8 +18,8 @@ from src.core.exceptions import InvalidRequestException, translate_pydantic_erro
from src.database import get_db
from src.models.api import CreateAnnouncementRequest, UpdateAnnouncementRequest
from src.models.database import User
from src.services.auth.service import AuthService
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"])
pipeline = ApiRequestPipeline()
@@ -262,27 +262,7 @@ class AnnouncementOptionalAuthAdapter(ApiAdapter):
token = authorization[7:].strip()
try:
payload = await AuthService.verify_token(token, token_type="access")
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
return await authenticate_user_from_bearer_token(token, context.db, context.request)
except Exception:
return None

View File

@@ -7,6 +7,7 @@ from datetime import date, datetime, timedelta, timezone
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.concurrency import run_in_threadpool
from pydantic import ValidationError
from sqlalchemy import and_, func
from sqlalchemy.orm import Session
@@ -25,7 +26,7 @@ from src.core.exceptions import (
)
from src.core.logger import logger
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 (
ChangePasswordRequest,
CreateMyApiKeyRequest,
@@ -44,6 +45,7 @@ from src.models.database import (
User,
UserModelUsageCount,
)
from src.services.cache.user_cache import UserCacheService
from src.services.system.config import SystemConfigService
from src.services.system.time_range import TimeRangeParams
from src.services.usage.service import UsageService
@@ -57,6 +59,280 @@ router = APIRouter(prefix="/api/users/me", tags=["User Profile"])
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(
start_date: date | None,
end_date: date | None,
@@ -486,27 +762,15 @@ class UpdateProfileAdapter(AuthenticatedApiAdapter):
raise InvalidRequestException(translate_pydantic_error(errors[0]))
raise InvalidRequestException("请求数据验证失败")
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
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)
db.commit()
context.request.state.tx_committed_by_route = True
db.refresh(user)
return {"message": "个人信息更新成功"}
result, old_email, new_email = await run_in_threadpool(
_update_profile_sync,
user.id,
request,
)
await UserCacheService.invalidate_user_cache(user.id, old_email)
if new_email and new_email != old_email:
await UserCacheService.invalidate_user_cache(user.id, new_email)
return result
class ChangePasswordAdapter(AuthenticatedApiAdapter):
@@ -524,35 +788,13 @@ class ChangePasswordAdapter(AuthenticatedApiAdapter):
raise InvalidRequestException(translate_pydantic_error(errors[0]))
raise InvalidRequestException("请求数据验证失败")
# LDAP 用户不能修改密码
from src.core.enums import AuthSource
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("旧密码错误")
# 无密码(如 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}成功"}
result, email, action = await run_in_threadpool(
_change_password_sync,
user.id,
request,
)
logger.info(f"用户{action}密码: {email}")
return result
class ListMyApiKeysAdapter(AuthenticatedApiAdapter):
@@ -639,22 +881,8 @@ class CreateMyApiKeyAdapter(AuthenticatedApiAdapter):
if errors:
raise InvalidRequestException(translate_pydantic_error(errors[0]))
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 {
"id": api_key.id,
"name": api_key.name,
"key": plain_key,
"key_display": api_key.get_display_key(),
"message": "API密钥创建成功",
}
return await run_in_threadpool(_create_my_api_key_sync, context.user.id, request)
@dataclass
@@ -729,20 +957,7 @@ class DeleteMyApiKeyAdapter(AuthenticatedApiAdapter):
key_id: str
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
api_key = (
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密钥已删除"}
return await run_in_threadpool(_delete_my_api_key_sync, context.user.id, self.key_id)
@dataclass
@@ -752,24 +967,7 @@ class ToggleMyApiKeyAdapter(AuthenticatedApiAdapter):
key_id: str
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
api_key = (
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 '禁用'}",
}
return await run_in_threadpool(_toggle_my_api_key_sync, context.user.id, self.key_id)
@dataclass
@@ -1460,38 +1658,14 @@ class UpdateApiKeyProvidersAdapter(AuthenticatedApiAdapter):
raise InvalidRequestException(translate_pydantic_error(errors[0]))
raise InvalidRequestException("请求数据验证失败")
api_key = (
db.query(ApiKey).filter(ApiKey.id == self.api_key_id, ApiKey.user_id == user.id).first()
result = await run_in_threadpool(
_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} 的可用提供商")
return {"message": "API密钥可用提供商已更新"}
return result
@dataclass
@@ -1509,59 +1683,16 @@ class UpdateApiKeyCapabilitiesAdapter(AuthenticatedApiAdapter):
user = context.user
payload = context.ensure_json_body()
api_key = (
db.query(ApiKey).filter(ApiKey.id == self.api_key_id, ApiKey.user_id == user.id).first()
result = await run_in_threadpool(
_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(
f"用户 {user.id} 更新API密钥 {self.api_key_id} 的强制能力配置: {force_capabilities}"
f"用户 {user.id} 更新API密钥 {self.api_key_id} 的强制能力配置: {result['force_capabilities']}"
)
return {
"message": "API密钥能力配置已更新",
"force_capabilities": api_key.force_capabilities,
}
return result
class GetPreferencesAdapter(AuthenticatedApiAdapter):
@@ -1600,20 +1731,7 @@ class UpdatePreferencesAdapter(AuthenticatedApiAdapter):
raise InvalidRequestException(translate_pydantic_error(errors[0]))
raise InvalidRequestException("请求数据验证失败")
PreferenceService.update_preferences(
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": "偏好设置更新成功"}
return await run_in_threadpool(_update_preferences_sync, context.user.id, request)
class GetModelCapabilitySettingsAdapter(AuthenticatedApiAdapter):
@@ -1632,68 +1750,19 @@ class UpdateModelCapabilitySettingsAdapter(AuthenticatedApiAdapter):
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
from src.core.key_capabilities import CAPABILITY_DEFINITIONS, CapabilityConfigMode
from src.models.database import AuditEventType
from src.services.cache.user_cache import UserCacheService
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()
# 保存旧值用于审计
old_settings = user.model_capability_settings
# 验证 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,
},
result, email = await run_in_threadpool(
_update_model_capability_settings_sync,
context.user.id,
payload,
)
logger.debug(f"用户 {user.id} 更新模型能力配置: {settings}")
return {
"message": "模型能力配置已更新",
"model_capability_settings": user.model_capability_settings,
}
await UserCacheService.invalidate_user_cache(context.user.id, email)
logger.debug(
f"用户 {context.user.id} 更新模型能力配置: {result['model_capability_settings']}"
)
return result
class GetEndpointStatusAdapter(AuthenticatedApiAdapter):

View File

@@ -9,6 +9,7 @@ from uuid import uuid4
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, Query, Request
from fastapi.concurrency import run_in_threadpool
from pydantic import BaseModel, Field, ValidationError
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
@@ -25,10 +26,11 @@ from src.api.serializers import (
serialize_wallet_transaction,
)
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 (
PaymentOrder,
RefundRequest,
User,
Wallet,
WalletDailyUsageLedger,
WalletTransaction,
@@ -40,6 +42,154 @@ router = APIRouter(prefix="/api/wallet", tags=["Wallet"])
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):
amount_usd: float = Field(..., gt=0, allow_inf_nan=False)
payment_order_id: str | None = None
@@ -340,25 +490,7 @@ class WalletRechargeCreateAdapter(AuthenticatedApiAdapter):
raise InvalidRequestException(translate_pydantic_error(errors[0]))
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))
db.commit()
db.refresh(order)
return {
"order": serialize_payment_order(order, sanitize_gateway_response=True),
"payment_instructions": safe_gateway_response(order.gateway_response),
}
return await run_in_threadpool(_create_recharge_order_sync, user.id, req)
@dataclass
@@ -371,22 +503,12 @@ class WalletRechargeListAdapter(AuthenticatedApiAdapter):
if user is None:
raise InvalidRequestException("未登录")
items, total, changed = PaymentService.list_user_orders(
context.db,
user_id=user.id,
limit=self.limit,
offset=self.offset,
return await run_in_threadpool(
_list_recharge_orders_sync,
user.id,
self.limit,
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
@@ -398,12 +520,7 @@ class WalletRechargeDetailAdapter(AuthenticatedApiAdapter):
if user is None:
raise InvalidRequestException("未登录")
order = PaymentService.get_user_order(context.db, user_id=user.id, order_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)}
return await run_in_threadpool(_get_recharge_order_sync, user.id, self.order_id)
@dataclass
@@ -417,29 +534,7 @@ class WalletRefundListAdapter(AuthenticatedApiAdapter):
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": 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,
}
return await run_in_threadpool(_list_refunds_sync, user.id, self.limit, self.offset)
@dataclass
@@ -478,65 +573,4 @@ class WalletRefundCreateAdapter(AuthenticatedApiAdapter):
raise InvalidRequestException(translate_pydantic_error(errors[0]))
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("退款申请重复,请勿重复提交")
except ValueError as exc:
db.rollback()
raise InvalidRequestException(str(exc))
return await run_in_threadpool(_create_refund_sync, user.id, req)

View File

@@ -6,6 +6,7 @@ from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
import httpx
from fastapi import HTTPException, status
from fastapi.concurrency import run_in_threadpool
from sqlalchemy import func
from sqlalchemy.exc import IntegrityError
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.logger import logger
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.services.auth.oauth.base import OAuthProviderBase
from src.services.auth.oauth.models import OAuthFlowError, OAuthUserInfo
@@ -39,6 +41,322 @@ def _build_oauth_client_kwargs(
class OAuthService:
"""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
def _require_module_active(db: Session) -> None:
registry = get_module_registry()
@@ -379,144 +697,11 @@ class OAuthService:
async def _handle_login(
db: Session, *, config: OAuthProvider, oauth_user: OAuthUserInfo
) -> User:
now = datetime.now(timezone.utc)
# 1) 已绑定账号:直接登录
existing_link = (
db.query(UserOAuthLink)
.filter(
UserOAuthLink.provider_type == config.provider_type,
UserOAuthLink.provider_user_id == oauth_user.id,
)
.first()
user = await run_in_threadpool(
OAuthService._handle_login_sync,
config.provider_type,
oauth_user,
)
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
await UserCacheService.invalidate_user_cache(user.id, user.email)
return user
@@ -525,50 +710,13 @@ class OAuthService:
async def _handle_bind(
db: Session, *, user_id: str, config: OAuthProvider, oauth_user: OAuthUserInfo
) -> UserOAuthLink:
now = datetime.now(timezone.utc)
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=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,
return await run_in_threadpool(
OAuthService._handle_bind_sync,
user_id,
config.provider_type,
oauth_user,
)
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
async def list_bindable_providers(db: Session, user: User) -> list[dict[str, str]]:
OAuthService._require_module_active(db)
@@ -686,79 +834,13 @@ class OAuthService:
@staticmethod
async def upsert_provider_config(db: Session, provider_type: str, data: Any) -> OAuthProvider:
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)
# 禁用前防锁号检查(仅在从 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
return await run_in_threadpool(
OAuthService._upsert_provider_config_sync, provider_type, data
)
@staticmethod
async def delete_provider_config(db: Session, provider_type: str) -> None:
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)
db.commit()
await run_in_threadpool(OAuthService._delete_provider_config_sync, provider_type)
@staticmethod
def _validate_provider_config(provider: OAuthProviderBase, data: Any) -> None:
@@ -991,38 +1073,6 @@ class OAuthService:
@staticmethod
async def unbind_provider(db: Session, user: User, provider_type: str) -> None:
OAuthService._require_module_active(db)
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 绑定")
# 本地用户无密码时,解绑最后一个 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()
await run_in_threadpool(OAuthService._unbind_provider_sync, user.id, provider_type)
if user.id is not None:
await UserCacheService.invalidate_user_cache(user.id, user.email)

View File

@@ -11,6 +11,7 @@ from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
from fastapi.concurrency import run_in_threadpool
from sqlalchemy import delete as sa_delete
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.logger import logger
from src.core.provider_types import ProviderType
from src.database import get_db_context
from src.models.database import (
Provider,
ProviderAPIKey,
@@ -90,6 +92,96 @@ class _DeleteKeyResult:
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:
"""在同步上下文中执行异步任务(有事件循环则调度,无则阻塞执行)。"""
try:
@@ -388,24 +480,13 @@ async def update_endpoint_key_response(
key_data: EndpointAPIKeyUpdate,
) -> EndpointAPIKeyResponse:
"""更新 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()
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)
db.commit()
db.refresh(key)
await run_update_key_side_effects(
db=db,
key=key,
@@ -427,29 +508,12 @@ async def create_provider_key_response(
key_data: EndpointAPIKeyCreate,
) -> EndpointAPIKeyResponse:
"""创建 Provider Key 并返回响应对象。"""
provider = db.query(Provider).filter(Provider.id == provider_id).first()
if not provider:
raise NotFoundException(f"Provider {provider_id} 不存在")
key_id = await run_in_threadpool(_create_provider_key_core_sync, provider_id, key_data)
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,
)
# 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)
db.expire_all()
new_key = db.query(ProviderAPIKey).filter(ProviderAPIKey.id == key_id).first()
if not new_key:
raise NotFoundException(f"Key {key_id} 不存在")
key_tail = (key_data.api_key or "")[-4:]
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]:
"""删除 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(
db=db,
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:
return {"success_count": 0, "failed_count": 0, "failed": []}
# 一次查询所有 Key
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]
result = await run_in_threadpool(_batch_delete_endpoint_keys_core_sync, key_ids)
affected_provider_ids = result["affected_provider_ids"]
failed = result["failed"]
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:
try:
await run_delete_key_side_effects(

View File

@@ -518,19 +518,46 @@ class MaintenanceScheduler:
return
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()
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,
)
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("小时统计聚合完成: {}", last_hour.isoformat())
except Exception as e:
logger.exception("钱包每日消费汇总任务执行失败: {}", e)
logger.exception("小时统计聚合任务执行失败: {}", e)
try:
db.rollback()
except Exception:
@@ -538,26 +565,7 @@ class MaintenanceScheduler:
finally:
db.close()
async def _perform_hourly_stats_aggregation(self) -> None:
"""执行小时统计聚合任务"""
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()
await asyncio.to_thread(_do)
async def _perform_pending_cleanup(self) -> None:
"""执行 pending 状态清理"""
@@ -664,23 +672,27 @@ class MaintenanceScheduler:
async def _perform_gemini_file_mapping_cleanup(self) -> None:
"""清理过期的 Gemini 文件映射记录"""
db = create_session()
try:
from src.services.gemini_files_mapping import cleanup_expired_mappings
deleted_count = cleanup_expired_mappings(db)
if deleted_count > 0:
logger.info(f"清理了 {deleted_count} 条过期的 Gemini 文件映射")
except Exception as e:
logger.exception(f"Gemini 文件映射清理失败: {e}")
def _do() -> None:
db = create_session()
try:
db.rollback()
except Exception:
pass
finally:
db.close()
from src.services.gemini_files_mapping import cleanup_expired_mappings
deleted_count = cleanup_expired_mappings(db)
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:
"""执行 Provider 签到任务
@@ -688,41 +700,29 @@ class MaintenanceScheduler:
遍历所有已配置 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:
# 检查是否启用签到任务
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")]
provider_ids = await asyncio.to_thread(_load_provider_ids)
if not provider_ids:
logger.info("无已配置的 Provider跳过签到任务")
return
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 # 签到任务并发数
semaphore = asyncio.Semaphore(concurrency)
@@ -774,9 +774,6 @@ class MaintenanceScheduler:
except Exception as e:
logger.exception(f"Provider 签到任务执行失败: {e}")
finally:
if db is not None:
db.close()
async def _perform_candidate_cleanup(self) -> None:
"""清理过期的 request_candidates 记录"""

View File

@@ -8,12 +8,14 @@ from __future__ import annotations
import hashlib
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 sqlalchemy.orm import Session
from src.core.logger import logger
from src.models.database import ManagementToken
from src.services.auth.service import AuthService
from src.utils.request_utils import get_client_ip
from ..core.exceptions import ForbiddenException
from ..database import get_db
@@ -22,8 +24,91 @@ from ..models.database import User, UserRole
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(
credentials: HTTPAuthorizationCredentials = Depends(security), db: Session = Depends(get_db)
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db),
) -> User:
"""
获取当前登录用户
@@ -39,71 +124,8 @@ async def get_current_user(
Raises:
HTTPException: 认证失败时抛出
"""
token = credentials.credentials
try:
# 验证Token格式和签名
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
return await authenticate_user_from_bearer_token(credentials.credentials, db, request)
except HTTPException:
raise
@@ -116,7 +138,9 @@ async def get_current_user(
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:
"""
从Header中获取当前用户兼容性函数
@@ -134,29 +158,12 @@ async def get_current_user_from_header(
if not authorization or not authorization.startswith("Bearer "):
raise ForbiddenException("未提供认证令牌")
token = authorization.replace("Bearer ", "")
try:
payload = await AuthService.verify_token(token, token_type="access")
user_id = payload.get("user_id")
if not user_id:
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
return await authenticate_user_from_bearer_token(
authorization.replace("Bearer ", ""),
db,
request,
)
except HTTPException:
# 保持原始的HTTPException (包括401)
raise