import { onSchedule } from "firebase-functions/scheduler"; import { logger } from "firebase-functions"; import { rtdb } from "../firebase"; import { setDay } from "../repositories/voteHistoryRepository"; import { invalidateStats } from "../services/statsService"; import { judgeDay } from "../services/judgmentService"; import { precomputeScoreboardCache, computeRankSnapshot, loadRankStandings, type RankStandings, } from "../services/rankSnapshotService"; import { getUser } from "../repositories/userRepository"; import { maybeSettleSeason } from "../services/seasonService"; import { todayKst } from "../types/dateString"; import { getGame, createGameDayCache, type GameDayCache, } from "../repositories/gameRepository"; import { processGameEndWithGame } from "../services/gameResultService"; import { getVotesByDate, isVoteDateIndexBackfilled, } from "../repositories/voteRepository"; import { DRAW_TEAM_CODE, type RankSnapshot, type User, type VoteHistoryDoc, } from "../types/panit"; import { daysAgoKst, type DateString, } from "../types/dateString"; interface RawVote { team: string; result?: boolean; /** 경기 취소로 무효 처리된 투표 — 판정에서 제외하되 기록에는 남긴다. */ cancelled?: boolean; } type DayVotes = Record; /** * 미판정 경기들을 games 문서로 조회해 자가치유한다. * - completed + winningTeamCode: 즉석 processGameEndWithGame 호출 후 result 재주입. * - cancelled: 무효(cancelled) 마킹 — 판정에서 빠지되 참여 흔적은 보존. * - 그 외: warn 로그 후 그대로 둔다(진짜 미판정). * * 반환: 갱신된 dayVotes (result 채워졌거나 cancelled 마킹된 상태). */ async function reconcileDayVotes( uid: string, date: DateString, dayVotes: DayVotes, gameCache: GameDayCache, healed: Set ): Promise { const result: DayVotes = { ...dayVotes }; // 해당 날짜 경기를 한 번에 확보한다 — 유저×미판정경기 수만큼 getGame을 치던 N+1 제거. const byId = new Map( (await gameCache.listByDate(date)).map((g) => [g.gameId, g]) ); for (const [gameId, vote] of Object.entries(dayVotes)) { if (vote.result !== undefined || vote.cancelled) continue; const game = byId.get(gameId) ?? (await getGame(gameId)); if (!game) { logger.warn(`reconcile: game not found ${gameId}, uid=${uid}`); continue; } // 트리거가 실패/유실되었거나 배포 전 상태 전이가 일어난 경우의 자가치유 경로. // `winningTeamCode`가 없으면 무승부 — 무승부('DRAW') 투표만 적중으로 처리한다 // (`processGameEndWithGame`과 동일 규칙). if (game.status === "completed") { try { // `byUid`는 정지된 스냅샷이라 앞선 유저가 치유한 경기도 뒤 유저에겐 여전히 // 미판정으로 보인다. run 스코프 Set으로 경기당 1회만 처리해, 같은 경기의 // 투표자 전원 재처리(getAllUserVotes + RTDB update + deleteGameVotes)를 막는다. if (!healed.has(gameId)) { // 아카이브는 루프 끝에서 유저 단위로 1회 무효화하므로 경기별 fan-out은 생략. await processGameEndWithGame(gameId, game, { skipInvalidate: true }); healed.add(gameId); } const isDraw = !game.winningTeamCode; result[gameId] = { team: vote.team, result: isDraw ? vote.team === DRAW_TEAM_CODE : vote.team === game.winningTeamCode, }; logger.info(`reconcile: judged ${gameId} on the fly (uid=${uid})`); } catch (err) { logger.error(`reconcile: processGameEnd failed ${gameId}`, err); } // 취소 경기 뒤늦게 감지: 무효 마킹으로 allJudged 통과를 허용하되 // "픽했지만 취소됨"이라는 참여 흔적은 보존한다. } else if (game.status === "cancelled") { result[gameId] = { team: vote.team, cancelled: true }; logger.info(`reconcile: voided cancelled ${gameId} (uid=${uid})`); } else { logger.warn( `reconcile: ${gameId} still ${game.status} (uid=${uid}, date=${date})` ); } } return result; } /** * dailyArchive의 본체 로직. 스케줄 콜백과 수동 트리거(`runDailyArchiveNow`) 양쪽에서 호출. * * @param overrideDate 지정 시 그 날짜를 archive 대상으로 사용. 미지정 시 어제(KST). * @returns 처리 결과 요약 */ export async function runDailyArchive( overrideDate?: DateString ): Promise<{ date: DateString; archived: number; judgedUids: string[] }> { const date = overrideDate ?? daysAgoKst(1); logger.info(`dailyArchive start: ${date}`); let archived = 0; const judgedUids: string[] = []; // 같은 날짜(및 결석 판정용 직전 날짜들)의 games를 유저마다 재조회하지 않도록 // run 전체에서 1개의 날짜별 캐시를 공유한다. 날짜당 Firestore read 1회로 수렴. const gameCache = createGameDayCache(); // run 중 자가치유된 경기 — 뒤따르는 유저들이 같은 경기를 재처리하지 않도록 한다. const healedGames = new Set(); // 순위표를 run 시작 시 1회 로드해 유저별 count aggregation(O(N×M))을 없앤다. // 실패 시 standings 없이 진행하면 computeRankSnapshot이 aggregation으로 폴백한다. let standings: RankStandings | null = null; try { standings = await loadRankStandings(); } catch (err) { logger.error("loadRankStandings failed — per-user aggregation으로 폴백", err); } try { // 날짜별 인덱스만 읽는다 — 예전에는 하루치를 위해 `/userVotes` 트리 전체 // (전 유저 × 전 보존 날짜)를 내려받았다. const byUid = (await getVotesByDate(date)) as Record; let mergedFromLegacy = 0; // 백필이 끝났으면 인덱스가 전 이력을 담고 있으므로, 비어 있다는 것은 // "그날 투표가 없었다"는 사실이다 — 전체 스캔으로 되돌아가지 않는다. // (이 가드가 없으면 월요일·비시즌 같은 무투표일마다 트리 전체를 다시 읽는다.) // // 반대로 마커가 없는 롤아웃 기간에는 인덱스가 **부분적으로만** 찼을 수 있다. // 미러 배포 전에 투표한 유저는 인덱스에 없고 원본에만 있는데, 같은 날 배포 후 // 투표한 유저가 하나라도 있으면 인덱스가 비지 않는다. "비었을 때만 폴백"으로 // 두면 그 배포 전 투표자들이 판정·보상·스트릭 없이 영구 유실된다 // (아카이브는 매 run 다른 날짜를 처리하므로 그 날짜는 다시 열리지 않는다). // 그래서 마커가 없으면 항상 원본을 읽어 인덱스에 없는 uid만 보충한다. if (!(await isVoteDateIndexBackfilled())) { const legacy = await rtdb.ref("/userVotes").get(); if (legacy.exists()) { const all = legacy.val() as Record>; for (const uid of Object.keys(all)) { const day = all[uid]?.[date]; // 인덱스 값이 우선 — 원본은 인덱스에 없는 uid를 채우는 용도로만 쓴다. if (day && !(uid in byUid)) { byUid[uid] = day; mergedFromLegacy += 1; } } } if (mergedFromLegacy > 0) { logger.warn( `dailyArchive: ${date} — 인덱스에 없는 ${mergedFromLegacy}명을 원본에서 보충했다. ` + "백필 스크립트(npm run backfill:vote-index -- --apply) 실행 권장" ); } } if (Object.keys(byUid).length === 0) { logger.info("no userVotes to archive"); return { date, archived: 0, judgedUids: [] }; } for (const uid of Object.keys(byUid)) { let dayVotes = byUid[uid]; if (!dayVotes) continue; const hasUnjudged = Object.values(dayVotes).some( (v) => v.result === undefined && !v.cancelled ); if (hasUnjudged) { dayVotes = await reconcileDayVotes(uid, date, dayVotes, gameCache, healedGames); } const data: VoteHistoryDoc["data"] = []; let allJudged = true; for (const [gameId, vote] of Object.entries(dayVotes)) { // 취소 무효표: 판정 계산에는 안 들어가지만 참여 흔적으로 기록에 남긴다. if (vote.cancelled) { data.push({ gameId, team: vote.team, cancelled: true }); continue; } if (vote.result === undefined) { allJudged = false; break; } data.push({ gameId, team: vote.team, result: vote.result }); } if (!allJudged) { logger.warn(`skip uid=${uid}: not all games judged for ${date}`); continue; } // 리컨실 결과 모든 경기가 취소 무효 처리된 경우에도 skip 판정은 남겨 // 스트릭 유지 로직이 동작하도록 judgeDay를 호출한다. // 판정 전 rank를 계산해(=현재 tierPoints 기준) judgeDay 트랜잭션에 함께 기록한다. // 판정 트랜잭션과 같은 patch로 묶여 user doc write가 1회로 준다("판정 전 rank" 보존). // user 문서는 여기서 1회만 읽어 rank 계산과 judgeDay가 공유한다. // (판정 트랜잭션 내부의 tx.get은 원자성상 필수라 남는다.) // 읽기 실패는 undefined로 남긴다 — null을 넘기면 judgeDay가 "유저 문서 없음" // 으로 해석해 결석 판정을 건너뛴다. undefined면 judgeDay가 스스로 다시 읽는다. let user: User | null | undefined; try { user = await getUser(uid); } catch (err) { logger.error(`getUser failed uid=${uid} — judgeDay가 재조회한다`, err); } let rankSnapshot: RankSnapshot | null = null; try { rankSnapshot = await computeRankSnapshot(uid, date, { ...(user !== undefined ? { user } : {}), ...(standings ? { standings } : {}), }); } catch (err) { logger.error(`computeRankSnapshot failed uid=${uid} date=${date}`, err); } // voteHistory 기록은 judgeDay가 1회 수행한다(판정 필드 포함). 별도 선기록은 생략. // judgeDay가 doc을 영속화한 뒤에야 userVotes를 제거해 데이터 유실을 막는다. try { await judgeDay(uid, date, { data }, { gameCache, rankSnapshot, ...(user !== undefined ? { userPre: user } : {}), }); } catch (err) { logger.error(`judgeDay failed uid=${uid} date=${date}`, err); // 판정 실패 시에도 data만은 보존(기존 동작 유지) — userVotes를 곧 지우기 때문. await setDay(uid, date, { data }).catch(() => undefined); } // 원본과 날짜별 미러를 함께 정리한다. await rtdb.ref().update({ [`/userVotes/${uid}/${date}`]: null, [`/userVotesByDate/${date}/${uid}`]: null, }); await invalidateStats(uid).catch(() => undefined); judgedUids.push(uid); archived += 1; } logger.info( `dailyArchive done: ${archived} users archived for ${date}` + (mergedFromLegacy > 0 ? ` (원본 보충 ${mergedFromLegacy}명)` : "") ); return { date, archived, judgedUids }; } finally { // 시즌 종료 다음 날 첫 archive: 어제(=시즌 마지막 날) 판정을 반영한 뒤 // 정산·리셋하고, 이어지는 캐시 재계산이 리셋된(새 시즌) 보드를 만든다. // 정산 실패가 캐시 재계산을 막지 않도록 각각 독립적으로 감싼다. try { await maybeSettleSeason(todayKst()); } catch (err) { logger.error("maybeSettleSeason failed", err); } try { await precomputeScoreboardCache(todayKst()); } catch (err) { logger.error("precomputeScoreboardCache failed", err); } } } export const dailyArchive = onSchedule( { schedule: "0 3 * * *", timeZone: "Asia/Seoul", region: "asia-northeast3" }, async () => { await runDailyArchive(); } );