o2o-triple-pick/backend/app/models.py
jwkim fdb04a2ccc 응원가 영구 보존·종료 경기 노출 — 음원 DB 저장 + 경기별 조회 API
- Suno CDN 임시 URL 만료 대비: 완성 곡 첫 트랙을 워커가 다운로드해
  song_audio(bytea)로 보존하고 audioUrl을 /api/songs/audio/{id}/{idx}로 재작성
  (틱당 5곡, 실패 3회 상한 — 기존 곡도 자동 백필)
- GET /api/songs/match/{match_id} 추가 — 날짜 무관 조회로 종료된 경기
  상세에서도 '응원가 듣기' 버튼 노출
- 프론트 getTeamSongTracks를 오늘 전체 캐시 → 경기별 조회·캐시로 전환
- 음원 서빙은 Range(206) 지원, immutable 캐시 + task_id 캐시버스터

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 15:39:16 +09:00

387 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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()
)