Automate game result judging with triggers and self-healing archive.

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

124
src/VOTE_FLOW.md Normal file
View File

@ -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-<name>-asia-northeast3 --location=asia-northeast3` 또는 GCP 콘솔
- **재처리**: 누락된 게임은 games 문서를 터치(예: status 재기록)하여 트리거 재구동 가능.
## 엣지케이스 / 주의사항
- **트리거 실패**: Cloud Functions 자체 재시도 없이 `dailyArchive`의 리컨실리 경로로 자가치유한다 (최대 24h 지연). 심각한 장애 시 수동 터치로 복구.
- **취소 경기**: 5단계에서도 cancelled로 남아있으면 archive가 알아서 제거.
- **진짜 미판정(상태 전이 미발생)**: KBO 공식 일정이 늦게 업데이트되는 경우 — `dailyArchive`가 스킵하고 warn 로그를 남김. 다음 refresh 사이클 이후 재시도 흐름은 없고(다음 날 archive는 더 과거 날짜를 본다) 수동 개입 필요.
- **한 유저가 여러 경기에 투표**: 전원 판정 완료돼야 아카이브. 1경기라도 `scheduled`/`live`면 유저 전체 스킵.

View File

@ -10,3 +10,4 @@ export { stats } from "./handlers/statsHandlers";
export { admin } from "./handlers/adminHandlers"; export { admin } from "./handlers/adminHandlers";
export { kboDailyRefresh } from "./scheduled/kboRefresh"; export { kboDailyRefresh } from "./scheduled/kboRefresh";
export { dailyArchive } from "./scheduled/dailyArchive"; export { dailyArchive } from "./scheduled/dailyArchive";
export { onGameCompleted } from "./triggers/onGameCompleted";

View File

@ -121,3 +121,15 @@ export async function getUserDateVotes(
export async function deleteGameVotes(gameId: string): Promise<void> { export async function deleteGameVotes(gameId: string): Promise<void> {
await rtdb.ref(`/votes/${gameId}`).remove(); await rtdb.ref(`/votes/${gameId}`).remove();
} }
/**
* .
* / .
*/
export async function deleteUserVoteGame(
uid: string,
date: DateString,
gameId: string
): Promise<void> {
await rtdb.ref(`/userVotes/${uid}/${date}/${gameId}`).remove();
}

View File

@ -3,16 +3,70 @@ import { logger } from "firebase-functions";
import { rtdb } from "../firebase.js"; import { rtdb } from "../firebase.js";
import { setDay } from "../repositories/voteHistoryRepository.js"; import { setDay } from "../repositories/voteHistoryRepository.js";
import { invalidateStats } from "../services/statsService.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 type { VoteHistoryDoc } from "../types/panit.js";
import { daysAgoKst } from "../types/dateString.js"; import { daysAgoKst, type DateString } from "../types/dateString.js";
interface RawVote { interface RawVote {
team: string; team: string;
result?: boolean; result?: boolean;
} }
type DayVotes = Record<string, RawVote>;
/**
* games .
* - completed + winningTeamCode: 즉석 processGameEndWithGame result .
* - cancelled: 유저 .
* - : warn ( ).
*
* 반환: 갱신된 dayVotes (result cancelled ).
*/
async function reconcileDayVotes(
uid: string,
date: DateString,
dayVotes: DayVotes
): Promise<DayVotes> {
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( export const dailyArchive = onSchedule(
{ schedule: "0 2 * * *", timeZone: "Asia/Seoul", region: "asia-northeast3" }, { schedule: "0 3 * * *", timeZone: "Asia/Seoul", region: "asia-northeast3" },
async () => { async () => {
const date = daysAgoKst(1); const date = daysAgoKst(1);
logger.info(`dailyArchive start: ${date}`); logger.info(`dailyArchive start: ${date}`);
@ -22,13 +76,20 @@ export const dailyArchive = onSchedule(
logger.info("no userVotes to archive"); logger.info("no userVotes to archive");
return; return;
} }
const byUid = snap.val() as Record<string, Record<string, Record<string, RawVote>>>; const byUid = snap.val() as Record<string, Record<string, DayVotes>>;
let archived = 0; let archived = 0;
for (const uid of Object.keys(byUid)) { for (const uid of Object.keys(byUid)) {
const dayVotes = byUid[uid]?.[date]; let dayVotes = byUid[uid]?.[date];
if (!dayVotes) continue; 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"] = []; const data: VoteHistoryDoc["data"] = [];
let allJudged = true; let allJudged = true;
for (const [gameId, vote] of Object.entries(dayVotes)) { for (const [gameId, vote] of Object.entries(dayVotes)) {
@ -43,6 +104,12 @@ export const dailyArchive = onSchedule(
continue; continue;
} }
// 리컨실 결과 모든 경기가 cancelled로 제거된 경우: voteHistory에 빈 문서를 남기지 않고 인덱스만 정리.
if (data.length === 0) {
await rtdb.ref(`/userVotes/${uid}/${date}`).remove();
continue;
}
await setDay(uid, date, { data }); await setDay(uid, date, { data });
await rtdb.ref(`/userVotes/${uid}/${date}`).remove(); await rtdb.ref(`/userVotes/${uid}/${date}`).remove();
await invalidateStats(uid).catch(() => undefined); await invalidateStats(uid).catch(() => undefined);
@ -51,4 +118,4 @@ export const dailyArchive = onSchedule(
logger.info(`dailyArchive done: ${archived} users archived for ${date}`); logger.info(`dailyArchive done: ${archived} users archived for ${date}`);
} }
); );

View File

@ -6,10 +6,18 @@ import { getGame } from "../repositories/gameRepository.js";
import { getAllUserVotes, deleteGameVotes } from "../repositories/voteRepository.js"; import { getAllUserVotes, deleteGameVotes } from "../repositories/voteRepository.js";
import { invalidateStats } from "./statsService.js"; import { invalidateStats } from "./statsService.js";
import { fromTimestamp } from "../types/dateString.js"; import { fromTimestamp } from "../types/dateString.js";
import type { Game } from "../types/panit.js";
export async function processGameEnd(gameId: string): Promise<{ processed: number }> { export async function processGameEnd(gameId: string): Promise<{ processed: number }> {
const game = await getGame(gameId); const game = await getGame(gameId);
if (!game) throw new HttpError(404, `game not found: ${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.winningTeamCode) throw new HttpError(400, "winningTeamCode not set");
if (game.status !== "completed") throw new HttpError(409, `game status must be completed, got ${game.status}`); 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( export async function markGameEnded(
gameId: string, gameId: string,
winningTeamCode: string winningTeamCode: string
): Promise<{ processed: number }> { ): Promise<{ ok: true; gameId: string }> {
if (!gameId || !winningTeamCode) { if (!gameId || !winningTeamCode) {
throw new HttpError(400, "gameId and winningTeamCode required"); throw new HttpError(400, "gameId and winningTeamCode required");
} }
@ -51,5 +59,5 @@ export async function markGameEnded(
winningTeamCode, winningTeamCode,
endedAt: FieldValue.serverTimestamp(), endedAt: FieldValue.serverTimestamp(),
}); });
return processGameEnd(gameId); return { ok: true, gameId };
} }

View File

@ -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<string, null> = {};
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);
}
}
}
);