o2o-triple-pick/backend/app/routers/admin.py

135 lines
4.8 KiB
Python

"""관리자 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})