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

144 lines
3.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 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 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