"""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 ( JSON, 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() ) class Comment(Base): """경기별 한마디(댓글). 비로그인 — device_id 로 식별, 투표 시 입력한 email 을 닉네임(마스킹 표시)으로 사용. 테이블은 범용이고 노출 경기는 프론트가 제한한다. - 최신순 조회(created_at DESC) + limit/offset 페이징("더보기" 인라인 펼침). - is_hidden=True 는 관리자/신고 숨김 — 조회에서 제외. """ __tablename__ = "comments" id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) match_id: Mapped[str] = mapped_column( ForeignKey("matches.match_id", ondelete="CASCADE"), index=True ) device_id: Mapped[str] = mapped_column(String, index=True) # 비로그인 식별 + 쿨다운 기준 email: Mapped[str] = mapped_column(String) # 소문자 정규화 — 마스킹해 표시 body: Mapped[str] = mapped_column(String) # 길이 제한은 스키마(200자)에서 is_hidden: Mapped[bool] = mapped_column(Boolean, default=False) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), index=True ) class FootballCache(Base): """축구 데이터 캐시 (API-Football 수집 결과) — 예측 프롬프트 조립용. key 규칙: team:{CODE} 팀 베이스라인(폼·평균득실·클린시트·스쿼드) h2h:{A}-{B} 상대전적 teamid:{CODE} 팀코드→API팀ID 매핑 payload 는 가공된 압축 JSON. fetched_at 으로 캐시 신선도(TTL) 판단. api·worker 가 공유하는 유일한 영속 저장소가 DB 라 여기에 둔다. """ __tablename__ = "football_cache" key: Mapped[str] = mapped_column(String, primary_key=True) payload: Mapped[dict] = mapped_column(JSON, default=dict) fetched_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now() )