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
parent
464145a234
commit
4244518ffd
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue