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'; import { deviceHashOf, txIdOf } from '../core/ids.js'; import { dayKey, isPlausibleOccurredAt } from '../core/time.js'; import { DEFAULT_POLICY, loadPolicy, type MusicShakePolicy, type ShakeGrade } from '../core/config.js'; import { grantPop, type GrantInput } from '../ledger/ledger.js'; import { fandomOfUser, markCompletion, writeFpiEvent } from '../ledger/fpi.js'; import { dailyLimitOf, progress, progressWriter, questOf } from './quest.js'; import { payFromPool } from './ugc.js'; import { hasLocationConsent } from '../account/account.js'; /** * 검증이 필요한 퀘스트 둘 — Music Shake 와 위치. * * 별첨 1 ③ 판정 기준이 「부정 시도 필터 차단(중복 참여·반복 요청·비정상 패턴)」과 * 「보상 전량 서버검증 경유」를 요구한다. **클라이언트가 보낸 판정 결과를 쓰지 * 않는 것**이 두 함수의 공통 원칙이다 — 증거만 받고 판정은 서버가 다시 한다. */ /** 두 좌표 사이 거리(m). 지구를 구로 보는 하버사인 식이다. */ export function distanceMeters( lat1: number, lon1: number, lat2: number, lon2: number, ): number { const R = 6_371_000; const toRad = (d: number) => (d * Math.PI) / 180; const dLat = toRad(lat2 - lat1); const dLon = toRad(lon2 - lon1); const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; return 2 * R * Math.asin(Math.min(1, Math.sqrt(a))); } /** * Music Shake 판정 — 설계서 E-02. * * 클라이언트는 구간별 판정표를 증거로 보낸다. 서버는 **그 표가 스스로 * 모순되지 않는지**와 **완주했는지**를 본다. 판정 등급을 클라이언트가 정해 * 보내면 그대로 믿게 되므로, 등급은 서버가 다시 매긴다. */ export interface ShakeJudgement { perfect: number; good: number; miss: number; maxCombo: number; } export type { ShakeGrade }; /** * 판정표 → 등급. F 면 실패(보상 0 — 원장 B-24). 문턱은 **운영값**(`config/policy.musicShake`)이며 * 🟡 임시값 — 회신 대기다(근거 없음 · 해석 확인 요청 2026-10-07 Q-MS-05). */ export function gradeShake( judgement: ShakeJudgement, totalSegments: number, rule: MusicShakePolicy = DEFAULT_POLICY.musicShake, ): ShakeGrade { const hit = judgement.perfect + judgement.good; if (totalSegments <= 0 || hit + judgement.miss !== totalSegments) return 'F'; if (hit / totalSegments < rule.passRate) return 'F'; const accuracy = judgement.perfect / totalSegments; if (judgement.miss === 0 && accuracy >= rule.gradeCuts.S) return 'S'; if (accuracy >= rule.gradeCuts.A) return 'A'; if (accuracy >= rule.gradeCuts.B) return 'B'; return 'C'; } /** YouTube 링크의 영상 ID — 앱 `youtubeVideoId` 와 같은 규칙. 아니면 null. */ export function videoIdOf(raw: unknown): string | null { if (typeof raw !== 'string') return null; let u: URL; try { u = new URL(raw.trim()); } catch { return null; } const host = u.hostname.toLowerCase(); const seg = u.pathname.split('/').filter(Boolean); let id: string | null = null; if (host === 'youtu.be') id = seg[0] ?? null; else if (host === 'youtube.com' || host.endsWith('.youtube.com')) { id = u.searchParams.get('v') ?? (seg.length >= 2 && ['shorts', 'embed', 'live'].includes(seg[0]!) ? seg[1]! : null); } return id && /^[A-Za-z0-9_-]{11}$/.test(id) ? id : null; } /** `fn-quest-verify-music-shake` */ export const fnQuestVerifyMusicShake = onCall(async (req) => { const uid = callerUid(req); const trackId = str(req.data, 'trackId'); const now = new Date(); const data = (req.data ?? {}) as Record; // 곡과 하루 한도는 **퀘스트 문서**가 정한다. 예전 멱등키는 `trackId · 날짜` 라 곡 ID 만 // 바꿔 부르면 하루에 무제한 지급됐다(리뷰 재검토 blocker). 곡은 퀘스트의 카탈로그와 대조하고, // 구간 수도 퀘스트에 정의돼 있으면 그 값만 받는다 — 판정표를 줄여 등급을 올리지 못하게. const questId = str(req.data, 'questId'); const quest = await questOf(uid, questId, ['musicshake', 'music_shake', 'shake']); // 곡은 퀘스트의 카탈로그(`trackIds` · `trackId`) 또는 지정 공식 영상(`link.url` · PRD-04 MVP). const trackIds = [ ...(Array.isArray(quest.get('trackIds')) ? quest.get('trackIds') as unknown[] : [quest.get('trackId')]), videoIdOf((quest.get('link') as { url?: unknown } | undefined)?.url), ].filter((v) => v != null); if (!trackIds.includes(trackId)) { fail(ERR.questIncomplete, '이 퀘스트의 곡이 아닙니다.'); } const raw = data.occurredAt; const at = typeof raw === 'string' ? new Date(raw) : now; if (Number.isNaN(at.getTime()) || !isPlausibleOccurredAt(at, now)) { fail(ERR.questAbnormal, '활동 시각이 서버 시각과 크게 어긋납니다.'); } const key = dayKey(at); const judgement = data.judgement as ShakeJudgement | undefined; const totalSegments = positiveInt(num(req.data, 'totalSegments'), 'totalSegments'); if (!judgement) fail(ERR.questIncomplete, '판정 결과가 없습니다.'); const questSegments = quest.get('totalSegments'); if (typeof questSegments === 'number' && questSegments !== totalSegments) { fail(ERR.questAbnormal, '곡의 구간 수가 퀘스트 정의와 다릅니다.'); } // 첫 박 시각(영상 시각 기준 박 위치 `firstBeatMs + i·60/bpm`)도 같은 기준이어야 한다. const questFirstBeat = quest.get('firstBeatMs'); const firstBeatMs = typeof questFirstBeat === 'number' ? questFirstBeat : 0; const sentFirstBeat = req.data?.firstBeatMs; if (sentFirstBeat !== undefined && sentFirstBeat !== firstBeatMs) { fail(ERR.questAbnormal, '첫 박 시각이 퀘스트 정의와 다릅니다.'); } const limit = await dailyLimitOf(questId); const state = await progress(uid, questId, key); if (state.count >= limit) { fail(ERR.popDuplicate, '오늘 이 퀘스트의 보상은 이미 받았습니다.'); } const policy = await loadPolicy(); const grade = gradeShake(judgement, totalSegments, policy.musicShake); const db = getFirestore(); const logRef = db.collection(`users/${uid}/activityLogs`).doc(); await logRef.set({ type: 'musicShake', fandomId: optionalStr(req.data, 'fandomId') ?? '', value: judgement.perfect, evidence: { musicShake: { trackId, totalSegments, firstBeatMs, judgements: judgement } }, questId, verified: grade !== 'F', rejectReason: grade === 'F' ? ERR.questIncomplete : null, grade, deviceHash: optionalStr(req.data, 'deviceRaw') ? deviceHashOf(str(req.data, 'deviceRaw')) : null, occurredAt: at, createdAt: FieldValue.serverTimestamp(), }); if (grade === 'F') { // 판정표가 스스로 모순되거나 성공 기준에 못 미친 경우다(B-24 — 판정 실패 시 보상 0). // 증거는 남기고 보상은 없다. fail(ERR.questIncomplete, '박자 판정이 성공 기준에 미치지 못했습니다.'); } // 원장 B-24 「보상액은 운영값」 — 퀘스트 문서(Admin 「설정 보상 POP」)가 정하고, 없으면 // `config/policy.musicShake.pop` 으로 물러난다. 앱 화면도 같은 순서로 읽는다(`QuestType.rewardFrom`). // 등급 배수는 🟡 임시값 — 회신 대기(Q-MS-05). const base = quest.get('rewardPop'); const amount = Math.round( (typeof base === 'number' && base >= 0 ? base : policy.musicShake.pop) * (policy.musicShake.gradeMultiplier[grade] ?? 0), ); const result = await grantPop({ uid, popType: 'activity', amount, reasonCode: 'QUEST_MUSIC_SHAKE', growthSource: 'quest_reward', // 하루 한도는 곡이 아니라 **퀘스트 단위**다(`quests/{id}.dailyLimit`). n 번째 인정이 멱등 단위. idempotencyKey: `quest:${uid}:${questId}:${key}:${state.count + 1}`, refPath: logRef.path, alsoWrite: (tx, user) => { progressWriter(state.ref, questId, key, state.count + 1, state.rewardedPop + amount)(tx); // Music Shake 완주 1회 = FPI **BPM** 1 단위(C-35). 등급 배수 · 보상액과 무관(C-34). writeFpiEvent(tx, { uid, fandomId: fandomOfUser(user), axis: 'bpm', units: 1, activity: 'music_shake', sourceId: `music:${questId}:${key}:${state.count + 1}`, occurredAt: at, refPath: logRef.path, }, user); // 검증 활동 — 같은 반복 Quest 는 같은 날 1회(C-128). 같은 날 두 번째 완주는 같은 표식에 모인다. markCompletion(tx, uid, `${questId}_${key}`, { questId, txId: txIdOf(`quest:${uid}:${questId}:${key}:${state.count + 1}`), }); }, }); return { grade, txId: result.txId, balanceAfter: result.balanceAfter, rewarded: amount }; }); /** * `fn-quest-verify-place` — 방문 인증. 설계서 E-06. * * 정책 원장 **C-03** — 현장 방문 조건은 **GPS / Geofence 가 필수**이고 QR · 사진은 * Location 의 **추가 인증 수단(선택)** 이다. 예전 구현은 인증 수단이 없으면 보상을 * 막았는데, 앱이 좌표만 보내므로 위치 Quest 가 **한 번도 지급되지 않았다.** 인증 수단이 * 오면 판정을 함께 남기고 1회성만 확인한다. 반경은 Admin 운영값이다(D-08). * * 참여 횟수는 **C-12** — 동일 Quest · 동일 사용자 **기본 1회.** 운영자가 허용한 반복형 * (`quests.repeat.allowed`)만 직전 **유효** 인증부터 `intervalHours`(기본 24) 간격으로 * `maxCount` 까지. 실패는 간격을 다시 시작하지 않는다(진행 문서는 지급과 함께만 쓴다). */ /** 방문 인증 사진이 Storage 에 있는가. 테스트가 바꿔 끼운다. */ export const placeProofStorage = { exists: async (path: string): Promise => (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); // 위치기반 서비스 약관 동의(Q-E1 · R02) — 가입 때가 아니라 위치기능 최초 사용 전에 받는다. 앱이 먼저 // 묻지만 서버도 같은 기준을 지킨다. 좌표를 받기 전에 끊는다(동의 없이 위치를 처리하지 않는다). if (!(await hasLocationConsent(uid))) { fail(ERR.permissionDenied, '위치기반 서비스 이용약관에 동의한 뒤 인증할 수 있습니다.', { reason: 'location-consent' }); } const placeId = str(req.data, 'placeId'); const lat = num(req.data, 'lat'); const lng = num(req.data, 'lng'); // 참여 횟수는 Quest 단위다(C-12 — 같은 장소의 다른 Quest 는 별개). 그래서 퀘스트 문서가 // 반드시 있어야 하고 **그 퀘스트의 장소**여야 한다 — 없는 ID 로 바꿔 부르면 1회 제한이 // 매번 새로 열린다(리뷰 B2). 대표 팬덤 자격(C-16)도 여기서 본다. const questId = str(req.data, 'questId'); const questDoc = await questOf(uid, questId, ['place', 'location']); if (questDoc.get('authorUid') === uid) fail(ERR.permissionDenied, '내가 만든 퀘스트에는 참여할 수 없습니다.'); const conditions = questDoc.get('conditions') as { placeId?: unknown } | undefined; const questPlace = questDoc.get('placeId') ?? conditions?.placeId; if (questPlace !== placeId) { fail(ERR.questOutOfRange, '이 퀘스트의 인증 장소가 아닙니다.'); } // 추가 인증 수단(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)?.mocked === true; const accuracyM = (req.data as Record)?.accuracyM; const now = new Date(); const db = getFirestore(); const place = await db.doc(`places/${placeId}`).get(); if (!place.exists) fail(ERR.questOutOfRange, '등록되지 않은 장소입니다.'); const geo = place.get('geo') as GeoPoint | undefined; if (!geo) fail(ERR.questOutOfRange, '장소 좌표가 없습니다.'); // 반경은 Admin 운영값이다(D-08 · 별첨 B **B-27** 기본 100m). 문서에 값이 없을 때만 // 기본값을 쓰고, 앱에는 두지 않는다. const radius = (place.get('radiusM') as number | undefined) ?? 100; const gap = distanceMeters(lat, lng, geo.latitude, geo.longitude); const logRef = db.collection(`users/${uid}/activityLogs`).doc(); const inRange = gap <= radius; // 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 : !qrOk || !photoOk ? ERR.questIncomplete : null; await logRef.set({ type: qr ? 'placeQr' : photo ? 'placePhoto' : 'placeGps', fandomId: optionalStr(req.data, 'fandomId') ?? '', value: Math.round(gap), evidence: { place: { placeId, // 사용자 위경도는 판정에만 쓰고 저장하지 않는다(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 } } : {}), ...(qr || photo ? { proof: { kind: qr ? 'qr' : 'photo', ref: qr || photo } } : {}), }, }, questId, verified: reject === null, rejectReason: reject, occurredAt: now, createdAt: FieldValue.serverTimestamp(), }); if (mocked) { // 별첨 1 ③ 예외 처리 「위치 스푸핑 — 서버검증 필터 차단」. fail(ERR.questAbnormal, '모의 위치로 판정해 보상을 지급하지 않았습니다.'); } 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 }; const repeatable = repeat.allowed === true; const intervalHours = typeof repeat.intervalHours === 'number' && repeat.intervalHours > 0 ? repeat.intervalHours : 24; const maxCount = !repeatable ? 1 : typeof repeat.maxCount === 'number' && repeat.maxCount > 0 ? repeat.maxCount : Infinity; const count = (progressSnap.get('count') as number | undefined) ?? 0; const lastAt = progressSnap.get('lastVerifiedAt') as Timestamp | undefined; if (count >= maxCount) { fail(ERR.popDuplicate, '이미 참여한 퀘스트입니다.'); } if (lastAt && now.getTime() - lastAt.toMillis() < intervalHours * 3_600_000) { fail(ERR.popDuplicate, `다음 인증은 직전 인증 ${intervalHours}시간 뒤부터 가능합니다.`); } // 사진은 한 번만 쓴다 — 같은 파일을 다시 내는 것은 같은 방문을 여러 번 인정하는 셈이다. QR 은 장소에 // 붙은 같은 코드를 모두가 찍으므로 1회성이 아니다(1인 횟수는 위 C-12 가 막는다). if (methods.has('photo')) { try { await db.doc(`placeProofs/photo_${photo.replaceAll('/', '_')}`).create({ uid, placeId, createdAt: FieldValue.serverTimestamp(), }); } catch { fail(ERR.popDuplicate, '이미 사용한 인증입니다.'); } } // 사용자 생성 Quest 는 생성자 Reward Pool 에서 Quest 의 보상액을 지급한다(C-02 · C-12 예치 한도). const author = questDoc.get('authorUid'); const ugc = typeof author === 'string' && author.length > 0; const policy = await loadPolicy(); const amount = ugc ? (questDoc.get('rewardPop') as number | undefined) ?? 0 : (place.get('rewardPop') as number | undefined) ?? policy.attendPop; const grant: GrantInput = { uid, popType: 'activity', amount, reasonCode: 'QUEST_PLACE', growthSource: 'quest_reward', // n 번째 유효 인증이 멱등 단위다. 동시 요청은 같은 키로 모여 한 번만 지급된다. idempotencyKey: `quest:${uid}:${questId}:${count + 1}`, refPath: logRef.path, alsoWrite: (tx, user) => { tx.set(progressSnap.ref, { questId, count: count + 1, lastVerifiedAt: Timestamp.fromDate(now), updatedAt: FieldValue.serverTimestamp(), }, { merge: true }); // 성지 · 생일 팝업 · 응원 현장 방문 = FPI **COMMUNITY** 1 단위(C-35). 장소 보상액과 무관(C-34). writeFpiEvent(tx, { uid, fandomId: fandomOfUser(user), axis: 'community', units: 1, activity: 'place', sourceId: `place:${questId}:${count + 1}`, occurredAt: now, refPath: logRef.path, }, user); // 검증 활동 — 방문 1회(같은 날 반복형은 하루 1회로 모인다). markCompletion(tx, uid, `${questId}_${dayKey(now)}`, { questId, txId: txIdOf(`quest:${uid}:${questId}:${count + 1}`), }); }, }; const result = ugc ? await payFromPool(questDoc.ref, amount, false, grant) : await grantPop(grant); return { txId: result.txId, balanceAfter: result.balanceAfter, rewarded: amount, distanceM: Math.round(gap), }; });