# 투표 파이프라인 (Vote Flow) 경기 일정 수집 → 유저 투표 → 경기 종료 감지 → 결과 자동 판정 → 하루치 아카이빙 → 통계 응답의 전 과정. ## 개관 ``` [KBO API] │ 02:00 KST kboDailyRefresh (scheduled) ▼ Firestore: games/{gameId} ← status, winningTeamCode upsert │ update ▼ onGameCompleted (Firestore trigger) │ ├─ status → "completed" + winningTeamCode: processGameEndWithGame │ └─ status → "cancelled": 투표/인덱스 정리 ▼ RTDB: /userVotes/{uid}/{date}/{gameId}.result 기록 + /votes/{gameId} 삭제 │ │ 03:00 KST dailyArchive (scheduled) ▼ Firestore: voteHistory/{uid}/days/{date} ← 어제치 이전 │ ▼ stats 응답: voteHistory + 오늘 /userVotes 합산 ``` ## 1. 경기 일정 수집 — 매일 02:00 KST **`kboDailyRefresh`** · `src/scheduled/kboRefresh.ts:23` - `fetchScheduleFromKbo`로 KBO 공식 일정 조회 - `syncGamesForMonth`(`src/services/gameSyncService.ts:55`)가 Firestore `games/{gameId}`에 `merge: true`로 upsert - 완료 경기는 스코어로 `winningTeamCode` 계산해 동시 기록 - 월말 3일 이내면 다음 달도 함께 sync - rank/schedule 캐시(`kboCache`) invalidate ## 2. 유저 투표 — 실시간 클라이언트 → `POST /prediction` → `createPrediction` · `src/handlers/predictionHandlers.ts:32` `submitVote` (`src/repositories/voteRepository.ts:56`)가 RTDB 원자 업데이트: | Path | Value | |---|---| | `/votes/{gameId}/counts/{homeCount\|awayCount}` | `increment(1)` | | `/votes/{gameId}/users/{uid}` | `{ team }` | | `/userVotes/{uid}/{date}/{gameId}` | `{ team }` | 변경(`PUT /prediction`)은 `changeVote`가 양쪽 카운트 조정. ## 3. 경기 종료 감지 `games/{gameId}` 문서 `status` 필드가 업데이트되는 것으로 통일. 경로는 두 가지: - **자동**: `kboDailyRefresh`가 KBO 스코어 기반으로 `status: "completed"` + `winningTeamCode` 기록 - **수동**: `POST /admin/game/end` · `markGameEnded` (`src/services/gameResultService.ts`) — `endedAt` 포함 동일 필드 업데이트 취소 경기는 `status: "cancelled"`로 기록됨. ## 4. 자동 판정 (Firestore 트리거) **`onGameCompleted`** · `src/triggers/onGameCompleted.ts` `onDocumentUpdated("games/{gameId}")`에서 `before`/`after` 비교로 분기: ### 4-a. completed 전이 → 결과 반영 가드: `before.status !== "completed" && after.status === "completed" && winningTeamCode` (멱등성). `processGameEndWithGame(gameId, after)` (`src/services/gameResultService.ts`): 1. `getAllUserVotes(gameId)` — RTDB `/votes/{gameId}/users` 조회 2. 각 유저별 `/userVotes/{uid}/{date}/{gameId}`에 `team` + `result: team === winningTeamCode` 기록 3. `deleteGameVotes(gameId)` — `/votes/{gameId}` 제거 (집계 데이터는 이후 불필요) 4. 전 유저 `invalidateStats` ### 4-b. cancelled 전이 → 정리 `before.status !== "cancelled" && after.status === "cancelled"`일 때: - 전 유저 `/userVotes/{uid}/{date}/{gameId}` 제거 - `/votes/{gameId}` 제거 - 투표했던 유저들 `invalidateStats` ## 5. 하루치 아카이빙 — 매일 03:00 KST **`dailyArchive`** · `src/scheduled/dailyArchive.ts:14` `kboDailyRefresh`(02:00) + 트리거 처리 마진 후 실행. 어제 날짜(`daysAgoKst(1)`)의 `/userVotes` 스캔: 1. 각 유저의 모든 경기 투표가 `result` 보유인지 확인 (`allJudged`) 2. **리컨실리에이션**: 미판정 경기가 있으면 `getGame`으로 Firestore 조회 후 분기 - `completed` + `winningTeamCode` → `processGameEndWithGame` 즉석 호출(트리거 누락 자가치유) - `cancelled` → 해당 vote 항목 삭제 - `scheduled`/`live` → warn 로그 + 유저 스킵 (실데이터 이슈) 3. 모두 정리된 유저만 `voteHistory` 에 `setDay(uid, date, { data })` 저장 4. RTDB `/userVotes/{uid}/{date}` 제거 + `invalidateStats` ## 6. 통계 응답 `stats` 핸들러 → `statsService`가 캐시 미스면 **`voteHistory` + 오늘 `/userVotes`**를 합산해 계산, 캐시 저장. ## 타이밍 요약 | 시각(KST) | 작업 | |---|---| | 실시간 | 유저 투표 `POST /prediction` | | 경기 종료 직후~다음날 | KBO 스코어 확정 시점에 따라 변동 | | 02:00 | `kboDailyRefresh` → games 문서 업데이트 → `onGameCompleted` 트리거 연쇄 | | 03:00 | `dailyArchive` (리컨실리 + voteHistory 이전) | ## 수동 운영 경로 - **특정 경기 즉시 종료 처리**: `POST /admin/game/end { gameId, winningTeamCode }` (`adminHandlers.ts`) — 관리자 인증 필요. Firestore update를 통해 자동 트리거가 돈다. - **스케줄 강제 실행**: - 로컬: `firebase functions:shell` → `dailyArchive()` 또는 `kboDailyRefresh()` - 배포: `gcloud scheduler jobs run firebase-schedule--asia-northeast3 --location=asia-northeast3` 또는 GCP 콘솔 - **재처리**: 누락된 게임은 games 문서를 터치(예: status 재기록)하여 트리거 재구동 가능. ## 엣지케이스 / 주의사항 - **트리거 실패**: Cloud Functions 자체 재시도 없이 `dailyArchive`의 리컨실리 경로로 자가치유한다 (최대 24h 지연). 심각한 장애 시 수동 터치로 복구. - **취소 경기**: 5단계에서도 cancelled로 남아있으면 archive가 알아서 제거. - **진짜 미판정(상태 전이 미발생)**: KBO 공식 일정이 늦게 업데이트되는 경우 — `dailyArchive`가 스킵하고 warn 로그를 남김. 다음 refresh 사이클 이후 재시도 흐름은 없고(다음 날 archive는 더 과거 날짜를 본다) 수동 개입 필요. - **한 유저가 여러 경기에 투표**: 전원 판정 완료돼야 아카이브. 1경기라도 `scheduled`/`live`면 유저 전체 스킵.