diff --git a/src/scheduled/dailyArchive.ts b/src/scheduled/dailyArchive.ts index 3973036..cd3ad62 100644 --- a/src/scheduled/dailyArchive.ts +++ b/src/scheduled/dailyArchive.ts @@ -130,19 +130,22 @@ export async function runDailyArchive( // 리컨실 결과 모든 경기가 cancelled로 제거된 경우에도 skip 판정은 남겨 // 스트릭 유지 로직이 동작하도록 judgeDay를 호출한다. - await setDay(uid, date, { data }); - await rtdb.ref(`/userVotes/${uid}/${date}`).remove(); // 판정 전에 현재 rank를 rankSnapshot에 기록 — 판정 후 변화량을 delta로 노출하기 위함. try { await snapshotRankForUser(uid, date); } catch (err) { logger.error(`snapshotRank failed uid=${uid} date=${date}`, err); } + // voteHistory 기록은 judgeDay가 1회 수행한다(판정 필드 포함). 별도 선기록은 생략. + // judgeDay가 doc을 영속화한 뒤에야 userVotes를 제거해 데이터 유실을 막는다. try { await judgeDay(uid, date, { data }, gameCache); } catch (err) { logger.error(`judgeDay failed uid=${uid} date=${date}`, err); + // 판정 실패 시에도 data만은 보존(기존 동작 유지) — userVotes를 곧 지우기 때문. + await setDay(uid, date, { data }).catch(() => undefined); } + await rtdb.ref(`/userVotes/${uid}/${date}`).remove(); await invalidateStats(uid).catch(() => undefined); judgedUids.push(uid); archived += 1; diff --git a/src/services/judgmentService.ts b/src/services/judgmentService.ts index 66f0dbc..a03a9f4 100644 --- a/src/services/judgmentService.ts +++ b/src/services/judgmentService.ts @@ -3,7 +3,7 @@ import { createGameDayCache, type GameDayCache, } from "../repositories/gameRepository"; -import { getRange, setDay } from "../repositories/voteHistoryRepository"; +import { getDay, getRange, setDay } from "../repositories/voteHistoryRepository"; import { applyDailyJudgmentTx, applyWeeklyMasterTx, @@ -95,6 +95,11 @@ export async function judgeDay( if (tx.skippedByGuard) { logger.info(`judgeDay: ${uid} ${date} already judged, skipping`); + // 복구 경로: 앞선 run에서 트랜잭션은 커밋됐지만(=lastJudgedDate 갱신) voteHistory + // 기록 직전에 크래시한 경우 doc이 비어있을 수 있다. 그 때만 data를 복원한다. + // 정상 멱등 재실행에서는 doc이 이미 존재하므로 판정 필드를 덮어쓰지 않는다. + const existing = await getDay(uid, date); + if (!existing) await setDay(uid, date, voteDoc); return; }