diff --git a/app/firebase/functions/src/account/account.ts b/app/firebase/functions/src/account/account.ts index 80bb0c7..07d87f3 100644 --- a/app/firebase/functions/src/account/account.ts +++ b/app/firebase/functions/src/account/account.ts @@ -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 { + 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 | 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 | 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` — 약관 동의 이력. * diff --git a/app/firebase/functions/src/account/referral.ts b/app/firebase/functions/src/account/referral.ts new file mode 100644 index 0000000..134824d --- /dev/null +++ b/app/firebase/functions/src/account/referral.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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) }); + } + } +}); diff --git a/app/firebase/functions/src/account/translation.ts b/app/firebase/functions/src/account/translation.ts new file mode 100644 index 0000000..2726901 --- /dev/null +++ b/app/firebase/functions/src/account/translation.ts @@ -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 | 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 }); +}); diff --git a/app/firebase/functions/src/admin/admin.ts b/app/firebase/functions/src/admin/admin.ts index d6aff75..0564a74 100644 --- a/app/firebase/functions/src/admin/admin.ts +++ b/app/firebase/functions/src/admin/admin.ts @@ -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> = { * * 이 함수는 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; + 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(items: T[], size: number): Generator { + 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 { + return getFirestore().runTransaction(async (tx) => (await voidCompletions(tx, uid, key))(tx)); +} diff --git a/app/firebase/functions/src/briefing/briefing.ts b/app/firebase/functions/src/briefing/briefing.ts new file mode 100644 index 0000000..e6c2b6c --- /dev/null +++ b/app/firebase/functions/src/briefing/briefing.ts @@ -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; + 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 | 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(); + 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 = {}; + 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, + }; +}); diff --git a/app/firebase/functions/src/campaign/create.ts b/app/firebase/functions/src/campaign/create.ts new file mode 100644 index 0000000..5405c9f --- /dev/null +++ b/app/firebase/functions/src/campaign/create.ts @@ -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 | 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 { + 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 { + 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) }; +} diff --git a/app/firebase/functions/src/campaign/settle.ts b/app/firebase/functions/src/campaign/settle.ts new file mode 100644 index 0000000..4781e15 --- /dev/null +++ b/app/firebase/functions/src/campaign/settle.ts @@ -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 { + 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 { + // 단일 필드 조건이라 복합 색인이 필요 없다. 상태는 아래에서 거른다. + 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 { + 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 { + 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 | 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 => + (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 = 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)?.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 }) }; +} diff --git a/app/firebase/functions/src/core/config.ts b/app/firebase/functions/src/core/config.ts new file mode 100644 index 0000000..ac79889 --- /dev/null +++ b/app/firebase/functions/src/core/config.ts @@ -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; + /** 검증 통과 번역 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 { + 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): boolean { + return FPI_AXES.every((axis) => { + const w = weights[axis]; + return typeof w === 'number' && Number.isFinite(w) && w >= 0; + }); +} diff --git a/app/firebase/functions/src/core/errors.ts b/app/firebase/functions/src/core/errors.ts index ea327e0..a3ff6ce 100644 --- a/app/firebase/functions/src/core/errors.ts +++ b/app/firebase/functions/src/core/errors.ts @@ -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 = { + '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 = {}) { + 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): never { + throw new CatalogError(code, message, extra); } /** diff --git a/app/firebase/functions/src/core/ids.ts b/app/firebase/functions/src/core/ids.ts new file mode 100644 index 0000000..6a82bff --- /dev/null +++ b/app/firebase/functions/src/core/ids.ts @@ -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); diff --git a/app/firebase/functions/src/core/level.ts b/app/firebase/functions/src/core/level.ts new file mode 100644 index 0000000..dc45444 --- /dev/null +++ b/app/firebase/functions/src/core/level.ts @@ -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); +} diff --git a/app/firebase/functions/src/core/names.ts b/app/firebase/functions/src/core/names.ts new file mode 100644 index 0000000..f1acde0 --- /dev/null +++ b/app/firebase/functions/src/core/names.ts @@ -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; +} diff --git a/app/firebase/functions/src/core/time.ts b/app/firebase/functions/src/core/time.ts new file mode 100644 index 0000000..515cfd8 --- /dev/null +++ b/app/firebase/functions/src/core/time.ts @@ -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; +} diff --git a/app/firebase/functions/src/fandom/eclipse_target.ts b/app/firebase/functions/src/fandom/eclipse_target.ts new file mode 100644 index 0000000..3ddd343 --- /dev/null +++ b/app/firebase/functions/src/fandom/eclipse_target.ts @@ -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 }; +} diff --git a/app/firebase/functions/src/fandom/evolution.ts b/app/firebase/functions/src/fandom/evolution.ts new file mode 100644 index 0000000..7c5921a --- /dev/null +++ b/app/firebase/functions/src/fandom/evolution.ts @@ -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; + } + }, +); diff --git a/app/firebase/functions/src/fandom/fandom.ts b/app/firebase/functions/src/fandom/fandom.ts new file mode 100644 index 0000000..96466ba --- /dev/null +++ b/app/firebase/functions/src/fandom/fandom.ts @@ -0,0 +1,1043 @@ +import { eclipseTarget } from './eclipse_target.js'; +import { hibernationSettings, hibernationPatch } from './hibernation.js'; +import { 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, num, str } from '../core/callable.js'; +import { ERR, fail, positiveInt } from '../core/errors.js'; +import { FPI_AXES, loadPolicy, weightsAreUsable, type FpiAxis } from '../core/config.js'; +import { spendPop } from '../ledger/ledger.js'; +import { rankWithTies } from '../ledger/fpi.js'; +import { txIdOf } from '../core/ids.js'; +import { dayKey } from '../core/time.js'; +import { notifyEclipseWarning } from '../notify/push.js'; +import { publicNameOf } from '../core/names.js'; + +/** + * 티커 이벤트 보존 창. + * + * 홈 티커·갤럭시 섬광 배너는 **최근 것만** 읽으므로 오래된 행은 보여 줄 자리가 + * 없다. 지우지 않으면 팬덤당 참여 횟수만큼 쌓이기만 하므로 `expiresAt` 을 실어 + * Firestore TTL 정책이 쓸어 가게 한다(필드 이름이 정책의 입력이다). + */ +const TICKER_TTL_MS = 60 * 60 * 1000; + +/** + * 티커 이벤트 종류 — 별첨 B **「Global Ticker」** 확정. + * + * *Eclipse·Artist Hot Time·Hero Gift·Campaign·공지 등은 **하나의 Global Ticker + * 시스템으로 통합**하고 시작/종료·우선순위·팬덤·국가/언어·클릭이동처를 운영설정으로 + * 관리한다*. 그래서 `fandoms/{id}/liveEvents` 를 이클립스 전용으로 굳히지 않고 + * `kind` 로 갈라 두었다 — 뒤에 들어올 종류가 컬렉션을 새로 만들지 않아도 된다. + */ +const TICKER_KIND_ECLIPSE_DEFENSE = 'eclipse_defense'; + +/** 우선순위 기본값. 낮을수록 먼저 뜬다. 종류별 값은 운영설정으로 뺄 자리다. */ +const TICKER_PRIORITY: Readonly> = { + [TICKER_KIND_ECLIPSE_DEFENSE]: 20, +}; + +/** + * `fn-eclipse-defend` — 이클립스 방어(**팬덤 공동 Shake 참여**). + * + * 정책 원장 **B-55** 비고 · 정책설계서 v1.1 255~264행 · Firestore 설계서 v1.1 §9-#5 — + * *이클립스 방어는 팬덤 공동 Shake 참여이며 **1 회당 POP 소모를 두지 않는다**. 하루 2 회, + * 세부 수치는 `config` 운영값*. 세 문서 모두 2026-09-11 발주사께 제출되었다. + * + * 별첨 1 ① 시나리오 4 의 「번개 버튼 연타·오토 도네이션」은 별첨 B 가 상위 기준이므로 + * (공통 기준 2항) 따르지 않는다. ZAP 버튼은 이클립스 방어가 아니라 **행성 성장 기여**다 + * (B-23) — `fn-pop-spend` 로 분리되어 이 함수와 샤드를 공유하지 않는다. + * + * v1.5 §5 확정: 수동 방어는 Shake only, POP 소모 없음. HOME ZAP은 성장 기여다. + * + * 서버가 판정하는 것 셋 — 별첨 1 ② 서버검증 원칙. + * 1. **사용자당·이벤트당 상한**(운영값). 흔들기 횟수를 그대로 믿으면 단말에서 숫자만 + * 키워 보내면 된다. + * 2. **속도 제한**(운영값). 상한만 두면 상한까지 한 번에 보내는 것을 막지 못한다. + * 3. **멱등** — 재시도가 샤드·로그·라이브 이벤트를 두 번 올리지 않는다. + * + * 방어량은 샤드 10 개에 흩는다. 한 문서에 몰면 초당 1 회 쓰기 한도에 걸려 방어가 + * 몰리는 순간에 오히려 실패한다. + */ +export const fnEclipseDefend = onCall(async (req) => defendEclipse({ + uid: callerUid(req), + fandomId: str(req.data, 'fandomId'), + eclipseId: str(req.data, 'eclipseId'), + clientSeq: str(req.data, 'clientSeq'), + shakes: positiveInt( + ((req.data as Record)?.shakes as number | undefined) ?? 1, + 'shakes', + ), +})); + +export interface DefendResult { + /** 이번 호출로 집계에 더해진 방어량. 상한에 걸리면 보낸 값보다 작다. */ + accepted: number; + /** 상한 때문에 깎인 양. 화면이 「상한에 닿았습니다」를 띄우는 근거다. */ + clamped: number; + /** 이 이벤트에서 내가 지금까지 쌓은 방어량. */ + myTotal: number; + /** 재시도로 판정되어 아무것도 더하지 않았는가. */ + duplicate: boolean; +} + +/** + * 방어 한 건의 코어. `onCall` 래퍼와 떼어 둔 이유는 검증이다 — 샤드 합산 유실과 + * 라이브 이벤트 기록은 HTTP 왕복 없이 에뮬레이터에서 직접 돌려 봐야 확인된다. + */ +export async function defendEclipse(input: { + uid: string; + fandomId: string; + eclipseId: string; + clientSeq: string; + /** 단말이 감지한 흔들기 횟수. **서버가 상한과 속도로 다시 판정한다.** */ + shakes: number; +}): Promise { + const { uid, fandomId, eclipseId, clientSeq, shakes } = input; + const db = getFirestore(); + + // 설계서 v1.2 §2.11 — v0.3.0 `eclipses` · `defenders` 를 `eclipseRounds` · `participants` 로 전환. + const eclipseRef = db.doc(`fandoms/${fandomId}/eclipseRounds/${eclipseId}`); + positiveInt(shakes, 'shakes'); + const [user, settings] = await Promise.all([ + db.doc(`users/${uid}`).get(), + loadEclipseSettings(), + ]); + + const idempotencyKey = `defend:${uid}:${eclipseId}:${clientSeq}`; + const logRef = eclipseRef.collection('defenseLogs').doc(txIdOf(idempotencyKey)); + // 참여자별 누적(`validShakes`). 상한 판정과 속도 제한의 근거이며 문서 ID 가 uid 라 + // 같은 사용자의 복수 단말도 1명이다(G-04). + const memberRef = eclipseRef.collection('participants').doc(uid); + const shardRef = eclipseRef.collection('shards').doc(String(Math.floor(Math.random() * 10))); + const tickerRef = db.collection(`fandoms/${fandomId}/liveEvents`).doc(); + + // **로그 생성을 멱등 게이트로 쓴다.** 조회 후 쓰기로 막으면 그 사이에 끼어든 + // 재시도가 샤드를 두 번 올린다. 문서 ID 충돌을 Firestore 가 원자적으로 판정한다. + const result = await db.runTransaction(async (tx) => { + const [logSnap, memberSnap, eclipse] = await Promise.all([ + tx.get(logRef), tx.get(memberRef), tx.get(eclipseRef), + ]); + if (logSnap.exists) { + return { + accepted: (logSnap.get('accepted') as number | undefined) ?? 0, + clamped: (logSnap.get('clamped') as number | undefined) ?? 0, + myTotal: (logSnap.get('myTotal') as number | undefined) + ?? (memberSnap.get('validShakes') as number | undefined) ?? 0, + duplicate: true, + }; + } + + const now = Date.now(); + const endsAt = eclipse.get('endsAt'); + const startAt = eclipse.get('startAt'); + if (!eclipse.exists || eclipse.get('status') !== 'active' || + eclipse.get('defended') === true || !(endsAt instanceof Timestamp) || + endsAt.toMillis() <= now || !(startAt instanceof Timestamp) || startAt.toMillis() > now) { + fail(ERR.eclipseClosed, '방어 가능한 이클립스가 아닙니다.'); + } + const myTotal = (memberSnap.get('validShakes') as number | undefined) ?? 0; + const lastAt = (memberSnap.get('lastAt') as Timestamp | undefined)?.toMillis() ?? 0; + // 속도 제한 — 상한만 두면 상한까지 한 번에 보내는 것을 막지 못한다. + if (now - lastAt < settings.minIntervalMs) { + fail(ERR.questAbnormal, '너무 빠르게 참여했습니다. 잠시 뒤 다시 시도해 주십시오.'); + } + + // 사용자당 상한. 남은 만큼만 받고 나머지는 깎는다 — 상한을 넘겼다고 통째로 + // 버리면 정상 참여의 마지막 묶음이 사라진다. + const room = Math.max(0, settings.perUserMaxDefense - myTotal); + const accepted = Math.min(shakes, settings.perCallMaxShakes, room); + const clamped = shakes - accepted; + // 원장 C-25: 서버가 인정한 인정 횟수(기본 60회, 운영값)에 처음 도달할 때 회차당 한 명만 센다. + // 회차 문서에 고정한 값을 쓴다 — 진행 중 운영값 변경이 같은 회차의 기준을 바꾸지 않는다. + const need = (eclipse.get('qualifyShakes') as number | undefined) ?? settings.qualifyShakes; + const qualified = myTotal < need && myTotal + accepted >= need; + + tx.create(logRef, { + uid, + shakes, + accepted, + clamped, + myTotal: myTotal + accepted, + idempotencyKey, + createdAt: FieldValue.serverTimestamp(), + }); + tx.set(memberRef, { + uid, + validShakes: FieldValue.increment(accepted), + calls: FieldValue.increment(1), + ...(qualified ? { qualified: true, qualifiedAt: Timestamp.fromMillis(now) } : {}), + lastAt: Timestamp.fromMillis(now), + updatedAt: FieldValue.serverTimestamp(), + }, { merge: true }); + + if (accepted > 0) { + // 인정 로그·개인 누적·고유 참여자·라이브 집계는 함께 커밋하거나 함께 취소한다. + tx.set(shardRef, { + defense: FieldValue.increment(accepted), + defenseCount: FieldValue.increment(1), + qualifiedParticipants: FieldValue.increment(qualified ? 1 : 0), + updatedAt: FieldValue.serverTimestamp(), + }, { merge: true }); + tx.create(tickerRef, { + kind: TICKER_KIND_ECLIPSE_DEFENSE, eclipseId, uid, + nickname: publicNameOf(user), + defense: accepted, + linkTo: `/eclipse/${fandomId}/${eclipseId}`, + priority: TICKER_PRIORITY[TICKER_KIND_ECLIPSE_DEFENSE] ?? 50, + createdAt: FieldValue.serverTimestamp(), + expiresAt: Timestamp.fromMillis(now + TICKER_TTL_MS), + }); + } + return { accepted, clamped, myTotal: myTotal + accepted, duplicate: false }; + }); + + if (result.accepted === 0) return result; + // **성공 판정을 1 분 집계에만 맡기지 않는다**(검수 D2). 목표에 닿은 순간 세워야 + // 그 뒤의 참여가 헛돌지 않고, 화면이 성공을 곧 본다. + await markDefendedIfReached(eclipseRef); + return result; +} + +/** + * 유효 참여자 샤드 합이 DAU 기반 인원 목표에 닿으면 즉시 성공을 확정한다. + * + * 합산을 여기서 한 번 더 하는 비용을 받아들인 이유는, 1 분 주기 집계만으로는 + * 목표를 넘긴 뒤에도 최대 1 분간 참여가 계속 받아들여지기 때문이다. + */ +async function markDefendedIfReached( + eclipseRef: FirebaseFirestore.DocumentReference, + refresh = false, +): Promise { + await eclipseRef.firestore.runTransaction(async (tx) => { + const snap = await tx.get(eclipseRef); + if (!snap.exists || snap.get('status') !== 'active') return false; + if (snap.get('defended') === true) return true; + const shards = await tx.get(eclipseRef.collection('shards')); + const target = snap.get('target'); + const invalid = !Number.isSafeInteger(target) || target < 1 || shards.docs.some((doc) => + !Number.isSafeInteger(doc.get('qualifiedParticipants')) || doc.get('qualifiedParticipants') < 0); + if (invalid) { + tx.update(eclipseRef, { + status: 'cancelled', result: 'cancelled_error', + cancelReason: 'INVALID_PARTICIPANT_AGGREGATE', endedAt: FieldValue.serverTimestamp(), + }); + return false; + } + const qualifiedParticipants = shards.docs.reduce((sum, doc) => sum + doc.get('qualifiedParticipants'), 0); + const defended = qualifiedParticipants >= target; + if (refresh || defended) { + tx.update(eclipseRef, { + defense: shards.docs.reduce((sum, doc) => sum + (doc.get('defense') ?? 0), 0), + defenseCount: shards.docs.reduce((sum, doc) => sum + (doc.get('defenseCount') ?? 0), 0), + qualifiedParticipants, + erosionRate: Math.max(0, Math.round((1 - qualifiedParticipants / target) * 100)), + defended, + ...(defended ? { defendedAt: FieldValue.serverTimestamp() } : {}), + aggregatedAt: FieldValue.serverTimestamp(), + }); + } + return defended; + }); +} + +/** + * `fn-eclipse-aggregate` — 샤드 합산. + * + * 설계서는 1초 주기라고 적었으나 Cloud Scheduler 의 최소 주기는 1분이다. 실시간 + * 감각은 클라이언트가 샤드를 직접 구독해 보완하고, 이 스케줄은 요약 문서의 + * 정합성을 맞추는 역할만 한다. + * + * 목표를 채우면 `defended` 를 세워 방어 성공을 확정한다. **`status` 는 건드리지 않는다** — 활성 질의에서 + * 문서가 빠지면 화면이 성공한 순간을 놓치고 배너가 그냥 사라진다. + */ +export const fnEclipseAggregate = onSchedule('every 1 minutes', async () => { + const count = await aggregateActiveEclipses(); + logger.info('[eclipse] 합산 완료', { eclipses: count }); +}); + +/** + * 활성 이클립스 전부를 합산한다. 합산한 건수를 돌려준다. + * + * 스케줄 래퍼와 떼어 둔 이유는 검증이다 — 「두 클라이언트가 동시에 방어 → + * 집계 일치」를 확인하려면 테스트가 합산을 직접 한 번 돌려야 한다. + */ +export async function aggregateActiveEclipses(): Promise { + const db = getFirestore(); + const actives = await db.collectionGroup('eclipseRounds') + .where('status', '==', 'active').get(); + + // 푸시는 여기서 보내지 않는다 — 활성화 뒤라 C-22 「시작 − 3분」을 지킬 수 없다. + // 사전 알림은 `runEclipseSchedule` 이 `warnAt` 에 보낸다. + for (const eclipse of actives.docs) { + await markDefendedIfReached(eclipse.ref, true); + } + return actives.size; +} + +/** + * 이클립스 운영값 — Admin 「관리 마스터_이클립스 관리」(원장 §4.2 S-AD-ECLIPSE)의 항목. + * + * Firestore 설계서 v1.1 에 이클립스 운영값 경로 정의가 없어 `config/eclipse` 로 둔다 + * (§2.7 `config/features` 와 같은 자리). 팬덤별 조정은 `fandoms/{id}.eclipseSettings` + * 가 이 값을 덮는다 — Admin 화면이 **행성마다** 활성화를 켜고 끄기 때문이다. + * + * **기본값이 `enabled: false` 인 이유.** 운영값 문서가 없는 배포가 스스로 FPI 를 + * 깎기 시작하면 안 된다. 침식은 되돌릴 수는 있어도 사용자 눈에는 이미 일어난 일이다. + */ +export interface EclipseSettings { + /** 이 팬덤에 이클립스를 발동하는가. Admin 「활성화」. */ + enabled: boolean; + /** + * 공통 발생 일정 — 원장 **C-24** · 설계서 v1.2 `config/eclipse.schedule[]`. + * 배열 길이가 곧 하루 회차 수다(**하루 2회는 고정 제한이 아니다**). 팬덤 문서의 + * `eclipseSchedule[]` 이 있으면 그것이 우선한다. + */ + schedule: EclipseSlot[]; + /** 1회 발동당 FPI 침식률(%). Admin 「FPI 감소 수치」 · 수치표 v1.4 「침식 0.25% FPI」. */ + erosionPercent: number; + /** 사전 알림 시간(초). 원장 C-22 「발생 3분 전 푸시」 = 180. `warnAt` 에 발송한다. */ + warnSeconds: number; + /** 평균 DAU 대비 고유 유효 참여자 목표율. 원장 C-25 기본 5%. */ + targetRate: number; + /** 목표 분모의 DAU 평균 기간(일). 원장 C-21 기본 7일 · 운영값. */ + dauWindowDays: number; + /** 공통 방어 창(초). 원장 C-22 기본 60초 · 운영값. */ + windowSec: number; + /** 고유 참여자 1명으로 인정하는 개인 유효 Shake 누적. 원장 C-25 기본 60회 · 운영값. */ + qualifyShakes: number; + /** 한 호출이 실을 수 있는 최대 흔들기 수. 서버 상한이며 단말 값을 믿지 않는다. */ + perCallMaxShakes: number; + /** 한 사용자가 한 이벤트에서 쌓을 수 있는 최대 방어량. 어뷰징 상한(P-06). */ + perUserMaxDefense: number; + /** 같은 사용자의 연속 호출 최소 간격(ms). 속도 제한. */ + minIntervalMs: number; +} + +// UX·FX 구현정렬기준 v1.5 §5 확정 기본값. 팬덤별 운영 설정은 이를 덮는다. +export const DEFAULT_ECLIPSE: EclipseSettings = { + enabled: false, + schedule: [{ hhmm: '07:50' }, { hhmm: '17:50' }], + erosionPercent: 0.25, + warnSeconds: 180, + targetRate: 0.05, + dauWindowDays: 7, + windowSec: 60, + qualifyShakes: 60, + perCallMaxShakes: 50, + perUserMaxDefense: 500, + minIntervalMs: 300, +}; + +/** 일정 한 칸. `tz` 는 설계서 v1.2 `{hhmm, tz}` 이며 비우면 KST 다. */ +export interface EclipseSlot { + hhmm: string; + tz?: string; +} + +/** Eclipse 실패 확정 차감의 사유 코드. */ +const FPI_EROSION_REASON = 'ECLIPSE_EROSION'; + +export async function loadEclipseSettings(): Promise { + const snap = await getFirestore().doc('config/eclipse').get(); + return withSettings(DEFAULT_ECLIPSE, snap.data() ?? {}); +} + +/** + * [base] 위에 운영값 문서 [raw] 를 덮는다. v0.3.0 필드(`firstAt` · `secondAt` · + * `windowMinutes`)만 있는 문서도 읽는다 — 운영값을 옮기기 전에 배포돼도 일정이 끊기지 않는다. + */ +function withSettings(base: EclipseSettings, raw: Record): EclipseSettings { + const legacySchedule = typeof raw.firstAt === 'string' || typeof raw.secondAt === 'string' + ? [{ hhmm: (raw.firstAt as string | undefined) ?? '07:50' }, + { hhmm: (raw.secondAt as string | undefined) ?? '17:50' }] + : undefined; + return { + ...base, + ...raw, + schedule: Array.isArray(raw.schedule) ? raw.schedule as EclipseSlot[] : legacySchedule ?? base.schedule, + windowSec: typeof raw.windowSec === 'number' ? raw.windowSec + : typeof raw.windowMinutes === 'number' ? raw.windowMinutes * 60 : base.windowSec, + } as EclipseSettings; +} + +/** + * KST 날짜(`YYYY-MM-DD`)와 일정 한 칸을 절대 시각으로 옮긴다. + * + * ponytail: `tz` 는 KST(`Asia/Seoul`)만 받는다. 회차 ID 의 날짜 키가 KST 라서다. 다른 + * 시간대가 필요해지면 `Intl` 로 그 시간대의 오프셋을 구해 여기서 옮긴다. + */ +function kstMoment(day: string, slot: EclipseSlot | undefined): Date | null { + const hhmm = slot?.hhmm; + if (typeof hhmm !== 'string' || !/^\d{2}:\d{2}$/.test(hhmm)) return null; + if (slot?.tz !== undefined && slot.tz !== 'Asia/Seoul') return null; + const at = new Date(`${day}T${hhmm}:00+09:00`); + return Number.isNaN(at.getTime()) ? null : at; +} + +/** + * `fn-eclipse-trigger` — 이클립스 발동 · 사전 알림 · 종료. + * + * 원장 **C-24** — *기본 KST 07:50 · 17:50, 팬덤별 발생 횟수 · 각 시각 별도 설정, 미설정 시 + * 공통값. 하루 2회는 고정 제한이 아님. 일정 변경 시 푸시 · 종료 시각 함께 계산*. + * + * **중복 발동을 조건문으로 막지 않는다.** 문서 ID 를 `{KST 날짜}-{회차}` 로 고정하고 + * `create()` 로 만들므로, 같은 분에 두 인스턴스가 겹쳐도 둘째가 ALREADY_EXISTS 로 + * 떨어진다. 조회 후 쓰기로 막으면 그 사이에 끼어든 요청이 두 번째 이클립스를 만든다. + * + * **사전 알림 시각부터 문서를 만들고 `status: 'scheduled'` 로 둔다.** 발동 시각에야 + * 문서를 만들면 `warnAt` 이 이미 지난 시각이라 사전 알림을 보낼 수 없다. 푸시 + * 예고(원장 C-22 「발생 3분 전 푸시」)는 이 함수가 `warnAt` 에 바로 보내고, 앱의 경보 + * 배너는 `active` 만 구독하므로 시작 전에 배너가 뜨지는 않는다. + * + * 시작 시 FPI 스냅샷만 남기고, 마감 후 실패 확정 때에만 그 기준으로 차감한다. + */ +export const fnEclipseTrigger = onSchedule( + { schedule: 'every 1 minutes', timeZone: 'Asia/Seoul' }, + async () => { + const result = await runEclipseSchedule(); + logger.info('[eclipse] 발동 점검 완료', result); + }, +); + +export interface EclipseScheduleResult { + scheduled: number; + activated: number; + closed: number; +} + +/** + * 예약·활성화·종료를 한 바퀴 돈다. + * + * [now] 를 인자로 받는 이유는 검증이다 — 「하루 정확히 2 회」와 「중복 발동 0 건」은 + * 시각을 옮겨 가며 여러 번 돌려야 확인되고, 실제 시계로는 확인할 수 없다. + */ +export async function runEclipseSchedule( + now: Date = new Date(), +): Promise { + const db = getFirestore(); + const global = await loadEclipseSettings(); + const day = dayKey(now); + const fandoms = await db.collection('fandoms') + .select('fpiAxes', 'name', 'createdAt', 'eclipseSettings', 'eclipseSchedule').get(); + + const result: EclipseScheduleResult = { scheduled: 0, activated: 0, closed: 0 }; + + for (const fandom of fandoms.docs) { + const settings = withSettings( + global, (fandom.get('eclipseSettings') as Record | undefined) ?? {}, + ); + // 팬덤별 일정이 있으면 우선, 없으면 공통값(C-24). 길이가 곧 하루 회차 수다. + const own = fandom.get('eclipseSchedule'); + const schedule = Array.isArray(own) && own.length > 0 ? own as EclipseSlot[] : settings.schedule; + + // 종료는 활성화 여부와 무관하게 돈다. 운영값을 끄는 것이 이미 열린 이클립스를 + // 영원히 열린 채로 두는 뜻이 되면, 앱의 경보 배너가 내려가지 않는다. + result.closed += await closeExpiredEclipses(fandom.ref, now); + if (!settings.enabled) continue; + + for (const [i, slot] of schedule.entries()) { + const round = i + 1; + const startAt = kstMoment(day, slot); + if (startAt === null) { + // 운영값이 깨진 것이다. 건너뛰고 남긴다 — 말없이 넘기면 이클립스가 안 도는 + // 이유를 찾을 근거가 사라진다. + logger.error('[eclipse] 발동 시각 운영값을 읽을 수 없습니다', { + fandomId: fandom.id, round, value: slot, + }); + continue; + } + // 푸시 = 시작 − 사전 알림, 종료 = 시작 + 창. 일정이 바뀌면 둘 다 여기서 다시 나온다. + const warnAt = new Date(startAt.getTime() - settings.warnSeconds * 1_000); + if (now < warnAt) continue; + + const ref = fandom.ref.collection('eclipseRounds').doc(`${day}-${round}`); + // 이미 있는 회차는 목표를 다시 산출하지 않는다 — 매분 DAU 문서 7건을 읽지 않게. + let snap = await ref.get(); + if (!snap.exists) { + if (await createScheduledEclipse(ref, { + day, round, settings, startAt, warnAt, + createdAt: fandom.get('createdAt'), + fandomName: (fandom.get('name') as string | undefined) ?? fandom.id, + })) { + result.scheduled += 1; + } + snap = await ref.get(); + } + await notifyEclipseWarning(snap, now); + + if (now >= startAt && snap.get('status') === 'scheduled') { + if (await activateEclipse(fandom.ref, ref, now)) result.activated += 1; + } + } + } + return result; +} + +/** 예약 문서를 만든다. 이미 있으면 false. */ +async function createScheduledEclipse( + ref: FirebaseFirestore.DocumentReference, + input: { + day: string; + round: number; + settings: EclipseSettings; + startAt: Date; + warnAt: Date; + fandomName: string; + createdAt: unknown; + }, +): Promise { + const target = await eclipseTarget( + ref.parent.parent!, input.createdAt, input.day, + input.settings.targetRate, input.settings.dauWindowDays, + ); + try { + await ref.create({ + status: 'result' in target ? 'cancelled' : 'scheduled', + ...target, + round: input.round, + dayKey: input.day, + fandomName: input.fandomName, + erosionPercent: input.settings.erosionPercent, + erosionRate: 100, + defense: 0, + defenseCount: 0, + defended: false, + // 설계서 v1.2 §2.11 — 공통 창이다. 늦게 들어온 참여자에게 창을 새로 주지 않는다. + startAt: Timestamp.fromDate(input.startAt), + windowSec: input.settings.windowSec, + endsAt: Timestamp.fromMillis(input.startAt.getTime() + input.settings.windowSec * 1_000), + qualifyShakes: input.settings.qualifyShakes, + // 사전 알림을 보낼 시각(C-22 시작 − 3분). + warnAt: Timestamp.fromDate(input.warnAt), + appliedAt: null, + createdAt: FieldValue.serverTimestamp(), + }); + return true; + } catch (e) { + // 같은 분에 두 인스턴스가 겹친 정상 경합이다. 그 밖의 실패는 남긴다. + const code = (e as { code?: number | string }).code; + if (code === 6 || code === 'already-exists') return false; + throw e; + } +} + +/** 시작 시에는 FPI 스냅샷만 고정한다. 선차감·성공 복구는 하지 않는다. */ +async function activateEclipse( + fandomRef: FirebaseFirestore.DocumentReference, + ref: FirebaseFirestore.DocumentReference, + now: Date, +): Promise { + return ref.firestore.runTransaction(async (tx) => { + const [snap, fandom] = await Promise.all([tx.get(ref), tx.get(fandomRef)]); + if (!snap.exists || snap.get('status') !== 'scheduled') return false; + const fpi = fandom.get('fpiScore'); + const end = snap.get('endsAt'); + const rate = snap.get('erosionPercent'); + if (typeof fpi !== 'number' || !Number.isFinite(fpi) || fpi < 0 || + typeof rate !== 'number' || !Number.isFinite(rate) || rate < 0 || rate > 100 || + !(end instanceof Timestamp) || end.toMillis() <= now.getTime()) { + tx.update(ref, { status: 'cancelled', result: 'cancelled_error', cancelReason: 'INVALID_START_SNAPSHOT_OR_WINDOW' }); + return false; + } + // 감소량은 시작 FPI 로만 정해진다(C-23). 적용 여부는 `appliedAt` 이 말한다. + tx.update(ref, { + status: 'active', fpiAtStart: fpi, penalty: fpi * rate / 100, + activatedAt: Timestamp.fromDate(now), + }); + return true; + }); +} + +/** 마감 판정과 실패 차감은 같은 트랜잭션으로 회차당 한 번만 확정한다. */ +async function closeExpiredEclipses( + fandomRef: FirebaseFirestore.DocumentReference, + now: Date, +): Promise { + const actives = await fandomRef.collection('eclipseRounds').where('status', '==', 'active').get(); + let closed = 0; + for (const doc of actives.docs) { + const changed = await fandomRef.firestore.runTransaction(async (tx) => { + const [snap, shards, fandom] = await Promise.all([ + tx.get(doc.ref), tx.get(doc.ref.collection('shards')), tx.get(fandomRef), + ]); + if (snap.get('status') !== 'active') return false; + const end = snap.get('endsAt'); + if (end instanceof Timestamp && end.toMillis() > now.getTime()) return false; + const target = snap.get('target'); + const planned = snap.get('penalty'); + const qualified = shards.docs.reduce((sum, shard) => sum + shard.get('qualifiedParticipants'), 0); + const valid = end instanceof Timestamp && Number.isSafeInteger(target) && target >= 1 && + Number.isSafeInteger(qualified) && qualified >= 0 && shards.docs.every((shard) => + Number.isSafeInteger(shard.get('qualifiedParticipants')) && shard.get('qualifiedParticipants') >= 0) && + typeof planned === 'number' && Number.isFinite(planned) && planned >= 0 && + typeof fandom.get('fpiScore') === 'number' && Number.isFinite(fandom.get('fpiScore')); + if (!valid) { + tx.update(doc.ref, { + status: 'cancelled', result: 'cancelled_error', + cancelReason: 'INVALID_FINAL_AGGREGATE', endedAt: Timestamp.fromDate(now), + }); + return true; + } + const defended = snap.get('defended') === true || qualified >= target; + const penalty = defended ? 0 : planned; + const occurredAt = Timestamp.fromDate(now); + if (penalty > 0) { + tx.create(fandomRef.collection('fpiEvents').doc(`eclipse-${doc.id}-penalty`), { + kind: 'score_adjustment', value: -penalty, verified: true, + reasonCode: FPI_EROSION_REASON, eclipseId: doc.id, + occurredAt, createdAt: FieldValue.serverTimestamp(), + }); + tx.update(fandomRef, { + // FPI 는 0 아래로 내려가지 않는다(리뷰 M2). 감소량 기록(penalty)은 그대로 남긴다. + fpiScore: Math.max(0, (fandom.get('fpiScore') as number) - penalty), + eclipsePenaltyRevision: FieldValue.increment(1), + }); + } + // `appliedAt` 은 실패 확정 시각에만 채운다(설계서 v1.2 §2.11). 성공은 무차감이다. + tx.update(doc.ref, { + status: 'ended', result: defended ? 'success' : 'failed', + defended, qualifiedParticipants: qualified, + endedAt: occurredAt, ...(defended ? {} : { appliedAt: occurredAt }), + }); + return true; + }); + if (changed) closed++; + } + return closed; +} + +/** + * `fn-fpi-aggregate` — FPI 집계(§4) · **1 분 주기**(별첨 B 「FPI 집계」 확정). + * + * 최근 7일 창 안의 검증 완료 이벤트만 축별로 합산하고 가중 합을 낸다. 고정 감쇠는 + * 두지 않는다 — 창 밖 이벤트가 자연히 빠지므로 감쇠를 겹치면 두 번 깎인다. + * FPI 감소는 Eclipse 만 담당한다(별첨 B 「FPI 기간」). + * + * **축별 순위(`fpiRanks`)를 여기서 함께 적는다.** 앱의 지표 카드가 「전체 n위」를 + * 띄우고 지표별 랭킹 화면이 같은 축으로 정렬하는데, 두 화면이 각자 순위를 매기면 + * 값이 갈린다(발주사 검토회신서 4절 「지표 카드와 Ranking 사이 수치·순위 불일치」). + * + * 모집단은 별첨 B 「FPI 랭킹」 확정값 — *정식 행성(Stage 1 이상) 중 최근 7 일 + * 유효 활동 존재 행성*. `rankingEligible` 한 필드로 내려 주어야 클라이언트가 + * 정렬 키와 다른 필드에 부등호를 거는 질의를 만들지 않는다. + * + * 실패하면 직전 값을 유지한다. 부분 갱신은 랭킹을 뒤집어 놓는다. + */ +export const fnFpiAggregate = onSchedule('every 1 minutes', async () => { + const db = getFirestore(); + const policy = await loadPolicy(); + if (!weightsAreUsable(policy.fpiWeights)) { + // 직전 값을 그대로 두고 물러난다. 잘못된 가중치로 덮으면 랭킹이 뒤집힌다. + logger.error('[fpi] 가중치를 쓸 수 없습니다', { weights: policy.fpiWeights }); + return; + } + + const sleepConfig = await db.doc('config/hibernation').get(); + let sleepSettings: ReturnType = null; + try { + sleepSettings = hibernationSettings(sleepConfig.data() ?? {}); + } catch (error) { + // 불완전한 운영 설정은 FPI 집계를 막지 않으며 휴면 상태를 임의로 바꾸지 않는다. + logger.error('[hibernation] 운영 설정 확인 필요', { error: String(error) }); + } + const evaluatedAt = Timestamp.now(); + + const since = Timestamp.fromMillis( + Date.now() - policy.fpiWindowDays * 24 * 60 * 60 * 1000, + ); + // 이 루프가 문서에서 쓰는 필드는 `stage` 와 `fpiRanks` 둘뿐이다. 전체 문서를 + // 받으면 행성 수 × 문서 크기만큼 읽기 대역이 늘고, 1 분마다 그 값을 치른다. + const fandoms = await db.collection('fandoms').select('stage', 'fpiRanks', 'eclipsePenaltyRevision').get(); + + const rows: { + ref: FirebaseFirestore.DocumentReference; + axes: Record; + fpiScore: number; + penaltyRevision: number; + eligible: boolean; + activity: { id: string; at: Timestamp } | undefined; + prevRanks: Partial>; + }[] = []; + + for (const fandom of fandoms.docs) { + const events = await fandom.ref.collection('fpiEvents') + .where('verified', '==', true) + .where('occurredAt', '>=', since) + .get(); + + const axes = Object.fromEntries(FPI_AXES.map((a) => [a, 0])) as Record; + let activity: { id: string; at: Timestamp } | undefined; + let scoreAdjustment = 0; + // 랭킹 모집단의 「최근 7 일 유효 활동」은 팬 활동만 센다. Eclipse 차감 이벤트는 활동이 아니다. + let hasActivity = false; + for (const ev of events.docs) { + if (ev.get('kind') === 'score_adjustment' && ev.get('reasonCode') === FPI_EROSION_REASON) { + const value = ev.get('value'); + if (typeof value !== 'number' || !Number.isFinite(value) || value > 0) throw new Error('Invalid Eclipse penalty'); + scoreAdjustment += value; + continue; + } + const axis = ev.get('axis') as FpiAxis | undefined; + if (!axis || !(axis in axes)) continue; + axes[axis] += (ev.get('value') as number | undefined) ?? 0; + if (((ev.get('value') as number | undefined) ?? 0) > 0) hasActivity = true; + const occurredAt = ev.get('occurredAt'); + // 침식 복원은 새 팬 활동이 아니다. Wake-up은 실제 신규 인정 활동만 쓴다. + if (ev.get('value') > 0 && occurredAt instanceof Timestamp && + occurredAt.toMillis() <= evaluatedAt.toMillis() && + !String(ev.get('reasonCode') ?? '').startsWith('ECLIPSE_') && + (!activity || occurredAt.toMillis() > activity.at.toMillis())) { + activity = { id: ev.id, at: occurredAt }; + } + } + const fpiScore = FPI_AXES + .reduce((acc, axis) => acc + axes[axis] * policy.fpiWeights[axis], 0); + + rows.push({ + ref: fandom.ref, + axes, + activity, + fpiScore: Math.max(0, Math.round(fpiScore) + scoreAdjustment), + penaltyRevision: (fandom.get('eclipsePenaltyRevision') as number | undefined) ?? 0, + // 성운은 Stage 0 이고 정식 행성은 1 이상이다(별첨 B 「행성 Stage 구성」). + eligible: ((fandom.get('stage') as number | undefined) ?? 0) >= 1 + && hasActivity, + prevRanks: (fandom.get('fpiRanks') as Record | undefined) ?? {}, + }); + } + + // 축마다 따로 줄을 세운다. 축 하나로 매긴 순위를 다섯 축에 돌려 쓰면 발주사가 + // 본 「서로 다른 지표 화면에 동일한 Ranking」이 그대로 재현된다. + const ranks = new Map>>(); + for (const axis of FPI_AXES) { + // 공동 순위 1·1·3 · 동점 내부는 fandomId 오름차순 고정(C-19). + for (const { item: row, rank } of rankWithTies( + rows.filter((r) => r.eligible), (r) => r.axes[axis], (r) => r.ref.id)) { + const bucket = ranks.get(row.ref) ?? {}; + bucket[axis] = rank; + ranks.set(row.ref, bucket); + } + } + + for (const row of rows) { + const update = { + fpiScore: row.fpiScore, + fpiAxes: row.axes, + fpiRanks: ranks.get(row.ref) ?? {}, + // 순위 동향은 「직전 순위 − 현재 순위」다. 직전 순위를 남겨 두지 않으면 + // 화면이 ▲▼ 를 그릴 근거가 없다. + fpiRanksPrev: row.prevRanks, + rankingEligible: row.eligible, + fpiUpdatedAt: FieldValue.serverTimestamp(), + }; + { + const settings = sleepSettings; + await db.runTransaction(async (tx) => { + const current = await tx.get(row.ref); + // 입력을 읽은 뒤 실패 차감이 확정됐으면 보존하고 다음 집계에서 다시 계산한다. + if (!current.exists || (current.get('eclipsePenaltyRevision') ?? 0) !== row.penaltyRevision) return; + const patch = settings + ? hibernationPatch(current.data()!, row.fpiScore, settings, evaluatedAt, row.activity) + : {}; + tx.update(row.ref, { ...update, ...patch }); + if (patch.hibernationChangedAt) { + const sleeping = patch.isHibernating === true; + // 상태와 이력을 함께 확정한다. 재시도에서는 상태 전환이 없어 추가되지 않는다. + tx.create(row.ref.collection('stateHistory').doc(`hibernation-${evaluatedAt.toMillis()}`), { + kind: sleeping ? 'hibernation' : 'wake_up', + occurredAt: evaluatedAt, + fromHibernating: current.get('isHibernating') === true, + toHibernating: sleeping, + reasonCode: sleeping ? 'LOW_FPI_SUSTAINED' : 'NEW_ACTIVITY_RECOVERY_SUSTAINED', + source: 'fnFpiAggregate', + sourceEventId: sleeping ? null : row.activity?.id ?? null, + fpiScore: row.fpiScore, + qualifyingSince: sleeping ? patch.hibernationLowSince : patch.hibernationWakeSince, + settings, + }); + } + }); + } + } + logger.info('[fpi] 집계 완료', { fandoms: fandoms.size }); +}); + +/** + * HUD 「LIVE」 — 팬덤별 현재 접속자 수(해석 확인 요청 Q-GM-03 권고 A안). + * + * 최근 `windowMinutes`(운영값 5) 안에 앱을 쓴(`users.lastSeenAt` — 앱이 포그라운드에서 갱신) + * 사용자를 그 사람이 가입한 팬덤(`users.fandomIds` — 가입 · 탈퇴 트랜잭션이 유지)마다 1명으로 센다. + * 읽기는 접속자 수만큼 한 번뿐이다. 멤버 수(`memberCount`)와는 다른 값이다. + */ +export async function onlineCounts(windowMinutes: number, now = Date.now()): Promise> { + const seen = await getFirestore().collection('users') + .where('lastSeenAt', '>=', Timestamp.fromMillis(now - windowMinutes * 60_000)).select('fandomIds').get(); + const counts = new Map(); + for (const user of seen.docs) { + const ids = user.get('fandomIds'); + if (!Array.isArray(ids)) continue; + for (const id of new Set(ids)) if (typeof id === 'string') counts.set(id, (counts.get(id) ?? 0) + 1); + } + return counts; +} + +/** + * `fn-live-aggregate` — LIVE 집계를 FPI 1분 집계와 떼어 **2분 주기**로 돈다. 접속자가 몰려도 + * FPI 집계가 밀리지 않게 하고, 시간 상한을 명시한다. 이번에 0 이 된 팬덤만 골라 0 으로 내린다. + */ +export const fnLiveAggregate = onSchedule( + { schedule: 'every 2 minutes', timeoutSeconds: 110 }, + async () => { await aggregateLive(); }, +); + +export async function aggregateLive(now = Date.now()): Promise { + const db = getFirestore(); + const { liveWindowMinutes } = await loadPolicy(); + const counts = await onlineCounts(liveWindowMinutes, now); + const stale = await db.collection('fandoms').where('onlineCount', '>', 0).select().get(); + const writer = db.bulkWriter(); + // 사용자 문서에 남은 없는 팬덤 ID 는 update 가 NOT_FOUND 로 떨어진다 — 재시도하지 않고 넘긴다. + writer.onWriteError(() => false); + for (const doc of stale.docs) { + if (!counts.has(doc.id)) void writer.update(doc.ref, { onlineCount: 0 }).catch(() => undefined); + } + for (const [id, n] of counts) { + void writer.update(db.doc(`fandoms/${id}`), { onlineCount: n }).catch(() => undefined); + } + await writer.close(); + return counts.size; +} + +/** + * `fn-fpi-daily-snapshot` — Daily FPI Snapshot · **일 1 회**(별첨 B 「FPI 집계」). + * + * 별첨 B — *Daily FPI Snapshot 은 일 1 회 sFPI 에 누적합니다* · *별도 환산비율을 + * 두지 않습니다. Daily FPI Snapshot 을 하루 1 회 그대로 누적한 Sum FPI 가 sFPI 입니다*. + * + * 1 분 집계와 나눠 둔 이유 — 앱의 「최근 동향」은 *현재값 − 오늘 스냅샷*이다. + * 스냅샷을 1 분마다 쓰면 그 차가 늘 0 이 되어 화면에서 증감이 사라진다. + */ +export const fnFpiDailySnapshot = onSchedule( + // `every 24 hours` 는 배포 시각 기준 상대 주기라 재배포할 때마다 경계가 옮겨 + // 다니고, 하루 두 번 도는 날이 생긴다. 문서 ID 가 KST 날짜(대조표 4-2)이므로 + // 스케줄도 KST 자정 고정으로 못 박는다. + { schedule: '0 0 * * *', timeZone: 'Asia/Seoul' }, + async () => { + const result = await closeDay(); + logger.info('[fpi] 일일 스냅샷 · DAU 집계 완료', result); + }, +); + +/** + * KST 하루를 닫는다 — Daily FPI Snapshot 과 팬덤 일별 DAU. + * + * **키는 집계 대상일(전날 KST)이다.** KST 자정 직후 도는 작업이 실행일 키를 쓰면 전날 + * 활동분이 당일 키로 남는다(S01 조사). [now] 는 테스트가 경계 시각을 옮기려고 받는다. + * + * DAU(`fandoms/{id}/fandomDailyStats/{day}.dau`)는 Eclipse 목표의 입력이다(C-21 · C-26). + * 쓰는 곳이 없으면 모든 회차가 「집계 누락 취소」(C-28)로 간다. **유효 활동 = 서버가 + * 인정해 남긴 FPI 원천 이벤트(`fpiEvents`, verified · uid 있음) 1건 이상** — S01 확정 전 + * 유지 기준. 활동 0명인 날도 `dau: 0` 으로 남긴다(C-27 「0명인 날 포함」). + */ +export async function closeDay(now: Date = new Date()): Promise<{ day: string; fandoms: number }> { + const db = getFirestore(); + const day = dayKey(new Date(now.getTime() - 24 * 60 * 60 * 1000)); + const from = Timestamp.fromDate(new Date(`${day}T00:00:00+09:00`)); + const to = Timestamp.fromMillis(from.toMillis() + 24 * 60 * 60 * 1000); + const fandoms = await db.collection('fandoms').select('fpiAxes', 'fpiScore').get(); + + for (const fandom of fandoms.docs) { + const fpiAxes = (fandom.get('fpiAxes') as Record | undefined) + ?? Object.fromEntries(FPI_AXES.map((a) => [a, 0])) as Record; + const fpiScore = (fandom.get('fpiScore') as number | undefined) ?? 0; + + // ponytail: 팬덤당 하루치 이벤트 전수 조회. 팬덤이 커지면 이벤트 적재 때 일별 고유 + // 사용자 문서(`fandomDailyStats/{day}/users/{uid}`)를 함께 써서 개수만 세도록 옮긴다. + const events = await fandom.ref.collection('fpiEvents') + .where('occurredAt', '>=', from).where('occurredAt', '<', to).get(); + const users = new Set(events.docs + .filter((e) => e.get('verified') === true && typeof e.get('uid') === 'string' + && e.get('dauEligible') !== false) + .map((e) => e.get('uid') as string)); + + // 스냅샷과 누적값은 함께 확정한다. 중간 실패가 하루치를 영구 누락시키지 않는다. + await db.runTransaction(async (tx) => { + const snapshotRef = fandom.ref.collection('fpiSnapshots').doc(day); + const statsRef = fandom.ref.collection('fandomDailyStats').doc(day); + const snapshot = await tx.get(snapshotRef); + // DAU 는 재실행에도 같은 값이다(같은 하루의 같은 이벤트). 덮어써도 안전하다. + tx.set(statsRef, { + day, dau: users.size, source: 'fpiEvents', computedAt: FieldValue.serverTimestamp(), + }); + if (snapshot.exists) return; + tx.create(snapshotRef, { + fpiAxes, + fpiScore, + createdAt: FieldValue.serverTimestamp(), + }); + tx.set(fandom.ref, { sfpi: FieldValue.increment(fpiScore) }, { merge: true }); + }); + } + return { day, fandoms: fandoms.size }; +} + +/** + * `fn-ranking-country` — 국가별 지표 랭킹 사전 집계(설계서 §10). + * + * 랭킹 화면은 `rankings/{rankingId}` 문서 1건만 읽는다. 축마다 문서를 따로 두어 + * 축을 바꾸면 다른 문서를 읽게 한다 — 한 문서에 다섯 축을 담으면 화면이 어느 축을 + * 그리는지 다시 분기해야 하고, 그 분기가 「지표를 바꿔도 같은 Ranking」의 자리다. + * + * 국가는 멤버 문서에 비정규화된 `countryCode`(사용자 프로필 기준)이며, 없으면 + * `ZZ`(미지정) 버킷으로 모은다. + * + * 모집단은 행성 랭킹과 같다 — 별첨 B 「정식 행성 중 최근 7 일 유효 활동 존재 행성」. + * 여기만 전수로 세면 국가 합이 행성 랭킹의 합보다 커져 두 화면이 어긋난다. + * + * ponytail: 대상 행성의 멤버 전수 스캔이다. 멤버가 늘면 실행 시간을 넘으므로 + * 9주차 부하 설계에서 국가 카운터 샤드로 옮긴다. 지금은 화면이 실데이터로 도는 + * 것이 우선이다. + */ +export const fnRankingCountryAggregate = onSchedule('every 5 minutes', async () => { + const db = getFirestore(); + const eligible = await db.collection('fandoms') + .where('rankingEligible', '==', true).select().get(); + const members = (await Promise.all( + eligible.docs.map((f) => f.ref.collection('members').get()), + )).flatMap((snap) => snap.docs); + + const totals = new Map>(); + for (const member of members) { + const code = (member.get('countryCode') as string | undefined) || 'ZZ'; + const axes = (member.get('fpiAxes') as Record | undefined) ?? {} as Record; + const bucket = totals.get(code) + ?? Object.fromEntries(FPI_AXES.map((a) => [a, 0])) as Record; + for (const axis of FPI_AXES) bucket[axis] += axes[axis] ?? 0; + totals.set(code, bucket); + } + + for (const axis of FPI_AXES) { + const doc = db.doc(`rankings/country-${axis}`); + const before = new Map( + ((await doc.get()).get('rows') as { code: string; rank: number }[] | undefined ?? []) + .map((r) => [r.code, r.rank]), + ); + // 공동 순위 1·1·3 · 동점 내부는 국가 코드 오름차순 고정(C-19). + const rows = rankWithTies([...totals.entries()], ([, axes]) => axes[axis], ([code]) => code) + // 표시명은 적지 않는다 — 서버가 아는 것은 코드뿐이고, 앱의 `countryName` + // 한 곳이 표기를 맡는다. 여기에 코드를 이름 자리에 넣어 두면 화면에 + // 「KR」이 그대로 뜬다. + .map(({ item: [code, axes], rank }) => ({ + code, + value: axes[axis], + rank, + change: (before.get(code) ?? rank) - rank, + })); + await doc.set({ axis, rows, updatedAt: FieldValue.serverTimestamp() }); + } + logger.info('[ranking] 국가 집계 완료', { + countries: totals.size, fandoms: eligible.size, members: members.length, + }); +}); + +/** + * `fn-campaign-fund` — 캠페인 후원. + * + * 승인 전 캠페인은 모금을 받지 않고(E-CMP-03), 목표를 넘기는 후원도 받지 않는다 + * (E-CMP-01). 차감이 성공한 뒤에만 후원 문서를 만든다. + */ +export const fnCampaignFund = onCall(async (req) => fundCampaign({ + uid: callerUid(req), + campaignId: str(req.data, 'campaignId'), + clientSeq: str(req.data, 'clientSeq'), + amount: positiveInt(num(req.data, 'amountPop'), 'amountPop'), +})); + +/** + * 후원 한 건의 코어. 테스트가 HTTP 왕복 없이 부르도록 떼어 둔다. + * + * - **기여 POP 만 쓴다**(원장 B-33). 클라이언트가 보낸 `popType` 을 따르지 않는다. + * - 목표는 규칙·설계서의 스키마 이름 `targetPop` 이다. `goalPop` 은 옛 이름이라 보조로만 + * 읽는다 — 예전에는 `goalPop` 만 읽어 목표 초과 차단(E-CMP-01)이 한 번도 걸리지 않았다. + * - 성장 귀속 팬덤은 **캠페인 문서의 `fandomId`** 다. 클라이언트가 보낸 값을 믿지 않는다. + */ +export async function fundCampaign(input: { + uid: string; campaignId: string; clientSeq: string; amount: number; +}) { + const { uid, campaignId, clientSeq, amount } = input; + const popType = 'support' as const; + const db = getFirestore(); + + const campaignRef = db.doc(`campaigns/${campaignId}`); + // 귀속 팬덤은 바뀌지 않는 값이라 트랜잭션 밖에서 한 번 읽는다. 상태 · 목표 · 모금액은 + // 아래 precheck 가 **차감과 같은 트랜잭션에서** 다시 읽는다 — 밖에서 판정하면 동시 + // 후원 둘이 같은 잔여분을 보고 함께 통과해 목표를 넘긴다(B-34). + const campaign = await campaignRef.get(); + if (!campaign.exists) fail(ERR.campaignNotApproved, '캠페인을 찾을 수 없습니다.'); + const fandomId = campaign.get('fandomId'); + let goal = 0; + let raised = 0; + + const result = await spendPop({ + uid, + popType, + amount, + reasonCode: 'CAMPAIGN_FUND', + idempotencyKey: `fund:${uid}:${campaignId}:${clientSeq}`, + refPath: campaignRef.path, + ...(typeof fandomId === 'string' && fandomId ? { fandomId } : {}), + precheck: async (tx) => { + const snap = await tx.get(campaignRef); + if (snap.get('status') !== 'funding') { + fail(ERR.campaignNotApproved, '아직 모금이 시작되지 않은 캠페인입니다.'); + } + // 마감이 지났으면 정산 전이라도 받지 않는다. 정산도 이 문서를 트랜잭션으로 다시 + // 읽으므로, 판정 뒤에 모금액이 움직이는 일이 없다. + const endsAt = snap.get('endsAt') as Timestamp | undefined; + if (!(endsAt instanceof Timestamp) || endsAt.toMillis() <= Date.now()) { + fail(ERR.campaignNotApproved, '모금이 마감된 캠페인입니다.'); + } + goal = (snap.get('targetPop') as number | undefined) + ?? (snap.get('goalPop') as number | undefined) ?? 0; + raised = (snap.get('raisedPop') as number | undefined) ?? 0; + // 잔여분으로 깎아 받지 않고 거부한다(B-34 「잔여 목표액까지만 후원 가능」). + if (goal > 0 && raised + amount > goal) { + fail(ERR.campaignOverGoal, '남은 모금 목표보다 큰 금액입니다.'); + } + }, + // 후원 문서와 모금액은 차감과 함께 서거나 함께 없다. + alsoWrite: (tx) => { + tx.set(campaignRef.collection('donations').doc(txIdOf(`fund:${uid}:${campaignId}:${clientSeq}`)), { + uid, + // 「기여중」 목록의 collectionGroup 인덱스 키(firestore.indexes.json)와 같은 이름. + donorUid: uid, + amount, + popType, + txId: txIdOf(`fund:${uid}:${campaignId}:${clientSeq}`), + createdAt: FieldValue.serverTimestamp(), + }); + // 후원자 표식 — 규칙이 문서 ID(uid)로 찾는다. 취소(cancelled) 캠페인을 후원자에게만 연다. + // 이 쓰기 이전의 후원에는 표식이 없다: 배포 전 donations 의 donorUid 로 한 번 채운다. + tx.set(campaignRef.collection('backers').doc(uid), { + uid, + updatedAt: FieldValue.serverTimestamp(), + }, { merge: true }); + tx.set(campaignRef, { + raisedPop: FieldValue.increment(amount), + donorCount: FieldValue.increment(1), + updatedAt: FieldValue.serverTimestamp(), + }, { merge: true }); + }, + }); + + const achieved = goal > 0 ? Math.round(((raised + amount) / goal) * 100) : 0; + return { + donationId: result.txId, + txId: result.txId, + balanceAfter: result.balanceAfter, + achievedPercent: achieved, + }; +} diff --git a/app/firebase/functions/src/fandom/herodrop.ts b/app/firebase/functions/src/fandom/herodrop.ts new file mode 100644 index 0000000..a1fb189 --- /dev/null +++ b/app/firebase/functions/src/fandom/herodrop.ts @@ -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 { + const snap = await getFirestore().doc('config/policy').get(); + const raw = (snap.get('herodrop') ?? {}) as Partial; + 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 }; + }); +}); diff --git a/app/firebase/functions/src/fandom/hibernation.ts b/app/firebase/functions/src/fandom/hibernation.ts new file mode 100644 index 0000000..85c5155 --- /dev/null +++ b/app/firebase/functions/src/fandom/hibernation.ts @@ -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 } : {}), + }; +} diff --git a/app/firebase/functions/src/fandom/history.ts b/app/firebase/functions/src/fandom/history.ts new file mode 100644 index 0000000..7fbe781 --- /dev/null +++ b/app/firebase/functions/src/fandom/history.ts @@ -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 }; +}); diff --git a/app/firebase/functions/src/index.ts b/app/firebase/functions/src/index.ts index 71df7df..3c77842 100644 --- a/app/firebase/functions/src/index.ts +++ b/app/firebase/functions/src/index.ts @@ -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'; diff --git a/app/firebase/functions/src/inside/lounge.ts b/app/firebase/functions/src/inside/lounge.ts new file mode 100644 index 0000000..1e38b18 --- /dev/null +++ b/app/firebase/functions/src/inside/lounge.ts @@ -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 { + return recount(`posts/${postId}`, kind); +} + +/** [recountPost] 의 본체 — 부모 문서 경로만 받는다. 캠페인 응원 게시판도 같은 셈을 쓴다. */ +export async function recount(parentPath: string, kind: 'likes' | 'comments'): Promise { + 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; + +const deleteStorageFiles: DeleteFiles = (prefix) => + getStorage().bucket().deleteFiles({ prefix }); + +/** + * 글이 지워지면 딸린 것도 지운다 — 하위 `likes` · `comments` 와 + * `users/{작성자}/lounge/{글}-*` 사진. 앱은 글 문서만 지우고 나머지는 여기가 맡는다. + * 둘 다 다시 돌아도 같은 결과라 재전달에 안전하다. + */ +export async function cleanupPost( + postId: string, + authorUid: unknown, + deleteFiles: DeleteFiles = deleteStorageFiles, +): Promise { + 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 { + 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), +); diff --git a/app/firebase/functions/src/inside/qna.ts b/app/firebase/functions/src/inside/qna.ts new file mode 100644 index 0000000..a4c7d81 --- /dev/null +++ b/app/firebase/functions/src/inside/qna.ts @@ -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 { + 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 }; +} diff --git a/app/firebase/functions/src/ledger/fpi.ts b/app/firebase/functions/src/ledger/fpi.ts new file mode 100644 index 0000000..ce92ec2 --- /dev/null +++ b/app/firebase/functions/src/ledger/fpi.ts @@ -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 { + 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( + 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 }; + }); +} diff --git a/app/firebase/functions/src/ledger/ledger.ts b/app/firebase/functions/src/ledger/ledger.ts new file mode 100644 index 0000000..956b241 --- /dev/null +++ b/app/firebase/functions/src/ledger/ledger.ts @@ -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; + /** 차감과 함께 확정할 이벤트. 재시도에서는 다시 쓰지 않는다. `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 { + 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 => { + 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 { + 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 { + 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): Promise { + return spendParts(input, ['activity', 'support']); +} + +async function spendParts( + input: Omit, types: PopType[], +): Promise { + 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 { + 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; + +/** + * 환불 — `fn-pop-refund` 의 코어. + * + * 원 거래를 되돌리는 것이 아니라 **반대 부호의 새 원장**을 쓴다. 원장이 진실이라는 + * 원칙(§3.3)을 지키려면 과거 행이 그대로 남아 있어야 한다. 다른 멱등키로 이미 환불된 + * 원거래는 E-POP-03 이다 — 호출자가 그 환불(`refundedBy`)을 받아들일지 정한다. + */ +export async function refundPop(params: { + uid: string; + originalTxId: string; + idempotencyKey: string; + reasonCode: RefundReason; +}): Promise { + 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 { + 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 { + 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 = 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 { + 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 }; +} diff --git a/app/firebase/functions/src/ledger/pop.ts b/app/firebase/functions/src/ledger/pop.ts new file mode 100644 index 0000000..8766ade --- /dev/null +++ b/app/firebase/functions/src/ledger/pop.ts @@ -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 }); +}); diff --git a/app/firebase/functions/src/notify/push.ts b/app/firebase/functions/src/notify/push.ts new file mode 100644 index 0000000..303cd77 --- /dev/null +++ b/app/firebase/functions/src/notify/push.ts @@ -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; +} + +/** + * 팬덤 토픽 한 건 발송. + * + * **발송 실패가 호출자를 깨뜨리지 않게 한다.** 브리핑 게시와 이클립스 집계가 + * 푸시 실패로 롤백되면 데이터는 맞는데 화면이 비는 상태가 된다. 실패는 로그로 + * 남기고 `false` 를 돌려준다. + */ +export async function sendFandomPush(message: PushMessage): Promise { + 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 { + 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'; +} diff --git a/app/firebase/functions/src/payment/iap.ts b/app/firebase/functions/src/payment/iap.ts new file mode 100644 index 0000000..1335bdb --- /dev/null +++ b/app/firebase/functions/src/payment/iap.ts @@ -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> = { + 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 { + 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 { + 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; + 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 } | 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 +> = { + 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)?.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, + }; +}); diff --git a/app/firebase/functions/src/payment/receipt.ts b/app/firebase/functions/src/payment/receipt.ts new file mode 100644 index 0000000..8b12dae --- /dev/null +++ b/app/firebase/functions/src/payment/receipt.ts @@ -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; +} + +/** + * 상품 표 — 상품 ID 하나가 지원 POP 얼마인지. + * + * **클라이언트가 보낸 금액을 믿지 않는다.** 앱이 「10만 POP 짜리를 샀다」고 말해도 + * 서버는 상품 ID 로 이 표를 본다. 표에 없는 상품 ID 는 지급하지 않는다. + */ +export const PRODUCT_POP: Readonly> = { + '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 { + return fail(ERR.payVerifyFailed, '결제 검증 설정이 아직 준비되지 않았습니다.'); + } +} diff --git a/app/firebase/functions/src/quest/link_preview.ts b/app/firebase/functions/src/quest/link_preview.ts new file mode 100644 index 0000000..183637f --- /dev/null +++ b/app/firebase/functions/src/quest/link_preview.ts @@ -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 { + 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 { + 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(/"/g, '"').replace(/'|'/g, "'") + .replace(/</g, '<').replace(/>/g, '>').replace(/&/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 메타 → 제목 · 썸네일 · 서비스명. 없으면 `` 로 떨어진다. + * + * 입력은 공격자가 고른 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 }); + }); +} diff --git a/app/firebase/functions/src/quest/quest.ts b/app/firebase/functions/src/quest/quest.ts new file mode 100644 index 0000000..29eb6f0 --- /dev/null +++ b/app/firebase/functions/src/quest/quest.ts @@ -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), + }; +}); diff --git a/app/firebase/functions/src/quest/timeattack.ts b/app/firebase/functions/src/quest/timeattack.ts new file mode 100644 index 0000000..6bb9444 --- /dev/null +++ b/app/firebase/functions/src/quest/timeattack.ts @@ -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() }; +}); diff --git a/app/firebase/functions/src/quest/ugc.ts b/app/firebase/functions/src/quest/ugc.ts new file mode 100644 index 0000000..199f65d --- /dev/null +++ b/app/firebase/functions/src/quest/ugc.ts @@ -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]) }; + }); +} diff --git a/app/firebase/functions/src/quest/verify.ts b/app/firebase/functions/src/quest/verify.ts new file mode 100644 index 0000000..2cb5dcb --- /dev/null +++ b/app/firebase/functions/src/quest/verify.ts @@ -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), + }; +}); diff --git a/app/firebase/functions/test/admin_write.test.ts b/app/firebase/functions/test/admin_write.test.ts index 2f6e432..0f9c452 100644 --- a/app/firebase/functions/test/admin_write.test.ts +++ b/app/firebase/functions/test/admin_write.test.ts @@ -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'); diff --git a/app/firebase/functions/test/briefing.test.ts b/app/firebase/functions/test/briefing.test.ts new file mode 100644 index 0000000..0abe783 --- /dev/null +++ b/app/firebase/functions/test/briefing.test.ts @@ -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'); + }); +}); diff --git a/app/firebase/functions/test/campaign_settle.test.ts b/app/firebase/functions/test/campaign_settle.test.ts new file mode 100644 index 0000000..1752a9d --- /dev/null +++ b/app/firebase/functions/test/campaign_settle.test.ts @@ -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'); + }); +}); diff --git a/app/firebase/functions/test/eclipse.test.ts b/app/firebase/functions/test/eclipse.test.ts new file mode 100644 index 0000000..872af3e --- /dev/null +++ b/app/firebase/functions/test/eclipse.test.ts @@ -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); + }); +}); diff --git a/app/firebase/functions/test/eclipse_target.test.ts b/app/firebase/functions/test/eclipse_target.test.ts new file mode 100644 index 0000000..47692d0 --- /dev/null +++ b/app/firebase/functions/test/eclipse_target.test.ts @@ -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' }); +}); diff --git a/app/firebase/functions/test/eclipse_trigger.test.ts b/app/firebase/functions/test/eclipse_trigger.test.ts new file mode 100644 index 0000000..4cff826 --- /dev/null +++ b/app/firebase/functions/test/eclipse_trigger.test.ts @@ -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); + }); +}); diff --git a/app/firebase/functions/test/evolution.test.ts b/app/firebase/functions/test/evolution.test.ts new file mode 100644 index 0000000..744bb7d --- /dev/null +++ b/app/firebase/functions/test/evolution.test.ts @@ -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); +}); diff --git a/app/firebase/functions/test/extended.test.ts b/app/firebase/functions/test/extended.test.ts new file mode 100644 index 0000000..a1a7671 --- /dev/null +++ b/app/firebase/functions/test/extended.test.ts @@ -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); + }); +}); diff --git a/app/firebase/functions/test/fpi_snapshot.test.ts b/app/firebase/functions/test/fpi_snapshot.test.ts new file mode 100644 index 0000000..329636e --- /dev/null +++ b/app/firebase/functions/test/fpi_snapshot.test.ts @@ -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(); + } +}); diff --git a/app/firebase/functions/test/growth.test.ts b/app/firebase/functions/test/growth.test.ts new file mode 100644 index 0000000..1de1e48 --- /dev/null +++ b/app/firebase/functions/test/growth.test.ts @@ -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`)); +}); diff --git a/app/firebase/functions/test/guard.test.ts b/app/firebase/functions/test/guard.test.ts deleted file mode 100644 index d19d6a1..0000000 --- a/app/firebase/functions/test/guard.test.ts +++ /dev/null @@ -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' } }); - }); -}); diff --git a/app/firebase/functions/test/herodrop.test.ts b/app/firebase/functions/test/herodrop.test.ts new file mode 100644 index 0000000..82177d8 --- /dev/null +++ b/app/firebase/functions/test/herodrop.test.ts @@ -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); +}); diff --git a/app/firebase/functions/test/hibernation.test.ts b/app/firebase/functions/test/hibernation.test.ts new file mode 100644 index 0000000..de9f8f7 --- /dev/null +++ b/app/firebase/functions/test/hibernation.test.ts @@ -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); +}); diff --git a/app/firebase/functions/test/iap_verify.test.ts b/app/firebase/functions/test/iap_verify.test.ts new file mode 100644 index 0000000..183e1e9 --- /dev/null +++ b/app/firebase/functions/test/iap_verify.test.ts @@ -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); + }); +}); diff --git a/app/firebase/functions/test/ledger.test.ts b/app/firebase/functions/test/ledger.test.ts new file mode 100644 index 0000000..84b5f4e --- /dev/null +++ b/app/firebase/functions/test/ledger.test.ts @@ -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(); + }); +}); diff --git a/app/firebase/functions/test/ledger_split.test.ts b/app/firebase/functions/test/ledger_split.test.ts new file mode 100644 index 0000000..3b040e3 --- /dev/null +++ b/app/firebase/functions/test/ledger_split.test.ts @@ -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 }); + }); +}); diff --git a/app/firebase/functions/test/lounge.test.ts b/app/firebase/functions/test/lounge.test.ts new file mode 100644 index 0000000..3666f8a --- /dev/null +++ b/app/firebase/functions/test/lounge.test.ts @@ -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); +}); diff --git a/app/firebase/functions/test/payment.test.ts b/app/firebase/functions/test/payment.test.ts new file mode 100644 index 0000000..50f3109 --- /dev/null +++ b/app/firebase/functions/test/payment.test.ts @@ -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(); + }); +}); diff --git a/app/firebase/functions/test/planet_create.test.ts b/app/firebase/functions/test/planet_create.test.ts new file mode 100644 index 0000000..efdc21f --- /dev/null +++ b/app/firebase/functions/test/planet_create.test.ts @@ -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'); + }); +}); diff --git a/app/firebase/functions/test/planet_history.test.ts b/app/firebase/functions/test/planet_history.test.ts new file mode 100644 index 0000000..9385320 --- /dev/null +++ b/app/firebase/functions/test/planet_history.test.ts @@ -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(); +}); diff --git a/app/firebase/functions/test/pure.test.ts b/app/firebase/functions/test/pure.test.ts new file mode 100644 index 0000000..3019435 --- /dev/null +++ b/app/firebase/functions/test/pure.test.ts @@ -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); + }); +}); diff --git a/app/firebase/functions/test/qna.test.ts b/app/firebase/functions/test/qna.test.ts new file mode 100644 index 0000000..c3e2a21 --- /dev/null +++ b/app/firebase/functions/test/qna.test.ts @@ -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); + }); +}); diff --git a/app/firebase/functions/test/quest_e2e.test.ts b/app/firebase/functions/test/quest_e2e.test.ts new file mode 100644 index 0000000..8bf994c --- /dev/null +++ b/app/firebase/functions/test/quest_e2e.test.ts @@ -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` 가 같은 에뮬레이터를 물고 + // 여기서 남긴 문서를 그대로 읽는다. 그래서 이 테스트는 상태를 지우지 않는다. + }); +}); diff --git a/app/firebase/functions/test/quest_ugc.test.ts b/app/firebase/functions/test/quest_ugc.test.ts new file mode 100644 index 0000000..b225f7d --- /dev/null +++ b/app/firebase/functions/test/quest_ugc.test.ts @@ -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 & 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')) + .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 }).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'); + }); +}); diff --git a/app/firebase/functions/test/referral.test.ts b/app/firebase/functions/test/referral.test.ts new file mode 100644 index 0000000..c0b1f80 --- /dev/null +++ b/app/firebase/functions/test/referral.test.ts @@ -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[0]; +const attribute = (uid: string, referralCode: string) => + fnReferralAttribute.run({ data: { referralCode, deviceRaw: 'device-1' }, auth: { uid, token: {} } } as AttrReq); +const codeOf = async (p: Promise) => { + 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[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); + }); +}); diff --git a/app/firebase/functions/test/support.ts b/app/firebase/functions/test/support.ts index 6b05e89..5dc8cac 100644 --- a/app/firebase/functions/test/support.ts +++ b/app/firebase/functions/test/support.ts @@ -23,3 +23,29 @@ export function bootAdmin(): App { export async function shutdownAdmin(): Promise { for (const app of getApps()) await deleteApp(app); } + +/** + * 그 사용자가 남긴 문서를 전부 지운다. + * + * **`users/{uid}` 아래만 지우면 모자란다.** 기여 차감은 팬덤 멤버 문서에도 + * 누적되므로, 거기를 남겨 두면 다음 테스트에서 값이 겹쳐 올라간다. 실제로 + * 그 때문에 「기여 40」을 기대하는 검증이 회차가 쌓이며 120 을 보았다. + */ +export async function wipeUser(uid: string): Promise { + 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()), + ); +} diff --git a/app/firebase/functions/test/timeattack.test.ts b/app/firebase/functions/test/timeattack.test.ts new file mode 100644 index 0000000..5364406 --- /dev/null +++ b/app/firebase/functions/test/timeattack.test.ts @@ -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; +const call = async ( + fn: { run(request: never): unknown }, + data: Record, + token: Record = {}, + uid = UID, +): Promise => + (await fn.run({ data, auth: { uid, token } } as unknown as never)) as Reply; + +async function codeOf(run: () => Promise): Promise { + 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'); + }); +}); diff --git a/app/firebase/functions/test/translation.test.ts b/app/firebase/functions/test/translation.test.ts new file mode 100644 index 0000000..6e0d9bf --- /dev/null +++ b/app/firebase/functions/test/translation.test.ts @@ -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[0]; +type MarkReq = Parameters[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[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' } }); + } + }); +});