Cloud Functions 도메인 함수 — POP 원장 · 퀘스트 · 이클립스 · 행성 성장 · 캠페인 · 결제 · 알림 · 브리핑 · INSIDE · 추천 · 번역 · 서버 테스트
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
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 };
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user