o2o-triple-pick/backend/app/domain.py
jwkim 4e1ebc8989 feat: KBO/MLB 운영 안정화 — 순위 탭·라이브 개선·취소/더블헤더 지원·야구 점수제
AI 예측
- 생성 시점을 킥오프 22시간 전으로 변경(선발투수 예고 이후), 잡 매시간 실행
- 경기 시작 후 생성 금지(사후 예측 방지), 경기 단위 커밋으로 중단 시 진행분 보존

일정·라이브 UI
- 야구 일정에 순위 탭 추가 (KBO 단일 순위표 · MLB 6개 디비전, /api/standings 신설)
- 경기중 카드에 현재 이닝·실시간 스코어 표시 (30s 폴링, 서버 15s 캐시)
- MLB 선발 누락 수정: statsapi 날짜 파라미터 미국 날짜 기준 보정(-1일)
- 라이브 대기타석 스크롤바 다크 테마 처리

운영 견고성
- 우천취소: 경기 삭제 → cancelled 상태 보존 (투표·예측 기록 유지, 정산/투표/AI 제외)
- 더블헤더: 시작시각순 seq로 1·2차전 분리 등록 (match_id _2, 정산·라이브·프리뷰 매핑)
- 축구 데이터 수집을 wc 리그로 한정 (야구 팀을 축구 API에 검색하던 쿼터 낭비 수정)

점수제
- 야구 판정 완화: 근접=득실차 ±1, 부분=한 팀 득점 ±1 (등급·배점은 리그 공통 유지)
- 채점 판정을 _grade_of() 단일 함수로 통합, 종료된 야구 경기 재채점
- 배점 안내 모달 야구 문구 분리, docs/SCORING.md 갱신

기타
- 야구 전용 댓글 닉네임 풀 (종목별 세션 캐시 분리)
- 리더보드 리그별 조회 연동 (?league=)
2026-07-22 11:01:10 +09:00

192 lines
6.0 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:
"""투표/경기 단계:
cancelled 우천취소 등 취소 확정 → "취소" (투표·정산 제외, 기록은 보존)
finished 결과 입력됨 → "종료"
live 킥오프 이후, 결과 입력 전 → "경기중"
locked 투표 마감(킥오프 1h 전) ~ 킥오프 → "투표 종료"(비활성)
scheduled 오픈 전 → "오픈 예정"
open 투표 중
"""
now = now or now_utc()
if m.status == "cancelled": # 우천취소 등 — 시간과 무관하게 고정
return "cancelled"
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.status == "cancelled":
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,
extras: dict | 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,
league=m.league or "wc",
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),
extras=extras,
)