diff --git a/backend/app/main.py b/backend/app/main.py index e2e5ce8..abb8bc9 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -13,7 +13,15 @@ from fastapi.middleware.cors import CORSMiddleware from .config import settings from .database import init_db -from .routers import admin, leaderboard, matches, predictions, share, visits +from .routers import ( + admin, + comments, + leaderboard, + matches, + predictions, + share, + visits, +) from .scoring import load_scoring_data from .seed import seed_if_empty @@ -41,6 +49,7 @@ app.add_middleware( ) app.include_router(matches.router) +app.include_router(comments.router) app.include_router(predictions.router) app.include_router(leaderboard.router) app.include_router(admin.router) diff --git a/backend/app/models.py b/backend/app/models.py index 4e49e3f..4d6adef 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -204,6 +204,29 @@ class PageVisit(Base): ) +class Comment(Base): + """경기별 한마디(댓글). 비로그인 — device_id 로 식별, 투표 시 입력한 email 을 + 닉네임(마스킹 표시)으로 사용. 테이블은 범용이고 노출 경기는 프론트가 제한한다. + + - 최신순 조회(created_at DESC) + limit/offset 페이징("더보기" 인라인 펼침). + - is_hidden=True 는 관리자/신고 숨김 — 조회에서 제외. + """ + + __tablename__ = "comments" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + 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) # 소문자 정규화 — 마스킹해 표시 + body: Mapped[str] = mapped_column(String) # 길이 제한은 스키마(200자)에서 + is_hidden: Mapped[bool] = mapped_column(Boolean, default=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), index=True + ) + + class FootballCache(Base): """축구 데이터 캐시 (API-Football 수집 결과) — 예측 프롬프트 조립용. diff --git a/backend/app/routers/comments.py b/backend/app/routers/comments.py new file mode 100644 index 0000000..379ca39 --- /dev/null +++ b/backend/app/routers/comments.py @@ -0,0 +1,156 @@ +"""경기별 한마디(댓글) 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) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 1621f3e..980c506 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -122,6 +122,34 @@ class AdminSetResultIn(BaseModel): scoreB: int = Field(ge=0, le=50) +# ── 댓글(경기별 한마디) ───────────────────────────────────── +class CommentIn(BaseModel): + deviceId: str = Field(min_length=8, max_length=64) + email: EmailStr # 투표 시 입력한 이메일 — 마스킹해 닉네임으로 노출 + body: str = Field(min_length=1, max_length=200) + + @field_validator("body") + @classmethod + def body_not_blank(cls, v: str) -> str: + v = v.strip() + if not v: + raise ValueError("EMPTY_BODY") + return v + + +class CommentOut(BaseModel): + id: int + nickname: str # 마스킹된 이메일 (ab***@gmail.com) + body: str + createdAt: str # ISO8601 + mine: bool = False # 조회 요청 기기(device)가 작성한 댓글이면 True (삭제 버튼 노출용) + + +class CommentListOut(BaseModel): + items: list[CommentOut] + total: int # 숨김 제외 전체 개수 — "더보기" 남은 수 계산용 + + class GenericOk(BaseModel): ok: bool detail: str = "" diff --git a/frontend/src/components/Comments.tsx b/frontend/src/components/Comments.tsx new file mode 100644 index 0000000..dba1f99 --- /dev/null +++ b/frontend/src/components/Comments.tsx @@ -0,0 +1,265 @@ +import { useEffect, useState } from "react"; +import { dict, type Lang } from "@/lib/i18n"; +import { relativeTime } from "@/lib/format"; +import { + listComments, + postComment, + deleteComment, + getDeviceId, + getRecentEmails, + rememberEmail, + ApiError, +} from "@/lib/api"; +import type { Comment } from "@/lib/types"; + +const FIRST = 3; // 처음 보여줄 최신 댓글 수 +const PAGE = 10; // "더보기" 한 번에 더 불러올 수 + +// 경기별 한마디(댓글). 최신 3개 + "더보기" 인라인 펼침. +export default function Comments({ + matchId, + lang, +}: { + matchId: string; + lang: Lang; +}) { + const t = dict(lang); + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [cursor, setCursor] = useState(0); // 서버에서 페이징으로 받은 개수 + const [loadingMore, setLoadingMore] = useState(false); + const [expanded, setExpanded] = useState(false); // true=전체 보기 / false=최신 FIRST개만 + + const [body, setBody] = useState(""); + // 투표 폼과 동일: 칸은 비우고 입력/포커스 시 datalist 드롭다운으로 최근 이메일 자동완성 + const [email, setEmail] = useState(""); + const [recentEmails, setRecentEmails] = useState(getRecentEmails); + const [posting, setPosting] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + let alive = true; + listComments(matchId, FIRST, 0) + .then((res) => { + if (!alive) return; + setItems(res.items); + setTotal(res.total); + setCursor(res.items.length); + }) + .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); // 접힘 시 가려진 전체 수 + + // "더보기": 펼치고, 받아온 게 FIRST개뿐이면 다음 페이지도 가져온다 + async function showMore() { + setExpanded(true); + if (items.length <= FIRST && unloaded > 0) await loadMore(); + } + + async function loadMore() { + if (loadingMore) return; + setLoadingMore(true); + 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))]; + }); + setTotal(res.total); + setCursor((c) => c + res.items.length); + } catch { + /* 조용히 무시 — 다시 누르면 재시도 */ + } finally { + setLoadingMore(false); + } + } + + async function remove(id: number) { + if (!window.confirm(t.commentDeleteConfirm)) return; + try { + await deleteComment(matchId, id); + setItems((prev) => prev.filter((c) => c.id !== id)); + setTotal((n) => Math.max(0, n - 1)); + } catch { + setError(t.commentError); + } + } + + async function submit() { + const text = body.trim(); + const mail = email.trim(); + if (posting) return; + setError(""); + if (!mail) { + setError(t.commentNeedEmail); + return; + } + if (!text) return; + setPosting(true); + try { + const created = await postComment(matchId, { + deviceId: getDeviceId(), + email: mail, + 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); + else setError(t.commentError); + } finally { + setPosting(false); + } + } + + return ( +
+ {/* 헤더 */} +
+

+ 💬 {t.commentsTitle} +

+ + {t.commentsCount(total)} + +
+ + {/* 입력 */} +
+ 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) => ( + + )} +
+