o2o-triple-pick/backend/app/models.py
jwkim 277378477a 오늘의 응원가 자동 생성 파이프라인 (Suno)
- 경기 맥락(전날 결과·순위·연전 차수·선발투수) 조립 → LLM 작사
  (콜앤리스폰스·섹션 태그 형식) → Suno(sunoapi.org) 생성 → songs 테이블
- 워커: 킥오프 150분 전 윈도우 진입 시 생성 시작 + 2분 주기 폴링
- API: GET /api/songs/today · POST /api/songs/callback(싱크대)
  · POST /api/songs/generate(관리자 강제 생성)
- 프론트: MusicBar 가 경기 상세·메인에서 오늘의 응원가를 동적 로드
  (정적 플레이리스트는 폴백 유지)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 09:33:34 +09:00

357 lines
15 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,
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)가 생성.
audio/image URL 은 제공자 CDN 을 그대로 사용(당일 소비 콘텐츠라 다운로드 불필요).
"""
__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) # 실패 재시도 상한용
# 완성 트랙 [{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 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()
)