diff --git a/backend/app/config.py b/backend/app/config.py index 1be7c3c..0c2c1a6 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -87,12 +87,30 @@ class Settings(BaseSettings): return "football-data" return self.schedule_source.lower() + # ── 멀티리그 (wc=월드컵 축구 · kbo · mlb) ──────────────── + # 활성 리그 (콤마구분). 야구 리그는 워커가 각자 소스에서 일정·결과를 동기화. + leagues: str = "wc,kbo,mlb" + # 야구 일정 수집 윈도우 — 오늘 기준 미래 며칠치. + # (투표 오픈·AI 예측 생성은 리그 공통: vote_open_hours_before / ai_generate_lookahead_hours) + baseball_days_ahead: int = 7 + # 네이버 스포츠 비공식 API (KBO 일정·결과·프리뷰·문자중계) — 비공식, 로컬용. + naver_api_base: str = "https://api-gw.sports.naver.com" + # MLB 공식 Stats API (키 불필요). + mlb_api_base: str = "https://statsapi.mlb.com/api" + + @property + def league_list(self) -> list[str]: + return [x.strip() for x in self.leagues.split(",") if x.strip()] + + # AI 예측 생성 전체 스위치 — False 면 워커가 AI 호출을 전혀 하지 않음(로컬 테스트). + ai_enabled: bool = True # 매일 AI 예측 생성 시각 (KST). 기능정의서: 매일 0시 1회 생성. ai_generate_hour: int = 0 ai_generate_minute: int = 5 # 투표 오픈(D-2) 선행 생성 시간(h). 1일 1회 생성이므로 다음 주기 전 오픈할 # 경기를 미리 채우려면 ~24h 이상 필요. 30h = 하루치 + 여유. (only_missing 이라 총 호출 수 동일) - ai_generate_lookahead_hours: int = 30 + # 킥오프 N시간 전부터 생성 — 야구 선발투수 예고(경기 전날 저녁) 이후 시점. + ai_generate_lookahead_hours: int = 22 # ── 결과 자동 정산(관리자 입력 불필요) ─────────────────── # 킥오프 + 이 시간(시) 경과 후, 외부 소스에서 스코어를 자동 수집해 채점·집계·메일. diff --git a/backend/app/domain.py b/backend/app/domain.py index eb538d4..f742136 100644 --- a/backend/app/domain.py +++ b/backend/app/domain.py @@ -42,6 +42,7 @@ def kst_iso(dt: datetime) -> str: def compute_phase(m: Match, now: datetime | None = None) -> str: """투표/경기 단계: + cancelled 우천취소 등 취소 확정 → "취소" (투표·정산 제외, 기록은 보존) finished 결과 입력됨 → "종료" live 킥오프 이후, 결과 입력 전 → "경기중" locked 투표 마감(킥오프 1h 전) ~ 킥오프 → "투표 종료"(비활성) @@ -49,6 +50,8 @@ def compute_phase(m: Match, now: datetime | None = None) -> str: open 투표 중 """ now = now or now_utc() + if m.status == "cancelled": # 우천취소 등 — 시간과 무관하게 고정 + return "cancelled" if m.result_outcome is not None: return "finished" if now >= ensure_aware(m.kickoff_at): @@ -71,6 +74,8 @@ def is_open_for_voting(m: Match, now: datetime | None = None) -> bool: now = now or now_utc() if not is_votable(m): return False + if m.status == "cancelled": + return False if m.result_outcome is not None: return False if now >= ensure_aware(m.lock_at): @@ -146,6 +151,7 @@ def match_out( lang: str = "ko", include_predictions: bool = True, now: datetime | None = None, + extras: dict | None = None, ) -> MatchOut: result = None if m.result_outcome is not None: @@ -164,6 +170,7 @@ def match_out( phase = compute_phase(m, now) return MatchOut( matchId=m.match_id, + league=m.league or "wc", roundLabel=m.round_label, group=m.group, teamA=_team_a(m), @@ -180,4 +187,5 @@ def match_out( result=result, predictions=preds, crowd=crowd_out(m.crowd, m.match_id), + extras=extras, ) diff --git a/backend/app/main.py b/backend/app/main.py index abb8bc9..1e03abe 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -20,6 +20,7 @@ from .routers import ( matches, predictions, share, + standings, visits, ) from .scoring import load_scoring_data @@ -52,6 +53,7 @@ app.include_router(matches.router) app.include_router(comments.router) app.include_router(predictions.router) app.include_router(leaderboard.router) +app.include_router(standings.router) app.include_router(admin.router) app.include_router(visits.router) # 공유 미리보기(OG) 프리렌더 — nginx 가 크롤러 UA 의 /match/:id 만 여기로 보낸다. diff --git a/backend/app/models.py b/backend/app/models.py index 8373ae6..70018e2 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -35,6 +35,8 @@ 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") @@ -246,3 +248,20 @@ class FootballCache(Base): fetched_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() + ) diff --git a/backend/app/routers/comments.py b/backend/app/routers/comments.py index 7256204..f25bc41 100644 --- a/backend/app/routers/comments.py +++ b/backend/app/routers/comments.py @@ -28,8 +28,8 @@ router = APIRouter(prefix="/api/matches", tags=["comments"]) # 같은 기기 연속 작성 최소 간격(초) — 도배 방지(기본 방어). COMMENT_COOLDOWN_SECONDS = 5 -# 닉네임 조합: 2글자(코믹 수식) + 3글자(축구 역할) = 항상 한국어 5글자. -# 40 × 30 = 1200가지. +# 닉네임 조합: 2글자(코믹 수식) + 3글자(스포츠 역할) = 항상 한국어 5글자. +# 리그별 풀 — 축구(월드컵) / 야구(KBO·MLB). 각 40 × 30 = 1200가지. _NICK_A = [ "잔디", "침대", "벤치", "후보", "똥손", "헛발", "발컨", "노룩", "왼발", "멘붕", "국대", "동네", "주말", "폭발", "광속", "진지", "발끝", "번개", "백수", "천재", @@ -42,6 +42,18 @@ _NICK_B = [ "압박왕", "태클왕", "중거리", "발리슛", "빌드업", "백패스", "자책골", "골기계", "어시왕", "역습왕", "수문장", "골부자", "골가뭄", "발연기", ] +_NICK_A_BB = [ + "잔디", "벤치", "후보", "똥손", "노룩", "멘붕", "동네", "주말", "폭발", "광속", + "진지", "번개", "백수", "천재", "괴물", "폭격", "강철", "무적", "질풍", "돌풍", + "불꽃", "분노", "음속", "폭탄", "슈퍼", "만년", "전설", "비밀", "미친", "라면", + "치킨", "출근", "직관", "치맥", "응원", "국대", "신인", "은퇴", "각성", "꾸준", +] +_NICK_B_BB = [ + "홈런왕", "도루왕", "타격왕", "안타왕", "삼진왕", "수비왕", "번트왕", "대타왕", + "역전타", "끝내기", "결승타", "병살타", "유격수", "외야수", "내야수", "마무리", + "셋업맨", "불펜왕", "승리조", "강속구", "커브왕", "직구왕", "변화구", "풀스윙", + "헛스윙", "배트맨", "만루왕", "출루왕", "도루자", "홈스틸", +] def _author_hash(device_id: str) -> str: @@ -49,12 +61,19 @@ def _author_hash(device_id: str) -> str: return hashlib.sha256(device_id.encode("utf-8")).hexdigest() -_NICK_A_SET = set(_NICK_A) -_NICK_B_SET = set(_NICK_B) +# 검증은 두 리그 풀 합집합 기준(발급 리그와 작성 리그가 달라도 정상 닉이면 통과) +_NICK_A_SET = set(_NICK_A) | set(_NICK_A_BB) +_NICK_B_SET = set(_NICK_B) | set(_NICK_B_BB) -def _random_nickname() -> str: - """랜덤 닉네임. 두 단어 조합으로 항상 5글자.""" +def _is_baseball(match_id: str) -> bool: + return match_id.startswith(("KBO_", "MLB_")) + + +def _random_nickname(baseball: bool = False) -> str: + """랜덤 닉네임. 두 단어 조합으로 항상 5글자 — 리그에 맞는 풀 사용.""" + if baseball: + return random.choice(_NICK_A_BB) + random.choice(_NICK_B_BB) return random.choice(_NICK_A) + random.choice(_NICK_B) @@ -67,7 +86,7 @@ def _valid_nickname(n: str) -> bool: @router.get("/{match_id}/comments/nickname", response_model=CommentNicknameOut) async def issue_nickname(match_id: str) -> CommentNicknameOut: """세션 시작 시 랜덤 닉 발급(클라가 sessionStorage 에 캐싱해 재사용).""" - return CommentNicknameOut(nickname=_random_nickname()) + return CommentNicknameOut(nickname=_random_nickname(_is_baseball(match_id))) def _out(c: Comment) -> CommentOut: @@ -130,7 +149,11 @@ async def create_comment( raise HTTPException(status_code=429, detail="COMMENT_COOLDOWN") # 세션 캐싱된 닉을 사용하되, 풀에 없는(조작된) 값이면 새 랜덤으로 대체 - nickname = body.nickname if _valid_nickname(body.nickname) else _random_nickname() + nickname = ( + body.nickname + if _valid_nickname(body.nickname) + else _random_nickname(_is_baseball(match_id)) + ) comment = Comment( match_id=match_id, author_hash=author_hash, diff --git a/backend/app/routers/leaderboard.py b/backend/app/routers/leaderboard.py index 3490622..b2a2900 100644 --- a/backend/app/routers/leaderboard.py +++ b/backend/app/routers/leaderboard.py @@ -12,7 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from ..database import get_db -from ..models import Match, UserPoints +from ..models import Match, UserPoints, UserPrediction from ..scoring import is_excluded, score_prediction from ..schemas import ( AILeaderboardOut, @@ -32,8 +32,52 @@ def _mask(email: str) -> str: @router.get("", response_model=LeaderboardOut) async def leaderboard( - limit: int = Query(50, ge=1, le=200), db: AsyncSession = Depends(get_db) + limit: int = Query(50, ge=1, le=200), + league: str = Query("", description="wc | kbo | mlb — 빈값이면 전체 합산"), + db: AsyncSession = Depends(get_db), ) -> LeaderboardOut: + if league: + # 리그별 랭킹 — 채점된 픽(points)을 리그 경기로 한정해 이메일별 재집계. + picks = ( + await db.execute( + select(UserPrediction, Match) + .join(Match, Match.match_id == UserPrediction.match_id) + .where( + Match.league == league, + Match.result_outcome.is_not(None), + UserPrediction.points.is_not(None), + UserPrediction.email.is_not(None), + ) + ) + ).all() + agg: dict[str, list] = {} # email → [pts, exact, played, first_ts] + for pk, m in picks: + row = agg.setdefault(pk.email, [0, 0, 0, None]) + row[0] += pk.points or 0 + row[1] += 1 if ( + pk.score_a == m.result_score_a and pk.score_b == m.result_score_b + ) else 0 + row[2] += 1 + ranked_rows = sorted( + ((e, v) for e, v in agg.items() if not is_excluded(e)), + key=lambda kv: (-kv[1][0], -kv[1][1], -kv[1][2]), + ) + scored_matches = ( + await db.execute( + select(func.count()).select_from(Match).where( + Match.league == league, Match.result_outcome.is_not(None) + ) + ) + ).scalar() or 0 + standings = [ + StandingOut( + rank=i + 1, emailMasked=_mask(e), + totalPoints=v[0], exactCount=v[1], matchesPlayed=v[2], + ) + for i, (e, v) in enumerate(ranked_rows[:limit]) + ] + return LeaderboardOut(standings=standings, scoredMatches=scored_matches) + rows = ( await db.execute(select(UserPoints)) ).scalars().all() @@ -71,25 +115,32 @@ async def leaderboard( @router.get("/ai", response_model=AILeaderboardOut) -async def ai_leaderboard(db: AsyncSession = Depends(get_db)) -> AILeaderboardOut: +async def ai_leaderboard( + league: str = Query("", description="wc | kbo | mlb — 빈값이면 전체"), + db: AsyncSession = Depends(get_db), +) -> AILeaderboardOut: """AI 모델 누적 랭킹 — 종료된 경기의 AI 예측을 유저와 동일한 배점으로 채점·합산. 별도 누적 테이블 없이 매 조회 시 종료 경기 전수 재계산 → 결과/배점 변동에 항상 일관. (모델 3개 × 경기 수라 비용 무시 가능.) """ - matches = ( - await db.execute( - select(Match) - .where(Match.result_outcome.is_not(None)) - .options(selectinload(Match.predictions)) - ) - ).scalars().all() + q = ( + select(Match) + .where(Match.result_outcome.is_not(None)) + .options(selectinload(Match.predictions)) + ) + if league: + q = q.where(Match.league == league) + matches = (await db.execute(q)).scalars().all() # model → [points, exact_count, matches_played] agg: dict[str, list[int]] = {} for m in matches: + bb = m.league in ("kbo", "mlb") for p in m.predictions: - pts = score_prediction(p.score_a, p.score_b, m.result_score_a, m.result_score_b) + pts = score_prediction( + p.score_a, p.score_b, m.result_score_a, m.result_score_b, baseball=bb + ) row = agg.setdefault(p.model, [0, 0, 0]) row[0] += pts row[1] += 1 if (p.score_a == m.result_score_a and p.score_b == m.result_score_b) else 0 diff --git a/backend/app/routers/matches.py b/backend/app/routers/matches.py index 21b460e..12bb81d 100644 --- a/backend/app/routers/matches.py +++ b/backend/app/routers/matches.py @@ -10,6 +10,7 @@ from ..database import get_db from ..domain import match_out from ..models import Match from ..schemas import MatchOut +from ..services.baseball_details import fetch_live, get_extras router = APIRouter(prefix="/api/matches", tags=["matches"]) @@ -17,16 +18,19 @@ router = APIRouter(prefix="/api/matches", tags=["matches"]) @router.get("", response_model=list[MatchOut]) async def list_matches( lang: str = Query("ko"), + league: str = Query("", description="wc | kbo | mlb — 빈값이면 전체"), db: AsyncSession = Depends(get_db), ) -> list[MatchOut]: - rows = ( - await db.execute( - select(Match) - .options(selectinload(Match.predictions), selectinload(Match.crowd)) - .order_by(Match.kickoff_at) - ) - ).scalars().all() - return [match_out(m, lang) for m in rows] + q = ( + select(Match) + .options(selectinload(Match.predictions), selectinload(Match.crowd)) + .order_by(Match.kickoff_at) + ) + if league: + q = q.where(Match.league == league) + rows = (await db.execute(q)).scalars().all() + extras = await get_extras(db, rows) + return [match_out(m, lang, extras=extras.get(m.match_id)) for m in rows] @router.get("/{match_id}", response_model=MatchOut) @@ -44,4 +48,19 @@ async def get_match( ).scalars().first() if not m: raise HTTPException(status_code=404, detail="MATCH_NOT_FOUND") - return match_out(m, lang) + extras = await get_extras(db, [m]) + return match_out(m, lang, extras=extras.get(m.match_id)) + + +@router.get("/{match_id}/live") +async def get_live( + match_id: str, + db: AsyncSession = Depends(get_db), +) -> dict: + """야구 라이브 필드 뷰 (kbo=네이버 relay, mlb=공식 feed/live). 15초 TTL 캐시.""" + m = await db.get(Match, match_id) + if not m: + raise HTTPException(status_code=404, detail="MATCH_NOT_FOUND") + if m.league not in ("kbo", "mlb"): + return {"available": False} + return await fetch_live(m) diff --git a/backend/app/routers/standings.py b/backend/app/routers/standings.py new file mode 100644 index 0000000..b56ad07 --- /dev/null +++ b/backend/app/routers/standings.py @@ -0,0 +1,53 @@ +"""리그 순위표 API — 워커가 캐싱한 standings:{league} 를 팀 정보와 합쳐 서빙. + +KBO: 단일 테이블(10팀, 순위순). MLB: 디비전(AL/NL × 동·중·서) 6그룹. +캐시가 아직 없으면 빈 groups 를 반환한다(프론트는 안내 문구 표시). +""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from ..database import get_db +from ..models import DataCache +from ..teams_baseball import team_info + +router = APIRouter(prefix="/api/standings", tags=["standings"]) + +# MLB 디비전 표시 순서 (AL 동→중→서, NL 동→중→서) +_MLB_DIV_ORDER = ["ALE", "ALC", "ALW", "NLE", "NLC", "NLW"] + + +@router.get("") +async def get_standings( + league: str = Query(..., description="kbo | mlb"), + db: AsyncSession = Depends(get_db), +) -> dict: + if league not in ("kbo", "mlb"): + return {"league": league, "updatedAt": None, "groups": []} + row = await db.get(DataCache, f"standings:{league}") + table: dict = row.payload if row else {} + rows = [{**team_info(league, code), **st} for code, st in table.items()] + + if league == "kbo": + rows.sort(key=lambda r: r.get("rank") or 99) + groups = [{"key": None, "rows": rows}] if rows else [] + else: + by_div: dict[str, list] = {} + for r in rows: + by_div.setdefault(r.get("div") or "", []).append(r) + for lst in by_div.values(): + lst.sort(key=lambda r: r.get("rank") or 99) + groups = [ + {"key": d, "rows": by_div[d]} for d in _MLB_DIV_ORDER if d in by_div + ] + if not groups and rows: + # 캐시가 div 주입 이전 버전이면 전체 승률순 단일 그룹으로 폴백 + rows.sort(key=lambda r: -float(r.get("wra") or 0)) + groups = [{"key": None, "rows": rows}] + + return { + "league": league, + "updatedAt": row.fetched_at.isoformat() if row and row.fetched_at else None, + "groups": groups, + } diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 874ec50..3299795 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -47,6 +47,7 @@ class CrowdStatsOut(BaseModel): class MatchOut(BaseModel): matchId: str + league: str = "wc" # wc | kbo | mlb roundLabel: str group: str teamA: Team @@ -63,6 +64,8 @@ class MatchOut(BaseModel): result: MatchResult | None = None predictions: list[AIPredictionOut] = Field(default_factory=list) crowd: CrowdStatsOut | None = None + # 야구 부가정보 (프리뷰·순위 캐시 — 축구/캐시없음이면 None) + extras: dict | None = None # ── 픽 제출 ───────────────────────────────────────────────── @@ -70,8 +73,8 @@ class SubmitPredictionIn(BaseModel): matchId: str deviceId: str = Field(min_length=8, max_length=64) outcome: Outcome - scoreA: int = Field(ge=0, le=9) - scoreB: int = Field(ge=0, le=9) + scoreA: int = Field(ge=0, le=25) # 야구(득점) 상한. 축구는 UI 가 0-9 로 제한. + scoreB: int = Field(ge=0, le=25) email: EmailStr | None = None notify: bool = False diff --git a/backend/app/scoring.py b/backend/app/scoring.py index b3b526e..05af390 100644 --- a/backend/app/scoring.py +++ b/backend/app/scoring.py @@ -2,6 +2,8 @@ 배점: 정확 스코어 5 · 근접(승패+득실차) 3 · 승패 2 · 부분(한 팀 득점 일치) 1 · 빗나감 0. 단조 증가(정확>근접>승패>부분>빗나감). +야구(KBO/MLB)는 득점 범위가 넓어 같은 등급 체계에 판정만 완화한다: +근접 = 승패 + 득실차 오차 ±1, 부분 = 한 팀 득점 오차 ±1. 배점·배제 대상은 backend/data/scoring.json 에서 기동 시 로드(없으면 아래 기본값). """ from __future__ import annotations @@ -24,19 +26,31 @@ def outcome_of(score_a: int, score_b: int) -> str: return "DRAW" +# 야구 판정 허용 오차 — 근접(득실차)·부분(한 팀 득점) 공통 ±1 +BASEBALL_TOLERANCE = 1 + + +def _grade_of( + pick_a: int, pick_b: int, result_a: int, result_b: int, baseball: bool = False +) -> str: + """등급 판정 (단일 SSOT) — baseball 이면 근접/부분 오차 ±1 허용.""" + tol = BASEBALL_TOLERANCE if baseball else 0 + if pick_a == result_a and pick_b == result_b: + return "EXACT" + if outcome_of(pick_a, pick_b) == outcome_of(result_a, result_b): + if abs((pick_a - pick_b) - (result_a - result_b)) <= tol: + return "CLOSE" + return "OUTCOME" + if abs(pick_a - result_a) <= tol or abs(pick_b - result_b) <= tol: + return "PARTIAL" + return "MISS" + + def score_prediction( - pick_a: int, pick_b: int, result_a: int, result_b: int + pick_a: int, pick_b: int, result_a: int, result_b: int, baseball: bool = False ) -> int: """내 예측 vs 실제 결과 → 적중 포인트 (결정론적).""" - if pick_a == result_a and pick_b == result_b: - return SCORE["EXACT"] - if outcome_of(pick_a, pick_b) == outcome_of(result_a, result_b): - if (pick_a - pick_b) == (result_a - result_b): - return SCORE["CLOSE"] - return SCORE["OUTCOME"] - if pick_a == result_a or pick_b == result_b: - return SCORE["PARTIAL"] - return SCORE["MISS"] + return SCORE[_grade_of(pick_a, pick_b, result_a, result_b, baseball)] # ── 자격 · 배제 (docs/SCORING.md §4, P1 구현) ────────────────── @@ -65,11 +79,12 @@ _JSON_KEY = { def grade_prediction( pick_a: int, pick_b: int, result_a: int, result_b: int, path: Path | None = None, + baseball: bool = False, ) -> tuple[str, int]: """유저 투표(픽) vs 실제 결과 → scoring.json 에서 해당 등급의 (key, value). - score_prediction 과 같은 판정 기준. 예: 승패+득실차 일치 → ("score_close", 3). - scoring.json 이 없거나 키가 빠지면 기본 SCORE 값으로 대체. + score_prediction 과 같은 판정 기준(_grade_of 공유). 예: 승패+득실차 일치 + → ("score_close", 3). scoring.json 이 없거나 키가 빠지면 기본 SCORE 값으로 대체. """ p = path or DATA_FILE try: @@ -78,15 +93,7 @@ def grade_prediction( log.warning("scoring data 읽기 실패 (%s): %s — 기본 배점 사용", p, e) data = {} - if pick_a == result_a and pick_b == result_b: - grade = "EXACT" - elif outcome_of(pick_a, pick_b) == outcome_of(result_a, result_b): - grade = "CLOSE" if (pick_a - pick_b) == (result_a - result_b) else "OUTCOME" - elif pick_a == result_a or pick_b == result_b: - grade = "PARTIAL" - else: - grade = "MISS" - + grade = _grade_of(pick_a, pick_b, result_a, result_b, baseball) key = _JSON_KEY[grade] value = data[key] if isinstance(data.get(key), int) else SCORE[grade] return key, value diff --git a/backend/app/services/ai.py b/backend/app/services/ai.py index d748b00..c779ea8 100644 --- a/backend/app/services/ai.py +++ b/backend/app/services/ai.py @@ -26,11 +26,12 @@ class ProviderUnavailable(RuntimeError): @dataclass class MatchContext: - team_a: str # 표시명 (예: Korea Republic) + team_a: str # 표시명 (예: Korea Republic / 한화 이글스) team_b: str venue: str kickoff: str # ISO - data_block: str | None = None # 실데이터(폼·H2H·랭킹·WC결과) 주입 블록. 없으면 이름만. + data_block: str | None = None # 실데이터(폼·H2H·랭킹 등) 주입 블록. 없으면 이름만. + league: str = "wc" # wc(축구) | kbo | mlb — 프롬프트·스코어 범위 분기 # 모델별 분석 관점(페르소나) — 동일 경기라도 서로 다른 시각으로 보게 해 @@ -54,6 +55,61 @@ PERSONA = { } +# 야구용 페르소나 — 축구 페르소나와 같은 3분화(데이터/수비·투수/공격) 구도. +BASEBALL_PERSONA = { + "GPT": ( + "You are a DATA-DRIVEN baseball analyst. Base your call on recent form, " + "season standings, head-to-head record and run-scored/allowed trends. " + "Be objective and evidence-led; pick the scoreline the numbers most support." + ), + "Claude": ( + "You are a PITCHING-AND-DEFENSE analyst. Weigh starting rotation strength, " + "bullpen fatigue, and defensive quality. Take low-scoring games, tight " + "margins and genuine upset potential seriously — do not just rubber-stamp " + "the favorite." + ), + "Gemini": ( + "You are an OFFENSE-MINDED analyst. Weigh lineup depth, power hitting, " + "momentum and ballpark factors. Lean toward open, higher-scoring scenarios " + "when the bats and conditions justify it." + ), +} + +_LEAGUE_LABEL = { + "kbo": "2026 KBO League (Korean professional baseball) regular-season game", + "mlb": "2026 MLB (Major League Baseball) regular-season game", +} + + +def _prompt_baseball(ctx: MatchContext, model: str) -> str: + persona = BASEBALL_PERSONA[model] + data = f"\n{ctx.data_block}\n" if ctx.data_block else "" + draw_note = ( + "KBO regular-season games can end in a DRAW after 12 innings, but draws " + "are rare (~1-2% of games); only predict a draw with strong reason.\n" + if ctx.league == "kbo" + else "MLB games cannot end in a draw — never predict DRAW.\n" + ) + return ( + f"{persona}\n" + f"Predict the result of this {_LEAGUE_LABEL[ctx.league]} using YOUR " + f"perspective above. Judge independently — it is fine to differ from the " + f"obvious consensus pick when your perspective warrants it.\n" + f"Team A (away): {ctx.team_a}\nTeam B (home): {ctx.team_b}\n" + f"Ballpark: {ctx.venue}\nFirst pitch: {ctx.kickoff}\n" + f"{data}" + f"Important: Team B is the HOME team — home advantage applies. {draw_note}\n" + f"Predict the final score in runs. " + f"Respond with a single JSON object and nothing else, with keys:\n" + f' "scoreA": integer 0-25 (Team A runs),\n' + f' "scoreB": integer 0-25 (Team B runs),\n' + f' "outcome": one of "TEAM_A_WIN" | "DRAW" | "TEAM_B_WIN" (must match the score),\n' + f' "confidencePct": integer 0-100,\n' + f' "reasonKo": a short one-line rationale in Korean (max ~30 chars),\n' + f' "reasonEn": a short one-line rationale in English (max ~60 chars).\n' + ) + + def _prompt(ctx: MatchContext, persona: str) -> str: # 실데이터 블록이 있으면 팀/킥오프 다음에 삽입(없으면 빈 문자열 — 기존 동작 동일). data = f"\n{ctx.data_block}\n" if ctx.data_block else "" @@ -80,6 +136,13 @@ def _prompt(ctx: MatchContext, persona: str) -> str: ) +def _build_prompt(ctx: MatchContext, model: str) -> str: + """리그별 프롬프트 선택 — 야구(kbo/mlb)는 야구 프롬프트, 그 외 축구.""" + if ctx.league in ("kbo", "mlb"): + return _prompt_baseball(ctx, model) + return _prompt(ctx, PERSONA[model]) + + # JSON Schema (구조화 출력용 — Anthropic/OpenAI 공통) _SCHEMA = { "type": "object", @@ -96,9 +159,9 @@ _SCHEMA = { } -def _normalize(data: dict) -> dict: - a = max(0, min(9, int(data["scoreA"]))) - b = max(0, min(9, int(data["scoreB"]))) +def _normalize(data: dict, max_score: int = 9) -> dict: + a = max(0, min(max_score, int(data["scoreA"]))) + b = max(0, min(max_score, int(data["scoreB"]))) # outcome 은 스코어와 일관되도록 서버에서 재도출 (모델 불일치 방지) outcome = "TEAM_A_WIN" if a > b else "TEAM_B_WIN" if a < b else "DRAW" conf = max(0, min(100, int(data.get("confidencePct", 50)))) @@ -123,12 +186,12 @@ async def predict_gpt(ctx: MatchContext) -> dict: model=settings.openai_model, messages=[ {"role": "system", "content": "You output only valid JSON."}, - {"role": "user", "content": _prompt(ctx, PERSONA["GPT"])}, + {"role": "user", "content": _build_prompt(ctx, "GPT")}, ], response_format={"type": "json_object"}, ) content = resp.choices[0].message.content or "{}" - return _normalize(json.loads(content)) + return _normalize(json.loads(content), 25 if ctx.league in ("kbo", "mlb") else 9) # ── Claude (Anthropic) ────────────────────────────────────── @@ -143,7 +206,7 @@ async def predict_claude(ctx: MatchContext) -> dict: return await client.messages.create( model=settings.anthropic_model, max_tokens=1024, - messages=[{"role": "user", "content": _prompt(ctx, PERSONA["Claude"])}], + messages=[{"role": "user", "content": _build_prompt(ctx, "Claude")}], **extra, ) @@ -157,7 +220,7 @@ async def predict_claude(ctx: MatchContext) -> dict: except TypeError: msg = await _create() text = "".join(b.text for b in msg.content if getattr(b, "type", "") == "text") - return _normalize(json.loads(text)) + return _normalize(json.loads(text), 25 if ctx.league in ("kbo", "mlb") else 9) # ── Gemini (Google) ───────────────────────────────────────── @@ -170,10 +233,10 @@ async def predict_gemini(ctx: MatchContext) -> dict: client = genai.Client(api_key=settings.google_api_key) resp = await client.aio.models.generate_content( model=settings.google_model, - contents=_prompt(ctx, PERSONA["Gemini"]), + contents=_build_prompt(ctx, "Gemini"), config=types.GenerateContentConfig(response_mime_type="application/json"), ) - return _normalize(json.loads(resp.text or "{}")) + return _normalize(json.loads(resp.text or "{}"), 25 if ctx.league in ("kbo", "mlb") else 9) PROVIDERS = { diff --git a/backend/app/services/baseball_data.py b/backend/app/services/baseball_data.py new file mode 100644 index 0000000..db3ed47 --- /dev/null +++ b/backend/app/services/baseball_data.py @@ -0,0 +1,151 @@ +"""야구 예측 프롬프트용 데이터 블록 — 자체 DB(정산 결과) + DataCache(프리뷰·순위). + +결과 동기화가 쌓은 종료 경기에서 팀 폼·시즌 성적·상대전적을 계산하고, +캐시된 선발투수·공식 상대전적·순위를 덧붙인다. 모두 없으면 None(이름만 예측). +""" +from __future__ import annotations + +from sqlalchemy import or_, select + +from ..models import DataCache, Match + + +def _wdl(gf: int, ga: int) -> str: + return "W" if gf > ga else "L" if gf < ga else "D" + + +async def _team_games(db, league: str, code: str, before) -> list[dict]: + rows = (await db.execute( + select(Match) + .where( + Match.league == league, + or_(Match.team_a_code == code, Match.team_b_code == code), + Match.result_outcome.isnot(None), + Match.kickoff_at < before, + ) + .order_by(Match.kickoff_at) + )).scalars().all() + out = [] + for m in rows: + is_a = m.team_a_code == code # team_a = 원정 + gf = m.result_score_a if is_a else m.result_score_b + ga = m.result_score_b if is_a else m.result_score_a + if gf is None or ga is None: + continue + out.append({ + "r": _wdl(gf, ga), "gf": gf, "ga": ga, + "opp": m.team_b_short if is_a else m.team_a_short, + }) + return out + + +def _team_lines(name: str, games: list[dict]) -> str: + head = f"[{name}]" + if not games: + return f"{head}\n (no season data yet — use general knowledge)" + n = len(games) + w = sum(1 for g in games if g["r"] == "W") + d = sum(1 for g in games if g["r"] == "D") + l = sum(1 for g in games if g["r"] == "L") + gf_avg = round(sum(g["gf"] for g in games) / n, 2) + ga_avg = round(sum(g["ga"] for g in games) / n, 2) + recent = games[-8:] + detail = "; ".join("{r} {gf}-{ga} vs {opp}".format(**g) for g in recent[-4:]) + return ( + f"{head}\n" + f" Season(in our data): {w}-{d}-{l} (W-D-L), " + f"runs avg {gf_avg} scored / {ga_avg} allowed over {n} games\n" + f" Form(last{len(recent)}): {' '.join(g['r'] for g in recent)} ({detail})" + ) + + +async def _h2h_line(db, league: str, code_a: str, code_b: str, before) -> str: + rows = (await db.execute( + select(Match) + .where( + Match.league == league, + or_( + (Match.team_a_code == code_a) & (Match.team_b_code == code_b), + (Match.team_a_code == code_b) & (Match.team_b_code == code_a), + ), + Match.result_outcome.isnot(None), + Match.kickoff_at < before, + ) + .order_by(Match.kickoff_at) + )).scalars().all() + parts = [ + f"{m.team_a_short} {m.result_score_a}-{m.result_score_b} {m.team_b_short}" + for m in rows[-5:] + if m.result_score_a is not None + ] + return "; ".join(parts) if parts else "no meetings in our data yet" + + +def _starter_line(side: str, s: dict | None) -> str | None: + if not s or not s.get("name"): + return None + era = f", season ERA {s['era']}" if s.get("era") else "" + rec = ( + f" ({s['w']}W-{s['l']}L)" + if s.get("w") is not None and s.get("l") is not None else "" + ) + vs = f", ERA vs this opponent {s['vsEra']}" if s.get("vsEra") else "" + return f"[{side} starting pitcher] {s['name']}{era}{rec}{vs}" + + +def _standing_line(name: str, st: dict | None) -> str | None: + if not st: + return None + extra = f", last5 {st['last5']}" if st.get("last5") else "" + return ( + f"[{name} standings] rank {st.get('rank')}, {st.get('w')}W-" + f"{st.get('l')}L (pct {st.get('wra')}){extra}" + ) + + +async def _cached_extras(db, match: Match) -> list[str]: + lines: list[str] = [] + prev = await db.get(DataCache, f"preview:{match.match_id}") + if prev: + p = prev.payload + for line in ( + _starter_line("Away", p.get("starterA")), + _starter_line("Home", p.get("starterB")), + ): + if line: + lines.append(line) + vs = p.get("seasonVs") + if vs and vs.get("aWin") is not None: + lines.append( + f"[Season head-to-head (official)] away {vs['aWin']}W - " + f"{vs.get('draw', 0)}D - home {vs.get('bWin')}W" + ) + st_row = await db.get(DataCache, f"standings:{match.league}") + if st_row: + table = st_row.payload + for line in ( + _standing_line(match.team_a_short, table.get(match.team_a_code)), + _standing_line(match.team_b_short, table.get(match.team_b_code)), + ): + if line: + lines.append(line) + return lines + + +async def build_baseball_data_block(db, match: Match) -> str | None: + ga = await _team_games(db, match.league, match.team_a_code, match.kickoff_at) + gb = await _team_games(db, match.league, match.team_b_code, match.kickoff_at) + extras = await _cached_extras(db, match) + if not ga and not gb and not extras: + return None + h2h = await _h2h_line( + db, match.league, match.team_a_code, match.team_b_code, match.kickoff_at + ) + return "\n".join([ + "=== MATCH DATA (factual; weigh heavily over priors) ===", + "Away " + _team_lines(match.team_a_name, ga), + "Home " + _team_lines(match.team_b_name, gb), + f"[Head-to-head in our data] {h2h}", + *extras, + "===", + ]) diff --git a/backend/app/services/baseball_details.py b/backend/app/services/baseball_details.py new file mode 100644 index 0000000..3fe656e --- /dev/null +++ b/backend/app/services/baseball_details.py @@ -0,0 +1,540 @@ +"""야구 부가 데이터 — 프리뷰(선발투수·상대전적)·리그 순위·라이브 필드 뷰. + +KBO: 네이버 스포츠 비공식 API (프리뷰·순위·문자중계 relay) +MLB: 공식 Stats API (probablePitcher·standings·feed/live) + +수집(refresh_*)은 워커가 매일, 조회(get_extras)는 라우터가 캐시만 읽음. +라이브(fetch_live)는 요청 시 프록시 + 짧은 TTL 메모리 캐시. +""" +from __future__ import annotations + +import logging +import time +from datetime import datetime, timedelta, timezone + +from ..config import settings +from ..domain import ensure_aware, now_utc +from ..models import DataCache, Match +from ..teams_baseball import KBO_SHORT_TO_CODE, MLB_ID_TO_CODE, MLB_TEAMS +from .baseball_sync import match_seq + +log = logging.getLogger("triplepick.baseball") + +KST = timezone(timedelta(hours=9)) +UA = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/126.0 Safari/537.36" + ) +} + + +def naver_game_id(m: Match, game_no: str = "0") -> str: + kst = ensure_aware(m.kickoff_at).astimezone(KST) + return f"{kst.strftime('%Y%m%d')}{m.team_a_code}{m.team_b_code}{game_no}{kst.year}" + + +def naver_game_id_candidates(m: Match) -> list[str]: + """더블헤더 대응 gameId 후보 — 끝번호 0(단일)/1(DH 1차전)/2(DH 2차전). + seq1 은 단일을 먼저 시도하고 DH 1차전으로 폴백, seq2 는 2차전 고정.""" + nos = ["2"] if match_seq(m.match_id) == 2 else ["0", "1"] + return [naver_game_id(m, n) for n in nos] + + +async def _naver_get_first(client, m: Match, suffix: str) -> dict | None: + """gameId 후보를 순서대로 시도해 첫 성공 응답을 반환.""" + for gid in naver_game_id_candidates(m): + try: + res = await _naver_get(client, f"/schedule/games/{gid}/{suffix}") + except Exception: # noqa: BLE001 — 후보 불일치(404 등)는 다음 후보로 + continue + if res: + return res + return None + + +async def _naver_get(client, path: str) -> dict | None: + r = await client.get(settings.naver_api_base + path, headers=UA) + r.raise_for_status() + data = r.json() + return data.get("result") if data.get("success") else None + + +async def _upsert(db, key: str, payload: dict) -> None: + row = await db.get(DataCache, key) + if row: + row.payload = payload + row.fetched_at = now_utc() + else: + db.add(DataCache(key=key, payload=payload, fetched_at=now_utc())) + + +# ── 프리뷰 (선발투수·시즌 상대전적) ──────────────────────────── +def _kbo_starter(raw: dict | None) -> dict | None: + if not raw: + return None + info = raw.get("playerInfo") or {} + season = raw.get("currentSeasonStats") or {} + vs = raw.get("currentSeasonStatsOnOpponents") or {} + out = { + "name": info.get("name", ""), + "hitType": info.get("hitType", ""), + "era": season.get("era"), + "w": season.get("w"), "l": season.get("l"), + "vsEra": vs.get("era"), + } + return out if out["name"] else None + + +async def _refresh_previews_kbo(db, matches: list[Match]) -> int: + import httpx + + n = 0 + async with httpx.AsyncClient(timeout=15) as client: + for m in matches: + try: + res = await _naver_get_first(client, m, "preview") + p = (res or {}).get("previewData") or {} + except Exception as e: # noqa: BLE001 + log.warning("kbo preview 실패 %s: %s", m.match_id, e) + continue + vs = p.get("seasonVsResult") or {} + payload = { + "starterA": _kbo_starter(p.get("awayStarter")), + "starterB": _kbo_starter(p.get("homeStarter")), + "seasonVs": { + "aWin": vs.get("aw"), "draw": vs.get("ad") or 0, "bWin": vs.get("hw"), + } if vs else None, + } + if payload["starterA"] or payload["starterB"] or payload["seasonVs"]: + await _upsert(db, f"preview:{m.match_id}", payload) + n += 1 + return n + + +_HAND_KO = {"L": "좌", "R": "우", "S": "양"} + + +def _mlb_starter(p: dict, stats: dict[int, dict]) -> dict | None: + if not p.get("fullName"): + return None + out: dict = {"name": p["fullName"]} + out.update(stats.get(p.get("id"), {})) + return out + + +async def _mlb_season_vs(c, m: Match, year: int) -> dict | None: + """시즌 정규 상대전적 — 두 팀 간 완료 경기 승수 집계 (팀쌍당 1콜).""" + aid = (MLB_TEAMS.get(m.team_a_code) or {}).get("mlb_id") + bid = (MLB_TEAMS.get(m.team_b_code) or {}).get("mlb_id") + if not aid or not bid: + return None + r = await c.get( + f"{settings.mlb_api_base}/v1/schedule?sportId=1&season={year}&gameType=R" + f"&teamId={bid}&opponentId={aid}" + f"&startDate={year}-03-01&endDate={datetime.now(KST).date().isoformat()}" + ) + r.raise_for_status() + wins = {aid: 0, bid: 0} + for day in r.json().get("dates") or []: + for g in day.get("games") or []: + if (g.get("status") or {}).get("abstractGameState") != "Final": + continue + for side in ("away", "home"): + t = g["teams"][side] + tid = (t.get("team") or {}).get("id") + if t.get("isWinner") and tid in wins: + wins[tid] += 1 + if wins[aid] + wins[bid] == 0: + return None + return {"aWin": wins[aid], "draw": 0, "bWin": wins[bid]} + + +async def _refresh_previews_mlb(db, matches: list[Match]) -> int: + """MLB 예고 선발(시즌 ERA·승패·투타 포함)·시즌 상대전적 — 공식 Stats API. + + 호출량: 일정 1콜 + 선발 스탯 일괄 1콜 + 상대전적 팀쌍당 1콜. + """ + import httpx + + if not matches: + return 0 + dates = sorted({ensure_aware(m.kickoff_at).astimezone(KST).date() for m in matches}) + year = datetime.now(KST).year + # statsapi 의 start/endDate 는 미국 날짜 — KST 새벽~오전 경기는 미국 전날이라 + # 시작일을 하루 앞당겨야 누락되지 않는다. + url = ( + f"{settings.mlb_api_base}/v1/schedule?sportId=1" + f"&startDate={(dates[0] - timedelta(days=1)).isoformat()}" + f"&endDate={dates[-1].isoformat()}" + "&hydrate=probablePitcher" + ) + async with httpx.AsyncClient(timeout=20) as c: + r = await c.get(url) + r.raise_for_status() + data = r.json() + + # (dateKst, away, home) → (원정 선발 raw, 홈 선발 raw) + starters: dict[tuple, tuple[dict, dict]] = {} + for day in data.get("dates") or []: + for g in day.get("games") or []: + a = MLB_ID_TO_CODE.get((g["teams"]["away"]["team"] or {}).get("id")) + b = MLB_ID_TO_CODE.get((g["teams"]["home"]["team"] or {}).get("id")) + gd = g.get("gameDate") + if not a or not b or not gd: + continue + d = ( + datetime.fromisoformat(gd.replace("Z", "+00:00")) + .astimezone(KST).strftime("%Y%m%d") + ) + pa = g["teams"]["away"].get("probablePitcher") or {} + pb = g["teams"]["home"].get("probablePitcher") or {} + starters[(d, a, b)] = (pa, pb) + + # 선발 시즌 스탯 — people 일괄 조회 1콜 (ERA·승패·투타) + pids = sorted({ + p["id"] for pair in starters.values() for p in pair if p.get("id") + }) + pstats: dict[int, dict] = {} + if pids: + try: + r2 = await c.get( + f"{settings.mlb_api_base}/v1/people" + f"?personIds={','.join(map(str, pids))}" + f"&hydrate=stats(group=[pitching],type=[season],season={year})" + ) + r2.raise_for_status() + for p in r2.json().get("people") or []: + splits = (p.get("stats") or [{}])[0].get("splits") or [] + s = splits[0].get("stat", {}) if splits else {} + hand = _HAND_KO.get((p.get("pitchHand") or {}).get("code")) + bat = _HAND_KO.get((p.get("batSide") or {}).get("code")) + pstats[p["id"]] = { + "hitType": f"{hand}투{bat}타" if hand and bat else None, + "era": s.get("era"), + "w": s.get("wins"), "l": s.get("losses"), + } + except Exception as e: # noqa: BLE001 — 스탯 실패 시 이름만 표시 + log.warning("mlb 선발 스탯 실패: %s", e) + + n = 0 + vs_cache: dict[tuple, dict | None] = {} + for m in matches: + d = ensure_aware(m.kickoff_at).astimezone(KST).strftime("%Y%m%d") + pa, pb = starters.get((d, m.team_a_code, m.team_b_code), ({}, {})) + pair = (m.team_a_code, m.team_b_code) + if pair not in vs_cache: + try: + vs_cache[pair] = await _mlb_season_vs(c, m, year) + except Exception as e: # noqa: BLE001 + log.warning("mlb 상대전적 실패 %s: %s", m.match_id, e) + vs_cache[pair] = None + sa = _mlb_starter(pa, pstats) + sb = _mlb_starter(pb, pstats) + if sa or sb or vs_cache[pair]: + await _upsert(db, f"preview:{m.match_id}", { + "starterA": sa, + "starterB": sb, + "seasonVs": vs_cache[pair], + }) + n += 1 + return n + + +# ── 리그 순위 ────────────────────────────────────────────────── +async def _refresh_standings_kbo(db) -> bool: + import httpx + + year = datetime.now(KST).year + try: + async with httpx.AsyncClient(timeout=15) as client: + res = await _naver_get(client, f"/stats/categories/kbo/seasons/{year}/teams") + except Exception as e: # noqa: BLE001 + log.warning("kbo standings 실패: %s", e) + return False + table: dict[str, dict] = {} + for r in (res or {}).get("seasonTeamStats") or []: + code = KBO_SHORT_TO_CODE.get(r.get("teamShortName", "")) + if code: + table[code] = { + "rank": r.get("ranking"), + "w": r.get("winGameCount"), "d": r.get("drawnGameCount"), + "l": r.get("loseGameCount"), "wra": r.get("wra"), + "gb": r.get("gameBehind"), "last5": r.get("lastFiveGames"), + "avg": r.get("offenseHra"), "era": r.get("defenseEra"), + } + if not table: + return False + await _upsert(db, "standings:kbo", table) + return True + + +# statsapi division.id → 순위표 그룹 키 (AL/NL × 동·중·서) +_MLB_DIV = {201: "ALE", 202: "ALC", 200: "ALW", 204: "NLE", 205: "NLC", 203: "NLW"} + + +async def _refresh_standings_mlb(db) -> bool: + import httpx + + year = datetime.now(KST).year + table: dict[str, dict] = {} + try: + async with httpx.AsyncClient(timeout=15) as c: + for lid in (103, 104): # AL, NL + r = await c.get( + f"{settings.mlb_api_base}/v1/standings?leagueId={lid}&season={year}" + ) + r.raise_for_status() + for rec_div in r.json().get("records") or []: + div = _MLB_DIV.get((rec_div.get("division") or {}).get("id")) + for t in rec_div.get("teamRecords") or []: + code = MLB_ID_TO_CODE.get((t.get("team") or {}).get("id")) + if code: + table[code] = { + "div": div, + "rank": int(t.get("divisionRank") or 0) or None, + "w": t.get("wins"), "d": 0, "l": t.get("losses"), + "wra": t.get("winningPercentage"), + "gb": t.get("gamesBack"), + "last5": None, "avg": None, "era": None, + } + except Exception as e: # noqa: BLE001 + log.warning("mlb standings 실패: %s", e) + return False + if not table: + return False + await _upsert(db, "standings:mlb", table) + return True + + +async def refresh_baseball_details(db, league: str, matches: list[Match]) -> None: + """임박(48h 내) 미종료 경기 프리뷰 + 리그 순위 캐시 갱신.""" + horizon = now_utc() + timedelta(hours=48) + targets = [ + m for m in matches + if m.result_outcome is None + and m.status != "cancelled" + and ensure_aware(m.kickoff_at) <= horizon + ] + if league == "kbo": + n = await _refresh_previews_kbo(db, targets) + await _refresh_standings_kbo(db) + elif league == "mlb": + n = await _refresh_previews_mlb(db, targets) + await _refresh_standings_mlb(db) + else: + return + await db.commit() + log.info("baseball details(%s): 프리뷰 %d경기 캐싱", league, n) + + +# ── extras 조회 (라우터 — 캐시만) ────────────────────────────── +async def get_extras(db, matches: list[Match]) -> dict[str, dict]: + standings_cache: dict[str, dict] = {} + out: dict[str, dict] = {} + for m in matches: + if m.league not in ("kbo", "mlb"): + continue + if m.league not in standings_cache: + row = await db.get(DataCache, f"standings:{m.league}") + standings_cache[m.league] = row.payload if row else {} + extras: dict = {} + prev = await db.get(DataCache, f"preview:{m.match_id}") + if prev: + extras.update(prev.payload) + st = standings_cache[m.league] + st_a, st_b = st.get(m.team_a_code), st.get(m.team_b_code) + if st_a or st_b: + extras["standings"] = {"a": st_a, "b": st_b} + if extras: + out[m.match_id] = extras + return out + + +# ── 라이브 필드 뷰 ───────────────────────────────────────────── +_LIVE_TTL_SEC = 15.0 +_live_cache: dict[str, tuple[float, dict]] = {} + +FIELD_POSITIONS = ( + "포수", "1루수", "2루수", "3루수", "유격수", "좌익수", "중견수", "우익수", +) +# MLB 포지션 약어 → 한글 (필드 좌표 키와 통일) +MLB_POS = { + "C": "포수", "1B": "1루수", "2B": "2루수", "3B": "3루수", "SS": "유격수", + "LF": "좌익수", "CF": "중견수", "RF": "우익수", +} + + +def _current_slots(batters: list[dict]) -> list[dict]: + by_order: dict[int, dict] = {} + for b in batters or []: + o = b.get("batOrder") + if o is None: + continue + cur = by_order.get(o) + if cur is None or (b.get("seqno") or 0) > (cur.get("seqno") or 0): + by_order[o] = b + return [by_order[o] for o in sorted(by_order)] + + +def _batter_out(b: dict) -> dict: + return { + "order": b.get("batOrder"), + "name": b.get("name", ""), + "pos": b.get("posName", ""), + "avg": b.get("seasonHra"), + "sub": (b.get("seqno") or 1) > 1, + } + + +def _transform_naver_relay(t: dict) -> dict: + gs = t.get("currentGameState") or {} + home_batting = str(t.get("homeOrAway")) == "1" + home_lu = t.get("homeLineup") or {} + away_lu = t.get("awayLineup") or {} + offense_lu = home_lu if home_batting else away_lu + defense_lu = away_lu if home_batting else home_lu + + offense = _current_slots(offense_lu.get("batter")) + defense = _current_slots(defense_lu.get("batter")) + pitchers = defense_lu.get("pitcher") or [] + pitcher = max(pitchers, key=lambda p: p.get("seqno") or 0) if pitchers else {} + batter_code = str(gs.get("batter") or "") + batter = next((b for b in offense if str(b.get("pcode")) == batter_code), None) + + return { + "available": True, + "inn": t.get("inn"), + "half": "B" if home_batting else "T", + "score": {"away": gs.get("awayScore"), "home": gs.get("homeScore")}, + "bso": {"b": gs.get("ball"), "s": gs.get("strike"), "o": gs.get("out")}, + "bases": [ + str(gs.get(k) or "0") != "0" for k in ("base1", "base2", "base3") + ], + "batter": _batter_out(batter) if batter else None, + "pitcher": { + "name": pitcher.get("name", ""), + "ballCount": pitcher.get("ballCount"), + } if pitcher else None, + "vsRecord": t.get("pitcherVsBatterCareerStats") or "", + "defense": [ + {"pos": b.get("posName"), "name": b.get("name", "")} + for b in defense if b.get("posName") in FIELD_POSITIONS + ], + "offenseLineup": [_batter_out(b) for b in offense], + } + + +def _transform_mlb_feed(feed: dict) -> dict: + ld = feed.get("liveData") or {} + ls = ld.get("linescore") or {} + box = (ld.get("boxscore") or {}).get("teams") or {} + half_top = (ls.get("inningHalf") or "").lower() == "top" + offense_side, defense_side = ("away", "home") if half_top else ("home", "away") + off = ls.get("offense") or {} + defn = ls.get("defense") or {} + + def _players(side: str) -> dict: + return (box.get(side) or {}).get("players") or {} + + # 수비 배치: boxscore players 의 position + 현재 출장(battingOrder 존재) + defense = [] + for p in _players(defense_side).values(): + pos = MLB_POS.get(((p.get("position") or {}).get("abbreviation") or "")) + name = ((p.get("person") or {}).get("fullName")) or "" + if pos and name and p.get("gameStatus", {}).get("isCurrentBatter") is not None: + defense.append({"pos": pos, "name": name}) + # 같은 포지션 중복(교체) — 마지막 것만 + dedup: dict[str, dict] = {f["pos"]: f for f in defense} + + order_raw = (box.get(offense_side) or {}).get("battingOrder") or [] + id_to_player = _players(offense_side) + lineup = [] + for i, pid in enumerate(order_raw[:9]): + p = id_to_player.get(f"ID{pid}") or {} + lineup.append({ + "order": i + 1, + "name": ((p.get("person") or {}).get("fullName")) or "", + "pos": MLB_POS.get(((p.get("position") or {}).get("abbreviation") or ""), ""), + "avg": None, + "sub": False, + }) + + batter_name = ((off.get("batter") or {}).get("fullName")) or "" + batter = next((b for b in lineup if b["name"] == batter_name), None) + pitcher = (defn.get("pitcher") or {}).get("fullName") or \ + (off.get("pitcher") or {}).get("fullName") or "" + + return { + "available": bool(ls.get("currentInning")), + "inn": ls.get("currentInning"), + "half": "T" if half_top else "B", + "score": { + "away": ((ls.get("teams") or {}).get("away") or {}).get("runs"), + "home": ((ls.get("teams") or {}).get("home") or {}).get("runs"), + }, + "bso": {"b": ls.get("balls"), "s": ls.get("strikes"), "o": ls.get("outs")}, + "bases": [bool(off.get("first")), bool(off.get("second")), bool(off.get("third"))], + "batter": batter or ({"order": None, "name": batter_name} if batter_name else None), + "pitcher": {"name": pitcher, "ballCount": None} if pitcher else None, + "vsRecord": "", + "defense": list(dedup.values()), + "offenseLineup": lineup, + } + + +async def fetch_live(m: Match) -> dict: + """라이브 필드 뷰 페이로드 (리그별 소스). 미게시면 available=False.""" + import httpx + + cached = _live_cache.get(m.match_id) + if cached and time.monotonic() - cached[0] < _LIVE_TTL_SEC: + return cached[1] + payload: dict = {"available": False} + try: + if m.league == "kbo": + async with httpx.AsyncClient(timeout=10) as client: + res = await _naver_get_first(client, m, "relay") + t = (res or {}).get("textRelayData") + if t: + payload = _transform_naver_relay(t) + elif m.league == "mlb": + # gamePk 를 일정에서 재조회 — MLB 일정 API 의 date 는 미국 날짜라 + # KST 기준 하루 전~당일 범위로 조회 후 dateKst 로 정확히 매칭. + kst = ensure_aware(m.kickoff_at).astimezone(KST) + date_kst = kst.strftime("%Y%m%d") + start = (kst.date() - timedelta(days=1)).isoformat() + async with httpx.AsyncClient(timeout=15) as c: + r = await c.get( + f"{settings.mlb_api_base}/v1/schedule?sportId=1" + f"&startDate={start}&endDate={kst.date().isoformat()}" + ) + r.raise_for_status() + # 더블헤더 대응: 같은 날짜·팀쌍 경기를 시작시각순으로 모아 + # match_id 의 차수(seq)에 해당하는 경기를 고른다. + cands: list[tuple[str, int]] = [] + for day in r.json().get("dates") or []: + for g in day.get("games") or []: + a = MLB_ID_TO_CODE.get((g["teams"]["away"]["team"] or {}).get("id")) + b = MLB_ID_TO_CODE.get((g["teams"]["home"]["team"] or {}).get("id")) + gd = g.get("gameDate") + if not a or not b or not gd: + continue + g_kst = ( + datetime.fromisoformat(gd.replace("Z", "+00:00")) + .astimezone(KST).strftime("%Y%m%d") + ) + if a == m.team_a_code and b == m.team_b_code and g_kst == date_kst: + cands.append((gd, g.get("gamePk"))) + cands.sort() + idx = match_seq(m.match_id) - 1 + pk = cands[idx][1] if idx < len(cands) else None + if pk: + r2 = await c.get(f"{settings.mlb_api_base}/v1.1/game/{pk}/feed/live") + r2.raise_for_status() + payload = _transform_mlb_feed(r2.json()) + except Exception as e: # noqa: BLE001 + log.warning("live 실패 %s: %s", m.match_id, e) + payload = {"available": False} + _live_cache[m.match_id] = (time.monotonic(), payload) + return payload diff --git a/backend/app/services/baseball_fetch.py b/backend/app/services/baseball_fetch.py new file mode 100644 index 0000000..6d35d1a --- /dev/null +++ b/backend/app/services/baseball_fetch.py @@ -0,0 +1,140 @@ +"""야구 일정·결과 수집 — KBO(네이버 비공식) + MLB(공식 Stats API). + +반환 표준 레코드 (두 리그 공통): + {league, teamA(원정), teamB(홈), dateKst 'YYYYMMDD', kickoffKst ISO(+09:00), + venue, cancelled, [scoreA, scoreB]} +야구는 같은 두 팀이 시즌 내 반복 대결 → 경기 식별에 반드시 dateKst 포함. +""" +from __future__ import annotations + +import logging +from datetime import datetime, timedelta, timezone + +from ..config import settings +from ..teams_baseball import KBO_TEAMS, MLB_ID_TO_CODE + +log = logging.getLogger("triplepick.baseball") + +KST = timezone(timedelta(hours=9)) +UA = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/126.0 Safari/537.36" + ) +} + + +# ── KBO (네이버) ─────────────────────────────────────────────── +async def _fetch_kbo(days_back: int, days_ahead: int) -> list[dict]: + import httpx + + today = datetime.now(KST).date() + url = ( + f"{settings.naver_api_base}/schedule/games" + "?fields=basic,stadium,statusNum" + "&upperCategoryId=kbaseball&categoryId=kbo" + f"&fromDate={(today - timedelta(days=days_back)).isoformat()}" + f"&toDate={(today + timedelta(days=days_ahead)).isoformat()}&size=500" + ) + async with httpx.AsyncClient(timeout=20) as c: + r = await c.get(url, headers=UA) + r.raise_for_status() + games = (r.json().get("result") or {}).get("games") or [] + + out: list[dict] = [] + for g in games: + a = (g.get("awayTeamCode") or "").upper() + b = (g.get("homeTeamCode") or "").upper() + if a not in KBO_TEAMS or b not in KBO_TEAMS: + continue # 올스타/시범 등 제외 + dt = g.get("gameDateTime") # KST, 오프셋 없음 + if not dt: + continue + kickoff = datetime.fromisoformat(dt).replace(tzinfo=KST) + rec = { + "league": "kbo", + "teamA": a, "teamB": b, + "dateKst": kickoff.strftime("%Y%m%d"), + "kickoffKst": kickoff.isoformat(), + "venue": g.get("stadium", ""), + "cancelled": bool(g.get("cancel")), + } + if g.get("statusCode") == "RESULT" and not rec["cancelled"]: + sa, sb = g.get("awayTeamScore"), g.get("homeTeamScore") + if sa is not None and sb is not None: + rec["scoreA"], rec["scoreB"] = int(sa), int(sb) + out.append(rec) + return out + + +# ── MLB (공식 Stats API) ─────────────────────────────────────── +async def _fetch_mlb(days_back: int, days_ahead: int) -> list[dict]: + import httpx + + today = datetime.now(KST).date() + url = ( + f"{settings.mlb_api_base}/v1/schedule?sportId=1" + f"&startDate={(today - timedelta(days=days_back)).isoformat()}" + f"&endDate={(today + timedelta(days=days_ahead)).isoformat()}" + ) + async with httpx.AsyncClient(timeout=20) as c: + r = await c.get(url) + r.raise_for_status() + data = r.json() + + out: list[dict] = [] + for day in data.get("dates") or []: + for g in day.get("games") or []: + a = MLB_ID_TO_CODE.get((g["teams"]["away"]["team"] or {}).get("id")) + b = MLB_ID_TO_CODE.get((g["teams"]["home"]["team"] or {}).get("id")) + if not a or not b: + continue # 올스타전 등 제외 + gd = g.get("gameDate") # ISO UTC + if not gd: + continue + kickoff = ( + datetime.fromisoformat(gd.replace("Z", "+00:00")).astimezone(KST) + ) + state = (g.get("status") or {}).get("detailedState", "") + rec = { + "league": "mlb", + "teamA": a, "teamB": b, + "dateKst": kickoff.strftime("%Y%m%d"), + "kickoffKst": kickoff.isoformat(), + "venue": (g.get("venue") or {}).get("name", ""), + "cancelled": state in ("Postponed", "Cancelled", "Suspended"), + "gamePk": g.get("gamePk"), # MLB 라이브 피드 키 + } + if state == "Final" and not rec["cancelled"]: + sa = g["teams"]["away"].get("score") + sb = g["teams"]["home"].get("score") + if sa is not None and sb is not None: + rec["scoreA"], rec["scoreB"] = int(sa), int(sb) + out.append(rec) + return out + + +# ── 공개 API ──────────────────────────────────────────────────── +async def fetch_baseball_schedule(league: str) -> list[dict]: + """리그 일정+결과 수집 (취소 포함). 실패 시 빈 리스트 — 기존 일정 유지.""" + try: + if league == "kbo": + records = await _fetch_kbo(settings.result_recheck_days, settings.baseball_days_ahead) + elif league == "mlb": + records = await _fetch_mlb(settings.result_recheck_days, settings.baseball_days_ahead) + else: + return [] + # 더블헤더 차수 부여 — sync·정산이 같은 seq 로 경기를 식별한다. + from .baseball_sync import assign_seq + + assign_seq(records) + log.info("baseball schedule(%s): %d경기 수집", league, len(records)) + return records + except Exception as e: # noqa: BLE001 + log.error("baseball schedule(%s) 실패: %s — 기존 일정 유지", league, e) + return [] + + +async def fetch_baseball_results(league: str) -> list[dict]: + """확정 스코어만 → [{league, teamA, teamB, dateKst, scoreA, scoreB}].""" + return [r for r in await fetch_baseball_schedule(league) if "scoreA" in r] diff --git a/backend/app/services/baseball_sync.py b/backend/app/services/baseball_sync.py new file mode 100644 index 0000000..ab1e04f --- /dev/null +++ b/backend/app/services/baseball_sync.py @@ -0,0 +1,130 @@ +"""야구 일정 DB 반영 — (리그, KST 날짜, 팀쌍, 차수) 키 매칭. 워커가 매일 호출. + +- 기존 경기: 키 매칭 → 시각/venue/투표시간 갱신 (결과·예측·crowd 보존) +- 신규 경기: 삽입 + crowd 초기화. + match_id = {LEAGUE}_{away}_{home}_{yyyymmdd} (더블헤더 2차전은 _2 접미) +- 취소(우천 등): 삭제하지 않고 status="cancelled" 로 보존 — 투표·예측 기록 유지, + 정산·투표·AI 생성에서 제외. 소스가 취소를 번복하면 scheduled 로 복귀. + (보강 경기는 새 날짜의 신규 경기로 재등장) +- 더블헤더: 같은 (날짜, 팀쌍) 복수 경기를 시작시각순 seq(1,2)로 구분해 모두 등록. +- 투표창: 리그 공통 오픈 오프셋(vote_open_hours_before, 기본 -168h) +""" +from __future__ import annotations + +import logging +from datetime import datetime, timedelta, timezone + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..config import settings +from ..models import CrowdStats, Match +from ..teams_baseball import team_info + +log = logging.getLogger("triplepick.baseball") +KST = timezone(timedelta(hours=9)) + + +def baseball_opens_at(kickoff: datetime) -> datetime: + # 축구와 동일 정책(vote_open_hours_before=168h) — 야구는 일정을 7일치만 + # 수집하므로 사실상 동기화 즉시 투표 오픈된다. + return kickoff - timedelta(hours=settings.vote_open_hours_before) + + +def baseball_lock_at(kickoff: datetime) -> datetime: + return kickoff - timedelta(minutes=settings.vote_lock_minutes_before) + + +def _date_of(m: Match) -> str: + return m.kickoff_at.astimezone(KST).strftime("%Y%m%d") if m.kickoff_at else "" + + +def match_seq(match_id: str) -> int: + """match_id 의 더블헤더 차수. `..._20260722` → 1, `..._20260722_2` → 2.""" + tail = match_id.rsplit("_", 1)[-1] + return int(tail) if len(tail) <= 2 and tail.isdigit() else 1 + + +def assign_seq(records: list[dict]) -> None: + """같은 (날짜, 팀쌍) 레코드에 시작시각순 seq(1,2,…)를 부여 — 더블헤더 구분.""" + groups: dict[tuple, list[dict]] = {} + for rec in records: + groups.setdefault((rec["dateKst"], rec["teamA"], rec["teamB"]), []).append(rec) + for recs in groups.values(): + recs.sort(key=lambda r: r["kickoffKst"]) + for i, rec in enumerate(recs, start=1): + rec["seq"] = i + + +async def sync_baseball_schedule( + db: AsyncSession, league: str, records: list[dict] +) -> dict: + if not records: + return {"updated": 0, "inserted": 0, "skipped": 0, "removed": 0} + + assign_seq(records) + + existing = ( + await db.execute(select(Match).where(Match.league == league)) + ).scalars().all() + by_key: dict[tuple, Match] = {} + for m in existing: + d, s = _date_of(m), match_seq(m.match_id) + by_key[(d, m.team_a_code, m.team_b_code, s)] = m + by_key[(d, m.team_b_code, m.team_a_code, s)] = m + + updated = inserted = skipped = removed = 0 + for rec in records: + a, b, d = rec["teamA"], rec["teamB"], rec["dateKst"] + seq = rec.get("seq", 1) + m = by_key.get((d, a, b, seq)) + + if rec.get("cancelled"): + # 소프트 취소 — 투표/예측 기록 보존, 정산·투표·AI 대상에서 제외. + if m is not None and m.result_outcome is None and m.status != "cancelled": + m.status = "cancelled" + removed += 1 + continue + + kickoff = datetime.fromisoformat(rec["kickoffKst"]).astimezone(timezone.utc) + if m is not None: + if m.result_outcome is not None: + skipped += 1 + continue + if m.status == "cancelled": + m.status = "scheduled" # 취소 번복 — 시간 기준 상태는 tick 이 복원 + m.kickoff_at = kickoff + m.opens_at = baseball_opens_at(kickoff) + m.lock_at = baseball_lock_at(kickoff) + if rec.get("venue"): + m.venue = rec["venue"] + updated += 1 + else: + ta, tb = team_info(league, a), team_info(league, b) + match_id = f"{league.upper()}_{a}_{b}_{d}" + (f"_{seq}" if seq > 1 else "") + new = Match( + match_id=match_id, + league=league, + round_label="정규시즌", + group="", + team_a_name=ta["name"], team_a_short=ta["shortName"], + team_a_code=ta["code"], team_a_flag=ta["flag"], + team_b_name=tb["name"], team_b_short=tb["shortName"], + team_b_code=tb["code"], team_b_flag=tb["flag"], + venue=rec.get("venue", ""), + hook_text=f"{ta['shortName']} vs {tb['shortName']}", + kickoff_at=kickoff, + opens_at=baseball_opens_at(kickoff), + lock_at=baseball_lock_at(kickoff), + status="scheduled", + ) + db.add(new) + db.add(CrowdStats(match_id=match_id, total=0, team_a_win=0, draw=0, team_b_win=0)) + by_key[(d, a, b, seq)] = new + by_key[(d, b, a, seq)] = new + inserted += 1 + + await db.commit() + result = {"updated": updated, "inserted": inserted, "skipped": skipped, "removed": removed} + log.info("baseball sync(%s): %s", league, result) + return result diff --git a/backend/app/services/grading.py b/backend/app/services/grading.py index 835cba6..722c70f 100644 --- a/backend/app/services/grading.py +++ b/backend/app/services/grading.py @@ -32,8 +32,11 @@ async def apply_result(db: AsyncSession, match: Match, score_a: int, score_b: in ) ).scalars().all() + baseball = match.league in ("kbo", "mlb") for p in picks: - key, value = grade_prediction(p.score_a, p.score_b, score_a, score_b) + key, value = grade_prediction( + p.score_a, p.score_b, score_a, score_b, baseball=baseball + ) p.points = value p.scored_at = now_utc() diff --git a/backend/app/services/points.py b/backend/app/services/points.py index 538e763..9545841 100644 --- a/backend/app/services/points.py +++ b/backend/app/services/points.py @@ -66,7 +66,8 @@ async def accumulate_user_points( agg: dict[str, dict] = {e: _empty() for e in targets} for pick, match in rows: key, value = grade_prediction( - pick.score_a, pick.score_b, match.result_score_a, match.result_score_b + pick.score_a, pick.score_b, match.result_score_a, match.result_score_b, + baseball=match.league in ("kbo", "mlb"), ) t = agg[pick.email.strip().lower()] t["total_points"] += value diff --git a/backend/app/teams_baseball.py b/backend/app/teams_baseball.py new file mode 100644 index 0000000..5773874 --- /dev/null +++ b/backend/app/teams_baseball.py @@ -0,0 +1,78 @@ +"""야구 팀 데이터 — KBO 10개 구단 + MLB 30개 구단 (코드 → 한글/약식/영문). + +KBO 코드: 네이버/KBO 표준 2글자 (HT=KIA, OB=두산, WO=키움, LT=롯데, SK=SSG). +MLB 코드: 공식 약어 (NYY, LAD …) + mlb_id 는 statsapi 의 team.id (불변). +로고: frontend /assets/teams/{league}/{code소문자}.png +""" +from __future__ import annotations + +# ── KBO ──────────────────────────────────────────────────────── +KBO_TEAMS: dict[str, dict] = { + "HT": {"ko": "KIA 타이거즈", "short": "KIA", "en": "Kia Tigers", "home": "광주기아챔피언스필드"}, + "LG": {"ko": "LG 트윈스", "short": "LG", "en": "LG Twins", "home": "잠실야구장"}, + "OB": {"ko": "두산 베어스", "short": "두산", "en": "Doosan Bears", "home": "잠실야구장"}, + "SS": {"ko": "삼성 라이온즈", "short": "삼성", "en": "Samsung Lions", "home": "대구삼성라이온즈파크"}, + "LT": {"ko": "롯데 자이언츠", "short": "롯데", "en": "Lotte Giants", "home": "사직야구장"}, + "SK": {"ko": "SSG 랜더스", "short": "SSG", "en": "SSG Landers", "home": "인천SSG랜더스필드"}, + "KT": {"ko": "KT 위즈", "short": "KT", "en": "KT Wiz", "home": "수원KT위즈파크"}, + "NC": {"ko": "NC 다이노스", "short": "NC", "en": "NC Dinos", "home": "창원NC파크"}, + "WO": {"ko": "키움 히어로즈", "short": "키움", "en": "Kiwoom Heroes", "home": "고척스카이돔"}, + "HH": {"ko": "한화 이글스", "short": "한화", "en": "Hanwha Eagles", "home": "대전한화생명볼파크"}, +} + +# ── MLB (statsapi team.id → 우리 코드) ───────────────────────── +MLB_TEAMS: dict[str, dict] = { + "LAD": {"ko": "LA 다저스", "short": "다저스", "en": "Los Angeles Dodgers", "mlb_id": 119}, + "NYY": {"ko": "뉴욕 양키스", "short": "양키스", "en": "New York Yankees", "mlb_id": 147}, + "NYM": {"ko": "뉴욕 메츠", "short": "메츠", "en": "New York Mets", "mlb_id": 121}, + "BOS": {"ko": "보스턴 레드삭스", "short": "보스턴", "en": "Boston Red Sox", "mlb_id": 111}, + "CHC": {"ko": "시카고 컵스", "short": "컵스", "en": "Chicago Cubs", "mlb_id": 112}, + "CWS": {"ko": "시카고 화이트삭스", "short": "화이트삭스", "en": "Chicago White Sox", "mlb_id": 145}, + "CLE": {"ko": "클리블랜드 가디언스", "short": "클리블랜드", "en": "Cleveland Guardians", "mlb_id": 114}, + "DET": {"ko": "디트로이트 타이거스", "short": "디트로이트", "en": "Detroit Tigers", "mlb_id": 116}, + "HOU": {"ko": "휴스턴 애스트로스", "short": "휴스턴", "en": "Houston Astros", "mlb_id": 117}, + "KC": {"ko": "캔자스시티 로열스", "short": "캔자스시티", "en": "Kansas City Royals", "mlb_id": 118}, + "LAA": {"ko": "LA 에인절스", "short": "에인절스", "en": "Los Angeles Angels", "mlb_id": 108}, + "MIN": {"ko": "미네소타 트윈스", "short": "미네소타", "en": "Minnesota Twins", "mlb_id": 142}, + "ATH": {"ko": "애슬레틱스", "short": "애슬레틱스", "en": "Athletics", "mlb_id": 133}, + "SEA": {"ko": "시애틀 매리너스", "short": "시애틀", "en": "Seattle Mariners", "mlb_id": 136}, + "TB": {"ko": "탬파베이 레이스", "short": "탬파베이", "en": "Tampa Bay Rays", "mlb_id": 139}, + "TEX": {"ko": "텍사스 레인저스", "short": "텍사스", "en": "Texas Rangers", "mlb_id": 140}, + "TOR": {"ko": "토론토 블루제이스", "short": "토론토", "en": "Toronto Blue Jays", "mlb_id": 141}, + "ARI": {"ko": "애리조나 다이아몬드백스", "short": "애리조나", "en": "Arizona Diamondbacks", "mlb_id": 109}, + "ATL": {"ko": "애틀랜타 브레이브스", "short": "애틀랜타", "en": "Atlanta Braves", "mlb_id": 144}, + "BAL": {"ko": "볼티모어 오리올스", "short": "볼티모어", "en": "Baltimore Orioles", "mlb_id": 110}, + "CIN": {"ko": "신시내티 레즈", "short": "신시내티", "en": "Cincinnati Reds", "mlb_id": 113}, + "COL": {"ko": "콜로라도 로키스", "short": "콜로라도", "en": "Colorado Rockies", "mlb_id": 115}, + "MIA": {"ko": "마이애미 말린스", "short": "마이애미", "en": "Miami Marlins", "mlb_id": 146}, + "MIL": {"ko": "밀워키 브루어스", "short": "밀워키", "en": "Milwaukee Brewers", "mlb_id": 158}, + "WSH": {"ko": "워싱턴 내셔널스", "short": "워싱턴", "en": "Washington Nationals", "mlb_id": 120}, + "PHI": {"ko": "필라델피아 필리스", "short": "필라델피아", "en": "Philadelphia Phillies", "mlb_id": 143}, + "PIT": {"ko": "피츠버그 파이리츠", "short": "피츠버그", "en": "Pittsburgh Pirates", "mlb_id": 134}, + "SD": {"ko": "샌디에이고 파드리스", "short": "샌디에이고", "en": "San Diego Padres", "mlb_id": 135}, + "SF": {"ko": "샌프란시스코 자이언츠", "short": "SF", "en": "San Francisco Giants", "mlb_id": 137}, + "STL": {"ko": "세인트루이스 카디널스", "short": "세인트루이스", "en": "St. Louis Cardinals", "mlb_id": 138}, +} + +MLB_ID_TO_CODE: dict[int, str] = {v["mlb_id"]: k for k, v in MLB_TEAMS.items()} + +# 순위표 등 팀 축약명 → 코드 (리그별) +KBO_SHORT_TO_CODE = {v["short"]: k for k, v in KBO_TEAMS.items()} + + +def teams_of(league: str) -> dict[str, dict]: + return KBO_TEAMS if league == "kbo" else MLB_TEAMS + + +def team_info(league: str, code: str) -> dict: + """flag 에 로고 경로/URL 을 실어 프론트(TeamFlag)가 그대로 렌더한다. + KBO: 로컬 자산(/assets/teams/kbo/*.png) · MLB: 공식 CDN(mlbstatic) SVG.""" + c = (code or "").strip().upper() + t = teams_of(league).get(c) + if not t: + return {"name": c, "shortName": c, "code": c, "flag": ""} + if league == "mlb": + flag = f"https://www.mlbstatic.com/team-logos/{t['mlb_id']}.svg" + else: + flag = f"/assets/teams/kbo/{c.lower()}.png" + return {"name": t["ko"], "shortName": t["short"], "code": c, "flag": flag} diff --git a/backend/app/worker.py b/backend/app/worker.py index 266aee2..6d4b7d3 100644 --- a/backend/app/worker.py +++ b/backend/app/worker.py @@ -30,8 +30,10 @@ from .database import SessionLocal, init_db from .domain import compute_phase, ensure_aware, now_utc from .models import AIPrediction, Match, UserPrediction from .scoring import load_scoring_data -from .services import football_data +from .services import baseball_data, baseball_details, football_data from .services.ai import MatchContext, PROVIDERS, ProviderUnavailable +from .services.baseball_fetch import fetch_baseball_results, fetch_baseball_schedule +from .services.baseball_sync import match_seq, sync_baseball_schedule from .services.email import EmailUnavailable, build_result_email, send_email from .services.grading import apply_result from .services.schedule_fetch import fetch_results, fetch_schedule @@ -43,6 +45,9 @@ log = logging.getLogger("triplepick.worker") # ── 0) 경기 일정 동기화 (외부 크롤링) ────────────────────── async def sync_schedule_job() -> None: + """월드컵(축구) 일정 동기화 — 기존 로직 그대로.""" + if "wc" not in settings.league_list: + return records = await fetch_schedule() if not records: return @@ -56,6 +61,34 @@ async def sync_schedule_job() -> None: await generate_ai_predictions() +async def sync_baseball_job() -> None: + """야구(kbo/mlb) 일정 동기화 + 프리뷰·순위 캐시 갱신.""" + inserted_any = False + for league in settings.league_list: + if league not in ("kbo", "mlb"): + continue + records = await fetch_baseball_schedule(league) + if not records: + continue + async with SessionLocal() as db: + result = await sync_baseball_schedule(db, league, records) + rows = ( + await db.execute( + select(Match).where( + Match.league == league, Match.result_outcome.is_(None) + ) + ) + ).scalars().all() + try: + await baseball_details.refresh_baseball_details(db, league, rows) + except Exception as e: # noqa: BLE001 + log.warning("baseball details(%s) 갱신 실패: %s", league, e) + inserted_any = inserted_any or bool(result.get("inserted")) + await tick_status() + if inserted_any: + await generate_ai_predictions() + + # ── 1) 상태 전이 ──────────────────────────────────────────── async def tick_status() -> None: async with SessionLocal() as db: @@ -83,11 +116,15 @@ async def generate_ai_predictions(only_missing: bool = True) -> None: 생성한다(직전에 실패/누락된 모델만 채워짐 — API 비용↓, 예측 안정). only_missing=False: 전부 재생성(덮어쓰기) — 수동 재생성 스크립트용. """ + if not settings.ai_enabled: + log.info("AI 예측 생성 스킵 — AI_ENABLED=false (로컬 테스트 모드)") + return async with SessionLocal() as db: - # 투표 오픈(킥오프 D-2)이 임박/도래한 미종료 경기 — 조 무관. - # lookahead_hours 만큼 선행 생성해 다음 생성 주기 전에 오픈할 경기도 미리 채운다 - # (1일 1회 생성 → 오픈 시점에 AI 가 비어 보이지 않도록). 더 먼 미래 경기는 제외. - horizon = now_utc() + timedelta(hours=settings.ai_generate_lookahead_hours) + # 킥오프가 lookahead(기본 22h) 이내로 다가온 미시작 경기만 생성. + # 야구 선발투수 예고(전날 저녁)가 나온 뒤 시점이라 데이터 품질이 좋다. + # 킥오프가 지난 경기는 제외(경기 중 생성 = 사후 예측 방지). 취소 경기 제외. + now = now_utc() + horizon = now + timedelta(hours=settings.ai_generate_lookahead_hours) rows = ( await db.execute( select(Match) @@ -95,17 +132,25 @@ async def generate_ai_predictions(only_missing: bool = True) -> None: .options(selectinload(Match.predictions)) ) ).scalars().all() - matches = [m for m in rows if ensure_aware(m.opens_at) <= horizon] + matches = [ + m for m in rows + if m.status != "cancelled" + and now < ensure_aware(m.kickoff_at) <= horizon + ] for m in matches: - # 실데이터 블록(폼·H2H·랭킹·WC결과) — 캐시만 읽음. 없으면 None(이름만). - data_block = await football_data.build_data_block(db, m) + # 실데이터 블록 — 리그별 소스(축구=API-Football 캐시, 야구=자체DB+프리뷰). + if m.league in ("kbo", "mlb"): + data_block = await baseball_data.build_baseball_data_block(db, m) + else: + data_block = await football_data.build_data_block(db, m) ctx = MatchContext( team_a=m.team_a_name, team_b=m.team_b_name, venue=m.venue, kickoff=m.kickoff_at.isoformat(), data_block=data_block, + league=m.league, ) for model, fn in PROVIDERS.items(): pred = next((p for p in m.predictions if p.model == model), None) @@ -133,7 +178,9 @@ async def generate_ai_predictions(only_missing: bool = True) -> None: pred.generated_at = now_utc() pred.source = "llm" log.info("AI %s → %s %d-%d", model, m.match_id, data["scoreA"], data["scoreB"]) - await db.commit() + # 경기 단위 커밋 — 수백 콜 도중 재시작/오류가 나도 진행분을 잃지 + # 않는다(전체 커밋이면 중단 시 API 비용을 다시 지출하게 됨). + await db.commit() # ── 2.1) 축구 데이터 수집 (예측 생성 전에 캐시를 채워둔다) ── @@ -149,7 +196,11 @@ async def refresh_football_data() -> None: return async with SessionLocal() as db: rows = ( - await db.execute(select(Match).where(Match.result_outcome.is_(None))) + await db.execute( + select(Match).where( + Match.result_outcome.is_(None), Match.league == "wc" + ) + ) ).scalars().all() # 가까운 경기부터 우선 수집(하루 예산 소진 시 먼 경기 팀은 다음 잡에서). matches = sorted(rows, key=lambda m: ensure_aware(m.kickoff_at)) @@ -178,7 +229,11 @@ async def settle_matches() -> None: ) async with SessionLocal() as db: pending = ( - await db.execute(select(Match).where(Match.result_outcome.is_(None))) + await db.execute( + select(Match).where( + Match.league == "wc", Match.result_outcome.is_(None) + ) + ) ).scalars().all() # 킥오프가 지난 경기만 (소스가 FINISHED 줄 수 있는 시점) due = [m.match_id for m in pending if ensure_aware(m.kickoff_at) <= now] @@ -188,6 +243,7 @@ async def settle_matches() -> None: finished = ( await db.execute( select(Match).where( + Match.league == "wc", Match.result_outcome.is_not(None), Match.finished_at.is_not(None), ) @@ -257,6 +313,50 @@ async def settle_matches() -> None: await maybe_send_result_emails() +# ── 2.6) 야구 결과 자동 정산 — (리그, KST 날짜, 팀쌍) 키 매칭 ── +async def settle_baseball() -> None: + _KST = timedelta(hours=9) + now = now_utc() + for league in settings.league_list: + if league not in ("kbo", "mlb"): + continue + async with SessionLocal() as db: + pending = ( + await db.execute( + select(Match).where( + Match.league == league, + Match.result_outcome.is_(None), + Match.status != "cancelled", + ) + ) + ).scalars().all() + due = [m.match_id for m in pending if ensure_aware(m.kickoff_at) <= now] + if not due: + continue + results = await fetch_baseball_results(league) + by_key: dict[tuple, tuple[int, int]] = {} + for r in results: + d, a2, b2, s = r["dateKst"], r["teamA"], r["teamB"], r.get("seq", 1) + by_key[(d, a2, b2, s)] = (r["scoreA"], r["scoreB"]) + by_key[(d, b2, a2, s)] = (r["scoreB"], r["scoreA"]) + + settled = 0 + async with SessionLocal() as db: + for mid in due: + m = await db.get(Match, mid) + if m is None or m.result_outcome is not None: + continue + d = (ensure_aware(m.kickoff_at) + _KST).strftime("%Y%m%d") + sc = by_key.get((d, m.team_a_code, m.team_b_code, match_seq(mid))) + if not sc: + continue + await apply_result(db, m, sc[0], sc[1]) + settled += 1 + log.info("settle(%s): %s 자동 정산 %d-%d", league, mid, sc[0], sc[1]) + if settled: + await maybe_send_result_emails() + + # ── 3) 결과 메일 ──────────────────────────────────────────── async def maybe_send_result_emails() -> None: async with SessionLocal() as db: @@ -350,13 +450,14 @@ def build_scheduler() -> AsyncIOScheduler: minute=settings.football_refresh_minute, id="football_refresh", ) - # AI 예측 생성: 매일 KST 00:05 (실 API) + # AI 예측 생성: 매시간 — 킥오프 22h 전에 든 경기를 놓치지 않게. + # only_missing 이라 이미 생성된 (경기×모델)은 API 호출 없이 건너뜀(비용 동일). sched.add_job( generate_ai_predictions, - "cron", - hour=settings.ai_generate_hour, - minute=settings.ai_generate_minute, + "interval", + hours=1, id="ai_generate", + next_run_time=now_utc(), ) # 결과 자동 정산 + 메일: 킥오프+Nh 지난 경기 스코어 수집·채점·발송 sched.add_job( @@ -366,29 +467,41 @@ def build_scheduler() -> AsyncIOScheduler: id="settle", next_run_time=now_utc(), ) + # 야구(kbo/mlb): 일정·프리뷰 동기화 매일 09:00 + AI 생성 직전 00:00 + sched.add_job( + sync_baseball_job, "cron", + hour=settings.schedule_sync_hour, minute=settings.schedule_sync_minute, + id="baseball_sync", + ) + sched.add_job(sync_baseball_job, "cron", hour=0, minute=0, id="baseball_sync_night") + # 야구 결과 정산 폴링 + sched.add_job( + settle_baseball, "interval", + seconds=settings.settle_tick_seconds, id="baseball_settle", + ) return sched async def main() -> None: load_scoring_data() # data/scoring.json → 배점·배제 대상 (채점·결과메일에 사용) await init_db() # 테이블 보장 (idempotent). 시드는 API 가 담당. - # 기동 시 1회: 일정 동기화 → AI 예측 생성 → 밀린 경기 자동 정산 + # 기동 시 1회: 일정 동기화(축구+야구) → AI 예측 생성 → 밀린 경기 자동 정산 await sync_schedule_job() + await sync_baseball_job() await refresh_football_data() # 축구 데이터 캐시 선채움 (키 없으면 no-op) await generate_ai_predictions() await settle_matches() + await settle_baseball() sched = build_scheduler() sched.start() log.info( "scheduling-server started — schedule sync daily %02d:%02d · status every %ss · " - "AI gen daily %02d:%02d %s · auto-settle on FINISHED (poll every %ss) · 맞춘사람만=%s", + "AI gen hourly (kickoff-%dh) · auto-settle on FINISHED (poll every %ss) · 맞춘사람만=%s", settings.schedule_sync_hour, settings.schedule_sync_minute, settings.status_tick_seconds, - settings.ai_generate_hour, - settings.ai_generate_minute, - settings.timezone, + settings.ai_generate_lookahead_hours, settings.settle_tick_seconds, settings.result_email_correct_only, ) diff --git a/docs/DATA_SOURCES.md b/docs/DATA_SOURCES.md new file mode 100644 index 0000000..026ef97 --- /dev/null +++ b/docs/DATA_SOURCES.md @@ -0,0 +1,38 @@ +# 야구(KBO/MLB) 데이터 소스 카탈로그 (실검증: 2026-07-21) + +## 소스 총평 + +| 소스 | 상태 | 요약 | +|---|---|---| +| **네이버 스포츠 API** (비공식) | ✅ KBO 주력 | 일정·결과·순위·프리뷰·박스스코어·문자중계(투구 단위)까지 전부. 키 불필요, UA 헤더만 | +| **MLB 공식 Stats API** | ✅ MLB 주력 | statsapi.mlb.com — 키 불필요·공식. 일정·결과·순위·예고선발·투구 단위 라이브 피드 | +| TheSportsDB | ⚠️ 유료 전용 | 무료 키 `123`은 next/past 조회당 1경기만(전 리그 공통). 유료 ~$10/월 | +| API-Sports 야구 | ❌ 미사용 | KBO=league 5 존재하나 무료는 2022~24 시즌만 | + +## 네이버 (api-gw.sports.naver.com) — KBO + +- 인증 없음, User-Agent 필수. gameId = `{yyyymmdd}{원정}{홈}0{연도}` +- 팀코드: HT(KIA) LG OB(두산) SS(삼성) LT(롯데) SK(SSG) KT NC WO(키움) HH(한화) +- `GET /schedule/games?...&categoryId=kbo&fromDate=&toDate=` — 일정·스코어·statusInfo("N회초/말")·cancel·suspended +- `GET /schedule/games/{id}/preview` — 예고 선발투수(구종·상대전적)·핫콜드존·시즌 상대전적·순위 +- `GET /schedule/games/{id}/record` — R/H/E/B·이닝별·타자/투수 박스스코어·교체·홈런일지 +- `GET /schedule/games/{id}/relay?inning=N` — currentGameState(투수/타자/볼카운트/주자)·라인업(seqno=교체)·투구단위 텍스트 +- `GET /stats/categories/kbo/seasons/{year}/teams` — 순위·승률·최근5·팀 공격/수비 풀스탯 +- 리스크: 비공식(약관·차단). 로컬/데모용 — 상용 전환 시 정식 소스 교체 + +## MLB 공식 (statsapi.mlb.com) — MLB + +- `GET /api/v1/schedule?sportId=1&startDate=&endDate=` (+`&hydrate=probablePitcher`) +- `GET /api/v1/standings?leagueId=103|104&season=` — AL/NL 지구 순위 +- `GET /api/v1.1/game/{gamePk}/feed/live` — linescore(B/S/O·주자)·offense(batter/onDeck/inHole/pitcher)·boxscore(타순·포지션)·plays(투구 단위) +- 팀 로고: `https://www.mlbstatic.com/team-logos/{teamId}.svg` (공식 CDN) +- 한글 팀명은 자체 매핑(`teams_baseball.py`), 선수명은 영문 + +## 구현 위치 (backend/app) + +- `teams_baseball.py` — KBO 10 + MLB 30 (한글·statsapi id·로고) +- `services/baseball_fetch.py` — 일정·결과 수집 (리그별 어댑터) +- `services/baseball_sync.py` — (리그, KST날짜, 팀쌍) 키 동기화 · 우천취소 제거 +- `services/baseball_details.py` — 프리뷰·순위 캐시(DataCache) + 라이브 필드 뷰 프록시(15s TTL) +- `services/baseball_data.py` — AI 프롬프트 데이터 블록 (자체 DB 폼 + 캐시) +- 호출량: 동기화 하루 2회 + 정산 5분 폴링 + 라이브는 경기당 최대 4콜/분(캐시 상한) diff --git a/docs/SCORING.md b/docs/SCORING.md index 00b4ba1..97cef24 100644 --- a/docs/SCORING.md +++ b/docs/SCORING.md @@ -44,13 +44,28 @@ - 무승부: 승패 적중이면서 득실차(=0) 일치 → 정확이 아니면 **근접(3점)**. - **배점 수치는 황 본부장 확정 후 dev 전달**(회의 05:48). 위는 기본 제안값. +### 야구(KBO/MLB) 판정 완화 (2026-07-22) + +야구는 득점 범위가 넓어(0~12+) 축구 기준 그대로면 정확/근접/부분이 거의 나오지 +않는다. **등급 체계·배점은 리그 공통**으로 유지하고, 야구만 판정에 허용 오차 +±1(`scoring.BASEBALL_TOLERANCE`)을 둔다. 리그별 시상이므로 리그 간 점수 크기 +차이는 문제되지 않는다. + +| 등급 | 야구 조건 | 포인트 | +|---|---|---| +| 근접 | 승패 적중 + **득실차 오차 ≤1** | 3 | +| 부분 | **한 팀 득점 오차 ≤1** | 1 | + +(정확·승패·빗나감 조건은 축구와 동일) + ### 채점 알고리즘 (결정론적) ``` +tol = 야구 ? 1 : 0 1) pick.scoreA == result.scoreA && pick.scoreB == result.scoreB → 정확(5) 2) else if pick.outcome == result.outcome: - (pick.scoreA - pick.scoreB) == (result.scoreA - result.scoreB) ? 근접(3) : 승패(2) -3) else if pick.scoreA == result.scoreA || pick.scoreB == result.scoreB → 부분(1) + |득실차 오차| <= tol ? 근접(3) : 승패(2) +3) else if |pick.scoreA - result.scoreA| <= tol || |pick.scoreB - result.scoreB| <= tol → 부분(1) 4) else → 0 ``` diff --git a/frontend/public/assets/teams/kbo/hh.png b/frontend/public/assets/teams/kbo/hh.png new file mode 100644 index 0000000..3f07166 Binary files /dev/null and b/frontend/public/assets/teams/kbo/hh.png differ diff --git a/frontend/public/assets/teams/kbo/ht.png b/frontend/public/assets/teams/kbo/ht.png new file mode 100644 index 0000000..3d67894 Binary files /dev/null and b/frontend/public/assets/teams/kbo/ht.png differ diff --git a/frontend/public/assets/teams/kbo/kt.png b/frontend/public/assets/teams/kbo/kt.png new file mode 100644 index 0000000..60d00bc Binary files /dev/null and b/frontend/public/assets/teams/kbo/kt.png differ diff --git a/frontend/public/assets/teams/kbo/lg.png b/frontend/public/assets/teams/kbo/lg.png new file mode 100644 index 0000000..d3648e7 Binary files /dev/null and b/frontend/public/assets/teams/kbo/lg.png differ diff --git a/frontend/public/assets/teams/kbo/lt.png b/frontend/public/assets/teams/kbo/lt.png new file mode 100644 index 0000000..64bdc3c Binary files /dev/null and b/frontend/public/assets/teams/kbo/lt.png differ diff --git a/frontend/public/assets/teams/kbo/nc.png b/frontend/public/assets/teams/kbo/nc.png new file mode 100644 index 0000000..ebd4fba Binary files /dev/null and b/frontend/public/assets/teams/kbo/nc.png differ diff --git a/frontend/public/assets/teams/kbo/ob.png b/frontend/public/assets/teams/kbo/ob.png new file mode 100644 index 0000000..a6af696 Binary files /dev/null and b/frontend/public/assets/teams/kbo/ob.png differ diff --git a/frontend/public/assets/teams/kbo/sk.png b/frontend/public/assets/teams/kbo/sk.png new file mode 100644 index 0000000..36aabc9 Binary files /dev/null and b/frontend/public/assets/teams/kbo/sk.png differ diff --git a/frontend/public/assets/teams/kbo/ss.png b/frontend/public/assets/teams/kbo/ss.png new file mode 100644 index 0000000..dff8de6 Binary files /dev/null and b/frontend/public/assets/teams/kbo/ss.png differ diff --git a/frontend/public/assets/teams/kbo/wo.png b/frontend/public/assets/teams/kbo/wo.png new file mode 100644 index 0000000..676b8d0 Binary files /dev/null and b/frontend/public/assets/teams/kbo/wo.png differ diff --git a/frontend/src/components/Arena.tsx b/frontend/src/components/Arena.tsx index 5d404ad..9077b96 100644 --- a/frontend/src/components/Arena.tsx +++ b/frontend/src/components/Arena.tsx @@ -101,6 +101,9 @@ export default function Arena({ const rightShort = flip ? aShort : bShort; // 마스코트(사자·버튼 양옆 캐릭터)는 모든 경기에서 노출. const [outcome, setOutcome] = useState("DRAW"); + // 야구(KBO/MLB): 스코어 상한 25, 마스코트 미노출 + const isBaseball = match.league === "kbo" || match.league === "mlb"; + const maxScore = isBaseball ? 25 : 9; const [scoreA, setScoreA] = useState(0); const [scoreB, setScoreB] = useState(0); const [step, setStep] = useState("form"); @@ -227,7 +230,8 @@ export default function Arena({ {/* ===== 레이어드 화이트 시트 (002) ===== */}
- {/* AI 박스 오른쪽 위 사자 — 모든 경기 노출 */} + {/* AI 박스 오른쪽 위 사자 — 축구 전용 */} + {!isBaseball && (
+ )}

{t.aiBattle}

+ {predictions.length === 0 && ( +

+ {lang === "en" + ? "AI picks are generated daily at midnight — check back soon." + : "AI 예측은 매일 자정에 생성됩니다. 잠시 후 다시 확인해주세요."} +

+ )}
{predictions.map((p) => { const wp = winProb(match, p); @@ -328,15 +340,15 @@ export default function Arena({
{flip ? ( <> - + : - + ) : ( <> - + : - + )}
@@ -378,14 +390,14 @@ export default function Arena({ )} {/* 투표하기 버튼 — 모든 경기에서 양옆에 캐릭터(자동 말풍선) */}
- + />} - + />}
)} @@ -554,8 +566,8 @@ export default function Arena({
)} - {/* ===== 누적 랭킹 TOP 10 (골드 카드 위 · 폴딩) — 랭킹→상금 도전 흐름 ===== */} - + {/* ===== 누적 랭킹 TOP 10 (골드 카드 위 · 폴딩) — 현재 리그 기준 집계 ===== */} + {/* ===== 골드 상금 (003) ===== */}
@@ -599,9 +611,9 @@ export default function Arena({ {( [ [t.rulesExact, 5], - [t.rulesCloseGrade, 3], + [isBaseball ? t.rulesCloseGradeBB : t.rulesCloseGrade, 3], [t.rulesOutcome, 2], - [t.rulesPartial, 1], + [isBaseball ? t.rulesPartialBB : t.rulesPartial, 1], [t.rulesMiss, 0], ] as [string, number][] ).map(([label, pt]) => ( @@ -670,11 +682,13 @@ function Stepper({ value, onChange, disabled, + max = 9, }: { label: string; value: number; onChange: (n: number) => void; disabled?: boolean; + max?: number; }) { return (
@@ -687,7 +701,7 @@ function Stepper({ {label}
- = 9} onClick={() => onChange(Math.min(9, value + 1))}> + = max} onClick={() => onChange(Math.min(max, value + 1))}> + diff --git a/frontend/src/components/Hero.tsx b/frontend/src/components/Hero.tsx index 78e75ec..42bf15b 100644 --- a/frontend/src/components/Hero.tsx +++ b/frontend/src/components/Hero.tsx @@ -2,6 +2,7 @@ import { Link, useNavigate } from "react-router-dom"; import ShareButton from "./ShareButton"; import LangSwitch from "./LangSwitch"; import { type Lang, dict } from "@/lib/i18n"; +import type { League } from "@/lib/types"; type Share = { url: string; title: string; text: string }; @@ -10,10 +11,12 @@ export default function Hero({ back = false, share, lang = "ko", + league = "wc", }: { back?: boolean; share?: Share; lang?: Lang; + league?: League; }) { const t = dict(lang); const navigate = useNavigate(); @@ -54,8 +57,55 @@ export default function Hero({
- {t.heroPill} + {league === "kbo" + ? lang === "en" + ? "AI-powered 2026 KBO League game predictions" + : "AI와 함께하는 2026 프로야구 KBO 승부예측 챌린지" + : league === "mlb" + ? lang === "en" + ? "AI-powered 2026 MLB game predictions" + : "AI와 함께하는 2026 MLB 승부예측 챌린지" + : t.heroPill}
+ ); } + +// 리그 전환 탭 — 단일 앱 내 전환. 탭 클릭 시 해당 리그 대시보드로 이동. +function LeagueTabs({ lang, current }: { lang: Lang; current: League }) { + const navigate = useNavigate(); + const tabs: { key: League; label: string }[] = [ + { key: "wc", label: lang === "en" ? "World Cup" : "월드컵" }, + { key: "kbo", label: "KBO" }, + { key: "mlb", label: "MLB" }, + ]; + return ( +
+
+ {tabs.map((tab) => + tab.key === current ? ( + + {tab.label} + + ) : ( + + ), + )} +
+
+ ); +} diff --git a/frontend/src/components/LiveField.tsx b/frontend/src/components/LiveField.tsx new file mode 100644 index 0000000..ff26786 --- /dev/null +++ b/frontend/src/components/LiveField.tsx @@ -0,0 +1,295 @@ +import { useEffect, useState } from "react"; +import type { Lang } from "@/lib/i18n"; +import { timeOnly } from "@/lib/format"; +import { getLive, type LiveData } from "@/lib/api"; +import type { Match } from "@/lib/types"; + +// 라이브 필드 뷰 — 수비 배치 다이아몬드 + 현재 타자/투수 + B/S/O + 주자 + 대기타석. +// phase === "live" 일 때 20초 폴링. (?debugLive=1 로 종료 경기에서도 강제 표시) + +// 포지션 → 필드 위 좌표(%) — left/top (SVG viewBox 100×70 과 동일 비율) +const POS_XY: Record = { + 포수: [50, 90], + "1루수": [71, 55], + "2루수": [61, 39], + 유격수: [39, 39], + "3루수": [29, 55], + 좌익수: [20, 26], + 중견수: [50, 14], + 우익수: [80, 26], +}; + +// 필드 SVG 상 베이스 좌표 (viewBox 0 0 100 70) — 1루·2루·3루 +const BASE_XY: [number, number][] = [ + [63.5, 42.5], + [50, 29], + [36.5, 42.5], +]; + +function NameChip({ + name, + sub, + accent = false, + x, + y, +}: { + name: string; + sub?: string; + accent?: boolean; + x: number; + y: number; +}) { + return ( +
+
+ {name} +
+ {sub && ( +
{sub}
+ )} +
+ ); +} + +function Dot({ on, color }: { on: boolean; color: string }) { + return ( + + ); +} + +// 구장 SVG — 부채꼴 외야 + 파울라인 + 흙 내야 + 마운드 + 베이스(주자 점등). +// 경기 전(빈 구장)·경기 중·종료 스냅샷 모두 공용. +function BallparkSvg({ bases }: { bases?: boolean[] }) { + return ( + + + + + + + + {BASE_XY.map(([cx, cy], i) => ( + + ))} + + + ); +} + +export default function LiveField({ match, lang = "ko" }: { match: Match; lang?: Lang }) { + const isLive = match.phase === "live"; + // 경기 중엔 20초 폴링, 그 외(경기 전·종료)는 1회 로드. + // 경기 전엔 중계 미게시(available=false)라도 빈 구장 + "경기 전"으로 표시. + const [data, setData] = useState(null); + + const isBaseball = match.league === "kbo" || match.league === "mlb"; + + useEffect(() => { + if (!isBaseball) return; + let alive = true; + const load = () => + getLive(match.matchId) + .then((d) => alive && setData(d)) + .catch(() => {}); + load(); + if (!isLive) return () => { alive = false; }; + const id = setInterval(load, 20_000); + return () => { + alive = false; + clearInterval(id); + }; + }, [isBaseball, isLive, match.matchId]); + + // 야구 전용 위젯 — 축구(월드컵)에서는 렌더하지 않음 + if (!isBaseball) return null; + + // 중계 데이터 없음 (경기 전 또는 시작 직후) — 빈 구장 + 상태 라벨 + if (!data?.available) { + if (match.phase === "finished") return null; // 종료인데 기록 없음 — 표시할 것 없음 + return ( +
+
+
+ + {lang === "en" ? "FIELD" : "필드"} + + + {timeOnly(match.kickoffKst)} KST + +
+
+ +
+ + {isLive + ? lang === "en" + ? "Starting soon…" + : "경기 시작 대기 중" + : lang === "en" + ? "Pre-game · lineups TBA" + : "경기 전 · 라인업 발표 전"} + +
+
+
+
+ ); + } + + const half = data.half === "B" ? (lang === "en" ? "Bot" : "말") : lang === "en" ? "Top" : "초"; + const battingTeam = data.half === "B" ? match.teamB : match.teamA; + const num = (v: unknown) => Number(v ?? 0) || 0; + + return ( +
+
+ {/* 헤더: 이닝·스코어·B/S/O */} +
+
+ {isLive ? ( + <> + + + + + + LIVE · {data.inn} + {lang === "en" ? ` (${half})` : `회${half}`} + + + ) : ( + + {lang === "en" ? "FINAL" : "경기 종료"} · {data.inn} + {lang === "en" ? ` (${half})` : `회${half}`} + + )} +
+ + {match.teamA.shortName} {num(data.score?.away)} + : + {num(data.score?.home)} {match.teamB.shortName} + +
+ + {/* B/S/O + 주자 다이아몬드 */} +
+
+
+ B{" "} + {[0, 1, 2].map((i) => ( + i} color="#4ade80" /> + ))} +
+
+ S{" "} + {[0, 1].map((i) => ( + i} color="#facc15" /> + ))} +
+
+ O{" "} + {[0, 1].map((i) => ( + i} color="#f87171" /> + ))} +
+
+ +
+
+ {lang === "en" ? "AB" : "타석"}{" "} + {battingTeam.shortName} +
+ {data.vsRecord &&
{data.vsRecord}
} +
+
+ + {/* 필드: 구장 SVG + 수비 배치/투수/타자 칩 */} +
+ + {data.defense?.map((f) => { + const xy = POS_XY[f.pos ?? ""]; + if (!xy) return null; + return ; + })} + {data.pitcher && ( + + )} + {data.batter && ( + + )} +
+ + {/* 대기타석 */} + {data.batter && data.offenseLineup && ( +
+ {lang === "en" ? "Next" : "대기타석"} + {data.offenseLineup + .filter((b) => { + const cur = data.batter!.order ?? 0; + const diff = ((b.order ?? 0) - cur + 9) % 9; + return diff >= 1 && diff <= 3; + }) + .sort((a, b) => { + const cur = data.batter!.order ?? 0; + return (((a.order ?? 0) - cur + 9) % 9) - (((b.order ?? 0) - cur + 9) % 9); + }) + .map((b) => ( + + {b.order}. {b.name} + {b.sub ? " ↺" : ""} + + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/MatchupHUD.tsx b/frontend/src/components/MatchupHUD.tsx index 9a866c8..1e64931 100644 --- a/frontend/src/components/MatchupHUD.tsx +++ b/frontend/src/components/MatchupHUD.tsx @@ -5,17 +5,39 @@ import BallIcon from "./BallIcon"; import TeamFlag from "./TeamFlag"; import WaveStrip from "./WaveStrip"; +// 순위 캐시 → "3위 · 52승37패 (0.584)" 한 줄 (야구) +function standingLine( + st: { rank?: number | null; w?: number; l?: number; wra?: string | number } | null | undefined, + lang: Lang, +): string | null { + if (!st?.rank) return null; + const rank = lang === "en" ? `#${st.rank}` : `${st.rank}위`; + if (st.w == null || st.l == null) return rank; + const rec = lang === "en" ? `${st.w}W ${st.l}L` : `${st.w}승${st.l}패`; + return `${rank} · ${rec}${st.wra ? ` (${st.wra})` : ""}`; +} + export default function MatchupHUD({ match, lang = "ko" }: { match: Match; lang?: Lang }) { const t = dict(lang); const { group } = match; const finished = !!match.result; - // 한국을 항상 왼쪽에 표시(요청). 데이터/투표는 원본 A/B 프레임 유지, 화면 좌우만 교체. - const flip = match.teamB.code === "KOR" && match.teamA.code !== "KOR"; + const isBaseball = match.league === "kbo" || match.league === "mlb"; + // 축구: 한국을 항상 왼쪽에. 야구: A=원정(좌), B=홈(우) 고정. + const flip = !isBaseball && match.teamB.code === "KOR" && match.teamA.code !== "KOR"; const left = flip ? match.teamB : match.teamA; const right = flip ? match.teamA : match.teamB; const leftScore = flip ? match.result?.scoreB : match.result?.scoreA; const rightScore = flip ? match.result?.scoreA : match.result?.scoreB; - // 마스코트(파도타기 등)는 모든 경기에서 노출. + + const ex = match.extras; + const spL = ex?.starterA; + const spR = ex?.starterB; + const stL = standingLine(ex?.standings?.a, lang); + const stR = standingLine(ex?.standings?.b, lang); + const vs = ex?.seasonVs; + const hasVs = !!vs && vs.aWin != null && vs.bWin != null; + const flagCls = isBaseball ? "mx-auto h-[72px] w-[72px]" : "mx-auto h-[68px] w-[104px]"; + return (
{/* 대결 카드 (녹색 글로우 보더) */} @@ -23,10 +45,12 @@ export default function MatchupHUD({ match, lang = "ko" }: { match: Match; lang? className="relative rounded-3xl border-2 border-[var(--green)] bg-[#171b21] p-5" style={{ boxShadow: "0 0 28px rgba(74,255,160,0.35), inset 0 0 24px rgba(74,255,160,0.06)" }} > - {/* 박스 오른쪽 상단 파도타기 (응원 5마리) — 모든 경기 노출 */} -
- -
+ {/* 파도타기 마스코트 — 축구 전용 (야구는 미노출) */} + {!isBaseball && ( +
+ +
+ )} {/* 헤더: 브랜드 아이콘 + 팀명 + 일시 */}
@@ -38,15 +62,22 @@ export default function MatchupHUD({ match, lang = "ko" }: { match: Match; lang?
{kickoffDisplay(match.kickoffKst, lang)} ·{" "} - {group ? t.group(group) : tRound(match.roundLabel, lang)} + {!isBaseball && group ? t.group(group) : tRound(match.roundLabel, lang)} +
+
+ {match.venue} + {isBaseball && ( + + {" "}· {lang === "en" ? "Home: " : "홈 "}{teamShort(right, lang)} + + )}
-
{match.venue}
- {/* 국기 + 라이트닝 VS (종료 시 최종 스코어) */} + {/* 국기/로고 + 라이트닝 VS (종료 시 최종 스코어) */}
- +
{finished && match.result ? ( @@ -64,9 +95,78 @@ export default function MatchupHUD({ match, lang = "ko" }: { match: Match; lang? )}
- +
+ {/* 야구 정보 패널: 선발 매치업 · 순위 · 시즌 상대전적 (캐시 있을 때만) */} + {isBaseball && (spL || spR || stL || stR || hasVs) && ( +
+ {(spL || spR) && ( +
+
+
+ {spL?.name ?? (lang === "en" ? "TBA" : "미정")} + {spL?.hitType ? ( + {spL.hitType.slice(0, 2)} + ) : null} +
+ {spL?.era != null && ( +
+ ERA {spL.era} + {spL.w != null ? ` · ${spL.w}${lang === "en" ? "W" : "승"}${spL.l ?? 0}${lang === "en" ? "L" : "패"}` : ""} +
+ )} +
+ + {lang === "en" ? "Starting P" : "선발 투수"} + +
+
+ {spR?.hitType ? ( + {spR.hitType.slice(0, 2)} + ) : null} + {spR?.name ?? (lang === "en" ? "TBA" : "미정")} +
+ {spR?.era != null && ( +
+ ERA {spR.era} + {spR.w != null ? ` · ${spR.w}${lang === "en" ? "W" : "승"}${spR.l ?? 0}${lang === "en" ? "L" : "패"}` : ""} +
+ )} +
+
+ )} + + {(stL || stR) && ( +
+ {stL ?? "—"} + {lang === "en" ? "STANDINGS" : "순위"} + {stR ?? "—"} +
+ )} + + {hasVs && ( +
+
+ {vs!.aWin}{lang === "en" ? "W" : "승"} + + {lang === "en" ? "SEASON H2H" : "시즌 상대전적"} + {vs!.draw ? ` · ${vs!.draw}${lang === "en" ? "D" : "무"}` : ""} + + {vs!.bWin}{lang === "en" ? "W" : "승"} +
+
+
+
+
+
+ )} +
+ )} + {finished && (
{t.matchEnded} diff --git a/frontend/src/components/RankingBoard.tsx b/frontend/src/components/RankingBoard.tsx index 3c934ee..09eff00 100644 --- a/frontend/src/components/RankingBoard.tsx +++ b/frontend/src/components/RankingBoard.tsx @@ -9,9 +9,11 @@ import type { RankingEntry, RankingTab } from "@/lib/types"; export default function RankingBoard({ lang = "ko", defaultOpen = false, + league = "", }: { lang?: Lang; defaultOpen?: boolean; + league?: string; // 리그별 집계 ("" = 전체 통합) }) { const t = dict(lang); const [open, setOpen] = useState(defaultOpen); @@ -24,7 +26,7 @@ export default function RankingBoard({ if (!open) return; let alive = true; setLoaded(false); - fetchRanking(tab) + fetchRanking(tab, league) .then((r) => { if (!alive) return; setRows(r); @@ -38,7 +40,7 @@ export default function RankingBoard({ return () => { alive = false; }; - }, [open, tab]); + }, [open, tab, league]); return (
diff --git a/frontend/src/components/ScheduleBoard.tsx b/frontend/src/components/ScheduleBoard.tsx index 4393bad..9c6d661 100644 --- a/frontend/src/components/ScheduleBoard.tsx +++ b/frontend/src/components/ScheduleBoard.tsx @@ -1,9 +1,11 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { Link } from "react-router-dom"; import { timeOnly, dateKey } from "@/lib/format"; +import { getLive, type LiveData } from "@/lib/api"; import { type Lang, dict, teamShort, roundLabel as tRound, dayName, monthEn } from "@/lib/i18n"; -import type { Match, MatchPhase } from "@/lib/types"; +import type { League, Match, MatchPhase } from "@/lib/types"; import TeamFlag from "./TeamFlag"; +import StandingsTable from "./StandingsTable"; // 칩용 짧은 날짜: "6.12 (금)" / "Jun 12 (Fri)" function shortDate(iso: string, lang: Lang): string { @@ -20,22 +22,27 @@ const PHASE_CLS: Record = { locked: "border-[var(--line-d)] text-white/45", live: "border-[#FF5B5B] text-[#FF5B5B]", finished: "border-white/15 text-white/55", + cancelled: "border-white/10 text-white/35", }; // 토너먼트 탭 라운드 칩 정렬(대진 순서) const KO_ROUND_ORDER = ["32강", "16강", "8강", "4강", "3·4위전", "결승"]; const isGroupLetter = (s: string) => /^[A-Z]$/.test(s); -type Tab = "date" | "group" | "tournament"; +type Tab = "date" | "group" | "tournament" | "team" | "rank"; export default function ScheduleBoard({ matches, lang = "ko", + league = "wc", }: { matches: Match[]; lang?: Lang; + league?: League; }) { const t = dict(lang); + // 야구(KBO/MLB)는 조/토너먼트 대신 날짜별/팀별 탭 제공 + const isBaseball = league !== "wc"; // 날짜 목록 (정렬) const dates = useMemo( @@ -63,11 +70,34 @@ export default function ScheduleBoard({ // 시작 탭: 토너먼트가 시작됐으면 '토너먼트', 아니면 '날짜별'. // 단, 직전 세션 선택이 있으면 우선(상세 진입 후 뒤로가기 복원). - const [tab, setTab] = useState(() => { + const [tab, setTabRaw] = useState(() => { const saved = sessionStorage.getItem("tp.sched.tab"); - if (saved === "date" || saved === "group" || saved === "tournament") return saved; + if ( + saved === "date" || saved === "group" || saved === "tournament" || + saved === "team" || saved === "rank" + ) + return saved; + if (isBaseball) return "date"; return rounds.length > 0 ? "tournament" : "date"; }); + // 리그에 없는 탭이 저장돼 있으면 날짜별로 강등 (야구↔축구 전환 시) + const effTab: Tab = isBaseball + ? (tab === "team" || tab === "rank" ? tab : "date") + : (tab === "team" || tab === "rank" ? "date" : tab); + const setTab = setTabRaw; + + // 팀별 탭: 등장 팀 목록 (짧은 이름 가나다/알파벳 순) + const teams = useMemo(() => { + if (!isBaseball) return []; + const byCode = new Map(); + for (const m of matches) { + byCode.set(m.teamA.code, m.teamA); + byCode.set(m.teamB.code, m.teamB); + } + return [...byCode.values()].sort((a, b) => + teamShort(a, lang).localeCompare(teamShort(b, lang), lang === "en" ? "en" : "ko"), + ); + }, [matches, isBaseball, lang]); // 기본 선택: 오늘(없으면 첫 날짜) / A조 / 현재 진행 라운드. // 단, 직전 세션 선택을 sessionStorage 에 보관 → 상세 진입 후 뒤로가기 시 그대로 복원(오늘로 초기화 X). @@ -87,6 +117,9 @@ export default function ScheduleBoard({ if (saved && rounds.includes(saved)) return saved; return currentRound; }); + const [selTeam, setSelTeam] = useState( + () => sessionStorage.getItem("tp.sched.team") ?? "", + ); // 선택 변경 시 보관(다음 진입/뒤로가기에서 복원) useEffect(() => { @@ -101,29 +134,58 @@ export default function ScheduleBoard({ useEffect(() => { sessionStorage.setItem("tp.sched.tab", tab); }, [tab]); + useEffect(() => { + if (selTeam) sessionStorage.setItem("tp.sched.team", selTeam); + }, [selTeam]); + + useEffect(() => { + if (dates.length && !dates.includes(selDate)) { + setSelDate(dates.includes(todayKey) ? todayKey : dates[0]); + } + }, [dates, selDate, todayKey]); + // 리그 전환 시 목록에 없는 팀이면 첫 팀으로 + useEffect(() => { + if (teams.length && !teams.some((tm) => tm.code === selTeam)) { + setSelTeam(teams[0].code); + } + }, [teams, selTeam]); const shown = useMemo(() => { let list: Match[]; - if (tab === "date") list = matches.filter((m) => dateKey(m.kickoffKst) === selDate); - else if (tab === "group") list = matches.filter((m) => !!m.group && m.group === selGroup); + if (effTab === "date") list = matches.filter((m) => dateKey(m.kickoffKst) === selDate); + else if (effTab === "team") + list = matches.filter((m) => m.teamA.code === selTeam || m.teamB.code === selTeam); + else if (effTab === "group") list = matches.filter((m) => !!m.group && m.group === selGroup); else list = matches.filter((m) => m.roundLabel === selRound); // 토너먼트 return [...list].sort( (a, b) => new Date(a.kickoffKst).getTime() - new Date(b.kickoffKst).getTime(), ); - }, [matches, tab, selDate, selGroup, selRound]); + }, [matches, effTab, selDate, selGroup, selRound, selTeam]); const tabLabel = (k: Tab) => k === "date" ? lang === "en" ? "By date" : "날짜별" - : k === "group" + : k === "team" ? lang === "en" - ? "Groups" - : "조별리그" - : lang === "en" - ? "Tournament" - : "토너먼트"; + ? "By team" + : "팀별" + : k === "rank" + ? lang === "en" + ? "Standings" + : "순위" + : k === "group" + ? lang === "en" + ? "Groups" + : "조별리그" + : lang === "en" + ? "Tournament" + : "토너먼트"; + + const leagueTabs: Tab[] = isBaseball + ? ["date", "team", "rank"] + : ["date", "group", "tournament"]; return (
@@ -132,14 +194,14 @@ export default function ScheduleBoard({ {t.schedGuide}
- {/* 탭: 날짜별 / 조별리그 / 토너먼트 */} + {/* 탭: 축구 = 날짜별/조별리그/토너먼트 · 야구 = 날짜별/팀별 */}
- {(["date", "group", "tournament"] as Tab[]).map((k) => ( + {leagueTabs.map((k) => (
+ {/* 순위 탭: 칩/카드 대신 순위표 */} + {effTab === "rank" && } + {/* 칩: 날짜 / 조 / 라운드 — 단일 줄 가로 스크롤 + 양 끝 화살표(PC에서 마우스로 넘김, 끝 도달 시 숨김) */} - - {tab === "date" + {effTab !== "rank" && ( + + {effTab === "date" ? dates.map((d) => ( setSelDate(d)}> {shortDate(d, lang)} )) - : tab === "group" - ? groups.map((g) => ( - setSelGroup(g)}> - {lang === "en" ? `Group ${g}` : `${g}조`} + : effTab === "team" + ? teams.map((tm) => ( + setSelTeam(tm.code)}> + + + {teamShort(tm, lang)} + )) - : rounds.map((r) => ( - setSelRound(r)}> - {tRound(r, lang)} - - ))} + : effTab === "group" + ? groups.map((g) => ( + setSelGroup(g)}> + {lang === "en" ? `Group ${g}` : `${g}조`} + + )) + : rounds.map((r) => ( + setSelRound(r)}> + {tRound(r, lang)} + + ))} + )} {/* 경기 카드 */} + {effTab !== "rank" && (
{shown.length === 0 && (

- {tab === "tournament" + {effTab === "tournament" ? lang === "en" ? "Bracket is set after the group stage." : "토너먼트 대진은 조별리그 종료 후 확정됩니다" @@ -184,9 +261,10 @@ export default function ScheduleBoard({

)} {shown.map((m) => ( - + ))}
+ )}
); } @@ -301,44 +379,99 @@ function Chip({ ); } -function MatchCard({ match, lang }: { match: Match; lang: Lang }) { +// 팀명 옆 순위 배지 (야구 — 순위 캐시 없으면 렌더 안 함) +function RankBadge({ rank, lang = "ko" }: { rank?: number | null; lang?: Lang }) { + if (!rank) return null; + return ( + + {lang === "en" ? `#${rank}` : `${rank}위`} + + ); +} + +function MatchCard({ + match, + lang, + showDate = false, +}: { + match: Match; + lang: Lang; + showDate?: boolean; +}) { const t = dict(lang); const phase = match.phase; const finished = !!match.result; + const isBaseball = match.league === "kbo" || match.league === "mlb"; + + // 야구 진행 중: 회차·스코어 표시 (서버 15초 캐시라 부담 없음, 30초 폴링) + const [live, setLive] = useState(null); + useEffect(() => { + if (phase !== "live" || !isBaseball) return; + let alive = true; + const load = () => + getLive(match.matchId) + .then((d) => { + if (alive && d.available) setLive(d); + }) + .catch(() => {}); + load(); + const id = setInterval(load, 30_000); + return () => { + alive = false; + clearInterval(id); + }; + }, [match.matchId, phase, isBaseball]); + // "6회초" / "Top 6" + const inning = + live?.inn != null + ? lang === "en" + ? `${live.half === "B" ? "Bot" : "Top"} ${live.inn}` + : `${live.inn}회${live.half === "B" ? "말" : "초"}` + : null; // 한국을 항상 왼쪽에 표시(상세 페이지와 동일 규칙). 데이터는 원본 A/B 유지, 화면 좌우만 교체. - const flip = match.teamB.code === "KOR" && match.teamA.code !== "KOR"; + const flip = !isBaseball && match.teamB.code === "KOR" && match.teamA.code !== "KOR"; const left = flip ? match.teamB : match.teamA; const right = flip ? match.teamA : match.teamB; const leftScore = flip ? match.result?.scoreB : match.result?.scoreA; const rightScore = flip ? match.result?.scoreA : match.result?.scoreB; + const flagCls = isBaseball ? "h-7 w-7 shrink-0" : "h-6 w-9 shrink-0"; // 비투표(타 조)는 클릭 비활성, 투표 가능 조(A)는 상세로 이동 const inner = ( <>
+ {showDate && ( + {shortDate(dateKey(match.kickoffKst), lang)} + )} {timeOnly(match.kickoffKst)} KST - {/* 조별리그 seed 의 부가 라벨(예: 개막전)만 여기에. 녹아웃 라운드는 우측에 표시. */} - {match.group && match.roundLabel ? <> · {tRound(match.roundLabel, lang)} : null} + {isBaseball && match.venue ? ( + · {match.venue} + ) : match.group && match.roundLabel ? ( + <> · {tRound(match.roundLabel, lang)} + ) : null} - - {match.group - ? lang === "en" - ? `Group ${match.group}` - : `${match.group}조` - : tRound(match.roundLabel, lang)} - + {!isBaseball && ( + + {match.group + ? lang === "en" + ? `Group ${match.group}` + : `${match.group}조` + : tRound(match.roundLabel, lang)} + + )} - {t.phase[phase]} + {phase === "live" && inning ? inning : t.phase[phase]}
- + {teamShort(left, lang)} + {isBaseball && }
{match.result ? ( @@ -346,17 +479,35 @@ function MatchCard({ match, lang }: { match: Match; lang: Lang }) { : {rightScore} + ) : phase === "live" && live?.score ? ( + // 진행 중 실시간 스코어 (A=원정, B=홈) + + {live.score.away ?? 0} + : + {live.score.home ?? 0} + ) : ( VS )}
+ {isBaseball && } {teamShort(right, lang)} - +
- {/* 투표 가능 조만: AI 픽 갈림 + 참여수 + 이동 화살표 */} - {match.votable && ( + {/* 야구: 선발 매치업 (프리뷰 캐시 있을 때만) */} + {isBaseball && !match.result && (match.extras?.starterA || match.extras?.starterB) && ( +
+ {lang === "en" ? "SP" : "선발"}{" "} + {match.extras?.starterA?.name ?? "?"} + vs + {match.extras?.starterB?.name ?? "?"} +
+ )} + + {/* 투표 가능 조만: AI 픽 갈림 + 참여수 + 이동 화살표 (취소 경기는 숨김) */} + {match.votable && phase !== "cancelled" && (
{t.aiPicks}{" "} @@ -372,6 +523,10 @@ function MatchCard({ match, lang }: { match: Match; lang: Lang }) { ); const base = "block rounded-2xl border-2 bg-[#171b21] p-4"; + // 취소 경기: 클릭 불가·저채도 표시 전용 + if (phase === "cancelled") { + return
{inner}
; + } if (match.votable && !finished) { return ( = { + ALE: { ko: "AL 동부", en: "AL East" }, + ALC: { ko: "AL 중부", en: "AL Central" }, + ALW: { ko: "AL 서부", en: "AL West" }, + NLE: { ko: "NL 동부", en: "NL East" }, + NLC: { ko: "NL 중부", en: "NL Central" }, + NLW: { ko: "NL 서부", en: "NL West" }, +}; + +function fmtWra(v: StandingRow["wra"]): string { + if (v == null || v === "") return "-"; + const n = typeof v === "number" ? v : parseFloat(v); + return Number.isNaN(n) ? String(v) : n.toFixed(3); +} + +function fmtGb(v: StandingRow["gb"]): string { + if (v == null || v === "" || v === "-") return "-"; + const n = Number(v); + return !Number.isNaN(n) && n === 0 ? "-" : String(v); +} + +export default function StandingsTable({ + league, + lang = "ko", +}: { + league: string; + lang?: Lang; +}) { + const [data, setData] = useState(null); + const [loaded, setLoaded] = useState(false); + + useEffect(() => { + let alive = true; + setLoaded(false); + getStandings(league) + .then((d) => { + if (!alive) return; + setData(d); + setLoaded(true); + }) + .catch(() => { + if (!alive) return; + setData(null); + setLoaded(true); + }); + return () => { + alive = false; + }; + }, [league]); + + if (!loaded) { + return ( +

+ {lang === "en" ? "Loading…" : "불러오는 중…"} +

+ ); + } + if (!data || data.groups.length === 0) { + return ( +

+ {lang === "en" ? "Standings not available yet" : "순위 정보가 아직 없습니다"} +

+ ); + } + + const hasDraw = league === "kbo"; // KBO 만 무승부 존재 + + return ( +
+ {data.groups.map((g, gi) => ( +
+ {g.key && ( +
+ {DIV_LABEL[g.key]?.[lang === "en" ? "en" : "ko"] ?? g.key} +
+ )} + + + + + + + {hasDraw && ( + + )} + + + + + + + {g.rows.map((r) => ( + + + + + {hasDraw && ( + + )} + + + + + ))} + +
{lang === "en" ? "#" : "순위"}{lang === "en" ? "Team" : "팀"}{lang === "en" ? "W" : "승"}{lang === "en" ? "D" : "무"}{lang === "en" ? "L" : "패"}{lang === "en" ? "PCT" : "승률"}{lang === "en" ? "GB" : "게임차"}
+ {r.rank ?? "-"} + + + + {teamShort(r, lang)} + + {r.w ?? "-"}{r.d ?? "-"}{r.l ?? "-"}{fmtWra(r.wra)}{fmtGb(r.gb)}
+
+ ))} + {data.updatedAt && ( +

+ {(() => { + const d = new Date(data.updatedAt); + const hh = String(d.getHours()).padStart(2, "0"); + const mm = String(d.getMinutes()).padStart(2, "0"); + return lang === "en" + ? `As of ${d.getMonth() + 1}/${d.getDate()} ${hh}:${mm}` + : `${d.getMonth() + 1}.${d.getDate()} ${hh}:${mm} 기준`; + })()} +

+ )} +
+ ); +} diff --git a/frontend/src/components/TeamFlag.tsx b/frontend/src/components/TeamFlag.tsx index c63b76d..33e4bbc 100644 --- a/frontend/src/components/TeamFlag.tsx +++ b/frontend/src/components/TeamFlag.tsx @@ -1,9 +1,10 @@ import { useState } from "react"; import type { Team } from "@/lib/types"; -// 모든 경기에 일반화된 국기 렌더. -// 글로벌 축구 축전 출전 48개국 SVG 자산(/assets/flags/{fifa코드}.svg, 예: kor.svg)을 사용하고, -// 자산이 없는 코드는 이모지 폴백(컨테이너 높이에 비례, 안 잘림). +// 모든 경기에 일반화된 국기/팀로고 렌더. +// 야구(KBO/MLB): 백엔드가 flag 필드에 로고 경로/URL 을 실어줌 → 그대로 img. +// 축구(월드컵): 48개국 SVG 자산(/assets/flags/{fifa코드}.svg) 사용. +// 없으면 이모지 폴백(컨테이너 높이에 비례, 안 잘림). export default function TeamFlag({ team, className = "", @@ -12,6 +13,19 @@ export default function TeamFlag({ className?: string; }) { const [failed, setFailed] = useState(false); + const isLogoUrl = + !!team.flag && (team.flag.startsWith("/") || team.flag.startsWith("http")); + + if (!failed && isLogoUrl) { + return ( + {team.name} setFailed(true)} + className={`object-contain ${className}`} + /> + ); + } if (!failed && team.code) { return ( diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index bf719b2..debeb89 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -10,6 +10,7 @@ import type { MyPrediction, RankingEntry, RankingTab, + StandingsOut, } from "./types"; import type { Lang } from "./i18n"; @@ -42,14 +43,41 @@ export class ApiError extends Error { } } -export function listMatches(lang: Lang): Promise { - return http(`/matches?lang=${lang}`); +export function listMatches(lang: Lang, league = ""): Promise { + return http(`/matches?lang=${lang}${league ? `&league=${league}` : ""}`); } export function getMatch(matchId: string, lang: Lang): Promise { return http(`/matches/${matchId}?lang=${lang}`); } +// ── 야구 라이브 필드 뷰 ────────────────────────────────────── +export interface LiveBatter { + order?: number | null; + name: string; + pos?: string; + avg?: number | string | null; + sub?: boolean; +} + +export interface LiveData { + available: boolean; + inn?: number; + half?: "T" | "B"; + score?: { away?: number | string; home?: number | string }; + bso?: { b?: number | string; s?: number | string; o?: number | string }; + bases?: boolean[]; + batter?: LiveBatter | null; + pitcher?: { name: string; ballCount?: number | string | null } | null; + vsRecord?: string; + defense?: { pos?: string; name: string }[]; + offenseLineup?: LiveBatter[]; +} + +export function getLive(matchId: string): Promise { + return http(`/matches/${matchId}/live`); +} + export interface SubmitResult { ok: boolean; predictionId: string; @@ -73,23 +101,28 @@ export function submitPrediction(body: { }); } -export function getLeaderboard(): Promise { - return http(`/leaderboard`); +export function getLeaderboard(league = ""): Promise { + return http(`/leaderboard${league ? `?league=${league}` : ""}`); +} + +// 리그 순위표 (야구 — 워커가 캐싱한 시즌 순위) +export function getStandings(league: string): Promise { + return http(`/standings?league=${league}`); } // AI 모델 누적 랭킹 (종료 경기 합산 — 서버가 매 조회 시 재계산) -export function getAILeaderboard(): Promise { - return http(`/leaderboard/ai`); +export function getAILeaderboard(league = ""): Promise { + return http(`/leaderboard/ai${league ? `?league=${league}` : ""}`); } // 랭킹 위젯 공용 — 탭에 맞춰 TOP 10 을 {label, points} 로 정규화해 반환. -// 경기 종료·채점 때마다 서버 값이 갱신되므로 호출 시점 최신 누적이 내려옴. -export async function fetchRanking(tab: RankingTab): Promise { +// league 를 주면 해당 리그 경기만 집계(빈 값 = 전체 통합). +export async function fetchRanking(tab: RankingTab, league = ""): Promise { if (tab === "ai") { - const { standings } = await getAILeaderboard(); + const { standings } = await getAILeaderboard(league); return standings.map((s) => ({ label: s.model, points: s.totalPoints })); } - const { standings } = await getLeaderboard(); + const { standings } = await getLeaderboard(league); return standings .slice(0, 10) .map((s) => ({ label: s.emailMasked, points: s.totalPoints })); @@ -125,10 +158,14 @@ export function postComment( // ── 세션 닉네임: 세션당 한 번 발급받아 sessionStorage 에 캐싱 ── // 탭/창을 닫으면 비워지고(껐다 켜면 새 닉), 시크릿창은 별도 저장소라 새로 발급됨. +// 종목별(축구/야구) 캐시 분리 — 서버가 리그에 맞는 단어풀로 발급하므로 +// 축구 닉이 야구 경기에 재사용되지 않게 키를 나눈다. const SESSION_NICK_KEY = "tp_session_nick"; +const nickKey = (matchId: string) => + `${SESSION_NICK_KEY}.${/^(KBO|MLB)_/.test(matchId) ? "bb" : "wc"}`; export async function getSessionNickname(matchId: string): Promise { try { - const cached = sessionStorage.getItem(SESSION_NICK_KEY); + const cached = sessionStorage.getItem(nickKey(matchId)); if (cached) return cached; } catch { /* sessionStorage 불가 환경은 매번 발급 */ @@ -137,7 +174,7 @@ export async function getSessionNickname(matchId: string): Promise { `/matches/${matchId}/comments/nickname`, ); try { - sessionStorage.setItem(SESSION_NICK_KEY, nickname); + sessionStorage.setItem(nickKey(matchId), nickname); } catch { /* noop */ } @@ -150,7 +187,7 @@ export async function rerollSessionNickname(matchId: string): Promise { `/matches/${matchId}/comments/nickname`, ); try { - sessionStorage.setItem(SESSION_NICK_KEY, nickname); + sessionStorage.setItem(nickKey(matchId), nickname); } catch { /* noop */ } diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index ee24eeb..1098621 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -58,7 +58,14 @@ type Dict = { prizeLine2: string; schedTitle: string; schedGuide: string; - phase: { open: string; scheduled: string; locked: string; live: string; finished: string }; + phase: { + open: string; + scheduled: string; + locked: string; + live: string; + finished: string; + cancelled: string; + }; aiPicks: string; draw: string; joined: (n: string) => string; @@ -90,8 +97,10 @@ type Dict = { rulesCumulative: string; rulesExact: string; rulesCloseGrade: string; + rulesCloseGradeBB: string; // 야구: 득실차 ±1 허용 rulesOutcome: string; rulesPartial: string; + rulesPartialBB: string; // 야구: 한 팀 득점 ±1 허용 rulesMiss: string; rulesEmailRequired: string; rulesCloseBtn: string; @@ -163,7 +172,7 @@ export const DICT: Record = { prizeLine2: "최종 우승자에게 100만원 상금", schedTitle: "전체 일정", schedGuide: "투표하고 싶은 경기를 선택해주세요", - phase: { open: "투표 중", scheduled: "오픈 예정", locked: "투표 종료", live: "경기중", finished: "종료" }, + phase: { open: "투표 중", scheduled: "오픈 예정", locked: "투표 종료", live: "경기중", finished: "종료", cancelled: "취소" }, aiPicks: "AI 픽", draw: "무", joined: (n) => `${n}명 참여`, @@ -196,8 +205,10 @@ export const DICT: Record = { rulesCumulative: "경기마다 적중 포인트를 쌓아, 대회 누적 포인트 1위 한 분에게 최종 100만 원을 드립니다.", rulesExact: "정확 스코어 적중", rulesCloseGrade: "근접 (승패 + 득실차 일치)", + rulesCloseGradeBB: "근접 (승패 + 득실차 ±1)", rulesOutcome: "승패만 적중", rulesPartial: "부분 (한 팀 스코어만 일치)", + rulesPartialBB: "부분 (한 팀 득점 ±1)", rulesMiss: "빗나감", rulesEmailRequired: "픽 제출 시 입력한 이메일로 챌린지에 참여됩니다. 같은 이메일의 모든 경기 포인트가 합산되어 누적 순위가 매겨집니다.", @@ -266,7 +277,7 @@ export const DICT: Record = { prizeLine2: "overall winner takes ₩1,000,000", schedTitle: "All Matches", schedGuide: "Pick a match to predict", - phase: { open: "Voting", scheduled: "Opens soon", locked: "Closed", live: "Live", finished: "Ended" }, + phase: { open: "Voting", scheduled: "Opens soon", locked: "Closed", live: "Live", finished: "Ended", cancelled: "Canceled" }, aiPicks: "AI picks", draw: "Draw", joined: (n) => `${n} joined`, @@ -299,8 +310,10 @@ export const DICT: Record = { rulesCumulative: "Earn points each match — the player with the highest cumulative total wins ₩1,000,000.", rulesExact: "Exact score", rulesCloseGrade: "Close (result + goal difference)", + rulesCloseGradeBB: "Close (result + run diff ±1)", rulesOutcome: "Correct result only", rulesPartial: "Partial (one team's score)", + rulesPartialBB: "Partial (one team's runs ±1)", rulesMiss: "Miss", rulesEmailRequired: "You enter the challenge with the email you provide when submitting a pick. Points from all matches under the same email are summed for the overall ranking.", diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 03beac7..30470ca 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -2,7 +2,13 @@ export type Outcome = "TEAM_A_WIN" | "DRAW" | "TEAM_B_WIN"; export type ModelName = "GPT" | "Claude" | "Gemini"; -export type MatchPhase = "scheduled" | "open" | "locked" | "live" | "finished"; +export type MatchPhase = + | "scheduled" + | "open" + | "locked" + | "live" + | "finished" + | "cancelled"; export interface Team { name: string; @@ -36,8 +42,50 @@ export interface CrowdStats { teamBWin: number; } +// 리그: wc(월드컵 축구) | kbo | mlb +export type League = "wc" | "kbo" | "mlb"; + +// 야구 부가정보 (프리뷰·순위 캐시 — 없으면 undefined) +export interface StarterInfo { + name: string; + hitType?: string; + era?: string | number | null; + w?: number | null; + l?: number | null; + vsEra?: string | number | null; +} + +export interface TeamStanding { + rank?: number | null; + w?: number; + d?: number; + l?: number; + wra?: string | number; + gb?: string | number; + last5?: string | null; +} + +// /standings 응답 — 팀 정보(Team) + 시즌 성적 한 행 +export interface StandingRow extends Team, TeamStanding { + div?: string | null; // MLB 디비전 키 (ALE/ALC/ALW/NLE/NLC/NLW) +} + +export interface StandingsOut { + league: string; + updatedAt?: string | null; + groups: { key: string | null; rows: StandingRow[] }[]; +} + +export interface MatchExtras { + starterA?: StarterInfo | null; + starterB?: StarterInfo | null; + seasonVs?: { aWin?: number; draw?: number; bWin?: number } | null; + standings?: { a?: TeamStanding | null; b?: TeamStanding | null }; +} + export interface Match { matchId: string; + league: League; roundLabel: string; group: string; teamA: Team; @@ -54,6 +102,7 @@ export interface Match { result?: MatchResult | null; predictions: AIPrediction[]; crowd?: CrowdStats | null; + extras?: MatchExtras | null; } export interface UserPick { diff --git a/frontend/src/lib/useLeague.ts b/frontend/src/lib/useLeague.ts new file mode 100644 index 0000000..de30a65 --- /dev/null +++ b/frontend/src/lib/useLeague.ts @@ -0,0 +1,19 @@ +import { useSearchParams } from "react-router-dom"; +import type { League } from "./types"; + +// 리그 상태 — URL 쿼리(?league=)가 SSOT. 기본값은 KBO(메인 탭). +// 링크 공유 시에도 리그가 보존되도록 쿼리 파라미터를 쓴다. +export const DEFAULT_LEAGUE: League = "kbo"; + +export function useLeague(): [League, (l: League) => void] { + const [params, setParams] = useSearchParams(); + const raw = params.get("league"); + const league: League = + raw === "wc" || raw === "kbo" || raw === "mlb" ? raw : DEFAULT_LEAGUE; + const setLeague = (l: League) => { + const next = new URLSearchParams(params); + next.set("league", l); + setParams(next, { replace: false }); + }; + return [league, setLeague]; +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 5823022..63a7a5c 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -5,12 +5,14 @@ import Ado2Ad from "@/components/Ado2Ad"; import Footer from "@/components/Footer"; import { dict } from "@/lib/i18n"; import { useLang } from "@/lib/useLang"; +import { useLeague } from "@/lib/useLeague"; import { listMatches } from "@/lib/api"; import type { Match } from "@/lib/types"; -// L1. 전체 일정 대시보드 (랜딩 진입) +// L1. 전체 일정 대시보드 (랜딩 진입) — 리그 탭(월드컵/KBO/MLB)별 경기 목록 export default function Dashboard() { const lang = useLang(); + const [league] = useLeague(); const t = dict(lang); const [matches, setMatches] = useState(null); const [error, setError] = useState(false); @@ -19,17 +21,17 @@ export default function Dashboard() { let alive = true; setMatches(null); setError(false); - listMatches(lang) + listMatches(lang, league) .then((m) => alive && setMatches(m)) .catch(() => alive && setError(true)); return () => { alive = false; }; - }, [lang]); + }, [lang, league]); return (
- + {/* 기간 안내 + CTA 영역 */}
@@ -48,7 +50,7 @@ export default function Dashboard() { {lang === "en" ? "Failed to load matches." : "경기 정보를 불러오지 못했습니다."}

)} - {matches && } + {matches && }
diff --git a/frontend/src/pages/Leaderboard.tsx b/frontend/src/pages/Leaderboard.tsx index aac22ef..582eda6 100644 --- a/frontend/src/pages/Leaderboard.tsx +++ b/frontend/src/pages/Leaderboard.tsx @@ -2,11 +2,13 @@ import Hero from "@/components/Hero"; import Footer from "@/components/Footer"; import RankingBoard from "@/components/RankingBoard"; import { useLang } from "@/lib/useLang"; +import { useLeague } from "@/lib/useLeague"; // L3. 리더보드 — 누적 포인트 랭킹(참가자/AI 탭). 펼친 상태로 전체 표시. -// 빈 상태·AI 탭·TOP10 렌더는 RankingBoard 위젯이 일괄 처리(매치 페이지와 동일 UI). +// ?league= 쿼리에 따라 해당 리그 경기만 집계(기본 KBO). export default function Leaderboard() { const lang = useLang(); + const [league] = useLeague(); return (
@@ -19,7 +21,7 @@ export default function Leaderboard() { : "매 경기 누적 점수로 순위를 매기고, 끝까지 가장 잘 맞힌 한 분께 최종 100만원을 드립니다."}

{/* 전체 페이지: 펼친 상태(폴딩 토글 숨김) */} - +