- GET /stats/seasons 추가 — 유저의 시즌별 최종 성적(seasonHistory)을 최근 시즌부터 반환 - SeasonRecordDto로 응답 형태 고정, settledAt(Timestamp)은 UTC ISO 문자열로 변환해 유출 방지 - 정산 전 빈 목록·정렬·DTO 변환 테스트 추가
94 lines
3.6 KiB
TypeScript
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 };
|
|
}
|