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