126 lines
7.1 KiB
Markdown
126 lines
7.1 KiB
Markdown
# 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)**
|
||
- **투표 창 = 오픈 킥오프 D-2(−48h) · 마감 킥오프 정각(경기 시작 전까지)**
|
||
- 채점·상금 대상 픽은 경기 시작 전에 닫는다(블라인드 예측·AI vs 유저 동일 출발선·사행성 회피). 하프타임 등 경기 중 투표는 채택 안 함.
|
||
- 필드: `matches.opensAt`(=킥오프−48h), `matches.lockAt`(=킥오프). 상태: scheduled→open→locked→live→finished.
|
||
- 프론트 데모용 `DEMO_FORCE_OPEN`(mockData) — 운영 배포 시 false.
|
||
|
||
## 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_NOT_OPEN`(now < opensAt) · `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`로 생성 후 위 계약대로 구현.
|