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"" ) 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"" )