o2o-triple-pick/backend/app/schemas.py

209 lines
5.8 KiB
Python

"""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 MyResultOut(BaseModel):
scoreA: int
scoreB: int
outcome: Outcome
hitOutcome: bool # 내 outcome 이 실제 결과와 일치했는지
class MyPredictionOut(BaseModel):
matchId: str
teamA: Team
teamB: Team
kickoffKst: str
outcome: Outcome
scoreA: int
scoreB: int
submittedAt: str # ISO — 최신순 정렬 기준(updated_at)
result: MyResultOut | None = None # 경기 종료 시에만 채워짐
# ── 리더보드 ────────────────────────────────────────────────
class StandingOut(BaseModel):
rank: int
emailMasked: str
totalPoints: int
exactCount: int
matchesPlayed: int
class LeaderboardOut(BaseModel):
standings: list[StandingOut]
scoredMatches: int
# ── AI 모델 랭킹 (종료 경기 누적, 매 조회 시 재계산) ──────────
class AIStandingOut(BaseModel):
rank: int
model: ModelName
totalPoints: int
exactCount: int
matchesPlayed: int
class AILeaderboardOut(BaseModel):
standings: list[AIStandingOut]
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