import { fetchGameDetail, type GameDetail, type GameDetailFilters, type ScoreBoardMeta, } from "../kbo/game-detail"; import { encodeKey, getCached, setCached, acquireLock, releaseLock, } from "../repositories/kboCacheRepository"; const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; const ONE_HOUR_MS = 60 * 60 * 1000; const THIRTY_SEC_MS = 30_000; /** * 종료된 경기는 데이터 불변 → 7d. * 라이브 중에는 30s. * 시작 전이면 시작 시각까지 (최대 1h). * * 종료 판정: scoreBoard.meta.endTime이 채워져 있으면 종료. */ function gameDetailTtlMs(meta: ScoreBoardMeta): number { if (meta.endTime && meta.endTime.trim() !== "") { return SEVEN_DAYS_MS; } const startTs = parseGameStartTs(meta.gameDate, meta.startTime); if (startTs == null) return THIRTY_SEC_MS; const now = Date.now(); if (now < startTs) { return Math.min(ONE_HOUR_MS, Math.max(startTs - now, THIRTY_SEC_MS)); } return THIRTY_SEC_MS; } function parseGameStartTs( gameDate: string | undefined, startTime: string | undefined ): number | null { const dm = gameDate?.match(/^(\d{4})-(\d{2})-(\d{2})$/); const tm = startTime?.match(/^(\d{1,2}):(\d{2})$/); if (!dm || !tm) return null; const [, y, mo, d] = dm; const [, h, mi] = tm; // KST(UTC+9) 기준 절대 시각 return Date.UTC( Number(y), Number(mo) - 1, Number(d), Number(h) - 9, Number(mi) ); } export async function getGameDetail( filters: GameDetailFilters ): Promise { if (!/^\d{8}[A-Z]{2}[A-Z]{2}\d$/.test(filters.gameId)) { throw new Error(`Invalid gameId: "${filters.gameId}"`); } const key = encodeKey(["game_detail", filters.gameId]); return getOrFetchDynamic(key, async () => { const detail = await fetchGameDetail(filters); const ttlMs = gameDetailTtlMs(detail.scoreBoard.meta); return { value: detail, ttlMs }; }); } interface DynamicResult { value: T; ttlMs: number; } /** * fetcher가 응답을 보고 동적으로 TTL을 결정해야 하는 경우 쓰는 헬퍼. * * `kboCacheRepository.getOrFetch`는 호출 시점에 TTL을 받기 때문에 응답 의존 * TTL을 표현할 수 없다. 여기선 캐시 doc의 `ttlMs` 필드(getCached가 우선 사용)에 * 의존하므로 read 시 fallback TTL은 무한대로 두고, write 시 fetcher가 정한 값을 쓴다. * 락 흐름은 kboCacheRepository와 동일. */ async function getOrFetchDynamic( key: string, fetcher: () => Promise> ): Promise { const cached = await getCached(key, Number.MAX_SAFE_INTEGER); if (cached !== null) return cached; const locked = await acquireLock(key); if (!locked) { for (let i = 0; i < 50; i++) { await new Promise((r) => setTimeout(r, 500)); const c = await getCached(key, Number.MAX_SAFE_INTEGER); if (c !== null) return c; } const { value } = await fetcher(); return value; } try { const { value, ttlMs } = await fetcher(); await setCached(key, value, ttlMs); return value; } finally { await releaseLock(key); } }