o2o-triple-pick/backend/app/models.py
jwkim 7705099c64 feat(auth): 카카오 로그인 — o2o-castad-backend 인증 스택 이식
백엔드 (castad 와 동일 흐름, triplepick 스타일로 재작성):
- users/refresh_tokens 테이블 (신규 — create_all 로 자동 생성)
- 카카오 OAuth (code → 토큰 → 사용자 정보), aiohttp 대신 httpx
- JWT HS256 access 60분 / refresh 7일, Refresh Token Rotation
  (재사용 감지 시 TOKEN_REVOKED, castad 동일 에러 코드 체계)
- /api/auth/{kakao/login, kakao/callback, refresh, logout, me}
- 콜백 → 프론트 리다이렉트는 쿼리 대신 해시 프래그먼트로 토큰 전달
  (서버 로그·Referer 에 토큰 노출 방지 — castad 방식에서 한 단계 보강)
- 크레딧·소셜계정 연동 등 castad 전용 개념은 제외

프론트:
- lib/auth.ts — 토큰 보관·자동 갱신·/me 캐시·useAuth 훅
- Hero 에 카카오 로그인 버튼(브랜드 노랑) / 프로필 칩 + 로그아웃
- 투표 폼(Arena) 이메일을 로그인 계정 이메일로 자동 채움
  (기존 이메일 기반 투표·리더보드 파이프라인은 그대로 유지)

설정: KAKAO_CLIENT_ID/SECRET/REDIRECT_URI, JWT_SECRET (.env.example 참고)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 15:42:54 +09:00

316 lines
13 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 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()
)