o2o-triple-pick/docs/BACKEND.md

126 lines
7.1 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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`(09 int) · `NICKNAME_INVALID`(216자·금칙어) · `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` 생성 계약대로 구현.