import { onCall } from 'firebase-functions/v2/https'; import { getFirestore, FieldValue } from 'firebase-admin/firestore'; import { callerUid, num, optionalStr, requireRole, str } from '../core/callable.js'; import { ERR, fail, positiveInt } from '../core/errors.js'; import { reverseGrowth } from '../ledger/ledger.js'; import { voidCompletions } from '../ledger/fpi.js'; /** * `fn-admin-write` — 모든 Admin 쓰기의 단일 관문(§7.5). * * Admin 콘솔이 Firestore 를 직접 쓰지 않고 여기를 지나는 이유는 로그 때문이다. * 보안 규칙은 거부만 할 수 있고 로그를 남기지 못한다. 누가 무엇을 왜 바꿨는지는 * 여기서만 남길 수 있고, rbac-summary 가 요구하는 작업 로그가 그것이다. */ /** * 원장 · 지표 · 감사 기록은 이 경로로 못 쓴다 — **master 도 불가**(리뷰 H3). * 반드시 원장 · 집계 함수를 지나야 하고, 감사 로그는 고칠 수 없어야 감사 로그다. */ const FORBIDDEN = [ /popTransactions/, /popLots/, /\/wallet\//, /fpiSnapshots/, /growthEvents/, /\/fpiEvents\//, /\/eclipseRounds\//, /\/fandomDailyStats\//, /^\/?adminActionLogs\//, ]; /** 서버 산출 필드 — 문서 자체는 쓸 수 있어도 이 필드는 master 도 못 바꾼다. */ const DERIVED_FIELDS: Readonly> = { fandoms: ['fpiScore', 'fpiAxes', 'fpiRanks', 'fpiRanksPrev', 'sfpi', 'growthPop', 'rankingEligible', 'eclipsePenaltyRevision'], users: ['userExp', 'userLevel', 'passportTier', 'totalContributedPop'], }; /** * 팬덤 문서의 Eclipse 운영값(팬덤별 일정 · 설정, C-24)은 rules 의 `fandoms` 쓰기처럼 * master 만 바꾼다. 원장 · 지표는 아니므로 master 에게는 열어 둔다. */ const MASTER_ONLY_FIELDS: Readonly> = { fandoms: ['eclipseSettings', 'eclipseSchedule'], }; /** * `firestore.rules` 에서 **master 전용 쓰기**인 최상위 컬렉션(rules 「allow write: if isMaster()」). * * 이 함수는 Admin SDK 라 규칙을 거치지 않는다. 여기서 같은 선을 긋지 않으면 ops 가 * 재화 정책(`config`) · 퀘스트 · 행성 단계 같은 마스터 전용 문서를 우회해 쓴다. * `configPrivate` 는 규칙상 서버 전용이라 함께 막는다. 역할 체계 개편은 S05 결정 대기. */ const MASTER_ONLY = new Set([ 'config', 'configPrivate', 'appConfig', 'i18n', 'planetStages', 'quests', 'badges', ]); /** 거부도 남긴다(FR-ADM-01). 로그 실패가 원래 거부 사유를 가리지 않게 삼킨다. */ async function logDenied(actorUid: string, role: unknown, targetPath: unknown, why: string) { try { await getFirestore().collection('adminActionLogs').add({ actorUid, role: typeof role === 'string' ? role : null, targetPath: typeof targetPath === 'string' ? targetPath : null, denied: true, deniedReason: why, createdAt: FieldValue.serverTimestamp(), }); } catch { /* 거부 응답이 우선이다 */ } } export const fnAdminWrite = onCall(async (req) => { const actorUid = callerUid(req); const rawPath = (req.data as Record | null)?.targetPath; let role: 'master' | 'ops'; try { role = requireRole(req, ['master', 'ops']) as 'master' | 'ops'; } catch (e) { await logDenied(actorUid, req.auth?.token?.role, rawPath, 'ROLE'); throw e; } const targetPath = str(req.data, 'targetPath'); const reason = str(req.data, 'reason'); const patch = (req.data as Record)?.patch; const segments0 = targetPath.split('/').filter(Boolean); // 최상위 문서(`fandoms/{id}` · `users/{uid}`)에만 산출 필드가 있다. const docKind = segments0.length === 2 ? segments0[0]! : ''; const keys = typeof patch === 'object' && patch !== null ? Object.keys(patch) : []; if (FORBIDDEN.some((re) => re.test(targetPath)) || keys.some((k) => DERIVED_FIELDS[docKind]?.includes(k))) { await logDenied(actorUid, role, targetPath, 'LEDGER_OR_DERIVED'); // 관리자라도 잔액 · 지표를 직접 고칠 수 없다. 별첨1 ② 「원장 정합성」의 마지막 방벽이다. fail(ERR.permissionDenied, '원장 · 지표 · 감사 기록은 전용 함수로만 변경할 수 있습니다.'); } if (role !== 'master' && (MASTER_ONLY.has(segments0[0] ?? '') || keys.some((k) => MASTER_ONLY_FIELDS[docKind]?.includes(k)))) { await logDenied(actorUid, role, targetPath, 'MASTER_ONLY'); fail(ERR.permissionDenied, '마스터 권한이 필요한 문서입니다.'); } if (typeof patch !== 'object' || patch === null || Array.isArray(patch)) { fail(ERR.permissionDenied, '변경 내용이 올바르지 않습니다.'); } // 경로 깊이가 짝수여야 문서다. 홀수면 컬렉션이라 통째로 덮어쓸 위험이 있다. const segments = targetPath.split('/').filter(Boolean); if (segments.length === 0 || segments.length % 2 !== 0) { fail(ERR.permissionDenied, '문서 경로가 아닙니다.'); } const db = getFirestore(); const ref = db.doc(targetPath); const before = await ref.get(); await ref.set({ ...(patch as Record), updatedAt: FieldValue.serverTimestamp(), }, { merge: true }); const log = await db.collection('adminActionLogs').add({ actorUid, role, targetPath, reason, patch, existedBefore: before.exists, createdAt: FieldValue.serverTimestamp(), }); return { logId: log.id, targetPath }; }); /** * `fn-admin-pop-grant-batch` — 일괄 지급 미리보기와 실행. * * 별첨 1 ⑨ 가 「미리보기 + 2단계 확인」을 요구한다. `dryRun` 이 기본값이고, * 실행하려면 미리보기에서 받은 대상 수를 `confirmCount` 로 되돌려 줘야 한다 — * 두 번째 호출이 첫 번째와 같은 모집단을 보고 있는지 확인하는 장치다. */ export const fnAdminPopGrantBatch = onCall(async (req) => { const actorUid = callerUid(req); requireRole(req, ['master', 'ops']); const batchId = str(req.data, 'batchId'); const data = (req.data ?? {}) as Record; const amount = data.amount; if (typeof amount !== 'number' || !Number.isInteger(amount) || amount <= 0) { fail(ERR.popVerifyFailed, '지급 수량이 올바르지 않습니다.'); } const targets = Array.isArray(data.uids) ? (data.uids as unknown[]).filter((u): u is string => typeof u === 'string') : []; if (targets.length === 0) fail(ERR.popVerifyFailed, '지급 대상이 없습니다.'); const dryRun = data.dryRun !== false; if (dryRun) { return { preview: true, batchId, targetCount: targets.length, amount }; } if (data.confirmCount !== targets.length) { fail(ERR.popVerifyFailed, '미리보기에서 확인한 대상 수와 다릅니다.'); } const { grantPop } = await import('../ledger/ledger.js'); const succeeded: string[] = []; const failed: Array<{ uid: string; error: string }> = []; // 500건 청크로 나눈다 — 한 번에 몰면 Functions 실행 시간을 넘긴다. for (const chunk of chunks(targets, 500)) { for (const uid of chunk) { try { await grantPop({ uid, popType: 'activity', amount: amount as number, reasonCode: 'ADMIN_GRANT', idempotencyKey: `grant:${batchId}:${uid}`, actorUid, }); succeeded.push(uid); } catch (error) { failed.push({ uid, error: String(error) }); } } } await getFirestore().collection('adminActionLogs').add({ actorUid, targetPath: `batch/${batchId}`, reason: 'ADMIN_GRANT batch', patch: { amount, targetCount: targets.length }, succeeded: succeeded.length, failed: failed.length, createdAt: FieldValue.serverTimestamp(), }); return { preview: false, batchId, succeeded: succeeded.length, failed }; }); function* chunks(items: T[], size: number): Generator { for (let i = 0; i < items.length; i += size) yield items.slice(i, i + size); } /** * `fn-admin-growth-reverse` — 오지급 확정분의 성장 역분개(정책 원장 **C-32**). * * 허위 참여 · 중복 지급 · 오지급이 **확정**된 건만 부른다. 의심 단계에서는 부르지 * 않는다. 사유와 수치는 역분개 행과 작업 로그 양쪽에 남는다. */ export const fnAdminGrowthReverse = onCall(async (req) => { const actorUid = callerUid(req); const role = requireRole(req, ['master', 'ops']); const uid = str(req.data, 'uid'); const sourceEventId = str(req.data, 'sourceEventId'); const reason = str(req.data, 'reason'); if (uid.includes('/') || sourceEventId.includes('/')) { fail(ERR.permissionDenied, '문서 경로가 아닙니다.'); } const result = await reverseGrowth({ uid, sourceEventId, reason, actorUid, amount: positiveInt(num(req.data, 'amount'), 'amount'), }); await getFirestore().collection('adminActionLogs').add({ actorUid, role, targetPath: `users/${uid}/growthEvents/${result.reversalId}`, reason, patch: { reversalOf: sourceEventId, amount: -result.amount, fandomId: result.fandomId }, createdAt: FieldValue.serverTimestamp(), }); return result; }); /** * `fn-admin-completion-void` — Quest 완료의 사후 무효를 「검증 활동」 수에 반영한다(Campaign 개설자격 A안). * * 성장 역분개가 없는 완료(무보상 · 팬덤 없음 · EXP 만)도 무효로 할 수 있게 **원장 행(`txId`) 또는 * 제출 건(`submissionId`)** 으로 찾는다. 이미 무효인 표식은 다시 세지 않는다(`voided` 1회). */ export const fnAdminCompletionVoid = onCall(async (req) => { const actorUid = callerUid(req); const role = requireRole(req, ['master', 'ops']); const uid = str(req.data, 'uid'); const reason = str(req.data, 'reason'); const txId = optionalStr(req.data, 'txId'); const submissionId = optionalStr(req.data, 'submissionId'); if (uid.includes('/') || (!txId && !submissionId)) fail(ERR.permissionDenied, '대상 원장 행 또는 제출 건이 필요합니다.'); const voided = await voidCompletion(uid, txId ? { txId } : { submissionId: submissionId! }); await getFirestore().collection('adminActionLogs').add({ actorUid, role, targetPath: `users/${uid}/verifiedCompletions`, reason, patch: { txId: txId ?? null, submissionId: submissionId ?? null, voided }, createdAt: FieldValue.serverTimestamp(), }); return { voided }; }); export async function voidCompletion(uid: string, key: { txId?: string; submissionId?: string }): Promise { return getFirestore().runTransaction(async (tx) => (await voidCompletions(tx, uid, key))(tx)); }