- 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 인덱스 추가, 정산 시나리오 테스트 신규 작성
86 lines
3.1 KiB
TypeScript
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();
|
|
}
|
|
}
|