diff --git a/backend/app/config.py b/backend/app/config.py index 24b6292..75c51a4 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -93,6 +93,10 @@ class Settings(BaseSettings): result_settle_hours: int = 3 # 정산/메일 점검 주기(초). status_tick 와 함께 도는 별도 폴링. settle_tick_seconds: int = 300 + # 종료 경기 결과 재확인 기간(일). 최근 이 기간 내 종료 경기는 매 틱 외부 소스와 + # 대조해, 소스가 스코어를 정정(예: 잠정값→확정값)하면 자동 갱신·재채점한다. + # 0이면 재확인 비활성(첫 정산값 고정). 오래된 경기는 대상에서 빠져 부하 bounded. + result_recheck_days: int = 3 # 결과 메일을 '맞춘 사람(승패 적중)'에게만 보낼지 여부. False면 구독자 전체. result_email_correct_only: bool = True # 결과 메일: 결과 확정 후 추가 지연(분). 자동정산은 이미 킥오프+Nh라 기본 0. diff --git a/backend/app/worker.py b/backend/app/worker.py index 698c59b..ac25d6f 100644 --- a/backend/app/worker.py +++ b/backend/app/worker.py @@ -159,29 +159,56 @@ async def refresh_football_data() -> None: # ── 2.5) 결과 자동 정산 (관리자 입력 불필요) ──────────────── async def settle_matches() -> None: """킥오프가 지난 미정산 경기를, 소스가 FINISHED 로 보고하는 즉시 채점·종료. + 또한 최근 N일(result_recheck_days) 내 종료된 경기는 외부 소스와 대조해, 소스가 + 스코어를 정정하면(잠정값→확정값 등) 자동 갱신·재채점한다. 킥오프 이후의 미정산 경기를 폴링하되, 외부 소스(football-data)가 FINISHED 로 확정 스코어를 줄 때만 apply_result → 채점·유저 포인트 집계·finished_at·메일. 경기 중(IN_PLAY)에는 FINISHED 가 아니라 결과에 안 잡혀 자동 스킵된다(조기 종료 없음). 아직 FINISHED 가 아니면 다음 틱(5분)에 재시도 → 종료 후 최대 1틱 내 반영. + + 재확인은 '최근 N일 종료 경기'로만 한정 → 오래된 경기는 대상에서 빠져 부하 bounded. + 소스 호출은 신규/재확인이 모두 같은 단일 fetch_results() 응답을 공유한다. """ 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: pending = ( await db.execute(select(Match).where(Match.result_outcome.is_(None))) ).scalars().all() # 킥오프가 지난 경기만 (소스가 FINISHED 줄 수 있는 시점) 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() # 정방향/역방향 키 모두 등록 — 소스의 홈/원정 순서가 우리와 달라도 매칭. by_pair: dict[tuple, tuple[int, int]] = {} for r in results: by_pair[(r["teamA"], r["teamB"])] = (r["scoreA"], r["scoreB"]) by_pair[(r["teamB"], r["teamA"])] = (r["scoreB"], r["scoreA"]) # 뒤집어 저장 - settled = 0 + settled = corrected = 0 async with SessionLocal() as db: + # 1) 신규 정산 — 미정산 경기에 FINISHED 스코어 반영 for mid in due: m = await db.get(Match, mid) 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 settled += 1 log.info("settle: %s 자동 정산 %d-%d", mid, sc[0], sc[1]) - if settled == 0: - log.info("settle: %d경기 진행/대기 — 아직 FINISHED 아님", len(due)) + # 2) 최근 종료 경기 재확인 — 소스 스코어가 DB와 다르면 정정+재채점 + 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()