가입 트리거 서울 리전 · 탈퇴 시 업로드 파일 삭제 · 위치 인증 위경도 미저장
This commit is contained in:
@@ -12,7 +12,7 @@
|
|||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run",
|
"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": {
|
"dependencies": {
|
||||||
"firebase-admin": "13.6.0",
|
"firebase-admin": "13.6.0",
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import * as functionsV1 from 'firebase-functions/v1';
|
|||||||
import { HttpsError, onCall } from 'firebase-functions/v2/https';
|
import { HttpsError, onCall } from 'firebase-functions/v2/https';
|
||||||
import { getFirestore, FieldValue, Timestamp, type Transaction } from 'firebase-admin/firestore';
|
import { getFirestore, FieldValue, Timestamp, type Transaction } from 'firebase-admin/firestore';
|
||||||
import { getAuth } from 'firebase-admin/auth';
|
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 { callerUid, str } from '../core/callable.js';
|
||||||
import { dayKey } from '../core/time.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 db = getFirestore();
|
||||||
const userRef = db.doc(`users/${user.uid}`);
|
const userRef = db.doc(`users/${user.uid}`);
|
||||||
const walletRef = db.doc(`users/${user.uid}/wallet/main`);
|
const walletRef = db.doc(`users/${user.uid}/wallet/main`);
|
||||||
@@ -513,6 +516,10 @@ export const fnSignupConsent = onCall(async (req) => {
|
|||||||
* 않고 데이터 보존」 · 별첨 B 「원장 보존 5년」). 사용자 문서의 개인식별정보(닉네임 · 표시 이름 · 이메일 ·
|
* 않고 데이터 보존」 · 별첨 B 「원장 보존 5년」). 사용자 문서의 개인식별정보(닉네임 · 표시 이름 · 이메일 ·
|
||||||
* 사진 · 푸시 토큰)는 지우고 `status: 'withdrawn'` · `withdrawnAt` 을 남긴다(별첨 B 「탈퇴 후 개인식별정보는
|
* 사진 · 푸시 토큰)는 지우고 `status: 'withdrawn'` · `withdrawnAt` 을 남긴다(별첨 B 「탈퇴 후 개인식별정보는
|
||||||
* 파기/비식별」, Admin 사용자 일람의 탈퇴일 · 상태 `557:40942`).
|
* 파기/비식별」, Admin 사용자 일람의 탈퇴일 · 상태 `557:40942`).
|
||||||
|
* - 본인이 올린 Storage 파일 중 **본인 전용 경로**를 지운다: `users/{uid}/profile/`(프로필 사진),
|
||||||
|
* `questProofs/{uid}/`(Quest 인증 사진). 삭제 실패는 로그만 남기고 탈퇴를 막지 않는다.
|
||||||
|
* **`users/{uid}/lounge/`(라운지 글 사진)는 지우지 않는다** — 글 처리 범위가 해석 확인 Q-E4 회신 대기라
|
||||||
|
* 글과 함께 「회신 후」에 정한다(글 문서가 이 경로를 가리키므로 먼저 지우면 깨진 사진이 남는다).
|
||||||
* - Auth 계정을 지운다. 같은 소셜 계정으로 다시 가입하면 새 계정이다.
|
* - Auth 계정을 지운다. 같은 소셜 계정으로 다시 가입하면 새 계정이다.
|
||||||
*/
|
*/
|
||||||
export const fnAccountWithdraw = onCall(async (req) => {
|
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 });
|
if (typeof code === 'string' && code) batch.set(db.doc(`inviteCodes/${code}`), { active: false }, { merge: true });
|
||||||
await batch.commit();
|
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);
|
await auth.deleteUser(uid);
|
||||||
return { withdrawn: true };
|
return { withdrawn: true };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -273,9 +273,9 @@ export const fnQuestVerifyPlace = onCall(async (req) => {
|
|||||||
evidence: {
|
evidence: {
|
||||||
place: {
|
place: {
|
||||||
placeId,
|
placeId,
|
||||||
// 정확도와 모의 위치 여부를 함께 남긴다 — 설계서 §2.1 「위치 사진 경로·GPS
|
// 사용자 위경도는 판정에만 쓰고 저장하지 않는다(10/6 발주사 개인정보 기준) —
|
||||||
// 좌표·정확도」와 §8 「mock location 차단」의 판정 입력이다.
|
// 거리 · 정확도 · 모의 위치 여부만 남긴다(§8 「mock location 차단」 판정 입력).
|
||||||
gps: { lat, lng, ...(typeof accuracyM === 'number' ? { accuracyM } : {}), mocked },
|
location: { distanceM: Math.round(gap), ...(typeof accuracyM === 'number' ? { accuracyM } : {}), mocked },
|
||||||
// C-03 「GPS 와 QR 판정이 모두 기록」 — 추가 수단은 값과 판정을 함께 남긴다.
|
// C-03 「GPS 와 QR 판정이 모두 기록」 — 추가 수단은 값과 판정을 함께 남긴다.
|
||||||
...(qr ? { qr: { value: qr, ok: qrOk } } : {}),
|
...(qr ? { qr: { value: qr, ok: qrOk } } : {}),
|
||||||
...(photo ? { photo: { path: photo, ok: photoOk } } : {}),
|
...(photo ? { photo: { path: photo, ok: photoOk } } : {}),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { getAuth } from 'firebase-admin/auth';
|
import { getAuth } from 'firebase-admin/auth';
|
||||||
import { getFirestore } from 'firebase-admin/firestore';
|
import { getFirestore } from 'firebase-admin/firestore';
|
||||||
|
import { getStorage } from 'firebase-admin/storage';
|
||||||
|
|
||||||
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
|
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
|
||||||
import { fnAccountWithdraw, fnAuthOnCreate, fnSignupConsent } from '../src/account/account.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);
|
expect((await db().doc(`users/${U}/popTransactions/t1`).get()).exists).toBe(true);
|
||||||
await expect(getAuth().getUser(U)).rejects.toBeDefined();
|
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('카카오 이메일 · 탈퇴 후 재가입', () => {
|
describe('카카오 이메일 · 탈퇴 후 재가입', () => {
|
||||||
|
|||||||
@@ -456,8 +456,9 @@ describe('방문 — S-H-PLACE (fn-quest-verify-place)', () => {
|
|||||||
const logs = await db().collection(`users/${UID}/activityLogs`).get();
|
const logs = await db().collection(`users/${UID}/activityLogs`).get();
|
||||||
expect(only(logs.docs).get('rejectReason')).toBe('E-QST-02');
|
expect(only(logs.docs).get('rejectReason')).toBe('E-QST-02');
|
||||||
const evidence = only(logs.docs).get('evidence') as
|
const evidence = only(logs.docs).get('evidence') as
|
||||||
{ place: { gps: { mocked: boolean } } };
|
{ place: { location: { mocked: boolean }; gps?: unknown } };
|
||||||
expect(evidence.place.gps.mocked).toBe(true);
|
expect(evidence.place.location.mocked).toBe(true);
|
||||||
|
expect(evidence.place.gps).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('좌표만으로도 반경 안이면 지급한다 — QR · 사진은 추가 수단(C-03)', async () => {
|
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' });
|
const result = await call(fnQuestVerifyPlace, { ...q, qr: 'code-ok' });
|
||||||
expect(result.rewarded).toBe(100);
|
expect(result.rewarded).toBe(100);
|
||||||
const ok = (await db().collection(`users/${UID}/activityLogs`).where('verified', '==', true).get()).docs;
|
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 } } };
|
const evidence = only(ok).get('evidence') as { place: { location: { distanceM: number }; qr: { value: string; ok: boolean } } };
|
||||||
expect(evidence.place.gps).toBeDefined();
|
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 });
|
expect(evidence.place.qr).toEqual({ value: 'code-ok', ok: true });
|
||||||
// GPS 없이(반경 밖) QR 만으로는 0건.
|
// GPS 없이(반경 밖) QR 만으로는 0건.
|
||||||
await db().doc('quests/place-quest-qr2').set({ type: 'location', placeId: PLACE, verifyMethods: ['gps', 'qr'] });
|
await db().doc('quests/place-quest-qr2').set({ type: 'location', placeId: PLACE, verifyMethods: ['gps', 'qr'] });
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export function bootAdmin(): App {
|
|||||||
}
|
}
|
||||||
const existing = getApps()[0];
|
const existing = getApps()[0];
|
||||||
if (existing) return existing;
|
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> {
|
export async function shutdownAdmin(): Promise<void> {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "npm run test:rules && npm run test:functions",
|
"test": "npm run test:rules && npm run test:functions",
|
||||||
"test:rules": "firebase emulators:exec --only firestore --project demo-fanuzu \"npm --prefix test test\"",
|
"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": {
|
"devDependencies": {
|
||||||
"firebase-tools": "15.28.2"
|
"firebase-tools": "15.28.2"
|
||||||
|
|||||||
Reference in New Issue
Block a user