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