import { onCall } from 'firebase-functions/v2/https'; import { getFirestore, FieldValue, 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, type PolicyConfig } from '../core/config.js'; import { grantPop } from '../ledger/ledger.js'; import { fandomOfUser, markCompletion, writeFpiEvent } from '../ledger/fpi.js'; /** * 퀘스트 검증 — 별첨 1 ③ 「서버검증 필터」의 구현부. * * 공통 원칙: 클라이언트가 보낸 값은 **증거일 뿐 판정 결과가 아니다.** 보상은 * 서버 판정이 통과한 뒤에만 원장을 거쳐 나간다. */ /** 단말 시각을 읽고 조작 여부를 본다. `occurredAt` 은 ISO 8601 문자열로 받는다. */ function occurredAt(data: unknown, now: Date): Date { const raw = optionalStr(data, 'occurredAt'); if (!raw) return now; const at = new Date(raw); if (Number.isNaN(at.getTime())) { fail(ERR.questAbnormal, '활동 시각이 올바르지 않습니다.'); } if (!isPlausibleOccurredAt(at, now)) { // 별첨 1 ③ 예외 처리 「시간 조작 — 서버검증 필터 차단」. fail(ERR.questAbnormal, '활동 시각이 서버 시각과 크게 어긋납니다.'); } return at; } /** * 진행 문서의 ID 는 **퀘스트 문서 ID** 다 — 설계서 §2.1 `users/{uid}/questProgress/{questId}`. * * 예전에는 `attend`·`steps` 같은 고정 문자열에 썼는데, 앱은 `quests` 컬렉션의 문서 ID 로 * 읽는다. 두 이름이 어긋나면 **지급은 되는데 화면은 영원히 「시작하기」** 로 남고, 일일 * 한도도 퀘스트마다 따로 서지 않는다. 호출자가 주지 않으면 유형 이름으로 떨어뜨린다. */ function questIdOf(data: unknown, fallback: string): string { return optionalStr(data, 'questId') ?? fallback; } /** * 일일 한도 — **유일한 원본은 `quests/{questId}.dailyLimit`** 이다(설계서 §2.8). * * 문서가 없으면 1 로 본다. 한도를 모르는 채 무제한으로 열면 지급이 무한해진다. */ export async function dailyLimitOf(questId: string): Promise { const snap = await getFirestore().doc(`quests/${questId}`).get(); const limit = snap.get('dailyLimit') as number | undefined; return typeof limit === 'number' && limit > 0 ? limit : 1; } /** * 참여할 퀘스트 문서를 확인한다 — 리뷰 H2 · B2. * * **문서가 없거나 유형이 다르면 거부한다.** 예전에는 `questId` 가 진행 문서 ID 로만 쓰여 * 있지도 않은 ID 를 바꿔 가며 부르면 일일 한도 · 1회 제한이 매번 새로 열렸다. * 정책 원장 **C-16** — 현재 대표 팬덤의 Quest 와 일반 Quest(`fandomId` 없음)만 참여할 수 있다. */ export async function questOf( uid: string, questId: string, types: readonly string[], ): Promise { const db = getFirestore(); const [quest, user] = await Promise.all([ db.doc(`quests/${questId}`).get(), db.doc(`users/${uid}`).get(), ]); const type = String(quest.get('type') ?? '').toLowerCase(); if (!quest.exists || !types.includes(type)) { fail(ERR.questIncomplete, '참여할 수 있는 퀘스트가 아닙니다.'); } // 사용자 생성 Quest 는 공개된 동안만(`status`), 모든 Quest 는 **서버 시각**으로 마감 전까지만(G-04). const status = quest.get('status'); const endsAt = quest.get('endsAt'); if ((status !== undefined && status !== 'published') || (endsAt instanceof Timestamp && Date.now() >= endsAt.toMillis())) { fail(ERR.questIncomplete, '마감된 퀘스트입니다.'); } const questFandom = quest.get('fandomId'); if (typeof questFandom === 'string' && questFandom && questFandom !== fandomOfUser(user)) { fail(ERR.permissionDenied, '현재 대표 팬덤의 퀘스트만 참여할 수 있습니다.'); } return quest; } /** 당일 진행 문서. 일일 한도는 여기서만 판정한다. */ export async function progress(uid: string, questId: string, key: string) { const ref = getFirestore().doc(`users/${uid}/questProgress/${questId}`); const snap = await ref.get(); const sameDay = snap.get('dayKey') === key; return { ref, count: sameDay ? ((snap.get('count') as number | undefined) ?? 0) : 0, rewardedPop: sameDay ? ((snap.get('rewardedPop') as number | undefined) ?? 0) : 0, }; } /** * 진행 카운터를 **지급과 같은 트랜잭션에서** 쓴다. * * 지급 뒤에 따로 쓰면 그 사이 실패가 났을 때 「POP 은 나갔는데 한도는 0」 상태가 남고, * 한 번 더 부르면 같은 날 두 번 지급된다. */ export function progressWriter( ref: FirebaseFirestore.DocumentReference, questId: string, key: string, count: number, rewardedPop: number, ) { return (tx: FirebaseFirestore.Transaction) => { tx.set(ref, { questId, dayKey: key, count, rewardedPop, completedAt: FieldValue.serverTimestamp(), updatedAt: FieldValue.serverTimestamp(), }, { merge: true }); }; } /** `fn-quest-attend` — 출석. 한도는 `quests/{questId}.dailyLimit` 이 정한다. */ export const fnQuestAttend = onCall(async (req) => { const uid = callerUid(req); const now = new Date(); const key = dayKey(now); const questId = questIdOf(req.data, 'attend'); await questOf(uid, questId, ['attend', 'checkin']); const policy = await loadPolicy(); const limit = await dailyLimitOf(questId); const state = await progress(uid, questId, key); if (state.count >= limit) { fail(ERR.popDuplicate, '오늘 출석은 이미 완료했습니다.'); } const count = state.count + 1; const rewardedPop = state.rewardedPop + policy.attendPop; const writeProgress = progressWriter(state.ref, questId, key, count, rewardedPop); const result = await grantPop({ uid, popType: 'activity', amount: policy.attendPop, reasonCode: 'QUEST_ATTEND', growthSource: 'quest_reward', // 퀘스트마다 따로 세야 하므로 멱등키에도 퀘스트 ID 를 넣는다. idempotencyKey: `attend:${uid}:${questId}:${key}:${count}`, alsoWrite: (tx, user) => { writeProgress(tx); // 출석은 FPI **COMMUNITY** 축 1 단위다(C-35). 보상액과 무관하다(C-34). writeFpiEvent(tx, { uid, fandomId: fandomOfUser(user), axis: 'community', units: 1, activity: 'attend', sourceId: `attend:${questId}:${key}:${count}`, occurredAt: now, }, user); // 검증 활동 — 출석은 하루 1회(Campaign 개설자격 A안 집계 규칙). markCompletion(tx, uid, `${questId}_${key}`, { questId, txId: txIdOf(`attend:${uid}:${questId}:${key}:${count}`), }); }, }); return { txId: result.txId, balanceAfter: result.balanceAfter, rewarded: policy.attendPop, questId, remaining: limit - count, }; }); /** * `fn-quest-verify-steps` — 만보기. * * 걸음 수를 그대로 믿지 않는다. 완주 목표 미달이면 E-QST-01, 사람이 하루에 낼 수 * 없는 값이면 E-QST-02 로 끊고 24시간 쿨다운을 남긴다. */ export const fnQuestVerifySteps = onCall(async (req) => { const uid = callerUid(req); const now = new Date(); const at = occurredAt(req.data, now); const key = dayKey(at); // 퀘스트 문서가 있어야 하고 걸음 유형이어야 한다. 없는 ID 로 바꿔 부르면 진행 문서 · 멱등키가 // 새로 열려 구간 보상이 다시 나간다(리뷰 재검토 blocker). 대표 팬덤 자격(C-16)도 본다. const questId = str(req.data, 'questId'); await questOf(uid, questId, ['walk', 'steps', 'health']); const steps = positiveInt(num(req.data, 'steps'), 'steps'); const policy = await loadPolicy(); const db = getFirestore(); const logRef = db.collection(`users/${uid}/activityLogs`).doc(); const deviceHash = optionalStr(req.data, 'deviceRaw') ? deviceHashOf(str(req.data, 'deviceRaw')) : null; // 사람이 하루에 걷는 상한을 크게 잡아도 10만 보를 넘지 않는다. 넘으면 증거를 // 남기되 보상하지 않는다 — 판정 근거가 사라지면 어뷰징 분석이 불가능하다. const abnormal = steps > 100_000; // 정책 원장 **B-26** — 1 천/5 천/1 만/1.5 만보 구간. 걸음 수는 **그 날 누적**이므로 // 5 천보를 보고한 사용자는 1 천보 구간도 지난 것이다. 그래도 지급은 아랫 구간부터 // 차례로 한 건씩 남긴다 — 원장만 봐도 어느 구간이 얼마에 나갔는지 보여야 한다. const tiers = policy.stepTiers; const cleared = tiers.filter((tier) => steps >= tier.steps).length; const state = await progress(uid, questId, key); await logRef.set({ type: 'steps', fandomId: optionalStr(req.data, 'fandomId') ?? '', value: steps, evidence: { steps, clearedTier: cleared }, questId, verified: cleared > state.count && !abnormal, rejectReason: abnormal ? ERR.questAbnormal : cleared > state.count ? null : ERR.questIncomplete, deviceHash, occurredAt: at, createdAt: FieldValue.serverTimestamp(), updatedAt: FieldValue.serverTimestamp(), }); if (abnormal) fail(ERR.questAbnormal, '비정상적인 활동으로 판정했습니다.'); if (cleared === 0) { fail(ERR.questIncomplete, `아직 ${tiers[0]!.steps.toLocaleString()}보에 도달하지 않았습니다.`); } if (cleared <= state.count) { // 오늘 도달한 구간은 이미 전부 받았다. 같은 구간을 두 번 주지 않는다. fail(ERR.popDuplicate, '이 구간의 보상은 이미 받았습니다.'); } let rewarded = 0; let balanceAfter = 0; let txId = ''; for (let index = state.count; index < cleared; index += 1) { const tier = tiers[index]!; rewarded += tier.pop; const result = await grantPop({ uid, popType: 'activity', amount: tier.pop, reasonCode: 'QUEST_STEPS', growthSource: 'quest_reward', // 구간마다 원장 한 행. 같은 날 같은 구간은 한 번만 나간다. idempotencyKey: `quest:${uid}:${questId}:t${index + 1}:${key}`, refPath: logRef.path, alsoWrite: (tx, user) => { progressWriter(state.ref, questId, key, index + 1, state.rewardedPop + rewarded)(tx); // 걸음은 FPI **HEALTH** 축이다(C-35). 새로 도달한 구간 1개 = 1 단위 — // 구간 보상액(5/6/7/8 POP)은 축 값에 들어가지 않는다(C-34). writeFpiEvent(tx, { uid, fandomId: fandomOfUser(user), axis: 'health', units: 1, activity: 'steps', sourceId: `steps:${questId}:${key}:t${index + 1}`, occurredAt: at, refPath: logRef.path, }, user); // 검증 활동 — 걸음은 구간 수와 무관하게 하루 1회. 같은 날 구간은 같은 표식으로 모인다. markCompletion(tx, uid, `${questId}_${key}`, { questId, txId: txIdOf(`quest:${uid}:${questId}:t${index + 1}:${key}`), }); }, }); balanceAfter = result.balanceAfter; txId = result.txId; } return { txId, balanceAfter, rewarded, questId, clearedTier: cleared, // 다음 구간 문턱. 다 받았으면 0 이다. nextTierSteps: cleared < tiers.length ? tiers[cleared]!.steps : 0, }; }); /** * 광고 증분 — 원장 B-03 · C-02(B-28). * * - 사용자 생성 Quest(`authorUid` 있음): 고정 [PolicyConfig.adRewardPop] 기여 POP. * - 운영 Quest: 기본 보상 × (배수 − 1) 기여 POP. 배수는 Quest 지정값(`adMultiplier`, * 예: 3) 또는 운영 기본값(x2). * * 기본 보상은 **원장 행에서** 읽는다 — 앱이 보낸 금액을 쓰지 않는다. */ export function adIncrement( quest: { authorUid?: unknown; adMultiplier?: unknown }, basePop: number, policy: Pick, ): number { if (typeof quest.authorUid === 'string' && quest.authorUid.length > 0) { return policy.adRewardPop; } const multiplier = typeof quest.adMultiplier === 'number' && quest.adMultiplier >= 1 ? quest.adMultiplier : policy.adMultiplier; return Math.floor(basePop * (multiplier - 1)); } /** 기본 보상 원장 행의 멱등키에서 퀘스트 ID 를 꺼낸다(`quest:` · `attend:` 접두 공통 3번째 칸). */ function questIdOfKey(key: unknown): string | null { if (typeof key !== 'string') return null; const [prefix, , questId] = key.split(':'); return (prefix === 'quest' || prefix === 'attend') && questId ? questId : null; } /** * `fn-ad-reward` — 보상형 광고 증분. * * 앱은 방금 받은 **기본 보상의 원장 txId**(`baseTxId`)와 노출 ID 를 보낸다. 서버는 그 * 원장 행이 본인 · 오늘 · 퀘스트 보상인지 확인하고 증분을 기여 POP 으로 지급한다. * 기본 보상 한 건에 광고 보상은 한 번이다(`users/{uid}/adBonuses/{baseTxId}`). * * 일일 한도를 넘으면 E-AD-02. 같은 노출 ID 의 재전송은 첫 지급 결과를 그대로 돌려준다(`duplicate`). */ export const fnAdReward = onCall(async (req) => { const uid = callerUid(req); const impressionId = str(req.data, 'adImpressionId'); const network = optionalStr(req.data, 'network') ?? 'unknown'; const now = new Date(); const key = dayKey(now); const policy = await loadPolicy(); const db = getFirestore(); const logRef = getFirestore().doc(`adRewardLogs/${impressionId}`); // 성공한 호출의 재전송(응답 유실)은 오류가 아니라 **첫 지급 결과**로 돌려준다. const prior = await logRef.get(); if (prior.exists) { if (prior.get('uid') !== uid || typeof prior.get('txId') !== 'string') { fail(ERR.popDuplicate, '이미 보상받은 광고입니다.'); } const tx = await getFirestore().doc(`users/${uid}/popTransactions/${prior.get('txId')}`).get(); return { txId: prior.get('txId') as string, balanceAfter: (tx.get('balanceAfter') as number | undefined) ?? 0, rewarded: (tx.get('amount') as number | undefined) ?? 0, remaining: Math.max(0, policy.adDailyLimit - (await progress(uid, 'ad', key)).count), duplicate: true, }; } // 멱등 단위는 **그날 n 번째 광고 보상**이다. 예전에는 노출 ID(클라이언트 값)를 키로 써서 // 서로 다른 노출 ID 로 동시에 부르면 모두 한도 판정을 통과했다(리뷰 재검토 · ID 전수 점검). // 같은 n 은 같은 키로 모여 한 번만 지급되고, 노출 로그는 지급과 같은 트랜잭션에서 `create` // 해 같은 노출을 두 번 쓰지 못한다. const baseTxId = str(req.data, 'baseTxId'); const base = await db.doc(`users/${uid}/popTransactions/${baseTxId}`).get(); const questId = questIdOfKey(base.get('idempotencyKey')); const baseAt = base.get('createdAt'); if (!base.exists || base.get('type') !== 'grant' || questId === null || !String(base.get('reasonCode') ?? '').startsWith('QUEST_') || !(baseAt instanceof Timestamp) || dayKey(baseAt.toDate()) !== key) { fail(ERR.questIncomplete, '광고 추가 보상은 오늘 받은 퀘스트 보상에만 붙습니다.'); } const bonusRef = db.doc(`users/${uid}/adBonuses/${baseTxId}`); if ((await bonusRef.get()).exists) { fail(ERR.popDuplicate, '이 퀘스트 보상의 광고 추가 보상은 이미 받았습니다.'); } const quest = (await db.doc(`quests/${questId}`).get()).data() ?? {}; const amount = adIncrement(quest, (base.get('amount') as number | undefined) ?? 0, policy); if (!Number.isSafeInteger(amount) || amount <= 0) { fail(ERR.questIncomplete, '광고 추가 보상 대상이 아닌 퀘스트입니다.'); } const state = await progress(uid, 'ad', key); if (state.count >= policy.adDailyLimit) { fail(ERR.adLimitExceeded, '오늘 광고 보상 한도를 모두 사용했습니다.'); } const idempotencyKey = `ad:${uid}:${key}:${state.count + 1}`; let result; try { result = await grantPop({ uid, // B-03 「광고로 늘어난 증분만 기여 POP」 · B-28 「FANUZU 재원」. popType: 'support', amount, reasonCode: 'AD_REWARD', growthSource: 'company_ad_bonus', idempotencyKey, refPath: logRef.path, // 한도 카운터도 지급과 같은 트랜잭션이다. 따로 쓰면 「POP 은 나갔는데 한도는 // 그대로」인 창이 열려 광고 보상이 한도를 넘어 나간다. alsoWrite: (tx) => { progressWriter( state.ref, 'ad', key, state.count + 1, state.rewardedPop + amount, )(tx); tx.create(logRef, { uid, network, dayKey: key, txId: txIdOf(idempotencyKey), baseTxId, questId, createdAt: FieldValue.serverTimestamp(), }); tx.create(bonusRef, { impressionId, amount, createdAt: FieldValue.serverTimestamp() }); }, }); } catch (e) { const code = (e as { code?: number | string }).code; if (code === 6 || code === 'already-exists') fail(ERR.popDuplicate, '이미 보상받은 광고입니다.'); throw e; } return { txId: result.txId, balanceAfter: result.balanceAfter, // 앱이 보상 화면에 띄우는 값. 운영값이 바뀌어도 화면이 원장과 같은 수를 그린다. rewarded: amount, remaining: policy.adDailyLimit - (state.count + 1), }; });