diff --git a/backend/.env.example b/backend/.env.example index 2cf31d2..cf881e6 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -20,7 +20,7 @@ CORS_ORIGINS=* 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분 전 DEMO_FORCE_OPEN=true # 운영 배포 시 false diff --git a/backend/app/config.py b/backend/app/config.py index ba68a9c..24b6292 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -50,8 +50,8 @@ class Settings(BaseSettings): public_origin: str = "http://localhost:8080" # 공유 딥링크 베이스 # ── 투표 윈도우 (도메인 규칙) ───────────────────────────── - # 오픈 = 킥오프 - VOTE_OPEN_HOURS_BEFORE (D-5 = 120h) - vote_open_hours_before: int = 120 + # 오픈 = 킥오프 - VOTE_OPEN_HOURS_BEFORE (D-7 = 168h) + vote_open_hours_before: int = 168 # 마감 = 킥오프 5분 전 (경기 시작 5분 전까지 투표). lock 오프셋(분). vote_lock_minutes_before: int = 5 # 데모: True 면 오픈 게이트 무시(항상 투표 가능). 운영 배포 시 False. diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 4393ea2..c495b2b 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from ..config import settings from ..database import get_db 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 ..services.grading import apply_result @@ -85,3 +85,50 @@ async def daily_visits(db: AsyncSession = Depends(get_db)) -> list[DailyVisitOut ) ).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}) diff --git a/backend/app/routers/predictions.py b/backend/app/routers/predictions.py index cfed9ab..3f75358 100644 --- a/backend/app/routers/predictions.py +++ b/backend/app/routers/predictions.py @@ -26,14 +26,20 @@ _COL = {"TEAM_A_WIN": "team_a_win", "DRAW": "draw", "TEAM_B_WIN": "team_b_win"} async def _adjust_crowd( db: AsyncSession, match_id: str, *, add: str | None, remove: str | 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 = {} if add: col = _COL[add] values[col] = CrowdStats.__table__.c[col] + 1 if not remove: # 신규 제출이면 total +1 values["total"] = CrowdStats.total + 1 - if remove and remove != add: + if remove: col = _COL[remove] values[col] = CrowdStats.__table__.c[col] - 1 if values: diff --git a/frontend/src/components/Arena.tsx b/frontend/src/components/Arena.tsx index 34b5104..7926ad1 100644 --- a/frontend/src/components/Arena.tsx +++ b/frontend/src/components/Arena.tsx @@ -542,10 +542,18 @@ export default function Arena({ Crowd Pick{" "} {t.crowdSub} + {/* 비율은 분포 합 기준(=total 과 동일해야 정상) — 구 데이터 불일치 시에도 100% 이내로 표시 */}
- - - + {(() => { + const sum = crowd.teamAWin + crowd.draw + crowd.teamBWin; + return ( + <> + + + + + ); + })()}
{t.joined(crowd.total.toLocaleString())}