Files
fanuzu/app/firebase/functions/src/quest/verify.ts
T

377 lines
19 KiB
TypeScript

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<string, unknown>;
// 곡과 하루 한도는 **퀘스트 문서**가 정한다. 예전 멱등키는 `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<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);
// 위치기반 서비스 약관 동의(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<string, unknown>)?.mocked === true;
const accuracyM = (req.data as Record<string, unknown>)?.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),
};
});