o2o-triple-pick/backend/app/routers/predictions.py

170 lines
6.0 KiB
Python

"""픽 제출 API — 검증 · 중복방지(1회 수정) · crowd 원자적 증분 · 매칭 모델 계산."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from ..database import get_db
from ..domain import (
compute_phase,
crowd_out,
is_open_for_voting,
is_votable,
my_prediction_out,
)
from ..models import AIPrediction, CrowdStats, Match, UserPrediction
from ..scoring import outcome_of
from ..schemas import MyPredictionOut, SubmitPredictionIn, SubmitPredictionOut
router = APIRouter(prefix="/api/predictions", tags=["predictions"])
_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).
같은 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:
col = _COL[remove]
values[col] = CrowdStats.__table__.c[col] - 1
if values:
await db.execute(
update(CrowdStats).where(CrowdStats.match_id == match_id).values(**values)
)
@router.get("/mine", response_model=list[MyPredictionOut])
async def my_predictions(
email: str, db: AsyncSession = Depends(get_db)
) -> list[MyPredictionOut]:
"""이메일 기준 내 지난 예측 목록 (최신 제출순). 로그인 없음 — 이메일이 신원."""
e = email.strip().lower()
if not e:
return []
rows = (
await db.execute(
select(UserPrediction, Match)
.join(Match, Match.match_id == UserPrediction.match_id)
.where(func.lower(UserPrediction.email) == e)
.order_by(UserPrediction.updated_at.desc())
)
).all()
return [my_prediction_out(up, m) for up, m in rows]
@router.post("", response_model=SubmitPredictionOut)
async def submit_prediction(
body: SubmitPredictionIn, db: AsyncSession = Depends(get_db)
) -> SubmitPredictionOut:
match = (
await db.execute(
select(Match)
.where(Match.match_id == body.matchId)
.options(selectinload(Match.predictions))
)
).scalars().first()
if not match:
raise HTTPException(status_code=404, detail="MATCH_NOT_FOUND")
if not is_votable(match):
raise HTTPException(status_code=403, detail="MATCH_NOT_VOTABLE")
if not is_open_for_voting(match):
# 종료 사유 정밀 구분: 종료 / 경기중 / 투표종료 / 오픈전
phase = compute_phase(match)
detail = {
"finished": "MATCH_FINISHED", # 결과 입력됨
"live": "MATCH_LIVE", # 경기중
"locked": "MATCH_LOCKED", # 투표 종료(킥오프 1h 전)
"scheduled": "MATCH_NOT_OPEN", # 아직 오픈 전
}.get(phase, "MATCH_LOCKED")
code = 409 if phase == "finished" else 423
raise HTTPException(status_code=code, detail=detail)
# outcome 은 스코어에서 도출 (입력과 불일치해도 스코어 기준으로 정규화)
outcome = outcome_of(body.scoreA, body.scoreB)
email = str(body.email).strip().lower() if body.email else None
# 중복 식별: ① 이메일(있으면 1차 신원 — 다른 기기여도 동일인) ② 기기(deviceId)
existing = None
if email:
existing = (
await db.execute(
select(UserPrediction).where(
UserPrediction.match_id == body.matchId,
func.lower(UserPrediction.email) == email,
)
)
).scalars().first()
if existing is None:
existing = (
await db.execute(
select(UserPrediction).where(
UserPrediction.match_id == body.matchId,
UserPrediction.device_id == body.deviceId,
)
)
).scalars().first()
if existing:
# 마감 전 1회 수정: 분포 보정 (이전 outcome 제거, 새 outcome 추가)
await _adjust_crowd(db, body.matchId, add=outcome, remove=existing.outcome)
existing.outcome = outcome
existing.score_a = body.scoreA
existing.score_b = body.scoreB
existing.device_id = body.deviceId # 최신 제출 기기로 갱신
if email:
existing.email = email
existing.notify = body.notify
pred = existing
else:
await _adjust_crowd(db, body.matchId, add=outcome, remove=None)
pred = UserPrediction(
match_id=body.matchId,
device_id=body.deviceId,
outcome=outcome,
score_a=body.scoreA,
score_b=body.scoreB,
email=email,
notify=body.notify,
)
db.add(pred)
await db.commit()
# 매칭 AI 모델 (같은 outcome) + 정확스코어 일치 여부
matched: list[str] = []
exact = False
for p in match.predictions:
if p.outcome == outcome:
matched.append(p.model)
if p.score_a == body.scoreA and p.score_b == body.scoreB:
exact = True
crowd = (
await db.execute(
select(CrowdStats).where(CrowdStats.match_id == body.matchId)
)
).scalars().first()
return SubmitPredictionOut(
ok=True,
predictionId=f"{body.matchId}_{body.deviceId}",
matchedModels=matched, # type: ignore[arg-type]
exactMatch=exact,
crowd=crowd_out(crowd, body.matchId),
)