feat(wallet): 钱包系统替代配额系统,新增支付与退款机制

- 新增钱包余额管理、充值、扣费、退款完整流程
- 新增支付网关抽象层(支持手动/支付宝/微信)
- 用量计费从配额系统迁移到钱包余额扣费
- 新增管理员钱包管理与支付订单管理页面
- 新增用户钱包中心页面
- 移除独立 Key 锁定机制,统一由钱包余额控制
- 新增相关 API 路由、序列化器与数据库迁移
- 新增钱包、支付、退款相关测试
This commit is contained in:
LewisPen
2026-03-08 00:05:48 +08:00
committed by fawney19
parent 9cdcce1b5f
commit 783f654953
108 changed files with 13152 additions and 3372 deletions

View File

@@ -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 时注册

View File

@@ -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_daysnull 或空字符串表示永不过期)
- `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,
}

View File

@@ -0,0 +1,5 @@
"""Admin payment routes."""
from .routes import router
__all__ = ["router"]

View 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,
}

View File

@@ -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,
)

View File

@@ -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}

View File

@@ -0,0 +1,5 @@
"""Admin wallet routes."""
from .routes import router
__all__ = ["router"]

View 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)}