Compare commits

..

10 Commits

Author SHA1 Message Date
628053f72b Merge branch 'perf/firestore-io-audit' into develop 2026-08-03 15:48:43 +09:00
89e817206b Clamp future schedule cache TTL to the target day
- 모레 이후 일자의 TTL 이 7일 고정이라, 2~7일 앞서 캐시된 날짜가 경기 당일과
  그 이후까지 "경기 전" 스냅샷을 계속 내놓았다
- TTL 은 쓰는 시점의 미래 거리로만 정해지고 시간이 흘러도 재평가되지 않으므로,
  해당 날짜 00:00 을 넘지 않도록 잘라 경기 당일에는 반드시 다시 받아오게 한다.
  이후로는 '오늘'/'과거' 분기가 실제 진행 상태에 맞는 TTL 을 다시 매긴다
- 실제 사고: 07-27 에 담긴 08-01 스냅샷이 7일 TTL 로 08-03 까지 살아남아,
  폭염취소된 NC 경기가 달력에서 계속 scheduled 로 보였다
- dayTtlMs 를 export 하고 fake timer 기반 회귀 테스트 8건 추가
- 함수 주석의 TTL 표를 실제 동작대로 다시 적었다 (기존 "본 모듈 상단의 표
  참조" 는 존재하지 않는 표를 가리키고 있었다)
2026-08-03 14:54:03 +09:00
90d6895de4 Pin script RTDB region and gate empty-source backfill marker
- 스크립트 부트스트랩의 databaseURL 기본값에 리전 포함 — 기존 `...firebaseio.com`은 SDK가 "Database lives in a different region" 경고만 내고 다른 리전의 없는 DB를 보게 해서, 조회가 조용히 빈 결과를 돌려주고 실행이 멈췄다. 지금까지 스크립트가 RTDB를 안 써서 드러나지 않던 문제다. FIREBASE_DATABASE_REGION으로 덮어쓸 수 있다
- 백필 스크립트가 접속한 database URL을 먼저 출력하도록 변경
- 소스가 비었을 때 완료 마커를 자동으로 남기지 않고 --mark-empty를 요구하도록 변경 — 빈 조회는 "정말 투표가 없다"와 "엉뚱한 DB에 붙었다"를 구분하지 못하는데, 후자에서 마커가 켜지면 이후 dailyArchive가 인덱스만 믿고 전 유저를 건너뛴다
2026-07-27 13:39:10 +09:00
d2df63e63c Trim chat context and history reads
- gatherUserContext 제거 — 메시지마다 6개 소스를 병렬 조회했지만 USER_CONTEXT_TEMPLATE은 날짜·닉네임·응원팀 3개 필드만 쓴다. 시사 데이터를 도구 조회로 옮기면서 선주입 블록만 지우고 fetch는 남아 있던 것으로, 메시지당 33~64 read가 전량 폐기되고 있었다
- identityContext(Firestore read 0, 이미 로드한 user 문서만 사용)와 gatherSuggestionFlags(추천 질문 전용, 응원팀 미설정 시 일정 조회도 생략)로 분리. 소비되지 않던 필드와 fetchRecentTeamGames·formatRecentResults·formatTodaySchedule 삭제
- fetchScheduleMonth가 리필 직후 전 키(28~31 doc)를 재읽기하던 것을 byDate 메모리 병합으로 교체. loadMonthDayCache를 추출해 단일일 조회도 월 리필 결과를 재사용하게 하고, live 병합을 호출자에서 1회만 수행해 이중 적용을 막았다
- loadHistory를 2단계 페치로 변경. 위기 요청은 일일 한도를 우회하므로 위기 교환쌍 수에 상한이 없고, 그 쌍은 문서 2개를 먹고 윈도잉에서 둘 다 빠진다. 1차 페이지가 다 찼는데도 목표 턴에 못 미칠 때만 한 번 넓혀 재조회한다(평시 추가 쿼리 0회)
- 예약 트랜잭션이 계산한 used를 ReserveOutcome으로 돌려줘 응답 조립의 쿼터 재조회 제거(트랜잭션이 없는 replay·GET /chat/quota는 기존 read 유지)
- loadHistory가 threadExists를 반환해 저장 단계로 전달 — 같은 요청에서 thread 문서를 두 번 읽던 것 제거(위기 경로는 loadHistory를 안 거치므로 optional)
- upsertReport를 create 후 ALREADY_EXISTS 폴백 merge로 전환해 최초 신고의 사전 read 제거
2026-07-27 13:27:10 +09:00
ca5c4c69b9 Harden KBO cache invalidation against batch limit
- schedule_day__ 일괄 무효화 제거 — dayTtlMs가 완료된 과거 경기일에 7일 TTL을 주는데 매일 밤 지워 무의미하게 만들고 있었다. 동일한 동적 TTL을 가진 game_detail__를 이미 제외하던 논리를 그대로 적용해 자연 만료에 위임한다
- 남은 rank__ 무효화를 400건 단위로 청킹 — 단일 batch는 500 op 하드 상한에서 INVALID_ARGUMENT로 던지므로, 캐시 문서가 늘어난 어느 날 잡 전체가 조용히 죽을 수 있었다
- 삭제 쿼리에 .select() 적용해 ref만 전송(문서 본문 불필요)
- 무효화를 try 블록 안으로 이동 — 기존에는 바깥이라 실패 시 rank 갱신·syncGamesForMonth·forceSyncDay가 전부 실행되지 않았다
2026-07-27 13:26:51 +09:00
513bf70e87 Reduce per-user reads in daily archive and stats
- dailyArchive가 날짜별 인덱스만 읽도록 전환. 백필 마커가 없는 롤아웃 기간에는 원본도 함께 읽어 인덱스에 없는 uid만 보충한다 — "인덱스가 비었을 때만 폴백"으로 두면 미러 배포 당일처럼 인덱스가 부분적으로만 찬 날짜에서 배포 전 투표자가 판정·보상·스트릭 없이 영구 유실된다(그 날짜는 다시 처리되지 않는다)
- 순위표를 run 시작 시 1회 로드해 유저별 count aggregation을 제거(O(N x M) -> 이진 탐색). listAllRankedUsers의 프로젝션에 favoriteTeamCode 추가(문서당 과금이라 read unit은 불변). 로드 실패 시 기존 aggregation으로 폴백
- user 문서를 루프에서 1회만 읽어 computeRankSnapshot과 judgeDay가 공유 — 유저당 3 read 중 1개 제거. 판정 트랜잭션 내부 tx.get은 멱등 가드와 streak read-modify-write 구동에 필수라 유지. getUser 실패 시엔 undefined로 남겨 judgeDay가 재조회하게 한다(null을 넘기면 결석 판정이 조용히 꺼진다)
- reconcileDayVotes가 날짜 경기를 1회 확보해 getGame N+1 제거. run 스코프 Set으로 같은 경기의 processGameEndWithGame 중복 재처리 차단(byUid는 정지된 스냅샷이라 앞 유저가 치유한 경기도 뒤 유저에겐 미판정으로 보인다)
- settleDailyReward에 gameCache 파라미터 추가하고 games 조회를 트랜잭션 조기 return 가드 뒤로 이동 — no_history/already_settled/not_judged 재실행은 games read 0회로 끝난다
- 통산 예측·적중 롤링 카운터 도입(user 문서). computeStats가 전수 스캔 대신 카운터 + 올해 구간 range 조회 1회를 쓴다. 미백필 유저만 1회 스캔 후 백필하며, 이력이 없으면 기준선을 세우지 않는다(오늘로 잡으면 아직 아카이브되지 않은 어제 투표가 영구 누락된다). 기준선을 lastJudgedDate로 앞당기지도 않는다 — judgeDay가 판정 트랜잭션을 voteHistory 기록보다 먼저 커밋하므로 같은 누락이 생긴다
- stats period 범위 검증(연도 2024~올해, 월 1-12, 미래 날짜 거부)과 캐시 키 정규화 추가 — 검증이 없으면 임의 period 값으로 캐시를 매번 미스시켜 전수 집계를 강제할 수 있었고, 같은 주의 7개 날짜가 7개 캐시 엔트리를 만들었다
- precomputeScoreboardCache에 scopes 파라미터 추가 — 탈퇴 경로가 11개 전체 대신 overall과 본인 팀만 재계산(영향 범위가 그 둘뿐이고 existing은 이미 읽은 값이다)
- 테스트 16건 추가: 순위표 동등성 6건, 통산 집계·기준선 7건, dailyArchive 롤아웃 5건(신규 파일)
2026-07-27 13:26:41 +09:00
eb44f50886 Index user votes by date for daily archive
- /userVotesByDate/{date}/{uid}/{gameId} fan-out 인덱스 추가 — RTDB에는 부분 노드 읽기가 없어서 dailyArchive가 하루치를 처리하려면 /userVotes 트리 전체(전 유저 x 전 보존 날짜)를 내려받아야 했다
- 투표를 쓰는 3곳(submitVote, changeVote, processGameEndWithGame의 판정 결과 주입)이 원자 update 하나로 원본과 미러를 함께 기록
- 삭제 경로도 동기화: deleteUserVoteGame은 양쪽에서, deleteUserVoteIndex는 유저의 날짜 목록을 먼저 읽어 해당 날짜 미러만 정리
- database.rules.json에 userVotesByDate·userVotesByDateMeta를 read/write false로 추가 — 교차 유저 데이터라 서버 전용
- 백필 스크립트 추가(npm run backfill:vote-index). dry-run 기본, --apply로 500건씩 청킹 기록, 멱등. 완료 시 /userVotesByDateMeta/backfilledAt 마커를 남겨 잡이 원본 스캔을 그만두는 근거로 쓴다
- 미러 정합성 테스트 8건 추가
2026-07-27 13:26:15 +09:00
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
aa53a5b070 Extract ALREADY_EXISTS guard and drop duplicate wallet read
- isAlreadyExistsError를 pointService에서 middleware/errors로 이동 — 레포지토리가 서비스를 import하는 계층 역전 없이 양쪽에서 쓰기 위함. pointService는 re-export로 기존 호출부 호환 유지
- createOrder에서 getWalletTx 사전 호출과 잔액 사전 검사 제거 — applyPointChangesTx가 같은 트랜잭션에서 동일한 HttpError(409, INSUFFICIENT_BALANCE)를 던지므로 동작은 같고 wallet 문서 tx.get이 2회에서 1회로 준다
2026-07-27 13:24:35 +09:00
a9634b7229 Expand notification settings API with marketing consent
- NotificationKey에 predictionRemind·jjaekTalk 추가(마케팅은 opt-in 맵에서 제외)
- GET/POST/PATCH /user 응답에 notifications 맵과 marketingOptIn 포함 — 클라가 서버 설정을 읽을 수 있게 함
- updateMe에 marketingOptIn 처리 추가: 동의 시각은 서버 serverTimestamp로 기록, 철회 시 동의 이력 보존 (기존에는 미파싱으로 단독 PATCH가 400)
- updateNotifications가 전체 맵 재기록 대신 patch 키만 merge 기록하도록 변경(동시 토글 유실 방지)
- userService 테스트 8건 추가(허용/차단 키, boolean 검증, 동의 시각 기록·보존, 기본값)
- backend-writes.md §1.1에 새 쓰기 필드·규칙 반영
2026-07-23 17:19:00 +09:00
42 changed files with 1884 additions and 408 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

@ -17,9 +17,9 @@
| 항목 | 내용 | | 항목 | 내용 |
|---|---| |---|---|
| 주요 필드 | `displayName, email, provider, knowledgeLevel, photoUrl, favoriteTeamCode, createdAt` (생성), `currentStreak, highestStreak, tierPoints, lastJudgedDate, rankSnapshot, notifications` (운영 중 갱신) | | 주요 필드 | `displayName, email, provider, knowledgeLevel, photoUrl, favoriteTeamCode, createdAt` (생성), `currentStreak, highestStreak, tierPoints, lastJudgedDate, rankSnapshot, notifications, marketingOptIn, marketingOptInAt` (운영 중 갱신) |
| Writer / 위치 | `createUser` `userRepository.ts:128-139` · `updateUser` `:145-150` · `applyDailyJudgmentTx` `:182-254` · `deleteUser` `:155-157` · `snapshotRankForUser` `rankSnapshotService.ts:26-54` · `settleUsers` `seasonRepository.ts`(tierPoints=0 리셋 + rankSnapshot 삭제) | | Writer / 위치 | `createUser` `userRepository.ts:128-139` · `updateUser` `:145-150` · `applyDailyJudgmentTx` `:182-254` · `deleteUser` `:155-157` · `snapshotRankForUser` `rankSnapshotService.ts:26-54` · `settleUsers` `seasonRepository.ts`(tierPoints=0 리셋 + rankSnapshot 삭제) |
| Trigger | 생성=POST `/user`(`createMe`, userService.ts:120) · 갱신=PATCH `/user`(`updateMe`), PATCH `/user/notifications`, **GET `/user`(`getMe`) 의 photoUrl 자동 동기화**(userService.ts:101-105) · 판정=`dailyArchive` cron→`judgeDay``applyDailyJudgmentTx` · rankSnapshot=`dailyArchive` cron(judge **이전**) · 시즌 리셋=`dailyArchive` cron→`maybeSettleSeason`(시즌 endDate 다음 날 1회, §1.8) · 삭제=DELETE `/user` | | Trigger | 생성=POST `/user`(`createMe`, userService.ts:120) · 갱신=PATCH `/user`(`updateMe``marketingOptIn` 포함, 동의 시각은 서버 serverTimestamp 로만 기록·철회 시 보존), PATCH `/user/notifications`(키 whitelist=`NotificationKey`: attendance/predictionRemind/jjaekTalk, **patch 키만 merge 기록**), **GET `/user`(`getMe`) 의 photoUrl 자동 동기화**(userService.ts:101-105) · 판정=`dailyArchive` cron→`judgeDay``applyDailyJudgmentTx` · rankSnapshot=`dailyArchive` cron(judge **이전**) · 시즌 리셋=`dailyArchive` cron→`maybeSettleSeason`(시즌 endDate 다음 날 1회, §1.8) · 삭제=DELETE `/user` |
| Mechanism | 생성 `set({merge:false})`; 일반 갱신 `set({merge:true})`; 판정 `runTransaction`+`set(merge:true)`+`FieldValue.serverTimestamp()`(createdAt); 삭제 `recursiveDelete`(서브컬렉션 voteHistory/attendance/pointLedger 포함) | | Mechanism | 생성 `set({merge:false})`; 일반 갱신 `set({merge:true})`; 판정 `runTransaction`+`set(merge:true)`+`FieldValue.serverTimestamp()`(createdAt); 삭제 `recursiveDelete`(서브컬렉션 voteHistory/attendance/pointLedger 포함) |
| 빈도/볼륨 | 가입 1회/유저 · 프로필 수정 드묾 · **rankSnapshot + 판정 = 유저당 매일 2회 write** (전체 활성 유저 N명 × 매일) | | 빈도/볼륨 | 가입 1회/유저 · 프로필 수정 드묾 · **rankSnapshot + 판정 = 유저당 매일 2회 write** (전체 활성 유저 N명 × 매일) |
| 비용 관찰 | ⚠️ **`getMe`(읽기 경로)에서 토큰 사진이 다르면 매 요청 write 발생** — 사진 변경이 잦은 토큰이면 read마다 hot write. ⚠️ `rankSnapshot` write(archive 중)와 `applyDailyJudgmentTx` write가 **같은 doc을 같은 cron run에서 2번** 건드림 → 1 write로 합칠 여지. | | 비용 관찰 | ⚠️ **`getMe`(읽기 경로)에서 토큰 사진이 다르면 매 요청 write 발생** — 사진 변경이 잦은 토큰이면 read마다 hot write. ⚠️ `rankSnapshot` write(archive 중)와 `applyDailyJudgmentTx` write가 **같은 doc을 같은 cron run에서 2번** 건드림 → 1 write로 합칠 여지. |

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

@ -28,6 +28,8 @@ function toMeResponse(uid: string, profile: UserProfileDto): MeResponseDto {
knowledgeLevel: profile.knowledgeLevel, knowledgeLevel: profile.knowledgeLevel,
createdAt: profile.createdAt, createdAt: profile.createdAt,
lastJudgedDate: profile.lastJudgedDate, lastJudgedDate: profile.lastJudgedDate,
notifications: profile.notifications,
marketingOptIn: profile.marketingOptIn,
}, },
}; };
} }

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,18 +36,55 @@ 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}`;
}
/** /**
* `YYYY-MM-DD` ·· . * `YYYY-MM-DD` ·· .
@ -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

@ -48,6 +48,8 @@ function toUserProfile(user: User): UserProfileDto {
knowledgeLevel: user.knowledgeLevel, knowledgeLevel: user.knowledgeLevel,
createdAt: toIsoOrUndefined(user.createdAt), createdAt: toIsoOrUndefined(user.createdAt),
lastJudgedDate: user.lastJudgedDate, lastJudgedDate: user.lastJudgedDate,
notifications: user.notifications ?? {},
marketingOptIn: user.marketingOptIn ?? false,
}; };
} }
@ -191,6 +193,8 @@ export async function createMe(
provider, provider,
knowledgeLevel, knowledgeLevel,
createdAt: toIso(Timestamp.now()), createdAt: toIso(Timestamp.now()),
notifications: {},
marketingOptIn: false,
}; };
if (token.picture) profile.photoUrl = token.picture; if (token.picture) profile.photoUrl = token.picture;
if (favoriteTeamCode) profile.favoriteTeamCode = favoriteTeamCode; if (favoriteTeamCode) profile.favoriteTeamCode = favoriteTeamCode;
@ -230,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}`,
@ -290,6 +301,7 @@ export interface UpdateMeBody {
displayName?: unknown; displayName?: unknown;
favoriteTeamCode?: unknown; favoriteTeamCode?: unknown;
knowledgeLevel?: unknown; knowledgeLevel?: unknown;
marketingOptIn?: unknown;
} }
/** /**
@ -336,6 +348,22 @@ export async function updateMe(
); );
} }
if (body.marketingOptIn !== undefined) {
if (typeof body.marketingOptIn !== "boolean") {
throw new HttpError(
400,
"marketingOptIn must be boolean",
"INVALID_INPUT"
);
}
patch.marketingOptIn = body.marketingOptIn;
// 동의 시각은 서버 시각이 법적 근거 — 클라가 보낸 값은 쓰지 않는다.
// 철회(false) 시에는 동의 이력(marketingOptInAt)을 보존한다.
if (body.marketingOptIn) {
patch.marketingOptInAt = FieldValue.serverTimestamp();
}
}
if (Object.keys(patch).length === 0) { if (Object.keys(patch).length === 0) {
throw new HttpError(400, "no fields to update", "INVALID_INPUT"); throw new HttpError(400, "no fields to update", "INVALID_INPUT");
} }
@ -357,6 +385,9 @@ export async function updateMe(
if (typeof patch.knowledgeLevel === "string") { if (typeof patch.knowledgeLevel === "string") {
merged.knowledgeLevel = patch.knowledgeLevel as KnowledgeLevel; merged.knowledgeLevel = patch.knowledgeLevel as KnowledgeLevel;
} }
if (typeof patch.marketingOptIn === "boolean") {
merged.marketingOptIn = patch.marketingOptIn;
}
return toUserProfile(merged); return toUserProfile(merged);
} }
@ -415,9 +446,10 @@ export async function updateNotifications(
throw new HttpError(400, "no notification keys to update", "INVALID_INPUT"); throw new HttpError(400, "no notification keys to update", "INVALID_INPUT");
} }
const merged: NotificationsMap = { ...(user.notifications ?? {}), ...patch }; // set(merge:true)가 맵을 재귀 병합하므로 patch 키만 쓴다 — 전체 맵을 다시 쓰면
await updateUser(token.uid, { notifications: merged }); // 동시 토글이 서로의 변경을 last-write-wins로 지울 수 있다.
return merged; await updateUser(token.uid, { notifications: patch });
return { ...(user.notifications ?? {}), ...patch };
} }
/** /**

View File

@ -17,6 +17,10 @@ export interface UserProfileDto {
knowledgeLevel: KnowledgeLevel; knowledgeLevel: KnowledgeLevel;
createdAt?: string; createdAt?: string;
lastJudgedDate?: DateString; lastJudgedDate?: DateString;
/** 알림 opt-in 맵. 키는 `NotificationKey`, 미설정 키는 발송 안 함과 동일. */
notifications: NotificationsMap;
/** 마케팅 수신 동의 여부. 동의 시각은 서버 내부 기록(marketingOptInAt)으로만 보존. */
marketingOptIn: boolean;
} }
/** `GET/POST/PATCH /user` 공통 응답. */ /** `GET/POST/PATCH /user` 공통 응답. */

View File

@ -45,8 +45,17 @@ export enum AttendanceResult {
AlreadyCheckedIn = "alreadyCheckedIn", AlreadyCheckedIn = "alreadyCheckedIn",
} }
/**
* `users/{uid}.notifications` . /false "발송 안 함" opt-in .
* `marketingOptIn`/`marketingOptInAt`
* .
*/
export enum NotificationKey { export enum NotificationKey {
Attendance = "attendance", Attendance = "attendance",
/** 경기 시작 전 예측 마감 임박 리마인드. */
PredictionRemind = "predictionRemind",
/** 짹의 톡톡(선톡·경기 결과·응원 메시지). */
JjaekTalk = "jjaekTalk",
} }
export type NotificationsMap = Partial<Record<NotificationKey, boolean>>; export type NotificationsMap = Partial<Record<NotificationKey, boolean>>;
@ -103,6 +112,15 @@ export interface User {
knowledgeLevel: KnowledgeLevel; knowledgeLevel: KnowledgeLevel;
createdAt: Timestamp; createdAt: Timestamp;
notifications?: NotificationsMap; notifications?: NotificationsMap;
/**
* . `notifications`
* (marketingOptInAt) .
* marketingOptIn만 false로 .
*/
marketingOptIn?: boolean;
marketingOptInAt?: Timestamp;
/** 마지막 로그인 기기의 FCM 토큰. 클라가 직접 덮어쓰기. */ /** 마지막 로그인 기기의 FCM 토큰. 클라가 직접 덮어쓰기. */
fcmToken?: string; fcmToken?: string;
@ -147,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);
});
});

View File

@ -9,6 +9,7 @@ import {
purgeExpiredAccounts, purgeExpiredAccounts,
PURGE_GRACE_DAYS, PURGE_GRACE_DAYS,
updateMe, updateMe,
updateNotifications,
} from "../../src/services/userService"; } from "../../src/services/userService";
import { HttpError } from "../../src/middleware/errors"; import { HttpError } from "../../src/middleware/errors";
import { import {
@ -109,6 +110,99 @@ describe("userService", () => {
}); });
}); });
describe("updateNotifications", () => {
it("허용 키(attendance/predictionRemind/jjaekTalk)를 저장하고 병합해 반환한다", async () => {
await createMeWithReservation();
const first = await updateNotifications(fakeToken(), {
notifications: { attendance: true, predictionRemind: true },
});
expect(first).toEqual({ attendance: true, predictionRemind: true });
// 부분 업데이트 — 미포함 키는 유지된다.
const second = await updateNotifications(fakeToken(), {
notifications: { jjaekTalk: true, predictionRemind: false },
});
expect(second).toEqual({
attendance: true,
predictionRemind: false,
jjaekTalk: true,
});
});
it("whitelist 밖 키는 400 + INVALID_INPUT (marketing 포함)", async () => {
await createMeWithReservation();
// marketing은 marketingOptIn 필드가 단일 소스라 notifications 맵에서 제외.
for (const key of ["marketing", "unknown"]) {
await expect(
updateNotifications(fakeToken(), { notifications: { [key]: true } })
).rejects.toMatchObject({ status: 400, code: "INVALID_INPUT" });
}
});
it("boolean이 아닌 값은 400", async () => {
await createMeWithReservation();
await expect(
updateNotifications(fakeToken(), {
notifications: { attendance: "yes" },
})
).rejects.toMatchObject({ status: 400, code: "INVALID_INPUT" });
});
it("getMe 응답에 notifications 맵이 포함된다 (미설정이면 빈 맵)", async () => {
await createMeWithReservation();
expect((await getMe(fakeToken())).notifications).toEqual({});
await updateNotifications(fakeToken(), {
notifications: { predictionRemind: true },
});
expect((await getMe(fakeToken())).notifications).toEqual({
predictionRemind: true,
});
});
});
describe("마케팅 동의 (updateMe.marketingOptIn)", () => {
async function readUserDoc() {
const snap = await firestore.collection("users").doc(uid).get();
return snap.data() ?? {};
}
it("marketingOptIn 단독 PATCH가 성공하고 동의 시각을 서버가 기록한다", async () => {
await createMeWithReservation();
const u = await updateMe(fakeToken(), { marketingOptIn: true });
expect(u.marketingOptIn).toBe(true);
const doc = await readUserDoc();
expect(doc.marketingOptIn).toBe(true);
expect(doc.marketingOptInAt).toBeTruthy();
});
it("철회(false) 시 동의 이력(marketingOptInAt)은 보존된다", async () => {
await createMeWithReservation();
await updateMe(fakeToken(), { marketingOptIn: true });
const optInAt = (await readUserDoc()).marketingOptInAt;
const u = await updateMe(fakeToken(), { marketingOptIn: false });
expect(u.marketingOptIn).toBe(false);
const doc = await readUserDoc();
expect(doc.marketingOptIn).toBe(false);
expect(doc.marketingOptInAt).toEqual(optInAt);
});
it("boolean이 아니면 400", async () => {
await createMeWithReservation();
await expect(
updateMe(fakeToken(), { marketingOptIn: "yes" })
).rejects.toMatchObject({ status: 400, code: "INVALID_INPUT" });
});
it("미설정 유저의 getMe는 marketingOptIn=false", async () => {
await createMeWithReservation();
expect((await getMe(fakeToken())).marketingOptIn).toBe(false);
});
});
describe("createMe", () => { describe("createMe", () => {
it("정상 생성 시 토큰의 email/photo/provider를 사용한다", async () => { it("정상 생성 시 토큰의 email/photo/provider를 사용한다", async () => {
const u = await createMeWithReservation(); const u = await createMeWithReservation();

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