"""경기별 한마디(댓글) API — 조회(최신순·페이징) · 작성(검증·쿨다운) · 본인 삭제. 신원 기준은 전부 **이메일**(포인트·리더보드와 동일). device_id 는 저장만 하고 로직엔 안 씀. - 닉네임: 투표 시 입력한 email 을 마스킹(ab***@gmail.com)해 노출. - 작성 자격: 그 이메일로 이 경기에 투표했어야 함. - 본인 댓글: 같은 이메일이면 어느 기기에서든 삭제 가능(다른 기기에서도 OK). - 방어(기본): body 길이 제한(스키마 200자) · 같은 이메일 연속 작성 쿨다운 · is_hidden 제외. """ from __future__ import annotations from datetime import datetime, timedelta, timezone from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from ..database import get_db from ..models import Comment, Match, UserPrediction from ..schemas import CommentIn, CommentListOut, CommentOut, GenericOk router = APIRouter(prefix="/api/matches", tags=["comments"]) # 같은 이메일 연속 작성 최소 간격(초) — 도배 방지(기본 방어). COMMENT_COOLDOWN_SECONDS = 5 def _mask(email: str) -> str: """leaderboard._mask 와 동일 규칙 — ab***@gmail.com.""" name, _, domain = email.partition("@") head = name[:2] if len(name) >= 2 else name return f"{head}{'*' * max(1, len(name) - 2)}@{domain}" def _out(c: Comment, viewer_emails: set[str] | None = None) -> CommentOut: return CommentOut( id=c.id, nickname=_mask(c.email), body=c.body, createdAt=c.created_at.isoformat(), mine=bool(viewer_emails) and c.email in viewer_emails, ) def _norm_emails(emails: list[str] | None) -> set[str]: """뷰어가 보낸 이메일 목록을 소문자 집합으로 정규화.""" return {e.strip().lower() for e in (emails or []) if e and e.strip()} @router.get("/{match_id}/comments", response_model=CommentListOut) async def list_comments( match_id: str, limit: int = Query(3, ge=1, le=50), offset: int = Query(0, ge=0), # 뷰어 본인 이메일 목록(localStorage 최근 이메일). 일치하는 댓글에 mine=True → 삭제 버튼 노출 email: list[str] | None = Query(None), db: AsyncSession = Depends(get_db), ) -> CommentListOut: viewer = _norm_emails(email) base = ( select(Comment) .where(Comment.match_id == match_id, Comment.is_hidden.is_(False)) ) total = ( await db.execute( select(func.count()).select_from(base.subquery()) ) ).scalar() or 0 rows = ( await db.execute( base.order_by(Comment.created_at.desc(), Comment.id.desc()) .limit(limit) .offset(offset) ) ).scalars().all() return CommentListOut( items=[_out(c, viewer) for c in rows], total=total ) @router.post("/{match_id}/comments", response_model=CommentOut) async def create_comment( match_id: str, body: CommentIn, db: AsyncSession = Depends(get_db) ) -> CommentOut: match = ( await db.execute(select(Match).where(Match.match_id == match_id)) ).scalars().first() if not match: raise HTTPException(status_code=404, detail="MATCH_NOT_FOUND") # 이 경기에 "그 이메일"로 투표한 사람만 작성 가능 — 포인트도 이메일 단위라 신원 기준을 통일. # device_id 는 게이트에 쓰지 않음(닉네임=이메일, 자격=이메일 투표 여부). email_norm = str(body.email).strip().lower() voted = ( await db.execute( select(UserPrediction.id) .where( UserPrediction.match_id == match_id, func.lower(UserPrediction.email) == email_norm, ) .limit(1) ) ).scalar() if voted is None: raise HTTPException(status_code=403, detail="NOT_VOTED") # 쿨다운: 같은 이메일의 가장 최근 작성과의 간격 확인 last_at = ( await db.execute( select(func.max(Comment.created_at)).where( func.lower(Comment.email) == email_norm ) ) ).scalar() if last_at is not None: elapsed = datetime.now(timezone.utc) - last_at if elapsed < timedelta(seconds=COMMENT_COOLDOWN_SECONDS): raise HTTPException(status_code=429, detail="COMMENT_COOLDOWN") comment = Comment( match_id=match_id, device_id=body.deviceId, # 저장만(분석용), 권한 판정엔 사용 안 함 email=email_norm, body=body.body, ) db.add(comment) await db.commit() await db.refresh(comment) return _out(comment, {email_norm}) # 작성자 본인이므로 mine=True @router.delete("/{match_id}/comments/{comment_id}", response_model=GenericOk) async def delete_comment( match_id: str, comment_id: int, # 뷰어 본인 이메일 목록 — 댓글 작성 이메일과 일치하면 어느 기기에서든 삭제 가능 email: list[str] = Query(...), db: AsyncSession = Depends(get_db), ) -> GenericOk: """본인 댓글 삭제 — 작성에 쓰인 이메일과 같으면 기기 무관하게 삭제.""" viewer = _norm_emails(email) if not viewer: raise HTTPException(status_code=422, detail="EMAIL_REQUIRED") comment = ( await db.execute( select(Comment).where( Comment.id == comment_id, Comment.match_id == match_id ) ) ).scalars().first() if comment is None: raise HTTPException(status_code=404, detail="COMMENT_NOT_FOUND") if comment.email not in viewer: raise HTTPException(status_code=403, detail="NOT_OWNER") await db.delete(comment) await db.commit() return GenericOk(ok=True)