- /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건 추가
82 lines
3.6 KiB
TypeScript
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 };
|
|
}
|