"""관리자 API — Bearer 토큰 인증. 경기/AI예측 upsert + 결과 입력(채점).""" from __future__ import annotations from fastapi import APIRouter, Depends, Header, HTTPException from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from ..config import settings from ..database import get_db from ..domain import now_utc from ..models import AIPrediction, CrowdStats, Match, PageVisit, UserPrediction from ..schemas import AdminAIPredictionIn, AdminSetResultIn, DailyVisitOut, GenericOk from ..services.grading import apply_result router = APIRouter(prefix="/api/admin", tags=["admin"]) def require_admin(authorization: str = Header(default="")) -> None: token = authorization.removeprefix("Bearer ").strip() if not token or token != settings.admin_api_token: raise HTTPException(status_code=401, detail="UNAUTHORIZED") @router.post("/ai-predictions", response_model=GenericOk, dependencies=[Depends(require_admin)]) async def upsert_ai_prediction( body: AdminAIPredictionIn, db: AsyncSession = Depends(get_db) ) -> GenericOk: match = ( await db.execute(select(Match).where(Match.match_id == body.matchId)) ).scalars().first() if not match: raise HTTPException(status_code=404, detail="MATCH_NOT_FOUND") pred = ( await db.execute( select(AIPrediction).where( AIPrediction.match_id == body.matchId, AIPrediction.model == body.model ) ) ).scalars().first() if pred is None: pred = AIPrediction(match_id=body.matchId, model=body.model) db.add(pred) pred.outcome = body.outcome pred.score_a = body.scoreA pred.score_b = body.scoreB pred.confidence_pct = body.confidencePct pred.reason_ko = body.reasonKo pred.reason_en = body.reasonEn pred.generated_at = now_utc() pred.source = "admin" await db.commit() return GenericOk(ok=True, detail="upserted") @router.post("/result", response_model=GenericOk, dependencies=[Depends(require_admin)]) async def set_result( body: AdminSetResultIn, db: AsyncSession = Depends(get_db) ) -> GenericOk: match = ( await db.execute(select(Match).where(Match.match_id == body.matchId)) ).scalars().first() if not match: raise HTTPException(status_code=404, detail="MATCH_NOT_FOUND") graded = await apply_result(db, match, body.scoreA, body.scoreB) # 결과 메일은 워커가 result_email_delay_minutes 경과 후 발송(@finished_at 기준). return GenericOk( ok=True, detail="result set & graded", extra={"gradedPicks": graded, "emailsSentBy": "worker"}, ) @router.get( "/visits", response_model=list[DailyVisitOut], dependencies=[Depends(require_admin)] ) async def daily_visits(db: AsyncSession = Depends(get_db)) -> list[DailyVisitOut]: """일별 순수 방문자 수 (최신순).""" rows = ( await db.execute( select(PageVisit.visit_date, func.count()) .group_by(PageVisit.visit_date) .order_by(PageVisit.visit_date.desc()) ) ).all() return [DailyVisitOut(date=d.isoformat(), uniqueVisitors=c) for d, c in rows] @router.post( "/recount-crowd", response_model=GenericOk, dependencies=[Depends(require_admin)] ) async def recount_crowd(db: AsyncSession = Depends(get_db)) -> GenericOk: """crowd_stats 를 user_predictions(진실 원천)에서 재집계. 과거 같은 outcome 재제출로 팀 컬럼만 부풀려진(=합 100% 초과) 행을 복구한다. """ # 경기별 outcome 분포 집계 rows = ( await db.execute( select( UserPrediction.match_id, UserPrediction.outcome, func.count(), ).group_by(UserPrediction.match_id, UserPrediction.outcome) ) ).all() tally: dict[str, dict[str, int]] = {} for match_id, outcome, c in rows: t = tally.setdefault( match_id, {"total": 0, "team_a_win": 0, "draw": 0, "team_b_win": 0} ) col = {"TEAM_A_WIN": "team_a_win", "DRAW": "draw", "TEAM_B_WIN": "team_b_win"}[outcome] t[col] += c t["total"] += c stats = (await db.execute(select(CrowdStats))).scalars().all() fixed = 0 for s in stats: t = tally.get(s.match_id, {"total": 0, "team_a_win": 0, "draw": 0, "team_b_win": 0}) if ( s.total != t["total"] or s.team_a_win != t["team_a_win"] or s.draw != t["draw"] or s.team_b_win != t["team_b_win"] ): s.total = t["total"] s.team_a_win = t["team_a_win"] s.draw = t["draw"] s.team_b_win = t["team_b_win"] fixed += 1 await db.commit() return GenericOk(ok=True, detail="crowd recounted", extra={"matchesFixed": fixed}) # ── 운영 대시보드용 읽기 API (개인 관리용 — 이메일 원본 노출 주의) ── @router.get("/matches/{match_id}/votes", dependencies=[Depends(require_admin)]) async def match_votes( match_id: str, db: AsyncSession = Depends(get_db) ) -> list[dict]: """경기별 투표 전체 — 누가(이메일 원본) 어떻게 찍었고 몇 점 받았는지.""" rows = ( await db.execute( select(UserPrediction) .where(UserPrediction.match_id == match_id) .order_by(UserPrediction.created_at.desc()) ) ).scalars().all() return [ { "email": r.email, "outcome": r.outcome, "scoreA": r.score_a, "scoreB": r.score_b, "points": r.points, "notify": r.notify, "createdAt": r.created_at.isoformat() if r.created_at else None, } for r in rows ] @router.get("/leaderboard", dependencies=[Depends(require_admin)]) async def full_leaderboard( league: str = "", db: AsyncSession = Depends(get_db) ) -> list[dict]: """이메일 원본 랭킹 — 공개 리더보드와 같은 집계(채점된 픽 재집계). 배제 대상(운영진 도메인)도 표시하되 excluded 플래그로 구분.""" from ..scoring import is_excluded q = ( select(UserPrediction, Match) .join(Match, Match.match_id == UserPrediction.match_id) .where( Match.result_outcome.is_not(None), UserPrediction.points.is_not(None), UserPrediction.email.is_not(None), ) ) if league: q = q.where(Match.league == league) picks = (await db.execute(q)).all() agg: dict[str, list[int]] = {} for pk, m in picks: row = agg.setdefault(pk.email, [0, 0, 0]) 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 = sorted(agg.items(), key=lambda kv: (-kv[1][0], -kv[1][1], -kv[1][2])) return [ { "rank": i + 1, "email": e, "totalPoints": v[0], "exactCount": v[1], "matchesPlayed": v[2], "excluded": is_excluded(e), } for i, (e, v) in enumerate(ranked) ]