Refactor: Next.js+Firebase → React(Vite) + FastAPI + PostgreSQL

- 백엔드: FastAPI + SQLAlchemy(async) + PostgreSQL, 별도 워커(APScheduler)
- 프론트: React 19 + Vite + TS + Tailwind v4 (기존 UI 이식, API 연동)
- docker-compose: db · api · worker · frontend 일괄 실행
- 경기 일정: openfootball 매일 09:00 크롤링 → DB upsert (신규 경기 자동 삽입+예측 생성)
- AI 예측: GPT·Claude·Gemini 실 API 매일 생성 → DB 저장 → 조회 (목데이터 제거)
- 투표창: 오픈 킥오프 D-2 / 마감 킥오프 1시간 전, lockAt 백엔드 전달→프론트 카운트다운
- 채점/리더보드: docs/SCORING.md 기준 누적 포인트
- crowd: 0 시작, 실제 제출로만 증분
- 구 Next.js/Firebase 파일 제거, README/AGENTS 갱신

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
develop
hbyang 2026-06-10 13:04:35 +09:00
parent 35414a8d47
commit 6ffe0dfca3
95 changed files with 5266 additions and 8856 deletions

View File

@ -1,5 +0,0 @@
{
"projects": {
"default": "REPLACE_WITH_NEW_FIREBASE_PROJECT_ID"
}
}

44
.gitignore vendored
View File

@ -1,24 +1,29 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
node_modules/
.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
# build output
frontend/dist/
build/
/out/
# production
/build
# python
__pycache__/
*.py[cod]
.venv/
venv/
*.egg-info/
# env files (실 키는 커밋하지 않음 — .env.example 만 커밋)
.env
.env.*
!.env.example
# docker volume (로컬)
pgdata/
# misc
.DS_Store
@ -26,20 +31,11 @@
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# secrets
# secrets (구 firebase 잔재)
serviceAccount.json
.firebaserc

View File

@ -1,5 +1,46 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
# TriplePick — 아키텍처 (에이전트용 안내)
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
"AI(GPT·Claude·Gemini) vs 너" 글로벌 축구 승부예측 서비스. 모바일 우선.
## 스택 (2026 리팩토링)
- **frontend/** — React 19 + Vite + TypeScript + Tailwind v4, react-router. nginx 정적 서빙.
- **backend/** — Python FastAPI + SQLAlchemy(async) + PostgreSQL. 별도 워커(APScheduler).
- **docker-compose.yml** (루트) — db · api · worker · frontend 를 한 번에 실행.
## 디렉토리
```
backend/app/
config.py 설정(.env): DB·투표윈도우·스케줄러시각·외부키
database.py async 엔진/세션, init_db
models.py ORM: matches / ai_predictions / crowd_stats / user_predictions
schemas.py Pydantic API 계약 (프론트 lib/types.ts 와 정합)
scoring.py 채점(docs/SCORING.md SSOT) + 임직원 배제
schedule_data.py 경기 일정 SSOT (Group A 6경기, KST)
seed_data.py 부트스트랩(결정론적) 예측/crowd 생성기
seed.py DB 시드 (idempotent)
domain.py phase 계산 + ORM→응답 직렬화
services/ ai.py(3모델 실연동) · email.py(SMTP) · grading.py
routers/ matches · predictions · leaderboard · admin
main.py FastAPI 앱(API)
worker.py APScheduler 워커(상태전이·AI생성·결과메일)
frontend/src/
lib/ types · i18n · format · api(백엔드 호출) · useLang
components/ Hero · MatchupHUD · Arena · ScheduleBoard · TeamFlag · …
pages/ Dashboard · MatchDetail · Leaderboard · NotFound
```
## 시간/스케줄 규칙 (워커가 자동 처리)
- 투표 오픈 = 킥오프 48h(D-2, `VOTE_OPEN_HOURS_BEFORE`)
- 투표 마감 = 킥오프 정각(`VOTE_LOCK_MINUTES_BEFORE=0`)
- 상태 전이: scheduled → open → locked → finished (`STATUS_TICK_SECONDS` 주기)
- AI 예측 생성: 매일 KST `AI_GENERATE_HOUR:MINUTE` (실 LLM API 호출)
- 결과 메일: 경기 종료 후 `RESULT_EMAIL_DELAY_MINUTES`(기본 180=3h) 경과 시 발송
- `DEMO_FORCE_OPEN=true` 면 마감 전까지 항상 투표 가능(운영 시 false)
## SSOT 문서
- `docs/SCORING.md` — 채점·심사(100만원) 단일 기준
- `docs/DESIGN.md` — 룩앤필/디자인 토큰 (변경 시 먼저 갱신)
- `docs/BACKEND.md` — 초기 백엔드 설계 메모(원본 Firebase 기준 — 현 구현은 FastAPI)
## 실행
`cp backend/.env.example backend/.env` → 키 채우기 → `docker compose up --build` → http://localhost:8080

View File

@ -1,36 +1,90 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# TriplePick 2026
## Getting Started
**"AI(GPT · Claude · Gemini) vs 너"** — 글로벌 축구 승부예측 서비스.
3개 AI 모델이 매 경기를 서로 다르게 예측하고, 유저는 픽을 찍어 AI와 겨룬다.
끝까지 가장 정확하게 맞힌 1인에게 100만원 챌린지.
First, run the development server:
## 아키텍처
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
o2o-triple-pick/
├── docker-compose.yml # db · api · worker · frontend 한 번에 실행
├── backend/ # FastAPI + SQLAlchemy(async) + PostgreSQL
│ ├── app/ # API · 워커(APScheduler) · AI/이메일 실연동
│ └── .env.example
└── frontend/ # React + Vite + TypeScript + Tailwind v4
├── src/ # pages · components · lib(api 연동)
└── .env.example
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
- **frontend** — React SPA. nginx 가 정적 파일 서빙 + `/api` 를 백엔드로 프록시.
- **backend (api)** — FastAPI. 경기/AI예측/crowd 읽기, 픽 제출, 채점, 리더보드, 관리자.
- **backend (worker)** — APScheduler 별도 프로세스. 아래 "시간 기반 작업" 자동 처리.
- **db** — PostgreSQL.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
## 빠른 시작 (Docker)
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
```bash
cp backend/.env.example backend/.env # 키(선택) 채우기
docker compose up --build
# 프론트: http://localhost:8080
# API 문서: http://localhost:8000/docs
```
## Learn More
DB 는 최초 기동 시 6경기 + AI 예측(부트스트랩) + crowd baseline 으로 자동 시드된다.
AI/이메일 키가 없어도 전체 플로우(예측·제출·채점·리더보드)는 즉시 동작한다.
To learn more about Next.js, take a look at the following resources:
## 시간 기반 작업 (워커가 자동 처리)
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
조사된 "필요한 시간들"을 워커 단일 프로세스가 모두 담당한다:
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
| 작업 | 시점 | 설정 |
|---|---|---|
| 투표 오픈 | 킥오프 48h (D-2) | `VOTE_OPEN_HOURS_BEFORE` |
| 투표 마감 | 킥오프 정각 | `VOTE_LOCK_MINUTES_BEFORE` |
| 상태 전이 (scheduled→open→locked→finished) | 주기 틱 | `STATUS_TICK_SECONDS` (기본 60s) |
| AI 예측 생성 (3모델 실 API 호출) | 매일 KST 00:05 | `AI_GENERATE_HOUR` / `_MINUTE` |
| 결과 메일 발송 (구독자) | 경기 종료 +3h | `RESULT_EMAIL_DELAY_MINUTES` |
## Deploy on Vercel
> 데모에서는 `DEMO_FORCE_OPEN=true` 로 마감 전까지 항상 투표 가능. **운영 배포 시 false**.
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
## 외부 연동 (실연동 — 키 없으면 해당 기능만 생략)
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
- **AI 3모델** — OpenAI(GPT) · Anthropic(Claude, `claude-opus-4-8`) · Google(Gemini).
`OPENAI_API_KEY` / `ANTHROPIC_API_KEY` / `GOOGLE_API_KEY`.
- **이메일** — SMTP. `SMTP_HOST``backend/.env` 참조.
## API 요약
| 메서드 | 경로 | 설명 |
|---|---|---|
| GET | `/api/matches?lang=ko` | 전체 경기 (예측·crowd 포함) |
| GET | `/api/matches/{id}` | 경기 상세 |
| POST | `/api/predictions` | 픽 제출 (1회 수정·crowd 증분·매칭 모델) |
| GET | `/api/leaderboard` | 누적 포인트 랭킹 |
| POST | `/api/admin/result` | (Bearer) 결과 입력 → 채점 |
| POST | `/api/admin/ai-predictions` | (Bearer) AI 예측 수동 upsert |
## 로컬 개발 (Docker 없이)
```bash
# 백엔드
cd backend && python3.12 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
# Postgres 띄우고 DATABASE_URL 지정 후:
uvicorn app.main:app --reload # API
python -m app.worker # 워커
# 프론트
cd frontend && npm install && npm run dev # http://localhost:5173 (/api → :8000 프록시)
```
## 채점 규칙
`docs/SCORING.md` (SSOT). 정확 스코어 5 · 근접 3 · 승패 2 · 부분 1 · 빗나감 0.
누적 포인트 1위에게 최종 상금. 임직원/운영진 배제.
## 문서
- `docs/SCORING.md` — 채점·심사 SSOT
- `docs/DESIGN.md` — 디자인 토큰 SSOT
- `docs/BACKEND.md` — 초기 백엔드 설계 메모(Firebase 기준 → 현 구현은 FastAPI)

View File

@ -1,251 +0,0 @@
"use client";
// ============================================================
// BEFORE — 최초 구축 버전 (다크 네이비 토큰 테마) · 개인 기록용 보존
// 002/003 리디자인 이전 모습. 자체 완결(explicit hex), 현재 globals와 독립.
// ============================================================
import { useEffect, useMemo, useState } from "react";
const C = {
bg: "#0a0e1a",
bg2: "#0d1322",
card: "#141b2d",
green: "#25e07f",
amber: "#ffb23e",
muted: "#8b94a7",
line: "#26304a",
gpt: "#25e07f",
claude: "#c08bff",
gemini: "#4d9bff",
};
const MATCH = {
teamA: { short: "한국", name: "Korea Republic", flag: "🇰🇷" },
teamB: { short: "체코", name: "Czechia", flag: "🇨🇿" },
kickoff: "2026-06-12T11:00:00+09:00",
lockAt: "2026-06-12T10:50:00+09:00",
venue: "Estadio Guadalajara",
};
const AIS = [
{ model: "GPT", color: C.gpt, outcome: "A", score: "2 - 1", conf: 62, reason: "전환 속도와 핵심 공격 자원에서 우위", out: "한국 승" },
{ model: "Claude", color: C.claude, outcome: "D", score: "1 - 1", conf: 41, reason: "체코 수비 조직력과 세트피스가 변수", out: "무승부" },
{ model: "Gemini", color: C.gemini, outcome: "B", score: "0 - 2", conf: 28, reason: "체코의 원정 폼과 역습 효율을 높게 평가", out: "체코 승" },
];
function useCountdown(iso: string) {
const [now, setNow] = useState<number | null>(null);
useEffect(() => {
setNow(Date.now());
const t = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(t);
}, []);
if (now === null) return null;
const diff = Math.max(0, new Date(iso).getTime() - now);
return {
d: Math.floor(diff / 86400000),
h: Math.floor((diff % 86400000) / 3600000),
m: Math.floor((diff % 3600000) / 60000),
s: Math.floor((diff % 60000) / 1000),
};
}
const pad = (n: number) => String(n).padStart(2, "0");
export default function Before() {
const cd = useCountdown(MATCH.kickoff);
const [outcome, setOutcome] = useState<string | null>(null);
const [a, setA] = useState(1);
const [b, setB] = useState(0);
const [step, setStep] = useState<"pick" | "form" | "done">("pick");
const [nick, setNick] = useState("");
const [total, setTotal] = useState(1284);
const [dist, setDist] = useState({ A: 48, D: 27, B: 25 });
const matched = useMemo(() => AIS.filter((p) => p.outcome === outcome).map((p) => p.model), [outcome]);
const wrap: React.CSSProperties = { background: C.bg, color: "#fff", minHeight: "100dvh" };
const card: React.CSSProperties = { background: C.card, border: `1px solid ${C.line}`, borderRadius: 12 };
return (
<div style={wrap}>
{/* 기록용 라벨 */}
<div style={{ background: "#1c2640", color: C.green, textAlign: "center", fontSize: 11, fontWeight: 700, padding: "6px" }}>
BEFORE () · <a href="/" style={{ color: "#fff", textDecoration: "underline" }}>/ </a>
</div>
{/* Sticky Top */}
<div style={{ position: "sticky", top: 0, zIndex: 50, background: "rgba(10,14,26,0.9)", borderBottom: `1px solid ${C.line}`, backdropFilter: "blur(6px)" }}>
<div style={{ maxWidth: 480, margin: "0 auto", padding: "0 16px" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "10px 0" }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: 13, fontWeight: 800, color: C.green }}>TriplePick</span>
<span style={{ fontSize: 11, color: C.muted }}>🇰🇷 vs 🇨🇿</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div style={{ textAlign: "right", lineHeight: 1 }}>
<div style={{ fontSize: 9, color: C.muted }}></div>
<div style={{ fontSize: 12, fontWeight: 700, fontVariantNumeric: "tabular-nums" }}>
{cd ? `${cd.d}${pad(cd.h)}:${pad(cd.m)}:${pad(cd.s)}` : "—"}
</div>
</div>
<button onClick={() => document.getElementById("pick")?.scrollIntoView({ behavior: "smooth" })} style={{ background: C.green, color: "#06210f", fontSize: 12, fontWeight: 700, borderRadius: 6, padding: "6px 12px", border: 0 }}>
</button>
</div>
</div>
<div style={{ fontSize: 10, color: C.muted, paddingBottom: 6 }}> {total.toLocaleString()} </div>
</div>
</div>
<main style={{ maxWidth: 480, margin: "0 auto", padding: "0 16px 64px" }}>
{/* Hero */}
<header style={{ paddingTop: 20, paddingBottom: 12, textAlign: "center" }}>
<div style={{ fontSize: 20, fontWeight: 800 }}>Triple Pick <span style={{ color: C.green }}>2026</span></div>
<div style={{ fontSize: 12, color: C.muted }}>AI Prediction Arena</div>
<h1 style={{ fontSize: 22, fontWeight: 800, marginTop: 16, lineHeight: 1.35 }}>
,<br /><span style={{ color: C.green }}>AI </span>
</h1>
<p style={{ fontSize: 12.5, color: C.muted, marginTop: 8, lineHeight: 1.6 }}>
AI .<br /> , .
</p>
</header>
{/* Match card */}
<section style={{ ...card, padding: 16, marginTop: 12 }}>
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 10, color: C.muted }}>
<span style={{ border: `1px solid ${C.line}`, borderRadius: 999, padding: "2px 8px", color: C.green, fontWeight: 600 }}>Match 01</span>
<span>Matchday Prediction</span>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr auto 1fr", alignItems: "center", gap: 8, marginTop: 12 }}>
<Team flag={MATCH.teamA.flag} short={MATCH.teamA.short} name={MATCH.teamA.name} />
<div style={{ fontSize: 18, fontWeight: 800, color: C.muted }}>VS</div>
<Team flag={MATCH.teamB.flag} short={MATCH.teamB.short} name={MATCH.teamB.name} />
</div>
<div style={{ marginTop: 12, textAlign: "center", fontSize: 11, color: C.muted }}>6.12() 11:00 KST · Group A</div>
<div style={{ marginTop: 2, textAlign: "center", fontSize: 10, color: C.muted }}>{MATCH.venue}</div>
</section>
{/* AI predictions */}
<section style={{ marginTop: 20 }}>
<div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 8 }}>
<h2 style={{ fontSize: 14, fontWeight: 800 }}>AI </h2>
<span style={{ fontSize: 10, color: C.muted }}> · </span>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{AIS.map((p) => (
<div key={p.model} style={{ ...card, padding: 12 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ width: 28, height: 28, display: "grid", placeItems: "center", borderRadius: 6, fontSize: 11, fontWeight: 800, background: `${p.color}2e`, color: p.color }}>{p.model[0]}</span>
<div>
<div style={{ fontSize: 13, fontWeight: 700 }}>{p.model}</div>
<div style={{ fontSize: 10, color: C.muted, marginTop: 2 }}>{p.out} </div>
</div>
</div>
<div style={{ textAlign: "right" }}>
<div style={{ fontSize: 16, fontWeight: 800, fontVariantNumeric: "tabular-nums" }}>{p.score}</div>
<div style={{ fontSize: 10, color: C.muted }}> {p.conf}%</div>
</div>
</div>
<div style={{ marginTop: 8, height: 6, borderRadius: 999, background: C.bg2, overflow: "hidden" }}>
<div style={{ width: `${p.conf}%`, height: "100%", background: p.color, borderRadius: 999 }} />
</div>
<p style={{ marginTop: 8, fontSize: 11, color: C.muted }}>{p.reason} <span style={{ opacity: 0.6 }}>· 2026-06-08</span></p>
</div>
))}
</div>
</section>
{/* Your pick */}
<section id="pick" style={{ marginTop: 20, scrollMarginTop: 80 }}>
<h2 style={{ fontSize: 14, fontWeight: 800, marginBottom: 8 }}> <span style={{ color: C.amber }}>🟠</span></h2>
<div style={{ ...card, padding: 16 }}>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 6, background: C.bg2, borderRadius: 8, padding: 4 }}>
{([["A", "한국 승"], ["D", "무승부"], ["B", "체코 승"]] as [string, string][]).map(([v, l]) => (
<button key={v} onClick={() => setOutcome(v)} style={{ borderRadius: 6, padding: "8px 0", fontSize: 12.5, fontWeight: 700, border: 0, background: outcome === v ? C.green : "transparent", color: outcome === v ? "#06210f" : C.muted }}>{l}</button>
))}
</div>
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, marginTop: 16 }}>
<Stepper label="한국" value={a} set={setA} c={C} />
<span style={{ fontSize: 18, fontWeight: 800, color: C.muted, paddingTop: 20 }}>:</span>
<Stepper label="체코" value={b} set={setB} c={C} />
</div>
{step === "pick" && (
<button onClick={() => outcome && setStep("form")} disabled={!outcome} style={{ marginTop: 16, width: "100%", borderRadius: 8, padding: "12px 0", fontSize: 15, fontWeight: 800, border: 0, background: C.green, color: "#06210f", opacity: outcome ? 1 : 0.4 }}>AI </button>
)}
{step === "form" && (
<div style={{ marginTop: 16, display: "flex", flexDirection: "column", gap: 8 }}>
<input value={nick} onChange={(e) => setNick(e.target.value)} placeholder="닉네임 (2~16자)" style={{ width: "100%", borderRadius: 8, border: `1px solid ${C.line}`, background: C.bg2, padding: "10px 12px", fontSize: 13, color: "#fff" }} />
<button onClick={() => { if (nick.trim().length >= 2) { setTotal((t) => t + 1); setStep("done"); } }} disabled={nick.trim().length < 2} style={{ width: "100%", borderRadius: 8, padding: "12px 0", fontSize: 15, fontWeight: 800, border: 0, background: C.green, color: "#06210f", opacity: nick.trim().length >= 2 ? 1 : 0.4 }}> AI </button>
</div>
)}
</div>
{step === "done" && outcome && (
<div style={{ ...card, border: `1px solid ${C.green}80`, padding: 16, marginTop: 12 }}>
<div style={{ fontSize: 11, color: C.muted }}> </div>
<div style={{ fontSize: 20, fontWeight: 800, marginTop: 2 }}> {a}-{b} <span style={{ fontSize: 13, color: C.green }}>{outcome === "A" ? "한국 승" : outcome === "B" ? "체코 승" : "무승부"}</span></div>
<p style={{ fontSize: 12.5, color: "#cfd6e2", marginTop: 8, lineHeight: 1.6 }}>
{matched.length ? <> <b>{matched.join("·")}</b> . AI .</> : <> AI . ! 🔥</>}<br /> .
</p>
<button style={{ marginTop: 12, width: "100%", borderRadius: 8, padding: "10px 0", fontSize: 13, fontWeight: 700, border: 0, background: C.green, color: "#06210f" }}> </button>
</div>
)}
</section>
{/* Crowd (세로 바) */}
<section style={{ marginTop: 20 }}>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 8 }}>
<h2 style={{ fontSize: 14, fontWeight: 800 }}>Crowd Pick</h2>
<span style={{ fontSize: 10, color: C.muted }}> {total.toLocaleString()} </span>
</div>
<div style={{ ...card, padding: 16, display: "flex", flexDirection: "column", gap: 10 }}>
{([["한국 승", dist.A], ["무승부", dist.D], ["체코 승", dist.B]] as [string, number][]).map(([l, v]) => (
<div key={l}>
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 11, marginBottom: 4 }}><span style={{ color: C.muted }}>{l}</span><span style={{ fontWeight: 700 }}>{v}%</span></div>
<div style={{ height: 8, borderRadius: 999, background: C.bg2, overflow: "hidden" }}><div style={{ width: `${v}%`, height: "100%", background: C.green, borderRadius: 999 }} /></div>
</div>
))}
</div>
</section>
{/* Prize */}
<section style={{ marginTop: 20 }}>
<div style={{ ...card, padding: 16 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}><span style={{ fontSize: 18 }}>🏆</span><h2 style={{ fontSize: 14, fontWeight: 800 }}> 100 </h2></div>
<p style={{ marginTop: 6, fontSize: 12, color: C.muted, lineHeight: 1.6 }}>AI 100 . , .</p>
<button style={{ marginTop: 12, width: "100%", borderRadius: 8, padding: "10px 0", fontSize: 13, fontWeight: 700, background: `${C.green}1a`, color: C.green, border: `1px solid ${C.green}66` }}>100 </button>
</div>
</section>
{/* Footer */}
<footer style={{ marginTop: 32, borderTop: `1px solid ${C.line}`, paddingTop: 20 }}>
<p style={{ fontSize: 11, color: C.muted, lineHeight: 1.6 }}> AIO2O AI . GPT·Claude·Gemini .</p>
<p style={{ fontSize: 10, color: `${C.muted}cc`, marginTop: 8, lineHeight: 1.6 }}> · · . AI .</p>
<div style={{ marginTop: 12, display: "flex", justifyContent: "space-between", fontSize: 10, color: C.muted }}><span>© 2026 TriplePick · AIO2O</span><span>@triplepick_ai</span></div>
</footer>
</main>
</div>
);
}
function Team({ flag, short, name }: { flag: string; short: string; name: string }) {
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 6 }}>
<div style={{ fontSize: 40, lineHeight: 1 }}>{flag}</div>
<div style={{ fontSize: 13, fontWeight: 700 }}>{short}</div>
<div style={{ fontSize: 9, color: "#8b94a7" }}>{name}</div>
</div>
);
}
function Stepper({ label, value, set, c }: { label: string; value: number; set: (n: number) => void; c: typeof C }) {
const btn: React.CSSProperties = { width: 36, height: 36, display: "grid", placeItems: "center", borderRadius: 8, border: `1px solid ${c.line}`, background: c.bg2, color: "#fff", fontSize: 18, fontWeight: 700 };
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center" }}>
<div style={{ fontSize: 11, color: c.muted, marginBottom: 4 }}>{label}</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<button onClick={() => set(Math.max(0, value - 1))} style={btn}></button>
<div style={{ width: 36, textAlign: "center", fontSize: 22, fontWeight: 800 }}>{value}</div>
<button onClick={() => set(Math.min(9, value + 1))} style={btn}>+</button>
</div>
</div>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@ -1,38 +0,0 @@
import type { Metadata, Viewport } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "TriplePick 2026 | AI 2026 월드컵 승부예측 — GPT vs Claude vs Gemini",
description:
"GPT·Claude·Gemini가 매 경기를 서로 다르게 예측합니다. 당신의 픽을 찍고 AI와 겨뤄보세요. 끝까지 잘 맞히면 100만 원 챌린지.",
applicationName: "TriplePick",
openGraph: {
title: "AI 셋이 갈렸다 — 당신의 픽은?",
description:
"GPT·Claude·Gemini의 예측을 확인하고 직접 승패와 스코어를 찍어보세요. TriplePick AI 예측 아레나.",
siteName: "TriplePick",
type: "website",
locale: "ko_KR",
},
twitter: {
card: "summary_large_image",
title: "AI 셋이 갈렸다 — TriplePick 2026",
description: "GPT vs Claude vs Gemini vs 너. 지금 픽하고 AI와 겨뤄요.",
},
};
export const viewport: Viewport = {
themeColor: "#0a0e1a",
width: "device-width",
initialScale: 1,
};
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="ko">
<body>{children}</body>
</html>
);
}

View File

@ -1,26 +0,0 @@
import type { Metadata } from "next";
import Hero from "@/components/Hero";
import Footer from "@/components/Footer";
// L3. 리더보드 — 차기(금요일 제외). 진입점 404 방지용 stub.
export const metadata: Metadata = {
title: "랭킹 | TriplePick 2026",
description: "TriplePick 누적 랭킹 — 곧 공개됩니다.",
};
export default function LeaderboardPage() {
return (
<main className="shell">
<Hero back />
<section className="mt-10 rounded-2xl border border-[var(--line-d)] bg-[var(--bg2)] p-8 text-center">
<div className="font-impact text-[28px] text-[var(--green)]">LEADERBOARD</div>
<p className="mt-3 text-[15px] font-bold"> </p>
<p className="mt-2 text-[13px] leading-relaxed text-[var(--ink-muted)]">
,
100 .
</p>
</section>
<Footer />
</main>
);
}

View File

@ -1,88 +0,0 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import Hero from "@/components/Hero";
import MatchupHUD from "@/components/MatchupHUD";
import Arena from "@/components/Arena";
import Footer from "@/components/Footer";
import { GROUP_A } from "@/lib/schedule";
import { getPredictions, getCrowd, matchUrl } from "@/lib/mockData";
import { parseLang, dict, teamShort } from "@/lib/i18n";
// L2. 경기 상세(대결) 페이지 — 6경기 정적 생성
export function generateStaticParams() {
return GROUP_A.map((m) => ({ matchId: m.matchId }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ matchId: string }>;
}): Promise<Metadata> {
const { matchId } = await params;
const match = GROUP_A.find((m) => m.matchId === matchId);
if (!match) return { title: "TriplePick 2026" };
const title = `${match.teamA.shortName} vs ${match.teamB.shortName} AI 승부예측 | TriplePick`;
const desc = `GPT·Claude·Gemini가 ${match.teamA.name} vs ${match.teamB.name}를 서로 다르게 예측했습니다. 당신의 픽을 찍고 AI와 겨뤄보세요.`;
return {
title,
description: desc,
openGraph: { title, description: desc, siteName: "TriplePick", type: "website", locale: "ko_KR" },
twitter: { card: "summary_large_image", title, description: desc },
};
}
export default async function MatchPage({
params,
searchParams,
}: {
params: Promise<{ matchId: string }>;
searchParams: Promise<{ lang?: string }>;
}) {
const { matchId } = await params;
const { lang: langParam } = await searchParams;
const lang = parseLang(langParam);
const t = dict(lang);
const match = GROUP_A.find((m) => m.matchId === matchId);
if (!match) notFound();
const predictions = getPredictions(match, lang);
const crowd = getCrowd(match);
const aShort = teamShort(match.teamA, lang);
const bShort = teamShort(match.teamB, lang);
const hook = lang === "en" ? t.hook(aShort, bShort) : match.hookText;
return (
<main className="shell">
<Hero
back
lang={lang}
share={{
url: matchUrl(match.matchId),
title: `${aShort} vs ${bShort} — TriplePick`,
text:
lang === "en"
? `${hook} — see all 3 AI picks and make yours!`
: `${hook} — AI 셋의 예측을 보고 너의 픽을 찍어봐!`,
}}
/>
{/* 경기별 후킹 카피 (한 줄) */}
<h1 className="mt-6 flex items-center justify-center gap-2 whitespace-nowrap text-[19px] font-extrabold leading-snug">
<span className="text-[var(--green)]"></span>
{hook}
<span className="text-[var(--green)]"></span>
</h1>
<MatchupHUD match={match} lang={lang} />
<Arena
match={match}
predictions={predictions}
crowd={crowd}
shareUrl={matchUrl(match.matchId)}
lang={lang}
/>
<Footer lang={lang} />
</main>
);
}

View File

@ -1,37 +0,0 @@
import Hero from "@/components/Hero";
import ScheduleBoard from "@/components/ScheduleBoard";
import Ado2Ad from "@/components/Ado2Ad";
import Footer from "@/components/Footer";
import { parseLang, dict } from "@/lib/i18n";
// L1. 전체 일정 대시보드 (랜딩 진입)
export default async function Home({
searchParams,
}: {
searchParams: Promise<{ lang?: string }>;
}) {
const { lang: langParam } = await searchParams;
const lang = parseLang(langParam);
const t = dict(lang);
return (
<main className="shell">
<Hero lang={lang} />
{/* 기간 안내 + CTA 영역 */}
<div className="mt-5 rounded-2xl border border-[var(--line-d)] bg-[var(--bg2)] p-4 text-center">
<p className="whitespace-pre-line text-[14px] font-bold leading-snug">
{t.introLead}
</p>
<p className="mt-1 text-[14px] font-bold leading-snug text-[var(--green)]">
{t.prizeLine1}
<br />
<span className="whitespace-nowrap">{t.prizeLine2}</span>
</p>
</div>
<ScheduleBoard lang={lang} />
<Ado2Ad lang={lang} />
<Footer lang={lang} />
</main>
);
}

50
backend/.env.example Normal file
View File

@ -0,0 +1,50 @@
# ─────────────────────────────────────────────────────────────
# TriplePick 백엔드 환경변수 — 복사: cp .env.example .env
# ─────────────────────────────────────────────────────────────
# DB (docker-compose 내부 네트워크 기본값). 로컬 단독이면 localhost 로.
DATABASE_URL=postgresql+asyncpg://triplepick:triplepick@db:5432/triplepick
# 일반
TIMEZONE=Asia/Seoul
CORS_ORIGINS=*
PUBLIC_ORIGIN=http://localhost:8080
# 투표 윈도우 (도메인 규칙)
VOTE_OPEN_HOURS_BEFORE=48 # 오픈 = 킥오프 D-2
VOTE_LOCK_MINUTES_BEFORE=60 # 마감 = 킥오프 1시간 전
DEMO_FORCE_OPEN=true # 운영 배포 시 false
# 스케줄링 서버 (워커)
# 경기 일정 동적 수집: 매일 KST 09:00 외부 소스에서 일정·투표시간 갱신
SCHEDULE_SYNC_HOUR=9
SCHEDULE_SYNC_MINUTE=0
SCHEDULE_SOURCE=openfootball # openfootball(키 불필요) | football-data(토큰) | fallback
SCHEDULE_URL=https://raw.githubusercontent.com/openfootball/worldcup.json/master/2026/worldcup.json
SCHEDULE_GROUP=Group A
FOOTBALL_DATA_TOKEN= # SCHEDULE_SOURCE=football-data 일 때
FOOTBALL_DATA_COMPETITION=WC
AI_GENERATE_HOUR=0 # 매일 KST 00:05 AI 예측 생성
AI_GENERATE_MINUTE=5
RESULT_EMAIL_DELAY_MINUTES=180 # 경기 종료 3시간 후 결과 메일
STATUS_TICK_SECONDS=60
# 관리자 (강한 토큰으로 교체)
ADMIN_API_TOKEN=change-me-admin-token
# ── 외부 연동: AI 3모델 (실연동, 키 없으면 해당 모델 생성 생략) ──
OPENAI_API_KEY=
OPENAI_MODEL=gpt-4o
ANTHROPIC_API_KEY=
ANTHROPIC_MODEL=claude-opus-4-8
GOOGLE_API_KEY=
GOOGLE_MODEL=gemini-2.5-flash
# ── 외부 연동: 이메일 (SMTP, 실연동) ──
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=TriplePick <no-reply@triplepick.app>
SMTP_STARTTLS=true

22
backend/Dockerfile Normal file
View File

@ -0,0 +1,22 @@
# TriplePick 백엔드 — API 서버 & 워커 공용 이미지.
# docker-compose 에서 command 를 달리해 두 서비스로 띄운다.
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
EXPOSE 8000
# 기본: API 서버. 워커는 compose 에서 command 오버라이드.
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

0
backend/app/__init__.py Normal file
View File

89
backend/app/config.py Normal file
View File

@ -0,0 +1,89 @@
"""애플리케이션 설정 — 환경변수(.env)에서 로드.
pydantic-settings 타입 검증. 누락 조용한 실패 없이 명확하게 동작.
시간 윈도우(투표 오픈/마감), 스케줄러 시각, 외부 연동 키를 모두 여기서 관리.
"""
from __future__ import annotations
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env", env_file_encoding="utf-8", extra="ignore"
)
# ── 데이터베이스 ─────────────────────────────────────────
# docker-compose 의 db 서비스를 가리키는 기본값. 로컬 단독 실행 시 .env 로 덮어쓴다.
database_url: str = (
"postgresql+asyncpg://triplepick:triplepick@db:5432/triplepick"
)
# ── 일반 ────────────────────────────────────────────────
timezone: str = "Asia/Seoul" # 모든 경기 시각의 기준 (KST)
cors_origins: str = "*" # 콤마구분. nginx 프록시 사용 시 동일 출처라 보통 불필요.
public_origin: str = "http://localhost:8080" # 공유 딥링크 베이스
# ── 투표 윈도우 (도메인 규칙) ─────────────────────────────
# 오픈 = 킥오프 - VOTE_OPEN_HOURS_BEFORE (D-2 = 48h)
vote_open_hours_before: int = 48
# 마감 = 킥오프 1시간 전 (경기 시작 1시간 전까지 투표). lock 오프셋(분).
vote_lock_minutes_before: int = 60
# 데모: True 면 오픈 게이트 무시(항상 투표 가능). 운영 배포 시 False.
demo_force_open: bool = True
# ── 스케줄러 (별도 워커 프로세스 = "스케줄링 서버") ───────
# 경기 일정 동적 수집: 매일 KST 09:00 외부 소스 크롤링 → 경기·투표시간 갱신.
schedule_sync_hour: int = 9
schedule_sync_minute: int = 0
# 소스: openfootball(키 불필요·기본) | football-data(토큰 필요) | fallback(하드코딩만)
schedule_source: str = "openfootball"
schedule_url: str = (
"https://raw.githubusercontent.com/openfootball/worldcup.json/master/2026/worldcup.json"
)
schedule_group: str = "Group A" # 추적할 그룹 (openfootball 라벨)
football_data_token: str = "" # schedule_source=football-data 일 때
football_data_competition: str = "WC"
# 매일 AI 예측 생성 시각 (KST). 기능정의서: 매일 0시 1회 생성.
ai_generate_hour: int = 0
ai_generate_minute: int = 5
# 결과 메일: 경기 종료(결과 입력) 후 이 시간(분) 경과하면 발송. 기본 180분(3시간).
result_email_delay_minutes: int = 180
# 상태 전이 틱 주기(초): scheduled→open→locked 자동 갱신.
status_tick_seconds: int = 60
# ── 관리자 ──────────────────────────────────────────────
admin_api_token: str = "change-me-admin-token"
# ── 외부 연동: AI 3모델 (실연동) ─────────────────────────
openai_api_key: str = ""
openai_model: str = "gpt-4o"
anthropic_api_key: str = ""
anthropic_model: str = "claude-opus-4-8"
google_api_key: str = ""
google_model: str = "gemini-2.5-flash"
# ── 외부 연동: 이메일 (SMTP, 실연동) ─────────────────────
smtp_host: str = ""
smtp_port: int = 587
smtp_user: str = ""
smtp_password: str = ""
smtp_from: str = "TriplePick <no-reply@triplepick.app>"
smtp_starttls: bool = True
@property
def cors_origin_list(self) -> list[str]:
if self.cors_origins.strip() == "*":
return ["*"]
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()

39
backend/app/database.py Normal file
View File

@ -0,0 +1,39 @@
"""SQLAlchemy 2.0 async 엔진 + 세션 팩토리.
API(main.py) 워커(worker.py) 공유한다. asyncpg 드라이버 사용.
"""
from __future__ import annotations
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase
from .config import settings
class Base(DeclarativeBase):
pass
engine = create_async_engine(settings.database_url, echo=False, pool_pre_ping=True)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""FastAPI 의존성: 요청당 세션."""
async with SessionLocal() as session:
yield session
async def init_db() -> None:
"""테이블 생성 (없으면). 단순화를 위해 alembic 대신 create_all 사용."""
from . import models # noqa: F401 (모델 등록)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)

130
backend/app/domain.py Normal file
View File

@ -0,0 +1,130 @@
"""도메인 헬퍼 — 투표 단계(phase) 계산 + ORM→응답 스키마 직렬화.
phase now 기준 동적 계산 (lib/schedule.ts matchPhase 동일 규칙):
result 존재 finished
now >= lockAt locked
now < opensAt scheduled
open
"""
from __future__ import annotations
from datetime import datetime, timezone
from .config import settings
from .models import AIPrediction, CrowdStats, Match
from .schemas import (
AIPredictionOut,
CrowdStatsOut,
MatchOut,
MatchResult,
Team,
)
def now_utc() -> datetime:
return datetime.now(timezone.utc)
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 compute_phase(m: Match, now: datetime | None = None) -> str:
now = now or now_utc()
if m.result_outcome is not None:
return "finished"
if now >= ensure_aware(m.lock_at):
return "locked"
if now < ensure_aware(m.opens_at):
return "scheduled"
return "open"
def is_open_for_voting(m: Match, now: datetime | None = None) -> bool:
"""제출 허용 여부. demo_force_open 이면 마감 전까지 항상 오픈."""
now = now or now_utc()
if m.result_outcome is not None:
return False
if now >= ensure_aware(m.lock_at):
return False
if settings.demo_force_open:
return True
return now >= ensure_aware(m.opens_at)
def _team_a(m: Match) -> Team:
return Team(
name=m.team_a_name, shortName=m.team_a_short, code=m.team_a_code, flag=m.team_a_flag
)
def _team_b(m: Match) -> Team:
return Team(
name=m.team_b_name, shortName=m.team_b_short, code=m.team_b_code, flag=m.team_b_flag
)
def prediction_out(p: AIPrediction, lang: str = "ko") -> AIPredictionOut:
reason = p.reason_en if lang == "en" and p.reason_en else p.reason_ko
return AIPredictionOut(
matchId=p.match_id,
model=p.model, # type: ignore[arg-type]
outcome=p.outcome, # type: ignore[arg-type]
scoreA=p.score_a,
scoreB=p.score_b,
confidencePct=p.confidence_pct,
reasonShort=reason,
generatedAt=p.generated_at.date().isoformat() if p.generated_at else "",
)
def crowd_out(c: CrowdStats | None, match_id: str) -> CrowdStatsOut:
if c is None:
return CrowdStatsOut(matchId=match_id, total=0, teamAWin=0, draw=0, teamBWin=0)
return CrowdStatsOut(
matchId=match_id,
total=c.total,
teamAWin=c.team_a_win,
draw=c.draw,
teamBWin=c.team_b_win,
)
def match_out(
m: Match,
lang: str = "ko",
include_predictions: bool = True,
now: datetime | None = None,
) -> MatchOut:
result = None
if m.result_outcome is not None:
result = MatchResult(
scoreA=m.result_score_a or 0,
scoreB=m.result_score_b or 0,
outcome=m.result_outcome, # type: ignore[arg-type]
)
preds: list[AIPredictionOut] = []
if include_predictions:
# 모델 순서 고정: GPT, Claude, Gemini
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))
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(),
venue=m.venue,
opensAt=m.opens_at.isoformat(),
lockAt=m.lock_at.isoformat(),
status=m.status,
phase=compute_phase(m, now),
votingOpen=is_open_for_voting(m, now),
hookText=m.hook_text,
result=result,
predictions=preds,
crowd=crowd_out(m.crowd, m.match_id),
)

49
backend/app/main.py Normal file
View File

@ -0,0 +1,49 @@
"""FastAPI 앱 진입점 — API 서버.
시작 DB 초기화 + 시드. CORS 허용. 라우터 등록.
스케줄러는 별도 워커 컨테이너(worker.py)에서 실행한다(중복 방지).
"""
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .config import settings
from .database import init_db
from .routers import admin, leaderboard, matches, predictions
from .seed import seed_if_empty
logging.basicConfig(level=logging.INFO)
log = logging.getLogger("triplepick")
@asynccontextmanager
async def lifespan(app: FastAPI): # noqa: ANN201
await init_db()
await seed_if_empty()
log.info("API ready")
yield
app = FastAPI(title="TriplePick API", version="1.0.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origin_list,
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(matches.router)
app.include_router(predictions.router)
app.include_router(leaderboard.router)
app.include_router(admin.router)
@app.get("/api/health")
async def health() -> dict:
return {"ok": True, "service": "triplepick-api"}

154
backend/app/models.py Normal file
View File

@ -0,0 +1,154 @@
"""ORM 모델 — 기능정의서 데이터모델 + lib/types.ts 와 정합.
테이블:
- matches 경기 (/시각/투표윈도우/상태/결과)
- ai_predictions GPT/Claude/Gemini 예측 (경기 × 모델)
- crowd_stats 군중 투표 분포 (경기당 1, 원자적 증분)
- user_predictions 유저 (이메일 식별, 채점/알림 플래그 포함)
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
Integer,
String,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .database import Base
# Outcome: TEAM_A_WIN | DRAW | TEAM_B_WIN
# ModelName: GPT | Claude | Gemini
# MatchStatus: scheduled | open | locked | live | finished
class Match(Base):
__tablename__ = "matches"
match_id: Mapped[str] = mapped_column(String, primary_key=True)
round_label: Mapped[str] = mapped_column(String, default="")
group: Mapped[str] = mapped_column(String, default="A")
# 팀 정보 (표시명/약식/코드/이모지) — 분리 컬럼으로 저장
team_a_name: Mapped[str] = mapped_column(String)
team_a_short: Mapped[str] = mapped_column(String)
team_a_code: Mapped[str] = mapped_column(String)
team_a_flag: Mapped[str] = mapped_column(String, default="")
team_b_name: Mapped[str] = mapped_column(String)
team_b_short: Mapped[str] = mapped_column(String)
team_b_code: Mapped[str] = mapped_column(String)
team_b_flag: Mapped[str] = mapped_column(String, default="")
venue: Mapped[str] = mapped_column(String, default="")
hook_text: Mapped[str] = mapped_column(String, default="")
# 모든 시각은 timezone-aware (UTC 저장, KST 환산은 표현 계층)
kickoff_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
opens_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
lock_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
status: Mapped[str] = mapped_column(String, default="scheduled")
# 결과 (입력 전 None)
result_score_a: Mapped[int | None] = mapped_column(Integer, nullable=True)
result_score_b: Mapped[int | None] = mapped_column(Integer, nullable=True)
result_outcome: Mapped[str | None] = mapped_column(String, nullable=True)
finished_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
results_emailed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
predictions: Mapped[list["AIPrediction"]] = relationship(
back_populates="match", cascade="all, delete-orphan"
)
crowd: Mapped["CrowdStats | None"] = relationship(
back_populates="match", uselist=False, cascade="all, delete-orphan"
)
class AIPrediction(Base):
__tablename__ = "ai_predictions"
__table_args__ = (UniqueConstraint("match_id", "model", name="uq_match_model"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
match_id: Mapped[str] = mapped_column(
ForeignKey("matches.match_id", ondelete="CASCADE")
)
model: Mapped[str] = mapped_column(String) # GPT | Claude | Gemini
outcome: Mapped[str] = mapped_column(String)
score_a: Mapped[int] = mapped_column(Integer)
score_b: Mapped[int] = mapped_column(Integer)
confidence_pct: Mapped[int] = mapped_column(Integer)
reason_ko: Mapped[str] = mapped_column(String, default="")
reason_en: Mapped[str] = mapped_column(String, default="")
generated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
# 실연동 LLM 출력인지 시드 데이터인지 구분 (재생성 제어용)
source: Mapped[str] = mapped_column(String, default="seed") # seed | llm
match: Mapped["Match"] = relationship(back_populates="predictions")
class CrowdStats(Base):
__tablename__ = "crowd_stats"
match_id: Mapped[str] = mapped_column(
ForeignKey("matches.match_id", ondelete="CASCADE"), primary_key=True
)
total: Mapped[int] = mapped_column(Integer, default=0)
team_a_win: Mapped[int] = mapped_column(Integer, default=0)
draw: Mapped[int] = mapped_column(Integer, default=0)
team_b_win: Mapped[int] = mapped_column(Integer, default=0)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
match: Mapped["Match"] = relationship(back_populates="crowd")
class UserPrediction(Base):
__tablename__ = "user_predictions"
__table_args__ = (
UniqueConstraint("match_id", "device_id", name="uq_match_device"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
match_id: Mapped[str] = mapped_column(
ForeignKey("matches.match_id", ondelete="CASCADE")
)
device_id: Mapped[str] = mapped_column(String) # 비로그인 식별 (클라 생성 uuid)
outcome: Mapped[str] = mapped_column(String)
score_a: Mapped[int] = mapped_column(Integer)
score_b: Mapped[int] = mapped_column(Integer)
email: Mapped[str | None] = mapped_column(String, nullable=True)
notify: Mapped[bool] = mapped_column(Boolean, default=False)
# 채점 (결과 입력 후 갱신)
points: Mapped[int | None] = mapped_column(Integer, nullable=True)
scored_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
notified: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)

View File

View File

@ -0,0 +1,72 @@
"""관리자 API — Bearer 토큰 인증. 경기/AI예측 upsert + 결과 입력(채점)."""
from __future__ import annotations
from fastapi import APIRouter, Depends, Header, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..config import settings
from ..database import get_db
from ..domain import now_utc
from ..models import AIPrediction, Match
from ..schemas import AdminAIPredictionIn, AdminSetResultIn, GenericOk
from ..services.grading import apply_result
router = APIRouter(prefix="/api/admin", tags=["admin"])
def require_admin(authorization: str = Header(default="")) -> None:
token = authorization.removeprefix("Bearer ").strip()
if not token or token != settings.admin_api_token:
raise HTTPException(status_code=401, detail="UNAUTHORIZED")
@router.post("/ai-predictions", response_model=GenericOk, dependencies=[Depends(require_admin)])
async def upsert_ai_prediction(
body: AdminAIPredictionIn, db: AsyncSession = Depends(get_db)
) -> GenericOk:
match = (
await db.execute(select(Match).where(Match.match_id == body.matchId))
).scalars().first()
if not match:
raise HTTPException(status_code=404, detail="MATCH_NOT_FOUND")
pred = (
await db.execute(
select(AIPrediction).where(
AIPrediction.match_id == body.matchId, AIPrediction.model == body.model
)
)
).scalars().first()
if pred is None:
pred = AIPrediction(match_id=body.matchId, model=body.model)
db.add(pred)
pred.outcome = body.outcome
pred.score_a = body.scoreA
pred.score_b = body.scoreB
pred.confidence_pct = body.confidencePct
pred.reason_ko = body.reasonKo
pred.reason_en = body.reasonEn
pred.generated_at = now_utc()
pred.source = "admin"
await db.commit()
return GenericOk(ok=True, detail="upserted")
@router.post("/result", response_model=GenericOk, dependencies=[Depends(require_admin)])
async def set_result(
body: AdminSetResultIn, db: AsyncSession = Depends(get_db)
) -> GenericOk:
match = (
await db.execute(select(Match).where(Match.match_id == body.matchId))
).scalars().first()
if not match:
raise HTTPException(status_code=404, detail="MATCH_NOT_FOUND")
graded = await apply_result(db, match, body.scoreA, body.scoreB)
# 결과 메일은 워커가 result_email_delay_minutes 경과 후 발송(@finished_at 기준).
return GenericOk(
ok=True,
detail="result set & graded",
extra={"gradedPicks": graded, "emailsSentBy": "worker"},
)

View File

@ -0,0 +1,80 @@
"""리더보드 API — 누적 포인트 1위 (docs/SCORING.md §3 랭킹 규칙).
1 정렬: 누적 포인트. 동점: 정확스코어 횟수 적중률 참여수 최초도달.
주최측/임직원 배제(P1). 이메일은 마스킹하여 노출.
"""
from __future__ import annotations
from collections import defaultdict
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..database import get_db
from ..models import UserPrediction
from ..scoring import SCORE, is_excluded
from ..schemas import LeaderboardOut, StandingOut
router = APIRouter(prefix="/api/leaderboard", tags=["leaderboard"])
def _mask(email: str) -> str:
name, _, domain = email.partition("@")
head = name[:2] if len(name) >= 2 else name
return f"{head}{'*' * max(1, len(name) - 2)}@{domain}"
@router.get("", response_model=LeaderboardOut)
async def leaderboard(
limit: int = Query(50, ge=1, le=200), db: AsyncSession = Depends(get_db)
) -> LeaderboardOut:
rows = (
await db.execute(
select(UserPrediction).where(
UserPrediction.points.is_not(None),
UserPrediction.email.is_not(None),
)
)
).scalars().all()
agg: dict[str, dict] = defaultdict(
lambda: {"points": 0, "exact": 0, "played": 0, "first": None}
)
matches: set[str] = set()
for r in rows:
email = (r.email or "").strip().lower()
if not email or is_excluded(email):
continue
matches.add(r.match_id)
a = agg[email]
a["points"] += r.points or 0
a["played"] += 1
if (r.points or 0) >= SCORE["EXACT"]:
a["exact"] += 1
ts = r.scored_at or r.created_at
if a["first"] is None or (ts and ts < a["first"]):
a["first"] = ts
ranked = sorted(
agg.items(),
key=lambda kv: (
-kv[1]["points"],
-kv[1]["exact"],
-(kv[1]["points"] / kv[1]["played"] if kv[1]["played"] else 0),
-kv[1]["played"],
kv[1]["first"].timestamp() if kv[1]["first"] else 0,
),
)
standings = [
StandingOut(
rank=i + 1,
emailMasked=_mask(email),
totalPoints=v["points"],
exactCount=v["exact"],
matchesPlayed=v["played"],
)
for i, (email, v) in enumerate(ranked[:limit])
]
return LeaderboardOut(standings=standings, scoredMatches=len(matches))

View File

@ -0,0 +1,47 @@
"""공개 읽기 API — 경기 목록 / 경기 상세 (AI예측 + crowd 포함)."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from ..database import get_db
from ..domain import match_out
from ..models import Match
from ..schemas import MatchOut
router = APIRouter(prefix="/api/matches", tags=["matches"])
@router.get("", response_model=list[MatchOut])
async def list_matches(
lang: str = Query("ko"),
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]
@router.get("/{match_id}", response_model=MatchOut)
async def get_match(
match_id: str,
lang: str = Query("ko"),
db: AsyncSession = Depends(get_db),
) -> MatchOut:
m = (
await db.execute(
select(Match)
.where(Match.match_id == match_id)
.options(selectinload(Match.predictions), selectinload(Match.crowd))
)
).scalars().first()
if not m:
raise HTTPException(status_code=404, detail="MATCH_NOT_FOUND")
return match_out(m, lang)

View File

@ -0,0 +1,116 @@
"""픽 제출 API — 검증 · 중복방지(1회 수정) · crowd 원자적 증분 · 매칭 모델 계산."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, update
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 ..models import AIPrediction, CrowdStats, Match, UserPrediction
from ..scoring import outcome_of
from ..schemas import SubmitPredictionIn, SubmitPredictionOut
router = APIRouter(prefix="/api/predictions", tags=["predictions"])
_COL = {"TEAM_A_WIN": "team_a_win", "DRAW": "draw", "TEAM_B_WIN": "team_b_win"}
async def _adjust_crowd(
db: AsyncSession, match_id: str, *, add: str | None, remove: str | None
) -> None:
"""crowd_stats 원자적 증분/보정 (Postgres UPDATE)."""
values: dict = {}
if add:
col = _COL[add]
values[col] = CrowdStats.__table__.c[col] + 1
if not remove: # 신규 제출이면 total +1
values["total"] = CrowdStats.total + 1
if remove and remove != add:
col = _COL[remove]
values[col] = CrowdStats.__table__.c[col] - 1
if values:
await db.execute(
update(CrowdStats).where(CrowdStats.match_id == match_id).values(**values)
)
@router.post("", response_model=SubmitPredictionOut)
async def submit_prediction(
body: SubmitPredictionIn, db: AsyncSession = Depends(get_db)
) -> SubmitPredictionOut:
match = (
await db.execute(
select(Match)
.where(Match.match_id == body.matchId)
.options(selectinload(Match.predictions))
)
).scalars().first()
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")
# outcome 은 스코어에서 도출 (입력과 불일치해도 스코어 기준으로 정규화)
outcome = outcome_of(body.scoreA, body.scoreB)
existing = (
await db.execute(
select(UserPrediction).where(
UserPrediction.match_id == body.matchId,
UserPrediction.device_id == body.deviceId,
)
)
).scalars().first()
if existing:
# 마감 전 1회 수정: 분포 보정 (이전 outcome 제거, 새 outcome 추가)
await _adjust_crowd(db, body.matchId, add=outcome, remove=existing.outcome)
existing.outcome = outcome
existing.score_a = body.scoreA
existing.score_b = body.scoreB
if body.email:
existing.email = str(body.email)
existing.notify = body.notify
pred = existing
else:
await _adjust_crowd(db, body.matchId, add=outcome, remove=None)
pred = UserPrediction(
match_id=body.matchId,
device_id=body.deviceId,
outcome=outcome,
score_a=body.scoreA,
score_b=body.scoreB,
email=str(body.email) if body.email else None,
notify=body.notify,
)
db.add(pred)
await db.commit()
# 매칭 AI 모델 (같은 outcome) + 정확스코어 일치 여부
matched: list[str] = []
exact = False
for p in match.predictions:
if p.outcome == outcome:
matched.append(p.model)
if p.score_a == body.scoreA and p.score_b == body.scoreB:
exact = True
crowd = (
await db.execute(
select(CrowdStats).where(CrowdStats.match_id == body.matchId)
)
).scalars().first()
return SubmitPredictionOut(
ok=True,
predictionId=f"{body.matchId}_{body.deviceId}",
matchedModels=matched, # type: ignore[arg-type]
exactMatch=exact,
crowd=crowd_out(crowd, body.matchId),
)

View File

@ -0,0 +1,94 @@
"""경기 일정 SSOT — lib/schedule.ts(Group A 정식 일정, KST) 와 동일.
투표 오픈 = 킥오프 - settings.vote_open_hours_before (D-2 = 48h)
투표 마감 = 킥오프 - settings.vote_lock_minutes_before (기본 0 = 킥오프 정각)
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
from .config import settings
# 팀 정의 (코드 → 표시명/약식/이모지)
TEAMS = {
"KOR": {"name": "Korea Republic", "shortName": "한국", "code": "KOR", "flag": "🇰🇷"},
"CZE": {"name": "Czechia", "shortName": "체코", "code": "CZE", "flag": "🇨🇿"},
"MEX": {"name": "Mexico", "shortName": "멕시코", "code": "MEX", "flag": "🇲🇽"},
"RSA": {"name": "South Africa", "shortName": "남아공", "code": "RSA", "flag": "🇿🇦"},
}
# 외부 소스의 팀 표기 → 내부 코드 매핑 (openfootball / football-data 등 변형 포괄).
NAME_TO_CODE = {
"korea republic": "KOR",
"south korea": "KOR",
"korea": "KOR",
"czechia": "CZE",
"czech republic": "CZE",
"mexico": "MEX",
"south africa": "RSA",
}
def code_for(name: str) -> str | None:
return NAME_TO_CODE.get(name.strip().lower())
@dataclass(frozen=True)
class MatchSeed:
match_id: str
round_label: str
team_a: str # 코드
team_b: str
kickoff_iso: str # ISO with +09:00
venue: str
hook_text: str
# Group A 전체 6경기 (출처: lib/schedule.ts)
GROUP_A: list[MatchSeed] = [
MatchSeed(
"A_MEX_RSA_20260612", "Match 01 · 개막전", "MEX", "RSA",
"2026-06-12T04:00:00+09:00", "Estadio Azteca · Mexico City",
"글로벌 축구 개막전, AI는 개최국을 믿을까",
),
MatchSeed(
"A_KOR_CZE_20260612", "Match 01", "KOR", "CZE",
"2026-06-12T11:00:00+09:00", "Estadio Guadalajara (Akron)",
"한국 첫 경기, AI의 선택은 갈렸다",
),
MatchSeed(
"A_CZE_RSA_20260619", "Match 02", "CZE", "RSA",
"2026-06-19T01:00:00+09:00", "USA (TBD)",
"체코 vs 남아공, AI의 예측은",
),
MatchSeed(
"A_MEX_KOR_20260619", "Match 02", "MEX", "KOR",
"2026-06-19T10:00:00+09:00", "Estadio Guadalajara (Akron)",
"개최국 멕시코 vs 한국, AI는 누구 편",
),
MatchSeed(
"A_CZE_MEX_20260625", "Match 03", "CZE", "MEX",
"2026-06-25T10:00:00+09:00", "Estadio Azteca · Mexico City",
"체코 vs 멕시코, 운명의 최종전",
),
MatchSeed(
"A_RSA_KOR_20260625", "Match 03", "RSA", "KOR",
"2026-06-25T10:00:00+09:00", "Estadio BBVA · Monterrey",
"한국 16강의 갈림길, AI의 마지막 예측",
),
]
FEATURED_MATCH_ID = "A_KOR_CZE_20260612"
def parse_kickoff(iso: str) -> datetime:
return datetime.fromisoformat(iso)
def opens_at(kickoff: datetime) -> datetime:
return kickoff - timedelta(hours=settings.vote_open_hours_before)
def lock_at(kickoff: datetime) -> datetime:
return kickoff - timedelta(minutes=settings.vote_lock_minutes_before)

127
backend/app/schemas.py Normal file
View File

@ -0,0 +1,127 @@
"""Pydantic 스키마 — API 요청/응답 계약.
프론트(lib/types.ts) 1:1 정합. 시각은 ISO8601 문자열로 직렬화.
"""
from __future__ import annotations
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, EmailStr, Field, field_validator
Outcome = Literal["TEAM_A_WIN", "DRAW", "TEAM_B_WIN"]
ModelName = Literal["GPT", "Claude", "Gemini"]
class Team(BaseModel):
name: str
shortName: str
code: str
flag: str = ""
class MatchResult(BaseModel):
scoreA: int
scoreB: int
outcome: Outcome
class AIPredictionOut(BaseModel):
matchId: str
model: ModelName
outcome: Outcome
scoreA: int
scoreB: int
confidencePct: int
reasonShort: str
generatedAt: str
class CrowdStatsOut(BaseModel):
matchId: str
total: int
teamAWin: int
draw: int
teamBWin: int
class MatchOut(BaseModel):
matchId: str
roundLabel: str
group: str
teamA: Team
teamB: Team
kickoffKst: str
venue: str
opensAt: str
lockAt: str
status: str
phase: str # scheduled | open | locked | finished (now 기준 계산)
votingOpen: bool # 현재 제출 허용 여부 (demo_force_open 반영)
hookText: str
result: MatchResult | None = None
predictions: list[AIPredictionOut] = Field(default_factory=list)
crowd: CrowdStatsOut | None = None
# ── 픽 제출 ─────────────────────────────────────────────────
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)
email: EmailStr | None = None
notify: bool = False
@field_validator("outcome")
@classmethod
def outcome_matches_score(cls, v: str, info): # noqa: ANN001
# outcome 은 스코어에서 파생되어야 일관됨 — 프론트가 자동계산하지만 서버에서 재검증
return v
class SubmitPredictionOut(BaseModel):
ok: bool
predictionId: str
matchedModels: list[ModelName]
exactMatch: bool
crowd: CrowdStatsOut
# ── 리더보드 ────────────────────────────────────────────────
class StandingOut(BaseModel):
rank: int
emailMasked: str
totalPoints: int
exactCount: int
matchesPlayed: int
class LeaderboardOut(BaseModel):
standings: list[StandingOut]
scoredMatches: int
# ── 관리자 ──────────────────────────────────────────────────
class AdminAIPredictionIn(BaseModel):
matchId: str
model: ModelName
outcome: Outcome
scoreA: int = Field(ge=0, le=20)
scoreB: int = Field(ge=0, le=20)
confidencePct: int = Field(ge=0, le=100)
reasonKo: str = ""
reasonEn: str = ""
class AdminSetResultIn(BaseModel):
matchId: str
scoreA: int = Field(ge=0, le=50)
scoreB: int = Field(ge=0, le=50)
class GenericOk(BaseModel):
ok: bool
detail: str = ""
extra: dict | None = None

42
backend/app/scoring.py Normal file
View File

@ -0,0 +1,42 @@
"""채점 로직 — docs/SCORING.md (SSOT) 그대로 구현.
배점: 정확 스코어 5 · 근접(승패+득실차) 3 · 승패 2 · 부분( 득점 일치) 1 · 빗나감 0.
단조 증가(정확>근접>승패>부분>빗나감). 배점 수치는 SCORE 상수만 교체하면 .
"""
from __future__ import annotations
# 팀 확정 후 이 상수만 교체 (docs/SCORING.md §2)
SCORE = {"EXACT": 5, "CLOSE": 3, "OUTCOME": 2, "PARTIAL": 1, "MISS": 0}
def outcome_of(score_a: int, score_b: int) -> str:
if score_a > score_b:
return "TEAM_A_WIN"
if score_a < score_b:
return "TEAM_B_WIN"
return "DRAW"
def score_prediction(
pick_a: int, pick_b: int, result_a: int, result_b: int
) -> 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"]
# ── 자격 · 배제 (docs/SCORING.md §4, P1 구현) ──────────────────
STAFF_EMAILS: set[str] = set() # 운영진/임직원 블록리스트
STAFF_DOMAINS = ["o2o.kr", "aio2o.kr"]
def is_excluded(email: str) -> bool:
e = email.strip().lower()
return e in STAFF_EMAILS or any(e.endswith("@" + d) for d in STAFF_DOMAINS)

63
backend/app/seed.py Normal file
View File

@ -0,0 +1,63 @@
"""DB 시드 — 경기 6개 + AI 예측(부트스트랩) + crowd baseline.
init_db 직후 호출. 이미 경기가 있으면 건너뛴다(idempotent).
"""
from __future__ import annotations
import logging
from sqlalchemy import select
from .database import SessionLocal
from .models import CrowdStats, Match
from .schedule_data import GROUP_A, lock_at, opens_at, parse_kickoff, TEAMS
from .seed_data import generate_crowd
log = logging.getLogger("triplepick.seed")
async def seed_if_empty() -> None:
async with SessionLocal() as db:
existing = (await db.execute(select(Match.match_id))).scalars().first()
if existing:
log.info("seed: matches already present — skip")
return
for s in GROUP_A:
kickoff = parse_kickoff(s.kickoff_iso)
ta, tb = TEAMS[s.team_a], TEAMS[s.team_b]
match = Match(
match_id=s.match_id,
round_label=s.round_label,
group="A",
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=s.venue,
hook_text=s.hook_text,
kickoff_at=kickoff,
opens_at=opens_at(kickoff),
lock_at=lock_at(kickoff),
status="scheduled",
)
db.add(match)
crowd = generate_crowd(s.match_id)
db.add(
CrowdStats(
match_id=s.match_id,
total=crowd["total"],
team_a_win=crowd["teamAWin"],
draw=crowd["draw"],
team_b_win=crowd["teamBWin"],
)
)
# AI 예측은 시드하지 않는다 — 워커가 실 API 로 생성.
await db.commit()
log.info("seed: %d matches + crowd seeded (예측 없음 — 워커가 실 API 생성)", len(GROUP_A))

11
backend/app/seed_data.py Normal file
View File

@ -0,0 +1,11 @@
"""부트스트랩 데이터.
AI 예측은 시드하지 않는다 3모델 전부 워커가 API 생성(기동 1 + 매일 00:05).
crowd(참여수/분포) 더미 baseline 없이 0 에서 시작한다 실제 유저 제출로만 증분.
"""
from __future__ import annotations
def generate_crowd(_match_id: str) -> dict:
"""초기 군중 = 0. 실제 유저 픽 제출로만 증가(순수 실데이터)."""
return {"total": 0, "teamAWin": 0, "draw": 0, "teamBWin": 0}

View File

152
backend/app/services/ai.py Normal file
View File

@ -0,0 +1,152 @@
"""AI 3모델 실연동 — GPT(OpenAI) · Claude(Anthropic) · Gemini(Google).
모델에 동일한 경기 컨텍스트를 주고 구조화된 예측 JSON 받는다.
키가 없으면 ProviderUnavailable 던지고(조용한 실패 0), 워커는 모델별로
독립 처리하여 가능한 것만 갱신한다.
반환 표준 dict:
{outcome, scoreA, scoreB, confidencePct, reasonKo, reasonEn}
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from ..config import settings
log = logging.getLogger("triplepick.ai")
OUTCOMES = {"TEAM_A_WIN", "DRAW", "TEAM_B_WIN"}
class ProviderUnavailable(RuntimeError):
"""API 키 미설정 등으로 해당 모델을 호출할 수 없음."""
@dataclass
class MatchContext:
team_a: str # 표시명 (예: Korea Republic)
team_b: str
venue: str
kickoff: str # ISO
def _prompt(ctx: MatchContext) -> str:
return (
f"You are a football match analyst. Predict the result of this match.\n"
f"Match: {ctx.team_a} (Team A, home) vs {ctx.team_b} (Team B, away)\n"
f"Venue: {ctx.venue}\nKickoff: {ctx.kickoff}\n\n"
f"Predict the final regulation-time score. "
f"Respond with a single JSON object and nothing else, with keys:\n"
f' "scoreA": integer 0-9 (Team A goals),\n'
f' "scoreB": integer 0-9 (Team B goals),\n'
f' "outcome": one of "TEAM_A_WIN" | "DRAW" | "TEAM_B_WIN" (must match the score),\n'
f' "confidencePct": integer 0-100 (confidence in the predicted winner; '
f"for a draw, confidence in the draw),\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'
)
# JSON Schema (구조화 출력용 — Anthropic/OpenAI 공통)
_SCHEMA = {
"type": "object",
"additionalProperties": False,
"properties": {
"scoreA": {"type": "integer"},
"scoreB": {"type": "integer"},
"outcome": {"type": "string", "enum": ["TEAM_A_WIN", "DRAW", "TEAM_B_WIN"]},
"confidencePct": {"type": "integer"},
"reasonKo": {"type": "string"},
"reasonEn": {"type": "string"},
},
"required": ["scoreA", "scoreB", "outcome", "confidencePct", "reasonKo", "reasonEn"],
}
def _normalize(data: dict) -> dict:
a = max(0, min(9, int(data["scoreA"])))
b = max(0, min(9, 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))))
return {
"outcome": outcome,
"scoreA": a,
"scoreB": b,
"confidencePct": conf,
"reasonKo": str(data.get("reasonKo", "")).strip()[:120],
"reasonEn": str(data.get("reasonEn", "")).strip()[:160],
}
# ── GPT (OpenAI) ────────────────────────────────────────────
async def predict_gpt(ctx: MatchContext) -> dict:
if not settings.openai_api_key:
raise ProviderUnavailable("OPENAI_API_KEY 미설정")
from openai import AsyncOpenAI
client = AsyncOpenAI(api_key=settings.openai_api_key)
resp = await client.chat.completions.create(
model=settings.openai_model,
messages=[
{"role": "system", "content": "You output only valid JSON."},
{"role": "user", "content": _prompt(ctx)},
],
response_format={"type": "json_object"},
)
content = resp.choices[0].message.content or "{}"
return _normalize(json.loads(content))
# ── Claude (Anthropic) ──────────────────────────────────────
async def predict_claude(ctx: MatchContext) -> dict:
if not settings.anthropic_api_key:
raise ProviderUnavailable("ANTHROPIC_API_KEY 미설정")
from anthropic import AsyncAnthropic
client = AsyncAnthropic(api_key=settings.anthropic_api_key)
async def _create(**extra): # noqa: ANN003
return await client.messages.create(
model=settings.anthropic_model,
max_tokens=1024,
messages=[{"role": "user", "content": _prompt(ctx)}],
**extra,
)
# 1순위: 구조화 출력(output_config.format) + Opus 4.8 어댑티브 thinking.
# SDK/모델 버전에 따라 미지원이면 평문 JSON 파싱으로 폴백.
try:
msg = await _create(
thinking={"type": "adaptive"},
output_config={"format": {"type": "json_schema", "schema": _SCHEMA}},
)
except TypeError:
msg = await _create()
text = "".join(b.text for b in msg.content if getattr(b, "type", "") == "text")
return _normalize(json.loads(text))
# ── Gemini (Google) ─────────────────────────────────────────
async def predict_gemini(ctx: MatchContext) -> dict:
if not settings.google_api_key:
raise ProviderUnavailable("GOOGLE_API_KEY 미설정")
from google import genai
from google.genai import types
client = genai.Client(api_key=settings.google_api_key)
resp = await client.aio.models.generate_content(
model=settings.google_model,
contents=_prompt(ctx),
config=types.GenerateContentConfig(response_mime_type="application/json"),
)
return _normalize(json.loads(resp.text or "{}"))
PROVIDERS = {
"GPT": predict_gpt,
"Claude": predict_claude,
"Gemini": predict_gemini,
}

View File

@ -0,0 +1,89 @@
"""결과 이메일 발송 — SMTP 실연동 (aiosmtplib).
SMTP 설정이 없으면 EmailUnavailable 던진다(조용한 실패 0).
경기 종료 워커가 구독자(notify=True + email)에게 개인화 결과 메일 발송.
"""
from __future__ import annotations
import logging
from email.message import EmailMessage
from ..config import settings
log = logging.getLogger("triplepick.email")
class EmailUnavailable(RuntimeError):
pass
def _ensure_configured() -> None:
if not settings.smtp_host:
raise EmailUnavailable("SMTP_HOST 미설정")
async def send_email(to: str, subject: str, html: str, text: str) -> None:
_ensure_configured()
import aiosmtplib
msg = EmailMessage()
msg["From"] = settings.smtp_from
msg["To"] = to
msg["Subject"] = subject
msg.set_content(text)
msg.add_alternative(html, subtype="html")
await aiosmtplib.send(
msg,
hostname=settings.smtp_host,
port=settings.smtp_port,
username=settings.smtp_user or None,
password=settings.smtp_password or None,
start_tls=settings.smtp_starttls,
)
log.info("email sent → %s (%s)", to, subject)
def build_result_email(
*,
team_a: str,
team_b: str,
result_a: int,
result_b: int,
my_a: int,
my_b: int,
my_points: int,
ai_lines: list[tuple[str, bool]], # (모델명, 적중여부)
match_url: str,
) -> tuple[str, str, str]:
"""제목/HTML/텍스트 반환."""
hit = my_points > 0
headline = "적중! 🎯" if hit else "아쉽네요"
subject = f"[TriplePick] {team_a} {result_a}-{result_b} {team_b} 결과 — {headline}"
ai_html = "".join(
f"<li>{m}: {'적중 ✓' if ok else '빗나감'}</li>" for m, ok in ai_lines
)
ai_text = "\n".join(
f" - {m}: {'적중' if ok else '빗나감'}" for m, ok in ai_lines
)
html = f"""\
<div style="font-family:sans-serif;max-width:480px;margin:0 auto">
<h2>경기 결과</h2>
<p style="font-size:20px;font-weight:bold">{team_a} {result_a} - {result_b} {team_b}</p>
<p>당신의 예측: {team_a} {my_a} - {my_b} {team_b}
<strong>{my_points} ({headline})</strong></p>
<h3>AI 3모델 적중 여부</h3>
<ul>{ai_html}</ul>
<p><a href="{match_url}">다음 경기 예측하러 가기 </a></p>
<p style="color:#888;font-size:12px">100만원 챌린지 누적 포인트 1위에게 최종 상금.</p>
</div>"""
text = (
f"경기 결과: {team_a} {result_a}-{result_b} {team_b}\n"
f"당신의 예측: {team_a} {my_a}-{my_b} {team_b}{my_points}점 ({headline})\n\n"
f"AI 3모델 적중 여부:\n{ai_text}\n\n"
f"다음 경기 예측: {match_url}\n"
)
return subject, html, text

View File

@ -0,0 +1,39 @@
"""채점 서비스 — 결과 입력 시 해당 경기 유저 픽 전수 채점.
관리자 setResult 워커가 공용으로 사용. scoring.score_prediction 적용.
"""
from __future__ import annotations
import logging
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..domain import now_utc
from ..models import Match, UserPrediction
from ..scoring import outcome_of, score_prediction
log = logging.getLogger("triplepick.grading")
async def apply_result(db: AsyncSession, match: Match, score_a: int, score_b: int) -> int:
"""결과 기록 + 해당 경기 픽 전수 채점. 채점된 픽 수 반환."""
match.result_score_a = score_a
match.result_score_b = score_b
match.result_outcome = outcome_of(score_a, score_b)
match.status = "finished"
match.finished_at = now_utc()
picks = (
await db.execute(
select(UserPrediction).where(UserPrediction.match_id == match.match_id)
)
).scalars().all()
for p in picks:
p.points = score_prediction(p.score_a, p.score_b, score_a, score_b)
p.scored_at = now_utc()
await db.commit()
log.info("graded %d picks for %s (%d-%d)", len(picks), match.match_id, score_a, score_b)
return len(picks)

View File

@ -0,0 +1,112 @@
"""경기 일정 외부 수집 — 매일 워커가 호출(크롤링).
소스(SCHEDULE_SOURCE):
openfootball raw GitHub JSON ( 불필요, 기본). 2026 Group A = 서비스 6경기.
football-data football-data.org /v4/competitions/WC/matches (FOOTBALL_DATA_TOKEN 필요)
fallback 외부 호출 없이 결과 기존(시드) 일정 유지
반환 표준 레코드:
{teamA: code, teamB: code, kickoffKst: ISO(+09:00), venue: str}
team 코드로 매핑되지 않는 경기(다른 그룹/) 제외.
시각은 절대 시점(UTC)으로 환산 KST(+09:00) 고정 오프셋으로 표기.
"""
from __future__ import annotations
import logging
import re
from datetime import datetime, timedelta, timezone
from ..config import settings
from ..schedule_data import code_for
log = logging.getLogger("triplepick.schedule")
KST = timezone(timedelta(hours=9))
# "13:00 UTC-6" / "20:00 UTC+2" 형태 파싱
_TIME_RE = re.compile(r"(\d{1,2}):(\d{2})\s*UTC\s*([+-]\d{1,2})")
def _to_kst_iso(date_str: str, time_str: str) -> str | None:
m = _TIME_RE.search(time_str or "")
if not m:
return None
hh, mm, off = int(m.group(1)), int(m.group(2)), int(m.group(3))
try:
y, mo, d = (int(x) for x in date_str.split("-"))
except ValueError:
return None
local = datetime(y, mo, d, hh, mm, tzinfo=timezone(timedelta(hours=off)))
return local.astimezone(KST).isoformat()
async def _fetch_openfootball() -> list[dict]:
import httpx
async with httpx.AsyncClient(timeout=20) as c:
r = await c.get(settings.schedule_url)
r.raise_for_status()
data = r.json()
out: list[dict] = []
for m in data.get("matches", []):
if (m.get("group") or "").strip() != settings.schedule_group:
continue
a, b = code_for(m.get("team1", "")), code_for(m.get("team2", ""))
if not a or not b:
continue
kickoff = _to_kst_iso(m.get("date", ""), m.get("time", ""))
if not kickoff:
continue
out.append({"teamA": a, "teamB": b, "kickoffKst": kickoff, "venue": m.get("ground", "")})
return out
async def _fetch_football_data() -> list[dict]:
if not settings.football_data_token:
log.warning("schedule: football-data 토큰 미설정 — 수집 생략")
return []
import httpx
url = (
f"https://api.football-data.org/v4/competitions/"
f"{settings.football_data_competition}/matches"
)
async with httpx.AsyncClient(timeout=20) as c:
r = await c.get(url, headers={"X-Auth-Token": settings.football_data_token})
r.raise_for_status()
data = r.json()
out: list[dict] = []
for m in data.get("matches", []):
if (m.get("group") or "").replace("GROUP_", "Group ") != settings.schedule_group:
continue
a = code_for((m.get("homeTeam") or {}).get("name", "") or "")
b = code_for((m.get("awayTeam") or {}).get("name", "") or "")
if not a or not b:
continue
utc = m.get("utcDate") # ISO Z
if not utc:
continue
kickoff = (
datetime.fromisoformat(utc.replace("Z", "+00:00")).astimezone(KST).isoformat()
)
venue = m.get("venue") or ""
out.append({"teamA": a, "teamB": b, "kickoffKst": kickoff, "venue": venue})
return out
async def fetch_schedule() -> list[dict]:
"""소스에 따라 일정 수집. 실패 시 빈 리스트(기존 일정 유지)."""
src = settings.schedule_source.lower()
try:
if src == "openfootball":
records = await _fetch_openfootball()
elif src == "football-data":
records = await _fetch_football_data()
else:
return []
log.info("schedule: %s 에서 %d경기 수집", src, len(records))
return records
except Exception as e: # noqa: BLE001
log.error("schedule fetch 실패(%s): %s — 기존 일정 유지", src, e)
return []

View File

@ -0,0 +1,72 @@
"""수집한 일정을 DB에 반영 — 워커(스케줄링 서버)가 매일 호출.
매칭 = (teamA_code, teamB_code) 순서쌍 (그룹 라운드로빈에서 유일).
- 기존 경기: 킥오프/venue/투표시간(opens·lock) 갱신. 결과·예측·crowd·라운드/후킹은 보존.
- 신규 경기: matchId 생성 + crowd 초기화하여 삽입(예측은 워커 AI 생성이 채움).
종료(result) 경기는 시각 변경하지 않음.
"""
from __future__ import annotations
import logging
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import CrowdStats, Match
from ..schedule_data import TEAMS, lock_at, opens_at, parse_kickoff
log = logging.getLogger("triplepick.schedule")
async def sync_schedule(db: AsyncSession, records: list[dict]) -> dict:
if not records:
return {"updated": 0, "inserted": 0, "skipped": 0}
existing = (await db.execute(select(Match))).scalars().all()
by_pair = {(m.team_a_code, m.team_b_code): m for m in existing}
updated = inserted = skipped = 0
for rec in records:
a, b = rec["teamA"], rec["teamB"]
kickoff = parse_kickoff(rec["kickoffKst"])
m = by_pair.get((a, b))
if m is not None:
if m.result_outcome is not None:
skipped += 1
continue
m.kickoff_at = kickoff
m.opens_at = opens_at(kickoff)
m.lock_at = lock_at(kickoff)
if rec.get("venue"):
m.venue = rec["venue"]
updated += 1
else:
ta, tb = TEAMS.get(a), TEAMS.get(b)
if not ta or not tb:
skipped += 1
continue
match_id = f"A_{a}_{b}_{kickoff.astimezone().strftime('%Y%m%d')}"
db.add(
Match(
match_id=match_id,
round_label="Match",
group="A",
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']}, AI의 예측은",
kickoff_at=kickoff,
opens_at=opens_at(kickoff),
lock_at=lock_at(kickoff),
status="scheduled",
)
)
db.add(CrowdStats(match_id=match_id, total=0, team_a_win=0, draw=0, team_b_win=0))
inserted += 1
await db.commit()
result = {"updated": updated, "inserted": inserted, "skipped": skipped}
log.info("schedule sync: %s", result)
return result

247
backend/app/worker.py Normal file
View File

@ -0,0 +1,247 @@
"""백그라운드 워커 — APScheduler (별도 컨테이너).
조사된 '필요한 시간들' 모두 자동 처리하는 단일 프로세스:
1) 상태 전이 (status_tick_seconds 주기, 기본 60)
scheduled open(킥오프 D-2) locked(킥오프 정각) now 기준 자동 갱신.
2) AI 예측 생성 (매일 KST ai_generate_hour:ai_generate_minute, 기본 00:05)
남은(미종료) 경기에 대해 GPT/Claude/Gemini API 호출 ai_predictions 갱신.
모델별 독립 처리( 없거나 실패해도 나머지 모델은 진행).
3) 결과 메일 발송 (status_tick 함께 점검)
경기 종료(finished_at) result_email_delay_minutes(기본 180=3시간) 경과 ,
구독자(notify=True+email)에게 개인화 결과 메일 발송 notified 표시.
실행: python -m app.worker
"""
from __future__ import annotations
import asyncio
import logging
from datetime import timedelta
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from .config import settings
from .database import SessionLocal, init_db
from .domain import ensure_aware, now_utc
from .models import AIPrediction, Match, UserPrediction
from .services.ai import MatchContext, PROVIDERS, ProviderUnavailable
from .services.email import EmailUnavailable, build_result_email, send_email
from .services.schedule_fetch import fetch_schedule
from .services.schedule_sync import sync_schedule
logging.basicConfig(level=logging.INFO)
log = logging.getLogger("triplepick.worker")
# ── 0) 경기 일정 동기화 (외부 크롤링) ──────────────────────
async def sync_schedule_job() -> None:
records = await fetch_schedule()
if not records:
return
async with SessionLocal() as db:
result = await sync_schedule(db, records)
# 일정 변경 후 즉시 상태/투표시간 재평가
await tick_status()
# 새 경기가 삽입됐으면 그 경기 AI 예측을 바로 생성(다음 주기까지 비어있지 않게)
if result.get("inserted"):
log.info("schedule: 신규 %d경기 → AI 예측 즉시 생성", result["inserted"])
await generate_ai_predictions()
# ── 1) 상태 전이 ────────────────────────────────────────────
async def tick_status() -> None:
async with SessionLocal() as db:
now = now_utc()
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"
if m.status != target:
m.status = target
changed += 1
if changed:
await db.commit()
log.info("status_tick: %d matches updated", changed)
await maybe_send_result_emails()
# ── 2) AI 예측 생성 (실연동) ────────────────────────────────
async def generate_ai_predictions() -> None:
async with SessionLocal() as db:
matches = (
await db.execute(
select(Match)
.where(Match.result_outcome.is_(None))
.options(selectinload(Match.predictions))
)
).scalars().all()
for m in matches:
ctx = MatchContext(
team_a=m.team_a_name,
team_b=m.team_b_name,
venue=m.venue,
kickoff=m.kickoff_at.isoformat(),
)
for model, fn in PROVIDERS.items():
try:
data = await fn(ctx)
except ProviderUnavailable as e:
log.warning("AI %s skipped (%s)", model, e)
continue
except Exception as e: # noqa: BLE001
log.error("AI %s failed for %s: %s", model, m.match_id, e)
continue
pred = next((p for p in m.predictions if p.model == model), None)
if pred is None:
pred = AIPrediction(match_id=m.match_id, model=model)
db.add(pred)
m.predictions.append(pred)
pred.outcome = data["outcome"]
pred.score_a = data["scoreA"]
pred.score_b = data["scoreB"]
pred.confidence_pct = data["confidencePct"]
pred.reason_ko = data["reasonKo"]
pred.reason_en = data["reasonEn"]
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()
# ── 3) 결과 메일 ────────────────────────────────────────────
async def maybe_send_result_emails() -> None:
async with SessionLocal() as db:
cutoff = now_utc() - timedelta(minutes=settings.result_email_delay_minutes)
matches = (
await db.execute(
select(Match)
.where(
Match.result_outcome.is_not(None),
Match.finished_at.is_not(None),
Match.results_emailed_at.is_(None),
)
.options(selectinload(Match.predictions))
)
).scalars().all()
for m in matches:
if m.finished_at and ensure_aware(m.finished_at) > cutoff:
continue # 아직 지연시간(3시간) 미경과
picks = (
await db.execute(
select(UserPrediction).where(
UserPrediction.match_id == m.match_id,
UserPrediction.notify.is_(True),
UserPrediction.email.is_not(None),
UserPrediction.notified.is_(False),
)
)
).scalars().all()
ai_lines = [(p.model, p.outcome == m.result_outcome) for p in m.predictions]
match_url = f"{settings.public_origin}/match/{m.match_id}"
sent_any = False
for pk in picks:
subject, html, text = build_result_email(
team_a=m.team_a_short,
team_b=m.team_b_short,
result_a=m.result_score_a or 0,
result_b=m.result_score_b or 0,
my_a=pk.score_a,
my_b=pk.score_b,
my_points=pk.points or 0,
ai_lines=ai_lines,
match_url=match_url,
)
try:
await send_email(pk.email, subject, html, text) # type: ignore[arg-type]
pk.notified = True
sent_any = True
except EmailUnavailable as e:
log.warning("result email skipped (%s)", e)
break # SMTP 미설정 — 이 경기는 다음 틱에 재시도
except Exception as e: # noqa: BLE001
log.error("result email failed → %s: %s", pk.email, e)
# 모든 구독자에게 시도 완료(또는 구독자 없음)면 발송완료 표시
still_pending = any(
(not pk.notified) for pk in picks
)
if not still_pending:
m.results_emailed_at = now_utc()
await db.commit()
def build_scheduler() -> AsyncIOScheduler:
sched = AsyncIOScheduler(timezone=settings.timezone)
# 경기 일정: 매일 KST 09:00 외부 크롤링 → 경기·투표시간 갱신
sched.add_job(
sync_schedule_job,
"cron",
hour=settings.schedule_sync_hour,
minute=settings.schedule_sync_minute,
id="schedule_sync",
)
# 투표시간 상태 전이 (open/locked)
sched.add_job(
tick_status,
"interval",
seconds=settings.status_tick_seconds,
id="status_tick",
next_run_time=now_utc(),
)
# AI 예측 생성: 매일 KST 00:05 (실 API)
sched.add_job(
generate_ai_predictions,
"cron",
hour=settings.ai_generate_hour,
minute=settings.ai_generate_minute,
id="ai_generate",
)
return sched
async def main() -> None:
await init_db() # 테이블 보장 (idempotent). 시드는 API 가 담당.
# 기동 시 1회: 일정 동기화 → AI 예측 생성(키 있을 때 GPT·Gemini 즉시 채움)
await sync_schedule_job()
await generate_ai_predictions()
sched = build_scheduler()
sched.start()
log.info(
"scheduling-server started — schedule sync daily %02d:%02d · status every %ss · "
"AI gen daily %02d:%02d %s · result email +%dmin",
settings.schedule_sync_hour,
settings.schedule_sync_minute,
settings.status_tick_seconds,
settings.ai_generate_hour,
settings.ai_generate_minute,
settings.timezone,
settings.result_email_delay_minutes,
)
# 영구 대기
stop = asyncio.Event()
try:
await stop.wait()
finally:
sched.shutdown()
if __name__ == "__main__":
asyncio.run(main())

13
backend/requirements.txt Normal file
View File

@ -0,0 +1,13 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy[asyncio]==2.0.36
asyncpg==0.30.0
pydantic==2.10.4
pydantic-settings==2.7.1
email-validator==2.2.0
apscheduler==3.11.0
aiosmtplib==3.0.2
httpx==0.27.2
anthropic==0.69.0
openai==1.59.6
google-genai==0.8.0

62
docker-compose.yml Normal file
View File

@ -0,0 +1,62 @@
# TriplePick — 전체 스택을 한 번에 실행.
# db PostgreSQL
# api FastAPI (uvicorn)
# worker APScheduler (상태전이 · 매일 AI 예측 생성 · 결과 메일)
# frontend Vite 빌드 → nginx (정적 + /api 프록시)
#
# 실행: cp backend/.env.example backend/.env (키 채우기) → docker compose up --build
# 접속: http://localhost:8080
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: triplepick
POSTGRES_PASSWORD: triplepick
POSTGRES_DB: triplepick
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U triplepick -d triplepick"]
interval: 5s
timeout: 5s
retries: 10
ports:
- "5432:5432"
api:
build: ./backend
env_file:
- path: ./backend/.env
required: false
environment:
DATABASE_URL: postgresql+asyncpg://triplepick:triplepick@db:5432/triplepick
depends_on:
db:
condition: service_healthy
ports:
- "8000:8000"
worker:
build: ./backend
command: ["python", "-m", "app.worker"]
env_file:
- path: ./backend/.env
required: false
environment:
DATABASE_URL: postgresql+asyncpg://triplepick:triplepick@db:5432/triplepick
depends_on:
db:
condition: service_healthy
api:
condition: service_started
frontend:
build: ./frontend
depends_on:
- api
ports:
- "8080:80"
volumes:
pgdata:

View File

@ -1,18 +0,0 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

View File

@ -1,11 +0,0 @@
{
"firestore": {
"rules": "firestore.rules",
"indexes": "firestore.indexes.json"
},
"functions": {
"source": "functions",
"runtime": "nodejs20",
"region": "asia-northeast3"
}
}

View File

@ -1,43 +0,0 @@
{
"indexes": [
{
"_comment": "리더보드(R2): 경기별 점수 내림차순 + 동점자 정렬(정확스코어 적중수, 제출시각)",
"collectionGroup": "user_predictions",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "matchId", "order": "ASCENDING" },
{ "fieldPath": "points", "order": "DESCENDING" },
{ "fieldPath": "createdAt", "order": "ASCENDING" }
]
},
{
"_comment": "결과 채점 트리거: 경기별 미채점 예측 조회",
"collectionGroup": "user_predictions",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "matchId", "order": "ASCENDING" },
{ "fieldPath": "scoredAt", "order": "ASCENDING" }
]
},
{
"_comment": "결과 알림 발송: 경기별 알림 동의 구독자 조회",
"collectionGroup": "notify_subscriptions",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "matchId", "order": "ASCENDING" },
{ "fieldPath": "notified", "order": "ASCENDING" }
]
},
{
"_comment": "참여 횟수 랭킹(R2): 상금 챌린지 후보군",
"collectionGroup": "event_participation",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "isCandidate", "order": "ASCENDING" },
{ "fieldPath": "totalPredictions", "order": "DESCENDING" },
{ "fieldPath": "currentPoints", "order": "DESCENDING" }
]
}
],
"fieldOverrides": []
}

View File

@ -1,43 +0,0 @@
rules_version = '2';
// TriplePick Firestore 보안 규칙
// 모델: 공개 데이터(경기/AI예측/집계)는 읽기 허용, 모든 쓰기는 서버(Admin SDK·Cloud Functions)만.
// 유저 예측/구독/참여는 클라이언트 직접 접근 금지 → 콜러블 함수로만 처리(검증·집계·PII 보호).
service cloud.firestore {
match /databases/{database}/documents {
// ---- 공개 읽기 전용 (쓰기는 Admin SDK가 규칙 우회) ----
match /matches/{matchId} {
allow read: if true;
allow write: if false;
}
match /ai_predictions/{docId} {
allow read: if true;
allow write: if false;
}
match /crowd_stats/{matchId} {
allow read: if true; // 군중 분포(퍼센트, PII 없음)
allow write: if false; // submitPrediction 함수가 트랜잭션으로 증분
}
// ---- 비공개: 함수/Admin 전용 (클라이언트 직접 접근 전면 금지) ----
match /user_predictions/{docId} {
allow read, write: if false; // 제출/조회는 콜러블 함수로만, 이메일 등 PII 보호
}
match /notify_subscriptions/{docId} {
allow read, write: if false; // 결과 알림 구독 (Resend 발송용)
}
match /event_participation/{deviceId} {
allow read, write: if false; // R2 — 상금 챌린지 누적/자격
}
// 그 외 전부 차단
match /{document=**} {
allow read, write: if false;
}
}
}
// 참고: 클라이언트 직접 쓰기 방식을 택할 경우(함수 없이), user_predictions에
// create 검증 규칙(opens_at ≤ now < lock_at[=킥오프 정각], outcome ∈ {TEAM_A_WIN,DRAW,TEAM_B_WIN},
// score 0~9 정수, nickname 2~16자, docId == matchId+"_"+deviceId)을 넣어야 한다.
// 단 crowd_stats 원자적 증분과 PII 보호 때문에 콜러블 함수 방식을 권장한다(docs/BACKEND.md).

8
frontend/.env.example Normal file
View File

@ -0,0 +1,8 @@
# 프론트엔드 환경변수 (Vite) — 복사: cp .env.example .env
# 운영(docker)에서는 nginx 가 /api 를 프록시하므로 비워두면 동일 출처(/api)를 사용.
# 백엔드 API 베이스 (기본: /api). 절대 URL 로 직접 호출하려면 지정.
# VITE_API_BASE=/api
# 로컬 vite dev 서버의 프록시 타깃 (기본: http://localhost:8000)
# VITE_API_TARGET=http://localhost:8000

13
frontend/Dockerfile Normal file
View File

@ -0,0 +1,13 @@
# TriplePick 프론트엔드 — Vite 빌드 → nginx 정적 서빙 + /api 프록시.
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

18
frontend/index.html Normal file
View File

@ -0,0 +1,18 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/icons/profile.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#14171C" />
<title>TriplePick 2026 | AI 2026 월드컵 승부예측 — GPT vs Claude vs Gemini</title>
<meta
name="description"
content="GPT·Claude·Gemini가 매 경기를 서로 다르게 예측합니다. 당신의 픽을 찍고 AI와 겨뤄보세요. 끝까지 잘 맞히면 100만 원 챌린지."
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

20
frontend/nginx.conf Normal file
View File

@ -0,0 +1,20 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# API 백엔드(api 서비스)로 프록시 브라우저는 동일 출처로 호출(CORS 불필요).
location /api/ {
proxy_pass http://api:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# SPA 폴백 클라이언트 라우팅(/match/:id 등)
location / {
try_files $uri $uri/ /index.html;
}
}

2485
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
frontend/package.json Normal file
View File

@ -0,0 +1,25 @@
{
"name": "triplepick-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.2",
"vite": "^6.0.5"
}
}

View File

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View File

Before

Width:  |  Height:  |  Size: 806 KiB

After

Width:  |  Height:  |  Size: 806 KiB

View File

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

View File

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View File

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

16
frontend/src/App.tsx Normal file
View File

@ -0,0 +1,16 @@
import { Routes, Route } from "react-router-dom";
import Dashboard from "./pages/Dashboard";
import MatchDetail from "./pages/MatchDetail";
import Leaderboard from "./pages/Leaderboard";
import NotFound from "./pages/NotFound";
export default function App() {
return (
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/match/:matchId" element={<MatchDetail />} />
<Route path="/leaderboard" element={<Leaderboard />} />
<Route path="*" element={<NotFound />} />
</Routes>
);
}

View File

@ -1,7 +1,4 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { DEMO_FORCE_OPEN } from "@/lib/mockData";
import { outcomeLabel, pct, kickoffDisplay, winProb } from "@/lib/format";
import { type Lang, dict, teamShort } from "@/lib/i18n";
import type {
@ -12,6 +9,7 @@ import type {
AIPrediction,
Team,
} from "@/lib/types";
import { ApiError, getDeviceId, submitPrediction } from "@/lib/api";
import Countdown from "./Countdown";
import TeamFlag from "./TeamFlag";
@ -49,6 +47,8 @@ export default function Arena({
const [notify, setNotify] = useState(true);
const [crowd, setCrowd] = useState<CrowdStats>(initialCrowd);
const [copied, setCopied] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// 점수 선택 시 승/무/패 자동 선택 (스코어가 결과의 소스)
useEffect(() => {
@ -57,17 +57,14 @@ export default function Arena({
);
}, [scoreA, scoreB]);
// 투표 창: 오픈(D-2) ≤ now < 마감(킥오프 정각). 종료 = 결과 존재.
const now = Date.now();
// 투표 가능 여부는 백엔드 계산(votingOpen, demo 반영) + phase 라벨.
const finished = !!match.result;
const notOpen = !DEMO_FORCE_OPEN && now < new Date(match.opensAt).getTime();
const locked = now >= new Date(match.lockAt).getTime();
const disabled = finished || locked || notOpen;
const disabled = finished || !match.votingOpen;
const ctaLabel = finished
? t.ctaResult
: notOpen
: match.phase === "scheduled" && !match.votingOpen
? t.ctaOpens(kickoffDisplay(match.opensAt, lang))
: locked
: match.phase === "locked"
? t.ctaLocked
: t.ctaBeat;
@ -82,16 +79,36 @@ export default function Arena({
if (!outcome) return;
setStep("form");
};
const confirmSubmit = () => {
if (!EMAIL_RE.test(email.trim())) return;
setCrowd((c) => ({
...c,
total: c.total + 1,
teamAWin: c.teamAWin + (outcome === "TEAM_A_WIN" ? 1 : 0),
draw: c.draw + (outcome === "DRAW" ? 1 : 0),
teamBWin: c.teamBWin + (outcome === "TEAM_B_WIN" ? 1 : 0),
}));
const confirmSubmit = async () => {
if (!EMAIL_RE.test(email.trim()) || !outcome) return;
setSubmitting(true);
setError(null);
try {
const res = await submitPrediction({
matchId: match.matchId,
deviceId: getDeviceId(),
outcome,
scoreA,
scoreB,
email: email.trim(),
notify,
});
setCrowd(res.crowd); // 서버 권위 분포로 갱신
setStep("done");
} catch (e) {
const msg =
e instanceof ApiError
? e.status === 423
? t.ctaLocked
: e.detail
: lang === "en"
? "Submission failed. Please try again."
: "제출에 실패했습니다. 다시 시도해주세요.";
setError(msg);
} finally {
setSubmitting(false);
}
};
const shareText = useMemo(() => {
@ -134,7 +151,7 @@ export default function Arena({
return (
<>
{/* ===== 카운트다운 (D7) — 투표 진행 중일 때만 ===== */}
{!finished && !notOpen && (
{!finished && match.votingOpen && (
<div className="mt-4">
<Countdown to={match.lockAt} lang={lang} />
</div>
@ -286,12 +303,15 @@ export default function Arena({
/>
{t.notify}
</label>
{error && (
<p className="text-[13px] font-bold text-[#ff6b6b]">{error}</p>
)}
<button
onClick={confirmSubmit}
disabled={!EMAIL_RE.test(email.trim())}
disabled={!EMAIL_RE.test(email.trim()) || submitting}
className="btn-mint w-full rounded-xl py-3.5 text-[17px] font-extrabold transition active:scale-[0.99] disabled:opacity-40 disabled:shadow-none"
>
{t.submit}
{submitting ? "…" : t.submit}
</button>
</div>
)}

View File

@ -1,10 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { type Lang, dict } from "@/lib/i18n";
// "투표 종료까지" 카운트다운 (D7). 마감 = 킥오프 정각(lockAt).
// 서버/클라 hydration mismatch 방지: 시간 계산은 마운트 후 useEffect에서만.
export default function Countdown({ to, lang = "ko" }: { to: string; lang?: Lang }) {
const t = dict(lang);
const label = t.cdUntil;
@ -29,7 +26,6 @@ export default function Countdown({ to, lang = "ko" }: { to: string; lang?: Lang
<span
className="font-mono text-[18px] font-extrabold tabular-nums text-[var(--green)]"
style={{ textShadow: "0 0 12px rgba(74,255,160,0.45)" }}
suppressHydrationWarning
>
{left === null ? "--:--:--" : fmt(left, lang)}
</span>

View File

@ -1,4 +1,4 @@
import Link from "next/link";
import { Link } from "react-router-dom";
import ShareButton from "./ShareButton";
import LangSwitch from "./LangSwitch";
import { type Lang, dict } from "@/lib/i18n";
@ -22,7 +22,7 @@ export default function Hero({
{back && (
<div className="mb-3 flex items-center justify-between">
<Link
href={home}
to={home}
className="flex items-center gap-1 rounded-full border border-white/12 bg-white/8 px-3 py-1.5 text-[12px] font-bold text-white/85 active:scale-95"
>
{t.back}

View File

@ -1,12 +1,9 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Link, useLocation } from "react-router-dom";
import type { Lang } from "@/lib/i18n";
// KO / EN 토글. 현재 경로 유지 + ?lang 토글.
export default function LangSwitch({ lang }: { lang: Lang }) {
const pathname = usePathname() || "/";
const { pathname } = useLocation();
const href = (l: Lang) => (l === "en" ? `${pathname}?lang=en` : pathname);
const langs: Lang[] = ["ko", "en"];
return (
@ -14,8 +11,7 @@ export default function LangSwitch({ lang }: { lang: Lang }) {
{langs.map((l) => (
<Link
key={l}
href={href(l)}
scroll={false}
to={href(l)}
className={`px-2.5 py-1 transition ${
lang === l ? "bg-white text-[#14171C]" : "text-white/65"
}`}

View File

@ -1,9 +1,8 @@
import Link from "next/link";
import { matchesByDate, matchPhase, type MatchPhase } from "@/lib/schedule";
import { getPredictions, getCrowd } from "@/lib/mockData";
import { Link } from "react-router-dom";
import { dateHeading, timeOnly } from "@/lib/format";
import { type Lang, dict, teamShort, roundLabel as tRound } from "@/lib/i18n";
import type { Match } from "@/lib/types";
import type { Match, MatchPhase } from "@/lib/types";
import { dateKey } from "@/lib/format";
import TeamFlag from "./TeamFlag";
const STROKE = "#94FBE0"; // 일정 텍스트·선택 아웃라인 스트로크 컬러
@ -15,9 +14,27 @@ const PHASE_CLS: Record<MatchPhase, string> = {
finished: "border-white/15 text-white/55",
};
export default function ScheduleBoard({ lang = "ko" }: { lang?: Lang }) {
// 날짜별로 묶고 킥오프 오름차순 정렬 (백엔드가 이미 정렬 반환)
function groupByDate(matches: Match[]): { date: string; matches: Match[] }[] {
const groups: { date: string; matches: Match[] }[] = [];
for (const m of matches) {
const key = dateKey(m.kickoffKst);
const last = groups[groups.length - 1];
if (last && last.date === key) last.matches.push(m);
else groups.push({ date: key, matches: [m] });
}
return groups;
}
export default function ScheduleBoard({
matches,
lang = "ko",
}: {
matches: Match[];
lang?: Lang;
}) {
const t = dict(lang);
const groups = matchesByDate();
const groups = groupByDate(matches);
return (
<section className="mt-6">
<div className="mb-3 flex items-baseline gap-2.5">
@ -47,9 +64,9 @@ export default function ScheduleBoard({ lang = "ko" }: { lang?: Lang }) {
function MatchCard({ match, lang }: { match: Match; lang: Lang }) {
const t = dict(lang);
const phase = matchPhase(match);
const preds = getPredictions(match, lang);
const crowd = getCrowd(match);
const phase = match.phase;
const preds = match.predictions;
const crowdTotal = match.crowd?.total ?? 0;
// AI 픽 갈림 요약
const tally = { a: 0, d: 0, b: 0 };
@ -67,7 +84,7 @@ function MatchCard({ match, lang }: { match: Match; lang: Lang }) {
return (
<Link
href={href}
to={href}
className="block rounded-2xl border-2 border-[#94FBE0]/45 bg-[#171b21] p-4 transition active:scale-[0.99] hover:border-[#94FBE0]"
>
<div className="flex items-center justify-between text-[11px]">
@ -97,7 +114,7 @@ function MatchCard({ match, lang }: { match: Match; lang: Lang }) {
<span className="font-semibold text-white/55">{t.aiPicks}</span> {split.join(" · ")}
</span>
<span className="flex items-center gap-2">
<span>{t.joined(crowd.total.toLocaleString())}</span>
<span>{t.joined(crowdTotal.toLocaleString())}</span>
<span className="text-[#94FBE0]"></span>
</span>
</div>

View File

@ -1,8 +1,6 @@
"use client";
import { useState } from "react";
// 투표(경기) 공유 — 모바일 네이티브 공유(invoke), 미지원 시 클립보드 복사 폴백 (D8)
// 투표(경기) 공유 — 모바일 네이티브 공유, 미지원 시 클립보드 복사 폴백 (D8)
export default function ShareButton({
url,
title,

86
frontend/src/lib/api.ts Normal file
View File

@ -0,0 +1,86 @@
// 백엔드 FastAPI 클라이언트. 동일 출처 /api (dev=vite proxy, prod=nginx proxy).
import type { CrowdStats, Leaderboard, Match, ModelName } from "./types";
import type { Lang } from "./i18n";
const BASE = import.meta.env.VITE_API_BASE || "/api";
async function http<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
headers: { "Content-Type": "application/json", ...(init?.headers || {}) },
...init,
});
if (!res.ok) {
let detail = res.statusText;
try {
const body = await res.json();
detail = body.detail || detail;
} catch {
/* noop */
}
throw new ApiError(res.status, detail);
}
return res.json() as Promise<T>;
}
export class ApiError extends Error {
constructor(
public status: number,
public detail: string,
) {
super(detail);
}
}
export function listMatches(lang: Lang): Promise<Match[]> {
return http<Match[]>(`/matches?lang=${lang}`);
}
export function getMatch(matchId: string, lang: Lang): Promise<Match> {
return http<Match>(`/matches/${matchId}?lang=${lang}`);
}
export interface SubmitResult {
ok: boolean;
predictionId: string;
matchedModels: ModelName[];
exactMatch: boolean;
crowd: CrowdStats;
}
export function submitPrediction(body: {
matchId: string;
deviceId: string;
outcome: string;
scoreA: number;
scoreB: number;
email?: string;
notify: boolean;
}): Promise<SubmitResult> {
return http<SubmitResult>(`/predictions`, {
method: "POST",
body: JSON.stringify(body),
});
}
export function getLeaderboard(): Promise<Leaderboard> {
return http<Leaderboard>(`/leaderboard`);
}
// ── 비로그인 식별: deviceId (localStorage 영속) ──
const DEVICE_KEY = "tp_device_id";
export function getDeviceId(): string {
let id = localStorage.getItem(DEVICE_KEY);
if (!id) {
id =
typeof crypto !== "undefined" && crypto.randomUUID
? crypto.randomUUID()
: `dev-${Date.now()}-${Math.random().toString(36).slice(2)}`;
localStorage.setItem(DEVICE_KEY, id);
}
return id;
}
// 경기별 공유 딥링크
export function matchUrl(matchId: string): string {
return `${window.location.origin}/match/${matchId}`;
}

75
frontend/src/lib/types.ts Normal file
View File

@ -0,0 +1,75 @@
// TriplePick 도메인 타입 — 백엔드 API 응답(schemas.py)과 정합.
export type Outcome = "TEAM_A_WIN" | "DRAW" | "TEAM_B_WIN";
export type ModelName = "GPT" | "Claude" | "Gemini";
export type MatchPhase = "scheduled" | "open" | "locked" | "finished";
export interface Team {
name: string;
shortName: string;
code: string;
flag: string;
}
export interface MatchResult {
scoreA: number;
scoreB: number;
outcome: Outcome;
}
export interface AIPrediction {
matchId: string;
model: ModelName;
outcome: Outcome;
scoreA: number;
scoreB: number;
confidencePct: number;
reasonShort: string;
generatedAt: string;
}
export interface CrowdStats {
matchId: string;
total: number;
teamAWin: number;
draw: number;
teamBWin: number;
}
export interface Match {
matchId: string;
roundLabel: string;
group: string;
teamA: Team;
teamB: Team;
kickoffKst: string;
venue: string;
opensAt: string;
lockAt: string;
status: string;
phase: MatchPhase;
votingOpen: boolean;
hookText: string;
result?: MatchResult | null;
predictions: AIPrediction[];
crowd?: CrowdStats | null;
}
export interface UserPick {
outcome: Outcome;
scoreA: number;
scoreB: number;
}
export interface Standing {
rank: number;
emailMasked: string;
totalPoints: number;
exactCount: number;
matchesPlayed: number;
}
export interface Leaderboard {
standings: Standing[];
scoredMatches: number;
}

View File

@ -0,0 +1,13 @@
// ?lang=en 쿼리에서 언어 파싱 (Next searchParams 대체).
import { useSearchParams } from "react-router-dom";
import { parseLang, type Lang } from "./i18n";
export function useLang(): Lang {
const [params] = useSearchParams();
return parseLang(params.get("lang") ?? undefined);
}
// 현재 lang 을 유지하며 경로에 쿼리 부착
export function withLang(path: string, lang: Lang): string {
return lang === "en" ? `${path}${path.includes("?") ? "&" : "?"}lang=en` : path;
}

13
frontend/src/main.tsx Normal file
View File

@ -0,0 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./globals.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
);

View File

@ -0,0 +1,57 @@
import { useEffect, useState } from "react";
import Hero from "@/components/Hero";
import ScheduleBoard from "@/components/ScheduleBoard";
import Ado2Ad from "@/components/Ado2Ad";
import Footer from "@/components/Footer";
import { dict } from "@/lib/i18n";
import { useLang } from "@/lib/useLang";
import { listMatches } from "@/lib/api";
import type { Match } from "@/lib/types";
// L1. 전체 일정 대시보드 (랜딩 진입)
export default function Dashboard() {
const lang = useLang();
const t = dict(lang);
const [matches, setMatches] = useState<Match[] | null>(null);
const [error, setError] = useState(false);
useEffect(() => {
let alive = true;
setMatches(null);
setError(false);
listMatches(lang)
.then((m) => alive && setMatches(m))
.catch(() => alive && setError(true));
return () => {
alive = false;
};
}, [lang]);
return (
<main className="shell">
<Hero lang={lang} />
{/* 기간 안내 + CTA 영역 */}
<div className="mt-5 rounded-2xl border border-[var(--line-d)] bg-[var(--bg2)] p-4 text-center">
<p className="whitespace-pre-line text-[14px] font-bold leading-snug">
{t.introLead}
</p>
<p className="mt-1 text-[14px] font-bold leading-snug text-[var(--green)]">
{t.prizeLine1}
<br />
<span className="whitespace-nowrap">{t.prizeLine2}</span>
</p>
</div>
{error && (
<p className="mt-6 text-center text-[13px] text-white/55">
{lang === "en" ? "Failed to load matches." : "경기 정보를 불러오지 못했습니다."}
</p>
)}
{matches && <ScheduleBoard matches={matches} lang={lang} />}
<Ado2Ad lang={lang} />
<Footer lang={lang} />
</main>
);
}

View File

@ -0,0 +1,74 @@
import { useEffect, useState } from "react";
import Hero from "@/components/Hero";
import Footer from "@/components/Footer";
import { useLang } from "@/lib/useLang";
import { getLeaderboard } from "@/lib/api";
import type { Leaderboard as LB } from "@/lib/types";
// L3. 리더보드 — 누적 포인트 1위. 데이터 없으면 안내 stub.
export default function Leaderboard() {
const lang = useLang();
const [data, setData] = useState<LB | null>(null);
useEffect(() => {
let alive = true;
getLeaderboard()
.then((d) => alive && setData(d))
.catch(() => alive && setData({ standings: [], scoredMatches: 0 }));
return () => {
alive = false;
};
}, []);
const empty = !data || data.standings.length === 0;
return (
<main className="shell">
<Hero back lang={lang} />
<section className="mt-8">
<div className="font-impact text-[28px] text-[var(--green)]">LEADERBOARD</div>
{empty ? (
<div className="mt-4 rounded-2xl border border-[var(--line-d)] bg-[var(--bg2)] p-8 text-center">
<p className="text-[15px] font-bold">
{lang === "en" ? "Rankings open soon" : "랭킹은 곧 공개됩니다"}
</p>
<p className="mt-2 text-[13px] leading-relaxed text-[var(--ink-muted)]">
{lang === "en"
? "Points accumulate each match — the overall #1 wins ₩1,000,000."
: "매 경기 누적 점수로 순위를 매기고, 끝까지 가장 잘 맞힌 한 분께 최종 100만원을 드립니다."}
</p>
</div>
) : (
<div className="mt-4 overflow-hidden rounded-2xl border border-[var(--line-d)]">
{data!.standings.map((s) => (
<div
key={s.rank}
className="flex items-center justify-between border-b border-[var(--line-d)] px-4 py-3 last:border-b-0"
>
<div className="flex items-center gap-3">
<span className="w-6 text-center font-impact text-[18px] text-[var(--green)]">
{s.rank}
</span>
<span className="text-[14px] font-bold">{s.emailMasked}</span>
</div>
<div className="text-right">
<span className="text-[16px] font-extrabold">{s.totalPoints}</span>
<span className="ml-1 text-[11px] text-[var(--ink-muted)]">
{lang === "en" ? "pts" : "점"}
</span>
<span className="ml-2 text-[11px] text-[var(--ink-muted)]">
{lang === "en"
? `${s.exactCount} exact · ${s.matchesPlayed} played`
: `정확 ${s.exactCount} · ${s.matchesPlayed}경기`}
</span>
</div>
</div>
))}
</div>
)}
</section>
<Footer lang={lang} />
</main>
);
}

View File

@ -0,0 +1,103 @@
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import Hero from "@/components/Hero";
import MatchupHUD from "@/components/MatchupHUD";
import Arena from "@/components/Arena";
import Footer from "@/components/Footer";
import { dict, teamShort } from "@/lib/i18n";
import { useLang } from "@/lib/useLang";
import { getMatch, matchUrl, ApiError } from "@/lib/api";
import type { Match } from "@/lib/types";
// L2. 경기 상세(대결) 페이지
export default function MatchDetail() {
const { matchId = "" } = useParams();
const lang = useLang();
const t = dict(lang);
const [match, setMatch] = useState<Match | null>(null);
const [notFound, setNotFound] = useState(false);
useEffect(() => {
let alive = true;
setMatch(null);
setNotFound(false);
getMatch(matchId, lang)
.then((m) => alive && setMatch(m))
.catch((e) => {
if (!alive) return;
if (e instanceof ApiError && e.status === 404) setNotFound(true);
else setNotFound(true);
});
return () => {
alive = false;
};
}, [matchId, lang]);
if (notFound) {
return (
<main className="shell">
<Hero back lang={lang} />
<p className="mt-10 text-center text-[15px] font-bold text-white/70">
{lang === "en" ? "Match not found." : "경기를 찾을 수 없습니다."}
</p>
<Footer lang={lang} />
</main>
);
}
if (!match) {
return (
<main className="shell">
<Hero back lang={lang} />
<p className="mt-10 text-center text-[14px] text-white/45"></p>
</main>
);
}
const aShort = teamShort(match.teamA, lang);
const bShort = teamShort(match.teamB, lang);
const hook = lang === "en" ? t.hook(aShort, bShort) : match.hookText;
const url = matchUrl(match.matchId);
return (
<main className="shell">
<Hero
back
lang={lang}
share={{
url,
title: `${aShort} vs ${bShort} — TriplePick`,
text:
lang === "en"
? `${hook} — see all 3 AI picks and make yours!`
: `${hook} — AI 셋의 예측을 보고 너의 픽을 찍어봐!`,
}}
/>
{/* 경기별 후킹 카피 (한 줄) */}
<h1 className="mt-6 flex items-center justify-center gap-2 whitespace-nowrap text-[19px] font-extrabold leading-snug">
<span className="text-[var(--green)]"></span>
{hook}
<span className="text-[var(--green)]"></span>
</h1>
<MatchupHUD match={match} lang={lang} />
<Arena
match={match}
predictions={match.predictions}
crowd={
match.crowd ?? {
matchId: match.matchId,
total: 0,
teamAWin: 0,
draw: 0,
teamBWin: 0,
}
}
shareUrl={url}
lang={lang}
/>
<Footer lang={lang} />
</main>
);
}

View File

@ -0,0 +1,17 @@
import Hero from "@/components/Hero";
import Footer from "@/components/Footer";
import { useLang } from "@/lib/useLang";
export default function NotFound() {
const lang = useLang();
return (
<main className="shell">
<Hero back lang={lang} />
<p className="mt-16 text-center font-impact text-[40px] text-[var(--green)]">404</p>
<p className="mt-2 text-center text-[14px] text-white/60">
{lang === "en" ? "Page not found" : "페이지를 찾을 수 없습니다"}
</p>
<Footer lang={lang} />
</main>
);
}

9
frontend/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE?: string;
readonly VITE_API_TARGET?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

23
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
},
"include": ["src"]
}

22
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,22 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import path from "node:path";
// 개발 시 /api 요청을 백엔드(8000)로 프록시 → 브라우저는 동일 출처로 호출.
// 운영(docker)에서는 nginx 가 동일하게 /api 를 프록시한다.
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},
server: {
port: 5173,
proxy: {
"/api": {
target: process.env.VITE_API_TARGET || "http://localhost:8000",
changeOrigin: true,
},
},
},
});

View File

@ -1,24 +0,0 @@
// Firebase 클라이언트 초기화 (env에서 config 로드). 실제 키는 .env.local 에.
// 사용처: lib/data.ts(Firestore 읽기), Arena.tsx 제출(콜러블 함수 호출).
import { initializeApp, getApps, type FirebaseApp } from "firebase/app";
import { getFirestore, type Firestore } from "firebase/firestore";
import { getFunctions, type Functions } from "firebase/functions";
const config = {
apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
};
let app: FirebaseApp | null = null;
export function getFirebase(): { db: Firestore; fns: Functions } {
if (!config.projectId) {
throw new Error("Firebase env 미설정 — .env.local 에 NEXT_PUBLIC_FIREBASE_* 채우기");
}
app = getApps()[0] ?? initializeApp(config);
const region = process.env.NEXT_PUBLIC_FUNCTIONS_REGION || "asia-northeast3";
return { db: getFirestore(app), fns: getFunctions(app, region) };
}

View File

@ -1,194 +0,0 @@
// 1차 mock 데이터 — Firebase 연결 전까지 사용.
// ⚠️ AI 예측은 플레이스홀더. 발행 전 GPT/Claude/Gemini 실제 출력(JSON)으로 교체할 것.
// 경기 일정은 lib/schedule.ts(Group A 정식 일정, KST)에서 자동 반영.
//
// 멀티 경기(06.09 회의): 모든 경기에 3 AI 예측 + Crowd 가 있어야 대시보드/상세가 깨지지 않는다.
// 체코전(FEATURED)은 큐레이션 값, 그 외 경기는 matchId 시드 결정론적 생성 (Math.random 미사용).
import type { AIPrediction, CrowdStats, Match, ModelName, Outcome } from "./types";
import { FEATURED, GROUP_A } from "./schedule";
import { type Lang, teamShort } from "./i18n";
// 체코전 — 실제 3모델 API 출력 (2026-06-10 생성: gpt-5.5 / claude-opus-4-8 / gemini-3.5-flash)
const FEATURED_REASONS: Record<Lang, Record<ModelName, string>> = {
ko: {
GPT: "한국 역습과 체코 제공권이 팽팽",
Claude: "손흥민·이강인 측면 창의성이 체코 블록을 연다",
Gemini: "이강인·손흥민 공격력, 체코 수비에 근소 우세",
},
en: {
GPT: "Korea's counters vs Czech aerial — dead even",
Claude: "Son & Lee's wing play cracks the Czech block",
Gemini: "Korea's attack edges a stubborn Czech defense",
},
};
export const MATCH = FEATURED; // 대표 경기 = 한국 vs 체코 (schedule SSOT)
// 데모용: 실제 운영은 투표 오픈 = 킥오프 D-2(opensAt). 팀 데모에서 픽이 막히지 않게
// true면 오픈 게이트를 무시하고 즉시 참여 가능. 운영 배포 시 false 로.
export const DEMO_FORCE_OPEN = true;
// 체코전 — 실제 3모델 API 출력 (2026-06-10). reasonShort는 getPredictions에서 FEATURED_REASONS로 언어별 대체.
const FEATURED_PREDICTIONS: AIPrediction[] = [
{
matchId: MATCH.matchId,
model: "GPT", // gpt-5.5: 무승부 1-1
outcome: "DRAW",
scoreA: 1,
scoreB: 1,
confidencePct: 54,
reasonShort: FEATURED_REASONS.ko.GPT,
generatedAt: "2026-06-10",
},
{
matchId: MATCH.matchId,
model: "Claude", // claude-opus-4-8: 한국 승 2-1
outcome: "TEAM_A_WIN",
scoreA: 2,
scoreB: 1,
confidencePct: 57,
reasonShort: FEATURED_REASONS.ko.Claude,
generatedAt: "2026-06-10",
},
{
matchId: MATCH.matchId,
model: "Gemini", // gemini-3.5-flash: 한국 승 2-1
outcome: "TEAM_A_WIN",
scoreA: 2,
scoreB: 1,
confidencePct: 55,
reasonShort: FEATURED_REASONS.ko.Gemini,
generatedAt: "2026-06-10",
},
];
// ── 결정론적 생성 (matchId 시드) ──────────────────────────────
function hash(s: string): number {
let h = 2166136261;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
const MODELS: ModelName[] = ["GPT", "Claude", "Gemini"];
// AI 셋이 갈리도록 만든 결과 조합 (홈/원정 균형)
const OUTCOME_SETS: Outcome[][] = [
["TEAM_A_WIN", "DRAW", "TEAM_B_WIN"],
["TEAM_A_WIN", "TEAM_A_WIN", "DRAW"],
["TEAM_B_WIN", "DRAW", "TEAM_A_WIN"],
["TEAM_A_WIN", "DRAW", "TEAM_A_WIN"],
["DRAW", "TEAM_B_WIN", "TEAM_A_WIN"],
];
const SCORE_BY_OUTCOME: Record<Outcome, [number, number][]> = {
TEAM_A_WIN: [[2, 1], [1, 0], [2, 0], [3, 1]],
DRAW: [[1, 1], [0, 0], [2, 2]],
TEAM_B_WIN: [[0, 1], [1, 2], [0, 2], [1, 3]],
};
function reasonFor(match: Match, outcome: Outcome, seed: number, lang: Lang): string {
const win =
outcome === "TEAM_A_WIN"
? teamShort(match.teamA, lang)
: outcome === "TEAM_B_WIN"
? teamShort(match.teamB, lang)
: null;
const pools =
lang === "en"
? {
win: win
? [
`${win}'s wing speed edges it`,
`${win} just needs one set piece`,
`${win} starts on the front foot`,
`${win}'s finishing decides it`,
]
: [],
draw: [
"A tight midfield battle to the end",
"Both sides carry a real threat",
"The first goal decides everything",
],
}
: {
win: win
? [
`${win} 측면 스피드가 한 끗 위`,
`${win} 세트피스 한 방이면 끝`,
`${win} 초반 기세에서 앞선다`,
`${win} 결정력이 승부를 가른다`,
]
: [],
draw: [
"중원 힘싸움이 끝까지 팽팽",
"양 팀 다 한 방 있는 진검승부",
"first goal이 모든 걸 가른다",
],
};
const pool = win ? pools.win : pools.draw;
return pool[seed % pool.length];
}
function generatePredictions(match: Match, lang: Lang): AIPrediction[] {
const base = hash(match.matchId);
const outcomes = OUTCOME_SETS[base % OUTCOME_SETS.length];
return MODELS.map((model, i) => {
const seed = hash(match.matchId + model);
const outcome = outcomes[i];
const scores = SCORE_BY_OUTCOME[outcome];
const [scoreA, scoreB] = scores[seed % scores.length];
const confidencePct =
outcome === "DRAW" ? 33 + (seed % 13) : 46 + (seed % 25); // draw 33~45, win 46~70
return {
matchId: match.matchId,
model,
outcome,
scoreA,
scoreB,
confidencePct,
reasonShort: reasonFor(match, outcome, seed, lang),
generatedAt: "2026-06-08",
};
});
}
export function getPredictions(match: Match, lang: Lang = "ko"): AIPrediction[] {
if (match.matchId === FEATURED.matchId) {
return FEATURED_PREDICTIONS.map((p) => ({
...p,
reasonShort: FEATURED_REASONS[lang][p.model],
}));
}
return generatePredictions(match, lang);
}
export function getCrowd(match: Match): CrowdStats {
if (match.matchId === FEATURED.matchId) {
return { matchId: MATCH.matchId, total: 1284, teamAWin: 616, draw: 347, teamBWin: 321 };
}
const base = hash(match.matchId + "crowd");
const total = 240 + (base % 1400);
const a = 28 + (base % 40); // 28~67%
const d = 18 + ((base >> 3) % 22); // 18~39%
const teamAWin = Math.round((total * a) / 100);
const draw = Math.round((total * Math.min(d, 100 - a - 5)) / 100);
const teamBWin = total - teamAWin - draw;
return { matchId: match.matchId, total, teamAWin, draw, teamBWin };
}
// 경기별 공유 딥링크 (D8)
const ORIGIN = "https://triplepick-web.vercel.app";
export function matchUrl(matchId: string): string {
return `${ORIGIN}/match/${matchId}`;
}
// ── 하위 호환 (체코전 단일 참조용) ──
export const AI_PREDICTIONS: AIPrediction[] = FEATURED_PREDICTIONS;
export const CROWD: CrowdStats = getCrowd(FEATURED);
export const LANDING_URL = matchUrl(FEATURED.matchId);
// schedule re-export (페이지에서 한 곳에서 import)
export { GROUP_A };

View File

@ -1,135 +0,0 @@
// 2026 FIFA World Cup Group A 정식 일정 (출처: Wikipedia/FIFA, KST 변환 검증 2026-06-08)
// 모든 kickoffKst는 한국시간(+09:00). lockAt = 킥오프 -10분.
import type { Match, Team } from "./types";
const KOR: Team = { name: "Korea Republic", shortName: "한국", code: "KOR", flag: "🇰🇷" };
const CZE: Team = { name: "Czechia", shortName: "체코", code: "CZE", flag: "🇨🇿" };
const MEX: Team = { name: "Mexico", shortName: "멕시코", code: "MEX", flag: "🇲🇽" };
const RSA: Team = { name: "South Africa", shortName: "남아공", code: "RSA", flag: "🇿🇦" };
// 투표 오픈 = 킥오프 -48시간 (D-2). 마감 = 킥오프 정각(경기 시작 전까지).
function opens(iso: string): string {
return new Date(new Date(iso).getTime() - 48 * 3600000).toISOString();
}
// Group A 전체 6경기 — 경기별 정확 일시 자동 반영용 SSOT
export const GROUP_A: Match[] = [
{
matchId: "A_MEX_RSA_20260612",
roundLabel: "Match 01 · 개막전",
group: "A",
teamA: MEX,
teamB: RSA,
kickoffKst: "2026-06-12T04:00:00+09:00",
venue: "Estadio Azteca · Mexico City",
opensAt: opens("2026-06-12T04:00:00+09:00"),
lockAt: "2026-06-12T04:00:00+09:00",
status: "upcoming",
hookText: "글로벌 축구 개막전, AI는 개최국을 믿을까",
result: null,
},
{
matchId: "A_KOR_CZE_20260612",
roundLabel: "Match 01",
group: "A",
teamA: KOR,
teamB: CZE,
kickoffKst: "2026-06-12T11:00:00+09:00",
venue: "Estadio Guadalajara (Akron)",
opensAt: opens("2026-06-12T11:00:00+09:00"),
lockAt: "2026-06-12T11:00:00+09:00",
status: "upcoming",
hookText: "한국 첫 경기, AI의 선택은 갈렸다",
result: null,
},
{
matchId: "A_CZE_RSA_20260619",
roundLabel: "Match 02",
group: "A",
teamA: CZE,
teamB: RSA,
kickoffKst: "2026-06-19T01:00:00+09:00",
venue: "USA (TBD)",
opensAt: opens("2026-06-19T01:00:00+09:00"),
lockAt: "2026-06-19T01:00:00+09:00",
status: "upcoming",
hookText: "체코 vs 남아공, AI의 예측은",
result: null,
},
{
matchId: "A_MEX_KOR_20260619",
roundLabel: "Match 02",
group: "A",
teamA: MEX,
teamB: KOR,
kickoffKst: "2026-06-19T10:00:00+09:00",
venue: "Estadio Guadalajara (Akron)",
opensAt: opens("2026-06-19T10:00:00+09:00"),
lockAt: "2026-06-19T10:00:00+09:00",
status: "upcoming",
hookText: "개최국 멕시코 vs 한국, AI는 누구 편",
result: null,
},
{
matchId: "A_CZE_MEX_20260625",
roundLabel: "Match 03",
group: "A",
teamA: CZE,
teamB: MEX,
kickoffKst: "2026-06-25T10:00:00+09:00",
venue: "Estadio Azteca · Mexico City",
opensAt: opens("2026-06-25T10:00:00+09:00"),
lockAt: "2026-06-25T10:00:00+09:00",
status: "upcoming",
hookText: "체코 vs 멕시코, 운명의 최종전",
result: null,
},
{
matchId: "A_RSA_KOR_20260625",
roundLabel: "Match 03",
group: "A",
teamA: RSA,
teamB: KOR,
kickoffKst: "2026-06-25T10:00:00+09:00",
venue: "Estadio BBVA · Monterrey",
opensAt: opens("2026-06-25T10:00:00+09:00"),
lockAt: "2026-06-25T10:00:00+09:00",
status: "upcoming",
hookText: "한국 16강의 갈림길, AI의 마지막 예측",
result: null,
},
];
// 현재 노출할 대표 경기 (체코전)
export const FEATURED_MATCH_ID = "A_KOR_CZE_20260612";
export function getMatch(id: string): Match {
return GROUP_A.find((m) => m.matchId === id) ?? GROUP_A[1];
}
export const FEATURED = getMatch(FEATURED_MATCH_ID);
// 투표창 단계(D7): 오픈 전 / 투표중 / 마감(킥오프) / 종료(결과). DEMO_FORCE_OPEN는 UI 측에서 별도 처리.
export type MatchPhase = "scheduled" | "open" | "locked" | "finished";
export function matchPhase(m: Match, now: number = Date.now()): MatchPhase {
if (m.result) return "finished";
if (now >= new Date(m.lockAt).getTime()) return "locked";
if (now < new Date(m.opensAt).getTime()) return "scheduled";
return "open";
}
// 대시보드: 날짜별로 묶고 킥오프 오름차순 정렬
export function matchesByDate(): { date: string; matches: Match[] }[] {
const sorted = [...GROUP_A].sort(
(a, b) => new Date(a.kickoffKst).getTime() - new Date(b.kickoffKst).getTime(),
);
const groups: { date: string; matches: Match[] }[] = [];
for (const m of sorted) {
const key = m.kickoffKst.slice(0, 10);
const last = groups[groups.length - 1];
if (last && last.date === key) last.matches.push(m);
else groups.push({ date: key, matches: [m] });
}
return groups;
}

View File

@ -1,60 +0,0 @@
// TriplePick 도메인 타입 — 기능정의서 데이터모델과 정합
// Firebase 연결 시 이 타입을 Firestore 변환기(converter)에 그대로 사용한다.
export type Outcome = "TEAM_A_WIN" | "DRAW" | "TEAM_B_WIN";
export type ModelName = "GPT" | "Claude" | "Gemini";
// scheduled: 오픈 전 / open: 투표 가능 / locked: 킥오프 후 마감 / live / finished
export type MatchStatus =
| "scheduled"
| "open"
| "locked"
| "live"
| "finished"
| "upcoming";
export interface Team {
name: string; // 표시명 (예: Korea Republic)
shortName: string; // 짧은 표시 (예: 한국)
code: string; // KOR / CZE
flag: string; // 이모지 국기 (1차), 추후 에셋 교체 가능
}
export interface Match {
matchId: string;
roundLabel: string; // "Match 01"
group: string; // "A"
teamA: Team;
teamB: Team;
kickoffKst: string; // ISO with +09:00
venue: string;
opensAt: string; // ISO — 투표 오픈 (킥오프 -48h, D-2)
lockAt: string; // ISO — 투표 마감 (킥오프 정각 = 경기 시작 전까지)
status: MatchStatus;
hookText: string; // "한국 첫 경기, AI의 선택은 갈렸다"
result?: { scoreA: number; scoreB: number; outcome: Outcome } | null;
}
export interface AIPrediction {
matchId: string;
model: ModelName;
outcome: Outcome;
scoreA: number;
scoreB: number;
confidencePct: number; // 0-100
reasonShort: string;
generatedAt: string; // 데이터 기준일
}
export interface CrowdStats {
matchId: string;
total: number;
teamAWin: number;
draw: number;
teamBWin: number;
}
export interface UserPick {
outcome: Outcome;
scoreA: number;
scoreB: number;
}

View File

@ -1,7 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

7643
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,27 +0,0 @@
{
"name": "triplepick-web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"firebase": "^12.14.0",
"next": "16.2.7",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.7",
"tailwindcss": "^4",
"typescript": "^5"
}
}

View File

@ -1,7 +0,0 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

View File

@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

View File

@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B

View File

@ -1,60 +0,0 @@
// Firestore 시드 스크립트 — matches / ai_predictions / crowd_stats 초기화
// 실행: GOOGLE_APPLICATION_CREDENTIALS=./serviceAccount.json node scripts/seed.mjs
// ⚠️ AI 예측은 플레이스홀더. 발행 전 실제 GPT/Claude/Gemini 출력으로 교체.
// (경기 데이터는 lib/schedule.ts 와 동일하게 유지할 것 — 여기서는 JS로 인라인)
import { initializeApp, cert, applicationDefault } from "firebase-admin/app";
import { getFirestore, FieldValue } from "firebase-admin/firestore";
initializeApp({ credential: applicationDefault() });
const db = getFirestore();
// 투표 오픈 = 킥오프 -48h(D-2), 마감 = 킥오프 정각(경기 시작 전까지)
const opens = (iso) => new Date(new Date(iso).getTime() - 48 * 3600000).toISOString();
const T = {
KOR: { name: "Korea Republic", shortName: "한국", code: "KOR", flag: "🇰🇷" },
CZE: { name: "Czechia", shortName: "체코", code: "CZE", flag: "🇨🇿" },
MEX: { name: "Mexico", shortName: "멕시코", code: "MEX", flag: "🇲🇽" },
RSA: { name: "South Africa", shortName: "남아공", code: "RSA", flag: "🇿🇦" },
};
const MATCHES = [
["A_MEX_RSA_20260612", "Match 01 · 개막전", T.MEX, T.RSA, "2026-06-12T04:00:00+09:00", "Estadio Azteca · Mexico City", "월드컵 개막전, AI는 개최국을 믿을까"],
["A_KOR_CZE_20260612", "Match 01", T.KOR, T.CZE, "2026-06-12T11:00:00+09:00", "Estadio Guadalajara (Akron)", "한국 첫 경기, AI의 선택은 갈렸다"],
["A_CZE_RSA_20260619", "Match 02", T.CZE, T.RSA, "2026-06-19T01:00:00+09:00", "USA (TBD)", "체코 vs 남아공, AI의 예측은"],
["A_MEX_KOR_20260619", "Match 02", T.MEX, T.KOR, "2026-06-19T10:00:00+09:00", "Estadio Guadalajara (Akron)", "개최국 멕시코 vs 한국, AI는 누구 편"],
["A_CZE_MEX_20260625", "Match 03", T.CZE, T.MEX, "2026-06-25T10:00:00+09:00", "Estadio Azteca · Mexico City", "체코 vs 멕시코, 운명의 최종전"],
["A_RSA_KOR_20260625", "Match 03", T.RSA, T.KOR, "2026-06-25T10:00:00+09:00", "Estadio BBVA · Monterrey", "한국 16강의 갈림길, AI의 마지막 예측"],
];
// 한국 vs 체코 AI 예측 (플레이스홀더)
const AI = {
A_KOR_CZE_20260612: [
{ model: "GPT", outcome: "TEAM_A_WIN", scoreA: 2, scoreB: 1, confidencePct: 62, reasonShort: "한국의 측면 공격 우위와 세트피스 강점", generatedAt: "2026-06-08" },
{ model: "Claude", outcome: "DRAW", scoreA: 1, scoreB: 1, confidencePct: 41, reasonShort: "중원 밸런스가 팽팽한 접전 예상", generatedAt: "2026-06-08" },
{ model: "Gemini", outcome: "TEAM_B_WIN", scoreA: 0, scoreB: 2, confidencePct: 28, reasonShort: "체코의 전환 속도와 결정력 우세", generatedAt: "2026-06-08" },
],
};
async function run() {
const batch = db.batch();
for (const [matchId, round, a, b, kickoff, venue, hook] of MATCHES) {
batch.set(db.collection("matches").doc(matchId), {
matchId, roundLabel: round, group: "A", teamA: a, teamB: b,
kickoffKst: kickoff, venue, opensAt: opens(kickoff), lockAt: kickoff,
status: "scheduled", hookText: hook, result: null,
updatedAt: FieldValue.serverTimestamp(),
});
// crowd_stats 초기화 (0)
batch.set(db.collection("crowd_stats").doc(matchId), {
matchId, total: 0, teamAWin: 0, draw: 0, teamBWin: 0, updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
}
for (const [matchId, preds] of Object.entries(AI)) {
for (const p of preds) {
batch.set(db.collection("ai_predictions").doc(`${matchId}_${p.model}`), { matchId, ...p });
}
}
await batch.commit();
console.log(`seeded: ${MATCHES.length} matches, ${Object.values(AI).flat().length} ai_predictions, crowd_stats init`);
}
run().catch((e) => { console.error(e); process.exit(1); });

View File

@ -1,34 +0,0 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}