276 lines
8.0 KiB
Python
276 lines
8.0 KiB
Python
from datetime import datetime
|
|
from enum import Enum
|
|
from typing import TYPE_CHECKING, Optional
|
|
|
|
from sqlalchemy import (
|
|
BigInteger,
|
|
DateTime,
|
|
ForeignKey,
|
|
Index,
|
|
Integer,
|
|
String,
|
|
UniqueConstraint,
|
|
func,
|
|
)
|
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
|
|
from app.database.session import Base
|
|
|
|
if TYPE_CHECKING:
|
|
from app.backoffice.admin.models import Admin
|
|
from app.user.models import User
|
|
|
|
|
|
class ChargeRequestStatus(str, Enum):
|
|
PENDING = "pending"
|
|
APPROVED = "approved"
|
|
REJECTED = "rejected"
|
|
CANCELLED = "cancelled"
|
|
|
|
|
|
class CreditTransactionType(str, Enum):
|
|
CHARGE = "charge"
|
|
CONSUME = "consume"
|
|
REFUND = "refund"
|
|
ADMIN_ADJUST = "admin_adjust"
|
|
|
|
|
|
class CreditChargeRequest(Base):
|
|
__tablename__ = "credit_charge_request"
|
|
__table_args__ = (
|
|
Index("idx_credit_request_user_uuid", "user_uuid"),
|
|
Index("idx_credit_request_status", "status"),
|
|
Index("idx_credit_request_created_at", "created_at"),
|
|
Index("idx_credit_request_status_created", "status", "created_at"),
|
|
{
|
|
"mysql_engine": "InnoDB",
|
|
"mysql_charset": "utf8mb4",
|
|
"mysql_collate": "utf8mb4_unicode_ci",
|
|
},
|
|
)
|
|
|
|
id: Mapped[int] = mapped_column(
|
|
BigInteger,
|
|
primary_key=True,
|
|
nullable=False,
|
|
autoincrement=True,
|
|
comment="고유 식별자",
|
|
)
|
|
|
|
user_uuid: Mapped[str] = mapped_column(
|
|
String(36),
|
|
ForeignKey("user.user_uuid", ondelete="CASCADE"),
|
|
nullable=False,
|
|
comment="사용자 UUID (user.user_uuid 참조)",
|
|
)
|
|
|
|
requested_amount: Mapped[int] = mapped_column(
|
|
Integer,
|
|
nullable=False,
|
|
comment="요청 크레딧 수량 (양수)",
|
|
)
|
|
|
|
message: Mapped[Optional[str]] = mapped_column(
|
|
String(500),
|
|
nullable=True,
|
|
comment="사용자 요청 메시지",
|
|
)
|
|
|
|
status: Mapped[str] = mapped_column(
|
|
String(20),
|
|
nullable=False,
|
|
default=ChargeRequestStatus.PENDING,
|
|
server_default="pending",
|
|
comment="처리 상태 (pending/approved/rejected/cancelled)",
|
|
)
|
|
|
|
admin_id: Mapped[Optional[int]] = mapped_column(
|
|
BigInteger,
|
|
ForeignKey("admin.id", ondelete="SET NULL"),
|
|
nullable=True,
|
|
comment="처리한 백오피스 관리자 ID",
|
|
)
|
|
|
|
admin_note: Mapped[Optional[str]] = mapped_column(
|
|
String(1000),
|
|
nullable=True,
|
|
comment="관리자 메모",
|
|
)
|
|
|
|
processed_at: Mapped[Optional[datetime]] = mapped_column(
|
|
DateTime,
|
|
nullable=True,
|
|
comment="처리 일시",
|
|
)
|
|
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime,
|
|
nullable=False,
|
|
server_default=func.now(),
|
|
comment="요청 일시",
|
|
)
|
|
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
DateTime,
|
|
nullable=False,
|
|
server_default=func.now(),
|
|
onupdate=func.now(),
|
|
comment="수정 일시",
|
|
)
|
|
|
|
user: Mapped["User"] = relationship(
|
|
"User",
|
|
foreign_keys=[user_uuid],
|
|
primaryjoin="CreditChargeRequest.user_uuid == User.user_uuid",
|
|
back_populates="credit_requests",
|
|
lazy="noload",
|
|
)
|
|
|
|
transactions: Mapped[list["CreditTransaction"]] = relationship(
|
|
"CreditTransaction",
|
|
back_populates="charge_request",
|
|
lazy="noload",
|
|
)
|
|
|
|
admin: Mapped[Optional["Admin"]] = relationship(
|
|
"Admin",
|
|
foreign_keys=[admin_id],
|
|
primaryjoin="CreditChargeRequest.admin_id == Admin.id",
|
|
lazy="selectin",
|
|
)
|
|
|
|
def __repr__(self) -> str:
|
|
return (
|
|
f"<CreditChargeRequest("
|
|
f"id={self.id}, user_uuid='{self.user_uuid}', "
|
|
f"amount={self.requested_amount}, status='{self.status}'"
|
|
f")>"
|
|
)
|
|
|
|
|
|
class CreditTransaction(Base):
|
|
__tablename__ = "credit_transaction"
|
|
__table_args__ = (
|
|
Index("idx_credit_tx_user_uuid", "user_uuid"),
|
|
Index("idx_credit_tx_user_uuid_created", "user_uuid", "created_at"),
|
|
Index("idx_credit_tx_type", "type"),
|
|
Index("idx_credit_tx_related_request", "related_request_id"),
|
|
# 작업 1건당 consume 1행 / refund 1행을 DB 레벨에서 보장하는 멱등 키.
|
|
# MySQL 은 NULL 을 서로 다르게 취급하므로 job_type 이 NULL 인 기존
|
|
# charge/admin_adjust 행은 이 제약의 영향을 받지 않는다.
|
|
UniqueConstraint("job_type", "job_ref", "type", name="uq_credit_job"),
|
|
{
|
|
"mysql_engine": "InnoDB",
|
|
"mysql_charset": "utf8mb4",
|
|
"mysql_collate": "utf8mb4_unicode_ci",
|
|
},
|
|
)
|
|
|
|
id: Mapped[int] = mapped_column(
|
|
BigInteger,
|
|
primary_key=True,
|
|
nullable=False,
|
|
autoincrement=True,
|
|
comment="고유 식별자",
|
|
)
|
|
|
|
user_uuid: Mapped[str] = mapped_column(
|
|
String(36),
|
|
ForeignKey("user.user_uuid", ondelete="CASCADE"),
|
|
nullable=False,
|
|
comment="사용자 UUID",
|
|
)
|
|
|
|
amount: Mapped[int] = mapped_column(
|
|
Integer,
|
|
nullable=False,
|
|
comment="변경 크레딧 수량 (충전 양수, 차감 음수)",
|
|
)
|
|
|
|
balance_after: Mapped[int] = mapped_column(
|
|
Integer,
|
|
nullable=False,
|
|
comment="변경 직후 잔액",
|
|
)
|
|
|
|
type: Mapped[str] = mapped_column(
|
|
String(20),
|
|
nullable=False,
|
|
comment="변경 유형 (charge/consume/refund/admin_adjust)",
|
|
)
|
|
|
|
reason: Mapped[Optional[str]] = mapped_column(
|
|
String(255),
|
|
nullable=True,
|
|
comment="변경 사유",
|
|
)
|
|
|
|
admin_id: Mapped[Optional[int]] = mapped_column(
|
|
BigInteger,
|
|
ForeignKey("admin.id", ondelete="SET NULL"),
|
|
nullable=True,
|
|
comment="처리 관리자 ID (관리자 충전/차감 시)",
|
|
)
|
|
|
|
related_request_id: Mapped[Optional[int]] = mapped_column(
|
|
BigInteger,
|
|
ForeignKey("credit_charge_request.id", ondelete="SET NULL"),
|
|
nullable=True,
|
|
comment="연관 충전 요청 ID",
|
|
)
|
|
|
|
# ==========================================================================
|
|
# 작업 기반 멱등 키 — (job_type, job_ref, type) 유니크
|
|
# ==========================================================================
|
|
# 사전차감 정책의 핵심. 생성 작업을 시작할 때 차감하고 실패 시 환불하는데,
|
|
# 재시도·중복 요청·크래시 후 재처리에도 "작업 1건당 정확히 1번"을 보장해야 한다.
|
|
# FK 를 걸지 않는 이유: 썰박스(ssul_task.id 숫자)와 영상(video.task_id UUID7 문자열)
|
|
# 이라는 이질적인 두 작업 테이블을 하나의 컬럼이 가리키기 때문이다.
|
|
job_type: Mapped[Optional[str]] = mapped_column(
|
|
String(20),
|
|
nullable=True,
|
|
comment="차감 유발 작업 종류 (ssul/video). 충전·관리자 조정은 NULL",
|
|
)
|
|
|
|
job_ref: Mapped[Optional[str]] = mapped_column(
|
|
String(64),
|
|
nullable=True,
|
|
comment="작업 식별자 (ssul_task.id 문자열 또는 video.task_id)",
|
|
)
|
|
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime,
|
|
nullable=False,
|
|
server_default=func.now(),
|
|
comment="변경 일시",
|
|
)
|
|
|
|
user: Mapped["User"] = relationship(
|
|
"User",
|
|
foreign_keys=[user_uuid],
|
|
primaryjoin="CreditTransaction.user_uuid == User.user_uuid",
|
|
back_populates="credit_transactions",
|
|
lazy="noload",
|
|
)
|
|
|
|
charge_request: Mapped[Optional[CreditChargeRequest]] = relationship(
|
|
"CreditChargeRequest",
|
|
back_populates="transactions",
|
|
lazy="noload",
|
|
)
|
|
|
|
admin: Mapped[Optional["Admin"]] = relationship(
|
|
"Admin",
|
|
foreign_keys=[admin_id],
|
|
primaryjoin="CreditTransaction.admin_id == Admin.id",
|
|
lazy="selectin",
|
|
)
|
|
|
|
def __repr__(self) -> str:
|
|
return (
|
|
f"<CreditTransaction("
|
|
f"id={self.id}, user_uuid='{self.user_uuid}', "
|
|
f"amount={self.amount}, type='{self.type}'"
|
|
f")>"
|
|
)
|