Implement scoreboard system with rank snapshots and delta tracking.
- 전체 및 팀별 스코어보드 기능을 구현하고, 순위 변동(delta)을 시각화하기 위한 스냅샷 시스템을 도입했습니다. - `dailyArchive` 과정에서 사용자의 현재 순위를 `rankSnapshot`으로 기록하여, 다음 판정 시점의 순위 변화량을 정확히 계산합니다. - 상위권 데이터는 RTDB에 사전 계산(precompute)하여 저장함으로써 대규모 조회 요청에 대응하고, `MemCache`를 통해 API 응답 성능을 최적화했습니다. - Firestore 복합 색인과 Count Aggregation을 활용하여 동점자 처리가 포함된 랭킹 및 상위 퍼센타일 산출 로직을 구현했습니다. - 관련 서비스 레이어, 저장소 함수, API 핸들러 및 단위 테스트 코드를 추가했습니다.
This commit is contained in:
parent
392ecfbd0b
commit
b25e78dde8
@ -46,6 +46,15 @@
|
|||||||
// ]
|
// ]
|
||||||
// },
|
// },
|
||||||
// ]
|
// ]
|
||||||
"indexes": [],
|
"indexes": [
|
||||||
|
{
|
||||||
|
"collectionGroup": "users",
|
||||||
|
"queryScope": "COLLECTION",
|
||||||
|
"fields": [
|
||||||
|
{ "fieldPath": "favoriteTeamCode", "order": "ASCENDING" },
|
||||||
|
{ "fieldPath": "tierPoints", "order": "DESCENDING" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
"fieldOverrides": []
|
"fieldOverrides": []
|
||||||
}
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { onRequest } from "firebase-functions/https";
|
import { onRequest } from "firebase-functions/https";
|
||||||
import { requireAuth } from "../middleware/auth.js";
|
import { requireAuth } from "../middleware/auth.js";
|
||||||
import { sendError } from "../middleware/errors.js";
|
import { HttpError, sendError } from "../middleware/errors.js";
|
||||||
import {
|
import {
|
||||||
createPrediction,
|
createPrediction,
|
||||||
updatePrediction,
|
updatePrediction,
|
||||||
@ -8,6 +8,7 @@ import {
|
|||||||
getSummary,
|
getSummary,
|
||||||
listGamesByDate,
|
listGamesByDate,
|
||||||
} from "../services/predictionService.js";
|
} from "../services/predictionService.js";
|
||||||
|
import { getScoreboard } from "../services/scoreboardService.js";
|
||||||
|
|
||||||
export const prediction = onRequest(async (req, res) => {
|
export const prediction = onRequest(async (req, res) => {
|
||||||
const segs = req.path.replace(/^\/+|\/+$/g, "").split("/");
|
const segs = req.path.replace(/^\/+|\/+$/g, "").split("/");
|
||||||
@ -21,6 +22,18 @@ export const prediction = onRequest(async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tail === "scoreboard" && req.method === "GET") {
|
||||||
|
const uid = await requireAuth(req);
|
||||||
|
const type = String(req.query.type ?? "");
|
||||||
|
if (type !== "team" && type !== "overall") {
|
||||||
|
throw new HttpError(400, `invalid type: ${type}`);
|
||||||
|
}
|
||||||
|
const result = await getScoreboard(uid, type);
|
||||||
|
res.set("Cache-Control", "private, max-age=60");
|
||||||
|
res.status(200).json(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (tail === "summary" && req.method === "GET") {
|
if (tail === "summary" && req.method === "GET") {
|
||||||
const gameId = String(req.query.gameId ?? "");
|
const gameId = String(req.query.gameId ?? "");
|
||||||
const result = await getSummary(gameId);
|
const result = await getSummary(gameId);
|
||||||
|
|||||||
36
src/repositories/scoreboardCacheRepository.ts
Normal file
36
src/repositories/scoreboardCacheRepository.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { rtdb } from "../firebase.js";
|
||||||
|
import type { DateString } from "../types/dateString.js";
|
||||||
|
import type { TeamCode } from "../types/panit.js";
|
||||||
|
import type { ScoreboardScopeCache } from "../types/scoreboard.js";
|
||||||
|
|
||||||
|
const BASE = "/scoreboardCache";
|
||||||
|
|
||||||
|
export type Scope = { kind: "overall" } | { kind: "team"; teamCode: TeamCode };
|
||||||
|
|
||||||
|
function pathFor(date: DateString, scope: Scope): string {
|
||||||
|
if (scope.kind === "overall") return `${BASE}/${date}/overall`;
|
||||||
|
return `${BASE}/${date}/team/${scope.teamCode}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readScope(
|
||||||
|
date: DateString,
|
||||||
|
scope: Scope
|
||||||
|
): Promise<ScoreboardScopeCache | null> {
|
||||||
|
const snap = await rtdb.ref(pathFor(date, scope)).get();
|
||||||
|
if (!snap.exists()) return null;
|
||||||
|
const raw = snap.val() as ScoreboardScopeCache;
|
||||||
|
// RTDB는 null 값을 저장하지 않아 read 시 필드가 누락된다 — 복원.
|
||||||
|
raw.top = (raw.top ?? []).map((e) => ({
|
||||||
|
...e,
|
||||||
|
rankDelta: e.rankDelta ?? null,
|
||||||
|
}));
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeScope(
|
||||||
|
date: DateString,
|
||||||
|
scope: Scope,
|
||||||
|
doc: ScoreboardScopeCache
|
||||||
|
): Promise<void> {
|
||||||
|
await rtdb.ref(pathFor(date, scope)).set(doc);
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ import type {
|
|||||||
DailyJudgment,
|
DailyJudgment,
|
||||||
KnowledgeLevel,
|
KnowledgeLevel,
|
||||||
Provider,
|
Provider,
|
||||||
|
RankSnapshot,
|
||||||
TeamCode,
|
TeamCode,
|
||||||
User,
|
User,
|
||||||
} from "../types/panit";
|
} from "../types/panit";
|
||||||
@ -31,6 +32,76 @@ export async function getUser(uid: string): Promise<User | null> {
|
|||||||
return snap.exists ? (snap.data() as User) : null;
|
return snap.exists ? (snap.data() as User) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ScoreboardUserEntry {
|
||||||
|
uid: string;
|
||||||
|
displayName: string;
|
||||||
|
photoUrl?: string;
|
||||||
|
tierPoints: number;
|
||||||
|
favoriteTeamCode?: TeamCode;
|
||||||
|
rankSnapshot?: RankSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `tierPoints` 상위 N명을 조회한다. `teamCode` 지정 시 해당 팀을 응원하는
|
||||||
|
* 유저로 한정한다. `tierPoints`가 없는 유저는 결과에 포함되지 않는다.
|
||||||
|
* `rankSnapshot`도 함께 가져와 delta 계산에 사용 가능.
|
||||||
|
*/
|
||||||
|
export async function listTopByTierPoints(
|
||||||
|
limit: number,
|
||||||
|
teamCode?: TeamCode
|
||||||
|
): Promise<ScoreboardUserEntry[]> {
|
||||||
|
let query: FirebaseFirestore.Query = firestore.collection(COLLECTION);
|
||||||
|
if (teamCode) query = query.where("favoriteTeamCode", "==", teamCode);
|
||||||
|
const snap = await query
|
||||||
|
.orderBy("tierPoints", "desc")
|
||||||
|
.limit(limit)
|
||||||
|
.select(
|
||||||
|
"displayName",
|
||||||
|
"photoUrl",
|
||||||
|
"tierPoints",
|
||||||
|
"favoriteTeamCode",
|
||||||
|
"rankSnapshot"
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
return snap.docs.map((d) => {
|
||||||
|
const data = d.data() as Partial<User>;
|
||||||
|
const entry: ScoreboardUserEntry = {
|
||||||
|
uid: d.id,
|
||||||
|
displayName: data.displayName ?? "",
|
||||||
|
tierPoints: data.tierPoints ?? 0,
|
||||||
|
};
|
||||||
|
if (data.photoUrl) entry.photoUrl = data.photoUrl;
|
||||||
|
if (data.favoriteTeamCode) entry.favoriteTeamCode = data.favoriteTeamCode;
|
||||||
|
if (data.rankSnapshot) entry.rankSnapshot = data.rankSnapshot;
|
||||||
|
return entry;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `tierPoints > threshold`인 유저 수를 반환한다. `teamCode` 지정 시 해당
|
||||||
|
* 팀을 응원하는 유저로 한정한다. Firestore count aggregation 사용.
|
||||||
|
*/
|
||||||
|
export async function countUsersAboveTierPoints(
|
||||||
|
threshold: number,
|
||||||
|
teamCode?: TeamCode
|
||||||
|
): Promise<number> {
|
||||||
|
let query: FirebaseFirestore.Query = firestore.collection(COLLECTION);
|
||||||
|
if (teamCode) query = query.where("favoriteTeamCode", "==", teamCode);
|
||||||
|
const agg = await query.where("tierPoints", ">", threshold).count().get();
|
||||||
|
return agg.data().count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `tierPoints > 0`인 유저(= 스코어보드 대상)의 총 수. `teamCode` 지정 시
|
||||||
|
* 해당 팀 내 총 수를 반환한다. percentile 계산용.
|
||||||
|
*/
|
||||||
|
export async function countRankedUsers(teamCode?: TeamCode): Promise<number> {
|
||||||
|
let query: FirebaseFirestore.Query = firestore.collection(COLLECTION);
|
||||||
|
if (teamCode) query = query.where("favoriteTeamCode", "==", teamCode);
|
||||||
|
const agg = await query.where("tierPoints", ">", 0).count().get();
|
||||||
|
return agg.data().count;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* displayName으로 유저의 uid를 조회한다. 없으면 `null`.
|
* displayName으로 유저의 uid를 조회한다. 없으면 `null`.
|
||||||
* 닉네임 유니크성 검증용 fallback.
|
* 닉네임 유니크성 검증용 fallback.
|
||||||
|
|||||||
@ -4,6 +4,11 @@ import { rtdb } from "../firebase.js";
|
|||||||
import { setDay } from "../repositories/voteHistoryRepository.js";
|
import { setDay } from "../repositories/voteHistoryRepository.js";
|
||||||
import { invalidateStats } from "../services/statsService.js";
|
import { invalidateStats } from "../services/statsService.js";
|
||||||
import { judgeDay, judgeWeekIfNeeded } from "../services/judgmentService.js";
|
import { judgeDay, judgeWeekIfNeeded } from "../services/judgmentService.js";
|
||||||
|
import {
|
||||||
|
precomputeScoreboardCache,
|
||||||
|
snapshotRankForUser,
|
||||||
|
} from "../services/rankSnapshotService.js";
|
||||||
|
import { todayKst } from "../types/dateString.js";
|
||||||
import { getGame } from "../repositories/gameRepository.js";
|
import { getGame } from "../repositories/gameRepository.js";
|
||||||
import { deleteUserVoteGame } from "../repositories/voteRepository.js";
|
import { deleteUserVoteGame } from "../repositories/voteRepository.js";
|
||||||
import { processGameEndWithGame } from "../services/gameResultService.js";
|
import { processGameEndWithGame } from "../services/gameResultService.js";
|
||||||
@ -114,6 +119,12 @@ export const dailyArchive = onSchedule(
|
|||||||
// 스트릭 유지 로직이 동작하도록 judgeDay를 호출한다.
|
// 스트릭 유지 로직이 동작하도록 judgeDay를 호출한다.
|
||||||
await setDay(uid, date, { data });
|
await setDay(uid, date, { data });
|
||||||
await rtdb.ref(`/userVotes/${uid}/${date}`).remove();
|
await rtdb.ref(`/userVotes/${uid}/${date}`).remove();
|
||||||
|
// 판정 전에 현재 rank를 rankSnapshot에 기록 — 판정 후 변화량을 delta로 노출하기 위함.
|
||||||
|
try {
|
||||||
|
await snapshotRankForUser(uid, date);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`snapshotRank failed uid=${uid} date=${date}`, err);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await judgeDay(uid, date, { data });
|
await judgeDay(uid, date, { data });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -135,6 +146,12 @@ export const dailyArchive = onSchedule(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await precomputeScoreboardCache(todayKst());
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("precomputeScoreboardCache failed", err);
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(`dailyArchive done: ${archived} users archived for ${date}`);
|
logger.info(`dailyArchive done: ${archived} users archived for ${date}`);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
148
src/services/rankSnapshotService.ts
Normal file
148
src/services/rankSnapshotService.ts
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
import { logger } from "firebase-functions";
|
||||||
|
import { firestore } from "../firebase.js";
|
||||||
|
import {
|
||||||
|
countRankedUsers,
|
||||||
|
countUsersAboveTierPoints,
|
||||||
|
getUser,
|
||||||
|
listTopByTierPoints,
|
||||||
|
type ScoreboardUserEntry,
|
||||||
|
} from "../repositories/userRepository.js";
|
||||||
|
import { writeScope, type Scope } from "../repositories/scoreboardCacheRepository.js";
|
||||||
|
import { computePercentile, deltaFor } from "./scoreboardHelpers.js";
|
||||||
|
import type { DateString } from "../types/dateString.js";
|
||||||
|
import { TeamCode, type RankSnapshot } from "../types/panit.js";
|
||||||
|
import type {
|
||||||
|
ScoreboardEntry,
|
||||||
|
ScoreboardScopeCache,
|
||||||
|
} from "../types/scoreboard.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 유저 1명의 현재 `tierPoints` 기반 rank를 count aggregation으로 계산해
|
||||||
|
* `rankSnapshot`에 기록한다. `dailyArchive`에서 `judgeDay` **이전에** 호출해
|
||||||
|
* 스냅샷이 "직전 상태의 rank"를 담도록 한다.
|
||||||
|
*
|
||||||
|
* `tierPoints === 0`이면 스냅샷을 남기지 않는다.
|
||||||
|
*/
|
||||||
|
export async function snapshotRankForUser(
|
||||||
|
uid: string,
|
||||||
|
date: DateString
|
||||||
|
): Promise<void> {
|
||||||
|
const user = await getUser(uid);
|
||||||
|
if (!user) return;
|
||||||
|
const tierPoints = user.tierPoints ?? 0;
|
||||||
|
if (tierPoints <= 0) return;
|
||||||
|
|
||||||
|
const overallAbove = await countUsersAboveTierPoints(tierPoints);
|
||||||
|
const snapshot: RankSnapshot = {
|
||||||
|
date,
|
||||||
|
overall: overallAbove + 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (user.favoriteTeamCode) {
|
||||||
|
const teamAbove = await countUsersAboveTierPoints(
|
||||||
|
tierPoints,
|
||||||
|
user.favoriteTeamCode
|
||||||
|
);
|
||||||
|
snapshot.team = teamAbove + 1;
|
||||||
|
snapshot.teamCode = user.favoriteTeamCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
await firestore
|
||||||
|
.collection("users")
|
||||||
|
.doc(uid)
|
||||||
|
.set({ rankSnapshot: snapshot }, { merge: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 여러 유저의 rank 스냅샷을 순차 기록. 한 명 실패해도 나머지는 계속.
|
||||||
|
*/
|
||||||
|
export async function snapshotRanksForUsers(
|
||||||
|
uids: string[],
|
||||||
|
date: DateString
|
||||||
|
): Promise<void> {
|
||||||
|
for (const uid of uids) {
|
||||||
|
try {
|
||||||
|
await snapshotRankForUser(uid, date);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`snapshotRank failed for uid=${uid}`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 동점자 동일 rank ("above count + 1"). 상위가 1위임을 가정한 in-memory 계산. */
|
||||||
|
function assignRanks(entries: ScoreboardUserEntry[]): number[] {
|
||||||
|
const ranks: number[] = [];
|
||||||
|
for (let i = 0; i < entries.length; i++) {
|
||||||
|
if (i === 0) {
|
||||||
|
ranks.push(1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (entries[i].tierPoints === entries[i - 1].tierPoints) {
|
||||||
|
ranks.push(ranks[i - 1]);
|
||||||
|
} else {
|
||||||
|
ranks.push(i + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ranks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enrichTop(
|
||||||
|
entries: ScoreboardUserEntry[],
|
||||||
|
totalCount: number,
|
||||||
|
scope: Scope
|
||||||
|
): ScoreboardEntry[] {
|
||||||
|
const ranks = assignRanks(entries);
|
||||||
|
return entries.map((e, i) => {
|
||||||
|
const rank = ranks[i];
|
||||||
|
const entry: ScoreboardEntry = {
|
||||||
|
uid: e.uid,
|
||||||
|
displayName: e.displayName,
|
||||||
|
tierPoints: e.tierPoints,
|
||||||
|
rank,
|
||||||
|
rankDelta: deltaFor(e.rankSnapshot, rank, scope),
|
||||||
|
percentile: computePercentile(rank, totalCount),
|
||||||
|
};
|
||||||
|
if (e.photoUrl) entry.photoUrl = e.photoUrl;
|
||||||
|
if (e.favoriteTeamCode) entry.favoriteTeamCode = e.favoriteTeamCode;
|
||||||
|
return entry;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildScopeCache(scope: Scope): Promise<ScoreboardScopeCache> {
|
||||||
|
const teamCode = scope.kind === "team" ? scope.teamCode : undefined;
|
||||||
|
const [top, totalCount] = await Promise.all([
|
||||||
|
listTopByTierPoints(10, teamCode),
|
||||||
|
countRankedUsers(teamCode),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
top: enrichTop(top, totalCount, scope),
|
||||||
|
totalCount,
|
||||||
|
generatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 크론에서 호출: overall + 10팀 각각의 top 10 / totalCount를 RTDB에 기록한다.
|
||||||
|
* `snapshotRanksForUsers` 이후에 호출해야 rankDelta가 정확함.
|
||||||
|
*/
|
||||||
|
export async function precomputeScoreboardCache(
|
||||||
|
date: DateString
|
||||||
|
): Promise<void> {
|
||||||
|
const scopes: Scope[] = [
|
||||||
|
{ kind: "overall" },
|
||||||
|
...Object.values(TeamCode).map(
|
||||||
|
(teamCode) => ({ kind: "team" as const, teamCode })
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const scope of scopes) {
|
||||||
|
try {
|
||||||
|
const doc = await buildScopeCache(scope);
|
||||||
|
await writeScope(date, scope, doc);
|
||||||
|
} catch (err) {
|
||||||
|
const label =
|
||||||
|
scope.kind === "overall" ? "overall" : `team/${scope.teamCode}`;
|
||||||
|
logger.error(`precomputeScoreboardCache failed scope=${label}`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/services/scoreboardHelpers.ts
Normal file
20
src/services/scoreboardHelpers.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import type { Scope } from "../repositories/scoreboardCacheRepository.js";
|
||||||
|
import type { RankSnapshot } from "../types/panit.js";
|
||||||
|
|
||||||
|
export function computePercentile(rank: number, totalCount: number): number {
|
||||||
|
if (totalCount <= 0) return 0;
|
||||||
|
return Math.round((1 - (rank - 1) / totalCount) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deltaFor(
|
||||||
|
snapshot: RankSnapshot | undefined,
|
||||||
|
currentRank: number,
|
||||||
|
scope: Scope
|
||||||
|
): number | null {
|
||||||
|
if (!snapshot) return null;
|
||||||
|
if (scope.kind === "overall") return snapshot.overall - currentRank;
|
||||||
|
if (snapshot.teamCode !== scope.teamCode || snapshot.team === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return snapshot.team - currentRank;
|
||||||
|
}
|
||||||
100
src/services/scoreboardService.ts
Normal file
100
src/services/scoreboardService.ts
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import { MemCache } from "../lib/memCache.js";
|
||||||
|
import {
|
||||||
|
countUsersAboveTierPoints,
|
||||||
|
getUser,
|
||||||
|
} from "../repositories/userRepository.js";
|
||||||
|
import {
|
||||||
|
readScope,
|
||||||
|
type Scope,
|
||||||
|
} from "../repositories/scoreboardCacheRepository.js";
|
||||||
|
import { HttpError } from "../middleware/errors.js";
|
||||||
|
import { computePercentile, deltaFor } from "./scoreboardHelpers.js";
|
||||||
|
import { todayKst } from "../types/dateString.js";
|
||||||
|
import { type TeamCode } from "../types/panit.js";
|
||||||
|
import type {
|
||||||
|
ScoreboardEntry,
|
||||||
|
ScoreboardResponse,
|
||||||
|
ScoreboardScopeCache,
|
||||||
|
ScoreboardType,
|
||||||
|
} from "../types/scoreboard.js";
|
||||||
|
|
||||||
|
const BURST_TTL_MS = 30_000;
|
||||||
|
|
||||||
|
const scopeCache = new MemCache<ScoreboardScopeCache>(BURST_TTL_MS);
|
||||||
|
const meRankCache = new MemCache<{ rank: number; tierPoints: number }>(
|
||||||
|
BURST_TTL_MS
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 테스트 전용: 캐시를 전부 비운다. */
|
||||||
|
export function __resetScoreboardCaches(): void {
|
||||||
|
// @ts-expect-error -- 내부 Map 접근.
|
||||||
|
scopeCache.cache.clear();
|
||||||
|
// @ts-expect-error
|
||||||
|
scopeCache.inflight.clear();
|
||||||
|
// @ts-expect-error
|
||||||
|
meRankCache.cache.clear();
|
||||||
|
// @ts-expect-error
|
||||||
|
meRankCache.inflight.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopeOf(type: ScoreboardType, teamCode: TeamCode | undefined): Scope {
|
||||||
|
if (type === "overall") return { kind: "overall" };
|
||||||
|
if (!teamCode) throw new HttpError(400, "no favorite team");
|
||||||
|
return { kind: "team", teamCode };
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopeKey(scope: Scope): string {
|
||||||
|
return scope.kind === "overall" ? "overall" : `team:${scope.teamCode}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getScoreboard(
|
||||||
|
uid: string,
|
||||||
|
type: ScoreboardType
|
||||||
|
): Promise<ScoreboardResponse> {
|
||||||
|
const user = await getUser(uid);
|
||||||
|
if (!user) throw new HttpError(404, `user not found: ${uid}`);
|
||||||
|
|
||||||
|
const teamCode = type === "team" ? user.favoriteTeamCode : undefined;
|
||||||
|
const scope = scopeOf(type, teamCode);
|
||||||
|
|
||||||
|
const date = todayKst();
|
||||||
|
const cacheKey = `${date}:${scopeKey(scope)}`;
|
||||||
|
|
||||||
|
const cached = await scopeCache.getOrFetch(cacheKey, async () => {
|
||||||
|
const doc = await readScope(date, scope);
|
||||||
|
if (!doc) throw new HttpError(503, "scoreboard not ready");
|
||||||
|
return doc;
|
||||||
|
});
|
||||||
|
|
||||||
|
const myPoints = user.tierPoints ?? 0;
|
||||||
|
let me: ScoreboardEntry | null = null;
|
||||||
|
if (myPoints > 0) {
|
||||||
|
const { rank } = await meRankCache.getOrFetch(
|
||||||
|
`${cacheKey}:${uid}`,
|
||||||
|
async () => {
|
||||||
|
const above = await countUsersAboveTierPoints(myPoints, teamCode);
|
||||||
|
return { rank: above + 1, tierPoints: myPoints };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const entry: ScoreboardEntry = {
|
||||||
|
uid,
|
||||||
|
displayName: user.displayName,
|
||||||
|
tierPoints: myPoints,
|
||||||
|
rank,
|
||||||
|
rankDelta: deltaFor(user.rankSnapshot, rank, scope),
|
||||||
|
percentile: computePercentile(rank, cached.totalCount),
|
||||||
|
};
|
||||||
|
if (user.photoUrl) entry.photoUrl = user.photoUrl;
|
||||||
|
if (user.favoriteTeamCode) entry.favoriteTeamCode = user.favoriteTeamCode;
|
||||||
|
me = entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: ScoreboardResponse = {
|
||||||
|
type,
|
||||||
|
totalCount: cached.totalCount,
|
||||||
|
top: cached.top,
|
||||||
|
me,
|
||||||
|
};
|
||||||
|
if (teamCode) response.teamCode = teamCode;
|
||||||
|
return response;
|
||||||
|
}
|
||||||
@ -46,6 +46,18 @@ export interface User {
|
|||||||
tickets?: TicketMap;
|
tickets?: TicketMap;
|
||||||
lastJudgedDate?: DateString;
|
lastJudgedDate?: DateString;
|
||||||
lastWeeklyMasterTuesday?: DateString;
|
lastWeeklyMasterTuesday?: DateString;
|
||||||
|
rankSnapshot?: RankSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RankSnapshot {
|
||||||
|
/** 스냅샷 생성 날짜 (KST). */
|
||||||
|
date: DateString;
|
||||||
|
/** 전체 스코어보드에서의 rank (동점자 동일 rank). */
|
||||||
|
overall: number;
|
||||||
|
/** 해당 팀 내 rank. 스냅샷 당시 `teamCode`와 함께만 유효. */
|
||||||
|
team?: number;
|
||||||
|
/** 스냅샷 생성 시점의 `favoriteTeamCode`. 이후 팀 변경 시 team delta 무효화용. */
|
||||||
|
teamCode?: TeamCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Game {
|
export interface Game {
|
||||||
|
|||||||
30
src/types/scoreboard.ts
Normal file
30
src/types/scoreboard.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import type { TeamCode } from "./panit.js";
|
||||||
|
|
||||||
|
export interface ScoreboardEntry {
|
||||||
|
uid: string;
|
||||||
|
displayName: string;
|
||||||
|
photoUrl?: string;
|
||||||
|
tierPoints: number;
|
||||||
|
favoriteTeamCode?: TeamCode;
|
||||||
|
rank: number;
|
||||||
|
/** 이전 rankSnapshot 대비 변화량. + 상승, − 하락, null = 스냅샷 없음/팀 변경. */
|
||||||
|
rankDelta: number | null;
|
||||||
|
/** 0~100, 상위일수록 큼. */
|
||||||
|
percentile: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScoreboardScopeCache {
|
||||||
|
top: ScoreboardEntry[];
|
||||||
|
totalCount: number;
|
||||||
|
generatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScoreboardType = "team" | "overall";
|
||||||
|
|
||||||
|
export interface ScoreboardResponse {
|
||||||
|
type: ScoreboardType;
|
||||||
|
teamCode?: TeamCode;
|
||||||
|
totalCount: number;
|
||||||
|
top: ScoreboardEntry[];
|
||||||
|
me: ScoreboardEntry | null;
|
||||||
|
}
|
||||||
281
tests/services/scoreboardService.test.ts
Normal file
281
tests/services/scoreboardService.test.ts
Normal file
@ -0,0 +1,281 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { firestore, rtdb } from "../../src/firebase.js";
|
||||||
|
import {
|
||||||
|
__resetScoreboardCaches,
|
||||||
|
getScoreboard,
|
||||||
|
} from "../../src/services/scoreboardService.js";
|
||||||
|
import {
|
||||||
|
precomputeScoreboardCache,
|
||||||
|
snapshotRankForUser,
|
||||||
|
} from "../../src/services/rankSnapshotService.js";
|
||||||
|
import { todayKst } from "../../src/types/dateString.js";
|
||||||
|
import { TeamCode, type RankSnapshot } from "../../src/types/panit.js";
|
||||||
|
|
||||||
|
interface SeedUser {
|
||||||
|
uid: string;
|
||||||
|
displayName: string;
|
||||||
|
tierPoints?: number;
|
||||||
|
favoriteTeamCode?: TeamCode;
|
||||||
|
photoUrl?: string;
|
||||||
|
rankSnapshot?: RankSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedUser(u: SeedUser): Promise<void> {
|
||||||
|
const doc: Record<string, unknown> = {
|
||||||
|
displayName: u.displayName,
|
||||||
|
email: `${u.uid}@example.com`,
|
||||||
|
provider: "google",
|
||||||
|
knowledgeLevel: "casual",
|
||||||
|
};
|
||||||
|
if (u.tierPoints !== undefined) doc.tierPoints = u.tierPoints;
|
||||||
|
if (u.favoriteTeamCode) doc.favoriteTeamCode = u.favoriteTeamCode;
|
||||||
|
if (u.photoUrl) doc.photoUrl = u.photoUrl;
|
||||||
|
if (u.rankSnapshot) doc.rankSnapshot = u.rankSnapshot;
|
||||||
|
await firestore.collection("users").doc(u.uid).set(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("scoreboardService.getScoreboard", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await firestore.recursiveDelete(firestore.collection("users"));
|
||||||
|
await rtdb.ref("/scoreboardCache").remove();
|
||||||
|
__resetScoreboardCaches();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("type=overall", () => {
|
||||||
|
it("RTDB 캐시 기반 top 10과 me(rank/delta/percentile)를 반환한다", async () => {
|
||||||
|
for (let i = 1; i <= 12; i++) {
|
||||||
|
await seedUser({
|
||||||
|
uid: `u${i}`,
|
||||||
|
displayName: `유저${i}`,
|
||||||
|
tierPoints: i * 10,
|
||||||
|
rankSnapshot: { date: "2026-04-21", overall: 20 - i },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await precomputeScoreboardCache(todayKst());
|
||||||
|
|
||||||
|
const res = await getScoreboard("u5", "overall");
|
||||||
|
expect(res.type).toBe("overall");
|
||||||
|
expect(res.teamCode).toBeUndefined();
|
||||||
|
expect(res.totalCount).toBe(12);
|
||||||
|
expect(res.top).toHaveLength(10);
|
||||||
|
expect(res.top[0].uid).toBe("u12");
|
||||||
|
expect(res.top[0].rank).toBe(1);
|
||||||
|
expect(res.top[0].percentile).toBe(100);
|
||||||
|
expect(res.top[9].uid).toBe("u3");
|
||||||
|
expect(res.top[9].rank).toBe(10);
|
||||||
|
|
||||||
|
// u5 rankSnapshot.overall = 15, currentRank = 8 → delta = 7
|
||||||
|
expect(res.me).not.toBeNull();
|
||||||
|
expect(res.me!.uid).toBe("u5");
|
||||||
|
expect(res.me!.rank).toBe(8);
|
||||||
|
expect(res.me!.rankDelta).toBe(7);
|
||||||
|
// percentile = round((1 - 7/12) * 100) = round(41.67) = 42
|
||||||
|
expect(res.me!.percentile).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tierPoints가 없거나 0이면 me=null", async () => {
|
||||||
|
await seedUser({ uid: "u1", displayName: "유저1", tierPoints: 100 });
|
||||||
|
await seedUser({ uid: "me", displayName: "나" });
|
||||||
|
await precomputeScoreboardCache(todayKst());
|
||||||
|
|
||||||
|
const res = await getScoreboard("me", "overall");
|
||||||
|
expect(res.me).toBeNull();
|
||||||
|
expect(res.top[0].uid).toBe("u1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rankSnapshot 없으면 rankDelta=null", async () => {
|
||||||
|
await seedUser({ uid: "me", displayName: "나", tierPoints: 50 });
|
||||||
|
await precomputeScoreboardCache(todayKst());
|
||||||
|
|
||||||
|
const res = await getScoreboard("me", "overall");
|
||||||
|
expect(res.me!.rankDelta).toBeNull();
|
||||||
|
expect(res.top[0].rankDelta).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("동점자는 rank가 같다 (competition ranking)", async () => {
|
||||||
|
await seedUser({ uid: "a", displayName: "A", tierPoints: 100 });
|
||||||
|
await seedUser({ uid: "b", displayName: "B", tierPoints: 50 });
|
||||||
|
await seedUser({ uid: "me", displayName: "나", tierPoints: 50 });
|
||||||
|
await precomputeScoreboardCache(todayKst());
|
||||||
|
|
||||||
|
const res = await getScoreboard("me", "overall");
|
||||||
|
// top[0]=a rank1, top[1]=b or me (동점 rank2), top[2] 다른 동점자 rank2
|
||||||
|
expect(res.top[0].rank).toBe(1);
|
||||||
|
expect(res.top[1].rank).toBe(2);
|
||||||
|
expect(res.top[2].rank).toBe(2);
|
||||||
|
expect(res.me!.rank).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("유저 doc이 없으면 404", async () => {
|
||||||
|
await precomputeScoreboardCache(todayKst());
|
||||||
|
await expect(getScoreboard("nobody", "overall")).rejects.toMatchObject({
|
||||||
|
status: 404,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("RTDB 캐시가 없으면 503", async () => {
|
||||||
|
await seedUser({ uid: "me", displayName: "나", tierPoints: 100 });
|
||||||
|
await expect(getScoreboard("me", "overall")).rejects.toMatchObject({
|
||||||
|
status: 503,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("photoUrl이 top/me에 포함된다", async () => {
|
||||||
|
await seedUser({
|
||||||
|
uid: "me",
|
||||||
|
displayName: "나",
|
||||||
|
tierPoints: 100,
|
||||||
|
photoUrl: "https://cdn/p.png",
|
||||||
|
});
|
||||||
|
await precomputeScoreboardCache(todayKst());
|
||||||
|
|
||||||
|
const res = await getScoreboard("me", "overall");
|
||||||
|
expect(res.top[0].photoUrl).toBe("https://cdn/p.png");
|
||||||
|
expect(res.me!.photoUrl).toBe("https://cdn/p.png");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("type=team", () => {
|
||||||
|
it("같은 favoriteTeamCode 유저만 포함하고 team rank로 계산한다", async () => {
|
||||||
|
await seedUser({
|
||||||
|
uid: "lg1",
|
||||||
|
displayName: "LG1",
|
||||||
|
tierPoints: 100,
|
||||||
|
favoriteTeamCode: TeamCode.LG,
|
||||||
|
rankSnapshot: { date: "2026-04-21", overall: 5, team: 3, teamCode: TeamCode.LG },
|
||||||
|
});
|
||||||
|
await seedUser({
|
||||||
|
uid: "lg2",
|
||||||
|
displayName: "LG2",
|
||||||
|
tierPoints: 50,
|
||||||
|
favoriteTeamCode: TeamCode.LG,
|
||||||
|
});
|
||||||
|
await seedUser({
|
||||||
|
uid: "kt1",
|
||||||
|
displayName: "KT1",
|
||||||
|
tierPoints: 200,
|
||||||
|
favoriteTeamCode: TeamCode.KT,
|
||||||
|
});
|
||||||
|
await seedUser({
|
||||||
|
uid: "me",
|
||||||
|
displayName: "나",
|
||||||
|
tierPoints: 80,
|
||||||
|
favoriteTeamCode: TeamCode.LG,
|
||||||
|
rankSnapshot: { date: "2026-04-21", overall: 7, team: 5, teamCode: TeamCode.LG },
|
||||||
|
});
|
||||||
|
await precomputeScoreboardCache(todayKst());
|
||||||
|
|
||||||
|
const res = await getScoreboard("me", "team");
|
||||||
|
expect(res.type).toBe("team");
|
||||||
|
expect(res.teamCode).toBe(TeamCode.LG);
|
||||||
|
expect(res.totalCount).toBe(3);
|
||||||
|
expect(res.top.map((e) => e.uid)).toEqual(["lg1", "me", "lg2"]);
|
||||||
|
// me team rank = 2 (above: lg1), delta = 5 - 2 = 3
|
||||||
|
expect(res.me!.rank).toBe(2);
|
||||||
|
expect(res.me!.rankDelta).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("팀 변경 시 team delta는 null", async () => {
|
||||||
|
await seedUser({
|
||||||
|
uid: "me",
|
||||||
|
displayName: "나",
|
||||||
|
tierPoints: 80,
|
||||||
|
favoriteTeamCode: TeamCode.NC,
|
||||||
|
rankSnapshot: { date: "2026-04-21", overall: 1, team: 1, teamCode: TeamCode.LG },
|
||||||
|
});
|
||||||
|
await precomputeScoreboardCache(todayKst());
|
||||||
|
|
||||||
|
const res = await getScoreboard("me", "team");
|
||||||
|
expect(res.me!.rankDelta).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("favoriteTeamCode가 없으면 400", async () => {
|
||||||
|
await seedUser({ uid: "me", displayName: "나", tierPoints: 100 });
|
||||||
|
await precomputeScoreboardCache(todayKst());
|
||||||
|
await expect(getScoreboard("me", "team")).rejects.toMatchObject({
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("팀 유저가 본인뿐이면 top=[me], rank=1, percentile=100", async () => {
|
||||||
|
await seedUser({
|
||||||
|
uid: "me",
|
||||||
|
displayName: "나",
|
||||||
|
tierPoints: 30,
|
||||||
|
favoriteTeamCode: TeamCode.NC,
|
||||||
|
});
|
||||||
|
await seedUser({
|
||||||
|
uid: "other",
|
||||||
|
displayName: "타인",
|
||||||
|
tierPoints: 999,
|
||||||
|
favoriteTeamCode: TeamCode.KT,
|
||||||
|
});
|
||||||
|
await precomputeScoreboardCache(todayKst());
|
||||||
|
|
||||||
|
const res = await getScoreboard("me", "team");
|
||||||
|
expect(res.top).toHaveLength(1);
|
||||||
|
expect(res.top[0].uid).toBe("me");
|
||||||
|
expect(res.me!.rank).toBe(1);
|
||||||
|
expect(res.me!.percentile).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rankSnapshotService.snapshotRankForUser", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await firestore.recursiveDelete(firestore.collection("users"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("overall/team rank를 rankSnapshot에 기록한다", async () => {
|
||||||
|
await firestore.collection("users").doc("a").set({
|
||||||
|
displayName: "A", email: "a@e", provider: "google", knowledgeLevel: "casual",
|
||||||
|
tierPoints: 200, favoriteTeamCode: TeamCode.LG,
|
||||||
|
});
|
||||||
|
await firestore.collection("users").doc("b").set({
|
||||||
|
displayName: "B", email: "b@e", provider: "google", knowledgeLevel: "casual",
|
||||||
|
tierPoints: 100, favoriteTeamCode: TeamCode.LG,
|
||||||
|
});
|
||||||
|
await firestore.collection("users").doc("c").set({
|
||||||
|
displayName: "C", email: "c@e", provider: "google", knowledgeLevel: "casual",
|
||||||
|
tierPoints: 150, favoriteTeamCode: TeamCode.KT,
|
||||||
|
});
|
||||||
|
|
||||||
|
await snapshotRankForUser("b", "2026-04-22");
|
||||||
|
|
||||||
|
const doc = await firestore.collection("users").doc("b").get();
|
||||||
|
const snap = doc.data()!.rankSnapshot as RankSnapshot;
|
||||||
|
expect(snap.date).toBe("2026-04-22");
|
||||||
|
// b tierPoints=100, above 전체: a(200), c(150) → 2 → overall rank=3
|
||||||
|
expect(snap.overall).toBe(3);
|
||||||
|
// team=LG 내 above: a → 1 → team rank=2
|
||||||
|
expect(snap.team).toBe(2);
|
||||||
|
expect(snap.teamCode).toBe(TeamCode.LG);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tierPoints=0이면 스냅샷을 남기지 않는다", async () => {
|
||||||
|
await firestore.collection("users").doc("zero").set({
|
||||||
|
displayName: "Z", email: "z@e", provider: "google", knowledgeLevel: "casual",
|
||||||
|
tierPoints: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
await snapshotRankForUser("zero", "2026-04-22");
|
||||||
|
|
||||||
|
const doc = await firestore.collection("users").doc("zero").get();
|
||||||
|
expect(doc.data()!.rankSnapshot).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("favoriteTeamCode가 없으면 team 필드 없이 overall만 기록", async () => {
|
||||||
|
await firestore.collection("users").doc("solo").set({
|
||||||
|
displayName: "S", email: "s@e", provider: "google", knowledgeLevel: "casual",
|
||||||
|
tierPoints: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
await snapshotRankForUser("solo", "2026-04-22");
|
||||||
|
|
||||||
|
const doc = await firestore.collection("users").doc("solo").get();
|
||||||
|
const snap = doc.data()!.rankSnapshot as RankSnapshot;
|
||||||
|
expect(snap.overall).toBe(1);
|
||||||
|
expect(snap.team).toBeUndefined();
|
||||||
|
expect(snap.teamCode).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user