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건(신규 파일)
This commit is contained in:
parent
eb44f50886
commit
513bf70e87
@ -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 {
|
||||||
|
|||||||
@ -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 {
|
||||||
// 아카이브는 루프 끝에서 유저 단위로 1회 무효화하므로 경기별 fan-out은 생략.
|
// `byUid`는 정지된 스냅샷이라 앞선 유저가 치유한 경기도 뒤 유저에겐 여전히
|
||||||
await processGameEndWithGame(gameId, game, { skipInvalidate: true });
|
// 미판정으로 보인다. run 스코프 Set으로 경기당 1회만 처리해, 같은 경기의
|
||||||
|
// 투표자 전원 재처리(getAllUserVotes + RTDB update + deleteGameVotes)를 막는다.
|
||||||
|
if (!healed.has(gameId)) {
|
||||||
|
// 아카이브는 루프 끝에서 유저 단위로 1회 무효화하므로 경기별 fan-out은 생략.
|
||||||
|
await processGameEndWithGame(gameId, game, { skipInvalidate: true });
|
||||||
|
healed.add(gameId);
|
||||||
|
}
|
||||||
const isDraw = !game.winningTeamCode;
|
const isDraw = !game.winningTeamCode;
|
||||||
result[gameId] = {
|
result[gameId] = {
|
||||||
team: vote.team,
|
team: vote.team,
|
||||||
@ -91,31 +119,74 @@ async function reconcileDayVotes(
|
|||||||
export async function runDailyArchive(
|
export async function runDailyArchive(
|
||||||
overrideDate?: DateString
|
overrideDate?: DateString
|
||||||
): Promise<{ date: DateString; archived: number; judgedUids: string[] }> {
|
): Promise<{ date: DateString; archived: number; judgedUids: string[] }> {
|
||||||
const date = overrideDate ?? daysAgoKst(1);
|
const date = overrideDate ?? daysAgoKst(1);
|
||||||
logger.info(`dailyArchive start: ${date}`);
|
logger.info(`dailyArchive start: ${date}`);
|
||||||
|
|
||||||
let archived = 0;
|
let archived = 0;
|
||||||
const judgedUids: string[] = [];
|
const judgedUids: string[] = [];
|
||||||
// 같은 날짜(및 결석 판정용 직전 날짜들)의 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: 어제(=시즌 마지막 날) 판정을 반영한 뒤
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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,19 +36,56 @@ 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 year = Number(mMonth[1]);
|
||||||
|
const month = Number(mMonth[2]);
|
||||||
|
if (!inYearRange(year) || month < 1 || month > 12) {
|
||||||
|
throw new HttpError(400, `invalid period: ${p}`);
|
||||||
|
}
|
||||||
|
return {kind: "month", year, month};
|
||||||
|
}
|
||||||
const mDate = /^(\d{4})-(\d{2})-(\d{2})$/.exec(p);
|
const mDate = /^(\d{4})-(\d{2})-(\d{2})$/.exec(p);
|
||||||
if (mDate) return {kind: "week", tuesday: tuesdayOf(parseDateString(p))};
|
if (mDate) {
|
||||||
|
if (!inYearRange(Number(mDate[1])) || p > today) {
|
||||||
|
throw new HttpError(400, `invalid period: ${p}`);
|
||||||
|
}
|
||||||
|
return {kind: "week", tuesday: tuesdayOf(parseDateString(p))};
|
||||||
|
}
|
||||||
throw new HttpError(400, `invalid period: ${p}`);
|
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 기준이 달라지므로 무효화하고 재계산한다.
|
||||||
|
|||||||
@ -234,8 +234,15 @@ export async function deleteMe(token: DecodedIdToken): Promise<void> {
|
|||||||
if (code !== "auth/user-not-found") throw err;
|
if (code !== "auth/user-not-found") throw err;
|
||||||
}
|
}
|
||||||
// 사전계산된 랭킹 리스트에서 즉시 제외. 실패해도 다음 새벽 재계산이 정리한다.
|
// 사전계산된 랭킹 리스트에서 즉시 제외. 실패해도 다음 새벽 재계산이 정리한다.
|
||||||
|
// 탈퇴 유저가 영향을 줄 수 있는 스코프는 overall과 본인 응원팀뿐이므로
|
||||||
|
// 11개 전체가 아니라 그 둘만 재계산한다(`existing`은 위에서 이미 읽었다).
|
||||||
try {
|
try {
|
||||||
await precomputeScoreboardCache(todayKst());
|
await precomputeScoreboardCache(todayKst(), [
|
||||||
|
{ kind: "overall" },
|
||||||
|
...(existing.favoriteTeamCode ?
|
||||||
|
[{ kind: "team" as const, teamCode: existing.favoriteTeamCode }] :
|
||||||
|
[]),
|
||||||
|
]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`deactivate: precomputeScoreboardCache failed uid=${token.uid}`,
|
`deactivate: precomputeScoreboardCache failed uid=${token.uid}`,
|
||||||
|
|||||||
@ -165,6 +165,19 @@ export interface User {
|
|||||||
*/
|
*/
|
||||||
lastJudgedDate?: DateString;
|
lastJudgedDate?: DateString;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 통산 예측 수·적중 수 롤링 집계 — `overall` 승률을 voteHistory 전수 스캔 없이
|
||||||
|
* 산출하기 위한 것. 취소 무효표(result 없음)는 제외한 값만 누적한다.
|
||||||
|
*
|
||||||
|
* `lifetimeStatsThrough`는 집계에 반영된 마지막 판정 날짜다. 세 필드는 항상
|
||||||
|
* 함께 갱신되며, 없으면 `computeStats`가 전수 스캔으로 백필한다.
|
||||||
|
* 증분은 `applyDailyJudgmentTx`가 `date > lifetimeStatsThrough`일 때만 수행해
|
||||||
|
* 백필과 증분이 겹쳐 이중 계상되는 것을 막는다.
|
||||||
|
*/
|
||||||
|
lifetimePredictions?: number;
|
||||||
|
lifetimeCorrect?: number;
|
||||||
|
lifetimeStatsThrough?: DateString;
|
||||||
|
|
||||||
rankSnapshot?: RankSnapshot;
|
rankSnapshot?: RankSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
129
tests/scheduled/dailyArchive.test.ts
Normal file
129
tests/scheduled/dailyArchive.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user