Merge branch 'perf/firestore-io-audit' into develop

This commit is contained in:
윤정민 2026-08-03 15:48:43 +09:00
commit 628053f72b
38 changed files with 1736 additions and 403 deletions

View File

@ -15,6 +15,14 @@
".write": false ".write": false
} }
}, },
"userVotesByDate": {
".read": false,
".write": false
},
"userVotesByDateMeta": {
".read": false,
".write": false
},
"cache": { "cache": {
".read": false, ".read": false,
".write": false ".write": false

View File

@ -14,7 +14,8 @@
"tools:sheet": "npx tsx scripts/chat-tools-sheet.ts", "tools:sheet": "npx tsx scripts/chat-tools-sheet.ts",
"seed:rewards": "npx tsx scripts/seed-reward-products.ts", "seed:rewards": "npx tsx scripts/seed-reward-products.ts",
"upload:reward-assets": "npx tsx scripts/upload-reward-product-assets.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": { "engines": {
"node": "24" "node": "24"

View File

@ -7,14 +7,19 @@
* gcloud auth application-default login * gcloud auth application-default login
* GOOGLE_APPLICATION_CREDENTIALS=<서비스계정.json> * GOOGLE_APPLICATION_CREDENTIALS=<서비스계정.json>
* *
* RTDB를 src/firebase가 getDatabase() * databaseURL은 ** ** .
* databaseURL이 import throw한다 URL을 . * (`...firebaseio.com`) SDK가 "Database lives in a different region"
* ( ) DB를 ,
* 믿 .
* FIREBASE_DATABASE_REGION, URL은 FIREBASE_DATABASE_URL로 .
*/ */
import { initializeApp, getApps } from "firebase-admin/app"; import { initializeApp, getApps } from "firebase-admin/app";
const projectId = process.env.FIREBASE_PROJECT ?? process.env.GCLOUD_PROJECT ?? "mmday-panit"; const projectId = process.env.FIREBASE_PROJECT ?? process.env.GCLOUD_PROJECT ?? "mmday-panit";
const databaseRegion = process.env.FIREBASE_DATABASE_REGION ?? "asia-southeast1";
const databaseURL = 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) { if (getApps().length === 0) {
initializeApp({ projectId, databaseURL }); initializeApp({ projectId, databaseURL });

View File

@ -0,0 +1,101 @@
/**
* `/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 #
* npx tsx scripts/backfill-user-votes-by-date.ts --apply --mark-empty #
*
* (/userVotes) .
* "정말 투표가 없다" "엉뚱한 DB에 붙었다" ,
* 믿 .
* --mark-empty로 .
*/
import "./_bootstrap";
import { rtdb } from "../src/firebase";
type VoteEntry = { team: string; result?: boolean; cancelled?: boolean };
type ByUid = Record<string, Record<string, Record<string, VoteEntry>>>;
/** RTDB update는 한 번에 너무 많은 경로를 담으면 실패하므로 나눠 커밋한다. */
const CHUNK = 500;
async function main(): Promise<void> {
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) return;
if (!markEmpty) {
console.log(
"마커를 남기지 않았다. 위 database URL이 맞고 정말 투표가 없다면 --mark-empty를 붙여 다시 실행할 것."
);
return;
}
await markBackfilled();
return;
}
const byUid = snap.val() as ByUid;
const updates: Record<string, unknown> = {};
let uidCount = 0;
let entryCount = 0;
const dates = new Set<string>();
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<string, unknown> = {};
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<void> {
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);
});

View File

@ -36,6 +36,9 @@ export const prediction = onRequest(async (req, res) => {
const date = resolveDateParam(req.query.date); const date = resolveDateParam(req.query.date);
const games = await listGamesByDate(date); const games = await listGamesByDate(date);
const dto: GamesResponseDto = { date, games }; const dto: GamesResponseDto = { date, games };
// 무인증·전 유저 공통 응답 — CDN/브라우저에서 중복 요청을 합칠 수 있게 한다.
// 경기 중 스코어 변동을 고려해 서버 캐시(15초)와 같은 수준으로 짧게 잡는다.
res.set("Cache-Control", "public, max-age=15");
res.status(200).json(dto); res.status(200).json(dto);
return; return;
} }

View File

@ -14,13 +14,23 @@ export class MemCache<T> {
) {} ) {}
get(key: string): T | null { 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); const e = this.cache.get(key);
if (!e) return null; if (!e) return null;
if (Date.now() > e.expiresAt) { if (Date.now() > e.expiresAt) {
this.cache.delete(key); this.cache.delete(key);
return null; return null;
} }
return e.data; return { data: e.data };
} }
set(key: string, data: T): void { set(key: string, data: T): void {
@ -44,12 +54,19 @@ export class MemCache<T> {
this.inflight.delete(key); this.inflight.delete(key);
} }
/** 전체 엔트리를 버린다. 여러 key에 영향을 주는 쓰기 이후의 일괄 무효화용. */
clear(): void {
this.cache.clear();
this.inflight.clear();
}
/** /**
* miss면 fetcher ( key ). * miss면 fetcher ( key ).
*/ */
async getOrFetch(key: string, fetcher: () => Promise<T>): Promise<T> { async getOrFetch(key: string, fetcher: () => Promise<T>): Promise<T> {
const cached = this.get(key); // truthy 검사가 아니라 히트 여부로 판정한다 — 캐시된 null/0/""도 서빙된다.
if (cached) return cached; const cached = this.peek(key);
if (cached) return cached.data;
const existing = this.inflight.get(key); const existing = this.inflight.get(key);
if (existing) return existing; if (existing) return existing;

View File

@ -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 { export function sendError(res: express.Response, err: unknown): void {
if (err instanceof HttpError) { if (err instanceof HttpError) {
const body: Record<string, unknown> = err.code const body: Record<string, unknown> = err.code

View File

@ -2,7 +2,7 @@ import { randomBytes, createHash } from "node:crypto";
import { FieldPath, Timestamp } from "firebase-admin/firestore"; import { FieldPath, Timestamp } from "firebase-admin/firestore";
import { ServerValue } from "firebase-admin/database"; import { ServerValue } from "firebase-admin/database";
import { firestore, rtdb } from "../firebase"; import { firestore, rtdb } from "../firebase";
import { HttpError } from "../middleware/errors"; import { HttpError, isAlreadyExistsError } from "../middleware/errors";
import { MemCache } from "../lib/memCache"; import { MemCache } from "../lib/memCache";
import type { import type {
ChatMessageDoc, ChatMessageDoc,
@ -98,8 +98,11 @@ export interface ReserveParams {
export type ReserveOutcome = export type ReserveOutcome =
| { kind: "done"; assistantMessageId: string; threadId: string } | { 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 . * ·· Firestore .
@ -136,7 +139,9 @@ export async function reserveRequestTx(params: ReserveParams): Promise<ReserveOu
const stillDebited = req.debited && !req.refunded; const stillDebited = req.debited && !req.refunded;
if (stillDebited || params.crisisPath) { if (stillDebited || params.crisisPath) {
tx.update(reqRef, { createdAt: now }); tx.update(reqRef, { createdAt: now });
return { kind: "reserved", threadId: req.threadId, debited: stillDebited, resumed: true }; // 이 분기는 쿼터를 갱신하지 않으므로 읽은 값이 그대로 현재 값이다.
const keptUsed = ((quotaSnap.data() ?? {}) as Partial<ChatQuotaDoc>).used ?? 0;
return { kind: "reserved", threadId: req.threadId, debited: stillDebited, resumed: true, used: keptUsed };
} }
const resumeQuota = (quotaSnap.data() ?? {}) as Partial<ChatQuotaDoc>; const resumeQuota = (quotaSnap.data() ?? {}) as Partial<ChatQuotaDoc>;
const resumeUsed = resumeQuota.used ?? 0; const resumeUsed = resumeQuota.used ?? 0;
@ -152,7 +157,7 @@ export async function reserveRequestTx(params: ReserveParams): Promise<ReserveOu
limit: params.limit, limit: params.limit,
updatedAt: now, updatedAt: now,
}, { merge: true }); }, { merge: true });
return { kind: "reserved", threadId: req.threadId, debited: true, resumed: true }; return { kind: "reserved", threadId: req.threadId, debited: true, resumed: true, used: resumeUsed + 1 };
} }
const quota = (quotaSnap.data() ?? {}) as Partial<ChatQuotaDoc>; const quota = (quotaSnap.data() ?? {}) as Partial<ChatQuotaDoc>;
@ -208,7 +213,7 @@ export async function reserveRequestTx(params: ReserveParams): Promise<ReserveOu
updatedAt: now, updatedAt: now,
}, { merge: true }); }, { merge: true });
return { kind: "reserved", threadId: params.threadId, debited, resumed: false }; return { kind: "reserved", threadId: params.threadId, debited, resumed: false, used: debited ? used + 1 : used };
}); });
} }
@ -304,6 +309,11 @@ export interface ExchangeParams {
/** assistant 화면 이동 액션(마커에서 추출). 비었으면 미저장. */ /** assistant 화면 이동 액션(마커에서 추출). 비었으면 미저장. */
actions?: NavAction[]; actions?: NavAction[];
retentionDays: number; retentionDays: number;
/**
* . `createdAt` .
* read를 . .
*/
threadExists?: boolean;
} }
export interface ExchangeResult { export interface ExchangeResult {
@ -324,7 +334,9 @@ export async function finalizeExchange(params: ExchangeParams): Promise<Exchange
const batch = firestore.batch(); const batch = firestore.batch();
const tRef = threadRef(params.uid, params.threadId); const tRef = threadRef(params.uid, params.threadId);
const threadExists = (await tRef.get()).exists; // 호출자가 이미 스레드 문서를 읽었으면(loadHistory 경유 정상 경로) 그 값을 쓴다.
// 위기 경로는 loadHistory를 거치지 않으므로 여기서 직접 읽는다.
const threadExists = params.threadExists ?? (await tRef.get()).exists;
const userDoc: ChatMessageDoc = { const userDoc: ChatMessageDoc = {
role: "user", role: "user",
@ -459,17 +471,31 @@ export async function upsertReport(
): Promise<void> { ): Promise<void> {
const ref = firestore.collection(REPORTS).doc(`${uid}_${messageId}`); const ref = firestore.collection(REPORTS).doc(`${uid}_${messageId}`);
const now = Timestamp.now(); const now = Timestamp.now();
const existing = await ref.get();
const doc: ChatReportDoc = { const doc: ChatReportDoc = {
uid, uid,
messageId, messageId,
reason, reason,
...(comment ? { comment } : {}), ...(comment ? { comment } : {}),
status: "open", status: "open",
createdAt: existing.exists ? (existing.data() as ChatReportDoc).createdAt : now, createdAt: now,
updatedAt: 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) ── // ── 전역 호출·토큰 카운터(RTDB, §8.3) ──

View File

@ -1,10 +1,23 @@
import { Timestamp } from "firebase-admin/firestore"; import { Timestamp } from "firebase-admin/firestore";
import { firestore } from "../firebase"; import { firestore } from "../firebase";
import { MemCache } from "../lib/memCache";
import type { Game } from "../types/panit"; import type { Game } from "../types/panit";
import { startOfDayKst, type DateString } from "../types/dateString"; import { startOfDayKst, type DateString } from "../types/dateString";
const COLLECTION = "games"; const COLLECTION = "games";
/**
* games .
*
* .
* `GET /prediction/games` ()
* range .
*
* TTL이 이유: 경기 · .
* `invalidateGameDay` TTL은 .
*/
const dayCache = new MemCache<GameWithId[]>(15_000, 64);
export type GameWithId = Game & { gameId: string }; export type GameWithId = Game & { gameId: string };
/** /**
@ -37,11 +50,41 @@ export async function listByDate(date: DateString): Promise<GameWithId[]> {
} }
/** /**
* `listByDate` . * `listByDate` . .
*
* `MemCache` inflight .
*/
export async function listByDateCached(date: DateString): Promise<GameWithId[]> {
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` * `dailyArchive` run에서 `games`
* , Firestore read를 1 . * , Firestore read를 1 .
* Promise를 . * Promise를 .
*
* (`listByDateCached`) ,
* .
*/ */
export interface GameDayCache { export interface GameDayCache {
listByDate(date: DateString): Promise<GameWithId[]>; listByDate(date: DateString): Promise<GameWithId[]>;
@ -54,7 +97,7 @@ export function createGameDayCache(): GameDayCache {
listByDate(date: DateString): Promise<GameWithId[]> { listByDate(date: DateString): Promise<GameWithId[]> {
let p = cache.get(date); let p = cache.get(date);
if (!p) { if (!p) {
p = listByDate(date); p = listByDateCached(date);
cache.set(date, p); cache.set(date, p);
} }
return p; return p;

View File

@ -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 y = parseInt(yyyymmdd.slice(0, 4), 10);
const m = parseInt(yyyymmdd.slice(4, 6), 10); const m = parseInt(yyyymmdd.slice(4, 6), 10);
const d = parseInt(yyyymmdd.slice(6, 8), 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; return allDone ? SEVEN_DAYS_MS : THIRTY_SEC_MS;
} }
if (diffDays === 1) return SIX_HOURS_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; if (games.length === 0) return SIX_HOURS_MS;
@ -376,14 +392,18 @@ async function fetchScheduleSingleDay(
let cached = (await readDayDocs([key])).get(key) ?? null; let cached = (await readDayDocs([key])).get(key) ?? null;
if (cached === null) { if (cached === null) {
// 미스 → 월 단위 외부 fetch 흐름이 캐시를 채우도록 호출. 결과는 사용하지 않음. // 미스 → 월 단위 리필. 리필 결과를 그대로 재사용해 day doc 재읽기를 없앤다.
await fetchScheduleMonth({ // loadMonthDayCache는 live 병합 전 원본을 돌려주므로 아래 merge가 이중 적용되지 않는다.
const load = await loadMonthDayCache({
year: filters.year, year: filters.year,
month: filters.month, month: filters.month,
team: filters.team, team: filters.team,
series: filters.series, 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); const merged = await mergeLiveIntoSchedule(ymd, cached, filters.series);
@ -396,9 +416,19 @@ async function fetchScheduleSingleDay(
}; };
} }
async function fetchScheduleMonth( type MonthDayCacheLoad =
| { kind: "cache"; allDays: string[]; cached: Map<string, ScheduleGame[] | null> }
| { kind: "bypass"; games: ScheduleGame[] };
/**
* day ( fetch로 ).
*
* live ****
* / .
*/
async function loadMonthDayCache(
filters: ScheduleFilters filters: ScheduleFilters
): Promise<ScheduleResult> { ): Promise<MonthDayCacheLoad> {
const allDays = enumerateMonthDays(filters.year, filters.month); const allDays = enumerateMonthDays(filters.year, filters.month);
const keys = allDays.map((d) => dayKey(d, filters.team, filters.series)); const keys = allDays.map((d) => dayKey(d, filters.team, filters.series));
@ -436,7 +466,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 { } finally {
await releaseLock(lockKey); await releaseLock(lockKey);
} }
@ -451,15 +485,26 @@ async function fetchScheduleMonth(
} }
if (missing.length > 0) { if (missing.length > 0) {
// 타임아웃 — 캐시 우회하여 직접 fetch (저장은 안 함). // 타임아웃 — 캐시 우회하여 직접 fetch (저장은 안 함).
return fetchSchedule(filters); return { kind: "bypass", games: (await fetchSchedule(filters)).games };
} }
} }
} }
return { kind: "cache", allDays, cached };
}
async function fetchScheduleMonth(
filters: ScheduleFilters
): Promise<ScheduleResult> {
const load = await loadMonthDayCache(filters);
if (load.kind === "bypass") {
return { year: filters.year, month: filters.month, games: load.games };
}
const games: ScheduleGame[] = []; const games: ScheduleGame[] = [];
const today = todayKst().replace(/-/g, ""); const today = todayKst().replace(/-/g, "");
for (const ymd of allDays) { for (const ymd of load.allDays) {
const list = cached.get(dayKey(ymd, filters.team, filters.series)) ?? []; const list = load.cached.get(dayKey(ymd, filters.team, filters.series)) ?? [];
if (ymd === today) { if (ymd === today) {
const merged = await mergeLiveIntoSchedule(ymd, list, filters.series); const merged = await mergeLiveIntoSchedule(ymd, list, filters.series);
games.push(...merged); games.push(...merged);

View File

@ -130,10 +130,14 @@ export async function listTopByTierPoints(
* 전용: 페이지 . * 전용: 페이지 .
*/ */
export async function listAllRankedUsers(): Promise< export async function listAllRankedUsers(): Promise<
Array<{ uid: string; tierPoints: number }> Array<{ uid: string; tierPoints: number; favoriteTeamCode?: TeamCode }>
> { > {
const PAGE = 500; const PAGE = 500;
const results: Array<{ uid: string; tierPoints: number }> = []; const results: Array<{
uid: string;
tierPoints: number;
favoriteTeamCode?: TeamCode;
}> = [];
let last: FirebaseFirestore.QueryDocumentSnapshot | undefined; let last: FirebaseFirestore.QueryDocumentSnapshot | undefined;
for (;;) { for (;;) {
let query = firestore let query = firestore
@ -141,14 +145,18 @@ export async function listAllRankedUsers(): Promise<
.where("active", "==", true) .where("active", "==", true)
.where("tierPoints", ">", 0) .where("tierPoints", ">", 0)
.orderBy("tierPoints", "desc") .orderBy("tierPoints", "desc")
.select("tierPoints") // favoriteTeamCode는 팀 스코프 순위를 in-memory로 만들기 위해 함께 읽는다.
// 프로젝션 필드 추가는 read unit에 영향이 없다(문서당 과금) — 대역폭만 늘어난다.
.select("tierPoints", "favoriteTeamCode")
.limit(PAGE); .limit(PAGE);
if (last) query = query.startAfter(last); if (last) query = query.startAfter(last);
const snap = await query.get(); const snap = await query.get();
for (const d of snap.docs) { for (const d of snap.docs) {
const data = d.data() as Partial<User>;
results.push({ results.push({
uid: d.id, uid: d.id,
tierPoints: (d.data() as Partial<User>).tierPoints ?? 0, tierPoints: data.tierPoints ?? 0,
...(data.favoriteTeamCode ? { favoriteTeamCode: data.favoriteTeamCode } : {}),
}); });
} }
if (snap.docs.length < PAGE) return results; if (snap.docs.length < PAGE) return results;
@ -341,6 +349,12 @@ export async function applyDailyJudgmentTx(
* . * .
*/ */
rankSnapshot?: RankSnapshot; rankSnapshot?: RankSnapshot;
/**
* .
* `judgment` skip이어도 `correctCount`
* .
*/
lifetimeDelta?: { predictions: number; correct: number };
computePoints: (streakAfter: number) => number; computePoints: (streakAfter: number) => number;
} }
): Promise<DailyJudgmentResult> { ): Promise<DailyJudgmentResult> {
@ -384,6 +398,18 @@ export async function applyDailyJudgmentTx(
}; };
// 판정 전 rank 스냅샷을 같은 patch에 합쳐 user doc write를 1회로 줄인다. // 판정 전 rank 스냅샷을 같은 patch에 합쳐 user doc write를 1회로 줄인다.
if (input.rankSnapshot) patch.rankSnapshot = input.rankSnapshot; 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 }); tx.set(ref, patch, { merge: true });
return { return {

View File

@ -66,6 +66,7 @@ export async function submitVote(params: {
updates[`/votes/${gameId}/counts/${side}Count`] = ServerValue.increment(1); updates[`/votes/${gameId}/counts/${side}Count`] = ServerValue.increment(1);
updates[`/votes/${gameId}/users/${uid}`] = { team }; updates[`/votes/${gameId}/users/${uid}`] = { team };
updates[`/userVotes/${uid}/${date}/${gameId}`] = { team }; updates[`/userVotes/${uid}/${date}/${gameId}`] = { team };
updates[byDatePath(uid, date, gameId)] = { team };
await rtdb.ref().update(updates); 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}/counts/${newSide}Count`] = ServerValue.increment(1);
updates[`/votes/${gameId}/users/${uid}`] = { team: newTeam }; updates[`/votes/${gameId}/users/${uid}`] = { team: newTeam };
updates[`/userVotes/${uid}/${date}/${gameId}`] = { team: newTeam }; updates[`/userVotes/${uid}/${date}/${gameId}`] = { team: newTeam };
updates[byDatePath(uid, date, gameId)] = { team: newTeam };
await rtdb.ref().update(updates); 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<Record<string, Record<string, VoteEntry>>> {
const snap = await rtdb.ref(`/userVotesByDate/${date}`).get();
return snap.exists() ? (snap.val() as Record<string, Record<string, VoteEntry>>) : {};
}
/**
* .
*
* ,
* "그날 투표가 없었다" .
* `dailyArchive` .
*/
export async function isVoteDateIndexBackfilled(): Promise<boolean> {
const snap = await rtdb.ref("/userVotesByDateMeta/backfilledAt").get();
return snap.exists();
}
/** /**
* . * .
* *
@ -127,7 +166,13 @@ export async function deleteGameVotes(gameId: string): Promise<void> {
* . * .
*/ */
export async function deleteUserVoteIndex(uid: string): Promise<void> { export async function deleteUserVoteIndex(uid: string): Promise<void> {
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<string, unknown>) : [];
const updates: Record<string, unknown> = { [`/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, date: DateString,
gameId: string gameId: string
): Promise<void> { ): Promise<void> {
await rtdb.ref(`/userVotes/${uid}/${date}/${gameId}`).remove(); await rtdb.ref().update({
[`/userVotes/${uid}/${date}/${gameId}`]: null,
[byDatePath(uid, date, gameId)]: null,
});
} }

View File

@ -7,12 +7,28 @@ import { judgeDay } from "../services/judgmentService";
import { import {
precomputeScoreboardCache, precomputeScoreboardCache,
computeRankSnapshot, computeRankSnapshot,
loadRankStandings,
type RankStandings,
} from "../services/rankSnapshotService"; } from "../services/rankSnapshotService";
import { getUser } from "../repositories/userRepository";
import { maybeSettleSeason } from "../services/seasonService"; import { maybeSettleSeason } from "../services/seasonService";
import { todayKst } from "../types/dateString"; 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 { 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 { import {
daysAgoKst, daysAgoKst,
type DateString, type DateString,
@ -38,13 +54,19 @@ type DayVotes = Record<string, RawVote>;
async function reconcileDayVotes( async function reconcileDayVotes(
uid: string, uid: string,
date: DateString, date: DateString,
dayVotes: DayVotes dayVotes: DayVotes,
gameCache: GameDayCache,
healed: Set<string>
): Promise<DayVotes> { ): Promise<DayVotes> {
const result: DayVotes = { ...dayVotes }; 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)) { for (const [gameId, vote] of Object.entries(dayVotes)) {
if (vote.result !== undefined || vote.cancelled) continue; if (vote.result !== undefined || vote.cancelled) continue;
const game = await getGame(gameId); const game = byId.get(gameId) ?? (await getGame(gameId));
if (!game) { if (!game) {
logger.warn(`reconcile: game not found ${gameId}, uid=${uid}`); logger.warn(`reconcile: game not found ${gameId}, uid=${uid}`);
continue; continue;
@ -55,8 +77,14 @@ async function reconcileDayVotes(
// (`processGameEndWithGame`과 동일 규칙). // (`processGameEndWithGame`과 동일 규칙).
if (game.status === "completed") { if (game.status === "completed") {
try { try {
// `byUid`는 정지된 스냅샷이라 앞선 유저가 치유한 경기도 뒤 유저에겐 여전히
// 미판정으로 보인다. run 스코프 Set으로 경기당 1회만 처리해, 같은 경기의
// 투표자 전원 재처리(getAllUserVotes + RTDB update + deleteGameVotes)를 막는다.
if (!healed.has(gameId)) {
// 아카이브는 루프 끝에서 유저 단위로 1회 무효화하므로 경기별 fan-out은 생략. // 아카이브는 루프 끝에서 유저 단위로 1회 무효화하므로 경기별 fan-out은 생략.
await processGameEndWithGame(gameId, game, { skipInvalidate: true }); await processGameEndWithGame(gameId, game, { skipInvalidate: true });
healed.add(gameId);
}
const isDraw = !game.winningTeamCode; const isDraw = !game.winningTeamCode;
result[gameId] = { result[gameId] = {
team: vote.team, team: vote.team,
@ -99,23 +127,66 @@ export async function runDailyArchive(
// 같은 날짜(및 결석 판정용 직전 날짜들)의 games를 유저마다 재조회하지 않도록 // 같은 날짜(및 결석 판정용 직전 날짜들)의 games를 유저마다 재조회하지 않도록
// run 전체에서 1개의 날짜별 캐시를 공유한다. 날짜당 Firestore read 1회로 수렴. // run 전체에서 1개의 날짜별 캐시를 공유한다. 날짜당 Firestore read 1회로 수렴.
const gameCache = createGameDayCache(); const gameCache = createGameDayCache();
// run 중 자가치유된 경기 — 뒤따르는 유저들이 같은 경기를 재처리하지 않도록 한다.
const healedGames = new Set<string>();
// 순위표를 run 시작 시 1회 로드해 유저별 count aggregation(O(N×M))을 없앤다.
// 실패 시 standings 없이 진행하면 computeRankSnapshot이 aggregation으로 폴백한다.
let standings: RankStandings | null = null;
try { try {
const snap = await rtdb.ref("/userVotes").get(); standings = await loadRankStandings();
if (!snap.exists()) { } catch (err) {
logger.error("loadRankStandings failed — per-user aggregation으로 폴백", err);
}
try {
// 날짜별 인덱스만 읽는다 — 예전에는 하루치를 위해 `/userVotes` 트리 전체
// (전 유저 × 전 보존 날짜)를 내려받았다.
const byUid = (await getVotesByDate(date)) as Record<string, DayVotes>;
let mergedFromLegacy = 0;
// 백필이 끝났으면 인덱스가 전 이력을 담고 있으므로, 비어 있다는 것은
// "그날 투표가 없었다"는 사실이다 — 전체 스캔으로 되돌아가지 않는다.
// (이 가드가 없으면 월요일·비시즌 같은 무투표일마다 트리 전체를 다시 읽는다.)
//
// 반대로 마커가 없는 롤아웃 기간에는 인덱스가 **부분적으로만** 찼을 수 있다.
// 미러 배포 전에 투표한 유저는 인덱스에 없고 원본에만 있는데, 같은 날 배포 후
// 투표한 유저가 하나라도 있으면 인덱스가 비지 않는다. "비었을 때만 폴백"으로
// 두면 그 배포 전 투표자들이 판정·보상·스트릭 없이 영구 유실된다
// (아카이브는 매 run 다른 날짜를 처리하므로 그 날짜는 다시 열리지 않는다).
// 그래서 마커가 없으면 항상 원본을 읽어 인덱스에 없는 uid만 보충한다.
if (!(await isVoteDateIndexBackfilled())) {
const legacy = await rtdb.ref("/userVotes").get();
if (legacy.exists()) {
const all = legacy.val() as Record<string, Record<string, DayVotes>>;
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"); logger.info("no userVotes to archive");
return { date, archived: 0, judgedUids: [] }; return { date, archived: 0, judgedUids: [] };
} }
const byUid = snap.val() as Record<string, Record<string, DayVotes>>;
for (const uid of Object.keys(byUid)) { for (const uid of Object.keys(byUid)) {
let dayVotes = byUid[uid]?.[date]; let dayVotes = byUid[uid];
if (!dayVotes) continue; if (!dayVotes) continue;
const hasUnjudged = Object.values(dayVotes).some( const hasUnjudged = Object.values(dayVotes).some(
(v) => v.result === undefined && !v.cancelled (v) => v.result === undefined && !v.cancelled
); );
if (hasUnjudged) { if (hasUnjudged) {
dayVotes = await reconcileDayVotes(uid, date, dayVotes); dayVotes = await reconcileDayVotes(uid, date, dayVotes, gameCache, healedGames);
} }
const data: VoteHistoryDoc["data"] = []; const data: VoteHistoryDoc["data"] = [];
@ -141,28 +212,52 @@ export async function runDailyArchive(
// 스트릭 유지 로직이 동작하도록 judgeDay를 호출한다. // 스트릭 유지 로직이 동작하도록 judgeDay를 호출한다.
// 판정 전 rank를 계산해(=현재 tierPoints 기준) judgeDay 트랜잭션에 함께 기록한다. // 판정 전 rank를 계산해(=현재 tierPoints 기준) judgeDay 트랜잭션에 함께 기록한다.
// 판정 트랜잭션과 같은 patch로 묶여 user doc write가 1회로 준다("판정 전 rank" 보존). // 판정 트랜잭션과 같은 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; let rankSnapshot: RankSnapshot | null = null;
try { try {
rankSnapshot = await computeRankSnapshot(uid, date); rankSnapshot = await computeRankSnapshot(uid, date, {
...(user !== undefined ? { user } : {}),
...(standings ? { standings } : {}),
});
} catch (err) { } catch (err) {
logger.error(`computeRankSnapshot failed uid=${uid} date=${date}`, err); logger.error(`computeRankSnapshot failed uid=${uid} date=${date}`, err);
} }
// voteHistory 기록은 judgeDay가 1회 수행한다(판정 필드 포함). 별도 선기록은 생략. // voteHistory 기록은 judgeDay가 1회 수행한다(판정 필드 포함). 별도 선기록은 생략.
// judgeDay가 doc을 영속화한 뒤에야 userVotes를 제거해 데이터 유실을 막는다. // judgeDay가 doc을 영속화한 뒤에야 userVotes를 제거해 데이터 유실을 막는다.
try { try {
await judgeDay(uid, date, { data }, { gameCache, rankSnapshot }); await judgeDay(uid, date, { data }, {
gameCache,
rankSnapshot,
...(user !== undefined ? { userPre: user } : {}),
});
} catch (err) { } catch (err) {
logger.error(`judgeDay failed uid=${uid} date=${date}`, err); logger.error(`judgeDay failed uid=${uid} date=${date}`, err);
// 판정 실패 시에도 data만은 보존(기존 동작 유지) — userVotes를 곧 지우기 때문. // 판정 실패 시에도 data만은 보존(기존 동작 유지) — userVotes를 곧 지우기 때문.
await setDay(uid, date, { data }).catch(() => undefined); 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); await invalidateStats(uid).catch(() => undefined);
judgedUids.push(uid); judgedUids.push(uid);
archived += 1; 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 }; return { date, archived, judgedUids };
} finally { } finally {
// 시즌 종료 다음 날 첫 archive: 어제(=시즌 마지막 날) 판정을 반영한 뒤 // 시즌 종료 다음 날 첫 archive: 어제(=시즌 마지막 날) 판정을 반영한 뒤

View File

@ -13,15 +13,26 @@ import { daysAgoKst } from "../types/dateString";
const CACHE_COLLECTION = "kboCache"; const CACHE_COLLECTION = "kboCache";
async function invalidateByPrefix(prefix: string): Promise<void> { /** batch 하드 상한(500)보다 낮게 잡아 커밋이 터지지 않게 한다. */
const DELETE_CHUNK = 400;
async function invalidateByPrefix(prefix: string): Promise<number> {
const snap = await firestore const snap = await firestore
.collection(CACHE_COLLECTION) .collection(CACHE_COLLECTION)
.where("__name__", ">=", prefix) .where("__name__", ">=", prefix)
.where("__name__", "<", prefix + "\uf8ff") .where("__name__", "<", prefix + "\uf8ff")
// 삭제에는 ref만 필요하다 — 문서 본문 전송을 막는다.
.select()
.get(); .get();
// 단일 batch는 500 op에서 INVALID_ARGUMENT로 던진다. 청킹하지 않으면
// 캐시 문서가 늘어난 어느 날 이 잡 전체가 조용히 죽는다.
for (let i = 0; i < snap.docs.length; i += DELETE_CHUNK) {
const batch = firestore.batch(); const batch = firestore.batch();
snap.docs.forEach((d) => batch.delete(d.ref)); for (const d of snap.docs.slice(i, i + DELETE_CHUNK)) batch.delete(d.ref);
if (!snap.empty) await batch.commit(); await batch.commit();
}
return snap.size;
} }
export const kboDailyRefresh = onSchedule( export const kboDailyRefresh = onSchedule(
@ -33,13 +44,15 @@ export const kboDailyRefresh = onSchedule(
logger.info(`KBO refresh start: ${year}-${month}`); logger.info(`KBO refresh start: ${year}-${month}`);
await invalidateByPrefix("rank__"); // game_detail__와 schedule_day__는 응답 기반 동적 TTL(종료 7d, 라이브 30s 등)을
await invalidateByPrefix("schedule_day__"); // 이미 갖고 있어 02:00 일괄 무효화 대상에서 제외한다. 일괄 삭제하면 직후 조회가
// game_detail__는 응답 기반 동적 TTL(종료 경기 7d, 라이브 30s 등)을 이미 갖고 있어 // 한꺼번에 미스나 외부 재조회 폭주를 부르므로 TTL 자연 만료에 위임한다.
// 02:00 일괄 무효화 대상에서 제외한다. 일괄 삭제 시 직후 상세 조회가 한꺼번에 미스나 // (schedule_day__의 TTL은 dayTtlMs가 부여한다 — 완료된 과거 경기일은 7일이라
// 외부 재조회 폭주를 유발하므로, TTL 자연 만료에 위임한다. // 매일 밤 지우면 그 TTL이 통째로 무의미해진다.)
try { try {
const rankPurged = await invalidateByPrefix("rank__");
logger.info(`invalidated ${rankPurged} rank cache docs`);
await fetchRankFromKbo([year]); await fetchRankFromKbo([year]);
await fetchScheduleFromKbo({ year, month }); await fetchScheduleFromKbo({ year, month });

View File

@ -1,13 +1,10 @@
import { getSchedule } from "./scheduleService"; import { getSchedule } from "./scheduleService";
import { getRank } from "./rankService";
import { getStats } from "./statsService";
import { getUserDateVotes } from "../repositories/voteRepository"; import { getUserDateVotes } from "../repositories/voteRepository";
import { getDay } from "../repositories/voteHistoryRepository"; import { getDay } from "../repositories/voteHistoryRepository";
import { import {
COMMON_SYSTEM_PROMPT, COMMON_SYSTEM_PROMPT,
DEFAULT_PERSONA_BLOCK, DEFAULT_PERSONA_BLOCK,
DEFAULT_TEAM_PERSONAS, DEFAULT_TEAM_PERSONAS,
KBO_RANK_TEAM_NAMES,
KNOWLEDGE_GUIDANCE, KNOWLEDGE_GUIDANCE,
SERVER_DIRECTIVE_BLOCK, SERVER_DIRECTIVE_BLOCK,
TEAM_DISPLAY_NAMES, 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; date: DateString;
displayName: string; displayName: string;
knowledgeLevel: KnowledgeLevel; knowledgeLevel: KnowledgeLevel;
teamCode: TeamCode | null; teamCode: TeamCode | null;
teamName: string | null; teamName: string | null;
todaySchedule: string; }
todayMyPredictions: string;
yesterdayRecap: string; /** 추천 질문 노출 조건(§6.2) 플래그 — GET /chat/suggestions 전용. */
/** 응원팀 미설정 시 null → 줄 생략. */ export interface SuggestionFlags {
recentTeamResults: string | null; teamCode: TeamCode | null;
/** 오늘 응원팀 경기 없음/미설정 시 null → 줄 생략. */
h2hRecords: string | null;
myStats: string;
/** 추천 질문 노출 조건(§6.2) 공용 플래그. */
hasYesterdayRecap: boolean; hasYesterdayRecap: boolean;
hasTodayTeamGame: boolean; hasTodayTeamGame: boolean;
hasPredictedToday: boolean; hasPredictedToday: boolean;
@ -92,178 +92,65 @@ async function safely<T>(label: string, fallback: T, task: () => Promise<T>): Pr
} }
} }
function matchupLabel(g: ScheduleGame): string {
return `${g.awayTeamCode} vs ${g.homeTeamCode}`;
}
/** /**
* (2.2 {{todaySchedule}}). * Firestore .
* `live` , `completed` , `cancelled` . *
* `users/{uid}` . (§3.1)
* .
*/ */
export function formatTodaySchedule(games: ScheduleGame[]): string { export function identityContext(user: User | null, config?: ChatConfig): IdentityContext {
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<ScheduleGame[]> {
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<UserContext> {
const date = todayKst();
const [y, m, d] = date.split("-").map(Number);
const teamCode = resolveTeamCode(user?.favoriteTeamCode); 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<ScheduleGame[]>("todaySchedule", [], async () => (await getSchedule(y, m, undefined, undefined, d)).games),
safely<Record<string, { team: string }>>("todayMyPredictions", {}, () => getUserDateVotes(uid, date)),
safely("yesterdayRecap", null, async () =>
user?.lastJudgedDate ? getDay(uid, parseDateString(user.lastJudgedDate)) : null,
),
safely<ScheduleGame[]>("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 { return {
date, date: todayKst(),
displayName, displayName: sanitizeDisplayName(user?.displayName),
knowledgeLevel, knowledgeLevel: resolveKnowledgeLevel(user?.knowledgeLevel),
teamCode, teamCode,
// 표기명은 config로 강등(닉네임 전환) 가능 — KBO 라이선스 미확보 대비(1-2) // 표기명은 config로 강등(닉네임 전환) 가능 — KBO 라이선스 미확보 대비(1-2)
teamName: teamCode ? teamName: teamCode ?
config?.teamDisplayNames?.[teamCode] ?? TEAM_DISPLAY_NAMES[teamCode] : config?.teamDisplayNames?.[teamCode] ?? TEAM_DISPLAY_NAMES[teamCode] :
null, null,
todaySchedule: formatTodaySchedule(todayGames), };
todayMyPredictions, }
yesterdayRecap,
recentTeamResults, /**
h2hRecords, * (§6.2) GET /chat/suggestions .
myStats, *
hasYesterdayRecap, *
hasTodayTeamGame, * . false로 degrade된다( ).
hasPredictedToday: voteEntries.length > 0, */
export async function gatherSuggestionFlags(
uid: string,
user: User | null,
): Promise<SuggestionFlags> {
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<ScheduleGame[]>("todaySchedule", [], async () =>
(await getSchedule(y, m, undefined, undefined, d)).games,
) :
Promise.resolve<ScheduleGame[]>([]),
safely<Record<string, { team: string }>>("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, string>): string {
} }
/** /**
* [ 3] () + . * [ 3] () .
* ····· . * ····· .
*/ */
export function buildUserContextBlock(ctx: UserContext): string { export function buildUserContextBlock(ctx: IdentityContext): string {
return fill(USER_CONTEXT_TEMPLATE, { return fill(USER_CONTEXT_TEMPLATE, {
todayDate: ctx.date, todayDate: ctx.date,
displayName: ctx.displayName, displayName: ctx.displayName,
@ -333,7 +220,7 @@ export interface AssembledPrompt {
* ( · §7.3 ) . * ( · §7.3 ) .
* user (§7.5). * 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 style = resolveStylePack(config.stylePack);
const common = config.systemPromptCommon.trim().length > 0 ? const common = config.systemPromptCommon.trim().length > 0 ?
config.systemPromptCommon : 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; return assemblePrompt(config, ctx).system;
} }

View File

@ -2,7 +2,7 @@ import { getChatConfig, invalidateChatConfigCache } from "./chatConfigService";
import { getUser } from "../repositories/userRepository"; import { getUser } from "../repositories/userRepository";
import { import {
assemblePrompt, assemblePrompt,
gatherUserContext, identityContext,
resolveTeamCode, resolveTeamCode,
} from "./chatContextService"; } from "./chatContextService";
import { buildChatTools, type ChatToolContext } from "./chatToolService"; import { buildChatTools, type ChatToolContext } from "./chatToolService";
@ -112,7 +112,7 @@ async function buildProbeEnv(
} }
const teamCode = resolveTeamCode(user.favoriteTeamCode); const teamCode = resolveTeamCode(user.favoriteTeamCode);
const date = todayKst(); const date = todayKst();
const ctx = await gatherUserContext(uid, user, config); const ctx = identityContext(user, config);
const { system } = assemblePrompt(config, ctx); const { system } = assemblePrompt(config, ctx);
const provider = getChatProvider(config.provider); const provider = getChatProvider(config.provider);
const teamName = teamCode ? TEAM_DISPLAY_NAMES[teamCode] ?? teamCode : "(미설정)"; const teamName = teamCode ? TEAM_DISPLAY_NAMES[teamCode] ?? teamCode : "(미설정)";

View File

@ -30,7 +30,13 @@ import {
import { checkInput, checkOutput, crisisReply, detectCrisis } from "./chatFilterService"; import { checkInput, checkOutput, crisisReply, detectCrisis } from "./chatFilterService";
import { logChatEvent } from "./chatAnalyticsService"; import { logChatEvent } from "./chatAnalyticsService";
import { extractNavActions } from "./chatNavService"; 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 { buildChatTools, withToolLabels } from "./chatToolService";
import { import {
EMPTY_REPLY_NOTICE, FALLBACK_SUGGESTION_IDS, FILTERED_REPLY, INPUT_BLOCKED_NOTICE, 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 }; return { used: quota.used ?? 0 };
} }
/**
* @param usedOverride `used`.
* . (replay, GET /chat/quota)
* .
*/
async function buildSendResult( async function buildSendResult(
uid: string, uid: string,
date: DateString, date: DateString,
@ -88,8 +99,9 @@ async function buildSendResult(
createdAt: Timestamp, createdAt: Timestamp,
toolCalls?: ChatToolCallInfo[], toolCalls?: ChatToolCallInfo[],
actions?: NavAction[], actions?: NavAction[],
usedOverride?: number,
): Promise<ChatSendResult> { ): Promise<ChatSendResult> {
const { used } = await quotaView(uid, date); const used = usedOverride ?? (await quotaView(uid, date)).used;
return { return {
messageId, messageId,
reply, reply,
@ -121,14 +133,23 @@ async function replayDone(
); );
} }
/** 단기 문맥 윈도잉(§5.5) — 활성 스레드에서 N턴, 최대 나이, 위기/필터 제외. */ /**
* (§5.5) N턴, , / .
*
* `threadExists` (`finalizeExchange`) `createdAt`
* .
*/
async function loadHistory( async function loadHistory(
uid: string, uid: string,
threadId: string, threadId: string,
config: ChatConfig, config: ChatConfig,
): Promise<ChatProviderMessage[]> { ): Promise<{ messages: ChatProviderMessage[]; threadExists: boolean }> {
const fetchLimit = config.historyTurns * 2 + 30; const target = config.historyTurns * 2;
const [thread, recentDesc] = await Promise.all([ // 위기 요청은 일일 한도를 우회하므로(§7.3) 창 안의 위기 교환쌍 수에는 상한이 없다.
// 위기 쌍은 문서 2개를 차지하고 윈도잉에서 둘 다 빠지므로, 고정 padding은
// 유효한 상한이 될 수 없다. 평시에는 작게 읽고, 실제로 모자랄 때만 한 번 넓힌다.
const fetchLimit = target + 10;
const [thread, firstPage] = await Promise.all([
getThreadDoc(uid, threadId), getThreadDoc(uid, threadId),
getRecentMessages(uid, threadId, fetchLimit), getRecentMessages(uid, threadId, fetchLimit),
]); ]);
@ -138,26 +159,38 @@ async function loadHistory(
// 실제 문맥 분리는 스레드(팀)·턴수·나이 3가지로만 이뤄짐. // 실제 문맥 분리는 스레드(팀)·턴수·나이 3가지로만 이뤄짐.
const cutAt = thread?.historyCutAt?.toMillis() ?? 0; 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) // 위기 교환 쌍 제외 — crisis assistant와 그 replyTo user 메시지(§5.5)
const excludedUserIds = new Set<string>(); const excludedUserIds = new Set<string>();
for (const m of asc) { for (const m of asc) {
if (m.role === "assistant" && m.crisis && m.replyTo) excludedUserIds.add(m.replyTo); if (m.role === "assistant" && m.crisis && m.replyTo) excludedUserIds.add(m.replyTo);
} }
return asc.filter((m: MessageWithId) => {
const windowed = asc.filter((m: MessageWithId) => {
const ms = m.createdAt.toMillis(); const ms = m.createdAt.toMillis();
if (ms < minCreatedAt || ms < cutAt) return false; if (ms < minCreatedAt || ms < cutAt) return false;
if (m.role === "assistant" && (m.crisis || m.filtered)) return false; if (m.role === "assistant" && (m.crisis || m.filtered)) return false;
if (m.role === "user" && excludedUserIds.has(m.messageId)) return false; if (m.role === "user" && excludedUserIds.has(m.messageId)) return false;
return true; return true;
}); });
};
const lastN = windowed.slice(-config.historyTurns * 2); let windowed = window(firstPage);
// 가져온 만큼을 다 채웠는데도 목표에 못 미친다 = 제외된 분량 때문에 잘렸을 수
// 있다는 뜻. 더 오래된 유효 메시지가 남아 있을 수 있으므로 한 번만 넓혀 재조회한다.
// (스레드가 원래 짧아서 못 채운 경우에는 firstPage가 fetchLimit보다 작아 재조회하지 않는다.)
if (windowed.length < target && firstPage.length === fetchLimit) {
windowed = window(await getRecentMessages(uid, threadId, target + 40));
}
const lastN = windowed.slice(-target);
// provider 제약: 첫 메시지는 user여야 한다 — 앞쪽 assistant 잔여분 제거 // provider 제약: 첫 메시지는 user여야 한다 — 앞쪽 assistant 잔여분 제거
while (lastN.length > 0 && lastN[0].role === "assistant") lastN.shift(); 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회만 경고. */ /** 비용 가드 80% 운영 알림(§8.3) — 인스턴스·날짜당 1회만 경고. */
@ -277,20 +310,30 @@ export async function sendMessage(uid: string, body: SendBody): Promise<ChatSend
crisis: true, crisisType: crisis.type, crisis: true, crisisType: crisis.type,
msgLen: message.length, latencyMs: Date.now() - startedAt, msgLen: message.length, latencyMs: Date.now() - startedAt,
}); });
return buildSendResult(uid, date, config, saved.assistantMessageId, reply, true, saved.createdAt); return buildSendResult(
uid, date, config, saved.assistantMessageId, reply, true, saved.createdAt,
undefined, undefined, outcome.used,
);
} }
let saved: ExchangeResult | null = null; let saved: ExchangeResult | null = null;
// loadHistory가 읽은 스레드 존재 여부 — 저장 단계로 넘겨 중복 read를 없앤다.
let threadExists: boolean | undefined;
let finalReply = ""; let finalReply = "";
let finalCrisis = false; let finalCrisis = false;
let finalToolCalls: ChatToolCallInfo[] = []; let finalToolCalls: ChatToolCallInfo[] = [];
let finalActions: NavAction[] = []; let finalActions: NavAction[] = [];
try { try {
// 6) 컨텍스트 조립(§5) // 6) 컨텍스트 조립(§5)
const ctx: UserContext = await gatherUserContext(uid, user, config); // 정체성만 조립한다 — 시사 데이터는 도구로 조회되므로 여기서 선조회하지 않는다
const ctx: IdentityContext = identityContext(user, config);
const { system, leakBody } = assemblePrompt(config, ctx); const { system, leakBody } = assemblePrompt(config, ctx);
const history = await loadHistory(uid, activeThreadId, config); const history = await loadHistory(uid, activeThreadId, config);
const messages: ChatProviderMessage[] = [...history, { role: "user", content: message }]; threadExists = history.threadExists;
const messages: ChatProviderMessage[] = [
...history.messages,
{ role: "user", content: message },
];
// 7) 입력 필터 2단계(provider 모더레이션, 선택) — 차단 시 422 + 차감 복원 // 7) 입력 필터 2단계(provider 모더레이션, 선택) — 차단 시 422 + 차감 복원
const provider = getChatProvider(config.provider); const provider = getChatProvider(config.provider);
@ -383,6 +426,7 @@ export async function sendMessage(uid: string, body: SendBody): Promise<ChatSend
crisis: crisisOut, crisis: crisisOut,
toolCalls, toolCalls,
actions, actions,
...(threadExists !== undefined ? { threadExists } : {}),
retentionDays: config.retentionDays, retentionDays: config.retentionDays,
}); });
finalReply = reply; finalReply = reply;
@ -413,9 +457,11 @@ export async function sendMessage(uid: string, body: SendBody): Promise<ChatSend
throw err; throw err;
} }
// 11) 응답 반환 — 저장 성공 이후의 쿼터 재조회 실패는 복원 대상이 아니다(§6.2) // 11) 응답 반환 — 예약 트랜잭션이 계산한 used를 그대로 쓴다. 여기 도달하는
// 경로에서 used를 바꾸는 것은 refund뿐인데, refund는 모두 throw로 빠진다.
return buildSendResult( return buildSendResult(
uid, date, config, saved.assistantMessageId, finalReply, finalCrisis, saved.createdAt, finalToolCalls, finalActions, uid, date, config, saved.assistantMessageId, finalReply, finalCrisis, saved.createdAt,
finalToolCalls, finalActions, outcome.used,
); );
} }
@ -548,17 +594,17 @@ export async function getSuggestions(uid: string): Promise<ChatSuggestionsView>
let priority: ChatSuggestion[] = []; let priority: ChatSuggestion[] = [];
try { try {
const user = await getUser(uid); const user = await getUser(uid);
const ctx = await gatherUserContext(uid, user, config); const flags = await gatherSuggestionFlags(uid, user);
candidates = config.suggestions.filter((s) => { 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 제외"는 질문 문구("오늘 우리 경기")에 // 6.2 표의 "오늘 경기 없음 → Q7/Q11/Q12 제외"는 질문 문구("오늘 우리 경기")에
// 맞춰 "응원팀의 오늘 경기 유무"로 해석해 적용한다(리그 전체 기준보다 엄격) // 맞춰 "응원팀의 오늘 경기 유무"로 해석해 적용한다(리그 전체 기준보다 엄격)
if (s.requiresTodayTeamGame && !ctx.hasTodayTeamGame) return false; if (s.requiresTodayTeamGame && !flags.hasTodayTeamGame) return false;
if (s.requiresYesterdayRecap && !ctx.hasYesterdayRecap) return false; if (s.requiresYesterdayRecap && !flags.hasYesterdayRecap) return false;
if (s.excludeWhenPredictedToday && ctx.hasPredictedToday) return false; if (s.excludeWhenPredictedToday && flags.hasPredictedToday) return false;
return true; return true;
}); });
if (ctx.hasYesterdayRecap) { if (flags.hasYesterdayRecap) {
priority = candidates.filter((s) => s.priorityWhenRecap); priority = candidates.filter((s) => s.priorityWhenRecap);
} }
} catch (err) { } catch (err) {

View File

@ -2,7 +2,7 @@ import { FieldValue } from "firebase-admin/firestore";
import { logger } from "firebase-functions"; import { logger } from "firebase-functions";
import { firestore, rtdb } from "../firebase"; import { firestore, rtdb } from "../firebase";
import { HttpError } from "../middleware/errors"; import { HttpError } from "../middleware/errors";
import { getGame } from "../repositories/gameRepository"; import { getGame, invalidateGameDay } from "../repositories/gameRepository";
import { getAllUserVotes, deleteGameVotes } from "../repositories/voteRepository"; import { getAllUserVotes, deleteGameVotes } from "../repositories/voteRepository";
import { invalidateStats } from "./statsService"; import { invalidateStats } from "./statsService";
import { fromTimestamp } from "../types/dateString"; import { fromTimestamp } from "../types/dateString";
@ -36,6 +36,9 @@ export async function processGameEndWithGame(
: voted === game.winningTeamCode; : voted === game.winningTeamCode;
rtdbUpdates[`/userVotes/${uid}/${date}/${gameId}/team`] = voted; rtdbUpdates[`/userVotes/${uid}/${date}/${gameId}/team`] = voted;
rtdbUpdates[`/userVotes/${uid}/${date}/${gameId}/result`] = result; 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) { if (Object.keys(rtdbUpdates).length > 0) {
await rtdb.ref().update(rtdbUpdates); await rtdb.ref().update(rtdbUpdates);
@ -71,5 +74,8 @@ export async function markGameEnded(
winningTeamCode: isDraw ? FieldValue.delete() : winningTeamCode, winningTeamCode: isDraw ? FieldValue.delete() : winningTeamCode,
endedAt: FieldValue.serverTimestamp(), endedAt: FieldValue.serverTimestamp(),
}); });
// 스코어·상태가 바뀌었으므로 해당 날짜의 games 캐시를 즉시 버린다.
const time = (snap.data() as { time?: { toDate(): Date } }).time;
if (time) invalidateGameDay(fromTimestamp(time));
return { ok: true, gameId }; return { ok: true, gameId };
} }

View File

@ -4,9 +4,14 @@ import {
fetchScheduleFromKbo, fetchScheduleFromKbo,
statusFromRecord, statusFromRecord,
} from "../repositories/kboRepository"; } from "../repositories/kboRepository";
import {
invalidateAllGameDays,
invalidateGameDay,
} from "../repositories/gameRepository";
import { getGameList } from "./gameListService"; import { getGameList } from "./gameListService";
import type { GameListRecord } from "../kbo/game-list"; import type { GameListRecord } from "../kbo/game-list";
import type { ScheduleGame, GameStatus } from "../kbo/schedule"; import type { ScheduleGame, GameStatus } from "../kbo/schedule";
import { parseDateString } from "../types/dateString";
import type { Game } from "../types/panit"; import type { Game } from "../types/panit";
const COLLECTION = "games"; const COLLECTION = "games";
@ -109,7 +114,11 @@ export async function syncGamesForMonth(year: number, month: number): Promise<nu
count++; count++;
}); });
if (count > 0) await batch.commit(); if (count > 0) {
await batch.commit();
// 월 전체에 걸쳐 쓰므로 날짜별 무효화 대신 일괄 폐기한다.
invalidateAllGameDays();
}
return count; return count;
} }
@ -196,6 +205,13 @@ export async function forceSyncDay(
updated++; 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 }; return { updated };
} }

View File

@ -13,7 +13,7 @@ import {
streakBonus, streakBonus,
thresholdsFor, thresholdsFor,
} from "../constants/judgment"; } 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 { addDays, type DateString } from "../types/dateString";
import { settleDailyReward } from "./rewardSettlementService"; import { settleDailyReward } from "./rewardSettlementService";
@ -56,12 +56,17 @@ export async function hasMissedGameDayBetween(
* @param voteDoc - voteHistory ( ) * @param voteDoc - voteHistory ( )
* @param opts.gameCache - games read를 * @param opts.gameCache - games read를
* @param opts.rankSnapshot - rank . . * @param opts.rankSnapshot - rank . .
* @param opts.userPre - user . .
*/ */
export async function judgeDay( export async function judgeDay(
uid: string, uid: string,
date: DateString, date: DateString,
voteDoc: VoteHistoryDoc, voteDoc: VoteHistoryDoc,
opts?: { gameCache?: GameDayCache; rankSnapshot?: RankSnapshot | null } opts?: {
gameCache?: GameDayCache;
rankSnapshot?: RankSnapshot | null;
userPre?: User | null;
}
): Promise<void> { ): Promise<void> {
const fetch = opts?.gameCache ?? createGameDayCache(); const fetch = opts?.gameCache ?? createGameDayCache();
const games = await fetch.listByDate(date); const games = await fetch.listByDate(date);
@ -80,19 +85,30 @@ export async function judgeDay(
// 결석 여부는 휴장일을 제외하고 판단한다. (lastJudged, date) 구간에 실제 // 결석 여부는 휴장일을 제외하고 판단한다. (lastJudged, date) 구간에 실제
// 경기일이 하나라도 있었는데 user가 그날 안 했으면 streak 끊김. // 경기일이 하나라도 있었는데 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 lastJudgedPre = userPre?.lastJudgedDate;
const streakBrokenIn = const streakBrokenIn =
lastJudgedPre != null && lastJudgedPre != null &&
lastJudgedPre < addDays(date, -1) && lastJudgedPre < addDays(date, -1) &&
(await hasMissedGameDayBetween(lastJudgedPre, date, fetch)); (await hasMissedGameDayBetween(lastJudgedPre, date, fetch));
// 통산 승률 모집단은 판정(skip 포함)과 무관하게 "결과가 확정된 투표" 전부다.
// 취소 무효표(result 없음)는 제외한다 — aggregate()의 정의와 동일.
const countable = voteDoc.data.filter((v) => typeof v.result === "boolean");
const tx = await applyDailyJudgmentTx(uid, date, { const tx = await applyDailyJudgmentTx(uid, date, {
judgment, judgment,
correctCount, correctCount,
completedCount, completedCount,
streakBrokenIn, streakBrokenIn,
rankSnapshot: opts?.rankSnapshot ?? undefined, rankSnapshot: opts?.rankSnapshot ?? undefined,
lifetimeDelta: {
predictions: countable.length,
correct: countable.filter((v) => v.result).length,
},
computePoints: (streakAfter) => correctCount * 10 + streakBonus(streakAfter), computePoints: (streakAfter) => correctCount * 10 + streakBonus(streakAfter),
}); });
@ -103,7 +119,7 @@ export async function judgeDay(
// 정상 멱등 재실행에서는 doc이 이미 존재하므로 판정 필드를 덮어쓰지 않는다. // 정상 멱등 재실행에서는 doc이 이미 존재하므로 판정 필드를 덮어쓰지 않는다.
const existing = await getDay(uid, date); const existing = await getDay(uid, date);
if (!existing) await setDay(uid, date, voteDoc); 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; return;
} }

View File

@ -4,7 +4,6 @@ import { HttpError } from "../middleware/errors";
import { getOrder, listOrders, orderRef } from "../repositories/orderRepository"; import { getOrder, listOrders, orderRef } from "../repositories/orderRepository";
import { productRef } from "../repositories/productRepository"; import { productRef } from "../repositories/productRepository";
import { applyPointChangesTx } from "./pointService"; import { applyPointChangesTx } from "./pointService";
import { getWalletTx } from "../repositories/walletRepository";
import { PointLedgerType } from "../types/points"; import { PointLedgerType } from "../types/points";
import { import {
ORDER_TRANSITIONS, ORDER_TRANSITIONS,
@ -72,14 +71,16 @@ export async function createOrder(uid: string, input: CreateOrderInput) {
const snapById = new Map<string, DocumentSnapshot>(); for (const item of input.items) { const snapById = new Map<string, DocumentSnapshot>(); for (const item of input.items) {
if (!snapById.has(item.productId)) snapById.set(item.productId, await tx.get(productRef(item.productId))); 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 orderItems: OrderItem[] = input.items.map((item) => {
const p = snapById.get(item.productId)!.data() as ProductDoc | undefined; const p = snapById.get(item.productId)!.data() as ProductDoc | undefined;
if (!p || !p.active || !p.redeemable) throw new HttpError(409, "product unavailable", "PRODUCT_UNAVAILABLE"); if (!p || !p.active || !p.redeemable) throw new HttpError(409, "product unavailable", "PRODUCT_UNAVAILABLE");
const option = resolveOrderOption(p, item.optionId); 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 } : {}) }; 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 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 }; 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 };

View File

@ -1,12 +1,12 @@
import { Timestamp, type Transaction } from "firebase-admin/firestore"; import { Timestamp, type Transaction } from "firebase-admin/firestore";
import { firestore } from "../firebase"; import { firestore } from "../firebase";
import { HttpError } from "../middleware/errors"; import { HttpError, isAlreadyExistsError } from "../middleware/errors";
import { createLedgerEntryTx } from "../repositories/pointLedgerRepository"; import { createLedgerEntryTx } from "../repositories/pointLedgerRepository";
import { getWalletTx, walletDocRef } from "../repositories/walletRepository"; import { getWalletTx, walletDocRef } from "../repositories/walletRepository";
import { OP_BY_TYPE, PointLedgerType, type PointChange, type PointLedgerEntry, type WalletDoc } from "../types/points"; import { OP_BY_TYPE, PointLedgerType, type PointChange, type PointLedgerEntry, type WalletDoc } from "../types/points";
const TX_ID = /^[A-Za-z0-9:_-]{1,240}$/; 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<WalletDoc | null> { export async function applyPointChangesTx(tx: Transaction, uid: string, changes: PointChange[]): Promise<WalletDoc | null> {
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"); 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); const existing = await getWalletTx(tx, uid);

View File

@ -1,5 +1,5 @@
import { HttpError } from "../middleware/errors"; import { HttpError } from "../middleware/errors";
import { getGame, listByDate } from "../repositories/gameRepository"; import { getGame, listByDateCached } from "../repositories/gameRepository";
import { import {
getUserVote, getUserVote,
submitVote, submitVote,
@ -107,7 +107,7 @@ export async function getMyVotes(
export async function listGamesByDate(date: string): Promise<GameDto[]> { export async function listGamesByDate(date: string): Promise<GameDto[]> {
try { try {
const games = await listByDate(parseDateString(date)); const games = await listByDateCached(parseDateString(date));
return games.map(toGameDto); return games.map(toGameDto);
} catch (err) { } catch (err) {
throw new HttpError(400, (err as Error).message); throw new HttpError(400, (err as Error).message);

View File

@ -4,6 +4,7 @@ import {
countRankedUsers, countRankedUsers,
countUsersAboveTierPoints, countUsersAboveTierPoints,
getUser, getUser,
listAllRankedUsers,
listTopByTierPoints, listTopByTierPoints,
type ScoreboardUserEntry, type ScoreboardUserEntry,
} from "../repositories/userRepository"; } from "../repositories/userRepository";
@ -11,40 +12,93 @@ import { writeScope, type Scope } from "../repositories/scoreboardCacheRepositor
import { tierOf } from "../constants/tiers"; import { tierOf } from "../constants/tiers";
import { computePercentile, deltaFor } from "./scoreboardHelpers"; import { computePercentile, deltaFor } from "./scoreboardHelpers";
import type { DateString } from "../types/dateString"; import type { DateString } from "../types/dateString";
import { TeamCode, type RankSnapshot } from "../types/panit"; import { TeamCode, type RankSnapshot, type User } from "../types/panit";
import type { import type {
ScoreboardEntry, ScoreboardEntry,
ScoreboardScopeCache, ScoreboardScopeCache,
} from "../types/scoreboard"; } from "../types/scoreboard";
/** /**
* 1 `tierPoints` rank를 count aggregation으로 * run `tierPoints` .
* `rankSnapshot` . `dailyArchive` `judgeDay` **** *
* "직전 상태의 rank" . * `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<TeamCode, number[]>();
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<RankStandings> {
return buildRankStandings(await listAllRankedUsers());
}
/**
* 1 `tierPoints` rank를 . `dailyArchive`
* `judgeDay` **** "직전 상태의 rank" .
* *
* `tierPoints === 0` . * `tierPoints === 0` .
*
* @param opts.user - user . .
* @param opts.standings - run . count aggregation을 .
*/ */
export async function computeRankSnapshot( export async function computeRankSnapshot(
uid: string, uid: string,
date: DateString date: DateString,
opts?: { user?: User | null; standings?: RankStandings }
): Promise<RankSnapshot | null> { ): Promise<RankSnapshot | null> {
const user = await getUser(uid); const user = opts && "user" in opts ? opts.user : await getUser(uid);
if (!user) return null; if (!user) return null;
const tierPoints = user.tierPoints ?? 0; const tierPoints = user.tierPoints ?? 0;
if (tierPoints <= 0) return null; if (tierPoints <= 0) return null;
const overallAbove = await countUsersAboveTierPoints(tierPoints); const standings = opts?.standings;
const snapshot: RankSnapshot = { const snapshot: RankSnapshot = {
date, date,
overall: overallAbove + 1, overall: standings ?
standings.rankOf(tierPoints) :
(await countUsersAboveTierPoints(tierPoints)) + 1,
}; };
if (user.favoriteTeamCode) { if (user.favoriteTeamCode) {
const teamAbove = await countUsersAboveTierPoints( snapshot.team = standings ?
tierPoints, standings.rankOf(tierPoints, user.favoriteTeamCode) :
user.favoriteTeamCode (await countUsersAboveTierPoints(tierPoints, user.favoriteTeamCode)) + 1;
);
snapshot.team = teamAbove + 1;
snapshot.teamCode = user.favoriteTeamCode; snapshot.teamCode = user.favoriteTeamCode;
} }
@ -132,20 +186,28 @@ async function buildScopeCache(scope: Scope): Promise<ScoreboardScopeCache> {
}; };
} }
/** /** overall + 10팀 = 전체 11개 스코프. */
* 호출: overall + 10 top 10 / totalCount를 RTDB에 . export function allScoreboardScopes(): Scope[] {
* `snapshotRanksForUsers` rankDelta가 . return [
*/
export async function precomputeScoreboardCache(
date: DateString
): Promise<void> {
const scopes: Scope[] = [
{ kind: "overall" }, { kind: "overall" },
...Object.values(TeamCode).map( ...Object.values(TeamCode).map(
(teamCode) => ({ kind: "team" as const, teamCode }) (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<void> {
for (const scope of scopes) { for (const scope of scopes) {
try { try {
const doc = await buildScopeCache(scope); const doc = await buildScopeCache(scope);

View File

@ -1,20 +1,30 @@
import { Timestamp } from "firebase-admin/firestore"; import { Timestamp } from "firebase-admin/firestore";
import { firestore } from "../firebase"; import { firestore } from "../firebase";
import { PREDICTION_PARTICIPATION_POINTS, PREDICTION_PERFECT_BONUS_POINTS, PREDICTION_SUCCESS_POINTS } from "../constants/points"; 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 { PointLedgerType, type VoteHistoryDoc } from "../types/panit";
import type { DateString } from "../types/dateString"; import type { DateString } from "../types/dateString";
import type { PointChange } from "../types/points"; import type { PointChange } from "../types/points";
import { applyPointChangesTx, isAlreadyExistsError } from "./pointService"; import { applyPointChangesTx, isAlreadyExistsError } from "./pointService";
export type SettlementResult = "settled" | "no_history" | "already_settled" | "not_judged"; 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 { try {
return await firestore.runTransaction(async (tx) => { return await firestore.runTransaction(async (tx) => {
const snap = await tx.get(ref); if (!snap.exists) return { result: "no_history" as const, total: 0 }; 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 }; 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 }; 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 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[] = []; const full = eligible.length > 0 && eligible.every((g) => voted.has(g.gameId)); const changes: PointChange[] = [];
if (full) { if (full) {

View File

@ -1,8 +1,8 @@
import {rtdb} from "../firebase"; import {rtdb} from "../firebase";
import {HttpError} from "../middleware/errors"; import {HttpError} from "../middleware/errors";
import {tierOf} from "../constants/tiers"; import {tierOf} from "../constants/tiers";
import {getAll, getDay} from "../repositories/voteHistoryRepository"; import {getAll, getDay, getRange} from "../repositories/voteHistoryRepository";
import {getUser} from "../repositories/userRepository"; import {getUser, updateUser} from "../repositories/userRepository";
import {hasMissedGameDayBetween} from "./judgmentService"; import {hasMissedGameDayBetween} from "./judgmentService";
import type {DailyJudgment, StatsResponse, VoteHistoryDoc} from "../types/panit"; import type {DailyJudgment, StatsResponse, VoteHistoryDoc} from "../types/panit";
import {EMPTY_VOTE_HISTORY_DTO, toVoteHistoryDto} from "../types/dto/statsDto"; import {EMPTY_VOTE_HISTORY_DTO, toVoteHistoryDto} from "../types/dto/statsDto";
@ -17,6 +17,9 @@ import {
type DateString, type DateString,
} from "../types/dateString"; } from "../types/dateString";
/** 조회 가능한 가장 이른 연도 — 서비스 개시 이전은 받지 않는다. */
const EARLIEST_STATS_YEAR = 2024;
type Period = type Period =
| "current" | "current"
| { kind: "year"; year: number } | { kind: "year"; year: number }
@ -33,17 +36,54 @@ type Period =
* - `"2026-04-23"` (~) * - `"2026-04-23"` (~)
* *
* @param p - * @param p -
* @throws {HttpError} 400 * @throws {HttpError} 400
*/ */
function parsePeriod(p: string | undefined): Period { function parsePeriod(p: string | undefined): Period {
if (!p || p === "current") return "current"; 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); 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); const mMonth = /^(\d{4})-(\d{2})$/.exec(p);
if (mMonth) return {kind: "month", year: Number(mMonth[1]), month: Number(mMonth[2])}; if (mMonth) {
const mDate = /^(\d{4})-(\d{2})-(\d{2})$/.exec(p); const year = Number(mMonth[1]);
if (mDate) return {kind: "week", tuesday: tuesdayOf(parseDateString(p))}; const month = Number(mMonth[2]);
if (!inYearRange(year) || month < 1 || month > 12) {
throw new HttpError(400, `invalid period: ${p}`); throw new HttpError(400, `invalid period: ${p}`);
}
return {kind: "month", year, month};
}
const mDate = /^(\d{4})-(\d{2})-(\d{2})$/.exec(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}`;
} }
/** /**
@ -158,6 +198,99 @@ function weeklyResultsOf(entries: Array<{ date: DateString; doc: VoteHistoryDoc
return results; 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<Period, "current">,
recent: Array<{ date: DateString; doc: VoteHistoryDoc }>,
recentStart: DateString,
recentEnd: DateString,
): Promise<Array<{ date: DateString; doc: VoteHistoryDoc }>> {
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<Period, "current">): [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 - * @param period -
*/ */
async function computeStats(uid: string, period: Period): Promise<StatsResponse> { async function computeStats(uid: string, period: Period): Promise<StatsResponse> {
const [all, user] = await Promise.all([getAll(uid), getUser(uid)]);
const overall = aggregate(all);
const today = todayKst(); const today = todayKst();
const {y: nowYear, m: nowMonth} = parseYmd(today); const {y: nowYear, m: nowMonth} = parseYmd(today);
const thisTuesday = tuesdayOf(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) => const [recent, user] = await Promise.all([
matchesPeriod(e.date, {kind: "month", year: nowYear, month: nowMonth}) 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 monthly = aggregate(
recent.filter((e) => matchesPeriod(e.date, {kind: "month", year: nowYear, month: nowMonth}))
const weeklyEntries = all.filter((e) => );
matchesPeriod(e.date, {kind: "week", tuesday: thisTuesday}) 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)); // "current"는 통산과 동일하므로 카운터를 재사용한다. 과거 기간은 그 구간만 읽는다.
const periodAgg = aggregate(periodEntries); const periodAgg = period === "current" ?
overall :
aggregate(await entriesForPeriod(uid, period, recent, recentStart, today));
// 결석으로 streak이 끊겼는지 lazy 보정. // 결석으로 streak이 끊겼는지 lazy 보정.
// 1) `lastJudgedDate`가 어제 이후면 정상. // 1) `lastJudgedDate`가 어제 이후면 정상.
@ -206,10 +350,13 @@ async function computeStats(uid: string, period: Period): Promise<StatsResponse>
} }
} }
const storedStreak = streakBroken ? 0 : user?.currentStreak; const storedStreak = streakBroken ? 0 : user?.currentStreak;
const streakDays = storedStreak ?? computeStreak(all); // 폴백은 `currentStreak`이 아직 없는 유저(레거시·신규)에만 쓰인다. 올해 구간만
// 보므로 해를 넘긴 연속 기록은 연초에 과소 계산될 수 있다 — 판정이 한 번이라도
// 돌면 `currentStreak`이 채워져 이 경로를 타지 않는다.
const streakDays = storedStreak ?? computeStreak(recent);
const highestStreak = user?.highestStreak ?? streakDays; const highestStreak = user?.highestStreak ?? streakDays;
const tierPoints = user?.tierPoints ?? 0; const tierPoints = user?.tierPoints ?? 0;
const weeklyResults = weeklyResultsOf(all); const weeklyResults = weeklyResultsOf(recent);
return { return {
streakDays, streakDays,
@ -245,7 +392,7 @@ function cachePath(uid: string, key: string): string {
*/ */
export async function getStats(uid: string, periodParam?: string): Promise<UserStatsDto> { export async function getStats(uid: string, periodParam?: string): Promise<UserStatsDto> {
const period = parsePeriod(periodParam); const period = parsePeriod(periodParam);
const key = periodParam && periodParam !== "current" ? periodParam : "current"; const key = periodCacheKey(period);
// 캐시는 산출 시점의 KST 날짜(`forDate`)와 함께 저장된다. // 캐시는 산출 시점의 KST 날짜(`forDate`)와 함께 저장된다.
// 날짜가 바뀌면 streak/weeklyResults 기준이 달라지므로 무효화하고 재계산한다. // 날짜가 바뀌면 streak/weeklyResults 기준이 달라지므로 무효화하고 재계산한다.

View File

@ -234,8 +234,15 @@ export async function deleteMe(token: DecodedIdToken): Promise<void> {
if (code !== "auth/user-not-found") throw err; if (code !== "auth/user-not-found") throw err;
} }
// 사전계산된 랭킹 리스트에서 즉시 제외. 실패해도 다음 새벽 재계산이 정리한다. // 사전계산된 랭킹 리스트에서 즉시 제외. 실패해도 다음 새벽 재계산이 정리한다.
// 탈퇴 유저가 영향을 줄 수 있는 스코프는 overall과 본인 응원팀뿐이므로
// 11개 전체가 아니라 그 둘만 재계산한다(`existing`은 위에서 이미 읽었다).
try { try {
await precomputeScoreboardCache(todayKst()); await precomputeScoreboardCache(todayKst(), [
{ kind: "overall" },
...(existing.favoriteTeamCode ?
[{ kind: "team" as const, teamCode: existing.favoriteTeamCode }] :
[]),
]);
} catch (err) { } catch (err) {
logger.error( logger.error(
`deactivate: precomputeScoreboardCache failed uid=${token.uid}`, `deactivate: precomputeScoreboardCache failed uid=${token.uid}`,

View File

@ -165,6 +165,19 @@ export interface User {
*/ */
lastJudgedDate?: DateString; lastJudgedDate?: DateString;
/**
* · `overall` voteHistory
* . (result ) .
*
* `lifetimeStatsThrough` .
* , `computeStats` .
* `applyDailyJudgmentTx` `date > lifetimeStatsThrough`
* .
*/
lifetimePredictions?: number;
lifetimeCorrect?: number;
lifetimeStatsThrough?: DateString;
rankSnapshot?: RankSnapshot; rankSnapshot?: RankSnapshot;
} }

View File

@ -1,12 +1,17 @@
import { beforeEach, describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
import { Timestamp } from "firebase-admin/firestore"; import { Timestamp } from "firebase-admin/firestore";
import { firestore } from "../../src/firebase"; 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"; import type { DateString } from "../../src/types/dateString";
describe("gameRepository", () => { describe("gameRepository", () => {
beforeEach(async () => { beforeEach(async () => {
await firestore.recursiveDelete(firestore.collection("games")); await firestore.recursiveDelete(firestore.collection("games"));
invalidateAllGameDays();
}); });
it("존재하지 않는 경기는 null을 반환한다", async () => { it("존재하지 않는 경기는 null을 반환한다", async () => {

View File

@ -7,6 +7,10 @@ import {
getCounts, getCounts,
getUserDateVotes, getUserDateVotes,
getUserVote, getUserVote,
getVotesByDate,
isVoteDateIndexBackfilled,
deleteUserVoteGame,
deleteUserVoteIndex,
submitVote, submitVote,
} from "../../src/repositories/voteRepository"; } from "../../src/repositories/voteRepository";
import type { DateString } from "../../src/types/dateString"; 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);
});
});

View File

@ -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<void> {
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<void> {
const user: Partial<User> = {
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<void> {
await rtdb.ref(`/userVotes/${uid}/${date}/${gameId}`).set({ team: "LG", result: true });
}
/** 날짜별 인덱스에도 심는다(= 미러 배포 이후에 투표한 유저). */
async function seedIndexedVote(uid: string): Promise<void> {
await rtdb.ref(`/userVotesByDate/${date}/${uid}/${gameId}`).set({ team: "LG", result: true });
}
async function hasHistory(uid: string): Promise<boolean> {
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);
});
});

View File

@ -1,56 +1,25 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
buildUserContextBlock, buildUserContextBlock,
formatTodaySchedule,
resolveKnowledgeLevel, resolveKnowledgeLevel,
resolvePersonaBlock, resolvePersonaBlock,
resolveTeamCode, resolveTeamCode,
sanitizeDisplayName, sanitizeDisplayName,
type UserContext, type IdentityContext,
} from "../../src/services/chatContextService"; } from "../../src/services/chatContextService";
import { assembleSystemPrompt } from "../../src/services/chatContextService"; import { assembleSystemPrompt } from "../../src/services/chatContextService";
import { DEFAULT_CHAT_CONFIG } from "../../src/services/chatConfigService"; import { DEFAULT_CHAT_CONFIG } from "../../src/services/chatConfigService";
import { CHAT_CANARY_TOKEN } from "../../src/constants/chatPrompts"; import { CHAT_CANARY_TOKEN } from "../../src/constants/chatPrompts";
import { KnowledgeLevel, TeamCode } from "../../src/types/panit"; import { KnowledgeLevel, TeamCode } from "../../src/types/panit";
import type { DateString } from "../../src/types/dateString"; import type { DateString } from "../../src/types/dateString";
import type { ScheduleGame } from "../../src/types/kbo";
function game(overrides: Partial<ScheduleGame>): ScheduleGame { function ctx(overrides: Partial<IdentityContext>): IdentityContext {
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>): UserContext {
return { return {
date: "2026-06-12" as DateString, date: "2026-06-12" as DateString,
displayName: "솔방울", displayName: "솔방울",
knowledgeLevel: KnowledgeLevel.Casual, knowledgeLevel: KnowledgeLevel.Casual,
teamCode: TeamCode.HH, teamCode: TeamCode.HH,
teamName: "한화 이글스", teamName: "한화 이글스",
todaySchedule: "오늘 경기 없음",
todayMyPredictions: "오늘 예측 없음",
yesterdayRecap: "어제 예측 기록 없음",
recentTeamResults: null,
h2hRecords: null,
myStats: "통계 없음",
hasYesterdayRecap: false,
hasTodayTeamGame: false,
hasPredictedToday: false,
...overrides, ...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 템플릿)", () => { describe("buildUserContextBlock(2.2 템플릿)", () => {
it("고정 구분자로 감싸고 응원팀 미설정·선택 줄 생략을 적용한다", () => { it("고정 구분자로 감싸고 응원팀 미설정·선택 줄 생략을 적용한다", () => {
const block = buildUserContextBlock(ctx({ teamCode: null, teamName: null })); const block = buildUserContextBlock(ctx({ teamCode: null, teamName: null }));
@ -114,7 +60,7 @@ describe("chatContextService", () => {
}); });
it("정체성(닉네임·응원팀)만 주입하고 시사 수치·일정은 넣지 않는다(경량판)", () => { it("정체성(닉네임·응원팀)만 주입하고 시사 수치·일정은 넣지 않는다(경량판)", () => {
const block = buildUserContextBlock(ctx({ hasTodayTeamGame: true })); const block = buildUserContextBlock(ctx({}));
expect(block).toContain("- 사용자: 솔방울"); expect(block).toContain("- 사용자: 솔방울");
expect(block).not.toContain("오늘"); // 오늘 경기·일정은 컨텍스트에 없음(도구로) expect(block).not.toContain("오늘"); // 오늘 경기·일정은 컨텍스트에 없음(도구로)
expect(block).not.toContain("최근 5경기"); expect(block).not.toContain("최근 5경기");

View File

@ -617,6 +617,60 @@ describe("chatService", () => {
expect(contents).toEqual(["어제 경기 봤어?", "봤지! 짜릿했어", "오늘은 어때?"]); expect(contents).toEqual(["어제 경기 봤어?", "봤지! 짜릿했어", "오늘은 어때?"]);
expect(history[0].role).toBe("user"); 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<ChatMessageDoc> = {},
) =>
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)", () => { describe("스레드 결정 규칙(추가-1·추가-2)", () => {

View File

@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
import { Timestamp } from "firebase-admin/firestore"; import { Timestamp } from "firebase-admin/firestore";
import { firestore } from "../../src/firebase"; import { firestore } from "../../src/firebase";
import { invalidateAllGameDays } from "../../src/repositories/gameRepository";
import { import {
judgeDay, judgeDay,
} from "../../src/services/judgmentService"; } from "../../src/services/judgmentService";
@ -51,6 +52,7 @@ async function seedGames(
const gameId = `${date.replace(/-/g, "")}G${i}`; const gameId = `${date.replace(/-/g, "")}G${i}`;
const game = makeGame(date, spec.status, "LG", spec.winner ?? null); const game = makeGame(date, spec.status, "LG", spec.winner ?? null);
await firestore.collection("games").doc(gameId).set(game); await firestore.collection("games").doc(gameId).set(game);
invalidateAllGameDays();
} }
} }
@ -109,6 +111,8 @@ describe("judgmentService (Firestore emulator)", () => {
beforeEach(async () => { beforeEach(async () => {
await firestore.recursiveDelete(firestore.collection("users")); await firestore.recursiveDelete(firestore.collection("users"));
await firestore.recursiveDelete(firestore.collection("games")); await firestore.recursiveDelete(firestore.collection("games"));
// 테스트는 games를 직접 쓰므로 프로덕션 쓰기 경로와 같은 무효화를 수동 수행한다.
invalidateAllGameDays();
await seedUser(); await seedUser();
}); });

View File

@ -6,6 +6,7 @@ import {
} from "../../src/services/scoreboardService"; } from "../../src/services/scoreboardService";
import { import {
precomputeScoreboardCache, precomputeScoreboardCache,
buildRankStandings,
snapshotRankForUser, snapshotRankForUser,
} from "../../src/services/rankSnapshotService"; } from "../../src/services/rankSnapshotService";
import { todayKst, type DateString } from "../../src/types/dateString"; import { todayKst, type DateString } from "../../src/types/dateString";
@ -281,3 +282,60 @@ describe("rankSnapshotService.snapshotRankForUser", () => {
expect(snap.teamCode).toBeUndefined(); 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);
}
});
});

View File

@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
import { Timestamp } from "firebase-admin/firestore"; import { Timestamp } from "firebase-admin/firestore";
import { firestore, rtdb } from "../../src/firebase"; import { firestore, rtdb } from "../../src/firebase";
import { invalidateAllGameDays } from "../../src/repositories/gameRepository";
import { getStats } from "../../src/services/statsService"; import { getStats } from "../../src/services/statsService";
import { import {
addDays, addDays,
@ -33,6 +34,7 @@ async function seedGames(
}; };
if (spec.winner) doc.winningTeamCode = spec.winner; if (spec.winner) doc.winningTeamCode = spec.winner;
await firestore.collection("games").doc(gameId).set(doc); await firestore.collection("games").doc(gameId).set(doc);
invalidateAllGameDays();
} }
} }
@ -64,6 +66,8 @@ describe("statsService.getStats — 결석 lazy 보정", () => {
beforeEach(async () => { beforeEach(async () => {
await firestore.recursiveDelete(firestore.collection("users")); await firestore.recursiveDelete(firestore.collection("users"));
await firestore.recursiveDelete(firestore.collection("games")); await firestore.recursiveDelete(firestore.collection("games"));
// 테스트는 games를 직접 쓰므로 프로덕션 쓰기 경로와 같은 무효화를 수동 수행한다.
invalidateAllGameDays();
await rtdb.ref(`/cache/stats/${uid}`).remove(); await rtdb.ref(`/cache/stats/${uid}`).remove();
await rtdb.ref(`/userVotes/${uid}`).remove(); await rtdb.ref(`/userVotes/${uid}`).remove();
}); });
@ -227,3 +231,194 @@ describe("statsService.getStats — 캐시 forDate 검증", () => {
expect(stats.streakDays).toBe(3); // user doc 기준으로 재계산 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<boolean | null>
): Promise<void> {
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);
});
});

106
tests/unit/memCache.test.ts Normal file
View File

@ -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<number>(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<string | null>(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<number>(60_000);
const empty = new MemCache<string>(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<number>(-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<number>(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<number>(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);
});
});

View File

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