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 { 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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" });
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user