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
|
|
@ -1,5 +0,0 @@
|
||||||
{
|
|
||||||
"projects": {
|
|
||||||
"default": "REPLACE_WITH_NEW_FIREBASE_PROJECT_ID"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +1,29 @@
|
||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
/node_modules
|
node_modules/
|
||||||
/.pnp
|
.pnp
|
||||||
.pnp.*
|
.pnp.*
|
||||||
.yarn/*
|
|
||||||
!.yarn/patches
|
|
||||||
!.yarn/plugins
|
|
||||||
!.yarn/releases
|
|
||||||
!.yarn/versions
|
|
||||||
|
|
||||||
# testing
|
# build output
|
||||||
/coverage
|
frontend/dist/
|
||||||
|
build/
|
||||||
# next.js
|
|
||||||
/.next/
|
|
||||||
/out/
|
/out/
|
||||||
|
|
||||||
# production
|
# python
|
||||||
/build
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# env files (실 키는 커밋하지 않음 — .env.example 만 커밋)
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# docker volume (로컬)
|
||||||
|
pgdata/
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
@ -26,20 +31,11 @@
|
||||||
|
|
||||||
# debug
|
# debug
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
.pnpm-debug.log*
|
|
||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
|
||||||
.env*
|
|
||||||
|
|
||||||
# vercel
|
|
||||||
.vercel
|
|
||||||
|
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
|
||||||
|
|
||||||
# secrets
|
# secrets (구 firebase 잔재)
|
||||||
serviceAccount.json
|
serviceAccount.json
|
||||||
.firebaserc
|
.firebaserc
|
||||||
|
|
|
||||||
49
AGENTS.md
|
|
@ -1,5 +1,46 @@
|
||||||
<!-- BEGIN:nextjs-agent-rules -->
|
# TriplePick — 아키텍처 (에이전트용 안내)
|
||||||
# This is NOT the Next.js you know
|
|
||||||
|
|
||||||
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.
|
"AI(GPT·Claude·Gemini) vs 너" 글로벌 축구 승부예측 서비스. 모바일 우선.
|
||||||
<!-- END:nextjs-agent-rules -->
|
|
||||||
|
## 스택 (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
|
||||||
|
|
|
||||||
98
README.md
|
|
@ -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
|
o2o-triple-pick/
|
||||||
# or
|
├── docker-compose.yml # db · api · worker · frontend 한 번에 실행
|
||||||
yarn dev
|
├── backend/ # FastAPI + SQLAlchemy(async) + PostgreSQL
|
||||||
# or
|
│ ├── app/ # API · 워커(APScheduler) · AI/이메일 실연동
|
||||||
pnpm dev
|
│ └── .env.example
|
||||||
# or
|
└── frontend/ # React + Vite + TypeScript + Tailwind v4
|
||||||
bun dev
|
├── 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)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
BIN
app/favicon.ico
|
Before Width: | Height: | Size: 25 KiB |
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
37
app/page.tsx
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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,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()
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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),
|
||||||
|
)
|
||||||
|
|
@ -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"}
|
||||||
|
|
@ -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()
|
||||||
|
)
|
||||||
|
|
@ -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"},
|
||||||
|
)
|
||||||
|
|
@ -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))
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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),
|
||||||
|
)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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))
|
||||||
|
|
@ -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}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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 []
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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:
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
{
|
|
||||||
"firestore": {
|
|
||||||
"rules": "firestore.rules",
|
|
||||||
"indexes": "firestore.indexes.json"
|
|
||||||
},
|
|
||||||
"functions": {
|
|
||||||
"source": "functions",
|
|
||||||
"runtime": "nodejs20",
|
|
||||||
"region": "asia-northeast3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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": []
|
|
||||||
}
|
|
||||||
|
|
@ -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).
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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;"]
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 806 KiB After Width: | Height: | Size: 806 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.8 MiB |
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,4 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { DEMO_FORCE_OPEN } from "@/lib/mockData";
|
|
||||||
import { outcomeLabel, pct, kickoffDisplay, winProb } from "@/lib/format";
|
import { outcomeLabel, pct, kickoffDisplay, winProb } from "@/lib/format";
|
||||||
import { type Lang, dict, teamShort } from "@/lib/i18n";
|
import { type Lang, dict, teamShort } from "@/lib/i18n";
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -12,6 +9,7 @@ import type {
|
||||||
AIPrediction,
|
AIPrediction,
|
||||||
Team,
|
Team,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
|
import { ApiError, getDeviceId, submitPrediction } from "@/lib/api";
|
||||||
import Countdown from "./Countdown";
|
import Countdown from "./Countdown";
|
||||||
import TeamFlag from "./TeamFlag";
|
import TeamFlag from "./TeamFlag";
|
||||||
|
|
||||||
|
|
@ -49,6 +47,8 @@ export default function Arena({
|
||||||
const [notify, setNotify] = useState(true);
|
const [notify, setNotify] = useState(true);
|
||||||
const [crowd, setCrowd] = useState<CrowdStats>(initialCrowd);
|
const [crowd, setCrowd] = useState<CrowdStats>(initialCrowd);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// 점수 선택 시 승/무/패 자동 선택 (스코어가 결과의 소스)
|
// 점수 선택 시 승/무/패 자동 선택 (스코어가 결과의 소스)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -57,17 +57,14 @@ export default function Arena({
|
||||||
);
|
);
|
||||||
}, [scoreA, scoreB]);
|
}, [scoreA, scoreB]);
|
||||||
|
|
||||||
// 투표 창: 오픈(D-2) ≤ now < 마감(킥오프 정각). 종료 = 결과 존재.
|
// 투표 가능 여부는 백엔드 계산(votingOpen, demo 반영) + phase 라벨.
|
||||||
const now = Date.now();
|
|
||||||
const finished = !!match.result;
|
const finished = !!match.result;
|
||||||
const notOpen = !DEMO_FORCE_OPEN && now < new Date(match.opensAt).getTime();
|
const disabled = finished || !match.votingOpen;
|
||||||
const locked = now >= new Date(match.lockAt).getTime();
|
|
||||||
const disabled = finished || locked || notOpen;
|
|
||||||
const ctaLabel = finished
|
const ctaLabel = finished
|
||||||
? t.ctaResult
|
? t.ctaResult
|
||||||
: notOpen
|
: match.phase === "scheduled" && !match.votingOpen
|
||||||
? t.ctaOpens(kickoffDisplay(match.opensAt, lang))
|
? t.ctaOpens(kickoffDisplay(match.opensAt, lang))
|
||||||
: locked
|
: match.phase === "locked"
|
||||||
? t.ctaLocked
|
? t.ctaLocked
|
||||||
: t.ctaBeat;
|
: t.ctaBeat;
|
||||||
|
|
||||||
|
|
@ -82,16 +79,36 @@ export default function Arena({
|
||||||
if (!outcome) return;
|
if (!outcome) return;
|
||||||
setStep("form");
|
setStep("form");
|
||||||
};
|
};
|
||||||
const confirmSubmit = () => {
|
|
||||||
if (!EMAIL_RE.test(email.trim())) return;
|
const confirmSubmit = async () => {
|
||||||
setCrowd((c) => ({
|
if (!EMAIL_RE.test(email.trim()) || !outcome) return;
|
||||||
...c,
|
setSubmitting(true);
|
||||||
total: c.total + 1,
|
setError(null);
|
||||||
teamAWin: c.teamAWin + (outcome === "TEAM_A_WIN" ? 1 : 0),
|
try {
|
||||||
draw: c.draw + (outcome === "DRAW" ? 1 : 0),
|
const res = await submitPrediction({
|
||||||
teamBWin: c.teamBWin + (outcome === "TEAM_B_WIN" ? 1 : 0),
|
matchId: match.matchId,
|
||||||
}));
|
deviceId: getDeviceId(),
|
||||||
|
outcome,
|
||||||
|
scoreA,
|
||||||
|
scoreB,
|
||||||
|
email: email.trim(),
|
||||||
|
notify,
|
||||||
|
});
|
||||||
|
setCrowd(res.crowd); // 서버 권위 분포로 갱신
|
||||||
setStep("done");
|
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(() => {
|
const shareText = useMemo(() => {
|
||||||
|
|
@ -134,7 +151,7 @@ export default function Arena({
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* ===== 카운트다운 (D7) — 투표 진행 중일 때만 ===== */}
|
{/* ===== 카운트다운 (D7) — 투표 진행 중일 때만 ===== */}
|
||||||
{!finished && !notOpen && (
|
{!finished && match.votingOpen && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<Countdown to={match.lockAt} lang={lang} />
|
<Countdown to={match.lockAt} lang={lang} />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -286,12 +303,15 @@ export default function Arena({
|
||||||
/>
|
/>
|
||||||
{t.notify}
|
{t.notify}
|
||||||
</label>
|
</label>
|
||||||
|
{error && (
|
||||||
|
<p className="text-[13px] font-bold text-[#ff6b6b]">{error}</p>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={confirmSubmit}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1,10 +1,7 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { type Lang, dict } from "@/lib/i18n";
|
import { type Lang, dict } from "@/lib/i18n";
|
||||||
|
|
||||||
// "투표 종료까지" 카운트다운 (D7). 마감 = 킥오프 정각(lockAt).
|
// "투표 종료까지" 카운트다운 (D7). 마감 = 킥오프 정각(lockAt).
|
||||||
// 서버/클라 hydration mismatch 방지: 시간 계산은 마운트 후 useEffect에서만.
|
|
||||||
export default function Countdown({ to, lang = "ko" }: { to: string; lang?: Lang }) {
|
export default function Countdown({ to, lang = "ko" }: { to: string; lang?: Lang }) {
|
||||||
const t = dict(lang);
|
const t = dict(lang);
|
||||||
const label = t.cdUntil;
|
const label = t.cdUntil;
|
||||||
|
|
@ -29,7 +26,6 @@ export default function Countdown({ to, lang = "ko" }: { to: string; lang?: Lang
|
||||||
<span
|
<span
|
||||||
className="font-mono text-[18px] font-extrabold tabular-nums text-[var(--green)]"
|
className="font-mono text-[18px] font-extrabold tabular-nums text-[var(--green)]"
|
||||||
style={{ textShadow: "0 0 12px rgba(74,255,160,0.45)" }}
|
style={{ textShadow: "0 0 12px rgba(74,255,160,0.45)" }}
|
||||||
suppressHydrationWarning
|
|
||||||
>
|
>
|
||||||
{left === null ? "--:--:--" : fmt(left, lang)}
|
{left === null ? "--:--:--" : fmt(left, lang)}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import Link from "next/link";
|
import { Link } from "react-router-dom";
|
||||||
import ShareButton from "./ShareButton";
|
import ShareButton from "./ShareButton";
|
||||||
import LangSwitch from "./LangSwitch";
|
import LangSwitch from "./LangSwitch";
|
||||||
import { type Lang, dict } from "@/lib/i18n";
|
import { type Lang, dict } from "@/lib/i18n";
|
||||||
|
|
@ -22,7 +22,7 @@ export default function Hero({
|
||||||
{back && (
|
{back && (
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<Link
|
<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"
|
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}
|
{t.back}
|
||||||
|
|
@ -1,12 +1,9 @@
|
||||||
"use client";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
import type { Lang } from "@/lib/i18n";
|
import type { Lang } from "@/lib/i18n";
|
||||||
|
|
||||||
// KO / EN 토글. 현재 경로 유지 + ?lang 토글.
|
// KO / EN 토글. 현재 경로 유지 + ?lang 토글.
|
||||||
export default function LangSwitch({ lang }: { lang: 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 href = (l: Lang) => (l === "en" ? `${pathname}?lang=en` : pathname);
|
||||||
const langs: Lang[] = ["ko", "en"];
|
const langs: Lang[] = ["ko", "en"];
|
||||||
return (
|
return (
|
||||||
|
|
@ -14,8 +11,7 @@ export default function LangSwitch({ lang }: { lang: Lang }) {
|
||||||
{langs.map((l) => (
|
{langs.map((l) => (
|
||||||
<Link
|
<Link
|
||||||
key={l}
|
key={l}
|
||||||
href={href(l)}
|
to={href(l)}
|
||||||
scroll={false}
|
|
||||||
className={`px-2.5 py-1 transition ${
|
className={`px-2.5 py-1 transition ${
|
||||||
lang === l ? "bg-white text-[#14171C]" : "text-white/65"
|
lang === l ? "bg-white text-[#14171C]" : "text-white/65"
|
||||||
}`}
|
}`}
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import Link from "next/link";
|
import { Link } from "react-router-dom";
|
||||||
import { matchesByDate, matchPhase, type MatchPhase } from "@/lib/schedule";
|
|
||||||
import { getPredictions, getCrowd } from "@/lib/mockData";
|
|
||||||
import { dateHeading, timeOnly } from "@/lib/format";
|
import { dateHeading, timeOnly } from "@/lib/format";
|
||||||
import { type Lang, dict, teamShort, roundLabel as tRound } from "@/lib/i18n";
|
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";
|
import TeamFlag from "./TeamFlag";
|
||||||
|
|
||||||
const STROKE = "#94FBE0"; // 일정 텍스트·선택 아웃라인 스트로크 컬러
|
const STROKE = "#94FBE0"; // 일정 텍스트·선택 아웃라인 스트로크 컬러
|
||||||
|
|
@ -15,9 +14,27 @@ const PHASE_CLS: Record<MatchPhase, string> = {
|
||||||
finished: "border-white/15 text-white/55",
|
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 t = dict(lang);
|
||||||
const groups = matchesByDate();
|
const groups = groupByDate(matches);
|
||||||
return (
|
return (
|
||||||
<section className="mt-6">
|
<section className="mt-6">
|
||||||
<div className="mb-3 flex items-baseline gap-2.5">
|
<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 }) {
|
function MatchCard({ match, lang }: { match: Match; lang: Lang }) {
|
||||||
const t = dict(lang);
|
const t = dict(lang);
|
||||||
const phase = matchPhase(match);
|
const phase = match.phase;
|
||||||
const preds = getPredictions(match, lang);
|
const preds = match.predictions;
|
||||||
const crowd = getCrowd(match);
|
const crowdTotal = match.crowd?.total ?? 0;
|
||||||
|
|
||||||
// AI 픽 갈림 요약
|
// AI 픽 갈림 요약
|
||||||
const tally = { a: 0, d: 0, b: 0 };
|
const tally = { a: 0, d: 0, b: 0 };
|
||||||
|
|
@ -67,7 +84,7 @@ function MatchCard({ match, lang }: { match: Match; lang: Lang }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<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]"
|
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]">
|
<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 className="font-semibold text-white/55">{t.aiPicks}</span> {split.join(" · ")}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-2">
|
<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 className="text-[#94FBE0]">→</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
// 투표(경기) 공유 — 모바일 네이티브 공유(invoke), 미지원 시 클립보드 복사 폴백 (D8)
|
// 투표(경기) 공유 — 모바일 네이티브 공유, 미지원 시 클립보드 복사 폴백 (D8)
|
||||||
export default function ShareButton({
|
export default function ShareButton({
|
||||||
url,
|
url,
|
||||||
title,
|
title,
|
||||||
|
|
@ -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}`;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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>,
|
||||||
|
);
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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"]
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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) };
|
|
||||||
}
|
|
||||||
194
lib/mockData.ts
|
|
@ -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 };
|
|
||||||
135
lib/schedule.ts
|
|
@ -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;
|
|
||||||
}
|
|
||||||
60
lib/types.ts
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import type { NextConfig } from "next";
|
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
|
||||||
/* config options here */
|
|
||||||
};
|
|
||||||
|
|
||||||
export default nextConfig;
|
|
||||||
27
package.json
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
const config = {
|
|
||||||
plugins: {
|
|
||||||
"@tailwindcss/postcss": {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default config;
|
|
||||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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); });
|
|
||||||
|
|
@ -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"]
|
|
||||||
}
|
|
||||||