Crowd Pick 합 100% 초과 버그 수정 및 투표 오픈을 킥오프 7일 전으로 변경
parent
76eafceee8
commit
0a64a220ca
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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())}
|
||||
|
|
|
|||
Loading…
Reference in New Issue