Capture cancellation reasons and generalize the game sync function.
- KBO 경기 취소 시 '우천취소' 등 구체적인 사유(cancelReason)를 파싱하여 저장하는 로직을 추가했습니다. - 기존의 `syncYesterdayFromLive` 함수를 `forceSyncDay`로 이름을 변경하고, 어제 날짜뿐만 아니라 특정 날짜를 강제로 동기화할 수 있도록 기능을 일반화했습니다. - Firestore 업데이트 전 기존 데이터와 취소 사유를 비교하는 로직을 추가하여 불필요한 쓰기 비용을 최적화했습니다. - 다양한 취소 상황과 노트 필드 처리 방식에 대한 회귀 테스트 케이스를 추가하여 신뢰성을 확보했습니다.
This commit is contained in:
parent
590b3f7612
commit
e32def7e64
@ -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;
|
||||
}
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -107,6 +107,8 @@ export interface Game {
|
||||
homeTeamCode: string;
|
||||
awayTeamCode: string;
|
||||
winningTeamCode?: string;
|
||||
/** 경기가 cancelled일 때의 사유 텍스트 (예: "우천취소", "그라운드 사정"). */
|
||||
cancelReason?: string;
|
||||
}
|
||||
|
||||
export interface VoteEntry {
|
||||
|
||||
@ -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" });
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user