Cloud Functions 도메인 함수 — POP 원장 · 퀘스트 · 이클립스 · 행성 성장 · 캠페인 · 결제 · 알림 · 브리핑 · INSIDE · 추천 · 번역 · 서버 테스트

This commit is contained in:
Sn-Kinos
2026-09-29 17:06:48 +09:00
parent f483e8d94b
commit 316821e7d5
61 changed files with 14660 additions and 60 deletions
+313
View File
@@ -0,0 +1,313 @@
import { onCall } from 'firebase-functions/v2/https';
import { getFirestore, FieldValue, GeoPoint, Timestamp } from 'firebase-admin/firestore';
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 { loadPolicy } 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';
/**
* 검증이 필요한 퀘스트 둘 — 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 = 'S' | 'A' | 'B' | 'C' | 'F';
export function gradeShake(
judgement: ShakeJudgement,
totalSegments: number,
): ShakeGrade {
const hit = judgement.perfect + judgement.good;
if (totalSegments <= 0 || hit + judgement.miss !== totalSegments) return 'F';
const accuracy = judgement.perfect / totalSegments;
if (judgement.miss === 0 && accuracy >= 0.95) return 'S';
if (accuracy >= 0.8) return 'A';
if (accuracy >= 0.6) return 'B';
if (hit / totalSegments >= 0.5) return 'C';
return 'F';
}
/** 등급별 보상 배수. 기본 보상에 곱한다. */
const GRADE_MULTIPLIER: Readonly<Record<ShakeGrade, number>> = {
S: 2, A: 1.5, B: 1.2, C: 1, F: 0,
};
/** `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']);
const trackIds = Array.isArray(quest.get('trackIds'))
? quest.get('trackIds') as unknown[] : [quest.get('trackId')];
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, '곡의 구간 수가 퀘스트 정의와 다릅니다.');
}
const limit = await dailyLimitOf(questId);
const state = await progress(uid, questId, key);
if (state.count >= limit) {
fail(ERR.popDuplicate, '오늘 이 퀘스트의 보상은 이미 받았습니다.');
}
const grade = gradeShake(judgement, totalSegments);
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, 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') {
// 판정표가 스스로 모순되거나 완주하지 못한 경우다. 증거는 남기고 보상은 없다.
fail(ERR.questIncomplete, '완주하지 못했습니다.');
}
const policy = await loadPolicy();
const amount = Math.round(policy.stepsPop * GRADE_MULTIPLIER[grade]);
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);
// 검증 활동 — Music Shake 는 완주마다 1회.
markCompletion(tx, uid, `${questId}_${key}_${state.count + 1}`, {
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` 까지. 실패는 간격을 다시 시작하지 않는다(진행 문서는 지급과 함께만 쓴다).
*/
export const fnQuestVerifyPlace = onCall(async (req) => {
const uid = callerUid(req);
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).
const proofKind = optionalStr(req.data, 'proofKind') ?? '';
const proof = optionalStr(req.data, 'proof') ?? '';
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;
const proofGiven = (proofKind === 'photo' || proofKind === 'qr') && proof.length > 0;
// 판정 순서는 **조작 → 반경**이다. 모의 위치는 좌표 자체를 믿을 수 없다는 뜻이라
// 반경 판정보다 앞선다.
const reject = mocked
? ERR.questAbnormal
: !inRange
? ERR.questOutOfRange
: null;
await logRef.set({
type: proofKind === 'qr' ? 'placeQr' : proofKind === 'photo' ? 'placePhoto' : 'placeGps',
fandomId: optionalStr(req.data, 'fandomId') ?? '',
value: Math.round(gap),
evidence: {
place: {
placeId,
// 정확도와 모의 위치 여부를 함께 남긴다 — 설계서 §2.1 「위치 사진 경로·GPS
// 좌표·정확도」와 §8 「mock location 차단」의 판정 입력이다.
gps: { lat, lng, ...(typeof accuracyM === 'number' ? { accuracyM } : {}), mocked },
...(proofGiven ? { proof: { kind: proofKind, ref: proof } } : {}),
},
},
questId,
verified: reject === null,
rejectReason: reject,
occurredAt: now,
createdAt: FieldValue.serverTimestamp(),
});
if (mocked) {
// 별첨 1 ③ 예외 처리 「위치 스푸핑 — 서버검증 필터 차단」.
fail(ERR.questAbnormal, '모의 위치로 판정해 보상을 지급하지 않았습니다.');
}
if (!inRange) {
fail(ERR.questOutOfRange, '해당 장소에서 너무 멀리 떨어져 있습니다.');
}
// 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}시간 뒤부터 가능합니다.`);
}
// 추가 인증 수단이 왔으면 한 번만 쓴다. 재사용은 같은 방문을 여러 번 인정하는 셈이다.
if (proofGiven) {
try {
await db.doc(`placeProofs/${proofKind}_${proof}`).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),
};
});