diff --git a/src/constants/points.ts b/src/constants/points.ts new file mode 100644 index 0000000..06cfd45 --- /dev/null +++ b/src/constants/points.ts @@ -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; diff --git a/src/handlers/debugHandlers.ts b/src/handlers/debugHandlers.ts index 9ca6f6b..0bdbbf1 100644 --- a/src/handlers/debugHandlers.ts +++ b/src/handlers/debugHandlers.ts @@ -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) || diff --git a/src/repositories/attendanceRepository.ts b/src/repositories/attendanceRepository.ts index 129451c..28fa873 100644 --- a/src/repositories/attendanceRepository.ts +++ b/src/repositories/attendanceRepository.ts @@ -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 { const s = await stateDocRef(uid).get(); return s.exists ? s.data() as AttendanceStateDoc : null; } +export async function getStateDocTx(tx: Transaction, uid: string): Promise { const s = await tx.get(stateDocRef(uid)); return s.exists ? s.data() as AttendanceStateDoc : null; } diff --git a/src/repositories/pointLedgerRepository.ts b/src/repositories/pointLedgerRepository.ts index 3e1c3d2..8906bc4 100644 --- a/src/repositories/pointLedgerRepository.ts +++ b/src/repositories/pointLedgerRepository.ts @@ -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 { - 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 { - 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 = { - 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 }; } diff --git a/src/repositories/userRepository.ts b/src/repositories/userRepository.ts index 86424a2..37b8c3d 100644 --- a/src/repositories/userRepository.ts +++ b/src/repositories/userRepository.ts @@ -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 { - const ref = firestore.collection(COLLECTION).doc(uid); - return firestore.runTransaction(async (tx) => { - const snap = await tx.get(ref); - const user = (snap.data() ?? {}) as Partial; - - 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; - }); -} diff --git a/src/repositories/walletRepository.ts b/src/repositories/walletRepository.ts new file mode 100644 index 0000000..e70fa5b --- /dev/null +++ b/src/repositories/walletRepository.ts @@ -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 { const s = await walletDocRef(uid).get(); return s.exists ? s.data() as WalletDoc : null; } +export async function getWalletTx(tx: Transaction, uid: string): Promise { const s = await tx.get(walletDocRef(uid)); return s.exists ? s.data() as WalletDoc : null; } +export async function getAvailableBalance(uid: string): Promise { return (await getWallet(uid))?.availableBalance ?? 0; } diff --git a/src/scheduled/dailyArchive.ts b/src/scheduled/dailyArchive.ts index c91d715..6ef353a 100644 --- a/src/scheduled/dailyArchive.ts +++ b/src/scheduled/dailyArchive.ts @@ -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 { diff --git a/src/services/adminPointService.ts b/src/services/adminPointService.ts new file mode 100644 index 0000000..ea5084f --- /dev/null +++ b/src/services/adminPointService.ts @@ -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 }])); } diff --git a/src/services/attendanceService.ts b/src/services/attendanceService.ts index 78bde04..c31861a 100644 --- a/src/services/attendanceService.ts +++ b/src/services/attendanceService.ts @@ -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 { - 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 { + 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, - }); - } - - // 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 = { - days: newDays, - lastCheckedInAt: Timestamp.fromDate(serverNowDate), - lastIdempotencyKey: idempotencyKey, - lastResult: result, - }; - tx.set(monthRef, monthPatch, { merge: true }); - - return result; + 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; }); } - -export async function getMonth( - token: DecodedIdToken, - monthRaw: unknown -): Promise { - 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 { 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 }; } diff --git a/src/services/chatToolService.ts b/src/services/chatToolService.ts index 54ebb83..5286aea 100644 --- a/src/services/chatToolService.ts +++ b/src/services/chatToolService.ts @@ -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) { diff --git a/src/services/judgmentService.ts b/src/services/judgmentService.ts index daec012..02e6708 100644 --- a/src/services/judgmentService.ts +++ b/src/services/judgmentService.ts @@ -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 { - 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}`); - } -} diff --git a/src/services/pointService.ts b/src/services/pointService.ts new file mode 100644 index 0000000..d2463f5 --- /dev/null +++ b/src/services/pointService.ts @@ -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 { + 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)); } diff --git a/src/services/rewardSettlementService.ts b/src/services/rewardSettlementService.ts new file mode 100644 index 0000000..95044f3 --- /dev/null +++ b/src/services/rewardSettlementService.ts @@ -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 }; + } +} diff --git a/src/services/statsService.ts b/src/services/statsService.ts index 5b55c72..a807b46 100644 --- a/src/services/statsService.ts +++ b/src/services/statsService.ts @@ -208,7 +208,6 @@ async function computeStats(uid: string, period: Period): Promise 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 progress, tier: tierOf(tierPoints), tierPoints, - tickets, updatedAt: Date.now(), forDate: today, }; diff --git a/src/types/panit.ts b/src/types/panit.ts index 7f23260..4c1f57c 100644 --- a/src/types/panit.ts +++ b/src/types/panit.ts @@ -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>; * `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; } diff --git a/src/types/points.ts b/src/types/points.ts new file mode 100644 index 0000000..39ac5fc --- /dev/null +++ b/src/types/points.ts @@ -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.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; +} diff --git a/tests/services/attendanceService.test.ts b/tests/services/attendanceService.test.ts index c74a9c5..9f787a9 100644 --- a/tests/services/attendanceService.test.ts +++ b/tests/services/attendanceService.test.ts @@ -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 { - 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 { - 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("같은 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); - }); + 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"); }); - 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 }); }); }); diff --git a/tests/services/judgmentService.test.ts b/tests/services/judgmentService.test.ts index 64e3087..4f1c05d 100644 --- a/tests/services/judgmentService.test.ts +++ b/tests/services/judgmentService.test.ts @@ -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 - ): Promise { - 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); - }); - }); }); diff --git a/tests/services/statsService.test.ts b/tests/services/statsService.test.ts index e4564c8..f1396dd 100644 --- a/tests/services/statsService.test.ts +++ b/tests/services/statsService.test.ts @@ -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, });