feat(predictions): 이메일도 중복 투표 방지 (이메일 우선 식별)
같은 이메일은 같은 경기에 1픽만 — 다른 기기에서 같은 이메일로 제출 시 새 픽 추가 대신 기존 픽 수정 + crowd 보정. 이메일은 소문자 정규화 저장. 모델에 (match_id, email) 유니크 제약 추가(신규 DB 적용, 기존 테이블은 앱 로직 보장). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>develop
parent
86290a7c69
commit
c9244bc42d
|
|
@ -128,6 +128,9 @@ class UserPrediction(Base):
|
||||||
__tablename__ = "user_predictions"
|
__tablename__ = "user_predictions"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint("match_id", "device_id", name="uq_match_device"),
|
UniqueConstraint("match_id", "device_id", name="uq_match_device"),
|
||||||
|
# 같은 이메일은 같은 경기에 1픽만 (NULL 이메일은 다수 허용 — NULL 은 서로 구별).
|
||||||
|
# 신규 DB 에만 자동 적용. 기존 테이블은 앱 로직(이메일 우선 식별)으로 보장.
|
||||||
|
UniqueConstraint("match_id", "email", name="uq_match_email"),
|
||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import func, select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
|
@ -63,15 +63,28 @@ async def submit_prediction(
|
||||||
|
|
||||||
# outcome 은 스코어에서 도출 (입력과 불일치해도 스코어 기준으로 정규화)
|
# outcome 은 스코어에서 도출 (입력과 불일치해도 스코어 기준으로 정규화)
|
||||||
outcome = outcome_of(body.scoreA, body.scoreB)
|
outcome = outcome_of(body.scoreA, body.scoreB)
|
||||||
|
email = str(body.email).strip().lower() if body.email else None
|
||||||
|
|
||||||
existing = (
|
# 중복 식별: ① 이메일(있으면 1차 신원 — 다른 기기여도 동일인) ② 기기(deviceId)
|
||||||
await db.execute(
|
existing = None
|
||||||
select(UserPrediction).where(
|
if email:
|
||||||
UserPrediction.match_id == body.matchId,
|
existing = (
|
||||||
UserPrediction.device_id == body.deviceId,
|
await db.execute(
|
||||||
|
select(UserPrediction).where(
|
||||||
|
UserPrediction.match_id == body.matchId,
|
||||||
|
func.lower(UserPrediction.email) == email,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
).scalars().first()
|
||||||
).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:
|
if existing:
|
||||||
# 마감 전 1회 수정: 분포 보정 (이전 outcome 제거, 새 outcome 추가)
|
# 마감 전 1회 수정: 분포 보정 (이전 outcome 제거, 새 outcome 추가)
|
||||||
|
|
@ -79,8 +92,9 @@ async def submit_prediction(
|
||||||
existing.outcome = outcome
|
existing.outcome = outcome
|
||||||
existing.score_a = body.scoreA
|
existing.score_a = body.scoreA
|
||||||
existing.score_b = body.scoreB
|
existing.score_b = body.scoreB
|
||||||
if body.email:
|
existing.device_id = body.deviceId # 최신 제출 기기로 갱신
|
||||||
existing.email = str(body.email)
|
if email:
|
||||||
|
existing.email = email
|
||||||
existing.notify = body.notify
|
existing.notify = body.notify
|
||||||
pred = existing
|
pred = existing
|
||||||
else:
|
else:
|
||||||
|
|
@ -91,7 +105,7 @@ async def submit_prediction(
|
||||||
outcome=outcome,
|
outcome=outcome,
|
||||||
score_a=body.scoreA,
|
score_a=body.scoreA,
|
||||||
score_b=body.scoreB,
|
score_b=body.scoreB,
|
||||||
email=str(body.email) if body.email else None,
|
email=email,
|
||||||
notify=body.notify,
|
notify=body.notify,
|
||||||
)
|
)
|
||||||
db.add(pred)
|
db.add(pred)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue