from collections import defaultdict from typing import List, Literal, Optional from fastapi import HTTPException from sqlalchemy import exists, select from sqlalchemy.ext.asyncio import AsyncSession from app.comment.models import Comment from app.comment.schemas.comment_schema import CommentItem, ReplyItem from app.ssulbox.models import SsulContent from app.user.models import User from app.utils.pagination import PaginatedResponse from app.video.models import Video ContentType = Literal["video", "ssul"] def _target_col(content_type: ContentType): """댓글이 달린 대상을 가리키는 컬럼. `comment` 는 ADO2 영상과 썰박스를 함께 담고 `video_id` / `content_id` 중 정확히 하나만 채운다(CHECK 로 강제). 종류는 어느 컬럼이 채워졌는지가 결정한다. """ return Comment.video_id if content_type == "video" else Comment.content_id async def _ensure_target_exists( session: AsyncSession, content_type: ContentType, target_id: int, ) -> None: """댓글 대상(영상 또는 썰박스 콘텐츠)이 존재·공개 상태인지 확인. id 가 종류별 독립 시퀀스라, 반대쪽 테이블에 같은 id 가 있어도 잡으면 안 된다. """ if content_type == "ssul": q = select(SsulContent.id).where( SsulContent.id == target_id, SsulContent.status == "done", SsulContent.is_deleted.is_(False), ) else: q = select(Video.id).where( Video.id == target_id, Video.status == "completed", Video.is_deleted.is_(False), ) if (await session.execute(q)).scalar_one_or_none() is None: raise HTTPException(status_code=404, detail="콘텐츠를 찾을 수 없습니다.") async def _validate_parent( session: AsyncSession, parent_id: int, content_type: ContentType, target_id: int, ) -> None: """2-depth 제한 + 동일 대상 검증.""" result = await session.execute( select(Comment).where( Comment.id == parent_id, Comment.is_deleted == False, # noqa: E712 ) ) parent = result.scalar_one_or_none() if parent is None: raise HTTPException(status_code=400, detail="부모 댓글을 찾을 수 없습니다.") # 부모가 같은 종류의 같은 대상에 달렸는지 본다. video_id 만 비교하면 # 썰박스 댓글(video_id=NULL)에 ADO2 대댓글이 달리는 교차를 못 막는다. parent_target = parent.video_id if content_type == "video" else parent.content_id if parent_target != target_id: raise HTTPException(status_code=400, detail="다른 콘텐츠의 댓글에는 대댓글을 달 수 없습니다.") if parent.parent_id is not None: raise HTTPException(status_code=400, detail="대댓글에는 대댓글을 달 수 없습니다. (최대 2-depth)") def _build_comment_items( parents: list, replies_map: dict, current_user_uuid: Optional[str], profile_image_map: dict, ) -> List[CommentItem]: items = [] for c in parents: raw_replies = replies_map.get(c.id, []) replies = [ ReplyItem( id=r.id, nickname=r.nickname or "익명", profile_image_url=profile_image_map.get(r.user_uuid), content=None if r.is_deleted else r.content, is_deleted=r.is_deleted, is_mine=(current_user_uuid == r.user_uuid) if current_user_uuid else False, created_at=r.created_at, ) for r in raw_replies ] items.append( CommentItem( id=c.id, nickname=c.nickname or "익명", profile_image_url=profile_image_map.get(c.user_uuid), content=None if c.is_deleted else c.content, is_deleted=c.is_deleted, is_mine=(current_user_uuid == c.user_uuid) if current_user_uuid else False, created_at=c.created_at, replies=replies, ) ) return items async def create_comment( session: AsyncSession, video_id: int, user_uuid: str, nickname: Optional[str], content: str, parent_id: Optional[int], content_type: ContentType = "video", ) -> Comment: # 대상 존재 확인 await _ensure_target_exists(session, content_type, video_id) # parent_id 검증 if parent_id is not None: await _validate_parent(session, parent_id, content_type, video_id) comment = Comment( # 종류에 따라 둘 중 하나만 채운다 (CHECK ck_comment_one_target 이 강제) video_id=video_id if content_type == "video" else None, content_id=video_id if content_type == "ssul" else None, user_uuid=user_uuid, nickname=nickname, parent_id=parent_id, content=content, ) session.add(comment) await session.commit() await session.refresh(comment) return comment async def list_comments( session: AsyncSession, video_id: int, page: int, page_size: int, current_user_uuid: Optional[str], content_type: ContentType = "video", ) -> PaginatedResponse[CommentItem]: offset = (page - 1) * page_size # 살아있는 자식이 있는지 확인하는 서브쿼리 has_live_reply = ( exists() .where( Comment.parent_id == Comment.id, Comment.is_deleted == False, # noqa: E712 ) .correlate(Comment) ) # 최상위 댓글 필터: 삭제 안 됐거나 살아있는 대댓글이 있는 것. # 종류에 맞는 대상 컬럼으로 걸러야 같은 id 의 반대 종류 댓글이 섞이지 않는다. parent_where = [ _target_col(content_type) == video_id, Comment.parent_id.is_(None), (Comment.is_deleted == False) | has_live_reply, # noqa: E712 ] from sqlalchemy import func count_q = select(func.count(Comment.id)).where(*parent_where) total = (await session.execute(count_q)).scalar() or 0 parents_q = ( select(Comment) .where(*parent_where) .order_by(Comment.created_at.desc()) .offset(offset) .limit(page_size) ) parents = (await session.execute(parents_q)).scalars().all() replies_map: dict = defaultdict(list) replies: list = [] if parents: parent_ids = [c.id for c in parents] replies_q = ( select(Comment) .where( Comment.parent_id.in_(parent_ids), Comment.is_deleted == False, # noqa: E712 ) .order_by(Comment.created_at.asc()) ) replies = (await session.execute(replies_q)).scalars().all() for r in replies: replies_map[r.parent_id].append(r) # 작성자 프로필 이미지는 스냅샷을 저장하지 않고, 응답 시 User 테이블을 조인해 최신값을 조회한다. user_uuids = {c.user_uuid for c in parents} | {r.user_uuid for r in replies} profile_image_map: dict = {} if user_uuids: profile_q = select(User.user_uuid, User.profile_image_url).where( User.user_uuid.in_(user_uuids) ) profile_image_map = {uuid: url for uuid, url in (await session.execute(profile_q)).all()} items = _build_comment_items(list(parents), replies_map, current_user_uuid, profile_image_map) return PaginatedResponse.create( items=items, total=total, page=page, page_size=page_size, ) async def delete_comment( session: AsyncSession, comment_id: int, current_user_uuid: str, ) -> None: result = await session.execute( select(Comment).where( Comment.id == comment_id, Comment.is_deleted == False, # noqa: E712 ) ) comment = result.scalar_one_or_none() if comment is None: raise HTTPException(status_code=404, detail="댓글을 찾을 수 없습니다.") if comment.user_uuid != current_user_uuid: raise HTTPException(status_code=403, detail="삭제 권한이 없습니다.") comment.is_deleted = True await session.commit()