- TypeScript 소스 파일 내 모든 import 구문에서 불필요한 `.js` 확장자를 제거하여 모듈 참조 방식을 표준화했습니다. - 핸들러, 서비스, 리포지토리 및 테스트 코드를 포함한 프로젝트 전반의 import 경로를 일관성 있게 정리했습니다.
101 lines
2.9 KiB
TypeScript
101 lines
2.9 KiB
TypeScript
import { MemCache } from "../lib/memCache";
|
|
import {
|
|
countUsersAboveTierPoints,
|
|
getUser,
|
|
} from "../repositories/userRepository";
|
|
import {
|
|
readScope,
|
|
type Scope,
|
|
} from "../repositories/scoreboardCacheRepository";
|
|
import { HttpError } from "../middleware/errors";
|
|
import { computePercentile, deltaFor } from "./scoreboardHelpers";
|
|
import { todayKst } from "../types/dateString";
|
|
import { type TeamCode } from "../types/panit";
|
|
import type {
|
|
ScoreboardEntry,
|
|
ScoreboardResponse,
|
|
ScoreboardScopeCache,
|
|
ScoreboardType,
|
|
} from "../types/scoreboard";
|
|
|
|
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;
|
|
}
|