mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
- 删除全部 Python 源码 (src/) 及 Alembic 迁移脚本,归档至 _deprecated_py_src/ - 重构 Rust gateway ai_pipeline: 拆分 planner/finalize 模块,新增 contracts/adaptation 层 - 重组 handlers 模块为 admin/public/proxy/internal/shared 子模块结构 - 新增 executor 模块,引入 Rust 原生数据库迁移 (aether-data/migrations) - 简化 CI/Docker 构建流程,移除 base image 二级构建,统一为单一 app image - 移除 Python 相关基础设施文件 (entrypoint.sh, gunicorn_conf.py, Dockerfile.base)
970 lines
36 KiB
Python
970 lines
36 KiB
Python
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
|
||
balance_snapshot: Decimal | 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_wallets_by_user_ids(
|
||
cls,
|
||
db: Session,
|
||
user_ids: list[str],
|
||
) -> dict[str, Wallet]:
|
||
if not user_ids:
|
||
return {}
|
||
wallets = db.query(Wallet).filter(Wallet.user_id.in_(user_ids)).all()
|
||
return {wallet.user_id: wallet for wallet in wallets if wallet.user_id is not 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 _get_balance_snapshot_from_wallet(cls, wallet: Wallet | None) -> Decimal | None:
|
||
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 check_request_allowed(
|
||
cls,
|
||
db: Session,
|
||
*,
|
||
user: User | None,
|
||
api_key: ApiKey | None = None,
|
||
) -> WalletAccessResult:
|
||
wallet = cls.get_or_create_wallet(db, user=user, api_key=api_key)
|
||
balance_snapshot = cls._get_balance_snapshot_from_wallet(wallet)
|
||
|
||
if user and user.role == UserRole.ADMIN:
|
||
return WalletAccessResult(True, None, "OK", wallet, balance_snapshot)
|
||
|
||
if wallet is None:
|
||
return WalletAccessResult(False, Decimal("0"), "钱包不存在", None, 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, balance_snapshot)
|
||
# Negative recharge balance means overdue; block further spending.
|
||
if recharge_balance < Decimal("0"):
|
||
return WalletAccessResult(
|
||
False, recharge_balance, "钱包欠费,请先充值", wallet, balance_snapshot
|
||
)
|
||
if cls.is_unlimited_wallet(wallet):
|
||
return WalletAccessResult(True, None, "OK", wallet, balance_snapshot)
|
||
if remaining <= Decimal("0"):
|
||
return WalletAccessResult(False, remaining, "钱包余额不足", wallet, balance_snapshot)
|
||
return WalletAccessResult(True, remaining, "OK", wallet, balance_snapshot)
|
||
|
||
@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)
|
||
return cls._get_balance_snapshot_from_wallet(wallet)
|
||
|
||
@classmethod
|
||
def _resolve_wallet_for_usage(
|
||
cls, db: Session, usage: Usage, *, for_update: bool = False
|
||
) -> Wallet | None:
|
||
"""解析 Usage 对应的钱包。for_update=True 时直接返回加锁的钱包,避免二次查询。"""
|
||
wallet_id = None
|
||
if usage.wallet_id:
|
||
wallet_id = usage.wallet_id
|
||
else:
|
||
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:
|
||
wallet = cls.get_or_create_wallet(db, api_key=api_key)
|
||
wallet_id = wallet.id if wallet else None
|
||
if wallet_id is None and usage.user_id:
|
||
user = db.query(User).filter(User.id == usage.user_id).first()
|
||
wallet = cls.get_or_create_wallet(db, user=user, api_key=api_key)
|
||
wallet_id = wallet.id if wallet else None
|
||
|
||
if wallet_id is None:
|
||
return None
|
||
|
||
query = db.query(Wallet).filter(Wallet.id == wallet_id)
|
||
if for_update:
|
||
query = query.with_for_update()
|
||
return query.first()
|
||
|
||
@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
|
||
|
||
locked_wallet = cls._resolve_wallet_for_usage(db, usage, for_update=True)
|
||
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
|