diff --git a/database.rules.json b/database.rules.json index d9dd5e1..458d837 100644 --- a/database.rules.json +++ b/database.rules.json @@ -15,6 +15,14 @@ ".write": false } }, + "userVotesByDate": { + ".read": false, + ".write": false + }, + "userVotesByDateMeta": { + ".read": false, + ".write": false + }, "cache": { ".read": false, ".write": false diff --git a/package.json b/package.json index 8f48979..2c64d68 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "tools:sheet": "npx tsx scripts/chat-tools-sheet.ts", "seed:rewards": "npx tsx scripts/seed-reward-products.ts", "upload:reward-assets": "npx tsx scripts/upload-reward-product-assets.ts", - "optimize:reward-images": "npx tsx scripts/optimize-reward-product-images.ts" + "optimize:reward-images": "npx tsx scripts/optimize-reward-product-images.ts", + "backfill:vote-index": "npx tsx scripts/backfill-user-votes-by-date.ts" }, "engines": { "node": "24" diff --git a/scripts/backfill-user-votes-by-date.ts b/scripts/backfill-user-votes-by-date.ts new file mode 100644 index 0000000..4637845 --- /dev/null +++ b/scripts/backfill-user-votes-by-date.ts @@ -0,0 +1,85 @@ +/** + * `/userVotes/{uid}/{date}/{gameId}` → `/userVotesByDate/{date}/{uid}/{gameId}` 백필. + * + * `dailyArchive`는 날짜별 인덱스만 읽는다(원래는 하루치를 위해 `/userVotes` 트리 + * 전체를 내려받았다). 인덱스 도입 이전에 기록된 투표는 이 스크립트로 옮겨야 + * 잡의 레거시 폴백 경로를 타지 않는다. + * + * 멱등하다 — 여러 번 돌려도 같은 값으로 덮어쓸 뿐이다. + * + * 실행: + * npx tsx scripts/backfill-user-votes-by-date.ts # 미리보기 + * npx tsx scripts/backfill-user-votes-by-date.ts --apply # 실제 쓰기 + */ +import "./_bootstrap"; +import { rtdb } from "../src/firebase"; + +type VoteEntry = { team: string; result?: boolean; cancelled?: boolean }; +type ByUid = Record>>; + +/** RTDB update는 한 번에 너무 많은 경로를 담으면 실패하므로 나눠 커밋한다. */ +const CHUNK = 500; + +async function main(): Promise { + const apply = process.argv.includes("--apply"); + + const snap = await rtdb.ref("/userVotes").get(); + if (!snap.exists()) { + console.log("no /userVotes data — nothing to backfill"); + if (apply) await markBackfilled(); + return; + } + + const byUid = snap.val() as ByUid; + const updates: Record = {}; + let uidCount = 0; + let entryCount = 0; + const dates = new Set(); + + for (const uid of Object.keys(byUid)) { + const byDate = byUid[uid] ?? {}; + let touched = false; + for (const date of Object.keys(byDate)) { + const games = byDate[date] ?? {}; + for (const gameId of Object.keys(games)) { + updates[`/userVotesByDate/${date}/${uid}/${gameId}`] = games[gameId]; + entryCount += 1; + dates.add(date); + touched = true; + } + } + if (touched) uidCount += 1; + } + + const paths = Object.keys(updates); + console.log( + `${entryCount} entries / ${uidCount} uids / ${dates.size} dates` + + (apply ? "" : " (dry run — pass --apply to write)") + ); + if (!apply) return; + + for (let i = 0; i < paths.length; i += CHUNK) { + const slice: Record = {}; + for (const p of paths.slice(i, i + CHUNK)) slice[p] = updates[p]; + await rtdb.ref().update(slice); + console.log(` written ${Math.min(i + CHUNK, paths.length)}/${paths.length}`); + } + await markBackfilled(); + console.log("backfill complete"); +} + +/** + * 완료 표시. 이 값이 있으면 `dailyArchive`는 인덱스가 비어 있어도 + * "그날 투표가 없었다"로 해석하고 레거시 전체 스캔 폴백을 쓰지 않는다. + */ +async function markBackfilled(): Promise { + await rtdb.ref("/userVotesByDateMeta/backfilledAt").set(new Date().toISOString()); + console.log("marked /userVotesByDateMeta/backfilledAt"); +} + +main() + .then(() => process.exit(0)) + .catch((err) => { + console.error(err); + process.exit(1); + }); diff --git a/src/repositories/voteRepository.ts b/src/repositories/voteRepository.ts index db3e1c3..5f62438 100644 --- a/src/repositories/voteRepository.ts +++ b/src/repositories/voteRepository.ts @@ -66,6 +66,7 @@ export async function submitVote(params: { updates[`/votes/${gameId}/counts/${side}Count`] = ServerValue.increment(1); updates[`/votes/${gameId}/users/${uid}`] = { team }; updates[`/userVotes/${uid}/${date}/${gameId}`] = { team }; + updates[byDatePath(uid, date, gameId)] = { team }; await rtdb.ref().update(updates); } @@ -93,9 +94,47 @@ export async function changeVote(params: { updates[`/votes/${gameId}/counts/${newSide}Count`] = ServerValue.increment(1); updates[`/votes/${gameId}/users/${uid}`] = { team: newTeam }; updates[`/userVotes/${uid}/${date}/${gameId}`] = { team: newTeam }; + updates[byDatePath(uid, date, gameId)] = { team: newTeam }; await rtdb.ref().update(updates); } +/** + * 날짜별 투표 인덱스 경로 — `/userVotes/{uid}/{date}`의 (date, uid) 전치 미러. + * + * RTDB에는 부분 노드 읽기가 없어서 `dailyArchive`가 하루치를 처리하려면 + * `/userVotes` 트리 **전체**(전 유저 × 전 보존 날짜)를 내려받아야 했다. + * 쓰기 시점에 fan-out 해 두면 잡이 `/userVotesByDate/{date}` 한 노드만 읽는다. + * + * ⚠️ 이 경로는 서버 전용이다(교차 유저 데이터). RTDB 규칙에서 read/write 모두 false. + */ +function byDatePath(uid: string, date: DateString, gameId: string): string { + return `/userVotesByDate/${date}/${uid}/${gameId}`; +} + +/** + * 특정 날짜에 투표한 전 유저의 기록을 조회한다(아카이브 전용). + * + * @returns `{ [uid]: { [gameId]: VoteEntry } }`. 없으면 빈 객체. + */ +export async function getVotesByDate( + date: DateString +): Promise>> { + const snap = await rtdb.ref(`/userVotesByDate/${date}`).get(); + return snap.exists() ? (snap.val() as Record>) : {}; +} + +/** + * 백필 완료 표시를 조회한다. + * + * 표시가 있으면 날짜별 인덱스가 전 이력을 담고 있다는 뜻이므로, 어떤 날짜가 + * 비어 있어도 그것은 "그날 투표가 없었다"는 사실이지 인덱스 누락이 아니다. + * `dailyArchive`가 레거시 전체 스캔 폴백을 건너뛰는 근거로 쓴다. + */ +export async function isVoteDateIndexBackfilled(): Promise { + const snap = await rtdb.ref("/userVotesByDateMeta/backfilledAt").get(); + return snap.exists(); +} + /** * 특정 유저가 특정 날짜에 한 모든 경기 투표를 조회한다. * @@ -127,7 +166,13 @@ export async function deleteGameVotes(gameId: string): Promise { * 탈퇴 비활성화 시 아카이브가 비활성 계정을 판정하지 않도록 정리한다. */ export async function deleteUserVoteIndex(uid: string): Promise { - await rtdb.ref(`/userVotes/${uid}`).remove(); + // 날짜별 미러도 함께 지운다. 어느 날짜에 기록이 있는지는 원본에만 있으므로 + // 먼저 읽어서 해당 날짜들만 정리한다(유저 1명분이라 작다). + const snap = await rtdb.ref(`/userVotes/${uid}`).get(); + const dates = snap.exists() ? Object.keys(snap.val() as Record) : []; + const updates: Record = { [`/userVotes/${uid}`]: null }; + for (const date of dates) updates[`/userVotesByDate/${date}/${uid}`] = null; + await rtdb.ref().update(updates); } /** @@ -139,5 +184,8 @@ export async function deleteUserVoteGame( date: DateString, gameId: string ): Promise { - await rtdb.ref(`/userVotes/${uid}/${date}/${gameId}`).remove(); + await rtdb.ref().update({ + [`/userVotes/${uid}/${date}/${gameId}`]: null, + [byDatePath(uid, date, gameId)]: null, + }); } diff --git a/src/services/gameResultService.ts b/src/services/gameResultService.ts index c8b4dd8..aa3a59b 100644 --- a/src/services/gameResultService.ts +++ b/src/services/gameResultService.ts @@ -2,7 +2,7 @@ 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 { getGame, invalidateGameDay } from "../repositories/gameRepository"; import { getAllUserVotes, deleteGameVotes } from "../repositories/voteRepository"; import { invalidateStats } from "./statsService"; import { fromTimestamp } from "../types/dateString"; @@ -36,6 +36,9 @@ export async function processGameEndWithGame( : 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); @@ -71,5 +74,8 @@ export async function markGameEnded( 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 }; } diff --git a/tests/repositories/voteRepository.test.ts b/tests/repositories/voteRepository.test.ts index 313914a..ab9409f 100644 --- a/tests/repositories/voteRepository.test.ts +++ b/tests/repositories/voteRepository.test.ts @@ -7,6 +7,10 @@ import { getCounts, getUserDateVotes, getUserVote, + getVotesByDate, + isVoteDateIndexBackfilled, + deleteUserVoteGame, + deleteUserVoteIndex, submitVote, } from "../../src/repositories/voteRepository"; import type { DateString } from "../../src/types/dateString"; @@ -175,3 +179,87 @@ describe("voteRepository (RTDB)", () => { }); }); }); + +/** + * 날짜별 인덱스는 `/userVotes`의 (date, uid) 전치 미러다. dailyArchive가 이쪽만 + * 읽으므로, 원본을 바꾸는 모든 경로가 미러도 같이 갱신해야 투표가 유실되지 않는다. + */ +describe("voteRepository — /userVotesByDate 미러", () => { + beforeEach(async () => { + await rtdb.ref("/votes").remove(); + await rtdb.ref("/userVotes").remove(); + await rtdb.ref("/userVotesByDate").remove(); + }); + + it("submitVote가 날짜별 인덱스에도 기록한다", async () => { + await submitVote({ gameId, uid, date, side: "home", team: "LG" }); + + const byDate = await getVotesByDate(date); + expect(byDate[uid]?.[gameId]).toEqual({ team: "LG" }); + }); + + it("여러 유저의 같은 날짜 투표가 한 노드에 모인다", async () => { + await submitVote({ gameId, uid, date, side: "home", team: "LG" }); + await submitVote({ gameId, uid: uid2, date, side: "away", team: "KIA" }); + await submitVote({ gameId: gameId2, uid, date, side: "draw", team: "DRAW" }); + + const byDate = await getVotesByDate(date); + expect(Object.keys(byDate).sort()).toEqual([uid, uid2].sort()); + expect(Object.keys(byDate[uid])).toHaveLength(2); + }); + + it("changeVote가 미러의 팀도 함께 바꾼다", async () => { + await submitVote({ gameId, uid, date, side: "home", team: "LG" }); + await changeVote({ + gameId, uid, date, oldSide: "home", newSide: "away", newTeam: "KIA", + }); + + const byDate = await getVotesByDate(date); + expect(byDate[uid][gameId]).toEqual({ team: "KIA" }); + }); + + it("deleteUserVoteGame이 양쪽에서 제거한다", async () => { + await submitVote({ gameId, uid, date, side: "home", team: "LG" }); + await submitVote({ gameId: gameId2, uid, date, side: "away", team: "KIA" }); + + await deleteUserVoteGame(uid, date, gameId); + + expect(await getUserDateVotes(uid, date)).toEqual({ [gameId2]: { team: "KIA" } }); + const byDate = await getVotesByDate(date); + expect(byDate[uid]).toEqual({ [gameId2]: { team: "KIA" } }); + }); + + it("deleteUserVoteIndex가 그 유저의 모든 날짜 미러를 지운다", async () => { + const other = "2026-04-13" as DateString; + await submitVote({ gameId, uid, date, side: "home", team: "LG" }); + await submitVote({ gameId: gameId2, uid, date: other, side: "away", team: "KIA" }); + await submitVote({ gameId, uid: uid2, date, side: "home", team: "LG" }); + + await deleteUserVoteIndex(uid); + + expect(await getUserDateVotes(uid, date)).toEqual({}); + expect((await getVotesByDate(date))[uid]).toBeUndefined(); + expect((await getVotesByDate(other))[uid]).toBeUndefined(); + // 다른 유저 기록은 남아 있어야 한다 + expect((await getVotesByDate(date))[uid2]).toEqual({ [gameId]: { team: "LG" } }); + }); + + it("투표가 없는 날짜는 빈 객체를 돌려준다", async () => { + expect(await getVotesByDate("2026-01-01" as DateString)).toEqual({}); + }); +}); + +describe("voteRepository — 백필 완료 표시", () => { + beforeEach(async () => { + await rtdb.ref("/userVotesByDateMeta").remove(); + }); + + it("표시가 없으면 false", async () => { + expect(await isVoteDateIndexBackfilled()).toBe(false); + }); + + it("표시가 있으면 true — dailyArchive가 레거시 폴백을 건너뛰는 근거", async () => { + await rtdb.ref("/userVotesByDateMeta/backfilledAt").set(new Date().toISOString()); + expect(await isVoteDateIndexBackfilled()).toBe(true); + }); +});