- Firestore 트리거(`onGameCompleted`)를 도입하여 경기 종료(`completed`) 및 취소(`cancelled`) 시 투표 결과 판정과 데이터 정리를 자동화했습니다. - `dailyArchive` 과정에 자가치유(self-healing) 로직인 `reconcileDayVotes`를 추가하여, 트리거 누락이나 지연된 상태 변경 건을 아카이빙 시점에 보정합니다. - `markGameEnded`가 직접 로직을 수행하는 대신 Firestore 문서만 업데이트하도록 변경하여, 결과 처리 흐름을 트리거로 일원화했습니다. - 경기 일정 수집부터 통계 산출까지의 전체 투표 파이프라인과 운영 방법을 상세히 기술한 `VOTE_FLOW.md` 문서를 작성했습니다.
5.7 KiB
투표 파이프라인 (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)가 Firestoregames/{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):
getAllUserVotes(gameId)— RTDB/votes/{gameId}/users조회- 각 유저별
/userVotes/{uid}/{date}/{gameId}에team+result: team === winningTeamCode기록 deleteGameVotes(gameId)—/votes/{gameId}제거 (집계 데이터는 이후 불필요)- 전 유저
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 스캔:
- 각 유저의 모든 경기 투표가
result보유인지 확인 (allJudged) - 리컨실리에이션: 미판정 경기가 있으면
getGame으로 Firestore 조회 후 분기completed+winningTeamCode→processGameEndWithGame즉석 호출(트리거 누락 자가치유)cancelled→ 해당 vote 항목 삭제scheduled/live→ warn 로그 + 유저 스킵 (실데이터 이슈)
- 모두 정리된 유저만
voteHistory에setDay(uid, date, { data })저장 - 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-<name>-asia-northeast3 --location=asia-northeast3또는 GCP 콘솔
- 로컬:
- 재처리: 누락된 게임은 games 문서를 터치(예: status 재기록)하여 트리거 재구동 가능.
엣지케이스 / 주의사항
- 트리거 실패: Cloud Functions 자체 재시도 없이
dailyArchive의 리컨실리 경로로 자가치유한다 (최대 24h 지연). 심각한 장애 시 수동 터치로 복구. - 취소 경기: 5단계에서도 cancelled로 남아있으면 archive가 알아서 제거.
- 진짜 미판정(상태 전이 미발생): KBO 공식 일정이 늦게 업데이트되는 경우 —
dailyArchive가 스킵하고 warn 로그를 남김. 다음 refresh 사이클 이후 재시도 흐름은 없고(다음 날 archive는 더 과거 날짜를 본다) 수동 개입 필요. - 한 유저가 여러 경기에 투표: 전원 판정 완료돼야 아카이브. 1경기라도
scheduled/live면 유저 전체 스킵.