Merge live game data into KBO schedule results for today.

- KBO 일정 조회 시 오늘 날짜의 경기에 대해 실시간 점수와 경기 상태를 병합하는 기능을 추가했습니다.
- `GameStatus` 타입에 'live'를 추가하고, 게임센터 API 데이터를 기반으로 상태를 변환하는 로직을 구현했습니다.
- 외부 API 연동 시 발생할 수 있는 일시적인 오류(503 등)에 대비하여 예외 발생 시 기존 캐시 데이터를 반환하도록 예외 처리를 적용했습니다.
- 경기 ID 또는 팀 정보와 시간을 조합한 복합 키를 사용하여 스케줄 데이터와 실시간 데이터를 정확하게 매칭합니다.
This commit is contained in:
윤정민 2026-04-14 22:29:23 +09:00
parent 98502916de
commit a7ce2799ef
2 changed files with 75 additions and 3 deletions

View File

@ -14,7 +14,7 @@ const USER_AGENT =
// ── Types ──
export type GameStatus = "completed" | "cancelled" | "scheduled";
export type GameStatus = "completed" | "cancelled" | "scheduled" | "live";
export interface ScheduleGame {
date: string;

View File

@ -8,6 +8,7 @@ import {
type ScheduleFilters,
type ScheduleResult,
type ScheduleGame,
type GameStatus,
} from "../kbo/schedule.js";
import {
fetchPlayerStatsInitial,
@ -25,6 +26,8 @@ import {
releaseLock,
} from "./kboCacheRepository.js";
import { firestore } from "../firebase.js";
import { getGameList } from "../services/gameListService.js";
import type { GameListRecord } from "../kbo/game-list.js";
const TTL_MS = 60 * 60 * 1000; // 1 hour
@ -164,6 +167,67 @@ function dayTtlMs(yyyymmdd: string, games: ScheduleGame[]): number {
return THIRTY_SEC_MS;
}
function statusFromRecord(rec: GameListRecord): GameStatus | null {
// cancelCode: "0" = 정상경기, 그 외 = 취소/노게임
if (rec.status.cancelCode && rec.status.cancelCode !== "0") return "cancelled";
// stateCode: "1" = 예정, "2" = 진행 중, "3" = 종료
switch (rec.status.stateCode) {
case "1": return "scheduled";
case "2": return "live";
case "3": return "completed";
default: return null;
}
}
function liveKey(
away: string,
home: string,
time: string
): string {
return `${away}|${home}|${time}`;
}
async function mergeLiveIntoSchedule(
ymd: string,
games: ScheduleGame[],
series: string | undefined
): Promise<ScheduleGame[]> {
const today = formatYmd(new Date());
if (ymd !== today) return games;
let live: GameListRecord[];
try {
const result = await getGameList(ymd, series);
live = result.games;
} catch {
return games;
}
const byId = new Map<string, GameListRecord>();
const byComposite = new Map<string, GameListRecord>();
for (const rec of live) {
if (rec.gameId) byId.set(rec.gameId, rec);
byComposite.set(
liveKey(rec.awayTeamCode, rec.homeTeamCode, rec.time),
rec
);
}
return games.map((g) => {
const rec =
(g.gameId && byId.get(g.gameId)) ||
byComposite.get(liveKey(g.awayTeamCode, g.homeTeamCode, g.time));
if (!rec) return g;
const merged: ScheduleGame = { ...g };
if (rec.score.away != null) merged.awayScore = rec.score.away;
if (rec.score.home != null) merged.homeScore = rec.score.home;
const nextStatus = statusFromRecord(rec);
if (nextStatus) merged.status = nextStatus;
return merged;
});
}
async function readDayDocs(keys: string[]): Promise<Map<string, ScheduleGame[] | null>> {
if (keys.length === 0) return new Map();
const refs = keys.map((k) => firestore.collection(CACHE_COLLECTION).doc(k));
@ -224,11 +288,13 @@ async function fetchScheduleSingleDay(
cached = (await readDayDocs([key])).get(key) ?? [];
}
const merged = await mergeLiveIntoSchedule(ymd, cached, filters.series);
return {
year: filters.year,
month: filters.month,
day: filters.day,
games: cached,
games: merged,
};
}
@ -293,10 +359,16 @@ async function fetchScheduleMonth(
}
const games: ScheduleGame[] = [];
const today = formatYmd(new Date());
for (const ymd of allDays) {
const list = cached.get(dayKey(ymd, filters.team, filters.series)) ?? [];
if (ymd === today) {
const merged = await mergeLiveIntoSchedule(ymd, list, filters.series);
games.push(...merged);
} else {
games.push(...list);
}
}
return { year: filters.year, month: filters.month, games };
}