Add season records endpoint

- GET /stats/seasons 추가 — 유저의 시즌별 최종 성적(seasonHistory)을 최근 시즌부터 반환
- SeasonRecordDto로 응답 형태 고정, settledAt(Timestamp)은 UTC ISO 문자열로 변환해 유출 방지
- 정산 전 빈 목록·정렬·DTO 변환 테스트 추가
This commit is contained in:
윤정민 2026-07-23 14:55:13 +09:00
parent 5a02b074bf
commit 477fcabc78
5 changed files with 97 additions and 2 deletions

View File

@ -2,6 +2,7 @@ import { onRequest } from "firebase-functions/https";
import { requireAuth } from "../middleware/auth";
import { sendError } from "../middleware/errors";
import { getStats, getHistory } from "../services/statsService";
import { getSeasonRecords } from "../services/seasonService";
import type { UserStatsDto, VoteHistoryDto } from "../types/dto/statsDto";
export const stats = onRequest(async (req, res) => {
@ -18,6 +19,11 @@ export const stats = onRequest(async (req, res) => {
return;
}
if (tail === "seasons" && req.method === "GET") {
res.status(200).json({ seasons: await getSeasonRecords(uid) });
return;
}
if ((tail === "stats" || segs.length === 1) && req.method === "GET") {
const period = req.query.period ? String(req.query.period) : undefined;
const result: UserStatsDto = await getStats(uid, period);

View File

@ -56,6 +56,19 @@ export async function listSeasonHistoryEntries(
.filter((e) => e.uid.length > 0);
}
/** 유저의 시즌별 최종 성적 목록 — 최근 시즌부터(seasonId 내림차순). */
export async function listUserSeasonHistory(
uid: string
): Promise<SeasonHistoryDoc[]> {
const snap = await firestore
.collection("users")
.doc(uid)
.collection("seasonHistory")
.orderBy("seasonId", "desc")
.get();
return snap.docs.map((d) => d.data() as SeasonHistoryDoc);
}
/** 배치 한도(500 write)의 여유분 — 유저당 2 write(아카이브 + 리셋). */
const SETTLE_CHUNK = 200;

View File

@ -3,14 +3,24 @@ 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" .

View File

@ -1,5 +1,6 @@
import { toIsoOrUndefined } from "./iso";
import type { DailyJudgment, StatsResponse, VoteHistoryDoc } from "../panit";
import type { DailyJudgment, StatsResponse, TierName, VoteHistoryDoc } from "../panit";
import type { SeasonHistoryDoc } from "../season";
/** GET /stats 응답. Timestamp 유출이 없어 기존 타입을 그대로 별칭한다. */
export type UserStatsDto = StatsResponse;
@ -30,6 +31,31 @@ export interface VoteHistoryDto {
/** 문서가 없는 날은 빈 목록으로 응답한다 (기존 동작 유지). */
export const EMPTY_VOTE_HISTORY_DTO: VoteHistoryDto = { data: [] };
/**
* GET /stats/seasons .
* `settledAt`(Timestamp) UTC ISO 8601 .
*/
export interface SeasonRecordDto {
seasonId: string;
tierPoints: number;
tier: TierName;
rank: number;
totalRanked: number;
settledAt?: string;
}
/** 필드를 명시적으로 나열한다 — 스프레드의 Timestamp 유출 방지(toVoteHistoryDto와 동일 이유). */
export function toSeasonRecordDto(doc: SeasonHistoryDoc): SeasonRecordDto {
return {
seasonId: doc.seasonId,
tierPoints: doc.tierPoints,
tier: doc.tier,
rank: doc.rank,
totalRanked: doc.totalRanked,
settledAt: toIsoOrUndefined(doc.settledAt),
};
}
/** 필드를 명시적으로 나열한다 — 스프레드를 쓰면 문서에 새로 생긴 Timestamp 가 그대로 유출된다. */
export function toVoteHistoryDto(doc: VoteHistoryDoc): VoteHistoryDto {
return {

View File

@ -1,7 +1,10 @@
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 {
getSeasonRecords,
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";
@ -164,4 +167,41 @@ describe("seasonService.maybeSettleSeason", () => {
expect((await readHistory("done"))?.rank).toBe(1);
expect((await readUser("b")).tierPoints).toBe(0);
});
it("getSeasonRecords: 정산 전 빈 목록, 정산 후 최근 시즌부터 DTO 반환", async () => {
await seedUser("a");
expect(await getSeasonRecords("a")).toEqual([]);
const history = firestore
.collection("users")
.doc("a")
.collection("seasonHistory");
await history.doc("2025").set({
seasonId: "2025",
tierPoints: 300,
tier: "PR",
rank: 5,
totalRanked: 10,
settledAt: Timestamp.now(),
});
await history.doc("2026").set({
seasonId: "2026",
tierPoints: 900,
tier: "ST",
rank: 2,
totalRanked: 12,
settledAt: Timestamp.now(),
});
const records = await getSeasonRecords("a");
expect(records.map((r) => r.seasonId)).toEqual(["2026", "2025"]);
expect(records[0]).toMatchObject({
tierPoints: 900,
tier: "ST",
rank: 2,
totalRanked: 12,
});
// Timestamp가 그대로 유출되지 않고 ISO 문자열로 변환된다.
expect(typeof records[0].settledAt).toBe("string");
});
});