Cloud Functions 도메인 함수 — POP 원장 · 퀘스트 · 이클립스 · 행성 성장 · 캠페인 · 결제 · 알림 · 브리핑 · INSIDE · 추천 · 번역 · 서버 테스트
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import { onCall } from 'firebase-functions/v2/https';
|
||||
import { getFirestore, FieldValue } from 'firebase-admin/firestore';
|
||||
|
||||
import { callerUid, requireRole, str } from '../core/callable.js';
|
||||
import { ERR, fail } from '../core/errors.js';
|
||||
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).
|
||||
@@ -13,7 +15,7 @@ import { ERR, fail } from '../core/errors.js';
|
||||
*/
|
||||
|
||||
/**
|
||||
* 원장 · 지표 · 감사 기록은 이 경로로 못 쓴다 — **master 도 불가**.
|
||||
* 원장 · 지표 · 감사 기록은 이 경로로 못 쓴다 — **master 도 불가**(리뷰 H3).
|
||||
* 반드시 원장 · 집계 함수를 지나야 하고, 감사 로그는 고칠 수 없어야 감사 로그다.
|
||||
*/
|
||||
const FORBIDDEN = [
|
||||
@@ -41,7 +43,7 @@ const MASTER_ONLY_FIELDS: Readonly<Record<string, readonly string[]>> = {
|
||||
*
|
||||
* 이 함수는 Admin SDK 라 규칙을 거치지 않는다. 여기서 같은 선을 긋지 않으면 ops 가
|
||||
* 재화 정책(`config`) · 퀘스트 · 행성 단계 같은 마스터 전용 문서를 우회해 쓴다.
|
||||
* `configPrivate` 는 규칙상 서버 전용이라 함께 막는다.
|
||||
* `configPrivate` 는 규칙상 서버 전용이라 함께 막는다. 역할 체계 개편은 S05 결정 대기.
|
||||
*/
|
||||
const MASTER_ONLY = new Set([
|
||||
'config', 'configPrivate', 'appConfig', 'i18n', 'planetStages', 'quests', 'badges',
|
||||
@@ -118,3 +120,128 @@ export const fnAdminWrite = onCall(async (req) => {
|
||||
|
||||
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<string, unknown>;
|
||||
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<T>(items: T[], size: number): Generator<T[]> {
|
||||
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<number> {
|
||||
return getFirestore().runTransaction(async (tx) => (await voidCompletions(tx, uid, key))(tx));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user