From aa53a5b0702848d8bccb84b4f62a534985849f54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=A0=95=EB=AF=BC?= Date: Mon, 27 Jul 2026 13:24:35 +0900 Subject: [PATCH 1/8] Extract ALREADY_EXISTS guard and drop duplicate wallet read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - isAlreadyExistsError를 pointService에서 middleware/errors로 이동 — 레포지토리가 서비스를 import하는 계층 역전 없이 양쪽에서 쓰기 위함. pointService는 re-export로 기존 호출부 호환 유지 - createOrder에서 getWalletTx 사전 호출과 잔액 사전 검사 제거 — applyPointChangesTx가 같은 트랜잭션에서 동일한 HttpError(409, INSUFFICIENT_BALANCE)를 던지므로 동작은 같고 wallet 문서 tx.get이 2회에서 1회로 준다 --- src/middleware/errors.ts | 10 ++++++++++ src/services/orderService.ts | 7 ++++--- src/services/pointService.ts | 4 ++-- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/middleware/errors.ts b/src/middleware/errors.ts index 0b1cb3f..ef14afb 100644 --- a/src/middleware/errors.ts +++ b/src/middleware/errors.ts @@ -11,6 +11,16 @@ export class HttpError extends Error { } } +/** + * Firestore ALREADY_EXISTS 판별 — `create()`를 멱등 쓰기로 쓰는 경로에서 + * "이미 존재"를 정상 분기로 처리하기 위한 것. 레포지토리·서비스 양쪽이 쓰므로 + * 계층 역전이 없는 여기에 둔다. + */ +export function isAlreadyExistsError(err: unknown): boolean { + const code = (err as { code?: unknown })?.code; + return code === 6 || code === "already-exists" || code === "ALREADY_EXISTS"; +} + export function sendError(res: express.Response, err: unknown): void { if (err instanceof HttpError) { const body: Record = err.code diff --git a/src/services/orderService.ts b/src/services/orderService.ts index 7aa4504..a61ea22 100644 --- a/src/services/orderService.ts +++ b/src/services/orderService.ts @@ -4,7 +4,6 @@ import { HttpError } from "../middleware/errors"; import { getOrder, listOrders, orderRef } from "../repositories/orderRepository"; import { productRef } from "../repositories/productRepository"; import { applyPointChangesTx } from "./pointService"; -import { getWalletTx } from "../repositories/walletRepository"; import { PointLedgerType } from "../types/points"; import { ORDER_TRANSITIONS, @@ -72,14 +71,16 @@ export async function createOrder(uid: string, input: CreateOrderInput) { const snapById = new Map(); for (const item of input.items) { if (!snapById.has(item.productId)) snapById.set(item.productId, await tx.get(productRef(item.productId))); } - const currentWallet = await getWalletTx(tx, uid); const orderItems: OrderItem[] = input.items.map((item) => { const p = snapById.get(item.productId)!.data() as ProductDoc | undefined; if (!p || !p.active || !p.redeemable) throw new HttpError(409, "product unavailable", "PRODUCT_UNAVAILABLE"); const option = resolveOrderOption(p, item.optionId); return { productId: item.productId, qty: item.qty, pointPrice: p.pointPrice, name: p.name, ...(option ? { optionId: option.id, optionName: option.name } : {}) }; }); - const total = orderItems.reduce((n, i) => n + i.pointPrice * i.qty, 0); if ((currentWallet?.availableBalance ?? 0) < total) throw new HttpError(409, "insufficient balance", "INSUFFICIENT_BALANCE"); + // 잔액 검사는 applyPointChangesTx가 담당한다(pointService.ts:24가 동일한 + // HttpError(409, INSUFFICIENT_BALANCE)를 던진다). 같은 트랜잭션 안이라 + // 사전 검사를 두면 동일한 wallet 문서를 두 번 tx.get 하는 것 외에 차이가 없다. + const total = orderItems.reduce((n, i) => n + i.pointPrice * i.qty, 0); // 주문은 즉시 확정 — 홀드 없이 바로 차감하고, 되돌림은 어드민 환불로만 처리한다. const debitTxId = `${uid}:order:${id}:debit`; const wallet = await applyPointChangesTx(tx, uid, [{ txId: debitTxId, type: PointLedgerType.OrderCapture, amount: total, orderId: id }]); const order: OrderDoc = { uid, items: orderItems, totalPoints: total, recipient: input.recipient, status: "confirmed", clientIdempotencyKey: input.clientIdempotencyKey, debitLedgerTxId: debitTxId, orderedAt: now, confirmedAt: now, statusHistory: [{ status: "confirmed", at: now, actor: uid }], createdAt: now, updatedAt: now }; diff --git a/src/services/pointService.ts b/src/services/pointService.ts index 1c1aaef..52bdbc5 100644 --- a/src/services/pointService.ts +++ b/src/services/pointService.ts @@ -1,12 +1,12 @@ import { Timestamp, type Transaction } from "firebase-admin/firestore"; import { firestore } from "../firebase"; -import { HttpError } from "../middleware/errors"; +import { HttpError, isAlreadyExistsError } from "../middleware/errors"; import { createLedgerEntryTx } from "../repositories/pointLedgerRepository"; import { getWalletTx, walletDocRef } from "../repositories/walletRepository"; import { OP_BY_TYPE, PointLedgerType, type PointChange, type PointLedgerEntry, type WalletDoc } from "../types/points"; const TX_ID = /^[A-Za-z0-9:_-]{1,240}$/; -export function isAlreadyExistsError(err: unknown): boolean { const code = (err as { code?: unknown })?.code; return code === 6 || code === "already-exists" || code === "ALREADY_EXISTS"; } +export { isAlreadyExistsError }; export async function applyPointChangesTx(tx: Transaction, uid: string, changes: PointChange[]): Promise { for (const c of changes) if (!TX_ID.test(c.txId) || !Number.isSafeInteger(c.amount) || c.amount <= 0) throw new HttpError(400, "invalid point change", "INVALID_POINT_CHANGE"); const existing = await getWalletTx(tx, uid); From ed573a81e03b850557bd8f7114758d7cd24d6253 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=A0=95=EB=AF=BC?= Date: Mon, 27 Jul 2026 13:25:59 +0900 Subject: [PATCH 2/8] Cache games by date and invalidate on writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - gameRepository에 날짜 키 공유 캐시(MemCache, 15초) 추가 — 날짜별 경기 목록은 유저 독립 전역 데이터라 요청 간 공유할 수 있다. GET /prediction/games가 요청마다 range 쿼리를 재발행하던 것을 인스턴스당 분당 수 회로 수렴시킨다 - games 문서 쓰기 경로에 무효화 연결: syncGamesForMonth는 여러 날짜에 걸치므로 전량 폐기, forceSyncDay는 해당 날짜만 - createGameDayCache가 공유 캐시를 경유하도록 변경 — 요청마다 새로 만드는 짧은 수명 인스턴스도 실제 쿼리를 유발하지 않는다 - predictionHandlers의 games 분기에 Cache-Control public max-age=15 추가(무인증·전 유저 공통 응답이라 CDN·브라우저 중복 제거가 가능한데 그동안 헤더가 없었다) - MemCache.getOrFetch가 truthy 대신 히트 여부로 판정하도록 수정 — 캐시된 null이 미스로 취급돼 fetcher가 매번 재실행되던 문제(존재하지 않는 상품 조회가 요청마다 Firestore read 유발) - MemCache에 peek·clear 추가, 단위 테스트 6건 신설 --- src/handlers/predictionHandlers.ts | 3 + src/lib/memCache.ts | 23 ++++- src/repositories/gameRepository.ts | 47 +++++++++- src/services/gameSyncService.ts | 20 +++- src/services/predictionService.ts | 4 +- tests/repositories/gameRepository.test.ts | 7 +- tests/unit/memCache.test.ts | 106 ++++++++++++++++++++++ 7 files changed, 200 insertions(+), 10 deletions(-) create mode 100644 tests/unit/memCache.test.ts diff --git a/src/handlers/predictionHandlers.ts b/src/handlers/predictionHandlers.ts index 42fce01..7200090 100644 --- a/src/handlers/predictionHandlers.ts +++ b/src/handlers/predictionHandlers.ts @@ -36,6 +36,9 @@ export const prediction = onRequest(async (req, res) => { const date = resolveDateParam(req.query.date); const games = await listGamesByDate(date); const dto: GamesResponseDto = { date, games }; + // 무인증·전 유저 공통 응답 — CDN/브라우저에서 중복 요청을 합칠 수 있게 한다. + // 경기 중 스코어 변동을 고려해 서버 캐시(15초)와 같은 수준으로 짧게 잡는다. + res.set("Cache-Control", "public, max-age=15"); res.status(200).json(dto); return; } diff --git a/src/lib/memCache.ts b/src/lib/memCache.ts index 0c385c8..df8d034 100644 --- a/src/lib/memCache.ts +++ b/src/lib/memCache.ts @@ -14,13 +14,23 @@ export class MemCache { ) {} get(key: string): T | null { + return this.peek(key)?.data ?? null; + } + + /** + * 히트 여부와 값을 함께 돌려준다. + * + * `get`은 캐시된 `null`·`0`·`""`를 미스와 구분할 수 없다. negative 캐싱처럼 + * falsy 값을 캐시하는 경로는 이쪽을 써야 fetcher가 매번 다시 돌지 않는다. + */ + peek(key: string): { data: T } | null { const e = this.cache.get(key); if (!e) return null; if (Date.now() > e.expiresAt) { this.cache.delete(key); return null; } - return e.data; + return { data: e.data }; } set(key: string, data: T): void { @@ -44,12 +54,19 @@ export class MemCache { this.inflight.delete(key); } + /** 전체 엔트리를 버린다. 여러 key에 영향을 주는 쓰기 이후의 일괄 무효화용. */ + clear(): void { + this.cache.clear(); + this.inflight.clear(); + } + /** * 캐시 조회 → miss면 fetcher 실행 (동일 key 중복 호출 방지). */ async getOrFetch(key: string, fetcher: () => Promise): Promise { - const cached = this.get(key); - if (cached) return cached; + // truthy 검사가 아니라 히트 여부로 판정한다 — 캐시된 null/0/""도 서빙된다. + const cached = this.peek(key); + if (cached) return cached.data; const existing = this.inflight.get(key); if (existing) return existing; diff --git a/src/repositories/gameRepository.ts b/src/repositories/gameRepository.ts index 8626bc0..f0dddd4 100644 --- a/src/repositories/gameRepository.ts +++ b/src/repositories/gameRepository.ts @@ -1,10 +1,23 @@ import { Timestamp } from "firebase-admin/firestore"; import { firestore } from "../firebase"; +import { MemCache } from "../lib/memCache"; import type { Game } from "../types/panit"; import { startOfDayKst, type DateString } from "../types/dateString"; const COLLECTION = "games"; +/** + * 날짜별 games 조회 결과의 인스턴스 공유 캐시. + * + * 날짜별 경기 목록은 유저와 무관한 전역 데이터이므로 요청 간에 공유할 수 있다. + * `GET /prediction/games`처럼 대부분의 트래픽이 같은 날짜(오늘)로 수렴하는 경로에서 + * 요청마다 range 쿼리를 재발행하지 않도록 한다. + * + * TTL이 짧은 이유: 경기 중 스코어·상태가 바뀐다. 쓰기 경로는 + * `invalidateGameDay`로 즉시 무효화하므로 TTL은 누락된 무효화의 안전망이다. + */ +const dayCache = new MemCache(15_000, 64); + export type GameWithId = Game & { gameId: string }; /** @@ -37,11 +50,41 @@ export async function listByDate(date: DateString): Promise { } /** - * 동일 날짜의 `listByDate` 결과를 메모이즈하는 캐시. + * `listByDate`의 공유 캐시 래퍼. 요청 경로는 이쪽을 쓴다. + * + * 동일 날짜 동시 요청은 `MemCache`의 inflight 병합으로 단일 쿼리가 된다. + */ +export async function listByDateCached(date: DateString): Promise { + return dayCache.getOrFetch(date, () => listByDate(date)); +} + +/** + * 지정 날짜의 games 캐시를 버린다. `games` 문서를 쓰는 경로에서 호출한다. + * + * 무효화를 빠뜨리면 최대 TTL(15초)만큼 stale 스코어가 노출되므로, + * 경기 문서를 쓰는 새 경로를 추가할 때 함께 호출할 것. + */ +export function invalidateGameDay(date: DateString): void { + dayCache.delete(date); +} + +/** + * 여러 날짜에 걸쳐 games를 쓰는 경로(월 단위 동기화 등)에서 캐시 전체를 버린다. + * 캐시는 최대 64개 날짜뿐이라 전량 폐기 비용이 날짜 추출 비용보다 싸다. + */ +export function invalidateAllGameDays(): void { + dayCache.clear(); +} + +/** + * 동일 날짜의 `listByDate` 결과를 메모이즈하는 run 스코프 캐시. * * `dailyArchive`처럼 한 번의 run에서 여러 유저를 처리하며 같은 날짜의 `games`를 * 반복 조회하는 경로에서, 날짜당 Firestore read를 1회로 줄이기 위해 쓴다. * Promise를 캐싱하므로 동시 호출도 단일 쿼리로 합쳐진다. + * + * 하위 조회는 공유 캐시(`listByDateCached`)를 거치므로, 요청마다 새로 만드는 + * 짧은 수명의 인스턴스도 실제 쿼리를 유발하지 않는다. */ export interface GameDayCache { listByDate(date: DateString): Promise; @@ -54,7 +97,7 @@ export function createGameDayCache(): GameDayCache { listByDate(date: DateString): Promise { let p = cache.get(date); if (!p) { - p = listByDate(date); + p = listByDateCached(date); cache.set(date, p); } return p; diff --git a/src/services/gameSyncService.ts b/src/services/gameSyncService.ts index b1b626f..d13b0e4 100644 --- a/src/services/gameSyncService.ts +++ b/src/services/gameSyncService.ts @@ -4,9 +4,14 @@ import { fetchScheduleFromKbo, statusFromRecord, } from "../repositories/kboRepository"; +import { + invalidateAllGameDays, + invalidateGameDay, +} from "../repositories/gameRepository"; import { getGameList } from "./gameListService"; import type { GameListRecord } from "../kbo/game-list"; import type { ScheduleGame, GameStatus } from "../kbo/schedule"; +import { parseDateString } from "../types/dateString"; import type { Game } from "../types/panit"; const COLLECTION = "games"; @@ -109,7 +114,11 @@ export async function syncGamesForMonth(year: number, month: number): Promise 0) await batch.commit(); + if (count > 0) { + await batch.commit(); + // 월 전체에 걸쳐 쓰므로 날짜별 무효화 대신 일괄 폐기한다. + invalidateAllGameDays(); + } return count; } @@ -196,6 +205,13 @@ export async function forceSyncDay( updated++; }); - if (updated > 0) await batch.commit(); + if (updated > 0) { + await batch.commit(); + invalidateGameDay( + parseDateString( + `${yyyymmdd.slice(0, 4)}-${yyyymmdd.slice(4, 6)}-${yyyymmdd.slice(6, 8)}` + ) + ); + } return { updated }; } diff --git a/src/services/predictionService.ts b/src/services/predictionService.ts index d992580..e0ed74a 100644 --- a/src/services/predictionService.ts +++ b/src/services/predictionService.ts @@ -1,5 +1,5 @@ import { HttpError } from "../middleware/errors"; -import { getGame, listByDate } from "../repositories/gameRepository"; +import { getGame, listByDateCached } from "../repositories/gameRepository"; import { getUserVote, submitVote, @@ -107,7 +107,7 @@ export async function getMyVotes( export async function listGamesByDate(date: string): Promise { try { - const games = await listByDate(parseDateString(date)); + const games = await listByDateCached(parseDateString(date)); return games.map(toGameDto); } catch (err) { throw new HttpError(400, (err as Error).message); diff --git a/tests/repositories/gameRepository.test.ts b/tests/repositories/gameRepository.test.ts index 7afbcd1..9bfee5e 100644 --- a/tests/repositories/gameRepository.test.ts +++ b/tests/repositories/gameRepository.test.ts @@ -1,12 +1,17 @@ import { beforeEach, describe, expect, it } from "vitest"; import { Timestamp } from "firebase-admin/firestore"; import { firestore } from "../../src/firebase"; -import { getGame, listByDate } from "../../src/repositories/gameRepository"; +import { + getGame, + invalidateAllGameDays, + listByDate, +} from "../../src/repositories/gameRepository"; import type { DateString } from "../../src/types/dateString"; describe("gameRepository", () => { beforeEach(async () => { await firestore.recursiveDelete(firestore.collection("games")); + invalidateAllGameDays(); }); it("존재하지 않는 경기는 null을 반환한다", async () => { diff --git a/tests/unit/memCache.test.ts b/tests/unit/memCache.test.ts new file mode 100644 index 0000000..c02c185 --- /dev/null +++ b/tests/unit/memCache.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import { MemCache } from "../../src/lib/memCache"; + +describe("MemCache", () => { + it("TTL 내에는 캐시된 값을 반환하고 fetcher를 다시 돌리지 않는다", async () => { + const cache = new MemCache(60_000); + let calls = 0; + const fetcher = async () => { + calls += 1; + return 42; + }; + + expect(await cache.getOrFetch("k", fetcher)).toBe(42); + expect(await cache.getOrFetch("k", fetcher)).toBe(42); + expect(calls).toBe(1); + }); + + /** + * negative 캐싱 회귀 — truthy 검사로 히트를 판정하면 캐시된 null이 미스와 + * 구분되지 않아 fetcher가 매번 다시 돈다(상품 상세 조회의 Firestore read 증폭). + */ + it("캐시된 null도 히트로 취급해 fetcher를 다시 돌리지 않는다", async () => { + const cache = new MemCache(60_000); + let calls = 0; + const fetcher = async () => { + calls += 1; + return null; + }; + + expect(await cache.getOrFetch("missing", fetcher)).toBeNull(); + expect(await cache.getOrFetch("missing", fetcher)).toBeNull(); + expect(calls).toBe(1); + }); + + it("0과 빈 문자열도 히트로 취급한다", async () => { + const zero = new MemCache(60_000); + const empty = new MemCache(60_000); + let zeroCalls = 0; + let emptyCalls = 0; + + await zero.getOrFetch("z", async () => { + zeroCalls += 1; + return 0; + }); + await zero.getOrFetch("z", async () => { + zeroCalls += 1; + return 0; + }); + await empty.getOrFetch("e", async () => { + emptyCalls += 1; + return ""; + }); + await empty.getOrFetch("e", async () => { + emptyCalls += 1; + return ""; + }); + + expect(zeroCalls).toBe(1); + expect(emptyCalls).toBe(1); + }); + + it("TTL이 지나면 다시 조회한다", async () => { + const cache = new MemCache(-1); // 즉시 만료 + let calls = 0; + const fetcher = async () => { + calls += 1; + return 1; + }; + + await cache.getOrFetch("k", fetcher); + await cache.getOrFetch("k", fetcher); + expect(calls).toBe(2); + }); + + it("delete는 해당 키만, clear는 전부 버린다", async () => { + const cache = new MemCache(60_000); + await cache.getOrFetch("a", async () => 1); + await cache.getOrFetch("b", async () => 2); + + cache.delete("a"); + expect(cache.get("a")).toBeNull(); + expect(cache.get("b")).toBe(2); + + cache.clear(); + expect(cache.get("b")).toBeNull(); + }); + + it("동일 키 동시 호출은 fetcher를 한 번만 실행한다", async () => { + const cache = new MemCache(60_000); + let calls = 0; + const fetcher = async () => { + calls += 1; + await new Promise((r) => setTimeout(r, 10)); + return 7; + }; + + const [a, b, c] = await Promise.all([ + cache.getOrFetch("k", fetcher), + cache.getOrFetch("k", fetcher), + cache.getOrFetch("k", fetcher), + ]); + + expect([a, b, c]).toEqual([7, 7, 7]); + expect(calls).toBe(1); + }); +}); From eb44f5088690e2fa9c1909e298638ad70826ac6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=A0=95=EB=AF=BC?= Date: Mon, 27 Jul 2026 13:26:15 +0900 Subject: [PATCH 3/8] Index user votes by date for daily archive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /userVotesByDate/{date}/{uid}/{gameId} fan-out 인덱스 추가 — RTDB에는 부분 노드 읽기가 없어서 dailyArchive가 하루치를 처리하려면 /userVotes 트리 전체(전 유저 x 전 보존 날짜)를 내려받아야 했다 - 투표를 쓰는 3곳(submitVote, changeVote, processGameEndWithGame의 판정 결과 주입)이 원자 update 하나로 원본과 미러를 함께 기록 - 삭제 경로도 동기화: deleteUserVoteGame은 양쪽에서, deleteUserVoteIndex는 유저의 날짜 목록을 먼저 읽어 해당 날짜 미러만 정리 - database.rules.json에 userVotesByDate·userVotesByDateMeta를 read/write false로 추가 — 교차 유저 데이터라 서버 전용 - 백필 스크립트 추가(npm run backfill:vote-index). dry-run 기본, --apply로 500건씩 청킹 기록, 멱등. 완료 시 /userVotesByDateMeta/backfilledAt 마커를 남겨 잡이 원본 스캔을 그만두는 근거로 쓴다 - 미러 정합성 테스트 8건 추가 --- database.rules.json | 8 +++ package.json | 3 +- scripts/backfill-user-votes-by-date.ts | 85 ++++++++++++++++++++++ src/repositories/voteRepository.ts | 52 +++++++++++++- src/services/gameResultService.ts | 8 ++- tests/repositories/voteRepository.test.ts | 88 +++++++++++++++++++++++ 6 files changed, 240 insertions(+), 4 deletions(-) create mode 100644 scripts/backfill-user-votes-by-date.ts diff --git a/database.rules.json b/database.rules.json index d9dd5e1..458d837 100644 --- a/database.rules.json +++ b/database.rules.json @@ -15,6 +15,14 @@ ".write": false } }, + "userVotesByDate": { + ".read": false, + ".write": false + }, + "userVotesByDateMeta": { + ".read": false, + ".write": false + }, "cache": { ".read": false, ".write": false diff --git a/package.json b/package.json index 8f48979..2c64d68 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "tools:sheet": "npx tsx scripts/chat-tools-sheet.ts", "seed:rewards": "npx tsx scripts/seed-reward-products.ts", "upload:reward-assets": "npx tsx scripts/upload-reward-product-assets.ts", - "optimize:reward-images": "npx tsx scripts/optimize-reward-product-images.ts" + "optimize:reward-images": "npx tsx scripts/optimize-reward-product-images.ts", + "backfill:vote-index": "npx tsx scripts/backfill-user-votes-by-date.ts" }, "engines": { "node": "24" diff --git a/scripts/backfill-user-votes-by-date.ts b/scripts/backfill-user-votes-by-date.ts new file mode 100644 index 0000000..4637845 --- /dev/null +++ b/scripts/backfill-user-votes-by-date.ts @@ -0,0 +1,85 @@ +/** + * `/userVotes/{uid}/{date}/{gameId}` → `/userVotesByDate/{date}/{uid}/{gameId}` 백필. + * + * `dailyArchive`는 날짜별 인덱스만 읽는다(원래는 하루치를 위해 `/userVotes` 트리 + * 전체를 내려받았다). 인덱스 도입 이전에 기록된 투표는 이 스크립트로 옮겨야 + * 잡의 레거시 폴백 경로를 타지 않는다. + * + * 멱등하다 — 여러 번 돌려도 같은 값으로 덮어쓸 뿐이다. + * + * 실행: + * npx tsx scripts/backfill-user-votes-by-date.ts # 미리보기 + * npx tsx scripts/backfill-user-votes-by-date.ts --apply # 실제 쓰기 + */ +import "./_bootstrap"; +import { rtdb } from "../src/firebase"; + +type VoteEntry = { team: string; result?: boolean; cancelled?: boolean }; +type ByUid = Record>>; + +/** RTDB update는 한 번에 너무 많은 경로를 담으면 실패하므로 나눠 커밋한다. */ +const CHUNK = 500; + +async function main(): Promise { + const apply = process.argv.includes("--apply"); + + const snap = await rtdb.ref("/userVotes").get(); + if (!snap.exists()) { + console.log("no /userVotes data — nothing to backfill"); + if (apply) await markBackfilled(); + return; + } + + const byUid = snap.val() as ByUid; + const updates: Record = {}; + let uidCount = 0; + let entryCount = 0; + const dates = new Set(); + + for (const uid of Object.keys(byUid)) { + const byDate = byUid[uid] ?? {}; + let touched = false; + for (const date of Object.keys(byDate)) { + const games = byDate[date] ?? {}; + for (const gameId of Object.keys(games)) { + updates[`/userVotesByDate/${date}/${uid}/${gameId}`] = games[gameId]; + entryCount += 1; + dates.add(date); + touched = true; + } + } + if (touched) uidCount += 1; + } + + const paths = Object.keys(updates); + console.log( + `${entryCount} entries / ${uidCount} uids / ${dates.size} dates` + + (apply ? "" : " (dry run — pass --apply to write)") + ); + if (!apply) return; + + for (let i = 0; i < paths.length; i += CHUNK) { + const slice: Record = {}; + for (const p of paths.slice(i, i + CHUNK)) slice[p] = updates[p]; + await rtdb.ref().update(slice); + console.log(` written ${Math.min(i + CHUNK, paths.length)}/${paths.length}`); + } + await markBackfilled(); + console.log("backfill complete"); +} + +/** + * 완료 표시. 이 값이 있으면 `dailyArchive`는 인덱스가 비어 있어도 + * "그날 투표가 없었다"로 해석하고 레거시 전체 스캔 폴백을 쓰지 않는다. + */ +async function markBackfilled(): Promise { + await rtdb.ref("/userVotesByDateMeta/backfilledAt").set(new Date().toISOString()); + console.log("marked /userVotesByDateMeta/backfilledAt"); +} + +main() + .then(() => process.exit(0)) + .catch((err) => { + console.error(err); + process.exit(1); + }); diff --git a/src/repositories/voteRepository.ts b/src/repositories/voteRepository.ts index db3e1c3..5f62438 100644 --- a/src/repositories/voteRepository.ts +++ b/src/repositories/voteRepository.ts @@ -66,6 +66,7 @@ export async function submitVote(params: { updates[`/votes/${gameId}/counts/${side}Count`] = ServerValue.increment(1); updates[`/votes/${gameId}/users/${uid}`] = { team }; updates[`/userVotes/${uid}/${date}/${gameId}`] = { team }; + updates[byDatePath(uid, date, gameId)] = { team }; await rtdb.ref().update(updates); } @@ -93,9 +94,47 @@ export async function changeVote(params: { updates[`/votes/${gameId}/counts/${newSide}Count`] = ServerValue.increment(1); updates[`/votes/${gameId}/users/${uid}`] = { team: newTeam }; updates[`/userVotes/${uid}/${date}/${gameId}`] = { team: newTeam }; + updates[byDatePath(uid, date, gameId)] = { team: newTeam }; await rtdb.ref().update(updates); } +/** + * 날짜별 투표 인덱스 경로 — `/userVotes/{uid}/{date}`의 (date, uid) 전치 미러. + * + * RTDB에는 부분 노드 읽기가 없어서 `dailyArchive`가 하루치를 처리하려면 + * `/userVotes` 트리 **전체**(전 유저 × 전 보존 날짜)를 내려받아야 했다. + * 쓰기 시점에 fan-out 해 두면 잡이 `/userVotesByDate/{date}` 한 노드만 읽는다. + * + * ⚠️ 이 경로는 서버 전용이다(교차 유저 데이터). RTDB 규칙에서 read/write 모두 false. + */ +function byDatePath(uid: string, date: DateString, gameId: string): string { + return `/userVotesByDate/${date}/${uid}/${gameId}`; +} + +/** + * 특정 날짜에 투표한 전 유저의 기록을 조회한다(아카이브 전용). + * + * @returns `{ [uid]: { [gameId]: VoteEntry } }`. 없으면 빈 객체. + */ +export async function getVotesByDate( + date: DateString +): Promise>> { + const snap = await rtdb.ref(`/userVotesByDate/${date}`).get(); + return snap.exists() ? (snap.val() as Record>) : {}; +} + +/** + * 백필 완료 표시를 조회한다. + * + * 표시가 있으면 날짜별 인덱스가 전 이력을 담고 있다는 뜻이므로, 어떤 날짜가 + * 비어 있어도 그것은 "그날 투표가 없었다"는 사실이지 인덱스 누락이 아니다. + * `dailyArchive`가 레거시 전체 스캔 폴백을 건너뛰는 근거로 쓴다. + */ +export async function isVoteDateIndexBackfilled(): Promise { + const snap = await rtdb.ref("/userVotesByDateMeta/backfilledAt").get(); + return snap.exists(); +} + /** * 특정 유저가 특정 날짜에 한 모든 경기 투표를 조회한다. * @@ -127,7 +166,13 @@ export async function deleteGameVotes(gameId: string): Promise { * 탈퇴 비활성화 시 아카이브가 비활성 계정을 판정하지 않도록 정리한다. */ export async function deleteUserVoteIndex(uid: string): Promise { - await rtdb.ref(`/userVotes/${uid}`).remove(); + // 날짜별 미러도 함께 지운다. 어느 날짜에 기록이 있는지는 원본에만 있으므로 + // 먼저 읽어서 해당 날짜들만 정리한다(유저 1명분이라 작다). + const snap = await rtdb.ref(`/userVotes/${uid}`).get(); + const dates = snap.exists() ? Object.keys(snap.val() as Record) : []; + const updates: Record = { [`/userVotes/${uid}`]: null }; + for (const date of dates) updates[`/userVotesByDate/${date}/${uid}`] = null; + await rtdb.ref().update(updates); } /** @@ -139,5 +184,8 @@ export async function deleteUserVoteGame( date: DateString, gameId: string ): Promise { - await rtdb.ref(`/userVotes/${uid}/${date}/${gameId}`).remove(); + await rtdb.ref().update({ + [`/userVotes/${uid}/${date}/${gameId}`]: null, + [byDatePath(uid, date, gameId)]: null, + }); } diff --git a/src/services/gameResultService.ts b/src/services/gameResultService.ts index c8b4dd8..aa3a59b 100644 --- a/src/services/gameResultService.ts +++ b/src/services/gameResultService.ts @@ -2,7 +2,7 @@ import { FieldValue } from "firebase-admin/firestore"; import { logger } from "firebase-functions"; import { firestore, rtdb } from "../firebase"; import { HttpError } from "../middleware/errors"; -import { getGame } from "../repositories/gameRepository"; +import { getGame, invalidateGameDay } from "../repositories/gameRepository"; import { getAllUserVotes, deleteGameVotes } from "../repositories/voteRepository"; import { invalidateStats } from "./statsService"; import { fromTimestamp } from "../types/dateString"; @@ -36,6 +36,9 @@ export async function processGameEndWithGame( : voted === game.winningTeamCode; rtdbUpdates[`/userVotes/${uid}/${date}/${gameId}/team`] = voted; rtdbUpdates[`/userVotes/${uid}/${date}/${gameId}/result`] = result; + // 날짜별 미러도 같은 원자 update에 포함 — 아카이브가 이쪽을 읽는다. + rtdbUpdates[`/userVotesByDate/${date}/${uid}/${gameId}/team`] = voted; + rtdbUpdates[`/userVotesByDate/${date}/${uid}/${gameId}/result`] = result; } if (Object.keys(rtdbUpdates).length > 0) { await rtdb.ref().update(rtdbUpdates); @@ -71,5 +74,8 @@ export async function markGameEnded( winningTeamCode: isDraw ? FieldValue.delete() : winningTeamCode, endedAt: FieldValue.serverTimestamp(), }); + // 스코어·상태가 바뀌었으므로 해당 날짜의 games 캐시를 즉시 버린다. + const time = (snap.data() as { time?: { toDate(): Date } }).time; + if (time) invalidateGameDay(fromTimestamp(time)); return { ok: true, gameId }; } diff --git a/tests/repositories/voteRepository.test.ts b/tests/repositories/voteRepository.test.ts index 313914a..ab9409f 100644 --- a/tests/repositories/voteRepository.test.ts +++ b/tests/repositories/voteRepository.test.ts @@ -7,6 +7,10 @@ import { getCounts, getUserDateVotes, getUserVote, + getVotesByDate, + isVoteDateIndexBackfilled, + deleteUserVoteGame, + deleteUserVoteIndex, submitVote, } from "../../src/repositories/voteRepository"; import type { DateString } from "../../src/types/dateString"; @@ -175,3 +179,87 @@ describe("voteRepository (RTDB)", () => { }); }); }); + +/** + * 날짜별 인덱스는 `/userVotes`의 (date, uid) 전치 미러다. dailyArchive가 이쪽만 + * 읽으므로, 원본을 바꾸는 모든 경로가 미러도 같이 갱신해야 투표가 유실되지 않는다. + */ +describe("voteRepository — /userVotesByDate 미러", () => { + beforeEach(async () => { + await rtdb.ref("/votes").remove(); + await rtdb.ref("/userVotes").remove(); + await rtdb.ref("/userVotesByDate").remove(); + }); + + it("submitVote가 날짜별 인덱스에도 기록한다", async () => { + await submitVote({ gameId, uid, date, side: "home", team: "LG" }); + + const byDate = await getVotesByDate(date); + expect(byDate[uid]?.[gameId]).toEqual({ team: "LG" }); + }); + + it("여러 유저의 같은 날짜 투표가 한 노드에 모인다", async () => { + await submitVote({ gameId, uid, date, side: "home", team: "LG" }); + await submitVote({ gameId, uid: uid2, date, side: "away", team: "KIA" }); + await submitVote({ gameId: gameId2, uid, date, side: "draw", team: "DRAW" }); + + const byDate = await getVotesByDate(date); + expect(Object.keys(byDate).sort()).toEqual([uid, uid2].sort()); + expect(Object.keys(byDate[uid])).toHaveLength(2); + }); + + it("changeVote가 미러의 팀도 함께 바꾼다", async () => { + await submitVote({ gameId, uid, date, side: "home", team: "LG" }); + await changeVote({ + gameId, uid, date, oldSide: "home", newSide: "away", newTeam: "KIA", + }); + + const byDate = await getVotesByDate(date); + expect(byDate[uid][gameId]).toEqual({ team: "KIA" }); + }); + + it("deleteUserVoteGame이 양쪽에서 제거한다", async () => { + await submitVote({ gameId, uid, date, side: "home", team: "LG" }); + await submitVote({ gameId: gameId2, uid, date, side: "away", team: "KIA" }); + + await deleteUserVoteGame(uid, date, gameId); + + expect(await getUserDateVotes(uid, date)).toEqual({ [gameId2]: { team: "KIA" } }); + const byDate = await getVotesByDate(date); + expect(byDate[uid]).toEqual({ [gameId2]: { team: "KIA" } }); + }); + + it("deleteUserVoteIndex가 그 유저의 모든 날짜 미러를 지운다", async () => { + const other = "2026-04-13" as DateString; + await submitVote({ gameId, uid, date, side: "home", team: "LG" }); + await submitVote({ gameId: gameId2, uid, date: other, side: "away", team: "KIA" }); + await submitVote({ gameId, uid: uid2, date, side: "home", team: "LG" }); + + await deleteUserVoteIndex(uid); + + expect(await getUserDateVotes(uid, date)).toEqual({}); + expect((await getVotesByDate(date))[uid]).toBeUndefined(); + expect((await getVotesByDate(other))[uid]).toBeUndefined(); + // 다른 유저 기록은 남아 있어야 한다 + expect((await getVotesByDate(date))[uid2]).toEqual({ [gameId]: { team: "LG" } }); + }); + + it("투표가 없는 날짜는 빈 객체를 돌려준다", async () => { + expect(await getVotesByDate("2026-01-01" as DateString)).toEqual({}); + }); +}); + +describe("voteRepository — 백필 완료 표시", () => { + beforeEach(async () => { + await rtdb.ref("/userVotesByDateMeta").remove(); + }); + + it("표시가 없으면 false", async () => { + expect(await isVoteDateIndexBackfilled()).toBe(false); + }); + + it("표시가 있으면 true — dailyArchive가 레거시 폴백을 건너뛰는 근거", async () => { + await rtdb.ref("/userVotesByDateMeta/backfilledAt").set(new Date().toISOString()); + expect(await isVoteDateIndexBackfilled()).toBe(true); + }); +}); From 513bf70e87c3ca476b593f8be07e69bd91f42992 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=A0=95=EB=AF=BC?= Date: Mon, 27 Jul 2026 13:26:41 +0900 Subject: [PATCH 4/8] Reduce per-user reads in daily archive and stats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dailyArchive가 날짜별 인덱스만 읽도록 전환. 백필 마커가 없는 롤아웃 기간에는 원본도 함께 읽어 인덱스에 없는 uid만 보충한다 — "인덱스가 비었을 때만 폴백"으로 두면 미러 배포 당일처럼 인덱스가 부분적으로만 찬 날짜에서 배포 전 투표자가 판정·보상·스트릭 없이 영구 유실된다(그 날짜는 다시 처리되지 않는다) - 순위표를 run 시작 시 1회 로드해 유저별 count aggregation을 제거(O(N x M) -> 이진 탐색). listAllRankedUsers의 프로젝션에 favoriteTeamCode 추가(문서당 과금이라 read unit은 불변). 로드 실패 시 기존 aggregation으로 폴백 - user 문서를 루프에서 1회만 읽어 computeRankSnapshot과 judgeDay가 공유 — 유저당 3 read 중 1개 제거. 판정 트랜잭션 내부 tx.get은 멱등 가드와 streak read-modify-write 구동에 필수라 유지. getUser 실패 시엔 undefined로 남겨 judgeDay가 재조회하게 한다(null을 넘기면 결석 판정이 조용히 꺼진다) - reconcileDayVotes가 날짜 경기를 1회 확보해 getGame N+1 제거. run 스코프 Set으로 같은 경기의 processGameEndWithGame 중복 재처리 차단(byUid는 정지된 스냅샷이라 앞 유저가 치유한 경기도 뒤 유저에겐 미판정으로 보인다) - settleDailyReward에 gameCache 파라미터 추가하고 games 조회를 트랜잭션 조기 return 가드 뒤로 이동 — no_history/already_settled/not_judged 재실행은 games read 0회로 끝난다 - 통산 예측·적중 롤링 카운터 도입(user 문서). computeStats가 전수 스캔 대신 카운터 + 올해 구간 range 조회 1회를 쓴다. 미백필 유저만 1회 스캔 후 백필하며, 이력이 없으면 기준선을 세우지 않는다(오늘로 잡으면 아직 아카이브되지 않은 어제 투표가 영구 누락된다). 기준선을 lastJudgedDate로 앞당기지도 않는다 — judgeDay가 판정 트랜잭션을 voteHistory 기록보다 먼저 커밋하므로 같은 누락이 생긴다 - stats period 범위 검증(연도 2024~올해, 월 1-12, 미래 날짜 거부)과 캐시 키 정규화 추가 — 검증이 없으면 임의 period 값으로 캐시를 매번 미스시켜 전수 집계를 강제할 수 있었고, 같은 주의 7개 날짜가 7개 캐시 엔트리를 만들었다 - precomputeScoreboardCache에 scopes 파라미터 추가 — 탈퇴 경로가 11개 전체 대신 overall과 본인 팀만 재계산(영향 범위가 그 둘뿐이고 existing은 이미 읽은 값이다) - 테스트 16건 추가: 순위표 동등성 6건, 통산 집계·기준선 7건, dailyArchive 롤아웃 5건(신규 파일) --- src/repositories/userRepository.ts | 34 +++- src/scheduled/dailyArchive.ts | 139 +++++++++++++--- src/services/judgmentService.ts | 24 ++- src/services/rankSnapshotService.ts | 104 +++++++++--- src/services/rewardSettlementService.ts | 16 +- src/services/statsService.ts | 193 +++++++++++++++++++--- src/services/userService.ts | 9 +- src/types/panit.ts | 13 ++ tests/scheduled/dailyArchive.test.ts | 129 +++++++++++++++ tests/services/judgmentService.test.ts | 4 + tests/services/scoreboardService.test.ts | 58 +++++++ tests/services/statsService.test.ts | 195 +++++++++++++++++++++++ 12 files changed, 840 insertions(+), 78 deletions(-) create mode 100644 tests/scheduled/dailyArchive.test.ts diff --git a/src/repositories/userRepository.ts b/src/repositories/userRepository.ts index a5b5db9..4fd13fe 100644 --- a/src/repositories/userRepository.ts +++ b/src/repositories/userRepository.ts @@ -130,10 +130,14 @@ export async function listTopByTierPoints( * 시즌 정산 전용: 페이지 단위로 나눠 읽되 결과는 전량 메모리에 올린다. */ export async function listAllRankedUsers(): Promise< - Array<{ uid: string; tierPoints: number }> + Array<{ uid: string; tierPoints: number; favoriteTeamCode?: TeamCode }> > { const PAGE = 500; - const results: Array<{ uid: string; tierPoints: number }> = []; + const results: Array<{ + uid: string; + tierPoints: number; + favoriteTeamCode?: TeamCode; + }> = []; let last: FirebaseFirestore.QueryDocumentSnapshot | undefined; for (;;) { let query = firestore @@ -141,14 +145,18 @@ export async function listAllRankedUsers(): Promise< .where("active", "==", true) .where("tierPoints", ">", 0) .orderBy("tierPoints", "desc") - .select("tierPoints") + // favoriteTeamCode는 팀 스코프 순위를 in-memory로 만들기 위해 함께 읽는다. + // 프로젝션 필드 추가는 read unit에 영향이 없다(문서당 과금) — 대역폭만 늘어난다. + .select("tierPoints", "favoriteTeamCode") .limit(PAGE); if (last) query = query.startAfter(last); const snap = await query.get(); for (const d of snap.docs) { + const data = d.data() as Partial; results.push({ uid: d.id, - tierPoints: (d.data() as Partial).tierPoints ?? 0, + tierPoints: data.tierPoints ?? 0, + ...(data.favoriteTeamCode ? { favoriteTeamCode: data.favoriteTeamCode } : {}), }); } if (snap.docs.length < PAGE) return results; @@ -341,6 +349,12 @@ export async function applyDailyJudgmentTx( * 보존하려면 호출자가 트랜잭션 호출 전에 계산해 넘겨야 한다. */ rankSnapshot?: RankSnapshot; + /** + * 통산 집계에 더할 값 — 취소 무효표를 제외한 예측 수와 적중 수. + * `judgment`가 skip이어도 투표 자체는 승률 모집단이므로 `correctCount`와 + * 별개로 계산해서 넘긴다. + */ + lifetimeDelta?: { predictions: number; correct: number }; computePoints: (streakAfter: number) => number; } ): Promise { @@ -384,6 +398,18 @@ export async function applyDailyJudgmentTx( }; // 판정 전 rank 스냅샷을 같은 patch에 합쳐 user doc write를 1회로 줄인다. if (input.rankSnapshot) patch.rankSnapshot = input.rankSnapshot; + + // 통산 집계 증분 — 백필(computeStats)이 이미 세운 기준선 이후 날짜만 더한다. + // 기준선이 없으면(미백필 유저) 아무것도 하지 않는다. 백필이 전수 스캔으로 + // 세우고 나서부터 증분이 이어진다. + const through = user.lifetimeStatsThrough; + if (input.lifetimeDelta && through != null && date > through) { + patch.lifetimePredictions = + (user.lifetimePredictions ?? 0) + input.lifetimeDelta.predictions; + patch.lifetimeCorrect = + (user.lifetimeCorrect ?? 0) + input.lifetimeDelta.correct; + patch.lifetimeStatsThrough = date; + } tx.set(ref, patch, { merge: true }); return { diff --git a/src/scheduled/dailyArchive.ts b/src/scheduled/dailyArchive.ts index b7ba322..a5711ac 100644 --- a/src/scheduled/dailyArchive.ts +++ b/src/scheduled/dailyArchive.ts @@ -7,12 +7,28 @@ import { judgeDay } from "../services/judgmentService"; import { precomputeScoreboardCache, computeRankSnapshot, + loadRankStandings, + type RankStandings, } from "../services/rankSnapshotService"; +import { getUser } from "../repositories/userRepository"; import { maybeSettleSeason } from "../services/seasonService"; import { todayKst } from "../types/dateString"; -import { getGame, createGameDayCache } from "../repositories/gameRepository"; +import { + getGame, + createGameDayCache, + type GameDayCache, +} from "../repositories/gameRepository"; import { processGameEndWithGame } from "../services/gameResultService"; -import { DRAW_TEAM_CODE, type RankSnapshot, type VoteHistoryDoc } from "../types/panit"; +import { + getVotesByDate, + isVoteDateIndexBackfilled, +} from "../repositories/voteRepository"; +import { + DRAW_TEAM_CODE, + type RankSnapshot, + type User, + type VoteHistoryDoc, +} from "../types/panit"; import { daysAgoKst, type DateString, @@ -38,13 +54,19 @@ type DayVotes = Record; async function reconcileDayVotes( uid: string, date: DateString, - dayVotes: DayVotes + dayVotes: DayVotes, + gameCache: GameDayCache, + healed: Set ): Promise { const result: DayVotes = { ...dayVotes }; + // 해당 날짜 경기를 한 번에 확보한다 — 유저×미판정경기 수만큼 getGame을 치던 N+1 제거. + const byId = new Map( + (await gameCache.listByDate(date)).map((g) => [g.gameId, g]) + ); for (const [gameId, vote] of Object.entries(dayVotes)) { if (vote.result !== undefined || vote.cancelled) continue; - const game = await getGame(gameId); + const game = byId.get(gameId) ?? (await getGame(gameId)); if (!game) { logger.warn(`reconcile: game not found ${gameId}, uid=${uid}`); continue; @@ -55,8 +77,14 @@ async function reconcileDayVotes( // (`processGameEndWithGame`과 동일 규칙). if (game.status === "completed") { try { - // 아카이브는 루프 끝에서 유저 단위로 1회 무효화하므로 경기별 fan-out은 생략. - await processGameEndWithGame(gameId, game, { skipInvalidate: true }); + // `byUid`는 정지된 스냅샷이라 앞선 유저가 치유한 경기도 뒤 유저에겐 여전히 + // 미판정으로 보인다. run 스코프 Set으로 경기당 1회만 처리해, 같은 경기의 + // 투표자 전원 재처리(getAllUserVotes + RTDB update + deleteGameVotes)를 막는다. + if (!healed.has(gameId)) { + // 아카이브는 루프 끝에서 유저 단위로 1회 무효화하므로 경기별 fan-out은 생략. + await processGameEndWithGame(gameId, game, { skipInvalidate: true }); + healed.add(gameId); + } const isDraw = !game.winningTeamCode; result[gameId] = { team: vote.team, @@ -91,31 +119,74 @@ async function reconcileDayVotes( export async function runDailyArchive( overrideDate?: DateString ): Promise<{ date: DateString; archived: number; judgedUids: string[] }> { - const date = overrideDate ?? daysAgoKst(1); - logger.info(`dailyArchive start: ${date}`); + const date = overrideDate ?? daysAgoKst(1); + logger.info(`dailyArchive start: ${date}`); - let archived = 0; - const judgedUids: string[] = []; - // 같은 날짜(및 결석 판정용 직전 날짜들)의 games를 유저마다 재조회하지 않도록 - // run 전체에서 1개의 날짜별 캐시를 공유한다. 날짜당 Firestore read 1회로 수렴. - const gameCache = createGameDayCache(); + let archived = 0; + const judgedUids: string[] = []; + // 같은 날짜(및 결석 판정용 직전 날짜들)의 games를 유저마다 재조회하지 않도록 + // run 전체에서 1개의 날짜별 캐시를 공유한다. 날짜당 Firestore read 1회로 수렴. + const gameCache = createGameDayCache(); + // run 중 자가치유된 경기 — 뒤따르는 유저들이 같은 경기를 재처리하지 않도록 한다. + const healedGames = new Set(); + // 순위표를 run 시작 시 1회 로드해 유저별 count aggregation(O(N×M))을 없앤다. + // 실패 시 standings 없이 진행하면 computeRankSnapshot이 aggregation으로 폴백한다. + let standings: RankStandings | null = null; try { - const snap = await rtdb.ref("/userVotes").get(); - if (!snap.exists()) { + standings = await loadRankStandings(); + } catch (err) { + logger.error("loadRankStandings failed — per-user aggregation으로 폴백", err); + } + try { + // 날짜별 인덱스만 읽는다 — 예전에는 하루치를 위해 `/userVotes` 트리 전체 + // (전 유저 × 전 보존 날짜)를 내려받았다. + const byUid = (await getVotesByDate(date)) as Record; + let mergedFromLegacy = 0; + // 백필이 끝났으면 인덱스가 전 이력을 담고 있으므로, 비어 있다는 것은 + // "그날 투표가 없었다"는 사실이다 — 전체 스캔으로 되돌아가지 않는다. + // (이 가드가 없으면 월요일·비시즌 같은 무투표일마다 트리 전체를 다시 읽는다.) + // + // 반대로 마커가 없는 롤아웃 기간에는 인덱스가 **부분적으로만** 찼을 수 있다. + // 미러 배포 전에 투표한 유저는 인덱스에 없고 원본에만 있는데, 같은 날 배포 후 + // 투표한 유저가 하나라도 있으면 인덱스가 비지 않는다. "비었을 때만 폴백"으로 + // 두면 그 배포 전 투표자들이 판정·보상·스트릭 없이 영구 유실된다 + // (아카이브는 매 run 다른 날짜를 처리하므로 그 날짜는 다시 열리지 않는다). + // 그래서 마커가 없으면 항상 원본을 읽어 인덱스에 없는 uid만 보충한다. + if (!(await isVoteDateIndexBackfilled())) { + const legacy = await rtdb.ref("/userVotes").get(); + if (legacy.exists()) { + const all = legacy.val() as Record>; + for (const uid of Object.keys(all)) { + const day = all[uid]?.[date]; + // 인덱스 값이 우선 — 원본은 인덱스에 없는 uid를 채우는 용도로만 쓴다. + if (day && !(uid in byUid)) { + byUid[uid] = day; + mergedFromLegacy += 1; + } + } + } + if (mergedFromLegacy > 0) { + logger.warn( + `dailyArchive: ${date} — 인덱스에 없는 ${mergedFromLegacy}명을 원본에서 보충했다. ` + + "백필 스크립트(npm run backfill:vote-index -- --apply) 실행 권장" + ); + } + } + + if (Object.keys(byUid).length === 0) { logger.info("no userVotes to archive"); return { date, archived: 0, judgedUids: [] }; } - const byUid = snap.val() as Record>; for (const uid of Object.keys(byUid)) { - let dayVotes = byUid[uid]?.[date]; + let dayVotes = byUid[uid]; if (!dayVotes) continue; const hasUnjudged = Object.values(dayVotes).some( (v) => v.result === undefined && !v.cancelled ); if (hasUnjudged) { - dayVotes = await reconcileDayVotes(uid, date, dayVotes); + dayVotes = await reconcileDayVotes(uid, date, dayVotes, gameCache, healedGames); } const data: VoteHistoryDoc["data"] = []; @@ -141,28 +212,52 @@ export async function runDailyArchive( // 스트릭 유지 로직이 동작하도록 judgeDay를 호출한다. // 판정 전 rank를 계산해(=현재 tierPoints 기준) judgeDay 트랜잭션에 함께 기록한다. // 판정 트랜잭션과 같은 patch로 묶여 user doc write가 1회로 준다("판정 전 rank" 보존). + // user 문서는 여기서 1회만 읽어 rank 계산과 judgeDay가 공유한다. + // (판정 트랜잭션 내부의 tx.get은 원자성상 필수라 남는다.) + // 읽기 실패는 undefined로 남긴다 — null을 넘기면 judgeDay가 "유저 문서 없음" + // 으로 해석해 결석 판정을 건너뛴다. undefined면 judgeDay가 스스로 다시 읽는다. + let user: User | null | undefined; + try { + user = await getUser(uid); + } catch (err) { + logger.error(`getUser failed uid=${uid} — judgeDay가 재조회한다`, err); + } let rankSnapshot: RankSnapshot | null = null; try { - rankSnapshot = await computeRankSnapshot(uid, date); + rankSnapshot = await computeRankSnapshot(uid, date, { + ...(user !== undefined ? { user } : {}), + ...(standings ? { standings } : {}), + }); } catch (err) { logger.error(`computeRankSnapshot failed uid=${uid} date=${date}`, err); } // voteHistory 기록은 judgeDay가 1회 수행한다(판정 필드 포함). 별도 선기록은 생략. // judgeDay가 doc을 영속화한 뒤에야 userVotes를 제거해 데이터 유실을 막는다. try { - await judgeDay(uid, date, { data }, { gameCache, rankSnapshot }); + await judgeDay(uid, date, { data }, { + gameCache, + rankSnapshot, + ...(user !== undefined ? { userPre: user } : {}), + }); } catch (err) { logger.error(`judgeDay failed uid=${uid} date=${date}`, err); // 판정 실패 시에도 data만은 보존(기존 동작 유지) — userVotes를 곧 지우기 때문. await setDay(uid, date, { data }).catch(() => undefined); } - await rtdb.ref(`/userVotes/${uid}/${date}`).remove(); + // 원본과 날짜별 미러를 함께 정리한다. + await rtdb.ref().update({ + [`/userVotes/${uid}/${date}`]: null, + [`/userVotesByDate/${date}/${uid}`]: null, + }); await invalidateStats(uid).catch(() => undefined); judgedUids.push(uid); archived += 1; } - logger.info(`dailyArchive done: ${archived} users archived for ${date}`); + logger.info( + `dailyArchive done: ${archived} users archived for ${date}` + + (mergedFromLegacy > 0 ? ` (원본 보충 ${mergedFromLegacy}명)` : "") + ); return { date, archived, judgedUids }; } finally { // 시즌 종료 다음 날 첫 archive: 어제(=시즌 마지막 날) 판정을 반영한 뒤 diff --git a/src/services/judgmentService.ts b/src/services/judgmentService.ts index 37a6f3b..8f12227 100644 --- a/src/services/judgmentService.ts +++ b/src/services/judgmentService.ts @@ -13,7 +13,7 @@ import { streakBonus, thresholdsFor, } from "../constants/judgment"; -import type { DailyJudgment, RankSnapshot, VoteHistoryDoc } from "../types/panit"; +import type { DailyJudgment, RankSnapshot, User, VoteHistoryDoc } from "../types/panit"; import { addDays, type DateString } from "../types/dateString"; import { settleDailyReward } from "./rewardSettlementService"; @@ -56,12 +56,17 @@ export async function hasMissedGameDayBetween( * @param voteDoc - 해당 날짜의 voteHistory 도큐먼트 내용(판정 필드 미포함) * @param opts.gameCache - 여러 유저를 처리할 때 날짜별 games read를 공유하는 캐시 * @param opts.rankSnapshot - 판정 직전 rank 스냅샷. 제공 시 판정 트랜잭션에 함께 기록한다. + * @param opts.userPre - 호출자가 이미 읽은 판정 전 user 문서. 넘기면 재조회하지 않는다. */ export async function judgeDay( uid: string, date: DateString, voteDoc: VoteHistoryDoc, - opts?: { gameCache?: GameDayCache; rankSnapshot?: RankSnapshot | null } + opts?: { + gameCache?: GameDayCache; + rankSnapshot?: RankSnapshot | null; + userPre?: User | null; + } ): Promise { const fetch = opts?.gameCache ?? createGameDayCache(); const games = await fetch.listByDate(date); @@ -80,19 +85,30 @@ export async function judgeDay( // 결석 여부는 휴장일을 제외하고 판단한다. (lastJudged, date) 구간에 실제 // 경기일이 하나라도 있었는데 user가 그날 안 했으면 streak 끊김. - const userPre = await getUser(uid); + // 판정 트랜잭션 내부의 tx.get은 원자성(멱등 가드 + streak read-modify-write)상 + // 필수라 남긴다. 여기 read만 호출자 주입으로 제거 가능하다. + const userPre = + opts && "userPre" in opts ? opts.userPre : await getUser(uid); const lastJudgedPre = userPre?.lastJudgedDate; const streakBrokenIn = lastJudgedPre != null && lastJudgedPre < addDays(date, -1) && (await hasMissedGameDayBetween(lastJudgedPre, date, fetch)); + // 통산 승률 모집단은 판정(skip 포함)과 무관하게 "결과가 확정된 투표" 전부다. + // 취소 무효표(result 없음)는 제외한다 — aggregate()의 정의와 동일. + const countable = voteDoc.data.filter((v) => typeof v.result === "boolean"); + const tx = await applyDailyJudgmentTx(uid, date, { judgment, correctCount, completedCount, streakBrokenIn, rankSnapshot: opts?.rankSnapshot ?? undefined, + lifetimeDelta: { + predictions: countable.length, + correct: countable.filter((v) => v.result).length, + }, computePoints: (streakAfter) => correctCount * 10 + streakBonus(streakAfter), }); @@ -103,7 +119,7 @@ export async function judgeDay( // 정상 멱등 재실행에서는 doc이 이미 존재하므로 판정 필드를 덮어쓰지 않는다. const existing = await getDay(uid, date); if (!existing) await setDay(uid, date, voteDoc); - await settleDailyReward(uid, date).catch((err) => logger.error(`reward settlement failed uid=${uid} date=${date}`, err)); + await settleDailyReward(uid, date, fetch).catch((err) => logger.error(`reward settlement failed uid=${uid} date=${date}`, err)); return; } diff --git a/src/services/rankSnapshotService.ts b/src/services/rankSnapshotService.ts index f80528a..e865b69 100644 --- a/src/services/rankSnapshotService.ts +++ b/src/services/rankSnapshotService.ts @@ -4,6 +4,7 @@ import { countRankedUsers, countUsersAboveTierPoints, getUser, + listAllRankedUsers, listTopByTierPoints, type ScoreboardUserEntry, } from "../repositories/userRepository"; @@ -11,40 +12,93 @@ import { writeScope, type Scope } from "../repositories/scoreboardCacheRepositor import { tierOf } from "../constants/tiers"; import { computePercentile, deltaFor } from "./scoreboardHelpers"; import type { DateString } from "../types/dateString"; -import { TeamCode, type RankSnapshot } from "../types/panit"; +import { TeamCode, type RankSnapshot, type User } from "../types/panit"; import type { ScoreboardEntry, ScoreboardScopeCache, } from "../types/scoreboard"; /** - * 유저 1명의 현재 `tierPoints` 기반 rank를 count aggregation으로 계산해 - * `rankSnapshot`에 기록한다. `dailyArchive`에서 `judgeDay` **이전에** 호출해 - * 스냅샷이 "직전 상태의 rank"를 담도록 한다. + * run 스코프 순위표 — `tierPoints`만으로 순위를 이진 탐색으로 구한다. + * + * `dailyArchive`처럼 유저 N명을 한 run에서 처리하는 경로에서, 유저마다 1~2회씩 + * count aggregation을 발행하던 O(N×M) 비용을 순위표 1회 로드로 대체한다. + */ +export interface RankStandings { + /** 동점자 동일 순위("초과 인원 + 1"). `teamCode` 지정 시 팀 내 순위. */ + rankOf(tierPoints: number, teamCode?: TeamCode): number; +} + +/** 내림차순 배열에서 `threshold` 초과 원소 개수(= 첫 `<= threshold` 위치). */ +function countAbove(sortedDesc: number[], threshold: number): number { + let lo = 0; + let hi = sortedDesc.length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (sortedDesc[mid] > threshold) lo = mid + 1; + else hi = mid; + } + return lo; +} + +/** 랭킹 대상 전원 목록으로 순위표를 만든다. */ +export function buildRankStandings( + users: Array<{ tierPoints: number; favoriteTeamCode?: TeamCode }> +): RankStandings { + const overall = users.map((u) => u.tierPoints).sort((a, b) => b - a); + const byTeam = new Map(); + for (const u of users) { + if (!u.favoriteTeamCode) continue; + const list = byTeam.get(u.favoriteTeamCode); + if (list) list.push(u.tierPoints); + else byTeam.set(u.favoriteTeamCode, [u.tierPoints]); + } + for (const list of byTeam.values()) list.sort((a, b) => b - a); + + return { + rankOf(tierPoints: number, teamCode?: TeamCode): number { + const list = teamCode ? byTeam.get(teamCode) ?? [] : overall; + return countAbove(list, tierPoints) + 1; + }, + }; +} + +/** 랭킹 대상 전원을 읽어 순위표를 만든다. run 시작 시 1회만 호출할 것. */ +export async function loadRankStandings(): Promise { + return buildRankStandings(await listAllRankedUsers()); +} + +/** + * 유저 1명의 현재 `tierPoints` 기반 rank를 계산한다. `dailyArchive`에서 + * `judgeDay` **이전에** 호출해 스냅샷이 "직전 상태의 rank"를 담도록 한다. * * `tierPoints === 0`이면 스냅샷을 남기지 않는다. + * + * @param opts.user - 호출자가 이미 읽은 user 문서. 넘기면 재조회하지 않는다. + * @param opts.standings - run 스코프 순위표. 넘기면 count aggregation을 쓰지 않는다. */ export async function computeRankSnapshot( uid: string, - date: DateString + date: DateString, + opts?: { user?: User | null; standings?: RankStandings } ): Promise { - const user = await getUser(uid); + const user = opts && "user" in opts ? opts.user : await getUser(uid); if (!user) return null; const tierPoints = user.tierPoints ?? 0; if (tierPoints <= 0) return null; - const overallAbove = await countUsersAboveTierPoints(tierPoints); + const standings = opts?.standings; const snapshot: RankSnapshot = { date, - overall: overallAbove + 1, + overall: standings ? + standings.rankOf(tierPoints) : + (await countUsersAboveTierPoints(tierPoints)) + 1, }; if (user.favoriteTeamCode) { - const teamAbove = await countUsersAboveTierPoints( - tierPoints, - user.favoriteTeamCode - ); - snapshot.team = teamAbove + 1; + snapshot.team = standings ? + standings.rankOf(tierPoints, user.favoriteTeamCode) : + (await countUsersAboveTierPoints(tierPoints, user.favoriteTeamCode)) + 1; snapshot.teamCode = user.favoriteTeamCode; } @@ -132,20 +186,28 @@ async function buildScopeCache(scope: Scope): Promise { }; } -/** - * 크론에서 호출: overall + 10팀 각각의 top 10 / totalCount를 RTDB에 기록한다. - * `snapshotRanksForUsers` 이후에 호출해야 rankDelta가 정확함. - */ -export async function precomputeScoreboardCache( - date: DateString -): Promise { - const scopes: Scope[] = [ +/** overall + 10팀 = 전체 11개 스코프. */ +export function allScoreboardScopes(): Scope[] { + return [ { kind: "overall" }, ...Object.values(TeamCode).map( (teamCode) => ({ kind: "team" as const, teamCode }) ), ]; +} +/** + * overall + 10팀 각각의 top 10 / totalCount를 RTDB에 기록한다. + * `snapshotRanksForUsers` 이후에 호출해야 rankDelta가 정확함. + * + * @param scopes 재계산할 스코프. 한 유저의 변경처럼 영향 범위가 좁을 땐 해당 + * 스코프만 넘겨 11개 전체 재계산(~110 read + 11 aggregation)을 피한다. + * 생략 시 전체(크론 경로). + */ +export async function precomputeScoreboardCache( + date: DateString, + scopes: Scope[] = allScoreboardScopes() +): Promise { for (const scope of scopes) { try { const doc = await buildScopeCache(scope); diff --git a/src/services/rewardSettlementService.ts b/src/services/rewardSettlementService.ts index 95044f3..9e53da6 100644 --- a/src/services/rewardSettlementService.ts +++ b/src/services/rewardSettlementService.ts @@ -1,20 +1,30 @@ import { Timestamp } from "firebase-admin/firestore"; import { firestore } from "../firebase"; import { PREDICTION_PARTICIPATION_POINTS, PREDICTION_PERFECT_BONUS_POINTS, PREDICTION_SUCCESS_POINTS } from "../constants/points"; -import { listByDate } from "../repositories/gameRepository"; +import { createGameDayCache, type GameDayCache } from "../repositories/gameRepository"; import { PointLedgerType, type VoteHistoryDoc } from "../types/panit"; import type { DateString } from "../types/dateString"; import type { PointChange } from "../types/points"; import { applyPointChangesTx, isAlreadyExistsError } from "./pointService"; export type SettlementResult = "settled" | "no_history" | "already_settled" | "not_judged"; -export async function settleDailyReward(uid: string, date: DateString): Promise<{ result: SettlementResult; total: number }> { - const eligible = (await listByDate(date)).filter((g) => g.status === "completed"); const ref = firestore.doc(`users/${uid}/voteHistory/${date}`); + +/** + * 하루치 예측 보상을 정산한다. + * + * @param gameCache - 여러 유저를 한 run에서 처리할 때 날짜별 games read를 공유하는 캐시. + * 미지정 시 자체 생성하지만, 하위 조회가 공유 캐시를 거치므로 단독 호출도 안전하다. + */ +export async function settleDailyReward(uid: string, date: DateString, gameCache?: GameDayCache): Promise<{ result: SettlementResult; total: number }> { + const ref = firestore.doc(`users/${uid}/voteHistory/${date}`); try { return await firestore.runTransaction(async (tx) => { const snap = await tx.get(ref); if (!snap.exists) return { result: "no_history" as const, total: 0 }; const history = snap.data() as VoteHistoryDoc; if (history.rewardSettledAt) return { result: "already_settled" as const, total: history.rewardTotal ?? 0 }; if (!history.judgment) return { result: "not_judged" as const, total: 0 }; + // games 조회는 full 판정에만 필요하므로 조기 return 가드 뒤에서 수행한다 — + // no_history/already_settled/not_judged 재실행은 games read 0회로 끝난다. + const eligible = (await (gameCache ?? createGameDayCache()).listByDate(date)).filter((g) => g.status === "completed"); const voted = new Set(history.data.filter((v) => !v.cancelled).map((v) => v.gameId)); const full = eligible.length > 0 && eligible.every((g) => voted.has(g.gameId)); const changes: PointChange[] = []; if (full) { diff --git a/src/services/statsService.ts b/src/services/statsService.ts index 02c7f2e..883fb5a 100644 --- a/src/services/statsService.ts +++ b/src/services/statsService.ts @@ -1,8 +1,8 @@ import {rtdb} from "../firebase"; import {HttpError} from "../middleware/errors"; import {tierOf} from "../constants/tiers"; -import {getAll, getDay} from "../repositories/voteHistoryRepository"; -import {getUser} from "../repositories/userRepository"; +import {getAll, getDay, getRange} from "../repositories/voteHistoryRepository"; +import {getUser, updateUser} from "../repositories/userRepository"; import {hasMissedGameDayBetween} from "./judgmentService"; import type {DailyJudgment, StatsResponse, VoteHistoryDoc} from "../types/panit"; import {EMPTY_VOTE_HISTORY_DTO, toVoteHistoryDto} from "../types/dto/statsDto"; @@ -17,6 +17,9 @@ import { type DateString, } from "../types/dateString"; +/** 조회 가능한 가장 이른 연도 — 서비스 개시 이전은 받지 않는다. */ +const EARLIEST_STATS_YEAR = 2024; + type Period = | "current" | { kind: "year"; year: number } @@ -33,19 +36,56 @@ type Period = * - `"2026-04-23"` → 해당 날짜가 속한 주 (화~월) * * @param p - 기간 문자열 - * @throws {HttpError} 400 — 형식이 맞지 않을 때 + * @throws {HttpError} 400 — 형식이 맞지 않거나 허용 범위를 벗어날 때 */ function parsePeriod(p: string | undefined): Period { if (!p || p === "current") return "current"; + const today = todayKst(); + const {y: nowYear} = parseYmd(today); + // 범위 검증 — 검증이 없으면 서로 다른 period 값을 무한히 만들어 + // 캐시를 매번 미스시키고 전수 집계를 강제할 수 있다. + const inYearRange = (y: number) => y >= EARLIEST_STATS_YEAR && y <= nowYear; + const mYear = /^(\d{4})$/.exec(p); - if (mYear) return {kind: "year", year: Number(mYear[1])}; + if (mYear) { + const year = Number(mYear[1]); + if (!inYearRange(year)) throw new HttpError(400, `invalid period: ${p}`); + return {kind: "year", year}; + } const mMonth = /^(\d{4})-(\d{2})$/.exec(p); - if (mMonth) return {kind: "month", year: Number(mMonth[1]), month: Number(mMonth[2])}; + if (mMonth) { + const year = Number(mMonth[1]); + const month = Number(mMonth[2]); + if (!inYearRange(year) || month < 1 || month > 12) { + throw new HttpError(400, `invalid period: ${p}`); + } + return {kind: "month", year, month}; + } const mDate = /^(\d{4})-(\d{2})-(\d{2})$/.exec(p); - if (mDate) return {kind: "week", tuesday: tuesdayOf(parseDateString(p))}; + if (mDate) { + if (!inYearRange(Number(mDate[1])) || p > today) { + throw new HttpError(400, `invalid period: ${p}`); + } + return {kind: "week", tuesday: tuesdayOf(parseDateString(p))}; + } throw new HttpError(400, `invalid period: ${p}`); } +/** + * 캐시 키를 기간의 정규형으로 만든다. + * + * 원본 쿼리 문자열을 그대로 키로 쓰면 같은 주를 가리키는 7개 날짜가 7개 캐시 + * 엔트리가 된다. 화요일로 접어 키 공간을 기간 수만큼으로 제한한다. + */ +function periodCacheKey(period: Period): string { + if (period === "current") return "current"; + if (period.kind === "year") return `${period.year}`; + if (period.kind === "month") { + return `${period.year}-${String(period.month).padStart(2, "0")}`; + } + return `w${period.tuesday}`; +} + /** * `YYYY-MM-DD` 문자열을 연·월·일 숫자로 분해한다. * @@ -158,6 +198,99 @@ function weeklyResultsOf(entries: Array<{ date: DateString; doc: VoteHistoryDoc return results; } +/** + * 통산 집계를 얻는다 — 카운터가 있으면 그대로, 없으면 1회 전수 스캔 후 백필. + * + * 백필은 `lifetimeStatsThrough`(집계에 포함된 마지막 날짜)를 함께 기록한다. + * 이후 `applyDailyJudgmentTx`가 그 이후 날짜만 증분하므로 이중 계상이 없다. + * 판정은 항상 과거 날짜를 오름차순으로 처리하므로 이 기준선은 단조 증가한다. + */ +async function resolveLifetime( + uid: string, + user: { + lifetimePredictions?: number; + lifetimeCorrect?: number; + lifetimeStatsThrough?: DateString; + lastJudgedDate?: DateString; + } | null, +): Promise<{ totals: { total: number; correct: number } }> { + const through = user?.lifetimeStatsThrough; + // 기준선이 마지막 판정일보다 뒤처져 있으면 그 사이 판정이 집계에 반영되지 + // 않은 것이므로 재스캔한다. 백필과 판정이 겹칠 때(백필 스캔이 그날 문서를 + // 보기 전에 판정 트랜잭션이 커밋되면 그 판정은 기준선이 없어 증분되지 않는다) + // 생기는 영구 누락을 자가치유한다. + const stale = + through != null && user?.lastJudgedDate != null && through < user.lastJudgedDate; + if (through != null && !stale) { + return { + totals: { + total: user?.lifetimePredictions ?? 0, + correct: user?.lifetimeCorrect ?? 0, + }, + }; + } + + const all = await getAll(uid); + const totals = aggregate(all); + + // 이력이 하나도 없으면 기준선을 세우지 않는다. + // + // 여기서 오늘 날짜를 넣으면, 아직 아카이브되지 않은 어제 투표가 영구히 누락된다: + // 신규 유저가 어제 처음 투표하고 오늘 03:00 아카이브 전에 통계를 조회하면 + // voteHistory가 비어 기준선이 오늘로 잡히고, 이어지는 판정은 + // `date(어제) > through(오늘)`이 false라 증분되지 않는다. 게다가 기준선이 + // lastJudgedDate보다 뒤(미래)라서 아래 stale 검사로도 복구되지 않는다. + // 기준선을 비워 두면 다음 조회가 다시 스캔해 정확히 백필한다(빈 컬렉션이라 비용도 없다). + if (all.length === 0) return {totals}; + + // 기준선은 "집계에 실제로 포함된 마지막 날짜"뿐이다. + // lastJudgedDate로 앞당기면 안 된다 — judgeDay는 applyDailyJudgmentTx(=lastJudgedDate + // 갱신)를 setDay(=voteHistory 기록)보다 먼저 하므로, 그 사이 스캔에서는 + // lastJudgedDate가 voteHistory보다 앞서 있고 그날 예측이 집계에 없는 채로 + // 기준선만 올라간다. 재스캔이 한 번 더 도는 낭비가 영구 누락보다 낫다. + const nextThrough = all[all.length - 1].date; + await updateUser(uid, { + lifetimePredictions: totals.total, + lifetimeCorrect: totals.correct, + lifetimeStatsThrough: nextThrough, + }).catch((err) => { + // 백필 실패는 조회를 막지 않는다 — 다음 호출에서 다시 스캔·재시도한다. + console.warn(`[stats] lifetime 백필 실패 uid=${uid}`, err); + }); + return {totals}; +} + +/** 과거 기간 집계용 항목 — 올해 구간에 이미 들어있으면 재사용한다. */ +async function entriesForPeriod( + uid: string, + period: Exclude, + recent: Array<{ date: DateString; doc: VoteHistoryDoc }>, + recentStart: DateString, + recentEnd: DateString, +): Promise> { + const [start, end] = periodBounds(period); + if (start >= recentStart && end <= recentEnd) { + return recent.filter((e) => matchesPeriod(e.date, period)); + } + return (await getRange(uid, start, end)).filter((e) => matchesPeriod(e.date, period)); +} + +/** 기간의 날짜 경계(양끝 포함). */ +function periodBounds(period: Exclude): [DateString, DateString] { + if (period.kind === "year") { + return [`${period.year}-01-01` as DateString, `${period.year}-12-31` as DateString]; + } + if (period.kind === "month") { + const mm = String(period.month).padStart(2, "0"); + const last = new Date(Date.UTC(period.year, period.month, 0)).getUTCDate(); + return [ + `${period.year}-${mm}-01` as DateString, + `${period.year}-${mm}-${String(last).padStart(2, "0")}` as DateString, + ]; + } + return [period.tuesday, addDays(period.tuesday, 6)]; +} + /** * 유저의 예측 통계를 산출한다. * 전체·시즌·월간·주간 승률, 연속 참여일, 주간 결과, 티어를 포함한다. @@ -166,28 +299,39 @@ function weeklyResultsOf(entries: Array<{ date: DateString; doc: VoteHistoryDoc * @param period - 예측 수 집계에 사용할 기간 */ async function computeStats(uid: string, period: Period): Promise { - const [all, user] = await Promise.all([getAll(uid), getUser(uid)]); - const overall = aggregate(all); - const today = todayKst(); const {y: nowYear, m: nowMonth} = parseYmd(today); const thisTuesday = tuesdayOf(today); - const seasonEntries = all.filter((e) => matchesPeriod(e.date, {kind: "year", year: nowYear})); - const season = aggregate(seasonEntries); + // 시즌·월간·주간·주간결과는 모두 "올해" 구간의 부분집합이다. 이번 주가 연초를 + // 걸치면 화요일까지 앞으로 늘려 한 번의 범위 조회로 전부 덮는다. + const recentStart = (thisTuesday < `${nowYear}-01-01` ? + thisTuesday : + `${nowYear}-01-01`) as DateString; - const monthlyEntries = all.filter((e) => - matchesPeriod(e.date, {kind: "month", year: nowYear, month: nowMonth}) + const [recent, user] = await Promise.all([ + getRange(uid, recentStart, today), + getUser(uid), + ]); + + // 통산 집계는 롤링 카운터로 얻는다. 아직 백필되지 않은 유저만 1회 전수 스캔. + const backfilled = await resolveLifetime(uid, user); + const overall = backfilled.totals; + + const season = aggregate( + recent.filter((e) => matchesPeriod(e.date, {kind: "year", year: nowYear})) ); - const monthly = aggregate(monthlyEntries); - - const weeklyEntries = all.filter((e) => - matchesPeriod(e.date, {kind: "week", tuesday: thisTuesday}) + const monthly = aggregate( + recent.filter((e) => matchesPeriod(e.date, {kind: "month", year: nowYear, month: nowMonth})) + ); + const weekly = aggregate( + recent.filter((e) => matchesPeriod(e.date, {kind: "week", tuesday: thisTuesday})) ); - const weekly = aggregate(weeklyEntries); - const periodEntries = period === "current" ? all : all.filter((e) => matchesPeriod(e.date, period)); - const periodAgg = aggregate(periodEntries); + // "current"는 통산과 동일하므로 카운터를 재사용한다. 과거 기간은 그 구간만 읽는다. + const periodAgg = period === "current" ? + overall : + aggregate(await entriesForPeriod(uid, period, recent, recentStart, today)); // 결석으로 streak이 끊겼는지 lazy 보정. // 1) `lastJudgedDate`가 어제 이후면 정상. @@ -206,10 +350,13 @@ async function computeStats(uid: string, period: Period): Promise } } const storedStreak = streakBroken ? 0 : user?.currentStreak; - const streakDays = storedStreak ?? computeStreak(all); + // 폴백은 `currentStreak`이 아직 없는 유저(레거시·신규)에만 쓰인다. 올해 구간만 + // 보므로 해를 넘긴 연속 기록은 연초에 과소 계산될 수 있다 — 판정이 한 번이라도 + // 돌면 `currentStreak`이 채워져 이 경로를 타지 않는다. + const streakDays = storedStreak ?? computeStreak(recent); const highestStreak = user?.highestStreak ?? streakDays; const tierPoints = user?.tierPoints ?? 0; - const weeklyResults = weeklyResultsOf(all); + const weeklyResults = weeklyResultsOf(recent); return { streakDays, @@ -245,7 +392,7 @@ function cachePath(uid: string, key: string): string { */ export async function getStats(uid: string, periodParam?: string): Promise { const period = parsePeriod(periodParam); - const key = periodParam && periodParam !== "current" ? periodParam : "current"; + const key = periodCacheKey(period); // 캐시는 산출 시점의 KST 날짜(`forDate`)와 함께 저장된다. // 날짜가 바뀌면 streak/weeklyResults 기준이 달라지므로 무효화하고 재계산한다. diff --git a/src/services/userService.ts b/src/services/userService.ts index c8c063d..c1e1d66 100644 --- a/src/services/userService.ts +++ b/src/services/userService.ts @@ -234,8 +234,15 @@ export async function deleteMe(token: DecodedIdToken): Promise { if (code !== "auth/user-not-found") throw err; } // 사전계산된 랭킹 리스트에서 즉시 제외. 실패해도 다음 새벽 재계산이 정리한다. + // 탈퇴 유저가 영향을 줄 수 있는 스코프는 overall과 본인 응원팀뿐이므로 + // 11개 전체가 아니라 그 둘만 재계산한다(`existing`은 위에서 이미 읽었다). try { - await precomputeScoreboardCache(todayKst()); + await precomputeScoreboardCache(todayKst(), [ + { kind: "overall" }, + ...(existing.favoriteTeamCode ? + [{ kind: "team" as const, teamCode: existing.favoriteTeamCode }] : + []), + ]); } catch (err) { logger.error( `deactivate: precomputeScoreboardCache failed uid=${token.uid}`, diff --git a/src/types/panit.ts b/src/types/panit.ts index 2ab8759..8a6508c 100644 --- a/src/types/panit.ts +++ b/src/types/panit.ts @@ -165,6 +165,19 @@ export interface User { */ lastJudgedDate?: DateString; + /** + * 통산 예측 수·적중 수 롤링 집계 — `overall` 승률을 voteHistory 전수 스캔 없이 + * 산출하기 위한 것. 취소 무효표(result 없음)는 제외한 값만 누적한다. + * + * `lifetimeStatsThrough`는 집계에 반영된 마지막 판정 날짜다. 세 필드는 항상 + * 함께 갱신되며, 없으면 `computeStats`가 전수 스캔으로 백필한다. + * 증분은 `applyDailyJudgmentTx`가 `date > lifetimeStatsThrough`일 때만 수행해 + * 백필과 증분이 겹쳐 이중 계상되는 것을 막는다. + */ + lifetimePredictions?: number; + lifetimeCorrect?: number; + lifetimeStatsThrough?: DateString; + rankSnapshot?: RankSnapshot; } diff --git a/tests/scheduled/dailyArchive.test.ts b/tests/scheduled/dailyArchive.test.ts new file mode 100644 index 0000000..220bee4 --- /dev/null +++ b/tests/scheduled/dailyArchive.test.ts @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { Timestamp } from "firebase-admin/firestore"; +import { firestore, rtdb } from "../../src/firebase"; +import { invalidateAllGameDays } from "../../src/repositories/gameRepository"; +import { runDailyArchive } from "../../src/scheduled/dailyArchive"; +import type { DateString } from "../../src/types/dateString"; +import type { Game, User } from "../../src/types/panit"; + +const date = "2026-05-12" as DateString; +const gameId = "20260512HTLG0"; + +async function seedGame(): Promise { + const doc: Game = { + time: Timestamp.fromDate(new Date(Date.UTC(2026, 4, 12, 9, 0))), + stadium: "잠실", + status: "completed", + homeTeamCode: "LG", + awayTeamCode: "HT", + winningTeamCode: "LG", + }; + await firestore.collection("games").doc(gameId).set(doc); + invalidateAllGameDays(); +} + +async function seedUser(uid: string): Promise { + const user: Partial = { + displayName: uid, + email: `${uid}@e.com`, + provider: "google", + knowledgeLevel: "casual", + active: true, + createdAt: Timestamp.now(), + }; + await firestore.collection("users").doc(uid).set(user); +} + +/** 판정이 끝난 투표를 원본에 심는다. reconcile 경로를 타지 않게 result를 채운다. */ +async function seedRawVote(uid: string): Promise { + await rtdb.ref(`/userVotes/${uid}/${date}/${gameId}`).set({ team: "LG", result: true }); +} + +/** 날짜별 인덱스에도 심는다(= 미러 배포 이후에 투표한 유저). */ +async function seedIndexedVote(uid: string): Promise { + await rtdb.ref(`/userVotesByDate/${date}/${uid}/${gameId}`).set({ team: "LG", result: true }); +} + +async function hasHistory(uid: string): Promise { + const snap = await firestore + .collection("users").doc(uid) + .collection("voteHistory").doc(date) + .get(); + return snap.exists; +} + +describe("runDailyArchive — 날짜 인덱스 롤아웃", () => { + beforeEach(async () => { + await firestore.recursiveDelete(firestore.collection("users")); + await firestore.recursiveDelete(firestore.collection("games")); + invalidateAllGameDays(); + await rtdb.ref("/userVotes").remove(); + await rtdb.ref("/userVotesByDate").remove(); + await rtdb.ref("/userVotesByDateMeta").remove(); + await seedGame(); + }); + + /** + * 미러 배포 당일에는 인덱스가 부분적으로만 찬다 — 배포 전 투표자는 원본에만, + * 배포 후 투표자는 양쪽에 있다. 인덱스가 비지 않았다는 이유로 원본을 건너뛰면 + * 배포 전 투표자가 영구 유실된다(그 날짜는 다시 처리되지 않는다). + */ + it("인덱스가 부분적으로만 찼으면 원본에서 누락 유저를 보충한다", async () => { + await seedUser("before-deploy"); + await seedUser("after-deploy"); + // 배포 전 투표자 — 원본에만 존재 + await seedRawVote("before-deploy"); + // 배포 후 투표자 — 원본 + 인덱스 + await seedRawVote("after-deploy"); + await seedIndexedVote("after-deploy"); + + const result = await runDailyArchive(date); + + expect(result.archived).toBe(2); + expect(result.judgedUids.sort()).toEqual(["after-deploy", "before-deploy"]); + expect(await hasHistory("before-deploy")).toBe(true); + expect(await hasHistory("after-deploy")).toBe(true); + }); + + it("인덱스가 완전히 비어도 원본만으로 아카이브한다", async () => { + await seedUser("legacy-only"); + await seedRawVote("legacy-only"); + + const result = await runDailyArchive(date); + + expect(result.archived).toBe(1); + expect(await hasHistory("legacy-only")).toBe(true); + }); + + it("백필 마커가 있으면 인덱스만 신뢰한다(원본 전체 스캔 안 함)", async () => { + await rtdb.ref("/userVotesByDateMeta/backfilledAt").set(new Date().toISOString()); + await seedUser("indexed"); + await seedUser("stale-raw"); + await seedIndexedVote("indexed"); + await seedRawVote("indexed"); + // 인덱스에 없는 원본 잔재 — 백필 완료 후에는 보충 대상이 아니다 + await seedRawVote("stale-raw"); + + const result = await runDailyArchive(date); + + expect(result.judgedUids).toEqual(["indexed"]); + expect(await hasHistory("stale-raw")).toBe(false); + }); + + it("양쪽 모두 비어 있으면 아무것도 아카이브하지 않는다", async () => { + const result = await runDailyArchive(date); + expect(result.archived).toBe(0); + expect(result.judgedUids).toEqual([]); + }); + + it("아카이브 후 원본과 날짜별 미러를 모두 정리한다", async () => { + await seedUser("cleanup"); + await seedRawVote("cleanup"); + await seedIndexedVote("cleanup"); + + await runDailyArchive(date); + + expect((await rtdb.ref(`/userVotes/cleanup/${date}`).get()).exists()).toBe(false); + expect((await rtdb.ref(`/userVotesByDate/${date}/cleanup`).get()).exists()).toBe(false); + }); +}); diff --git a/tests/services/judgmentService.test.ts b/tests/services/judgmentService.test.ts index 9bb5e38..eeaa60a 100644 --- a/tests/services/judgmentService.test.ts +++ b/tests/services/judgmentService.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import { Timestamp } from "firebase-admin/firestore"; import { firestore } from "../../src/firebase"; +import { invalidateAllGameDays } from "../../src/repositories/gameRepository"; import { judgeDay, } from "../../src/services/judgmentService"; @@ -51,6 +52,7 @@ async function seedGames( const gameId = `${date.replace(/-/g, "")}G${i}`; const game = makeGame(date, spec.status, "LG", spec.winner ?? null); await firestore.collection("games").doc(gameId).set(game); + invalidateAllGameDays(); } } @@ -109,6 +111,8 @@ describe("judgmentService (Firestore emulator)", () => { beforeEach(async () => { await firestore.recursiveDelete(firestore.collection("users")); await firestore.recursiveDelete(firestore.collection("games")); + // 테스트는 games를 직접 쓰므로 프로덕션 쓰기 경로와 같은 무효화를 수동 수행한다. + invalidateAllGameDays(); await seedUser(); }); diff --git a/tests/services/scoreboardService.test.ts b/tests/services/scoreboardService.test.ts index 2cf9d51..8780f2f 100644 --- a/tests/services/scoreboardService.test.ts +++ b/tests/services/scoreboardService.test.ts @@ -6,6 +6,7 @@ import { } from "../../src/services/scoreboardService"; import { precomputeScoreboardCache, + buildRankStandings, snapshotRankForUser, } from "../../src/services/rankSnapshotService"; import { todayKst, type DateString } from "../../src/types/dateString"; @@ -281,3 +282,60 @@ describe("rankSnapshotService.snapshotRankForUser", () => { expect(snap.teamCode).toBeUndefined(); }); }); + +/** + * `buildRankStandings`는 유저별 count aggregation을 대체하므로, aggregation과 + * 동일한 순위 정의("초과 인원 + 1", 동점자 동일 순위)를 지켜야 한다. + */ +describe("rankSnapshotService.buildRankStandings", () => { + const users = [ + { tierPoints: 200, favoriteTeamCode: TeamCode.LG }, + { tierPoints: 150, favoriteTeamCode: TeamCode.KT }, + { tierPoints: 100, favoriteTeamCode: TeamCode.LG }, + { tierPoints: 100, favoriteTeamCode: TeamCode.LG }, + { tierPoints: 50 }, + ]; + + it("overall 순위는 '초과 인원 + 1'이다", () => { + const s = buildRankStandings(users); + expect(s.rankOf(200)).toBe(1); + expect(s.rankOf(150)).toBe(2); + expect(s.rankOf(100)).toBe(3); + expect(s.rankOf(50)).toBe(5); + }); + + it("동점자는 같은 순위를 받는다", () => { + const s = buildRankStandings(users); + // 100점이 2명 → 둘 다 3위, 그 아래 50점은 5위 + expect(s.rankOf(100)).toBe(3); + expect(s.rankOf(99)).toBe(5); + }); + + it("팀 스코프는 해당 팀 유저만 센다", () => { + const s = buildRankStandings(users); + // LG: 200, 100, 100 + expect(s.rankOf(200, TeamCode.LG)).toBe(1); + expect(s.rankOf(100, TeamCode.LG)).toBe(2); + // KT: 150 하나뿐 + expect(s.rankOf(150, TeamCode.KT)).toBe(1); + }); + + it("해당 팀 유저가 없으면 1위로 계산한다", () => { + const s = buildRankStandings(users); + expect(s.rankOf(10, TeamCode.HH)).toBe(1); + }); + + it("빈 순위표에서도 1위를 돌려준다", () => { + const s = buildRankStandings([]); + expect(s.rankOf(0)).toBe(1); + }); + + it("무작위 입력에서 선형 스캔 결과와 일치한다", () => { + const rand = [3, 17, 17, 2, 99, 41, 41, 41, 8, 60].map((tierPoints) => ({ tierPoints })); + const s = buildRankStandings(rand); + for (const { tierPoints } of rand) { + const linear = rand.filter((u) => u.tierPoints > tierPoints).length + 1; + expect(s.rankOf(tierPoints)).toBe(linear); + } + }); +}); diff --git a/tests/services/statsService.test.ts b/tests/services/statsService.test.ts index ba3aec6..3204245 100644 --- a/tests/services/statsService.test.ts +++ b/tests/services/statsService.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import { Timestamp } from "firebase-admin/firestore"; import { firestore, rtdb } from "../../src/firebase"; +import { invalidateAllGameDays } from "../../src/repositories/gameRepository"; import { getStats } from "../../src/services/statsService"; import { addDays, @@ -33,6 +34,7 @@ async function seedGames( }; if (spec.winner) doc.winningTeamCode = spec.winner; await firestore.collection("games").doc(gameId).set(doc); + invalidateAllGameDays(); } } @@ -64,6 +66,8 @@ describe("statsService.getStats — 결석 lazy 보정", () => { beforeEach(async () => { await firestore.recursiveDelete(firestore.collection("users")); await firestore.recursiveDelete(firestore.collection("games")); + // 테스트는 games를 직접 쓰므로 프로덕션 쓰기 경로와 같은 무효화를 수동 수행한다. + invalidateAllGameDays(); await rtdb.ref(`/cache/stats/${uid}`).remove(); await rtdb.ref(`/userVotes/${uid}`).remove(); }); @@ -227,3 +231,194 @@ describe("statsService.getStats — 캐시 forDate 검증", () => { expect(stats.streakDays).toBe(3); // user doc 기준으로 재계산 }); }); + +/** + * 통산 집계는 voteHistory 전수 스캔을 대체하므로, 백필 값과 이후 증분이 + * 스캔 결과와 동일해야 한다(이중 계상·누락 없이). + */ +describe("statsService.getStats — 통산 롤링 집계", () => { + beforeEach(async () => { + await firestore.recursiveDelete(firestore.collection("users")); + await rtdb.ref(`/cache/stats/${uid}`).remove(); + await rtdb.ref(`/userVotes/${uid}`).remove(); + }); + + async function seedHistory( + date: DateString, + results: Array + ): Promise { + await firestore + .collection("users").doc(uid) + .collection("voteHistory").doc(date) + .set({ + data: results.map((result, i) => ({ + gameId: `${date.replace(/-/g, "")}G${i}`, + team: "LG", + // null = 취소 무효표(result 없음) — 승률 모집단에서 제외되어야 한다 + ...(result === null ? { cancelled: true } : { result }), + })), + judgment: "success", + }); + } + + it("카운터가 없으면 전수 스캔으로 산출하고 user doc에 백필한다", async () => { + const today = todayKst(); + await seedUser(); + await seedHistory(addDays(today, -2), [true, false, true]); + await seedHistory(addDays(today, -1), [true, null]); + + const stats = await getStats(uid); + + // [true,false,true] + [true, 취소] → 유효표 4건, 적중 3건 (취소 무효표 제외) + expect(stats.totalPredictions).toBe(4); + expect(stats.totalCorrect).toBe(3); + expect(stats.winRates.overall).toBeCloseTo(3 / 4); + + const user = (await firestore.collection("users").doc(uid).get()).data()!; + expect(user.lifetimePredictions).toBe(4); + expect(user.lifetimeCorrect).toBe(3); + // 기준선은 스캔에 포함된 마지막 날짜 + expect(user.lifetimeStatsThrough).toBe(addDays(today, -1)); + }); + + it("백필된 카운터가 있으면 그 값을 그대로 쓴다(재스캔 없음)", async () => { + await seedUser({ + lifetimePredictions: 40, + lifetimeCorrect: 25, + lifetimeStatsThrough: addDays(todayKst(), -1), + }); + // 카운터를 쓰는지 확인하려고 이력과 어긋나는 값을 심는다 + await seedHistory(addDays(todayKst(), -2), [true]); + + const stats = await getStats(uid); + + expect(stats.totalPredictions).toBe(40); + expect(stats.totalCorrect).toBe(25); + }); + + /** + * 이력이 없을 때 기준선을 오늘로 잡으면, 아직 아카이브되지 않은 어제 투표가 + * `date > through` 조건에 걸려 영구히 누락되고 stale 검사로도 복구되지 않는다. + */ + it("이력이 없으면 기준선을 세우지 않는다", async () => { + await seedUser(); + + const stats = await getStats(uid); + + expect(stats.totalPredictions).toBe(0); + const user = (await firestore.collection("users").doc(uid).get()).data()!; + expect(user.lifetimeStatsThrough).toBeUndefined(); + expect(user.lifetimePredictions).toBeUndefined(); + }); + + it("아카이브 전 조회 후 어제가 판정돼도 그날 예측이 누락되지 않는다", async () => { + const yesterday = addDays(todayKst(), -1); + await seedUser(); + + // 1) 어제 처음 투표한 유저가 아카이브(03:00) 전에 통계를 조회한다 + await getStats(uid); + + // 2) 이후 아카이브가 어제를 판정해 voteHistory를 기록한다 + await seedHistory(yesterday, [true, false]); + await firestore.collection("users").doc(uid) + .set({ lastJudgedDate: yesterday }, { merge: true }); + await rtdb.ref(`/cache/stats/${uid}`).remove(); + + // 3) 다시 조회하면 어제 예측이 통산에 반영돼 있어야 한다 + const stats = await getStats(uid); + + expect(stats.totalPredictions).toBe(2); + expect(stats.totalCorrect).toBe(1); + }); + + it("기준선을 lastJudgedDate로 앞당기지 않는다", async () => { + const today = todayKst(); + const d1 = addDays(today, -2); + const d2 = addDays(today, -1); + // 판정 트랜잭션은 커밋됐지만(setDay 이전) voteHistory에는 d2가 아직 없는 상태 + await seedUser({ lastJudgedDate: d2 }); + await seedHistory(d1, [true]); + + await getStats(uid); + + // 기준선이 d2로 올라가면 d2 예측이 영영 집계되지 않는다 — d1이어야 한다 + const user = (await firestore.collection("users").doc(uid).get()).data()!; + expect(user.lifetimeStatsThrough).toBe(d1); + }); + + it("기준선이 lastJudgedDate보다 뒤처지면 재스캔해 자가치유한다", async () => { + const today = todayKst(); + const d1 = addDays(today, -2); + const d2 = addDays(today, -1); + // 백필이 d1까지만 반영된 상태에서 d2 판정이 증분되지 못한 상황을 재현한다 + // (백필 스캔이 d2 문서를 보기 전에 판정 트랜잭션이 커밋되면 발생한다) + await seedUser({ + lifetimePredictions: 1, + lifetimeCorrect: 1, + lifetimeStatsThrough: d1, + lastJudgedDate: d2, + }); + await seedHistory(d1, [true]); + await seedHistory(d2, [true, false]); + + const stats = await getStats(uid); + + // 재스캔되어 d2까지 반영돼야 한다 + expect(stats.totalPredictions).toBe(3); + expect(stats.totalCorrect).toBe(2); + const user = (await firestore.collection("users").doc(uid).get()).data()!; + expect(user.lifetimeStatsThrough).toBe(d2); + }); + + it("기준선이 lastJudgedDate와 같으면 재스캔하지 않는다", async () => { + const d = addDays(todayKst(), -1); + await seedUser({ + lifetimePredictions: 40, + lifetimeCorrect: 25, + lifetimeStatsThrough: d, + lastJudgedDate: d, + }); + await seedHistory(d, [true]); // 카운터와 어긋나는 이력을 심어도 무시돼야 한다 + + const stats = await getStats(uid); + + expect(stats.totalPredictions).toBe(40); + }); +}); + +describe("statsService.getStats — period 검증·정규화", () => { + beforeEach(async () => { + await firestore.recursiveDelete(firestore.collection("users")); + await rtdb.ref(`/cache/stats/${uid}`).remove(); + await seedUser(); + }); + + it("서비스 개시 이전 연도는 거부한다", async () => { + await expect(getStats(uid, "1999")).rejects.toThrow(/invalid period/); + }); + + it("미래 연도는 거부한다", async () => { + const nextYear = Number(todayKst().slice(0, 4)) + 1; + await expect(getStats(uid, String(nextYear))).rejects.toThrow(/invalid period/); + }); + + it("잘못된 월은 거부한다", async () => { + const year = todayKst().slice(0, 4); + await expect(getStats(uid, `${year}-13`)).rejects.toThrow(/invalid period/); + }); + + it("미래 날짜는 거부한다", async () => { + await expect(getStats(uid, addDays(todayKst(), 1))).rejects.toThrow(/invalid period/); + }); + + it("같은 주의 서로 다른 날짜는 하나의 캐시 키로 접힌다", async () => { + const today = todayKst(); + await getStats(uid, today); + const keys = Object.keys( + (await rtdb.ref(`/cache/stats/${uid}`).get()).val() ?? {} + ); + // 원본 날짜 문자열이 아니라 주 단위 정규형(w<화요일>)으로 저장된다 + expect(keys.some((k) => k.startsWith("w"))).toBe(true); + expect(keys).not.toContain(today); + }); +}); From ca5c4c69b92a6dfae77ffd3ecba196bff8051e32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=A0=95=EB=AF=BC?= Date: Mon, 27 Jul 2026 13:26:51 +0900 Subject: [PATCH 5/8] Harden KBO cache invalidation against batch limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - schedule_day__ 일괄 무효화 제거 — dayTtlMs가 완료된 과거 경기일에 7일 TTL을 주는데 매일 밤 지워 무의미하게 만들고 있었다. 동일한 동적 TTL을 가진 game_detail__를 이미 제외하던 논리를 그대로 적용해 자연 만료에 위임한다 - 남은 rank__ 무효화를 400건 단위로 청킹 — 단일 batch는 500 op 하드 상한에서 INVALID_ARGUMENT로 던지므로, 캐시 문서가 늘어난 어느 날 잡 전체가 조용히 죽을 수 있었다 - 삭제 쿼리에 .select() 적용해 ref만 전송(문서 본문 불필요) - 무효화를 try 블록 안으로 이동 — 기존에는 바깥이라 실패 시 rank 갱신·syncGamesForMonth·forceSyncDay가 전부 실행되지 않았다 --- src/scheduled/kboRefresh.ts | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/src/scheduled/kboRefresh.ts b/src/scheduled/kboRefresh.ts index 1b9a220..b0bbc02 100644 --- a/src/scheduled/kboRefresh.ts +++ b/src/scheduled/kboRefresh.ts @@ -13,15 +13,26 @@ import { daysAgoKst } from "../types/dateString"; const CACHE_COLLECTION = "kboCache"; -async function invalidateByPrefix(prefix: string): Promise { +/** batch 하드 상한(500)보다 낮게 잡아 커밋이 터지지 않게 한다. */ +const DELETE_CHUNK = 400; + +async function invalidateByPrefix(prefix: string): Promise { const snap = await firestore .collection(CACHE_COLLECTION) .where("__name__", ">=", prefix) .where("__name__", "<", prefix + "\uf8ff") + // 삭제에는 ref만 필요하다 — 문서 본문 전송을 막는다. + .select() .get(); - const batch = firestore.batch(); - snap.docs.forEach((d) => batch.delete(d.ref)); - if (!snap.empty) await batch.commit(); + + // 단일 batch는 500 op에서 INVALID_ARGUMENT로 던진다. 청킹하지 않으면 + // 캐시 문서가 늘어난 어느 날 이 잡 전체가 조용히 죽는다. + for (let i = 0; i < snap.docs.length; i += DELETE_CHUNK) { + const batch = firestore.batch(); + for (const d of snap.docs.slice(i, i + DELETE_CHUNK)) batch.delete(d.ref); + await batch.commit(); + } + return snap.size; } export const kboDailyRefresh = onSchedule( @@ -33,13 +44,15 @@ export const kboDailyRefresh = onSchedule( logger.info(`KBO refresh start: ${year}-${month}`); - await invalidateByPrefix("rank__"); - await invalidateByPrefix("schedule_day__"); - // game_detail__는 응답 기반 동적 TTL(종료 경기 7d, 라이브 30s 등)을 이미 갖고 있어 - // 02:00 일괄 무효화 대상에서 제외한다. 일괄 삭제 시 직후 상세 조회가 한꺼번에 미스나 - // 외부 재조회 폭주를 유발하므로, TTL 자연 만료에 위임한다. - + // game_detail__와 schedule_day__는 응답 기반 동적 TTL(종료 7d, 라이브 30s 등)을 + // 이미 갖고 있어 02:00 일괄 무효화 대상에서 제외한다. 일괄 삭제하면 직후 조회가 + // 한꺼번에 미스나 외부 재조회 폭주를 부르므로 TTL 자연 만료에 위임한다. + // (schedule_day__의 TTL은 dayTtlMs가 부여한다 — 완료된 과거 경기일은 7일이라 + // 매일 밤 지우면 그 TTL이 통째로 무의미해진다.) try { + const rankPurged = await invalidateByPrefix("rank__"); + logger.info(`invalidated ${rankPurged} rank cache docs`); + await fetchRankFromKbo([year]); await fetchScheduleFromKbo({ year, month }); From d2df63e63c44be5bb37d1a387a64e8715e7c593f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=A0=95=EB=AF=BC?= Date: Mon, 27 Jul 2026 13:27:10 +0900 Subject: [PATCH 6/8] Trim chat context and history reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - gatherUserContext 제거 — 메시지마다 6개 소스를 병렬 조회했지만 USER_CONTEXT_TEMPLATE은 날짜·닉네임·응원팀 3개 필드만 쓴다. 시사 데이터를 도구 조회로 옮기면서 선주입 블록만 지우고 fetch는 남아 있던 것으로, 메시지당 33~64 read가 전량 폐기되고 있었다 - identityContext(Firestore read 0, 이미 로드한 user 문서만 사용)와 gatherSuggestionFlags(추천 질문 전용, 응원팀 미설정 시 일정 조회도 생략)로 분리. 소비되지 않던 필드와 fetchRecentTeamGames·formatRecentResults·formatTodaySchedule 삭제 - fetchScheduleMonth가 리필 직후 전 키(28~31 doc)를 재읽기하던 것을 byDate 메모리 병합으로 교체. loadMonthDayCache를 추출해 단일일 조회도 월 리필 결과를 재사용하게 하고, live 병합을 호출자에서 1회만 수행해 이중 적용을 막았다 - loadHistory를 2단계 페치로 변경. 위기 요청은 일일 한도를 우회하므로 위기 교환쌍 수에 상한이 없고, 그 쌍은 문서 2개를 먹고 윈도잉에서 둘 다 빠진다. 1차 페이지가 다 찼는데도 목표 턴에 못 미칠 때만 한 번 넓혀 재조회한다(평시 추가 쿼리 0회) - 예약 트랜잭션이 계산한 used를 ReserveOutcome으로 돌려줘 응답 조립의 쿼터 재조회 제거(트랜잭션이 없는 replay·GET /chat/quota는 기존 read 유지) - loadHistory가 threadExists를 반환해 저장 단계로 전달 — 같은 요청에서 thread 문서를 두 번 읽던 것 제거(위기 경로는 loadHistory를 안 거치므로 optional) - upsertReport를 create 후 ALREADY_EXISTS 폴백 merge로 전환해 최초 신고의 사전 read 제거 --- src/repositories/chatRepository.ts | 46 +++- src/repositories/kboRepository.ts | 47 +++- src/services/chatContextService.ts | 247 ++++++---------------- src/services/chatProbeService.ts | 4 +- src/services/chatService.ts | 110 +++++++--- tests/services/chatContextService.test.ts | 60 +----- tests/services/chatService.test.ts | 54 +++++ 7 files changed, 278 insertions(+), 290 deletions(-) diff --git a/src/repositories/chatRepository.ts b/src/repositories/chatRepository.ts index 12e5ae9..6ffdbfe 100644 --- a/src/repositories/chatRepository.ts +++ b/src/repositories/chatRepository.ts @@ -2,7 +2,7 @@ import { randomBytes, createHash } from "node:crypto"; import { FieldPath, Timestamp } from "firebase-admin/firestore"; import { ServerValue } from "firebase-admin/database"; import { firestore, rtdb } from "../firebase"; -import { HttpError } from "../middleware/errors"; +import { HttpError, isAlreadyExistsError } from "../middleware/errors"; import { MemCache } from "../lib/memCache"; import type { ChatMessageDoc, @@ -98,8 +98,11 @@ export interface ReserveParams { export type ReserveOutcome = | { kind: "done"; assistantMessageId: string; threadId: string } - /** threadId는 pin된 값 — 크래시 재개 시 원래 예약의 스레드를 그대로 쓴다(§3.1 처리 5). */ - | { kind: "reserved"; threadId: string; debited: boolean; resumed: boolean }; + /** + * threadId는 pin된 값 — 크래시 재개 시 원래 예약의 스레드를 그대로 쓴다(§3.1 처리 5). + * `used`는 차감 반영 후 값 — 응답 조립이 쿼터 문서를 다시 읽지 않도록 함께 돌려준다. + */ + | { kind: "reserved"; threadId: string; debited: boolean; resumed: boolean; used: number }; /** * 멱등 예약·레이트리밋·한도 차감을 단일 Firestore 트랜잭션으로 원자 수행한다. @@ -136,7 +139,9 @@ export async function reserveRequestTx(params: ReserveParams): Promise).used ?? 0; + return { kind: "reserved", threadId: req.threadId, debited: stillDebited, resumed: true, used: keptUsed }; } const resumeQuota = (quotaSnap.data() ?? {}) as Partial; const resumeUsed = resumeQuota.used ?? 0; @@ -152,7 +157,7 @@ export async function reserveRequestTx(params: ReserveParams): Promise; @@ -208,7 +213,7 @@ export async function reserveRequestTx(params: ReserveParams): Promise { const ref = firestore.collection(REPORTS).doc(`${uid}_${messageId}`); const now = Timestamp.now(); - const existing = await ref.get(); const doc: ChatReportDoc = { uid, messageId, reason, ...(comment ? { comment } : {}), status: "open", - createdAt: existing.exists ? (existing.data() as ChatReportDoc).createdAt : now, + createdAt: now, updatedAt: now, }; - await ref.set(doc); + // 신규면 create가 성공하고, 재신고면 ALREADY_EXISTS로 떨어져 merge 갱신한다. + // createdAt 보존을 위해 사전 read를 하던 것을 대체한다 — 최초 신고는 read 0회. + try { + await ref.create(doc); + } catch (err) { + if (!isAlreadyExistsError(err)) throw err; + // 재신고는 createdAt을 건드리지 않는다 — 최초 신고 시각을 보존. + await ref.set({ + uid, + messageId, + reason, + ...(comment ? { comment } : {}), + status: "open", + updatedAt: now, + }, { merge: true }); + } } // ── 전역 호출·토큰 카운터(RTDB, §8.3) ── diff --git a/src/repositories/kboRepository.ts b/src/repositories/kboRepository.ts index 3c90344..5a5d9b0 100644 --- a/src/repositories/kboRepository.ts +++ b/src/repositories/kboRepository.ts @@ -376,14 +376,18 @@ async function fetchScheduleSingleDay( let cached = (await readDayDocs([key])).get(key) ?? null; if (cached === null) { - // 미스 → 월 단위 외부 fetch 흐름이 캐시를 채우도록 호출. 결과는 사용하지 않음. - await fetchScheduleMonth({ + // 미스 → 월 단위 리필. 리필 결과를 그대로 재사용해 day doc 재읽기를 없앤다. + // loadMonthDayCache는 live 병합 전 원본을 돌려주므로 아래 merge가 이중 적용되지 않는다. + const load = await loadMonthDayCache({ year: filters.year, month: filters.month, team: filters.team, series: filters.series, }); - cached = (await readDayDocs([key])).get(key) ?? []; + cached = + load.kind === "bypass" ? + load.games.filter((g) => gameDateToYmd(filters.year, g.date) === ymd) : + load.cached.get(key) ?? []; } const merged = await mergeLiveIntoSchedule(ymd, cached, filters.series); @@ -396,9 +400,19 @@ async function fetchScheduleSingleDay( }; } -async function fetchScheduleMonth( +type MonthDayCacheLoad = + | { kind: "cache"; allDays: string[]; cached: Map } + | { kind: "bypass"; games: ScheduleGame[] }; + +/** + * 월 단위 day 캐시를 확보한다(미스가 있으면 외부 fetch로 리필). + * + * live 병합 **전** 원본을 돌려준다 — 호출자가 필요한 범위에만 한 번 병합하도록 + * 해서 월/일 경로가 서로 다른 병합 횟수를 갖는 문제를 막는다. + */ +async function loadMonthDayCache( filters: ScheduleFilters -): Promise { +): Promise { const allDays = enumerateMonthDays(filters.year, filters.month); const keys = allDays.map((d) => dayKey(d, filters.team, filters.series)); @@ -436,7 +450,11 @@ async function fetchScheduleMonth( }) ); - cached = await readDayDocs(keys); + // 방금 쓴 내용은 byDate에 그대로 있다 — 전 키(28~31 doc) 재읽기 대신 + // 메모리에서 병합한다. 재읽기가 새로 가져오는 정보는 없다. + for (const ymd of missing) { + cached.set(dayKey(ymd, filters.team, filters.series), byDate.get(ymd) ?? []); + } } finally { await releaseLock(lockKey); } @@ -451,15 +469,26 @@ async function fetchScheduleMonth( } if (missing.length > 0) { // 타임아웃 — 캐시 우회하여 직접 fetch (저장은 안 함). - return fetchSchedule(filters); + return { kind: "bypass", games: (await fetchSchedule(filters)).games }; } } } + return { kind: "cache", allDays, cached }; +} + +async function fetchScheduleMonth( + filters: ScheduleFilters +): Promise { + const load = await loadMonthDayCache(filters); + if (load.kind === "bypass") { + return { year: filters.year, month: filters.month, games: load.games }; + } + const games: ScheduleGame[] = []; const today = todayKst().replace(/-/g, ""); - for (const ymd of allDays) { - const list = cached.get(dayKey(ymd, filters.team, filters.series)) ?? []; + for (const ymd of load.allDays) { + const list = load.cached.get(dayKey(ymd, filters.team, filters.series)) ?? []; if (ymd === today) { const merged = await mergeLiveIntoSchedule(ymd, list, filters.series); games.push(...merged); diff --git a/src/services/chatContextService.ts b/src/services/chatContextService.ts index 1e24c64..6567ad0 100644 --- a/src/services/chatContextService.ts +++ b/src/services/chatContextService.ts @@ -1,13 +1,10 @@ import { getSchedule } from "./scheduleService"; -import { getRank } from "./rankService"; -import { getStats } from "./statsService"; import { getUserDateVotes } from "../repositories/voteRepository"; import { getDay } from "../repositories/voteHistoryRepository"; import { COMMON_SYSTEM_PROMPT, DEFAULT_PERSONA_BLOCK, DEFAULT_TEAM_PERSONAS, - KBO_RANK_TEAM_NAMES, KNOWLEDGE_GUIDANCE, SERVER_DIRECTIVE_BLOCK, TEAM_DISPLAY_NAMES, @@ -63,21 +60,24 @@ export function sanitizeDisplayName(raw: unknown): string { // ── 컨텍스트 데이터 수집 ── -export interface UserContext { +/** + * 프롬프트 조립에 필요한 정체성 컨텍스트. + * + * 전량 `users/{uid}` 문서에서 파생되므로 조립 경로의 Firestore 조회는 0회다. + * 매치업·선발·순위·예측·전적·통계는 컨텍스트 블록에 선주입하지 않고 도구로 + * 조회하므로(2.2 경량판), 여기에 시사 데이터를 다시 넣지 말 것. + */ +export interface IdentityContext { date: DateString; displayName: string; knowledgeLevel: KnowledgeLevel; teamCode: TeamCode | null; teamName: string | null; - todaySchedule: string; - todayMyPredictions: string; - yesterdayRecap: string; - /** 응원팀 미설정 시 null → 줄 생략. */ - recentTeamResults: string | null; - /** 오늘 응원팀 경기 없음/미설정 시 null → 줄 생략. */ - h2hRecords: string | null; - myStats: string; - /** 추천 질문 노출 조건(§6.2) 공용 플래그. */ +} + +/** 추천 질문 노출 조건(§6.2) 플래그 — GET /chat/suggestions 전용. */ +export interface SuggestionFlags { + teamCode: TeamCode | null; hasYesterdayRecap: boolean; hasTodayTeamGame: boolean; hasPredictedToday: boolean; @@ -92,178 +92,65 @@ async function safely(label: string, fallback: T, task: () => Promise): Pr } } -function matchupLabel(g: ScheduleGame): string { - return `${g.awayTeamCode} vs ${g.homeTeamCode}`; -} - /** - * 오늘 경기 일정 포맷(2.2 {{todaySchedule}}). - * `live`는 확정 필드만 주입하고 스코어는 제거, `completed`는 스코어 포함, `cancelled`는 취소 라벨. + * 정체성 컨텍스트 구성 — Firestore 조회 없음. + * + * 호출자가 이미 로드한 `users/{uid}` 문서만으로 구성된다. 전송 경로(§3.1)와 + * 프로브가 공유한다. */ -export function formatTodaySchedule(games: ScheduleGame[]): string { - if (games.length === 0) return "오늘 경기 없음"; - const lines = games.map((g) => { - const pitchers = - g.awayStartingPitcher || g.homeStartingPitcher ? - ` 선발 ${g.awayStartingPitcher?.name ?? "미정"} vs ${g.homeStartingPitcher?.name ?? "미정"}` : - ""; - const base = `${matchupLabel(g)} ${g.time} ${g.stadium}${pitchers}`; - switch (g.status) { - case "completed": - return `${base} — 종료 ${g.awayScore ?? "?"}:${g.homeScore ?? "?"}`; - case "live": - return `${base} — 진행 중(스코어 미제공)`; - case "cancelled": - return `${base} — 취소${g.note ? `(${g.note})` : ""}`; - default: - return `${base} — 예정`; - } - }); - return lines.join(" / "); -} - -function formatRecentResults(team: TeamCode, games: ScheduleGame[]): string { - const completed = games.filter( - (g) => - g.status === "completed" && - g.awayScore != null && - g.homeScore != null && - (g.awayTeamCode === team || g.homeTeamCode === team), - ); - if (completed.length === 0) return "최근 경기 정보 없음"; - const recent = completed.slice(-5); - const lines = recent.map((g) => { - const isAway = g.awayTeamCode === team; - const my = isAway ? g.awayScore as number : g.homeScore as number; - const opp = isAway ? g.homeScore as number : g.awayScore as number; - const oppCode = isAway ? g.homeTeamCode : g.awayTeamCode; - const result = my > opp ? "승" : my < opp ? "패" : "무"; - return `${g.date} vs ${oppCode} ${my}:${opp} ${result}`; - }); - return lines.join(", "); -} - -/** - * 응원팀 최근 경기 수집 — 당월 완료 경기가 5건 미만이면 전월을 보충 조회한다 - * (월초에 "최근 5경기"가 비는 것을 방지, 페르소나 문서 2.2 {{recentTeamResults}}). - */ -async function fetchRecentTeamGames(y: number, m: number, team: TeamCode): Promise { - const current = (await getSchedule(y, m, team)).games; - const completed = current.filter((g) => g.status === "completed").length; - if (completed >= 5) return current; - const prevY = m === 1 ? y - 1 : y; - const prevM = m === 1 ? 12 : m - 1; - try { - const prev = (await getSchedule(prevY, prevM, team)).games; - return [...prev, ...current]; - } catch { - return current; // 전월 보충 실패는 당월만으로 degrade - } -} - -/** 전체 사용자 컨텍스트를 병렬 수집한다. 각 항목 실패는 부재 표기로 대체된다. */ -export async function gatherUserContext( - uid: string, - user: User | null, - config?: ChatConfig, -): Promise { - const date = todayKst(); - const [y, m, d] = date.split("-").map(Number); +export function identityContext(user: User | null, config?: ChatConfig): IdentityContext { const teamCode = resolveTeamCode(user?.favoriteTeamCode); - const knowledgeLevel = resolveKnowledgeLevel(user?.knowledgeLevel); - const displayName = sanitizeDisplayName(user?.displayName); - - const [todayGames, myVotes, recapDoc, teamMonthGames, rankResults, stats] = await Promise.all([ - safely("todaySchedule", [], async () => (await getSchedule(y, m, undefined, undefined, d)).games), - safely>("todayMyPredictions", {}, () => getUserDateVotes(uid, date)), - safely("yesterdayRecap", null, async () => - user?.lastJudgedDate ? getDay(uid, parseDateString(user.lastJudgedDate)) : null, - ), - safely("recentTeamResults", [], async () => - teamCode ? fetchRecentTeamGames(y, m, teamCode) : [], - ), - safely("h2hRecords", null, async () => (teamCode ? getRank([y]) : null)), - safely("myStats", null, () => getStats(uid, "current")), - ]); - - // 오늘 내 예측 — 매치업 라벨로 표기(gameId 단독보다 모델이 읽기 좋다) - const voteEntries = Object.entries(myVotes); - const gameById = new Map(todayGames.filter((g) => g.gameId).map((g) => [g.gameId as string, g])); - const todayMyPredictions = - voteEntries.length === 0 ? - "오늘 예측 없음" : - voteEntries - .map(([gameId, v]) => { - const g = gameById.get(gameId); - return g ? `${matchupLabel(g)}: ${v.team} 선택` : `${gameId}: ${v.team} 선택`; - }) - .join(", "); - - // 어제(최근 채점일) 예측 결과 — 서버 채점 결과(result)를 그대로 사용, 재계산 금지 - let yesterdayRecap = "어제 예측 기록 없음"; - let hasYesterdayRecap = false; - if (recapDoc && Array.isArray(recapDoc.data) && recapDoc.data.length > 0) { - hasYesterdayRecap = true; - const correct = recapDoc.data.filter((e) => e.result === true).length; - const detail = recapDoc.data - .map((e) => `${e.team} 선택 → ${e.result ? "적중" : "오답"}`) - .join(", "); - yesterdayRecap = `${user?.lastJudgedDate ?? ""} 기준 ${correct}/${recapDoc.data.length} 적중 (${detail})`; - } - - // 응원팀 최근 5경기(completed만) - const recentTeamResults = teamCode ? formatRecentResults(teamCode, teamMonthGames) : null; - - // 오늘 상대팀과의 시즌 상대 전적(vsRecords) — 오늘 응원팀 경기 없으면 줄 생략 - let h2hRecords: string | null = null; - let hasTodayTeamGame = false; - if (teamCode) { - const todayTeamGame = todayGames.find( - (g) => g.awayTeamCode === teamCode || g.homeTeamCode === teamCode, - ); - hasTodayTeamGame = todayTeamGame != null && todayTeamGame.status !== "cancelled"; - // 취소된 경기는 "오늘 경기 없음"과 동일하게 취급 — h2h도 주입하지 않는다 - if (todayTeamGame && hasTodayTeamGame && rankResults && rankResults.length > 0) { - const opponentCode = todayTeamGame.awayTeamCode === teamCode ? - todayTeamGame.homeTeamCode : - todayTeamGame.awayTeamCode; - const myName = KBO_RANK_TEAM_NAMES[teamCode]; - const oppName = KBO_RANK_TEAM_NAMES[opponentCode as TeamCode]; - const record = rankResults[0].vsRecords.find((r) => r.team === myName); - const wld = oppName ? record?.headToHead[oppName] : undefined; - if (wld) { - h2hRecords = `vs ${oppName} 시즌 ${wld.wins}승 ${wld.losses}패 ${wld.draws}무`; - } - } - } - - // 내 통계 - let myStats = "통계 없음"; - if (stats) { - const pct = (v: number) => `${Math.round(v * 100)}%`; - myStats = - `연속 참여 ${stats.streakDays}일, 적중률 전체 ${pct(stats.winRates.overall)} / ` + - `주간 ${pct(stats.winRates.weekly)} / 월간 ${pct(stats.winRates.monthly)}`; - } - return { - date, - displayName, - knowledgeLevel, + date: todayKst(), + displayName: sanitizeDisplayName(user?.displayName), + knowledgeLevel: resolveKnowledgeLevel(user?.knowledgeLevel), teamCode, // 표기명은 config로 강등(닉네임 전환) 가능 — KBO 라이선스 미확보 대비(1-2) teamName: teamCode ? config?.teamDisplayNames?.[teamCode] ?? TEAM_DISPLAY_NAMES[teamCode] : null, - todaySchedule: formatTodaySchedule(todayGames), - todayMyPredictions, - yesterdayRecap, - recentTeamResults, - h2hRecords, - myStats, - hasYesterdayRecap, - hasTodayTeamGame, - hasPredictedToday: voteEntries.length > 0, + }; +} + +/** + * 추천 질문 노출 플래그 수집(§6.2) — GET /chat/suggestions 전용. + * + * 응원팀 미설정이면 오늘 일정은 어떤 플래그에도 영향을 줄 수 없으므로 조회를 + * 건너뛴다. 각 항목 실패는 플래그 false로 degrade된다(추천 질문은 비핵심). + */ +export async function gatherSuggestionFlags( + uid: string, + user: User | null, +): Promise { + const date = todayKst(); + const [y, m, d] = date.split("-").map(Number); + const teamCode = resolveTeamCode(user?.favoriteTeamCode); + + const [todayGames, myVotes, recapDoc] = await Promise.all([ + teamCode ? + safely("todaySchedule", [], async () => + (await getSchedule(y, m, undefined, undefined, d)).games, + ) : + Promise.resolve([]), + safely>("todayMyPredictions", {}, () => + getUserDateVotes(uid, date), + ), + safely("yesterdayRecap", null, async () => + user?.lastJudgedDate ? getDay(uid, parseDateString(user.lastJudgedDate)) : null, + ), + ]); + + const todayTeamGame = teamCode ? + todayGames.find((g) => g.awayTeamCode === teamCode || g.homeTeamCode === teamCode) : + undefined; + + return { + teamCode, + // 취소된 경기는 "오늘 경기 없음"과 동일하게 취급한다 + hasTodayTeamGame: todayTeamGame != null && todayTeamGame.status !== "cancelled", + hasYesterdayRecap: + recapDoc != null && Array.isArray(recapDoc.data) && recapDoc.data.length > 0, + hasPredictedToday: Object.keys(myVotes).length > 0, }; } @@ -278,10 +165,10 @@ function fill(template: string, vars: Record): string { } /** - * [블록 3] 사용자 컨텍스트 블록(경량판) — 정체성 + 오늘 경기 유무 플래그만. + * [블록 3] 사용자 컨텍스트 블록(경량판) — 정체성만. * 매치업·선발·순위·예측·전적·통계는 도구로 조회하므로 여기서 선주입하지 않는다. */ -export function buildUserContextBlock(ctx: UserContext): string { +export function buildUserContextBlock(ctx: IdentityContext): string { return fill(USER_CONTEXT_TEMPLATE, { todayDate: ctx.date, displayName: ctx.displayName, @@ -333,7 +220,7 @@ export interface AssembledPrompt { * 맨 끝에 서버 지시(위기 마커·카나리 — 기술 설계 §7.3 ②)를 덧붙인다. * 사용자 입력은 여기에 절대 이어붙이지 않는다 — user 롤로만 전달(§7.5). */ -export function assemblePrompt(config: ChatConfig, ctx: UserContext): AssembledPrompt { +export function assemblePrompt(config: ChatConfig, ctx: IdentityContext): AssembledPrompt { const style = resolveStylePack(config.stylePack); const common = config.systemPromptCommon.trim().length > 0 ? config.systemPromptCommon : @@ -353,6 +240,6 @@ export function assemblePrompt(config: ChatConfig, ctx: UserContext): AssembledP } /** 조립된 시스템 프롬프트 문자열만 필요할 때의 단축형. */ -export function assembleSystemPrompt(config: ChatConfig, ctx: UserContext): string { +export function assembleSystemPrompt(config: ChatConfig, ctx: IdentityContext): string { return assemblePrompt(config, ctx).system; } diff --git a/src/services/chatProbeService.ts b/src/services/chatProbeService.ts index 8038f5a..d9b398b 100644 --- a/src/services/chatProbeService.ts +++ b/src/services/chatProbeService.ts @@ -2,7 +2,7 @@ import { getChatConfig, invalidateChatConfigCache } from "./chatConfigService"; import { getUser } from "../repositories/userRepository"; import { assemblePrompt, - gatherUserContext, + identityContext, resolveTeamCode, } from "./chatContextService"; import { buildChatTools, type ChatToolContext } from "./chatToolService"; @@ -112,7 +112,7 @@ async function buildProbeEnv( } const teamCode = resolveTeamCode(user.favoriteTeamCode); const date = todayKst(); - const ctx = await gatherUserContext(uid, user, config); + const ctx = identityContext(user, config); const { system } = assemblePrompt(config, ctx); const provider = getChatProvider(config.provider); const teamName = teamCode ? TEAM_DISPLAY_NAMES[teamCode] ?? teamCode : "(미설정)"; diff --git a/src/services/chatService.ts b/src/services/chatService.ts index 209b0cf..3a03e0e 100644 --- a/src/services/chatService.ts +++ b/src/services/chatService.ts @@ -30,7 +30,13 @@ import { import { checkInput, checkOutput, crisisReply, detectCrisis } from "./chatFilterService"; import { logChatEvent } from "./chatAnalyticsService"; import { extractNavActions } from "./chatNavService"; -import { assemblePrompt, gatherUserContext, resolveTeamCode, type UserContext } from "./chatContextService"; +import { + assemblePrompt, + gatherSuggestionFlags, + identityContext, + resolveTeamCode, + type IdentityContext, +} from "./chatContextService"; import { buildChatTools, withToolLabels } from "./chatToolService"; import { EMPTY_REPLY_NOTICE, FALLBACK_SUGGESTION_IDS, FILTERED_REPLY, INPUT_BLOCKED_NOTICE, @@ -78,6 +84,11 @@ async function quotaView(uid: string, date: DateString): Promise<{ used: number return { used: quota.used ?? 0 }; } +/** + * @param usedOverride 예약 트랜잭션이 이미 계산한 차감 후 `used`. 넘기면 쿼터 + * 문서를 다시 읽지 않는다. 트랜잭션이 돌지 않는 경로(replay, GET /chat/quota)는 + * 생략해서 조회하게 둔다. + */ async function buildSendResult( uid: string, date: DateString, @@ -88,8 +99,9 @@ async function buildSendResult( createdAt: Timestamp, toolCalls?: ChatToolCallInfo[], actions?: NavAction[], + usedOverride?: number, ): Promise { - const { used } = await quotaView(uid, date); + const used = usedOverride ?? (await quotaView(uid, date)).used; return { messageId, reply, @@ -121,14 +133,23 @@ async function replayDone( ); } -/** 단기 문맥 윈도잉(§5.5) — 활성 스레드에서 N턴, 최대 나이, 위기/필터 제외. */ +/** + * 단기 문맥 윈도잉(§5.5) — 활성 스레드에서 N턴, 최대 나이, 위기/필터 제외. + * + * `threadExists`를 함께 돌려준다 — 저장 단계(`finalizeExchange`)가 `createdAt` + * 보존 판단을 위해 같은 문서를 다시 읽지 않도록. + */ async function loadHistory( uid: string, threadId: string, config: ChatConfig, -): Promise { - const fetchLimit = config.historyTurns * 2 + 30; - const [thread, recentDesc] = await Promise.all([ +): Promise<{ messages: ChatProviderMessage[]; threadExists: boolean }> { + const target = config.historyTurns * 2; + // 위기 요청은 일일 한도를 우회하므로(§7.3) 창 안의 위기 교환쌍 수에는 상한이 없다. + // 위기 쌍은 문서 2개를 차지하고 윈도잉에서 둘 다 빠지므로, 고정 padding은 + // 유효한 상한이 될 수 없다. 평시에는 작게 읽고, 실제로 모자랄 때만 한 번 넓힌다. + const fetchLimit = target + 10; + const [thread, firstPage] = await Promise.all([ getThreadDoc(uid, threadId), getRecentMessages(uid, threadId, fetchLimit), ]); @@ -138,26 +159,38 @@ async function loadHistory( // 실제 문맥 분리는 스레드(팀)·턴수·나이 3가지로만 이뤄짐. const cutAt = thread?.historyCutAt?.toMillis() ?? 0; - const asc = [...recentDesc].reverse(); + /** 나이·cut·위기/필터 제외를 적용해 사용 가능한 메시지만 시간순으로 남긴다. */ + const window = (desc: MessageWithId[]): MessageWithId[] => { + const asc = [...desc].reverse(); + // 위기 교환 쌍 제외 — crisis assistant와 그 replyTo user 메시지(§5.5) + const excludedUserIds = new Set(); + for (const m of asc) { + if (m.role === "assistant" && m.crisis && m.replyTo) excludedUserIds.add(m.replyTo); + } + return asc.filter((m: MessageWithId) => { + const ms = m.createdAt.toMillis(); + if (ms < minCreatedAt || ms < cutAt) return false; + if (m.role === "assistant" && (m.crisis || m.filtered)) return false; + if (m.role === "user" && excludedUserIds.has(m.messageId)) return false; + return true; + }); + }; - // 위기 교환 쌍 제외 — crisis assistant와 그 replyTo user 메시지(§5.5) - const excludedUserIds = new Set(); - for (const m of asc) { - if (m.role === "assistant" && m.crisis && m.replyTo) excludedUserIds.add(m.replyTo); + let windowed = window(firstPage); + // 가져온 만큼을 다 채웠는데도 목표에 못 미친다 = 제외된 분량 때문에 잘렸을 수 + // 있다는 뜻. 더 오래된 유효 메시지가 남아 있을 수 있으므로 한 번만 넓혀 재조회한다. + // (스레드가 원래 짧아서 못 채운 경우에는 firstPage가 fetchLimit보다 작아 재조회하지 않는다.) + if (windowed.length < target && firstPage.length === fetchLimit) { + windowed = window(await getRecentMessages(uid, threadId, target + 40)); } - const windowed = asc.filter((m: MessageWithId) => { - const ms = m.createdAt.toMillis(); - if (ms < minCreatedAt || ms < cutAt) return false; - if (m.role === "assistant" && (m.crisis || m.filtered)) return false; - if (m.role === "user" && excludedUserIds.has(m.messageId)) return false; - return true; - }); - - const lastN = windowed.slice(-config.historyTurns * 2); + const lastN = windowed.slice(-target); // provider 제약: 첫 메시지는 user여야 한다 — 앞쪽 assistant 잔여분 제거 while (lastN.length > 0 && lastN[0].role === "assistant") lastN.shift(); - return lastN.map((m) => ({ role: m.role, content: m.content })); + return { + messages: lastN.map((m) => ({ role: m.role, content: m.content })), + threadExists: thread != null, + }; } /** 비용 가드 80% 운영 알림(§8.3) — 인스턴스·날짜당 1회만 경고. */ @@ -277,20 +310,30 @@ export async function sendMessage(uid: string, body: SendBody): Promise let priority: ChatSuggestion[] = []; try { const user = await getUser(uid); - const ctx = await gatherUserContext(uid, user, config); + const flags = await gatherSuggestionFlags(uid, user); candidates = config.suggestions.filter((s) => { - if (s.requiresTeam && ctx.teamCode == null) return false; + if (s.requiresTeam && flags.teamCode == null) return false; // 6.2 표의 "오늘 경기 없음 → Q7/Q11/Q12 제외"는 질문 문구("오늘 우리 경기")에 // 맞춰 "응원팀의 오늘 경기 유무"로 해석해 적용한다(리그 전체 기준보다 엄격) - if (s.requiresTodayTeamGame && !ctx.hasTodayTeamGame) return false; - if (s.requiresYesterdayRecap && !ctx.hasYesterdayRecap) return false; - if (s.excludeWhenPredictedToday && ctx.hasPredictedToday) return false; + if (s.requiresTodayTeamGame && !flags.hasTodayTeamGame) return false; + if (s.requiresYesterdayRecap && !flags.hasYesterdayRecap) return false; + if (s.excludeWhenPredictedToday && flags.hasPredictedToday) return false; return true; }); - if (ctx.hasYesterdayRecap) { + if (flags.hasYesterdayRecap) { priority = candidates.filter((s) => s.priorityWhenRecap); } } catch (err) { diff --git a/tests/services/chatContextService.test.ts b/tests/services/chatContextService.test.ts index 083c7f7..ee66916 100644 --- a/tests/services/chatContextService.test.ts +++ b/tests/services/chatContextService.test.ts @@ -1,56 +1,25 @@ import { describe, expect, it } from "vitest"; import { buildUserContextBlock, - formatTodaySchedule, resolveKnowledgeLevel, resolvePersonaBlock, resolveTeamCode, sanitizeDisplayName, - type UserContext, + type IdentityContext, } from "../../src/services/chatContextService"; import { assembleSystemPrompt } from "../../src/services/chatContextService"; import { DEFAULT_CHAT_CONFIG } from "../../src/services/chatConfigService"; import { CHAT_CANARY_TOKEN } from "../../src/constants/chatPrompts"; import { KnowledgeLevel, TeamCode } from "../../src/types/panit"; import type { DateString } from "../../src/types/dateString"; -import type { ScheduleGame } from "../../src/types/kbo"; -function game(overrides: Partial): ScheduleGame { - return { - date: "06.12", - dayOfWeek: "금", - time: "18:30", - awayTeamCode: "LG", - homeTeamCode: "HH", - awayScore: null, - homeScore: null, - status: "scheduled", - stadium: "대전", - broadcast: "", - note: "", - gameId: "20260612LGHH0", - awayStartingPitcher: { id: 1, name: "김선발" }, - homeStartingPitcher: { id: 2, name: "박선발" }, - ...overrides, - }; -} - -function ctx(overrides: Partial): UserContext { +function ctx(overrides: Partial): IdentityContext { return { date: "2026-06-12" as DateString, displayName: "솔방울", knowledgeLevel: KnowledgeLevel.Casual, teamCode: TeamCode.HH, teamName: "한화 이글스", - todaySchedule: "오늘 경기 없음", - todayMyPredictions: "오늘 예측 없음", - yesterdayRecap: "어제 예측 기록 없음", - recentTeamResults: null, - h2hRecords: null, - myStats: "통계 없음", - hasYesterdayRecap: false, - hasTodayTeamGame: false, - hasPredictedToday: false, ...overrides, }; } @@ -80,29 +49,6 @@ describe("chatContextService", () => { }); }); - describe("formatTodaySchedule — 결정된 사항만(2-1)", () => { - it("진행 중 경기는 확정 필드만 주입하고 스코어를 제거한다", () => { - const out = formatTodaySchedule([game({ status: "live", awayScore: 3, homeScore: 5 })]); - expect(out).toContain("진행 중(스코어 미제공)"); - expect(out).not.toContain("3:5"); // 스코어 미주입 - expect(out).toContain("김선발"); // 선발 예고는 확정 정보 — 주입 - }); - - it("종료 경기는 스코어를 포함한다", () => { - const out = formatTodaySchedule([game({ status: "completed", awayScore: 2, homeScore: 7 })]); - expect(out).toContain("2:7"); - expect(out).toContain("종료"); - }); - - it("취소 경기는 취소 라벨로 표기한다", () => { - expect(formatTodaySchedule([game({ status: "cancelled", note: "우천취소" })])).toContain("취소"); - }); - - it("경기 없으면 부재 표기를 쓴다", () => { - expect(formatTodaySchedule([])).toBe("오늘 경기 없음"); - }); - }); - describe("buildUserContextBlock(2.2 템플릿)", () => { it("고정 구분자로 감싸고 응원팀 미설정·선택 줄 생략을 적용한다", () => { const block = buildUserContextBlock(ctx({ teamCode: null, teamName: null })); @@ -114,7 +60,7 @@ describe("chatContextService", () => { }); it("정체성(닉네임·응원팀)만 주입하고 시사 수치·일정은 넣지 않는다(경량판)", () => { - const block = buildUserContextBlock(ctx({ hasTodayTeamGame: true })); + const block = buildUserContextBlock(ctx({})); expect(block).toContain("- 사용자: 솔방울"); expect(block).not.toContain("오늘"); // 오늘 경기·일정은 컨텍스트에 없음(도구로) expect(block).not.toContain("최근 5경기"); diff --git a/tests/services/chatService.test.ts b/tests/services/chatService.test.ts index 079f579..8d6506d 100644 --- a/tests/services/chatService.test.ts +++ b/tests/services/chatService.test.ts @@ -617,6 +617,60 @@ describe("chatService", () => { expect(contents).toEqual(["어제 경기 봤어?", "봤지! 짜릿했어", "오늘은 어때?"]); expect(history[0].role).toBe("user"); }); + + /** + * 위기 요청은 일일 한도를 우회하므로(§7.3) 창 안의 위기 교환쌍 수에 상한이 없다. + * 위기 쌍은 문서 2개를 먹고 윈도잉에서 둘 다 빠지므로, 1차 페이지가 위기 문서로 + * 채워지면 나이 창 안에 유효 메시지가 남아 있는데도 턴 수가 모자라게 된다. + */ + it("위기 쌍이 1차 페이지를 채워도 목표 턴 수를 유지한다", async () => { + const col = messagesCol(uid, "HH"); + const now = Date.now(); + const mk = ( + id: string, + role: "user" | "assistant", + content: string, + atMs: number, + flags: Partial = {}, + ) => + col.doc(id).set({ + role, + content, + createdAt: Timestamp.fromMillis(atMs), + filtered: false, + crisis: false, + expireAt: Timestamp.fromMillis(atMs + 1000_000), + ...flags, + }); + + // 유효 대화 10쌍(20 doc) — 60분 전부터 42분 전까지 + for (let i = 0; i < 10; i++) { + const at = now - (60 - i * 2) * 60_000; + await mk(`v${i}u`, "user", `유효질문${i}`, at); + await mk(`v${i}a`, "assistant", `유효답변${i}`, at + 1); + } + // 위기 6쌍(12 doc) — 더 최근(30분 전부터). 1차 페이지(30건)를 잠식한다. + for (let j = 0; j < 6; j++) { + const at = now - (30 - j * 2) * 60_000; + await mk(`c${j}u`, "user", `위기질문${j}`, at); + await mk(`c${j}a`, "assistant", `위기안내${j}`, at + 1, { + crisis: true, + replyTo: `c${j}u`, + }); + } + + const calls = useEchoProvider(); + await sendMessage(uid, { message: "오늘은 어때?", clientMessageId: newUuid() }); + + const history = calls[0].messages; + const contents = history.map((m) => m.content); + // 위기 쌍은 전부 제외되고, 유효 10턴(20 doc) + 이번 입력 1건이 남아야 한다 + expect(contents.some((c) => c.startsWith("위기"))).toBe(false); + expect(history).toHaveLength(21); + // 2차 확장 없이는 가장 오래된 유효 쌍이 잘려 나간다 + expect(contents[0]).toBe("유효질문0"); + expect(history[0].role).toBe("user"); + }); }); describe("스레드 결정 규칙(추가-1·추가-2)", () => { From 90d6895de4003b3bb7e2ddac2633252af84122fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=A0=95=EB=AF=BC?= Date: Mon, 27 Jul 2026 13:39:10 +0900 Subject: [PATCH 7/8] Pin script RTDB region and gate empty-source backfill marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 스크립트 부트스트랩의 databaseURL 기본값에 리전 포함 — 기존 `...firebaseio.com`은 SDK가 "Database lives in a different region" 경고만 내고 다른 리전의 없는 DB를 보게 해서, 조회가 조용히 빈 결과를 돌려주고 실행이 멈췄다. 지금까지 스크립트가 RTDB를 안 써서 드러나지 않던 문제다. FIREBASE_DATABASE_REGION으로 덮어쓸 수 있다 - 백필 스크립트가 접속한 database URL을 먼저 출력하도록 변경 - 소스가 비었을 때 완료 마커를 자동으로 남기지 않고 --mark-empty를 요구하도록 변경 — 빈 조회는 "정말 투표가 없다"와 "엉뚱한 DB에 붙었다"를 구분하지 못하는데, 후자에서 마커가 켜지면 이후 dailyArchive가 인덱스만 믿고 전 유저를 건너뛴다 --- scripts/_bootstrap.ts | 11 ++++++++--- scripts/backfill-user-votes-by-date.ts | 22 +++++++++++++++++++--- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/scripts/_bootstrap.ts b/scripts/_bootstrap.ts index 5da8fed..0dcb7d7 100644 --- a/scripts/_bootstrap.ts +++ b/scripts/_bootstrap.ts @@ -7,14 +7,19 @@ * gcloud auth application-default login * 또는 GOOGLE_APPLICATION_CREDENTIALS=<서비스계정.json> * - * 도구는 RTDB를 쓰지 않지만 src/firebase가 getDatabase()를 호출하므로 - * databaseURL이 없으면 import 시점에 throw한다 — 그래서 형식상 URL을 채워준다. + * databaseURL은 반드시 **리전을 포함**해야 한다. 리전 없는 기본 호스트 + * (`...firebaseio.com`)로 붙으면 SDK가 "Database lives in a different region"만 + * 경고하고 다른 리전의 (존재하지 않는) DB를 보게 되어, 조회가 조용히 빈 결과를 + * 돌려준다 — 그 값을 사실로 믿는 스크립트는 위험한 판단을 내릴 수 있다. + * 리전은 FIREBASE_DATABASE_REGION, 전체 URL은 FIREBASE_DATABASE_URL로 덮어쓴다. */ import { initializeApp, getApps } from "firebase-admin/app"; const projectId = process.env.FIREBASE_PROJECT ?? process.env.GCLOUD_PROJECT ?? "mmday-panit"; +const databaseRegion = process.env.FIREBASE_DATABASE_REGION ?? "asia-southeast1"; const databaseURL = - process.env.FIREBASE_DATABASE_URL ?? `https://${projectId}-default-rtdb.firebaseio.com`; + process.env.FIREBASE_DATABASE_URL ?? + `https://${projectId}-default-rtdb.${databaseRegion}.firebasedatabase.app`; if (getApps().length === 0) { initializeApp({ projectId, databaseURL }); diff --git a/scripts/backfill-user-votes-by-date.ts b/scripts/backfill-user-votes-by-date.ts index 4637845..974fe79 100644 --- a/scripts/backfill-user-votes-by-date.ts +++ b/scripts/backfill-user-votes-by-date.ts @@ -8,8 +8,14 @@ * 멱등하다 — 여러 번 돌려도 같은 값으로 덮어쓸 뿐이다. * * 실행: - * npx tsx scripts/backfill-user-votes-by-date.ts # 미리보기 - * npx tsx scripts/backfill-user-votes-by-date.ts --apply # 실제 쓰기 + * npx tsx scripts/backfill-user-votes-by-date.ts # 미리보기 + * npx tsx scripts/backfill-user-votes-by-date.ts --apply # 실제 쓰기 + * npx tsx scripts/backfill-user-votes-by-date.ts --apply --mark-empty # 소스가 빈 상태에서 마커만 + * + * ⚠️ 소스(/userVotes)가 비었을 때는 마커를 자동으로 남기지 않는다. 빈 조회는 + * "정말 투표가 없다"와 "엉뚱한 DB에 붙었다"를 구분하지 못하는데, 후자에서 마커가 + * 켜지면 이후 아카이브가 인덱스만 믿고 전 유저를 건너뛴다. 비었다는 것을 사람이 + * 확인했을 때만 --mark-empty로 명시한다. */ import "./_bootstrap"; import { rtdb } from "../src/firebase"; @@ -22,11 +28,21 @@ const CHUNK = 500; async function main(): Promise { const apply = process.argv.includes("--apply"); + const markEmpty = process.argv.includes("--mark-empty"); + + console.log(`database: ${rtdb.app.options.databaseURL}`); const snap = await rtdb.ref("/userVotes").get(); if (!snap.exists()) { console.log("no /userVotes data — nothing to backfill"); - if (apply) await markBackfilled(); + if (!apply) return; + if (!markEmpty) { + console.log( + "마커를 남기지 않았다. 위 database URL이 맞고 정말 투표가 없다면 --mark-empty를 붙여 다시 실행할 것." + ); + return; + } + await markBackfilled(); return; } From 89e817206b4d4cedb487181ffec5a24cc4d4e322 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=A0=95=EB=AF=BC?= Date: Mon, 3 Aug 2026 14:54:03 +0900 Subject: [PATCH 8/8] Clamp future schedule cache TTL to the target day MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 모레 이후 일자의 TTL 이 7일 고정이라, 2~7일 앞서 캐시된 날짜가 경기 당일과 그 이후까지 "경기 전" 스냅샷을 계속 내놓았다 - TTL 은 쓰는 시점의 미래 거리로만 정해지고 시간이 흘러도 재평가되지 않으므로, 해당 날짜 00:00 을 넘지 않도록 잘라 경기 당일에는 반드시 다시 받아오게 한다. 이후로는 '오늘'/'과거' 분기가 실제 진행 상태에 맞는 TTL 을 다시 매긴다 - 실제 사고: 07-27 에 담긴 08-01 스냅샷이 7일 TTL 로 08-03 까지 살아남아, 폭염취소된 NC 경기가 달력에서 계속 scheduled 로 보였다 - dayTtlMs 를 export 하고 fake timer 기반 회귀 테스트 8건 추가 - 함수 주석의 TTL 표를 실제 동작대로 다시 적었다 (기존 "본 모듈 상단의 표 참조" 는 존재하지 않는 표를 가리키고 있었다) --- src/repositories/kboRepository.ts | 22 ++++++- tests/unit/scheduleDayTtl.test.ts | 96 +++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 3 deletions(-) create mode 100644 tests/unit/scheduleDayTtl.test.ts diff --git a/src/repositories/kboRepository.ts b/src/repositories/kboRepository.ts index 5a5d9b0..b335c85 100644 --- a/src/repositories/kboRepository.ts +++ b/src/repositories/kboRepository.ts @@ -122,9 +122,20 @@ function parseHHMM(t: string): number | null { } /** - * 일자별 TTL 산출. 본 모듈 상단의 표 참조. + * 일자별 TTL 산출. + * + * 과거 : 그 날 경기가 모두 종료/취소면 7d, 아니면 30s + * 오늘 : 시작 전이면 첫 경기 시작까지(최대 1h), 그 외 30s, 모두 끝났으면 7d + * 내일 : 6h + * 모레 이후: 7d — 단 해당 날짜 자정을 넘기지 않는다 + * + * 마지막 조건이 중요하다. TTL은 '쓰는 시점의 미래 거리'로만 정해지고 시간이 + * 흘러도 재평가되지 않으므로, 자르지 않으면 2~7일 앞서 캐시된 날짜가 경기 + * 당일과 그 이후까지 "경기 전" 스냅샷을 계속 내놓는다. + * (실제 사고: 07-27에 담긴 08-01이 7d TTL로 08-03까지 살아남아, 폭염취소된 + * 경기가 달력에서 계속 `scheduled`로 보였다.) */ -function dayTtlMs(yyyymmdd: string, games: ScheduleGame[]): number { +export function dayTtlMs(yyyymmdd: string, games: ScheduleGame[]): number { const y = parseInt(yyyymmdd.slice(0, 4), 10); const m = parseInt(yyyymmdd.slice(4, 6), 10); const d = parseInt(yyyymmdd.slice(6, 8), 10); @@ -148,7 +159,12 @@ function dayTtlMs(yyyymmdd: string, games: ScheduleGame[]): number { return allDone ? SEVEN_DAYS_MS : THIRTY_SEC_MS; } if (diffDays === 1) return SIX_HOURS_MS; - if (diffDays >= 2) return SEVEN_DAYS_MS; + if (diffDays >= 2) { + // 해당 날짜 00:00 에 만료시켜, 경기 당일에는 반드시 다시 받아오게 한다. + // 그 뒤로는 '오늘'/'과거' 분기가 실제 진행 상태에 맞는 TTL을 다시 매긴다. + const untilDayStartMs = dayStart - now.getTime(); + return Math.min(SEVEN_DAYS_MS, Math.max(untilDayStartMs, THIRTY_SEC_MS)); + } // 오늘 if (games.length === 0) return SIX_HOURS_MS; diff --git a/tests/unit/scheduleDayTtl.test.ts b/tests/unit/scheduleDayTtl.test.ts new file mode 100644 index 0000000..0d927a3 --- /dev/null +++ b/tests/unit/scheduleDayTtl.test.ts @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { dayTtlMs } from "../../src/repositories/kboRepository"; +import type { ScheduleGame, GameStatus } from "../../src/kbo/schedule"; + +const ONE_DAY_MS = 24 * 60 * 60 * 1000; +const SEVEN_DAYS_MS = 7 * ONE_DAY_MS; +const SIX_HOURS_MS = 6 * 60 * 60 * 1000; +const THIRTY_SEC_MS = 30_000; + +function game(status: GameStatus, time = "18:00"): ScheduleGame { + return { status, time } as unknown as ScheduleGame; +} + +/** yyyymmdd 문자열의 로컬 자정 타임스탬프. dayTtlMs 와 같은 기준. */ +function localDayStart(yyyymmdd: string): number { + const y = Number(yyyymmdd.slice(0, 4)); + const m = Number(yyyymmdd.slice(4, 6)); + const d = Number(yyyymmdd.slice(6, 8)); + return new Date(y, m - 1, d).getTime(); +} + +describe("dayTtlMs", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe("모레 이후", () => { + it("TTL이 해당 날짜 자정을 넘기지 않는다", () => { + // 회귀 테스트: 2026-07-27 에 담긴 08-01 스냅샷이 7d TTL로 08-03까지 + // 살아남아, 폭염취소된 경기가 달력에서 계속 `scheduled` 로 보였다. + vi.setSystemTime(new Date(2026, 6, 27, 21, 6)); + + const ttl = dayTtlMs("20260801", [game("scheduled")]); + + expect(Date.now() + ttl).toBe(localDayStart("20260801")); + expect(ttl).toBeLessThan(SEVEN_DAYS_MS); + }); + + it("7일보다 먼 날짜는 7d 로 상한을 둔다", () => { + vi.setSystemTime(new Date(2026, 6, 27, 21, 6)); + + expect(dayTtlMs("20260901", [game("scheduled")])).toBe(SEVEN_DAYS_MS); + }); + + it("자정 직전이어도 최소 30s 는 보장한다", () => { + // 2026-08-01 23:59:59 → 08-03 자정까지 남은 시간은 0 에 가깝지 않지만, + // 경계에서 0/음수 TTL 이 나오지 않는지 하한을 확인한다. + vi.setSystemTime(new Date(2026, 7, 1, 23, 59, 59, 900)); + + expect(dayTtlMs("20260803", [game("scheduled")])) + .toBeGreaterThanOrEqual(THIRTY_SEC_MS); + }); + }); + + it("내일은 6h", () => { + vi.setSystemTime(new Date(2026, 7, 1, 10, 0)); + + expect(dayTtlMs("20260802", [game("scheduled")])).toBe(SIX_HOURS_MS); + }); + + describe("지난 날짜", () => { + it("모두 종료/취소면 7d", () => { + vi.setSystemTime(new Date(2026, 7, 3, 10, 0)); + + const games = [game("completed"), game("cancelled")]; + expect(dayTtlMs("20260801", games)).toBe(SEVEN_DAYS_MS); + }); + + it("미종료 경기가 남아 있으면 30s", () => { + vi.setSystemTime(new Date(2026, 7, 3, 10, 0)); + + const games = [game("completed"), game("scheduled")]; + expect(dayTtlMs("20260801", games)).toBe(THIRTY_SEC_MS); + }); + }); + + describe("오늘", () => { + it("첫 경기 시작 전이면 시작까지만 캐시한다", () => { + vi.setSystemTime(new Date(2026, 7, 3, 17, 30)); + + // 18:00 시작 → 30분 + expect(dayTtlMs("20260803", [game("scheduled", "18:00")])) + .toBe(30 * 60_000); + }); + + it("모두 끝났으면 7d", () => { + vi.setSystemTime(new Date(2026, 7, 3, 23, 0)); + + expect(dayTtlMs("20260803", [game("completed")])).toBe(SEVEN_DAYS_MS); + }); + }); +});