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 { 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;
}

View File

@ -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");

View File

@ -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<nu
*/
export function gameUpdateFromRecord(
rec: GameListRecord
): { status: GameStatus; winningTeamCode?: string } | null {
): { status: GameStatus; winningTeamCode?: string; cancelReason?: string } | null {
const status = statusFromRecord(rec);
if (!status) return null;
const update: { status: GameStatus; winningTeamCode?: string } = { status };
const update: {
status: GameStatus;
winningTeamCode?: string;
cancelReason?: string;
} = { status };
if (
status === "completed" &&
rec.score.home != null &&
@ -95,28 +102,35 @@ export function gameUpdateFromRecord(
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
* . 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<Game>) : 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,
});

View File

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

View File

@ -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" });