Compare commits
116 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bb930cc59f | |||
| df367070ce | |||
| fdb04a2ccc | |||
| bc333d64f4 | |||
| 11dd968ced | |||
| 746eaced80 | |||
| cb00945612 | |||
| eb4064279b | |||
| f8c24d1f3c | |||
| 9e8993c67a | |||
| 579ab768bf | |||
| 6770543318 | |||
| abcdf42aeb | |||
| c895bc9c6b | |||
| 434f282e6b | |||
| dbc26a4150 | |||
| 3a5fc36ee8 | |||
| dbd08e62c7 | |||
| 0fc7598d88 | |||
| 390e06113d | |||
| 277378477a | |||
| 2a8f2bc1fa | |||
| 8da8b7e7bd | |||
| d6b3ddeee0 | |||
| 6a9a7115fd | |||
| ea4d1ab746 | |||
| d97023629e | |||
| 22e2e8c29c | |||
| 9b57d56daf | |||
| b6fa863b58 | |||
| 7705099c64 | |||
| e2d1647100 | |||
| eecc9829b4 | |||
| e1b85aeba6 | |||
| 00494e9e93 | |||
| 13a4997cc2 | |||
| 0415ab7636 | |||
| 74de57fc1c | |||
| ad5bbec82e | |||
| c73a9ad9f9 | |||
| e1fb1f6b1d | |||
| 4a7719895e | |||
| bbad63ee92 | |||
| 4e1ebc8989 | |||
| bfaf621949 | |||
| 0392a63d5e | |||
| 590335b005 | |||
| 3864974ac0 | |||
| 6815540ae6 | |||
| 59db1c4e9a | |||
| 906896afad | |||
| a81c7f5644 | |||
| 56261e7667 | |||
| a2507439d7 | |||
| beaf65ef09 | |||
| 3b5f9458c4 | |||
| fd8493ae2d | |||
| d6b7737955 | |||
| ff7d84016a | |||
| 6fe52db8d1 | |||
| 7325c96f2c | |||
| 198f20a0f6 | |||
| 39766bae55 | |||
| 16bd01fc95 | |||
| 10b533af28 | |||
| d05e85e593 | |||
| 90d00a817b | |||
| 8580bbb713 | |||
| 0a64a220ca | |||
| 76eafceee8 | |||
| f03d3e373c | |||
| a8e20d5ac8 | |||
| 39c2003080 | |||
| b4fc96f299 | |||
| 0e5a63a28b | |||
| 2988a59749 | |||
| 7a68d27693 | |||
| 220eb946c1 | |||
| 7c42bd5277 | |||
| bac505fa99 | |||
| b62bedc665 | |||
| 8a67094510 | |||
| 7625db36a0 | |||
| 8140cd9e02 | |||
| 48885ec80e | |||
| 65e7c7571c | |||
| dc262009f9 | |||
| 4246ad6936 | |||
| 356706e1b4 | |||
| a031d60f3a | |||
| 6765e12054 | |||
| 4a0188a443 | |||
| c77d014a01 | |||
| 1d406f6a4e | |||
| 0f055dace1 | |||
| 3bcc47c0e8 | |||
| 955ffae4cf | |||
| deb1332440 | |||
| c9244bc42d | |||
| 86290a7c69 | |||
| 7a2affe10a | |||
| 54ed8bf10d | |||
| 140e15fb9f | |||
| 51352c7c5c | |||
| d8db7ed0f4 | |||
| 4244518ffd | |||
| 464145a234 | |||
| 5f8858c978 | |||
| 34f5aae010 | |||
| 1dc21e7916 | |||
| 8a4a23b0cb | |||
| c19ed6f1b5 | |||
| 7185cfbb72 | |||
| dc50fc6d8c | |||
| 1a803b7fa0 | |||
| 6ffe0dfca3 |
@ -1,5 +0,0 @@
|
||||
{
|
||||
"projects": {
|
||||
"default": "REPLACE_WITH_NEW_FIREBASE_PROJECT_ID"
|
||||
}
|
||||
}
|
||||
53
.gitignore
vendored
@ -1,24 +1,32 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
node_modules/
|
||||
.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
# build output
|
||||
frontend/dist/
|
||||
build/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
# python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
venv/
|
||||
*.egg-info/
|
||||
|
||||
# env files (실 키는 커밋하지 않음 — .env.example 만 커밋)
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# docker volume (로컬)
|
||||
pgdata/
|
||||
|
||||
# 로컬 검증 전용 compose (서버에 가면 안 됨)
|
||||
docker-compose.local.yml
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
@ -26,20 +34,17 @@
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# secrets
|
||||
# secrets (구 firebase 잔재)
|
||||
serviceAccount.json
|
||||
.firebaserc
|
||||
|
||||
# 로컬 전용 댓글 관리 도구
|
||||
tp-comment-admin/
|
||||
|
||||
# 개인 운영 대시보드 (관리자 토큰 포함 — 커밋 금지)
|
||||
ops/
|
||||
|
||||
49
AGENTS.md
@ -1,5 +1,46 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
# This is NOT the Next.js you know
|
||||
# TriplePick — 아키텍처 (에이전트용 안내)
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
"AI(GPT·Claude·Gemini) vs 너" 글로벌 축구 승부예측 서비스. 모바일 우선.
|
||||
|
||||
## 스택 (2026 리팩토링)
|
||||
- **frontend/** — React 19 + Vite + TypeScript + Tailwind v4, react-router. nginx 정적 서빙.
|
||||
- **backend/** — Python FastAPI + SQLAlchemy(async) + PostgreSQL. 별도 워커(APScheduler).
|
||||
- **docker-compose.yml** (루트) — db · api · worker · frontend 를 한 번에 실행.
|
||||
|
||||
## 디렉토리
|
||||
```
|
||||
backend/app/
|
||||
config.py 설정(.env): DB·투표윈도우·스케줄러시각·외부키
|
||||
database.py async 엔진/세션, init_db
|
||||
models.py ORM: matches / ai_predictions / crowd_stats / user_predictions
|
||||
schemas.py Pydantic API 계약 (프론트 lib/types.ts 와 정합)
|
||||
scoring.py 채점(docs/SCORING.md SSOT) + 임직원 배제
|
||||
schedule_data.py 경기 일정 SSOT (Group A 6경기, KST)
|
||||
seed_data.py 부트스트랩(결정론적) 예측/crowd 생성기
|
||||
seed.py DB 시드 (idempotent)
|
||||
domain.py phase 계산 + ORM→응답 직렬화
|
||||
services/ ai.py(3모델 실연동) · email.py(SMTP) · grading.py
|
||||
routers/ matches · predictions · leaderboard · admin
|
||||
main.py FastAPI 앱(API)
|
||||
worker.py APScheduler 워커(상태전이·AI생성·결과메일)
|
||||
frontend/src/
|
||||
lib/ types · i18n · format · api(백엔드 호출) · useLang
|
||||
components/ Hero · MatchupHUD · Arena · ScheduleBoard · TeamFlag · …
|
||||
pages/ Dashboard · MatchDetail · Leaderboard · NotFound
|
||||
```
|
||||
|
||||
## 시간/스케줄 규칙 (워커가 자동 처리)
|
||||
- 투표 오픈 = 킥오프 − 120h(D-5, `VOTE_OPEN_HOURS_BEFORE`)
|
||||
- 투표 마감 = 킥오프 5분 전(`VOTE_LOCK_MINUTES_BEFORE=5`)
|
||||
- 상태 전이: 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
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
o2o-triple-pick/
|
||||
├── docker-compose.yml # db · api · worker · frontend 한 번에 실행
|
||||
├── backend/ # FastAPI + SQLAlchemy(async) + PostgreSQL
|
||||
│ ├── app/ # API · 워커(APScheduler) · AI/이메일 실연동
|
||||
│ └── .env.example
|
||||
└── frontend/ # React + Vite + TypeScript + Tailwind v4
|
||||
├── src/ # pages · components · lib(api 연동)
|
||||
└── .env.example
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
- **frontend** — React SPA. nginx 가 정적 파일 서빙 + `/api` 를 백엔드로 프록시.
|
||||
- **backend (api)** — FastAPI. 경기/AI예측/crowd 읽기, 픽 제출, 채점, 리더보드, 관리자.
|
||||
- **backend (worker)** — APScheduler 별도 프로세스. 아래 "시간 기반 작업" 자동 처리.
|
||||
- **db** — PostgreSQL.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
## 빠른 시작 (Docker)
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
```bash
|
||||
cp backend/.env.example backend/.env # 키(선택) 채우기
|
||||
docker compose up --build
|
||||
# 프론트: http://localhost:8080
|
||||
# API 문서: http://localhost:8000/docs
|
||||
```
|
||||
|
||||
## Learn More
|
||||
DB 는 최초 기동 시 6경기 + AI 예측(부트스트랩) + crowd baseline 으로 자동 시드된다.
|
||||
AI/이메일 키가 없어도 전체 플로우(예측·제출·채점·리더보드)는 즉시 동작한다.
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
## 시간 기반 작업 (워커가 자동 처리)
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
조사된 "필요한 시간들"을 워커 단일 프로세스가 모두 담당한다:
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
| 작업 | 시점 | 설정 |
|
||||
|---|---|---|
|
||||
| 투표 오픈 | 킥오프 − 48h (D-2) | `VOTE_OPEN_HOURS_BEFORE` |
|
||||
| 투표 마감 | 킥오프 − 5분 | `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>
|
||||
);
|
||||
}
|
||||
87
backend/.env.example
Normal file
@ -0,0 +1,87 @@
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# TriplePick 백엔드 환경변수 — 복사: cp .env.example .env
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
# ── 데이터베이스 (외부 PostgreSQL 연결) ──
|
||||
# DB_HOST 가 설정되면 아래 DB_* 로 접속 URL 을 조합한다(특수문자 비밀번호 안전 처리).
|
||||
# 비밀번호에 # 가 들어가면 따옴표로 감쌀 것: DB_PASSWORD="비밀#번호"
|
||||
DB_NAME=triplepick
|
||||
DB_USER=o2o_db_admin
|
||||
DB_PASSWORD=changeme
|
||||
DB_HOST=172.30.1.36
|
||||
DB_PORT=5432
|
||||
|
||||
# (대안) DB_HOST 를 비워두면 아래 DATABASE_URL 을 그대로 사용. 로컬 sqlite 테스트 등.
|
||||
# DATABASE_URL=postgresql+asyncpg://triplepick:triplepick@localhost:5432/triplepick
|
||||
|
||||
# 일반
|
||||
TIMEZONE=Asia/Seoul
|
||||
CORS_ORIGINS=*
|
||||
PUBLIC_ORIGIN=http://localhost:8080
|
||||
|
||||
# 투표 윈도우 (도메인 규칙)
|
||||
VOTE_OPEN_HOURS_BEFORE=168 # 오픈 = 킥오프 D-7
|
||||
VOTE_LOCK_MINUTES_BEFORE=5 # 마감 = 킥오프 5분 전
|
||||
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= # football-data 사용 시 토큰 (football-data.org/client/register)
|
||||
FOOTBALL_DATA_COMPETITION=WC
|
||||
# 결과(스코어) 소스 — 일정과 분리. 비우면 SCHEDULE_SOURCE 사용.
|
||||
# openfootball 은 결과 미게시 → 자동 종료 쓰려면 football-data 권장.
|
||||
RESULT_SOURCE=football-data
|
||||
|
||||
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
|
||||
|
||||
# ── 카카오 로그인 (o2o-castad-backend 인증 이식) ──
|
||||
# 카카오 개발자 콘솔(developers.kakao.com) 앱의 REST API 키.
|
||||
# redirect_uri 는 콘솔 [카카오 로그인 > Redirect URI] 에 등록돼 있어야 한다.
|
||||
# 운영: https://triplepick.o2o.kr/api/auth/kakao/callback
|
||||
KAKAO_CLIENT_ID=
|
||||
KAKAO_CLIENT_SECRET= # 콘솔에서 Client Secret 활성화한 경우만
|
||||
KAKAO_REDIRECT_URI=http://localhost:8080/api/auth/kakao/callback
|
||||
JWT_SECRET=change-me-triplepick-jwt-secret # 32자 이상 무작위 문자열로 교체
|
||||
|
||||
# ── 외부 연동: 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
|
||||
|
||||
# ── 외부 연동: 축구 데이터 (API-Football 무료 티어) ──
|
||||
# 키 없으면 데이터 수집/주입 생략 → 기존(이름만) 예측으로 동작.
|
||||
# 발급: https://dashboard.api-football.com (무료 100req/일)
|
||||
FOOTBALL_API_KEY=
|
||||
|
||||
# ── 외부 연동: 이메일 ──
|
||||
# 1순위: Azure Communication Services(ACS) Email (endpoint + accesskey)
|
||||
# AZURE_ACS_SENDER 는 검증된 MailFrom 주소(예: donotreply@triplepick.o2o.kr)
|
||||
AZURE_ACS_ENDPOINT=https://o2o-common-acs.korea.communication.azure.com/
|
||||
AZURE_ACS_ACCESSKEY=
|
||||
AZURE_ACS_SENDER=donotreply@triplepick.o2o.kr
|
||||
|
||||
# 2순위(폴백): SMTP — ACS 미설정 시 사용
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM=TriplePick <no-reply@triplepick.app>
|
||||
SMTP_STARTTLS=true
|
||||
|
||||
# Suno (sunoapi.org 게이트웨이) — 오늘의 응원가 자동 생성
|
||||
SUNO_API_KEY=
|
||||
SONGS_ENABLED=true
|
||||
23
backend/Dockerfile
Normal file
@ -0,0 +1,23 @@
|
||||
# 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
|
||||
COPY data ./data
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# 기본: API 서버. 워커는 compose 에서 command 오버라이드.
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
0
backend/app/__init__.py
Normal file
224
backend/app/config.py
Normal file
@ -0,0 +1,224 @@
|
||||
"""애플리케이션 설정 — 환경변수(.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"
|
||||
)
|
||||
|
||||
# ── 데이터베이스 ─────────────────────────────────────────
|
||||
# 외부 DB 연결: DB_HOST 가 설정되면 아래 DB_* 항목으로 접속 URL 을 조합한다
|
||||
# (비밀번호 특수문자는 SQLAlchemy 가 안전하게 인코딩). DB_HOST 미설정 시
|
||||
# database_url(또는 sqlite 등 직접 지정값)을 그대로 사용.
|
||||
db_host: str = ""
|
||||
db_port: int = 5432
|
||||
db_name: str = "triplepick"
|
||||
db_user: str = "triplepick"
|
||||
db_password: str = "triplepick"
|
||||
# 직접 지정용 폴백 (DB_HOST 미설정 시 사용). 로컬 sqlite 테스트 등.
|
||||
database_url: str = (
|
||||
"postgresql+asyncpg://triplepick:triplepick@db:5432/triplepick"
|
||||
)
|
||||
|
||||
def sqlalchemy_url(self): # noqa: ANN201
|
||||
"""엔진 생성용 URL. DB_HOST 가 있으면 DB_* 로 조합(특수문자 안전)."""
|
||||
if self.db_host:
|
||||
from sqlalchemy import URL
|
||||
|
||||
return URL.create(
|
||||
"postgresql+asyncpg",
|
||||
username=self.db_user,
|
||||
password=self.db_password,
|
||||
host=self.db_host,
|
||||
port=self.db_port,
|
||||
database=self.db_name,
|
||||
)
|
||||
return self.database_url
|
||||
|
||||
# ── 일반 ────────────────────────────────────────────────
|
||||
timezone: str = "Asia/Seoul" # 모든 경기 시각의 기준 (KST)
|
||||
cors_origins: str = "*" # 콤마구분. nginx 프록시 사용 시 동일 출처라 보통 불필요.
|
||||
public_origin: str = "http://localhost:8080" # 공유 딥링크 베이스
|
||||
|
||||
# ── 투표 윈도우 (도메인 규칙) ─────────────────────────────
|
||||
# 오픈 = 킥오프 - VOTE_OPEN_HOURS_BEFORE (D-7 = 168h)
|
||||
vote_open_hours_before: int = 168
|
||||
# 마감 = 킥오프 5분 전 (경기 시작 5분 전까지 투표). lock 오프셋(분).
|
||||
vote_lock_minutes_before: int = 5
|
||||
# 데모: 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 단일 그룹 라벨 — 폴백용
|
||||
# 전체 조별리그(12개조 72경기) 일정을 불러올지. 결과/스코어는 전 경기 표시.
|
||||
schedule_all_groups: bool = True
|
||||
# AI 예측·투표 대상 조(이 조만 예측 생성·투표 가능). 그 외는 일정/결과만 표시.
|
||||
featured_group: str = "A"
|
||||
football_data_token: str = "" # football-data 사용 시 토큰
|
||||
football_data_competition: str = "WC"
|
||||
# 결과(스코어) 소스 — 일정 소스와 분리. 빈값이면 자동 결정(아래 effective).
|
||||
# openfootball 은 결과 미게시라, 자동 종료를 쓰려면 football-data 가 필요하다.
|
||||
result_source: str = ""
|
||||
|
||||
@property
|
||||
def effective_result_source(self) -> str:
|
||||
# 명시값 우선. 없으면: 전체 조 모드는 football-data 기반이므로 결과도 동일 소스로
|
||||
# 자동 정렬(openfootball 폴백 시 녹아웃·타 조 결과가 영영 안 들어오는 함정 방지).
|
||||
if self.result_source:
|
||||
return self.result_source.lower()
|
||||
if self.schedule_all_groups:
|
||||
return "football-data"
|
||||
return self.schedule_source.lower()
|
||||
|
||||
# ── 멀티리그 (kbo · mlb · mls) — 월드컵(wc)은 2026-08 종료 ──
|
||||
# 활성 리그 (콤마구분). 각 리그 워커가 자기 소스에서 일정·결과를 동기화.
|
||||
leagues: str = "kbo,mlb,mls"
|
||||
# 야구 일정 수집 윈도우 — 오늘 기준 미래 며칠치.
|
||||
# (투표 오픈·AI 예측 생성은 리그 공통: vote_open_hours_before / ai_generate_lookahead_hours)
|
||||
baseball_days_ahead: int = 7
|
||||
# 네이버 스포츠 비공식 API (KBO 일정·결과·프리뷰·문자중계) — 비공식, 로컬용.
|
||||
naver_api_base: str = "https://api-gw.sports.naver.com"
|
||||
# MLB 공식 Stats API (키 불필요).
|
||||
mlb_api_base: str = "https://statsapi.mlb.com/api"
|
||||
# ── 카카오 로그인 (o2o-castad-backend 인증 이식) ──────────
|
||||
# 카카오 개발자 콘솔의 REST API 키. redirect_uri 는 콘솔에 등록돼 있어야 한다.
|
||||
kakao_client_id: str = ""
|
||||
kakao_client_secret: str = "" # 콘솔에서 활성화한 경우만
|
||||
kakao_redirect_uri: str = "http://localhost:8080/api/auth/kakao/callback"
|
||||
# JWT — castad 와 동일: HS256, access 60분 / refresh 7일 (rotation)
|
||||
jwt_secret: str = "change-me-triplepick-jwt-secret"
|
||||
jwt_access_expire_minutes: int = 60
|
||||
jwt_refresh_expire_days: int = 7
|
||||
|
||||
# ESPN 비공식 API (MLS 일정·결과·순위·상세) — 키 불필요, 비공식.
|
||||
espn_api_base: str = "https://site.api.espn.com/apis"
|
||||
espn_mls_path: str = "sports/soccer/usa.1"
|
||||
# MLS 과거 경기 백필 일수 — MLS 는 주말 위주 편성이라 야구식 recheck 윈도우(3일)만으론
|
||||
# 일정판에 긴 공백이 생기고 자체 DB 폼 데이터도 비어서, 최근 한 달을 함께 수집한다.
|
||||
# (범위를 넓혀도 scoreboard 1콜, sync 는 추가형이라 과거 경기 재수집 무해)
|
||||
mls_days_back: int = 30
|
||||
|
||||
@property
|
||||
def league_list(self) -> list[str]:
|
||||
return [x.strip() for x in self.leagues.split(",") if x.strip()]
|
||||
|
||||
# AI 예측 생성 전체 스위치 — False 면 워커가 AI 호출을 전혀 하지 않음(로컬 테스트).
|
||||
ai_enabled: bool = True
|
||||
# 매일 AI 예측 생성 시각 (KST). 기능정의서: 매일 0시 1회 생성.
|
||||
ai_generate_hour: int = 0
|
||||
ai_generate_minute: int = 5
|
||||
# 투표 오픈(D-2) 선행 생성 시간(h). 1일 1회 생성이므로 다음 주기 전 오픈할
|
||||
# 경기를 미리 채우려면 ~24h 이상 필요. 30h = 하루치 + 여유. (only_missing 이라 총 호출 수 동일)
|
||||
# 킥오프 N시간 전부터 생성 — 야구 선발투수 예고(경기 전날 저녁) 이후 시점.
|
||||
ai_generate_lookahead_hours: int = 22
|
||||
|
||||
# ── 결과 자동 정산(관리자 입력 불필요) ───────────────────
|
||||
# 킥오프 + 이 시간(시) 경과 후, 외부 소스에서 스코어를 자동 수집해 채점·집계·메일.
|
||||
result_settle_hours: int = 3
|
||||
# 정산/메일 점검 주기(초). status_tick 와 함께 도는 별도 폴링.
|
||||
settle_tick_seconds: int = 300
|
||||
# 종료 경기 결과 재확인 기간(일). 최근 이 기간 내 종료 경기는 매 틱 외부 소스와
|
||||
# 대조해, 소스가 스코어를 정정(예: 잠정값→확정값)하면 자동 갱신·재채점한다.
|
||||
# 0이면 재확인 비활성(첫 정산값 고정). 오래된 경기는 대상에서 빠져 부하 bounded.
|
||||
result_recheck_days: int = 3
|
||||
# 결과 메일을 '맞춘 사람(승패 적중)'에게만 보낼지 여부. False면 구독자 전체.
|
||||
result_email_correct_only: bool = True
|
||||
# 결과 메일: 결과 확정 후 추가 지연(분). 자동정산은 이미 킥오프+Nh라 기본 0.
|
||||
result_email_delay_minutes: int = 0
|
||||
|
||||
# 상태 전이 틱 주기(초): 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"
|
||||
|
||||
# ── 외부 연동: 축구 데이터 (API-Football 무료 티어) ───────
|
||||
# 키 미설정 시 데이터 수집/주입 전부 no-op → 기존(이름만) 예측으로 폴백.
|
||||
football_api_key: str = ""
|
||||
football_api_base: str = "https://v3.football.api-sports.io"
|
||||
# 팀당 1회만 수집(fetch-once): 과거 시즌 폼·스쿼드·H2H 는 변하지 않고, 2026
|
||||
# 진행 결과(승패·스코어)는 build 시 우리 DB 에서 라이브로 읽는다. 한 번 캐시되면
|
||||
# 다시 받지 않으므로, 전 팀이 채워지면 이후 잡은 호출 0(사실상 수집 자동 종료).
|
||||
# 하루 호출 예산(무료 100/일 보호). 팀당 ~3콜이라 90이면 하루 ~30팀 →
|
||||
# 48팀이 약 2일에 채워짐. 예산 소진 시 남은 팀은 다음 날 잡이 이어서 누적 수집.
|
||||
football_daily_call_budget: int = 90
|
||||
# API 호출 간 최소 간격(초) — 무료 10req/분 제한 회피.
|
||||
football_call_interval_sec: float = 7.0
|
||||
# 수집 잡 시각(KST) — 예측 생성(00:05)보다 앞서 캐시를 채워둔다.
|
||||
football_refresh_hour: int = 0
|
||||
football_refresh_minute: int = 0
|
||||
|
||||
# ── 외부 연동: Suno (sunoapi.org 서드파티 게이트웨이) ─────
|
||||
# 오늘의 응원가 자동 생성. 키 미설정 시 전체 no-op.
|
||||
suno_api_key: str = ""
|
||||
suno_api_base: str = "https://api.sunoapi.org"
|
||||
suno_model: str = "V5"
|
||||
# 응원가 생성 전체 스위치 (KBO 만 대상 — 한국어 응원가)
|
||||
songs_enabled: bool = True
|
||||
# 킥오프 N분 전부터 생성 윈도우 시작 — 윈도우 안에서 라인업 발표를 기다린다
|
||||
song_generate_minutes_before: int = 150
|
||||
# 킥오프 N분 전까지 라인업이 안 뜨면 라인업 없이 폴백 생성 (곡 없는 날 방지)
|
||||
song_lineup_fallback_minutes_before: int = 40
|
||||
# 생성 대상 점검·생성 상태 폴링 주기(초)
|
||||
song_tick_seconds: int = 120
|
||||
# 응원가 커버 합성 시 KBO 로고를 가져올 내부 프론트 주소 (docker 네트워크)
|
||||
internal_frontend_base: str = "http://frontend"
|
||||
|
||||
# ── 외부 연동: 이메일 ─────────────────────────────────────
|
||||
# 1순위: Azure Communication Services(ACS) Email — endpoint + accesskey.
|
||||
# AZURE_ACS_SENDER 는 검증된 MailFrom 주소(예: donotreply@triplepick.o2o.kr).
|
||||
azure_acs_endpoint: str = ""
|
||||
azure_acs_accesskey: str = ""
|
||||
azure_acs_sender: str = ""
|
||||
# 2순위(폴백): SMTP — ACS 미설정 시 사용.
|
||||
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 acs_configured(self) -> bool:
|
||||
return bool(
|
||||
self.azure_acs_endpoint and self.azure_acs_accesskey and self.azure_acs_sender
|
||||
)
|
||||
|
||||
@property
|
||||
def cors_origin_list(self) -> list[str]:
|
||||
if self.cors_origins.strip() == "*":
|
||||
return ["*"]
|
||||
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
39
backend/app/database.py
Normal file
@ -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.sqlalchemy_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)
|
||||
191
backend/app/domain.py
Normal file
@ -0,0 +1,191 @@
|
||||
"""도메인 헬퍼 — 투표 단계(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, timedelta, timezone
|
||||
|
||||
from .config import settings
|
||||
from .models import AIPrediction, CrowdStats, Match, UserPrediction
|
||||
from .schemas import (
|
||||
AIPredictionOut,
|
||||
CrowdStatsOut,
|
||||
MatchOut,
|
||||
MatchResult,
|
||||
MyPredictionOut,
|
||||
MyResultOut,
|
||||
Team,
|
||||
)
|
||||
|
||||
|
||||
def now_utc() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
KST = timezone(timedelta(hours=9))
|
||||
|
||||
|
||||
def ensure_aware(dt: datetime) -> datetime:
|
||||
"""naive datetime(일부 DB 드라이버 반환)을 UTC aware 로 보정."""
|
||||
return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt
|
||||
|
||||
|
||||
def kst_iso(dt: datetime) -> str:
|
||||
"""저장값(UTC)을 KST(+09:00) ISO 문자열로 직렬화 — 프론트 표시 기준 통일."""
|
||||
return ensure_aware(dt).astimezone(KST).isoformat()
|
||||
|
||||
|
||||
def compute_phase(m: Match, now: datetime | None = None) -> str:
|
||||
"""투표/경기 단계:
|
||||
cancelled 우천취소 등 취소 확정 → "취소" (투표·정산 제외, 기록은 보존)
|
||||
finished 결과 입력됨 → "종료"
|
||||
live 킥오프 이후, 결과 입력 전 → "경기중"
|
||||
locked 투표 마감(킥오프 1h 전) ~ 킥오프 → "투표 종료"(비활성)
|
||||
scheduled 오픈 전 → "오픈 예정"
|
||||
open 투표 중
|
||||
"""
|
||||
now = now or now_utc()
|
||||
if m.status == "cancelled": # 우천취소 등 — 시간과 무관하게 고정
|
||||
return "cancelled"
|
||||
if m.result_outcome is not None:
|
||||
return "finished"
|
||||
if now >= ensure_aware(m.kickoff_at):
|
||||
return "live"
|
||||
if now >= ensure_aware(m.lock_at):
|
||||
return "locked"
|
||||
if now < ensure_aware(m.opens_at):
|
||||
return "scheduled"
|
||||
return "open"
|
||||
|
||||
|
||||
def is_votable(m: Match) -> bool:
|
||||
"""투표 지원 경기 여부 — 전체 조 투표 가능.
|
||||
실제 오픈/마감은 is_open_for_voting 의 시간창(D-2 오픈 ~ 킥오프 60분 전 마감)이 결정."""
|
||||
return True
|
||||
|
||||
|
||||
def is_open_for_voting(m: Match, now: datetime | None = None) -> bool:
|
||||
"""제출 허용 여부. demo_force_open 이면 마감 전까지 항상 오픈."""
|
||||
now = now or now_utc()
|
||||
if not is_votable(m):
|
||||
return False
|
||||
if m.status == "cancelled":
|
||||
return False
|
||||
if m.result_outcome is not None:
|
||||
return False
|
||||
if now >= ensure_aware(m.lock_at):
|
||||
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 my_prediction_out(p: UserPrediction, m: Match) -> MyPredictionOut:
|
||||
"""유저 픽 1건을 경기 정보와 합쳐 직렬화 (내 지난 예측 목록용)."""
|
||||
result = None
|
||||
if m.result_outcome is not None:
|
||||
result = MyResultOut(
|
||||
scoreA=m.result_score_a or 0,
|
||||
scoreB=m.result_score_b or 0,
|
||||
outcome=m.result_outcome, # type: ignore[arg-type]
|
||||
hitOutcome=p.outcome == m.result_outcome,
|
||||
)
|
||||
return MyPredictionOut(
|
||||
matchId=p.match_id,
|
||||
teamA=_team_a(m),
|
||||
teamB=_team_b(m),
|
||||
kickoffKst=kst_iso(m.kickoff_at),
|
||||
outcome=p.outcome, # type: ignore[arg-type]
|
||||
scoreA=p.score_a,
|
||||
scoreB=p.score_b,
|
||||
submittedAt=kst_iso(p.updated_at or p.created_at),
|
||||
result=result,
|
||||
)
|
||||
|
||||
|
||||
def match_out(
|
||||
m: Match,
|
||||
lang: str = "ko",
|
||||
include_predictions: bool = True,
|
||||
now: datetime | None = None,
|
||||
extras: dict | None = None,
|
||||
) -> MatchOut:
|
||||
result = None
|
||||
if m.result_outcome is not None:
|
||||
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))
|
||||
# status·phase 를 동일한 실시간 계산값으로 통일 — 워커 틱 지연과 무관하게 일관.
|
||||
phase = compute_phase(m, now)
|
||||
return MatchOut(
|
||||
matchId=m.match_id,
|
||||
league=m.league or "wc",
|
||||
roundLabel=m.round_label,
|
||||
group=m.group,
|
||||
teamA=_team_a(m),
|
||||
teamB=_team_b(m),
|
||||
kickoffKst=kst_iso(m.kickoff_at),
|
||||
venue=m.venue,
|
||||
opensAt=kst_iso(m.opens_at),
|
||||
lockAt=kst_iso(m.lock_at),
|
||||
status=phase,
|
||||
phase=phase,
|
||||
votable=is_votable(m),
|
||||
votingOpen=is_open_for_voting(m, now),
|
||||
hookText=m.hook_text,
|
||||
result=result,
|
||||
predictions=preds,
|
||||
crowd=crowd_out(m.crowd, m.match_id),
|
||||
extras=extras,
|
||||
)
|
||||
69
backend/app/main.py
Normal file
@ -0,0 +1,69 @@
|
||||
"""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,
|
||||
auth,
|
||||
comments,
|
||||
leaderboard,
|
||||
matches,
|
||||
predictions,
|
||||
share,
|
||||
songs,
|
||||
standings,
|
||||
visits,
|
||||
)
|
||||
from .scoring import load_scoring_data
|
||||
from .seed import seed_if_empty
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
log = logging.getLogger("triplepick")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI): # noqa: ANN201
|
||||
load_scoring_data() # data/scoring.json → 배점·배제 대상
|
||||
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(auth.router)
|
||||
app.include_router(matches.router)
|
||||
app.include_router(comments.router)
|
||||
app.include_router(predictions.router)
|
||||
app.include_router(leaderboard.router)
|
||||
app.include_router(standings.router)
|
||||
app.include_router(admin.router)
|
||||
app.include_router(visits.router)
|
||||
app.include_router(songs.router)
|
||||
# 공유 미리보기(OG) 프리렌더 — nginx 가 크롤러 UA 의 /match/:id 만 여기로 보낸다.
|
||||
app.include_router(share.router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health() -> dict:
|
||||
return {"ok": True, "service": "triplepick-api"}
|
||||
386
backend/app/models.py
Normal file
@ -0,0 +1,386 @@
|
||||
"""ORM 모델 — 기능정의서 데이터모델 + lib/types.ts 와 정합.
|
||||
|
||||
테이블:
|
||||
- matches 경기 (팀/시각/투표윈도우/상태/결과)
|
||||
- ai_predictions GPT/Claude/Gemini 예측 (경기 × 모델)
|
||||
- crowd_stats 군중 투표 분포 (경기당 1행, 원자적 증분)
|
||||
- user_predictions 유저 픽 (이메일 식별, 채점/알림 플래그 포함)
|
||||
- user_points 유저별 누적 포인트 (채점 시 갱신, 이메일당 1행)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
BigInteger,
|
||||
Boolean,
|
||||
Date,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
LargeBinary,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from .database import Base
|
||||
|
||||
# Outcome: TEAM_A_WIN | DRAW | TEAM_B_WIN
|
||||
# ModelName: GPT | Claude | Gemini
|
||||
# MatchStatus: scheduled | open | locked | live | finished
|
||||
|
||||
|
||||
class Match(Base):
|
||||
__tablename__ = "matches"
|
||||
|
||||
match_id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
# 리그: wc(월드컵 축구) | kbo | mlb — 멀티리그 단일 서비스의 분기 키
|
||||
league: Mapped[str] = mapped_column(String, default="wc", index=True)
|
||||
round_label: Mapped[str] = mapped_column(String, default="")
|
||||
group: Mapped[str] = mapped_column(String, default="A")
|
||||
|
||||
# 팀 정보 (표시명/약식/코드/이모지) — 분리 컬럼으로 저장
|
||||
team_a_name: Mapped[str] = mapped_column(String)
|
||||
team_a_short: Mapped[str] = mapped_column(String)
|
||||
team_a_code: Mapped[str] = mapped_column(String)
|
||||
team_a_flag: Mapped[str] = mapped_column(String, default="")
|
||||
team_b_name: Mapped[str] = mapped_column(String)
|
||||
team_b_short: Mapped[str] = mapped_column(String)
|
||||
team_b_code: Mapped[str] = mapped_column(String)
|
||||
team_b_flag: Mapped[str] = mapped_column(String, default="")
|
||||
|
||||
venue: Mapped[str] = mapped_column(String, default="")
|
||||
hook_text: Mapped[str] = mapped_column(String, default="")
|
||||
|
||||
# 모든 시각은 timezone-aware (UTC 저장, KST 환산은 표현 계층)
|
||||
kickoff_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
opens_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
lock_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
status: Mapped[str] = mapped_column(String, default="scheduled")
|
||||
|
||||
# 결과 (입력 전 None)
|
||||
result_score_a: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
result_score_b: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
result_outcome: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
results_emailed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
predictions: Mapped[list["AIPrediction"]] = relationship(
|
||||
back_populates="match", cascade="all, delete-orphan"
|
||||
)
|
||||
crowd: Mapped["CrowdStats | None"] = relationship(
|
||||
back_populates="match", uselist=False, cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class AIPrediction(Base):
|
||||
__tablename__ = "ai_predictions"
|
||||
__table_args__ = (UniqueConstraint("match_id", "model", name="uq_match_model"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
match_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("matches.match_id", ondelete="CASCADE")
|
||||
)
|
||||
model: Mapped[str] = mapped_column(String) # GPT | Claude | Gemini
|
||||
outcome: Mapped[str] = mapped_column(String)
|
||||
score_a: Mapped[int] = mapped_column(Integer)
|
||||
score_b: Mapped[int] = mapped_column(Integer)
|
||||
confidence_pct: Mapped[int] = mapped_column(Integer)
|
||||
reason_ko: Mapped[str] = mapped_column(String, default="")
|
||||
reason_en: Mapped[str] = mapped_column(String, default="")
|
||||
generated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
# 실연동 LLM 출력인지 시드 데이터인지 구분 (재생성 제어용)
|
||||
source: Mapped[str] = mapped_column(String, default="seed") # seed | llm
|
||||
|
||||
match: Mapped["Match"] = relationship(back_populates="predictions")
|
||||
|
||||
|
||||
class CrowdStats(Base):
|
||||
__tablename__ = "crowd_stats"
|
||||
|
||||
match_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("matches.match_id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
total: Mapped[int] = mapped_column(Integer, default=0)
|
||||
team_a_win: Mapped[int] = mapped_column(Integer, default=0)
|
||||
draw: Mapped[int] = mapped_column(Integer, default=0)
|
||||
team_b_win: Mapped[int] = mapped_column(Integer, default=0)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
match: Mapped["Match"] = relationship(back_populates="crowd")
|
||||
|
||||
|
||||
class UserPrediction(Base):
|
||||
__tablename__ = "user_predictions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("match_id", "device_id", name="uq_match_device"),
|
||||
# 같은 이메일은 같은 경기에 1픽만 (NULL 이메일은 다수 허용 — NULL 은 서로 구별).
|
||||
# 신규 DB 에만 자동 적용. 기존 테이블은 앱 로직(이메일 우선 식별)으로 보장.
|
||||
UniqueConstraint("match_id", "email", name="uq_match_email"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
match_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("matches.match_id", ondelete="CASCADE")
|
||||
)
|
||||
device_id: Mapped[str] = mapped_column(String) # 비로그인 식별 (클라 생성 uuid)
|
||||
outcome: Mapped[str] = mapped_column(String)
|
||||
score_a: Mapped[int] = mapped_column(Integer)
|
||||
score_b: Mapped[int] = mapped_column(Integer)
|
||||
email: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
notify: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# 채점 (결과 입력 후 갱신)
|
||||
points: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
scored_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
notified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class UserPoints(Base):
|
||||
"""유저별 누적 포인트 — 채점(grade_prediction) 결과를 이메일 단위로 집계.
|
||||
|
||||
등급별 횟수 컬럼은 scoring.json 의 key(score_*) 와 1:1 대응.
|
||||
exact_count 는 리더보드 동점 보정 1순위(정확 스코어 횟수)에 사용.
|
||||
"""
|
||||
|
||||
__tablename__ = "user_points"
|
||||
|
||||
email: Mapped[str] = mapped_column(String, primary_key=True) # 소문자 정규화
|
||||
total_points: Mapped[int] = mapped_column(Integer, default=0)
|
||||
exact_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
close_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
outcome_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
partial_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
miss_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
matches_played: Mapped[int] = mapped_column(Integer, default=0)
|
||||
first_scored_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class PageVisit(Base):
|
||||
"""페이지 방문 — 하루(KST)에 같은 기기(device_id)는 1회만 기록(순수 방문자 수).
|
||||
|
||||
일별 집계 = visit_date 로 GROUP BY COUNT. 같은 날 재방문은 유니크 제약으로 무시.
|
||||
"""
|
||||
|
||||
__tablename__ = "page_visits"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("visit_date", "device_id", name="uq_visit_date_device"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
visit_date: Mapped[date] = mapped_column(Date, index=True) # KST 기준 날짜
|
||||
device_id: Mapped[str] = mapped_column(String) # 비로그인 식별 (localStorage uuid)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
|
||||
class Comment(Base):
|
||||
"""경기별 한마디(댓글). 완전 익명 — 인증/이메일 없음.
|
||||
|
||||
- author_hash: sha256(device_id). 원본 기기ID는 저장하지 않음(추적 불가). 쿨다운 식별용.
|
||||
- nickname: 축구+코믹 한국어 5글자(기기 해시로 자동 배정, 기기당 고정).
|
||||
- id(PK)·author_hash 는 내부용으로 API 응답에 노출하지 않음.
|
||||
- 최신순 조회(created_at DESC) + limit/offset 페이징. is_hidden=True 는 조회 제외.
|
||||
"""
|
||||
|
||||
__tablename__ = "comments"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
match_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("matches.match_id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
author_hash: Mapped[str] = mapped_column(String, index=True) # sha256(device_id) — 원본 비저장
|
||||
nickname: Mapped[str] = mapped_column(String) # 축구 코믹 한국어 5글자
|
||||
body: Mapped[str] = mapped_column(String) # 길이 제한은 스키마(200자)에서
|
||||
is_hidden: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True
|
||||
)
|
||||
|
||||
|
||||
class FootballCache(Base):
|
||||
"""축구 데이터 캐시 (API-Football 수집 결과) — 예측 프롬프트 조립용.
|
||||
|
||||
key 규칙:
|
||||
team:{CODE} 팀 베이스라인(폼·평균득실·클린시트·스쿼드)
|
||||
h2h:{A}-{B} 상대전적
|
||||
teamid:{CODE} 팀코드→API팀ID 매핑
|
||||
payload 는 가공된 압축 JSON. fetched_at 으로 캐시 신선도(TTL) 판단.
|
||||
api·worker 가 공유하는 유일한 영속 저장소가 DB 라 여기에 둔다.
|
||||
"""
|
||||
|
||||
__tablename__ = "football_cache"
|
||||
|
||||
key: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
payload: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
fetched_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""카카오 소셜 로그인 사용자 (o2o-castad-backend 인증 이식).
|
||||
|
||||
투표(user_predictions)는 기존 이메일 식별을 유지하고,
|
||||
로그인 시 카카오 이메일을 투표 이메일로 자동 사용해 연결한다.
|
||||
"""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
kakao_id: Mapped[int] = mapped_column(BigInteger, unique=True, index=True)
|
||||
user_uuid: Mapped[str] = mapped_column(String, unique=True, index=True)
|
||||
email: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
nickname: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
profile_image_url: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
last_login_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
|
||||
class RefreshToken(Base):
|
||||
"""리프레시 토큰 (해시 저장·회전 시 폐기). castad 와 동일한 rotation 방식."""
|
||||
|
||||
__tablename__ = "refresh_tokens"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
user_uuid: Mapped[str] = mapped_column(String, index=True)
|
||||
token_hash: Mapped[str] = mapped_column(String, unique=True, index=True)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
is_revoked: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
user_agent: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
ip_address: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
|
||||
class Song(Base):
|
||||
"""오늘의 응원가 — 경기×팀 단위 Suno 생성 트랙.
|
||||
|
||||
가사·스타일은 LLM(경기 맥락 주입)이 쓰고, 음원은 Suno(sunoapi.org)가 생성.
|
||||
Suno CDN URL 은 임시라 완성 후 워커가 음원을 SongAudio 로 내려받아 보존하고
|
||||
tracks 의 audioUrl 을 자체 서빙 경로(/api/songs/audio/...)로 바꾼다.
|
||||
"""
|
||||
|
||||
__tablename__ = "songs"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("match_id", "team_code", name="uq_song_match_team"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
league: Mapped[str] = mapped_column(String, index=True)
|
||||
date_kst: Mapped[date] = mapped_column(Date, index=True) # 경기일 (KST)
|
||||
match_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("matches.match_id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
team_code: Mapped[str] = mapped_column(String)
|
||||
team_name: Mapped[str] = mapped_column(String)
|
||||
|
||||
title: Mapped[str] = mapped_column(String, default="")
|
||||
lyrics: Mapped[str] = mapped_column(String, default="")
|
||||
style: Mapped[str] = mapped_column(String, default="")
|
||||
|
||||
task_id: Mapped[str] = mapped_column(String, default="")
|
||||
# generating(Suno 작업 대기) | complete | failed
|
||||
status: Mapped[str] = mapped_column(String, default="generating", index=True)
|
||||
error: Mapped[str] = mapped_column(String, default="")
|
||||
attempts: Mapped[int] = mapped_column(Integer, default=0) # 실패 재시도 상한용
|
||||
# 라인업 발표 후 생성했는지 — False 면 라인업 공개 시 자동 재생성(업그레이드) 대상
|
||||
with_lineup: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
# 완성 트랙 [{title, audioUrl, imageUrl, duration}] — 보통 생성당 2곡
|
||||
tracks: Mapped[list] = mapped_column(JSON, default=list)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class SongAudio(Base):
|
||||
"""응원가 음원 원본 — Suno CDN 임시 URL 만료 대비 DB 보존.
|
||||
|
||||
노출 트랙(첫 트랙)만 저장한다. 업그레이드(라인업 반영 재생성) 시
|
||||
같은 (song_id, track_idx) 행을 새 음원으로 교체.
|
||||
"""
|
||||
|
||||
__tablename__ = "song_audio"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("song_id", "track_idx", name="uq_song_audio_track"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
song_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("songs.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
track_idx: Mapped[int] = mapped_column(Integer, default=0)
|
||||
mime: Mapped[str] = mapped_column(String, default="audio/mpeg")
|
||||
size: Mapped[int] = mapped_column(Integer, default=0)
|
||||
data: Mapped[bytes] = mapped_column(LargeBinary)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
|
||||
class DataCache(Base):
|
||||
"""야구(KBO/MLB) 부가 데이터 캐시 — 프리뷰·순위, API 응답·AI 프롬프트 조립용.
|
||||
|
||||
key 규칙:
|
||||
preview:{match_id} 경기 프리뷰 요약(선발투수·시즌 상대전적)
|
||||
standings:{league} 리그 순위표 (팀코드 → 순위·승률·최근5 등)
|
||||
"""
|
||||
|
||||
__tablename__ = "data_cache"
|
||||
|
||||
key: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
payload: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
fetched_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
58
backend/app/regenerate_ai.py
Normal file
@ -0,0 +1,58 @@
|
||||
"""전체 AI 예측 재생성 — 수동 트리거.
|
||||
|
||||
워커의 generate_ai_predictions() 를 즉시 1회 실행한다(매일 00:05 자동 생성과 동일 로직).
|
||||
미종료(결과 미입력) 경기 전부에 대해 GPT/Claude/Gemini 를 실 API 호출하여
|
||||
ai_predictions 를 덮어쓴다(source='llm'). 키 없는/실패한 모델은 건너뛴다.
|
||||
|
||||
DB 접속은 backend/.env(DB_* 또는 DATABASE_URL)를 따른다.
|
||||
|
||||
실행:
|
||||
# Docker (운영 DB로 1회 실행)
|
||||
docker compose run --rm worker python -m app.regenerate_ai
|
||||
# 로컬
|
||||
cd backend && python -m app.regenerate_ai
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from .database import SessionLocal, init_db
|
||||
from .models import AIPrediction, Match
|
||||
from .worker import generate_ai_predictions
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
log = logging.getLogger("triplepick.regen")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
await init_db() # 테이블 보장(idempotent)
|
||||
|
||||
async with SessionLocal() as db:
|
||||
targets = (
|
||||
await db.execute(
|
||||
select(func.count())
|
||||
.select_from(Match)
|
||||
.where(Match.result_outcome.is_(None))
|
||||
)
|
||||
).scalar_one()
|
||||
log.info("재생성 대상(미종료) 경기: %d", targets)
|
||||
|
||||
# 수동 트리거 = 전부 강제 재생성(덮어쓰기). 프롬프트 수정 후 갱신 등에 사용.
|
||||
await generate_ai_predictions(only_missing=False)
|
||||
|
||||
async with SessionLocal() as db:
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(AIPrediction.model, func.count())
|
||||
.where(AIPrediction.source == "llm")
|
||||
.group_by(AIPrediction.model)
|
||||
)
|
||||
).all()
|
||||
log.info("재생성 완료. 모델별 LLM 예측 수: %s", {m: c for m, c in rows})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
0
backend/app/routers/__init__.py
Normal file
203
backend/app/routers/admin.py
Normal file
@ -0,0 +1,203 @@
|
||||
"""관리자 API — Bearer 토큰 인증. 경기/AI예측 upsert + 결과 입력(채점)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from sqlalchemy import func, 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, CrowdStats, Match, PageVisit, UserPrediction
|
||||
from ..schemas import AdminAIPredictionIn, AdminSetResultIn, DailyVisitOut, 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"},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/visits", response_model=list[DailyVisitOut], dependencies=[Depends(require_admin)]
|
||||
)
|
||||
async def daily_visits(db: AsyncSession = Depends(get_db)) -> list[DailyVisitOut]:
|
||||
"""일별 순수 방문자 수 (최신순)."""
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(PageVisit.visit_date, func.count())
|
||||
.group_by(PageVisit.visit_date)
|
||||
.order_by(PageVisit.visit_date.desc())
|
||||
)
|
||||
).all()
|
||||
return [DailyVisitOut(date=d.isoformat(), uniqueVisitors=c) for d, c in rows]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/recount-crowd", response_model=GenericOk, dependencies=[Depends(require_admin)]
|
||||
)
|
||||
async def recount_crowd(db: AsyncSession = Depends(get_db)) -> GenericOk:
|
||||
"""crowd_stats 를 user_predictions(진실 원천)에서 재집계.
|
||||
|
||||
과거 같은 outcome 재제출로 팀 컬럼만 부풀려진(=합 100% 초과) 행을 복구한다.
|
||||
"""
|
||||
# 경기별 outcome 분포 집계
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(
|
||||
UserPrediction.match_id,
|
||||
UserPrediction.outcome,
|
||||
func.count(),
|
||||
).group_by(UserPrediction.match_id, UserPrediction.outcome)
|
||||
)
|
||||
).all()
|
||||
|
||||
tally: dict[str, dict[str, int]] = {}
|
||||
for match_id, outcome, c in rows:
|
||||
t = tally.setdefault(
|
||||
match_id, {"total": 0, "team_a_win": 0, "draw": 0, "team_b_win": 0}
|
||||
)
|
||||
col = {"TEAM_A_WIN": "team_a_win", "DRAW": "draw", "TEAM_B_WIN": "team_b_win"}[outcome]
|
||||
t[col] += c
|
||||
t["total"] += c
|
||||
|
||||
stats = (await db.execute(select(CrowdStats))).scalars().all()
|
||||
fixed = 0
|
||||
for s in stats:
|
||||
t = tally.get(s.match_id, {"total": 0, "team_a_win": 0, "draw": 0, "team_b_win": 0})
|
||||
if (
|
||||
s.total != t["total"]
|
||||
or s.team_a_win != t["team_a_win"]
|
||||
or s.draw != t["draw"]
|
||||
or s.team_b_win != t["team_b_win"]
|
||||
):
|
||||
s.total = t["total"]
|
||||
s.team_a_win = t["team_a_win"]
|
||||
s.draw = t["draw"]
|
||||
s.team_b_win = t["team_b_win"]
|
||||
fixed += 1
|
||||
await db.commit()
|
||||
return GenericOk(ok=True, detail="crowd recounted", extra={"matchesFixed": fixed})
|
||||
|
||||
|
||||
# ── 운영 대시보드용 읽기 API (개인 관리용 — 이메일 원본 노출 주의) ──
|
||||
@router.get("/matches/{match_id}/votes", dependencies=[Depends(require_admin)])
|
||||
async def match_votes(
|
||||
match_id: str, db: AsyncSession = Depends(get_db)
|
||||
) -> list[dict]:
|
||||
"""경기별 투표 전체 — 누가(이메일 원본) 어떻게 찍었고 몇 점 받았는지."""
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(UserPrediction)
|
||||
.where(UserPrediction.match_id == match_id)
|
||||
.order_by(UserPrediction.created_at.desc())
|
||||
)
|
||||
).scalars().all()
|
||||
return [
|
||||
{
|
||||
"email": r.email,
|
||||
"outcome": r.outcome,
|
||||
"scoreA": r.score_a,
|
||||
"scoreB": r.score_b,
|
||||
"points": r.points,
|
||||
"notify": r.notify,
|
||||
"createdAt": r.created_at.isoformat() if r.created_at else None,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/leaderboard", dependencies=[Depends(require_admin)])
|
||||
async def full_leaderboard(
|
||||
league: str = "", db: AsyncSession = Depends(get_db)
|
||||
) -> list[dict]:
|
||||
"""이메일 원본 랭킹 — 공개 리더보드와 같은 집계(채점된 픽 재집계).
|
||||
배제 대상(운영진 도메인)도 표시하되 excluded 플래그로 구분."""
|
||||
from ..scoring import is_excluded
|
||||
|
||||
q = (
|
||||
select(UserPrediction, Match)
|
||||
.join(Match, Match.match_id == UserPrediction.match_id)
|
||||
.where(
|
||||
Match.result_outcome.is_not(None),
|
||||
UserPrediction.points.is_not(None),
|
||||
UserPrediction.email.is_not(None),
|
||||
)
|
||||
)
|
||||
if league:
|
||||
q = q.where(Match.league == league)
|
||||
picks = (await db.execute(q)).all()
|
||||
agg: dict[str, list[int]] = {}
|
||||
for pk, m in picks:
|
||||
row = agg.setdefault(pk.email, [0, 0, 0])
|
||||
row[0] += pk.points or 0
|
||||
row[1] += 1 if (
|
||||
pk.score_a == m.result_score_a and pk.score_b == m.result_score_b
|
||||
) else 0
|
||||
row[2] += 1
|
||||
ranked = sorted(agg.items(), key=lambda kv: (-kv[1][0], -kv[1][1], -kv[1][2]))
|
||||
return [
|
||||
{
|
||||
"rank": i + 1,
|
||||
"email": e,
|
||||
"totalPoints": v[0],
|
||||
"exactCount": v[1],
|
||||
"matchesPlayed": v[2],
|
||||
"excluded": is_excluded(e),
|
||||
}
|
||||
for i, (e, v) in enumerate(ranked)
|
||||
]
|
||||
76
backend/app/routers/auth.py
Normal file
@ -0,0 +1,76 @@
|
||||
"""인증 API — 카카오 로그인·토큰 갱신·로그아웃·내 정보 (castad 이식).
|
||||
|
||||
콜백은 백엔드가 받아 JWT 발급 후 프론트로 `#access_token=..&refresh_token=..`
|
||||
해시 프래그먼트로 리다이렉트한다 (쿼리스트링이 아니라 해시 — 서버 로그·Referer 에
|
||||
토큰이 남지 않게 castad 방식에서 한 단계 보강).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, Request, status
|
||||
from fastapi.responses import RedirectResponse, Response
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..config import settings
|
||||
from ..database import get_db
|
||||
from ..models import User
|
||||
from ..services import auth_kakao
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
class RefreshIn(BaseModel):
|
||||
refreshToken: str
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str | None:
|
||||
fwd = request.headers.get("X-Forwarded-For")
|
||||
if fwd:
|
||||
return fwd.split(",")[0].strip()
|
||||
return request.client.host if request.client else None
|
||||
|
||||
|
||||
@router.get("/kakao/login")
|
||||
async def kakao_login_url() -> dict:
|
||||
"""카카오 인증 페이지 URL — 프론트가 이 URL 로 이동시킨다."""
|
||||
return {"authUrl": auth_kakao.kakao_authorization_url()}
|
||||
|
||||
|
||||
@router.get("/kakao/callback")
|
||||
async def kakao_callback(
|
||||
request: Request,
|
||||
code: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user_agent: Optional[str] = Header(None, alias="User-Agent"),
|
||||
) -> RedirectResponse:
|
||||
"""카카오 콜백 — 인가 코드로 JWT 발급 후 프론트로 리다이렉트."""
|
||||
result = await auth_kakao.kakao_login(
|
||||
db, code, user_agent=user_agent, ip_address=_client_ip(request)
|
||||
)
|
||||
redirect = (
|
||||
f"{settings.public_origin}/"
|
||||
f"#access_token={result['access_token']}"
|
||||
f"&refresh_token={result['refresh_token']}"
|
||||
)
|
||||
return RedirectResponse(url=redirect, status_code=302)
|
||||
|
||||
|
||||
@router.post("/refresh")
|
||||
async def refresh(body: RefreshIn, db: AsyncSession = Depends(get_db)) -> dict:
|
||||
"""리프레시 토큰 회전 — 새 access/refresh 발급, 기존 refresh 폐기."""
|
||||
return await auth_kakao.refresh_tokens(db, body.refreshToken)
|
||||
|
||||
|
||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def logout(body: RefreshIn, db: AsyncSession = Depends(get_db)) -> Response:
|
||||
"""리프레시 토큰 폐기. access 만료 후엔 재갱신 불가."""
|
||||
await auth_kakao.logout(db, body.refreshToken)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def me(user: User = Depends(auth_kakao.get_current_user)) -> dict:
|
||||
"""현재 로그인 사용자 정보."""
|
||||
return auth_kakao.user_out(user)
|
||||
166
backend/app/routers/comments.py
Normal file
@ -0,0 +1,166 @@
|
||||
"""경기별 한마디(댓글) API — 완전 익명. 조회(최신순·페이징) · 작성(검증·쿨다운).
|
||||
|
||||
신원/인증 없음:
|
||||
- author_hash = sha256(device_id). 기기 원본 ID는 저장하지 않음(추적 불가). 쿨다운 전용.
|
||||
- nickname: 축구+코믹 한국어 5글자. **세션 단위 발급** — 클라가 sessionStorage 에 캐싱해
|
||||
한 세션 동안 같은 닉을 재사용, 탭/창을 닫았다 켜거나 시크릿이면 새로 발급.
|
||||
발급은 서버 단어풀에서만(욕설/사칭 방지) — 작성 시에도 풀 검증.
|
||||
- id(PK)·author_hash 는 내부용으로 API 응답에 노출하지 않음.
|
||||
- 방어(기본): body 길이 제한(스키마 200자) · 같은 기기 연속 작성 쿨다운 · is_hidden 제외.
|
||||
- 테이블은 범용(모든 경기)이고, 노출 경기 제한은 프론트가 담당.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import random
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import Comment, Match
|
||||
from ..schemas import CommentIn, CommentListOut, CommentNicknameOut, CommentOut
|
||||
|
||||
router = APIRouter(prefix="/api/matches", tags=["comments"])
|
||||
|
||||
# 같은 기기 연속 작성 최소 간격(초) — 도배 방지(기본 방어).
|
||||
COMMENT_COOLDOWN_SECONDS = 5
|
||||
|
||||
# 닉네임 조합: 2글자(코믹 수식) + 3글자(스포츠 역할) = 항상 한국어 5글자.
|
||||
# 리그별 풀 — 축구(월드컵) / 야구(KBO·MLB). 각 40 × 30 = 1200가지.
|
||||
_NICK_A = [
|
||||
"잔디", "침대", "벤치", "후보", "똥손", "헛발", "발컨", "노룩", "왼발", "멘붕",
|
||||
"국대", "동네", "주말", "폭발", "광속", "진지", "발끝", "번개", "백수", "천재",
|
||||
"야수", "괴물", "폭격", "강철", "무적", "질풍", "돌풍", "불꽃", "발광", "분노",
|
||||
"음속", "폭탄", "슈퍼", "만년", "전설", "비밀", "미친", "라면", "치킨", "출근",
|
||||
]
|
||||
_NICK_B = [
|
||||
"드리블", "골사냥", "해결사", "종결자", "자판기", "수비수", "골키퍼", "패스왕",
|
||||
"헤더왕", "골게터", "오버랩", "프리킥", "발재간", "삽질러", "똥볼러", "돌파왕",
|
||||
"압박왕", "태클왕", "중거리", "발리슛", "빌드업", "백패스", "자책골", "골기계",
|
||||
"어시왕", "역습왕", "수문장", "골부자", "골가뭄", "발연기",
|
||||
]
|
||||
_NICK_A_BB = [
|
||||
"잔디", "벤치", "후보", "똥손", "노룩", "멘붕", "동네", "주말", "폭발", "광속",
|
||||
"진지", "번개", "백수", "천재", "괴물", "폭격", "강철", "무적", "질풍", "돌풍",
|
||||
"불꽃", "분노", "음속", "폭탄", "슈퍼", "만년", "전설", "비밀", "미친", "라면",
|
||||
"치킨", "출근", "직관", "치맥", "응원", "국대", "신인", "은퇴", "각성", "꾸준",
|
||||
]
|
||||
_NICK_B_BB = [
|
||||
"홈런왕", "도루왕", "타격왕", "안타왕", "삼진왕", "수비왕", "번트왕", "대타왕",
|
||||
"역전타", "끝내기", "결승타", "병살타", "유격수", "외야수", "내야수", "마무리",
|
||||
"셋업맨", "불펜왕", "승리조", "강속구", "커브왕", "직구왕", "변화구", "풀스윙",
|
||||
"헛스윙", "배트맨", "만루왕", "출루왕", "도루자", "홈스틸",
|
||||
]
|
||||
|
||||
|
||||
def _author_hash(device_id: str) -> str:
|
||||
"""기기 원본 ID는 저장하지 않고 해시만 사용(추적 불가)."""
|
||||
return hashlib.sha256(device_id.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
# 검증은 두 리그 풀 합집합 기준(발급 리그와 작성 리그가 달라도 정상 닉이면 통과)
|
||||
_NICK_A_SET = set(_NICK_A) | set(_NICK_A_BB)
|
||||
_NICK_B_SET = set(_NICK_B) | set(_NICK_B_BB)
|
||||
|
||||
|
||||
def _is_baseball(match_id: str) -> bool:
|
||||
return match_id.startswith(("KBO_", "MLB_"))
|
||||
|
||||
|
||||
def _random_nickname(baseball: bool = False) -> str:
|
||||
"""랜덤 닉네임. 두 단어 조합으로 항상 5글자 — 리그에 맞는 풀 사용."""
|
||||
if baseball:
|
||||
return random.choice(_NICK_A_BB) + random.choice(_NICK_B_BB)
|
||||
return random.choice(_NICK_A) + random.choice(_NICK_B)
|
||||
|
||||
|
||||
def _valid_nickname(n: str) -> bool:
|
||||
"""서버 단어풀에서 나온 정상 닉인지 검증(앞2 + 뒤3)."""
|
||||
n = (n or "").strip()
|
||||
return len(n) == 5 and n[:2] in _NICK_A_SET and n[2:] in _NICK_B_SET
|
||||
|
||||
|
||||
@router.get("/{match_id}/comments/nickname", response_model=CommentNicknameOut)
|
||||
async def issue_nickname(match_id: str) -> CommentNicknameOut:
|
||||
"""세션 시작 시 랜덤 닉 발급(클라가 sessionStorage 에 캐싱해 재사용)."""
|
||||
return CommentNicknameOut(nickname=_random_nickname(_is_baseball(match_id)))
|
||||
|
||||
|
||||
def _out(c: Comment) -> CommentOut:
|
||||
return CommentOut(
|
||||
nickname=c.nickname,
|
||||
body=c.body,
|
||||
createdAt=c.created_at.isoformat(),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{match_id}/comments", response_model=CommentListOut)
|
||||
async def list_comments(
|
||||
match_id: str,
|
||||
limit: int = Query(3, ge=1, le=50),
|
||||
offset: int = Query(0, ge=0),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> CommentListOut:
|
||||
base = (
|
||||
select(Comment)
|
||||
.where(Comment.match_id == match_id, Comment.is_hidden.is_(False))
|
||||
)
|
||||
total = (
|
||||
await db.execute(
|
||||
select(func.count()).select_from(base.subquery())
|
||||
)
|
||||
).scalar() or 0
|
||||
rows = (
|
||||
await db.execute(
|
||||
base.order_by(Comment.created_at.desc(), Comment.id.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
).scalars().all()
|
||||
return CommentListOut(items=[_out(c) for c in rows], total=total)
|
||||
|
||||
|
||||
@router.post("/{match_id}/comments", response_model=CommentOut)
|
||||
async def create_comment(
|
||||
match_id: str, body: CommentIn, db: AsyncSession = Depends(get_db)
|
||||
) -> CommentOut:
|
||||
match = (
|
||||
await db.execute(select(Match).where(Match.match_id == match_id))
|
||||
).scalars().first()
|
||||
if not match:
|
||||
raise HTTPException(status_code=404, detail="MATCH_NOT_FOUND")
|
||||
|
||||
author_hash = _author_hash(body.deviceId)
|
||||
|
||||
# 쿨다운: 같은 기기(해시)의 가장 최근 작성과의 간격 확인
|
||||
last_at = (
|
||||
await db.execute(
|
||||
select(func.max(Comment.created_at)).where(
|
||||
Comment.author_hash == author_hash
|
||||
)
|
||||
)
|
||||
).scalar()
|
||||
if last_at is not None:
|
||||
elapsed = datetime.now(timezone.utc) - last_at
|
||||
if elapsed < timedelta(seconds=COMMENT_COOLDOWN_SECONDS):
|
||||
raise HTTPException(status_code=429, detail="COMMENT_COOLDOWN")
|
||||
|
||||
# 세션 캐싱된 닉을 사용하되, 풀에 없는(조작된) 값이면 새 랜덤으로 대체
|
||||
nickname = (
|
||||
body.nickname
|
||||
if _valid_nickname(body.nickname)
|
||||
else _random_nickname(_is_baseball(match_id))
|
||||
)
|
||||
comment = Comment(
|
||||
match_id=match_id,
|
||||
author_hash=author_hash,
|
||||
nickname=nickname,
|
||||
body=body.body,
|
||||
)
|
||||
db.add(comment)
|
||||
await db.commit()
|
||||
await db.refresh(comment)
|
||||
return _out(comment)
|
||||
165
backend/app/routers/leaderboard.py
Normal file
@ -0,0 +1,165 @@
|
||||
"""리더보드 API — 누적 포인트 1위 (docs/SCORING.md §3 랭킹 규칙).
|
||||
|
||||
user_points 테이블(채점 시 누적 갱신) 기준 랭킹.
|
||||
1차 정렬: 누적 포인트. 동점: 정확스코어 횟수 → 적중률 → 참여수 → 최초도달.
|
||||
주최측/임직원 배제(P1). 이메일은 마스킹하여 노출.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import Match, UserPoints, UserPrediction
|
||||
from ..scoring import is_excluded, score_prediction
|
||||
from ..schemas import (
|
||||
AILeaderboardOut,
|
||||
AIStandingOut,
|
||||
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),
|
||||
league: str = Query("", description="wc | kbo | mlb — 빈값이면 전체 합산"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> LeaderboardOut:
|
||||
if league:
|
||||
# 리그별 랭킹 — 채점된 픽(points)을 리그 경기로 한정해 이메일별 재집계.
|
||||
picks = (
|
||||
await db.execute(
|
||||
select(UserPrediction, Match)
|
||||
.join(Match, Match.match_id == UserPrediction.match_id)
|
||||
.where(
|
||||
Match.league == league,
|
||||
Match.result_outcome.is_not(None),
|
||||
UserPrediction.points.is_not(None),
|
||||
UserPrediction.email.is_not(None),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
agg: dict[str, list] = {} # email → [pts, exact, played, first_ts]
|
||||
for pk, m in picks:
|
||||
row = agg.setdefault(pk.email, [0, 0, 0, None])
|
||||
row[0] += pk.points or 0
|
||||
row[1] += 1 if (
|
||||
pk.score_a == m.result_score_a and pk.score_b == m.result_score_b
|
||||
) else 0
|
||||
row[2] += 1
|
||||
ranked_rows = sorted(
|
||||
((e, v) for e, v in agg.items() if not is_excluded(e)),
|
||||
key=lambda kv: (-kv[1][0], -kv[1][1], -kv[1][2]),
|
||||
)
|
||||
scored_matches = (
|
||||
await db.execute(
|
||||
select(func.count()).select_from(Match).where(
|
||||
Match.league == league, Match.result_outcome.is_not(None)
|
||||
)
|
||||
)
|
||||
).scalar() or 0
|
||||
standings = [
|
||||
StandingOut(
|
||||
rank=i + 1, emailMasked=_mask(e),
|
||||
totalPoints=v[0], exactCount=v[1], matchesPlayed=v[2],
|
||||
)
|
||||
for i, (e, v) in enumerate(ranked_rows[:limit])
|
||||
]
|
||||
return LeaderboardOut(standings=standings, scoredMatches=scored_matches)
|
||||
|
||||
rows = (
|
||||
await db.execute(select(UserPoints))
|
||||
).scalars().all()
|
||||
|
||||
ranked = sorted(
|
||||
(r for r in rows if not is_excluded(r.email)),
|
||||
key=lambda r: (
|
||||
-r.total_points,
|
||||
-r.exact_count,
|
||||
-(r.total_points / r.matches_played if r.matches_played else 0),
|
||||
-r.matches_played,
|
||||
r.first_scored_at.timestamp() if r.first_scored_at else 0,
|
||||
),
|
||||
)
|
||||
|
||||
scored_matches = (
|
||||
await db.execute(
|
||||
select(func.count())
|
||||
.select_from(Match)
|
||||
.where(Match.result_outcome.is_not(None))
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
standings = [
|
||||
StandingOut(
|
||||
rank=i + 1,
|
||||
emailMasked=_mask(r.email),
|
||||
totalPoints=r.total_points,
|
||||
exactCount=r.exact_count,
|
||||
matchesPlayed=r.matches_played,
|
||||
)
|
||||
for i, r in enumerate(ranked[:limit])
|
||||
]
|
||||
return LeaderboardOut(standings=standings, scoredMatches=scored_matches)
|
||||
|
||||
|
||||
@router.get("/ai", response_model=AILeaderboardOut)
|
||||
async def ai_leaderboard(
|
||||
league: str = Query("", description="wc | kbo | mlb — 빈값이면 전체"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> AILeaderboardOut:
|
||||
"""AI 모델 누적 랭킹 — 종료된 경기의 AI 예측을 유저와 동일한 배점으로 채점·합산.
|
||||
|
||||
별도 누적 테이블 없이 매 조회 시 종료 경기 전수 재계산 → 결과/배점 변동에
|
||||
항상 일관. (모델 3개 × 경기 수라 비용 무시 가능.)
|
||||
"""
|
||||
q = (
|
||||
select(Match)
|
||||
.where(Match.result_outcome.is_not(None))
|
||||
.options(selectinload(Match.predictions))
|
||||
)
|
||||
if league:
|
||||
q = q.where(Match.league == league)
|
||||
matches = (await db.execute(q)).scalars().all()
|
||||
|
||||
# model → [points, exact_count, matches_played]
|
||||
agg: dict[str, list[int]] = {}
|
||||
for m in matches:
|
||||
bb = m.league in ("kbo", "mlb")
|
||||
for p in m.predictions:
|
||||
pts = score_prediction(
|
||||
p.score_a, p.score_b, m.result_score_a, m.result_score_b, baseball=bb
|
||||
)
|
||||
row = agg.setdefault(p.model, [0, 0, 0])
|
||||
row[0] += pts
|
||||
row[1] += 1 if (p.score_a == m.result_score_a and p.score_b == m.result_score_b) else 0
|
||||
row[2] += 1
|
||||
|
||||
order = {"GPT": 0, "Claude": 1, "Gemini": 2}
|
||||
ranked = sorted(
|
||||
agg.items(),
|
||||
key=lambda kv: (-kv[1][0], -kv[1][1], -kv[1][2], order.get(kv[0], 9)),
|
||||
)
|
||||
|
||||
standings = [
|
||||
AIStandingOut(
|
||||
rank=i + 1,
|
||||
model=model, # type: ignore[arg-type]
|
||||
totalPoints=pts,
|
||||
exactCount=exact,
|
||||
matchesPlayed=played,
|
||||
)
|
||||
for i, (model, (pts, exact, played)) in enumerate(ranked)
|
||||
]
|
||||
return AILeaderboardOut(standings=standings, scoredMatches=len(matches))
|
||||
70
backend/app/routers/matches.py
Normal file
@ -0,0 +1,70 @@
|
||||
"""공개 읽기 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
|
||||
from ..services.baseball_details import fetch_live, get_extras
|
||||
|
||||
router = APIRouter(prefix="/api/matches", tags=["matches"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[MatchOut])
|
||||
async def list_matches(
|
||||
lang: str = Query("ko"),
|
||||
league: str = Query("", description="wc | kbo | mlb — 빈값이면 전체"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[MatchOut]:
|
||||
q = (
|
||||
select(Match)
|
||||
.options(selectinload(Match.predictions), selectinload(Match.crowd))
|
||||
.order_by(Match.kickoff_at)
|
||||
)
|
||||
if league:
|
||||
q = q.where(Match.league == league)
|
||||
rows = (await db.execute(q)).scalars().all()
|
||||
extras = await get_extras(db, rows)
|
||||
return [match_out(m, lang, extras=extras.get(m.match_id)) for m in rows]
|
||||
|
||||
|
||||
@router.get("/{match_id}", response_model=MatchOut)
|
||||
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")
|
||||
extras = await get_extras(db, [m])
|
||||
return match_out(m, lang, extras=extras.get(m.match_id))
|
||||
|
||||
|
||||
@router.get("/{match_id}/live")
|
||||
async def get_live(
|
||||
match_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""라이브 뷰 (kbo=네이버 relay, mlb=공식 feed/live, mls=ESPN). 15초 TTL 캐시."""
|
||||
m = await db.get(Match, match_id)
|
||||
if not m:
|
||||
raise HTTPException(status_code=404, detail="MATCH_NOT_FOUND")
|
||||
if m.league == "mls":
|
||||
from ..services.mls_espn import fetch_live_mls
|
||||
|
||||
return await fetch_live_mls(m)
|
||||
if m.league not in ("kbo", "mlb"):
|
||||
return {"available": False}
|
||||
return await fetch_live(m)
|
||||
169
backend/app/routers/predictions.py
Normal file
@ -0,0 +1,169 @@
|
||||
"""픽 제출 API — 검증 · 중복방지(1회 수정) · crowd 원자적 증분 · 매칭 모델 계산."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from ..database import get_db
|
||||
from ..domain import (
|
||||
compute_phase,
|
||||
crowd_out,
|
||||
is_open_for_voting,
|
||||
is_votable,
|
||||
my_prediction_out,
|
||||
)
|
||||
from ..models import AIPrediction, CrowdStats, Match, UserPrediction
|
||||
from ..scoring import outcome_of
|
||||
from ..schemas import MyPredictionOut, 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).
|
||||
|
||||
같은 outcome 재제출(add == remove)은 분포 변화가 없으므로 no-op.
|
||||
이를 보정하지 않으면 팀 컬럼만 +1 되어 a+draw+b > total → 합 100% 초과.
|
||||
"""
|
||||
if add == remove: # 결과 변동 없음(스코어만 수정 등) → 분포 그대로
|
||||
return
|
||||
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:
|
||||
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.get("/mine", response_model=list[MyPredictionOut])
|
||||
async def my_predictions(
|
||||
email: str, db: AsyncSession = Depends(get_db)
|
||||
) -> list[MyPredictionOut]:
|
||||
"""이메일 기준 내 지난 예측 목록 (최신 제출순). 로그인 없음 — 이메일이 신원."""
|
||||
e = email.strip().lower()
|
||||
if not e:
|
||||
return []
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(UserPrediction, Match)
|
||||
.join(Match, Match.match_id == UserPrediction.match_id)
|
||||
.where(func.lower(UserPrediction.email) == e)
|
||||
.order_by(UserPrediction.updated_at.desc())
|
||||
)
|
||||
).all()
|
||||
return [my_prediction_out(up, m) for up, m in rows]
|
||||
|
||||
|
||||
@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_votable(match):
|
||||
raise HTTPException(status_code=403, detail="MATCH_NOT_VOTABLE")
|
||||
if not is_open_for_voting(match):
|
||||
# 종료 사유 정밀 구분: 종료 / 경기중 / 투표종료 / 오픈전
|
||||
phase = compute_phase(match)
|
||||
detail = {
|
||||
"finished": "MATCH_FINISHED", # 결과 입력됨
|
||||
"live": "MATCH_LIVE", # 경기중
|
||||
"locked": "MATCH_LOCKED", # 투표 종료(킥오프 1h 전)
|
||||
"scheduled": "MATCH_NOT_OPEN", # 아직 오픈 전
|
||||
}.get(phase, "MATCH_LOCKED")
|
||||
code = 409 if phase == "finished" else 423
|
||||
raise HTTPException(status_code=code, detail=detail)
|
||||
|
||||
# outcome 은 스코어에서 도출 (입력과 불일치해도 스코어 기준으로 정규화)
|
||||
outcome = outcome_of(body.scoreA, body.scoreB)
|
||||
email = str(body.email).strip().lower() if body.email else None
|
||||
|
||||
# 중복 식별: ① 이메일(있으면 1차 신원 — 다른 기기여도 동일인) ② 기기(deviceId)
|
||||
existing = None
|
||||
if email:
|
||||
existing = (
|
||||
await db.execute(
|
||||
select(UserPrediction).where(
|
||||
UserPrediction.match_id == body.matchId,
|
||||
func.lower(UserPrediction.email) == email,
|
||||
)
|
||||
)
|
||||
).scalars().first()
|
||||
if existing is None:
|
||||
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
|
||||
existing.device_id = body.deviceId # 최신 제출 기기로 갱신
|
||||
if email:
|
||||
existing.email = 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=email,
|
||||
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),
|
||||
)
|
||||
191
backend/app/routers/share.py
Normal file
@ -0,0 +1,191 @@
|
||||
"""공유 미리보기(OG) 프리렌더 — 카카오톡·트위터 등 크롤러 전용.
|
||||
|
||||
SPA(index.html)의 og:image 는 정적이라 모든 /match/:id 공유가 같은 썸네일로
|
||||
나온다(크롤러는 JS 미실행). 그래서 nginx 가 '크롤러 UA' 의 /match/:id 요청만
|
||||
이 라우트로 보내고, 여기서 경기별 og:image·og:title 을 박은 HTML 을 반환한다.
|
||||
일반 사용자는 nginx 가 그대로 SPA 로 보내므로 영향 없음.
|
||||
|
||||
경기별 커스텀 이미지는 OG_IMAGES 에 등록된 매치업만 적용되고, 나머지는
|
||||
기본 썸네일로 폴백한다. 이미지 파일은 frontend/public/assets/og/ 에 둔다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..config import settings
|
||||
from ..database import get_db
|
||||
from ..models import Match, Song
|
||||
from ..schedule_data import TEAMS
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# 경기별 커스텀 OG 이미지: 두 팀 코드(순서무관) → /assets/og/ 하위 파일명.
|
||||
# 파일을 frontend/public/assets/og/ 에 두고 아래에 등록하면 적용된다.
|
||||
# 미등록 매치업은 DEFAULT_OG 로 폴백.
|
||||
# 예) frozenset({"KOR", "MEX"}): "kor_mex.png",
|
||||
OG_IMAGES: dict[frozenset[str], str] = {
|
||||
# 한국-멕시코전 공유 카드 — 가로 1200x630 합성본(앱 배너는 세로 원본 kor_mex.png).
|
||||
frozenset({"KOR", "MEX"}): "kor_mex_card.png",
|
||||
}
|
||||
|
||||
DEFAULT_OG = "/assets/bi/og-image.png"
|
||||
OG_DIR = "/assets/og/"
|
||||
|
||||
# 전체 일정(타조 포함) 팀 한글명 — 타이틀용. schedule_data.TEAMS 우선, 없으면 코드.
|
||||
_KOR_NAME = {code: t["shortName"] for code, t in TEAMS.items()}
|
||||
|
||||
|
||||
def _parse_codes(match_id: str) -> tuple[str | None, str | None]:
|
||||
"""경기 ID '{조}_{팀A}_{팀B}_{YYYYMMDD}' 에서 두 팀 코드 추출."""
|
||||
parts = match_id.split("_")
|
||||
if len(parts) >= 3:
|
||||
return parts[1], parts[2]
|
||||
return None, None
|
||||
|
||||
|
||||
def _team_label(code: str | None) -> str:
|
||||
if not code:
|
||||
return ""
|
||||
return _KOR_NAME.get(code, code)
|
||||
|
||||
|
||||
@router.get("/song/{match_id}/{team_code}", response_class=HTMLResponse)
|
||||
async def song_share(
|
||||
match_id: str, team_code: str, db: AsyncSession = Depends(get_db)
|
||||
) -> HTMLResponse:
|
||||
"""응원가 공유 OG — '8월 28일 OO 응원가' 제목 + Suno 앨범아트 썸네일.
|
||||
|
||||
제목의 날짜는 제작일(경기일 KST) — 매일 갱신되는 콘텐츠임을 드러낸다.
|
||||
"""
|
||||
s = (
|
||||
await db.execute(
|
||||
select(Song).where(Song.match_id == match_id, Song.team_code == team_code)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
team = (s.team_name if s else "") or _team_label(team_code) or team_code
|
||||
song_title = (s.title if s else "") or f"{team} 응원가"
|
||||
day = f"{s.date_kst.month}월 {s.date_kst.day}일" if s and s.date_kst else "오늘의"
|
||||
title = f"{day} {team} 응원가 — {song_title}"
|
||||
desc = (
|
||||
"AI가 경기 당일(순위·선발 라인업·전날 결과)을 반영해 매일 새로 만드는 응원가. "
|
||||
"듣고 나서 GPT·Claude·Gemini와 승부예측도 겨뤄보세요!"
|
||||
)
|
||||
|
||||
origin = settings.public_origin.rstrip("/")
|
||||
# 썸네일: 합성 커버(Suno 아트 + 팀 로고, 1200x630) → 곡 없으면 기본 카드
|
||||
if s and s.tracks:
|
||||
img_url = (
|
||||
f"{origin}/api/songs/cover/{html.escape(match_id)}/"
|
||||
f"{html.escape(team_code)}?v={(s.task_id or '')[:8]}"
|
||||
)
|
||||
else:
|
||||
img_url = f"{origin}{DEFAULT_OG}?v=2"
|
||||
|
||||
page_url = f"{origin}/song/{html.escape(match_id)}/{html.escape(team_code)}"
|
||||
t = html.escape(title)
|
||||
d = html.escape(desc)
|
||||
page = f"""<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>{t}</title>
|
||||
<meta name="description" content="{d}" />
|
||||
<meta property="og:type" content="music.song" />
|
||||
<meta property="og:site_name" content="TriplePick" />
|
||||
<meta property="og:title" content="{t}" />
|
||||
<meta property="og:description" content="{d}" />
|
||||
<meta property="og:url" content="{page_url}" />
|
||||
<meta property="og:image" content="{img_url}" />
|
||||
<meta property="og:image:secure_url" content="{img_url}" />
|
||||
<meta property="og:locale" content="ko_KR" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="{t}" />
|
||||
<meta name="twitter:description" content="{d}" />
|
||||
<meta name="twitter:image" content="{img_url}" />
|
||||
<link rel="canonical" href="{page_url}" />
|
||||
</head>
|
||||
<body><a href="{page_url}">TriplePick — {t}</a></body>
|
||||
</html>"""
|
||||
return HTMLResponse(page)
|
||||
|
||||
|
||||
@router.get("/match/{match_id}", response_class=HTMLResponse)
|
||||
async def match_share(match_id: str, db: AsyncSession = Depends(get_db)) -> HTMLResponse:
|
||||
a, b = _parse_codes(match_id)
|
||||
|
||||
image = DEFAULT_OG
|
||||
is_custom = False
|
||||
if a and b:
|
||||
key = frozenset({a, b})
|
||||
if key in OG_IMAGES:
|
||||
image = OG_DIR + OG_IMAGES[key]
|
||||
is_custom = True
|
||||
|
||||
# 팀명: DB 의 경기 행이 있으면 한글 short 명(클럽 리그 포함),
|
||||
# 없으면 월드컵 정적 테이블 → 코드 순으로 폴백.
|
||||
m = (
|
||||
await db.execute(select(Match).where(Match.match_id == match_id))
|
||||
).scalar_one_or_none()
|
||||
if m:
|
||||
la, lb = m.team_a_short, m.team_b_short
|
||||
a, b = m.team_a_code, m.team_b_code
|
||||
else:
|
||||
la, lb = _team_label(a), _team_label(b)
|
||||
# 한국은 항상 왼쪽으로 표기(프론트 화면 규칙과 동일).
|
||||
if b == "KOR" and a != "KOR":
|
||||
la, lb = lb, la
|
||||
league_ko = {"kbo": "KBO", "mlb": "MLB", "mls": "MLS"}.get(m.league if m else "", "")
|
||||
if la and lb:
|
||||
tag = f"{league_ko} " if league_ko else ""
|
||||
title = f"TriplePick — {tag}{la} vs {lb} AI 승부예측"
|
||||
else:
|
||||
title = "TriplePick — AI 스포츠 승부예측 (KBO·MLB·MLS)"
|
||||
desc = (
|
||||
"GPT·Claude·Gemini 3대 AI가 이 경기를 서로 다르게 예측합니다. "
|
||||
"당신의 픽을 찍고 AI와 겨뤄보세요."
|
||||
)
|
||||
|
||||
origin = settings.public_origin.rstrip("/")
|
||||
page_url = f"{origin}/match/{html.escape(match_id)}"
|
||||
# ?v= 캐시버스터: 메신저가 과거 실패/구버전 썸네일을 캐시한 경우 재수집 유도
|
||||
img_url = f"{origin}{image}?v=2"
|
||||
t = html.escape(title)
|
||||
d = html.escape(desc)
|
||||
# 기본 썸네일만 규격이 1200x630 으로 확정 → width/height 명시.
|
||||
# 커스텀 이미지는 규격이 제각각이라 태그를 빼고 크롤러가 직접 측정하게 둔다.
|
||||
dims = (
|
||||
""
|
||||
if is_custom
|
||||
else '<meta property="og:image:width" content="1200" />\n'
|
||||
'<meta property="og:image:height" content="630" />\n'
|
||||
)
|
||||
|
||||
page = f"""<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>{t}</title>
|
||||
<meta name="description" content="{d}" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="TriplePick" />
|
||||
<meta property="og:title" content="{t}" />
|
||||
<meta property="og:description" content="{d}" />
|
||||
<meta property="og:url" content="{page_url}" />
|
||||
<meta property="og:image" content="{img_url}" />
|
||||
<meta property="og:image:secure_url" content="{img_url}" />
|
||||
<meta property="og:image:type" content="image/png" />
|
||||
{dims}<meta property="og:locale" content="ko_KR" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="{t}" />
|
||||
<meta name="twitter:description" content="{d}" />
|
||||
<meta name="twitter:image" content="{img_url}" />
|
||||
<link rel="canonical" href="{page_url}" />
|
||||
</head>
|
||||
<body><a href="{page_url}">TriplePick</a></body>
|
||||
</html>"""
|
||||
return HTMLResponse(page)
|
||||
226
backend/app/routers/songs.py
Normal file
@ -0,0 +1,226 @@
|
||||
"""오늘의 응원가 API — 조회(공개) · 커버 합성 · Suno 콜백 싱크 · 관리자 수동 생성."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..config import settings
|
||||
from ..database import get_db
|
||||
from ..models import Match, Song, SongAudio
|
||||
from ..services.songs import persist_audio, poll_generating, start_due_songs
|
||||
from .admin import require_admin
|
||||
|
||||
router = APIRouter(prefix="/api/songs", tags=["songs"])
|
||||
|
||||
log = logging.getLogger("triplepick.songs")
|
||||
|
||||
KST = timezone(timedelta(hours=9))
|
||||
|
||||
|
||||
def _cover_path(s: Song) -> str:
|
||||
# v= 캐시버스터: 업그레이드(재생성)로 task 가 바뀌면 새 커버로
|
||||
return f"/api/songs/cover/{s.match_id}/{s.team_code}?v={(s.task_id or '')[:8]}"
|
||||
|
||||
|
||||
def _song_out(s: Song) -> dict:
|
||||
# Suno 는 생성당 2트랙을 주지만 노출은 팀당 1곡만 (첫 트랙).
|
||||
# 커버는 Suno 아트 + 팀 로고 합성본으로 교체.
|
||||
tracks = [
|
||||
{**t, "imageUrl": _cover_path(s)} for t in (s.tracks or [])[:1]
|
||||
]
|
||||
return {
|
||||
"matchId": s.match_id,
|
||||
"league": s.league,
|
||||
"teamCode": s.team_code,
|
||||
"teamName": s.team_name,
|
||||
"title": s.title,
|
||||
"lyrics": s.lyrics,
|
||||
# 제작일(경기일 KST) — 공유·플레이어 문구의 "8월 28일 OO 응원가" 표기용
|
||||
"dateKst": s.date_kst.isoformat() if s.date_kst else None,
|
||||
"tracks": tracks,
|
||||
}
|
||||
|
||||
|
||||
# ── 커버 합성 (Suno 아트 배경 + 팀 로고 중앙) ──────────────────
|
||||
_COVER_W, _COVER_H = 1200, 630 # OG 권장 규격 — 플레이어 썸네일은 중앙 크롭
|
||||
_cover_cache: dict[str, bytes] = {}
|
||||
|
||||
|
||||
async def _fetch_bytes(url: str) -> bytes | None:
|
||||
import httpx
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as c:
|
||||
r = await c.get(url)
|
||||
r.raise_for_status()
|
||||
return r.content
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning("cover 소스 다운로드 실패 %s: %s", url, e)
|
||||
return None
|
||||
|
||||
|
||||
def _logo_url(s: Song) -> str | None:
|
||||
if s.league == "kbo":
|
||||
return f"{settings.internal_frontend_base}/assets/teams/kbo/{s.team_code.lower()}.png"
|
||||
if s.league == "mlb":
|
||||
from ..teams_baseball import MLB_TEAMS
|
||||
|
||||
mlb_id = (MLB_TEAMS.get(s.team_code) or {}).get("mlb_id")
|
||||
if mlb_id:
|
||||
return f"https://midfield.mlbstatic.com/v1/team/{mlb_id}/spots/500"
|
||||
return None
|
||||
|
||||
|
||||
def _compose(logo_bytes: bytes | None) -> bytes:
|
||||
"""다크 단색 배경 + 팀 로고 최대 크기 중앙 배치 (배경 아트 없음)."""
|
||||
import io
|
||||
|
||||
from PIL import Image
|
||||
|
||||
canvas = Image.new("RGB", (_COVER_W, _COVER_H), (16, 20, 26))
|
||||
if logo_bytes:
|
||||
try:
|
||||
logo = Image.open(io.BytesIO(logo_bytes)).convert("RGBA")
|
||||
# 캔버스에 여백 8%만 남기고 최대로 채운다
|
||||
max_h = round(_COVER_H * 0.84)
|
||||
max_w = round(_COVER_W * 0.84)
|
||||
scale = min(max_w / logo.width, max_h / logo.height)
|
||||
w, h = round(logo.width * scale), round(logo.height * scale)
|
||||
logo = logo.resize((w, h))
|
||||
canvas.paste(logo, ((_COVER_W - w) // 2, (_COVER_H - h) // 2), logo)
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning("cover 로고 처리 실패: %s", e)
|
||||
out = io.BytesIO()
|
||||
canvas.save(out, "JPEG", quality=88)
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
@router.get("/cover/{match_id}/{team_code}")
|
||||
async def song_cover(
|
||||
match_id: str, team_code: str, db: AsyncSession = Depends(get_db)
|
||||
) -> Response:
|
||||
s = (
|
||||
await db.execute(
|
||||
select(Song).where(Song.match_id == match_id, Song.team_code == team_code)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not s:
|
||||
raise HTTPException(status_code=404, detail="SONG_NOT_FOUND")
|
||||
key = f"{match_id}:{team_code}"
|
||||
if key not in _cover_cache:
|
||||
if len(_cover_cache) > 200:
|
||||
_cover_cache.clear()
|
||||
logo_url = _logo_url(s)
|
||||
if not logo_url and s.league == "mls":
|
||||
# MLS 로고는 Match 행의 flag(ESPN PNG URL) 재사용
|
||||
m = await db.get(Match, s.match_id)
|
||||
if m:
|
||||
logo_url = m.team_a_flag if m.team_a_code == s.team_code else m.team_b_flag
|
||||
logo = await _fetch_bytes(logo_url) if logo_url else None
|
||||
_cover_cache[key] = _compose(logo)
|
||||
return Response(
|
||||
content=_cover_cache[key],
|
||||
media_type="image/jpeg",
|
||||
headers={"Cache-Control": "public, max-age=86400"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/today")
|
||||
async def today_songs(league: str = "", db: AsyncSession = Depends(get_db)) -> list[dict]:
|
||||
"""오늘의 응원가 목록 — league 미지정 시 전 리그(kbo+mlb)."""
|
||||
today = datetime.now(KST).date()
|
||||
# 업그레이드(라인업 반영 재생성) 중인 곡도 이전 트랙을 계속 서빙 — 재생 공백 없음
|
||||
conds = [Song.date_kst == today, Song.status.in_(("complete", "generating"))]
|
||||
if league:
|
||||
conds.append(Song.league == league)
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(Song).where(*conds).order_by(Song.match_id, Song.team_code)
|
||||
)
|
||||
).scalars().all()
|
||||
return [_song_out(s) for s in rows if s.tracks]
|
||||
|
||||
|
||||
@router.get("/match/{match_id}")
|
||||
async def match_songs(match_id: str, db: AsyncSession = Depends(get_db)) -> list[dict]:
|
||||
"""경기별 응원가 — 날짜 무관. 종료된 경기 상세에서도 그날 곡을 노출한다."""
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(Song)
|
||||
.where(
|
||||
Song.match_id == match_id,
|
||||
Song.status.in_(("complete", "generating")),
|
||||
)
|
||||
.order_by(Song.team_code)
|
||||
)
|
||||
).scalars().all()
|
||||
return [_song_out(s) for s in rows if s.tracks]
|
||||
|
||||
|
||||
@router.get("/audio/{song_id}/{track_idx}")
|
||||
async def song_audio_bytes(
|
||||
song_id: int,
|
||||
track_idx: int,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Response:
|
||||
"""DB 보존 음원 서빙 — Range 지원(재생 위치 탐색용). v= 쿼리로 캐시버스트."""
|
||||
row = (
|
||||
await db.execute(
|
||||
select(SongAudio).where(
|
||||
SongAudio.song_id == song_id, SongAudio.track_idx == track_idx
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="AUDIO_NOT_FOUND")
|
||||
data, total = row.data, len(row.data)
|
||||
headers = {
|
||||
"Accept-Ranges": "bytes",
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
}
|
||||
m = re.fullmatch(r"bytes=(\d*)-(\d*)", request.headers.get("range") or "")
|
||||
if m and (m.group(1) or m.group(2)):
|
||||
if m.group(1):
|
||||
start = int(m.group(1))
|
||||
end = int(m.group(2)) if m.group(2) else total - 1
|
||||
else: # suffix range: bytes=-N (마지막 N바이트)
|
||||
start = max(0, total - int(m.group(2)))
|
||||
end = total - 1
|
||||
if start >= total or start > end:
|
||||
return Response(
|
||||
status_code=416, headers={"Content-Range": f"bytes */{total}"}
|
||||
)
|
||||
end = min(end, total - 1)
|
||||
headers["Content-Range"] = f"bytes {start}-{end}/{total}"
|
||||
return Response(
|
||||
content=data[start : end + 1],
|
||||
status_code=206,
|
||||
media_type=row.mime,
|
||||
headers=headers,
|
||||
)
|
||||
return Response(content=data, media_type=row.mime, headers=headers)
|
||||
|
||||
|
||||
@router.post("/callback")
|
||||
async def suno_callback(request: Request) -> dict:
|
||||
"""Suno 게이트웨이 callBackUrl 싱크대 — 완료 감지는 워커 폴링이 담당."""
|
||||
try:
|
||||
await request.json()
|
||||
except Exception: # noqa: BLE001 — 본문 형식 무관
|
||||
pass
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/generate", dependencies=[Depends(require_admin)])
|
||||
async def force_generate(db: AsyncSession = Depends(get_db)) -> dict:
|
||||
"""관리자: 오늘 KBO 전 경기 응원가 즉시 생성 시작 + 폴링 1회."""
|
||||
started = await start_due_songs(db, force_today=True)
|
||||
done = await poll_generating(db)
|
||||
saved = await persist_audio(db)
|
||||
return {"ok": True, "started": started, "completed": done, "persisted": saved}
|
||||
65
backend/app/routers/standings.py
Normal file
@ -0,0 +1,65 @@
|
||||
"""리그 순위표 API — 워커가 캐싱한 standings:{league} 를 팀 정보와 합쳐 서빙.
|
||||
|
||||
KBO: 단일 테이블(10팀, 순위순). MLB: 디비전(AL/NL × 동·중·서) 6그룹.
|
||||
MLS: 컨퍼런스(동/서부) 2그룹 — 승점제.
|
||||
캐시가 아직 없으면 빈 groups 를 반환한다(프론트는 안내 문구 표시).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import DataCache
|
||||
from ..teams_baseball import team_info
|
||||
|
||||
router = APIRouter(prefix="/api/standings", tags=["standings"])
|
||||
|
||||
# MLB 디비전 표시 순서 (AL 동→중→서, NL 동→중→서)
|
||||
_MLB_DIV_ORDER = ["ALE", "ALC", "ALW", "NLE", "NLC", "NLW"]
|
||||
# MLS 컨퍼런스 표시 순서 (동부 → 서부)
|
||||
_MLS_CONF_ORDER = ["EAST", "WEST"]
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_standings(
|
||||
league: str = Query(..., description="kbo | mlb | mls"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
if league not in ("kbo", "mlb", "mls"):
|
||||
return {"league": league, "updatedAt": None, "groups": []}
|
||||
row = await db.get(DataCache, f"standings:{league}")
|
||||
table: dict = row.payload if row else {}
|
||||
rows = [{**team_info(league, code), **st} for code, st in table.items()]
|
||||
|
||||
if league == "kbo":
|
||||
rows.sort(key=lambda r: r.get("rank") or 99)
|
||||
groups = [{"key": None, "rows": rows}] if rows else []
|
||||
elif league == "mls":
|
||||
by_conf: dict[str, list] = {}
|
||||
for r in rows:
|
||||
by_conf.setdefault(r.get("div") or "", []).append(r)
|
||||
for lst in by_conf.values():
|
||||
lst.sort(key=lambda r: r.get("rank") or 99)
|
||||
groups = [
|
||||
{"key": c, "rows": by_conf[c]} for c in _MLS_CONF_ORDER if c in by_conf
|
||||
]
|
||||
else:
|
||||
by_div: dict[str, list] = {}
|
||||
for r in rows:
|
||||
by_div.setdefault(r.get("div") or "", []).append(r)
|
||||
for lst in by_div.values():
|
||||
lst.sort(key=lambda r: r.get("rank") or 99)
|
||||
groups = [
|
||||
{"key": d, "rows": by_div[d]} for d in _MLB_DIV_ORDER if d in by_div
|
||||
]
|
||||
if not groups and rows:
|
||||
# 캐시가 div 주입 이전 버전이면 전체 승률순 단일 그룹으로 폴백
|
||||
rows.sort(key=lambda r: -float(r.get("wra") or 0))
|
||||
groups = [{"key": None, "rows": rows}]
|
||||
|
||||
return {
|
||||
"league": league,
|
||||
"updatedAt": row.fetched_at.isoformat() if row and row.fetched_at else None,
|
||||
"groups": groups,
|
||||
}
|
||||
26
backend/app/routers/visits.py
Normal file
@ -0,0 +1,26 @@
|
||||
"""방문자 집계 — 페이지 접속 기록(일별 순수 방문자)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..database import get_db
|
||||
from ..domain import KST, now_utc
|
||||
from ..models import PageVisit
|
||||
from ..schemas import VisitIn, VisitOut
|
||||
|
||||
router = APIRouter(prefix="/api/visit", tags=["visits"])
|
||||
|
||||
|
||||
@router.post("", response_model=VisitOut)
|
||||
async def record_visit(body: VisitIn, db: AsyncSession = Depends(get_db)) -> VisitOut:
|
||||
"""페이지 접속 기록. 같은 기기가 같은 날(KST) 재접속하면 집계하지 않는다."""
|
||||
today = now_utc().astimezone(KST).date()
|
||||
db.add(PageVisit(visit_date=today, device_id=body.deviceId))
|
||||
try:
|
||||
await db.commit()
|
||||
return VisitOut(ok=True, counted=True) # 오늘 첫 방문 → 집계
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
return VisitOut(ok=True, counted=False) # 재방문 → 무시
|
||||
95
backend/app/schedule_data.py
Normal file
@ -0,0 +1,95 @@
|
||||
"""경기 일정 SSOT — lib/schedule.ts(Group A 정식 일정, KST) 와 동일.
|
||||
|
||||
투표 오픈 = 킥오프 - settings.vote_open_hours_before (D-5 = 120h)
|
||||
투표 마감 = 킥오프 - settings.vote_lock_minutes_before (기본 0 = 킥오프 정각)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
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:
|
||||
# 저장은 UTC 로 통일 (DB 종류 무관하게 절대시점 일관). 출력 시 KST 로 직렬화.
|
||||
return datetime.fromisoformat(iso).astimezone(timezone.utc)
|
||||
|
||||
|
||||
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)
|
||||
211
backend/app/schemas.py
Normal file
@ -0,0 +1,211 @@
|
||||
"""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
|
||||
league: str = "wc" # wc | kbo | mlb
|
||||
roundLabel: str
|
||||
group: str
|
||||
teamA: Team
|
||||
teamB: Team
|
||||
kickoffKst: str
|
||||
venue: str
|
||||
opensAt: str
|
||||
lockAt: str
|
||||
status: str # = phase (실시간 계산값으로 통일)
|
||||
phase: str # scheduled | open | locked | live | finished (now 기준 계산)
|
||||
votable: bool # AI 예측·투표 대상 조인지 (그 외는 일정/결과만)
|
||||
votingOpen: bool # 현재 제출 허용 여부 (demo_force_open 반영)
|
||||
hookText: str
|
||||
result: MatchResult | None = None
|
||||
predictions: list[AIPredictionOut] = Field(default_factory=list)
|
||||
crowd: CrowdStatsOut | None = None
|
||||
# 야구 부가정보 (프리뷰·순위 캐시 — 축구/캐시없음이면 None)
|
||||
extras: dict | None = None
|
||||
|
||||
|
||||
# ── 픽 제출 ─────────────────────────────────────────────────
|
||||
class SubmitPredictionIn(BaseModel):
|
||||
matchId: str
|
||||
deviceId: str = Field(min_length=8, max_length=64)
|
||||
outcome: Outcome
|
||||
scoreA: int = Field(ge=0, le=25) # 야구(득점) 상한. 축구는 UI 가 0-9 로 제한.
|
||||
scoreB: int = Field(ge=0, le=25)
|
||||
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 MyResultOut(BaseModel):
|
||||
scoreA: int
|
||||
scoreB: int
|
||||
outcome: Outcome
|
||||
hitOutcome: bool # 내 outcome 이 실제 결과와 일치했는지
|
||||
|
||||
|
||||
class MyPredictionOut(BaseModel):
|
||||
matchId: str
|
||||
teamA: Team
|
||||
teamB: Team
|
||||
kickoffKst: str
|
||||
outcome: Outcome
|
||||
scoreA: int
|
||||
scoreB: int
|
||||
submittedAt: str # ISO — 최신순 정렬 기준(updated_at)
|
||||
result: MyResultOut | None = None # 경기 종료 시에만 채워짐
|
||||
|
||||
|
||||
# ── 리더보드 ────────────────────────────────────────────────
|
||||
class StandingOut(BaseModel):
|
||||
rank: int
|
||||
emailMasked: str
|
||||
totalPoints: int
|
||||
exactCount: int
|
||||
matchesPlayed: int
|
||||
|
||||
|
||||
class LeaderboardOut(BaseModel):
|
||||
standings: list[StandingOut]
|
||||
scoredMatches: int
|
||||
|
||||
|
||||
# ── AI 모델 랭킹 (종료 경기 누적, 매 조회 시 재계산) ──────────
|
||||
class AIStandingOut(BaseModel):
|
||||
rank: int
|
||||
model: ModelName
|
||||
totalPoints: int
|
||||
exactCount: int
|
||||
matchesPlayed: int
|
||||
|
||||
|
||||
class AILeaderboardOut(BaseModel):
|
||||
standings: list[AIStandingOut]
|
||||
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 CommentIn(BaseModel):
|
||||
deviceId: str = Field(min_length=8, max_length=64) # 서버에서 해시 후 폐기(원본 비저장)
|
||||
nickname: str = Field(min_length=1, max_length=12) # 세션 캐싱된 닉(서버 풀 검증)
|
||||
body: str = Field(min_length=1, max_length=200)
|
||||
|
||||
@field_validator("body")
|
||||
@classmethod
|
||||
def body_not_blank(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("EMPTY_BODY")
|
||||
return v
|
||||
|
||||
|
||||
class CommentOut(BaseModel):
|
||||
# id·author_hash 등 내부 식별자는 노출하지 않음(닉네임만 공개)
|
||||
nickname: str # 축구 코믹 한국어 5글자 (세션 발급)
|
||||
body: str
|
||||
createdAt: str # ISO8601
|
||||
|
||||
|
||||
class CommentNicknameOut(BaseModel):
|
||||
nickname: str # 세션 시작 시 발급받는 랜덤 닉(클라이언트가 sessionStorage에 캐싱)
|
||||
|
||||
|
||||
class CommentListOut(BaseModel):
|
||||
items: list[CommentOut]
|
||||
total: int # 숨김 제외 전체 개수 — "더보기" 남은 수 계산용
|
||||
|
||||
|
||||
class GenericOk(BaseModel):
|
||||
ok: bool
|
||||
detail: str = ""
|
||||
extra: dict | None = None
|
||||
|
||||
|
||||
# ── 방문자 집계 ─────────────────────────────────────────────
|
||||
class VisitIn(BaseModel):
|
||||
deviceId: str = Field(min_length=8, max_length=64)
|
||||
|
||||
|
||||
class VisitOut(BaseModel):
|
||||
ok: bool
|
||||
counted: bool # 오늘 첫 방문이면 True(집계됨), 재방문이면 False
|
||||
|
||||
|
||||
class DailyVisitOut(BaseModel):
|
||||
date: str # YYYY-MM-DD (KST)
|
||||
uniqueVisitors: int
|
||||
140
backend/app/scoring.py
Normal file
@ -0,0 +1,140 @@
|
||||
"""채점 로직 — docs/SCORING.md (SSOT) 그대로 구현.
|
||||
|
||||
배점: 정확 스코어 5 · 근접(승패+득실차) 3 · 승패 2 · 부분(한 팀 득점 일치) 1 · 빗나감 0.
|
||||
단조 증가(정확>근접>승패>부분>빗나감).
|
||||
야구(KBO/MLB)는 득점 범위가 넓어 같은 등급 체계에 판정만 완화한다:
|
||||
근접 = 승패 + 득실차 오차 ±1, 부분 = 한 팀 득점 오차 ±1.
|
||||
배점·배제 대상은 backend/data/scoring.json 에서 기동 시 로드(없으면 아래 기본값).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger("triplepick.scoring")
|
||||
|
||||
# 기본 배점 — backend/data/scoring.json 로드 시 덮어씀 (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"
|
||||
|
||||
|
||||
# 야구 판정 허용 오차 — 근접(득실차)·부분(한 팀 득점) 공통 ±1
|
||||
BASEBALL_TOLERANCE = 1
|
||||
|
||||
|
||||
def _grade_of(
|
||||
pick_a: int, pick_b: int, result_a: int, result_b: int, baseball: bool = False
|
||||
) -> str:
|
||||
"""등급 판정 (단일 SSOT) — baseball 이면 근접/부분 오차 ±1 허용."""
|
||||
tol = BASEBALL_TOLERANCE if baseball else 0
|
||||
if pick_a == result_a and pick_b == result_b:
|
||||
return "EXACT"
|
||||
if outcome_of(pick_a, pick_b) == outcome_of(result_a, result_b):
|
||||
if abs((pick_a - pick_b) - (result_a - result_b)) <= tol:
|
||||
return "CLOSE"
|
||||
return "OUTCOME"
|
||||
if abs(pick_a - result_a) <= tol or abs(pick_b - result_b) <= tol:
|
||||
return "PARTIAL"
|
||||
return "MISS"
|
||||
|
||||
|
||||
def score_prediction(
|
||||
pick_a: int, pick_b: int, result_a: int, result_b: int, baseball: bool = False
|
||||
) -> int:
|
||||
"""내 예측 vs 실제 결과 → 적중 포인트 (결정론적)."""
|
||||
return SCORE[_grade_of(pick_a, pick_b, result_a, result_b, baseball)]
|
||||
|
||||
|
||||
# ── 자격 · 배제 (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)
|
||||
|
||||
|
||||
# ── 외부 설정 로드 (backend/data/scoring.json, key-value) ──────
|
||||
DATA_FILE = Path(__file__).resolve().parent.parent / "data" / "scoring.json"
|
||||
|
||||
# 등급 → scoring.json 의 key
|
||||
_JSON_KEY = {
|
||||
"EXACT": "score_exact",
|
||||
"CLOSE": "score_close",
|
||||
"OUTCOME": "score_outcome",
|
||||
"PARTIAL": "score_partial",
|
||||
"MISS": "score_miss",
|
||||
}
|
||||
|
||||
|
||||
def grade_prediction(
|
||||
pick_a: int, pick_b: int, result_a: int, result_b: int,
|
||||
path: Path | None = None,
|
||||
baseball: bool = False,
|
||||
) -> tuple[str, int]:
|
||||
"""유저 투표(픽) vs 실제 결과 → scoring.json 에서 해당 등급의 (key, value).
|
||||
|
||||
score_prediction 과 같은 판정 기준(_grade_of 공유). 예: 승패+득실차 일치
|
||||
→ ("score_close", 3). scoring.json 이 없거나 키가 빠지면 기본 SCORE 값으로 대체.
|
||||
"""
|
||||
p = path or DATA_FILE
|
||||
try:
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
log.warning("scoring data 읽기 실패 (%s): %s — 기본 배점 사용", p, e)
|
||||
data = {}
|
||||
|
||||
grade = _grade_of(pick_a, pick_b, result_a, result_b, baseball)
|
||||
key = _JSON_KEY[grade]
|
||||
value = data[key] if isinstance(data.get(key), int) else SCORE[grade]
|
||||
return key, value
|
||||
|
||||
_SCORE_KEYS = {
|
||||
"score_exact": "EXACT",
|
||||
"score_close": "CLOSE",
|
||||
"score_outcome": "OUTCOME",
|
||||
"score_partial": "PARTIAL",
|
||||
"score_miss": "MISS",
|
||||
}
|
||||
|
||||
|
||||
def load_scoring_data(path: Path | None = None) -> dict:
|
||||
"""data/scoring.json 을 읽어 배점(SCORE)·배제 대상을 갱신. 기동 시 1회 호출.
|
||||
|
||||
SCORE/STAFF_EMAILS/STAFF_DOMAINS 는 타 모듈이 import 한 객체라 재할당 대신
|
||||
in-place 갱신. 파일이 없거나 깨져도 기본값으로 동작(부팅 실패 방지).
|
||||
"""
|
||||
p = path or DATA_FILE
|
||||
try:
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
log.warning("scoring data 없음 (%s) — 기본 배점 사용", p)
|
||||
return {}
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
log.warning("scoring data 읽기 실패 (%s): %s — 기본 배점 사용", p, e)
|
||||
return {}
|
||||
|
||||
for key, score_key in _SCORE_KEYS.items():
|
||||
if isinstance(data.get(key), int):
|
||||
SCORE[score_key] = data[key]
|
||||
if isinstance(data.get("excluded_emails"), list):
|
||||
STAFF_EMAILS.clear()
|
||||
STAFF_EMAILS.update(e.strip().lower() for e in data["excluded_emails"])
|
||||
if isinstance(data.get("excluded_domains"), list):
|
||||
STAFF_DOMAINS[:] = [
|
||||
d.strip().lower().lstrip("@") for d in data["excluded_domains"]
|
||||
]
|
||||
log.info(
|
||||
"scoring data 로드 (%s): 배점 %s · 배제 이메일 %d · 도메인 %s",
|
||||
p, SCORE, len(STAFF_EMAILS), STAFF_DOMAINS,
|
||||
)
|
||||
return data
|
||||
63
backend/app/seed.py
Normal file
@ -0,0 +1,63 @@
|
||||
"""DB 시드 — 경기 6개 + AI 예측(부트스트랩) + crowd baseline.
|
||||
|
||||
init_db 직후 호출. 이미 경기가 있으면 건너뛴다(idempotent).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from .database import SessionLocal
|
||||
from .models import CrowdStats, Match
|
||||
from .schedule_data import GROUP_A, lock_at, opens_at, parse_kickoff, TEAMS
|
||||
from .seed_data import generate_crowd
|
||||
|
||||
log = logging.getLogger("triplepick.seed")
|
||||
|
||||
|
||||
async def seed_if_empty() -> None:
|
||||
async with SessionLocal() as db:
|
||||
existing = (await db.execute(select(Match.match_id))).scalars().first()
|
||||
if existing:
|
||||
log.info("seed: matches already present — skip")
|
||||
return
|
||||
|
||||
for s in GROUP_A:
|
||||
kickoff = parse_kickoff(s.kickoff_iso)
|
||||
ta, tb = TEAMS[s.team_a], TEAMS[s.team_b]
|
||||
match = Match(
|
||||
match_id=s.match_id,
|
||||
round_label=s.round_label,
|
||||
group="A",
|
||||
team_a_name=ta["name"],
|
||||
team_a_short=ta["shortName"],
|
||||
team_a_code=ta["code"],
|
||||
team_a_flag=ta["flag"],
|
||||
team_b_name=tb["name"],
|
||||
team_b_short=tb["shortName"],
|
||||
team_b_code=tb["code"],
|
||||
team_b_flag=tb["flag"],
|
||||
venue=s.venue,
|
||||
hook_text=s.hook_text,
|
||||
kickoff_at=kickoff,
|
||||
opens_at=opens_at(kickoff),
|
||||
lock_at=lock_at(kickoff),
|
||||
status="scheduled",
|
||||
)
|
||||
db.add(match)
|
||||
|
||||
crowd = generate_crowd(s.match_id)
|
||||
db.add(
|
||||
CrowdStats(
|
||||
match_id=s.match_id,
|
||||
total=crowd["total"],
|
||||
team_a_win=crowd["teamAWin"],
|
||||
draw=crowd["draw"],
|
||||
team_b_win=crowd["teamBWin"],
|
||||
)
|
||||
)
|
||||
# AI 예측은 시드하지 않는다 — 워커가 실 API 로 생성.
|
||||
|
||||
await db.commit()
|
||||
log.info("seed: %d matches + crowd seeded (예측 없음 — 워커가 실 API 생성)", len(GROUP_A))
|
||||
11
backend/app/seed_data.py
Normal file
@ -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
backend/app/services/__init__.py
Normal file
243
backend/app/services/ai.py
Normal file
@ -0,0 +1,243 @@
|
||||
"""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
|
||||
data_block: str | None = None # 실데이터(폼·H2H·랭킹 등) 주입 블록. 없으면 이름만.
|
||||
league: str = "wc" # wc(축구) | kbo | mlb | mls — 프롬프트·스코어 범위 분기
|
||||
|
||||
|
||||
# 공통 페르소나 — 세 모델 모두 동일한 입력(동일 프롬프트+동일 데이터)을 받는다.
|
||||
# 예측 차이는 모델 자체의 판단 차이에서만 나온다.
|
||||
ANALYST = (
|
||||
"You are an expert soccer analyst. Use ALL of the factual match data provided "
|
||||
"below — recent form, standings, head-to-head and matchup context — and weigh "
|
||||
"it over your prior knowledge to make the most accurate prediction possible."
|
||||
)
|
||||
|
||||
BASEBALL_ANALYST = (
|
||||
"You are an expert baseball analyst. Use ALL of the factual match data provided "
|
||||
"below — recent form, standings with games-behind, team batting/ERA, "
|
||||
"head-to-head record, starting pitchers, confirmed lineups and yesterday's "
|
||||
"results — and weigh it over your prior knowledge to make the most accurate "
|
||||
"prediction possible."
|
||||
)
|
||||
|
||||
_LEAGUE_LABEL = {
|
||||
"kbo": "2026 KBO League (Korean professional baseball) regular-season game",
|
||||
"mlb": "2026 MLB (Major League Baseball) regular-season game",
|
||||
}
|
||||
|
||||
|
||||
def _prompt_baseball(ctx: MatchContext, model: str) -> str:
|
||||
persona = BASEBALL_ANALYST # 모델 공통 — model 파라미터는 호출부 호환용
|
||||
data = f"\n{ctx.data_block}\n" if ctx.data_block else ""
|
||||
draw_note = (
|
||||
"KBO regular-season games can end in a DRAW after 12 innings, but draws "
|
||||
"are rare (~1-2% of games); only predict a draw with strong reason.\n"
|
||||
if ctx.league == "kbo"
|
||||
else "MLB games cannot end in a draw — never predict DRAW.\n"
|
||||
)
|
||||
return (
|
||||
f"{persona}\n"
|
||||
f"Predict the result of this {_LEAGUE_LABEL[ctx.league]}.\n"
|
||||
f"Team A (away): {ctx.team_a}\nTeam B (home): {ctx.team_b}\n"
|
||||
f"Ballpark: {ctx.venue}\nFirst pitch: {ctx.kickoff}\n"
|
||||
f"{data}"
|
||||
f"Important: Team B is the HOME team — home advantage applies. {draw_note}\n"
|
||||
f"Predict the final score in runs. "
|
||||
f"Respond with a single JSON object and nothing else, with keys:\n"
|
||||
f' "scoreA": integer 0-25 (Team A runs),\n'
|
||||
f' "scoreB": integer 0-25 (Team B runs),\n'
|
||||
f' "outcome": one of "TEAM_A_WIN" | "DRAW" | "TEAM_B_WIN" (must match the score),\n'
|
||||
f' "confidencePct": integer 0-100,\n'
|
||||
f' "reasonKo": a short one-line rationale in Korean (max ~30 chars),\n'
|
||||
f' "reasonEn": a short one-line rationale in English (max ~60 chars).\n'
|
||||
)
|
||||
|
||||
|
||||
def _prompt(ctx: MatchContext, persona: str) -> str:
|
||||
# 실데이터 블록이 있으면 팀/킥오프 다음에 삽입(없으면 빈 문자열 — 기존 동작 동일).
|
||||
data = f"\n{ctx.data_block}\n" if ctx.data_block else ""
|
||||
return (
|
||||
f"{persona}\n"
|
||||
f"Predict the result of this 2026 FIFA World Cup match.\n"
|
||||
f"Team A: {ctx.team_a}\nTeam B: {ctx.team_b}\n"
|
||||
f"Venue: {ctx.venue}\nKickoff: {ctx.kickoff}\n"
|
||||
f"{data}"
|
||||
f"Important: World Cup group-stage matches are played at NEUTRAL venues. "
|
||||
f"Neither team has home advantage unless it is a host nation "
|
||||
f"(Mexico, USA, or Canada). Do NOT claim home advantage otherwise.\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'
|
||||
)
|
||||
|
||||
|
||||
def _prompt_mls(ctx: MatchContext, persona: str) -> str:
|
||||
"""MLS 정규시즌 — 월드컵과 달리 홈 어드밴티지가 있고 무승부가 흔하다."""
|
||||
data = f"\n{ctx.data_block}\n" if ctx.data_block else ""
|
||||
return (
|
||||
f"{persona}\n"
|
||||
f"Predict the result of this 2026 MLS (Major League Soccer) regular-season "
|
||||
f"match.\n"
|
||||
f"Team A (away): {ctx.team_a}\nTeam B (home): {ctx.team_b}\n"
|
||||
f"Venue: {ctx.venue}\nKickoff: {ctx.kickoff}\n"
|
||||
f"{data}"
|
||||
f"Important: Team B is the HOME team — MLS home advantage is significant "
|
||||
f"(long travel distances). Draws are common in MLS (~25% of matches) — "
|
||||
f"predict one when the matchup genuinely points that way.\n\n"
|
||||
f"Predict the final 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,\n'
|
||||
f' "reasonKo": a short one-line rationale in Korean (max ~30 chars),\n'
|
||||
f' "reasonEn": a short one-line rationale in English (max ~60 chars).\n'
|
||||
)
|
||||
|
||||
|
||||
def _build_prompt(ctx: MatchContext, model: str) -> str:
|
||||
"""리그별 프롬프트 선택 — 야구(kbo/mlb)는 야구, mls 는 MLS 축구, 그 외 월드컵."""
|
||||
if ctx.league in ("kbo", "mlb"):
|
||||
return _prompt_baseball(ctx, model)
|
||||
if ctx.league == "mls":
|
||||
return _prompt_mls(ctx, ANALYST)
|
||||
return _prompt(ctx, ANALYST)
|
||||
|
||||
|
||||
# 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, max_score: int = 9) -> dict:
|
||||
a = max(0, min(max_score, int(data["scoreA"])))
|
||||
b = max(0, min(max_score, int(data["scoreB"])))
|
||||
# outcome 은 스코어와 일관되도록 서버에서 재도출 (모델 불일치 방지)
|
||||
outcome = "TEAM_A_WIN" if a > b else "TEAM_B_WIN" if a < b else "DRAW"
|
||||
conf = max(0, min(100, int(data.get("confidencePct", 50))))
|
||||
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": _build_prompt(ctx, "GPT")},
|
||||
],
|
||||
response_format={"type": "json_object"},
|
||||
)
|
||||
content = resp.choices[0].message.content or "{}"
|
||||
return _normalize(json.loads(content), 25 if ctx.league in ("kbo", "mlb") else 9)
|
||||
|
||||
|
||||
# ── 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": _build_prompt(ctx, "Claude")}],
|
||||
**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), 25 if ctx.league in ("kbo", "mlb") else 9)
|
||||
|
||||
|
||||
# ── 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=_build_prompt(ctx, "Gemini"),
|
||||
config=types.GenerateContentConfig(response_mime_type="application/json"),
|
||||
)
|
||||
return _normalize(json.loads(resp.text or "{}"), 25 if ctx.league in ("kbo", "mlb") else 9)
|
||||
|
||||
|
||||
PROVIDERS = {
|
||||
"GPT": predict_gpt,
|
||||
"Claude": predict_claude,
|
||||
"Gemini": predict_gemini,
|
||||
}
|
||||
342
backend/app/services/auth_kakao.py
Normal file
@ -0,0 +1,342 @@
|
||||
"""카카오 로그인 + JWT 인증 — o2o-castad-backend 의 인증 스택 이식.
|
||||
|
||||
흐름 (castad 와 동일):
|
||||
1. GET /api/auth/kakao/login → 카카오 인증 페이지 URL
|
||||
2. 카카오 로그인 후 redirect_uri 로 인가 코드(code) 수신
|
||||
3. 코드 → 카카오 액세스 토큰 → 사용자 정보 조회
|
||||
4. users 조회/생성 → JWT access(60분)/refresh(7일) 발급
|
||||
5. refresh 는 해시로 DB 저장, 갱신 시 rotation(기존 폐기 + 신규 발급)
|
||||
|
||||
castad 대비 변경: aiohttp→httpx(프로젝트 표준), 크레딧·소셜계정 연동 제거,
|
||||
설정은 triplepick settings 로 통합.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from jose import jwt
|
||||
from jose.exceptions import ExpiredSignatureError, JWTError
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..config import settings
|
||||
from ..database import get_db
|
||||
from ..domain import ensure_aware, now_utc
|
||||
from ..models import RefreshToken, User
|
||||
|
||||
log = logging.getLogger("triplepick.auth")
|
||||
|
||||
JWT_ALGORITHM = "HS256"
|
||||
|
||||
KAKAO_AUTH_URL = "https://kauth.kakao.com/oauth/authorize"
|
||||
KAKAO_TOKEN_URL = "https://kauth.kakao.com/oauth/token"
|
||||
KAKAO_USER_INFO_URL = "https://kapi.kakao.com/v2/user/me"
|
||||
|
||||
|
||||
# ── 예외 (castad 코드 체계 유지) ───────────────────────────────
|
||||
class AuthError(HTTPException):
|
||||
def __init__(self, status_code: int, code: str, message: str):
|
||||
super().__init__(
|
||||
status_code=status_code, detail={"code": code, "message": message}
|
||||
)
|
||||
|
||||
|
||||
def _unauthorized(code: str, message: str) -> AuthError:
|
||||
return AuthError(status.HTTP_401_UNAUTHORIZED, code, message)
|
||||
|
||||
|
||||
# ── JWT ────────────────────────────────────────────────────────
|
||||
def create_access_token(user_uuid: str) -> str:
|
||||
expire = now_utc() + timedelta(minutes=settings.jwt_access_expire_minutes)
|
||||
return jwt.encode(
|
||||
{"sub": user_uuid, "exp": expire, "type": "access"},
|
||||
settings.jwt_secret,
|
||||
algorithm=JWT_ALGORITHM,
|
||||
)
|
||||
|
||||
|
||||
def create_refresh_token(user_uuid: str) -> str:
|
||||
expire = now_utc() + timedelta(days=settings.jwt_refresh_expire_days)
|
||||
return jwt.encode(
|
||||
{"sub": user_uuid, "exp": expire, "type": "refresh"},
|
||||
settings.jwt_secret,
|
||||
algorithm=JWT_ALGORITHM,
|
||||
)
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict | None:
|
||||
"""유효하면 payload, 만료/위조면 None."""
|
||||
try:
|
||||
return jwt.decode(token, settings.jwt_secret, algorithms=[JWT_ALGORITHM])
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
def is_token_expired(token: str) -> bool:
|
||||
try:
|
||||
jwt.decode(token, settings.jwt_secret, algorithms=[JWT_ALGORITHM])
|
||||
return False
|
||||
except ExpiredSignatureError:
|
||||
return True
|
||||
except JWTError:
|
||||
return False
|
||||
|
||||
|
||||
def get_token_hash(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
# ── 카카오 OAuth ───────────────────────────────────────────────
|
||||
def kakao_authorization_url() -> str:
|
||||
return (
|
||||
f"{KAKAO_AUTH_URL}?client_id={settings.kakao_client_id}"
|
||||
f"&redirect_uri={settings.kakao_redirect_uri}&response_type=code"
|
||||
)
|
||||
|
||||
|
||||
async def _kakao_access_token(code: str) -> str:
|
||||
import httpx
|
||||
|
||||
data = {
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": settings.kakao_client_id,
|
||||
"redirect_uri": settings.kakao_redirect_uri,
|
||||
"code": code,
|
||||
}
|
||||
if settings.kakao_client_secret:
|
||||
data["client_secret"] = settings.kakao_client_secret
|
||||
async with httpx.AsyncClient(timeout=15) as c:
|
||||
r = await c.post(KAKAO_TOKEN_URL, data=data)
|
||||
result = r.json()
|
||||
if "error" in result:
|
||||
desc = result.get("error_description", result.get("error", "알 수 없는 오류"))
|
||||
log.error("kakao 토큰 발급 실패: %s", desc)
|
||||
raise AuthError(
|
||||
status.HTTP_400_BAD_REQUEST, "KAKAO_AUTH_FAILED",
|
||||
f"카카오 토큰 발급 실패: {desc}",
|
||||
)
|
||||
return result["access_token"]
|
||||
|
||||
|
||||
async def _kakao_user_info(access_token: str) -> dict:
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient(timeout=15) as c:
|
||||
r = await c.get(
|
||||
KAKAO_USER_INFO_URL, headers={"Authorization": f"Bearer {access_token}"}
|
||||
)
|
||||
result = r.json()
|
||||
if "id" not in result:
|
||||
log.error("kakao 사용자 정보 조회 실패: %s", result)
|
||||
raise AuthError(
|
||||
status.HTTP_400_BAD_REQUEST, "KAKAO_AUTH_FAILED",
|
||||
"카카오 사용자 정보를 가져올 수 없습니다.",
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# ── 사용자 조회/생성 ───────────────────────────────────────────
|
||||
def _profile_of(info: dict) -> tuple[str | None, str | None, str | None]:
|
||||
"""카카오 응답 → (email, nickname, profile_image_url)."""
|
||||
account = info.get("kakao_account") or {}
|
||||
profile = account.get("profile") or {}
|
||||
return (
|
||||
account.get("email"),
|
||||
profile.get("nickname"),
|
||||
profile.get("profile_image_url"),
|
||||
)
|
||||
|
||||
|
||||
async def _get_or_create_user(db: AsyncSession, info: dict) -> tuple[User, bool]:
|
||||
kakao_id = int(info["id"])
|
||||
email, nickname, image = _profile_of(info)
|
||||
|
||||
user = (
|
||||
await db.execute(select(User).where(User.kakao_id == kakao_id))
|
||||
).scalar_one_or_none()
|
||||
if user is not None:
|
||||
# 기존 사용자 — 프로필 최신화
|
||||
if nickname:
|
||||
user.nickname = nickname
|
||||
if image:
|
||||
user.profile_image_url = image
|
||||
if email:
|
||||
user.email = email
|
||||
await db.flush()
|
||||
return user, False
|
||||
|
||||
import uuid
|
||||
|
||||
new_user = User(
|
||||
kakao_id=kakao_id,
|
||||
user_uuid=str(uuid.uuid4()),
|
||||
email=email,
|
||||
nickname=nickname,
|
||||
profile_image_url=image,
|
||||
)
|
||||
db.add(new_user)
|
||||
try:
|
||||
await db.flush()
|
||||
return new_user, True
|
||||
except IntegrityError:
|
||||
# 동시 요청으로 인한 중복 삽입 — 기존 사용자 재조회 (castad 동일 처리)
|
||||
await db.rollback()
|
||||
existing = (
|
||||
await db.execute(select(User).where(User.kakao_id == kakao_id))
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
return existing, False
|
||||
raise
|
||||
|
||||
|
||||
# ── 로그인/갱신/로그아웃 ───────────────────────────────────────
|
||||
async def kakao_login(
|
||||
db: AsyncSession,
|
||||
code: str,
|
||||
user_agent: Optional[str] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""인가 코드 → JWT 발급. {access_token, refresh_token, expires_in, is_new_user, user}."""
|
||||
kakao_token = await _kakao_access_token(code)
|
||||
info = await _kakao_user_info(kakao_token)
|
||||
user, is_new = await _get_or_create_user(db, info)
|
||||
|
||||
if not user.is_active:
|
||||
raise AuthError(
|
||||
status.HTTP_403_FORBIDDEN, "USER_INACTIVE", "활성화 상태가 아닌 사용자 입니다."
|
||||
)
|
||||
|
||||
access_token = create_access_token(user.user_uuid)
|
||||
refresh_token = create_refresh_token(user.user_uuid)
|
||||
db.add(RefreshToken(
|
||||
user_id=user.id,
|
||||
user_uuid=user.user_uuid,
|
||||
token_hash=get_token_hash(refresh_token),
|
||||
expires_at=now_utc() + timedelta(days=settings.jwt_refresh_expire_days),
|
||||
user_agent=user_agent,
|
||||
ip_address=ip_address,
|
||||
))
|
||||
user.last_login_at = now_utc()
|
||||
await db.commit()
|
||||
log.info("kakao 로그인: user_id=%s new=%s", user.id, is_new)
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": settings.jwt_access_expire_minutes * 60,
|
||||
"is_new_user": is_new,
|
||||
"user": user_out(user),
|
||||
}
|
||||
|
||||
|
||||
async def refresh_tokens(db: AsyncSession, refresh_token: str) -> dict:
|
||||
"""Refresh Token Rotation — 기존 토큰 폐기 + 새 access/refresh 발급."""
|
||||
payload = decode_token(refresh_token)
|
||||
if payload is None:
|
||||
if is_token_expired(refresh_token):
|
||||
raise _unauthorized("TOKEN_EXPIRED", "토큰이 만료되었습니다. 다시 로그인해주세요.")
|
||||
raise _unauthorized("INVALID_TOKEN", "유효하지 않은 토큰입니다.")
|
||||
if payload.get("type") != "refresh":
|
||||
raise _unauthorized("INVALID_TOKEN", "리프레시 토큰이 아닙니다.")
|
||||
|
||||
token_hash = get_token_hash(refresh_token)
|
||||
db_token = (
|
||||
await db.execute(
|
||||
select(RefreshToken).where(RefreshToken.token_hash == token_hash)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if db_token is None:
|
||||
raise _unauthorized("INVALID_TOKEN", "유효하지 않은 토큰입니다.")
|
||||
if db_token.is_revoked: # 이미 폐기된 토큰 재사용 — replay 의심
|
||||
log.warning("폐기된 refresh 재사용: user_uuid=%s", db_token.user_uuid)
|
||||
raise _unauthorized("TOKEN_REVOKED", "취소된 토큰입니다. 다시 로그인해주세요.")
|
||||
if ensure_aware(db_token.expires_at) < now_utc():
|
||||
raise _unauthorized("TOKEN_EXPIRED", "토큰이 만료되었습니다. 다시 로그인해주세요.")
|
||||
|
||||
user = (
|
||||
await db.execute(select(User).where(User.user_uuid == payload.get("sub")))
|
||||
).scalar_one_or_none()
|
||||
if user is None:
|
||||
raise AuthError(
|
||||
status.HTTP_404_NOT_FOUND, "USER_NOT_FOUND", "가입되지 않은 사용자 입니다."
|
||||
)
|
||||
if not user.is_active:
|
||||
raise AuthError(
|
||||
status.HTTP_403_FORBIDDEN, "USER_INACTIVE", "활성화 상태가 아닌 사용자 입니다."
|
||||
)
|
||||
|
||||
db_token.is_revoked = True
|
||||
db_token.revoked_at = now_utc()
|
||||
new_access = create_access_token(user.user_uuid)
|
||||
new_refresh = create_refresh_token(user.user_uuid)
|
||||
db.add(RefreshToken(
|
||||
user_id=user.id,
|
||||
user_uuid=user.user_uuid,
|
||||
token_hash=get_token_hash(new_refresh),
|
||||
expires_at=now_utc() + timedelta(days=settings.jwt_refresh_expire_days),
|
||||
))
|
||||
await db.commit() # 폐기 + 저장을 한 트랜잭션으로
|
||||
|
||||
return {
|
||||
"access_token": new_access,
|
||||
"refresh_token": new_refresh,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": settings.jwt_access_expire_minutes * 60,
|
||||
}
|
||||
|
||||
|
||||
async def logout(db: AsyncSession, refresh_token: str) -> None:
|
||||
await db.execute(
|
||||
update(RefreshToken)
|
||||
.where(RefreshToken.token_hash == get_token_hash(refresh_token))
|
||||
.values(is_revoked=True, revoked_at=now_utc())
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
def user_out(user: User) -> dict:
|
||||
return {
|
||||
"userUuid": user.user_uuid,
|
||||
"email": user.email,
|
||||
"nickname": user.nickname,
|
||||
"profileImageUrl": user.profile_image_url,
|
||||
}
|
||||
|
||||
|
||||
# ── FastAPI 의존성 ─────────────────────────────────────────────
|
||||
_security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(_security),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
"""Bearer access 토큰 → User. 실패 시 401/403/404."""
|
||||
if credentials is None:
|
||||
raise _unauthorized("MISSING_TOKEN", "인증 토큰이 필요합니다.")
|
||||
payload = decode_token(credentials.credentials)
|
||||
if payload is None:
|
||||
if is_token_expired(credentials.credentials):
|
||||
raise _unauthorized("TOKEN_EXPIRED", "토큰이 만료되었습니다. 다시 로그인해주세요.")
|
||||
raise _unauthorized("INVALID_TOKEN", "유효하지 않은 토큰입니다.")
|
||||
if payload.get("type") != "access":
|
||||
raise _unauthorized("INVALID_TOKEN", "액세스 토큰이 아닙니다.")
|
||||
user = (
|
||||
await db.execute(select(User).where(User.user_uuid == payload.get("sub")))
|
||||
).scalar_one_or_none()
|
||||
if user is None:
|
||||
raise AuthError(
|
||||
status.HTTP_404_NOT_FOUND, "USER_NOT_FOUND", "가입되지 않은 사용자 입니다."
|
||||
)
|
||||
if not user.is_active:
|
||||
raise AuthError(
|
||||
status.HTTP_403_FORBIDDEN, "USER_INACTIVE", "활성화 상태가 아닌 사용자 입니다."
|
||||
)
|
||||
return user
|
||||
196
backend/app/services/baseball_data.py
Normal file
@ -0,0 +1,196 @@
|
||||
"""야구 예측 프롬프트용 데이터 블록 — 자체 DB(정산 결과) + DataCache(프리뷰·순위).
|
||||
|
||||
결과 동기화가 쌓은 종료 경기에서 팀 폼·시즌 성적·상대전적을 계산하고,
|
||||
캐시된 선발투수·공식 상대전적·순위를 덧붙인다. 모두 없으면 None(이름만 예측).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
|
||||
from ..models import DataCache, Match
|
||||
|
||||
|
||||
def _wdl(gf: int, ga: int) -> str:
|
||||
return "W" if gf > ga else "L" if gf < ga else "D"
|
||||
|
||||
|
||||
async def _team_games(db, league: str, code: str, before) -> list[dict]:
|
||||
rows = (await db.execute(
|
||||
select(Match)
|
||||
.where(
|
||||
Match.league == league,
|
||||
or_(Match.team_a_code == code, Match.team_b_code == code),
|
||||
Match.result_outcome.isnot(None),
|
||||
Match.kickoff_at < before,
|
||||
)
|
||||
.order_by(Match.kickoff_at)
|
||||
)).scalars().all()
|
||||
out = []
|
||||
for m in rows:
|
||||
is_a = m.team_a_code == code # team_a = 원정
|
||||
gf = m.result_score_a if is_a else m.result_score_b
|
||||
ga = m.result_score_b if is_a else m.result_score_a
|
||||
if gf is None or ga is None:
|
||||
continue
|
||||
out.append({
|
||||
"r": _wdl(gf, ga), "gf": gf, "ga": ga,
|
||||
"opp": m.team_b_short if is_a else m.team_a_short,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _team_lines(name: str, games: list[dict]) -> str:
|
||||
head = f"[{name}]"
|
||||
if not games:
|
||||
return f"{head}\n (no season data yet — use general knowledge)"
|
||||
n = len(games)
|
||||
w = sum(1 for g in games if g["r"] == "W")
|
||||
d = sum(1 for g in games if g["r"] == "D")
|
||||
l = sum(1 for g in games if g["r"] == "L")
|
||||
gf_avg = round(sum(g["gf"] for g in games) / n, 2)
|
||||
ga_avg = round(sum(g["ga"] for g in games) / n, 2)
|
||||
recent = games[-8:]
|
||||
detail = "; ".join("{r} {gf}-{ga} vs {opp}".format(**g) for g in recent[-4:])
|
||||
return (
|
||||
f"{head}\n"
|
||||
f" Season(in our data): {w}-{d}-{l} (W-D-L), "
|
||||
f"runs avg {gf_avg} scored / {ga_avg} allowed over {n} games\n"
|
||||
f" Form(last{len(recent)}): {' '.join(g['r'] for g in recent)} ({detail})"
|
||||
)
|
||||
|
||||
|
||||
async def _h2h_line(db, league: str, code_a: str, code_b: str, before) -> str:
|
||||
rows = (await db.execute(
|
||||
select(Match)
|
||||
.where(
|
||||
Match.league == league,
|
||||
or_(
|
||||
(Match.team_a_code == code_a) & (Match.team_b_code == code_b),
|
||||
(Match.team_a_code == code_b) & (Match.team_b_code == code_a),
|
||||
),
|
||||
Match.result_outcome.isnot(None),
|
||||
Match.kickoff_at < before,
|
||||
)
|
||||
.order_by(Match.kickoff_at)
|
||||
)).scalars().all()
|
||||
parts = [
|
||||
f"{m.team_a_short} {m.result_score_a}-{m.result_score_b} {m.team_b_short}"
|
||||
for m in rows[-5:]
|
||||
if m.result_score_a is not None
|
||||
]
|
||||
return "; ".join(parts) if parts else "no meetings in our data yet"
|
||||
|
||||
|
||||
def _starter_line(side: str, s: dict | None) -> str | None:
|
||||
if not s or not s.get("name"):
|
||||
return None
|
||||
era = f", season ERA {s['era']}" if s.get("era") else ""
|
||||
rec = (
|
||||
f" ({s['w']}W-{s['l']}L)"
|
||||
if s.get("w") is not None and s.get("l") is not None else ""
|
||||
)
|
||||
# 상대 ERA 0.00 은 대부분 '대전 기록 없음' — 무실점으로 오해하지 않게 생략
|
||||
vs_val = s.get("vsEra")
|
||||
vs = (
|
||||
f", ERA vs this opponent {vs_val}"
|
||||
if vs_val not in (None, "", 0, "0", "0.00", "-")
|
||||
else ""
|
||||
)
|
||||
return f"[{side} starting pitcher] {s['name']}{era}{rec}{vs}"
|
||||
|
||||
|
||||
def _standing_line(name: str, st: dict | None) -> str | None:
|
||||
if not st:
|
||||
return None
|
||||
gb = f", GB {st['gb']}" if st.get("gb") not in (None, "", 0, "0.0") else ""
|
||||
ba = f", team AVG {st['avg']}" if st.get("avg") else ""
|
||||
era = f", team ERA {st['era']}" if st.get("era") else ""
|
||||
extra = f", last5 {st['last5']}" if st.get("last5") else ""
|
||||
return (
|
||||
f"[{name} standings] rank {st.get('rank')}, {st.get('w')}W-"
|
||||
f"{st.get('l')}L (pct {st.get('wra')}){gb}{ba}{era}{extra}"
|
||||
)
|
||||
|
||||
|
||||
async def _cached_extras(db, match: Match) -> list[str]:
|
||||
lines: list[str] = []
|
||||
prev = await db.get(DataCache, f"preview:{match.match_id}")
|
||||
if prev:
|
||||
p = prev.payload
|
||||
for line in (
|
||||
_starter_line("Away", p.get("starterA")),
|
||||
_starter_line("Home", p.get("starterB")),
|
||||
):
|
||||
if line:
|
||||
lines.append(line)
|
||||
vs = p.get("seasonVs")
|
||||
if vs and vs.get("aWin") is not None:
|
||||
lines.append(
|
||||
f"[Season head-to-head (official)] away {vs['aWin']}W - "
|
||||
f"{vs.get('draw', 0)}D - home {vs.get('bWin')}W"
|
||||
)
|
||||
st_row = await db.get(DataCache, f"standings:{match.league}")
|
||||
if st_row:
|
||||
table = st_row.payload
|
||||
for line in (
|
||||
_standing_line(match.team_a_short, table.get(match.team_a_code)),
|
||||
_standing_line(match.team_b_short, table.get(match.team_b_code)),
|
||||
):
|
||||
if line:
|
||||
lines.append(line)
|
||||
return lines
|
||||
|
||||
|
||||
async def _context_extras(db, match: Match) -> list[str]:
|
||||
"""전날 결과·활약 + 확정 라인업(타자 시즌 타율) — 응원가 파이프라인 헬퍼 재사용.
|
||||
|
||||
라인업은 발표 전이면 생략(예측이 킥오프 22h 전부터 생성되므로 보통 미포함).
|
||||
"""
|
||||
from .songs import _lineup_line, _yesterday_info, fetch_lineups
|
||||
|
||||
lines: list[str] = []
|
||||
avg_maps: dict[str, dict] = {}
|
||||
for side, code, name in (
|
||||
("a", match.team_a_code, match.team_a_short),
|
||||
("b", match.team_b_code, match.team_b_short),
|
||||
):
|
||||
label = "Away" if side == "a" else "Home"
|
||||
try:
|
||||
y, avg_map = await _yesterday_info(db, match, code, name)
|
||||
except Exception: # noqa: BLE001 — 부가 데이터 실패는 생략
|
||||
y, avg_map = None, {}
|
||||
avg_maps[side] = avg_map
|
||||
if y:
|
||||
lines.append(f"[{label} yesterday] {y}")
|
||||
try:
|
||||
lu = await fetch_lineups(match)
|
||||
except Exception: # noqa: BLE001
|
||||
lu = None
|
||||
if lu and lu.get("announced"):
|
||||
for side in ("a", "b"):
|
||||
label = "Away" if side == "a" else "Home"
|
||||
line = _lineup_line(lu, side, avg_maps.get(side))
|
||||
if line:
|
||||
lines.append(f"[{label}] {line}")
|
||||
return lines
|
||||
|
||||
|
||||
async def build_baseball_data_block(db, match: Match) -> str | None:
|
||||
ga = await _team_games(db, match.league, match.team_a_code, match.kickoff_at)
|
||||
gb = await _team_games(db, match.league, match.team_b_code, match.kickoff_at)
|
||||
extras = await _cached_extras(db, match)
|
||||
if not ga and not gb and not extras:
|
||||
return None
|
||||
h2h = await _h2h_line(
|
||||
db, match.league, match.team_a_code, match.team_b_code, match.kickoff_at
|
||||
)
|
||||
context = await _context_extras(db, match)
|
||||
return "\n".join([
|
||||
"=== MATCH DATA (factual; weigh heavily over priors) ===",
|
||||
"Away " + _team_lines(match.team_a_name, ga),
|
||||
"Home " + _team_lines(match.team_b_name, gb),
|
||||
f"[Head-to-head in our data] {h2h}",
|
||||
*extras,
|
||||
*context,
|
||||
"===",
|
||||
])
|
||||
861
backend/app/services/baseball_details.py
Normal file
@ -0,0 +1,861 @@
|
||||
"""야구 부가 데이터 — 프리뷰(선발투수·상대전적)·리그 순위·라이브 필드 뷰.
|
||||
|
||||
KBO: 네이버 스포츠 비공식 API (프리뷰·순위·문자중계 relay)
|
||||
MLB: 공식 Stats API (probablePitcher·standings·feed/live)
|
||||
|
||||
수집(refresh_*)은 워커가 매일, 조회(get_extras)는 라우터가 캐시만 읽음.
|
||||
라이브(fetch_live)는 요청 시 프록시 + 짧은 TTL 메모리 캐시.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from ..config import settings
|
||||
from ..domain import ensure_aware, now_utc
|
||||
from ..models import DataCache, Match
|
||||
from ..teams_baseball import KBO_SHORT_TO_CODE, MLB_ID_TO_CODE, MLB_TEAMS
|
||||
from .baseball_sync import match_seq
|
||||
|
||||
log = logging.getLogger("triplepick.baseball")
|
||||
|
||||
KST = timezone(timedelta(hours=9))
|
||||
UA = {
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/126.0 Safari/537.36"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def naver_game_id(m: Match, game_no: str = "0") -> str:
|
||||
kst = ensure_aware(m.kickoff_at).astimezone(KST)
|
||||
return f"{kst.strftime('%Y%m%d')}{m.team_a_code}{m.team_b_code}{game_no}{kst.year}"
|
||||
|
||||
|
||||
def naver_game_id_candidates(m: Match) -> list[str]:
|
||||
"""더블헤더 대응 gameId 후보 — 끝번호 0(단일)/1(DH 1차전)/2(DH 2차전).
|
||||
seq1 은 단일을 먼저 시도하고 DH 1차전으로 폴백, seq2 는 2차전 고정."""
|
||||
nos = ["2"] if match_seq(m.match_id) == 2 else ["0", "1"]
|
||||
return [naver_game_id(m, n) for n in nos]
|
||||
|
||||
|
||||
async def _naver_get_first_with_id(
|
||||
client, m: Match, suffix: str
|
||||
) -> tuple[str, dict] | None:
|
||||
"""gameId 후보를 순서대로 시도해 (성공한 gameId, 응답) 을 반환.
|
||||
|
||||
같은 경기를 추가 조회할 때(이닝별 문자중계) gameId 를 다시 찾지 않게 함께 돌려준다.
|
||||
"""
|
||||
for gid in naver_game_id_candidates(m):
|
||||
try:
|
||||
res = await _naver_get(client, f"/schedule/games/{gid}/{suffix}")
|
||||
except Exception: # noqa: BLE001 — 후보 불일치(404 등)는 다음 후보로
|
||||
continue
|
||||
if res:
|
||||
return gid, res
|
||||
return None
|
||||
|
||||
|
||||
async def _naver_get_first(client, m: Match, suffix: str) -> dict | None:
|
||||
"""gameId 후보를 순서대로 시도해 첫 성공 응답을 반환."""
|
||||
found = await _naver_get_first_with_id(client, m, suffix)
|
||||
return found[1] if found else None
|
||||
|
||||
|
||||
async def _naver_get(client, path: str) -> dict | None:
|
||||
r = await client.get(settings.naver_api_base + path, headers=UA)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
return data.get("result") if data.get("success") else None
|
||||
|
||||
|
||||
async def _upsert(db, key: str, payload: dict) -> None:
|
||||
row = await db.get(DataCache, key)
|
||||
if row:
|
||||
row.payload = payload
|
||||
row.fetched_at = now_utc()
|
||||
else:
|
||||
db.add(DataCache(key=key, payload=payload, fetched_at=now_utc()))
|
||||
|
||||
|
||||
# ── 프리뷰 (선발투수·시즌 상대전적) ────────────────────────────
|
||||
def _kbo_starter(raw: dict | None) -> dict | None:
|
||||
if not raw:
|
||||
return None
|
||||
info = raw.get("playerInfo") or {}
|
||||
season = raw.get("currentSeasonStats") or {}
|
||||
vs = raw.get("currentSeasonStatsOnOpponents") or {}
|
||||
out = {
|
||||
"name": info.get("name", ""),
|
||||
"hitType": info.get("hitType", ""),
|
||||
"era": season.get("era"),
|
||||
"w": season.get("w"), "l": season.get("l"),
|
||||
"vsEra": vs.get("era"),
|
||||
}
|
||||
return out if out["name"] else None
|
||||
|
||||
|
||||
async def _refresh_previews_kbo(db, matches: list[Match]) -> int:
|
||||
import httpx
|
||||
|
||||
n = 0
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
for m in matches:
|
||||
try:
|
||||
res = await _naver_get_first(client, m, "preview")
|
||||
p = (res or {}).get("previewData") or {}
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning("kbo preview 실패 %s: %s", m.match_id, e)
|
||||
continue
|
||||
vs = p.get("seasonVsResult") or {}
|
||||
payload = {
|
||||
"starterA": _kbo_starter(p.get("awayStarter")),
|
||||
"starterB": _kbo_starter(p.get("homeStarter")),
|
||||
"seasonVs": {
|
||||
"aWin": vs.get("aw"), "draw": vs.get("ad") or 0, "bWin": vs.get("hw"),
|
||||
} if vs else None,
|
||||
}
|
||||
if payload["starterA"] or payload["starterB"] or payload["seasonVs"]:
|
||||
await _upsert(db, f"preview:{m.match_id}", payload)
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
_HAND_KO = {"L": "좌", "R": "우", "S": "양"}
|
||||
|
||||
|
||||
def _mlb_starter(p: dict, stats: dict[int, dict]) -> dict | None:
|
||||
if not p.get("fullName"):
|
||||
return None
|
||||
out: dict = {"name": p["fullName"]}
|
||||
out.update(stats.get(p.get("id"), {}))
|
||||
return out
|
||||
|
||||
|
||||
async def _mlb_season_vs(c, m: Match, year: int) -> dict | None:
|
||||
"""시즌 정규 상대전적 — 두 팀 간 완료 경기 승수 집계 (팀쌍당 1콜)."""
|
||||
aid = (MLB_TEAMS.get(m.team_a_code) or {}).get("mlb_id")
|
||||
bid = (MLB_TEAMS.get(m.team_b_code) or {}).get("mlb_id")
|
||||
if not aid or not bid:
|
||||
return None
|
||||
r = await c.get(
|
||||
f"{settings.mlb_api_base}/v1/schedule?sportId=1&season={year}&gameType=R"
|
||||
f"&teamId={bid}&opponentId={aid}"
|
||||
f"&startDate={year}-03-01&endDate={datetime.now(KST).date().isoformat()}"
|
||||
)
|
||||
r.raise_for_status()
|
||||
wins = {aid: 0, bid: 0}
|
||||
for day in r.json().get("dates") or []:
|
||||
for g in day.get("games") or []:
|
||||
if (g.get("status") or {}).get("abstractGameState") != "Final":
|
||||
continue
|
||||
for side in ("away", "home"):
|
||||
t = g["teams"][side]
|
||||
tid = (t.get("team") or {}).get("id")
|
||||
if t.get("isWinner") and tid in wins:
|
||||
wins[tid] += 1
|
||||
if wins[aid] + wins[bid] == 0:
|
||||
return None
|
||||
return {"aWin": wins[aid], "draw": 0, "bWin": wins[bid]}
|
||||
|
||||
|
||||
async def _refresh_previews_mlb(db, matches: list[Match]) -> int:
|
||||
"""MLB 예고 선발(시즌 ERA·승패·투타 포함)·시즌 상대전적 — 공식 Stats API.
|
||||
|
||||
호출량: 일정 1콜 + 선발 스탯 일괄 1콜 + 상대전적 팀쌍당 1콜.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
if not matches:
|
||||
return 0
|
||||
dates = sorted({ensure_aware(m.kickoff_at).astimezone(KST).date() for m in matches})
|
||||
year = datetime.now(KST).year
|
||||
# statsapi 의 start/endDate 는 미국 날짜 — KST 새벽~오전 경기는 미국 전날이라
|
||||
# 시작일을 하루 앞당겨야 누락되지 않는다.
|
||||
url = (
|
||||
f"{settings.mlb_api_base}/v1/schedule?sportId=1"
|
||||
f"&startDate={(dates[0] - timedelta(days=1)).isoformat()}"
|
||||
f"&endDate={dates[-1].isoformat()}"
|
||||
"&hydrate=probablePitcher"
|
||||
)
|
||||
async with httpx.AsyncClient(timeout=20) as c:
|
||||
r = await c.get(url)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
|
||||
# (dateKst, away, home) → (원정 선발 raw, 홈 선발 raw)
|
||||
starters: dict[tuple, tuple[dict, dict]] = {}
|
||||
for day in data.get("dates") or []:
|
||||
for g in day.get("games") or []:
|
||||
a = MLB_ID_TO_CODE.get((g["teams"]["away"]["team"] or {}).get("id"))
|
||||
b = MLB_ID_TO_CODE.get((g["teams"]["home"]["team"] or {}).get("id"))
|
||||
gd = g.get("gameDate")
|
||||
if not a or not b or not gd:
|
||||
continue
|
||||
d = (
|
||||
datetime.fromisoformat(gd.replace("Z", "+00:00"))
|
||||
.astimezone(KST).strftime("%Y%m%d")
|
||||
)
|
||||
pa = g["teams"]["away"].get("probablePitcher") or {}
|
||||
pb = g["teams"]["home"].get("probablePitcher") or {}
|
||||
starters[(d, a, b)] = (pa, pb)
|
||||
|
||||
# 선발 시즌 스탯 — people 일괄 조회 1콜 (ERA·승패·투타)
|
||||
pids = sorted({
|
||||
p["id"] for pair in starters.values() for p in pair if p.get("id")
|
||||
})
|
||||
pstats: dict[int, dict] = {}
|
||||
if pids:
|
||||
try:
|
||||
r2 = await c.get(
|
||||
f"{settings.mlb_api_base}/v1/people"
|
||||
f"?personIds={','.join(map(str, pids))}"
|
||||
f"&hydrate=stats(group=[pitching],type=[season],season={year})"
|
||||
)
|
||||
r2.raise_for_status()
|
||||
for p in r2.json().get("people") or []:
|
||||
splits = (p.get("stats") or [{}])[0].get("splits") or []
|
||||
s = splits[0].get("stat", {}) if splits else {}
|
||||
hand = _HAND_KO.get((p.get("pitchHand") or {}).get("code"))
|
||||
bat = _HAND_KO.get((p.get("batSide") or {}).get("code"))
|
||||
pstats[p["id"]] = {
|
||||
"hitType": f"{hand}투{bat}타" if hand and bat else None,
|
||||
"era": s.get("era"),
|
||||
"w": s.get("wins"), "l": s.get("losses"),
|
||||
}
|
||||
except Exception as e: # noqa: BLE001 — 스탯 실패 시 이름만 표시
|
||||
log.warning("mlb 선발 스탯 실패: %s", e)
|
||||
|
||||
n = 0
|
||||
vs_cache: dict[tuple, dict | None] = {}
|
||||
for m in matches:
|
||||
d = ensure_aware(m.kickoff_at).astimezone(KST).strftime("%Y%m%d")
|
||||
pa, pb = starters.get((d, m.team_a_code, m.team_b_code), ({}, {}))
|
||||
pair = (m.team_a_code, m.team_b_code)
|
||||
if pair not in vs_cache:
|
||||
try:
|
||||
vs_cache[pair] = await _mlb_season_vs(c, m, year)
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning("mlb 상대전적 실패 %s: %s", m.match_id, e)
|
||||
vs_cache[pair] = None
|
||||
sa = _mlb_starter(pa, pstats)
|
||||
sb = _mlb_starter(pb, pstats)
|
||||
if sa or sb or vs_cache[pair]:
|
||||
await _upsert(db, f"preview:{m.match_id}", {
|
||||
"starterA": sa,
|
||||
"starterB": sb,
|
||||
"seasonVs": vs_cache[pair],
|
||||
})
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
# ── 리그 순위 ──────────────────────────────────────────────────
|
||||
async def _refresh_standings_kbo(db) -> bool:
|
||||
import httpx
|
||||
|
||||
year = datetime.now(KST).year
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
res = await _naver_get(client, f"/stats/categories/kbo/seasons/{year}/teams")
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning("kbo standings 실패: %s", e)
|
||||
return False
|
||||
table: dict[str, dict] = {}
|
||||
for r in (res or {}).get("seasonTeamStats") or []:
|
||||
code = KBO_SHORT_TO_CODE.get(r.get("teamShortName", ""))
|
||||
if code:
|
||||
table[code] = {
|
||||
"rank": r.get("ranking"),
|
||||
"w": r.get("winGameCount"), "d": r.get("drawnGameCount"),
|
||||
"l": r.get("loseGameCount"), "wra": r.get("wra"),
|
||||
"gb": r.get("gameBehind"), "last5": r.get("lastFiveGames"),
|
||||
"avg": r.get("offenseHra"), "era": r.get("defenseEra"),
|
||||
}
|
||||
if not table:
|
||||
return False
|
||||
await _upsert(db, "standings:kbo", table)
|
||||
return True
|
||||
|
||||
|
||||
# statsapi division.id → 순위표 그룹 키 (AL/NL × 동·중·서)
|
||||
_MLB_DIV = {201: "ALE", 202: "ALC", 200: "ALW", 204: "NLE", 205: "NLC", 203: "NLW"}
|
||||
|
||||
|
||||
async def _refresh_standings_mlb(db) -> bool:
|
||||
import httpx
|
||||
|
||||
year = datetime.now(KST).year
|
||||
table: dict[str, dict] = {}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as c:
|
||||
for lid in (103, 104): # AL, NL
|
||||
r = await c.get(
|
||||
f"{settings.mlb_api_base}/v1/standings?leagueId={lid}&season={year}"
|
||||
)
|
||||
r.raise_for_status()
|
||||
for rec_div in r.json().get("records") or []:
|
||||
div = _MLB_DIV.get((rec_div.get("division") or {}).get("id"))
|
||||
for t in rec_div.get("teamRecords") or []:
|
||||
code = MLB_ID_TO_CODE.get((t.get("team") or {}).get("id"))
|
||||
if code:
|
||||
table[code] = {
|
||||
"div": div,
|
||||
"rank": int(t.get("divisionRank") or 0) or None,
|
||||
"w": t.get("wins"), "d": 0, "l": t.get("losses"),
|
||||
"wra": t.get("winningPercentage"),
|
||||
"gb": t.get("gamesBack"),
|
||||
"last5": None, "avg": None, "era": None,
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning("mlb standings 실패: %s", e)
|
||||
return False
|
||||
if not table:
|
||||
return False
|
||||
await _upsert(db, "standings:mlb", table)
|
||||
return True
|
||||
|
||||
|
||||
async def refresh_baseball_details(db, league: str, matches: list[Match]) -> None:
|
||||
"""임박(48h 내) 미종료 경기 프리뷰 + 리그 순위 캐시 갱신."""
|
||||
horizon = now_utc() + timedelta(hours=48)
|
||||
targets = [
|
||||
m for m in matches
|
||||
if m.result_outcome is None
|
||||
and m.status != "cancelled"
|
||||
and ensure_aware(m.kickoff_at) <= horizon
|
||||
]
|
||||
if league == "kbo":
|
||||
n = await _refresh_previews_kbo(db, targets)
|
||||
await _refresh_standings_kbo(db)
|
||||
elif league == "mlb":
|
||||
n = await _refresh_previews_mlb(db, targets)
|
||||
await _refresh_standings_mlb(db)
|
||||
else:
|
||||
return
|
||||
await db.commit()
|
||||
log.info("baseball details(%s): 프리뷰 %d경기 캐싱", league, n)
|
||||
|
||||
|
||||
# ── extras 조회 (라우터 — 캐시만) ──────────────────────────────
|
||||
# MLS 도 동일 캐시 키(preview:{id}, standings:mls)를 쓰므로 여기서 함께 서빙.
|
||||
async def get_extras(db, matches: list[Match]) -> dict[str, dict]:
|
||||
standings_cache: dict[str, dict] = {}
|
||||
out: dict[str, dict] = {}
|
||||
for m in matches:
|
||||
if m.league not in ("kbo", "mlb", "mls"):
|
||||
continue
|
||||
if m.league not in standings_cache:
|
||||
row = await db.get(DataCache, f"standings:{m.league}")
|
||||
standings_cache[m.league] = row.payload if row else {}
|
||||
extras: dict = {}
|
||||
prev = await db.get(DataCache, f"preview:{m.match_id}")
|
||||
if prev:
|
||||
extras.update(prev.payload)
|
||||
st = standings_cache[m.league]
|
||||
st_a, st_b = st.get(m.team_a_code), st.get(m.team_b_code)
|
||||
if st_a or st_b:
|
||||
extras["standings"] = {"a": st_a, "b": st_b}
|
||||
if extras:
|
||||
out[m.match_id] = extras
|
||||
return out
|
||||
|
||||
|
||||
# ── 라이브 필드 뷰 ─────────────────────────────────────────────
|
||||
_LIVE_TTL_SEC = 15.0
|
||||
_live_cache: dict[str, tuple[float, dict]] = {}
|
||||
|
||||
FIELD_POSITIONS = (
|
||||
"포수", "1루수", "2루수", "3루수", "유격수", "좌익수", "중견수", "우익수",
|
||||
)
|
||||
# MLB 포지션 약어 → 한글 (필드 좌표 키와 통일)
|
||||
MLB_POS = {
|
||||
"C": "포수", "1B": "1루수", "2B": "2루수", "3B": "3루수", "SS": "유격수",
|
||||
"LF": "좌익수", "CF": "중견수", "RF": "우익수",
|
||||
}
|
||||
|
||||
|
||||
def _current_slots(batters: list[dict]) -> list[dict]:
|
||||
by_order: dict[int, dict] = {}
|
||||
for b in batters or []:
|
||||
o = b.get("batOrder")
|
||||
if o is None:
|
||||
continue
|
||||
cur = by_order.get(o)
|
||||
if cur is None or (b.get("seqno") or 0) > (cur.get("seqno") or 0):
|
||||
by_order[o] = b
|
||||
return [by_order[o] for o in sorted(by_order)]
|
||||
|
||||
|
||||
def _batter_out(b: dict) -> dict:
|
||||
return {
|
||||
"order": b.get("batOrder"),
|
||||
"name": b.get("name", ""),
|
||||
"pos": b.get("posName", ""),
|
||||
"avg": b.get("seasonHra"),
|
||||
"sub": (b.get("seqno") or 1) > 1,
|
||||
}
|
||||
|
||||
|
||||
def _transform_naver_relay(t: dict) -> dict:
|
||||
gs = t.get("currentGameState") or {}
|
||||
home_batting = str(t.get("homeOrAway")) == "1"
|
||||
home_lu = t.get("homeLineup") or {}
|
||||
away_lu = t.get("awayLineup") or {}
|
||||
offense_lu = home_lu if home_batting else away_lu
|
||||
defense_lu = away_lu if home_batting else home_lu
|
||||
|
||||
offense = _current_slots(offense_lu.get("batter"))
|
||||
defense = _current_slots(defense_lu.get("batter"))
|
||||
pitchers = defense_lu.get("pitcher") or []
|
||||
pitcher = max(pitchers, key=lambda p: p.get("seqno") or 0) if pitchers else {}
|
||||
batter_code = str(gs.get("batter") or "")
|
||||
batter = next((b for b in offense if str(b.get("pcode")) == batter_code), None)
|
||||
|
||||
return {
|
||||
"available": True,
|
||||
"inn": t.get("inn"),
|
||||
"half": "B" if home_batting else "T",
|
||||
"score": {"away": gs.get("awayScore"), "home": gs.get("homeScore")},
|
||||
"bso": {"b": gs.get("ball"), "s": gs.get("strike"), "o": gs.get("out")},
|
||||
"bases": [
|
||||
str(gs.get(k) or "0") != "0" for k in ("base1", "base2", "base3")
|
||||
],
|
||||
"batter": _batter_out(batter) if batter else None,
|
||||
"pitcher": {
|
||||
"name": pitcher.get("name", ""),
|
||||
"ballCount": pitcher.get("ballCount"),
|
||||
} if pitcher else None,
|
||||
"vsRecord": t.get("pitcherVsBatterCareerStats") or "",
|
||||
"defense": [
|
||||
{"pos": b.get("posName"), "name": b.get("name", "")}
|
||||
for b in defense if b.get("posName") in FIELD_POSITIONS
|
||||
],
|
||||
"offenseLineup": [_batter_out(b) for b in offense],
|
||||
}
|
||||
|
||||
|
||||
# ── KBO 문자중계 (네이버 relay textRelays) ─────────────────────
|
||||
# 네이버는 이미 한국어 중계문을 타석 단위로 묶어 준다 — 번역 없이 그대로 서빙.
|
||||
# relay 기본 응답은 "현재 이닝"만 담고, 지난 이닝은 ?inning=N 으로 따로 받아야 한다.
|
||||
|
||||
# textOptions.type → 프론트 표시용 의미 (네이버 숫자 코드를 UI 에 노출하지 않기 위함)
|
||||
_RELAY_KIND = {
|
||||
0: "inning", # 이닝 시작 ("1회초 한화 공격")
|
||||
1: "pitch", # 투구 ("3구 헛스윙")
|
||||
2: "sub", # 선수 교체
|
||||
7: "note", # 투수판 이탈·비디오 판독·마운드 방문
|
||||
8: "batter", # 타석 시작 (그룹 제목과 동일)
|
||||
13: "result", # 타석 결과
|
||||
23: "result", # 타석 결과 (주자 있을 때 표기)
|
||||
14: "run", # 주루
|
||||
24: "run", # 주루·홈인
|
||||
99: "end", # 경기 종료·승리투수
|
||||
}
|
||||
# 라이브 페이로드에 싣는 최근 타석 수 — 전 이닝(약 80타석)을 매 폴링마다 보내지 않기 위한 상한
|
||||
_RELAY_MAX_GROUPS = 40
|
||||
# 지난 이닝 중계 캐시 (경기 → {이닝: 그룹[]}). 종료된 이닝은 불변이라 재조회하지 않는다.
|
||||
_relay_innings: dict[str, dict[int, list[dict]]] = {}
|
||||
|
||||
|
||||
def _score_total(gs: dict) -> int | None:
|
||||
try:
|
||||
return int(gs.get("homeScore") or 0) + int(gs.get("awayScore") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _relay_commentary(groups: list[dict]) -> list[dict]:
|
||||
"""relay 그룹 → 문자중계 [{inn, half, no, title, events[]}] (최신 타석 순).
|
||||
|
||||
득점 표시는 텍스트 패턴 대신 옵션마다 실린 currentGameState 의 점수 합 변화로
|
||||
판정한다 — 홈런·적시타뿐 아니라 홈인·실책 득점·밀어내기까지 정확히 잡힌다.
|
||||
"""
|
||||
out: list[dict] = []
|
||||
prev_total: int | None = None
|
||||
for r in sorted(groups, key=lambda g: g.get("no") or 0):
|
||||
title = (r.get("title") or "").strip()
|
||||
if not title.strip("="): # 경기 종료 구분선 그룹 — 제목 없이 내용만
|
||||
title = ""
|
||||
events: list[dict] = []
|
||||
for o in sorted(r.get("textOptions") or [], key=lambda o: o.get("seqno") or 0):
|
||||
total = _score_total(o.get("currentGameState") or {})
|
||||
scored = prev_total is not None and total is not None and total > prev_total
|
||||
if total is not None:
|
||||
prev_total = total
|
||||
text = (o.get("text") or "").strip()
|
||||
# 구분선("=====")·제목 중복 라인은 버린다
|
||||
if not text or text == title or not text.strip("="):
|
||||
continue
|
||||
ev = {"kind": _RELAY_KIND.get(o.get("type"), "note"), "text": text}
|
||||
if scored:
|
||||
ev["score"] = True
|
||||
events.append(ev)
|
||||
# 이닝 헤더 그룹은 events 가 비지만 구분선으로 쓰이므로 남긴다
|
||||
if not title and not events:
|
||||
continue
|
||||
out.append({
|
||||
"inn": r.get("inn"),
|
||||
"half": "B" if str(r.get("homeOrAway")) == "1" else "T",
|
||||
"no": r.get("no"),
|
||||
"title": title,
|
||||
"events": events,
|
||||
})
|
||||
out.reverse()
|
||||
# 종료된 경기는 폴링이 멈추므로 전 경기 중계를 그대로 준다(다시보기).
|
||||
# 진행 중엔 20초마다 다시 실리므로 최근 타석만 — 스크롤 분량으로도 충분하다.
|
||||
ended = any(e["kind"] == "end" for g in out for e in g["events"])
|
||||
return out if ended else out[:_RELAY_MAX_GROUPS]
|
||||
|
||||
|
||||
async def _kbo_relay_groups(client, m: Match, gid: str, t: dict) -> list[dict]:
|
||||
"""전 이닝 문자중계 그룹. 이닝별 조회를 캐시와 병렬 조회로 채운다.
|
||||
|
||||
현재·직전 이닝은 매번 다시 받는다 — 기본 응답은 진행 중인 half 만 담을 때가 있고,
|
||||
이닝이 넘어간 직후엔 직전 이닝 마지막 타석이 캐시에 안 들어와 있을 수 있다.
|
||||
그보다 앞선 이닝은 더 변하지 않으므로 한 번만 받는다.
|
||||
"""
|
||||
cur_groups = t.get("textRelays") or []
|
||||
cur = int(t.get("inn") or max((g.get("inn") or 0) for g in cur_groups) or 1)
|
||||
if len(_relay_innings) > 40: # 하루치 경기 이상 쌓이면 통째로 비움
|
||||
_relay_innings.clear()
|
||||
cache = _relay_innings.setdefault(m.match_id, {})
|
||||
volatile = {cur - 1, cur}
|
||||
need = [i for i in range(1, cur + 1) if i in volatile or i not in cache]
|
||||
if need:
|
||||
res = await asyncio.gather(
|
||||
*(
|
||||
_naver_get(client, f"/schedule/games/{gid}/relay?inning={i}")
|
||||
for i in need
|
||||
),
|
||||
return_exceptions=True,
|
||||
)
|
||||
for i, r in zip(need, res):
|
||||
if isinstance(r, BaseException) or not r:
|
||||
continue # 실패한 이닝은 기존 캐시 유지 → 다음 폴링에서 재시도
|
||||
cache[i] = ((r.get("textRelayData") or {}).get("textRelays")) or []
|
||||
cache.setdefault(cur, cur_groups) # 현재 이닝 조회 실패 시 기본 응답으로 대체
|
||||
return [g for i in sorted(cache) for g in cache[i]]
|
||||
|
||||
|
||||
# ── MLB 문자중계 (feed/live allPlays 규칙 기반 한글 변환) ──────
|
||||
# MLB 중계문은 정형 영문 템플릿 — eventType·투구 호칭을 한글로 매핑하고
|
||||
# 타구 방향·주자 진루는 정규식으로 뽑는다. 미지원 문장은 영문 원문 폴백.
|
||||
|
||||
_MLB_PITCH_KO = {
|
||||
"Ball": "볼", "Ball In Dirt": "볼 (원바운드)", "Intent Ball": "고의사구 볼",
|
||||
"Called Strike": "스트라이크", "Swinging Strike": "헛스윙",
|
||||
"Swinging Strike (Blocked)": "헛스윙", "Foul": "파울", "Foul Tip": "파울팁",
|
||||
"Foul Bunt": "번트 파울", "Missed Bunt": "번트 헛스윙",
|
||||
"Hit By Pitch": "몸에 맞는 볼", "Pitchout": "피치아웃",
|
||||
"Automatic Ball": "자동 볼 (피치클록)", "Automatic Strike": "자동 스트라이크 (피치클록)",
|
||||
"In play, out(s)": "타격", "In play, no out": "타격", "In play, run(s)": "타격",
|
||||
}
|
||||
_MLB_EVENT_KO = {
|
||||
"Single": "1루타", "Double": "2루타", "Triple": "3루타", "Home Run": "홈런",
|
||||
"Walk": "볼넷", "Intent Walk": "고의4구", "Strikeout": "삼진 아웃",
|
||||
"Strikeout Double Play": "삼진 (병살)", "Groundout": "땅볼 아웃",
|
||||
"Flyout": "플라이 아웃", "Lineout": "직선타 아웃", "Pop Out": "팝플라이 아웃",
|
||||
"Bunt Groundout": "번트 아웃", "Bunt Pop Out": "번트 팝플라이 아웃",
|
||||
"Forceout": "포스 아웃", "Grounded Into DP": "병살타", "Double Play": "병살",
|
||||
"Triple Play": "삼중살", "Sac Fly": "희생플라이", "Sac Bunt": "희생번트",
|
||||
"Field Error": "실책 출루", "Fielders Choice": "야수선택",
|
||||
"Fielders Choice Out": "야수선택 아웃", "Hit By Pitch": "몸에 맞는 볼",
|
||||
"Catcher Interference": "포수 타격방해", "Runner Out": "주자 아웃",
|
||||
"Caught Stealing 2B": "도루자 (2루)", "Caught Stealing 3B": "도루자 (3루)",
|
||||
"Caught Stealing Home": "도루자 (홈)", "Pickoff 1B": "견제사 (1루)",
|
||||
"Pickoff 2B": "견제사 (2루)", "Pickoff 3B": "견제사 (3루)",
|
||||
}
|
||||
# 타구 방향/처리 야수 — desc 에서 가장 먼저 나오는 표현 (긴 표현 우선 매칭)
|
||||
_MLB_DIR_KO = [
|
||||
("left fielder", "좌익수"), ("left field", "좌익수"),
|
||||
("center fielder", "중견수"), ("center field", "중견수"),
|
||||
("right fielder", "우익수"), ("right field", "우익수"),
|
||||
("shortstop", "유격수"), ("first baseman", "1루수"),
|
||||
("second baseman", "2루수"), ("third baseman", "3루수"),
|
||||
("first base", "1루수"), ("second base", "2루수"), ("third base", "3루수"),
|
||||
("pitcher", "투수"), ("catcher", "포수"),
|
||||
]
|
||||
# 대문자 시작 단어 연속 = 선수명 ("J.P. Crawford", "Wenceel Pérez").
|
||||
# 마침표는 이니셜("J.P.")에만 허용 — 일반 단어 끝 마침표를 막아 문장 경계를 넘지 않게 한다.
|
||||
_MLB_NAME_W = r"(?:(?:[A-ZÀ-Ý]\.)+|[A-ZÀ-Ý][\w'\-]*)"
|
||||
_MLB_NAME = rf"{_MLB_NAME_W}(?: {_MLB_NAME_W})*"
|
||||
_BASE_KO = {"2nd": "2루", "3rd": "3루", "home": "홈"}
|
||||
|
||||
|
||||
def _mlb_direction(desc: str) -> str:
|
||||
hits = [(desc.find(en), ko) for en, ko in _MLB_DIR_KO if en in desc]
|
||||
return min(hits)[1] if hits else ""
|
||||
|
||||
|
||||
def _mlb_runs(desc: str) -> list[dict]:
|
||||
"""desc 의 주자 이동 문장 → run 이벤트 (홈인·진루·주루사)."""
|
||||
out: list[dict] = []
|
||||
for nm in re.findall(rf"({_MLB_NAME}) scores\b", desc):
|
||||
out.append({"kind": "run", "text": f"{nm} : 홈인"})
|
||||
for nm, base in re.findall(rf"({_MLB_NAME})(?: advances)? to (2nd|3rd)\b", desc):
|
||||
out.append({"kind": "run", "text": f"{nm} : {_BASE_KO[base]}까지 진루"})
|
||||
for nm, base in re.findall(rf"({_MLB_NAME}) out at (2nd|3rd|home)\b", desc):
|
||||
out.append({"kind": "run", "text": f"{nm} : {_BASE_KO[base]}에서 아웃"})
|
||||
seen: set[str] = set() # "to 3rd"+"advances to 3rd" 같은 중복 문장 제거
|
||||
return [e for e in out if not (e["text"] in seen or seen.add(e["text"]))]
|
||||
|
||||
|
||||
def _tr_mlb_result(res: dict, batter: str) -> str:
|
||||
event, desc = res.get("event") or "", res.get("description") or ""
|
||||
ko = _MLB_EVENT_KO.get(event)
|
||||
if not ko: # 미지원 이벤트 — 영문 원문 폴백
|
||||
return f"{batter} : {desc}" if desc else f"{batter} : {event}"
|
||||
if event == "Strikeout":
|
||||
if "called out on strikes" in desc:
|
||||
ko += " (루킹)"
|
||||
elif "strikes out swinging" in desc:
|
||||
ko += " (헛스윙)"
|
||||
direction = _mlb_direction(desc)
|
||||
return f"{batter} : {direction} {ko}".replace(" ", " ").replace(" : ", " : ")
|
||||
|
||||
|
||||
def _tr_mlb_action(det: dict) -> tuple[str, str] | None:
|
||||
"""playEvents 액션 → (kind, text). None 이면 표시하지 않음 (노이즈)."""
|
||||
event, desc = det.get("event") or "", det.get("description") or ""
|
||||
if event in ("Game Advisory", "Batter Timeout") or not (event or desc):
|
||||
return None
|
||||
if event.startswith("Stolen Base"):
|
||||
base = "2루" if event.endswith("2B") else "3루" if event.endswith("3B") else "홈"
|
||||
nm = re.match(rf"({_MLB_NAME}) steals", desc)
|
||||
who = nm.group(1) if nm else ""
|
||||
return "run", f"{who} : 도루로 {base}까지 진루".replace(" ", " ")
|
||||
if event.startswith("Caught Stealing"):
|
||||
return "run", _MLB_EVENT_KO.get(event, "도루자")
|
||||
if event == "Wild Pitch":
|
||||
return "note", "폭투"
|
||||
if event == "Passed Ball":
|
||||
return "note", "포일"
|
||||
if event == "Mound Visit":
|
||||
return "note", "마운드 방문"
|
||||
if event in ("Pitching Substitution", "Offensive Substitution",
|
||||
"Defensive Sub", "Defensive Substitution"):
|
||||
label = {
|
||||
"Pitching Substitution": "투수 교체",
|
||||
"Offensive Substitution": "대타" if "hitter" in desc else "대주자",
|
||||
}.get(event, "수비 교체")
|
||||
# "Pitching Change: A replaces B." / "...: Pinch-hitter A replaces
|
||||
# right fielder B, batting 2nd..." → "{label} — B → A"
|
||||
body = desc.split(": ", 1)[-1].rstrip(".")
|
||||
if " replaces " in body:
|
||||
new, old = body.split(" replaces ", 1)
|
||||
new = re.sub(r"^Pinch-(?:hitter|runner) ", "", new)
|
||||
old = re.sub(r"^[a-z][a-z ]* ", "", old) # 포지션 수식어 제거
|
||||
old = old.split(", batting")[0].split(" because")[0]
|
||||
return "sub", f"{label} — {old} → {new}"
|
||||
return "sub", desc
|
||||
if event == "Defensive Switch":
|
||||
return None # 포지션 이동 — 중계 피드에선 노이즈
|
||||
if desc.startswith("Pickoff Attempt"):
|
||||
return "note", "견제 시도"
|
||||
if desc.startswith("Pitcher Step Off"):
|
||||
return None
|
||||
return "note", desc or event # 미지원 액션 — 영문 폴백
|
||||
|
||||
|
||||
def _transform_mlb_relay(feed: dict, m: Match) -> list[dict]:
|
||||
"""allPlays → 문자중계 그룹 (KBO relay 와 동일 스키마, 최신 타석순)."""
|
||||
plays = ((feed.get("liveData") or {}).get("plays") or {}).get("allPlays") or []
|
||||
away_s = m.team_a_short or m.team_a_code
|
||||
home_s = m.team_b_short or m.team_b_code
|
||||
groups: list[dict] = []
|
||||
prev_half: tuple | None = None
|
||||
prev_total = 0
|
||||
no = 0
|
||||
for p in plays:
|
||||
about = p.get("about") or {}
|
||||
inn = about.get("inning")
|
||||
top = about.get("halfInning") == "top"
|
||||
if (inn, top) != prev_half: # 이닝 구분 헤더 (KBO 와 동일한 빈 그룹)
|
||||
prev_half = (inn, top)
|
||||
no += 1
|
||||
groups.append({
|
||||
"inn": inn, "half": "T" if top else "B", "no": no,
|
||||
"title": f"{inn}회{'초' if top else '말'} {away_s if top else home_s} 공격",
|
||||
"events": [],
|
||||
})
|
||||
events: list[dict] = []
|
||||
for ev in p.get("playEvents") or []:
|
||||
det = ev.get("details") or {}
|
||||
if ev.get("isPitch"):
|
||||
call = det.get("description") or ""
|
||||
n = ev.get("pitchNumber")
|
||||
ko = _MLB_PITCH_KO.get(call, call)
|
||||
events.append({"kind": "pitch", "text": f"{n}구 {ko}" if n else ko})
|
||||
else:
|
||||
ke = _tr_mlb_action(det)
|
||||
if ke:
|
||||
events.append({"kind": ke[0], "text": ke[1]})
|
||||
events += _mlb_runs(det.get("description") or "") # 폭투 득점 등
|
||||
res = p.get("result") or {}
|
||||
batter = ((p.get("matchup") or {}).get("batter") or {}).get("fullName") or ""
|
||||
if res.get("event"):
|
||||
events.append({"kind": "result", "text": _tr_mlb_result(res, batter)})
|
||||
events += _mlb_runs(res.get("description") or "")
|
||||
# 득점 판정 — 플레이 종료 시점 점수 합 변화 (KBO 와 동일 원리)
|
||||
if about.get("isComplete"):
|
||||
total = int(res.get("awayScore") or 0) + int(res.get("homeScore") or 0)
|
||||
if total > prev_total:
|
||||
marked = False
|
||||
for e in events:
|
||||
if e["text"].endswith("홈인"):
|
||||
e["score"] = True
|
||||
marked = True
|
||||
if not marked and events: # 홈인 문장 미파싱 (솔로 홈런 등) — 결과 라인 강조
|
||||
events[-1]["score"] = True
|
||||
# 홈런은 타자 본인 득점이 홈인 문장에 없음 — 결과 라인도 강조
|
||||
if res.get("event") == "Home Run":
|
||||
for e in events:
|
||||
if e["kind"] == "result":
|
||||
e["score"] = True
|
||||
prev_total = total
|
||||
if not events:
|
||||
continue
|
||||
no += 1
|
||||
groups.append({
|
||||
"inn": inn, "half": "T" if top else "B", "no": no,
|
||||
"title": batter, "events": events,
|
||||
})
|
||||
groups.reverse()
|
||||
ended = ((feed.get("gameData") or {}).get("status") or {}).get(
|
||||
"abstractGameState") == "Final"
|
||||
return groups if ended else groups[:_RELAY_MAX_GROUPS]
|
||||
|
||||
|
||||
def _transform_mlb_feed(feed: dict) -> dict:
|
||||
ld = feed.get("liveData") or {}
|
||||
ls = ld.get("linescore") or {}
|
||||
box = (ld.get("boxscore") or {}).get("teams") or {}
|
||||
half_top = (ls.get("inningHalf") or "").lower() == "top"
|
||||
offense_side, defense_side = ("away", "home") if half_top else ("home", "away")
|
||||
off = ls.get("offense") or {}
|
||||
defn = ls.get("defense") or {}
|
||||
|
||||
def _players(side: str) -> dict:
|
||||
return (box.get(side) or {}).get("players") or {}
|
||||
|
||||
# 수비 배치: boxscore players 의 position + 현재 출장(battingOrder 존재)
|
||||
defense = []
|
||||
for p in _players(defense_side).values():
|
||||
pos = MLB_POS.get(((p.get("position") or {}).get("abbreviation") or ""))
|
||||
name = ((p.get("person") or {}).get("fullName")) or ""
|
||||
if pos and name and p.get("gameStatus", {}).get("isCurrentBatter") is not None:
|
||||
defense.append({"pos": pos, "name": name})
|
||||
# 같은 포지션 중복(교체) — 마지막 것만
|
||||
dedup: dict[str, dict] = {f["pos"]: f for f in defense}
|
||||
|
||||
order_raw = (box.get(offense_side) or {}).get("battingOrder") or []
|
||||
id_to_player = _players(offense_side)
|
||||
lineup = []
|
||||
for i, pid in enumerate(order_raw[:9]):
|
||||
p = id_to_player.get(f"ID{pid}") or {}
|
||||
lineup.append({
|
||||
"order": i + 1,
|
||||
"name": ((p.get("person") or {}).get("fullName")) or "",
|
||||
"pos": MLB_POS.get(((p.get("position") or {}).get("abbreviation") or ""), ""),
|
||||
"avg": None,
|
||||
"sub": False,
|
||||
})
|
||||
|
||||
batter_name = ((off.get("batter") or {}).get("fullName")) or ""
|
||||
batter = next((b for b in lineup if b["name"] == batter_name), None)
|
||||
pitcher = (defn.get("pitcher") or {}).get("fullName") or \
|
||||
(off.get("pitcher") or {}).get("fullName") or ""
|
||||
|
||||
return {
|
||||
"available": bool(ls.get("currentInning")),
|
||||
"inn": ls.get("currentInning"),
|
||||
"half": "T" if half_top else "B",
|
||||
"score": {
|
||||
"away": ((ls.get("teams") or {}).get("away") or {}).get("runs"),
|
||||
"home": ((ls.get("teams") or {}).get("home") or {}).get("runs"),
|
||||
},
|
||||
"bso": {"b": ls.get("balls"), "s": ls.get("strikes"), "o": ls.get("outs")},
|
||||
"bases": [bool(off.get("first")), bool(off.get("second")), bool(off.get("third"))],
|
||||
"batter": batter or ({"order": None, "name": batter_name} if batter_name else None),
|
||||
"pitcher": {"name": pitcher, "ballCount": None} if pitcher else None,
|
||||
"vsRecord": "",
|
||||
"defense": list(dedup.values()),
|
||||
"offenseLineup": lineup,
|
||||
}
|
||||
|
||||
|
||||
async def fetch_live(m: Match) -> dict:
|
||||
"""라이브 필드 뷰 페이로드 (리그별 소스). 미게시면 available=False."""
|
||||
import httpx
|
||||
|
||||
cached = _live_cache.get(m.match_id)
|
||||
if cached and time.monotonic() - cached[0] < _LIVE_TTL_SEC:
|
||||
return cached[1]
|
||||
payload: dict = {"available": False}
|
||||
try:
|
||||
if m.league == "kbo":
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
found = await _naver_get_first_with_id(client, m, "relay")
|
||||
gid, res = found if found else ("", None)
|
||||
t = (res or {}).get("textRelayData")
|
||||
if t:
|
||||
payload = _transform_naver_relay(t)
|
||||
try: # 문자중계는 부가 정보 — 실패해도 필드 뷰는 그대로 서빙
|
||||
payload["relay"] = _relay_commentary(
|
||||
await _kbo_relay_groups(client, m, gid, t)
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning("kbo 문자중계 실패 %s: %s", m.match_id, e)
|
||||
elif m.league == "mlb":
|
||||
# gamePk 를 일정에서 재조회 — MLB 일정 API 의 date 는 미국 날짜라
|
||||
# KST 기준 하루 전~당일 범위로 조회 후 dateKst 로 정확히 매칭.
|
||||
kst = ensure_aware(m.kickoff_at).astimezone(KST)
|
||||
date_kst = kst.strftime("%Y%m%d")
|
||||
start = (kst.date() - timedelta(days=1)).isoformat()
|
||||
async with httpx.AsyncClient(timeout=15) as c:
|
||||
r = await c.get(
|
||||
f"{settings.mlb_api_base}/v1/schedule?sportId=1"
|
||||
f"&startDate={start}&endDate={kst.date().isoformat()}"
|
||||
)
|
||||
r.raise_for_status()
|
||||
# 더블헤더 대응: 같은 날짜·팀쌍 경기를 시작시각순으로 모아
|
||||
# match_id 의 차수(seq)에 해당하는 경기를 고른다.
|
||||
cands: list[tuple[str, int]] = []
|
||||
for day in r.json().get("dates") or []:
|
||||
for g in day.get("games") or []:
|
||||
a = MLB_ID_TO_CODE.get((g["teams"]["away"]["team"] or {}).get("id"))
|
||||
b = MLB_ID_TO_CODE.get((g["teams"]["home"]["team"] or {}).get("id"))
|
||||
gd = g.get("gameDate")
|
||||
if not a or not b or not gd:
|
||||
continue
|
||||
g_kst = (
|
||||
datetime.fromisoformat(gd.replace("Z", "+00:00"))
|
||||
.astimezone(KST).strftime("%Y%m%d")
|
||||
)
|
||||
if a == m.team_a_code and b == m.team_b_code and g_kst == date_kst:
|
||||
cands.append((gd, g.get("gamePk")))
|
||||
cands.sort()
|
||||
idx = match_seq(m.match_id) - 1
|
||||
pk = cands[idx][1] if idx < len(cands) else None
|
||||
if pk:
|
||||
r2 = await c.get(f"{settings.mlb_api_base}/v1.1/game/{pk}/feed/live")
|
||||
r2.raise_for_status()
|
||||
feed = r2.json()
|
||||
payload = _transform_mlb_feed(feed)
|
||||
try: # 문자중계는 부가 정보 — 실패해도 필드 뷰는 그대로 서빙
|
||||
relay = _transform_mlb_relay(feed, m)
|
||||
if relay:
|
||||
payload["relay"] = relay
|
||||
payload["available"] = True # 종료 경기 다시보기 지원
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning("mlb 문자중계 실패 %s: %s", m.match_id, e)
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning("live 실패 %s: %s", m.match_id, e)
|
||||
payload = {"available": False}
|
||||
_live_cache[m.match_id] = (time.monotonic(), payload)
|
||||
return payload
|
||||
153
backend/app/services/baseball_fetch.py
Normal file
@ -0,0 +1,153 @@
|
||||
"""야구 일정·결과 수집 — KBO(네이버 비공식) + MLB(공식 Stats API).
|
||||
|
||||
반환 표준 레코드 (두 리그 공통):
|
||||
{league, teamA(원정), teamB(홈), dateKst 'YYYYMMDD', kickoffKst ISO(+09:00),
|
||||
venue, cancelled, [scoreA, scoreB]}
|
||||
야구는 같은 두 팀이 시즌 내 반복 대결 → 경기 식별에 반드시 dateKst 포함.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from ..config import settings
|
||||
from ..teams_baseball import KBO_TEAMS, MLB_ID_TO_CODE
|
||||
|
||||
log = logging.getLogger("triplepick.baseball")
|
||||
|
||||
KST = timezone(timedelta(hours=9))
|
||||
UA = {
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/126.0 Safari/537.36"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
# ── KBO (네이버) ───────────────────────────────────────────────
|
||||
async def _fetch_kbo(days_back: int, days_ahead: int) -> list[dict]:
|
||||
import httpx
|
||||
|
||||
today = datetime.now(KST).date()
|
||||
url = (
|
||||
f"{settings.naver_api_base}/schedule/games"
|
||||
"?fields=basic,stadium,statusNum"
|
||||
"&upperCategoryId=kbaseball&categoryId=kbo"
|
||||
f"&fromDate={(today - timedelta(days=days_back)).isoformat()}"
|
||||
f"&toDate={(today + timedelta(days=days_ahead)).isoformat()}&size=500"
|
||||
)
|
||||
async with httpx.AsyncClient(timeout=20) as c:
|
||||
r = await c.get(url, headers=UA)
|
||||
r.raise_for_status()
|
||||
games = (r.json().get("result") or {}).get("games") or []
|
||||
|
||||
out: list[dict] = []
|
||||
for g in games:
|
||||
a = (g.get("awayTeamCode") or "").upper()
|
||||
b = (g.get("homeTeamCode") or "").upper()
|
||||
if a not in KBO_TEAMS or b not in KBO_TEAMS:
|
||||
continue # 올스타/시범 등 제외
|
||||
dt = g.get("gameDateTime") # KST, 오프셋 없음
|
||||
if not dt:
|
||||
continue
|
||||
kickoff = datetime.fromisoformat(dt).replace(tzinfo=KST)
|
||||
rec = {
|
||||
"league": "kbo",
|
||||
"teamA": a, "teamB": b,
|
||||
"dateKst": kickoff.strftime("%Y%m%d"),
|
||||
"kickoffKst": kickoff.isoformat(),
|
||||
"venue": g.get("stadium", ""),
|
||||
"cancelled": bool(g.get("cancel")),
|
||||
}
|
||||
if g.get("statusCode") == "RESULT" and not rec["cancelled"]:
|
||||
sa, sb = g.get("awayTeamScore"), g.get("homeTeamScore")
|
||||
if sa is not None and sb is not None:
|
||||
rec["scoreA"], rec["scoreB"] = int(sa), int(sb)
|
||||
out.append(rec)
|
||||
return out
|
||||
|
||||
|
||||
# ── MLB (공식 Stats API) ───────────────────────────────────────
|
||||
async def _fetch_mlb(days_back: int, days_ahead: int) -> list[dict]:
|
||||
import httpx
|
||||
|
||||
today = datetime.now(KST).date()
|
||||
url = (
|
||||
f"{settings.mlb_api_base}/v1/schedule?sportId=1"
|
||||
f"&startDate={(today - timedelta(days=days_back)).isoformat()}"
|
||||
f"&endDate={(today + timedelta(days=days_ahead)).isoformat()}"
|
||||
)
|
||||
async with httpx.AsyncClient(timeout=20) as c:
|
||||
r = await c.get(url)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
|
||||
out: list[dict] = []
|
||||
for day in data.get("dates") or []:
|
||||
for g in day.get("games") or []:
|
||||
a = MLB_ID_TO_CODE.get((g["teams"]["away"]["team"] or {}).get("id"))
|
||||
b = MLB_ID_TO_CODE.get((g["teams"]["home"]["team"] or {}).get("id"))
|
||||
if not a or not b:
|
||||
continue # 올스타전 등 제외
|
||||
gd = g.get("gameDate") # ISO UTC
|
||||
if not gd:
|
||||
continue
|
||||
kickoff = (
|
||||
datetime.fromisoformat(gd.replace("Z", "+00:00")).astimezone(KST)
|
||||
)
|
||||
status_obj = g.get("status") or {}
|
||||
# detailedState 는 "Final"/"Game Over"/"Completed Early"(우천 조기종료) 등
|
||||
# 종료를 뜻하는 문자열이 여러 갈래라 그것만으로 판정하면 일부가 누락된다.
|
||||
# abstractGameState 는 Preview/Live/Final 세 값만 존재하는 신뢰 가능한 판정 기준.
|
||||
abstract = status_obj.get("abstractGameState", "")
|
||||
detailed = status_obj.get("detailedState", "")
|
||||
rec = {
|
||||
"league": "mlb",
|
||||
"teamA": a, "teamB": b,
|
||||
"dateKst": kickoff.strftime("%Y%m%d"),
|
||||
"kickoffKst": kickoff.isoformat(),
|
||||
"venue": (g.get("venue") or {}).get("name", ""),
|
||||
"cancelled": abstract != "Final" and detailed.startswith(
|
||||
("Postponed", "Cancelled", "Suspended")
|
||||
),
|
||||
"gamePk": g.get("gamePk"), # MLB 라이브 피드 키
|
||||
}
|
||||
if abstract == "Final" and not rec["cancelled"]:
|
||||
sa = g["teams"]["away"].get("score")
|
||||
sb = g["teams"]["home"].get("score")
|
||||
if sa is not None and sb is not None:
|
||||
rec["scoreA"], rec["scoreB"] = int(sa), int(sb)
|
||||
out.append(rec)
|
||||
return out
|
||||
|
||||
|
||||
# ── 공개 API ────────────────────────────────────────────────────
|
||||
async def fetch_baseball_schedule(league: str, days_back: int | None = None) -> list[dict]:
|
||||
"""리그 일정+결과 수집 (취소 포함). 실패 시 빈 리스트 — 기존 일정 유지.
|
||||
|
||||
days_back 기본값은 result_recheck_days(짧은 재확인 윈도우). settle_baseball 은
|
||||
미정산 경기 중 가장 오래된 날짜를 덮도록 더 큰 값을 넘겨, 오래 방치된
|
||||
미정산 경기(수집 실패 등으로 첫 정산을 놓친 경기)도 결국 잡히게 한다.
|
||||
"""
|
||||
days_back = settings.result_recheck_days if days_back is None else days_back
|
||||
try:
|
||||
if league == "kbo":
|
||||
records = await _fetch_kbo(days_back, settings.baseball_days_ahead)
|
||||
elif league == "mlb":
|
||||
records = await _fetch_mlb(days_back, settings.baseball_days_ahead)
|
||||
else:
|
||||
return []
|
||||
# 더블헤더 차수 부여 — sync·정산이 같은 seq 로 경기를 식별한다.
|
||||
from .baseball_sync import assign_seq
|
||||
|
||||
assign_seq(records)
|
||||
log.info("baseball schedule(%s): %d경기 수집", league, len(records))
|
||||
return records
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.error("baseball schedule(%s) 실패: %s — 기존 일정 유지", league, e)
|
||||
return []
|
||||
|
||||
|
||||
async def fetch_baseball_results(league: str, days_back: int | None = None) -> list[dict]:
|
||||
"""확정 스코어만 → [{league, teamA, teamB, dateKst, scoreA, scoreB}]."""
|
||||
return [r for r in await fetch_baseball_schedule(league, days_back) if "scoreA" in r]
|
||||
130
backend/app/services/baseball_sync.py
Normal file
@ -0,0 +1,130 @@
|
||||
"""야구 일정 DB 반영 — (리그, KST 날짜, 팀쌍, 차수) 키 매칭. 워커가 매일 호출.
|
||||
|
||||
- 기존 경기: 키 매칭 → 시각/venue/투표시간 갱신 (결과·예측·crowd 보존)
|
||||
- 신규 경기: 삽입 + crowd 초기화.
|
||||
match_id = {LEAGUE}_{away}_{home}_{yyyymmdd} (더블헤더 2차전은 _2 접미)
|
||||
- 취소(우천 등): 삭제하지 않고 status="cancelled" 로 보존 — 투표·예측 기록 유지,
|
||||
정산·투표·AI 생성에서 제외. 소스가 취소를 번복하면 scheduled 로 복귀.
|
||||
(보강 경기는 새 날짜의 신규 경기로 재등장)
|
||||
- 더블헤더: 같은 (날짜, 팀쌍) 복수 경기를 시작시각순 seq(1,2)로 구분해 모두 등록.
|
||||
- 투표창: 리그 공통 오픈 오프셋(vote_open_hours_before, 기본 -168h)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..config import settings
|
||||
from ..models import CrowdStats, Match
|
||||
from ..teams_baseball import team_info
|
||||
|
||||
log = logging.getLogger("triplepick.baseball")
|
||||
KST = timezone(timedelta(hours=9))
|
||||
|
||||
|
||||
def baseball_opens_at(kickoff: datetime) -> datetime:
|
||||
# 축구와 동일 정책(vote_open_hours_before=168h) — 야구는 일정을 7일치만
|
||||
# 수집하므로 사실상 동기화 즉시 투표 오픈된다.
|
||||
return kickoff - timedelta(hours=settings.vote_open_hours_before)
|
||||
|
||||
|
||||
def baseball_lock_at(kickoff: datetime) -> datetime:
|
||||
return kickoff - timedelta(minutes=settings.vote_lock_minutes_before)
|
||||
|
||||
|
||||
def _date_of(m: Match) -> str:
|
||||
return m.kickoff_at.astimezone(KST).strftime("%Y%m%d") if m.kickoff_at else ""
|
||||
|
||||
|
||||
def match_seq(match_id: str) -> int:
|
||||
"""match_id 의 더블헤더 차수. `..._20260722` → 1, `..._20260722_2` → 2."""
|
||||
tail = match_id.rsplit("_", 1)[-1]
|
||||
return int(tail) if len(tail) <= 2 and tail.isdigit() else 1
|
||||
|
||||
|
||||
def assign_seq(records: list[dict]) -> None:
|
||||
"""같은 (날짜, 팀쌍) 레코드에 시작시각순 seq(1,2,…)를 부여 — 더블헤더 구분."""
|
||||
groups: dict[tuple, list[dict]] = {}
|
||||
for rec in records:
|
||||
groups.setdefault((rec["dateKst"], rec["teamA"], rec["teamB"]), []).append(rec)
|
||||
for recs in groups.values():
|
||||
recs.sort(key=lambda r: r["kickoffKst"])
|
||||
for i, rec in enumerate(recs, start=1):
|
||||
rec["seq"] = i
|
||||
|
||||
|
||||
async def sync_baseball_schedule(
|
||||
db: AsyncSession, league: str, records: list[dict]
|
||||
) -> dict:
|
||||
if not records:
|
||||
return {"updated": 0, "inserted": 0, "skipped": 0, "removed": 0}
|
||||
|
||||
assign_seq(records)
|
||||
|
||||
existing = (
|
||||
await db.execute(select(Match).where(Match.league == league))
|
||||
).scalars().all()
|
||||
by_key: dict[tuple, Match] = {}
|
||||
for m in existing:
|
||||
d, s = _date_of(m), match_seq(m.match_id)
|
||||
by_key[(d, m.team_a_code, m.team_b_code, s)] = m
|
||||
by_key[(d, m.team_b_code, m.team_a_code, s)] = m
|
||||
|
||||
updated = inserted = skipped = removed = 0
|
||||
for rec in records:
|
||||
a, b, d = rec["teamA"], rec["teamB"], rec["dateKst"]
|
||||
seq = rec.get("seq", 1)
|
||||
m = by_key.get((d, a, b, seq))
|
||||
|
||||
if rec.get("cancelled"):
|
||||
# 소프트 취소 — 투표/예측 기록 보존, 정산·투표·AI 대상에서 제외.
|
||||
if m is not None and m.result_outcome is None and m.status != "cancelled":
|
||||
m.status = "cancelled"
|
||||
removed += 1
|
||||
continue
|
||||
|
||||
kickoff = datetime.fromisoformat(rec["kickoffKst"]).astimezone(timezone.utc)
|
||||
if m is not None:
|
||||
if m.result_outcome is not None:
|
||||
skipped += 1
|
||||
continue
|
||||
if m.status == "cancelled":
|
||||
m.status = "scheduled" # 취소 번복 — 시간 기준 상태는 tick 이 복원
|
||||
m.kickoff_at = kickoff
|
||||
m.opens_at = baseball_opens_at(kickoff)
|
||||
m.lock_at = baseball_lock_at(kickoff)
|
||||
if rec.get("venue"):
|
||||
m.venue = rec["venue"]
|
||||
updated += 1
|
||||
else:
|
||||
ta, tb = team_info(league, a), team_info(league, b)
|
||||
match_id = f"{league.upper()}_{a}_{b}_{d}" + (f"_{seq}" if seq > 1 else "")
|
||||
new = Match(
|
||||
match_id=match_id,
|
||||
league=league,
|
||||
round_label="정규시즌",
|
||||
group="",
|
||||
team_a_name=ta["name"], team_a_short=ta["shortName"],
|
||||
team_a_code=ta["code"], team_a_flag=ta["flag"],
|
||||
team_b_name=tb["name"], team_b_short=tb["shortName"],
|
||||
team_b_code=tb["code"], team_b_flag=tb["flag"],
|
||||
venue=rec.get("venue", ""),
|
||||
hook_text=f"{ta['shortName']} vs {tb['shortName']}",
|
||||
kickoff_at=kickoff,
|
||||
opens_at=baseball_opens_at(kickoff),
|
||||
lock_at=baseball_lock_at(kickoff),
|
||||
status="scheduled",
|
||||
)
|
||||
db.add(new)
|
||||
db.add(CrowdStats(match_id=match_id, total=0, team_a_win=0, draw=0, team_b_win=0))
|
||||
by_key[(d, a, b, seq)] = new
|
||||
by_key[(d, b, a, seq)] = new
|
||||
inserted += 1
|
||||
|
||||
await db.commit()
|
||||
result = {"updated": updated, "inserted": inserted, "skipped": skipped, "removed": removed}
|
||||
log.info("baseball sync(%s): %s", league, result)
|
||||
return result
|
||||
114
backend/app/services/email.py
Normal file
@ -0,0 +1,114 @@
|
||||
"""결과 이메일 발송.
|
||||
|
||||
1순위: Azure Communication Services(ACS) Email (endpoint + accesskey).
|
||||
2순위(폴백): SMTP (aiosmtplib).
|
||||
둘 다 미설정이면 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
|
||||
|
||||
|
||||
async def _send_acs(to: str, subject: str, html: str, text: str) -> None:
|
||||
"""Azure Communication Services Email — 키(accesskey) 인증."""
|
||||
from azure.communication.email.aio import EmailClient
|
||||
|
||||
conn = f"endpoint={settings.azure_acs_endpoint};accesskey={settings.azure_acs_accesskey}"
|
||||
message = {
|
||||
"senderAddress": settings.azure_acs_sender,
|
||||
"recipients": {"to": [{"address": to}]},
|
||||
"content": {"subject": subject, "plainText": text, "html": html},
|
||||
}
|
||||
async with EmailClient.from_connection_string(conn) as client:
|
||||
poller = await client.begin_send(message)
|
||||
await poller.result()
|
||||
log.info("email sent via ACS → %s (%s)", to, subject)
|
||||
|
||||
|
||||
async def _send_smtp(to: str, subject: str, html: str, text: str) -> None:
|
||||
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 via SMTP → %s (%s)", to, subject)
|
||||
|
||||
|
||||
async def send_email(to: str, subject: str, html: str, text: str) -> None:
|
||||
if settings.acs_configured:
|
||||
await _send_acs(to, subject, html, text)
|
||||
return
|
||||
if settings.smtp_host:
|
||||
await _send_smtp(to, subject, html, text)
|
||||
return
|
||||
# ACS endpoint/accesskey 만 있고 sender 누락 시 명확히 안내
|
||||
if settings.azure_acs_endpoint and not settings.azure_acs_sender:
|
||||
raise EmailUnavailable("AZURE_ACS_SENDER(검증된 MailFrom 주소) 미설정")
|
||||
raise EmailUnavailable("이메일 미설정 — ACS(endpoint+accesskey+sender) 또는 SMTP 필요")
|
||||
|
||||
|
||||
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
|
||||
319
backend/app/services/football_data.py
Normal file
@ -0,0 +1,319 @@
|
||||
"""축구 데이터 연동 (API-Football 무료 티어) — 예측 프롬프트용 수집·캐싱·조립.
|
||||
|
||||
설계 요약
|
||||
- 수집(refresh): 워커가 다가오는 경기의 팀/H2H 를 무료 한도(100/일·10/분) 안에서
|
||||
점진 갱신해 FootballCache(DB)에 저장. 호출 간 간격(throttle)으로 분당 제한 회피.
|
||||
- 조립(build_data_block): 예측 생성 시 캐시만 읽어 프롬프트 블록을 만든다(외부 호출 X).
|
||||
2026 실제 결과는 우리 DB(matches)에서 직접 계산해 보완(무료 API 가 2026 시즌 차단).
|
||||
- FOOTBALL_API_KEY 미설정이면 전부 no-op → 기존(이름만) 예측으로 자연 폴백.
|
||||
|
||||
무료 티어 제약 우회
|
||||
- season=2026, last=N 파라미터는 막힘 → season(2024,2023) 조회로 폼/H2H 확보.
|
||||
- FIFA 랭킹은 API 에 없음 → 아래 FIFA_RANK(수동 관리, 외부 무료 소스 기반)로 보완.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import or_, select
|
||||
|
||||
from ..config import settings
|
||||
from ..domain import now_utc
|
||||
from ..models import FootballCache, Match
|
||||
|
||||
log = logging.getLogger("triplepick.football")
|
||||
|
||||
# 무료 접근 가능한 시즌(최신 우선). 폼 표본 확보용.
|
||||
_SEASONS = (2024, 2023)
|
||||
|
||||
# FIFA 랭킹(수동 관리) — API-Football 에 없어 외부 무료 소스값을 코드코어로 둔다.
|
||||
# 없는 팀은 블록에서 랭킹 줄을 생략(폴백). 갱신은 가끔 수동.
|
||||
# FIFA 세계랭킹 — 공식 발표 기준(2026-06-11). API-Football 엔 없어 외부값을 코드에 둔다.
|
||||
# 갱신: FIFA 공식 랭킹 새로 나오면 숫자만 교체.
|
||||
FIFA_RANK: dict[str, int] = {
|
||||
"MEX": 13, "RSA": 61, "KOR": 22, "CZE": 43, # A
|
||||
"SUI": 19, "BIH": 63, "CAN": 32, "QAT": 49, # B
|
||||
"SCO": 38, "MAR": 7, "BRA": 6, "HAI": 84, # C
|
||||
"USA": 15, "AUS": 23, "TUR": 26, "PAR": 42, # D
|
||||
"GER": 9, "CIV": 29, "ECU": 28, "CUW": 82, # E
|
||||
"NED": 8, "SWE": 35, "TUN": 56, "JPN": 18, # F
|
||||
"BEL": 10, "EGY": 30, "IRN": 20, "NZL": 85, # G
|
||||
"ESP": 2, "CPV": 67, "KSA": 60, "URU": 17, # H
|
||||
"FRA": 3, "SEN": 16, "IRQ": 57, "NOR": 31, # I
|
||||
"ARG": 1, "ALG": 27, "AUT": 24, "JOR": 64, # J
|
||||
"POR": 5, "COD": 45, "UZB": 50, "COL": 14, # K
|
||||
"ENG": 4, "CRO": 11, "GHA": 73, "PAN": 34, # L
|
||||
}
|
||||
|
||||
# 팀코드→API-Football 팀ID 직접 매핑(이름검색 오인 방지 — 청소년/여자/표기차).
|
||||
# 48개 본선 팀 전체. 2026 본선 확정명단 기준 시니어 대표팀 ID.
|
||||
TEAM_ID_OVERRIDE: dict[str, int] = {
|
||||
"MEX": 16, "RSA": 1531, "KOR": 17, "CZE": 770, # A
|
||||
"SUI": 15, "BIH": 1113, "CAN": 5529, "QAT": 1569, # B
|
||||
"SCO": 1108, "MAR": 31, "BRA": 6, "HAI": 2386, # C
|
||||
"USA": 2384, "AUS": 20, "TUR": 777, "PAR": 2380, # D
|
||||
"GER": 25, "CIV": 1501, "ECU": 2382, "CUW": 5530, # E
|
||||
"NED": 1118, "SWE": 5, "TUN": 28, "JPN": 12, # F
|
||||
"BEL": 1, "EGY": 32, "IRN": 22, "NZL": 4673, # G
|
||||
"ESP": 9, "CPV": 1533, "KSA": 23, "URU": 7, # H
|
||||
"FRA": 2, "SEN": 13, "IRQ": 1567, "NOR": 1090, # I
|
||||
"ARG": 26, "ALG": 1532, "AUT": 775, "JOR": 1548, # J
|
||||
"POR": 27, "COD": 1508, "UZB": 1568, "COL": 8, # K
|
||||
"ENG": 10, "CRO": 3, "GHA": 1504, "PAN": 11, # L
|
||||
}
|
||||
|
||||
|
||||
def _is_senior(name: str) -> bool:
|
||||
"""청소년/여자/올림픽 팀 제외(시니어 대표팀만)."""
|
||||
n = name.upper()
|
||||
if any(b in n for b in ("U23", "U21", "U20", "U19", "U17", "U-23", "WOMEN", "OLYMPIC")):
|
||||
return False
|
||||
return not n.endswith(" W")
|
||||
|
||||
|
||||
def enabled() -> bool:
|
||||
return bool(settings.football_api_key)
|
||||
|
||||
|
||||
# ── 저수준: API 호출 (throttle 포함) ───────────────────────────
|
||||
async def _get(client: httpx.AsyncClient, path: str, **params) -> dict:
|
||||
r = await client.get(
|
||||
settings.football_api_base + path,
|
||||
headers={"x-apisports-key": settings.football_api_key},
|
||||
params=params or None,
|
||||
timeout=25,
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
# API-Football 은 한도초과·레이트·파라미터 오류를 HTTP 200 + errors 로 준다.
|
||||
# 빈 응답을 정상으로 오해해 빈 데이터를 캐싱하지 않도록 여기서 예외 발생.
|
||||
errs = data.get("errors")
|
||||
if errs: # 빈 리스트([])는 정상, 채워진 dict 면 오류
|
||||
raise RuntimeError(f"API-Football error: {errs}")
|
||||
await asyncio.sleep(settings.football_call_interval_sec) # 10/분 제한 회피
|
||||
return data
|
||||
|
||||
|
||||
# ── 캐시 upsert ────────────────────────────────────────────────
|
||||
async def _upsert(db, key: str, payload: dict) -> None:
|
||||
row = await db.get(FootballCache, key)
|
||||
if row:
|
||||
row.payload = payload
|
||||
row.fetched_at = now_utc()
|
||||
else:
|
||||
db.add(FootballCache(key=key, payload=payload, fetched_at=now_utc()))
|
||||
|
||||
|
||||
async def _team_id(db, client, code: str, name: str) -> int | None:
|
||||
"""팀코드→API팀ID. 1)직접매핑 2)캐시 3)이름검색(시니어 대표팀 필터)."""
|
||||
if code in TEAM_ID_OVERRIDE:
|
||||
return TEAM_ID_OVERRIDE[code]
|
||||
cached = await db.get(FootballCache, f"teamid:{code}")
|
||||
if cached and cached.payload.get("id"):
|
||||
return cached.payload["id"]
|
||||
query = name.split(" (")[0].strip() # "Korea Republic (...)" → "Korea Republic"
|
||||
data = await _get(client, "/teams", search=query)
|
||||
nats = [
|
||||
x["team"] for x in (data.get("response") or [])
|
||||
if x["team"].get("national") and _is_senior(x["team"]["name"])
|
||||
]
|
||||
if not nats:
|
||||
return None
|
||||
tid = nats[0]["id"]
|
||||
await _upsert(db, f"teamid:{code}", {"id": tid, "name": nats[0]["name"]})
|
||||
return tid
|
||||
|
||||
|
||||
# ── 팀 베이스라인 수집 → 압축 payload ──────────────────────────
|
||||
async def _fetch_team(db, client, code: str, name: str) -> bool:
|
||||
tid = await _team_id(db, client, code, name)
|
||||
if not tid:
|
||||
log.warning("football: team id 미확인 (%s/%s)", code, name)
|
||||
return False
|
||||
|
||||
sq = await _get(client, "/players/squads", team=tid)
|
||||
players = (sq.get("response") or [{}])[0].get("players", []) if sq.get("response") else []
|
||||
|
||||
fixtures = []
|
||||
for season in _SEASONS:
|
||||
d = await _get(client, "/fixtures", team=tid, season=season)
|
||||
fixtures += d.get("response", [])
|
||||
|
||||
results = []
|
||||
for f in fixtures:
|
||||
g = f["goals"]
|
||||
home, away = f["teams"]["home"], f["teams"]["away"]
|
||||
is_home = home["id"] == tid
|
||||
gf = g["home"] if is_home else g["away"]
|
||||
ga = g["away"] if is_home else g["home"]
|
||||
if gf is None or ga is None:
|
||||
continue
|
||||
r = "W" if gf > ga else "L" if gf < ga else "D"
|
||||
results.append({"date": f["fixture"]["date"][:10], "r": r, "gf": gf, "ga": ga})
|
||||
results.sort(key=lambda x: x["date"])
|
||||
|
||||
n = max(len(results), 1)
|
||||
w = sum(1 for x in results if x["r"] == "W")
|
||||
d_ = sum(1 for x in results if x["r"] == "D")
|
||||
l = sum(1 for x in results if x["r"] == "L")
|
||||
payload = {
|
||||
"team_id": tid,
|
||||
"form": " ".join(x["r"] for x in results[-8:]),
|
||||
"w": w, "d": d_, "l": l,
|
||||
"gf_avg": round(sum(x["gf"] for x in results) / n, 2),
|
||||
"ga_avg": round(sum(x["ga"] for x in results) / n, 2),
|
||||
"clean_sheets": sum(1 for x in results if x["ga"] == 0),
|
||||
"stars": [p["name"] for p in players if p.get("position") == "Attacker"][:5],
|
||||
"squad_n": len(players),
|
||||
}
|
||||
await _upsert(db, f"team:{code}", payload)
|
||||
return True
|
||||
|
||||
|
||||
async def _fetch_h2h(db, client, code_a, id_a, code_b, id_b) -> None:
|
||||
d = await _get(client, "/fixtures/headtohead", h2h=f"{id_a}-{id_b}")
|
||||
rows = []
|
||||
for f in d.get("response", []):
|
||||
g = f["goals"]
|
||||
if g["home"] is None:
|
||||
continue
|
||||
rows.append({
|
||||
"date": f["fixture"]["date"][:10],
|
||||
"home": f["teams"]["home"]["name"], "hg": g["home"],
|
||||
"ag": g["away"], "away": f["teams"]["away"]["name"],
|
||||
})
|
||||
await _upsert(db, f"h2h:{code_a}-{code_b}", {"rows": rows[-5:]})
|
||||
|
||||
|
||||
# 팀 1건 수집 비용(API 호출 수): 스쿼드 1 + 시즌별 경기. H2H 는 1.
|
||||
_TEAM_CALLS = 1 + len(_SEASONS)
|
||||
_H2H_CALLS = 1
|
||||
|
||||
|
||||
# ── 수집 잡 (워커 호출) ─────────────────────────────────────────
|
||||
async def refresh(db, matches: list[Match]) -> int:
|
||||
"""경기들의 팀/H2H 캐시를 '아직 없는 것만' 한 번씩 수집한다(fetch-once).
|
||||
|
||||
과거 시즌 폼·스쿼드·H2H 는 변하지 않고, 2026 진행 결과는 build_data_block 이
|
||||
우리 DB 에서 라이브로 읽으므로 팀당 1회 수집이면 충분하다. 무료 한도를 넘지
|
||||
않도록 하루 호출 예산(football_daily_call_budget) 안에서만 받고, 못 받은 팀은
|
||||
다음 잡이 이어서 누적한다. 전 팀이 캐시되면 이후 호출 0(자동 무동작).
|
||||
이번 잡에서 새로 수집한 팀 수 반환."""
|
||||
if not enabled():
|
||||
return 0
|
||||
budget = settings.football_daily_call_budget
|
||||
refreshed = 0
|
||||
calls = 0
|
||||
async with httpx.AsyncClient() as client:
|
||||
seen: set[str] = set()
|
||||
for m in matches:
|
||||
for code, name in ((m.team_a_code, m.team_a_name), (m.team_b_code, m.team_b_name)):
|
||||
if code in seen:
|
||||
continue
|
||||
seen.add(code)
|
||||
if await db.get(FootballCache, f"team:{code}"):
|
||||
continue # 이미 수집됨 — 다시 받지 않음(fetch-once)
|
||||
if calls + _TEAM_CALLS > budget:
|
||||
continue # 오늘 호출 예산 소진 — 남은 팀은 다음 잡에서
|
||||
try:
|
||||
if await _fetch_team(db, client, code, name):
|
||||
refreshed += 1
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning("football: 팀 %s 수집 실패 %s", code, e)
|
||||
calls += _TEAM_CALLS # 성공/실패 무관 호출은 소비됨(한도 보호)
|
||||
# H2H — 양 팀이 캐시됐고 아직 안 받은 쌍만
|
||||
for m in matches:
|
||||
if calls + _H2H_CALLS > budget:
|
||||
break
|
||||
if await db.get(FootballCache, f"h2h:{m.team_a_code}-{m.team_b_code}"):
|
||||
continue
|
||||
ta = await db.get(FootballCache, f"team:{m.team_a_code}")
|
||||
tb = await db.get(FootballCache, f"team:{m.team_b_code}")
|
||||
if not (ta and tb):
|
||||
continue
|
||||
try:
|
||||
await _fetch_h2h(db, client, m.team_a_code, ta.payload["team_id"],
|
||||
m.team_b_code, tb.payload["team_id"])
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning("football: H2H %s 수집 실패 %s", m.match_id, e)
|
||||
calls += _H2H_CALLS
|
||||
await db.commit()
|
||||
if refreshed or calls:
|
||||
log.info("football: 신규 팀 %d개 수집 (호출 ~%d/%d)", refreshed, calls, budget)
|
||||
return refreshed
|
||||
|
||||
|
||||
# ── 라이브 WC 결과 (우리 DB) ───────────────────────────────────
|
||||
async def _live_wc(db, code: str, before) -> list[str]:
|
||||
rows = (await db.execute(
|
||||
select(Match)
|
||||
.where(
|
||||
or_(Match.team_a_code == code, Match.team_b_code == code),
|
||||
Match.result_outcome.isnot(None),
|
||||
Match.kickoff_at < before,
|
||||
)
|
||||
.order_by(Match.kickoff_at)
|
||||
)).scalars().all()
|
||||
out = []
|
||||
for m in rows:
|
||||
is_a = m.team_a_code == code
|
||||
gf = m.result_score_a if is_a else m.result_score_b
|
||||
ga = m.result_score_b if is_a else m.result_score_a
|
||||
opp = m.team_b_short if is_a else m.team_a_short
|
||||
if gf is None or ga is None:
|
||||
continue
|
||||
r = "W" if gf > ga else "L" if gf < ga else "D"
|
||||
out.append(f"{r} {gf}-{ga} vs {opp}")
|
||||
return out
|
||||
|
||||
|
||||
def _h2h_line(row, default_code: str) -> str:
|
||||
if not row or not row.payload.get("rows"):
|
||||
return "no recent meetings"
|
||||
rows = row.payload["rows"][-3:]
|
||||
return "; ".join(
|
||||
f'{r["home"]} {r["hg"]}-{r["ag"]} {r["away"]} ({r["date"][:4]})' for r in rows
|
||||
)
|
||||
|
||||
|
||||
async def _team_lines(db, code, name, row, kickoff) -> str:
|
||||
rank = FIFA_RANK.get(code)
|
||||
head = f"[{name}]" + (f" FIFA #{rank}" if rank else "")
|
||||
if not row:
|
||||
return f"{head}\n (no external data — use general knowledge)"
|
||||
p = row.payload
|
||||
live = await _live_wc(db, code, kickoff)
|
||||
wc = "; ".join(live) if live else "(none yet)"
|
||||
return (
|
||||
f"{head}\n"
|
||||
f" Form(last8): {p.get('form','')} | {p.get('w',0)}-{p.get('d',0)}-{p.get('l',0)} "
|
||||
f"(W-D-L), avg {p.get('gf_avg','?')}-{p.get('ga_avg','?')}, "
|
||||
f"clean sheets {p.get('clean_sheets',0)}\n"
|
||||
f" WC2026 so far: {wc}\n"
|
||||
f" Key attackers: {', '.join(p.get('stars', []))}"
|
||||
)
|
||||
|
||||
|
||||
async def build_data_block(db, match: Match) -> str | None:
|
||||
"""경기 예측 프롬프트에 끼울 데이터블록. 캐시만 읽음(외부 호출 X).
|
||||
양 팀 모두 데이터가 없으면 None → 기존(이름만) 동작 폴백."""
|
||||
if not enabled():
|
||||
return None
|
||||
ta = await db.get(FootballCache, f"team:{match.team_a_code}")
|
||||
tb = await db.get(FootballCache, f"team:{match.team_b_code}")
|
||||
# 라이브 WC 결과는 캐시 없어도 의미 있으므로, 캐시가 둘 다 없을 때만 폴백
|
||||
has_live = bool(await _live_wc(db, match.team_a_code, match.kickoff_at)) or \
|
||||
bool(await _live_wc(db, match.team_b_code, match.kickoff_at))
|
||||
if not ta and not tb and not has_live:
|
||||
return None
|
||||
h2h = (await db.get(FootballCache, f"h2h:{match.team_a_code}-{match.team_b_code}")) or \
|
||||
(await db.get(FootballCache, f"h2h:{match.team_b_code}-{match.team_a_code}"))
|
||||
return "\n".join([
|
||||
"=== MATCH DATA (factual, weigh heavily over priors) ===",
|
||||
await _team_lines(db, match.team_a_code, match.team_a_name, ta, match.kickoff_at),
|
||||
await _team_lines(db, match.team_b_code, match.team_b_name, tb, match.kickoff_at),
|
||||
f"[Head-to-head] {_h2h_line(h2h, match.team_a_code)}",
|
||||
"===",
|
||||
])
|
||||
48
backend/app/services/grading.py
Normal file
@ -0,0 +1,48 @@
|
||||
"""채점 서비스 — 결과 입력 시 해당 경기 유저 픽 전수 채점 + 유저별 누적.
|
||||
|
||||
관리자 setResult 와 워커가 공용으로 사용. scoring.grade_prediction 적용
|
||||
(scoring.json 배점) 후 services.points 로 user_points 누적 갱신.
|
||||
"""
|
||||
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 grade_prediction, outcome_of
|
||||
from .points import accumulate_user_points
|
||||
|
||||
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()
|
||||
|
||||
baseball = match.league in ("kbo", "mlb")
|
||||
for p in picks:
|
||||
key, value = grade_prediction(
|
||||
p.score_a, p.score_b, score_a, score_b, baseball=baseball
|
||||
)
|
||||
p.points = value
|
||||
p.scored_at = now_utc()
|
||||
|
||||
# 채점된 픽의 유저(이메일)별 누적 포인트 갱신 — 같은 트랜잭션
|
||||
await accumulate_user_points(db, {p.email for p in picks})
|
||||
|
||||
await db.commit()
|
||||
log.info("graded %d picks for %s (%d-%d)", len(picks), match.match_id, score_a, score_b)
|
||||
return len(picks)
|
||||
704
backend/app/services/mls_espn.py
Normal file
@ -0,0 +1,704 @@
|
||||
"""MLS 데이터 — ESPN 비공식 API (일정·결과·순위·프리뷰·라이브).
|
||||
|
||||
ESPN site API 는 키 불필요·비공식. 응답 표준 레코드는 야구와 동일 형태
|
||||
({league, teamA(원정), teamB(홈), dateKst, kickoffKst, venue, cancelled,
|
||||
[scoreA, scoreB]})라 sync_baseball_schedule/settle 파이프라인을 그대로 탄다.
|
||||
|
||||
- 일정: scoreboard?dates=YYYYMMDD-YYYYMMDD (dates 는 미국 동부 기준 날짜라
|
||||
KST 매칭은 이벤트의 UTC 시각을 KST 로 환산해 산출)
|
||||
- 순위: 동부/서부 컨퍼런스 → standings:mls 캐시 (승점제)
|
||||
- 프리뷰: scoreboard 의 form(최근5)·records(시즌 W-D-L)·odds(머니라인)
|
||||
+ summary 의 headToHeadGames(맞대결 최근 5) → preview:{match_id} 캐시.
|
||||
odds 는 AI 프롬프트 참고용으로만 쓰고 UI 에는 노출하지 않는다.
|
||||
- 라이브: scoreboard 이벤트의 clock·스코어·득점 이벤트(details) — 15초 TTL
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import unicodedata
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
from ..config import settings
|
||||
from ..domain import ensure_aware, now_utc
|
||||
from ..models import DataCache, Match
|
||||
from ..teams_mls import MLS_ID_TO_CODE, MLS_TEAMS
|
||||
|
||||
log = logging.getLogger("triplepick.mls")
|
||||
|
||||
KST = timezone(timedelta(hours=9))
|
||||
# 주의: ESPN 은 브라우저 위장 UA(풀 Chrome 문자열)를 보내면 WAF 가 403 으로 차단한다
|
||||
# (TLS 핑거프린트와 UA 불일치 감지로 추정). httpx 기본 UA 로 보내야 통과 — 헤더 없이 호출.
|
||||
|
||||
# 취소로 취급하는 ESPN 상태 (연기 포함 — 보강 일정이 새 이벤트로 재등장)
|
||||
_CANCEL_STATUS = {"STATUS_POSTPONED", "STATUS_CANCELED", "STATUS_ABANDONED"}
|
||||
|
||||
|
||||
def _sb_url(dates: str) -> str:
|
||||
return (
|
||||
f"{settings.espn_api_base}/site/v2/{settings.espn_mls_path}"
|
||||
f"/scoreboard?dates={dates}&limit=200"
|
||||
)
|
||||
|
||||
|
||||
def _summary_url(event_id: str) -> str:
|
||||
return (
|
||||
f"{settings.espn_api_base}/site/v2/{settings.espn_mls_path}"
|
||||
f"/summary?event={event_id}"
|
||||
)
|
||||
|
||||
|
||||
def _standings_url() -> str:
|
||||
return f"{settings.espn_api_base}/v2/{settings.espn_mls_path}/standings"
|
||||
|
||||
|
||||
def _parse_event(e: dict) -> dict | None:
|
||||
"""scoreboard 이벤트 1건 → 표준 레코드 (+espnId·form·records·odds 원본)."""
|
||||
comp = (e.get("competitions") or [{}])[0]
|
||||
sides: dict[str, dict] = {}
|
||||
for t in comp.get("competitors") or []:
|
||||
sides[t.get("homeAway", "")] = t
|
||||
away, home = sides.get("away"), sides.get("home")
|
||||
if not away or not home:
|
||||
return None
|
||||
a = (away.get("team") or {}).get("abbreviation", "")
|
||||
b = (home.get("team") or {}).get("abbreviation", "")
|
||||
if a not in MLS_TEAMS or b not in MLS_TEAMS:
|
||||
return None # 올스타전 등 제외
|
||||
gd = e.get("date")
|
||||
if not gd:
|
||||
return None
|
||||
kickoff = datetime.fromisoformat(gd.replace("Z", "+00:00")).astimezone(KST)
|
||||
stype = (e.get("status") or {}).get("type") or {}
|
||||
rec: dict = {
|
||||
"league": "mls",
|
||||
"teamA": a, "teamB": b,
|
||||
"dateKst": kickoff.strftime("%Y%m%d"),
|
||||
"kickoffKst": kickoff.isoformat(),
|
||||
"venue": ((comp.get("venue") or {}).get("fullName")) or "",
|
||||
"cancelled": stype.get("name") in _CANCEL_STATUS,
|
||||
"espnId": e.get("id"),
|
||||
"_away": away, "_home": home, "_comp": comp, "_status": e.get("status"),
|
||||
}
|
||||
if stype.get("state") == "post" and stype.get("completed") and not rec["cancelled"]:
|
||||
sa, sb = away.get("score"), home.get("score")
|
||||
if sa is not None and sb is not None:
|
||||
rec["scoreA"], rec["scoreB"] = int(sa), int(sb)
|
||||
return rec
|
||||
|
||||
|
||||
async def _fetch_events(client, dates: str) -> list[dict]:
|
||||
r = await client.get(_sb_url(dates))
|
||||
r.raise_for_status()
|
||||
out = []
|
||||
for e in r.json().get("events") or []:
|
||||
rec = _parse_event(e)
|
||||
if rec:
|
||||
out.append(rec)
|
||||
return out
|
||||
|
||||
|
||||
def _clean(rec: dict) -> dict:
|
||||
"""sync/정산에 넘길 때 원본 참조 필드 제거."""
|
||||
return {k: v for k, v in rec.items() if not k.startswith("_")}
|
||||
|
||||
|
||||
async def fetch_mls_schedule(days_back: int | None = None) -> list[dict]:
|
||||
"""일정+결과 수집 (취소 포함). 실패 시 빈 리스트 — 기존 일정 유지.
|
||||
|
||||
days_back 기본값은 mls_days_back. settle_baseball 이 미정산 경기 중
|
||||
가장 오래된 날짜를 덮도록 더 큰 값을 넘길 수 있다(오래 방치된 미정산 경기 구제).
|
||||
"""
|
||||
import httpx
|
||||
|
||||
today = datetime.now(KST).date()
|
||||
# 시작 = 백필 윈도우(주말 위주 편성 공백 방지). ESPN dates 는 미국 동부 날짜라
|
||||
# KST 범위를 놓치지 않게 하루 여유를 둔다.
|
||||
start = today - timedelta(days=(days_back if days_back is not None else settings.mls_days_back) + 1)
|
||||
end = today + timedelta(days=settings.baseball_days_ahead)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=20) as c:
|
||||
records = await _fetch_events(
|
||||
c, f"{start.strftime('%Y%m%d')}-{end.strftime('%Y%m%d')}"
|
||||
)
|
||||
records = [_clean(r) for r in records]
|
||||
from .baseball_sync import assign_seq
|
||||
|
||||
assign_seq(records) # MLS 는 더블헤더가 없어 전부 seq=1 이지만 키 일관성 유지
|
||||
log.info("mls schedule: %d경기 수집", len(records))
|
||||
return records
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.error("mls schedule 실패: %s — 기존 일정 유지", e)
|
||||
return []
|
||||
|
||||
|
||||
async def fetch_mls_results(days_back: int | None = None) -> list[dict]:
|
||||
"""확정 스코어만 → 정산(settle) 입력."""
|
||||
return [r for r in await fetch_mls_schedule(days_back) if "scoreA" in r]
|
||||
|
||||
|
||||
# ── 순위 (동/서부 컨퍼런스, 승점제) ─────────────────────────────
|
||||
_CONF_KEY = {"Eastern Conference": "EAST", "Western Conference": "WEST"}
|
||||
|
||||
|
||||
async def _refresh_standings(db) -> bool:
|
||||
import httpx
|
||||
|
||||
table: dict[str, dict] = {}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as c:
|
||||
r = await c.get(_standings_url())
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
for conf in data.get("children") or []:
|
||||
key = _CONF_KEY.get(conf.get("name", ""), conf.get("abbreviation", ""))
|
||||
for ent in ((conf.get("standings") or {}).get("entries")) or []:
|
||||
code = (ent.get("team") or {}).get("abbreviation", "")
|
||||
if code not in MLS_TEAMS:
|
||||
continue
|
||||
stats = {s.get("name"): s for s in ent.get("stats") or []}
|
||||
|
||||
def _v(name: str): # noqa: ANN202
|
||||
s = stats.get(name) or {}
|
||||
return s.get("value") if s.get("value") is not None else s.get("displayValue")
|
||||
|
||||
table[code] = {
|
||||
"div": key,
|
||||
"rank": int(_v("rank") or 0) or None,
|
||||
"gp": int(_v("gamesPlayed") or 0),
|
||||
"w": int(_v("wins") or 0),
|
||||
"d": int(_v("ties") or 0),
|
||||
"l": int(_v("losses") or 0),
|
||||
"pts": int(_v("points") or 0),
|
||||
"gf": int(_v("pointsFor") or 0),
|
||||
"ga": int(_v("pointsAgainst") or 0),
|
||||
"diff": (stats.get("pointDifferential") or {}).get("displayValue"),
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning("mls standings 실패: %s", e)
|
||||
return False
|
||||
if not table:
|
||||
return False
|
||||
row = await db.get(DataCache, "standings:mls")
|
||||
if row:
|
||||
row.payload = table
|
||||
row.fetched_at = now_utc()
|
||||
else:
|
||||
db.add(DataCache(key="standings:mls", payload=table, fetched_at=now_utc()))
|
||||
return True
|
||||
|
||||
|
||||
# ── 프리뷰 (폼·시즌 성적·맞대결·머니라인) ──────────────────────
|
||||
def _odds_of(comp: dict) -> dict | None:
|
||||
"""scoreboard odds → {home, draw, away, provider} (없으면 None)."""
|
||||
o = (comp.get("odds") or [{}])[0] or {} # odds: [null] 인 경기도 있음
|
||||
ml = o.get("moneyline") or {}
|
||||
|
||||
def _pick(side: dict | None) -> str | None:
|
||||
if not side:
|
||||
return None
|
||||
for k in ("current", "close", "open"):
|
||||
v = (side.get(k) or {}).get("odds")
|
||||
if v and v != "OFF":
|
||||
return str(v)
|
||||
return None
|
||||
|
||||
home, away = _pick(ml.get("home")), _pick(ml.get("away"))
|
||||
draw = _pick(ml.get("draw")) or (
|
||||
str((o.get("drawOdds") or {}).get("moneyLine") or "") or None
|
||||
)
|
||||
if not (home or away or draw):
|
||||
return None
|
||||
return {
|
||||
"home": home, "draw": draw, "away": away,
|
||||
"provider": ((o.get("provider") or {}).get("displayName")) or "",
|
||||
}
|
||||
|
||||
|
||||
def _h2h_of(summary: dict) -> list[dict]:
|
||||
"""summary.headToHeadGames → 최근 맞대결 [{date, home, away, scoreH, scoreA}]."""
|
||||
out: list[dict] = []
|
||||
for grp in summary.get("headToHeadGames") or []:
|
||||
for ev in grp.get("events") or []:
|
||||
h = MLS_ID_TO_CODE.get(int(ev.get("homeTeamId") or 0))
|
||||
a = MLS_ID_TO_CODE.get(int(ev.get("awayTeamId") or 0))
|
||||
sh, sa = ev.get("homeTeamScore"), ev.get("awayTeamScore")
|
||||
if not h or not a or sh is None or sa is None:
|
||||
continue
|
||||
out.append({
|
||||
"date": (ev.get("gameDate") or "")[:10],
|
||||
"home": h, "away": a,
|
||||
"scoreH": int(sh), "scoreA": int(sa),
|
||||
})
|
||||
break # 첫 그룹(상대팀 기준)만
|
||||
return out[:5]
|
||||
|
||||
|
||||
async def _refresh_previews(db, matches: list[Match]) -> int:
|
||||
import httpx
|
||||
|
||||
if not matches:
|
||||
return 0
|
||||
dates = sorted({ensure_aware(m.kickoff_at).astimezone(KST).date() for m in matches})
|
||||
span = f"{(dates[0] - timedelta(days=1)).strftime('%Y%m%d')}-{dates[-1].strftime('%Y%m%d')}"
|
||||
n = 0
|
||||
async with httpx.AsyncClient(timeout=20) as c:
|
||||
events = await _fetch_events(c, span)
|
||||
by_key = {(r["dateKst"], r["teamA"], r["teamB"]): r for r in events}
|
||||
for m in matches:
|
||||
d = ensure_aware(m.kickoff_at).astimezone(KST).strftime("%Y%m%d")
|
||||
rec = by_key.get((d, m.team_a_code, m.team_b_code))
|
||||
if not rec:
|
||||
continue
|
||||
payload: dict = {
|
||||
"formA": (rec["_away"].get("form")) or None, # 최근5 "WWLDW"
|
||||
"formB": (rec["_home"].get("form")) or None,
|
||||
"recordA": next( # 시즌 전적 "8-2-4" (W-L-D)
|
||||
(r.get("summary") for r in rec["_away"].get("records") or []
|
||||
if r.get("type") == "total"), None),
|
||||
"recordB": next(
|
||||
(r.get("summary") for r in rec["_home"].get("records") or []
|
||||
if r.get("type") == "total"), None),
|
||||
"odds": _odds_of(rec["_comp"]),
|
||||
}
|
||||
try: # 맞대결(h2h)은 summary 1콜 — 실패해도 나머지 프리뷰는 유지
|
||||
r2 = await c.get(_summary_url(rec["espnId"]))
|
||||
r2.raise_for_status()
|
||||
payload["h2h"] = _h2h_of(r2.json())
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning("mls summary 실패 %s: %s", m.match_id, e)
|
||||
if any(v for v in payload.values()):
|
||||
row = await db.get(DataCache, f"preview:{m.match_id}")
|
||||
if row:
|
||||
row.payload = payload
|
||||
row.fetched_at = now_utc()
|
||||
else:
|
||||
db.add(DataCache(
|
||||
key=f"preview:{m.match_id}", payload=payload,
|
||||
fetched_at=now_utc(),
|
||||
))
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
async def refresh_mls_details(db, matches: list[Match]) -> None:
|
||||
"""임박(48h 내) 미종료 경기 프리뷰 + 컨퍼런스 순위 캐시 갱신."""
|
||||
horizon = now_utc() + timedelta(hours=48)
|
||||
targets = [
|
||||
m for m in matches
|
||||
if m.result_outcome is None
|
||||
and m.status != "cancelled"
|
||||
and ensure_aware(m.kickoff_at) <= horizon
|
||||
]
|
||||
n = await _refresh_previews(db, targets)
|
||||
await _refresh_standings(db)
|
||||
await db.commit()
|
||||
log.info("mls details: 프리뷰 %d경기 캐싱", n)
|
||||
|
||||
|
||||
# ── AI 예측 프롬프트 데이터 블록 ────────────────────────────────
|
||||
async def build_mls_data_block(db, match: Match) -> str | None:
|
||||
"""자체 DB 축적 결과(폼·상대전적) + ESPN 프리뷰(폼·시즌·맞대결·배당) 결합."""
|
||||
from .baseball_data import _h2h_line, _team_games, _team_lines
|
||||
|
||||
ga = await _team_games(db, "mls", match.team_a_code, match.kickoff_at)
|
||||
gb = await _team_games(db, "mls", match.team_b_code, match.kickoff_at)
|
||||
lines: list[str] = []
|
||||
|
||||
prev = await db.get(DataCache, f"preview:{match.match_id}")
|
||||
p = prev.payload if prev else {}
|
||||
if p.get("formA") or p.get("recordA"):
|
||||
lines.append(
|
||||
f"[{match.team_a_short} season] record {p.get('recordA') or '?'} (W-L-D), "
|
||||
f"last5 {p.get('formA') or '?'}"
|
||||
)
|
||||
if p.get("formB") or p.get("recordB"):
|
||||
lines.append(
|
||||
f"[{match.team_b_short} season] record {p.get('recordB') or '?'} (W-L-D), "
|
||||
f"last5 {p.get('formB') or '?'}"
|
||||
)
|
||||
if p.get("h2h"):
|
||||
h2h = "; ".join(
|
||||
f"{g['away']} {g['scoreA']}-{g['scoreH']} {g['home']} (away-home, {g['date']})"
|
||||
for g in p["h2h"]
|
||||
)
|
||||
lines.append(f"[Recent head-to-head] {h2h}")
|
||||
if p.get("odds"):
|
||||
o = p["odds"]
|
||||
lines.append(
|
||||
f"[Bookmaker moneyline ({o.get('provider')})] "
|
||||
f"home {o.get('home') or '?'} / draw {o.get('draw') or '?'} / "
|
||||
f"away {o.get('away') or '?'} (American odds; use as market signal)"
|
||||
)
|
||||
|
||||
st_row = await db.get(DataCache, "standings:mls")
|
||||
if st_row:
|
||||
for code, name in (
|
||||
(match.team_a_code, match.team_a_short),
|
||||
(match.team_b_code, match.team_b_short),
|
||||
):
|
||||
st = st_row.payload.get(code)
|
||||
if st:
|
||||
lines.append(
|
||||
f"[{name} standings] {st.get('div')} rank {st.get('rank')}, "
|
||||
f"{st.get('w')}W-{st.get('d')}D-{st.get('l')}L, "
|
||||
f"{st.get('pts')}pts, GF {st.get('gf')} GA {st.get('ga')}"
|
||||
)
|
||||
|
||||
if not ga and not gb and not lines:
|
||||
return None
|
||||
h2h_db = await _h2h_line(db, "mls", match.team_a_code, match.team_b_code, match.kickoff_at)
|
||||
return "\n".join([
|
||||
"=== MATCH DATA (factual; weigh heavily over priors) ===",
|
||||
"Away " + _team_lines(match.team_a_name, ga),
|
||||
"Home " + _team_lines(match.team_b_name, gb),
|
||||
f"[Head-to-head in our data] {h2h_db}",
|
||||
*lines,
|
||||
"===",
|
||||
])
|
||||
|
||||
|
||||
# ── 문자중계 한글 번역 (규칙 기반) ─────────────────────────────
|
||||
# ESPN 중계 문장은 정형 템플릿이라 정규식으로 한국어 요약 변환. 매칭 실패 시 원문.
|
||||
# 선수명은 영문 유지, 팀 풀네임은 한글 축약으로 치환.
|
||||
_TEAM_EN2KO: dict[str, str] = {
|
||||
**{v["en"]: v["short"] for v in MLS_TEAMS.values()},
|
||||
# ESPN 표기가 우리 en 값과 다른 팀 별칭
|
||||
"Red Bull New York": "레드불스",
|
||||
"Vancouver Whitecaps": "밴쿠버",
|
||||
"St. Louis City SC": "세인트루이스",
|
||||
}
|
||||
|
||||
_ATTEMPT_LABEL = {"missed": "슛 빗나감", "blocked": "슛 차단", "saved": "슛 (GK 선방)"}
|
||||
|
||||
# ESPN 팀 표기(중계 원문) → 팀 코드 — 교체 이벤트 팀 매칭용
|
||||
_TEAM_EN2CODE: dict[str, str] = {
|
||||
**{v["en"]: k for k, v in MLS_TEAMS.items()},
|
||||
"Red Bull New York": "RBNY",
|
||||
"Vancouver Whitecaps": "VAN",
|
||||
"St. Louis City SC": "STL",
|
||||
}
|
||||
|
||||
|
||||
def _ko_teams(s: str) -> str:
|
||||
for en, ko in _TEAM_EN2KO.items():
|
||||
s = s.replace(en, ko)
|
||||
return s
|
||||
|
||||
|
||||
def _tr_commentary(text: str) -> str: # noqa: PLR0911
|
||||
t = text.strip()
|
||||
|
||||
if t in ("First Half begins.", "First Half Kicks Off."):
|
||||
return "전반전 시작"
|
||||
if t.startswith("Second Half begins"):
|
||||
return "후반전 시작"
|
||||
m = re.match(r"^First Half ends, (.+)\.$", t)
|
||||
if m:
|
||||
return f"전반전 종료 — {_ko_teams(m.group(1))}"
|
||||
m = re.match(r"^Match ends, (.+)\.$", t)
|
||||
if m:
|
||||
return f"경기 종료 — {_ko_teams(m.group(1))}"
|
||||
m = re.match(r"^Second Half ends, (.+)\.$", t)
|
||||
if m:
|
||||
return f"후반전 종료 — {_ko_teams(m.group(1))}"
|
||||
|
||||
m = re.match(r"^Goal!\s+(.+?) (\d+), (.+?) (\d+)\.\s*(.+?) \((.+?)\)(.*)$", t)
|
||||
if m:
|
||||
t1, s1, t2, s2, player, team, rest = m.groups()
|
||||
tag = " (PK)" if "penalty" in rest else " (헤딩)" if "header" in rest else ""
|
||||
return (
|
||||
f"골! {_ko_teams(t1)} {s1} : {s2} {_ko_teams(t2)} — "
|
||||
f"{player} ({_ko_teams(team)}){tag}"
|
||||
)
|
||||
m = re.match(r"^Own Goal by (.+?), (.+?) (\d+), (.+?) (\d+)\.", t)
|
||||
if m:
|
||||
og, t1, s1, t2, s2 = m.groups()
|
||||
return f"자책골 — {_ko_teams(og)} · {_ko_teams(t1)} {s1} : {s2} {_ko_teams(t2)}"
|
||||
|
||||
m = re.match(r"^Attempt (missed|blocked|saved)\.\s*(.+?) \((.+?)\)(.*)$", t)
|
||||
if m:
|
||||
kind, player, team, rest = m.groups()
|
||||
head = "헤딩 " if "header" in rest else ""
|
||||
am = re.search(r"Assisted by ([^.]+?)(?: with| following|\.|$)", rest)
|
||||
assist = f" · 도움 {am.group(1).strip()}" if am else ""
|
||||
return f"{head}{_ATTEMPT_LABEL[kind]} — {player} ({_ko_teams(team)}){assist}"
|
||||
|
||||
m = re.match(r"^(.+?) \((.+?)\) hits the (left post|right post|bar|crossbar)(.*)$", t)
|
||||
if m:
|
||||
return f"골대! — {m.group(1)} ({_ko_teams(m.group(2))})"
|
||||
|
||||
m = re.match(r"^Foul by (.+?) \((.+?)\)\.$", t)
|
||||
if m:
|
||||
return f"파울 — {m.group(1)} ({_ko_teams(m.group(2))})"
|
||||
m = re.match(r"^(.+?) \((.+?)\) wins a free kick (.+)\.$", t)
|
||||
if m:
|
||||
return f"프리킥 획득 — {m.group(1)} ({_ko_teams(m.group(2))})"
|
||||
m = re.match(r"^(.+?) \((.+?)\) is shown the (yellow|red) card(.*)\.$", t)
|
||||
if m:
|
||||
card = "옐로카드" if m.group(3) == "yellow" else "레드카드"
|
||||
return f"{card} — {m.group(1)} ({_ko_teams(m.group(2))})"
|
||||
m = re.match(
|
||||
r"^Substitution, (.+?)\.\s*(.+?) replaces (.+?)( because of [^.]*)?\.$", t
|
||||
)
|
||||
if m:
|
||||
inj = " (부상)" if m.group(4) else ""
|
||||
return f"교체 ({_ko_teams(m.group(1))}) — IN {m.group(2)} · OUT {m.group(3)}{inj}"
|
||||
m = re.match(r"^Corner,\s+(.+?)\. Conceded by (.+?)\.$", t)
|
||||
if m:
|
||||
return f"코너킥 — {_ko_teams(m.group(1))}"
|
||||
m = re.match(r"^Offside, (.+?)\.(.*)$", t)
|
||||
if m:
|
||||
return f"오프사이드 — {_ko_teams(m.group(1))}"
|
||||
m = re.match(r"^Hand ball by (.+?) \((.+?)\)\.$", t)
|
||||
if m:
|
||||
return f"핸드볼 — {m.group(1)} ({_ko_teams(m.group(2))})"
|
||||
m = re.match(r"^Penalty conceded by (.+?) \((.+?)\)(.*)$", t)
|
||||
if m:
|
||||
return f"페널티 유발 — {m.group(1)} ({_ko_teams(m.group(2))})"
|
||||
m = re.match(r"^Penalty (.+?)\. (.+?) draws a foul(.*)$", t)
|
||||
if m:
|
||||
return f"페널티 획득 — {_ko_teams(m.group(1))} ({m.group(2)})"
|
||||
if t.startswith("Delay in match"):
|
||||
return "경기 지연"
|
||||
if t.startswith("Delay over"):
|
||||
return "경기 재개"
|
||||
m = re.match(r"^Fourth official has announced (\d+) minutes? of added time\.$", t)
|
||||
if m:
|
||||
return f"추가시간 {m.group(1)}분"
|
||||
if t.startswith("Lineups are announced"):
|
||||
return "라인업 발표 · 선수 몸풀기"
|
||||
|
||||
return _ko_teams(t) # 미지원 템플릿 — 팀명만 한글화한 원문
|
||||
|
||||
|
||||
# ── 라이브 (스코어·경기시간·득점 이벤트·라인업) ────────────────
|
||||
_LIVE_TTL_SEC = 15.0
|
||||
_live_cache: dict[str, tuple[float, dict]] = {}
|
||||
|
||||
|
||||
def _player_out(p: dict) -> dict:
|
||||
ath = p.get("athlete") or {}
|
||||
return {
|
||||
"name": ath.get("displayName", ""),
|
||||
"pos": ((p.get("position") or {}).get("abbreviation")) or "",
|
||||
"jersey": p.get("jersey", ""),
|
||||
"in": bool(p.get("subbedIn")),
|
||||
"out": bool(p.get("subbedOut")),
|
||||
}
|
||||
|
||||
|
||||
def _lineups_of(summary: dict) -> dict | None:
|
||||
"""summary.rosters → {away, home}: {formation, starters[], bench[]}.
|
||||
|
||||
starters 는 formationPlace 순(1=GK). subbedIn/Out 플래그로 실시간 교체 표시.
|
||||
라인업 미발표(경기 한참 전)면 rosters 가 비어 None.
|
||||
"""
|
||||
out: dict = {}
|
||||
for r in summary.get("rosters") or []:
|
||||
side = r.get("homeAway")
|
||||
players = r.get("roster") or []
|
||||
if side not in ("home", "away") or not players:
|
||||
continue
|
||||
starters = sorted(
|
||||
(p for p in players if p.get("starter")),
|
||||
key=lambda p: int(p.get("formationPlace") or 99),
|
||||
)
|
||||
bench = [p for p in players if not p.get("starter")]
|
||||
out[side] = {
|
||||
"formation": r.get("formation"),
|
||||
"starters": [_player_out(p) for p in starters],
|
||||
"bench": [_player_out(p) for p in bench],
|
||||
}
|
||||
return out or None
|
||||
|
||||
|
||||
def _norm_name(s: str) -> str:
|
||||
"""악센트 제거·소문자 — 선수명 비교용."""
|
||||
s = unicodedata.normalize("NFKD", s)
|
||||
return "".join(ch for ch in s if not unicodedata.combining(ch)).lower().strip()
|
||||
|
||||
|
||||
def _make_name_resolver(lineups: dict | None):
|
||||
"""중계 원문의 선수명 → 로스터 표기명 스냅.
|
||||
|
||||
ESPN 중계와 로스터가 같은 선수를 다르게 적는 경우가 흔하다
|
||||
(미들네임 생략 "Sékou Bangoura"↔"Sékou Tidiany Bangoura",
|
||||
철자 차이 "Akhundzada"↔"Akhundzade", 애칭 "Máximo"↔"Maxi Carrizo").
|
||||
로스터명과 못 맞추면 교체 대체·골/어시 마커가 피치에 못 붙으므로
|
||||
정규화 일치 → 성(姓) 유일 일치 → 유사도(≥0.75) 순으로 맞춘다.
|
||||
"""
|
||||
roster: list[str] = []
|
||||
for side in (lineups or {}).values():
|
||||
for p in (side.get("starters") or []) + (side.get("bench") or []):
|
||||
if p.get("name"):
|
||||
roster.append(p["name"])
|
||||
by_norm = {_norm_name(n): n for n in roster}
|
||||
|
||||
def resolve(name: str) -> str:
|
||||
if not name or name in roster:
|
||||
return name
|
||||
n = _norm_name(name)
|
||||
if n in by_norm:
|
||||
return by_norm[n]
|
||||
last = n.rsplit(" ", 1)[-1]
|
||||
cands = [r for r in roster if _norm_name(r).rsplit(" ", 1)[-1] == last]
|
||||
if len(cands) == 1:
|
||||
return cands[0]
|
||||
# 토큰 순서가 뒤바뀐 표기 ("Djé D'Avilla" ↔ "D'Avilla Dje Tah") — 집합 포함 관계로 매칭
|
||||
toks = set(n.split())
|
||||
cands = [
|
||||
r for r in roster
|
||||
if toks <= set(_norm_name(r).split()) or set(_norm_name(r).split()) <= toks
|
||||
]
|
||||
if len(cands) == 1:
|
||||
return cands[0]
|
||||
best, score = name, 0.0
|
||||
for r in roster:
|
||||
s = SequenceMatcher(None, n, _norm_name(r)).ratio()
|
||||
if s > score:
|
||||
best, score = r, s
|
||||
return best if score >= 0.75 else name
|
||||
|
||||
return resolve
|
||||
|
||||
|
||||
async def fetch_live_mls(m: Match) -> dict:
|
||||
"""라이브 페이로드 {available, soccer, clock, period, score, goals[]}."""
|
||||
import httpx
|
||||
|
||||
cached = _live_cache.get(m.match_id)
|
||||
if cached and time.monotonic() - cached[0] < _LIVE_TTL_SEC:
|
||||
return cached[1]
|
||||
payload: dict = {"available": False}
|
||||
try:
|
||||
kst = ensure_aware(m.kickoff_at).astimezone(KST)
|
||||
date_kst = kst.strftime("%Y%m%d")
|
||||
span = (
|
||||
f"{(kst.date() - timedelta(days=1)).strftime('%Y%m%d')}"
|
||||
f"-{kst.date().strftime('%Y%m%d')}"
|
||||
)
|
||||
async with httpx.AsyncClient(timeout=15) as c:
|
||||
events = await _fetch_events(c, span)
|
||||
rec = next(
|
||||
(r for r in events
|
||||
if r["dateKst"] == date_kst
|
||||
and r["teamA"] == m.team_a_code and r["teamB"] == m.team_b_code),
|
||||
None,
|
||||
)
|
||||
if rec:
|
||||
status = rec["_status"] or {}
|
||||
stype = status.get("type") or {}
|
||||
# 라인업+문자중계 — summary 1콜 (경기 전 선발 발표~경기 중 교체·중계 반영)
|
||||
lineups = None
|
||||
subs: list[dict] = []
|
||||
assist_q: dict[str, list[str]] = {}
|
||||
commentary: list[dict] = []
|
||||
_rn = _make_name_resolver(None) # summary 실패 시 원문 그대로
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as c2:
|
||||
r2 = await c2.get(_summary_url(rec["espnId"]))
|
||||
r2.raise_for_status()
|
||||
sj = r2.json()
|
||||
lineups = _lineups_of(sj)
|
||||
_rn = _make_name_resolver(lineups)
|
||||
# 문자중계 — 규칙 기반 한글 번역, 경기 시작부터 전체(최신순).
|
||||
# 교체·어시스트는 details 에 없어 중계 원문에서 함께 추출한다.
|
||||
for e in sj.get("commentary") or []:
|
||||
raw = e.get("text", "")
|
||||
clk = (e.get("time") or {}).get("displayValue", "")
|
||||
ptype = (((e.get("play") or {}).get("type")) or {}).get("type", "")
|
||||
sm = re.match(
|
||||
r"^Substitution, (.+?)\.\s*(.+?) replaces (.+?)(?: because of [^.]*)?\.$",
|
||||
raw,
|
||||
)
|
||||
if sm:
|
||||
subs.append({
|
||||
"clock": clk,
|
||||
"team": _TEAM_EN2CODE.get(sm.group(1), ""),
|
||||
"inName": _rn(sm.group(2).strip()),
|
||||
"outName": _rn(sm.group(3).strip()),
|
||||
})
|
||||
if raw.startswith("Goal!"):
|
||||
gm = re.match(r"^Goal!\s+.+?\d+, .+?\d+\.\s*(.+?) \(", raw)
|
||||
am = re.search(r"Assisted by ([^.]+?)(?: with| following|\.|$)", raw)
|
||||
if gm and am:
|
||||
assist_q.setdefault(_rn(gm.group(1).strip()), []).append(
|
||||
_rn(am.group(1).strip())
|
||||
)
|
||||
commentary.append({
|
||||
"clock": clk,
|
||||
"text": _tr_commentary(raw),
|
||||
"goal": (
|
||||
ptype == "goal"
|
||||
or raw.startswith("Goal!")
|
||||
or raw.startswith("Own Goal")
|
||||
),
|
||||
})
|
||||
commentary.reverse()
|
||||
except Exception as e: # noqa: BLE001 — 라인업 실패해도 스코어는 서빙
|
||||
log.warning("mls lineups 실패 %s: %s", m.match_id, e)
|
||||
if stype.get("state") in ("in", "post"):
|
||||
goals = []
|
||||
cards = []
|
||||
for det in rec["_comp"].get("details") or []:
|
||||
team_id = int((det.get("team") or {}).get("id") or 0)
|
||||
aths = det.get("athletesInvolved") or []
|
||||
if det.get("yellowCard") or det.get("redCard"):
|
||||
cards.append({
|
||||
"clock": (det.get("clock") or {}).get("displayValue", ""),
|
||||
"team": MLS_ID_TO_CODE.get(team_id, ""),
|
||||
"player": _rn((aths[0] if aths else {}).get("displayName", "")),
|
||||
"red": bool(det.get("redCard")),
|
||||
})
|
||||
continue
|
||||
if not det.get("scoringPlay"):
|
||||
continue
|
||||
player = _rn((aths[0] if aths else {}).get("displayName", ""))
|
||||
# 어시스트: 중계 원문 큐(시간순) 우선, 없으면 details 두 번째 선수
|
||||
queue = assist_q.get(player)
|
||||
assist = queue.pop(0) if queue else (
|
||||
_rn(aths[1].get("displayName", ""))
|
||||
if len(aths) > 1 and not det.get("ownGoal")
|
||||
else ""
|
||||
)
|
||||
goals.append({
|
||||
"clock": (det.get("clock") or {}).get("displayValue", ""),
|
||||
"team": MLS_ID_TO_CODE.get(team_id, ""),
|
||||
"player": player,
|
||||
"assist": assist,
|
||||
"ownGoal": bool(det.get("ownGoal")),
|
||||
"penalty": bool(det.get("penaltyKick")),
|
||||
})
|
||||
payload = {
|
||||
"available": True,
|
||||
"soccer": True,
|
||||
"clock": status.get("displayClock", ""),
|
||||
"period": status.get("period"),
|
||||
"state": stype.get("state"),
|
||||
"score": {
|
||||
"away": rec["_away"].get("score"),
|
||||
"home": rec["_home"].get("score"),
|
||||
},
|
||||
"goals": goals,
|
||||
"cards": cards,
|
||||
"subs": subs,
|
||||
"lineups": lineups,
|
||||
"commentary": commentary,
|
||||
}
|
||||
elif lineups:
|
||||
# 경기 전 선발 라인업 발표됨 — 스코어 없이 라인업만
|
||||
payload = {
|
||||
"available": True,
|
||||
"soccer": True,
|
||||
"state": "pre",
|
||||
"lineups": lineups,
|
||||
"commentary": commentary,
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning("mls live 실패 %s: %s", m.match_id, e)
|
||||
payload = {"available": False}
|
||||
_live_cache[m.match_id] = (time.monotonic(), payload)
|
||||
return payload
|
||||
100
backend/app/services/points.py
Normal file
@ -0,0 +1,100 @@
|
||||
"""유저별 포인트 누적 — grade_prediction 의 (key, value) 를 user_points 에 반영.
|
||||
|
||||
채점(apply_result)에서 호출. 결과 정정(재채점)에도 안전하도록 영향받은
|
||||
이메일의 채점 가능한 픽 전체를 재집계해 upsert 한다(idempotent — 몇 번을
|
||||
다시 돌려도 같은 결과, 증분 방식의 이중 누적 위험 없음).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..domain import now_utc
|
||||
from ..models import Match, UserPoints, UserPrediction
|
||||
from ..scoring import grade_prediction
|
||||
|
||||
log = logging.getLogger("triplepick.points")
|
||||
|
||||
# scoring.json 의 key → user_points 등급별 횟수 컬럼
|
||||
_KEY_TO_COL = {
|
||||
"score_exact": "exact_count",
|
||||
"score_close": "close_count",
|
||||
"score_outcome": "outcome_count",
|
||||
"score_partial": "partial_count",
|
||||
"score_miss": "miss_count",
|
||||
}
|
||||
|
||||
|
||||
def _empty() -> dict:
|
||||
return {
|
||||
"total_points": 0,
|
||||
"exact_count": 0,
|
||||
"close_count": 0,
|
||||
"outcome_count": 0,
|
||||
"partial_count": 0,
|
||||
"miss_count": 0,
|
||||
"matches_played": 0,
|
||||
"first_scored_at": None,
|
||||
}
|
||||
|
||||
|
||||
async def accumulate_user_points(
|
||||
db: AsyncSession, emails: set[str | None]
|
||||
) -> int:
|
||||
"""이메일별 누적 포인트 재집계 → user_points upsert. 갱신 행 수 반환.
|
||||
|
||||
커밋은 호출자(apply_result) 책임 — 채점과 누적이 한 트랜잭션으로 묶인다.
|
||||
"""
|
||||
targets = {e.strip().lower() for e in emails if e and e.strip()}
|
||||
if not targets:
|
||||
return 0
|
||||
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(UserPrediction, Match)
|
||||
.join(Match, UserPrediction.match_id == Match.match_id)
|
||||
.where(
|
||||
func.lower(UserPrediction.email).in_(targets),
|
||||
Match.result_score_a.is_not(None),
|
||||
Match.result_score_b.is_not(None),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
|
||||
agg: dict[str, dict] = {e: _empty() for e in targets}
|
||||
for pick, match in rows:
|
||||
key, value = grade_prediction(
|
||||
pick.score_a, pick.score_b, match.result_score_a, match.result_score_b,
|
||||
baseball=match.league in ("kbo", "mlb"),
|
||||
)
|
||||
t = agg[pick.email.strip().lower()]
|
||||
t["total_points"] += value
|
||||
t[_KEY_TO_COL[key]] += 1
|
||||
t["matches_played"] += 1
|
||||
ts = pick.scored_at or pick.created_at
|
||||
if ts and (t["first_scored_at"] is None or ts < t["first_scored_at"]):
|
||||
t["first_scored_at"] = ts
|
||||
|
||||
existing = {
|
||||
up.email: up
|
||||
for up in (
|
||||
await db.execute(select(UserPoints).where(UserPoints.email.in_(targets)))
|
||||
).scalars()
|
||||
}
|
||||
for email, t in agg.items():
|
||||
row = existing.get(email)
|
||||
if row is None:
|
||||
row = UserPoints(email=email)
|
||||
db.add(row)
|
||||
for col, val in t.items():
|
||||
setattr(row, col, val)
|
||||
row.updated_at = now_utc()
|
||||
|
||||
log.info(
|
||||
"user_points: %d명 누적 갱신 (%s)",
|
||||
len(agg),
|
||||
", ".join(f"{e}={t['total_points']}p" for e, t in agg.items()),
|
||||
)
|
||||
return len(agg)
|
||||
284
backend/app/services/schedule_fetch.py
Normal file
@ -0,0 +1,284 @@
|
||||
"""경기 일정 외부 수집 — 매일 워커가 호출(크롤링).
|
||||
|
||||
소스(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
|
||||
from ..teams_data import code_for_tla
|
||||
|
||||
log = logging.getLogger("triplepick.schedule")
|
||||
|
||||
KST = timezone(timedelta(hours=9))
|
||||
|
||||
# football-data stage → 녹아웃 라운드 라벨(한글). 조별리그는 group 으로 따로 처리.
|
||||
# 2026 포맷(48개국)은 32강(LAST_32)부터 토너먼트가 시작된다.
|
||||
KO_STAGE_LABEL = {
|
||||
"LAST_32": "32강",
|
||||
"LAST_16": "16강",
|
||||
"QUARTER_FINALS": "8강",
|
||||
"SEMI_FINALS": "4강",
|
||||
"THIRD_PLACE": "3·4위전",
|
||||
"FINAL": "결승",
|
||||
}
|
||||
# "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_all_groups_football_data() -> list[dict]:
|
||||
"""football-data 에서 전체 조별리그 + 녹아웃 토너먼트 일정 수집.
|
||||
|
||||
반환: [{teamA, teamB, group('A'..'L' 또는 ''), roundLabel('' 또는 '32강'..), kickoffKst, venue}]
|
||||
조별리그는 group 으로, 32강 이후 토너먼트는 roundLabel 로 구분한다(tla→코드).
|
||||
녹아웃 경기는 대진이 확정되기 전엔 팀이 TBD 라 소스가 tla 를 주지 않아 자동 제외되고,
|
||||
조별리그 종료로 대진이 확정되면 다음 동기화에서 자동 등장한다.
|
||||
"""
|
||||
if not settings.football_data_token:
|
||||
log.warning("schedule(all): 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", []):
|
||||
stage = m.get("stage")
|
||||
if stage == "GROUP_STAGE":
|
||||
g = (m.get("group") or "").replace("GROUP_", "") # 'A'..'L'
|
||||
round_label = ""
|
||||
elif stage in KO_STAGE_LABEL:
|
||||
g = "" # 녹아웃은 조 없음 — roundLabel 로 구분
|
||||
round_label = KO_STAGE_LABEL[stage]
|
||||
else:
|
||||
continue # 예선/플레이오프 등은 제외
|
||||
ht = (m.get("homeTeam") or {}).get("tla")
|
||||
at = (m.get("awayTeam") or {}).get("tla")
|
||||
if not ht or not at:
|
||||
continue # 대진 미확정(TBD) — 확정 후 다음 동기화에서 등장
|
||||
utc = m.get("utcDate")
|
||||
if not utc:
|
||||
continue
|
||||
kickoff = (
|
||||
datetime.fromisoformat(utc.replace("Z", "+00:00")).astimezone(KST).isoformat()
|
||||
)
|
||||
out.append(
|
||||
{
|
||||
"teamA": code_for_tla(ht),
|
||||
"teamB": code_for_tla(at),
|
||||
"group": g,
|
||||
"roundLabel": round_label,
|
||||
"kickoffKst": kickoff,
|
||||
"venue": m.get("venue") or "",
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
async def fetch_schedule() -> list[dict]:
|
||||
"""일정 수집. 전체 조 모드면 football-data 전 조별리그, 아니면 단일 조."""
|
||||
if settings.schedule_all_groups:
|
||||
try:
|
||||
records = await _fetch_all_groups_football_data()
|
||||
log.info("schedule: 전체 조별리그 %d경기 수집", len(records))
|
||||
if records:
|
||||
return records
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.error("schedule(all) 실패: %s — 단일 조 폴백", e)
|
||||
|
||||
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 []
|
||||
|
||||
|
||||
# ── 경기 결과(스코어) 수집 — 자동 정산용 ─────────────────────
|
||||
def _extract_ft(m: dict) -> tuple[int, int] | None:
|
||||
"""openfootball 매치에서 정규시간 스코어 추출(다양한 표기 포괄)."""
|
||||
sc = m.get("score")
|
||||
if isinstance(sc, dict):
|
||||
ft = sc.get("ft")
|
||||
if isinstance(ft, (list, tuple)) and len(ft) == 2 and ft[0] is not None:
|
||||
return int(ft[0]), int(ft[1])
|
||||
if m.get("score1") is not None and m.get("score2") is not None:
|
||||
return int(m["score1"]), int(m["score2"])
|
||||
return None
|
||||
|
||||
|
||||
async def _results_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
|
||||
ft = _extract_ft(m)
|
||||
if ft is None: # 아직 미진행/미집계
|
||||
continue
|
||||
out.append({"teamA": a, "teamB": b, "scoreA": ft[0], "scoreB": ft[1]})
|
||||
return out
|
||||
|
||||
|
||||
async def _results_football_data() -> list[dict]:
|
||||
if not settings.football_data_token:
|
||||
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("status") != "FINISHED":
|
||||
continue
|
||||
ht = m.get("homeTeam") or {}
|
||||
at = m.get("awayTeam") or {}
|
||||
# 일정 수집과 동일하게 tla(3글자) → 코드. 풀네임 매핑(code_for)은 일부 팀만
|
||||
# 커버해 누락이 생기므로 tla 우선, 없을 때만 풀네임 폴백.
|
||||
a = code_for_tla(ht.get("tla") or "") or code_for(ht.get("name") or "")
|
||||
b = code_for_tla(at.get("tla") or "") or code_for(at.get("name") or "")
|
||||
if not a or not b:
|
||||
continue
|
||||
ft = ((m.get("score") or {}).get("fullTime") or {})
|
||||
if ft.get("home") is None or ft.get("away") is None:
|
||||
continue
|
||||
# 라운드 표기 — 같은 두 팀이 조별리그·토너먼트에서 만나도 정산을 구분(없으면 "").
|
||||
round_label = KO_STAGE_LABEL.get(m.get("stage") or "", "")
|
||||
out.append({
|
||||
"teamA": a, "teamB": b, "scoreA": int(ft["home"]), "scoreB": int(ft["away"]),
|
||||
"roundLabel": round_label,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
async def fetch_results() -> list[dict]:
|
||||
"""확정된 경기 스코어 수집 → [{teamA, teamB, scoreA, scoreB}]. 실패 시 빈 리스트.
|
||||
|
||||
소스는 result_source(없으면 schedule_source). openfootball 은 결과 미게시라
|
||||
자동 종료에는 football-data 를 권장.
|
||||
"""
|
||||
src = settings.effective_result_source
|
||||
try:
|
||||
if src == "openfootball":
|
||||
out = await _results_openfootball()
|
||||
elif src == "football-data":
|
||||
out = await _results_football_data()
|
||||
else:
|
||||
return []
|
||||
if out:
|
||||
log.info("results: %s 에서 %d경기 스코어 수집", src, len(out))
|
||||
return out
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.error("results fetch 실패(%s): %s", src, e)
|
||||
return []
|
||||
94
backend/app/services/schedule_sync.py
Normal file
@ -0,0 +1,94 @@
|
||||
"""수집한 일정을 DB에 반영 — 워커(스케줄링 서버)가 매일 호출.
|
||||
|
||||
전체 조별리그 모드: 12개조 72경기를 적재. 결과/스코어는 전 경기 표시.
|
||||
- 기존 경기: (팀쌍, 순서 무관) 매칭 → 킥오프/venue/투표시간만 갱신. 결과·예측·crowd·팀명·라운드 보존.
|
||||
- 신규 경기: teams_data(48개국)로 팀 구성 + matchId 생성 + crowd 초기화하여 삽입.
|
||||
종료(result) 경기는 시각 변경하지 않음.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..config import settings
|
||||
from ..models import CrowdStats, Match
|
||||
from ..schedule_data import lock_at, opens_at, parse_kickoff
|
||||
from ..teams_data import team_info
|
||||
|
||||
log = logging.getLogger("triplepick.schedule")
|
||||
KST = timezone(timedelta(hours=9))
|
||||
|
||||
# 녹아웃 라운드 라벨 → match_id 접두어(조가 없는 토너먼트 경기 식별용)
|
||||
KO_PREFIX = {"32강": "R32", "16강": "R16", "8강": "QF", "4강": "SF", "3·4위전": "P3", "결승": "F"}
|
||||
|
||||
|
||||
def _stage_prefix(group: str, round_label: str) -> str:
|
||||
"""경기 식별 접두어 — 조별리그는 조 문자, 녹아웃은 라운드 코드.
|
||||
같은 두 팀이 조별리그와 결승에서 다시 만나도 서로 다른 경기로 구분된다."""
|
||||
return group or KO_PREFIX.get(round_label, "KO")
|
||||
|
||||
|
||||
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: dict[tuple, Match] = {}
|
||||
for m in existing:
|
||||
p = _stage_prefix(m.group, m.round_label)
|
||||
by_pair[(p, m.team_a_code, m.team_b_code)] = m
|
||||
by_pair[(p, m.team_b_code, m.team_a_code)] = m
|
||||
|
||||
updated = inserted = skipped = 0
|
||||
for rec in records:
|
||||
a, b = rec["teamA"], rec["teamB"]
|
||||
round_label = rec.get("roundLabel", "")
|
||||
# 조별리그는 조 문자, 녹아웃은 조 없음(""). roundLabel 이 있으면 토너먼트 경기.
|
||||
group = "" if round_label else rec.get("group", settings.featured_group)
|
||||
prefix = _stage_prefix(group, round_label)
|
||||
kickoff = parse_kickoff(rec["kickoffKst"])
|
||||
m = by_pair.get((prefix, 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 = team_info(a), team_info(b)
|
||||
kst_date = kickoff.astimezone(KST).strftime("%Y%m%d")
|
||||
match_id = f"{prefix}_{a}_{b}_{kst_date}"
|
||||
new = Match(
|
||||
match_id=match_id,
|
||||
round_label=round_label,
|
||||
group=group,
|
||||
team_a_name=ta["name"], team_a_short=ta["shortName"],
|
||||
team_a_code=ta["code"], team_a_flag=ta["flag"],
|
||||
team_b_name=tb["name"], team_b_short=tb["shortName"],
|
||||
team_b_code=tb["code"], team_b_flag=tb["flag"],
|
||||
venue=rec.get("venue", ""),
|
||||
hook_text=f"{ta['shortName']} vs {tb['shortName']}",
|
||||
kickoff_at=kickoff,
|
||||
opens_at=opens_at(kickoff),
|
||||
lock_at=lock_at(kickoff),
|
||||
status="scheduled",
|
||||
)
|
||||
db.add(new)
|
||||
db.add(CrowdStats(match_id=match_id, total=0, team_a_win=0, draw=0, team_b_win=0))
|
||||
by_pair[(prefix, a, b)] = new
|
||||
by_pair[(prefix, b, a)] = new
|
||||
inserted += 1
|
||||
|
||||
await db.commit()
|
||||
result = {"updated": updated, "inserted": inserted, "skipped": skipped}
|
||||
log.info("schedule sync: %s", result)
|
||||
return result
|
||||
845
backend/app/services/songs.py
Normal file
@ -0,0 +1,845 @@
|
||||
"""오늘의 응원가 — 경기 맥락 반영 자동 생성 파이프라인 (KBO 전용).
|
||||
|
||||
흐름 (워커 tick_songs, song_tick_seconds 주기):
|
||||
1) 킥오프 song_generate_minutes_before(기본 150분) 전에 든 경기 → 팀별로
|
||||
경기 맥락(전날 결과·순위·연전 차수·선발투수) 조립 → LLM 이 가사·스타일 작성
|
||||
→ Suno 생성 작업 시작 (Song status=generating)
|
||||
2) generating 행 폴링 → 완료 시 트랙 URL 저장 (status=complete)
|
||||
|
||||
실패는 attempts 3회까지 다음 틱에 재시도. 데이터가 비어도(캐시 미스)
|
||||
가사는 팀·상대·경기 정보만으로 생성한다 — 조용한 전체 실패 없음.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import date, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from ..config import settings
|
||||
from ..database import SessionLocal
|
||||
from ..domain import ensure_aware, now_utc
|
||||
from ..models import DataCache, Match, Song, SongAudio
|
||||
from . import suno
|
||||
|
||||
log = logging.getLogger("triplepick.songs")
|
||||
|
||||
KST = timezone(timedelta(hours=9))
|
||||
MAX_ATTEMPTS = 3
|
||||
GENERATE_TIMEOUT_MIN = 30 # Suno 작업이 이 시간 넘게 미완이면 실패 처리
|
||||
|
||||
# 곡 스타일 — 전 곡 고정 (경기장 웅장 앤섬 컨셉, 레퍼런스 확정본).
|
||||
# LLM 은 가사·제목만 쓰고 스타일은 변주하지 않는다. MLS 만 soccer 로 치환.
|
||||
DEFAULT_STYLE = (
|
||||
"Korean baseball stadium cheer anthem, powerful brass fanfare, thumping "
|
||||
"drum corps, group chant call-and-response, gang vocals, energetic male "
|
||||
"crowd shouting, 128bpm, live stadium atmosphere"
|
||||
)
|
||||
|
||||
|
||||
def style_for(league: str) -> str:
|
||||
if league == "mls":
|
||||
return DEFAULT_STYLE.replace("baseball", "soccer")
|
||||
return DEFAULT_STYLE
|
||||
|
||||
# LLM 에게 주는 형식 레퍼런스 (가사 구조·스타일 문구의 톤)
|
||||
_REFERENCE = """[Intro - Brass Fanfare]
|
||||
(두! 산! 베어스!) (두! 산! 베어스!)
|
||||
|
||||
[Verse 1]
|
||||
잠실의 함성이 하늘을 울려
|
||||
곰들의 심장이 뜨겁게 뛴다
|
||||
|
||||
[Chorus]
|
||||
두산! (두산!) 베어스! (베어스!)
|
||||
날려버려 담장 너머로
|
||||
두산! (두산!) 베어스! (베어스!)
|
||||
오늘 승리는 우리의 것
|
||||
|
||||
[Bridge - Chant]
|
||||
(두산 승리! 두산 승리!)
|
||||
잠실을 가득 채운 함성
|
||||
|
||||
[Final Chorus - Key Up]
|
||||
두산! (두산!) 베어스! (베어스!)
|
||||
잠실 하늘 높이 울려라
|
||||
|
||||
[Outro]
|
||||
(두! 산! 베어스!) 최강 두산!"""
|
||||
|
||||
|
||||
# ── 경기 맥락 조립 ─────────────────────────────────────────────
|
||||
def _fmt_standing(name: str, st: dict | None) -> str:
|
||||
if not st:
|
||||
return f"{name}: 순위 정보 없음"
|
||||
parts = [f"{st.get('rank')}위"] if st.get("rank") else []
|
||||
if st.get("w") is not None:
|
||||
parts.append(f"{st.get('w')}승 {st.get('d') or 0}무 {st.get('l')}패")
|
||||
if st.get("wra"):
|
||||
parts.append(f"승률 {st.get('wra')}")
|
||||
if st.get("gb") not in (None, "", "0.0", 0):
|
||||
parts.append(f"게임차 {st.get('gb')}")
|
||||
if st.get("last5"):
|
||||
parts.append(f"최근 5경기 {st.get('last5')}")
|
||||
return f"{name}: " + ", ".join(parts)
|
||||
|
||||
|
||||
def _fmt_starter(label: str, s: dict | None) -> str | None:
|
||||
if not s or not s.get("name"):
|
||||
return None
|
||||
bits = [s["name"]]
|
||||
if s.get("era") not in (None, ""):
|
||||
bits.append(f"평균자책 {s['era']}")
|
||||
if s.get("w") is not None:
|
||||
bits.append(f"{s.get('w')}승 {s.get('l') or 0}패")
|
||||
return f"{label} 선발: " + " ".join(bits)
|
||||
|
||||
|
||||
async def _yesterday_info(db, m: Match, code: str, name: str) -> tuple[str | None, dict]:
|
||||
"""(전날 결과 한 줄 + 활약 하이라이트, 시즌타율 맵). 경기 없으면 맵은 빈 dict."""
|
||||
line, g = await _yesterday_result(db, m, code, name)
|
||||
avg_map: dict = {}
|
||||
if g is not None and m.league in ("kbo", "mlb"):
|
||||
try:
|
||||
stats = await (
|
||||
_mlb_team_game_stats(g, code)
|
||||
if m.league == "mlb"
|
||||
else _kbo_team_game_stats(g, code)
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning("어제 경기 기록 조회 실패 %s: %s", g.match_id, e)
|
||||
stats = None
|
||||
else:
|
||||
stats = None
|
||||
if stats:
|
||||
avg_map = stats.get("avg") or {}
|
||||
if line and stats.get("highlights"):
|
||||
line += " · 활약: " + ", ".join(stats["highlights"])
|
||||
return line, avg_map
|
||||
|
||||
|
||||
async def _yesterday_result(
|
||||
db, m: Match, code: str, name: str
|
||||
) -> tuple[str | None, Match | None]:
|
||||
"""해당 팀의 전날 경기 결과 한 줄 + 그 경기 행 (없으면 (안내문, None))."""
|
||||
day = ensure_aware(m.kickoff_at).astimezone(KST).date() - timedelta(days=1)
|
||||
lo = ensure_aware(m.kickoff_at).astimezone(KST).replace(
|
||||
hour=0, minute=0, second=0, microsecond=0
|
||||
) - timedelta(days=1)
|
||||
hi = lo + timedelta(days=1)
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(Match).where(
|
||||
Match.league == m.league,
|
||||
Match.result_outcome.is_not(None),
|
||||
Match.kickoff_at >= lo.astimezone(timezone.utc),
|
||||
Match.kickoff_at < hi.astimezone(timezone.utc),
|
||||
(Match.team_a_code == code) | (Match.team_b_code == code),
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
if not rows:
|
||||
return f"어제({day.month}/{day.day})는 경기가 없었다", None
|
||||
g = rows[-1]
|
||||
is_a = g.team_a_code == code
|
||||
my, opp = (
|
||||
(g.result_score_a, g.result_score_b) if is_a else (g.result_score_b, g.result_score_a)
|
||||
)
|
||||
opp_name = g.team_b_short if is_a else g.team_a_short
|
||||
if my is None or opp is None:
|
||||
return None, None
|
||||
verdict = "승리" if my > opp else "패배" if my < opp else "무승부"
|
||||
margin = abs((my or 0) - (opp or 0))
|
||||
tight = " (1점차 석패)" if verdict == "패배" and margin == 1 else (
|
||||
" (1점차 신승)" if verdict == "승리" and margin == 1 else ""
|
||||
)
|
||||
return f"어제 {opp_name}전 {my}:{opp} {verdict}{tight}", g
|
||||
|
||||
|
||||
async def _series_line(db, m: Match) -> str | None:
|
||||
"""같은 팀 상대 연전 차수 — '3연전 중 2차전' 형태 (단일 경기면 None)."""
|
||||
center = ensure_aware(m.kickoff_at).astimezone(KST).date()
|
||||
lo = center - timedelta(days=3)
|
||||
hi = center + timedelta(days=4)
|
||||
pair = {m.team_a_code, m.team_b_code}
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(Match).where(
|
||||
Match.league == m.league,
|
||||
Match.team_a_code.in_(pair),
|
||||
Match.team_b_code.in_(pair),
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
days: list[date] = sorted(
|
||||
{
|
||||
ensure_aware(r.kickoff_at).astimezone(KST).date()
|
||||
for r in rows
|
||||
if lo <= ensure_aware(r.kickoff_at).astimezone(KST).date() < hi
|
||||
}
|
||||
)
|
||||
# 오늘을 포함해 연속된 날짜 구간만 자른다
|
||||
if center not in days:
|
||||
return None
|
||||
run = [center]
|
||||
for d in reversed([d for d in days if d < center]):
|
||||
if (run[0] - d).days == 1:
|
||||
run.insert(0, d)
|
||||
else:
|
||||
break
|
||||
for d in [d for d in days if d > center]:
|
||||
if (d - run[-1]).days == 1:
|
||||
run.append(d)
|
||||
else:
|
||||
break
|
||||
if len(run) < 2:
|
||||
return None
|
||||
idx = run.index(center) + 1
|
||||
return f"{len(run)}연전 중 {idx}차전"
|
||||
|
||||
|
||||
async def fetch_lineups(m: Match) -> dict | None:
|
||||
"""오늘 선발 라인업 조회 (발표 전이면 announced=False). KBO=네이버, MLB=공식 API.
|
||||
|
||||
MLS 는 라인업 소스 미연동 — None (라인업 없이 생성).
|
||||
"""
|
||||
if m.league == "mlb":
|
||||
return await _fetch_lineups_mlb(m)
|
||||
if m.league == "kbo":
|
||||
return await _fetch_lineups_kbo(m)
|
||||
return None
|
||||
|
||||
|
||||
async def _fetch_lineups_kbo(m: Match) -> dict | None:
|
||||
"""네이버 preview fullLineUp — 발표 전엔 선발투수 1명만. 양팀 타자 8명 이상 = 발표."""
|
||||
import httpx
|
||||
|
||||
from .baseball_details import UA, naver_game_id_candidates
|
||||
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
for gid in naver_game_id_candidates(m):
|
||||
try:
|
||||
r = await client.get(
|
||||
f"{settings.naver_api_base}/schedule/games/{gid}/preview",
|
||||
headers=UA,
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
except Exception: # noqa: BLE001 — gameId 후보 불일치는 다음 후보로
|
||||
continue
|
||||
if not data.get("success"):
|
||||
continue
|
||||
p = (data.get("result") or {}).get("previewData") or {}
|
||||
|
||||
def batters(key: str) -> list[dict]:
|
||||
fl = ((p.get(key) or {}).get("fullLineUp")) or []
|
||||
return [
|
||||
e for e in fl
|
||||
if e.get("playerName") and e.get("positionName") != "선발투수"
|
||||
]
|
||||
|
||||
away, home = batters("awayTeamLineUp"), batters("homeTeamLineUp")
|
||||
return {
|
||||
"away": away,
|
||||
"home": home,
|
||||
"announced": len(away) >= 8 and len(home) >= 8,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
async def _mlb_game_pk(m: Match) -> int | None:
|
||||
"""MLB schedule 에서 이 경기의 gamePk 해석 (더블헤더는 차수 매칭)."""
|
||||
import httpx
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from ..teams_baseball import MLB_ID_TO_CODE
|
||||
from .baseball_sync import match_seq
|
||||
|
||||
kick = ensure_aware(m.kickoff_at)
|
||||
date_kst = kick.astimezone(KST).strftime("%Y%m%d")
|
||||
start = (kick - timedelta(days=1)).date().isoformat()
|
||||
end = kick.date().isoformat()
|
||||
async with httpx.AsyncClient(timeout=15) as c:
|
||||
r = await c.get(
|
||||
f"{settings.mlb_api_base}/v1/schedule?sportId=1"
|
||||
f"&startDate={start}&endDate={end}"
|
||||
)
|
||||
r.raise_for_status()
|
||||
cands: list[tuple[str, int]] = []
|
||||
for day in r.json().get("dates") or []:
|
||||
for g in day.get("games") or []:
|
||||
a = MLB_ID_TO_CODE.get((g["teams"]["away"]["team"] or {}).get("id"))
|
||||
b = MLB_ID_TO_CODE.get((g["teams"]["home"]["team"] or {}).get("id"))
|
||||
gd = g.get("gameDate")
|
||||
if not a or not b or not gd:
|
||||
continue
|
||||
g_kst = (
|
||||
datetime.fromisoformat(gd.replace("Z", "+00:00"))
|
||||
.astimezone(KST).strftime("%Y%m%d")
|
||||
)
|
||||
if a == m.team_a_code and b == m.team_b_code and g_kst == date_kst:
|
||||
cands.append((gd, g.get("gamePk")))
|
||||
cands.sort()
|
||||
idx = match_seq(m.match_id) - 1
|
||||
return cands[idx][1] if idx < len(cands) else None
|
||||
|
||||
|
||||
async def _mlb_boxscore_teams(pk: int) -> dict | None:
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient(timeout=15) as c:
|
||||
r = await c.get(f"{settings.mlb_api_base}/v1/game/{pk}/boxscore")
|
||||
r.raise_for_status()
|
||||
return r.json().get("teams") or {}
|
||||
|
||||
|
||||
async def _fetch_lineups_mlb(m: Match) -> dict | None:
|
||||
"""MLB boxscore battingOrder — 발표 전엔 빈 배열. 양팀 9명 이상 = 발표.
|
||||
|
||||
boxscore seasonStats 로 각 타자의 시즌 타율도 함께 싣는다.
|
||||
"""
|
||||
pk = await _mlb_game_pk(m)
|
||||
if not pk:
|
||||
return None
|
||||
teams = await _mlb_boxscore_teams(pk)
|
||||
if teams is None:
|
||||
return None
|
||||
|
||||
def batters(side_key: str) -> list[dict]:
|
||||
t = teams.get(side_key) or {}
|
||||
players = t.get("players") or {}
|
||||
out = []
|
||||
for pid in t.get("battingOrder") or []:
|
||||
p = players.get(f"ID{pid}") or {}
|
||||
name = (p.get("person") or {}).get("fullName")
|
||||
if name:
|
||||
avg = (((p.get("seasonStats") or {}).get("batting")) or {}).get("avg")
|
||||
out.append({
|
||||
"playerName": name,
|
||||
"positionName": (p.get("position") or {}).get("abbreviation", ""),
|
||||
"avg": avg,
|
||||
})
|
||||
return out
|
||||
|
||||
away, home = batters("away"), batters("home")
|
||||
return {"away": away, "home": home, "announced": len(away) >= 9 and len(home) >= 9}
|
||||
|
||||
|
||||
# ── 최근 경기 타자 기록 (활약 하이라이트 + 시즌 타율 맵) ────────
|
||||
async def _kbo_team_game_stats(g: Match, code: str) -> dict | None:
|
||||
"""종료된 KBO 경기 record → 그 팀 타자 시즌타율 맵 + 활약(홈런·멀티히트)."""
|
||||
import httpx
|
||||
|
||||
from .baseball_details import UA, naver_game_id_candidates
|
||||
|
||||
is_away = g.team_a_code == code
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
for gid in naver_game_id_candidates(g):
|
||||
try:
|
||||
r = await client.get(
|
||||
f"{settings.naver_api_base}/schedule/games/{gid}/record",
|
||||
headers=UA,
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
except Exception: # noqa: BLE001 — gameId 후보 불일치는 다음 후보로
|
||||
continue
|
||||
if not data.get("success"):
|
||||
continue
|
||||
rec = (data.get("result") or {}).get("recordData") or {}
|
||||
batters = (rec.get("battersBoxscore") or {}).get(
|
||||
"away" if is_away else "home"
|
||||
) or []
|
||||
if not batters:
|
||||
continue
|
||||
avg = {b["name"]: b.get("hra") for b in batters if b.get("name")}
|
||||
hi = []
|
||||
for b in batters:
|
||||
if b.get("hr"):
|
||||
hi.append(f"{b['name']} 홈런 {b['hr']}방")
|
||||
elif (b.get("hit") or 0) >= 3:
|
||||
hi.append(f"{b['name']} {b['hit']}안타 맹타")
|
||||
return {"avg": avg, "highlights": hi[:4]}
|
||||
return None
|
||||
|
||||
|
||||
async def _mlb_team_game_stats(g: Match, code: str) -> dict | None:
|
||||
"""종료된 MLB 경기 boxscore → 그 팀 활약(홈런·멀티히트)."""
|
||||
pk = await _mlb_game_pk(g)
|
||||
if not pk:
|
||||
return None
|
||||
teams = await _mlb_boxscore_teams(pk)
|
||||
t = (teams or {}).get("away" if g.team_a_code == code else "home") or {}
|
||||
hi = []
|
||||
for p in (t.get("players") or {}).values():
|
||||
st = ((p.get("stats") or {}).get("batting")) or {}
|
||||
name = (p.get("person") or {}).get("fullName")
|
||||
if not name or not st:
|
||||
continue
|
||||
if st.get("homeRuns"):
|
||||
hi.append(f"{name} 홈런 {st['homeRuns']}방")
|
||||
elif (st.get("hits") or 0) >= 3:
|
||||
hi.append(f"{name} {st['hits']}안타 맹타")
|
||||
return {"avg": {}, "highlights": hi[:4]}
|
||||
|
||||
|
||||
def _lineup_line(lineups: dict | None, side: str, avg_map: dict | None = None) -> str | None:
|
||||
if not lineups or not lineups.get("announced"):
|
||||
return None
|
||||
avg_map = avg_map or {}
|
||||
parts = []
|
||||
for i, e in enumerate(lineups["away" if side == "a" else "home"][:10]):
|
||||
avg = e.get("avg") or avg_map.get(e["playerName"])
|
||||
tail = f", 타율 {avg}" if avg else ""
|
||||
parts.append(f"{i + 1}번 {e['playerName']}({e.get('positionName', '')}{tail})")
|
||||
return f"오늘 확정 선발 라인업: {', '.join(parts)}" if parts else None
|
||||
|
||||
|
||||
async def build_context(
|
||||
db, m: Match, side: str, lineups: dict | None = None
|
||||
) -> tuple[str, str, str]:
|
||||
"""(팀코드, 팀명, 맥락 텍스트). side = 'a'(원정) | 'b'(홈)."""
|
||||
code = m.team_a_code if side == "a" else m.team_b_code
|
||||
name = m.team_a_short if side == "a" else m.team_b_short
|
||||
opp_name = m.team_b_short if side == "a" else m.team_a_short
|
||||
opp_code = m.team_b_code if side == "a" else m.team_a_code
|
||||
home = "홈" if side == "b" else "원정"
|
||||
kick = ensure_aware(m.kickoff_at).astimezone(KST)
|
||||
|
||||
lines = [
|
||||
f"우리 팀: {name} ({home} 경기)",
|
||||
f"오늘 경기: {kick.month}/{kick.day} {kick:%H:%M} {m.venue or ''} — 상대 {opp_name}",
|
||||
]
|
||||
|
||||
st_row = await db.get(DataCache, f"standings:{m.league}")
|
||||
st = st_row.payload if st_row else {}
|
||||
lines.append(_fmt_standing(name, st.get(code)))
|
||||
lines.append(_fmt_standing(f"상대 {opp_name}", st.get(opp_code)))
|
||||
|
||||
prev_row = await db.get(DataCache, f"preview:{m.match_id}")
|
||||
prev = prev_row.payload if prev_row else {}
|
||||
my_starter = _fmt_starter("우리 팀", prev.get("starterA" if side == "a" else "starterB"))
|
||||
opp_starter = _fmt_starter("상대", prev.get("starterB" if side == "a" else "starterA"))
|
||||
for s in (my_starter, opp_starter):
|
||||
if s:
|
||||
lines.append(s)
|
||||
vs = prev.get("seasonVs")
|
||||
if vs:
|
||||
mine = vs.get("aWin") if side == "a" else vs.get("bWin")
|
||||
theirs = vs.get("bWin") if side == "a" else vs.get("aWin")
|
||||
if mine is not None and theirs is not None:
|
||||
lines.append(f"시즌 상대전적 {mine}승 {vs.get('draw') or 0}무 {theirs}패")
|
||||
|
||||
y, avg_map = await _yesterday_info(db, m, code, name)
|
||||
if y:
|
||||
lines.append(y)
|
||||
series = await _series_line(db, m)
|
||||
if series:
|
||||
lines.append(f"오늘은 {opp_name}와의 {series}")
|
||||
lu = _lineup_line(lineups, side, avg_map)
|
||||
if lu:
|
||||
lines.append(lu)
|
||||
|
||||
return code, name, "\n".join(x for x in lines if x)
|
||||
|
||||
|
||||
# ── LLM 작사 ───────────────────────────────────────────────────
|
||||
_LEAGUE_LABEL = {
|
||||
"kbo": "한국 프로야구(KBO)",
|
||||
"mlb": "메이저리그(MLB)",
|
||||
"mls": "미국 프로축구(MLS)",
|
||||
}
|
||||
|
||||
|
||||
def _lyrics_prompt(team_name: str, context: str, league: str = "kbo") -> str:
|
||||
return (
|
||||
f"너는 {_LEAGUE_LABEL.get(league, '프로야구')} 응원가 전문 작사가다. "
|
||||
"아래 오늘 경기 정보를 바탕으로 "
|
||||
f"'{team_name}'의 **오늘의 응원가**를 한국어로 만들어라"
|
||||
+ (" (팀명·선수명은 한국 팬에게 익숙한 표기로)" if league in ("mlb", "mls") else "")
|
||||
+ ".\n\n"
|
||||
f"[오늘 경기 정보]\n{context}\n\n"
|
||||
"[요구사항]\n"
|
||||
"- 경기장에서 수만 관중이 떼창하는 웅장한 스타디움 앤섬\n"
|
||||
"- 오늘 경기 맥락(어제/최근 결과 설욕·기세, 순위 싸움, 연전 차수, 선발·라인업)을 "
|
||||
"가사에 구체적으로 녹일 것 — 선수 실명 사용 가능\n"
|
||||
"- 숫자는 자연스럽게 표기 (13:1, 78승, 2차전 등 — 발음 변환은 시스템이 처리)\n"
|
||||
"- 괄호로 관중 콜앤리스폰스 파트 표기, 섹션 태그에 연주 지시 포함\n"
|
||||
"- 섹션 구성 필수: [Intro] → [Verse 1] → [Chorus] → [Verse 2] → [Chorus] → "
|
||||
"[Bridge] → [Final Chorus] → [Outro] 전부 포함 (생략 금지), 가사 본문 700자 이상\n\n"
|
||||
f"[가사 형식 레퍼런스 — 구조와 톤만 참고, 내용은 오늘 경기에 맞게 새로 쓸 것]\n{_REFERENCE}\n\n"
|
||||
"다음 키를 가진 JSON 객체 하나만 출력하라:\n"
|
||||
' "title": 곡 제목 (한국어, 25자 이내, 오늘 경기 느낌이 나게),\n'
|
||||
' "lyrics": 위 형식의 전체 가사\n'
|
||||
)
|
||||
|
||||
|
||||
# ── 가사 숫자 한글화 (Suno 가 아라비아 숫자를 잘못 읽는 문제 방지) ──
|
||||
_SINO = "영일이삼사오육칠팔구"
|
||||
_NATIVE = {
|
||||
1: "한", 2: "두", 3: "세", 4: "네", 5: "다섯", 6: "여섯", 7: "일곱",
|
||||
8: "여덟", 9: "아홉", 10: "열", 11: "열한", 12: "열두", 13: "열세",
|
||||
14: "열네", 15: "열다섯", 16: "열여섯", 17: "열일곱", 18: "열여덟",
|
||||
19: "열아홉", 20: "스무",
|
||||
}
|
||||
|
||||
|
||||
def _sino(n: int) -> str:
|
||||
"""한자어 수사 — 78 → 칠십팔 (0~9999)."""
|
||||
if n == 0:
|
||||
return "영"
|
||||
parts = []
|
||||
for unit, name in ((1000, "천"), (100, "백"), (10, "십")):
|
||||
d, n = divmod(n, unit)
|
||||
if d:
|
||||
parts.append(("" if d == 1 else _SINO[d]) + name)
|
||||
if n:
|
||||
parts.append(_SINO[n])
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _native(n: int) -> str:
|
||||
"""고유어 수사 — 7 → 일곱 (범위 밖은 한자어 폴백)."""
|
||||
return _NATIVE.get(n) or _sino(n)
|
||||
|
||||
|
||||
# 서수 관형형 — 1~4는 첫/두/세/네, 이후는 고유어 수사 그대로 (다섯 번째)
|
||||
_ORDINAL = {1: "첫", 2: "두", 3: "세", 4: "네"}
|
||||
|
||||
|
||||
def hangulize_numbers(text: str) -> str:
|
||||
"""가사 속 아라비아 숫자를 한글 발음으로 치환 (Suno 전송본 전용).
|
||||
|
||||
1승→일승, 78승→칠십팔승, 7게임→일곱 게임, 5:3→오 대 삼, 0.312→영점삼일이.
|
||||
[Verse 1] 같은 섹션 태그는 Suno 구조 인식용이라 건드리지 않는다.
|
||||
"""
|
||||
parts = re.split(r"(\[[^\]]*\])", text)
|
||||
return "".join(
|
||||
p if p.startswith("[") else _hangulize_plain(p) for p in parts
|
||||
)
|
||||
|
||||
|
||||
def _hangulize_plain(text: str) -> str:
|
||||
# 소수(타율 등): 0.312 → 영점삼일이 (소수부는 자리별 낭독)
|
||||
text = re.sub(
|
||||
r"(\d)\.(\d+)",
|
||||
lambda m: _sino(int(m.group(1))) + "점" + "".join(_SINO[int(c)] for c in m.group(2)),
|
||||
text,
|
||||
)
|
||||
# 스코어: 5:3 / 5대3 → 오 대 삼
|
||||
text = re.sub(
|
||||
r"(\d+)\s*[:대]\s*(\d+)",
|
||||
lambda m: f"{_sino(int(m.group(1)))} 대 {_sino(int(m.group(2)))}",
|
||||
text,
|
||||
)
|
||||
# 아웃 카운트는 야구 관례상 영어 수사 (2아웃→투아웃)
|
||||
text = re.sub(
|
||||
r"([123])\s*아웃",
|
||||
lambda m: {1: "원", 2: "투", 3: "쓰리"}[int(m.group(1))] + "아웃",
|
||||
text,
|
||||
)
|
||||
# 서수는 관형 고유어 수사 (1번째→첫 번째, 4번째→네 번째, 5번째→다섯 번째)
|
||||
text = re.sub(
|
||||
r"(\d+)\s*번째",
|
||||
lambda m: (_ORDINAL.get(int(m.group(1))) or _native(int(m.group(1)))) + " 번째",
|
||||
text,
|
||||
)
|
||||
# 고유어 조수사 (7게임→일곱 게임, 2방→두 방, 3개→세 개)
|
||||
text = re.sub(
|
||||
r"(\d+)\s*(게임|경기|개|명|방|골|마리|살|바퀴)",
|
||||
lambda m: f"{_native(int(m.group(1)))} {m.group(2)}",
|
||||
text,
|
||||
)
|
||||
# 남은 모든 숫자는 한자어 수사 (1위→일위, 2차전→이차전, 15호→십오호)
|
||||
return re.sub(r"\d+", lambda m: _sino(int(m.group(0))), text)
|
||||
|
||||
|
||||
def _parse_json(text: str) -> dict:
|
||||
t = text.strip()
|
||||
t = re.sub(r"^```(?:json)?\s*|\s*```$", "", t)
|
||||
m = re.search(r"\{.*\}", t, re.S)
|
||||
return json.loads(m.group(0) if m else t)
|
||||
|
||||
|
||||
async def write_lyrics(team_name: str, context: str, league: str = "kbo") -> dict:
|
||||
"""LLM 으로 {title, style, lyrics} 생성 — Claude 우선, GPT 폴백."""
|
||||
prompt = _lyrics_prompt(team_name, context, league)
|
||||
if settings.anthropic_api_key:
|
||||
from anthropic import AsyncAnthropic
|
||||
|
||||
client = AsyncAnthropic(api_key=settings.anthropic_api_key)
|
||||
msg = await client.messages.create(
|
||||
model=settings.anthropic_model,
|
||||
max_tokens=8000,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
text = "".join(b.text for b in msg.content if getattr(b, "type", "") == "text")
|
||||
return _parse_json(text)
|
||||
if settings.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},
|
||||
],
|
||||
response_format={"type": "json_object"},
|
||||
)
|
||||
return _parse_json(resp.choices[0].message.content or "{}")
|
||||
raise RuntimeError("작사용 LLM 키 미설정 (ANTHROPIC/OPENAI)")
|
||||
|
||||
|
||||
# ── 생성 시작 · 폴링 ───────────────────────────────────────────
|
||||
SONG_LEAGUES = ("kbo", "mlb", "mls") # 응원가 대상 리그
|
||||
# 라인업 발표를 기다리는 리그 — MLS 는 라인업 소스가 없어 윈도우 진입 즉시 생성
|
||||
LINEUP_LEAGUES = ("kbo", "mlb")
|
||||
|
||||
|
||||
def _song_leagues() -> list[str]:
|
||||
return [l for l in SONG_LEAGUES if l in settings.league_list]
|
||||
|
||||
|
||||
def _enabled() -> bool:
|
||||
return bool(settings.songs_enabled and settings.suno_api_key and _song_leagues())
|
||||
|
||||
|
||||
async def _start_one(db, m: Match, side: str, lineups: dict | None) -> bool:
|
||||
announced = bool(lineups and lineups.get("announced"))
|
||||
code, name, context = await build_context(db, m, side, lineups)
|
||||
row = (
|
||||
await db.execute(
|
||||
select(Song).where(Song.match_id == m.match_id, Song.team_code == code)
|
||||
)
|
||||
).scalars().first()
|
||||
if row:
|
||||
if row.attempts >= MAX_ATTEMPTS:
|
||||
return False
|
||||
if row.status == "generating":
|
||||
return False
|
||||
# complete 은 '라인업 없이 만든 곡 + 라인업 발표됨'일 때만 재생성(업그레이드)
|
||||
if row.status == "complete" and (row.with_lineup or not announced):
|
||||
return False
|
||||
|
||||
piece = await write_lyrics(name, context, m.league)
|
||||
title = str(piece.get("title") or f"{name} 오늘의 응원가").strip()[:40]
|
||||
style = style_for(m.league) # 전 곡 고정 스타일 — LLM 변주 없음
|
||||
lyrics = str(piece.get("lyrics") or "").strip()
|
||||
if not lyrics:
|
||||
raise RuntimeError("LLM 가사 비어있음")
|
||||
if len(lyrics) < 450:
|
||||
raise RuntimeError(f"가사 너무 짧음({len(lyrics)}자) — 재시도")
|
||||
# 표기용(화면 '가사 보기')은 원문 그대로 저장하고,
|
||||
# Suno 전송본만 숫자를 한글 발음으로 치환 (13:1 → 십삼 대 일)
|
||||
sung_lyrics = hangulize_numbers(lyrics)
|
||||
|
||||
task_id = await suno.start_generation(title, style, sung_lyrics)
|
||||
upgrade = bool(row and row.status == "complete")
|
||||
if row is None:
|
||||
row = Song(match_id=m.match_id, team_code=code)
|
||||
db.add(row)
|
||||
row.league = m.league
|
||||
row.date_kst = ensure_aware(m.kickoff_at).astimezone(KST).date()
|
||||
row.team_name = name
|
||||
row.title = title
|
||||
row.style = style
|
||||
row.lyrics = lyrics
|
||||
row.task_id = task_id
|
||||
row.status = "generating"
|
||||
row.error = ""
|
||||
row.with_lineup = announced
|
||||
# 업그레이드는 기존 트랙을 유지 — 새 곡 완성 시점에 교체 (재생 공백 없음)
|
||||
if not upgrade:
|
||||
row.tracks = []
|
||||
row.attempts = (row.attempts or 0) + 1
|
||||
await db.commit()
|
||||
log.info(
|
||||
"song 생성 시작: %s %s (task=%s, 라인업=%s%s)",
|
||||
m.match_id, name, task_id, announced, ", 업그레이드" if upgrade else "",
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def start_due_songs(db, force_today: bool = False) -> int:
|
||||
"""생성 윈도우에 든 경기의 팀별 응원가 생성 시작.
|
||||
|
||||
윈도우 안에서는 라인업 발표를 기다렸다가 생성하고, 킥오프
|
||||
song_lineup_fallback_minutes_before 전까지 미발표면 라인업 없이 생성한다.
|
||||
라인업 없이 만든 곡은 발표 후 자동 재생성(업그레이드).
|
||||
force_today=오늘 전 경기 즉시(라인업 대기 없이) 생성 — 테스트용.
|
||||
"""
|
||||
now = now_utc()
|
||||
conds = [
|
||||
Match.league.in_(_song_leagues()),
|
||||
Match.result_outcome.is_(None),
|
||||
Match.status.notin_(("cancelled", "finished")),
|
||||
]
|
||||
matches = (await db.execute(select(Match).where(*conds))).scalars().all()
|
||||
today = now.astimezone(KST).date()
|
||||
started = 0
|
||||
for m in matches:
|
||||
kick = ensure_aware(m.kickoff_at)
|
||||
mins = (kick - now).total_seconds() / 60
|
||||
if force_today:
|
||||
if kick.astimezone(KST).date() != today:
|
||||
continue
|
||||
elif not (0 < mins <= settings.song_generate_minutes_before):
|
||||
continue
|
||||
try:
|
||||
lineups = await fetch_lineups(m)
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning("lineup 조회 실패 %s: %s", m.match_id, e)
|
||||
lineups = None
|
||||
announced = bool(lineups and lineups.get("announced"))
|
||||
# 윈도우 내 라인업 대기 — 폴백 시점 전엔 발표될 때까지 생성 보류.
|
||||
# 라인업 소스가 없는 리그(MLS)는 윈도우 진입 즉시 생성.
|
||||
if (
|
||||
not force_today
|
||||
and m.league in LINEUP_LEAGUES
|
||||
and not announced
|
||||
and mins > settings.song_lineup_fallback_minutes_before
|
||||
):
|
||||
continue
|
||||
for side in ("a", "b"):
|
||||
try:
|
||||
if await _start_one(db, m, side, lineups):
|
||||
started += 1
|
||||
except Exception as e: # noqa: BLE001 — 팀 단위 독립 실패
|
||||
await db.rollback()
|
||||
log.error("song 생성 실패 %s side=%s: %s", m.match_id, side, e)
|
||||
return started
|
||||
|
||||
|
||||
def _mark_failed(row: Song, error: str) -> None:
|
||||
"""실패 처리 — 업그레이드 중이었으면(이전 트랙 보유) 이전 곡으로 복귀."""
|
||||
row.error = error
|
||||
if row.tracks:
|
||||
row.status = "complete"
|
||||
row.with_lineup = False # 다음 틱에 업그레이드 재시도 (attempts 상한 내)
|
||||
else:
|
||||
row.status = "failed"
|
||||
|
||||
|
||||
async def poll_generating(db) -> int:
|
||||
"""generating 상태 Suno 작업 폴링 → 완료/실패 반영."""
|
||||
rows = (
|
||||
await db.execute(select(Song).where(Song.status == "generating"))
|
||||
).scalars().all()
|
||||
done = 0
|
||||
for row in rows:
|
||||
try:
|
||||
data = await suno.get_task(row.task_id)
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning("song 폴링 실패 %s: %s", row.task_id, e)
|
||||
continue
|
||||
status = data.get("status") or ""
|
||||
if status == suno.SUNO_DONE:
|
||||
tracks = suno.extract_tracks(data)
|
||||
if tracks:
|
||||
row.tracks = tracks
|
||||
row.status = "complete"
|
||||
done += 1
|
||||
log.info("song 완료: %s %s (%d트랙)", row.match_id, row.team_name, len(tracks))
|
||||
else:
|
||||
_mark_failed(row, "SUCCESS 인데 트랙 없음")
|
||||
elif status in suno.SUNO_FAILED:
|
||||
_mark_failed(row, f"{status}: {data.get('errorMessage') or ''}"[:300])
|
||||
log.warning("song 실패: %s %s", row.match_id, row.error)
|
||||
else:
|
||||
# 진행 중 — 오래 걸리면 실패 처리 후 재시도 대상으로
|
||||
created = ensure_aware(row.created_at) if row.created_at else now_utc()
|
||||
if (now_utc() - created).total_seconds() > GENERATE_TIMEOUT_MIN * 60:
|
||||
_mark_failed(row, f"타임아웃({status})")
|
||||
await db.commit()
|
||||
return done
|
||||
|
||||
|
||||
PERSIST_MAX_FAILS = 3 # 트랙별 다운로드 실패 상한 (만료 URL 무한 재시도 방지)
|
||||
PERSIST_PER_TICK = 5 # 틱당 다운로드 곡 수 상한 — 틱 지연 방지
|
||||
|
||||
|
||||
async def persist_audio(db, limit: int = PERSIST_PER_TICK) -> int:
|
||||
"""완성 곡의 노출 트랙(첫 트랙) 음원을 DB(SongAudio)로 보존.
|
||||
|
||||
Suno CDN URL 은 임시라 종료된 경기의 응원가도 계속 재생하려면 원본을
|
||||
내려받아야 한다. audioUrl 이 아직 원격(http)인 complete 곡을 골라
|
||||
다운로드 → SongAudio 교체 저장 → audioUrl 을 자체 경로로 재작성.
|
||||
업그레이드로 트랙이 원격 URL 로 갈리면 자동으로 다시 저장된다.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
rows = (
|
||||
await db.execute(select(Song).where(Song.status == "complete"))
|
||||
).scalars().all()
|
||||
todo = []
|
||||
for row in rows:
|
||||
t = (row.tracks or [None])[0]
|
||||
if not t or not (t.get("audioUrl") or "").startswith("http"):
|
||||
continue
|
||||
if (t.get("persistFails") or 0) >= PERSIST_MAX_FAILS:
|
||||
continue
|
||||
todo.append(row)
|
||||
saved = 0
|
||||
for row in todo[:limit]:
|
||||
t = dict(row.tracks[0])
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=90, follow_redirects=True) as c:
|
||||
r = await c.get(t["audioUrl"])
|
||||
r.raise_for_status()
|
||||
body = r.content
|
||||
if not body:
|
||||
raise ValueError("빈 응답")
|
||||
mime = (r.headers.get("content-type") or "audio/mpeg").split(";")[0]
|
||||
except Exception as e: # noqa: BLE001
|
||||
t["persistFails"] = (t.get("persistFails") or 0) + 1
|
||||
row.tracks = [t, *row.tracks[1:]]
|
||||
await db.commit()
|
||||
log.warning(
|
||||
"song 음원 보존 실패 %s %s (%d회): %s",
|
||||
row.match_id, row.team_code, t["persistFails"], e,
|
||||
)
|
||||
continue
|
||||
existing = (
|
||||
await db.execute(
|
||||
select(SongAudio).where(
|
||||
SongAudio.song_id == row.id, SongAudio.track_idx == 0
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing:
|
||||
await db.delete(existing)
|
||||
await db.flush()
|
||||
db.add(SongAudio(song_id=row.id, track_idx=0, mime=mime, size=len(body), data=body))
|
||||
t["sourceUrl"] = t["audioUrl"]
|
||||
t["audioUrl"] = f"/api/songs/audio/{row.id}/0?v={(row.task_id or '')[:8]}"
|
||||
t.pop("persistFails", None)
|
||||
row.tracks = [t, *row.tracks[1:]]
|
||||
await db.commit()
|
||||
saved += 1
|
||||
log.info(
|
||||
"song 음원 보존: %s %s (%.1fMB)", row.match_id, row.team_code, len(body) / 1e6
|
||||
)
|
||||
return saved
|
||||
|
||||
|
||||
async def tick_songs() -> None:
|
||||
"""워커 주기 작업 — 생성 시작 + 폴링 + 음원 보존. 미설정 시 no-op."""
|
||||
if not _enabled():
|
||||
return
|
||||
async with SessionLocal() as db:
|
||||
try:
|
||||
await start_due_songs(db)
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.error("song start 오류: %s", e)
|
||||
try:
|
||||
await poll_generating(db)
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.error("song poll 오류: %s", e)
|
||||
try:
|
||||
await persist_audio(db)
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.error("song 음원 보존 오류: %s", e)
|
||||
104
backend/app/services/suno.py
Normal file
@ -0,0 +1,104 @@
|
||||
"""Suno 음악 생성 클라이언트 — sunoapi.org 서드파티 게이트웨이.
|
||||
|
||||
공식 Suno API 가 없어 게이트웨이를 쓴다. 흐름:
|
||||
POST /api/v1/generate (customMode: 가사·스타일·제목) → taskId
|
||||
GET /api/v1/generate/record-info?taskId= → status 폴링 → sunoData[] (보통 2곡)
|
||||
|
||||
오디오/커버는 게이트웨이 CDN URL 을 그대로 쓴다 (당일 소비 콘텐츠).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from ..config import settings
|
||||
|
||||
log = logging.getLogger("triplepick.suno")
|
||||
|
||||
# record-info status 값
|
||||
SUNO_DONE = "SUCCESS"
|
||||
SUNO_FAILED = {
|
||||
"CREATE_TASK_FAILED",
|
||||
"GENERATE_AUDIO_FAILED",
|
||||
"CALLBACK_EXCEPTION",
|
||||
"SENSITIVE_WORD_ERROR",
|
||||
}
|
||||
|
||||
|
||||
class SunoUnavailable(RuntimeError):
|
||||
"""SUNO_API_KEY 미설정 등으로 호출 불가."""
|
||||
|
||||
|
||||
class SunoError(RuntimeError):
|
||||
"""게이트웨이가 에러 코드를 반환."""
|
||||
|
||||
|
||||
def _headers() -> dict:
|
||||
if not settings.suno_api_key:
|
||||
raise SunoUnavailable("SUNO_API_KEY 미설정")
|
||||
return {
|
||||
"Authorization": f"Bearer {settings.suno_api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
async def start_generation(title: str, style: str, lyrics: str) -> str:
|
||||
"""생성 작업 시작 → taskId. customMode: 가사(prompt)·style·title 을 그대로 사용."""
|
||||
import httpx
|
||||
|
||||
payload = {
|
||||
"prompt": lyrics[:4900],
|
||||
"style": style[:950],
|
||||
"title": title[:80],
|
||||
"customMode": True,
|
||||
"instrumental": False,
|
||||
"model": settings.suno_model,
|
||||
# 게이트웨이 필수 파라미터 — 실제 완료 감지는 폴링으로 한다(콜백은 싱크대).
|
||||
"callBackUrl": f"{settings.public_origin}/api/songs/callback",
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=30) as c:
|
||||
r = await c.post(
|
||||
f"{settings.suno_api_base}/api/v1/generate",
|
||||
json=payload,
|
||||
headers=_headers(),
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if data.get("code") != 200 or not (data.get("data") or {}).get("taskId"):
|
||||
raise SunoError(f"generate 실패: {data.get('code')} {data.get('msg')}")
|
||||
return data["data"]["taskId"]
|
||||
|
||||
|
||||
async def get_task(task_id: str) -> dict:
|
||||
"""작업 상태 조회 — {status, response: {sunoData: [...]}} 형태의 data 반환."""
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient(timeout=30) as c:
|
||||
r = await c.get(
|
||||
f"{settings.suno_api_base}/api/v1/generate/record-info",
|
||||
params={"taskId": task_id},
|
||||
headers=_headers(),
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if data.get("code") != 200:
|
||||
raise SunoError(f"record-info 실패: {data.get('code')} {data.get('msg')}")
|
||||
return data.get("data") or {}
|
||||
|
||||
|
||||
def extract_tracks(task_data: dict) -> list[dict]:
|
||||
"""record-info data → 표준 트랙 목록 [{title, audioUrl, imageUrl, duration}]."""
|
||||
items = ((task_data.get("response") or {}).get("sunoData")) or []
|
||||
out = []
|
||||
for it in items:
|
||||
url = it.get("audioUrl") or it.get("sourceAudioUrl") or ""
|
||||
if not url:
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"title": it.get("title") or "",
|
||||
"audioUrl": url,
|
||||
"imageUrl": it.get("imageUrl") or it.get("sourceImageUrl") or "",
|
||||
"duration": it.get("duration"),
|
||||
}
|
||||
)
|
||||
return out
|
||||
85
backend/app/teams_baseball.py
Normal file
@ -0,0 +1,85 @@
|
||||
"""야구 팀 데이터 — KBO 10개 구단 + MLB 30개 구단 (코드 → 한글/약식/영문).
|
||||
|
||||
KBO 코드: 네이버/KBO 표준 2글자 (HT=KIA, OB=두산, WO=키움, LT=롯데, SK=SSG).
|
||||
MLB 코드: 공식 약어 (NYY, LAD …) + mlb_id 는 statsapi 의 team.id (불변).
|
||||
로고: frontend /assets/teams/{league}/{code소문자}.png
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
# ── KBO ────────────────────────────────────────────────────────
|
||||
KBO_TEAMS: dict[str, dict] = {
|
||||
"HT": {"ko": "KIA 타이거즈", "short": "KIA", "en": "Kia Tigers", "home": "광주기아챔피언스필드"},
|
||||
"LG": {"ko": "LG 트윈스", "short": "LG", "en": "LG Twins", "home": "잠실야구장"},
|
||||
"OB": {"ko": "두산 베어스", "short": "두산", "en": "Doosan Bears", "home": "잠실야구장"},
|
||||
"SS": {"ko": "삼성 라이온즈", "short": "삼성", "en": "Samsung Lions", "home": "대구삼성라이온즈파크"},
|
||||
"LT": {"ko": "롯데 자이언츠", "short": "롯데", "en": "Lotte Giants", "home": "사직야구장"},
|
||||
"SK": {"ko": "SSG 랜더스", "short": "SSG", "en": "SSG Landers", "home": "인천SSG랜더스필드"},
|
||||
"KT": {"ko": "KT 위즈", "short": "KT", "en": "KT Wiz", "home": "수원KT위즈파크"},
|
||||
"NC": {"ko": "NC 다이노스", "short": "NC", "en": "NC Dinos", "home": "창원NC파크"},
|
||||
"WO": {"ko": "키움 히어로즈", "short": "키움", "en": "Kiwoom Heroes", "home": "고척스카이돔"},
|
||||
"HH": {"ko": "한화 이글스", "short": "한화", "en": "Hanwha Eagles", "home": "대전한화생명볼파크"},
|
||||
}
|
||||
|
||||
# ── MLB (statsapi team.id → 우리 코드) ─────────────────────────
|
||||
MLB_TEAMS: dict[str, dict] = {
|
||||
"LAD": {"ko": "LA 다저스", "short": "다저스", "en": "Los Angeles Dodgers", "mlb_id": 119},
|
||||
"NYY": {"ko": "뉴욕 양키스", "short": "양키스", "en": "New York Yankees", "mlb_id": 147},
|
||||
"NYM": {"ko": "뉴욕 메츠", "short": "메츠", "en": "New York Mets", "mlb_id": 121},
|
||||
"BOS": {"ko": "보스턴 레드삭스", "short": "보스턴", "en": "Boston Red Sox", "mlb_id": 111},
|
||||
"CHC": {"ko": "시카고 컵스", "short": "컵스", "en": "Chicago Cubs", "mlb_id": 112},
|
||||
"CWS": {"ko": "시카고 화이트삭스", "short": "화이트삭스", "en": "Chicago White Sox", "mlb_id": 145},
|
||||
"CLE": {"ko": "클리블랜드 가디언스", "short": "클리블랜드", "en": "Cleveland Guardians", "mlb_id": 114},
|
||||
"DET": {"ko": "디트로이트 타이거스", "short": "디트로이트", "en": "Detroit Tigers", "mlb_id": 116},
|
||||
"HOU": {"ko": "휴스턴 애스트로스", "short": "휴스턴", "en": "Houston Astros", "mlb_id": 117},
|
||||
"KC": {"ko": "캔자스시티 로열스", "short": "캔자스시티", "en": "Kansas City Royals", "mlb_id": 118},
|
||||
"LAA": {"ko": "LA 에인절스", "short": "에인절스", "en": "Los Angeles Angels", "mlb_id": 108},
|
||||
"MIN": {"ko": "미네소타 트윈스", "short": "미네소타", "en": "Minnesota Twins", "mlb_id": 142},
|
||||
"ATH": {"ko": "애슬레틱스", "short": "애슬레틱스", "en": "Athletics", "mlb_id": 133},
|
||||
"SEA": {"ko": "시애틀 매리너스", "short": "시애틀", "en": "Seattle Mariners", "mlb_id": 136},
|
||||
"TB": {"ko": "탬파베이 레이스", "short": "탬파베이", "en": "Tampa Bay Rays", "mlb_id": 139},
|
||||
"TEX": {"ko": "텍사스 레인저스", "short": "텍사스", "en": "Texas Rangers", "mlb_id": 140},
|
||||
"TOR": {"ko": "토론토 블루제이스", "short": "토론토", "en": "Toronto Blue Jays", "mlb_id": 141},
|
||||
"ARI": {"ko": "애리조나 다이아몬드백스", "short": "애리조나", "en": "Arizona Diamondbacks", "mlb_id": 109},
|
||||
"ATL": {"ko": "애틀랜타 브레이브스", "short": "애틀랜타", "en": "Atlanta Braves", "mlb_id": 144},
|
||||
"BAL": {"ko": "볼티모어 오리올스", "short": "볼티모어", "en": "Baltimore Orioles", "mlb_id": 110},
|
||||
"CIN": {"ko": "신시내티 레즈", "short": "신시내티", "en": "Cincinnati Reds", "mlb_id": 113},
|
||||
"COL": {"ko": "콜로라도 로키스", "short": "콜로라도", "en": "Colorado Rockies", "mlb_id": 115},
|
||||
"MIA": {"ko": "마이애미 말린스", "short": "마이애미", "en": "Miami Marlins", "mlb_id": 146},
|
||||
"MIL": {"ko": "밀워키 브루어스", "short": "밀워키", "en": "Milwaukee Brewers", "mlb_id": 158},
|
||||
"WSH": {"ko": "워싱턴 내셔널스", "short": "워싱턴", "en": "Washington Nationals", "mlb_id": 120},
|
||||
"PHI": {"ko": "필라델피아 필리스", "short": "필라델피아", "en": "Philadelphia Phillies", "mlb_id": 143},
|
||||
"PIT": {"ko": "피츠버그 파이리츠", "short": "피츠버그", "en": "Pittsburgh Pirates", "mlb_id": 134},
|
||||
"SD": {"ko": "샌디에이고 파드리스", "short": "샌디에이고", "en": "San Diego Padres", "mlb_id": 135},
|
||||
"SF": {"ko": "샌프란시스코 자이언츠", "short": "SF", "en": "San Francisco Giants", "mlb_id": 137},
|
||||
"STL": {"ko": "세인트루이스 카디널스", "short": "세인트루이스", "en": "St. Louis Cardinals", "mlb_id": 138},
|
||||
}
|
||||
|
||||
MLB_ID_TO_CODE: dict[int, str] = {v["mlb_id"]: k for k, v in MLB_TEAMS.items()}
|
||||
|
||||
# 순위표 등 팀 축약명 → 코드 (리그별)
|
||||
KBO_SHORT_TO_CODE = {v["short"]: k for k, v in KBO_TEAMS.items()}
|
||||
|
||||
|
||||
def teams_of(league: str) -> dict[str, dict]:
|
||||
if league == "mls":
|
||||
from .teams_mls import MLS_TEAMS
|
||||
|
||||
return MLS_TEAMS
|
||||
return KBO_TEAMS if league == "kbo" else MLB_TEAMS
|
||||
|
||||
|
||||
def team_info(league: str, code: str) -> dict:
|
||||
"""flag 에 로고 경로/URL 을 실어 프론트(TeamFlag)가 그대로 렌더한다.
|
||||
KBO: 로컬 자산(/assets/teams/kbo/*.png) · MLB: 공식 CDN(mlbstatic) SVG ·
|
||||
MLS: ESPN CDN PNG."""
|
||||
c = (code or "").strip().upper()
|
||||
t = teams_of(league).get(c)
|
||||
if not t:
|
||||
return {"name": c, "shortName": c, "code": c, "flag": ""}
|
||||
if league == "mlb":
|
||||
flag = f"https://www.mlbstatic.com/team-logos/{t['mlb_id']}.svg"
|
||||
elif league == "mls":
|
||||
flag = f"https://a.espncdn.com/i/teamlogos/soccer/500/{t['espn_id']}.png"
|
||||
else:
|
||||
flag = f"/assets/teams/kbo/{c.lower()}.png"
|
||||
return {"name": t["ko"], "shortName": t["short"], "code": c, "flag": flag}
|
||||
76
backend/app/teams_data.py
Normal file
@ -0,0 +1,76 @@
|
||||
"""2026 월드컵 48개국 팀 데이터 — 코드(대문자 FIFA) → 한글/약식/영문.
|
||||
|
||||
국기는 frontend /assets/flags/{code소문자}.svg 를 사용(48개 자산 보유).
|
||||
football-data tla → 코드 변환은 TLA_REMAP(대부분 동일, CUR/URY만 예외).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
# football-data tla → 내부 코드(=FIFA 국기 코드). 대부분 동일, 예외만 매핑.
|
||||
TLA_REMAP = {"CUR": "CUW", "URY": "URU"}
|
||||
|
||||
|
||||
def code_for_tla(tla: str) -> str:
|
||||
tla = (tla or "").strip().upper()
|
||||
return TLA_REMAP.get(tla, tla)
|
||||
|
||||
|
||||
# 코드 → {ko(전체명), short(약식), en}
|
||||
TEAMS: dict[str, dict] = {
|
||||
"ALG": {"ko": "알제리", "short": "알제리", "en": "Algeria"},
|
||||
"ARG": {"ko": "아르헨티나", "short": "아르헨티나", "en": "Argentina"},
|
||||
"AUS": {"ko": "호주", "short": "호주", "en": "Australia"},
|
||||
"AUT": {"ko": "오스트리아", "short": "오스트리아", "en": "Austria"},
|
||||
"BEL": {"ko": "벨기에", "short": "벨기에", "en": "Belgium"},
|
||||
"BIH": {"ko": "보스니아 헤르체고비나", "short": "보스니아", "en": "Bosnia-Herzegovina"},
|
||||
"BRA": {"ko": "브라질", "short": "브라질", "en": "Brazil"},
|
||||
"CAN": {"ko": "캐나다", "short": "캐나다", "en": "Canada"},
|
||||
"CIV": {"ko": "코트디부아르", "short": "코트디부아르", "en": "Ivory Coast"},
|
||||
"COD": {"ko": "콩고민주공화국", "short": "콩고DR", "en": "Congo DR"},
|
||||
"COL": {"ko": "콜롬비아", "short": "콜롬비아", "en": "Colombia"},
|
||||
"CPV": {"ko": "카보베르데", "short": "카보베르데", "en": "Cape Verde"},
|
||||
"CRO": {"ko": "크로아티아", "short": "크로아티아", "en": "Croatia"},
|
||||
"CUW": {"ko": "퀴라소", "short": "퀴라소", "en": "Curaçao"},
|
||||
"CZE": {"ko": "체코", "short": "체코", "en": "Czechia"},
|
||||
"ECU": {"ko": "에콰도르", "short": "에콰도르", "en": "Ecuador"},
|
||||
"EGY": {"ko": "이집트", "short": "이집트", "en": "Egypt"},
|
||||
"ENG": {"ko": "잉글랜드", "short": "잉글랜드", "en": "England"},
|
||||
"ESP": {"ko": "스페인", "short": "스페인", "en": "Spain"},
|
||||
"FRA": {"ko": "프랑스", "short": "프랑스", "en": "France"},
|
||||
"GER": {"ko": "독일", "short": "독일", "en": "Germany"},
|
||||
"GHA": {"ko": "가나", "short": "가나", "en": "Ghana"},
|
||||
"HAI": {"ko": "아이티", "short": "아이티", "en": "Haiti"},
|
||||
"IRN": {"ko": "이란", "short": "이란", "en": "Iran"},
|
||||
"IRQ": {"ko": "이라크", "short": "이라크", "en": "Iraq"},
|
||||
"JOR": {"ko": "요르단", "short": "요르단", "en": "Jordan"},
|
||||
"JPN": {"ko": "일본", "short": "일본", "en": "Japan"},
|
||||
"KOR": {"ko": "대한민국", "short": "한국", "en": "Korea Republic"},
|
||||
"KSA": {"ko": "사우디아라비아", "short": "사우디", "en": "Saudi Arabia"},
|
||||
"MAR": {"ko": "모로코", "short": "모로코", "en": "Morocco"},
|
||||
"MEX": {"ko": "멕시코", "short": "멕시코", "en": "Mexico"},
|
||||
"NED": {"ko": "네덜란드", "short": "네덜란드", "en": "Netherlands"},
|
||||
"NOR": {"ko": "노르웨이", "short": "노르웨이", "en": "Norway"},
|
||||
"NZL": {"ko": "뉴질랜드", "short": "뉴질랜드", "en": "New Zealand"},
|
||||
"PAN": {"ko": "파나마", "short": "파나마", "en": "Panama"},
|
||||
"PAR": {"ko": "파라과이", "short": "파라과이", "en": "Paraguay"},
|
||||
"POR": {"ko": "포르투갈", "short": "포르투갈", "en": "Portugal"},
|
||||
"QAT": {"ko": "카타르", "short": "카타르", "en": "Qatar"},
|
||||
"RSA": {"ko": "남아프리카 공화국", "short": "남아공", "en": "South Africa"},
|
||||
"SCO": {"ko": "스코틀랜드", "short": "스코틀랜드", "en": "Scotland"},
|
||||
"SEN": {"ko": "세네갈", "short": "세네갈", "en": "Senegal"},
|
||||
"SUI": {"ko": "스위스", "short": "스위스", "en": "Switzerland"},
|
||||
"SWE": {"ko": "스웨덴", "short": "스웨덴", "en": "Sweden"},
|
||||
"TUN": {"ko": "튀니지", "short": "튀니지", "en": "Tunisia"},
|
||||
"TUR": {"ko": "튀르키예", "short": "튀르키예", "en": "Turkey"},
|
||||
"URU": {"ko": "우루과이", "short": "우루과이", "en": "Uruguay"},
|
||||
"USA": {"ko": "미국", "short": "미국", "en": "United States"},
|
||||
"UZB": {"ko": "우즈베키스탄", "short": "우즈벡", "en": "Uzbekistan"},
|
||||
}
|
||||
|
||||
|
||||
def team_info(code: str) -> dict:
|
||||
c = (code or "").strip().upper()
|
||||
t = TEAMS.get(c)
|
||||
if t:
|
||||
return {"name": t["ko"], "shortName": t["short"], "code": c, "flag": ""}
|
||||
# 미등록 코드 폴백
|
||||
return {"name": c, "shortName": c, "code": c, "flag": ""}
|
||||
49
backend/app/teams_mls.py
Normal file
@ -0,0 +1,49 @@
|
||||
"""MLS 팀 데이터 — 30개 구단 (ESPN 약어 코드 → 한글/약식/영문/ESPN ID).
|
||||
|
||||
코드: ESPN abbreviation (일정·순위·라이브 매칭 키와 동일해 변환 불필요).
|
||||
espn_id: ESPN 팀 ID (불변) — 로고 CDN URL 조립에 사용.
|
||||
로고: https://a.espncdn.com/i/teamlogos/soccer/500/{espn_id}.png
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
MLS_TEAMS: dict[str, dict] = {
|
||||
# ── 동부(Eastern Conference) ─────────────────────────────
|
||||
"ATL": {"ko": "애틀랜타 유나이티드", "short": "애틀랜타", "en": "Atlanta United FC", "espn_id": 18418},
|
||||
"CLT": {"ko": "샬럿 FC", "short": "샬럿", "en": "Charlotte FC", "espn_id": 21300},
|
||||
"CHI": {"ko": "시카고 파이어", "short": "시카고", "en": "Chicago Fire FC", "espn_id": 182},
|
||||
"CIN": {"ko": "FC 신시내티", "short": "신시내티", "en": "FC Cincinnati", "espn_id": 18267},
|
||||
"CLB": {"ko": "콜럼버스 크루", "short": "콜럼버스", "en": "Columbus Crew", "espn_id": 183},
|
||||
"DC": {"ko": "DC 유나이티드", "short": "DC", "en": "D.C. United", "espn_id": 193},
|
||||
"MIA": {"ko": "인터 마이애미", "short": "마이애미", "en": "Inter Miami CF", "espn_id": 20232},
|
||||
"MTL": {"ko": "CF 몬트리올", "short": "몬트리올", "en": "CF Montréal", "espn_id": 9720},
|
||||
"NSH": {"ko": "내슈빌 SC", "short": "내슈빌", "en": "Nashville SC", "espn_id": 18986},
|
||||
"NE": {"ko": "뉴잉글랜드 레볼루션", "short": "뉴잉글랜드", "en": "New England Revolution", "espn_id": 189},
|
||||
"NYC": {"ko": "뉴욕 시티 FC", "short": "뉴욕시티", "en": "New York City FC", "espn_id": 17606},
|
||||
"RBNY": {"ko": "뉴욕 레드불스", "short": "레드불스", "en": "New York Red Bulls", "espn_id": 190},
|
||||
"ORL": {"ko": "올랜도 시티", "short": "올랜도", "en": "Orlando City SC", "espn_id": 12011},
|
||||
"PHI": {"ko": "필라델피아 유니언", "short": "필라델피아", "en": "Philadelphia Union", "espn_id": 10739},
|
||||
"TOR": {"ko": "토론토 FC", "short": "토론토", "en": "Toronto FC", "espn_id": 7318},
|
||||
# ── 서부(Western Conference) ─────────────────────────────
|
||||
"ATX": {"ko": "오스틴 FC", "short": "오스틴", "en": "Austin FC", "espn_id": 20906},
|
||||
"COL": {"ko": "콜로라도 래피즈", "short": "콜로라도", "en": "Colorado Rapids", "espn_id": 184},
|
||||
"DAL": {"ko": "FC 댈러스", "short": "댈러스", "en": "FC Dallas", "espn_id": 185},
|
||||
"HOU": {"ko": "휴스턴 다이너모", "short": "휴스턴", "en": "Houston Dynamo FC", "espn_id": 6077},
|
||||
"LA": {"ko": "LA 갤럭시", "short": "LA갤럭시", "en": "LA Galaxy", "espn_id": 187},
|
||||
"LAFC": {"ko": "LAFC", "short": "LAFC", "en": "LAFC", "espn_id": 18966},
|
||||
"MIN": {"ko": "미네소타 유나이티드", "short": "미네소타", "en": "Minnesota United FC", "espn_id": 17362},
|
||||
"POR": {"ko": "포틀랜드 팀버스", "short": "포틀랜드", "en": "Portland Timbers", "espn_id": 9723},
|
||||
"RSL": {"ko": "레알 솔트레이크", "short": "솔트레이크", "en": "Real Salt Lake", "espn_id": 4771},
|
||||
"SD": {"ko": "샌디에이고 FC", "short": "샌디에이고", "en": "San Diego FC", "espn_id": 22529},
|
||||
"SJ": {"ko": "산호세 어스퀘이크스", "short": "산호세", "en": "San Jose Earthquakes", "espn_id": 191},
|
||||
"SEA": {"ko": "시애틀 사운더스", "short": "시애틀", "en": "Seattle Sounders FC", "espn_id": 9726},
|
||||
"SKC": {"ko": "스포팅 캔자스시티", "short": "캔자스시티", "en": "Sporting Kansas City", "espn_id": 186},
|
||||
"STL": {"ko": "세인트루이스 시티", "short": "세인트루이스", "en": "St. Louis CITY SC", "espn_id": 21812},
|
||||
"VAN": {"ko": "밴쿠버 화이트캡스", "short": "밴쿠버", "en": "Vancouver Whitecaps FC", "espn_id": 9727},
|
||||
}
|
||||
|
||||
MLS_ID_TO_CODE: dict[int, str] = {v["espn_id"]: k for k, v in MLS_TEAMS.items()}
|
||||
|
||||
|
||||
def mls_logo(code: str) -> str:
|
||||
t = MLS_TEAMS.get(code)
|
||||
return f"https://a.espncdn.com/i/teamlogos/soccer/500/{t['espn_id']}.png" if t else ""
|
||||
550
backend/app/worker.py
Normal file
@ -0,0 +1,550 @@
|
||||
"""백그라운드 워커 — 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, timezone
|
||||
|
||||
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 compute_phase, ensure_aware, now_utc
|
||||
from .models import AIPrediction, Match, UserPrediction
|
||||
from .scoring import load_scoring_data
|
||||
from .services import baseball_data, baseball_details, football_data, mls_espn
|
||||
from .services.ai import MatchContext, PROVIDERS, ProviderUnavailable
|
||||
from .services.baseball_fetch import fetch_baseball_results, fetch_baseball_schedule
|
||||
from .services.baseball_sync import match_seq, sync_baseball_schedule
|
||||
from .services.email import EmailUnavailable, build_result_email, send_email
|
||||
from .services.grading import apply_result
|
||||
from .services.schedule_fetch import fetch_results, fetch_schedule
|
||||
from .services.schedule_sync import sync_schedule
|
||||
from .services.songs import tick_songs
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
log = logging.getLogger("triplepick.worker")
|
||||
|
||||
|
||||
# ── 0) 경기 일정 동기화 (외부 크롤링) ──────────────────────
|
||||
async def sync_schedule_job() -> None:
|
||||
"""월드컵(축구) 일정 동기화 — 기존 로직 그대로."""
|
||||
if "wc" not in settings.league_list:
|
||||
return
|
||||
records = await fetch_schedule()
|
||||
if not records:
|
||||
return
|
||||
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()
|
||||
|
||||
|
||||
async def sync_baseball_job() -> None:
|
||||
"""야구(kbo/mlb)+MLS 일정 동기화 + 프리뷰·순위 캐시 갱신.
|
||||
|
||||
MLS 도 (리그, KST 날짜, 팀쌍, 차수) 키 구조가 동일해 야구 sync 를 그대로 탄다.
|
||||
"""
|
||||
inserted_any = False
|
||||
for league in settings.league_list:
|
||||
if league not in ("kbo", "mlb", "mls"):
|
||||
continue
|
||||
records = (
|
||||
await mls_espn.fetch_mls_schedule()
|
||||
if league == "mls"
|
||||
else await fetch_baseball_schedule(league)
|
||||
)
|
||||
if not records:
|
||||
continue
|
||||
async with SessionLocal() as db:
|
||||
result = await sync_baseball_schedule(db, league, records)
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(Match).where(
|
||||
Match.league == league, Match.result_outcome.is_(None)
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
try:
|
||||
if league == "mls":
|
||||
await mls_espn.refresh_mls_details(db, rows)
|
||||
else:
|
||||
await baseball_details.refresh_baseball_details(db, league, rows)
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning("details(%s) 갱신 실패: %s", league, e)
|
||||
inserted_any = inserted_any or bool(result.get("inserted"))
|
||||
await tick_status()
|
||||
if inserted_any:
|
||||
await generate_ai_predictions()
|
||||
|
||||
|
||||
# ── 1) 상태 전이 ────────────────────────────────────────────
|
||||
async def tick_status() -> None:
|
||||
async with SessionLocal() as db:
|
||||
now = now_utc()
|
||||
matches = (await db.execute(select(Match))).scalars().all()
|
||||
changed = 0
|
||||
for m in matches:
|
||||
# 화면용 phase 와 동일 기준으로 status 저장(단일 기준):
|
||||
# scheduled → open → locked(투표종료) → live(경기중) → finished
|
||||
target = compute_phase(m, now)
|
||||
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(only_missing: bool = True) -> None:
|
||||
"""미종료 경기의 AI 예측 생성.
|
||||
|
||||
only_missing=True(기본): 이미 있는 (경기×모델) 예측은 보존하고 **비어 있는 것만**
|
||||
생성한다(직전에 실패/누락된 모델만 채워짐 — API 비용↓, 예측 안정).
|
||||
only_missing=False: 전부 재생성(덮어쓰기) — 수동 재생성 스크립트용.
|
||||
"""
|
||||
if not settings.ai_enabled:
|
||||
log.info("AI 예측 생성 스킵 — AI_ENABLED=false (로컬 테스트 모드)")
|
||||
return
|
||||
async with SessionLocal() as db:
|
||||
# 킥오프가 lookahead(기본 22h) 이내로 다가온 미시작 경기만 생성.
|
||||
# 야구 선발투수 예고(전날 저녁)가 나온 뒤 시점이라 데이터 품질이 좋다.
|
||||
# 킥오프가 지난 경기는 제외(경기 중 생성 = 사후 예측 방지). 취소 경기 제외.
|
||||
now = now_utc()
|
||||
horizon = now + timedelta(hours=settings.ai_generate_lookahead_hours)
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(Match)
|
||||
.where(Match.result_outcome.is_(None))
|
||||
.options(selectinload(Match.predictions))
|
||||
)
|
||||
).scalars().all()
|
||||
matches = [
|
||||
m for m in rows
|
||||
if m.status != "cancelled"
|
||||
and now < ensure_aware(m.kickoff_at) <= horizon
|
||||
]
|
||||
|
||||
for m in matches:
|
||||
# 실데이터 블록 — 리그별 소스(축구=API-Football 캐시, 야구=자체DB+프리뷰,
|
||||
# MLS=자체DB+ESPN 프리뷰).
|
||||
if m.league in ("kbo", "mlb"):
|
||||
data_block = await baseball_data.build_baseball_data_block(db, m)
|
||||
elif m.league == "mls":
|
||||
data_block = await mls_espn.build_mls_data_block(db, m)
|
||||
else:
|
||||
data_block = await football_data.build_data_block(db, m)
|
||||
ctx = MatchContext(
|
||||
team_a=m.team_a_name,
|
||||
team_b=m.team_b_name,
|
||||
venue=m.venue,
|
||||
kickoff=m.kickoff_at.isoformat(),
|
||||
data_block=data_block,
|
||||
league=m.league,
|
||||
)
|
||||
for model, fn in PROVIDERS.items():
|
||||
pred = next((p for p in m.predictions if p.model == model), None)
|
||||
if only_missing and pred is not None:
|
||||
continue # 이미 작성됨 — 보존, API 호출 안 함
|
||||
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
|
||||
|
||||
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"])
|
||||
# 경기 단위 커밋 — 수백 콜 도중 재시작/오류가 나도 진행분을 잃지
|
||||
# 않는다(전체 커밋이면 중단 시 API 비용을 다시 지출하게 됨).
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── 2.1) 축구 데이터 수집 (예측 생성 전에 캐시를 채워둔다) ──
|
||||
async def refresh_football_data() -> None:
|
||||
"""전 팀의 팀/H2H 축구 데이터를 캐시에 한 번씩 선수집.
|
||||
|
||||
예측이 데이터를 쓰려면 미리 캐시에 있어야 하므로, 임박 경기만 기다리지 않고
|
||||
모든 미종료 경기의 팀을 대상으로 한다. 팀당 1회만 받고(fetch-once), 무료 한도
|
||||
(100/일)를 넘지 않게 하루 호출 예산만큼만 받아 며칠에 걸쳐 누적한다. 전 팀이
|
||||
캐시되면 이후 잡은 호출 0(자동 무동작). 키 미설정이면 no-op.
|
||||
"""
|
||||
if not football_data.enabled():
|
||||
return
|
||||
async with SessionLocal() as db:
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(Match).where(
|
||||
Match.result_outcome.is_(None), Match.league == "wc"
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
# 가까운 경기부터 우선 수집(하루 예산 소진 시 먼 경기 팀은 다음 잡에서).
|
||||
matches = sorted(rows, key=lambda m: ensure_aware(m.kickoff_at))
|
||||
await football_data.refresh(db, matches)
|
||||
|
||||
|
||||
# ── 2.5) 결과 자동 정산 (관리자 입력 불필요) ────────────────
|
||||
async def settle_matches() -> None:
|
||||
"""킥오프가 지난 미정산 경기를, 소스가 FINISHED 로 보고하는 즉시 채점·종료.
|
||||
또한 최근 N일(result_recheck_days) 내 종료된 경기는 외부 소스와 대조해, 소스가
|
||||
스코어를 정정하면(잠정값→확정값 등) 자동 갱신·재채점한다.
|
||||
|
||||
킥오프 이후의 미정산 경기를 폴링하되, 외부 소스(football-data)가 FINISHED 로
|
||||
확정 스코어를 줄 때만 apply_result → 채점·유저 포인트 집계·finished_at·메일.
|
||||
경기 중(IN_PLAY)에는 FINISHED 가 아니라 결과에 안 잡혀 자동 스킵된다(조기 종료 없음).
|
||||
아직 FINISHED 가 아니면 다음 틱(5분)에 재시도 → 종료 후 최대 1틱 내 반영.
|
||||
|
||||
재확인은 '최근 N일 종료 경기'로만 한정 → 오래된 경기는 대상에서 빠져 부하 bounded.
|
||||
소스 호출은 신규/재확인이 모두 같은 단일 fetch_results() 응답을 공유한다.
|
||||
"""
|
||||
now = now_utc()
|
||||
recheck_cutoff = (
|
||||
now - timedelta(days=settings.result_recheck_days)
|
||||
if settings.result_recheck_days > 0
|
||||
else None
|
||||
)
|
||||
async with SessionLocal() as db:
|
||||
pending = (
|
||||
await db.execute(
|
||||
select(Match).where(
|
||||
Match.league == "wc", Match.result_outcome.is_(None)
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
# 킥오프가 지난 경기만 (소스가 FINISHED 줄 수 있는 시점)
|
||||
due = [m.match_id for m in pending if ensure_aware(m.kickoff_at) <= now]
|
||||
# 최근 종료 경기(소스 정정 반영용) — TZ 안전하게 파이썬에서 기간 필터.
|
||||
recheck_ids: list[str] = []
|
||||
if recheck_cutoff is not None:
|
||||
finished = (
|
||||
await db.execute(
|
||||
select(Match).where(
|
||||
Match.league == "wc",
|
||||
Match.result_outcome.is_not(None),
|
||||
Match.finished_at.is_not(None),
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
recheck_ids = [
|
||||
m.match_id
|
||||
for m in finished
|
||||
if m.finished_at and ensure_aware(m.finished_at) >= recheck_cutoff
|
||||
]
|
||||
|
||||
if due or recheck_ids:
|
||||
results = await fetch_results()
|
||||
# 정방향/역방향 키 모두 등록 — 소스의 홈/원정 순서가 우리와 달라도 매칭.
|
||||
# 라운드(round_label)를 키에 포함해 같은 두 팀의 조별리그·토너먼트 경기를 구분.
|
||||
# round_label 없는 소스(openfootball) 대비 라운드 무시 키도 함께 등록(폴백).
|
||||
by_pair: dict[tuple, tuple[int, int]] = {}
|
||||
for r in results:
|
||||
rl = r.get("roundLabel", "")
|
||||
a2, b2, sa, sb = r["teamA"], r["teamB"], r["scoreA"], r["scoreB"]
|
||||
for key in ((rl, a2, b2), (a2, b2)):
|
||||
by_pair[key] = (sa, sb)
|
||||
for key in ((rl, b2, a2), (b2, a2)): # 뒤집어 저장
|
||||
by_pair[key] = (sb, sa)
|
||||
|
||||
def _lookup(m: Match) -> tuple[int, int] | None:
|
||||
# 라운드까지 일치하는 결과 우선, 없으면 팀쌍만으로 폴백.
|
||||
return by_pair.get((m.round_label, m.team_a_code, m.team_b_code)) or by_pair.get(
|
||||
(m.team_a_code, m.team_b_code)
|
||||
)
|
||||
|
||||
settled = corrected = 0
|
||||
async with SessionLocal() as db:
|
||||
# 1) 신규 정산 — 미정산 경기에 FINISHED 스코어 반영
|
||||
for mid in due:
|
||||
m = await db.get(Match, mid)
|
||||
if m is None or m.result_outcome is not None:
|
||||
continue
|
||||
sc = _lookup(m)
|
||||
if not sc:
|
||||
continue
|
||||
await apply_result(db, m, sc[0], sc[1]) # 우리 팀A/팀B 기준으로 채점+집계+commit
|
||||
settled += 1
|
||||
log.info("settle: %s 자동 정산 %d-%d", mid, sc[0], sc[1])
|
||||
# 2) 최근 종료 경기 재확인 — 소스 스코어가 DB와 다르면 정정+재채점
|
||||
for mid in recheck_ids:
|
||||
m = await db.get(Match, mid)
|
||||
if m is None or m.result_outcome is None:
|
||||
continue
|
||||
sc = _lookup(m)
|
||||
if not sc:
|
||||
continue # 소스에 아직 없으면 기존값 유지
|
||||
if (m.result_score_a, m.result_score_b) == (sc[0], sc[1]):
|
||||
continue # 동일 — 변경 없음(대부분 여기서 종료, 재채점 안 함)
|
||||
old_a, old_b = m.result_score_a, m.result_score_b
|
||||
await apply_result(db, m, sc[0], sc[1]) # 정정+재채점+집계+commit
|
||||
corrected += 1
|
||||
log.warning(
|
||||
"settle: %s 결과 정정 %s-%s → %d-%d (소스 변경 반영)",
|
||||
mid, old_a, old_b, sc[0], sc[1],
|
||||
)
|
||||
if not settled and not corrected:
|
||||
log.info(
|
||||
"settle: 신규 %d·재확인 %d경기 — 변경 없음", len(due), len(recheck_ids)
|
||||
)
|
||||
|
||||
await maybe_send_result_emails()
|
||||
|
||||
|
||||
# ── 2.6) 야구·MLS 결과 자동 정산 — (리그, KST 날짜, 팀쌍) 키 매칭 ──
|
||||
async def settle_baseball() -> None:
|
||||
_KST = timedelta(hours=9)
|
||||
now = now_utc()
|
||||
for league in settings.league_list:
|
||||
if league not in ("kbo", "mlb", "mls"):
|
||||
continue
|
||||
async with SessionLocal() as db:
|
||||
pending = (
|
||||
await db.execute(
|
||||
select(Match).where(
|
||||
Match.league == league,
|
||||
Match.result_outcome.is_(None),
|
||||
Match.status != "cancelled",
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
due_matches = [m for m in pending if ensure_aware(m.kickoff_at) <= now]
|
||||
if not due_matches:
|
||||
continue
|
||||
due = [m.match_id for m in due_matches]
|
||||
# 미정산 경기 중 가장 오래된 킥오프까지 덮도록 조회 범위를 동적 확장.
|
||||
# 기본 윈도우(며칠)만 쓰면 첫 수집에 실패한 경기가 윈도우 밖으로
|
||||
# 밀려나 영원히 "live"에 고정되는 문제가 있었다 — 최대 120일까지 소급.
|
||||
oldest_kst_date = min(
|
||||
ensure_aware(m.kickoff_at).astimezone(timezone(_KST)).date()
|
||||
for m in due_matches
|
||||
)
|
||||
days_back = min((now.astimezone(timezone(_KST)).date() - oldest_kst_date).days + 1, 120)
|
||||
results = (
|
||||
await mls_espn.fetch_mls_results(days_back=days_back)
|
||||
if league == "mls"
|
||||
else await fetch_baseball_results(league, days_back=days_back)
|
||||
)
|
||||
by_key: dict[tuple, tuple[int, int]] = {}
|
||||
for r in results:
|
||||
d, a2, b2, s = r["dateKst"], r["teamA"], r["teamB"], r.get("seq", 1)
|
||||
by_key[(d, a2, b2, s)] = (r["scoreA"], r["scoreB"])
|
||||
by_key[(d, b2, a2, s)] = (r["scoreB"], r["scoreA"])
|
||||
|
||||
settled = 0
|
||||
async with SessionLocal() as db:
|
||||
for mid in due:
|
||||
m = await db.get(Match, mid)
|
||||
if m is None or m.result_outcome is not None:
|
||||
continue
|
||||
d = (ensure_aware(m.kickoff_at) + _KST).strftime("%Y%m%d")
|
||||
sc = by_key.get((d, m.team_a_code, m.team_b_code, match_seq(mid)))
|
||||
if not sc:
|
||||
continue
|
||||
await apply_result(db, m, sc[0], sc[1])
|
||||
settled += 1
|
||||
log.info("settle(%s): %s 자동 정산 %d-%d", league, mid, sc[0], sc[1])
|
||||
if settled:
|
||||
await maybe_send_result_emails()
|
||||
|
||||
|
||||
# ── 3) 결과 메일 ────────────────────────────────────────────
|
||||
async def maybe_send_result_emails() -> None:
|
||||
async with SessionLocal() as db:
|
||||
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시간) 미경과
|
||||
|
||||
conds = [
|
||||
UserPrediction.match_id == m.match_id,
|
||||
UserPrediction.notify.is_(True),
|
||||
UserPrediction.email.is_not(None),
|
||||
UserPrediction.notified.is_(False),
|
||||
]
|
||||
# '맞춘 사람만' 옵션: 승패(outcome) 적중자에게만 발송
|
||||
if settings.result_email_correct_only:
|
||||
conds.append(UserPrediction.outcome == m.result_outcome)
|
||||
picks = (
|
||||
await db.execute(select(UserPrediction).where(*conds))
|
||||
).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(),
|
||||
)
|
||||
# 축구 데이터 수집: 매일 KST 00:00 (예측 생성보다 먼저 캐시를 채움)
|
||||
sched.add_job(
|
||||
refresh_football_data,
|
||||
"cron",
|
||||
hour=settings.football_refresh_hour,
|
||||
minute=settings.football_refresh_minute,
|
||||
id="football_refresh",
|
||||
)
|
||||
# AI 예측 생성: 매시간 — 킥오프 22h 전에 든 경기를 놓치지 않게.
|
||||
# only_missing 이라 이미 생성된 (경기×모델)은 API 호출 없이 건너뜀(비용 동일).
|
||||
sched.add_job(
|
||||
generate_ai_predictions,
|
||||
"interval",
|
||||
hours=1,
|
||||
id="ai_generate",
|
||||
next_run_time=now_utc(),
|
||||
)
|
||||
# 결과 자동 정산 + 메일: 킥오프+Nh 지난 경기 스코어 수집·채점·발송
|
||||
sched.add_job(
|
||||
settle_matches,
|
||||
"interval",
|
||||
seconds=settings.settle_tick_seconds,
|
||||
id="settle",
|
||||
next_run_time=now_utc(),
|
||||
)
|
||||
# 야구(kbo/mlb): 일정·프리뷰 동기화 매일 09:00 + AI 생성 직전 00:00
|
||||
sched.add_job(
|
||||
sync_baseball_job, "cron",
|
||||
hour=settings.schedule_sync_hour, minute=settings.schedule_sync_minute,
|
||||
id="baseball_sync",
|
||||
)
|
||||
sched.add_job(sync_baseball_job, "cron", hour=0, minute=0, id="baseball_sync_night")
|
||||
# 야구 결과 정산 폴링
|
||||
sched.add_job(
|
||||
settle_baseball, "interval",
|
||||
seconds=settings.settle_tick_seconds, id="baseball_settle",
|
||||
)
|
||||
# 오늘의 응원가: 킥오프 임박 경기 생성 시작 + Suno 작업 폴링
|
||||
sched.add_job(
|
||||
tick_songs, "interval",
|
||||
seconds=settings.song_tick_seconds, id="songs",
|
||||
next_run_time=now_utc(),
|
||||
)
|
||||
return sched
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
load_scoring_data() # data/scoring.json → 배점·배제 대상 (채점·결과메일에 사용)
|
||||
await init_db() # 테이블 보장 (idempotent). 시드는 API 가 담당.
|
||||
# 기동 시 1회: 일정 동기화(축구+야구) → AI 예측 생성 → 밀린 경기 자동 정산
|
||||
await sync_schedule_job()
|
||||
await sync_baseball_job()
|
||||
await refresh_football_data() # 축구 데이터 캐시 선채움 (키 없으면 no-op)
|
||||
await generate_ai_predictions()
|
||||
await settle_matches()
|
||||
await settle_baseball()
|
||||
|
||||
sched = build_scheduler()
|
||||
sched.start()
|
||||
log.info(
|
||||
"scheduling-server started — schedule sync daily %02d:%02d · status every %ss · "
|
||||
"AI gen hourly (kickoff-%dh) · auto-settle on FINISHED (poll every %ss) · 맞춘사람만=%s",
|
||||
settings.schedule_sync_hour,
|
||||
settings.schedule_sync_minute,
|
||||
settings.status_tick_seconds,
|
||||
settings.ai_generate_lookahead_hours,
|
||||
settings.settle_tick_seconds,
|
||||
settings.result_email_correct_only,
|
||||
)
|
||||
# 영구 대기
|
||||
stop = asyncio.Event()
|
||||
try:
|
||||
await stop.wait()
|
||||
finally:
|
||||
sched.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
7
backend/data/scoring.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"score_exact": 5,
|
||||
"score_close": 3,
|
||||
"score_outcome": 2,
|
||||
"score_partial": 1,
|
||||
"score_miss": 0
|
||||
}
|
||||
17
backend/requirements.txt
Normal file
@ -0,0 +1,17 @@
|
||||
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
|
||||
azure-communication-email==1.0.0
|
||||
aiohttp==3.10.11
|
||||
httpx==0.27.2
|
||||
python-jose[cryptography]==3.5.0
|
||||
anthropic==0.69.0
|
||||
openai==1.59.6
|
||||
google-genai==0.8.0
|
||||
pillow==11.1.0
|
||||
@ -1,433 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { DEMO_FORCE_OPEN } from "@/lib/mockData";
|
||||
import { outcomeLabel, pct, kickoffDisplay, winProb } from "@/lib/format";
|
||||
import { type Lang, dict, teamShort } from "@/lib/i18n";
|
||||
import type {
|
||||
Outcome,
|
||||
CrowdStats,
|
||||
ModelName,
|
||||
Match,
|
||||
AIPrediction,
|
||||
Team,
|
||||
} from "@/lib/types";
|
||||
import Countdown from "./Countdown";
|
||||
import TeamFlag from "./TeamFlag";
|
||||
|
||||
type Step = "pick" | "form" | "done";
|
||||
|
||||
const MODEL_ICON: Record<ModelName, string> = {
|
||||
GPT: "/icons/gpt.png",
|
||||
Claude: "/icons/claude.jpg",
|
||||
Gemini: "/icons/gemini.jpeg",
|
||||
};
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export default function Arena({
|
||||
match,
|
||||
predictions,
|
||||
crowd: initialCrowd,
|
||||
shareUrl,
|
||||
lang = "ko",
|
||||
}: {
|
||||
match: Match;
|
||||
predictions: AIPrediction[];
|
||||
crowd: CrowdStats;
|
||||
shareUrl: string;
|
||||
lang?: Lang;
|
||||
}) {
|
||||
const t = dict(lang);
|
||||
const aShort = teamShort(match.teamA, lang);
|
||||
const bShort = teamShort(match.teamB, lang);
|
||||
const [outcome, setOutcome] = useState<Outcome | null>(null);
|
||||
const [scoreA, setScoreA] = useState(2);
|
||||
const [scoreB, setScoreB] = useState(1);
|
||||
const [step, setStep] = useState<Step>("pick");
|
||||
const [email, setEmail] = useState("");
|
||||
const [notify, setNotify] = useState(true);
|
||||
const [crowd, setCrowd] = useState<CrowdStats>(initialCrowd);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// 점수 선택 시 승/무/패 자동 선택 (스코어가 결과의 소스)
|
||||
useEffect(() => {
|
||||
setOutcome(
|
||||
scoreA > scoreB ? "TEAM_A_WIN" : scoreA < scoreB ? "TEAM_B_WIN" : "DRAW",
|
||||
);
|
||||
}, [scoreA, scoreB]);
|
||||
|
||||
// 투표 창: 오픈(D-2) ≤ now < 마감(킥오프 정각). 종료 = 결과 존재.
|
||||
const now = Date.now();
|
||||
const finished = !!match.result;
|
||||
const notOpen = !DEMO_FORCE_OPEN && now < new Date(match.opensAt).getTime();
|
||||
const locked = now >= new Date(match.lockAt).getTime();
|
||||
const disabled = finished || locked || notOpen;
|
||||
const ctaLabel = finished
|
||||
? t.ctaResult
|
||||
: notOpen
|
||||
? t.ctaOpens(kickoffDisplay(match.opensAt, lang))
|
||||
: locked
|
||||
? t.ctaLocked
|
||||
: t.ctaBeat;
|
||||
|
||||
const matched = useMemo(() => {
|
||||
if (!outcome) return [];
|
||||
return predictions
|
||||
.filter((p) => p.outcome === outcome)
|
||||
.map((p) => ({ model: p.model, exact: p.scoreA === scoreA && p.scoreB === scoreB }));
|
||||
}, [outcome, scoreA, scoreB, predictions]);
|
||||
|
||||
const submitPick = () => {
|
||||
if (!outcome) return;
|
||||
setStep("form");
|
||||
};
|
||||
const confirmSubmit = () => {
|
||||
if (!EMAIL_RE.test(email.trim())) return;
|
||||
setCrowd((c) => ({
|
||||
...c,
|
||||
total: c.total + 1,
|
||||
teamAWin: c.teamAWin + (outcome === "TEAM_A_WIN" ? 1 : 0),
|
||||
draw: c.draw + (outcome === "DRAW" ? 1 : 0),
|
||||
teamBWin: c.teamBWin + (outcome === "TEAM_B_WIN" ? 1 : 0),
|
||||
}));
|
||||
setStep("done");
|
||||
};
|
||||
|
||||
const shareText = useMemo(() => {
|
||||
if (!outcome) return "";
|
||||
const me = `${aShort} ${scoreA}-${scoreB} ${bShort}`;
|
||||
const sameAI = matched.map((m) => m.model).join("·");
|
||||
if (lang === "en") {
|
||||
return sameAI
|
||||
? `I picked ${me}, same as ${sameAI}! You? — TriplePick`
|
||||
: `I picked ${me} — different from all 3 AIs! You? — TriplePick`;
|
||||
}
|
||||
return sameAI
|
||||
? `나는 ${me}. ${sameAI}와 같은 선택! 너는? — TriplePick`
|
||||
: `나는 ${me}. AI 셋과 다 다른 선택! 너는? — TriplePick`;
|
||||
}, [outcome, scoreA, scoreB, matched, aShort, bShort, lang]);
|
||||
|
||||
const copyLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(`${shareText}\n${shareUrl}`);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1800);
|
||||
} catch {
|
||||
setCopied(false);
|
||||
}
|
||||
};
|
||||
const nativeShare = async () => {
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: `${aShort} vs ${bShort} — TriplePick`,
|
||||
text: shareText,
|
||||
url: shareUrl,
|
||||
});
|
||||
} catch {
|
||||
/* cancelled */
|
||||
}
|
||||
} else copyLink();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ===== 카운트다운 (D7) — 투표 진행 중일 때만 ===== */}
|
||||
{!finished && !notOpen && (
|
||||
<div className="mt-4">
|
||||
<Countdown to={match.lockAt} lang={lang} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== 레이어드 화이트 시트 (002) ===== */}
|
||||
<section className="sheet mt-4 p-5 text-[var(--ink)]">
|
||||
<div className="mb-3">
|
||||
<h2 className="text-[22px] font-extrabold">{t.aiBattle}</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{predictions.map((p) => {
|
||||
const wp = winProb(match, p);
|
||||
const wpLabel = wp.team
|
||||
? t.winProb(teamShort(wp.team, lang))
|
||||
: t.drawOdds;
|
||||
return (
|
||||
<div
|
||||
key={p.model}
|
||||
className="rounded-2xl border border-[var(--line-l)] bg-[var(--sheet-card)] p-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src={MODEL_ICON[p.model]}
|
||||
alt={p.model}
|
||||
className="h-12 w-12 shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[20px] font-extrabold leading-none">
|
||||
{p.model}
|
||||
</div>
|
||||
{/* D3: 승리 예측 팀의 승리 확률 + 승리팀 국기 */}
|
||||
<div className="mt-1.5 flex items-center gap-1.5">
|
||||
{wp.team && (
|
||||
<TeamFlag team={wp.team} className="h-3.5 w-5 shrink-0" />
|
||||
)}
|
||||
<span className="text-[12px] font-bold text-[var(--ink-muted)]">
|
||||
{wpLabel} <span className="text-[var(--gpt)]">{wp.pct}%</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto shrink-0 whitespace-nowrap font-mono text-[28px] font-extrabold tabular-nums">
|
||||
{p.scoreA} - {p.scoreB}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메인 리즌 강조 (D3: 확률은 텍스트, 바 제거) */}
|
||||
<p className="mt-3 text-[15px] font-bold leading-snug text-[var(--ink)]">
|
||||
“{p.reasonShort}”
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 당신의 선택 */}
|
||||
<div className="mt-5 flex items-center justify-between">
|
||||
<span className="text-[13px] font-bold text-[var(--ink-muted)]">
|
||||
{t.yourPickLabel}
|
||||
</span>
|
||||
<span className="text-[20px] font-extrabold">{t.yourChoice}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||
{(
|
||||
[
|
||||
["TEAM_A_WIN", `${aShort} ${t.win}`],
|
||||
["DRAW", t.drawLabel],
|
||||
["TEAM_B_WIN", `${bShort} ${t.win}`],
|
||||
] as [Outcome, string][]
|
||||
).map(([val, label]) => (
|
||||
<button
|
||||
key={val}
|
||||
disabled={disabled}
|
||||
onClick={() => setOutcome(val)}
|
||||
className={`rounded-xl border py-3.5 text-[16px] font-extrabold transition disabled:opacity-50 ${
|
||||
outcome === val
|
||||
? "border-transparent bg-[var(--mint)] text-[var(--mint-ink)]"
|
||||
: "border-[var(--line-l)] bg-white text-[var(--ink-muted)]"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-center gap-5 rounded-xl border border-[var(--line-l)] bg-white py-3">
|
||||
<Stepper label={aShort} value={scoreA} onChange={setScoreA} disabled={disabled} />
|
||||
<span className="text-[26px] font-extrabold text-[var(--ink-muted)]">:</span>
|
||||
<Stepper label={bShort} value={scoreB} onChange={setScoreB} disabled={disabled} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ===== 종료된 경기: 결과 보기 ===== */}
|
||||
{finished && match.result && (
|
||||
<section className="mt-5 rounded-2xl border border-[var(--green)]/50 bg-[var(--bg2)] p-5">
|
||||
<div className="text-[13px] text-[var(--ink-muted)]">{t.finalResult}</div>
|
||||
<div className="mt-1 text-[26px] font-extrabold">
|
||||
{aShort} {match.result.scoreA}-{match.result.scoreB} {bShort}
|
||||
<span className="ml-2 text-[16px] font-bold text-[var(--green)]">
|
||||
{outcomeLabel(match, match.result.outcome, lang)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-col gap-1.5">
|
||||
{predictions.map((p) => {
|
||||
const hit = p.outcome === match.result!.outcome;
|
||||
return (
|
||||
<div key={p.model} className="flex items-center justify-between text-[13px]">
|
||||
<span className="font-bold text-white/85">{p.model}</span>
|
||||
<span className={hit ? "font-bold text-[var(--green)]" : "text-white/45"}>
|
||||
{hit ? t.hit : t.miss}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ===== CTA 민트 #85FCA8 (진행 중일 때) ===== */}
|
||||
{!finished && step === "pick" && (
|
||||
<button
|
||||
onClick={submitPick}
|
||||
disabled={!outcome || disabled}
|
||||
className="btn-mint mt-5 w-full rounded-2xl py-5 text-[20px] font-extrabold transition active:scale-[0.99] disabled:opacity-40 disabled:shadow-none"
|
||||
>
|
||||
{ctaLabel}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* ===== 폼: 이메일만 (D5, 닉네임 제거) ===== */}
|
||||
{!finished && step === "form" && (
|
||||
<div className="mt-5 space-y-2.5 rounded-2xl border border-[var(--line-d)] bg-[var(--bg2)] p-4">
|
||||
<input
|
||||
type="email"
|
||||
inputMode="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder={t.emailPh}
|
||||
className="w-full rounded-lg border border-[var(--line-d)] bg-[#0f1217] px-3 py-3 text-[15px] text-white outline-none focus:border-[var(--green)]"
|
||||
/>
|
||||
<label className="flex items-start gap-2 text-[13px] text-[var(--ink-muted)]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notify}
|
||||
onChange={(e) => setNotify(e.target.checked)}
|
||||
className="mt-0.5 accent-[var(--mint)]"
|
||||
/>
|
||||
{t.notify}
|
||||
</label>
|
||||
<button
|
||||
onClick={confirmSubmit}
|
||||
disabled={!EMAIL_RE.test(email.trim())}
|
||||
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}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== 제출 완료: 같은 AI + 경기별 공유 (D8) ===== */}
|
||||
{!finished && step === "done" && outcome && (
|
||||
<div className="mt-5 rounded-2xl border border-[var(--green)]/50 bg-[var(--bg2)] p-5">
|
||||
<div className="text-[13px] text-[var(--ink-muted)]">{t.myPick}</div>
|
||||
<div className="mt-1 text-[26px] font-extrabold">
|
||||
{aShort} {scoreA}-{scoreB} {bShort}
|
||||
<span className="ml-2 text-[16px] font-bold text-[var(--green)]">
|
||||
{outcomeLabel(match, outcome, lang)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-[14px] leading-relaxed text-white/80">
|
||||
{matched.length > 0
|
||||
? t.sameAI(
|
||||
matched.map((m) => m.model).join("·"),
|
||||
matched.some((m) => m.exact),
|
||||
)
|
||||
: t.soloPick}
|
||||
</p>
|
||||
<div className="mt-4 grid grid-cols-2 gap-2.5">
|
||||
<button onClick={nativeShare} className="btn-mint rounded-xl py-3 text-[15px] font-bold active:scale-[0.99]">
|
||||
{t.shareThis}
|
||||
</button>
|
||||
<button onClick={copyLink} className="rounded-xl border border-[var(--line-d)] py-3 text-[15px] font-bold active:scale-[0.99]">
|
||||
{copied ? t.copied : t.copyLink}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== 골드 상금 (003) ===== */}
|
||||
<section className="gold-card mt-5 rounded-2xl p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5 text-[18px] font-extrabold text-[var(--gold-border)]">
|
||||
<span>₩1,000,000 Final Challenge</span>
|
||||
<span>★</span>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[13px] leading-snug text-white/75">
|
||||
{t.goldDesc}
|
||||
</p>
|
||||
</div>
|
||||
<button className="gold-btn shrink-0 whitespace-pre-line rounded-xl px-4 py-3.5 text-[14px] font-extrabold leading-tight active:scale-[0.98]">
|
||||
{t.goldBtn}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ===== Crowd Pick (003) — 보팅 비율(≠ AI 승리 확률) ===== */}
|
||||
<section className="mt-6">
|
||||
<div className="mb-2.5 text-[20px] font-extrabold">
|
||||
Crowd Pick{" "}
|
||||
<span className="text-[14px] text-[var(--ink-muted)]">{t.crowdSub}</span>
|
||||
</div>
|
||||
<div className="flex h-14 overflow-hidden rounded-2xl border border-[var(--line-d)]">
|
||||
<CrowdSeg team={match.teamA} value={pct(crowd.teamAWin, crowd.total)} tone="a" />
|
||||
<CrowdSeg value={pct(crowd.draw, crowd.total)} tone="draw" />
|
||||
<CrowdSeg team={match.teamB} value={pct(crowd.teamBWin, crowd.total)} tone="b" />
|
||||
</div>
|
||||
<div className="mt-1.5 text-right text-[11px] text-[var(--ink-muted)]">
|
||||
{t.joined(crowd.total.toLocaleString())}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Stepper({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (n: number) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<StepBtn disabled={disabled || value <= 0} onClick={() => onChange(Math.max(0, value - 1))}>
|
||||
−
|
||||
</StepBtn>
|
||||
<div className="flex w-12 flex-col items-center">
|
||||
<span className="font-mono text-[32px] font-extrabold leading-none tabular-nums">
|
||||
{value}
|
||||
</span>
|
||||
<span className="mt-1 text-[11px] text-[var(--ink-muted)]">{label}</span>
|
||||
</div>
|
||||
<StepBtn disabled={disabled || value >= 9} onClick={() => onChange(Math.min(9, value + 1))}>
|
||||
+
|
||||
</StepBtn>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepBtn({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className="grid h-11 w-11 place-items-center rounded-xl border border-[var(--line-l)] bg-white text-[22px] font-bold leading-none text-[var(--ink)] active:scale-95 disabled:opacity-30"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function CrowdSeg({
|
||||
team,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
team?: Team;
|
||||
value: number;
|
||||
tone: "a" | "draw" | "b";
|
||||
}) {
|
||||
const bg = tone === "draw" ? "#2b313c" : tone === "a" ? "rgba(74,255,160,0.22)" : "#222831";
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center gap-1.5 border-r border-[var(--line-d)] text-[16px] font-extrabold last:border-r-0"
|
||||
style={{ width: `${value}%`, background: bg, minWidth: 56 }}
|
||||
>
|
||||
{team && <TeamFlag team={team} className="h-4 w-6" />}
|
||||
<span>{value}%</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,51 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import ShareButton from "./ShareButton";
|
||||
import LangSwitch from "./LangSwitch";
|
||||
import { type Lang, dict } from "@/lib/i18n";
|
||||
|
||||
type Share = { url: string; title: string; text: string };
|
||||
|
||||
// 브랜드 헤더 (대시보드·상세 공용). 경기별 후킹 카피는 상세 페이지에서 별도 노출.
|
||||
export default function Hero({
|
||||
back = false,
|
||||
share,
|
||||
lang = "ko",
|
||||
}: {
|
||||
back?: boolean;
|
||||
share?: Share;
|
||||
lang?: Lang;
|
||||
}) {
|
||||
const t = dict(lang);
|
||||
const home = lang === "en" ? "/?lang=en" : "/";
|
||||
return (
|
||||
<header className="pt-5 text-center">
|
||||
{back && (
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<Link
|
||||
href={home}
|
||||
className="flex items-center gap-1 rounded-full border border-white/12 bg-white/8 px-3 py-1.5 text-[12px] font-bold text-white/85 active:scale-95"
|
||||
>
|
||||
{t.back}
|
||||
</Link>
|
||||
{share && (
|
||||
<ShareButton {...share} label={t.share} copiedLabel={t.shareCopied} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="font-impact text-[44px] leading-none tracking-tight">
|
||||
TRIPLE PICK <span className="text-[var(--green)]">2026</span>
|
||||
</div>
|
||||
<div className="relative mt-2">
|
||||
<div className="text-[16px] font-extrabold text-[var(--green)]">
|
||||
AI Prediction Arena
|
||||
</div>
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2">
|
||||
<LangSwitch lang={lang} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 inline-block rounded-full border border-white/12 bg-white/8 px-3 py-1 text-[13px] font-semibold text-white/85">
|
||||
{t.heroPill}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@ -1,64 +0,0 @@
|
||||
import type { Match } from "@/lib/types";
|
||||
import { kickoffDisplay } from "@/lib/format";
|
||||
import { type Lang, dict } from "@/lib/i18n";
|
||||
import BallIcon from "./BallIcon";
|
||||
import TeamFlag from "./TeamFlag";
|
||||
|
||||
export default function MatchupHUD({ match, lang = "ko" }: { match: Match; lang?: Lang }) {
|
||||
const t = dict(lang);
|
||||
const { group } = match;
|
||||
const finished = !!match.result;
|
||||
return (
|
||||
<section className="mt-6">
|
||||
{/* 대결 카드 (녹색 글로우 보더) */}
|
||||
<div
|
||||
className="rounded-3xl border-2 border-[var(--green)] bg-[#171b21] p-5"
|
||||
style={{ boxShadow: "0 0 28px rgba(74,255,160,0.35), inset 0 0 24px rgba(74,255,160,0.06)" }}
|
||||
>
|
||||
{/* 헤더: 브랜드 아이콘 + 팀명 + 일시 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<BallIcon className="h-14 w-14 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-[22px] font-extrabold leading-tight">
|
||||
{match.teamA.name} <span className="text-white/55">vs</span>{" "}
|
||||
{match.teamB.name}
|
||||
</div>
|
||||
<div className="mt-1 text-[15px] font-bold text-[var(--green)]">
|
||||
{kickoffDisplay(match.kickoffKst, lang)} · {t.group(group)}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[12px] text-white/65">{match.venue}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 국기 + 라이트닝 VS (종료 시 최종 스코어) */}
|
||||
<div className="mt-5 grid grid-cols-[1fr_auto_1fr] items-center gap-3">
|
||||
<TeamFlag team={match.teamA} className="mx-auto h-[68px] w-[104px]" />
|
||||
<div className="relative grid place-items-center">
|
||||
<div className="vs-glow absolute h-28 w-28" />
|
||||
{finished && match.result ? (
|
||||
<span className="relative whitespace-nowrap font-mono text-[34px] font-extrabold tabular-nums text-white">
|
||||
{match.result.scoreA}
|
||||
<span className="px-1 text-white/55">-</span>
|
||||
{match.result.scoreB}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="relative font-impact text-[46px] italic leading-none text-[var(--green)]"
|
||||
style={{ textShadow: "0 0 18px rgba(74,255,160,0.8)" }}
|
||||
>
|
||||
VS
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<TeamFlag team={match.teamB} className="mx-auto h-[68px] w-[104px]" />
|
||||
</div>
|
||||
|
||||
{finished && (
|
||||
<div className="mt-3 text-center text-[12px] font-bold text-[var(--green)]">
|
||||
{t.matchEnded}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -1,106 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { matchesByDate, matchPhase, type MatchPhase } from "@/lib/schedule";
|
||||
import { getPredictions, getCrowd } from "@/lib/mockData";
|
||||
import { dateHeading, timeOnly } from "@/lib/format";
|
||||
import { type Lang, dict, teamShort, roundLabel as tRound } from "@/lib/i18n";
|
||||
import type { Match } from "@/lib/types";
|
||||
import TeamFlag from "./TeamFlag";
|
||||
|
||||
const STROKE = "#94FBE0"; // 일정 텍스트·선택 아웃라인 스트로크 컬러
|
||||
|
||||
const PHASE_CLS: Record<MatchPhase, string> = {
|
||||
open: "border-[#94FBE0] text-[#94FBE0]",
|
||||
scheduled: "border-[var(--line-d)] text-[var(--ink-muted)]",
|
||||
locked: "border-[var(--line-d)] text-[var(--ink-muted)]",
|
||||
finished: "border-white/15 text-white/55",
|
||||
};
|
||||
|
||||
export default function ScheduleBoard({ lang = "ko" }: { lang?: Lang }) {
|
||||
const t = dict(lang);
|
||||
const groups = matchesByDate();
|
||||
return (
|
||||
<section className="mt-6">
|
||||
<div className="mb-3 flex items-baseline gap-2.5">
|
||||
<h2 className="shrink-0 text-[22px] font-extrabold">{t.schedTitle}</h2>
|
||||
<span className="whitespace-nowrap text-[12px] text-white/55">
|
||||
{t.schedGuide}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-5">
|
||||
{groups.map((g) => (
|
||||
<div key={g.date}>
|
||||
<div className="mb-2 text-[13px] font-bold" style={{ color: STROKE }}>
|
||||
{dateHeading(g.date, lang)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{g.matches.map((m) => (
|
||||
<MatchCard key={m.matchId} match={m} lang={lang} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function MatchCard({ match, lang }: { match: Match; lang: Lang }) {
|
||||
const t = dict(lang);
|
||||
const phase = matchPhase(match);
|
||||
const preds = getPredictions(match, lang);
|
||||
const crowd = getCrowd(match);
|
||||
|
||||
// AI 픽 갈림 요약
|
||||
const tally = { a: 0, d: 0, b: 0 };
|
||||
for (const p of preds) {
|
||||
if (p.outcome === "TEAM_A_WIN") tally.a++;
|
||||
else if (p.outcome === "DRAW") tally.d++;
|
||||
else tally.b++;
|
||||
}
|
||||
const split: string[] = [];
|
||||
if (tally.a) split.push(`${teamShort(match.teamA, lang)} ${tally.a}`);
|
||||
if (tally.d) split.push(`${t.draw} ${tally.d}`);
|
||||
if (tally.b) split.push(`${teamShort(match.teamB, lang)} ${tally.b}`);
|
||||
|
||||
const href = `/match/${match.matchId}${lang === "en" ? "?lang=en" : ""}`;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="block rounded-2xl border-2 border-[#94FBE0]/45 bg-[#171b21] p-4 transition active:scale-[0.99] hover:border-[#94FBE0]"
|
||||
>
|
||||
<div className="flex items-center justify-between text-[11px]">
|
||||
<span className="font-mono font-bold text-white/70">
|
||||
{timeOnly(match.kickoffKst)} <span className="text-white/40">KST</span> ·{" "}
|
||||
{tRound(match.roundLabel, lang)}
|
||||
</span>
|
||||
<span className={`rounded-md border px-2 py-0.5 font-semibold ${PHASE_CLS[phase]}`}>
|
||||
{t.phase[phase]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-[1fr_auto_1fr] items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<TeamFlag team={match.teamA} className="h-6 w-9 shrink-0" />
|
||||
<span className="truncate text-[15px] font-extrabold">{teamShort(match.teamA, lang)}</span>
|
||||
</div>
|
||||
<span className="font-impact text-[18px] italic text-[#94FBE0]">VS</span>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<span className="truncate text-right text-[15px] font-extrabold">{teamShort(match.teamB, lang)}</span>
|
||||
<TeamFlag team={match.teamB} className="h-6 w-9 shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between text-[11px] font-bold text-[#F4F3FE]">
|
||||
<span>
|
||||
<span className="font-semibold text-white/55">{t.aiPicks}</span> {split.join(" · ")}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{t.joined(crowd.total.toLocaleString())}</span>
|
||||
<span className="text-[#94FBE0]">→</span>
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@ -1,47 +0,0 @@
|
||||
import type { Team } from "@/lib/types";
|
||||
|
||||
// 모든 경기에 일반화된 국기 렌더.
|
||||
// KOR = 실제 png 자산, CZE = 인라인 SVG(비율 보존), 그 외 = 이모지(컨테이너 높이에 비례, 안 잘림).
|
||||
export default function TeamFlag({
|
||||
team,
|
||||
className = "",
|
||||
}: {
|
||||
team: Team;
|
||||
className?: string;
|
||||
}) {
|
||||
if (team.code === "KOR") {
|
||||
return (
|
||||
<img
|
||||
src="/icons/kor.png"
|
||||
alt={team.name}
|
||||
className={`flag-img object-cover ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (team.code === "CZE") {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 6 4"
|
||||
className={`border border-[var(--line-d)] ${className}`}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
aria-label={team.name}
|
||||
>
|
||||
<rect width="6" height="2" y="0" fill="#ffffff" />
|
||||
<rect width="6" height="2" y="2" fill="#d7141a" />
|
||||
<polygon points="0,0 3,2 0,4" fill="#11457e" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
// 그 외 국가 — 이모지 국기. 컨테이너 높이(cqh) 기준으로 크기를 잡아 잘림/찌그러짐 방지.
|
||||
return (
|
||||
<span
|
||||
className={`grid place-items-center overflow-hidden border border-[var(--line-d)] bg-white/[0.06] ${className}`}
|
||||
style={{ containerType: "size" }}
|
||||
aria-label={team.name}
|
||||
>
|
||||
<span className="leading-none" style={{ fontSize: "92cqh" }}>
|
||||
{team.flag}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
37
docker-compose.yml
Normal file
@ -0,0 +1,37 @@
|
||||
# TriplePick — 외부 PostgreSQL 에 연결하는 구성 (로컬 db 컨테이너 없음).
|
||||
# api FastAPI (uvicorn)
|
||||
# worker APScheduler (상태전이 · 매일 AI 예측 생성 · 결과 메일)
|
||||
# frontend Vite 빌드 → nginx (정적 + /api 프록시)
|
||||
#
|
||||
# DB 는 이미 떠 있는 외부 PostgreSQL(예: 172.30.1.36:5432)에 연결한다.
|
||||
# backend/.env 에 DB_HOST/DB_PORT/DB_NAME/DB_USER/DB_PASSWORD 를 채우면
|
||||
# app 이 접속 URL 을 조합해 그 DB 에 스키마(테이블)를 생성한다.
|
||||
#
|
||||
# 실행: cp backend/.env.example backend/.env (DB·키 채우기) → docker compose up --build
|
||||
# 접속: http://localhost:8080
|
||||
|
||||
services:
|
||||
api:
|
||||
build: ./backend
|
||||
env_file:
|
||||
- path: ./backend/.env
|
||||
required: false
|
||||
ports:
|
||||
- "8000:8000"
|
||||
|
||||
worker:
|
||||
build: ./backend
|
||||
command: ["python", "-m", "app.worker"]
|
||||
env_file:
|
||||
- path: ./backend/.env
|
||||
required: false
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_started
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
depends_on:
|
||||
- api
|
||||
ports:
|
||||
- "8080:80"
|
||||
38
docs/DATA_SOURCES.md
Normal file
@ -0,0 +1,38 @@
|
||||
# 야구(KBO/MLB) 데이터 소스 카탈로그 (실검증: 2026-07-21)
|
||||
|
||||
## 소스 총평
|
||||
|
||||
| 소스 | 상태 | 요약 |
|
||||
|---|---|---|
|
||||
| **네이버 스포츠 API** (비공식) | ✅ KBO 주력 | 일정·결과·순위·프리뷰·박스스코어·문자중계(투구 단위)까지 전부. 키 불필요, UA 헤더만 |
|
||||
| **MLB 공식 Stats API** | ✅ MLB 주력 | statsapi.mlb.com — 키 불필요·공식. 일정·결과·순위·예고선발·투구 단위 라이브 피드 |
|
||||
| TheSportsDB | ⚠️ 유료 전용 | 무료 키 `123`은 next/past 조회당 1경기만(전 리그 공통). 유료 ~$10/월 |
|
||||
| API-Sports 야구 | ❌ 미사용 | KBO=league 5 존재하나 무료는 2022~24 시즌만 |
|
||||
|
||||
## 네이버 (api-gw.sports.naver.com) — KBO
|
||||
|
||||
- 인증 없음, User-Agent 필수. gameId = `{yyyymmdd}{원정}{홈}0{연도}`
|
||||
- 팀코드: HT(KIA) LG OB(두산) SS(삼성) LT(롯데) SK(SSG) KT NC WO(키움) HH(한화)
|
||||
- `GET /schedule/games?...&categoryId=kbo&fromDate=&toDate=` — 일정·스코어·statusInfo("N회초/말")·cancel·suspended
|
||||
- `GET /schedule/games/{id}/preview` — 예고 선발투수(구종·상대전적)·핫콜드존·시즌 상대전적·순위
|
||||
- `GET /schedule/games/{id}/record` — R/H/E/B·이닝별·타자/투수 박스스코어·교체·홈런일지
|
||||
- `GET /schedule/games/{id}/relay?inning=N` — currentGameState(투수/타자/볼카운트/주자)·라인업(seqno=교체)·투구단위 텍스트
|
||||
- `GET /stats/categories/kbo/seasons/{year}/teams` — 순위·승률·최근5·팀 공격/수비 풀스탯
|
||||
- 리스크: 비공식(약관·차단). 로컬/데모용 — 상용 전환 시 정식 소스 교체
|
||||
|
||||
## MLB 공식 (statsapi.mlb.com) — MLB
|
||||
|
||||
- `GET /api/v1/schedule?sportId=1&startDate=&endDate=` (+`&hydrate=probablePitcher`)
|
||||
- `GET /api/v1/standings?leagueId=103|104&season=` — AL/NL 지구 순위
|
||||
- `GET /api/v1.1/game/{gamePk}/feed/live` — linescore(B/S/O·주자)·offense(batter/onDeck/inHole/pitcher)·boxscore(타순·포지션)·plays(투구 단위)
|
||||
- 팀 로고: `https://www.mlbstatic.com/team-logos/{teamId}.svg` (공식 CDN)
|
||||
- 한글 팀명은 자체 매핑(`teams_baseball.py`), 선수명은 영문
|
||||
|
||||
## 구현 위치 (backend/app)
|
||||
|
||||
- `teams_baseball.py` — KBO 10 + MLB 30 (한글·statsapi id·로고)
|
||||
- `services/baseball_fetch.py` — 일정·결과 수집 (리그별 어댑터)
|
||||
- `services/baseball_sync.py` — (리그, KST날짜, 팀쌍) 키 동기화 · 우천취소 제거
|
||||
- `services/baseball_details.py` — 프리뷰·순위 캐시(DataCache) + 라이브 필드 뷰 프록시(15s TTL)
|
||||
- `services/baseball_data.py` — AI 프롬프트 데이터 블록 (자체 DB 폼 + 캐시)
|
||||
- 호출량: 동기화 하루 2회 + 정산 5분 폴링 + 라이브는 경기당 최대 4콜/분(캐시 상한)
|
||||
@ -44,13 +44,28 @@
|
||||
- 무승부: 승패 적중이면서 득실차(=0) 일치 → 정확이 아니면 **근접(3점)**.
|
||||
- **배점 수치는 황 본부장 확정 후 dev 전달**(회의 05:48). 위는 기본 제안값.
|
||||
|
||||
### 야구(KBO/MLB) 판정 완화 (2026-07-22)
|
||||
|
||||
야구는 득점 범위가 넓어(0~12+) 축구 기준 그대로면 정확/근접/부분이 거의 나오지
|
||||
않는다. **등급 체계·배점은 리그 공통**으로 유지하고, 야구만 판정에 허용 오차
|
||||
±1(`scoring.BASEBALL_TOLERANCE`)을 둔다. 리그별 시상이므로 리그 간 점수 크기
|
||||
차이는 문제되지 않는다.
|
||||
|
||||
| 등급 | 야구 조건 | 포인트 |
|
||||
|---|---|---|
|
||||
| 근접 | 승패 적중 + **득실차 오차 ≤1** | 3 |
|
||||
| 부분 | **한 팀 득점 오차 ≤1** | 1 |
|
||||
|
||||
(정확·승패·빗나감 조건은 축구와 동일)
|
||||
|
||||
### 채점 알고리즘 (결정론적)
|
||||
|
||||
```
|
||||
tol = 야구 ? 1 : 0
|
||||
1) pick.scoreA == result.scoreA && pick.scoreB == result.scoreB → 정확(5)
|
||||
2) else if pick.outcome == result.outcome:
|
||||
(pick.scoreA - pick.scoreB) == (result.scoreA - result.scoreB) ? 근접(3) : 승패(2)
|
||||
3) else if pick.scoreA == result.scoreA || pick.scoreB == result.scoreB → 부분(1)
|
||||
|득실차 오차| <= tol ? 근접(3) : 승패(2)
|
||||
3) else if |pick.scoreA - result.scoreA| <= tol || |pick.scoreB - result.scoreB| <= tol → 부분(1)
|
||||
4) else → 0
|
||||
```
|
||||
|
||||
|
||||
@ -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).
|
||||
8
frontend/.env.example
Normal file
@ -0,0 +1,8 @@
|
||||
# 프론트엔드 환경변수 (Vite) — 복사: cp .env.example .env
|
||||
# 운영(docker)에서는 nginx 가 /api 를 프록시하므로 비워두면 동일 출처(/api)를 사용.
|
||||
|
||||
# 백엔드 API 베이스 (기본: /api). 절대 URL 로 직접 호출하려면 지정.
|
||||
# VITE_API_BASE=/api
|
||||
|
||||
# 로컬 vite dev 서버의 프록시 타깃 (기본: http://localhost:8000)
|
||||
# VITE_API_TARGET=http://localhost:8000
|
||||
15
frontend/Dockerfile
Normal file
@ -0,0 +1,15 @@
|
||||
# TriplePick 프론트엔드 — Vite 빌드 → nginx 정적 서빙 + /api 프록시.
|
||||
# lock은 macOS(arm64)에서 생성돼 rollup darwin-x64 optional dep가 version 없는
|
||||
# stub로 남는 npm 버그가 있어, linux 빌드에선 lock 없이 package.json만으로 설치한다.
|
||||
FROM node:24-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install --no-audit --no-fund
|
||||
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;"]
|
||||
143
frontend/index.html
Normal file
@ -0,0 +1,143 @@
|
||||
<!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" />
|
||||
|
||||
<!-- Pretendard 웹폰트 — globals.css 의 font-family 가 참조. 미로드 시 Windows 는
|
||||
맑은 고딕 폴백으로 떨어져 글씨가 가늘고 흐릿해진다. dynamic-subset: 쓰인 글자만 로드 -->
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css"
|
||||
/>
|
||||
<title>TriplePick | AI 스포츠 승부예측 — KBO·MLB·MLS, GPT vs Claude vs Gemini</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="프로야구 승부예측(KBO)부터 해외야구 승부예측(MLB 메이저리그), MLS까지 — GPT·Claude·Gemini 3대 AI가 매 경기를 서로 다르게 예측합니다. 실시간 스코어·문자중계를 보며 당신의 픽을 찍고 AI와 겨뤄보세요. 무료 참여."
|
||||
/>
|
||||
|
||||
<!-- 공유 썸네일 (Open Graph / Twitter) — 카카오톡 등 소셜 미리보기 -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="TriplePick" />
|
||||
<meta property="og:title" content="TriplePick — AI 스포츠 승부예측 (KBO·MLB·MLS)" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="GPT·Claude·Gemini 3대 AI가 KBO·MLB·MLS 매 경기를 서로 다르게 예측합니다. 당신의 픽을 찍고 AI와 겨뤄보세요. 무료 참여."
|
||||
/>
|
||||
<meta property="og:url" content="https://triplepick.o2o.kr/" />
|
||||
<meta property="og:image" content="https://triplepick.o2o.kr/assets/bi/og-image.png?v=2" />
|
||||
<meta property="og:image:secure_url" content="https://triplepick.o2o.kr/assets/bi/og-image.png?v=2" />
|
||||
<meta property="og:image:type" content="image/png" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:alt" content="TriplePick AI" />
|
||||
<meta property="og:locale" content="ko_KR" />
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="TriplePick — AI 스포츠 승부예측 (KBO·MLB·MLS)" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="GPT·Claude·Gemini 3대 AI가 KBO·MLB·MLS 매 경기를 예측합니다. 당신의 픽을 찍고 AI와 겨뤄보세요."
|
||||
/>
|
||||
<meta name="twitter:image" content="https://triplepick.o2o.kr/assets/bi/og-image.png?v=2" />
|
||||
|
||||
<!-- 검색엔진 소유확인 (verification) -->
|
||||
<meta name="google-site-verification" content="ka2LWpjjrTMayrSJ4_LYowl3w1rt6-RSeaT24xvmUiQ" />
|
||||
<!-- 네이버 서치어드바이저 소유확인 토큰 (2026-06-29 발급·반영 완료) -->
|
||||
<meta name="naver-site-verification" content="d31508b6376797f7a8c587e4f60b72ffb6c7407c" />
|
||||
|
||||
<!-- 검색 키워드 / 색인 지시 -->
|
||||
<meta name="keywords" content="트리플픽, AI승부예측, 스포츠 승부예측, 프로야구 승부예측, 해외야구 승부예측, 메이저리그 승부예측, MLB 승부예측, KBO 승부예측, 야구 승부예측, 축구 승부예측, 해외축구 승부예측, MLS 승부예측, 프로야구 예측, GPT 예측, 클로드 예측, 제미나이 예측, AI vs 사람, 프로야구 문자중계, TriplePick" />
|
||||
<meta name="robots" content="index, follow, max-image-preview:large" />
|
||||
<link rel="canonical" href="https://triplepick.o2o.kr/" />
|
||||
|
||||
<!-- 구조화 데이터 (JSON-LD): 브랜드 인지 + 지식그래프 보조 -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "Organization",
|
||||
"@id": "https://triplepick.o2o.kr/#organization",
|
||||
"name": "TriplePick",
|
||||
"alternateName": "트리플픽",
|
||||
"url": "https://triplepick.o2o.kr/",
|
||||
"logo": "https://triplepick.o2o.kr/assets/bi/og-image.png",
|
||||
"description": "GPT·Claude·Gemini 3대 AI가 같은 경기 데이터로 승패·스코어·승리확률을 예측·비교하는 참여형 스포츠 승부예측 서비스. KBO 프로야구 승부예측·MLB 해외야구 승부예측·MLS 지원.",
|
||||
"sameAs": [
|
||||
"https://www.youtube.com/channel/UCIiFvxaahQA-rLP8KpkDM0w",
|
||||
"https://www.instagram.com/triplepickai"
|
||||
],
|
||||
"parentOrganization": {
|
||||
"@type": "Organization",
|
||||
"name": "AIO2O",
|
||||
"alternateName": "AI오투오",
|
||||
"url": "https://www.o2osolution.ai/",
|
||||
"sameAs": [
|
||||
"https://www.facebook.com/aio2o",
|
||||
"https://www.linkedin.com/company/aio2o"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "WebSite",
|
||||
"@id": "https://triplepick.o2o.kr/#website",
|
||||
"url": "https://triplepick.o2o.kr/",
|
||||
"name": "TriplePick — AI 스포츠 승부예측 아레나",
|
||||
"inLanguage": "ko-KR",
|
||||
"publisher": { "@id": "https://triplepick.o2o.kr/#organization" }
|
||||
},
|
||||
{
|
||||
"@type": "WebApplication",
|
||||
"name": "TriplePick AI 승부예측",
|
||||
"url": "https://triplepick.o2o.kr/",
|
||||
"applicationCategory": "SportsApplication",
|
||||
"operatingSystem": "Web",
|
||||
"inLanguage": "ko-KR",
|
||||
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "KRW" },
|
||||
"description": "GPT·Claude·Gemini의 예측을 확인하고 직접 승패와 스코어를 찍어 AI와 겨루는 무료 참여형 스포츠 승부예측 서비스. KBO 프로야구·MLB 해외야구·MLS 경기 지원, 실시간 스코어·문자중계 제공."
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">
|
||||
<!-- 크롤 가능 본문 폴백(SPA 빈 root 보완). React 마운트 시 실제 앱으로 교체됨.
|
||||
⚠️ CSS로 숨기지 말 것(클로킹 페널티) — 보이게 두면 React가 즉시 덮음. -->
|
||||
<div class="seo-fallback">
|
||||
<h1>트리플픽 — GPT·Claude·Gemini AI 스포츠 승부예측 대결</h1>
|
||||
<p>
|
||||
트리플픽(TriplePick)은 AI승부예측 서비스입니다. GPT·Claude·Gemini 3대 AI가
|
||||
같은 경기 데이터로 승패·스코어·승리확률을 서로 다르게 예측하고, 당신은 직접
|
||||
스포츠 승부예측 픽을 찍어 AI와 겨룹니다. KBO 프로야구·MLB·MLS 전 경기를
|
||||
대상으로 무료로 참여할 수 있습니다.
|
||||
</p>
|
||||
<h2>프로야구 승부예측부터 해외야구 승부예측까지</h2>
|
||||
<p>
|
||||
KBO 프로야구 승부예측은 두산·LG·KIA·삼성 등 10개 구단 전 경기를 매일
|
||||
지원하고, 해외야구 승부예측은 MLB 메이저리그 전 경기를 다룹니다. 야구뿐
|
||||
아니라 해외축구(MLS) 승부예측까지 한 곳에서 — 3대 AI의
|
||||
예측 근거와 승리확률을 비교하고 내 픽의 적중률을 확인해 보세요.
|
||||
</p>
|
||||
<h2>트리플픽은 이런 스포츠 승부예측 서비스입니다</h2>
|
||||
<ul>
|
||||
<li>KBO 프로야구·MLB 해외야구·MLS 3개 리그 경기별 승부예측 지원</li>
|
||||
<li>3대 AI(GPT·Claude·Gemini)의 경기별 예측을 한눈에 비교</li>
|
||||
<li>승패·정확 스코어를 직접 찍는 참여형 AI승부예측</li>
|
||||
<li>진행 중 경기 실시간 스코어와 문자중계 제공</li>
|
||||
<li>경기 종료 후 AI와 내 적중률을 채점·공개</li>
|
||||
<li>무료 참여 — 카카오 간편 로그인 지원</li>
|
||||
</ul>
|
||||
<p>
|
||||
AI 셋이 갈렸을 때, 당신의 픽은 누구와 같을까요? 트리플픽에서 스포츠 승부예측을
|
||||
시작하세요. <a href="https://triplepick.o2o.kr/">triplepick.o2o.kr</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
50
frontend/nginx.conf
Normal file
@ -0,0 +1,50 @@
|
||||
# 공유 크롤러(카카오톡·트위터·페북 등) 판별 → /match/:id 만 백엔드 OG 프리렌더로.
|
||||
# 일반 사용자(JS 실행)는 0 → 기존 SPA 그대로.
|
||||
map $http_user_agent $is_share_crawler {
|
||||
default 0;
|
||||
# kakaotalk-scrap = 카톡 공유 미리보기 봇만. (인앱 브라우저 UA 'KAKAOTALK x.x'
|
||||
# 는 안 걸려야 사용자가 링크를 탭했을 때 SPA 로 정상 진입한다)
|
||||
"~*(kakaotalk-scrap|facebookexternalhit|facebot|twitterbot|slackbot|discordbot|telegrambot|whatsapp|line\\b|skypeuripreview|pinterest|redditbot|googlebot|bingbot|daumoa|yeti)" 1;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
# 경기 공유 링크: 크롤러면 백엔드 OG 프리렌더, 사람이면 SPA.
|
||||
# (418 → 내부 named location 으로 우회: if + try_files 충돌 회피 정석 패턴)
|
||||
location /match/ {
|
||||
error_page 418 = @og_prerender;
|
||||
if ($is_share_crawler) { return 418; }
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
# 응원가 공유 링크(/song/:matchId/:teamCode) — 동일 패턴
|
||||
location /song/ {
|
||||
error_page 418 = @og_prerender;
|
||||
if ($is_share_crawler) { return 418; }
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
location @og_prerender {
|
||||
proxy_pass http://api:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# SPA 폴백 — 클라이언트 라우팅(/match/:id 등)
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
2485
frontend/package-lock.json
generated
Normal file
25
frontend/package.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "triplepick-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/react": "^19.0.2",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.5"
|
||||
}
|
||||
}
|
||||
505
frontend/public/assets/bi/TriplePick_BI.svg
Normal file
@ -0,0 +1,505 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 656.07 656.07">
|
||||
<!-- Generator: Adobe Illustrator 30.5.1, SVG Export Plug-In . SVG Version: 2.1.4 Build 3) -->
|
||||
<defs>
|
||||
<style>
|
||||
.st0 {
|
||||
fill: #a9adad;
|
||||
}
|
||||
|
||||
.st1 {
|
||||
fill: #252b32;
|
||||
}
|
||||
|
||||
.st2 {
|
||||
fill: #010101;
|
||||
}
|
||||
|
||||
.st3 {
|
||||
fill: #fdfdfb;
|
||||
}
|
||||
|
||||
.st4 {
|
||||
fill: #7de9a7;
|
||||
}
|
||||
|
||||
.st5 {
|
||||
fill: #548570;
|
||||
}
|
||||
|
||||
.st6 {
|
||||
fill: #7deca6;
|
||||
}
|
||||
|
||||
.st7 {
|
||||
fill: #252b30;
|
||||
}
|
||||
|
||||
.st8 {
|
||||
fill: #252b31;
|
||||
}
|
||||
|
||||
.st9 {
|
||||
fill: #22262c;
|
||||
}
|
||||
|
||||
.st10 {
|
||||
fill: #262b33;
|
||||
}
|
||||
|
||||
.st11 {
|
||||
fill: #7deea8;
|
||||
}
|
||||
|
||||
.st12 {
|
||||
fill: #517c68;
|
||||
}
|
||||
|
||||
.st13 {
|
||||
fill: #212b2f;
|
||||
}
|
||||
|
||||
.st14 {
|
||||
fill: #252c31;
|
||||
}
|
||||
|
||||
.st15 {
|
||||
fill: #7feca8;
|
||||
}
|
||||
|
||||
.st16 {
|
||||
fill: #486d5c;
|
||||
}
|
||||
|
||||
.st17 {
|
||||
fill: #84e3ad;
|
||||
}
|
||||
|
||||
.st18 {
|
||||
fill: #4e7d65;
|
||||
}
|
||||
|
||||
.st19 {
|
||||
fill: #242e30;
|
||||
}
|
||||
|
||||
.st20 {
|
||||
fill: #53836c;
|
||||
}
|
||||
|
||||
.st21 {
|
||||
fill: #64967e;
|
||||
}
|
||||
|
||||
.st22 {
|
||||
fill: #272f35;
|
||||
}
|
||||
|
||||
.st23 {
|
||||
fill: #24272e;
|
||||
}
|
||||
|
||||
.st24 {
|
||||
fill: #4d7b66;
|
||||
}
|
||||
|
||||
.st25 {
|
||||
fill: #7deba6;
|
||||
}
|
||||
|
||||
.st26 {
|
||||
fill: #010201;
|
||||
}
|
||||
|
||||
.st27 {
|
||||
fill: #23282e;
|
||||
}
|
||||
|
||||
.st28 {
|
||||
fill: #010001;
|
||||
}
|
||||
|
||||
.st29 {
|
||||
fill: #262c32;
|
||||
}
|
||||
|
||||
.st30 {
|
||||
fill: #84ebad;
|
||||
}
|
||||
|
||||
.st31 {
|
||||
fill: #fdfdfc;
|
||||
}
|
||||
|
||||
.st32 {
|
||||
fill: #7eeca6;
|
||||
}
|
||||
|
||||
.st33 {
|
||||
fill: #7deba7;
|
||||
}
|
||||
|
||||
.st34 {
|
||||
fill: #7feea9;
|
||||
}
|
||||
|
||||
.st35 {
|
||||
fill: #4f7965;
|
||||
}
|
||||
|
||||
.st36 {
|
||||
fill: #7eeea9;
|
||||
}
|
||||
|
||||
.st37 {
|
||||
fill: #7ceaa6;
|
||||
}
|
||||
|
||||
.st38 {
|
||||
fill: #83ebaa;
|
||||
}
|
||||
|
||||
.st39 {
|
||||
fill: #4c7561;
|
||||
}
|
||||
|
||||
.st40 {
|
||||
fill: #262a31;
|
||||
}
|
||||
|
||||
.st41 {
|
||||
fill: #83e7a9;
|
||||
}
|
||||
|
||||
.st42 {
|
||||
fill: #94ddb2;
|
||||
}
|
||||
|
||||
.st43 {
|
||||
fill: #000100;
|
||||
}
|
||||
|
||||
.st44 {
|
||||
fill: #7deaa7;
|
||||
}
|
||||
|
||||
.st45 {
|
||||
fill: #263232;
|
||||
}
|
||||
|
||||
.st46 {
|
||||
fill: #7ce9a5;
|
||||
}
|
||||
|
||||
.st47 {
|
||||
fill: #7ceca6;
|
||||
}
|
||||
|
||||
.st48 {
|
||||
fill: #232a2f;
|
||||
}
|
||||
|
||||
.st49 {
|
||||
fill: #7beba4;
|
||||
}
|
||||
|
||||
.st50 {
|
||||
fill: #80eca8;
|
||||
}
|
||||
|
||||
.st51 {
|
||||
fill: #fefefe;
|
||||
}
|
||||
|
||||
.st52 {
|
||||
fill: #262a30;
|
||||
}
|
||||
|
||||
.st53 {
|
||||
fill: #7ceba6;
|
||||
}
|
||||
|
||||
.st54 {
|
||||
fill: #7feda8;
|
||||
}
|
||||
|
||||
.st55 {
|
||||
fill: #75d79d;
|
||||
}
|
||||
|
||||
.st56 {
|
||||
fill: #252930;
|
||||
}
|
||||
|
||||
.st57 {
|
||||
fill: #262b31;
|
||||
}
|
||||
|
||||
.st58 {
|
||||
fill: #7ce8a8;
|
||||
}
|
||||
|
||||
.st59 {
|
||||
fill: #7bd8a1;
|
||||
}
|
||||
|
||||
.st60 {
|
||||
fill: #4f7765;
|
||||
}
|
||||
|
||||
.st61 {
|
||||
fill: #7deda8;
|
||||
}
|
||||
|
||||
.st62 {
|
||||
fill: #7df0aa;
|
||||
}
|
||||
|
||||
.st63 {
|
||||
fill: #4e7962;
|
||||
}
|
||||
|
||||
.st64 {
|
||||
fill: #7deaa5;
|
||||
}
|
||||
|
||||
.st65 {
|
||||
fill: #242b31;
|
||||
}
|
||||
|
||||
.st66 {
|
||||
fill: #233132;
|
||||
}
|
||||
|
||||
.st67 {
|
||||
fill: #262e33;
|
||||
}
|
||||
|
||||
.st68 {
|
||||
fill: #7feba8;
|
||||
}
|
||||
|
||||
.st69 {
|
||||
fill: #24292f;
|
||||
}
|
||||
|
||||
.st70 {
|
||||
fill: #4e7663;
|
||||
}
|
||||
|
||||
.st71 {
|
||||
fill: #010202;
|
||||
}
|
||||
|
||||
.st72 {
|
||||
fill: #507b67;
|
||||
}
|
||||
|
||||
.st73 {
|
||||
fill: #252a30;
|
||||
}
|
||||
|
||||
.st74 {
|
||||
fill: #283235;
|
||||
}
|
||||
|
||||
.st75 {
|
||||
fill: #272b32;
|
||||
}
|
||||
|
||||
.st76 {
|
||||
fill: #7eeca9;
|
||||
}
|
||||
|
||||
.st77 {
|
||||
fill: #4b7260;
|
||||
}
|
||||
|
||||
.st78 {
|
||||
fill: #fdfdfd;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Generative_Object">
|
||||
<g>
|
||||
<path class="st57" d="M338.61,0c39.93.15,80.37,9.62,117.48,25.08,41.71,17.68,79.44,43.94,110.56,76.83,31.14,32.91,53.43,69.6,69.27,111,11.4,31.05,18.2,63.2,19.82,94.7l-.32.25c.11,1.5.22,2.99.33,4.48.28.31.24,1.22,0,1.6.11,2.09.22,4.22.32,6.41v18.58l-.15.36c-.3,47.92-13.83,97-35.93,140.06l-15.65,27.09c-27.24,42.15-63.89,77.49-106.94,103.24-44.37,26.53-90.35,40.63-139.45,45.75-8.3.22-16.24.44-23.82.64h-11.85c-12.63-.15-25.7-.93-38.5-2.31-29.73-4.89-59.02-11.63-87.55-24.72-52.49-23.28-99.13-59.87-133.81-107.31C26.26,472.28,6.13,415.55.71,355.76c-.23-6.98-.47-13.77-.71-20.36l.07-15.03c.44-80.72,34.45-161.79,91.15-220.28C150.45,38.48,233.7.64,319.09.04l19.52-.04Z"/>
|
||||
<path class="st0" d="M656.07,320.35c-.13.02-.31-.11-.31-.33v-6.07s.31,0,.31,0v6.41Z"/>
|
||||
<circle class="st30" cx="328.1" cy="328.09" r="304.47"/>
|
||||
<path class="st0" d="M655.75,312.34c-.22-.04-.34-.27-.34-.62v-3.86c.23.03.36.27.35.61l-.02,3.87Z"/>
|
||||
<circle class="st57" cx="328.12" cy="328.13" r="300.93"/>
|
||||
<g>
|
||||
<path d="M328.29,575.49l-46.57-26.47-17.15-9.64-22.27-.05-66.53-37.55-56.66-32.03-17.89-10.09v-59.14s-2.64-.14-2.64-.14v-122.61s-14.86-.03-14.86-.03c-.34-.68-.35-1.19-.39-1.95l-.02-44.47h17.94s-.1-51.04-.1-51.04l19.06-11.03,12.05-6.92,54.96-31.6,23.92-.02,19.95-11.42,26.06-14.93,25.21-14.52,34.54-19.88,9.39-5.37c1.52-.87,2.77-.54,4.16.25l12.93,7.34,33.31,19.21,68.61,39.31h23.69s26.73,15.27,26.73,15.27l48.09,27.76,11.16,6.25-.05,82.7,14.35-.09,7.7-.17c.25-.12.64,0,.68.14.06.23-.08.63-.19,1.01l-12.42,40.68-6.37,21.19,8.96,35.99,9.9,38.99-21.38.06c-.37,0-.76-.12-1.07.03-.19.09-.13.48-.13.95v57.86c0,.58-.87,1.01-1.18,1.19l-14.2,7.98-39.23,22.19c-.35.2-.59.32-.6.75l-.11,4.31-21.13,12.25-37.51,21.71-5.24-2.75-21.6,12.4-22.69.07-46.13,26.23-17,9.84Z"/>
|
||||
<g>
|
||||
<g>
|
||||
<path class="st73" d="M410.6,349.23v13.05c-.28.99-.62,1.95-1.03,2.88l-10.6,20.6-.74.05-.1-8.16-.02-41.24,7.78-1.97c.24-.07.61-.14.68.01,1.88,4.35,3.19,10.11,4.04,14.77Z"/>
|
||||
<path class="st38" d="M410.61,362.27v9.93s-12.33,24.14-12.33,24.14l-.06-10.53,10.58-20.56c.87-1.71,1.37-3.05,1.81-2.97Z"/>
|
||||
<path class="st27" d="M327.06,261.06c.08-.17.29-.31.63-.31h7.71s8.01-3.19,8.01-3.19l7.07-3.17v11.03c-4.38-2.26-8.85-4.31-13.78-4.33l-9.64-.03Z"/>
|
||||
<path class="st16" d="M476.27,402.38l-.1.25-2.72.06c-.31,0-.53-.13-.44-.33l3.26.02Z"/>
|
||||
<g>
|
||||
<path class="st50" d="M559.79,386.91l-20.35.05-5.42-24.99-4.95-22.89c-.3-1.4-.14-2.71-.92-4.11l-.25,10.08-.09,41.87-18.8.03-.17-13.69.11-68.94.03-59.36,18.92.02-.06,45.53.17,27.04,11.63-41.38,19.68.02-5.95,19.61-8.69,28.78,8.46,34.94,6.64,27.38Z"/>
|
||||
<path class="st15" d="M473.99,368.73c1.56,1.77,4.44,1.73,5.93.06,1.04-1.17,1.3-2.64,1.41-4.18v-23.84s18.43.03,18.43.03l.03,23.08c0,9.99-4.14,20.18-13.8,23.39-4.91,1.63-10.06,1.59-15.12.7-6.64-1.17-12.32-5.39-14.98-11.58-1.99-4.62-2.67-9.35-2.66-14.46l.1-62.7c.01-6.61,2.19-15.01,6.96-19.26,3.45-3.06,7.54-4.73,12.07-5.29,13.25-1.65,23.4,3,26.49,16.42.75,4.2.92,8.26.93,12.57l-.04,16.36h-18.3s-.02-21.48-.02-21.48c0-1.65-.33-3.16-1.33-4.42-1.17-1.47-3.07-1.86-4.97-1.18-1.34.48-2.61,2.11-2.62,4.03l-.11,33.17v7.87s0,12.88,0,12.88l.04,12.67c0,1.82.22,3.64,1.54,5.14Z"/>
|
||||
<polygon class="st50" points="131.98 386.91 112.12 386.97 112.13 264.57 96.82 264.57 96.8 245 147.13 245.04 147.11 264.55 131.99 264.59 131.98 386.91"/>
|
||||
<rect class="st15" x="279.35" y="245.02" width="19.18" height="141.94"/>
|
||||
<path class="st15" d="M170.97,386.89l-19.02.07v-110.75s15.78-.01,15.78-.01l.23,6.19c2.93-6.25,11.23-9.75,17.54-6.51l-.04,19.94c-3.09-.94-6.2-1.26-9.23-.47-3.38.87-5.42,3.51-5.25,7.14l-.02,84.41Z"/>
|
||||
<rect class="st50" x="424.14" y="276.16" width="19.24" height="110.8"/>
|
||||
<rect class="st50" x="192.42" y="276.16" width="19.01" height="110.8"/>
|
||||
<rect class="st32" x="422.74" y="246.37" width="22.03" height="19.22" transform="translate(177.53 689.59) rotate(-89.97)"/>
|
||||
<rect class="st6" x="190.92" y="246.42" width="22.04" height="19.05" transform="translate(-54.09 457.79) rotate(-89.98)"/>
|
||||
<path class="st58" d="M178.43,252.15l-.02,5.4c-.11,1.14-.23,2.27-.36,3.39l-.4.14-5.26.94h-1.32c-.24-.11-.36-.28-.38-.54s2.25-4.06,2.57-4.47l.78-.07,4-8.51c.23,1.47-.29,2.68.39,3.72Z"/>
|
||||
<path class="st5" d="M410.5,341.22l.1,8-4.72-14.78,1.57-.64,1.38-.31c.98,2.46,1.53,5.03,1.67,7.73Z"/>
|
||||
<path class="st45" d="M410.5,341.22l-2.01-7.15c-.11-.38-.88-.34-1.04-.27l3.22-1.64-.17,9.06Z"/>
|
||||
<path class="st42" d="M459.27,264.55l-1.5.72c-.17,0-.3-.14-.3-.36l.06-4.56,1.53,2.99c.22.43.29.84.21,1.2Z"/>
|
||||
<path class="st29" d="M177.65,261.07c.88-1.22.06-2.73.76-3.53l-.03,3.46c-.1.16-.57.04-.73.07Z"/>
|
||||
<g>
|
||||
<path class="st54" d="M415.95,298.93c-.08,4.21-1.23,8-2.82,11.69-1.83,4.25-4.92,7.77-9.07,9.78-3.41,1.65-6.94,2.64-10.7,2.7l-8.94.13-.05,63.64-20.39.06-.02-141.94,26.53.07c4.9.01,9.44.86,13.79,2.89,4.23,2.17,7.83,5.41,9.24,9.97l1.8,5.85,1.1,12.69-.45,22.46Z"/>
|
||||
<path d="M396.19,294.35l-.52,4.53c-.32,2.77-2.13,4.71-4.89,5.14l-6.38.26v-40.16s6.12.1,6.12.1c3.72.06,5.6,3.01,5.61,6.64l.05,23.5Z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path class="st15" d="M241.18,384.65l-.08,16.43-.15,13.79-7.18.16-11.82-.09-.05-27.38-.02-111.4h15.56s.33,5.34.33,5.34c3.79-4.9,9.18-7.33,15.31-7.04,6.3.3,11.64,3.8,14.07,9.74,1.52,3.72,2.43,7.7,2.44,11.95l.1,67.71-.47,5.97c-.31,3.85-1.11,7.68-3.07,10.96-2.42,4.03-6.19,6.65-10.81,7.21-5.03.62-10.06-.21-14.17-3.34Z"/>
|
||||
<path class="st43" d="M249.04,368.69c-1.88,2.14-4.92,1.32-6.49-.67-1.31-1.66-1.64-3.53-1.64-5.77l-.02-60.75c0-2.03.08-3.66.67-5.52.79-2.47,3.69-4.07,6.19-2.96,1.88.84,2.65,2.95,2.65,5.08l.09,66.06c0,1.63-.41,3.32-1.45,4.51Z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path class="st50" d="M354.06,342.96v21.26c0,10.78-4.09,20.88-15.06,23.49-5.7,1.35-11.53,1.29-17.03-.72-10.63-3.89-13.86-14.16-13.87-25.05l-.02-61.4c0-7.52,1.77-16.39,7.59-21.08,6.55-5.28,16.54-6.21,24.42-3.87,10.9,3.23,13.95,13.5,14.01,24.3l.02,34.59-27.27.03-.04,29.67c.23,1.86.55,3.62,1.81,4.87,1.41,1,3.04,1.32,4.67.68,1.37-.54,2.33-2.08,2.45-3.91l.04-22.83h18.28Z"/>
|
||||
<path class="st28" d="M335.77,321.26l-9,.05.16-24.14c0-1.47.81-2.73,1.61-3.48,1.13-1.05,2.76-1.25,4.29-.84,1.97.53,2.92,2.69,2.97,4.75l-.03,23.65Z"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<polygon class="st61" points="551.22 182.4 551.22 262.35 542.67 262.33 542.63 187.16 475.75 148.66 451.18 134.64 467.98 134.55 480.54 141.59 498.43 151.93 551.22 182.4"/>
|
||||
<polygon class="st78" points="138.4 231.21 126.03 231.21 126.02 194.29 144.62 183.67 192.5 156.01 212.98 144.23 210.86 149.06 205.31 162.76 138.42 201.35 138.4 231.21"/>
|
||||
<path class="st78" d="M517.91,231.16l.03-29.82-60.13-34.7-6.85-3.89-6.64-16.01c-.34-.81-.93-1.32-1.01-2.55l31.92,18.4,36.32,20.98,18.59,10.83v36.76s-12.23,0-12.23,0Z"/>
|
||||
<polygon class="st36" points="113.75 187.16 113.73 231.24 105.07 231.16 105.07 182.39 162.06 149.6 188.09 134.57 205.18 134.66 187.37 144.8 139.37 172.36 113.75 187.16"/>
|
||||
<polygon class="st11" points="500.97 231.25 499.4 227.12 499.17 211.82 484.26 203.15 476.55 192.28 504.83 208.39 504.86 231.18 500.97 231.25"/>
|
||||
<path class="st33" d="M156.94,226.67c-.02,1.69-1.24,3.04-1.68,4.44l-3.85.04-.05-22.71,20.58-11.81,8.13-4.51-8.06,11-14.89,8.57-.19,14.96Z"/>
|
||||
<g>
|
||||
<path class="st75" d="M396.59,142.52c-25.86-10.62-54.41-14.96-82.29-12.72-5.89.47-11.39.92-17.23,1.99-8.75,1.6-17.17,3.64-25.63,6.35-6.35,2.03-12.14,4.42-18.19,7.12-7.91,3.53-15.22,7.42-22.5,12.11-5.39,3.47-10.44,6.84-15.45,10.84-4.92,3.93-9.69,7.67-14.13,12.11l-6.27,6.28c-8.82,8.83-19.32,23.56-25.66,34.51-2.93,5.07-5.59,9.86-7.98,15.1l-.15-4.85-1.48-.13.8-2.91.37-14.69,13.38-7.5,10.42-14.15c2.64-2.73,2.76-3.6,2.49-7.45-1.16-.13-1.86.65-2.7,1.11l-7.68,4.21-21.55,12.35-7.27,4.2-.21,24.87-5.67.04v-27.98s19.44-11.14,19.44-11.14l30.22-17.47,16.6-9.6,8.97-21.54c.8-1.92,1.42-3.77,1.24-5.85-.97-.19-1.81.12-2.63.6l-15.98,9.33-16.35,9.32-20.09,11.57-15.58,9.12-25.52,14.68v38.88s-4.89.04-4.89.04l-.05-42.06,8.09-4.54,69.47-39.97,28.92-16.89,23.38-13.52,41.25-23.73,30.29-17.41,9.32-5.36,11.89,6.76,35.55,20.47,36.84,21.14,29.82,17.29,14.93,8.79,15.45,8.87,17.72,10.18,22.36,12.84,16.44,9.5,9.7,5.43.06,41.24c0,.37.02.63-.06.81-.06.13-.32.23-.63.23l-3.9-.03c-.29,0-.61-.07-.6-.2l.05-.8-.03-38.07-11.73-6.88-7.4-4.18-17.2-10-15.26-8.82-27.03-15.5-13.09-7.75c-1.27-.75-2.55-1.58-4.12-1.47-.36,1.79.09,3.38.73,4.87l3.93,9.14,5.19,13.46,13.88,7.88,13.35,7.76,12.81,7.4,20.27,11.74,5.25,2.78c.64.34,1.08.62,1.08,1.42l-.03,15.03.06,10.83c0,.34.72,1,.06,1.35l-5.93-.03-.13-24.9-21.15-12.23-17.77-9.79c-.82,5.14.57,5.46,1.94,7.34l10.24,14.04,13.83,8.05.21,13.29.92,4.05c-.44.21-1.04.2-1.78.23l.37,3.86c.31.3-.1.76-.33.34-8.1-17.06-18.53-32.49-31.05-46.48-5.28-5.9-10.86-11.05-16.89-16.19-15.02-12.8-32.04-22.83-50.36-30.35Z"/>
|
||||
<g>
|
||||
<path class="st26" d="M337.18,101.52l-9.14-5.2-8.05,4.67-69.95,40.46-18.6,10.87-12.81,7.38c-1.36.78-2.68,1.57-4.35,1.5.11-1.88.84-3.4,1.45-5.05l4.66-11.01,5.09-12.2,11.46-6.56,19.99-11.38,30.87-17.77,18.9-10.92,20.33-11.73c.59-.34,1.43-.42,2.09-.04l36.2,20.78,32.32,18.63,33.18,18.93,2.55,6.55,5.49,12.75,3.04,8.95c-1.19.15-2.13-.17-3.01-.69l-8.91-5.22-14.72-8.6-8.89-5.23-20.71-11.94-33.89-19.6-14.59-8.35Z"/>
|
||||
<path class="st34" d="M327.17,92.69l-100.38,58.1-6.15,3.3,6.66-16.41c.69-1.7,1.34-2.42,2.91-3.31l11.81-6.71,15.14-8.61,42.03-24.23,28.91-16.69,29.08,16.71,41.43,23.88,29.33,16.87,7.53,18.26-8.49-4.59-33.63-19.51-64.36-37.1c-.55-.32-1.01-.43-1.83.04Z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path class="st2" d="M328.11,117.57l-6.48,3.56-8.68,5.04c-1.25.72-19.24,2.29-24.35,2.31-.51-.39-.59-1.96-.02-2.31l12.11-7.46,10.16-5.86,15.87-9.06c1.09-.62,1.83-.51,2.85.06l9.07,5.04,17.82,10.31c3.92,2.27,7.78,4.17,11.31,7.02.52.42.5,1.84.02,2.25-7.26.01-14.43-.97-21.55-1.76-2.01-.22-3.47-.63-5.21-1.64l-12.93-7.5Z"/>
|
||||
<path class="st62" d="M328.19,113.51l-13.9,7.93c-1.5.86-2.88,1.58-4.64,1.78l-11.2,1.28c1.41-1.5,2.93-2.02,4.5-2.92l25.11-14.51,29.8,17.28-11.89-1.29c-1.11-.23-2.12-.54-3.11-1.11l-14.67-8.44Z"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<path class="st57" d="M481.55,261.09c-7.58-1.13-15.38.13-22.27,3.45-.49-.28-.87-.79-1.14-1.55.13.89.01,1.65-.36,2.27-.14.68-.94.21-.57-.24l.1-6.21-.14-27.51-20.16-.04c-.62-.21-1.01-.82-1.16-1.82l-10.79-26.15c-.21-.79-.35-1.32.68-1.28l23.3,9.79,10.93,4.82,5.98,11.29,10.06,18.75,4.8,9.35c.63,1.22-.34,3.14-.32,4.36.32.5.75.54,1.07.71Z"/>
|
||||
<path class="st7" d="M241.48,262.52l-16.53.07-.04-12.98.17-.65,13.18-.91c.74-.14.66-.45.22-.96l-13.05,1.4-.53-.59v-16.58s-5.11-.08-5.11-.08l-.46-.49,5.95-11.29-1.15.53-5.94,10.93-.7.36-24.9-.05c.43-1,1.02-1.95,1.79-2.95l18.66-9.17,16.06-7.77,11.74,24.43,5.28,11.05-8.68,15.49,4.05.2Z"/>
|
||||
<path class="st57" d="M325.15,261.16c-4.44,1-8.75,2.04-13.04,4.15l-.05-12.87.06-21.13-24.5-.03,18.38-8.92,22.94-10.96c.93-.34,1.39-.19,1.36.43l-.57,48.04c-.33.53-.92.63-1.76.29l-15.05-6.9v1.51s11.1,5.11,11.1,5.11c1.22.16,1.59.59,1.12,1.28Z"/>
|
||||
<path class="st57" d="M375.49,231.23l-25.01.18v20.11s-.39.87-.39.87l-17.56,7.71c-.63.21-1.04-.12-1.25-.73l.49-46.89c0-.87.16-1.39,1.11-1.1l4.91,2.21,37.7,17.64Z"/>
|
||||
<path class="st57" d="M434.02,231.24l-23.46.09.16,4.74c-5.36-2.49-10.92-4.6-16.66-4.65l8.9-9.75,6.76-7.03,7.84-8.15,4.64-5.03.79.03c3.82,9.73,7.65,19.46,11.48,29.18l-.46.58Z"/>
|
||||
<path class="st57" d="M275.33,231.25l-9.47.06v5.11c-.35.05-.78.13-1.24.35l-14.69,6.8-4.99-10.58-10.54-21.93.59-.43,40.9,18.43c.8.8.95,1.3-.19,1.66.32.27.2.45-.37.53Z"/>
|
||||
<path class="st65" d="M494.83,244.62c1.27,4.27-.04,9.44.38,14.2l-.54,5.77c-.69-.04-.76-.48-1.07-.63.15.02.4.12.7-.04-2.5-4.1-7.98-7.86-9.67-11.08l-7.63-14.52-11.54-21.42c-.66-1.33-1.29-2.52-1.48-3.99l-1.34-10.55,7.86,9.81c7.8,11.02,14.06,22.68,19.3,35.11,1.76,4.17,3.56,7.97,4.65,12.27.52-2.16-.03-4.1.14-6.16l.27-3.24-.03-5.55Z"/>
|
||||
<path class="st12" d="M329.32,173.96l-.65-.27-.12-2.78c-15.59,18.9-31.12,37.83-46.6,56.78l-1.61,1.49c-1.44.69-2.82,1.42-4.6,1.56l1.38.52h-1.79c.12-.24.77-1.17.46-1.56l-39.27-17.68c-.85-.38-1.37-.62-2.12-.96-.43-.9-1.02-1.8-1.21-2.86.57-.62,1.39-.69,2.48-.19l1.78.63,39.87,18.13c16.74-20.38,33.63-40.65,50.67-60.82.25,0-.17,1.07.04-.36.36,0,1.47.19,1.49.77l.15,6.97c0,.35-.15.57-.34.64Z"/>
|
||||
<path class="st29" d="M187.37,231.25l-8.87.08-.07,20.83c-.74.2-.94-.26-.98-.96l-2.63,5.59-.78.15c-.86.76-1.02.09-1.12-.85l4.69-29.64.78-2.57,4.07-6.5,9.3-12.87-2.03,21.45-2.36,5.29Z"/>
|
||||
<path class="st63" d="M332.88,211.38c-.48.29-.46.71-.47,1.35l-.34,32.28-.15,14.66,18.56-8.15v2.87s-.26.79-.26.79l-12.57,5.36c-3.52.33-7.06.5-10.6.52-.65,0-1.31-.03-1.91.1-.24-.59-.9-.47-1.35-.68l-11.52-5.29v-2.95s16.84,7.73,16.84,7.73l.36-24.59.2-23.63c0-.38-.55-.42-.73-.36,1.37-.66,2.38-.72,3.94-.02Z"/>
|
||||
<path class="st7" d="M171.07,262.02c.7-.82,1.14-.82,1.32,0-.41.64-1.26.27-1.94.28l-8.72.09c-.31,0-.6.13-.55-.31.57-.25.4-.99.62-1.58l5.89-15.62,6.49-13.62c.8-.49,1.08,0,1.06.87l-4.43,29.13.27.77Z"/>
|
||||
<path class="st69" d="M265.86,260.72c0,1.22.17,2.36-.18,3.8-6.45-4-15.34-4.66-22.12-2.77l6.37-11.52c.23-.3.51-.48.83-.55,4.99,3.39,9.94,6.84,14.85,10.35l.25.69Z"/>
|
||||
<path class="st22" d="M265.86,257.51l-.6.37c-4.39-2.93-8.79-5.86-13.19-8.79l.03-.75c.1-.13.26-.4.53-.53l13.25-6.22-.03,15.91Z"/>
|
||||
<path class="st35" d="M425.73,202.02c-.49.69.1,1.31.4,2.03l10.31,25.15c.31.75.49,1.41.57,2.06l-2.98-.02-4.26-11.05-6.97-17.01c-.24-.6-.14-1.33-.58-1.73l.37-.5c.2-.28.76-.2,1.11-.02l2.03,1.08Z"/>
|
||||
<path class="st20" d="M252.11,248.34l13.75,9.17v3.22c-.7,0-1.18-.56-1.82-1.01l-12.42-8.72c-.57-.4-1.01-.88-1.7-.76.49-.85,1.13-1.83,2.19-1.89Z"/>
|
||||
<path class="st59" d="M219.79,231.25l-2.31.04c0-.42.15-.78.4-1.23l5.86-10.7c.97-.26,1.67-.83,2.69-.67l-6.64,12.56Z"/>
|
||||
<path class="st55" d="M224.91,249.61v-1.71s13.6-1.43,13.6-1.43c.48-.05.91.82.98,1.15.09.39-.49,1-1.14,1.05l-13.44.93Z"/>
|
||||
<path class="st57" d="M328.02,165.58c.12.13.57.34.59.53.03.24-.57.52-.7.67l-10.48,12.61-12.65,15.21-13.78,16.64-9.01,10.85-4.54,5.52-39.91-18.23c-1.53-.7-2.95-1.05-4.36-1.18-.07-.42.07-.77.33-1.31l16.07-33.18c.7-1.45,1.08-2.26,2.67-2.91l40.4-16.48c.82-.33,1.36-.56,2.23-.36l33.14,11.62Z"/>
|
||||
<path class="st57" d="M329.32,173.96l-.26,10.25-.39,21.83-32.45,15.48-15.88,7.65c-.03-.2-.03-.47.11-.65l10.73-13.3,31.6-38.15,6.44-8.02.1,4.9Z"/>
|
||||
<path class="st18" d="M174.03,256.93l-2.96,5.09c-.25,0-1.06.15-1-.27l4.4-28.96c.1-.64.32-1.2-.31-1.53l4.22-7.38c-.03,3.55-.8,6.89-1.37,10.51l-3.48,21.87c-.06.39.26.65.5.68Z"/>
|
||||
<g>
|
||||
<path class="st76" d="M494.83,244.62c.21,1.78.44,14.32-.07,14.86-.45.46-.74.37-1.05-.29-6.83-19.59-16.82-38.97-30.12-54.63.29,3.43.81,6.82,1.56,10.16,6.76,12.69,13.48,25.37,20.17,38.03,2.82,3.28,5.73,6.5,8.74,9.66.58,1.08.84,2.35-.45,1.55-2.6-1.29-5.39-2.12-8.22-2.69-1.22,0-2.41-.06-3.58-.18h-.26s-1.64-.49-1.64-.49c.27-1.46.37-2.91.3-4.33-6.82-13.08-13.73-26.13-20.71-39.13-11.24-4.86-22.49-9.74-33.73-14.64l-.04-.48-.6.41c-1-.74-1.97-1.07-2.91-.97v.91c-9.04,9.48-18.02,19.03-26.93,28.67l-1.21.38-18.58-.18-2.77-.59-39.69-18.49-.14-.77c-.47.37-1.12.42-1.95.14-.73.16-1.39.12-1.98-.12v.71s-38.68,18.6-38.68,18.6l-2.64.57h-10.51c-2.63-.22-1.13-1.29.71-1.66,1.01-.66,1.84-.8,2.51-.43.37-.55.88-1.03,1.51-1.44l46.19-22.18.63-31.14.65-.45c.16-1.38.28-3.46.08-4.84-.01-.08-.21-.13-.47-.17.29-1.72-.01-2.85-.9-3.38l-.42.53c-11.13-3.98-22.27-7.84-33.43-11.59-13.97,5.54-27.91,11.18-41.84,16.94l-1.11.73c-5.98,11.78-11.79,23.68-17.42,35.69l-.63.32c.88.29,1.94,2.15,1.21,2.86l1.29,1.21,14.53,30.41c4.98-2.3,9.97-4.56,14.99-6.79l.67.53v5.18s-.35.88-.35.88l-12.73,6.02-.68-.15c-.38.86-1.25,1.82-2.19,1.89.22.12.52.36.46.5-1.33,2.78-3.4,6.58-5.06,9.15-.37.89-.91,1.42-1.77,1.87l-2.07.77c-1.55.07-3.05-.03-4.49-.31,2.64-5.18,5.44-10.33,8.37-15.45l-16.63-34.53-33.95,16.53c-.56,1.21-1.3,2.04-2.2,2.47h-5.21s-.44-.58-.44-.58l2.19-4.88,1.78-18.94c-4.2,5.48-8.1,11.14-11.71,16.98l-.79.05c-.72,2.58-1.86,5.01-3.44,7.3l-.78.08c.11.72-.04,1.51-.43,2.35-4.58,9.1-8.48,18.55-11.58,28.23-.26.36-.6.44-.98.23l.03-16.94c6.51-15.03,14.45-29.16,24.44-42.12l8.57-10.38c5.08-6.15,10.9-11.18,16.9-16.4,4.27-3.71,8.6-6.92,13.2-10.19l5.29-3.75c21.86-14.1,46.24-23.71,72.11-27.4,8.6-1.23,16.99-1.97,25.66-2.13s16.98.96,25.53,1.88c7.82.85,15.03,2.6,22.6,4.62,10.92,2.92,21.15,7.01,31.31,11.92,7.75,3.74,14.97,7.7,22.04,12.58,8.71,6.02,16.9,12.33,24.38,19.78l3.56,3.54,10.19,11.29c8.09,9.66,14.94,19.94,20.69,31.18,2.56,4.99,5.52,9.64,7.14,15.06Z"/>
|
||||
<g>
|
||||
<path class="st57" d="M485.38,261.27l-3.25.12c-.23,0-.35-.15-.33-.3,1.22-.03,2.38-.06,3.58.18Z"/>
|
||||
<path class="st57" d="M195.18,222.41l2-24.31c.08-.94,1.24-1.98,1.85-2.58l9.81-9.72,9.31-8.02,12.41-9.32,14.76,2.92-16.33,33.84c-.34.7-1.13,1.2-1.8,1.53l-31.99,15.68Z"/>
|
||||
<path class="st57" d="M456.71,195.3l1.88,15.32-11.82-5.18-22.89-9.83-25.59-32.59-7.42-9.41c.45-1.05.92-1.91,1.86-2.41l16.41,7.62,11.19,6.43,12.76,8.73c6.02,4.12,11.21,8.88,16.52,13.87l7.1,7.44Z"/>
|
||||
<path class="st57" d="M332.59,161.98l-33.53-11.86,10.93-8.6,2.73-2.23,18.21-.52c20.35.38,36.97,3.61,56.35,10.35-.47,1.63-1.63,2.29-3.32,2.63l-51.37,10.24Z"/>
|
||||
<path class="st1" d="M248.67,166.9l-11.44-2.38c9.96-6.24,20.25-11.24,31.27-15.2,11.17-4.04,22.37-7.18,34.38-8.74l-10.63,8.53-43.57,17.8Z"/>
|
||||
<g>
|
||||
<path class="st57" d="M387.83,230.37c-.54.55-1.72.33-2.26.08l-17.04-7.93-15.71-7.37-19.19-8.97.75-39.41,20.97-4.27,31.57-6.26,16.93,21.67,15.17,19.59-11.68,12.48-5.31,5.62-5.14,5.46-9.07,9.32Z"/>
|
||||
<path class="st17" d="M379.98,208.97l-6.9-26.04-14.3,9.28-17.39,11.42c-.62.41-1.86-.49-1.88-1.07l6.63-4.45,25.19-16.87-30.22-10.7c1.32-.45,2.87-1.12,4.14-.67l12.3,4.36,15.59,5.24,5.95-10.83,3.91-7.31c.65-.21,2.21.61,1.81,1.37l-9.53,17.71,17.57,6.59,17.94,6.98c.49.29.88.54,1.04.83.27.46.34,1.39-.16,1.66l-17.47-6.62-18.63-6.89,2.51,10.51,6.68,25.61c.44,1.68,1.03,3.09,1.06,5.07-1.37.05-2.36-1.05-2.71-2.44l-3.13-12.73Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<path class="st44" d="M174.17,486.92l86.05,48.65-17.03.03-56.28-31.85-54.85-30.95-26.91-15.13v-57.14s8.7-.06,8.7-.06l-.06,51.81c1.19,1.31,2.59,2.02,4.17,2.91l29.07,16.39,27.14,15.34Z"/>
|
||||
<polygon class="st51" points="376.83 532.16 328.2 559.84 305.93 547.16 279.38 532.08 275.95 526.5 265.24 509.52 328.09 545.75 383.34 514.11 391.3 509.58 376.83 532.16"/>
|
||||
<polygon class="st31" points="184.58 464.8 197.85 486.01 183.27 477.92 155.62 462.33 126.12 445.61 126.13 400.53 138.49 400.48 138.49 438.71 157.62 449.45 184.58 464.8"/>
|
||||
<path class="st73" d="M356.83,400.48l2.13.64,21.58,10.57c1.51.33,2.05.89,1.61,1.68l-24.94,11.77-24.06,11.27c-.85.3-1.06-.24-1.09-1.05l-1.11-32.54.62-.49c6.84-.55,12.13-.92,18.81-4.53l.1,2.66h6.35Z"/>
|
||||
<path class="st73" d="M331.22,402.36c-.09.61-.58.84-1.48.69.35,10.94.67,21.9.96,32.87-.4.05-.78,0-1.15-.17l-15.17-7.3-31.26-15.2-.08-.7c6.38-3.84,12.76-7.67,19.12-11.49l2.2-.58,7.72.02.16-3.18c5.4,3.02,11.06,4.52,17.09,4.86.63.04,1.51-.54,1.89.18Z"/>
|
||||
<polygon class="st68" points="499.84 486.68 499.73 477.27 519.68 465.86 542.53 452.67 542.53 400.55 551.23 400.55 551.25 457.59 499.84 486.68"/>
|
||||
<polygon class="st3" points="499.77 463.07 499.7 449 517.78 438.54 517.8 400.47 530.22 400.45 530.21 445.5 499.77 463.07"/>
|
||||
<path class="st73" d="M382.45,427.31c-.27.78-.26,1.63-.26,2.61v18.21c0,.22-.25.37-.3.37-.66-.18-.87-.91-.65-2.2-.04-2.59.17-5.17.62-7.74l-.1-9.56-7.23,29.05-30.64-15.1-6.35-3.2,15.55-7.25,31.03-14.56-1.91,8.55c-.08.37.06.67.24.84Z"/>
|
||||
<path class="st73" d="M381.85,468.05c.74,1.24.11,2.75.11,4.22v15.62s.14,7.41.14,7.41c-.57-1.73-.09-3.52-.28-5.64-1.38.76-2.69.8-4.19.53l-17.28-4.21-26.56-6.76,40.64-17.76,7.28,10.16.13-3.58Z"/>
|
||||
<path class="st69" d="M397.23,418.61l-7.35-5.44,5.35-10.87c.32-.65.74-1.16.72-1.81l2.14-.04-.2-4.82-.05-17c0-.45.08-.77.27-.98.25.07.61.16.65.36.39,2.12.02,5.72-.55,7.79l.64.28.07,7.57,10.93-21.4.75-.05v28.24s17.6.1,17.6.1l-19.89,11.6-11.09,6.47Z"/>
|
||||
<polygon class="st73" points="499.79 472.94 499.78 467.28 515.98 457.99 533.84 447.78 533.88 400.6 538.77 400.5 538.74 450.93 503.96 470.57 499.79 472.94"/>
|
||||
<path class="st49" d="M168,434.12c1.87,1.07,4.09,1.81,5.32,3.41l8.09,10.47-30.25-16.59.1-31.24,5.59,11-.13,16.48,11.27,6.47Z"/>
|
||||
<path class="st73" d="M206.56,400.5l.45.58-9.02,21.54c-.39.78-.71,1.01-1.33.32l-13.99-22.47,23.89.02Z"/>
|
||||
<polygon class="st52" points="499.87 444.73 499.81 438.59 508.5 433.69 508.64 400.49 514.21 400.48 514.23 436.59 499.87 444.73"/>
|
||||
<path class="st73" d="M255.05,402.33c-.25.33-.4.76-.39,1.28l.15,7.48,2.85-4.68,16.9,8.38.27.8c-6.33,4.23-12.76,8.33-19.29,12.31-.27.58-.52.97-1.21.69l.13-11.48-.11-8.02-.02-6.41c0-.41.54-.29.7-.35Z"/>
|
||||
<polygon class="st46" points="413.1 535.65 396.39 535.55 423.8 519.86 432.37 524.72 413.1 535.65"/>
|
||||
<path class="st23" d="M298.95,400.48c.11.23.22.61.07.7-4.89,3.13-10.37,6.29-15.46,9.14-1.19.97-2.39,1.43-3.62,1.39l-1.91-.96c-2.68-3.01-5.17-6.19-7.48-9.53l.24-.76h28.17Z"/>
|
||||
<path class="st10" d="M390.78,400.47l-5.7,10.84c-.18.23-.41.4-.71.37-7.24-3.49-14.48-6.97-21.72-10.46l-.07-.74h28.2Z"/>
|
||||
<polygon class="st47" points="499.78 434.2 499.5 429.58 499.38 411.31 504.92 400.17 505 431.52 499.78 434.2"/>
|
||||
<polygon class="st73" points="495.56 424.91 485.94 419.36 493.27 406.44 496.08 400.59 500.16 400.54 495.74 410.24 495.56 424.91"/>
|
||||
<path class="st14" d="M208.54,428.55l-9-3.49-.85-.81c3.09-6.88,5.93-13.86,8.53-20.93-.06-1.2.17-2.15.69-2.85.28,0,.38.27.37.6l-.19,11,.06,14.34c0,.65-.15,1.87.39,2.16Z"/>
|
||||
<path class="st25" d="M478.6,415.08c-.39-.24-.57-.51-.54-.81,2.05-4,4.21-7.94,6.47-11.82l1.06-.67,6.96-2.11c-2.92,6.4-6.22,12-9.78,17.88l-4.17-2.47Z"/>
|
||||
<path class="st39" d="M331.22,402.36l.34-.03.3,8.37.83,24.72c.01.44.22.76.45.98-.69.32-1.25.63-2.15.13l-1.43-.79c.11,0,.55.06.67-.12l-1.29-33.2,2.28-.07Z"/>
|
||||
<path class="st1" d="M485.59,401.78l-6.13,11.04c-.44.8-.92,1.53-.86,2.26l-5.85-3.16c1.02-3.16,2.16-6.25,3.42-9.28-.24-.16-.19-.24.1-.25,3.2.02,6.38.29,9.32-.6Z"/>
|
||||
<path class="st24" d="M362.58,400.48l22.5,10.84c-.55,1.04-1.92,1.58-2.94,2.05-.34-.75-1.15-.75-1.88-1.1l-22.76-11.15c-.36-.18-.54-.41-.67-.64h5.76Z"/>
|
||||
<path class="st9" d="M470.43,401.95c-.18.37-.43.67-.75.91l-1.66,5.5-.74.25-13.76-8.02,3.94-.16c.07-1.11-.23-2.05.22-3.14,4.06,2.65,8.31,3.67,12.74,4.65Z"/>
|
||||
<polygon class="st35" points="304.36 400.48 296.75 405.06 283.13 413.25 279.94 411.72 298.95 400.48 304.36 400.48"/>
|
||||
<path class="st72" d="M207.91,400.45c.35,2.26-.26,4.28-1.19,6.46l-7.23,17.13c-.35.37-.2.81.05,1.01-1.04-.43-2.24-1.1-2.87-2.11.64-.07.72-.54.95-1.09l6.42-15.3,2.52-6.06,1.35-.04Z"/>
|
||||
<polygon class="st4" points="411.44 512.69 397.14 520.74 404.87 508.79 411.44 512.69"/>
|
||||
<path class="st41" d="M473.01,402.36c1.03.14,2.08.23,3.16.27.27.29.29.71.12,1.2l-2.72,7.58c-.16.45-.41.61-.82.5l-3.43-2.2c-.21-.16-.51-.4-.45-.59.22-.73,2.38-6.35,2.65-6.57.22-.18.5-.21.77-.19h.73Z"/>
|
||||
<path class="st77" d="M270.78,400.47l6.64,8.93c.33.44.6.81.6,1.36l-5.75-2.63c.25-.02.46-.04.57-.15l-5.69-7.51h3.62Z"/>
|
||||
<path class="st16" d="M470.43,401.95l1.85.41-2.97,7.35-2.03-1.1,1.69-5.63c.09-.31.21-.63.36-.76.27-.25.66-.26,1.1-.27Z"/>
|
||||
<path class="st57" d="M278.17,416.43l1.18.6,2.03,1.6,45.59,59.76c.41.48.12.87-.9,1.17l-31.06,8.63-14.13-5.41-22.16-8.18-5.77-8.75-15.37-23.03c-.19-.28-.42-.66-.45-.91s.44-.54.5-.79l.82-.93,38.28-23.93,1.44.17Z"/>
|
||||
<path class="st73" d="M327.99,473.12l-.28,2.96-.74.12c-13.95-18.59-27.97-36.96-42.08-55.11-.42-.87-.69-1.47.5-1.26l13.34,6.41,12.46,6.03,7.76,3.84,10.4,5.1-1.37,31.91Z"/>
|
||||
<g>
|
||||
<path class="st37" d="M170.48,400.49l1.01,1.07c6.58,12.06,13.62,24.43,22.58,34.61l-1.24-8.22c-5.14-8.94-10.38-17.88-15.74-26.8l.36-.65,5.23-.02,1.07.5c8.85,14.2,13.27,21.3,13.27,21.3l-.34.67c.52-.17,1.02.04,1.49.64.85.41,1.07.72,1.38,1.47.12-.22.32-.54.5-.49.75.17,7.55,2.79,7.87,3.07l.63.92,3.38.71,20.54,8.54,5.01-8.77c.6-.44,6.3-.29,6.44.05.18.43-3.21,7.73-4.37,7.91.63,1.13-.88,3.34-1.89,4.14.14.46.3.91.49,1.35l21.1,31.62c12.01,4.38,23.95,8.85,35.82,13.41l30.47-8.47.55.51c.88-.67,1.24-1.83,1.08-3.48l.56-.13.27-2.83-.63-.25,1.33-31.09c-13.89-7.08-27.94-13.98-42.15-20.69-.66-.32-1.04-.74-1.14-1.26l-.53.46c-2.06-.74-3.9-1.83-5.51-3.26-.49.82-1.38.3-1.18-.6-1.39.1-3.13-.14-3.61-1.64-.16.28-.4.43-.72.36l-15.93-7.9-2.5,4.05c-.34.16-.61.11-.81-.18-.24-.35-.19-8.05-.02-8.43.09-.2.25-.32.47-.36l1.85-.56c.86-.41,2.78-.33,3.71-.06,1.48.43,9.37,4.3,10.56,5.17.66.31,1.03.73,1.11,1.25.76-.34,1.63-.18,2.6.48,1.33.42,2.38,1.14,3.16,2.15.32-.92,2.18.06,1.91.96,1.07-.39,3.23.33,3.19,1.53l1.05-.2,45.31,21.96.07.74c.95-.25,1.07-.23,2,.35.67-.09,1.2,0,1.59.31l-.07-.67,48.03-22.58c.38-.1.76-.07,1.04.22.06-.67.54-1.15,1.44-1.45.5-.57,1-.77,1.5-.61l-.47-.49,5.22-9.93.95-.43,5.18.02c.65.17.69.56.38,1.11l-5.57,11.24c2.04,1.73,4.2,3.39,6.48,4.98v.79s-4.52,2.74-4.52,2.74l-.91.13-4.24-3.04c-.67,1.97-1.2,4.05-1.59,6.24l-.89,1.04-2.65,1.59c-.65.28-.92.02-.9-.67l1.65-7.58-44.09,20.65c11.54,5.89,23.19,11.68,34.93,17.38l7.06-28.07h.88c.09,3.22.05,6.4-.12,9.54-.93,6.3-2.33,12.52-4.2,18.67l3.94-.75c.4.7.58,1.84.25,2.6l-.26.74c-1.3.31-2.54.72-3.71,1.22l3.48,4.8.55,1.84-.05.37c.2.71.28,3.68-.31,3.84-.14.04-.31,0-.47-.06l-6.93-9.53-38.37,16.77c14.01,3.6,28,7.15,41.98,10.5,1.32.3,2.53.08,3.78-.35l.36.32c-.03,1.93.03,3.86.2,5.77.28.85,1.38.8,2.25,1.79-7.14,2.64-14.34,4.25-21.71,5.86l-12.93,2.12c-16.7,2.09-33.26,1.67-49.93-1.09s-32.03-7.27-46.91-14.28c-17.39-8.19-33.22-18.8-47.14-31.94l-7.22-7.25-7.34-8.09c-10.9-12.66-19.74-26.74-26.97-41.9l6.28-.04Z"/>
|
||||
<g>
|
||||
<path class="st48" d="M177.45,400.49l10.49,17.65,5.5,9.61,1.64,10.61-5.47-6.53c-7.48-9.83-13.73-20.37-19.12-31.34h6.97Z"/>
|
||||
<path class="st67" d="M381.88,448.5l.09,8.49c0,.61.62,1.83-.13,2.08l-.05-2.03-5.04,1.11,5.1-19.59.03,9.94Z"/>
|
||||
<path class="st74" d="M267.16,400.47l1.05.37c.44.34,4.97,6.32,5.06,6.73.18.79-.3.78-1,.56l-11.39-5.58c-1.36-.67-2.56-1.22-3.99-.79.04-.28.28-.37.65-.38,2.86-.04,5.53-.57,8-2.08.27,1.09.86,1.16,1.62,1.16Z"/>
|
||||
<polygon class="st19" points="392.72 421.34 385.09 425.72 387 417.24 392.72 421.34"/>
|
||||
<polygon class="st66" points="381.84 459.07 381.89 467.68 376.74 460.57 381.84 459.07"/>
|
||||
<path class="st73" d="M253.27,475.01l-11.09,2.88c-7.62-4.82-15.08-9.59-22.15-15.24-6.67-5.32-12.77-10.8-18.58-17.01l-3.02-15.62,11.96,4.77,13.35,5.54,8.48,3.36,4.35,6.29,16.7,25.03Z"/>
|
||||
<path class="st56" d="M353,498.79c-14.03,1.85-27.82,2.15-41.95.68l-11.11-7.49,30.34-8.43,6.48,1.76,36.41,9.05c-6.76,2.1-13.33,3.16-20.17,4.44Z"/>
|
||||
<path class="st8" d="M332.91,477.35l1.24-34.28c.02-.68.16-1.16.61-1.48.29-.21.86-.53,1.45-.23l36.41,18.37-39.7,17.63Z"/>
|
||||
<path class="st13" d="M301.03,498.4l-11.9-2.34c-7.67-1.51-14.77-3.98-22.07-6.75l-18.11-7.85,8.65-2.31,14.12,5.48,21.58,8.25c2.99,1.14,5.12,3.67,7.73,5.52Z"/>
|
||||
</g>
|
||||
</g>
|
||||
<path class="st60" d="M285.4,419.84c-.17.09-.29.3-.29.49l6.12,7.7,36.39,47.97.37-2.87-.37,6-1.54.43.62-.48-9.53-12.4-18.24-24.03-19.58-25.62,6.04,2.8Z"/>
|
||||
<path class="st70" d="M278.17,416.43c-.74,0-1.16.45-1.87.9l-36.6,22.88c-.76.47-1.32.82-2.06.92.3-1.43,1.23-2.82,1.89-4.14.04.24.19.25.3.24.24,0,.45-.22.67-.37l11.82-7.71c.6-.28,1.62.15,2.03-.57l.78-1.15,18.25-11.49c.55-.35.99-.6,1.19-1.16,1.18.58,2.42,1.04,3.61,1.64Z"/>
|
||||
<g>
|
||||
<path class="st40" d="M359.51,523.71l-31.41,18.02-12.26-7.09-30.07-17.3-18.19-10.61-5.94-3.1c-2.23-1.17-4.11-2.68-6.71-3.37l3.02,4.56,10.47,16.39,8.77,13.64,12.1,6.88,38.95,22.2,28.66-16.34,13.27-7.61c2.89-1.98,6.71-3.05,9.17-5.5l9.41-14.34,9.73-15.04c1.12.45,1.95,1.03,3.09,1.86l-14.87,23.68,20.21-11.23,8.19-4.61,4.97,3.06-15.28,8.69-13.73,7.73-8.1,4.58c.45.16.92.18,1.13.55l-23.83,13.46-32.14,18.45-36.4-20.84-19.51-11c-.3-.13-.21-.66.4-.9l-16.39-9.35-14.53-8.25-52.15-29.43-21.95-12.39-37.66-21.23-12.42-6.95-.03-49.53c0-.35-.07-.66.03-.81.08-.12.34-.19.64-.18l4.17.06.04,47.19,22.2,12.56,49.63,27.82,12.71,7.23c.33.19.75-.06.7-.35l-10.43-16.88-8.95-14.4-1.46-1.8-18.8-10.67-25.86-14.65v-36s5.53-.06,5.53-.06v33.06s20.78,11.43,20.78,11.43l15.94,8.78,11.07,5.92c.39.19.69-.05.47-.42l-9.71-11.52-10.57-13.57-15.01-8.37-.41-15.05-3.99-9.53c-.05-.13-.41-.35-.22-.63.08-.13.31-.17.61-.17l3.52.08,7.38,14.36c4.11,7.01,7.97,13.91,13.11,20.19l7.79,9.52,10.57,11.56c9.37,9.63,19.66,17.84,31.01,25.01l12.97,7.58c11.18,6.53,34.21,14.97,46.99,17.08l17.58,2.91c19.82,2.41,39.38,1.45,58.88-2.92,7.61-1.7,14.88-3.53,22.22-6.45l5.63,3.38c.2.12.56.1.6.42.05.49-.28.42-.65.63l-14.86,8.54-19.86,11.39Z"/>
|
||||
<g>
|
||||
<path class="st71" d="M267.45,526.66l2.46,3.79c-1.21.2-1.62-.51-2.43-.97l-29.45-16.61-23.17-12.95-17.04-27.54-3.98-6.21c1.16-.13,2.05.65,3.13,1.24l14.86,8.23,15.8,9.18,20.5,11.43,9.01,14.15,10.3,16.25Z"/>
|
||||
<polygon class="st53" points="259.25 520.73 217.28 497.21 208.45 482.56 204.61 475.71 245.91 499.1 259.25 520.73"/>
|
||||
</g>
|
||||
<g>
|
||||
<path class="st26" d="M375.38,508.4l-13.76,7.99-32.14,18.39c-1.07.61-1.76.47-2.74-.09l-8.96-5.13-14.31-8.14-21.71-12.83,30.06,3.74c2.43.3,4.18.91,6.22,2.24,3.27,2.13,6.66,4.21,10.21,5.73l13.3-7.31,9.23-1.11,24.6-3.47Z"/>
|
||||
<path class="st37" d="M329.33,523.91l13.36-7.63,14.83-1.66-29.3,16.7-29.4-16.7,15.06,1.72,13.52,7.75c.57.33,1.3.23,1.93-.18Z"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<path class="st65" d="M254.34,428.58c.52.79-.07,1.18-1.77,1.15l-12.05,7.89c-.83.28-1.36.38-.99-.64l3.93-8-5.21-.05-5.49,9.68-24.22-10.08,14.83.21,19.14-.1,6.72.09,5.11-.17Z"/>
|
||||
<path class="st21" d="M230.59,431.19c-.21.73-2.52.59-3.5-2.2l2.87.14c.47.02.91,1.1.63,2.06Z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path class="st73" d="M441.03,525.42l-16.95-9.59-14.62-8.49-23-13.46c-.55-.32-.65-1.05-.65-1.74l.09-62.53,10.1-6.02,18.85-10.97,20.78-12.15,10.86.04,8.92,5.53,31.59,18.3,8.97,5.29-.09,63.96-27.97,16.3-26.9,15.54Z"/>
|
||||
<path class="st64" d="M442.92,518c-1.14.66-2.36.94-3.42.34l-17.31-9.89-12.43-7.14-16.92-10c-1.29-.76-1.98-2.55-1.98-4.14l-.05-51.14c0-2.31.95-3.84,2.83-4.95l16.73-9.85,28.36-16.41c1.55-.89,2.91-.94,4.47-.03l21.22,12.39,22.97,13.52c1.25.73,3.05,1.9,3.35,3.44l.02,53.69c0,1.87-1.02,3.16-2.49,4.01l-21.65,12.55-6.4,3.6-17.3,10.01Z"/>
|
||||
<g>
|
||||
<polygon class="st73" points="484.8 486.62 440.83 512.3 396.89 486.58 396.99 436.13 418.04 423.79 440.86 410.51 472.7 429.05 484.7 436.31 484.8 486.62"/>
|
||||
<rect class="st51" x="443.63" y="456.47" width="39.08" height="9.18" transform="translate(2.01 924.13) rotate(-89.99)"/>
|
||||
<g>
|
||||
<polygon class="st51" points="441.2 472.33 422.6 472.26 418.99 480.57 409.67 480.56 423.51 448.88 426.63 441.59 436.71 441.6 441.03 450.86 454.45 480.55 444.76 480.55 441.2 472.33"/>
|
||||
<polygon class="st57" points="438.23 464.97 425.53 465.01 431.63 450.2 438.23 464.97"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 35 KiB |
BIN
frontend/public/assets/bi/og-image-square.png
Normal file
|
After Width: | Height: | Size: 168 KiB |
BIN
frontend/public/assets/bi/og-image.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
1
frontend/public/assets/flags/alg.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="600"><path fill="#fff" d="M0 0h900v600H0z"/><path fill="#063" d="M0 0h450v600H0z"/><path fill="#d21034" d="M579.904 225a150 150 0 1 0 0 150 120 120 0 1 1 0-150m5.772 75L450 255.916l83.853 115.413V228.671L450 344.084z"/></svg>
|
||||
|
After Width: | Height: | Size: 285 B |
1
frontend/public/assets/flags/arg.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg width="800" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.0" height="500"><path fill="#74acdf" d="M0 0h800v500H0z"/><path fill="#fff" d="M0 166.67h800v166.67H0z"/><g id="c"><path id="a" stroke-width="1.112" stroke="#85340a" fill="#f6b40e" d="m396.84 251.31 28.454 61.992s.49 1.185 1.28.859c.79-.327.299-1.512.299-1.512l-23.715-63.956m-.68 24.12c-.347 9.428 5.452 14.613 4.694 23.032-.757 8.42 3.867 13.18 4.94 16.454 1.073 3.274-1.16 5.232-.198 5.698.963.466 3.07-2.12 2.383-6.775-.687-4.655-4.22-6.037-3.39-16.32.83-10.283-4.206-12.678-2.98-22.058"/><use xlink:href="#a" transform="rotate(22.5 400 250)"/><use xlink:href="#a" transform="rotate(45 400 250)"/><use xlink:href="#a" transform="rotate(67.5 400 250)"/><path id="b" fill="#85340a" d="M404.31 274.41c.453 9.054 5.587 13.063 4.579 21.314 2.213-6.525-3.124-11.583-2.82-21.22m-7.649-23.757 19.487 42.577-16.329-43.887"/><use xlink:href="#b" transform="rotate(22.5 400 250)"/><use xlink:href="#b" transform="rotate(45 400 250)"/><use xlink:href="#b" transform="rotate(67.5 400 250)"/></g><use xlink:href="#c" transform="rotate(90 400 250)"/><use xlink:href="#c" transform="rotate(180 400 250)"/><use xlink:href="#c" transform="rotate(270 400 250)"/><circle r="27.778" stroke="#85340a" cy="250" cx="400" stroke-width="1.5" fill="#f6b40e"/><path id="h" fill="#843511" d="M409.47 244.06c-1.897 0-3.713.822-4.781 2.531 2.136 1.923 6.856 2.132 10.062-.219a7.333 7.333 0 0 0-5.281-2.312zm-.031.438c1.846-.034 3.571.814 3.812 1.656-2.136 2.35-5.55 2.146-7.687.437.935-1.495 2.439-2.067 3.875-2.094z"/><use xlink:href="#d" transform="matrix(-1 0 0 1 800.25 0)"/><use xlink:href="#e" transform="matrix(-1 0 0 1 800.25 0)"/><use xlink:href="#f" transform="translate(18.862)"/><use xlink:href="#g" transform="matrix(-1 0 0 1 800.25 0)"/><path d="M395.75 253.84c-.913.167-1.563.977-1.563 1.906 0 1.062.878 1.906 1.938 1.906a1.89 1.89 0 0 0 1.563-.812c.739.556 1.764.615 2.312.625.084.002.193 0 .25 0 .548-.01 1.573-.069 2.313-.625.36.516.935.812 1.562.812 1.06 0 1.938-.844 1.938-1.906 0-.929-.65-1.74-1.563-1.906.513.18.844.676.844 1.219a1.28 1.28 0 0 1-1.281 1.281c-.68 0-1.242-.54-1.282-1.219-.208.417-1.034 1.655-2.656 1.719-1.622-.064-2.447-1.302-2.656-1.719-.04.679-.6 1.219-1.281 1.219a1.28 1.28 0 0 1-1.281-1.281c0-.542.33-1.038.843-1.219zM397.84 259.53c-2.138 0-2.983 1.937-4.906 3.219 1.068-.427 1.91-1.27 3.406-2.125 1.496-.855 2.772.187 3.625.187h.031c.853 0 2.13-1.041 3.625-.187 1.497.856 2.369 1.698 3.438 2.125-1.924-1.282-2.8-3.219-4.938-3.219-.426 0-1.271.23-2.125.656h-.031c-.853-.426-1.698-.656-2.125-.656z" fill="#85340a"/><path d="M397.12 262.06c-.844.037-1.96.207-3.563.688 3.848-.855 4.697.437 6.407.437h.03c1.71 0 2.56-1.292 6.407-.438-4.274-1.282-5.124-.437-6.406-.437h-.031c-.802 0-1.437-.312-2.844-.25z" fill="#85340a"/><path d="M393.75 262.72c-.248.003-.519.005-.813.031 4.488.428 2.331 3 7.032 3h.03c4.702 0 2.575-2.572 7.063-3-4.7-.426-3.214 2.344-7.062 2.344h-.031c-3.608 0-2.496-2.421-6.22-2.375zM403.85 269.66a3.848 3.848 0 0 0-3.846-3.846 3.848 3.848 0 0 0-3.847 3.846 3.955 3.955 0 0 1 3.847-3.04 3.952 3.952 0 0 1 3.846 3.04z" fill="#85340a"/><path id="e" fill="#85340a" d="M382.73 244.02c4.915-4.273 11.11-4.915 14.53-1.709.837 1.121 1.373 2.32 1.593 3.57.43 2.433-.33 5.062-2.236 7.756.215-.001.643.212.856.427 1.697-3.244 2.297-6.577 1.74-9.746a13.815 13.815 0 0 0-.67-2.436c-4.7-3.845-11.11-4.272-15.81 2.138z"/><path id="d" fill="#85340a" d="M390.42 242.74c2.777 0 3.419.642 4.7 1.71 1.284 1.068 1.924.854 2.137 1.068.213.215 0 .854-.426.64s-1.284-.64-2.564-1.708c-1.283-1.07-2.563-1.069-3.846-1.069-3.846 0-5.983 3.205-6.41 2.991-.426-.214 2.137-3.632 6.41-3.632z"/><use xlink:href="#h" transform="translate(-19.181)"/><circle id="f" cy="246.15" cx="390.54" r="1.923" fill="#85340a"/><path id="g" fill="#85340a" d="M385.29 247.44c3.633 2.778 7.265 2.564 9.402 1.282 2.136-1.282 2.136-1.709 1.71-1.709-.427 0-.853.427-2.564 1.281-1.71.856-4.273.856-8.546-.854z"/></svg>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
1
frontend/public/assets/flags/aus.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1280" height="640" viewBox="0 0 10080 5040"><defs><clipPath id="a"><path d="M0 0h6v3H0z"/></clipPath><clipPath id="b"><path d="M0 0v1.5h6V3zm6 0H3v3H0z"/></clipPath><path id="c" d="m0-360 69.421 215.845 212.038-80.301L155.99-35.603l194.985 115.71-225.881 19.651 31.105 224.59L0 160l-156.198 164.349 31.105-224.59-225.881-19.651 194.986-115.711-125.471-188.853 212.038 80.301z"/><path id="d" d="M0-210 54.86-75.508l144.862 10.614L88.765 28.842l34.67 141.052L0 93.334l-123.435 76.56 34.67-141.052-110.957-93.736L-54.86-75.508z"/></defs><path fill="#012169" d="M0 0h10080v5040H0z"/><path d="m0 0 6 3m0-3L0 3" stroke="#fff" stroke-width=".6" clip-path="url(#a)" transform="scale(840)"/><path d="m0 0 6 3m0-3L0 3" stroke="#e4002b" stroke-width=".4" clip-path="url(#b)" transform="scale(840)"/><path d="M2520 0v2520M0 1260h5040" stroke="#fff" stroke-width="840"/><path d="M2520 0v2520M0 1260h5040" stroke="#e4002b" stroke-width="504"/><g fill="#fff"><use xlink:href="#c" transform="matrix(2.1 0 0 2.1 2520 3780)"/><use xlink:href="#c" x="7560" y="4200"/><use xlink:href="#c" x="6300" y="2205"/><use xlink:href="#c" x="7560" y="840"/><use xlink:href="#c" x="8680" y="1869"/><use xlink:href="#d" x="8064" y="2730"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
frontend/public/assets/flags/aut.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="600"><path fill="#c8102e" d="M0 0h900v600H0z"/><path fill="#fff" d="M0 200h900v200H0z"/></svg>
|
||||
|
After Width: | Height: | Size: 154 B |
1
frontend/public/assets/flags/bel.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="780"><path fill="#ef3340" d="M0 0h900v780H0z"/><path fill="#fdda25" d="M0 0h600v780H0z"/><path d="M0 0h300v780H0z"/></svg>
|
||||
|
After Width: | Height: | Size: 182 B |
1
frontend/public/assets/flags/bih.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="800" height="400" viewBox="0 0 16 8"><path fill="#002395" d="M0 0h16v8H0z"/><path d="M4.24 0h8v8z" fill="#fecb00"/><g id="b"><path d="M2.353.525 2.8-.85 3.247.525l-1.17-.85h1.446z" fill="#fff" id="a"/><use xlink:href="#a" x="1" y="1"/><use xlink:href="#a" x="2" y="2"/></g><use xlink:href="#b" x="3" y="3"/><use xlink:href="#b" x="6" y="6"/></svg>
|
||||
|
After Width: | Height: | Size: 437 B |
1
frontend/public/assets/flags/bra.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1000" height="700" viewBox="-2100 -1470 4200 2940"><defs><path id="j" fill-rule="evenodd" d="M-31.5 0h33a30 30 0 0 0 30-30v-10a30 30 0 0 0-30-30h-33zm13-13h19a19 19 0 0 0 19-19v-6a19 19 0 0 0-19-19h-19z"/><path id="k" d="M0 0h63v-13H12v-18h40v-12H12v-14h48v-13H0z" transform="translate(-31.5)"/><path id="m" d="M-26.25 0h52.5v-12h-40.5v-16h33v-12h-33v-11H25v-12h-51.25z"/><path id="l" d="M-31.5 0h12v-48l14 48h11l14-48V0h12v-70H14L0-22l-14-48h-17.5z"/><path id="b" fill-rule="evenodd" d="M0 0a31.5 35 0 0 0 0-70A31.5 35 0 0 0 0 0m0-13a18.5 22 0 0 0 0-44 18.5 22 0 0 0 0 44"/><path id="c" fill-rule="evenodd" d="M-31.5 0h13v-26h28a22 22 0 0 0 0-44h-40zm13-39h27a9 9 0 0 0 0-18h-27z"/><path id="o" d="M-15.75-22C-15.75-15-9-11.5 1-11.5s14.74-3.25 14.75-7.75c0-14.25-46.75-5.25-46.5-30.25C-30.5-71-6-70 3-70s26 4 25.75 21.25H13.5c0-7.5-7-10.25-15-10.25-7.75 0-13.25 1.25-13.25 8.5-.25 11.75 46.25 4 46.25 28.75C31.5-3.5 13.5 0 0 0c-11.5 0-31.55-4.5-31.5-22z"/><use xlink:href="#f" id="p" transform="scale(31.5)"/><use xlink:href="#f" id="q" transform="scale(26.25)"/><use xlink:href="#f" id="u" transform="scale(21)"/><use xlink:href="#f" id="r" transform="scale(15)"/><use xlink:href="#f" id="v" transform="scale(10.5)"/><g id="n"><clipPath id="a"><path d="M-31.5 0v-70h63V0zM0-47v12h31.5v-12z"/></clipPath><use xlink:href="#b" clip-path="url(#a)"/><path d="M5-35h26.5v10H5z"/><path d="M21.5-35h10V0h-10z"/></g><g id="i"><use xlink:href="#c"/><path d="M28 0c0-10 0-32-15-32H-6c22 0 22 22 22 32"/></g><g id="f" fill="#fff"><g id="e"><path id="d" d="M0-1v1h.5" transform="rotate(18 0 -1)"/><use xlink:href="#d" transform="scale(-1 1)"/></g><use xlink:href="#e" transform="rotate(72)"/><use xlink:href="#e" transform="rotate(-72)"/><use xlink:href="#e" transform="rotate(144)"/><use xlink:href="#e" transform="rotate(216)"/></g></defs><clipPath id="h"><circle r="735"/></clipPath><path fill="#009440" d="M-2100-1470h4200v2940h-4200z"/><path fill="#ffcb00" d="M-1743 0 0 1113 1743 0 0-1113Z"/><circle r="735" fill="#302681"/><path fill="#fff" d="M-2205 1470a1785 1785 0 0 1 3570 0h-105a1680 1680 0 1 0-3360 0z" clip-path="url(#h)"/><g fill="#009440" transform="translate(-420 1470)"><use xlink:href="#b" y="-1697.5" transform="rotate(-7)"/><use xlink:href="#i" y="-1697.5" transform="rotate(-4)"/><use xlink:href="#j" y="-1697.5" transform="rotate(-1)"/><use xlink:href="#k" y="-1697.5" transform="rotate(2)"/><use xlink:href="#l" y="-1697.5" transform="rotate(5)"/><use xlink:href="#m" y="-1697.5" transform="rotate(9.75)"/><use xlink:href="#c" y="-1697.5" transform="rotate(14.5)"/><use xlink:href="#i" y="-1697.5" transform="rotate(17.5)"/><use xlink:href="#b" y="-1697.5" transform="rotate(20.5)"/><use xlink:href="#n" y="-1697.5" transform="rotate(23.5)"/><use xlink:href="#i" y="-1697.5" transform="rotate(26.5)"/><use xlink:href="#k" y="-1697.5" transform="rotate(29.5)"/><use xlink:href="#o" y="-1697.5" transform="rotate(32.5)"/><use xlink:href="#o" y="-1697.5" transform="rotate(35.5)"/><use xlink:href="#b" y="-1697.5" transform="rotate(38.5)"/></g><use xlink:href="#p" x="-600" y="-132"/><use xlink:href="#p" x="-535" y="177"/><use xlink:href="#q" x="-625" y="243"/><use xlink:href="#r" x="-463" y="132"/><use xlink:href="#q" x="-382" y="250"/><use xlink:href="#u" x="-404" y="323"/><use xlink:href="#p" x="228" y="-228"/><use xlink:href="#p" x="515" y="258"/><use xlink:href="#u" x="617" y="265"/><use xlink:href="#q" x="545" y="323"/><use xlink:href="#q" x="368" y="477"/><use xlink:href="#u" x="367" y="551"/><use xlink:href="#u" x="441" y="419"/><use xlink:href="#q" x="500" y="382"/><use xlink:href="#u" x="365" y="405"/><use xlink:href="#q" x="-280" y="30"/><use xlink:href="#u" x="200" y="-37"/><use xlink:href="#p" y="330"/><use xlink:href="#q" x="85" y="184"/><use xlink:href="#q" y="118"/><use xlink:href="#u" x="-74" y="184"/><use xlink:href="#r" x="-37" y="235"/><use xlink:href="#q" x="220" y="495"/><use xlink:href="#u" x="283" y="430"/><use xlink:href="#u" x="162" y="412"/><use xlink:href="#p" x="-295" y="390"/><use xlink:href="#v" y="575"/></svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
1
frontend/public/assets/flags/can.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="600" viewBox="0 0 9600 4800"><path fill="red" d="M0 0h2400l99 99h4602l99-99h2400v4800H7200l-99-99H2499l-99 99H0z"/><path fill="#fff" d="M2400 0h4800v4800H2400zm2490 4430-45-863a95 95 0 0 1 111-98l859 151-116-320a65 65 0 0 1 20-73l941-762-212-99a65 65 0 0 1-34-79l186-572-542 115a65 65 0 0 1-73-38l-105-247-423 454a65 65 0 0 1-111-57l204-1052-327 189a65 65 0 0 1-91-27l-332-652-332 652a65 65 0 0 1-91 27l-327-189 204 1052a65 65 0 0 1-111 57l-423-454-105 247a65 65 0 0 1-73 38l-542-115 186 572a65 65 0 0 1-34 79l-212 99 941 762a65 65 0 0 1 20 73l-116 320 859-151a95 95 0 0 1 111 98l-45 863z"/></svg>
|
||||
|
After Width: | Height: | Size: 658 B |
1
frontend/public/assets/flags/civ.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="600"><path fill="#009e60" d="M0 0h900v600H0z"/><path fill="#fff" d="M0 0h600v600H0z"/><path fill="#f77f00" d="M0 0h300v600H0z"/></svg>
|
||||
|
After Width: | Height: | Size: 194 B |
1
frontend/public/assets/flags/cod.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="600"><path style="fill:#007fff" d="M0 0h800v600H0z"/><path d="M36 120h84l26-84 26 84h84l-68 52 26 84-68-52-68 52 26-84-68-52zM750 0 0 450v150h50l750-450V0h-50" style="fill:#f7d618"/><path d="M800 0 0 480v120l800-480V0" style="fill:#ce1021"/></svg>
|
||||
|
After Width: | Height: | Size: 307 B |
1
frontend/public/assets/flags/col.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="600"><path fill="#ffcd00" d="M0 0h900v600H0z"/><path fill="#003087" d="M0 300h900v300H0z"/><path fill="#c8102e" d="M0 450h900v150H0z"/></svg>
|
||||
|
After Width: | Height: | Size: 201 B |
1
frontend/public/assets/flags/cpv.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1020" height="600"><path fill="#003893" d="M0 0h1020v600H0z"/><path fill="#fff" d="M0 300h1020v150H0z"/><path fill="#cf2027" d="M0 350h1020v50H0z"/><path fill="#f7d116" d="m382.5 198.715 5.933 18.119 19.066.043-15.4 11.242 5.852 18.148-15.452-11.173-15.45 11.173 5.851-18.148-15.4-11.242 19.066-.043zm-88.168 28.646 5.933 18.121 19.066.043-15.4 11.242 5.852 18.147-15.452-11.172-15.45 11.172 5.851-18.147-15.4-11.242 19.066-.043zm176.336 0 5.933 18.121 19.066.043-15.4 11.242 5.852 18.147-15.452-11.172-15.45 11.172 5.851-18.147-15.4-11.242 19.066-.043zm-230.826 73.863 5.933 18.12 19.067.043-15.4 11.242 5.85 18.148-15.45-11.174-15.452 11.174 5.852-18.148-15.4-11.242 19.066-.043zm285.316 0 5.934 18.12 19.066.043-15.4 11.242 5.851 18.148-15.451-11.174-15.451 11.174 5.851-18.148-15.4-11.242 19.066-.043zm-285.316 100 5.933 18.119 19.067.044-15.4 11.242 5.85 18.148-15.45-11.173-15.452 11.173 5.852-18.148-15.4-11.242 19.066-.043zm285.316 0 5.934 18.119 19.066.044-15.4 11.242 5.851 18.148-15.451-11.173-15.451 11.173 5.851-18.148-15.4-11.242 19.066-.043zm-230.826 68.842 5.933 18.121 19.066.044-15.4 11.242 5.852 18.146-15.452-11.172-15.45 11.172 5.851-18.146-15.4-11.242 19.066-.044zm176.336 0 5.933 18.121 19.066.044-15.4 11.242 5.852 18.146-15.452-11.172-15.45 11.172 5.851-18.146-15.4-11.242 19.066-.044zM382.5 498.715l5.933 18.119 19.066.043-15.4 11.242 5.852 18.149-15.452-11.174-15.45 11.174 5.851-18.149-15.4-11.242 19.066-.043z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
1
frontend/public/assets/flags/cro.svg
Normal file
|
After Width: | Height: | Size: 80 KiB |
1
frontend/public/assets/flags/cuw.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="600" viewBox="0 0 54 36"><path fill="#002b7f" d="M0 0h54v36H0"/><path fill="#f9e814" d="M0 22.5h54V27H0"/><path fill="#fff" d="m4.2 8.4.1-.1L6 3l1.8 5.4-4.6-3.3h5.7m.7 10.1L12 8l2.4 7.2-6.2-4.4h7.6"/></svg>
|
||||
|
After Width: | Height: | Size: 266 B |
1
frontend/public/assets/flags/cze.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg" width="900" height="600"><path fill="#d7141a" d="M0 0h900v600H0z"/><path fill="#fff" d="M0 0h900v300H0z"/><path d="M450 300 0 0v600z" fill="#11457e"/></svg>
|
||||
|
After Width: | Height: | Size: 210 B |
1
frontend/public/assets/flags/ecu.svg
Normal file
|
After Width: | Height: | Size: 212 KiB |
1
frontend/public/assets/flags/egy.svg
Normal file
|
After Width: | Height: | Size: 18 KiB |
1
frontend/public/assets/flags/eng.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="480"><path fill="#FFF" d="M0 0h800v480H0"/><path stroke="#C8102E" stroke-width="96" d="M0 240h800M400 0v480"/></svg>
|
||||
|
After Width: | Height: | Size: 176 B |
1
frontend/public/assets/flags/esp.svg
Normal file
|
After Width: | Height: | Size: 149 KiB |
1
frontend/public/assets/flags/fra.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="600"><path fill="#CE1126" d="M0 0h900v600H0"/><path fill="#fff" d="M0 0h600v600H0"/><path fill="#002654" d="M0 0h300v600H0"/></svg>
|
||||
|
After Width: | Height: | Size: 191 B |
1
frontend/public/assets/flags/ger.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="600" viewBox="0 0 5 3"><path d="M0 0h5v3H0z"/><path fill="#D00" d="M0 1h5v2H0z"/><path fill="#FFCE00" d="M0 2h5v1H0z"/></svg>
|
||||
|
After Width: | Height: | Size: 186 B |
1
frontend/public/assets/flags/gha.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="600"><path fill="#006b3f" d="M0 0h900v600H0"/><path fill="#fcd116" d="M0 0h900v400H0"/><path fill="#ce1126" d="M0 0h900v200H0"/><path d="m450 200 64.98 200-170.13-123.61h210.3L385.02 400"/></svg>
|
||||
|
After Width: | Height: | Size: 255 B |