- selectedTeamCode에 무승부 예약 코드 'DRAW' 허용 (sideOf가 draw 진영 반환)
- RTDB /votes/{gameId}/counts에 drawCount 추가, 투표 요약 응답에 포함
- 채점 규칙 변경: 무승부 경기는 'DRAW' 투표만 적중, 팀 투표는 오답 (기존: 전원 적중 처리)
- 무승부 경기(completed + winningTeamCode 없음)도 onGameCompleted 트리거에서 즉시 채점 (기존: dailyArchive 리컨실까지 지연)
- 어드민 수동 종료(POST /admin/game/end)에 winningTeamCode: 'DRAW' 지원
- VOTE_FLOW.md 갱신, voteRepository 무승부 투표/카운트 재분배 테스트 추가
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)가 Firestoregames/{gameId}에merge: true로 upsert- 완료 경기는 스코어로
winningTeamCode계산해 동시 기록
- 완료 경기는 스코어로
- 월말 3일 이내면 다음 달도 함께 sync
- rank/schedule 캐시(
kboCache) invalidate
2. 유저 투표 — 실시간
클라이언트 → POST /prediction → createPrediction · 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):
getAllUserVotes(gameId)— RTDB/votes/{gameId}/users조회- 각 유저별
/userVotes/{uid}/{date}/{gameId}에team+result기록- 승부가 난 경기:
result = team === winningTeamCode(무승부 투표는 오답) - 무승부(
winningTeamCode없음):result = team === "DRAW"(무승부 투표만 적중)
- 승부가 난 경기:
deleteGameVotes(gameId)—/votes/{gameId}제거 (집계 데이터는 이후 불필요)- 전 유저
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 스캔:
- 각 유저의 모든 경기 투표가
result또는cancelled보유인지 확인 (allJudged) - 리컨실리에이션: 미판정 경기가 있으면
getGame으로 Firestore 조회 후 분기completed→processGameEndWithGame즉석 호출(트리거 누락 자가치유, 무승부 동일 규칙)cancelled→ 해당 vote 항목 무효(cancelled: true) 마킹scheduled/live→ warn 로그 + 유저 스킵 (실데이터 이슈)
- 모두 정리된 유저만
voteHistory에setDay(uid, date, { data })저장 — 무효표는result없이cancelled: true로 포함되고 판정·승률 집계에서 제외 - 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: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면 유저 전체 스킵.