가입 트리거 서울 리전 · 탈퇴 시 업로드 파일 삭제 · 위치 인증 위경도 미저장

This commit is contained in:
Sn-Kinos
2026-10-06 18:29:51 +09:00
parent 14fef64999
commit e9e4260e2c
7 changed files with 41 additions and 12 deletions
+1 -1
View File
@@ -12,7 +12,7 @@
"build": "tsc",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:emulator": "../test/node_modules/.bin/firebase emulators:exec --config ../firebase.json --only firestore,auth --project demo-fanuzu \"npm test\""
"test:emulator": "../test/node_modules/.bin/firebase emulators:exec --config ../firebase.json --only firestore,auth,storage --project demo-fanuzu \"npm test\""
},
"dependencies": {
"firebase-admin": "13.6.0",
+16 -2
View File
@@ -2,6 +2,8 @@ import * as functionsV1 from 'firebase-functions/v1';
import { HttpsError, onCall } from 'firebase-functions/v2/https';
import { getFirestore, FieldValue, Timestamp, type Transaction } from 'firebase-admin/firestore';
import { getAuth } from 'firebase-admin/auth';
import { getStorage } from 'firebase-admin/storage';
import { logger } from 'firebase-functions/v2';
import { callerUid, str } from '../core/callable.js';
import { dayKey } from '../core/time.js';
@@ -15,9 +17,10 @@ import { ERR, fail } from '../core/errors.js';
* 고정한 이유는 경합 때문이다 — 클라이언트가 먼저 만들면 서버 산출 필드가 빈
* 사용자 문서가 남는다.
*
* v2 에는 사용자 생성 배경 트리거가 없어 v1 Auth 트리거를 쓴다.
* v2 에는 사용자 생성 배경 트리거가 없어 v1 Auth 트리거를 쓴다. v1 은 `setGlobalOptions`(v2 전용)를
* 따르지 않으므로 리전을 여기서 직접 서울로 고정한다.
*/
export const fnAuthOnCreate = functionsV1.auth.user().onCreate(async (user) => {
export const fnAuthOnCreate = functionsV1.region('asia-northeast3').auth.user().onCreate(async (user) => {
const db = getFirestore();
const userRef = db.doc(`users/${user.uid}`);
const walletRef = db.doc(`users/${user.uid}/wallet/main`);
@@ -513,6 +516,10 @@ export const fnSignupConsent = onCall(async (req) => {
* 않고 데이터 보존」 · 별첨 B 「원장 보존 5년」). 사용자 문서의 개인식별정보(닉네임 · 표시 이름 · 이메일 ·
* 사진 · 푸시 토큰)는 지우고 `status: 'withdrawn'` · `withdrawnAt` 을 남긴다(별첨 B 「탈퇴 후 개인식별정보는
* 파기/비식별」, Admin 사용자 일람의 탈퇴일 · 상태 `557:40942`).
* - 본인이 올린 Storage 파일 중 **본인 전용 경로**를 지운다: `users/{uid}/profile/`(프로필 사진),
* `questProofs/{uid}/`(Quest 인증 사진). 삭제 실패는 로그만 남기고 탈퇴를 막지 않는다.
* **`users/{uid}/lounge/`(라운지 글 사진)는 지우지 않는다** — 글 처리 범위가 해석 확인 Q-E4 회신 대기라
* 글과 함께 「회신 후」에 정한다(글 문서가 이 경로를 가리키므로 먼저 지우면 깨진 사진이 남는다).
* - Auth 계정을 지운다. 같은 소셜 계정으로 다시 가입하면 새 계정이다.
*/
export const fnAccountWithdraw = onCall(async (req) => {
@@ -539,6 +546,13 @@ export const fnAccountWithdraw = onCall(async (req) => {
// 탈퇴한 사람의 추천 코드로 새 귀속이 생기지 않게 닫는다.
if (typeof code === 'string' && code) batch.set(db.doc(`inviteCodes/${code}`), { active: false }, { merge: true });
await batch.commit();
for (const prefix of [`users/${uid}/profile/`, `questProofs/${uid}/`]) {
try {
await getStorage().bucket().deleteFiles({ prefix });
} catch (e) {
logger.error('withdraw: storage cleanup failed', { uid, prefix, error: String(e) });
}
}
await auth.deleteUser(uid);
return { withdrawn: true };
});
+3 -3
View File
@@ -273,9 +273,9 @@ export const fnQuestVerifyPlace = onCall(async (req) => {
evidence: {
place: {
placeId,
// 정확도와 모의 위치 여부를 함께 남긴다 — 설계서 §2.1 「위치 사진 경로·GPS
// 좌표·정확도」와 §8 「mock location 차단」의 판정 입력이다.
gps: { lat, lng, ...(typeof accuracyM === 'number' ? { accuracyM } : {}), mocked },
// 사용자 위경도는 판정에만 쓰고 저장하지 않는다(10/6 발주사 개인정보 기준) —
// 거리 · 정확도 · 모의 위치 여부만 남긴다(§8 「mock location 차단」 판정 입력).
location: { distanceM: Math.round(gap), ...(typeof accuracyM === 'number' ? { accuracyM } : {}), mocked },
// C-03 「GPS 와 QR 판정이 모두 기록」 — 추가 수단은 값과 판정을 함께 남긴다.
...(qr ? { qr: { value: qr, ok: qrOk } } : {}),
...(photo ? { photo: { path: photo, ok: photoOk } } : {}),
@@ -1,6 +1,7 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { getAuth } from 'firebase-admin/auth';
import { getFirestore } from 'firebase-admin/firestore';
import { getStorage } from 'firebase-admin/storage';
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
import { fnAccountWithdraw, fnAuthOnCreate, fnSignupConsent } from '../src/account/account.js';
@@ -103,6 +104,18 @@ describe('fnAccountWithdraw', () => {
expect((await db().doc(`users/${U}/popTransactions/t1`).get()).exists).toBe(true);
await expect(getAuth().getUser(U)).rejects.toBeDefined();
});
it.skipIf(!process.env.STORAGE_EMULATOR_HOST)('본인 전용 Storage 경로는 지우고 라운지 글 사진은 남긴다', async () => {
await getAuth().createUser({ uid: U });
const bucket = getStorage().bucket();
const paths = [`users/${U}/profile/photo`, `questProofs/${U}/q1/1.jpg`, `users/${U}/lounge/aaaaaaaaaaaaaaaaaaaa-0`, 'users/other/profile/photo'];
for (const p of paths) await bucket.file(p).save('x');
await fnAccountWithdraw.run(as(U, {}));
const exists = await Promise.all(paths.map(async (p) => (await bucket.file(p).exists())[0]));
expect(exists).toEqual([false, false, true, true]);
await bucket.file(paths[2]!).delete();
await bucket.file(paths[3]!).delete();
});
});
describe('카카오 이메일 · 탈퇴 후 재가입', () => {
@@ -456,8 +456,9 @@ describe('방문 — S-H-PLACE (fn-quest-verify-place)', () => {
const logs = await db().collection(`users/${UID}/activityLogs`).get();
expect(only(logs.docs).get('rejectReason')).toBe('E-QST-02');
const evidence = only(logs.docs).get('evidence') as
{ place: { gps: { mocked: boolean } } };
expect(evidence.place.gps.mocked).toBe(true);
{ place: { location: { mocked: boolean }; gps?: unknown } };
expect(evidence.place.location.mocked).toBe(true);
expect(evidence.place.gps).toBeUndefined();
});
it('좌표만으로도 반경 안이면 지급한다 — QR · 사진은 추가 수단(C-03)', async () => {
@@ -517,8 +518,9 @@ describe('방문 — S-H-PLACE (fn-quest-verify-place)', () => {
const result = await call(fnQuestVerifyPlace, { ...q, qr: 'code-ok' });
expect(result.rewarded).toBe(100);
const ok = (await db().collection(`users/${UID}/activityLogs`).where('verified', '==', true).get()).docs;
const evidence = only(ok).get('evidence') as { place: { gps: unknown; qr: { value: string; ok: boolean } } };
expect(evidence.place.gps).toBeDefined();
const evidence = only(ok).get('evidence') as { place: { location: { distanceM: number }; qr: { value: string; ok: boolean } } };
expect(evidence.place.location.distanceM).toBeGreaterThanOrEqual(0);
expect(JSON.stringify(evidence)).not.toMatch(/"(lat|lng)"/);
expect(evidence.place.qr).toEqual({ value: 'code-ok', ok: true });
// GPS 없이(반경 밖) QR 만으로는 0건.
await db().doc('quests/place-quest-qr2').set({ type: 'location', placeId: PLACE, verifyMethods: ['gps', 'qr'] });
+1 -1
View File
@@ -17,7 +17,7 @@ export function bootAdmin(): App {
}
const existing = getApps()[0];
if (existing) return existing;
return initializeApp({ projectId: PROJECT_ID });
return initializeApp({ projectId: PROJECT_ID, storageBucket: `${PROJECT_ID}.appspot.com` });
}
export async function shutdownAdmin(): Promise<void> {
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"test": "npm run test:rules && npm run test:functions",
"test:rules": "firebase emulators:exec --only firestore --project demo-fanuzu \"npm --prefix test test\"",
"test:functions": "firebase emulators:exec --only firestore,auth --project demo-fanuzu \"npm --prefix functions test\""
"test:functions": "firebase emulators:exec --only firestore,auth,storage --project demo-fanuzu \"npm --prefix functions test\""
},
"devDependencies": {
"firebase-tools": "15.28.2"