종료 경기 결과 외부 소스 재대조로 자동 정정

develop
jwkim 2026-06-22 15:06:19 +09:00
parent 90d00a817b
commit d05e85e593
2 changed files with 54 additions and 4 deletions

View File

@ -93,6 +93,10 @@ class Settings(BaseSettings):
result_settle_hours: int = 3 result_settle_hours: int = 3
# 정산/메일 점검 주기(초). status_tick 와 함께 도는 별도 폴링. # 정산/메일 점검 주기(초). status_tick 와 함께 도는 별도 폴링.
settle_tick_seconds: int = 300 settle_tick_seconds: int = 300
# 종료 경기 결과 재확인 기간(일). 최근 이 기간 내 종료 경기는 매 틱 외부 소스와
# 대조해, 소스가 스코어를 정정(예: 잠정값→확정값)하면 자동 갱신·재채점한다.
# 0이면 재확인 비활성(첫 정산값 고정). 오래된 경기는 대상에서 빠져 부하 bounded.
result_recheck_days: int = 3
# 결과 메일을 '맞춘 사람(승패 적중)'에게만 보낼지 여부. False면 구독자 전체. # 결과 메일을 '맞춘 사람(승패 적중)'에게만 보낼지 여부. False면 구독자 전체.
result_email_correct_only: bool = True result_email_correct_only: bool = True
# 결과 메일: 결과 확정 후 추가 지연(분). 자동정산은 이미 킥오프+Nh라 기본 0. # 결과 메일: 결과 확정 후 추가 지연(분). 자동정산은 이미 킥오프+Nh라 기본 0.

View File

@ -159,29 +159,56 @@ async def refresh_football_data() -> None:
# ── 2.5) 결과 자동 정산 (관리자 입력 불필요) ──────────────── # ── 2.5) 결과 자동 정산 (관리자 입력 불필요) ────────────────
async def settle_matches() -> None: async def settle_matches() -> None:
"""킥오프가 지난 미정산 경기를, 소스가 FINISHED 로 보고하는 즉시 채점·종료. """킥오프가 지난 미정산 경기를, 소스가 FINISHED 로 보고하는 즉시 채점·종료.
또한 최근 N일(result_recheck_days) 종료된 경기는 외부 소스와 대조해, 소스가
스코어를 정정하면(잠정값확정값 ) 자동 갱신·재채점한다.
킥오프 이후의 미정산 경기를 폴링하되, 외부 소스(football-data) FINISHED 킥오프 이후의 미정산 경기를 폴링하되, 외부 소스(football-data) FINISHED
확정 스코어를 때만 apply_result 채점·유저 포인트 집계·finished_at·메일. 확정 스코어를 때만 apply_result 채점·유저 포인트 집계·finished_at·메일.
경기 (IN_PLAY)에는 FINISHED 아니라 결과에 잡혀 자동 스킵된다(조기 종료 없음). 경기 (IN_PLAY)에는 FINISHED 아니라 결과에 잡혀 자동 스킵된다(조기 종료 없음).
아직 FINISHED 아니면 다음 (5) 재시도 종료 최대 1 반영. 아직 FINISHED 아니면 다음 (5) 재시도 종료 최대 1 반영.
재확인은 '최근 N일 종료 경기'로만 한정 오래된 경기는 대상에서 빠져 부하 bounded.
소스 호출은 신규/재확인이 모두 같은 단일 fetch_results() 응답을 공유한다.
""" """
now = now_utc() now = now_utc()
recheck_cutoff = (
now - timedelta(days=settings.result_recheck_days)
if settings.result_recheck_days > 0
else None
)
async with SessionLocal() as db: async with SessionLocal() as db:
pending = ( pending = (
await db.execute(select(Match).where(Match.result_outcome.is_(None))) await db.execute(select(Match).where(Match.result_outcome.is_(None)))
).scalars().all() ).scalars().all()
# 킥오프가 지난 경기만 (소스가 FINISHED 줄 수 있는 시점) # 킥오프가 지난 경기만 (소스가 FINISHED 줄 수 있는 시점)
due = [m.match_id for m in pending if ensure_aware(m.kickoff_at) <= now] due = [m.match_id for m in pending if ensure_aware(m.kickoff_at) <= now]
# 최근 종료 경기(소스 정정 반영용) — TZ 안전하게 파이썬에서 기간 필터.
recheck_ids: list[str] = []
if recheck_cutoff is not None:
finished = (
await db.execute(
select(Match).where(
Match.result_outcome.is_not(None),
Match.finished_at.is_not(None),
)
)
).scalars().all()
recheck_ids = [
m.match_id
for m in finished
if m.finished_at and ensure_aware(m.finished_at) >= recheck_cutoff
]
if due: if due or recheck_ids:
results = await fetch_results() results = await fetch_results()
# 정방향/역방향 키 모두 등록 — 소스의 홈/원정 순서가 우리와 달라도 매칭. # 정방향/역방향 키 모두 등록 — 소스의 홈/원정 순서가 우리와 달라도 매칭.
by_pair: dict[tuple, tuple[int, int]] = {} by_pair: dict[tuple, tuple[int, int]] = {}
for r in results: for r in results:
by_pair[(r["teamA"], r["teamB"])] = (r["scoreA"], r["scoreB"]) by_pair[(r["teamA"], r["teamB"])] = (r["scoreA"], r["scoreB"])
by_pair[(r["teamB"], r["teamA"])] = (r["scoreB"], r["scoreA"]) # 뒤집어 저장 by_pair[(r["teamB"], r["teamA"])] = (r["scoreB"], r["scoreA"]) # 뒤집어 저장
settled = 0 settled = corrected = 0
async with SessionLocal() as db: async with SessionLocal() as db:
# 1) 신규 정산 — 미정산 경기에 FINISHED 스코어 반영
for mid in due: for mid in due:
m = await db.get(Match, mid) m = await db.get(Match, mid)
if m is None or m.result_outcome is not None: if m is None or m.result_outcome is not None:
@ -192,8 +219,27 @@ async def settle_matches() -> None:
await apply_result(db, m, sc[0], sc[1]) # 우리 팀A/팀B 기준으로 채점+집계+commit await apply_result(db, m, sc[0], sc[1]) # 우리 팀A/팀B 기준으로 채점+집계+commit
settled += 1 settled += 1
log.info("settle: %s 자동 정산 %d-%d", mid, sc[0], sc[1]) log.info("settle: %s 자동 정산 %d-%d", mid, sc[0], sc[1])
if settled == 0: # 2) 최근 종료 경기 재확인 — 소스 스코어가 DB와 다르면 정정+재채점
log.info("settle: %d경기 진행/대기 — 아직 FINISHED 아님", len(due)) for mid in recheck_ids:
m = await db.get(Match, mid)
if m is None or m.result_outcome is None:
continue
sc = by_pair.get((m.team_a_code, m.team_b_code))
if not sc:
continue # 소스에 아직 없으면 기존값 유지
if (m.result_score_a, m.result_score_b) == (sc[0], sc[1]):
continue # 동일 — 변경 없음(대부분 여기서 종료, 재채점 안 함)
old_a, old_b = m.result_score_a, m.result_score_b
await apply_result(db, m, sc[0], sc[1]) # 정정+재채점+집계+commit
corrected += 1
log.warning(
"settle: %s 결과 정정 %s-%s%d-%d (소스 변경 반영)",
mid, old_a, old_b, sc[0], sc[1],
)
if not settled and not corrected:
log.info(
"settle: 신규 %d·재확인 %d경기 — 변경 없음", len(due), len(recheck_ids)
)
await maybe_send_result_emails() await maybe_send_result_emails()