Capture cancellation reasons and generalize the game sync function.

- KBO 경기 취소 시 '우천취소' 등 구체적인 사유(cancelReason)를 파싱하여 저장하는 로직을 추가했습니다.
- 기존의 `syncYesterdayFromLive` 함수를 `forceSyncDay`로 이름을 변경하고, 어제 날짜뿐만 아니라 특정 날짜를 강제로 동기화할 수 있도록 기능을 일반화했습니다.
- Firestore 업데이트 전 기존 데이터와 취소 사유를 비교하는 로직을 추가하여 불필요한 쓰기 비용을 최적화했습니다.
- 다양한 취소 상황과 노트 필드 처리 방식에 대한 회귀 테스트 케이스를 추가하여 신뢰성을 확보했습니다.
This commit is contained in:
윤정민 2026-05-07 17:00:43 +09:00
parent 590b3f7612
commit e32def7e64
5 changed files with 61 additions and 20 deletions

View File

@ -1,13 +1,15 @@
import { onRequest } from "firebase-functions/https"; import { onRequest } from "firebase-functions/https";
import { logger } from "firebase-functions"; import { logger } from "firebase-functions";
import { runDailyArchive } from "../scheduled/dailyArchive"; import { runDailyArchive } from "../scheduled/dailyArchive";
import { syncYesterdayFromLive } from "../services/gameSyncService"; import { forceSyncDay } from "../services/gameSyncService";
import { sendError } from "../middleware/errors"; import { sendError } from "../middleware/errors";
import { daysAgoKst, parseDateString } from "../types/dateString"; 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` (KST) dailyArchive .
* - GET/POST `/debug/dailyArchive?date=YYYY-MM-DD` archive . * - 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]; const tail = segs.slice(-1)[0];
try { try {
if (tail === "syncYesterday") { if (tail === "forceSync") {
const dateParam = const dateParam =
(typeof req.query.date === "string" && req.query.date) || (typeof req.query.date === "string" && req.query.date) ||
(typeof req.body?.date === "string" && req.body.date) || (typeof req.body?.date === "string" && req.body.date) ||
@ -26,8 +28,8 @@ export const debug = onRequest(async (req, res) => {
: daysAgoKst(1); : daysAgoKst(1);
const ymd = dateString.replace(/-/g, ""); const ymd = dateString.replace(/-/g, "");
logger.info(`debug.syncYesterday triggered manually (ymd=${ymd})`); logger.info(`debug.forceSync triggered manually (ymd=${ymd})`);
const result = await syncYesterdayFromLive(ymd); const result = await forceSyncDay(ymd);
res.status(200).json({ ymd, ...result }); res.status(200).json({ ymd, ...result });
return; return;
} }

View File

@ -7,7 +7,7 @@ import {
} from "../repositories/kboRepository"; } from "../repositories/kboRepository";
import { import {
syncGamesForMonth, syncGamesForMonth,
syncYesterdayFromLive, forceSyncDay,
} from "../services/gameSyncService"; } from "../services/gameSyncService";
import { daysAgoKst } from "../types/dateString"; import { daysAgoKst } from "../types/dateString";
@ -58,10 +58,10 @@ export const kboDailyRefresh = onSchedule(
// 라이브 데이터(`getGameList`)로 보완. 휴장일이면 0건 반환되어 무해. // 라이브 데이터(`getGameList`)로 보완. 휴장일이면 0건 반환되어 무해.
try { try {
const yesterdayYmd = daysAgoKst(1).replace(/-/g, ""); const yesterdayYmd = daysAgoKst(1).replace(/-/g, "");
const { updated } = await syncYesterdayFromLive(yesterdayYmd); const { updated } = await forceSyncDay(yesterdayYmd);
logger.info(`syncYesterdayFromLive: ${updated} games updated for ${yesterdayYmd}`); logger.info(`forceSyncDay: ${updated} games updated for ${yesterdayYmd}`);
} catch (err) { } catch (err) {
logger.error("syncYesterdayFromLive failed", err); logger.error("forceSyncDay failed", err);
} }
logger.info("KBO refresh complete"); logger.info("KBO refresh complete");

View File

@ -46,6 +46,9 @@ export function toGameDoc(year: number, g: ScheduleGame): Game | null {
doc.winningTeamCode = doc.winningTeamCode =
g.homeScore > g.awayScore ? g.homeTeamCode : g.awayTeamCode; g.homeScore > g.awayScore ? g.homeTeamCode : g.awayTeamCode;
} }
if (g.status === "cancelled" && g.note && g.note !== "-") {
doc.cancelReason = g.note;
}
return doc; return doc;
} }
@ -82,10 +85,14 @@ export async function syncGamesForMonth(year: number, month: number): Promise<nu
*/ */
export function gameUpdateFromRecord( export function gameUpdateFromRecord(
rec: GameListRecord rec: GameListRecord
): { status: GameStatus; winningTeamCode?: string } | null { ): { status: GameStatus; winningTeamCode?: string; cancelReason?: string } | null {
const status = statusFromRecord(rec); const status = statusFromRecord(rec);
if (!status) return null; if (!status) return null;
const update: { status: GameStatus; winningTeamCode?: string } = { status }; const update: {
status: GameStatus;
winningTeamCode?: string;
cancelReason?: string;
} = { status };
if ( if (
status === "completed" && status === "completed" &&
rec.score.home != null && rec.score.home != null &&
@ -95,28 +102,35 @@ export function gameUpdateFromRecord(
update.winningTeamCode = update.winningTeamCode =
rec.score.home > rec.score.away ? rec.homeTeamCode : rec.awayTeamCode; rec.score.home > rec.score.away ? rec.homeTeamCode : rec.awayTeamCode;
} }
if (status === "cancelled" && rec.status.cancelName) {
update.cancelReason = rec.status.cancelName;
}
return update; return update;
} }
/** /**
* (YYYYMMDD) `games` status·winningTeamCode * (YYYYMMDD) `games` status·winningTeamCode·
* . read로 ** ** write한다 * cancelReason을 . read로 ** ** write한다
* write를 . status `onGameCompleted` * write를 . status
* (userVotes에 result ) . * `onGameCompleted` (userVotes에 result ) .
* *
* schedule refresh가 "월의 마지막 날" . 0 . * :
* - cron(`kboDailyRefresh`): . schedule refresh가 .
* - debug (`/debug/forceSync`): sync. stuck .
*
* API가 0 no-op.
* *
* @param yyyymmdd KST YYYYMMDD * @param yyyymmdd KST YYYYMMDD
* @returns `updated`: write가 * @returns `updated`: write가
*/ */
export async function syncYesterdayFromLive( export async function forceSyncDay(
yyyymmdd: string yyyymmdd: string
): Promise<{ updated: number }> { ): Promise<{ updated: number }> {
const result = await getGameList(yyyymmdd); const result = await getGameList(yyyymmdd);
const targets: Array<{ const targets: Array<{
gameId: string; gameId: string;
update: { status: GameStatus; winningTeamCode?: string }; update: { status: GameStatus; winningTeamCode?: string; cancelReason?: string };
}> = []; }> = [];
for (const rec of result.games) { for (const rec of result.games) {
if (!rec.gameId) continue; if (!rec.gameId) continue;
@ -137,7 +151,8 @@ export async function syncYesterdayFromLive(
const existing = snap.exists ? (snap.data() as Partial<Game>) : null; const existing = snap.exists ? (snap.data() as Partial<Game>) : null;
const sameStatus = existing?.status === update.status; const sameStatus = existing?.status === update.status;
const sameWinner = existing?.winningTeamCode === update.winningTeamCode; 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, { batch.set(firestore.collection(COLLECTION).doc(gameId), update, {
merge: true, merge: true,
}); });

View File

@ -107,6 +107,8 @@ export interface Game {
homeTeamCode: string; homeTeamCode: string;
awayTeamCode: string; awayTeamCode: string;
winningTeamCode?: string; winningTeamCode?: string;
/** 경기가 cancelled일 때의 사유 텍스트 (예: "우천취소", "그라운드 사정"). */
cancelReason?: string;
} }
export interface VoteEntry { export interface VoteEntry {

View File

@ -63,6 +63,19 @@ describe("gameSyncService.toGameDoc", () => {
expect(doc!.status).toBe("cancelled"); 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가 없다", () => { it("무승부는 winningTeamCode가 없다", () => {
const doc = toGameDoc( const doc = toGameDoc(
2026, 2026,
@ -154,10 +167,19 @@ describe("gameSyncService.gameUpdateFromRecord", () => {
expect(u).toEqual({ status: "completed" }); expect(u).toEqual({ status: "completed" });
}); });
it("cancelCode != '0' 이면 stateCode 무관하게 status=cancelled", () => { it("cancelCode != '0' + cancelName 있으면 status=cancelled + cancelReason 저장", () => {
const u = gameUpdateFromRecord( const u = gameUpdateFromRecord(
baseLiveRecord({ 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" }); expect(u).toEqual({ status: "cancelled" });