167 lines
6.8 KiB
Python
167 lines
6.8 KiB
Python
"""경기별 한마디(댓글) 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)
|