import { onCall } from 'firebase-functions/v2/https'; import { getFirestore, FieldValue, Timestamp } from 'firebase-admin/firestore'; import { callerUid, str } from '../core/callable.js'; import { txIdOf } from '../core/ids.js'; import { ERR, fail } from '../core/errors.js'; import { grantPop, spendPop } from '../ledger/ledger.js'; import { publicNameOf } from '../core/names.js'; /** * 영웅의 선물 — 한 사람이 쏘고 팬덤이 나눠 받는다. * * 총량은 서버 운영값, 시간·1인 상한은 B-41의 기본 10분·Pool 5%를 적용한다. * 이벤트 생성 때 운영 비율을 정수 POP 상한으로 고정한다. */ interface DropConfig { totalPop: number; perUserCap: number; perUserCapPercent: number; durationSec: number; } /** * 총량 하나에 운영값(1인 상한 % · 시간)을 붙여 Drop 규격을 만든다. * * [totalPop] 을 받으면 운영 기본 총량 대신 그 값을 쓴다 — Hero Gift 구매는 상품별 * Admin 확정값이 총량이다(원장 B-84 · B-85). */ export async function dropConfig(totalPop?: number): Promise { const snap = await getFirestore().doc('config/policy').get(); const raw = (snap.get('herodrop') ?? {}) as Partial; const total = totalPop ?? raw.totalPop; const percent = raw.perUserCapPercent ?? 5; const duration = raw.durationSec ?? 600; if ( typeof total !== 'number' || !Number.isSafeInteger(total) || total <= 0 || typeof percent !== 'number' || !Number.isFinite(percent) || percent <= 0 || percent > 100 || !Number.isSafeInteger(duration) || duration <= 0 ) { fail( ERR.popVerifyFailed, '영웅의 선물 운영값이 아직 설정되지 않았습니다.', ); } const cap = Math.floor(total * percent / 100); if (cap < 1) fail(ERR.popVerifyFailed, '수령 가능한 정수 POP 상한이 없습니다.'); return { totalPop: total, perUserCap: cap, perUserCapPercent: percent, durationSec: duration }; } /** Drop 문서 본문. 발동 경로(POP 차감 · IAP 구매)가 같은 모양을 쓴다. */ export function dropDocument( config: DropConfig, fields: { triggeredBy: string; heroNickname: string; productId: string }, ) { return { triggeredBy: fields.triggeredBy, ...(fields.heroNickname ? { heroNickname: fields.heroNickname } : {}), productId: fields.productId, totalPop: config.totalPop, perUserCap: config.perUserCap, perUserCapPercent: config.perUserCapPercent, remainingPop: config.totalPop, claimedCount: 0, endsAt: Timestamp.fromMillis(Date.now() + config.durationSec * 1000), createdAt: FieldValue.serverTimestamp(), }; } /** 공개 닉네임만 보존한다. 실명/displayName/email을 대체값으로 쓰지 않는다 — 없으면 「팬」. */ export function publicNickname(user: FirebaseFirestore.DocumentSnapshot): string { return publicNameOf(user); } /** * `fn-herodrop-trigger` — 발동. * * 결제로 사는 아이템이라 영수증 검증을 지난 뒤에 불린다. 여기서는 **지원 POP * 차감**만 하고, 영수증 자체는 `fn-iap-verify` 가 본다. */ export const fnHeroDropTrigger = onCall(async (req) => { const uid = callerUid(req); const fandomId = str(req.data, 'fandomId'); const productId = str(req.data, 'productId'); const clientSeq = str(req.data, 'clientSeq'); const config = await dropConfig(); const db = getFirestore(); const idempotencyKey = `herodrop:trigger:${uid}:${clientSeq}`; const dropId = txIdOf(idempotencyKey); const heroNickname = publicNickname(await db.doc(`users/${uid}`).get()); const result = await spendPop({ uid, popType: 'support', amount: config.totalPop, reasonCode: 'HERO_DROP_TRIGGER', idempotencyKey, fandomId, alsoWrite: (tx) => tx.create( db.doc(`fandoms/${fandomId}/heroDrops/${dropId}`), dropDocument(config, { triggeredBy: uid, heroNickname, productId }), ), }); return { dropId, txId: result.txId, balanceAfter: result.balanceAfter }; }); /** * `fn-herodrop-claim` — 수령. * * **중복 수령은 문서 ID 로 막는다.** 수령 기록의 ID 가 `uid` 라 조건문 없이 * 구조로 차단된다(설계서 §2.1 「중복을 막아야 하는 곳은 문서 ID 를 자연키로」). * 남은 총량은 트랜잭션에서 줄인다 — 조회 후 차감으로 나누면 동시 수령에서 초과한다. */ export const fnHeroDropClaim = onCall(async (req) => { const uid = callerUid(req); const fandomId = str(req.data, 'fandomId'); const dropId = str(req.data, 'dropId'); const db = getFirestore(); const dropRef = db.doc(`fandoms/${fandomId}/heroDrops/${dropId}`); const claimRef = dropRef.collection('claims').doc(uid); return db.runTransaction(async (tx) => { const existing = await tx.get(claimRef); const drop = await tx.get(dropRef); if (!drop.exists) fail(ERR.popVerifyFailed, '선물을 찾을 수 없습니다.'); const endsAt = drop.get('endsAt'); const remaining = drop.get('remainingPop'); const cap = drop.get('perUserCap'); // 이미 확정한 수령은 종료 후에도 같은 원장 결과를 반환한다. if (!existing.exists) { if (!(endsAt instanceof Timestamp) || endsAt.toMillis() <= Date.now()) { fail(ERR.popVerifyFailed, '수령 시간이 끝났습니다.'); } if (!Number.isSafeInteger(remaining) || remaining <= 0 || !Number.isSafeInteger(cap) || cap <= 0) { fail(ERR.popVerifyFailed, '수령 가능한 선물이 없습니다.'); } } const share = existing.exists ? existing.get('amount') : Math.min(cap, remaining); if (!Number.isSafeInteger(share) || share <= 0) { fail(ERR.popVerifyFailed, '수령 기록을 확인할 수 없습니다.'); } const result = await grantPop({ uid, popType: 'activity', amount: share, reasonCode: 'HERO_DROP', idempotencyKey: `herodrop:${uid}:${dropId}`, refPath: dropRef.path, alsoWrite: (t) => { if (existing.exists) return; t.create(claimRef, { uid, amount: share, createdAt: FieldValue.serverTimestamp() }); t.update(dropRef, { remainingPop: FieldValue.increment(-share), claimedCount: FieldValue.increment(1), }); }, }, tx); return { claimedPop: share, txId: result.txId, balanceAfter: result.balanceAfter }; }); });