diff --git a/backend/app/config.py b/backend/app/config.py index b53a87c..1d42fa3 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -73,8 +73,17 @@ class Settings(BaseSettings): # 매일 AI 예측 생성 시각 (KST). 기능정의서: 매일 0시 1회 생성. ai_generate_hour: int = 0 ai_generate_minute: int = 5 - # 결과 메일: 경기 종료(결과 입력) 후 이 시간(분) 경과하면 발송. 기본 180분(3시간). - result_email_delay_minutes: int = 180 + + # ── 결과 자동 정산(관리자 입력 불필요) ─────────────────── + # 킥오프 + 이 시간(시) 경과 후, 외부 소스에서 스코어를 자동 수집해 채점·집계·메일. + result_settle_hours: int = 3 + # 정산/메일 점검 주기(초). status_tick 와 함께 도는 별도 폴링. + settle_tick_seconds: int = 300 + # 결과 메일을 '맞춘 사람(승패 적중)'에게만 보낼지 여부. False면 구독자 전체. + result_email_correct_only: bool = True + # 결과 메일: 결과 확정 후 추가 지연(분). 자동정산은 이미 킥오프+Nh라 기본 0. + result_email_delay_minutes: int = 0 + # 상태 전이 틱 주기(초): scheduled→open→locked 자동 갱신. status_tick_seconds: int = 60 diff --git a/backend/app/services/schedule_fetch.py b/backend/app/services/schedule_fetch.py index b164beb..b746f6b 100644 --- a/backend/app/services/schedule_fetch.py +++ b/backend/app/services/schedule_fetch.py @@ -110,3 +110,85 @@ async def fetch_schedule() -> list[dict]: except Exception as e: # noqa: BLE001 log.error("schedule fetch 실패(%s): %s — 기존 일정 유지", src, e) return [] + + +# ── 경기 결과(스코어) 수집 — 자동 정산용 ───────────────────── +def _extract_ft(m: dict) -> tuple[int, int] | None: + """openfootball 매치에서 정규시간 스코어 추출(다양한 표기 포괄).""" + sc = m.get("score") + if isinstance(sc, dict): + ft = sc.get("ft") + if isinstance(ft, (list, tuple)) and len(ft) == 2 and ft[0] is not None: + return int(ft[0]), int(ft[1]) + if m.get("score1") is not None and m.get("score2") is not None: + return int(m["score1"]), int(m["score2"]) + return None + + +async def _results_openfootball() -> list[dict]: + import httpx + + async with httpx.AsyncClient(timeout=20) as c: + r = await c.get(settings.schedule_url) + r.raise_for_status() + data = r.json() + + out: list[dict] = [] + for m in data.get("matches", []): + if (m.get("group") or "").strip() != settings.schedule_group: + continue + a, b = code_for(m.get("team1", "")), code_for(m.get("team2", "")) + if not a or not b: + continue + ft = _extract_ft(m) + if ft is None: # 아직 미진행/미집계 + continue + out.append({"teamA": a, "teamB": b, "scoreA": ft[0], "scoreB": ft[1]}) + return out + + +async def _results_football_data() -> list[dict]: + if not settings.football_data_token: + return [] + import httpx + + url = ( + f"https://api.football-data.org/v4/competitions/" + f"{settings.football_data_competition}/matches" + ) + async with httpx.AsyncClient(timeout=20) as c: + r = await c.get(url, headers={"X-Auth-Token": settings.football_data_token}) + r.raise_for_status() + data = r.json() + + out: list[dict] = [] + for m in data.get("matches", []): + if m.get("status") != "FINISHED": + continue + a = code_for((m.get("homeTeam") or {}).get("name", "") or "") + b = code_for((m.get("awayTeam") or {}).get("name", "") or "") + if not a or not b: + continue + ft = ((m.get("score") or {}).get("fullTime") or {}) + if ft.get("home") is None or ft.get("away") is None: + continue + out.append({"teamA": a, "teamB": b, "scoreA": int(ft["home"]), "scoreB": int(ft["away"])}) + return out + + +async def fetch_results() -> list[dict]: + """확정된 경기 스코어 수집 → [{teamA, teamB, scoreA, scoreB}]. 실패 시 빈 리스트.""" + src = settings.schedule_source.lower() + try: + if src == "openfootball": + out = await _results_openfootball() + elif src == "football-data": + out = await _results_football_data() + else: + return [] + if out: + log.info("results: %s 에서 %d경기 스코어 수집", src, len(out)) + return out + except Exception as e: # noqa: BLE001 + log.error("results fetch 실패(%s): %s", src, e) + return [] diff --git a/backend/app/worker.py b/backend/app/worker.py index 8e36fbe..b44ac47 100644 --- a/backend/app/worker.py +++ b/backend/app/worker.py @@ -32,7 +32,8 @@ from .models import AIPrediction, Match, UserPrediction from .scoring import load_scoring_data from .services.ai import MatchContext, PROVIDERS, ProviderUnavailable from .services.email import EmailUnavailable, build_result_email, send_email -from .services.schedule_fetch import fetch_schedule +from .services.grading import apply_result +from .services.schedule_fetch import fetch_results, fetch_schedule from .services.schedule_sync import sync_schedule logging.basicConfig(level=logging.INFO) @@ -118,6 +119,43 @@ async def generate_ai_predictions() -> None: await db.commit() +# ── 2.5) 결과 자동 정산 (관리자 입력 불필요) ──────────────── +async def settle_matches() -> None: + """킥오프 + result_settle_hours 경과한 미정산 경기를 외부 스코어로 자동 채점. + + 외부 소스(openfootball/football-data)에서 확정 스코어를 수집해 apply_result + 호출 → 채점·유저 포인트 집계·finished_at 기록. 직후 결과 메일을 발송한다. + 소스에 스코어가 아직 없으면 다음 틱에 재시도. + """ + now = now_utc() + cutoff = now - timedelta(hours=settings.result_settle_hours) + async with SessionLocal() as db: + pending = ( + await db.execute(select(Match).where(Match.result_outcome.is_(None))) + ).scalars().all() + due = [m.match_id for m in pending if ensure_aware(m.kickoff_at) <= cutoff] + + if due: + results = await fetch_results() + by_pair = {(r["teamA"], r["teamB"]): r for r in results} + settled = 0 + async with SessionLocal() as db: + for mid in due: + m = await db.get(Match, mid) + if m is None or m.result_outcome is not None: + continue + r = by_pair.get((m.team_a_code, m.team_b_code)) + if not r: + continue + await apply_result(db, m, r["scoreA"], r["scoreB"]) # 채점+집계+commit + settled += 1 + log.info("settle: %s 자동 정산 %d-%d", mid, r["scoreA"], r["scoreB"]) + if settled == 0: + log.info("settle: %d경기 정산 대기 — 외부 스코어 아직 없음", len(due)) + + await maybe_send_result_emails() + + # ── 3) 결과 메일 ──────────────────────────────────────────── async def maybe_send_result_emails() -> None: async with SessionLocal() as db: @@ -138,15 +176,17 @@ async def maybe_send_result_emails() -> None: if m.finished_at and ensure_aware(m.finished_at) > cutoff: continue # 아직 지연시간(3시간) 미경과 + conds = [ + UserPrediction.match_id == m.match_id, + UserPrediction.notify.is_(True), + UserPrediction.email.is_not(None), + UserPrediction.notified.is_(False), + ] + # '맞춘 사람만' 옵션: 승패(outcome) 적중자에게만 발송 + if settings.result_email_correct_only: + conds.append(UserPrediction.outcome == m.result_outcome) picks = ( - await db.execute( - select(UserPrediction).where( - UserPrediction.match_id == m.match_id, - UserPrediction.notify.is_(True), - UserPrediction.email.is_not(None), - UserPrediction.notified.is_(False), - ) - ) + await db.execute(select(UserPrediction).where(*conds)) ).scalars().all() ai_lines = [(p.model, p.outcome == m.result_outcome) for p in m.predictions] @@ -209,28 +249,39 @@ def build_scheduler() -> AsyncIOScheduler: minute=settings.ai_generate_minute, id="ai_generate", ) + # 결과 자동 정산 + 메일: 킥오프+Nh 지난 경기 스코어 수집·채점·발송 + sched.add_job( + settle_matches, + "interval", + seconds=settings.settle_tick_seconds, + id="settle", + next_run_time=now_utc(), + ) return sched async def main() -> None: load_scoring_data() # data/scoring.json → 배점·배제 대상 (채점·결과메일에 사용) await init_db() # 테이블 보장 (idempotent). 시드는 API 가 담당. - # 기동 시 1회: 일정 동기화 → AI 예측 생성(키 있을 때 GPT·Gemini 즉시 채움) + # 기동 시 1회: 일정 동기화 → AI 예측 생성 → 밀린 경기 자동 정산 await sync_schedule_job() await generate_ai_predictions() + await settle_matches() sched = build_scheduler() sched.start() log.info( "scheduling-server started — schedule sync daily %02d:%02d · status every %ss · " - "AI gen daily %02d:%02d %s · result email +%dmin", + "AI gen daily %02d:%02d %s · auto-settle 킥오프+%dh (every %ss) · 맞춘사람만=%s", settings.schedule_sync_hour, settings.schedule_sync_minute, settings.status_tick_seconds, settings.ai_generate_hour, settings.ai_generate_minute, settings.timezone, - settings.result_email_delay_minutes, + settings.result_settle_hours, + settings.settle_tick_seconds, + settings.result_email_correct_only, ) # 영구 대기 stop = asyncio.Event()