- KBO 경기 취소 시 '우천취소' 등 구체적인 사유(cancelReason)를 파싱하여 저장하는 로직을 추가했습니다. - 기존의 `syncYesterdayFromLive` 함수를 `forceSyncDay`로 이름을 변경하고, 어제 날짜뿐만 아니라 특정 날짜를 강제로 동기화할 수 있도록 기능을 일반화했습니다. - Firestore 업데이트 전 기존 데이터와 취소 사유를 비교하는 로직을 추가하여 불필요한 쓰기 비용을 최적화했습니다. - 다양한 취소 상황과 노트 필드 처리 방식에 대한 회귀 테스트 케이스를 추가하여 신뢰성을 확보했습니다.
165 lines
5.7 KiB
TypeScript
165 lines
5.7 KiB
TypeScript
import { Timestamp } from "firebase-admin/firestore";
|
|
import { firestore } from "../firebase";
|
|
import {
|
|
fetchScheduleFromKbo,
|
|
statusFromRecord,
|
|
} from "../repositories/kboRepository";
|
|
import { getGameList } from "./gameListService";
|
|
import type { GameListRecord } from "../kbo/game-list";
|
|
import type { ScheduleGame, GameStatus } from "../kbo/schedule";
|
|
import type { Game } from "../types/panit";
|
|
|
|
const COLLECTION = "games";
|
|
|
|
/**
|
|
* KBO schedule의 `date`("MM.DD")와 `time`("HH:mm")을 KST 기준 Firestore Timestamp로 변환한다.
|
|
*/
|
|
export function toTime(year: number, date: string, time: string): Timestamp {
|
|
const [mm, dd] = date.split(".").map((v) => parseInt(v, 10));
|
|
const [hhRaw, miRaw] = (time || "00:00").split(":");
|
|
const hh = parseInt(hhRaw, 10);
|
|
const mi = parseInt(miRaw, 10);
|
|
// KST = UTC+9
|
|
const utcMs = Date.UTC(year, mm - 1, dd, hh - 9, mi);
|
|
return Timestamp.fromMillis(utcMs);
|
|
}
|
|
|
|
/**
|
|
* ScheduleGame → Firestore Game doc 변환. gameId가 없으면 `null`.
|
|
* 완료된 경기의 경우 점수로 승리팀 코드를 계산한다.
|
|
*/
|
|
export function toGameDoc(year: number, g: ScheduleGame): Game | null {
|
|
if (!g.gameId) return null;
|
|
const doc: Game = {
|
|
time: toTime(year, g.date, g.time),
|
|
stadium: g.stadium,
|
|
status: g.status,
|
|
homeTeamCode: g.homeTeamCode,
|
|
awayTeamCode: g.awayTeamCode,
|
|
};
|
|
if (
|
|
g.status === "completed" &&
|
|
g.homeScore !== null &&
|
|
g.awayScore !== null &&
|
|
g.homeScore !== g.awayScore
|
|
) {
|
|
doc.winningTeamCode =
|
|
g.homeScore > g.awayScore ? g.homeTeamCode : g.awayTeamCode;
|
|
}
|
|
if (g.status === "cancelled" && g.note && g.note !== "-") {
|
|
doc.cancelReason = g.note;
|
|
}
|
|
return doc;
|
|
}
|
|
|
|
/**
|
|
* 특정 연도·월의 KBO 경기 일정을 fetch하여 Firestore `games` 컬렉션에 upsert한다.
|
|
*
|
|
* - `merge: true`로 기존 문서의 수동 필드(예: 외부에서 채운 `winningTeamCode`)를 보존한다.
|
|
* - gameId가 없는 일정 엔트리는 스킵.
|
|
*
|
|
* @returns upsert한 문서 개수
|
|
*/
|
|
export async function syncGamesForMonth(year: number, month: number): Promise<number> {
|
|
const result = await fetchScheduleFromKbo({ year, month });
|
|
const batch = firestore.batch();
|
|
let count = 0;
|
|
for (const g of result.games) {
|
|
const doc = toGameDoc(year, g);
|
|
if (!doc) continue;
|
|
batch.set(firestore.collection(COLLECTION).doc(g.gameId as string), doc, {
|
|
merge: true,
|
|
});
|
|
count++;
|
|
}
|
|
if (count > 0) await batch.commit();
|
|
return count;
|
|
}
|
|
|
|
/**
|
|
* 라이브 게임 레코드(`getGameList` 응답)로부터 `games` 문서에 적용할 부분 업데이트를 도출한다.
|
|
* status가 결정 불가능한 경우(`null`) 호출자가 무시할 수 있도록 `null`을 반환.
|
|
*
|
|
* `completed`이고 무승부가 아니면 점수 비교로 `winningTeamCode`를 채운다.
|
|
* 무승부거나 status가 `completed`가 아니면 `winningTeamCode`는 포함하지 않는다(기존 값 유지를 위함).
|
|
*/
|
|
export function gameUpdateFromRecord(
|
|
rec: GameListRecord
|
|
): { status: GameStatus; winningTeamCode?: string; cancelReason?: string } | null {
|
|
const status = statusFromRecord(rec);
|
|
if (!status) return null;
|
|
const update: {
|
|
status: GameStatus;
|
|
winningTeamCode?: string;
|
|
cancelReason?: string;
|
|
} = { status };
|
|
if (
|
|
status === "completed" &&
|
|
rec.score.home != null &&
|
|
rec.score.away != null &&
|
|
rec.score.home !== rec.score.away
|
|
) {
|
|
update.winningTeamCode =
|
|
rec.score.home > rec.score.away ? rec.homeTeamCode : rec.awayTeamCode;
|
|
}
|
|
if (status === "cancelled" && rec.status.cancelName) {
|
|
update.cancelReason = rec.status.cancelName;
|
|
}
|
|
return update;
|
|
}
|
|
|
|
/**
|
|
* 지정 날짜(YYYYMMDD)의 라이브 게임 데이터를 가져와 `games` 컬렉션의 status·winningTeamCode·
|
|
* cancelReason을 갱신한다. 사전 read로 기존 값과 비교하여 **변경이 있는 문서만** write한다 —
|
|
* 매일 같은 값으로 덮어쓰는 무의미한 write를 줄이기 위함. status 변경이 발생하면
|
|
* `onGameCompleted` 트리거가 깨어나 후속 처리(userVotes에 result 기록 등)를 이어간다.
|
|
*
|
|
* 호출 경로:
|
|
* - cron(`kboDailyRefresh`): 어제 날짜로 호출. 월간 schedule refresh가 놓치는 월말 갱신 보완.
|
|
* - debug 엔드포인트(`/debug/forceSync`): 임의 날짜를 강제 sync. stuck 데이터 즉시 복구용.
|
|
*
|
|
* 휴장일이면 라이브 API가 0건 반환 → no-op.
|
|
*
|
|
* @param yyyymmdd KST 기준 YYYYMMDD 문자열
|
|
* @returns `updated`: 실제로 write가 일어난 문서 수
|
|
*/
|
|
export async function forceSyncDay(
|
|
yyyymmdd: string
|
|
): Promise<{ updated: number }> {
|
|
const result = await getGameList(yyyymmdd);
|
|
|
|
const targets: Array<{
|
|
gameId: string;
|
|
update: { status: GameStatus; winningTeamCode?: string; cancelReason?: string };
|
|
}> = [];
|
|
for (const rec of result.games) {
|
|
if (!rec.gameId) continue;
|
|
const update = gameUpdateFromRecord(rec);
|
|
if (!update) continue;
|
|
targets.push({ gameId: rec.gameId, update });
|
|
}
|
|
|
|
if (targets.length === 0) return { updated: 0 };
|
|
|
|
const refs = targets.map((t) => firestore.collection(COLLECTION).doc(t.gameId));
|
|
const snaps = await firestore.getAll(...refs);
|
|
|
|
const batch = firestore.batch();
|
|
let updated = 0;
|
|
snaps.forEach((snap, i) => {
|
|
const { gameId, update } = targets[i];
|
|
const existing = snap.exists ? (snap.data() as Partial<Game>) : null;
|
|
const sameStatus = existing?.status === update.status;
|
|
const sameWinner = existing?.winningTeamCode === update.winningTeamCode;
|
|
const sameReason = existing?.cancelReason === update.cancelReason;
|
|
if (existing && sameStatus && sameWinner && sameReason) return;
|
|
batch.set(firestore.collection(COLLECTION).doc(gameId), update, {
|
|
merge: true,
|
|
});
|
|
updated++;
|
|
});
|
|
|
|
if (updated > 0) await batch.commit();
|
|
return { updated };
|
|
}
|