diff --git a/backend/app/config.py b/backend/app/config.py index 4690353..ba68a9c 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -116,11 +116,12 @@ class Settings(BaseSettings): # 키 미설정 시 데이터 수집/주입 전부 no-op → 기존(이름만) 예측으로 폴백. football_api_key: str = "" football_api_base: str = "https://v3.football.api-sports.io" - # 캐시 신선도(시간). 이 시간 지난 팀만 워커가 재수집(랭킹/폼은 자주 안 변함). - football_cache_ttl_hours: int = 168 # 7일 - # 수집 잡 1회당 갱신할 최대 팀 수. 팀당 ~3콜이라 24팀≈72콜(무료 100/일 안). - # 24면 48팀이 약 2일에 채워짐(8이면 6일). 한도 소진 시 남은 팀은 다음 잡에서. - football_refresh_batch: int = 24 + # 팀당 1회만 수집(fetch-once): 과거 시즌 폼·스쿼드·H2H 는 변하지 않고, 2026 + # 진행 결과(승패·스코어)는 build 시 우리 DB 에서 라이브로 읽는다. 한 번 캐시되면 + # 다시 받지 않으므로, 전 팀이 채워지면 이후 잡은 호출 0(사실상 수집 자동 종료). + # 하루 호출 예산(무료 100/일 보호). 팀당 ~3콜이라 90이면 하루 ~30팀 → + # 48팀이 약 2일에 채워짐. 예산 소진 시 남은 팀은 다음 날 잡이 이어서 누적 수집. + football_daily_call_budget: int = 90 # API 호출 간 최소 간격(초) — 무료 10req/분 제한 회피. football_call_interval_sec: float = 7.0 # 수집 잡 시각(KST) — 예측 생성(00:05)보다 앞서 캐시를 채워둔다. diff --git a/backend/app/services/football_data.py b/backend/app/services/football_data.py index 1106ec3..77e30a1 100644 --- a/backend/app/services/football_data.py +++ b/backend/app/services/football_data.py @@ -15,13 +15,12 @@ from __future__ import annotations import asyncio import logging -from datetime import timedelta import httpx from sqlalchemy import or_, select from ..config import settings -from ..domain import ensure_aware, now_utc +from ..domain import now_utc from ..models import FootballCache, Match log = logging.getLogger("triplepick.football") @@ -188,33 +187,47 @@ async def _fetch_h2h(db, client, code_a, id_a, code_b, id_b) -> None: await _upsert(db, f"h2h:{code_a}-{code_b}", {"rows": rows[-5:]}) +# 팀 1건 수집 비용(API 호출 수): 스쿼드 1 + 시즌별 경기. H2H 는 1. +_TEAM_CALLS = 1 + len(_SEASONS) +_H2H_CALLS = 1 + + # ── 수집 잡 (워커 호출) ───────────────────────────────────────── -async def refresh(db, matches: list[Match], limit: int | None = None) -> int: - """주어진 경기들의 팀/H2H 캐시를 stale 우선으로 갱신. 갱신한 팀 수 반환.""" +async def refresh(db, matches: list[Match]) -> int: + """경기들의 팀/H2H 캐시를 '아직 없는 것만' 한 번씩 수집한다(fetch-once). + + 과거 시즌 폼·스쿼드·H2H 는 변하지 않고, 2026 진행 결과는 build_data_block 이 + 우리 DB 에서 라이브로 읽으므로 팀당 1회 수집이면 충분하다. 무료 한도를 넘지 + 않도록 하루 호출 예산(football_daily_call_budget) 안에서만 받고, 못 받은 팀은 + 다음 잡이 이어서 누적한다. 전 팀이 캐시되면 이후 호출 0(자동 무동작). + 이번 잡에서 새로 수집한 팀 수 반환.""" if not enabled(): return 0 - limit = limit if limit is not None else settings.football_refresh_batch - cutoff = now_utc() - timedelta(hours=settings.football_cache_ttl_hours) + budget = settings.football_daily_call_budget refreshed = 0 + calls = 0 async with httpx.AsyncClient() as client: seen: set[str] = set() for m in matches: for code, name in ((m.team_a_code, m.team_a_name), (m.team_b_code, m.team_b_name)): - if code in seen or refreshed >= limit: + if code in seen: continue seen.add(code) - row = await db.get(FootballCache, f"team:{code}") - if row and ensure_aware(row.fetched_at) > cutoff: - continue # 신선 — skip + if await db.get(FootballCache, f"team:{code}"): + continue # 이미 수집됨 — 다시 받지 않음(fetch-once) + if calls + _TEAM_CALLS > budget: + continue # 오늘 호출 예산 소진 — 남은 팀은 다음 잡에서 try: if await _fetch_team(db, client, code, name): refreshed += 1 except Exception as e: # noqa: BLE001 log.warning("football: 팀 %s 수집 실패 %s", code, e) - # H2H (양 팀 캐시가 있을 때만) + calls += _TEAM_CALLS # 성공/실패 무관 호출은 소비됨(한도 보호) + # H2H — 양 팀이 캐시됐고 아직 안 받은 쌍만 for m in matches: - h = await db.get(FootballCache, f"h2h:{m.team_a_code}-{m.team_b_code}") - if h and ensure_aware(h.fetched_at) > cutoff: + if calls + _H2H_CALLS > budget: + break + if await db.get(FootballCache, f"h2h:{m.team_a_code}-{m.team_b_code}"): continue ta = await db.get(FootballCache, f"team:{m.team_a_code}") tb = await db.get(FootballCache, f"team:{m.team_b_code}") @@ -225,9 +238,10 @@ async def refresh(db, matches: list[Match], limit: int | None = None) -> int: m.team_b_code, tb.payload["team_id"]) except Exception as e: # noqa: BLE001 log.warning("football: H2H %s 수집 실패 %s", m.match_id, e) + calls += _H2H_CALLS await db.commit() - if refreshed: - log.info("football: 팀 %d개 캐시 갱신", refreshed) + if refreshed or calls: + log.info("football: 신규 팀 %d개 수집 (호출 ~%d/%d)", refreshed, calls, budget) return refreshed diff --git a/backend/app/worker.py b/backend/app/worker.py index f429bec..698c59b 100644 --- a/backend/app/worker.py +++ b/backend/app/worker.py @@ -138,23 +138,21 @@ async def generate_ai_predictions(only_missing: bool = True) -> None: # ── 2.1) 축구 데이터 수집 (예측 생성 전에 캐시를 채워둔다) ── async def refresh_football_data() -> None: - """다가오는 미종료 경기의 팀/H2H 축구 데이터를 캐시에 갱신. + """전 팀의 팀/H2H 축구 데이터를 캐시에 한 번씩 선수집. - FOOTBALL_API_KEY 미설정이면 no-op. 무료 한도(100/일·10/분)를 호출 간격·배치로 - 분산하므로 한 번에 일부 팀만 갱신되고, 매일 잡이 누적해 채운다. + 예측이 데이터를 쓰려면 미리 캐시에 있어야 하므로, 임박 경기만 기다리지 않고 + 모든 미종료 경기의 팀을 대상으로 한다. 팀당 1회만 받고(fetch-once), 무료 한도 + (100/일)를 넘지 않게 하루 호출 예산만큼만 받아 며칠에 걸쳐 누적한다. 전 팀이 + 캐시되면 이후 잡은 호출 0(자동 무동작). 키 미설정이면 no-op. """ if not football_data.enabled(): return async with SessionLocal() as db: - horizon = now_utc() + timedelta(hours=settings.ai_generate_lookahead_hours) rows = ( await db.execute(select(Match).where(Match.result_outcome.is_(None))) ).scalars().all() - # 투표 윈도우에 든(곧 들) 경기만, 가장 가까운 경기부터(배치 한도 시 우선 수집). - matches = sorted( - (m for m in rows if ensure_aware(m.opens_at) <= horizon), - key=lambda m: ensure_aware(m.opens_at), - ) + # 가까운 경기부터 우선 수집(하루 예산 소진 시 먼 경기 팀은 다음 잡에서). + matches = sorted(rows, key=lambda m: ensure_aware(m.kickoff_at)) await football_data.refresh(db, matches)