import { fetchGameDetail, type GameDetail, type GameDetailFilters, } from "../kbo/game-detail"; import { encodeKey, getCached, setCached, acquireLock, releaseLock, backoffDelayMs, } from "../repositories/kboCacheRepository"; const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; const ONE_HOUR_MS = 60 * 60 * 1000; const TEN_MIN_MS = 10 * 60 * 1000; const THIRTY_SEC_MS = 30_000; /** * 종료된 경기는 데이터 불변 → 7d. * 라이브 중에는 30s. * 시작 전이면 시작 시각까지 (최대 1h). * * 종료 판정: scoreBoard.meta.endTime이 채워져 있으면 종료. * scoreBoard가 null이면 KBO가 아직 경기 데이터를 만들지 않은 시작 전 상태 → * 라인업이 발표 전(`preview-estimated`)이면 곧 바뀔 수 있으니 10분, * 그 외엔 gameId에서 추정한 KST 18:30 시작 시각까지 캐시. */ function gameDetailTtlMs(detail: GameDetail): number { const sb = detail.scoreBoard; if (sb?.meta.endTime && sb.meta.endTime.trim() !== "") { return SEVEN_DAYS_MS; } const startTs = sb ? parseGameStartTs(sb.meta.gameDate, sb.meta.startTime) : parseGameStartFromGameId(detail.gameId); if (startTs == null) return THIRTY_SEC_MS; const now = Date.now(); if (now < startTs) { // 발표 전 fallback 라인업은 짧게 폴링 — false → true 전환 시점을 빠르게 잡기 위해. if (detail.lineup?.source === "preview-estimated") { return Math.min(TEN_MIN_MS, Math.max(startTs - now, THIRTY_SEC_MS)); } 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) ); } // scoreBoard가 없을 때(시작 전) gameId의 날짜만으로 시작 시각을 추정한다. // 정확한 시각은 모르지만 KBO 정규 경기의 평일 18:30 / 주말 14:00·17:00을 // 일률적으로 18:30 KST로 가정해 캐시 TTL 상한선용으로 쓴다. function parseGameStartFromGameId(gameId: string): number | null { const m = gameId.match(/^(\d{4})(\d{2})(\d{2})/); if (!m) return null; const [, y, mo, d] = m; return Date.UTC(Number(y), Number(mo) - 1, Number(d), 18 - 9, 30); } 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); 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) { const start = Date.now(); for (let attempt = 0; Date.now() - start < 25_000; attempt++) { await new Promise((r) => setTimeout(r, backoffDelayMs(attempt))); 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); } }