diff --git a/src/repositories/userRepository.ts b/src/repositories/userRepository.ts index 231a6bb..35f6c89 100644 --- a/src/repositories/userRepository.ts +++ b/src/repositories/userRepository.ts @@ -191,6 +191,12 @@ export async function applyDailyJudgmentTx( * 판정 분기를 적용한다. 휴장일은 제외하고 판단되어 들어온다. */ streakBrokenIn: boolean; + /** + * 판정 직전(=현재 tierPoints 기준) rank 스냅샷. 제공되면 동일 트랜잭션 patch에 + * `rankSnapshot`으로 함께 기록해 별도 write를 절약한다. "판정 전 rank" 의미를 + * 보존하려면 호출자가 트랜잭션 호출 전에 계산해 넘겨야 한다. + */ + rankSnapshot?: RankSnapshot; computePoints: (streakAfter: number) => number; } ): Promise { @@ -241,6 +247,8 @@ export async function applyDailyJudgmentTx( }, lastJudgedDate: date, }; + // 판정 전 rank 스냅샷을 같은 patch에 합쳐 user doc write를 1회로 줄인다. + if (input.rankSnapshot) patch.rankSnapshot = input.rankSnapshot; tx.set(ref, patch, { merge: true }); return { diff --git a/src/scheduled/dailyArchive.ts b/src/scheduled/dailyArchive.ts index 2e6fe16..f2a4798 100644 --- a/src/scheduled/dailyArchive.ts +++ b/src/scheduled/dailyArchive.ts @@ -6,13 +6,13 @@ import { invalidateStats } from "../services/statsService"; import { judgeDay, judgeWeekIfNeeded } from "../services/judgmentService"; import { precomputeScoreboardCache, - snapshotRankForUser, + computeRankSnapshot, } from "../services/rankSnapshotService"; import { todayKst } from "../types/dateString"; import { getGame, createGameDayCache } from "../repositories/gameRepository"; import { deleteUserVoteGame } from "../repositories/voteRepository"; import { processGameEndWithGame } from "../services/gameResultService"; -import type { VoteHistoryDoc } from "../types/panit"; +import type { RankSnapshot, VoteHistoryDoc } from "../types/panit"; import { dayOfWeekKst, daysAgoKst, @@ -131,16 +131,18 @@ export async function runDailyArchive( // 리컨실 결과 모든 경기가 cancelled로 제거된 경우에도 skip 판정은 남겨 // 스트릭 유지 로직이 동작하도록 judgeDay를 호출한다. - // 판정 전에 현재 rank를 rankSnapshot에 기록 — 판정 후 변화량을 delta로 노출하기 위함. + // 판정 전 rank를 계산해(=현재 tierPoints 기준) judgeDay 트랜잭션에 함께 기록한다. + // 판정 트랜잭션과 같은 patch로 묶여 user doc write가 1회로 준다("판정 전 rank" 보존). + let rankSnapshot: RankSnapshot | null = null; try { - await snapshotRankForUser(uid, date); + rankSnapshot = await computeRankSnapshot(uid, date); } catch (err) { - logger.error(`snapshotRank failed uid=${uid} date=${date}`, err); + logger.error(`computeRankSnapshot failed uid=${uid} date=${date}`, err); } // voteHistory 기록은 judgeDay가 1회 수행한다(판정 필드 포함). 별도 선기록은 생략. // judgeDay가 doc을 영속화한 뒤에야 userVotes를 제거해 데이터 유실을 막는다. try { - await judgeDay(uid, date, { data }, gameCache); + await judgeDay(uid, date, { data }, { gameCache, rankSnapshot }); } catch (err) { logger.error(`judgeDay failed uid=${uid} date=${date}`, err); // 판정 실패 시에도 data만은 보존(기존 동작 유지) — userVotes를 곧 지우기 때문. diff --git a/src/services/judgmentService.ts b/src/services/judgmentService.ts index a03a9f4..ceca46a 100644 --- a/src/services/judgmentService.ts +++ b/src/services/judgmentService.ts @@ -14,7 +14,7 @@ import { streakBonus, thresholdsFor, } from "../constants/judgment"; -import type { DailyJudgment, VoteHistoryDoc } from "../types/panit"; +import type { DailyJudgment, RankSnapshot, VoteHistoryDoc } from "../types/panit"; import { addDaysKst, tuesdayOf, type DateString } from "../types/dateString"; /** @@ -54,14 +54,16 @@ export async function hasMissedGameDayBetween( * @param uid - 유저 ID * @param date - 판정 대상 날짜 (KST) * @param voteDoc - 해당 날짜의 voteHistory 도큐먼트 내용(판정 필드 미포함) + * @param opts.gameCache - 여러 유저를 처리할 때 날짜별 games read를 공유하는 캐시 + * @param opts.rankSnapshot - 판정 직전 rank 스냅샷. 제공 시 판정 트랜잭션에 함께 기록한다. */ export async function judgeDay( uid: string, date: DateString, voteDoc: VoteHistoryDoc, - gameCache?: GameDayCache + opts?: { gameCache?: GameDayCache; rankSnapshot?: RankSnapshot | null } ): Promise { - const fetch = gameCache ?? createGameDayCache(); + const fetch = opts?.gameCache ?? createGameDayCache(); const games = await fetch.listByDate(date); const completedCount = games.filter((g) => g.status === "completed").length; const threshold = thresholdsFor(completedCount); @@ -90,6 +92,7 @@ export async function judgeDay( correctCount, completedCount, streakBrokenIn, + rankSnapshot: opts?.rankSnapshot ?? undefined, computePoints: (streakAfter) => correctCount * 10 + streakBonus(streakAfter), }); diff --git a/src/services/rankSnapshotService.ts b/src/services/rankSnapshotService.ts index c39a906..23386b0 100644 --- a/src/services/rankSnapshotService.ts +++ b/src/services/rankSnapshotService.ts @@ -23,14 +23,14 @@ import type { * * `tierPoints === 0`이면 스냅샷을 남기지 않는다. */ -export async function snapshotRankForUser( +export async function computeRankSnapshot( uid: string, date: DateString -): Promise { +): Promise { const user = await getUser(uid); - if (!user) return; + if (!user) return null; const tierPoints = user.tierPoints ?? 0; - if (tierPoints <= 0) return; + if (tierPoints <= 0) return null; const overallAbove = await countUsersAboveTierPoints(tierPoints); const snapshot: RankSnapshot = { @@ -47,6 +47,15 @@ export async function snapshotRankForUser( snapshot.teamCode = user.favoriteTeamCode; } + return snapshot; +} + +export async function snapshotRankForUser( + uid: string, + date: DateString +): Promise { + const snapshot = await computeRankSnapshot(uid, date); + if (!snapshot) return; await firestore .collection("users") .doc(uid)