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

View File

@@ -103,6 +103,9 @@ class Config:
# API Key 配置
self.api_key_prefix = os.getenv("API_KEY_PREFIX", "sk")
# 支付回调安全配置(公开回调入口必须携带该共享密钥)
self.payment_callback_secret = os.getenv("PAYMENT_CALLBACK_SECRET", "").strip()
# LLM API 速率限制配置(每分钟请求数)
self.llm_api_rate_limit = int(os.getenv("LLM_API_RATE_LIMIT", "100"))
self.public_api_rate_limit = int(os.getenv("PUBLIC_API_RATE_LIMIT", "60"))
@@ -458,6 +461,11 @@ class Config:
# CORS 配置警告(生产环境)
if self.environment == "production" and not self.cors_origins:
logger.warning("生产环境 CORS 未配置,前端将无法访问 API。请设置 CORS_ORIGINS。")
if self.environment == "production" and not self.payment_callback_secret:
logger.warning(
"生产环境未设置 PAYMENT_CALLBACK_SECRET支付回调将被拒绝。"
"如需启用支付回调,请配置共享密钥。"
)
def validate_security_config(self) -> list[str]:
"""

View File

@@ -63,7 +63,7 @@ FIELD_NAME_TRANSLATIONS = {
"username": "用户名",
"email": "邮箱",
"role": "角色",
"quota_usd": "配额",
"initial_gift_usd": "初始赠款",
"name": "名称",
"title": "标题",
"content": "内容",
@@ -261,18 +261,25 @@ class ProviderRateLimitException(ProviderException):
)
class QuotaExceededException(ProxyException):
"""配额超限"""
class BalanceInsufficientException(ProxyException):
"""余额或额度不足"""
def __init__(self, quota_type: str = "tokens", remaining: float | None = None):
message = f"{quota_type}配额已用尽"
if remaining is not None:
message += f"(剩余: {remaining}"
def __init__(self, balance_type: str = "tokens", remaining: float | None = None, **kwargs: Any):
# 兼容旧调用方使用 quota_type= 关键字参数
balance_type = kwargs.get("quota_type", balance_type)
if balance_type.upper() == "USD":
message = "余额不足"
if remaining is not None:
message += f"(剩余: ${remaining:.2f}"
else:
message = f"{balance_type}额度已用尽"
if remaining is not None:
message += f"(剩余: {remaining}"
super().__init__(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
error_type="quota_exceeded",
error_type="balance_exceeded",
message=message,
details={"quota_type": quota_type, "remaining": remaining},
details={"balance_type": balance_type, "remaining": remaining},
)

View File

@@ -2,7 +2,7 @@
数据库模块
"""
from ..models.database import ApiKey, Base, Usage, User, UserQuota
from ..models.database import ApiKey, Base, Usage, User
from .database import create_session, get_db, get_db_context, get_db_url, init_db, log_pool_status
__all__ = [
@@ -10,7 +10,6 @@ __all__ = [
"User",
"ApiKey",
"Usage",
"UserQuota",
"get_db",
"get_db_context",
"init_db",

View File

@@ -406,12 +406,21 @@ def init_admin_user(db: Session) -> None:
role=UserRole.ADMIN,
is_active=True,
)
admin.quota_usd = cast(Any, 1000.0)
admin.set_password(config.admin_password)
db.add(admin)
db.flush() # 分配ID但不提交事务由外层 init_db 统一 commit
from src.services.wallet import WalletService
WalletService.initialize_user_wallet(
db,
user=admin,
initial_gift_usd=0,
unlimited=True,
description="系统管理员初始化钱包",
)
logger.info(f"创建管理员账户成功: {admin.email} ({admin.username})")
except Exception as e:
logger.error(f"创建管理员账户失败: {e}")
@@ -429,9 +438,12 @@ def init_default_models(db: Session) -> None:
def init_system_configs(db: Session) -> None:
"""初始化系统配置"""
configs: list[dict[str, Any]] = [
{"key": "default_user_quota_usd", "value": 10.0, "description": "新用户默认美元配额"},
{
"key": "default_user_initial_gift_usd",
"value": 10.0,
"description": "新用户默认初始赠款(美元)",
},
{"key": "rate_limit_per_minute", "value": 60, "description": "每分钟请求限制"},
{"key": "enable_registration", "value": False, "description": "是否开放用户注册"},
{"key": "require_email_verification", "value": False, "description": "是否需要邮箱验证"},

View File

@@ -20,8 +20,10 @@ from src.api.announcements import router as announcement_router
from src.api.auth import router as auth_router
from src.api.dashboard import router as dashboard_router
from src.api.monitoring import router as monitoring_router
from src.api.payment import router as payment_router
from src.api.public import router as public_router
from src.api.user_me import router as me_router
from src.api.wallet import router as wallet_router
from src.clients.http_client import HTTPClientPool, close_http_clients
# 核心模块
@@ -599,6 +601,8 @@ else:
app.include_router(auth_router) # 认证相关
app.include_router(admin_router) # 管理员端点
app.include_router(me_router) # 用户个人端点
app.include_router(wallet_router) # 钱包端点
app.include_router(payment_router) # 支付回调端点
app.include_router(announcement_router) # 公告系统
app.include_router(dashboard_router) # 仪表盘端点
app.include_router(public_router) # 公开API端点用户可查看提供商和模型

View File

@@ -689,7 +689,7 @@ class UpdateUserRequest(BaseModel):
password: str | None = Field(
None, min_length=6, max_length=128, description="新密码(留空保持不变)"
)
quota_usd: float | None = Field(None, ge=0)
unlimited: bool | None = Field(None, description="是否无限制true=无限制false=有限制)")
is_active: bool | None = None
role: str | None = None
allowed_providers: list[str] | None = Field(None, description="允许使用的提供商 ID 列表")

View File

@@ -241,8 +241,10 @@ class CreateUserRequest(BaseModel):
password: str = Field(..., min_length=6, max_length=128, description="密码")
email: str | None = Field(None, max_length=255, description="邮箱地址(可选)")
role: UserRole | None = Field(UserRole.USER, description="用户角色")
quota_usd: float | None = Field(default=None, description="USD配额null表示使用系统默认配额")
unlimited: bool = Field(default=False, description="是否无限配额")
initial_gift_usd: float | None = Field(
default=None, description="初始赠款USDnull 表示使用系统默认初始赠款"
)
unlimited: bool = Field(default=False, description="是否无限制")
# 访问限制字段
allowed_providers: list[str] | None = Field(
default=None, description="允许使用的提供商ID列表null表示无限制"
@@ -254,16 +256,16 @@ class CreateUserRequest(BaseModel):
default=None, description="允许使用的模型名称列表null表示无限制"
)
@field_validator("quota_usd", mode="before")
@field_validator("initial_gift_usd", mode="before")
@classmethod
def validate_quota_usd(cls, v: Any) -> Any:
"""验证配额值null表示使用系统默认配额"""
def validate_initial_gift_usd(cls, v: Any) -> Any:
"""验证初始赠款金额null 表示使用系统默认初始赠款。"""
if v is None:
return None
if isinstance(v, (int, float)) and v >= 0 and v <= 10000:
return float(v)
if isinstance(v, (int, float)):
raise ValueError("配额必须在 0-10000 范围内")
raise ValueError("初始赠款必须在 0-10000 范围内")
return v
@field_validator("email")
@@ -337,10 +339,10 @@ class UpdateUserRequest(BaseModel):
username: str | None = None
password: str | None = None
role: UserRole | None = None
unlimited: bool | None = None
allowed_providers: list[str] | None = None # 允许使用的提供商 ID 列表
allowed_api_formats: list[str] | None = None # 允许使用的 API 格式列表
allowed_models: list[str] | None = None # 允许使用的模型名称列表
quota_usd: float | None = None
is_active: bool | None = None
@field_validator("allowed_api_formats")
@@ -349,18 +351,6 @@ class UpdateUserRequest(BaseModel):
# 与 CreateUserRequest 保持一致
return CreateUserRequest.validate_allowed_api_formats(v)
@field_validator("quota_usd", mode="before")
@classmethod
def validate_quota_usd(cls, v: Any) -> Any:
"""验证配额值允许null表示无限制"""
if v is None:
return None
if isinstance(v, (int, float)) and v >= 0 and v <= 10000:
return float(v)
if isinstance(v, (int, float)):
raise ValueError("配额必须在 0-10000 范围内")
return v
class CreateApiKeyRequest(BaseModel):
"""创建API密钥请求"""
@@ -370,11 +360,14 @@ class CreateApiKeyRequest(BaseModel):
allowed_api_formats: list[str] | None = None # 允许使用的 API 格式列表
allowed_models: list[str] | None = None # 允许使用的模型名称列表
rate_limit: int | None = None # None = 无限制
expire_days: int | None = None # None = 永不过期,数字 = 多少天后过期(兼容旧版)
expire_days: int | None = None # None = 永不过期,数字 = 多少天后过期
expires_at: str | None = None # ISO 日期字符串,如 "2025-12-31",优先于 expire_days
initial_balance_usd: float | None = Field(
None, description="初始余额USD仅用于独立KeyNone = 无限制"
)
unlimited_balance: bool | None = Field(
None, description="是否无限余额编辑独立Key时用于切换额度模式"
)
is_standalone: bool = Field(False, description="是否为独立余额Key给非注册用户使用")
auto_delete_on_expiry: bool = Field(
False, description="过期后是否自动删除True=物理删除False=仅禁用)"
@@ -397,8 +390,7 @@ class UserResponse(BaseModel):
allowed_providers: list[str] | None = None # 允许使用的提供商 ID 列表
allowed_api_formats: list[str] | None = None # 允许使用的 API 格式列表
allowed_models: list[str] | None = None # 允许使用的模型名称列表
quota_usd: float
used_usd: float
unlimited: bool = False
is_active: bool
created_at: datetime
updated_at: datetime
@@ -421,8 +413,6 @@ class ApiKeyResponse(BaseModel):
rate_limit: int
is_active: bool
expires_at: datetime | None = None
balance_used_usd: float = 0.0
current_balance_usd: float | None = None # NULL = 无限制
is_standalone: bool = False
force_capabilities: dict[str, bool] | None = None # 强制开启的能力
created_at: datetime

View File

@@ -25,6 +25,7 @@ from sqlalchemy import (
Index,
Integer,
LargeBinary,
Numeric,
String,
Text,
UniqueConstraint,
@@ -71,13 +72,13 @@ class User(Base):
__tablename__ = "users"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
# OAuth 用户可能没有邮箱Postgres unique 允许多个 NULL
email = Column(String(255), unique=True, index=True, nullable=True)
# 注意:所有创建用户的入口必须显式写入 true/false禁止依赖默认值
email_verified = Column(Boolean, nullable=False)
username = Column(String(100), unique=True, index=True, nullable=False)
# OAuth 用户可能没有本地密码v1 仅做字段兼容)
# OAuth 用户可能没有本地密码
password_hash = Column(String(255), nullable=True)
role = Column(
Enum(
@@ -113,11 +114,6 @@ class User(Base):
model_capability_settings = Column(JSON, nullable=True) # 用户针对特定模型的能力配置
# 示例: {"claude-sonnet-4-20250514": {"cache_1h": true}}
# 配额管理
quota_usd = Column(Float, nullable=True) # 美元配额(NULL 表示无限制)
used_usd = Column(Float, default=0.0) # 当前周期已使用美元
total_usd = Column(Float, default=0.0) # 累积消费总额
# 状态
is_active = Column(Boolean, default=True, nullable=False)
is_deleted = Column(Boolean, default=False, nullable=False)
@@ -142,9 +138,6 @@ class User(Base):
preferences = relationship(
"UserPreference", back_populates="user", cascade="all, delete-orphan", passive_deletes=True
)
quotas = relationship(
"UserQuota", back_populates="user", cascade="all, delete-orphan", passive_deletes=True
)
announcement_reads = relationship(
"AnnouncementRead",
back_populates="user",
@@ -154,6 +147,14 @@ class User(Base):
# 关系 - SET NULL: 保留历史记录,让数据库处理 SET NULL
usage_records = relationship("Usage", back_populates="user", passive_deletes=True)
wallet = relationship("Wallet", back_populates="user", uselist=False, passive_deletes=True)
payment_orders = relationship("PaymentOrder", back_populates="user", passive_deletes=True)
refund_requests = relationship(
"RefundRequest",
back_populates="user",
passive_deletes=True,
foreign_keys="RefundRequest.user_id",
)
authored_announcements = relationship(
"Announcement",
back_populates="author",
@@ -179,8 +180,14 @@ class ApiKey(Base):
"""API密钥模型"""
__tablename__ = "api_keys"
__table_args__ = (
CheckConstraint(
"(NOT is_standalone) OR (NOT is_locked)",
name="ck_api_keys_standalone_not_locked",
),
)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
key_hash = Column(String(64), unique=True, index=True, nullable=False) # API密钥的SHA256哈希
key_encrypted = Column(Text, nullable=True) # 加密后的完整密钥,用于查看
@@ -190,9 +197,7 @@ class ApiKey(Base):
total_requests = Column(Integer, default=0)
total_cost_usd = Column(Float, default=0.0)
# 余额管理(仅用于独立余额 Key
balance_used_usd = Column(Float, default=0.0) # 已使用余额USD用于统计
current_balance_usd = Column(Float, nullable=True) # 当前余额USDNULL 表示无限制
# 钱包体系:余额/额度由 wallets 表统一管理
is_standalone = Column(
Boolean, default=False, nullable=False
) # 是否为独立余额 Key给非注册用户使用
@@ -210,7 +215,7 @@ class ApiKey(Base):
# 状态
is_active = Column(Boolean, default=True, nullable=False)
is_locked = Column(Boolean, default=False, nullable=False) # 管理员锁定,用户无法使用/操作
is_locked = Column(Boolean, default=False, nullable=False) # 仅普通用户Key可锁定
last_used_at = Column(DateTime(timezone=True), nullable=True)
expires_at = Column(DateTime(timezone=True), nullable=True) # 过期时间
auto_delete_on_expiry = Column(Boolean, default=False, nullable=False) # 过期后是否自动删除
@@ -229,6 +234,7 @@ class ApiKey(Base):
# 关系
user = relationship("User", back_populates="api_keys")
usage_records = relationship("Usage", back_populates="api_key")
wallet = relationship("Wallet", back_populates="api_key", uselist=False, passive_deletes=True)
provider_mappings = relationship(
"ApiKeyProviderMapping", back_populates="api_key", cascade="all, delete-orphan"
)
@@ -310,9 +316,10 @@ class Usage(Base):
Index("idx_usage_provider_key", "provider_id", "provider_api_key_id"),
)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = Column(String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
api_key_id = Column(String(36), ForeignKey("api_keys.id", ondelete="SET NULL"), nullable=True)
wallet_id = Column(String(36), ForeignKey("wallets.id", ondelete="SET NULL"), nullable=True)
# 请求信息
request_id = Column(String(100), unique=True, index=True, nullable=False)
@@ -345,7 +352,7 @@ class Usage(Base):
# 成本计算
input_cost_usd = Column(Float, default=0.0)
output_cost_usd = Column(Float, default=0.0)
cache_cost_usd = Column(Float, default=0.0) # 总缓存成本(兼容旧数据)
cache_cost_usd = Column(Float, default=0.0) # 总缓存成本
cache_creation_cost_usd = Column(Float, default=0.0) # 缓存创建成本
cache_read_cost_usd = Column(Float, default=0.0) # 缓存读取成本
request_cost_usd = Column(Float, default=0.0) # 按次计费成本
@@ -397,6 +404,12 @@ class Usage(Base):
# - void: 作废(不收费,如任务未开始就取消)
billing_status = Column(String(20), default="settled", nullable=False, index=True)
finalized_at = Column(DateTime(timezone=True), nullable=True) # 结算完成时间(可选)
wallet_balance_before = Column(Numeric(20, 8), nullable=True) # 结算前可用总余额快照
wallet_balance_after = Column(Numeric(20, 8), nullable=True) # 结算后可用总余额快照
wallet_recharge_balance_before = Column(Numeric(20, 8), nullable=True) # 结算前充值余额
wallet_recharge_balance_after = Column(Numeric(20, 8), nullable=True) # 结算后充值余额
wallet_gift_balance_before = Column(Numeric(20, 8), nullable=True) # 结算前赠款余额
wallet_gift_balance_after = Column(Numeric(20, 8), nullable=True) # 结算后赠款余额
# 完整请求和响应记录
request_headers = Column(JSON, nullable=True) # 客户端请求头
@@ -428,6 +441,7 @@ class Usage(Base):
# 关系
user = relationship("User", back_populates="usage_records")
api_key = relationship("ApiKey", back_populates="usage_records")
wallet = relationship("Wallet", back_populates="usage_records")
provider_obj = relationship("Provider") # 使用 provider_obj 避免与 provider 字段名冲突
provider_endpoint = relationship("ProviderEndpoint")
provider_api_key = relationship("ProviderAPIKey")
@@ -473,31 +487,45 @@ class Usage(Base):
return None
class UserQuota(Base):
"""用户配额历史记录"""
class Wallet(Base):
"""统一钱包模型(用户钱包 / 独立 API Key 钱包)"""
__tablename__ = "user_quotas"
__tablename__ = "wallets"
__table_args__ = (
CheckConstraint(
# 活跃钱包必须归属唯一 ownerowner 被删除后允许双 NULL孤立钱包由清理策略回收
"(user_id IS NOT NULL AND api_key_id IS NULL) "
"OR (user_id IS NULL AND api_key_id IS NOT NULL) "
"OR (user_id IS NULL AND api_key_id IS NULL)",
name="ck_wallet_single_owner",
),
CheckConstraint("gift_balance >= 0", name="ck_wallets_gift_balance_non_negative"),
# user_id/api_key_id 的 unique=True 已隐含唯一索引,无需额外 Index
Index("idx_wallets_status", "status"),
)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = Column(
String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, unique=True
)
api_key_id = Column(
String(36), ForeignKey("api_keys.id", ondelete="SET NULL"), nullable=True, unique=True
)
# 配额类型
quota_type = Column(String(50), nullable=False) # monthly, daily, custom
# balance: 充值余额(可退款余额)
balance = Column(Numeric(20, 8), nullable=False, default=0)
# gift_balance: 赠款余额(不可退款)
gift_balance = Column(Numeric(20, 8), nullable=False, default=0)
# finite: 按余额校验unlimited: 忽略余额放行,但仍统计消费
limit_mode = Column(String(20), nullable=False, default="finite")
currency = Column(String(3), nullable=False, default="USD")
status = Column(String(20), nullable=False, default="active")
# 配额值
quota_usd = Column(Float, nullable=False)
total_recharged = Column(Numeric(20, 8), nullable=False, default=0)
total_consumed = Column(Numeric(20, 8), nullable=False, default=0)
total_refunded = Column(Numeric(20, 8), nullable=False, default=0)
total_adjusted = Column(Numeric(20, 8), nullable=False, default=0)
# 时间范围
period_start = Column(DateTime(timezone=True), nullable=False)
period_end = Column(DateTime(timezone=True), nullable=False)
# 使用情况
used_usd = Column(Float, default=0.0)
# 状态
is_active = Column(Boolean, default=True)
# 时间戳
created_at = Column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
)
@@ -508,8 +536,192 @@ class UserQuota(Base):
nullable=False,
)
# 关系
user = relationship("User", back_populates="quotas")
user = relationship("User", back_populates="wallet")
api_key = relationship("ApiKey", back_populates="wallet")
usage_records = relationship("Usage", back_populates="wallet")
transactions = relationship(
"WalletTransaction", back_populates="wallet", cascade="all, delete-orphan"
)
payment_orders = relationship("PaymentOrder", back_populates="wallet")
refund_requests = relationship("RefundRequest", back_populates="wallet")
class WalletTransaction(Base):
"""钱包资金流水(只记录资金动作,不重复记录每次请求消费)"""
__tablename__ = "wallet_transactions"
__table_args__ = (
CheckConstraint(
"balance_before = recharge_balance_before + gift_balance_before",
name="ck_wallet_tx_balance_before_consistent",
),
CheckConstraint(
"balance_after = recharge_balance_after + gift_balance_after",
name="ck_wallet_tx_balance_after_consistent",
),
Index("idx_wallet_tx_wallet_created", "wallet_id", "created_at"),
Index("idx_wallet_tx_link", "link_type", "link_id"),
Index("idx_wallet_tx_category_created", "category", "created_at"),
Index("idx_wallet_tx_reason_created", "reason_code", "created_at"),
)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
wallet_id = Column(String(36), ForeignKey("wallets.id", ondelete="CASCADE"), nullable=False)
category = Column(String(20), nullable=False)
reason_code = Column(String(40), nullable=False)
amount = Column(Numeric(20, 8), nullable=False)
# 总可用余额(充值+赠款)快照
balance_before = Column(Numeric(20, 8), nullable=False)
balance_after = Column(Numeric(20, 8), nullable=False)
# 分账户快照(审计用)
recharge_balance_before = Column(Numeric(20, 8), nullable=False)
recharge_balance_after = Column(Numeric(20, 8), nullable=False)
gift_balance_before = Column(Numeric(20, 8), nullable=False)
gift_balance_after = Column(Numeric(20, 8), nullable=False)
link_type = Column(String(30), nullable=True)
link_id = Column(String(100), nullable=True)
operator_id = Column(String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
description = Column(Text, nullable=True)
created_at = Column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
)
wallet = relationship("Wallet", back_populates="transactions")
operator = relationship("User")
class PaymentOrder(Base):
"""充值订单"""
__tablename__ = "payment_orders"
__table_args__ = (
UniqueConstraint("order_no", name="uq_payment_orders_order_no"),
Index("idx_payment_orders_wallet_created", "wallet_id", "created_at"),
Index("idx_payment_orders_user_created", "user_id", "created_at"),
Index("idx_payment_orders_status", "status"),
Index("idx_payment_orders_gateway_order_id", "gateway_order_id"),
)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
order_no = Column(String(64), nullable=False)
wallet_id = Column(String(36), ForeignKey("wallets.id", ondelete="RESTRICT"), nullable=False)
user_id = Column(String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
amount_usd = Column(Numeric(20, 8), nullable=False)
pay_amount = Column(Numeric(20, 2), nullable=True)
pay_currency = Column(String(3), nullable=True)
exchange_rate = Column(Numeric(18, 8), nullable=True)
refunded_amount_usd = Column(Numeric(20, 8), nullable=False, default=0)
refundable_amount_usd = Column(Numeric(20, 8), nullable=False, default=0)
payment_method = Column(String(30), nullable=False)
gateway_order_id = Column(String(128), nullable=True)
gateway_response = Column(JSONB, nullable=True)
status = Column(String(20), nullable=False, default="pending")
created_at = Column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
)
paid_at = Column(DateTime(timezone=True), nullable=True)
credited_at = Column(DateTime(timezone=True), nullable=True)
expires_at = Column(DateTime(timezone=True), nullable=True)
wallet = relationship("Wallet", back_populates="payment_orders")
user = relationship("User", back_populates="payment_orders")
callbacks = relationship("PaymentCallback", back_populates="payment_order")
refund_requests = relationship("RefundRequest", back_populates="payment_order")
class PaymentCallback(Base):
"""支付回调日志(幂等与审计)"""
__tablename__ = "payment_callbacks"
__table_args__ = (
UniqueConstraint("callback_key", name="uq_payment_callbacks_callback_key"),
Index("idx_payment_callbacks_order", "order_no"),
Index("idx_payment_callbacks_gateway_order", "gateway_order_id"),
Index("idx_payment_callbacks_created", "created_at"),
)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
payment_order_id = Column(
String(36), ForeignKey("payment_orders.id", ondelete="SET NULL"), nullable=True
)
payment_method = Column(String(30), nullable=False)
callback_key = Column(String(128), nullable=False)
order_no = Column(String(64), nullable=True)
gateway_order_id = Column(String(128), nullable=True)
payload_hash = Column(String(128), nullable=True)
signature_valid = Column(Boolean, nullable=False, default=False)
status = Column(String(20), nullable=False, default="received")
payload = Column(JSONB, nullable=True)
error_message = Column(Text, nullable=True)
created_at = Column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
)
processed_at = Column(DateTime(timezone=True), nullable=True)
payment_order = relationship("PaymentOrder", back_populates="callbacks")
class RefundRequest(Base):
"""退款申请(原路退款 / 非原路人工打款)"""
__tablename__ = "refund_requests"
__table_args__ = (
UniqueConstraint("refund_no", name="uq_refund_requests_refund_no"),
UniqueConstraint("idempotency_key", name="uq_refund_requests_idempotency_key"),
Index("idx_refund_wallet_created", "wallet_id", "created_at"),
Index("idx_refund_user_created", "user_id", "created_at"),
Index("idx_refund_status", "status"),
)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
refund_no = Column(String(64), nullable=False)
wallet_id = Column(String(36), ForeignKey("wallets.id", ondelete="RESTRICT"), nullable=False)
user_id = Column(String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
payment_order_id = Column(
String(36), ForeignKey("payment_orders.id", ondelete="SET NULL"), nullable=True
)
source_type = Column(String(30), nullable=False) # payment_order/manual_recharge/card_recharge
source_id = Column(String(100), nullable=True)
refund_mode = Column(String(30), nullable=False) # original_channel/offline_payout
amount_usd = Column(Numeric(20, 8), nullable=False)
status = Column(String(30), nullable=False, default="pending_approval")
reason = Column(Text, nullable=True)
requested_by = Column(String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
approved_by = Column(String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
processed_by = Column(String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
gateway_refund_id = Column(String(128), nullable=True)
payout_method = Column(String(50), nullable=True)
payout_reference = Column(String(255), nullable=True)
payout_proof = Column(JSONB, nullable=True)
failure_reason = Column(Text, nullable=True)
idempotency_key = Column(String(128), nullable=True)
created_at = Column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
)
updated_at = Column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
nullable=False,
)
processed_at = Column(DateTime(timezone=True), nullable=True)
completed_at = Column(DateTime(timezone=True), nullable=True)
wallet = relationship("Wallet", back_populates="refund_requests")
user = relationship("User", back_populates="refund_requests", foreign_keys=[user_id])
payment_order = relationship("PaymentOrder", back_populates="refund_requests")
class SystemConfig(Base):
@@ -517,7 +729,7 @@ class SystemConfig(Base):
__tablename__ = "system_configs"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
key = Column(String(100), unique=True, nullable=False)
value = Column(JSON, nullable=False)
description = Column(Text, nullable=True)
@@ -703,7 +915,7 @@ class Provider(ExportMixin, Base):
}
)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
name = Column(String(100), unique=True, nullable=False, index=True) # 提供商名称(唯一)
description = Column(Text, nullable=True) # 提供商描述
website = Column(String(500), nullable=True) # 主站网站
@@ -814,7 +1026,7 @@ class ProviderEndpoint(ExportMixin, Base):
}
)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
provider_id = Column(String(36), ForeignKey("providers.id", ondelete="CASCADE"), nullable=False)
# API 格式和配置
@@ -886,7 +1098,7 @@ class ProxyNode(Base):
__tablename__ = "proxy_nodes"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
name = Column(String(100), nullable=False) # 节点名
ip = Column(String(512), nullable=False) # 公网 IP 或手动节点的主机名(含协议前缀)
port = Column(Integer, nullable=False) # 代理端口
@@ -1016,7 +1228,7 @@ class GlobalModel(ExportMixin, Base):
}
)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
name = Column(String(100), unique=True, nullable=False, index=True) # 统一模型名(唯一)
display_name = Column(String(100), nullable=False)
@@ -1116,7 +1328,7 @@ class Model(ExportMixin, Base):
}
)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
provider_id = Column(String(36), ForeignKey("providers.id"), nullable=False)
# 必须关联一个 GlobalModel
global_model_id = Column(String(36), ForeignKey("global_models.id"), nullable=False, index=True)
@@ -1540,7 +1752,7 @@ class ProviderAPIKey(ExportMixin, Base):
}
)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
# 外键关系 - 直接关联 Provider
provider_id = Column(
@@ -1548,7 +1760,7 @@ class ProviderAPIKey(ExportMixin, Base):
)
# API 格式支持列表(核心字段)
# None 表示支持所有格式(兼容历史数据),空列表 [] 表示不支持任何格式
# None 表示支持所有格式,空列表 [] 表示不支持任何格式
api_formats = Column(JSON, nullable=True, default=list) # ["claude:chat", "claude:cli"]
# 认证类型
@@ -1799,7 +2011,7 @@ class UserPreference(Base):
__tablename__ = "user_preferences"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = Column(
String(36), ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False
)
@@ -1840,7 +2052,7 @@ class Announcement(Base):
__tablename__ = "announcements"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
title = Column(String(200), nullable=False)
content = Column(Text, nullable=False) # 支持 Markdown
type = Column(String(20), default="info") # info, warning, maintenance, important
@@ -1881,7 +2093,7 @@ class AnnouncementRead(Base):
__tablename__ = "announcement_reads"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
announcement_id = Column(String(36), ForeignKey("announcements.id"), nullable=False)
read_at = Column(
@@ -1945,7 +2157,7 @@ class ManagementToken(Base):
TOKEN_PREFIX = "ae_"
TOKEN_RANDOM_LENGTH = 40
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
# Token 信息
@@ -2119,7 +2331,7 @@ class AuditLog(Base):
__tablename__ = "audit_logs"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
event_type = Column(String(50), nullable=False, index=True)
user_id = Column(
String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
@@ -2696,7 +2908,7 @@ class GeminiFileMapping(Base):
__tablename__ = "gemini_file_mappings"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
# 文件名(如 files/abc123xyz
file_name = Column(String(255), nullable=False, unique=True, index=True)

View File

@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
from src.core.logger import logger
from src.services.auth.service import AuthService
from src.services.usage.service import UsageService
from src.services.wallet import WalletService
from .base import AuthContext, AuthPlugin
@@ -62,8 +63,13 @@ class ApiKeyAuthPlugin(AuthPlugin):
user, api_key_obj = auth_result
# 检查用户配额或独立Key余额
quota_ok, message = UsageService.check_user_quota(db, user, api_key=api_key_obj)
# 检查用户或独立 Key 的钱包余额可用性
access_ok, message = UsageService.check_request_balance(db, user, api_key=api_key_obj)
billing_wallet = (
WalletService.get_wallet(db, api_key_id=api_key_obj.id)
if api_key_obj.is_standalone
else WalletService.get_wallet(db, user_id=user.id)
)
# 创建认证上下文
auth_context = AuthContext(
@@ -72,15 +78,13 @@ class ApiKeyAuthPlugin(AuthPlugin):
api_key_id=api_key_obj.id,
api_key_name=api_key_obj.name if hasattr(api_key_obj, "name") else None,
permissions={
"can_use_api": quota_ok,
"can_use_api": access_ok,
"is_admin": user.is_admin if hasattr(user, "is_admin") else False,
"is_standalone_key": api_key_obj.is_standalone, # 标记是否为独立余额Key
},
quota_info={
"quota_usd": user.quota_usd,
"used_usd": user.used_usd,
"remaining_usd": None if user.quota_usd is None else user.quota_usd - user.used_usd,
"quota_ok": quota_ok,
billing_info={
"billing": WalletService.serialize_wallet_summary(billing_wallet),
"balance_ok": access_ok,
"message": message,
},
metadata={

View File

@@ -27,7 +27,7 @@ class AuthContext:
api_key_id: int | None = None
api_key_name: str | None = None
permissions: dict[str, bool] = None
quota_info: dict[str, Any] = None
billing_info: dict[str, Any] = None
metadata: dict[str, Any] = None
def __post_init__(self) -> None:

View File

@@ -13,6 +13,7 @@ from sqlalchemy.orm import Session
from src.core.logger import logger
from src.models.database import User
from src.services.auth.service import AuthService
from src.services.wallet import WalletService
from .base import AuthContext, AuthPlugin
@@ -82,18 +83,22 @@ class JwtAuthPlugin(AuthPlugin):
logger.warning("JWT认证失败 - Token身份校验失败")
return None
wallet_access = WalletService.check_request_allowed(db, user=user, api_key=None)
# 创建认证上下文
auth_context = AuthContext(
user_id=user.id,
user_name=user.username,
permissions={"can_use_api": True, "is_admin": user.role.value == "admin"},
quota_info={
"quota_usd": user.quota_usd,
"used_usd": user.used_usd,
"remaining_usd": (
None if user.quota_usd is None else user.quota_usd - user.used_usd
permissions={
"can_use_api": wallet_access.allowed,
"is_admin": user.role.value == "admin",
},
billing_info={
"billing": WalletService.serialize_wallet_summary(
WalletService.get_wallet(db, user_id=user.id)
),
"quota_ok": True, # JWT用户通常已经通过前端验证
"balance_ok": wallet_access.allowed,
"message": wallet_access.message,
},
metadata={
"auth_method": "jwt",

View File

@@ -428,7 +428,9 @@ class OAuthService:
or (email.split("@", 1)[0] if email else None)
or f"user_{uuid.uuid4().hex[:8]}"
)
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
)
# 生成唯一用户名 + 创建用户(简单重试)
user: User | None = None
@@ -445,9 +447,20 @@ class OAuthService:
role=UserRole.USER,
is_active=True,
last_login_at=now,
quota_usd=default_quota,
)
db.add(user)
db.flush()
from src.services.wallet import WalletService
WalletService.initialize_user_wallet(
db,
user=user,
initial_gift_usd=default_initial_gift,
unlimited=False,
description="OAuth 注册初始赠款",
)
db.commit()
db.refresh(user)
last_error = None

View File

@@ -31,7 +31,6 @@ if TYPE_CHECKING:
from src.models.database import ApiKey, User, UserRole
from src.services.auth.jwt_blacklist import JWTBlacklistService
from src.services.cache.user_cache import UserCacheService
from src.services.user.apikey import ApiKeyService
# API Key last_used_at 更新节流配置
# 同一个 API Key 在此时间间隔内只会更新一次 last_used_at
@@ -336,9 +335,9 @@ class AuthService:
username = f"{base_username}_ldap_{int(time.time())}{uuid.uuid4().hex[:4]}"
logger.info(f"LDAP 用户名冲突,使用新用户名: {ldap_user['username']} -> {username}")
# 读取系统配置的默认配额
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
)
# 创建新用户
@@ -353,11 +352,22 @@ class AuthService:
role=UserRole.USER,
is_active=True,
last_login_at=datetime.now(timezone.utc),
quota_usd=default_quota,
)
try:
db.add(user)
db.flush()
from src.services.wallet import WalletService
WalletService.initialize_user_wallet(
db,
user=user,
initial_gift_usd=default_initial_gift,
unlimited=False,
description="LDAP 注册初始赠款",
)
db.commit()
db.refresh(user)
logger.info(f"LDAP 用户创建成功: {ldap_user['email']} (ID: {user.id})")
@@ -408,7 +418,7 @@ class AuthService:
logger.warning("API认证失败 - 密钥已禁用")
return None
if key_record.is_locked:
if key_record.is_locked and not key_record.is_standalone:
logger.warning("API认证失败 - 密钥已被管理员锁定")
raise ForbiddenException("该密钥已被管理员锁定,请联系管理员")
@@ -424,17 +434,6 @@ class AuthService:
logger.warning("API认证失败 - 密钥已过期")
return None
# 检查余额限制仅独立Key
is_balance_ok, remaining = ApiKeyService.check_balance(key_record)
if not is_balance_ok:
# 获取剩余余额用于日志
remaining_balance = ApiKeyService.get_remaining_balance(key_record)
logger.warning(
f"API认证失败 - 余额不足 "
f"(已用: ${key_record.balance_used_usd:.4f}, 剩余: ${remaining_balance:.4f})"
)
return None
# 获取用户
user = key_record.user
if not user.is_active:
@@ -467,23 +466,22 @@ class AuthService:
return user, key_record
@staticmethod
def check_user_quota(user: User, estimated_cost: float = 0) -> bool:
"""检查用户配额"""
if user.role == UserRole.ADMIN:
return True # 管理员无限制
def check_user_balance_access(user: User, estimated_cost: float = 0) -> bool:
"""按钱包余额/额度模式校验请求可用性。"""
from src.services.wallet import WalletService
# NULL 表示无限制
if user.quota_usd is None:
_ = estimated_cost
if user.role == UserRole.ADMIN:
return True
# 检查美元配额
if user.used_usd + estimated_cost > user.quota_usd:
logger.warning(
f"用户配额不足: {user.email} (已用: ${user.used_usd:.2f}, 配额: ${user.quota_usd:.2f})"
)
wallet = getattr(user, "wallet", None)
if wallet is None:
return False
return True
if wallet.status != "active":
return False
if WalletService.is_unlimited_wallet(wallet):
return True
return WalletService.get_spendable_balance_value(wallet) > 0
@staticmethod
def check_permission(user: User, required_role: UserRole = UserRole.USER) -> bool:

View File

@@ -31,6 +31,11 @@ def to_decimal(value: float | int | str | Decimal | None) -> Decimal:
return Decimal(str(value))
def to_money_decimal(value: float | int | str | Decimal | None) -> Decimal:
"""Convert values to Decimal and quantize to billing storage precision."""
return quantize_cost(to_decimal(value))
def quantize_decimal(value: Decimal, *, precision: int) -> Decimal:
"""Quantize a Decimal to the given number of decimal places (ROUND_HALF_UP)."""
quantizer = Decimal(10) ** -precision

View File

@@ -133,8 +133,6 @@ class UserCacheService:
"role": user.role.value if user.role else None,
"is_active": user.is_active,
"auth_source": user.auth_source.value if user.auth_source else None,
"quota_usd": float(user.quota_usd) if user.quota_usd is not None else None,
"used_usd": float(user.used_usd),
"created_at": user.created_at.isoformat() if user.created_at else None,
"last_login_at": user.last_login_at.isoformat() if user.last_login_at else None,
"model_capability_settings": user.model_capability_settings,
@@ -159,7 +157,6 @@ class UserCacheService:
email_verified=user_dict.get("email_verified", False),
username=user_dict["username"],
is_active=user_dict["is_active"],
used_usd=user_dict["used_usd"],
)
# 设置可选字段
@@ -169,9 +166,6 @@ class UserCacheService:
if user_dict.get("auth_source"):
user.auth_source = AuthSource(user_dict["auth_source"])
if user_dict.get("quota_usd") is not None:
user.quota_usd = user_dict["quota_usd"]
if user_dict.get("created_at"):
user.created_at = datetime.fromisoformat(user_dict["created_at"])

View File

@@ -0,0 +1,3 @@
from src.services.payment.service import PaymentService
__all__ = ["PaymentService"]

View File

@@ -0,0 +1,23 @@
from __future__ import annotations
from src.services.payment.gateway.alipay import AlipayGateway
from src.services.payment.gateway.base import PaymentGateway
from src.services.payment.gateway.manual import ManualGateway
from src.services.payment.gateway.wechat import WeChatGateway
_GATEWAYS: dict[str, PaymentGateway] = {
"alipay": AlipayGateway(),
"wechat": WeChatGateway(),
"manual": ManualGateway(),
}
def get_payment_gateway(payment_method: str) -> PaymentGateway:
key = (payment_method or "").strip().lower()
gateway = _GATEWAYS.get(key)
if gateway is None:
raise ValueError(f"unsupported payment_method: {payment_method}")
return gateway
__all__ = ["PaymentGateway", "get_payment_gateway"]

View File

@@ -0,0 +1,21 @@
from __future__ import annotations
from typing import Any
from src.services.payment.gateway.base import PaymentGateway
class AlipayGateway(PaymentGateway):
payment_method = "alipay"
display_name = "支付宝"
def create_checkout_payload(self, *, order: Any) -> dict[str, Any]:
gateway_order_id = getattr(order, "gateway_order_id", None) or f"ali_{order.order_no}"
return {
"gateway": self.payment_method,
"display_name": self.display_name,
"gateway_order_id": gateway_order_id,
"payment_url": f"/pay/mock/alipay/{order.order_no}",
"qr_code": f"mock://alipay/{order.order_no}",
"expires_at": getattr(order, "expires_at", None),
}

View File

@@ -0,0 +1,69 @@
from __future__ import annotations
import hashlib
import hmac
import json
from abc import ABC, abstractmethod
from typing import Any
class PaymentGateway(ABC):
"""支付网关抽象。
当前阶段只提供统一结构和占位返回,便于后续接入真实 SDK。
"""
payment_method: str
display_name: str
@abstractmethod
def create_checkout_payload(self, *, order: Any) -> dict[str, Any]:
"""为前端返回统一的支付指引结构。"""
@staticmethod
def build_callback_signature(
*,
payload: dict[str, Any] | None,
callback_secret: str | None,
) -> str | None:
if payload is None:
return None
if not callback_secret:
return None
canonical = json.dumps(
payload,
sort_keys=True,
ensure_ascii=False,
separators=(",", ":"),
default=str,
)
return hmac.new(
callback_secret.encode("utf-8"),
canonical.encode("utf-8"),
hashlib.sha256,
).hexdigest()
def verify_callback_payload(
self,
*,
payload: dict[str, Any] | None,
callback_signature: str | None = None,
callback_secret: str | None = None,
) -> bool:
"""校验回调。
默认使用 HMAC-SHA256 对 payload 进行签名校验。
真实接入时可由各支付渠道覆写该方法使用官方 SDK 验签。
"""
expected_signature = self.build_callback_signature(
payload=payload,
callback_secret=callback_secret,
)
if expected_signature is None:
return False
provided = (callback_signature or "").strip()
if not provided:
return False
if provided.lower().startswith("sha256="):
provided = provided.split("=", 1)[1]
return hmac.compare_digest(provided.lower(), expected_signature.lower())

View File

@@ -0,0 +1,22 @@
from __future__ import annotations
from typing import Any
from src.services.payment.gateway.base import PaymentGateway
class ManualGateway(PaymentGateway):
payment_method = "manual"
display_name = "人工打款"
def create_checkout_payload(self, *, order: Any) -> dict[str, Any]:
gateway_order_id = getattr(order, "gateway_order_id", None) or f"manual_{order.order_no}"
return {
"gateway": self.payment_method,
"display_name": self.display_name,
"gateway_order_id": gateway_order_id,
"payment_url": None,
"qr_code": None,
"instructions": "请线下确认到账后由管理员处理",
"expires_at": getattr(order, "expires_at", None),
}

View File

@@ -0,0 +1,21 @@
from __future__ import annotations
from typing import Any
from src.services.payment.gateway.base import PaymentGateway
class WeChatGateway(PaymentGateway):
payment_method = "wechat"
display_name = "微信支付"
def create_checkout_payload(self, *, order: Any) -> dict[str, Any]:
gateway_order_id = getattr(order, "gateway_order_id", None) or f"wx_{order.order_no}"
return {
"gateway": self.payment_method,
"display_name": self.display_name,
"gateway_order_id": gateway_order_id,
"payment_url": f"/pay/mock/wechat/{order.order_no}",
"qr_code": f"mock://wechat/{order.order_no}",
"expires_at": getattr(order, "expires_at", None),
}

View File

@@ -0,0 +1,478 @@
from __future__ import annotations
import hashlib
import json
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from typing import Any
from uuid import uuid4
from sqlalchemy.orm import Session
from src.models.database import PaymentCallback, PaymentOrder, User, Wallet
from src.services.billing.precision import to_money_decimal
from src.services.payment.gateway import get_payment_gateway
from src.services.wallet import WalletService
class PaymentService:
"""支付订单与回调处理服务。
当前实现目标:
- 打通充值订单创建
- 打通支付回调幂等到账
- 真实网关签名/SDK 留给后续渠道适配层
"""
@staticmethod
def _build_order_no() -> str:
ts = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S%f")
return f"po_{ts}_{uuid4().hex[:12]}"
@staticmethod
def _build_payload_hash(payload: dict[str, Any] | None) -> str | None:
if payload is None:
return None
encoded = json.dumps(payload, sort_keys=True, ensure_ascii=False, default=str).encode(
"utf-8"
)
return hashlib.sha256(encoded).hexdigest()
@classmethod
def create_recharge_order(
cls,
db: Session,
*,
user: User,
amount_usd: Decimal | float | int | str,
payment_method: str,
pay_amount: Decimal | float | int | str | None = None,
pay_currency: str | None = None,
exchange_rate: Decimal | float | int | str | None = None,
expires_in_minutes: int = 30,
gateway_order_id: str | None = None,
gateway_response: dict[str, Any] | None = None,
) -> PaymentOrder:
amount = to_money_decimal(amount_usd)
if amount <= Decimal("0"):
raise ValueError("recharge amount must be positive")
if not payment_method:
raise ValueError("payment_method is required")
if payment_method == "admin_manual":
raise ValueError("admin_manual is reserved for admin recharge")
gateway = get_payment_gateway(payment_method)
wallet = WalletService.get_or_create_wallet(db, user=user)
if wallet is None:
raise ValueError("wallet not available")
if wallet.status != "active":
raise ValueError("wallet is not active")
now = datetime.now(timezone.utc)
order = PaymentOrder(
order_no=cls._build_order_no(),
wallet_id=wallet.id,
user_id=user.id,
amount_usd=amount,
pay_amount=to_money_decimal(pay_amount) if pay_amount is not None else None,
pay_currency=pay_currency,
exchange_rate=to_money_decimal(exchange_rate) if exchange_rate is not None else None,
refunded_amount_usd=Decimal("0"),
refundable_amount_usd=Decimal("0"),
payment_method=payment_method,
gateway_order_id=gateway_order_id,
gateway_response=gateway_response,
status="pending",
expires_at=now + timedelta(minutes=max(expires_in_minutes, 1)),
)
db.add(order)
db.flush()
checkout = gateway.create_checkout_payload(order=order)
order.gateway_order_id = order.gateway_order_id or checkout.get("gateway_order_id")
order.gateway_response = gateway_response if gateway_response is not None else checkout
return order
@classmethod
def refresh_order_status(cls, order: PaymentOrder | None) -> bool:
if order is None:
return False
if order.status != "pending":
return False
now = datetime.now(timezone.utc)
if order.expires_at is not None and order.expires_at < now:
order.status = "expired"
return True
return False
@staticmethod
def get_order(
db: Session,
*,
order_id: str | None = None,
order_no: str | None = None,
gateway_order_id: str | None = None,
) -> PaymentOrder | None:
if order_id:
return db.query(PaymentOrder).filter(PaymentOrder.id == order_id).first()
if order_no:
return db.query(PaymentOrder).filter(PaymentOrder.order_no == order_no).first()
if gateway_order_id:
return (
db.query(PaymentOrder)
.filter(PaymentOrder.gateway_order_id == gateway_order_id)
.first()
)
return None
@classmethod
def list_user_orders(
cls,
db: Session,
*,
user_id: str,
limit: int,
offset: int,
) -> tuple[list[PaymentOrder], int, bool]:
expired_count = cls.expire_overdue_pending_orders(db, user_id=user_id)
q = db.query(PaymentOrder).filter(PaymentOrder.user_id == user_id)
total = q.count()
items = q.order_by(PaymentOrder.created_at.desc()).offset(offset).limit(limit).all()
return items, total, expired_count > 0
@classmethod
def list_orders(
cls,
db: Session,
*,
status: str | None = None,
payment_method: str | None = None,
limit: int = 50,
offset: int = 0,
) -> tuple[list[PaymentOrder], int, bool]:
expired_count = 0
if status in {None, "pending", "expired"}:
expired_count = cls.expire_overdue_pending_orders(
db,
payment_method=payment_method,
)
q = db.query(PaymentOrder)
if status:
q = q.filter(PaymentOrder.status == status)
if payment_method:
q = q.filter(PaymentOrder.payment_method == payment_method)
total = q.count()
items = q.order_by(PaymentOrder.created_at.desc()).offset(offset).limit(limit).all()
return items, total, expired_count > 0
@staticmethod
def expire_overdue_pending_orders(
db: Session,
*,
user_id: str | None = None,
payment_method: str | None = None,
) -> int:
now = datetime.now(timezone.utc)
q = db.query(PaymentOrder).filter(
PaymentOrder.status == "pending",
PaymentOrder.expires_at.isnot(None),
PaymentOrder.expires_at < now,
)
if user_id:
q = q.filter(PaymentOrder.user_id == user_id)
if payment_method:
q = q.filter(PaymentOrder.payment_method == payment_method)
return int(q.update({PaymentOrder.status: "expired"}, synchronize_session=False) or 0)
@staticmethod
def list_callbacks(
db: Session,
*,
payment_method: str | None = None,
limit: int = 50,
offset: int = 0,
) -> tuple[list[PaymentCallback], int]:
q = db.query(PaymentCallback)
if payment_method:
q = q.filter(PaymentCallback.payment_method == payment_method)
total = q.count()
items = q.order_by(PaymentCallback.created_at.desc()).offset(offset).limit(limit).all()
return items, total
@staticmethod
def get_user_order(
db: Session,
*,
user_id: str,
order_id: str,
) -> PaymentOrder | None:
return (
db.query(PaymentOrder)
.filter(PaymentOrder.id == order_id, PaymentOrder.user_id == user_id)
.first()
)
@classmethod
def fail_order(
cls,
db: Session,
*,
order: PaymentOrder,
reason: str | None = None,
) -> PaymentOrder:
locked_order = (
db.query(PaymentOrder)
.filter(PaymentOrder.id == order.id)
.with_for_update()
.one_or_none()
)
if locked_order is None:
raise ValueError("payment order not found")
if locked_order.status == "credited":
raise ValueError("credited order cannot be failed")
locked_order.status = "failed"
payload = dict(locked_order.gateway_response or {})
if reason:
payload["failure_reason"] = reason
payload["failed_at"] = datetime.now(timezone.utc).isoformat()
locked_order.gateway_response = payload
return locked_order
@classmethod
def expire_order(
cls,
db: Session,
*,
order: PaymentOrder,
reason: str | None = None,
) -> tuple[PaymentOrder, bool]:
locked_order = (
db.query(PaymentOrder)
.filter(PaymentOrder.id == order.id)
.with_for_update()
.one_or_none()
)
if locked_order is None:
raise ValueError("payment order not found")
if locked_order.status == "credited":
raise ValueError("credited order cannot be expired")
if locked_order.status == "expired":
return locked_order, False
if locked_order.status != "pending":
raise ValueError(f"only pending order can be expired: {locked_order.status}")
locked_order.status = "expired"
payload = dict(locked_order.gateway_response or {})
if reason:
payload["expire_reason"] = reason
payload["expired_at"] = datetime.now(timezone.utc).isoformat()
locked_order.gateway_response = payload
return locked_order, True
@classmethod
def log_callback(
cls,
db: Session,
*,
payment_method: str,
callback_key: str,
order_no: str | None = None,
gateway_order_id: str | None = None,
payload: dict[str, Any] | None = None,
signature_valid: bool = False,
status: str = "received",
payment_order: PaymentOrder | None = None,
error_message: str | None = None,
) -> tuple[PaymentCallback, bool]:
existing = (
db.query(PaymentCallback).filter(PaymentCallback.callback_key == callback_key).first()
)
if existing is not None:
return existing, False
callback = PaymentCallback(
payment_order_id=payment_order.id if payment_order else None,
payment_method=payment_method,
callback_key=callback_key,
order_no=order_no,
gateway_order_id=gateway_order_id,
payload_hash=cls._build_payload_hash(payload),
signature_valid=signature_valid,
status=status,
payload=payload,
error_message=error_message,
)
db.add(callback)
db.flush()
return callback, True
@classmethod
def credit_order(
cls,
db: Session,
*,
order: PaymentOrder,
gateway_order_id: str | None = None,
gateway_response: dict[str, Any] | None = None,
pay_amount: Decimal | float | int | str | None = None,
pay_currency: str | None = None,
exchange_rate: Decimal | float | int | str | None = None,
) -> tuple[PaymentOrder, bool]:
locked_order = (
db.query(PaymentOrder)
.filter(PaymentOrder.id == order.id)
.with_for_update()
.one_or_none()
)
if locked_order is None:
raise ValueError("payment order not found")
if locked_order.status == "credited":
return locked_order, False
if locked_order.status in {"failed", "expired", "refunded"}:
raise ValueError(f"payment order is not creditable: {locked_order.status}")
now = datetime.now(timezone.utc)
if locked_order.expires_at is not None and locked_order.expires_at < now:
locked_order.status = "expired"
raise ValueError("payment order expired")
wallet = db.query(Wallet).filter(Wallet.id == locked_order.wallet_id).first()
if wallet is None:
raise ValueError("wallet not found")
if wallet.status != "active":
raise ValueError("wallet is not active")
if gateway_order_id:
locked_order.gateway_order_id = gateway_order_id
if gateway_response is not None:
locked_order.gateway_response = gateway_response
if pay_amount is not None:
locked_order.pay_amount = to_money_decimal(pay_amount)
if pay_currency is not None:
locked_order.pay_currency = pay_currency
if exchange_rate is not None:
locked_order.exchange_rate = to_money_decimal(exchange_rate)
locked_order.status = "paid"
locked_order.paid_at = locked_order.paid_at or now
locked_order.refundable_amount_usd = to_money_decimal(locked_order.amount_usd)
WalletService.create_wallet_transaction(
db,
wallet=wallet,
category="recharge",
reason_code="topup_gateway",
amount=locked_order.amount_usd,
balance_type="recharge",
link_type="payment_order",
link_id=locked_order.id,
description=f"充值到账({locked_order.payment_method})",
)
locked_order.status = "credited"
locked_order.credited_at = now
return locked_order, True
@classmethod
def handle_callback(
cls,
db: Session,
*,
payment_method: str,
callback_key: str,
payload: dict[str, Any] | None,
callback_signature: str | None,
callback_secret: str | None,
order_no: str | None = None,
gateway_order_id: str | None = None,
amount_usd: Decimal | float | int | str | None = None,
pay_amount: Decimal | float | int | str | None = None,
pay_currency: str | None = None,
exchange_rate: Decimal | float | int | str | None = None,
) -> dict[str, Any]:
gateway = get_payment_gateway(payment_method)
verified = gateway.verify_callback_payload(
payload=payload,
callback_signature=callback_signature,
callback_secret=callback_secret,
)
callback, created = cls.log_callback(
db,
payment_method=payment_method,
callback_key=callback_key,
order_no=order_no,
gateway_order_id=gateway_order_id,
payload=payload,
signature_valid=verified,
)
if not created and callback.status == "processed":
return {
"ok": True,
"duplicate": True,
"credited": False,
"order_id": callback.payment_order_id,
}
if not verified:
callback.status = "failed"
callback.error_message = "invalid callback signature"
callback.processed_at = datetime.now(timezone.utc)
return {"ok": False, "duplicate": not created, "error": callback.error_message}
order = cls.get_order(
db,
order_no=order_no or callback.order_no,
gateway_order_id=gateway_order_id or callback.gateway_order_id,
)
if order is None:
callback.status = "failed"
callback.error_message = "payment order not found"
callback.processed_at = datetime.now(timezone.utc)
return {"ok": False, "duplicate": not created, "error": callback.error_message}
callback.payment_order_id = order.id
callback.order_no = order.order_no
callback.gateway_order_id = gateway_order_id or order.gateway_order_id
if amount_usd is None:
callback.status = "failed"
callback.error_message = "callback amount is required"
callback.processed_at = datetime.now(timezone.utc)
return {"ok": False, "duplicate": not created, "error": callback.error_message}
expected = to_money_decimal(order.amount_usd)
actual = to_money_decimal(amount_usd)
if actual != expected:
callback.status = "failed"
callback.error_message = "callback amount mismatch"
callback.processed_at = datetime.now(timezone.utc)
return {"ok": False, "duplicate": not created, "error": callback.error_message}
try:
updated_order, credited = cls.credit_order(
db,
order=order,
gateway_order_id=gateway_order_id,
gateway_response=payload,
pay_amount=pay_amount,
pay_currency=pay_currency,
exchange_rate=exchange_rate,
)
except ValueError as exc:
callback.status = "failed"
callback.error_message = str(exc)
callback.processed_at = datetime.now(timezone.utc)
return {"ok": False, "duplicate": not created, "error": callback.error_message}
callback.status = "processed"
callback.error_message = None
callback.processed_at = datetime.now(timezone.utc)
return {
"ok": True,
"duplicate": not created,
"credited": credited,
"order_id": updated_order.id,
"order_no": updated_order.order_no,
"status": updated_order.status,
"wallet_id": updated_order.wallet_id,
}

View File

@@ -16,6 +16,7 @@ from typing import Any
from sqlalchemy.orm import Session
from src.core.enums import UserRole
from src.core.logger import logger
from src.database import create_session
@@ -80,7 +81,7 @@ class CacheWarmupService:
db = create_session()
# 获取一个管理员用户用于构造 context
admin_user = db.query(DBUser).filter(DBUser.role == "admin").first()
admin_user = db.query(DBUser).filter(DBUser.role == UserRole.ADMIN).first()
if not admin_user:
logger.info("缓存预热: 无管理员用户,跳过仪表盘统计预热")
return True
@@ -138,7 +139,7 @@ class CacheWarmupService:
db = create_session()
# 获取一个管理员用户
admin_user = db.query(DBUser).filter(DBUser.role == "admin").first()
admin_user = db.query(DBUser).filter(DBUser.role == UserRole.ADMIN).first()
if not admin_user:
logger.info("缓存预热: 无管理员用户,跳过每日统计预热")
return True

View File

@@ -94,6 +94,10 @@ class SystemConfigService:
"value": "AI Gateway",
"description": "站点副标题,显示在导航栏品牌名称下方",
},
"default_user_initial_gift_usd": {
"value": 10.0,
"description": "新用户默认初始赠款(美元)",
},
REQUEST_RECORD_LEVEL_KEY: {
"value": RequestRecordLevel.BASIC.value,
"description": "请求记录级别basic(基本信息), headers(含请求/响应头), full(完整请求/响应)",
@@ -143,38 +147,6 @@ class SystemConfigService:
"value": "01:05",
"description": "Provider 自动签到执行时间HH:MM 格式24小时制",
},
"enable_user_quota_reset": {
"value": False,
"description": "是否启用用户配额自动重置任务(按配置时间触发,按周期执行)",
},
"user_quota_reset_time": {
"value": "05:00",
"description": "用户配额自动重置执行时间HH:MM 格式24小时制",
},
"user_quota_reset_interval_days": {
"value": 1,
"description": "用户配额重置周期(天数)",
},
"enable_standalone_key_quota_reset": {
"value": False,
"description": "是否启用独立密钥额度自动重置任务(按配置时间触发,按周期执行)",
},
"standalone_key_quota_reset_time": {
"value": "05:00",
"description": "独立密钥额度自动重置执行时间HH:MM 格式24小时制",
},
"standalone_key_quota_reset_interval_days": {
"value": 1,
"description": "独立密钥额度重置周期(天数)",
},
"standalone_key_quota_reset_mode": {
"value": "all",
"description": "独立密钥额度重置模式all(全部独立密钥) 或 selected(指定密钥)",
},
"standalone_key_quota_reset_key_ids": {
"value": [],
"description": "独立密钥额度重置指定的密钥 ID 列表(仅 mode=selected 时生效)",
},
"provider_priority_mode": {
"value": "provider",
"description": "优先级策略provider(提供商优先模式) 或 global_key(全局Key优先模式)",

View File

@@ -25,7 +25,7 @@ from sqlalchemy import delete, text
from src.core.logger import logger
from src.database import create_session
from src.models.database import ApiKey, AuditLog, Provider, RequestCandidate, Usage
from src.models.database import AuditLog, Provider, RequestCandidate, Usage
from src.services.provider_ops.service import ProviderOpsService
from src.services.system.config import SystemConfigService
from src.services.system.scheduler import get_scheduler
@@ -39,10 +39,6 @@ class MaintenanceScheduler:
# 签到任务的 job_id
CHECKIN_JOB_ID = "provider_checkin"
# 用户配额重置任务的 job_id
USER_QUOTA_RESET_JOB_ID = "user_quota_reset"
# 独立密钥额度重置任务的 job_id
STANDALONE_KEY_QUOTA_RESET_JOB_ID = "standalone_key_quota_reset"
def __init__(self) -> None:
self.running = False
@@ -62,19 +58,6 @@ class MaintenanceScheduler:
finally:
db.close()
def _get_user_quota_reset_time(self) -> tuple[int, int]:
"""获取用户配额重置任务的执行时间
Returns:
(hour, minute) 元组
"""
db = create_session()
try:
time_str = SystemConfigService.get_config(db, "user_quota_reset_time", "05:00")
return self._parse_user_quota_reset_time_string(time_str)
finally:
db.close()
@staticmethod
def _parse_time_string(time_str: str) -> tuple[int, int]:
"""解析时间字符串为 (hour, minute) 元组
@@ -98,26 +81,6 @@ class MaintenanceScheduler:
except (ValueError, IndexError):
return (1, 5)
@staticmethod
def _parse_user_quota_reset_time_string(time_str: str) -> tuple[int, int]:
"""解析用户配额重置时间字符串为 (hour, minute) 元组
Returns:
(hour, minute) 元组,解析失败返回默认值 (5, 0)
"""
try:
if not time_str or ":" not in time_str:
return (5, 0)
parts = time_str.split(":")
hour = int(parts[0])
minute = int(parts[1])
# 验证范围
if 0 <= hour <= 23 and 0 <= minute <= 59:
return (hour, minute)
return (5, 0)
except (ValueError, IndexError):
return (5, 0)
def update_checkin_time(self, time_str: str) -> bool:
"""更新签到任务的执行时间
@@ -141,56 +104,6 @@ class MaintenanceScheduler:
return success
def update_user_quota_reset_time(self, time_str: str) -> bool:
"""更新用户配额重置任务的执行时间
Args:
time_str: HH:MM 格式的时间字符串
Returns:
是否成功更新
"""
hour, minute = self._parse_user_quota_reset_time_string(time_str)
scheduler = get_scheduler()
success = scheduler.reschedule_cron_job(
self.USER_QUOTA_RESET_JOB_ID,
hour=hour,
minute=minute,
)
if success:
logger.info(f"用户配额重置任务时间已更新为: {hour:02d}:{minute:02d}")
return success
def _get_standalone_key_quota_reset_time(self) -> tuple[int, int]:
"""获取独立密钥额度重置任务的执行时间"""
db = create_session()
try:
time_str = SystemConfigService.get_config(
db, "standalone_key_quota_reset_time", "05:00"
)
return self._parse_user_quota_reset_time_string(time_str)
finally:
db.close()
def update_standalone_key_quota_reset_time(self, time_str: str) -> bool:
"""更新独立密钥额度重置任务的执行时间"""
hour, minute = self._parse_user_quota_reset_time_string(time_str)
scheduler = get_scheduler()
success = scheduler.reschedule_cron_job(
self.STANDALONE_KEY_QUOTA_RESET_JOB_ID,
hour=hour,
minute=minute,
)
if success:
logger.info(f"独立密钥额度重置任务时间已更新为: {hour:02d}:{minute:02d}")
return success
def get_checkin_job_info(self) -> dict | None:
"""获取签到任务的信息
@@ -318,26 +231,6 @@ class MaintenanceScheduler:
name="Provider签到",
)
# 用户配额重置任务 - 根据配置时间执行(按周期配置决定是否执行)
quota_reset_hour, quota_reset_minute = self._get_user_quota_reset_time()
scheduler.add_cron_job(
self._scheduled_user_quota_reset,
hour=quota_reset_hour,
minute=quota_reset_minute,
job_id=self.USER_QUOTA_RESET_JOB_ID,
name="用户配额自动重置",
)
# 独立密钥额度重置任务 - 根据配置时间执行(按周期配置决定是否执行)
sk_reset_hour, sk_reset_minute = self._get_standalone_key_quota_reset_time()
scheduler.add_cron_job(
self._scheduled_standalone_key_quota_reset,
hour=sk_reset_hour,
minute=sk_reset_minute,
job_id=self.STANDALONE_KEY_QUOTA_RESET_JOB_ID,
name="独立密钥额度自动重置",
)
# 启动时执行一次初始化任务
asyncio.create_task(self._run_startup_tasks())
@@ -440,14 +333,6 @@ class MaintenanceScheduler:
"""Provider 签到任务(定时调用)"""
await self._perform_provider_checkin()
async def _scheduled_user_quota_reset(self) -> None:
"""用户配额重置任务(定时调用)"""
await self._perform_user_quota_reset()
async def _scheduled_standalone_key_quota_reset(self) -> None:
"""独立密钥额度重置任务(定时调用)"""
await self._perform_standalone_key_quota_reset()
# ========== 实际任务实现 ==========
async def _perform_stats_aggregation(self, backfill: bool = False) -> None:
@@ -858,227 +743,6 @@ class MaintenanceScheduler:
if db is not None:
db.close()
async def _perform_user_quota_reset(self) -> None:
"""执行用户配额自动重置任务
适用范围:
- 未删除is_deleted=false
- 仅对 quota_usd != NULL 的用户生效
"""
db = create_session()
try:
# 检查是否启用用户配额重置
if not SystemConfigService.get_config(db, "enable_user_quota_reset", False):
logger.info("用户配额自动重置已禁用,跳过任务")
return
# 重置周期(天数),不限制上限
interval_value = SystemConfigService.get_config(db, "user_quota_reset_interval_days", 1)
try:
interval_days = int(interval_value)
except Exception:
interval_days = 1
if interval_days < 1:
interval_days = 1
# 滚动计算根据上次执行日APP_TIMEZONE判断是否到期
last_reset_at = SystemConfigService.get_config(db, "user_quota_last_reset_at")
should_run = True
if last_reset_at:
last_dt: datetime | None = None
try:
if isinstance(last_reset_at, str):
last_dt = datetime.fromisoformat(last_reset_at)
except Exception:
last_dt = None
if last_dt is None:
logger.warning("user_quota_last_reset_at 格式无效,视为需要执行一次")
else:
if last_dt.tzinfo is None:
last_dt = last_dt.replace(tzinfo=timezone.utc)
from zoneinfo import ZoneInfo
from src.services.system.scheduler import APP_TIMEZONE
tz = ZoneInfo(APP_TIMEZONE)
now_local = datetime.now(tz)
last_local_date = last_dt.astimezone(tz).date()
days_since_reset = (now_local.date() - last_local_date).days
if days_since_reset < 0:
logger.warning("user_quota_last_reset_at 在未来,跳过本次用户配额自动重置")
should_run = False
elif days_since_reset < interval_days:
logger.info(
f"用户配额自动重置未到周期,跳过任务({days_since_reset}/{interval_days}天)"
)
should_run = False
if not should_run:
return
from src.models.database import User as DBUser
now_utc = datetime.now(timezone.utc)
reset_count = (
db.query(DBUser)
.filter(
DBUser.is_deleted.is_(False),
DBUser.quota_usd.isnot(None),
)
.update(
{
DBUser.used_usd: 0.0,
DBUser.updated_at: now_utc,
},
synchronize_session=False,
)
)
db.commit()
# 记录 last_reset_at成功执行后更新滚动计算用
SystemConfigService.set_config(
db,
"user_quota_last_reset_at",
now_utc.isoformat(),
"用户配额自动重置的上次执行时间UTC内部使用",
)
logger.info(
f"用户配额自动重置完成: interval_days={interval_days}, 重置用户数={reset_count}"
)
except Exception as e:
logger.exception(f"用户配额自动重置任务执行失败: {e}")
try:
db.rollback()
except Exception:
pass
finally:
db.close()
async def _perform_standalone_key_quota_reset(self) -> None:
"""执行独立密钥额度自动重置任务
适用范围:
- is_standalone=True 的密钥
- current_balance_usd != NULL有限额的密钥
- 支持 all全部和 selected指定密钥两种模式
"""
db = create_session()
try:
if not SystemConfigService.get_config(db, "enable_standalone_key_quota_reset", False):
logger.info("独立密钥额度自动重置已禁用,跳过任务")
return
# 重置周期
interval_value = SystemConfigService.get_config(
db, "standalone_key_quota_reset_interval_days", 1
)
try:
interval_days = int(interval_value)
except Exception:
interval_days = 1
if interval_days < 1:
interval_days = 1
# 滚动计算
last_reset_at = SystemConfigService.get_config(db, "standalone_key_quota_last_reset_at")
should_run = True
if last_reset_at:
last_dt: datetime | None = None
try:
if isinstance(last_reset_at, str):
last_dt = datetime.fromisoformat(last_reset_at)
except Exception:
last_dt = None
if last_dt is None:
logger.warning("standalone_key_quota_last_reset_at 格式无效,视为需要执行一次")
else:
if last_dt.tzinfo is None:
last_dt = last_dt.replace(tzinfo=timezone.utc)
from zoneinfo import ZoneInfo
from src.services.system.scheduler import APP_TIMEZONE
tz = ZoneInfo(APP_TIMEZONE)
now_local = datetime.now(tz)
last_local_date = last_dt.astimezone(tz).date()
days_since_reset = (now_local.date() - last_local_date).days
if days_since_reset < 0:
logger.warning("standalone_key_quota_last_reset_at 在未来,跳过本次重置")
should_run = False
elif days_since_reset < interval_days:
logger.info(
f"独立密钥额度自动重置未到周期,跳过任务"
f"{days_since_reset}/{interval_days}天)"
)
should_run = False
if not should_run:
return
# 确定重置范围
reset_mode = SystemConfigService.get_config(
db, "standalone_key_quota_reset_mode", "all"
)
now_utc = datetime.now(timezone.utc)
base_filter = [
ApiKey.is_standalone.is_(True),
ApiKey.current_balance_usd.isnot(None),
]
if reset_mode == "selected":
key_ids = SystemConfigService.get_config(
db, "standalone_key_quota_reset_key_ids", []
)
if not key_ids:
logger.info("独立密钥额度重置模式为 selected 但未选择任何密钥,跳过")
return
base_filter.append(ApiKey.id.in_(key_ids))
reset_count = (
db.query(ApiKey)
.filter(*base_filter)
.update(
{
ApiKey.balance_used_usd: 0.0,
ApiKey.updated_at: now_utc,
},
synchronize_session=False,
)
)
db.commit()
SystemConfigService.set_config(
db,
"standalone_key_quota_last_reset_at",
now_utc.isoformat(),
"独立密钥额度自动重置的上次执行时间UTC内部使用",
)
logger.info(
f"独立密钥额度自动重置完成: mode={reset_mode}, "
f"interval_days={interval_days}, 重置密钥数={reset_count}"
)
except Exception as e:
logger.exception(f"独立密钥额度自动重置任务执行失败: {e}")
try:
db.rollback()
except Exception:
pass
finally:
db.close()
async def _perform_candidate_cleanup(self) -> None:
"""清理过期的 request_candidates 记录"""

View File

@@ -6,6 +6,7 @@ from typing import Any
from sqlalchemy.orm import Session
from src.models.database import ApiKey, Usage, User
from src.services.billing.precision import to_money_decimal
from src.services.system.config import SystemConfigService
from src.services.usage._types import UsageCostInfo
from src.services.usage.error_classifier import classify_error
@@ -206,19 +207,19 @@ def build_usage_params(
"cache_read_input_tokens": cache_read_input_tokens,
"cache_creation_input_tokens_5m": cache_creation_input_tokens_5m,
"cache_creation_input_tokens_1h": cache_creation_input_tokens_1h,
"input_cost_usd": input_cost,
"output_cost_usd": output_cost,
"cache_cost_usd": cache_cost,
"cache_creation_cost_usd": cache_creation_cost,
"cache_read_cost_usd": cache_read_cost,
"request_cost_usd": request_cost,
"total_cost_usd": total_cost,
"actual_input_cost_usd": actual_input_cost,
"actual_output_cost_usd": actual_output_cost,
"actual_cache_creation_cost_usd": actual_cache_creation_cost,
"actual_cache_read_cost_usd": actual_cache_read_cost,
"actual_request_cost_usd": actual_request_cost,
"actual_total_cost_usd": actual_total_cost,
"input_cost_usd": to_money_decimal(input_cost),
"output_cost_usd": to_money_decimal(output_cost),
"cache_cost_usd": to_money_decimal(cache_cost),
"cache_creation_cost_usd": to_money_decimal(cache_creation_cost),
"cache_read_cost_usd": to_money_decimal(cache_read_cost),
"request_cost_usd": to_money_decimal(request_cost),
"total_cost_usd": to_money_decimal(total_cost),
"actual_input_cost_usd": to_money_decimal(actual_input_cost),
"actual_output_cost_usd": to_money_decimal(actual_output_cost),
"actual_cache_creation_cost_usd": to_money_decimal(actual_cache_creation_cost),
"actual_cache_read_cost_usd": to_money_decimal(actual_cache_read_cost),
"actual_request_cost_usd": to_money_decimal(actual_request_cost),
"actual_total_cost_usd": to_money_decimal(actual_total_cost),
"rate_multiplier": actual_rate_multiplier,
"input_price_per_1m": input_price,
"output_price_per_1m": output_price,

View File

@@ -7,10 +7,12 @@ from sqlalchemy.orm import Session
from src.core.logger import logger
from src.models.database import ApiKey, Usage, User
from src.services.billing.precision import to_money_decimal
from src.services.provider_keys.codex_quota_sync_dispatcher import (
dispatch_codex_quota_sync_from_response_headers,
)
from src.services.system.config import SystemConfigService
from src.services.wallet import WalletService
class UsageLifecycleMixin:
@@ -156,41 +158,32 @@ class UsageLifecycleMixin:
- 仅当 billing_status='pending' 时才会生效rowcount==1
- 不在本方法内 commit由调用方决定事务提交时机
"""
from sqlalchemy import update
now = datetime.now(timezone.utc)
cost = float(total_cost_usd)
request_cost = float(request_cost_usd) if request_cost_usd is not None else cost
cost = to_money_decimal(total_cost_usd)
request_cost = to_money_decimal(request_cost_usd) if request_cost_usd is not None else cost
result = db.execute(
update(Usage)
.where(
Usage.request_id == request_id,
Usage.billing_status == "pending",
)
.values(
billing_status="settled",
finalized_at=now,
total_cost_usd=cost,
request_cost_usd=request_cost,
status=status,
status_code=status_code,
error_message=error_message,
response_time_ms=response_time_ms,
)
)
if result.rowcount != 1:
usage = db.query(Usage).filter(Usage.request_id == request_id).with_for_update().first()
if not usage or usage.billing_status != "pending":
return False
usage.billing_status = "settled"
usage.finalized_at = now
usage.total_cost_usd = cost
usage.request_cost_usd = request_cost
usage.status = status
usage.status_code = status_code
usage.error_message = error_message
usage.response_time_ms = response_time_ms
if cost > 0:
WalletService.apply_usage_charge(db, usage=usage, amount_usd=cost)
# 写入审计快照(只在本次 finalize 生效时执行)
usage = db.query(Usage).filter(Usage.request_id == request_id).first()
if usage:
metadata = usage.request_metadata or {}
if billing_snapshot is not None:
metadata["billing_snapshot"] = billing_snapshot
if extra_metadata:
metadata.update(extra_metadata)
usage.request_metadata = cls._sanitize_request_metadata(metadata)
metadata = usage.request_metadata or {}
if billing_snapshot is not None:
metadata["billing_snapshot"] = billing_snapshot
if extra_metadata:
metadata.update(extra_metadata)
usage.request_metadata = cls._sanitize_request_metadata(metadata)
return True
@@ -210,27 +203,20 @@ class UsageLifecycleMixin:
- 仅当 billing_status='pending' 时才会生效rowcount==1
- 不在本方法内 commit由调用方决定事务提交时机
"""
from sqlalchemy import update
now = datetime.now(timezone.utc)
result = db.execute(
update(Usage)
.where(
Usage.request_id == request_id,
Usage.billing_status == "pending",
)
.values(
billing_status="void",
finalized_at=now,
total_cost_usd=0.0,
request_cost_usd=0.0,
status="cancelled",
status_code=status_code,
error_message=reason,
response_time_ms=None,
)
)
return result.rowcount == 1
usage = db.query(Usage).filter(Usage.request_id == request_id).with_for_update().first()
if not usage or usage.billing_status != "pending":
return False
usage.billing_status = "void"
usage.finalized_at = now
usage.total_cost_usd = to_money_decimal(0)
usage.request_cost_usd = to_money_decimal(0)
usage.status = "cancelled"
usage.status_code = status_code
usage.error_message = reason
usage.response_time_ms = None
return True
@classmethod
def finalize_submitted(
@@ -252,17 +238,13 @@ class UsageLifecycleMixin:
"""
异步任务提交成功时的幂等结算。
将 pending 使用记录标记为 settled费用暂时为 0
后续轮询完成后通过 update_settled_billing 更新实际费用
将 pending 使用记录保留为 pending仅补齐已知的 provider/响应信息
后续轮询完成后通过 update_settled_billing 一次性写入实际费用并扣钱包
约定:
- 仅当 billing_status='pending' 时才会生效rowcount==1
- 不在本方法内 commit由调用方决定事务提交时机
"""
from sqlalchemy import update
now = datetime.now(timezone.utc)
# 处理响应头和响应体
should_log_headers = SystemConfigService.should_log_headers(db)
should_log_body = SystemConfigService.should_log_body(db)
@@ -284,11 +266,7 @@ class UsageLifecycleMixin:
)
values: dict[str, Any] = {
"billing_status": "settled",
"finalized_at": now,
"total_cost_usd": 0.0,
"request_cost_usd": 0.0,
"status": "completed",
"status": "pending",
"status_code": status_code,
"response_time_ms": response_time_ms,
"provider_name": provider_name,
@@ -305,15 +283,12 @@ class UsageLifecycleMixin:
if processed_response_body is not None:
values["response_body"] = processed_response_body
result = db.execute(
update(Usage)
.where(
Usage.request_id == request_id,
Usage.billing_status == "pending",
)
.values(**values)
)
finalized = result.rowcount == 1
usage = db.query(Usage).filter(Usage.request_id == request_id).with_for_update().first()
if not usage or usage.billing_status != "pending":
return False
for key, value in values.items():
setattr(usage, key, value)
finalized = True
if finalized:
dispatch_codex_quota_sync_from_response_headers(
provider_api_key_id=provider_api_key_id,
@@ -338,54 +313,51 @@ class UsageLifecycleMixin:
extra_metadata: dict[str, Any] | None = None,
) -> bool:
"""
更新已结算记录的计费信息(用于异步任务轮询完成后)。
写入异步任务最终账单(轮询完成后调用)。
与 finalize_settled 不同
- finalize_settled: pending -> settled首次结算
- update_settled_billing: settled -> settled更新费用
语义
- 正常路径:pending -> settled / void(首次最终结算)
- 补写路径:已写入 0 成本但尚未扣钱包的记录,可补写一次最终值
- 已 void 的记录不可再结算
- 已扣钱包wallet_balance_after 已存在)的记录不可重复扣费
约定:
- 仅当 billing_status='settled' 时才会生效
- 不在本方法内 commit由调用方决定事务提交时机
"""
from sqlalchemy import update
now = datetime.now(timezone.utc)
cost = float(total_cost_usd)
request_cost = float(request_cost_usd) if request_cost_usd is not None else cost
cost = to_money_decimal(total_cost_usd)
request_cost = to_money_decimal(request_cost_usd) if request_cost_usd is not None else cost
values: dict[str, Any] = {
"total_cost_usd": cost,
"request_cost_usd": request_cost,
"status": status,
"status_code": status_code,
}
if error_message is not None:
values["error_message"] = error_message
if response_time_ms is not None:
values["response_time_ms"] = response_time_ms
result = db.execute(
update(Usage)
.where(
Usage.request_id == request_id,
Usage.billing_status == "settled",
)
.values(**values)
)
if result.rowcount != 1:
usage = db.query(Usage).filter(Usage.request_id == request_id).with_for_update().first()
if not usage or usage.billing_status == "void":
return False
if usage.billing_status == "settled" and usage.wallet_balance_after is not None:
return False
usage.total_cost_usd = cost
usage.request_cost_usd = request_cost
usage.status = status
usage.status_code = status_code
if error_message is not None:
usage.error_message = error_message
if response_time_ms is not None:
usage.response_time_ms = response_time_ms
usage.finalized_at = usage.finalized_at or now
if cost > 0:
usage.billing_status = "settled"
WalletService.apply_usage_charge(db, usage=usage, amount_usd=cost)
else:
usage.billing_status = "void" if status in {"failed", "cancelled"} else "settled"
# 写入审计快照
usage = db.query(Usage).filter(Usage.request_id == request_id).first()
if usage:
metadata = usage.request_metadata or {}
if billing_snapshot is not None:
metadata["billing_snapshot"] = billing_snapshot
if extra_metadata:
metadata.update(extra_metadata)
metadata["billing_updated_at"] = now.isoformat()
usage.request_metadata = cls._sanitize_request_metadata(metadata)
metadata = usage.request_metadata or {}
if billing_snapshot is not None:
metadata["billing_snapshot"] = billing_snapshot
if extra_metadata:
metadata.update(extra_metadata)
metadata["billing_updated_at"] = now.isoformat()
usage.request_metadata = cls._sanitize_request_metadata(metadata)
return True
@@ -409,26 +381,22 @@ class UsageLifecycleMixin:
- 仅当 billing_status='settled' 时才会生效
- 不在本方法内 commit由调用方决定事务提交时机
"""
from sqlalchemy import update
now = datetime.now(timezone.utc)
result = db.execute(
update(Usage)
.where(
Usage.request_id == request_id,
Usage.billing_status == "settled",
)
.values(
billing_status="void",
finalized_at=now,
total_cost_usd=0.0,
request_cost_usd=0.0,
status="cancelled",
status_code=status_code,
error_message=reason,
)
)
return result.rowcount == 1
usage = db.query(Usage).filter(Usage.request_id == request_id).with_for_update().first()
if not usage or usage.billing_status != "settled":
return False
if usage.wallet_balance_after is not None and to_money_decimal(usage.total_cost_usd) > 0:
# 已实际扣费的记录当前不做自动回滚,避免 silent inconsistency。
return False
usage.billing_status = "void"
usage.finalized_at = now
usage.total_cost_usd = to_money_decimal(0)
usage.request_cost_usd = to_money_decimal(0)
usage.status = "cancelled"
usage.status_code = status_code
usage.error_message = reason
return True
@classmethod
def update_usage_status(
@@ -549,11 +517,14 @@ class UsageLifecycleMixin:
db, provider_request_body, is_request=True
)
# 结算状态:当请求进入终态时,将 billing_status 标记为 settled
# 注意:取消是否应 VOID/部分结算由更高层策略决定;这里默认终态均视为已结算。
if status in ("completed", "failed", "cancelled"):
if getattr(usage, "billing_status", None) == "pending":
usage.billing_status = "settled"
# 仅在“明确不会收费”的终态下直接关闭账单。
# completed 的费用通常要由后续 record_usage / update_settled_billing 写入,
# 这里不能提前把 billing_status 置为 settled否则会阻断真正扣费。
if (
status in ("failed", "cancelled")
and getattr(usage, "billing_status", None) == "pending"
):
usage.billing_status = "void"
if getattr(usage, "finalized_at", None) is None:
usage.finalized_at = datetime.now(timezone.utc)

View File

@@ -7,7 +7,7 @@ from sqlalchemy import case, func
from sqlalchemy.orm import Session
from src.core.logger import logger
from src.models.database import ApiKey, Usage, User, UserRole
from src.models.database import ApiKey, Usage, User
class UsageQueryMixin:
@@ -125,66 +125,44 @@ class UsageQueryMixin:
return result
@staticmethod
def check_user_quota(
def check_request_balance(
db: Session,
user: User,
estimated_tokens: int = 0,
estimated_cost: float = 0,
api_key: ApiKey | None = None,
) -> tuple[bool, str]:
"""检查用户配额或独立Key余额
"""检查请求是否满足余额条件(支持独立 Key)。"""
from src.services.wallet import WalletService
Args:
db: 数据库会话
user: 用户对象
estimated_tokens: 预估token数
estimated_cost: 预估费用
api_key: API Key对象用于检查独立余额Key
wallet_access = WalletService.check_request_allowed(
db,
user=None if (api_key and api_key.is_standalone) else user,
api_key=api_key,
)
if wallet_access.allowed:
return True, "OK"
Returns:
(是否通过, 消息)
"""
if wallet_access.message == "钱包欠费,请先充值":
if api_key and api_key.is_standalone:
return False, "Key欠费请先调账或充值"
return False, "账户欠费,请先充值"
# 如果是独立余额Key检查Key的余额而不是用户配额
if wallet_access.message == "钱包不可用":
if api_key and api_key.is_standalone:
return False, "Key钱包不可用"
return False, "钱包不可用"
remaining = float(wallet_access.remaining) if wallet_access.remaining is not None else None
if api_key and api_key.is_standalone:
# 导入 ApiKeyService 以使用统一的余额计算方法
from src.services.user.apikey import ApiKeyService
if remaining is None:
return False, "Key余额不足"
return False, f"Key余额不足剩余: ${remaining:.2f}"
# NULL 表示无限制
if api_key.current_balance_usd is None:
return True, "OK"
# 使用统一的余额计算方法
remaining_balance = ApiKeyService.get_remaining_balance(api_key)
if remaining_balance is None:
return True, "OK"
# 检查余额是否充足
if remaining_balance < estimated_cost:
return (
False,
f"Key余额不足剩余: ${remaining_balance:.2f},需要: ${estimated_cost:.2f}",
)
return True, "OK"
# 普通Key检查用户配额
# 管理员无限制
if user.role == UserRole.ADMIN:
return True, "OK"
# NULL 表示无限制
if user.quota_usd is None:
return True, "OK"
# 有配额限制,检查是否超额
used_usd = float(user.used_usd or 0)
quota_usd = float(user.quota_usd)
if used_usd + estimated_cost > quota_usd:
remaining = quota_usd - used_usd
return False, f"配额不足(剩余: ${remaining:.2f}"
return True, "OK"
# admin 已在 WalletService.check_request_allowed 中放行,此处不再重复检查
if remaining is None:
return False, wallet_access.message or "余额不足"
return False, f"余额不足(剩余: ${remaining:.2f}"
@staticmethod
def get_usage_summary(
@@ -210,14 +188,14 @@ class UsageQueryMixin:
if end_date:
query = query.filter(Usage.created_at < end_date)
# 使用跨数据库兼容的日期函数
# 使用跨数据库可用的日期函数
from src.utils.database_helpers import date_trunc_portable
# 检测数据库方言
bind = db.bind
dialect = bind.dialect.name if bind is not None else "sqlite"
# 根据分组类型选择日期函数(兼容多种数据库)
# 根据分组类型选择日期函数(适配多种数据库)
if group_by == "day":
date_func = date_trunc_portable(dialect, "day", Usage.created_at)
elif group_by == "week":

View File

@@ -8,6 +8,7 @@ from sqlalchemy.orm import Session
from src.core.logger import logger
from src.models.database import ApiKey, Provider, ProxyNode, Usage, User, UserModelUsageCount
from src.services.billing.precision import to_money_decimal
from src.services.provider_keys.codex_quota_sync_dispatcher import (
dispatch_codex_quota_sync_from_response_headers,
)
@@ -20,6 +21,7 @@ from src.services.usage._recording_helpers import (
update_existing_usage,
)
from src.services.usage._types import UsageCostInfo, UsageRecordParams
from src.services.wallet import WalletService
def _extract_manual_proxy_node_id(metadata: dict[str, Any] | None) -> str | None:
@@ -69,12 +71,12 @@ def _increment_proxy_node_requests(
class UsageRecordingMixin(UsageBillingIntegrationMixin):
"""记录用量相关方法"""
# Metadata pruning configuration -- re-export from helpers for backward compatibility
# Metadata pruning configuration
_METADATA_PRUNE_KEYS: tuple[str, ...] = METADATA_PRUNE_KEYS
_METADATA_KEEP_KEYS: frozenset[str] = METADATA_KEEP_KEYS
# ------------------------------------------------------------------
# Backward-compatible thin wrappers
# Helper wrappers
# ------------------------------------------------------------------
@staticmethod
@@ -121,6 +123,56 @@ class UsageRecordingMixin(UsageBillingIntegrationMixin):
"""元数据清理(委托到模块级函数)"""
return sanitize_request_metadata(metadata)
@staticmethod
def _is_terminal_status(status: str | None) -> bool:
return status in {"completed", "failed", "cancelled"}
@staticmethod
def _is_usage_finalized(usage: Usage) -> bool:
return (
getattr(usage, "billing_status", None) in {"settled", "void"}
and getattr(usage, "finalized_at", None) is not None
)
@classmethod
def _finalize_usage_billing(
cls,
db: Session,
*,
usage: Usage,
total_cost: float,
status: str | None,
finalized_at: datetime | None = None,
) -> tuple[bool, bool]:
"""完成 usage 的结算状态,并在需要时扣减钱包。
Returns:
(是否首次进入终态, 是否发生扣费)
"""
if not cls._is_terminal_status(status):
if getattr(usage, "billing_status", None) is None:
usage.billing_status = "pending"
return False, False
if (
getattr(usage, "billing_status", None) in {"settled", "void"}
and getattr(usage, "finalized_at", None) is not None
):
return False, False
now = finalized_at or datetime.now(timezone.utc)
charge_amount = to_money_decimal(total_cost)
usage.finalized_at = usage.finalized_at or now
if charge_amount > 0:
WalletService.apply_usage_charge(db, usage=usage, amount_usd=charge_amount)
usage.billing_status = "settled"
return True, True
usage.billing_status = "void" if status in {"failed", "cancelled"} else "settled"
return True, False
# ------------------------------------------------------------------
# Recording methods
# ------------------------------------------------------------------
@@ -221,7 +273,8 @@ class UsageRecordingMixin(UsageBillingIntegrationMixin):
use_tiered_pricing=use_tiered_pricing,
target_model=target_model,
)
usage_params, _ = await cls._prepare_usage_record(params)
usage_params, total_cost = await cls._prepare_usage_record(params)
total_cost = to_money_decimal(total_cost)
# 创建 Usage 记录
usage = Usage(**usage_params)
@@ -243,17 +296,19 @@ class UsageRecordingMixin(UsageBillingIntegrationMixin):
# 更新 Provider 月度使用量(原子操作)
if provider_id:
actual_total_cost = usage_params["actual_total_cost_usd"]
actual_total_cost = float(usage_params["actual_total_cost_usd"])
db.execute(
update(Provider)
.where(Provider.id == provider_id)
.values(monthly_used_usd=Provider.monthly_used_usd + actual_total_cost)
)
# 结算标记record_usage_async 写入的 Usage 通常为终态记录
if status not in ("pending", "streaming"):
usage.billing_status = "settled"
usage.finalized_at = datetime.now(timezone.utc)
cls._finalize_usage_billing(
db,
usage=usage,
total_cost=total_cost,
status=status,
)
dispatch_codex_quota_sync_from_response_headers(
provider_api_key_id=provider_api_key_id,
@@ -363,10 +418,20 @@ class UsageRecordingMixin(UsageBillingIntegrationMixin):
target_model=target_model,
)
usage_params, total_cost = await cls._prepare_usage_record(params)
total_cost = to_money_decimal(total_cost)
# 检查是否已存在相同 request_id 的记录
existing_usage = db.query(Usage).filter(Usage.request_id == request_id).first()
existing_usage = (
db.query(Usage).filter(Usage.request_id == request_id).with_for_update().first()
)
if existing_usage:
if cls._is_usage_finalized(existing_usage):
logger.debug(
"request_id {} 已完成结算,跳过重复记账 (billing_status={})",
request_id,
getattr(existing_usage, "billing_status", None),
)
return existing_usage
logger.debug(
f"request_id {request_id} 已存在,更新现有记录 "
f"(status: {existing_usage.status} -> {status})"
@@ -389,75 +454,52 @@ class UsageRecordingMixin(UsageBillingIntegrationMixin):
from src.models.database import ApiKey as ApiKeyModel
from src.models.database import GlobalModel
from src.models.database import User as UserModel
# 更新用户使用量(独立 Key 不计入创建者的使用记录)
if user and not (api_key and api_key.is_standalone):
db.execute(
update(UserModel)
.where(UserModel.id == user.id)
.values(
used_usd=UserModel.used_usd + total_cost,
total_usd=UserModel.total_usd + total_cost,
updated_at=sql_func.now(),
)
)
# 更新 API 密钥使用量
if api_key:
if api_key.is_standalone:
db.execute(
update(ApiKeyModel)
.where(ApiKeyModel.id == api_key.id)
.values(
total_requests=ApiKeyModel.total_requests + 1,
total_cost_usd=ApiKeyModel.total_cost_usd + total_cost,
balance_used_usd=ApiKeyModel.balance_used_usd + total_cost,
last_used_at=sql_func.now(),
updated_at=sql_func.now(),
)
)
else:
db.execute(
update(ApiKeyModel)
.where(ApiKeyModel.id == api_key.id)
.values(
total_requests=ApiKeyModel.total_requests + 1,
total_cost_usd=ApiKeyModel.total_cost_usd + total_cost,
last_used_at=sql_func.now(),
updated_at=sql_func.now(),
)
)
# 更新 GlobalModel 使用计数
db.execute(
update(GlobalModel)
.where(GlobalModel.name == model)
.values(usage_count=GlobalModel.usage_count + 1)
accounted, charge_applied = cls._finalize_usage_billing(
db,
usage=usage,
total_cost=total_cost,
status=status,
)
# 更新用户-模型调用次数计数器
cls._increment_user_model_usage(db, user, model)
if accounted:
# 更新 API 密钥使用量
if api_key:
values: dict[str, Any] = {
"total_requests": ApiKeyModel.total_requests + 1,
"last_used_at": sql_func.now(),
"updated_at": sql_func.now(),
}
if charge_applied:
values["total_cost_usd"] = ApiKeyModel.total_cost_usd + float(
to_money_decimal(total_cost)
)
db.execute(update(ApiKeyModel).where(ApiKeyModel.id == api_key.id).values(**values))
# 更新 Provider 月度使用量
if provider_id:
actual_total_cost = usage_params["actual_total_cost_usd"]
# 更新 GlobalModel 使用计数
db.execute(
update(Provider)
.where(Provider.id == provider_id)
.values(monthly_used_usd=Provider.monthly_used_usd + actual_total_cost)
update(GlobalModel)
.where(GlobalModel.name == model)
.values(usage_count=GlobalModel.usage_count + 1)
)
# 更新手动代理节点请求计数tunnel 节点由心跳上报,不在此处统计)
manual_node_id = _extract_manual_proxy_node_id(metadata)
if manual_node_id:
failed = {manual_node_id: 1} if status == "failed" else None
_increment_proxy_node_requests(db, {manual_node_id: 1}, failed)
# 更新用户-模型调用次数计数器
cls._increment_user_model_usage(db, user, model)
# 结算标记:终态请求写入 settled + finalized_at
if status not in ("pending", "streaming"):
usage.billing_status = "settled"
usage.finalized_at = datetime.now(timezone.utc)
# 更新 Provider 月度使用量Provider 端真实成本,无论钱包是否扣费)
if provider_id:
actual_total_cost = float(usage_params["actual_total_cost_usd"])
db.execute(
update(Provider)
.where(Provider.id == provider_id)
.values(monthly_used_usd=Provider.monthly_used_usd + actual_total_cost)
)
# 更新手动代理节点请求计数tunnel 节点由心跳上报,不在此处统计)
manual_node_id = _extract_manual_proxy_node_id(metadata)
if manual_node_id:
failed = {manual_node_id: 1} if status == "failed" else None
_increment_proxy_node_requests(db, {manual_node_id: 1}, failed)
dispatch_codex_quota_sync_from_response_headers(
provider_api_key_id=provider_api_key_id,
@@ -542,10 +584,14 @@ class UsageRecordingMixin(UsageBillingIntegrationMixin):
cache_creation_cost = 0.0
cache_read_cost = 0.0
cache_cost = 0.0
request_cost = (
float(request_cost_usd) if request_cost_usd is not None else float(total_cost_usd)
request_cost_decimal = (
to_money_decimal(request_cost_usd)
if request_cost_usd is not None
else to_money_decimal(total_cost_usd)
)
total_cost = float(total_cost_usd)
total_cost_decimal = to_money_decimal(total_cost_usd)
request_cost = float(request_cost_decimal)
total_cost = float(total_cost_decimal)
usage_params = build_usage_params(
db=db,
@@ -598,10 +644,10 @@ class UsageRecordingMixin(UsageBillingIntegrationMixin):
),
)
# Upsert并发幂等优先用 billing_status 作为结算闸门
from sqlalchemy import update
existing_usage = db.query(Usage).filter(Usage.request_id == request_id).first()
# Upsert并发幂等锁定 request_id 对应行,避免重复结算
existing_usage = (
db.query(Usage).filter(Usage.request_id == request_id).with_for_update().first()
)
if existing_usage:
# 避免重复记账:若已结算/作废,直接返回(防止并发重复加计数)
if getattr(existing_usage, "billing_status", None) in ("settled", "void"):
@@ -612,25 +658,6 @@ class UsageRecordingMixin(UsageBillingIntegrationMixin):
)
return existing_usage
# 并发闸门:只有 billing_status='pending' 的那一次调用可以继续
now = datetime.now(timezone.utc)
claim = db.execute(
update(Usage)
.where(
Usage.request_id == request_id,
Usage.billing_status == "pending",
)
.values(billing_status="settled", finalized_at=now)
)
if claim.rowcount != 1:
# 已被其他 worker 抢先处理(或被 VOID
latest = db.query(Usage).filter(Usage.request_id == request_id).first()
return latest or existing_usage
# 同步 ORM 对象(避免后续代码读到旧值)
existing_usage.billing_status = "settled"
existing_usage.finalized_at = now
cls._update_existing_usage(existing_usage, usage_params, target_model)
usage = existing_usage
else:
@@ -649,69 +676,46 @@ class UsageRecordingMixin(UsageBillingIntegrationMixin):
from src.models.database import ApiKey as ApiKeyModel
from src.models.database import GlobalModel
from src.models.database import User as UserModel
# 更新用户使用量(独立 Key 不计入创建者)
if user and not (api_key and api_key.is_standalone):
db.execute(
update(UserModel)
.where(UserModel.id == user.id)
.values(
used_usd=UserModel.used_usd + total_cost,
total_usd=UserModel.total_usd + total_cost,
updated_at=sql_func.now(),
)
)
# 更新 API 密钥使用量
if api_key:
if api_key.is_standalone:
db.execute(
update(ApiKeyModel)
.where(ApiKeyModel.id == api_key.id)
.values(
total_requests=ApiKeyModel.total_requests + 1,
total_cost_usd=ApiKeyModel.total_cost_usd + total_cost,
balance_used_usd=ApiKeyModel.balance_used_usd + total_cost,
last_used_at=sql_func.now(),
updated_at=sql_func.now(),
)
)
else:
db.execute(
update(ApiKeyModel)
.where(ApiKeyModel.id == api_key.id)
.values(
total_requests=ApiKeyModel.total_requests + 1,
total_cost_usd=ApiKeyModel.total_cost_usd + total_cost,
last_used_at=sql_func.now(),
updated_at=sql_func.now(),
)
)
# 更新 GlobalModel 使用计数
db.execute(
update(GlobalModel)
.where(GlobalModel.name == model)
.values(usage_count=GlobalModel.usage_count + 1)
accounted, charge_applied = cls._finalize_usage_billing(
db,
usage=usage,
total_cost=total_cost,
status=status,
)
# 更新用户-模型调用次数计数器
cls._increment_user_model_usage(db, user, model)
if accounted:
# 更新 API 密钥使用量
if api_key:
values: dict[str, Any] = {
"total_requests": ApiKeyModel.total_requests + 1,
"last_used_at": sql_func.now(),
"updated_at": sql_func.now(),
}
if charge_applied:
values["total_cost_usd"] = ApiKeyModel.total_cost_usd + float(
total_cost_decimal
)
db.execute(update(ApiKeyModel).where(ApiKeyModel.id == api_key.id).values(**values))
# 更新 Provider 月度使用量(使用 actual_total_cost
if provider_id:
actual_total_cost = usage_params["actual_total_cost_usd"]
# 更新 GlobalModel 使用计数
db.execute(
update(Provider)
.where(Provider.id == provider_id)
.values(monthly_used_usd=Provider.monthly_used_usd + actual_total_cost)
update(GlobalModel)
.where(GlobalModel.name == model)
.values(usage_count=GlobalModel.usage_count + 1)
)
# 结算标记record_usage_with_custom_cost 写入/更新的 Usage 通常为终态记录
if status not in ("pending", "streaming"):
usage.billing_status = "settled"
usage.finalized_at = datetime.now(timezone.utc)
# 更新用户-模型调用次数计数器
cls._increment_user_model_usage(db, user, model)
# 更新 Provider 月度使用量Provider 端真实成本,无论钱包是否扣费)
if provider_id:
actual_total_cost = float(usage_params["actual_total_cost_usd"])
db.execute(
update(Provider)
.where(Provider.id == provider_id)
.values(monthly_used_usd=Provider.monthly_used_usd + actual_total_cost)
)
dispatch_codex_quota_sync_from_response_headers(
provider_api_key_id=provider_api_key_id,
@@ -770,7 +774,6 @@ class UsageRecordingMixin(UsageBillingIntegrationMixin):
from src.models.database import ApiKey as ApiKeyModel
from src.models.database import GlobalModel
from src.models.database import User as UserModel
# 分离需要更新和需要新建的记录
request_ids = [r.get("request_id") for r in records if r.get("request_id")]
@@ -782,15 +785,17 @@ class UsageRecordingMixin(UsageBillingIntegrationMixin):
# 查询已存在的 Usage 记录(包括 pending/streaming 状态)
from sqlalchemy.orm import selectinload
existing_records = (
existing_query = (
db.query(Usage)
.options(
selectinload(Usage.user),
selectinload(Usage.api_key),
)
.filter(Usage.request_id.in_(request_ids))
.all()
)
if hasattr(existing_query, "with_for_update"):
existing_query = existing_query.with_for_update()
existing_records = existing_query.all()
existing_usages = {u.request_id: u for u in existing_records}
for record in records:
@@ -826,7 +831,6 @@ class UsageRecordingMixin(UsageBillingIntegrationMixin):
)
usages: list[Usage] = []
user_costs: dict[str, float] = defaultdict(float) # user_id -> total_cost
apikey_stats: dict[str, dict[str, Any]] = defaultdict(
lambda: {"requests": 0, "cost": 0.0, "is_standalone": False}
)
@@ -858,6 +862,7 @@ class UsageRecordingMixin(UsageBillingIntegrationMixin):
skipped_count = 0
updated_count = 0
inserted_count = 0
total_count = len(all_records)
# 辅助函数:构建 UsageRecordParams
@@ -949,7 +954,6 @@ class UsageRecordingMixin(UsageBillingIntegrationMixin):
insert_results = prepared_results[len(update_params_list) :]
finalized_at = datetime.now(timezone.utc)
terminal_statuses = {"completed", "failed", "cancelled"}
# 1. 处理需要更新的记录
for i, (record, request_id, params) in enumerate(update_params_list):
@@ -965,42 +969,40 @@ class UsageRecordingMixin(UsageBillingIntegrationMixin):
# 更新已存在的 Usage 记录
cls._update_existing_usage(existing_usage, usage_params, record.get("target_model"))
# 结算标记pending -> settled幂等闸门由 prefilter 控制)
if (
usage_params.get("status") in terminal_statuses
and getattr(existing_usage, "billing_status", None) == "pending"
):
existing_usage.billing_status = "settled"
if getattr(existing_usage, "finalized_at", None) is None:
existing_usage.finalized_at = finalized_at
accounted, charge_applied = cls._finalize_usage_billing(
db,
usage=existing_usage,
total_cost=total_cost,
status=usage_params.get("status"),
finalized_at=finalized_at,
)
usages.append(existing_usage)
updated_count += 1
# 聚合统计
model_name = record.get("model") or "unknown"
model_counts[model_name] += 1
if user:
user_model_counts[(str(user.id), model_name)] += 1
if accounted:
model_name = record.get("model") or "unknown"
model_counts[model_name] += 1
if user:
user_model_counts[(str(user.id), model_name)] += 1
provider_id = record.get("provider_id")
if provider_id:
actual_cost = usage_params.get("actual_total_cost_usd", 0)
provider_costs[provider_id] += actual_cost
provider_id = record.get("provider_id")
if charge_applied and provider_id:
actual_cost = usage_params.get("actual_total_cost_usd", 0)
provider_costs[provider_id] += actual_cost
if user and not (api_key and api_key.is_standalone):
user_costs[str(user.id)] += total_cost
if api_key:
key_id = str(api_key.id)
apikey_stats[key_id]["requests"] += 1
if charge_applied:
apikey_stats[key_id]["cost"] += total_cost
apikey_stats[key_id]["is_standalone"] = api_key.is_standalone
if api_key:
key_id = str(api_key.id)
apikey_stats[key_id]["requests"] += 1
apikey_stats[key_id]["cost"] += total_cost
apikey_stats[key_id]["is_standalone"] = api_key.is_standalone
manual_nid = _extract_manual_proxy_node_id(record.get("metadata"))
if manual_nid:
proxy_node_counts[manual_nid] += 1
if record.get("status") == "failed":
proxy_node_failed[manual_nid] += 1
manual_nid = _extract_manual_proxy_node_id(record.get("metadata"))
if manual_nid:
proxy_node_counts[manual_nid] += 1
if record.get("status") == "failed":
proxy_node_failed[manual_nid] += 1
provider_api_key_id = record.get("provider_api_key_id")
response_headers = record.get("response_headers")
@@ -1016,10 +1018,7 @@ class UsageRecordingMixin(UsageBillingIntegrationMixin):
logger.warning("批量记录中更新失败: {}, request_id={}", e, request_id)
continue
# 2. 处理需要新建的记录(批量插入)
insert_mappings: list[dict[str, Any]] = []
insert_request_ids: list[str] = []
# 2. 处理需要新建的记录
for i, (record, request_id, params) in enumerate(insert_params_list):
try:
usage_params, total_cost, exc = insert_results[i]
@@ -1029,45 +1028,43 @@ class UsageRecordingMixin(UsageBillingIntegrationMixin):
user = params.user
api_key = params.api_key
# 终态记录:补齐 settled/finalized_at非终态确保 billing_status=pending
status = usage_params.get("status")
if status in terminal_statuses:
if usage_params.get("billing_status") in (None, "pending"):
usage_params["billing_status"] = "settled"
usage_params.setdefault("finalized_at", finalized_at)
elif usage_params.get("billing_status") is None:
usage_params["billing_status"] = "pending"
insert_mappings.append(usage_params)
insert_request_ids.append(request_id)
usage = Usage(**usage_params)
db.add(usage)
accounted, charge_applied = cls._finalize_usage_billing(
db,
usage=usage,
total_cost=total_cost,
status=usage_params.get("status"),
finalized_at=finalized_at,
)
usages.append(usage)
inserted_count += 1
# 聚合统计
model_name = record.get("model") or "unknown"
model_counts[model_name] += 1
if user:
user_model_counts[(str(user.id), model_name)] += 1
if accounted:
model_name = record.get("model") or "unknown"
model_counts[model_name] += 1
if user:
user_model_counts[(str(user.id), model_name)] += 1
provider_id = record.get("provider_id")
if provider_id:
actual_cost = usage_params.get("actual_total_cost_usd", 0)
provider_costs[provider_id] += actual_cost
provider_id = record.get("provider_id")
if charge_applied and provider_id:
actual_cost = usage_params.get("actual_total_cost_usd", 0)
provider_costs[provider_id] += actual_cost
# 用户统计(独立 Key 不计入创建者)
if user and not (api_key and api_key.is_standalone):
user_costs[str(user.id)] += total_cost
# API Key 统计
if api_key:
key_id = str(api_key.id)
apikey_stats[key_id]["requests"] += 1
if charge_applied:
apikey_stats[key_id]["cost"] += total_cost
apikey_stats[key_id]["is_standalone"] = api_key.is_standalone
# API Key 统计
if api_key:
key_id = str(api_key.id)
apikey_stats[key_id]["requests"] += 1
apikey_stats[key_id]["cost"] += total_cost
apikey_stats[key_id]["is_standalone"] = api_key.is_standalone
manual_nid = _extract_manual_proxy_node_id(record.get("metadata"))
if manual_nid:
proxy_node_counts[manual_nid] += 1
if record.get("status") == "failed":
proxy_node_failed[manual_nid] += 1
manual_nid = _extract_manual_proxy_node_id(record.get("metadata"))
if manual_nid:
proxy_node_counts[manual_nid] += 1
if record.get("status") == "failed":
proxy_node_failed[manual_nid] += 1
provider_api_key_id = record.get("provider_api_key_id")
response_headers = record.get("response_headers")
@@ -1083,24 +1080,6 @@ class UsageRecordingMixin(UsageBillingIntegrationMixin):
logger.warning("批量记录中跳过无效记录: {}, request_id={}", e, request_id)
continue
if insert_mappings:
try:
db.bulk_insert_mappings(Usage, insert_mappings)
# 仅用于保持返回值语义:将新建记录读回为 ORM 对象
inserted_records = (
db.query(Usage).filter(Usage.request_id.in_(insert_request_ids)).all()
)
inserted_map = {u.request_id: u for u in inserted_records}
for rid in insert_request_ids:
inserted_usage = inserted_map.get(rid)
if inserted_usage is not None:
usages.append(inserted_usage)
except Exception as e:
logger.error("批量插入 Usage 记录时出错: {}", e)
db.rollback()
raise
# 统计跳过的记录,失败率超过 10% 时提升日志级别
if skipped_count > 0:
skip_ratio = skipped_count / total_count if total_count > 0 else 0
@@ -1152,47 +1131,22 @@ class UsageRecordingMixin(UsageBillingIntegrationMixin):
db.execute(
update(Provider)
.where(Provider.id == provider_id)
.values(monthly_used_usd=Provider.monthly_used_usd + cost)
)
# 批量更新用户使用量
for user_id, cost in user_costs.items():
if cost > 0:
db.execute(
update(UserModel)
.where(UserModel.id == user_id)
.values(
used_usd=UserModel.used_usd + cost,
total_usd=UserModel.total_usd + cost,
updated_at=sql_func.now(),
)
.values(monthly_used_usd=Provider.monthly_used_usd + float(cost))
)
# 批量更新 API Key 统计
for key_id, stats in apikey_stats.items():
if stats["is_standalone"]:
db.execute(
update(ApiKeyModel)
.where(ApiKeyModel.id == key_id)
.values(
total_requests=ApiKeyModel.total_requests + stats["requests"],
total_cost_usd=ApiKeyModel.total_cost_usd + stats["cost"],
balance_used_usd=ApiKeyModel.balance_used_usd + stats["cost"],
last_used_at=sql_func.now(),
updated_at=sql_func.now(),
)
)
else:
db.execute(
update(ApiKeyModel)
.where(ApiKeyModel.id == key_id)
.values(
total_requests=ApiKeyModel.total_requests + stats["requests"],
total_cost_usd=ApiKeyModel.total_cost_usd + stats["cost"],
last_used_at=sql_func.now(),
updated_at=sql_func.now(),
)
db.execute(
update(ApiKeyModel)
.where(ApiKeyModel.id == key_id)
.values(
total_requests=ApiKeyModel.total_requests + stats["requests"],
total_cost_usd=ApiKeyModel.total_cost_usd
+ float(to_money_decimal(stats["cost"])),
last_used_at=sql_func.now(),
updated_at=sql_func.now(),
)
)
# 批量更新手动代理节点请求计数
_increment_proxy_node_requests(db, proxy_node_counts, proxy_node_failed)
@@ -1208,7 +1162,6 @@ class UsageRecordingMixin(UsageBillingIntegrationMixin):
# 单次提交所有更改
try:
db.commit()
inserted_count = len(insert_mappings)
total_written = updated_count + inserted_count
if updated_count > 0:
logger.debug("批量记录成功: 更新 {} 条, 新建 {}", updated_count, inserted_count)

View File

@@ -30,7 +30,6 @@ class ApiKeyService:
concurrent_limit: int = 5,
expire_days: int | None = None,
expires_at: datetime | None = None, # 直接传入过期时间,优先于 expire_days
initial_balance_usd: float | None = None,
is_standalone: bool = False,
auto_delete_on_expiry: bool = False,
) -> tuple[ApiKey, str]:
@@ -47,7 +46,6 @@ class ApiKeyService:
concurrent_limit: 并发限制
expire_days: 过期天数None = 永不过期
expires_at: 直接指定过期时间,优先于 expire_days
initial_balance_usd: 初始余额USD仅用于独立KeyNone = 无限制
is_standalone: 是否为独立余额Key仅管理员可创建
auto_delete_on_expiry: 过期后是否自动删除True=物理删除False=仅禁用)
"""
@@ -74,8 +72,6 @@ class ApiKeyService:
rate_limit=rate_limit,
concurrent_limit=concurrent_limit,
expires_at=final_expires_at,
balance_used_usd=0.0,
current_balance_usd=initial_balance_usd, # 直接使用初始余额None = 无限制
is_standalone=is_standalone,
auto_delete_on_expiry=auto_delete_on_expiry,
is_active=True,
@@ -87,7 +83,7 @@ class ApiKeyService:
logger.info(
f"创建API密钥: 用户ID {user_id}, 密钥名 {api_key.name}, "
f"独立Key={is_standalone}, 初始余额={initial_balance_usd}"
f"独立Key={is_standalone}"
)
return api_key, key # 返回密钥对象和明文密钥
@@ -143,7 +139,6 @@ class ApiKeyService:
"concurrent_limit",
"is_active",
"expires_at",
"balance_limit_usd",
"auto_delete_on_expiry",
]
@@ -188,48 +183,6 @@ class ApiKeyService:
logger.info(f"删除API密钥: ID {key_id}")
return True
@staticmethod
def get_remaining_balance(api_key: ApiKey) -> float | None:
"""计算剩余余额仅用于独立Key
Returns:
剩余余额None 表示无限制或非独立Key
"""
if not api_key.is_standalone:
return None
if api_key.current_balance_usd is None:
return None
# 剩余余额 = 当前余额 - 已使用余额
remaining = api_key.current_balance_usd - (api_key.balance_used_usd or 0)
return max(0, remaining) # 不能为负数
@staticmethod
def check_balance(api_key: ApiKey) -> tuple[bool, float | None]:
"""检查余额限制仅用于独立Key
Returns:
(is_allowed, remaining_balance): 是否允许请求剩余余额None表示无限制
"""
if not api_key.is_standalone:
# 非独立Key不检查余额
return True, None
# 使用新的预付费模式: current_balance_usd
if api_key.current_balance_usd is None:
# 无余额限制
return True, None
# 使用统一的余额计算方法
remaining = ApiKeyService.get_remaining_balance(api_key)
is_allowed = remaining > 0 if remaining is not None else True
if not is_allowed:
logger.warning(f"API密钥余额不足: Key ID {api_key.id}, " f"剩余余额 ${remaining:.4f}")
return is_allowed, remaining
@staticmethod
def check_rate_limit(db: Session, api_key: ApiKey, window_minutes: int = 1) -> tuple[bool, int]:
"""检查速率限制
@@ -263,57 +216,6 @@ class ApiKeyService:
return is_allowed, api_key.rate_limit - request_count
@staticmethod
def add_balance(db: Session, key_id: str, amount_usd: float) -> ApiKey | None:
"""为独立余额Key调整余额
Args:
db: 数据库会话
key_id: API Key ID
amount_usd: 要调整的余额金额USD正数为增加负数为扣除
Returns:
更新后的API Key对象如果Key不存在或不是独立Key则返回None
"""
api_key = db.query(ApiKey).filter(ApiKey.id == key_id).first()
if not api_key:
logger.warning(f"余额调整失败: Key ID {key_id} 不存在")
return None
if not api_key.is_standalone:
logger.warning(f"余额调整失败: Key ID {key_id} 不是独立余额Key")
return None
if amount_usd == 0:
logger.warning(f"余额调整失败: 调整金额不能为0当前值 ${amount_usd}")
return None
# 如果是扣除(负数),检查是否超过当前余额
if amount_usd < 0:
current = api_key.current_balance_usd or 0
if abs(amount_usd) > current:
logger.warning(
f"余额扣除失败: 扣除金额 ${abs(amount_usd):.4f} 超过当前余额 ${current:.4f}"
)
return None
# 调整当前余额
if api_key.current_balance_usd is None:
api_key.current_balance_usd = amount_usd if amount_usd > 0 else 0
else:
api_key.current_balance_usd = max(0, api_key.current_balance_usd + amount_usd)
api_key.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(api_key)
action = "增加" if amount_usd > 0 else "扣除"
logger.info(
f"余额调整成功: Key ID {key_id}, {action} ${abs(amount_usd):.4f}, "
f"新余额 ${api_key.current_balance_usd:.4f}"
)
return api_key
@staticmethod
def cleanup_expired_keys(db: Session, auto_delete: bool = False) -> int:
"""清理过期的API密钥

View File

@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session
from src.core.exceptions import NotFoundException
from src.core.logger import logger
from src.models.database import Provider, User, UserPreference
from src.services.wallet import WalletService
class PreferenceService:
@@ -98,6 +99,8 @@ class PreferenceService:
raise NotFoundException("User not found")
preferences = PreferenceService.get_or_create_preferences(db, user_id)
wallet = WalletService.get_wallet(db, user_id=user.id)
billing = WalletService.serialize_wallet_summary(wallet)
# 构建返回数据
user_data = {
@@ -125,12 +128,10 @@ class PreferenceService:
"announcements": preferences.announcement_notifications,
},
},
# 配额信息
"quota_usd": user.quota_usd,
"used_usd": user.used_usd,
"billing": billing,
"stats": {
"total_cost": user.used_usd,
"total_cost_all_time": user.total_usd,
"total_cost": billing["total_consumed"],
"total_cost_all_time": billing["total_consumed"],
"api_keys_count": len(user.api_keys),
},
}

View File

@@ -8,7 +8,7 @@ import asyncio
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import and_, func
from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session
from src.core.logger import logger
@@ -30,13 +30,14 @@ class UserService:
username: str,
password: str,
role: UserRole = UserRole.USER,
quota_usd: float | None = 10.0,
initial_gift_usd: float | None = 10.0,
unlimited: bool = False,
email_verified: bool = False,
allowed_providers: list[str] | None = None,
allowed_api_formats: list[str] | None = None,
allowed_models: list[str] | None = None,
) -> User:
"""创建新用户quota_usd 为 None 表示无限制email 为 None 表示无邮箱"""
"""创建新用户"""
# 验证邮箱格式(仅当提供邮箱时)
if email is not None:
@@ -66,7 +67,6 @@ class UserService:
email_verified=email_verified if email else False,
username=username,
role=role,
quota_usd=quota_usd,
is_active=True,
allowed_providers=allowed_providers,
allowed_api_formats=allowed_api_formats,
@@ -75,6 +75,18 @@ class UserService:
user.set_password(password)
db.add(user)
db.flush()
from src.services.wallet import WalletService
WalletService.initialize_user_wallet(
db,
user=user,
initial_gift_usd=initial_gift_usd,
unlimited=unlimited,
description="用户初始赠款",
)
db.commit() # 立即提交事务,释放数据库锁
db.refresh(user)
@@ -91,7 +103,8 @@ class UserService:
password: str,
api_key_name: str = "默认密钥",
role: UserRole = UserRole.USER,
quota_usd: float | None = 10.0,
initial_gift_usd: float | None = 10.0,
unlimited: bool = False,
concurrent_limit: int = 5,
) -> tuple[User, ApiKey]:
"""
@@ -104,7 +117,8 @@ class UserService:
password: 密码
api_key_name: API密钥名称
role: 用户角色
quota_usd: USD配额None 表示无限制
initial_gift_usd: 初始赠款USD
unlimited: 是否无限制
concurrent_limit: 并发限制
Returns:
@@ -115,7 +129,13 @@ class UserService:
"""
# 创建用户
user = UserService.create_user(
db=db, email=email, username=username, password=password, role=role, quota_usd=quota_usd
db=db,
email=email,
username=username,
password=password,
role=role,
initial_gift_usd=initial_gift_usd,
unlimited=unlimited,
)
# 导入API密钥服务避免循环导入
@@ -173,7 +193,9 @@ class UserService:
if is_active is not None:
query = query.filter(User.is_active == is_active)
return query.offset(skip).limit(limit).all()
return (
query.order_by(User.created_at.desc(), User.id.desc()).offset(skip).limit(limit).all()
)
@staticmethod
@transactional()
@@ -187,7 +209,6 @@ class UserService:
updatable_fields = [
"email",
"username",
"quota_usd",
"is_active",
"role",
# 访问限制字段
@@ -198,7 +219,6 @@ class UserService:
# 允许设置为 None 的字段(表示无限制)
nullable_fields = [
"quota_usd",
"allowed_providers",
"allowed_api_formats",
"allowed_models",
@@ -237,16 +257,19 @@ class UserService:
"""删除用户(硬删除)
删除流程:
1. 手动删除关联的子记录(避免 SQLAlchemy ORM 与数据库 CASCADE 冲突)
2. 删除用户记录
3. 历史 Usage 记录保留user_id 会被数据库设为 NULL
4. 新用户注册时会有新的 UUID看不到旧用户的记录
1. 检查未完结账务,阻止删除
2. 手动删除 ORM cascade 冲突的子记录
3. 删除用户记录
4. 财务记录Wallet/PaymentOrder/RefundRequest/WalletTransaction
Usage 记录保留,外键 SET NULL由自动清理策略统一回收
"""
from src.models.database import (
AnnouncementRead,
ApiKey,
PaymentOrder,
RefundRequest,
UserPreference,
UserQuota,
Wallet,
)
user = db.query(User).filter(User.id == user_id).first()
@@ -256,16 +279,53 @@ class UserService:
# 记录删除信息用于日志
email = user.email
# 删除前阻断未完结账务,避免删除导致资金状态不一致。
wallet_ids = [
wallet_id
for (wallet_id,) in (
db.query(Wallet.id)
.outerjoin(ApiKey, Wallet.api_key_id == ApiKey.id)
.filter(or_(Wallet.user_id == user_id, ApiKey.user_id == user_id))
.all()
)
]
if wallet_ids:
pending_refund_count = (
db.query(RefundRequest)
.filter(
RefundRequest.wallet_id.in_(wallet_ids),
RefundRequest.status.in_(["pending_approval", "approved", "processing"]),
)
.count()
)
if pending_refund_count > 0:
raise ValueError("用户存在未完结退款,禁止删除")
pending_order_count = (
db.query(PaymentOrder)
.filter(
PaymentOrder.wallet_id.in_(wallet_ids),
PaymentOrder.status.in_(["pending", "paid"]),
)
.count()
)
if pending_order_count > 0:
raise ValueError("用户存在未完结充值订单,禁止删除")
# 手动删除子记录,避免 SQLAlchemy 的 ORM cascade 与数据库 CASCADE 冲突
# 这些表的数据库外键已经设置了 ON DELETE CASCADE但 SQLAlchemy 会先尝试 UPDATE 设置为 NULL
# 所以我们手动删除来避免这个问题
# UserPreference/AnnouncementRead 的数据库外键 ON DELETE CASCADE
# 但 SQLAlchemy 会先尝试 UPDATE SET NULL 导致冲突)
db.query(UserPreference).filter(UserPreference.user_id == user_id).delete(
synchronize_session=False
)
db.query(UserQuota).filter(UserQuota.user_id == user_id).delete(synchronize_session=False)
db.query(AnnouncementRead).filter(AnnouncementRead.user_id == user_id).delete(
synchronize_session=False
)
# 财务记录Wallet/WalletTransaction/PaymentOrder/RefundRequest/PaymentCallback
# 和 Usage 记录全部保留,数据库外键 SET NULL 自动断开关联,
# 由自动清理策略统一回收。
api_key_count = int(
db.query(func.count(ApiKey.id)).filter(ApiKey.user_id == user_id).scalar() or 0
)
@@ -326,29 +386,6 @@ class UserService:
logger.info(f"密码更改成功: 用户ID {user_id}")
return True, "密码更改成功"
@staticmethod
def update_user_quota(
db: Session,
user_id: str,
quota_usd: float | None = None,
) -> User | None:
"""更新用户配额"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
return None
if quota_usd is not None:
user.quota_usd = quota_usd
db.commit()
db.refresh(user)
# 清除用户缓存
asyncio.create_task(UserCacheService.invalidate_user_cache(user.id, user.email))
logger.debug(f"更新用户配额: {user.email} (USD: {quota_usd})")
return user
@staticmethod
def get_user_usage_stats(
db: Session,

View File

@@ -0,0 +1,3 @@
from src.services.wallet.service import WalletAccessResult, WalletService
__all__ = ["WalletAccessResult", "WalletService"]

View File

@@ -0,0 +1,942 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from decimal import Decimal
from typing import Literal
from uuid import uuid4
from sqlalchemy import func
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from src.core.enums import UserRole
from src.models.database import (
ApiKey,
PaymentOrder,
RefundRequest,
Usage,
User,
Wallet,
WalletTransaction,
)
from src.services.billing.precision import to_money_decimal
WalletCategory = Literal["recharge", "gift", "adjust", "refund"]
WalletBalanceBucket = Literal["recharge", "gift"]
REASON_TOPUP_ADMIN_MANUAL = "topup_admin_manual"
REASON_TOPUP_GATEWAY = "topup_gateway"
REASON_TOPUP_CARD_CODE = "topup_card_code"
REASON_GIFT_INITIAL = "gift_initial"
REASON_GIFT_CAMPAIGN = "gift_campaign"
REASON_GIFT_EXPIRE_RECLAIM = "gift_expire_reclaim"
REASON_ADJUST_ADMIN = "adjust_admin"
REASON_ADJUST_SYSTEM = "adjust_system"
REASON_REFUND_OUT = "refund_out"
REASON_REFUND_REVERT = "refund_revert"
@dataclass(slots=True)
class WalletAccessResult:
allowed: bool
remaining: Decimal | None
message: str
wallet: Wallet | None = None
class WalletService:
"""统一钱包服务。"""
@staticmethod
def get_limit_mode(wallet: Wallet | None) -> str:
if wallet is None:
return "finite"
limit_mode = getattr(wallet, "limit_mode", None)
if limit_mode in {"finite", "unlimited"}:
return str(limit_mode)
return "finite"
@classmethod
def is_unlimited_wallet(cls, wallet: Wallet | None) -> bool:
return cls.get_limit_mode(wallet) == "unlimited"
@classmethod
def get_recharge_balance_value(cls, wallet: Wallet | None) -> Decimal:
if wallet is None:
return Decimal("0")
return to_money_decimal(wallet.balance)
@classmethod
def get_gift_balance_value(cls, wallet: Wallet | None) -> Decimal:
if wallet is None:
return Decimal("0")
return to_money_decimal(getattr(wallet, "gift_balance", None))
@classmethod
def get_spendable_balance_value(cls, wallet: Wallet | None) -> Decimal:
return cls.get_recharge_balance_value(wallet) + cls.get_gift_balance_value(wallet)
@classmethod
def get_refundable_balance_value(cls, wallet: Wallet | None) -> Decimal:
# 赠款余额不可退款,仅充值余额可退。
return cls.get_recharge_balance_value(wallet)
@classmethod
def serialize_wallet_summary(cls, wallet: Wallet | None) -> dict[str, object]:
recharge_balance = cls.get_recharge_balance_value(wallet)
gift_balance = cls.get_gift_balance_value(wallet)
spendable_balance = recharge_balance + gift_balance
limit_mode = cls.get_limit_mode(wallet)
return {
"id": wallet.id if wallet else None,
"balance": float(spendable_balance),
"recharge_balance": float(recharge_balance),
"gift_balance": float(gift_balance),
"refundable_balance": float(recharge_balance),
"currency": wallet.currency if wallet else "USD",
"status": wallet.status if wallet else "active",
"limit_mode": limit_mode,
"unlimited": limit_mode == "unlimited",
"total_recharged": float(wallet.total_recharged or 0) if wallet else 0.0,
"total_consumed": float(wallet.total_consumed or 0) if wallet else 0.0,
"total_refunded": float(wallet.total_refunded or 0) if wallet else 0.0,
"total_adjusted": float(wallet.total_adjusted or 0) if wallet else 0.0,
"updated_at": wallet.updated_at if wallet else None,
}
@staticmethod
def _build_order_no(prefix: str) -> str:
ts = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S%f")
return f"{prefix}_{ts}_{uuid4().hex[:12]}"
@classmethod
def initialize_user_wallet(
cls,
db: Session,
*,
user: User,
initial_gift_usd: Decimal | float | int | str | None,
unlimited: bool = False,
description: str = "用户初始赠款",
) -> Wallet | None:
"""初始化用户钱包,并按需要写入初始赠款。"""
if not user.id:
return None
wallet = cls.get_wallet(db, user_id=user.id)
if wallet is None:
wallet = Wallet(
user_id=user.id,
balance=Decimal("0"),
gift_balance=Decimal("0"),
total_recharged=Decimal("0"),
total_consumed=Decimal("0"),
total_refunded=Decimal("0"),
total_adjusted=Decimal("0"),
limit_mode="unlimited" if unlimited else "finite",
currency="USD",
status="active",
)
db.add(wallet)
db.flush()
else:
wallet.limit_mode = "unlimited" if unlimited else "finite"
gift_amount = to_money_decimal(initial_gift_usd)
if not unlimited and gift_amount > Decimal("0"):
cls.create_wallet_transaction(
db,
wallet=wallet,
category="gift",
reason_code=REASON_GIFT_INITIAL,
amount=gift_amount,
balance_type="gift",
link_type="system_task",
link_id=user.id,
description=description,
)
return wallet
@classmethod
def initialize_api_key_wallet(
cls,
db: Session,
*,
api_key: ApiKey,
initial_balance_usd: Decimal | float | int | str | None,
unlimited: bool = False,
operator_id: str | None = None,
description: str = "初始调账",
) -> Wallet | None:
"""初始化独立 Key 钱包,并按需执行初始调账。
设计目标:
- 初始化语义与用户钱包保持一致(均由 WalletService 统一入口完成)
- 独立 Key 不支持充值,余额变动统一通过调账流水实现
"""
if not api_key.id:
return None
wallet = cls.get_wallet(db, api_key_id=api_key.id)
if wallet is None:
wallet = Wallet(
api_key_id=api_key.id,
balance=Decimal("0"),
gift_balance=Decimal("0"),
total_recharged=Decimal("0"),
total_consumed=Decimal("0"),
total_refunded=Decimal("0"),
total_adjusted=Decimal("0"),
limit_mode="unlimited" if unlimited else "finite",
currency="USD",
status="active",
)
db.add(wallet)
db.flush()
else:
wallet.limit_mode = "unlimited" if unlimited else "finite"
initial_amount = to_money_decimal(initial_balance_usd)
if not unlimited and initial_amount > Decimal("0"):
cls.create_wallet_transaction(
db,
wallet=wallet,
category="adjust",
reason_code=REASON_ADJUST_SYSTEM,
amount=initial_amount,
balance_type="recharge",
link_type="system_task",
link_id=api_key.id,
operator_id=operator_id,
description=description,
)
return wallet
@classmethod
def get_wallet(
cls,
db: Session,
*,
user_id: str | None = None,
api_key_id: str | None = None,
) -> Wallet | None:
if api_key_id:
wallet = db.query(Wallet).filter(Wallet.api_key_id == api_key_id).first()
if wallet is not None:
return wallet
if user_id:
return db.query(Wallet).filter(Wallet.user_id == user_id).first()
return None
@classmethod
def get_or_create_wallet(
cls,
db: Session,
*,
user: User | None = None,
api_key: ApiKey | None = None,
user_id: str | None = None,
api_key_id: str | None = None,
) -> Wallet | None:
if user is None and user_id:
user = db.query(User).filter(User.id == user_id).first()
if api_key is None and api_key_id:
api_key = db.query(ApiKey).filter(ApiKey.id == api_key_id).first()
owner_user_id = user.id if user else user_id
owner_api_key_id = api_key.id if api_key else api_key_id
# owner 解析规则:
# - 独立 Key: 归属 API Key 钱包
# - 普通 Key + 用户: 归属用户钱包(避免 user_id/api_key_id 同时写入)
# - 仅提供 API Key: 归属 API Key 钱包
api_key_is_standalone = bool(getattr(api_key, "is_standalone", False)) if api_key else False
if owner_user_id is not None and not api_key_is_standalone:
owner_api_key_id = None
elif owner_api_key_id is not None:
owner_user_id = None
wallet = cls.get_wallet(db, user_id=owner_user_id, api_key_id=owner_api_key_id)
if wallet:
return wallet
if owner_user_id is None and owner_api_key_id is None:
return None
bootstrap = Wallet(
user_id=owner_user_id,
api_key_id=owner_api_key_id,
balance=Decimal("0"),
gift_balance=Decimal("0"),
total_recharged=Decimal("0"),
total_consumed=Decimal("0"),
total_refunded=Decimal("0"),
total_adjusted=Decimal("0"),
limit_mode="finite",
currency="USD",
status="active",
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
try:
with db.begin_nested():
db.add(bootstrap)
db.flush()
return bootstrap
except IntegrityError:
# 并发创建时可能触发唯一约束,回查已创建的钱包并复用。
wallet = cls.get_wallet(db, user_id=owner_user_id, api_key_id=owner_api_key_id)
if wallet is not None:
return wallet
raise
@classmethod
def check_request_allowed(
cls,
db: Session,
*,
user: User | None,
api_key: ApiKey | None = None,
) -> WalletAccessResult:
if user and user.role == UserRole.ADMIN:
return WalletAccessResult(True, None, "OK", None)
wallet = cls.get_or_create_wallet(db, user=user, api_key=api_key)
if wallet is None:
return WalletAccessResult(False, Decimal("0"), "钱包不存在", None)
remaining = cls.get_spendable_balance_value(wallet)
recharge_balance = cls.get_recharge_balance_value(wallet)
if wallet.status != "active":
return WalletAccessResult(False, remaining, "钱包不可用", wallet)
# 充值余额为负视为欠费,禁止继续消费(即使总可用余额仍为正)。
if recharge_balance < Decimal("0"):
return WalletAccessResult(False, recharge_balance, "钱包欠费,请先充值", wallet)
if cls.is_unlimited_wallet(wallet):
return WalletAccessResult(True, None, "OK", wallet)
if remaining <= Decimal("0"):
return WalletAccessResult(False, remaining, "钱包余额不足", wallet)
return WalletAccessResult(True, remaining, "OK", wallet)
@classmethod
def get_balance_snapshot(
cls,
db: Session,
*,
user: User | None,
api_key: ApiKey | None = None,
) -> Decimal | None:
wallet = cls.get_or_create_wallet(db, user=user, api_key=api_key)
if wallet is None:
return None
recharge_balance = cls.get_recharge_balance_value(wallet)
if recharge_balance < Decimal("0"):
return recharge_balance
if cls.is_unlimited_wallet(wallet):
return None
return cls.get_spendable_balance_value(wallet)
@classmethod
def _resolve_wallet_for_usage(cls, db: Session, usage: Usage) -> Wallet | None:
if usage.wallet_id:
wallet = db.query(Wallet).filter(Wallet.id == usage.wallet_id).first()
if wallet:
return wallet
api_key = None
if usage.api_key_id:
api_key = db.query(ApiKey).filter(ApiKey.id == usage.api_key_id).first()
if api_key and api_key.is_standalone:
return cls.get_or_create_wallet(db, api_key=api_key)
if usage.user_id:
user = db.query(User).filter(User.id == usage.user_id).first()
return cls.get_or_create_wallet(db, user=user, api_key=api_key)
return None
@classmethod
def apply_usage_charge(
cls,
db: Session,
*,
usage: Usage,
amount_usd: Decimal | float | int | str,
) -> tuple[Decimal | None, Decimal | None]:
amount = to_money_decimal(amount_usd)
if amount <= Decimal("0"):
return None, None
wallet = cls._resolve_wallet_for_usage(db, usage)
if wallet is None:
return None, None
locked_wallet = (
db.query(Wallet).filter(Wallet.id == wallet.id).with_for_update().one_or_none()
)
if locked_wallet is None:
return None, None
before_recharge = cls.get_recharge_balance_value(locked_wallet)
before_gift = cls.get_gift_balance_value(locked_wallet)
before_total = before_recharge + before_gift
if cls.is_unlimited_wallet(locked_wallet):
locked_wallet.total_consumed = to_money_decimal(locked_wallet.total_consumed) + amount
locked_wallet.updated_at = datetime.now(timezone.utc)
usage.wallet_id = locked_wallet.id
usage.wallet_balance_before = before_total
usage.wallet_balance_after = before_total
usage.wallet_recharge_balance_before = before_recharge
usage.wallet_recharge_balance_after = before_recharge
usage.wallet_gift_balance_before = before_gift
usage.wallet_gift_balance_after = before_gift
return before_total, before_total
# 赠款优先扣减:赠款不可退款,优先消耗可避免与充值余额混淆。
gift_deduction = min(max(before_gift, Decimal("0")), amount)
recharge_deduction = amount - gift_deduction
after_gift = before_gift - gift_deduction
after_recharge = before_recharge - recharge_deduction
after_total = after_recharge + after_gift
locked_wallet.balance = after_recharge
locked_wallet.gift_balance = after_gift
locked_wallet.total_consumed = to_money_decimal(locked_wallet.total_consumed) + amount
locked_wallet.updated_at = datetime.now(timezone.utc)
usage.wallet_id = locked_wallet.id
usage.wallet_balance_before = before_total
usage.wallet_balance_after = after_total
usage.wallet_recharge_balance_before = before_recharge
usage.wallet_recharge_balance_after = after_recharge
usage.wallet_gift_balance_before = before_gift
usage.wallet_gift_balance_after = after_gift
return before_total, after_total
@classmethod
def set_wallet_limit_mode(
cls,
db: Session,
*,
wallet: Wallet,
limit_mode: Literal["finite", "unlimited"],
) -> Wallet:
if limit_mode not in {"finite", "unlimited"}:
raise ValueError("limit_mode must be finite or unlimited")
locked_wallet = (
db.query(Wallet).filter(Wallet.id == wallet.id).with_for_update().one_or_none()
)
if locked_wallet is None:
raise ValueError("wallet not found")
locked_wallet.limit_mode = limit_mode
locked_wallet.updated_at = datetime.now(timezone.utc)
db.flush()
return locked_wallet
@classmethod
def create_wallet_transaction(
cls,
db: Session,
*,
wallet: Wallet,
category: WalletCategory,
reason_code: str,
amount: Decimal | float | int | str,
balance_type: WalletBalanceBucket | None = None,
link_type: str | None = None,
link_id: str | None = None,
operator_id: str | None = None,
description: str | None = None,
) -> WalletTransaction:
if category not in {"recharge", "gift", "adjust", "refund"}:
raise ValueError("category must be recharge/gift/adjust/refund")
if not reason_code:
raise ValueError("reason_code is required")
locked_wallet = (
db.query(Wallet).filter(Wallet.id == wallet.id).with_for_update().one_or_none()
)
if locked_wallet is None:
raise ValueError("wallet not found")
delta = to_money_decimal(amount)
bucket = balance_type
if bucket is None:
bucket = "gift" if category == "gift" else "recharge"
before_recharge = cls.get_recharge_balance_value(locked_wallet)
before_gift = cls.get_gift_balance_value(locked_wallet)
before_total = before_recharge + before_gift
after_recharge = before_recharge
after_gift = before_gift
if bucket == "recharge":
after_recharge = before_recharge + delta
else:
after_gift = before_gift + delta
after_total = after_recharge + after_gift
if category == "refund" and bucket != "recharge":
raise ValueError("refund transaction must use recharge balance")
if category == "refund" and delta < Decimal("0") and after_recharge < Decimal("0"):
raise ValueError("refund amount exceeds refundable recharge balance")
if bucket == "gift" and delta < Decimal("0") and after_gift < Decimal("0"):
raise ValueError("gift balance cannot be negative")
if bucket == "gift" and locked_wallet.api_key_id is not None:
raise ValueError("api key wallet does not support gift balance")
locked_wallet.balance = after_recharge
locked_wallet.gift_balance = after_gift
locked_wallet.updated_at = datetime.now(timezone.utc)
if category == "recharge":
locked_wallet.total_recharged = to_money_decimal(locked_wallet.total_recharged) + delta
elif category == "refund":
# refund_out 为负值累计退款增加refund_revert 为正值(累计退款回退)。
next_total_refunded = to_money_decimal(locked_wallet.total_refunded) - delta
locked_wallet.total_refunded = max(next_total_refunded, Decimal("0"))
elif category in {"gift", "adjust"}:
locked_wallet.total_adjusted = to_money_decimal(locked_wallet.total_adjusted) + delta
tx = WalletTransaction(
wallet_id=locked_wallet.id,
category=category,
reason_code=reason_code,
amount=delta,
balance_before=before_total,
balance_after=after_total,
recharge_balance_before=before_recharge,
recharge_balance_after=after_recharge,
gift_balance_before=before_gift,
gift_balance_after=after_gift,
link_type=link_type,
link_id=link_id,
operator_id=operator_id,
description=description,
)
db.add(tx)
db.flush()
return tx
@classmethod
def create_manual_recharge_order(
cls,
db: Session,
*,
wallet: Wallet,
amount_usd: Decimal | float | int | str,
payment_method: str = "admin_manual",
operator_id: str | None = None,
description: str | None = None,
reason_code: str | None = None,
link_type: str = "payment_order",
link_id: str | None = None,
) -> PaymentOrder:
amount = to_money_decimal(amount_usd)
if amount <= Decimal("0"):
raise ValueError("recharge amount must be positive")
if wallet.api_key_id is not None:
raise ValueError("api key wallet does not support recharge, use adjust instead")
now = datetime.now(timezone.utc)
order = PaymentOrder(
order_no=cls._build_order_no("po"),
wallet_id=wallet.id,
user_id=wallet.user_id,
amount_usd=amount,
refunded_amount_usd=Decimal("0"),
refundable_amount_usd=amount,
payment_method=payment_method,
status="credited",
paid_at=now,
credited_at=now,
gateway_response={
"source": "manual",
"operator_id": operator_id,
"description": description,
},
)
db.add(order)
db.flush()
tx_reason = reason_code
if tx_reason is None:
if payment_method in {"card_code", "gift_code", "card_recharge"}:
tx_reason = REASON_TOPUP_CARD_CODE
else:
tx_reason = REASON_TOPUP_ADMIN_MANUAL
cls.create_wallet_transaction(
db,
wallet=wallet,
category="recharge",
reason_code=tx_reason,
amount=amount,
balance_type="recharge",
link_type=link_type,
link_id=link_id or order.id,
operator_id=operator_id,
description=description or "管理员充值",
)
return order
@classmethod
def admin_adjust_balance(
cls,
db: Session,
*,
wallet: Wallet,
amount_usd: Decimal | float | int | str,
balance_type: Literal["recharge", "gift"] = "recharge",
operator_id: str | None = None,
description: str | None = None,
) -> WalletTransaction:
amount = to_money_decimal(amount_usd)
if amount == Decimal("0"):
raise ValueError("adjust amount must not be zero")
if balance_type not in {"recharge", "gift"}:
raise ValueError("balance_type must be recharge or gift")
if balance_type == "gift" and wallet.api_key_id is not None:
raise ValueError("api key wallet does not support gift balance")
# 正向调账:加给谁就加给谁,不做抵充。
if amount > Decimal("0"):
return cls.create_wallet_transaction(
db,
wallet=wallet,
category="adjust",
reason_code=REASON_ADJUST_ADMIN,
amount=amount,
balance_type=balance_type,
link_type="admin_action",
link_id=wallet.id,
operator_id=operator_id,
description=description or "管理员调账",
)
# 负向调账:先扣所选账户,再扣另一账户;若仍不足,继续计入充值余额(可为负)。
locked_wallet = (
db.query(Wallet).filter(Wallet.id == wallet.id).with_for_update().one_or_none()
)
if locked_wallet is None:
raise ValueError("wallet not found")
before_recharge = cls.get_recharge_balance_value(locked_wallet)
before_gift = cls.get_gift_balance_value(locked_wallet)
before_total = before_recharge + before_gift
after_recharge = before_recharge
after_gift = before_gift
remaining = -amount
def consume_positive_bucket(
balance: Decimal, to_consume: Decimal
) -> tuple[Decimal, Decimal]:
if to_consume <= Decimal("0"):
return balance, Decimal("0")
available = max(balance, Decimal("0"))
consumed = min(available, to_consume)
return balance - consumed, to_consume - consumed
if balance_type == "gift":
after_gift, remaining = consume_positive_bucket(after_gift, remaining)
after_recharge, remaining = consume_positive_bucket(after_recharge, remaining)
else:
after_recharge, remaining = consume_positive_bucket(after_recharge, remaining)
after_gift, remaining = consume_positive_bucket(after_gift, remaining)
if remaining > Decimal("0"):
after_recharge = after_recharge - remaining
if after_gift < Decimal("0"):
raise ValueError("gift balance cannot be negative")
after_total = after_recharge + after_gift
locked_wallet.balance = after_recharge
locked_wallet.gift_balance = after_gift
locked_wallet.updated_at = datetime.now(timezone.utc)
locked_wallet.total_adjusted = to_money_decimal(locked_wallet.total_adjusted) + amount
tx = WalletTransaction(
wallet_id=locked_wallet.id,
category="adjust",
reason_code=REASON_ADJUST_ADMIN,
amount=amount,
balance_before=before_total,
balance_after=after_total,
recharge_balance_before=before_recharge,
recharge_balance_after=after_recharge,
gift_balance_before=before_gift,
gift_balance_after=after_gift,
link_type="admin_action",
link_id=wallet.id,
operator_id=operator_id,
description=description or "管理员调账",
)
db.add(tx)
db.flush()
return tx
@classmethod
def _get_pending_refund_reserved_amount(
cls,
db: Session,
*,
wallet_id: str | None = None,
payment_order_id: str | None = None,
) -> Decimal:
query = db.query(func.coalesce(func.sum(RefundRequest.amount_usd), 0)).filter(
RefundRequest.status.in_(["pending_approval", "approved"])
)
if wallet_id is not None:
query = query.filter(RefundRequest.wallet_id == wallet_id)
if payment_order_id is not None:
query = query.filter(RefundRequest.payment_order_id == payment_order_id)
return to_money_decimal(query.scalar() or 0)
@classmethod
def create_refund_request(
cls,
db: Session,
*,
wallet: Wallet,
user_id: str | None,
amount_usd: Decimal | float | int | str,
refund_no: str,
source_type: str,
source_id: str | None,
refund_mode: str,
payment_order: PaymentOrder | None = None,
reason: str | None = None,
requested_by: str | None = None,
idempotency_key: str | None = None,
) -> RefundRequest:
amount = to_money_decimal(amount_usd)
if amount <= Decimal("0"):
raise ValueError("refund amount must be positive")
locked_wallet = (
db.query(Wallet).filter(Wallet.id == wallet.id).with_for_update().one_or_none()
)
if locked_wallet is None:
raise ValueError("wallet not found")
refundable_balance = cls.get_refundable_balance_value(locked_wallet)
reserved_wallet_amount = cls._get_pending_refund_reserved_amount(
db,
wallet_id=locked_wallet.id,
)
available_refundable_balance = refundable_balance - reserved_wallet_amount
if amount > available_refundable_balance:
raise ValueError("refund amount exceeds available refundable recharge balance")
locked_payment_order = None
if payment_order is not None:
locked_payment_order = (
db.query(PaymentOrder)
.filter(PaymentOrder.id == payment_order.id)
.with_for_update()
.one_or_none()
)
if locked_payment_order is None:
raise ValueError("payment order not found")
if locked_payment_order.wallet_id != locked_wallet.id:
raise ValueError("payment order does not belong to wallet")
if locked_payment_order.status != "credited":
raise ValueError("payment order is not refundable")
refundable_amount = to_money_decimal(locked_payment_order.refundable_amount_usd)
reserved_order_amount = cls._get_pending_refund_reserved_amount(
db,
payment_order_id=locked_payment_order.id,
)
available_refundable_amount = refundable_amount - reserved_order_amount
if amount > available_refundable_amount:
raise ValueError("refund amount exceeds available refundable amount")
refund = RefundRequest(
refund_no=refund_no,
wallet_id=locked_wallet.id,
user_id=user_id,
payment_order_id=locked_payment_order.id if locked_payment_order else None,
source_type=source_type,
source_id=source_id,
refund_mode=refund_mode,
amount_usd=amount,
status="pending_approval",
reason=reason,
requested_by=requested_by,
idempotency_key=idempotency_key,
)
db.add(refund)
db.flush()
return refund
@classmethod
def move_refund_to_processing(
cls,
db: Session,
*,
refund: RefundRequest,
operator_id: str | None = None,
) -> WalletTransaction:
locked_refund = (
db.query(RefundRequest)
.filter(RefundRequest.id == refund.id)
.with_for_update()
.one_or_none()
)
if locked_refund is None:
raise ValueError("refund not found")
if locked_refund.status not in {"approved", "pending_approval"}:
raise ValueError("refund status is not approvable")
locked_wallet = (
db.query(Wallet)
.filter(Wallet.id == locked_refund.wallet_id)
.with_for_update()
.one_or_none()
)
if locked_wallet is None:
raise ValueError("wallet not found")
payment_order = None
if locked_refund.payment_order_id:
payment_order = (
db.query(PaymentOrder)
.filter(PaymentOrder.id == locked_refund.payment_order_id)
.with_for_update()
.one_or_none()
)
if payment_order is None:
raise ValueError("payment order not found")
refund_amount = to_money_decimal(locked_refund.amount_usd)
refundable_amount = to_money_decimal(payment_order.refundable_amount_usd)
if refund_amount > refundable_amount:
raise ValueError("refund amount exceeds refundable amount")
tx = cls.create_wallet_transaction(
db,
wallet=locked_wallet,
category="refund",
reason_code=REASON_REFUND_OUT,
amount=-to_money_decimal(locked_refund.amount_usd),
balance_type="recharge",
link_type="refund_request",
link_id=locked_refund.id,
operator_id=operator_id,
description="退款占款",
)
if payment_order is not None:
delta = to_money_decimal(locked_refund.amount_usd)
payment_order.refunded_amount_usd = (
to_money_decimal(payment_order.refunded_amount_usd) + delta
)
payment_order.refundable_amount_usd = (
to_money_decimal(payment_order.refundable_amount_usd) - delta
)
locked_refund.status = "processing"
locked_refund.approved_by = operator_id
locked_refund.processed_by = operator_id
locked_refund.processed_at = datetime.now(timezone.utc)
locked_refund.updated_at = datetime.now(timezone.utc)
return tx
@classmethod
def fail_refund(
cls,
db: Session,
*,
refund: RefundRequest,
reason: str,
operator_id: str | None = None,
) -> WalletTransaction | None:
locked_refund = (
db.query(RefundRequest)
.filter(RefundRequest.id == refund.id)
.with_for_update()
.one_or_none()
)
if locked_refund is None:
raise ValueError("refund not found")
if locked_refund.status in {"pending_approval", "approved"}:
locked_refund.status = "failed"
locked_refund.failure_reason = reason
locked_refund.updated_at = datetime.now(timezone.utc)
return None
if locked_refund.status != "processing":
raise ValueError(f"cannot fail refund in status: {locked_refund.status}")
wallet = db.query(Wallet).filter(Wallet.id == locked_refund.wallet_id).first()
if wallet is None:
raise ValueError("wallet not found")
tx = cls.create_wallet_transaction(
db,
wallet=wallet,
category="refund",
reason_code=REASON_REFUND_REVERT,
amount=to_money_decimal(locked_refund.amount_usd),
balance_type="recharge",
link_type="refund_request",
link_id=locked_refund.id,
operator_id=operator_id,
description="退款失败回补",
)
if locked_refund.payment_order_id:
payment_order = (
db.query(PaymentOrder)
.filter(PaymentOrder.id == locked_refund.payment_order_id)
.with_for_update()
.one_or_none()
)
if payment_order is not None:
delta = to_money_decimal(locked_refund.amount_usd)
payment_order.refunded_amount_usd = (
to_money_decimal(payment_order.refunded_amount_usd) - delta
)
payment_order.refundable_amount_usd = (
to_money_decimal(payment_order.refundable_amount_usd) + delta
)
locked_refund.status = "failed"
locked_refund.failure_reason = reason
locked_refund.updated_at = datetime.now(timezone.utc)
return tx
@classmethod
def complete_refund(
cls,
db: Session,
*,
refund: RefundRequest,
gateway_refund_id: str | None = None,
payout_reference: str | None = None,
payout_proof: dict | None = None,
) -> RefundRequest:
locked_refund = (
db.query(RefundRequest)
.filter(RefundRequest.id == refund.id)
.with_for_update()
.one_or_none()
)
if locked_refund is None:
raise ValueError("refund not found")
if locked_refund.status != "processing":
raise ValueError("refund status must be processing before completion")
locked_refund.status = "succeeded"
locked_refund.gateway_refund_id = gateway_refund_id
locked_refund.payout_reference = payout_reference
locked_refund.payout_proof = payout_proof
locked_refund.completed_at = datetime.now(timezone.utc)
locked_refund.updated_at = datetime.now(timezone.utc)
return locked_refund