- 지갑 문서(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 필드 삭제로 클라 응답 스키마 변경
127 lines
5.1 KiB
TypeScript
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 - 방금 아카이브/판정이 끝난 일요일 날짜
|
|
*/
|