1219 lines
59 KiB
TypeScript
1219 lines
59 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 { loadGrowthConfig, zapWeightOf } from './planet_level.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 });
|
|
}
|
|
// 라이브 소셜 프루프는 **참여 합류 1회**만 — 개별 Shake 횟수를 방송하지 않는다(UX·FX v1.5 §1.1 · §3.1 · §5.1
|
|
// 「다른 팬의 행동은 개별 Shake 횟수를 모두 노출하지 않고 참여자 수 변화 중심」). 집약 수는 화면의 유효 참여자 표시.
|
|
if (accepted > 0 && myTotal === 0) {
|
|
tx.create(tickerRef, {
|
|
kind: TICKER_KIND_ECLIPSE_DEFENSE, eclipseId, uid,
|
|
nickname: publicNameOf(user),
|
|
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 defended ? 'success' : true;
|
|
});
|
|
if (changed) closed++;
|
|
if (changed === 'success') await recordEclipseSuccess(fandomRef.id, doc.ref);
|
|
}
|
|
return closed;
|
|
}
|
|
|
|
/**
|
|
* Eclipse 성공 참여 PASSPORT 기록 — UX·FX 정렬기준 v1.5 **§5.2** 「인정 횟수(60회) 이상 + 최종 성공 시 회차당
|
|
* 1회 성공 참여 기록」. 신규 Badge type 을 만들지 않고 PASSPORT 기록으로 남긴다(§5.2 「Badge 확장은 별도 검토」).
|
|
* 문서 ID 가 회차라 재실행에도 한 번이다.
|
|
*/
|
|
export async function recordEclipseSuccess(
|
|
fandomId: string, roundRef: FirebaseFirestore.DocumentReference,
|
|
): Promise<number> {
|
|
const db = getFirestore();
|
|
const qualified = await roundRef.collection('participants').where('qualified', '==', true).get();
|
|
for (const p of qualified.docs) {
|
|
try {
|
|
await db.doc(`users/${p.id}/passportHistory/eclipse_${fandomId}_${roundRef.id}`).create({
|
|
kind: 'eclipse_success', fandomId, eclipseId: roundRef.id,
|
|
validShakes: p.get('validShakes') ?? null,
|
|
occurredAt: FieldValue.serverTimestamp(),
|
|
});
|
|
} catch (error) {
|
|
const code = (error as { code?: number | string }).code;
|
|
if (code !== 6 && code !== 'already-exists') throw error;
|
|
}
|
|
}
|
|
return qualified.size;
|
|
}
|
|
|
|
/**
|
|
* `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();
|
|
const growth = await loadGrowthConfig();
|
|
|
|
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 active = (e: FirebaseFirestore.QueryDocumentSnapshot) => e.get('verified') === true &&
|
|
typeof e.get('uid') === 'string' && e.get('dauEligible') !== false;
|
|
const users = new Set(events.docs.filter(active).map((e) => e.get('uid') as string));
|
|
// 수치표 v1.4 「ZAP 참여자 집계 — 일 1회 이상 유효 ZAP 한 고유 사용자」 ÷ 「최근 30일 Active 팬덤 사용자」.
|
|
// 🟡 Active 의 정의는 자료에 없다(해석 확인 요청 영역A Q-A03) — DAU 와 같은 「서버 인정 활동 1건 이상」.
|
|
// ponytail: 30일 이벤트 전수 조회. 팬덤이 커지면 일별 고유 사용자 문서로 옮긴다.
|
|
const zapUsers = new Set(events.docs.filter((e) => active(e) && e.get('activity') === 'zap')
|
|
.map((e) => e.get('uid') as string));
|
|
const activeSince = Timestamp.fromMillis(to.toMillis() - growth.activeDays * 86_400_000);
|
|
const activeUsers = new Set((await fandom.ref.collection('fpiEvents')
|
|
.where('occurredAt', '>=', activeSince).where('occurredAt', '<', to).select('uid', 'verified', 'dauEligible').get())
|
|
.docs.filter(active).map((e) => e.get('uid') as string));
|
|
const zapRate = activeUsers.size > 0 ? zapUsers.size / activeUsers.size : 0;
|
|
const recent = await fandom.ref.collection('fandomDailyStats')
|
|
.where('day', '<', day).orderBy('day', 'desc').limit(growth.zapRateDays - 1).get();
|
|
const rates = [zapRate, ...recent.docs.map((d) => Number(d.get('zapRate') ?? 0))];
|
|
const zapRateAvg = rates.reduce((a, b) => a + b, 0) / rates.length;
|
|
|
|
// 스냅샷과 누적값은 함께 확정한다. 중간 실패가 하루치를 영구 누락시키지 않는다.
|
|
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',
|
|
zapUsers: zapUsers.size, activeUsers: activeUsers.size, zapRate,
|
|
computedAt: FieldValue.serverTimestamp(),
|
|
});
|
|
// 성장 가중치 — 최근 [zapRateDays]일 평균 참여율의 구간 배율. 레벨업 POP 진행도에만 쓴다(promoteStages).
|
|
tx.set(fandom.ref, {
|
|
zapRate7: zapRateAvg, zapWeight: zapWeightOf(zapRateAvg, growth.zapWeightBands),
|
|
}, { merge: true });
|
|
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);
|
|
}
|
|
// 사용자 랭킹 화면의 「마지막 갱신」 — 별첨 1 ② 예외처리.
|
|
void writer.update(fandom.ref, { memberRanked: true, memberRankedAt: FieldValue.serverTimestamp() });
|
|
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,
|
|
};
|
|
}
|