Cloud Functions 서버 기반과 앱 서버 연동 계층 — 계정 생성 트리거 · 약관 동의 기록 · 운영자 권한 · Admin 쓰기 관문 · Firestore·Functions 클라이언트 · 오류 코드 전달
This commit is contained in:
@@ -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 };
|
||||
});
|
||||
Reference in New Issue
Block a user