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

167 lines
6.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""경기별 한마디(댓글) 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글자.
# 리그별 풀 — 축구(월드컵) / 야구(KBO·MLB). 각 40 × 30 = 1200가지.
_NICK_A = [
"잔디", "침대", "벤치", "후보", "똥손", "헛발", "발컨", "노룩", "왼발", "멘붕",
"국대", "동네", "주말", "폭발", "광속", "진지", "발끝", "번개", "백수", "천재",
"야수", "괴물", "폭격", "강철", "무적", "질풍", "돌풍", "불꽃", "발광", "분노",
"음속", "폭탄", "슈퍼", "만년", "전설", "비밀", "미친", "라면", "치킨", "출근",
]
_NICK_B = [
"드리블", "골사냥", "해결사", "종결자", "자판기", "수비수", "골키퍼", "패스왕",
"헤더왕", "골게터", "오버랩", "프리킥", "발재간", "삽질러", "똥볼러", "돌파왕",
"압박왕", "태클왕", "중거리", "발리슛", "빌드업", "백패스", "자책골", "골기계",
"어시왕", "역습왕", "수문장", "골부자", "골가뭄", "발연기",
]
_NICK_A_BB = [
"잔디", "벤치", "후보", "똥손", "노룩", "멘붕", "동네", "주말", "폭발", "광속",
"진지", "번개", "백수", "천재", "괴물", "폭격", "강철", "무적", "질풍", "돌풍",
"불꽃", "분노", "음속", "폭탄", "슈퍼", "만년", "전설", "비밀", "미친", "라면",
"치킨", "출근", "직관", "치맥", "응원", "국대", "신인", "은퇴", "각성", "꾸준",
]
_NICK_B_BB = [
"홈런왕", "도루왕", "타격왕", "안타왕", "삼진왕", "수비왕", "번트왕", "대타왕",
"역전타", "끝내기", "결승타", "병살타", "유격수", "외야수", "내야수", "마무리",
"셋업맨", "불펜왕", "승리조", "강속구", "커브왕", "직구왕", "변화구", "풀스윙",
"헛스윙", "배트맨", "만루왕", "출루왕", "도루자", "홈스틸",
]
def _author_hash(device_id: str) -> str:
"""기기 원본 ID는 저장하지 않고 해시만 사용(추적 불가)."""
return hashlib.sha256(device_id.encode("utf-8")).hexdigest()
# 검증은 두 리그 풀 합집합 기준(발급 리그와 작성 리그가 달라도 정상 닉이면 통과)
_NICK_A_SET = set(_NICK_A) | set(_NICK_A_BB)
_NICK_B_SET = set(_NICK_B) | set(_NICK_B_BB)
def _is_baseball(match_id: str) -> bool:
return match_id.startswith(("KBO_", "MLB_"))
def _random_nickname(baseball: bool = False) -> str:
"""랜덤 닉네임. 두 단어 조합으로 항상 5글자 — 리그에 맞는 풀 사용."""
if baseball:
return random.choice(_NICK_A_BB) + random.choice(_NICK_B_BB)
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(_is_baseball(match_id)))
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(_is_baseball(match_id))
)
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)