mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 09:50:21 +08:00
feat(wallet): 钱包系统替代配额系统,新增支付与退款机制
- 新增钱包余额管理、充值、扣费、退款完整流程 - 新增支付网关抽象层(支持手动/支付宝/微信) - 用量计费从配额系统迁移到钱包余额扣费 - 新增管理员钱包管理与支付订单管理页面 - 新增用户钱包中心页面 - 移除独立 Key 锁定机制,统一由钱包余额控制 - 新增相关 API 路由、序列化器与数据库迁移 - 新增钱包、支付、退款相关测试
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user