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

410 lines
9.8 KiB
Python
Raw Permalink 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.

"""
兑换码模型
定义余额兑换码相关的数据表结构。
设计说明:
- 兑换码支持批量生成、导入导出
- 记录完整的使用日志
- 支持设置有效期和使用限制
"""
from datetime import datetime, timezone
from enum import Enum
from typing import TYPE_CHECKING
from uuid import uuid4
import secrets
import string
from sqlalchemy import (
BigInteger,
Boolean,
DateTime,
Enum as SQLEnum,
ForeignKey,
Index,
Integer,
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)
def generate_redeem_code(length: int = 16) -> str:
"""
生成兑换码
格式:大写字母和数字,分段显示(如 XXXX-XXXX-XXXX-XXXX
排除容易混淆的字符0/O, 1/I/L
"""
# 可用字符集(排除易混淆字符)
alphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
code = "".join(secrets.choice(alphabet) for _ in range(length))
# 每 4 个字符用连字符分隔
return "-".join(code[i:i + 4] for i in range(0, len(code), 4))
class RedeemCodeStatus(str, Enum):
"""兑换码状态枚举"""
ACTIVE = "active" # 可用
USED = "used" # 已使用
DISABLED = "disabled" # 已禁用
EXPIRED = "expired" # 已过期
class RedeemCodeBatch(Base):
"""
兑换码批次模型
用于管理批量生成的兑换码
"""
__tablename__ = "redeem_code_batches"
# 主键
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=generate_uuid,
comment="批次唯一标识",
)
# 批次信息
name: Mapped[str] = mapped_column(
String(128),
nullable=False,
comment="批次名称",
)
description: Mapped[str | None] = mapped_column(
Text,
nullable=True,
comment="批次描述",
)
# 面值(单位额度)
face_value: Mapped[int] = mapped_column(
BigInteger,
nullable=False,
comment="面值单位额度1000 = 1.00",
)
# 数量统计
total_count: Mapped[int] = mapped_column(
Integer,
default=0,
nullable=False,
comment="生成总数",
)
used_count: Mapped[int] = mapped_column(
Integer,
default=0,
nullable=False,
comment="已使用数量",
)
# 创建者
created_by: Mapped[str] = mapped_column(
String(36),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
comment="创建者 ID",
)
# 时间戳
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=utc_now,
server_default=func.now(),
nullable=False,
comment="创建时间",
)
# 关系
codes: Mapped[list["RedeemCode"]] = relationship(
"RedeemCode",
back_populates="batch",
lazy="selectin",
)
creator: Mapped["User"] = relationship(
"User",
lazy="selectin",
)
@property
def display_face_value(self) -> str:
"""显示面值2 位小数)"""
return f"{self.face_value / 1000:.2f}"
def __repr__(self) -> str:
return f"<RedeemCodeBatch(id={self.id!r}, name={self.name!r})>"
class RedeemCode(Base):
"""
兑换码模型
单个兑换码记录
"""
__tablename__ = "redeem_codes"
__table_args__ = (
Index("ix_redeem_codes_status_expires", "status", "expires_at"),
)
# 主键
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=generate_uuid,
comment="兑换码记录唯一标识",
)
# 兑换码(唯一索引)
code: Mapped[str] = mapped_column(
String(32),
unique=True,
nullable=False,
index=True,
default=generate_redeem_code,
comment="兑换码",
)
# 批次关联(可选)
batch_id: Mapped[str | None] = mapped_column(
String(36),
ForeignKey("redeem_code_batches.id", ondelete="SET NULL"),
nullable=True,
index=True,
comment="关联批次 ID",
)
# 面值(单位额度)
face_value: Mapped[int] = mapped_column(
BigInteger,
nullable=False,
comment="面值单位额度1000 = 1.00",
)
# 状态
status: Mapped[RedeemCodeStatus] = mapped_column(
SQLEnum(RedeemCodeStatus),
default=RedeemCodeStatus.ACTIVE,
nullable=False,
index=True,
comment="兑换码状态",
)
# 使用限制
max_uses: Mapped[int] = mapped_column(
Integer,
default=1,
nullable=False,
comment="最大使用次数",
)
used_count: Mapped[int] = mapped_column(
Integer,
default=0,
nullable=False,
comment="已使用次数",
)
# 有效期
expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
comment="过期时间",
)
# 使用信息
used_by: Mapped[str | None] = mapped_column(
String(36),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
comment="使用者 ID最后使用",
)
used_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
comment="使用时间(最后使用)",
)
# 备注
remark: Mapped[str | None] = mapped_column(
Text,
nullable=True,
comment="备注",
)
# 创建者
created_by: Mapped[str | None] = mapped_column(
String(36),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
comment="创建者 ID",
)
# 时间戳
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="更新时间",
)
# 关系
batch: Mapped["RedeemCodeBatch | None"] = relationship(
"RedeemCodeBatch",
back_populates="codes",
lazy="selectin",
)
usage_logs: Mapped[list["RedeemCodeUsageLog"]] = relationship(
"RedeemCodeUsageLog",
back_populates="redeem_code",
lazy="selectin",
order_by="desc(RedeemCodeUsageLog.created_at)",
)
@property
def display_face_value(self) -> str:
"""显示面值2 位小数)"""
return f"{self.face_value / 1000:.2f}"
@property
def is_valid(self) -> bool:
"""检查兑换码是否有效"""
if self.status != RedeemCodeStatus.ACTIVE:
return False
if self.used_count >= self.max_uses:
return False
if self.expires_at and self.expires_at < utc_now():
return False
return True
def __repr__(self) -> str:
return f"<RedeemCode(code={self.code!r}, status={self.status.value})>"
class RedeemCodeUsageLog(Base):
"""
兑换码使用日志模型
记录每次兑换的详细信息
"""
__tablename__ = "redeem_code_usage_logs"
__table_args__ = (
Index("ix_redeem_usage_user_created", "user_id", "created_at"),
)
# 主键
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=generate_uuid,
comment="日志唯一标识",
)
# 关联
redeem_code_id: Mapped[str] = mapped_column(
String(36),
ForeignKey("redeem_codes.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="关联兑换码 ID",
)
user_id: Mapped[str] = mapped_column(
String(36),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="使用者 ID",
)
transaction_id: Mapped[str | None] = mapped_column(
String(36),
ForeignKey("balance_transactions.id", ondelete="SET NULL"),
nullable=True,
comment="关联交易记录 ID",
)
# 兑换信息快照
code_snapshot: Mapped[str] = mapped_column(
String(32),
nullable=False,
comment="兑换码快照",
)
face_value: Mapped[int] = mapped_column(
BigInteger,
nullable=False,
comment="面值(单位额度)",
)
# 客户端信息
ip_address: Mapped[str | None] = mapped_column(
String(64),
nullable=True,
comment="客户端 IP",
)
user_agent: Mapped[str | None] = mapped_column(
String(512),
nullable=True,
comment="User Agent",
)
# 时间戳
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=utc_now,
server_default=func.now(),
nullable=False,
comment="使用时间",
)
# 关系
redeem_code: Mapped["RedeemCode"] = relationship(
"RedeemCode",
back_populates="usage_logs",
lazy="selectin",
)
user: Mapped["User"] = relationship(
"User",
lazy="selectin",
)
@property
def display_face_value(self) -> str:
"""显示面值2 位小数)"""
return f"{self.face_value / 1000:.2f}"
def __repr__(self) -> str:
return (
f"<RedeemCodeUsageLog(code={self.code_snapshot!r}, "
f"user_id={self.user_id!r})>"
)