from datetime import datetime from typing import TYPE_CHECKING, List, Optional from sqlalchemy import ( BigInteger, Boolean, CheckConstraint, DateTime, ForeignKey, Index, Integer, String, func, ) from sqlalchemy.orm import Mapped, mapped_column, relationship from app.database.session import Base if TYPE_CHECKING: from app.ssulbox.models import SsulContent from app.user.models import User from app.video.models import Video class Comment(Base): """ 영상 댓글 테이블 2-depth 구조 (최상위 댓글 + 대댓글 1단계). parent_id가 NULL이면 최상위 댓글, 값이 있으면 대댓글. 작성자 닉네임은 카카오 로그인 정보를 작성 시점에 그대로 저장한 스냅샷이며, 프로필 이미지는 별도 컬럼 없이 응답 시 User 테이블을 조인해 최신값을 조회한다. **ADO2 영상과 썰박스 콘텐츠를 모두 담는다.** 대상은 `video_id` 또는 `content_id` 중 **정확히 하나**만 채워지며, 이를 DB `CHECK` 로 강제한다. MySQL 은 하나의 FK 가 두 테이블을 조건부로 가리키게 할 수 없어 컬럼을 나눠 둔 것이다. 별도의 종류 컬럼은 두지 않는다 — 어느 FK 가 채워졌는지가 곧 종류이고, 컬럼을 하나 더 두면 둘이 어긋날 수 있다. """ __tablename__ = "comment" __table_args__ = ( CheckConstraint( "(video_id IS NULL) <> (content_id IS NULL)", name="ck_comment_one_target", ), Index("idx_comment_video_id", "video_id"), Index("idx_comment_content_id", "content_id"), Index("idx_comment_user_uuid", "user_uuid"), Index("idx_comment_parent_id", "parent_id"), Index("idx_comment_is_deleted", "is_deleted"), { "mysql_engine": "InnoDB", "mysql_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci", }, ) id: Mapped[int] = mapped_column( Integer, primary_key=True, autoincrement=True, comment="고유 식별자" ) # 대상은 아래 둘 중 **정확히 하나**만 채워진다 (ck_comment_one_target). video_id: Mapped[Optional[int]] = mapped_column( Integer, ForeignKey("video.id", ondelete="CASCADE"), nullable=True, comment="ADO2 영상 id (썰박스 댓글이면 NULL)", ) content_id: Mapped[Optional[int]] = mapped_column( # ssul_content.id 는 BIGINT 다. INT 로 두면 FK 타입 불일치(errno 3780). BigInteger, ForeignKey("ssul_content.id", ondelete="CASCADE"), nullable=True, comment="썰박스 콘텐츠 id (ADO2 댓글이면 NULL)", ) user_uuid: Mapped[str] = mapped_column( ForeignKey("user.user_uuid", ondelete="CASCADE"), nullable=False, comment="작성자 UUID (응답 미노출, 권한 검증용)", ) parent_id: Mapped[Optional[int]] = mapped_column( Integer, ForeignKey("comment.id", ondelete="CASCADE"), nullable=True, comment="NULL=최상위 댓글, 값=대댓글의 부모 id", ) nickname: Mapped[Optional[str]] = mapped_column( String(50), nullable=True, comment="댓글 작성자 카카오 닉네임 스냅샷 (null이면 익명)" ) content: Mapped[str] = mapped_column( String(100), nullable=False, comment="댓글 본문 (한글 기준 100자 이내)" ) is_deleted: Mapped[bool] = mapped_column( Boolean, nullable=False, default=False, comment="소프트 삭제 여부" ) created_at: Mapped[datetime] = mapped_column( DateTime, nullable=False, server_default=func.now(), comment="작성 일시", ) # 썰박스 댓글이면 None 이다. 접근하는 쪽에서 반드시 방어할 것. video: Mapped[Optional["Video"]] = relationship( "Video", foreign_keys=[video_id], back_populates="comments" ) content_ref: Mapped[Optional["SsulContent"]] = relationship( "SsulContent", foreign_keys=[content_id], lazy="noload" ) user: Mapped["User"] = relationship("User", back_populates="comments") parent: Mapped[Optional["Comment"]] = relationship( "Comment", remote_side=[id], back_populates="replies" ) replies: Mapped[List["Comment"]] = relationship( "Comment", back_populates="parent", cascade="all, delete-orphan", )