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

242 lines
8.1 KiB
Python

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