mmday-firebase/src/services/gameResultService.ts
윤정민 eb44f50886 Index user votes by date for daily archive
- /userVotesByDate/{date}/{uid}/{gameId} fan-out 인덱스 추가 — RTDB에는 부분 노드 읽기가 없어서 dailyArchive가 하루치를 처리하려면 /userVotes 트리 전체(전 유저 x 전 보존 날짜)를 내려받아야 했다
- 투표를 쓰는 3곳(submitVote, changeVote, processGameEndWithGame의 판정 결과 주입)이 원자 update 하나로 원본과 미러를 함께 기록
- 삭제 경로도 동기화: deleteUserVoteGame은 양쪽에서, deleteUserVoteIndex는 유저의 날짜 목록을 먼저 읽어 해당 날짜 미러만 정리
- database.rules.json에 userVotesByDate·userVotesByDateMeta를 read/write false로 추가 — 교차 유저 데이터라 서버 전용
- 백필 스크립트 추가(npm run backfill:vote-index). dry-run 기본, --apply로 500건씩 청킹 기록, 멱등. 완료 시 /userVotesByDateMeta/backfilledAt 마커를 남겨 잡이 원본 스캔을 그만두는 근거로 쓴다
- 미러 정합성 테스트 8건 추가
2026-07-27 13:26:15 +09:00

82 lines
3.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, invalidateGameDay } from "../repositories/gameRepository";
import { getAllUserVotes, deleteGameVotes } from "../repositories/voteRepository";
import { invalidateStats } from "./statsService";
import { fromTimestamp } from "../types/dateString";
import { DRAW_TEAM_CODE, 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` 없음): 무승부('DRAW') 투표만 적중, 팀 투표는 오답.
// 승부가 난 경기: 승리 팀 투표만 적중 — 무승부 투표는 자연히 오답이 된다.
const isDraw = !game.winningTeamCode;
const rtdbUpdates: Record<string, unknown> = {};
for (const uid of uids) {
const voted = votes[uid].team;
const result = isDraw
? voted === DRAW_TEAM_CODE
: voted === game.winningTeamCode;
rtdbUpdates[`/userVotes/${uid}/${date}/${gameId}/team`] = voted;
rtdbUpdates[`/userVotes/${uid}/${date}/${gameId}/result`] = result;
// 날짜별 미러도 같은 원자 update에 포함 — 아카이브가 이쪽을 읽는다.
rtdbUpdates[`/userVotesByDate/${date}/${uid}/${gameId}/team`] = voted;
rtdbUpdates[`/userVotesByDate/${date}/${uid}/${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}`);
// 'DRAW'는 무승부 수동 처리 — winningTeamCode 없이 completed로 기록한다
// (판정 규칙상 "winningTeamCode 없음 = 무승부"이므로 남아있는 값도 지운다).
const isDraw = winningTeamCode === DRAW_TEAM_CODE;
await ref.update({
status: "completed",
winningTeamCode: isDraw ? FieldValue.delete() : winningTeamCode,
endedAt: FieldValue.serverTimestamp(),
});
// 스코어·상태가 바뀌었으므로 해당 날짜의 games 캐시를 즉시 버린다.
const time = (snap.data() as { time?: { toDate(): Date } }).time;
if (time) invalidateGameDay(fromTimestamp(time));
return { ok: true, gameId };
}