import { logger } from "firebase-functions"; import { tierOf } from "../constants/tiers"; import { getSeasonConfig, listSeasonHistoryEntries, listUserSeasonHistory, markSeasonSettled, settleUsers, } from "../repositories/seasonRepository"; import { listAllRankedUsers } from "../repositories/userRepository"; import { invalidateStats } from "./statsService"; import { toSeasonRecordDto, type SeasonRecordDto } from "../types/dto/statsDto"; import type { DateString } from "../types/dateString"; import type { SeasonHistoryDoc } from "../types/season"; /** GET /stats/seasons — 유저의 시즌별 최종 성적(최근 시즌부터). 정산 전에는 빈 목록. */ export async function getSeasonRecords( uid: string ): Promise { const docs = await listUserSeasonHistory(uid); return docs.map(toSeasonRecordDto); } /** * 시즌 종료 정산. `dailyArchive`가 매일 호출하지만 실제 실행 조건은 * "`config/season`이 존재 + `settledAt` 없음 + today > endDate"의 단 한 번이다. * * 동작: 랭킹 대상(active && tierPoints > 0) 전원의 최종 성적을 * `users/{uid}/seasonHistory/{seasonId}`에 아카이브한 뒤 `tierPoints`를 0으로 * 리셋하고 `rankSnapshot`을 제거한다. 스트릭은 시즌과 무관하므로 유지한다 * (이월 이득은 streakBonus 상한이 제한 — constants/judgment.ts 참고). * * 재실행 안전성: 유저 단위(아카이브+리셋)가 같은 배치로 묶이므로 중간 실패 시 * "정산 완료(0pt + history)"와 "미정산(점수 유지)" 유저만 존재한다. 재실행에서는 * 이미 정산된 유저의 최종 점수를 seasonHistory에서 되읽어 순위 산정에 합류시켜 * 미정산 유저도 원래와 같은 rank/totalRanked를 받는다. `settledAt` 마커는 * 전원 완료 후에만 기록한다. */ export async function maybeSettleSeason( today: DateString ): Promise<{ settled: boolean; users: number }> { const config = await getSeasonConfig(); if (!config || config.settledAt) return { settled: false, users: 0 }; // DateString(YYYY-MM-DD)은 사전순 비교가 곧 날짜 비교다. if (today <= config.endDate) return { settled: false, users: 0 }; const [live, alreadySettled] = await Promise.all([ listAllRankedUsers(), listSeasonHistoryEntries(config.id), ]); const settledUids = new Set(alreadySettled.map((e) => e.uid)); const standings = [ ...live.filter((e) => !settledUids.has(e.uid)), ...alreadySettled, ].sort((a, b) => b.tierPoints - a.tierPoints); const totalRanked = standings.length; const entries: Array<{ uid: string; history: Omit; }> = []; let rank = 0; let prevPoints = -1; standings.forEach((e, i) => { if (e.tierPoints !== prevPoints) { rank = i + 1; prevPoints = e.tierPoints; } // 이미 아카이브된 유저는 첫 실행 때 같은 순위표로 rank가 확정되어 있다. if (settledUids.has(e.uid)) return; entries.push({ uid: e.uid, history: { seasonId: config.id, tierPoints: e.tierPoints, tier: tierOf(e.tierPoints), rank, totalRanked, }, }); }); await settleUsers(entries); await markSeasonSettled(); // 리셋된 tier/tierPoints가 /stats 캐시에 남지 않도록 무효화(실패 무시). await Promise.all( entries.map((e) => invalidateStats(e.uid).catch(() => undefined)) ); logger.info( `season ${config.id} settled: ${entries.length} users archived+reset (totalRanked=${totalRanked})` ); return { settled: true, users: entries.length }; }