걸음 수 Health Connect·HealthKit · 위치 퀘스트 사진·QR 인증 · 행성 형태 확정

This commit is contained in:
Sn-Kinos
2026-10-05 23:57:01 +09:00
parent dc9861df38
commit dce2f64362
44 changed files with 930 additions and 66 deletions
+19 -1
View File
@@ -1,3 +1,4 @@
import { randomBytes } from 'node:crypto';
import { HttpsError, onCall } from 'firebase-functions/v2/https';
import { onSchedule } from 'firebase-functions/v2/scheduler';
import { logger } from 'firebase-functions';
@@ -352,8 +353,13 @@ export interface CreateInput {
goal?: number | undefined;
/** 지도에 꽂은 핀(등록 장소가 아닐 때) — Figma `371:356705` 위치 선택. */
pin?: { lat: number; lng: number; address: string } | undefined;
/** 위치 Quest 의 추가 인증 수단(C-03 「Location 의 추가 인증 수단」) — `photo` · `qr`. GPS 는 항상 들어간다. */
extraVerify?: string[] | undefined;
}
/** 위치 Quest 에 덧붙일 수 있는 인증 수단(C-03 · PRD-05 「GPS · 사진 · QR 인증」). NFC 는 명세 제출 전이라 뺀다. */
export const PLACE_EXTRA_VERIFY = ['photo', 'qr'] as const;
export const fnQuestCreate = onCall(async (req) => {
const data = (req.data ?? {}) as Record<string, unknown>;
const int = (k: string) => (typeof data[k] === 'number' ? data[k] as number : 0);
@@ -372,6 +378,7 @@ export const fnQuestCreate = onCall(async (req) => {
placeId: optionalStr(data, 'placeId'),
goal: int('goal'),
pin: data.pin && typeof data.pin === 'object' ? data.pin as CreateInput['pin'] : undefined,
extraVerify: Array.isArray(data.extraVerify) ? data.extraVerify.filter((v): v is string => typeof v === 'string') : undefined,
});
});
@@ -474,12 +481,23 @@ export async function createQuest(input: CreateInput, now = new Date()): Promise
}
}
// 위치 Quest 의 인증 수단(C-03) — 현장 방문 조건이라 GPS 는 반드시 들어가고, 고른 추가 수단이 붙는다.
const extras = placeId
? PLACE_EXTRA_VERIFY.filter((m) => input.extraVerify?.includes(m))
: [];
if (extras.includes('qr')) {
// QR 코드 발급 — 생성자만 읽는다(현장에 붙일 QR). 참여자가 읽을 수 있으면 찍지 않고 값을 보낼 수 있다.
await db.doc(`questSecrets/${questRef.id}`).set({
authorUid: uid, qrCode: randomBytes(12).toString('hex'), createdAt: FieldValue.serverTimestamp(),
});
}
// 1) 예치 + Draft(수수료 미부과 — B-30).
const draft = {
type: activity.key, activityKey: activity.key, categoryKey: category.key,
title, userDescription: description, authorUid: uid, authorName: publicNameOf(user),
fandomId, rewardMode, rewardPop, capacity, goal,
link, placeId, verifyMethods: placeId ? ['gps'] : [],
link, placeId, verifyMethods: placeId ? ['gps', ...extras] : [],
repeat: { allowed: false, intervalHours: 24, maxCount: 1 },
endsAt: Timestamp.fromDate(endsAt), creatorTz: offsetLabel(input.utcOffsetMinutes),
// 마감은 검수 승인(실제 공개) 시각으로 다시 계산한다 — 입력값을 남겨 둔다.
+34 -11
View File
@@ -1,5 +1,6 @@
import { onCall } from 'firebase-functions/v2/https';
import { getFirestore, FieldValue, GeoPoint, Timestamp } from 'firebase-admin/firestore';
import { getStorage } from 'firebase-admin/storage';
import { callerUid, num, optionalStr, str } from '../core/callable.js';
import { ERR, fail, positiveInt } from '../core/errors.js';
@@ -197,6 +198,12 @@ export const fnQuestVerifyMusicShake = onCall(async (req) => {
* (`quests.repeat.allowed`)만 직전 **유효** 인증부터 `intervalHours`(기본 24) 간격으로
* `maxCount` 까지. 실패는 간격을 다시 시작하지 않는다(진행 문서는 지급과 함께만 쓴다).
*/
/** 방문 인증 사진이 Storage 에 있는가. 테스트가 바꿔 끼운다. */
export const placeProofStorage = {
exists: async (path: string): Promise<boolean> => (await getStorage().bucket().file(path).exists())[0],
};
const proofPhotoExists = (path: string) => placeProofStorage.exists(path).catch(() => false);
export const fnQuestVerifyPlace = onCall(async (req) => {
const uid = callerUid(req);
const placeId = str(req.data, 'placeId');
@@ -213,9 +220,13 @@ export const fnQuestVerifyPlace = onCall(async (req) => {
if (questPlace !== placeId) {
fail(ERR.questOutOfRange, '이 퀘스트의 인증 장소가 아닙니다.');
}
// 인증 수단은 **선택 입력**이다(C-03).
const proofKind = optionalStr(req.data, 'proofKind') ?? '';
const proof = optionalStr(req.data, 'proof') ?? '';
// 추가 인증 수단(C-03) — Quest 가 고른 수단(`verifyMethods` 의 photo · qr)은 GPS 와 **함께** 와야 한다.
// 고르지 않은 Quest 는 GPS 만으로 인증된다. 예전 앱의 `proofKind` · `proof` 한 쌍도 받는다.
const methods = new Set((questDoc.get('verifyMethods') as unknown[] | undefined) ?? []);
const legacyKind = optionalStr(req.data, 'proofKind') ?? '';
const legacyProof = optionalStr(req.data, 'proof') ?? '';
const qr = optionalStr(req.data, 'qr') ?? (legacyKind === 'qr' ? legacyProof : '');
const photo = optionalStr(req.data, 'photo') ?? (legacyKind === 'photo' ? legacyProof : '');
const mocked = (req.data as Record<string, unknown>)?.mocked === true;
const accuracyM = (req.data as Record<string, unknown>)?.accuracyM;
const now = new Date();
@@ -233,17 +244,23 @@ export const fnQuestVerifyPlace = onCall(async (req) => {
const logRef = db.collection(`users/${uid}/activityLogs`).doc();
const inRange = gap <= radius;
const proofGiven = (proofKind === 'photo' || proofKind === 'qr') && proof.length > 0;
// 판정 순서는 **조작 → 반경**이다. 모의 위치는 좌표 자체를 믿을 수 없다는 뜻이라
// QR — 생성 때 발급한 코드(`questSecrets`)와 같아야 한다. 사진 — 본인이 이 Quest 로 올린 파일이어야 한다.
const qrOk = !methods.has('qr')
|| (qr.length > 0 && qr === (await db.doc(`questSecrets/${questId}`).get()).get('qrCode'));
const photoOk = !methods.has('photo')
|| (photo.startsWith(`questProofs/${uid}/${questId}/`) && !photo.includes('..') && await proofPhotoExists(photo));
// 판정 순서는 **조작 → 반경 → 추가 수단**이다. 모의 위치는 좌표 자체를 믿을 수 없다는 뜻이라
// 반경 판정보다 앞선다.
const reject = mocked
? ERR.questAbnormal
: !inRange
? ERR.questOutOfRange
: null;
: !qrOk || !photoOk
? ERR.questIncomplete
: null;
await logRef.set({
type: proofKind === 'qr' ? 'placeQr' : proofKind === 'photo' ? 'placePhoto' : 'placeGps',
type: qr ? 'placeQr' : photo ? 'placePhoto' : 'placeGps',
fandomId: optionalStr(req.data, 'fandomId') ?? '',
value: Math.round(gap),
evidence: {
@@ -252,7 +269,10 @@ export const fnQuestVerifyPlace = onCall(async (req) => {
// 정확도와 모의 위치 여부를 함께 남긴다 — 설계서 §2.1 「위치 사진 경로·GPS
// 좌표·정확도」와 §8 「mock location 차단」의 판정 입력이다.
gps: { lat, lng, ...(typeof accuracyM === 'number' ? { accuracyM } : {}), mocked },
...(proofGiven ? { proof: { kind: proofKind, ref: proof } } : {}),
// C-03 「GPS 와 QR 판정이 모두 기록」 — 추가 수단은 값과 판정을 함께 남긴다.
...(qr ? { qr: { value: qr, ok: qrOk } } : {}),
...(photo ? { photo: { path: photo, ok: photoOk } } : {}),
...(qr || photo ? { proof: { kind: qr ? 'qr' : 'photo', ref: qr || photo } } : {}),
},
},
questId,
@@ -269,6 +289,8 @@ export const fnQuestVerifyPlace = onCall(async (req) => {
if (!inRange) {
fail(ERR.questOutOfRange, '해당 장소에서 너무 멀리 떨어져 있습니다.');
}
if (!qrOk) fail(ERR.questIncomplete, '이 장소의 QR 코드를 스캔해 주세요.');
if (!photoOk) fail(ERR.questIncomplete, '방문 인증 사진을 찍어 주세요.');
// C-12 참여 횟수 · 간격. 판정은 진행 문서의 **직전 유효 인증**만 본다.
const progressSnap = await db.doc(`users/${uid}/questProgress/${questId}`).get();
const repeat = (questDoc.get('repeat') ?? {}) as { allowed?: unknown; intervalHours?: unknown; maxCount?: unknown };
@@ -286,10 +308,11 @@ export const fnQuestVerifyPlace = onCall(async (req) => {
fail(ERR.popDuplicate, `다음 인증은 직전 인증 ${intervalHours}시간 뒤부터 가능합니다.`);
}
// 추가 인증 수단이 왔으면 한 번만 쓴다. 재사용은 같은 방문을 여러 번 인정하는 셈이다.
if (proofGiven) {
// 사진은 한 번만 쓴다 — 같은 파일을 다시 내는 것은 같은 방문을 여러 번 인정하는 셈이다. QR 은 장소에
// 붙은 같은 코드를 모두가 찍으므로 1회성이 아니다(1인 횟수는 위 C-12 가 막는다).
if (methods.has('photo')) {
try {
await db.doc(`placeProofs/${proofKind}_${proof}`).create({
await db.doc(`placeProofs/photo_${photo.replaceAll('/', '_')}`).create({
uid, placeId, createdAt: FieldValue.serverTimestamp(),
});
} catch {
+47 -13
View File
@@ -7,7 +7,7 @@ import { grantPop } from '../src/ledger/ledger.js';
import { verifiedQuestCountOf } from '../src/ledger/fpi.js';
import { fnAdminCompletionVoid } from '../src/admin/admin.js';
import { fnAdReward, fnQuestAttend, fnQuestVerifySteps } from '../src/quest/quest.js';
import { fnQuestVerifyMusicShake, fnQuestVerifyPlace } from '../src/quest/verify.js';
import { fnQuestVerifyMusicShake, fnQuestVerifyPlace, placeProofStorage } from '../src/quest/verify.js';
import { fnCampaignFund } from '../src/fandom/fandom.js';
/**
@@ -81,7 +81,7 @@ beforeEach(async () => {
const snap = await db().collection(`users/${UID}/${sub}`).get();
await Promise.all(snap.docs.map((d) => d.ref.delete()));
}
for (const c of ['placeProofs', 'adSsv', 'adRewardLogs']) {
for (const c of ['placeProofs', 'questSecrets', 'adSsv', 'adRewardLogs']) {
const docs = await db().collection(c).get();
await Promise.all(docs.docs.map((d) => d.ref.delete()));
}
@@ -503,20 +503,54 @@ describe('방문 — S-H-PLACE (fn-quest-verify-place)', () => {
expect((await db().collection(`users/${UID}/popTransactions`).get()).size).toBe(1);
});
it('반경 안 + QR 이면 장소 보상값으로 지급하고 인증 재사용을 막는다', async () => {
const result = await call(
fnQuestVerifyPlace, { ...inRange, proofKind: 'qr', proof: 'tok-ok' },
);
it('QR 을 고른 Quest 는 발급 코드와 같은 QR 이 GPS 와 함께 와야 지급하고 두 판정을 모두 남긴다(C-03)', async () => {
await db().doc('quests/place-quest-qr').set({ type: 'location', placeId: PLACE, verifyMethods: ['gps', 'qr'] });
await db().doc('questSecrets/place-quest-qr').set({ authorUid: 'someone', qrCode: 'code-ok' });
const q = { ...inRange, questId: 'place-quest-qr' };
// GPS 만 · 다른 코드 — 미완료.
expect(await codeOf(() => call(fnQuestVerifyPlace, q))).toBe('E-QST-01');
expect(await codeOf(() => call(fnQuestVerifyPlace, { ...q, qr: 'code-bad' }))).toBe('E-QST-01');
const result = await call(fnQuestVerifyPlace, { ...q, qr: 'code-ok' });
expect(result.rewarded).toBe(100);
expect(result.distanceM).toBeLessThanOrEqual(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();
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'] });
await db().doc('questSecrets/place-quest-qr2').set({ authorUid: 'someone', qrCode: 'code-ok' });
expect(await codeOf(() => call(fnQuestVerifyPlace, { ...farAway, questId: 'place-quest-qr2', qr: 'code-ok' })))
.toBe('E-QST-03');
});
it('사진을 고른 Quest 는 본인이 이 Quest 로 올린 사진이어야 하고 같은 사진은 다시 못 쓴다', async () => {
const real = placeProofStorage.exists;
placeProofStorage.exists = async (path) => path.endsWith('/a.jpg');
try {
for (const id of ['place-quest-photo', 'place-quest-photo2']) {
await db().doc(`quests/${id}`).set({ type: 'location', placeId: PLACE, verifyMethods: ['gps', 'photo'] });
}
const q = { ...inRange, questId: 'place-quest-photo' };
expect(await codeOf(() => call(fnQuestVerifyPlace, q))).toBe('E-QST-01');
expect(await codeOf(() => call(fnQuestVerifyPlace, { ...q, photo: `questProofs/other/place-quest-photo/a.jpg` })))
.toBe('E-QST-01');
expect(await codeOf(() => call(fnQuestVerifyPlace, { ...q, photo: `questProofs/${UID}/place-quest-photo/none.jpg` })))
.toBe('E-QST-01');
const photo = `questProofs/${UID}/place-quest-photo/a.jpg`;
expect((await call(fnQuestVerifyPlace, { ...q, photo })).rewarded).toBe(100);
// 같은 파일 경로는 이 Quest 접두라 다른 Quest 에는 애초에 맞지 않는다.
expect(await codeOf(() => call(fnQuestVerifyPlace, { ...inRange, questId: 'place-quest-photo2', photo })))
.toBe('E-QST-01');
} finally {
placeProofStorage.exists = real;
}
});
it('추가 수단을 고르지 않은 Quest 에 온 QR 은 기록만 하고 GPS 로 판정한다', async () => {
const result = await call(fnQuestVerifyPlace, { ...inRange, proofKind: 'qr', proof: 'tok-ok' });
expect(result.rewarded).toBe(100);
const logs = await db().collection(`users/${UID}/activityLogs`).get();
expect(only(logs.docs).get('verified')).toBe(true);
// 같은 QR 토큰을 다른 Quest 에 다시 쓰는 시도.
expect(await codeOf(
() => call(fnQuestVerifyPlace, { ...inRange, questId: 'place-quest-reuse', proofKind: 'qr', proof: 'tok-ok' }),
)).toBe('E-POP-03');
expect(only(logs.docs).get('type')).toBe('placeQr');
});
});
@@ -541,6 +541,18 @@ describe('위치 선택 지도 핀 (Figma 371:356705)', () => {
expect((ok as Record<string, unknown>).rewarded).toBe(5);
});
it('추가 인증 수단(사진 · QR)을 고르면 GPS 에 덧붙고, QR 이면 생성자만 읽는 코드를 발급한다(C-03)', async () => {
const { questId } = await create({
activityKey: 'location', url: undefined, manualTitle: undefined,
pin: { lat: 37.4979, lng: 127.0276, address: '서울특별시 강남구 역삼동' },
extraVerify: ['qr', 'nfc', 'photo'],
});
expect((await quest(questId)).verifyMethods).toEqual(['gps', 'photo', 'qr']);
const secret = (await db().doc(`questSecrets/${questId}`).get()).data()!;
expect(secret.authorUid).toBe(CREATOR);
expect(secret.qrCode).toMatch(/^[0-9a-f]{24}$/);
});
it('좌표가 틀리면 거부한다', async () => {
expect(await codeOf(() => create({
activityKey: 'location', url: undefined, manualTitle: undefined,