걸음 수 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 {