걸음 수 Health Connect·HealthKit · 위치 퀘스트 사진·QR 인증 · 행성 형태 확정
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user