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

1168 lines
55 KiB
TypeScript

import { eclipseTarget } from './eclipse_target.js';
import { hibernationSettings, hibernationPatch } from './hibernation.js';
import { onCall } from 'firebase-functions/v2/https';
import { onSchedule } from 'firebase-functions/v2/scheduler';
import { getFirestore, FieldValue, Timestamp } from 'firebase-admin/firestore';
import { logger } from 'firebase-functions';
import { callerUid, num, str } from '../core/callable.js';
import { ERR, fail, positiveInt } from '../core/errors.js';
import { FPI_AXES, loadPolicy, weightsAreUsable, type FpiAxis } from '../core/config.js';
import { spendPop } from '../ledger/ledger.js';
import { isMemberOf, 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';
/**
* 티커 이벤트 보존 창.
*
* 홈 티커·갤럭시 섬광 배너는 **최근 것만** 읽으므로 오래된 행은 보여 줄 자리가
* 없다. 지우지 않으면 팬덤당 참여 횟수만큼 쌓이기만 하므로 `expiresAt` 을 실어
* Firestore TTL 정책이 쓸어 가게 한다(필드 이름이 정책의 입력이다).
*/
const TICKER_TTL_MS = 60 * 60 * 1000;
/**
* 티커 이벤트 종류 — 별첨 B **「Global Ticker」** 확정.
*
* *Eclipse·Artist Hot Time·Hero Gift·Campaign·공지 등은 **하나의 Global Ticker
* 시스템으로 통합**하고 시작/종료·우선순위·팬덤·국가/언어·클릭이동처를 운영설정으로
* 관리한다*. 그래서 `fandoms/{id}/liveEvents` 를 이클립스 전용으로 굳히지 않고
* `kind` 로 갈라 두었다 — 뒤에 들어올 종류가 컬렉션을 새로 만들지 않아도 된다.
*/
const TICKER_KIND_ECLIPSE_DEFENSE = 'eclipse_defense';
/** 우선순위 기본값. 낮을수록 먼저 뜬다. 종류별 값은 운영설정으로 뺄 자리다. */
const TICKER_PRIORITY: Readonly<Record<string, number>> = {
[TICKER_KIND_ECLIPSE_DEFENSE]: 20,
};
/**
* `fn-eclipse-defend` — 이클립스 방어(**팬덤 공동 Shake 참여**).
*
* 정책 원장 **B-55** 비고 · 정책설계서 v1.1 255~264행 · Firestore 설계서 v1.1 §9-#5 —
* *이클립스 방어는 팬덤 공동 Shake 참여이며 **1 회당 POP 소모를 두지 않는다**. 하루 2 회,
* 세부 수치는 `config` 운영값*. 세 문서 모두 2026-09-11 발주사께 제출되었다.
*
* 별첨 1 ① 시나리오 4 의 「번개 버튼 연타·오토 도네이션」은 별첨 B 가 상위 기준이므로
* (공통 기준 2항) 따르지 않는다. ZAP 버튼은 이클립스 방어가 아니라 **행성 성장 기여**다
* (B-23) — `fn-pop-spend` 로 분리되어 이 함수와 샤드를 공유하지 않는다.
*
* v1.5 §5 확정: 수동 방어는 Shake only, POP 소모 없음. HOME ZAP은 성장 기여다.
*
* 서버가 판정하는 것 셋 — 별첨 1 ② 서버검증 원칙.
* 1. **사용자당·이벤트당 상한**(운영값). 흔들기 횟수를 그대로 믿으면 단말에서 숫자만
* 키워 보내면 된다.
* 2. **속도 제한**(운영값). 상한만 두면 상한까지 한 번에 보내는 것을 막지 못한다.
* 3. **멱등** — 재시도가 샤드·로그·라이브 이벤트를 두 번 올리지 않는다.
*
* 방어량은 샤드 10 개에 흩는다. 한 문서에 몰면 초당 1 회 쓰기 한도에 걸려 방어가
* 몰리는 순간에 오히려 실패한다.
*/
export const fnEclipseDefend = onCall(async (req) => defendEclipse({
uid: callerUid(req),
fandomId: str(req.data, 'fandomId'),
eclipseId: str(req.data, 'eclipseId'),
clientSeq: str(req.data, 'clientSeq'),
shakes: positiveInt(
((req.data as Record<string, unknown>)?.shakes as number | undefined) ?? 1,
'shakes',
),
}));
export interface DefendResult {
/** 이번 호출로 집계에 더해진 방어량. 상한에 걸리면 보낸 값보다 작다. */
accepted: number;
/** 상한 때문에 깎인 양. 화면이 「상한에 닿았습니다」를 띄우는 근거다. */
clamped: number;
/** 이 이벤트에서 내가 지금까지 쌓은 방어량. */
myTotal: number;
/** 재시도로 판정되어 아무것도 더하지 않았는가. */
duplicate: boolean;
}
/**
* 방어 한 건의 코어. `onCall` 래퍼와 떼어 둔 이유는 검증이다 — 샤드 합산 유실과
* 라이브 이벤트 기록은 HTTP 왕복 없이 에뮬레이터에서 직접 돌려 봐야 확인된다.
*/
export async function defendEclipse(input: {
uid: string;
fandomId: string;
eclipseId: string;
clientSeq: string;
/** 단말이 감지한 흔들기 횟수. **서버가 상한과 속도로 다시 판정한다.** */
shakes: number;
}): Promise<DefendResult> {
const { uid, fandomId, eclipseId, clientSeq, shakes } = input;
const db = getFirestore();
// 설계서 v1.2 §2.11 — v0.3.0 `eclipses` · `defenders` 를 `eclipseRounds` · `participants` 로 전환.
const eclipseRef = db.doc(`fandoms/${fandomId}/eclipseRounds/${eclipseId}`);
positiveInt(shakes, 'shakes');
const [user, settings] = await Promise.all([
db.doc(`users/${uid}`).get(),
loadEclipseSettings(),
]);
// 방어는 그 팬덤의 활동이다 — 가입하지 않은 행성의 이클립스는 막을 수 없다(10/3 회신 #10).
if (!isMemberOf(user, fandomId)) {
fail(ERR.permissionDenied, '가입한 팬덤의 이클립스에만 참여할 수 있습니다.');
}
const idempotencyKey = `defend:${uid}:${eclipseId}:${clientSeq}`;
const logRef = eclipseRef.collection('defenseLogs').doc(txIdOf(idempotencyKey));
// 참여자별 누적(`validShakes`). 상한 판정과 속도 제한의 근거이며 문서 ID 가 uid 라
// 같은 사용자의 복수 단말도 1명이다(G-04).
const memberRef = eclipseRef.collection('participants').doc(uid);
const shardRef = eclipseRef.collection('shards').doc(String(Math.floor(Math.random() * 10)));
const tickerRef = db.collection(`fandoms/${fandomId}/liveEvents`).doc();
// **로그 생성을 멱등 게이트로 쓴다.** 조회 후 쓰기로 막으면 그 사이에 끼어든
// 재시도가 샤드를 두 번 올린다. 문서 ID 충돌을 Firestore 가 원자적으로 판정한다.
const result = await db.runTransaction<DefendResult>(async (tx) => {
const [logSnap, memberSnap, eclipse] = await Promise.all([
tx.get(logRef), tx.get(memberRef), tx.get(eclipseRef),
]);
if (logSnap.exists) {
return {
accepted: (logSnap.get('accepted') as number | undefined) ?? 0,
clamped: (logSnap.get('clamped') as number | undefined) ?? 0,
myTotal: (logSnap.get('myTotal') as number | undefined)
?? (memberSnap.get('validShakes') as number | undefined) ?? 0,
duplicate: true,
};
}
const now = Date.now();
const endsAt = eclipse.get('endsAt');
const startAt = eclipse.get('startAt');
if (!eclipse.exists || eclipse.get('status') !== 'active' ||
eclipse.get('defended') === true || !(endsAt instanceof Timestamp) ||
endsAt.toMillis() <= now || !(startAt instanceof Timestamp) || startAt.toMillis() > now) {
fail(ERR.eclipseClosed, '방어 가능한 이클립스가 아닙니다.');
}
const myTotal = (memberSnap.get('validShakes') as number | undefined) ?? 0;
const lastAt = (memberSnap.get('lastAt') as Timestamp | undefined)?.toMillis() ?? 0;
// 속도 제한 — 상한만 두면 상한까지 한 번에 보내는 것을 막지 못한다.
if (now - lastAt < settings.minIntervalMs) {
fail(ERR.questAbnormal, '너무 빠르게 참여했습니다. 잠시 뒤 다시 시도해 주십시오.');
}
// 사용자당 상한. 남은 만큼만 받고 나머지는 깎는다 — 상한을 넘겼다고 통째로
// 버리면 정상 참여의 마지막 묶음이 사라진다.
const room = Math.max(0, settings.perUserMaxDefense - myTotal);
const accepted = Math.min(shakes, settings.perCallMaxShakes, room);
const clamped = shakes - accepted;
// 원장 C-25: 서버가 인정한 인정 횟수(기본 60회, 운영값)에 처음 도달할 때 회차당 한 명만 센다.
// 회차 문서에 고정한 값을 쓴다 — 진행 중 운영값 변경이 같은 회차의 기준을 바꾸지 않는다.
const need = (eclipse.get('qualifyShakes') as number | undefined) ?? settings.qualifyShakes;
const qualified = myTotal < need && myTotal + accepted >= need;
tx.create(logRef, {
uid,
shakes,
accepted,
clamped,
myTotal: myTotal + accepted,
idempotencyKey,
createdAt: FieldValue.serverTimestamp(),
});
tx.set(memberRef, {
uid,
validShakes: FieldValue.increment(accepted),
calls: FieldValue.increment(1),
...(qualified ? { qualified: true, qualifiedAt: Timestamp.fromMillis(now) } : {}),
lastAt: Timestamp.fromMillis(now),
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
if (accepted > 0) {
// 인정 로그·개인 누적·고유 참여자·라이브 집계는 함께 커밋하거나 함께 취소한다.
tx.set(shardRef, {
defense: FieldValue.increment(accepted),
defenseCount: FieldValue.increment(1),
qualifiedParticipants: FieldValue.increment(qualified ? 1 : 0),
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
tx.create(tickerRef, {
kind: TICKER_KIND_ECLIPSE_DEFENSE, eclipseId, uid,
nickname: publicNameOf(user),
defense: accepted,
linkTo: `/eclipse/${fandomId}/${eclipseId}`,
priority: TICKER_PRIORITY[TICKER_KIND_ECLIPSE_DEFENSE] ?? 50,
createdAt: FieldValue.serverTimestamp(),
expiresAt: Timestamp.fromMillis(now + TICKER_TTL_MS),
});
}
return { accepted, clamped, myTotal: myTotal + accepted, duplicate: false };
});
if (result.accepted === 0) return result;
// **성공 판정을 1 분 집계에만 맡기지 않는다**(검수 D2). 목표에 닿은 순간 세워야
// 그 뒤의 참여가 헛돌지 않고, 화면이 성공을 곧 본다.
await markDefendedIfReached(eclipseRef);
return result;
}
/**
* 유효 참여자 샤드 합이 DAU 기반 인원 목표에 닿으면 즉시 성공을 확정한다.
*
* 합산을 여기서 한 번 더 하는 비용을 받아들인 이유는, 1 분 주기 집계만으로는
* 목표를 넘긴 뒤에도 최대 1 분간 참여가 계속 받아들여지기 때문이다.
*/
async function markDefendedIfReached(
eclipseRef: FirebaseFirestore.DocumentReference,
refresh = false,
): Promise<void> {
await eclipseRef.firestore.runTransaction(async (tx) => {
const snap = await tx.get(eclipseRef);
if (!snap.exists || snap.get('status') !== 'active') return false;
if (snap.get('defended') === true) return true;
const shards = await tx.get(eclipseRef.collection('shards'));
const target = snap.get('target');
const invalid = !Number.isSafeInteger(target) || target < 1 || shards.docs.some((doc) =>
!Number.isSafeInteger(doc.get('qualifiedParticipants')) || doc.get('qualifiedParticipants') < 0);
if (invalid) {
tx.update(eclipseRef, {
status: 'cancelled', result: 'cancelled_error',
cancelReason: 'INVALID_PARTICIPANT_AGGREGATE', endedAt: FieldValue.serverTimestamp(),
});
return false;
}
const qualifiedParticipants = shards.docs.reduce((sum, doc) => sum + doc.get('qualifiedParticipants'), 0);
const defended = qualifiedParticipants >= target;
if (refresh || defended) {
tx.update(eclipseRef, {
defense: shards.docs.reduce((sum, doc) => sum + (doc.get('defense') ?? 0), 0),
defenseCount: shards.docs.reduce((sum, doc) => sum + (doc.get('defenseCount') ?? 0), 0),
qualifiedParticipants,
erosionRate: Math.max(0, Math.round((1 - qualifiedParticipants / target) * 100)),
defended,
...(defended ? { defendedAt: FieldValue.serverTimestamp() } : {}),
aggregatedAt: FieldValue.serverTimestamp(),
});
}
return defended;
});
}
/**
* `fn-eclipse-aggregate` — 샤드 합산.
*
* 설계서는 1초 주기라고 적었으나 Cloud Scheduler 의 최소 주기는 1분이다. 실시간
* 감각은 클라이언트가 샤드를 직접 구독해 보완하고, 이 스케줄은 요약 문서의
* 정합성을 맞추는 역할만 한다.
*
* 목표를 채우면 `defended` 를 세워 방어 성공을 확정한다. **`status` 는 건드리지 않는다** — 활성 질의에서
* 문서가 빠지면 화면이 성공한 순간을 놓치고 배너가 그냥 사라진다.
*/
export const fnEclipseAggregate = onSchedule('every 1 minutes', async () => {
const count = await aggregateActiveEclipses();
logger.info('[eclipse] 합산 완료', { eclipses: count });
});
/**
* 활성 이클립스 전부를 합산한다. 합산한 건수를 돌려준다.
*
* 스케줄 래퍼와 떼어 둔 이유는 검증이다 — 「두 클라이언트가 동시에 방어 →
* 집계 일치」를 확인하려면 테스트가 합산을 직접 한 번 돌려야 한다.
*/
export async function aggregateActiveEclipses(): Promise<number> {
const db = getFirestore();
const actives = await db.collectionGroup('eclipseRounds')
.where('status', '==', 'active').get();
// 푸시는 여기서 보내지 않는다 — 활성화 뒤라 C-22 「시작 − 3분」을 지킬 수 없다.
// 사전 알림은 `runEclipseSchedule` 이 `warnAt` 에 보낸다.
for (const eclipse of actives.docs) {
await markDefendedIfReached(eclipse.ref, true);
}
return actives.size;
}
/**
* 이클립스 운영값 — Admin 「관리 마스터_이클립스 관리」(원장 §4.2 S-AD-ECLIPSE)의 항목.
*
* Firestore 설계서 v1.1 에 이클립스 운영값 경로 정의가 없어 `config/eclipse` 로 둔다
* (§2.7 `config/features` 와 같은 자리). 팬덤별 조정은 `fandoms/{id}.eclipseSettings`
* 가 이 값을 덮는다 — Admin 화면이 **행성마다** 활성화를 켜고 끄기 때문이다.
*
* **기본값이 `enabled: false` 인 이유.** 운영값 문서가 없는 배포가 스스로 FPI 를
* 깎기 시작하면 안 된다. 침식은 되돌릴 수는 있어도 사용자 눈에는 이미 일어난 일이다.
*/
export interface EclipseSettings {
/** 이 팬덤에 이클립스를 발동하는가. Admin 「활성화」. */
enabled: boolean;
/**
* 공통 발생 일정 — 원장 **C-24** · 설계서 v1.2 `config/eclipse.schedule[]`.
* 배열 길이가 곧 하루 회차 수다(**하루 2회는 고정 제한이 아니다**). 팬덤 문서의
* `eclipseSchedule[]` 이 있으면 그것이 우선한다.
*/
schedule: EclipseSlot[];
/** 1회 발동당 FPI 침식률(%). Admin 「FPI 감소 수치」 · 수치표 v1.4 「침식 0.25% FPI」. */
erosionPercent: number;
/** 사전 알림 시간(초). 원장 C-22 「발생 3분 전 푸시」 = 180. `warnAt` 에 발송한다. */
warnSeconds: number;
/** 평균 DAU 대비 고유 유효 참여자 목표율. 원장 C-25 기본 5%. */
targetRate: number;
/** 목표 분모의 DAU 평균 기간(일). 원장 C-21 기본 7일 · 운영값. */
dauWindowDays: number;
/** 공통 방어 창(초). 원장 C-22 기본 60초 · 운영값. */
windowSec: number;
/** 고유 참여자 1명으로 인정하는 개인 유효 Shake 누적. 원장 C-25 기본 60회 · 운영값. */
qualifyShakes: number;
/** 한 호출이 실을 수 있는 최대 흔들기 수. 서버 상한이며 단말 값을 믿지 않는다. */
perCallMaxShakes: number;
/** 한 사용자가 한 이벤트에서 쌓을 수 있는 최대 방어량. 어뷰징 상한(P-06). */
perUserMaxDefense: number;
/** 같은 사용자의 연속 호출 최소 간격(ms). 속도 제한. */
minIntervalMs: number;
}
// UX·FX 구현정렬기준 v1.5 §5 확정 기본값. 팬덤별 운영 설정은 이를 덮는다.
export const DEFAULT_ECLIPSE: EclipseSettings = {
enabled: false,
schedule: [{ hhmm: '07:50' }, { hhmm: '17:50' }],
erosionPercent: 0.25,
warnSeconds: 180,
targetRate: 0.05,
dauWindowDays: 7,
windowSec: 60,
qualifyShakes: 60,
// 🟡 임시값 — 회신 대기(해석 확인 요청 2026-10-07 영역A Q-A10). 아래 세 값은 자료 근거가 없다
// (9/16 회신 3절 4항 「센서 판정 수치는 근거 없이 추가 확정하지 않음」). `config/eclipse` 운영값이 덮는다.
perCallMaxShakes: 50,
perUserMaxDefense: 500,
minIntervalMs: 300,
};
/** 일정 한 칸. `tz` 는 설계서 v1.2 `{hhmm, tz}` 이며 비우면 KST 다. */
export interface EclipseSlot {
hhmm: string;
tz?: string;
}
/** Eclipse 실패 확정 차감의 사유 코드. */
const FPI_EROSION_REASON = 'ECLIPSE_EROSION';
export async function loadEclipseSettings(): Promise<EclipseSettings> {
const snap = await getFirestore().doc('config/eclipse').get();
return withSettings(DEFAULT_ECLIPSE, snap.data() ?? {});
}
/**
* [base] 위에 운영값 문서 [raw] 를 덮는다. v0.3.0 필드(`firstAt` · `secondAt` ·
* `windowMinutes`)만 있는 문서도 읽는다 — 운영값을 옮기기 전에 배포돼도 일정이 끊기지 않는다.
*/
function withSettings(base: EclipseSettings, raw: Record<string, unknown>): EclipseSettings {
const legacySchedule = typeof raw.firstAt === 'string' || typeof raw.secondAt === 'string'
? [{ hhmm: (raw.firstAt as string | undefined) ?? '07:50' },
{ hhmm: (raw.secondAt as string | undefined) ?? '17:50' }]
: undefined;
return {
...base,
...raw,
schedule: Array.isArray(raw.schedule) ? raw.schedule as EclipseSlot[] : legacySchedule ?? base.schedule,
windowSec: typeof raw.windowSec === 'number' ? raw.windowSec
: typeof raw.windowMinutes === 'number' ? raw.windowMinutes * 60 : base.windowSec,
} as EclipseSettings;
}
/**
* KST 날짜(`YYYY-MM-DD`)와 일정 한 칸을 절대 시각으로 옮긴다.
*
* ponytail: `tz` 는 KST(`Asia/Seoul`)만 받는다. 회차 ID 의 날짜 키가 KST 라서다. 다른
* 시간대가 필요해지면 `Intl` 로 그 시간대의 오프셋을 구해 여기서 옮긴다.
*/
function kstMoment(day: string, slot: EclipseSlot | undefined): Date | null {
const hhmm = slot?.hhmm;
if (typeof hhmm !== 'string' || !/^\d{2}:\d{2}$/.test(hhmm)) return null;
if (slot?.tz !== undefined && slot.tz !== 'Asia/Seoul') return null;
const at = new Date(`${day}T${hhmm}:00+09:00`);
return Number.isNaN(at.getTime()) ? null : at;
}
/**
* `fn-eclipse-trigger` — 이클립스 발동 · 사전 알림 · 종료.
*
* 원장 **C-24** — *기본 KST 07:50 · 17:50, 팬덤별 발생 횟수 · 각 시각 별도 설정, 미설정 시
* 공통값. 하루 2회는 고정 제한이 아님. 일정 변경 시 푸시 · 종료 시각 함께 계산*.
*
* **중복 발동을 조건문으로 막지 않는다.** 문서 ID 를 `{KST 날짜}-{회차}` 로 고정하고
* `create()` 로 만들므로, 같은 분에 두 인스턴스가 겹쳐도 둘째가 ALREADY_EXISTS 로
* 떨어진다. 조회 후 쓰기로 막으면 그 사이에 끼어든 요청이 두 번째 이클립스를 만든다.
*
* **사전 알림 시각부터 문서를 만들고 `status: 'scheduled'` 로 둔다.** 발동 시각에야
* 문서를 만들면 `warnAt` 이 이미 지난 시각이라 사전 알림을 보낼 수 없다. 푸시
* 예고(원장 C-22 「발생 3분 전 푸시」)는 이 함수가 `warnAt` 에 바로 보내고, 앱의 경보
* 배너는 `active` 만 구독하므로 시작 전에 배너가 뜨지는 않는다.
*
* 시작 시 FPI 스냅샷만 남기고, 마감 후 실패 확정 때에만 그 기준으로 차감한다.
*/
export const fnEclipseTrigger = onSchedule(
{ schedule: 'every 1 minutes', timeZone: 'Asia/Seoul' },
async () => {
const result = await runEclipseSchedule();
logger.info('[eclipse] 발동 점검 완료', result);
},
);
export interface EclipseScheduleResult {
scheduled: number;
activated: number;
closed: number;
}
/**
* 예약·활성화·종료를 한 바퀴 돈다.
*
* [now] 를 인자로 받는 이유는 검증이다 — 「하루 정확히 2 회」와 「중복 발동 0 건」은
* 시각을 옮겨 가며 여러 번 돌려야 확인되고, 실제 시계로는 확인할 수 없다.
*/
export async function runEclipseSchedule(
now: Date = new Date(),
): Promise<EclipseScheduleResult> {
const db = getFirestore();
const global = await loadEclipseSettings();
const day = dayKey(now);
const fandoms = await db.collection('fandoms')
.select('fpiAxes', 'name', 'createdAt', 'eclipseSettings', 'eclipseSchedule').get();
const result: EclipseScheduleResult = { scheduled: 0, activated: 0, closed: 0 };
for (const fandom of fandoms.docs) {
const settings = withSettings(
global, (fandom.get('eclipseSettings') as Record<string, unknown> | undefined) ?? {},
);
// 팬덤별 일정이 있으면 우선, 없으면 공통값(C-24). 길이가 곧 하루 회차 수다.
const own = fandom.get('eclipseSchedule');
const schedule = Array.isArray(own) && own.length > 0 ? own as EclipseSlot[] : settings.schedule;
// 종료는 활성화 여부와 무관하게 돈다. 운영값을 끄는 것이 이미 열린 이클립스를
// 영원히 열린 채로 두는 뜻이 되면, 앱의 경보 배너가 내려가지 않는다.
result.closed += await closeExpiredEclipses(fandom.ref, now);
if (!settings.enabled) continue;
for (const [i, slot] of schedule.entries()) {
const round = i + 1;
const startAt = kstMoment(day, slot);
if (startAt === null) {
// 운영값이 깨진 것이다. 건너뛰고 남긴다 — 말없이 넘기면 이클립스가 안 도는
// 이유를 찾을 근거가 사라진다.
logger.error('[eclipse] 발동 시각 운영값을 읽을 수 없습니다', {
fandomId: fandom.id, round, value: slot,
});
continue;
}
// 푸시 = 시작 − 사전 알림, 종료 = 시작 + 창. 일정이 바뀌면 둘 다 여기서 다시 나온다.
const warnAt = new Date(startAt.getTime() - settings.warnSeconds * 1_000);
if (now < warnAt) continue;
const ref = fandom.ref.collection('eclipseRounds').doc(`${day}-${round}`);
// 이미 있는 회차는 목표를 다시 산출하지 않는다 — 매분 DAU 문서 7건을 읽지 않게.
let snap = await ref.get();
if (!snap.exists) {
if (await createScheduledEclipse(ref, {
day, round, settings, startAt, warnAt,
createdAt: fandom.get('createdAt'),
fandomName: (fandom.get('name') as string | undefined) ?? fandom.id,
})) {
result.scheduled += 1;
}
snap = await ref.get();
}
await notifyEclipseWarning(snap, now);
if (now >= startAt && snap.get('status') === 'scheduled') {
if (await activateEclipse(fandom.ref, ref, now)) result.activated += 1;
}
}
}
return result;
}
/** 예약 문서를 만든다. 이미 있으면 false. */
async function createScheduledEclipse(
ref: FirebaseFirestore.DocumentReference,
input: {
day: string;
round: number;
settings: EclipseSettings;
startAt: Date;
warnAt: Date;
fandomName: string;
createdAt: unknown;
},
): Promise<boolean> {
const target = await eclipseTarget(
ref.parent.parent!, input.createdAt, input.day,
input.settings.targetRate, input.settings.dauWindowDays,
);
try {
await ref.create({
status: 'result' in target ? 'cancelled' : 'scheduled',
...target,
round: input.round,
dayKey: input.day,
fandomName: input.fandomName,
erosionPercent: input.settings.erosionPercent,
erosionRate: 100,
defense: 0,
defenseCount: 0,
defended: false,
// 설계서 v1.2 §2.11 — 공통 창이다. 늦게 들어온 참여자에게 창을 새로 주지 않는다.
startAt: Timestamp.fromDate(input.startAt),
windowSec: input.settings.windowSec,
endsAt: Timestamp.fromMillis(input.startAt.getTime() + input.settings.windowSec * 1_000),
qualifyShakes: input.settings.qualifyShakes,
// 사전 알림을 보낼 시각(C-22 시작 − 3분).
warnAt: Timestamp.fromDate(input.warnAt),
appliedAt: null,
createdAt: FieldValue.serverTimestamp(),
});
return true;
} catch (e) {
// 같은 분에 두 인스턴스가 겹친 정상 경합이다. 그 밖의 실패는 남긴다.
const code = (e as { code?: number | string }).code;
if (code === 6 || code === 'already-exists') return false;
throw e;
}
}
/** 시작 시에는 FPI 스냅샷만 고정한다. 선차감·성공 복구는 하지 않는다. */
async function activateEclipse(
fandomRef: FirebaseFirestore.DocumentReference,
ref: FirebaseFirestore.DocumentReference,
now: Date,
): Promise<boolean> {
return ref.firestore.runTransaction(async (tx) => {
const [snap, fandom] = await Promise.all([tx.get(ref), tx.get(fandomRef)]);
if (!snap.exists || snap.get('status') !== 'scheduled') return false;
const fpi = fandom.get('fpiScore');
const end = snap.get('endsAt');
const rate = snap.get('erosionPercent');
if (typeof fpi !== 'number' || !Number.isFinite(fpi) || fpi < 0 ||
typeof rate !== 'number' || !Number.isFinite(rate) || rate < 0 || rate > 100 ||
!(end instanceof Timestamp) || end.toMillis() <= now.getTime()) {
tx.update(ref, { status: 'cancelled', result: 'cancelled_error', cancelReason: 'INVALID_START_SNAPSHOT_OR_WINDOW' });
return false;
}
// 감소량은 시작 FPI 로만 정해진다(C-23). 적용 여부는 `appliedAt` 이 말한다.
tx.update(ref, {
status: 'active', fpiAtStart: fpi, penalty: fpi * rate / 100,
activatedAt: Timestamp.fromDate(now),
});
return true;
});
}
/** 마감 판정과 실패 차감은 같은 트랜잭션으로 회차당 한 번만 확정한다. */
async function closeExpiredEclipses(
fandomRef: FirebaseFirestore.DocumentReference,
now: Date,
): Promise<number> {
const actives = await fandomRef.collection('eclipseRounds').where('status', '==', 'active').get();
let closed = 0;
for (const doc of actives.docs) {
const changed = await fandomRef.firestore.runTransaction(async (tx) => {
const [snap, shards, fandom] = await Promise.all([
tx.get(doc.ref), tx.get(doc.ref.collection('shards')), tx.get(fandomRef),
]);
if (snap.get('status') !== 'active') return false;
const end = snap.get('endsAt');
if (end instanceof Timestamp && end.toMillis() > now.getTime()) return false;
const target = snap.get('target');
const planned = snap.get('penalty');
const qualified = shards.docs.reduce((sum, shard) => sum + shard.get('qualifiedParticipants'), 0);
const valid = end instanceof Timestamp && Number.isSafeInteger(target) && target >= 1 &&
Number.isSafeInteger(qualified) && qualified >= 0 && shards.docs.every((shard) =>
Number.isSafeInteger(shard.get('qualifiedParticipants')) && shard.get('qualifiedParticipants') >= 0) &&
typeof planned === 'number' && Number.isFinite(planned) && planned >= 0 &&
typeof fandom.get('fpiScore') === 'number' && Number.isFinite(fandom.get('fpiScore'));
if (!valid) {
tx.update(doc.ref, {
status: 'cancelled', result: 'cancelled_error',
cancelReason: 'INVALID_FINAL_AGGREGATE', endedAt: Timestamp.fromDate(now),
});
return true;
}
const defended = snap.get('defended') === true || qualified >= target;
const penalty = defended ? 0 : planned;
const occurredAt = Timestamp.fromDate(now);
if (penalty > 0) {
tx.create(fandomRef.collection('fpiEvents').doc(`eclipse-${doc.id}-penalty`), {
kind: 'score_adjustment', value: -penalty, verified: true,
reasonCode: FPI_EROSION_REASON, eclipseId: doc.id,
occurredAt, createdAt: FieldValue.serverTimestamp(),
});
tx.update(fandomRef, {
// FPI 는 0 아래로 내려가지 않는다(리뷰 M2). 감소량 기록(penalty)은 그대로 남긴다.
fpiScore: Math.max(0, (fandom.get('fpiScore') as number) - penalty),
eclipsePenaltyRevision: FieldValue.increment(1),
});
}
// `appliedAt` 은 실패 확정 시각에만 채운다(설계서 v1.2 §2.11). 성공은 무차감이다.
tx.update(doc.ref, {
status: 'ended', result: defended ? 'success' : 'failed',
defended, qualifiedParticipants: qualified,
endedAt: occurredAt, ...(defended ? {} : { appliedAt: occurredAt }),
});
return true;
});
if (changed) closed++;
}
return closed;
}
/**
* `fn-fpi-aggregate` — FPI 집계(§4) · **1 분 주기**(별첨 B 「FPI 집계」 확정).
*
* 최근 7일 창 안의 검증 완료 이벤트만 축별로 합산하고 가중 합을 낸다. 고정 감쇠는
* 두지 않는다 — 창 밖 이벤트가 자연히 빠지므로 감쇠를 겹치면 두 번 깎인다.
* FPI 감소는 Eclipse 만 담당한다(별첨 B 「FPI 기간」).
*
* **축별 순위(`fpiRanks`)를 여기서 함께 적는다.** 앱의 지표 카드가 「전체 n위」를
* 띄우고 지표별 랭킹 화면이 같은 축으로 정렬하는데, 두 화면이 각자 순위를 매기면
* 값이 갈린다(발주사 검토회신서 4절 「지표 카드와 Ranking 사이 수치·순위 불일치」).
*
* 모집단은 별첨 B 「FPI 랭킹」 확정값 — *정식 행성(Stage 1 이상) 중 최근 7 일
* 유효 활동 존재 행성*. `rankingEligible` 한 필드로 내려 주어야 클라이언트가
* 정렬 키와 다른 필드에 부등호를 거는 질의를 만들지 않는다.
*
* 실패하면 직전 값을 유지한다. 부분 갱신은 랭킹을 뒤집어 놓는다.
*/
export const fnFpiAggregate = onSchedule('every 1 minutes', async () => {
const db = getFirestore();
const policy = await loadPolicy();
if (!weightsAreUsable(policy.fpiWeights)) {
// 직전 값을 그대로 두고 물러난다. 잘못된 가중치로 덮으면 랭킹이 뒤집힌다.
logger.error('[fpi] 가중치를 쓸 수 없습니다', { weights: policy.fpiWeights });
return;
}
const sleepConfig = await db.doc('config/hibernation').get();
let sleepSettings: ReturnType<typeof hibernationSettings> = null;
try {
sleepSettings = hibernationSettings(sleepConfig.data() ?? {});
} catch (error) {
// 불완전한 운영 설정은 FPI 집계를 막지 않으며 휴면 상태를 임의로 바꾸지 않는다.
logger.error('[hibernation] 운영 설정 확인 필요', { error: String(error) });
}
const evaluatedAt = Timestamp.now();
const since = Timestamp.fromMillis(
Date.now() - policy.fpiWindowDays * 24 * 60 * 60 * 1000,
);
// 이 루프가 문서에서 쓰는 필드는 `stage` 와 `fpiRanks` 둘뿐이다. 전체 문서를
// 받으면 행성 수 × 문서 크기만큼 읽기 대역이 늘고, 1 분마다 그 값을 치른다.
const fandoms = await db.collection('fandoms').select('stage', 'fpiRanks', 'eclipsePenaltyRevision').get();
const rows: {
ref: FirebaseFirestore.DocumentReference;
axes: Record<FpiAxis, number>;
fpiScore: number;
penaltyRevision: number;
eligible: boolean;
activity: { id: string; at: Timestamp } | undefined;
prevRanks: Partial<Record<FpiAxis, number>>;
}[] = [];
for (const fandom of fandoms.docs) {
const events = await fandom.ref.collection('fpiEvents')
.where('verified', '==', true)
.where('occurredAt', '>=', since)
.get();
const axes = Object.fromEntries(FPI_AXES.map((a) => [a, 0])) as Record<FpiAxis, number>;
let activity: { id: string; at: Timestamp } | undefined;
let scoreAdjustment = 0;
// 랭킹 모집단의 「최근 7 일 유효 활동」은 팬 활동만 센다. Eclipse 차감 이벤트는 활동이 아니다.
let hasActivity = false;
for (const ev of events.docs) {
if (ev.get('kind') === 'score_adjustment' && ev.get('reasonCode') === FPI_EROSION_REASON) {
const value = ev.get('value');
if (typeof value !== 'number' || !Number.isFinite(value) || value > 0) throw new Error('Invalid Eclipse penalty');
scoreAdjustment += value;
continue;
}
const axis = ev.get('axis') as FpiAxis | undefined;
if (!axis || !(axis in axes)) continue;
axes[axis] += (ev.get('value') as number | undefined) ?? 0;
if (((ev.get('value') as number | undefined) ?? 0) > 0) hasActivity = true;
const occurredAt = ev.get('occurredAt');
// 침식 복원은 새 팬 활동이 아니다. Wake-up은 실제 신규 인정 활동만 쓴다.
if (ev.get('value') > 0 && occurredAt instanceof Timestamp &&
occurredAt.toMillis() <= evaluatedAt.toMillis() &&
!String(ev.get('reasonCode') ?? '').startsWith('ECLIPSE_') &&
(!activity || occurredAt.toMillis() > activity.at.toMillis())) {
activity = { id: ev.id, at: occurredAt };
}
}
const fpiScore = FPI_AXES
.reduce((acc, axis) => acc + axes[axis] * policy.fpiWeights[axis], 0);
rows.push({
ref: fandom.ref,
axes,
activity,
fpiScore: Math.max(0, Math.round(fpiScore) + scoreAdjustment),
penaltyRevision: (fandom.get('eclipsePenaltyRevision') as number | undefined) ?? 0,
// 성운은 Stage 0 이고 정식 행성은 1 이상이다(별첨 B 「행성 Stage 구성」).
eligible: ((fandom.get('stage') as number | undefined) ?? 0) >= 1
&& hasActivity,
prevRanks: (fandom.get('fpiRanks') as Record<FpiAxis, number> | undefined) ?? {},
});
}
// 축마다 따로 줄을 세운다. 축 하나로 매긴 순위를 다섯 축에 돌려 쓰면 발주사가
// 본 「서로 다른 지표 화면에 동일한 Ranking」이 그대로 재현된다.
const ranks = new Map<FirebaseFirestore.DocumentReference, Partial<Record<FpiAxis, number>>>();
for (const axis of FPI_AXES) {
// 공동 순위 1·1·3 · 동점 내부는 fandomId 오름차순 고정(C-19).
for (const { item: row, rank } of rankWithTies(
rows.filter((r) => r.eligible), (r) => r.axes[axis], (r) => r.ref.id)) {
const bucket = ranks.get(row.ref) ?? {};
bucket[axis] = rank;
ranks.set(row.ref, bucket);
}
}
for (const row of rows) {
const update = {
fpiScore: row.fpiScore,
fpiAxes: row.axes,
fpiRanks: ranks.get(row.ref) ?? {},
// 순위 동향은 「직전 순위 − 현재 순위」다. 직전 순위를 남겨 두지 않으면
// 화면이 ▲▼ 를 그릴 근거가 없다.
fpiRanksPrev: row.prevRanks,
rankingEligible: row.eligible,
fpiUpdatedAt: FieldValue.serverTimestamp(),
};
{
const settings = sleepSettings;
await db.runTransaction(async (tx) => {
const current = await tx.get(row.ref);
// 입력을 읽은 뒤 실패 차감이 확정됐으면 보존하고 다음 집계에서 다시 계산한다.
if (!current.exists || (current.get('eclipsePenaltyRevision') ?? 0) !== row.penaltyRevision) return;
const patch = settings
? hibernationPatch(current.data()!, row.fpiScore, settings, evaluatedAt, row.activity)
: {};
tx.update(row.ref, { ...update, ...patch });
if (patch.hibernationChangedAt) {
const sleeping = patch.isHibernating === true;
// 상태와 이력을 함께 확정한다. 재시도에서는 상태 전환이 없어 추가되지 않는다.
tx.create(row.ref.collection('stateHistory').doc(`hibernation-${evaluatedAt.toMillis()}`), {
kind: sleeping ? 'hibernation' : 'wake_up',
occurredAt: evaluatedAt,
fromHibernating: current.get('isHibernating') === true,
toHibernating: sleeping,
reasonCode: sleeping ? 'LOW_FPI_SUSTAINED' : 'NEW_ACTIVITY_RECOVERY_SUSTAINED',
source: 'fnFpiAggregate',
sourceEventId: sleeping ? null : row.activity?.id ?? null,
fpiScore: row.fpiScore,
qualifyingSince: sleeping ? patch.hibernationLowSince : patch.hibernationWakeSince,
settings,
});
}
});
}
}
logger.info('[fpi] 집계 완료', { fandoms: fandoms.size });
});
/**
* HUD 「LIVE」 — 팬덤별 현재 접속자 수(해석 확인 요청 Q-GM-03 권고 A안).
*
* 최근 `windowMinutes`(운영값 5) 안에 앱을 쓴(`users.lastSeenAt` — 앱이 포그라운드에서 갱신)
* 사용자를 그 사람이 가입한 팬덤(`users.fandomIds` — 가입 · 탈퇴 트랜잭션이 유지)마다 1명으로 센다.
* 읽기는 접속자 수만큼 한 번뿐이다. 멤버 수(`memberCount`)와는 다른 값이다.
*/
export async function onlineCounts(windowMinutes: number, now = Date.now()): Promise<Map<string, number>> {
const seen = await getFirestore().collection('users')
.where('lastSeenAt', '>=', Timestamp.fromMillis(now - windowMinutes * 60_000)).select('fandomIds').get();
const counts = new Map<string, number>();
for (const user of seen.docs) {
const ids = user.get('fandomIds');
if (!Array.isArray(ids)) continue;
for (const id of new Set(ids)) if (typeof id === 'string') counts.set(id, (counts.get(id) ?? 0) + 1);
}
return counts;
}
/**
* `fn-live-aggregate` — LIVE 집계를 FPI 1분 집계와 떼어 **2분 주기**로 돈다. 접속자가 몰려도
* FPI 집계가 밀리지 않게 하고, 시간 상한을 명시한다. 이번에 0 이 된 팬덤만 골라 0 으로 내린다.
*/
export const fnLiveAggregate = onSchedule(
{ schedule: 'every 2 minutes', timeoutSeconds: 110 },
async () => { await aggregateLive(); },
);
export async function aggregateLive(now = Date.now()): Promise<number> {
const db = getFirestore();
const { liveWindowMinutes } = await loadPolicy();
const counts = await onlineCounts(liveWindowMinutes, now);
const stale = await db.collection('fandoms').where('onlineCount', '>', 0).select().get();
const writer = db.bulkWriter();
// 사용자 문서에 남은 없는 팬덤 ID 는 update 가 NOT_FOUND 로 떨어진다 — 재시도하지 않고 넘긴다.
writer.onWriteError(() => false);
for (const doc of stale.docs) {
if (!counts.has(doc.id)) void writer.update(doc.ref, { onlineCount: 0 }).catch(() => undefined);
}
for (const [id, n] of counts) {
void writer.update(db.doc(`fandoms/${id}`), { onlineCount: n }).catch(() => undefined);
}
await writer.close();
return counts.size;
}
/**
* `fn-fpi-daily-snapshot` — Daily FPI Snapshot · **일 1 회**(별첨 B 「FPI 집계」).
*
* 별첨 B — *Daily FPI Snapshot 은 일 1 회 sFPI 에 누적합니다* · *별도 환산비율을
* 두지 않습니다. Daily FPI Snapshot 을 하루 1 회 그대로 누적한 Sum FPI 가 sFPI 입니다*.
*
* 1 분 집계와 나눠 둔 이유 — 앱의 「최근 동향」은 *현재값 − 오늘 스냅샷*이다.
* 스냅샷을 1 분마다 쓰면 그 차가 늘 0 이 되어 화면에서 증감이 사라진다.
*/
export const fnFpiDailySnapshot = onSchedule(
// `every 24 hours` 는 배포 시각 기준 상대 주기라 재배포할 때마다 경계가 옮겨
// 다니고, 하루 두 번 도는 날이 생긴다. 문서 ID 가 KST 날짜(대조표 4-2)이므로
// 스케줄도 KST 자정 고정으로 못 박는다.
{ schedule: '0 0 * * *', timeZone: 'Asia/Seoul' },
async () => {
const result = await closeDay();
logger.info('[fpi] 일일 스냅샷 · DAU 집계 완료', result);
},
);
/**
* KST 하루를 닫는다 — Daily FPI Snapshot 과 팬덤 일별 DAU.
*
* **키는 집계 대상일(전날 KST)이다.** KST 자정 직후 도는 작업이 실행일 키를 쓰면 전날
* 활동분이 당일 키로 남는다(S01 조사). [now] 는 테스트가 경계 시각을 옮기려고 받는다.
*
* DAU(`fandoms/{id}/fandomDailyStats/{day}.dau`)는 Eclipse 목표의 입력이다(C-21 · C-26).
* 쓰는 곳이 없으면 모든 회차가 「집계 누락 취소」(C-28)로 간다. **유효 활동 = 서버가
* 인정해 남긴 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; 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`));
const to = Timestamp.fromMillis(from.toMillis() + 24 * 60 * 60 * 1000);
const fandoms = await db.collection('fandoms').select('fpiAxes', 'fpiScore').get();
for (const fandom of fandoms.docs) {
const fpiAxes = (fandom.get('fpiAxes') as Record<FpiAxis, number> | undefined)
?? Object.fromEntries(FPI_AXES.map((a) => [a, 0])) as Record<FpiAxis, number>;
const fpiScore = (fandom.get('fpiScore') as number | undefined) ?? 0;
// ponytail: 팬덤당 하루치 이벤트 전수 조회. 팬덤이 커지면 이벤트 적재 때 일별 고유
// 사용자 문서(`fandomDailyStats/{day}/users/{uid}`)를 함께 써서 개수만 세도록 옮긴다.
const events = await fandom.ref.collection('fpiEvents')
.where('occurredAt', '>=', from).where('occurredAt', '<', to).get();
const users = new Set(events.docs
.filter((e) => e.get('verified') === true && typeof e.get('uid') === 'string'
&& e.get('dauEligible') !== false)
.map((e) => e.get('uid') as string));
// 스냅샷과 누적값은 함께 확정한다. 중간 실패가 하루치를 영구 누락시키지 않는다.
await db.runTransaction(async (tx) => {
const snapshotRef = fandom.ref.collection('fpiSnapshots').doc(day);
const statsRef = fandom.ref.collection('fandomDailyStats').doc(day);
const snapshot = await tx.get(snapshotRef);
// DAU 는 재실행에도 같은 값이다(같은 하루의 같은 이벤트). 덮어써도 안전하다.
tx.set(statsRef, {
day, dau: users.size, source: 'fpiEvents', computedAt: FieldValue.serverTimestamp(),
});
if (snapshot.exists) return;
tx.create(snapshotRef, {
fpiAxes,
fpiScore,
createdAt: FieldValue.serverTimestamp(),
});
tx.set(fandom.ref, { sfpi: FieldValue.increment(fpiScore) }, { merge: true });
});
}
// 원장 B-54 — sFPI 누적 직후 누적 Growth POP 과 함께 Stage 승급을 판정한다.
const promoted = await promoteStages();
return { day, fandoms: fandoms.size, promoted: promoted.length };
}
/**
* `fn-ranking-country` — **사용자 지표 랭킹 + 국가별 지표 랭킹** 사전 집계 · 5 분 주기(설계서 §2.6 · §4.4).
*
* 1 분 FPI 집계(`fnFpiAggregate`)는 행성 단위만 적는다. 멤버 단위 값은 멤버 전수를 훑어야
* 해서 국가 집계와 같은 5 분 배치에서 한 번에 낸다(설계서 §2.6 「멤버 전수 스캔이라 5 분」).
*
* 1. **멤버별 축 값** — 행성 FPI 와 같은 원천(최근 `fpiWindowDays` 일 · verified `fpiEvents`)을
* `uid` 별로 합산해 `members/{uid}.fpiAxes` 에 적는다. Eclipse 차감은 행성 총점만 깎으므로
* 개인 값에 넣지 않는다.
* 2. **멤버 축별 순위** `rankByAxis` (C-19 1·1·3, 동점 내부 uid 오름차순) · 직전 순위
* `rankPrevByAxis` (= 직전 배치의 `rankByAxis`, 행성 `fpiRanksPrev` 와 같은 정의) ·
* 「내 상위 %」 `topPercentByAxis` = ⌈순위 ÷ 멤버 수 × 100⌉ — 그 축 기여가 0 이면 적지 않는다
* (「상위 100%」는 최하위라는 사실 주장이 된다).
* 3. **국가 합** — 위 멤버 값을 사용자 프로필 `users/{uid}.countryCode` 로 묶는다(없으면 `ZZ`).
* 멤버 문서에도 `countryCode` · 공개 닉네임(`nickname`, M-7)을 비정규화해 앱이 「내 국가」 행과
* 사용자 랭킹 이름을 멤버 문서 한 건으로 그리게 한다.
*
* 모집단은 행성 랭킹과 같다 — 별첨 B 「정식 행성 중 최근 7 일 유효 활동 존재 행성」(B-17,
* `rankingEligible`). 모집단에서 빠진 행성은 멤버 랭킹 필드를 지워 지난 순위가 남지 않게 한다.
* 바뀐 멤버 문서만 쓴다.
*
* ponytail: 대상 행성의 멤버 · 7 일 이벤트 전수 스캔이다. 멤버가 늘어 실행 시간을 넘으면
* 행성별 샤드 배치(Cloud Tasks)로 쪼갠다.
*/
export const fnRankingCountryAggregate = onSchedule(
{ schedule: 'every 5 minutes', timeoutSeconds: 280 },
async () => { await aggregateRankings(); },
);
const MEMBER_RANK_FIELDS = ['fpiAxes', 'rankByAxis', 'rankPrevByAxis', 'topPercentByAxis'] as const;
export async function aggregateRankings(now = Date.now()): Promise<{ fandoms: number; members: number; countries: number }> {
const db = getFirestore();
const policy = await loadPolicy();
const since = Timestamp.fromMillis(now - policy.fpiWindowDays * 24 * 60 * 60 * 1000);
const [eligible, ranked] = await Promise.all([
db.collection('fandoms').where('rankingEligible', '==', true).select().get(),
db.collection('fandoms').where('memberRanked', '==', true).select('rankingEligible').get(),
]);
const writer = db.bulkWriter();
// 모집단에서 빠진 행성 — 지난 순위를 지운다. 남겨 두면 활동 없는 행성에 옛 순위 · 상위 % 가 뜬다.
for (const fandom of ranked.docs) {
if (fandom.get('rankingEligible') === true) continue;
const members = await fandom.ref.collection('members').select(...MEMBER_RANK_FIELDS).get();
for (const m of members.docs) {
if (MEMBER_RANK_FIELDS.some((f) => m.get(f) !== undefined)) {
void writer.update(m.ref, Object.fromEntries(MEMBER_RANK_FIELDS.map((f) => [f, FieldValue.delete()])));
}
}
void writer.update(fandom.ref, { memberRanked: false });
}
const totals = new Map<string, Record<FpiAxis, number>>();
let memberTotal = 0;
for (const fandom of eligible.docs) {
const [members, events] = await Promise.all([
fandom.ref.collection('members').get(),
fandom.ref.collection('fpiEvents').where('verified', '==', true).where('occurredAt', '>=', since).get(),
]);
const values = new Map<string, Record<FpiAxis, number>>(
members.docs.map((m) => [m.id, zeroAxes()]));
for (const ev of events.docs) {
const bucket = values.get(ev.get('uid') as string);
const axis = ev.get('axis') as FpiAxis;
const value = ev.get('value');
// 차감 이벤트(uid · axis 없음)와 탈퇴한 사용자의 이벤트는 개인 값에 넣지 않는다.
if (bucket && axis in bucket && typeof value === 'number' && Number.isFinite(value)) bucket[axis] += value;
}
const patches = memberRankPatches(values);
const profiles = await profilesOf(members.docs.map((m) => m.id));
for (const member of members.docs) {
const patch = patches.get(member.id)!;
const profile = profiles.get(member.id);
const code = profile?.countryCode ?? 'ZZ';
const next = {
fpiAxes: patch.fpiAxes,
rankByAxis: patch.rankByAxis,
// 직전 순위 = 직전 배치의 순위. 첫 배치는 현재 순위(변동 0)로 둔다.
rankPrevByAxis: (member.get('rankByAxis') as Record<string, number> | undefined) ?? patch.rankByAxis,
topPercentByAxis: patch.topPercentByAxis,
countryCode: code,
// 사용자 랭킹의 이름 칸 — 공개 닉네임만(M-7). 프로필이 없으면 「팬」.
nickname: profile?.nickname ?? PUBLIC_NAME_FALLBACK,
};
if (Object.entries(next).some(([k, v]) => stable(member.get(k)) !== stable(v))) {
void writer.update(member.ref, next);
}
const total = totals.get(code) ?? zeroAxes();
for (const axis of FPI_AXES) total[axis] += patch.fpiAxes[axis];
totals.set(code, total);
}
void writer.update(fandom.ref, { memberRanked: true });
memberTotal += members.size;
}
await writer.close();
for (const axis of FPI_AXES) {
const doc = db.doc(`rankings/country-${axis}`);
const before = new Map(
((await doc.get()).get('rows') as { code: string; rank: number }[] | undefined ?? [])
.map((r) => [r.code, r.rank]),
);
// 공동 순위 1·1·3 · 동점 내부는 국가 코드 오름차순 고정(C-19).
const rows = rankWithTies([...totals.entries()], ([, axes]) => axes[axis], ([code]) => code)
// 표시명은 적지 않는다 — 서버가 아는 것은 코드뿐이고, 앱의 `countryName`
// 한 곳이 표기를 맡는다. 여기에 코드를 이름 자리에 넣어 두면 화면에
// 「KR」이 그대로 뜬다.
.map(({ item: [code, axes], rank }) => ({
code,
value: axes[axis],
rank,
change: (before.get(code) ?? rank) - rank,
}));
await doc.set({ axis, rows, updatedAt: FieldValue.serverTimestamp() });
}
const result = { fandoms: eligible.size, members: memberTotal, countries: totals.size };
logger.info('[ranking] 사용자 · 국가 집계 완료', result);
return result;
}
/** 키 순서와 무관한 비교용 직렬화 — Firestore 가 돌려주는 맵의 키 순서는 쓴 순서와 다를 수 있다. */
function stable(value: unknown): string {
return JSON.stringify(value, (_, v: unknown) => v && typeof v === 'object' && !Array.isArray(v)
? Object.fromEntries(Object.entries(v).sort(([a], [b]) => (a < b ? -1 : 1))) : v) ?? 'undefined';
}
function zeroAxes(): Record<FpiAxis, number> {
return Object.fromEntries(FPI_AXES.map((a) => [a, 0])) as Record<FpiAxis, number>;
}
/** 사용자 프로필의 국가 코드 · 공개 닉네임. 한 번에 읽는 수는 300건씩 자른다. */
async function profilesOf(uids: string[]): Promise<Map<string, { countryCode: string | undefined; nickname: string }>> {
const db = getFirestore();
const out = new Map<string, { countryCode: string | undefined; nickname: string }>();
for (let i = 0; i < uids.length; i += 300) {
const refs = uids.slice(i, i + 300).map((uid) => db.doc(`users/${uid}`));
if (refs.length === 0) continue;
for (const snap of await db.getAll(...refs, { fieldMask: ['countryCode', 'nickname'] })) {
if (!snap.exists) continue;
const code = snap.get('countryCode');
out.set(snap.id, {
countryCode: typeof code === 'string' && /^[A-Z]{2}$/.test(code) ? code : undefined,
nickname: publicNameOf(snap),
});
}
}
return out;
}
/**
* 한 행성 멤버의 축별 순위 · 「내 상위 %」(순수 계산). 순위는 C-19 공동 순위 — 실값 내림차순,
* 동점은 같은 순위, 동점 내부는 uid 오름차순. 상위 % 는 ⌈순위 ÷ 멤버 수 × 100⌉(최소 1),
* 그 축 값이 0 이면 비운다.
*/
export function memberRankPatches(values: ReadonlyMap<string, Record<FpiAxis, number>>): Map<string, {
fpiAxes: Record<FpiAxis, number>;
rankByAxis: Partial<Record<FpiAxis, number>>;
topPercentByAxis: Partial<Record<FpiAxis, number>>;
}> {
const out = new Map([...values].map(([uid, fpiAxes]) => [uid, {
fpiAxes, rankByAxis: {} as Partial<Record<FpiAxis, number>>, topPercentByAxis: {} as Partial<Record<FpiAxis, number>>,
}]));
const n = values.size;
for (const axis of FPI_AXES) {
for (const { item: [uid, axes], rank } of rankWithTies([...values], ([, a]) => a[axis], ([uid]) => uid)) {
const row = out.get(uid)!;
row.rankByAxis[axis] = rank;
if (axes[axis] > 0) row.topPercentByAxis[axis] = Math.max(1, Math.ceil(rank / n * 100));
}
}
return out;
}
/**
* `fn-campaign-fund` — 캠페인 후원.
*
* 승인 전 캠페인은 모금을 받지 않고(E-CMP-03), 목표를 넘기는 후원도 받지 않는다
* (E-CMP-01). 차감이 성공한 뒤에만 후원 문서를 만든다.
*/
export const fnCampaignFund = onCall(async (req) => fundCampaign({
uid: callerUid(req),
campaignId: str(req.data, 'campaignId'),
clientSeq: str(req.data, 'clientSeq'),
amount: positiveInt(num(req.data, 'amountPop'), 'amountPop'),
}));
/**
* 후원 한 건의 코어. 테스트가 HTTP 왕복 없이 부르도록 떼어 둔다.
*
* - **기여 POP 만 쓴다**(원장 B-33). 클라이언트가 보낸 `popType` 을 따르지 않는다.
* - 목표는 규칙·설계서의 스키마 이름 `targetPop` 이다. `goalPop` 은 옛 이름이라 보조로만
* 읽는다 — 예전에는 `goalPop` 만 읽어 목표 초과 차단(E-CMP-01)이 한 번도 걸리지 않았다.
* - 성장 귀속 팬덤은 **캠페인 문서의 `fandomId`** 다. 클라이언트가 보낸 값을 믿지 않는다.
*/
export async function fundCampaign(input: {
uid: string; campaignId: string; clientSeq: string; amount: number;
}) {
const { uid, campaignId, clientSeq, amount } = input;
const popType = 'support' as const;
const db = getFirestore();
const campaignRef = db.doc(`campaigns/${campaignId}`);
// 귀속 팬덤은 바뀌지 않는 값이라 트랜잭션 밖에서 한 번 읽는다. 상태 · 목표 · 모금액은
// 아래 precheck 가 **차감과 같은 트랜잭션에서** 다시 읽는다 — 밖에서 판정하면 동시
// 후원 둘이 같은 잔여분을 보고 함께 통과해 목표를 넘긴다(B-34).
const campaign = await campaignRef.get();
if (!campaign.exists) fail(ERR.campaignNotApproved, '캠페인을 찾을 수 없습니다.');
const fandomId = campaign.get('fandomId');
let goal = 0;
let raised = 0;
const result = await spendPop({
uid,
popType,
amount,
reasonCode: 'CAMPAIGN_FUND',
idempotencyKey: `fund:${uid}:${campaignId}:${clientSeq}`,
refPath: campaignRef.path,
...(typeof fandomId === 'string' && fandomId ? { fandomId } : {}),
precheck: async (tx) => {
const snap = await tx.get(campaignRef);
if (snap.get('status') !== 'funding') {
fail(ERR.campaignNotApproved, '아직 모금이 시작되지 않은 캠페인입니다.');
}
// 마감이 지났으면 정산 전이라도 받지 않는다. 정산도 이 문서를 트랜잭션으로 다시
// 읽으므로, 판정 뒤에 모금액이 움직이는 일이 없다.
const endsAt = snap.get('endsAt') as Timestamp | undefined;
if (!(endsAt instanceof Timestamp) || endsAt.toMillis() <= Date.now()) {
fail(ERR.campaignNotApproved, '모금이 마감된 캠페인입니다.');
}
goal = (snap.get('targetPop') as number | undefined)
?? (snap.get('goalPop') as number | undefined) ?? 0;
raised = (snap.get('raisedPop') as number | undefined) ?? 0;
// 잔여분으로 깎아 받지 않고 거부한다(B-34 「잔여 목표액까지만 후원 가능」).
if (goal > 0 && raised + amount > goal) {
fail(ERR.campaignOverGoal, '남은 모금 목표보다 큰 금액입니다.');
}
},
// 후원 문서와 모금액은 차감과 함께 서거나 함께 없다.
alsoWrite: (tx) => {
tx.set(campaignRef.collection('donations').doc(txIdOf(`fund:${uid}:${campaignId}:${clientSeq}`)), {
uid,
// 「기여중」 목록의 collectionGroup 인덱스 키(firestore.indexes.json)와 같은 이름.
donorUid: uid,
amount,
popType,
txId: txIdOf(`fund:${uid}:${campaignId}:${clientSeq}`),
createdAt: FieldValue.serverTimestamp(),
});
// 후원자 표식 — 규칙이 문서 ID(uid)로 찾는다. 취소(cancelled) 캠페인을 후원자에게만 연다.
// 이 쓰기 이전의 후원에는 표식이 없다: 배포 전 donations 의 donorUid 로 한 번 채운다.
tx.set(campaignRef.collection('backers').doc(uid), {
uid,
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
tx.set(campaignRef, {
raisedPop: FieldValue.increment(amount),
donorCount: FieldValue.increment(1),
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
},
});
const achieved = goal > 0 ? Math.round(((raised + amount) / goal) * 100) : 0;
return {
donationId: result.txId,
txId: result.txId,
balanceAfter: result.balanceAfter,
achievedPercent: achieved,
};
}