mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
refactor: 移除 Python 后端源码,全面迁移至 Rust gateway 架构
- 删除全部 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)
This commit is contained in:
12
_deprecated_py_src/services/wallet/__init__.py
Normal file
12
_deprecated_py_src/services/wallet/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from src.services.wallet.daily_usage_ledger import (
|
||||
WalletDailyUsageLedgerService,
|
||||
WalletDailyUsageSnapshot,
|
||||
)
|
||||
from src.services.wallet.service import WalletAccessResult, WalletService
|
||||
|
||||
__all__ = [
|
||||
"WalletAccessResult",
|
||||
"WalletDailyUsageLedgerService",
|
||||
"WalletDailyUsageSnapshot",
|
||||
"WalletService",
|
||||
]
|
||||
206
_deprecated_py_src/services/wallet/daily_usage_ledger.py
Normal file
206
_deprecated_py_src/services/wallet/daily_usage_ledger.py
Normal file
@@ -0,0 +1,206 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from decimal import Decimal
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.config import config
|
||||
from src.core.logger import logger
|
||||
from src.models.database import Usage, WalletDailyUsageLedger
|
||||
from src.services.billing.precision import to_money_decimal
|
||||
|
||||
APP_TIMEZONE = config.app_timezone
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class WalletDailyUsageSnapshot:
|
||||
wallet_id: str | None
|
||||
billing_date: date
|
||||
billing_timezone: str
|
||||
total_cost_usd: Decimal
|
||||
total_requests: int
|
||||
input_tokens: int
|
||||
output_tokens: int
|
||||
cache_creation_tokens: int
|
||||
cache_read_tokens: int
|
||||
first_finalized_at: datetime | None
|
||||
last_finalized_at: datetime | None
|
||||
aggregated_at: datetime
|
||||
is_today: bool = False
|
||||
id: str | None = None
|
||||
|
||||
|
||||
class WalletDailyUsageLedgerService:
|
||||
"""钱包每日消费汇总服务。"""
|
||||
|
||||
@staticmethod
|
||||
def get_timezone(timezone_name: str | None = None) -> ZoneInfo:
|
||||
tz_name = timezone_name or APP_TIMEZONE
|
||||
try:
|
||||
return ZoneInfo(tz_name)
|
||||
except Exception:
|
||||
logger.warning("Invalid billing timezone {}, fallback to UTC", tz_name)
|
||||
return ZoneInfo("UTC")
|
||||
|
||||
@classmethod
|
||||
def get_today_billing_date(cls, timezone_name: str | None = None) -> date:
|
||||
tz = cls.get_timezone(timezone_name)
|
||||
return datetime.now(tz).date()
|
||||
|
||||
@classmethod
|
||||
def get_day_window_utc(
|
||||
cls,
|
||||
billing_date: date,
|
||||
timezone_name: str | None = None,
|
||||
) -> tuple[datetime, datetime]:
|
||||
tz = cls.get_timezone(timezone_name)
|
||||
local_start = datetime.combine(billing_date, time.min, tzinfo=tz)
|
||||
local_end = local_start + timedelta(days=1)
|
||||
return local_start.astimezone(timezone.utc), local_end.astimezone(timezone.utc)
|
||||
|
||||
@classmethod
|
||||
def aggregate_day(
|
||||
cls,
|
||||
db: Session,
|
||||
billing_date: date,
|
||||
*,
|
||||
timezone_name: str | None = None,
|
||||
commit: bool = True,
|
||||
) -> int:
|
||||
tz_name = timezone_name or APP_TIMEZONE
|
||||
start_utc, end_utc = cls.get_day_window_utc(billing_date, tz_name)
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
|
||||
rows = (
|
||||
db.query(
|
||||
Usage.wallet_id.label("wallet_id"),
|
||||
func.count(Usage.id).label("total_requests"),
|
||||
func.coalesce(func.sum(Usage.total_cost_usd), 0).label("total_cost_usd"),
|
||||
func.coalesce(func.sum(Usage.input_tokens), 0).label("input_tokens"),
|
||||
func.coalesce(func.sum(Usage.output_tokens), 0).label("output_tokens"),
|
||||
func.coalesce(func.sum(Usage.cache_creation_input_tokens), 0).label(
|
||||
"cache_creation_tokens"
|
||||
),
|
||||
func.coalesce(func.sum(Usage.cache_read_input_tokens), 0).label(
|
||||
"cache_read_tokens"
|
||||
),
|
||||
func.min(Usage.finalized_at).label("first_finalized_at"),
|
||||
func.max(Usage.finalized_at).label("last_finalized_at"),
|
||||
)
|
||||
.filter(
|
||||
Usage.wallet_id.isnot(None),
|
||||
Usage.billing_status == "settled",
|
||||
Usage.total_cost_usd > 0,
|
||||
Usage.finalized_at >= start_utc,
|
||||
Usage.finalized_at < end_utc,
|
||||
)
|
||||
.group_by(Usage.wallet_id)
|
||||
.all()
|
||||
)
|
||||
|
||||
existing_ledgers = (
|
||||
db.query(WalletDailyUsageLedger)
|
||||
.filter(
|
||||
WalletDailyUsageLedger.billing_date == billing_date,
|
||||
WalletDailyUsageLedger.billing_timezone == tz_name,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
existing_map = {ledger.wallet_id: ledger for ledger in existing_ledgers}
|
||||
seen_wallet_ids: set[str] = set()
|
||||
|
||||
for row in rows:
|
||||
wallet_id = getattr(row, "wallet_id", None)
|
||||
if not wallet_id:
|
||||
continue
|
||||
seen_wallet_ids.add(str(wallet_id))
|
||||
ledger = existing_map.get(str(wallet_id))
|
||||
if ledger is None:
|
||||
ledger = WalletDailyUsageLedger(
|
||||
wallet_id=str(wallet_id),
|
||||
billing_date=billing_date,
|
||||
billing_timezone=tz_name,
|
||||
aggregated_at=now_utc,
|
||||
)
|
||||
db.add(ledger)
|
||||
|
||||
ledger.total_cost_usd = to_money_decimal(getattr(row, "total_cost_usd", 0) or 0)
|
||||
ledger.total_requests = int(getattr(row, "total_requests", 0) or 0)
|
||||
ledger.input_tokens = int(getattr(row, "input_tokens", 0) or 0)
|
||||
ledger.output_tokens = int(getattr(row, "output_tokens", 0) or 0)
|
||||
ledger.cache_creation_tokens = int(getattr(row, "cache_creation_tokens", 0) or 0)
|
||||
ledger.cache_read_tokens = int(getattr(row, "cache_read_tokens", 0) or 0)
|
||||
ledger.first_finalized_at = getattr(row, "first_finalized_at", None)
|
||||
ledger.last_finalized_at = getattr(row, "last_finalized_at", None)
|
||||
ledger.aggregated_at = now_utc
|
||||
|
||||
stale_ledgers = [
|
||||
ledger for ledger in existing_ledgers if ledger.wallet_id not in seen_wallet_ids
|
||||
]
|
||||
for ledger in stale_ledgers:
|
||||
db.delete(ledger)
|
||||
|
||||
if commit:
|
||||
db.commit()
|
||||
return len(seen_wallet_ids)
|
||||
|
||||
@classmethod
|
||||
def get_today_snapshot(
|
||||
cls,
|
||||
db: Session,
|
||||
wallet_id: str | None,
|
||||
*,
|
||||
timezone_name: str | None = None,
|
||||
) -> WalletDailyUsageSnapshot:
|
||||
tz_name = timezone_name or APP_TIMEZONE
|
||||
billing_date = cls.get_today_billing_date(tz_name)
|
||||
start_utc, end_utc = cls.get_day_window_utc(billing_date, tz_name)
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
|
||||
if wallet_id:
|
||||
row = (
|
||||
db.query(
|
||||
func.count(Usage.id).label("total_requests"),
|
||||
func.coalesce(func.sum(Usage.total_cost_usd), 0).label("total_cost_usd"),
|
||||
func.coalesce(func.sum(Usage.input_tokens), 0).label("input_tokens"),
|
||||
func.coalesce(func.sum(Usage.output_tokens), 0).label("output_tokens"),
|
||||
func.coalesce(func.sum(Usage.cache_creation_input_tokens), 0).label(
|
||||
"cache_creation_tokens"
|
||||
),
|
||||
func.coalesce(func.sum(Usage.cache_read_input_tokens), 0).label(
|
||||
"cache_read_tokens"
|
||||
),
|
||||
func.min(Usage.finalized_at).label("first_finalized_at"),
|
||||
func.max(Usage.finalized_at).label("last_finalized_at"),
|
||||
)
|
||||
.filter(
|
||||
Usage.wallet_id == wallet_id,
|
||||
Usage.billing_status == "settled",
|
||||
Usage.total_cost_usd > 0,
|
||||
Usage.finalized_at >= start_utc,
|
||||
Usage.finalized_at < end_utc,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
else:
|
||||
row = None
|
||||
|
||||
return WalletDailyUsageSnapshot(
|
||||
wallet_id=wallet_id,
|
||||
billing_date=billing_date,
|
||||
billing_timezone=tz_name,
|
||||
total_cost_usd=to_money_decimal(getattr(row, "total_cost_usd", 0) or 0),
|
||||
total_requests=int(getattr(row, "total_requests", 0) or 0),
|
||||
input_tokens=int(getattr(row, "input_tokens", 0) or 0),
|
||||
output_tokens=int(getattr(row, "output_tokens", 0) or 0),
|
||||
cache_creation_tokens=int(getattr(row, "cache_creation_tokens", 0) or 0),
|
||||
cache_read_tokens=int(getattr(row, "cache_read_tokens", 0) or 0),
|
||||
first_finalized_at=getattr(row, "first_finalized_at", None),
|
||||
last_finalized_at=getattr(row, "last_finalized_at", None),
|
||||
aggregated_at=now_utc,
|
||||
is_today=True,
|
||||
)
|
||||
969
_deprecated_py_src/services/wallet/service.py
Normal file
969
_deprecated_py_src/services/wallet/service.py
Normal file
@@ -0,0 +1,969 @@
|
||||
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
|
||||
Reference in New Issue
Block a user