242 lines
8.1 KiB
Python
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()
|