Add Backend Readiness Pack (firestore rules/indexes, env, seed, firebase init, BACKEND.md)
- firestore.rules, firestore.indexes.json, firebase.json, .firebaserc.example - .env.example, lib/firebase.ts (client init), scripts/seed.mjs - docs/BACKEND.md: R1 scope, API contracts, scoring, Resend, deploy, test checklist - Decisions: new Firebase project (self), prize rules deferred to R2, email via Resend Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>main
parent
5ba653cfdf
commit
1b9dbd2276
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"projects": {
|
||||
"default": "REPLACE_WITH_NEW_FIREBASE_PROJECT_ID"
|
||||
}
|
||||
}
|
||||
|
|
@ -39,3 +39,7 @@ yarn-error.log*
|
|||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# secrets
|
||||
serviceAccount.json
|
||||
.firebaserc
|
||||
|
|
|
|||
|
|
@ -0,0 +1,121 @@
|
|||
# TriplePick — Backend Readiness Pack
|
||||
|
||||
작성일: 2026-06-08 KST
|
||||
짝 문서: `../../Worldcup 2026/000. Plan/Landing 기능정의서 — TriplePick 2026.md` (데이터모델 §3 / 채점 §5 원본)
|
||||
이 문서 = 백엔드 즉시 착수용 종합. 실파일(rules·indexes·env·seed)은 레포 루트에 동봉.
|
||||
|
||||
---
|
||||
|
||||
## 0. 확정 결정 (2026-06-08)
|
||||
- **Firebase = 사용자가 새 프로젝트 직접 생성** → config 6키·서비스계정 추후 제공
|
||||
- **상금 챌린지(약관) = R2로 연기** → R1 백엔드는 픽 저장·채점·Crowd·이메일 알림까지만. `event_participation` 로직 보류
|
||||
- **결과 알림 = 이메일(Resend)**
|
||||
|
||||
## 1. R1 백엔드 범위 (이번에 만들 것)
|
||||
1. 픽 제출 저장 (검증·중복방지·마감) — 콜러블 함수 `submitPrediction`
|
||||
2. 군중 분포 원자적 증분 — `crowd_stats` 트랜잭션
|
||||
3. 경기 결과 입력(관리자) → **자동 채점** + **결과 이메일 발송**
|
||||
4. 결과 알림 구독 `subscribeResult`
|
||||
5. 공개 읽기(경기·AI예측·Crowd)는 클라이언트 Firestore 직접 read
|
||||
- **R2 이후**: 리더보드, event_participation(상금), 웹푸시, 영어판
|
||||
|
||||
## 2. 아키텍처
|
||||
```
|
||||
Next.js (Vercel) ──read──> Firestore (공개: matches/ai_predictions/crowd_stats)
|
||||
│
|
||||
└─ callable ──> Cloud Functions (asia-northeast3)
|
||||
├ submitPrediction (검증+crowd 증분+user_predictions 쓰기)
|
||||
├ updatePrediction (마감 전 1회 수정)
|
||||
├ subscribeResult (notify_subscriptions)
|
||||
├ admin HTTP: upsertMatch / upsertAIPrediction / setResult (Bearer ADMIN_API_TOKEN)
|
||||
└ onResultSet 트리거 → scoreAll + Resend 발송
|
||||
```
|
||||
- 클라이언트는 `user_predictions`에 **직접 쓰지 않음** (PII·crowd 원자성 때문). 모두 함수 경유.
|
||||
- 본인 픽 재방문 표시는 **localStorage 캐시**로 처리(서버 read 불필요).
|
||||
|
||||
## 3. API 계약
|
||||
|
||||
### 3.1 submitPrediction (callable)
|
||||
요청:
|
||||
```json
|
||||
{ "matchId":"A_KOR_CZE_20260612","deviceId":"uuid","outcome":"TEAM_A_WIN",
|
||||
"scoreA":2,"scoreB":1,"nickname":"민준","email":"opt@x.com","notify":true,
|
||||
"source":"youtube_shorts","utm":{"source":"youtube","campaign":"kor_cze_ai_prediction"} }
|
||||
```
|
||||
응답:
|
||||
```json
|
||||
{ "ok":true,"predictionId":"A_KOR_CZE_20260612_uuid","matchedModels":["GPT"],
|
||||
"exactMatch":false,"crowd":{"total":1285,"teamAWin":616,"draw":347,"teamBWin":322} }
|
||||
```
|
||||
검증/에러: `MATCH_NOT_FOUND` · `MATCH_LOCKED`(now ≥ lockAt) · `INVALID_OUTCOME` · `INVALID_SCORE`(0–9 int) · `NICKNAME_INVALID`(2–16자·금칙어) · `ALREADY_SUBMITTED`(→ updatePrediction 안내)
|
||||
처리: 트랜잭션으로 ① docId=`matchId_deviceId` 없으면 create ② crowd_stats 증분 ③ matchedModels 계산.
|
||||
|
||||
### 3.2 updatePrediction (callable)
|
||||
- 마감 전 1회 수정. 이전 outcome→새 outcome 변경 시 crowd 분포 보정.
|
||||
|
||||
### 3.3 subscribeResult (callable)
|
||||
- `{matchId, deviceId, email}` → `notify_subscriptions/{matchId_deviceId}` upsert(`notified:false`).
|
||||
|
||||
### 3.4 관리자 (HTTP, `Authorization: Bearer <ADMIN_API_TOKEN>`)
|
||||
- `POST /admin/upsertMatch` — 경기 등록/수정
|
||||
- `POST /admin/upsertAIPrediction` — 모델 예측 입력/수정
|
||||
- `POST /admin/setResult` — `{matchId, scoreA, scoreB}` → matches.result 기록 + status=finished (→ onResultSet 트리거)
|
||||
|
||||
### 3.5 onResultSet (Firestore 트리거)
|
||||
- matches.result 세팅 시: 해당 경기 user_predictions 전수 채점(§아래) → `points`/`scoredAt` 기록 → notify_subscriptions 조회 → Resend로 "AI 이겼나" 결과 메일 발송 → `notified:true`.
|
||||
|
||||
## 4. 채점 (기능정의서 §5)
|
||||
```
|
||||
pts = 0
|
||||
if outcome == result.outcome: pts += 3
|
||||
if scoreA==result.scoreA && scoreB==result.scoreB: pts += 5
|
||||
if (scoreA-scoreB) == (result.scoreA-result.scoreB): pts += 1
|
||||
```
|
||||
동점자: 정확스코어 적중수 → 총 참여수(R2) → 최초 제출시각.
|
||||
|
||||
## 5. 데이터 모델
|
||||
- 원본: 기능정의서 §3 + `lib/types.ts`. 시드: `scripts/seed.mjs` (matches 6 + ai_predictions + crowd_stats 0 초기화).
|
||||
- 신규 R1 필드: user_predictions에 `points:number|null`, `scoredAt:ts|null`. notify_subscriptions `{matchId,deviceId,email,notified,createdAt}`.
|
||||
|
||||
## 6. 보안 (firestore.rules)
|
||||
- 공개 read / 클라 write 차단: matches·ai_predictions·crowd_stats
|
||||
- 전면 차단(함수 전용): user_predictions·notify_subscriptions·event_participation
|
||||
- 어뷰징: **App Check 권장**(콜러블에 enforce), 함수 내 deviceId rate-limit, nickname 금칙어.
|
||||
|
||||
## 7. 알림 (Resend)
|
||||
- 키 `RESEND_API_KEY`, 발신 `RESEND_FROM`. onResultSet에서 구독자에 발송.
|
||||
- 메일 내용: 경기 결과 + 내 픽 적중 여부 + AI 3모델 적중 여부 + 랜딩 재방문 CTA(다음 경기).
|
||||
|
||||
## 8. 환경 변수
|
||||
`.env.example` 참조. 클라 6키(NEXT_PUBLIC_*) + 서버(RESEND_API_KEY, RESEND_FROM, ADMIN_API_TOKEN) + 시드(GOOGLE_APPLICATION_CREDENTIALS).
|
||||
|
||||
## 9. 배포 절차
|
||||
```
|
||||
firebase login
|
||||
cp .firebaserc.example .firebaserc # projectId 기입
|
||||
firebase deploy --only firestore:rules,firestore:indexes
|
||||
GOOGLE_APPLICATION_CREDENTIALS=./serviceAccount.json node scripts/seed.mjs
|
||||
cd functions && npm i && firebase deploy --only functions
|
||||
# 프론트는 Vercel 자동 배포 (env에 NEXT_PUBLIC_* 추가)
|
||||
```
|
||||
프론트 전환: `lib/mockData.ts` 읽기 → `lib/firebase.ts`+Firestore read, `Arena.confirmSubmit` mock → `submitPrediction` 콜러블.
|
||||
|
||||
## 10. 테스트 체크리스트
|
||||
- [ ] 마감(lockAt) 전/후 제출 — 후엔 423/MATCH_LOCKED
|
||||
- [ ] 동일 deviceId 재제출 → update 경로, crowd 중복증가 없음
|
||||
- [ ] crowd 증분 동시성(트랜잭션) — 병렬 제출 시 합계 일치
|
||||
- [ ] 금칙어/길이 닉네임 거절
|
||||
- [ ] setResult → 채점 정확(3/+5/+1) + 메일 발송 + notified 갱신
|
||||
- [ ] 보안규칙: 클라가 user_predictions read/write 시도 → 거부
|
||||
- [ ] App Check 미통과 호출 차단
|
||||
- [ ] env 누락 시 명확한 에러(조용한 실패 0)
|
||||
|
||||
## 11. 아직 사용자에게 필요한 것 (착수 차단/부분차단)
|
||||
- 🔴 **새 Firebase 프로젝트 config 6키 + 서비스계정 JSON** (생성 후)
|
||||
- 🔴 **Resend API Key + 발신 도메인**
|
||||
- 🟠 **ADMIN_API_TOKEN** 값 지정(임의 강한 토큰)
|
||||
- 🟡 **상금 약관(D5)** — R2 event_participation 착수 시 필요(지금은 불필요)
|
||||
- 🟡 도메인(D1) — 미정 시 Vercel 기본 URL 사용
|
||||
|
||||
## 12. 함수 폴더(미생성, 다음 세션)
|
||||
`functions/` (TypeScript, firebase-functions v2) — onCall/onRequest/onDocumentWritten. 다음 세션에서 `firebase init functions`로 생성 후 위 계약대로 구현.
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"firestore": {
|
||||
"rules": "firestore.rules",
|
||||
"indexes": "firestore.indexes.json"
|
||||
},
|
||||
"functions": {
|
||||
"source": "functions",
|
||||
"runtime": "nodejs20",
|
||||
"region": "asia-northeast3"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
{
|
||||
"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": []
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
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 검증 규칙(킥오프 -10분 lock, outcome ∈ {TEAM_A_WIN,DRAW,TEAM_B_WIN},
|
||||
// score 0~9 정수, nickname 2~16자, docId == matchId+"_"+deviceId)을 넣어야 한다.
|
||||
// 단 crowd_stats 원자적 증분과 PII 보호 때문에 콜러블 함수 방식을 권장한다(docs/BACKEND.md).
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
// Firebase 클라이언트 초기화 (env에서 config 로드). 실제 키는 .env.local 에.
|
||||
// 사용처: lib/data.ts(Firestore 읽기), Arena.tsx 제출(콜러블 함수 호출).
|
||||
import { initializeApp, getApps, type FirebaseApp } from "firebase/app";
|
||||
import { getFirestore, type Firestore } from "firebase/firestore";
|
||||
import { getFunctions, type Functions } from "firebase/functions";
|
||||
|
||||
const config = {
|
||||
apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
|
||||
authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
|
||||
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
|
||||
storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
|
||||
messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
|
||||
appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
|
||||
};
|
||||
|
||||
let app: FirebaseApp | null = null;
|
||||
export function getFirebase(): { db: Firestore; fns: Functions } {
|
||||
if (!config.projectId) {
|
||||
throw new Error("Firebase env 미설정 — .env.local 에 NEXT_PUBLIC_FIREBASE_* 채우기");
|
||||
}
|
||||
app = getApps()[0] ?? initializeApp(config);
|
||||
const region = process.env.NEXT_PUBLIC_FUNCTIONS_REGION || "asia-northeast3";
|
||||
return { db: getFirestore(app), fns: getFunctions(app, region) };
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -9,6 +9,7 @@
|
|||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"firebase": "^12.14.0",
|
||||
"next": "16.2.7",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
// Firestore 시드 스크립트 — matches / ai_predictions / crowd_stats 초기화
|
||||
// 실행: GOOGLE_APPLICATION_CREDENTIALS=./serviceAccount.json node scripts/seed.mjs
|
||||
// ⚠️ AI 예측은 플레이스홀더. 발행 전 실제 GPT/Claude/Gemini 출력으로 교체.
|
||||
// (경기 데이터는 lib/schedule.ts 와 동일하게 유지할 것 — 여기서는 JS로 인라인)
|
||||
import { initializeApp, cert, applicationDefault } from "firebase-admin/app";
|
||||
import { getFirestore, FieldValue } from "firebase-admin/firestore";
|
||||
|
||||
initializeApp({ credential: applicationDefault() });
|
||||
const db = getFirestore();
|
||||
|
||||
const lock = (iso) => new Date(new Date(iso).getTime() - 10 * 60000).toISOString();
|
||||
const T = {
|
||||
KOR: { name: "Korea Republic", shortName: "한국", code: "KOR", flag: "🇰🇷" },
|
||||
CZE: { name: "Czechia", shortName: "체코", code: "CZE", flag: "🇨🇿" },
|
||||
MEX: { name: "Mexico", shortName: "멕시코", code: "MEX", flag: "🇲🇽" },
|
||||
RSA: { name: "South Africa", shortName: "남아공", code: "RSA", flag: "🇿🇦" },
|
||||
};
|
||||
|
||||
const MATCHES = [
|
||||
["A_MEX_RSA_20260612", "Match 01 · 개막전", T.MEX, T.RSA, "2026-06-12T04:00:00+09:00", "Estadio Azteca · Mexico City", "월드컵 개막전, AI는 개최국을 믿을까"],
|
||||
["A_KOR_CZE_20260612", "Match 01", T.KOR, T.CZE, "2026-06-12T11:00:00+09:00", "Estadio Guadalajara (Akron)", "한국 첫 경기, AI의 선택은 갈렸다"],
|
||||
["A_CZE_RSA_20260619", "Match 02", T.CZE, T.RSA, "2026-06-19T01:00:00+09:00", "USA (TBD)", "체코 vs 남아공, AI의 예측은"],
|
||||
["A_MEX_KOR_20260619", "Match 02", T.MEX, T.KOR, "2026-06-19T10:00:00+09:00", "Estadio Guadalajara (Akron)", "개최국 멕시코 vs 한국, AI는 누구 편"],
|
||||
["A_CZE_MEX_20260625", "Match 03", T.CZE, T.MEX, "2026-06-25T10:00:00+09:00", "Estadio Azteca · Mexico City", "체코 vs 멕시코, 운명의 최종전"],
|
||||
["A_RSA_KOR_20260625", "Match 03", T.RSA, T.KOR, "2026-06-25T10:00:00+09:00", "Estadio BBVA · Monterrey", "한국 16강의 갈림길, AI의 마지막 예측"],
|
||||
];
|
||||
|
||||
// 한국 vs 체코 AI 예측 (플레이스홀더)
|
||||
const AI = {
|
||||
A_KOR_CZE_20260612: [
|
||||
{ model: "GPT", outcome: "TEAM_A_WIN", scoreA: 2, scoreB: 1, confidencePct: 62, reasonShort: "한국의 측면 공격 우위와 세트피스 강점", generatedAt: "2026-06-08" },
|
||||
{ model: "Claude", outcome: "DRAW", scoreA: 1, scoreB: 1, confidencePct: 41, reasonShort: "중원 밸런스가 팽팽한 접전 예상", generatedAt: "2026-06-08" },
|
||||
{ model: "Gemini", outcome: "TEAM_B_WIN", scoreA: 0, scoreB: 2, confidencePct: 28, reasonShort: "체코의 전환 속도와 결정력 우세", generatedAt: "2026-06-08" },
|
||||
],
|
||||
};
|
||||
|
||||
async function run() {
|
||||
const batch = db.batch();
|
||||
for (const [matchId, round, a, b, kickoff, venue, hook] of MATCHES) {
|
||||
batch.set(db.collection("matches").doc(matchId), {
|
||||
matchId, roundLabel: round, group: "A", teamA: a, teamB: b,
|
||||
kickoffKst: kickoff, venue, lockAt: lock(kickoff), status: "upcoming",
|
||||
hookText: hook, result: null, updatedAt: FieldValue.serverTimestamp(),
|
||||
});
|
||||
// crowd_stats 초기화 (0)
|
||||
batch.set(db.collection("crowd_stats").doc(matchId), {
|
||||
matchId, total: 0, teamAWin: 0, draw: 0, teamBWin: 0, updatedAt: FieldValue.serverTimestamp(),
|
||||
}, { merge: true });
|
||||
}
|
||||
for (const [matchId, preds] of Object.entries(AI)) {
|
||||
for (const p of preds) {
|
||||
batch.set(db.collection("ai_predictions").doc(`${matchId}_${p.model}`), { matchId, ...p });
|
||||
}
|
||||
}
|
||||
await batch.commit();
|
||||
console.log(`seeded: ${MATCHES.length} matches, ${Object.values(AI).flat().length} ai_predictions, crowd_stats init`);
|
||||
}
|
||||
run().catch((e) => { console.error(e); process.exit(1); });
|
||||
Loading…
Reference in New Issue