mmday-firebase/src/repositories/seasonRepository.ts
윤정민 cf5f10af64 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 인덱스 추가, 정산 시나리오 테스트 신규 작성
2026-07-23 14:46:22 +09:00

86 lines
3.1 KiB
TypeScript

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();
}
}