mmday-firebase/src/services/judgmentService.ts
윤정민 c964572a9c Replace point ledger with wallet engine and rework reward earning
- 지갑 문서(users/{uid}/wallet/current)와 문서 ID=멱등키 원장(pointLedger/{txId})으로 포인트 엔진 교체 — 기존 balanceAfter 최신 row 조회 방식 폐기
- 모든 포인트 변경은 pointService.applyPointChangesTx 단일 경로로 처리, available+reserved == totalEarned-totalSpent 불변식을 매 커밋 검증
- 출석 리워드 개편: 일일 20P, 연속 5일 +50P(사이클당 1회), 10일 단위 +100P — attendance/state 문서에 스트릭 상태 저장, 주간·월간 보너스 폐기
- 승부예측 일일 리워드 정산 신설: 전체 참여 50P + 성공 100P + 퍼펙트 50P, judgeDay 이후 voteHistory.rewardSettledAt 플래그와 원장 멱등키로 배치 재실행에도 중복 지급 차단
- 관리자 포인트 지급·회수(adminPointService)와 수동 재정산 디버그 라우트(/debug/settle-reward) 추가
- 소비처 없던 티켓 시스템(dailyAllKill·weeklyMaster)과 위클리마스터 판정 흐름 전체 제거 — StatsResponse.tickets 필드 삭제로 클라 응답 스키마 변경
2026-07-16 14:25:42 +09:00

127 lines
5.1 KiB
TypeScript

import { logger } from "firebase-functions";
import {
createGameDayCache,
type GameDayCache,
} from "../repositories/gameRepository";
import { getDay, setDay } from "../repositories/voteHistoryRepository";
import {
applyDailyJudgmentTx,
getUser,
} from "../repositories/userRepository";
import {
judgeByCounts,
streakBonus,
thresholdsFor,
} from "../constants/judgment";
import type { DailyJudgment, RankSnapshot, VoteHistoryDoc } from "../types/panit";
import { addDays, type DateString } from "../types/dateString";
import { settleDailyReward } from "./rewardSettlementService";
/**
* `(lastJudged, upTo)` 구간(양 끝 제외)에 **실제 판정 가능한 경기일**(=`thresholdsFor`가
* `"skip"`이 아닌 날)이 한 번이라도 있었는지 검사한다. 있으면 그 날을 결석한 것이므로
* streak이 끊겼다고 본다. 모두 skip이거나 해당 구간이 비어있으면 끊기지 않음.
*
* KBO 휴장일(월요일, 우천 전체 취소, 올스타브레이크 등)을 자연스럽게 무시한다.
*
* 비용: 구간 일수만큼 `games` 컬렉션 read. 14일까지만 거슬러 올라가고 그 이상이면
* 결석으로 간주한다(현실적으로 14일 연속 휴장은 없음).
*/
export async function hasMissedGameDayBetween(
lastJudged: DateString,
upTo: DateString,
gameCache?: GameDayCache
): Promise<boolean> {
const fetch = gameCache ?? createGameDayCache();
const MAX_LOOKBACK = 14;
let cursor = addDays(upTo, -1);
for (let i = 0; i < MAX_LOOKBACK && cursor > lastJudged; i++) {
const games = await fetch.listByDate(cursor);
const completed = games.filter((g) => g.status === "completed").length;
if (thresholdsFor(completed) !== "skip") return true;
cursor = addDays(cursor, -1);
}
// MAX_LOOKBACK 초과로 종료된 경우엔 안전을 위해 결석으로 본다.
return cursor > lastJudged;
}
/**
* 지정 날짜의 투표 이력을 판정하고 결과를 voteHistory와 user doc에 반영한다.
*
* - `dailyArchive`가 해당 날짜의 voteHistory 도큐먼트를 기록한 이후에 호출한다.
* - 전부 취소되어 투표 데이터가 비어있는 날에도 skip 판정을 남기기 위해 호출 가능.
*
* @param uid - 유저 ID
* @param date - 판정 대상 날짜 (KST)
* @param voteDoc - 해당 날짜의 voteHistory 도큐먼트 내용(판정 필드 미포함)
* @param opts.gameCache - 여러 유저를 처리할 때 날짜별 games read를 공유하는 캐시
* @param opts.rankSnapshot - 판정 직전 rank 스냅샷. 제공 시 판정 트랜잭션에 함께 기록한다.
*/
export async function judgeDay(
uid: string,
date: DateString,
voteDoc: VoteHistoryDoc,
opts?: { gameCache?: GameDayCache; rankSnapshot?: RankSnapshot | null }
): Promise<void> {
const fetch = opts?.gameCache ?? createGameDayCache();
const games = await fetch.listByDate(date);
const completedCount = games.filter((g) => g.status === "completed").length;
const threshold = thresholdsFor(completedCount);
let judgment: DailyJudgment;
let correctCount: number;
if (threshold === "skip") {
judgment = "skip";
correctCount = 0;
} else {
correctCount = voteDoc.data.filter((v) => v.result).length;
judgment = judgeByCounts(correctCount, threshold);
}
// 결석 여부는 휴장일을 제외하고 판단한다. (lastJudged, date) 구간에 실제
// 경기일이 하나라도 있었는데 user가 그날 안 했으면 streak 끊김.
const userPre = await getUser(uid);
const lastJudgedPre = userPre?.lastJudgedDate;
const streakBrokenIn =
lastJudgedPre != null &&
lastJudgedPre < addDays(date, -1) &&
(await hasMissedGameDayBetween(lastJudgedPre, date, fetch));
const tx = await applyDailyJudgmentTx(uid, date, {
judgment,
correctCount,
completedCount,
streakBrokenIn,
rankSnapshot: opts?.rankSnapshot ?? undefined,
computePoints: (streakAfter) => correctCount * 10 + streakBonus(streakAfter),
});
if (tx.skippedByGuard) {
logger.info(`judgeDay: ${uid} ${date} already judged, skipping`);
// 복구 경로: 앞선 run에서 트랜잭션은 커밋됐지만(=lastJudgedDate 갱신) voteHistory
// 기록 직전에 크래시한 경우 doc이 비어있을 수 있다. 그 때만 data를 복원한다.
// 정상 멱등 재실행에서는 doc이 이미 존재하므로 판정 필드를 덮어쓰지 않는다.
const existing = await getDay(uid, date);
if (!existing) await setDay(uid, date, voteDoc);
await settleDailyReward(uid, date).catch((err) => logger.error(`reward settlement failed uid=${uid} date=${date}`, err));
return;
}
await setDay(uid, date, {
...voteDoc,
judgment,
correctCount,
completedCount,
streakAfter: tx.streakAfter,
});
await settleDailyReward(uid, date).catch((err) => logger.error(`reward settlement failed uid=${uid} date=${date}`, err));
}
/**
* 일요일 아카이브 직후 호출되어, 해당 주(화~일) 6일의 판정이 모두
* `success|perfect|skip` 이면 주간 마스터 티켓을 지급한다.
*
* @param uid - 유저 ID
* @param sundayDate - 방금 아카이브/판정이 끝난 일요일 날짜
*/