From 8a4a23b0cbddcb2576ed8733686c312ac93515a7 Mon Sep 17 00:00:00 2001 From: hbyang Date: Thu, 11 Jun 2026 10:48:16 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A0=84=EC=B2=B4=EC=9D=BC=EC=A0=95=20?= =?UTF-8?q?=EC=8A=A4=EC=BD=94=EC=96=B4=20=EC=A1=B0=EC=A0=95,=20utc=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20->=20kst=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/domain.py | 31 ++++++++++++++++++----- backend/app/routers/predictions.py | 16 ++++++++---- backend/app/schedule_data.py | 5 ++-- backend/app/schemas.py | 4 +-- backend/app/worker.py | 13 +++------- frontend/src/components/Arena.tsx | 8 +++--- frontend/src/components/ScheduleBoard.tsx | 14 ++++++++-- frontend/src/lib/i18n.ts | 13 ++++++---- frontend/src/lib/types.ts | 2 +- 9 files changed, 71 insertions(+), 35 deletions(-) diff --git a/backend/app/domain.py b/backend/app/domain.py index b52dda6..de3c2ba 100644 --- a/backend/app/domain.py +++ b/backend/app/domain.py @@ -8,7 +8,7 @@ phase 는 now 기준 동적 계산 (lib/schedule.ts matchPhase 와 동일 규칙 """ from __future__ import annotations -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from .config import settings from .models import AIPrediction, CrowdStats, Match @@ -25,15 +25,32 @@ def now_utc() -> datetime: return datetime.now(timezone.utc) +KST = timezone(timedelta(hours=9)) + + def ensure_aware(dt: datetime) -> datetime: """naive datetime(일부 DB 드라이버 반환)을 UTC aware 로 보정.""" return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt +def kst_iso(dt: datetime) -> str: + """저장값(UTC)을 KST(+09:00) ISO 문자열로 직렬화 — 프론트 표시 기준 통일.""" + return ensure_aware(dt).astimezone(KST).isoformat() + + def compute_phase(m: Match, now: datetime | None = None) -> str: + """투표/경기 단계: + finished 결과 입력됨 → "종료" + live 킥오프 이후, 결과 입력 전 → "경기중" + locked 투표 마감(킥오프 1h 전) ~ 킥오프 → "투표 종료"(비활성) + scheduled 오픈 전 → "오픈 예정" + open 투표 중 + """ now = now or now_utc() if m.result_outcome is not None: return "finished" + if now >= ensure_aware(m.kickoff_at): + return "live" if now >= ensure_aware(m.lock_at): return "locked" if now < ensure_aware(m.opens_at): @@ -110,18 +127,20 @@ def match_out( order = {"GPT": 0, "Claude": 1, "Gemini": 2} for p in sorted(m.predictions, key=lambda x: order.get(x.model, 9)): preds.append(prediction_out(p, lang)) + # status·phase 를 동일한 실시간 계산값으로 통일 — 워커 틱 지연과 무관하게 일관. + phase = compute_phase(m, now) return MatchOut( matchId=m.match_id, roundLabel=m.round_label, group=m.group, teamA=_team_a(m), teamB=_team_b(m), - kickoffKst=m.kickoff_at.isoformat(), + kickoffKst=kst_iso(m.kickoff_at), venue=m.venue, - opensAt=m.opens_at.isoformat(), - lockAt=m.lock_at.isoformat(), - status=m.status, - phase=compute_phase(m, now), + opensAt=kst_iso(m.opens_at), + lockAt=kst_iso(m.lock_at), + status=phase, + phase=phase, votingOpen=is_open_for_voting(m, now), hookText=m.hook_text, result=result, diff --git a/backend/app/routers/predictions.py b/backend/app/routers/predictions.py index bbd5b99..2beabaf 100644 --- a/backend/app/routers/predictions.py +++ b/backend/app/routers/predictions.py @@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from ..database import get_db -from ..domain import crowd_out, is_open_for_voting +from ..domain import compute_phase, crowd_out, is_open_for_voting from ..models import AIPrediction, CrowdStats, Match, UserPrediction from ..scoring import outcome_of from ..schemas import SubmitPredictionIn, SubmitPredictionOut @@ -50,10 +50,16 @@ async def submit_prediction( if not match: raise HTTPException(status_code=404, detail="MATCH_NOT_FOUND") if not is_open_for_voting(match): - # 마감/종료/오픈전 구분 - if match.result_outcome is not None: - raise HTTPException(status_code=409, detail="MATCH_FINISHED") - raise HTTPException(status_code=423, detail="MATCH_LOCKED") + # 종료 사유 정밀 구분: 종료 / 경기중 / 투표종료 / 오픈전 + phase = compute_phase(match) + detail = { + "finished": "MATCH_FINISHED", # 결과 입력됨 + "live": "MATCH_LIVE", # 경기중 + "locked": "MATCH_LOCKED", # 투표 종료(킥오프 1h 전) + "scheduled": "MATCH_NOT_OPEN", # 아직 오픈 전 + }.get(phase, "MATCH_LOCKED") + code = 409 if phase == "finished" else 423 + raise HTTPException(status_code=code, detail=detail) # outcome 은 스코어에서 도출 (입력과 불일치해도 스코어 기준으로 정규화) outcome = outcome_of(body.scoreA, body.scoreB) diff --git a/backend/app/schedule_data.py b/backend/app/schedule_data.py index 6ed183a..25a3a6d 100644 --- a/backend/app/schedule_data.py +++ b/backend/app/schedule_data.py @@ -6,7 +6,7 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from .config import settings @@ -83,7 +83,8 @@ FEATURED_MATCH_ID = "A_KOR_CZE_20260612" def parse_kickoff(iso: str) -> datetime: - return datetime.fromisoformat(iso) + # 저장은 UTC 로 통일 (DB 종류 무관하게 절대시점 일관). 출력 시 KST 로 직렬화. + return datetime.fromisoformat(iso).astimezone(timezone.utc) def opens_at(kickoff: datetime) -> datetime: diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 780df38..ffa59aa 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -55,8 +55,8 @@ class MatchOut(BaseModel): venue: str opensAt: str lockAt: str - status: str - phase: str # scheduled | open | locked | finished (now 기준 계산) + status: str # = phase (실시간 계산값으로 통일) + phase: str # scheduled | open | locked | live | finished (now 기준 계산) votingOpen: bool # 현재 제출 허용 여부 (demo_force_open 반영) hookText: str result: MatchResult | None = None diff --git a/backend/app/worker.py b/backend/app/worker.py index 37fcac8..8e36fbe 100644 --- a/backend/app/worker.py +++ b/backend/app/worker.py @@ -27,7 +27,7 @@ from sqlalchemy.orm import selectinload from .config import settings from .database import SessionLocal, init_db -from .domain import ensure_aware, now_utc +from .domain import compute_phase, ensure_aware, now_utc from .models import AIPrediction, Match, UserPrediction from .scoring import load_scoring_data from .services.ai import MatchContext, PROVIDERS, ProviderUnavailable @@ -61,14 +61,9 @@ async def tick_status() -> None: matches = (await db.execute(select(Match))).scalars().all() changed = 0 for m in matches: - if m.result_outcome is not None: - target = "finished" - elif now >= ensure_aware(m.lock_at): - target = "locked" - elif now >= ensure_aware(m.opens_at): - target = "open" - else: - target = "scheduled" + # 화면용 phase 와 동일 기준으로 status 저장(단일 기준): + # scheduled → open → locked(투표종료) → live(경기중) → finished + target = compute_phase(m, now) if m.status != target: m.status = target changed += 1 diff --git a/frontend/src/components/Arena.tsx b/frontend/src/components/Arena.tsx index 00245c8..4b1866d 100644 --- a/frontend/src/components/Arena.tsx +++ b/frontend/src/components/Arena.tsx @@ -62,11 +62,13 @@ export default function Arena({ const disabled = finished || !match.votingOpen; const ctaLabel = finished ? t.ctaResult - : match.phase === "scheduled" && !match.votingOpen - ? t.ctaOpens(kickoffDisplay(match.opensAt, lang)) + : match.phase === "live" + ? t.ctaLive : match.phase === "locked" ? t.ctaLocked - : t.ctaBeat; + : match.phase === "scheduled" && !match.votingOpen + ? t.ctaOpens(kickoffDisplay(match.opensAt, lang)) + : t.ctaBeat; const matched = useMemo(() => { if (!outcome) return []; diff --git a/frontend/src/components/ScheduleBoard.tsx b/frontend/src/components/ScheduleBoard.tsx index e4d1d25..7417147 100644 --- a/frontend/src/components/ScheduleBoard.tsx +++ b/frontend/src/components/ScheduleBoard.tsx @@ -10,7 +10,8 @@ const STROKE = "#94FBE0"; // 일정 텍스트·선택 아웃라인 스트로크 const PHASE_CLS: Record = { open: "border-[#94FBE0] text-[#94FBE0]", scheduled: "border-[var(--line-d)] text-[var(--ink-muted)]", - locked: "border-[var(--line-d)] text-[var(--ink-muted)]", + locked: "border-[var(--line-d)] text-white/45", // 투표 종료 — 비활성 톤 + live: "border-[#FF5B5B] text-[#FF5B5B]", // 경기중 — 라이브 강조 finished: "border-white/15 text-white/55", }; @@ -102,7 +103,16 @@ function MatchCard({ match, lang }: { match: Match; lang: Lang }) { {teamShort(match.teamA, lang)} - VS + {match.result ? ( + // 종료된 경기 — 최종 스코어 (예: 3 : 2) + + {match.result.scoreA} + : + {match.result.scoreB} + + ) : ( + VS + )}
{teamShort(match.teamB, lang)} diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index 56171f7..acfb566 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -47,7 +47,7 @@ type Dict = { prizeLine2: string; schedTitle: string; schedGuide: string; - phase: { open: string; scheduled: string; locked: string; finished: string }; + phase: { open: string; scheduled: string; locked: string; live: string; finished: string }; aiPicks: string; draw: string; joined: (n: string) => string; @@ -61,6 +61,7 @@ type Dict = { ctaBeat: string; ctaResult: string; ctaLocked: string; + ctaLive: string; ctaOpens: (d: string) => string; emailPh: string; notify: string; @@ -104,7 +105,7 @@ export const DICT: Record = { prizeLine2: "최종 우승자에게 100만원 상금", schedTitle: "전체 일정", schedGuide: "투표하고 싶은 경기를 선택해주세요", - phase: { open: "투표 중", scheduled: "오픈 예정", locked: "투표 마감", finished: "종료" }, + phase: { open: "투표 중", scheduled: "오픈 예정", locked: "투표 종료", live: "경기중", finished: "종료" }, aiPicks: "AI 픽", draw: "무", joined: (n) => `${n}명 참여`, @@ -117,7 +118,8 @@ export const DICT: Record = { drawOdds: "무승부 가능성", ctaBeat: "AI보다 잘 맞히기", ctaResult: "결과 보기", - ctaLocked: "예측 마감 (경기 시작)", + ctaLocked: "투표 종료", + ctaLive: "경기 진행 중", ctaOpens: (d) => `투표 오픈 ${d}`, emailPh: "이메일 (결과·다음 경기 알림)", notify: "경기 결과가 나오면 알려주세요. 다음 경기·100만원 챌린지 소식도 받겠습니다.", @@ -161,7 +163,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", finished: "Ended" }, + phase: { open: "Voting", scheduled: "Opens soon", locked: "Closed", live: "Live", finished: "Ended" }, aiPicks: "AI picks", draw: "Draw", joined: (n) => `${n} joined`, @@ -174,7 +176,8 @@ export const DICT: Record = { drawOdds: "Draw odds", ctaBeat: "Beat the AI", ctaResult: "View result", - ctaLocked: "Closed (kickoff)", + ctaLocked: "Voting closed", + ctaLive: "Match in progress", ctaOpens: (d) => `Opens ${d}`, emailPh: "Email (result & next-match alerts)", notify: "Notify me when the result is out. I'll also get next-match & ₩1,000,000 challenge news.", diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index a40eb3b..18cc4f7 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -2,7 +2,7 @@ export type Outcome = "TEAM_A_WIN" | "DRAW" | "TEAM_B_WIN"; export type ModelName = "GPT" | "Claude" | "Gemini"; -export type MatchPhase = "scheduled" | "open" | "locked" | "finished"; +export type MatchPhase = "scheduled" | "open" | "locked" | "live" | "finished"; export interface Team { name: string;