204 lines
8.2 KiB
Python
204 lines
8.2 KiB
Python
"""ORM 모델 — 기능정의서 데이터모델 + lib/types.ts 와 정합.
|
||
|
||
테이블:
|
||
- matches 경기 (팀/시각/투표윈도우/상태/결과)
|
||
- ai_predictions GPT/Claude/Gemini 예측 (경기 × 모델)
|
||
- crowd_stats 군중 투표 분포 (경기당 1행, 원자적 증분)
|
||
- user_predictions 유저 픽 (이메일 식별, 채점/알림 플래그 포함)
|
||
- user_points 유저별 누적 포인트 (채점 시 갱신, 이메일당 1행)
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from datetime import date, datetime
|
||
|
||
from sqlalchemy import (
|
||
Boolean,
|
||
Date,
|
||
DateTime,
|
||
ForeignKey,
|
||
Integer,
|
||
String,
|
||
UniqueConstraint,
|
||
func,
|
||
)
|
||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||
|
||
from .database import Base
|
||
|
||
# Outcome: TEAM_A_WIN | DRAW | TEAM_B_WIN
|
||
# ModelName: GPT | Claude | Gemini
|
||
# MatchStatus: scheduled | open | locked | live | finished
|
||
|
||
|
||
class Match(Base):
|
||
__tablename__ = "matches"
|
||
|
||
match_id: Mapped[str] = mapped_column(String, primary_key=True)
|
||
round_label: Mapped[str] = mapped_column(String, default="")
|
||
group: Mapped[str] = mapped_column(String, default="A")
|
||
|
||
# 팀 정보 (표시명/약식/코드/이모지) — 분리 컬럼으로 저장
|
||
team_a_name: Mapped[str] = mapped_column(String)
|
||
team_a_short: Mapped[str] = mapped_column(String)
|
||
team_a_code: Mapped[str] = mapped_column(String)
|
||
team_a_flag: Mapped[str] = mapped_column(String, default="")
|
||
team_b_name: Mapped[str] = mapped_column(String)
|
||
team_b_short: Mapped[str] = mapped_column(String)
|
||
team_b_code: Mapped[str] = mapped_column(String)
|
||
team_b_flag: Mapped[str] = mapped_column(String, default="")
|
||
|
||
venue: Mapped[str] = mapped_column(String, default="")
|
||
hook_text: Mapped[str] = mapped_column(String, default="")
|
||
|
||
# 모든 시각은 timezone-aware (UTC 저장, KST 환산은 표현 계층)
|
||
kickoff_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||
opens_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||
lock_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||
|
||
status: Mapped[str] = mapped_column(String, default="scheduled")
|
||
|
||
# 결과 (입력 전 None)
|
||
result_score_a: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||
result_score_b: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||
result_outcome: Mapped[str | None] = mapped_column(String, nullable=True)
|
||
finished_at: Mapped[datetime | None] = mapped_column(
|
||
DateTime(timezone=True), nullable=True
|
||
)
|
||
results_emailed_at: Mapped[datetime | None] = mapped_column(
|
||
DateTime(timezone=True), nullable=True
|
||
)
|
||
|
||
created_at: Mapped[datetime] = mapped_column(
|
||
DateTime(timezone=True), server_default=func.now()
|
||
)
|
||
updated_at: Mapped[datetime] = mapped_column(
|
||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||
)
|
||
|
||
predictions: Mapped[list["AIPrediction"]] = relationship(
|
||
back_populates="match", cascade="all, delete-orphan"
|
||
)
|
||
crowd: Mapped["CrowdStats | None"] = relationship(
|
||
back_populates="match", uselist=False, cascade="all, delete-orphan"
|
||
)
|
||
|
||
|
||
class AIPrediction(Base):
|
||
__tablename__ = "ai_predictions"
|
||
__table_args__ = (UniqueConstraint("match_id", "model", name="uq_match_model"),)
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||
match_id: Mapped[str] = mapped_column(
|
||
ForeignKey("matches.match_id", ondelete="CASCADE")
|
||
)
|
||
model: Mapped[str] = mapped_column(String) # GPT | Claude | Gemini
|
||
outcome: Mapped[str] = mapped_column(String)
|
||
score_a: Mapped[int] = mapped_column(Integer)
|
||
score_b: Mapped[int] = mapped_column(Integer)
|
||
confidence_pct: Mapped[int] = mapped_column(Integer)
|
||
reason_ko: Mapped[str] = mapped_column(String, default="")
|
||
reason_en: Mapped[str] = mapped_column(String, default="")
|
||
generated_at: Mapped[datetime] = mapped_column(
|
||
DateTime(timezone=True), server_default=func.now()
|
||
)
|
||
# 실연동 LLM 출력인지 시드 데이터인지 구분 (재생성 제어용)
|
||
source: Mapped[str] = mapped_column(String, default="seed") # seed | llm
|
||
|
||
match: Mapped["Match"] = relationship(back_populates="predictions")
|
||
|
||
|
||
class CrowdStats(Base):
|
||
__tablename__ = "crowd_stats"
|
||
|
||
match_id: Mapped[str] = mapped_column(
|
||
ForeignKey("matches.match_id", ondelete="CASCADE"), primary_key=True
|
||
)
|
||
total: Mapped[int] = mapped_column(Integer, default=0)
|
||
team_a_win: Mapped[int] = mapped_column(Integer, default=0)
|
||
draw: Mapped[int] = mapped_column(Integer, default=0)
|
||
team_b_win: Mapped[int] = mapped_column(Integer, default=0)
|
||
updated_at: Mapped[datetime] = mapped_column(
|
||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||
)
|
||
|
||
match: Mapped["Match"] = relationship(back_populates="crowd")
|
||
|
||
|
||
class UserPrediction(Base):
|
||
__tablename__ = "user_predictions"
|
||
__table_args__ = (
|
||
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)
|
||
match_id: Mapped[str] = mapped_column(
|
||
ForeignKey("matches.match_id", ondelete="CASCADE")
|
||
)
|
||
device_id: Mapped[str] = mapped_column(String) # 비로그인 식별 (클라 생성 uuid)
|
||
outcome: Mapped[str] = mapped_column(String)
|
||
score_a: Mapped[int] = mapped_column(Integer)
|
||
score_b: Mapped[int] = mapped_column(Integer)
|
||
email: Mapped[str | None] = mapped_column(String, nullable=True)
|
||
notify: Mapped[bool] = mapped_column(Boolean, default=False)
|
||
|
||
# 채점 (결과 입력 후 갱신)
|
||
points: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||
scored_at: Mapped[datetime | None] = mapped_column(
|
||
DateTime(timezone=True), nullable=True
|
||
)
|
||
notified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||
|
||
created_at: Mapped[datetime] = mapped_column(
|
||
DateTime(timezone=True), server_default=func.now()
|
||
)
|
||
updated_at: Mapped[datetime] = mapped_column(
|
||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||
)
|
||
|
||
|
||
class UserPoints(Base):
|
||
"""유저별 누적 포인트 — 채점(grade_prediction) 결과를 이메일 단위로 집계.
|
||
|
||
등급별 횟수 컬럼은 scoring.json 의 key(score_*) 와 1:1 대응.
|
||
exact_count 는 리더보드 동점 보정 1순위(정확 스코어 횟수)에 사용.
|
||
"""
|
||
|
||
__tablename__ = "user_points"
|
||
|
||
email: Mapped[str] = mapped_column(String, primary_key=True) # 소문자 정규화
|
||
total_points: Mapped[int] = mapped_column(Integer, default=0)
|
||
exact_count: Mapped[int] = mapped_column(Integer, default=0)
|
||
close_count: Mapped[int] = mapped_column(Integer, default=0)
|
||
outcome_count: Mapped[int] = mapped_column(Integer, default=0)
|
||
partial_count: Mapped[int] = mapped_column(Integer, default=0)
|
||
miss_count: Mapped[int] = mapped_column(Integer, default=0)
|
||
matches_played: Mapped[int] = mapped_column(Integer, default=0)
|
||
first_scored_at: Mapped[datetime | None] = mapped_column(
|
||
DateTime(timezone=True), nullable=True
|
||
)
|
||
updated_at: Mapped[datetime] = mapped_column(
|
||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||
)
|
||
|
||
|
||
class PageVisit(Base):
|
||
"""페이지 방문 — 하루(KST)에 같은 기기(device_id)는 1회만 기록(순수 방문자 수).
|
||
|
||
일별 집계 = visit_date 로 GROUP BY COUNT. 같은 날 재방문은 유니크 제약으로 무시.
|
||
"""
|
||
|
||
__tablename__ = "page_visits"
|
||
__table_args__ = (
|
||
UniqueConstraint("visit_date", "device_id", name="uq_visit_date_device"),
|
||
)
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||
visit_date: Mapped[date] = mapped_column(Date, index=True) # KST 기준 날짜
|
||
device_id: Mapped[str] = mapped_column(String) # 비로그인 식별 (localStorage uuid)
|
||
created_at: Mapped[datetime] = mapped_column(
|
||
DateTime(timezone=True), server_default=func.now()
|
||
)
|