"""픽 제출 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).""" 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: 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), )