o2o-triple-pick/backend/app/routers/leaderboard.py
jwkim 4e1ebc8989 feat: KBO/MLB 운영 안정화 — 순위 탭·라이브 개선·취소/더블헤더 지원·야구 점수제
AI 예측
- 생성 시점을 킥오프 22시간 전으로 변경(선발투수 예고 이후), 잡 매시간 실행
- 경기 시작 후 생성 금지(사후 예측 방지), 경기 단위 커밋으로 중단 시 진행분 보존

일정·라이브 UI
- 야구 일정에 순위 탭 추가 (KBO 단일 순위표 · MLB 6개 디비전, /api/standings 신설)
- 경기중 카드에 현재 이닝·실시간 스코어 표시 (30s 폴링, 서버 15s 캐시)
- MLB 선발 누락 수정: statsapi 날짜 파라미터 미국 날짜 기준 보정(-1일)
- 라이브 대기타석 스크롤바 다크 테마 처리

운영 견고성
- 우천취소: 경기 삭제 → cancelled 상태 보존 (투표·예측 기록 유지, 정산/투표/AI 제외)
- 더블헤더: 시작시각순 seq로 1·2차전 분리 등록 (match_id _2, 정산·라이브·프리뷰 매핑)
- 축구 데이터 수집을 wc 리그로 한정 (야구 팀을 축구 API에 검색하던 쿼터 낭비 수정)

점수제
- 야구 판정 완화: 근접=득실차 ±1, 부분=한 팀 득점 ±1 (등급·배점은 리그 공통 유지)
- 채점 판정을 _grade_of() 단일 함수로 통합, 종료된 야구 경기 재채점
- 배점 안내 모달 야구 문구 분리, docs/SCORING.md 갱신

기타
- 야구 전용 댓글 닉네임 풀 (종목별 세션 캐시 분리)
- 리더보드 리그별 조회 연동 (?league=)
2026-07-22 11:01:10 +09:00

166 lines
5.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""리더보드 API — 누적 포인트 1위 (docs/SCORING.md §3 랭킹 규칙).
user_points 테이블(채점 시 누적 갱신) 기준 랭킹.
1차 정렬: 누적 포인트. 동점: 정확스코어 횟수 → 적중률 → 참여수 → 최초도달.
주최측/임직원 배제(P1). 이메일은 마스킹하여 노출.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from ..database import get_db
from ..models import Match, UserPoints, UserPrediction
from ..scoring import is_excluded, score_prediction
from ..schemas import (
AILeaderboardOut,
AIStandingOut,
LeaderboardOut,
StandingOut,
)
router = APIRouter(prefix="/api/leaderboard", tags=["leaderboard"])
def _mask(email: str) -> str:
name, _, domain = email.partition("@")
head = name[:2] if len(name) >= 2 else name
return f"{head}{'*' * max(1, len(name) - 2)}@{domain}"
@router.get("", response_model=LeaderboardOut)
async def leaderboard(
limit: int = Query(50, ge=1, le=200),
league: str = Query("", description="wc | kbo | mlb — 빈값이면 전체 합산"),
db: AsyncSession = Depends(get_db),
) -> LeaderboardOut:
if league:
# 리그별 랭킹 — 채점된 픽(points)을 리그 경기로 한정해 이메일별 재집계.
picks = (
await db.execute(
select(UserPrediction, Match)
.join(Match, Match.match_id == UserPrediction.match_id)
.where(
Match.league == league,
Match.result_outcome.is_not(None),
UserPrediction.points.is_not(None),
UserPrediction.email.is_not(None),
)
)
).all()
agg: dict[str, list] = {} # email → [pts, exact, played, first_ts]
for pk, m in picks:
row = agg.setdefault(pk.email, [0, 0, 0, None])
row[0] += pk.points or 0
row[1] += 1 if (
pk.score_a == m.result_score_a and pk.score_b == m.result_score_b
) else 0
row[2] += 1
ranked_rows = sorted(
((e, v) for e, v in agg.items() if not is_excluded(e)),
key=lambda kv: (-kv[1][0], -kv[1][1], -kv[1][2]),
)
scored_matches = (
await db.execute(
select(func.count()).select_from(Match).where(
Match.league == league, Match.result_outcome.is_not(None)
)
)
).scalar() or 0
standings = [
StandingOut(
rank=i + 1, emailMasked=_mask(e),
totalPoints=v[0], exactCount=v[1], matchesPlayed=v[2],
)
for i, (e, v) in enumerate(ranked_rows[:limit])
]
return LeaderboardOut(standings=standings, scoredMatches=scored_matches)
rows = (
await db.execute(select(UserPoints))
).scalars().all()
ranked = sorted(
(r for r in rows if not is_excluded(r.email)),
key=lambda r: (
-r.total_points,
-r.exact_count,
-(r.total_points / r.matches_played if r.matches_played else 0),
-r.matches_played,
r.first_scored_at.timestamp() if r.first_scored_at else 0,
),
)
scored_matches = (
await db.execute(
select(func.count())
.select_from(Match)
.where(Match.result_outcome.is_not(None))
)
).scalar() or 0
standings = [
StandingOut(
rank=i + 1,
emailMasked=_mask(r.email),
totalPoints=r.total_points,
exactCount=r.exact_count,
matchesPlayed=r.matches_played,
)
for i, r in enumerate(ranked[:limit])
]
return LeaderboardOut(standings=standings, scoredMatches=scored_matches)
@router.get("/ai", response_model=AILeaderboardOut)
async def ai_leaderboard(
league: str = Query("", description="wc | kbo | mlb — 빈값이면 전체"),
db: AsyncSession = Depends(get_db),
) -> AILeaderboardOut:
"""AI 모델 누적 랭킹 — 종료된 경기의 AI 예측을 유저와 동일한 배점으로 채점·합산.
별도 누적 테이블 없이 매 조회 시 종료 경기 전수 재계산 → 결과/배점 변동에
항상 일관. (모델 3개 × 경기 수라 비용 무시 가능.)
"""
q = (
select(Match)
.where(Match.result_outcome.is_not(None))
.options(selectinload(Match.predictions))
)
if league:
q = q.where(Match.league == league)
matches = (await db.execute(q)).scalars().all()
# model → [points, exact_count, matches_played]
agg: dict[str, list[int]] = {}
for m in matches:
bb = m.league in ("kbo", "mlb")
for p in m.predictions:
pts = score_prediction(
p.score_a, p.score_b, m.result_score_a, m.result_score_b, baseball=bb
)
row = agg.setdefault(p.model, [0, 0, 0])
row[0] += pts
row[1] += 1 if (p.score_a == m.result_score_a and p.score_b == m.result_score_b) else 0
row[2] += 1
order = {"GPT": 0, "Claude": 1, "Gemini": 2}
ranked = sorted(
agg.items(),
key=lambda kv: (-kv[1][0], -kv[1][1], -kv[1][2], order.get(kv[0], 9)),
)
standings = [
AIStandingOut(
rank=i + 1,
model=model, # type: ignore[arg-type]
totalPoints=pts,
exactCount=exact,
matchesPlayed=played,
)
for i, (model, (pts, exact, played)) in enumerate(ranked)
]
return AILeaderboardOut(standings=standings, scoredMatches=len(matches))