라이브 티커 · 갤럭시 검색 색인 · 주간 랭킹 스냅샷 · 연속 ZAP 상한 · 가입 행성 Zap · 성운 목표 · 랭킹 갱신 안내 · 행성 터치 반응

This commit is contained in:
Sn-Kinos
2026-10-05 22:38:42 +09:00
parent 000c058097
commit 4bb92c2ee7
44 changed files with 727 additions and 78 deletions
+12 -2
View File
@@ -1,6 +1,6 @@
import { onCall } from 'firebase-functions/v2/https';
import { onSchedule } from 'firebase-functions/v2/scheduler';
import { getFirestore } from 'firebase-admin/firestore';
import { getFirestore, Timestamp } from 'firebase-admin/firestore';
import { logger } from 'firebase-functions';
import { callerUid, num, optionalStr, requireRole, str } from '../core/callable.js';
@@ -66,11 +66,14 @@ export const fnPopSpend = onCall(async (req) => {
fail(ERR.permissionDenied, '이 경로로 쓸 수 없는 사유입니다.');
}
const amount = positiveInt(num(req.data, 'amount'), 'amount');
const { zapPop } = await loadPolicy();
const { zapPop, zapMaxPerCall, zapMinIntervalMs } = await loadPolicy();
const count = Math.floor(amount / zapPop);
if (!Number.isSafeInteger(count) || count < 1 || count * zapPop !== amount) {
fail(ERR.popVerifyFailed, `Zap 은 ${zapPop} POP 단위로만 보낼 수 있습니다.`);
}
// 연속 ZAP Rate Limit(FX §3) — 한 호출 상한과 호출 간격. 차감과 같은 트랜잭션에서 판정 · 기록한다.
if (count > zapMaxPerCall) fail(ERR.questAbnormal, '한 번에 보낼 수 있는 Zap 횟수를 넘었습니다.');
const limitRef = getFirestore().doc(`users/${uid}/rateLimits/zap`);
return spendPop({
uid,
popType: popType(req.data),
@@ -83,6 +86,13 @@ export const fnPopSpend = onCall(async (req) => {
...(optionalStr(req.data, 'fandomId') ? { fandomId: str(req.data, 'fandomId') } : {}),
// 묶음 횟수. 원장 행에 남아 「Zap N 회」를 세고, FPI 단위가 된다.
count,
precheck: async (tx) => {
const last = (await tx.get(limitRef)).get('lastAt') as Timestamp | undefined;
if (last && Date.now() - last.toMillis() < zapMinIntervalMs) {
fail(ERR.questAbnormal, '너무 빠르게 Zap 했습니다. 잠시 뒤 다시 시도해 주세요.');
}
},
alsoWrite: (tx) => tx.set(limitRef, { lastAt: Timestamp.now() }),
});
});