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
# 투표 윈도우 (도메인 규칙)
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

View File

@ -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.

View File

@ -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})

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(
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:

View File

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