feat: 댓글 익명화 + 전 경기 적용
- 이메일/투표 게이트 제거 → 완전 익명 - 세션 단위 발급(sessionStorage 캐싱) — 탭 닫으면/시크릿이면 새 닉develop
parent
8a67094510
commit
b62bedc665
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
)}
|
||||
{/* 한마디(댓글) — 전 경기 노출 (경기 상태 무관) */}
|
||||
<Comments matchId={match.matchId} lang={lang} />
|
||||
|
||||
{/* 하단 CTA — 전체 일정으로(다른 경기 투표). ADO2 버튼과 동일 사양(퍼플 #A65EFF) */}
|
||||
<Link
|
||||
|
|
|
|||
Loading…
Reference in New Issue