From c73a9ad9f9f21d628b5b4352176b9fe79752c7ae Mon Sep 17 00:00:00 2001 From: jwkim Date: Wed, 22 Jul 2026 14:04:14 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API=20=EC=B6=94=EA=B0=80(=EA=B2=BD=EA=B8=B0?= =?UTF-8?q?=EB=B3=84=20=ED=88=AC=ED=91=9C=C2=B7=EC=9D=B4=EB=A9=94=EC=9D=BC?= =?UTF-8?q?=20=EB=9E=AD=ED=82=B9,=20=ED=86=A0=ED=81=B0=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D)=20+=20ops/=20gitignore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .gitignore | 3 ++ backend/app/routers/admin.py | 69 ++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/.gitignore b/.gitignore index 1e9a192..93833c8 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,6 @@ serviceAccount.json # 로컬 전용 댓글 관리 도구 tp-comment-admin/ + +# 개인 운영 대시보드 (관리자 토큰 포함 — 커밋 금지) +ops/ diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index c495b2b..4c6c0d7 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -132,3 +132,72 @@ async def recount_crowd(db: AsyncSession = Depends(get_db)) -> GenericOk: 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) + ]