Kakao 로그인을 SDK와 서버 커스텀 토큰으로 · sFPI 일 집계 직후 Stage 승급 판정
This commit is contained in:
@@ -1,4 +1,11 @@
|
||||
import { onDocumentUpdated } from 'firebase-functions/v2/firestore';
|
||||
import { onCall } from 'firebase-functions/v2/https';
|
||||
import { FieldValue, getFirestore } from 'firebase-admin/firestore';
|
||||
import { logger } from 'firebase-functions';
|
||||
|
||||
import { callerUid, requireRole, str } from '../core/callable.js';
|
||||
import { ERR, fail } from '../core/errors.js';
|
||||
import { sendFandomPush } from '../notify/push.js';
|
||||
|
||||
/** v1.5 §2.1/§7: 확정 Stage 상승을 보존한다. 개인 참여/보상을 추정하지 않는다. */
|
||||
export const fnPlanetEvolutionRecord = onDocumentUpdated(
|
||||
@@ -30,3 +37,93 @@ export const fnPlanetEvolutionRecord = onDocumentUpdated(
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/** 별첨 B **B-53** — 정식 행성 6 단계 명칭. 성운(Stage0)은 `fn-planet-create` 가 다룬다. */
|
||||
const STAGE_NAMES = ['', '시작', '성장', '함께', '동행', '빛', '영원'];
|
||||
const FINAL_STAGE = 6;
|
||||
|
||||
export interface Promotion { fandomId: string; fromStage: number; toStage: number }
|
||||
|
||||
/**
|
||||
* 정식 행성 Stage 승급 판정 — 원장 **B-11 · B-53 · B-54**.
|
||||
*
|
||||
* - **누적 Growth POP(`growthPop`) 과 sFPI(`sfpi`) 를 동시에** 다음 Stage 진입값
|
||||
* (`planetStages/{n}.requiredPop` · `requiredSfpi`, 설계서 §2.3 누계) 이상이어야 오른다.
|
||||
* 하나만 채우면 그대로 대기한다.
|
||||
* - 진입값이 누계라 초과분은 자연히 다음 Stage 진행도로 이월된다(B-54 Carry-over). 그래서
|
||||
* 한 번의 판정에서 여러 Stage 를 채웠으면 **채운 가장 높은 Stage 까지** 올린다.
|
||||
* - 강등하지 않는다(B-53 검증 기준 「Stage 강등 0건」). Stage6 이 마지막이다.
|
||||
* - 다음 Stage 마스터 문서가 없거나 값이 비면 그 Stage 에서 멈춘다 — 빈 값을 0 으로 읽어
|
||||
* 승급시키지 않는다.
|
||||
*
|
||||
* 트랜잭션 안에서 다시 읽고 쓰므로 같은 날 두 번 돌아도 두 번째는 할 일이 없다(멱등).
|
||||
* 기록(`stateHistory`)은 `fnPlanetEvolutionRecord` 가, 연출은 앱이 `stage` 상승을 보고 띄운다.
|
||||
*/
|
||||
export async function promoteStages(fandomIds?: string[]): Promise<Promotion[]> {
|
||||
const db = getFirestore();
|
||||
const stageDocs = await db.collection('planetStages').get();
|
||||
const required = new Map<number, { pop: number; sfpi: number }>();
|
||||
for (const doc of stageDocs.docs) {
|
||||
const pop = doc.get('requiredPop');
|
||||
const sfpi = doc.get('requiredSfpi');
|
||||
if (Number.isFinite(pop) && Number.isFinite(sfpi)) required.set(Number(doc.id), { pop, sfpi });
|
||||
}
|
||||
|
||||
const refs = fandomIds
|
||||
? fandomIds.map((id) => db.doc(`fandoms/${id}`))
|
||||
: (await db.collection('fandoms').where('stage', '>=', 1).select().get()).docs.map((d) => d.ref);
|
||||
|
||||
const promoted: Promotion[] = [];
|
||||
for (const ref of refs) {
|
||||
const result = await db.runTransaction(async (tx) => {
|
||||
const snap = await tx.get(ref);
|
||||
const from = snap.get('stage');
|
||||
if (!Number.isInteger(from) || from < 1 || from >= FINAL_STAGE) return null;
|
||||
const pop = Number(snap.get('growthPop') ?? 0);
|
||||
const sfpi = Number(snap.get('sfpi') ?? 0);
|
||||
let to = from as number;
|
||||
for (let next = to + 1; next <= FINAL_STAGE; next++) {
|
||||
const req = required.get(next);
|
||||
if (!req || pop < req.pop || sfpi < req.sfpi) break;
|
||||
to = next;
|
||||
}
|
||||
if (to === from) return null;
|
||||
tx.update(ref, { stage: to, stageChangedAt: FieldValue.serverTimestamp() });
|
||||
return { fandomId: ref.id, fromStage: from as number, toStage: to, name: snap.get('name') as string | undefined };
|
||||
});
|
||||
if (!result) continue;
|
||||
const { name, ...promotion } = result;
|
||||
promoted.push(promotion);
|
||||
// 원장 C-78 ③ 성취 · C-77 「Evolution」 메시지. 발송 실패는 승급을 되돌리지 않는다.
|
||||
await sendFandomPush({
|
||||
fandomId: ref.id,
|
||||
category: 'fandomEvent',
|
||||
title: `${name ?? ref.id} 행성이 「${STAGE_NAMES[promotion.toStage]}」 단계로 진화했습니다`,
|
||||
body: '당신도 이 진화에 함께했습니다. 행성에서 확인해 보세요.',
|
||||
data: { kind: 'evolution', toStage: String(promotion.toStage) },
|
||||
});
|
||||
}
|
||||
if (promoted.length > 0) logger.info('[evolution] Stage 승급', { promoted });
|
||||
return promoted;
|
||||
}
|
||||
|
||||
/**
|
||||
* `fn-admin-planet-evolution-run` — 승급 판정을 지금 한 팬덤에 돌린다(master 전용).
|
||||
*
|
||||
* 판정은 매일 KST 자정 sFPI 누적 직후(`fnFpiDailySnapshot`)에 돈다. 실기기 확인 때 하루를
|
||||
* 기다리지 않도록 같은 판정을 즉시 부르는 경로다. 조건을 우회하지 않으며 결과(승급 없음
|
||||
* 포함)를 `adminActionLogs` 에 남긴다.
|
||||
*/
|
||||
export const fnAdminPlanetEvolutionRun = onCall(async (req) => {
|
||||
const actorUid = callerUid(req);
|
||||
const role = requireRole(req, ['master']);
|
||||
const fandomId = str(req.data, 'fandomId');
|
||||
if (fandomId.includes('/')) fail(ERR.permissionDenied, '문서 경로가 아닙니다.');
|
||||
const [promotion] = await promoteStages([fandomId]);
|
||||
await getFirestore().collection('adminActionLogs').add({
|
||||
actorUid, role, targetPath: `fandoms/${fandomId}`, reason: 'PLANET_EVOLUTION_RUN',
|
||||
patch: promotion ? { fromStage: promotion.fromStage, stage: promotion.toStage } : { promoted: false },
|
||||
createdAt: FieldValue.serverTimestamp(),
|
||||
});
|
||||
return { fandomId, promoted: promotion ?? null };
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import { rankWithTies } from '../ledger/fpi.js';
|
||||
import { txIdOf } from '../core/ids.js';
|
||||
import { dayKey } from '../core/time.js';
|
||||
import { notifyEclipseWarning } from '../notify/push.js';
|
||||
import { promoteStages } from './evolution.js';
|
||||
import { PUBLIC_NAME_FALLBACK, publicNameOf } from '../core/names.js';
|
||||
|
||||
/**
|
||||
@@ -842,7 +843,7 @@ export const fnFpiDailySnapshot = onSchedule(
|
||||
* 인정해 남긴 FPI 원천 이벤트(`fpiEvents`, verified · uid 있음) 1건 이상** — S01 확정 전
|
||||
* 유지 기준. 활동 0명인 날도 `dau: 0` 으로 남긴다(C-27 「0명인 날 포함」).
|
||||
*/
|
||||
export async function closeDay(now: Date = new Date()): Promise<{ day: string; fandoms: number }> {
|
||||
export async function closeDay(now: Date = new Date()): Promise<{ day: string; fandoms: number; promoted: number }> {
|
||||
const db = getFirestore();
|
||||
const day = dayKey(new Date(now.getTime() - 24 * 60 * 60 * 1000));
|
||||
const from = Timestamp.fromDate(new Date(`${day}T00:00:00+09:00`));
|
||||
@@ -881,7 +882,9 @@ export async function closeDay(now: Date = new Date()): Promise<{ day: string; f
|
||||
tx.set(fandom.ref, { sfpi: FieldValue.increment(fpiScore) }, { merge: true });
|
||||
});
|
||||
}
|
||||
return { day, fandoms: fandoms.size };
|
||||
// 원장 B-54 — sFPI 누적 직후 누적 Growth POP 과 함께 Stage 승급을 판정한다.
|
||||
const promoted = await promoteStages();
|
||||
return { day, fandoms: fandoms.size, promoted: promoted.length };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user