Cloud Functions 도메인 함수 — POP 원장 · 퀘스트 · 이클립스 · 행성 성장 · 캠페인 · 결제 · 알림 · 브리핑 · INSIDE · 추천 · 번역 · 서버 테스트
This commit is contained in:
@@ -0,0 +1,406 @@
|
||||
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),
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user