From e9e4260e2c6acdfc4b75596c16e89e2a39a8625a Mon Sep 17 00:00:00 2001 From: Sn-Kinos Date: Tue, 6 Oct 2026 18:29:51 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B0=80=EC=9E=85=20=ED=8A=B8=EB=A6=AC?= =?UTF-8?q?=EA=B1=B0=20=EC=84=9C=EC=9A=B8=20=EB=A6=AC=EC=A0=84=20=C2=B7=20?= =?UTF-8?q?=ED=83=88=ED=87=B4=20=EC=8B=9C=20=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=82=AD=EC=A0=9C=20=C2=B7=20=EC=9C=84?= =?UTF-8?q?=EC=B9=98=20=EC=9D=B8=EC=A6=9D=20=EC=9C=84=EA=B2=BD=EB=8F=84=20?= =?UTF-8?q?=EB=AF=B8=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/firebase/functions/package.json | 2 +- app/firebase/functions/src/account/account.ts | 18 ++++++++++++++++-- app/firebase/functions/src/quest/verify.ts | 6 +++--- .../functions/test/account_onboarding.test.ts | 13 +++++++++++++ app/firebase/functions/test/quest_e2e.test.ts | 10 ++++++---- app/firebase/functions/test/support.ts | 2 +- app/firebase/package.json | 2 +- 7 files changed, 41 insertions(+), 12 deletions(-) diff --git a/app/firebase/functions/package.json b/app/firebase/functions/package.json index fb2987b..e41fc4f 100644 --- a/app/firebase/functions/package.json +++ b/app/firebase/functions/package.json @@ -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", diff --git a/app/firebase/functions/src/account/account.ts b/app/firebase/functions/src/account/account.ts index 0d1a6f2..2885005 100644 --- a/app/firebase/functions/src/account/account.ts +++ b/app/firebase/functions/src/account/account.ts @@ -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 }; }); diff --git a/app/firebase/functions/src/quest/verify.ts b/app/firebase/functions/src/quest/verify.ts index ef39339..8ff94f0 100644 --- a/app/firebase/functions/src/quest/verify.ts +++ b/app/firebase/functions/src/quest/verify.ts @@ -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 } } : {}), diff --git a/app/firebase/functions/test/account_onboarding.test.ts b/app/firebase/functions/test/account_onboarding.test.ts index dcdb0cd..808dce9 100644 --- a/app/firebase/functions/test/account_onboarding.test.ts +++ b/app/firebase/functions/test/account_onboarding.test.ts @@ -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('카카오 이메일 · 탈퇴 후 재가입', () => { diff --git a/app/firebase/functions/test/quest_e2e.test.ts b/app/firebase/functions/test/quest_e2e.test.ts index d75338d..82c7102 100644 --- a/app/firebase/functions/test/quest_e2e.test.ts +++ b/app/firebase/functions/test/quest_e2e.test.ts @@ -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'] }); diff --git a/app/firebase/functions/test/support.ts b/app/firebase/functions/test/support.ts index 5dc8cac..9389b03 100644 --- a/app/firebase/functions/test/support.ts +++ b/app/firebase/functions/test/support.ts @@ -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 { diff --git a/app/firebase/package.json b/app/firebase/package.json index 8fe849b..9ebe714 100644 --- a/app/firebase/package.json +++ b/app/firebase/package.json @@ -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"