Cloud Functions 도메인 함수 — POP 원장 · 퀘스트 · 이클립스 · 행성 성장 · 캠페인 · 결제 · 알림 · 브리핑 · INSIDE · 추천 · 번역 · 서버 테스트

This commit is contained in:
Sn-Kinos
2026-09-29 17:06:48 +09:00
parent f483e8d94b
commit 316821e7d5
61 changed files with 14660 additions and 60 deletions
+315 -2
View File
@@ -1,9 +1,10 @@
import * as functionsV1 from 'firebase-functions/v1';
import { onCall } from 'firebase-functions/v2/https';
import { getFirestore, FieldValue } from 'firebase-admin/firestore';
import { HttpsError, onCall } from 'firebase-functions/v2/https';
import { getFirestore, FieldValue, Timestamp, type Transaction } from 'firebase-admin/firestore';
import { getAuth } from 'firebase-admin/auth';
import { callerUid, str } from '../core/callable.js';
import { dayKey } from '../core/time.js';
import { ERR, fail } from '../core/errors.js';
/**
@@ -48,6 +49,318 @@ export const fnAuthOnCreate = functionsV1.auth.user().onCreate(async (user) => {
await batch.commit();
});
/** 행성 생성 화면이 고르는 값 — 앱 `PlanetCreateScreen.shapes` · `.colors` 와 같은 키(C-50). */
export const PLANET_SHAPES = ['shape1', 'shape2', 'shape3', 'shape4', 'shape5', 'shape6'];
export const PLANET_COLORS = ['white', 'black', 'blue', 'green', 'gold', 'orange', 'red', 'purple'];
/**
* 운영값 기본 — 🟡 `config/policy` 가 덮는다.
* - 성운 전환: 원장 B-10 「10명 이상 + 1,000 POP 이상 + 기본 목표 7일」.
* `nebulaConversionDeadlineEnforced` — 7일을 넘긴 성운의 전환을 막을지. B-10 「미달성 시 유지」와
* 「7일 이내 판정」이 갈려 발주사 확인 중이며, 회신 전에는 막지 않는다(false).
* - 대표 팬덤: 원장 C-15 「변경 무료 · 재변경까지 초기 30일 대기 · 운영자 조정 가능」.
* - `nebulaFounderLeaveBlocked` — 성운 창립자는 행성 탄생 전 탈퇴 불가(해석 확인 요청 Q-PN-03
* 권고 A안 · true). false 면 막지 않는다.
* - `primaryOnLeave` — 대표 팬덤에서 탈퇴할 때 남은 가입 팬덤 가운데 무엇을 대표로 둘지
* (Q-PN-04). `latest`(권고 B안 · 가장 최근 가입) · `earliest`(C안) · `none`(A안 · 비워 둠).
* 자동 지정은 C-15 대기에 세지 않는다.
*/
const FANDOM_DEFAULTS = {
nebulaMinMembers: 10,
nebulaMinPop: 1_000,
nebulaDays: 7,
nebulaConversionDeadlineEnforced: false,
primaryCooldownDays: 30,
nebulaFounderLeaveBlocked: true,
primaryOnLeave: 'latest' as 'latest' | 'earliest' | 'none',
};
async function fandomPolicy(): Promise<typeof FANDOM_DEFAULTS> {
const raw = (await getFirestore().doc('config/policy').get()).data() ?? {};
return { ...FANDOM_DEFAULTS, ...raw } as typeof FANDOM_DEFAULTS;
}
/** 기능 명세 §2.4 성운명 — 2~20자 · 한글/영문/숫자/공백 · 연속 공백 · 앞뒤 공백 불가. */
export function isValidNebulaName(name: string): boolean {
return /^[가-힣A-Za-z0-9 ]{2,20}$/.test(name) && name === name.trim() && !name.includes(' ');
}
type Data = Record<string, unknown> | undefined;
/** 현재 대표 팬덤. `primaryFandomId` 가 없던 계정은 옛 `fandomId` 를 쓴다(설계서 §2.1 이관 3). */
function primaryOf(user: Data): string | null {
const id = (user?.primaryFandomId ?? user?.fandomId) as string | null | undefined;
return id ? id : null;
}
/**
* 대표 팬덤을 [fandomId] 로 바꾸는 필드 — 원장 C-15.
*
* 대표가 없던 계정의 첫 지정은 「변경」이 아니므로 대기를 걸지 않고 시각도 찍지 않는다.
* 이미 대표가 있으면 `primaryChangedAt + 대기일` 이 지나야 바뀐다. `patch` 가 null 이면
* 바꾸지 않는다(같은 팬덤이거나 대기 중 — 대기 중이면 `blocked`). 옛 독자를 위해
* `fandomId` 도 같은 값으로 쓴다.
*/
function primaryPatch(user: Data, fandomId: string, cooldownDays: number, now: number):
{ patch: Record<string, unknown> | null; blocked: boolean } {
const current = primaryOf(user);
if (current === fandomId) return { patch: null, blocked: false };
const base = { primaryFandomId: fandomId, fandomId, updatedAt: FieldValue.serverTimestamp() };
if (!current) return { patch: base, blocked: false };
const last = user?.primaryChangedAt;
if (last instanceof Timestamp && now < last.toMillis() + cooldownDays * 86_400_000) {
return { patch: null, blocked: true };
}
return { patch: { ...base, primaryChangedAt: FieldValue.serverTimestamp() }, blocked: false };
}
/**
* 멤버십 한 건을 트랜잭션 안에서 쓴다. 호출 측이 「아직 멤버가 아님」을 확인한 뒤에만 부른다.
*
* - `fandoms/{id}/members/{uid}` — 가입 사실 · 가입 순번(`memberNo`, 원장 B-68 #001~ 기록).
* 순번은 탈퇴로 줄지 않는 `memberSeq` 에서 뽑아 재사용하지 않는다.
* - `users/{uid}/memberships/{id}` — 「가입 또는 생성한 행성/성운」 목록(명세 §2.58)의 원천.
* - `memberCount` 는 같은 트랜잭션에서 올린다 — 전환 조건(B-10)이 이 값을 읽는다.
*/
function writeJoin(tx: Transaction, uid: string, fandomId: string, fandom: Data): void {
const db = getFirestore();
const memberNo = Number(fandom?.memberSeq ?? fandom?.memberCount ?? 0) + 1;
tx.set(db.doc(`fandoms/${fandomId}/members/${uid}`), {
uid,
fandomId,
memberNo,
contributedPop: 0,
joinedAt: FieldValue.serverTimestamp(),
updatedAt: FieldValue.serverTimestamp(),
});
tx.set(db.doc(`users/${uid}/memberships/${fandomId}`), {
fandomId,
joinedAt: FieldValue.serverTimestamp(),
});
// LIVE 집계(Q-GM-03)가 접속자 문서 한 건으로 가입 팬덤을 읽는 비정규화 — 서버만 쓴다(규칙이 막음).
tx.set(db.doc(`users/${uid}`), { fandomIds: FieldValue.arrayUnion(fandomId) }, { merge: true });
tx.set(db.doc(`fandoms/${fandomId}`), {
memberCount: FieldValue.increment(1),
memberSeq: memberNo,
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
}
/**
* `fn-nebula-create` — 성운(Stage0) 생성. 원장 B-08 · B-09 · B-10 · C-15.
*
* 팬덤 문서 자체가 공용 성운/행성이다(팬덤 1 : 행성 1). 만든 사람은 창립 멤버 #001 이다.
* 확인 다이얼로그 「선택한 행성을 기본 행성으로 변경할까요?」대로 대표 팬덤을 바꾸되, C-15
* 30일 대기 중이면 성운과 멤버십만 만들고 대표는 그대로 둔다(`primaryChanged: false`).
* 이름 중복은 `fandomNames/{소문자 이름}` 문서 ID 로, 일 1회 제한은
* `users/{uid}/nebulaCreates/{날짜}` 문서 ID 로 막는다 — 동시 요청에도 원자적이다.
* 기존 팬덤의 이름 문서는 `tools/demo/migrate_v040.mjs fandom-names` 가 채운다.
*/
export const fnNebulaCreate = onCall(async (req) => {
const uid = callerUid(req);
const name = str(req.data, 'name');
if (!isValidNebulaName(name)) {
throw new HttpsError('invalid-argument', '성운 이름은 2~20자의 한글·영문·숫자로 입력해 주세요.');
}
const db = getFirestore();
const policy = await fandomPolicy();
const fandom = db.collection('fandoms').doc();
const now = Date.now();
const primaryChanged = await db.runTransaction(async (tx) => {
const todayRef = db.doc(`users/${uid}/nebulaCreates/${dayKey(new Date(now))}`);
const nameRef = db.doc(`fandomNames/${name.toLowerCase()}`);
const userRef = db.doc(`users/${uid}`);
const [todaySnap, nameSnap, userSnap] = await Promise.all([
tx.get(todayRef), tx.get(nameRef), tx.get(userRef),
]);
if (todaySnap.exists) fail(ERR.nebulaDailyLimit, '이미 오늘 성운을 생성하셨습니다.');
if (nameSnap.exists) fail(ERR.fandomNameTaken, '이미 사용중인 이름입니다.');
const { patch } = primaryPatch(userSnap.data(), fandom.id, policy.primaryCooldownDays, now);
tx.create(todayRef, { fandomId: fandom.id, createdAt: FieldValue.serverTimestamp() });
tx.create(nameRef, { fandomId: fandom.id, createdAt: FieldValue.serverTimestamp() });
tx.create(fandom, {
name,
stage: 0,
founderUid: uid,
memberCount: 0,
growthPop: 0,
fpiScore: 0,
nebulaDeadline: Timestamp.fromMillis(now + Number(policy.nebulaDays) * 86_400_000),
createdAt: FieldValue.serverTimestamp(),
updatedAt: FieldValue.serverTimestamp(),
});
writeJoin(tx, uid, fandom.id, undefined);
if (patch) tx.set(userRef, patch, { merge: true });
return patch !== null;
});
return { fandomId: fandom.id, primaryChanged };
});
/**
* `fn-fandom-join` — 행성/성운 가입. 원장 C-15 「복수 팬덤 가입」.
*
* 이미 멤버면 아무것도 쓰지 않는다(두 번 눌러도 `memberCount` 는 한 번만 오른다).
* 대표 팬덤이 없던 계정은 처음 가입한 팬덤이 대표가 된다 — 그 외에는 대표를 바꾸지 않는다.
*/
export const fnFandomJoin = onCall(async (req) => {
const uid = callerUid(req);
const fandomId = str(req.data, 'fandomId');
const db = getFirestore();
return db.runTransaction(async (tx) => {
const [fandomSnap, memberSnap, userSnap] = await Promise.all([
tx.get(db.doc(`fandoms/${fandomId}`)),
tx.get(db.doc(`fandoms/${fandomId}/members/${uid}`)),
tx.get(db.doc(`users/${uid}`)),
]);
if (!fandomSnap.exists) fail(ERR.permissionDenied, '행성을 찾을 수 없습니다.');
if (memberSnap.exists) return { fandomId, joined: false };
writeJoin(tx, uid, fandomId, fandomSnap.data());
if (!primaryOf(userSnap.data())) {
tx.set(userSnap.ref, {
primaryFandomId: fandomId, fandomId, updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
}
return { fandomId, joined: true };
});
});
/**
* `fn-fandom-leave` — 탈퇴. 멤버가 아니면 아무것도 하지 않는다(두 번 눌러도 한 번만 내린다).
*
* 과거 기여 · Passport 기록(원장 · growthEvents · popTransactions)은 지우지 않는다(C-15).
*
* - 성운(Stage0) 창립자는 탄생 전 떠날 수 없다(E-FDM-05 · Q-PN-03) — 탄생 권한자가 사라지면
* 조건을 채워도 행성이 될 수 없다.
* - 대표 팬덤을 떠나면 `primaryOnLeave` 대로 남은 팬덤 하나를 대표로 둔다(Q-PN-04). 자동 지정이라
* `primaryChangedAt` 을 찍지 않는다 — 사용자의 다음 직접 변경을 30일 대기로 막지 않는다.
*/
export const fnFandomLeave = onCall(async (req) => {
const uid = callerUid(req);
const fandomId = str(req.data, 'fandomId');
const db = getFirestore();
const policy = await fandomPolicy();
return db.runTransaction(async (tx) => {
const memberRef = db.doc(`fandoms/${fandomId}/members/${uid}`);
const [memberSnap, userSnap, fandomSnap] = await Promise.all([
tx.get(memberRef), tx.get(db.doc(`users/${uid}`)), tx.get(db.doc(`fandoms/${fandomId}`)),
]);
if (!memberSnap.exists) return { fandomId, left: false };
if (policy.nebulaFounderLeaveBlocked === true &&
fandomSnap.get('stage') === 0 && fandomSnap.get('founderUid') === uid) {
fail(ERR.nebulaFounderLeave, '행성이 탄생한 뒤에 탈퇴할 수 있어요.');
}
const wasPrimary = primaryOf(userSnap.data()) === fandomId;
let nextPrimary: string | null = null;
if (wasPrimary && policy.primaryOnLeave !== 'none') {
// 떠나는 팬덤을 빼고 하나가 남도록 두 건만 읽는다.
const rest = await tx.get(db.collection(`users/${uid}/memberships`)
.orderBy('joinedAt', policy.primaryOnLeave === 'earliest' ? 'asc' : 'desc').limit(2));
nextPrimary = rest.docs.map((d) => d.id).find((id) => id !== fandomId) ?? null;
}
tx.delete(memberRef);
tx.delete(db.doc(`users/${uid}/memberships/${fandomId}`));
tx.set(userSnap.ref, { fandomIds: FieldValue.arrayRemove(fandomId) }, { merge: true });
tx.update(db.doc(`fandoms/${fandomId}`), {
memberCount: FieldValue.increment(-1),
updatedAt: FieldValue.serverTimestamp(),
});
if (wasPrimary) {
tx.set(userSnap.ref, {
primaryFandomId: nextPrimary, fandomId: nextPrimary ?? '', updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
}
return { fandomId, left: true, primaryFandomId: wasPrimary ? nextPrimary : primaryOf(userSnap.data()) };
});
});
/**
* `fn-primary-fandom-set` — 대표 팬덤 변경. 원장 C-15 · 명세 §2.56 「기본 행성 변경」.
*
* 가입한 팬덤만 대표가 될 수 있다. 변경은 무료이고, 재변경은 `primaryChangedAt + 30일`
* (운영값 `primaryCooldownDays`) 이 지나야 한다. 과거 기여 · Passport 는 건드리지 않는다.
*/
export const fnPrimaryFandomSet = onCall(async (req) => {
const uid = callerUid(req);
const fandomId = str(req.data, 'fandomId');
const db = getFirestore();
const policy = await fandomPolicy();
const now = Date.now();
return db.runTransaction(async (tx) => {
const [memberSnap, userSnap] = await Promise.all([
tx.get(db.doc(`fandoms/${fandomId}/members/${uid}`)), tx.get(db.doc(`users/${uid}`)),
]);
if (!memberSnap.exists) fail(ERR.permissionDenied, '가입한 행성만 기본 행성으로 정할 수 있습니다.');
const { patch, blocked } = primaryPatch(userSnap.data(), fandomId, policy.primaryCooldownDays, now);
if (blocked) {
fail(ERR.primaryCooldown, `기본 행성은 변경 후 ${policy.primaryCooldownDays}일이 지나야 다시 바꿀 수 있습니다.`);
}
if (patch) tx.set(userSnap.ref, patch, { merge: true });
return { fandomId, changed: patch !== null };
});
});
/**
* `fn-planet-create` — 성운(Stage0) → 정식 행성(Stage1) 탄생. 원장 B-09 · B-10 · C-50.
*
* 형태(`planetType`)와 컬러(`colorKey`) 두 가지만 이때 한 번 고른다. 행성 문서 ID 는
* 팬덤 안에서 고정(`planet`)이라 팬덤당 1건이 문서 ID 충돌로 원자적으로 지켜진다
* (Firestore 설계서 §2.4 정정). 렌더링이 팬덤 문서 하나로 끝나도록 두 값을 팬덤 문서에도
* 싣는다. 생성 후 두 값을 바꾸는 경로는 없다(보안 규칙이 클라이언트 쓰기를 전부 닫음).
*
* 전환 조건(인원 · POP)은 **트랜잭션 안에서 읽은** 팬덤 문서로 판정한다 — 가입 · 탈퇴와
* 경합해도 낡은 `memberCount` 로 통과하지 않는다. 7일 목표는 `nebulaConversionDeadlineEnforced`
* 가 켜졌을 때만 거부 사유가 된다.
*/
export const fnPlanetCreate = onCall(async (req) => {
const uid = callerUid(req);
const fandomId = str(req.data, 'fandomId');
const planetType = str(req.data, 'planetType');
const colorKey = str(req.data, 'colorKey');
if (!PLANET_SHAPES.includes(planetType) || !PLANET_COLORS.includes(colorKey)) {
throw new HttpsError('invalid-argument', '행성 형태 또는 색이 올바르지 않습니다.');
}
const db = getFirestore();
const policy = await fandomPolicy();
const fandomRef = db.doc(`fandoms/${fandomId}`);
await db.runTransaction(async (tx) => {
const [fandomSnap, planetSnap] = await Promise.all([
tx.get(fandomRef), tx.get(db.doc(`fandoms/${fandomId}/planets/planet`)),
]);
const fandom = fandomSnap.data();
if (!fandom) fail(ERR.permissionDenied, '성운을 찾을 수 없습니다.');
if (fandom.founderUid !== uid) fail(ERR.permissionDenied, '성운을 만든 멤버만 행성을 생성할 수 있습니다.');
if (planetSnap.exists || fandom.stage !== 0) fail(ERR.popDuplicate, '이미 행성이 생성되었습니다.');
if (Number(fandom.memberCount ?? 0) < Number(policy.nebulaMinMembers) ||
Number(fandom.growthPop ?? 0) < Number(policy.nebulaMinPop)) {
fail(ERR.nebulaNotReady, '성운 전환 조건을 아직 채우지 못했습니다.');
}
if (policy.nebulaConversionDeadlineEnforced === true &&
fandom.nebulaDeadline instanceof Timestamp && Date.now() > fandom.nebulaDeadline.toMillis()) {
fail(ERR.nebulaNotReady, '성운 목표 기간이 지났습니다.');
}
tx.create(planetSnap.ref, {
ownerUid: uid,
fandomId,
planetType,
colorKey,
createdAt: FieldValue.serverTimestamp(),
});
tx.update(fandomRef, {
stage: 1,
planetType,
colorKey,
updatedAt: FieldValue.serverTimestamp(),
});
});
return { fandomId };
});
/**
* `fn-consent-record` — 약관 동의 이력.
*