o2o-castad-backend/app/comment/models.py
김성경 fde48e8674 Merge branch 'main' into feature-ssulbox
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 15:25:13 +09:00

119 lines
4.4 KiB
Python

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