Cloud Functions 서버 기반과 앱 서버 연동 계층 — 계정 생성 트리거 · 약관 동의 기록 · 운영자 권한 · Admin 쓰기 관문 · Firestore·Functions 클라이언트 · 오류 코드 전달

This commit is contained in:
Sn-Kinos
2026-09-01 00:20:45 +09:00
parent 2037b6d573
commit 1d462ebe31
21 changed files with 21578 additions and 12 deletions
@@ -0,0 +1,102 @@
import * as functionsV1 from 'firebase-functions/v1';
import { onCall } from 'firebase-functions/v2/https';
import { getFirestore, FieldValue } from 'firebase-admin/firestore';
import { getAuth } from 'firebase-admin/auth';
import { callerUid, str } from '../core/callable.js';
import { ERR, fail } from '../core/errors.js';
/**
* `fn-auth-oncreate` — 계정 문서의 유일한 생성 주체.
*
* 보안 규칙이 `users` 의 클라이언트 `create` 를 닫아 두었으므로(교차검증 F-06)
* 이 트리거가 만들지 않으면 계정 문서가 존재하지 않는다. 생성 주체를 하나로
* 고정한 이유는 경합 때문이다 — 클라이언트가 먼저 만들면 서버 산출 필드가 빈
* 사용자 문서가 남는다.
*
* v2 에는 사용자 생성 배경 트리거가 없어 v1 Auth 트리거를 쓴다.
*/
export const fnAuthOnCreate = functionsV1.auth.user().onCreate(async (user) => {
const db = getFirestore();
const batch = db.batch();
batch.set(db.doc(`users/${user.uid}`), {
uid: user.uid,
displayName: user.displayName ?? '팬우주 여행자',
photoUrl: user.photoURL ?? null,
countryCode: 'KR',
locale: 'ko',
fandomId: '',
authProviders: user.providerData.map((p) => p.providerId),
totalContributedPop: 0,
representativeBadgeId: null,
status: 'active',
notificationPrefs: {},
createdAt: FieldValue.serverTimestamp(),
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
batch.set(db.doc(`users/${user.uid}/wallet/main`), {
activityBalance: 0,
supportBalance: 0,
pendingActivity: 0,
reconcileStatus: 'ok',
createdAt: FieldValue.serverTimestamp(),
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
await batch.commit();
});
/**
* `fn-consent-record` — 약관 동의 이력(교차검증 F-04).
*
* append-only 다. 철회도 기존 행을 고치지 않고 `granted: false` 인 새 행으로 남긴다.
* 동의 시점을 되짚을 수 없으면 분쟁에서 증명할 수단이 사라진다.
*/
export const fnConsentRecord = onCall(async (req) => {
const uid = callerUid(req);
const docType = str(req.data, 'docType');
const docVersion = str(req.data, 'docVersion');
const granted = (req.data as Record<string, unknown>)?.granted === true;
const consentId = `${docType}_${docVersion}_${granted ? 'grant' : 'revoke'}`;
await getFirestore().doc(`users/${uid}/consents/${consentId}`).set({
docType,
docVersion,
granted,
ip: req.rawRequest?.ip ?? null,
userAgent: req.rawRequest?.headers?.['user-agent'] ?? null,
createdAt: FieldValue.serverTimestamp(),
}, { merge: true });
return { consentId };
});
/**
* `fn-admin-set-role` — 마스터 전용. 운영자 등급을 커스텀 클레임으로 심는다.
*
* 클레임은 다음 토큰 갱신부터 적용되므로 `admins` 문서도 함께 갱신해 Admin 화면이
* 즉시 목록을 보여줄 수 있게 한다.
*/
export const fnAdminSetRole = onCall(async (req) => {
const actorUid = callerUid(req);
if (req.auth?.token?.role !== 'master') {
fail(ERR.permissionDenied, '마스터 권한이 필요합니다.');
}
const targetUid = str(req.data, 'uid');
const role = str(req.data, 'role');
if (!['master', 'ops', 'viewer'].includes(role)) {
fail(ERR.permissionDenied, '알 수 없는 권한 등급입니다.');
}
await getAuth().setCustomUserClaims(targetUid, { role });
await getFirestore().doc(`admins/${targetUid}`).set({
uid: targetUid,
role,
grantedBy: actorUid,
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
return { uid: targetUid, role };
});