mmday-firebase/src/VOTE_FLOW.md
윤정민 c3b62c5527 Automate game result judging with triggers and self-healing archive.
- Firestore 트리거(`onGameCompleted`)를 도입하여 경기 종료(`completed`) 및 취소(`cancelled`) 시 투표 결과 판정과 데이터 정리를 자동화했습니다.
- `dailyArchive` 과정에 자가치유(self-healing) 로직인 `reconcileDayVotes`를 추가하여, 트리거 누락이나 지연된 상태 변경 건을 아카이빙 시점에 보정합니다.
- `markGameEnded`가 직접 로직을 수행하는 대신 Firestore 문서만 업데이트하도록 변경하여, 결과 처리 흐름을 트리거로 일원화했습니다.
- 경기 일정 수집부터 통계 산출까지의 전체 투표 파이프라인과 운영 방법을 상세히 기술한 `VOTE_FLOW.md` 문서를 작성했습니다.
2026-04-15 10:37:06 +09:00

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)가 Firestore games/{gameId}merge: true로 upsert
    • 완료 경기는 스코어로 winningTeamCode 계산해 동시 기록
  • 월말 3일 이내면 다음 달도 함께 sync
  • rank/schedule 캐시(kboCache) invalidate

2. 유저 투표 — 실시간

클라이언트 → POST /predictioncreatePrediction · 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 + winningTeamCodeprocessGameEndWithGame 즉석 호출(트리거 누락 자가치유)
    • cancelled → 해당 vote 항목 삭제
    • scheduled/live → warn 로그 + 유저 스킵 (실데이터 이슈)
  3. 모두 정리된 유저만 voteHistorysetDay(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:shelldailyArchive() 또는 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면 유저 전체 스킵.