feat: 댓글 익명화 + 전 경기 적용

- 이메일/투표 게이트 제거 → 완전 익명
- 세션 단위 발급(sessionStorage 캐싱) — 탭 닫으면/시크릿이면 새 닉
develop
jwkim 2026-06-17 14:47:26 +09:00
parent 8a67094510
commit b62bedc665
8 changed files with 132 additions and 204 deletions

View File

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

View File

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

View File

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

View File

@ -4,10 +4,8 @@ import { relativeTime } from "@/lib/format";
import {
listComments,
postComment,
deleteComment,
getSessionNickname,
getDeviceId,
getRecentEmails,
rememberEmail,
ApiError,
} from "@/lib/api";
import type { Comment } from "@/lib/types";
@ -15,7 +13,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 +29,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<string[]>(getRecentEmails);
const [nickname, setNickname] = useState(""); // 세션 닉(캐싱)
const [posting, setPosting] = useState(false);
const [error, setError] = useState("");
@ -47,17 +46,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 +69,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 +81,22 @@ export default function Comments({
}
}
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;
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 +117,11 @@ export default function Comments({
{/* 입력 */}
<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>
{nickname && (
<p className="text-[12px] font-bold text-white/45">
{t.commentAs}{" "}
<span className="text-[var(--green)]">{nickname}</span>
</p>
)}
<div className="flex items-end gap-2">
<textarea
@ -199,8 +164,8 @@ export default function Comments({
expanded ? "max-h-[360px] overflow-y-auto pr-1" : ""
}`}
>
{visible.map((c) => (
<li key={c.id} className="py-3 first:pt-0">
{visible.map((c, i) => (
<li key={`${keyOf(c)}-${i}`} className="py-3 first:pt-0">
<div className="flex items-baseline gap-2">
<span className="text-[13px] font-extrabold text-[var(--green)]">
{c.nickname}
@ -208,15 +173,6 @@ export default function Comments({
<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}

View File

@ -73,29 +73,20 @@ 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}`,
`/matches/${matchId}/comments?limit=${limit}&offset=${offset}`,
);
}
export function postComment(
matchId: string,
body: { deviceId: string; email: string; body: string },
body: { deviceId: string; nickname: string; body: string },
): Promise<Comment> {
return http<Comment>(`/matches/${matchId}/comments`, {
method: "POST",
@ -103,16 +94,25 @@ export function postComment(
});
}
// 본인 댓글 삭제 — 내 이메일과 작성 이메일이 같으면 어느 기기에서든 삭제 가능
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" },
// ── 세션 닉네임: 세션당 한 번 발급받아 sessionStorage 에 캐싱 ──
// 탭/창을 닫으면 비워지고(껐다 켜면 새 닉), 시크릿창은 별도 저장소라 새로 발급됨.
const SESSION_NICK_KEY = "tp_session_nick";
export async function getSessionNickname(matchId: string): Promise<string> {
try {
const cached = sessionStorage.getItem(SESSION_NICK_KEY);
if (cached) return cached;
} catch {
/* sessionStorage 불가 환경은 매번 발급 */
}
const { nickname } = await http<{ nickname: string }>(
`/matches/${matchId}/comments/nickname`,
);
try {
sessionStorage.setItem(SESSION_NICK_KEY, nickname);
} catch {
/* noop */
}
return nickname;
}
// 방문 기록 (하루 1기기 1회 집계). 실패는 무시(fire-and-forget).

View File

@ -102,15 +102,11 @@ type Dict = {
commentsTitle: string;
commentsCount: (n: number) => string;
commentPh: string;
commentEmailPh: string;
commentAs: string;
commentSubmit: string;
commentMore: string;
commentCollapse: string;
commentDelete: string;
commentDeleteConfirm: string;
commentEmpty: string;
commentNeedEmail: string;
commentNeedVote: string;
commentCooldown: string;
commentError: string;
adLabel: string;
@ -188,15 +184,11 @@ export const DICT: Record<Lang, Dict> = {
commentsTitle: "한마디",
commentsCount: (n) => `${n}`,
commentPh: "응원 한마디 남기기...",
commentEmailPh: "이메일",
commentAs: "이번 닉네임",
commentSubmit: "등록",
commentMore: "댓글 더보기",
commentCollapse: "접기",
commentDelete: "삭제",
commentDeleteConfirm: "이 댓글을 삭제할까요?",
commentEmpty: "첫 한마디를 남겨보세요!",
commentNeedEmail: "이메일을 입력해주세요.",
commentNeedVote: "이 경기에 투표한 후 댓글을 쓸 수 있어요.",
commentCooldown: "잠시 후 다시 시도해주세요.",
commentError: "등록에 실패했습니다. 잠시 후 다시 시도해주세요.",
adLabel: "광고",
@ -272,15 +264,11 @@ export const DICT: Record<Lang, Dict> = {
commentsTitle: "Comments",
commentsCount: (n) => `${n} total`,
commentPh: "Leave a comment...",
commentEmailPh: "Email (shown as masked nickname)",
commentAs: "Your name",
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",

View File

@ -76,11 +76,9 @@ export interface Leaderboard {
}
export interface Comment {
id: number;
nickname: string; // 마스킹된 이메일 (ab***@gmail.com)
nickname: string; // 축구 코믹 한국어 5글자 (서버가 기기 해시로 자동 배정)
body: string;
createdAt: string;
mine: boolean; // 내(이메일) 댓글이면 true → 삭제 버튼 노출
}
export interface CommentList {

View File

@ -10,9 +10,6 @@ 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();
@ -102,10 +99,8 @@ export default function MatchDetail() {
lang={lang}
/>
{/* 한국–멕시코 경기에만 한마디(댓글) 노출 */}
{match.matchId === COMMENTS_MATCH_ID && (
{/* 한마디(댓글) — 전 경기 노출 (경기 상태 무관) */}
<Comments matchId={match.matchId} lang={lang} />
)}
{/* 하단 CTA — 전체 일정으로(다른 경기 투표). ADO2 버튼과 동일 사양(퍼플 #A65EFF) */}
<Link