- TypeScript 소스 파일 내 모든 import 구문에서 불필요한 `.js` 확장자를 제거하여 모듈 참조 방식을 표준화했습니다. - 핸들러, 서비스, 리포지토리 및 테스트 코드를 포함한 프로젝트 전반의 import 경로를 일관성 있게 정리했습니다.
115 lines
3.1 KiB
TypeScript
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);
|
|
}
|
|
}
|