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 필드 삭제로 클라 응답 스키마 변경
This commit is contained in:
parent
9daba193e4
commit
c964572a9c
6
src/constants/points.ts
Normal file
6
src/constants/points.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export const ATTENDANCE_DAILY_POINTS = 20;
|
||||
export const ATTENDANCE_STREAK_5_POINTS = 50;
|
||||
export const ATTENDANCE_STREAK_10_INTERVAL_POINTS = 100;
|
||||
export const PREDICTION_PARTICIPATION_POINTS = 50;
|
||||
export const PREDICTION_SUCCESS_POINTS = 100;
|
||||
export const PREDICTION_PERFECT_BONUS_POINTS = 50;
|
||||
@ -6,6 +6,7 @@ import { runChatToolsSheet, renderSheetMarkdown } from "../services/chatToolsShe
|
||||
import { runChatProbe, runChatProbeBatch, runChatProbeConversation, renderProbeMarkdown } from "../services/chatProbeService";
|
||||
import { sendError } from "../middleware/errors";
|
||||
import { daysAgoKst, parseDateString } from "../types/dateString";
|
||||
import { settleDailyReward } from "../services/rewardSettlementService";
|
||||
|
||||
/**
|
||||
* 임시 디버그 핸들러. 인증 없음 — 운영 안정화 후 제거할 것.
|
||||
@ -29,6 +30,12 @@ export const debug = onRequest({ timeoutSeconds: 300 }, async (req, res) => {
|
||||
const tail = segs.slice(-1)[0];
|
||||
|
||||
try {
|
||||
if (tail === "settle-reward" && req.method === "POST") {
|
||||
const { uid, date } = req.body ?? {};
|
||||
if (typeof uid !== "string" || typeof date !== "string") { res.status(400).json({ error: "uid and date are required" }); return; }
|
||||
res.status(200).json(await settleDailyReward(uid, parseDateString(date)));
|
||||
return;
|
||||
}
|
||||
if (tail === "forceSync") {
|
||||
const dateParam =
|
||||
(typeof req.query.date === "string" && req.query.date) ||
|
||||
|
||||
@ -3,7 +3,7 @@ import {
|
||||
type Transaction,
|
||||
} from "firebase-admin/firestore";
|
||||
import { firestore } from "../firebase";
|
||||
import type { AttendanceMonthDoc } from "../types/panit";
|
||||
import type { AttendanceMonthDoc, AttendanceStateDoc } from "../types/panit";
|
||||
|
||||
const USERS = "users";
|
||||
const ATTENDANCE = "attendance";
|
||||
@ -28,3 +28,7 @@ export async function getMonthDocTx(
|
||||
const snap = await tx.get(monthDocRef(uid, month));
|
||||
return snap.exists ? (snap.data() as AttendanceMonthDoc) : null;
|
||||
}
|
||||
|
||||
export function stateDocRef(uid: string): DocumentReference { return firestore.doc(`users/${uid}/attendance/state`); }
|
||||
export async function getStateDoc(uid: string): Promise<AttendanceStateDoc | null> { const s = await stateDocRef(uid).get(); return s.exists ? s.data() as AttendanceStateDoc : null; }
|
||||
export async function getStateDocTx(tx: Transaction, uid: string): Promise<AttendanceStateDoc | null> { const s = await tx.get(stateDocRef(uid)); return s.exists ? s.data() as AttendanceStateDoc : null; }
|
||||
|
||||
@ -1,78 +1,12 @@
|
||||
import {
|
||||
FieldValue,
|
||||
type DocumentReference,
|
||||
type Transaction,
|
||||
} from "firebase-admin/firestore";
|
||||
import type { Transaction } from "firebase-admin/firestore";
|
||||
import { firestore } from "../firebase";
|
||||
import type { PointLedgerEntry, PointLedgerType } from "../types/panit";
|
||||
import type { PointLedgerEntry } from "../types/points";
|
||||
|
||||
const USERS = "users";
|
||||
const LEDGER = "pointLedger";
|
||||
|
||||
function ledgerCollection(uid: string) {
|
||||
return firestore.collection(USERS).doc(uid).collection(LEDGER);
|
||||
}
|
||||
|
||||
/**
|
||||
* 가장 최근 ledger row의 `balanceAfter`. 없으면 0.
|
||||
*
|
||||
* 정렬은 `createdAt desc, seq desc` — 같은 트랜잭션 내 다중 row가 동일
|
||||
* `serverTimestamp()`를 갖는 경우에도 `seq`로 안정 정렬한다.
|
||||
*/
|
||||
export async function getLatestBalance(uid: string): Promise<number> {
|
||||
const snap = await ledgerCollection(uid)
|
||||
.orderBy("createdAt", "desc")
|
||||
.orderBy("seq", "desc")
|
||||
.limit(1)
|
||||
.get();
|
||||
if (snap.empty) return 0;
|
||||
return (snap.docs[0].data() as PointLedgerEntry).balanceAfter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 트랜잭션 컨텍스트의 잔액 조회. `tx.get(query)` 사용.
|
||||
*/
|
||||
export async function getLatestBalanceTx(
|
||||
tx: Transaction,
|
||||
uid: string
|
||||
): Promise<number> {
|
||||
const query = ledgerCollection(uid)
|
||||
.orderBy("createdAt", "desc")
|
||||
.orderBy("seq", "desc")
|
||||
.limit(1);
|
||||
const snap = await tx.get(query);
|
||||
if (snap.empty) return 0;
|
||||
return (snap.docs[0].data() as PointLedgerEntry).balanceAfter;
|
||||
}
|
||||
|
||||
export interface NewLedgerEntry {
|
||||
type: PointLedgerType;
|
||||
amount: number;
|
||||
balanceAfter: number;
|
||||
seq: number;
|
||||
refMonth?: string;
|
||||
refDay?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 트랜잭션 안에서 새 ledger row를 생성한다. autoId는 미리 할당하므로
|
||||
* 호출 순서와 무관하게 각 row가 고유 doc을 갖는다.
|
||||
*/
|
||||
export function createLedgerEntryTx(
|
||||
tx: Transaction,
|
||||
uid: string,
|
||||
entry: NewLedgerEntry
|
||||
): DocumentReference {
|
||||
const ref = ledgerCollection(uid).doc();
|
||||
const data: Record<string, unknown> = {
|
||||
type: entry.type,
|
||||
amount: entry.amount,
|
||||
balanceAfter: entry.balanceAfter,
|
||||
seq: entry.seq,
|
||||
createdAt: FieldValue.serverTimestamp(),
|
||||
};
|
||||
if (entry.refMonth !== undefined) data.refMonth = entry.refMonth;
|
||||
if (entry.refDay !== undefined) data.refDay = entry.refDay;
|
||||
tx.create(ref, data);
|
||||
return ref;
|
||||
function col(uid: string) { return firestore.collection(`users/${uid}/pointLedger`); }
|
||||
export function createLedgerEntryTx(tx: Transaction, uid: string, entry: PointLedgerEntry) { const ref = col(uid).doc(entry.txId); tx.create(ref, entry); return ref; }
|
||||
export async function listLedger(uid: string, limit = 20, cursor?: string) {
|
||||
let q = col(uid).orderBy("createdAt", "desc").limit(Math.min(Math.max(limit, 1), 100));
|
||||
if (cursor) { const snap = await col(uid).doc(cursor).get(); if (snap.exists) q = q.startAfter(snap) as typeof q; }
|
||||
const snap = await q.get();
|
||||
return { items: snap.docs.map((d) => ({ id: d.id, ...(d.data() as PointLedgerEntry) })), cursor: snap.docs.length ? snap.docs[snap.docs.length - 1].id : null };
|
||||
}
|
||||
|
||||
@ -20,7 +20,6 @@ export interface RegisterInput {
|
||||
favoriteTeamCode?: TeamCode;
|
||||
knowledgeLevel: KnowledgeLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 유저 문서를 조회한다.
|
||||
*
|
||||
@ -243,7 +242,6 @@ export interface DailyJudgmentResult {
|
||||
* - perfect/success → 스트릭 +1, 하이스트릭 갱신, 티어 포인트 가산
|
||||
* - fail → 스트릭 0 (포인트 감점 없음)
|
||||
* - skip → 스트릭 불변, 포인트 불변
|
||||
* - perfect 시 `tickets.dailyAllKill` +1
|
||||
*
|
||||
* `computePoints`는 판정 후 스트릭(streakAfter)을 인자로 받아 해당 판정으로
|
||||
* 획득할 포인트를 반환한다. `fail|skip`일 땐 호출되지 않는다.
|
||||
@ -290,17 +288,12 @@ export async function applyDailyJudgmentTx(
|
||||
const currentStreak = input.streakBrokenIn ? 0 : user.currentStreak ?? 0;
|
||||
const highestStreak = user.highestStreak ?? 0;
|
||||
const tierPoints = user.tierPoints ?? 0;
|
||||
const dailyAllKill = user.tickets?.dailyAllKill ?? 0;
|
||||
const weeklyMaster = user.tickets?.weeklyMaster ?? 0;
|
||||
|
||||
let nextStreak = currentStreak;
|
||||
let nextPoints = tierPoints;
|
||||
let nextDailyAllKill = dailyAllKill;
|
||||
|
||||
if (input.judgment === "perfect" || input.judgment === "success") {
|
||||
nextStreak = currentStreak + 1;
|
||||
nextPoints = tierPoints + input.computePoints(nextStreak);
|
||||
if (input.judgment === "perfect") nextDailyAllKill = dailyAllKill + 1;
|
||||
} else if (input.judgment === "fail") {
|
||||
nextStreak = 0;
|
||||
}
|
||||
@ -310,10 +303,6 @@ export async function applyDailyJudgmentTx(
|
||||
currentStreak: nextStreak,
|
||||
highestStreak: Math.max(highestStreak, nextStreak),
|
||||
tierPoints: nextPoints,
|
||||
tickets: {
|
||||
dailyAllKill: nextDailyAllKill,
|
||||
weeklyMaster,
|
||||
},
|
||||
lastJudgedDate: date,
|
||||
};
|
||||
// 판정 전 rank 스냅샷을 같은 patch에 합쳐 user doc write를 1회로 줄인다.
|
||||
@ -329,37 +318,3 @@ export async function applyDailyJudgmentTx(
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 주간 마스터 티켓을 지급한다. 같은 화요일 키로 이미 지급됐다면 no-op.
|
||||
*
|
||||
* @returns 실제로 지급됐으면 `true`, 멱등 가드에 막혔으면 `false`.
|
||||
*/
|
||||
export async function applyWeeklyMasterTx(
|
||||
uid: string,
|
||||
tuesday: DateString
|
||||
): Promise<boolean> {
|
||||
const ref = firestore.collection(COLLECTION).doc(uid);
|
||||
return firestore.runTransaction(async (tx) => {
|
||||
const snap = await tx.get(ref);
|
||||
const user = (snap.data() ?? {}) as Partial<User>;
|
||||
|
||||
if (user.lastWeeklyMasterTuesday === tuesday) return false;
|
||||
|
||||
const dailyAllKill = user.tickets?.dailyAllKill ?? 0;
|
||||
const weeklyMaster = user.tickets?.weeklyMaster ?? 0;
|
||||
|
||||
tx.set(
|
||||
ref,
|
||||
{
|
||||
tickets: {
|
||||
dailyAllKill,
|
||||
weeklyMaster: weeklyMaster + 1,
|
||||
},
|
||||
lastWeeklyMasterTuesday: tuesday,
|
||||
},
|
||||
{ merge: true }
|
||||
);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
8
src/repositories/walletRepository.ts
Normal file
8
src/repositories/walletRepository.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import type { Transaction } from "firebase-admin/firestore";
|
||||
import { firestore } from "../firebase";
|
||||
import type { WalletDoc } from "../types/points";
|
||||
|
||||
export function walletDocRef(uid: string) { return firestore.doc(`users/${uid}/wallet/current`); }
|
||||
export async function getWallet(uid: string): Promise<WalletDoc | null> { const s = await walletDocRef(uid).get(); return s.exists ? s.data() as WalletDoc : null; }
|
||||
export async function getWalletTx(tx: Transaction, uid: string): Promise<WalletDoc | null> { const s = await tx.get(walletDocRef(uid)); return s.exists ? s.data() as WalletDoc : null; }
|
||||
export async function getAvailableBalance(uid: string): Promise<number> { return (await getWallet(uid))?.availableBalance ?? 0; }
|
||||
@ -3,7 +3,7 @@ import { logger } from "firebase-functions";
|
||||
import { rtdb } from "../firebase";
|
||||
import { setDay } from "../repositories/voteHistoryRepository";
|
||||
import { invalidateStats } from "../services/statsService";
|
||||
import { judgeDay, judgeWeekIfNeeded } from "../services/judgmentService";
|
||||
import { judgeDay } from "../services/judgmentService";
|
||||
import {
|
||||
precomputeScoreboardCache,
|
||||
computeRankSnapshot,
|
||||
@ -13,7 +13,6 @@ import { getGame, createGameDayCache } from "../repositories/gameRepository";
|
||||
import { processGameEndWithGame } from "../services/gameResultService";
|
||||
import { DRAW_TEAM_CODE, type RankSnapshot, type VoteHistoryDoc } from "../types/panit";
|
||||
import {
|
||||
dayOfWeek,
|
||||
daysAgoKst,
|
||||
type DateString,
|
||||
} from "../types/dateString";
|
||||
@ -162,17 +161,6 @@ export async function runDailyArchive(
|
||||
archived += 1;
|
||||
}
|
||||
|
||||
// 일요일(dow=0) 아카이브/판정이 끝난 시점에 주간 마스터 티켓 지급 판단.
|
||||
if (dayOfWeek(date) === 0) {
|
||||
for (const uid of judgedUids) {
|
||||
try {
|
||||
await judgeWeekIfNeeded(uid, date);
|
||||
} catch (err) {
|
||||
logger.error(`judgeWeek failed uid=${uid} date=${date}`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`dailyArchive done: ${archived} users archived for ${date}`);
|
||||
return { date, archived, judgedUids };
|
||||
} finally {
|
||||
|
||||
5
src/services/adminPointService.ts
Normal file
5
src/services/adminPointService.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { firestore } from "../firebase";
|
||||
import { HttpError } from "../middleware/errors";
|
||||
import { PointLedgerType } from "../types/points";
|
||||
import { applyPointChangesTx } from "./pointService";
|
||||
export async function adminPointChange(uid: string, amount: number, kind: "credit" | "debit", clientIdempotencyKey: string, reason: string, actor: string, reversalOf?: string) { if (!/^[A-Za-z0-9_-]{1,100}$/.test(clientIdempotencyKey) || !Number.isSafeInteger(amount) || amount <= 0 || typeof reason !== "string" || reason.length < 1 || reason.length > 500) throw new HttpError(400, "invalid admin point change", "INVALID_INPUT"); return firestore.runTransaction((tx) => applyPointChangesTx(tx, uid, [{ txId: `${uid}:admin:${clientIdempotencyKey}`, type: kind === "credit" ? PointLedgerType.AdminCredit : PointLedgerType.AdminDebit, amount, adminReason: reason, adminActor: actor, reversalOf }])); }
|
||||
@ -1,274 +1,39 @@
|
||||
import type { DecodedIdToken } from "firebase-admin/auth";
|
||||
import { Timestamp } from "firebase-admin/firestore";
|
||||
import { firestore } from "../firebase";
|
||||
import { ATTENDANCE_DAILY_POINTS, ATTENDANCE_STREAK_10_INTERVAL_POINTS, ATTENDANCE_STREAK_5_POINTS } from "../constants/points";
|
||||
import { HttpError } from "../middleware/errors";
|
||||
import {
|
||||
AttendanceResult,
|
||||
PointLedgerType,
|
||||
type AttendanceCheckInResult,
|
||||
type AttendanceMonth,
|
||||
type AttendanceMonthDoc,
|
||||
type PointAward,
|
||||
} from "../types/panit";
|
||||
import {
|
||||
dayOfWeek,
|
||||
isMonthString,
|
||||
lastDayOfMonth,
|
||||
monthOf,
|
||||
toDateString,
|
||||
} from "../types/dateString";
|
||||
import {
|
||||
getMonthDoc,
|
||||
getMonthDocTx,
|
||||
monthDocRef,
|
||||
} from "../repositories/attendanceRepository";
|
||||
import {
|
||||
createLedgerEntryTx,
|
||||
getLatestBalance,
|
||||
getLatestBalanceTx,
|
||||
} from "../repositories/pointLedgerRepository";
|
||||
import { getMonthDoc, getMonthDocTx, getStateDocTx, monthDocRef, stateDocRef } from "../repositories/attendanceRepository";
|
||||
import { getAvailableBalance, getWalletTx } from "../repositories/walletRepository";
|
||||
import { applyPointChangesTx } from "./pointService";
|
||||
import { AttendanceResult, PointLedgerType, type AttendanceCheckInResult, type AttendanceMonth, type AttendanceStateDoc, type PointAward } from "../types/panit";
|
||||
import { addDays, isMonthString, monthOf, toDateString } from "../types/dateString";
|
||||
|
||||
const SKEW_TOLERANCE_MS = 5 * 60 * 1000;
|
||||
const DAILY_AMOUNT = 10;
|
||||
const WEEKLY_BONUS_AMOUNT = 50;
|
||||
const MONTHLY_BONUS_AMOUNT = 100;
|
||||
|
||||
export interface CheckInBody {
|
||||
clientAttemptedAt?: unknown;
|
||||
clientIdempotencyKey?: unknown;
|
||||
}
|
||||
|
||||
// ISO 8601 끝부분의 timezone 표기. `Z`(UTC) 또는 `±HH:MM` / `±HHMM` 둘 다 허용.
|
||||
// TZ 표기 누락 시 서버 파싱이 UTC로 폴백되어 +9h 어긋남이 발생하므로 강제한다.
|
||||
const TZ_SUFFIX_PATTERN = /(Z|[+-]\d{2}:?\d{2})$/;
|
||||
|
||||
/**
|
||||
* 클라가 보낸 ISO 8601 문자열을 절대 시각 Date로 변환한다.
|
||||
* 반드시 timezone 표기(`Z` 또는 `±HH:MM`)가 포함돼야 한다 — 그래야
|
||||
* 서버가 정확한 instant로 해석할 수 있다.
|
||||
*
|
||||
* Flutter에서는 `DateTime.now().toUtc().toIso8601String()`을 권장
|
||||
* (자동으로 `Z` 접미사가 붙음).
|
||||
*/
|
||||
function parseClientAttemptedAt(value: unknown): Date {
|
||||
if (typeof value !== "string") {
|
||||
throw new HttpError(
|
||||
400,
|
||||
"clientAttemptedAt must be ISO 8601 string",
|
||||
"INVALID_INPUT"
|
||||
);
|
||||
}
|
||||
if (!TZ_SUFFIX_PATTERN.test(value)) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
"clientAttemptedAt must include timezone (e.g. 'Z' or '+09:00')",
|
||||
"INVALID_INPUT"
|
||||
);
|
||||
}
|
||||
const ms = Date.parse(value);
|
||||
if (Number.isNaN(ms)) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
"clientAttemptedAt is not a valid ISO 8601 date",
|
||||
"INVALID_INPUT"
|
||||
);
|
||||
}
|
||||
return new Date(ms);
|
||||
}
|
||||
|
||||
function parseIdempotencyKey(value: unknown): string {
|
||||
if (typeof value !== "string" || value.length === 0 || value.length > 128) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
"clientIdempotencyKey must be a non-empty string ≤128 chars",
|
||||
"INVALID_INPUT"
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 일요일 출석 시점에 호출. 같은 ISO 주의 월~토 6일이 모두 출석되었는지 검사.
|
||||
* 일요일 자체는 호출자(Sunday 게이트)가 이미 보장하므로 검사하지 않는다.
|
||||
*/
|
||||
function isPriorSixDaysAttended(days: number[], sundayDay: number): boolean {
|
||||
if (sundayDay < 7) return false; // 그 달 첫 주: 월~토가 이전 달이라 불가능
|
||||
const set = new Set(days);
|
||||
for (let d = sundayDay - 6; d < sundayDay; d++) {
|
||||
if (!set.has(d)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function sortedInsert(days: number[], day: number): number[] {
|
||||
const next = [...days, day];
|
||||
next.sort((a, b) => a - b);
|
||||
return next;
|
||||
}
|
||||
|
||||
export async function checkIn(
|
||||
token: DecodedIdToken,
|
||||
body: CheckInBody
|
||||
): Promise<AttendanceCheckInResult> {
|
||||
const clientAttemptedAt = parseClientAttemptedAt(body.clientAttemptedAt);
|
||||
const idempotencyKey = parseIdempotencyKey(body.clientIdempotencyKey);
|
||||
|
||||
const serverNowDate = new Date();
|
||||
const skewMs = clientAttemptedAt.getTime() - serverNowDate.getTime();
|
||||
if (Math.abs(skewMs) > SKEW_TOLERANCE_MS) {
|
||||
throw new HttpError(
|
||||
409,
|
||||
"client clock differs from server by more than 5 minutes",
|
||||
"CLOCK_SKEW",
|
||||
{
|
||||
serverNow: Timestamp.fromDate(serverNowDate),
|
||||
clientAttemptedAt: Timestamp.fromDate(clientAttemptedAt),
|
||||
skewMs,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const today = toDateString(serverNowDate);
|
||||
const month = monthOf(today);
|
||||
const todayDay = Number(today.slice(8, 10));
|
||||
|
||||
const monthRef = monthDocRef(token.uid, month);
|
||||
const SKEW = 5 * 60 * 1000; const TZ = /(Z|[+-]\d{2}:?\d{2})$/;
|
||||
export interface CheckInBody { clientAttemptedAt?: unknown; clientIdempotencyKey?: unknown }
|
||||
function attempted(v: unknown) { if (typeof v !== "string" || !TZ.test(v) || Number.isNaN(Date.parse(v))) throw new HttpError(400, "invalid clientAttemptedAt", "INVALID_INPUT"); return new Date(v); }
|
||||
function key(v: unknown) { if (typeof v !== "string" || v.length < 1 || v.length > 128) throw new HttpError(400, "invalid clientIdempotencyKey", "INVALID_INPUT"); return v; }
|
||||
function insert(days: number[], day: number) { return [...days, day].sort((a, b) => a - b); }
|
||||
|
||||
export async function checkIn(token: DecodedIdToken, body: CheckInBody): Promise<AttendanceCheckInResult> {
|
||||
const clientAt = attempted(body.clientAttemptedAt); const idem = key(body.clientIdempotencyKey); const nowDate = new Date();
|
||||
if (Math.abs(clientAt.getTime() - nowDate.getTime()) > SKEW) throw new HttpError(409, "client clock differs from server by more than 5 minutes", "CLOCK_SKEW", { serverNow: Timestamp.fromDate(nowDate) });
|
||||
const today = toDateString(nowDate); const month = monthOf(today); const day = Number(today.slice(8));
|
||||
return firestore.runTransaction(async (tx) => {
|
||||
// ── 읽기 단계 (모든 read는 write 전에) ──
|
||||
const monthDoc = await getMonthDocTx(tx, token.uid, month);
|
||||
const currentBalance = await getLatestBalanceTx(tx, token.uid);
|
||||
|
||||
// ── 멱등 가드: 같은 idempotencyKey 재호출 → 직전 결과 그대로 ──
|
||||
if (
|
||||
monthDoc?.lastIdempotencyKey === idempotencyKey &&
|
||||
monthDoc.lastResult
|
||||
) {
|
||||
return monthDoc.lastResult;
|
||||
}
|
||||
|
||||
const monthDoc = await getMonthDocTx(tx, token.uid, month); const state = await getStateDocTx(tx, token.uid);
|
||||
if (monthDoc?.lastIdempotencyKey === idem && monthDoc.lastResult) return monthDoc.lastResult;
|
||||
const days = monthDoc?.days ?? [];
|
||||
|
||||
// ── 이미 출석 가드: 같은 날 다른 키 → alreadyCheckedIn ──
|
||||
if (days.includes(todayDay)) {
|
||||
const result: AttendanceCheckInResult = {
|
||||
result: AttendanceResult.AlreadyCheckedIn,
|
||||
serverNow: Timestamp.fromDate(serverNowDate),
|
||||
attendedDays: days,
|
||||
totalCount: days.length,
|
||||
pointsAwarded: [],
|
||||
balanceAfter: currentBalance,
|
||||
};
|
||||
tx.set(
|
||||
monthRef,
|
||||
{
|
||||
lastIdempotencyKey: idempotencyKey,
|
||||
lastResult: result,
|
||||
},
|
||||
{ merge: true }
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── 신규 출석 처리 ──
|
||||
const newDays = sortedInsert(days, todayDay);
|
||||
const awards: PointAward[] = [];
|
||||
let runningBalance = currentBalance;
|
||||
let seq = 0;
|
||||
|
||||
// 1) daily +10
|
||||
runningBalance += DAILY_AMOUNT;
|
||||
awards.push({ type: PointLedgerType.AttendanceDaily, amount: DAILY_AMOUNT });
|
||||
createLedgerEntryTx(tx, token.uid, {
|
||||
type: PointLedgerType.AttendanceDaily,
|
||||
amount: DAILY_AMOUNT,
|
||||
balanceAfter: runningBalance,
|
||||
seq: seq++,
|
||||
refMonth: month,
|
||||
refDay: todayDay,
|
||||
});
|
||||
|
||||
// 2) weekly_bonus: 출석일이 일요일 + 그 주 월~토 모두 출석
|
||||
if (
|
||||
dayOfWeek(today) === 0 &&
|
||||
isPriorSixDaysAttended(newDays, todayDay)
|
||||
) {
|
||||
runningBalance += WEEKLY_BONUS_AMOUNT;
|
||||
awards.push({
|
||||
type: PointLedgerType.AttendanceWeeklyBonus,
|
||||
amount: WEEKLY_BONUS_AMOUNT,
|
||||
});
|
||||
createLedgerEntryTx(tx, token.uid, {
|
||||
type: PointLedgerType.AttendanceWeeklyBonus,
|
||||
amount: WEEKLY_BONUS_AMOUNT,
|
||||
balanceAfter: runningBalance,
|
||||
seq: seq++,
|
||||
refMonth: month,
|
||||
refDay: todayDay,
|
||||
if (days.includes(day)) { const wallet = await getWalletTx(tx, token.uid); return { result: AttendanceResult.AlreadyCheckedIn, serverNow: Timestamp.fromDate(nowDate), attendedDays: days, totalCount: days.length, pointsAwarded: [], balanceAfter: wallet?.availableBalance ?? 0, attendanceStreak: state?.currentAttendanceStreak ?? 0 }; }
|
||||
const consecutive = state && addDays(state.lastAttendanceDate, 1) === today;
|
||||
const streak = consecutive ? state.currentAttendanceStreak + 1 : 1; const cycle = consecutive ? state.streakCycleStart : today;
|
||||
const awards: PointAward[] = [{ type: PointLedgerType.AttendanceDaily, amount: ATTENDANCE_DAILY_POINTS }];
|
||||
const changes = [{ txId: `${token.uid}:${today}:attendance_daily`, type: PointLedgerType.AttendanceDaily, amount: ATTENDANCE_DAILY_POINTS, relatedDate: today }];
|
||||
if (streak === 5) { awards.push({ type: PointLedgerType.AttendanceStreak5, amount: ATTENDANCE_STREAK_5_POINTS }); changes.push({ txId: `${token.uid}:${cycle}:attendance_streak_5`, type: PointLedgerType.AttendanceStreak5, amount: ATTENDANCE_STREAK_5_POINTS, relatedDate: today }); }
|
||||
if (streak % 10 === 0) { awards.push({ type: PointLedgerType.AttendanceStreak10Interval, amount: ATTENDANCE_STREAK_10_INTERVAL_POINTS }); changes.push({ txId: `${token.uid}:${cycle}:${streak}:attendance_streak_10_interval`, type: PointLedgerType.AttendanceStreak10Interval, amount: ATTENDANCE_STREAK_10_INTERVAL_POINTS, relatedDate: today }); }
|
||||
const wallet = await applyPointChangesTx(tx, token.uid, changes); const newDays = insert(days, day);
|
||||
const result: AttendanceCheckInResult = { result: AttendanceResult.CheckedIn, serverNow: Timestamp.fromDate(nowDate), attendedDays: newDays, totalCount: newDays.length, pointsAwarded: awards, balanceAfter: wallet!.availableBalance, attendanceStreak: streak };
|
||||
const next: AttendanceStateDoc = { lastAttendanceDate: today, currentAttendanceStreak: streak, streakCycleStart: cycle, highestAttendanceStreak: Math.max(state?.highestAttendanceStreak ?? 0, streak), updatedAt: Timestamp.fromDate(nowDate) };
|
||||
tx.set(stateDocRef(token.uid), next); tx.set(monthDocRef(token.uid, month), { days: newDays, lastCheckedInAt: Timestamp.fromDate(nowDate), lastIdempotencyKey: idem, lastResult: result }, { merge: true }); return result;
|
||||
});
|
||||
}
|
||||
|
||||
// 3) monthly_bonus: 그 달 1~말일 모두 출석
|
||||
if (newDays.length === lastDayOfMonth(month)) {
|
||||
runningBalance += MONTHLY_BONUS_AMOUNT;
|
||||
awards.push({
|
||||
type: PointLedgerType.AttendanceMonthlyBonus,
|
||||
amount: MONTHLY_BONUS_AMOUNT,
|
||||
});
|
||||
createLedgerEntryTx(tx, token.uid, {
|
||||
type: PointLedgerType.AttendanceMonthlyBonus,
|
||||
amount: MONTHLY_BONUS_AMOUNT,
|
||||
balanceAfter: runningBalance,
|
||||
seq: seq++,
|
||||
refMonth: month,
|
||||
refDay: todayDay,
|
||||
});
|
||||
}
|
||||
|
||||
const result: AttendanceCheckInResult = {
|
||||
result: AttendanceResult.CheckedIn,
|
||||
serverNow: Timestamp.fromDate(serverNowDate),
|
||||
attendedDays: newDays,
|
||||
totalCount: newDays.length,
|
||||
pointsAwarded: awards,
|
||||
balanceAfter: runningBalance,
|
||||
};
|
||||
|
||||
const monthPatch: Partial<AttendanceMonthDoc> = {
|
||||
days: newDays,
|
||||
lastCheckedInAt: Timestamp.fromDate(serverNowDate),
|
||||
lastIdempotencyKey: idempotencyKey,
|
||||
lastResult: result,
|
||||
};
|
||||
tx.set(monthRef, monthPatch, { merge: true });
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
export async function getMonth(
|
||||
token: DecodedIdToken,
|
||||
monthRaw: unknown
|
||||
): Promise<AttendanceMonth> {
|
||||
if (!isMonthString(monthRaw)) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
"month must be YYYY-MM",
|
||||
"INVALID_INPUT"
|
||||
);
|
||||
}
|
||||
const month = monthRaw;
|
||||
const [doc, balance] = await Promise.all([
|
||||
getMonthDoc(token.uid, month),
|
||||
getLatestBalance(token.uid),
|
||||
]);
|
||||
if (!doc) {
|
||||
throw new HttpError(404, "month not found", "MONTH_NOT_FOUND");
|
||||
}
|
||||
return {
|
||||
month,
|
||||
attendedDays: doc.days,
|
||||
totalCount: doc.days.length,
|
||||
balance,
|
||||
};
|
||||
}
|
||||
export async function getMonth(token: DecodedIdToken, raw: unknown): Promise<AttendanceMonth> { if (!isMonthString(raw)) throw new HttpError(400, "month must be YYYY-MM", "INVALID_INPUT"); const [doc, balance] = await Promise.all([getMonthDoc(token.uid, raw), getAvailableBalance(token.uid)]); if (!doc) throw new HttpError(404, "month not found", "MONTH_NOT_FOUND"); return { month: raw, attendedDays: doc.days, totalCount: doc.days.length, balance }; }
|
||||
|
||||
@ -5,7 +5,7 @@ import { getRank } from "./rankService";
|
||||
import { resolveTeamCode } from "./chatContextService";
|
||||
import { getDay } from "../repositories/voteHistoryRepository";
|
||||
import { getMonthDoc } from "../repositories/attendanceRepository";
|
||||
import { getLatestBalance } from "../repositories/pointLedgerRepository";
|
||||
import { getAvailableBalance } from "../repositories/walletRepository";
|
||||
import { TEAM_DISPLAY_NAMES, KBO_RANK_TEAM_NAMES } from "../constants/chatPrompts";
|
||||
import type { ChatTool } from "./chatProviderService";
|
||||
import type { ChatToolCallInfo } from "../types/chat";
|
||||
@ -535,7 +535,7 @@ export function buildChatTools(ctx: ChatToolContext): ChatTool[] {
|
||||
if (!month) return "월은 \"YYYY-MM\"이나 \"지난달\" 같은 표현으로 알려줘.";
|
||||
const [doc, balance] = await Promise.all([
|
||||
getMonthDoc(ctx.uid, month),
|
||||
getLatestBalance(ctx.uid),
|
||||
getAvailableBalance(ctx.uid),
|
||||
]);
|
||||
return formatAttendanceResult(month, doc, balance);
|
||||
} catch (err) {
|
||||
|
||||
@ -3,10 +3,9 @@ import {
|
||||
createGameDayCache,
|
||||
type GameDayCache,
|
||||
} from "../repositories/gameRepository";
|
||||
import { getDay, getRange, setDay } from "../repositories/voteHistoryRepository";
|
||||
import { getDay, setDay } from "../repositories/voteHistoryRepository";
|
||||
import {
|
||||
applyDailyJudgmentTx,
|
||||
applyWeeklyMasterTx,
|
||||
getUser,
|
||||
} from "../repositories/userRepository";
|
||||
import {
|
||||
@ -15,7 +14,8 @@ import {
|
||||
thresholdsFor,
|
||||
} from "../constants/judgment";
|
||||
import type { DailyJudgment, RankSnapshot, VoteHistoryDoc } from "../types/panit";
|
||||
import { addDays, tuesdayOf, type DateString } from "../types/dateString";
|
||||
import { addDays, type DateString } from "../types/dateString";
|
||||
import { settleDailyReward } from "./rewardSettlementService";
|
||||
|
||||
/**
|
||||
* `(lastJudged, upTo)` 구간(양 끝 제외)에 **실제 판정 가능한 경기일**(=`thresholdsFor`가
|
||||
@ -103,6 +103,7 @@ export async function judgeDay(
|
||||
// 정상 멱등 재실행에서는 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;
|
||||
}
|
||||
|
||||
@ -113,6 +114,7 @@ export async function judgeDay(
|
||||
completedCount,
|
||||
streakAfter: tx.streakAfter,
|
||||
});
|
||||
await settleDailyReward(uid, date).catch((err) => logger.error(`reward settlement failed uid=${uid} date=${date}`, err));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -122,24 +124,3 @@ export async function judgeDay(
|
||||
* @param uid - 유저 ID
|
||||
* @param sundayDate - 방금 아카이브/판정이 끝난 일요일 날짜
|
||||
*/
|
||||
export async function judgeWeekIfNeeded(
|
||||
uid: string,
|
||||
sundayDate: DateString
|
||||
): Promise<void> {
|
||||
const tuesday = tuesdayOf(sundayDate);
|
||||
const entries = await getRange(uid, tuesday, sundayDate);
|
||||
if (entries.length === 0) return;
|
||||
|
||||
const byDate = new Map(entries.map((e) => [e.date, e.doc] as const));
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const d = addDays(tuesday, i);
|
||||
const doc = byDate.get(d);
|
||||
if (!doc || !doc.judgment) return; // 판정 누락
|
||||
if (doc.judgment === "fail") return;
|
||||
}
|
||||
|
||||
const granted = await applyWeeklyMasterTx(uid, tuesday);
|
||||
if (granted) {
|
||||
logger.info(`weekly-master granted: uid=${uid} tuesday=${tuesday}`);
|
||||
}
|
||||
}
|
||||
|
||||
36
src/services/pointService.ts
Normal file
36
src/services/pointService.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { Timestamp, type Transaction } from "firebase-admin/firestore";
|
||||
import { firestore } from "../firebase";
|
||||
import { HttpError } from "../middleware/errors";
|
||||
import { createLedgerEntryTx } from "../repositories/pointLedgerRepository";
|
||||
import { getWalletTx, walletDocRef } from "../repositories/walletRepository";
|
||||
import { OP_BY_TYPE, PointLedgerType, type PointChange, type PointLedgerEntry, type WalletDoc } from "../types/points";
|
||||
|
||||
const TX_ID = /^[A-Za-z0-9:_-]{1,240}$/;
|
||||
export function isAlreadyExistsError(err: unknown): boolean { const code = (err as { code?: unknown })?.code; return code === 6 || code === "already-exists" || code === "ALREADY_EXISTS"; }
|
||||
export async function applyPointChangesTx(tx: Transaction, uid: string, changes: PointChange[]): Promise<WalletDoc | null> {
|
||||
for (const c of changes) if (!TX_ID.test(c.txId) || !Number.isSafeInteger(c.amount) || c.amount <= 0) throw new HttpError(400, "invalid point change", "INVALID_POINT_CHANGE");
|
||||
const existing = await getWalletTx(tx, uid);
|
||||
if (changes.length === 0) return existing;
|
||||
const now = Timestamp.now();
|
||||
const wallet: WalletDoc = existing ? { ...existing } : { availableBalance: 0, reservedBalance: 0, totalEarned: 0, totalSpent: 0, version: 0, createdAt: now, updatedAt: now };
|
||||
for (const c of changes) {
|
||||
const beforeA = wallet.availableBalance; const beforeR = wallet.reservedBalance; const op = OP_BY_TYPE[c.type];
|
||||
if (op === "credit") {
|
||||
wallet.availableBalance += c.amount;
|
||||
if (c.type === PointLedgerType.OrderRefund) wallet.totalSpent -= c.amount;
|
||||
else wallet.totalEarned += c.amount;
|
||||
}
|
||||
if (op === "debit") { wallet.availableBalance -= c.amount; wallet.totalSpent += c.amount; }
|
||||
if (op === "reserve") { wallet.availableBalance -= c.amount; wallet.reservedBalance += c.amount; }
|
||||
if (op === "capture") { wallet.reservedBalance -= c.amount; wallet.totalSpent += c.amount; }
|
||||
if (op === "release") { wallet.reservedBalance -= c.amount; wallet.availableBalance += c.amount; }
|
||||
if (wallet.availableBalance < 0 || wallet.reservedBalance < 0) throw new HttpError(409, "insufficient balance", "INSUFFICIENT_BALANCE");
|
||||
const entry: PointLedgerEntry = { ...c, uid, op, availableBefore: beforeA, availableAfter: wallet.availableBalance, reservedBefore: beforeR, reservedAfter: wallet.reservedBalance, createdAt: now };
|
||||
createLedgerEntryTx(tx, uid, entry);
|
||||
}
|
||||
if (wallet.availableBalance + wallet.reservedBalance !== wallet.totalEarned - wallet.totalSpent) throw new Error("wallet invariant violated");
|
||||
wallet.version += 1; wallet.updatedAt = now;
|
||||
tx.set(walletDocRef(uid), wallet);
|
||||
return wallet;
|
||||
}
|
||||
export async function applyPointChanges(uid: string, changes: PointChange[]) { return firestore.runTransaction((tx) => applyPointChangesTx(tx, uid, changes)); }
|
||||
33
src/services/rewardSettlementService.ts
Normal file
33
src/services/rewardSettlementService.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { Timestamp } from "firebase-admin/firestore";
|
||||
import { firestore } from "../firebase";
|
||||
import { PREDICTION_PARTICIPATION_POINTS, PREDICTION_PERFECT_BONUS_POINTS, PREDICTION_SUCCESS_POINTS } from "../constants/points";
|
||||
import { listByDate } from "../repositories/gameRepository";
|
||||
import { PointLedgerType, type VoteHistoryDoc } from "../types/panit";
|
||||
import type { DateString } from "../types/dateString";
|
||||
import type { PointChange } from "../types/points";
|
||||
import { applyPointChangesTx, isAlreadyExistsError } from "./pointService";
|
||||
|
||||
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}`);
|
||||
try {
|
||||
return await firestore.runTransaction(async (tx) => {
|
||||
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 };
|
||||
if (!history.judgment) return { result: "not_judged" as const, total: 0 };
|
||||
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[] = [];
|
||||
if (full) {
|
||||
changes.push({ txId: `${uid}:${date}:prediction_daily_participation`, type: PointLedgerType.PredictionDailyParticipation, amount: PREDICTION_PARTICIPATION_POINTS, relatedDate: date });
|
||||
if (history.judgment === "success" || history.judgment === "perfect") changes.push({ txId: `${uid}:${date}:prediction_daily_success`, type: PointLedgerType.PredictionDailySuccess, amount: PREDICTION_SUCCESS_POINTS, relatedDate: date });
|
||||
if (history.judgment === "perfect") changes.push({ txId: `${uid}:${date}:prediction_daily_perfect`, type: PointLedgerType.PredictionDailyPerfect, amount: PREDICTION_PERFECT_BONUS_POINTS, relatedDate: date });
|
||||
}
|
||||
const total = changes.reduce((n, c) => n + c.amount, 0); if (total) await applyPointChangesTx(tx, uid, changes);
|
||||
tx.set(ref, { rewardSettledAt: Timestamp.now(), rewardTotal: total }, { merge: true }); return { result: "settled" as const, total };
|
||||
});
|
||||
} catch (err) {
|
||||
if (!isAlreadyExistsError(err)) throw err;
|
||||
const snap = await ref.get(); const total = (snap.data() as VoteHistoryDoc | undefined)?.rewardTotal ?? 0;
|
||||
await ref.set({ rewardSettledAt: Timestamp.now(), rewardTotal: total }, { merge: true }); return { result: "already_settled", total };
|
||||
}
|
||||
}
|
||||
@ -208,7 +208,6 @@ async function computeStats(uid: string, period: Period): Promise<StatsResponse>
|
||||
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);
|
||||
|
||||
@ -229,7 +228,6 @@ async function computeStats(uid: string, period: Period): Promise<StatsResponse>
|
||||
progress,
|
||||
tier: tierOf(tierPoints),
|
||||
tierPoints,
|
||||
tickets,
|
||||
updatedAt: Date.now(),
|
||||
forDate: today,
|
||||
};
|
||||
|
||||
@ -36,16 +36,8 @@ export enum KnowledgeLevel {
|
||||
Expert = "expert",
|
||||
}
|
||||
|
||||
export interface TicketMap {
|
||||
dailyAllKill: number;
|
||||
weeklyMaster: number;
|
||||
}
|
||||
|
||||
export enum PointLedgerType {
|
||||
AttendanceDaily = "attendance_daily",
|
||||
AttendanceWeeklyBonus = "attendance_weekly_bonus",
|
||||
AttendanceMonthlyBonus = "attendance_monthly_bonus",
|
||||
}
|
||||
export { PointLedgerType } from "./points";
|
||||
import type { PointLedgerType } from "./points";
|
||||
|
||||
export enum AttendanceResult {
|
||||
CheckedIn = "checkedIn",
|
||||
@ -63,17 +55,6 @@ export type NotificationsMap = Partial<Record<NotificationKey, boolean>>;
|
||||
* `balanceAfter`가 단일 진실 원천 — 현재 잔액은 가장 최신 row의 `balanceAfter`다.
|
||||
* 같은 트랜잭션 내 여러 row가 동시 작성될 때는 `seq`로 안정 정렬한다.
|
||||
*/
|
||||
export interface PointLedgerEntry {
|
||||
type: PointLedgerType;
|
||||
amount: number;
|
||||
balanceAfter: number;
|
||||
refMonth?: string;
|
||||
refDay?: number;
|
||||
createdAt: Timestamp;
|
||||
/** 같은 트랜잭션 내 동시 작성 시 안정 정렬용. 0,1,2,... */
|
||||
seq: number;
|
||||
}
|
||||
|
||||
export interface AttendanceMonthDoc {
|
||||
/** 정렬된 1~말일 배열. */
|
||||
days: number[];
|
||||
@ -82,6 +63,14 @@ export interface AttendanceMonthDoc {
|
||||
lastResult?: AttendanceCheckInResult;
|
||||
}
|
||||
|
||||
export interface AttendanceStateDoc {
|
||||
lastAttendanceDate: DateString;
|
||||
currentAttendanceStreak: number;
|
||||
streakCycleStart: DateString;
|
||||
highestAttendanceStreak: number;
|
||||
updatedAt: Timestamp;
|
||||
}
|
||||
|
||||
export interface PointAward {
|
||||
type: PointLedgerType;
|
||||
amount: number;
|
||||
@ -94,6 +83,7 @@ export interface AttendanceCheckInResult {
|
||||
totalCount: number;
|
||||
pointsAwarded: PointAward[];
|
||||
balanceAfter: number;
|
||||
attendanceStreak: number;
|
||||
}
|
||||
|
||||
export interface AttendanceMonth {
|
||||
@ -148,7 +138,6 @@ export interface User {
|
||||
* 스냅샷, 내 순위는 라이브 계산이라 재계산 없이 값만 바꾸면 둘이 어긋난다.
|
||||
*/
|
||||
tierPoints?: number;
|
||||
tickets?: TicketMap;
|
||||
|
||||
/**
|
||||
* 마지막으로 일일 판정이 적용된 KST 날짜.
|
||||
@ -157,7 +146,6 @@ export interface User {
|
||||
*/
|
||||
lastJudgedDate?: DateString;
|
||||
|
||||
lastWeeklyMasterTuesday?: DateString;
|
||||
rankSnapshot?: RankSnapshot;
|
||||
}
|
||||
|
||||
@ -217,6 +205,8 @@ export interface VoteHistoryDoc {
|
||||
correctCount?: number;
|
||||
completedCount?: number;
|
||||
streakAfter?: number;
|
||||
rewardSettledAt?: Timestamp;
|
||||
rewardTotal?: number;
|
||||
}
|
||||
|
||||
export interface StatsResponse {
|
||||
@ -236,7 +226,6 @@ export interface StatsResponse {
|
||||
progress: number;
|
||||
tier: TierName;
|
||||
tierPoints: number;
|
||||
tickets: TicketMap;
|
||||
updatedAt: number;
|
||||
forDate: DateString;
|
||||
}
|
||||
|
||||
68
src/types/points.ts
Normal file
68
src/types/points.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import type { Timestamp } from "firebase-admin/firestore";
|
||||
|
||||
export enum PointLedgerType {
|
||||
AttendanceDaily = "attendance_daily",
|
||||
AttendanceStreak5 = "attendance_streak_5",
|
||||
AttendanceStreak10Interval = "attendance_streak_10_interval",
|
||||
PredictionDailyParticipation = "prediction_daily_participation",
|
||||
PredictionDailySuccess = "prediction_daily_success",
|
||||
PredictionDailyPerfect = "prediction_daily_perfect",
|
||||
EventCredit = "event_credit",
|
||||
AdminCredit = "admin_credit",
|
||||
AdminDebit = "admin_debit",
|
||||
OrderReserve = "order_reserve",
|
||||
OrderCapture = "order_capture",
|
||||
OrderRelease = "order_release",
|
||||
OrderRefund = "order_refund",
|
||||
PointExpiry = "point_expiry",
|
||||
}
|
||||
|
||||
export type PointOperation = "credit" | "debit" | "reserve" | "capture" | "release";
|
||||
|
||||
export const OP_BY_TYPE: Record<PointLedgerType, PointOperation> = {
|
||||
[PointLedgerType.AttendanceDaily]: "credit",
|
||||
[PointLedgerType.AttendanceStreak5]: "credit",
|
||||
[PointLedgerType.AttendanceStreak10Interval]: "credit",
|
||||
[PointLedgerType.PredictionDailyParticipation]: "credit",
|
||||
[PointLedgerType.PredictionDailySuccess]: "credit",
|
||||
[PointLedgerType.PredictionDailyPerfect]: "credit",
|
||||
[PointLedgerType.EventCredit]: "credit",
|
||||
[PointLedgerType.AdminCredit]: "credit",
|
||||
[PointLedgerType.AdminDebit]: "debit",
|
||||
[PointLedgerType.OrderReserve]: "reserve",
|
||||
[PointLedgerType.OrderCapture]: "capture",
|
||||
[PointLedgerType.OrderRelease]: "release",
|
||||
[PointLedgerType.OrderRefund]: "credit",
|
||||
[PointLedgerType.PointExpiry]: "debit",
|
||||
};
|
||||
|
||||
export interface WalletDoc {
|
||||
availableBalance: number;
|
||||
reservedBalance: number;
|
||||
totalEarned: number;
|
||||
totalSpent: number;
|
||||
version: number;
|
||||
createdAt: Timestamp;
|
||||
updatedAt: Timestamp;
|
||||
}
|
||||
|
||||
export interface PointChange {
|
||||
txId: string;
|
||||
type: PointLedgerType;
|
||||
amount: number;
|
||||
relatedDate?: string;
|
||||
orderId?: string;
|
||||
reversalOf?: string;
|
||||
adminReason?: string;
|
||||
adminActor?: string;
|
||||
}
|
||||
|
||||
export interface PointLedgerEntry extends PointChange {
|
||||
uid: string;
|
||||
op: PointOperation;
|
||||
availableBefore: number;
|
||||
availableAfter: number;
|
||||
reservedBefore: number;
|
||||
reservedAfter: number;
|
||||
createdAt: Timestamp;
|
||||
}
|
||||
@ -2,323 +2,40 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DecodedIdToken } from "firebase-admin/auth";
|
||||
import { firestore } from "../../src/firebase";
|
||||
import { checkIn, getMonth } from "../../src/services/attendanceService";
|
||||
import { updateNotifications } from "../../src/services/userService";
|
||||
import {
|
||||
AttendanceResult,
|
||||
PointLedgerType,
|
||||
type AttendanceMonthDoc,
|
||||
type PointLedgerEntry,
|
||||
} from "../../src/types/panit";
|
||||
import { AttendanceResult, PointLedgerType } from "../../src/types/panit";
|
||||
import type { PointLedgerEntry, WalletDoc } from "../../src/types/points";
|
||||
|
||||
const uid = "att-user-1";
|
||||
const token = { uid, firebase: { identities: {}, sign_in_provider: "google.com" } } as unknown as DecodedIdToken;
|
||||
function at(iso: string, key: string) { const d = new Date(iso); vi.setSystemTime(d); return { clientAttemptedAt: d.toISOString(), clientIdempotencyKey: key }; }
|
||||
async function ledger() { const s = await firestore.collection(`users/${uid}/pointLedger`).get(); return s.docs.map((d) => d.data() as PointLedgerEntry); }
|
||||
async function wallet() { return (await firestore.doc(`users/${uid}/wallet/current`).get()).data() as WalletDoc; }
|
||||
|
||||
function fakeToken(): DecodedIdToken {
|
||||
return {
|
||||
uid,
|
||||
email: "att@example.com",
|
||||
firebase: { identities: {}, sign_in_provider: "google.com" },
|
||||
aud: "test",
|
||||
auth_time: 0,
|
||||
exp: 0,
|
||||
iat: 0,
|
||||
iss: "test",
|
||||
sub: uid,
|
||||
} as DecodedIdToken;
|
||||
}
|
||||
describe("attendanceService reward wallet", () => {
|
||||
beforeEach(async () => { await firestore.recursiveDelete(firestore.doc(`users/${uid}`)); vi.useFakeTimers({ toFake: ["Date"] }); });
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
/** 주어진 KST 날짜+시각의 절대 Date. */
|
||||
function kstDate(iso: string): Date {
|
||||
return new Date(iso);
|
||||
}
|
||||
|
||||
/**
|
||||
* KST 시각으로 시스템 클럭을 고정하고 valid한 client body를 생성한다.
|
||||
* clientAttemptedAt은 UTC ISO 8601 문자열(`Z` 접미사) — Flutter의
|
||||
* `DateTime.now().toUtc().toIso8601String()`과 동일 형태.
|
||||
*/
|
||||
function bodyAt(now: Date, key = "k1") {
|
||||
return {
|
||||
clientAttemptedAt: now.toISOString(), // 항상 'Z' 접미사 포함
|
||||
clientIdempotencyKey: key,
|
||||
};
|
||||
}
|
||||
|
||||
async function readLedger(): Promise<PointLedgerEntry[]> {
|
||||
const snap = await firestore
|
||||
.collection("users")
|
||||
.doc(uid)
|
||||
.collection("pointLedger")
|
||||
.orderBy("createdAt", "asc")
|
||||
.orderBy("seq", "asc")
|
||||
.get();
|
||||
return snap.docs.map((d) => d.data() as PointLedgerEntry);
|
||||
}
|
||||
|
||||
async function readMonthDoc(month: string): Promise<AttendanceMonthDoc | null> {
|
||||
const snap = await firestore
|
||||
.collection("users")
|
||||
.doc(uid)
|
||||
.collection("attendance")
|
||||
.doc(month)
|
||||
.get();
|
||||
return snap.exists ? (snap.data() as AttendanceMonthDoc) : null;
|
||||
}
|
||||
|
||||
describe("attendanceService", () => {
|
||||
beforeEach(async () => {
|
||||
await firestore.recursiveDelete(firestore.collection("users").doc(uid));
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
it("첫 출석은 20P를 지급하고 같은 키 replay는 원장을 늘리지 않는다", async () => {
|
||||
const body = at("2026-05-12T14:23:11+09:00", "one"); const first = await checkIn(token, body); const replay = await checkIn(token, body);
|
||||
expect(first).toMatchObject({ result: AttendanceResult.CheckedIn, balanceAfter: 20, attendanceStreak: 1, pointsAwarded: [{ type: PointLedgerType.AttendanceDaily, amount: 20 }] });
|
||||
expect(replay).toEqual(first); expect(await ledger()).toHaveLength(1); expect((await wallet()).availableBalance).toBe(20);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
it("5일차 70P, 10일차 120P 스트릭 보너스를 지급한다", async () => {
|
||||
let fifth; let tenth;
|
||||
for (let day = 1; day <= 10; day++) { const result = await checkIn(token, at(`2026-05-${String(day).padStart(2, "0")}T12:00:00+09:00`, `k${day}`)); if (day === 5) fifth = result; if (day === 10) tenth = result; }
|
||||
expect(fifth).toMatchObject({ balanceAfter: 150, attendanceStreak: 5 });
|
||||
expect(tenth).toMatchObject({ balanceAfter: 350, attendanceStreak: 10 });
|
||||
expect((await ledger()).filter((x) => x.type === PointLedgerType.AttendanceStreak5)).toHaveLength(1);
|
||||
expect((await ledger()).filter((x) => x.type === PointLedgerType.AttendanceStreak10Interval)).toHaveLength(1);
|
||||
});
|
||||
|
||||
describe("checkIn", () => {
|
||||
it("첫 출석 → checkedIn, daily +10, ledger 1건", async () => {
|
||||
const now = kstDate("2026-05-12T14:23:11+09:00");
|
||||
vi.setSystemTime(now);
|
||||
|
||||
const result = await checkIn(fakeToken(), bodyAt(now));
|
||||
|
||||
expect(result.result).toBe(AttendanceResult.CheckedIn);
|
||||
expect(result.attendedDays).toEqual([12]);
|
||||
expect(result.totalCount).toBe(1);
|
||||
expect(result.balanceAfter).toBe(10);
|
||||
expect(result.pointsAwarded).toEqual([
|
||||
{ type: PointLedgerType.AttendanceDaily, amount: 10 },
|
||||
]);
|
||||
|
||||
const ledger = await readLedger();
|
||||
expect(ledger).toHaveLength(1);
|
||||
expect(ledger[0].balanceAfter).toBe(10);
|
||||
expect(ledger[0].refMonth).toBe("2026-05");
|
||||
expect(ledger[0].refDay).toBe(12);
|
||||
|
||||
const month = await readMonthDoc("2026-05");
|
||||
expect(month?.days).toEqual([12]);
|
||||
expect(month?.lastIdempotencyKey).toBe("k1");
|
||||
it("결석 후 스트릭과 cycleStart를 재시작한다", async () => {
|
||||
await checkIn(token, at("2026-05-01T12:00:00+09:00", "a")); const result = await checkIn(token, at("2026-05-03T12:00:00+09:00", "b"));
|
||||
expect(result.attendanceStreak).toBe(1); expect((await firestore.doc(`users/${uid}/attendance/state`).get()).data()?.streakCycleStart).toBe("2026-05-03");
|
||||
});
|
||||
|
||||
it("같은 idempotencyKey 재호출 → 동일 결과, ledger 추가 X", async () => {
|
||||
const now = kstDate("2026-05-12T14:23:11+09:00");
|
||||
vi.setSystemTime(now);
|
||||
|
||||
const r1 = await checkIn(fakeToken(), bodyAt(now, "k1"));
|
||||
const r2 = await checkIn(fakeToken(), bodyAt(now, "k1"));
|
||||
|
||||
expect(r2).toEqual(r1);
|
||||
expect(await readLedger()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("같은 날 다른 idempotencyKey → alreadyCheckedIn, 보상 없음", async () => {
|
||||
const now = kstDate("2026-05-12T14:23:11+09:00");
|
||||
vi.setSystemTime(now);
|
||||
|
||||
await checkIn(fakeToken(), bodyAt(now, "k1"));
|
||||
const r2 = await checkIn(fakeToken(), bodyAt(now, "k2"));
|
||||
|
||||
expect(r2.result).toBe(AttendanceResult.AlreadyCheckedIn);
|
||||
expect(r2.pointsAwarded).toEqual([]);
|
||||
expect(r2.balanceAfter).toBe(10);
|
||||
expect(r2.attendedDays).toEqual([12]);
|
||||
expect(await readLedger()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("TZ 표기 없는 ISO 문자열 → 400 INVALID_INPUT", async () => {
|
||||
const now = kstDate("2026-05-12T14:23:11+09:00");
|
||||
vi.setSystemTime(now);
|
||||
|
||||
await expect(
|
||||
checkIn(fakeToken(), {
|
||||
clientAttemptedAt: "2026-05-12T14:23:11", // TZ 없음
|
||||
clientIdempotencyKey: "k1",
|
||||
})
|
||||
).rejects.toMatchObject({ status: 400, code: "INVALID_INPUT" });
|
||||
});
|
||||
|
||||
it("clientAttemptedAt이 서버보다 6분 미래면 409 CLOCK_SKEW + 시각 details", async () => {
|
||||
const now = kstDate("2026-05-12T14:23:11+09:00");
|
||||
vi.setSystemTime(now);
|
||||
const future = new Date(now.getTime() + 6 * 60 * 1000);
|
||||
|
||||
await expect(
|
||||
checkIn(fakeToken(), bodyAt(future, "k1"))
|
||||
).rejects.toMatchObject({
|
||||
status: 409,
|
||||
code: "CLOCK_SKEW",
|
||||
details: {
|
||||
skewMs: 6 * 60 * 1000,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("월~토 6일 출석 후 일요일 → daily + weekly_bonus, ledger 2건", async () => {
|
||||
// 2026-05-04(월) ~ 2026-05-09(토) 출석 후 2026-05-10(일).
|
||||
for (let day = 4; day <= 9; day++) {
|
||||
const t = kstDate(
|
||||
`2026-05-${String(day).padStart(2, "0")}T10:00:00+09:00`
|
||||
);
|
||||
vi.setSystemTime(t);
|
||||
await checkIn(fakeToken(), bodyAt(t, `seed-${day}`));
|
||||
}
|
||||
|
||||
const sunday = kstDate("2026-05-10T10:00:00+09:00");
|
||||
vi.setSystemTime(sunday);
|
||||
const result = await checkIn(fakeToken(), bodyAt(sunday, "sun"));
|
||||
|
||||
expect(result.pointsAwarded).toEqual([
|
||||
{ type: PointLedgerType.AttendanceDaily, amount: 10 },
|
||||
{ type: PointLedgerType.AttendanceWeeklyBonus, amount: 50 },
|
||||
]);
|
||||
expect(result.balanceAfter).toBe(7 * 10 + 50);
|
||||
|
||||
const ledger = await readLedger();
|
||||
expect(ledger).toHaveLength(8); // 6 daily + (daily + weekly) on Sunday
|
||||
expect(ledger[ledger.length - 1].type).toBe(
|
||||
PointLedgerType.AttendanceWeeklyBonus
|
||||
);
|
||||
expect(ledger[ledger.length - 1].balanceAfter).toBe(120);
|
||||
});
|
||||
|
||||
it("주가 월 경계를 걸치면 weekly_bonus 미발급 (단순화 정책)", async () => {
|
||||
// 2026-06-01(월) ~ 2026-06-06(토) 출석 후 2026-06-07(일).
|
||||
// 6월에 월~토 6일 + 일요일 7일째지만, 정책상 OK이므로 weekly 발급.
|
||||
// 반례 테스트: 2026-05-25(월)~2026-05-31(일) 한 주는 같은 달 안에 들어가므로 발급.
|
||||
// 더 명확한 반례: 2026-08-31이 월요일이라면 그 주 일요일은 9월 6일.
|
||||
// 2026-08-31은 실제로 월요일임.
|
||||
for (let day = 31; day <= 31; day++) {
|
||||
const t = kstDate(`2026-08-${day}T10:00:00+09:00`);
|
||||
vi.setSystemTime(t);
|
||||
await checkIn(fakeToken(), bodyAt(t, `seed-aug-${day}`));
|
||||
}
|
||||
for (let day = 1; day <= 5; day++) {
|
||||
const t = kstDate(
|
||||
`2026-09-${String(day).padStart(2, "0")}T10:00:00+09:00`
|
||||
);
|
||||
vi.setSystemTime(t);
|
||||
await checkIn(fakeToken(), bodyAt(t, `seed-sep-${day}`));
|
||||
}
|
||||
// 일요일 9/6
|
||||
const sunday = kstDate("2026-09-06T10:00:00+09:00");
|
||||
vi.setSystemTime(sunday);
|
||||
const result = await checkIn(fakeToken(), bodyAt(sunday, "sun-cross"));
|
||||
|
||||
// weekly_bonus는 9월 days 안에 1~5만 있으므로 (8/31 월은 9월 doc에 없음)
|
||||
// isWeekFullyAttended 통과 못 함.
|
||||
expect(
|
||||
result.pointsAwarded.find(
|
||||
(a) => a.type === PointLedgerType.AttendanceWeeklyBonus
|
||||
)
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("그 달 1~말일 모두 출석 → monthly_bonus 추가", async () => {
|
||||
// 2026-02 (28일). 2/1 ~ 2/27까지 출석한 뒤 2/28에 트리거.
|
||||
for (let day = 1; day <= 27; day++) {
|
||||
const t = kstDate(
|
||||
`2026-02-${String(day).padStart(2, "0")}T10:00:00+09:00`
|
||||
);
|
||||
vi.setSystemTime(t);
|
||||
await checkIn(fakeToken(), bodyAt(t, `feb-${day}`));
|
||||
}
|
||||
const lastDay = kstDate("2026-02-28T10:00:00+09:00");
|
||||
vi.setSystemTime(lastDay);
|
||||
const result = await checkIn(fakeToken(), bodyAt(lastDay, "feb-28"));
|
||||
|
||||
const monthly = result.pointsAwarded.find(
|
||||
(a) => a.type === PointLedgerType.AttendanceMonthlyBonus
|
||||
);
|
||||
expect(monthly).toEqual({
|
||||
type: PointLedgerType.AttendanceMonthlyBonus,
|
||||
amount: 100,
|
||||
});
|
||||
// 28 daily + (weekly 발급되는 주 수만큼) + 100 monthly.
|
||||
// 2026-02 weekly 발급되는 일요일: 2/1, 2/8, 2/15, 2/22 (모두 같은 달, 직전 6일이 1월에 걸쳐있는 2/1만 별도).
|
||||
// 그러나 2/1은 일요일인데 1/26~1/31이 같은 달이 아니라 weekly 미발급.
|
||||
// 2/8 발급 (2~7 모두 같은 달), 2/15, 2/22 발급. 2/28(토)는 일요일 아님.
|
||||
// weekly 3건 × 50 = 150. daily 28 × 10 = 280. monthly 100. 총 530.
|
||||
expect(result.balanceAfter).toBe(530);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMonth", () => {
|
||||
it("출석 기록 없는 월 → 404 MONTH_NOT_FOUND", async () => {
|
||||
await expect(getMonth(fakeToken(), "2026-05")).rejects.toMatchObject({
|
||||
status: 404,
|
||||
code: "MONTH_NOT_FOUND",
|
||||
});
|
||||
});
|
||||
|
||||
it("형식 오류 → 400 INVALID_INPUT", async () => {
|
||||
await expect(getMonth(fakeToken(), "2026-5")).rejects.toMatchObject({
|
||||
status: 400,
|
||||
code: "INVALID_INPUT",
|
||||
});
|
||||
});
|
||||
|
||||
it("정상 월 조회 → days, totalCount, balance 반환", async () => {
|
||||
const t = kstDate("2026-05-12T10:00:00+09:00");
|
||||
vi.setSystemTime(t);
|
||||
await checkIn(fakeToken(), bodyAt(t));
|
||||
vi.useRealTimers();
|
||||
|
||||
const m = await getMonth(fakeToken(), "2026-05");
|
||||
expect(m.month).toBe("2026-05");
|
||||
expect(m.attendedDays).toEqual([12]);
|
||||
expect(m.totalCount).toBe(1);
|
||||
expect(m.balance).toBe(10);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("userService.updateNotifications", () => {
|
||||
beforeEach(async () => {
|
||||
await firestore.recursiveDelete(firestore.collection("users").doc(uid));
|
||||
await firestore.collection("users").doc(uid).set({
|
||||
displayName: "tester",
|
||||
email: "x@x.com",
|
||||
provider: "google",
|
||||
knowledgeLevel: "casual",
|
||||
});
|
||||
});
|
||||
|
||||
it("attendance:true 토글 → merge 응답", async () => {
|
||||
const result = await updateNotifications(fakeToken(), {
|
||||
notifications: { attendance: true },
|
||||
});
|
||||
expect(result).toEqual({ attendance: true });
|
||||
|
||||
const user = await firestore.collection("users").doc(uid).get();
|
||||
expect(user.data()?.notifications).toEqual({ attendance: true });
|
||||
});
|
||||
|
||||
it("whitelist 외 키 → 400", async () => {
|
||||
await expect(
|
||||
updateNotifications(fakeToken(), {
|
||||
notifications: { unknown: true } as never,
|
||||
})
|
||||
).rejects.toMatchObject({ status: 400, code: "INVALID_INPUT" });
|
||||
});
|
||||
|
||||
it("boolean 아닌 값 → 400", async () => {
|
||||
await expect(
|
||||
updateNotifications(fakeToken(), {
|
||||
notifications: { attendance: "yes" } as never,
|
||||
})
|
||||
).rejects.toMatchObject({ status: 400, code: "INVALID_INPUT" });
|
||||
});
|
||||
|
||||
it("notifications 자체가 없으면 → 400", async () => {
|
||||
await expect(
|
||||
updateNotifications(fakeToken(), {} as never)
|
||||
).rejects.toMatchObject({ status: 400, code: "INVALID_INPUT" });
|
||||
});
|
||||
|
||||
it("미존재 유저 → 404", async () => {
|
||||
await firestore.collection("users").doc(uid).delete();
|
||||
await expect(
|
||||
updateNotifications(fakeToken(), { notifications: { attendance: true } })
|
||||
).rejects.toMatchObject({ status: 404, code: "USER_NOT_FOUND" });
|
||||
it("월 조회 잔액은 지갑에서 반환한다", async () => {
|
||||
await checkIn(token, at("2026-05-12T12:00:00+09:00", "m")); expect(await getMonth(token, "2026-05")).toMatchObject({ attendedDays: [12], totalCount: 1, balance: 20 });
|
||||
});
|
||||
});
|
||||
|
||||
@ -3,7 +3,6 @@ import { Timestamp } from "firebase-admin/firestore";
|
||||
import { firestore } from "../../src/firebase";
|
||||
import {
|
||||
judgeDay,
|
||||
judgeWeekIfNeeded,
|
||||
} from "../../src/services/judgmentService";
|
||||
import { setDay } from "../../src/repositories/voteHistoryRepository";
|
||||
import type { DateString } from "../../src/types/dateString";
|
||||
@ -136,8 +135,6 @@ describe("judgmentService (Firestore emulator)", () => {
|
||||
expect(hist.streakAfter).toBe(1);
|
||||
expect(user.currentStreak).toBe(1);
|
||||
expect(user.highestStreak).toBe(1);
|
||||
expect(user.tickets?.dailyAllKill).toBe(1);
|
||||
expect(user.tickets?.weeklyMaster).toBe(0);
|
||||
expect(user.tierPoints).toBe(50); // 5 * 10, streak 1이라 보너스 0
|
||||
expect(user.lastJudgedDate).toBe(TUE);
|
||||
});
|
||||
@ -160,7 +157,6 @@ describe("judgmentService (Firestore emulator)", () => {
|
||||
|
||||
expect(hist.judgment).toBe("success");
|
||||
expect(user.currentStreak).toBe(1);
|
||||
expect(user.tickets?.dailyAllKill).toBe(0);
|
||||
expect(user.tierPoints).toBe(30);
|
||||
});
|
||||
|
||||
@ -218,7 +214,6 @@ describe("judgmentService (Firestore emulator)", () => {
|
||||
const user = await readUser();
|
||||
const hist = await readVoteHistory(TUE);
|
||||
expect(hist.judgment).toBe("perfect");
|
||||
expect(user.tickets?.dailyAllKill).toBe(1);
|
||||
});
|
||||
|
||||
it("4 completed, 2적중이면 success (success=2, perfect=4)", async () => {
|
||||
@ -258,7 +253,6 @@ describe("judgmentService (Firestore emulator)", () => {
|
||||
expect(hist.streakAfter).toBe(4);
|
||||
expect(user.currentStreak).toBe(4);
|
||||
expect(user.tierPoints).toBe(80);
|
||||
expect(user.tickets?.dailyAllKill).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@ -388,84 +382,8 @@ describe("judgmentService (Firestore emulator)", () => {
|
||||
|
||||
const user = await readUser();
|
||||
expect(user.currentStreak).toBe(1);
|
||||
expect(user.tickets?.dailyAllKill).toBe(1);
|
||||
expect(user.tierPoints).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe("judgeWeekIfNeeded", () => {
|
||||
async function seedWeekJudgments(
|
||||
judgments: Record<DateString, DailyJudgment>
|
||||
): Promise<void> {
|
||||
for (const [date, j] of Object.entries(judgments)) {
|
||||
await seedJudgedHistory(date as DateString, j);
|
||||
}
|
||||
}
|
||||
|
||||
it("화~일 6일 전부 success|perfect|skip 이면 weeklyMaster 지급", async () => {
|
||||
await seedWeekJudgments({
|
||||
[TUE]: "success",
|
||||
[WED]: "perfect",
|
||||
[THU]: "skip",
|
||||
[FRI]: "success",
|
||||
[SAT]: "success",
|
||||
[SUN]: "success",
|
||||
});
|
||||
|
||||
await judgeWeekIfNeeded(uid, SUN);
|
||||
|
||||
const user = await readUser();
|
||||
expect(user.tickets?.weeklyMaster).toBe(1);
|
||||
expect(user.lastWeeklyMasterTuesday).toBe(TUE);
|
||||
});
|
||||
|
||||
it("한 날이라도 fail이면 지급 안 됨", async () => {
|
||||
await seedWeekJudgments({
|
||||
[TUE]: "success",
|
||||
[WED]: "fail",
|
||||
[THU]: "success",
|
||||
[FRI]: "success",
|
||||
[SAT]: "success",
|
||||
[SUN]: "success",
|
||||
});
|
||||
|
||||
await judgeWeekIfNeeded(uid, SUN);
|
||||
|
||||
const user = await readUser();
|
||||
expect(user.tickets?.weeklyMaster ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
it("판정 이력이 누락된 날이 있으면 지급 안 됨", async () => {
|
||||
await seedWeekJudgments({
|
||||
[TUE]: "success",
|
||||
[WED]: "success",
|
||||
// THU 누락
|
||||
[FRI]: "success",
|
||||
[SAT]: "success",
|
||||
[SUN]: "success",
|
||||
});
|
||||
|
||||
await judgeWeekIfNeeded(uid, SUN);
|
||||
|
||||
const user = await readUser();
|
||||
expect(user.tickets?.weeklyMaster ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
it("같은 주 재실행 시 중복 지급되지 않는다 (멱등)", async () => {
|
||||
await seedWeekJudgments({
|
||||
[TUE]: "success",
|
||||
[WED]: "success",
|
||||
[THU]: "success",
|
||||
[FRI]: "success",
|
||||
[SAT]: "success",
|
||||
[SUN]: "success",
|
||||
});
|
||||
|
||||
await judgeWeekIfNeeded(uid, SUN);
|
||||
await judgeWeekIfNeeded(uid, SUN);
|
||||
|
||||
const user = await readUser();
|
||||
expect(user.tickets?.weeklyMaster).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -195,7 +195,6 @@ describe("statsService.getStats — 캐시 forDate 검증", () => {
|
||||
progress: 0,
|
||||
tier: "bronze",
|
||||
tierPoints: 0,
|
||||
tickets: { dailyAllKill: 0, weeklyMaster: 0 },
|
||||
updatedAt: Date.now(),
|
||||
forDate: today,
|
||||
});
|
||||
@ -222,7 +221,6 @@ describe("statsService.getStats — 캐시 forDate 검증", () => {
|
||||
progress: 0,
|
||||
tier: "bronze",
|
||||
tierPoints: 0,
|
||||
tickets: { dailyAllKill: 0, weeklyMaster: 0 },
|
||||
updatedAt: Date.now() - 86_400_000,
|
||||
forDate: yesterday,
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user