Merge branch 'develop' of https://gitea.o2o.kr/castad/o2o-triple-pick into develop

develop
김성경 2026-06-17 16:51:59 +09:00
commit 220eb946c1
8 changed files with 162 additions and 199 deletions

View File

@ -205,11 +205,12 @@ class PageVisit(Base):
class Comment(Base): class Comment(Base):
"""경기별 한마디(댓글). 비로그인 — device_id 로 식별, 투표 시 입력한 email 을 """경기별 한마디(댓글). 완전 익명 — 인증/이메일 없음.
닉네임(마스킹 표시)으로 사용. 테이블은 범용이고 노출 경기는 프론트가 제한한다.
- 최신순 조회(created_at DESC) + limit/offset 페이징("더보기" 인라인 펼침). - author_hash: sha256(device_id). 원본 기기ID는 저장하지 않음(추적 불가). 쿨다운 식별용.
- is_hidden=True 관리자/신고 숨김 조회에서 제외. - nickname: 축구+코믹 한국어 5글자(기기 해시로 자동 배정, 기기당 고정).
- id(PK)·author_hash 내부용으로 API 응답에 노출하지 않음.
- 최신순 조회(created_at DESC) + limit/offset 페이징. is_hidden=True 조회 제외.
""" """
__tablename__ = "comments" __tablename__ = "comments"
@ -218,8 +219,8 @@ class Comment(Base):
match_id: Mapped[str] = mapped_column( match_id: Mapped[str] = mapped_column(
ForeignKey("matches.match_id", ondelete="CASCADE"), index=True ForeignKey("matches.match_id", ondelete="CASCADE"), index=True
) )
device_id: Mapped[str] = mapped_column(String, index=True) # 비로그인 식별 + 쿨다운 기준 author_hash: Mapped[str] = mapped_column(String, index=True) # sha256(device_id) — 원본 비저장
email: Mapped[str] = mapped_column(String) # 소문자 정규화 — 마스킹해 표시 nickname: Mapped[str] = mapped_column(String) # 축구 코믹 한국어 5글자
body: Mapped[str] = mapped_column(String) # 길이 제한은 스키마(200자)에서 body: Mapped[str] = mapped_column(String) # 길이 제한은 스키마(200자)에서
is_hidden: Mapped[bool] = mapped_column(Boolean, default=False) is_hidden: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(

View File

@ -1,13 +1,18 @@
"""경기별 한마디(댓글) API — 조회(최신순·페이징) · 작성(검증·쿨다운) · 본인 삭제. """경기별 한마디(댓글) API — 완전 익명. 조회(최신순·페이징) · 작성(검증·쿨다운).
신원 기준은 전부 **이메일**(포인트·리더보드와 동일). device_id 저장만 하고 로직엔 . 신원/인증 없음:
- 닉네임: 투표 입력한 email 마스킹(ab***@gmail.com) 노출. - author_hash = sha256(device_id). 기기 원본 ID는 저장하지 않음(추적 불가). 쿨다운 전용.
- 작성 자격: 이메일로 경기에 투표했어야 . - nickname: 축구+코믹 한국어 5글자. **세션 단위 발급** 클라가 sessionStorage 캐싱해
- 본인 댓글: 같은 이메일이면 어느 기기에서든 삭제 가능(다른 기기에서도 OK). 세션 동안 같은 닉을 재사용, /창을 닫았다 켜거나 시크릿이면 새로 발급.
- 방어(기본): body 길이 제한(스키마 200) · 같은 이메일 연속 작성 쿨다운 · is_hidden 제외. 발급은 서버 단어풀에서만(욕설/사칭 방지) 작성 시에도 검증.
- id(PK)·author_hash 내부용으로 API 응답에 노출하지 않음.
- 방어(기본): body 길이 제한(스키마 200) · 같은 기기 연속 작성 쿨다운 · is_hidden 제외.
- 테이블은 범용(모든 경기)이고, 노출 경기 제한은 프론트가 담당.
""" """
from __future__ import annotations from __future__ import annotations
import hashlib
import random
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
@ -15,47 +20,71 @@ from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ..database import get_db from ..database import get_db
from ..models import Comment, Match, UserPrediction from ..models import Comment, Match
from ..schemas import CommentIn, CommentListOut, CommentOut, GenericOk from ..schemas import CommentIn, CommentListOut, CommentNicknameOut, CommentOut
router = APIRouter(prefix="/api/matches", tags=["comments"]) router = APIRouter(prefix="/api/matches", tags=["comments"])
# 같은 이메일 연속 작성 최소 간격(초) — 도배 방지(기본 방어). # 같은 기기 연속 작성 최소 간격(초) — 도배 방지(기본 방어).
COMMENT_COOLDOWN_SECONDS = 5 COMMENT_COOLDOWN_SECONDS = 5
# 닉네임 조합: 2글자(코믹 수식) + 3글자(축구 역할) = 항상 한국어 5글자.
def _mask(email: str) -> str: # 40 × 30 = 1200가지.
"""leaderboard._mask 와 동일 규칙 — ab***@gmail.com.""" _NICK_A = [
name, _, domain = email.partition("@") "잔디", "침대", "벤치", "후보", "똥손", "헛발", "발컨", "노룩", "왼발", "멘붕",
head = name[:2] if len(name) >= 2 else name "국대", "동네", "주말", "폭발", "광속", "진지", "발끝", "번개", "백수", "천재",
return f"{head}{'*' * max(1, len(name) - 2)}@{domain}" "야수", "괴물", "폭격", "강철", "무적", "질풍", "돌풍", "불꽃", "발광", "분노",
"음속", "폭탄", "슈퍼", "만년", "전설", "비밀", "미친", "라면", "치킨", "출근",
]
_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( return CommentOut(
id=c.id, nickname=c.nickname,
nickname=_mask(c.email),
body=c.body, body=c.body,
createdAt=c.created_at.isoformat(), 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) @router.get("/{match_id}/comments", response_model=CommentListOut)
async def list_comments( async def list_comments(
match_id: str, match_id: str,
limit: int = Query(3, ge=1, le=50), limit: int = Query(3, ge=1, le=50),
offset: int = Query(0, ge=0), offset: int = Query(0, ge=0),
# 뷰어 본인 이메일 목록(localStorage 최근 이메일). 일치하는 댓글에 mine=True → 삭제 버튼 노출
email: list[str] | None = Query(None),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> CommentListOut: ) -> CommentListOut:
viewer = _norm_emails(email)
base = ( base = (
select(Comment) select(Comment)
.where(Comment.match_id == match_id, Comment.is_hidden.is_(False)) .where(Comment.match_id == match_id, Comment.is_hidden.is_(False))
@ -72,9 +101,7 @@ async def list_comments(
.offset(offset) .offset(offset)
) )
).scalars().all() ).scalars().all()
return CommentListOut( return CommentListOut(items=[_out(c) for c in rows], total=total)
items=[_out(c, viewer) for c in rows], total=total
)
@router.post("/{match_id}/comments", response_model=CommentOut) @router.post("/{match_id}/comments", response_model=CommentOut)
@ -87,27 +114,13 @@ async def create_comment(
if not match: if not match:
raise HTTPException(status_code=404, detail="MATCH_NOT_FOUND") raise HTTPException(status_code=404, detail="MATCH_NOT_FOUND")
# 이 경기에 "그 이메일"로 투표한 사람만 작성 가능 — 포인트도 이메일 단위라 신원 기준을 통일. author_hash = _author_hash(body.deviceId)
# 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 = ( last_at = (
await db.execute( await db.execute(
select(func.max(Comment.created_at)).where( select(func.max(Comment.created_at)).where(
func.lower(Comment.email) == email_norm Comment.author_hash == author_hash
) )
) )
).scalar() ).scalar()
@ -116,41 +129,15 @@ async def create_comment(
if elapsed < timedelta(seconds=COMMENT_COOLDOWN_SECONDS): if elapsed < timedelta(seconds=COMMENT_COOLDOWN_SECONDS):
raise HTTPException(status_code=429, detail="COMMENT_COOLDOWN") raise HTTPException(status_code=429, detail="COMMENT_COOLDOWN")
# 세션 캐싱된 닉을 사용하되, 풀에 없는(조작된) 값이면 새 랜덤으로 대체
nickname = body.nickname if _valid_nickname(body.nickname) else _random_nickname()
comment = Comment( comment = Comment(
match_id=match_id, match_id=match_id,
device_id=body.deviceId, # 저장만(분석용), 권한 판정엔 사용 안 함 author_hash=author_hash,
email=email_norm, nickname=nickname,
body=body.body, body=body.body,
) )
db.add(comment) db.add(comment)
await db.commit() await db.commit()
await db.refresh(comment) await db.refresh(comment)
return _out(comment, {email_norm}) # 작성자 본인이므로 mine=True return _out(comment)
@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,10 +122,10 @@ class AdminSetResultIn(BaseModel):
scoreB: int = Field(ge=0, le=50) scoreB: int = Field(ge=0, le=50)
# ── 댓글(경기별 한마디) ───────────────────────────────────── # ── 댓글(경기별 한마디) — 완전 익명 ─────────────────────────
class CommentIn(BaseModel): class CommentIn(BaseModel):
deviceId: str = Field(min_length=8, max_length=64) deviceId: str = Field(min_length=8, max_length=64) # 서버에서 해시 후 폐기(원본 비저장)
email: EmailStr # 투표 시 입력한 이메일 — 마스킹해 닉네임으로 노출 nickname: str = Field(min_length=1, max_length=12) # 세션 캐싱된 닉(서버 풀 검증)
body: str = Field(min_length=1, max_length=200) body: str = Field(min_length=1, max_length=200)
@field_validator("body") @field_validator("body")
@ -138,11 +138,14 @@ class CommentIn(BaseModel):
class CommentOut(BaseModel): class CommentOut(BaseModel):
id: int # id·author_hash 등 내부 식별자는 노출하지 않음(닉네임만 공개)
nickname: str # 마스킹된 이메일 (ab***@gmail.com) nickname: str # 축구 코믹 한국어 5글자 (세션 발급)
body: str body: str
createdAt: str # ISO8601 createdAt: str # ISO8601
mine: bool = False # 조회 요청 기기(device)가 작성한 댓글이면 True (삭제 버튼 노출용)
class CommentNicknameOut(BaseModel):
nickname: str # 세션 시작 시 발급받는 랜덤 닉(클라이언트가 sessionStorage에 캐싱)
class CommentListOut(BaseModel): class CommentListOut(BaseModel):

View File

@ -4,10 +4,9 @@ import { relativeTime } from "@/lib/format";
import { import {
listComments, listComments,
postComment, postComment,
deleteComment, getSessionNickname,
rerollSessionNickname,
getDeviceId, getDeviceId,
getRecentEmails,
rememberEmail,
ApiError, ApiError,
} from "@/lib/api"; } from "@/lib/api";
import type { Comment } from "@/lib/types"; import type { Comment } from "@/lib/types";
@ -15,7 +14,10 @@ import type { Comment } from "@/lib/types";
const FIRST = 3; // 처음 보여줄 최신 댓글 수 const FIRST = 3; // 처음 보여줄 최신 댓글 수
const PAGE = 10; // "더보기" 한 번에 더 불러올 수 const PAGE = 10; // "더보기" 한 번에 더 불러올 수
// 경기별 한마디(댓글). 최신 3개 + "더보기" 인라인 펼침. // 한 댓글을 식별하는 합성 키 (id 비노출이라 시각+내용으로 dedup/렌더)
const keyOf = (c: Comment) => `${c.createdAt}|${c.nickname}|${c.body}`;
// 경기별 한마디(댓글) — 완전 익명. 최신 3개 + 더보기/접기.
export default function Comments({ export default function Comments({
matchId, matchId,
lang, lang,
@ -28,12 +30,10 @@ export default function Comments({
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [cursor, setCursor] = useState(0); // 서버에서 페이징으로 받은 개수 const [cursor, setCursor] = useState(0); // 서버에서 페이징으로 받은 개수
const [loadingMore, setLoadingMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false);
const [expanded, setExpanded] = useState(false); // true=전체 보기 / false=최신 FIRST개만 const [expanded, setExpanded] = useState(false);
const [body, setBody] = useState(""); const [body, setBody] = useState("");
// 투표 폼과 동일: 칸은 비우고 입력/포커스 시 datalist 드롭다운으로 최근 이메일 자동완성 const [nickname, setNickname] = useState(""); // 세션 닉(캐싱)
const [email, setEmail] = useState("");
const [recentEmails, setRecentEmails] = useState<string[]>(getRecentEmails);
const [posting, setPosting] = useState(false); const [posting, setPosting] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
@ -47,17 +47,18 @@ export default function Comments({
setCursor(res.items.length); setCursor(res.items.length);
}) })
.catch(() => {}); .catch(() => {});
getSessionNickname(matchId)
.then((n) => alive && setNickname(n))
.catch(() => {});
return () => { return () => {
alive = false; alive = false;
}; };
}, [matchId]); }, [matchId]);
// 접힘 상태면 최신 FIRST개만 노출
const visible = expanded ? items : items.slice(0, FIRST); const visible = expanded ? items : items.slice(0, FIRST);
const unloaded = Math.max(0, total - items.length); // 아직 서버에서 안 받은 수 const unloaded = Math.max(0, total - items.length);
const hiddenWhenCollapsed = Math.max(0, total - FIRST); // 접힘 시 가려진 전체 수 const hiddenWhenCollapsed = Math.max(0, total - FIRST);
// "더보기": 펼치고, 받아온 게 FIRST개뿐이면 다음 페이지도 가져온다
async function showMore() { async function showMore() {
setExpanded(true); setExpanded(true);
if (items.length <= FIRST && unloaded > 0) await loadMore(); if (items.length <= FIRST && unloaded > 0) await loadMore();
@ -69,8 +70,8 @@ export default function Comments({
try { try {
const res = await listComments(matchId, PAGE, cursor); const res = await listComments(matchId, PAGE, cursor);
setItems((prev) => { setItems((prev) => {
const seen = new Set(prev.map((c) => c.id)); const seen = new Set(prev.map(keyOf));
return [...prev, ...res.items.filter((c) => !seen.has(c.id))]; return [...prev, ...res.items.filter((c) => !seen.has(keyOf(c)))];
}); });
setTotal(res.total); setTotal(res.total);
setCursor((c) => c + res.items.length); setCursor((c) => c + res.items.length);
@ -81,46 +82,31 @@ export default function Comments({
} }
} }
async function remove(id: number) { async function reroll() {
if (!window.confirm(t.commentDeleteConfirm)) return;
try { try {
await deleteComment(matchId, id); const n = await rerollSessionNickname(matchId);
setItems((prev) => prev.filter((c) => c.id !== id)); setNickname(n);
setTotal((n) => Math.max(0, n - 1));
} catch { } catch {
setError(t.commentError); /* 무시 */
} }
} }
async function submit() { async function submit() {
const text = body.trim(); const text = body.trim();
const mail = email.trim(); if (posting || !text) return;
if (posting) return;
setError(""); setError("");
if (!mail) {
setError(t.commentNeedEmail);
return;
}
if (!text) return;
setPosting(true); setPosting(true);
try { try {
const created = await postComment(matchId, { const created = await postComment(matchId, {
deviceId: getDeviceId(), deviceId: getDeviceId(),
email: mail, nickname,
body: text, body: text,
}); });
rememberEmail(mail);
setRecentEmails(getRecentEmails()); // 방금 쓴 이메일을 자동완성 목록에 반영
setItems((prev) => [created, ...prev]); // 낙관적: 맨 위에 즉시 표시 setItems((prev) => [created, ...prev]); // 낙관적: 맨 위에 즉시 표시
setTotal((n) => n + 1); setTotal((n) => n + 1);
setBody(""); setBody("");
} catch (e) { } catch (e) {
if (e instanceof ApiError && e.status === 403) if (e instanceof ApiError && e.status === 429) setError(t.commentCooldown);
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); else setError(t.commentError);
} finally { } finally {
setPosting(false); setPosting(false);
@ -141,22 +127,20 @@ export default function Comments({
{/* 입력 */} {/* 입력 */}
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
<input {nickname && (
type="email" <p className="flex items-center gap-1.5 text-[12px] font-bold text-white/45">
inputMode="email" {t.commentAs}{" "}
autoComplete="email" <span className="text-[var(--green)]">{nickname}</span>
list="tp-comment-email-suggestions" <button
value={email} type="button"
onChange={(e) => setEmail(e.target.value)} onClick={reroll}
placeholder={t.commentEmailPh} title={t.commentReroll}
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)]" aria-label={t.commentReroll}
/> className="grid h-5 w-5 place-items-center rounded-full text-[13px] leading-none text-white/45 transition hover:text-white active:rotate-180"
{recentEmails.length > 0 && ( >
<datalist id="tp-comment-email-suggestions">
{recentEmails.map((e) => ( </button>
<option key={e} value={e} /> </p>
))}
</datalist>
)} )}
<div className="flex items-end gap-2"> <div className="flex items-end gap-2">
<textarea <textarea
@ -199,8 +183,8 @@ export default function Comments({
expanded ? "max-h-[360px] overflow-y-auto pr-1" : "" expanded ? "max-h-[360px] overflow-y-auto pr-1" : ""
}`} }`}
> >
{visible.map((c) => ( {visible.map((c, i) => (
<li key={c.id} className="py-3 first:pt-0"> <li key={`${keyOf(c)}-${i}`} className="py-3 first:pt-0">
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2">
<span className="text-[13px] font-extrabold text-[var(--green)]"> <span className="text-[13px] font-extrabold text-[var(--green)]">
{c.nickname} {c.nickname}
@ -208,15 +192,6 @@ export default function Comments({
<span className="text-[11px] text-white/35"> <span className="text-[11px] text-white/35">
{relativeTime(c.createdAt, lang)} {relativeTime(c.createdAt, lang)}
</span> </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> </div>
<p className="mt-1 whitespace-pre-wrap break-words text-[14px] leading-snug text-white/90"> <p className="mt-1 whitespace-pre-wrap break-words text-[14px] leading-snug text-white/90">
{c.body} {c.body}

View File

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

View File

@ -102,15 +102,12 @@ type Dict = {
commentsTitle: string; commentsTitle: string;
commentsCount: (n: number) => string; commentsCount: (n: number) => string;
commentPh: string; commentPh: string;
commentEmailPh: string; commentAs: string;
commentReroll: string;
commentSubmit: string; commentSubmit: string;
commentMore: string; commentMore: string;
commentCollapse: string; commentCollapse: string;
commentDelete: string;
commentDeleteConfirm: string;
commentEmpty: string; commentEmpty: string;
commentNeedEmail: string;
commentNeedVote: string;
commentCooldown: string; commentCooldown: string;
commentError: string; commentError: string;
adLabel: string; adLabel: string;
@ -195,15 +192,12 @@ export const DICT: Record<Lang, Dict> = {
commentsTitle: "한마디", commentsTitle: "한마디",
commentsCount: (n) => `${n}`, commentsCount: (n) => `${n}`,
commentPh: "응원 한마디 남기기...", commentPh: "응원 한마디 남기기...",
commentEmailPh: "이메일", commentAs: "이번 닉네임",
commentReroll: "닉네임 새로 뽑기",
commentSubmit: "등록", commentSubmit: "등록",
commentMore: "댓글 더보기", commentMore: "댓글 더보기",
commentCollapse: "접기", commentCollapse: "접기",
commentDelete: "삭제",
commentDeleteConfirm: "이 댓글을 삭제할까요?",
commentEmpty: "첫 한마디를 남겨보세요!", commentEmpty: "첫 한마디를 남겨보세요!",
commentNeedEmail: "이메일을 입력해주세요.",
commentNeedVote: "이 경기에 투표한 후 댓글을 쓸 수 있어요.",
commentCooldown: "잠시 후 다시 시도해주세요.", commentCooldown: "잠시 후 다시 시도해주세요.",
commentError: "등록에 실패했습니다. 잠시 후 다시 시도해주세요.", commentError: "등록에 실패했습니다. 잠시 후 다시 시도해주세요.",
adLabel: "광고", adLabel: "광고",
@ -285,15 +279,12 @@ export const DICT: Record<Lang, Dict> = {
commentsTitle: "Comments", commentsTitle: "Comments",
commentsCount: (n) => `${n} total`, commentsCount: (n) => `${n} total`,
commentPh: "Leave a comment...", commentPh: "Leave a comment...",
commentEmailPh: "Email (shown as masked nickname)", commentAs: "Your name",
commentReroll: "Get a new name",
commentSubmit: "Post", commentSubmit: "Post",
commentMore: "Show more", commentMore: "Show more",
commentCollapse: "Collapse", commentCollapse: "Collapse",
commentDelete: "Delete",
commentDeleteConfirm: "Delete this comment?",
commentEmpty: "Be the first to 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.", commentCooldown: "Please wait a moment before posting again.",
commentError: "Failed to post. Please try again shortly.", commentError: "Failed to post. Please try again shortly.",
adLabel: "AD", adLabel: "AD",

View File

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

View File

@ -10,9 +10,6 @@ import { useLang } from "@/lib/useLang";
import { getMatch, matchUrl, ApiError } from "@/lib/api"; import { getMatch, matchUrl, ApiError } from "@/lib/api";
import type { Match } from "@/lib/types"; import type { Match } from "@/lib/types";
// 댓글(한마디)을 노출할 경기 — 한국 vs 멕시코 (A조 2차전)
const COMMENTS_MATCH_ID = "A_MEX_KOR_20260619";
// L2. 경기 상세(대결) 페이지 // L2. 경기 상세(대결) 페이지
export default function MatchDetail() { export default function MatchDetail() {
const { matchId = "" } = useParams(); const { matchId = "" } = useParams();
@ -102,10 +99,8 @@ export default function MatchDetail() {
lang={lang} lang={lang}
/> />
{/* 한국–멕시코 경기에만 한마디(댓글) 노출 */} {/* 한마디(댓글) — 전 경기 노출 (경기 상태 무관) */}
{match.matchId === COMMENTS_MATCH_ID && ( <Comments matchId={match.matchId} lang={lang} />
<Comments matchId={match.matchId} lang={lang} />
)}
{/* 하단 CTA — 전체 일정으로(다른 경기 투표). ADO2 버튼과 동일 사양(퍼플 #A65EFF) */} {/* 하단 CTA — 전체 일정으로(다른 경기 투표). ADO2 버튼과 동일 사양(퍼플 #A65EFF) */}
<Link <Link