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(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 { 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; }