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

407 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<number> {
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<FirebaseFirestore.DocumentSnapshot> {
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<PolicyConfig, 'adRewardPop' | 'adMultiplier'>,
): 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),
};
});