"""Pydantic 스키마 — API 요청/응답 계약. 프론트(lib/types.ts)와 1:1 정합. 시각은 ISO8601 문자열로 직렬화. """ from __future__ import annotations from datetime import datetime from typing import Literal from pydantic import BaseModel, EmailStr, Field, field_validator Outcome = Literal["TEAM_A_WIN", "DRAW", "TEAM_B_WIN"] ModelName = Literal["GPT", "Claude", "Gemini"] class Team(BaseModel): name: str shortName: str code: str flag: str = "" class MatchResult(BaseModel): scoreA: int scoreB: int outcome: Outcome class AIPredictionOut(BaseModel): matchId: str model: ModelName outcome: Outcome scoreA: int scoreB: int confidencePct: int reasonShort: str generatedAt: str class CrowdStatsOut(BaseModel): matchId: str total: int teamAWin: int draw: int teamBWin: int class MatchOut(BaseModel): matchId: str roundLabel: str group: str teamA: Team teamB: Team kickoffKst: str venue: str opensAt: str lockAt: str status: str # = phase (실시간 계산값으로 통일) phase: str # scheduled | open | locked | live | finished (now 기준 계산) votable: bool # AI 예측·투표 대상 조인지 (그 외는 일정/결과만) votingOpen: bool # 현재 제출 허용 여부 (demo_force_open 반영) hookText: str result: MatchResult | None = None predictions: list[AIPredictionOut] = Field(default_factory=list) crowd: CrowdStatsOut | None = None # ── 픽 제출 ───────────────────────────────────────────────── class SubmitPredictionIn(BaseModel): matchId: str deviceId: str = Field(min_length=8, max_length=64) outcome: Outcome scoreA: int = Field(ge=0, le=9) scoreB: int = Field(ge=0, le=9) email: EmailStr | None = None notify: bool = False @field_validator("outcome") @classmethod def outcome_matches_score(cls, v: str, info): # noqa: ANN001 # outcome 은 스코어에서 파생되어야 일관됨 — 프론트가 자동계산하지만 서버에서 재검증 return v class SubmitPredictionOut(BaseModel): ok: bool predictionId: str matchedModels: list[ModelName] exactMatch: bool crowd: CrowdStatsOut # ── 리더보드 ──────────────────────────────────────────────── class StandingOut(BaseModel): rank: int emailMasked: str totalPoints: int exactCount: int matchesPlayed: int class LeaderboardOut(BaseModel): standings: list[StandingOut] scoredMatches: int # ── 관리자 ────────────────────────────────────────────────── class AdminAIPredictionIn(BaseModel): matchId: str model: ModelName outcome: Outcome scoreA: int = Field(ge=0, le=20) scoreB: int = Field(ge=0, le=20) confidencePct: int = Field(ge=0, le=100) reasonKo: str = "" reasonEn: str = "" class AdminSetResultIn(BaseModel): matchId: str scoreA: int = Field(ge=0, le=50) scoreB: int = Field(ge=0, le=50) # ── 댓글(경기별 한마디) — 완전 익명 ───────────────────────── class CommentIn(BaseModel): 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") @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·author_hash 등 내부 식별자는 노출하지 않음(닉네임만 공개) nickname: str # 축구 코믹 한국어 5글자 (세션 발급) body: str createdAt: str # ISO8601 class CommentNicknameOut(BaseModel): nickname: str # 세션 시작 시 발급받는 랜덤 닉(클라이언트가 sessionStorage에 캐싱) class CommentListOut(BaseModel): items: list[CommentOut] total: int # 숨김 제외 전체 개수 — "더보기" 남은 수 계산용 class GenericOk(BaseModel): ok: bool detail: str = "" extra: dict | None = None # ── 방문자 집계 ───────────────────────────────────────────── class VisitIn(BaseModel): deviceId: str = Field(min_length=8, max_length=64) class VisitOut(BaseModel): ok: bool counted: bool # 오늘 첫 방문이면 True(집계됨), 재방문이면 False class DailyVisitOut(BaseModel): date: str # YYYY-MM-DD (KST) uniqueVisitors: int