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

159 lines
4.8 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
from .schemas import (
AIPredictionOut,
CrowdStatsOut,
MatchOut,
MatchResult,
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 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),
)