diff --git a/src/handlers/debugHandlers.ts b/src/handlers/debugHandlers.ts index 2ad6b12..b44e107 100644 --- a/src/handlers/debugHandlers.ts +++ b/src/handlers/debugHandlers.ts @@ -1,13 +1,15 @@ import { onRequest } from "firebase-functions/https"; import { logger } from "firebase-functions"; import { runDailyArchive } from "../scheduled/dailyArchive"; -import { syncYesterdayFromLive } from "../services/gameSyncService"; +import { forceSyncDay } from "../services/gameSyncService"; import { sendError } from "../middleware/errors"; import { daysAgoKst, parseDateString } from "../types/dateString"; /** * 임시 디버그 핸들러. 인증 없음 — 운영 안정화 후 제거할 것. * + * - GET/POST `/debug/forceSync` — 어제(KST) 기준으로 라이브 데이터 sync 실행. + * - GET/POST `/debug/forceSync?date=YYYY-MM-DD` — 지정한 날짜를 강제 sync. * - GET/POST `/debug/dailyArchive` — 어제(KST) 기준으로 dailyArchive 본체 실행. * - GET/POST `/debug/dailyArchive?date=YYYY-MM-DD` — 지정한 날짜를 archive 대상으로 실행. */ @@ -16,7 +18,7 @@ export const debug = onRequest(async (req, res) => { const tail = segs.slice(-1)[0]; try { - if (tail === "syncYesterday") { + if (tail === "forceSync") { const dateParam = (typeof req.query.date === "string" && req.query.date) || (typeof req.body?.date === "string" && req.body.date) || @@ -26,8 +28,8 @@ export const debug = onRequest(async (req, res) => { : daysAgoKst(1); const ymd = dateString.replace(/-/g, ""); - logger.info(`debug.syncYesterday triggered manually (ymd=${ymd})`); - const result = await syncYesterdayFromLive(ymd); + logger.info(`debug.forceSync triggered manually (ymd=${ymd})`); + const result = await forceSyncDay(ymd); res.status(200).json({ ymd, ...result }); return; } diff --git a/src/scheduled/kboRefresh.ts b/src/scheduled/kboRefresh.ts index 959b632..6fdaa3c 100644 --- a/src/scheduled/kboRefresh.ts +++ b/src/scheduled/kboRefresh.ts @@ -7,7 +7,7 @@ import { } from "../repositories/kboRepository"; import { syncGamesForMonth, - syncYesterdayFromLive, + forceSyncDay, } from "../services/gameSyncService"; import { daysAgoKst } from "../types/dateString"; @@ -58,10 +58,10 @@ export const kboDailyRefresh = onSchedule( // 라이브 데이터(`getGameList`)로 보완. 휴장일이면 0건 반환되어 무해. try { const yesterdayYmd = daysAgoKst(1).replace(/-/g, ""); - const { updated } = await syncYesterdayFromLive(yesterdayYmd); - logger.info(`syncYesterdayFromLive: ${updated} games updated for ${yesterdayYmd}`); + const { updated } = await forceSyncDay(yesterdayYmd); + logger.info(`forceSyncDay: ${updated} games updated for ${yesterdayYmd}`); } catch (err) { - logger.error("syncYesterdayFromLive failed", err); + logger.error("forceSyncDay failed", err); } logger.info("KBO refresh complete"); diff --git a/src/services/gameSyncService.ts b/src/services/gameSyncService.ts index 90e1789..6df184a 100644 --- a/src/services/gameSyncService.ts +++ b/src/services/gameSyncService.ts @@ -46,6 +46,9 @@ export function toGameDoc(year: number, g: ScheduleGame): Game | null { doc.winningTeamCode = g.homeScore > g.awayScore ? g.homeTeamCode : g.awayTeamCode; } + if (g.status === "cancelled" && g.note && g.note !== "-") { + doc.cancelReason = g.note; + } return doc; } @@ -82,10 +85,14 @@ export async function syncGamesForMonth(year: number, month: number): Promise rec.score.away ? rec.homeTeamCode : rec.awayTeamCode; } + if (status === "cancelled" && rec.status.cancelName) { + update.cancelReason = rec.status.cancelName; + } return update; } /** - * 지정 날짜(YYYYMMDD)의 라이브 게임 데이터를 가져와 `games` 컬렉션의 status·winningTeamCode를 - * 갱신한다. 사전 read로 기존 값과 비교하여 **변경이 있는 문서만** write한다 — 매일 같은 값으로 - * 덮어쓰는 무의미한 write를 줄이기 위함. status 변경이 발생하면 `onGameCompleted` 트리거가 - * 깨어나 후속 처리(userVotes에 result 기록 등)를 이어간다. + * 지정 날짜(YYYYMMDD)의 라이브 게임 데이터를 가져와 `games` 컬렉션의 status·winningTeamCode· + * cancelReason을 갱신한다. 사전 read로 기존 값과 비교하여 **변경이 있는 문서만** write한다 — + * 매일 같은 값으로 덮어쓰는 무의미한 write를 줄이기 위함. status 변경이 발생하면 + * `onGameCompleted` 트리거가 깨어나 후속 처리(userVotes에 result 기록 등)를 이어간다. * - * 월간 schedule refresh가 놓치는 "월의 마지막 날" 갱신을 보완하는 용도. 휴장일이면 0건 반환. + * 호출 경로: + * - cron(`kboDailyRefresh`): 어제 날짜로 호출. 월간 schedule refresh가 놓치는 월말 갱신 보완. + * - debug 엔드포인트(`/debug/forceSync`): 임의 날짜를 강제 sync. stuck 데이터 즉시 복구용. + * + * 휴장일이면 라이브 API가 0건 반환 → no-op. * * @param yyyymmdd KST 기준 YYYYMMDD 문자열 * @returns `updated`: 실제로 write가 일어난 문서 수 */ -export async function syncYesterdayFromLive( +export async function forceSyncDay( yyyymmdd: string ): Promise<{ updated: number }> { const result = await getGameList(yyyymmdd); const targets: Array<{ gameId: string; - update: { status: GameStatus; winningTeamCode?: string }; + update: { status: GameStatus; winningTeamCode?: string; cancelReason?: string }; }> = []; for (const rec of result.games) { if (!rec.gameId) continue; @@ -137,7 +151,8 @@ export async function syncYesterdayFromLive( const existing = snap.exists ? (snap.data() as Partial) : null; const sameStatus = existing?.status === update.status; const sameWinner = existing?.winningTeamCode === update.winningTeamCode; - if (existing && sameStatus && sameWinner) return; + const sameReason = existing?.cancelReason === update.cancelReason; + if (existing && sameStatus && sameWinner && sameReason) return; batch.set(firestore.collection(COLLECTION).doc(gameId), update, { merge: true, }); diff --git a/src/types/panit.ts b/src/types/panit.ts index 64a87c0..ff52198 100644 --- a/src/types/panit.ts +++ b/src/types/panit.ts @@ -107,6 +107,8 @@ export interface Game { homeTeamCode: string; awayTeamCode: string; winningTeamCode?: string; + /** 경기가 cancelled일 때의 사유 텍스트 (예: "우천취소", "그라운드 사정"). */ + cancelReason?: string; } export interface VoteEntry { diff --git a/tests/services/gameSyncService.test.ts b/tests/services/gameSyncService.test.ts index 4740c21..821a27f 100644 --- a/tests/services/gameSyncService.test.ts +++ b/tests/services/gameSyncService.test.ts @@ -63,6 +63,19 @@ describe("gameSyncService.toGameDoc", () => { expect(doc!.status).toBe("cancelled"); }); + it("cancelled + note가 '-' 이외면 cancelReason에 사유 저장", () => { + const doc = toGameDoc( + 2026, + baseGame({ status: "cancelled", note: "우천취소" }) + ); + expect(doc!.cancelReason).toBe("우천취소"); + }); + + it("cancelled여도 note가 '-'이면 cancelReason 미설정", () => { + const doc = toGameDoc(2026, baseGame({ status: "cancelled", note: "-" })); + expect(doc!.cancelReason).toBeUndefined(); + }); + it("무승부는 winningTeamCode가 없다", () => { const doc = toGameDoc( 2026, @@ -154,10 +167,19 @@ describe("gameSyncService.gameUpdateFromRecord", () => { expect(u).toEqual({ status: "completed" }); }); - it("cancelCode != '0' 이면 stateCode 무관하게 status=cancelled", () => { + it("cancelCode != '0' + cancelName 있으면 status=cancelled + cancelReason 저장", () => { const u = gameUpdateFromRecord( baseLiveRecord({ - status: { stateCode: "1", cancelCode: "1", cancelName: "우천", inning: null, topBottom: null }, + status: { stateCode: "1", cancelCode: "1", cancelName: "우천취소", inning: null, topBottom: null }, + }) + ); + expect(u).toEqual({ status: "cancelled", cancelReason: "우천취소" }); + }); + + it("cancelCode != '0'이지만 cancelName 비어있으면 cancelReason 미설정", () => { + const u = gameUpdateFromRecord( + baseLiveRecord({ + status: { stateCode: "1", cancelCode: "1", cancelName: "", inning: null, topBottom: null }, }) ); expect(u).toEqual({ status: "cancelled" });