407 lines
17 KiB
TypeScript
407 lines
17 KiB
TypeScript
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),
|
||
};
|
||
});
|