- Firestore Timestamp가 res.json으로 그대로 나가 {_seconds,_nanoseconds}로 직렬화되던 문제 수정
- 앱의 포인트 내역(/reward/ledger)과 예측 기록(/stats/history) 크래시 원인 제거
- 클라 소비 7개 핸들러(reward/attendance/prediction/user/stats/kbo/chat)의 모든 응답에 명시적 DTO 타입 도입
- 날짜 와이어 형식을 순수 UTC ISO 8601(...Z)로 통일 — 채팅의 기존 KST(+09:00) 출력도 UTC로 전환
- DTO는 필드를 명시적으로 나열해 조립 (스프레드 덤프 제거) — 문서에 새 Timestamp 필드가 생겨도 다시 새지 않는다
- 재사용되는 주문 형태만 toOrderDto로 분리, 나머지는 응답 경계에서 직접 조립
- Firestore 저장 형식은 변경하지 않음. 출석 idempotent 재요청 경로는 저장된 Timestamp/문자열을 모두 처리
- DTO 직렬화 회귀 테스트 추가 (_seconds 부재, UTC ISO 형식, optional 키 생략, 미지 필드 차단)
304 lines
11 KiB
TypeScript
304 lines
11 KiB
TypeScript
import {rtdb} from "../firebase";
|
|
import {HttpError} from "../middleware/errors";
|
|
import {computeLevel} from "../constants/levels";
|
|
import {tierOf} from "../constants/tiers";
|
|
import {getAll, getDay} from "../repositories/voteHistoryRepository";
|
|
import {getUser} from "../repositories/userRepository";
|
|
import {hasMissedGameDayBetween} from "./judgmentService";
|
|
import type {DailyJudgment, StatsResponse, VoteHistoryDoc} from "../types/panit";
|
|
import {EMPTY_VOTE_HISTORY_DTO, toVoteHistoryDto} from "../types/dto/statsDto";
|
|
import type {UserStatsDto, VoteHistoryDto} from "../types/dto/statsDto";
|
|
import {
|
|
addDays,
|
|
parseDateString,
|
|
toDateString,
|
|
startOfDayKst,
|
|
todayKst,
|
|
tuesdayOf as tuesdayOfUtil,
|
|
type DateString,
|
|
} from "../types/dateString";
|
|
|
|
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) {
|
|
// 취소 경기 무효표(result 없음)는 승률·예측 수 집계에서 제외.
|
|
if (typeof v.result !== "boolean") continue;
|
|
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 = addDays(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 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,
|
|
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<UserStatsDto> {
|
|
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 normalizeWeeklyResults(val);
|
|
}
|
|
|
|
const stats = await computeStats(uid, period);
|
|
await rtdb.ref(cachePath(uid, key)).set(stats);
|
|
return normalizeWeeklyResults(stats);
|
|
}
|
|
|
|
// RTDB는 sparse array를 객체로 반환하거나 trailing null을 잘라낸다.
|
|
// 응답 직전에 항상 길이 7 배열로 정규화한다.
|
|
function normalizeWeeklyResults(stats: StatsResponse): StatsResponse {
|
|
const w = stats.weeklyResults as unknown as Record<number, DailyJudgment | null> | Array<DailyJudgment | null> | null | undefined;
|
|
const normalized: Array<DailyJudgment | null> = Array.from({length: 7}, (_, i) => w?.[i] ?? null);
|
|
return {...stats, weeklyResults: normalized};
|
|
}
|
|
|
|
/**
|
|
* 유저의 통계 캐시를 모두 삭제한다.
|
|
* 경기 결과 확정 등으로 통계가 변경되었을 때 호출한다.
|
|
*
|
|
* @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<VoteHistoryDto> {
|
|
let parsed;
|
|
try {
|
|
parsed = parseDateString(date);
|
|
} catch (err) {
|
|
throw new HttpError(400, (err as Error).message);
|
|
}
|
|
const doc = await getDay(uid, parsed);
|
|
if (!doc) return EMPTY_VOTE_HISTORY_DTO;
|
|
return toVoteHistoryDto(doc);
|
|
}
|