mmday-firebase/src/services/gameResultService.ts
윤정민 ee3badb112 Skip per-game stats invalidation fan-out in archive reconcile (W3)
During dailyArchive, reconcileDayVotes may call processGameEndWithGame
per unjudged game, each invalidating every voter's stats cache. Since
dailyArchive already invalidates each archived user once at the end of
its per-user loop, the per-game fan-out is redundant. Add an opt-in
skipInvalidate flag and use it from reconcile. The live onGameCompleted
trigger path keeps invalidating as before. Stale data is impossible:
getStats also self-invalidates on the daily forDate rollover.
2026-05-28 17:24:29 +09:00

70 lines
2.6 KiB
TypeScript

import { FieldValue } from "firebase-admin/firestore";
import { logger } from "firebase-functions";
import { firestore, rtdb } from "../firebase";
import { HttpError } from "../middleware/errors";
import { getGame } from "../repositories/gameRepository";
import { getAllUserVotes, deleteGameVotes } from "../repositories/voteRepository";
import { invalidateStats } from "./statsService";
import { fromTimestamp } from "../types/dateString";
import type { Game } from "../types/panit";
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,
opts?: { skipInvalidate?: boolean }
): Promise<{ processed: number }> {
if (game.status !== "completed") throw new HttpError(409, `game status must be completed, got ${game.status}`);
const date = fromTimestamp(game.time);
const votes = await getAllUserVotes(gameId);
const uids = Object.keys(votes);
// 무승부(`winningTeamCode` 없음): 양 팀 투표 모두 적중(`result: true`)으로 처리.
const isDraw = !game.winningTeamCode;
const rtdbUpdates: Record<string, unknown> = {};
for (const uid of uids) {
const voted = votes[uid].team;
const result = isDraw ? true : voted === game.winningTeamCode;
rtdbUpdates[`/userVotes/${uid}/${date}/${gameId}/team`] = voted;
rtdbUpdates[`/userVotes/${uid}/${date}/${gameId}/result`] = result;
}
if (Object.keys(rtdbUpdates).length > 0) {
await rtdb.ref().update(rtdbUpdates);
}
await deleteGameVotes(gameId);
// 아카이브 리컨실 경로에선 호출자(dailyArchive)가 유저 단위로 1회 무효화하므로
// 경기마다 투표자 전원을 다시 무효화하는 fan-out을 건너뛴다(중복 제거).
if (!opts?.skipInvalidate) {
await Promise.all(uids.map((uid) => invalidateStats(uid).catch(() => undefined)));
}
logger.info(`processGameEnd: ${gameId} processed ${uids.length} votes`);
return { processed: uids.length };
}
export async function markGameEnded(
gameId: string,
winningTeamCode: string
): Promise<{ ok: true; gameId: string }> {
if (!gameId || !winningTeamCode) {
throw new HttpError(400, "gameId and winningTeamCode required");
}
const ref = firestore.collection("games").doc(gameId);
const snap = await ref.get();
if (!snap.exists) throw new HttpError(404, `game not found: ${gameId}`);
await ref.update({
status: "completed",
winningTeamCode,
endedAt: FieldValue.serverTimestamp(),
});
return { ok: true, gameId };
}