"""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, BigInteger, Boolean, Date, DateTime, ForeignKey, Integer, LargeBinary, 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) # 리그: wc(월드컵 축구) | kbo | mlb — 멀티리그 단일 서비스의 분기 키 league: Mapped[str] = mapped_column(String, default="wc", index=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): """경기별 한마디(댓글). 완전 익명 — 인증/이메일 없음. - author_hash: sha256(device_id). 원본 기기ID는 저장하지 않음(추적 불가). 쿨다운 식별용. - nickname: 축구+코믹 한국어 5글자(기기 해시로 자동 배정, 기기당 고정). - id(PK)·author_hash 는 내부용으로 API 응답에 노출하지 않음. - 최신순 조회(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 ) author_hash: Mapped[str] = mapped_column(String, index=True) # sha256(device_id) — 원본 비저장 nickname: Mapped[str] = mapped_column(String) # 축구 코믹 한국어 5글자 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() ) class User(Base): """카카오 소셜 로그인 사용자 (o2o-castad-backend 인증 이식). 투표(user_predictions)는 기존 이메일 식별을 유지하고, 로그인 시 카카오 이메일을 투표 이메일로 자동 사용해 연결한다. """ __tablename__ = "users" id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) kakao_id: Mapped[int] = mapped_column(BigInteger, unique=True, index=True) user_uuid: Mapped[str] = mapped_column(String, unique=True, index=True) email: Mapped[str | None] = mapped_column(String, nullable=True) nickname: Mapped[str | None] = mapped_column(String, nullable=True) profile_image_url: Mapped[str | None] = mapped_column(String, nullable=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True) last_login_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now() ) class RefreshToken(Base): """리프레시 토큰 (해시 저장·회전 시 폐기). castad 와 동일한 rotation 방식.""" __tablename__ = "refresh_tokens" id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) user_id: Mapped[int] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), index=True ) user_uuid: Mapped[str] = mapped_column(String, index=True) token_hash: Mapped[str] = mapped_column(String, unique=True, index=True) expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) is_revoked: Mapped[bool] = mapped_column(Boolean, default=False) revoked_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) user_agent: Mapped[str | None] = mapped_column(String, nullable=True) ip_address: Mapped[str | None] = mapped_column(String, nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now() ) class Song(Base): """오늘의 응원가 — 경기×팀 단위 Suno 생성 트랙. 가사·스타일은 LLM(경기 맥락 주입)이 쓰고, 음원은 Suno(sunoapi.org)가 생성. Suno CDN URL 은 임시라 완성 후 워커가 음원을 SongAudio 로 내려받아 보존하고 tracks 의 audioUrl 을 자체 서빙 경로(/api/songs/audio/...)로 바꾼다. """ __tablename__ = "songs" __table_args__ = ( UniqueConstraint("match_id", "team_code", name="uq_song_match_team"), ) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) league: Mapped[str] = mapped_column(String, index=True) date_kst: Mapped[date] = mapped_column(Date, index=True) # 경기일 (KST) match_id: Mapped[str] = mapped_column( ForeignKey("matches.match_id", ondelete="CASCADE"), index=True ) team_code: Mapped[str] = mapped_column(String) team_name: Mapped[str] = mapped_column(String) title: Mapped[str] = mapped_column(String, default="") lyrics: Mapped[str] = mapped_column(String, default="") style: Mapped[str] = mapped_column(String, default="") task_id: Mapped[str] = mapped_column(String, default="") # generating(Suno 작업 대기) | complete | failed status: Mapped[str] = mapped_column(String, default="generating", index=True) error: Mapped[str] = mapped_column(String, default="") attempts: Mapped[int] = mapped_column(Integer, default=0) # 실패 재시도 상한용 # 라인업 발표 후 생성했는지 — False 면 라인업 공개 시 자동 재생성(업그레이드) 대상 with_lineup: Mapped[bool] = mapped_column(Boolean, default=False) # 완성 트랙 [{title, audioUrl, imageUrl, duration}] — 보통 생성당 2곡 tracks: Mapped[list] = mapped_column(JSON, default=list) 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 SongAudio(Base): """응원가 음원 원본 — Suno CDN 임시 URL 만료 대비 DB 보존. 노출 트랙(첫 트랙)만 저장한다. 업그레이드(라인업 반영 재생성) 시 같은 (song_id, track_idx) 행을 새 음원으로 교체. """ __tablename__ = "song_audio" __table_args__ = ( UniqueConstraint("song_id", "track_idx", name="uq_song_audio_track"), ) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) song_id: Mapped[int] = mapped_column( ForeignKey("songs.id", ondelete="CASCADE"), index=True ) track_idx: Mapped[int] = mapped_column(Integer, default=0) mime: Mapped[str] = mapped_column(String, default="audio/mpeg") size: Mapped[int] = mapped_column(Integer, default=0) data: Mapped[bytes] = mapped_column(LargeBinary) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now() ) class DataCache(Base): """야구(KBO/MLB) 부가 데이터 캐시 — 프리뷰·순위, API 응답·AI 프롬프트 조립용. key 규칙: preview:{match_id} 경기 프리뷰 요약(선발투수·시즌 상대전적) standings:{league} 리그 순위표 (팀코드 → 순위·승률·최근5 등) """ __tablename__ = "data_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() )