mmday-firebase/src/services/predictionService.ts
윤정민 ed573a81e0 Cache games by date and invalidate on writes
- gameRepository에 날짜 키 공유 캐시(MemCache, 15초) 추가 — 날짜별 경기 목록은 유저 독립 전역 데이터라 요청 간 공유할 수 있다. GET /prediction/games가 요청마다 range 쿼리를 재발행하던 것을 인스턴스당 분당 수 회로 수렴시킨다
- games 문서 쓰기 경로에 무효화 연결: syncGamesForMonth는 여러 날짜에 걸치므로 전량 폐기, forceSyncDay는 해당 날짜만
- createGameDayCache가 공유 캐시를 경유하도록 변경 — 요청마다 새로 만드는 짧은 수명 인스턴스도 실제 쿼리를 유발하지 않는다
- predictionHandlers의 games 분기에 Cache-Control public max-age=15 추가(무인증·전 유저 공통 응답이라 CDN·브라우저 중복 제거가 가능한데 그동안 헤더가 없었다)
- MemCache.getOrFetch가 truthy 대신 히트 여부로 판정하도록 수정 — 캐시된 null이 미스로 취급돼 fetcher가 매번 재실행되던 문제(존재하지 않는 상품 조회가 요청마다 Firestore read 유발)
- MemCache에 peek·clear 추가, 단위 테스트 6건 신설
2026-07-27 13:25:59 +09:00

121 lines
3.6 KiB
TypeScript

import { HttpError } from "../middleware/errors";
import { getGame, listByDateCached } from "../repositories/gameRepository";
import {
getUserVote,
submitVote,
changeVote,
getCounts,
getUserDateVotes,
} from "../repositories/voteRepository";
import { DRAW_TEAM_CODE, type Game, type VoteSide } from "../types/panit";
import { fromTimestamp, parseDateString, type DateString } from "../types/dateString";
import { MemCache } from "../lib/memCache";
import {
toGameDto,
type GameDto,
type MyVotesDto,
type PredictionMutationDto,
type VoteSummaryDto,
} from "../types/dto/predictionDto";
type SummaryCounts = { homeCount: number; awayCount: number; drawCount: number };
const summaryCache = new MemCache<SummaryCounts>(5_000);
function gameDate(game: Game): DateString {
return fromTimestamp(game.time);
}
function sideOf(game: Game, teamCode: string): VoteSide {
if (teamCode === DRAW_TEAM_CODE) return "draw";
if (teamCode === game.homeTeamCode) return "home";
if (teamCode === game.awayTeamCode) return "away";
throw new HttpError(400, `teamCode ${teamCode} not in game ${game.homeTeamCode}/${game.awayTeamCode}`);
}
async function loadWaitingGame(gameId: string): Promise<Game> {
const game = await getGame(gameId);
if (!game) throw new HttpError(404, `game not found: ${gameId}`);
if (game.status !== "scheduled") {
throw new HttpError(409, `game status is ${game.status}, voting closed`);
}
return game;
}
export async function createPrediction(
uid: string,
body: { gameId?: string; selectedTeamCode?: string }
): Promise<PredictionMutationDto> {
if (!body.gameId || !body.selectedTeamCode) {
throw new HttpError(400, "gameId and selectedTeamCode required");
}
const game = await loadWaitingGame(body.gameId);
const side = sideOf(game, body.selectedTeamCode);
const existing = await getUserVote(body.gameId, uid);
if (existing) throw new HttpError(409, "already voted; use PUT to change");
await submitVote({
gameId: body.gameId,
uid,
date: gameDate(game),
side,
team: body.selectedTeamCode,
});
summaryCache.delete(body.gameId);
return { ok: true };
}
export async function updatePrediction(
uid: string,
body: { gameId?: string; selectedTeamCode?: string }
): Promise<PredictionMutationDto> {
if (!body.gameId || !body.selectedTeamCode) {
throw new HttpError(400, "gameId and selectedTeamCode required");
}
const game = await loadWaitingGame(body.gameId);
const newSide = sideOf(game, body.selectedTeamCode);
const existing = await getUserVote(body.gameId, uid);
if (!existing) throw new HttpError(400, "no prior vote; use POST");
if (existing.team === body.selectedTeamCode) return { ok: true, changed: false };
const oldSide = sideOf(game, existing.team);
await changeVote({
gameId: body.gameId,
uid,
date: gameDate(game),
oldSide,
newSide,
newTeam: body.selectedTeamCode,
});
summaryCache.delete(body.gameId);
return { ok: true, changed: true };
}
export async function getMyVotes(
uid: string,
date: string
): Promise<MyVotesDto> {
try {
return getUserDateVotes(uid, parseDateString(date));
} catch (err) {
throw new HttpError(400, (err as Error).message);
}
}
export async function listGamesByDate(date: string): Promise<GameDto[]> {
try {
const games = await listByDateCached(parseDateString(date));
return games.map(toGameDto);
} catch (err) {
throw new HttpError(400, (err as Error).message);
}
}
export async function getSummary(gameId: string): Promise<VoteSummaryDto> {
if (!gameId) throw new HttpError(400, "gameId required");
return summaryCache.getOrFetch(gameId, () => getCounts(gameId));
}