mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
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:
@@ -202,7 +202,7 @@ RUN printf '%s\n' \
|
||||
'environment=PYTHONUNBUFFERED=1,PYTHONIOENCODING=utf-8,LANG=C.UTF-8,LC_ALL=C.UTF-8,DOCKER_CONTAINER=true' \
|
||||
'' \
|
||||
'[program:tunnel-hub]' \
|
||||
'command=/usr/local/bin/aether-hub --bind 0.0.0.0:8085' \
|
||||
'command=/usr/local/bin/aether-hub --bind 0.0.0.0:8085 --worker-idle-timeout 0' \
|
||||
'autostart=true' \
|
||||
'autorestart=true' \
|
||||
'stdout_logfile=/dev/stdout' \
|
||||
|
||||
@@ -213,7 +213,7 @@ RUN printf '%s\n' \
|
||||
'environment=PYTHONUNBUFFERED=1,PYTHONIOENCODING=utf-8,LANG=C.UTF-8,LC_ALL=C.UTF-8,DOCKER_CONTAINER=true' \
|
||||
'' \
|
||||
'[program:tunnel-hub]' \
|
||||
'command=/usr/local/bin/aether-hub --bind 0.0.0.0:8085' \
|
||||
'command=/usr/local/bin/aether-hub --bind 0.0.0.0:8085 --worker-idle-timeout 0' \
|
||||
'autostart=true' \
|
||||
'autorestart=true' \
|
||||
'stdout_logfile=/dev/stdout' \
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
# 删除关联的 VideoTask(user_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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
# 尝试从多个来源获取 token:query 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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 记录"""
|
||||
|
||||
@@ -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("无效的认证凭据")
|
||||
|
||||
# 兼容旧 token:email 字段可能存在;新 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
|
||||
|
||||
25
tests/unit/test_api_auth_conventions.py
Normal file
25
tests/unit/test_api_auth_conventions.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_no_direct_access_token_verification_outside_pipeline() -> None:
|
||||
api_root = Path("src/api")
|
||||
allowed = {
|
||||
Path("src/api/base/pipeline.py"),
|
||||
Path("src/api/auth/routes.py"),
|
||||
}
|
||||
offenders: list[str] = []
|
||||
|
||||
for path in api_root.rglob("*.py"):
|
||||
if path in allowed:
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if "verify_token(" in text and 'token_type="access"' in text:
|
||||
offenders.append(str(path))
|
||||
|
||||
assert (
|
||||
offenders == []
|
||||
), "这些 API 文件仍在手写 access token 校验,应改为走 pipeline 或 auth_utils 统一入口: " + ", ".join(
|
||||
sorted(offenders)
|
||||
)
|
||||
87
tests/unit/test_auth_utils.py
Normal file
87
tests/unit/test_auth_utils.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
|
||||
from src.models.database import UserRole
|
||||
from src.utils.auth_utils import get_current_user, get_current_user_from_header
|
||||
|
||||
|
||||
class TestAuthUtilsManagementToken:
|
||||
@staticmethod
|
||||
def _make_request() -> Any:
|
||||
return MagicMock(
|
||||
headers={},
|
||||
client=MagicMock(host="127.0.0.1"),
|
||||
state=MagicMock(spec=[]),
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_current_user_accepts_management_token(self) -> None:
|
||||
request = self._make_request()
|
||||
credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials="ae_valid_token")
|
||||
db = MagicMock()
|
||||
user = MagicMock()
|
||||
user.id = "admin-123"
|
||||
user.role = UserRole.ADMIN
|
||||
management_token = MagicMock()
|
||||
management_token.id = "mt-123"
|
||||
|
||||
with patch(
|
||||
"src.utils.auth_utils.AuthService.authenticate_management_token",
|
||||
new_callable=AsyncMock,
|
||||
return_value=(user, management_token),
|
||||
) as mock_authenticate:
|
||||
result = await get_current_user(request, credentials, db)
|
||||
|
||||
assert result == user
|
||||
assert request.state.user_id == "admin-123"
|
||||
assert request.state.management_token_id == "mt-123"
|
||||
mock_authenticate.assert_awaited_once_with(db, "ae_valid_token", "127.0.0.1")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_current_user_from_header_accepts_management_token(self) -> None:
|
||||
request = self._make_request()
|
||||
db = MagicMock()
|
||||
user = MagicMock()
|
||||
user.id = "admin-123"
|
||||
user.role = UserRole.ADMIN
|
||||
management_token = MagicMock()
|
||||
management_token.id = "mt-123"
|
||||
|
||||
with patch(
|
||||
"src.utils.auth_utils.AuthService.authenticate_management_token",
|
||||
new_callable=AsyncMock,
|
||||
return_value=(user, management_token),
|
||||
) as mock_authenticate:
|
||||
result = await get_current_user_from_header(
|
||||
request,
|
||||
authorization="Bearer ae_valid_token",
|
||||
db=db,
|
||||
)
|
||||
|
||||
assert result == user
|
||||
assert request.state.user_id == "admin-123"
|
||||
assert request.state.management_token_id == "mt-123"
|
||||
mock_authenticate.assert_awaited_once_with(db, "ae_valid_token", "127.0.0.1")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_current_user_invalid_management_token_raises_401(self) -> None:
|
||||
request = self._make_request()
|
||||
credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials="ae_invalid_token")
|
||||
db = MagicMock()
|
||||
|
||||
with patch(
|
||||
"src.utils.auth_utils.AuthService.authenticate_management_token",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_current_user(request, credentials, db)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert exc_info.value.detail == "无效的Token"
|
||||
Reference in New Issue
Block a user