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

View File

@@ -41,6 +41,7 @@ from src.services.rate_limit.ip_limiter import IPRateLimiter
from src.services.system.audit import AuditService
from src.services.system.config import SystemConfigService
from src.services.user.service import UserService
from src.services.wallet import WalletService
from src.utils.request_utils import get_client_ip, get_user_agent
@@ -166,7 +167,7 @@ async def get_current_user_info(request: Request, db: Session = Depends(get_db))
"""
获取当前用户信息
返回当前登录用户的基本信息,包括邮箱、用户名、角色、配额等。
返回当前登录用户的基本信息,包括邮箱、用户名、角色、钱包信息等。
需要 Bearer Token 认证。
"""
adapter = AuthCurrentUserAdapter()
@@ -519,9 +520,9 @@ class AuthRegisterAdapter(AuthPublicAdapter):
)
try:
# 读取系统配置的默认配额
default_quota = SystemConfigService.get_config(
db, "default_user_quota_usd", default=10.0
# 读取系统配置的默认初始赠款
default_initial_gift = SystemConfigService.get_config(
db, "default_user_initial_gift_usd", default=None
)
# email_verified 逻辑:
@@ -534,7 +535,7 @@ class AuthRegisterAdapter(AuthPublicAdapter):
username=register_request.username,
password=register_request.password,
role=UserRole.USER,
quota_usd=default_quota,
initial_gift_usd=default_initial_gift,
email_verified=bool(require_verification and email),
)
AuditService.log_event(
@@ -579,15 +580,14 @@ class AuthRegisterAdapter(AuthPublicAdapter):
class AuthCurrentUserAdapter(AuthenticatedApiAdapter):
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
user = context.user
wallet = WalletService.get_wallet(context.db, user_id=user.id)
return {
"id": user.id,
"email": user.email,
"username": user.username,
"role": user.role.value,
"is_active": user.is_active,
"quota_usd": user.quota_usd,
"used_usd": user.used_usd,
"total_usd": user.total_usd,
"billing": WalletService.serialize_wallet_summary(wallet),
"allowed_providers": user.allowed_providers,
"allowed_api_formats": user.allowed_api_formats,
"allowed_models": user.allowed_models,

View File

@@ -34,7 +34,7 @@ class ApiRequestContext:
query_params: dict[str, str]
raw_body: bytes | None = None
json_body: dict[str, Any] | None = None
quota_remaining: float | None = None
balance_remaining: float | None = None
mode: str = "standard" # standard / proxy
api_format_hint: str | None = None

View File

@@ -10,12 +10,13 @@ from sqlalchemy.orm import Session
from src.config.settings import config
from src.core.enums import UserRole
from src.core.exceptions import QuotaExceededException
from src.core.exceptions import BalanceInsufficientException
from src.core.logger import logger
from src.models.database import ApiKey, AuditEventType, User
from src.services.auth.service import AuthService
from src.services.system.audit import AuditService
from src.services.usage.service import UsageService
from src.services.wallet import WalletService
from src.utils.perf import PerfRecorder
if TYPE_CHECKING:
@@ -35,7 +36,7 @@ QUIET_POLLING_PATHS: set[str] = {
class ApiRequestPipeline:
"""负责统一执行认证、额校验、上下文构建等通用逻辑的管道。"""
"""负责统一执行认证、额校验、上下文构建等通用逻辑的管道。"""
def __init__(
self,
@@ -164,7 +165,8 @@ class ApiRequestPipeline:
# 存储 quiet 标志到 context用于审计日志判断
context.quiet_logging = is_quiet
if mode != ApiMode.ADMIN and user:
context.quota_remaining = self._calculate_quota_remaining(user)
remaining = self._calculate_balance_remaining(db, user, api_key=api_key)
context.balance_remaining = remaining
# authorize 可能是异步的,需要检查并 await
authorize_start = PerfRecorder.start(force=perf_sampled)
try:
@@ -238,25 +240,11 @@ class ApiRequestPipeline:
request.state.user_id = user.id
request.state.api_key_id = api_key.id
# 检查配额或余额支持独立Key
quota_ok, message = self.usage_service.check_user_quota(db, user, api_key=api_key)
if not quota_ok:
# 根据Key类型计算剩余额度
if api_key.is_standalone:
# 独立Key显示剩余余额
remaining = (
None
if api_key.current_balance_usd is None
else float(api_key.current_balance_usd - (api_key.balance_used_usd or 0))
)
else:
# 普通Key显示用户配额剩余
remaining = (
None
if user.quota_usd is None or user.quota_usd < 0
else float(user.quota_usd - user.used_usd)
)
raise QuotaExceededException(quota_type="USD", remaining=remaining)
# 检查余额(支持独立 Key
access_ok, _message = self.usage_service.check_request_balance(db, user, api_key=api_key)
if not access_ok:
remaining = self._calculate_balance_remaining(db, user, api_key=api_key)
raise BalanceInsufficientException(balance_type="USD", remaining=remaining)
return user, api_key
@@ -418,12 +406,15 @@ class ApiRequestPipeline:
detail="无效的 Token 格式,需要 Management Token",
)
def _calculate_quota_remaining(self, user: User | None) -> float | None:
def _calculate_balance_remaining(
self, db: Session, user: User | None, api_key: ApiKey | None = None
) -> float | None:
if not user:
return None
if user.quota_usd is None or user.quota_usd < 0:
balance = WalletService.get_balance_snapshot(db, user=user, api_key=api_key)
if balance is None:
return None
return max(float(user.quota_usd - user.used_usd), 0.0)
return float(balance)
def _record_audit_event(
self,
@@ -513,7 +504,7 @@ class ApiRequestPipeline:
"request_body_bytes": len(context.raw_body or b""),
"has_body": bool(context.raw_body),
"request_content_type": request.headers.get("content-type"),
"quota_remaining": context.quota_remaining,
"balance_remaining": context.balance_remaining,
"success": success,
# 传递 quiet_logging 标志给审计服务,用于抑制高频轮询日志
"quiet_logging": getattr(context, "quiet_logging", False),

View File

@@ -33,6 +33,7 @@ from src.services.system.stats_aggregator import (
query_time_series,
)
from src.services.system.time_range import TimeRangeParams
from src.services.wallet import WalletService
from src.utils.cache_decorator import cache_result
router = APIRouter(prefix="/api/dashboard", tags=["Dashboard"])
@@ -102,7 +103,7 @@ async def get_dashboard_stats(request: Request, db: Session = Depends(get_db)) -
- `users`: 用户统计total, active
**返回字段(普通用户)**:
- `stats`: 统计卡片数组,包含 API 密钥、本月请求、配额使用、总Token 等信息
- `stats`: 统计卡片数组,包含 API 密钥、本月请求、钱包状态、总Token 等信息
- `today`: 今日统计
- `token_breakdown`: Token 详细分类
- `cache_stats`: 缓存统计信息
@@ -770,20 +771,18 @@ class UserDashboardStatsAdapter(DashboardAdapter):
int(usage_stats.today_cache_read_tokens or 0) if usage_stats else 0
)
# 配额状态
if user.quota_usd is None:
quota_value = "无限制"
quota_change = f"已用 ${user.used_usd:.2f}"
quota_high = False
elif user.quota_usd > 0:
percent = min(100, int((user.used_usd / user.quota_usd) * 100))
quota_value = f"${user.quota_usd:.0f}"
quota_change = f"已用 ${user.used_usd:.2f}"
quota_high = percent > 80
wallet = WalletService.get_wallet(db, user_id=user.id)
billing = WalletService.serialize_wallet_summary(wallet)
wallet_balance = float(billing["balance"])
wallet_consumed = float(billing["total_consumed"])
if bool(billing["unlimited"]):
wallet_value = "无限制"
wallet_change = f"累计消费 ${wallet_consumed:.2f}"
wallet_high = False
else:
quota_value = "$0"
quota_change = f"已用 ${user.used_usd:.2f}"
quota_high = True
wallet_value = f"${wallet_balance:.2f}"
wallet_change = f"累计消费 ${wallet_consumed:.2f}"
wallet_high = wallet_balance <= 0
return {
"stats": [
@@ -804,10 +803,10 @@ class UserDashboardStatsAdapter(DashboardAdapter):
"icon": "Activity",
},
{
"name": "配额使用",
"value": quota_value,
"change": quota_change,
"changeType": "increase" if quota_high else "neutral",
"name": "钱包状态",
"value": wallet_value,
"change": wallet_change,
"changeType": "increase" if wallet_high else "neutral",
"icon": "TrendingUp",
},
{

View File

@@ -622,9 +622,9 @@ class BaseMessageHandler:
error: 异常对象
"""
from src.core.exceptions import (
BalanceInsufficientException,
ModelNotSupportedException,
ProviderException,
QuotaExceededException,
RateLimitException,
UpstreamClientException,
)
@@ -633,7 +633,7 @@ class BaseMessageHandler:
error,
(
ProviderException,
QuotaExceededException,
BalanceInsufficientException,
RateLimitException,
ModelNotSupportedException,
UpstreamClientException,

View File

@@ -28,13 +28,13 @@ from src.api.base.context import ApiRequestContext
from src.api.handlers.base.chat_handler_base import ChatHandlerBase
from src.api.handlers.base.handler_adapter_base import HandlerAdapterBase
from src.core.exceptions import (
BalanceInsufficientException,
InvalidRequestException,
ModelNotSupportedException,
ProviderAuthException,
ProviderNotAvailableException,
ProviderRateLimitException,
ProviderTimeoutException,
QuotaExceededException,
UpstreamClientException,
)
from src.core.logger import logger
@@ -63,7 +63,7 @@ class ChatAdapterBase(HandlerAdapterBase):
api_key = context.api_key
db = context.db
request_id = context.request_id
quota_remaining_value = context.quota_remaining
balance_remaining_value = context.balance_remaining
start_time = context.start_time
client_ip = context.client_ip
user_agent = context.user_agent
@@ -91,14 +91,14 @@ class ChatAdapterBase(HandlerAdapterBase):
context.add_audit_metadata(**audit_metadata)
# 格式化额度显示
quota_display = (
"unlimited" if quota_remaining_value is None else f"${quota_remaining_value:.2f}"
balance_display = (
"unlimited" if balance_remaining_value is None else f"${balance_remaining_value:.2f}"
)
# 请求开始日志
logger.info(
f"[REQ] {request_id[:8]} | {self.FORMAT_ID} | {getattr(api_key, 'name', 'unknown')} | "
f"{model} | {'stream' if stream else 'sync'} | quota:{quota_display}"
f"{model} | {'stream' if stream else 'sync'} | balance:{balance_display}"
)
try:
@@ -146,7 +146,7 @@ class ChatAdapterBase(HandlerAdapterBase):
except (
ModelNotSupportedException,
QuotaExceededException,
BalanceInsufficientException,
InvalidRequestException,
) as e:
logger.info(f"客户端请求错误: {e.error_type}")

View File

@@ -28,13 +28,13 @@ from src.api.handlers.base.cli_handler_base import CliMessageHandlerBase
from src.api.handlers.base.handler_adapter_base import HandlerAdapterBase
from src.core.api_format import EndpointKind
from src.core.exceptions import (
BalanceInsufficientException,
InvalidRequestException,
ModelNotSupportedException,
ProviderAuthException,
ProviderNotAvailableException,
ProviderRateLimitException,
ProviderTimeoutException,
QuotaExceededException,
UpstreamClientException,
)
from src.core.logger import logger
@@ -66,7 +66,7 @@ class CliAdapterBase(HandlerAdapterBase):
api_key = context.api_key
db = context.db
request_id = context.request_id
quota_remaining_value = context.quota_remaining
balance_remaining_value = context.balance_remaining
start_time = context.start_time
client_ip = context.client_ip
user_agent = context.user_agent
@@ -107,14 +107,14 @@ class CliAdapterBase(HandlerAdapterBase):
context.add_audit_metadata(**audit_metadata)
# 格式化额度显示
quota_display = (
"unlimited" if quota_remaining_value is None else f"${quota_remaining_value:.2f}"
balance_display = (
"unlimited" if balance_remaining_value is None else f"${balance_remaining_value:.2f}"
)
# 请求开始日志
logger.info(
f"[REQ] {request_id[:8]} | {self.FORMAT_ID} | {getattr(api_key, 'name', 'unknown')} | "
f"{model} | {'stream' if stream else 'sync'} | quota:{quota_display}"
f"{model} | {'stream' if stream else 'sync'} | balance:{balance_display}"
)
try:
@@ -163,7 +163,7 @@ class CliAdapterBase(HandlerAdapterBase):
except (
ModelNotSupportedException,
QuotaExceededException,
BalanceInsufficientException,
InvalidRequestException,
) as e:
logger.debug("客户端请求错误: {}", e.error_type)

View File

@@ -285,7 +285,7 @@ class GeminiVeoHandler(VideoHandlerBase):
base_url = self._get_request_base_url(http_request)
response_body = self._normalizer.video_task_from_internal(internal_task, base_url=base_url)
# 提交成功后立即结算 Usage(费用暂时为 0轮询完成后更新
# 提交成功后补齐 Usage 的 provider 上下文,真正结算留到轮询完成时
response_time_ms = int((time.time() - self.start_time) * 1000)
try:
# 构建发送给上游的请求头(脱敏)

View File

@@ -289,7 +289,7 @@ class OpenAIVideoHandler(VideoHandlerBase):
)
response_body = self._normalizer.video_task_from_internal(internal_task)
# 提交成功后立即结算 Usage(费用暂时为 0轮询完成后更新
# 提交成功后补齐 Usage 的 provider 上下文,真正结算留到轮询完成时
response_time_ms = int((time.time() - self.start_time) * 1000)
try:
# 构建发送给上游的请求头(脱敏)

View File

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

125
src/api/payment/routes.py Normal file
View File

@@ -0,0 +1,125 @@
"""支付回调接口。"""
from __future__ import annotations
import secrets
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from src.config import config
from src.database import get_db
from src.services.payment import PaymentService
router = APIRouter(prefix="/api/payment", tags=["Payment"])
CALLBACK_TOKEN_HEADER = "x-payment-callback-token"
CALLBACK_SIGNATURE_HEADER = "x-payment-callback-signature"
class PaymentCallbackPayload(BaseModel):
callback_key: str = Field(..., min_length=1, max_length=128)
order_no: str | None = Field(default=None, max_length=64)
gateway_order_id: str | None = Field(default=None, max_length=128)
amount_usd: float = Field(..., gt=0, allow_inf_nan=False)
pay_amount: float | None = Field(default=None, gt=0, allow_inf_nan=False)
pay_currency: str | None = Field(default=None, min_length=3, max_length=3)
exchange_rate: float | None = Field(default=None, gt=0, allow_inf_nan=False)
payload: dict[str, Any] | None = None
def _verify_callback_request_auth(request: Request) -> None:
expected_token = config.payment_callback_secret
if not expected_token:
raise HTTPException(status_code=503, detail="payment callback is disabled")
provided_token = (request.headers.get(CALLBACK_TOKEN_HEADER) or "").strip()
if not provided_token or not secrets.compare_digest(provided_token, expected_token):
raise HTTPException(status_code=401, detail="invalid payment callback token")
async def _process_callback(
*,
payment_method: str,
request: Request,
payload: PaymentCallbackPayload,
db: Session = Depends(get_db),
) -> dict[str, Any]:
if not payment_method:
raise HTTPException(status_code=400, detail="payment_method is required")
_verify_callback_request_auth(request)
callback_signature = (request.headers.get(CALLBACK_SIGNATURE_HEADER) or "").strip()
if not callback_signature:
raise HTTPException(status_code=401, detail="missing payment callback signature")
try:
callback_payload = payload.payload if payload.payload is not None else payload.model_dump()
result = PaymentService.handle_callback(
db,
payment_method=payment_method,
callback_key=payload.callback_key,
payload=callback_payload,
callback_signature=callback_signature,
callback_secret=config.payment_callback_secret,
order_no=payload.order_no,
gateway_order_id=payload.gateway_order_id,
amount_usd=payload.amount_usd,
pay_amount=payload.pay_amount,
pay_currency=payload.pay_currency,
exchange_rate=payload.exchange_rate,
)
db.commit()
return {
**result,
"payment_method": payment_method,
"request_path": request.url.path,
}
except Exception as exc:
db.rollback()
if isinstance(exc, HTTPException):
raise
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/callback/alipay")
async def handle_alipay_callback(
request: Request,
payload: PaymentCallbackPayload,
db: Session = Depends(get_db),
) -> dict[str, Any]:
return await _process_callback(
payment_method="alipay",
request=request,
payload=payload,
db=db,
)
@router.post("/callback/wechat")
async def handle_wechat_callback(
request: Request,
payload: PaymentCallbackPayload,
db: Session = Depends(get_db),
) -> dict[str, Any]:
return await _process_callback(
payment_method="wechat",
request=request,
payload=payload,
db=db,
)
@router.post("/callback/{payment_method}")
async def handle_payment_callback(
payment_method: str,
request: Request,
payload: PaymentCallbackPayload,
db: Session = Depends(get_db),
) -> dict[str, Any]:
return await _process_callback(
payment_method=payment_method,
request=request,
payload=payload,
db=db,
)

View File

@@ -39,6 +39,7 @@ from src.services.auth.service import AuthService
from src.services.gemini_files_mapping import delete_file_key_mapping, store_file_key_mapping
from src.services.provider.transport import redact_url_for_log
from src.services.scheduling.aware_scheduler import CacheAwareScheduler, ProviderCandidate
from src.services.usage.service import UsageService
@dataclass
@@ -111,6 +112,22 @@ def _build_upstream_headers(
return headers
def _ensure_balance_access(db: Session, user: User, api_key: ApiKey) -> None:
access_ok, message = UsageService.check_request_balance(db, user, api_key=api_key)
if access_ok:
return
raise HTTPException(
status_code=429,
detail={
"error": {
"code": 429,
"message": message or "Insufficient balance",
"status": "RESOURCE_EXHAUSTED",
}
},
)
def _build_upstream_url(
base_url: str,
path: str,
@@ -276,6 +293,7 @@ async def _resolve_upstream_context(
)
user, user_api_key = auth_result
_ensure_balance_access(db, user, user_api_key)
model_name = _resolve_files_model_name(db, user_api_key, user)
if not model_name:
raise HTTPException(
@@ -728,6 +746,7 @@ async def download_file(
)
user, _user_api_key = auth_result
_ensure_balance_access(db, user, _user_api_key)
# 根据前缀判断处理方式
if file_id.startswith("aev_"):

View File

@@ -0,0 +1,23 @@
from .wallet_payment import (
safe_gateway_response,
serialize_admin_wallet,
serialize_admin_wallet_refund,
serialize_admin_wallet_transaction,
serialize_payment_callback,
serialize_payment_order,
serialize_wallet_payload,
serialize_wallet_refund,
serialize_wallet_transaction,
)
__all__ = [
"safe_gateway_response",
"serialize_admin_wallet",
"serialize_admin_wallet_refund",
"serialize_admin_wallet_transaction",
"serialize_payment_callback",
"serialize_payment_order",
"serialize_wallet_payload",
"serialize_wallet_refund",
"serialize_wallet_transaction",
]

View File

@@ -0,0 +1,250 @@
from __future__ import annotations
from typing import Any
from src.models.database import (
PaymentCallback,
PaymentOrder,
RefundRequest,
Wallet,
WalletTransaction,
)
from src.services.wallet import WalletService
def safe_gateway_response(raw: dict[str, Any] | None) -> dict[str, Any]:
if not isinstance(raw, dict):
return {}
allowed_keys = {
"gateway",
"display_name",
"gateway_order_id",
"payment_url",
"qr_code",
"expires_at",
"manual_credit",
}
return {key: raw[key] for key in allowed_keys if key in raw}
def serialize_payment_order(
order: PaymentOrder,
*,
sanitize_gateway_response: bool = False,
) -> dict[str, Any]:
return {
"id": order.id,
"order_no": order.order_no,
"wallet_id": order.wallet_id,
"user_id": order.user_id,
"amount_usd": float(order.amount_usd or 0),
"pay_amount": float(order.pay_amount or 0) if order.pay_amount is not None else None,
"pay_currency": order.pay_currency,
"exchange_rate": (
float(order.exchange_rate or 0) if order.exchange_rate is not None else None
),
"refunded_amount_usd": float(order.refunded_amount_usd or 0),
"refundable_amount_usd": float(order.refundable_amount_usd or 0),
"payment_method": order.payment_method,
"gateway_order_id": order.gateway_order_id,
"gateway_response": (
safe_gateway_response(order.gateway_response)
if sanitize_gateway_response
else order.gateway_response
),
"status": order.status,
"created_at": order.created_at,
"paid_at": order.paid_at,
"credited_at": order.credited_at,
"expires_at": order.expires_at,
}
def serialize_payment_callback(callback: PaymentCallback) -> dict[str, Any]:
return {
"id": callback.id,
"payment_order_id": callback.payment_order_id,
"payment_method": callback.payment_method,
"callback_key": callback.callback_key,
"order_no": callback.order_no,
"gateway_order_id": callback.gateway_order_id,
"payload_hash": callback.payload_hash,
"signature_valid": callback.signature_valid,
"status": callback.status,
"payload": callback.payload,
"error_message": callback.error_message,
"created_at": callback.created_at,
"processed_at": callback.processed_at,
}
def serialize_wallet_payload(wallet: Wallet | None) -> dict[str, Any]:
if wallet is None:
return {
"wallet": None,
"unlimited": False,
"limit_mode": "finite",
"balance": 0.0,
"recharge_balance": 0.0,
"gift_balance": 0.0,
"refundable_balance": 0.0,
"currency": "USD",
}
summary = WalletService.serialize_wallet_summary(wallet)
return {
"wallet": summary,
"unlimited": bool(summary["unlimited"]),
"limit_mode": summary["limit_mode"],
"balance": summary["balance"],
"recharge_balance": summary["recharge_balance"],
"gift_balance": summary["gift_balance"],
"refundable_balance": summary["refundable_balance"],
"currency": summary["currency"],
}
def serialize_wallet_transaction(tx: WalletTransaction) -> dict[str, Any]:
return {
"id": tx.id,
"category": tx.category,
"reason_code": tx.reason_code,
"amount": float(tx.amount or 0),
"balance_before": float(tx.balance_before or 0),
"balance_after": float(tx.balance_after or 0),
"recharge_balance_before": float(tx.recharge_balance_before),
"recharge_balance_after": float(tx.recharge_balance_after),
"gift_balance_before": float(tx.gift_balance_before),
"gift_balance_after": float(tx.gift_balance_after),
"link_type": tx.link_type,
"link_id": tx.link_id,
"operator_id": tx.operator_id,
"description": tx.description,
"created_at": tx.created_at,
}
def serialize_wallet_refund(refund: RefundRequest) -> dict[str, Any]:
return {
"id": refund.id,
"refund_no": refund.refund_no,
"payment_order_id": refund.payment_order_id,
"source_type": refund.source_type,
"source_id": refund.source_id,
"refund_mode": refund.refund_mode,
"amount_usd": float(refund.amount_usd or 0),
"status": refund.status,
"reason": refund.reason,
"failure_reason": refund.failure_reason,
"gateway_refund_id": refund.gateway_refund_id,
"payout_method": refund.payout_method,
"payout_reference": refund.payout_reference,
"payout_proof": refund.payout_proof,
"created_at": refund.created_at,
"updated_at": refund.updated_at,
"processed_at": refund.processed_at,
"completed_at": refund.completed_at,
}
def _wallet_owner(wallet: Wallet | None) -> tuple[str, str | None]:
if wallet is None:
return "unknown", None
owner_name: str | None = None
if wallet.user_id:
owner_name = wallet.user.username if wallet.user else None
return "user", owner_name
if wallet.api_key_id:
if wallet.api_key:
owner_name = wallet.api_key.name or f"Key-{wallet.api_key.id[:8]}"
else:
owner_name = f"Key-{wallet.api_key_id[:8]}"
return "api_key", owner_name
return "orphaned", None
def serialize_admin_wallet(wallet: Wallet) -> dict[str, Any]:
owner_type, owner_name = _wallet_owner(wallet)
summary = WalletService.serialize_wallet_summary(wallet)
return {
"id": wallet.id,
"user_id": wallet.user_id,
"api_key_id": wallet.api_key_id,
"owner_type": owner_type,
"owner_name": owner_name,
"balance": summary["balance"],
"recharge_balance": summary["recharge_balance"],
"gift_balance": summary["gift_balance"],
"refundable_balance": summary["refundable_balance"],
"currency": summary["currency"],
"status": summary["status"],
"limit_mode": summary["limit_mode"],
"unlimited": summary["unlimited"],
"total_recharged": summary["total_recharged"],
"total_consumed": summary["total_consumed"],
"total_refunded": summary["total_refunded"],
"total_adjusted": summary["total_adjusted"],
"created_at": wallet.created_at,
"updated_at": summary["updated_at"],
}
def serialize_admin_wallet_transaction(tx: WalletTransaction) -> dict[str, Any]:
owner_type, owner_name = _wallet_owner(tx.wallet)
wallet_status = tx.wallet.status if tx.wallet is not None else None
return {
"id": tx.id,
"wallet_id": tx.wallet_id,
"owner_type": owner_type,
"owner_name": owner_name,
"wallet_status": wallet_status,
"category": tx.category,
"reason_code": tx.reason_code,
"amount": float(tx.amount or 0),
"balance_before": float(tx.balance_before or 0),
"balance_after": float(tx.balance_after or 0),
"recharge_balance_before": float(tx.recharge_balance_before),
"recharge_balance_after": float(tx.recharge_balance_after),
"gift_balance_before": float(tx.gift_balance_before),
"gift_balance_after": float(tx.gift_balance_after),
"link_type": tx.link_type,
"link_id": tx.link_id,
"operator_id": tx.operator_id,
"operator_name": tx.operator.username if tx.operator else None,
"operator_email": tx.operator.email if tx.operator else None,
"description": tx.description,
"created_at": tx.created_at,
}
def serialize_admin_wallet_refund(refund: RefundRequest) -> dict[str, Any]:
owner_type, owner_name = _wallet_owner(refund.wallet)
wallet_status = refund.wallet.status if refund.wallet is not None else None
return {
"id": refund.id,
"refund_no": refund.refund_no,
"wallet_id": refund.wallet_id,
"owner_type": owner_type,
"owner_name": owner_name,
"wallet_status": wallet_status,
"user_id": refund.user_id,
"payment_order_id": refund.payment_order_id,
"source_type": refund.source_type,
"source_id": refund.source_id,
"refund_mode": refund.refund_mode,
"amount_usd": float(refund.amount_usd or 0),
"status": refund.status,
"reason": refund.reason,
"failure_reason": refund.failure_reason,
"gateway_refund_id": refund.gateway_refund_id,
"payout_method": refund.payout_method,
"payout_reference": refund.payout_reference,
"payout_proof": refund.payout_proof,
"requested_by": refund.requested_by,
"approved_by": refund.approved_by,
"processed_by": refund.processed_by,
"created_at": refund.created_at,
"updated_at": refund.updated_at,
"processed_at": refund.processed_at,
"completed_at": refund.completed_at,
}

View File

@@ -16,6 +16,7 @@ from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import ApiRequestPipeline
from src.config.constants import CacheTTL
from src.core.crypto import crypto_service
from src.core.enums import UserRole
from src.core.exceptions import (
ForbiddenException,
InvalidRequestException,
@@ -46,6 +47,7 @@ from src.services.system.time_range import TimeRangeParams
from src.services.usage.service import UsageService
from src.services.user.apikey import ApiKeyService
from src.services.user.preference import PreferenceService
from src.services.wallet import WalletService
from src.utils.cache_decorator import cache_result
router = APIRouter(prefix="/api/users/me", tags=["User Profile"])
@@ -80,7 +82,7 @@ async def get_my_profile(request: Request, db: Session = Depends(get_db)) -> Any
返回当前登录用户的完整信息,包括基本信息和偏好设置。
**返回字段**: id, email, username, role, is_active, quota_usd, used_usd, preferences 等
**返回字段**: id, email, username, role, is_active, billing, preferences 等
"""
adapter = MeProfileAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -826,7 +828,7 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
# 管理员可以看到真实成本
total_actual_cost = 0.0
if user.role == "admin":
if user.role == UserRole.ADMIN:
total_actual_cost = (
sum(item.get("actual_total_cost_usd", 0.0) for item in filtered_summary)
if filtered_summary
@@ -845,7 +847,7 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
"total_cost_usd": 0.0,
}
# 管理员可以看到真实成本
if user.role == "admin":
if user.role == UserRole.ADMIN:
base_stats["actual_total_cost_usd"] = 0.0
stats = model_summary.setdefault(model_name, base_stats)
@@ -855,7 +857,7 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
stats["total_tokens"] += item["total_tokens"]
stats["total_cost_usd"] += item["total_cost_usd"]
# 管理员可以看到真实成本
if user.role == "admin":
if user.role == UserRole.ADMIN:
stats["actual_total_cost_usd"] += item.get("actual_total_cost_usd", 0.0)
summary_by_model = sorted(model_summary.values(), key=lambda x: x["requests"], reverse=True)
@@ -983,6 +985,7 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
if total_success_response_count > 0
else 0.0
)
wallet = WalletService.get_wallet(db, user_id=user.id)
# 构建响应数据
response_data = {
@@ -992,8 +995,7 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
"total_tokens": total_tokens,
"total_cost": total_cost,
"avg_response_time": avg_response_time,
"quota_usd": user.quota_usd,
"used_usd": user.used_usd,
"billing": WalletService.serialize_wallet_summary(wallet),
"summary_by_model": summary_by_model,
# 分页信息
"pagination": {
@@ -1002,11 +1004,13 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
"offset": self.offset,
"has_more": self.offset + self.limit < total_records,
},
"records": self._build_usage_records(usage_records, is_admin=(user.role == "admin")),
"records": self._build_usage_records(
usage_records, is_admin=(user.role == UserRole.ADMIN)
),
}
# 管理员可以看到真实成本
if user.role == "admin":
if user.role == UserRole.ADMIN:
response_data["total_actual_cost"] = total_actual_cost
# 为每条记录添加真实成本和倍率信息
for i, (r, _, _) in enumerate(usage_records):
@@ -1146,7 +1150,7 @@ class GetMyActivityHeatmapAdapter(AuthenticatedApiAdapter):
result = await UsageService.get_cached_heatmap(
db=context.db,
user_id=user.id,
include_actual_cost=user.role == "admin",
include_actual_cost=user.role == UserRole.ADMIN,
)
context.add_audit_metadata(action="activity_heatmap")
return result

View File

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

416
src/api/wallet/routes.py Normal file
View File

@@ -0,0 +1,416 @@
"""用户钱包与退款接口。"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
from uuid import uuid4
from fastapi import APIRouter, Depends, Query, Request
from pydantic import BaseModel, Field, ValidationError
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from src.api.base.authenticated_adapter import AuthenticatedApiAdapter
from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import ApiRequestPipeline
from src.api.serializers import (
safe_gateway_response,
serialize_payment_order,
serialize_wallet_payload,
serialize_wallet_refund,
serialize_wallet_transaction,
)
from src.core.exceptions import InvalidRequestException, NotFoundException, translate_pydantic_error
from src.database import get_db
from src.models.database import PaymentOrder, RefundRequest, Wallet, WalletTransaction
from src.services.payment import PaymentService
from src.services.wallet import WalletService
router = APIRouter(prefix="/api/wallet", tags=["Wallet"])
pipeline = ApiRequestPipeline()
class CreateRefundPayload(BaseModel):
amount_usd: float = Field(..., gt=0, allow_inf_nan=False)
payment_order_id: str | None = None
source_type: str | None = Field(default=None, max_length=30)
source_id: str | None = Field(default=None, max_length=100)
refund_mode: str | None = Field(default=None, max_length=30)
reason: str | None = Field(default=None, max_length=500)
idempotency_key: str | None = Field(default=None, max_length=128)
class CreateRechargePayload(BaseModel):
amount_usd: float = Field(..., gt=0, allow_inf_nan=False)
payment_method: str = Field(..., min_length=1, max_length=30)
pay_amount: float | None = Field(default=None, gt=0, allow_inf_nan=False)
pay_currency: str | None = Field(default=None, min_length=3, max_length=3)
exchange_rate: float | None = Field(default=None, gt=0, allow_inf_nan=False)
def _default_refund_mode_for_order(order: PaymentOrder) -> str:
if order.payment_method in {"admin_manual", "card_recharge", "card_code", "gift_code"}:
return "offline_payout"
return "original_channel"
def _build_refund_no() -> str:
ts = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S%f")
return f"rf_{ts}_{uuid4().hex[:8]}"
@router.get("/balance")
async def get_wallet_balance(request: Request, db: Session = Depends(get_db)) -> Any:
adapter = WalletBalanceAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/transactions")
async def list_wallet_transactions(
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 = WalletTransactionsAdapter(limit=limit, offset=offset)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/recharge")
async def create_recharge_order(request: Request, db: Session = Depends(get_db)) -> Any:
adapter = WalletRechargeCreateAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/recharge")
async def list_recharge_orders(
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 = WalletRechargeListAdapter(limit=limit, offset=offset)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/recharge/{order_id}")
async def get_recharge_order(order_id: str, request: Request, db: Session = Depends(get_db)) -> Any:
adapter = WalletRechargeDetailAdapter(order_id=order_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/refunds")
async def list_refunds(
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 = WalletRefundListAdapter(limit=limit, offset=offset)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/refunds/{refund_id}")
async def get_refund_detail(refund_id: str, request: Request, db: Session = Depends(get_db)) -> Any:
adapter = WalletRefundDetailAdapter(refund_id=refund_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/refunds")
async def create_refund(request: Request, db: Session = Depends(get_db)) -> Any:
adapter = WalletRefundCreateAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@dataclass
class WalletTransactionsAdapter(AuthenticatedApiAdapter):
limit: int
offset: int
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
db = context.db
user = context.user
if user is None:
raise InvalidRequestException("未登录")
existing_wallet = WalletService.get_wallet(db, user_id=user.id)
wallet = existing_wallet or WalletService.get_or_create_wallet(db, user=user)
if wallet is None:
return {
"items": [],
"total": 0,
"limit": self.limit,
"offset": self.offset,
**serialize_wallet_payload(None),
}
if existing_wallet is None:
db.commit()
db.refresh(wallet)
base_query = db.query(WalletTransaction).filter(WalletTransaction.wallet_id == wallet.id)
total = base_query.count()
items = (
base_query.order_by(WalletTransaction.created_at.desc())
.offset(self.offset)
.limit(self.limit)
.all()
)
return {
"items": [serialize_wallet_transaction(item) for item in items],
"total": total,
"limit": self.limit,
"offset": self.offset,
**serialize_wallet_payload(wallet),
}
class WalletBalanceAdapter(AuthenticatedApiAdapter):
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
db = context.db
user = context.user
if user is None:
raise InvalidRequestException("未登录")
existing_wallet = WalletService.get_wallet(db, user_id=user.id)
wallet = existing_wallet or WalletService.get_or_create_wallet(db, user=user)
if wallet is None:
return serialize_wallet_payload(None)
if existing_wallet is None:
db.commit()
db.refresh(wallet)
pending_refunds = (
db.query(RefundRequest)
.filter(
RefundRequest.wallet_id == wallet.id,
RefundRequest.status.in_(["pending_approval", "approved", "processing"]),
)
.count()
)
payload = serialize_wallet_payload(wallet)
payload["pending_refund_count"] = pending_refunds
return payload
class WalletRechargeCreateAdapter(AuthenticatedApiAdapter):
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
db = context.db
user = context.user
if user is None:
raise InvalidRequestException("未登录")
payload = context.ensure_json_body()
try:
req = CreateRechargePayload.model_validate(payload)
except ValidationError as exc:
errors = exc.errors()
if errors:
raise InvalidRequestException(translate_pydantic_error(errors[0]))
raise InvalidRequestException("请求数据验证失败")
try:
order = PaymentService.create_recharge_order(
db,
user=user,
amount_usd=req.amount_usd,
payment_method=req.payment_method,
pay_amount=req.pay_amount,
pay_currency=req.pay_currency,
exchange_rate=req.exchange_rate,
)
except ValueError as exc:
raise InvalidRequestException(str(exc))
db.commit()
db.refresh(order)
return {
"order": serialize_payment_order(order, sanitize_gateway_response=True),
"payment_instructions": safe_gateway_response(order.gateway_response),
}
@dataclass
class WalletRechargeListAdapter(AuthenticatedApiAdapter):
limit: int
offset: int
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
user = context.user
if user is None:
raise InvalidRequestException("未登录")
items, total, changed = PaymentService.list_user_orders(
context.db,
user_id=user.id,
limit=self.limit,
offset=self.offset,
)
if changed:
context.db.commit()
return {
"items": [
serialize_payment_order(item, sanitize_gateway_response=True) for item in items
],
"total": total,
"limit": self.limit,
"offset": self.offset,
}
@dataclass
class WalletRechargeDetailAdapter(AuthenticatedApiAdapter):
order_id: str
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
user = context.user
if user is None:
raise InvalidRequestException("未登录")
order = PaymentService.get_user_order(context.db, user_id=user.id, order_id=self.order_id)
if order is None:
raise NotFoundException("Payment order not found")
if PaymentService.refresh_order_status(order):
context.db.commit()
return {"order": serialize_payment_order(order, sanitize_gateway_response=True)}
@dataclass
class WalletRefundListAdapter(AuthenticatedApiAdapter):
limit: int
offset: int
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
db = context.db
user = context.user
if user is None:
raise InvalidRequestException("未登录")
existing_wallet = WalletService.get_wallet(db, user_id=user.id)
wallet = existing_wallet or WalletService.get_or_create_wallet(db, user=user)
if wallet is None:
return {"items": [], "total": 0, "limit": self.limit, "offset": self.offset}
if existing_wallet is None:
db.commit()
db.refresh(wallet)
base_query = db.query(RefundRequest).filter(RefundRequest.wallet_id == wallet.id)
total = base_query.count()
items = (
base_query.order_by(RefundRequest.created_at.desc())
.offset(self.offset)
.limit(self.limit)
.all()
)
return {
"items": [serialize_wallet_refund(item) for item in items],
"total": total,
"limit": self.limit,
"offset": self.offset,
}
@dataclass
class WalletRefundDetailAdapter(AuthenticatedApiAdapter):
refund_id: str
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
db = context.db
user = context.user
if user is None:
raise InvalidRequestException("未登录")
refund = (
db.query(RefundRequest)
.filter(RefundRequest.id == self.refund_id, RefundRequest.user_id == user.id)
.first()
)
if refund is None:
raise NotFoundException("Refund request not found")
return serialize_wallet_refund(refund)
class WalletRefundCreateAdapter(AuthenticatedApiAdapter):
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
db = context.db
user = context.user
if user is None:
raise InvalidRequestException("未登录")
payload = context.ensure_json_body()
try:
req = CreateRefundPayload.model_validate(payload)
except ValidationError as exc:
errors = exc.errors()
if errors:
raise InvalidRequestException(translate_pydantic_error(errors[0]))
raise InvalidRequestException("请求数据验证失败")
wallet = WalletService.get_or_create_wallet(db, user=user)
if wallet is None:
raise InvalidRequestException("当前账户尚未开通钱包,无法申请退款")
payment_order = None
source_type = req.source_type or "wallet_balance"
source_id = req.source_id
refund_mode = req.refund_mode or "offline_payout"
if req.payment_order_id:
payment_order = (
db.query(PaymentOrder)
.filter(
PaymentOrder.id == req.payment_order_id,
PaymentOrder.wallet_id == wallet.id,
)
.first()
)
if payment_order is None:
raise NotFoundException("Payment order not found")
source_type = "payment_order"
source_id = payment_order.id
refund_mode = req.refund_mode or _default_refund_mode_for_order(payment_order)
try:
refund = WalletService.create_refund_request(
db,
wallet=wallet,
user_id=user.id,
amount_usd=req.amount_usd,
refund_no=_build_refund_no(),
source_type=source_type,
source_id=source_id,
refund_mode=refund_mode,
payment_order=payment_order,
reason=req.reason,
requested_by=user.id,
idempotency_key=req.idempotency_key,
)
db.commit()
db.refresh(refund)
return serialize_wallet_refund(refund)
except ValueError as exc:
db.rollback()
raise InvalidRequestException(str(exc)) from exc
except IntegrityError:
db.rollback()
if req.idempotency_key:
existing = (
db.query(RefundRequest)
.filter(
RefundRequest.idempotency_key == req.idempotency_key,
RefundRequest.user_id == user.id,
)
.first()
)
if existing is not None:
return serialize_wallet_refund(existing)
raise InvalidRequestException("退款申请重复,请勿重复提交")
except ValueError as exc:
db.rollback()
raise InvalidRequestException(str(exc))