"""경기별 한마디(댓글) API — 완전 익명. 조회(최신순·페이징) · 작성(검증·쿨다운). 신원/인증 없음: - author_hash = sha256(device_id). 기기 원본 ID는 저장하지 않음(추적 불가). 쿨다운 전용. - nickname: 축구+코믹 한국어 5글자. **세션 단위 발급** — 클라가 sessionStorage 에 캐싱해 한 세션 동안 같은 닉을 재사용, 탭/창을 닫았다 켜거나 시크릿이면 새로 발급. 발급은 서버 단어풀에서만(욕설/사칭 방지) — 작성 시에도 풀 검증. - id(PK)·author_hash 는 내부용으로 API 응답에 노출하지 않음. - 방어(기본): body 길이 제한(스키마 200자) · 같은 기기 연속 작성 쿨다운 · is_hidden 제외. - 테이블은 범용(모든 경기)이고, 노출 경기 제한은 프론트가 담당. """ from __future__ import annotations import hashlib import random 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 from ..schemas import CommentIn, CommentListOut, CommentNicknameOut, CommentOut router = APIRouter(prefix="/api/matches", tags=["comments"]) # 같은 기기 연속 작성 최소 간격(초) — 도배 방지(기본 방어). COMMENT_COOLDOWN_SECONDS = 5 # 닉네임 조합: 2글자(코믹 수식) + 3글자(축구 역할) = 항상 한국어 5글자. # 40 × 30 = 1200가지. _NICK_A = [ "잔디", "침대", "벤치", "후보", "똥손", "헛발", "발컨", "노룩", "왼발", "멘붕", "국대", "동네", "주말", "폭발", "광속", "진지", "발끝", "번개", "백수", "천재", "야수", "괴물", "폭격", "강철", "무적", "질풍", "돌풍", "불꽃", "발광", "분노", "음속", "폭탄", "슈퍼", "만년", "전설", "비밀", "미친", "라면", "치킨", "출근", ] _NICK_B = [ "드리블", "골사냥", "해결사", "종결자", "자판기", "수비수", "골키퍼", "패스왕", "헤더왕", "골게터", "오버랩", "프리킥", "발재간", "삽질러", "똥볼러", "돌파왕", "압박왕", "태클왕", "중거리", "발리슛", "빌드업", "백패스", "자책골", "골기계", "어시왕", "역습왕", "수문장", "골부자", "골가뭄", "발연기", ] def _author_hash(device_id: str) -> str: """기기 원본 ID는 저장하지 않고 해시만 사용(추적 불가).""" return hashlib.sha256(device_id.encode("utf-8")).hexdigest() _NICK_A_SET = set(_NICK_A) _NICK_B_SET = set(_NICK_B) def _random_nickname() -> str: """랜덤 닉네임. 두 단어 조합으로 항상 5글자.""" return random.choice(_NICK_A) + random.choice(_NICK_B) def _valid_nickname(n: str) -> bool: """서버 단어풀에서 나온 정상 닉인지 검증(앞2 + 뒤3).""" n = (n or "").strip() return len(n) == 5 and n[:2] in _NICK_A_SET and n[2:] in _NICK_B_SET @router.get("/{match_id}/comments/nickname", response_model=CommentNicknameOut) async def issue_nickname(match_id: str) -> CommentNicknameOut: """세션 시작 시 랜덤 닉 발급(클라가 sessionStorage 에 캐싱해 재사용).""" return CommentNicknameOut(nickname=_random_nickname()) def _out(c: Comment) -> CommentOut: return CommentOut( nickname=c.nickname, body=c.body, createdAt=c.created_at.isoformat(), ) @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), db: AsyncSession = Depends(get_db), ) -> CommentListOut: 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) 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") author_hash = _author_hash(body.deviceId) # 쿨다운: 같은 기기(해시)의 가장 최근 작성과의 간격 확인 last_at = ( await db.execute( select(func.max(Comment.created_at)).where( Comment.author_hash == author_hash ) ) ).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") # 세션 캐싱된 닉을 사용하되, 풀에 없는(조작된) 값이면 새 랜덤으로 대체 nickname = body.nickname if _valid_nickname(body.nickname) else _random_nickname() comment = Comment( match_id=match_id, author_hash=author_hash, nickname=nickname, body=body.body, ) db.add(comment) await db.commit() await db.refresh(comment) return _out(comment)