Files
SatoNano/app/models/balance.py
2026-01-06 23:49:23 +08:00

321 lines
8.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
余额与交易模型
定义用户余额、余额交易记录相关的数据表结构。
设计说明:
- 余额内部以无符号整数存储(单位额度),避免浮点精度问题
- 1.00 显示余额 = 1000 单位额度(精度 0.001
- 所有金额操作都在单位额度层面进行,只在展示时转换
"""
from datetime import datetime, timezone
from enum import Enum
from typing import TYPE_CHECKING
from uuid import uuid4
from sqlalchemy import (
BigInteger,
DateTime,
Enum as SQLEnum,
ForeignKey,
Index,
String,
Text,
func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
if TYPE_CHECKING:
from app.models.user import User
def generate_uuid() -> str:
"""生成 UUID 字符串"""
return str(uuid4())
def utc_now() -> datetime:
"""获取当前 UTC 时间"""
return datetime.now(timezone.utc)
class TransactionType(str, Enum):
"""交易类型枚举"""
RECHARGE = "recharge" # 充值(兑换码等)
DEDUCTION = "deduction" # 扣款API 调用等)
REFUND = "refund" # 退款
ADJUSTMENT = "adjustment" # 管理员调整
TRANSFER_IN = "transfer_in" # 转入
TRANSFER_OUT = "transfer_out" # 转出
class TransactionStatus(str, Enum):
"""交易状态枚举"""
PENDING = "pending" # 待处理
COMPLETED = "completed" # 已完成
FAILED = "failed" # 失败
CANCELLED = "cancelled" # 已取消
class UserBalance(Base):
"""
用户余额模型
独立的余额表,便于扩展(如多币种、账户类型)和锁管理
"""
__tablename__ = "user_balances"
# 主键
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=generate_uuid,
comment="余额记录唯一标识",
)
# 关联用户(一对一)
user_id: Mapped[str] = mapped_column(
String(36),
ForeignKey("users.id", ondelete="CASCADE"),
unique=True,
nullable=False,
index=True,
comment="关联用户 ID",
)
# 余额信息(内部以整数单位存储)
balance: Mapped[int] = mapped_column(
BigInteger,
default=0,
nullable=False,
comment="当前余额单位额度1000 = 1.00 显示余额)",
)
# 冻结金额(用于处理中的交易)
frozen_balance: Mapped[int] = mapped_column(
BigInteger,
default=0,
nullable=False,
comment="冻结余额(单位额度)",
)
# 累计统计
total_recharged: Mapped[int] = mapped_column(
BigInteger,
default=0,
nullable=False,
comment="累计充值(单位额度)",
)
total_consumed: Mapped[int] = mapped_column(
BigInteger,
default=0,
nullable=False,
comment="累计消费(单位额度)",
)
# 乐观锁版本号
version: Mapped[int] = mapped_column(
BigInteger,
default=0,
nullable=False,
comment="版本号(乐观锁)",
)
# 时间戳
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=utc_now,
server_default=func.now(),
nullable=False,
comment="创建时间",
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=utc_now,
onupdate=utc_now,
server_default=func.now(),
nullable=False,
comment="更新时间",
)
# 关系
user: Mapped["User"] = relationship(
"User",
back_populates="balance_account",
lazy="selectin",
)
transactions: Mapped[list["BalanceTransaction"]] = relationship(
"BalanceTransaction",
back_populates="balance_account",
lazy="selectin",
order_by="desc(BalanceTransaction.created_at)",
)
@property
def available_balance(self) -> int:
"""可用余额(总余额 - 冻结余额)"""
return self.balance - self.frozen_balance
@property
def display_balance(self) -> str:
"""显示余额2 位小数)"""
return f"{self.balance / 1000:.2f}"
@property
def display_available_balance(self) -> str:
"""显示可用余额2 位小数)"""
return f"{self.available_balance / 1000:.2f}"
def __repr__(self) -> str:
return f"<UserBalance(user_id={self.user_id!r}, balance={self.display_balance})>"
class BalanceTransaction(Base):
"""
余额交易记录模型
记录所有余额变动,用于审计和对账
"""
__tablename__ = "balance_transactions"
__table_args__ = (
Index("ix_balance_transactions_user_created", "user_id", "created_at"),
Index("ix_balance_transactions_type_status", "transaction_type", "status"),
)
# 主键
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=generate_uuid,
comment="交易记录唯一标识",
)
# 关联
user_id: Mapped[str] = mapped_column(
String(36),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="关联用户 ID",
)
balance_account_id: Mapped[str] = mapped_column(
String(36),
ForeignKey("user_balances.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="关联余额账户 ID",
)
# 交易信息
transaction_type: Mapped[TransactionType] = mapped_column(
SQLEnum(TransactionType),
nullable=False,
comment="交易类型",
)
status: Mapped[TransactionStatus] = mapped_column(
SQLEnum(TransactionStatus),
default=TransactionStatus.COMPLETED,
nullable=False,
comment="交易状态",
)
# 金额(整数单位)
amount: Mapped[int] = mapped_column(
BigInteger,
nullable=False,
comment="交易金额(单位额度,正数表示收入,负数表示支出)",
)
balance_before: Mapped[int] = mapped_column(
BigInteger,
nullable=False,
comment="交易前余额(单位额度)",
)
balance_after: Mapped[int] = mapped_column(
BigInteger,
nullable=False,
comment="交易后余额(单位额度)",
)
# 业务关联
reference_type: Mapped[str | None] = mapped_column(
String(64),
nullable=True,
index=True,
comment="关联业务类型(如 redeem_code、api_call",
)
reference_id: Mapped[str | None] = mapped_column(
String(64),
nullable=True,
index=True,
comment="关联业务 ID",
)
# 描述
description: Mapped[str | None] = mapped_column(
String(255),
nullable=True,
comment="交易描述",
)
remark: Mapped[str | None] = mapped_column(
Text,
nullable=True,
comment="备注(内部使用)",
)
# 操作人(管理员调整时记录)
operator_id: Mapped[str | None] = mapped_column(
String(36),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
comment="操作人 ID管理员调整时",
)
# 幂等键(防止重复提交)
idempotency_key: Mapped[str | None] = mapped_column(
String(64),
unique=True,
nullable=True,
comment="幂等键",
)
# 时间戳
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=utc_now,
server_default=func.now(),
nullable=False,
comment="创建时间",
)
# 关系
user: Mapped["User"] = relationship(
"User",
foreign_keys=[user_id],
lazy="selectin",
)
balance_account: Mapped["UserBalance"] = relationship(
"UserBalance",
back_populates="transactions",
lazy="selectin",
)
@property
def display_amount(self) -> str:
"""显示金额带符号2 位小数)"""
return f"{self.amount / 1000:+.2f}"
def __repr__(self) -> str:
return (
f"<BalanceTransaction(id={self.id!r}, "
f"type={self.transaction_type.value}, "
f"amount={self.display_amount})>"
)