feat(worker): 경기 결과 자동 정산 + '맞춘 사람만' 결과 메일

- 킥오프+result_settle_hours 경과 경기를 외부 스코어로 자동 채점(settle_matches)
- schedule_fetch.fetch_results: openfootball/football-data 확정 스코어 수집
- 결과 메일을 승패 적중자에게만 발송하는 옵션(result_email_correct_only)
- 스케줄러에 settle 잡 추가 + 기동 시 1회 밀린 경기 정산

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
develop
hbyang 2026-06-11 14:25:23 +09:00
parent 464145a234
commit 4244518ffd
3 changed files with 156 additions and 14 deletions

View File

@ -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

View File

@ -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 []

View File

@ -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()