mmday-firebase/src/services/seasonService.ts
윤정민 477fcabc78 Add season records endpoint
- GET /stats/seasons 추가 — 유저의 시즌별 최종 성적(seasonHistory)을 최근 시즌부터 반환
- SeasonRecordDto로 응답 형태 고정, settledAt(Timestamp)은 UTC ISO 문자열로 변환해 유출 방지
- 정산 전 빈 목록·정렬·DTO 변환 테스트 추가
2026-07-23 14:55:13 +09:00

94 lines
3.6 KiB
TypeScript

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<SeasonRecordDto[]> {
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<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 };
}