한국 멕시코 경기 댓글 기능 추가

develop
jwkim 2026-06-17 11:22:29 +09:00
parent 7625db36a0
commit 8a67094510
10 changed files with 611 additions and 2 deletions

View File

@ -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)

View File

@ -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 수집 결과) — 예측 프롬프트 조립용.

View File

@ -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)

View File

@ -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 = ""

View File

@ -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<Comment[]>([]);
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<string[]>(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 (
<section className="mt-7 rounded-2xl border border-[var(--line-d)] bg-[var(--bg2)] p-4">
{/* 헤더 */}
<div className="flex items-baseline gap-2">
<h2 className="text-[17px] font-extrabold text-white">
💬 {t.commentsTitle}
</h2>
<span className="text-[13px] font-bold text-white/45">
{t.commentsCount(total)}
</span>
</div>
{/* 입력 */}
<div className="mt-3 space-y-2">
<input
type="email"
inputMode="email"
autoComplete="email"
list="tp-comment-email-suggestions"
value={email}
onChange={(e) => 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 && (
<datalist id="tp-comment-email-suggestions">
{recentEmails.map((e) => (
<option key={e} value={e} />
))}
</datalist>
)}
<div className="flex items-end gap-2">
<textarea
value={body}
onChange={(e) => setBody(e.target.value.slice(0, 200))}
placeholder={t.commentPh}
rows={2}
maxLength={200}
className="min-h-[44px] flex-1 resize-none rounded-lg border border-[var(--line-d)] bg-[#0f1217] px-3 py-2.5 text-[15px] text-white outline-none focus:border-[var(--green)]"
/>
<button
type="button"
onClick={submit}
disabled={posting || !body.trim()}
className="shrink-0 rounded-lg bg-[var(--green)] px-4 py-2.5 text-[15px] font-extrabold text-black transition active:scale-[0.98] disabled:opacity-40"
>
{t.commentSubmit}
</button>
</div>
<div className="flex items-center justify-between">
{error ? (
<span className="text-[12px] font-bold text-red-400">{error}</span>
) : (
<span />
)}
<span className="text-[11px] tabular-nums text-white/35">
{body.length}/200
</span>
</div>
</div>
{/* 목록 */}
{items.length === 0 ? (
<p className="mt-4 py-3 text-center text-[13px] text-white/40">
{t.commentEmpty}
</p>
) : (
<ul
className={`mt-4 divide-y divide-[var(--line-d)] ${
expanded ? "max-h-[360px] overflow-y-auto pr-1" : ""
}`}
>
{visible.map((c) => (
<li key={c.id} className="py-3 first:pt-0">
<div className="flex items-baseline gap-2">
<span className="text-[13px] font-extrabold text-[var(--green)]">
{c.nickname}
</span>
<span className="text-[11px] text-white/35">
{relativeTime(c.createdAt, lang)}
</span>
{c.mine && (
<button
type="button"
onClick={() => remove(c.id)}
className="ml-auto text-[11px] font-bold text-white/35 transition hover:text-red-400"
>
{t.commentDelete}
</button>
)}
</div>
<p className="mt-1 whitespace-pre-wrap break-words text-[14px] leading-snug text-white/90">
{c.body}
</p>
</li>
))}
</ul>
)}
{/* 더보기 / 접기 */}
{!expanded && hiddenWhenCollapsed > 0 && (
<button
type="button"
onClick={showMore}
disabled={loadingMore}
className="mt-2 w-full rounded-lg border border-[var(--line-d)] py-2.5 text-[13px] font-bold text-white/70 transition hover:text-white disabled:opacity-50"
>
{t.commentMore}
</button>
)}
{expanded && (
<div className="mt-2 flex gap-2">
{unloaded > 0 && (
<button
type="button"
onClick={loadMore}
disabled={loadingMore}
className="flex-1 rounded-lg border border-[var(--line-d)] py-2.5 text-[13px] font-bold text-white/70 transition hover:text-white disabled:opacity-50"
>
{t.commentMore}
</button>
)}
{items.length > FIRST && (
<button
type="button"
onClick={() => setExpanded(false)}
className="flex-1 rounded-lg border border-[var(--line-d)] py-2.5 text-[13px] font-bold text-white/70 transition hover:text-white"
>
{t.commentCollapse}
</button>
)}
</div>
)}
</section>
);
}

View File

@ -1,5 +1,12 @@
// 백엔드 FastAPI 클라이언트. 동일 출처 /api (dev=vite proxy, prod=nginx proxy).
import type { CrowdStats, Leaderboard, Match, ModelName } from "./types";
import type {
Comment,
CommentList,
CrowdStats,
Leaderboard,
Match,
ModelName,
} from "./types";
import type { Lang } from "./i18n";
const BASE = import.meta.env.VITE_API_BASE || "/api";
@ -66,6 +73,48 @@ export function getLeaderboard(): Promise<Leaderboard> {
return http<Leaderboard>(`/leaderboard`);
}
// ── 댓글(경기별 한마디) ──
// 내 이메일(localStorage 최근 이메일) 목록을 함께 보내 mine 플래그/삭제 권한 판정에 사용
function emailQuery(emails: string[]): string {
return emails
.filter(Boolean)
.map((e) => `&email=${encodeURIComponent(e)}`)
.join("");
}
export function listComments(
matchId: string,
limit: number,
offset: number,
): Promise<CommentList> {
const q = emailQuery(getRecentEmails());
return http<CommentList>(
`/matches/${matchId}/comments?limit=${limit}&offset=${offset}${q}`,
);
}
export function postComment(
matchId: string,
body: { deviceId: string; email: string; body: string },
): Promise<Comment> {
return http<Comment>(`/matches/${matchId}/comments`, {
method: "POST",
body: JSON.stringify(body),
});
}
// 본인 댓글 삭제 — 내 이메일과 작성 이메일이 같으면 어느 기기에서든 삭제 가능
export function deleteComment(
matchId: string,
commentId: number,
): Promise<{ ok: boolean }> {
const q = emailQuery(getRecentEmails());
return http<{ ok: boolean }>(
`/matches/${matchId}/comments/${commentId}?${q.replace(/^&/, "")}`,
{ method: "DELETE" },
);
}
// 방문 기록 (하루 1기기 1회 집계). 실패는 무시(fire-and-forget).
export function recordVisit(): void {
void http(`/visit`, {

View File

@ -54,3 +54,18 @@ export function pct(part: number, total: number): number {
if (!total) return 0;
return Math.round((part / total) * 100);
}
// "방금 전 / 3분 전 / 2시간 전 / 5일 전" — 댓글 상대시간
export function relativeTime(iso: string, lang: Lang = "ko"): string {
const then = new Date(iso).getTime();
if (Number.isNaN(then)) return "";
const sec = Math.max(0, Math.floor((Date.now() - then) / 1000));
const en = lang === "en";
if (sec < 60) return en ? "just now" : "방금 전";
const min = Math.floor(sec / 60);
if (min < 60) return en ? `${min}m ago` : `${min}분 전`;
const hr = Math.floor(min / 60);
if (hr < 24) return en ? `${hr}h ago` : `${hr}시간 전`;
const day = Math.floor(hr / 24);
return en ? `${day}d ago` : `${day}일 전`;
}

View File

@ -99,6 +99,20 @@ type Dict = {
footerDisc3: string;
hook: (a: string, b: string) => string;
moreMatches: string;
commentsTitle: string;
commentsCount: (n: number) => string;
commentPh: string;
commentEmailPh: string;
commentSubmit: string;
commentMore: string;
commentCollapse: string;
commentDelete: string;
commentDeleteConfirm: string;
commentEmpty: string;
commentNeedEmail: string;
commentNeedVote: string;
commentCooldown: string;
commentError: string;
adLabel: string;
ado2Tagline: string;
ado2Sub: string;
@ -171,6 +185,20 @@ export const DICT: Record<Lang, Dict> = {
footerDisc3: "100만 원 이벤트는 무료 참여형 챌린지입니다. 지급·동점 처리 조건은 별도 약관에 따릅니다. 이메일은 결과·이벤트 알림 목적으로만 사용됩니다.",
hook: (a, b) => `${a} vs ${b}, AI의 선택은 갈렸다`,
moreMatches: "다른 경기 투표하기",
commentsTitle: "한마디",
commentsCount: (n) => `${n}`,
commentPh: "응원 한마디 남기기...",
commentEmailPh: "이메일",
commentSubmit: "등록",
commentMore: "댓글 더보기",
commentCollapse: "접기",
commentDelete: "삭제",
commentDeleteConfirm: "이 댓글을 삭제할까요?",
commentEmpty: "첫 한마디를 남겨보세요!",
commentNeedEmail: "이메일을 입력해주세요.",
commentNeedVote: "이 경기에 투표한 후 댓글을 쓸 수 있어요.",
commentCooldown: "잠시 후 다시 시도해주세요.",
commentError: "등록에 실패했습니다. 잠시 후 다시 시도해주세요.",
adLabel: "광고",
ado2Tagline: "AI 마케팅 자동화 플랫폼",
ado2Sub: "AI Marketing Automation Platform →",
@ -241,6 +269,20 @@ export const DICT: Record<Lang, Dict> = {
footerDisc3: "The ₩1,000,000 event is a free-to-enter challenge. Payout & tie-break terms follow separate rules. Email is used only for result & event alerts.",
hook: (a, b) => `AI is split on ${a} vs ${b}`,
moreMatches: "Vote on another match",
commentsTitle: "Comments",
commentsCount: (n) => `${n} total`,
commentPh: "Leave a comment...",
commentEmailPh: "Email (shown as masked nickname)",
commentSubmit: "Post",
commentMore: "Show more",
commentCollapse: "Collapse",
commentDelete: "Delete",
commentDeleteConfirm: "Delete this comment?",
commentEmpty: "Be the first to comment!",
commentNeedEmail: "Please enter your email.",
commentNeedVote: "Vote on this match first to leave a comment.",
commentCooldown: "Please wait a moment before posting again.",
commentError: "Failed to post. Please try again shortly.",
adLabel: "AD",
ado2Tagline: "AI Marketing Automation Platform",
ado2Sub: "Visit ado2.o2osolution.ai →",

View File

@ -74,3 +74,16 @@ export interface Leaderboard {
standings: Standing[];
scoredMatches: number;
}
export interface Comment {
id: number;
nickname: string; // 마스킹된 이메일 (ab***@gmail.com)
body: string;
createdAt: string;
mine: boolean; // 내(이메일) 댓글이면 true → 삭제 버튼 노출
}
export interface CommentList {
items: Comment[];
total: number;
}

View File

@ -3,12 +3,16 @@ import { Link, useParams } from "react-router-dom";
import Hero from "@/components/Hero";
import MatchupHUD from "@/components/MatchupHUD";
import Arena from "@/components/Arena";
import Comments from "@/components/Comments";
import Footer from "@/components/Footer";
import { dict, teamShort } from "@/lib/i18n";
import { useLang } from "@/lib/useLang";
import { getMatch, matchUrl, ApiError } from "@/lib/api";
import type { Match } from "@/lib/types";
// 댓글(한마디)을 노출할 경기 — 한국 vs 멕시코 (A조 2차전)
const COMMENTS_MATCH_ID = "A_MEX_KOR_20260619";
// L2. 경기 상세(대결) 페이지
export default function MatchDetail() {
const { matchId = "" } = useParams();
@ -98,6 +102,11 @@ export default function MatchDetail() {
lang={lang}
/>
{/* 한국–멕시코 경기에만 한마디(댓글) 노출 */}
{match.matchId === COMMENTS_MATCH_ID && (
<Comments matchId={match.matchId} lang={lang} />
)}
{/* 하단 CTA — 전체 일정으로(다른 경기 투표). ADO2 버튼과 동일 사양(퍼플 #A65EFF) */}
<Link
to={lang === "en" ? "/?lang=en" : "/"}