"""리더보드 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 ..database import get_db from ..models import Match, UserPoints from ..scoring import is_excluded from ..schemas import 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), db: AsyncSession = Depends(get_db) ) -> LeaderboardOut: 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)