164 lines
5.7 KiB
Python
164 lines
5.7 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)."""
|
|
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),
|
|
)
|