diff --git a/backend/app/models.py b/backend/app/models.py index 4d6adef..8373ae6 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -205,11 +205,12 @@ class PageVisit(Base): class Comment(Base): - """경기별 한마디(댓글). 비로그인 — device_id 로 식별, 투표 시 입력한 email 을 - 닉네임(마스킹 표시)으로 사용. 테이블은 범용이고 노출 경기는 프론트가 제한한다. + """경기별 한마디(댓글). 완전 익명 — 인증/이메일 없음. - - 최신순 조회(created_at DESC) + limit/offset 페이징("더보기" 인라인 펼침). - - is_hidden=True 는 관리자/신고 숨김 — 조회에서 제외. + - author_hash: sha256(device_id). 원본 기기ID는 저장하지 않음(추적 불가). 쿨다운 식별용. + - nickname: 축구+코믹 한국어 5글자(기기 해시로 자동 배정, 기기당 고정). + - id(PK)·author_hash 는 내부용으로 API 응답에 노출하지 않음. + - 최신순 조회(created_at DESC) + limit/offset 페이징. is_hidden=True 는 조회 제외. """ __tablename__ = "comments" @@ -218,8 +219,8 @@ class Comment(Base): match_id: Mapped[str] = mapped_column( ForeignKey("matches.match_id", ondelete="CASCADE"), index=True ) - device_id: Mapped[str] = mapped_column(String, index=True) # 비로그인 식별 + 쿨다운 기준 - email: Mapped[str] = mapped_column(String) # 소문자 정규화 — 마스킹해 표시 + author_hash: Mapped[str] = mapped_column(String, index=True) # sha256(device_id) — 원본 비저장 + nickname: Mapped[str] = mapped_column(String) # 축구 코믹 한국어 5글자 body: Mapped[str] = mapped_column(String) # 길이 제한은 스키마(200자)에서 is_hidden: Mapped[bool] = mapped_column(Boolean, default=False) created_at: Mapped[datetime] = mapped_column( diff --git a/backend/app/routers/comments.py b/backend/app/routers/comments.py index 379ca39..7256204 100644 --- a/backend/app/routers/comments.py +++ b/backend/app/routers/comments.py @@ -1,13 +1,18 @@ -"""경기별 한마디(댓글) API — 조회(최신순·페이징) · 작성(검증·쿨다운) · 본인 삭제. +"""경기별 한마디(댓글) API — 완전 익명. 조회(최신순·페이징) · 작성(검증·쿨다운). -신원 기준은 전부 **이메일**(포인트·리더보드와 동일). device_id 는 저장만 하고 로직엔 안 씀. -- 닉네임: 투표 시 입력한 email 을 마스킹(ab***@gmail.com)해 노출. -- 작성 자격: 그 이메일로 이 경기에 투표했어야 함. -- 본인 댓글: 같은 이메일이면 어느 기기에서든 삭제 가능(다른 기기에서도 OK). -- 방어(기본): body 길이 제한(스키마 200자) · 같은 이메일 연속 작성 쿨다운 · is_hidden 제외. +신원/인증 없음: +- 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 @@ -15,47 +20,71 @@ 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 +from ..models import Comment, Match +from ..schemas import CommentIn, CommentListOut, CommentNicknameOut, CommentOut 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}" +# 닉네임 조합: 2글자(코믹 수식) + 3글자(축구 역할) = 항상 한국어 5글자. +# 40 × 30 = 1200가지. +_NICK_A = [ + "잔디", "침대", "벤치", "후보", "똥손", "헛발", "발컨", "노룩", "왼발", "멘붕", + "국대", "동네", "주말", "폭발", "광속", "진지", "발끝", "번개", "백수", "천재", + "야수", "괴물", "폭격", "강철", "무적", "질풍", "돌풍", "불꽃", "발광", "분노", + "음속", "폭탄", "슈퍼", "만년", "전설", "비밀", "미친", "라면", "치킨", "출근", +] +_NICK_B = [ + "드리블", "골사냥", "해결사", "종결자", "자판기", "수비수", "골키퍼", "패스왕", + "헤더왕", "골게터", "오버랩", "프리킥", "발재간", "삽질러", "똥볼러", "돌파왕", + "압박왕", "태클왕", "중거리", "발리슛", "빌드업", "백패스", "자책골", "골기계", + "어시왕", "역습왕", "수문장", "골부자", "골가뭄", "발연기", +] -def _out(c: Comment, viewer_emails: set[str] | None = None) -> CommentOut: +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( - id=c.id, - nickname=_mask(c.email), + nickname=c.nickname, 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)) @@ -72,9 +101,7 @@ async def list_comments( .offset(offset) ) ).scalars().all() - return CommentListOut( - items=[_out(c, viewer) for c in rows], total=total - ) + return CommentListOut(items=[_out(c) for c in rows], total=total) @router.post("/{match_id}/comments", response_model=CommentOut) @@ -87,27 +114,13 @@ async def create_comment( 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") + author_hash = _author_hash(body.deviceId) - # 쿨다운: 같은 이메일의 가장 최근 작성과의 간격 확인 + # 쿨다운: 같은 기기(해시)의 가장 최근 작성과의 간격 확인 last_at = ( await db.execute( select(func.max(Comment.created_at)).where( - func.lower(Comment.email) == email_norm + Comment.author_hash == author_hash ) ) ).scalar() @@ -116,41 +129,15 @@ async def create_comment( 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, - device_id=body.deviceId, # 저장만(분석용), 권한 판정엔 사용 안 함 - email=email_norm, + author_hash=author_hash, + nickname=nickname, 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) + return _out(comment) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 980c506..e51b2d9 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -122,10 +122,10 @@ class AdminSetResultIn(BaseModel): scoreB: int = Field(ge=0, le=50) -# ── 댓글(경기별 한마디) ───────────────────────────────────── +# ── 댓글(경기별 한마디) — 완전 익명 ───────────────────────── class CommentIn(BaseModel): - deviceId: str = Field(min_length=8, max_length=64) - email: EmailStr # 투표 시 입력한 이메일 — 마스킹해 닉네임으로 노출 + deviceId: str = Field(min_length=8, max_length=64) # 서버에서 해시 후 폐기(원본 비저장) + nickname: str = Field(min_length=1, max_length=12) # 세션 캐싱된 닉(서버 풀 검증) body: str = Field(min_length=1, max_length=200) @field_validator("body") @@ -138,11 +138,14 @@ class CommentIn(BaseModel): class CommentOut(BaseModel): - id: int - nickname: str # 마스킹된 이메일 (ab***@gmail.com) + # id·author_hash 등 내부 식별자는 노출하지 않음(닉네임만 공개) + nickname: str # 축구 코믹 한국어 5글자 (세션 발급) body: str createdAt: str # ISO8601 - mine: bool = False # 조회 요청 기기(device)가 작성한 댓글이면 True (삭제 버튼 노출용) + + +class CommentNicknameOut(BaseModel): + nickname: str # 세션 시작 시 발급받는 랜덤 닉(클라이언트가 sessionStorage에 캐싱) class CommentListOut(BaseModel): diff --git a/frontend/src/components/Comments.tsx b/frontend/src/components/Comments.tsx index dba1f99..fb1b40f 100644 --- a/frontend/src/components/Comments.tsx +++ b/frontend/src/components/Comments.tsx @@ -4,10 +4,9 @@ import { relativeTime } from "@/lib/format"; import { listComments, postComment, - deleteComment, + getSessionNickname, + rerollSessionNickname, getDeviceId, - getRecentEmails, - rememberEmail, ApiError, } from "@/lib/api"; import type { Comment } from "@/lib/types"; @@ -15,7 +14,10 @@ import type { Comment } from "@/lib/types"; const FIRST = 3; // 처음 보여줄 최신 댓글 수 const PAGE = 10; // "더보기" 한 번에 더 불러올 수 -// 경기별 한마디(댓글). 최신 3개 + "더보기" 인라인 펼침. +// 한 댓글을 식별하는 합성 키 (id 비노출이라 시각+내용으로 dedup/렌더) +const keyOf = (c: Comment) => `${c.createdAt}|${c.nickname}|${c.body}`; + +// 경기별 한마디(댓글) — 완전 익명. 최신 3개 + 더보기/접기. export default function Comments({ matchId, lang, @@ -28,12 +30,10 @@ export default function Comments({ const [total, setTotal] = useState(0); const [cursor, setCursor] = useState(0); // 서버에서 페이징으로 받은 개수 const [loadingMore, setLoadingMore] = useState(false); - const [expanded, setExpanded] = useState(false); // true=전체 보기 / false=최신 FIRST개만 + const [expanded, setExpanded] = useState(false); const [body, setBody] = useState(""); - // 투표 폼과 동일: 칸은 비우고 입력/포커스 시 datalist 드롭다운으로 최근 이메일 자동완성 - const [email, setEmail] = useState(""); - const [recentEmails, setRecentEmails] = useState(getRecentEmails); + const [nickname, setNickname] = useState(""); // 세션 닉(캐싱) const [posting, setPosting] = useState(false); const [error, setError] = useState(""); @@ -47,17 +47,18 @@ export default function Comments({ setCursor(res.items.length); }) .catch(() => {}); + getSessionNickname(matchId) + .then((n) => alive && setNickname(n)) + .catch(() => {}); return () => { alive = false; }; }, [matchId]); - // 접힘 상태면 최신 FIRST개만 노출 const visible = expanded ? items : items.slice(0, FIRST); - const unloaded = Math.max(0, total - items.length); // 아직 서버에서 안 받은 수 - const hiddenWhenCollapsed = Math.max(0, total - FIRST); // 접힘 시 가려진 전체 수 + const unloaded = Math.max(0, total - items.length); + const hiddenWhenCollapsed = Math.max(0, total - FIRST); - // "더보기": 펼치고, 받아온 게 FIRST개뿐이면 다음 페이지도 가져온다 async function showMore() { setExpanded(true); if (items.length <= FIRST && unloaded > 0) await loadMore(); @@ -69,8 +70,8 @@ export default function Comments({ try { const res = await listComments(matchId, PAGE, cursor); setItems((prev) => { - const seen = new Set(prev.map((c) => c.id)); - return [...prev, ...res.items.filter((c) => !seen.has(c.id))]; + const seen = new Set(prev.map(keyOf)); + return [...prev, ...res.items.filter((c) => !seen.has(keyOf(c)))]; }); setTotal(res.total); setCursor((c) => c + res.items.length); @@ -81,46 +82,31 @@ export default function Comments({ } } - async function remove(id: number) { - if (!window.confirm(t.commentDeleteConfirm)) return; + async function reroll() { try { - await deleteComment(matchId, id); - setItems((prev) => prev.filter((c) => c.id !== id)); - setTotal((n) => Math.max(0, n - 1)); + const n = await rerollSessionNickname(matchId); + setNickname(n); } catch { - setError(t.commentError); + /* 무시 */ } } async function submit() { const text = body.trim(); - const mail = email.trim(); - if (posting) return; + if (posting || !text) return; setError(""); - if (!mail) { - setError(t.commentNeedEmail); - return; - } - if (!text) return; setPosting(true); try { const created = await postComment(matchId, { deviceId: getDeviceId(), - email: mail, + nickname, body: text, }); - rememberEmail(mail); - setRecentEmails(getRecentEmails()); // 방금 쓴 이메일을 자동완성 목록에 반영 setItems((prev) => [created, ...prev]); // 낙관적: 맨 위에 즉시 표시 setTotal((n) => n + 1); setBody(""); } catch (e) { - if (e instanceof ApiError && e.status === 403) - setError(t.commentNeedVote); - else if (e instanceof ApiError && e.status === 429) - setError(t.commentCooldown); - else if (e instanceof ApiError && e.status === 422) - setError(t.commentNeedEmail); + if (e instanceof ApiError && e.status === 429) setError(t.commentCooldown); else setError(t.commentError); } finally { setPosting(false); @@ -141,22 +127,20 @@ export default function Comments({ {/* 입력 */}
- setEmail(e.target.value)} - placeholder={t.commentEmailPh} - className="w-full rounded-lg border border-[var(--line-d)] bg-[#0f1217] px-3 py-2.5 text-[14px] text-white outline-none focus:border-[var(--green)]" - /> - {recentEmails.length > 0 && ( - - {recentEmails.map((e) => ( - + {nickname && ( +

+ ⚽ {t.commentAs}{" "} + {nickname} + +

)}