diff --git a/src/VOTE_FLOW.md b/src/VOTE_FLOW.md new file mode 100644 index 0000000..104e3a9 --- /dev/null +++ b/src/VOTE_FLOW.md @@ -0,0 +1,124 @@ +# 투표 파이프라인 (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`면 유저 전체 스킵. diff --git a/src/index.ts b/src/index.ts index 05dad63..96bfb79 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,3 +10,4 @@ export { stats } from "./handlers/statsHandlers"; export { admin } from "./handlers/adminHandlers"; export { kboDailyRefresh } from "./scheduled/kboRefresh"; export { dailyArchive } from "./scheduled/dailyArchive"; +export { onGameCompleted } from "./triggers/onGameCompleted"; diff --git a/src/repositories/voteRepository.ts b/src/repositories/voteRepository.ts index 9d92d35..8b52c72 100644 --- a/src/repositories/voteRepository.ts +++ b/src/repositories/voteRepository.ts @@ -121,3 +121,15 @@ export async function getUserDateVotes( export async function deleteGameVotes(gameId: string): Promise { await rtdb.ref(`/votes/${gameId}`).remove(); } + +/** + * 유저별 일자 인덱스에서 특정 경기 항목을 제거한다. + * 취소/리컨실리 경로에서 아카이브 대상에서 제외하기 위해 사용. + */ +export async function deleteUserVoteGame( + uid: string, + date: DateString, + gameId: string +): Promise { + await rtdb.ref(`/userVotes/${uid}/${date}/${gameId}`).remove(); +} diff --git a/src/scheduled/dailyArchive.ts b/src/scheduled/dailyArchive.ts index d6ab449..a742af4 100644 --- a/src/scheduled/dailyArchive.ts +++ b/src/scheduled/dailyArchive.ts @@ -3,16 +3,70 @@ import { logger } from "firebase-functions"; import { rtdb } from "../firebase.js"; import { setDay } from "../repositories/voteHistoryRepository.js"; import { invalidateStats } from "../services/statsService.js"; +import { getGame } from "../repositories/gameRepository.js"; +import { deleteUserVoteGame } from "../repositories/voteRepository.js"; +import { processGameEndWithGame } from "../services/gameResultService.js"; import type { VoteHistoryDoc } from "../types/panit.js"; -import { daysAgoKst } from "../types/dateString.js"; +import { daysAgoKst, type DateString } from "../types/dateString.js"; interface RawVote { team: string; result?: boolean; } +type DayVotes = Record; + +/** + * 미판정 경기들을 games 문서로 조회해 자가치유한다. + * - completed + winningTeamCode: 즉석 processGameEndWithGame 호출 후 result 재주입. + * - cancelled: 유저 인덱스에서 제거. + * - 그 외: warn 로그 후 그대로 둔다(진짜 미판정). + * + * 반환: 갱신된 dayVotes (result 채워졌거나 cancelled 제거된 상태). + */ +async function reconcileDayVotes( + uid: string, + date: DateString, + dayVotes: DayVotes +): Promise { + const result: DayVotes = { ...dayVotes }; + for (const [gameId, vote] of Object.entries(dayVotes)) { + if (vote.result !== undefined) continue; + + const game = await getGame(gameId); + if (!game) { + logger.warn(`reconcile: game not found ${gameId}, uid=${uid}`); + continue; + } + + // 트리거가 실패/유실되었거나 배포 전 상태 전이가 일어난 경우의 자가치유 경로. + if (game.status === "completed" && game.winningTeamCode) { + try { + await processGameEndWithGame(gameId, game); + result[gameId] = { + team: vote.team, + result: vote.team === game.winningTeamCode, + }; + logger.info(`reconcile: judged ${gameId} on the fly (uid=${uid})`); + } catch (err) { + logger.error(`reconcile: processGameEnd failed ${gameId}`, err); + } + // 취소 경기 뒤늦게 감지: 아카이브 대상에서 제외해 allJudged 통과를 허용. + } else if (game.status === "cancelled") { + await deleteUserVoteGame(uid, date, gameId); + delete result[gameId]; + logger.info(`reconcile: dropped cancelled ${gameId} (uid=${uid})`); + } else { + logger.warn( + `reconcile: ${gameId} still ${game.status} (uid=${uid}, date=${date})` + ); + } + } + return result; +} + export const dailyArchive = onSchedule( - { schedule: "0 2 * * *", timeZone: "Asia/Seoul", region: "asia-northeast3" }, + { schedule: "0 3 * * *", timeZone: "Asia/Seoul", region: "asia-northeast3" }, async () => { const date = daysAgoKst(1); logger.info(`dailyArchive start: ${date}`); @@ -22,13 +76,20 @@ export const dailyArchive = onSchedule( logger.info("no userVotes to archive"); return; } - const byUid = snap.val() as Record>>; + const byUid = snap.val() as Record>; let archived = 0; for (const uid of Object.keys(byUid)) { - const dayVotes = byUid[uid]?.[date]; + let dayVotes = byUid[uid]?.[date]; if (!dayVotes) continue; + const hasUnjudged = Object.values(dayVotes).some( + (v) => v.result === undefined + ); + if (hasUnjudged) { + dayVotes = await reconcileDayVotes(uid, date, dayVotes); + } + const data: VoteHistoryDoc["data"] = []; let allJudged = true; for (const [gameId, vote] of Object.entries(dayVotes)) { @@ -43,6 +104,12 @@ export const dailyArchive = onSchedule( continue; } + // 리컨실 결과 모든 경기가 cancelled로 제거된 경우: voteHistory에 빈 문서를 남기지 않고 인덱스만 정리. + if (data.length === 0) { + await rtdb.ref(`/userVotes/${uid}/${date}`).remove(); + continue; + } + await setDay(uid, date, { data }); await rtdb.ref(`/userVotes/${uid}/${date}`).remove(); await invalidateStats(uid).catch(() => undefined); @@ -51,4 +118,4 @@ export const dailyArchive = onSchedule( logger.info(`dailyArchive done: ${archived} users archived for ${date}`); } -); +); \ No newline at end of file diff --git a/src/services/gameResultService.ts b/src/services/gameResultService.ts index d81671b..6ab2f47 100644 --- a/src/services/gameResultService.ts +++ b/src/services/gameResultService.ts @@ -6,10 +6,18 @@ import { getGame } from "../repositories/gameRepository.js"; import { getAllUserVotes, deleteGameVotes } from "../repositories/voteRepository.js"; import { invalidateStats } from "./statsService.js"; import { fromTimestamp } from "../types/dateString.js"; +import type { Game } from "../types/panit.js"; export async function processGameEnd(gameId: string): Promise<{ processed: number }> { const game = await getGame(gameId); if (!game) throw new HttpError(404, `game not found: ${gameId}`); + return processGameEndWithGame(gameId, game); +} + +export async function processGameEndWithGame( + gameId: string, + game: Game +): Promise<{ processed: number }> { if (!game.winningTeamCode) throw new HttpError(400, "winningTeamCode not set"); if (game.status !== "completed") throw new HttpError(409, `game status must be completed, got ${game.status}`); @@ -39,7 +47,7 @@ export async function processGameEnd(gameId: string): Promise<{ processed: numbe export async function markGameEnded( gameId: string, winningTeamCode: string -): Promise<{ processed: number }> { +): Promise<{ ok: true; gameId: string }> { if (!gameId || !winningTeamCode) { throw new HttpError(400, "gameId and winningTeamCode required"); } @@ -51,5 +59,5 @@ export async function markGameEnded( winningTeamCode, endedAt: FieldValue.serverTimestamp(), }); - return processGameEnd(gameId); + return { ok: true, gameId }; } diff --git a/src/triggers/onGameCompleted.ts b/src/triggers/onGameCompleted.ts new file mode 100644 index 0000000..f949efb --- /dev/null +++ b/src/triggers/onGameCompleted.ts @@ -0,0 +1,68 @@ +import {onDocumentUpdated} from "firebase-functions/firestore"; +import {logger} from "firebase-functions"; +import {rtdb} from "../firebase.js"; +import {processGameEndWithGame} from "../services/gameResultService.js"; +import { + getAllUserVotes, + deleteGameVotes, +} from "../repositories/voteRepository.js"; +import {invalidateStats} from "../services/statsService.js"; +import {fromTimestamp} from "../types/dateString.js"; +import type {Game} from "../types/panit.js"; + +/** + * `games/{gameId}` 문서 업데이트를 받아 유저 투표를 정리하는 Firestore 트리거. + * + * 두 전이 경로를 커버한다: + * - status → `completed` (+ winningTeamCode): `processGameEndWithGame`로 result 기록. + * - status → `cancelled`: 해당 경기의 유저 인덱스/카운트를 제거하여 아카이브 대상에서 제외. + * + * 전이 가드(이전 상태와 비교)로 동일 문서 재업데이트 시 중복 실행을 방지한다. + */ +export const onGameCompleted = onDocumentUpdated( + {document: "games/{gameId}", region: "asia-northeast3"}, + async (event) => { + const before = event.data?.before.data() as Game | undefined; + const after = event.data?.after.data() as Game | undefined; + if (!after) return; + + const gameId = event.params.gameId; + + // 게임이 completed로 전이되었을때 (어떤 게임이 끝났음으로 처리되었을 때) + const becameCompleted = before?.status !== "completed" && after.status === "completed"; + if (becameCompleted && after.winningTeamCode) { + try { + const {processed} = await processGameEndWithGame(gameId, after); + logger.info(`onGameCompleted: ${gameId} processed ${processed} votes`); + } catch (err) { + logger.error(`onGameCompleted failed for ${gameId}`, err); + } + return; + } + + // 게임이 cancelled로 전이되었을 때 (어떤 게임이 취소되었음으로 처리되었을 때) + const becameCancelled = before?.status !== "cancelled" && after.status === "cancelled"; + if (becameCancelled) { + try { + const date = fromTimestamp(after.time); + const votes = await getAllUserVotes(gameId); + const uids = Object.keys(votes); + + if (uids.length > 0) { + const updates: Record = {}; + for (const uid of uids) { + updates[`/userVotes/${uid}/${date}/${gameId}`] = null; + } + await rtdb.ref().update(updates); + } + await deleteGameVotes(gameId); + await Promise.all( + uids.map((uid) => invalidateStats(uid).catch(() => undefined)) + ); + logger.info(`onGameCancelled: ${gameId} cleared ${uids.length} votes`); + } catch (err) { + logger.error(`onGameCancelled failed for ${gameId}`, err); + } + } + } +); \ No newline at end of file