o2o-triple-pick/backend/app/routers/comments.py

157 lines
5.7 KiB
Python

"""경기별 한마디(댓글) 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)