377 lines
19 KiB
TypeScript
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),
|
|
};
|
|
});
|