mmday-firebase/src/VOTE_FLOW.md
윤정민 9daba193e4 Add draw prediction support to vote pipeline
- selectedTeamCode에 무승부 예약 코드 'DRAW' 허용 (sideOf가 draw 진영 반환)
- RTDB /votes/{gameId}/counts에 drawCount 추가, 투표 요약 응답에 포함
- 채점 규칙 변경: 무승부 경기는 'DRAW' 투표만 적중, 팀 투표는 오답 (기존: 전원 적중 처리)
- 무승부 경기(completed + winningTeamCode 없음)도 onGameCompleted 트리거에서 즉시 채점 (기존: dailyArchive 리컨실까지 지연)
- 어드민 수동 종료(POST /admin/game/end)에 winningTeamCode: 'DRAW' 지원
- VOTE_FLOW.md 갱신, voteRepository 무승부 투표/카운트 재분배 테스트 추가
2026-07-10 16:51:56 +09:00

7.0 KiB

투표 파이프라인 (Vote Flow)

경기 일정 수집 → 유저 투표 → 경기 종료 감지 → 결과 자동 판정 → 하루치 아카이빙 → 통계 응답의 전 과정.

개관

 [KBO API]
     │ 02:00 KST  kboDailyRefresh (scheduled)
     ▼
 Firestore: games/{gameId}           ← status, winningTeamCode upsert
     │  update
     ▼
 onGameCompleted (Firestore trigger)
     │   ├─ status → "completed": 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

selectedTeamCode는 홈/원정 팀 코드 또는 무승부 예약 코드 DRAW(DRAW_TEAM_CODE).

submitVote (src/repositories/voteRepository.ts)가 RTDB 원자 업데이트:

Path Value
/votes/{gameId}/counts/{homeCount|awayCount|drawCount} 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가 없는 completed는 무승부로 즉시 처리한다.

processGameEndWithGame(gameId, after) (src/services/gameResultService.ts):

  1. getAllUserVotes(gameId) — RTDB /votes/{gameId}/users 조회
  2. 각 유저별 /userVotes/{uid}/{date}/{gameId}team + result 기록
    • 승부가 난 경기: result = team === winningTeamCode (무승부 투표는 오답)
    • 무승부(winningTeamCode 없음): result = team === "DRAW" (무승부 투표만 적중)
  3. deleteGameVotes(gameId)/votes/{gameId} 제거 (집계 데이터는 이후 불필요)
  4. 전 유저 invalidateStats

4-b. cancelled 전이 → 무효 처리

before.status !== "cancelled" && after.status === "cancelled"일 때:

  • 전 유저 /userVotes/{uid}/{date}/{gameId}{ team, cancelled: true }로 무효 마킹 (참여 흔적 보존 — 판정·승률 집계에서는 제외)
  • /votes/{gameId} 제거
  • 투표했던 유저들 invalidateStats

5. 하루치 아카이빙 — 매일 03:00 KST

dailyArchive · src/scheduled/dailyArchive.ts:14

kboDailyRefresh(02:00) + 트리거 처리 마진 후 실행.

어제 날짜(daysAgoKst(1))의 /userVotes 스캔:

  1. 각 유저의 모든 경기 투표가 result 또는 cancelled 보유인지 확인 (allJudged)
  2. 리컨실리에이션: 미판정 경기가 있으면 getGame으로 Firestore 조회 후 분기
    • completedprocessGameEndWithGame 즉석 호출(트리거 누락 자가치유, 무승부 동일 규칙)
    • cancelled → 해당 vote 항목 무효(cancelled: true) 마킹
    • scheduled/live → warn 로그 + 유저 스킵 (실데이터 이슈)
  3. 모두 정리된 유저만 voteHistorysetDay(uid, date, { data }) 저장 — 무효표는 result 없이 cancelled: true로 포함되고 판정·승률 집계에서 제외
  4. RTDB /userVotes/{uid}/{date} 제거 + invalidateStats

6. 통계 응답

stats 핸들러 → statsService가 캐시 미스면 **voteHistory + 오늘 /userVotes**를 합산해 계산, 캐시 저장.

스코어보드 불변식

리스트(top10·totalCount)는 사전계산 스냅샷(RTDB /scoreboardCache/{날짜}), 내 순위(me)는 요청 시점 라이브 카운트다. 둘이 항상 일치하는 근거는 **"tierPoints는 새벽 판정에서만 변하고, 변경 직후 precomputeScoreboardCache가 반드시 실행된다"**는 불변식뿐이다. 백필·어드민 보정 등 파이프라인 밖에서 tierPoints를 변경했다면 반드시 GET /debug/dailyArchive(또는 precomputeScoreboardCache 직접 호출)로 재계산할 것. 탈퇴(비활성화)도 이 불변식에 포함되어 deleteMe가 재계산을 호출한다.

타이밍 요약

시각(KST) 작업
실시간 유저 투표 POST /prediction
경기 종료 직후~다음날 KBO 스코어 확정 시점에 따라 변동
02:00 kboDailyRefresh → games 문서 업데이트 → onGameCompleted 트리거 연쇄
03:00 dailyArchive (리컨실리 + voteHistory 이전)

수동 운영 경로

  • 특정 경기 즉시 종료 처리: POST /admin/game/end { gameId, winningTeamCode } (adminHandlers.ts) — 관리자 인증 필요. Firestore update를 통해 자동 트리거가 돈다. 무승부는 winningTeamCode: "DRAW"로 호출(문서에는 winningTeamCode 없이 completed 기록).
  • 스케줄 강제 실행:
    • 로컬: 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면 유저 전체 스킵.