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
+315 -2
View File
@@ -1,9 +1,10 @@
import * as functionsV1 from 'firebase-functions/v1';
import { onCall } from 'firebase-functions/v2/https';
import { getFirestore, FieldValue } from 'firebase-admin/firestore';
import { HttpsError, onCall } from 'firebase-functions/v2/https';
import { getFirestore, FieldValue, Timestamp, type Transaction } from 'firebase-admin/firestore';
import { getAuth } from 'firebase-admin/auth';
import { callerUid, str } from '../core/callable.js';
import { dayKey } from '../core/time.js';
import { ERR, fail } from '../core/errors.js';
/**
@@ -48,6 +49,318 @@ export const fnAuthOnCreate = functionsV1.auth.user().onCreate(async (user) => {
await batch.commit();
});
/** 행성 생성 화면이 고르는 값 — 앱 `PlanetCreateScreen.shapes` · `.colors` 와 같은 키(C-50). */
export const PLANET_SHAPES = ['shape1', 'shape2', 'shape3', 'shape4', 'shape5', 'shape6'];
export const PLANET_COLORS = ['white', 'black', 'blue', 'green', 'gold', 'orange', 'red', 'purple'];
/**
* 운영값 기본 — 🟡 `config/policy` 가 덮는다.
* - 성운 전환: 원장 B-10 「10명 이상 + 1,000 POP 이상 + 기본 목표 7일」.
* `nebulaConversionDeadlineEnforced` — 7일을 넘긴 성운의 전환을 막을지. B-10 「미달성 시 유지」와
* 「7일 이내 판정」이 갈려 발주사 확인 중이며, 회신 전에는 막지 않는다(false).
* - 대표 팬덤: 원장 C-15 「변경 무료 · 재변경까지 초기 30일 대기 · 운영자 조정 가능」.
* - `nebulaFounderLeaveBlocked` — 성운 창립자는 행성 탄생 전 탈퇴 불가(해석 확인 요청 Q-PN-03
* 권고 A안 · true). false 면 막지 않는다.
* - `primaryOnLeave` — 대표 팬덤에서 탈퇴할 때 남은 가입 팬덤 가운데 무엇을 대표로 둘지
* (Q-PN-04). `latest`(권고 B안 · 가장 최근 가입) · `earliest`(C안) · `none`(A안 · 비워 둠).
* 자동 지정은 C-15 대기에 세지 않는다.
*/
const FANDOM_DEFAULTS = {
nebulaMinMembers: 10,
nebulaMinPop: 1_000,
nebulaDays: 7,
nebulaConversionDeadlineEnforced: false,
primaryCooldownDays: 30,
nebulaFounderLeaveBlocked: true,
primaryOnLeave: 'latest' as 'latest' | 'earliest' | 'none',
};
async function fandomPolicy(): Promise<typeof FANDOM_DEFAULTS> {
const raw = (await getFirestore().doc('config/policy').get()).data() ?? {};
return { ...FANDOM_DEFAULTS, ...raw } as typeof FANDOM_DEFAULTS;
}
/** 기능 명세 §2.4 성운명 — 2~20자 · 한글/영문/숫자/공백 · 연속 공백 · 앞뒤 공백 불가. */
export function isValidNebulaName(name: string): boolean {
return /^[가-힣A-Za-z0-9 ]{2,20}$/.test(name) && name === name.trim() && !name.includes(' ');
}
type Data = Record<string, unknown> | undefined;
/** 현재 대표 팬덤. `primaryFandomId` 가 없던 계정은 옛 `fandomId` 를 쓴다(설계서 §2.1 이관 3). */
function primaryOf(user: Data): string | null {
const id = (user?.primaryFandomId ?? user?.fandomId) as string | null | undefined;
return id ? id : null;
}
/**
* 대표 팬덤을 [fandomId] 로 바꾸는 필드 — 원장 C-15.
*
* 대표가 없던 계정의 첫 지정은 「변경」이 아니므로 대기를 걸지 않고 시각도 찍지 않는다.
* 이미 대표가 있으면 `primaryChangedAt + 대기일` 이 지나야 바뀐다. `patch` 가 null 이면
* 바꾸지 않는다(같은 팬덤이거나 대기 중 — 대기 중이면 `blocked`). 옛 독자를 위해
* `fandomId` 도 같은 값으로 쓴다.
*/
function primaryPatch(user: Data, fandomId: string, cooldownDays: number, now: number):
{ patch: Record<string, unknown> | null; blocked: boolean } {
const current = primaryOf(user);
if (current === fandomId) return { patch: null, blocked: false };
const base = { primaryFandomId: fandomId, fandomId, updatedAt: FieldValue.serverTimestamp() };
if (!current) return { patch: base, blocked: false };
const last = user?.primaryChangedAt;
if (last instanceof Timestamp && now < last.toMillis() + cooldownDays * 86_400_000) {
return { patch: null, blocked: true };
}
return { patch: { ...base, primaryChangedAt: FieldValue.serverTimestamp() }, blocked: false };
}
/**
* 멤버십 한 건을 트랜잭션 안에서 쓴다. 호출 측이 「아직 멤버가 아님」을 확인한 뒤에만 부른다.
*
* - `fandoms/{id}/members/{uid}` — 가입 사실 · 가입 순번(`memberNo`, 원장 B-68 #001~ 기록).
* 순번은 탈퇴로 줄지 않는 `memberSeq` 에서 뽑아 재사용하지 않는다.
* - `users/{uid}/memberships/{id}` — 「가입 또는 생성한 행성/성운」 목록(명세 §2.58)의 원천.
* - `memberCount` 는 같은 트랜잭션에서 올린다 — 전환 조건(B-10)이 이 값을 읽는다.
*/
function writeJoin(tx: Transaction, uid: string, fandomId: string, fandom: Data): void {
const db = getFirestore();
const memberNo = Number(fandom?.memberSeq ?? fandom?.memberCount ?? 0) + 1;
tx.set(db.doc(`fandoms/${fandomId}/members/${uid}`), {
uid,
fandomId,
memberNo,
contributedPop: 0,
joinedAt: FieldValue.serverTimestamp(),
updatedAt: FieldValue.serverTimestamp(),
});
tx.set(db.doc(`users/${uid}/memberships/${fandomId}`), {
fandomId,
joinedAt: FieldValue.serverTimestamp(),
});
// LIVE 집계(Q-GM-03)가 접속자 문서 한 건으로 가입 팬덤을 읽는 비정규화 — 서버만 쓴다(규칙이 막음).
tx.set(db.doc(`users/${uid}`), { fandomIds: FieldValue.arrayUnion(fandomId) }, { merge: true });
tx.set(db.doc(`fandoms/${fandomId}`), {
memberCount: FieldValue.increment(1),
memberSeq: memberNo,
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
}
/**
* `fn-nebula-create` — 성운(Stage0) 생성. 원장 B-08 · B-09 · B-10 · C-15.
*
* 팬덤 문서 자체가 공용 성운/행성이다(팬덤 1 : 행성 1). 만든 사람은 창립 멤버 #001 이다.
* 확인 다이얼로그 「선택한 행성을 기본 행성으로 변경할까요?」대로 대표 팬덤을 바꾸되, C-15
* 30일 대기 중이면 성운과 멤버십만 만들고 대표는 그대로 둔다(`primaryChanged: false`).
* 이름 중복은 `fandomNames/{소문자 이름}` 문서 ID 로, 일 1회 제한은
* `users/{uid}/nebulaCreates/{날짜}` 문서 ID 로 막는다 — 동시 요청에도 원자적이다.
* 기존 팬덤의 이름 문서는 `tools/demo/migrate_v040.mjs fandom-names` 가 채운다.
*/
export const fnNebulaCreate = onCall(async (req) => {
const uid = callerUid(req);
const name = str(req.data, 'name');
if (!isValidNebulaName(name)) {
throw new HttpsError('invalid-argument', '성운 이름은 2~20자의 한글·영문·숫자로 입력해 주세요.');
}
const db = getFirestore();
const policy = await fandomPolicy();
const fandom = db.collection('fandoms').doc();
const now = Date.now();
const primaryChanged = await db.runTransaction(async (tx) => {
const todayRef = db.doc(`users/${uid}/nebulaCreates/${dayKey(new Date(now))}`);
const nameRef = db.doc(`fandomNames/${name.toLowerCase()}`);
const userRef = db.doc(`users/${uid}`);
const [todaySnap, nameSnap, userSnap] = await Promise.all([
tx.get(todayRef), tx.get(nameRef), tx.get(userRef),
]);
if (todaySnap.exists) fail(ERR.nebulaDailyLimit, '이미 오늘 성운을 생성하셨습니다.');
if (nameSnap.exists) fail(ERR.fandomNameTaken, '이미 사용중인 이름입니다.');
const { patch } = primaryPatch(userSnap.data(), fandom.id, policy.primaryCooldownDays, now);
tx.create(todayRef, { fandomId: fandom.id, createdAt: FieldValue.serverTimestamp() });
tx.create(nameRef, { fandomId: fandom.id, createdAt: FieldValue.serverTimestamp() });
tx.create(fandom, {
name,
stage: 0,
founderUid: uid,
memberCount: 0,
growthPop: 0,
fpiScore: 0,
nebulaDeadline: Timestamp.fromMillis(now + Number(policy.nebulaDays) * 86_400_000),
createdAt: FieldValue.serverTimestamp(),
updatedAt: FieldValue.serverTimestamp(),
});
writeJoin(tx, uid, fandom.id, undefined);
if (patch) tx.set(userRef, patch, { merge: true });
return patch !== null;
});
return { fandomId: fandom.id, primaryChanged };
});
/**
* `fn-fandom-join` — 행성/성운 가입. 원장 C-15 「복수 팬덤 가입」.
*
* 이미 멤버면 아무것도 쓰지 않는다(두 번 눌러도 `memberCount` 는 한 번만 오른다).
* 대표 팬덤이 없던 계정은 처음 가입한 팬덤이 대표가 된다 — 그 외에는 대표를 바꾸지 않는다.
*/
export const fnFandomJoin = onCall(async (req) => {
const uid = callerUid(req);
const fandomId = str(req.data, 'fandomId');
const db = getFirestore();
return db.runTransaction(async (tx) => {
const [fandomSnap, memberSnap, userSnap] = await Promise.all([
tx.get(db.doc(`fandoms/${fandomId}`)),
tx.get(db.doc(`fandoms/${fandomId}/members/${uid}`)),
tx.get(db.doc(`users/${uid}`)),
]);
if (!fandomSnap.exists) fail(ERR.permissionDenied, '행성을 찾을 수 없습니다.');
if (memberSnap.exists) return { fandomId, joined: false };
writeJoin(tx, uid, fandomId, fandomSnap.data());
if (!primaryOf(userSnap.data())) {
tx.set(userSnap.ref, {
primaryFandomId: fandomId, fandomId, updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
}
return { fandomId, joined: true };
});
});
/**
* `fn-fandom-leave` — 탈퇴. 멤버가 아니면 아무것도 하지 않는다(두 번 눌러도 한 번만 내린다).
*
* 과거 기여 · Passport 기록(원장 · growthEvents · popTransactions)은 지우지 않는다(C-15).
*
* - 성운(Stage0) 창립자는 탄생 전 떠날 수 없다(E-FDM-05 · Q-PN-03) — 탄생 권한자가 사라지면
* 조건을 채워도 행성이 될 수 없다.
* - 대표 팬덤을 떠나면 `primaryOnLeave` 대로 남은 팬덤 하나를 대표로 둔다(Q-PN-04). 자동 지정이라
* `primaryChangedAt` 을 찍지 않는다 — 사용자의 다음 직접 변경을 30일 대기로 막지 않는다.
*/
export const fnFandomLeave = onCall(async (req) => {
const uid = callerUid(req);
const fandomId = str(req.data, 'fandomId');
const db = getFirestore();
const policy = await fandomPolicy();
return db.runTransaction(async (tx) => {
const memberRef = db.doc(`fandoms/${fandomId}/members/${uid}`);
const [memberSnap, userSnap, fandomSnap] = await Promise.all([
tx.get(memberRef), tx.get(db.doc(`users/${uid}`)), tx.get(db.doc(`fandoms/${fandomId}`)),
]);
if (!memberSnap.exists) return { fandomId, left: false };
if (policy.nebulaFounderLeaveBlocked === true &&
fandomSnap.get('stage') === 0 && fandomSnap.get('founderUid') === uid) {
fail(ERR.nebulaFounderLeave, '행성이 탄생한 뒤에 탈퇴할 수 있어요.');
}
const wasPrimary = primaryOf(userSnap.data()) === fandomId;
let nextPrimary: string | null = null;
if (wasPrimary && policy.primaryOnLeave !== 'none') {
// 떠나는 팬덤을 빼고 하나가 남도록 두 건만 읽는다.
const rest = await tx.get(db.collection(`users/${uid}/memberships`)
.orderBy('joinedAt', policy.primaryOnLeave === 'earliest' ? 'asc' : 'desc').limit(2));
nextPrimary = rest.docs.map((d) => d.id).find((id) => id !== fandomId) ?? null;
}
tx.delete(memberRef);
tx.delete(db.doc(`users/${uid}/memberships/${fandomId}`));
tx.set(userSnap.ref, { fandomIds: FieldValue.arrayRemove(fandomId) }, { merge: true });
tx.update(db.doc(`fandoms/${fandomId}`), {
memberCount: FieldValue.increment(-1),
updatedAt: FieldValue.serverTimestamp(),
});
if (wasPrimary) {
tx.set(userSnap.ref, {
primaryFandomId: nextPrimary, fandomId: nextPrimary ?? '', updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
}
return { fandomId, left: true, primaryFandomId: wasPrimary ? nextPrimary : primaryOf(userSnap.data()) };
});
});
/**
* `fn-primary-fandom-set` — 대표 팬덤 변경. 원장 C-15 · 명세 §2.56 「기본 행성 변경」.
*
* 가입한 팬덤만 대표가 될 수 있다. 변경은 무료이고, 재변경은 `primaryChangedAt + 30일`
* (운영값 `primaryCooldownDays`) 이 지나야 한다. 과거 기여 · Passport 는 건드리지 않는다.
*/
export const fnPrimaryFandomSet = onCall(async (req) => {
const uid = callerUid(req);
const fandomId = str(req.data, 'fandomId');
const db = getFirestore();
const policy = await fandomPolicy();
const now = Date.now();
return db.runTransaction(async (tx) => {
const [memberSnap, userSnap] = await Promise.all([
tx.get(db.doc(`fandoms/${fandomId}/members/${uid}`)), tx.get(db.doc(`users/${uid}`)),
]);
if (!memberSnap.exists) fail(ERR.permissionDenied, '가입한 행성만 기본 행성으로 정할 수 있습니다.');
const { patch, blocked } = primaryPatch(userSnap.data(), fandomId, policy.primaryCooldownDays, now);
if (blocked) {
fail(ERR.primaryCooldown, `기본 행성은 변경 후 ${policy.primaryCooldownDays}일이 지나야 다시 바꿀 수 있습니다.`);
}
if (patch) tx.set(userSnap.ref, patch, { merge: true });
return { fandomId, changed: patch !== null };
});
});
/**
* `fn-planet-create` — 성운(Stage0) → 정식 행성(Stage1) 탄생. 원장 B-09 · B-10 · C-50.
*
* 형태(`planetType`)와 컬러(`colorKey`) 두 가지만 이때 한 번 고른다. 행성 문서 ID 는
* 팬덤 안에서 고정(`planet`)이라 팬덤당 1건이 문서 ID 충돌로 원자적으로 지켜진다
* (Firestore 설계서 §2.4 정정). 렌더링이 팬덤 문서 하나로 끝나도록 두 값을 팬덤 문서에도
* 싣는다. 생성 후 두 값을 바꾸는 경로는 없다(보안 규칙이 클라이언트 쓰기를 전부 닫음).
*
* 전환 조건(인원 · POP)은 **트랜잭션 안에서 읽은** 팬덤 문서로 판정한다 — 가입 · 탈퇴와
* 경합해도 낡은 `memberCount` 로 통과하지 않는다. 7일 목표는 `nebulaConversionDeadlineEnforced`
* 가 켜졌을 때만 거부 사유가 된다.
*/
export const fnPlanetCreate = onCall(async (req) => {
const uid = callerUid(req);
const fandomId = str(req.data, 'fandomId');
const planetType = str(req.data, 'planetType');
const colorKey = str(req.data, 'colorKey');
if (!PLANET_SHAPES.includes(planetType) || !PLANET_COLORS.includes(colorKey)) {
throw new HttpsError('invalid-argument', '행성 형태 또는 색이 올바르지 않습니다.');
}
const db = getFirestore();
const policy = await fandomPolicy();
const fandomRef = db.doc(`fandoms/${fandomId}`);
await db.runTransaction(async (tx) => {
const [fandomSnap, planetSnap] = await Promise.all([
tx.get(fandomRef), tx.get(db.doc(`fandoms/${fandomId}/planets/planet`)),
]);
const fandom = fandomSnap.data();
if (!fandom) fail(ERR.permissionDenied, '성운을 찾을 수 없습니다.');
if (fandom.founderUid !== uid) fail(ERR.permissionDenied, '성운을 만든 멤버만 행성을 생성할 수 있습니다.');
if (planetSnap.exists || fandom.stage !== 0) fail(ERR.popDuplicate, '이미 행성이 생성되었습니다.');
if (Number(fandom.memberCount ?? 0) < Number(policy.nebulaMinMembers) ||
Number(fandom.growthPop ?? 0) < Number(policy.nebulaMinPop)) {
fail(ERR.nebulaNotReady, '성운 전환 조건을 아직 채우지 못했습니다.');
}
if (policy.nebulaConversionDeadlineEnforced === true &&
fandom.nebulaDeadline instanceof Timestamp && Date.now() > fandom.nebulaDeadline.toMillis()) {
fail(ERR.nebulaNotReady, '성운 목표 기간이 지났습니다.');
}
tx.create(planetSnap.ref, {
ownerUid: uid,
fandomId,
planetType,
colorKey,
createdAt: FieldValue.serverTimestamp(),
});
tx.update(fandomRef, {
stage: 1,
planetType,
colorKey,
updatedAt: FieldValue.serverTimestamp(),
});
});
return { fandomId };
});
/**
* `fn-consent-record` — 약관 동의 이력.
*
@@ -0,0 +1,337 @@
import { randomInt } from 'node:crypto';
import { onCall } from 'firebase-functions/v2/https';
import { onDocumentCreated, onDocumentUpdated } from 'firebase-functions/v2/firestore';
import { onSchedule } from 'firebase-functions/v2/scheduler';
import { logger } from 'firebase-functions';
import { getFirestore, FieldValue, Timestamp } from 'firebase-admin/firestore';
import { callerUid, optionalStr, str } from '../core/callable.js';
import { loadPolicy } from '../core/config.js';
import { ERR, fail } from '../core/errors.js';
import { deviceHashOf } from '../core/ids.js';
import { dayKey } from '../core/time.js';
import { grantPop, settlePendingGrant, spendPop } from '../ledger/ledger.js';
import { fandomOfUser, writeFpiEvent } from '../ledger/fpi.js';
/**
* 추천 귀속. 번역 보상은 `translation.ts`.
*
* **자기 자신을 통한 이득을 막는 것**이 핵심이다 — 자기 추천을 막지 않으면 한 사람이
* 계정 하나로 POP 을 만들어 낸다.
*/
/**
* 초대 코드 문자 — 대문자 + 숫자에서 혼동 문자(`O` · `0` · `I` · `1`)를 뺀 32자. 10자면 약 2^50
* 조합이다(Firestore 설계서 §`/inviteCodes`). `inviteCodes` 는 코드 확인을 위해
* 미로그인 직접 조회가 열려 있어, 무차별 대입 방어선이 이 엔트로피 하나뿐이다.
*/
export const INVITE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
export const INVITE_LENGTH = 10;
/** 초대 코드 형식 — 읽기 전에 거른다(형식이 틀린 문자열로 문서를 조회하지 않는다). */
export const INVITE_PATTERN = /^[A-HJ-NP-Z2-9]{10}$/;
export function newInviteCode(): string {
let code = '';
for (let i = 0; i < INVITE_LENGTH; i++) code += INVITE_ALPHABET[randomInt(INVITE_ALPHABET.length)];
return code;
}
/**
* 내 초대 코드를 돌려준다. 없으면 만든다 — 사용자 1명당 코드 1개(`users/{uid}.inviteCode`).
* 유일성은 `inviteCodes/{code}` 를 `create` 해 보장하고, 충돌하면 새 코드로 다시 시도한다.
* `inviteCodes/{code}` 는 미로그인 직접 조회가 열려 있어 `{active}` 만 둔다. 주인은 서버 전용
* `inviteCodeOwners/{code}` 에 둔다 — 코드로 사용자 UID 를 알아낼 수 없게. 클라이언트는 모두 쓸 수 없다(규칙).
*/
export async function inviteCodeOf(uid: string): Promise<string> {
const db = getFirestore();
const userRef = db.doc(`users/${uid}`);
for (let attempt = 0; attempt < 5; attempt++) {
const code = newInviteCode();
const codeRef = db.doc(`inviteCodes/${code}`);
const result = await db.runTransaction(async (tx) => {
const [user, taken] = await Promise.all([tx.get(userRef), tx.get(codeRef)]);
const existing = user.get('inviteCode');
if (typeof existing === 'string' && existing) return existing;
if (taken.exists) return null;
tx.create(codeRef, { active: true });
tx.create(db.doc(`inviteCodeOwners/${code}`), {
uid, type: 'personal', createdAt: FieldValue.serverTimestamp(),
});
tx.set(userRef, { inviteCode: code }, { merge: true });
return code;
});
if (result) return result;
}
throw new Error('초대 코드를 만들지 못했습니다.');
}
/** `fn-invite-code-get` — 내 초대 코드(없으면 만들어서). 공유 링크 `?ref=` 에 붙는다. */
export const fnInviteCodeGet = onCall(async (req) => ({ code: await inviteCodeOf(callerUid(req)) }));
/** 추천 보상 보류 시간 — 원장 **B-71** 「기록 → 보상 pending/24h」. 지난 뒤 정산 작업이 확정한다. */
export const REFERRAL_HOLD_MS = 24 * 60 * 60 * 1000;
/**
* 잠금 문서 `referralsByUser/{uid}.reward` — 피추천인 1명의 보상 상태.
* - `pending`: 귀속됨 · 귀속 뒤 첫 검증 활동 대기
* - `ineligible`: 귀속 전에 이미 검증 활동이 있었다(「최초」가 아님) — 지급 없음
* - `capped`: 추천인의 하루 상한(`referralDailyCapPerReferrer`) 초과 — 지급 없음
* - `held`: 보류 지급(pending POP) 중 · `settleAfter` 뒤 확정
* - `paid`: 확정 · FPI REFERRAL 1 단위
* - `released`: 확정 전에 완료가 무효 · 이상 표시(`flagged`)로 보류 해제
* - `clawedback` / `clawback-pending`: 확정 뒤 무효 — 회수됨 / 잔액 부족으로 운영 확인 대기
*/
export type ReferralReward =
'pending' | 'ineligible' | 'capped' | 'held' | 'paid' | 'released' | 'clawedback' | 'clawback-pending';
/**
* `fn-referral-attribute` — 추천 귀속.
*
* 명세 §8.5 — *자기 추천·중복 전환 차단*. 가입자 1명당 귀속 1번(`referralsByUser/{uid}` 잠금).
* 가입 후 `referralAttachDays`(기본 7일) 안에만, 서로 추천(A→B · B→A)은 막는다.
* 보상은 여기서 나가지 않는다 — 귀속 **뒤** 첫 검증 활동 때 보류 지급([payReferralReward]).
*/
export const fnReferralAttribute = onCall(async (req) => {
const uid = callerUid(req);
const code = str(req.data, 'referralCode');
if (!INVITE_PATTERN.test(code)) fail(ERR.popVerifyFailed, '추천 코드를 확인해 주세요.');
const channel = optionalStr(req.data, 'channel') ?? 'unknown';
const deviceRaw = optionalStr(req.data, 'deviceRaw');
const db = getFirestore();
const [pub, owner] = await Promise.all([
db.doc(`inviteCodes/${code}`).get(), db.doc(`inviteCodeOwners/${code}`).get(),
]);
const referrerUid = owner.get('uid') as string | undefined;
if (!pub.exists || pub.get('active') !== true || !referrerUid) {
fail(ERR.popVerifyFailed, '추천 코드를 확인해 주세요.');
}
if (referrerUid === uid) {
// 자기 추천. 계정 하나로 POP 을 만들어 내는 가장 쉬운 길이다.
fail(ERR.questAbnormal, '본인 추천 코드는 쓸 수 없습니다.');
}
const { referralAttachDays, referralDailyCapPerReferrer } = await loadPolicy();
const attributionRef = db.doc(`referrals/${uid}_${code}`);
const lockRef = db.doc(`referralsByUser/${uid}`);
const capRef = db.doc(`referralDaily/${referrerUid}_${dayKey(new Date())}`);
let reward: ReferralReward = 'pending';
try {
await db.runTransaction(async (tx) => {
// 동시 호출의 둘째는 잠금 문서 `create` 충돌(ALREADY_EXISTS)로 떨어진다.
const [existing, lock, user, reverse, cap, done] = await Promise.all([
tx.get(attributionRef), tx.get(lockRef), tx.get(db.doc(`users/${uid}`)),
tx.get(db.doc(`referralsByUser/${referrerUid}`)), tx.get(capRef),
// 「최초 검증 활동」은 귀속 뒤의 첫 완료다. 같은 트랜잭션에서 읽어 경합에도 판정이 같다.
tx.get(db.collection(`users/${uid}/verifiedCompletions`).limit(1)),
]);
if (existing.exists || lock.exists) fail(ERR.popDuplicate, '이미 처리된 추천입니다.');
if (reverse.get('referrerUid') === uid) fail(ERR.questAbnormal, '서로 추천할 수 없습니다.');
const joined = user.get('createdAt');
if (!(joined instanceof Timestamp) ||
Date.now() - joined.toMillis() > referralAttachDays * 86_400_000) {
fail(ERR.popVerifyFailed, `가입 후 ${referralAttachDays}일 안에만 추천 코드를 등록할 수 있습니다.`);
}
const today = (cap.get('count') as number | undefined) ?? 0;
reward = !done.empty ? 'ineligible' : today >= referralDailyCapPerReferrer ? 'capped' : 'pending';
if (reward === 'pending') {
tx.set(capRef, { count: today + 1, updatedAt: FieldValue.serverTimestamp() }, { merge: true });
}
tx.create(lockRef, {
attributionId: attributionRef.id, referrerUid, reward, createdAt: FieldValue.serverTimestamp(),
});
tx.create(attributionRef, {
referredUid: uid,
referrerUid,
code,
channel,
reward,
// 다계정 신호(B-71 Risk Engine 입력). 원값은 저장하지 않는다.
...(deviceRaw ? { deviceHash: deviceHashOf(deviceRaw) } : {}),
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 { attributionId: attributionRef.id, referrerUid, reward };
});
const grantKey = (uid: string, role: 'referrer' | 'referred') => `referral:${uid}:${role}`;
/** 귀속 뒤에 생긴, 무효가 아닌 검증 활동 하나. 없으면 null. */
async function liveCompletionAfter(uid: string, since: Timestamp): Promise<string | null> {
const snap = await getFirestore().collection(`users/${uid}/verifiedCompletions`).get();
const live = snap.docs.find((d) => d.get('voided') !== true && d.createTime.toMillis() >= since.toMillis());
return live?.id ?? null;
}
/**
* 추천 보상 — 정책 원장 **B-69** 「최초 검증 활동 완료 후 추천인 일반 POP 100, 피추천인 추가
* 일반 POP 20」 · **B-71** 「보상 pending/24h」. 「검증 활동」은 Quest · Campaign 생성자격과 같은
* `verifiedCompletions` 표식이다. 귀속 **뒤**에 생긴 표식만 센다.
*
* 둘 다 **보류 지급**(`pending`)으로 잡고 잠금을 `held` 로 둔다. 확정 · FPI REFERRAL 은 24시간 뒤
* [settleReferral]. 지급은 멱등키로, 중간에 끊겨도 다음 완료가 이어서 끝낸다.
*/
export async function payReferralReward(uid: string, completionId: string): Promise<boolean> {
const db = getFirestore();
const lockRef = db.doc(`referralsByUser/${uid}`);
const [lock, completion] = await Promise.all([
lockRef.get(), db.doc(`users/${uid}/verifiedCompletions/${completionId}`).get(),
]);
if (!lock.exists || lock.get('reward') !== 'pending' || !completion.exists) return false;
if (completion.get('voided') === true || completion.createTime!.toMillis() < lock.createTime!.toMillis()) return false;
const referrerUid = lock.get('referrerUid') as string;
const refPath = `referrals/${lock.get('attributionId')}`;
const { referralReferrerPop, referralReferredPop } = await loadPolicy();
const grant = (to: string, amount: number, role: 'referrer' | 'referred') => grantPop({
uid: to, popType: 'activity', amount, reasonCode: 'REFERRAL_REWARD',
idempotencyKey: grantKey(uid, role), refPath, pending: true,
});
await grant(referrerUid, referralReferrerPop, 'referrer');
await grant(uid, referralReferredPop, 'referred');
return db.runTransaction(async (tx) => {
const fresh = await tx.get(lockRef);
if (fresh.get('reward') !== 'pending') return false;
tx.update(lockRef, {
reward: 'held', completionId, heldAt: FieldValue.serverTimestamp(),
settleAfter: Timestamp.fromMillis(Date.now() + REFERRAL_HOLD_MS),
});
tx.update(db.doc(refPath), { reward: 'held' });
return true;
});
}
/** 잠금 · 귀속 문서의 상태를 함께 옮긴다. `from` 이 아니면 아무것도 하지 않는다. */
async function moveReward(
uid: string, from: ReferralReward, to: ReferralReward,
also?: (tx: FirebaseFirestore.Transaction, lock: FirebaseFirestore.DocumentSnapshot) => Promise<() => void>,
): Promise<boolean> {
const db = getFirestore();
const lockRef = db.doc(`referralsByUser/${uid}`);
return db.runTransaction(async (tx) => {
const lock = await tx.get(lockRef);
if (lock.get('reward') !== from) return false;
const write = also ? await also(tx, lock) : null;
tx.update(lockRef, { reward: to, [`${to}At`]: FieldValue.serverTimestamp() });
tx.update(db.doc(`referrals/${lock.get('attributionId')}`), { reward: to });
write?.();
return true;
});
}
const referralFpiSource = (lock: FirebaseFirestore.DocumentSnapshot) => `referral:${lock.get('attributionId')}`;
/**
* 보류 정산 — 24시간이 지난 `held` 한 건. 귀속 뒤의 유효 완료가 남아 있고 이상 표시(`flagged`)가
* 없으면 두 보류 지급을 확정하고 FPI REFERRAL 1 단위를 남긴다. 아니면 둘 다 해제한다.
*/
export async function settleReferral(uid: string): Promise<'paid' | 'released' | null> {
const db = getFirestore();
const lock = await db.doc(`referralsByUser/${uid}`).get();
if (lock.get('reward') !== 'held') return null;
const referrerUid = lock.get('referrerUid') as string;
const ok = lock.get('flagged') !== true && (await liveCompletionAfter(uid, lock.createTime!)) !== null;
const outcome = ok ? 'confirm' : 'release';
await settlePendingGrant({ uid: referrerUid, idempotencyKey: grantKey(uid, 'referrer'), outcome });
await settlePendingGrant({ uid, idempotencyKey: grantKey(uid, 'referred'), outcome });
if (!ok) return (await moveReward(uid, 'held', 'released')) ? 'released' : null;
const moved = await moveReward(uid, 'held', 'paid', async (tx, fresh) => {
const referrer = await tx.get(db.doc(`users/${referrerUid}`));
return () => writeFpiEvent(tx, {
uid: referrerUid, fandomId: fandomOfUser(referrer), axis: 'referral', units: 1,
activity: 'referral', sourceId: referralFpiSource(fresh), occurredAt: new Date(),
refPath: `referrals/${fresh.get('attributionId')}`,
// 추천인은 이 활동을 직접 하지 않았다 — EXP · DAU 에서 뺀다(리뷰 M5). FPI REFERRAL 축만.
grantsExp: false,
}, referrer);
});
return moved ? 'paid' : null;
}
/**
* 검증 활동이 무효가 됐을 때. 귀속 뒤의 다른 유효 완료가 남아 있으면 그대로 둔다.
* - `held`: 두 보류 지급을 해제한다.
* - `paid`: 두 지급을 `ADMIN_CLAWBACK` 으로 회수하고(멱등) FPI REFERRAL 이벤트를 집계에서 뺀다
* (`verified: false`). 잔액이 모자라면 `clawback-pending` 으로 두고 운영 로그에 올린다.
*/
export async function onReferralCompletionVoided(uid: string): Promise<ReferralReward | null> {
const db = getFirestore();
const lock = await db.doc(`referralsByUser/${uid}`).get();
const state = lock.get('reward') as ReferralReward | undefined;
if (state !== 'held' && state !== 'paid') return null;
if (await liveCompletionAfter(uid, lock.createTime!)) return null;
const referrerUid = lock.get('referrerUid') as string;
if (state === 'held') {
for (const [who, role] of [[referrerUid, 'referrer'], [uid, 'referred']] as const) {
await settlePendingGrant({ uid: who, idempotencyKey: grantKey(uid, role), outcome: 'release' });
}
return (await moveReward(uid, 'held', 'released')) ? 'released' : null;
}
const { referralReferrerPop, referralReferredPop } = await loadPolicy();
try {
for (const [who, role, amount] of [
[referrerUid, 'referrer', referralReferrerPop], [uid, 'referred', referralReferredPop],
] as const) {
await spendPop({
uid: who, popType: 'activity', amount, reasonCode: 'ADMIN_CLAWBACK',
idempotencyKey: `${grantKey(uid, role)}:clawback`, refPath: `referrals/${lock.get('attributionId')}`,
});
}
} catch (e) {
logger.warn('[referral] 회수 보류', { uid, error: String(e) });
await db.collection('adminActionLogs').add({
actorUid: 'system', targetPath: lock.ref.path, reason: '추천 보상 회수 보류 — 잔액 부족',
patch: { error: String(e) }, createdAt: FieldValue.serverTimestamp(),
});
return (await moveReward(uid, 'paid', 'clawback-pending')) ? 'clawback-pending' : null;
}
const moved = await moveReward(uid, 'paid', 'clawedback', async (tx, fresh) => {
const referrer = await tx.get(db.doc(`users/${referrerUid}`));
const fandomId = fandomOfUser(referrer);
const eventRef = fandomId
? db.doc(`fandoms/${fandomId}/fpiEvents/${referrerUid}_${referralFpiSource(fresh)}`) : null;
const event = eventRef ? await tx.get(eventRef) : null;
return () => {
if (event?.exists) tx.update(event.ref, { verified: false, voidedAt: FieldValue.serverTimestamp() });
};
});
return moved ? 'clawedback' : null;
}
/** 검증 활동 표식이 새로 생길 때마다 — 대상이 아니면 잠금 문서 한 번 읽고 끝난다. */
export const fnReferralRewardOnCompletion = onDocumentCreated(
'users/{uid}/verifiedCompletions/{completionId}',
async (event) => {
await payReferralReward(event.params.uid, event.params.completionId);
},
);
/** 표식이 무효(`voided`)로 바뀌면 보류 해제 · 회수. */
export const fnReferralOnCompletionVoid = onDocumentUpdated(
'users/{uid}/verifiedCompletions/{completionId}',
async (event) => {
if (event.data?.before.get('voided') !== true && event.data?.after.get('voided') === true) {
await onReferralCompletionVoided(event.params.uid);
}
},
);
/** `fn-referral-settle` — 24시간 지난 보류 추천 보상을 확정 · 해제한다. */
export const fnReferralSettle = onSchedule('every 1 hours', async () => {
const due = await getFirestore().collection('referralsByUser')
.where('reward', '==', 'held').where('settleAfter', '<=', Timestamp.now()).get();
for (const doc of due.docs) {
try {
await settleReferral(doc.id);
} catch (error) {
logger.error('[referral] 정산 실패', { uid: doc.id, error: String(error) });
}
}
});
@@ -0,0 +1,306 @@
import { HttpsError, onCall } from 'firebase-functions/v2/https';
import { onSchedule } from 'firebase-functions/v2/scheduler';
import { getFirestore, FieldValue, Timestamp } from 'firebase-admin/firestore';
import { logger } from 'firebase-functions';
import { callerUid, str } from '../core/callable.js';
import { loadPolicy } from '../core/config.js';
import { ERR, fail } from '../core/errors.js';
import { deviceHashOf, txIdOf } from '../core/ids.js';
import { dayKey } from '../core/time.js';
import { publicNameOf } from '../core/names.js';
import { grantPop, settlePendingGrant } from '../ledger/ledger.js';
import { fandomOfUser } from '../ledger/fpi.js';
/**
* 참여형 번역 — 별첨 1 ⑧ · 요구사항 FR-I18N-02 · 정책 원장 B-63.
*
* 등록(`fn-translation-submit`) → 다른 멤버 마킹(`fn-translation-mark`) → 24시간 뒤
* 정산(`fn-translation-settle`). 보상은 등록 때 **보류 지급**으로 잡아 두고(예약 잔액)
* 정산이 확정하거나 해제한다 — 24시간 전 잔액 미포함(B-63 검증 기준).
*/
/** 1차 공식 지원 언어 — 정책 원장 B-76. 앱 `appLocales` 와 같은 태그다. */
export const TRANSLATION_LOCALES = ['ko', 'en', 'ja', 'zh-CN', 'es'] as const;
export const TRANSLATION_MAX_LENGTH = 2000;
/** 하루 등록 자체를 막는 선 = 보상 일일상한 × 이 배수(도배 차단). */
export const TRANSLATION_HARD_LIMIT_FACTOR = 3;
/**
* 번역을 붙일 수 있는 콘텐츠 — 인사이드 질문·답변 · 라운지 글·댓글 · 캠페인(응원 게시판) · 공지.
* 그 밖의 경로(사용자 문서 등)는 존재 여부를 드러내지 않고 E-TRN-03 으로 끊는다.
*/
const TARGET_PATTERNS = [
/^questions\/[^/]+$/,
/^questions\/[^/]+\/answers\/[^/]+$/,
/^posts\/[^/]+$/,
/^posts\/[^/]+\/comments\/[^/]+$/,
/^campaigns\/[^/]+$/,
/^campaigns\/[^/]+\/comments\/[^/]+$/,
/^notices\/[^/]+$/,
];
export interface TranslationInput {
targetPath: string;
locale: string;
body: string;
}
const invalidTarget = (): never => fail(ERR.trnInvalidTarget, '번역 대상을 찾을 수 없습니다.');
export function parseTranslationInput(data: unknown): TranslationInput {
const targetPath = str(data, 'targetPath');
const locale = str(data, 'locale');
const body = str(data, 'body').trim();
if (!TARGET_PATTERNS.some((p) => p.test(targetPath))
|| targetPath.split('/').some((s) => s === '.' || s === '..')) {
invalidTarget();
}
if (!(TRANSLATION_LOCALES as readonly string[]).includes(locale)) {
throw new HttpsError('invalid-argument', '지원하지 않는 언어입니다.');
}
if (body.length === 0 || body.length > TRANSLATION_MAX_LENGTH) {
throw new HttpsError('invalid-argument', `번역은 1~${TRANSLATION_MAX_LENGTH}자입니다.`);
}
return { targetPath, locale, body };
}
/**
* 등록 시점 판정 — 원장 B-63 「중복·도배 제외 · 일일상한 초과분 미지급」.
* 상한의 [TRANSLATION_HARD_LIMIT_FACTOR] 배를 넘으면 등록 자체를 거부(E-TRN-02),
* 상한까지는 보상, 그 사이는 등록만 받고 보상 0.
*/
export function translationRewardOf(input: {
pop: number; dailyLimit: number; todayCount: number; duplicate: boolean;
}): { rewardPop: number; excludedReason: 'duplicate' | 'daily_limit' | null } {
if (input.todayCount >= input.dailyLimit * TRANSLATION_HARD_LIMIT_FACTOR) {
fail(ERR.trnDailyCap, '오늘 등록할 수 있는 번역 수를 넘었습니다.');
}
if (input.duplicate) return { rewardPop: 0, excludedReason: 'duplicate' };
if (input.todayCount >= input.dailyLimit) return { rewardPop: 0, excludedReason: 'daily_limit' };
return { rewardPop: input.pop, excludedReason: null };
}
/** 정산 판정 — 확정 · 해제 · 대기. 작성자 마킹은 이미 빠진 수를 받는다. */
export function translationSettleOf(input: {
valid: number; invalid: number; ageHours: number;
minValid: number; maxPendingHours: number;
}): 'confirm' | 'release' | 'wait' {
if (input.valid >= input.minValid && input.valid > input.invalid) return 'confirm';
return input.ageHours >= input.maxPendingHours ? 'release' : 'wait';
}
/** 번역을 받는 캠페인 상태 — 공개된 캠페인만(검수 · 철회 · 취소 · 반려는 제외). */
const VISIBLE_CAMPAIGN_STATUSES = ['funding', 'success', 'fail', 'report'];
/**
* 다른 사용자에게 보이는 콘텐츠인가. 캠페인은 [VISIBLE_CAMPAIGN_STATUSES], 나머지(글 · 댓글 ·
* 질문 · 답변 · 공지)는 `visibility` 가 `visible` 이어야 한다 — 필드가 없는 질문 · 공지는 공개다.
* 없는 콘텐츠와 숨긴 콘텐츠는 호출자가 같은 E-TRN-03 으로 끊는다(존재 여부를 드러내지 않게).
*/
export function isVisibleTarget(targetPath: string, data: FirebaseFirestore.DocumentData | undefined): boolean {
if (!data) return false;
// 캠페인 문서는 상태로, 응원 게시판 댓글(`campaigns/{id}/comments/{cid}`)은 아래 visibility 로 본다.
if (/^campaigns\/[^/]+$/.test(targetPath)) return VISIBLE_CAMPAIGN_STATUSES.includes(String(data.status));
return (data.visibility ?? 'visible') === 'visible';
}
/** 콘텐츠의 팬덤. 답변·댓글은 부모 글에서 읽는다. 공지처럼 팬덤이 없으면 null. */
async function fandomOfTarget(targetPath: string, target: FirebaseFirestore.DocumentSnapshot) {
const own = target.get('fandomId');
if (typeof own === 'string' && own.length > 0) return own;
const segments = targetPath.split('/');
if (segments.length <= 2) return null;
const root = await getFirestore().doc(segments.slice(0, 2).join('/')).get();
const parent = root.get('fandomId');
return typeof parent === 'string' && parent.length > 0 ? parent : null;
}
/** `fn-translation-submit` — 번역 등록. 클라이언트 직접 쓰기는 규칙이 막는다. */
export const fnTranslationSubmit = onCall(async (req) => {
const uid = callerUid(req);
const input = parseTranslationInput(req.data);
const db = getFirestore();
const target = await db.doc(input.targetPath).get();
const rootPath = input.targetPath.split('/').slice(0, 2).join('/');
// 댓글 · 답변은 달린 글도 보여야 한다.
const root = rootPath === input.targetPath ? target : await db.doc(rootPath).get();
if (!isVisibleTarget(input.targetPath, target.data()) || !isVisibleTarget(rootPath, root.data())) {
invalidTarget();
}
const fandomId = await fandomOfTarget(input.targetPath, target);
const author = await db.doc(`users/${uid}`).get();
const policy = await loadPolicy();
const today = dayKey(new Date());
const entryRef = db.doc(`translationEntries/${txIdOf(`trn:${uid}:${input.targetPath}:${input.locale}`)}`);
const counterRef = db.doc(`translationDaily/${uid}_${today}`);
// 본문은 색인 한도(1,500바이트)를 넘을 수 있어 같은 문장 판정은 해시로 한다.
const bodyHash = deviceHashOf(input.body);
const result = await db.runTransaction(async (tx) => {
const [existing, counter, same] = await Promise.all([
tx.get(entryRef),
tx.get(counterRef),
tx.get(db.collection('translationEntries').where('targetPath', '==', input.targetPath)
.where('locale', '==', input.locale).where('bodyHash', '==', bodyHash).limit(1)),
]);
if (existing.exists) fail(ERR.trnDuplicate, '이미 이 콘텐츠에 같은 언어로 번역을 등록했습니다.');
const reward = translationRewardOf({
pop: policy.translationPop,
dailyLimit: policy.translationDailyLimit,
todayCount: (counter.get('count') as number | undefined) ?? 0,
duplicate: !same.empty,
});
// 보상분을 예약 잔액으로 잡는다(읽기가 끝난 뒤 · 쓰기 전에 — 트랜잭션 순서 규칙).
if (reward.rewardPop > 0) {
await grantPop({
uid, popType: 'activity', amount: reward.rewardPop, reasonCode: 'TRANSLATION',
idempotencyKey: `trn:${entryRef.id}`, refPath: entryRef.path, pending: true,
}, tx);
}
tx.create(entryRef, {
authorUid: uid,
targetPath: input.targetPath,
// 글 · 질문 · 캠페인 경로. 상세 화면이 본문과 댓글의 번역 댓글을 한 번에 읽는다.
rootPath,
authorName: publicNameOf(author),
fandomId,
locale: input.locale,
body: input.body,
bodyHash,
status: 'pending',
// 마킹 수 — fn-translation-mark 가 같은 트랜잭션에서 올린다. 앱은 이 값만 읽는다.
validCount: 0,
invalidCount: 0,
...reward,
dayKey: today,
createdAt: FieldValue.serverTimestamp(),
});
tx.set(counterRef, {
uid, dayKey: today, count: FieldValue.increment(1), updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
return reward;
});
return { entryId: entryRef.id, ...result };
});
/**
* 마킹 자격 — 가입 24시간 이상 · 번역 대상 팬덤의 멤버(팬덤 없는 공지는 로그인만).
* INSIDE 질문·답변은 운영값 `translationInsideAnyMember`(🟡 Q-TR-04 권고 B안 · 기본 true)
* 가 켜져 있으면 팬덤 조건 없이 가입 24시간 경과 회원 전체가 마킹한다.
*/
const ACCOUNT_MIN_AGE_MS = 24 * 60 * 60 * 1000;
/**
* `fn-translation-mark` — 유효/무효 마킹. 본인 번역은 E-TRN-01, 자격 미달은 E-TRN-04,
* 두 번째 마킹은 E-TRN-05. 번복은 없다.
*/
export const fnTranslationMark = onCall(async (req) => {
const uid = callerUid(req);
const entryId = str(req.data, 'entryId');
const valid = (req.data as Record<string, unknown> | null)?.valid;
if (typeof valid !== 'boolean') throw new HttpsError('invalid-argument', 'valid 이(가) 필요합니다.');
const db = getFirestore();
const entryRef = db.doc(`translationEntries/${entryId}`);
const entry = await entryRef.get();
if (!entry.exists || entry.get('status') !== 'pending') invalidTarget();
if (entry.get('authorUid') === uid) fail(ERR.trnSelfMark, '본인 번역은 마킹할 수 없습니다.');
const user = await db.doc(`users/${uid}`).get();
const created = user.get('createdAt') as Timestamp | undefined;
if (!created || Date.now() - created.toMillis() < ACCOUNT_MIN_AGE_MS) {
fail(ERR.trnNotEligible, '가입 후 24시간이 지나야 마킹할 수 있습니다.');
}
const targetPath = String(entry.get('targetPath'));
// 등록 뒤 가려진 콘텐츠(숨김 · 캠페인 철회 등)의 번역은 더 마킹하지 않는다 — 등록과 같은 E-TRN-03.
const rootPath = targetPath.split('/').slice(0, 2).join('/');
const [target, root] = await Promise.all([db.doc(targetPath).get(), db.doc(rootPath).get()]);
if (!isVisibleTarget(targetPath, target.data()) || !isVisibleTarget(rootPath, root.data())) invalidTarget();
const fandomId = entry.get('fandomId') as string | null | undefined;
const insideOpen = targetPath.startsWith('questions/')
&& (await loadPolicy()).translationInsideAnyMember;
// 캠페인은 팬덤 멤버만 마킹한다 — 팬덤이 없는 캠페인은 마킹할 수 있는 사람이 없다.
if (!fandomId && targetPath.startsWith('campaigns/')) {
fail(ERR.trnNotEligible, '이 팬덤의 멤버만 마킹할 수 있습니다.');
}
if (fandomId && !insideOpen) {
const member = await db.doc(`fandoms/${fandomId}/members/${uid}`).get();
if (!member.exists && fandomOfUser(user) !== fandomId) {
fail(ERR.trnNotEligible, '이 팬덤의 멤버만 마킹할 수 있습니다.');
}
}
// 마킹과 수 집계는 함께 커밋한다 — 앱은 번역 문서의 수만 읽는다.
const markRef = entryRef.collection('markings').doc(uid);
await db.runTransaction(async (tx) => {
const [fresh, mine] = await Promise.all([tx.get(entryRef), tx.get(markRef)]);
if (fresh.get('status') !== 'pending') invalidTarget();
if (mine.exists) fail(ERR.trnDuplicate, '이미 마킹한 번역입니다.');
tx.create(markRef, { valid, createdAt: FieldValue.serverTimestamp() });
tx.update(entryRef, { [valid ? 'validCount' : 'invalidCount']: FieldValue.increment(1) });
});
return { entryId, valid };
});
/**
* `fn-translation-settle` — 24시간 지난 번역 정산.
*
* 유효 마킹이 운영값 이상이고 무효보다 많으면 확정(예약 → 사용 가능). 못 채우면 기다리고,
* 최대 대기 시간을 넘기면 예약을 해제하고 닫는다. 자기 마킹은 세지 않는다(별첨 1 ⑧).
*/
export const fnTranslationSettle = onSchedule('every 1 hours', async () => {
const db = getFirestore();
const policy = await loadPolicy();
const now = Date.now();
const due = await db
.collection('translationEntries')
.where('status', '==', 'pending')
.where('createdAt', '<=', Timestamp.fromMillis(now - 24 * 60 * 60 * 1000))
.get();
const count = { confirmed: 0, excluded: 0, waiting: 0 };
for (const entry of due.docs) {
const authorUid = entry.get('authorUid') as string | undefined;
if (!authorUid) continue;
const reward = (entry.get('rewardPop') as number | undefined) ?? 0;
try {
if (reward <= 0) {
// 등록 때 보상에서 제외된 건(중복·일일상한). 예약이 없으니 닫기만 한다.
await entry.ref.set({ status: 'excluded', settledAt: FieldValue.serverTimestamp() }, { merge: true });
count.excluded += 1;
continue;
}
const markings = await entry.ref.collection('markings').get();
const others = markings.docs.filter((m) => m.id !== authorUid);
const decision = translationSettleOf({
valid: others.filter((m) => m.get('valid') === true).length,
invalid: others.filter((m) => m.get('valid') === false).length,
ageHours: (now - (entry.get('createdAt') as Timestamp).toMillis()) / 3_600_000,
minValid: policy.translationMinValidMarks,
maxPendingHours: policy.translationMaxPendingHours,
});
if (decision === 'wait') {
count.waiting += 1;
continue;
}
await settlePendingGrant({
uid: authorUid,
idempotencyKey: `trn:${entry.id}`,
outcome: decision,
});
await entry.ref.set({
status: decision === 'confirm' ? 'confirmed' : 'excluded',
...(decision === 'release' ? { excludedReason: 'unverified' } : {}),
settledAt: FieldValue.serverTimestamp(),
}, { merge: true });
count[decision === 'confirm' ? 'confirmed' : 'excluded'] += 1;
} catch (error) {
logger.error('[translation] 정산 실패', { entryId: entry.id, error: String(error) });
}
}
logger.info('[translation] 정산', { scanned: due.size, ...count });
});
+131 -4
View File
@@ -1,8 +1,10 @@
import { onCall } from 'firebase-functions/v2/https';
import { getFirestore, FieldValue } from 'firebase-admin/firestore';
import { callerUid, requireRole, str } from '../core/callable.js';
import { ERR, fail } from '../core/errors.js';
import { callerUid, num, optionalStr, requireRole, str } from '../core/callable.js';
import { ERR, fail, positiveInt } from '../core/errors.js';
import { reverseGrowth } from '../ledger/ledger.js';
import { voidCompletions } from '../ledger/fpi.js';
/**
* `fn-admin-write` — 모든 Admin 쓰기의 단일 관문(§7.5).
@@ -13,7 +15,7 @@ import { ERR, fail } from '../core/errors.js';
*/
/**
* 원장 · 지표 · 감사 기록은 이 경로로 못 쓴다 — **master 도 불가**.
* 원장 · 지표 · 감사 기록은 이 경로로 못 쓴다 — **master 도 불가**(리뷰 H3).
* 반드시 원장 · 집계 함수를 지나야 하고, 감사 로그는 고칠 수 없어야 감사 로그다.
*/
const FORBIDDEN = [
@@ -41,7 +43,7 @@ const MASTER_ONLY_FIELDS: Readonly<Record<string, readonly string[]>> = {
*
* 이 함수는 Admin SDK 라 규칙을 거치지 않는다. 여기서 같은 선을 긋지 않으면 ops 가
* 재화 정책(`config`) · 퀘스트 · 행성 단계 같은 마스터 전용 문서를 우회해 쓴다.
* `configPrivate` 는 규칙상 서버 전용이라 함께 막는다.
* `configPrivate` 는 규칙상 서버 전용이라 함께 막는다. 역할 체계 개편은 S05 결정 대기.
*/
const MASTER_ONLY = new Set([
'config', 'configPrivate', 'appConfig', 'i18n', 'planetStages', 'quests', 'badges',
@@ -118,3 +120,128 @@ export const fnAdminWrite = onCall(async (req) => {
return { logId: log.id, targetPath };
});
/**
* `fn-admin-pop-grant-batch` — 일괄 지급 미리보기와 실행.
*
* 별첨 1 ⑨ 가 「미리보기 + 2단계 확인」을 요구한다. `dryRun` 이 기본값이고,
* 실행하려면 미리보기에서 받은 대상 수를 `confirmCount` 로 되돌려 줘야 한다 —
* 두 번째 호출이 첫 번째와 같은 모집단을 보고 있는지 확인하는 장치다.
*/
export const fnAdminPopGrantBatch = onCall(async (req) => {
const actorUid = callerUid(req);
requireRole(req, ['master', 'ops']);
const batchId = str(req.data, 'batchId');
const data = (req.data ?? {}) as Record<string, unknown>;
const amount = data.amount;
if (typeof amount !== 'number' || !Number.isInteger(amount) || amount <= 0) {
fail(ERR.popVerifyFailed, '지급 수량이 올바르지 않습니다.');
}
const targets = Array.isArray(data.uids)
? (data.uids as unknown[]).filter((u): u is string => typeof u === 'string')
: [];
if (targets.length === 0) fail(ERR.popVerifyFailed, '지급 대상이 없습니다.');
const dryRun = data.dryRun !== false;
if (dryRun) {
return { preview: true, batchId, targetCount: targets.length, amount };
}
if (data.confirmCount !== targets.length) {
fail(ERR.popVerifyFailed, '미리보기에서 확인한 대상 수와 다릅니다.');
}
const { grantPop } = await import('../ledger/ledger.js');
const succeeded: string[] = [];
const failed: Array<{ uid: string; error: string }> = [];
// 500건 청크로 나눈다 — 한 번에 몰면 Functions 실행 시간을 넘긴다.
for (const chunk of chunks(targets, 500)) {
for (const uid of chunk) {
try {
await grantPop({
uid,
popType: 'activity',
amount: amount as number,
reasonCode: 'ADMIN_GRANT',
idempotencyKey: `grant:${batchId}:${uid}`,
actorUid,
});
succeeded.push(uid);
} catch (error) {
failed.push({ uid, error: String(error) });
}
}
}
await getFirestore().collection('adminActionLogs').add({
actorUid,
targetPath: `batch/${batchId}`,
reason: 'ADMIN_GRANT batch',
patch: { amount, targetCount: targets.length },
succeeded: succeeded.length,
failed: failed.length,
createdAt: FieldValue.serverTimestamp(),
});
return { preview: false, batchId, succeeded: succeeded.length, failed };
});
function* chunks<T>(items: T[], size: number): Generator<T[]> {
for (let i = 0; i < items.length; i += size) yield items.slice(i, i + size);
}
/**
* `fn-admin-growth-reverse` — 오지급 확정분의 성장 역분개(정책 원장 **C-32**).
*
* 허위 참여 · 중복 지급 · 오지급이 **확정**된 건만 부른다. 의심 단계에서는 부르지
* 않는다. 사유와 수치는 역분개 행과 작업 로그 양쪽에 남는다.
*/
export const fnAdminGrowthReverse = onCall(async (req) => {
const actorUid = callerUid(req);
const role = requireRole(req, ['master', 'ops']);
const uid = str(req.data, 'uid');
const sourceEventId = str(req.data, 'sourceEventId');
const reason = str(req.data, 'reason');
if (uid.includes('/') || sourceEventId.includes('/')) {
fail(ERR.permissionDenied, '문서 경로가 아닙니다.');
}
const result = await reverseGrowth({
uid, sourceEventId, reason, actorUid,
amount: positiveInt(num(req.data, 'amount'), 'amount'),
});
await getFirestore().collection('adminActionLogs').add({
actorUid,
role,
targetPath: `users/${uid}/growthEvents/${result.reversalId}`,
reason,
patch: { reversalOf: sourceEventId, amount: -result.amount, fandomId: result.fandomId },
createdAt: FieldValue.serverTimestamp(),
});
return result;
});
/**
* `fn-admin-completion-void` — Quest 완료의 사후 무효를 「검증 활동」 수에 반영한다(Campaign 개설자격 A안).
*
* 성장 역분개가 없는 완료(무보상 · 팬덤 없음 · EXP 만)도 무효로 할 수 있게 **원장 행(`txId`) 또는
* 제출 건(`submissionId`)** 으로 찾는다. 이미 무효인 표식은 다시 세지 않는다(`voided` 1회).
*/
export const fnAdminCompletionVoid = onCall(async (req) => {
const actorUid = callerUid(req);
const role = requireRole(req, ['master', 'ops']);
const uid = str(req.data, 'uid');
const reason = str(req.data, 'reason');
const txId = optionalStr(req.data, 'txId');
const submissionId = optionalStr(req.data, 'submissionId');
if (uid.includes('/') || (!txId && !submissionId)) fail(ERR.permissionDenied, '대상 원장 행 또는 제출 건이 필요합니다.');
const voided = await voidCompletion(uid, txId ? { txId } : { submissionId: submissionId! });
await getFirestore().collection('adminActionLogs').add({
actorUid, role, targetPath: `users/${uid}/verifiedCompletions`, reason,
patch: { txId: txId ?? null, submissionId: submissionId ?? null, voided },
createdAt: FieldValue.serverTimestamp(),
});
return { voided };
});
export async function voidCompletion(uid: string, key: { txId?: string; submissionId?: string }): Promise<number> {
return getFirestore().runTransaction(async (tx) => (await voidCompletions(tx, uid, key))(tx));
}
@@ -0,0 +1,284 @@
import { onCall } from 'firebase-functions/v2/https';
import { getFirestore, FieldValue, Timestamp } from 'firebase-admin/firestore';
import { logger } from 'firebase-functions';
import { callerUid, requireRole, str } from '../core/callable.js';
import { ERR, fail } from '../core/errors.js';
import { briefingHashOf } from '../core/ids.js';
import { dayKey } from '../core/time.js';
import { sendFandomPush } from '../notify/push.js';
/**
* AI Daily Briefing — 정책 원장 B-47 · B-48 · B-75, 별첨 2 6 주차.
*
* **문안을 우리가 만들지 않는다.** B-48 이 「외부 Agent 엔진은 발주사 영역」이고
* 우리 범위는 「Candidate/Draft 수신 · Admin 승인 · 노출」 3 단계, 「Agent 엔진 자체
* 구현 0건」이라고 확정했다. 그래서 이 파일에는 수신(`fn-briefing-ingest`)과
* 승인(`fn-briefing-publish`)만 있다.
*
* **문서 한 장이 카드 한 줄이다.** 앱과 시드가 이미 `briefings/{id}` 를 한 줄로
* 읽고 있어(`rank` 오름차순) 그 형태를 유지한다.
*/
/** 수신 가능한 섹션 — B-47 이 News 와 SNS 둘로 확정했다. */
const SECTIONS = ['News', 'SNS'] as const;
type Section = typeof SECTIONS[number];
/**
* B-75 가 요구하는 5 요소를 담은 수신 후보.
*
* *각 항목에 출처명·게시/발행시각·원본 URL·원문보기·AI 요약 표시를 제공합니다.*
* *출처 확인 불가 AI 내용은 게시하지 않습니다.* · *메타 누락 건은 Draft 단계에서 배제*
*
* 그래서 검증을 게시가 아니라 **수신**에서 한다. 누락 건을 일단 저장해 두면
* 관리자 화면에 게시할 수 없는 초안이 쌓이고, 그것이 노출 사고의 재료가 된다.
*/
export interface BriefingCandidate {
fandomId: string;
section: Section;
/** 카드에 뜨는 제목. */
title: string;
/** 출처명 — 언론사·계정명. */
sourceName: string;
/** 게시·발행 시각. ISO 8601 문자열로 받는다. */
sourceAt: string;
/** 원본 URL. */
sourceUrl: string;
/** 원문보기 링크. 원본과 같아도 된다. */
articleUrl: string;
/** AI 요약 본문. */
aiSummary: string;
}
export interface IngestResult {
accepted: number;
rejected: Array<{ index: number; reason: string }>;
dayKey: string;
}
/**
* 후보 한 건을 검증한다. 통과하면 정규화된 값을, 아니면 사유를 돌려준다.
*
* **부분 통과를 만들지 않는다.** 5 요소 중 하나라도 비면 그 항목을 저장하지 않는다.
* 빈 문자열과 공백만 있는 값을 누락으로 본다 — 수집기가 값을 못 찾았을 때 빈
* 문자열을 채워 보내는 것이 흔하고, 그것을 통과시키면 카드에 출처가 비어 뜬다.
*/
export function validateCandidate(
raw: unknown,
): { ok: true; value: BriefingCandidate } | { ok: false; reason: string } {
const data = (raw ?? {}) as Record<string, unknown>;
const text = (key: string): string | null => {
const value = data[key];
return typeof value === 'string' && value.trim().length > 0
? value.trim()
: null;
};
const section = text('section');
if (!section || !(SECTIONS as readonly string[]).includes(section)) {
return { ok: false, reason: `section 은 ${SECTIONS.join('|')} 중 하나여야 합니다.` };
}
const fandomId = text('fandomId');
if (!fandomId) return { ok: false, reason: 'fandomId 가 필요합니다.' };
// B-75 5 요소. 빠진 것을 전부 모아서 알린다 — 하나씩 돌려주면 수집기 쪽이
// 같은 항목으로 다섯 번 재시도한다.
const required = {
title: text('title'),
sourceName: text('sourceName'),
sourceAt: text('sourceAt'),
sourceUrl: text('sourceUrl'),
articleUrl: text('articleUrl'),
aiSummary: text('aiSummary'),
};
const missing = Object.entries(required)
.filter(([, value]) => value === null)
.map(([key]) => key);
if (missing.length > 0) {
return { ok: false, reason: `출처 메타가 없습니다: ${missing.join(', ')}` };
}
// 시각은 파싱까지 해 본다. 문자열이 있기만 하면 통과시키면 카드가 「Invalid
// Date」 를 띄우고, 그 원인이 수집 단계였다는 것이 나중에는 보이지 않는다.
if (Number.isNaN(Date.parse(required.sourceAt!))) {
return { ok: false, reason: 'sourceAt 이 시각 형식이 아닙니다.' };
}
// URL 도 형태를 본다. http(s) 가 아니면 원문보기가 열리지 않는다.
for (const key of ['sourceUrl', 'articleUrl'] as const) {
let parsed: URL;
try {
parsed = new URL(required[key]!);
} catch {
return { ok: false, reason: `${key} 이 http(s) URL 이 아닙니다.` };
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return { ok: false, reason: `${key} 이 http(s) URL 이 아닙니다.` };
}
}
return {
ok: true,
value: {
fandomId,
section: section as Section,
title: required.title!,
sourceName: required.sourceName!,
sourceAt: required.sourceAt!,
sourceUrl: required.sourceUrl!,
articleUrl: required.articleUrl!,
aiSummary: required.aiSummary!,
},
};
}
/** 같은 기사를 두 번 받아도 문서가 늘지 않게 하는 ID — `briefingHashOf` 참고. */
export function candidateDocId(candidate: BriefingCandidate, day: string): string {
const key = `${candidate.fandomId}:${day}:${candidate.section}:${candidate.sourceUrl}`;
return `${candidate.fandomId}_${day}_${candidate.section}_${briefingHashOf(key)}`;
}
/**
* `fn-briefing-ingest` — 발주사 Agent 엔진의 Candidate/Draft 수신 규격.
*
* **인증** — 기존 코드베이스에 공유 비밀 헤더 선례가 없어 callable + Admin 역할로
* 둔다. B-48 비고 *「Agent 엔진 연동 규격은 발주사 제공 시점에 맞춰 연결합니다」*
* 에 따라, 발주사가 서비스 계정이나 서명 규격을 주면 **이 게이트 두 줄만** 바꾼다.
* 저장 경로와 검증을 게이트 뒤에 두어 규격이 바뀌어도 그대로 쓸 수 있게 했다.
*
* 입력은 `items` 배열이다. 한 건씩 받으면 수집이 항목마다 왕복을 돌고, 그 사이에
* 날짜가 바뀌면 같은 회차가 두 `dayKey` 로 갈린다.
*
* **자동 게시는 하지 않는다**(B-47). 저장은 언제나 `status: 'draft'` 다.
*/
export const fnBriefingIngest = onCall(async (req) => {
const actorUid = callerUid(req);
const role = requireRole(req, ['master', 'ops']);
const items = (req.data as Record<string, unknown> | null)?.items;
if (!Array.isArray(items) || items.length === 0) {
fail(ERR.permissionDenied, '수신할 브리핑 항목이 없습니다.');
}
const db = getFirestore();
const day = dayKey(new Date());
const rejected: Array<{ index: number; reason: string }> = [];
const accepted: BriefingCandidate[] = [];
(items as unknown[]).forEach((raw, index) => {
const result = validateCandidate(raw);
if (result.ok) accepted.push(result.value);
else rejected.push({ index, reason: result.reason });
});
// 섹션별로 rank 를 매긴다. 앱이 섹션마다 따로 줄을 세우므로 전체 통번호를
// 매기면 News 가 3 건일 때 SNS 가 4 번부터 시작한다.
const rankBySection = new Map<Section, number>();
const batch = db.batch();
for (const candidate of accepted) {
const rank = (rankBySection.get(candidate.section) ?? 0) + 1;
rankBySection.set(candidate.section, rank);
batch.set(db.doc(`briefings/${candidateDocId(candidate, day)}`), {
fandomId: candidate.fandomId,
dayKey: day,
section: candidate.section,
rank,
// 카드 한 줄은 제목을 띄운다. 필드 이름은 기존 앱 코드와 맞춘다.
headline: candidate.title,
sourceName: candidate.sourceName,
sourceAt: Timestamp.fromDate(new Date(candidate.sourceAt)),
sourceUrl: candidate.sourceUrl,
articleUrl: candidate.articleUrl,
aiSummary: candidate.aiSummary,
// B-75 「AI 요약 표시」 — 사람이 쓴 글이 아님을 화면이 밝힐 근거다.
aiGenerated: true,
// B-47 「AI Draft 자동게시 금지」 — 수신은 언제나 draft 로 끝난다.
status: 'draft',
ingestedBy: actorUid,
createdAt: FieldValue.serverTimestamp(),
}, { merge: true });
}
if (accepted.length > 0) await batch.commit();
const result: IngestResult = {
accepted: accepted.length,
rejected,
dayKey: day,
};
logger.info('[briefing] 수신 완료', {
role, dayKey: day, accepted: accepted.length, rejected: rejected.length,
});
return result;
});
/**
* `fn-briefing-publish` — 관리자 승인 게시(B-47 「Admin 승인 → 당일자 노출」).
*
* 권한 검사와 작업 로그는 `fn-admin-write` · `fn-admin-set-role` 과 같은 방식이다 —
* 커스텀 클레임만 보고, 누가 무엇을 왜 게시했는지 `adminActionLogs` 에 남긴다.
* 규칙 층은 거부만 할 수 있고 로그를 남기지 못한다.
*
* 게시와 동시에 팬덤의 AI Briefing 토픽으로 푸시를 보낸다. 발송이 실패해도 게시는
* 되돌리지 않는다 — 카드가 이미 노출 대상이 된 상태에서 롤백하면 관리자가 본
* 결과와 사용자가 보는 화면이 갈린다.
*/
export const fnBriefingPublish = onCall(async (req) => {
const actorUid = callerUid(req);
const role = requireRole(req, ['master', 'ops']);
const fandomId = str(req.data, 'fandomId');
const day = str(req.data, 'dayKey');
const db = getFirestore();
const drafts = await db.collection('briefings')
.where('fandomId', '==', fandomId)
.where('dayKey', '==', day)
.where('status', '==', 'draft')
.get();
if (drafts.empty) {
fail(ERR.permissionDenied, '게시할 브리핑 초안이 없습니다.');
}
const batch = db.batch();
for (const draft of drafts.docs) {
batch.set(draft.ref, {
status: 'published',
publishedAt: FieldValue.serverTimestamp(),
publishedBy: actorUid,
}, { merge: true });
}
await batch.commit();
// 섹션별 건수를 함께 남긴다. 「News 만 승인했는가」를 로그에서 되짚을 수 있어야
// B-47 의 섹션별 노출 판정을 사후에 대조할 수 있다.
const bySection: Record<string, number> = {};
for (const draft of drafts.docs) {
const section = (draft.get('section') as string | undefined) ?? 'unknown';
bySection[section] = (bySection[section] ?? 0) + 1;
}
await db.collection('adminActionLogs').add({
actorUid,
role,
targetPath: `briefings/${fandomId}_${day}`,
targetType: 'briefing',
reason: 'BRIEFING_PUBLISH',
patch: { status: 'published', count: drafts.size, bySection },
createdAt: FieldValue.serverTimestamp(),
});
// 앱은 당일(KST) 게시분만 본다. 어제 날짜를 게시하면 푸시를 받고도 카드가
// 없는 상태가 되므로 보내지 않는다(B-47 「전일 콘텐츠 자동 재사용 없음」).
const isToday = day === dayKey(new Date());
const pushed = isToday && await sendFandomPush({
fandomId,
category: 'briefing',
title: '오늘의 AI 브리핑이 도착했습니다',
body: (drafts.docs.find((d) => d.get('rank') === 1)?.get('headline') as string | undefined)
?? '홈에서 오늘의 브리핑을 확인해 보세요.',
data: { kind: 'briefing', dayKey: day },
});
return {
fandomId, dayKey: day, published: drafts.size, bySection,
pushed: pushed === true,
};
});
@@ -0,0 +1,198 @@
import { HttpsError, onCall } from 'firebase-functions/v2/https';
import { logger } from 'firebase-functions';
import { getFirestore, FieldValue, Timestamp } from 'firebase-admin/firestore';
import { callerUid, num, str } from '../core/callable.js';
import { ERR, fail, positiveInt } from '../core/errors.js';
import { txIdOf } from '../core/ids.js';
import { refundSplit, spendSplit } from '../ledger/ledger.js';
import { verifiedQuestCountOf } from '../ledger/fpi.js';
import { publicNameOf } from '../core/names.js';
/**
* `fn-campaign-create` — 사용자 캠페인 개설(원장 B-56 자격 · B-57 등록비).
*
* 예전에는 앱이 `campaigns` 에 `draft` 로 직접 썼는데 개설자는 status 를 바꿀 수 없어
* 심사(`review`)로 넘어갈 길이 없었다. 개설 · 등록비 차감 · `review` 진입을
* 한 트랜잭션에서 하고, 승인은 그대로 `fn-campaign-admin-review` 가 한다.
*
* 등록비는 겸용이다(B-57 · B-06) — 일반 POP 을 먼저 쓰고 부족분만 기여 POP 에서 뺀다
* (`spendSplit`, 원장 최대 2건). 그 거래 ID 들을 `feeTxIds` 에 남겨 반환(B-58)이 행마다
* 원래 타입으로 돌린다. 등록비는 성장 · FPI 원천이 아니다(C-30).
*
* 🟡 운영값(등록비 · 가입 경과일 · 동시 진행 수 · 검증 활동 횟수)은 `config/campaign` 이 덮는다.
* 「검증 활동」은 서버가 인정한 Quest 완료 누적 수다(해석 확인 요청 Campaign 개설자격 A안 · 5회).
*/
const DEFAULTS = { feePop: 100, minAccountDays: 7, maxActive: 1, minVerifiedQuests: 5 };
const MAX_DAYS = 365;
export const fnCampaignCreate = onCall(async (req) => {
const data = req.data as Record<string, unknown> | null;
return createCampaign({
uid: callerUid(req),
clientSeq: str(data, 'clientSeq'),
title: str(data, 'title'),
body: str(data, 'body'),
targetPop: positiveInt(num(data, 'targetPop'), 'targetPop'),
days: positiveInt(num(data, 'days'), 'days'),
});
});
export async function createCampaign(input: {
uid: string; clientSeq: string; title: string; body: string; targetPop: number; days: number;
}): Promise<{ campaignId: string; status: 'review' }> {
const { uid, clientSeq } = input;
const title = input.title.trim();
const body = input.body.trim();
if (!title || title.length > 100) throw new HttpsError('invalid-argument', '제목은 1~100자입니다.');
if (!body || body.length > 5000) throw new HttpsError('invalid-argument', '내용은 1~5000자입니다.');
if (input.days > MAX_DAYS) throw new HttpsError('invalid-argument', `진행 기간은 ${MAX_DAYS}일 이하입니다.`);
const db = getFirestore();
const key = `campaign:${uid}:${clientSeq}`;
const campaignRef = db.doc(`campaigns/${txIdOf(key)}`);
// 같은 요청의 재시도 — 등록비도 두 번 빼지 않는다(spendPop 도 같은 키로 멱등).
if ((await campaignRef.get()).exists) return { campaignId: campaignRef.id, status: 'review' };
const cfg = { ...DEFAULTS, ...((await db.doc('config/campaign').get()).data() ?? {}) };
const user = await db.doc(`users/${uid}`).get();
const fandomId = user.get('fandomId') as string | undefined;
if (!fandomId) fail(ERR.permissionDenied, '팬덤에 가입한 뒤 캠페인을 만들 수 있습니다.');
if ((user.get('status') ?? 'active') !== 'active') {
fail(ERR.permissionDenied, '이용이 제한된 계정은 캠페인을 만들 수 없습니다.');
}
const joined = user.get('createdAt') as Timestamp | undefined;
if (!joined || Date.now() - joined.toMillis() < cfg.minAccountDays * 86_400_000) {
fail(ERR.permissionDenied, `가입 ${cfg.minAccountDays}일이 지나야 캠페인을 만들 수 있습니다.`);
}
const need = Number(cfg.minVerifiedQuests);
const done = need > 0 ? await verifiedQuestCountOf(uid) : 0;
if (done < need) {
fail(ERR.campaignNotEligible, `퀘스트 완료 ${done}/${need} — 퀘스트를 ${need - done}번 더 완료하면 캠페인을 열 수 있습니다.`, { done, need });
}
// 단일 필드 조건만 써서 복합 색인 없이 센다. 개설자 한 명의 캠페인 수는 작다.
const mine = await db.collection('campaigns').where('creatorUid', '==', uid).get();
const active = mine.docs.filter((d) => ['review', 'funding'].includes(d.get('status'))).length;
if (active >= cfg.maxActive) {
fail(ERR.permissionDenied, `진행 중인 캠페인은 ${cfg.maxActive}건까지입니다.`);
}
await spendSplit({
uid,
amount: cfg.feePop,
reasonCode: 'CAMPAIGN_FEE',
idempotencyKey: `campaignFee:${uid}:${clientSeq}`,
refPath: campaignRef.path,
alsoWrite: (tx, parts) => tx.create(campaignRef, {
fandomId,
creatorUid: uid,
creatorName: publicNameOf(user),
title,
summary: '',
body,
targetPop: input.targetPop,
endsAt: Timestamp.fromMillis(Date.now() + input.days * 86_400_000),
status: 'review',
raisedPop: 0,
achievedRate: 0,
donorCount: 0,
refundState: 'none',
feePop: cfg.feePop,
feeTxIds: parts.map((p) => p.txId),
createdAt: FieldValue.serverTimestamp(),
updatedAt: FieldValue.serverTimestamp(),
}),
});
return { campaignId: campaignRef.id, status: 'review' };
}
/**
* 등록비 반환 — B-58 「회사귀책 · 반려는 등록비 반환」 · 공개 전 철회(B-57 「공개 확정 시 Burn」).
*
* 반환이 정해진 캠페인은 상태 전이와 같은 쓰기에서 `feeRefund: 'due'` 를 받는다. 여기서
* 행마다 원래 타입으로 돌려준 뒤 `done` 으로 닫는다. 캠페인 하나에 멱등키 하나라 중간에
* 실패해도 다시 부르면 두 번 돌려주지 않는다. `due` 가 아니면 이미 남긴 결과만 돌려준다.
*/
export async function settleFeeRefund(campaignId: string): Promise<string[]> {
const ref = getFirestore().doc(`campaigns/${campaignId}`);
const snap = await ref.get();
if (snap.get('feeRefund') !== 'due') {
return (snap.get('feeRefundTxIds') as string[] | undefined) ?? [];
}
// 분할 차감 전의 옛 캠페인은 `feeTxId` 한 건이다. 둘 다 없으면 면제(Official 등)다.
const legacy = snap.get('feeTxId') as string | undefined;
const txIds = (snap.get('feeTxIds') as string[] | undefined) ?? (legacy ? [legacy] : []);
const refunded = txIds.length === 0 ? [] : (await refundSplit({
uid: snap.get('creatorUid') as string,
originalTxIds: txIds,
idempotencyKey: `campaignFeeRefund:${campaignId}`,
reasonCode: 'CAMPAIGN_FEE_REFUND',
})).map((r) => r.txId);
await ref.update({
feeRefund: 'done',
feeRefundTxIds: refunded,
updatedAt: FieldValue.serverTimestamp(),
});
return refunded;
}
/**
* 반환이 정해졌는데 끝나지 않은 캠페인을 다시 돈다 — `fn-campaign-settle` 회차마다.
* 상태 전이 뒤 반환 전에 죽은 호출을 복구하는 경로다. 실패한 건은 다음 회차가 다시 집는다.
*/
export async function retryFeeRefunds(): Promise<number> {
const due = await getFirestore().collection('campaigns').where('feeRefund', '==', 'due').get();
let done = 0;
for (const doc of due.docs) {
try {
await settleFeeRefund(doc.id);
done += 1;
} catch (error) {
logger.error('[campaign] 등록비 반환 실패 — 다음 회차 재시도', { campaignId: doc.id, error: String(error) });
}
}
return done;
}
/**
* `fn-campaign-withdraw` — 개설자 철회(B-58).
*
* - 공개 전(`draft` · `review`) — 철회하고 등록비를 돌려준다. 등록비는 공개 확정 때
* Burn 되므로(B-57) 아직 확정되지 않았다.
* - 공개 후 첫 후원 전(`funding` · 후원 0건) — 철회는 되지만 **생성자 변심이라 등록비는
* 비환불**이다.
* - 첫 후원 뒤 — 개설자 단독으로는 못 한다. 운영 승인 경로다(E-PERM-01).
*
* 판정과 상태 전이를 한 트랜잭션에 둔다. 후원(`fn-campaign-fund`)도 같은 문서의 `status`
* 를 트랜잭션으로 읽으므로 「후원 0건이라 철회」와 「첫 후원」이 함께 서지 않는다.
*/
export const fnCampaignWithdraw = onCall(async (req) =>
withdrawCampaign(callerUid(req), str(req.data, 'campaignId')));
export async function withdrawCampaign(
uid: string, campaignId: string,
): Promise<{ status: 'withdrawn'; feeRefundTxIds: string[] }> {
const db = getFirestore();
const ref = db.doc(`campaigns/${campaignId}`);
await db.runTransaction(async (tx) => {
const snap = await tx.get(ref);
if (!snap.exists) fail(ERR.campaignNotApproved, '캠페인을 찾을 수 없습니다.');
if (snap.get('creatorUid') !== uid) fail(ERR.permissionDenied, '개설자만 철회할 수 있습니다.');
const status = snap.get('status') as string | undefined;
if (status === 'withdrawn') return; // 재시도 — 남은 반환만 아래에서 마저 한다.
const beforePublish = status === 'draft' || status === 'review';
const noDonation = status === 'funding'
&& ((snap.get('donorCount') as number | undefined) ?? 0) === 0
&& ((snap.get('raisedPop') as number | undefined) ?? 0) === 0;
if (!beforePublish && !noDonation) {
fail(ERR.permissionDenied, '첫 후원 뒤에는 운영 승인 없이 철회할 수 없습니다.');
}
tx.update(ref, {
status: 'withdrawn',
withdrawnAt: FieldValue.serverTimestamp(),
...(beforePublish ? { feeRefund: 'due' } : {}),
updatedAt: FieldValue.serverTimestamp(),
});
});
return { status: 'withdrawn', feeRefundTxIds: await settleFeeRefund(campaignId) };
}
@@ -0,0 +1,564 @@
import { HttpsError, onCall } from 'firebase-functions/v2/https';
import { onSchedule } from 'firebase-functions/v2/scheduler';
import {
getFirestore, FieldPath, FieldValue, Timestamp, type DocumentSnapshot,
} from 'firebase-admin/firestore';
import { getStorage } from 'firebase-admin/storage';
import { logger } from 'firebase-functions';
import { callerUid, requireRole, str } from '../core/callable.js';
import { CatalogError, ERR, fail } from '../core/errors.js';
import { refundPop } from '../ledger/ledger.js';
import { retryFeeRefunds, settleFeeRefund } from './create.js';
/**
* 캠페인 정산과 환불 — 별첨 1 ⑤.
*
* 판정 기준이 「성공/실패 분기 정확 · 자동환불 무결성(**부분 실패 방치·중복 환불
* 0건**) · 결과보고 게시 확인」이다. 부분 실패를 방치하지 않는 것이 핵심이라
* 실패한 건을 세어 남기고 Admin 큐로 넘긴다.
*
* 환불 기한은 정책 §P-09 가 **마감 후 72시간**으로 확정했다.
*/
const REFUND_DEADLINE_HOURS = 72;
/**
* 자동 환불 패스 상한. 실패가 남은 패스가 이만큼 쌓이면 Admin Exception Queue 로 넘기고
* 자동 재시도를 멈춘다(원장 B-36 「부분 실패는 자동 재시도 후 Admin Exception Queue」).
*/
export const MAX_AUTO_REFUND_ATTEMPTS = 3;
/** 한 회차에 처리하는 후원 수. 넘으면 커서를 남기고 다음 회차가 이어 간다. */
export const REFUND_PAGE = 200;
/** 함수 제한 시간과 리스. 리스가 제한 시간보다 길어야 죽은 회차의 잠금이 풀린 뒤에만 겹친다. */
const TIMEOUT_SECONDS = 540;
const LEASE_MS = (TIMEOUT_SECONDS + 60) * 1000;
/**
* 후원을 돌려주는 캠페인 상태. `fail` 이 규칙·앱의 표기이고 `failed` 는 예전 정산이 쓴
* 값이다. `cancelled` 는 회사귀책 취소(B-58 · `fn-campaign-admin-cancel`)다.
*/
const FAILED = ['fail', 'failed', 'cancelled'];
/** 규칙·설계서의 스키마 이름은 `targetPop` 이다. `goalPop` 은 옛 이름이라 보조로만 읽는다. */
const goalOf = (snap: DocumentSnapshot): number =>
(snap.get('targetPop') as number | undefined)
?? (snap.get('goalPop') as number | undefined) ?? 0;
const raisedOf = (snap: DocumentSnapshot): number =>
(snap.get('raisedPop') as number | undefined) ?? 0;
/**
* `fn-campaign-settle` — 마감을 지난 캠페인을 성공·실패로 가르고, 실패면 **같은 회차에서**
* 환불을 시작한다(B-36 「즉시 환불 시작」). 부분 실패·미완 캠페인도 여기서 이어 돈다.
*
* 스케줄을 10분으로 둔 이유는 마감 직후의 대기 시간을 줄이기 위해서다.
* 그보다 촘촘히 돌려도 마감 시각이 분 단위라 얻는 것이 없다.
*/
export const fnCampaignSettle = onSchedule(
{ schedule: 'every 10 minutes', timeoutSeconds: TIMEOUT_SECONDS },
async () => {
const settled = await settleDueCampaigns();
const retried = await retryPendingRefunds();
const fees = await retryFeeRefunds();
logger.info('[campaign] 정산 완료', { settled, retried, fees });
},
);
/**
* 마감이 지난 `funding` 캠페인 판정. 판정한 건수를 돌려준다.
*
* **캠페인마다 트랜잭션으로 다시 읽고 판정한다.** 쿼리 결과로 판정하면 그 사이 들어온
* 후원이 빠진 모금액으로 성공·실패를 가른다. 후원(`fn-campaign-fund`)도 같은 문서를
* 트랜잭션으로 읽고 마감 뒤 후원을 거부하므로 판정 뒤에 모금액이 움직이지 않는다.
*/
export async function settleDueCampaigns(now: Timestamp = Timestamp.now()): Promise<number> {
const db = getFirestore();
const due = await db
.collection('campaigns')
.where('status', '==', 'funding')
.where('endsAt', '<=', now)
.get();
let settled = 0;
for (const doc of due.docs) {
const success = await db.runTransaction(async (tx) => {
const snap = await tx.get(doc.ref);
const endsAt = snap.get('endsAt') as Timestamp | undefined;
if (snap.get('status') !== 'funding' || !endsAt || endsAt.toMillis() > now.toMillis()) {
return null;
}
const goal = goalOf(snap);
const ok = goal > 0 && raisedOf(snap) >= goal;
tx.update(doc.ref, {
// 규칙 `campaignIsPublic()` · 앱과 같은 표기.
status: ok ? 'success' : 'fail',
settledAt: FieldValue.serverTimestamp(),
// 실패면 환불 기한을 함께 박아 둔다. 나중에 계산하면 기준이 흔들린다.
...(ok
? {}
: {
refundDueAt: Timestamp.fromMillis(
now.toMillis() + REFUND_DEADLINE_HOURS * 60 * 60 * 1000,
),
refundState: 'pending',
refundAttempts: 0,
refundPassFailed: 0,
}),
updatedAt: FieldValue.serverTimestamp(),
});
return ok;
});
if (success === null) continue;
settled += 1;
logger.info('[campaign] 정산', { id: doc.id, success });
}
return settled;
}
/**
* 환불이 끝나지 않은 실패 캠페인을 돌린다 — 방금 실패로 판정된 건도 여기서 첫 환불을 받는다.
* 자동 시도 상한을 넘긴 건은 Admin 큐에 있으므로 건드리지 않는다. 실제로 돈 건수를 돌려준다.
*/
export async function retryPendingRefunds(): Promise<number> {
// 단일 필드 조건이라 복합 색인이 필요 없다. 상태는 아래에서 거른다.
const open = await getFirestore()
.collection('campaigns')
.where('refundState', 'in', ['pending', 'partial'])
.get();
let ran = 0;
for (const doc of open.docs) {
if (!FAILED.includes(doc.get('status') as string)) continue;
if (((doc.get('refundAttempts') as number | undefined) ?? 0) >= MAX_AUTO_REFUND_ATTEMPTS) continue;
const result = await refundCampaign(doc.id, { manual: false });
if (!result.skipped) ran += 1;
}
return ran;
}
type RefundFailure = { donationId: string; error: string };
export interface RefundResult {
/** 이 회차에 새로 돌려준 건수. */
refunded: number;
failed: RefundFailure[];
/** 한 바퀴를 다 돌았는가. `false` 면 커서가 남아 다음 회차가 이어 간다. */
done: boolean;
/** 돌지 않은 이유 — 다른 회차가 잡고 있거나, 옛 정산이 실패로 잘못 적은 캠페인. */
skipped?: 'locked' | 'legacy-goal-met';
}
/**
* 실패 캠페인 환불 한 회차. 자동(스케줄)과 수동(Admin) 이 같은 코어를 지난다.
*
* - **리스** — `lockedUntil` 을 트랜잭션으로 잡은 회차만 돈다. 겹친 회차는 건너뛴다.
* - **분할** — 한 회차에 [REFUND_PAGE] 건. 커서(`refundCursor`)와 이번 바퀴의 실패 수
* (`refundPassFailed`)를 매 회차 남긴다.
* - **중복 환불 0건** — 후원 한 건의 멱등 키가 `refund:{donationId}` 하나다. 다른 경로
* (`fn-pop-refund`)가 먼저 돌려준 원거래는 그 환불을 받아들여 끝난 건으로 본다.
* - **부분 실패를 성공으로 덮지 않는다.** 실패가 남은 바퀴는 `partial` 로 남기고 자동 시도를
* 센다(`refundAttempts`). 상한에 닿거나 Admin 이 돌려도 남으면 Exception Queue
* (`adminActionLogs`)에 적는다(E-CMP-02).
* - **옛 데이터** — `refundAttempts` 가 없는 문서는 goalPop 만 읽던 예전 정산이 판정했다.
* `targetPop` 으로 다시 판정해 목표를 채운 캠페인은 돌려주지 않는다.
*/
export async function refundCampaign(
campaignId: string,
opts: { manual: boolean },
): Promise<RefundResult> {
const db = getFirestore();
const campaignRef = db.doc(`campaigns/${campaignId}`);
const lease = await db.runTransaction(async (tx) => {
const snap = await tx.get(campaignRef);
if (!snap.exists) fail(ERR.campaignNotApproved, '캠페인을 찾을 수 없습니다.');
if (!FAILED.includes(snap.get('status') as string)) {
fail(ERR.campaignNotApproved, '실패하거나 취소된 캠페인만 환불할 수 있습니다.');
}
const locked = snap.get('lockedUntil') as Timestamp | undefined;
if (locked && locked.toMillis() > Date.now()) return { skipped: 'locked' as const };
const legacy = typeof snap.get('refundAttempts') !== 'number';
if (legacy) {
const goal = goalOf(snap);
if (goal > 0 && raisedOf(snap) >= goal) return { skipped: 'legacy-goal-met' as const };
}
tx.update(campaignRef, {
lockedUntil: Timestamp.fromMillis(Date.now() + LEASE_MS),
// 다시 판정해도 실패인 옛 문서는 지금 표기로 맞춘다.
...(legacy ? { status: 'fail', refundAttempts: 0, refundPassFailed: 0 } : {}),
});
return {
title: (snap.get('title') as string | undefined) ?? '캠페인',
cancelled: snap.get('status') === 'cancelled',
cursor: (snap.get('refundCursor') as string | undefined) ?? null,
attempts: legacy ? 0 : (snap.get('refundAttempts') as number),
passFailed: legacy ? 0 : ((snap.get('refundPassFailed') as number | undefined) ?? 0),
};
});
if ('skipped' in lease) {
logger.warn('[campaign] 환불 건너뜀', { campaignId, reason: lease.skipped });
return { refunded: 0, failed: [], done: false, skipped: lease.skipped };
}
let query = campaignRef.collection('donations')
.orderBy(FieldPath.documentId())
.limit(REFUND_PAGE);
if (lease.cursor) query = query.startAfter(lease.cursor);
const page = await query.get();
const failed: RefundFailure[] = [];
let refunded = 0;
for (const donation of page.docs) {
const uid = donation.get('uid') as string | undefined;
let refundTxId = donation.get('refundTxId') as string | undefined;
if (!refundTxId) {
const txId = donation.get('txId') as string | undefined;
if (!uid || !txId) {
failed.push({ donationId: donation.id, error: 'missing-uid-or-tx' });
continue;
}
try {
refundTxId = (await refundPop({
uid,
originalTxId: txId,
// 후원 한 건당 한 번. 재실행해도 두 번 돌려주지 않는다.
idempotencyKey: `refund:${donation.id}`,
reasonCode: 'CAMPAIGN_REFUND',
})).txId;
} catch (error) {
if (error instanceof CatalogError && error.catalogCode === ERR.popDuplicate) {
// 다른 경로가 이미 돌려준 원거래 — 그 환불을 이 후원의 환불로 받아들인다.
refundTxId = (await db.doc(`users/${uid}/popTransactions/${txId}`).get())
.get('refundedBy') as string;
} else {
failed.push({ donationId: donation.id, error: String(error) });
await donation.ref.set({ refundError: String(error) }, { merge: true });
continue;
}
}
await donation.ref.set({
refundTxId,
refundedAt: FieldValue.serverTimestamp(),
refundError: FieldValue.delete(),
}, { merge: true });
refunded += 1;
}
// 환불과 알림 사이에 죽어도 다음 회차가 알림만 다시 보낸다(문서 ID = 환불 거래).
if (uid && !donation.get('refundNotifiedAt')) {
await notifyOnce(uid, `campaignRefund_${refundTxId}`, {
title: lease.cancelled
? `「${lease.title}」 캠페인이 취소되어 후원 POP 을 돌려드렸습니다`
: `「${lease.title}」 목표 미달로 후원 POP 을 돌려드렸습니다`,
campaignId,
});
await donation.ref.set({ refundNotifiedAt: FieldValue.serverTimestamp() }, { merge: true });
}
}
const passFailed = lease.passFailed + failed.length;
if (page.size === REFUND_PAGE) {
// 아직 남았다 — 진행 상황만 남기고 잠금을 푼다. 다음 회차가 커서부터 잇는다.
await campaignRef.update({
refundCursor: page.docs[page.docs.length - 1]!.id,
refundPassFailed: passFailed,
refundLastRunAt: FieldValue.serverTimestamp(),
lockedUntil: FieldValue.delete(),
updatedAt: FieldValue.serverTimestamp(),
});
return { refunded, failed, done: false };
}
const counted = passFailed > 0 && !opts.manual ? 1 : 0;
const toQueue = passFailed > 0
&& (opts.manual || lease.attempts + counted >= MAX_AUTO_REFUND_ATTEMPTS);
const refundedCount = (await campaignRef.collection('donations')
.where('refundTxId', '!=', null).count().get()).data().count;
await campaignRef.update({
refundState: passFailed === 0 ? 'done' : 'partial',
refundedCount,
refundFailedCount: passFailed,
refundAttempts: FieldValue.increment(counted),
refundCursor: FieldValue.delete(),
refundPassFailed: 0,
refundLastRunAt: FieldValue.serverTimestamp(),
lockedUntil: FieldValue.delete(),
...(passFailed === 0 ? { refundedAt: FieldValue.serverTimestamp() } : {}),
...(toQueue ? { refundQueuedAt: FieldValue.serverTimestamp() } : {}),
updatedAt: FieldValue.serverTimestamp(),
});
if (toQueue) {
await db.collection('adminActionLogs').add({
actorUid: 'system',
targetPath: campaignRef.path,
reason: '캠페인 환불 부분 실패 — 수동 복구 필요',
// 건별 사유는 후원 문서의 `refundError` 에 남아 있다.
patch: { failedCount: passFailed, attempts: lease.attempts + counted, lastRun: failed },
createdAt: FieldValue.serverTimestamp(),
});
logger.error('[campaign] 환불 부분 실패 — Admin 큐', { campaignId, passFailed });
} else if (passFailed > 0) {
logger.warn('[campaign] 환불 부분 실패 — 자동 재시도 예정', { campaignId, passFailed });
}
return { refunded, failed, done: true };
}
/**
* 알림함 한 건. 문서 ID 를 고정해 재실행이 같은 알림을 두 번 쌓지 않는다.
* `type` 은 B-77 수신 설정 축, `eventType` 은 알림함 이동 축이다(앱 `NotificationKind`).
*/
async function notifyOnce(
uid: string,
id: string,
data: { title: string; campaignId: string },
): Promise<void> {
try {
await getFirestore().doc(`users/${uid}/notifications/${id}`).create({
type: 'essential',
eventType: 'campaign',
...data,
read: false,
createdAt: FieldValue.serverTimestamp(),
});
} catch (error) {
// 이미 보낸 알림(ALREADY_EXISTS = 6)은 정상이다. 그 밖의 실패도 환불을 되돌리지 않는다.
if ((error as { code?: number }).code !== 6) {
logger.error('[campaign] 알림 기록 실패', { uid, id, message: (error as Error).message });
}
}
}
/**
* `fn-campaign-refund-batch` — Admin 수동 복구. 환불은 정산이 자동으로 시작하므로
* 이 호출은 Exception Queue 에 오른 건을 다시 돌리는 경로다. `done: false` 면 다시 부른다.
*/
export const fnCampaignRefundBatch = onCall({ timeoutSeconds: TIMEOUT_SECONDS }, async (req) => {
callerUid(req);
requireRole(req, ['master', 'ops']);
return refundCampaign(str(req.data, 'campaignId'), { manual: true });
});
/** 결과보고 사진·영수증 상한 — 원장 B-44 「이미지 5장 · 10MB」. 용량·형식은 Storage 규칙이 본다. */
export const REPORT_MAX_PHOTOS = 5;
const REPORT_MAX_CHARS = 5000;
const BUDGET_MAX_LINES = 50;
const FILE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
const CAMPAIGN_ID = /^[A-Za-z0-9_-]{1,128}$/;
export interface BudgetLine { label: string; amount: number }
/**
* `fn-campaign-report-submit` — 성공 캠페인 결과보고(원장 B-35 · FR-ADM-05 · FR-CMP-06).
*
* **운영(master·ops)만 올린다.** B-35 는 funded 이후 집행·증빙·결과보고를 Admin 수동 운영으로
* 두었다. 집행 사진 · 영수증 · 예산 사용 내역을 함께 받아 보고와 상태 `report` 를 한
* 트랜잭션에 쓰고, 후원자 전원의 알림함에 안내를 남긴다.
*
* 파일은 URL 이 아니라 **Storage 경로**로 받는다. 경로가 정확히 `campaignReports/{id}/{파일}`
* 이고(상위 이동 · 하위 폴더 없음) 실제로 올라가 있어야 한다.
*/
export const fnCampaignReportSubmit = onCall({ timeoutSeconds: 120 }, async (req) => {
const uid = callerUid(req);
requireRole(req, ['master', 'ops']);
const data = req.data as Record<string, unknown> | null;
return submitCampaignReport({
uid,
campaignId: str(data, 'campaignId'),
body: str(data, 'body'),
photoPaths: data?.photoPaths,
receiptPaths: data?.receiptPaths,
budget: data?.budget,
});
});
const storageExists = async (path: string): Promise<boolean> =>
(await getStorage().bucket().file(path).exists())[0];
function reportPaths(raw: unknown, campaignId: string, what: string): string[] {
const prefix = `campaignReports/${campaignId}/`;
const ok = Array.isArray(raw) && raw.length >= 1 && raw.length <= REPORT_MAX_PHOTOS
&& new Set(raw).size === raw.length
&& raw.every((p) => typeof p === 'string' && p.startsWith(prefix)
&& FILE_NAME.test(p.slice(prefix.length)));
if (!ok) throw new HttpsError('invalid-argument', `${what}을 1~${REPORT_MAX_PHOTOS}장 올려 주세요.`);
return raw as string[];
}
function budgetLines(raw: unknown): BudgetLine[] {
const ok = Array.isArray(raw) && raw.length >= 1 && raw.length <= BUDGET_MAX_LINES
&& raw.every((l) => typeof l?.label === 'string'
&& l.label.trim().length >= 1 && l.label.trim().length <= 100
&& Number.isSafeInteger(l.amount) && l.amount > 0);
if (!ok) {
throw new HttpsError('invalid-argument', `예산 사용 내역을 1~${BUDGET_MAX_LINES}줄(항목 · 금액) 적어 주세요.`);
}
return (raw as BudgetLine[]).map((l) => ({ label: l.label.trim(), amount: l.amount }));
}
export async function submitCampaignReport(
input: {
uid: string; campaignId: string; body: string;
photoPaths: unknown; receiptPaths: unknown; budget: unknown;
},
exists: (path: string) => Promise<boolean> = storageExists,
): Promise<{ duplicate: boolean }> {
const { uid, campaignId } = input;
if (!CAMPAIGN_ID.test(campaignId)) throw new HttpsError('invalid-argument', 'campaignId 가 올바르지 않습니다.');
const body = input.body.trim();
if (body.length === 0 || body.length > REPORT_MAX_CHARS) {
throw new HttpsError('invalid-argument', `본문은 1~${REPORT_MAX_CHARS}자입니다.`);
}
const photoPaths = reportPaths(input.photoPaths, campaignId, '집행 사진');
const receiptPaths = reportPaths(input.receiptPaths, campaignId, '영수증');
const budget = budgetLines(input.budget);
for (const path of [...photoPaths, ...receiptPaths]) {
if (!(await exists(path))) throw new HttpsError('invalid-argument', '올라가지 않은 파일이 있습니다.');
}
const db = getFirestore();
const campaignRef = db.doc(`campaigns/${campaignId}`);
const reportRef = campaignRef.collection('reports').doc('main');
const duplicate = await db.runTransaction(async (tx) => {
const campaign = await tx.get(campaignRef);
if (!campaign.exists) fail(ERR.campaignNotApproved, '캠페인을 찾을 수 없습니다.');
if (campaign.get('status') === 'report') return true;
if (campaign.get('status') !== 'success') {
fail(ERR.campaignNotApproved, '성공한 캠페인만 결과보고를 올릴 수 있습니다.');
}
tx.create(reportRef, {
authorUid: uid,
body,
photoPaths,
receiptPaths,
budget,
budgetTotal: budget.reduce((sum, l) => sum + l.amount, 0),
createdAt: FieldValue.serverTimestamp(),
});
tx.update(campaignRef, {
status: 'report',
reportedAt: FieldValue.serverTimestamp(),
updatedAt: FieldValue.serverTimestamp(),
});
return false;
});
// 게시와 알림 사이에 죽었으면 재호출이 알림만 다시 보낸다(문서 ID = 보고 ID).
const report = await reportRef.get();
if (!report.get('notifiedAt')) {
const title = ((await campaignRef.get()).get('title') as string | undefined) ?? '캠페인';
const donations = await campaignRef.collection('donations').get();
const donors = new Set(donations.docs
.map((d) => d.get('uid') as string | undefined)
.filter((u): u is string => !!u));
for (const donor of donors) {
await notifyOnce(donor, `campaignReport_${campaignId}_${reportRef.id}`, {
title: `후원하신 「${title}」 결과보고가 올라왔습니다`,
campaignId,
});
}
await reportRef.update({ notifiedAt: FieldValue.serverTimestamp() });
}
return { duplicate };
}
/**
* `fn-campaign-admin-review` — 승인·반려.
*
* **승인 전에는 모금이 열리지 않는다**(`fn-campaign-fund` 가 E-CMP-03 로 막는다).
* 여기서 `funding` 으로 올려야 후원이 시작된다. 반려는 등록비를 원래 타입으로 돌려준다
* (B-58). 반환이 중간에 실패하면 같은 요청의 재시도가 마저 한다.
*/
export const fnCampaignAdminReview = onCall(async (req) => {
const actorUid = callerUid(req);
requireRole(req, ['master', 'ops']);
const campaignId = str(req.data, 'campaignId');
const requestId = str(req.data, 'requestId');
const approve = (req.data as Record<string, unknown>)?.approve === true;
const memo = str(req.data, 'memo');
const db = getFirestore();
const seen = db.doc(`campaigns/${campaignId}/reviews/${requestId}`);
const campaignRef = db.doc(`campaigns/${campaignId}`);
// 앞선 반려의 반환이 중간에 끊겼으면 여기서 마저 한다 — 상태 판정보다 먼저.
await settleFeeRefund(campaignId);
// 판정과 전이를 한 트랜잭션에 둔다. 개설자 철회(`fn-campaign-withdraw`)도 같은 문서를
// 트랜잭션으로 읽으므로 승인과 철회가 함께 서지 않는다.
const result = await db.runTransaction(async (tx) => {
const [review, campaign] = await Promise.all([tx.get(seen), tx.get(campaignRef)]);
// 같은 요청이 두 번 들어와도 판정을 뒤집지 않는다.
if (review.exists) return { duplicate: true };
if (!campaign.exists) fail(ERR.campaignNotApproved, '캠페인을 찾을 수 없습니다.');
if (campaign.get('status') !== 'review') {
fail(ERR.campaignNotApproved, '검토 대기 상태가 아닙니다.');
}
tx.update(campaignRef, {
status: approve ? 'funding' : 'rejected',
// 반려는 등록비 반환(B-58). 전이와 같은 쓰기에 표시해 두면 반환이 끊겨도 스케줄이 잇는다.
...(approve ? {} : { feeRefund: 'due' }),
reviewedBy: actorUid,
reviewMemo: memo,
updatedAt: FieldValue.serverTimestamp(),
});
tx.create(seen, { actorUid, approve, memo, createdAt: FieldValue.serverTimestamp() });
return { duplicate: false, status: approve ? 'funding' : 'rejected' };
});
if (!approve) await settleFeeRefund(campaignId);
return result;
});
/**
* `fn-campaign-admin-cancel` — 회사귀책 취소(B-58).
*
* 심사 중 · 모금 중 캠페인을 `cancelled` 로 닫고 **등록비와 후원 전액**을 돌려준다.
* 후원 환불은 실패 캠페인과 같은 코어(`refundCampaign` — 건당 멱등 · 분할 · 부분 실패 큐)를
* 지나며, 한 회차에 끝나지 않으면 `fn-campaign-settle` 이 이어 돈다. 개설자에게 알린다.
* 이미 취소된 캠페인에 다시 부르면 남은 반환만 이어 간다.
*/
export const fnCampaignAdminCancel = onCall({ timeoutSeconds: TIMEOUT_SECONDS }, async (req) => {
const actorUid = callerUid(req);
requireRole(req, ['master', 'ops']);
return cancelCampaign(str(req.data, 'campaignId'), actorUid, str(req.data, 'reason'));
});
export async function cancelCampaign(
campaignId: string, actorUid: string, reason: string,
): Promise<{ status: 'cancelled'; refund: RefundResult }> {
const db = getFirestore();
const campaignRef = db.doc(`campaigns/${campaignId}`);
const creator = await db.runTransaction(async (tx) => {
const snap = await tx.get(campaignRef);
if (!snap.exists) fail(ERR.campaignNotApproved, '캠페인을 찾을 수 없습니다.');
const status = snap.get('status') as string | undefined;
if (status !== 'cancelled') {
if (status !== 'review' && status !== 'funding') {
fail(ERR.campaignNotApproved, '심사 중이거나 모금 중인 캠페인만 취소할 수 있습니다.');
}
tx.update(campaignRef, {
status: 'cancelled',
cancelReason: reason,
cancelledBy: actorUid,
cancelledAt: FieldValue.serverTimestamp(),
feeRefund: 'due',
refundState: 'pending',
refundAttempts: 0,
refundPassFailed: 0,
updatedAt: FieldValue.serverTimestamp(),
});
}
return {
uid: snap.get('creatorUid') as string | undefined,
title: (snap.get('title') as string | undefined) ?? '캠페인',
};
});
await settleFeeRefund(campaignId);
if (creator.uid) {
await notifyOnce(creator.uid, `campaignCancel_${campaignId}`, {
title: `「${creator.title}」 캠페인이 운영 사유로 취소되어 등록비를 돌려드렸습니다`,
campaignId,
});
}
return { status: 'cancelled', refund: await refundCampaign(campaignId, { manual: false }) };
}
+223
View File
@@ -0,0 +1,223 @@
import { getFirestore } from 'firebase-admin/firestore';
/**
* 운영값 읽기 — 설계서 §10 「미확정 수치는 하드코딩하지 않고 `config` 컬렉션으로 뺐습니다」.
*
* 정책 설계서에서 아직 확정되지 않은 값은 전부 이 경로를 거친다. 코드에 박으면
* 발주사 회신이 올 때마다 재배포해야 하고, 그 사이 값이 문서와 어긋난다.
*/
export interface PolicyConfig {
/** 출석 1회 보상 활동 POP. */
attendPop: number;
/**
* 사용자 생성 Quest 의 광고 추가 보상 — 원장 **C-02** 고정형 **기여 POP** / 회.
* 기본 보상액에 비례하지 않고 회사 재원이다(B-28).
*/
adRewardPop: number;
/**
* 운영(관리자) Quest 의 광고 배수 — 원장 **B-03** 기본 x2. 지정 Quest 는
* `quests/{id}.adMultiplier`(예: 3)가 덮는다. 지급은 **증분만**(기본 보상 × (배수 − 1))
* 기여 POP 이다.
*/
adMultiplier: number;
/**
* POP 묶음 상품(`pop.support.*`) 판매 스위치 — 원장 **B-02** 「POP 자체 직접 판매 없음」
* 이라 기본 false. 켜지 않으면 `fn-iap-verify` 가 E-PAY-01 로 거부한다.
*/
popPackSalesEnabled: boolean;
/** Focus 구독 일일 Claim 기여 POP — 원장 **B-04** 확정 40. */
focusDailyPop: number;
/** 광고 보상 일일 상한 횟수 — E-AD-02 판정 기준. */
adDailyLimit: number;
/** 만보기 완주 걸음 수. `stepTiers` 가 없는 문서를 위한 하위 호환값이다. */
stepsGoal: number;
/** 만보기 완주 보상 활동 POP. 같은 이유로 남긴다. */
stepsPop: number;
/**
* 걸음 구간표 — 정책 원장 **B-26** 확정값.
*
* *1 천/5 천/1 만/1.5 만보 = 일반 POP 5/6/7/8, 전 단계 순차클리어.* 구간을 건너뛰고
* 윗 구간만 받는 일이 없도록 **아랫 구간부터 차례로** 지급한다. 🟡 보상액은 운영값이라
* `config/policy` 가 덮는다.
*/
stepTiers: StepTier[];
/** Zap 1회 기여 POP — Figma 「Zap 버튼」이 어드민 설정값이라 명시한 값. */
zapPop: number;
/** FPI 집계 창 — 설계서 §4.2 최근 7일 Rolling Window. */
fpiWindowDays: number;
/**
* FPI 지표별 가중치.
*
* **초기값은 전부 1.0 이다** — 정책 설계서 §P-03 「초기 MVP는 단순 합산」(PRD-03).
* 가중 평균이 아니므로 합이 1일 필요가 없다. 시즌 고도화 때 Admin 에서 조정한다.
*/
fpiWeights: Record<FpiAxis, number>;
/** 검증 통과 번역 1건 보상 일반 POP — 정책 원장 B-63 확정값 5. 🟡 금액은 운영값. */
translationPop: number;
/** 번역 보상 일일 상한(건). B-63 「일일상한은 운영설정」 — 초과분은 등록되지만 보상 0. */
translationDailyLimit: number;
/** 확정에 필요한 최소 유효 마킹 수(작성자 제외). 🟡 발주사 회신 전 제안값 1. */
translationMinValidMarks: number;
/** 이 시간이 지나도 확정 조건을 못 채우면 보상을 해제하고 닫는다. 🟡 제안값 72. */
translationMaxPendingHours: number;
/** INSIDE 번역 마킹을 팬덤 밖 회원에게도 여는가. 🟡 Q-TR-04 권고 B안 true · A안이면 false. */
translationInsideAnyMember: boolean;
/** 사용자 생성 Quest 최대 기간(일) — 공개일을 1일째로 센다(C-06). 🟡 운영값. */
ugcMaxDays: number;
/** 종료일 선택 시 공개 시각부터 확보해야 하는 최소 시간(C-06). 🟡 운영값. */
ugcMinHours: number;
/** 수수료율 · 최소 수수료(B-30 — Pool 의 10%, 최소 10 POP). */
ugcFeeRate: number;
ugcFeeMin: number;
/**
* 보상 없는 Quest(Pool 0)에도 최소 수수료를 받는가 — 해석 확인 요청 **Q-UQ-02**.
* 🟡 회신 전 권고안(A안 · 0 POP)으로 false. B안(문언대로 최소 10)은 true.
*/
ugcFeeOnNoReward: boolean;
/** 보상 없는 Quest 의 1인 하루 생성 상한 — 수수료 0 의 도배 방지. 🟡 운영값 3. */
ugcFreeQuestDailyCap: number;
/** 생성 자격 — 가입 경과 시간(B-29 · 24h). 검증 활동 1회는 원장에서 본다. */
ugcMinAccountHours: number;
/**
* 생성 자격 — 「검증 활동」(서버가 인정한 Quest 완료 · 보상 무관 · 누적) 최소 횟수(B-29 · 1회).
* 정의는 해석 확인 요청 Campaign 개설자격 A안. Campaign 쪽 5회는 `config/campaign.minVerifiedQuests`.
*/
ugcMinVerifiedQuests: number;
/** 사람 검토 목표 시간(C-08 · 주말 포함 24h). 지연 안내 기준이며 자동 승인은 없다. */
ugcReviewHours: number;
/** 긴급 돌발 타임어택 1회 보상 상한(활동 POP). 🟡 기준자료에 수치가 없어 보수적 출발점 1,000. */
timeAttackMaxPop: number;
/**
* 타임어택 참여 1건의 FPI 축 — 해석 확인 요청 **Q-TA-05**. 🟡 회신 전 권고안(A안) `community`
* (출석과 같은 축 · C-35). null 이면 B안 — FPI 에 넣지 않고 User EXP 만 준다.
*/
timeAttackFpiAxis: FpiAxis | null;
/**
* HUD 「LIVE」 = 최근 이 분(分) 안에 앱을 쓴 팬덤 멤버 수 — 해석 확인 요청 **Q-GM-03** A안. 🟡 운영값 5.
*/
liveWindowMinutes: number;
/** 추천 보상 — 원장 **B-69** 확정 추천인 일반 POP 100 · 피추천인 20. 피추천인 최초 검증 활동 뒤 지급. */
referralReferrerPop: number;
referralReferredPop: number;
/** 추천인 1명이 하루에 보상 대상으로 받을 수 있는 귀속 수(B-71 어뷰징). 넘으면 `capped`. 🟡 운영값 5. */
referralDailyCapPerReferrer: number;
/** 가입 후 추천 코드를 등록할 수 있는 기간(일). 🟡 운영값 7. */
referralAttachDays: number;
/** 긴급 돌발 타임어택 지속 시간 상한(분) — 원장 「최대 24시간」. 🟡 운영값. */
timeAttackMaxMinutes: number;
}
/**
* FPI 산출 지표 — 정책 설계서 §P-03 확정값(답변서 v1.1 Q3).
*
* 2주차 설계서가 적었던 `action·content·contribution·community·growth` 를
* **대체한다.** 두 이름이 공존하면 집계 축과 화면 표기가 갈린다.
*/
/** 걸음 구간 하나 — 도달 걸음 수와 그 구간의 보상. */
export interface StepTier {
steps: number;
pop: number;
}
export type FpiAxis = 'bpm' | 'health' | 'referral' | 'community' | 'contribution';
export const FPI_AXES: readonly FpiAxis[] = [
'bpm', 'health', 'referral', 'community', 'contribution',
];
/**
* 기본값. **정책 확정값이 아니라 config 문서가 없을 때의 안전한 출발점**이다.
* 운영에서는 `/config/policy` 문서가 항상 이 값을 덮는다.
*/
export const DEFAULT_POLICY: PolicyConfig = {
attendPop: 10,
adRewardPop: 10,
adMultiplier: 2,
popPackSalesEnabled: false,
focusDailyPop: 40,
adDailyLimit: 5,
stepsGoal: 10_000,
stepsPop: 50,
stepTiers: [
{ steps: 1_000, pop: 5 },
{ steps: 5_000, pop: 6 },
{ steps: 10_000, pop: 7 },
{ steps: 15_000, pop: 8 },
],
zapPop: 10,
fpiWindowDays: 7,
// 단순 합산이 초기 정책이므로 전부 1.0 이다.
fpiWeights: {
bpm: 1, health: 1, referral: 1, community: 1, contribution: 1,
},
translationPop: 5,
// 🟡 운영값. 원장이 수치를 정하지 않아 보수적으로 둔 출발점이다.
translationDailyLimit: 10,
translationMinValidMarks: 1,
translationMaxPendingHours: 72,
translationInsideAnyMember: true,
ugcMaxDays: 30,
ugcMinHours: 1,
ugcFeeRate: 0.1,
ugcFeeMin: 10,
ugcFeeOnNoReward: false,
ugcFreeQuestDailyCap: 3,
ugcMinAccountHours: 24,
ugcMinVerifiedQuests: 1,
ugcReviewHours: 24,
timeAttackMaxPop: 1000,
timeAttackFpiAxis: 'community',
liveWindowMinutes: 5,
referralReferrerPop: 100,
referralReferredPop: 20,
referralDailyCapPerReferrer: 5,
referralAttachDays: 7,
timeAttackMaxMinutes: 1440,
};
export async function loadPolicy(): Promise<PolicyConfig> {
const snap = await getFirestore().doc('config/policy').get();
if (!snap.exists) return DEFAULT_POLICY;
const raw = snap.data() ?? {};
const weights = { ...DEFAULT_POLICY.fpiWeights, ...(raw.fpiWeights ?? {}) };
return {
...DEFAULT_POLICY,
...raw,
// 가중치는 부분 지정이 흔하므로 축별로 병합한다. 통째로 덮으면 축이 사라진다.
fpiWeights: weights,
// 구간표는 통째로 덮는다. 섞으면 운영에서 지운 구간이 되살아난다.
stepTiers: normalizeTiers(raw.stepTiers) ?? DEFAULT_POLICY.stepTiers,
} as PolicyConfig;
}
/**
* 운영 문서의 구간표를 쓸 수 있는 형태로 만든다.
*
* **오름차순을 보장한다.** 순서가 뒤섞인 표를 그대로 쓰면 「아랫 구간부터 순차
* 클리어」가 깨져 윗 구간이 먼저 지급된다(B-26 검증 기준).
*/
export function normalizeTiers(raw: unknown): StepTier[] | null {
if (!Array.isArray(raw) || raw.length === 0) return null;
const tiers: StepTier[] = [];
for (const row of raw) {
const steps = (row as StepTier)?.steps;
const pop = (row as StepTier)?.pop;
if (typeof steps !== 'number' || typeof pop !== 'number') return null;
if (!Number.isFinite(steps) || steps <= 0 || pop < 0) return null;
tiers.push({ steps, pop });
}
return tiers.sort((a, b) => a.steps - b.steps);
}
/**
* 가중치가 쓸 수 있는 값인지 확인한다.
*
* 합이 1인지는 보지 않는다 — 단순 합산 정책에서는 합이 5다. 대신 **축이 빠졌거나
* 음수인지**를 본다. 음수 가중치는 활동할수록 점수가 깎이는 뒤집힌 랭킹을 만든다.
*/
export function weightsAreUsable(weights: Record<FpiAxis, number>): boolean {
return FPI_AXES.every((axis) => {
const w = weights[axis];
return typeof w === 'number' && Number.isFinite(w) && w >= 0;
});
}
+68 -4
View File
@@ -7,8 +7,42 @@ import { HttpsError } from 'firebase-functions/v2/https';
* 카탈로그에 먼저 등재한 뒤 여기에 더한다.
*/
export const ERR = {
popInsufficient: 'E-POP-01',
popVerifyFailed: 'E-POP-02',
popDuplicate: 'E-POP-03',
questIncomplete: 'E-QST-01',
questAbnormal: 'E-QST-02',
questOutOfRange: 'E-QST-03',
// 보상 없는 사용자 생성 Quest 하루 상한(`ugcFreeQuestDailyCap`).
ugcFreeDailyCap: 'E-QST-04',
adLoadFailed: 'E-AD-01',
adLimitExceeded: 'E-AD-02',
eclipseClosed: 'E-ECL-01',
campaignOverGoal: 'E-CMP-01',
campaignPartialFail: 'E-CMP-02',
campaignNotApproved: 'E-CMP-03',
// 검증 활동(Quest 완료) 누적 수 미달 — 해석 확인 요청 Campaign 개설자격 A안.
campaignNotEligible: 'E-CMP-04',
qnaSelfAnswer: 'E-QNA-01',
qnaBountyExpired: 'E-QNA-02',
qnaAlreadyAccepted: 'E-QNA-03',
payInvalidReceipt: 'E-PAY-01',
payVerifyFailed: 'E-PAY-02',
paySubscription: 'E-PAY-03',
fandomNameTaken: 'E-FDM-01',
nebulaDailyLimit: 'E-FDM-02',
nebulaNotReady: 'E-FDM-03',
primaryCooldown: 'E-FDM-04',
// 성운 창립자는 행성 탄생 전 탈퇴 불가(해석 확인 요청 Q-PN-03 A안).
nebulaFounderLeave: 'E-FDM-05',
permissionDenied: 'E-PERM-01',
authFailed: 'E-AUTH-03',
// 참여형 번역(FR-I18N-02). E-TRN-01 은 정책 설계서 §P-13 이 「본인 번역 마킹 금지」로 이미 정했다.
trnSelfMark: 'E-TRN-01',
trnDailyCap: 'E-TRN-02',
trnInvalidTarget: 'E-TRN-03',
trnNotEligible: 'E-TRN-04',
trnDuplicate: 'E-TRN-05',
} as const;
export type ErrorCode = (typeof ERR)[keyof typeof ERR];
@@ -18,14 +52,44 @@ type Status = 'invalid-argument' | 'failed-precondition' | 'already-exists'
| 'permission-denied' | 'not-found' | 'resource-exhausted' | 'internal';
const STATUS: Record<ErrorCode, Status> = {
'E-POP-01': 'failed-precondition',
'E-POP-02': 'internal',
'E-POP-03': 'already-exists',
'E-QST-01': 'failed-precondition',
'E-QST-02': 'permission-denied',
'E-QST-03': 'failed-precondition',
'E-QST-04': 'resource-exhausted',
'E-AD-01': 'internal',
'E-AD-02': 'resource-exhausted',
'E-ECL-01': 'failed-precondition',
'E-CMP-01': 'failed-precondition',
'E-CMP-02': 'internal',
'E-CMP-03': 'failed-precondition',
'E-CMP-04': 'failed-precondition',
'E-QNA-01': 'failed-precondition',
'E-QNA-02': 'failed-precondition',
'E-QNA-03': 'failed-precondition',
'E-PAY-01': 'invalid-argument',
'E-PAY-02': 'failed-precondition',
'E-PAY-03': 'failed-precondition',
'E-FDM-01': 'already-exists',
'E-FDM-02': 'resource-exhausted',
'E-FDM-03': 'failed-precondition',
'E-FDM-04': 'failed-precondition',
'E-FDM-05': 'failed-precondition',
'E-PERM-01': 'permission-denied',
'E-AUTH-03': 'permission-denied',
'E-TRN-01': 'permission-denied',
'E-TRN-02': 'resource-exhausted',
'E-TRN-03': 'not-found',
'E-TRN-04': 'permission-denied',
'E-TRN-05': 'already-exists',
};
/** 카탈로그 ID 를 그대로 실은 오류. `details.code` 가 계약면이다. */
export class CatalogError extends HttpsError {
constructor(readonly catalogCode: ErrorCode, message: string) {
super(STATUS[catalogCode], message, { code: catalogCode });
constructor(readonly catalogCode: ErrorCode, message: string, extra: Record<string, unknown> = {}) {
super(STATUS[catalogCode], message, { ...extra, code: catalogCode });
}
}
@@ -36,8 +100,8 @@ export class CatalogError extends HttpsError {
* => {...}` 형태로는 호출 이후를 도달 불가로 좁히지 못한다. 그러면 호출 뒤의
* 코드가 「값이 없을 수도 있다」로 남아 불필요한 널 검사가 번진다.
*/
export function fail(code: ErrorCode, message: string): never {
throw new CatalogError(code, message);
export function fail(code: ErrorCode, message: string, extra?: Record<string, unknown>): never {
throw new CatalogError(code, message, extra);
}
/**
+27
View File
@@ -0,0 +1,27 @@
import { createHash } from 'node:crypto';
/**
* 원장 문서 ID — 설계서 §3.3 「`txId = SHA-256(idempotencyKey)` 의 앞 20자」.
*
* 조건문으로 중복을 막지 않는 것이 핵심이다. 조회와 쓰기 사이에 동시 요청이
* 끼어들 수 있으므로, 문서 ID 충돌을 Firestore 가 원자적으로 판정하게 한다.
*/
export const txIdOf = (idempotencyKey: string): string =>
createHash('sha256').update(idempotencyKey).digest('hex').slice(0, 20);
/** lot ID 는 원장과 1:1 로 묶어 대사 때 역추적할 수 있게 한다. */
export const lotIdOf = (txId: string): string => `lot_${txId}`;
/** 단말 해시 — 어뷰징 판정 입력. 원값을 저장하지 않는다. */
export const deviceHashOf = (raw: string): string =>
createHash('sha256').update(raw).digest('hex').slice(0, 32);
/**
* 브리핑 중복 판정 키 — 같은 기사를 두 번 받아도 문서가 늘지 않게 한다.
*
* 수집기 재시도는 정상 경로다. 자동 생성 ID 를 쓰면 재시도마다 카드가 늘어나고
* 관리자가 같은 기사를 세 번 승인하게 된다. 암호 용도가 아니라 중복 판정이므로
* sha1 로 충분하고, 앞 12 자면 하루치 기사 수에서 충돌하지 않는다.
*/
export const briefingHashOf = (key: string): string =>
createHash('sha1').update(key).digest('hex').slice(0, 12);
+70
View File
@@ -0,0 +1,70 @@
/**
* User Level · 칭호 · Passport Tier — 정책 원장 **C-18 · B-65 · B-66** 확정.
*
* 세 값은 **서로 다른 체계**다(B-66). `userExp → userLevel → 칭호` 가 한 줄이고,
* Passport Tier 는 별개 필드로 저장한다. 누적 기여 POP 문턱(3,000/20,000/…)으로
* 등급을 매기던 빌드 v0.3.0 의 `tierOf` 는 C-18 로 기각되어 이 파일로 대체했다(R04).
*/
export type Title = 'GLOW' | 'SPARK' | 'BEAM' | 'STARLIGHT' | 'NOVA';
export type PassportTier = 'SILVER' | 'GOLD' | 'DIAMOND';
/**
* 레벨 요구량 곡선 — **Lv.L → L+1 요구량 = 243 × L^1.5 (10 단위 반올림)**, 누적은 그 합.
*
* S01 확정 전 유지 기준(S01 제안서 A안). 새 곡선을 만들지 않고 Admin Figma 표본
* 「Lv.18 · User EXP 128,450 · Next Level 14,550 EXP」에 맞춘 식이다 — 누적 Lv.18 = 124,450,
* Lv.19 = 143,010 이라 표본에서 다음 레벨까지 14,560(표본 대비 10 차). 확정되면 이 두 상수가 바뀐다.
*/
export const EXP_CURVE_K = 243;
export const EXP_CURVE_POWER = 1.5;
/** Lv.L 에서 L+1 로 가는 데 필요한 EXP. */
export function expToNext(level: number): number {
return Math.round(EXP_CURVE_K * Math.pow(level, EXP_CURVE_POWER) / 10) * 10;
}
/** 그 레벨에 **도달하는** 누적 EXP. Lv.1 은 0 이다. */
export function expForLevel(level: number): number {
let total = 0;
for (let l = 1; l < level; l += 1) total += expToNext(l);
return total;
}
/** 누적 EXP → 레벨. 상한 없이 100 이후에도 계속 오른다(C-18). */
export function levelOf(exp: number): number {
let level = 1;
let reach = expToNext(1);
while (reach <= exp) {
level += 1;
reach += expToNext(level);
}
return level;
}
/** 레벨 구간 칭호 — 표시일 뿐 권한·보상이 붙지 않는다(C-18). */
export function titleOf(level: number): Title {
if (level >= 100) return 'NOVA';
if (level >= 60) return 'STARLIGHT';
if (level >= 30) return 'BEAM';
if (level >= 10) return 'SPARK';
return 'GLOW';
}
const PASSPORT_ORDER: readonly PassportTier[] = ['SILVER', 'GOLD', 'DIAMOND'];
/**
* Passport Tier — Silver 기본 / Gold Lv.30+ / Diamond Lv.70+(B-65 비고 · v1.6.1 4장 초기 기준, 🟡 운영값).
*
* **강등하지 않는다** — `current` 보다 낮은 값은 돌려주지 않는다. B-65 의 다른 입력
* (활동기간 · Badge · 누적기여)은 조건이 S01 미확정이라 아직 쓰지 않는다.
*/
export function passportTierOf(level: number, current?: PassportTier | null): PassportTier {
const byLevel: PassportTier = level >= 70 ? 'DIAMOND' : level >= 30 ? 'GOLD' : 'SILVER';
const kept = current && PASSPORT_ORDER.includes(current) ? current : 'SILVER';
return passportRank(byLevel) >= passportRank(kept) ? byLevel : kept;
}
export function passportRank(tier: string | undefined | null): number {
return PASSPORT_ORDER.indexOf(tier as PassportTier);
}
+13
View File
@@ -0,0 +1,13 @@
/**
* 다른 사용자에게 보이는 이름 — 공개 닉네임만 쓴다(M-7).
*
* 공급자 실명(`displayName`) · 이메일은 대체값으로 쓰지 않는다. 닉네임이 없거나 이메일
* 형태면 「팬」이다 — 빈 이름을 저장하지 않는다. 앱 `publicName` · 규칙 `publicNameOf` 와 같은 계산.
*/
export const PUBLIC_NAME_FALLBACK = '팬';
export function publicNameOf(user: { get(field: string): unknown } | null | undefined): string {
const raw = user?.get('nickname');
const v = typeof raw === 'string' ? raw.trim() : '';
return v.length === 0 || v.includes('@') ? PUBLIC_NAME_FALLBACK : v;
}
+20
View File
@@ -0,0 +1,20 @@
/**
* 일일 한도의 리셋 단위 — 설계서 `questProgress.dayKey` 「`YYYY-MM-DD`(사용자 시간대)」.
*
* 서버 UTC 로 끊으면 한국 사용자는 오전 9시에 하루가 바뀐다. 그래서 분 단위
* offset 을 받아 사용자 시간대의 날짜를 만든다.
*/
export function dayKey(at: Date, utcOffsetMinutes = 540): string {
const shifted = new Date(at.getTime() + utcOffsetMinutes * 60_000);
return shifted.toISOString().slice(0, 10);
}
/** 시간 조작 탐지 — 단말 시각이 서버 시각에서 너무 벗어나면 증거로 쓰지 않는다. */
export function isPlausibleOccurredAt(
occurredAt: Date,
now: Date,
toleranceMinutes = 15,
): boolean {
const drift = Math.abs(now.getTime() - occurredAt.getTime());
return drift <= toleranceMinutes * 60_000;
}
@@ -0,0 +1,45 @@
import { Timestamp, type DocumentReference } from 'firebase-admin/firestore';
import { dayKey } from '../core/time.js';
/**
* 2026-09-16 EC02·EC05: 누락된 집계를 0으로 채우지 않는다.
* 원장 C-26 `target = max(1, ceil(avgDau × rate))` · C-27 생성 후 완료 집계일 ≤ [windowDays] 일 평균 ·
* C-21 집계 기간은 운영값(기본 7일).
*/
export async function eclipseTarget(
fandom: DocumentReference,
createdAt: unknown,
day: string,
rate = 0.05,
windowDays = 7,
): Promise<
{ target: number; averageDau: number | null; dauDays: string[] }
| { result: 'cancelled_error'; cancelReason: string }
> {
const invalid = (cancelReason: string) => ({ result: 'cancelled_error' as const, cancelReason });
if (!Number.isFinite(rate) || rate <= 0 || rate > 1) return invalid('INVALID_TARGET_RATE');
if (!Number.isSafeInteger(windowDays) || windowDays < 1) return invalid('INVALID_DAU_WINDOW');
if (!(createdAt instanceof Timestamp)) return invalid('MISSING_FANDOM_CREATED_AT');
// 기존 scheduler/dayKey와 같은 날짜 키로 완료된 일별 집계를 조회한다.
const createdDay = dayKey(createdAt.toDate());
if (createdDay > day) return invalid('FUTURE_FANDOM_CREATED_AT');
if (createdDay === day) return { target: 1, averageDau: null, dauDays: [] };
const days: string[] = [];
for (let i = 1; i <= windowDays; i++) {
const previous = new Date(`${day}T00:00:00Z`);
previous.setUTCDate(previous.getUTCDate() - i);
const key = previous.toISOString().slice(0, 10);
if (key < createdDay) break;
days.push(key);
}
const docs = await fandom.firestore.getAll(
...days.map((key) => fandom.collection('fandomDailyStats').doc(key)),
);
if (docs.some((doc) => !doc.exists || !Number.isSafeInteger(doc.get('dau')) || doc.get('dau') < 0)) {
return invalid('MISSING_OR_INVALID_DAU');
}
const sum = docs.reduce((total, doc) => total + (doc.get('dau') as number), 0);
if (!Number.isSafeInteger(sum)) return invalid('INVALID_DAU_TOTAL');
const averageDau = sum / days.length;
return { target: Math.max(1, Math.ceil(averageDau * rate)), averageDau, dauDays: days };
}
@@ -0,0 +1,32 @@
import { onDocumentUpdated } from 'firebase-functions/v2/firestore';
/** v1.5 §2.1/§7: 확정 Stage 상승을 보존한다. 개인 참여/보상을 추정하지 않는다. */
export const fnPlanetEvolutionRecord = onDocumentUpdated(
{ document: 'fandoms/{fandomId}', retry: true },
async (event) => {
const change = event.data;
if (!change) return;
const from = change.before.get('stage');
const to = change.after.get('stage');
if (!Number.isInteger(from) || !Number.isInteger(to) ||
from < 0 || to > 6 || to <= from) return;
// 이벤트 ID는 재전달에도 같다. 처리 시각 대신 실제 문서 확정 시각을 보존한다.
const history = change.after.ref.collection('stateHistory')
.doc(`evolution-${encodeURIComponent(event.id)}`);
try {
await history.create({
kind: 'evolution',
occurredAt: change.after.updateTime,
fromStage: from,
toStage: to,
reasonCode: 'STAGE_INCREASED',
source: 'fnPlanetEvolutionRecord',
sourceEventId: event.id,
});
} catch (error) {
const code = (error as { code?: number | string }).code;
if (code !== 6 && code !== 'already-exists') throw error;
}
},
);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,162 @@
import { onCall } from 'firebase-functions/v2/https';
import { getFirestore, FieldValue, Timestamp } from 'firebase-admin/firestore';
import { callerUid, str } from '../core/callable.js';
import { txIdOf } from '../core/ids.js';
import { ERR, fail } from '../core/errors.js';
import { grantPop, spendPop } from '../ledger/ledger.js';
import { publicNameOf } from '../core/names.js';
/**
* 영웅의 선물 — 한 사람이 쏘고 팬덤이 나눠 받는다.
*
* 총량은 서버 운영값, 시간·1인 상한은 B-41의 기본 10분·Pool 5%를 적용한다.
* 이벤트 생성 때 운영 비율을 정수 POP 상한으로 고정한다.
*/
interface DropConfig {
totalPop: number;
perUserCap: number;
perUserCapPercent: number;
durationSec: number;
}
/**
* 총량 하나에 운영값(1인 상한 % · 시간)을 붙여 Drop 규격을 만든다.
*
* [totalPop] 을 받으면 운영 기본 총량 대신 그 값을 쓴다 — Hero Gift 구매는 상품별
* Admin 확정값이 총량이다(원장 B-84 · B-85).
*/
export async function dropConfig(totalPop?: number): Promise<DropConfig> {
const snap = await getFirestore().doc('config/policy').get();
const raw = (snap.get('herodrop') ?? {}) as Partial<DropConfig>;
const total = totalPop ?? raw.totalPop;
const percent = raw.perUserCapPercent ?? 5;
const duration = raw.durationSec ?? 600;
if (
typeof total !== 'number' || !Number.isSafeInteger(total) || total <= 0 ||
typeof percent !== 'number' || !Number.isFinite(percent) || percent <= 0 || percent > 100 ||
!Number.isSafeInteger(duration) || duration <= 0
) {
fail(
ERR.popVerifyFailed,
'영웅의 선물 운영값이 아직 설정되지 않았습니다.',
);
}
const cap = Math.floor(total * percent / 100);
if (cap < 1) fail(ERR.popVerifyFailed, '수령 가능한 정수 POP 상한이 없습니다.');
return { totalPop: total, perUserCap: cap, perUserCapPercent: percent, durationSec: duration };
}
/** Drop 문서 본문. 발동 경로(POP 차감 · IAP 구매)가 같은 모양을 쓴다. */
export function dropDocument(
config: DropConfig,
fields: { triggeredBy: string; heroNickname: string; productId: string },
) {
return {
triggeredBy: fields.triggeredBy,
...(fields.heroNickname ? { heroNickname: fields.heroNickname } : {}),
productId: fields.productId,
totalPop: config.totalPop,
perUserCap: config.perUserCap,
perUserCapPercent: config.perUserCapPercent,
remainingPop: config.totalPop,
claimedCount: 0,
endsAt: Timestamp.fromMillis(Date.now() + config.durationSec * 1000),
createdAt: FieldValue.serverTimestamp(),
};
}
/** 공개 닉네임만 보존한다. 실명/displayName/email을 대체값으로 쓰지 않는다 — 없으면 「팬」. */
export function publicNickname(user: FirebaseFirestore.DocumentSnapshot): string {
return publicNameOf(user);
}
/**
* `fn-herodrop-trigger` — 발동.
*
* 결제로 사는 아이템이라 영수증 검증을 지난 뒤에 불린다. 여기서는 **지원 POP
* 차감**만 하고, 영수증 자체는 `fn-iap-verify` 가 본다.
*/
export const fnHeroDropTrigger = onCall(async (req) => {
const uid = callerUid(req);
const fandomId = str(req.data, 'fandomId');
const productId = str(req.data, 'productId');
const clientSeq = str(req.data, 'clientSeq');
const config = await dropConfig();
const db = getFirestore();
const idempotencyKey = `herodrop:trigger:${uid}:${clientSeq}`;
const dropId = txIdOf(idempotencyKey);
const heroNickname = publicNickname(await db.doc(`users/${uid}`).get());
const result = await spendPop({
uid,
popType: 'support',
amount: config.totalPop,
reasonCode: 'HERO_DROP_TRIGGER',
idempotencyKey,
fandomId,
alsoWrite: (tx) => tx.create(
db.doc(`fandoms/${fandomId}/heroDrops/${dropId}`),
dropDocument(config, { triggeredBy: uid, heroNickname, productId }),
),
});
return { dropId, txId: result.txId, balanceAfter: result.balanceAfter };
});
/**
* `fn-herodrop-claim` — 수령.
*
* **중복 수령은 문서 ID 로 막는다.** 수령 기록의 ID 가 `uid` 라 조건문 없이
* 구조로 차단된다(설계서 §2.1 「중복을 막아야 하는 곳은 문서 ID 를 자연키로」).
* 남은 총량은 트랜잭션에서 줄인다 — 조회 후 차감으로 나누면 동시 수령에서 초과한다.
*/
export const fnHeroDropClaim = onCall(async (req) => {
const uid = callerUid(req);
const fandomId = str(req.data, 'fandomId');
const dropId = str(req.data, 'dropId');
const db = getFirestore();
const dropRef = db.doc(`fandoms/${fandomId}/heroDrops/${dropId}`);
const claimRef = dropRef.collection('claims').doc(uid);
return db.runTransaction(async (tx) => {
const existing = await tx.get(claimRef);
const drop = await tx.get(dropRef);
if (!drop.exists) fail(ERR.popVerifyFailed, '선물을 찾을 수 없습니다.');
const endsAt = drop.get('endsAt');
const remaining = drop.get('remainingPop');
const cap = drop.get('perUserCap');
// 이미 확정한 수령은 종료 후에도 같은 원장 결과를 반환한다.
if (!existing.exists) {
if (!(endsAt instanceof Timestamp) || endsAt.toMillis() <= Date.now()) {
fail(ERR.popVerifyFailed, '수령 시간이 끝났습니다.');
}
if (!Number.isSafeInteger(remaining) || remaining <= 0 ||
!Number.isSafeInteger(cap) || cap <= 0) {
fail(ERR.popVerifyFailed, '수령 가능한 선물이 없습니다.');
}
}
const share = existing.exists ? existing.get('amount') : Math.min(cap, remaining);
if (!Number.isSafeInteger(share) || share <= 0) {
fail(ERR.popVerifyFailed, '수령 기록을 확인할 수 없습니다.');
}
const result = await grantPop({
uid,
popType: 'activity',
amount: share,
reasonCode: 'HERO_DROP',
idempotencyKey: `herodrop:${uid}:${dropId}`,
refPath: dropRef.path,
alsoWrite: (t) => {
if (existing.exists) return;
t.create(claimRef, { uid, amount: share, createdAt: FieldValue.serverTimestamp() });
t.update(dropRef, {
remainingPop: FieldValue.increment(-share),
claimedCount: FieldValue.increment(1),
});
},
}, tx);
return { claimedPop: share, txId: result.txId, balanceAfter: result.balanceAfter };
});
});
@@ -0,0 +1,65 @@
import { Timestamp } from 'firebase-admin/firestore';
interface HibernationSettings {
enterFpi: number;
enterSeconds: number;
wakeFpi: number;
wakeSeconds: number;
}
/** v1.6.1: FPI < 10, 30일. 복귀 수치는 원본에 없어 운영 설정을 필수로 받는다. */
export function hibernationSettings(data: FirebaseFirestore.DocumentData): HibernationSettings | null {
if (data.enabled !== true) return null;
const settings = {
enterFpi: data.enterFpi ?? 10,
enterSeconds: data.enterSeconds ?? 30 * 24 * 60 * 60,
wakeFpi: data.wakeFpi,
wakeSeconds: data.wakeSeconds,
};
if (!Object.values(settings).every((value) => typeof value === 'number' && Number.isFinite(value)) ||
settings.enterFpi <= 0 || settings.wakeFpi <= settings.enterFpi ||
!Number.isSafeInteger(settings.enterSeconds) || settings.enterSeconds <= 0 ||
!Number.isSafeInteger(settings.wakeSeconds) || settings.wakeSeconds < 0) {
throw new Error('Invalid hibernation settings: explicit wake threshold and duration required');
}
return settings;
}
/** FPI 집계와 같은 트랜잭션에 기록한다. 재실행·과거 실행이 유지시간을 되돌리지 않는다. */
export function hibernationPatch(
current: FirebaseFirestore.DocumentData,
fpi: number,
settings: HibernationSettings,
now: Timestamp,
activity?: { id: string; at: Timestamp },
): FirebaseFirestore.DocumentData {
if (!Number.isFinite(fpi)) return {};
const evaluated = current.hibernationEvaluatedAt;
if (evaluated instanceof Timestamp && evaluated.toMillis() >= now.toMillis()) return {};
const policy = JSON.stringify(settings);
const unchangedPolicy = current.hibernationPolicy === policy;
let asleep = current.isHibernating === true;
const wasAsleep = asleep;
let lowSince: Timestamp | null = null;
let wakeSince: Timestamp | null = null;
const since = (value: unknown) => unchangedPolicy && value instanceof Timestamp &&
value.toMillis() <= now.toMillis() ? value : now;
if (!asleep && fpi < settings.enterFpi) {
lowSince = since(current.hibernationLowSince);
asleep = now.toMillis() - lowSince.toMillis() >= settings.enterSeconds * 1000;
} else if (asleep && fpi >= settings.wakeFpi &&
activity && current.hibernationChangedAt instanceof Timestamp &&
activity.at.toMillis() > current.hibernationChangedAt.toMillis() &&
activity.at.toMillis() <= now.toMillis()) {
wakeSince = since(current.hibernationWakeSince);
asleep = now.toMillis() - wakeSince.toMillis() < settings.wakeSeconds * 1000;
}
return {
isHibernating: asleep,
hibernationLowSince: lowSince,
hibernationWakeSince: wakeSince,
hibernationEvaluatedAt: now,
hibernationPolicy: policy,
...(wasAsleep !== asleep ? { hibernationChangedAt: now } : {}),
};
}
@@ -0,0 +1,69 @@
import { HttpsError, onCall } from 'firebase-functions/v2/https';
import { getFirestore, Timestamp } from 'firebase-admin/firestore';
import { callerUid, str } from '../core/callable.js';
/** PASSPORT에서 쓸 팬덤 역사와 호출자 본인의 참여 근거만 반환한다. */
export const fnPlanetHistory = onCall(async (request) => {
const uid = callerUid(request);
const fandomId = str(request.data, 'fandomId');
if (fandomId.includes('/') || fandomId.length > 512) {
throw new HttpsError('invalid-argument', '팬덤을 확인해 주세요.');
}
const scope = request.data?.scope;
if (scope != null && scope !== 'fandom' && scope !== 'personal') {
throw new HttpsError('invalid-argument', '조회 범위를 확인해 주세요.');
}
const personal = scope === 'personal';
const db = getFirestore();
const history = db.collection(personal ? `users/${uid}/growthEvents` : `fandoms/${fandomId}/stateHistory`);
const dateField = personal ? 'createdAt' : 'occurredAt';
let query = (personal ? history.where('fandomIdAtPayout', '==', fandomId) : history)
.orderBy(dateField, 'desc').limit(21);
if (request.data?.cursor != null) {
const cursor = str(request.data, 'cursor');
if (cursor.includes('/') || cursor.length > 1500) {
throw new HttpsError('invalid-argument', '조회 위치를 확인해 주세요.');
}
const previous = await history.doc(cursor).get();
if (!previous.exists || !(previous.get(dateField) instanceof Timestamp) ||
(personal && previous.get('fandomIdAtPayout') !== fandomId)) {
throw new HttpsError('invalid-argument', '조회 위치를 확인해 주세요.');
}
query = query.startAfter(previous);
}
const [page, contribution] = await Promise.all([
query.get(),
// 성장 이벤트는 실제 지급/기여 원장과 같은 트랜잭션에서 생성된다.
personal ? Promise.resolve(null) : db.collection(`users/${uid}/growthEvents`)
.where('fandomIdAtPayout', '==', fandomId).orderBy('createdAt').limit(1).get(),
]);
const first = contribution?.docs[0];
const firstAt = first?.get('createdAt');
const hasProof = firstAt instanceof Timestamp && Number.isSafeInteger(first?.get('amount')) &&
first?.get('amount') > 0 &&
first?.get('reversalOf') == null &&
['quest_reward', 'company_ad_bonus', 'zap', 'funding'].includes(first?.get('sourceType'));
const rows = page.docs.slice(0, 20);
const items = rows.flatMap((doc) => {
const kind = doc.get(personal ? 'sourceType' : 'kind');
const occurredAt = doc.get(dateField);
const allowed = personal ? ['quest_reward', 'company_ad_bonus', 'zap', 'funding']
: ['evolution', 'hibernation', 'wake_up'];
const amount = doc.get('amount');
if (!(occurredAt instanceof Timestamp) || !allowed.includes(kind) ||
(personal && (!Number.isSafeInteger(amount) || amount <= 0 || doc.get('reversalOf') != null))) return [];
const from = doc.get('fromStage');
const to = doc.get('toStage');
if (kind === 'evolution' && (!Number.isInteger(from) || !Number.isInteger(to) ||
from < 0 || to > 6 || to <= from)) return [];
return [{
id: doc.id, kind, occurredAt: occurredAt.toDate().toISOString(),
...(personal ? { amount } : {}),
...(kind === 'evolution' ? { fromStage: from, toStage: to } : {}),
participationConfirmed: kind === 'evolution' && hasProof &&
firstAt.toMillis() <= occurredAt.toMillis(),
}];
});
// 원인 이벤트 ID·운영 조건·원장·다른 사용자의 식별자는 공개 응답에 포함하지 않는다.
return { items, nextCursor: page.size > 20 ? rows.at(-1)!.id : null };
});
+46 -2
View File
@@ -4,6 +4,50 @@
import './core/bootstrap.js';
export {
fnAuthOnCreate, fnConsentRecord, fnAdminSetRole,
fnPopGrant, fnPopSpend, fnPopRefund, fnPopReconcile,
} from './ledger/pop.js';
export {
fnQuestAttend, fnQuestVerifySteps, fnAdReward,
} from './quest/quest.js';
export {
fnAuthOnCreate, fnNebulaCreate, fnPlanetCreate,
fnFandomJoin, fnFandomLeave, fnPrimaryFandomSet, fnConsentRecord, fnAdminSetRole,
} from './account/account.js';
export { fnAdminWrite } from './admin/admin.js';
export {
fnEclipseDefend, fnEclipseAggregate, fnEclipseTrigger,
fnFpiAggregate, fnLiveAggregate, fnFpiDailySnapshot,
fnRankingCountryAggregate, fnCampaignFund,
} from './fandom/fandom.js';
export { fnAdminWrite, fnAdminPopGrantBatch, fnAdminGrowthReverse, fnAdminCompletionVoid } from './admin/admin.js';
export {
fnIapVerify, fnIapRefundWebhook, fnSubscriptionWebhook, fnFocusDailyClaim,
} from './payment/iap.js';
export {
fnQuestVerifyMusicShake, fnQuestVerifyPlace,
} from './quest/verify.js';
export { fnQuestTimeAttackStart, fnQuestTimeAttack, fnServerTime } from './quest/timeattack.js';
export {
fnQuestCreate, fnQuestSubmit, fnQuestReview, fnQuestEdit, fnQuestPoolSettle,
} from './quest/ugc.js';
export { fnLinkPreview } from './quest/link_preview.js';
export {
fnCampaignSettle, fnCampaignRefundBatch, fnCampaignAdminReview, fnCampaignAdminCancel,
fnCampaignReportSubmit,
} from './campaign/settle.js';
export { fnCampaignCreate, fnCampaignWithdraw } from './campaign/create.js';
export {
fnQuestionCreate, fnAnswerAccept, fnAnswerOnCreate, fnQuestionBountyExpire,
} from './inside/qna.js';
export { fnPostLikeCount, fnPostCommentCount, fnPostCleanup, fnCampaignCommentCount,
fnCampaignCommentLikeCount, fnCampaignCommentCleanup } from './inside/lounge.js';
export { fnHeroDropTrigger, fnHeroDropClaim } from './fandom/herodrop.js';
export {
fnInviteCodeGet, fnReferralAttribute, fnReferralOnCompletionVoid, fnReferralRewardOnCompletion, fnReferralSettle,
} from './account/referral.js';
export {
fnTranslationSubmit, fnTranslationMark, fnTranslationSettle,
} from './account/translation.js';
export { fnBriefingIngest, fnBriefingPublish } from './briefing/briefing.js';
export { fnPlanetEvolutionRecord } from './fandom/evolution.js';
export { fnPlanetHistory } from './fandom/history.js';
+123
View File
@@ -0,0 +1,123 @@
import { getFirestore, Timestamp } from 'firebase-admin/firestore';
import { getStorage } from 'firebase-admin/storage';
import { onDocumentDeleted, onDocumentWritten } from 'firebase-functions/v2/firestore';
/**
* 라운지 글의 좋아요 · 댓글 수 — 설계서 §2.6 `likeCount` · `commentCount`(집계).
*
* 규칙이 글 문서의 두 수를 작성자에게도 닫아 두었으므로(firestore.rules `posts`)
* 수를 적는 곳은 여기뿐이다. **증감이 아니라 다시 센다.** 트리거는 적어도 한 번
* 전달되므로 `increment` 는 재전달마다 수가 부푼다.
*
* **늦게 센 값이 새 값을 덮지 않는다.** 센 시점(집계 질의의 `readTime`)을
* `likeCountAt` · `commentCountAt` 에 함께 적고, 트랜잭션 안에서 저장된 시점보다
* 새로울 때만 쓴다. 동시 반응 두 건의 트리거가 순서를 바꿔 끝나도 마지막 상태가 남는다.
*/
export function recountPost(postId: string, kind: 'likes' | 'comments'): Promise<void> {
return recount(`posts/${postId}`, kind);
}
/** [recountPost] 의 본체 — 부모 문서 경로만 받는다. 캠페인 응원 게시판도 같은 셈을 쓴다. */
export async function recount(parentPath: string, kind: 'likes' | 'comments'): Promise<void> {
const db = getFirestore();
const post = db.doc(parentPath);
const sub = post.collection(kind);
// 숨김 댓글은 목록에 안 뜨므로 수에서도 뺀다. 좋아요는 숨김 상태가 없다.
const query = kind === 'comments' ? sub.where('visibility', '==', 'visible') : sub;
const snap = await query.count().get();
const count = snap.data().count;
const countField = kind === 'likes' ? 'likeCount' : 'commentCount';
const atField = `${countField}At`;
await db.runTransaction(async (tx) => {
const current = await tx.get(post);
// 글이 먼저 지워졌으면 적을 곳이 없다 — 다시 만들지 않는다.
if (!current.exists) return;
const at = current.get(atField);
if (at instanceof Timestamp && at.toMillis() >= snap.readTime.toMillis()) return;
tx.update(post, { [countField]: count, [atField]: snap.readTime });
});
}
export const fnPostLikeCount = onDocumentWritten(
{ document: 'posts/{postId}/likes/{uid}', retry: true },
(event) => recountPost(event.params.postId, 'likes'),
);
export const fnPostCommentCount = onDocumentWritten(
{ document: 'posts/{postId}/comments/{commentId}', retry: true },
async (event) => {
const before = event.data?.before;
const after = event.data?.after;
// 본문만 고친 수정은 수를 바꾸지 않는다 — 다시 세지 않는다.
if (before?.exists && after?.exists &&
before.get('visibility') === after.get('visibility')) return;
await recountPost(event.params.postId, 'comments');
},
);
/**
* 캠페인 응원 게시판 댓글 수 — 라운지와 같은 셈. 규칙이 캠페인 문서의
* `commentCount` 를 개설자 · 운영 수정 목록에서 빼 두어 위조 경로가 없다.
*/
export const fnCampaignCommentCount = onDocumentWritten(
{ document: 'campaigns/{campaignId}/comments/{commentId}', retry: true },
async (event) => {
const before = event.data?.before;
const after = event.data?.after;
if (before?.exists && after?.exists &&
before.get('visibility') === after.get('visibility')) return;
await recount(`campaigns/${event.params.campaignId}`, 'comments');
},
);
/** 응원 게시판 댓글 좋아요 수(시안 `527:21192`) — 댓글 문서의 `likeCount`. */
export const fnCampaignCommentLikeCount = onDocumentWritten(
{ document: 'campaigns/{campaignId}/comments/{commentId}/likes/{uid}', retry: true },
(event) => recount(
`campaigns/${event.params.campaignId}/comments/${event.params.commentId}`, 'likes',
),
);
/** 글의 사진 파일을 지운다. 테스트가 Storage 없이 돌도록 바꿔 끼울 수 있게 뺐다. */
export type DeleteFiles = (prefix: string) => Promise<void>;
const deleteStorageFiles: DeleteFiles = (prefix) =>
getStorage().bucket().deleteFiles({ prefix });
/**
* 글이 지워지면 딸린 것도 지운다 — 하위 `likes` · `comments` 와
* `users/{작성자}/lounge/{글}-*` 사진. 앱은 글 문서만 지우고 나머지는 여기가 맡는다.
* 둘 다 다시 돌아도 같은 결과라 재전달에 안전하다.
*/
export async function cleanupPost(
postId: string,
authorUid: unknown,
deleteFiles: DeleteFiles = deleteStorageFiles,
): Promise<void> {
const db = getFirestore();
// 문서가 이미 없어도 recursiveDelete 는 하위 컬렉션을 지운다.
await db.recursiveDelete(db.doc(`posts/${postId}`));
if (typeof authorUid === 'string' && authorUid.length > 0) {
await deleteFiles(`users/${authorUid}/lounge/${postId}-`);
}
}
export const fnPostCleanup = onDocumentDeleted(
{ document: 'posts/{postId}', retry: true },
(event) => cleanupPost(event.params.postId, event.data?.get('authorUid')),
);
/**
* 응원 게시판 댓글이 지워지면 하위 `likes` 도 지운다(fnPostCleanup 과 같은 방식).
* 번역 댓글은 라운지 글 정리와 마찬가지로 남긴다. 좋아요 수 트리거는 댓글이
* 없으면 다시 만들지 않는다(`recount`).
*/
export async function cleanupCampaignComment(campaignId: string, commentId: string): Promise<void> {
const db = getFirestore();
await db.recursiveDelete(db.doc(`campaigns/${campaignId}/comments/${commentId}`));
}
export const fnCampaignCommentCleanup = onDocumentDeleted(
{ document: 'campaigns/{campaignId}/comments/{commentId}', retry: true },
(event) => cleanupCampaignComment(event.params.campaignId, event.params.commentId),
);
+299
View File
@@ -0,0 +1,299 @@
import { HttpsError, onCall } from 'firebase-functions/v2/https';
import { onSchedule } from 'firebase-functions/v2/scheduler';
import { onDocumentCreated } from 'firebase-functions/v2/firestore';
import { getFirestore, FieldValue, Timestamp } from 'firebase-admin/firestore';
import { logger } from 'firebase-functions';
import { callerUid, num, str } from '../core/callable.js';
import { ERR, fail, positiveInt } from '../core/errors.js';
import { txIdOf } from '../core/ids.js';
import { grantPop, refundSplit, spendSplit } from '../ledger/ledger.js';
import { publicNameOf } from '../core/names.js';
/**
* 현상금 Q&A — 별첨 1 ④.
*
* 판정 기준이 「현상금 **예치·전송·환불** 원장 정합성 오류 0건」이다. 세 갈래가
* 모두 원장을 지나야 하므로 화면에서 POP 을 옮기는 경로를 두지 않는다.
*
* 채택 기한은 정책 원장 B-39 가 7일로 확정했고 운영값이다. 코드에 박지 않고
* `config/policy.qnaAcceptDays` 에서 읽는다. 현상금 하한도 `config/policy.qnaMinBountyPop`.
*
* 질문 상태: `open` → `accepted`(채택 · 번복 불가) 또는 `refunding` → `expired`(환불 완료).
* 환불이 [MAX_REFUND_ATTEMPTS] 번 실패하면 `refund_failed` 로 멈추고 운영 로그에 올린다.
*/
const DEFAULT_ACCEPT_DAYS = 7;
/**
* 현상금 하한 기본값 — 기능 명세 2.60-3 「100POP 이상 및 보유 POP 구간 사이 입력 가능」.
* 화면만 막으면 앱을 고쳐 우회할 수 있으므로 서버도 같은 값으로 끊는다.
* 운영값이라 `config/policy.qnaMinBountyPop` 이 덮는다.
*/
export const DEFAULT_MIN_BOUNTY_POP = 100;
/**
* 제목 · 본문 상한. 기능 명세 2.60 은 길이를 정하지 않았다 — 앱 입력칸(60 · 2,000)보다
* 넉넉한 서버 안전 상한이며 정책값이 아니다.
*/
const MAX_TITLE = 100;
const MAX_BODY = 5_000;
/** 만료 환불 재시도 한도. 넘으면 자동 재시도를 멈추고 수동 복구로 넘긴다. */
export const MAX_REFUND_ATTEMPTS = 3;
async function qnaPolicy(): Promise<{ acceptDays: number; minBounty: number }> {
const snap = await getFirestore().doc('config/policy').get();
const positive = (key: string, fallback: number) => {
const value = snap.get(key);
return typeof value === 'number' && value > 0 ? value : fallback;
};
return {
acceptDays: positive('qnaAcceptDays', DEFAULT_ACCEPT_DAYS),
minBounty: positive('qnaMinBountyPop', DEFAULT_MIN_BOUNTY_POP),
};
}
/**
* `fn-question-create` — 현상금을 예치하고 질문을 연다.
*
* 예치와 질문 문서를 **한 트랜잭션**에 둔다. 따로 쓰면 그 사이 실패에서 「POP 은
* 빠졌는데 질문이 없다」가 남는다. 목록이 팬덤으로 거르므로 `fandomId` 와 표시명을
* 사용자 문서에서 복제한다 — 클라이언트가 보낸 값은 믿지 않는다.
*
* 예치는 겸용이다(B-39 · B-06) — 일반(activity) POP 을 먼저 쓰고 부족분만 기여(support)
* POP 에서 뺀다. 두 재화가 함께 쓰이면 원장 2건이며, 그 거래 ID 들을 `bountyTxIds` 에
* 남겨 만료 환불이 행마다 원래 타입으로 돌린다.
*/
export const fnQuestionCreate = onCall(async (req) => {
const uid = callerUid(req);
const title = str(req.data, 'title');
const body = str(req.data, 'body');
const clientSeq = str(req.data, 'clientSeq');
if (title.length > MAX_TITLE || body.length > MAX_BODY) {
throw new HttpsError('invalid-argument', `제목은 ${MAX_TITLE}자, 본문은 ${MAX_BODY}자까지입니다.`);
}
const bounty = positiveInt(num(req.data, 'bountyPop'), 'bountyPop');
const { acceptDays: days, minBounty } = await qnaPolicy();
if (bounty < minBounty) {
throw new HttpsError('invalid-argument', `현상금은 ${minBounty} POP 이상이어야 합니다.`);
}
const db = getFirestore();
const user = (await db.doc(`users/${uid}`).get()).data() ?? {};
const idempotencyKey = `bounty:${uid}:${clientSeq}`;
// 질문 ID 는 예치 거래 ID 와 같다. 재시도가 질문을 둘 만들지 않는다.
const questionId = txIdOf(idempotencyKey);
const parts = await spendSplit({
uid,
amount: bounty,
reasonCode: 'QNA_BOUNTY',
idempotencyKey,
refPath: `questions/${questionId}`,
alsoWrite: (tx, written) => {
tx.create(db.doc(`questions/${questionId}`), {
authorUid: uid,
authorName: publicNameOf({ get: (k: string) => user[k] }),
fandomId: typeof user.fandomId === 'string' ? user.fandomId : '',
title,
body,
bountyPop: bounty,
bountyTxIds: written.map((p) => p.txId),
status: 'open',
answerCount: 0,
expiresAt: Timestamp.fromMillis(Date.now() + days * 24 * 60 * 60 * 1000),
createdAt: FieldValue.serverTimestamp(),
updatedAt: FieldValue.serverTimestamp(),
});
},
});
return {
questionId,
txIds: parts.map((p) => p.txId),
parts: parts.map((p) => ({ popType: p.popType, amount: p.amount })),
};
});
/**
* `fn-answer-accept` — 채택. 예치한 현상금이 답변자에게 **일반 POP** 으로 간다(B-39).
*
* 명세가 정한 둘을 지킨다 — **자기 답변 채택 금지**(E-QNA-01)와 **번복 불가**.
* 판정 · 지급 · 상태 전환을 한 트랜잭션에 둔다. 만료 배치도 질문 문서를 먼저
* 점유하므로(`refunding`) 채택과 환불이 함께 일어나지 않는다.
*/
export const fnAnswerAccept = onCall(async (req) => {
const uid = callerUid(req);
const questionId = str(req.data, 'questionId');
const answerId = str(req.data, 'answerId');
const db = getFirestore();
const questionRef = db.doc(`questions/${questionId}`);
const answerRef = questionRef.collection('answers').doc(answerId);
return db.runTransaction(async (tx) => {
const question = await tx.get(questionRef);
if (!question.exists) throw new HttpsError('not-found', '질문을 찾을 수 없습니다.');
if (question.get('authorUid') !== uid) {
fail(ERR.permissionDenied, '질문을 올린 분만 채택할 수 있습니다.');
}
const status = question.get('status') as string | undefined;
if (status === 'accepted') {
fail(ERR.qnaAlreadyAccepted, '이미 채택한 질문입니다. 채택은 바꿀 수 없습니다.');
}
const expiresAt = question.get('expiresAt') as Timestamp | undefined;
if (status !== 'open' || (expiresAt && expiresAt.toMillis() <= Date.now())) {
// 환불이 실제로 끝났을 때만 「환불되었습니다」라고 말한다.
fail(ERR.qnaBountyExpired, status === 'expired'
? '채택 기한이 지나 현상금이 환불되었습니다.'
: '채택 기한이 지나 현상금이 환불될 예정입니다.');
}
const answer = await tx.get(answerRef);
if (!answer.exists) throw new HttpsError('not-found', '답변을 찾을 수 없습니다.');
const answerUid = answer.get('authorUid') as string | undefined;
if (!answerUid) throw new HttpsError('failed-precondition', '답변자를 알 수 없습니다.');
if (answerUid === uid) {
fail(ERR.qnaSelfAnswer, '자기 답변은 채택할 수 없습니다.');
}
const bounty = (question.get('bountyPop') as number | undefined) ?? 0;
const result = await grantPop({
uid: answerUid,
popType: 'activity',
amount: bounty,
reasonCode: 'QNA_REWARD',
// 질문 하나에 한 번. 재시도가 두 번 지급하지 않는다.
idempotencyKey: `accept:${questionId}`,
refPath: questionRef.path,
}, tx);
tx.update(questionRef, {
status: 'accepted',
acceptedAnswerId: answerId,
acceptedUid: answerUid,
acceptTxId: result.txId,
updatedAt: FieldValue.serverTimestamp(),
});
tx.update(answerRef, { accepted: true, updatedAt: FieldValue.serverTimestamp() });
return { txId: result.txId, to: answerUid, amount: bounty };
});
});
/**
* 답변 수 — 규칙이 답변 등록은 클라이언트에 열어 두었지만(재화 이동 없음) 질문
* 문서는 잠겨 있다. 인기순 정렬 필드(`answerCount`)를 서버가 올린다.
*
* 트리거는 재전달될 수 있으므로 `answerCounted/{answerId}` 표식과 증가를 한 트랜잭션에
* 둔다 — 표식이 이미 있으면 세지 않는다. 질문이 없으면 건너뛴다.
*/
export async function countAnswer(questionId: string, answerId: string): Promise<boolean> {
const db = getFirestore();
const questionRef = db.doc(`questions/${questionId}`);
const markerRef = questionRef.collection('answerCounted').doc(answerId);
return db.runTransaction(async (tx) => {
const [question, marker] = await Promise.all([tx.get(questionRef), tx.get(markerRef)]);
if (!question.exists || marker.exists) return false;
tx.create(markerRef, { createdAt: FieldValue.serverTimestamp() });
tx.update(questionRef, { answerCount: FieldValue.increment(1) });
return true;
});
}
export const fnAnswerOnCreate = onDocumentCreated(
'questions/{questionId}/answers/{answerId}',
async (event) => {
await countAnswer(event.params.questionId, event.params.answerId);
},
);
/**
* `fn-question-bounty-expire` — 채택 기한이 지난 현상금을 돌려준다(E-QNA-02).
* 예치한 원거래의 타입 그대로 환불된다(`refundSplit` 이 행마다 원거래 `popType` 을 따른다).
*/
export const fnQuestionBountyExpire = onSchedule('every 1 hours', async () => {
await expireBounties();
});
/**
* 만료 처리 본체. 질문을 트랜잭션으로 `refunding` 에 점유한 뒤 환불하고 `expired`
* 로 닫는다. 환불이 실패한 건은 `refunding` 으로 남아 다음 회차가 다시 집는다.
* [MAX_REFUND_ATTEMPTS] 번 실패했거나 환불 근거(질문자 · 예치 거래)가 없으면
* `refund_failed` 로 멈추고 `adminActionLogs` 에 수동 복구 건으로 올린다
* (캠페인 환불 부분 실패와 같은 운영 경로).
*/
export async function expireBounties(): Promise<{ scanned: number; refunded: number }> {
const db = getFirestore();
const [expired, pending] = await Promise.all([
db.collection('questions')
.where('status', '==', 'open')
.where('expiresAt', '<=', Timestamp.now())
.get(),
db.collection('questions').where('status', '==', 'refunding').get(),
]);
const giveUp = async (ref: FirebaseFirestore.DocumentReference, attempts: number, error: string) => {
await ref.update({
status: 'refund_failed',
refundAttempts: attempts,
refundError: error,
updatedAt: FieldValue.serverTimestamp(),
});
await db.collection('adminActionLogs').add({
actorUid: 'system',
targetPath: ref.path,
reason: 'Q&A 현상금 만료 환불 실패 — 수동 복구 필요',
patch: { attempts, error },
createdAt: FieldValue.serverTimestamp(),
});
logger.error('[qna] 현상금 환불 중단', { questionId: ref.id, attempts, error });
};
let refunded = 0;
for (const doc of [...expired.docs, ...pending.docs]) {
const claimed = await db.runTransaction(async (tx) => {
const fresh = await tx.get(doc.ref);
const status = fresh.get('status');
if (status === 'refunding') return true;
const expiresAt = fresh.get('expiresAt') as Timestamp | undefined;
if (status !== 'open' || !expiresAt || expiresAt.toMillis() > Date.now()) return false;
tx.update(doc.ref, { status: 'refunding', updatedAt: FieldValue.serverTimestamp() });
return true;
});
if (!claimed) continue;
const uid = doc.get('authorUid') as string | undefined;
// 분할 예치 전의 옛 질문은 `bountyTxId` 한 건이다.
const legacy = doc.get('bountyTxId') as string | undefined;
const txIds = (doc.get('bountyTxIds') as string[] | undefined) ?? (legacy ? [legacy] : []);
if (!uid || txIds.length === 0) {
await giveUp(doc.ref, 0, '질문자 또는 예치 거래 ID 가 없습니다.');
continue;
}
try {
const results = await refundSplit({
uid,
originalTxIds: txIds,
idempotencyKey: `qnarefund:${doc.id}`,
reasonCode: 'QNA_REFUND',
});
await doc.ref.update({
status: 'expired',
refundTxIds: results.map((r) => r.txId),
updatedAt: FieldValue.serverTimestamp(),
});
refunded += 1;
} catch (error) {
const attempts = ((doc.get('refundAttempts') as number | undefined) ?? 0) + 1;
if (attempts >= MAX_REFUND_ATTEMPTS) {
await giveUp(doc.ref, attempts, String(error));
} else {
await doc.ref.update({ refundAttempts: attempts, refundError: String(error) });
logger.error('[qna] 현상금 환불 실패', { questionId: doc.id, attempts, error: String(error) });
}
}
}
const scanned = expired.size + pending.size;
logger.info('[qna] 기한 만료 처리', { scanned, refunded });
return { scanned, refunded };
}
+176
View File
@@ -0,0 +1,176 @@
import { getFirestore, FieldValue } from 'firebase-admin/firestore';
import type { DocumentSnapshot, Transaction } from 'firebase-admin/firestore';
import type { FpiAxis } from '../core/config.js';
import { levelOf, passportTierOf, type PassportTier } from '../core/level.js';
/**
* FPI 원천 이벤트 — 정책 원장 **C-34 · C-35 · G-05**, 설계서 §4.1.1.
*
* 축 값은 **인정된 활동 단위**다. 지급 POP 은 원천이 아니므로 이 문서에 POP 필드를
* 두지 않는다 — 보상 750 POP 인 Quest 와 1 POP 인 Quest 의 같은 활동은 같은 점수다.
*
* 축 매핑(C-35): 출석 · 성지/생일 팝업/응원 현장 방문 = community · 걸음/운동 = health ·
* Music Shake = bpm · 추천 = referral · Zap/후원 = contribution.
*/
/** 활동 1 단위의 축 값. S01 확정 전 유지 기준(설계서 §4.1.1 「축별 계수 초기값 1.0」). */
export const FPI_UNIT_FACTOR = 1.0;
/**
* 귀속 팬덤은 서버가 정한다 — `primaryFandomId ?? fandomId`.
*
* 클라이언트가 보낸 `fandomId` 는 쓰지 않는다. 남의 행성 축을 올리는 길이 된다.
* 성장 귀속(`fandomIdAtPayout`)도 같은 판정을 쓴다(C-33).
*/
export function fandomOfUser(user: DocumentSnapshot | null | undefined): string | null {
const id = user?.get('primaryFandomId') || user?.get('fandomId');
return typeof id === 'string' && id.length > 0 && !id.includes('/') ? id : null;
}
export interface FpiActivity {
uid: string;
fandomId: string | null;
axis: FpiAxis;
/** 인정 활동 단위 수(걸음은 새로 도달한 구간 수, Zap 은 누른 횟수). */
units: number;
/** 활동 종류 — 무효 정정 때 원 활동을 찾는 표식. */
activity: string;
/** 원 활동 ID. 문서 ID 가 `{uid}_{sourceId}` 라 같은 활동은 한 번만 남는다. */
sourceId: string;
occurredAt: Date;
refPath?: string;
/**
* User EXP 를 주는가. 기본 true. 반복 비용이 낮아 파밍되는 활동(Zap · 후원 — 1 POP 단위로
* 쪼갤 수 있다)과 본인 행위가 아닌 활동(추천인 귀속)은 false 다.
*/
grantsExp?: boolean;
}
/**
* 인정 활동 1건의 User EXP — 정책 원장 **C-18**.
*
* S01 확정 전 임시값. 누적 기여 POP 을 레벨로 치환하지 않도록(C-18) 금액이 아니라 **서버가
* 인정한 활동 건수**로 쌓는다 — 보상 750 POP 출석과 1 POP 출석이 같은 1건이다. 대상은 서버가
* 한도 · 1회성을 판정하는 Quest 활동(출석 · 걸음 구간 · Music Shake · 현장 방문)뿐이다.
* Zap · 후원 · 추천인 귀속은 `grantsExp: false`(파밍 · 본인 행위 아님).
*/
export const EXP_PER_ACTIVITY = 100;
/**
* 원 활동과 **같은 트랜잭션에서** FPI 이벤트와 User EXP 를 남긴다.
*
* 따로 쓰면 지급은 됐는데 FPI 는 빠진 상태가 남고, 재시도는 일일 한도에 막혀 영영
* 채워지지 않는다. `create` 라 같은 활동을 두 번 적으면 트랜잭션째 실패한다.
*
* `user` 는 **이 트랜잭션에서 이미 읽은** 활동 주체의 사용자 문서다(읽기가 쓰기보다 앞서야
* 한다). 레벨 · Passport Tier 는 EXP 의 파생값이라 함께 갱신하고 Passport 는 내려가지 않는다.
* 팬덤이 없어도 EXP 는 쌓인다 — 레벨은 개인 체계다(B-66).
*/
export function writeFpiEvent(
tx: Transaction, event: FpiActivity, user: DocumentSnapshot | null,
): void {
if (event.units <= 0) return;
if (event.grantsExp !== false) writeUserExp(tx, event.uid, user);
if (!event.fandomId) return;
tx.create(
getFirestore().doc(`fandoms/${event.fandomId}/fpiEvents/${event.uid}_${event.sourceId}`),
{
axis: event.axis,
value: event.units * FPI_UNIT_FACTOR,
units: event.units,
activity: event.activity,
uid: event.uid,
verified: true,
occurredAt: event.occurredAt,
sourceId: event.sourceId,
// 일별 DAU(Eclipse 목표 입력)는 본인이 직접 한 인정 활동만 센다. 추천인 귀속은 제외.
dauEligible: event.activity !== 'referral',
...(event.refPath ? { refPath: event.refPath } : {}),
createdAt: FieldValue.serverTimestamp(),
},
);
}
/**
* 인정 활동 1건의 User EXP(C-18) — 레벨 · Passport Tier 함께. FPI 축 없이 EXP 만 줄 때
* (타임어택 FPI 축이 꺼졌을 때) 직접 부른다.
*/
export function writeUserExp(tx: Transaction, uid: string, user: DocumentSnapshot | null): void {
const exp = ((user?.get('userExp') as number | undefined) ?? 0) + EXP_PER_ACTIVITY;
const level = levelOf(exp);
tx.set(getFirestore().doc(`users/${uid}`), {
userExp: exp,
userLevel: level,
passportTier: passportTierOf(level, user?.get('passportTier') as PassportTier | undefined),
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
}
/**
* 「검증 활동」 1회 — 해석 확인 요청 Campaign 개설자격 **A안** · 집계 규칙(2026-09-28 결정).
*
* 서버가 인정한 Quest 완료를 **완료 단위 표식** `users/{uid}/verifiedCompletions/{id}` 하나로 남긴다.
* `id` 가 곧 「하루 1회」 경계다 — 출석 · 걸음(구간 무관) · 방문 · UGC · 타임어택은
* `{questId}_{dayKey}`, Music Shake 는 완주마다 `{questId}_{dayKey}_{n}`. 같은 날 같은 표식을
* 다시 써도(구간 · 재시도) `merge` 라 하나로 모인다. 보상 유무 · 팬덤 유무와 무관하다.
*
* 무효 정정은 원장 행(`txIds`) 또는 제출 건(`submissionId`)으로 찾는다([voidCompletions]).
*/
export function markCompletion(
tx: Transaction, uid: string, id: string,
ref: { questId: string; txId?: string; submissionId?: string },
): void {
tx.set(getFirestore().doc(`users/${uid}/verifiedCompletions/${id}`), {
questId: ref.questId,
...(ref.txId ? { txIds: FieldValue.arrayUnion(ref.txId) } : {}),
...(ref.submissionId ? { submissionId: ref.submissionId } : {}),
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
}
/**
* 「검증 활동」 누적 수 = 표식 수 − 무효 표식 수. 기간 창 없음. Quest 생성자격 1회 · Campaign
* 생성자격 5회가 같은 값을 읽는다. 저장 카운터가 아니라 표식에서 세므로 음수 · 이중 차감이 없다.
*/
export async function verifiedQuestCountOf(uid: string): Promise<number> {
const col = getFirestore().collection(`users/${uid}/verifiedCompletions`);
const [all, voided] = await Promise.all([
col.count().get(), col.where('voided', '==', true).count().get(),
]);
return Math.max(0, all.data().count - voided.data().count);
}
/** 원장 행 또는 제출 건에 걸린 표식을 무효로 — 트랜잭션 안에서 **읽기 단계**에 부른다. 쓰기는 반환 함수로. */
export async function voidCompletions(
tx: Transaction, uid: string, key: { txId?: string; submissionId?: string },
): Promise<(tx: Transaction) => number> {
const col = getFirestore().collection(`users/${uid}/verifiedCompletions`);
const snap = key.txId
? await tx.get(col.where('txIds', 'array-contains', key.txId))
: key.submissionId ? await tx.get(col.where('submissionId', '==', key.submissionId)) : null;
const live = (snap?.docs ?? []).filter((d) => d.get('voided') !== true);
return (w) => {
for (const d of live) w.update(d.ref, { voided: true, voidedAt: FieldValue.serverTimestamp() });
return live.length;
};
}
/**
* 공동 순위 — 정책 원장 **C-19**.
*
* 실제 산정값(반올림 전) 내림차순, 동점은 같은 순위이고 다음 순위는 건너뛴다(1·1·3).
* 동점 안의 배치는 `key` 오름차순으로 고정한다(S01 확정 전 유지 기준 — 2차 정렬키
* `fandomId`). 최근 활동 시각은 쓰지 않는다.
*/
export function rankWithTies<T>(
items: readonly T[], value: (item: T) => number, key: (item: T) => string,
): Array<{ item: T; rank: number }> {
const sorted = [...items].sort((a, b) =>
value(b) - value(a) || (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0));
let rank = 0;
return sorted.map((item, i) => {
if (i === 0 || value(item) !== value(sorted[i - 1]!)) rank = i + 1;
return { item, rank };
});
}
+831
View File
@@ -0,0 +1,831 @@
import { getFirestore, FieldValue, Timestamp } from 'firebase-admin/firestore';
import type { DocumentReference, Firestore, Transaction } from 'firebase-admin/firestore';
import { ERR, fail } from '../core/errors.js';
import { lotIdOf, txIdOf } from '../core/ids.js';
import { levelOf, passportRank, passportTierOf, type PassportTier } from '../core/level.js';
import { fandomOfUser, voidCompletions, writeFpiEvent } from './fpi.js';
import { publicNameOf } from '../core/names.js';
/**
* POP 원장 — 별첨 1 「조건부 적합 제외 항목 ② POP/FPI 데이터 정합성」의 구현부.
*
* 이 파일이 계약 검수의 핵심이다. 정합성 오류가 나면 조건부 적합이 아니라
* **부적합(재검수)** 이므로, 원장을 건드리는 모든 경로가 여기를 지나게 했다.
* 화면이나 다른 함수가 지갑 문서를 직접 쓰는 일은 없어야 한다.
*
* 지키는 것 셋
* 1. **append-only** — 과거 원장 행을 고치지 않는다. 정정은 반대 부호의 새 행이다.
* 2. **멱등** — `txId = SHA-256(idempotencyKey)[0..20]` 이고 항상 `create()` 한다.
* 재시도가 겹쳐도 두 번째가 `ALREADY_EXISTS` 로 떨어진다.
* 3. **혼합 차감 금지** — 원장 한 행은 `popType` 하나다. 활동(일반) POP 과 지원(기여)
* POP 은 회계가 다르므로 한 행이 둘을 함께 건드릴 수 없다. 겸용 사용처(B-06)는
* `spendSplit` 이 타입별 행 2건을 한 트랜잭션에 쓴다.
*/
export type PopType = 'activity' | 'support';
export type TxType = 'grant' | 'spend' | 'refund' | 'void';
export type ReasonCode =
| 'QUEST_STEPS' | 'QUEST_MUSIC_SHAKE' | 'QUEST_PLACE' | 'QUEST_ATTEND' | 'QUEST_TIMEATTACK'
| 'AD_REWARD' | 'HERO_DROP' | 'TRANSLATION' | 'IAP_PURCHASE' | 'FOCUS_DAILY'
| 'ADMIN_GRANT' | 'PREREG_BONUS' | 'QNA_REWARD' | 'REFERRAL_REWARD'
| 'CAMPAIGN_FUND' | 'QNA_BOUNTY' | 'ECLIPSE_DEFENSE' | 'HERO_DROP_TRIGGER'
| 'ZAP_CONTRIBUTION' | 'CAMPAIGN_FEE'
| 'CAMPAIGN_REFUND' | 'QNA_REFUND' | 'ADMIN_CLAWBACK'
// 등록비 반환(B-58 반려 · 회사귀책 · 공개 전 철회). MY 표기 「캠페인 개설 수수료 환불」.
| 'CAMPAIGN_FEE_REFUND'
// 사용자 생성 Quest(C-02 · C-09 · B-30) — 참여 보상 · 수수료(Burn) · Pool 예치 · 미사용분 반환 ·
// 공개 전 실패 시 예치 원타입 환불.
| 'QUEST_UGC' | 'QUEST_FEE' | 'QUEST_POOL_DEPOSIT' | 'QUEST_POOL_RETURN' | 'QUEST_POOL_REFUND';
/**
* 기여로 집계하는 차감 사유 — `user.totalContributedPop` 의 모집단(§3.6).
*
* `ZAP_CONTRIBUTION` 은 **행성 성장 기여**다(별첨 B **B-23** — *ZAP 차감액은 누적 기여
* POP 에 실제 값으로 반영되고, 행성 성장용 Growth 진행도에만 가중을 둔다*). 가중은
* 원장과 sFPI 에 적용하지 않으므로 여기서는 실제 차감액 그대로 쌓인다.
*
* `ECLIPSE_DEFENSE` 는 남겨 두지만 **더 이상 쓰이지 않는다** — 이클립스 방어는 POP 을
* 소모하지 않는 공동 Shake 참여로 확정되었다(설계서 v1.1 §9-#5). 과거 원장 행을
* 읽는 쪽이 있어 사유 코드 자체는 지우지 않는다(카탈로그는 append-only).
*/
export const CONTRIBUTION_REASONS: readonly ReasonCode[] = [
'CAMPAIGN_FUND', 'QNA_BOUNTY', 'ECLIPSE_DEFENSE', 'ZAP_CONTRIBUTION',
];
export interface LotRef { lotId: string; delta: number }
export interface GrantInput {
uid: string;
popType: PopType;
amount: number;
reasonCode: ReasonCode;
idempotencyKey: string;
refPath?: string;
actorUid?: string;
/** 검수 대기 지급(정책설계서 P-02). 잔액이 아니라 `pendingActivity` 에 쌓인다. */
pending?: boolean;
/** 검증된 보상 경로가 지정한다. 일반 광고/관리자 지급은 자동 포함하지 않는다. */
growthSource?: 'quest_reward' | 'company_ad_bonus';
/**
* 지급과 **같은 트랜잭션에서** 함께 쓸 것.
*
* 퀘스트 진행 카운터가 대표적이다. 지급 뒤에 따로 쓰면 그 사이 실패가 났을 때
* 「POP 은 나갔는데 일일 한도는 0」인 상태가 남고, 그 상태에서 한 번 더 부르면
* 같은 날 두 번 지급된다. 같은 트랜잭션이면 둘이 함께 서거나 함께 없다.
*
* 멱등키가 같은 재시도에서는 부르지 않는다 — 첫 호출이 이미 썼다.
* `user` 는 이 트랜잭션에서 읽은 사용자 문서다(`growthSource` 지급일 때). FPI 이벤트와
* EXP(`writeFpiEvent`)가 이 값으로 레벨을 다시 매긴다.
*/
alsoWrite?: (tx: Transaction, user: FirebaseFirestore.DocumentSnapshot | null) => void;
}
export interface SpendInput {
/**
* 차감 전에 **같은 트랜잭션에서** 읽고 판정할 것(예: 캠페인 목표 잔여분). 던지면 차감도
* 없다. 모든 읽기가 쓰기보다 앞서야 하므로 원장 쓰기 전에 부른다. 재시도에서는 부르지 않는다.
*/
precheck?: (tx: Transaction) => Promise<void>;
/** 차감과 함께 확정할 이벤트. 재시도에서는 다시 쓰지 않는다. `parts` 는 이번에 쓴 원장 행이다. */
alsoWrite?: (tx: Transaction, parts: SpendPart[]) => void;
uid: string;
popType: PopType;
amount: number;
reasonCode: ReasonCode;
idempotencyKey: string;
refPath?: string;
fandomId?: string;
/**
* 한 행이 몇 번의 행위를 묶은 것인가. 기본값은 두지 않는다 — 없으면 적지 않는다.
*
* ZAP 연타처럼 화면이 여러 번의 누름을 한 호출로 묶으면 `amount` 만으로는 횟수를
* 복원할 수 없다. MY 의 POP 내역이 「Zap N 회」를 **행 수로 세면 실제보다 작게**
* 나오므로, 묶은 횟수를 원장 행에 그대로 남긴다.
*/
count?: number;
}
export interface LedgerResult {
txId: string;
balanceAfter: number;
lotRefs: LotRef[];
}
const balanceField = (t: PopType) =>
t === 'activity' ? 'activityBalance' : 'supportBalance';
const walletRef = (db: Firestore, uid: string) =>
db.doc(`users/${uid}/wallet/main`);
/**
* 지갑을 읽는다. 없으면 빈 지갑으로 본다.
*
* 트리거가 만들기 전에 첫 지급이 도착할 수 있으므로(경합) 여기서 없다고 실패시키지
* 않는다. 같은 트랜잭션 안에서 `set(merge)` 로 만들면 된다.
*/
async function readWallet(tx: Transaction, db: Firestore, uid: string) {
const ref = walletRef(db, uid);
const snap = await tx.get(ref);
const data = snap.data() ?? {};
return {
ref,
exists: snap.exists,
activityBalance: (data.activityBalance as number | undefined) ?? 0,
supportBalance: (data.supportBalance as number | undefined) ?? 0,
pendingActivity: (data.pendingActivity as number | undefined) ?? 0,
};
}
/** 원장과 같은 트랜잭션에서만 호출한다. 사용자별 원 거래 ID가 성장 멱등 키다. */
function writeGrowth(
tx: Transaction, db: Firestore, uid: string, txId: string,
fandomId: string, amount: number,
sourceType: 'quest_reward' | 'company_ad_bonus' | 'zap' | 'funding',
): void {
tx.create(db.doc(`users/${uid}/growthEvents/${txId}`), {
sourceEventId: txId,
sourceType,
fandomIdAtPayout: fandomId,
amount,
reversalOf: null,
reason: null,
createdAt: FieldValue.serverTimestamp(),
});
tx.set(db.doc(`fandoms/${fandomId}`), {
growthPop: FieldValue.increment(amount),
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
}
/**
* 지급 — `fn-pop-grant` 의 코어.
*
* lot 하나와 원장 한 행을 만들고 지갑 잔액을 올린다. 셋이 한 트랜잭션이라
* 부분 반영이 남지 않는다.
*/
export async function grantPop(input: GrantInput, transaction?: Transaction): Promise<LedgerResult> {
const db = getFirestore();
const txId = txIdOf(input.idempotencyKey);
const lotId = lotIdOf(txId);
const pending = input.pending === true;
if (pending && input.popType !== 'activity') {
// 지원 POP 은 결제 확정분이라 검수 대기 상태가 존재하지 않는다.
fail(ERR.popVerifyFailed, '지원 POP 은 보류 지급 대상이 아닙니다.');
}
const write = async (tx: Transaction): Promise<LedgerResult> => {
const wallet = await readWallet(tx, db, input.uid);
const txRef = db.doc(`users/${input.uid}/popTransactions/${txId}`);
const existing = await tx.get(txRef);
if (existing.exists) {
// 같은 멱등키의 재시도. 원장을 두 번 쓰지 않고 기존 결과를 그대로 돌려준다.
const prev = existing.data() ?? {};
return {
txId,
balanceAfter: (prev.balanceAfter as number | undefined) ?? 0,
lotRefs: (prev.lotRefs as LotRef[] | undefined) ?? [],
};
}
// 지급 순간 귀속을 원장에 고정한다. 재시도는 위 existing 분기에서 끝난다.
const growthUser = input.growthSource && !pending
? await tx.get(db.doc(`users/${input.uid}`)) : null;
const fandomIdAtPayout = fandomOfUser(growthUser);
const current = input.popType === 'activity'
? wallet.activityBalance
: wallet.supportBalance;
const balanceAfter = pending ? current : current + input.amount;
const lotRefs: LotRef[] = [{ lotId, delta: input.amount }];
tx.create(db.doc(`users/${input.uid}/popLots/${lotId}`), {
popType: input.popType,
grantedAmount: input.amount,
remaining: input.amount,
grantedAt: FieldValue.serverTimestamp(),
status: pending ? 'pending' : 'active',
sourceTxId: txId,
reasonCode: input.reasonCode,
createdAt: FieldValue.serverTimestamp(),
updatedAt: FieldValue.serverTimestamp(),
});
tx.create(txRef, {
type: 'grant',
...(input.growthSource ? { fandomIdAtPayout, growthSource: input.growthSource } : {}),
popType: input.popType,
amount: input.amount,
reasonCode: input.reasonCode,
idempotencyKey: input.idempotencyKey,
...(input.refPath ? { refPath: input.refPath } : {}),
...(input.actorUid ? { actorUid: input.actorUid } : {}),
lotRefs,
balanceAfter,
// 검증 없는 지급 경로가 없다는 것을 원장 자체에 남긴다 — 별첨1 ② 판정 근거.
serverVerified: true,
pending,
createdAt: FieldValue.serverTimestamp(),
});
if (input.growthSource && !pending && fandomIdAtPayout && input.amount > 0) {
writeGrowth(tx, db, input.uid, txId, fandomIdAtPayout, input.amount, input.growthSource);
}
// FPI 이벤트 · EXP 를 적는 콜백은 이 트랜잭션에서 읽은 사용자 문서가 필요하다.
input.alsoWrite?.(tx, growthUser);
tx.set(wallet.ref, {
[balanceField(input.popType)]: pending
? current
: FieldValue.increment(input.amount),
pendingActivity: pending
? FieldValue.increment(input.amount)
: wallet.pendingActivity,
updatedAt: FieldValue.serverTimestamp(),
...(wallet.exists ? {} : {
activityBalance: input.popType === 'activity' && !pending ? input.amount : 0,
supportBalance: input.popType === 'support' ? input.amount : 0,
pendingActivity: pending ? input.amount : 0,
reconcileStatus: 'ok',
createdAt: FieldValue.serverTimestamp(),
}),
}, { merge: true });
return { txId, balanceAfter, lotRefs };
};
return transaction ? write(transaction) : db.runTransaction(write);
}
/** 차감 한 행의 결과. 겸용 사용처에서 두 재화가 함께 쓰이면 두 행이다. */
export interface SpendPart extends LedgerResult {
popType: PopType;
amount: number;
}
/**
* 보류 지급 정산 — `grantPop({ pending: true })` 로 잡아 둔 금액을 **확정**하거나 **해제**한다.
*
* 원장은 append-only 라 원 보류 행을 고치지 않고 정산 행을 하나 더 쓴다. 정산 행 ID 가
* `{원 멱등키}:settle` 로 고정이라 확정과 해제가 둘 다 일어날 수 없고, 재시도는 기존 결과를 돌려준다.
* - confirm: lot `pending → active`, `pendingActivity -= n`, `activityBalance += n`, 정산 행 `grant`(n).
* - release: lot `pending → void`(잔량 0), `pendingActivity -= n`, 정산 행 `void`(0).
* 어느 쪽이든 `총 보유 = 예약(pendingActivity) + 사용 가능` 등식이 유지된다(QA 시험 사례 2-29).
*/
export async function settlePendingGrant(params: {
uid: string;
idempotencyKey: string;
outcome: 'confirm' | 'release';
}): Promise<LedgerResult> {
const db = getFirestore();
const originalTxId = txIdOf(params.idempotencyKey);
const settleTxId = txIdOf(`${params.idempotencyKey}:settle`);
const lotId = lotIdOf(originalTxId);
return db.runTransaction(async (tx) => {
const wallet = await readWallet(tx, db, params.uid);
const originalRef = db.doc(`users/${params.uid}/popTransactions/${originalTxId}`);
const settleRef = db.doc(`users/${params.uid}/popTransactions/${settleTxId}`);
const lotRef = db.doc(`users/${params.uid}/popLots/${lotId}`);
const [original, settled, lot] = await Promise.all([
tx.get(originalRef), tx.get(settleRef), tx.get(lotRef),
]);
if (settled.exists) {
const prev = settled.data() ?? {};
return {
txId: settleTxId,
balanceAfter: (prev.balanceAfter as number | undefined) ?? 0,
lotRefs: (prev.lotRefs as LotRef[] | undefined) ?? [],
};
}
if (!original.exists || original.get('pending') !== true || !lot.exists) {
fail(ERR.popVerifyFailed, '정산할 보류 지급이 없습니다.');
}
const amount = (original.get('amount') as number | undefined) ?? 0;
const confirm = params.outcome === 'confirm';
const balanceAfter = wallet.activityBalance + (confirm ? amount : 0);
const lotRefs: LotRef[] = [{ lotId, delta: confirm ? amount : 0 }];
tx.update(lotRef, confirm
? { status: 'active', updatedAt: FieldValue.serverTimestamp() }
: { status: 'void', remaining: 0, updatedAt: FieldValue.serverTimestamp() });
tx.create(settleRef, {
type: confirm ? 'grant' : 'void',
popType: 'activity',
amount: confirm ? amount : 0,
reasonCode: original.get('reasonCode'),
idempotencyKey: `${params.idempotencyKey}:settle`,
settlesTxId: originalTxId,
...(original.get('refPath') ? { refPath: original.get('refPath') } : {}),
lotRefs,
balanceAfter,
serverVerified: true,
pending: false,
createdAt: FieldValue.serverTimestamp(),
});
tx.set(wallet.ref, {
activityBalance: confirm ? FieldValue.increment(amount) : wallet.activityBalance,
pendingActivity: FieldValue.increment(-amount),
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
return { txId: settleTxId, balanceAfter, lotRefs };
});
}
/**
* 차감 — `fn-pop-spend` 의 코어. 한 재화만 건드린다.
*
* 잔액이 모자라면 E-POP-01 로 끊는다. 같은 타입 안에서는 `grantedAt` 오름차순
* (선입선출)으로 lot 을 소진한다(설계서 §3.2 ②).
*/
export async function spendPop(input: SpendInput): Promise<LedgerResult> {
const [part] = await spendParts(input, [input.popType]);
return { txId: part!.txId, balanceAfter: part!.balanceAfter, lotRefs: part!.lotRefs };
}
/**
* 겸용 사용처 차감 — 정책 원장 **B-06** · 설계서 §3.2.
*
* 일반(activity) POP 을 먼저 소진하고 부족분만 기여(support) POP 에서 뺀다. 두 재화가
* 함께 쓰이면 원장을 `popType` 별로 **2건** 쓰되 한 트랜잭션이라 함께 서거나 함께 없다
* (혼합 차감 금지 불변식은 행 단위로 그대로다). 합계가 모자라면 E-POP-01 이고 행은 0건이다.
*
* 멱등키 하나가 두 행을 묶는다 — 일반 행은 `txIdOf(key)`, 기여 행은 `txIdOf(key:support)`.
* 재시도는 이미 있는 행을 그대로 돌려준다. 환불은 [refundSplit] 이 행마다 원래 타입으로 돌린다.
*/
export async function spendSplit(input: Omit<SpendInput, 'popType'>): Promise<SpendPart[]> {
return spendParts(input, ['activity', 'support']);
}
async function spendParts(
input: Omit<SpendInput, 'popType'>, types: PopType[],
): Promise<SpendPart[]> {
if (!Number.isSafeInteger(input.amount) || input.amount <= 0) {
fail(ERR.popVerifyFailed, '차감액은 양의 정수여야 합니다.');
}
const db = getFirestore();
const txIds = types.map((t, i) =>
txIdOf(i === 0 ? input.idempotencyKey : `${input.idempotencyKey}:${t}`));
// lot 부족이 드러난 시도. 트랜잭션 안의 쓰기는 실패와 함께 되돌려지므로 대사 표시는
// 트랜잭션이 끝난 뒤 따로 쓴다.
let mismatch = false;
try {
return await db.runTransaction((tx) => {
mismatch = false;
return spendInTx(tx);
});
} catch (error) {
if (mismatch) {
await walletRef(db, input.uid).set({
reconcileStatus: 'mismatch',
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
}
throw error;
}
async function spendInTx(tx: Transaction): Promise<SpendPart[]> {
const txRefs = txIds.map((id) => db.doc(`users/${input.uid}/popTransactions/${id}`));
const prior = (await Promise.all(txRefs.map((ref) => tx.get(ref)))).filter((s) => s.exists);
if (prior.length > 0) {
// 같은 멱등키의 재시도. 모든 행을 한 트랜잭션에서 썼으므로 있는 것이 전부다.
return prior.map((s) => ({
txId: s.id,
popType: s.get('popType') as PopType,
amount: Math.abs((s.get('amount') as number | undefined) ?? 0),
balanceAfter: (s.get('balanceAfter') as number | undefined) ?? 0,
lotRefs: (s.get('lotRefs') as LotRef[] | undefined) ?? [],
}));
}
await input.precheck?.(tx);
const wallet = await readWallet(tx, db, input.uid);
// 음수 잔액(대사 전의 어긋난 지갑)을 분할 몫으로 쓰지 않는다.
const balanceOf = (t: PopType) =>
Math.max(0, t === 'activity' ? wallet.activityBalance : wallet.supportBalance);
if (types.reduce((sum, t) => sum + balanceOf(t), 0) < input.amount) {
fail(ERR.popInsufficient, 'POP 잔액이 부족합니다.');
}
// 앞 타입부터 채우고 부족분만 다음 타입(B-06).
let need = input.amount;
const plan = types.map((popType, i) => {
const amount = Math.min(balanceOf(popType), need);
need -= amount;
return { popType, amount, txId: txIds[i]!, ref: txRefs[i]! };
}).filter((p) => p.amount > 0);
// 기여 차감이면 EXP·FPI 귀속을 정해야 하므로 사용자 문서를 함께 읽는다.
// 트랜잭션은 모든 읽기가 쓰기보다 앞서야 하므로 여기서 읽어 둔다.
const userSnap = CONTRIBUTION_REASONS.includes(input.reasonCode)
? await tx.get(db.doc(`users/${input.uid}`))
: null;
const lotsSnaps = await Promise.all(plan.map((p) => tx.get(
db.collection(`users/${input.uid}/popLots`)
.where('popType', '==', p.popType)
.where('status', '==', 'active')
.orderBy('grantedAt', 'asc'),
)));
const parts: SpendPart[] = [];
const lotWrites: Array<[DocumentReference, number]> = [];
plan.forEach((p, i) => {
let left = p.amount;
const lotRefs: LotRef[] = [];
for (const doc of lotsSnaps[i]!.docs) {
if (left === 0) break;
const remaining = (doc.get('remaining') as number | undefined) ?? 0;
if (remaining <= 0) continue;
const take = Math.min(remaining, left);
left -= take;
lotRefs.push({ lotId: doc.id, delta: -take });
lotWrites.push([doc.ref, remaining - take]);
}
if (left > 0) {
// 지갑 잔액은 충분한데 lot 합이 모자란 상태. 원장이 이미 어긋나 있다는 뜻이라
// 차감을 진행하지 않고 대사 대상으로 표시한다 — 자동 정정은 원인을 덮는다(§3.6).
mismatch = true;
fail(ERR.popVerifyFailed, 'POP 잔액과 획득 내역이 일치하지 않습니다.');
}
parts.push({
txId: p.txId, popType: p.popType, amount: p.amount,
balanceAfter: balanceOf(p.popType) - p.amount, lotRefs,
});
});
for (const [ref, remaining] of lotWrites) {
tx.update(ref, {
remaining,
status: remaining === 0 ? 'exhausted' : 'active',
updatedAt: FieldValue.serverTimestamp(),
});
}
parts.forEach((part, i) => {
tx.create(plan[i]!.ref, {
type: 'spend',
popType: part.popType,
// 부호 있는 값 — 차감은 음수(§3.3). 합산만으로 잔액이 재현되어야 한다.
amount: -part.amount,
reasonCode: input.reasonCode,
// 분할 차감의 두 행은 같은 멱등키를 적는다 — 한 요청이었음을 원장에서 묶어 본다.
idempotencyKey: input.idempotencyKey,
...(input.refPath ? { refPath: input.refPath } : {}),
// 묶음 횟수. 없으면 적지 않는다 — 읽는 쪽이 「없으면 1 회」로 셀 수 있다.
...(input.count !== undefined ? { count: input.count } : {}),
lotRefs: part.lotRefs,
balanceAfter: part.balanceAfter,
serverVerified: true,
// 규범 확정(B-06) 뒤의 행임을 남긴다. 과거 행은 'grantedAt-asc-nonnormative'.
lotOrder: 'general-first-fifo',
createdAt: FieldValue.serverTimestamp(),
});
tx.set(wallet.ref, {
[balanceField(part.popType)]: FieldValue.increment(-part.amount),
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
});
// Zap 의 귀속 팬덤은 **서버가 이 트랜잭션에서 읽은 대표 팬덤**이다(FPI 와 같은 판정).
// 클라이언트 `fandomId` 를 믿으면 남의 행성 성장을 올린다. 후원은 호출자(fundCampaign)가
// 캠페인 문서에서 정한 팬덤을 넘긴다.
const fandomId = input.reasonCode === 'ZAP_CONTRIBUTION' ? fandomOfUser(userSnap) : input.fandomId;
if (CONTRIBUTION_REASONS.includes(input.reasonCode)) {
// 누적 기여 POP 은 Passport 입력 · 기여 실적 표시용으로 유지한다. 등급 문턱은
// 더 이상 여기서 매기지 않는다(C-18 — 누적 기여 POP 5등급 치환 폐기).
tx.set(db.doc(`users/${input.uid}`), {
totalContributedPop: FieldValue.increment(input.amount),
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
if (fandomId) {
tx.set(db.doc(`fandoms/${fandomId}/members/${input.uid}`), {
contributedPop: FieldValue.increment(input.amount),
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
}
}
// 예치(QNA_BOUNTY) · 등록비(CAMPAIGN_FEE) 등 다른 차감은 성장 · EXP · FPI 로 확대하지
// 않는다(C-30). 기여 POP 으로 냈어도 같다 — 원천을 가르는 것은 재화가 아니라 사유다.
const growthSpend = input.reasonCode === 'ZAP_CONTRIBUTION' || input.reasonCode === 'CAMPAIGN_FUND';
const sourceId = parts[0]!.txId;
if (growthSpend) {
// FPI 는 기여 **행위 단위**다(C-34). 금액이 아니라 Zap 횟수(`fn-pop-spend` 가 서버에서
// 금액 ÷ zapPop 으로 계산) · 후원 1건. EXP 는 주지 않는다 — POP 을 쓰는 행위는 1 POP
// 단위로 쪼개 반복할 수 있어 건당 EXP 가 파밍된다(C-18 · 리뷰 B1).
writeFpiEvent(tx, {
uid: input.uid, fandomId: fandomOfUser(userSnap), axis: 'contribution',
units: input.reasonCode === 'ZAP_CONTRIBUTION' ? input.count ?? 1 : 1,
activity: input.reasonCode === 'ZAP_CONTRIBUTION' ? 'zap' : 'campaign_fund',
sourceId, occurredAt: new Date(),
...(input.refPath ? { refPath: input.refPath } : {}),
grantsExp: false,
}, userSnap);
}
if (fandomId && growthSpend) {
writeGrowth(tx, db, input.uid, sourceId, fandomId, input.amount,
input.reasonCode === 'ZAP_CONTRIBUTION' ? 'zap' : 'funding');
tx.set(db.doc(`fandoms/${fandomId}`), {
lastContributorName: publicNameOf(userSnap),
lastContributedPop: input.amount,
}, { merge: true });
}
input.alsoWrite?.(tx, parts);
return parts;
}
}
type RefundReason = Extract<ReasonCode,
'CAMPAIGN_REFUND' | 'QNA_REFUND' | 'CAMPAIGN_FEE_REFUND' | 'QUEST_POOL_REFUND'>;
/**
* 환불 — `fn-pop-refund` 의 코어.
*
* 원 거래를 되돌리는 것이 아니라 **반대 부호의 새 원장**을 쓴다. 원장이 진실이라는
* 원칙(§3.3)을 지키려면 과거 행이 그대로 남아 있어야 한다. 다른 멱등키로 이미 환불된
* 원거래는 E-POP-03 이다 — 호출자가 그 환불(`refundedBy`)을 받아들일지 정한다.
*/
export async function refundPop(params: {
uid: string;
originalTxId: string;
idempotencyKey: string;
reasonCode: RefundReason;
}): Promise<LedgerResult> {
const [result] = await refundParts(
params.uid, [params.originalTxId], params.idempotencyKey, params.reasonCode, false);
return result!;
}
/**
* 분할 차감([spendSplit])의 환불 — 행마다 **원래 타입 그대로** 돌린다(B-39 「원타입 환불」).
*
* 모든 행을 한 트랜잭션에서 돌려주므로 일부만 환불된 상태가 남지 않는다. 멱등키 하나가
* 전체를 묶는다 — 첫 행은 `txIdOf(key)`, 나머지는 `txIdOf(key:원거래ID)`. 같은 원거래 ID 는
* 한 번만 센다. 다른 경로가 이미 돌려준 행은 다시 돌려주지 않고 그 환불 ID 를 결과로 쓴다.
*/
export async function refundSplit(params: {
uid: string;
originalTxIds: string[];
idempotencyKey: string;
reasonCode: RefundReason;
}): Promise<LedgerResult[]> {
return refundParts(params.uid, params.originalTxIds, params.idempotencyKey, params.reasonCode, true);
}
async function refundParts(
uid: string, originalTxIds: string[], idempotencyKey: string, reasonCode: RefundReason,
reuseRefunded: boolean,
): Promise<LedgerResult[]> {
const ids = [...new Set(originalTxIds)];
if (ids.length === 0) fail(ERR.popVerifyFailed, '환불할 원거래가 없습니다.');
const db = getFirestore();
const txIds = ids.map((orig, i) =>
txIdOf(i === 0 ? idempotencyKey : `${idempotencyKey}:${orig}`));
const rowOf = (s: FirebaseFirestore.DocumentSnapshot): LedgerResult => ({
txId: s.id,
balanceAfter: (s.get('balanceAfter') as number | undefined) ?? 0,
lotRefs: (s.get('lotRefs') as LotRef[] | undefined) ?? [],
});
return db.runTransaction(async (tx) => {
const txRefs = txIds.map((id) => db.doc(`users/${uid}/popTransactions/${id}`));
const originalRefs = ids.map((id) => db.doc(`users/${uid}/popTransactions/${id}`));
const [own, originals] = await Promise.all([
Promise.all(txRefs.map((ref) => tx.get(ref))),
Promise.all(originalRefs.map((ref) => tx.get(ref))),
]);
// 이 키로 이미 쓴 행은 그대로(재시도), 다른 환불이 먼저 간 행은 그 환불을 쓴다.
const results: Array<LedgerResult | null> = own.map((s) => (s.exists ? rowOf(s) : null));
const todo: number[] = [];
for (const [i, original] of originals.entries()) {
if (results[i]) continue;
if (!original.exists) {
fail(ERR.popVerifyFailed, '환불할 원거래를 찾을 수 없습니다.');
}
if (original.get('type') !== 'spend') {
fail(ERR.popVerifyFailed, '차감 거래만 환불할 수 있습니다.');
}
const refundedBy = original.get('refundedBy') as string | undefined;
if (!refundedBy) {
todo.push(i);
} else if (!reuseRefunded) {
fail(ERR.popDuplicate, '이미 환불된 거래입니다.');
} else {
results[i] = rowOf(await tx.get(db.doc(`users/${uid}/popTransactions/${refundedBy}`)));
}
}
if (todo.length === 0) return results as LedgerResult[];
const wallet = await readWallet(tx, db, uid);
const running = { activity: wallet.activityBalance, support: wallet.supportBalance };
let contributed = 0;
for (const i of todo) {
const original = originals[i]!;
const popType = original.get('popType') as PopType;
const amount = Math.abs(original.get('amount') as number);
running[popType] += amount;
const balanceAfter = running[popType];
const txId = txIds[i]!;
const lotId = lotIdOf(txId);
tx.create(db.doc(`users/${uid}/popLots/${lotId}`), {
popType,
grantedAmount: amount,
remaining: amount,
grantedAt: FieldValue.serverTimestamp(),
status: 'active',
sourceTxId: txId,
reasonCode,
createdAt: FieldValue.serverTimestamp(),
updatedAt: FieldValue.serverTimestamp(),
});
tx.create(txRefs[i]!, {
type: 'refund',
popType,
amount,
reasonCode,
idempotencyKey,
refPath: originalRefs[i]!.path,
lotRefs: [{ lotId, delta: amount }],
balanceAfter,
serverVerified: true,
createdAt: FieldValue.serverTimestamp(),
});
// 원 거래에는 환불 사실만 표시한다. 금액·부호는 손대지 않는다(append-only).
tx.update(originalRefs[i]!, { refundedBy: txId });
tx.set(wallet.ref, {
[balanceField(popType)]: FieldValue.increment(amount),
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
if (CONTRIBUTION_REASONS.includes(original.get('reasonCode') as ReasonCode)) contributed += amount;
results[i] = { txId, balanceAfter, lotRefs: [{ lotId, delta: amount }] };
}
if (contributed > 0) {
// 반환은 Growth · EXP 를 만들지도 되돌리지도 않는다(C-30 · C-32).
tx.set(db.doc(`users/${uid}`), {
totalContributedPop: FieldValue.increment(-contributed),
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
}
return results as LedgerResult[];
});
}
/**
* 성장 역분개 — 정책 원장 **C-32**.
*
* 허위 참여 · 중복 지급 · 오지급이 **확정**된 뒤 운영자가 부르는 경로다. 원 growth
* 이벤트를 참조하는 음수 행을 새로 쓰고 `fandoms.growthPop` 을 그만큼 줄인다(append-only).
* 잘못 반영된 몫만 정정하므로 `amount` 는 원 이벤트 이하다. 원 이벤트 하나에 역분개는
* 한 번뿐이다(문서 ID `rev-{원 이벤트}`) — 재시도는 기존 결과를 돌려준다.
*
* Quest 종료 · 취소 · 의심 단계는 이 경로를 부르지 않는다. 지갑 회수 · EXP · 과거
* FPI/sFPI 재산정과도 분리한다.
*/
export async function reverseGrowth(params: {
uid: string; sourceEventId: string; amount: number; reason: string; actorUid: string;
}): Promise<{ reversalId: string; fandomId: string; amount: number }> {
const db = getFirestore();
const reversalId = `rev-${params.sourceEventId}`;
return db.runTransaction(async (tx) => {
const originalRef = db.doc(`users/${params.uid}/growthEvents/${params.sourceEventId}`);
const reversalRef = db.doc(`users/${params.uid}/growthEvents/${reversalId}`);
const [original, existing] = await Promise.all([tx.get(originalRef), tx.get(reversalRef)]);
if (existing.exists) {
return {
reversalId,
fandomId: existing.get('fandomIdAtPayout') as string,
amount: -(existing.get('amount') as number),
};
}
const originalAmount = original.get('amount');
const fandomId = original.get('fandomIdAtPayout');
if (!original.exists || original.get('reversalOf') != null ||
typeof originalAmount !== 'number' || originalAmount <= 0 || typeof fandomId !== 'string') {
fail(ERR.popVerifyFailed, '역분개할 성장 기록을 찾을 수 없습니다.');
}
if (!Number.isInteger(params.amount) || params.amount <= 0 || params.amount > originalAmount) {
fail(ERR.popVerifyFailed, '역분개 수치는 원 성장량 이하의 양의 정수여야 합니다.');
}
// Quest 보상 전액 역분개 = 그 완료의 사후 무효 → 원장 행에 걸린 검증 활동 표식을 무효로
// (Campaign 개설자격 A안). 일부 정정은 금액 오류라 완료 자체는 유효하다. 읽기라 쓰기보다 앞선다.
const voidWrite = original.get('sourceType') === 'quest_reward' && params.amount === originalAmount
? await voidCompletions(tx, params.uid, { txId: original.get('sourceEventId') ?? params.sourceEventId })
: null;
tx.create(reversalRef, {
sourceEventId: original.get('sourceEventId') ?? params.sourceEventId,
sourceType: original.get('sourceType'),
fandomIdAtPayout: fandomId,
amount: -params.amount,
reversalOf: params.sourceEventId,
reason: params.reason,
actorUid: params.actorUid,
createdAt: FieldValue.serverTimestamp(),
});
tx.set(db.doc(`fandoms/${fandomId}`), {
growthPop: FieldValue.increment(-params.amount),
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
voidWrite?.(tx);
return { reversalId, fandomId, amount: params.amount };
});
}
/** 대사 결과 한 사용자분(§3.6). */
export interface ReconcileRow {
uid: string;
ok: boolean;
checks: Array<{ name: string; expected: number; actual: number }>;
}
/**
* 잔액 대조 — 별첨 1 ② 검수 방법의 3자 교차 대조.
*
* **자동으로 고치지 않는다.** 감지하고 표시만 한다 — 자동 정정은 원인을 덮는다.
*/
export async function reconcileUser(uid: string): Promise<ReconcileRow> {
const db = getFirestore();
const [walletSnap, lotsSnap, txSnap, userSnap] = await Promise.all([
walletRef(db, uid).get(),
db.collection(`users/${uid}/popLots`).where('status', '==', 'active').get(),
db.collection(`users/${uid}/popTransactions`).get(),
db.doc(`users/${uid}`).get(),
]);
const wallet = walletSnap.data() ?? {};
const checks: ReconcileRow['checks'] = [];
for (const t of ['activity', 'support'] as const) {
const lotSum = lotsSnap.docs
.filter((d) => d.get('popType') === t)
.reduce((acc, d) => acc + ((d.get('remaining') as number) ?? 0), 0);
const txSum = txSnap.docs
.filter((d) => d.get('popType') === t && d.get('pending') !== true)
.reduce((acc, d) => acc + ((d.get('amount') as number) ?? 0), 0);
const balance = (wallet[balanceField(t)] as number | undefined) ?? 0;
checks.push({ name: `${t}:balance-vs-lot`, expected: lotSum, actual: balance });
checks.push({ name: `${t}:balance-vs-ledger`, expected: txSum, actual: balance });
}
const contributionSpends = txSnap.docs.filter((d) =>
d.get('type') === 'spend' &&
CONTRIBUTION_REASONS.includes(d.get('reasonCode') as ReasonCode));
const contributed = contributionSpends
.reduce((acc, d) => acc + Math.abs((d.get('amount') as number) ?? 0), 0);
// 기여가 아닌 차감(등록비 등)의 환불은 누적 기여를 줄이지 않는다 — refundParts 와 같은 판정.
const contributionPaths = new Set(contributionSpends.map((d) => d.ref.path));
const refunded = txSnap.docs
.filter((d) => d.get('type') === 'refund' && contributionPaths.has(d.get('refPath') as string))
.reduce((acc, d) => acc + ((d.get('amount') as number) ?? 0), 0);
const statsActual =
(userSnap.get('totalContributedPop') as number | undefined) ?? 0;
checks.push({
name: 'ledger-vs-stats',
expected: contributed - refunded,
actual: statsActual,
});
// User Level ↔ EXP, Passport Tier 단조(설계서 §3.4 · B-65 · B-66). 두 체계를 따로 본다.
const storedLevel = userSnap.get('userLevel') as number | undefined;
if (storedLevel !== undefined) {
checks.push({
name: 'level-vs-exp',
expected: levelOf((userSnap.get('userExp') as number | undefined) ?? 0),
actual: storedLevel,
});
}
const storedPassport = userSnap.get('passportTier') as PassportTier | undefined;
if (storedPassport !== undefined) {
// 레벨이 요구하는 등급보다 낮게 저장돼 있으면(강등 · 누락) 불일치다.
checks.push({
name: 'passport-monotonic',
// 레벨 위조가 이 검사까지 오염시키지 않도록 저장된 레벨이 아니라 EXP 로 본다.
expected: passportRank(passportTierOf(
levelOf((userSnap.get('userExp') as number | undefined) ?? 0), storedPassport)),
actual: passportRank(storedPassport),
});
}
const ok = checks.every((c) => c.expected === c.actual);
await walletRef(db, uid).set({
lastReconciledAt: Timestamp.now(),
reconcileStatus: ok ? 'ok' : 'mismatch',
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
return { uid, ok, checks };
}
+118
View File
@@ -0,0 +1,118 @@
import { onCall } from 'firebase-functions/v2/https';
import { onSchedule } from 'firebase-functions/v2/scheduler';
import { getFirestore } from 'firebase-admin/firestore';
import { logger } from 'firebase-functions';
import { callerUid, num, optionalStr, requireRole, str } from '../core/callable.js';
import { ERR, fail, positiveInt } from '../core/errors.js';
import { loadPolicy } from '../core/config.js';
import {
grantPop, refundPop, reconcileUser, spendPop,
type PopType, type ReasonCode,
} from './ledger.js';
const POP_TYPES: readonly PopType[] = ['activity', 'support'];
function popType(data: unknown): PopType {
const value = str(data, 'popType');
if (!POP_TYPES.includes(value as PopType)) {
fail(ERR.popVerifyFailed, 'popType 은 activity 또는 support 여야 합니다.');
}
return value as PopType;
}
/**
* `fn-pop-grant` — 관리자 지급 전용 입구.
*
* 사용자 스스로 부르는 지급 경로는 두지 않았다. 퀘스트·광고 보상은 각자의 검증
* 함수를 거쳐야 하고, 이 함수를 사용자에게 열면 별첨1 ② 「클라이언트 단독 지급
* 0건」이 코드 층에서 깨진다.
*/
export const fnPopGrant = onCall(async (req) => {
const actorUid = callerUid(req);
requireRole(req, ['master', 'ops']);
const result = await grantPop({
uid: str(req.data, 'uid'),
popType: popType(req.data),
amount: positiveInt(num(req.data, 'amount'), 'amount'),
reasonCode: str(req.data, 'reasonCode') as ReasonCode,
idempotencyKey: str(req.data, 'idempotencyKey'),
actorUid,
...(optionalStr(req.data, 'refPath') ? { refPath: str(req.data, 'refPath') } : {}),
});
return { txId: result.txId, balanceAfter: result.balanceAfter };
});
/**
* 사용자가 직접 부를 수 있는 차감 사유 — **허용 목록**.
*
* 다른 사유는 각자의 검증 함수를 지나야 한다. 예전에는 사유를 그대로 받아 `CAMPAIGN_FUND`
* 를 캠페인 검증 없이 쓰거나(B-33 · B-34 우회) 원장 사유를 위조할 수 있었다(리뷰 H1 · R03).
*/
const USER_SPEND_REASONS: readonly ReasonCode[] = ['ZAP_CONTRIBUTION'];
/**
* `fn-pop-spend` — 본인 계정에서만 차감한다. 지금 열린 사유는 Zap 하나다.
*
* **Zap 횟수는 서버가 센다** — `금액 ÷ zapPop`(운영값). 클라이언트 `count` 를 믿으면
* 1 POP 으로 FPI CONTRIBUTION 축을 무제한 올린다(리뷰 B1). 귀속 팬덤도 원장이 사용자
* 문서에서 정한다 — 클라이언트 `fandomId` 는 받지 않는다(리뷰 M1).
*/
export const fnPopSpend = onCall(async (req) => {
const uid = callerUid(req);
const reasonCode = str(req.data, 'reasonCode') as ReasonCode;
if (!USER_SPEND_REASONS.includes(reasonCode)) {
fail(ERR.permissionDenied, '이 경로로 쓸 수 없는 사유입니다.');
}
const amount = positiveInt(num(req.data, 'amount'), 'amount');
const { zapPop } = await loadPolicy();
const count = Math.floor(amount / zapPop);
if (!Number.isSafeInteger(count) || count < 1 || count * zapPop !== amount) {
fail(ERR.popVerifyFailed, `Zap 은 ${zapPop} POP 단위로만 보낼 수 있습니다.`);
}
return spendPop({
uid,
popType: popType(req.data),
amount,
reasonCode,
// 사용자가 정한 키는 `user:{uid}:` 아래에 둔다. 그대로 쓰면 서버 경로의 키
// (`bounty:…` · `campaignFee:…`)를 먼저 차지해 그 거래를 막을 수 있다.
idempotencyKey: `user:${uid}:${str(req.data, 'idempotencyKey')}`,
...(optionalStr(req.data, 'refPath') ? { refPath: str(req.data, 'refPath') } : {}),
// 묶음 횟수. 원장 행에 남아 「Zap N 회」를 세고, FPI 단위가 된다.
count,
});
});
/** `fn-pop-refund` — 운영자만. 캠페인 실패·현상금 만료의 되돌림 경로다. */
export const fnPopRefund = onCall(async (req) => {
callerUid(req);
requireRole(req, ['master', 'ops']);
const result = await refundPop({
uid: str(req.data, 'uid'),
originalTxId: str(req.data, 'txId'),
idempotencyKey: str(req.data, 'idempotencyKey'),
reasonCode: str(req.data, 'reasonCode') as 'CAMPAIGN_REFUND' | 'QNA_REFUND',
});
return { txId: result.txId, balanceAfter: result.balanceAfter };
});
/**
* `fn-pop-reconcile` — 일 1회 3자 교차 대조(§3.6).
*
* 불일치를 **고치지 않고 표시만** 한다. 사용자 수가 늘면 이 전수 스캔은
* 샤딩해야 하지만, 대조 자체는 계약 검수 항목이라 먼저 동작하게 두었다.
*/
export const fnPopReconcile = onSchedule('every day 04:00', async () => {
const db = getFirestore();
const users = await db.collection('users').select().get();
let mismatched = 0;
for (const doc of users.docs) {
const row = await reconcileUser(doc.id);
if (!row.ok) {
mismatched += 1;
logger.error('[reconcile] 원장 불일치', { uid: row.uid, checks: row.checks });
}
}
logger.info('[reconcile] 완료', { scanned: users.size, mismatched });
});
+137
View File
@@ -0,0 +1,137 @@
import { getMessaging } from 'firebase-admin/messaging';
import { Timestamp, type DocumentSnapshot } from 'firebase-admin/firestore';
import { logger } from 'firebase-functions';
/**
* FCM 발송 — 정책 원장 B-77 「Push 알림」 5개 구분의 서버 쪽 절반.
*
* **구분별로 토픽을 따로 둔다.** 한 팬덤 토픽에 다섯 구분을 몰아 보내면 사용자가
* 「AI Briefing 만 끄기」를 골랐을 때 서버가 그것을 걸러 낼 방법이 없다. FCM 토픽
* 발송은 수신자 목록을 서버가 만들지 않기 때문이다. 구분마다 토픽을 나누고 앱이
* 자기 설정대로 구독·해지하면 **OFF 구분의 발송은 구조적으로 0 건**이 된다
* (B-77 검증 기준 「OFF 카테고리 발송 0건」).
*
* `marketing` 은 앱이 기본 OFF 로 두어 구독하지 않는다(별첨 B 「별도 동의 기본 OFF」).
*/
export const PUSH_CATEGORIES = [
'essential', 'fandomEvent', 'briefing', 'community', 'marketing',
] as const;
export type PushCategory = typeof PUSH_CATEGORIES[number];
/**
* FCM 토픽 이름은 `[a-zA-Z0-9-_.~%]+` 만 받는다. 팬덤 ID 에 그 밖의 문자가 섞이면
* 발송이 `invalid-argument` 로 떨어지는데, 오류 문구가 토픽을 가리키지 않아
* 원인을 찾기 어렵다. 그래서 여기서 한 번 걸러 둔다.
*/
export function fandomTopic(fandomId: string, category: PushCategory): string {
return `fandom_${fandomId.replace(/[^a-zA-Z0-9\-_.~%]/g, '-')}_${category}`;
}
export interface PushMessage {
fandomId: string;
category: PushCategory;
title: string;
body: string;
/** 앱이 탭 시 어디로 갈지 판단하는 값. 문자열만 실을 수 있다(FCM 규약). */
data?: Record<string, string>;
}
/**
* 팬덤 토픽 한 건 발송.
*
* **발송 실패가 호출자를 깨뜨리지 않게 한다.** 브리핑 게시와 이클립스 집계가
* 푸시 실패로 롤백되면 데이터는 맞는데 화면이 비는 상태가 된다. 실패는 로그로
* 남기고 `false` 를 돌려준다.
*/
export async function sendFandomPush(message: PushMessage): Promise<boolean> {
const topic = fandomTopic(message.fandomId, message.category);
try {
const id = await getMessaging().send({
topic,
notification: { title: message.title, body: message.body },
data: { category: message.category, fandomId: message.fandomId, ...message.data },
android: { priority: 'high' },
// iOS 는 APNs 키가 들어오기 전까지 실제로 전달되지 않는다(별첨 A 계정 이전
// 대기). 코드 경로는 지금 맞춰 두어야 키가 들어온 날 수정이 없다.
apns: { payload: { aps: { sound: 'default' } } },
});
logger.info('[push] 발송', { topic, messageId: id });
return true;
} catch (error) {
logger.error('[push] 발송 실패', { topic, message: (error as Error).message });
return false;
}
}
/**
* 이클립스 사전 알림 — 원장 **C-22** 「발생 3분 전 푸시」 · C-24 「푸시 = 시작 − 3분」.
*
* `fn-eclipse-trigger` 가 회차의 `warnAt`(시작 − `warnSeconds`) 이 지난 첫 점검에서
* 부른다. 활성화 뒤에 보내면 예고가 아니라 사후 통지가 된다.
*
* - `alertPushedAt` 이 있으면 보내지 않는다 — 1 분 주기 점검이 도배하지 않는다.
* - 시작 시각이 이미 지났으면 보내지 않고 표시만 남긴다 — 배포 직후 첫 점검이나
* 스케줄러 지연으로 늦게 본 회차에 「곧 시작」을 소급 발송하지 않는다.
* - 취소된 회차(C-28)에는 보내지 않는다.
*
* `alertPushedAt` 에 **판정 시각 [now]** 를 적는다 — 발송 시각 = 시작 − 3분을
* 회차 문서만으로 검증할 수 있게 한다.
*/
export type EclipseAlertOutcome =
/** 예고를 보냈다. */
| 'sent'
/** 보내려 했으나 FCM 발송이 실패했다. 표시는 남겼다. */
| 'failed'
/** 이미 보냈거나 보낼 대상이 아니다(없음 · 취소). */
| 'already'
/** 예고 시각 · 시작 시각을 몰라 보내지 않았다. */
| 'no-start'
/** 아직 예고 시각 전이다. */
| 'too-early'
/** 시작 시각이 지나 예고로서 의미가 없다. */
| 'too-old';
export async function notifyEclipseWarning(
eclipse: DocumentSnapshot,
now: Date = new Date(),
): Promise<EclipseAlertOutcome> {
if (!eclipse.exists || eclipse.get('alertPushedAt') || eclipse.get('status') === 'cancelled') {
return 'already';
}
const warnAt = eclipse.get('warnAt') as Timestamp | undefined;
const startAt = eclipse.get('startAt') as Timestamp | undefined;
if (!warnAt || !startAt) {
logger.debug('[push] 예고 시각이 없어 보내지 않습니다', { path: eclipse.ref.path });
return 'no-start';
}
if (now.getTime() < warnAt.toMillis()) return 'too-early';
const mark = () => eclipse.ref.set({ alertPushedAt: Timestamp.fromDate(now) }, { merge: true });
if (now.getTime() >= startAt.toMillis()) {
await mark();
logger.info('[push] 시작 시각이 지나 예고를 건너뜁니다', { path: eclipse.ref.path });
return 'too-old';
}
// `fandoms/{fandomId}/eclipseRounds/{roundId}` 이므로 조부모가 팬덤 문서다.
const fandomRef = eclipse.ref.parent.parent;
if (!fandomRef) {
logger.error('[push] 이클립스 경로가 팬덤 아래가 아닙니다', { path: eclipse.ref.path });
return 'no-start';
}
const name = ((await fandomRef.get()).get('name') as string | undefined) ?? fandomRef.id;
const minutes = Math.max(1, Math.round((startAt.toMillis() - now.getTime()) / 60_000));
const sent = await sendFandomPush({
fandomId: fandomRef.id,
category: 'fandomEvent',
title: '이클립스가 곧 시작됩니다',
body: `${minutes}분 뒤 ${name} 행성에 이클립스가 찾아옵니다. 함께 흔들어 행성을 지켜 주세요.`,
data: { kind: 'eclipse', eclipseId: eclipse.id },
});
// 발송 성공 여부와 무관하게 표시한다. 실패를 이유로 남겨 두면 1 분마다 같은
// 실패를 반복하고, 복구된 순간 지난 이클립스 예고가 한꺼번에 나간다.
await mark();
return sent ? 'sent' : 'failed';
}
+452
View File
@@ -0,0 +1,452 @@
import { onCall } from 'firebase-functions/v2/https';
import { getFirestore, FieldValue, Timestamp } from 'firebase-admin/firestore';
import { logger } from 'firebase-functions';
import { callerUid, optionalStr, requireRole, str } from '../core/callable.js';
import { txIdOf } from '../core/ids.js';
import { dayKey } from '../core/time.js';
import { dropConfig, dropDocument, publicNickname } from '../fandom/herodrop.js';
import { loadPolicy } from '../core/config.js';
import { ERR, fail } from '../core/errors.js';
import { grantPop, spendPop } from '../ledger/ledger.js';
import {
popForProduct,
StoreReceiptVerifier,
SUBSCRIPTION_PRODUCTS,
type Platform,
type ReceiptClaim,
type ReceiptVerifier,
} from './receipt.js';
/**
* 결제 — 별첨 1 ⑥ 「영수증 서버검증 통과(위조 차단)」·「환불 결제 건 지급 회수」.
*
* 세 가지를 지킨다.
* 1. **금액은 서버가 정한다.** 클라이언트가 보낸 수량을 쓰지 않고 상품 ID 로 표를 본다.
* 2. **멱등키는 스토어 거래 ID 다.** 같은 구매가 두 번 지급되지 않는다.
* 3. **환불은 회수한다.** 잔액이 모자라면 음수 잔액을 만들지 않고 회수 대기로 남긴다.
*/
let verifier: ReceiptVerifier = new StoreReceiptVerifier();
/** 테스트에서 스토어 호출을 대역으로 바꾼다. */
export function setReceiptVerifier(next: ReceiptVerifier): void {
verifier = next;
}
const PLATFORMS: readonly Platform[] = ['ios', 'android'];
function platformOf(data: unknown): Platform {
const value = str(data, 'platform');
if (!PLATFORMS.includes(value as Platform)) {
fail(ERR.payInvalidReceipt, 'platform 은 ios 또는 android 여야 합니다.');
}
return value as Platform;
}
/** 스토어 거래 ID 를 원장 멱등키로 옮긴다 — 설계서 §3.3 의 IAP 키 구성. */
const iapKey = (claim: ReceiptClaim) =>
`iap:${claim.platform}:${claim.transactionId}`;
/** POP 묶음 상품 ID 접두 — 원장 B-02 로 기본 판매 중지(`popPackSalesEnabled`). */
const POP_PACK_PREFIX = 'pop.support.';
const PAY_METHOD: Readonly<Record<Platform, string>> = {
android: 'Google Play',
ios: 'App Store',
};
interface HeroGiftProduct { name: string; buyerPop: number; dropPop: number }
/**
* Hero Gift 상품 Catalog — 원장 B-85. 문서 ID 가 스토어 상품 ID 다.
*
* Admin `관리 마스터_아이템 관리` 가 `fnAdminWrite` 로 쓴다. 상품을 늘려도 앱을
* 다시 배포하지 않는다(B-40 검증 기준). Catalog 에 없으면 null — 일반 상품 표로 넘어간다.
*/
async function heroGiftProduct(productId: string): Promise<HeroGiftProduct | null> {
const snap = await getFirestore().doc(`heroGiftProducts/${productId}`).get();
if (!snap.exists) return null;
if (snap.get('active') !== true) {
fail(ERR.payInvalidReceipt, '판매가 끝난 상품입니다.');
}
const buyerPop = snap.get('buyerPop');
const dropPop = snap.get('dropPop');
if (!Number.isSafeInteger(buyerPop) || buyerPop <= 0 ||
!Number.isSafeInteger(dropPop) || dropPop <= 0) {
// Admin 확정값이 없으면 지급량을 지어내지 않는다(B-84 「Admin 저장값과 정확 일치」).
fail(ERR.payVerifyFailed, '상품 지급량이 아직 설정되지 않았습니다.');
}
if (buyerPop !== dropPop) {
// B-84 — 구매자 기여 POP 과 팬덤 Drop Pool 일반 POP 은 **같은 수량**이다. 어긋난 Catalog
// 값으로 지급하지 않는다. Admin 이 고치면 다음 재전송에서 지급된다(재시도 가능한 E-PAY-02).
fail(ERR.payVerifyFailed, '상품 지급량이 정책(구매자 = Drop Pool 동일 수량)과 다릅니다.');
}
const name = snap.get('name');
return { name: typeof name === 'string' ? name : productId, buyerPop, dropPop };
}
/**
* 거래 ID 를 한 계정에 묶는다.
*
* 원장 멱등키는 사용자 아래에 있어서, 같은 영수증을 **다른 계정**이 보내면 그
* 계정 원장에는 키가 없어 한 번 더 지급된다. 거래 ID 의 주인을 전역 문서로 고정한다.
*/
async function claimTransaction(purchaseId: string, uid: string): Promise<void> {
const ref = getFirestore().doc(`iapTransactions/${purchaseId}`);
try {
await ref.create({ uid, createdAt: FieldValue.serverTimestamp() });
} catch (e) {
const code = (e as { code?: number | string }).code;
if (code !== 6 && code !== 'already-exists') throw e;
if ((await ref.get()).get('uid') !== uid) {
fail(ERR.payInvalidReceipt, '다른 계정에서 이미 사용된 결제입니다.');
}
}
}
/** 화면 표시용 값. 앱이 스토어에서 받은 가격·이름이라 **지급 판단에 쓰지 않는다.** */
function displayFields(data: unknown, platform: Platform) {
const raw = (data ?? {}) as Record<string, unknown>;
const price = raw.priceAmount;
const currency = raw.currency;
const title = raw.title;
return {
method: PAY_METHOD[platform],
...(typeof price === 'number' && Number.isFinite(price) && price >= 0
? { priceAmount: price } : {}),
...(typeof currency === 'string' && /^[A-Z]{3}$/.test(currency) ? { currency } : {}),
title: typeof title === 'string' ? title.slice(0, 60) : '',
};
}
/**
* `fn-iap-verify` — 구매 검증 후 혜택 지급.
*
* 상품 ID 로 세 갈래를 가른다.
* - 구독(Focus) — POP 없이 구독 상태를 켠다(B-04 광고 제거).
* - Hero Gift Catalog — 구매자 기여 POP + 소속 팬덤 Drop Pool(B-84). 둘이 한 트랜잭션.
* 알려진 한계: 이 구매가 환불되면 `fn-iap-refund-webhook` 은 구매자 기여 POP 만 회수하고
* **이미 열린 Drop Pool(다른 팬이 받았을 수 있는 일반 POP)은 회수하지 않는다.**
* - 그 밖의 상품 표 — 기여 POP. 단 POP 묶음(`pop.support.*`)은 B-02 로 `popPackSalesEnabled`
* 가 켜졌을 때만 지급한다(기본 거부 · E-PAY-01).
*
* 오류 코드 규약(앱이 이것으로 거래를 닫을지 정한다): E-PAY-01 은 재시도해도 결과가 같은
* 거부(판매 중지 · 다른 계정 · 소속 행성 없음), E-PAY-02 는 설정·검증이 준비되면 통과할 수 있는 실패다.
*
* 지급 전에 구매 기록을 먼저 남긴다. 지급이 실패해도 「검증은 됐는데 지급이
* 안 된 구매」가 추적 가능해야 한다. 앱은 성공 응답을 받은 뒤에만 스토어에 구매
* 완료를 알린다 — 실패하면 다음 실행에서 다시 보낸다.
*/
export const fnIapVerify = onCall(async (req) => {
const uid = callerUid(req);
const platform = platformOf(req.data);
const receipt = str(req.data, 'receipt');
const claim = await verifier.verify(platform, receipt, {
productId: optionalStr(req.data, 'productId'),
transactionId: optionalStr(req.data, 'transactionId'),
});
if (claim.revoked) {
fail(ERR.payVerifyFailed, '이미 취소된 결제입니다.');
}
const db = getFirestore();
const purchaseId = `${claim.platform}_${claim.transactionId}`;
const purchaseRef = db.doc(`users/${uid}/purchases/${purchaseId}`);
await claimTransaction(purchaseId, uid);
const existing = await purchaseRef.get();
const priorState = existing.get('state') as string | undefined;
if (existing.exists && priorState !== 'verified') {
// 재전송(복원 · 앱 재시작 · 응답 유실)은 첫 결과를 돌려준다.
// 환불 요청 · 회수 상태를 「지급 완료」로 되돌리지 않는다.
return {
duplicate: true,
state: priorState,
granted: (existing.get('amountPop') as number | undefined) ?? 0,
txId: (existing.get('txId') as string | undefined) ?? null,
};
}
const { title, ...display } = displayFields(req.data, platform);
const base = {
uid,
platform: claim.platform,
transactionId: claim.transactionId,
productId: claim.productId,
purchasedAt: Timestamp.fromMillis(claim.purchasedAtMillis),
...display,
updatedAt: FieldValue.serverTimestamp(),
...(existing.exists ? {} : { createdAt: FieldValue.serverTimestamp() }),
};
if (SUBSCRIPTION_PRODUCTS.includes(claim.productId)) {
// 만료 시각이 미래일 때만 활성이다. 스토어가 만료를 주지 않으면 활성으로 보지 않는다.
const expiresAt = claim.expiresAtMillis;
const active = typeof expiresAt === 'number' && expiresAt > Date.now();
const subRef = db.doc(`users/${uid}/subscription/current`);
const current = (await subRef.get()).get('expiresAt');
// 옛 거래(복원 재전송)가 더 늦게 끝나는 최신 구독 상태를 덮지 않는다.
const stale = current instanceof Timestamp &&
(typeof expiresAt !== 'number' || current.toMillis() > expiresAt);
const batch = db.batch();
if (!stale) {
batch.set(subRef, {
platform: claim.platform,
productId: claim.productId,
transactionId: claim.transactionId,
state: active ? 'active' : 'expired',
adsRemoved: active,
...(typeof expiresAt === 'number' ? { expiresAt: Timestamp.fromMillis(expiresAt) } : {}),
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
}
batch.set(purchaseRef, {
...base,
kind: 'subscription',
itemName: title || 'Focus 구독',
amountPop: 0,
state: 'granted',
...(typeof expiresAt === 'number' ? { expiresAt: Timestamp.fromMillis(expiresAt) } : {}),
}, { merge: true });
await batch.commit();
return { granted: 0, subscription: active ? 'active' : 'expired' };
}
const gift = await heroGiftProduct(claim.productId);
if (!gift && claim.productId.startsWith(POP_PACK_PREFIX) &&
(await loadPolicy()).popPackSalesEnabled !== true) {
// B-02 — POP 자체 판매 상품은 두지 않는다. 스토어에 남아 있어도 지급하지 않는다.
fail(ERR.payInvalidReceipt, '판매하지 않는 상품입니다.');
}
const amount = gift ? gift.buyerPop : popForProduct(claim.productId);
// Hero Gift 는 구매자의 소속 팬덤에 Drop Pool 을 연다. 팬덤은 앱이 아니라 사용자 문서에서 읽는다.
let drop: { fandomId: string; dropId: string; doc: ReturnType<typeof dropDocument> } | null = null;
if (gift) {
const user = await db.doc(`users/${uid}`).get();
// 재시도(지급 전 실패)는 첫 시도에 적어 둔 팬덤을 쓴다 — 그 사이 행성을 옮겨도 Drop 이 가는 곳은 같다.
const fandomId = existing.get('fandomId') ?? user.get('fandomId');
if (typeof fandomId !== 'string' || fandomId.length === 0) {
// 재시도해도 바뀌지 않는 거부라 E-PAY-01 — 앱이 거래를 닫는다.
fail(ERR.payInvalidReceipt, '소속 행성이 있어야 영웅의 선물을 보낼 수 있습니다.');
}
drop = {
fandomId,
dropId: txIdOf(`herodrop:${iapKey(claim)}`),
doc: dropDocument(await dropConfig(gift.dropPop), {
triggeredBy: uid,
heroNickname: publicNickname(user),
productId: claim.productId,
}),
};
}
await purchaseRef.set({
...base,
kind: gift ? 'hero_gift' : 'pop_benefit',
itemName: gift?.name ?? (title || claim.productId),
amountPop: amount,
state: 'verified',
...(drop ? { fandomId: drop.fandomId, dropId: drop.dropId, dropPop: gift!.dropPop } : {}),
}, { merge: true });
const result = await grantPop({
uid,
popType: 'support',
amount,
reasonCode: 'IAP_PURCHASE',
idempotencyKey: iapKey(claim),
refPath: purchaseRef.path,
// Drop 은 지급과 같은 트랜잭션에서 한 번만 만든다. 재시도는 원장 멱등으로 건너뛴다.
...(drop ? {
alsoWrite: (tx) => tx.create(
db.doc(`fandoms/${drop.fandomId}/heroDrops/${drop.dropId}`), drop.doc),
} : {}),
});
await purchaseRef.set({
state: 'granted',
txId: result.txId,
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
return {
txId: result.txId,
balanceAfter: result.balanceAfter,
granted: amount,
...(drop ? { dropId: drop.dropId } : {}),
};
});
/**
* `fn-iap-refund-webhook` — 스토어 환불·취소 알림 처리.
*
* 별첨 1 ⑥ 예외 처리 「환불 결제 건 지급 회수」.
*
* **잔액이 모자라도 음수 잔액을 만들지 않는다.** 이미 쓴 POP 을 강제로 빼면
* 원장 합과 잔액이 어긋나고, 사용자는 쓴 적 없는 마이너스를 보게 된다.
* 대신 회수 대기로 남기고 Admin 큐로 넘긴다.
*/
export const fnIapRefundWebhook = onCall(async (req) => {
// 스토어 서명 검증 어댑터가 연결되기 전에는 신뢰된 관리자만 호출할 수 있다.
requireRole(req, ['master']);
const uid = str(req.data, 'uid');
const platform = platformOf(req.data);
const transactionId = str(req.data, 'transactionId');
const db = getFirestore();
const purchaseId = `${platform}_${transactionId}`;
const purchaseRef = db.doc(`users/${uid}/purchases/${purchaseId}`);
const purchase = await purchaseRef.get();
if (!purchase.exists) {
fail(ERR.payVerifyFailed, '해당 결제를 찾을 수 없습니다.');
}
if (purchase.get('state') === 'clawed-back') {
// 스토어 알림은 중복 도착이 흔하다. 두 번 회수하지 않는다.
return { state: 'clawed-back', recovered: 0 };
}
const amount = (purchase.get('amountPop') as number | undefined) ?? 0;
const wallet = await db.doc(`users/${uid}/wallet/main`).get();
const balance = (wallet.get('supportBalance') as number | undefined) ?? 0;
if (balance < amount) {
await purchaseRef.set({
state: 'clawback-pending',
shortfall: amount - balance,
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
await db.collection('adminActionLogs').add({
actorUid: 'system',
targetPath: purchaseRef.path,
reason: 'IAP 환불 회수 보류 — 잔액 부족',
patch: { amount, balance },
createdAt: FieldValue.serverTimestamp(),
});
logger.warn('[iap] 회수 보류', { uid, purchaseId, amount, balance });
return { state: 'clawback-pending', recovered: 0, shortfall: amount - balance };
}
const result = await spendPop({
uid,
popType: 'support',
amount,
reasonCode: 'ADMIN_CLAWBACK',
idempotencyKey: `clawback:${platform}:${transactionId}`,
refPath: purchaseRef.path,
});
await purchaseRef.set({
state: 'clawed-back',
clawbackTxId: result.txId,
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
return { state: 'clawed-back', recovered: amount, txId: result.txId };
});
/** 이벤트 → 상태. `adsRemoved` 가 혜택이 살아 있는지를 나타낸다. */
const STATE_BY_EVENT: Readonly<
Record<string, { state: string; adsRemoved: boolean }>
> = {
purchased: { state: 'active', adsRemoved: true },
renewed: { state: 'active', adsRemoved: true },
// 해지해도 만료 전까지는 혜택이 남는다.
canceled: { state: 'canceled', adsRemoved: true },
expired: { state: 'expired', adsRemoved: false },
revoked: { state: 'revoked', adsRemoved: false },
};
/**
* `fn-subscription-webhook` — 구독 상태 전이.
*
* 결제 · 갱신 · 해지 · 만료 넷을 받는다. **해지와 만료를 가른다** — 해지는
* 갱신을 끄는 것이고 남은 기간 동안 혜택이 살아 있다. 둘을 합치면 사용자가
* 돈을 낸 기간에 광고가 다시 붙는다.
*/
export const fnSubscriptionWebhook = onCall(async (req) => {
requireRole(req, ['master']);
const uid = str(req.data, 'uid');
const platform = platformOf(req.data);
const productId = str(req.data, 'productId');
const event = str(req.data, 'event');
const notificationId = str(req.data, 'notificationId');
if (!SUBSCRIPTION_PRODUCTS.includes(productId)) {
fail(ERR.paySubscription, '구독 상품이 아닙니다.');
}
const next = STATE_BY_EVENT[event];
if (!next) fail(ERR.paySubscription, '알 수 없는 구독 이벤트입니다.');
const db = getFirestore();
const seen = db.doc(`users/${uid}/subscriptionEvents/${notificationId}`);
if ((await seen.get()).exists) {
// 스토어 알림 ID 로 멱등을 잡는다. 재전송이 상태를 되돌리면 안 된다.
return { state: next.state, duplicate: true };
}
const expiresAtMillis = (req.data as Record<string, unknown>)?.expiresAtMillis;
await db.doc(`users/${uid}/subscription/current`).set({
platform,
productId,
state: next.state,
adsRemoved: next.adsRemoved,
...(typeof expiresAtMillis === 'number'
? { expiresAt: Timestamp.fromMillis(expiresAtMillis) }
: {}),
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
await seen.set({
event,
productId,
createdAt: FieldValue.serverTimestamp(),
});
return { state: next.state, duplicate: false };
});
/**
* `fn-focus-daily-claim` — Focus 구독 혜택, 원장 **B-04**.
*
* *광고 제거 + 일 40 기여 POP, 당일 Claim, 미수령 누적 없음.* 하루는 서버 일 단위(KST
* `dayKey`, 퀘스트 한도와 같은 경계)다. 멱등키가 `focus:{uid}:{day}` 라 같은 날 두 번 지급되지
* 않고, 지난 날 몫은 키가 달라도 **오늘 부르는 것만** 지급하므로 이월이 없다.
*
* 성장 반영은 하지 않는다 — C-31 「Focus 일 40 기여 POP 등 다른 보너스에는 자동 적용하지
* 않음(적용 여부는 §19 S01)」. S01 회신 전까지 지급 시 성장 이벤트를 만들지 않는다.
*/
export const fnFocusDailyClaim = onCall(async (req) => {
const uid = callerUid(req);
const db = getFirestore();
const subRef = db.doc(`users/${uid}/subscription/current`);
const sub = await subRef.get();
const expiresAt = sub.get('expiresAt');
if (!(expiresAt instanceof Timestamp) || expiresAt.toMillis() <= Date.now() ||
!['active', 'canceled'].includes(sub.get('state') as string)) {
// 해지(canceled)는 만료 전까지 혜택이 남는다(fnSubscriptionWebhook 과 같은 규칙).
fail(ERR.paySubscription, 'Focus 구독 중일 때만 받을 수 있습니다.');
}
const day = dayKey(new Date());
const idempotencyKey = `focus:${uid}:${day}`;
const prior = await db.doc(`users/${uid}/popTransactions/${txIdOf(idempotencyKey)}`).get();
const amount = (await loadPolicy()).focusDailyPop;
const result = await grantPop({
uid,
popType: 'support',
amount,
reasonCode: 'FOCUS_DAILY',
idempotencyKey,
refPath: subRef.path,
// 앱이 「오늘 받음」을 그리는 값. 원장과 같은 트랜잭션에서 쓴다.
alsoWrite: (tx) => tx.set(subRef, { focusClaimedDay: day }, { merge: true }),
});
return {
txId: result.txId,
balanceAfter: result.balanceAfter,
granted: prior.exists ? ((prior.get('amount') as number | undefined) ?? amount) : amount,
day,
duplicate: prior.exists,
};
});
@@ -0,0 +1,86 @@
import { ERR, fail } from '../core/errors.js';
/**
* 영수증 검증 경계.
*
* **스토어 서버 호출만 여기 뒤에 둔다.** 검증에는 Apple 의 App Store Server API
* 키와 Google 의 서비스 계정이 필요한데, 둘 다 스토어 계정이 열려야 발급된다
* (계정·권한 이전 별첨 B-1·C-1). 그 자격증명을 기다리는 동안 지급·회수 로직이
* 통째로 멈추지 않도록 **검증만 인터페이스로 빼고 나머지는 지금 완성한다.**
*
* 자격증명이 들어오면 [StoreReceiptVerifier] 안쪽만 채우면 되고, 이 파일을
* 쓰는 함수들은 손대지 않는다.
*/
export type Platform = 'ios' | 'android';
export interface ReceiptClaim {
platform: Platform;
/** 스토어가 이 구매에 부여한 고유 ID. 멱등키의 뼈대다. */
transactionId: string;
productId: string;
/** 소비성 구매인가. 구독은 false. */
consumable: boolean;
purchasedAtMillis: number;
/** 스토어가 이미 환불·취소 처리한 건인가. */
revoked: boolean;
/** 구독 만료 시각. 구독이 아니거나 스토어가 주지 않았으면 없다. */
expiresAtMillis?: number;
}
/**
* 조회 키. **지급 기준으로 쓰지 않는다** — 지급은 스토어가 돌려준 [ReceiptClaim] 으로 정한다.
* - `productId`: Google Play 구매 조회 API 가 토큰과 함께 요구한다.
* - `transactionId`: iOS 앱 영수증은 여러 거래를 담는다. 앱이 방금 받은 거래를 이 ID 로 고른다.
*/
export interface ReceiptHint {
productId?: string | undefined;
transactionId?: string | undefined;
}
export interface ReceiptVerifier {
verify(platform: Platform, receipt: string, hint?: ReceiptHint): Promise<ReceiptClaim>;
}
/**
* 상품 표 — 상품 ID 하나가 지원 POP 얼마인지.
*
* **클라이언트가 보낸 금액을 믿지 않는다.** 앱이 「10만 POP 짜리를 샀다」고 말해도
* 서버는 상품 ID 로 이 표를 본다. 표에 없는 상품 ID 는 지급하지 않는다.
*/
export const PRODUCT_POP: Readonly<Record<string, number>> = {
'pop.support.1000': 1_000,
'pop.support.5500': 5_500,
'pop.support.12000': 12_000,
'pop.support.30000': 30_000,
'pop.support.65000': 65_000,
};
/** 구독 상품. POP 을 주지 않고 광고 제거 상태를 바꾼다. */
export const SUBSCRIPTION_PRODUCTS: readonly string[] = [
'focus.monthly',
'focus.yearly',
];
export function popForProduct(productId: string): number {
const amount = PRODUCT_POP[productId];
if (amount === undefined) {
// 표에 없는 상품 ID — 위조이거나 스토어 상품 등록과 코드가 어긋난 상태다.
// 둘 다 지급하면 안 된다.
fail(ERR.payInvalidReceipt, '알 수 없는 상품입니다.');
}
return amount;
}
/**
* 스토어 실호출 구현 — **자격증명 대기 중이라 아직 비어 있다.**
*
* 비워 두되 조용히 통과시키지 않는다. 자격증명 없이 부르면 `E-PAY-02` 로 끊어
* **검증 없는 지급 경로가 생기지 않게** 한다. 「나중에 채우자」는 주석만 두면
* 그 자리가 열린 채로 배포된다.
*/
export class StoreReceiptVerifier implements ReceiptVerifier {
async verify(_platform: Platform, _receipt: string, _hint?: ReceiptHint): Promise<ReceiptClaim> {
return fail(ERR.payVerifyFailed, '결제 검증 설정이 아직 준비되지 않았습니다.');
}
}
@@ -0,0 +1,267 @@
import { lookup } from 'node:dns';
import type { LookupAddress, LookupOptions } from 'node:dns';
import { request as httpRequest } from 'node:http';
import { request as httpsRequest } from 'node:https';
import { isIP } from 'node:net';
import { HttpsError, onCall } from 'firebase-functions/v2/https';
import { getFirestore, Timestamp } from 'firebase-admin/firestore';
import { callerUid, str } from '../core/callable.js';
/**
* `fn-link-preview` — 링크 기반 Quest 의 원본 제목 · 썸네일 · 서비스명(원장 **C-05** · R10).
*
* 서버가 사용자가 준 URL 을 대신 여는 경로라 **SSRF 방어가 이 파일의 본체**다.
* - http / https 만, 계정 정보(`user:pass@`) · 기본 외 포트 거부
* - 이름 풀이 결과가 사설 · 루프백 · 링크 로컬 · 예약 대역이면 거부 — **연결 시점의 lookup 을
* 갈아 끼워** 판정과 실제 연결이 같은 주소를 본다(DNS rebinding 차단)
* - 리다이렉트는 3회까지, 매 단계 같은 검사
* - 전체 5초 · 본문 512KB 상한
*
* 결과 `status` — `ok`(조회 성공) · `preview_failed`(형식은 유효한데 조회만 실패 → 기본 카드 +
* 제목 최소 입력) · `invalid`(저장 거부 · 수정 요청).
*/
export type LinkStatus = 'ok' | 'preview_failed' | 'invalid';
export interface LinkPreview {
url: string;
status: LinkStatus;
title?: string | undefined;
thumbnail?: string | undefined;
provider?: string | undefined;
}
const TIMEOUT_MS = 5_000;
const MAX_BYTES = 512 * 1024;
const MAX_REDIRECTS = 3;
/** 공개 http(s) URL 이면 URL, 아니면 null. 이름 풀이는 하지 않는다. */
export function parsePublicUrl(raw: string): URL | null {
if (raw.length > 2048) return null;
let url: URL;
try {
url = new URL(raw.trim());
} catch {
return null;
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
if (url.username || url.password || !url.hostname) return null;
if (url.port && url.port !== '80' && url.port !== '443') return null;
const host = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.internal')) return null;
if (isIP(host) && isPrivateAddress(host)) return null;
return url;
}
function v4Octets(ip: string): number[] | null {
const parts = ip.split('.').map(Number);
return parts.length === 4 && parts.every((n) => Number.isInteger(n) && n >= 0 && n <= 255)
? parts : null;
}
/** 공인 인터넷 주소가 아니면 true — 모르는 형식도 true(닫힌 쪽으로 판정). */
export function isPrivateAddress(ip: string): boolean {
const v4 = v4Octets(ip);
if (v4) {
const [a, b] = v4 as [number, number, number, number];
return a === 0 || a === 10 || a === 127 || a >= 224
|| (a === 100 && b >= 64 && b <= 127)
|| (a === 169 && b === 254)
|| (a === 172 && b >= 16 && b <= 31)
|| (a === 192 && b === 168)
|| (a === 192 && b === 0)
|| (a === 198 && (b === 18 || b === 19))
|| (a === 198 && b === 51 && v4[2] === 100) // TEST-NET-2
|| (a === 203 && b === 0 && v4[2] === 113); // TEST-NET-3
}
if (isIP(ip) !== 6) return true;
const low = ip.toLowerCase();
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(low);
if (mapped) return isPrivateAddress(mapped[1]!);
return low === '::' || low === '::1'
|| /^f[cd]/.test(low) // fc00::/7
|| /^fe[89ab]/.test(low) // fe80::/10
|| low.startsWith('ff') // 멀티캐스트
|| low.startsWith('64:ff9b:') // NAT64 — 안쪽 v4 를 가릴 수 없다
|| low.startsWith('2001:db8:') // 문서용
|| low.startsWith('2002:') // 6to4 — 안쪽 v4 를 가릴 수 없다
|| low.startsWith('::'); // v4 호환 등 나머지 예약
}
class BlockedAddress extends Error {}
/** 연결에 쓰는 lookup — 풀이 결과 중 하나라도 사설이면 연결 자체를 막는다. */
function safeLookup(
hostname: string,
options: LookupOptions,
callback: (err: NodeJS.ErrnoException | null, address: string | LookupAddress[], family?: number) => void,
): void {
lookup(hostname, { ...options, all: true }, (err, addresses) => {
if (err) return callback(err, '');
const list = addresses as LookupAddress[];
if (list.length === 0 || list.some((a) => isPrivateAddress(a.address))) {
return callback(new BlockedAddress('blocked address'), '');
}
if (options.all) return callback(null, list);
return callback(null, list[0]!.address, list[0]!.family);
});
}
interface Fetched { status: number; location?: string | undefined; body: string; contentType: string }
function fetchOnce(url: URL, deadline: number): Promise<Fetched> {
return new Promise((resolve, reject) => {
const left = deadline - Date.now();
if (left <= 0) return reject(new Error('timeout'));
const send = url.protocol === 'https:' ? httpsRequest : httpRequest;
const req = send(url, {
method: 'GET',
lookup: safeLookup as never,
timeout: left,
headers: { 'user-agent': 'FANUZU-LinkPreview/1.0', accept: 'text/html,application/json' },
}, (res) => {
const status = res.statusCode ?? 0;
const location = typeof res.headers.location === 'string' ? res.headers.location : undefined;
const contentType = String(res.headers['content-type'] ?? '');
if (status >= 300 && status < 400) {
res.resume();
return resolve({ status, location, body: '', contentType });
}
const chunks: Buffer[] = [];
let size = 0;
res.on('data', (chunk: Buffer) => {
size += chunk.length;
// 상한까지만 읽는다. 메타 태그는 문서 앞쪽에 있다.
if (size > MAX_BYTES) {
chunks.push(chunk.subarray(0, chunk.length - (size - MAX_BYTES)));
req.destroy();
resolve({ status, body: Buffer.concat(chunks).toString('utf8'), contentType });
return;
}
chunks.push(chunk);
});
res.on('end', () => resolve({ status, body: Buffer.concat(chunks).toString('utf8'), contentType }));
res.on('error', reject);
});
// `timeout` 은 유휴 시간이다. 조금씩 흘려보내는 응답도 전체 시한에서 끊는다.
const timer = setTimeout(() => req.destroy(new Error('timeout')), left);
req.on('close', () => clearTimeout(timer));
req.on('timeout', () => req.destroy(new Error('timeout')));
req.on('error', reject);
req.end();
});
}
/** 리다이렉트를 따라가며 매 단계 URL 을 다시 검사한다. */
async function fetchPublic(url: URL): Promise<Fetched> {
const deadline = Date.now() + TIMEOUT_MS;
let current = url;
for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
const res = await fetchOnce(current, deadline);
if (res.status < 300 || res.status >= 400) return res;
const next = res.location ? parsePublicUrl(new URL(res.location, current).toString()) : null;
if (!next) throw new BlockedAddress('blocked redirect');
current = next;
}
throw new Error('too many redirects');
}
function decodeEntities(s: string): string {
return s
.replace(/&#(\d+);/g, (_, n: string) => String.fromCodePoint(Number(n)))
.replace(/&#x([0-9a-f]+);/gi, (_, n: string) => String.fromCodePoint(parseInt(n, 16)))
.replace(/&quot;/g, '"').replace(/&#39;|&apos;/g, "'")
.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&')
.trim();
}
/** 태그 하나의 속성 값. 태그 길이가 이미 제한돼 있어 역추적이 짧다. */
function attr(tag: string, name: string): string | undefined {
const m = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, 'i').exec(tag);
return m ? m[1] ?? m[2] : undefined;
}
/**
* OG 메타 → 제목 · 썸네일 · 서비스명. 없으면 `<title>` 로 떨어진다.
*
* 입력은 공격자가 고른 HTML 이다. `</head>` 또는 앞 64KB 까지만 보고, 태그 단위로 길이를 묶은
* 정규식으로 잘라 낸 뒤 속성을 따로 읽는다 — 한 정규식에 대안 · 무제한 반복을 겹치면 역추적 폭발(ReDoS).
*/
export function parseHtmlMeta(html: string): Omit<LinkPreview, 'url' | 'status'> {
const headEnd = html.search(/<\/head>/i);
const head = html.slice(0, Math.min(headEnd < 0 ? html.length : headEnd, 64 * 1024));
const metas: Record<string, string> = {};
for (const [tag] of head.matchAll(/<meta\b[^>]{0,2048}>/gi)) {
const key = (attr(tag, 'property') ?? attr(tag, 'name'))?.toLowerCase();
const content = attr(tag, 'content');
if (key && content !== undefined && !(key in metas)) metas[key] = decodeEntities(content);
}
const titleTag = /<title[^>]{0,256}>([^<]{0,512})<\/title>/i.exec(head)?.[1];
const title = metas['og:title'] || (titleTag ? decodeEntities(titleTag) : '') || undefined;
const image = metas['og:image'];
return {
title: title?.slice(0, 200),
thumbnail: image && parsePublicUrl(image) ? image : undefined,
provider: metas['og:site_name']?.slice(0, 100) || undefined,
};
}
const YOUTUBE = /(^|\.)(youtube\.com|youtu\.be)$/i;
/** URL 하나의 미리보기. 네트워크 실패는 `preview_failed`, 형식 · 주소 위반은 `invalid`. */
export async function linkPreview(raw: string): Promise<LinkPreview> {
const url = parsePublicUrl(raw);
if (!url) return { url: raw, status: 'invalid' };
const href = url.toString();
try {
if (YOUTUBE.test(url.hostname)) {
const oembed = new URL('https://www.youtube.com/oembed');
oembed.searchParams.set('url', href);
oembed.searchParams.set('format', 'json');
const res = await fetchPublic(oembed);
if (res.status !== 200) return { url: href, status: 'preview_failed' };
const data = JSON.parse(res.body) as Record<string, unknown>;
const title = typeof data.title === 'string' ? data.title.slice(0, 200) : '';
const thumb = typeof data.thumbnail_url === 'string' && parsePublicUrl(data.thumbnail_url)
? data.thumbnail_url : undefined;
if (!title) return { url: href, status: 'preview_failed' };
return { url: href, status: 'ok', title, thumbnail: thumb, provider: 'YouTube' };
}
const res = await fetchPublic(url);
if (res.status !== 200 || !/html/i.test(res.contentType)) return { url: href, status: 'preview_failed' };
const meta = parseHtmlMeta(res.body);
if (!meta.title) return { url: href, status: 'preview_failed' };
return {
url: href, status: 'ok', title: meta.title, thumbnail: meta.thumbnail,
provider: meta.provider ?? url.hostname.replace(/^www\./, ''),
};
} catch (error) {
// 사설 주소로 풀리는 이름 · 사설로 튀는 리다이렉트는 공개 링크가 아니다.
return { url: href, status: error instanceof BlockedAddress ? 'invalid' : 'preview_failed' };
}
}
/** `fn-link-preview` — 생성 화면의 미리보기. 저장 시에는 `fn-quest-create` 가 다시 조회한다. */
export const fnLinkPreview = onCall(async (req) => {
const uid = callerUid(req);
const url = str(req.data, 'url');
await takeLinkPreviewSlot(uid);
return linkPreview(url);
});
/** 사용자당 분당 호출 상한 — 서버를 대신 여는 경로라 남용을 막는다. */
export const LINK_PREVIEW_PER_MINUTE = 30;
export async function takeLinkPreviewSlot(uid: string, now = Date.now()): Promise<void> {
const db = getFirestore();
const ref = db.doc(`users/${uid}/rateLimits/linkPreview`);
await db.runTransaction(async (tx) => {
const snap = await tx.get(ref);
const start = snap.get('windowStart');
const fresh = !(start instanceof Timestamp) || now - start.toMillis() >= 60_000;
const count = fresh ? 0 : ((snap.get('count') as number | undefined) ?? 0);
if (count >= LINK_PREVIEW_PER_MINUTE) throw new HttpsError('resource-exhausted', '잠시 후 다시 시도해 주세요.');
tx.set(ref, { windowStart: fresh ? Timestamp.fromMillis(now) : start, count: count + 1 });
});
}
+406
View File
@@ -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),
};
});
@@ -0,0 +1,200 @@
import { onCall } from 'firebase-functions/v2/https';
import { getFirestore, FieldValue, Timestamp } from 'firebase-admin/firestore';
import { callerUid, num, requireRole, str } from '../core/callable.js';
import { ERR, fail, positiveInt } from '../core/errors.js';
import { txIdOf } from '../core/ids.js';
import { dayKey } from '../core/time.js';
import { FPI_AXES, loadPolicy } from '../core/config.js';
import { fandomOfUser, markCompletion, writeFpiEvent, writeUserExp } from '../ledger/fpi.js';
import { grantPop } from '../ledger/ledger.js';
import { sendFandomPush } from '../notify/push.js';
import { questOf } from './quest.js';
/**
* 긴급 돌발 타임어택 — 별첨 1 ③-4 「참여 → 검증 → 보상 지급」 · FR-QST-06
* 「운영이 지정한 시간대에 돌발 퀘스트를 노출하고 제한 시간 내 참여 → 검증 → 보상 지급」.
*
* 기준자료에 있는 정의는 이 두 줄뿐이다(화면 대조 원장 `S-H-TIMEATTACK` 「Figma 화면 없음」).
* 그래서 **가장 보수적인 해석**으로 만든다 — 해석 확인 요청 대상은 보고서에 남겼다.
* - 발동은 운영(master)만. 팬덤 한 곳 · 지금부터 N분.
* - 참여 = 창 안에서 서버에 참여를 올리는 것. 판정은 **서버 시각**만 본다.
* - 보상은 퀘스트 문서의 `rewardPop` · 1인 1회 · 원장 멱등.
* - 보상 상한은 `config/policy.timeAttackMaxPop`(기본 1,000). 지속 시간 상한은
* `config/policy.timeAttackMaxMinutes`(기본 1,440 = 24시간).
* - 인정 활동이라 User EXP(C-18)를 주고, FPI 는 `config/policy.timeAttackFpiAxis` 축 1 단위
* (해석 확인 요청 Q-TA-05 권고 A안 · 기본 COMMUNITY). null 이면 FPI 에 넣지 않는다(B안).
*/
/** 제목 길이 상한 — 푸시 제목 · 홈 카드 한 줄에 들어가는 길이. */
const MAX_TITLE = 60;
/** 서버 시각이 창 안인가. 끝 시각은 포함하지 않는다. */
export function inWindow(
now: Date, from: Timestamp | undefined, to: Timestamp | undefined,
): boolean {
return !!from && !!to && now.getTime() >= from.toMillis() && now.getTime() < to.toMillis();
}
/**
* `fn-quest-timeattack-start` — 운영 발동.
*
* `quests` 는 master 전용 문서라(`fn-admin-write` MASTER_ONLY) 같은 선을 긋는다.
* 재전송은 `requestId` 로 모은다 — 같은 발동이 두 번 눌려 퀘스트 · 푸시가 둘이 되지 않게.
*/
export const fnQuestTimeAttackStart = onCall(async (req) => {
const actorUid = callerUid(req);
requireRole(req, ['master']);
const fandomId = str(req.data, 'fandomId');
const title = str(req.data, 'title');
const requestId = str(req.data, 'requestId');
const rewardPop = positiveInt(num(req.data, 'rewardPop'), 'rewardPop');
const minutes = positiveInt(num(req.data, 'durationMinutes'), 'durationMinutes');
const { timeAttackMaxPop, timeAttackMaxMinutes } = await loadPolicy();
if (minutes > timeAttackMaxMinutes) {
fail(ERR.questIncomplete, `타임어택은 ${timeAttackMaxMinutes}분을 넘길 수 없습니다.`);
}
// `/` 가 들어가면 문서 경로가 갈라져 다른 문서를 가리킨다.
if (fandomId.includes('/')) fail(ERR.questIncomplete, '팬덤 ID 가 올바르지 않습니다.');
if (title.length > MAX_TITLE) fail(ERR.questIncomplete, `제목은 ${MAX_TITLE}자 이내입니다.`);
if (rewardPop > timeAttackMaxPop) {
fail(ERR.questIncomplete, `타임어택 보상은 ${timeAttackMaxPop} POP 을 넘길 수 없습니다.`);
}
const db = getFirestore();
const fandom = await db.doc(`fandoms/${fandomId}`).get();
if (!fandom.exists) fail(ERR.questIncomplete, '없는 팬덤입니다.');
const questId = `timeattack_${txIdOf(`timeattack:${actorUid}:${requestId}`)}`;
const ref = db.doc(`quests/${questId}`);
const now = new Date();
const activeTo = Timestamp.fromMillis(now.getTime() + minutes * 60_000);
try {
await ref.create({
type: 'timeattack',
title,
fandomId,
rewardPop,
dailyLimit: 1,
active: true,
activeFrom: Timestamp.fromDate(now),
activeTo,
createdBy: actorUid,
createdAt: FieldValue.serverTimestamp(),
updatedAt: FieldValue.serverTimestamp(),
});
} catch (e) {
const code = (e as { code?: number | string }).code;
if (code !== 6 && code !== 'already-exists') throw e;
const prior = await ref.get();
return {
questId,
activeTo: (prior.get('activeTo') as Timestamp).toDate().toISOString(),
pushed: false,
duplicate: true,
};
}
await db.collection('adminActionLogs').add({
actorUid, role: 'master', targetPath: ref.path, reason: 'timeattack-start',
patch: { fandomId, title, rewardPop, durationMinutes: minutes },
existedBefore: false, createdAt: FieldValue.serverTimestamp(),
});
// 팬덤 이벤트 구분(B-77). 앱은 이 알림을 홈으로 연다 — 타임어택은 홈 TODAY'S QUEST 맨 앞이다.
const pushed = await sendFandomPush({
fandomId,
category: 'fandomEvent',
title: '긴급 돌발 타임어택',
body: `${title} — ${minutes}분 안에 참여하면 ${rewardPop} POP 을 받습니다.`,
data: { kind: 'timeattack', questId },
});
return { questId, activeTo: activeTo.toDate().toISOString(), pushed };
});
/**
* `fn-quest-timeattack` — 참여 · 검증 · 지급.
*
* 판정은 세 가지다. 대표 팬덤 자격(C-16, [questOf]) · **서버 시각** 창 · 1인 1회.
* 멱등키에 날짜를 넣지 않는다 — 창이 자정을 넘어도 두 번 받지 못한다. 접두 `quest:` 는
* `fnAdReward` 가 기본 보상 행을 알아보는 규칙이다.
*
* 이미 받은 사용자의 재호출(응답 유실 재시도)은 오류가 아니라 **첫 지급 결과**를
* `duplicate: true` 로 돌려준다 — 마감 뒤 재시도여도 같다. 새 지급은 없다.
*/
export const fnQuestTimeAttack = onCall(async (req) => {
const uid = callerUid(req);
const questId = str(req.data, 'questId');
const quest = await questOf(uid, questId, ['timeattack']);
const { timeAttackFpiAxis: axis } = await loadPolicy();
const now = new Date();
const idempotencyKey = `quest:${uid}:${questId}:1`;
const progressRef = getFirestore().doc(`users/${uid}/questProgress/${questId}`);
if (((await progressRef.get()).get('count') as number | undefined ?? 0) >= 1) {
const prior = await getFirestore()
.doc(`users/${uid}/popTransactions/${txIdOf(idempotencyKey)}`).get();
if (!prior.exists) fail(ERR.popDuplicate, '이미 참여한 타임어택입니다.');
return {
txId: prior.id,
balanceAfter: (prior.get('balanceAfter') as number | undefined) ?? 0,
rewarded: (prior.get('amount') as number | undefined) ?? 0,
questId,
duplicate: true,
};
}
if (quest.get('active') === false ||
!inWindow(now, quest.get('activeFrom') as Timestamp | undefined,
quest.get('activeTo') as Timestamp | undefined)) {
fail(ERR.questIncomplete, '타임어택 참여 시간이 아닙니다.');
}
const amount = quest.get('rewardPop');
if (!Number.isSafeInteger(amount) || (amount as number) <= 0) {
fail(ERR.questIncomplete, '보상이 설정되지 않은 타임어택입니다.');
}
const result = await grantPop({
uid,
popType: 'activity',
amount: amount as number,
reasonCode: 'QUEST_TIMEATTACK',
growthSource: 'quest_reward',
idempotencyKey,
refPath: quest.ref.path,
alsoWrite: (tx, user) => {
// 인정 활동 1건 = User EXP(C-18) + FPI 축 1 단위(Q-TA-05). 축이 꺼졌거나 모르는 값이면 EXP 만.
if (axis && FPI_AXES.includes(axis)) {
writeFpiEvent(tx, {
uid, fandomId: fandomOfUser(user), axis, units: 1, activity: 'timeattack',
sourceId: `timeattack:${questId}`, occurredAt: now, refPath: quest.ref.path,
}, user);
} else {
writeUserExp(tx, uid, user);
}
markCompletion(tx, uid, `${questId}_${dayKey(now)}`, { questId, txId: txIdOf(idempotencyKey) });
tx.set(progressRef, {
questId, dayKey: dayKey(now), count: 1, rewardedPop: amount,
completedAt: FieldValue.serverTimestamp(), updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
// 참여 인원 — 카드의 「참여 인원」이 읽는 필드. 지급과 같은 트랜잭션이라 중복 집계가 없다.
tx.update(quest.ref, { participantCount: FieldValue.increment(1) });
},
});
return {
txId: result.txId,
balanceAfter: result.balanceAfter,
rewarded: amount,
questId,
};
});
/**
* `fn-server-time` — 서버 시각(ms). 앱이 단말 시계와의 차이를 재어 타임어택 카운트다운 ·
* 노출 판정을 서버 시각에 맞춘다. 단말 시계를 돌려도 창이 늘어나 보이지 않게 한다.
*/
export const fnServerTime = onCall((req) => {
callerUid(req);
return { now: Date.now() };
});
+790
View File
@@ -0,0 +1,790 @@
import { HttpsError, onCall } from 'firebase-functions/v2/https';
import { onSchedule } from 'firebase-functions/v2/scheduler';
import { logger } from 'firebase-functions';
import { getFirestore, FieldValue, Timestamp } from 'firebase-admin/firestore';
import type { DocumentReference, DocumentSnapshot, Transaction } from 'firebase-admin/firestore';
import { callerUid, optionalStr, str } from '../core/callable.js';
import { ERR, fail, positiveInt } from '../core/errors.js';
import { txIdOf } from '../core/ids.js';
import { dayKey } from '../core/time.js';
import { loadPolicy, type PolicyConfig } from '../core/config.js';
import { grantPop, refundSplit, spendSplit, type GrantInput, type LedgerResult, type PopType } from '../ledger/ledger.js';
import { fandomOfUser, markCompletion, verifiedQuestCountOf, writeFpiEvent } from '../ledger/fpi.js';
import { linkPreview } from './link_preview.js';
import { questOf } from './quest.js';
import { publicNameOf } from '../core/names.js';
/**
* 사용자 생성(UGC) Quest — 별첨 1 ③ · 정책 원장 C-01 ~ C-12 · C-16 · C-17 · G-01 · G-03 · G-05.
*
* 흐름: 생성(예치 + 수수료 Burn) → 참여(자동 검증은 즉시 지급 · 사람 검토는 예약) → 검토(생성자 ·
* 운영자, 자동 승인 없음) → 종료(서버 시각) 즉시 미사용분 반환.
*
* Reward Pool 은 퀘스트 문서의 `pool` 이 장부다 — `deposited == paid + reserved + returned + 미배정`.
* 종료 뒤에는 미배정이 0 이 되어 C-09 등식 `예치 = 지급 + 예약 + 반환` 이 선다. 돈은 전부
* 원장(`grantPop` · `spendSplit`)을 지나고, 장부 증감은 **같은 트랜잭션**의 `alsoWrite` 에서 한다.
*/
// ─── 카탈로그(C-01) ─────────────────────────────────────────────────────────
export interface CatalogCategory { key: string; label: string; order: number; visible: boolean }
export interface CatalogActivity {
key: string; categoryKey: string; order: number; visible: boolean; ugcAllowed: boolean;
inputSchema: Array<{ field: string; required: boolean }>;
/** Community 목표 — 다른 회원의 유효 반응 수(C-11 기본 1). 🟡 운영값. */
minReactions?: number;
}
export interface QuestCatalog { categories: CatalogCategory[]; activities: CatalogActivity[] }
const field = (f: string) => [{ field: f, required: true }];
/**
* `config/questCatalog` 가 없을 때의 출발점 — C-01 매핑 · C-02 `ugcAllowed` 초기값(원장 §19 S03 A안).
* 위치 무관 QR / NFC 는 검증 명세 확정 전 `ugcAllowed = false`(C-03). 운영에서는 문서가 덮는다.
*/
export const DEFAULT_QUEST_CATALOG: QuestCatalog = {
categories: [
{ key: 'health', label: '건강', order: 1, visible: true },
{ key: 'planet', label: '행성 활동', order: 2, visible: true },
{ key: 'community', label: '커뮤니티', order: 3, visible: true },
{ key: 'location', label: '특정 위치', order: 4, visible: true },
],
activities: [
{ key: 'walk', categoryKey: 'health', order: 1, visible: true, ugcAllowed: false, inputSchema: field('goal') },
{ key: 'shake', categoryKey: 'health', order: 2, visible: true, ugcAllowed: false, inputSchema: field('goal') },
{ key: 'streaming', categoryKey: 'planet', order: 1, visible: true, ugcAllowed: false, inputSchema: field('link') },
{ key: 'youtube', categoryKey: 'planet', order: 2, visible: true, ugcAllowed: false, inputSchema: field('link') },
{ key: 'vote', categoryKey: 'planet', order: 3, visible: true, ugcAllowed: true, inputSchema: field('link') },
{ key: 'sns', categoryKey: 'planet', order: 4, visible: true, ugcAllowed: true, inputSchema: field('link') },
{ key: 'community', categoryKey: 'community', order: 1, visible: true, ugcAllowed: true, inputSchema: [], minReactions: 1 },
{ key: 'location', categoryKey: 'location', order: 1, visible: true, ugcAllowed: true, inputSchema: field('place') },
{ key: 'qr', categoryKey: 'location', order: 2, visible: true, ugcAllowed: false, inputSchema: field('code') },
{ key: 'nfc', categoryKey: 'location', order: 3, visible: true, ugcAllowed: false, inputSchema: field('tag') },
],
};
export async function loadCatalog(): Promise<QuestCatalog> {
const data = (await getFirestore().doc('config/questCatalog').get()).data();
if (!data || !Array.isArray(data.categories) || !Array.isArray(data.activities)) return DEFAULT_QUEST_CATALOG;
return data as QuestCatalog;
}
/**
* 서버에 사용자 생성 참여 경로가 있는 활동. Walk · Shake 는 Pool 지급 검증 경로가 없고, 위치 무관
* QR · NFC 는 검증 명세 전이다(C-03) — 운영자가 `ugcAllowed` 를 켜도 여기서 막는다.
*/
const UGC_SUPPORTED = new Set(['location', 'vote', 'sns', 'community', 'youtube', 'streaming']);
/** 무보상 기본 참여 Quest(C-04). Pool 예치가 없다. */
const NO_REWARD = new Set(['youtube', 'streaming']);
/** 사람 검토 대상(C-08). 나머지(위치 · Community)는 자동 검증 즉시 지급(B-60). */
export const REVIEWED = new Set(['vote', 'sns']);
// ─── 기간(C-06 · G-04) ───────────────────────────────────────────────────────
const HOUR = 3_600_000;
const DAY = 24 * HOUR;
function offsetLabel(minutes: number): string {
const sign = minutes < 0 ? '-' : '+';
const abs = Math.abs(minutes);
return `UTC${sign}${String(Math.floor(abs / 60)).padStart(2, '0')}:${String(abs % 60).padStart(2, '0')}`;
}
/**
* 마감 순간. 종료일이 없으면 공개 + 24h. 종료일은 생성자 시간대의 그 날짜 23:59:59 를 UTC 로 굳힌다.
* 공개일이 1일째라 `maxDays` 일째까지 허용하고, 공개 시각부터 `minHours` 미만이면 거부한다.
*/
export function endsAtOf(
publishedAt: Date, endDate: string | undefined, offsetMinutes: number,
policy: Pick<PolicyConfig, 'ugcMaxDays' | 'ugcMinHours'>,
): Date {
if (!endDate) return new Date(publishedAt.getTime() + DAY);
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(endDate);
if (!m) throw new HttpsError('invalid-argument', '종료일 형식이 올바르지 않습니다.');
const endDay = Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
const local = new Date(publishedAt.getTime() + offsetMinutes * 60_000);
const publishDay = Date.UTC(local.getUTCFullYear(), local.getUTCMonth(), local.getUTCDate());
const dayNo = Math.round((endDay - publishDay) / DAY) + 1;
if (dayNo < 1 || dayNo > policy.ugcMaxDays) {
throw new HttpsError('invalid-argument', `종료일은 공개일부터 ${policy.ugcMaxDays}일 안에서 고를 수 있습니다.`);
}
const endsAt = new Date(endDay + DAY - 1_000 - offsetMinutes * 60_000);
if (endsAt.getTime() - publishedAt.getTime() < policy.ugcMinHours * HOUR) {
throw new HttpsError('invalid-argument', `마감까지 최소 ${policy.ugcMinHours}시간이 남아야 합니다.`);
}
return endsAt;
}
/**
* 수수료 — Pool 의 10%(올림), 최소 10 POP(B-30). 보상 없는 Quest(Pool 0)는 `ugcFeeOnNoReward`
* 가 꺼져 있으면 0(해석 확인 요청 Q-UQ-02 권고 A안).
*/
export function feeOf(
pool: number, policy: Pick<PolicyConfig, 'ugcFeeRate' | 'ugcFeeMin' | 'ugcFeeOnNoReward'>,
): number {
if (pool === 0 && !policy.ugcFeeOnNoReward) return 0;
return Math.max(policy.ugcFeeMin, Math.ceil(pool * policy.ugcFeeRate));
}
// ─── Pool 장부 ───────────────────────────────────────────────────────────────
interface Pool {
deposited: number; depositSupport: number; paid: number; reserved: number;
returned: number; returnedSupport: number;
}
function poolOf(quest: DocumentSnapshot): Pool {
const p = (quest.get('pool') ?? {}) as Partial<Pool>;
const n = (v: unknown) => (typeof v === 'number' ? v : 0);
return {
deposited: n(p.deposited), depositSupport: n(p.depositSupport), paid: n(p.paid),
reserved: n(p.reserved), returned: n(p.returned), returnedSupport: n(p.returnedSupport),
};
}
/** 아직 누구에게도 배정되지 않은 몫. */
export const unallocated = (p: Pool) => p.deposited - p.paid - p.reserved - p.returned;
/** 트랜잭션 안에서 읽은 Quest 가 아직 열려 있는가 — 서버 시각(G-04). 호출 사이 마감을 다시 본다. */
function assertOpen(quest: DocumentSnapshot, now = Date.now()): void {
const endsAt = quest.get('endsAt');
if (quest.get('status') !== 'published' || (endsAt instanceof Timestamp && now >= endsAt.toMillis())) {
fail(ERR.questIncomplete, '마감된 퀘스트입니다.');
}
}
/** 마감이 지났는가(서버 시각). */
function isEnded(quest: DocumentSnapshot, now = Date.now()): boolean {
const endsAt = quest.get('endsAt');
return endsAt instanceof Timestamp && endsAt.toMillis() <= now;
}
/**
* Pool 에서 참여자에게 지급한다 — 지급 원장과 장부 증감이 한 트랜잭션이다.
* `fromReserved` 면 예약분(검토 승인)을, 아니면 미배정분(자동 검증)을 쓴다.
*/
export async function payFromPool(
questRef: DocumentReference, amount: number, fromReserved: boolean, grant: GrantInput,
): Promise<LedgerResult> {
return getFirestore().runTransaction(async (tx) => {
const quest = await tx.get(questRef);
if (!fromReserved) {
assertOpen(quest);
if (unallocated(poolOf(quest)) < amount) fail(ERR.questIncomplete, '보상 재원이 모두 배정된 퀘스트입니다.');
}
return grantPop({
...grant,
alsoWrite: (t, user) => {
grant.alsoWrite?.(t, user);
t.update(questRef, {
'pool.paid': FieldValue.increment(amount),
...(fromReserved
? { 'pool.reserved': FieldValue.increment(-amount) }
: { participantCount: FieldValue.increment(1) }),
updatedAt: FieldValue.serverTimestamp(),
});
},
}, tx);
});
}
/**
* 종료된 Quest 의 미배정분을 생성자에게 **즉시** 돌려준다(C-09). 기여 POP 예치분부터 원래 타입으로
* 돌린다(B-39 원타입 환불 준용). 타입마다 한 트랜잭션 — 반환 원장과 `pool.returned` 가 함께 선다.
* 멱등키에 직전 `returned` 를 넣어 같은 상태의 재시도는 한 번만, 다음 반환(최종 반려분)은 새 행이다.
*/
export async function settleQuestPool(questId: string, now = new Date()): Promise<number> {
const db = getFirestore();
const ref = db.doc(`quests/${questId}`);
// 마감 뒤에는 재제출 경로가 닫힌다(참여는 마감 전만). 보완 대기 반려분을 닫아 예약을 풀어야
// 예약이 끝없이 묶이지 않는다(C-09 「최종 반려 시 반환」).
if (isEnded(await ref.get(), now.getTime())) {
const waiting = await db.collection('questSubmissions').where('questId', '==', questId).get();
for (const sub of waiting.docs) {
if (sub.get('status') === 'rejected' && sub.get('resubmitAllowed') === true) {
await finalizeRejection(sub.ref, ref, 'system', null);
}
}
}
let returned = 0;
for (const popType of ['support', 'activity'] as PopType[]) {
returned += await db.runTransaction(async (tx) => {
const quest = await tx.get(ref);
const endsAt = quest.get('endsAt');
const author = quest.get('authorUid');
if (!(endsAt instanceof Timestamp) || endsAt.toMillis() > now.getTime() || typeof author !== 'string') return 0;
const pool = poolOf(quest);
const left = unallocated(pool);
const part = popType === 'support'
? Math.min(left, pool.depositSupport - pool.returnedSupport) : left;
if (part <= 0) return 0;
await grantPop({
uid: author, popType, amount: part, reasonCode: 'QUEST_POOL_RETURN',
idempotencyKey: `questPoolReturn:${questId}:${pool.returned}:${popType}`,
refPath: ref.path,
alsoWrite: (t) => t.update(ref, {
'pool.returned': FieldValue.increment(part),
...(popType === 'support' ? { 'pool.returnedSupport': FieldValue.increment(part) } : {}),
updatedAt: FieldValue.serverTimestamp(),
}),
}, tx);
return part;
});
}
await db.runTransaction(async (tx) => {
const quest = await tx.get(ref);
const endsAt = quest.get('endsAt');
if (quest.get('status') === 'published' && endsAt instanceof Timestamp && endsAt.toMillis() <= now.getTime()) {
tx.update(ref, { status: 'ended', endedAt: FieldValue.serverTimestamp(), updatedAt: FieldValue.serverTimestamp() });
}
});
return returned;
}
/**
* 보완 대기 반려 건을 최종 반려로 닫는다 — 상태 `rejected_final` · 예약 해제(미배정으로 돌아가고,
* 마감 뒤라면 곧이어 settleQuestPool 이 생성자에게 반환). 이미 닫혔으면 아무것도 하지 않는다.
*/
async function finalizeRejection(
subRef: DocumentReference, questRef: DocumentReference, actorUid: string, reason: string | null,
): Promise<boolean> {
return getFirestore().runTransaction(async (tx) => {
const sub = await tx.get(subRef);
if (sub.get('status') !== 'rejected' || sub.get('resubmitAllowed') !== true) return false;
tx.update(subRef, {
status: 'rejected_final', resubmitAllowed: false, payoutStatus: 'released',
...(reason ? { rejectReason: reason } : {}),
reviewLog: FieldValue.arrayUnion({ actorUid, action: 'finalize', reason, at: Timestamp.now() }),
});
tx.update(questRef, {
'pool.reserved': FieldValue.increment(-((sub.get('rewardPop') as number | undefined) ?? 0)),
participantCount: FieldValue.increment(-1), updatedAt: FieldValue.serverTimestamp(),
});
return true;
});
}
/** Draft 가 이만큼 공개되지 못하면 버려진 것으로 보고 예치를 돌려준다. */
export const DRAFT_TTL_MS = 15 * 60_000;
/**
* Draft 를 닫고 예치를 원타입으로 돌려준다(B-30 「Draft 취소 미부과」). 상태 전이가 먼저이고
* (`poolRefund: 'due'`), 환불은 멱등키 하나로 한 번만 — 중간에 죽으면 스케줄러가 `due` 를 다시 집는다.
* 공개(`fee` 차감)는 precheck 에서 `draft` 를 확인하므로 닫힌 Draft 가 공개되는 일은 없다.
*/
export async function abandonDraft(questId: string, status: 'abandoned' | 'cancelled'): Promise<void> {
const db = getFirestore();
const ref = db.doc(`quests/${questId}`);
await db.runTransaction(async (tx) => {
const quest = await tx.get(ref);
if (quest.get('status') !== 'draft') return;
tx.update(ref, { status, active: false, poolRefund: 'due', updatedAt: FieldValue.serverTimestamp() });
});
const quest = await ref.get();
if (quest.get('poolRefund') !== 'due') return;
const txIds = (quest.get('depositTxIds') as string[] | undefined) ?? [];
if (txIds.length > 0) {
await refundSplit({
uid: quest.get('authorUid') as string, originalTxIds: txIds,
idempotencyKey: `questPoolRefund:${questId}`, reasonCode: 'QUEST_POOL_REFUND',
});
}
await ref.update({ poolRefund: 'done', updatedAt: FieldValue.serverTimestamp() });
}
/** 15분 넘게 공개되지 못한 Draft 와 환불이 끝나지 않은 Draft 를 닫는다. */
export async function sweepDrafts(now = new Date()): Promise<number> {
const db = getFirestore();
const [stale, due] = await Promise.all([
db.collection('quests').where('status', '==', 'draft')
.where('createdAt', '<=', Timestamp.fromMillis(now.getTime() - DRAFT_TTL_MS)).limit(200).get(),
db.collection('quests').where('poolRefund', '==', 'due').limit(200).get(),
]);
let done = 0;
for (const doc of [...stale.docs, ...due.docs]) {
try {
await abandonDraft(doc.id, doc.get('status') === 'draft' ? 'abandoned' : doc.get('status'));
done += 1;
} catch (error) {
logger.error('[quest] Draft 정리 실패 — 다음 회차 재시도', { questId: doc.id, error: String(error) });
}
}
return done;
}
/**
* `fn-quest-pool-settle` — 1분마다: 마감이 지난 공개 Quest 를 닫아 미사용분을 반환하고(C-09 「즉시」 ·
* G-04 서버 시각), 버려진 Draft 의 예치를 돌려준다.
*/
export const fnQuestPoolSettle = onSchedule('every 1 minutes', async () => {
const due = await getFirestore().collection('quests')
.where('status', '==', 'published').where('endsAt', '<=', Timestamp.now()).limit(200).get();
for (const doc of due.docs) {
try {
await settleQuestPool(doc.id);
} catch (error) {
logger.error('[quest] Pool 반환 실패 — 다음 회차 재시도', { questId: doc.id, error: String(error) });
}
}
await sweepDrafts();
});
// ─── 생성(C-01 ~ C-06 · C-12 · B-29 · B-30) ─────────────────────────────────
export interface CreateInput {
uid: string; clientSeq: string; activityKey: string; title: string; description: string;
rewardPop: number; capacity: number; endDate?: string | undefined; utcOffsetMinutes: number;
url?: string | undefined; manualTitle?: string | undefined; placeId?: string | undefined;
}
export const fnQuestCreate = onCall(async (req) => {
const data = (req.data ?? {}) as Record<string, unknown>;
const int = (k: string) => (typeof data[k] === 'number' ? data[k] as number : 0);
return createQuest({
uid: callerUid(req),
clientSeq: str(data, 'clientSeq'),
activityKey: str(data, 'activityKey'),
title: str(data, 'title'),
description: optionalStr(data, 'description') ?? '',
rewardPop: int('rewardPop'),
capacity: int('capacity'),
endDate: optionalStr(data, 'endDate'),
utcOffsetMinutes: int('utcOffsetMinutes'),
url: optionalStr(data, 'url'),
manualTitle: optionalStr(data, 'manualTitle'),
placeId: optionalStr(data, 'placeId'),
});
});
export async function createQuest(input: CreateInput, now = new Date()): Promise<{ questId: string; status: string }> {
const db = getFirestore();
const { uid } = input;
const questRef = db.doc(`quests/${txIdOf(`quest:${uid}:${input.clientSeq}`)}`);
const existing = await questRef.get();
if (existing.exists) {
// 재시도 — 입력을 다시 계산하지 않고 Draft 에 굳힌 값(수수료 · 보상 · 인원 · 마감)으로만 공개한다.
if (existing.get('status') !== 'draft') return { questId: questRef.id, status: existing.get('status') as string };
return publishDraft(questRef, uid, input.clientSeq, now);
}
const [policy, catalog, user] = await Promise.all([loadPolicy(), loadCatalog(), db.doc(`users/${uid}`).get()]);
// 카탈로그 — 노출 중인 카테고리의 노출 중인 활동이고 사용자 생성이 열려 있어야 한다(C-01 · C-02 · C-03).
const activity = catalog.activities.find((a) => a.key === input.activityKey);
const category = catalog.categories.find((c) => c.key === activity?.categoryKey);
if (!activity || !category || !activity.visible || !category.visible || !activity.ugcAllowed
|| !UGC_SUPPORTED.has(activity.key)) {
fail(ERR.permissionDenied, '사용자가 만들 수 없는 퀘스트 활동입니다.');
}
// 생성 자격(B-29) — 팬덤 소속 · 가입 24h · 검증 활동 1회.
const fandomId = fandomOfUser(user);
if (!fandomId) fail(ERR.permissionDenied, '팬덤에 가입한 뒤 퀘스트를 만들 수 있습니다.');
if ((user.get('status') ?? 'active') !== 'active') fail(ERR.permissionDenied, '이용이 제한된 계정입니다.');
const joined = user.get('createdAt');
if (!(joined instanceof Timestamp) || now.getTime() - joined.toMillis() < policy.ugcMinAccountHours * HOUR) {
fail(ERR.permissionDenied, `가입 ${policy.ugcMinAccountHours}시간이 지나야 퀘스트를 만들 수 있습니다.`);
}
// 검증 활동 = 서버가 인정한 Quest 완료(보상 유무 무관) — Campaign 개설자격 A안과 같은 정의.
const need = policy.ugcMinVerifiedQuests;
if (need > 0 && await verifiedQuestCountOf(uid) < need) {
fail(ERR.permissionDenied, `검증된 퀘스트 활동을 ${need}회 이상 한 뒤 만들 수 있습니다.`);
}
const title = input.title.trim();
const description = input.description.trim();
if (!title || title.length > 100) throw new HttpsError('invalid-argument', '제목은 1~100자입니다.');
if (description.length > 1000) throw new HttpsError('invalid-argument', '설명은 1000자 이하입니다.');
// 실제 시간대 범위 UTC−12 ~ UTC+14. 계정에 저장된 시간대는 아직 없어 범위만 본다.
if (!Number.isInteger(input.utcOffsetMinutes) || input.utcOffsetMinutes < -12 * 60 || input.utcOffsetMinutes > 14 * 60) {
throw new HttpsError('invalid-argument', '시간대가 올바르지 않습니다.');
}
const endsAt = endsAtOf(now, input.endDate, input.utcOffsetMinutes, policy);
// 보상(C-02 · C-04 · C-12) — UGC 위치 Quest 는 반복 없음(운영자 허용 전용)이라 1인 1회.
const rewardMode = NO_REWARD.has(activity.key) ? 'none' : 'deposit';
const rewardPop = rewardMode === 'none' ? 0 : positiveInt(input.rewardPop, 'rewardPop');
const capacity = rewardMode === 'none' ? 0 : positiveInt(input.capacity, 'capacity');
if (capacity > 10_000) throw new HttpsError('invalid-argument', '참여 인원은 10,000명 이하입니다.');
const pool = rewardPop * capacity;
const fee = feeOf(pool, policy);
// 입력 필드 — 선택한 활동의 스키마만 받는다(C-01).
const fields = new Set(activity.inputSchema.map((f) => f.field));
let link: Record<string, unknown> | null = null;
if (fields.has('link')) {
if (!input.url) throw new HttpsError('invalid-argument', '링크를 입력해 주세요.');
const preview = await linkPreview(input.url);
if (preview.status === 'invalid') throw new HttpsError('invalid-argument', '올바른 링크가 아닙니다. 주소를 확인해 주세요.');
const manual = input.manualTitle?.trim() ?? '';
if (preview.status === 'preview_failed' && (!manual || manual.length > 100)) {
throw new HttpsError('invalid-argument', '링크 정보를 불러오지 못했습니다. 제목을 입력해 주세요.');
}
link = {
url: preview.url, status: preview.status,
title: preview.status === 'ok' ? preview.title : manual,
thumbnail: preview.thumbnail ?? null, provider: preview.provider ?? null,
fetchedAt: Timestamp.fromDate(now),
};
}
let placeId: string | null = null;
if (fields.has('place')) {
placeId = input.placeId ?? '';
if (!placeId || placeId.includes('/') || !(await db.doc(`places/${placeId}`).get()).exists) {
throw new HttpsError('invalid-argument', '등록된 장소를 골라 주세요.');
}
}
// 1) 예치 + Draft(수수료 미부과 — B-30).
const draft = {
type: activity.key, activityKey: activity.key, categoryKey: category.key,
title, userDescription: description, authorUid: uid, authorName: publicNameOf(user),
fandomId, rewardMode, rewardPop, capacity, goal: 1,
link, placeId, verifyMethods: placeId ? ['gps'] : [],
repeat: { allowed: false, intervalHours: 24, maxCount: 1 },
endsAt: Timestamp.fromDate(endsAt), creatorTz: offsetLabel(input.utcOffsetMinutes),
pool: { deposited: pool, depositSupport: 0, paid: 0, reserved: 0, returned: 0, returnedSupport: 0 },
feePop: fee, participantCount: 0, status: 'draft', active: false, depositTxIds: [] as string[],
createdAt: Timestamp.fromDate(now), updatedAt: FieldValue.serverTimestamp(),
};
if (pool > 0) {
await spendSplit({
uid, amount: pool, reasonCode: 'QUEST_POOL_DEPOSIT',
idempotencyKey: `questPool:${uid}:${input.clientSeq}`, refPath: questRef.path,
precheck: async (tx) => {
// 수수료까지 낼 수 있어야 예치한다 — 예치만 되고 공개가 막히는 상태를 줄인다.
const wallet = (await tx.get(db.doc(`users/${uid}/wallet/main`))).data() ?? {};
const total = Math.max(0, wallet.activityBalance ?? 0) + Math.max(0, wallet.supportBalance ?? 0);
if (total < pool + fee) fail(ERR.popInsufficient, 'POP 잔액이 부족합니다.');
},
alsoWrite: (tx, spent) => tx.create(questRef, {
...draft,
depositTxIds: spent.map((p) => p.txId),
// 반환은 기여 POP 예치분부터 원래 타입으로 돌린다(settleQuestPool).
pool: {
...draft.pool,
depositSupport: spent.filter((p) => p.popType === 'support').reduce((sum, p) => sum + p.amount, 0),
},
}),
});
} else {
// 무보상 Quest 는 수수료 0(Q-UQ-02)이라 도배를 하루 개수로 막는다 — 사용자 시간대 날짜 기준.
const capRef = db.doc(`users/${uid}/rateLimits/ugcFree_${dayKey(now, input.utcOffsetMinutes)}`);
await db.runTransaction(async (tx) => {
const used = ((await tx.get(capRef)).get('count') as number | undefined) ?? 0;
if (used >= policy.ugcFreeQuestDailyCap) {
fail(ERR.ugcFreeDailyCap, `보상 없는 퀘스트는 하루 ${policy.ugcFreeQuestDailyCap}개까지 만들 수 있습니다.`,
{ cap: policy.ugcFreeQuestDailyCap });
}
tx.set(capRef, { count: used + 1, updatedAt: FieldValue.serverTimestamp() }, { merge: true });
tx.create(questRef, draft);
});
}
return publishDraft(questRef, uid, input.clientSeq, now);
}
/**
* 2) 수수료(일반 우선 · Burn) + 공개 — Draft 에 저장된 `feePop` · `endsAt` 만 쓴다. 수수료를 못 내면
* Draft 취소(예치 원타입 환불). Draft 가 이미 닫혔으면(스케줄러가 버림) 수수료도 빼지 않는다.
*/
async function publishDraft(
questRef: DocumentReference, uid: string, clientSeq: string, now: Date,
): Promise<{ questId: string; status: string }> {
const draft = await questRef.get();
const fee = draft.get('feePop') as number;
const assertDraft = async (tx: Transaction) => {
if ((await tx.get(questRef)).get('status') !== 'draft') fail(ERR.permissionDenied, '공개할 수 없는 퀘스트입니다.');
};
const publish = (tx: Transaction, feeTxIds: string[]) => tx.update(questRef, {
status: 'published', active: true, feeTxIds,
publishedAt: Timestamp.fromDate(now), updatedAt: FieldValue.serverTimestamp(),
});
try {
if (fee > 0) {
await spendSplit({
uid, amount: fee, reasonCode: 'QUEST_FEE',
idempotencyKey: `questFee:${uid}:${clientSeq}`, refPath: questRef.path,
precheck: assertDraft,
alsoWrite: (tx, parts) => publish(tx, parts.map((p) => p.txId)),
});
} else {
// 수수료 0(무보상 · Q-UQ-02) — Burn 원장 행 없이 공개만 한다.
await getFirestore().runTransaction(async (tx) => {
await assertDraft(tx);
publish(tx, []);
});
}
} catch (error) {
await abandonDraft(questRef.id, 'cancelled');
throw error;
}
return { questId: questRef.id, status: 'published' };
}
// ─── 참여 · 제출(C-08 · C-11 · C-16 · C-17 · G-01) ──────────────────────────
const submissionIdOf = (questId: string, uid: string) => `${questId}_${uid}`;
/**
* Community 목표(C-11) — 공개 뒤 쓴 본인 글(최근 20건) 중 **다른 회원** 반응이 목표 이상인 글.
* 반응 수는 라운지 트리거가 유지하는 `likeCount`(본인 반응 포함)에서 본인 반응 1건만 뺀다.
* 이미 다른 Quest 를 채운 글(`questPostUses/{postId}`)은 쓰지 않는다.
*/
async function communityPost(uid: string, quest: DocumentSnapshot, minReactions: number): Promise<string | null> {
const db = getFirestore();
const since = (quest.get('publishedAt') as Timestamp | undefined) ?? Timestamp.fromMillis(0);
const fandomId = quest.get('fandomId') as string | null;
const posts = await db.collection('posts').where('authorUid', '==', uid)
.where('createdAt', '>=', since).orderBy('createdAt', 'desc').limit(20).get();
for (const post of posts.docs) {
if (post.get('visibility') !== 'visible') continue;
if (fandomId && post.get('fandomId') !== fandomId) continue;
const likes = (post.get('likeCount') as number | undefined) ?? 0;
if (likes < minReactions) continue;
const [self, used] = await Promise.all([
post.ref.collection('likes').doc(uid).get(), db.doc(`questPostUses/${post.id}`).get(),
]);
if (!used.exists && likes - (self.exists ? 1 : 0) >= minReactions) return post.id;
}
return null;
}
/**
* `fn-quest-submit` — 사용자 생성 Quest 참여.
*
* - Vote · SNS: 인증 자료를 제출하면 보상을 **예약**하고 검토 대기(`submitted`). 반려 후 재제출
* 가능이면 **같은 제출 건**에 보완을 붙인다(G-01).
* - Community: 목표 충족을 서버가 확인하면 즉시 지급(자동 검증 — B-60).
*/
export const fnQuestSubmit = onCall(async (req) => {
const uid = callerUid(req);
const data = (req.data ?? {}) as Record<string, unknown>;
return submitQuest(uid, str(data, 'questId'), {
url: optionalStr(data, 'url'), text: optionalStr(data, 'text'),
});
});
export async function submitQuest(
uid: string, questId: string, evidence: { url?: string | undefined; text?: string | undefined },
): Promise<Record<string, unknown>> {
const db = getFirestore();
// 대표 팬덤 자격(C-16) · 서버 시각 마감(G-04)은 questOf 가 본다.
const quest = await questOf(uid, questId, ['vote', 'sns', 'community']);
if (quest.get('authorUid') === uid) fail(ERR.permissionDenied, '내가 만든 퀘스트에는 참여할 수 없습니다.');
const rewardPop = (quest.get('rewardPop') as number | undefined) ?? 0;
const subRef = db.doc(`questSubmissions/${submissionIdOf(questId, uid)}`);
const user = await db.doc(`users/${uid}`).get();
const at = Timestamp.now();
const base = {
questId, uid, questAuthorUid: quest.get('authorUid'), rewardPop,
fandomIdAtSubmit: fandomOfUser(user), submittedAt: at,
};
if (quest.get('type') === 'community') {
if ((await subRef.get()).exists) fail(ERR.popDuplicate, '이미 참여한 퀘스트입니다.');
const catalog = await loadCatalog();
const min = catalog.activities.find((a) => a.key === 'community')?.minReactions ?? 1;
const postId = await communityPost(uid, quest, Math.max(1, min));
if (!postId) fail(ERR.questIncomplete, '응원글에 다른 회원의 반응이 달리면 완료됩니다.');
const result = await payFromPool(quest.ref, rewardPop, false, {
uid, popType: 'activity', amount: rewardPop, reasonCode: 'QUEST_UGC', growthSource: 'quest_reward',
idempotencyKey: `quest:${uid}:${questId}:${subRef.id}`, refPath: subRef.path,
alsoWrite: (tx, u) => {
// 글 하나는 Quest 하나만 채운다 — 동시에 두 Quest 가 같은 글을 쓰면 create 가 한쪽을 막는다.
tx.create(db.doc(`questPostUses/${postId}`), { questId, uid, at });
tx.update(quest.ref, { usedPostIds: FieldValue.arrayUnion(postId) });
tx.create(subRef, {
...base, status: 'paid', payoutStatus: 'succeeded',
evidence: [{ kind: 'post', postId, at }],
reviewLog: [{ actorUid: 'system', action: 'auto_verify', reason: null, at }],
});
writeFpiEvent(tx, {
uid, fandomId: fandomOfUser(u), axis: 'community', units: 1, activity: 'community_quest',
sourceId: `ugc:${questId}`, occurredAt: at.toDate(), refPath: subRef.path,
}, u);
// 검증 활동 — 자동 검증 통과 1회.
markCompletion(tx, uid, `${questId}_${dayKey(at.toDate())}`, {
questId, submissionId: subRef.id, txId: txIdOf(`quest:${uid}:${questId}:${subRef.id}`),
});
},
});
return { status: 'paid', txId: result.txId, balanceAfter: result.balanceAfter, rewarded: rewardPop };
}
const url = evidence.url?.trim() ?? '';
const text = evidence.text?.trim() ?? '';
if ((!url && !text) || url.length > 2048 || text.length > 1000) {
throw new HttpsError('invalid-argument', '인증 내용을 입력해 주세요.');
}
const proof = { kind: 'self', ...(url ? { url } : {}), ...(text ? { text } : {}), at };
await db.runTransaction(async (tx) => {
const [q, sub] = await Promise.all([tx.get(quest.ref), tx.get(subRef)]);
assertOpen(q);
if (sub.exists) {
// 보완 재제출 — 원 제출 건에 붙인다(G-01). 예약은 반려 때 유지돼 있다.
if (sub.get('status') !== 'rejected' || sub.get('resubmitAllowed') !== true) {
fail(ERR.popDuplicate, '이미 제출한 퀘스트입니다.');
}
tx.update(subRef, {
status: 'submitted', resubmittedAt: at, rejectReason: null, resubmitAllowed: null,
evidence: FieldValue.arrayUnion(proof),
reviewLog: FieldValue.arrayUnion({ actorUid: uid, action: 'resubmit', reason: null, at }),
});
return;
}
if (unallocated(poolOf(q)) < rewardPop) fail(ERR.questIncomplete, '보상 재원이 모두 배정된 퀘스트입니다.');
tx.create(subRef, {
...base, status: 'submitted', payoutStatus: 'pending', evidence: [proof],
reviewLog: [{ actorUid: uid, action: 'submit', reason: null, at }],
});
tx.update(quest.ref, {
'pool.reserved': FieldValue.increment(rewardPop), participantCount: FieldValue.increment(1),
updatedAt: FieldValue.serverTimestamp(),
});
});
return { status: 'submitted', submissionId: subRef.id };
}
// ─── 검토(C-08 · C-09 · C-17 · G-03) ─────────────────────────────────────────
/**
* `fn-quest-review` — 생성자 또는 운영자(ops · master)가 승인 · 반려한다. 시간 경과 자동 승인은 없다.
*
* 승인: `approved` + `payoutStatus: pending` 을 먼저 남기고 지급한다. 지급이 성공해야 `paid`.
* 실패는 `approved` + `failed` 로 남고 같은 호출로 다시 지급한다(멱등키 = 제출 건).
* 반려: 사유 필수. 재제출 가능이면 예약 유지, 최종 반려면 예약을 풀고 — 종료된 Quest 면 즉시 반환.
* 대표 팬덤이 바뀌어도 제출 건은 그대로 검토 · 지급한다(C-17).
*/
export const fnQuestReview = onCall(async (req) => {
const actorUid = callerUid(req);
const role = req.auth?.token?.role;
const data = (req.data ?? {}) as Record<string, unknown>;
const action = str(data, 'action');
if (action !== 'approve' && action !== 'reject') throw new HttpsError('invalid-argument', 'action 이 올바르지 않습니다.');
return reviewSubmission({
actorUid, isOps: role === 'ops' || role === 'master', submissionId: str(data, 'submissionId'), action,
reason: optionalStr(data, 'reason'), resubmitAllowed: data.resubmitAllowed === true,
});
});
export async function reviewSubmission(input: {
actorUid: string; isOps: boolean; submissionId: string; action: 'approve' | 'reject';
reason?: string | undefined; resubmitAllowed?: boolean;
}): Promise<Record<string, unknown>> {
const db = getFirestore();
if (input.submissionId.includes('/')) fail(ERR.permissionDenied, '제출 건이 아닙니다.');
const subRef = db.doc(`questSubmissions/${input.submissionId}`);
const at = Timestamp.now();
const snap = await subRef.get();
if (!snap.exists) fail(ERR.questIncomplete, '제출 건을 찾을 수 없습니다.');
if (snap.get('questAuthorUid') !== input.actorUid && !input.isOps) {
fail(ERR.permissionDenied, '생성자 또는 운영자만 검토할 수 있습니다.');
}
const questRef = db.doc(`quests/${snap.get('questId')}`);
const log = (action: string, reason: string | null = null) =>
FieldValue.arrayUnion({ actorUid: input.actorUid, action, reason, at });
if (input.action === 'reject') {
const reason = input.reason?.trim() ?? '';
if (!reason || reason.length > 500) throw new HttpsError('invalid-argument', '반려 사유를 입력해 주세요.');
const final = input.resubmitAllowed !== true;
// 마감 뒤 보완 대기 건은 검토자가 최종 반려로 닫을 수 있다(재제출 경로가 이미 닫혔다).
if (snap.get('status') === 'rejected' && snap.get('resubmitAllowed') === true) {
if (!final || !isEnded(await questRef.get())) fail(ERR.popDuplicate, '보완 제출을 기다리는 건입니다.');
await finalizeRejection(subRef, questRef, input.actorUid, reason);
await settleQuestPool(questRef.id);
return { status: 'rejected_final', resubmitAllowed: false };
}
await db.runTransaction(async (tx) => {
const sub = await tx.get(subRef);
if (sub.get('status') !== 'submitted') fail(ERR.popDuplicate, '검토 대기 중인 제출 건이 아닙니다.');
tx.update(subRef, {
status: 'rejected', rejectReason: reason, resubmitAllowed: !final,
...(final ? { payoutStatus: 'released' } : {}), reviewedAt: at, reviewLog: log('reject', reason),
});
if (final) {
tx.update(questRef, {
'pool.reserved': FieldValue.increment(-((sub.get('rewardPop') as number | undefined) ?? 0)),
participantCount: FieldValue.increment(-1), updatedAt: FieldValue.serverTimestamp(),
});
}
});
// 종료 뒤 최종 반려분은 즉시 생성자에게(C-09). 진행 중이면 다른 참여자 몫으로 돌아간다.
if (final) await settleQuestPool(questRef.id);
return { status: 'rejected', resubmitAllowed: !final };
}
await db.runTransaction(async (tx) => {
const sub = await tx.get(subRef);
const status = sub.get('status');
if (status === 'approved' && sub.get('payoutStatus') !== 'succeeded') return; // 지급 재시도
if (status !== 'submitted') fail(ERR.popDuplicate, '검토 대기 중인 제출 건이 아닙니다.');
tx.update(subRef, { status: 'approved', payoutStatus: 'pending', reviewedAt: at, reviewLog: log('approve') });
});
const uid = snap.get('uid') as string;
const amount = (snap.get('rewardPop') as number | undefined) ?? 0;
try {
const result = await payFromPool(questRef, amount, true, {
uid, popType: 'activity', amount, reasonCode: 'QUEST_UGC', growthSource: 'quest_reward',
idempotencyKey: `quest:${uid}:${snap.get('questId')}:${subRef.id}`, refPath: subRef.path,
alsoWrite: (tx) => {
tx.update(subRef, { status: 'paid', payoutStatus: 'succeeded', paidAt: at });
// 운영 검토를 통과한 완료 = 검증 활동 1회(Campaign 개설자격 A안).
markCompletion(tx, uid, `${snap.get('questId')}_${dayKey(at.toDate())}`, {
questId: snap.get('questId') as string, submissionId: subRef.id,
txId: txIdOf(`quest:${uid}:${snap.get('questId')}:${subRef.id}`),
});
},
});
return { status: 'paid', txId: result.txId };
} catch (error) {
// 지급 실패를 완료로 표시하지 않는다(G-03).
await subRef.update({ payoutStatus: 'failed' });
logger.error('[quest] 승인 지급 실패', { submissionId: subRef.id, error: String(error) });
return { status: 'approved', payoutStatus: 'failed' };
}
}
// ─── 공개 후 수정(C-07) ──────────────────────────────────────────────────────
const EDITABLE = new Set(['questId', 'title', 'description']);
/** `fn-quest-edit` — 제목 · 설명만, 이력을 남긴다. 다른 필드가 오면 요청 전체를 거부한다. */
export const fnQuestEdit = onCall(async (req) => {
const uid = callerUid(req);
const data = (req.data ?? {}) as Record<string, unknown>;
if (Object.keys(data).some((k) => !EDITABLE.has(k))) {
fail(ERR.permissionDenied, '공개 후에는 제목과 설명만 고칠 수 있습니다.');
}
return editQuest(uid, str(data, 'questId'), {
title: typeof data.title === 'string' ? data.title : undefined,
description: typeof data.description === 'string' ? data.description : undefined,
});
});
export async function editQuest(
uid: string, questId: string, patch: { title?: string | undefined; description?: string | undefined },
): Promise<{ edited: string[] }> {
const db = getFirestore();
if (questId.includes('/')) fail(ERR.permissionDenied, '퀘스트가 아닙니다.');
const ref = db.doc(`quests/${questId}`);
const title = patch.title?.trim();
const description = patch.description?.trim();
if (title !== undefined && (!title || title.length > 100)) throw new HttpsError('invalid-argument', '제목은 1~100자입니다.');
if (description !== undefined && description.length > 1000) throw new HttpsError('invalid-argument', '설명은 1000자 이하입니다.');
return db.runTransaction(async (tx: Transaction) => {
const quest = await tx.get(ref);
if (!quest.exists || quest.get('authorUid') !== uid) fail(ERR.permissionDenied, '생성자만 고칠 수 있습니다.');
const changes: Array<[string, string, string, string]> = [];
if (title !== undefined && title !== quest.get('title')) changes.push(['title', 'title', quest.get('title') ?? '', title]);
if (description !== undefined && description !== (quest.get('userDescription') ?? '')) {
changes.push(['description', 'userDescription', quest.get('userDescription') ?? '', description]);
}
for (const [name, key, before, after] of changes) {
tx.create(ref.collection('edits').doc(), { editorUid: uid, editedAt: FieldValue.serverTimestamp(), field: name, before, after });
tx.update(ref, { [key]: after, updatedAt: FieldValue.serverTimestamp() });
}
return { edited: changes.map((c) => c[0]) };
});
}
+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),
};
});