o2o-triple-pick/backend/app/services/baseball_sync.py

131 lines
5.2 KiB
Python

"""야구 일정 DB 반영 — (리그, KST 날짜, 팀쌍, 차수) 키 매칭. 워커가 매일 호출.
- 기존 경기: 키 매칭 → 시각/venue/투표시간 갱신 (결과·예측·crowd 보존)
- 신규 경기: 삽입 + crowd 초기화.
match_id = {LEAGUE}_{away}_{home}_{yyyymmdd} (더블헤더 2차전은 _2 접미)
- 취소(우천 등): 삭제하지 않고 status="cancelled" 로 보존 — 투표·예측 기록 유지,
정산·투표·AI 생성에서 제외. 소스가 취소를 번복하면 scheduled 로 복귀.
(보강 경기는 새 날짜의 신규 경기로 재등장)
- 더블헤더: 같은 (날짜, 팀쌍) 복수 경기를 시작시각순 seq(1,2)로 구분해 모두 등록.
- 투표창: 리그 공통 오픈 오프셋(vote_open_hours_before, 기본 -168h)
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..config import settings
from ..models import CrowdStats, Match
from ..teams_baseball import team_info
log = logging.getLogger("triplepick.baseball")
KST = timezone(timedelta(hours=9))
def baseball_opens_at(kickoff: datetime) -> datetime:
# 축구와 동일 정책(vote_open_hours_before=168h) — 야구는 일정을 7일치만
# 수집하므로 사실상 동기화 즉시 투표 오픈된다.
return kickoff - timedelta(hours=settings.vote_open_hours_before)
def baseball_lock_at(kickoff: datetime) -> datetime:
return kickoff - timedelta(minutes=settings.vote_lock_minutes_before)
def _date_of(m: Match) -> str:
return m.kickoff_at.astimezone(KST).strftime("%Y%m%d") if m.kickoff_at else ""
def match_seq(match_id: str) -> int:
"""match_id 의 더블헤더 차수. `..._20260722` → 1, `..._20260722_2` → 2."""
tail = match_id.rsplit("_", 1)[-1]
return int(tail) if len(tail) <= 2 and tail.isdigit() else 1
def assign_seq(records: list[dict]) -> None:
"""같은 (날짜, 팀쌍) 레코드에 시작시각순 seq(1,2,…)를 부여 — 더블헤더 구분."""
groups: dict[tuple, list[dict]] = {}
for rec in records:
groups.setdefault((rec["dateKst"], rec["teamA"], rec["teamB"]), []).append(rec)
for recs in groups.values():
recs.sort(key=lambda r: r["kickoffKst"])
for i, rec in enumerate(recs, start=1):
rec["seq"] = i
async def sync_baseball_schedule(
db: AsyncSession, league: str, records: list[dict]
) -> dict:
if not records:
return {"updated": 0, "inserted": 0, "skipped": 0, "removed": 0}
assign_seq(records)
existing = (
await db.execute(select(Match).where(Match.league == league))
).scalars().all()
by_key: dict[tuple, Match] = {}
for m in existing:
d, s = _date_of(m), match_seq(m.match_id)
by_key[(d, m.team_a_code, m.team_b_code, s)] = m
by_key[(d, m.team_b_code, m.team_a_code, s)] = m
updated = inserted = skipped = removed = 0
for rec in records:
a, b, d = rec["teamA"], rec["teamB"], rec["dateKst"]
seq = rec.get("seq", 1)
m = by_key.get((d, a, b, seq))
if rec.get("cancelled"):
# 소프트 취소 — 투표/예측 기록 보존, 정산·투표·AI 대상에서 제외.
if m is not None and m.result_outcome is None and m.status != "cancelled":
m.status = "cancelled"
removed += 1
continue
kickoff = datetime.fromisoformat(rec["kickoffKst"]).astimezone(timezone.utc)
if m is not None:
if m.result_outcome is not None:
skipped += 1
continue
if m.status == "cancelled":
m.status = "scheduled" # 취소 번복 — 시간 기준 상태는 tick 이 복원
m.kickoff_at = kickoff
m.opens_at = baseball_opens_at(kickoff)
m.lock_at = baseball_lock_at(kickoff)
if rec.get("venue"):
m.venue = rec["venue"]
updated += 1
else:
ta, tb = team_info(league, a), team_info(league, b)
match_id = f"{league.upper()}_{a}_{b}_{d}" + (f"_{seq}" if seq > 1 else "")
new = Match(
match_id=match_id,
league=league,
round_label="정규시즌",
group="",
team_a_name=ta["name"], team_a_short=ta["shortName"],
team_a_code=ta["code"], team_a_flag=ta["flag"],
team_b_name=tb["name"], team_b_short=tb["shortName"],
team_b_code=tb["code"], team_b_flag=tb["flag"],
venue=rec.get("venue", ""),
hook_text=f"{ta['shortName']} vs {tb['shortName']}",
kickoff_at=kickoff,
opens_at=baseball_opens_at(kickoff),
lock_at=baseball_lock_at(kickoff),
status="scheduled",
)
db.add(new)
db.add(CrowdStats(match_id=match_id, total=0, team_a_win=0, draw=0, team_b_win=0))
by_key[(d, a, b, seq)] = new
by_key[(d, b, a, seq)] = new
inserted += 1
await db.commit()
result = {"updated": updated, "inserted": inserted, "skipped": skipped, "removed": removed}
log.info("baseball sync(%s): %s", league, result)
return result