157 lines
5.7 KiB
Python
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)
|