Cloud Functions 도메인 함수 — POP 원장 · 퀘스트 · 이클립스 · 행성 성장 · 캠페인 · 결제 · 알림 · 브리핑 · INSIDE · 추천 · 번역 · 서버 테스트
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import { Timestamp, type DocumentReference } from 'firebase-admin/firestore';
|
||||
import { dayKey } from '../core/time.js';
|
||||
|
||||
/**
|
||||
* 2026-09-16 EC02·EC05: 누락된 집계를 0으로 채우지 않는다.
|
||||
* 원장 C-26 `target = max(1, ceil(avgDau × rate))` · C-27 생성 후 완료 집계일 ≤ [windowDays] 일 평균 ·
|
||||
* C-21 집계 기간은 운영값(기본 7일).
|
||||
*/
|
||||
export async function eclipseTarget(
|
||||
fandom: DocumentReference,
|
||||
createdAt: unknown,
|
||||
day: string,
|
||||
rate = 0.05,
|
||||
windowDays = 7,
|
||||
): Promise<
|
||||
{ target: number; averageDau: number | null; dauDays: string[] }
|
||||
| { result: 'cancelled_error'; cancelReason: string }
|
||||
> {
|
||||
const invalid = (cancelReason: string) => ({ result: 'cancelled_error' as const, cancelReason });
|
||||
if (!Number.isFinite(rate) || rate <= 0 || rate > 1) return invalid('INVALID_TARGET_RATE');
|
||||
if (!Number.isSafeInteger(windowDays) || windowDays < 1) return invalid('INVALID_DAU_WINDOW');
|
||||
if (!(createdAt instanceof Timestamp)) return invalid('MISSING_FANDOM_CREATED_AT');
|
||||
// 기존 scheduler/dayKey와 같은 날짜 키로 완료된 일별 집계를 조회한다.
|
||||
const createdDay = dayKey(createdAt.toDate());
|
||||
if (createdDay > day) return invalid('FUTURE_FANDOM_CREATED_AT');
|
||||
if (createdDay === day) return { target: 1, averageDau: null, dauDays: [] };
|
||||
const days: string[] = [];
|
||||
for (let i = 1; i <= windowDays; i++) {
|
||||
const previous = new Date(`${day}T00:00:00Z`);
|
||||
previous.setUTCDate(previous.getUTCDate() - i);
|
||||
const key = previous.toISOString().slice(0, 10);
|
||||
if (key < createdDay) break;
|
||||
days.push(key);
|
||||
}
|
||||
const docs = await fandom.firestore.getAll(
|
||||
...days.map((key) => fandom.collection('fandomDailyStats').doc(key)),
|
||||
);
|
||||
if (docs.some((doc) => !doc.exists || !Number.isSafeInteger(doc.get('dau')) || doc.get('dau') < 0)) {
|
||||
return invalid('MISSING_OR_INVALID_DAU');
|
||||
}
|
||||
const sum = docs.reduce((total, doc) => total + (doc.get('dau') as number), 0);
|
||||
if (!Number.isSafeInteger(sum)) return invalid('INVALID_DAU_TOTAL');
|
||||
const averageDau = sum / days.length;
|
||||
return { target: Math.max(1, Math.ceil(averageDau * rate)), averageDau, dauDays: days };
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { onDocumentUpdated } from 'firebase-functions/v2/firestore';
|
||||
|
||||
/** v1.5 §2.1/§7: 확정 Stage 상승을 보존한다. 개인 참여/보상을 추정하지 않는다. */
|
||||
export const fnPlanetEvolutionRecord = onDocumentUpdated(
|
||||
{ document: 'fandoms/{fandomId}', retry: true },
|
||||
async (event) => {
|
||||
const change = event.data;
|
||||
if (!change) return;
|
||||
const from = change.before.get('stage');
|
||||
const to = change.after.get('stage');
|
||||
if (!Number.isInteger(from) || !Number.isInteger(to) ||
|
||||
from < 0 || to > 6 || to <= from) return;
|
||||
|
||||
// 이벤트 ID는 재전달에도 같다. 처리 시각 대신 실제 문서 확정 시각을 보존한다.
|
||||
const history = change.after.ref.collection('stateHistory')
|
||||
.doc(`evolution-${encodeURIComponent(event.id)}`);
|
||||
try {
|
||||
await history.create({
|
||||
kind: 'evolution',
|
||||
occurredAt: change.after.updateTime,
|
||||
fromStage: from,
|
||||
toStage: to,
|
||||
reasonCode: 'STAGE_INCREASED',
|
||||
source: 'fnPlanetEvolutionRecord',
|
||||
sourceEventId: event.id,
|
||||
});
|
||||
} catch (error) {
|
||||
const code = (error as { code?: number | string }).code;
|
||||
if (code !== 6 && code !== 'already-exists') throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 };
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Timestamp } from 'firebase-admin/firestore';
|
||||
|
||||
interface HibernationSettings {
|
||||
enterFpi: number;
|
||||
enterSeconds: number;
|
||||
wakeFpi: number;
|
||||
wakeSeconds: number;
|
||||
}
|
||||
|
||||
/** v1.6.1: FPI < 10, 30일. 복귀 수치는 원본에 없어 운영 설정을 필수로 받는다. */
|
||||
export function hibernationSettings(data: FirebaseFirestore.DocumentData): HibernationSettings | null {
|
||||
if (data.enabled !== true) return null;
|
||||
const settings = {
|
||||
enterFpi: data.enterFpi ?? 10,
|
||||
enterSeconds: data.enterSeconds ?? 30 * 24 * 60 * 60,
|
||||
wakeFpi: data.wakeFpi,
|
||||
wakeSeconds: data.wakeSeconds,
|
||||
};
|
||||
if (!Object.values(settings).every((value) => typeof value === 'number' && Number.isFinite(value)) ||
|
||||
settings.enterFpi <= 0 || settings.wakeFpi <= settings.enterFpi ||
|
||||
!Number.isSafeInteger(settings.enterSeconds) || settings.enterSeconds <= 0 ||
|
||||
!Number.isSafeInteger(settings.wakeSeconds) || settings.wakeSeconds < 0) {
|
||||
throw new Error('Invalid hibernation settings: explicit wake threshold and duration required');
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
/** FPI 집계와 같은 트랜잭션에 기록한다. 재실행·과거 실행이 유지시간을 되돌리지 않는다. */
|
||||
export function hibernationPatch(
|
||||
current: FirebaseFirestore.DocumentData,
|
||||
fpi: number,
|
||||
settings: HibernationSettings,
|
||||
now: Timestamp,
|
||||
activity?: { id: string; at: Timestamp },
|
||||
): FirebaseFirestore.DocumentData {
|
||||
if (!Number.isFinite(fpi)) return {};
|
||||
const evaluated = current.hibernationEvaluatedAt;
|
||||
if (evaluated instanceof Timestamp && evaluated.toMillis() >= now.toMillis()) return {};
|
||||
const policy = JSON.stringify(settings);
|
||||
const unchangedPolicy = current.hibernationPolicy === policy;
|
||||
let asleep = current.isHibernating === true;
|
||||
const wasAsleep = asleep;
|
||||
let lowSince: Timestamp | null = null;
|
||||
let wakeSince: Timestamp | null = null;
|
||||
const since = (value: unknown) => unchangedPolicy && value instanceof Timestamp &&
|
||||
value.toMillis() <= now.toMillis() ? value : now;
|
||||
if (!asleep && fpi < settings.enterFpi) {
|
||||
lowSince = since(current.hibernationLowSince);
|
||||
asleep = now.toMillis() - lowSince.toMillis() >= settings.enterSeconds * 1000;
|
||||
} else if (asleep && fpi >= settings.wakeFpi &&
|
||||
activity && current.hibernationChangedAt instanceof Timestamp &&
|
||||
activity.at.toMillis() > current.hibernationChangedAt.toMillis() &&
|
||||
activity.at.toMillis() <= now.toMillis()) {
|
||||
wakeSince = since(current.hibernationWakeSince);
|
||||
asleep = now.toMillis() - wakeSince.toMillis() < settings.wakeSeconds * 1000;
|
||||
}
|
||||
return {
|
||||
isHibernating: asleep,
|
||||
hibernationLowSince: lowSince,
|
||||
hibernationWakeSince: wakeSince,
|
||||
hibernationEvaluatedAt: now,
|
||||
hibernationPolicy: policy,
|
||||
...(wasAsleep !== asleep ? { hibernationChangedAt: now } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { HttpsError, onCall } from 'firebase-functions/v2/https';
|
||||
import { getFirestore, Timestamp } from 'firebase-admin/firestore';
|
||||
import { callerUid, str } from '../core/callable.js';
|
||||
|
||||
/** PASSPORT에서 쓸 팬덤 역사와 호출자 본인의 참여 근거만 반환한다. */
|
||||
export const fnPlanetHistory = onCall(async (request) => {
|
||||
const uid = callerUid(request);
|
||||
const fandomId = str(request.data, 'fandomId');
|
||||
if (fandomId.includes('/') || fandomId.length > 512) {
|
||||
throw new HttpsError('invalid-argument', '팬덤을 확인해 주세요.');
|
||||
}
|
||||
const scope = request.data?.scope;
|
||||
if (scope != null && scope !== 'fandom' && scope !== 'personal') {
|
||||
throw new HttpsError('invalid-argument', '조회 범위를 확인해 주세요.');
|
||||
}
|
||||
const personal = scope === 'personal';
|
||||
const db = getFirestore();
|
||||
const history = db.collection(personal ? `users/${uid}/growthEvents` : `fandoms/${fandomId}/stateHistory`);
|
||||
const dateField = personal ? 'createdAt' : 'occurredAt';
|
||||
let query = (personal ? history.where('fandomIdAtPayout', '==', fandomId) : history)
|
||||
.orderBy(dateField, 'desc').limit(21);
|
||||
if (request.data?.cursor != null) {
|
||||
const cursor = str(request.data, 'cursor');
|
||||
if (cursor.includes('/') || cursor.length > 1500) {
|
||||
throw new HttpsError('invalid-argument', '조회 위치를 확인해 주세요.');
|
||||
}
|
||||
const previous = await history.doc(cursor).get();
|
||||
if (!previous.exists || !(previous.get(dateField) instanceof Timestamp) ||
|
||||
(personal && previous.get('fandomIdAtPayout') !== fandomId)) {
|
||||
throw new HttpsError('invalid-argument', '조회 위치를 확인해 주세요.');
|
||||
}
|
||||
query = query.startAfter(previous);
|
||||
}
|
||||
const [page, contribution] = await Promise.all([
|
||||
query.get(),
|
||||
// 성장 이벤트는 실제 지급/기여 원장과 같은 트랜잭션에서 생성된다.
|
||||
personal ? Promise.resolve(null) : db.collection(`users/${uid}/growthEvents`)
|
||||
.where('fandomIdAtPayout', '==', fandomId).orderBy('createdAt').limit(1).get(),
|
||||
]);
|
||||
const first = contribution?.docs[0];
|
||||
const firstAt = first?.get('createdAt');
|
||||
const hasProof = firstAt instanceof Timestamp && Number.isSafeInteger(first?.get('amount')) &&
|
||||
first?.get('amount') > 0 &&
|
||||
first?.get('reversalOf') == null &&
|
||||
['quest_reward', 'company_ad_bonus', 'zap', 'funding'].includes(first?.get('sourceType'));
|
||||
const rows = page.docs.slice(0, 20);
|
||||
const items = rows.flatMap((doc) => {
|
||||
const kind = doc.get(personal ? 'sourceType' : 'kind');
|
||||
const occurredAt = doc.get(dateField);
|
||||
const allowed = personal ? ['quest_reward', 'company_ad_bonus', 'zap', 'funding']
|
||||
: ['evolution', 'hibernation', 'wake_up'];
|
||||
const amount = doc.get('amount');
|
||||
if (!(occurredAt instanceof Timestamp) || !allowed.includes(kind) ||
|
||||
(personal && (!Number.isSafeInteger(amount) || amount <= 0 || doc.get('reversalOf') != null))) return [];
|
||||
const from = doc.get('fromStage');
|
||||
const to = doc.get('toStage');
|
||||
if (kind === 'evolution' && (!Number.isInteger(from) || !Number.isInteger(to) ||
|
||||
from < 0 || to > 6 || to <= from)) return [];
|
||||
return [{
|
||||
id: doc.id, kind, occurredAt: occurredAt.toDate().toISOString(),
|
||||
...(personal ? { amount } : {}),
|
||||
...(kind === 'evolution' ? { fromStage: from, toStage: to } : {}),
|
||||
participationConfirmed: kind === 'evolution' && hasProof &&
|
||||
firstAt.toMillis() <= occurredAt.toMillis(),
|
||||
}];
|
||||
});
|
||||
// 원인 이벤트 ID·운영 조건·원장·다른 사용자의 식별자는 공개 응답에 포함하지 않는다.
|
||||
return { items, nextCursor: page.size > 20 ? rows.at(-1)!.id : null };
|
||||
});
|
||||
Reference in New Issue
Block a user