Files
fanuzu/app/firebase/functions/src/fandom/herodrop.ts
T

163 lines
6.3 KiB
TypeScript

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<DropConfig> {
const snap = await getFirestore().doc('config/policy').get();
const raw = (snap.get('herodrop') ?? {}) as Partial<DropConfig>;
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 };
});
});