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> = { [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)?.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 { 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(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 { 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 { 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 { 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): 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 { 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 | 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 { 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 { 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 { 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 = 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; fpiScore: number; penaltyRevision: number; eligible: boolean; activity: { id: string; at: Timestamp } | undefined; prevRanks: Partial>; }[] = []; 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; 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 | undefined) ?? {}, }); } // 축마다 따로 줄을 세운다. 축 하나로 매긴 순위를 다섯 축에 돌려 쓰면 발주사가 // 본 「서로 다른 지표 화면에 동일한 Ranking」이 그대로 재현된다. const ranks = new Map>>(); 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> { const seen = await getFirestore().collection('users') .where('lastSeenAt', '>=', Timestamp.fromMillis(now - windowMinutes * 60_000)).select('fandomIds').get(); const counts = new Map(); 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 { 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 | undefined) ?? Object.fromEntries(FPI_AXES.map((a) => [a, 0])) as Record; 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>(); 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>( 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 | 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 { return Object.fromEntries(FPI_AXES.map((a) => [a, 0])) as Record; } /** 사용자 프로필의 국가 코드 · 공개 닉네임. 한 번에 읽는 수는 300건씩 자른다. */ async function profilesOf(uids: string[]): Promise> { const db = getFirestore(); const out = new Map(); 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>): Map; rankByAxis: Partial>; topPercentByAxis: Partial>; }> { const out = new Map([...values].map(([uid, fpiAxes]) => [uid, { fpiAxes, rankByAxis: {} as Partial>, topPercentByAxis: {} as Partial>, }])); 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, }; }