종료 경기 결과 외부 소스 재대조로 자동 정정
parent
90d00a817b
commit
d05e85e593
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue