204 lines
7.1 KiB
Python
204 lines
7.1 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})
|
|
|
|
|
|
# ── 운영 대시보드용 읽기 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)
|
|
]
|