mmday-firebase/src/services/statsService.ts
윤정민 1a68d41e4f Implement lazy streak correction and enhance game result synchronization.
- 사용자의 마지막 참여일 이후 실제 경기가 있었음에도 참여하지 않은 경우를 감지하여 스트릭을 0으로 초기화하는 지연 보정(lazy correction) 로직을 도입했습니다.
- KBO 휴장일(월요일, 전체 우천 취소 등)을 자동으로 식별하여 실제 경기가 열린 날에만 결석 판정이 내려지도록 `hasMissedGameDayBetween` 검증 기능을 구현했습니다.
- 실시간 라이브 데이터를 기반으로 전일 경기 결과를 확정하는 기능을 추가하여 월간 일정 갱신 과정에서 발생할 수 있는 데이터 누락을 보완했습니다.
- 일일 아카이브 및 경기 동기화 로직을 수동으로 트리거할 수 있는 디버그 엔드포인트를 추가하고, 통계 데이터에 산출 기준일(forDate)을 포함하여 캐시 무효화 정밀도를 개선했습니다.
2026-05-06 11:14:28 +09:00

293 lines
9.9 KiB
TypeScript

import {rtdb} from "../firebase.js";
import {HttpError} from "../middleware/errors.js";
import {computeLevel} from "../constants/levels.js";
import {tierOf} from "../constants/tiers.js";
import {getAll, getDay} from "../repositories/voteHistoryRepository.js";
import {getUser} from "../repositories/userRepository.js";
import {hasMissedGameDayBetween} from "./judgmentService.js";
import type {DailyJudgment, StatsResponse, VoteHistoryDoc} from "../types/panit.js";
import {
addDaysKst,
parseDateString,
toDateString,
startOfDayKst,
todayKst,
tuesdayOf as tuesdayOfUtil,
type DateString,
} from "../types/dateString.js";
type Period =
| "current"
| { kind: "year"; year: number }
| { kind: "month"; year: number; month: number }
| { kind: "week"; tuesday: DateString };
/**
* 기간 문자열을 `Period` 객체로 파싱한다.
* 문자열 길이로 종류를 구분한다.
*
* - `"current"` 또는 미지정 → 전체
* - `"2026"` → 연도
* - `"2026-04"` → 월
* - `"2026-04-23"` → 해당 날짜가 속한 주 (화~월)
*
* @param p - 기간 문자열
* @throws {HttpError} 400 — 형식이 맞지 않을 때
*/
function parsePeriod(p: string | undefined): Period {
if (!p || p === "current") return "current";
const mYear = /^(\d{4})$/.exec(p);
if (mYear) return {kind: "year", year: Number(mYear[1])};
const mMonth = /^(\d{4})-(\d{2})$/.exec(p);
if (mMonth) return {kind: "month", year: Number(mMonth[1]), month: Number(mMonth[2])};
const mDate = /^(\d{4})-(\d{2})-(\d{2})$/.exec(p);
if (mDate) return {kind: "week", tuesday: tuesdayOf(parseDateString(p))};
throw new HttpError(400, `invalid period: ${p}`);
}
/**
* `YYYY-MM-DD` 문자열을 연·월·일 숫자로 분해한다.
*
* @param date - KST 날짜 문자열
*/
function parseYmd(date: DateString): { y: number; m: number; d: number } {
const [y, m, d] = date.split("-").map(Number);
return {y, m, d};
}
/** 해당 날짜가 속한 화~월 주의 화요일을 반환한다. */
const tuesdayOf = tuesdayOfUtil;
/**
* 날짜가 주어진 기간에 속하는지 판정한다.
*
* @param dateStr - 판정 대상 날짜
* @param period - 비교 기간. `"current"`이면 항상 `true`.
*/
function matchesPeriod(dateStr: DateString, period: Period): boolean {
if (period === "current") return true;
const {y, m} = parseYmd(dateStr);
if (period.kind === "year") return y === period.year;
if (period.kind === "month") return y === period.year && m === period.month;
return tuesdayOf(dateStr) === period.tuesday;
}
/**
* 투표 이력 항목들의 전체 예측 수와 적중 수를 집계한다.
*
* @param entries - 날짜별 투표 이력 배열
*/
function aggregate(entries: Array<{ date: DateString; doc: VoteHistoryDoc }>): {
total: number;
correct: number;
} {
let total = 0;
let correct = 0;
for (const {doc} of entries) {
for (const v of doc.data) {
total += 1;
if (v.result) correct += 1;
}
}
return {total, correct};
}
/**
* 적중률을 계산한다. 예측이 없으면 0을 반환한다.
*
* @param correct - 적중 수
* @param total - 전체 예측 수
*/
function rate(correct: number, total: number): number {
return total === 0 ? 0 : correct / total;
}
/**
* 오늘부터 연속으로 예측에 참여한 일수를 계산한다.
* 오늘 아직 예측하지 않았더라도 어제까지 연속이면 카운트한다.
*
* @param entries - 전체 투표 이력
*/
function computeStreak(entries: Array<{ date: DateString; doc: VoteHistoryDoc }>): number {
if (entries.length === 0) return 0;
const sorted = [...entries].sort((a, b) => (a.date < b.date ? 1 : -1));
const todayMs = startOfDayKst(todayKst()).getTime();
let streak = 0;
for (const {date} of sorted) {
const ms = startOfDayKst(date).getTime();
const diff = Math.round((todayMs - ms) / 86400000);
if (diff === streak || (streak === 0 && diff <= 1)) {
streak += 1;
} else {
break;
}
}
return streak;
}
/**
* 이번 주(화~월) 7일간의 일별 판정 결과를 배열로 반환한다.
* 저장된 `VoteHistoryDoc.judgment`(perfect/success/fail/skip)을 그대로 노출하며,
* 예측 이력이 없거나 판정 전이면 `null`을 반환한다.
*
* @param entries - 전체 투표 이력
* @returns 화요일부터 월요일 순서의 7개 원소 배열
*/
function weeklyResultsOf(entries: Array<{ date: DateString; doc: VoteHistoryDoc }>): Array<DailyJudgment | null> {
const byDate = new Map(entries.map((e) => [e.date, e.doc] as const));
const today = todayKst();
const todayMs = startOfDayKst(today).getTime();
const {y, m, d} = parseYmd(today);
const dow = new Date(y, m - 1, d).getDay() || 7;
const offset = (dow - 2 + 7) % 7;
// 화요일을 기준으로 하기 위해 일요일 + 2 를 하고, 음수 방지를 위해 7을 더한다.
// 그러면 이번주 화요일 기준으로 몇일이 지났는지 계산 가능
const tuesdayMs = todayMs - offset * 86400000;
const results: Array<DailyJudgment | null> = [];
for (let i = 0; i < 7; i++) {
const key = toDateString(new Date(tuesdayMs + i * 86400000));
const doc = byDate.get(key);
results.push(doc?.judgment ?? null);
}
return results;
}
/**
* 유저의 예측 통계를 산출한다.
* 전체·시즌·월간·주간 승률, 연속 참여일, 주간 결과, 레벨을 포함한다.
*
* @param uid - 유저 ID
* @param period - 레벨·예측 수 집계에 사용할 기간
*/
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 {y: nowYear, m: nowMonth} = parseYmd(today);
const thisTuesday = tuesdayOf(today);
const seasonEntries = all.filter((e) => matchesPeriod(e.date, {kind: "year", year: nowYear}));
const season = aggregate(seasonEntries);
const monthlyEntries = all.filter((e) =>
matchesPeriod(e.date, {kind: "month", year: nowYear, month: nowMonth})
);
const monthly = aggregate(monthlyEntries);
const weeklyEntries = all.filter((e) =>
matchesPeriod(e.date, {kind: "week", tuesday: thisTuesday})
);
const weekly = aggregate(weeklyEntries);
const periodEntries = period === "current" ? all : all.filter((e) => matchesPeriod(e.date, period));
const periodAgg = aggregate(periodEntries);
// 결석으로 streak이 끊겼는지 lazy 보정.
// 1) `lastJudgedDate`가 어제 이후면 정상.
// 2) archive 대기 윈도우 보호: 어제분 `userVotes`가 아직 RTDB에 남아 있으면 archive가
// 안 돌았을 뿐이지 활성 유저이므로 결석 아님.
// 3) `(lastJudged, today)` 구간에 실제 경기일이 한 번이라도 있었으면 그날 결석한 것이므로 끊김.
// KBO 월요일/올스타브레이크 등 휴장일은 자동 무시(`thresholdsFor === "skip"`).
// user doc은 갱신하지 않는다 — 다음 판정 시 `applyDailyJudgmentTx`가 정리한다.
const lastJudged = user?.lastJudgedDate;
const yesterday = addDaysKst(today, -1);
let streakBroken = false;
if (lastJudged != null && lastJudged < yesterday) {
const pending = await rtdb.ref(`/userVotes/${uid}/${yesterday}`).get();
if (!pending.exists()) {
streakBroken = await hasMissedGameDayBetween(lastJudged, today);
}
}
const storedStreak = streakBroken ? 0 : user?.currentStreak;
const streakDays = storedStreak ?? computeStreak(all);
const highestStreak = user?.highestStreak ?? streakDays;
const tierPoints = user?.tierPoints ?? 0;
const tickets = user?.tickets ?? {dailyAllKill: 0, weeklyMaster: 0};
const weeklyResults = weeklyResultsOf(all);
const {level, progress} = computeLevel(periodAgg.total);
return {
streakDays,
highestStreak,
weeklyResults,
winRates: {
overall: rate(overall.correct, overall.total),
weekly: rate(weekly.correct, weekly.total),
monthly: rate(monthly.correct, monthly.total),
season: rate(season.correct, season.total),
},
totalPredictions: periodAgg.total,
totalCorrect: periodAgg.correct,
weeklyPredictions: weekly.total,
currentLevel: level,
progress,
tier: tierOf(tierPoints),
tierPoints,
tickets,
updatedAt: Date.now(),
forDate: today,
};
}
/** 유저별 통계 캐시의 RTDB 경로를 반환한다. */
function cachePath(uid: string, key: string): string {
return `/cache/stats/${uid}/${key}`;
}
/**
* 유저의 예측 통계를 조회한다. RTDB 캐시가 있으면 캐시를 반환하고,
* 없으면 새로 산출 후 캐시에 저장한다.
*
* @param uid - 유저 ID
* @param periodParam - 기간 문자열 (`"2026"`, `"2026-04"`, `"2026-04-23"` 등). 생략 시 전체.
*/
export async function getStats(uid: string, periodParam?: string): Promise<StatsResponse> {
const period = parsePeriod(periodParam);
const key = periodParam && periodParam !== "current" ? periodParam : "current";
// 캐시는 산출 시점의 KST 날짜(`forDate`)와 함께 저장된다.
// 날짜가 바뀌면 streak/weeklyResults 기준이 달라지므로 무효화하고 재계산한다.
const today = todayKst();
const cached = await rtdb.ref(cachePath(uid, key)).get();
if (cached.exists()) {
const val = cached.val() as StatsResponse;
if (val.forDate === today) return val;
}
const stats = await computeStats(uid, period);
await rtdb.ref(cachePath(uid, key)).set(stats);
return stats;
}
/**
* 유저의 통계 캐시를 모두 삭제한다.
* 경기 결과 확정 등으로 통계가 변경되었을 때 호출한다.
*
* @param uid - 유저 ID
*/
export async function invalidateStats(uid: string): Promise<void> {
await rtdb.ref(`/cache/stats/${uid}`).remove();
}
/**
* 특정 날짜의 유저 투표 이력을 조회한다.
*
* @param uid - 유저 ID
* @param date - 조회할 날짜 (`YYYY-MM-DD`)
* @throws {HttpError} 400 — 날짜 형식이 올바르지 않을 때
*/
export async function getHistory(uid: string, date: string): Promise<VoteHistoryDoc> {
let parsed;
try {
parsed = parseDateString(date);
} catch (err) {
throw new HttpError(400, (err as Error).message);
}
const doc = await getDay(uid, parsed);
return doc ?? {data: []};
}