"""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 datetime from sqlalchemy import ( Boolean, 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"), ) 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() )