mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
feat(wallet): 钱包系统替代配额系统,新增支付与退款机制
- 新增钱包余额管理、充值、扣费、退款完整流程 - 新增支付网关抽象层(支持手动/支付宝/微信) - 用量计费从配额系统迁移到钱包余额扣费 - 新增管理员钱包管理与支付订单管理页面 - 新增用户钱包中心页面 - 移除独立 Key 锁定机制,统一由钱包余额控制 - 新增相关 API 路由、序列化器与数据库迁移 - 新增钱包、支付、退款相关测试
This commit is contained in:
@@ -9,6 +9,7 @@ from .endpoints import router as endpoints_router
|
||||
from .models import router as models_router
|
||||
from .modules import router as modules_router
|
||||
from .monitoring import router as monitoring_router
|
||||
from .payments import router as payments_router
|
||||
from .pool import router as pool_router
|
||||
from .provider_oauth import router as provider_oauth_router
|
||||
from .provider_ops import router as provider_ops_router
|
||||
@@ -21,6 +22,7 @@ from .system import router as system_router
|
||||
from .usage import router as usage_router
|
||||
from .users import router as users_router
|
||||
from .video_tasks import router as video_tasks_router
|
||||
from .wallets import router as wallets_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(system_router)
|
||||
@@ -30,6 +32,7 @@ router.include_router(api_keys_router)
|
||||
router.include_router(billing_router)
|
||||
router.include_router(usage_router)
|
||||
router.include_router(monitoring_router)
|
||||
router.include_router(payments_router)
|
||||
router.include_router(endpoints_router)
|
||||
router.include_router(provider_strategy_router)
|
||||
router.include_router(provider_oauth_router)
|
||||
@@ -42,6 +45,7 @@ router.include_router(modules_router)
|
||||
router.include_router(pool_router)
|
||||
router.include_router(provider_ops_router)
|
||||
router.include_router(video_tasks_router)
|
||||
router.include_router(wallets_router)
|
||||
|
||||
# 注意:以下路由已迁移到模块系统,由 ModuleRegistry 动态注册
|
||||
# - ldap_router: 当 LDAP_AVAILABLE=true 时注册
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"""管理员独立余额 API Key 管理路由。
|
||||
|
||||
独立余额Key:不关联用户配额,有独立余额限制,用于给非注册用户使用。
|
||||
独立余额Key:不关联用户配额,可配置独立余额限制或无限额度,用于给非注册用户使用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
@@ -21,8 +21,9 @@ from src.core.exceptions import InvalidRequestException, NotFoundException
|
||||
from src.core.logger import logger
|
||||
from src.database import get_db
|
||||
from src.models.api import CreateApiKeyRequest
|
||||
from src.models.database import ApiKey
|
||||
from src.models.database import ApiKey, Wallet
|
||||
from src.services.user.apikey import ApiKeyService
|
||||
from src.services.wallet import WalletService
|
||||
|
||||
# 应用时区配置,默认为 Asia/Shanghai
|
||||
APP_TIMEZONE = ZoneInfo(os.getenv("APP_TIMEZONE", "Asia/Shanghai"))
|
||||
@@ -68,6 +69,23 @@ router = APIRouter(prefix="/api/admin/api-keys", tags=["Admin - API Keys (Standa
|
||||
pipeline = ApiRequestPipeline()
|
||||
|
||||
|
||||
def _ensure_standalone_wallet(
|
||||
db: Session,
|
||||
api_key: ApiKey,
|
||||
*,
|
||||
limit_mode: Literal["finite", "unlimited"] | None = None,
|
||||
) -> Wallet:
|
||||
"""确保独立 Key 已绑定钱包,并可选同步额度模式。"""
|
||||
wallet = WalletService.get_or_create_wallet(db, api_key=api_key)
|
||||
if wallet is None:
|
||||
raise InvalidRequestException("独立密钥钱包初始化失败")
|
||||
|
||||
if limit_mode is not None and wallet.limit_mode != limit_mode:
|
||||
wallet = WalletService.set_wallet_limit_mode(db, wallet=wallet, limit_mode=limit_mode)
|
||||
|
||||
return wallet
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_standalone_api_keys(
|
||||
request: Request,
|
||||
@@ -88,10 +106,9 @@ async def list_standalone_api_keys(
|
||||
- `is_active`: 可选,根据启用状态筛选(true/false)
|
||||
|
||||
**返回字段**:
|
||||
- `api_keys`: API Key 列表,包含 id, name, key_display, is_active, current_balance_usd,
|
||||
balance_used_usd, total_requests, total_cost_usd, rate_limit, allowed_providers,
|
||||
allowed_api_formats, allowed_models, last_used_at, expires_at, created_at, updated_at,
|
||||
auto_delete_on_expiry 等字段
|
||||
- `api_keys`: API Key 列表,包含 id, name, key_display, is_active, is_standalone,
|
||||
total_requests, total_cost_usd, rate_limit, allowed_providers, allowed_api_formats,
|
||||
allowed_models, last_used_at, expires_at, created_at, updated_at, auto_delete_on_expiry 等字段
|
||||
- `total`: 符合条件的总记录数
|
||||
- `limit`: 当前分页限制
|
||||
- `skip`: 当前分页偏移量
|
||||
@@ -109,16 +126,16 @@ async def create_standalone_api_key(
|
||||
"""
|
||||
创建独立余额 API Key
|
||||
|
||||
创建一个新的独立余额 API Key。独立余额 Key 必须设置初始余额限制。
|
||||
创建一个新的独立余额 API Key。独立余额 Key 可设置初始余额,或使用无限额度。
|
||||
|
||||
**请求体字段**:
|
||||
- `name`: API Key 的名称
|
||||
- `initial_balance_usd`: 必需,初始余额(美元),必须大于 0
|
||||
- `initial_balance_usd`: 可选,初始余额(美元),null 表示无限制额度
|
||||
- `allowed_providers`: 可选,允许使用的提供商列表
|
||||
- `allowed_api_formats`: 可选,允许使用的 API 格式列表
|
||||
- `allowed_models`: 可选,允许使用的模型列表
|
||||
- `rate_limit`: 可选,速率限制配置(请求数/秒)
|
||||
- `expire_days`: 可选,过期天数(兼容旧版)
|
||||
- `expire_days`: 可选,过期天数(与 expires_at 二选一)
|
||||
- `expires_at`: 可选,过期时间(ISO 格式或 YYYY-MM-DD 格式,优先级高于 expire_days)
|
||||
- `auto_delete_on_expiry`: 可选,过期后是否自动删除
|
||||
|
||||
@@ -128,8 +145,7 @@ async def create_standalone_api_key(
|
||||
- `name`: API Key 名称
|
||||
- `key_display`: 脱敏显示的 Key
|
||||
- `is_standalone`: 是否为独立余额 Key(始终为 true)
|
||||
- `current_balance_usd`: 当前余额
|
||||
- `balance_used_usd`: 已使用余额
|
||||
- `wallet`: 钱包摘要(总余额、充值余额、赠款余额、额度模式等)
|
||||
- `rate_limit`: 速率限制配置
|
||||
- `expires_at`: 过期时间
|
||||
- `created_at`: 创建时间
|
||||
@@ -153,11 +169,12 @@ async def update_api_key(
|
||||
|
||||
**请求体字段**:
|
||||
- `name`: 可选,API Key 的名称
|
||||
- `unlimited_balance`: 可选,是否无限余额(true=无限,false=有限,不修改余额数值)
|
||||
- `rate_limit`: 可选,速率限制配置(null 表示无限制)
|
||||
- `allowed_providers`: 可选,允许使用的提供商列表
|
||||
- `allowed_api_formats`: 可选,允许使用的 API 格式列表
|
||||
- `allowed_models`: 可选,允许使用的模型列表
|
||||
- `expire_days`: 可选,过期天数(兼容旧版)
|
||||
- `expire_days`: 可选,过期天数(与 expires_at 二选一)
|
||||
- `expires_at`: 可选,过期时间(ISO 格式或 YYYY-MM-DD 格式,优先级高于 expire_days,null 或空字符串表示永不过期)
|
||||
- `auto_delete_on_expiry`: 可选,过期后是否自动删除
|
||||
|
||||
@@ -166,8 +183,7 @@ async def update_api_key(
|
||||
- `name`: API Key 名称
|
||||
- `key_display`: 脱敏显示的 Key
|
||||
- `is_active`: 是否启用
|
||||
- `current_balance_usd`: 当前余额
|
||||
- `balance_used_usd`: 已使用余额
|
||||
- `wallet`: 钱包摘要(总余额、充值余额、赠款余额、额度模式等)
|
||||
- `rate_limit`: 速率限制配置
|
||||
- `expires_at`: 过期时间
|
||||
- `updated_at`: 更新时间
|
||||
@@ -213,106 +229,6 @@ async def delete_api_key(key_id: str, request: Request, db: Session = Depends(ge
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.patch("/{key_id}/lock")
|
||||
async def toggle_lock_api_key(key_id: str, request: Request, db: Session = Depends(get_db)) -> Any:
|
||||
"""
|
||||
切换 API Key 锁定状态
|
||||
|
||||
锁定/解锁指定的 API Key。锁定后用户无法使用和操作此密钥。
|
||||
|
||||
**路径参数**:
|
||||
- `key_id`: API Key ID
|
||||
|
||||
**返回字段**:
|
||||
- `id`: API Key ID
|
||||
- `is_locked`: 新的锁定状态
|
||||
- `message`: 提示信息
|
||||
"""
|
||||
adapter = AdminToggleLockApiKeyAdapter(key_id=key_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.patch("/{key_id}/balance")
|
||||
async def add_balance_to_key(
|
||||
key_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
"""
|
||||
调整独立余额 API Key 的余额
|
||||
|
||||
为指定的独立余额 API Key 增加或扣除余额。
|
||||
|
||||
**路径参数**:
|
||||
- `key_id`: API Key ID
|
||||
|
||||
**请求体字段**:
|
||||
- `amount_usd`: 调整金额(美元),正数为充值,负数为扣除
|
||||
|
||||
**返回字段**:
|
||||
- `id`: API Key ID
|
||||
- `name`: API Key 名称
|
||||
- `current_balance_usd`: 调整后的当前余额
|
||||
- `balance_used_usd`: 已使用余额
|
||||
- `message`: 提示信息
|
||||
"""
|
||||
# 从请求体获取调整金额
|
||||
body = await request.json()
|
||||
amount_usd = body.get("amount_usd")
|
||||
|
||||
# 参数校验
|
||||
if amount_usd is None:
|
||||
raise HTTPException(status_code=400, detail="缺少必需参数: amount_usd")
|
||||
|
||||
if amount_usd == 0:
|
||||
raise HTTPException(status_code=400, detail="调整金额不能为 0")
|
||||
|
||||
# 类型校验
|
||||
try:
|
||||
amount_usd = float(amount_usd)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="调整金额必须是有效数字")
|
||||
|
||||
# 如果是扣除操作,检查Key是否存在以及余额是否充足
|
||||
if amount_usd < 0:
|
||||
api_key = db.query(ApiKey).filter(ApiKey.id == key_id).first()
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=404, detail="API密钥不存在")
|
||||
|
||||
if not api_key.is_standalone:
|
||||
raise HTTPException(status_code=400, detail="只能为独立余额Key调整余额")
|
||||
|
||||
if api_key.current_balance_usd is not None:
|
||||
if abs(amount_usd) > api_key.current_balance_usd:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"扣除金额 ${abs(amount_usd):.2f} 超过当前余额 ${api_key.current_balance_usd:.2f}",
|
||||
)
|
||||
|
||||
adapter = AdminAddBalanceAdapter(key_id=key_id, amount_usd=amount_usd)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.patch("/{key_id}/reset-usage")
|
||||
async def reset_api_key_usage(key_id: str, request: Request, db: Session = Depends(get_db)) -> Any:
|
||||
"""
|
||||
重置独立余额 API Key 的已使用额度
|
||||
|
||||
将 balance_used_usd 重置为 0,不改变 current_balance_usd。
|
||||
|
||||
**路径参数**:
|
||||
- `key_id`: API Key ID
|
||||
|
||||
**返回字段**:
|
||||
- `id`: API Key ID
|
||||
- `current_balance_usd`: 当前余额
|
||||
- `balance_used_usd`: 已使用余额(重置后为 0)
|
||||
- `message`: 提示信息
|
||||
"""
|
||||
adapter = AdminResetKeyUsageAdapter(key_id=key_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/{key_id}")
|
||||
async def get_api_key_detail(
|
||||
key_id: str,
|
||||
@@ -333,9 +249,9 @@ async def get_api_key_detail(
|
||||
|
||||
**返回字段**:
|
||||
- 当 include_key=false 时,返回基本信息:id, user_id, name, key_display, is_active,
|
||||
is_standalone, current_balance_usd, balance_used_usd, total_requests, total_cost_usd,
|
||||
rate_limit, allowed_providers, allowed_api_formats, allowed_models, last_used_at,
|
||||
expires_at, created_at, updated_at
|
||||
is_standalone, total_requests, total_cost_usd, rate_limit, allowed_providers,
|
||||
allowed_api_formats, allowed_models, last_used_at, expires_at, created_at, updated_at,
|
||||
wallet
|
||||
- 当 include_key=true 时,返回完整密钥:key
|
||||
"""
|
||||
if include_key:
|
||||
@@ -372,6 +288,18 @@ class AdminListStandaloneKeysAdapter(AdminApiAdapter):
|
||||
query.order_by(ApiKey.created_at.desc()).offset(self.skip).limit(self.limit).all()
|
||||
)
|
||||
|
||||
# 保证返回的独立 Key 都已完成钱包初始化。
|
||||
wallet_initialized = False
|
||||
for api_key in api_keys:
|
||||
wallet = WalletService.get_wallet(db, api_key_id=api_key.id)
|
||||
if wallet is None:
|
||||
_ensure_standalone_wallet(db, api_key)
|
||||
wallet_initialized = True
|
||||
if wallet_initialized:
|
||||
db.commit()
|
||||
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,
|
||||
@@ -388,10 +316,7 @@ class AdminListStandaloneKeysAdapter(AdminApiAdapter):
|
||||
"name": api_key.name,
|
||||
"key_display": api_key.get_display_key(),
|
||||
"is_active": api_key.is_active,
|
||||
"is_locked": api_key.is_locked,
|
||||
"is_standalone": api_key.is_standalone,
|
||||
"current_balance_usd": api_key.current_balance_usd,
|
||||
"balance_used_usd": float(api_key.balance_used_usd or 0),
|
||||
"total_requests": api_key.total_requests,
|
||||
"total_cost_usd": float(api_key.total_cost_usd or 0),
|
||||
"rate_limit": api_key.rate_limit,
|
||||
@@ -423,11 +348,14 @@ class AdminCreateStandaloneKeyAdapter(AdminApiAdapter):
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
db = context.db
|
||||
|
||||
# 独立Key必须设置初始余额
|
||||
if not self.key_data.initial_balance_usd or self.key_data.initial_balance_usd <= 0:
|
||||
# 独立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必须设置有效的初始余额(initial_balance_usd > 0)",
|
||||
detail="创建独立余额Key时,初始余额必须大于 0(或设置为 null 表示无限制)",
|
||||
)
|
||||
|
||||
# 独立Key需要关联到管理员用户(从context获取)
|
||||
@@ -445,13 +373,28 @@ class AdminCreateStandaloneKeyAdapter(AdminApiAdapter):
|
||||
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, # 兼容旧版
|
||||
expire_days=self.key_data.expire_days,
|
||||
expires_at=expires_at_dt, # 优先使用
|
||||
initial_balance_usd=self.key_data.initial_balance_usd,
|
||||
is_standalone=True, # 标记为独立Key
|
||||
auto_delete_on_expiry=self.key_data.auto_delete_on_expiry,
|
||||
)
|
||||
|
||||
# 钱包体系:独立 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()
|
||||
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}"
|
||||
)
|
||||
@@ -468,11 +411,10 @@ class AdminCreateStandaloneKeyAdapter(AdminApiAdapter):
|
||||
"name": api_key.name,
|
||||
"key_display": api_key.get_display_key(),
|
||||
"is_standalone": True,
|
||||
"current_balance_usd": api_key.current_balance_usd,
|
||||
"balance_used_usd": 0.0,
|
||||
"rate_limit": api_key.rate_limit,
|
||||
"expires_at": api_key.expires_at.isoformat() if api_key.expires_at else None,
|
||||
"created_at": api_key.created_at.isoformat(),
|
||||
"wallet": wallet_summary,
|
||||
"message": "独立余额Key创建成功,请妥善保存完整密钥,后续将无法查看",
|
||||
}
|
||||
|
||||
@@ -489,6 +431,8 @@ class AdminUpdateApiKeyAdapter(AdminApiAdapter):
|
||||
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 = {}
|
||||
@@ -518,7 +462,7 @@ class AdminUpdateApiKeyAdapter(AdminApiAdapter):
|
||||
elif "expires_at" in self.key_data.model_fields_set:
|
||||
# expires_at 明确传递为 null 或空字符串,设为永不过期
|
||||
update_data["expires_at"] = None
|
||||
# 兼容旧版 expire_days
|
||||
# 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(
|
||||
@@ -528,29 +472,50 @@ class AdminUpdateApiKeyAdapter(AdminApiAdapter):
|
||||
# expire_days = None/0/负数 表示永不过期
|
||||
update_data["expires_at"] = None
|
||||
|
||||
changed_fields = list(update_data.keys())
|
||||
|
||||
# 编辑独立 Key 不允许通过此接口改余额,统一走钱包操作接口。
|
||||
if "initial_balance_usd" in self.key_data.model_fields_set:
|
||||
raise InvalidRequestException("编辑独立密钥不支持修改余额,请使用钱包操作")
|
||||
|
||||
# 允许编辑独立 Key 的额度模式(不修改余额数值)。
|
||||
if (
|
||||
"unlimited_balance" in self.key_data.model_fields_set
|
||||
and self.key_data.unlimited_balance is not None
|
||||
):
|
||||
wallet = _ensure_standalone_wallet(db, api_key)
|
||||
desired_mode: Literal["finite", "unlimited"] = (
|
||||
"unlimited" if self.key_data.unlimited_balance else "finite"
|
||||
)
|
||||
if wallet.limit_mode != desired_mode:
|
||||
WalletService.set_wallet_limit_mode(db, wallet=wallet, limit_mode=desired_mode)
|
||||
changed_fields.append("unlimited_balance")
|
||||
|
||||
# 使用 ApiKeyService 更新
|
||||
updated_key = ApiKeyService.update_api_key(db, self.key_id, **update_data)
|
||||
if not updated_key:
|
||||
raise NotFoundException("更新失败", "api_key")
|
||||
|
||||
logger.info(f"管理员更新独立余额Key: ID {self.key_id}, 更新字段 {list(update_data.keys())}")
|
||||
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=list(update_data.keys()),
|
||||
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,
|
||||
"current_balance_usd": updated_key.current_balance_usd,
|
||||
"balance_used_usd": float(updated_key.balance_used_usd or 0),
|
||||
"rate_limit": updated_key.rate_limit,
|
||||
"expires_at": updated_key.expires_at.isoformat() if updated_key.expires_at else None,
|
||||
"updated_at": updated_key.updated_at.isoformat() if updated_key.updated_at else None,
|
||||
"wallet": wallet_summary,
|
||||
"message": "API密钥已更新",
|
||||
}
|
||||
|
||||
@@ -564,6 +529,8 @@ class AdminToggleApiKeyAdapter(AdminApiAdapter):
|
||||
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)
|
||||
@@ -588,41 +555,6 @@ class AdminToggleApiKeyAdapter(AdminApiAdapter):
|
||||
}
|
||||
|
||||
|
||||
class AdminToggleLockApiKeyAdapter(AdminApiAdapter):
|
||||
"""切换API密钥锁定状态"""
|
||||
|
||||
def __init__(self, key_id: str):
|
||||
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")
|
||||
|
||||
api_key.is_locked = not api_key.is_locked
|
||||
api_key.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
db.refresh(api_key)
|
||||
|
||||
logger.info(
|
||||
f"管理员切换API密钥锁定状态: Key ID {self.key_id}, 新状态 {'锁定' if api_key.is_locked else '解锁'}"
|
||||
)
|
||||
|
||||
context.add_audit_metadata(
|
||||
action="toggle_lock_api_key",
|
||||
target_key_id=api_key.id,
|
||||
user_id=api_key.user_id,
|
||||
new_lock_status="locked" if api_key.is_locked else "unlocked",
|
||||
)
|
||||
|
||||
return {
|
||||
"id": api_key.id,
|
||||
"is_locked": api_key.is_locked,
|
||||
"message": f"API密钥已{'锁定' if api_key.is_locked else '解锁'}",
|
||||
}
|
||||
|
||||
|
||||
class AdminDeleteApiKeyAdapter(AdminApiAdapter):
|
||||
def __init__(self, key_id: str):
|
||||
self.key_id = key_id
|
||||
@@ -632,6 +564,8 @@ class AdminDeleteApiKeyAdapter(AdminApiAdapter):
|
||||
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
|
||||
db.delete(api_key)
|
||||
@@ -650,82 +584,6 @@ class AdminDeleteApiKeyAdapter(AdminApiAdapter):
|
||||
return {"message": "API密钥已删除"}
|
||||
|
||||
|
||||
class AdminAddBalanceAdapter(AdminApiAdapter):
|
||||
"""为独立余额Key增加余额"""
|
||||
|
||||
def __init__(self, key_id: str, amount_usd: float):
|
||||
self.key_id = key_id
|
||||
self.amount_usd = amount_usd
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
db = context.db
|
||||
|
||||
# 使用 ApiKeyService 增加余额
|
||||
updated_key = ApiKeyService.add_balance(db, self.key_id, self.amount_usd)
|
||||
|
||||
if not updated_key:
|
||||
raise NotFoundException("余额充值失败:Key不存在或不是独立余额Key", "api_key")
|
||||
|
||||
logger.info(f"管理员为独立余额Key充值: ID {self.key_id}, 充值 ${self.amount_usd:.4f}")
|
||||
|
||||
context.add_audit_metadata(
|
||||
action="add_balance_to_key",
|
||||
key_id=self.key_id,
|
||||
amount_usd=self.amount_usd,
|
||||
new_current_balance=updated_key.current_balance_usd,
|
||||
)
|
||||
|
||||
return {
|
||||
"id": updated_key.id,
|
||||
"name": updated_key.name,
|
||||
"current_balance_usd": updated_key.current_balance_usd,
|
||||
"balance_used_usd": float(updated_key.balance_used_usd or 0),
|
||||
"message": f"余额充值成功,充值 ${self.amount_usd:.2f},当前余额 ${updated_key.current_balance_usd:.2f}",
|
||||
}
|
||||
|
||||
|
||||
class AdminResetKeyUsageAdapter(AdminApiAdapter):
|
||||
"""重置独立余额Key的已使用额度"""
|
||||
|
||||
def __init__(self, key_id: str):
|
||||
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("只能重置独立余额Key的使用额度")
|
||||
|
||||
previous_used = float(api_key.balance_used_usd or 0)
|
||||
api_key.balance_used_usd = 0.0
|
||||
api_key.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
db.refresh(api_key)
|
||||
|
||||
logger.info(
|
||||
f"管理员重置独立余额Key使用额度: Key ID {self.key_id}, "
|
||||
f"重置前已使用 ${previous_used:.4f}"
|
||||
)
|
||||
|
||||
context.add_audit_metadata(
|
||||
action="reset_key_usage",
|
||||
key_id=self.key_id,
|
||||
current_balance_usd=api_key.current_balance_usd,
|
||||
previous_balance_used_usd=previous_used,
|
||||
)
|
||||
|
||||
return {
|
||||
"id": api_key.id,
|
||||
"name": api_key.name,
|
||||
"current_balance_usd": api_key.current_balance_usd,
|
||||
"balance_used_usd": 0.0,
|
||||
"message": "使用额度已重置",
|
||||
}
|
||||
|
||||
|
||||
class AdminGetFullKeyAdapter(AdminApiAdapter):
|
||||
"""获取完整的API密钥"""
|
||||
|
||||
@@ -741,6 +599,8 @@ class AdminGetFullKeyAdapter(AdminApiAdapter):
|
||||
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("仅支持查看独立密钥")
|
||||
|
||||
# 解密完整密钥
|
||||
if not api_key.key_encrypted:
|
||||
@@ -777,6 +637,11 @@ class AdminGetKeyDetailAdapter(AdminApiAdapter):
|
||||
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("仅支持查看独立密钥")
|
||||
|
||||
wallet = WalletService.get_wallet(db, api_key_id=api_key.id)
|
||||
wallet_summary = WalletService.serialize_wallet_summary(wallet)
|
||||
|
||||
context.add_audit_metadata(
|
||||
action="get_api_key_detail",
|
||||
@@ -789,10 +654,7 @@ class AdminGetKeyDetailAdapter(AdminApiAdapter):
|
||||
"name": api_key.name,
|
||||
"key_display": api_key.get_display_key(),
|
||||
"is_active": api_key.is_active,
|
||||
"is_locked": api_key.is_locked,
|
||||
"is_standalone": api_key.is_standalone,
|
||||
"current_balance_usd": api_key.current_balance_usd,
|
||||
"balance_used_usd": float(api_key.balance_used_usd or 0),
|
||||
"total_requests": api_key.total_requests,
|
||||
"total_cost_usd": float(api_key.total_cost_usd or 0),
|
||||
"rate_limit": api_key.rate_limit,
|
||||
@@ -803,4 +665,5 @@ class AdminGetKeyDetailAdapter(AdminApiAdapter):
|
||||
"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,
|
||||
"wallet": wallet_summary,
|
||||
}
|
||||
|
||||
5
src/api/admin/payments/__init__.py
Normal file
5
src/api/admin/payments/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Admin payment routes."""
|
||||
|
||||
from .routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
246
src/api/admin/payments/routes.py
Normal file
246
src/api/admin/payments/routes.py
Normal file
@@ -0,0 +1,246 @@
|
||||
"""管理员支付订单管理接口。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.api.base.admin_adapter import AdminApiAdapter
|
||||
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.services.payment import PaymentService
|
||||
|
||||
router = APIRouter(prefix="/api/admin/payments", tags=["Admin - Payments"])
|
||||
pipeline = ApiRequestPipeline()
|
||||
|
||||
|
||||
class AdminPaymentOrderCreditPayload(BaseModel):
|
||||
gateway_order_id: str | None = Field(default=None, max_length=128)
|
||||
pay_amount: float | None = Field(default=None, gt=0)
|
||||
pay_currency: str | None = Field(default=None, min_length=3, max_length=3)
|
||||
exchange_rate: float | None = Field(default=None, gt=0)
|
||||
gateway_response: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def _parse_payload(model_cls: type[BaseModel], payload: dict[str, Any]) -> BaseModel:
|
||||
try:
|
||||
return model_cls.model_validate(payload)
|
||||
except ValidationError as exc:
|
||||
errors = exc.errors()
|
||||
if errors:
|
||||
raise InvalidRequestException(translate_pydantic_error(errors[0]))
|
||||
raise InvalidRequestException("请求数据验证失败")
|
||||
|
||||
|
||||
@router.get("/orders")
|
||||
async def list_payment_orders(
|
||||
request: Request,
|
||||
status: str | None = Query(None),
|
||||
payment_method: str | None = Query(None),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0, le=5000),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
adapter = AdminPaymentOrderListAdapter(
|
||||
status=status,
|
||||
payment_method=payment_method,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/orders/{order_id}")
|
||||
async def get_payment_order(
|
||||
order_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
adapter = AdminPaymentOrderDetailAdapter(order_id=order_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("/orders/{order_id}/expire")
|
||||
async def expire_payment_order(
|
||||
order_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
adapter = AdminPaymentOrderExpireAdapter(order_id=order_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("/orders/{order_id}/credit")
|
||||
async def credit_payment_order(
|
||||
order_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
adapter = AdminPaymentOrderCreditAdapter(order_id=order_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("/orders/{order_id}/fail")
|
||||
async def fail_payment_order(
|
||||
order_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
adapter = AdminPaymentOrderFailAdapter(order_id=order_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/callbacks")
|
||||
async def list_payment_callbacks(
|
||||
request: Request,
|
||||
payment_method: str | None = Query(None),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0, le=5000),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
adapter = AdminPaymentCallbackListAdapter(
|
||||
payment_method=payment_method,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminPaymentOrderListAdapter(AdminApiAdapter):
|
||||
status: str | None
|
||||
payment_method: str | None
|
||||
limit: int
|
||||
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,
|
||||
)
|
||||
if changed:
|
||||
context.db.commit()
|
||||
return {
|
||||
"items": [serialize_payment_order(item) for item in items],
|
||||
"total": total,
|
||||
"limit": self.limit,
|
||||
"offset": self.offset,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
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)}
|
||||
|
||||
|
||||
@dataclass
|
||||
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}
|
||||
|
||||
|
||||
@dataclass
|
||||
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}
|
||||
|
||||
|
||||
@dataclass
|
||||
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)}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminPaymentCallbackListAdapter(AdminApiAdapter):
|
||||
payment_method: str | None
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
|
||||
items, total = PaymentService.list_callbacks(
|
||||
context.db,
|
||||
payment_method=self.payment_method,
|
||||
limit=self.limit,
|
||||
offset=self.offset,
|
||||
)
|
||||
return {
|
||||
"items": [serialize_payment_callback(item) for item in items],
|
||||
"total": total,
|
||||
"limit": self.limit,
|
||||
"offset": self.offset,
|
||||
}
|
||||
@@ -24,6 +24,7 @@ from src.models.database import ApiKey, Provider, Usage, User
|
||||
from src.services.email.email_template import EmailTemplate
|
||||
from src.services.provider_ops.types import SENSITIVE_CREDENTIAL_FIELDS
|
||||
from src.services.system.config import SystemConfigService
|
||||
from src.services.wallet import WalletService
|
||||
from src.utils.cache_decorator import cache_result
|
||||
|
||||
router = APIRouter(prefix="/api/admin/system", tags=["Admin - System"])
|
||||
@@ -678,26 +679,6 @@ class AdminSetSystemConfigAdapter(AdminApiAdapter):
|
||||
except Exception as e:
|
||||
logger.warning(f"更新签到任务时间失败: {e}")
|
||||
|
||||
# 如果更新的是用户配额重置任务时间,动态更新调度器
|
||||
if self.key == "user_quota_reset_time" and value:
|
||||
try:
|
||||
from src.services.system.maintenance_scheduler import get_maintenance_scheduler
|
||||
|
||||
scheduler = get_maintenance_scheduler()
|
||||
scheduler.update_user_quota_reset_time(value)
|
||||
except Exception as e:
|
||||
logger.warning(f"更新用户配额重置任务时间失败: {e}")
|
||||
|
||||
# 如果更新的是独立密钥额度重置任务时间,动态更新调度器
|
||||
if self.key == "standalone_key_quota_reset_time" and value:
|
||||
try:
|
||||
from src.services.system.maintenance_scheduler import get_maintenance_scheduler
|
||||
|
||||
scheduler = get_maintenance_scheduler()
|
||||
scheduler.update_standalone_key_quota_reset_time(value)
|
||||
except Exception as e:
|
||||
logger.warning(f"更新独立密钥额度重置任务时间失败: {e}")
|
||||
|
||||
# 如果更新的是调度模式或优先级模式,立即更新当前 Worker 的 Scheduler 单例
|
||||
if self.key in ("scheduling_mode", "provider_priority_mode"):
|
||||
try:
|
||||
@@ -1946,15 +1927,19 @@ class AdminImportConfigAdapter(AdminApiAdapter):
|
||||
|
||||
class AdminExportUsersAdapter(AdminApiAdapter):
|
||||
@staticmethod
|
||||
def _serialize_api_key(key: ApiKey, include_is_standalone: bool = False) -> dict[str, Any]:
|
||||
def _serialize_api_key(
|
||||
key: ApiKey, include_is_standalone: bool = False, db: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
"""序列化用户 API Key 为导出格式。"""
|
||||
from src.core.crypto import crypto_service
|
||||
|
||||
data = {
|
||||
wallet = None
|
||||
if db is not None and key.is_standalone:
|
||||
wallet = WalletService.get_wallet(db, api_key_id=key.id)
|
||||
|
||||
data: dict[str, Any] = {
|
||||
"key_hash": key.key_hash,
|
||||
"name": key.name,
|
||||
"balance_used_usd": key.balance_used_usd,
|
||||
"current_balance_usd": key.current_balance_usd,
|
||||
"allowed_providers": key.allowed_providers,
|
||||
"allowed_api_formats": key.allowed_api_formats,
|
||||
"allowed_models": key.allowed_models,
|
||||
@@ -1966,6 +1951,7 @@ class AdminExportUsersAdapter(AdminApiAdapter):
|
||||
"auto_delete_on_expiry": key.auto_delete_on_expiry,
|
||||
"total_requests": key.total_requests,
|
||||
"total_cost_usd": key.total_cost_usd,
|
||||
"wallet": WalletService.serialize_wallet_summary(wallet) if wallet else None,
|
||||
}
|
||||
|
||||
if key.key_encrypted:
|
||||
@@ -1995,6 +1981,7 @@ class AdminExportUsersAdapter(AdminApiAdapter):
|
||||
users = db.query(User).filter(User.is_deleted.is_(False), User.role != UserRole.ADMIN).all()
|
||||
users_data = []
|
||||
for user in users:
|
||||
wallet = WalletService.get_wallet(db, user_id=user.id)
|
||||
# 导出用户的 API Keys(排除独立余额Key,独立Key单独导出)
|
||||
api_keys = (
|
||||
db.query(ApiKey)
|
||||
@@ -2016,9 +2003,8 @@ class AdminExportUsersAdapter(AdminApiAdapter):
|
||||
"allowed_api_formats": user.allowed_api_formats,
|
||||
"allowed_models": user.allowed_models,
|
||||
"model_capability_settings": user.model_capability_settings,
|
||||
"quota_usd": user.quota_usd,
|
||||
"used_usd": user.used_usd,
|
||||
"total_usd": user.total_usd,
|
||||
"unlimited": WalletService.is_unlimited_wallet(wallet),
|
||||
"wallet": WalletService.serialize_wallet_summary(wallet) if wallet else None,
|
||||
"is_active": user.is_active,
|
||||
"api_keys": api_keys_data,
|
||||
}
|
||||
@@ -2026,7 +2012,7 @@ class AdminExportUsersAdapter(AdminApiAdapter):
|
||||
|
||||
# 导出独立余额 Keys(管理员创建的,不属于普通用户)
|
||||
standalone_keys = db.query(ApiKey).filter(ApiKey.is_standalone.is_(True)).all()
|
||||
standalone_keys_data = [self._serialize_api_key(key) for key in standalone_keys]
|
||||
standalone_keys_data = [self._serialize_api_key(key, db=db) for key in standalone_keys]
|
||||
|
||||
return {
|
||||
"version": "1.2",
|
||||
@@ -2119,8 +2105,6 @@ class AdminImportUsersAdapter(AdminApiAdapter):
|
||||
key_encrypted=key_encrypted,
|
||||
name=key_data.get("name"),
|
||||
is_standalone=is_standalone or key_data.get("is_standalone", False),
|
||||
balance_used_usd=key_data.get("balance_used_usd", 0.0),
|
||||
current_balance_usd=key_data.get("current_balance_usd"),
|
||||
allowed_providers=key_data.get("allowed_providers"),
|
||||
allowed_api_formats=key_data.get("allowed_api_formats"),
|
||||
allowed_models=key_data.get("allowed_models"),
|
||||
@@ -2153,6 +2137,14 @@ class AdminImportUsersAdapter(AdminApiAdapter):
|
||||
continue
|
||||
|
||||
existing_user = db.query(User).filter(User.email == import_email).first()
|
||||
wallet_payload = (
|
||||
user_data.get("wallet") if isinstance(user_data.get("wallet"), dict) else None
|
||||
)
|
||||
wallet_limit_mode = (
|
||||
str(wallet_payload.get("limit_mode"))
|
||||
if wallet_payload and wallet_payload.get("limit_mode") in {"finite", "unlimited"}
|
||||
else ("unlimited" if user_data.get("unlimited") else "finite")
|
||||
)
|
||||
|
||||
if existing_user:
|
||||
user_id = existing_user.id
|
||||
@@ -2173,11 +2165,20 @@ class AdminImportUsersAdapter(AdminApiAdapter):
|
||||
existing_user.model_capability_settings = user_data.get(
|
||||
"model_capability_settings"
|
||||
)
|
||||
existing_user.quota_usd = user_data.get("quota_usd")
|
||||
existing_user.used_usd = user_data.get("used_usd", 0.0)
|
||||
existing_user.total_usd = user_data.get("total_usd", 0.0)
|
||||
existing_user.is_active = user_data.get("is_active", True)
|
||||
existing_user.updated_at = datetime.now(timezone.utc)
|
||||
wallet = WalletService.get_or_create_wallet(db, user=existing_user)
|
||||
if wallet is not None:
|
||||
wallet.limit_mode = wallet_limit_mode
|
||||
if wallet_payload:
|
||||
wallet.balance = wallet_payload.get("recharge_balance", 0) or 0
|
||||
wallet.gift_balance = wallet_payload.get("gift_balance", 0) or 0
|
||||
wallet.total_recharged = wallet_payload.get("total_recharged", 0) or 0
|
||||
wallet.total_consumed = wallet_payload.get("total_consumed", 0) or 0
|
||||
wallet.total_refunded = wallet_payload.get("total_refunded", 0) or 0
|
||||
wallet.total_adjusted = wallet_payload.get("total_adjusted", 0) or 0
|
||||
wallet.status = wallet_payload.get("status", "active") or "active"
|
||||
wallet.updated_at = datetime.now(timezone.utc)
|
||||
stats["users"]["updated"] += 1
|
||||
else:
|
||||
# 创建新用户
|
||||
@@ -2196,13 +2197,22 @@ class AdminImportUsersAdapter(AdminApiAdapter):
|
||||
allowed_api_formats=user_data.get("allowed_api_formats"),
|
||||
allowed_models=user_data.get("allowed_models"),
|
||||
model_capability_settings=user_data.get("model_capability_settings"),
|
||||
quota_usd=user_data.get("quota_usd"),
|
||||
used_usd=user_data.get("used_usd", 0.0),
|
||||
total_usd=user_data.get("total_usd", 0.0),
|
||||
is_active=user_data.get("is_active", True),
|
||||
)
|
||||
db.add(new_user)
|
||||
db.flush()
|
||||
wallet = WalletService.get_or_create_wallet(db, user=new_user)
|
||||
if wallet is not None:
|
||||
wallet.limit_mode = wallet_limit_mode
|
||||
if wallet_payload:
|
||||
wallet.balance = wallet_payload.get("recharge_balance", 0) or 0
|
||||
wallet.gift_balance = wallet_payload.get("gift_balance", 0) or 0
|
||||
wallet.total_recharged = wallet_payload.get("total_recharged", 0) or 0
|
||||
wallet.total_consumed = wallet_payload.get("total_consumed", 0) or 0
|
||||
wallet.total_refunded = wallet_payload.get("total_refunded", 0) or 0
|
||||
wallet.total_adjusted = wallet_payload.get("total_adjusted", 0) or 0
|
||||
wallet.status = wallet_payload.get("status", "active") or "active"
|
||||
wallet.updated_at = datetime.now(timezone.utc)
|
||||
user_id = new_user.id
|
||||
stats["users"]["created"] += 1
|
||||
|
||||
@@ -2229,6 +2239,38 @@ class AdminImportUsersAdapter(AdminApiAdapter):
|
||||
)
|
||||
if new_key:
|
||||
db.add(new_key)
|
||||
db.flush()
|
||||
wallet = WalletService.get_or_create_wallet(db, api_key=new_key)
|
||||
wallet_payload = (
|
||||
key_data.get("wallet")
|
||||
if isinstance(key_data.get("wallet"), dict)
|
||||
else None
|
||||
)
|
||||
if wallet is not None:
|
||||
wallet.limit_mode = (
|
||||
str(wallet_payload.get("limit_mode"))
|
||||
if wallet_payload
|
||||
and wallet_payload.get("limit_mode")
|
||||
in {"finite", "unlimited"}
|
||||
else ("unlimited" if key_data.get("unlimited") else "finite")
|
||||
)
|
||||
if wallet_payload:
|
||||
wallet.balance = wallet_payload.get("recharge_balance", 0) or 0
|
||||
wallet.gift_balance = wallet_payload.get("gift_balance", 0) or 0
|
||||
wallet.total_recharged = (
|
||||
wallet_payload.get("total_recharged", 0) or 0
|
||||
)
|
||||
wallet.total_consumed = (
|
||||
wallet_payload.get("total_consumed", 0) or 0
|
||||
)
|
||||
wallet.total_refunded = (
|
||||
wallet_payload.get("total_refunded", 0) or 0
|
||||
)
|
||||
wallet.total_adjusted = (
|
||||
wallet_payload.get("total_adjusted", 0) or 0
|
||||
)
|
||||
wallet.status = wallet_payload.get("status", "active") or "active"
|
||||
wallet.updated_at = datetime.now(timezone.utc)
|
||||
stats["standalone_keys"]["created"] += 1
|
||||
elif status == "skipped":
|
||||
stats["standalone_keys"]["skipped"] += 1
|
||||
@@ -2655,15 +2697,11 @@ def _purge_stats_and_reset_counters(db: Session) -> None:
|
||||
db.query(StatsSummary).delete()
|
||||
db.query(StatsUserDaily).delete()
|
||||
|
||||
# 重置 User 上的累计统计字段
|
||||
db.query(User).update({User.used_usd: 0.0, User.total_usd: 0.0}, synchronize_session=False)
|
||||
|
||||
# 重置 ApiKey 上的缓存统计字段
|
||||
db.query(ApiKey).update(
|
||||
{
|
||||
ApiKey.total_requests: 0,
|
||||
ApiKey.total_cost_usd: 0.0,
|
||||
ApiKey.balance_used_usd: 0.0,
|
||||
},
|
||||
synchronize_session=False,
|
||||
)
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session, load_only
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.api.base.admin_adapter import AdminApiAdapter
|
||||
from src.api.base.context import ApiRequestContext
|
||||
@@ -23,12 +22,31 @@ from src.models.database import ApiKey, User, UserRole
|
||||
from src.services.system.config import SystemConfigService
|
||||
from src.services.user.apikey import ApiKeyService
|
||||
from src.services.user.service import UserService
|
||||
from src.services.wallet import WalletService
|
||||
from src.utils.cache_decorator import cache_result
|
||||
|
||||
router = APIRouter(prefix="/api/admin/users", tags=["Admin - Users"])
|
||||
pipeline = ApiRequestPipeline()
|
||||
|
||||
|
||||
def _serialize_user(db: Session, user: User) -> dict[str, Any]:
|
||||
wallet = WalletService.get_wallet(db, user_id=user.id)
|
||||
return {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"username": user.username,
|
||||
"role": user.role.value,
|
||||
"allowed_providers": user.allowed_providers,
|
||||
"allowed_api_formats": user.allowed_api_formats,
|
||||
"allowed_models": user.allowed_models,
|
||||
"unlimited": WalletService.is_unlimited_wallet(wallet),
|
||||
"is_active": user.is_active,
|
||||
"created_at": user.created_at.isoformat(),
|
||||
"updated_at": user.updated_at.isoformat() if user.updated_at else None,
|
||||
"last_login_at": user.last_login_at.isoformat() if user.last_login_at else None,
|
||||
}
|
||||
|
||||
|
||||
# 管理员端点
|
||||
@router.post("")
|
||||
async def create_user_endpoint(request: Request, db: Session = Depends(get_db)) -> Any:
|
||||
@@ -42,7 +60,8 @@ async def create_user_endpoint(request: Request, db: Session = Depends(get_db))
|
||||
- `username`: 用户名
|
||||
- `password`: 密码
|
||||
- `role`: 角色(user/admin)
|
||||
- `quota_usd`: 配额(USD)
|
||||
- `initial_gift_usd`: 初始赠款(USD,可选)
|
||||
- `unlimited`: 是否无限制
|
||||
"""
|
||||
adapter = AdminCreateUserAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
@@ -62,7 +81,7 @@ async def list_users(
|
||||
|
||||
分页获取用户列表,支持按角色和状态筛选。
|
||||
|
||||
**返回字段**: id, email, username, role, quota_usd, used_usd, is_active, created_at 等
|
||||
**返回字段**: id, email, username, role, unlimited, is_active, created_at 等
|
||||
"""
|
||||
adapter = AdminListUsersAdapter(skip=skip, limit=limit, role=role, is_active=is_active)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
@@ -91,7 +110,7 @@ async def update_user(
|
||||
"""
|
||||
更新用户信息
|
||||
|
||||
更新指定用户的信息,包括角色、配额、权限等。
|
||||
更新指定用户的信息,包括角色、无限制开关、权限等。
|
||||
|
||||
**路径参数**:
|
||||
- `user_id`: 用户 ID (UUID)
|
||||
@@ -100,7 +119,7 @@ async def update_user(
|
||||
- `email`: 邮箱地址
|
||||
- `username`: 用户名
|
||||
- `role`: 角色
|
||||
- `quota_usd`: 配额
|
||||
- `unlimited`: 是否无限制
|
||||
- `is_active`: 是否启用
|
||||
- `allowed_providers`: 允许的提供商列表
|
||||
- `allowed_models`: 允许的模型列表
|
||||
@@ -123,20 +142,6 @@ async def delete_user(user_id: str, request: Request, db: Session = Depends(get_
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.patch("/{user_id}/quota")
|
||||
async def reset_user_quota(user_id: str, request: Request, db: Session = Depends(get_db)) -> None:
|
||||
"""
|
||||
重置用户配额
|
||||
|
||||
将用户的已用配额(used_usd)重置为 0。
|
||||
|
||||
**路径参数**:
|
||||
- `user_id`: 用户 ID (UUID)
|
||||
"""
|
||||
adapter = AdminResetUserQuotaAdapter(user_id=user_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/{user_id}/api-keys")
|
||||
async def get_user_api_keys(
|
||||
user_id: str,
|
||||
@@ -203,6 +208,46 @@ async def delete_user_api_key(
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.patch("/{user_id}/api-keys/{key_id}/lock")
|
||||
async def toggle_user_api_key_lock(
|
||||
user_id: str,
|
||||
key_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
"""
|
||||
切换用户 API 密钥锁定状态
|
||||
|
||||
仅支持普通用户 Key(非独立 Key)。
|
||||
|
||||
**路径参数**:
|
||||
- `user_id`: 用户 ID (UUID)
|
||||
- `key_id`: 密钥 ID
|
||||
"""
|
||||
adapter = AdminToggleUserKeyLockAdapter(user_id=user_id, key_id=key_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/{user_id}/api-keys/{key_id}/full-key")
|
||||
async def get_user_api_key_full_key(
|
||||
user_id: str,
|
||||
key_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
"""
|
||||
获取用户 API 密钥完整值
|
||||
|
||||
仅支持普通用户 Key(非独立 Key)。
|
||||
|
||||
**路径参数**:
|
||||
- `user_id`: 用户 ID (UUID)
|
||||
- `key_id`: 密钥 ID
|
||||
"""
|
||||
adapter = AdminGetUserKeyFullKeyAdapter(user_id=user_id, key_id=key_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
# ============== 管理员适配器实现 ==============
|
||||
|
||||
|
||||
@@ -224,13 +269,15 @@ class AdminCreateUserAdapter(AdminApiAdapter):
|
||||
except (KeyError, AttributeError):
|
||||
raise InvalidRequestException("角色参数不合法")
|
||||
|
||||
# 确定配额:unlimited 优先,其次是指定值,最后是系统默认
|
||||
# 确定初始赠款:仅有限制用户才会发放初始赠款
|
||||
if request.unlimited:
|
||||
quota_usd = None # None 表示无限制
|
||||
elif request.quota_usd is not None:
|
||||
quota_usd = request.quota_usd
|
||||
initial_gift_usd = None
|
||||
elif request.initial_gift_usd is not None:
|
||||
initial_gift_usd = request.initial_gift_usd
|
||||
else:
|
||||
quota_usd = SystemConfigService.get_config(db, "default_user_quota_usd", default=10.0)
|
||||
initial_gift_usd = SystemConfigService.get_config(
|
||||
db, "default_user_initial_gift_usd", default=None
|
||||
)
|
||||
|
||||
# 处理访问权限字段:空数组转为 None(表示无限制)
|
||||
allowed_providers = request.allowed_providers if request.allowed_providers else None
|
||||
@@ -244,7 +291,8 @@ class AdminCreateUserAdapter(AdminApiAdapter):
|
||||
username=request.username,
|
||||
password=request.password,
|
||||
role=role,
|
||||
quota_usd=quota_usd,
|
||||
initial_gift_usd=initial_gift_usd,
|
||||
unlimited=request.unlimited,
|
||||
allowed_providers=allowed_providers,
|
||||
allowed_api_formats=allowed_api_formats,
|
||||
allowed_models=allowed_models,
|
||||
@@ -258,24 +306,11 @@ class AdminCreateUserAdapter(AdminApiAdapter):
|
||||
target_email=user.email,
|
||||
target_username=user.username,
|
||||
target_role=user.role.value,
|
||||
quota_usd=user.quota_usd,
|
||||
initial_gift_usd=initial_gift_usd,
|
||||
unlimited=request.unlimited,
|
||||
is_active=user.is_active,
|
||||
)
|
||||
|
||||
return {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"username": user.username,
|
||||
"role": user.role.value,
|
||||
"allowed_providers": user.allowed_providers,
|
||||
"allowed_api_formats": user.allowed_api_formats,
|
||||
"allowed_models": user.allowed_models,
|
||||
"quota_usd": user.quota_usd,
|
||||
"used_usd": user.used_usd,
|
||||
"total_usd": getattr(user, "total_usd", 0),
|
||||
"is_active": user.is_active,
|
||||
"created_at": user.created_at.isoformat(),
|
||||
}
|
||||
return _serialize_user(db, user)
|
||||
|
||||
|
||||
class AdminListUsersAdapter(AdminApiAdapter):
|
||||
@@ -293,52 +328,12 @@ class AdminListUsersAdapter(AdminApiAdapter):
|
||||
)
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
db = context.db
|
||||
role_enum = None
|
||||
if self.role:
|
||||
try:
|
||||
role_enum = UserRole[self.role.upper()]
|
||||
except KeyError as exc:
|
||||
raise InvalidRequestException("角色参数不合法") from exc
|
||||
|
||||
query = db.query(User).options(
|
||||
load_only(
|
||||
User.id,
|
||||
User.email,
|
||||
User.username,
|
||||
User.role,
|
||||
User.allowed_providers,
|
||||
User.allowed_api_formats,
|
||||
User.allowed_models,
|
||||
User.quota_usd,
|
||||
User.used_usd,
|
||||
User.total_usd,
|
||||
User.is_active,
|
||||
User.created_at,
|
||||
)
|
||||
)
|
||||
if role_enum:
|
||||
query = query.filter(User.role == role_enum)
|
||||
if self.is_active is not None:
|
||||
query = query.filter(User.is_active == self.is_active)
|
||||
|
||||
users = query.order_by(User.created_at.desc()).offset(self.skip).limit(self.limit).all()
|
||||
return [
|
||||
{
|
||||
"id": u.id,
|
||||
"email": u.email,
|
||||
"username": u.username,
|
||||
"role": u.role.value,
|
||||
"allowed_providers": u.allowed_providers,
|
||||
"allowed_api_formats": u.allowed_api_formats,
|
||||
"allowed_models": u.allowed_models,
|
||||
"quota_usd": u.quota_usd,
|
||||
"used_usd": u.used_usd,
|
||||
"total_usd": getattr(u, "total_usd", 0),
|
||||
"is_active": u.is_active,
|
||||
"created_at": u.created_at.isoformat(),
|
||||
}
|
||||
for u in users
|
||||
]
|
||||
try:
|
||||
role_enum = UserRole[self.role.upper()] if self.role else None
|
||||
except KeyError as exc:
|
||||
raise InvalidRequestException("角色参数不合法") from exc
|
||||
users = UserService.list_users(db, self.skip, self.limit, role_enum, self.is_active)
|
||||
return [_serialize_user(db, u) for u in users]
|
||||
|
||||
|
||||
class AdminGetUserAdapter(AdminApiAdapter):
|
||||
@@ -358,22 +353,7 @@ class AdminGetUserAdapter(AdminApiAdapter):
|
||||
include_history=bool(user.last_login_at),
|
||||
)
|
||||
|
||||
return {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"username": user.username,
|
||||
"role": user.role.value,
|
||||
"allowed_providers": user.allowed_providers,
|
||||
"allowed_api_formats": user.allowed_api_formats,
|
||||
"allowed_models": user.allowed_models,
|
||||
"quota_usd": user.quota_usd,
|
||||
"used_usd": user.used_usd,
|
||||
"total_usd": getattr(user, "total_usd", 0),
|
||||
"is_active": user.is_active,
|
||||
"created_at": user.created_at.isoformat(),
|
||||
"updated_at": user.updated_at.isoformat() if user.updated_at else None,
|
||||
"last_login_at": user.last_login_at.isoformat() if user.last_login_at else None,
|
||||
}
|
||||
return _serialize_user(db, user)
|
||||
|
||||
|
||||
class AdminUpdateUserAdapter(AdminApiAdapter):
|
||||
@@ -397,6 +377,11 @@ class AdminUpdateUserAdapter(AdminApiAdapter):
|
||||
|
||||
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"]
|
||||
@@ -414,31 +399,28 @@ class AdminUpdateUserAdapter(AdminApiAdapter):
|
||||
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,
|
||||
quota_usd=user.quota_usd,
|
||||
unlimited_before=unlimited_before,
|
||||
unlimited_after=(
|
||||
requested_unlimited if requested_unlimited is not None else unlimited_before
|
||||
),
|
||||
is_active=user.is_active,
|
||||
)
|
||||
|
||||
return {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"username": user.username,
|
||||
"role": user.role.value,
|
||||
"allowed_providers": user.allowed_providers,
|
||||
"allowed_api_formats": user.allowed_api_formats,
|
||||
"allowed_models": user.allowed_models,
|
||||
"quota_usd": user.quota_usd,
|
||||
"used_usd": user.used_usd,
|
||||
"total_usd": getattr(user, "total_usd", 0),
|
||||
"is_active": user.is_active,
|
||||
"created_at": user.created_at.isoformat(),
|
||||
"updated_at": user.updated_at.isoformat() if user.updated_at else None,
|
||||
}
|
||||
return _serialize_user(db, user)
|
||||
|
||||
|
||||
class AdminDeleteUserAdapter(AdminApiAdapter):
|
||||
@@ -458,7 +440,10 @@ class AdminDeleteUserAdapter(AdminApiAdapter):
|
||||
if admin_count <= 1:
|
||||
raise InvalidRequestException("不能删除最后一个管理员账户")
|
||||
|
||||
success = UserService.delete_user(db, self.user_id)
|
||||
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="用户不存在")
|
||||
|
||||
@@ -472,38 +457,6 @@ class AdminDeleteUserAdapter(AdminApiAdapter):
|
||||
return {"message": "用户删除成功"}
|
||||
|
||||
|
||||
class AdminResetUserQuotaAdapter(AdminApiAdapter):
|
||||
def __init__(self, user_id: str):
|
||||
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 NotFoundException("用户不存在", "user")
|
||||
|
||||
user.used_usd = 0.0
|
||||
user.total_usd = getattr(user, "total_usd", 0)
|
||||
user.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
|
||||
context.add_audit_metadata(
|
||||
action="reset_user_quota",
|
||||
target_user_id=user.id,
|
||||
quota_usd=user.quota_usd,
|
||||
used_usd=user.used_usd,
|
||||
total_usd=user.total_usd,
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "配额已重置",
|
||||
"user_id": user.id,
|
||||
"quota_usd": user.quota_usd,
|
||||
"used_usd": user.used_usd,
|
||||
"total_usd": user.total_usd,
|
||||
}
|
||||
|
||||
|
||||
class AdminGetUserKeysAdapter(AdminApiAdapter):
|
||||
"""获取用户的API Keys"""
|
||||
|
||||
@@ -584,7 +537,6 @@ class AdminCreateUserKeyAdapter(AdminApiAdapter):
|
||||
allowed_models=key_data.allowed_models,
|
||||
rate_limit=key_data.rate_limit, # None = 无限制
|
||||
expire_days=key_data.expire_days,
|
||||
initial_balance_usd=None, # 普通Key不设置余额限制
|
||||
is_standalone=False, # 不是独立Key
|
||||
)
|
||||
|
||||
@@ -644,3 +596,91 @@ class AdminDeleteUserKeyAdapter(AdminApiAdapter):
|
||||
)
|
||||
|
||||
return {"message": "API Key已删除"}
|
||||
|
||||
|
||||
class AdminToggleUserKeyLockAdapter(AdminApiAdapter):
|
||||
"""切换用户普通 API Key 的锁定状态"""
|
||||
|
||||
def __init__(self, user_id: str, key_id: str):
|
||||
self.user_id = user_id
|
||||
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()
|
||||
)
|
||||
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)
|
||||
|
||||
logger.info(
|
||||
f"管理员切换用户API Key锁定状态: 用户ID {self.user_id}, Key ID {self.key_id}, "
|
||||
f"新状态 {'锁定' if api_key.is_locked else '解锁'}"
|
||||
)
|
||||
|
||||
context.add_audit_metadata(
|
||||
action="toggle_user_api_key_lock",
|
||||
target_user_id=self.user_id,
|
||||
key_id=self.key_id,
|
||||
new_lock_status="locked" if api_key.is_locked else "unlocked",
|
||||
)
|
||||
|
||||
return {
|
||||
"id": api_key.id,
|
||||
"is_locked": api_key.is_locked,
|
||||
"message": f"API密钥已{'锁定' if api_key.is_locked else '解锁'}",
|
||||
}
|
||||
|
||||
|
||||
class AdminGetUserKeyFullKeyAdapter(AdminApiAdapter):
|
||||
"""获取用户普通 API Key 的完整密钥"""
|
||||
|
||||
def __init__(self, user_id: str, key_id: str):
|
||||
self.user_id = user_id
|
||||
self.key_id = key_id
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
from src.core.crypto import crypto_service
|
||||
|
||||
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()
|
||||
)
|
||||
if not api_key:
|
||||
raise NotFoundException("API Key不存在或不属于该用户", "api_key")
|
||||
if not api_key.key_encrypted:
|
||||
raise InvalidRequestException("该密钥没有存储完整密钥信息")
|
||||
|
||||
try:
|
||||
full_key = crypto_service.decrypt(api_key.key_encrypted)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
f"解密用户API密钥失败: 用户ID {self.user_id}, Key ID {self.key_id}, 错误: {exc}"
|
||||
)
|
||||
raise HTTPException(status_code=500, detail="解密密钥失败")
|
||||
|
||||
context.add_audit_metadata(
|
||||
action="view_user_api_key_full_key",
|
||||
target_user_id=self.user_id,
|
||||
key_id=self.key_id,
|
||||
)
|
||||
|
||||
return {"key": full_key}
|
||||
|
||||
5
src/api/admin/wallets/__init__.py
Normal file
5
src/api/admin/wallets/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Admin wallet routes."""
|
||||
|
||||
from .routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
546
src/api/admin/wallets/routes.py
Normal file
546
src/api/admin/wallets/routes.py
Normal file
@@ -0,0 +1,546 @@
|
||||
"""管理员钱包与退款处理接口。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from src.api.base.admin_adapter import AdminApiAdapter
|
||||
from src.api.base.context import ApiRequestContext
|
||||
from src.api.base.pipeline import ApiRequestPipeline
|
||||
from src.api.serializers import (
|
||||
serialize_admin_wallet,
|
||||
serialize_admin_wallet_refund,
|
||||
serialize_admin_wallet_transaction,
|
||||
)
|
||||
from src.core.exceptions import InvalidRequestException, NotFoundException, translate_pydantic_error
|
||||
from src.database import get_db
|
||||
from src.models.database import RefundRequest, Wallet, WalletTransaction
|
||||
from src.services.wallet import WalletService
|
||||
|
||||
router = APIRouter(prefix="/api/admin/wallets", tags=["Admin - Wallets"])
|
||||
pipeline = ApiRequestPipeline()
|
||||
|
||||
|
||||
class ManualRechargePayload(BaseModel):
|
||||
amount_usd: float = Field(..., gt=0, allow_inf_nan=False)
|
||||
payment_method: str = Field(default="admin_manual", max_length=30)
|
||||
description: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class WalletAdjustPayload(BaseModel):
|
||||
amount_usd: float = Field(..., allow_inf_nan=False)
|
||||
balance_type: str = Field(default="recharge", pattern="^(recharge|gift)$")
|
||||
description: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class RefundFailPayload(BaseModel):
|
||||
reason: str = Field(..., min_length=1, max_length=500)
|
||||
|
||||
|
||||
class RefundCompletePayload(BaseModel):
|
||||
gateway_refund_id: str | None = Field(default=None, max_length=128)
|
||||
payout_reference: str | None = Field(default=None, max_length=255)
|
||||
payout_proof: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def _get_wallet_or_raise(db: Session, wallet_id: str) -> Wallet:
|
||||
wallet = (
|
||||
db.query(Wallet)
|
||||
.options(joinedload(Wallet.user), joinedload(Wallet.api_key))
|
||||
.filter(Wallet.id == wallet_id)
|
||||
.first()
|
||||
)
|
||||
if wallet is None:
|
||||
raise NotFoundException("Wallet not found")
|
||||
return wallet
|
||||
|
||||
|
||||
def _get_refund_or_raise(db: Session, wallet_id: str, refund_id: str) -> RefundRequest:
|
||||
refund = (
|
||||
db.query(RefundRequest)
|
||||
.filter(RefundRequest.id == refund_id, RefundRequest.wallet_id == wallet_id)
|
||||
.first()
|
||||
)
|
||||
if refund is None:
|
||||
raise NotFoundException("Refund request not found")
|
||||
return refund
|
||||
|
||||
|
||||
def _ensure_user_wallet_for_refund(wallet: Wallet) -> None:
|
||||
if wallet.api_key_id is not None:
|
||||
raise InvalidRequestException("独立密钥钱包不支持退款审批")
|
||||
|
||||
|
||||
def _ensure_api_key_wallet_manual_recharge(wallet: Wallet, payment_method: str) -> None:
|
||||
if wallet.api_key_id is not None:
|
||||
raise InvalidRequestException("独立密钥钱包不支持充值,请使用调账")
|
||||
|
||||
|
||||
def _parse_payload(model_cls: type[BaseModel], payload: dict[str, Any]) -> BaseModel:
|
||||
try:
|
||||
return model_cls.model_validate(payload)
|
||||
except ValidationError as exc:
|
||||
errors = exc.errors()
|
||||
if errors:
|
||||
raise InvalidRequestException(translate_pydantic_error(errors[0]))
|
||||
raise InvalidRequestException("请求数据验证失败")
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_wallets(
|
||||
request: Request,
|
||||
status: str | None = Query(None),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0, le=5000),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
adapter = AdminWalletListAdapter(status=status, limit=limit, offset=offset)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/ledger")
|
||||
async def list_wallet_ledger(
|
||||
request: Request,
|
||||
category: str | None = Query(None),
|
||||
reason_code: str | None = Query(None),
|
||||
owner_type: str | None = Query(None),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0, le=5000),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
adapter = AdminWalletLedgerAdapter(
|
||||
category=category,
|
||||
reason_code=reason_code,
|
||||
owner_type=owner_type,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/refund-requests")
|
||||
async def list_global_refunds(
|
||||
request: Request,
|
||||
status: str | None = Query(None),
|
||||
owner_type: str | None = Query(None),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0, le=5000),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
adapter = AdminWalletGlobalRefundsAdapter(
|
||||
status=status,
|
||||
owner_type=owner_type,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/{wallet_id}")
|
||||
async def get_wallet_detail(wallet_id: str, request: Request, db: Session = Depends(get_db)) -> Any:
|
||||
adapter = AdminWalletDetailAdapter(wallet_id=wallet_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/{wallet_id}/transactions")
|
||||
async def get_wallet_transactions(
|
||||
wallet_id: str,
|
||||
request: Request,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0, le=5000),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
adapter = AdminWalletTransactionsAdapter(wallet_id=wallet_id, limit=limit, offset=offset)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/{wallet_id}/refunds")
|
||||
async def get_wallet_refunds(
|
||||
wallet_id: str,
|
||||
request: Request,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0, le=5000),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
adapter = AdminWalletRefundsAdapter(wallet_id=wallet_id, limit=limit, offset=offset)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("/{wallet_id}/recharge")
|
||||
async def recharge_wallet(wallet_id: str, request: Request, db: Session = Depends(get_db)) -> Any:
|
||||
adapter = AdminWalletRechargeAdapter(wallet_id=wallet_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("/{wallet_id}/adjust")
|
||||
async def adjust_wallet(wallet_id: str, request: Request, db: Session = Depends(get_db)) -> Any:
|
||||
adapter = AdminWalletAdjustAdapter(wallet_id=wallet_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("/{wallet_id}/refunds/{refund_id}/process")
|
||||
async def process_refund(
|
||||
wallet_id: str,
|
||||
refund_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
adapter = AdminWalletRefundProcessAdapter(wallet_id=wallet_id, refund_id=refund_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("/{wallet_id}/refunds/{refund_id}/fail")
|
||||
async def fail_refund(
|
||||
wallet_id: str,
|
||||
refund_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
adapter = AdminWalletRefundFailAdapter(wallet_id=wallet_id, refund_id=refund_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("/{wallet_id}/refunds/{refund_id}/complete")
|
||||
async def complete_refund(
|
||||
wallet_id: str,
|
||||
refund_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
adapter = AdminWalletRefundCompleteAdapter(wallet_id=wallet_id, refund_id=refund_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminWalletListAdapter(AdminApiAdapter):
|
||||
status: str | None
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
|
||||
q = context.db.query(Wallet).options(joinedload(Wallet.user), joinedload(Wallet.api_key))
|
||||
if self.status:
|
||||
q = q.filter(Wallet.status == self.status)
|
||||
total = q.count()
|
||||
items = q.order_by(Wallet.updated_at.desc()).offset(self.offset).limit(self.limit).all()
|
||||
return {
|
||||
"items": [serialize_admin_wallet(item) for item in items],
|
||||
"total": total,
|
||||
"limit": self.limit,
|
||||
"offset": self.offset,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminWalletDetailAdapter(AdminApiAdapter):
|
||||
wallet_id: str
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
|
||||
wallet = _get_wallet_or_raise(context.db, self.wallet_id)
|
||||
pending_refunds = (
|
||||
context.db.query(RefundRequest)
|
||||
.filter(
|
||||
RefundRequest.wallet_id == wallet.id,
|
||||
RefundRequest.status.in_(["pending_approval", "approved", "processing"]),
|
||||
)
|
||||
.count()
|
||||
)
|
||||
return {
|
||||
**serialize_admin_wallet(wallet),
|
||||
"pending_refund_count": pending_refunds,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminWalletLedgerAdapter(AdminApiAdapter):
|
||||
category: str | None
|
||||
reason_code: str | None
|
||||
owner_type: str | None
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
|
||||
q = (
|
||||
context.db.query(WalletTransaction)
|
||||
.join(Wallet, WalletTransaction.wallet_id == Wallet.id)
|
||||
.options(
|
||||
joinedload(WalletTransaction.wallet).joinedload(Wallet.user),
|
||||
joinedload(WalletTransaction.wallet).joinedload(Wallet.api_key),
|
||||
joinedload(WalletTransaction.operator),
|
||||
)
|
||||
)
|
||||
|
||||
if self.category:
|
||||
q = q.filter(WalletTransaction.category == self.category)
|
||||
if self.reason_code:
|
||||
q = q.filter(WalletTransaction.reason_code == self.reason_code)
|
||||
|
||||
if self.owner_type == "user":
|
||||
q = q.filter(Wallet.user_id.isnot(None))
|
||||
elif self.owner_type == "api_key":
|
||||
q = q.filter(Wallet.api_key_id.isnot(None))
|
||||
|
||||
total = q.count()
|
||||
items = (
|
||||
q.order_by(WalletTransaction.created_at.desc())
|
||||
.offset(self.offset)
|
||||
.limit(self.limit)
|
||||
.all()
|
||||
)
|
||||
return {
|
||||
"items": [serialize_admin_wallet_transaction(item) for item in items],
|
||||
"total": total,
|
||||
"limit": self.limit,
|
||||
"offset": self.offset,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminWalletTransactionsAdapter(AdminApiAdapter):
|
||||
wallet_id: str
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
|
||||
wallet = _get_wallet_or_raise(context.db, self.wallet_id)
|
||||
q = (
|
||||
context.db.query(WalletTransaction)
|
||||
.options(joinedload(WalletTransaction.operator))
|
||||
.filter(WalletTransaction.wallet_id == wallet.id)
|
||||
)
|
||||
total = q.count()
|
||||
items = (
|
||||
q.order_by(WalletTransaction.created_at.desc())
|
||||
.offset(self.offset)
|
||||
.limit(self.limit)
|
||||
.all()
|
||||
)
|
||||
return {
|
||||
"wallet": serialize_admin_wallet(wallet),
|
||||
"items": [serialize_admin_wallet_transaction(item) for item in items],
|
||||
"total": total,
|
||||
"limit": self.limit,
|
||||
"offset": self.offset,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminWalletRefundsAdapter(AdminApiAdapter):
|
||||
wallet_id: str
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
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)
|
||||
q = context.db.query(RefundRequest).filter(RefundRequest.wallet_id == wallet.id)
|
||||
total = q.count()
|
||||
items = (
|
||||
q.order_by(RefundRequest.created_at.desc()).offset(self.offset).limit(self.limit).all()
|
||||
)
|
||||
return {
|
||||
"wallet": serialize_admin_wallet(wallet),
|
||||
"items": [serialize_admin_wallet_refund(item) for item in items],
|
||||
"total": total,
|
||||
"limit": self.limit,
|
||||
"offset": self.offset,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminWalletGlobalRefundsAdapter(AdminApiAdapter):
|
||||
status: str | None
|
||||
owner_type: str | None
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
|
||||
if self.owner_type == "api_key":
|
||||
raise InvalidRequestException("独立密钥钱包不支持退款审批")
|
||||
|
||||
q = (
|
||||
context.db.query(RefundRequest)
|
||||
.join(Wallet, RefundRequest.wallet_id == Wallet.id)
|
||||
.options(
|
||||
joinedload(RefundRequest.wallet).joinedload(Wallet.user),
|
||||
joinedload(RefundRequest.wallet).joinedload(Wallet.api_key),
|
||||
)
|
||||
)
|
||||
|
||||
if self.status:
|
||||
q = q.filter(RefundRequest.status == self.status)
|
||||
|
||||
q = q.filter(Wallet.user_id.isnot(None))
|
||||
|
||||
total = q.count()
|
||||
items = (
|
||||
q.order_by(RefundRequest.created_at.desc()).offset(self.offset).limit(self.limit).all()
|
||||
)
|
||||
return {
|
||||
"items": [serialize_admin_wallet_refund(item) for item in items],
|
||||
"total": total,
|
||||
"limit": self.limit,
|
||||
"offset": self.offset,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminWalletRechargeAdapter(AdminApiAdapter):
|
||||
wallet_id: str
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
|
||||
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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminWalletAdjustAdapter(AdminApiAdapter):
|
||||
wallet_id: str
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
|
||||
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),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminWalletRefundProcessAdapter(AdminApiAdapter):
|
||||
wallet_id: str
|
||||
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),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminWalletRefundFailAdapter(AdminApiAdapter):
|
||||
wallet_id: str
|
||||
refund_id: str
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminWalletRefundCompleteAdapter(AdminApiAdapter):
|
||||
wallet_id: str
|
||||
refund_id: str
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
|
||||
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)}
|
||||
Reference in New Issue
Block a user