mmday-firebase/src/services/gameDetailService.ts
윤정민 c94ce69e4f Remove redundant .js extensions from import paths.
- TypeScript 소스 파일 내 모든 import 구문에서 불필요한 `.js` 확장자를 제거하여 모듈 참조 방식을 표준화했습니다.
- 핸들러, 서비스, 리포지토리 및 테스트 코드를 포함한 프로젝트 전반의 import 경로를 일관성 있게 정리했습니다.
2026-05-06 16:17:51 +09:00

115 lines
3.1 KiB
TypeScript

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<GameDetail> {
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<GameDetail>(key, async () => {
const detail = await fetchGameDetail(filters);
const ttlMs = gameDetailTtlMs(detail.scoreBoard.meta);
return { value: detail, ttlMs };
});
}
interface DynamicResult<T> {
value: T;
ttlMs: number;
}
/**
* fetcher가 응답을 보고 동적으로 TTL을 결정해야 하는 경우 쓰는 헬퍼.
*
* `kboCacheRepository.getOrFetch`는 호출 시점에 TTL을 받기 때문에 응답 의존
* TTL을 표현할 수 없다. 여기선 캐시 doc의 `ttlMs` 필드(getCached가 우선 사용)에
* 의존하므로 read 시 fallback TTL은 무한대로 두고, write 시 fetcher가 정한 값을 쓴다.
* 락 흐름은 kboCacheRepository와 동일.
*/
async function getOrFetchDynamic<T>(
key: string,
fetcher: () => Promise<DynamicResult<T>>
): Promise<T> {
const cached = await getCached<T>(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<T>(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);
}
}