diff --git a/firestore.indexes.json b/firestore.indexes.json index bfafeea..e1b99f6 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -124,6 +124,13 @@ } ], "fieldOverrides": [ + { + "collectionGroup": "seasonHistory", + "fieldPath": "seasonId", + "indexes": [ + { "order": "ASCENDING", "queryScope": "COLLECTION_GROUP" } + ] + }, { "collectionGroup": "products", "fieldPath": "mainImages", diff --git a/firestore.rules b/firestore.rules index 32d78a2..6475eb1 100644 --- a/firestore.rules +++ b/firestore.rules @@ -22,6 +22,12 @@ service cloud.firestore { allow write: if false; } + // 시즌 종료 시 서버가 기록하는 최종 성적 아카이브 — 본인 조회 전용. + match /seasonHistory/{seasonId} { + allow read: if request.auth != null && request.auth.uid == uid; + allow write: if false; + } + match /attendance/{month} { allow read: if request.auth != null && request.auth.uid == uid; allow write: if false; diff --git a/src/repositories/seasonRepository.ts b/src/repositories/seasonRepository.ts new file mode 100644 index 0000000..e05ebfe --- /dev/null +++ b/src/repositories/seasonRepository.ts @@ -0,0 +1,85 @@ +import { FieldValue, Timestamp } from "firebase-admin/firestore"; +import { firestore } from "../firebase"; +import { isDateString } from "../types/dateString"; +import type { SeasonConfig, SeasonHistoryDoc } from "../types/season"; + +const CONFIG_COLLECTION = "config"; +const CONFIG_DOC = "season"; + +/** + * `config/season`을 읽는다. 문서가 없거나 필수 필드가 온전치 않으면 + * 시즌제 비활성으로 취급해 `null`을 반환한다. + */ +export async function getSeasonConfig(): Promise { + const snap = await firestore + .collection(CONFIG_COLLECTION) + .doc(CONFIG_DOC) + .get(); + if (!snap.exists) return null; + const data = snap.data() ?? {}; + if (typeof data.id !== "string" || data.id.length === 0) return null; + if (!isDateString(data.startDate) || !isDateString(data.endDate)) return null; + const config: SeasonConfig = { + id: data.id, + startDate: data.startDate, + endDate: data.endDate, + }; + if (data.settledAt instanceof Timestamp) config.settledAt = data.settledAt; + return config; +} + +/** 정산 완료 마커 기록 — 이후 `maybeSettleSeason`은 no-op이 된다. */ +export async function markSeasonSettled(): Promise { + await firestore + .collection(CONFIG_COLLECTION) + .doc(CONFIG_DOC) + .set({ settledAt: FieldValue.serverTimestamp() }, { merge: true }); +} + +/** + * 해당 시즌의 `seasonHistory`가 이미 기록된 유저와 그 최종 점수 목록. + * 정산이 중간에 실패했던 경우, 재실행에서 이들의 점수를 순위 산정에 + * 다시 합류시키기 위한 조회다(collection group — seasonId 단일 필드 인덱스 필요). + */ +export async function listSeasonHistoryEntries( + seasonId: string +): Promise> { + const snap = await firestore + .collectionGroup("seasonHistory") + .where("seasonId", "==", seasonId) + .get(); + return snap.docs + .map((d) => ({ + uid: d.ref.parent.parent?.id ?? "", + tierPoints: (d.data() as Partial).tierPoints ?? 0, + })) + .filter((e) => e.uid.length > 0); +} + +/** 배치 한도(500 write)의 여유분 — 유저당 2 write(아카이브 + 리셋). */ +const SETTLE_CHUNK = 200; + +/** + * 유저별 시즌 정산 쓰기: `seasonHistory/{seasonId}` 아카이브와 + * `tierPoints` 리셋(+`rankSnapshot` 제거)을 같은 배치에 묶는다. + * 중간 실패 시에도 유저 단위로는 "정산 완료" 아니면 "점수 유지"만 존재한다. + */ +export async function settleUsers( + entries: Array<{ uid: string; history: Omit }> +): Promise { + for (let i = 0; i < entries.length; i += SETTLE_CHUNK) { + const batch = firestore.batch(); + for (const { uid, history } of entries.slice(i, i + SETTLE_CHUNK)) { + const userRef = firestore.collection("users").doc(uid); + batch.set(userRef.collection("seasonHistory").doc(history.seasonId), { + ...history, + settledAt: FieldValue.serverTimestamp(), + }); + batch.update(userRef, { + tierPoints: 0, + rankSnapshot: FieldValue.delete(), + }); + } + await batch.commit(); + } +} diff --git a/src/repositories/userRepository.ts b/src/repositories/userRepository.ts index 3c9ce01..a5b5db9 100644 --- a/src/repositories/userRepository.ts +++ b/src/repositories/userRepository.ts @@ -123,6 +123,39 @@ export async function listTopByTierPoints( }); } +/** + * 랭킹 대상(active && tierPoints > 0) 전원을 tierPoints 내림차순으로 반환한다. + * "랭킹 대상" 정의를 스코어보드(`listTopByTierPoints`/`countRankedUsers`)와 + * 공유한다 — 시즌 정산도 같은 모집단을 리셋해야 순위와 리셋 범위가 일치한다. + * 시즌 정산 전용: 페이지 단위로 나눠 읽되 결과는 전량 메모리에 올린다. + */ +export async function listAllRankedUsers(): Promise< + Array<{ uid: string; tierPoints: number }> +> { + const PAGE = 500; + const results: Array<{ uid: string; tierPoints: number }> = []; + let last: FirebaseFirestore.QueryDocumentSnapshot | undefined; + for (;;) { + let query = firestore + .collection(COLLECTION) + .where("active", "==", true) + .where("tierPoints", ">", 0) + .orderBy("tierPoints", "desc") + .select("tierPoints") + .limit(PAGE); + if (last) query = query.startAfter(last); + const snap = await query.get(); + for (const d of snap.docs) { + results.push({ + uid: d.id, + tierPoints: (d.data() as Partial).tierPoints ?? 0, + }); + } + if (snap.docs.length < PAGE) return results; + last = snap.docs[snap.docs.length - 1]; + } +} + /** * `tierPoints > threshold`인 유저 수를 반환한다. `teamCode` 지정 시 해당 * 팀을 응원하는 유저로 한정한다. Firestore count aggregation 사용. diff --git a/src/scheduled/dailyArchive.ts b/src/scheduled/dailyArchive.ts index 6ef353a..b7ba322 100644 --- a/src/scheduled/dailyArchive.ts +++ b/src/scheduled/dailyArchive.ts @@ -8,6 +8,7 @@ import { precomputeScoreboardCache, computeRankSnapshot, } from "../services/rankSnapshotService"; +import { maybeSettleSeason } from "../services/seasonService"; import { todayKst } from "../types/dateString"; import { getGame, createGameDayCache } from "../repositories/gameRepository"; import { processGameEndWithGame } from "../services/gameResultService"; @@ -164,6 +165,14 @@ export async function runDailyArchive( logger.info(`dailyArchive done: ${archived} users archived for ${date}`); return { date, archived, judgedUids }; } finally { + // 시즌 종료 다음 날 첫 archive: 어제(=시즌 마지막 날) 판정을 반영한 뒤 + // 정산·리셋하고, 이어지는 캐시 재계산이 리셋된(새 시즌) 보드를 만든다. + // 정산 실패가 캐시 재계산을 막지 않도록 각각 독립적으로 감싼다. + try { + await maybeSettleSeason(todayKst()); + } catch (err) { + logger.error("maybeSettleSeason failed", err); + } try { await precomputeScoreboardCache(todayKst()); } catch (err) { diff --git a/src/services/seasonService.ts b/src/services/seasonService.ts new file mode 100644 index 0000000..36d8949 --- /dev/null +++ b/src/services/seasonService.ts @@ -0,0 +1,83 @@ +import { logger } from "firebase-functions"; +import { tierOf } from "../constants/tiers"; +import { + getSeasonConfig, + listSeasonHistoryEntries, + markSeasonSettled, + settleUsers, +} from "../repositories/seasonRepository"; +import { listAllRankedUsers } from "../repositories/userRepository"; +import { invalidateStats } from "./statsService"; +import type { DateString } from "../types/dateString"; +import type { SeasonHistoryDoc } from "../types/season"; + +/** + * 시즌 종료 정산. `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 }; +} diff --git a/src/types/season.ts b/src/types/season.ts new file mode 100644 index 0000000..0a12283 --- /dev/null +++ b/src/types/season.ts @@ -0,0 +1,39 @@ +import type { Timestamp } from "firebase-admin/firestore"; +import type { DateString } from "./dateString"; +import type { TierName } from "./panit"; + +/** + * `config/season` — 현재 KBO 시즌의 경계 설정. + * + * 문서가 없으면 시즌제 비활성(정산·리셋이 일어나지 않음). 새 시즌 시작 시 + * 운영자가 새 `id`/기간으로 문서를 덮어쓴다(이때 `settledAt`도 함께 제거). + */ +export interface SeasonConfig { + /** 시즌 식별자 (예: "2026"). `seasonHistory` 문서 id로도 쓰인다. */ + id: string; + /** 시즌 시작일(KST, 개막일). 정산 트리거는 `endDate`만 보므로 참고용. */ + startDate: DateString; + /** 시즌 종료일(KST, 한국시리즈 종료일). 이 날짜가 지난 뒤 최초 archive에서 정산된다. */ + endDate: DateString; + /** 정산 완료 시각. 존재하면 이 시즌은 다시 정산하지 않는다(멱등 마커). */ + settledAt?: Timestamp; +} + +/** + * `users/{uid}/seasonHistory/{seasonId}` — 시즌 종료 시점의 최종 성적 아카이브. + * + * 정산(`maybeSettleSeason`) 시 서버만 기록한다. 이후 시즌 보상 지급·명예의 전당 + * 등재의 근거 데이터가 된다. 시즌 정산 보상은 현 단계에서 미구현(기획 보류). + */ +export interface SeasonHistoryDoc { + seasonId: string; + /** 시즌 최종 tierPoints (리셋 직전 값). */ + tierPoints: number; + /** 최종 티어 코드. */ + tier: TierName; + /** 전체 랭킹 — 동점자 동일 rank("above count + 1"), 스코어보드와 같은 규칙. */ + rank: number; + /** 랭킹 대상(active && tierPoints > 0) 총 인원. */ + totalRanked: number; + settledAt: Timestamp; +} diff --git a/tests/services/seasonService.test.ts b/tests/services/seasonService.test.ts new file mode 100644 index 0000000..07bf1b5 --- /dev/null +++ b/tests/services/seasonService.test.ts @@ -0,0 +1,167 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { FieldValue, Timestamp } from "firebase-admin/firestore"; +import { firestore } from "../../src/firebase"; +import { maybeSettleSeason } from "../../src/services/seasonService"; +import type { DateString } from "../../src/types/dateString"; +import type { SeasonHistoryDoc } from "../../src/types/season"; +import type { User } from "../../src/types/panit"; + +const END: DateString = "2026-10-30" as DateString; +const BEFORE_END: DateString = "2026-10-30" as DateString; +const AFTER_END: DateString = "2026-10-31" as DateString; + +async function seedSeasonConfig( + patch: Record = {} +): Promise { + await firestore.collection("config").doc("season").set({ + id: "2026", + startDate: "2026-03-28", + endDate: END, + ...patch, + }); +} + +async function seedUser( + uid: string, + patch: Partial & Record = {} +): Promise { + await firestore.collection("users").doc(uid).set({ + displayName: uid, + email: `${uid}@e.com`, + provider: "google", + knowledgeLevel: "beginner", + createdAt: Timestamp.now(), + active: true, + ...patch, + }); +} + +async function readUser(uid: string): Promise> { + const snap = await firestore.collection("users").doc(uid).get(); + return (snap.data() ?? {}) as Partial; +} + +async function readHistory( + uid: string, + seasonId = "2026" +): Promise | null> { + const snap = await firestore + .collection("users") + .doc(uid) + .collection("seasonHistory") + .doc(seasonId) + .get(); + return snap.exists ? (snap.data() as Partial) : null; +} + +describe("seasonService.maybeSettleSeason", () => { + beforeEach(async () => { + await firestore.recursiveDelete(firestore.collection("users")); + await firestore.recursiveDelete( + firestore.collection("config").doc("season") + ); + }); + + it("config/season이 없으면 no-op", async () => { + await seedUser("a", { tierPoints: 600 }); + const result = await maybeSettleSeason(AFTER_END); + expect(result.settled).toBe(false); + expect((await readUser("a")).tierPoints).toBe(600); + }); + + it("endDate 당일까지는 no-op (마지막 날 판정 완료 후 다음 날 정산)", async () => { + await seedSeasonConfig(); + await seedUser("a", { tierPoints: 600 }); + const result = await maybeSettleSeason(BEFORE_END); + expect(result.settled).toBe(false); + expect((await readUser("a")).tierPoints).toBe(600); + expect(await readHistory("a")).toBeNull(); + }); + + it("endDate 다음 날 정산: 아카이브·rank(동점 동일)·리셋·settledAt", async () => { + await seedSeasonConfig(); + await seedUser("gold", { + tierPoints: 600, + rankSnapshot: { date: END, overall: 1 }, + }); + await seedUser("tie1", { tierPoints: 200 }); + await seedUser("tie2", { tierPoints: 200 }); + // 비활성 유저는 랭킹 모집단이 아니므로 정산·리셋 대상도 아니다. + await seedUser("ghost", { tierPoints: 999, active: false }); + + const result = await maybeSettleSeason(AFTER_END); + expect(result).toEqual({ settled: true, users: 3 }); + + const gold = await readHistory("gold"); + expect(gold).toMatchObject({ + seasonId: "2026", + tierPoints: 600, + tier: "ST", // 500 <= 600 < 1200 + rank: 1, + totalRanked: 3, + }); + expect((await readHistory("tie1"))?.rank).toBe(2); + expect((await readHistory("tie2"))?.rank).toBe(2); + expect((await readHistory("tie1"))?.tier).toBe("PR"); // 150 <= 200 < 500 + + const goldUser = await readUser("gold"); + expect(goldUser.tierPoints).toBe(0); + expect(goldUser.rankSnapshot).toBeUndefined(); + expect((await readUser("tie1")).tierPoints).toBe(0); + + expect((await readUser("ghost")).tierPoints).toBe(999); + expect(await readHistory("ghost")).toBeNull(); + + const config = await firestore.collection("config").doc("season").get(); + expect(config.data()?.settledAt).toBeInstanceOf(Timestamp); + }); + + it("settledAt이 있으면 재실행해도 no-op (멱등)", async () => { + await seedSeasonConfig({ settledAt: Timestamp.now() }); + await seedUser("a", { tierPoints: 600 }); + const result = await maybeSettleSeason(AFTER_END); + expect(result.settled).toBe(false); + expect((await readUser("a")).tierPoints).toBe(600); + expect(await readHistory("a")).toBeNull(); + }); + + it("부분 실패 재실행: 기정산 유저 점수를 순위에 합류시켜 rank가 보존된다", async () => { + await seedSeasonConfig(); + // 첫 실행에서 1위(600pt)만 정산된 뒤 실패했다고 가정 — + // 이미 아카이브 + 리셋된 상태이고 settledAt은 아직 없다. + await seedUser("done", { tierPoints: 0 }); + await firestore + .collection("users") + .doc("done") + .collection("seasonHistory") + .doc("2026") + .set({ + seasonId: "2026", + tierPoints: 600, + tier: "ST", + rank: 1, + totalRanked: 3, + settledAt: FieldValue.serverTimestamp(), + }); + await seedUser("b", { tierPoints: 300 }); + await seedUser("c", { tierPoints: 100 }); + + const result = await maybeSettleSeason(AFTER_END); + expect(result).toEqual({ settled: true, users: 2 }); + + // done(600)이 순위표에 합류했으므로 b는 2위, c는 3위가 된다. + expect(await readHistory("b")).toMatchObject({ + tierPoints: 300, + rank: 2, + totalRanked: 3, + }); + expect(await readHistory("c")).toMatchObject({ + tierPoints: 100, + rank: 3, + totalRanked: 3, + }); + // 기정산 유저의 아카이브는 덮어쓰지 않는다. + expect((await readHistory("done"))?.rank).toBe(1); + expect((await readUser("b")).tierPoints).toBe(0); + }); +});