Add season-end settlement and tierPoints reset

- config/season 문서(id/startDate/endDate)로 시즌 경계 정의 — 문서가 없으면 시즌제 비활성(기존 동작 유지)
- endDate 다음 날 dailyArchive에서 maybeSettleSeason 1회 실행: 랭킹 대상 전원의 최종 성적(tierPoints·티어·동점 동일 rank)을 users/{uid}/seasonHistory/{seasonId}에 아카이브 후 tierPoints 0 리셋·rankSnapshot 제거
- 유저별 아카이브+리셋을 같은 배치로 묶고 settledAt 마커는 전원 완료 후 기록 — 부분 실패 재실행 시 기정산 유저 점수를 collectionGroup으로 되읽어 순위 보존(멱등)
- 스트릭은 시즌과 무관하게 유지(이월 이득은 streakBonus 상한이 제한)
- seasonHistory 본인 읽기 전용 rules와 seasonId collectionGroup 인덱스 추가, 정산 시나리오 테스트 신규 작성
This commit is contained in:
윤정민 2026-07-23 14:46:22 +09:00
parent a38d7ade28
commit cf5f10af64
8 changed files with 429 additions and 0 deletions

View File

@ -124,6 +124,13 @@
}
],
"fieldOverrides": [
{
"collectionGroup": "seasonHistory",
"fieldPath": "seasonId",
"indexes": [
{ "order": "ASCENDING", "queryScope": "COLLECTION_GROUP" }
]
},
{
"collectionGroup": "products",
"fieldPath": "mainImages",

View File

@ -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;

View File

@ -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<SeasonConfig | null> {
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<void> {
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<Array<{ uid: string; tierPoints: number }>> {
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<SeasonHistoryDoc>).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<SeasonHistoryDoc, "settledAt"> }>
): Promise<void> {
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();
}
}

View File

@ -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<User>).tierPoints ?? 0,
});
}
if (snap.docs.length < PAGE) return results;
last = snap.docs[snap.docs.length - 1];
}
}
/**
* `tierPoints > threshold` . `teamCode`
* . Firestore count aggregation .

View File

@ -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) {

View File

@ -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<SeasonHistoryDoc, "settledAt">;
}> = [];
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 };
}

39
src/types/season.ts Normal file
View File

@ -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;
}

View File

@ -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<string, unknown> = {}
): Promise<void> {
await firestore.collection("config").doc("season").set({
id: "2026",
startDate: "2026-03-28",
endDate: END,
...patch,
});
}
async function seedUser(
uid: string,
patch: Partial<User> & Record<string, unknown> = {}
): Promise<void> {
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<Partial<User>> {
const snap = await firestore.collection("users").doc(uid).get();
return (snap.data() ?? {}) as Partial<User>;
}
async function readHistory(
uid: string,
seasonId = "2026"
): Promise<Partial<SeasonHistoryDoc> | null> {
const snap = await firestore
.collection("users")
.doc(uid)
.collection("seasonHistory")
.doc(seasonId)
.get();
return snap.exists ? (snap.data() as Partial<SeasonHistoryDoc>) : 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);
});
});