184 lines
5.7 KiB
Python
184 lines
5.7 KiB
Python
"""도메인 헬퍼 — 투표 단계(phase) 계산 + ORM→응답 스키마 직렬화.
|
|
|
|
phase 는 now 기준 동적 계산 (lib/schedule.ts matchPhase 와 동일 규칙):
|
|
result 존재 → finished
|
|
now >= lockAt → locked
|
|
now < opensAt → scheduled
|
|
그 외 → open
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from .config import settings
|
|
from .models import AIPrediction, CrowdStats, Match, UserPrediction
|
|
from .schemas import (
|
|
AIPredictionOut,
|
|
CrowdStatsOut,
|
|
MatchOut,
|
|
MatchResult,
|
|
MyPredictionOut,
|
|
MyResultOut,
|
|
Team,
|
|
)
|
|
|
|
|
|
def now_utc() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
KST = timezone(timedelta(hours=9))
|
|
|
|
|
|
def ensure_aware(dt: datetime) -> datetime:
|
|
"""naive datetime(일부 DB 드라이버 반환)을 UTC aware 로 보정."""
|
|
return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt
|
|
|
|
|
|
def kst_iso(dt: datetime) -> str:
|
|
"""저장값(UTC)을 KST(+09:00) ISO 문자열로 직렬화 — 프론트 표시 기준 통일."""
|
|
return ensure_aware(dt).astimezone(KST).isoformat()
|
|
|
|
|
|
def compute_phase(m: Match, now: datetime | None = None) -> str:
|
|
"""투표/경기 단계:
|
|
finished 결과 입력됨 → "종료"
|
|
live 킥오프 이후, 결과 입력 전 → "경기중"
|
|
locked 투표 마감(킥오프 1h 전) ~ 킥오프 → "투표 종료"(비활성)
|
|
scheduled 오픈 전 → "오픈 예정"
|
|
open 투표 중
|
|
"""
|
|
now = now or now_utc()
|
|
if m.result_outcome is not None:
|
|
return "finished"
|
|
if now >= ensure_aware(m.kickoff_at):
|
|
return "live"
|
|
if now >= ensure_aware(m.lock_at):
|
|
return "locked"
|
|
if now < ensure_aware(m.opens_at):
|
|
return "scheduled"
|
|
return "open"
|
|
|
|
|
|
def is_votable(m: Match) -> bool:
|
|
"""투표 지원 경기 여부 — 전체 조 투표 가능.
|
|
실제 오픈/마감은 is_open_for_voting 의 시간창(D-2 오픈 ~ 킥오프 60분 전 마감)이 결정."""
|
|
return True
|
|
|
|
|
|
def is_open_for_voting(m: Match, now: datetime | None = None) -> bool:
|
|
"""제출 허용 여부. demo_force_open 이면 마감 전까지 항상 오픈."""
|
|
now = now or now_utc()
|
|
if not is_votable(m):
|
|
return False
|
|
if m.result_outcome is not None:
|
|
return False
|
|
if now >= ensure_aware(m.lock_at):
|
|
return False
|
|
if settings.demo_force_open:
|
|
return True
|
|
return now >= ensure_aware(m.opens_at)
|
|
|
|
|
|
def _team_a(m: Match) -> Team:
|
|
return Team(
|
|
name=m.team_a_name, shortName=m.team_a_short, code=m.team_a_code, flag=m.team_a_flag
|
|
)
|
|
|
|
|
|
def _team_b(m: Match) -> Team:
|
|
return Team(
|
|
name=m.team_b_name, shortName=m.team_b_short, code=m.team_b_code, flag=m.team_b_flag
|
|
)
|
|
|
|
|
|
def prediction_out(p: AIPrediction, lang: str = "ko") -> AIPredictionOut:
|
|
reason = p.reason_en if lang == "en" and p.reason_en else p.reason_ko
|
|
return AIPredictionOut(
|
|
matchId=p.match_id,
|
|
model=p.model, # type: ignore[arg-type]
|
|
outcome=p.outcome, # type: ignore[arg-type]
|
|
scoreA=p.score_a,
|
|
scoreB=p.score_b,
|
|
confidencePct=p.confidence_pct,
|
|
reasonShort=reason,
|
|
generatedAt=p.generated_at.date().isoformat() if p.generated_at else "",
|
|
)
|
|
|
|
|
|
def crowd_out(c: CrowdStats | None, match_id: str) -> CrowdStatsOut:
|
|
if c is None:
|
|
return CrowdStatsOut(matchId=match_id, total=0, teamAWin=0, draw=0, teamBWin=0)
|
|
return CrowdStatsOut(
|
|
matchId=match_id,
|
|
total=c.total,
|
|
teamAWin=c.team_a_win,
|
|
draw=c.draw,
|
|
teamBWin=c.team_b_win,
|
|
)
|
|
|
|
|
|
def my_prediction_out(p: UserPrediction, m: Match) -> MyPredictionOut:
|
|
"""유저 픽 1건을 경기 정보와 합쳐 직렬화 (내 지난 예측 목록용)."""
|
|
result = None
|
|
if m.result_outcome is not None:
|
|
result = MyResultOut(
|
|
scoreA=m.result_score_a or 0,
|
|
scoreB=m.result_score_b or 0,
|
|
outcome=m.result_outcome, # type: ignore[arg-type]
|
|
hitOutcome=p.outcome == m.result_outcome,
|
|
)
|
|
return MyPredictionOut(
|
|
matchId=p.match_id,
|
|
teamA=_team_a(m),
|
|
teamB=_team_b(m),
|
|
kickoffKst=kst_iso(m.kickoff_at),
|
|
outcome=p.outcome, # type: ignore[arg-type]
|
|
scoreA=p.score_a,
|
|
scoreB=p.score_b,
|
|
submittedAt=kst_iso(p.updated_at or p.created_at),
|
|
result=result,
|
|
)
|
|
|
|
|
|
def match_out(
|
|
m: Match,
|
|
lang: str = "ko",
|
|
include_predictions: bool = True,
|
|
now: datetime | None = None,
|
|
) -> MatchOut:
|
|
result = None
|
|
if m.result_outcome is not None:
|
|
result = MatchResult(
|
|
scoreA=m.result_score_a or 0,
|
|
scoreB=m.result_score_b or 0,
|
|
outcome=m.result_outcome, # type: ignore[arg-type]
|
|
)
|
|
preds: list[AIPredictionOut] = []
|
|
if include_predictions:
|
|
# 모델 순서 고정: GPT, Claude, Gemini
|
|
order = {"GPT": 0, "Claude": 1, "Gemini": 2}
|
|
for p in sorted(m.predictions, key=lambda x: order.get(x.model, 9)):
|
|
preds.append(prediction_out(p, lang))
|
|
# status·phase 를 동일한 실시간 계산값으로 통일 — 워커 틱 지연과 무관하게 일관.
|
|
phase = compute_phase(m, now)
|
|
return MatchOut(
|
|
matchId=m.match_id,
|
|
roundLabel=m.round_label,
|
|
group=m.group,
|
|
teamA=_team_a(m),
|
|
teamB=_team_b(m),
|
|
kickoffKst=kst_iso(m.kickoff_at),
|
|
venue=m.venue,
|
|
opensAt=kst_iso(m.opens_at),
|
|
lockAt=kst_iso(m.lock_at),
|
|
status=phase,
|
|
phase=phase,
|
|
votable=is_votable(m),
|
|
votingOpen=is_open_for_voting(m, now),
|
|
hookText=m.hook_text,
|
|
result=result,
|
|
predictions=preds,
|
|
crowd=crowd_out(m.crowd, m.match_id),
|
|
)
|