Crowd Pick 합 100% 초과 버그 수정 및 투표 오픈을 킥오프 7일 전으로 변경

develop
jwkim 2026-06-19 10:36:58 +09:00
parent 76eafceee8
commit 0a64a220ca
5 changed files with 70 additions and 9 deletions

View File

@ -20,7 +20,7 @@ CORS_ORIGINS=*
PUBLIC_ORIGIN=http://localhost:8080 PUBLIC_ORIGIN=http://localhost:8080
# 투표 윈도우 (도메인 규칙) # 투표 윈도우 (도메인 규칙)
VOTE_OPEN_HOURS_BEFORE=48 # 오픈 = 킥오프 D-2 VOTE_OPEN_HOURS_BEFORE=168 # 오픈 = 킥오프 D-7
VOTE_LOCK_MINUTES_BEFORE=5 # 마감 = 킥오프 5분 전 VOTE_LOCK_MINUTES_BEFORE=5 # 마감 = 킥오프 5분 전
DEMO_FORCE_OPEN=true # 운영 배포 시 false DEMO_FORCE_OPEN=true # 운영 배포 시 false

View File

@ -50,8 +50,8 @@ class Settings(BaseSettings):
public_origin: str = "http://localhost:8080" # 공유 딥링크 베이스 public_origin: str = "http://localhost:8080" # 공유 딥링크 베이스
# ── 투표 윈도우 (도메인 규칙) ───────────────────────────── # ── 투표 윈도우 (도메인 규칙) ─────────────────────────────
# 오픈 = 킥오프 - VOTE_OPEN_HOURS_BEFORE (D-5 = 120h) # 오픈 = 킥오프 - VOTE_OPEN_HOURS_BEFORE (D-7 = 168h)
vote_open_hours_before: int = 120 vote_open_hours_before: int = 168
# 마감 = 킥오프 5분 전 (경기 시작 5분 전까지 투표). lock 오프셋(분). # 마감 = 킥오프 5분 전 (경기 시작 5분 전까지 투표). lock 오프셋(분).
vote_lock_minutes_before: int = 5 vote_lock_minutes_before: int = 5
# 데모: True 면 오픈 게이트 무시(항상 투표 가능). 운영 배포 시 False. # 데모: True 면 오픈 게이트 무시(항상 투표 가능). 운영 배포 시 False.

View File

@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from ..config import settings from ..config import settings
from ..database import get_db from ..database import get_db
from ..domain import now_utc from ..domain import now_utc
from ..models import AIPrediction, Match, PageVisit from ..models import AIPrediction, CrowdStats, Match, PageVisit, UserPrediction
from ..schemas import AdminAIPredictionIn, AdminSetResultIn, DailyVisitOut, GenericOk from ..schemas import AdminAIPredictionIn, AdminSetResultIn, DailyVisitOut, GenericOk
from ..services.grading import apply_result from ..services.grading import apply_result
@ -85,3 +85,50 @@ async def daily_visits(db: AsyncSession = Depends(get_db)) -> list[DailyVisitOut
) )
).all() ).all()
return [DailyVisitOut(date=d.isoformat(), uniqueVisitors=c) for d, c in rows] 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})

View File

@ -26,14 +26,20 @@ _COL = {"TEAM_A_WIN": "team_a_win", "DRAW": "draw", "TEAM_B_WIN": "team_b_win"}
async def _adjust_crowd( async def _adjust_crowd(
db: AsyncSession, match_id: str, *, add: str | None, remove: str | None db: AsyncSession, match_id: str, *, add: str | None, remove: str | None
) -> None: ) -> None:
"""crowd_stats 원자적 증분/보정 (Postgres UPDATE).""" """crowd_stats 원자적 증분/보정 (Postgres UPDATE).
같은 outcome 재제출(add == remove) 분포 변화가 없으므로 no-op.
이를 보정하지 않으면 컬럼만 +1 되어 a+draw+b > total 100% 초과.
"""
if add == remove: # 결과 변동 없음(스코어만 수정 등) → 분포 그대로
return
values: dict = {} values: dict = {}
if add: if add:
col = _COL[add] col = _COL[add]
values[col] = CrowdStats.__table__.c[col] + 1 values[col] = CrowdStats.__table__.c[col] + 1
if not remove: # 신규 제출이면 total +1 if not remove: # 신규 제출이면 total +1
values["total"] = CrowdStats.total + 1 values["total"] = CrowdStats.total + 1
if remove and remove != add: if remove:
col = _COL[remove] col = _COL[remove]
values[col] = CrowdStats.__table__.c[col] - 1 values[col] = CrowdStats.__table__.c[col] - 1
if values: if values:

View File

@ -542,10 +542,18 @@ export default function Arena({
Crowd Pick{" "} Crowd Pick{" "}
<span className="text-[14px] text-[var(--ink-muted)]">{t.crowdSub}</span> <span className="text-[14px] text-[var(--ink-muted)]">{t.crowdSub}</span>
</div> </div>
{/* 비율은 분포 합 기준(=total 과 동일해야 정상) — 구 데이터 불일치 시에도 100% 이내로 표시 */}
<div className="flex h-14 overflow-hidden rounded-2xl border border-[var(--line-d)]"> <div className="flex h-14 overflow-hidden rounded-2xl border border-[var(--line-d)]">
<CrowdSeg team={match.teamA} value={pct(crowd.teamAWin, crowd.total)} tone="a" /> {(() => {
<CrowdSeg value={pct(crowd.draw, crowd.total)} tone="draw" /> const sum = crowd.teamAWin + crowd.draw + crowd.teamBWin;
<CrowdSeg team={match.teamB} value={pct(crowd.teamBWin, crowd.total)} tone="b" /> return (
<>
<CrowdSeg team={match.teamA} value={pct(crowd.teamAWin, sum)} tone="a" />
<CrowdSeg value={pct(crowd.draw, sum)} tone="draw" />
<CrowdSeg team={match.teamB} value={pct(crowd.teamBWin, sum)} tone="b" />
</>
);
})()}
</div> </div>
<div className="mt-1.5 text-right text-[11px] text-[var(--ink-muted)]"> <div className="mt-1.5 text-right text-[11px] text-[var(--ink-muted)]">
{t.joined(crowd.total.toLocaleString())} {t.joined(crowd.total.toLocaleString())}