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),
};
});
@@ -5,7 +5,7 @@ import { fnAdminWrite } from '../src/admin/admin.js';
/**
* `fn-admin-write` 권한 경계 — firestore.rules 에서 master 전용인 문서를 ops 가 이 함수로
* 우회해 쓰지 못한다. 거부도 `adminActionLogs` 에 남는다(FR-ADM-01).
* 우회해 쓰지 못한다(S05 조사). 거부도 `adminActionLogs` 에 남는다(FR-ADM-01).
*/
const run = (role: string | undefined, targetPath: string,
patch: Record<string, unknown> = { attendPop: 999 }) =>
@@ -46,7 +46,7 @@ it('ops 는 master 전용이 아닌 문서는 그대로 쓴다 · 역할 없는
expect(await denials('admin-none')).toBe(1);
});
it('master 도 원장 · 지표 · 감사 기록과 서버 산출 필드는 못 쓴다 · Eclipse 운영값은 master 만', async () => {
it('master 도 원장 · 지표 · 감사 기록과 서버 산출 필드는 못 쓴다 · Eclipse 운영값은 master 만(리뷰 H3)', async () => {
const denied = (role: string, path: string, patch?: Record<string, unknown>) =>
expect(run(role, path, patch)).rejects.toMatchObject({ details: { code: 'E-PERM-01' } });
await denied('master', 'fandoms/f1/fpiEvents/e1');
@@ -0,0 +1,367 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { getFirestore, Timestamp } from 'firebase-admin/firestore';
import type { CallableRequest } from 'firebase-functions/v2/https';
import { bootAdmin, shutdownAdmin } from './support.js';
import {
candidateDocId, fnBriefingIngest, fnBriefingPublish, validateCandidate,
} from '../src/briefing/briefing.js';
import { dayKey } from '../src/core/time.js';
import { fandomTopic, notifyEclipseWarning } from '../src/notify/push.js';
/**
* AI Daily Briefing 검증 — 정책 원장 B-47 · B-48 · B-75.
*
* 보는 것은 셋이다.
* - **수신이 5 요소를 강제하는가**(B-75 「메타 누락 건은 Draft 단계에서 배제」)
* - **수신이 draft 로만 끝나는가**(B-47 「AI Draft 자동게시 금지」)
* - **승인 권한이 함수에 실제로 붙어 있는가**
*
* 실제 FCM 발송은 확인하지 않는다 — 배포 전이고 에뮬레이터에 FCM 이 없다.
*/
const FANDOM = 'test-brief-fandom';
/** 5 요소를 모두 갖춘 정상 후보. 각 검증이 여기서 한 값만 빼서 쓴다. */
function candidate(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
fandomId: FANDOM,
section: 'News',
title: '데뷔 10주년 단독 콘서트 확정',
sourceName: '연합뉴스',
sourceAt: '2026-09-14T01:20:00Z',
sourceUrl: 'https://example.com/news/1',
articleUrl: 'https://example.com/news/1',
aiSummary: '서울 공연 이틀로 확정되었고 예매는 다음 주에 열립니다.',
...overrides,
};
}
const req = (role: string | undefined, data: unknown) => ({
auth: role ? { uid: 'admin-1', token: { role } } : undefined,
data,
}) as unknown as CallableRequest;
/** 섹션별 rank 목록. 정렬해 두어야 비교가 흔들리지 않는다. */
function ranksBySection(
docs: FirebaseFirestore.QueryDocumentSnapshot[],
): Record<string, number[]> {
const bySection: Record<string, number[]> = {};
for (const doc of docs) {
const section = doc.get('section') as string;
bySection[section] = [...(bySection[section] ?? []), doc.get('rank') as number];
}
for (const key of Object.keys(bySection)) bySection[key]!.sort((a, b) => a - b);
return bySection;
}
beforeAll(() => { bootAdmin(); });
afterAll(async () => { await shutdownAdmin(); });
beforeEach(async () => {
const db = getFirestore();
const briefs = await db.collection('briefings')
.where('fandomId', '==', FANDOM).get();
await Promise.all(briefs.docs.map((d) => d.ref.delete()));
// 작업 로그도 지운다. 남겨 두면 게시 검증의 로그 건수가 회차마다 늘어난다.
const logs = await db.collection('adminActionLogs')
.where('targetType', '==', 'briefing').get();
await Promise.all(logs.docs.map((d) => d.ref.delete()));
const eclipses = await db.collection(`fandoms/${FANDOM}/eclipseRounds`).get();
await Promise.all(eclipses.docs.map((d) => d.ref.delete()));
await db.doc(`fandoms/${FANDOM}`).set({ name: '테스트행성', stage: 1 });
});
describe('B-75 5 요소 검증', () => {
it('다 갖춘 후보를 통과시킨다', () => {
expect(validateCandidate(candidate()).ok).toBe(true);
});
it('5 요소 중 하나만 빠져도 거부한다', () => {
for (const key of [
'title', 'sourceName', 'sourceAt', 'sourceUrl', 'articleUrl', 'aiSummary',
]) {
const result = validateCandidate(candidate({ [key]: undefined }));
expect(result.ok, key).toBe(false);
if (!result.ok) expect(result.reason, key).toContain(key);
}
});
it('빈 문자열과 공백을 누락으로 본다 — 수집기가 흔히 채워 보낸다', () => {
for (const value of ['', ' ']) {
expect(validateCandidate(candidate({ sourceName: value })).ok).toBe(false);
}
});
it('빠진 것을 한 번에 모아서 알린다', () => {
const result = validateCandidate(
candidate({ sourceUrl: undefined, aiSummary: undefined }),
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toContain('sourceUrl');
expect(result.reason).toContain('aiSummary');
}
});
it('섹션은 News 와 SNS 만 받는다', () => {
expect(validateCandidate(candidate({ section: 'SNS' })).ok).toBe(true);
for (const bad of ['activity', 'news', '', undefined]) {
expect(validateCandidate(candidate({ section: bad })).ok, String(bad)).toBe(false);
}
});
it('시각 형식과 URL 형태까지 본다', () => {
expect(validateCandidate(candidate({ sourceAt: '어제' })).ok).toBe(false);
expect(validateCandidate(candidate({ articleUrl: 'javascript:alert(1)' })).ok)
.toBe(false);
expect(validateCandidate(candidate({ sourceUrl: 'example.com/1' })).ok).toBe(false);
});
it('같은 원본 URL 은 같은 문서 ID 로 모인다', () => {
const a = validateCandidate(candidate());
const b = validateCandidate(candidate({ title: '제목만 바뀐 같은 기사' }));
expect(a.ok && b.ok).toBe(true);
if (a.ok && b.ok) {
expect(candidateDocId(a.value, '2026-09-14'))
.toBe(candidateDocId(b.value, '2026-09-14'));
}
});
});
describe('수신 — fn-briefing-ingest (export 직접 호출)', () => {
it('통과 건만 저장하고 거부 사유를 함께 돌려준다', async () => {
const result = await fnBriefingIngest.run(req('ops', {
items: [
candidate(),
candidate({ section: 'SNS', sourceUrl: 'https://example.com/sns/1' }),
candidate({ sourceName: undefined, sourceUrl: 'https://example.com/news/2' }),
],
}));
expect(result.accepted).toBe(2);
expect(result.rejected).toHaveLength(1);
expect(result.rejected[0]!.index).toBe(2);
expect(result.rejected[0]!.reason).toContain('sourceName');
const docs = await getFirestore().collection('briefings')
.where('fandomId', '==', FANDOM).get();
expect(docs.size).toBe(2);
});
it('수신은 언제나 draft 로 끝난다 — 자동 게시 0 건', async () => {
await fnBriefingIngest.run(req('master', { items: [candidate()] }));
const docs = await getFirestore().collection('briefings')
.where('fandomId', '==', FANDOM).get();
expect(docs.size).toBe(1);
const doc = docs.docs[0]!;
expect(doc.get('status')).toBe('draft');
expect(doc.get('aiGenerated')).toBe(true);
expect(doc.get('dayKey')).toBe(dayKey(new Date()));
// B-75 5 요소가 문서에 전건 남아 있어야 화면이 그릴 수 있다.
for (const field of [
'sourceName', 'sourceAt', 'sourceUrl', 'articleUrl', 'aiSummary',
]) {
expect(doc.get(field), field).toBeTruthy();
}
});
it('rank 를 섹션마다 따로 매긴다', async () => {
await fnBriefingIngest.run(req('ops', {
items: [
candidate({ sourceUrl: 'https://example.com/n/1' }),
candidate({ sourceUrl: 'https://example.com/n/2' }),
candidate({ section: 'SNS', sourceUrl: 'https://example.com/s/1' }),
],
}));
const docs = await getFirestore().collection('briefings')
.where('fandomId', '==', FANDOM).get();
expect(ranksBySection(docs.docs)).toEqual({ News: [1, 2], SNS: [1] });
});
it('같은 회차를 두 번 받아도 문서가 늘지 않는다', async () => {
const items = [candidate()];
await fnBriefingIngest.run(req('ops', { items }));
await fnBriefingIngest.run(req('ops', { items }));
const docs = await getFirestore().collection('briefings')
.where('fandomId', '==', FANDOM).get();
expect(docs.size).toBe(1);
});
it('빈 목록과 권한 없는 호출을 끊는다', async () => {
await expect(fnBriefingIngest.run(req('ops', { items: [] })))
.rejects.toThrowError('수신할 브리핑 항목이 없습니다.');
await expect(fnBriefingIngest.run(req('viewer', { items: [candidate()] })))
.rejects.toThrowError('권한이 없습니다.');
await expect(fnBriefingIngest.run(req(undefined, { items: [candidate()] })))
.rejects.toThrowError('로그인이 필요합니다.');
});
it('전건 거부면 저장이 없다', async () => {
const result = await fnBriefingIngest.run(req('ops', {
items: [candidate({ aiSummary: undefined })],
}));
expect(result.accepted).toBe(0);
const docs = await getFirestore().collection('briefings')
.where('fandomId', '==', FANDOM).get();
expect(docs.size).toBe(0);
});
});
describe('승인 — fn-briefing-publish (export 직접 호출)', () => {
/** 당일 초안 3 건(News 2 · SNS 1)을 수신 경로로 심는다. */
async function seedDrafts(): Promise<void> {
await fnBriefingIngest.run(req('ops', {
items: [
candidate({ sourceUrl: 'https://example.com/n/1' }),
candidate({ sourceUrl: 'https://example.com/n/2' }),
candidate({ section: 'SNS', sourceUrl: 'https://example.com/s/1' }),
],
}));
}
it('viewer 는 게시하지 못하고 초안이 그대로 남는다', async () => {
await seedDrafts();
await expect(
fnBriefingPublish.run(req('viewer', {
fandomId: FANDOM, dayKey: dayKey(new Date()),
})),
).rejects.toThrowError('권한이 없습니다.');
const docs = await getFirestore().collection('briefings')
.where('fandomId', '==', FANDOM).get();
expect(docs.docs.every((d) => d.get('status') === 'draft')).toBe(true);
});
it('로그인하지 않은 호출은 끊는다', async () => {
await expect(
fnBriefingPublish.run(req(undefined, {
fandomId: FANDOM, dayKey: dayKey(new Date()),
})),
).rejects.toThrowError('로그인이 필요합니다.');
});
it('초안이 없으면 게시할 것이 없다고 알린다', async () => {
await expect(
fnBriefingPublish.run(req('master', {
fandomId: FANDOM, dayKey: dayKey(new Date()),
})),
).rejects.toThrowError('게시할 브리핑 초안이 없습니다.');
});
it('master 는 게시하고 섹션별 건수까지 로그에 남긴다', async () => {
const db = getFirestore();
const day = dayKey(new Date());
await seedDrafts();
const result = await fnBriefingPublish.run(
req('master', { fandomId: FANDOM, dayKey: day }),
);
expect(result.published).toBe(3);
expect(result.bySection).toEqual({ News: 2, SNS: 1 });
// FCM 자격 증명이 없는 환경이라 발송은 실패한다. **게시는 되돌리지 않는다** —
// 관리자가 본 결과와 사용자가 보는 화면이 갈리지 않게 한 설계 그대로다.
expect(result.pushed).toBe(false);
const docs = await db.collection('briefings')
.where('fandomId', '==', FANDOM).get();
expect(docs.docs.every((d) => d.get('status') === 'published')).toBe(true);
expect(docs.docs.every((d) => d.get('publishedBy') === 'admin-1')).toBe(true);
const logs = await db.collection('adminActionLogs')
.where('targetPath', '==', `briefings/${FANDOM}_${day}`).get();
expect(logs.size).toBe(1);
expect(logs.docs[0]!.get('reason')).toBe('BRIEFING_PUBLISH');
expect(logs.docs[0]!.get('role')).toBe('master');
});
});
describe('당일 필터 — 앱이 읽는 질의', () => {
it('게시된 당일분만 고르고 초안과 전일분은 제외한다', async () => {
const db = getFirestore();
const today = dayKey(new Date());
const yesterday = dayKey(new Date(Date.now() - 24 * 60 * 60 * 1000));
await db.doc(`briefings/${FANDOM}_${today}_News_a`).set({
fandomId: FANDOM, dayKey: today, section: 'News', rank: 1,
status: 'published', headline: '오늘 게시분',
});
await db.doc(`briefings/${FANDOM}_${today}_News_b`).set({
fandomId: FANDOM, dayKey: today, section: 'News', rank: 2,
status: 'draft', headline: '오늘 초안',
});
await db.doc(`briefings/${FANDOM}_${yesterday}_News_a`).set({
fandomId: FANDOM, dayKey: yesterday, section: 'News', rank: 1,
status: 'published', headline: '어제 게시분',
});
const visible = await db.collection('briefings')
.where('fandomId', '==', FANDOM)
.where('status', '==', 'published')
.where('dayKey', '==', today)
.orderBy('rank')
.get();
expect(visible.docs.map((d) => d.get('headline'))).toEqual(['오늘 게시분']);
});
});
describe('푸시 토픽', () => {
it('구분마다 토픽을 나눈다 — OFF 구분은 구독 자체가 없다', () => {
expect(fandomTopic('bts', 'briefing')).toBe('fandom_bts_briefing');
expect(fandomTopic('bts', 'marketing')).toBe('fandom_bts_marketing');
});
it('FCM 이 받지 않는 문자를 걸러 낸다', () => {
expect(fandomTopic('b t/s', 'briefing')).toBe('fandom_b-t-s_briefing');
});
});
describe('이클립스 사전 알림 — 시작 − 3분 (원장 C-22)', () => {
/// 예약 회차 한 건을 심는다. [startInMin] 분 뒤 시작, 예고는 그 3분 전이다.
async function round(id: string, startInMin: number | null, extra: Record<string, unknown> = {}) {
const ref = getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${id}`);
const start = Date.now() + (startInMin ?? 0) * 60_000;
await ref.set({
status: 'scheduled',
...(startInMin === null ? {} : {
startAt: Timestamp.fromMillis(start),
warnAt: Timestamp.fromMillis(start - 180_000),
}),
...extra,
});
return ref;
}
it('예고 시각이 지나고 시작 전이면 보내려 하고 판정 시각을 남긴다', async () => {
const ref = await round('fresh', 3);
const now = new Date();
// FCM 자격 증명이 없는 환경이라 발송 자체는 실패한다. 판정이 발송까지 갔다는 것이 확인 대상이다.
expect(await notifyEclipseWarning(await ref.get(), now)).toBe('failed');
expect((await ref.get()).get('alertPushedAt').toMillis()).toBe(now.getTime());
});
it('예고 시각 전에는 보내지 않고 표시도 남기지 않는다', async () => {
const ref = await round('early', 5);
expect(await notifyEclipseWarning(await ref.get())).toBe('too-early');
expect((await ref.get()).get('alertPushedAt')).toBeUndefined();
});
it('시작이 지난 회차에는 예고를 소급 발송하지 않는다', async () => {
const ref = await round('stale', -1);
expect(await notifyEclipseWarning(await ref.get())).toBe('too-old');
expect((await ref.get()).get('alertPushedAt')).toBeTruthy();
});
it('시각을 모르거나 취소된 회차에는 보내지 않는다', async () => {
const unknown = await round('unknown', null);
expect(await notifyEclipseWarning(await unknown.get())).toBe('no-start');
expect((await unknown.get()).get('alertPushedAt')).toBeUndefined();
const cancelled = await round('cancelled', 3, { status: 'cancelled' });
expect(await notifyEclipseWarning(await cancelled.get())).toBe('already');
});
it('같은 회차에 두 번 보내지 않는다 — 1 분 주기 점검이 도배하지 않는다', async () => {
const ref = await round('once', 3);
await notifyEclipseWarning(await ref.get());
expect(await notifyEclipseWarning(await ref.get())).toBe('already');
});
});
@@ -0,0 +1,494 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { FieldValue, getFirestore, Timestamp } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
import { grantPop, refundPop } from '../src/ledger/ledger.js';
import { fundCampaign } from '../src/fandom/fandom.js';
import { createCampaign, retryFeeRefunds, withdrawCampaign } from '../src/campaign/create.js';
import {
MAX_AUTO_REFUND_ATTEMPTS, cancelCampaign, fnCampaignAdminReview, fnCampaignReportSubmit, refundCampaign,
retryPendingRefunds, settleDueCampaigns, submitCampaignReport,
} from '../src/campaign/settle.js';
/**
* 캠페인 개설 · 정산 · 자동 환불 · 결과보고 — 별첨 1 ⑤ 판정 기준
* 「성공/실패 분기 정확 · 자동환불 무결성(부분 실패 방치·중복 환불 0건) · 결과보고 게시 확인」.
*
* 기간 단축은 `endsAt` 을 과거로 옮기고 정산 코어를 직접 부르는 것으로 한다 — 10분 스케줄을
* 기다리지 않고 같은 코드가 도는지를 본다.
*/
const CREATOR = 'test-cmp-creator';
/** 검증 활동(Quest 완료) 표식 n건 — Campaign 생성자격 5회(A안). */
async function completions(uid: string, n: number) {
for (let i = 0; i < n; i++) await getFirestore().doc(`users/${uid}/verifiedCompletions/q${i}_2026-09-0${i + 1}`).set({ questId: `q${i}` });
}
const A = 'test-cmp-donor-a';
const B = 'test-cmp-donor-b';
const db = () => getFirestore();
beforeAll(() => { bootAdmin(); });
afterAll(async () => { await shutdownAdmin(); });
beforeEach(async () => {
for (const uid of [CREATOR, A, B]) {
await db().recursiveDelete(db().collection(`users/${uid}/notifications`));
await wipeUser(uid);
}
// 정산·재시도는 컬렉션 전체를 훑으므로 다른 회차가 남긴 캠페인을 지운다.
await db().recursiveDelete(db().collection('campaigns'));
await db().recursiveDelete(db().collection('adminActionLogs'));
});
async function code(run: () => Promise<unknown>): Promise<string> {
try {
await run();
} catch (error) {
const e = error as { details?: { code?: string }; code?: string };
return e.details?.code ?? e.code ?? String(error);
}
throw new Error('실패해야 하는 호출이 성공했습니다.');
}
/** 모금 중 캠페인과 후원들을 만들고 마감을 지난 시각으로 옮긴다(기간 단축). */
async function fundedCampaign(id: string, target: number, gifts: Array<[string, number]>) {
await db().doc(`campaigns/${id}`).set({
creatorUid: CREATOR, title: `캠페인 ${id}`, status: 'funding',
targetPop: target, raisedPop: 0, donorCount: 0, refundState: 'none',
endsAt: Timestamp.fromMillis(Date.now() + 60_000),
});
for (const [uid, amount] of gifts) {
await grantPop({
uid, popType: 'support', amount: 1_000,
reasonCode: 'ADMIN_GRANT', idempotencyKey: `seed:${id}:${uid}`,
});
await fundCampaign({ uid, campaignId: id, clientSeq: '1', amount });
}
await db().doc(`campaigns/${id}`).update({ endsAt: Timestamp.fromMillis(Date.now() - 1_000) });
}
const supportOf = async (uid: string) =>
(await db().doc(`users/${uid}/wallet/main`).get()).get('supportBalance') as number;
const refundRows = async (uid: string) => (await db()
.collection(`users/${uid}/popTransactions`)
.where('reasonCode', '==', 'CAMPAIGN_REFUND').get()).size;
const feeRefundRows = async (uid: string) => (await db()
.collection(`users/${uid}/popTransactions`)
.where('reasonCode', '==', 'CAMPAIGN_FEE_REFUND').get()).size;
const notes = async (uid: string) =>
(await db().collection(`users/${uid}/notifications`).get()).docs.map((d) => d.id);
describe('개설 — fn-campaign-create', () => {
beforeEach(async () => {
await db().doc(`users/${CREATOR}`).set({
displayName: '김실명', nickname: '개설자', fandomId: 'f-cmp', status: 'active',
createdAt: Timestamp.fromMillis(Date.now() - 30 * 86_400_000),
});
await completions(CREATOR, 5);
});
const draft = { uid: CREATOR, title: '지하철 광고', body: '5주년', targetPop: 500, days: 7 };
it('등록비 100 POP(일반)을 빼고 review 로 올린다 · 같은 요청 재시도는 한 건', async () => {
await grantPop({ uid: CREATOR, popType: 'activity', amount: 150, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'g:fee' });
const first = await createCampaign({ ...draft, clientSeq: 's1' });
const again = await createCampaign({ ...draft, clientSeq: 's1' });
expect(again.campaignId).toBe(first.campaignId);
const doc = await db().doc(`campaigns/${first.campaignId}`).get();
expect(doc.get('status')).toBe('review');
expect(doc.get('fandomId')).toBe('f-cmp');
expect(doc.get('targetPop')).toBe(500);
// 개설자 이름은 공개 닉네임 — 공급자 실명(displayName)을 싣지 않는다(M-7).
expect(doc.get('creatorName')).toBe('개설자');
expect((await db().doc(`users/${CREATOR}/wallet/main`).get()).get('activityBalance')).toBe(50);
const fees = await db().collection(`users/${CREATOR}/popTransactions`)
.where('reasonCode', '==', 'CAMPAIGN_FEE').get();
expect(fees.size).toBe(1);
// 진행 중 1건 보유 시 2건째 차단(B-56).
expect(await code(() => createCampaign({ ...draft, clientSeq: 's2' }))).toBe('E-PERM-01');
// 운영 승인으로 모금이 열린다.
await fnCampaignAdminReview.run({
data: { campaignId: first.campaignId, requestId: 'r1', approve: true, memo: 'ok' },
auth: { uid: 'ops-user', token: { role: 'ops' } },
} as never);
expect((await db().doc(`campaigns/${first.campaignId}`).get()).get('status')).toBe('funding');
});
it('검증 활동(Quest 완료) 5회 미만은 E-CMP-04 · 남은 횟수를 싣는다(Campaign 개설자격 A안)', async () => {
await grantPop({ uid: CREATOR, popType: 'activity', amount: 150, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'g:elig' });
await db().recursiveDelete(db().collection(`users/${CREATOR}/verifiedCompletions`));
await completions(CREATOR, 3);
const err = await createCampaign({ ...draft, clientSeq: 'e1' }).then(() => null, (e: unknown) => e);
expect((err as { details?: Record<string, unknown> }).details).toEqual({ code: 'E-CMP-04', done: 3, need: 5 });
expect((await db().collection('campaigns').get()).size).toBe(0);
// 기준 횟수는 운영값이다.
await db().doc('config/campaign').set({ minVerifiedQuests: 3 }, { merge: true });
try {
expect((await createCampaign({ ...draft, clientSeq: 'e2' })).status).toBe('review');
} finally {
await db().doc('config/campaign').update({ minVerifiedQuests: FieldValue.delete() });
}
});
it('잔액 부족 · 가입 7일 미만은 거부하고 캠페인을 남기지 않는다', async () => {
expect(await code(() => createCampaign({ ...draft, clientSeq: 'poor' }))).toBe('E-POP-01');
await db().doc(`users/${CREATOR}`).update({ createdAt: Timestamp.now() });
expect(await code(() => createCampaign({ ...draft, clientSeq: 'new' }))).toBe('E-PERM-01');
expect((await db().collection('campaigns').get()).size).toBe(0);
});
});
describe('등록비 분할 · 반환 (B-57 · B-58)', () => {
beforeEach(async () => {
await db().doc(`users/${CREATOR}`).set({
displayName: '개설자', fandomId: 'f-cmp', status: 'active',
createdAt: Timestamp.fromMillis(Date.now() - 30 * 86_400_000),
});
await completions(CREATOR, 5);
await grantPop({ uid: CREATOR, popType: 'activity', amount: 40, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'g:fee:a' });
await grantPop({ uid: CREATOR, popType: 'support', amount: 500, reasonCode: 'IAP_PURCHASE', idempotencyKey: 'g:fee:s' });
});
const draft = { uid: CREATOR, title: '광고', body: '본문', targetPop: 500, days: 7 };
const walletOf = async () => {
const w = await db().doc(`users/${CREATOR}/wallet/main`).get();
return [w.get('activityBalance'), w.get('supportBalance')];
};
const review = (campaignId: string, requestId: string, approve: boolean) => fnCampaignAdminReview.run({
data: { campaignId, requestId, approve, memo: 'm' },
auth: { uid: 'ops-user', token: { role: 'ops' } },
} as never);
it('일반 40 + 기여 60 으로 나눠 내고, 반려되면 각 타입으로 한 번만 돌려받는다', async () => {
const { campaignId } = await createCampaign({ ...draft, clientSeq: 'split' });
expect(await walletOf()).toEqual([0, 440]);
const fee = await db().doc(`campaigns/${campaignId}`).get();
expect((fee.get('feeTxIds') as string[]).length).toBe(2);
await review(campaignId, 'r-reject', false);
await review(campaignId, 'r-reject', false);
const doc = await db().doc(`campaigns/${campaignId}`).get();
expect(doc.get('status')).toBe('rejected');
expect(doc.get('feeRefund')).toBe('done');
expect(await walletOf()).toEqual([40, 500]);
expect(await feeRefundRows(CREATOR)).toBe(2);
expect(await refundRows(CREATOR)).toBe(0);
});
it('반려 전이 뒤 반환 전에 끊겨도 — 새 요청 ID 의 재시도와 스케줄이 마저 돌려준다', async () => {
const { campaignId } = await createCampaign({ ...draft, clientSeq: 'crash' });
// 트랜잭션은 섰고 반환 호출 전에 죽은 상태.
await db().doc(`campaigns/${campaignId}`).update({ status: 'rejected', feeRefund: 'due' });
expect(await code(() => review(campaignId, 'r-new', false))).toBe('E-CMP-03');
expect(await walletOf()).toEqual([40, 500]);
expect((await db().doc(`campaigns/${campaignId}`).get()).get('feeRefund')).toBe('done');
});
it('스케줄(retryFeeRefunds)이 남은 반환을 한 번만 돌린다 · 옛 feeTxId 한 건도', async () => {
const { campaignId } = await createCampaign({ ...draft, clientSeq: 'sweep' });
const ref = db().doc(`campaigns/${campaignId}`);
const [general] = (await ref.get()).get('feeTxIds') as string[];
// 분할 전 데이터 모양 — 단일 feeTxId(일반 몫만 남긴다).
await ref.update({ status: 'rejected', feeRefund: 'due', feeTxIds: FieldValue.delete(), feeTxId: general });
expect(await retryFeeRefunds()).toBe(1);
expect(await retryFeeRefunds()).toBe(0);
expect(await walletOf()).toEqual([40, 440]);
expect(await feeRefundRows(CREATOR)).toBe(1);
});
it('승인과 철회가 겹치면 한쪽 판정만 선다', async () => {
const { campaignId } = await createCampaign({ ...draft, clientSeq: 'race' });
const [approved] = await Promise.allSettled([
review(campaignId, 'r-race', true),
withdrawCampaign(CREATOR, campaignId),
]);
const doc = await db().doc(`campaigns/${campaignId}`).get();
if (approved.status === 'fulfilled') {
// 승인이 먼저 — 철회는 공개 후 첫 후원 전 철회라 비환불.
expect(['funding', 'withdrawn']).toContain(doc.get('status'));
expect(await feeRefundRows(CREATOR)).toBe(0);
} else {
expect(doc.get('status')).toBe('withdrawn');
expect(await feeRefundRows(CREATOR)).toBe(2);
expect((await db().collection(`campaigns/${campaignId}/reviews`).get()).size).toBe(0);
}
});
it('공개 전 철회는 등록비를 돌려주고 재시도해도 한 번뿐이다', async () => {
const { campaignId } = await createCampaign({ ...draft, clientSeq: 'wd' });
expect((await withdrawCampaign(CREATOR, campaignId)).status).toBe('withdrawn');
await withdrawCampaign(CREATOR, campaignId);
expect(await walletOf()).toEqual([40, 500]);
expect(await feeRefundRows(CREATOR)).toBe(2);
// 철회한 캠페인은 진행 중 1건에 세지 않는다.
await createCampaign({ ...draft, clientSeq: 'wd2' });
});
it('공개 후 첫 후원 전 철회는 되지만 등록비는 비환불 · 첫 후원 뒤에는 E-PERM-01', async () => {
const { campaignId } = await createCampaign({ ...draft, clientSeq: 'pub' });
await review(campaignId, 'r-ok', true);
expect(await code(() => withdrawCampaign('someone-else', campaignId))).toBe('E-PERM-01');
await withdrawCampaign(CREATOR, campaignId);
expect((await db().doc(`campaigns/${campaignId}`).get()).get('status')).toBe('withdrawn');
expect(await walletOf()).toEqual([0, 440]);
expect(await feeRefundRows(CREATOR)).toBe(0);
await fundedCampaign('donated', 1_000, [[A, 10]]);
await db().doc('campaigns/donated').update({ endsAt: Timestamp.fromMillis(Date.now() + 60_000) });
expect(await code(() => withdrawCampaign(CREATOR, 'donated'))).toBe('E-PERM-01');
});
});
describe('회사귀책 취소 — fn-campaign-admin-cancel (B-58)', () => {
it('등록비와 후원 전액을 한 번만 돌려주고 개설자 · 후원자에게 알린다', async () => {
await db().doc(`users/${CREATOR}`).set({
displayName: '개설자', fandomId: 'f-cmp', status: 'active',
createdAt: Timestamp.fromMillis(Date.now() - 30 * 86_400_000),
});
await completions(CREATOR, 5);
await grantPop({ uid: CREATOR, popType: 'activity', amount: 100, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'g:cancel' });
const { campaignId } = await createCampaign({
uid: CREATOR, title: '광고', body: '본문', targetPop: 1_000, days: 7, clientSeq: 'cancel',
});
await fnCampaignAdminReview.run({
data: { campaignId, requestId: 'ok', approve: true, memo: 'm' },
auth: { uid: 'ops-user', token: { role: 'ops' } },
} as never);
for (const [uid, amount] of [[A, 30], [B, 50]] as const) {
await grantPop({ uid, popType: 'support', amount: 100, reasonCode: 'ADMIN_GRANT', idempotencyKey: `seed:cancel:${uid}` });
await fundCampaign({ uid, campaignId, clientSeq: '1', amount });
}
const first = await cancelCampaign(campaignId, 'ops-user', '운영 오류');
expect(first.refund.refunded).toBe(2);
await cancelCampaign(campaignId, 'ops-user', '운영 오류');
const doc = await db().doc(`campaigns/${campaignId}`).get();
expect(doc.get('status')).toBe('cancelled');
expect(doc.get('refundState')).toBe('done');
expect(doc.get('feeRefund')).toBe('done');
expect(await supportOf(A)).toBe(100);
expect(await supportOf(B)).toBe(100);
expect(await refundRows(A)).toBe(1);
expect(await refundRows(B)).toBe(1);
expect(await feeRefundRows(CREATOR)).toBe(1);
expect((await db().doc(`users/${CREATOR}/wallet/main`).get()).get('activityBalance')).toBe(100);
expect(await notes(CREATOR)).toEqual([`campaignCancel_${campaignId}`]);
expect((await notes(A)).length).toBe(1);
// 환불 알림 링크가 취소 문서를 열도록 규칙이 보는 후원자 표식(backers/{uid}).
const backers = await db().collection(`campaigns/${campaignId}/backers`).get();
expect(backers.docs.map((d) => d.id).sort()).toEqual([A, B].sort());
// 성공 · 실패로 끝난 캠페인은 취소 대상이 아니다.
await db().doc('campaigns/done').set({ creatorUid: CREATOR, status: 'success' });
expect(await code(() => cancelCampaign('done', 'ops-user', 'x'))).toBe('E-CMP-03');
});
});
describe('정산 분기', () => {
it('목표(targetPop) 달성은 success, 미달은 fail 이다', async () => {
await fundedCampaign('cmp-ok', 500, [[A, 300], [B, 200]]);
await fundedCampaign('cmp-short', 1_000, [[A, 100]]);
expect(await settleDueCampaigns()).toBe(2);
expect(await settleDueCampaigns()).toBe(0); // 두 번 판정하지 않는다.
const ok = await db().doc('campaigns/cmp-ok').get();
const short = await db().doc('campaigns/cmp-short').get();
expect(ok.get('status')).toBe('success');
expect(short.get('status')).toBe('fail');
expect(short.get('refundState')).toBe('pending');
expect(short.get('refundDueAt').toMillis() - Date.now()).toBeGreaterThan(71 * 3600_000);
});
it('마감이 지난 캠페인은 정산 전이라도 후원을 받지 않는다', async () => {
await fundedCampaign('cmp-late', 1_000, [[A, 100]]);
expect(await code(() => fundCampaign({ uid: A, campaignId: 'cmp-late', clientSeq: '2', amount: 50 })))
.toBe('E-CMP-03');
expect((await db().doc('campaigns/cmp-late').get()).get('raisedPop')).toBe(100);
});
});
describe('자동 환불', () => {
it('실패 판정 뒤 관리자 호출 없이 전건을 돌려주고 알린다 · 재실행해도 중복 0', async () => {
await fundedCampaign('cmp-fail', 1_000, [[A, 300], [B, 200]]);
await settleDueCampaigns();
await retryPendingRefunds();
const campaign = await db().doc('campaigns/cmp-fail').get();
expect(campaign.get('refundState')).toBe('done');
expect(campaign.get('refundedCount')).toBe(2);
expect(campaign.get('lockedUntil')).toBeUndefined();
expect(await supportOf(A)).toBe(1_000);
expect(await supportOf(B)).toBe(1_000);
const donation = (await db().collection('campaigns/cmp-fail/donations').where('uid', '==', A).get()).docs[0]!;
expect(await notes(A)).toEqual([`campaignRefund_${donation.get('refundTxId')}`]);
expect(await retryPendingRefunds()).toBe(0);
await refundCampaign('cmp-fail', { manual: true });
expect(await refundRows(A)).toBe(1);
expect(await notes(A)).toHaveLength(1);
});
it('스케줄과 Admin 배치가 겹쳐도 후원 한 건당 한 번만 돌려준다', async () => {
await fundedCampaign('cmp-race', 1_000, [[A, 300], [B, 200]]);
await settleDueCampaigns();
const [auto, manual] = await Promise.all([
retryPendingRefunds(),
refundCampaign('cmp-race', { manual: true }),
]);
expect(auto + (manual.skipped ? 0 : 1)).toBeGreaterThanOrEqual(1);
expect(await refundRows(A)).toBe(1);
expect(await refundRows(B)).toBe(1);
expect(await supportOf(A)).toBe(1_000);
expect((await db().doc('campaigns/cmp-race').get()).get('refundState')).toBe('done');
});
it('잠금이 살아 있으면 건너뛴다', async () => {
await fundedCampaign('cmp-lock', 1_000, [[A, 300]]);
await settleDueCampaigns();
await db().doc('campaigns/cmp-lock').update({ lockedUntil: Timestamp.fromMillis(Date.now() + 60_000) });
expect((await refundCampaign('cmp-lock', { manual: true })).skipped).toBe('locked');
expect(await refundRows(A)).toBe(0);
});
it('다른 경로로 이미 돌려준 원거래는 환불 끝으로 보고, 알림이 빠졌으면 다시 보낸다', async () => {
await fundedCampaign('cmp-dup', 1_000, [[A, 300]]);
await settleDueCampaigns();
const donation = (await db().collection('campaigns/cmp-dup/donations').get()).docs[0]!;
await refundPop({
uid: A, originalTxId: donation.get('txId'),
idempotencyKey: 'manual-other-key', reasonCode: 'CAMPAIGN_REFUND',
});
await retryPendingRefunds();
expect((await db().doc('campaigns/cmp-dup').get()).get('refundState')).toBe('done');
expect(await refundRows(A)).toBe(1);
expect(await supportOf(A)).toBe(1_000);
expect(await notes(A)).toHaveLength(1);
// 환불 표시는 남고 알림 전에 죽은 경우 — 알림만 다시 간다.
await db().recursiveDelete(db().collection(`users/${A}/notifications`));
await donation.ref.update({ refundNotifiedAt: null });
await db().doc('campaigns/cmp-dup').update({ refundState: 'partial' });
await retryPendingRefunds();
expect(await notes(A)).toHaveLength(1);
expect(await refundRows(A)).toBe(1);
});
it('부분 실패는 partial 로 남아 자동 재시도하고, 상한에서 Admin 큐로 간다', async () => {
await fundedCampaign('cmp-partial', 1_000, [[A, 300]]);
await db().doc('campaigns/cmp-partial/donations/broken').set({ uid: B, amount: 50 });
await settleDueCampaigns();
await retryPendingRefunds();
let campaign = await db().doc('campaigns/cmp-partial').get();
expect(campaign.get('refundState')).toBe('partial');
expect(campaign.get('refundFailedCount')).toBe(1);
expect(campaign.get('refundAttempts')).toBe(1);
expect(await supportOf(A)).toBe(1_000);
expect((await db().collection('adminActionLogs').get()).size).toBe(0);
for (let i = 1; i < MAX_AUTO_REFUND_ATTEMPTS; i++) await retryPendingRefunds();
campaign = await db().doc('campaigns/cmp-partial').get();
expect(campaign.get('refundAttempts')).toBe(MAX_AUTO_REFUND_ATTEMPTS);
expect(campaign.get('refundQueuedAt')).toBeTruthy();
const queue = await db().collection('adminActionLogs').get();
expect(queue.size).toBe(1);
expect(queue.docs[0]?.get('targetPath')).toBe('campaigns/cmp-partial');
expect(await retryPendingRefunds()).toBe(0);
expect(await refundRows(A)).toBe(1);
});
it('옛 정산의 failed 는 targetPop 으로 다시 판정한다 — 목표를 채웠으면 돌려주지 않는다', async () => {
// 예전 정산은 goalPop 만 읽어 목표를 채운 캠페인도 failed 로 적었다(refundAttempts 없음).
await fundedCampaign('cmp-legacy-met', 500, [[A, 500]]);
await fundedCampaign('cmp-legacy-short', 1_000, [[B, 200]]);
for (const id of ['cmp-legacy-met', 'cmp-legacy-short']) {
await db().doc(`campaigns/${id}`).update({ status: 'failed', refundState: 'pending' });
}
await retryPendingRefunds();
const met = await db().doc('campaigns/cmp-legacy-met').get();
expect(met.get('status')).toBe('failed');
expect(met.get('refundState')).toBe('pending');
expect(await refundRows(A)).toBe(0);
expect(await supportOf(A)).toBe(500);
expect((await refundCampaign('cmp-legacy-met', { manual: true })).skipped).toBe('legacy-goal-met');
const short = await db().doc('campaigns/cmp-legacy-short').get();
expect(short.get('status')).toBe('fail');
expect(short.get('refundState')).toBe('done');
expect(await supportOf(B)).toBe(1_000);
});
});
describe('결과보고 — 운영 전용 (B-35)', () => {
const file = (id: string, name: string) => `campaignReports/${id}/${name}`;
const uploaded = async () => true;
const report = (id: string) => ({
uid: 'ops-user', campaignId: id, body: '광고 집행 완료',
photoPaths: [file(id, 'p1.png')],
receiptPaths: [file(id, 'r1.png')],
budget: [{ label: '광고 집행', amount: 450 }, { label: '제작비', amount: 50 }],
});
it('성공 캠페인에 올리면 report 로 바뀌고 후원자에게 알린다 · 재호출은 중복이고 빠진 알림만 보낸다', async () => {
await fundedCampaign('cmp-rep', 500, [[A, 500]]);
await settleDueCampaigns();
expect(await submitCampaignReport(report('cmp-rep'), uploaded)).toEqual({ duplicate: false });
expect((await db().doc('campaigns/cmp-rep').get()).get('status')).toBe('report');
const doc = await db().doc('campaigns/cmp-rep/reports/main').get();
expect(doc.get('receiptPaths')).toEqual(['campaignReports/cmp-rep/r1.png']);
expect(doc.get('budgetTotal')).toBe(500);
expect(await notes(A)).toEqual(['campaignReport_cmp-rep_main']);
// 게시 뒤 알림 전에 죽은 경우.
await db().recursiveDelete(db().collection(`users/${A}/notifications`));
await doc.ref.update({ notifiedAt: null });
expect(await submitCampaignReport(report('cmp-rep'), uploaded)).toEqual({ duplicate: true });
expect(await notes(A)).toEqual(['campaignReport_cmp-rep_main']);
});
it('운영이 아니면 거부한다(개설자 포함)', async () => {
await fundedCampaign('cmp-rep-role', 500, [[A, 500]]);
await settleDueCampaigns();
expect(await code(() => fnCampaignReportSubmit.run({
data: { ...report('cmp-rep-role') }, auth: { uid: CREATOR, token: {} },
} as never))).toBe('E-PERM-01');
});
it('실패 캠페인 · 경로 우회 · URL · 올라가지 않은 파일 · 빈 영수증·예산은 거부한다', async () => {
await fundedCampaign('cmp-rep2', 500, [[A, 500]]);
await fundedCampaign('cmp-rep-fail', 1_000, [[B, 100]]);
await settleDueCampaigns();
const r = report('cmp-rep2');
expect(await code(() => submitCampaignReport(report('cmp-rep-fail'), uploaded))).toBe('E-CMP-03');
for (const bad of [
'campaignReports/cmp-rep2/../other/p.png',
'campaignReports/cmp-rep2/sub/p.png',
'campaignReports/other/p.png',
'users/ops-user/campaignReports/cmp-rep2/p.png',
'https://firebasestorage.googleapis.com/v0/b/x/o/campaignReports%2Fcmp-rep2%2Fp.png',
'campaignReports/cmp-rep2/..',
]) {
expect(await code(() => submitCampaignReport({ ...r, photoPaths: [bad] }, uploaded))).toBe('invalid-argument');
}
expect(await code(() => submitCampaignReport(r, async () => false))).toBe('invalid-argument');
expect(await code(() => submitCampaignReport({ ...r, receiptPaths: [] }, uploaded))).toBe('invalid-argument');
expect(await code(() => submitCampaignReport({ ...r, budget: [] }, uploaded))).toBe('invalid-argument');
expect(await code(() => submitCampaignReport(
{ ...r, budget: [{ label: '광고', amount: -1 }] }, uploaded,
))).toBe('invalid-argument');
expect((await db().doc('campaigns/cmp-rep2').get()).get('status')).toBe('success');
});
});
+488
View File
@@ -0,0 +1,488 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { getFirestore, FieldValue, Timestamp, Transaction } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
import { grantPop } from '../src/ledger/ledger.js';
import { aggregateActiveEclipses, defendEclipse } from '../src/fandom/fandom.js';
/**
* 이클립스 방어 = **팬덤 공동 Shake 참여** · POP 소모 없음.
*
* 근거 — 정책 원장 **B-55** 비고 · 정책설계서 v1.1 255~264행 · Firestore 설계서 v1.1
* §9-#5: *방어는 Shake 참여로 이루어지며 1 회당 POP 소모를 두지 않는다*. 별첨 1 ①
* 시나리오 4 의 「번개 연타·오토 도네이션」은 별첨 B 가 상위 기준이라 따르지 않는다
* (공통 기준 2항).
*
* 보는 것
* 1. **POP 이 움직이지 않는다** — 지갑과 원장이 그대로다.
* 2. **분산 카운터 유실 0** — 샤드를 흩어도 합이 참여 총량과 같다(별첨 1 ① 예외 처리).
* 3. **멱등** — 재시도가 샤드·방어 로그·티커 이벤트를 두 번 올리지 않는다(검수 D1·D6).
* 4. **서버 검증** — 단말이 보낸 흔들기 수를 상한과 속도로 다시 판정한다(별첨 1 ②).
* 5. **즉시 성공 판정** — 목표에 닿은 순간 `defended` 가 선다(검수 D2).
*/
const FANDOM = 'test-ecl-fandom';
const ECLIPSE = 'test-ecl-1';
const A = 'test-ecl-user-a';
const B = 'test-ecl-user-b';
/** 지갑 기준액. 방어가 POP 을 쓰지 않으므로 끝까지 이 값이어야 한다. */
const WALLET = 10_000;
beforeAll(() => { bootAdmin(); });
// 남긴 팬덤은 다음 파일의 팬덤 전수 질의에 섞인다. 끝나고 치운다.
afterAll(async () => {
await wipeUser(A);
await wipeUser(B);
await wipeEclipse();
await shutdownAdmin();
});
async function wipeEclipse(): Promise<void> {
const db = getFirestore();
const root = db.doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`);
for (const sub of ['shards', 'defenseLogs', 'participants']) {
const snap = await root.collection(sub).get();
await Promise.all(snap.docs.map((d) => d.ref.delete()));
}
const live = await db.collection(`fandoms/${FANDOM}/liveEvents`).get();
await Promise.all(live.docs.map((d) => d.ref.delete()));
await root.delete();
await db.doc(`fandoms/${FANDOM}`).delete();
await db.doc('config/eclipse').delete();
}
/**
* 활성 이클립스 하나와 사용자 둘을 세운다.
*
* 기본 설정은 **속도 제한을 끈다**(`minIntervalMs: 0`). 테스트는 같은 사용자로 연속
* 호출해 유실과 멱등을 보는데, 속도 제한이 켜져 있으면 그 확인 자체가 막힌다.
* 속도 제한은 전용 테스트에서 따로 켜서 본다.
*/
async function seed(overrides: Record<string, unknown> = {}): Promise<void> {
const db = getFirestore();
const target = (overrides.target as number | undefined) ?? 1_000_000;
await db.doc('config/eclipse').set({
enabled: true,
perCallMaxShakes: 50,
perUserMaxDefense: 500,
minIntervalMs: 0,
...overrides,
});
await db.doc(`fandoms/${FANDOM}`).set({ name: 'BTS', fandomName: 'BTS' });
await db.doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`).set({
status: 'active',
target,
erosionRate: 100,
defense: 0,
defenseCount: 0,
defended: false,
startAt: FieldValue.serverTimestamp(),
endsAt: Timestamp.fromMillis(Date.now() + 30 * 60 * 1000),
});
const people: [string, string][] = [[A, '아미보이'], [B, '버니즈']];
for (const [uid, nickname] of people) {
await db.doc(`users/${uid}`).set({ uid, nickname, fandomId: FANDOM });
await grantPop({
uid, popType: 'activity', amount: WALLET,
reasonCode: 'ADMIN_GRANT', idempotencyKey: `g:ecl:${uid}`,
});
}
}
/** 샤드 합. 「분산 카운터로 카운트 유실 없음」의 확인 수단이다. */
async function shardSum(): Promise<{ defense: number; calls: number }> {
const snap = await getFirestore()
.collection(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}/shards`).get();
return {
defense: snap.docs.reduce((a, d) => a + ((d.get('defense') as number) ?? 0), 0),
calls: snap.docs.reduce((a, d) => a + ((d.get('defenseCount') as number) ?? 0), 0),
};
}
const countOf = async (path: string): Promise<number> =>
(await getFirestore().collection(path).get()).size;
const logCount = () =>
countOf(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}/defenseLogs`);
const tickerCount = () => countOf(`fandoms/${FANDOM}/liveEvents`);
async function balanceOf(uid: string): Promise<number> {
const snap = await getFirestore().doc(`users/${uid}/wallet/main`).get();
return (snap.get('activityBalance') as number | undefined) ?? 0;
}
beforeEach(async () => {
await wipeUser(A);
await wipeUser(B);
await wipeEclipse();
});
describe('방어는 POP 을 소모하지 않는다 (설계서 v1.1 §9-#5)', () => {
it('참여해도 지갑과 원장이 그대로다', async () => {
await seed();
const db = getFirestore();
await defendEclipse({
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 's1', shakes: 20,
});
// 지갑이 한 푼도 움직이지 않는다.
expect(await balanceOf(A)).toBe(WALLET);
// 방어로 원장 행이 생기지 않는다 — 지급 1 건(ADMIN_GRANT)뿐이다.
const ledger = await db.collection(`users/${A}/popTransactions`).get();
expect(ledger.size).toBe(1);
expect(ledger.docs[0]!.get('reasonCode')).toBe('ADMIN_GRANT');
// 누적 기여도 움직이지 않는다 — 방어는 기여 POP 이 아니다.
const user = await db.doc(`users/${A}`).get();
expect(user.get('totalContributedPop') ?? 0).toBe(0);
});
it('참여량은 흔들기 수로 쌓인다', async () => {
await seed();
const result = await defendEclipse({
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 's1', shakes: 20,
});
expect(result.accepted).toBe(20);
expect(result.clamped).toBe(0);
expect(result.myTotal).toBe(20);
expect(await shardSum()).toEqual({ defense: 20, calls: 1 });
});
});
describe('분산 카운터 — 카운트 유실 0 (별첨 1 ① 예외 처리)', () => {
it('샤드를 흩어 적어도 합이 참여 총량과 같다', async () => {
await seed();
const shakes = [1, 2, 3, 1, 5, 1, 2, 1, 3, 1];
for (const [i, n] of shakes.entries()) {
await defendEclipse({
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: `seq-${i}`, shakes: n,
});
}
const total = shakes.reduce((a, b) => a + b, 0);
expect(await shardSum()).toEqual({ defense: total, calls: shakes.length });
await aggregateActiveEclipses();
const summary = await getFirestore()
.doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`).get();
expect(summary.get('defense')).toBe(total);
expect(summary.get('defenseCount')).toBe(shakes.length);
expect(summary.get('erosionRate')).toBe(
100,
);
});
it('두 사용자가 동시에 참여해도 집계가 일치한다', async () => {
await seed();
await Promise.all([
...Array.from({ length: 10 }, (_, i) => defendEclipse({
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: `a-${i}`, shakes: 3,
})),
...Array.from({ length: 10 }, (_, i) => defendEclipse({
uid: B, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: `b-${i}`, shakes: 3,
})),
]);
await aggregateActiveEclipses();
const summary = await getFirestore()
.doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`).get();
expect(summary.get('defense')).toBe(60);
expect(summary.get('defenseCount')).toBe(20);
expect(await logCount()).toBe(20);
expect(await tickerCount()).toBe(20);
});
});
describe('멱등 — 재시도가 이중 계산되지 않는다 (검수 D1·D6)', () => {
it('같은 clientSeq 를 두 번 보내도 샤드·로그·티커가 한 번만 늘어난다', async () => {
await seed();
const first = await defendEclipse({
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'same', shakes: 30,
});
const again = await defendEclipse({
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'same', shakes: 30,
});
expect(first.duplicate).toBe(false);
expect(again.duplicate).toBe(true);
expect(again.accepted).toBe(first.accepted);
// 여기가 검수 지적의 자리다 — 예전에는 60/2/2 가 되었다.
expect(await shardSum()).toEqual({ defense: 30, calls: 1 });
expect(await logCount()).toBe(1);
expect(await tickerCount()).toBe(1);
// 참여자 누적도 한 번만 오른다.
const me = await getFirestore()
.doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}/participants/${A}`).get();
expect(me.get('validShakes')).toBe(30);
expect(me.get('calls')).toBe(1);
});
it('같은 키를 동시에 다섯 번 보내도 한 번만 쌓인다', async () => {
await seed();
// 경합이 문서 ID 충돌로 판정되는지 본다. 조회 후 쓰기라면 여기서 여러 번 쌓인다.
const results = await Promise.allSettled(
Array.from({ length: 5 }, () => defendEclipse({
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'race', shakes: 10,
})),
);
expect(results.some((r) => r.status === 'fulfilled')).toBe(true);
expect(await shardSum()).toEqual({ defense: 10, calls: 1 });
expect(await logCount()).toBe(1);
expect(await tickerCount()).toBe(1);
});
});
describe('서버 검증 — 단말 값을 믿지 않는다 (별첨 1 ②)', () => {
it('한 호출 상한을 넘긴 흔들기 수를 깎는다', async () => {
await seed({ perCallMaxShakes: 10 });
const result = await defendEclipse({
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 's1', shakes: 9_999,
});
expect(result.accepted).toBe(10);
expect(result.clamped).toBe(9_989);
expect((await shardSum()).defense).toBe(10);
});
it('사용자당 상한에 닿으면 더 쌓이지 않는다', async () => {
await seed({ perCallMaxShakes: 50, perUserMaxDefense: 60 });
await defendEclipse({
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 's1', shakes: 50,
});
const second = await defendEclipse({
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 's2', shakes: 50,
});
// 남은 10 만 받는다 — 통째로 버리면 정상 참여의 마지막 묶음이 사라진다.
expect(second.accepted).toBe(10);
expect(second.clamped).toBe(40);
const third = await defendEclipse({
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 's3', shakes: 10,
});
expect(third.accepted).toBe(0);
expect((await shardSum()).defense).toBe(60);
// 받아들인 것이 없으면 티커에도 올리지 않는다 — 빈 참여가 흐르면 안 된다.
expect(await tickerCount()).toBe(2);
});
it('속도 제한보다 빠른 연속 호출을 끊는다', async () => {
await seed({ minIntervalMs: 60_000 });
await defendEclipse({
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 's1', shakes: 5,
});
await expect(defendEclipse({
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 's2', shakes: 5,
})).rejects.toMatchObject({ details: { code: 'E-QST-02' } });
expect((await shardSum()).defense).toBe(5);
});
it('다른 사용자는 서로의 속도 제한에 걸리지 않는다', async () => {
await seed({ minIntervalMs: 60_000 });
await defendEclipse({
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'a1', shakes: 5,
});
await defendEclipse({
uid: B, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'b1', shakes: 5,
});
expect((await shardSum()).defense).toBe(10);
});
});
describe('티커 이벤트 — Global Ticker 모양 (별첨 B)', () => {
it('참여마다 종류·닉네임·참여량·우선순위·이동처를 남긴다', async () => {
await seed();
await defendEclipse({
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 't1', shakes: 7,
});
const events = await getFirestore()
.collection(`fandoms/${FANDOM}/liveEvents`).get();
expect(events.size).toBe(1);
const event = events.docs[0]!;
// 다른 종류(Artist Hot Time · Hero Gift · Campaign · 공지)가 뒤에 들어올 자리다.
expect(event.get('kind')).toBe('eclipse_defense');
expect(event.get('nickname')).toBe('아미보이');
expect(event.get('defense')).toBe(7);
expect(event.get('eclipseId')).toBe(ECLIPSE);
expect(typeof event.get('priority')).toBe('number');
expect(event.get('linkTo')).toBe(`/eclipse/${FANDOM}/${ECLIPSE}`);
// 티커가 최신순으로 읽으므로 시각이 없으면 정렬 자체가 불가능하다.
expect(event.get('createdAt')).toBeTruthy();
// TTL 정책의 입력. 없으면 컬렉션이 무한히 쌓인다.
expect((event.get('expiresAt') as Timestamp).toMillis())
.toBeGreaterThan(Date.now());
});
it('닉네임이 없는 사용자도 문구가 비지 않는다', async () => {
await seed();
await getFirestore().doc(`users/${A}`).set({ nickname: '' }, { merge: true });
await defendEclipse({
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 't2', shakes: 1,
});
const events = await getFirestore()
.collection(`fandoms/${FANDOM}/liveEvents`).get();
expect(events.docs[0]!.get('nickname')).toBe('팬');
});
});
describe('성공 판정 — 목표에 닿은 즉시 (검수 D2)', () => {
it('한 사람의 추가 Shake는 목표 두 명을 채울 수 없고 두 번째 60회에서만 성공한다', async () => {
await seed({ target: 2, perCallMaxShakes: 60 });
const ref = getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`);
const send = (uid: string, clientSeq: string, shakes: number) => defendEclipse({
uid, clientSeq, shakes, fandomId: FANDOM, eclipseId: ECLIPSE,
});
await send(A, 'one', 60);
await send(A, 'extra', 60);
await send(B, 'under', 59);
await aggregateActiveEclipses();
expect((await ref.get()).data()).toMatchObject({
qualifiedParticipants: 1, defended: false, erosionRate: 50,
});
await send(B, 'two', 1);
const result = await ref.get();
expect(result.get('defended')).toBe(true);
expect(result.get('qualifiedParticipants')).toBe(2);
const at = result.get('defendedAt');
await aggregateActiveEclipses();
expect((await ref.get()).get('defendedAt')).toEqual(at);
});
it('인원 목표를 판정할 수 없으면 옛 Shake 목표로 성공시키지 않고 취소한다', async () => {
await seed();
const ref = getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`);
await ref.update({ target: FieldValue.delete(), targetPop: 1 });
await aggregateActiveEclipses();
expect((await ref.get()).data()).toMatchObject({
status: 'cancelled', result: 'cancelled_error', defended: false,
});
});
it('집계를 기다리지 않고 방어 호출이 defended 를 세운다', async () => {
await seed({ target: 1, perCallMaxShakes: 60 });
await defendEclipse({
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'w1', shakes: 60,
});
// 집계(aggregateActiveEclipses)를 한 번도 돌리지 않았다.
const snap = await getFirestore()
.doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`).get();
expect(snap.get('defended')).toBe(true);
expect(snap.get('erosionRate')).toBe(0);
// 활성 질의에서 빠지면 화면이 성공한 순간을 놓친다 — status 는 그대로다.
expect(snap.get('status')).toBe('active');
});
it('성공한 이클립스는 더 받지 않는다 (E-ECL-01)', async () => {
await seed({ target: 1, perCallMaxShakes: 60 });
await defendEclipse({
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'w1', shakes: 60,
});
await expect(defendEclipse({
uid: B, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'w2', shakes: 5,
})).rejects.toMatchObject({ details: { code: 'E-ECL-01' } });
expect((await shardSum()).defense).toBe(60);
});
it('종료된 이클립스는 참여를 받지 않는다 (E-ECL-01)', async () => {
await seed();
await getFirestore()
.doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`)
.set({ status: 'ended' }, { merge: true });
await expect(defendEclipse({
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'c1', shakes: 1,
})).rejects.toMatchObject({ details: { code: 'E-ECL-01' } });
});
it('마감이 지난 이클립스는 참여를 받지 않는다 (E-ECL-01)', async () => {
await seed();
await getFirestore()
.doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`)
.set({ endsAt: Timestamp.fromMillis(Date.now() - 1_000) }, { merge: true });
await expect(defendEclipse({
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'c2', shakes: 1,
})).rejects.toMatchObject({ details: { code: 'E-ECL-01' } });
});
});
describe('v1.5 유효 참여의 원자적 기록', () => {
it('59회는 미인정, 60회는 한 명이며 추가 참여·동시 요청·재전송도 중복 인정하지 않는다', async () => {
await seed();
const ref = getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`);
const send = (uid: string, clientSeq: string, shakes: number) => defendEclipse({
uid, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq, shakes,
});
const qualifiedCount = async () => (await ref.collection('shards').get()).docs
.reduce((sum, doc) => sum + (doc.get('qualifiedParticipants') ?? 0), 0);
await send(A, 'q50', 50);
await send(A, 'q59', 9);
expect(await qualifiedCount()).toBe(0);
expect((await ref.collection('participants').doc(A).get()).get('qualified')).toBeUndefined();
await Promise.all([send(A, 'q60', 1), send(A, 'q60', 1)]);
const qualifiedAt = (await ref.collection('participants').doc(A).get()).get('qualifiedAt');
expect(qualifiedAt).toBeInstanceOf(Timestamp);
expect(await qualifiedCount()).toBe(1);
// 120회를 흔들어도 1명이다(C-25).
await Promise.all([send(A, 'extra1', 20), send(A, 'extra2', 20), send(A, 'extra3', 20)]);
await send(B, 'b50', 50);
await send(B, 'b60', 10);
expect(await qualifiedCount()).toBe(2);
expect((await ref.collection('participants').doc(A).get()).get('qualifiedAt')).toEqual(qualifiedAt);
expect((await ref.collection('participants').doc(A).get()).get('validShakes')).toBe(120);
expect((await send(A, 'q60', 1)).myTotal).toBe(60);
await aggregateActiveEclipses();
expect((await ref.get()).get('qualifiedParticipants')).toBe(2);
});
it('마지막 티커 저장이 실패하면 로그·개인 누적·샤드까지 취소하고 같은 요청으로 복구한다', async () => {
await seed();
const db = getFirestore();
const ref = db.doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`);
const input = { uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'atomic', shakes: 30 };
const original = Transaction.prototype.create;
let calls = 0;
const spy = vi.spyOn(Transaction.prototype, 'create').mockImplementation(function (this: Transaction, ...args) {
if (++calls === 2) throw new Error('ticker write failed');
return original.apply(this, args);
});
try {
await expect(defendEclipse(input)).rejects.toThrow('ticker write failed');
} finally { spy.mockRestore(); }
for (const sub of ['defenseLogs', 'participants', 'shards']) {
expect((await ref.collection(sub).get()).size).toBe(0);
}
expect((await db.collection(`fandoms/${FANDOM}/liveEvents`).get()).size).toBe(0);
expect((await defendEclipse(input)).duplicate).toBe(false);
expect(await shardSum()).toEqual({ defense: 30, calls: 1 });
expect((await db.collection(`fandoms/${FANDOM}/liveEvents`).get()).size).toBe(1);
});
it('회차가 닫혀도 확정된 요청은 재확인할 수 있고 신규 요청은 거절한다', async () => {
await seed();
const input = { uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'lost-response', shakes: 10 };
await defendEclipse(input);
const ref = getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`);
await ref.update({ status: 'ended', endsAt: Timestamp.fromMillis(0) });
expect(await defendEclipse(input)).toEqual({ accepted: 10, clamped: 0, myTotal: 10, duplicate: true });
await expect(defendEclipse({ ...input, clientSeq: 'late' })).rejects.toMatchObject({ details: { code: 'E-ECL-01' } });
expect(await shardSum()).toEqual({ defense: 10, calls: 1 });
});
});
describe('개인 인정 횟수는 운영값이다 (원장 C-25)', () => {
it('회차에 고정된 인정 횟수로 판정하고 운영값 문서만 바꿔서는 진행 중 회차 기준이 바뀌지 않는다', async () => {
await seed({ target: 1, perCallMaxShakes: 60, qualifyShakes: 60 });
const ref = getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`);
await ref.update({ qualifyShakes: 30 });
await getFirestore().doc('config/eclipse').set({ qualifyShakes: 90 }, { merge: true });
await defendEclipse({ uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'q29', shakes: 29 });
expect((await ref.get()).get('defended')).toBe(false);
await defendEclipse({ uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'q30', shakes: 1 });
expect((await ref.get()).get('defended')).toBe(true);
});
});
@@ -0,0 +1,48 @@
import { afterAll, beforeAll, expect, it } from 'vitest';
import { getFirestore, Timestamp } from 'firebase-admin/firestore';
import { eclipseTarget } from '../src/fandom/eclipse_target.js';
import { bootAdmin, shutdownAdmin } from './support.js';
beforeAll(bootAdmin);
afterAll(async () => {
await getFirestore().recursiveDelete(getFirestore().doc('fandoms/test-eclipse-target'));
await shutdownAdmin();
});
it('생성일·0명·누락·최근 7일·목표율을 구분해 고유 참여자 목표를 산출한다', async () => {
const ref = getFirestore().doc('fandoms/test-eclipse-target');
const created = (date: string) => Timestamp.fromDate(new Date(`${date}T00:00:00+09:00`));
const stats = ref.collection('fandomDailyStats');
expect(await eclipseTarget(ref, created('2026-09-22'), '2026-09-22')).toEqual({
target: 1, averageDau: null, dauDays: [],
});
expect(await eclipseTarget(ref, undefined, '2026-09-22')).toMatchObject({ result: 'cancelled_error' });
expect(await eclipseTarget(ref, created('2026-09-23'), '2026-09-22')).toMatchObject({ result: 'cancelled_error' });
expect(await eclipseTarget(ref, created('2026-09-21'), '2026-09-22')).toMatchObject({ result: 'cancelled_error' });
await stats.doc('2026-09-21').set({ dau: 0, active30: 10000 });
expect(await eclipseTarget(ref, created('2026-09-21'), '2026-09-22')).toMatchObject({ target: 1, averageDau: 0 });
await stats.doc('2026-09-20').set({ dau: 30 });
await stats.doc('2026-09-19').set({ dau: 60 });
expect(await eclipseTarget(ref, created('2026-09-19'), '2026-09-22')).toEqual({
target: 2, averageDau: 30, dauDays: ['2026-09-21', '2026-09-20', '2026-09-19'],
});
expect(await eclipseTarget(ref, created('2026-09-19'), '2026-09-22', 0.1)).toMatchObject({ target: 3 });
for (const rate of [0, -1, Number.NaN, Number.POSITIVE_INFINITY, 1.1]) {
expect(await eclipseTarget(ref, created('2026-09-19'), '2026-09-22', rate)).toMatchObject({ result: 'cancelled_error' });
}
for (let d = 15; d <= 21; d++) await stats.doc(`2026-09-${d}`).set({ dau: 30 });
await stats.doc('2026-09-14').set({ dau: 100000 });
await stats.doc('2026-09-22').set({ dau: 100000 });
expect(await eclipseTarget(ref, created('2026-09-01'), '2026-09-22')).toMatchObject({ target: 2, averageDau: 30 });
// C-21 집계 기간은 운영값 — 8일로 늘리면 14일의 값이 분모에 들어온다.
expect(await eclipseTarget(ref, created('2026-09-01'), '2026-09-22', 0.05, 8)).toMatchObject({
target: Math.ceil((30 * 7 + 100000) / 8 * 0.05), averageDau: (30 * 7 + 100000) / 8,
});
expect(await eclipseTarget(ref, created('2026-09-01'), '2026-09-22', 0.05, 0)).toMatchObject({ result: 'cancelled_error' });
for (const invalid of [-1, 0.5, '30', null]) {
await stats.doc('2026-09-20').set({ dau: invalid });
expect(await eclipseTarget(ref, created('2026-09-01'), '2026-09-22')).toMatchObject({ result: 'cancelled_error' });
}
await stats.doc('2026-09-20').delete();
expect(await eclipseTarget(ref, created('2026-09-01'), '2026-09-22')).toMatchObject({ result: 'cancelled_error' });
});
@@ -0,0 +1,476 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { getFirestore, FieldValue, Timestamp, Transaction } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
import { grantPop } from '../src/ledger/ledger.js';
import {
aggregateActiveEclipses,
defendEclipse,
runEclipseSchedule,
fnFpiAggregate,
} from '../src/fandom/fandom.js';
/** v1.5 §5: 운영 일정, DAU 오류 취소, 시작 스냅샷, 실패 확정 차감과 경합을 검증한다. */
const FANDOM = 'test-trig-fandom';
const A = 'test-trig-user-a';
/** 침식률 — 수치표 v1.4 「침식 0.25% FPI」. */
const EROSION_PERCENT = 0.25;
/**
* 방어 목표(팬덤 공동 Shake 누적). **POP 이 아니다** — 방어는 POP 을 소모하지 않는다
* (설계서 v1.1 §9-#5). 한 번의 참여로 채울 수 있게 작게 잡았다.
*/
const TARGET = 60;
/** 축별 기준 FPI. 0.25% 가 정수로 떨어지도록 400의 배수로 잡았다. */
const BASE_AXES: Record<string, number> = {
bpm: 400_000,
health: 200_000,
referral: 80_000,
community: 40_000,
contribution: 120_000,
};
const AXES = Object.keys(BASE_AXES);
/** 기준 FPI 총합. 침식 전후를 이 값으로 견준다. */
const BASE_TOTAL = Object.values(BASE_AXES).reduce((a, b) => a + b, 0);
/** KST 시각을 절대 시각으로. 발동 시각 판정이 KST 기준이다. */
const kst = (day: string, hhmm: string) => new Date(`${day}T${hhmm}:00+09:00`);
const DAY = '2026-09-20';
const FIRST = '13:00';
const SECOND = '21:00';
const WARN_SECONDS = 300;
beforeAll(() => {
// 발동과 방어가 같은 시각을 보도록 Date만 고정한다. Firestore 타이머는 실제로 돈다.
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(kst(DAY, '13:10'));
bootAdmin();
});
// **끝나고도 치운다.** 남겨 두면 다음 파일의 전역 질의(`fn-fpi-aggregate` ·
// `fn-eclipse-trigger` 는 팬덤 전수를 돈다)가 이 팬덤까지 집어 값이 흔들린다.
afterAll(async () => {
await wipeUser(A);
await wipeFandom();
await shutdownAdmin();
vi.useRealTimers();
});
async function wipeFandom(): Promise<void> {
const db = getFirestore();
const root = db.doc(`fandoms/${FANDOM}`);
const eclipses = await root.collection('eclipseRounds').get();
for (const doc of eclipses.docs) {
for (const sub of ['shards', 'defenseLogs', 'participants']) {
const snap = await doc.ref.collection(sub).get();
await Promise.all(snap.docs.map((d) => d.ref.delete()));
}
await doc.ref.delete();
}
for (const sub of ['fpiEvents', 'liveEvents', 'members', 'fandomDailyStats']) {
const snap = await root.collection(sub).get();
await Promise.all(snap.docs.map((d) => d.ref.delete()));
}
await root.delete();
await db.doc('config/eclipse').delete();
}
/**
* 팬덤 하나에 FPI 원장을 세운다.
*
* **`fpiAxes` 를 직접 쓰지 않고 이벤트로 만든다.** 집계가 이벤트에서 요약을 다시
* 만들므로, 요약만 심어 두면 첫 집계에서 전부 0 이 되어 침식할 것이 없어진다.
*/
async function seedFandom(overrides: Record<string, unknown> = {}): Promise<void> {
const db = getFirestore();
await db.doc('config/eclipse').set({
enabled: true,
schedule: [{ hhmm: FIRST }, { hhmm: SECOND }],
erosionPercent: EROSION_PERCENT,
warnSeconds: WARN_SECONDS,
perCallMaxShakes: 60,
windowSec: 1800,
// 같은 사용자로 연속 참여해 보므로 속도 제한을 끈다. 제한 자체는
// eclipse.test.ts 의 전용 테스트가 본다.
minIntervalMs: 0,
...overrides,
});
await db.doc(`fandoms/${FANDOM}`).set({
name: 'BTS', stage: 1, fandomName: 'BTS',
createdAt: Timestamp.fromDate(kst(DAY, '00:00')),
});
// 활동 이벤트는 발동 시각보다 앞서고 7 일 창 안이다.
const occurredAt = Timestamp.fromDate(kst(DAY, '09:00'));
for (const axis of AXES) {
await db.doc(`fandoms/${FANDOM}/fpiEvents/seed-${axis}`).set({
axis, value: BASE_AXES[axis], verified: true, occurredAt,
reasonCode: 'SEED',
});
}
await db.doc(`users/${A}`).set({ uid: A, nickname: '아미보이', fandomId: FANDOM });
await grantPop({
uid: A, popType: 'activity', amount: 10_000,
reasonCode: 'ADMIN_GRANT', idempotencyKey: `g:trig:${A}`,
});
}
/** 집계를 실제로 한 번 돌린다. 스케줄 래퍼를 직접 호출한다. */
const aggregateFpi = () =>
(fnFpiAggregate.run as (e: unknown) => Promise<void>)({});
/** 가중치와 실패 차감까지 반영한 현재 FPI 총점. */
async function currentFpiTotal(): Promise<number> {
const snap = await getFirestore().doc(`fandoms/${FANDOM}`).get();
return snap.get('fpiScore') as number;
}
/** 사유 코드별 FPI 이벤트 수. 「감소 경로가 Eclipse 뿐인가」의 확인 수단이다. */
async function fpiEventsByReason(): Promise<Record<string, number>> {
const snap = await getFirestore()
.collection(`fandoms/${FANDOM}/fpiEvents`).get();
const out: Record<string, number> = {};
for (const doc of snap.docs) {
const reason = (doc.get('reasonCode') as string | undefined) ?? '(없음)';
out[reason] = (out[reason] ?? 0) + 1;
}
return out;
}
beforeEach(async () => {
await wipeUser(A);
await wipeFandom();
});
describe('발동 — 하루 정확히 2 회 (원장 B-55)', () => {
it('DAU 누락 회차는 취소 사유를 남기고 활성화·FPI 차감 없이 종료한다', async () => {
await seedFandom();
await aggregateFpi();
const db = getFirestore();
await db.doc(`fandoms/${FANDOM}`).update({ createdAt: Timestamp.fromDate(kst('2026-09-19', '00:00')) });
await runEclipseSchedule(kst(DAY, FIRST));
const ref = db.doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-1`);
expect((await ref.get()).data()).toMatchObject({
status: 'cancelled', result: 'cancelled_error', cancelReason: 'MISSING_OR_INVALID_DAU',
});
expect((await ref.get()).get('activatedAt')).toBeUndefined();
expect((await fpiEventsByReason())['ECLIPSE_EROSION'] ?? 0).toBe(0);
expect(await currentFpiTotal()).toBe(BASE_TOTAL);
await runEclipseSchedule(kst(DAY, FIRST));
expect((await ref.get()).get('status')).toBe('cancelled');
await db.doc(`fandoms/${FANDOM}/fandomDailyStats/2026-09-19`).set({ dau: 30 });
await runEclipseSchedule(kst(DAY, FIRST));
expect((await ref.get()).get('status')).toBe('cancelled');
await runEclipseSchedule(kst(DAY, SECOND));
expect((await db.doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-2`).get()).data()).toMatchObject({
status: 'active', target: 2, averageDau: 30,
});
});
it('v1.5 기본값으로 07:47/17:47 예고하고 07:50/17:50부터 60초 방어한다', async () => {
const db = getFirestore();
await db.doc('config/eclipse').set({ enabled: true });
await db.doc(`fandoms/${FANDOM}`).set({ name: '기본 운영값 검증', fpiScore: 0, createdAt: Timestamp.fromDate(kst(DAY, '00:00')) });
for (const [round, warning, start] of [[1, '07:47', '07:50'], [2, '17:47', '17:50']] as const) {
const ref = db.doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-${round}`);
await runEclipseSchedule(new Date(kst(DAY, warning).getTime() - 1));
expect((await ref.get()).exists).toBe(false);
await runEclipseSchedule(kst(DAY, warning));
const scheduled = await ref.get();
expect(scheduled.get('status')).toBe('scheduled');
expect(scheduled.get('target')).toBe(1);
expect(scheduled.get('dauDays')).toEqual([]);
expect(scheduled.get('warnAt').toMillis()).toBe(kst(DAY, warning).getTime());
expect(scheduled.get('startAt').toMillis()).toBe(kst(DAY, start).getTime());
expect(scheduled.get('endsAt').toMillis() - scheduled.get('startAt').toMillis()).toBe(60_000);
expect(scheduled.get('windowSec')).toBe(60);
expect(scheduled.get('qualifyShakes')).toBe(60);
// C-22 「푸시 발송 시각 = 시작 − 3분」 — 예고 판정 시각이 회차 문서에 남는다.
expect(scheduled.get('alertPushedAt').toMillis()).toBe(kst(DAY, warning).getTime());
await runEclipseSchedule(kst(DAY, start));
expect((await ref.get()).get('status')).toBe('active');
await runEclipseSchedule(new Date(kst(DAY, start).getTime() + 60_000));
expect((await ref.get()).get('status')).toBe('ended');
}
});
it('사전 알림 시각에 예약되고 발동 시각에 활성이 된다', async () => {
await seedFandom();
await aggregateFpi();
const db = getFirestore();
const ref = db.doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-1`);
// **반환 카운터를 단정하지 않는다.** `runEclipseSchedule` 은 모든 팬덤을 돌아
// 전역 건수를 돌려주므로, 다른 테스트가 남긴 팬덤이 있으면 값이 흔들린다.
// 이 테스트가 볼 것은 「이 팬덤의 이 회차 문서가 어떤 상태인가」다.
// 사전 알림 전에는 문서가 없다.
await runEclipseSchedule(kst(DAY, '12:50'));
expect((await ref.get()).exists).toBe(false);
// 사전 알림 시각(발동 300초 전 = 12:55)에 예약된다.
await runEclipseSchedule(kst(DAY, '12:56'));
let snap = await ref.get();
expect(snap.get('status')).toBe('scheduled');
// 푸시 워커가 읽을 사전 알림 시각. 예약 시점보다 앞이면 예고가 불가능하다.
expect((snap.get('warnAt') as Timestamp).toMillis())
.toBe(kst(DAY, '12:55').getTime());
expect((snap.get('startAt') as Timestamp).toMillis())
.toBe(kst(DAY, FIRST).getTime());
// 발동 시각에 활성이 된다.
await runEclipseSchedule(kst(DAY, '13:00'));
snap = await ref.get();
expect(snap.get('status')).toBe('active');
});
it('하루에 1차·2차 딱 둘만 만든다', async () => {
await seedFandom();
await aggregateFpi();
// 하루를 1분 간격이 아니라 구간별로 훑는다.
for (const at of ['00:10', '12:56', '13:00', '13:20', '20:56', '21:00', '23:50']) {
await runEclipseSchedule(kst(DAY, at));
}
const eclipses = await getFirestore()
.collection(`fandoms/${FANDOM}/eclipseRounds`).get();
expect(eclipses.size).toBe(2);
expect(eclipses.docs.map((d) => d.id).sort()).toEqual([`${DAY}-1`, `${DAY}-2`]);
expect(eclipses.docs.map((d) => d.get('round')).sort()).toEqual([1, 2]);
});
it('같은 회차를 여러 번 돌려도 중복 발동 0 건이다', async () => {
await seedFandom();
await aggregateFpi();
// 같은 분을 열 번 돌린다. 조회 후 쓰기로 막았다면 여기서 새 문서가 생긴다.
for (let i = 0; i < 10; i++) await runEclipseSchedule(kst(DAY, '13:05'));
// 동시에도 돌려 본다 — 스케줄 인스턴스가 겹치는 상황이다.
await Promise.all(
Array.from({ length: 5 }, () => runEclipseSchedule(kst(DAY, '13:06'))),
);
const eclipses = await getFirestore()
.collection(`fandoms/${FANDOM}/eclipseRounds`).get();
expect(eclipses.size).toBe(1);
expect(eclipses.docs[0]!.get('status')).toBe('active');
// 시작 시점에는 차감 이벤트가 없어야 한다.
const events = await getFirestore()
.collection(`fandoms/${FANDOM}/fpiEvents`).get();
const erosions = events.docs
.filter((d) => d.get('reasonCode') === 'ECLIPSE_EROSION');
expect(erosions).toHaveLength(0);
});
it('활성화가 꺼진 팬덤은 발동하지 않는다', async () => {
await seedFandom({ enabled: false });
await aggregateFpi();
await runEclipseSchedule(kst(DAY, '13:05'));
const eclipses = await getFirestore()
.collection(`fandoms/${FANDOM}/eclipseRounds`).get();
expect(eclipses.size).toBe(0);
});
it('발동 시각·침식률을 운영값으로 바꾸면 재배포 없이 따라간다', async () => {
// 원장 B-55 검증 기준 「방어 수치가 Admin 에서 앱 재배포 없이 변경됨」.
await seedFandom({ schedule: [{ hhmm: '06:30' }], erosionPercent: 1, warnSeconds: 60 });
await aggregateFpi();
await runEclipseSchedule(kst(DAY, '06:20'));
expect((await getFirestore()
.collection(`fandoms/${FANDOM}/eclipseRounds`).get()).size).toBe(0);
await runEclipseSchedule(kst(DAY, '06:31'));
const snap = await getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-1`).get();
expect(snap.get('status')).toBe('active');
expect(snap.get('erosionPercent')).toBe(1);
expect(snap.get('fpiAtStart')).toBe(BASE_TOTAL);
expect(snap.get('erodedTotal')).toBeUndefined();
expect(await currentFpiTotal()).toBe(BASE_TOTAL);
});
it('v0.3.0 팬덤별 firstAt 설정도 하위호환으로 읽는다', async () => {
await seedFandom();
await aggregateFpi();
await getFirestore().doc(`fandoms/${FANDOM}`)
.set({ eclipseSettings: { firstAt: '08:00' } }, { merge: true });
await runEclipseSchedule(kst(DAY, '08:01'));
const snap = await getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-1`).get();
expect((snap.get('startAt') as Timestamp).toMillis())
.toBe(kst(DAY, '08:00').getTime());
});
it('방어 창이 지나면 닫는다', async () => {
await seedFandom();
await aggregateFpi();
await runEclipseSchedule(kst(DAY, '13:01'));
// 창은 30분이다.
await runEclipseSchedule(kst(DAY, '13:20'));
expect((await getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-1`).get())
.get('status')).toBe('active');
await runEclipseSchedule(kst(DAY, '13:31'));
// 검수 D3 — 마감이 지나면 `ended` 로 닫아 배너 질의에서 빠진다.
expect((await getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-1`).get())
.get('status')).toBe('ended');
});
});
describe('일정 — 배열 길이 = 하루 회차 수 · 팬덤별 우선 (원장 C-24)', () => {
const ids = async () => (await getFirestore().collection(`fandoms/${FANDOM}/eclipseRounds`).get())
.docs.map((d) => [d.id, (d.get('startAt') as Timestamp).toMillis()]).sort();
it('공통 일정 3회를 설정하면 하루 3회차를 만든다', async () => {
await seedFandom({ schedule: [{ hhmm: '07:50' }, { hhmm: '12:00' }, { hhmm: '17:50' }] });
await aggregateFpi();
for (const at of ['07:50', '12:00', '17:50', '23:59']) await runEclipseSchedule(kst(DAY, at));
expect(await ids()).toEqual([
[`${DAY}-1`, kst(DAY, '07:50').getTime()],
[`${DAY}-2`, kst(DAY, '12:00').getTime()],
[`${DAY}-3`, kst(DAY, '17:50').getTime()],
]);
});
it('팬덤별 eclipseSchedule 이 있으면 공통 일정 대신 그것을 쓰고 푸시·종료를 다시 계산한다', async () => {
await seedFandom({ warnSeconds: 180, windowSec: 60 });
await aggregateFpi();
await getFirestore().doc(`fandoms/${FANDOM}`)
.set({ eclipseSchedule: [{ hhmm: '09:10', tz: 'Asia/Seoul' }] }, { merge: true });
for (const at of ['09:07', '09:10', '13:00', '21:00', '23:59']) await runEclipseSchedule(kst(DAY, at));
expect(await ids()).toEqual([[`${DAY}-1`, kst(DAY, '09:10').getTime()]]);
const snap = await getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-1`).get();
expect(snap.get('warnAt').toMillis()).toBe(kst(DAY, '09:07').getTime());
expect(snap.get('alertPushedAt').toMillis()).toBe(kst(DAY, '09:07').getTime());
expect(snap.get('endsAt').toMillis()).toBe(kst(DAY, '09:10').getTime() + 60_000);
});
});
describe('v1.5 실패 확정 시에만 시작 FPI 기준으로 1회 차감', () => {
const round = () => getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-1`);
async function start() {
await seedFandom();
await aggregateFpi();
await getFirestore().doc(`fandoms/${FANDOM}`).update({ sfpi: 12345 });
await runEclipseSchedule(kst(DAY, '13:00'));
}
const penalty = BASE_TOTAL * EROSION_PERCENT / 100;
it('시작과 방어 중에는 차감·복구 이벤트 없이 시작값만 보관한다', async () => {
await start();
expect((await round().get()).get('fpiAtStart')).toBe(BASE_TOTAL);
await aggregateFpi();
expect(await currentFpiTotal()).toBe(BASE_TOTAL);
expect(await fpiEventsByReason()).toEqual({ SEED: 5 });
});
it('성공은 마감 후에도 무차감이며 재집계가 보너스 FPI를 만들지 않는다', async () => {
await start();
await defendEclipse({ uid: A, fandomId: FANDOM, eclipseId: `${DAY}-1`, clientSeq: 'win', shakes: TARGET });
await runEclipseSchedule(kst(DAY, '13:30'));
await aggregateActiveEclipses();
await aggregateFpi();
expect((await round().get()).data()).toMatchObject({ result: 'success', appliedAt: null });
expect(await currentFpiTotal()).toBe(BASE_TOTAL);
expect(await fpiEventsByReason()).toEqual({ SEED: 5 });
});
it('새 활동을 보존하고 마감 경계부터 실패 1건만 차감하며 과거 sFPI는 그대로다', async () => {
await start();
const db = getFirestore();
await db.doc(`fandoms/${FANDOM}/fpiEvents/new-activity`).set({
axis: 'bpm', value: 1000000, verified: true, reasonCode: 'SEED',
occurredAt: Timestamp.fromDate(kst(DAY, '13:05')),
});
await aggregateFpi();
await runEclipseSchedule(new Date(kst(DAY, '13:30').getTime() - 1));
expect(await currentFpiTotal()).toBe(BASE_TOTAL + 1000000);
await Promise.all([runEclipseSchedule(kst(DAY, '13:30')), runEclipseSchedule(kst(DAY, '13:30'))]);
expect((await round().get()).data()).toMatchObject({ result: 'failed', penalty });
expect((await round().get()).get('appliedAt').toMillis()).toBe(kst(DAY, '13:30').getTime());
// 이미 확정된 회차를 다시 돌려도 차감은 1회다(G-02).
await runEclipseSchedule(kst(DAY, '13:31'));
expect(await currentFpiTotal()).toBe(BASE_TOTAL + 1000000 - penalty);
await aggregateFpi();
await aggregateFpi();
expect(await currentFpiTotal()).toBe(BASE_TOTAL + 1000000 - penalty);
expect((await db.doc(`fandoms/${FANDOM}`).get()).get('sfpi')).toBe(12345);
expect((await fpiEventsByReason())['ECLIPSE_EROSION']).toBe(1);
const event = await db.doc(`fandoms/${FANDOM}/fpiEvents/eclipse-${DAY}-1-penalty`).get();
expect(event.get('occurredAt').toMillis()).toBe(kst(DAY, '13:30').getTime());
expect(event.get('value')).toBe(-penalty);
});
it('차감 기록 저장 실패는 회차 결과와 FPI를 함께 취소하며 재시도로 복구한다', async () => {
await start();
const spy = vi.spyOn(Transaction.prototype, 'create').mockImplementationOnce(() => { throw new Error('penalty write failed'); });
try {
await expect(runEclipseSchedule(kst(DAY, '13:30'))).rejects.toThrow('penalty write failed');
} finally { spy.mockRestore(); }
expect((await round().get()).get('status')).toBe('active');
expect(await currentFpiTotal()).toBe(BASE_TOTAL);
expect((await fpiEventsByReason())['ECLIPSE_EROSION']).toBeUndefined();
await runEclipseSchedule(kst(DAY, '13:30'));
expect(await currentFpiTotal()).toBe(BASE_TOTAL - penalty);
expect((await round().get()).get('result')).toBe('failed');
});
it('결과 집계가 오염되면 실패로 처리하지 않고 무차감 취소한다', async () => {
await start();
await round().collection('shards').doc('invalid').set({ qualifiedParticipants: '1' });
await runEclipseSchedule(kst(DAY, '13:30'));
expect((await round().get()).data()).toMatchObject({ result: 'cancelled_error', status: 'cancelled' });
expect(await currentFpiTotal()).toBe(BASE_TOTAL);
expect(await fpiEventsByReason()).toEqual({ SEED: 5 });
});
it('축별 가중치와 소수 차감에도 시작 FPI의 정확한 비율을 유지한다', async () => {
const db = getFirestore();
const config = db.doc('config/policy');
const original = (await config.get()).data();
try {
await config.set({ fpiWeights: { bpm: 2, health: 0.5, referral: 1, community: 1, contribution: 1 } }, { merge: true });
await seedFandom();
await db.doc(`fandoms/${FANDOM}/fpiEvents/seed-bpm`).update({ value: BASE_AXES.bpm! + 1 });
await aggregateFpi();
const startFpi = await currentFpiTotal();
expect(startFpi).toBe(1140002);
await runEclipseSchedule(kst(DAY, '13:00'));
await runEclipseSchedule(kst(DAY, '13:30'));
const loss = startFpi * 0.0025;
expect((await round().get()).get('penalty')).toBe(loss);
expect(await currentFpiTotal()).toBeCloseTo(startFpi - loss, 8);
await aggregateFpi();
expect(await currentFpiTotal()).toBeCloseTo(startFpi - loss, 8);
} finally {
if (original) await config.set(original);
else await config.delete();
}
});
it('마감 전 읽은 FPI 집계가 뒤늦게 저장돼도 확정 차감을 덮어쓰지 않는다', async () => {
await start();
const original = Transaction.prototype.get;
let closed = false;
const spy = vi.spyOn(Transaction.prototype, 'get').mockImplementation(async function (this: Transaction, ...args) {
if (!closed && 'path' in args[0] && args[0].path === `fandoms/${FANDOM}`) {
closed = true;
await runEclipseSchedule(kst(DAY, '13:30'));
}
return original.apply(this, args);
});
try { await aggregateFpi(); } finally { spy.mockRestore(); }
expect(closed).toBe(true);
expect(await currentFpiTotal()).toBe(BASE_TOTAL - penalty);
await aggregateFpi();
expect(await currentFpiTotal()).toBe(BASE_TOTAL - penalty);
});
});
@@ -0,0 +1,56 @@
import { afterAll, beforeAll, expect, it, vi } from 'vitest';
import { DocumentReference, getFirestore } from 'firebase-admin/firestore';
import { fnPlanetEvolutionRecord } from '../src/fandom/evolution.js';
import { bootAdmin, shutdownAdmin } from './support.js';
const id = 'test-evolution-history';
beforeAll(() => { bootAdmin(); });
afterAll(async () => {
vi.restoreAllMocks();
await getFirestore().recursiveDelete(getFirestore().doc(`fandoms/${id}`));
await shutdownAdmin();
});
async function event(beforeStage: unknown, afterStage: unknown, eventId: string) {
const db = getFirestore();
const ref = db.doc(`fandoms/${id}`);
await ref.set({ ...(beforeStage === undefined ? {} : { stage: beforeStage }) });
const before = (await db.collection('fandoms').where('__name__', '==', id).get()).docs[0]!;
await ref.set({ stage: afterStage });
const after = (await db.collection('fandoms').where('__name__', '==', id).get()).docs[0]!;
return {
specversion: '1.0' as const, id: eventId, source: 'firestore',
type: 'google.cloud.firestore.document.v1.updated', time: new Date().toISOString(),
data: { before, after }, params: { fandomId: id },
location: 'asia-northeast3', project: 'demo-fanuzu', database: '(default)',
namespace: '', document: ref.path,
};
}
it('저장 실패를 재시도하고 동시 재전달에도 확정 진화를 한 번만 남긴다', async () => {
const input = await event(1, 3, 'confirmed/event');
const create = vi.spyOn(DocumentReference.prototype, 'create').mockRejectedValueOnce(new Error('write failed'));
try {
await expect(fnPlanetEvolutionRecord.run(input)).rejects.toThrow('write failed');
} finally {
create.mockRestore();
}
await Promise.all([fnPlanetEvolutionRecord.run(input), fnPlanetEvolutionRecord.run(input)]);
const records = await getFirestore().collection(`fandoms/${id}/stateHistory`).get();
expect(records.size).toBe(1);
const record = records.docs[0]!;
expect(record.get('kind')).toBe('evolution');
expect(record.get('fromStage')).toBe(1);
expect(record.get('toStage')).toBe(3);
expect(record.get('occurredAt').isEqual(input.data.after.updateTime)).toBe(true);
expect(record.get('participantIds')).toBeUndefined();
});
it('동일 Stage·하락·누락·범위 밖 수치를 진화로 기록하지 않는다', async () => {
const history = getFirestore().collection(`fandoms/${id}/stateHistory`);
const count = (await history.get()).size;
for (const [before, after] of [[3, 3], [3, 2], [undefined, 3], [-1, 1], [6, 7], [1, '2']]) {
await fnPlanetEvolutionRecord.run(await event(before, after, `skip-${before}-${after}`));
}
expect((await history.get()).size).toBe(count);
});
@@ -0,0 +1,212 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { getFirestore, FieldValue, Timestamp } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
import { distanceMeters, gradeShake } from '../src/quest/verify.js';
import { grantPop, refundPop, spendPop } from '../src/ledger/ledger.js';
/**
* 5~10주차 함수의 불변식 검증.
*
* 화면 없이도 확인할 수 있는 것만 본다 — 판정식, 중복 차단, 환불 무결성.
* 스케줄 함수의 전체 왕복은 배포 뒤 확인한다.
*/
const UID = 'test-ext-user';
const OTHER = 'test-ext-other';
beforeAll(() => { bootAdmin(); });
afterAll(async () => { await shutdownAdmin(); });
beforeEach(async () => {
await wipeUser(UID);
await wipeUser(OTHER);
});
describe('Music Shake 판정 (E-02)', () => {
it('판정표가 스스로 모순되면 실격이다', () => {
// perfect+good+miss 가 구간 수와 다르면 표가 조작되었거나 깨진 것이다.
expect(gradeShake({ perfect: 10, good: 0, miss: 0, maxCombo: 10 }, 20)).toBe('F');
});
it('빈틈없이 맞히면 S 다', () => {
expect(gradeShake({ perfect: 20, good: 0, miss: 0, maxCombo: 20 }, 20)).toBe('S');
});
it('한 번이라도 놓치면 S 가 아니다', () => {
expect(gradeShake({ perfect: 19, good: 0, miss: 1, maxCombo: 19 }, 20)).toBe('A');
});
it('절반도 못 맞히면 실격이다', () => {
expect(gradeShake({ perfect: 5, good: 2, miss: 13, maxCombo: 3 }, 20)).toBe('F');
});
it('구간이 0이면 실격이다', () => {
expect(gradeShake({ perfect: 0, good: 0, miss: 0, maxCombo: 0 }, 0)).toBe('F');
});
});
describe('방문 거리 (E-06)', () => {
it('같은 좌표는 0 이다', () => {
expect(distanceMeters(37.5, 127.0, 37.5, 127.0)).toBe(0);
});
it('서울 안 두 지점의 거리를 백 미터 단위로 맞춘다', () => {
// 강남역 ↔ 역삼역, 실측 약 1.1km.
const d = distanceMeters(37.4979, 127.0276, 37.5006, 127.0365);
expect(d).toBeGreaterThan(800);
expect(d).toBeLessThan(1400);
});
it('위도 1도는 약 111km 다', () => {
const d = distanceMeters(37.0, 127.0, 38.0, 127.0);
expect(d).toBeGreaterThan(110_000);
expect(d).toBeLessThan(112_000);
});
});
describe('현상금 — 예치·전송·환불 (별첨 1 ④)', () => {
it('예치·전송·환불이 모두 원장을 지나 잔액이 재현된다', async () => {
await grantPop({
uid: UID, popType: 'activity', amount: 1000,
reasonCode: 'ADMIN_GRANT', idempotencyKey: 'g:qna',
});
const deposit = await spendPop({
uid: UID, popType: 'activity', amount: 500,
reasonCode: 'QNA_BOUNTY', idempotencyKey: 'bounty:u:1',
});
expect(deposit.balanceAfter).toBe(500);
// 채택 — 답변자에게 지급.
const paid = await grantPop({
uid: OTHER, popType: 'activity', amount: 500,
reasonCode: 'QUEST_ATTEND', idempotencyKey: 'accept:q1',
});
expect(paid.balanceAfter).toBe(500);
const db = getFirestore();
const mine = await db.doc(`users/${UID}/wallet/main`).get();
const theirs = await db.doc(`users/${OTHER}/wallet/main`).get();
// 예치한 만큼 정확히 옮겨 갔다 — 만들어지거나 사라진 POP 이 없다.
expect(mine.get('activityBalance') + theirs.get('activityBalance')).toBe(1000);
});
it('기한 만료 환불은 한 번만 나간다', async () => {
await grantPop({
uid: UID, popType: 'activity', amount: 1000,
reasonCode: 'ADMIN_GRANT', idempotencyKey: 'g:qna2',
});
const deposit = await spendPop({
uid: UID, popType: 'activity', amount: 300,
reasonCode: 'QNA_BOUNTY', idempotencyKey: 'bounty:u:2',
});
await refundPop({
uid: UID, originalTxId: deposit.txId,
idempotencyKey: 'qnarefund:q2', reasonCode: 'QNA_REFUND',
});
await expect(refundPop({
uid: UID, originalTxId: deposit.txId,
idempotencyKey: 'qnarefund:q2-again', reasonCode: 'QNA_REFUND',
})).rejects.toMatchObject({ details: { code: 'E-POP-03' } });
const wallet = await getFirestore().doc(`users/${UID}/wallet/main`).get();
expect(wallet.get('activityBalance')).toBe(1000);
});
});
describe('캠페인 환불 — 부분 실패를 덮지 않는다 (별첨 1 ⑤)', () => {
it('후원 한 건당 한 번만 돌려준다', async () => {
await grantPop({
uid: UID, popType: 'activity', amount: 1000,
reasonCode: 'ADMIN_GRANT', idempotencyKey: 'g:cmp',
});
const donation = await spendPop({
uid: UID, popType: 'activity', amount: 400,
reasonCode: 'CAMPAIGN_FUND', idempotencyKey: 'fund:cmp:1',
});
const key = 'refund:donation-1';
const first = await refundPop({
uid: UID, originalTxId: donation.txId,
idempotencyKey: key, reasonCode: 'CAMPAIGN_REFUND',
});
// 배치를 다시 돌려도 같은 거래로 판정된다.
const again = await refundPop({
uid: UID, originalTxId: donation.txId,
idempotencyKey: key, reasonCode: 'CAMPAIGN_REFUND',
});
expect(again.txId).toBe(first.txId);
const wallet = await getFirestore().doc(`users/${UID}/wallet/main`).get();
expect(wallet.get('activityBalance')).toBe(1000);
});
it('환불하면 기여 통계도 되돌아간다', async () => {
await grantPop({
uid: UID, popType: 'activity', amount: 1000,
reasonCode: 'ADMIN_GRANT', idempotencyKey: 'g:cmp2',
});
const donation = await spendPop({
uid: UID, popType: 'activity', amount: 400,
reasonCode: 'CAMPAIGN_FUND', idempotencyKey: 'fund:cmp:2',
fandomId: 'f1',
});
const db = getFirestore();
expect((await db.doc(`users/${UID}`).get()).get('totalContributedPop')).toBe(400);
await refundPop({
uid: UID, originalTxId: donation.txId,
idempotencyKey: 'refund:donation-2', reasonCode: 'CAMPAIGN_REFUND',
});
expect((await db.doc(`users/${UID}`).get()).get('totalContributedPop')).toBe(0);
});
});
describe('영웅의 선물 — 문서 ID 로 중복을 막는다', () => {
it('같은 사람이 두 번 수령하지 못한다', async () => {
const db = getFirestore();
const dropRef = db.doc('fandoms/f1/heroDrops/d1');
await dropRef.set({
totalPop: 1000, perUserCap: 100, remainingPop: 1000,
endsAt: Timestamp.fromMillis(Date.now() + 60_000),
createdAt: FieldValue.serverTimestamp(),
});
const claimRef = dropRef.collection('claims').doc(UID);
await claimRef.create({ uid: UID, amount: 100 });
// 조건문이 아니라 구조로 막힌다 — 동시 요청에서도 뚫리지 않는다.
await expect(claimRef.create({ uid: UID, amount: 100 })).rejects.toThrow();
await dropRef.delete();
await claimRef.delete();
});
});
describe('추천 — 자기 추천과 중복 전환', () => {
it('같은 사람이 같은 코드로 두 번 전환되지 않는다', async () => {
const db = getFirestore();
const ref = db.doc(`referrals/${UID}_CODE1`);
await ref.create({ referredUid: UID, referrerUid: OTHER, code: 'CODE1' });
await expect(
ref.create({ referredUid: UID, referrerUid: OTHER, code: 'CODE1' }),
).rejects.toThrow();
await ref.delete();
});
});
describe('번역 — 자기검증 제외', () => {
it('자기 마킹은 유효 표에서 빠진다', () => {
const author = 'author';
const markings = [
{ id: author, valid: true },
{ id: 'a', valid: true },
{ id: 'b', valid: false },
{ id: 'c', valid: false },
];
const valid = markings.filter((m) => m.id !== author && m.valid).length;
const invalid = markings.filter((m) => m.id !== author && !m.valid).length;
// 자기 마킹을 세면 2:2 로 통과하지만, 빼면 1:2 로 무효다.
expect(valid).toBe(1);
expect(invalid).toBe(2);
expect(invalid > valid).toBe(true);
});
});
@@ -0,0 +1,99 @@
import { afterAll, beforeAll, beforeEach, expect, it, vi } from 'vitest';
import { FieldValue, getFirestore } from 'firebase-admin/firestore';
import { aggregateLive, closeDay, fnFpiAggregate, fnFpiDailySnapshot } from '../src/fandom/fandom.js';
import { Timestamp } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin } from './support.js';
const id = 'test-atomic-sfpi';
const run = () => fnFpiDailySnapshot.run({ scheduleTime: new Date().toISOString() });
beforeAll(() => { bootAdmin(); });
beforeEach(async () => {
const ref = getFirestore().doc(`fandoms/${id}`);
await getFirestore().recursiveDelete(ref);
await ref.set({ fpiScore: 125, sfpi: 50 });
});
afterAll(async () => {
vi.restoreAllMocks();
await getFirestore().recursiveDelete(getFirestore().doc(`fandoms/${id}`));
await shutdownAdmin();
});
it('누적 쓰기 준비가 실패해도 스냅샷만 남지 않고 재시도로 하루치를 복구한다', async () => {
const increment = vi.spyOn(FieldValue, 'increment').mockImplementationOnce(() => {
throw new Error('injected write failure');
});
await expect(run()).rejects.toThrow('injected write failure');
increment.mockRestore();
const ref = getFirestore().doc(`fandoms/${id}`);
expect((await ref.collection('fpiSnapshots').get()).size).toBe(0);
expect((await ref.get()).get('sfpi')).toBe(50);
await run();
await Promise.all([run(), run()]);
const snapshots = await ref.collection('fpiSnapshots').get();
expect(snapshots.size).toBe(1);
expect(snapshots.docs[0]!.get('fpiScore')).toBe(125);
expect((await ref.get()).get('sfpi')).toBe(175);
});
it('KST 자정 실행은 전날 키로 스냅샷 · DAU 를 남기고 DAU 는 인정 활동의 고유 사용자 수다', async () => {
const ref = getFirestore().doc(`fandoms/${id}`);
const at = (iso: string) => Timestamp.fromDate(new Date(iso));
const events = ref.collection('fpiEvents');
// 9/20 KST 하루: 사용자 a 2건 · b 1건 · 미인정 c · uid 없는 Eclipse 차감 · 경계 밖 d.
await events.add({ uid: 'a', verified: true, axis: 'community', value: 1, occurredAt: at('2026-09-20T00:00:00+09:00') });
await events.add({ uid: 'a', verified: true, axis: 'health', value: 1, occurredAt: at('2026-09-20T12:00:00+09:00') });
await events.add({ uid: 'b', verified: true, axis: 'bpm', value: 1, occurredAt: at('2026-09-20T23:59:59+09:00') });
await events.add({ uid: 'c', verified: false, axis: 'bpm', value: 1, occurredAt: at('2026-09-20T10:00:00+09:00') });
await events.add({ kind: 'score_adjustment', verified: true, value: -1, occurredAt: at('2026-09-20T17:51:00+09:00') });
await events.add({ uid: 'd', verified: true, axis: 'bpm', value: 1, occurredAt: at('2026-09-21T00:00:00+09:00') });
const result = await closeDay(new Date('2026-09-21T00:00:30+09:00'));
expect(result.day).toBe('2026-09-20');
expect((await ref.collection('fandomDailyStats').doc('2026-09-20').get()).get('dau')).toBe(2);
expect((await ref.collection('fpiSnapshots').doc('2026-09-20').get()).exists).toBe(true);
expect((await ref.collection('fpiSnapshots').doc('2026-09-21').get()).exists).toBe(false);
// 활동 0명인 날도 0 으로 남는다(C-27). 재실행은 스냅샷 · sFPI 를 한 번만 반영한다.
await closeDay(new Date('2026-09-22T00:00:30+09:00'));
await closeDay(new Date('2026-09-22T00:00:30+09:00'));
expect((await ref.collection('fandomDailyStats').doc('2026-09-21').get()).get('dau')).toBe(1);
await closeDay(new Date('2026-09-23T00:00:30+09:00'));
expect((await ref.collection('fandomDailyStats').doc('2026-09-22').get()).get('dau')).toBe(0);
expect((await ref.get()).get('sfpi')).toBe(50 + 125 * 3);
});
it('Eclipse 차감만 있는 팬덤은 FPI 0 아래로 내려가지 않고 랭킹 모집단에 들지 않는다(리뷰 M2)', async () => {
const ref = getFirestore().doc(`fandoms/${id}`);
await ref.set({ stage: 1 }, { merge: true });
await ref.collection('fpiEvents').add({
kind: 'score_adjustment', reasonCode: 'ECLIPSE_EROSION', value: -5, verified: true,
occurredAt: Timestamp.now(),
});
await fnFpiAggregate.run({ scheduleTime: new Date().toISOString() });
const doc = await ref.get();
expect(doc.get('fpiScore')).toBe(0);
expect(doc.get('rankingEligible')).toBe(false);
});
it('LIVE — 최근 창(분) 안에 앱을 쓴 사용자를 fandomIds 마다 세고 빠진 팬덤은 0 으로 내린다(Q-GM-03 A안)', async () => {
const db = getFirestore();
const now = Date.now();
const seed: Array<[string, number, string[]]> = [
['test-live-1', now - 60_000, ['test-live-f1', 'test-live-f2']],
['test-live-2', now - 10 * 60_000, ['test-live-f1']], // 창 밖
['test-live-3', now - 1_000, ['test-live-f1', 'test-live-gone']], // 없는 팬덤은 건너뛴다
];
try {
await db.doc('fandoms/test-live-f1').set({ onlineCount: 0 });
await db.doc('fandoms/test-live-f2').set({ onlineCount: 0 });
await db.doc('fandoms/test-live-f3').set({ onlineCount: 7 }); // 지난 집계의 값
for (const [u, at, fandomIds] of seed) await db.doc(`users/${u}`).set({ lastSeenAt: Timestamp.fromMillis(at), fandomIds });
await aggregateLive(now);
const online = async (id: string) => (await db.doc(`fandoms/${id}`).get()).get('onlineCount');
expect([await online('test-live-f1'), await online('test-live-f2'), await online('test-live-f3')]).toEqual([2, 1, 0]);
expect((await db.doc('fandoms/test-live-gone').get()).exists).toBe(false);
} finally {
for (const [u] of seed) await db.recursiveDelete(db.doc(`users/${u}`));
for (const f of ['f1', 'f2', 'f3']) await db.doc(`fandoms/test-live-${f}`).delete();
}
});
+177
View File
@@ -0,0 +1,177 @@
import { afterAll, beforeAll, beforeEach, expect, it } from 'vitest';
import { getFirestore, Timestamp } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin } from './support.js';
import { grantPop, refundPop, reverseGrowth, spendPop } from '../src/ledger/ledger.js';
import { fnAdminGrowthReverse } from '../src/admin/admin.js';
import { verifiedQuestCountOf } from '../src/ledger/fpi.js';
const uid = 'test-growth-user';
const a = 'test-growth-a';
const b = 'test-growth-b';
const grant = () => grantPop({ uid, popType: 'activity', amount: 10,
reasonCode: 'QUEST_ATTEND', growthSource: 'quest_reward', idempotencyKey: 'growth:reward' });
const spend = () => spendPop({ uid, popType: 'activity', amount: 10,
reasonCode: 'ZAP_CONTRIBUTION', fandomId: a, idempotencyKey: 'growth:zap' });
async function clean() {
const db = getFirestore();
for (const path of [`users/${uid}`, `fandoms/${a}`, `fandoms/${b}`]) {
await db.recursiveDelete(db.doc(path));
}
}
beforeAll(() => { bootAdmin(); });
beforeEach(async () => {
await clean();
const db = getFirestore();
await db.doc(`users/${uid}`).set({ fandomId: a, nickname: '공개팬' });
await db.doc(`fandoms/${a}`).set({ stage: 1, sfpi: 125 });
await db.doc(`fandoms/${b}`).set({ stage: 1, sfpi: 125 });
});
afterAll(async () => { await clean(); await shutdownAdmin(); });
it('보상10+ZAP10=성장20이며 동시 재시도와 대표 팬덤 변경에도 귀속이 고정된다', async () => {
const db = getFirestore();
const results = await Promise.all([grant(), grant()]);
expect(results[0]!.txId).toBe(results[1]!.txId);
await Promise.all([spend(), spend()]);
// 지급 · Zap 뒤에 대표 팬덤을 바꾸고 재시도해도 원 귀속이 유지된다(G-02).
await db.doc(`users/${uid}`).update({ fandomId: b });
await grant();
await spend();
expect((await db.doc(`fandoms/${a}`).get()).get('growthPop')).toBe(20);
expect((await db.doc(`fandoms/${a}`).get()).get('sfpi')).toBe(125);
expect((await db.doc(`fandoms/${b}`).get()).get('growthPop')).toBeUndefined();
const events = await db.collection(`users/${uid}/growthEvents`).get();
expect(events.size).toBe(2);
expect(events.docs.map(d => d.get('sourceType')).sort()).toEqual(['quest_reward', 'zap']);
expect(events.docs.every(d => d.get('fandomIdAtPayout') === a && d.id === d.get('sourceEventId'))).toBe(true);
expect((await db.doc(`users/${uid}/wallet/main`).get()).get('activityBalance')).toBe(0);
expect((await db.doc(`fandoms/${a}/members/${uid}`).get()).get('contributedPop')).toBe(10);
});
it('일반 광고/관리자 지급/보류 지급/예치성 차감에 성장을 자동 확대하지 않는다', async () => {
await grantPop({ uid, popType: 'activity', amount: 10, reasonCode: 'AD_REWARD', idempotencyKey: 'ad' });
await grantPop({ uid, popType: 'activity', amount: 10, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'admin' });
await grantPop({ uid, popType: 'activity', amount: 10, reasonCode: 'QUEST_ATTEND',
growthSource: 'quest_reward', pending: true, idempotencyKey: 'pending' });
await spendPop({ uid, popType: 'activity', amount: 10, reasonCode: 'QNA_BOUNTY', fandomId: a, idempotencyKey: 'deposit' });
expect((await getFirestore().collection(`users/${uid}/growthEvents`).get()).size).toBe(0);
expect((await getFirestore().doc(`fandoms/${a}`).get()).get('growthPop')).toBeUndefined();
});
it('지급 트랜잭션 실패는 성장도 남기지 않는다', async () => {
await expect(grantPop({ uid, popType: 'activity', amount: 10, reasonCode: 'QUEST_ATTEND',
growthSource: 'quest_reward', idempotencyKey: 'abort', alsoWrite: () => { throw new Error('abort'); },
})).rejects.toThrow('abort');
expect((await getFirestore().collection(`users/${uid}/growthEvents`).get()).size).toBe(0);
expect((await getFirestore().doc(`fandoms/${a}`).get()).get('growthPop')).toBeUndefined();
});
it('반환(CAMPAIGN_REFUND · QNA_REFUND)은 성장을 만들지도 되돌리지도 않는다(C-30)', async () => {
const db = getFirestore();
await grantPop({ uid, popType: 'activity', amount: 20, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'seed' });
const fund = await spendPop({ uid, popType: 'activity', amount: 10, reasonCode: 'CAMPAIGN_FUND',
fandomId: a, idempotencyKey: 'fund' });
const bounty = await spendPop({ uid, popType: 'activity', amount: 10, reasonCode: 'QNA_BOUNTY',
fandomId: a, idempotencyKey: 'bounty' });
await refundPop({ uid, originalTxId: fund.txId, idempotencyKey: 'r1', reasonCode: 'CAMPAIGN_REFUND' });
await refundPop({ uid, originalTxId: bounty.txId, idempotencyKey: 'r2', reasonCode: 'QNA_REFUND' });
const events = await db.collection(`users/${uid}/growthEvents`).get();
expect(events.docs.map((d) => d.get('sourceType'))).toEqual(['funding']);
expect((await db.doc(`fandoms/${a}`).get()).get('growthPop')).toBe(10);
});
it('오지급 확정 역분개는 원 이벤트 참조 · 사유 · 음수 수치를 남기고 한 번만 반영된다(C-32)', async () => {
const db = getFirestore();
const paid = await grant();
const reverse = () => reverseGrowth({ uid, sourceEventId: paid.txId, amount: 4,
reason: '중복 지급 확정', actorUid: 'ops-1' });
await Promise.all([reverse(), reverse()]);
const rev = await db.doc(`users/${uid}/growthEvents/rev-${paid.txId}`).get();
expect(rev.get('reversalOf')).toBe(paid.txId);
expect(rev.get('amount')).toBe(-4);
expect(rev.get('reason')).toBe('중복 지급 확정');
expect(rev.get('fandomIdAtPayout')).toBe(a);
// 정상분 6 은 유지된다. 원 행은 그대로다(append-only).
expect((await db.doc(`fandoms/${a}`).get()).get('growthPop')).toBe(6);
expect((await db.doc(`users/${uid}/growthEvents/${paid.txId}`).get()).get('amount')).toBe(10);
// 일부 정정은 금액 오류라 완료(검증 활동)는 그대로다.
expect((await db.collection(`users/${uid}/verifiedCompletions`).where('voided', '==', true).get()).size).toBe(0);
// 원 성장량을 넘는 정정 · 역분개 행의 재역분개는 거부한다.
await expect(reverseGrowth({ uid, sourceEventId: `rev-${paid.txId}`, amount: 1, reason: 'x', actorUid: 'ops-1' }))
.rejects.toMatchObject({ details: { code: 'E-POP-02' } });
});
it('Quest 보상 전액 역분개는 그 원장 행의 검증 활동 표식을 무효로 한다 · 일부 정정은 유지(Campaign 개설자격 A안)', async () => {
const db = getFirestore();
const paid = await grant();
await db.doc(`users/${uid}/verifiedCompletions/q_2026-09-28`).set({ questId: 'q', txIds: [paid.txId] });
await reverseGrowth({ uid, sourceEventId: paid.txId, amount: 10, reason: '허위 참여 확정', actorUid: 'ops-1' });
expect(await verifiedQuestCountOf(uid)).toBe(0);
});
it('역분개는 운영자(master/ops)만 부를 수 있다', async () => {
const paid = await grant();
const data = { uid, sourceEventId: paid.txId, amount: 1, reason: '오지급' };
for (const auth of [{ uid, token: {} }, { uid: 'viewer', token: { role: 'viewer' } }]) {
await expect(fnAdminGrowthReverse.run({ data, auth } as Parameters<typeof fnAdminGrowthReverse.run>[0]))
.rejects.toMatchObject({ details: { code: 'E-PERM-01' } });
}
expect((await getFirestore().doc(`fandoms/${a}`).get()).get('growthPop')).toBe(10);
});
it('캠페인 후원은 기여 POP 만 쓰고 targetPop 초과를 막으며 캠페인 팬덤에 귀속된다', async () => {
const db = getFirestore();
const { fundCampaign } = await import('../src/fandom/fandom.js');
const cid = 'test-growth-campaign';
await db.recursiveDelete(db.doc(`campaigns/${cid}`));
await db.doc(`campaigns/${cid}`).set({
status: 'funding', targetPop: 30, raisedPop: 0, fandomId: b,
endsAt: Timestamp.fromMillis(Date.now() + 86_400_000),
});
await grantPop({ uid, popType: 'support', amount: 50, reasonCode: 'IAP_PURCHASE', idempotencyKey: 'fund:seed' });
await grantPop({ uid, popType: 'activity', amount: 50, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'fund:seed-a' });
// 동시 후원 둘이 같은 잔여분(30)을 보고 함께 통과하지 않는다 — 판정이 차감 트랜잭션 안이다.
const settled = await Promise.allSettled([
fundCampaign({ uid, campaignId: cid, clientSeq: 's1', amount: 20 }),
fundCampaign({ uid, campaignId: cid, clientSeq: 's2', amount: 20 }),
]);
expect(settled.filter((r) => r.status === 'fulfilled')).toHaveLength(1);
expect(String((settled.find((r) => r.status === 'rejected') as PromiseRejectedResult).reason))
.toContain('남은 모금 목표');
expect((await db.doc(`campaigns/${cid}`).get()).get('raisedPop')).toBe(20);
const wallet = (await db.doc(`users/${uid}/wallet/main`).get()).data()!;
expect(wallet.supportBalance).toBe(30);
expect(wallet.activityBalance).toBe(50);
expect((await db.doc(`fandoms/${b}`).get()).get('growthPop')).toBe(20);
expect((await db.doc(`fandoms/${a}`).get()).get('growthPop')).toBeUndefined();
const donations = await db.collection(`campaigns/${cid}/donations`).get();
expect(donations.docs.map((d) => d.get('donorUid'))).toEqual([uid]);
await db.recursiveDelete(db.doc(`campaigns/${cid}`));
});
it('fn-pop-spend 는 Zap 만 받고 횟수 · 귀속 팬덤을 서버가 정하며 EXP 를 주지 않는다(리뷰 B1 · H1 · M1)', async () => {
const db = getFirestore();
const { fnPopSpend } = await import('../src/ledger/pop.js');
const run = (data: Record<string, unknown>) => fnPopSpend.run(
{ data, auth: { uid, token: {} } } as Parameters<typeof fnPopSpend.run>[0]);
await db.doc('config/policy').set({ zapPop: 10 }, { merge: true });
await grantPop({ uid, popType: 'activity', amount: 100, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'zap:seed' });
await expect(run({ popType: 'activity', amount: 10, reasonCode: 'CAMPAIGN_FUND', idempotencyKey: 'zap:h1' }))
.rejects.toMatchObject({ details: { code: 'E-PERM-01' } });
await expect(run({ popType: 'activity', amount: 5, reasonCode: 'ZAP_CONTRIBUTION', idempotencyKey: 'zap:odd' }))
.rejects.toMatchObject({ details: { code: 'E-POP-02' } });
// 클라이언트가 count 1e9 · 남의 팬덤 b 를 보내도 서버는 30 ÷ 10 = 3 회 · 대표 팬덤 a 로 적는다.
await run({ popType: 'activity', amount: 30, count: 1e9, fandomId: b,
reasonCode: 'ZAP_CONTRIBUTION', idempotencyKey: 'zap:b1' });
const events = await db.collection(`fandoms/${a}/fpiEvents`).get();
expect(events.docs.map((e) => [e.get('axis'), e.get('units')])).toEqual([['contribution', 3]]);
expect((await db.collection(`fandoms/${b}/fpiEvents`).get()).size).toBe(0);
expect((await db.doc(`fandoms/${a}`).get()).get('growthPop')).toBe(30);
expect((await db.doc(`fandoms/${b}`).get()).get('growthPop')).toBeUndefined();
expect((await db.doc(`users/${uid}`).get()).get('userExp')).toBeUndefined();
await db.recursiveDelete(db.collection(`fandoms/${a}/fpiEvents`));
});
-46
View File
@@ -1,46 +0,0 @@
import { describe, expect, it } from 'vitest';
import type { CallableRequest } from 'firebase-functions/v2/https';
import { callerUid, requireRole } from '../src/core/callable.js';
import { fnAdminSetRole, fnConsentRecord } from '../src/account/account.js';
import { fnAdminWrite } from '../src/admin/admin.js';
/** 에뮬레이터 없이 도는 인증 · 권한 관문 검증. 데이터에 닿기 전에 끊겨야 한다. */
const req = (auth?: { uid: string; token: Record<string, unknown> }) =>
({ data: {}, auth } as unknown as CallableRequest);
describe('인증 관문', () => {
it('익명 호출은 E-AUTH-03 으로 끊는다', () => {
expect(() => callerUid(req())).toThrow(expect.objectContaining({ details: { code: 'E-AUTH-03' } }));
});
it('로그인한 호출은 UID 를 돌려준다', () => {
expect(callerUid(req({ uid: 'u1', token: {} }))).toBe('u1');
});
});
describe('운영자 관문', () => {
it('허용 목록에 없는 역할은 E-PERM-01 로 끊는다', () => {
expect(() => requireRole(req({ uid: 'v', token: { role: 'viewer' } }), ['master', 'ops']))
.toThrow(expect.objectContaining({ details: { code: 'E-PERM-01' } }));
});
it('허용된 역할은 그대로 돌려준다', () => {
expect(requireRole(req({ uid: 'o', token: { role: 'ops' } }), ['master', 'ops'])).toBe('ops');
});
it('Admin 쓰기 · 권한 부여는 일반 사용자와 조회 운영자를 데이터 접근 전에 거부한다', async () => {
for (const fn of [fnAdminWrite, fnAdminSetRole]) {
for (const auth of [{ uid: 'user', token: {} }, { uid: 'viewer', token: { role: 'viewer' } }]) {
await expect(fn.run(req(auth) as Parameters<typeof fn.run>[0]))
.rejects.toMatchObject({ details: { code: 'E-PERM-01' } });
}
}
});
it('약관 동의 기록은 익명 호출을 거부한다', async () => {
await expect(fnConsentRecord.run(req() as Parameters<typeof fnConsentRecord.run>[0]))
.rejects.toMatchObject({ details: { code: 'E-AUTH-03' } });
});
});
@@ -0,0 +1,106 @@
import { afterAll, afterEach, beforeAll, beforeEach, expect, it, vi } from 'vitest';
import { getFirestore, Timestamp } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin } from './support.js';
import * as ledger from '../src/ledger/ledger.js';
import { txIdOf } from '../src/core/ids.js';
import { fnHeroDropTrigger, fnHeroDropClaim } from '../src/fandom/herodrop.js';
const uid = 'hero-atomic-test';
const fandomId = 'hero-atomic-test';
async function call(fn: { run(request: never): unknown }, data: Record<string, unknown>) {
return await fn.run({ data, auth: { uid, token: {} } } as never) as Record<string, unknown>;
}
async function clean() {
for (const path of [`users/${uid}`, `fandoms/${fandomId}`])
await getFirestore().recursiveDelete(getFirestore().doc(path));
}
beforeAll(() => { bootAdmin(); });
beforeEach(async () => {
await clean();
await getFirestore().doc('config/policy').set({ herodrop: { totalPop: 100, perUserCap: 5, perUserCapPercent: 5, durationSec: 600 } }, { merge: true });
await getFirestore().doc(`users/${uid}`).set({ nickname: '공개팬', displayName: '비공개이름', email: 'private@example.com' });
await ledger.grantPop({ uid, amount: 200, popType: 'support', reasonCode: 'ADMIN_GRANT', idempotencyKey: 'hero-seed' });
});
afterEach(() => vi.restoreAllMocks());
afterAll(async () => { await clean(); await shutdownAdmin(); });
const trigger = () => call(fnHeroDropTrigger, { fandomId, productId: 'gift', clientSeq: '1', heroNickname: '위조닉네임' });
it('발동 재시도는 소진된 Pool과 종료시각을 다시 쓰지 않는다', async () => {
const first = await trigger();
const ref = getFirestore().doc(`fandoms/${fandomId}/heroDrops/${first.dropId}`);
const before = (await ref.get()).get('endsAt');
expect((await ref.get()).get('heroNickname')).toBe('공개팬');
expect((await ref.get()).get('email')).toBeUndefined();
await ref.update({ remainingPop: 0, claimedCount: 20 });
await Promise.all([trigger(), trigger()]);
const after = await ref.get();
expect(after.get('remainingPop')).toBe(0);
expect(after.get('claimedCount')).toBe(20);
expect(after.get('endsAt')).toEqual(before);
expect((await getFirestore().doc(`users/${uid}/wallet/main`).get()).get('supportBalance')).toBe(100);
});
it('수령 지급 실패는 Pool과 수령 기록도 남기지 않는다', async () => {
const { dropId } = await trigger();
const ref = getFirestore().doc(`fandoms/${fandomId}/heroDrops/${dropId}`);
vi.spyOn(ledger, 'grantPop').mockRejectedValueOnce(new Error('grant unavailable'));
await expect(call(fnHeroDropClaim, { fandomId, dropId })).rejects.toThrow('grant unavailable');
expect((await ref.get()).get('remainingPop')).toBe(100);
expect((await ref.collection('claims').get()).size).toBe(0);
});
it('동시 수령과 종료 후 응답 재확인은 같은 지급 결과를 돌려준다', async () => {
const { dropId } = await trigger();
const results = await Promise.all([
call(fnHeroDropClaim, { fandomId, dropId }), call(fnHeroDropClaim, { fandomId, dropId }),
]);
expect(results[0]).toEqual(results[1]);
const ref = getFirestore().doc(`fandoms/${fandomId}/heroDrops/${dropId}`);
await ref.update({ endsAt: Timestamp.fromMillis(0) });
expect(await call(fnHeroDropClaim, { fandomId, dropId })).toEqual(results[0]);
expect((await ref.get()).get('remainingPop')).toBe(95);
expect((await ref.get()).get('claimedCount')).toBe(1);
expect((await getFirestore().doc(`users/${uid}/wallet/main`).get()).get('activityBalance')).toBe(5);
});
it('이벤트 생성 실패 시 지원 POP 차감도 되돌린다', async () => {
const dropId = txIdOf(`herodrop:trigger:${uid}:1`);
await getFirestore().doc(`fandoms/${fandomId}/heroDrops/${dropId}`).create({ remainingPop: 7 });
await expect(trigger()).rejects.toThrow();
expect((await getFirestore().doc(`users/${uid}/wallet/main`).get()).get('supportBalance')).toBe(200);
expect((await getFirestore().doc(`users/${uid}/popTransactions/${dropId}`).get()).exists).toBe(false);
});
it('공개 닉네임이 없으면 비공개 이름이나 이메일로 대체하지 않고 「팬」으로 싣는다', async () => {
await getFirestore().doc(`users/${uid}`).set({ displayName: '비공개이름', email: 'private@example.com' });
const { dropId } = await trigger();
const drop = await getFirestore().doc(`fandoms/${fandomId}/heroDrops/${dropId}`).get();
expect(drop.get('heroNickname')).toBe('팬');
expect(drop.get('displayName')).toBeUndefined();
expect(drop.get('email')).toBeUndefined();
});
it('기본 5% 상한과 10분을 적용하고 기존 고정 POP 상한은 사용하지 않는다', async () => {
await getFirestore().doc('config/policy').update({ herodrop: { totalPop: 200, perUserCap: 150 } });
const before = Date.now();
const { dropId } = await trigger();
const drop = await getFirestore().doc(`fandoms/${fandomId}/heroDrops/${dropId}`).get();
expect(drop.get('perUserCap')).toBe(10);
expect(drop.get('perUserCapPercent')).toBe(5);
expect(drop.get('endsAt').toMillis()).toBeGreaterThanOrEqual(before + 600000);
const claim = await call(fnHeroDropClaim, { fandomId, dropId });
expect(claim.claimedPop).toBe(10);
});
it('운영 상한 비율을 이벤트에 고정하고 소수 POP은 상한을 넘겨 올리지 않는다', async () => {
await getFirestore().doc('config/policy').update({ herodrop: {
totalPop: 101, perUserCapPercent: 3, durationSec: 120,
} });
const { dropId } = await trigger();
const drop = await getFirestore().doc(`fandoms/${fandomId}/heroDrops/${dropId}`).get();
expect(drop.get('perUserCap')).toBe(3);
await getFirestore().doc('config/policy').update({ 'herodrop.perUserCapPercent': 10 });
expect((await call(fnHeroDropClaim, { fandomId, dropId })).claimedPop).toBe(3);
});
it('유효하지 않은 운영 비율은 차감 전에 거부한다', async () => {
for (const cap of [0, -1, 101, '5']) {
await getFirestore().doc('config/policy').update({ 'herodrop.perUserCapPercent': cap });
await expect(trigger()).rejects.toThrow();
}
expect((await getFirestore().doc(`users/${uid}/wallet/main`).get()).get('supportBalance')).toBe(200);
});
@@ -0,0 +1,114 @@
import { afterAll, beforeAll, expect, it, vi } from 'vitest';
import { getFirestore, Timestamp, Transaction } from 'firebase-admin/firestore';
import { hibernationPatch, hibernationSettings } from '../src/fandom/hibernation.js';
import { fnFpiAggregate } from '../src/fandom/fandom.js';
import { bootAdmin, shutdownAdmin } from './support.js';
const config = { enabled: true, wakeFpi: 20, wakeSeconds: 60 };
const settings = hibernationSettings(config)!;
const day = 86_400_000;
const at = (ms: number) => Timestamp.fromMillis(ms);
let originalConfig: FirebaseFirestore.DocumentData | undefined;
beforeAll(async () => {
bootAdmin();
originalConfig = (await getFirestore().doc('config/hibernation').get()).data();
});
afterAll(async () => {
const db = getFirestore();
const ref = db.doc('config/hibernation');
if (originalConfig) await ref.set(originalConfig);
else await ref.delete();
await db.recursiveDelete(db.doc('fandoms/test-hibernation'));
await shutdownAdmin();
});
it('진입 기본값만 원문에서 가져오고 복귀 설정 누락·잘못된 값은 거부한다', () => {
expect(settings.enterFpi).toBe(10);
expect(settings.enterSeconds).toBe(30 * 86400);
expect(hibernationSettings({})).toBeNull();
for (const invalid of [
{ enabled: true }, { ...config, wakeFpi: 10 },
{ ...config, wakeSeconds: -1 }, { ...config, enterFpi: '10' },
]) expect(() => hibernationSettings(invalid)).toThrow();
expect(hibernationPatch({}, Number.NaN, settings, at(0))).toEqual({});
});
it('30일 연속 미만·경계값·복귀 유지시간·재시도·정책 변경을 구분한다', () => {
let state = hibernationPatch({}, 9, settings, at(0));
state = { ...state, ...hibernationPatch(state, 9, settings, at(30 * day - 1)) };
expect(state.isHibernating).toBe(false);
const interrupted = hibernationPatch(state, 10, settings, at(30 * day));
expect(interrupted.hibernationLowSince).toBeNull();
expect(interrupted.isHibernating).toBe(false);
const changed = hibernationPatch(state, 9, { ...settings, enterFpi: 11 }, at(30 * day));
expect(changed.isHibernating).toBe(false);
state = { ...state, ...hibernationPatch(state, 9, settings, at(30 * day)) };
expect(state.isHibernating).toBe(true);
expect(hibernationPatch(state, 99, settings, at(0))).toEqual({});
const noActivity = hibernationPatch(state, 99, settings, at(31 * day));
expect(noActivity.isHibernating).toBe(true);
expect(noActivity.hibernationWakeSince).toBeNull();
const activity = { id: 'new-activity', at: at(31 * day) };
state = { ...state, ...hibernationPatch(state, 20, settings, at(31 * day), activity) };
expect(state.isHibernating).toBe(true);
state = { ...state, ...hibernationPatch(state, 19, settings, at(31 * day + 30_000), activity) };
expect(state.hibernationWakeSince).toBeNull();
state = { ...state, ...hibernationPatch(state, 20, settings, at(32 * day), activity) };
expect(state.isHibernating).toBe(true);
state = { ...state, ...hibernationPatch(state, 20, settings, at(32 * day + 60_000), activity) };
expect(state.isHibernating).toBe(false);
});
it('실제 FPI 집계가 휴면을 함께 확정하며 sFPI나 별도 점수를 차감하지 않는다', async () => {
const db = getFirestore();
const ref = db.doc('fandoms/test-hibernation');
await db.doc('config/hibernation').set(config);
await ref.set({
stage: 1, sfpi: 100, fpiScore: 99,
...hibernationPatch({}, 0, settings, at(Date.now() - 31 * day)),
});
const create = vi.spyOn(Transaction.prototype, 'create').mockImplementationOnce(() => {
throw new Error('history write failed');
});
try {
await expect(fnFpiAggregate.run({ scheduleTime: new Date().toISOString() }))
.rejects.toThrow('history write failed');
} finally {
create.mockRestore();
}
expect((await ref.get()).get('isHibernating')).toBe(false);
expect((await ref.get()).get('fpiScore')).toBe(99);
expect((await ref.collection('stateHistory').get()).size).toBe(0);
await fnFpiAggregate.run({ scheduleTime: new Date().toISOString() });
const result = await ref.get();
expect(result.get('isHibernating')).toBe(true);
expect(result.get('fpiScore')).toBe(0);
expect(result.get('sfpi')).toBe(100);
expect((await ref.collection('stateHistory').get()).size).toBe(1);
await fnFpiAggregate.run({ scheduleTime: new Date().toISOString() });
expect((await ref.get()).get('hibernationChangedAt').toMillis())
.toBe(result.get('hibernationChangedAt').toMillis());
await db.doc('config/hibernation').set({ ...config, wakeSeconds: 0 });
const event = ref.collection('fpiEvents').doc('wake-activity');
await event.set({ axis: 'contribution', value: 100, verified: true,
occurredAt: Timestamp.now(), reasonCode: 'ECLIPSE_RESTORE' });
await fnFpiAggregate.run({ scheduleTime: new Date().toISOString() });
expect((await ref.get()).get('isHibernating')).toBe(true);
await event.update({ reasonCode: 'QUEST_ACTIVITY' });
await fnFpiAggregate.run({ scheduleTime: new Date().toISOString() });
const awake = await ref.get();
expect(awake.get('isHibernating')).toBe(false);
const history = await ref.collection('stateHistory').orderBy('occurredAt').get();
expect(history.size).toBe(2);
expect(history.docs.map((entry) => entry.get('kind'))).toEqual(['hibernation', 'wake_up']);
expect(history.docs[0]!.get('fpiScore')).toBe(0);
expect(history.docs[1]!.get('sourceEventId')).toBe('wake-activity');
expect(history.docs[1]!.get('settings').wakeSeconds).toBe(0);
expect(awake.get('hibernationWakeEventId')).toBeUndefined();
await Promise.all([
fnFpiAggregate.run({ scheduleTime: new Date().toISOString() }),
fnFpiAggregate.run({ scheduleTime: new Date().toISOString() }),
]);
expect((await ref.collection('stateHistory').get()).size).toBe(2);
expect(awake.get('sfpi')).toBe(100);
});
@@ -0,0 +1,261 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { getFirestore, Timestamp } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin } from './support.js';
import { fnFocusDailyClaim, fnIapVerify, setReceiptVerifier } from '../src/payment/iap.js';
import {
StoreReceiptVerifier,
type Platform, type ReceiptClaim, type ReceiptHint, type ReceiptVerifier,
} from '../src/payment/receipt.js';
/**
* `fn-iap-verify` 콜러블 — 별첨 1 ⑥ 샌드박스 연결분.
*
* 스토어 왕복은 대역으로 두고, 상품 ID 에 따른 세 갈래(구독 · Hero Gift · 상품 표)와
* 재전송 · 계정 간 영수증 재사용을 본다.
*/
const UID = 'iap-verify-user';
const OTHER = 'iap-verify-other';
const FANDOM = 'iap-verify-fandom';
const GIFT = 'herogift.test.basic';
let claim: Partial<ReceiptClaim> = {};
let lastHint: ReceiptHint | undefined;
class FakeVerifier implements ReceiptVerifier {
async verify(platform: Platform, _receipt: string, hint?: ReceiptHint): Promise<ReceiptClaim> {
lastHint = hint;
return {
platform,
transactionId: 'gpa.1',
productId: 'pop.support.5500',
consumable: true,
purchasedAtMillis: 1_788_000_000_000,
revoked: false,
...claim,
};
}
}
const db = () => getFirestore();
async function call(uid: string, data: Record<string, unknown>) {
return await fnIapVerify.run(
{ data: { platform: 'android', receipt: 'token', ...data }, auth: { uid, token: {} } } as never,
) as Record<string, unknown>;
}
async function codeOf(fn: () => Promise<unknown>): Promise<string | undefined> {
try {
await fn();
} catch (e) {
return (e as { details?: { code?: string } }).details?.code;
}
return undefined;
}
async function clean() {
for (const path of [`users/${UID}`, `users/${OTHER}`, `fandoms/${FANDOM}`, `heroGiftProducts/${GIFT}`]) {
await db().recursiveDelete(db().doc(path));
}
const owners = await db().collection('iapTransactions').get();
await Promise.all(owners.docs.map((d) => d.ref.delete()));
}
beforeAll(() => { bootAdmin(); });
beforeEach(async () => {
claim = {};
setReceiptVerifier(new FakeVerifier());
await clean();
await db().doc(`users/${UID}`).set({ nickname: '영웅팬', fandomId: FANDOM });
await db().doc('config/policy').set({
herodrop: { totalPop: 100, perUserCapPercent: 5, durationSec: 600 },
popPackSalesEnabled: false,
focusDailyPop: 40,
}, { merge: true });
await db().doc(`heroGiftProducts/${GIFT}`).set(
{ name: '베이직 드랍', buyerPop: 500, dropPop: 500, active: true });
});
afterEach(() => { setReceiptVerifier(new StoreReceiptVerifier()); });
afterAll(async () => { await clean(); await shutdownAdmin(); });
describe('Hero Gift — 구매자 기여 POP + 팬덤 Drop Pool (B-84)', () => {
it('Catalog 값대로 구매자에게 기여 POP, 소속 팬덤에 같은 규격의 Drop 을 연다', async () => {
claim = { productId: GIFT };
const result = await call(UID, { productId: GIFT });
expect(result.granted).toBe(500);
const wallet = await db().doc(`users/${UID}/wallet/main`).get();
expect(wallet.get('supportBalance')).toBe(500);
const drop = await db().doc(`fandoms/${FANDOM}/heroDrops/${result.dropId}`).get();
expect(drop.get('totalPop')).toBe(500);
expect(drop.get('remainingPop')).toBe(500);
expect(drop.get('perUserCap')).toBe(25);
expect(drop.get('heroNickname')).toBe('영웅팬');
const purchase = await db().doc(`users/${UID}/purchases/android_gpa.1`).get();
expect(purchase.get('state')).toBe('granted');
expect(purchase.get('kind')).toBe('hero_gift');
expect(purchase.get('itemName')).toBe('베이직 드랍');
});
it('같은 구매를 다시 보내도 지급과 Drop 은 한 번이다', async () => {
claim = { productId: GIFT };
const first = await call(UID, { productId: GIFT });
const again = await call(UID, { productId: GIFT });
expect(again.duplicate).toBe(true);
expect(again.txId).toBe(first.txId);
const drops = await db().collection(`fandoms/${FANDOM}/heroDrops`).get();
expect(drops.size).toBe(1);
const wallet = await db().doc(`users/${UID}/wallet/main`).get();
expect(wallet.get('supportBalance')).toBe(500);
});
it('구매자 POP 과 Drop Pool 이 다른 Catalog 값은 지급하지 않는다 (B-84 동일 수량)', async () => {
await db().doc(`heroGiftProducts/${GIFT}`).update({ buyerPop: 100 });
claim = { productId: GIFT };
expect(await codeOf(() => call(UID, { productId: GIFT }))).toBe('E-PAY-02');
expect((await db().doc(`users/${UID}/wallet/main`).get()).exists).toBe(false);
expect((await db().collection(`fandoms/${FANDOM}/heroDrops`).get()).size).toBe(0);
});
it('지급 전 실패 뒤 재시도는 첫 시도의 팬덤으로 Drop 을 연다', async () => {
claim = { productId: GIFT };
await db().doc(`users/${UID}/purchases/android_gpa.1`).set({ state: 'verified', fandomId: FANDOM });
await db().doc(`users/${UID}`).set({ nickname: '영웅팬', fandomId: 'moved-away' });
const result = await call(UID, { productId: GIFT });
expect((await db().doc(`fandoms/${FANDOM}/heroDrops/${result.dropId}`).get()).exists).toBe(true);
});
it('소속 행성이 없으면 지급도 Drop 도 없이 E-PAY-01(재시도 불가)로 끊는다', async () => {
await db().doc(`users/${UID}`).set({ nickname: '영웅팬' });
claim = { productId: GIFT };
expect(await codeOf(() => call(UID, { productId: GIFT }))).toBe('E-PAY-01');
expect((await db().doc(`users/${UID}/wallet/main`).get()).exists).toBe(false);
});
it('판매를 끈 상품은 지급하지 않는다', async () => {
await db().doc(`heroGiftProducts/${GIFT}`).update({ active: false });
claim = { productId: GIFT };
expect(await codeOf(() => call(UID, { productId: GIFT }))).toBe('E-PAY-01');
});
});
describe('영수증 재사용 · 재전송', () => {
it('다른 계정이 같은 거래를 보내면 E-PAY-01 이고 지급하지 않는다', async () => {
await db().doc('config/policy').set({ popPackSalesEnabled: true }, { merge: true });
await call(UID, {});
expect(await codeOf(() => call(OTHER, {}))).toBe('E-PAY-01');
expect((await db().doc(`users/${OTHER}/wallet/main`).get()).exists).toBe(false);
});
it('환불 요청 뒤 복원 재전송이 상태를 「지급 완료」로 되돌리지 않는다', async () => {
await db().doc('config/policy').set({ popPackSalesEnabled: true }, { merge: true });
await call(UID, {});
const ref = db().doc(`users/${UID}/purchases/android_gpa.1`);
await ref.update({ state: 'refund-requested' });
const again = await call(UID, {});
expect(again.duplicate).toBe(true);
expect((await ref.get()).get('state')).toBe('refund-requested');
});
it('표시용 가격은 저장하되 지급량은 상품 표를 따른다', async () => {
await db().doc('config/policy').set({ popPackSalesEnabled: true }, { merge: true });
const result = await call(UID, { priceAmount: 99, currency: 'KRW', title: '서포터 팩' });
expect(result.granted).toBe(5_500);
const purchase = await db().doc(`users/${UID}/purchases/android_gpa.1`).get();
expect(purchase.get('priceAmount')).toBe(99);
expect(purchase.get('itemName')).toBe('서포터 팩');
expect(purchase.get('method')).toBe('Google Play');
});
});
describe('POP 직접 판매 금지 (B-02)', () => {
it('스위치가 꺼져 있으면(기본) POP 묶음 상품을 E-PAY-01 로 거부하고 지급하지 않는다', async () => {
expect(await codeOf(() => call(UID, {}))).toBe('E-PAY-01');
expect((await db().doc(`users/${UID}/wallet/main`).get()).exists).toBe(false);
expect((await db().doc(`users/${UID}/purchases/android_gpa.1`).get()).exists).toBe(false);
});
it('popPackSalesEnabled 를 켜면 상품 표대로 지급한다', async () => {
await db().doc('config/policy').set({ popPackSalesEnabled: true }, { merge: true });
expect((await call(UID, {})).granted).toBe(5_500);
});
});
describe('Focus 구독 — 광고 제거 (B-04)', () => {
const future = () => Date.now() + 30 * 86_400_000;
it('만료가 미래면 구독을 켜고 만료 시각을 저장하며 POP 은 주지 않는다', async () => {
const expires = future();
claim = { productId: 'focus.monthly', consumable: false, expiresAtMillis: expires };
const result = await call(UID, { productId: 'focus.monthly', transactionId: 'gpa.1' });
expect(result.subscription).toBe('active');
const sub = await db().doc(`users/${UID}/subscription/current`).get();
expect(sub.get('adsRemoved')).toBe(true);
expect((sub.get('expiresAt') as Timestamp).toMillis()).toBe(expires);
expect((await db().doc(`users/${UID}/wallet/main`).get()).exists).toBe(false);
// iOS 영수증에서 이 거래를 고르도록 거래 ID 를 검증기에 넘긴다.
expect(lastHint).toEqual({ productId: 'focus.monthly', transactionId: 'gpa.1' });
});
it('만료가 지났거나 없으면 활성으로 보지 않는다', async () => {
claim = { productId: 'focus.monthly', consumable: false, expiresAtMillis: Date.now() - 1000 };
expect((await call(UID, {})).subscription).toBe('expired');
const sub = await db().doc(`users/${UID}/subscription/current`).get();
expect(sub.get('adsRemoved')).toBe(false);
});
});
describe('Focus 일일 Claim — 일 40 기여 POP · 당일 · 이월 없음 (B-04)', () => {
const claimFocus = async () => await fnFocusDailyClaim.run(
{ data: {}, auth: { uid: UID, token: {} } } as never,
) as Record<string, unknown>;
const subscribe = (expiresAtMillis: number, state = 'active') =>
db().doc(`users/${UID}/subscription/current`).set({
state, adsRemoved: true, expiresAt: Timestamp.fromMillis(expiresAtMillis),
});
it('구독 중이면 하루 한 번 40 기여 POP 을 주고 같은 날 재요청은 첫 결과다', async () => {
await subscribe(Date.now() + 86_400_000);
const first = await claimFocus();
expect(first.granted).toBe(40);
const again = await claimFocus();
expect(again.duplicate).toBe(true);
expect(again.txId).toBe(first.txId);
const wallet = await db().doc(`users/${UID}/wallet/main`).get();
expect(wallet.get('supportBalance')).toBe(40);
const sub = await db().doc(`users/${UID}/subscription/current`).get();
expect(sub.get('focusClaimedDay')).toBe(first.day);
// C-31 — 구독 보너스는 성장 반영을 자동 적용하지 않는다.
expect((await db().collection(`users/${UID}/growthEvents`).get()).size).toBe(0);
});
it('해지했어도 만료 전이면 받는다', async () => {
await subscribe(Date.now() + 86_400_000, 'canceled');
expect((await claimFocus()).granted).toBe(40);
});
it('구독이 없거나 만료됐으면 E-PAY-03 이고 지급하지 않는다', async () => {
expect(await codeOf(claimFocus)).toBe('E-PAY-03');
await subscribe(Date.now() - 1000);
expect(await codeOf(claimFocus)).toBe('E-PAY-03');
expect((await db().doc(`users/${UID}/wallet/main`).get()).exists).toBe(false);
});
it('운영값 focusDailyPop 을 따른다', async () => {
await db().doc('config/policy').set({ focusDailyPop: 55 }, { merge: true });
await subscribe(Date.now() + 86_400_000);
expect((await claimFocus()).granted).toBe(55);
await db().doc('config/policy').set({ focusDailyPop: 40 }, { merge: true });
});
});
describe('검증 구현이 비어 있는 동안', () => {
it('콜러블도 지급 없이 E-PAY-02 로 끊는다', async () => {
setReceiptVerifier(new StoreReceiptVerifier());
expect(await codeOf(() => call(UID, {}))).toBe('E-PAY-02');
expect((await db().doc(`users/${UID}/wallet/main`).get()).exists).toBe(false);
});
});
+326
View File
@@ -0,0 +1,326 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { getFirestore } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
import {
grantPop, spendPop, refundPop, reconcileUser,
} from '../src/ledger/ledger.js';
/**
* POP 원장 검증 — 별첨 1 「조건부 적합 제외 항목 ② POP/FPI 데이터 정합성」.
*
* 이 파일이 통과하지 못하면 검수는 조건부 적합이 아니라 부적합이다. 그래서
* 「값이 맞는가」가 아니라 **「불변식이 깨지지 않는가」**를 본다.
*/
const UID = 'test-ledger-user';
beforeAll(() => { bootAdmin(); });
afterAll(async () => { await shutdownAdmin(); });
beforeEach(async () => { await wipeUser(UID); });
describe('지급', () => {
it('lot·원장·지갑을 한 번에 만든다', async () => {
const r = await grantPop({
uid: UID, popType: 'activity', amount: 100,
reasonCode: 'QUEST_ATTEND', idempotencyKey: 'attend:test:2026-09-01',
});
expect(r.balanceAfter).toBe(100);
const db = getFirestore();
const wallet = await db.doc(`users/${UID}/wallet/main`).get();
expect(wallet.get('activityBalance')).toBe(100);
const lots = await db.collection(`users/${UID}/popLots`).get();
expect(lots.size).toBe(1);
expect(lots.docs[0]!.get('remaining')).toBe(100);
expect(lots.docs[0]!.get('status')).toBe('active');
const tx = await db.doc(`users/${UID}/popTransactions/${r.txId}`).get();
expect(tx.get('amount')).toBe(100);
expect(tx.get('serverVerified')).toBe(true);
});
it('같은 멱등키로 두 번 불러도 한 번만 지급한다', async () => {
const key = 'attend:test:2026-09-02';
const first = await grantPop({
uid: UID, popType: 'activity', amount: 50,
reasonCode: 'QUEST_ATTEND', idempotencyKey: key,
});
const second = await grantPop({
uid: UID, popType: 'activity', amount: 50,
reasonCode: 'QUEST_ATTEND', idempotencyKey: key,
});
expect(second.txId).toBe(first.txId);
const db = getFirestore();
const wallet = await db.doc(`users/${UID}/wallet/main`).get();
expect(wallet.get('activityBalance')).toBe(50);
const txs = await db.collection(`users/${UID}/popTransactions`).get();
expect(txs.size).toBe(1);
});
it('활동 POP 과 지원 POP 의 잔액을 섞지 않는다', async () => {
await grantPop({
uid: UID, popType: 'activity', amount: 100,
reasonCode: 'QUEST_ATTEND', idempotencyKey: 'a:1',
});
await grantPop({
uid: UID, popType: 'support', amount: 300,
reasonCode: 'IAP_PURCHASE', idempotencyKey: 's:1',
});
const wallet = await getFirestore().doc(`users/${UID}/wallet/main`).get();
expect(wallet.get('activityBalance')).toBe(100);
expect(wallet.get('supportBalance')).toBe(300);
});
it('보류 지급은 잔액이 아니라 확정 대기에 쌓인다', async () => {
await grantPop({
uid: UID, popType: 'activity', amount: 70,
reasonCode: 'TRANSLATION', idempotencyKey: 'trn:1', pending: true,
});
const db = getFirestore();
const wallet = await db.doc(`users/${UID}/wallet/main`).get();
expect(wallet.get('activityBalance')).toBe(0);
expect(wallet.get('pendingActivity')).toBe(70);
const lots = await db.collection(`users/${UID}/popLots`).get();
expect(lots.docs[0]!.get('status')).toBe('pending');
});
});
describe('차감', () => {
it('잔액이 모자라면 E-POP-01 로 끊는다', async () => {
await expect(spendPop({
uid: UID, popType: 'activity', amount: 10,
reasonCode: 'CAMPAIGN_FUND', idempotencyKey: 'fund:none',
})).rejects.toMatchObject({ details: { code: 'E-POP-01' } });
});
it('원장에 음수로 남고 lot 이 줄어든다', async () => {
await grantPop({
uid: UID, popType: 'activity', amount: 100,
reasonCode: 'QUEST_ATTEND', idempotencyKey: 'g:1',
});
const r = await spendPop({
uid: UID, popType: 'activity', amount: 40,
reasonCode: 'CAMPAIGN_FUND', idempotencyKey: 'fund:1',
fandomId: 'fandom-a',
});
expect(r.balanceAfter).toBe(60);
const db = getFirestore();
const tx = await db.doc(`users/${UID}/popTransactions/${r.txId}`).get();
expect(tx.get('amount')).toBe(-40);
const lots = await db.collection(`users/${UID}/popLots`).get();
expect(lots.docs[0]!.get('remaining')).toBe(60);
// 기여 사유의 차감은 통계와 팬덤 멤버 기여도에 함께 반영된다.
const user = await db.doc(`users/${UID}`).get();
expect(user.get('totalContributedPop')).toBe(40);
// 기여 POP 문턱 등급은 폐기됐다(C-18). 후원 · Zap 은 1 POP 단위로 쪼개 반복할 수 있어
// EXP 를 주지 않는다(리뷰 B1) — EXP 는 Quest 인정 활동에서만 쌓인다.
expect(user.get('tier')).toBeUndefined();
expect(user.get('userExp')).toBeUndefined();
const member = await db.doc(`fandoms/fandom-a/members/${UID}`).get();
expect(member.get('contributedPop')).toBe(40);
});
it('lot 여러 개에 걸쳐 차감하고 소진된 lot 을 닫는다', async () => {
await grantPop({
uid: UID, popType: 'activity', amount: 30,
reasonCode: 'QUEST_ATTEND', idempotencyKey: 'g:a',
});
await grantPop({
uid: UID, popType: 'activity', amount: 30,
reasonCode: 'AD_REWARD', idempotencyKey: 'g:b',
});
const r = await spendPop({
uid: UID, popType: 'activity', amount: 45,
reasonCode: 'QNA_BOUNTY', idempotencyKey: 'bounty:1',
});
expect(r.balanceAfter).toBe(15);
expect(r.lotRefs).toHaveLength(2);
const lots = await getFirestore()
.collection(`users/${UID}/popLots`).orderBy('grantedAt').get();
const statuses = lots.docs.map((d) => d.get('status'));
expect(statuses).toContain('exhausted');
});
it('같은 멱등키의 재시도는 두 번 차감하지 않는다', async () => {
await grantPop({
uid: UID, popType: 'activity', amount: 100,
reasonCode: 'QUEST_ATTEND', idempotencyKey: 'g:2',
});
const key = 'fund:dup';
await spendPop({
uid: UID, popType: 'activity', amount: 25,
reasonCode: 'CAMPAIGN_FUND', idempotencyKey: key,
});
const again = await spendPop({
uid: UID, popType: 'activity', amount: 25,
reasonCode: 'CAMPAIGN_FUND', idempotencyKey: key,
});
expect(again.balanceAfter).toBe(75);
const wallet = await getFirestore().doc(`users/${UID}/wallet/main`).get();
expect(wallet.get('activityBalance')).toBe(75);
});
});
describe('환불', () => {
it('원 거래를 고치지 않고 반대 부호의 새 원장을 쓴다', async () => {
await grantPop({
uid: UID, popType: 'activity', amount: 100,
reasonCode: 'QUEST_ATTEND', idempotencyKey: 'g:3',
});
const spent = await spendPop({
uid: UID, popType: 'activity', amount: 60,
reasonCode: 'CAMPAIGN_FUND', idempotencyKey: 'fund:refundable',
});
const refunded = await refundPop({
uid: UID, originalTxId: spent.txId,
idempotencyKey: `refund:${spent.txId}`, reasonCode: 'CAMPAIGN_REFUND',
});
expect(refunded.balanceAfter).toBe(100);
const db = getFirestore();
const original = await db.doc(`users/${UID}/popTransactions/${spent.txId}`).get();
// 금액은 그대로 남아 있어야 한다 — append-only.
expect(original.get('amount')).toBe(-60);
expect(original.get('refundedBy')).toBe(refunded.txId);
const user = await db.doc(`users/${UID}`).get();
expect(user.get('totalContributedPop')).toBe(0);
});
it('같은 거래를 두 번 환불하지 않는다', async () => {
await grantPop({
uid: UID, popType: 'activity', amount: 100,
reasonCode: 'QUEST_ATTEND', idempotencyKey: 'g:4',
});
const spent = await spendPop({
uid: UID, popType: 'activity', amount: 60,
reasonCode: 'QNA_BOUNTY', idempotencyKey: 'bounty:2',
});
await refundPop({
uid: UID, originalTxId: spent.txId,
idempotencyKey: 'refund:once', reasonCode: 'QNA_REFUND',
});
await expect(refundPop({
uid: UID, originalTxId: spent.txId,
idempotencyKey: 'refund:twice', reasonCode: 'QNA_REFUND',
})).rejects.toMatchObject({ details: { code: 'E-POP-03' } });
});
});
describe('대사 — 별첨 1 ② 3자 교차 대조', () => {
it('정상 거래 뒤에는 잔액·lot·원장이 모두 맞는다', async () => {
await grantPop({
uid: UID, popType: 'activity', amount: 100,
reasonCode: 'QUEST_ATTEND', idempotencyKey: 'g:5',
});
await spendPop({
uid: UID, popType: 'activity', amount: 30,
reasonCode: 'CAMPAIGN_FUND', idempotencyKey: 'fund:2',
});
const row = await reconcileUser(UID);
expect(row.ok).toBe(true);
const wallet = await getFirestore().doc(`users/${UID}/wallet/main`).get();
expect(wallet.get('reconcileStatus')).toBe('ok');
});
it('레벨과 Passport 위조를 서로 오염시키지 않고 따로 잡아낸다', async () => {
await grantPop({
uid: UID, popType: 'activity', amount: 5_000,
reasonCode: 'ADMIN_GRANT', idempotencyKey: 'g:rec-tier',
});
await spendPop({
uid: UID, popType: 'activity', amount: 3_000,
reasonCode: 'CAMPAIGN_FUND', idempotencyKey: 'fund:rec-tier',
});
const failedNames = async () => (await reconcileUser(UID)).checks
.filter((c) => c.expected !== c.actual).map((c) => c.name);
expect(await failedNames()).toEqual([]);
// 원장을 거치지 않은 레벨 위조.
await getFirestore().doc(`users/${UID}`).set({ userLevel: 50 }, { merge: true });
expect(await failedNames()).toEqual(['level-vs-exp']);
// 레벨을 되돌리고 EXP 를 Lv.30 이상으로 올렸는데 Passport 가 SILVER 로 남은 상태(강등 · 누락).
await getFirestore().doc(`users/${UID}`).set({
userExp: 500_000, userLevel: 31, passportTier: 'SILVER',
}, { merge: true });
expect(await failedNames()).toEqual(['passport-monotonic']);
});
it('지갑을 몰래 고치면 대사가 잡아낸다', async () => {
await grantPop({
uid: UID, popType: 'activity', amount: 100,
reasonCode: 'QUEST_ATTEND', idempotencyKey: 'g:6',
});
// 원장을 거치지 않은 잔액 변경 — 정확히 이것을 잡아내야 한다.
await getFirestore().doc(`users/${UID}/wallet/main`)
.set({ activityBalance: 999 }, { merge: true });
const row = await reconcileUser(UID);
expect(row.ok).toBe(false);
const failed = row.checks.filter((c) => c.expected !== c.actual);
expect(failed.length).toBeGreaterThan(0);
const wallet = await getFirestore().doc(`users/${UID}/wallet/main`).get();
// 자동으로 고치지 않는다 — 감지하고 표시만 한다(§3.6).
expect(wallet.get('activityBalance')).toBe(999);
expect(wallet.get('reconcileStatus')).toBe('mismatch');
});
});
describe('User EXP · Level · Passport 갱신 (C-18 · B-65)', () => {
it('기여 차감은 EXP · 등급을 만들지 않는다 · 기여 POP 문턱 승급 없음', async () => {
await grantPop({
uid: UID, popType: 'activity', amount: 5_000,
reasonCode: 'ADMIN_GRANT', idempotencyKey: 'g:tier',
});
await spendPop({
uid: UID, popType: 'activity', amount: 3_000,
reasonCode: 'CAMPAIGN_FUND', idempotencyKey: 'fund:tier',
});
const user = await getFirestore().doc(`users/${UID}`).get();
expect(user.get('totalContributedPop')).toBe(3_000);
// 3,000 POP 후원도 EXP 를 만들지 않는다(C-18 기여 POP 치환 금지 · 리뷰 B1).
expect(user.get('userExp')).toBeUndefined();
expect(user.get('passportTier')).toBeUndefined();
expect(user.get('tier')).toBeUndefined();
});
it('환불은 기여 통계만 되돌리고 EXP · Passport 는 내리지 않는다', async () => {
await getFirestore().doc(`users/${UID}`).set({ passportTier: 'GOLD' }, { merge: true });
await grantPop({
uid: UID, popType: 'activity', amount: 5_000,
reasonCode: 'ADMIN_GRANT', idempotencyKey: 'g:tier2',
});
const spent = await spendPop({
uid: UID, popType: 'activity', amount: 3_000,
reasonCode: 'CAMPAIGN_FUND', idempotencyKey: 'fund:tier2',
});
await refundPop({
uid: UID, originalTxId: spent.txId,
idempotencyKey: 'refund:tier2', reasonCode: 'CAMPAIGN_REFUND',
});
const user = await getFirestore().doc(`users/${UID}`).get();
expect(user.get('totalContributedPop')).toBe(0);
expect(user.get('userExp')).toBeUndefined();
// 레벨이 낮아도 이미 GOLD 였으므로 강등하지 않는다.
expect(user.get('passportTier')).toBe('GOLD');
});
it('기여가 아닌 차감은 EXP 를 건드리지 않는다', async () => {
await grantPop({
uid: UID, popType: 'support', amount: 1_000,
reasonCode: 'IAP_PURCHASE', idempotencyKey: 'g:tier3',
});
await spendPop({
uid: UID, popType: 'support', amount: 500,
reasonCode: 'HERO_DROP_TRIGGER', idempotencyKey: 'spend:tier3',
});
const user = await getFirestore().doc(`users/${UID}`).get();
expect(user.get('userExp')).toBeUndefined();
});
});
@@ -0,0 +1,195 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { getFirestore } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
import {
grantPop, reconcileUser, refundPop, refundSplit, spendSplit, type PopType,
} from '../src/ledger/ledger.js';
/**
* 겸용 사용처 분할 차감 — 정책 원장 B-06 · 설계서 §3.2.
* 일반(activity) 우선, 부족분만 기여(support). 두 재화면 원장 2건 · 한 트랜잭션 · 멱등키 하나.
*/
const UID = 'test-ledger-split';
const db = () => getFirestore();
beforeAll(() => { bootAdmin(); });
afterAll(async () => { await shutdownAdmin(); });
beforeEach(async () => {
await wipeUser(UID);
await db().recursiveDelete(db().doc('fandoms/f-split'));
});
async function seed(general: number, contribution: number) {
if (general > 0) {
await grantPop({ uid: UID, popType: 'activity', amount: general, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'seed:g' });
}
if (contribution > 0) {
await grantPop({ uid: UID, popType: 'support', amount: contribution, reasonCode: 'IAP_PURCHASE', idempotencyKey: 'seed:c' });
}
}
async function wallet() {
const w = await db().doc(`users/${UID}/wallet/main`).get();
return { general: w.get('activityBalance') as number, contribution: w.get('supportBalance') as number };
}
async function spendRows() {
const snap = await db().collection(`users/${UID}/popTransactions`).where('type', '==', 'spend').get();
return snap.docs.map((d) => ({ popType: d.get('popType') as PopType, amount: d.get('amount') as number }))
.sort((a, b) => a.popType.localeCompare(b.popType));
}
const spend = (amount: number, key = 'fee:1') =>
spendSplit({ uid: UID, amount, reasonCode: 'CAMPAIGN_FEE', idempotencyKey: key });
describe('spendSplit', () => {
it('일반 잔액이 딱 맞으면 일반 1건만', async () => {
await seed(100, 50);
const parts = await spend(100);
expect(parts.map((p) => [p.popType, p.amount])).toEqual([['activity', 100]]);
expect(await wallet()).toEqual({ general: 0, contribution: 50 });
expect(await spendRows()).toEqual([{ popType: 'activity', amount: -100 }]);
});
it('일반만 있으면 일반에서', async () => {
await seed(300, 0);
await spend(120);
expect((await wallet()).general).toBe(180);
expect(await spendRows()).toEqual([{ popType: 'activity', amount: -120 }]);
});
it('기여만 있으면 기여에서', async () => {
await seed(0, 300);
const parts = await spend(120);
expect(parts.map((p) => p.popType)).toEqual(['support']);
expect((await wallet()).contribution).toBe(180);
expect(await spendRows()).toEqual([{ popType: 'support', amount: -120 }]);
});
it('섞이면 일반을 다 쓰고 부족분만 기여 — 원장 2건 · 같은 멱등키 · lotOrder 규범', async () => {
await seed(30, 200);
const parts = await spend(100);
expect(parts.map((p) => [p.popType, p.amount, p.balanceAfter]))
.toEqual([['activity', 30, 0], ['support', 70, 130]]);
expect(await wallet()).toEqual({ general: 0, contribution: 130 });
expect(await spendRows()).toEqual([
{ popType: 'activity', amount: -30 }, { popType: 'support', amount: -70 },
]);
const rows = await db().collection(`users/${UID}/popTransactions`).where('type', '==', 'spend').get();
for (const r of rows.docs) {
expect(r.get('idempotencyKey')).toBe('fee:1');
expect(r.get('lotOrder')).toBe('general-first-fifo');
}
expect((await reconcileUser(UID)).ok).toBe(true);
});
it('합계가 모자라면 E-POP-01 이고 원장 행이 없다', async () => {
await seed(30, 20);
await expect(spend(100)).rejects.toMatchObject({ details: { code: 'E-POP-01' } });
expect(await spendRows()).toEqual([]);
expect(await wallet()).toEqual({ general: 30, contribution: 20 });
});
it('같은 멱등키 재시도는 어느 행도 두 번 쓰지 않는다(동시 포함)', async () => {
await seed(30, 200);
const first = await spend(100);
const [again, ...racing] = await Promise.all([spend(100), spend(100), spend(100)]);
expect(again!.map((p) => p.txId).sort()).toEqual(first.map((p) => p.txId).sort());
for (const r of racing) expect(r.length).toBe(2);
expect(await spendRows()).toHaveLength(2);
expect(await wallet()).toEqual({ general: 0, contribution: 130 });
});
it('기여 POP 으로 낸 예치 · 등록비는 성장 · FPI 원천이 아니다(C-30)', async () => {
await db().doc(`users/${UID}`).set({ fandomId: 'f-split', primaryFandomId: 'f-split' });
await seed(0, 500);
await spend(100, 'fee:growth');
await spendSplit({ uid: UID, amount: 100, reasonCode: 'QNA_BOUNTY', idempotencyKey: 'bounty:growth' });
expect((await db().collection(`users/${UID}/growthEvents`).get()).size).toBe(0);
expect((await db().collection('fandoms/f-split/fpiEvents').get()).size).toBe(0);
expect((await db().doc('fandoms/f-split').get()).get('growthPop')).toBeUndefined();
});
});
describe('lot 부족', () => {
it('기여 몫의 lot 이 모자라면 E-POP-02 · 행 0건 · 지갑은 트랜잭션 밖에서 mismatch 표시', async () => {
await seed(30, 200);
const lot = (await db().collection(`users/${UID}/popLots`).where('popType', '==', 'support').get()).docs[0]!;
await lot.ref.update({ remaining: 10 });
await expect(spend(100)).rejects.toMatchObject({ details: { code: 'E-POP-02' } });
expect(await spendRows()).toEqual([]);
const w = await db().doc(`users/${UID}/wallet/main`).get();
expect(w.get('reconcileStatus')).toBe('mismatch');
expect(w.get('activityBalance')).toBe(30);
const general = (await db().collection(`users/${UID}/popLots`).where('popType', '==', 'activity').get()).docs[0]!;
expect(general.get('remaining')).toBe(30);
});
});
describe('refundSplit', () => {
it('섞인 차감을 행마다 원래 타입으로 돌리고 재시도는 한 번뿐이다', async () => {
await seed(30, 200);
const parts = await spend(100);
const refund = () => refundSplit({
uid: UID, originalTxIds: parts.map((p) => p.txId),
idempotencyKey: 'feeRefund:1', reasonCode: 'CAMPAIGN_REFUND',
});
const first = await refund();
const again = await refund();
expect(again.map((r) => r.txId).sort()).toEqual(first.map((r) => r.txId).sort());
expect(await wallet()).toEqual({ general: 30, contribution: 200 });
const refunds = await db().collection(`users/${UID}/popTransactions`).where('type', '==', 'refund').get();
expect(refunds.docs.map((d) => [d.get('popType'), d.get('amount')]).sort())
.toEqual([['activity', 30], ['support', 70]]);
for (const p of parts) {
expect((await db().doc(`users/${UID}/popTransactions/${p.txId}`).get()).get('refundedBy')).toBeTruthy();
}
expect((await reconcileUser(UID)).ok).toBe(true);
});
it('이미 다른 경로로 환불된 행은 그 환불을 쓰고, 같은 원거래 ID 는 한 번만 센다', async () => {
await seed(30, 200);
const parts = await spend(100);
const [general, contribution] = parts.map((p) => p.txId);
const earlier = await refundPop({
uid: UID, originalTxId: contribution!, idempotencyKey: 'other-path', reasonCode: 'CAMPAIGN_REFUND',
});
const results = await refundSplit({
uid: UID, originalTxIds: [general!, contribution!, general!],
idempotencyKey: 'feeRefund:reuse', reasonCode: 'CAMPAIGN_FEE_REFUND',
});
expect(results).toHaveLength(2);
expect(results[1]!.txId).toBe(earlier.txId);
expect(await wallet()).toEqual({ general: 30, contribution: 200 });
const refunds = await db().collection(`users/${UID}/popTransactions`).where('type', '==', 'refund').get();
expect(refunds.size).toBe(2);
expect((await reconcileUser(UID)).ok).toBe(true);
});
it('QNA_BOUNTY 분할 예치 → 환불 → 대사가 맞는다(누적 기여도 0 으로 돌아온다)', async () => {
await seed(30, 200);
const parts = await spendSplit({ uid: UID, amount: 100, reasonCode: 'QNA_BOUNTY', idempotencyKey: 'bounty:mixed' });
expect((await db().doc(`users/${UID}`).get()).get('totalContributedPop')).toBe(100);
expect((await reconcileUser(UID)).ok).toBe(true);
await refundSplit({
uid: UID, originalTxIds: parts.map((p) => p.txId), idempotencyKey: 'qnarefund:mixed', reasonCode: 'QNA_REFUND',
});
expect((await db().doc(`users/${UID}`).get()).get('totalContributedPop')).toBe(0);
expect(await wallet()).toEqual({ general: 30, contribution: 200 });
expect((await reconcileUser(UID)).ok).toBe(true);
});
it('원거래 하나라도 없으면 아무 행도 쓰지 않는다', async () => {
await seed(30, 200);
const parts = await spend(100);
await expect(refundSplit({
uid: UID, originalTxIds: [parts[0]!.txId, 'missing'],
idempotencyKey: 'feeRefund:bad', reasonCode: 'CAMPAIGN_REFUND',
})).rejects.toMatchObject({ details: { code: 'E-POP-02' } });
expect((await db().collection(`users/${UID}/popTransactions`).where('type', '==', 'refund').get()).size).toBe(0);
expect(await wallet()).toEqual({ general: 0, contribution: 130 });
});
});
+168
View File
@@ -0,0 +1,168 @@
import { afterAll, beforeAll, expect, it, vi } from 'vitest';
import { getFirestore, Timestamp } from 'firebase-admin/firestore';
import {
cleanupCampaignComment, cleanupPost, fnCampaignCommentCount, fnCampaignCommentLikeCount, fnPostCommentCount, fnPostLikeCount, recountPost,
} from '../src/inside/lounge.js';
import { bootAdmin, shutdownAdmin } from './support.js';
/** 라운지 좋아요 · 댓글 수는 하위 문서를 다시 세어 적는다 — 재전달에도 부풀지 않는다. */
const POST = 'test-lounge-post';
beforeAll(() => { bootAdmin(); });
afterAll(async () => {
const db = getFirestore();
for (const id of [POST, 'test-lounge-gone', 'test-lounge-stale', 'test-lounge-del']) {
await db.recursiveDelete(db.doc(`posts/${id}`));
}
await db.recursiveDelete(db.doc('campaigns/test-cmp-comments'));
await shutdownAdmin();
});
/** 트리거 이벤트 모양. `before`/`after` 는 문서 스냅샷 흉내만 낸다. */
function written(document: string, params: Record<string, string>, data?: unknown) {
return {
specversion: '1.0', id: `e-${Math.random()}`, source: 'firestore',
type: 'google.cloud.firestore.document.v1.written', time: new Date().toISOString(),
data, params, location: 'asia-northeast3', project: 'demo-fanuzu',
database: '(default)', namespace: '', document,
} as never;
}
const snap = (fields: Record<string, unknown> | null) => ({
exists: fields !== null,
get: (k: string) => fields?.[k],
});
it('좋아요 수는 몇 번을 돌아도 하위 문서 수와 같다', async () => {
const db = getFirestore();
await db.doc(`posts/${POST}`).set({ likeCount: 99, commentCount: 99 });
for (const uid of ['a', 'b', 'c']) {
await db.doc(`posts/${POST}/likes/${uid}`).set({ createdAt: new Date() });
}
await recountPost(POST, 'likes');
await recountPost(POST, 'likes');
expect((await db.doc(`posts/${POST}`).get()).get('likeCount')).toBe(3);
await db.doc(`posts/${POST}/likes/a`).delete();
// 트리거 배선 — 경로 파라미터로 글을 찾는다.
await fnPostLikeCount.run(written(`posts/${POST}/likes/a`, { postId: POST, uid: 'a' }));
expect((await db.doc(`posts/${POST}`).get()).get('likeCount')).toBe(2);
});
it('먼저 센 값이 나중에 센 값을 덮지 않는다', async () => {
const db = getFirestore();
const ref = db.doc('posts/test-lounge-stale');
// 이미 「미래」 시점에 센 값이 적혀 있다 — 지금 센 값은 그보다 오래됐다.
await ref.set({ likeCount: 7, likeCountAt: Timestamp.fromMillis(Date.now() + 60_000) });
await db.doc('posts/test-lounge-stale/likes/a').set({ createdAt: new Date() });
await recountPost('test-lounge-stale', 'likes');
expect((await ref.get()).get('likeCount')).toBe(7);
});
it('댓글 수는 보이는 댓글만 세고, 본문만 고친 수정은 다시 세지 않는다', async () => {
const db = getFirestore();
await db.doc(`posts/${POST}/comments/c1`).set({ visibility: 'visible' });
await db.doc(`posts/${POST}/comments/c2`).set({ visibility: 'visible' });
await db.doc(`posts/${POST}/comments/c3`).set({ visibility: 'hidden' });
await recountPost(POST, 'comments');
expect((await db.doc(`posts/${POST}`).get()).get('commentCount')).toBe(2);
await db.doc(`posts/${POST}`).update({ commentCount: 42 });
const params = { postId: POST, commentId: 'c1' };
const doc = `posts/${POST}/comments/c1`;
await fnPostCommentCount.run(written(doc, params, {
before: snap({ visibility: 'visible', body: 'a' }),
after: snap({ visibility: 'visible', body: 'b' }),
}));
expect((await db.doc(`posts/${POST}`).get()).get('commentCount')).toBe(42);
// 숨김 전환은 다시 센다.
await db.doc(`posts/${POST}/comments/c2`).update({ visibility: 'hidden' });
await fnPostCommentCount.run(written(doc, { postId: POST, commentId: 'c2' }, {
before: snap({ visibility: 'visible' }),
after: snap({ visibility: 'hidden' }),
}));
expect((await db.doc(`posts/${POST}`).get()).get('commentCount')).toBe(1);
});
it('지워진 글은 다시 만들지 않는다', async () => {
const db = getFirestore();
await db.doc('posts/test-lounge-gone/likes/a').set({ createdAt: new Date() });
await recountPost('test-lounge-gone', 'likes');
expect((await db.doc('posts/test-lounge-gone').get()).exists).toBe(false);
});
it('글이 지워지면 좋아요 · 댓글과 작성자의 그 글 사진을 지운다', async () => {
const db = getFirestore();
await db.doc('posts/test-lounge-del/likes/a').set({ createdAt: new Date() });
await db.doc('posts/test-lounge-del/comments/c').set({ visibility: 'visible' });
const deleteFiles = vi.fn(async () => {});
await cleanupPost('test-lounge-del', 'alice', deleteFiles);
expect((await db.collection('posts/test-lounge-del/likes').get()).size).toBe(0);
expect((await db.collection('posts/test-lounge-del/comments').get()).size).toBe(0);
expect(deleteFiles).toHaveBeenCalledWith('users/alice/lounge/test-lounge-del-');
});
it('캠페인 응원 게시판 댓글 수도 보이는 댓글만 다시 세어 캠페인 문서에 적는다', async () => {
const db = getFirestore();
const cmp = 'campaigns/test-cmp-comments';
await db.doc(cmp).set({ status: 'funding', commentCount: 99 });
await db.doc(`${cmp}/comments/a`).set({ visibility: 'visible' });
await db.doc(`${cmp}/comments/b`).set({ visibility: 'hidden' });
const params = { campaignId: 'test-cmp-comments', commentId: 'a' };
await fnCampaignCommentCount.run(written(`${cmp}/comments/a`, params, {
before: snap(null),
after: snap({ visibility: 'visible' }),
}));
expect((await db.doc(cmp).get()).get('commentCount')).toBe(1);
// 본문만 고친 수정은 다시 세지 않는다.
await db.doc(cmp).update({ commentCount: 42 });
await fnCampaignCommentCount.run(written(`${cmp}/comments/a`, params, {
before: snap({ visibility: 'visible', body: 'x' }),
after: snap({ visibility: 'visible', body: 'y' }),
}));
expect((await db.doc(cmp).get()).get('commentCount')).toBe(42);
// 삭제는 다시 센다.
await db.doc(`${cmp}/comments/a`).delete();
await fnCampaignCommentCount.run(written(`${cmp}/comments/a`, params, {
before: snap({ visibility: 'visible' }),
after: snap(null),
}));
expect((await db.doc(cmp).get()).get('commentCount')).toBe(0);
});
it('응원 게시판 댓글 좋아요 수는 댓글 문서에 다시 세어 적고 댓글 수는 건드리지 않는다', async () => {
const db = getFirestore();
const cmp = 'campaigns/test-cmp-comments';
const comment = `${cmp}/comments/liked`;
await db.doc(cmp).set({ status: 'funding', commentCount: 5 });
await db.doc(comment).set({ visibility: 'visible', likeCount: 99 });
for (const uid of ['a', 'b']) {
await db.doc(`${comment}/likes/${uid}`).set({ createdAt: new Date() });
}
const params = { campaignId: 'test-cmp-comments', commentId: 'liked', uid: 'a' };
await fnCampaignCommentLikeCount.run(written(`${comment}/likes/a`, params));
await fnCampaignCommentLikeCount.run(written(`${comment}/likes/a`, params));
expect((await db.doc(comment).get()).get('likeCount')).toBe(2);
expect((await db.doc(cmp).get()).get('commentCount')).toBe(5);
});
it('응원 게시판 댓글이 지워지면 하위 좋아요를 지우고 댓글 문서를 되살리지 않는다', async () => {
const db = getFirestore();
const comment = 'campaigns/test-cmp-comments/comments/gone';
await db.doc(comment).set({ visibility: 'visible', likeCount: 2 });
for (const uid of ['a', 'b']) await db.doc(`${comment}/likes/${uid}`).set({ createdAt: new Date() });
await db.doc(comment).delete();
await cleanupCampaignComment('test-cmp-comments', 'gone');
await cleanupCampaignComment('test-cmp-comments', 'gone');
expect((await db.collection(`${comment}/likes`).get()).size).toBe(0);
// 좋아요 삭제로 뒤늦게 도는 수 트리거도 없는 댓글을 만들지 않는다.
await fnCampaignCommentLikeCount.run(written(`${comment}/likes/a`, {
campaignId: 'test-cmp-comments', commentId: 'gone', uid: 'a',
}));
expect((await db.doc(comment).get()).exists).toBe(false);
});
+175
View File
@@ -0,0 +1,175 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { getFirestore } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
import { grantPop, spendPop } from '../src/ledger/ledger.js';
import {
popForProduct, PRODUCT_POP, SUBSCRIPTION_PRODUCTS,
type Platform, type ReceiptClaim, type ReceiptVerifier,
} from '../src/payment/receipt.js';
import { StoreReceiptVerifier } from '../src/payment/receipt.js';
/**
* 결제 검증 — 별첨 1 ⑥ 「영수증 서버검증 통과(위조 차단)」.
*
* **스토어 왕복은 여기서 재지 않는다.** 자격증명이 스토어 계정에 묶여 있어
* 샌드박스 실행은 계정이 열린 뒤다. 대신 **금액 결정 · 멱등 · 환불 회수**를
* 지금 검증한다 — 계정이 열려도 이 세 가지가 틀리면 검수를 통과하지 못한다.
*/
const UID = 'test-pay-user';
class FakeVerifier implements ReceiptVerifier {
constructor(private readonly claim: Partial<ReceiptClaim> = {}) {}
async verify(platform: Platform, _receipt: string): Promise<ReceiptClaim> {
return {
platform,
transactionId: 'tx-1',
productId: 'pop.support.5500',
consumable: true,
purchasedAtMillis: 1_788_000_000_000,
revoked: false,
...this.claim,
};
}
}
beforeAll(() => { bootAdmin(); });
afterAll(async () => { await shutdownAdmin(); });
beforeEach(async () => { await wipeUser(UID); });
describe('상품 표 — 금액은 서버가 정한다', () => {
it('등록된 상품만 지급 수량을 낸다', () => {
expect(popForProduct('pop.support.5500')).toBe(5_500);
});
it('표에 없는 상품 ID 는 E-PAY-01 로 끊는다', () => {
// 위조이거나 스토어 상품 등록과 코드가 어긋난 상태다. 둘 다 지급하면 안 된다.
expect(() => popForProduct('pop.support.999999'))
.toThrowError(expect.objectContaining({ details: { code: 'E-PAY-01' } }));
});
it('구독 상품은 POP 표에 없다', () => {
for (const product of SUBSCRIPTION_PRODUCTS) {
expect(PRODUCT_POP[product]).toBeUndefined();
}
});
it('모든 상품 수량이 양의 정수다', () => {
for (const amount of Object.values(PRODUCT_POP)) {
expect(Number.isInteger(amount)).toBe(true);
expect(amount).toBeGreaterThan(0);
}
});
});
describe('검증 구현이 비어 있는 동안', () => {
it('자격증명 없이 부르면 지급하지 않고 E-PAY-02 로 끊는다', async () => {
// 「나중에 채우자」로 비워 두면 검증 없는 지급 경로가 열린 채 배포된다.
await expect(new StoreReceiptVerifier().verify('ios', 'receipt'))
.rejects.toMatchObject({ details: { code: 'E-PAY-02' } });
});
});
describe('지급 — 스토어 거래 ID 가 멱등키다', () => {
it('같은 거래로 두 번 지급하지 않는다', async () => {
const claim = await new FakeVerifier().verify('ios', 'r');
const key = `iap:${claim.platform}:${claim.transactionId}`;
const first = await grantPop({
uid: UID, popType: 'support', amount: popForProduct(claim.productId),
reasonCode: 'IAP_PURCHASE', idempotencyKey: key,
});
const second = await grantPop({
uid: UID, popType: 'support', amount: popForProduct(claim.productId),
reasonCode: 'IAP_PURCHASE', idempotencyKey: key,
});
expect(second.txId).toBe(first.txId);
const wallet = await getFirestore().doc(`users/${UID}/wallet/main`).get();
expect(wallet.get('supportBalance')).toBe(5_500);
});
it('플랫폼이 다르면 다른 거래로 본다', async () => {
const ios = await new FakeVerifier().verify('ios', 'r');
const android = await new FakeVerifier().verify('android', 'r');
expect(`iap:${ios.platform}:${ios.transactionId}`)
.not.toBe(`iap:${android.platform}:${android.transactionId}`);
});
it('지원 POP 으로만 들어가고 활동 POP 은 그대로다', async () => {
await grantPop({
uid: UID, popType: 'support', amount: 5_500,
reasonCode: 'IAP_PURCHASE', idempotencyKey: 'iap:ios:tx-a',
});
const wallet = await getFirestore().doc(`users/${UID}/wallet/main`).get();
expect(wallet.get('supportBalance')).toBe(5_500);
expect(wallet.get('activityBalance')).toBe(0);
});
});
describe('환불 회수 — 별첨 1 ⑥ 예외 처리', () => {
it('잔액이 남아 있으면 회수한다', async () => {
await grantPop({
uid: UID, popType: 'support', amount: 5_500,
reasonCode: 'IAP_PURCHASE', idempotencyKey: 'iap:ios:tx-b',
});
const clawed = await spendPop({
uid: UID, popType: 'support', amount: 5_500,
reasonCode: 'ADMIN_CLAWBACK', idempotencyKey: 'clawback:ios:tx-b',
});
expect(clawed.balanceAfter).toBe(0);
});
it('이미 써 버려 잔액이 모자라면 음수 잔액을 만들지 않는다', async () => {
await grantPop({
uid: UID, popType: 'support', amount: 5_500,
reasonCode: 'IAP_PURCHASE', idempotencyKey: 'iap:ios:tx-c',
});
await spendPop({
uid: UID, popType: 'support', amount: 5_000,
reasonCode: 'CAMPAIGN_FUND', idempotencyKey: 'fund:pay-1',
});
// 사용자는 쓴 적 없는 마이너스를 보면 안 되고, 원장 합과 잔액이 어긋나서도 안 된다.
await expect(spendPop({
uid: UID, popType: 'support', amount: 5_500,
reasonCode: 'ADMIN_CLAWBACK', idempotencyKey: 'clawback:ios:tx-c',
})).rejects.toMatchObject({ details: { code: 'E-POP-01' } });
const wallet = await getFirestore().doc(`users/${UID}/wallet/main`).get();
expect(wallet.get('supportBalance')).toBe(500);
});
it('회수도 멱등이다 — 스토어 알림은 중복 도착한다', async () => {
await grantPop({
uid: UID, popType: 'support', amount: 5_500,
reasonCode: 'IAP_PURCHASE', idempotencyKey: 'iap:ios:tx-d',
});
const key = 'clawback:ios:tx-d';
await spendPop({
uid: UID, popType: 'support', amount: 5_500,
reasonCode: 'ADMIN_CLAWBACK', idempotencyKey: key,
});
const again = await spendPop({
uid: UID, popType: 'support', amount: 5_500,
reasonCode: 'ADMIN_CLAWBACK', idempotencyKey: key,
});
expect(again.balanceAfter).toBe(0);
});
it('회수는 기여 통계를 건드리지 않는다', async () => {
await grantPop({
uid: UID, popType: 'support', amount: 5_500,
reasonCode: 'IAP_PURCHASE', idempotencyKey: 'iap:ios:tx-e',
});
await spendPop({
uid: UID, popType: 'support', amount: 5_500,
reasonCode: 'ADMIN_CLAWBACK', idempotencyKey: 'clawback:ios:tx-e',
});
const user = await getFirestore().doc(`users/${UID}`).get();
// 결제 회수는 팬덤 기여가 아니다.
expect(user.get('totalContributedPop')).toBeUndefined();
});
});
@@ -0,0 +1,240 @@
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import { FieldValue, getFirestore, Timestamp } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
import {
fnFandomJoin, fnFandomLeave, fnNebulaCreate, fnPlanetCreate, fnPrimaryFandomSet, isValidNebulaName,
} from '../src/account/account.js';
/** 성운 생성 → 가입 · 대표 팬덤 → 행성 탄생 — 원장 B-08 · B-09 · B-10 · C-15 · C-50. */
const UID = 'test-planet-create';
const OTHER = 'test-planet-create-other';
const db = () => getFirestore();
/** 이 파일이 만든 것만 지운다 — 같은 에뮬레이터를 쓰는 다른 테스트의 문서를 건드리지 않는다. */
const created = { fandoms: new Set<string>(), names: new Set<string>() };
async function call(fn: { run(request: never): unknown }, data: Record<string, unknown>, uid = UID) {
const out = (await fn.run({ data, auth: { uid, token: {} } } as unknown as never)) as Record<string, unknown>;
if (fn === fnNebulaCreate) {
created.fandoms.add(out.fandomId as string);
created.names.add(String(data.name).toLowerCase());
}
return out;
}
async function codeOf(run: () => Promise<unknown>): Promise<string> {
try {
await run();
} catch (error) {
return (error as { details?: { code?: string } }).details?.code ?? (error as { code?: string }).code ?? '?';
}
throw new Error('실패해야 하는 호출이 성공했습니다.');
}
const user = async (uid = UID) => (await db().doc(`users/${uid}`).get()).data() ?? {};
const fandom = async (id: string) => (await db().doc(`fandoms/${id}`).get()).data() ?? {};
beforeAll(() => { bootAdmin(); });
afterAll(async () => { await shutdownAdmin(); });
afterEach(async () => {
for (const uid of [UID, OTHER]) {
await wipeUser(uid);
await db().recursiveDelete(db().collection(`users/${uid}/nebulaCreates`));
await db().recursiveDelete(db().collection(`users/${uid}/memberships`));
await db().doc(`users/${uid}`).delete();
}
for (const id of created.fandoms) await db().recursiveDelete(db().doc(`fandoms/${id}`));
for (const name of created.names) await db().doc(`fandomNames/${name}`).delete();
created.fandoms.clear();
created.names.clear();
});
describe('성운 생성', () => {
it('Stage0 공용 성운을 만들고 만든 사람이 창립 멤버 #001 · 대표 팬덤이 된다', async () => {
const { fandomId, primaryChanged } = await call(fnNebulaCreate, { name: 'BTS RM' });
const f = await fandom(fandomId as string);
expect(f).toMatchObject({ name: 'BTS RM', stage: 0, founderUid: UID, memberCount: 1, growthPop: 0 });
expect(f.nebulaDeadline.toMillis() - Date.now()).toBeGreaterThan(6.9 * 86_400_000);
expect((await db().doc(`fandoms/${fandomId}/members/${UID}`).get()).get('memberNo')).toBe(1);
expect((await db().doc(`users/${UID}/memberships/${fandomId}`).get()).exists).toBe(true);
expect(primaryChanged).toBe(true);
expect(await user()).toMatchObject({ primaryFandomId: fandomId, fandomId });
});
it('대표 변경 30일 대기 중이면 성운 · 멤버십만 만들고 대표는 그대로 둔다(C-15)', async () => {
await db().doc(`users/${UID}`).set({
primaryFandomId: 'old', fandomId: 'old', primaryChangedAt: Timestamp.fromMillis(Date.now() - 86_400_000),
});
const { fandomId, primaryChanged } = await call(fnNebulaCreate, { name: 'Cooldown' });
expect(primaryChanged).toBe(false);
expect(await user()).toMatchObject({ primaryFandomId: 'old', fandomId: 'old' });
expect((await db().doc(`fandoms/${fandomId}/members/${UID}`).get()).exists).toBe(true);
});
it('같은 이름(대소문자 무시)은 E-FDM-01 · 하루 두 번째 생성은 E-FDM-02', async () => {
await call(fnNebulaCreate, { name: 'Aurora' });
expect(await codeOf(() => call(fnNebulaCreate, { name: 'aurora' }, OTHER))).toBe('E-FDM-01');
expect(await codeOf(() => call(fnNebulaCreate, { name: 'Borealis' }))).toBe('E-FDM-02');
});
it('이름 규칙 — 2~20자 · 한글/영문/숫자/공백 · 연속·앞뒤 공백 불가', () => {
expect(isValidNebulaName('방탄 소년단1')).toBe(true);
for (const bad of ['A', ' AB', 'AB ', 'A B', 'AB!', 'x'.repeat(21)]) {
expect(isValidNebulaName(bad)).toBe(false);
}
});
});
describe('가입 · 탈퇴 · 대표 팬덤', () => {
it('두 번 가입해도 한 번만 센다 · 탈퇴도 한 번만 내린다 · 첫 가입 팬덤이 대표가 된다', async () => {
const { fandomId } = await call(fnNebulaCreate, { name: 'Joinable' });
const id = fandomId as string;
expect((await call(fnFandomJoin, { fandomId: id }, OTHER)).joined).toBe(true);
expect((await call(fnFandomJoin, { fandomId: id }, OTHER)).joined).toBe(false);
expect(await fandom(id)).toMatchObject({ memberCount: 2, memberSeq: 2 });
expect((await db().doc(`fandoms/${id}/members/${OTHER}`).get()).get('memberNo')).toBe(2);
expect(await user(OTHER)).toMatchObject({ primaryFandomId: id });
expect((await call(fnFandomLeave, { fandomId: id }, OTHER)).left).toBe(true);
expect((await call(fnFandomLeave, { fandomId: id }, OTHER)).left).toBe(false);
expect((await fandom(id)).memberCount).toBe(1);
expect(await user(OTHER)).toMatchObject({ primaryFandomId: null, fandomId: '' });
expect((await db().doc(`users/${OTHER}/memberships/${id}`).get()).exists).toBe(false);
// LIVE 집계용 가입 목록 비정규화 — 가입 · 탈퇴 트랜잭션이 함께 유지한다.
expect((await user(OTHER)).fandomIds).toEqual([]);
expect((await user()).fandomIds).toEqual([id]);
});
it('없는 팬덤 가입 · 가입하지 않은 팬덤을 대표로 지정은 거부한다', async () => {
expect(await codeOf(() => call(fnFandomJoin, { fandomId: 'missing-fandom' }))).toBe('E-PERM-01');
const { fandomId } = await call(fnNebulaCreate, { name: 'NotMine' }, OTHER);
expect(await codeOf(() => call(fnPrimaryFandomSet, { fandomId }))).toBe('E-PERM-01');
});
it('대표 변경은 무료 · 30일 안 재변경은 E-FDM-04 · 대기가 지나면 허용', async () => {
const a = (await call(fnNebulaCreate, { name: 'Primary A' }, OTHER)).fandomId as string;
await call(fnFandomJoin, { fandomId: a });
// 대표가 없던 계정의 첫 지정(가입)은 변경이 아니라 대기 시각을 찍지 않는다.
expect((await user()).primaryChangedAt).toBeUndefined();
// 성운 생성 = 대표 변경(A → B). 여기서 대기가 시작된다.
const b = (await call(fnNebulaCreate, { name: 'Primary B' })).fandomId as string;
expect(await user()).toMatchObject({ primaryFandomId: b });
expect((await user()).primaryChangedAt).toBeInstanceOf(Timestamp);
expect(await codeOf(() => call(fnPrimaryFandomSet, { fandomId: a }))).toBe('E-FDM-04');
await db().doc(`users/${UID}`).update({ primaryChangedAt: Timestamp.fromMillis(Date.now() - 31 * 86_400_000) });
expect((await call(fnPrimaryFandomSet, { fandomId: a })).changed).toBe(true);
expect(await user()).toMatchObject({ primaryFandomId: a, fandomId: a });
});
});
describe('창립자 탈퇴 · 대표 팬덤 자동 지정(Q-PN-03 · Q-PN-04 권고안)', () => {
const setPolicy = (patch: Record<string, unknown>) => db().doc('config/policy').set(patch, { merge: true });
afterEach(async () => {
await db().doc('config/policy').set({
nebulaFounderLeaveBlocked: FieldValue.delete(), primaryOnLeave: FieldValue.delete(),
}, { merge: true });
});
it('성운 창립자는 탄생 전 탈퇴 E-FDM-05 · 탄생 뒤에는 탈퇴 가능 · 설정을 끄면 막지 않는다', async () => {
const id = (await call(fnNebulaCreate, { name: 'Founder Stay' })).fandomId as string;
expect(await codeOf(() => call(fnFandomLeave, { fandomId: id }))).toBe('E-FDM-05');
expect((await fandom(id)).memberCount).toBe(1);
await db().doc(`fandoms/${id}`).update({ stage: 1 });
expect((await call(fnFandomLeave, { fandomId: id })).left).toBe(true);
const other = (await call(fnNebulaCreate, { name: 'Founder Go' }, OTHER)).fandomId as string;
await setPolicy({ nebulaFounderLeaveBlocked: false });
expect((await call(fnFandomLeave, { fandomId: other }, OTHER)).left).toBe(true);
});
/** A(대표) · B · C 에 가입 — 가입 시각은 B < C 로 못박는다. */
async function joinThree(): Promise<[string, string, string]> {
const ids: string[] = [];
for (const name of ['leave-a', 'leave-b', 'leave-c']) {
// 성운 생성은 하루 1회라 정식 행성 문서를 직접 둔다.
const id = `test-planet-create-${name}`;
await db().doc(`fandoms/${id}`).set({ name, stage: 1, founderUid: OTHER, memberCount: 0 });
created.fandoms.add(id);
await call(fnFandomJoin, { fandomId: id });
ids.push(id);
}
const [a, b, c] = ids as [string, string, string];
await db().doc(`users/${UID}/memberships/${b}`).update({ joinedAt: Timestamp.fromMillis(Date.now() - 2 * 86_400_000) });
await db().doc(`users/${UID}/memberships/${c}`).update({ joinedAt: Timestamp.fromMillis(Date.now() - 86_400_000) });
expect(await user()).toMatchObject({ primaryFandomId: a });
return [a, b, c];
}
it('대표 팬덤을 떠나면 가장 최근 가입 팬덤이 대표 · 대기에 세지 않는다 · 남은 팬덤이 없으면 비운다', async () => {
const [a, b, c] = await joinThree();
expect((await call(fnFandomLeave, { fandomId: a })).primaryFandomId).toBe(c);
expect(await user()).toMatchObject({ primaryFandomId: c, fandomId: c });
expect((await user()).primaryChangedAt).toBeUndefined();
// 자동 지정 직후에도 사용자가 바로 바꿀 수 있다.
expect((await call(fnPrimaryFandomSet, { fandomId: b })).changed).toBe(true);
await call(fnFandomLeave, { fandomId: c }); // 대표가 아니면 대표는 그대로
expect(await user()).toMatchObject({ primaryFandomId: b });
await call(fnFandomLeave, { fandomId: b });
expect(await user()).toMatchObject({ primaryFandomId: null, fandomId: '' });
});
it('primaryOnLeave — earliest 는 가장 먼저 가입한 팬덤 · none 은 비워 둔다', async () => {
const [a, b] = await joinThree();
await setPolicy({ primaryOnLeave: 'earliest' });
await call(fnFandomLeave, { fandomId: a });
expect(await user()).toMatchObject({ primaryFandomId: b });
await setPolicy({ primaryOnLeave: 'none' });
await call(fnFandomLeave, { fandomId: b });
expect(await user()).toMatchObject({ primaryFandomId: null });
});
});
describe('행성 탄생', () => {
async function nebula(members: number, pop: number): Promise<string> {
const { fandomId } = await call(fnNebulaCreate, { name: `N${members}x${pop}` });
await db().doc(`fandoms/${fandomId}`).update({ memberCount: members, growthPop: pop });
return fandomId as string;
}
const pick = (fandomId: string) => ({ fandomId, planetType: 'shape2', colorKey: 'gold' });
it('없는 성운은 거부 · 한쪽 조건만 채우면 E-FDM-03(B-10)', async () => {
expect(await codeOf(() => call(fnPlanetCreate, pick('missing-nebula')))).toBe('E-PERM-01');
const id = await nebula(9, 5_000);
expect(await codeOf(() => call(fnPlanetCreate, pick(id)))).toBe('E-FDM-03');
await db().doc(`fandoms/${id}`).update({ memberCount: 10, growthPop: 999 });
expect(await codeOf(() => call(fnPlanetCreate, pick(id)))).toBe('E-FDM-03');
});
it('7일 목표는 설정이 켜졌을 때만 막는다(nebulaConversionDeadlineEnforced)', async () => {
const id = await nebula(10, 1_000);
await db().doc(`fandoms/${id}`).update({ nebulaDeadline: Timestamp.fromMillis(Date.now() - 1_000) });
await db().doc('config/policy').set({ nebulaConversionDeadlineEnforced: true }, { merge: true });
try {
expect(await codeOf(() => call(fnPlanetCreate, pick(id)))).toBe('E-FDM-03');
} finally {
await db().doc('config/policy').update({ nebulaConversionDeadlineEnforced: FieldValue.delete() });
}
await call(fnPlanetCreate, pick(id));
expect((await fandom(id)).stage).toBe(1);
});
it('10명 + 1,000 POP 이면 Stage1 이 되고 형태·컬러는 한 번만 저장된다(C-50)', async () => {
const id = await nebula(10, 1_000);
expect(await codeOf(() => call(fnPlanetCreate, pick(id), OTHER))).toBe('E-PERM-01');
await call(fnPlanetCreate, pick(id));
expect(await fandom(id)).toMatchObject({ stage: 1, planetType: 'shape2', colorKey: 'gold' });
expect((await db().doc(`fandoms/${id}/planets/planet`).get()).get('ownerUid')).toBe(UID);
// 두 번째 생성(= 외형 변경 시도)은 거부된다.
expect(await codeOf(() => call(fnPlanetCreate, { ...pick(id), colorKey: 'red' }))).toBe('E-POP-03');
});
it('팔레트 밖 형태·색은 받지 않는다', async () => {
const id = await nebula(10, 1_000);
expect(await codeOf(() => call(fnPlanetCreate, { ...pick(id), colorKey: '#123456' })))
.toBe('invalid-argument');
});
});
@@ -0,0 +1,78 @@
import { afterAll, beforeAll, expect, it } from 'vitest';
import { getFirestore, Timestamp } from 'firebase-admin/firestore';
import { fnPlanetHistory } from '../src/fandom/history.js';
import { bootAdmin, shutdownAdmin } from './support.js';
const fandomId = 'history-test';
const uid = 'history-reader';
const call = (data: Record<string, unknown>, user = uid) =>
fnPlanetHistory.run({ data, ...(user ? { auth: { uid: user, token: {} } } : {}) } as never);
beforeAll(async () => {
bootAdmin();
const db = getFirestore();
for (let i = 1; i <= 22; i++) {
await db.doc(`fandoms/${fandomId}/stateHistory/${i}`).set({
kind: 'evolution', fromStage: 1, toStage: 2,
occurredAt: Timestamp.fromMillis(i * 1000), sourceEventId: 'private-source',
settings: { private: true },
});
}
await db.doc(`users/${uid}/growthEvents/one`).set({
fandomIdAtPayout: fandomId, amount: 10, sourceType: 'zap', reversalOf: null,
createdAt: Timestamp.fromMillis(10_000),
});
});
afterAll(async () => {
const db = getFirestore();
await db.recursiveDelete(db.doc(`fandoms/${fandomId}`));
await db.recursiveDelete(db.doc(`users/${uid}`));
await shutdownAdmin();
});
it('자신의 과거 기여만 확인하며 다른 사용자 ID나 내부 기록을 노출하지 않는다', async () => {
const result = await call({ fandomId });
expect(result.items).toHaveLength(20);
expect(result.items[0]!.participationConfirmed).toBe(true);
expect(result.items.at(-1)!.participationConfirmed).toBe(false);
expect(JSON.stringify(result)).not.toContain('private');
const stranger = await call({ fandomId, uid }, 'other-user');
expect(stranger.items.every((item) => item.participationConfirmed === false)).toBe(true);
});
it('커서 페이지가 겹치지 않으며 인증·경로·커서 오류를 거부한다', async () => {
const first = await call({ fandomId });
const second = await call({ fandomId, cursor: first.nextCursor });
expect(second.items).toHaveLength(2);
expect(second.nextCursor).toBeNull();
expect(new Set([...first.items, ...second.items].map((item) => item.id)).size).toBe(22);
await expect(call({ fandomId }, '')).rejects.toThrow();
await expect(call({ fandomId: '../users/private' })).rejects.toThrow();
await expect(call({ fandomId, cursor: 'missing' })).rejects.toThrow();
});
it('수량이 잘못된 기록은 개인 참여 증거로 쓰지 않는다', async () => {
await getFirestore().doc(`users/${uid}/growthEvents/one`).update({ amount: '10' });
const result = await call({ fandomId });
expect(result.items.every((item) => item.participationConfirmed === false)).toBe(true);
});
it('내 활동은 호출자와 지급 당시 팬덤으로 제한하고 다른 팬덤 커서를 거부한다', async () => {
const events = getFirestore().collection(`users/${uid}/growthEvents`);
await events.doc('one').update({ amount: 10 });
for (const [index, sourceType] of ['quest_reward', 'company_ad_bonus', 'zap', 'funding'].entries()) {
await events.doc(`activity-${index}`).set({
fandomIdAtPayout: fandomId, sourceType, amount: 10 + index, reversalOf: null,
createdAt: Timestamp.fromMillis(30_000 + index), sourceEventId: 'private-ledger',
});
}
await events.doc('other-fandom').set({ fandomIdAtPayout: 'other-fandom', sourceType: 'zap',
amount: 999, createdAt: Timestamp.fromMillis(40_000) });
const result = await call({ fandomId, scope: 'personal' });
expect(result.items).toHaveLength(5);
expect(result.items[0]!.kind).toBe('funding');
expect(result.items[0]!.amount).toBe(13);
expect(JSON.stringify(result)).not.toContain('private-ledger');
expect((await call({ fandomId, scope: 'personal', uid }, 'other-user')).items).toEqual([]);
await expect(call({ fandomId, scope: 'personal', cursor: 'other-fandom' })).rejects.toThrow();
await expect(call({ fandomId, scope: 'all-users' })).rejects.toThrow();
});
+282
View File
@@ -0,0 +1,282 @@
import { describe, expect, it } from 'vitest';
import { txIdOf, lotIdOf, deviceHashOf } from '../src/core/ids.js';
import { dayKey, isPlausibleOccurredAt } from '../src/core/time.js';
import { DEFAULT_POLICY, FPI_AXES, weightsAreUsable } from '../src/core/config.js';
import { CONTRIBUTION_REASONS } from '../src/ledger/ledger.js';
import { expForLevel, levelOf, passportTierOf, titleOf } from '../src/core/level.js';
import { FPI_UNIT_FACTOR, rankWithTies } from '../src/ledger/fpi.js';
import { fnIapRefundWebhook, fnSubscriptionWebhook } from '../src/payment/iap.js';
import {
fnTranslationMark, fnTranslationSubmit, isVisibleTarget, parseTranslationInput, translationRewardOf,
translationSettleOf,
} from '../src/account/translation.js';
import { publicNameOf } from '../src/core/names.js';
describe('공개 이름 (M-7)', () => {
const user = (data: Record<string, unknown>) => ({ get: (k: string) => data[k] });
it('닉네임만 쓰고 없거나 이메일 형태면 「팬」 — 공급자 실명은 쓰지 않는다', () => {
expect(publicNameOf(user({ nickname: ' 아미보이 ', displayName: '김실명' }))).toBe('아미보이');
expect(publicNameOf(user({ displayName: '김실명' }))).toBe('팬');
expect(publicNameOf(user({ nickname: 'me@example.com' }))).toBe('팬');
expect(publicNameOf(user({ nickname: ' ' }))).toBe('팬');
expect(publicNameOf(null)).toBe('팬');
});
});
describe('번역 대상 공개 여부', () => {
it('캠페인은 공개 상태만 · 나머지는 visibility visible(없으면 공개)', () => {
for (const status of ['funding', 'success', 'fail', 'report']) {
expect(isVisibleTarget('campaigns/c1', { status })).toBe(true);
}
for (const status of ['review', 'withdrawn', 'cancelled', 'rejected', undefined]) {
expect(isVisibleTarget('campaigns/c1', { status })).toBe(false);
}
expect(isVisibleTarget('posts/p1', { visibility: 'visible' })).toBe(true);
expect(isVisibleTarget('posts/p1', { visibility: 'hidden' })).toBe(false);
expect(isVisibleTarget('questions/q1', {})).toBe(true);
expect(isVisibleTarget('questions/q1', undefined)).toBe(false);
});
});
describe('참여형 번역 등록 (FR-I18N-02 · B-63)', () => {
const ok = { targetPath: 'questions/q1', locale: 'en', body: ' Hello ' };
it('정상 입력은 본문을 다듬어 받는다', () => {
expect(parseTranslationInput(ok)).toEqual({ targetPath: 'questions/q1', locale: 'en', body: 'Hello' });
});
it('허용 콘텐츠 6종 경로만 받는다', () => {
for (const targetPath of [
'questions/q1', 'questions/q1/answers/a1', 'posts/p1', 'posts/p1/comments/c1', 'campaigns/c1', 'notices/n1',
]) {
expect(parseTranslationInput({ ...ok, targetPath }).targetPath).toBe(targetPath);
}
});
it('허용 밖 경로는 존재 여부를 드러내지 않고 E-TRN-03 이다', () => {
for (const targetPath of [
'questions', 'users/alice', 'users/alice/wallet/main', 'questions/../users',
'campaigns/c1/likes/u1', 'posts/p1/likes/u1', 'config/policy',
]) {
expect(() => parseTranslationInput({ ...ok, targetPath }))
.toThrow(expect.objectContaining({ details: { code: 'E-TRN-03' } }));
}
});
it('지원 5종 밖의 언어 · 빈 본문 · 2000자 초과는 거부한다', () => {
for (const bad of [
{ ...ok, locale: 'zh-TW' },
{ ...ok, body: ' ' },
{ ...ok, body: 'a'.repeat(2001) },
]) {
expect(() => parseTranslationInput(bad)).toThrow();
}
});
it('중복 문장 · 일일상한 초과는 등록하되 보상 0 이다', () => {
const base = { pop: 5, dailyLimit: 10, todayCount: 0, duplicate: false };
expect(translationRewardOf(base)).toEqual({ rewardPop: 5, excludedReason: null });
expect(translationRewardOf({ ...base, duplicate: true })).toEqual({ rewardPop: 0, excludedReason: 'duplicate' });
expect(translationRewardOf({ ...base, todayCount: 10 })).toEqual({ rewardPop: 0, excludedReason: 'daily_limit' });
expect(translationRewardOf({ ...base, todayCount: 29 })).toEqual({ rewardPop: 0, excludedReason: 'daily_limit' });
});
it('일일상한의 3배부터는 등록 자체를 E-TRN-02 로 거부한다', () => {
expect(() => translationRewardOf({ pop: 5, dailyLimit: 10, todayCount: 30, duplicate: false }))
.toThrow(expect.objectContaining({ details: { code: 'E-TRN-02' } }));
});
it('정산 — 유효가 최소치 이상이고 무효보다 많을 때만 확정, 못 채우면 최대 대기 뒤 해제', () => {
const base = { valid: 0, invalid: 0, ageHours: 25, minValid: 1, maxPendingHours: 72 };
expect(translationSettleOf(base)).toBe('wait');
expect(translationSettleOf({ ...base, valid: 1 })).toBe('confirm');
expect(translationSettleOf({ ...base, valid: 1, invalid: 1 })).toBe('wait');
expect(translationSettleOf({ ...base, valid: 2, invalid: 1 })).toBe('confirm');
expect(translationSettleOf({ ...base, ageHours: 72 })).toBe('release');
expect(translationSettleOf({ ...base, valid: 3, invalid: 5, ageHours: 80 })).toBe('release');
});
it('보상 기본값은 원장 B-63 의 5 POP 이다', () => {
expect(DEFAULT_POLICY.translationPop).toBe(5);
});
it('로그인하지 않으면 등록·마킹 모두 데이터에 닿기 전에 거부한다', async () => {
await expect(fnTranslationSubmit.run({ data: ok } as Parameters<typeof fnTranslationSubmit.run>[0]))
.rejects.toMatchObject({ details: { code: 'E-AUTH-03' } });
await expect(fnTranslationMark.run({ data: { entryId: 'e', valid: true } } as Parameters<typeof fnTranslationMark.run>[0]))
.rejects.toMatchObject({ details: { code: 'E-AUTH-03' } });
});
});
it('환불·구독 웹훅은 무인증·일반 사용자·조회 운영자의 호출을 데이터 접근 전에 거부한다', async () => {
for (const fn of [fnIapRefundWebhook, fnSubscriptionWebhook]) {
for (const auth of [undefined, { uid: 'user', token: {} }, { uid: 'viewer', token: { role: 'viewer' } }]) {
await expect(fn.run({ data: {}, auth } as Parameters<typeof fn.run>[0]))
.rejects.toMatchObject({ details: { code: 'E-PERM-01' } });
}
}
});
/** 에뮬레이터 없이 도는 순수 함수 검증. */
describe('멱등 ID', () => {
it('같은 키는 같은 txId 를 낸다', () => {
expect(txIdOf('attend:u1:2026-09-01')).toBe(txIdOf('attend:u1:2026-09-01'));
});
it('다른 키는 다른 txId 를 낸다', () => {
expect(txIdOf('attend:u1:2026-09-01')).not.toBe(txIdOf('attend:u1:2026-09-02'));
});
it('설계서가 정한 20자를 지킨다', () => {
expect(txIdOf('any')).toHaveLength(20);
});
it('lot 은 원장과 1:1 로 묶인다', () => {
const tx = txIdOf('g:1');
expect(lotIdOf(tx)).toBe(`lot_${tx}`);
});
it('단말 해시는 원값을 남기지 않는다', () => {
const raw = 'device-serial-1234';
expect(deviceHashOf(raw)).not.toContain(raw);
expect(deviceHashOf(raw)).toHaveLength(32);
});
});
describe('일일 한도의 하루', () => {
it('한국 시간대로 날짜를 끊는다', () => {
// UTC 로는 08-31 이지만 한국은 이미 09-01 이다.
expect(dayKey(new Date('2026-08-31T16:00:00Z'))).toBe('2026-09-01');
});
it('UTC 기준을 요구하면 그대로 따른다', () => {
expect(dayKey(new Date('2026-08-31T16:00:00Z'), 0)).toBe('2026-08-31');
});
});
describe('시간 조작 판정', () => {
const now = new Date('2026-09-01T00:00:00Z');
it('허용 오차 안이면 통과한다', () => {
expect(isPlausibleOccurredAt(new Date('2026-09-01T00:10:00Z'), now)).toBe(true);
});
it('미래로 크게 당긴 시각은 막는다', () => {
expect(isPlausibleOccurredAt(new Date('2026-09-01T02:00:00Z'), now)).toBe(false);
});
it('과거로 크게 미룬 시각도 막는다', () => {
expect(isPlausibleOccurredAt(new Date('2026-08-31T20:00:00Z'), now)).toBe(false);
});
});
describe('FPI 가중치', () => {
it('초기값은 단순 합산이라 전부 1.0 이다', () => {
// 정책 설계서 §P-03 「초기 MVP는 단순 합산」. 합이 1인 가중 평균이 아니다.
for (const axis of FPI_AXES) {
expect(DEFAULT_POLICY.fpiWeights[axis]).toBe(1);
}
expect(weightsAreUsable(DEFAULT_POLICY.fpiWeights)).toBe(true);
});
it('Admin 이 가중치를 바꿔도 합을 1로 맞출 필요가 없다', () => {
expect(weightsAreUsable({
bpm: 2, health: 1, referral: 0.5, community: 1, contribution: 3,
})).toBe(true);
});
it('음수 가중치는 걸러낸다 — 활동할수록 깎이는 랭킹이 된다', () => {
expect(weightsAreUsable({
bpm: -1, health: 1, referral: 1, community: 1, contribution: 1,
})).toBe(false);
});
it('축이 빠지면 걸러낸다', () => {
expect(weightsAreUsable(
{ bpm: 1, health: 1, referral: 1, community: 1 } as never,
)).toBe(false);
});
it('확정된 5지표를 그대로 쓴다', () => {
expect([...FPI_AXES])
.toEqual(['bpm', 'health', 'referral', 'community', 'contribution']);
});
});
describe('기여 집계 대상', () => {
it('캠페인 후원·현상금·ZAP 행성 성장 기여를 기여로 센다', () => {
// `ZAP_CONTRIBUTION` 은 별첨 B **B-23** 으로 더했다 — *ZAP 차감액은 누적 기여 POP 에
// 실제 값으로 반영*. Growth 진행도 가중(×1.5 이내)은 원장에 적용하지 않는다.
//
// `ECLIPSE_DEFENSE` 는 목록에 남아 있으나 **더 이상 쓰이지 않는다** — 이클립스
// 방어는 POP 을 소모하지 않는 공동 Shake 참여로 확정되었다(설계서 v1.1 §9-#5).
// 과거 원장 행이 이 코드를 달고 있어 카탈로그에서 지우지 않는다.
expect([...CONTRIBUTION_REASONS].sort()).toEqual(
['CAMPAIGN_FUND', 'ECLIPSE_DEFENSE', 'QNA_BOUNTY', 'ZAP_CONTRIBUTION'],
);
});
});
describe('User Level · 칭호 · Passport — C-18 · B-65 · B-66', () => {
it('신규 회원은 Lv.1 GLOW · SILVER 다', () => {
expect(levelOf(0)).toBe(1);
expect(titleOf(levelOf(0))).toBe('GLOW');
expect(passportTierOf(1)).toBe('SILVER');
});
it('레벨은 EXP 요구량 경계에서 정확히 오른다', () => {
for (const level of [2, 10, 18, 30, 100, 150]) {
expect(levelOf(expForLevel(level))).toBe(level);
expect(levelOf(expForLevel(level) - 1)).toBe(level - 1);
}
// Admin Figma 표본 「Lv.18 · User EXP 128,450 · Next Level 14,550」 — S01 A안(243 × L^1.5).
expect(levelOf(128_450)).toBe(18);
expect(expForLevel(18)).toBe(124_450);
expect(expForLevel(19)).toBe(143_010);
expect(expForLevel(19) - 128_450).toBe(14_560);
});
it('칭호 경계 9/10 · 29/30 · 59/60 · 99/100, 100 이후에도 숫자가 오른다', () => {
expect([9, 10, 29, 30, 59, 60, 99, 100].map(titleOf)).toEqual(
['GLOW', 'SPARK', 'SPARK', 'BEAM', 'BEAM', 'STARLIGHT', 'STARLIGHT', 'NOVA']);
expect(titleOf(150)).toBe('NOVA');
expect(levelOf(expForLevel(150) + 1)).toBe(150);
});
it('누적 기여 POP 문턱으로 승급하지 않는다', () => {
// v0.3.0 의 3,000 POP = SPARK 는 기각됐다. EXP 3,000 은 Lv.4 GLOW 다.
expect(titleOf(levelOf(3_000))).toBe('GLOW');
});
it('Passport 는 Gold Lv.30+ · Diamond Lv.70+ 이고 강등하지 않는다', () => {
expect(passportTierOf(29)).toBe('SILVER');
expect(passportTierOf(30)).toBe('GOLD');
expect(passportTierOf(70)).toBe('DIAMOND');
expect(passportTierOf(1, 'DIAMOND')).toBe('DIAMOND');
expect(passportTierOf(40, 'DIAMOND')).toBe('DIAMOND');
expect(passportTierOf(75, 'GOLD')).toBe('DIAMOND');
});
});
describe('FPI 공동 순위 — C-19', () => {
const rows = [
{ id: 'c', v: 10 }, { id: 'b', v: 5 }, { id: 'a', v: 10 }, { id: 'd', v: 10.4 },
];
const rank = (input: typeof rows) =>
rankWithTies(input, (r) => r.v, (r) => r.id).map(({ item, rank }) => `${item.id}:${rank}`);
it('동점은 같은 순위, 다음은 건너뛴다 · 반올림이 같아도 실값 순', () => {
expect(rank(rows)).toEqual(['d:1', 'a:2', 'c:2', 'b:4']);
expect(rank(rows.filter((r) => r.id !== 'd'))).toEqual(['a:1', 'c:1', 'b:3']);
});
it('입력 순서가 바뀌어도 동점 배치가 고정된다', () => {
expect(rank([...rows].reverse())).toEqual(rank(rows));
});
it('환산계수는 S01 확정 전 1.0 이다', () => {
expect(FPI_UNIT_FACTOR).toBe(1);
});
});
+308
View File
@@ -0,0 +1,308 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { FieldValue, getFirestore, Timestamp } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
import { grantPop } from '../src/ledger/ledger.js';
import {
countAnswer, expireBounties, fnAnswerAccept, fnQuestionCreate, MAX_REFUND_ATTEMPTS,
} from '../src/inside/qna.js';
/**
* 현상금 Q&A 왕복 — 별첨 1 ④ 「예치·전송·환불 원장 정합성 오류 0건」.
* 핸들러를 `CallableFunction.run` 으로 그대로 돌린다(quest_e2e 와 같은 방식).
*/
const ASKER = 'test-qna-asker';
const ANSWERER = 'test-qna-answerer';
type Reply = Record<string, unknown>;
async function call(
fn: { run(request: never): unknown },
uid: string,
data: Record<string, unknown>,
): Promise<Reply> {
const request = { data, auth: { uid, token: {} } } as unknown as never;
return (await fn.run(request)) as Reply;
}
async function codeOf(run: () => Promise<unknown>): Promise<string> {
try {
await run();
} catch (error) {
const e = error as { details?: { code?: string }; code?: string };
return e.details?.code ?? e.code ?? String(error);
}
throw new Error('실패해야 하는 호출이 성공했습니다.');
}
async function balance(uid: string): Promise<number> {
const w = await getFirestore().doc(`users/${uid}/wallet/main`).get();
return (w.get('activityBalance') as number | undefined) ?? 0;
}
let seq = 0;
async function ask(bountyPop: number): Promise<string> {
const reply = await call(fnQuestionCreate, ASKER, {
title: '질문', body: '본문', bountyPop, clientSeq: `s${++seq}-${Date.now()}`,
});
return reply.questionId as string;
}
async function answer(questionId: string, uid: string): Promise<string> {
const ref = await getFirestore().collection(`questions/${questionId}/answers`).add({
authorUid: uid, authorName: uid, body: '답', accepted: false,
});
return ref.id;
}
beforeAll(() => { bootAdmin(); });
afterAll(async () => { await shutdownAdmin(); });
beforeEach(async () => {
await wipeUser(ASKER);
await wipeUser(ANSWERER);
const old = await getFirestore().collection('questions').where('authorUid', '==', ASKER).get();
await Promise.all(old.docs.map((d) => getFirestore().recursiveDelete(d.ref)));
await getFirestore().doc(`users/${ASKER}`).set({ displayName: '김실명', nickname: '질문자', fandomId: 'f-qna' });
await grantPop({
uid: ASKER, popType: 'activity', amount: 1000,
reasonCode: 'ADMIN_GRANT', idempotencyKey: `g:qna:${Date.now()}`,
});
});
describe('fn-question-create', () => {
it('예치와 질문이 함께 서고, 목록이 읽는 팬덤·표시명이 복제된다', async () => {
const id = await ask(300);
const q = await getFirestore().doc(`questions/${id}`).get();
expect(q.get('status')).toBe('open');
expect(q.get('fandomId')).toBe('f-qna');
expect(q.get('authorName')).toBe('질문자');
});
it('닉네임이 없으면 공급자 실명 대신 「팬」을 싣는다(M-7)', async () => {
await getFirestore().doc(`users/${ASKER}`).update({ nickname: FieldValue.delete() });
const q = await getFirestore().doc(`questions/${await ask(300)}`).get();
expect(q.get('authorName')).toBe('팬');
expect(q.get('bountyPop')).toBe(300);
expect(await balance(ASKER)).toBe(700);
});
it('제목 · 본문 길이 상한을 서버가 끊는다', async () => {
const run = (title: string, body: string) => call(fnQuestionCreate, ASKER, {
title, body, bountyPop: 100, clientSeq: `len-${Date.now()}`,
});
expect(await codeOf(() => run('가'.repeat(101), '본문'))).toBe('invalid-argument');
expect(await codeOf(() => run('제목', '가'.repeat(5_001)))).toBe('invalid-argument');
expect(await balance(ASKER)).toBe(1000);
});
it('현상금 100 POP 미만은 서버가 거부한다', async () => {
expect(await codeOf(() => ask(99))).toBe('invalid-argument');
expect(await balance(ASKER)).toBe(1000);
});
it('현상금 하한은 config/policy.qnaMinBountyPop 이 덮는다', async () => {
const policy = getFirestore().doc('config/policy');
await policy.set({ qnaMinBountyPop: 200 }, { merge: true });
try {
expect(await codeOf(() => ask(150))).toBe('invalid-argument');
expect(await ask(200)).toBeTruthy();
} finally {
await policy.set({ qnaMinBountyPop: FieldValue.delete() }, { merge: true });
}
});
it('일반이 모자라면 부족분만 기여 POP 으로 예치하고, 만료 환불은 각 타입으로 돌아간다(B-39)', async () => {
await grantPop({
uid: ASKER, popType: 'support', amount: 500,
reasonCode: 'IAP_PURCHASE', idempotencyKey: `s:qna:${Date.now()}`,
});
const support = async () =>
((await getFirestore().doc(`users/${ASKER}/wallet/main`).get()).get('supportBalance') as number);
const id = await ask(1200);
expect(await balance(ASKER)).toBe(0);
expect(await support()).toBe(300);
const q = await getFirestore().doc(`questions/${id}`).get();
expect((q.get('bountyTxIds') as string[]).length).toBe(2);
await q.ref.update({ expiresAt: Timestamp.fromMillis(Date.now() - 1000) });
await expireBounties();
await expireBounties();
expect((await q.ref.get()).get('status')).toBe('expired');
expect(await balance(ASKER)).toBe(1000);
expect(await support()).toBe(500);
const refunds = await getFirestore().collection(`users/${ASKER}/popTransactions`)
.where('reasonCode', '==', 'QNA_REFUND').get();
expect(refunds.docs.map((d) => [d.get('popType'), d.get('amount')]).sort())
.toEqual([['activity', 1000], ['support', 200]]);
});
it('분할 전 옛 질문(bountyTxId 한 건)도 만료 환불된다', async () => {
const id = await ask(300);
const ref = getFirestore().doc(`questions/${id}`);
const [txId] = (await ref.get()).get('bountyTxIds') as string[];
await ref.update({
bountyTxIds: FieldValue.delete(), bountyTxId: txId,
expiresAt: Timestamp.fromMillis(Date.now() - 1000),
});
await expireBounties();
expect((await ref.get()).get('status')).toBe('expired');
expect(await balance(ASKER)).toBe(1000);
});
it('잔액이 모자라면 질문이 남지 않는다', async () => {
expect(await codeOf(() => ask(5000))).toBe('E-POP-01');
const left = await getFirestore().collection('questions')
.where('authorUid', '==', ASKER).where('bountyPop', '==', 5000).get();
expect(left.empty).toBe(true);
});
});
describe('fn-answer-accept', () => {
it('채택하면 현상금이 답변자에게 일반 POP 으로 가고 번복되지 않는다', async () => {
const id = await ask(400);
const a = await answer(id, ANSWERER);
await call(fnAnswerAccept, ASKER, { questionId: id, answerId: a });
expect(await balance(ANSWERER)).toBe(400);
expect((await balance(ASKER)) + (await balance(ANSWERER))).toBe(1000);
const q = await getFirestore().doc(`questions/${id}`).get();
expect(q.get('status')).toBe('accepted');
const ans = await getFirestore().doc(`questions/${id}/answers/${a}`).get();
expect(ans.get('accepted')).toBe(true);
const b = await answer(id, 'someone-else');
expect(await codeOf(() => call(fnAnswerAccept, ASKER, { questionId: id, answerId: b })))
.toBe('E-QNA-03');
expect(await balance(ANSWERER)).toBe(400);
});
it('자기 답변은 채택할 수 없다(E-QNA-01)', async () => {
const id = await ask(200);
const mine = await answer(id, ASKER);
expect(await codeOf(() => call(fnAnswerAccept, ASKER, { questionId: id, answerId: mine })))
.toBe('E-QNA-01');
});
it('질문자가 아니면 채택할 수 없다', async () => {
const id = await ask(200);
const a = await answer(id, ANSWERER);
expect(await codeOf(() => call(fnAnswerAccept, ANSWERER, { questionId: id, answerId: a })))
.toBe('E-PERM-01');
});
});
describe('fn-question-bounty-expire', () => {
it('기한이 지나면 원타입으로 한 번만 환불되고, 이후 채택은 E-QNA-02', async () => {
const id = await ask(500);
const a = await answer(id, ANSWERER);
await getFirestore().doc(`questions/${id}`).update({
expiresAt: Timestamp.fromMillis(Date.now() - 1000),
});
const early = await call(fnAnswerAccept, ASKER, { questionId: id, answerId: a })
.catch((e: { details?: { code?: string }; message: string }) => e);
expect(early).toMatchObject({ details: { code: 'E-QNA-02' } });
expect((early as { message: string }).message).toContain('환불될 예정');
await expireBounties();
await expireBounties();
const q = await getFirestore().doc(`questions/${id}`).get();
expect(q.get('status')).toBe('expired');
expect(await balance(ASKER)).toBe(1000);
expect(await balance(ANSWERER)).toBe(0);
const late = await call(fnAnswerAccept, ASKER, { questionId: id, answerId: a })
.catch((e: { message: string }) => e);
expect((late as { message: string }).message).toContain('환불되었습니다');
});
});
/** 두 사람 사이의 원장 행 수(초기 지급 제외). 예치 1 + 채택 또는 환불 1 이어야 한다. */
async function movements(): Promise<string[]> {
const out: string[] = [];
for (const uid of [ASKER, ANSWERER]) {
const rows = await getFirestore().collection(`users/${uid}/popTransactions`).get();
for (const r of rows.docs) {
const code = r.get('reasonCode') as string;
if (code !== 'ADMIN_GRANT') out.push(code);
}
}
return out.sort();
}
describe('채택과 만료 환불의 경합', () => {
it('동시에 돌아도 채택 또는 환불 중 정확히 하나만 원장을 움직인다', async () => {
const id = await ask(500);
const a = await answer(id, ANSWERER);
// 기한 직전에 둘이 겹치는 상황 — 만료 시각을 막 지난 값으로 둔다.
await getFirestore().doc(`questions/${id}`).update({
expiresAt: Timestamp.fromMillis(Date.now() + 300),
});
await new Promise((r) => setTimeout(r, 300));
await Promise.allSettled([
call(fnAnswerAccept, ASKER, { questionId: id, answerId: a }),
expireBounties(),
]);
const moved = (await movements()).filter((c) => c !== 'QNA_BOUNTY');
expect(moved).toHaveLength(1);
expect((await balance(ASKER)) + (await balance(ANSWERER))).toBe(1000);
});
it('기한 전 동시 채택은 한쪽만 지급된다', async () => {
const id = await ask(500);
const a = await answer(id, ANSWERER);
await Promise.allSettled([
call(fnAnswerAccept, ASKER, { questionId: id, answerId: a }),
expireBounties(),
]);
expect(await movements()).toEqual(['QNA_BOUNTY', 'QNA_REWARD']);
expect(await balance(ANSWERER)).toBe(500);
});
});
describe('환불 실패 처리', () => {
it(`${MAX_REFUND_ATTEMPTS}번 실패하면 refund_failed 로 멈추고 운영 로그를 남긴다`, async () => {
const id = await ask(200);
// 원거래를 가리키는 ID 를 깨뜨려 환불이 계속 실패하게 한다.
await getFirestore().doc(`questions/${id}`).update({
bountyTxIds: ['missing-tx'],
expiresAt: Timestamp.fromMillis(Date.now() - 1000),
});
for (let i = 0; i < MAX_REFUND_ATTEMPTS + 1; i++) await expireBounties();
const q = await getFirestore().doc(`questions/${id}`).get();
expect(q.get('status')).toBe('refund_failed');
expect(q.get('refundAttempts')).toBe(MAX_REFUND_ATTEMPTS);
const logs = await getFirestore().collection('adminActionLogs')
.where('targetPath', '==', `questions/${id}`).get();
expect(logs.size).toBe(1);
});
it('질문자 정보가 없으면 바로 refund_failed 다', async () => {
const id = await ask(200);
await getFirestore().doc(`questions/${id}`).update({
bountyTxIds: [],
expiresAt: Timestamp.fromMillis(Date.now() - 1000),
});
await expireBounties();
expect((await getFirestore().doc(`questions/${id}`).get()).get('status')).toBe('refund_failed');
});
});
describe('답변 수 트리거', () => {
it('같은 답변이 재전달되어도 한 번만 센다', async () => {
const id = await ask(100);
const a = await answer(id, ANSWERER);
expect(await countAnswer(id, a)).toBe(true);
expect(await countAnswer(id, a)).toBe(false);
const b = await answer(id, 'someone-else');
await Promise.all([countAnswer(id, b), countAnswer(id, b)]);
const q = await getFirestore().doc(`questions/${id}`).get();
expect(q.get('answerCount')).toBe(2);
});
it('질문이 없으면 건너뛴다', async () => {
expect(await countAnswer('no-such-question', 'x')).toBe(false);
});
});
@@ -0,0 +1,628 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { getFirestore, FieldValue, GeoPoint, Timestamp } from 'firebase-admin/firestore';
import type { CallableRequest } from 'firebase-functions/v2/https';
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
import { grantPop } from '../src/ledger/ledger.js';
import { verifiedQuestCountOf } from '../src/ledger/fpi.js';
import { fnAdminCompletionVoid } from '../src/admin/admin.js';
import { fnAdReward, fnQuestAttend, fnQuestVerifySteps } from '../src/quest/quest.js';
import { fnQuestVerifyMusicShake, fnQuestVerifyPlace } from '../src/quest/verify.js';
import { fnCampaignFund } from '../src/fandom/fandom.js';
/**
* 퀘스트 E2E — 별첨 1 ③ 「퀘스트별 수행 → 검증 → 보상」 판정 절차.
*
* 별첨 1 ② 완료 시나리오 1 「활동 보상 → 무료 POP 지급 → 원장 기록 → 행성 성장 반영」과
* ③ 「만보기·위치·출석 → 검증 → 보상」을 한 파일에서 끝까지 돈다. 화면 없이 도는 것은
* **서버가 판정 주체**이기 때문이다 — 앱은 증거만 싣는다.
*
* 확인하는 것
* 1. 출석 1회 → 원장 행 1건 → 잔액 증가 → 같은 날 2회차 거부(E-POP-03)
* 2. 걸음 미달(E-QST-01) · 비정상(E-QST-02) · 완주 지급 · 당일 재지급 거부
* 3. 방문 반경 밖(E-QST-03) · 모의 위치(E-QST-02) · 인증 수단 없음 · 정상 지급 · 재사용 차단
* 4. 받은 POP 을 캠페인에 쓰면 **화면이 읽는 필드**가 움직인다
* (`users.totalContributedPop` · `users.userExp/userLevel` · `members.contributedPop`)
*/
const UID = 'test-quest-e2e';
const FANDOM = 'fandom-quest-e2e';
const PLACE = 'place-quest-e2e';
/// 앱이 읽는 것과 같은 **퀘스트 문서 ID**. 서버가 진행 문서를 이 ID 로 써야 한다.
const ATTEND_QUEST = 'daily-checkin';
const STEPS_QUEST = 'daily-steps';
const PLACE_QUEST = 'place-visit';
/** 카탈로그 코드만 꺼낸다. 화면도 상태가 아니라 이 코드로 분기한다. */
async function codeOf(run: () => Promise<unknown>): Promise<string> {
try {
await run();
} catch (error) {
const details = (error as { details?: { code?: string } }).details;
return details?.code ?? `(코드 없음) ${(error as Error).message}`;
}
throw new Error('실패해야 하는 호출이 성공했습니다.');
}
/** 서버 응답 한 건. 값의 형이 섞여 있어 판정에 필요한 것만 꺼내 쓴다. */
type Reply = Record<string, unknown>;
/**
* 배포하지 않고 핸들러를 그대로 실행한다(`CallableFunction.run`).
*
* 배포·HTTP 왕복 없이도 **판정 로직 전체**가 도는 경로다. 인증 문맥만 흉내 내고
* 나머지는 실제 코드가 그대로 돈다 — 에뮬레이터의 Firestore 를 쓰므로 트랜잭션과
* 문서 ID 멱등까지 실제 동작이다.
*/
async function call(
fn: { run(request: never): unknown },
data: Record<string, unknown> = {},
): Promise<Reply> {
const request = { data, auth: { uid: UID, token: {} } } as unknown as never;
return (await fn.run(request)) as Reply;
}
/** 문서 하나를 꺼낸다. 없으면 그 자리에서 끊는다 — 뒤에서 undefined 로 새지 않게. */
function only<T>(docs: T[]): T {
const first = docs[0];
if (first === undefined) throw new Error('문서가 없습니다.');
return first;
}
const db = () => getFirestore();
beforeAll(() => { bootAdmin(); });
afterAll(async () => { await shutdownAdmin(); });
beforeEach(async () => {
await wipeUser(UID);
for (const sub of ['activityLogs']) {
const snap = await db().collection(`users/${UID}/${sub}`).get();
await Promise.all(snap.docs.map((d) => d.ref.delete()));
}
const proofs = await db().collection('placeProofs').get();
await Promise.all(proofs.docs.map((d) => d.ref.delete()));
const events = await db().collection(`fandoms/${FANDOM}/fpiEvents`).get();
await Promise.all(events.docs.map((d) => d.ref.delete()));
await db().doc(`users/${UID}`).set({ uid: UID, fandomId: FANDOM, totalContributedPop: 0 });
// 운영값은 문서로 둔다 — 코드에 박으면 회신마다 재배포다(설계서 §10).
await db().doc('config/policy').set({
attendPop: 10, stepsGoal: 10_000, stepsPop: 50, adRewardPop: 10, adMultiplier: 2, adDailyLimit: 5,
// 정책 원장 **B-26** 확정 구간. 운영값이라 코드가 아니라 문서에 둔다.
stepTiers: [
{ steps: 1_000, pop: 5 },
{ steps: 5_000, pop: 6 },
{ steps: 10_000, pop: 7 },
{ steps: 15_000, pop: 8 },
],
}, { merge: true });
// 퀘스트 정의. **일일 한도의 유일한 원본**이며(설계서 §2.8) 진행 문서 ID 도 이 ID 다.
await db().doc(`quests/${ATTEND_QUEST}`).set({
type: 'attend', title: '오늘의 출석', rewardPop: 10, dailyLimit: 1, active: true,
});
await db().doc(`quests/${STEPS_QUEST}`).set({
type: 'walk', title: '만보기 챌린지', rewardPop: 0, dailyLimit: 1, active: true,
});
// 위치 퀘스트는 장소를 가리킨다. 서버는 이 문서의 장소와 요청 장소가 같아야 지급한다.
for (const id of [PLACE_QUEST, 'place-quest-once', 'place-quest-other', 'place-quest-reuse']) {
await db().doc(`quests/${id}`).set({ type: 'location', placeId: PLACE, dailyLimit: 1 });
}
await db().doc('quests/place-quest-repeat').set({
type: 'location', placeId: PLACE, repeat: { allowed: true, intervalHours: 24, maxCount: 2 },
});
await db().doc(`places/${PLACE}`).set({
name: '홍대 팝업스토어',
geo: new GeoPoint(37.5563, 126.9236),
// 별첨 B **B-27** 기본 반경 100m. Admin 운영값이라 문서에 둔다.
radiusM: 100,
rewardPop: 100,
verifyMethods: ['gps', 'qr'],
});
});
describe('출석 — S-H-ATTEND (fn-quest-attend)', () => {
it('없는 퀘스트 · 출석이 아닌 퀘스트 ID 로는 지급하지 않는다(리뷰 H2)', async () => {
expect(await codeOf(() => call(fnQuestAttend, { questId: 'no-such-quest' }))).toBe('E-QST-01');
expect(await codeOf(() => call(fnQuestAttend, { questId: STEPS_QUEST }))).toBe('E-QST-01');
expect((await db().collection(`users/${UID}/popTransactions`).get()).size).toBe(0);
});
it('참여 1회로 원장 1행·잔액·진행 카운터가 함께 생긴다', async () => {
const result = await call(fnQuestAttend, { questId: ATTEND_QUEST });
expect(result.rewarded).toBe(10);
expect(result.balanceAfter).toBe(10);
const ledger = await db().collection(`users/${UID}/popTransactions`).get();
expect(ledger.size).toBe(1);
expect(only(ledger.docs).get('reasonCode')).toBe('QUEST_ATTEND');
// 검증 없는 지급 경로가 없다는 것을 원장 자체에 남긴다 — 별첨 1 ② 판정 근거.
expect(only(ledger.docs).get('serverVerified')).toBe(true);
expect(only(ledger.docs).get('amount')).toBe(10);
const wallet = await db().doc(`users/${UID}/wallet/main`).get();
expect(wallet.get('activityBalance')).toBe(10);
});
it('앱이 읽는 경로(퀘스트 문서 ID)에 진행이 완료로 남는다', async () => {
await call(fnQuestAttend, { questId: ATTEND_QUEST });
// 앱은 `users/{uid}/questProgress/{퀘스트 문서 ID}` 를 읽는다. 서버가 유형 이름에
// 쓰면 지급은 되는데 화면은 영원히 「시작하기」로 남는다.
const progress = await db().doc(`users/${UID}/questProgress/${ATTEND_QUEST}`).get();
expect(progress.exists).toBe(true);
expect(progress.get('questId')).toBe(ATTEND_QUEST);
expect(progress.get('count')).toBe(1);
expect(progress.get('rewardedPop')).toBe(10);
// 일일 한도는 `quests/{id}.dailyLimit` 이 정한다. 1 이면 여기서 완료로 보인다.
const quest = await db().doc(`quests/${ATTEND_QUEST}`).get();
expect(progress.get('count')).toBeGreaterThanOrEqual(quest.get('dailyLimit') as number);
// 유형 이름으로 쓰던 예전 자리에는 아무것도 남지 않는다.
const legacy = await db().doc(`users/${UID}/questProgress/attend`).get();
expect(legacy.exists).toBe(false);
});
it('같은 날 두 번째 참여는 서버가 거부한다', async () => {
await call(fnQuestAttend, { questId: ATTEND_QUEST });
expect(await codeOf(() => call(fnQuestAttend, { questId: ATTEND_QUEST })))
.toBe('E-POP-03');
// 거부된 호출이 원장을 늘리지 않는다.
const ledger = await db().collection(`users/${UID}/popTransactions`).get();
expect(ledger.size).toBe(1);
});
it('한도를 2회로 올린 퀘스트는 두 번까지 받는다', async () => {
// 한도의 유일한 원본이 퀘스트 문서라는 것을 값으로 보인다.
await db().doc(`quests/${ATTEND_QUEST}`).set({ dailyLimit: 2 }, { merge: true });
await call(fnQuestAttend, { questId: ATTEND_QUEST });
const second = await call(fnQuestAttend, { questId: ATTEND_QUEST });
expect(second.balanceAfter).toBe(20);
expect(await codeOf(() => call(fnQuestAttend, { questId: ATTEND_QUEST })))
.toBe('E-POP-03');
const progress = await db().doc(`users/${UID}/questProgress/${ATTEND_QUEST}`).get();
expect(progress.get('count')).toBe(2);
});
});
describe('만보기 — S-H-WALK (fn-quest-verify-steps · 정책 원장 B-26)', () => {
const walk = (steps: number) => call(fnQuestVerifySteps, { questId: STEPS_QUEST, steps });
it('첫 구간에 못 미치면 지급하지 않고 사유를 증거에 남긴다', async () => {
expect(await codeOf(() => walk(800))).toBe('E-QST-01');
const logs = await db().collection(`users/${UID}/activityLogs`).get();
expect(logs.size).toBe(1);
expect(only(logs.docs).get('verified')).toBe(false);
expect(only(logs.docs).get('rejectReason')).toBe('E-QST-01');
expect(only(logs.docs).get('value')).toBe(800);
const ledger = await db().collection(`users/${UID}/popTransactions`).get();
expect(ledger.size).toBe(0);
});
it('사람이 낼 수 없는 걸음 수는 비정상으로 끊는다', async () => {
expect(await codeOf(() => walk(150_000))).toBe('E-QST-02');
const logs = await db().collection(`users/${UID}/activityLogs`).get();
expect(only(logs.docs).get('rejectReason')).toBe('E-QST-02');
});
it('구간별 보상이 5/6/7/8 로 정확히 나간다', async () => {
// 1 천보 — 첫 구간.
const first = await walk(1_200);
expect(first.rewarded).toBe(5);
expect(first.clearedTier).toBe(1);
expect(first.nextTierSteps).toBe(5_000);
// 5 천보 — 둘째 구간만 추가로 나간다.
const second = await walk(5_200);
expect(second.rewarded).toBe(6);
expect(second.balanceAfter).toBe(11);
// 1 만보 · 1.5 만보.
expect((await walk(10_100)).rewarded).toBe(7);
const last = await walk(15_500);
expect(last.rewarded).toBe(8);
expect(last.nextTierSteps).toBe(0);
// 원장은 구간마다 한 행이다. 합계 26 POP.
const ledger = await db()
.collection(`users/${UID}/popTransactions`)
.where('reasonCode', '==', 'QUEST_STEPS')
.get();
expect(ledger.docs.map((d) => d.get('amount') as number).sort((a, b) => a - b))
.toEqual([5, 6, 7, 8]);
const wallet = await db().doc(`users/${UID}/wallet/main`).get();
expect(wallet.get('activityBalance')).toBe(26);
});
it('아랫 구간을 건너뛰고 윗 구간만 지급하지 않는다', async () => {
// 한 번에 1.5 만보를 보고해도 1 천보부터 차례로 네 구간이 나간다.
const result = await walk(16_000);
expect(result.rewarded).toBe(26);
expect(result.clearedTier).toBe(4);
const ledger = await db()
.collection(`users/${UID}/popTransactions`)
.where('reasonCode', '==', 'QUEST_STEPS')
.get();
expect(ledger.size).toBe(4);
// 1 천보 구간(5 POP)이 빠진 채 윗 구간만 나간 경우가 없다.
expect(ledger.docs.some((d) => d.get('amount') === 5)).toBe(true);
});
it('같은 구간을 두 번 받지 못한다', async () => {
await walk(5_200);
expect(await codeOf(() => walk(5_400))).toBe('E-POP-03');
const ledger = await db().collection(`users/${UID}/popTransactions`).get();
expect(ledger.size).toBe(2);
});
it('진행 카운터가 받은 구간 수와 함께 간다', async () => {
await walk(10_100);
const progress = await db().doc(`users/${UID}/questProgress/${STEPS_QUEST}`).get();
expect(progress.get('questId')).toBe(STEPS_QUEST);
// 세 구간을 받았다. 지급과 같은 트랜잭션에서 쓰므로 지급 수와 어긋나지 않는다.
expect(progress.get('count')).toBe(3);
expect(progress.get('rewardedPop')).toBe(18);
});
it('단말 시각이 서버와 크게 어긋나면 증거로 쓰지 않는다', async () => {
const drifted = new Date(Date.now() + 3 * 60 * 60 * 1000).toISOString();
expect(await codeOf(
() => call(fnQuestVerifySteps, { questId: STEPS_QUEST, steps: 10_400, occurredAt: drifted }),
)).toBe('E-QST-02');
});
it('FPI 집계가 읽는 HEALTH 축 이벤트를 남긴다', async () => {
await call(fnQuestVerifySteps, {
questId: STEPS_QUEST, steps: 5_200, fandomId: FANDOM,
});
// `fn-fpi-aggregate` 가 읽는 것은 `fpiEvents` 다. 이 문서가 없으면 퀘스트를 깨도
// FPI 가 움직이지 않고 sFPI 를 거쳐 행성 성장에 닿는 경로도 열리지 않는다.
const events = await db().collection(`fandoms/${FANDOM}/fpiEvents`).get();
// 5,200보 = 1 천 · 5 천 두 구간. 구간 1개 = 1 단위이며 보상액(5·6 POP)은 쓰지 않는다(C-34).
expect(events.size).toBe(2);
for (const ev of events.docs) {
expect(ev.get('axis')).toBe('health');
expect(ev.get('value')).toBe(1);
expect(ev.get('verified')).toBe(true);
expect(ev.get('uid')).toBe(UID);
}
});
it('FPI 귀속은 클라이언트 fandomId 가 아니라 서버의 대표 팬덤이다', async () => {
await call(fnQuestVerifySteps, { questId: STEPS_QUEST, steps: 1_200, fandomId: 'someone-else' });
expect((await db().collection('fandoms/someone-else/fpiEvents').get()).size).toBe(0);
expect((await db().collection(`fandoms/${FANDOM}/fpiEvents`).get()).size).toBe(1);
});
it('판정에 실패하면 FPI 이벤트도 남기지 않는다', async () => {
expect(await codeOf(
() => call(fnQuestVerifySteps, { questId: STEPS_QUEST, steps: 800, fandomId: FANDOM }),
)).toBe('E-QST-01');
const events = await db().collection(`fandoms/${FANDOM}/fpiEvents`).get();
expect(events.size).toBe(0);
});
});
describe('클라이언트가 고른 ID 로 한도를 다시 열 수 없다 (리뷰 재검토)', () => {
it('걸음: 없는 퀘스트 · 걸음이 아닌 퀘스트 ID 로는 지급하지 않는다', async () => {
expect(await codeOf(() => call(fnQuestVerifySteps, { questId: 'no-such-walk', steps: 5_200 })))
.toBe('E-QST-01');
expect(await codeOf(() => call(fnQuestVerifySteps, { questId: ATTEND_QUEST, steps: 5_200 })))
.toBe('E-QST-01');
expect((await db().collection(`users/${UID}/popTransactions`).get()).size).toBe(0);
});
it('Music Shake: 곡은 퀘스트 카탈로그와 대조하고 하루 한도는 곡이 아니라 퀘스트 단위다', async () => {
await db().doc('quests/music-e2e').set({
type: 'musicShake', trackId: 't1', totalSegments: 10, dailyLimit: 1,
});
const shake = (trackId: string, extra: Record<string, unknown> = {}) => call(fnQuestVerifyMusicShake, {
questId: 'music-e2e', trackId, totalSegments: 10,
judgement: { perfect: 10, good: 0, miss: 0, maxCombo: 10 }, ...extra,
});
expect(await codeOf(() => shake('t1', { questId: 'no-such-music' }))).toBe('E-QST-01');
expect(await codeOf(() => shake('t2'))).toBe('E-QST-01');
expect(await codeOf(() => shake('t1', {
totalSegments: 1, judgement: { perfect: 1, good: 0, miss: 0, maxCombo: 1 },
}))).toBe('E-QST-02');
// 동시 요청도 하루 1회. 곡 ID 를 바꿔도 다시 열리지 않는다.
await Promise.allSettled([shake('t1'), shake('t1')]);
expect(await codeOf(() => shake('t1'))).toBe('E-POP-03');
const ledger = await db().collection(`users/${UID}/popTransactions`).get();
expect(ledger.size).toBe(1);
});
/** 오늘의 출석으로 기본 보상 원장 행을 만든다 — 광고 증분의 기준 행이다. */
async function baseReward(attendPop = 10): Promise<string> {
await db().doc('config/policy').set({ attendPop }, { merge: true });
return (await call(fnQuestAttend, { questId: ATTEND_QUEST })).txId as string;
}
const adRows = async () => (await db().collection(`users/${UID}/popTransactions`)
.where('reasonCode', '==', 'AD_REWARD').get()).docs;
it('광고: 노출 ID 를 바꿔 동시에 불러도 일일 한도를 넘지 않고, 같은 노출 재전송은 첫 결과를 돌려준다', async () => {
const baseTxId = await baseReward();
await db().doc('config/policy').set({ adDailyLimit: 1 }, { merge: true });
await Promise.allSettled([
call(fnAdReward, { adImpressionId: `imp-a-${Date.now()}`, baseTxId }),
call(fnAdReward, { adImpressionId: `imp-b-${Date.now()}`, baseTxId }),
]);
const ledger = await adRows();
expect(ledger.length).toBe(1);
await db().doc('config/policy').set({ adDailyLimit: 5 }, { merge: true });
// 성공한 호출의 재전송(응답 유실) — 오류가 아니라 같은 txId 로 성공, 지급은 늘지 않는다.
const used = ledger[0]!.get('refPath') as string;
const again = await call(fnAdReward, { adImpressionId: used.split('/')[1], baseTxId });
expect(again.duplicate).toBe(true);
expect(again.txId).toBe(ledger[0]!.id);
// 앱은 이 값을 보상 화면에 띄운다 — 재전송에서도 원장 행의 실제 지급액이다.
expect(again.rewarded).toBe(ledger[0]!.get('amount'));
expect((await adRows()).length).toBe(1);
});
it('광고: 운영 Quest 는 기본 x2 증분만 기여 POP 으로 지급한다 (B-03)', async () => {
const baseTxId = await baseReward(30);
const reply = await call(fnAdReward, { adImpressionId: 'imp-x2', baseTxId });
expect(reply.rewarded).toBe(30);
const row = only(await adRows());
expect(row.get('popType')).toBe('support');
expect(row.get('amount')).toBe(30);
// 기본 보상은 일반 POP 그대로 — 원장 2건으로 분리된다.
const wallet = await db().doc(`users/${UID}/wallet/main`).get();
expect(wallet.get('activityBalance')).toBe(30);
expect(wallet.get('supportBalance')).toBe(30);
});
it('광고: 지정 Quest 의 x3 은 기본 보상의 2배를 증분으로 준다 (B-03)', async () => {
await db().doc(`quests/${ATTEND_QUEST}`).update({ adMultiplier: 3 });
const baseTxId = await baseReward(30);
expect((await call(fnAdReward, { adImpressionId: 'imp-x3', baseTxId })).rewarded).toBe(60);
});
it('광고: 사용자 생성 Quest 는 기본 보상과 무관한 고정 10 기여 POP 이다 (C-02 · B-28)', async () => {
await db().doc(`quests/${ATTEND_QUEST}`).update({ authorUid: 'creator-1' });
const baseTxId = await baseReward(30);
expect((await call(fnAdReward, { adImpressionId: 'imp-ugc', baseTxId })).rewarded).toBe(10);
const row = only(await adRows());
expect(row.get('popType')).toBe('support');
});
it('광고: 기본 보상 한 건에 광고 보상은 한 번 · 퀘스트 보상이 아닌 행에는 붙지 않는다', async () => {
const baseTxId = await baseReward();
await call(fnAdReward, { adImpressionId: 'imp-once-1', baseTxId });
expect(await codeOf(() => call(fnAdReward, { adImpressionId: 'imp-once-2', baseTxId })))
.toBe('E-POP-03');
expect(await codeOf(() => call(fnAdReward, { adImpressionId: 'imp-none', baseTxId: 'no-such-tx' })))
.toBe('E-QST-01');
const adTx = only(await adRows()).id;
expect(await codeOf(() => call(fnAdReward, { adImpressionId: 'imp-self', baseTxId: adTx })))
.toBe('E-QST-01');
});
});
describe('방문 — S-H-PLACE (fn-quest-verify-place)', () => {
const inRange = { questId: PLACE_QUEST, placeId: PLACE, lat: 37.5564, lng: 126.9237 };
const farAway = { questId: PLACE_QUEST, placeId: PLACE, lat: 37.4979, lng: 127.0276 };
it('반경 밖이면 거부하고 거리를 증거에 남긴다', async () => {
expect(await codeOf(
() => call(fnQuestVerifyPlace, { ...farAway, proofKind: 'qr', proof: 'tok-far' }),
)).toBe('E-QST-03');
const logs = await db().collection(`users/${UID}/activityLogs`).get();
expect(only(logs.docs).get('verified')).toBe(false);
expect(only(logs.docs).get('value')).toBeGreaterThan(100);
});
it('모의 위치는 반경 안이어도 비정상으로 끊는다', async () => {
expect(await codeOf(
() => call(fnQuestVerifyPlace, {
...inRange, mocked: true, proofKind: 'qr', proof: 'tok-mock',
}),
)).toBe('E-QST-02');
const logs = await db().collection(`users/${UID}/activityLogs`).get();
expect(only(logs.docs).get('rejectReason')).toBe('E-QST-02');
const evidence = only(logs.docs).get('evidence') as
{ place: { gps: { mocked: boolean } } };
expect(evidence.place.gps.mocked).toBe(true);
});
it('좌표만으로도 반경 안이면 지급한다 — QR · 사진은 추가 수단(C-03)', async () => {
const result = await call(fnQuestVerifyPlace, inRange);
expect(result.rewarded).toBe(100);
const logs = await db().collection(`users/${UID}/activityLogs`).get();
expect(only(logs.docs).get('type')).toBe('placeGps');
expect(only(logs.docs).get('verified')).toBe(true);
});
it('같은 Quest 는 기본 1회다 — 다른 날에도, 동시 요청에도(C-12)', async () => {
const q = { ...inRange, questId: 'place-quest-once' };
await Promise.all([call(fnQuestVerifyPlace, q), call(fnQuestVerifyPlace, q)]);
expect(await codeOf(() => call(fnQuestVerifyPlace, q))).toBe('E-POP-03');
const ledger = await db().collection(`users/${UID}/popTransactions`).get();
expect(ledger.size).toBe(1);
// 같은 장소의 다른 Quest 는 별개 조건이다.
await call(fnQuestVerifyPlace, { ...inRange, questId: 'place-quest-other' });
expect((await db().collection(`users/${UID}/popTransactions`).get()).size).toBe(2);
});
it('반복형은 직전 유효 인증부터 간격이 지나야 하고 최대 횟수까지다(C-12)', async () => {
const q = { ...inRange, questId: 'place-quest-repeat' };
await call(fnQuestVerifyPlace, q);
expect(await codeOf(() => call(fnQuestVerifyPlace, q))).toBe('E-POP-03');
// 간격이 지난 것처럼 직전 인증 시각을 25시간 앞으로 옮긴다.
const progress = db().doc(`users/${UID}/questProgress/place-quest-repeat`);
await progress.update({ lastVerifiedAt: Timestamp.fromMillis(Date.now() - 25 * 3_600_000) });
await call(fnQuestVerifyPlace, q);
await progress.update({ lastVerifiedAt: Timestamp.fromMillis(Date.now() - 25 * 3_600_000) });
expect(await codeOf(() => call(fnQuestVerifyPlace, q))).toBe('E-POP-03');
expect((await progress.get()).get('count')).toBe(2);
});
it('없는 퀘스트 · 다른 장소의 퀘스트 · 다른 팬덤 퀘스트로는 지급하지 않는다(리뷰 B2 · C-16)', async () => {
expect(await codeOf(() => call(fnQuestVerifyPlace, { ...inRange, questId: 'no-such-quest' })))
.toBe('E-QST-01');
await db().doc('quests/place-elsewhere').set({ type: 'location', placeId: 'other-place' });
expect(await codeOf(() => call(fnQuestVerifyPlace, { ...inRange, questId: 'place-elsewhere' })))
.toBe('E-QST-03');
await db().doc('quests/place-other-fandom').set({ type: 'location', placeId: PLACE, fandomId: 'another' });
expect(await codeOf(() => call(fnQuestVerifyPlace, { ...inRange, questId: 'place-other-fandom' })))
.toBe('E-PERM-01');
// 대표 팬덤 Quest 는 허용한다.
await db().doc('quests/place-my-fandom').set({ type: 'location', placeId: PLACE, fandomId: FANDOM });
await call(fnQuestVerifyPlace, { ...inRange, questId: 'place-my-fandom' });
expect((await db().collection(`users/${UID}/popTransactions`).get()).size).toBe(1);
});
it('반경 안 + QR 이면 장소 보상값으로 지급하고 인증 재사용을 막는다', async () => {
const result = await call(
fnQuestVerifyPlace, { ...inRange, proofKind: 'qr', proof: 'tok-ok' },
);
expect(result.rewarded).toBe(100);
expect(result.distanceM).toBeLessThanOrEqual(100);
const logs = await db().collection(`users/${UID}/activityLogs`).get();
expect(only(logs.docs).get('verified')).toBe(true);
// 같은 QR 토큰을 다른 Quest 에 다시 쓰는 시도.
expect(await codeOf(
() => call(fnQuestVerifyPlace, { ...inRange, questId: 'place-quest-reuse', proofKind: 'qr', proof: 'tok-ok' }),
)).toBe('E-POP-03');
});
});
describe('검증 활동 집계 규칙 — Quest 완료 1건 · 하루 1회 (Campaign 개설자격 A안)', () => {
const count = () => verifiedQuestCountOf(UID);
it('출석은 한도 2회여도 하루 1회 · 걸음은 구간 수와 무관하게 하루 1회', async () => {
await db().doc(`quests/${ATTEND_QUEST}`).set({ dailyLimit: 2 }, { merge: true });
await call(fnQuestAttend, { questId: ATTEND_QUEST });
await call(fnQuestAttend, { questId: ATTEND_QUEST });
expect(await count()).toBe(1);
await call(fnQuestVerifySteps, { questId: STEPS_QUEST, steps: 16_000 }); // 네 구간
expect(await count()).toBe(2);
});
it('Music Shake 는 완주마다 1회 · 방문은 1회', async () => {
await db().doc('quests/music-count').set({ type: 'musicShake', trackId: 't1', totalSegments: 10, dailyLimit: 2 });
const shake = () => call(fnQuestVerifyMusicShake, {
questId: 'music-count', trackId: 't1', totalSegments: 10,
judgement: { perfect: 10, good: 0, miss: 0, maxCombo: 10 },
});
await shake();
await shake();
expect(await count()).toBe(2);
await call(fnQuestVerifyPlace, { questId: PLACE_QUEST, placeId: PLACE, lat: 37.5564, lng: 126.9237 });
expect(await count()).toBe(3);
});
it('무효 처리는 원장 행으로 찾아 한 번만 빠진다 · 운영자만', async () => {
const { txId } = await call(fnQuestAttend, { questId: ATTEND_QUEST });
const run = (token: Record<string, unknown>) => fnAdminCompletionVoid.run(
{ data: { uid: UID, txId, reason: '허위 출석' }, auth: { uid: 'ops-1', token } } as never) as Promise<Reply>;
expect(await codeOf(() => run({}))).toBe('E-PERM-01');
expect((await run({ role: 'ops' })).voided).toBe(1);
expect((await run({ role: 'ops' })).voided).toBe(0);
expect(await count()).toBe(0);
});
});
describe('FPI 원천 = 인정 활동 (C-34 · C-35)', () => {
const fpiOf = async () => (await db().collection(`fandoms/${FANDOM}/fpiEvents`).get()).docs;
it('출석은 COMMUNITY 축 1 단위이고 보상 750 · 1 POP 이 같은 점수다', async () => {
await db().doc('config/policy').set({ attendPop: 750 }, { merge: true });
await call(fnQuestAttend, { questId: ATTEND_QUEST });
await db().doc(`quests/${ATTEND_QUEST}`).update({ dailyLimit: 2 });
await db().doc('config/policy').set({ attendPop: 1 }, { merge: true });
await call(fnQuestAttend, { questId: ATTEND_QUEST });
const events = await fpiOf();
expect(events.map((e) => [e.get('axis'), e.get('value')])).toEqual([['community', 1], ['community', 1]]);
// 지급 POP 필드가 FPI 원천에 없다(G-05).
expect(events.every((e) => e.get('amount') === undefined && e.get('pop') === undefined)).toBe(true);
// 원장에는 두 보상액이 그대로 남는다 — FPI 와 분리.
const ledger = await db().collection(`users/${UID}/popTransactions`).get();
expect(ledger.docs.map((d) => d.get('amount')).sort((a, b) => a - b)).toEqual([1, 750]);
// User EXP 도 금액이 아니라 인정 활동 건수다 — 750 POP 과 1 POP 출석이 각각 100(C-18).
expect((await db().doc(`users/${UID}`).get()).get('userExp')).toBe(200);
});
it('현장 방문은 COMMUNITY 축 1 단위다(장소 보상 100 POP 과 무관)', async () => {
await call(fnQuestVerifyPlace, { questId: PLACE_QUEST, placeId: PLACE, lat: 37.5564, lng: 126.9237, proofKind: 'qr', proof: 'tok-fpi' });
const events = await fpiOf();
expect(events.map((e) => [e.get('axis'), e.get('value')])).toEqual([['community', 1]]);
});
it('보상이 0 이어도 인정된 기여 활동은 FPI 에 반영된다', async () => {
// Zap 은 POP 을 쓰는 기여다. 지급 보상은 없고, 누른 횟수가 CONTRIBUTION 단위다.
await grantPop({ uid: UID, popType: 'activity', amount: 30, reasonCode: 'ADMIN_GRANT', idempotencyKey: `seed-zap:${UID}` });
const { spendPop } = await import('../src/ledger/ledger.js');
await spendPop({ uid: UID, popType: 'activity', amount: 30, reasonCode: 'ZAP_CONTRIBUTION',
fandomId: FANDOM, count: 3, idempotencyKey: `zap:${UID}:1` });
const events = await fpiOf();
expect(events.map((e) => [e.get('axis'), e.get('value')])).toEqual([['contribution', 3]]);
});
});
describe('POP → 행성 반영 (별첨 1 ② 완료 시나리오 1)', () => {
it('출석으로 받은 POP 을 캠페인에 쓰면 화면이 읽는 필드가 함께 움직인다', async () => {
// 0. 화면이 읽을 자리 — 팬덤·멤버 문서. 성운 생성·행성 탄생 경로는 planet_create.test.ts.
await db().doc(`fandoms/${FANDOM}`).set({
name: '퀘스트 루프 팬덤', fpiScore: 0, memberCount: 1, growthPop: 0,
}, { merge: true });
await db().doc(`fandoms/${FANDOM}/members/${UID}`).set({ uid: UID, fandomId: FANDOM, contributedPop: 0 });
// 다음 단계 문턱은 마스터 데이터다(설계서 §2.3). 값은 발주사 수치표 v1.4
// 「Stage Summary」의 누계이며, 운영에서는 `firebase/seed-demo.py` 가 같은 표를 넣는다.
await db().doc('planetStages/2').set({
stageNo: 2, levelFrom: 21, levelTo: 40, requiredPop: 100_000, requiredSfpi: 15_000,
});
// 1. 활동 보상 — 무료 POP 지급.
await call(fnQuestAttend, { questId: ATTEND_QUEST });
// 후원할 금액을 만든다. 지갑만 올리지 않고 원장을 지난다 — 3자 대조가 깨지면
// 차감이 E-POP-02 로 막힌다.
await grantPop({
// 후원은 기여 POP 만 쓴다(원장 B-33) — 기여 잔액을 만든다.
uid: UID, popType: 'support', amount: 3_000,
reasonCode: 'ADMIN_GRANT', idempotencyKey: `seed:${UID}`,
});
// 2. 받은 POP 을 캠페인에 쓴다 — 기여는 **쓸 때** 집계된다(설계서 §3.6).
await db().doc('campaigns/campaign-quest-e2e').set({
fandomId: FANDOM, status: 'funding', goalPop: 10_000, raisedPop: 0,
endsAt: Timestamp.fromMillis(Date.now() + 86_400_000),
createdAt: FieldValue.serverTimestamp(),
});
await call(fnCampaignFund, {
campaignId: 'campaign-quest-e2e', amountPop: 3_000,
clientSeq: '1',
});
// 3. HOME·MY 가 읽는 필드가 실제로 움직였는가.
const user = await db().doc(`users/${UID}`).get();
expect(user.get('totalContributedPop')).toBe(3_000);
// EXP 는 원장이 움직일 때 함께 갱신된다 — 출석 보상 10 + 후원 3,000(C-18 단순안).
expect(user.get('tier')).toBeUndefined();
// EXP 는 출석(인정 활동 1건)만 — 후원은 EXP 대상이 아니다(리뷰 B1).
expect(user.get('userExp')).toBe(100);
expect(user.get('userLevel')).toBe(1);
const member = await db().doc(`fandoms/${FANDOM}/members/${UID}`).get();
expect(member.get('contributedPop')).toBe(3_000);
// 보상 10 + 후원 3,000. 관리자 지급은 성장에 포함하지 않는다.
expect((await db().doc(`fandoms/${FANDOM}`).get()).get('growthPop')).toBe(3_010);
expect((await db().collection(`users/${UID}/growthEvents`).get()).size).toBe(2);
// 4. 잔액은 출석 보상 10 만 남는다. 활동 POP 과 기여 집계가 섞이지 않는다.
const wallet = await db().doc(`users/${UID}/wallet/main`).get();
expect(wallet.get('activityBalance')).toBe(10);
// 5. 앱이 이 상태를 읽어 HOME·MY 에 그리는지는 기기에서 잰다 —
// `integration_test/pop_loop_emulator_test.dart` 가 같은 에뮬레이터를 물고
// 여기서 남긴 문서를 그대로 읽는다. 그래서 이 테스트는 상태를 지우지 않는다.
});
});
@@ -0,0 +1,436 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { getFirestore, GeoPoint, Timestamp } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
import { DEFAULT_POLICY } from '../src/core/config.js';
import { grantPop } from '../src/ledger/ledger.js';
import { verifiedQuestCountOf } from '../src/ledger/fpi.js';
import { fnAdReward } from '../src/quest/quest.js';
import { fnQuestVerifyPlace } from '../src/quest/verify.js';
import {
isPrivateAddress, linkPreview, parseHtmlMeta, parsePublicUrl,
} from '../src/quest/link_preview.js';
import {
DEFAULT_QUEST_CATALOG, DRAFT_TTL_MS, createQuest, editQuest, endsAtOf, feeOf, fnQuestEdit,
reviewSubmission, settleQuestPool, submitQuest, sweepDrafts,
} from '../src/quest/ugc.js';
import { LINK_PREVIEW_PER_MINUTE, takeLinkPreviewSlot } from '../src/quest/link_preview.js';
import { spendSplit } from '../src/ledger/ledger.js';
import { txIdOf } from '../src/core/ids.js';
/**
* 사용자 생성 Quest — QA 체크리스트 v1.2 시험 사례 3-16 ~ 3-28(C-01 ~ C-12 · C-16 · C-17 · G-01 · G-03 · G-05).
*/
const CREATOR = 'test-ugc-creator';
const PLAYER = 'test-ugc-player';
const FAN = 'f-ugc-a';
const OTHER = 'f-ugc-b';
const PLACE = 'place-ugc';
const BAD_HOST = 'https://quest-ugc.invalid/page'; // 형식은 유효 · 이름 풀이 실패 → preview_failed
const db = () => getFirestore();
const HOUR = 3_600_000;
async function codeOf(run: () => Promise<unknown>): Promise<string> {
try {
await run();
} catch (error) {
const e = error as { details?: { code?: string }; code?: string };
return e.details?.code ?? e.code ?? 'unknown';
}
throw new Error('실패해야 하는 호출이 성공했습니다.');
}
const call = (fn: { run(request: never): unknown }, uid: string, data: Record<string, unknown>) =>
fn.run({ data, auth: { uid, token: {} } } as unknown as never) as Promise<Record<string, unknown>>;
async function user(uid: string, fandom: string, ageHours = 48) {
await db().doc(`users/${uid}`).set({
fandomId: fandom, primaryFandomId: fandom, nickname: uid, status: 'active',
createdAt: Timestamp.fromMillis(Date.now() - ageHours * HOUR),
});
}
async function wallet(uid: string) {
const w = await db().doc(`users/${uid}/wallet/main`).get();
return { general: (w.get('activityBalance') as number) ?? 0, contribution: (w.get('supportBalance') as number) ?? 0 };
}
let seq = 0;
const create = (over: Record<string, unknown> = {}) => createQuest({
uid: CREATOR, clientSeq: `s${++seq}`, activityKey: 'vote', title: '투표 인증', description: '인증샷',
rewardPop: 5, capacity: 10, utcOffsetMinutes: 540, url: BAD_HOST, manualTitle: '뮤직어워드 투표',
...over,
});
const quest = async (id: string) => (await db().doc(`quests/${id}`).get()).data()!;
beforeAll(() => { bootAdmin(); });
afterAll(async () => { await shutdownAdmin(); });
beforeEach(async () => {
for (const uid of [CREATOR, PLAYER]) await wipeUser(uid);
for (const c of ['quests', 'questSubmissions', 'posts', 'adRewardLogs', 'questPostUses']) {
await db().recursiveDelete(db().collection(c));
}
await db().recursiveDelete(db().collection(`users/${CREATOR}/rateLimits`));
await db().doc('config/questCatalog').delete();
await user(CREATOR, FAN);
await user(PLAYER, FAN);
// 생성 자격(B-29) — 검증 활동 1회 + 예치 잔액(일반 40 · 기여 100).
await grantPop({ uid: CREATOR, popType: 'activity', amount: 40, reasonCode: 'QUEST_ATTEND', idempotencyKey: 'ugc:seed:g', growthSource: 'quest_reward' });
await db().doc(`users/${CREATOR}/verifiedCompletions/seed_2026-09-01`).set({ questId: 'seed' });
await grantPop({ uid: CREATOR, popType: 'support', amount: 100, reasonCode: 'IAP_PURCHASE', idempotencyKey: 'ugc:seed:c' });
await db().doc(`places/${PLACE}`).set({ name: '홍대', geo: new GeoPoint(37.55, 126.92), radiusM: 100 });
});
describe('순수 규칙', () => {
const policy = DEFAULT_POLICY;
it('C-06 기간 — 기본 +24h · 생성자 시간대 23:59:59 · 31일째 · 1시간 미만 거부', () => {
const at = new Date('2026-09-28T14:30:00Z'); // KST 23:30
expect(endsAtOf(at, undefined, 540, policy).getTime()).toBe(at.getTime() + 24 * HOUR);
expect(() => endsAtOf(at, '2026-09-28', 540, policy)).toThrow(/최소 1시간/);
expect(endsAtOf(at, '2026-09-29', 540, policy).toISOString()).toBe('2026-09-29T14:59:59.000Z');
expect(endsAtOf(at, '2026-10-27', 540, policy).toISOString()).toBe('2026-10-27T14:59:59.000Z'); // 30일째
expect(() => endsAtOf(at, '2026-10-28', 540, policy)).toThrow(/30일/); // 31일째
expect(() => endsAtOf(at, '2026-09-27', 540, policy)).toThrow();
});
it('B-30 수수료 — Pool 50 → 10 · 500 → 50 · 무보상(Pool 0)은 0(Q-UQ-02 A안) · 설정을 켜면 최소 10', () => {
expect(feeOf(50, policy)).toBe(10);
expect(feeOf(500, policy)).toBe(50);
expect(feeOf(0, policy)).toBe(0);
expect(feeOf(0, { ...policy, ugcFeeOnNoReward: true })).toBe(10);
});
it('C-01 기본 카탈로그 — 4 카테고리 ↔ 10 활동 · ugcAllowed 초기값', () => {
const map = Object.fromEntries(DEFAULT_QUEST_CATALOG.categories.map((c) => [c.key,
DEFAULT_QUEST_CATALOG.activities.filter((a) => a.categoryKey === c.key).map((a) => a.key)]));
expect(map).toEqual({
health: ['walk', 'shake'], planet: ['streaming', 'youtube', 'vote', 'sns'],
community: ['community'], location: ['location', 'qr', 'nfc'],
});
const open = DEFAULT_QUEST_CATALOG.activities.filter((a) => a.ugcAllowed).map((a) => a.key);
expect(open).toEqual(['vote', 'sns', 'community', 'location']);
});
it('C-05 SSRF — http(s) 공개 주소만', async () => {
expect(parsePublicUrl('https://youtu.be/abc')).not.toBeNull();
for (const bad of ['ftp://x.com', 'javascript:alert(1)', 'http://127.0.0.1/', 'http://localhost/',
'http://10.0.0.1', 'http://[::1]/', 'http://u:p@x.com', 'http://x.com:8080/', 'not a url']) {
expect(parsePublicUrl(bad)).toBeNull();
}
for (const ip of ['169.254.169.254', '192.168.1.1', '172.20.0.1', '100.64.0.1', '::ffff:127.0.0.1', 'fd00::1', 'fe80::1']) {
expect(isPrivateAddress(ip)).toBe(true);
}
expect(isPrivateAddress('8.8.8.8')).toBe(false);
expect((await linkPreview('http://169.254.169.254/latest')).status).toBe('invalid');
expect((await linkPreview(BAD_HOST)).status).toBe('preview_failed');
});
it('C-05 메타 파싱은 악성 입력에서도 선형 — ReDoS', () => {
const t0 = performance.now();
parseHtmlMeta('<meta'.repeat(100_000));
parseHtmlMeta(`<meta property="og:title" content="${'a'.repeat(200_000)}`);
parseHtmlMeta(`<title>${'<title>'.repeat(50_000)}`);
expect(performance.now() - t0).toBeLessThan(200);
// </head> 뒤 · 64KB 뒤는 보지 않는다.
expect(parseHtmlMeta('<head></head><meta property="og:title" content="late">').title).toBeUndefined();
});
it('LOW 추가 차단 대역 · 링크 미리보기 분당 상한', async () => {
for (const ip of ['198.51.100.7', '203.0.113.9', '2002:c000:0204::1']) expect(isPrivateAddress(ip)).toBe(true);
for (let i = 0; i < LINK_PREVIEW_PER_MINUTE; i += 1) await takeLinkPreviewSlot(CREATOR);
expect(await codeOf(() => takeLinkPreviewSlot(CREATOR))).toBe('resource-exhausted');
await takeLinkPreviewSlot(CREATOR, Date.now() + 61_000); // 다음 창
});
it('C-05 OG 메타 파싱', () => {
const meta = parseHtmlMeta('<meta property="og:title" content="A &amp; B"><meta content="https://img.x/1.png" property="og:image"><meta property="og:site_name" content="X">');
expect(meta).toEqual({ title: 'A & B', thumbnail: 'https://img.x/1.png', provider: 'X' });
expect(parseHtmlMeta('<title>Only</title><meta property="og:image" content="http://10.0.0.1/a.png">'))
.toEqual({ title: 'Only', thumbnail: undefined, provider: undefined });
});
});
describe('생성', () => {
it('3-20 무효 URL 저장 0건 · 조회 실패는 제목 입력 시 기본 카드로 저장', async () => {
expect(await codeOf(() => create({ url: 'ftp://nope' }))).toBe('invalid-argument');
expect(await codeOf(() => create({ manualTitle: undefined }))).toBe('invalid-argument');
expect((await db().collection('quests').get()).size).toBe(0);
const { questId } = await create();
const q = await quest(questId);
expect(q.status).toBe('published');
expect(q.link).toMatchObject({ status: 'preview_failed', title: '뮤직어워드 투표', url: BAD_HOST });
expect(q.userDescription).toBe('인증샷');
});
it('B-30 · B-06 예치 50 + 수수료 10 — 일반 우선 · 성장 원천 아님', async () => {
const { questId } = await create();
expect(await wallet(CREATOR)).toEqual({ general: 0, contribution: 80 });
const q = await quest(questId);
expect(q.pool).toMatchObject({ deposited: 50, depositSupport: 10, paid: 0, reserved: 0, returned: 0 });
expect(q.feePop).toBe(10);
expect(q.fandomId).toBe(FAN);
const growth = await db().collection(`users/${CREATOR}/growthEvents`).get();
expect(growth.size).toBe(1); // 시드의 quest_reward 1건뿐
});
it('Q-UQ-02 무보상 Quest 는 수수료 없이 공개 · 생성 자격은 보상 없는 Quest 완료도 센다', async () => {
await db().doc('config/questCatalog').set({
...DEFAULT_QUEST_CATALOG,
activities: DEFAULT_QUEST_CATALOG.activities.map((a) => (a.key === 'youtube' ? { ...a, ugcAllowed: true } : a)),
});
const make = () => createQuest({
uid: PLAYER, clientSeq: `nr${++seq}`, activityKey: 'youtube', title: '뮤비 시청', description: '',
rewardPop: 0, capacity: 0, utcOffsetMinutes: 540, url: BAD_HOST, manualTitle: '뮤비',
});
// 보상 원장도 검증 활동도 없으면 자격 미달.
expect(await codeOf(make)).toBe('E-PERM-01');
await db().doc(`users/${PLAYER}/verifiedCompletions/seed_2026-09-01`).set({ questId: 'seed' });
const { questId, status } = await make();
expect(status).toBe('published');
expect(await quest(questId)).toMatchObject({ rewardMode: 'none', feePop: 0, feeTxIds: [] });
expect((await db().collection(`users/${PLAYER}/popTransactions`).get()).size).toBe(0);
});
it('보상 없는 Quest 는 1인 하루 ugcFreeQuestDailyCap(기본 3)개까지 · E-QST-04', async () => {
await db().doc('config/questCatalog').set({
...DEFAULT_QUEST_CATALOG,
activities: DEFAULT_QUEST_CATALOG.activities.map((a) => (a.key === 'youtube' ? { ...a, ugcAllowed: true } : a)),
});
const make = () => createQuest({
uid: CREATOR, clientSeq: `cap${++seq}`, activityKey: 'youtube', title: '뮤비', description: '',
rewardPop: 0, capacity: 0, utcOffsetMinutes: 540, url: BAD_HOST, manualTitle: '뮤비',
});
for (let i = 0; i < 3; i++) expect((await make()).status).toBe('published');
const err = await make().then(() => null, (e: unknown) => e);
expect((err as { details?: Record<string, unknown> }).details).toEqual({ code: 'E-QST-04', cap: 3 });
// 보상형은 상한과 무관하다.
expect((await create()).status).toBe('published');
});
it('3-16 · 3-18 카탈로그 — ugcAllowed false · 위치 무관 QR 거부 · Admin 변경 즉시 반영', async () => {
expect(await codeOf(() => create({ activityKey: 'walk' }))).toBe('E-PERM-01');
expect(await codeOf(() => create({ activityKey: 'qr' }))).toBe('E-PERM-01');
await db().doc('config/questCatalog').set({
...DEFAULT_QUEST_CATALOG,
activities: DEFAULT_QUEST_CATALOG.activities.map((a) => (a.key === 'vote' ? { ...a, ugcAllowed: false } : a)),
});
expect(await codeOf(() => create())).toBe('E-PERM-01');
});
it('MEDIUM 3 재시도는 Draft 에 굳힌 값으로만 공개한다', async () => {
const seqKey = 'retry-1';
const ref = db().doc(`quests/${txIdOf(`quest:${CREATOR}:${seqKey}`)}`);
// 예치까지 끝나고 공개 전에 끊긴 Draft 를 흉내 낸다.
const parts = await spendSplit({ uid: CREATOR, amount: 50, reasonCode: 'QUEST_POOL_DEPOSIT', idempotencyKey: `questPool:${CREATOR}:${seqKey}` });
await ref.set({
type: 'vote', authorUid: CREATOR, fandomId: FAN, rewardPop: 5, capacity: 10, feePop: 10,
endsAt: Timestamp.fromMillis(Date.now() + 5 * HOUR), status: 'draft', createdAt: Timestamp.now(),
depositTxIds: parts.map((p) => p.txId),
pool: { deposited: 50, depositSupport: 10, paid: 0, reserved: 0, returned: 0, returnedSupport: 0 },
});
const res = await createQuest({
uid: CREATOR, clientSeq: seqKey, activityKey: 'vote', title: '딴 제목', description: '',
rewardPop: 1, capacity: 1, utcOffsetMinutes: 540, url: BAD_HOST, manualTitle: 'x', endDate: '2099-01-01',
});
expect(res.status).toBe('published');
const q = await quest(ref.id);
expect(q).toMatchObject({ status: 'published', rewardPop: 5, capacity: 10, feePop: 10 });
expect((q.endsAt as Timestamp).toMillis()).toBeLessThan(Date.now() + 6 * HOUR);
expect(await wallet(CREATOR)).toEqual({ general: 0, contribution: 80 });
});
it('MEDIUM 4 15분 넘은 Draft 는 예치를 원타입으로 돌려주고 abandoned', async () => {
const ref = db().doc('quests/stale-draft');
const parts = await spendSplit({ uid: CREATOR, amount: 60, reasonCode: 'QUEST_POOL_DEPOSIT', idempotencyKey: 'stale:1' });
await ref.set({
authorUid: CREATOR, status: 'draft', depositTxIds: parts.map((p) => p.txId),
createdAt: Timestamp.fromMillis(Date.now() - DRAFT_TTL_MS - 1_000),
});
await db().doc('quests/fresh-draft').set({ authorUid: CREATOR, status: 'draft', depositTxIds: [], createdAt: Timestamp.now() });
expect(await wallet(CREATOR)).toEqual({ general: 0, contribution: 80 });
expect(await sweepDrafts()).toBe(1);
expect(await quest('stale-draft')).toMatchObject({ status: 'abandoned', poolRefund: 'done' });
expect((await quest('fresh-draft')).status).toBe('draft');
expect(await wallet(CREATOR)).toEqual({ general: 40, contribution: 100 });
expect(await sweepDrafts()).toBe(0); // 두 번 돌려주지 않는다
expect(await wallet(CREATOR)).toEqual({ general: 40, contribution: 100 });
});
it('MEDIUM 7 시간대는 UTC−12 ~ UTC+14', async () => {
expect(await codeOf(() => create({ utcOffsetMinutes: -13 * 60 }))).toBe('invalid-argument');
expect(await codeOf(() => create({ utcOffsetMinutes: 15 * 60 }))).toBe('invalid-argument');
});
it('B-29 가입 24h 미만 · 잔액 부족은 예치도 없다', async () => {
await user(CREATOR, FAN, 2);
expect(await codeOf(() => create())).toBe('E-PERM-01');
await user(CREATOR, FAN);
expect(await codeOf(() => create({ rewardPop: 50, capacity: 10 }))).toBe('E-POP-01');
expect(await wallet(CREATOR)).toEqual({ general: 40, contribution: 100 });
});
it('3-23 공개 후 잠금 필드 거부 · 제목 · 설명 이력', async () => {
const { questId } = await create();
expect(await codeOf(() => call(fnQuestEdit, CREATOR, { questId, rewardPop: 1 }))).toBe('E-PERM-01');
expect(await codeOf(() => call(fnQuestEdit, CREATOR, { questId, endsAt: 1 }))).toBe('E-PERM-01');
expect(await codeOf(() => editQuest(PLAYER, questId, { title: 'x' }))).toBe('E-PERM-01');
expect((await editQuest(CREATOR, questId, { title: '투표 인증!', description: '인증샷 1장' })).edited)
.toEqual(['title', 'description']);
const edits = await db().collection(`quests/${questId}/edits`).get();
expect(edits.docs.map((d) => [d.get('field'), d.get('before'), d.get('after')]).sort())
.toEqual([['description', '인증샷', '인증샷 1장'], ['title', '투표 인증', '투표 인증!']]);
expect((await quest(questId)).rewardPop).toBe(5);
});
});
describe('참여 · 검토 · 종료', () => {
it('3-27 대표 팬덤이 아닌 팬덤 Quest 거부 · 본인 Quest 거부', async () => {
const { questId } = await create();
await user(PLAYER, OTHER);
expect(await codeOf(() => submitQuest(PLAYER, questId, { text: 'done' }))).toBe('E-PERM-01');
expect(await codeOf(() => submitQuest(CREATOR, questId, { text: 'done' }))).toBe('E-PERM-01');
});
it('3-28 · 3-17 제출 → 대표 팬덤 변경 → 승인 지급 · 4요소 보존 · 광고 보상은 회사 재원', async () => {
const { questId } = await create();
await submitQuest(PLAYER, questId, { url: 'https://x.com/p/1', text: '투표함' });
expect((await quest(questId)).pool.reserved).toBe(5);
await user(PLAYER, OTHER); // 대표 팬덤 변경(C-17)
const subId = `${questId}_${PLAYER}`;
expect((await db().doc(`questSubmissions/${subId}`).get()).get('status')).toBe('submitted');
expect(await codeOf(() => reviewSubmission({ actorUid: PLAYER, isOps: false, submissionId: subId, action: 'approve' })))
.toBe('E-PERM-01');
const paid = await reviewSubmission({ actorUid: CREATOR, isOps: false, submissionId: subId, action: 'approve' });
expect(paid.status).toBe('paid');
const sub = (await db().doc(`questSubmissions/${subId}`).get()).data()!;
expect(sub).toMatchObject({ status: 'paid', payoutStatus: 'succeeded', fandomIdAtSubmit: FAN });
expect(sub.submittedAt).toBeInstanceOf(Timestamp);
expect(sub.evidence).toHaveLength(1);
expect(sub.reviewLog.map((r: { action: string }) => r.action)).toEqual(['submit', 'approve']);
expect((await wallet(PLAYER)).general).toBe(5);
// 운영 검토 통과 = 검증 활동 1회(Campaign 개설자격 A안).
expect(await verifiedQuestCountOf(PLAYER)).toBe(1);
const ad = await call(fnAdReward, PLAYER, { adImpressionId: 'imp-ugc-1', baseTxId: paid.txId });
expect(ad.rewarded).toBe(DEFAULT_POLICY.adRewardPop);
const q = await quest(questId);
expect(q.pool).toMatchObject({ paid: 5, reserved: 0 }); // 광고 보상은 Pool 무관
});
it('3-24 · G-01 반려 — 사유 · 재제출 가능 시 예약 유지 · 보완은 같은 건 · 최종 반려 시 해제', async () => {
const { questId } = await create();
const subId = `${questId}_${PLAYER}`;
await submitQuest(PLAYER, questId, { text: '1차' });
expect(await codeOf(() => reviewSubmission({ actorUid: CREATOR, isOps: false, submissionId: subId, action: 'reject' })))
.toBe('invalid-argument');
await reviewSubmission({ actorUid: CREATOR, isOps: false, submissionId: subId, action: 'reject', reason: '사진 불명확', resubmitAllowed: true });
let sub = (await db().doc(`questSubmissions/${subId}`).get()).data()!;
expect(sub).toMatchObject({ status: 'rejected', rejectReason: '사진 불명확', resubmitAllowed: true });
expect((await quest(questId)).pool.reserved).toBe(5);
await submitQuest(PLAYER, questId, { text: '보완' });
sub = (await db().doc(`questSubmissions/${subId}`).get()).data()!;
expect(sub.status).toBe('submitted');
expect(sub.evidence).toHaveLength(2);
await reviewSubmission({ actorUid: 'ops-1', isOps: true, submissionId: subId, action: 'reject', reason: '조건 불충족' });
expect((await quest(questId)).pool.reserved).toBe(0);
expect(await codeOf(() => submitQuest(PLAYER, questId, { text: '다시' }))).toBe('E-POP-03');
});
it('3-25 · 3-22 종료 — 서버 시각 마감 · 즉시 반환 · 예약 유지 · 자동 승인 0 · Pool 등식', async () => {
const { questId } = await create();
await submitQuest(PLAYER, questId, { text: '제출' });
const ref = db().doc(`quests/${questId}`);
// 25시간 방치 — 자동 승인 경로가 없으므로 여전히 검토 대기.
await db().doc(`questSubmissions/${questId}_${PLAYER}`).update({ submittedAt: Timestamp.fromMillis(Date.now() - 25 * HOUR) });
await ref.update({ endsAt: Timestamp.fromMillis(Date.now() + 1_000) });
expect(await settleQuestPool(questId)).toBe(0); // 마감 −1초: 아직 열림
await ref.update({ endsAt: Timestamp.fromMillis(Date.now() - 1_000) });
expect(await codeOf(() => submitQuest('someone', questId, { text: 'late' }))).toBe('E-QST-01'); // +1초 거부
expect(await settleQuestPool(questId)).toBe(45);
let q = await quest(questId);
expect(q.status).toBe('ended');
expect(q.pool).toMatchObject({ deposited: 50, paid: 0, reserved: 5, returned: 45, returnedSupport: 10 });
expect((await db().doc(`questSubmissions/${questId}_${PLAYER}`).get()).get('status')).toBe('submitted');
expect(await wallet(CREATOR)).toEqual({ general: 35, contribution: 90 });
expect(await settleQuestPool(questId)).toBe(0); // 재실행 멱등
await reviewSubmission({ actorUid: CREATOR, isOps: false, submissionId: `${questId}_${PLAYER}`, action: 'reject', reason: '불충족' });
q = await quest(questId);
expect(q.pool.paid + q.pool.reserved + q.pool.returned).toBe(q.pool.deposited);
expect(q.pool.returned).toBe(50);
const returns = await db().collection(`users/${CREATOR}/popTransactions`).where('reasonCode', '==', 'QUEST_POOL_RETURN').get();
expect(returns.docs.map((d) => d.get('amount')).reduce((a: number, b: number) => a + b, 0)).toBe(50);
});
it('HIGH 2 마감 뒤 보완 대기 반려 건은 정산이 최종 반려로 닫고 예약을 반환한다', async () => {
const { questId } = await create();
const subId = `${questId}_${PLAYER}`;
await submitQuest(PLAYER, questId, { text: '1차' });
await reviewSubmission({ actorUid: CREATOR, isOps: false, submissionId: subId, action: 'reject', reason: '보완 필요', resubmitAllowed: true });
// 마감 전에는 검토자가 닫을 수 없다(보완 대기).
expect(await codeOf(() => reviewSubmission({ actorUid: CREATOR, isOps: false, submissionId: subId, action: 'reject', reason: '끝' })))
.toBe('E-POP-03');
await db().doc(`quests/${questId}`).update({ endsAt: Timestamp.fromMillis(Date.now() - 1_000) });
expect(await codeOf(() => submitQuest(PLAYER, questId, { text: '늦은 보완' }))).toBe('E-QST-01');
expect(await settleQuestPool(questId)).toBe(50);
const sub = (await db().doc(`questSubmissions/${subId}`).get()).data()!;
expect(sub).toMatchObject({ status: 'rejected_final', resubmitAllowed: false, payoutStatus: 'released' });
const q = await quest(questId);
expect(q.pool).toMatchObject({ reserved: 0, returned: 50 });
expect(q.pool.paid + q.pool.reserved + q.pool.returned).toBe(q.pool.deposited);
});
it('HIGH 2 마감 뒤 검토자가 보완 대기 건을 직접 최종 반려할 수 있다', async () => {
const { questId } = await create();
const subId = `${questId}_${PLAYER}`;
await submitQuest(PLAYER, questId, { text: '1차' });
await reviewSubmission({ actorUid: CREATOR, isOps: false, submissionId: subId, action: 'reject', reason: '보완', resubmitAllowed: true });
await db().doc(`quests/${questId}`).update({ endsAt: Timestamp.fromMillis(Date.now() - 1_000) });
const res = await reviewSubmission({ actorUid: 'ops-1', isOps: true, submissionId: subId, action: 'reject', reason: '기한 경과' });
expect(res.status).toBe('rejected_final');
expect((await quest(questId)).pool).toMatchObject({ reserved: 0, returned: 50 });
});
it('3-26 위치 UGC — Pool 에서 보상액 지급 · 기본 1회', async () => {
const { questId } = await create({ activityKey: 'location', url: undefined, placeId: PLACE });
const verify = () => call(fnQuestVerifyPlace, PLAYER, { questId, placeId: PLACE, lat: 37.55, lng: 126.92 });
expect((await verify()).rewarded).toBe(5);
expect(await codeOf(verify)).toBe('E-POP-03');
expect((await quest(questId)).pool).toMatchObject({ paid: 5 });
expect((await quest(questId)).verifyMethods).toEqual(['gps']);
});
it('C-11 Community — 다른 회원 반응이 달린 응원글이면 즉시 지급 · 본인 반응 제외', async () => {
const { questId } = await create({ activityKey: 'community', url: undefined });
const post = db().doc('posts/ugc-post');
// 공개 전에 쓴 글은 세지 않는다.
await db().doc('posts/old-post').set({
fandomId: FAN, authorUid: PLAYER, visibility: 'visible', likeCount: 5,
createdAt: Timestamp.fromMillis(Date.now() - 2 * HOUR),
});
// likeCount 는 라운지 트리거처럼 본인 반응을 포함해 센다.
await post.set({ fandomId: FAN, authorUid: PLAYER, visibility: 'visible', createdAt: Timestamp.now(), likeCount: 1 });
await post.collection('likes').doc(PLAYER).set({ createdAt: Timestamp.now() });
expect(await codeOf(() => submitQuest(PLAYER, questId, {}))).toBe('E-QST-01');
await post.collection('likes').doc('someone-else').set({ createdAt: Timestamp.now() });
await post.update({ likeCount: 2 });
const res = await submitQuest(PLAYER, questId, {});
expect(res).toMatchObject({ status: 'paid', rewarded: 5 });
expect(await codeOf(() => submitQuest(PLAYER, questId, {}))).toBe('E-POP-03');
expect((await quest(questId)).usedPostIds).toEqual(['ugc-post']);
// 같은 글로 다른 Quest 를 채우지 못한다.
const second = await create({ activityKey: 'community', url: undefined });
expect(await codeOf(() => submitQuest(PLAYER, second.questId, {}))).toBe('E-QST-01');
});
});
@@ -0,0 +1,192 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { getFirestore, Timestamp } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
import {
fnInviteCodeGet, fnReferralAttribute, fnReferralRewardOnCompletion, fnReferralSettle,
inviteCodeOf, onReferralCompletionVoided, payReferralReward, settleReferral,
} from '../src/account/referral.js';
/** 추천 — 초대 코드 발급 · 귀속 · 보류 지급 · 24h 정산 · 무효 시 해제/회수(B-69 · B-71). */
const A = 'test-ref-a';
const B = 'test-ref-b';
const C = 'test-ref-c';
const USERS = [A, B, C];
const FANDOM = 'test-ref-fandom';
const db = () => getFirestore();
type AttrReq = Parameters<typeof fnReferralAttribute.run>[0];
const attribute = (uid: string, referralCode: string) =>
fnReferralAttribute.run({ data: { referralCode, deviceRaw: 'device-1' }, auth: { uid, token: {} } } as AttrReq);
const codeOf = async (p: Promise<unknown>) => {
try { await p; return 'ok'; } catch (e) { return (e as { details?: { code?: string } }).details?.code; }
};
const wallet = async (uid: string) => (await db().doc(`users/${uid}/wallet/main`).get()).data() ?? {};
const lock = async (uid: string) => (await db().doc(`referralsByUser/${uid}`).get()).data() ?? {};
const complete = async (uid: string, id = 'q_2026-09-28') => {
await db().doc(`users/${uid}/verifiedCompletions/${id}`).set({ questId: 'q' });
await fnReferralRewardOnCompletion.run({ params: { uid, completionId: id } } as never);
};
const fpiEvents = async () => (await db().collection(`fandoms/${FANDOM}/fpiEvents`).get()).docs;
async function cleanup() {
const owned = await db().collection('inviteCodeOwners').where('uid', 'in', USERS).get();
for (const d of owned.docs) await Promise.all([d.ref.delete(), db().doc(`inviteCodes/${d.id}`).delete()]);
for (const u of USERS) {
await db().doc(`referralsByUser/${u}`).delete();
const rows = await db().collection('referrals').where('referredUid', '==', u).get();
await Promise.all(rows.docs.map((d) => d.ref.delete()));
}
const daily = await db().collection('referralDaily').get();
await Promise.all(daily.docs.filter((d) => USERS.some((u) => d.id.startsWith(`${u}_`))).map((d) => d.ref.delete()));
await db().recursiveDelete(db().doc(`fandoms/${FANDOM}`));
await db().doc('config/policy').delete();
for (const u of USERS) await wipeUser(u);
for (const u of USERS) {
await db().doc(`users/${u}`).set({ uid: u, fandomId: FANDOM, createdAt: Timestamp.now() });
}
}
beforeAll(() => { bootAdmin(); });
afterAll(async () => { await cleanup(); await shutdownAdmin(); });
beforeEach(cleanup);
describe('초대 코드 발급', () => {
it('사용자당 1개 · 10자 32진 · 공개 문서는 {active} 만, 주인은 서버 전용 문서', async () => {
const [x, y] = await Promise.all([inviteCodeOf(A), inviteCodeOf(A)]);
const again = (await fnInviteCodeGet.run({ data: {}, auth: { uid: A, token: {} } } as
Parameters<typeof fnInviteCodeGet.run>[0])).code;
expect(new Set([x, y, again]).size).toBe(1);
expect(x).toMatch(/^[A-HJ-NP-Z2-9]{10}$/);
expect((await db().doc(`inviteCodes/${x}`).get()).data()).toEqual({ active: true });
expect((await db().doc(`inviteCodeOwners/${x}`).get()).get('uid')).toBe(A);
expect(await inviteCodeOf(B)).not.toBe(x);
});
it('형식이 틀린 코드는 조회 전에 거부한다', async () => {
for (const bad of ['short', 'ABCDEFGHI1', 'abcdefgh23', 'ABCDEFGH23/x']) {
expect(await codeOf(attribute(B, bad))).toBe('E-POP-02');
}
// 형식은 맞지만 없는 코드.
expect(await codeOf(attribute(B, 'ZZZZZZZZZ2'))).toBe('E-POP-02');
});
});
describe('귀속 제한', () => {
it('자기 추천 · 서로 추천 · 가입 7일 경과 · 가입자당 1회', async () => {
const codeA = await inviteCodeOf(A);
const codeB = await inviteCodeOf(B);
expect(await codeOf(attribute(A, codeA))).toBe('E-QST-02');
expect((await attribute(B, codeA)).reward).toBe('pending');
expect((await db().doc(`referrals/${B}_${codeA}`).get()).get('deviceHash')).toMatch(/^[0-9a-f]{32}$/);
expect(await codeOf(attribute(A, codeB))).toBe('E-QST-02'); // B→A 가 있으니 A→B 불가
expect(await codeOf(attribute(B, await inviteCodeOf(C)))).toBe('E-POP-03');
await db().doc(`users/${C}`).update({ createdAt: Timestamp.fromMillis(Date.now() - 8 * 86_400_000) });
expect(await codeOf(attribute(C, codeA))).toBe('E-POP-02');
});
it('서로 다른 코드 두 개를 동시에 보내도 귀속은 1건이다', async () => {
const [ca, cb] = [await inviteCodeOf(A), await inviteCodeOf(B)];
const settled = await Promise.allSettled([attribute(C, ca), attribute(C, cb)]);
expect(settled.filter((r) => r.status === 'fulfilled')).toHaveLength(1);
expect((await db().collection('referrals').where('referredUid', '==', C).get()).size).toBe(1);
});
it('추천인 하루 상한(referralDailyCapPerReferrer)을 넘으면 귀속만 남고 capped', async () => {
await db().doc('config/policy').set({ referralDailyCapPerReferrer: 1 });
const codeA = await inviteCodeOf(A);
expect((await attribute(B, codeA)).reward).toBe('pending');
expect((await attribute(C, codeA)).reward).toBe('capped');
await complete(C);
expect((await wallet(A)).pendingActivity ?? 0).toBe(0);
});
it('귀속 전에 검증 활동이 있으면 ineligible — 지급 없음', async () => {
await db().doc(`users/${B}/verifiedCompletions/q_2026-09-27`).set({ questId: 'q' });
expect((await attribute(B, await inviteCodeOf(A))).reward).toBe('ineligible');
await complete(B, 'q_2026-09-28');
expect((await wallet(A)).pendingActivity ?? 0).toBe(0);
});
it('경합 — 잠금보다 먼저 생긴 완료로는 지급하지 않는다', async () => {
await db().doc(`users/${B}/verifiedCompletions/q_early`).set({ questId: 'q' });
// 귀속 트랜잭션이 완료를 못 본 채 잠금을 쓴 상황을 그대로 만든다.
await db().doc(`referrals/${B}_RACE`).set({ referredUid: B, referrerUid: A, reward: 'pending' });
await db().doc(`referralsByUser/${B}`).set({ attributionId: `${B}_RACE`, referrerUid: A, reward: 'pending' });
expect(await payReferralReward(B, 'q_early')).toBe(false);
await db().doc(`users/${B}/verifiedCompletions/q_late`).set({ questId: 'q' });
expect(await payReferralReward(B, 'q_late')).toBe(true);
await db().doc(`referrals/${B}_RACE`).delete();
});
});
describe('보류 지급 → 24h 정산 → 무효 처리', () => {
it('첫 검증 활동은 보류 지급 100 · 20, 정산 뒤 확정 · FPI 1 단위, 재실행해도 한 번', async () => {
await attribute(B, await inviteCodeOf(A));
expect(await wallet(A)).toEqual({});
await complete(B);
await complete(B, 'q_2026-09-29');
expect((await wallet(A)).pendingActivity).toBe(100);
expect((await wallet(B)).pendingActivity).toBe(20);
expect((await wallet(A)).activityBalance).toBe(0);
expect((await lock(B)).reward).toBe('held');
expect(await fpiEvents()).toHaveLength(0);
// 보류 시간이 안 지났으면 정산 작업이 건드리지 않는다.
const run = () => fnReferralSettle.run({ scheduleTime: new Date().toISOString() } as never);
await run();
expect((await lock(B)).reward).toBe('held');
await db().doc(`referralsByUser/${B}`).update({ settleAfter: Timestamp.fromMillis(Date.now() - 1) });
await run();
await run();
expect((await lock(B)).reward).toBe('paid');
expect(await wallet(A)).toMatchObject({ activityBalance: 100, pendingActivity: 0 });
expect(await wallet(B)).toMatchObject({ activityBalance: 20, pendingActivity: 0 });
const events = await fpiEvents();
expect(events.map((e) => [e.get('axis'), e.get('dauEligible')])).toEqual([['referral', false]]);
expect((await db().doc(`users/${A}`).get()).get('userExp')).toBeUndefined();
});
it('정산 전에 유일한 완료가 무효가 되면 두 보류 지급을 해제한다', async () => {
await attribute(B, await inviteCodeOf(A));
await complete(B);
await db().doc(`users/${B}/verifiedCompletions/q_2026-09-28`).update({ voided: true });
expect(await onReferralCompletionVoided(B)).toBe('released');
expect(await wallet(A)).toMatchObject({ activityBalance: 0, pendingActivity: 0 });
expect(await wallet(B)).toMatchObject({ activityBalance: 0, pendingActivity: 0 });
expect(await settleReferral(B)).toBeNull();
expect(await fpiEvents()).toHaveLength(0);
});
it('다른 유효 완료가 남아 있으면 무효 하나로는 건드리지 않는다', async () => {
await attribute(B, await inviteCodeOf(A));
await complete(B, 'q1');
await complete(B, 'q2');
await db().doc(`users/${B}/verifiedCompletions/q1`).update({ voided: true });
expect(await onReferralCompletionVoided(B)).toBeNull();
expect((await lock(B)).reward).toBe('held');
});
it('정산 뒤 무효면 ADMIN_CLAWBACK 으로 회수하고 FPI 이벤트를 집계에서 뺀다(멱등)', async () => {
await attribute(B, await inviteCodeOf(A));
await complete(B);
expect(await settleReferral(B)).toBe('paid');
await db().doc(`users/${B}/verifiedCompletions/q_2026-09-28`).update({ voided: true });
expect(await onReferralCompletionVoided(B)).toBe('clawedback');
expect(await onReferralCompletionVoided(B)).toBeNull();
expect((await wallet(A)).activityBalance).toBe(0);
expect((await wallet(B)).activityBalance).toBe(0);
const reasons = (await db().collection(`users/${A}/popTransactions`).get()).docs.map((d) => d.get('reasonCode'));
expect(reasons.filter((r) => r === 'ADMIN_CLAWBACK')).toHaveLength(1);
expect((await fpiEvents()).map((e) => e.get('verified'))).toEqual([false]);
});
it('이상 표시(flagged)가 있으면 정산 때 해제한다', async () => {
await attribute(B, await inviteCodeOf(A));
await complete(B);
await db().doc(`referralsByUser/${B}`).update({ flagged: true });
expect(await settleReferral(B)).toBe('released');
expect((await wallet(A)).activityBalance).toBe(0);
});
});
+26
View File
@@ -23,3 +23,29 @@ export function bootAdmin(): App {
export async function shutdownAdmin(): Promise<void> {
for (const app of getApps()) await deleteApp(app);
}
/**
* 그 사용자가 남긴 문서를 전부 지운다.
*
* **`users/{uid}` 아래만 지우면 모자란다.** 기여 차감은 팬덤 멤버 문서에도
* 누적되므로, 거기를 남겨 두면 다음 테스트에서 값이 겹쳐 올라간다. 실제로
* 그 때문에 「기여 40」을 기대하는 검증이 회차가 쌓이며 120 을 보았다.
*/
export async function wipeUser(uid: string): Promise<void> {
const db = getFirestore();
for (const sub of [
'popLots', 'popTransactions', 'growthEvents', 'questProgress', 'wallet',
'consents', 'purchases', 'subscription', 'subscriptionEvents', 'adBonuses',
'verifiedCompletions', 'rateLimits',
]) {
const snap = await db.collection(`users/${uid}/${sub}`).get();
await Promise.all(snap.docs.map((d) => d.ref.delete()));
}
await db.doc(`users/${uid}`).delete();
// 이 사용자가 멤버로 올라간 팬덤 문서까지 지운다.
const members = await db.collectionGroup('members').get();
await Promise.all(
members.docs.filter((d) => d.id === uid).map((d) => d.ref.delete()),
);
}
@@ -0,0 +1,192 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { FieldValue, getFirestore, Timestamp } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
import { fnQuestTimeAttack, fnQuestTimeAttackStart, fnServerTime } from '../src/quest/timeattack.js';
import { verifiedQuestCountOf } from '../src/ledger/fpi.js';
/**
* 긴급 돌발 타임어택 — 별첨 1 ③-4 · FR-QST-06 검증 기준
* 「발동·마감 경계 시각의 참여 수락/거부 동작 확인」.
*/
const UID = 'test-timeattack';
const FANDOM = 'fandom-timeattack';
const db = () => getFirestore();
type Reply = Record<string, unknown>;
const call = async (
fn: { run(request: never): unknown },
data: Record<string, unknown>,
token: Record<string, unknown> = {},
uid = UID,
): Promise<Reply> =>
(await fn.run({ data, auth: { uid, token } } as unknown as never)) as Reply;
async function codeOf(run: () => Promise<unknown>): Promise<string> {
try {
await run();
} catch (error) {
return (error as { details?: { code?: string } }).details?.code ?? (error as Error).message;
}
throw new Error('실패해야 하는 호출이 성공했습니다.');
}
/** 창을 직접 박은 퀘스트. [fromMs] · [toMs] 는 지금 기준 상대 ms. */
async function quest(id: string, fromMs: number, toMs: number, extra: Reply = {}) {
await db().doc(`quests/${id}`).set({
type: 'timeattack', title: '돌발', fandomId: FANDOM, rewardPop: 30, dailyLimit: 1, active: true,
activeFrom: Timestamp.fromMillis(Date.now() + fromMs),
activeTo: Timestamp.fromMillis(Date.now() + toMs),
...extra,
});
}
beforeAll(() => { bootAdmin(); });
afterAll(async () => { await shutdownAdmin(); });
beforeEach(async () => {
await wipeUser(UID);
await db().doc(`users/${UID}`).set({ uid: UID, fandomId: FANDOM });
await db().doc(`fandoms/${FANDOM}`).set({ name: '타임어택 행성' }, { merge: true });
await db().recursiveDelete(db().collection(`fandoms/${FANDOM}/fpiEvents`));
});
describe('서버 시각 — fn-server-time', () => {
it('서버 시각(ms)을 돌려준다', async () => {
const before = Date.now();
const reply = await call(fnServerTime, {});
expect(reply.now as number).toBeGreaterThanOrEqual(before);
});
});
describe('타임어택 발동 — fn-quest-timeattack-start', () => {
const start = { fandomId: FANDOM, title: '10분 돌발', rewardPop: 30, durationMinutes: 10 };
it('master 가 아니면 거부한다', async () => {
expect(await codeOf(() => call(fnQuestTimeAttackStart, { ...start, requestId: 'r0' }))).toBe('E-PERM-01');
expect(await codeOf(() => call(fnQuestTimeAttackStart, { ...start, requestId: 'r0' }, { role: 'ops' })))
.toBe('E-PERM-01');
});
it('창을 서버 시각으로 열고 같은 요청의 재전송은 한 건으로 모은다', async () => {
const before = Date.now();
const first = await call(fnQuestTimeAttackStart, { ...start, requestId: 'r1' }, { role: 'master' });
const again = await call(fnQuestTimeAttackStart, { ...start, requestId: 'r1' }, { role: 'master' });
expect(again.questId).toBe(first.questId);
expect(again.duplicate).toBe(true);
const doc = await db().doc(`quests/${first.questId as string}`).get();
expect(doc.get('type')).toBe('timeattack');
expect(doc.get('fandomId')).toBe(FANDOM);
const span = (doc.get('activeTo') as Timestamp).toMillis() - (doc.get('activeFrom') as Timestamp).toMillis();
expect(span).toBe(10 * 60_000);
expect((doc.get('activeFrom') as Timestamp).toMillis()).toBeGreaterThanOrEqual(before - 1000);
});
it('보상 상한(config/policy.timeAttackMaxPop) · 경로 문자 · 긴 제목을 거부한다', async () => {
const saved = (await db().doc('config/policy').get()).data();
await db().doc('config/policy').set({ timeAttackMaxPop: 100 });
try {
expect(await codeOf(() => call(fnQuestTimeAttackStart,
{ ...start, rewardPop: 101, requestId: 'r4' }, { role: 'master' }))).toBe('E-QST-01');
const ok = await call(fnQuestTimeAttackStart,
{ ...start, rewardPop: 100, requestId: 'r5' }, { role: 'master' });
expect(ok.questId).toBeTruthy();
} finally {
await db().doc('config/policy').delete();
}
try {
// 문서가 없으면 기본 상한 1,000.
expect(await codeOf(() => call(fnQuestTimeAttackStart,
{ ...start, rewardPop: 1001, requestId: 'r6' }, { role: 'master' }))).toBe('E-QST-01');
} finally {
if (saved) await db().doc('config/policy').set(saved);
}
expect(await codeOf(() => call(fnQuestTimeAttackStart,
{ ...start, fandomId: 'a/b', requestId: 'r7' }, { role: 'master' }))).toBe('E-QST-01');
expect(await codeOf(() => call(fnQuestTimeAttackStart,
{ ...start, title: 'x'.repeat(61), requestId: 'r8' }, { role: 'master' }))).toBe('E-QST-01');
});
it('하루를 넘는 창 · 없는 팬덤은 거부한다', async () => {
expect(await codeOf(() => call(fnQuestTimeAttackStart,
{ ...start, durationMinutes: 24 * 60 + 1, requestId: 'r2' }, { role: 'master' }))).toBe('E-QST-01');
expect(await codeOf(() => call(fnQuestTimeAttackStart,
{ ...start, fandomId: 'no-such-fandom', requestId: 'r3' }, { role: 'master' }))).toBe('E-QST-01');
});
it('지속 시간 상한은 config/policy.timeAttackMaxMinutes 를 따른다', async () => {
const saved = (await db().doc('config/policy').get()).data();
await db().doc('config/policy').set({ timeAttackMaxMinutes: 30 });
try {
expect(await codeOf(() => call(fnQuestTimeAttackStart,
{ ...start, durationMinutes: 31, requestId: 'r9' }, { role: 'master' }))).toBe('E-QST-01');
const ok = await call(fnQuestTimeAttackStart,
{ ...start, durationMinutes: 30, requestId: 'r10' }, { role: 'master' });
expect(ok.questId).toBeTruthy();
} finally {
await db().doc('config/policy').delete();
if (saved) await db().doc('config/policy').set(saved);
}
});
});
describe('타임어택 참여 — fn-quest-timeattack', () => {
it('마감 뒤 재시도여도 이미 받은 사용자는 첫 결과를 받는다', async () => {
await quest('ta-retry', -60_000, 60_000);
const first = await call(fnQuestTimeAttack, { questId: 'ta-retry' });
await db().doc('quests/ta-retry').update({ activeTo: Timestamp.fromMillis(Date.now() - 1) });
const again = await call(fnQuestTimeAttack, { questId: 'ta-retry' });
expect(again.txId).toBe(first.txId);
expect(again.duplicate).toBe(true);
expect((await db().collection(`users/${UID}/popTransactions`).get()).size).toBe(1);
});
it('창 안 참여는 원장 1행 · EXP · FPI COMMUNITY 1 단위로 남고 재호출은 첫 결과를 돌려준다', async () => {
await quest('ta-open', -60_000, 60_000);
const reply = await call(fnQuestTimeAttack, { questId: 'ta-open' });
expect(reply.rewarded).toBe(30);
expect(reply.balanceAfter).toBe(30);
const again = await call(fnQuestTimeAttack, { questId: 'ta-open' });
expect(again).toMatchObject({ txId: reply.txId, balanceAfter: 30, rewarded: 30, duplicate: true });
// 인정 활동 1건 = EXP 100(C-18) · 검증 활동 1회 · FPI COMMUNITY 1 단위(Q-TA-05 권고 A안).
expect((await db().doc(`users/${UID}`).get()).get('userExp')).toBe(100);
expect(await verifiedQuestCountOf(UID)).toBe(1);
const fpi = await db().collection(`fandoms/${FANDOM}/fpiEvents`).get();
expect(fpi.docs.map((d) => [d.get('axis'), d.get('units'), d.get('activity')]))
.toEqual([['community', 1, 'timeattack']]);
const ledger = await db().collection(`users/${UID}/popTransactions`).get();
expect(ledger.size).toBe(1);
expect(ledger.docs[0]!.get('reasonCode')).toBe('QUEST_TIMEATTACK');
expect((await db().doc('quests/ta-open').get()).get('participantCount')).toBe(1);
expect((await db().doc(`users/${UID}/questProgress/ta-open`).get()).get('count')).toBe(1);
});
it('timeAttackFpiAxis 가 null 이면 FPI 없이 EXP 만(Q-TA-05 B안)', async () => {
await db().doc('config/policy').set({ timeAttackFpiAxis: null }, { merge: true });
try {
await quest('ta-no-fpi', -60_000, 60_000);
await call(fnQuestTimeAttack, { questId: 'ta-no-fpi' });
} finally {
await db().doc('config/policy').update({ timeAttackFpiAxis: FieldValue.delete() });
}
expect((await db().doc(`users/${UID}`).get()).get('userExp')).toBe(100);
expect((await db().collection(`fandoms/${FANDOM}/fpiEvents`).get()).size).toBe(0);
});
it('시작 전 · 마감 후(끝 시각 포함)는 거부한다', async () => {
await quest('ta-early', 60_000, 120_000);
await quest('ta-late', -120_000, -1);
expect(await codeOf(() => call(fnQuestTimeAttack, { questId: 'ta-early' }))).toBe('E-QST-01');
expect(await codeOf(() => call(fnQuestTimeAttack, { questId: 'ta-late' }))).toBe('E-QST-01');
expect((await db().collection(`users/${UID}/popTransactions`).get()).size).toBe(0);
});
it('다른 팬덤 · 다른 유형 퀘스트로는 받지 못한다', async () => {
await quest('ta-other', -60_000, 60_000, { fandomId: 'fandom-else' });
await db().doc('quests/ta-attend').set({ type: 'attend', active: true });
expect(await codeOf(() => call(fnQuestTimeAttack, { questId: 'ta-other' }))).toBe('E-PERM-01');
expect(await codeOf(() => call(fnQuestTimeAttack, { questId: 'ta-attend' }))).toBe('E-QST-01');
});
});
@@ -0,0 +1,298 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { FieldValue, getFirestore, Timestamp } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
import {
fnTranslationMark, fnTranslationSettle, fnTranslationSubmit,
} from '../src/account/translation.js';
import { grantPop, reconcileUser, settlePendingGrant } from '../src/ledger/ledger.js';
/**
* 참여형 번역 왕복 — 등록(보류 지급) → 마킹(자격 판정) → 24시간 뒤 정산(확정/해제).
* 원장 B-63: 1건 5 POP · 24시간 확정대기 · 자기 마킹 제외 · 중복 0 · 일일상한.
*/
const AUTHOR = 'test-trn-author';
const COPIER = 'test-trn-copier';
const MARKER = 'test-trn-marker';
const NEWBIE = 'test-trn-newbie';
const OUTSIDER = 'test-trn-outsider';
const USERS = [AUTHOR, COPIER, MARKER, NEWBIE, OUTSIDER];
const TARGET = 'questions/test-trn-q1';
const POST = 'posts/test-trn-p1';
const CAMPAIGN = 'campaigns/test-trn-c1';
const HIDDEN_POST = 'posts/test-trn-hidden';
const REVIEW_CAMPAIGN = 'campaigns/test-trn-review';
const NO_FANDOM_CAMPAIGN = 'campaigns/test-trn-nofandom';
const DAY = 24 * 3600 * 1000;
type SubmitReq = Parameters<typeof fnTranslationSubmit.run>[0];
type MarkReq = Parameters<typeof fnTranslationMark.run>[0];
const submit = (uid: string, body: string, targetPath = TARGET) => fnTranslationSubmit.run({
data: { targetPath, locale: 'en', body },
auth: { uid, token: {} },
} as SubmitReq);
const mark = (uid: string, entryId: string, valid = true) => fnTranslationMark.run({
data: { entryId, valid },
auth: { uid, token: {} },
} as MarkReq);
const settle = () => fnTranslationSettle.run(
{ scheduleTime: new Date().toISOString() } as Parameters<typeof fnTranslationSettle.run>[0],
);
const age = (entryId: string, hours: number) => getFirestore()
.doc(`translationEntries/${entryId}`)
.update({ createdAt: Timestamp.fromMillis(Date.now() - hours * 3600 * 1000) });
const wallet = async (uid: string) =>
(await getFirestore().doc(`users/${uid}/wallet/main`).get()).data() ?? {};
beforeAll(() => { bootAdmin(); });
afterAll(async () => { await shutdownAdmin(); });
beforeEach(async () => {
const db = getFirestore();
await Promise.all(USERS.map(wipeUser));
const old = await db.collection('translationEntries')
.where('targetPath', 'in', [TARGET, POST, `${POST}/comments/c1`, `${POST}/comments/hidden`, CAMPAIGN, NO_FANDOM_CAMPAIGN]).get();
for (const d of old.docs) {
const marks = await d.ref.collection('markings').get();
await Promise.all(marks.docs.map((m) => m.ref.delete()));
await d.ref.delete();
}
const counters = await db.collection('translationDaily').get();
await Promise.all(counters.docs.filter((d) => USERS.includes(d.get('uid'))).map((d) => d.ref.delete()));
const old2 = Timestamp.fromMillis(Date.now() - 2 * DAY);
await Promise.all([
db.doc(TARGET).set({ title: '질문', fandomId: 'trn-f1' }),
db.doc(POST).set({ body: '글', fandomId: 'trn-f1', visibility: 'visible' }),
db.doc(`${POST}/comments/c1`).set({ body: '댓글', visibility: 'visible' }),
db.doc(`${POST}/comments/hidden`).set({ body: '가린 댓글', visibility: 'hidden' }),
db.doc(HIDDEN_POST).set({ body: '가린 글', fandomId: 'trn-f1', visibility: 'hidden' }),
db.doc(CAMPAIGN).set({ title: '캠페인', fandomId: 'trn-f1', status: 'funding' }),
db.doc(REVIEW_CAMPAIGN).set({ title: '검수 중', fandomId: 'trn-f1', status: 'review' }),
db.doc(NO_FANDOM_CAMPAIGN).set({ title: '팬덤 없음', status: 'funding' }),
db.doc(`users/${AUTHOR}`).set({ createdAt: old2, fandomId: 'trn-f1', displayName: '김실명', nickname: '호떡이' }),
db.doc(`users/${COPIER}`).set({ createdAt: old2, fandomId: 'trn-f1' }),
db.doc(`users/${MARKER}`).set({ createdAt: old2, fandomId: 'trn-f1' }),
db.doc(`users/${NEWBIE}`).set({ createdAt: Timestamp.now(), fandomId: 'trn-f1' }),
db.doc(`users/${OUTSIDER}`).set({ createdAt: old2, fandomId: 'trn-f2' }),
]);
});
describe('번역 등록', () => {
it('보상분은 예약 잔액으로 잡히고(G-05) 일일 카운터가 오른다', async () => {
const first = await submit(AUTHOR, 'Hello fandom');
expect(first).toMatchObject({ rewardPop: 5, excludedReason: null });
const w = await wallet(AUTHOR);
expect(w.activityBalance ?? 0).toBe(0);
expect(w.pendingActivity).toBe(5);
const today = (await getFirestore().collection('translationDaily')
.where('uid', '==', AUTHOR).get()).docs[0]!;
expect(today.get('count')).toBe(1);
});
it('같은 대상·언어 재등록은 E-TRN-05 · 남의 문장 복사는 보상 0 · 예약 없음', async () => {
await submit(AUTHOR, 'Hello fandom');
await expect(submit(AUTHOR, 'Hello again')).rejects.toMatchObject({ details: { code: 'E-TRN-05' } });
expect(await submit(COPIER, 'Hello fandom')).toMatchObject({ rewardPop: 0, excludedReason: 'duplicate' });
expect((await wallet(COPIER)).pendingActivity ?? 0).toBe(0);
});
it('번역 댓글은 글 경로(rootPath)와 작성자 표시명을 함께 남긴다 — 댓글 · 캠페인 대상 포함', async () => {
const onComment = await submit(AUTHOR, 'Nice comment', `${POST}/comments/c1`);
const onCampaign = await submit(AUTHOR, 'Cheer up', CAMPAIGN);
const db = getFirestore();
const a = await db.doc(`translationEntries/${onComment.entryId}`).get();
expect(a.get('rootPath')).toBe(POST);
expect(a.get('fandomId')).toBe('trn-f1');
expect(a.get('authorName')).toBe('호떡이');
const b = await db.doc(`translationEntries/${onCampaign.entryId}`).get();
expect(b.get('rootPath')).toBe(CAMPAIGN);
expect(b.get('fandomId')).toBe('trn-f1');
});
it('작성자 닉네임이 없으면 공급자 실명 대신 「팬」을 싣는다(M-7)', async () => {
await getFirestore().doc(`users/${AUTHOR}`).update({ nickname: FieldValue.delete() });
const { entryId } = await submit(AUTHOR, 'Hello fandom');
const e = await getFirestore().doc(`translationEntries/${entryId}`).get();
expect(e.get('authorName')).toBe('팬');
expect(e.get('validCount')).toBe(0);
expect(e.get('invalidCount')).toBe(0);
});
it('숨긴 글 · 숨긴 댓글 · 공개 전 캠페인은 없는 콘텐츠와 같은 E-TRN-03 이다', async () => {
for (const target of [HIDDEN_POST, `${POST}/comments/hidden`, REVIEW_CAMPAIGN, `${HIDDEN_POST}/comments/x`]) {
await expect(submit(AUTHOR, 'x', target)).rejects.toMatchObject({ details: { code: 'E-TRN-03' } });
}
});
it('없는 콘텐츠와 허용 밖 경로는 같은 E-TRN-03 이다', async () => {
await expect(submit(AUTHOR, 'x', 'questions/none-such')).rejects.toMatchObject({ details: { code: 'E-TRN-03' } });
await expect(submit(AUTHOR, 'x', `users/${MARKER}`)).rejects.toMatchObject({ details: { code: 'E-TRN-03' } });
});
it('일일상한 3배(기본 30건)를 넘기면 E-TRN-02', async () => {
const db = getFirestore();
const { dayKey } = await import('../src/core/time.js');
await db.doc(`translationDaily/${AUTHOR}_${dayKey(new Date())}`).set({ uid: AUTHOR, count: 30 });
await expect(submit(AUTHOR, 'Hello')).rejects.toMatchObject({ details: { code: 'E-TRN-02' } });
});
});
describe('번역 마킹', () => {
it('본인 E-TRN-01 · 가입 24시간 미만 · 다른 팬덤(라운지) E-TRN-04 · 두 번째 E-TRN-05', async () => {
const { entryId } = await submit(AUTHOR, 'Hello fandom', POST);
await expect(mark(AUTHOR, entryId)).rejects.toMatchObject({ details: { code: 'E-TRN-01' } });
await expect(mark(NEWBIE, entryId)).rejects.toMatchObject({ details: { code: 'E-TRN-04' } });
await expect(mark(OUTSIDER, entryId)).rejects.toMatchObject({ details: { code: 'E-TRN-04' } });
await expect(mark(MARKER, entryId)).resolves.toMatchObject({ valid: true });
await expect(mark(MARKER, entryId, false)).rejects.toMatchObject({ details: { code: 'E-TRN-05' } });
// 마킹 수는 같은 트랜잭션에서 번역 문서에 오른다 — 실패한 두 번째 마킹은 세지 않는다.
const e = await getFirestore().doc(`translationEntries/${entryId}`).get();
expect(e.get('validCount')).toBe(1);
expect(e.get('invalidCount')).toBe(0);
});
it('무효 마킹은 invalidCount 를 올린다 · 동시에 두 번 눌러도 한 번만 센다', async () => {
const { entryId } = await submit(AUTHOR, 'Hello fandom', POST);
const results = await Promise.allSettled([mark(MARKER, entryId, false), mark(MARKER, entryId, false)]);
expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1);
const e = await getFirestore().doc(`translationEntries/${entryId}`).get();
expect(e.get('invalidCount')).toBe(1);
expect(e.get('validCount')).toBe(0);
});
it('등록 뒤 가려진 글의 번역은 마킹하지 못한다(E-TRN-03)', async () => {
const { entryId } = await submit(AUTHOR, 'Hello fandom', `${POST}/comments/c1`);
await getFirestore().doc(POST).update({ visibility: 'hidden' });
await expect(mark(MARKER, entryId)).rejects.toMatchObject({ details: { code: 'E-TRN-03' } });
});
it('팬덤이 없는 캠페인의 번역은 아무도 마킹하지 못한다(E-TRN-04)', async () => {
const { entryId } = await submit(AUTHOR, 'Cheer', NO_FANDOM_CAMPAIGN);
await expect(mark(MARKER, entryId)).rejects.toMatchObject({ details: { code: 'E-TRN-04' } });
});
it('캠페인 번역은 그 팬덤 멤버만 마킹한다', async () => {
const { entryId } = await submit(AUTHOR, 'Cheer', CAMPAIGN);
await expect(mark(OUTSIDER, entryId)).rejects.toMatchObject({ details: { code: 'E-TRN-04' } });
await expect(mark(MARKER, entryId)).resolves.toMatchObject({ valid: true });
});
it('INSIDE 번역은 다른 팬덤 회원도 마킹한다(Q-TR-04 권고 B안) · 가입 24시간은 그대로', async () => {
const { entryId } = await submit(AUTHOR, 'Hello fandom');
await expect(mark(NEWBIE, entryId)).rejects.toMatchObject({ details: { code: 'E-TRN-04' } });
await expect(mark(OUTSIDER, entryId)).resolves.toMatchObject({ valid: true });
});
it('운영값을 끄면(A안) INSIDE 도 그 팬덤 멤버만 마킹한다', async () => {
const policy = getFirestore().doc('config/policy');
const before = await policy.get();
await policy.set({ translationInsideAnyMember: false }, { merge: true });
try {
const { entryId } = await submit(AUTHOR, 'Hello fandom');
await expect(mark(OUTSIDER, entryId)).rejects.toMatchObject({ details: { code: 'E-TRN-04' } });
} finally {
if (before.exists) await policy.set(before.data()!);
else await policy.delete();
}
});
it('팬덤 멤버 문서가 있으면 users.fandomId 가 달라도 마킹할 수 있다', async () => {
const { entryId } = await submit(AUTHOR, 'Hello fandom', POST);
await getFirestore().doc(`fandoms/trn-f1/members/${OUTSIDER}`).set({ uid: OUTSIDER });
await expect(mark(OUTSIDER, entryId)).resolves.toMatchObject({ valid: true });
await getFirestore().doc(`fandoms/trn-f1/members/${OUTSIDER}`).delete();
});
});
describe('번역 정산', () => {
it('유효 마킹 1건 이상이면 예약이 사용 가능으로 옮겨지고 대사가 맞는다', async () => {
const { entryId } = await submit(AUTHOR, 'Hello fandom');
await mark(MARKER, entryId);
await age(entryId, 25);
await settle();
expect((await getFirestore().doc(`translationEntries/${entryId}`).get()).get('status')).toBe('confirmed');
const w = await wallet(AUTHOR);
expect(w.activityBalance).toBe(5);
expect(w.pendingActivity).toBe(0);
expect((await reconcileUser(AUTHOR)).ok).toBe(true);
});
it('24시간 전이거나 마킹이 모자라면 pending 을 유지한다', async () => {
const { entryId } = await submit(AUTHOR, 'Hello fandom');
await settle();
await age(entryId, 25);
await settle();
expect((await getFirestore().doc(`translationEntries/${entryId}`).get()).get('status')).toBe('pending');
expect((await wallet(AUTHOR)).pendingActivity).toBe(5);
});
it('최대 대기(72시간)를 넘기면 예약을 해제하고 excluded 로 닫는다', async () => {
const { entryId } = await submit(AUTHOR, 'Hello fandom');
await age(entryId, 73);
await settle();
const entry = await getFirestore().doc(`translationEntries/${entryId}`).get();
expect(entry.get('status')).toBe('excluded');
expect(entry.get('excludedReason')).toBe('unverified');
const w = await wallet(AUTHOR);
expect(w.activityBalance ?? 0).toBe(0);
expect(w.pendingActivity).toBe(0);
expect((await reconcileUser(AUTHOR)).ok).toBe(true);
});
it('보상 0 건은 예약 없이 excluded 로 닫는다', async () => {
await submit(AUTHOR, 'Hello fandom');
const { entryId } = await submit(COPIER, 'Hello fandom');
await age(entryId, 25);
await settle();
expect((await getFirestore().doc(`translationEntries/${entryId}`).get()).get('status')).toBe('excluded');
expect((await wallet(COPIER)).activityBalance ?? 0).toBe(0);
});
});
describe('보류 지급 정산 — settlePendingGrant', () => {
const key = 'trn:ledger-unit';
it('확정은 한 번뿐이고, 확정 뒤 해제 요청은 기존 결과를 돌려준다', async () => {
await grantPop({ uid: AUTHOR, popType: 'activity', amount: 7, reasonCode: 'TRANSLATION', idempotencyKey: key, pending: true });
await settlePendingGrant({ uid: AUTHOR, idempotencyKey: key, outcome: 'confirm' });
await settlePendingGrant({ uid: AUTHOR, idempotencyKey: key, outcome: 'release' });
const w = await wallet(AUTHOR);
expect(w.activityBalance).toBe(7);
expect(w.pendingActivity).toBe(0);
expect((await reconcileUser(AUTHOR)).ok).toBe(true);
});
it('보류가 아닌 지급은 정산할 수 없다', async () => {
await grantPop({ uid: AUTHOR, popType: 'activity', amount: 7, reasonCode: 'TRANSLATION', idempotencyKey: `${key}:x` });
await expect(settlePendingGrant({ uid: AUTHOR, idempotencyKey: `${key}:x`, outcome: 'confirm' }))
.rejects.toMatchObject({ details: { code: 'E-POP-02' } });
});
});
describe('캠페인 응원 게시판 댓글 번역', () => {
const OPEN = 'campaigns/test-trn-c2';
const DRAFT = 'campaigns/test-trn-c3';
beforeEach(async () => {
const db = getFirestore();
const old = await db.collection('translationEntries')
.where('targetPath', '==', `${OPEN}/comments/ok`).get();
await Promise.all(old.docs.map((d) => d.ref.delete()));
await Promise.all([
db.doc(OPEN).set({ status: 'funding', fandomId: 'trn-f1' }),
db.doc(`${OPEN}/comments/ok`).set({ body: '응원', visibility: 'visible' }),
db.doc(`${OPEN}/comments/hidden`).set({ body: '숨김', visibility: 'hidden' }),
db.doc(DRAFT).set({ status: 'review', fandomId: 'trn-f1' }),
db.doc(`${DRAFT}/comments/ok`).set({ body: '응원', visibility: 'visible' }),
]);
});
it('공개 캠페인의 보이는 댓글에만 달고, 루트는 캠페인이다', async () => {
const { entryId } = await submit(AUTHOR, 'Cheer', `${OPEN}/comments/ok`);
const entry = await getFirestore().doc(`translationEntries/${entryId}`).get();
expect(entry.get('rootPath')).toBe(OPEN);
expect(entry.get('fandomId')).toBe('trn-f1');
for (const path of [`${OPEN}/comments/hidden`, `${DRAFT}/comments/ok`, `${OPEN}/comments/none`]) {
await expect(submit(AUTHOR, 'x', path)).rejects.toMatchObject({ details: { code: 'E-TRN-03' } });
}
});
});