가입 약관 동의 기록 · 서비스 탈퇴 · 온보딩 6단계 · Coach Mark
This commit is contained in:
@@ -19,13 +19,19 @@ import { ERR, fail } from '../core/errors.js';
|
||||
*/
|
||||
export const fnAuthOnCreate = functionsV1.auth.user().onCreate(async (user) => {
|
||||
const db = getFirestore();
|
||||
const batch = db.batch();
|
||||
|
||||
batch.set(db.doc(`users/${user.uid}`), {
|
||||
const userRef = db.doc(`users/${user.uid}`);
|
||||
const walletRef = db.doc(`users/${user.uid}/wallet/main`);
|
||||
// 이메일 — 10/5 「플랫폼이 이메일 제공을 승인·동의한 경우에만 수집, 없어도 가입·로그인 가능」.
|
||||
// Auth 계정 이메일이 비면(OIDC 등) 제공자 정보의 이메일을 쓴다. 카카오(커스텀 토큰)는
|
||||
// 계정을 만든 뒤에 연결 정보가 붙으므로 `resolveKakaoUid` 가 따로 쓴다.
|
||||
const email = user.email ?? user.providerData.find((p) => p.email)?.email ?? null;
|
||||
const defaults: Record<string, unknown> = {
|
||||
uid: user.uid,
|
||||
displayName: user.displayName ?? '팬우주 여행자',
|
||||
photoUrl: user.photoURL ?? null,
|
||||
countryCode: 'KR',
|
||||
// 국가는 정하지 않는다 — 온보딩 프로필 단계가 기기 지역을 기본값으로 받아 쓴다
|
||||
// (해석 확인 Q-OB-02 A 「프로필 = 닉네임 · 국가 · 표시 언어」). 비어 있으면 국가 랭킹은 `ZZ`.
|
||||
email,
|
||||
locale: 'ko',
|
||||
fandomId: '',
|
||||
authProviders: user.providerData.map((p) => p.providerId),
|
||||
@@ -35,18 +41,25 @@ export const fnAuthOnCreate = functionsV1.auth.user().onCreate(async (user) => {
|
||||
notificationPrefs: {},
|
||||
createdAt: FieldValue.serverTimestamp(),
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
}, { merge: true });
|
||||
|
||||
batch.set(db.doc(`users/${user.uid}/wallet/main`), {
|
||||
};
|
||||
const wallet: Record<string, unknown> = {
|
||||
activityBalance: 0,
|
||||
supportBalance: 0,
|
||||
pendingActivity: 0,
|
||||
reconcileStatus: 'ok',
|
||||
createdAt: FieldValue.serverTimestamp(),
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
}, { merge: true });
|
||||
};
|
||||
|
||||
await batch.commit();
|
||||
// 트리거가 늦게 돌면 그 사이 앱이 쓴 값(약관 동의 · 닉네임 · 국가 · 이메일)이 이미 있다.
|
||||
// 없는 필드만 채운다 — 덮어쓰면 온보딩 입력이 기본값으로 되돌아간다.
|
||||
await db.runTransaction(async (tx) => {
|
||||
const [u, w] = await Promise.all([tx.get(userRef), tx.get(walletRef)]);
|
||||
const missing = (base: Record<string, unknown>, have: Record<string, unknown> | undefined) =>
|
||||
Object.fromEntries(Object.entries(base).filter(([k]) => have?.[k] == null));
|
||||
tx.set(userRef, missing(defaults, u.data()), { merge: true });
|
||||
tx.set(walletRef, missing(wallet, w.data()), { merge: true });
|
||||
});
|
||||
});
|
||||
|
||||
/** 행성 생성 화면이 고르는 값 — 앱 `PlanetCreateScreen.shapes` · `.colors` 와 같은 키(C-50). */
|
||||
@@ -434,6 +447,92 @@ export const fnConsentRecord = onCall(async (req) => {
|
||||
return { consentId };
|
||||
});
|
||||
|
||||
/**
|
||||
* 가입 약관 필수 항목 — Figma `179:24230`(서비스 이용약관 · 위치기반 서비스 · 만 14세 이상)에
|
||||
* 10/3 통합회신 3절 4 · 10/5 「개인정보처리방침을 약관과 함께 전달」의 개인정보 처리방침을 더했다.
|
||||
* 항목 구성은 해석 확인 요청 E-Q1(회신 대기). 마케팅 수신은 선택 · 기본 OFF(별첨 B 「Push 알림」).
|
||||
*/
|
||||
export const SIGNUP_REQUIRED_TERMS = ['service', 'privacy', 'location', 'age'] as const;
|
||||
|
||||
/**
|
||||
* `fn-signup-consent` — 가입 약관 동의를 한 번에 기록한다(FR-CMN-04 · 원장 C-134).
|
||||
*
|
||||
* 항목마다 `consents` 에 append-only 행을 남기고(약관 버전 포함), 사용자 문서에 `termsAgreedAt` 을
|
||||
* 찍는다. 앱은 이 값이 없으면 로그인 직후 온보딩(약관부터)으로 보낸다 — 「로그인」 버튼으로 처음
|
||||
* 들어온 소셜 계정(카카오 커스텀 토큰 포함)이 약관 · 만 14세 확인 없이 지나가지 못하게 하는 기준이다.
|
||||
* 마케팅 동의는 Push Marketing 구분(`notificationPrefs.marketing`)과 같은 값으로 둔다.
|
||||
*/
|
||||
export const fnSignupConsent = onCall(async (req) => {
|
||||
const uid = callerUid(req);
|
||||
const version = str(req.data, 'version');
|
||||
const raw = (req.data as Record<string, unknown>)?.agreed;
|
||||
const agreed = new Set(Array.isArray(raw) ? raw.filter((v): v is string => typeof v === 'string') : []);
|
||||
if (!SIGNUP_REQUIRED_TERMS.every((t) => agreed.has(t))) {
|
||||
throw new HttpsError('invalid-argument', '필수 약관에 모두 동의해 주세요.');
|
||||
}
|
||||
const marketing = agreed.has('marketing');
|
||||
const db = getFirestore();
|
||||
const batch = db.batch();
|
||||
const meta = {
|
||||
docVersion: version,
|
||||
ip: req.rawRequest?.ip ?? null,
|
||||
userAgent: req.rawRequest?.headers?.['user-agent'] ?? null,
|
||||
createdAt: FieldValue.serverTimestamp(),
|
||||
};
|
||||
for (const docType of [...SIGNUP_REQUIRED_TERMS, 'marketing']) {
|
||||
const granted = docType === 'marketing' ? marketing : true;
|
||||
batch.set(db.doc(`users/${uid}/consents/${docType}_${version}_${granted ? 'grant' : 'revoke'}`),
|
||||
{ docType, granted, ...meta }, { merge: true });
|
||||
}
|
||||
batch.set(db.doc(`users/${uid}`), {
|
||||
termsAgreedAt: FieldValue.serverTimestamp(),
|
||||
termsVersion: version,
|
||||
notificationPrefs: { marketing },
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
}, { merge: true });
|
||||
await batch.commit();
|
||||
return { termsVersion: version, marketing };
|
||||
});
|
||||
|
||||
/**
|
||||
* `fn-account-withdraw` — 서비스 탈퇴. Figma `554:22903` · `554:24885` · `554:24908`.
|
||||
*
|
||||
* - SNS 연동이 남아 있으면(로그인 수단 2개 이상) 「비정상 탈퇴」로 거부한다 — 「연동된 계정 해제 후
|
||||
* 계정 탈퇴를 진행해주세요.」(`374:14699`).
|
||||
* - 원장성 데이터(POP 원장 · 결제 · 정산 · 기여)와 작성한 게시물은 지우지 않는다(Figma 「게시물은 삭제되지
|
||||
* 않고 데이터 보존」 · 별첨 B 「원장 보존 5년」). 사용자 문서의 개인식별정보(닉네임 · 표시 이름 · 이메일 ·
|
||||
* 사진 · 푸시 토큰)는 지우고 `status: 'withdrawn'` · `withdrawnAt` 을 남긴다(별첨 B 「탈퇴 후 개인식별정보는
|
||||
* 파기/비식별」, Admin 사용자 일람의 탈퇴일 · 상태 `557:40942`).
|
||||
* - Auth 계정을 지운다. 같은 소셜 계정으로 다시 가입하면 새 계정이다.
|
||||
*/
|
||||
export const fnAccountWithdraw = onCall(async (req) => {
|
||||
const uid = callerUid(req);
|
||||
const auth = getAuth();
|
||||
const user = await auth.getUser(uid);
|
||||
if (user.providerData.length > 1) {
|
||||
fail(ERR.withdrawLinked, '연동된 계정 해제 후 계정 탈퇴를 진행해주세요.');
|
||||
}
|
||||
const db = getFirestore();
|
||||
const userRef = db.doc(`users/${uid}`);
|
||||
const code = (await userRef.get()).get('inviteCode');
|
||||
const batch = db.batch();
|
||||
batch.set(userRef, {
|
||||
status: 'withdrawn',
|
||||
withdrawnAt: FieldValue.serverTimestamp(),
|
||||
nickname: null,
|
||||
displayName: null,
|
||||
email: null,
|
||||
photoUrl: null,
|
||||
fcmTokens: FieldValue.delete(),
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
}, { merge: true });
|
||||
// 탈퇴한 사람의 추천 코드로 새 귀속이 생기지 않게 닫는다.
|
||||
if (typeof code === 'string' && code) batch.set(db.doc(`inviteCodes/${code}`), { active: false }, { merge: true });
|
||||
await batch.commit();
|
||||
await auth.deleteUser(uid);
|
||||
return { withdrawn: true };
|
||||
});
|
||||
|
||||
/**
|
||||
* `fn-admin-set-role` — 마스터 전용. 운영자 등급을 커스텀 클레임으로 심는다.
|
||||
*
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getAuth } from 'firebase-admin/auth';
|
||||
import { getFirestore } from 'firebase-admin/firestore';
|
||||
import { defineString } from 'firebase-functions/params';
|
||||
import { onCall } from 'firebase-functions/v2/https';
|
||||
|
||||
@@ -96,7 +97,11 @@ function linkInfo(p: KakaoProfile) {
|
||||
export async function resolveKakaoUid(p: KakaoProfile): Promise<string> {
|
||||
const existing = await ownerOf(p.id);
|
||||
if (existing) return existing;
|
||||
const uid = kakaoUidOf(p.id);
|
||||
// 탈퇴한 계정의 문서(원장 보존)가 `kakao:<id>` 에 남아 있으면 같은 UID 로 다시 만들지 않는다 —
|
||||
// 다시 가입한 사람이 탈퇴 전 지갑 · 원장을 물려받는다.
|
||||
const base = kakaoUidOf(p.id);
|
||||
const withdrawn = (await getFirestore().doc(`users/${base}`).get()).get('status') === 'withdrawn';
|
||||
const uid = withdrawn ? `${base}:${Date.now()}` : base;
|
||||
const auth = getAuth();
|
||||
try {
|
||||
await auth.createUser({ uid, ...(p.nickname ? { displayName: p.nickname } : {}) });
|
||||
@@ -139,5 +144,10 @@ export const fnAuthKakao = onCall(async (req) => {
|
||||
return { linked: true };
|
||||
}
|
||||
const uid = await resolveKakaoUid(profile);
|
||||
// 동의한 이메일만 사용자 문서에 남긴다(10/5). Auth 이메일은 다른 제공자 계정과 겹치면 생성이
|
||||
// 거부되므로 쓰지 않는다. 이메일이 없어도 로그인은 그대로 된다.
|
||||
if (profile.email) {
|
||||
await getFirestore().doc(`users/${uid}`).set({ email: profile.email }, { merge: true });
|
||||
}
|
||||
return { token: await getAuth().createCustomToken(uid) };
|
||||
});
|
||||
|
||||
@@ -9,7 +9,6 @@ import { callerUid, optionalStr, str } from '../core/callable.js';
|
||||
import { loadPolicy } from '../core/config.js';
|
||||
import { ERR, fail } from '../core/errors.js';
|
||||
import { deviceHashOf } from '../core/ids.js';
|
||||
import { dayKey } from '../core/time.js';
|
||||
import { grantPop, settlePendingGrant, spendPop } from '../ledger/ledger.js';
|
||||
import { fandomOfUser, writeFpiEvent } from '../ledger/fpi.js';
|
||||
|
||||
@@ -75,7 +74,7 @@ export const REFERRAL_HOLD_MS = 24 * 60 * 60 * 1000;
|
||||
* 잠금 문서 `referralsByUser/{uid}.reward` — 피추천인 1명의 보상 상태.
|
||||
* - `pending`: 귀속됨 · 귀속 뒤 첫 검증 활동 대기
|
||||
* - `ineligible`: 귀속 전에 이미 검증 활동이 있었다(「최초」가 아님) — 지급 없음
|
||||
* - `capped`: 추천인의 하루 상한(`referralDailyCapPerReferrer`) 초과 — 지급 없음
|
||||
* - `capped`: (폐지) 추천인 하루 상한 초과 — 10/3 통합회신 7항으로 상한 삭제. 이전 기록 해석용으로만 남긴다
|
||||
* - `held`: 보류 지급(pending POP) 중 · `settleAfter` 뒤 확정
|
||||
* - `paid`: 확정 · FPI REFERRAL 1 단위
|
||||
* - `released`: 확정 전에 완료가 무효 · 이상 표시(`flagged`)로 보류 해제
|
||||
@@ -110,18 +109,19 @@ export const fnReferralAttribute = onCall(async (req) => {
|
||||
// 자기 추천. 계정 하나로 POP 을 만들어 내는 가장 쉬운 길이다.
|
||||
fail(ERR.questAbnormal, '본인 추천 코드는 쓸 수 없습니다.');
|
||||
}
|
||||
const { referralAttachDays, referralDailyCapPerReferrer } = await loadPolicy();
|
||||
// 추천인 1명당 하루 보상 상한은 두지 않는다 — 10/3 통합회신 7항 「삭제」(원장 C-120).
|
||||
// 자기 · 상호 · 중복 · 이상 패턴은 아래 판정과 Risk Engine 이 맡는다.
|
||||
const { referralAttachDays } = await loadPolicy();
|
||||
|
||||
const attributionRef = db.doc(`referrals/${uid}_${code}`);
|
||||
const lockRef = db.doc(`referralsByUser/${uid}`);
|
||||
const capRef = db.doc(`referralDaily/${referrerUid}_${dayKey(new Date())}`);
|
||||
let reward: ReferralReward = 'pending';
|
||||
try {
|
||||
await db.runTransaction(async (tx) => {
|
||||
// 동시 호출의 둘째는 잠금 문서 `create` 충돌(ALREADY_EXISTS)로 떨어진다.
|
||||
const [existing, lock, user, reverse, cap, done] = await Promise.all([
|
||||
const [existing, lock, user, reverse, done] = await Promise.all([
|
||||
tx.get(attributionRef), tx.get(lockRef), tx.get(db.doc(`users/${uid}`)),
|
||||
tx.get(db.doc(`referralsByUser/${referrerUid}`)), tx.get(capRef),
|
||||
tx.get(db.doc(`referralsByUser/${referrerUid}`)),
|
||||
// 「최초 검증 활동」은 귀속 뒤의 첫 완료다. 같은 트랜잭션에서 읽어 경합에도 판정이 같다.
|
||||
tx.get(db.collection(`users/${uid}/verifiedCompletions`).limit(1)),
|
||||
]);
|
||||
@@ -132,11 +132,7 @@ export const fnReferralAttribute = onCall(async (req) => {
|
||||
Date.now() - joined.toMillis() > referralAttachDays * 86_400_000) {
|
||||
fail(ERR.popVerifyFailed, `가입 후 ${referralAttachDays}일 안에만 추천 코드를 등록할 수 있습니다.`);
|
||||
}
|
||||
const today = (cap.get('count') as number | undefined) ?? 0;
|
||||
reward = !done.empty ? 'ineligible' : today >= referralDailyCapPerReferrer ? 'capped' : 'pending';
|
||||
if (reward === 'pending') {
|
||||
tx.set(capRef, { count: today + 1, updatedAt: FieldValue.serverTimestamp() }, { merge: true });
|
||||
}
|
||||
reward = done.empty ? 'pending' : 'ineligible';
|
||||
tx.create(lockRef, {
|
||||
attributionId: attributionRef.id, referrerUid, reward, createdAt: FieldValue.serverTimestamp(),
|
||||
});
|
||||
|
||||
@@ -114,8 +114,6 @@ export interface PolicyConfig {
|
||||
/** 추천 보상 — 원장 **B-69** 확정 추천인 일반 POP 100 · 피추천인 20. 피추천인 최초 검증 활동 뒤 지급. */
|
||||
referralReferrerPop: number;
|
||||
referralReferredPop: number;
|
||||
/** 추천인 1명이 하루에 보상 대상으로 받을 수 있는 귀속 수(B-71 어뷰징). 넘으면 `capped`. 🟡 운영값 5. */
|
||||
referralDailyCapPerReferrer: number;
|
||||
/** 가입 후 추천 코드를 등록할 수 있는 기간(일). 🟡 운영값 7. */
|
||||
referralAttachDays: number;
|
||||
/** 긴급 돌발 타임어택 지속 시간 상한(분) — 원장 「최대 24시간」. 🟡 운영값. */
|
||||
@@ -223,7 +221,6 @@ export const DEFAULT_POLICY: PolicyConfig = {
|
||||
liveWindowMinutes: 5,
|
||||
referralReferrerPop: 100,
|
||||
referralReferredPop: 20,
|
||||
referralDailyCapPerReferrer: 5,
|
||||
referralAttachDays: 7,
|
||||
timeAttackMaxMinutes: 1440,
|
||||
// 🟡 임시값 — 회신 대기 · 근거 없음(위 MusicShakePolicy 설명).
|
||||
|
||||
@@ -40,6 +40,8 @@ export const ERR = {
|
||||
// 계정 연결(SNS 연동) — 앱 auth 카탈로그와 같은 ID. 08 은 「다른 계정에 이미 연결」·「다른 이메일」 둘 다다.
|
||||
authCredentialInUse: 'E-AUTH-08',
|
||||
authAlreadyLinked: 'E-AUTH-09',
|
||||
// 서비스 탈퇴 — SNS 연동이 남아 있으면 「비정상 탈퇴」(Figma `554:24885` · `554:24908`).
|
||||
withdrawLinked: 'E-AUTH-14',
|
||||
// 참여형 번역(FR-I18N-02). E-TRN-01 은 정책 설계서 §P-13 이 「본인 번역 마킹 금지」로 이미 정했다.
|
||||
trnSelfMark: 'E-TRN-01',
|
||||
trnDailyCap: 'E-TRN-02',
|
||||
@@ -84,6 +86,7 @@ const STATUS: Record<ErrorCode, Status> = {
|
||||
'E-AUTH-03': 'permission-denied',
|
||||
'E-AUTH-08': 'already-exists',
|
||||
'E-AUTH-09': 'already-exists',
|
||||
'E-AUTH-14': 'failed-precondition',
|
||||
'E-TRN-01': 'permission-denied',
|
||||
'E-TRN-02': 'resource-exhausted',
|
||||
'E-TRN-03': 'not-found',
|
||||
|
||||
@@ -11,7 +11,7 @@ export {
|
||||
} from './quest/quest.js';
|
||||
export {
|
||||
fnAuthOnCreate, fnNebulaCreate, fnPlanetCreate,
|
||||
fnFandomJoin, fnFandomLeave, fnPrimaryFandomSet, fnConsentRecord, fnAdminSetRole,
|
||||
fnFandomJoin, fnFandomLeave, fnPrimaryFandomSet, fnConsentRecord, fnSignupConsent, fnAccountWithdraw, fnAdminSetRole,
|
||||
} from './account/account.js';
|
||||
export { fnAuthKakao } from './account/kakao.js';
|
||||
export {
|
||||
|
||||
Reference in New Issue
Block a user