Cloud Functions 도메인 함수 — POP 원장 · 퀘스트 · 이클립스 · 행성 성장 · 캠페인 · 결제 · 알림 · 브리핑 · INSIDE · 추천 · 번역 · 서버 테스트
This commit is contained in:
@@ -1,9 +1,10 @@
|
|||||||
import * as functionsV1 from 'firebase-functions/v1';
|
import * as functionsV1 from 'firebase-functions/v1';
|
||||||
import { onCall } from 'firebase-functions/v2/https';
|
import { HttpsError, onCall } from 'firebase-functions/v2/https';
|
||||||
import { getFirestore, FieldValue } from 'firebase-admin/firestore';
|
import { getFirestore, FieldValue, Timestamp, type Transaction } from 'firebase-admin/firestore';
|
||||||
import { getAuth } from 'firebase-admin/auth';
|
import { getAuth } from 'firebase-admin/auth';
|
||||||
|
|
||||||
import { callerUid, str } from '../core/callable.js';
|
import { callerUid, str } from '../core/callable.js';
|
||||||
|
import { dayKey } from '../core/time.js';
|
||||||
import { ERR, fail } from '../core/errors.js';
|
import { ERR, fail } from '../core/errors.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,6 +49,318 @@ export const fnAuthOnCreate = functionsV1.auth.user().onCreate(async (user) => {
|
|||||||
await batch.commit();
|
await batch.commit();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** 행성 생성 화면이 고르는 값 — 앱 `PlanetCreateScreen.shapes` · `.colors` 와 같은 키(C-50). */
|
||||||
|
export const PLANET_SHAPES = ['shape1', 'shape2', 'shape3', 'shape4', 'shape5', 'shape6'];
|
||||||
|
export const PLANET_COLORS = ['white', 'black', 'blue', 'green', 'gold', 'orange', 'red', 'purple'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 운영값 기본 — 🟡 `config/policy` 가 덮는다.
|
||||||
|
* - 성운 전환: 원장 B-10 「10명 이상 + 1,000 POP 이상 + 기본 목표 7일」.
|
||||||
|
* `nebulaConversionDeadlineEnforced` — 7일을 넘긴 성운의 전환을 막을지. B-10 「미달성 시 유지」와
|
||||||
|
* 「7일 이내 판정」이 갈려 발주사 확인 중이며, 회신 전에는 막지 않는다(false).
|
||||||
|
* - 대표 팬덤: 원장 C-15 「변경 무료 · 재변경까지 초기 30일 대기 · 운영자 조정 가능」.
|
||||||
|
* - `nebulaFounderLeaveBlocked` — 성운 창립자는 행성 탄생 전 탈퇴 불가(해석 확인 요청 Q-PN-03
|
||||||
|
* 권고 A안 · true). false 면 막지 않는다.
|
||||||
|
* - `primaryOnLeave` — 대표 팬덤에서 탈퇴할 때 남은 가입 팬덤 가운데 무엇을 대표로 둘지
|
||||||
|
* (Q-PN-04). `latest`(권고 B안 · 가장 최근 가입) · `earliest`(C안) · `none`(A안 · 비워 둠).
|
||||||
|
* 자동 지정은 C-15 대기에 세지 않는다.
|
||||||
|
*/
|
||||||
|
const FANDOM_DEFAULTS = {
|
||||||
|
nebulaMinMembers: 10,
|
||||||
|
nebulaMinPop: 1_000,
|
||||||
|
nebulaDays: 7,
|
||||||
|
nebulaConversionDeadlineEnforced: false,
|
||||||
|
primaryCooldownDays: 30,
|
||||||
|
nebulaFounderLeaveBlocked: true,
|
||||||
|
primaryOnLeave: 'latest' as 'latest' | 'earliest' | 'none',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fandomPolicy(): Promise<typeof FANDOM_DEFAULTS> {
|
||||||
|
const raw = (await getFirestore().doc('config/policy').get()).data() ?? {};
|
||||||
|
return { ...FANDOM_DEFAULTS, ...raw } as typeof FANDOM_DEFAULTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 기능 명세 §2.4 성운명 — 2~20자 · 한글/영문/숫자/공백 · 연속 공백 · 앞뒤 공백 불가. */
|
||||||
|
export function isValidNebulaName(name: string): boolean {
|
||||||
|
return /^[가-힣A-Za-z0-9 ]{2,20}$/.test(name) && name === name.trim() && !name.includes(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
type Data = Record<string, unknown> | undefined;
|
||||||
|
|
||||||
|
/** 현재 대표 팬덤. `primaryFandomId` 가 없던 계정은 옛 `fandomId` 를 쓴다(설계서 §2.1 이관 3). */
|
||||||
|
function primaryOf(user: Data): string | null {
|
||||||
|
const id = (user?.primaryFandomId ?? user?.fandomId) as string | null | undefined;
|
||||||
|
return id ? id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대표 팬덤을 [fandomId] 로 바꾸는 필드 — 원장 C-15.
|
||||||
|
*
|
||||||
|
* 대표가 없던 계정의 첫 지정은 「변경」이 아니므로 대기를 걸지 않고 시각도 찍지 않는다.
|
||||||
|
* 이미 대표가 있으면 `primaryChangedAt + 대기일` 이 지나야 바뀐다. `patch` 가 null 이면
|
||||||
|
* 바꾸지 않는다(같은 팬덤이거나 대기 중 — 대기 중이면 `blocked`). 옛 독자를 위해
|
||||||
|
* `fandomId` 도 같은 값으로 쓴다.
|
||||||
|
*/
|
||||||
|
function primaryPatch(user: Data, fandomId: string, cooldownDays: number, now: number):
|
||||||
|
{ patch: Record<string, unknown> | null; blocked: boolean } {
|
||||||
|
const current = primaryOf(user);
|
||||||
|
if (current === fandomId) return { patch: null, blocked: false };
|
||||||
|
const base = { primaryFandomId: fandomId, fandomId, updatedAt: FieldValue.serverTimestamp() };
|
||||||
|
if (!current) return { patch: base, blocked: false };
|
||||||
|
const last = user?.primaryChangedAt;
|
||||||
|
if (last instanceof Timestamp && now < last.toMillis() + cooldownDays * 86_400_000) {
|
||||||
|
return { patch: null, blocked: true };
|
||||||
|
}
|
||||||
|
return { patch: { ...base, primaryChangedAt: FieldValue.serverTimestamp() }, blocked: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 멤버십 한 건을 트랜잭션 안에서 쓴다. 호출 측이 「아직 멤버가 아님」을 확인한 뒤에만 부른다.
|
||||||
|
*
|
||||||
|
* - `fandoms/{id}/members/{uid}` — 가입 사실 · 가입 순번(`memberNo`, 원장 B-68 #001~ 기록).
|
||||||
|
* 순번은 탈퇴로 줄지 않는 `memberSeq` 에서 뽑아 재사용하지 않는다.
|
||||||
|
* - `users/{uid}/memberships/{id}` — 「가입 또는 생성한 행성/성운」 목록(명세 §2.58)의 원천.
|
||||||
|
* - `memberCount` 는 같은 트랜잭션에서 올린다 — 전환 조건(B-10)이 이 값을 읽는다.
|
||||||
|
*/
|
||||||
|
function writeJoin(tx: Transaction, uid: string, fandomId: string, fandom: Data): void {
|
||||||
|
const db = getFirestore();
|
||||||
|
const memberNo = Number(fandom?.memberSeq ?? fandom?.memberCount ?? 0) + 1;
|
||||||
|
tx.set(db.doc(`fandoms/${fandomId}/members/${uid}`), {
|
||||||
|
uid,
|
||||||
|
fandomId,
|
||||||
|
memberNo,
|
||||||
|
contributedPop: 0,
|
||||||
|
joinedAt: FieldValue.serverTimestamp(),
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
tx.set(db.doc(`users/${uid}/memberships/${fandomId}`), {
|
||||||
|
fandomId,
|
||||||
|
joinedAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
// LIVE 집계(Q-GM-03)가 접속자 문서 한 건으로 가입 팬덤을 읽는 비정규화 — 서버만 쓴다(규칙이 막음).
|
||||||
|
tx.set(db.doc(`users/${uid}`), { fandomIds: FieldValue.arrayUnion(fandomId) }, { merge: true });
|
||||||
|
tx.set(db.doc(`fandoms/${fandomId}`), {
|
||||||
|
memberCount: FieldValue.increment(1),
|
||||||
|
memberSeq: memberNo,
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
}, { merge: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `fn-nebula-create` — 성운(Stage0) 생성. 원장 B-08 · B-09 · B-10 · C-15.
|
||||||
|
*
|
||||||
|
* 팬덤 문서 자체가 공용 성운/행성이다(팬덤 1 : 행성 1). 만든 사람은 창립 멤버 #001 이다.
|
||||||
|
* 확인 다이얼로그 「선택한 행성을 기본 행성으로 변경할까요?」대로 대표 팬덤을 바꾸되, C-15
|
||||||
|
* 30일 대기 중이면 성운과 멤버십만 만들고 대표는 그대로 둔다(`primaryChanged: false`).
|
||||||
|
* 이름 중복은 `fandomNames/{소문자 이름}` 문서 ID 로, 일 1회 제한은
|
||||||
|
* `users/{uid}/nebulaCreates/{날짜}` 문서 ID 로 막는다 — 동시 요청에도 원자적이다.
|
||||||
|
* 기존 팬덤의 이름 문서는 `tools/demo/migrate_v040.mjs fandom-names` 가 채운다.
|
||||||
|
*/
|
||||||
|
export const fnNebulaCreate = onCall(async (req) => {
|
||||||
|
const uid = callerUid(req);
|
||||||
|
const name = str(req.data, 'name');
|
||||||
|
if (!isValidNebulaName(name)) {
|
||||||
|
throw new HttpsError('invalid-argument', '성운 이름은 2~20자의 한글·영문·숫자로 입력해 주세요.');
|
||||||
|
}
|
||||||
|
const db = getFirestore();
|
||||||
|
const policy = await fandomPolicy();
|
||||||
|
const fandom = db.collection('fandoms').doc();
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const primaryChanged = await db.runTransaction(async (tx) => {
|
||||||
|
const todayRef = db.doc(`users/${uid}/nebulaCreates/${dayKey(new Date(now))}`);
|
||||||
|
const nameRef = db.doc(`fandomNames/${name.toLowerCase()}`);
|
||||||
|
const userRef = db.doc(`users/${uid}`);
|
||||||
|
const [todaySnap, nameSnap, userSnap] = await Promise.all([
|
||||||
|
tx.get(todayRef), tx.get(nameRef), tx.get(userRef),
|
||||||
|
]);
|
||||||
|
if (todaySnap.exists) fail(ERR.nebulaDailyLimit, '이미 오늘 성운을 생성하셨습니다.');
|
||||||
|
if (nameSnap.exists) fail(ERR.fandomNameTaken, '이미 사용중인 이름입니다.');
|
||||||
|
const { patch } = primaryPatch(userSnap.data(), fandom.id, policy.primaryCooldownDays, now);
|
||||||
|
|
||||||
|
tx.create(todayRef, { fandomId: fandom.id, createdAt: FieldValue.serverTimestamp() });
|
||||||
|
tx.create(nameRef, { fandomId: fandom.id, createdAt: FieldValue.serverTimestamp() });
|
||||||
|
tx.create(fandom, {
|
||||||
|
name,
|
||||||
|
stage: 0,
|
||||||
|
founderUid: uid,
|
||||||
|
memberCount: 0,
|
||||||
|
growthPop: 0,
|
||||||
|
fpiScore: 0,
|
||||||
|
nebulaDeadline: Timestamp.fromMillis(now + Number(policy.nebulaDays) * 86_400_000),
|
||||||
|
createdAt: FieldValue.serverTimestamp(),
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
writeJoin(tx, uid, fandom.id, undefined);
|
||||||
|
if (patch) tx.set(userRef, patch, { merge: true });
|
||||||
|
return patch !== null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { fandomId: fandom.id, primaryChanged };
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `fn-fandom-join` — 행성/성운 가입. 원장 C-15 「복수 팬덤 가입」.
|
||||||
|
*
|
||||||
|
* 이미 멤버면 아무것도 쓰지 않는다(두 번 눌러도 `memberCount` 는 한 번만 오른다).
|
||||||
|
* 대표 팬덤이 없던 계정은 처음 가입한 팬덤이 대표가 된다 — 그 외에는 대표를 바꾸지 않는다.
|
||||||
|
*/
|
||||||
|
export const fnFandomJoin = onCall(async (req) => {
|
||||||
|
const uid = callerUid(req);
|
||||||
|
const fandomId = str(req.data, 'fandomId');
|
||||||
|
const db = getFirestore();
|
||||||
|
return db.runTransaction(async (tx) => {
|
||||||
|
const [fandomSnap, memberSnap, userSnap] = await Promise.all([
|
||||||
|
tx.get(db.doc(`fandoms/${fandomId}`)),
|
||||||
|
tx.get(db.doc(`fandoms/${fandomId}/members/${uid}`)),
|
||||||
|
tx.get(db.doc(`users/${uid}`)),
|
||||||
|
]);
|
||||||
|
if (!fandomSnap.exists) fail(ERR.permissionDenied, '행성을 찾을 수 없습니다.');
|
||||||
|
if (memberSnap.exists) return { fandomId, joined: false };
|
||||||
|
writeJoin(tx, uid, fandomId, fandomSnap.data());
|
||||||
|
if (!primaryOf(userSnap.data())) {
|
||||||
|
tx.set(userSnap.ref, {
|
||||||
|
primaryFandomId: fandomId, fandomId, updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
}, { merge: true });
|
||||||
|
}
|
||||||
|
return { fandomId, joined: true };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `fn-fandom-leave` — 탈퇴. 멤버가 아니면 아무것도 하지 않는다(두 번 눌러도 한 번만 내린다).
|
||||||
|
*
|
||||||
|
* 과거 기여 · Passport 기록(원장 · growthEvents · popTransactions)은 지우지 않는다(C-15).
|
||||||
|
*
|
||||||
|
* - 성운(Stage0) 창립자는 탄생 전 떠날 수 없다(E-FDM-05 · Q-PN-03) — 탄생 권한자가 사라지면
|
||||||
|
* 조건을 채워도 행성이 될 수 없다.
|
||||||
|
* - 대표 팬덤을 떠나면 `primaryOnLeave` 대로 남은 팬덤 하나를 대표로 둔다(Q-PN-04). 자동 지정이라
|
||||||
|
* `primaryChangedAt` 을 찍지 않는다 — 사용자의 다음 직접 변경을 30일 대기로 막지 않는다.
|
||||||
|
*/
|
||||||
|
export const fnFandomLeave = onCall(async (req) => {
|
||||||
|
const uid = callerUid(req);
|
||||||
|
const fandomId = str(req.data, 'fandomId');
|
||||||
|
const db = getFirestore();
|
||||||
|
const policy = await fandomPolicy();
|
||||||
|
return db.runTransaction(async (tx) => {
|
||||||
|
const memberRef = db.doc(`fandoms/${fandomId}/members/${uid}`);
|
||||||
|
const [memberSnap, userSnap, fandomSnap] = await Promise.all([
|
||||||
|
tx.get(memberRef), tx.get(db.doc(`users/${uid}`)), tx.get(db.doc(`fandoms/${fandomId}`)),
|
||||||
|
]);
|
||||||
|
if (!memberSnap.exists) return { fandomId, left: false };
|
||||||
|
if (policy.nebulaFounderLeaveBlocked === true &&
|
||||||
|
fandomSnap.get('stage') === 0 && fandomSnap.get('founderUid') === uid) {
|
||||||
|
fail(ERR.nebulaFounderLeave, '행성이 탄생한 뒤에 탈퇴할 수 있어요.');
|
||||||
|
}
|
||||||
|
const wasPrimary = primaryOf(userSnap.data()) === fandomId;
|
||||||
|
let nextPrimary: string | null = null;
|
||||||
|
if (wasPrimary && policy.primaryOnLeave !== 'none') {
|
||||||
|
// 떠나는 팬덤을 빼고 하나가 남도록 두 건만 읽는다.
|
||||||
|
const rest = await tx.get(db.collection(`users/${uid}/memberships`)
|
||||||
|
.orderBy('joinedAt', policy.primaryOnLeave === 'earliest' ? 'asc' : 'desc').limit(2));
|
||||||
|
nextPrimary = rest.docs.map((d) => d.id).find((id) => id !== fandomId) ?? null;
|
||||||
|
}
|
||||||
|
tx.delete(memberRef);
|
||||||
|
tx.delete(db.doc(`users/${uid}/memberships/${fandomId}`));
|
||||||
|
tx.set(userSnap.ref, { fandomIds: FieldValue.arrayRemove(fandomId) }, { merge: true });
|
||||||
|
tx.update(db.doc(`fandoms/${fandomId}`), {
|
||||||
|
memberCount: FieldValue.increment(-1),
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
if (wasPrimary) {
|
||||||
|
tx.set(userSnap.ref, {
|
||||||
|
primaryFandomId: nextPrimary, fandomId: nextPrimary ?? '', updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
}, { merge: true });
|
||||||
|
}
|
||||||
|
return { fandomId, left: true, primaryFandomId: wasPrimary ? nextPrimary : primaryOf(userSnap.data()) };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `fn-primary-fandom-set` — 대표 팬덤 변경. 원장 C-15 · 명세 §2.56 「기본 행성 변경」.
|
||||||
|
*
|
||||||
|
* 가입한 팬덤만 대표가 될 수 있다. 변경은 무료이고, 재변경은 `primaryChangedAt + 30일`
|
||||||
|
* (운영값 `primaryCooldownDays`) 이 지나야 한다. 과거 기여 · Passport 는 건드리지 않는다.
|
||||||
|
*/
|
||||||
|
export const fnPrimaryFandomSet = onCall(async (req) => {
|
||||||
|
const uid = callerUid(req);
|
||||||
|
const fandomId = str(req.data, 'fandomId');
|
||||||
|
const db = getFirestore();
|
||||||
|
const policy = await fandomPolicy();
|
||||||
|
const now = Date.now();
|
||||||
|
return db.runTransaction(async (tx) => {
|
||||||
|
const [memberSnap, userSnap] = await Promise.all([
|
||||||
|
tx.get(db.doc(`fandoms/${fandomId}/members/${uid}`)), tx.get(db.doc(`users/${uid}`)),
|
||||||
|
]);
|
||||||
|
if (!memberSnap.exists) fail(ERR.permissionDenied, '가입한 행성만 기본 행성으로 정할 수 있습니다.');
|
||||||
|
const { patch, blocked } = primaryPatch(userSnap.data(), fandomId, policy.primaryCooldownDays, now);
|
||||||
|
if (blocked) {
|
||||||
|
fail(ERR.primaryCooldown, `기본 행성은 변경 후 ${policy.primaryCooldownDays}일이 지나야 다시 바꿀 수 있습니다.`);
|
||||||
|
}
|
||||||
|
if (patch) tx.set(userSnap.ref, patch, { merge: true });
|
||||||
|
return { fandomId, changed: patch !== null };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `fn-planet-create` — 성운(Stage0) → 정식 행성(Stage1) 탄생. 원장 B-09 · B-10 · C-50.
|
||||||
|
*
|
||||||
|
* 형태(`planetType`)와 컬러(`colorKey`) 두 가지만 이때 한 번 고른다. 행성 문서 ID 는
|
||||||
|
* 팬덤 안에서 고정(`planet`)이라 팬덤당 1건이 문서 ID 충돌로 원자적으로 지켜진다
|
||||||
|
* (Firestore 설계서 §2.4 정정). 렌더링이 팬덤 문서 하나로 끝나도록 두 값을 팬덤 문서에도
|
||||||
|
* 싣는다. 생성 후 두 값을 바꾸는 경로는 없다(보안 규칙이 클라이언트 쓰기를 전부 닫음).
|
||||||
|
*
|
||||||
|
* 전환 조건(인원 · POP)은 **트랜잭션 안에서 읽은** 팬덤 문서로 판정한다 — 가입 · 탈퇴와
|
||||||
|
* 경합해도 낡은 `memberCount` 로 통과하지 않는다. 7일 목표는 `nebulaConversionDeadlineEnforced`
|
||||||
|
* 가 켜졌을 때만 거부 사유가 된다.
|
||||||
|
*/
|
||||||
|
export const fnPlanetCreate = onCall(async (req) => {
|
||||||
|
const uid = callerUid(req);
|
||||||
|
const fandomId = str(req.data, 'fandomId');
|
||||||
|
const planetType = str(req.data, 'planetType');
|
||||||
|
const colorKey = str(req.data, 'colorKey');
|
||||||
|
if (!PLANET_SHAPES.includes(planetType) || !PLANET_COLORS.includes(colorKey)) {
|
||||||
|
throw new HttpsError('invalid-argument', '행성 형태 또는 색이 올바르지 않습니다.');
|
||||||
|
}
|
||||||
|
const db = getFirestore();
|
||||||
|
const policy = await fandomPolicy();
|
||||||
|
const fandomRef = db.doc(`fandoms/${fandomId}`);
|
||||||
|
|
||||||
|
await db.runTransaction(async (tx) => {
|
||||||
|
const [fandomSnap, planetSnap] = await Promise.all([
|
||||||
|
tx.get(fandomRef), tx.get(db.doc(`fandoms/${fandomId}/planets/planet`)),
|
||||||
|
]);
|
||||||
|
const fandom = fandomSnap.data();
|
||||||
|
if (!fandom) fail(ERR.permissionDenied, '성운을 찾을 수 없습니다.');
|
||||||
|
if (fandom.founderUid !== uid) fail(ERR.permissionDenied, '성운을 만든 멤버만 행성을 생성할 수 있습니다.');
|
||||||
|
if (planetSnap.exists || fandom.stage !== 0) fail(ERR.popDuplicate, '이미 행성이 생성되었습니다.');
|
||||||
|
if (Number(fandom.memberCount ?? 0) < Number(policy.nebulaMinMembers) ||
|
||||||
|
Number(fandom.growthPop ?? 0) < Number(policy.nebulaMinPop)) {
|
||||||
|
fail(ERR.nebulaNotReady, '성운 전환 조건을 아직 채우지 못했습니다.');
|
||||||
|
}
|
||||||
|
if (policy.nebulaConversionDeadlineEnforced === true &&
|
||||||
|
fandom.nebulaDeadline instanceof Timestamp && Date.now() > fandom.nebulaDeadline.toMillis()) {
|
||||||
|
fail(ERR.nebulaNotReady, '성운 목표 기간이 지났습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.create(planetSnap.ref, {
|
||||||
|
ownerUid: uid,
|
||||||
|
fandomId,
|
||||||
|
planetType,
|
||||||
|
colorKey,
|
||||||
|
createdAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
tx.update(fandomRef, {
|
||||||
|
stage: 1,
|
||||||
|
planetType,
|
||||||
|
colorKey,
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return { fandomId };
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `fn-consent-record` — 약관 동의 이력.
|
* `fn-consent-record` — 약관 동의 이력.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,337 @@
|
|||||||
|
import { randomInt } from 'node:crypto';
|
||||||
|
import { onCall } from 'firebase-functions/v2/https';
|
||||||
|
import { onDocumentCreated, onDocumentUpdated } from 'firebase-functions/v2/firestore';
|
||||||
|
import { onSchedule } from 'firebase-functions/v2/scheduler';
|
||||||
|
import { logger } from 'firebase-functions';
|
||||||
|
import { getFirestore, FieldValue, Timestamp } from 'firebase-admin/firestore';
|
||||||
|
|
||||||
|
import { callerUid, optionalStr, str } from '../core/callable.js';
|
||||||
|
import { loadPolicy } from '../core/config.js';
|
||||||
|
import { ERR, fail } from '../core/errors.js';
|
||||||
|
import { deviceHashOf } from '../core/ids.js';
|
||||||
|
import { dayKey } from '../core/time.js';
|
||||||
|
import { grantPop, settlePendingGrant, spendPop } from '../ledger/ledger.js';
|
||||||
|
import { fandomOfUser, writeFpiEvent } from '../ledger/fpi.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 추천 귀속. 번역 보상은 `translation.ts`.
|
||||||
|
*
|
||||||
|
* **자기 자신을 통한 이득을 막는 것**이 핵심이다 — 자기 추천을 막지 않으면 한 사람이
|
||||||
|
* 계정 하나로 POP 을 만들어 낸다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 초대 코드 문자 — 대문자 + 숫자에서 혼동 문자(`O` · `0` · `I` · `1`)를 뺀 32자. 10자면 약 2^50
|
||||||
|
* 조합이다(Firestore 설계서 §`/inviteCodes`). `inviteCodes` 는 코드 확인을 위해
|
||||||
|
* 미로그인 직접 조회가 열려 있어, 무차별 대입 방어선이 이 엔트로피 하나뿐이다.
|
||||||
|
*/
|
||||||
|
export const INVITE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||||
|
export const INVITE_LENGTH = 10;
|
||||||
|
/** 초대 코드 형식 — 읽기 전에 거른다(형식이 틀린 문자열로 문서를 조회하지 않는다). */
|
||||||
|
export const INVITE_PATTERN = /^[A-HJ-NP-Z2-9]{10}$/;
|
||||||
|
|
||||||
|
export function newInviteCode(): string {
|
||||||
|
let code = '';
|
||||||
|
for (let i = 0; i < INVITE_LENGTH; i++) code += INVITE_ALPHABET[randomInt(INVITE_ALPHABET.length)];
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 내 초대 코드를 돌려준다. 없으면 만든다 — 사용자 1명당 코드 1개(`users/{uid}.inviteCode`).
|
||||||
|
* 유일성은 `inviteCodes/{code}` 를 `create` 해 보장하고, 충돌하면 새 코드로 다시 시도한다.
|
||||||
|
* `inviteCodes/{code}` 는 미로그인 직접 조회가 열려 있어 `{active}` 만 둔다. 주인은 서버 전용
|
||||||
|
* `inviteCodeOwners/{code}` 에 둔다 — 코드로 사용자 UID 를 알아낼 수 없게. 클라이언트는 모두 쓸 수 없다(규칙).
|
||||||
|
*/
|
||||||
|
export async function inviteCodeOf(uid: string): Promise<string> {
|
||||||
|
const db = getFirestore();
|
||||||
|
const userRef = db.doc(`users/${uid}`);
|
||||||
|
for (let attempt = 0; attempt < 5; attempt++) {
|
||||||
|
const code = newInviteCode();
|
||||||
|
const codeRef = db.doc(`inviteCodes/${code}`);
|
||||||
|
const result = await db.runTransaction(async (tx) => {
|
||||||
|
const [user, taken] = await Promise.all([tx.get(userRef), tx.get(codeRef)]);
|
||||||
|
const existing = user.get('inviteCode');
|
||||||
|
if (typeof existing === 'string' && existing) return existing;
|
||||||
|
if (taken.exists) return null;
|
||||||
|
tx.create(codeRef, { active: true });
|
||||||
|
tx.create(db.doc(`inviteCodeOwners/${code}`), {
|
||||||
|
uid, type: 'personal', createdAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
tx.set(userRef, { inviteCode: code }, { merge: true });
|
||||||
|
return code;
|
||||||
|
});
|
||||||
|
if (result) return result;
|
||||||
|
}
|
||||||
|
throw new Error('초대 코드를 만들지 못했습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `fn-invite-code-get` — 내 초대 코드(없으면 만들어서). 공유 링크 `?ref=` 에 붙는다. */
|
||||||
|
export const fnInviteCodeGet = onCall(async (req) => ({ code: await inviteCodeOf(callerUid(req)) }));
|
||||||
|
|
||||||
|
/** 추천 보상 보류 시간 — 원장 **B-71** 「기록 → 보상 pending/24h」. 지난 뒤 정산 작업이 확정한다. */
|
||||||
|
export const REFERRAL_HOLD_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 잠금 문서 `referralsByUser/{uid}.reward` — 피추천인 1명의 보상 상태.
|
||||||
|
* - `pending`: 귀속됨 · 귀속 뒤 첫 검증 활동 대기
|
||||||
|
* - `ineligible`: 귀속 전에 이미 검증 활동이 있었다(「최초」가 아님) — 지급 없음
|
||||||
|
* - `capped`: 추천인의 하루 상한(`referralDailyCapPerReferrer`) 초과 — 지급 없음
|
||||||
|
* - `held`: 보류 지급(pending POP) 중 · `settleAfter` 뒤 확정
|
||||||
|
* - `paid`: 확정 · FPI REFERRAL 1 단위
|
||||||
|
* - `released`: 확정 전에 완료가 무효 · 이상 표시(`flagged`)로 보류 해제
|
||||||
|
* - `clawedback` / `clawback-pending`: 확정 뒤 무효 — 회수됨 / 잔액 부족으로 운영 확인 대기
|
||||||
|
*/
|
||||||
|
export type ReferralReward =
|
||||||
|
'pending' | 'ineligible' | 'capped' | 'held' | 'paid' | 'released' | 'clawedback' | 'clawback-pending';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `fn-referral-attribute` — 추천 귀속.
|
||||||
|
*
|
||||||
|
* 명세 §8.5 — *자기 추천·중복 전환 차단*. 가입자 1명당 귀속 1번(`referralsByUser/{uid}` 잠금).
|
||||||
|
* 가입 후 `referralAttachDays`(기본 7일) 안에만, 서로 추천(A→B · B→A)은 막는다.
|
||||||
|
* 보상은 여기서 나가지 않는다 — 귀속 **뒤** 첫 검증 활동 때 보류 지급([payReferralReward]).
|
||||||
|
*/
|
||||||
|
export const fnReferralAttribute = onCall(async (req) => {
|
||||||
|
const uid = callerUid(req);
|
||||||
|
const code = str(req.data, 'referralCode');
|
||||||
|
if (!INVITE_PATTERN.test(code)) fail(ERR.popVerifyFailed, '추천 코드를 확인해 주세요.');
|
||||||
|
const channel = optionalStr(req.data, 'channel') ?? 'unknown';
|
||||||
|
const deviceRaw = optionalStr(req.data, 'deviceRaw');
|
||||||
|
const db = getFirestore();
|
||||||
|
|
||||||
|
const [pub, owner] = await Promise.all([
|
||||||
|
db.doc(`inviteCodes/${code}`).get(), db.doc(`inviteCodeOwners/${code}`).get(),
|
||||||
|
]);
|
||||||
|
const referrerUid = owner.get('uid') as string | undefined;
|
||||||
|
if (!pub.exists || pub.get('active') !== true || !referrerUid) {
|
||||||
|
fail(ERR.popVerifyFailed, '추천 코드를 확인해 주세요.');
|
||||||
|
}
|
||||||
|
if (referrerUid === uid) {
|
||||||
|
// 자기 추천. 계정 하나로 POP 을 만들어 내는 가장 쉬운 길이다.
|
||||||
|
fail(ERR.questAbnormal, '본인 추천 코드는 쓸 수 없습니다.');
|
||||||
|
}
|
||||||
|
const { referralAttachDays, referralDailyCapPerReferrer } = await loadPolicy();
|
||||||
|
|
||||||
|
const attributionRef = db.doc(`referrals/${uid}_${code}`);
|
||||||
|
const lockRef = db.doc(`referralsByUser/${uid}`);
|
||||||
|
const capRef = db.doc(`referralDaily/${referrerUid}_${dayKey(new Date())}`);
|
||||||
|
let reward: ReferralReward = 'pending';
|
||||||
|
try {
|
||||||
|
await db.runTransaction(async (tx) => {
|
||||||
|
// 동시 호출의 둘째는 잠금 문서 `create` 충돌(ALREADY_EXISTS)로 떨어진다.
|
||||||
|
const [existing, lock, user, reverse, cap, done] = await Promise.all([
|
||||||
|
tx.get(attributionRef), tx.get(lockRef), tx.get(db.doc(`users/${uid}`)),
|
||||||
|
tx.get(db.doc(`referralsByUser/${referrerUid}`)), tx.get(capRef),
|
||||||
|
// 「최초 검증 활동」은 귀속 뒤의 첫 완료다. 같은 트랜잭션에서 읽어 경합에도 판정이 같다.
|
||||||
|
tx.get(db.collection(`users/${uid}/verifiedCompletions`).limit(1)),
|
||||||
|
]);
|
||||||
|
if (existing.exists || lock.exists) fail(ERR.popDuplicate, '이미 처리된 추천입니다.');
|
||||||
|
if (reverse.get('referrerUid') === uid) fail(ERR.questAbnormal, '서로 추천할 수 없습니다.');
|
||||||
|
const joined = user.get('createdAt');
|
||||||
|
if (!(joined instanceof Timestamp) ||
|
||||||
|
Date.now() - joined.toMillis() > referralAttachDays * 86_400_000) {
|
||||||
|
fail(ERR.popVerifyFailed, `가입 후 ${referralAttachDays}일 안에만 추천 코드를 등록할 수 있습니다.`);
|
||||||
|
}
|
||||||
|
const today = (cap.get('count') as number | undefined) ?? 0;
|
||||||
|
reward = !done.empty ? 'ineligible' : today >= referralDailyCapPerReferrer ? 'capped' : 'pending';
|
||||||
|
if (reward === 'pending') {
|
||||||
|
tx.set(capRef, { count: today + 1, updatedAt: FieldValue.serverTimestamp() }, { merge: true });
|
||||||
|
}
|
||||||
|
tx.create(lockRef, {
|
||||||
|
attributionId: attributionRef.id, referrerUid, reward, createdAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
tx.create(attributionRef, {
|
||||||
|
referredUid: uid,
|
||||||
|
referrerUid,
|
||||||
|
code,
|
||||||
|
channel,
|
||||||
|
reward,
|
||||||
|
// 다계정 신호(B-71 Risk Engine 입력). 원값은 저장하지 않는다.
|
||||||
|
...(deviceRaw ? { deviceHash: deviceHashOf(deviceRaw) } : {}),
|
||||||
|
createdAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
const code = (e as { code?: number | string }).code;
|
||||||
|
if (code === 6 || code === 'already-exists') fail(ERR.popDuplicate, '이미 처리된 추천입니다.');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { attributionId: attributionRef.id, referrerUid, reward };
|
||||||
|
});
|
||||||
|
|
||||||
|
const grantKey = (uid: string, role: 'referrer' | 'referred') => `referral:${uid}:${role}`;
|
||||||
|
|
||||||
|
/** 귀속 뒤에 생긴, 무효가 아닌 검증 활동 하나. 없으면 null. */
|
||||||
|
async function liveCompletionAfter(uid: string, since: Timestamp): Promise<string | null> {
|
||||||
|
const snap = await getFirestore().collection(`users/${uid}/verifiedCompletions`).get();
|
||||||
|
const live = snap.docs.find((d) => d.get('voided') !== true && d.createTime.toMillis() >= since.toMillis());
|
||||||
|
return live?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 추천 보상 — 정책 원장 **B-69** 「최초 검증 활동 완료 후 추천인 일반 POP 100, 피추천인 추가
|
||||||
|
* 일반 POP 20」 · **B-71** 「보상 pending/24h」. 「검증 활동」은 Quest · Campaign 생성자격과 같은
|
||||||
|
* `verifiedCompletions` 표식이다. 귀속 **뒤**에 생긴 표식만 센다.
|
||||||
|
*
|
||||||
|
* 둘 다 **보류 지급**(`pending`)으로 잡고 잠금을 `held` 로 둔다. 확정 · FPI REFERRAL 은 24시간 뒤
|
||||||
|
* [settleReferral]. 지급은 멱등키로, 중간에 끊겨도 다음 완료가 이어서 끝낸다.
|
||||||
|
*/
|
||||||
|
export async function payReferralReward(uid: string, completionId: string): Promise<boolean> {
|
||||||
|
const db = getFirestore();
|
||||||
|
const lockRef = db.doc(`referralsByUser/${uid}`);
|
||||||
|
const [lock, completion] = await Promise.all([
|
||||||
|
lockRef.get(), db.doc(`users/${uid}/verifiedCompletions/${completionId}`).get(),
|
||||||
|
]);
|
||||||
|
if (!lock.exists || lock.get('reward') !== 'pending' || !completion.exists) return false;
|
||||||
|
if (completion.get('voided') === true || completion.createTime!.toMillis() < lock.createTime!.toMillis()) return false;
|
||||||
|
const referrerUid = lock.get('referrerUid') as string;
|
||||||
|
const refPath = `referrals/${lock.get('attributionId')}`;
|
||||||
|
|
||||||
|
const { referralReferrerPop, referralReferredPop } = await loadPolicy();
|
||||||
|
const grant = (to: string, amount: number, role: 'referrer' | 'referred') => grantPop({
|
||||||
|
uid: to, popType: 'activity', amount, reasonCode: 'REFERRAL_REWARD',
|
||||||
|
idempotencyKey: grantKey(uid, role), refPath, pending: true,
|
||||||
|
});
|
||||||
|
await grant(referrerUid, referralReferrerPop, 'referrer');
|
||||||
|
await grant(uid, referralReferredPop, 'referred');
|
||||||
|
|
||||||
|
return db.runTransaction(async (tx) => {
|
||||||
|
const fresh = await tx.get(lockRef);
|
||||||
|
if (fresh.get('reward') !== 'pending') return false;
|
||||||
|
tx.update(lockRef, {
|
||||||
|
reward: 'held', completionId, heldAt: FieldValue.serverTimestamp(),
|
||||||
|
settleAfter: Timestamp.fromMillis(Date.now() + REFERRAL_HOLD_MS),
|
||||||
|
});
|
||||||
|
tx.update(db.doc(refPath), { reward: 'held' });
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 잠금 · 귀속 문서의 상태를 함께 옮긴다. `from` 이 아니면 아무것도 하지 않는다. */
|
||||||
|
async function moveReward(
|
||||||
|
uid: string, from: ReferralReward, to: ReferralReward,
|
||||||
|
also?: (tx: FirebaseFirestore.Transaction, lock: FirebaseFirestore.DocumentSnapshot) => Promise<() => void>,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const db = getFirestore();
|
||||||
|
const lockRef = db.doc(`referralsByUser/${uid}`);
|
||||||
|
return db.runTransaction(async (tx) => {
|
||||||
|
const lock = await tx.get(lockRef);
|
||||||
|
if (lock.get('reward') !== from) return false;
|
||||||
|
const write = also ? await also(tx, lock) : null;
|
||||||
|
tx.update(lockRef, { reward: to, [`${to}At`]: FieldValue.serverTimestamp() });
|
||||||
|
tx.update(db.doc(`referrals/${lock.get('attributionId')}`), { reward: to });
|
||||||
|
write?.();
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const referralFpiSource = (lock: FirebaseFirestore.DocumentSnapshot) => `referral:${lock.get('attributionId')}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 보류 정산 — 24시간이 지난 `held` 한 건. 귀속 뒤의 유효 완료가 남아 있고 이상 표시(`flagged`)가
|
||||||
|
* 없으면 두 보류 지급을 확정하고 FPI REFERRAL 1 단위를 남긴다. 아니면 둘 다 해제한다.
|
||||||
|
*/
|
||||||
|
export async function settleReferral(uid: string): Promise<'paid' | 'released' | null> {
|
||||||
|
const db = getFirestore();
|
||||||
|
const lock = await db.doc(`referralsByUser/${uid}`).get();
|
||||||
|
if (lock.get('reward') !== 'held') return null;
|
||||||
|
const referrerUid = lock.get('referrerUid') as string;
|
||||||
|
const ok = lock.get('flagged') !== true && (await liveCompletionAfter(uid, lock.createTime!)) !== null;
|
||||||
|
const outcome = ok ? 'confirm' : 'release';
|
||||||
|
await settlePendingGrant({ uid: referrerUid, idempotencyKey: grantKey(uid, 'referrer'), outcome });
|
||||||
|
await settlePendingGrant({ uid, idempotencyKey: grantKey(uid, 'referred'), outcome });
|
||||||
|
if (!ok) return (await moveReward(uid, 'held', 'released')) ? 'released' : null;
|
||||||
|
const moved = await moveReward(uid, 'held', 'paid', async (tx, fresh) => {
|
||||||
|
const referrer = await tx.get(db.doc(`users/${referrerUid}`));
|
||||||
|
return () => writeFpiEvent(tx, {
|
||||||
|
uid: referrerUid, fandomId: fandomOfUser(referrer), axis: 'referral', units: 1,
|
||||||
|
activity: 'referral', sourceId: referralFpiSource(fresh), occurredAt: new Date(),
|
||||||
|
refPath: `referrals/${fresh.get('attributionId')}`,
|
||||||
|
// 추천인은 이 활동을 직접 하지 않았다 — EXP · DAU 에서 뺀다(리뷰 M5). FPI REFERRAL 축만.
|
||||||
|
grantsExp: false,
|
||||||
|
}, referrer);
|
||||||
|
});
|
||||||
|
return moved ? 'paid' : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 검증 활동이 무효가 됐을 때. 귀속 뒤의 다른 유효 완료가 남아 있으면 그대로 둔다.
|
||||||
|
* - `held`: 두 보류 지급을 해제한다.
|
||||||
|
* - `paid`: 두 지급을 `ADMIN_CLAWBACK` 으로 회수하고(멱등) FPI REFERRAL 이벤트를 집계에서 뺀다
|
||||||
|
* (`verified: false`). 잔액이 모자라면 `clawback-pending` 으로 두고 운영 로그에 올린다.
|
||||||
|
*/
|
||||||
|
export async function onReferralCompletionVoided(uid: string): Promise<ReferralReward | null> {
|
||||||
|
const db = getFirestore();
|
||||||
|
const lock = await db.doc(`referralsByUser/${uid}`).get();
|
||||||
|
const state = lock.get('reward') as ReferralReward | undefined;
|
||||||
|
if (state !== 'held' && state !== 'paid') return null;
|
||||||
|
if (await liveCompletionAfter(uid, lock.createTime!)) return null;
|
||||||
|
const referrerUid = lock.get('referrerUid') as string;
|
||||||
|
if (state === 'held') {
|
||||||
|
for (const [who, role] of [[referrerUid, 'referrer'], [uid, 'referred']] as const) {
|
||||||
|
await settlePendingGrant({ uid: who, idempotencyKey: grantKey(uid, role), outcome: 'release' });
|
||||||
|
}
|
||||||
|
return (await moveReward(uid, 'held', 'released')) ? 'released' : null;
|
||||||
|
}
|
||||||
|
const { referralReferrerPop, referralReferredPop } = await loadPolicy();
|
||||||
|
try {
|
||||||
|
for (const [who, role, amount] of [
|
||||||
|
[referrerUid, 'referrer', referralReferrerPop], [uid, 'referred', referralReferredPop],
|
||||||
|
] as const) {
|
||||||
|
await spendPop({
|
||||||
|
uid: who, popType: 'activity', amount, reasonCode: 'ADMIN_CLAWBACK',
|
||||||
|
idempotencyKey: `${grantKey(uid, role)}:clawback`, refPath: `referrals/${lock.get('attributionId')}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('[referral] 회수 보류', { uid, error: String(e) });
|
||||||
|
await db.collection('adminActionLogs').add({
|
||||||
|
actorUid: 'system', targetPath: lock.ref.path, reason: '추천 보상 회수 보류 — 잔액 부족',
|
||||||
|
patch: { error: String(e) }, createdAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
return (await moveReward(uid, 'paid', 'clawback-pending')) ? 'clawback-pending' : null;
|
||||||
|
}
|
||||||
|
const moved = await moveReward(uid, 'paid', 'clawedback', async (tx, fresh) => {
|
||||||
|
const referrer = await tx.get(db.doc(`users/${referrerUid}`));
|
||||||
|
const fandomId = fandomOfUser(referrer);
|
||||||
|
const eventRef = fandomId
|
||||||
|
? db.doc(`fandoms/${fandomId}/fpiEvents/${referrerUid}_${referralFpiSource(fresh)}`) : null;
|
||||||
|
const event = eventRef ? await tx.get(eventRef) : null;
|
||||||
|
return () => {
|
||||||
|
if (event?.exists) tx.update(event.ref, { verified: false, voidedAt: FieldValue.serverTimestamp() });
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return moved ? 'clawedback' : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 검증 활동 표식이 새로 생길 때마다 — 대상이 아니면 잠금 문서 한 번 읽고 끝난다. */
|
||||||
|
export const fnReferralRewardOnCompletion = onDocumentCreated(
|
||||||
|
'users/{uid}/verifiedCompletions/{completionId}',
|
||||||
|
async (event) => {
|
||||||
|
await payReferralReward(event.params.uid, event.params.completionId);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 표식이 무효(`voided`)로 바뀌면 보류 해제 · 회수. */
|
||||||
|
export const fnReferralOnCompletionVoid = onDocumentUpdated(
|
||||||
|
'users/{uid}/verifiedCompletions/{completionId}',
|
||||||
|
async (event) => {
|
||||||
|
if (event.data?.before.get('voided') !== true && event.data?.after.get('voided') === true) {
|
||||||
|
await onReferralCompletionVoided(event.params.uid);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/** `fn-referral-settle` — 24시간 지난 보류 추천 보상을 확정 · 해제한다. */
|
||||||
|
export const fnReferralSettle = onSchedule('every 1 hours', async () => {
|
||||||
|
const due = await getFirestore().collection('referralsByUser')
|
||||||
|
.where('reward', '==', 'held').where('settleAfter', '<=', Timestamp.now()).get();
|
||||||
|
for (const doc of due.docs) {
|
||||||
|
try {
|
||||||
|
await settleReferral(doc.id);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[referral] 정산 실패', { uid: doc.id, error: String(error) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
import { HttpsError, onCall } from 'firebase-functions/v2/https';
|
||||||
|
import { onSchedule } from 'firebase-functions/v2/scheduler';
|
||||||
|
import { getFirestore, FieldValue, Timestamp } from 'firebase-admin/firestore';
|
||||||
|
import { logger } from 'firebase-functions';
|
||||||
|
|
||||||
|
import { callerUid, str } from '../core/callable.js';
|
||||||
|
import { loadPolicy } from '../core/config.js';
|
||||||
|
import { ERR, fail } from '../core/errors.js';
|
||||||
|
import { deviceHashOf, txIdOf } from '../core/ids.js';
|
||||||
|
import { dayKey } from '../core/time.js';
|
||||||
|
import { publicNameOf } from '../core/names.js';
|
||||||
|
import { grantPop, settlePendingGrant } from '../ledger/ledger.js';
|
||||||
|
import { fandomOfUser } from '../ledger/fpi.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 참여형 번역 — 별첨 1 ⑧ · 요구사항 FR-I18N-02 · 정책 원장 B-63.
|
||||||
|
*
|
||||||
|
* 등록(`fn-translation-submit`) → 다른 멤버 마킹(`fn-translation-mark`) → 24시간 뒤
|
||||||
|
* 정산(`fn-translation-settle`). 보상은 등록 때 **보류 지급**으로 잡아 두고(예약 잔액)
|
||||||
|
* 정산이 확정하거나 해제한다 — 24시간 전 잔액 미포함(B-63 검증 기준).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 1차 공식 지원 언어 — 정책 원장 B-76. 앱 `appLocales` 와 같은 태그다. */
|
||||||
|
export const TRANSLATION_LOCALES = ['ko', 'en', 'ja', 'zh-CN', 'es'] as const;
|
||||||
|
export const TRANSLATION_MAX_LENGTH = 2000;
|
||||||
|
/** 하루 등록 자체를 막는 선 = 보상 일일상한 × 이 배수(도배 차단). */
|
||||||
|
export const TRANSLATION_HARD_LIMIT_FACTOR = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 번역을 붙일 수 있는 콘텐츠 — 인사이드 질문·답변 · 라운지 글·댓글 · 캠페인(응원 게시판) · 공지.
|
||||||
|
* 그 밖의 경로(사용자 문서 등)는 존재 여부를 드러내지 않고 E-TRN-03 으로 끊는다.
|
||||||
|
*/
|
||||||
|
const TARGET_PATTERNS = [
|
||||||
|
/^questions\/[^/]+$/,
|
||||||
|
/^questions\/[^/]+\/answers\/[^/]+$/,
|
||||||
|
/^posts\/[^/]+$/,
|
||||||
|
/^posts\/[^/]+\/comments\/[^/]+$/,
|
||||||
|
/^campaigns\/[^/]+$/,
|
||||||
|
/^campaigns\/[^/]+\/comments\/[^/]+$/,
|
||||||
|
/^notices\/[^/]+$/,
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface TranslationInput {
|
||||||
|
targetPath: string;
|
||||||
|
locale: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidTarget = (): never => fail(ERR.trnInvalidTarget, '번역 대상을 찾을 수 없습니다.');
|
||||||
|
|
||||||
|
export function parseTranslationInput(data: unknown): TranslationInput {
|
||||||
|
const targetPath = str(data, 'targetPath');
|
||||||
|
const locale = str(data, 'locale');
|
||||||
|
const body = str(data, 'body').trim();
|
||||||
|
if (!TARGET_PATTERNS.some((p) => p.test(targetPath))
|
||||||
|
|| targetPath.split('/').some((s) => s === '.' || s === '..')) {
|
||||||
|
invalidTarget();
|
||||||
|
}
|
||||||
|
if (!(TRANSLATION_LOCALES as readonly string[]).includes(locale)) {
|
||||||
|
throw new HttpsError('invalid-argument', '지원하지 않는 언어입니다.');
|
||||||
|
}
|
||||||
|
if (body.length === 0 || body.length > TRANSLATION_MAX_LENGTH) {
|
||||||
|
throw new HttpsError('invalid-argument', `번역은 1~${TRANSLATION_MAX_LENGTH}자입니다.`);
|
||||||
|
}
|
||||||
|
return { targetPath, locale, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 등록 시점 판정 — 원장 B-63 「중복·도배 제외 · 일일상한 초과분 미지급」.
|
||||||
|
* 상한의 [TRANSLATION_HARD_LIMIT_FACTOR] 배를 넘으면 등록 자체를 거부(E-TRN-02),
|
||||||
|
* 상한까지는 보상, 그 사이는 등록만 받고 보상 0.
|
||||||
|
*/
|
||||||
|
export function translationRewardOf(input: {
|
||||||
|
pop: number; dailyLimit: number; todayCount: number; duplicate: boolean;
|
||||||
|
}): { rewardPop: number; excludedReason: 'duplicate' | 'daily_limit' | null } {
|
||||||
|
if (input.todayCount >= input.dailyLimit * TRANSLATION_HARD_LIMIT_FACTOR) {
|
||||||
|
fail(ERR.trnDailyCap, '오늘 등록할 수 있는 번역 수를 넘었습니다.');
|
||||||
|
}
|
||||||
|
if (input.duplicate) return { rewardPop: 0, excludedReason: 'duplicate' };
|
||||||
|
if (input.todayCount >= input.dailyLimit) return { rewardPop: 0, excludedReason: 'daily_limit' };
|
||||||
|
return { rewardPop: input.pop, excludedReason: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 정산 판정 — 확정 · 해제 · 대기. 작성자 마킹은 이미 빠진 수를 받는다. */
|
||||||
|
export function translationSettleOf(input: {
|
||||||
|
valid: number; invalid: number; ageHours: number;
|
||||||
|
minValid: number; maxPendingHours: number;
|
||||||
|
}): 'confirm' | 'release' | 'wait' {
|
||||||
|
if (input.valid >= input.minValid && input.valid > input.invalid) return 'confirm';
|
||||||
|
return input.ageHours >= input.maxPendingHours ? 'release' : 'wait';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 번역을 받는 캠페인 상태 — 공개된 캠페인만(검수 · 철회 · 취소 · 반려는 제외). */
|
||||||
|
const VISIBLE_CAMPAIGN_STATUSES = ['funding', 'success', 'fail', 'report'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 다른 사용자에게 보이는 콘텐츠인가. 캠페인은 [VISIBLE_CAMPAIGN_STATUSES], 나머지(글 · 댓글 ·
|
||||||
|
* 질문 · 답변 · 공지)는 `visibility` 가 `visible` 이어야 한다 — 필드가 없는 질문 · 공지는 공개다.
|
||||||
|
* 없는 콘텐츠와 숨긴 콘텐츠는 호출자가 같은 E-TRN-03 으로 끊는다(존재 여부를 드러내지 않게).
|
||||||
|
*/
|
||||||
|
export function isVisibleTarget(targetPath: string, data: FirebaseFirestore.DocumentData | undefined): boolean {
|
||||||
|
if (!data) return false;
|
||||||
|
// 캠페인 문서는 상태로, 응원 게시판 댓글(`campaigns/{id}/comments/{cid}`)은 아래 visibility 로 본다.
|
||||||
|
if (/^campaigns\/[^/]+$/.test(targetPath)) return VISIBLE_CAMPAIGN_STATUSES.includes(String(data.status));
|
||||||
|
return (data.visibility ?? 'visible') === 'visible';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 콘텐츠의 팬덤. 답변·댓글은 부모 글에서 읽는다. 공지처럼 팬덤이 없으면 null. */
|
||||||
|
async function fandomOfTarget(targetPath: string, target: FirebaseFirestore.DocumentSnapshot) {
|
||||||
|
const own = target.get('fandomId');
|
||||||
|
if (typeof own === 'string' && own.length > 0) return own;
|
||||||
|
const segments = targetPath.split('/');
|
||||||
|
if (segments.length <= 2) return null;
|
||||||
|
const root = await getFirestore().doc(segments.slice(0, 2).join('/')).get();
|
||||||
|
const parent = root.get('fandomId');
|
||||||
|
return typeof parent === 'string' && parent.length > 0 ? parent : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `fn-translation-submit` — 번역 등록. 클라이언트 직접 쓰기는 규칙이 막는다. */
|
||||||
|
export const fnTranslationSubmit = onCall(async (req) => {
|
||||||
|
const uid = callerUid(req);
|
||||||
|
const input = parseTranslationInput(req.data);
|
||||||
|
const db = getFirestore();
|
||||||
|
const target = await db.doc(input.targetPath).get();
|
||||||
|
const rootPath = input.targetPath.split('/').slice(0, 2).join('/');
|
||||||
|
// 댓글 · 답변은 달린 글도 보여야 한다.
|
||||||
|
const root = rootPath === input.targetPath ? target : await db.doc(rootPath).get();
|
||||||
|
if (!isVisibleTarget(input.targetPath, target.data()) || !isVisibleTarget(rootPath, root.data())) {
|
||||||
|
invalidTarget();
|
||||||
|
}
|
||||||
|
const fandomId = await fandomOfTarget(input.targetPath, target);
|
||||||
|
const author = await db.doc(`users/${uid}`).get();
|
||||||
|
|
||||||
|
const policy = await loadPolicy();
|
||||||
|
const today = dayKey(new Date());
|
||||||
|
const entryRef = db.doc(`translationEntries/${txIdOf(`trn:${uid}:${input.targetPath}:${input.locale}`)}`);
|
||||||
|
const counterRef = db.doc(`translationDaily/${uid}_${today}`);
|
||||||
|
// 본문은 색인 한도(1,500바이트)를 넘을 수 있어 같은 문장 판정은 해시로 한다.
|
||||||
|
const bodyHash = deviceHashOf(input.body);
|
||||||
|
|
||||||
|
const result = await db.runTransaction(async (tx) => {
|
||||||
|
const [existing, counter, same] = await Promise.all([
|
||||||
|
tx.get(entryRef),
|
||||||
|
tx.get(counterRef),
|
||||||
|
tx.get(db.collection('translationEntries').where('targetPath', '==', input.targetPath)
|
||||||
|
.where('locale', '==', input.locale).where('bodyHash', '==', bodyHash).limit(1)),
|
||||||
|
]);
|
||||||
|
if (existing.exists) fail(ERR.trnDuplicate, '이미 이 콘텐츠에 같은 언어로 번역을 등록했습니다.');
|
||||||
|
const reward = translationRewardOf({
|
||||||
|
pop: policy.translationPop,
|
||||||
|
dailyLimit: policy.translationDailyLimit,
|
||||||
|
todayCount: (counter.get('count') as number | undefined) ?? 0,
|
||||||
|
duplicate: !same.empty,
|
||||||
|
});
|
||||||
|
// 보상분을 예약 잔액으로 잡는다(읽기가 끝난 뒤 · 쓰기 전에 — 트랜잭션 순서 규칙).
|
||||||
|
if (reward.rewardPop > 0) {
|
||||||
|
await grantPop({
|
||||||
|
uid, popType: 'activity', amount: reward.rewardPop, reasonCode: 'TRANSLATION',
|
||||||
|
idempotencyKey: `trn:${entryRef.id}`, refPath: entryRef.path, pending: true,
|
||||||
|
}, tx);
|
||||||
|
}
|
||||||
|
tx.create(entryRef, {
|
||||||
|
authorUid: uid,
|
||||||
|
targetPath: input.targetPath,
|
||||||
|
// 글 · 질문 · 캠페인 경로. 상세 화면이 본문과 댓글의 번역 댓글을 한 번에 읽는다.
|
||||||
|
rootPath,
|
||||||
|
authorName: publicNameOf(author),
|
||||||
|
fandomId,
|
||||||
|
locale: input.locale,
|
||||||
|
body: input.body,
|
||||||
|
bodyHash,
|
||||||
|
status: 'pending',
|
||||||
|
// 마킹 수 — fn-translation-mark 가 같은 트랜잭션에서 올린다. 앱은 이 값만 읽는다.
|
||||||
|
validCount: 0,
|
||||||
|
invalidCount: 0,
|
||||||
|
...reward,
|
||||||
|
dayKey: today,
|
||||||
|
createdAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
tx.set(counterRef, {
|
||||||
|
uid, dayKey: today, count: FieldValue.increment(1), updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
}, { merge: true });
|
||||||
|
return reward;
|
||||||
|
});
|
||||||
|
return { entryId: entryRef.id, ...result };
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마킹 자격 — 가입 24시간 이상 · 번역 대상 팬덤의 멤버(팬덤 없는 공지는 로그인만).
|
||||||
|
* INSIDE 질문·답변은 운영값 `translationInsideAnyMember`(🟡 Q-TR-04 권고 B안 · 기본 true)
|
||||||
|
* 가 켜져 있으면 팬덤 조건 없이 가입 24시간 경과 회원 전체가 마킹한다.
|
||||||
|
*/
|
||||||
|
const ACCOUNT_MIN_AGE_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `fn-translation-mark` — 유효/무효 마킹. 본인 번역은 E-TRN-01, 자격 미달은 E-TRN-04,
|
||||||
|
* 두 번째 마킹은 E-TRN-05. 번복은 없다.
|
||||||
|
*/
|
||||||
|
export const fnTranslationMark = onCall(async (req) => {
|
||||||
|
const uid = callerUid(req);
|
||||||
|
const entryId = str(req.data, 'entryId');
|
||||||
|
const valid = (req.data as Record<string, unknown> | null)?.valid;
|
||||||
|
if (typeof valid !== 'boolean') throw new HttpsError('invalid-argument', 'valid 이(가) 필요합니다.');
|
||||||
|
const db = getFirestore();
|
||||||
|
|
||||||
|
const entryRef = db.doc(`translationEntries/${entryId}`);
|
||||||
|
const entry = await entryRef.get();
|
||||||
|
if (!entry.exists || entry.get('status') !== 'pending') invalidTarget();
|
||||||
|
if (entry.get('authorUid') === uid) fail(ERR.trnSelfMark, '본인 번역은 마킹할 수 없습니다.');
|
||||||
|
|
||||||
|
const user = await db.doc(`users/${uid}`).get();
|
||||||
|
const created = user.get('createdAt') as Timestamp | undefined;
|
||||||
|
if (!created || Date.now() - created.toMillis() < ACCOUNT_MIN_AGE_MS) {
|
||||||
|
fail(ERR.trnNotEligible, '가입 후 24시간이 지나야 마킹할 수 있습니다.');
|
||||||
|
}
|
||||||
|
const targetPath = String(entry.get('targetPath'));
|
||||||
|
// 등록 뒤 가려진 콘텐츠(숨김 · 캠페인 철회 등)의 번역은 더 마킹하지 않는다 — 등록과 같은 E-TRN-03.
|
||||||
|
const rootPath = targetPath.split('/').slice(0, 2).join('/');
|
||||||
|
const [target, root] = await Promise.all([db.doc(targetPath).get(), db.doc(rootPath).get()]);
|
||||||
|
if (!isVisibleTarget(targetPath, target.data()) || !isVisibleTarget(rootPath, root.data())) invalidTarget();
|
||||||
|
const fandomId = entry.get('fandomId') as string | null | undefined;
|
||||||
|
const insideOpen = targetPath.startsWith('questions/')
|
||||||
|
&& (await loadPolicy()).translationInsideAnyMember;
|
||||||
|
// 캠페인은 팬덤 멤버만 마킹한다 — 팬덤이 없는 캠페인은 마킹할 수 있는 사람이 없다.
|
||||||
|
if (!fandomId && targetPath.startsWith('campaigns/')) {
|
||||||
|
fail(ERR.trnNotEligible, '이 팬덤의 멤버만 마킹할 수 있습니다.');
|
||||||
|
}
|
||||||
|
if (fandomId && !insideOpen) {
|
||||||
|
const member = await db.doc(`fandoms/${fandomId}/members/${uid}`).get();
|
||||||
|
if (!member.exists && fandomOfUser(user) !== fandomId) {
|
||||||
|
fail(ERR.trnNotEligible, '이 팬덤의 멤버만 마킹할 수 있습니다.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 마킹과 수 집계는 함께 커밋한다 — 앱은 번역 문서의 수만 읽는다.
|
||||||
|
const markRef = entryRef.collection('markings').doc(uid);
|
||||||
|
await db.runTransaction(async (tx) => {
|
||||||
|
const [fresh, mine] = await Promise.all([tx.get(entryRef), tx.get(markRef)]);
|
||||||
|
if (fresh.get('status') !== 'pending') invalidTarget();
|
||||||
|
if (mine.exists) fail(ERR.trnDuplicate, '이미 마킹한 번역입니다.');
|
||||||
|
tx.create(markRef, { valid, createdAt: FieldValue.serverTimestamp() });
|
||||||
|
tx.update(entryRef, { [valid ? 'validCount' : 'invalidCount']: FieldValue.increment(1) });
|
||||||
|
});
|
||||||
|
return { entryId, valid };
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `fn-translation-settle` — 24시간 지난 번역 정산.
|
||||||
|
*
|
||||||
|
* 유효 마킹이 운영값 이상이고 무효보다 많으면 확정(예약 → 사용 가능). 못 채우면 기다리고,
|
||||||
|
* 최대 대기 시간을 넘기면 예약을 해제하고 닫는다. 자기 마킹은 세지 않는다(별첨 1 ⑧).
|
||||||
|
*/
|
||||||
|
export const fnTranslationSettle = onSchedule('every 1 hours', async () => {
|
||||||
|
const db = getFirestore();
|
||||||
|
const policy = await loadPolicy();
|
||||||
|
const now = Date.now();
|
||||||
|
const due = await db
|
||||||
|
.collection('translationEntries')
|
||||||
|
.where('status', '==', 'pending')
|
||||||
|
.where('createdAt', '<=', Timestamp.fromMillis(now - 24 * 60 * 60 * 1000))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const count = { confirmed: 0, excluded: 0, waiting: 0 };
|
||||||
|
|
||||||
|
for (const entry of due.docs) {
|
||||||
|
const authorUid = entry.get('authorUid') as string | undefined;
|
||||||
|
if (!authorUid) continue;
|
||||||
|
const reward = (entry.get('rewardPop') as number | undefined) ?? 0;
|
||||||
|
try {
|
||||||
|
if (reward <= 0) {
|
||||||
|
// 등록 때 보상에서 제외된 건(중복·일일상한). 예약이 없으니 닫기만 한다.
|
||||||
|
await entry.ref.set({ status: 'excluded', settledAt: FieldValue.serverTimestamp() }, { merge: true });
|
||||||
|
count.excluded += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const markings = await entry.ref.collection('markings').get();
|
||||||
|
const others = markings.docs.filter((m) => m.id !== authorUid);
|
||||||
|
const decision = translationSettleOf({
|
||||||
|
valid: others.filter((m) => m.get('valid') === true).length,
|
||||||
|
invalid: others.filter((m) => m.get('valid') === false).length,
|
||||||
|
ageHours: (now - (entry.get('createdAt') as Timestamp).toMillis()) / 3_600_000,
|
||||||
|
minValid: policy.translationMinValidMarks,
|
||||||
|
maxPendingHours: policy.translationMaxPendingHours,
|
||||||
|
});
|
||||||
|
if (decision === 'wait') {
|
||||||
|
count.waiting += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await settlePendingGrant({
|
||||||
|
uid: authorUid,
|
||||||
|
idempotencyKey: `trn:${entry.id}`,
|
||||||
|
outcome: decision,
|
||||||
|
});
|
||||||
|
await entry.ref.set({
|
||||||
|
status: decision === 'confirm' ? 'confirmed' : 'excluded',
|
||||||
|
...(decision === 'release' ? { excludedReason: 'unverified' } : {}),
|
||||||
|
settledAt: FieldValue.serverTimestamp(),
|
||||||
|
}, { merge: true });
|
||||||
|
count[decision === 'confirm' ? 'confirmed' : 'excluded'] += 1;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[translation] 정산 실패', { entryId: entry.id, error: String(error) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('[translation] 정산', { scanned: due.size, ...count });
|
||||||
|
});
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { onCall } from 'firebase-functions/v2/https';
|
import { onCall } from 'firebase-functions/v2/https';
|
||||||
import { getFirestore, FieldValue } from 'firebase-admin/firestore';
|
import { getFirestore, FieldValue } from 'firebase-admin/firestore';
|
||||||
|
|
||||||
import { callerUid, requireRole, str } from '../core/callable.js';
|
import { callerUid, num, optionalStr, requireRole, str } from '../core/callable.js';
|
||||||
import { ERR, fail } from '../core/errors.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).
|
* `fn-admin-write` — 모든 Admin 쓰기의 단일 관문(§7.5).
|
||||||
@@ -13,7 +15,7 @@ import { ERR, fail } from '../core/errors.js';
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 원장 · 지표 · 감사 기록은 이 경로로 못 쓴다 — **master 도 불가**.
|
* 원장 · 지표 · 감사 기록은 이 경로로 못 쓴다 — **master 도 불가**(리뷰 H3).
|
||||||
* 반드시 원장 · 집계 함수를 지나야 하고, 감사 로그는 고칠 수 없어야 감사 로그다.
|
* 반드시 원장 · 집계 함수를 지나야 하고, 감사 로그는 고칠 수 없어야 감사 로그다.
|
||||||
*/
|
*/
|
||||||
const FORBIDDEN = [
|
const FORBIDDEN = [
|
||||||
@@ -41,7 +43,7 @@ const MASTER_ONLY_FIELDS: Readonly<Record<string, readonly string[]>> = {
|
|||||||
*
|
*
|
||||||
* 이 함수는 Admin SDK 라 규칙을 거치지 않는다. 여기서 같은 선을 긋지 않으면 ops 가
|
* 이 함수는 Admin SDK 라 규칙을 거치지 않는다. 여기서 같은 선을 긋지 않으면 ops 가
|
||||||
* 재화 정책(`config`) · 퀘스트 · 행성 단계 같은 마스터 전용 문서를 우회해 쓴다.
|
* 재화 정책(`config`) · 퀘스트 · 행성 단계 같은 마스터 전용 문서를 우회해 쓴다.
|
||||||
* `configPrivate` 는 규칙상 서버 전용이라 함께 막는다.
|
* `configPrivate` 는 규칙상 서버 전용이라 함께 막는다. 역할 체계 개편은 S05 결정 대기.
|
||||||
*/
|
*/
|
||||||
const MASTER_ONLY = new Set([
|
const MASTER_ONLY = new Set([
|
||||||
'config', 'configPrivate', 'appConfig', 'i18n', 'planetStages', 'quests', 'badges',
|
'config', 'configPrivate', 'appConfig', 'i18n', 'planetStages', 'quests', 'badges',
|
||||||
@@ -118,3 +120,128 @@ export const fnAdminWrite = onCall(async (req) => {
|
|||||||
|
|
||||||
return { logId: log.id, targetPath };
|
return { logId: log.id, targetPath };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `fn-admin-pop-grant-batch` — 일괄 지급 미리보기와 실행.
|
||||||
|
*
|
||||||
|
* 별첨 1 ⑨ 가 「미리보기 + 2단계 확인」을 요구한다. `dryRun` 이 기본값이고,
|
||||||
|
* 실행하려면 미리보기에서 받은 대상 수를 `confirmCount` 로 되돌려 줘야 한다 —
|
||||||
|
* 두 번째 호출이 첫 번째와 같은 모집단을 보고 있는지 확인하는 장치다.
|
||||||
|
*/
|
||||||
|
export const fnAdminPopGrantBatch = onCall(async (req) => {
|
||||||
|
const actorUid = callerUid(req);
|
||||||
|
requireRole(req, ['master', 'ops']);
|
||||||
|
const batchId = str(req.data, 'batchId');
|
||||||
|
const data = (req.data ?? {}) as Record<string, unknown>;
|
||||||
|
const amount = data.amount;
|
||||||
|
if (typeof amount !== 'number' || !Number.isInteger(amount) || amount <= 0) {
|
||||||
|
fail(ERR.popVerifyFailed, '지급 수량이 올바르지 않습니다.');
|
||||||
|
}
|
||||||
|
const targets = Array.isArray(data.uids)
|
||||||
|
? (data.uids as unknown[]).filter((u): u is string => typeof u === 'string')
|
||||||
|
: [];
|
||||||
|
if (targets.length === 0) fail(ERR.popVerifyFailed, '지급 대상이 없습니다.');
|
||||||
|
|
||||||
|
const dryRun = data.dryRun !== false;
|
||||||
|
if (dryRun) {
|
||||||
|
return { preview: true, batchId, targetCount: targets.length, amount };
|
||||||
|
}
|
||||||
|
if (data.confirmCount !== targets.length) {
|
||||||
|
fail(ERR.popVerifyFailed, '미리보기에서 확인한 대상 수와 다릅니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { grantPop } = await import('../ledger/ledger.js');
|
||||||
|
const succeeded: string[] = [];
|
||||||
|
const failed: Array<{ uid: string; error: string }> = [];
|
||||||
|
// 500건 청크로 나눈다 — 한 번에 몰면 Functions 실행 시간을 넘긴다.
|
||||||
|
for (const chunk of chunks(targets, 500)) {
|
||||||
|
for (const uid of chunk) {
|
||||||
|
try {
|
||||||
|
await grantPop({
|
||||||
|
uid,
|
||||||
|
popType: 'activity',
|
||||||
|
amount: amount as number,
|
||||||
|
reasonCode: 'ADMIN_GRANT',
|
||||||
|
idempotencyKey: `grant:${batchId}:${uid}`,
|
||||||
|
actorUid,
|
||||||
|
});
|
||||||
|
succeeded.push(uid);
|
||||||
|
} catch (error) {
|
||||||
|
failed.push({ uid, error: String(error) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await getFirestore().collection('adminActionLogs').add({
|
||||||
|
actorUid,
|
||||||
|
targetPath: `batch/${batchId}`,
|
||||||
|
reason: 'ADMIN_GRANT batch',
|
||||||
|
patch: { amount, targetCount: targets.length },
|
||||||
|
succeeded: succeeded.length,
|
||||||
|
failed: failed.length,
|
||||||
|
createdAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { preview: false, batchId, succeeded: succeeded.length, failed };
|
||||||
|
});
|
||||||
|
|
||||||
|
function* chunks<T>(items: T[], size: number): Generator<T[]> {
|
||||||
|
for (let i = 0; i < items.length; i += size) yield items.slice(i, i + size);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `fn-admin-growth-reverse` — 오지급 확정분의 성장 역분개(정책 원장 **C-32**).
|
||||||
|
*
|
||||||
|
* 허위 참여 · 중복 지급 · 오지급이 **확정**된 건만 부른다. 의심 단계에서는 부르지
|
||||||
|
* 않는다. 사유와 수치는 역분개 행과 작업 로그 양쪽에 남는다.
|
||||||
|
*/
|
||||||
|
export const fnAdminGrowthReverse = onCall(async (req) => {
|
||||||
|
const actorUid = callerUid(req);
|
||||||
|
const role = requireRole(req, ['master', 'ops']);
|
||||||
|
const uid = str(req.data, 'uid');
|
||||||
|
const sourceEventId = str(req.data, 'sourceEventId');
|
||||||
|
const reason = str(req.data, 'reason');
|
||||||
|
if (uid.includes('/') || sourceEventId.includes('/')) {
|
||||||
|
fail(ERR.permissionDenied, '문서 경로가 아닙니다.');
|
||||||
|
}
|
||||||
|
const result = await reverseGrowth({
|
||||||
|
uid, sourceEventId, reason, actorUid,
|
||||||
|
amount: positiveInt(num(req.data, 'amount'), 'amount'),
|
||||||
|
});
|
||||||
|
await getFirestore().collection('adminActionLogs').add({
|
||||||
|
actorUid,
|
||||||
|
role,
|
||||||
|
targetPath: `users/${uid}/growthEvents/${result.reversalId}`,
|
||||||
|
reason,
|
||||||
|
patch: { reversalOf: sourceEventId, amount: -result.amount, fandomId: result.fandomId },
|
||||||
|
createdAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `fn-admin-completion-void` — Quest 완료의 사후 무효를 「검증 활동」 수에 반영한다(Campaign 개설자격 A안).
|
||||||
|
*
|
||||||
|
* 성장 역분개가 없는 완료(무보상 · 팬덤 없음 · EXP 만)도 무효로 할 수 있게 **원장 행(`txId`) 또는
|
||||||
|
* 제출 건(`submissionId`)** 으로 찾는다. 이미 무효인 표식은 다시 세지 않는다(`voided` 1회).
|
||||||
|
*/
|
||||||
|
export const fnAdminCompletionVoid = onCall(async (req) => {
|
||||||
|
const actorUid = callerUid(req);
|
||||||
|
const role = requireRole(req, ['master', 'ops']);
|
||||||
|
const uid = str(req.data, 'uid');
|
||||||
|
const reason = str(req.data, 'reason');
|
||||||
|
const txId = optionalStr(req.data, 'txId');
|
||||||
|
const submissionId = optionalStr(req.data, 'submissionId');
|
||||||
|
if (uid.includes('/') || (!txId && !submissionId)) fail(ERR.permissionDenied, '대상 원장 행 또는 제출 건이 필요합니다.');
|
||||||
|
const voided = await voidCompletion(uid, txId ? { txId } : { submissionId: submissionId! });
|
||||||
|
await getFirestore().collection('adminActionLogs').add({
|
||||||
|
actorUid, role, targetPath: `users/${uid}/verifiedCompletions`, reason,
|
||||||
|
patch: { txId: txId ?? null, submissionId: submissionId ?? null, voided },
|
||||||
|
createdAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
return { voided };
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function voidCompletion(uid: string, key: { txId?: string; submissionId?: string }): Promise<number> {
|
||||||
|
return getFirestore().runTransaction(async (tx) => (await voidCompletions(tx, uid, key))(tx));
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import { onCall } from 'firebase-functions/v2/https';
|
||||||
|
import { getFirestore, FieldValue, Timestamp } from 'firebase-admin/firestore';
|
||||||
|
import { logger } from 'firebase-functions';
|
||||||
|
|
||||||
|
import { callerUid, requireRole, str } from '../core/callable.js';
|
||||||
|
import { ERR, fail } from '../core/errors.js';
|
||||||
|
import { briefingHashOf } from '../core/ids.js';
|
||||||
|
import { dayKey } from '../core/time.js';
|
||||||
|
import { sendFandomPush } from '../notify/push.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI Daily Briefing — 정책 원장 B-47 · B-48 · B-75, 별첨 2 6 주차.
|
||||||
|
*
|
||||||
|
* **문안을 우리가 만들지 않는다.** B-48 이 「외부 Agent 엔진은 발주사 영역」이고
|
||||||
|
* 우리 범위는 「Candidate/Draft 수신 · Admin 승인 · 노출」 3 단계, 「Agent 엔진 자체
|
||||||
|
* 구현 0건」이라고 확정했다. 그래서 이 파일에는 수신(`fn-briefing-ingest`)과
|
||||||
|
* 승인(`fn-briefing-publish`)만 있다.
|
||||||
|
*
|
||||||
|
* **문서 한 장이 카드 한 줄이다.** 앱과 시드가 이미 `briefings/{id}` 를 한 줄로
|
||||||
|
* 읽고 있어(`rank` 오름차순) 그 형태를 유지한다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 수신 가능한 섹션 — B-47 이 News 와 SNS 둘로 확정했다. */
|
||||||
|
const SECTIONS = ['News', 'SNS'] as const;
|
||||||
|
type Section = typeof SECTIONS[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* B-75 가 요구하는 5 요소를 담은 수신 후보.
|
||||||
|
*
|
||||||
|
* *각 항목에 출처명·게시/발행시각·원본 URL·원문보기·AI 요약 표시를 제공합니다.*
|
||||||
|
* *출처 확인 불가 AI 내용은 게시하지 않습니다.* · *메타 누락 건은 Draft 단계에서 배제*
|
||||||
|
*
|
||||||
|
* 그래서 검증을 게시가 아니라 **수신**에서 한다. 누락 건을 일단 저장해 두면
|
||||||
|
* 관리자 화면에 게시할 수 없는 초안이 쌓이고, 그것이 노출 사고의 재료가 된다.
|
||||||
|
*/
|
||||||
|
export interface BriefingCandidate {
|
||||||
|
fandomId: string;
|
||||||
|
section: Section;
|
||||||
|
/** 카드에 뜨는 제목. */
|
||||||
|
title: string;
|
||||||
|
/** 출처명 — 언론사·계정명. */
|
||||||
|
sourceName: string;
|
||||||
|
/** 게시·발행 시각. ISO 8601 문자열로 받는다. */
|
||||||
|
sourceAt: string;
|
||||||
|
/** 원본 URL. */
|
||||||
|
sourceUrl: string;
|
||||||
|
/** 원문보기 링크. 원본과 같아도 된다. */
|
||||||
|
articleUrl: string;
|
||||||
|
/** AI 요약 본문. */
|
||||||
|
aiSummary: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IngestResult {
|
||||||
|
accepted: number;
|
||||||
|
rejected: Array<{ index: number; reason: string }>;
|
||||||
|
dayKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 후보 한 건을 검증한다. 통과하면 정규화된 값을, 아니면 사유를 돌려준다.
|
||||||
|
*
|
||||||
|
* **부분 통과를 만들지 않는다.** 5 요소 중 하나라도 비면 그 항목을 저장하지 않는다.
|
||||||
|
* 빈 문자열과 공백만 있는 값을 누락으로 본다 — 수집기가 값을 못 찾았을 때 빈
|
||||||
|
* 문자열을 채워 보내는 것이 흔하고, 그것을 통과시키면 카드에 출처가 비어 뜬다.
|
||||||
|
*/
|
||||||
|
export function validateCandidate(
|
||||||
|
raw: unknown,
|
||||||
|
): { ok: true; value: BriefingCandidate } | { ok: false; reason: string } {
|
||||||
|
const data = (raw ?? {}) as Record<string, unknown>;
|
||||||
|
const text = (key: string): string | null => {
|
||||||
|
const value = data[key];
|
||||||
|
return typeof value === 'string' && value.trim().length > 0
|
||||||
|
? value.trim()
|
||||||
|
: null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const section = text('section');
|
||||||
|
if (!section || !(SECTIONS as readonly string[]).includes(section)) {
|
||||||
|
return { ok: false, reason: `section 은 ${SECTIONS.join('|')} 중 하나여야 합니다.` };
|
||||||
|
}
|
||||||
|
const fandomId = text('fandomId');
|
||||||
|
if (!fandomId) return { ok: false, reason: 'fandomId 가 필요합니다.' };
|
||||||
|
|
||||||
|
// B-75 5 요소. 빠진 것을 전부 모아서 알린다 — 하나씩 돌려주면 수집기 쪽이
|
||||||
|
// 같은 항목으로 다섯 번 재시도한다.
|
||||||
|
const required = {
|
||||||
|
title: text('title'),
|
||||||
|
sourceName: text('sourceName'),
|
||||||
|
sourceAt: text('sourceAt'),
|
||||||
|
sourceUrl: text('sourceUrl'),
|
||||||
|
articleUrl: text('articleUrl'),
|
||||||
|
aiSummary: text('aiSummary'),
|
||||||
|
};
|
||||||
|
const missing = Object.entries(required)
|
||||||
|
.filter(([, value]) => value === null)
|
||||||
|
.map(([key]) => key);
|
||||||
|
if (missing.length > 0) {
|
||||||
|
return { ok: false, reason: `출처 메타가 없습니다: ${missing.join(', ')}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 시각은 파싱까지 해 본다. 문자열이 있기만 하면 통과시키면 카드가 「Invalid
|
||||||
|
// Date」 를 띄우고, 그 원인이 수집 단계였다는 것이 나중에는 보이지 않는다.
|
||||||
|
if (Number.isNaN(Date.parse(required.sourceAt!))) {
|
||||||
|
return { ok: false, reason: 'sourceAt 이 시각 형식이 아닙니다.' };
|
||||||
|
}
|
||||||
|
// URL 도 형태를 본다. http(s) 가 아니면 원문보기가 열리지 않는다.
|
||||||
|
for (const key of ['sourceUrl', 'articleUrl'] as const) {
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(required[key]!);
|
||||||
|
} catch {
|
||||||
|
return { ok: false, reason: `${key} 이 http(s) URL 이 아닙니다.` };
|
||||||
|
}
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||||
|
return { ok: false, reason: `${key} 이 http(s) URL 이 아닙니다.` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
value: {
|
||||||
|
fandomId,
|
||||||
|
section: section as Section,
|
||||||
|
title: required.title!,
|
||||||
|
sourceName: required.sourceName!,
|
||||||
|
sourceAt: required.sourceAt!,
|
||||||
|
sourceUrl: required.sourceUrl!,
|
||||||
|
articleUrl: required.articleUrl!,
|
||||||
|
aiSummary: required.aiSummary!,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 같은 기사를 두 번 받아도 문서가 늘지 않게 하는 ID — `briefingHashOf` 참고. */
|
||||||
|
export function candidateDocId(candidate: BriefingCandidate, day: string): string {
|
||||||
|
const key = `${candidate.fandomId}:${day}:${candidate.section}:${candidate.sourceUrl}`;
|
||||||
|
return `${candidate.fandomId}_${day}_${candidate.section}_${briefingHashOf(key)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `fn-briefing-ingest` — 발주사 Agent 엔진의 Candidate/Draft 수신 규격.
|
||||||
|
*
|
||||||
|
* **인증** — 기존 코드베이스에 공유 비밀 헤더 선례가 없어 callable + Admin 역할로
|
||||||
|
* 둔다. B-48 비고 *「Agent 엔진 연동 규격은 발주사 제공 시점에 맞춰 연결합니다」*
|
||||||
|
* 에 따라, 발주사가 서비스 계정이나 서명 규격을 주면 **이 게이트 두 줄만** 바꾼다.
|
||||||
|
* 저장 경로와 검증을 게이트 뒤에 두어 규격이 바뀌어도 그대로 쓸 수 있게 했다.
|
||||||
|
*
|
||||||
|
* 입력은 `items` 배열이다. 한 건씩 받으면 수집이 항목마다 왕복을 돌고, 그 사이에
|
||||||
|
* 날짜가 바뀌면 같은 회차가 두 `dayKey` 로 갈린다.
|
||||||
|
*
|
||||||
|
* **자동 게시는 하지 않는다**(B-47). 저장은 언제나 `status: 'draft'` 다.
|
||||||
|
*/
|
||||||
|
export const fnBriefingIngest = onCall(async (req) => {
|
||||||
|
const actorUid = callerUid(req);
|
||||||
|
const role = requireRole(req, ['master', 'ops']);
|
||||||
|
|
||||||
|
const items = (req.data as Record<string, unknown> | null)?.items;
|
||||||
|
if (!Array.isArray(items) || items.length === 0) {
|
||||||
|
fail(ERR.permissionDenied, '수신할 브리핑 항목이 없습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getFirestore();
|
||||||
|
const day = dayKey(new Date());
|
||||||
|
const rejected: Array<{ index: number; reason: string }> = [];
|
||||||
|
const accepted: BriefingCandidate[] = [];
|
||||||
|
|
||||||
|
(items as unknown[]).forEach((raw, index) => {
|
||||||
|
const result = validateCandidate(raw);
|
||||||
|
if (result.ok) accepted.push(result.value);
|
||||||
|
else rejected.push({ index, reason: result.reason });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 섹션별로 rank 를 매긴다. 앱이 섹션마다 따로 줄을 세우므로 전체 통번호를
|
||||||
|
// 매기면 News 가 3 건일 때 SNS 가 4 번부터 시작한다.
|
||||||
|
const rankBySection = new Map<Section, number>();
|
||||||
|
const batch = db.batch();
|
||||||
|
for (const candidate of accepted) {
|
||||||
|
const rank = (rankBySection.get(candidate.section) ?? 0) + 1;
|
||||||
|
rankBySection.set(candidate.section, rank);
|
||||||
|
batch.set(db.doc(`briefings/${candidateDocId(candidate, day)}`), {
|
||||||
|
fandomId: candidate.fandomId,
|
||||||
|
dayKey: day,
|
||||||
|
section: candidate.section,
|
||||||
|
rank,
|
||||||
|
// 카드 한 줄은 제목을 띄운다. 필드 이름은 기존 앱 코드와 맞춘다.
|
||||||
|
headline: candidate.title,
|
||||||
|
sourceName: candidate.sourceName,
|
||||||
|
sourceAt: Timestamp.fromDate(new Date(candidate.sourceAt)),
|
||||||
|
sourceUrl: candidate.sourceUrl,
|
||||||
|
articleUrl: candidate.articleUrl,
|
||||||
|
aiSummary: candidate.aiSummary,
|
||||||
|
// B-75 「AI 요약 표시」 — 사람이 쓴 글이 아님을 화면이 밝힐 근거다.
|
||||||
|
aiGenerated: true,
|
||||||
|
// B-47 「AI Draft 자동게시 금지」 — 수신은 언제나 draft 로 끝난다.
|
||||||
|
status: 'draft',
|
||||||
|
ingestedBy: actorUid,
|
||||||
|
createdAt: FieldValue.serverTimestamp(),
|
||||||
|
}, { merge: true });
|
||||||
|
}
|
||||||
|
if (accepted.length > 0) await batch.commit();
|
||||||
|
|
||||||
|
const result: IngestResult = {
|
||||||
|
accepted: accepted.length,
|
||||||
|
rejected,
|
||||||
|
dayKey: day,
|
||||||
|
};
|
||||||
|
logger.info('[briefing] 수신 완료', {
|
||||||
|
role, dayKey: day, accepted: accepted.length, rejected: rejected.length,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `fn-briefing-publish` — 관리자 승인 게시(B-47 「Admin 승인 → 당일자 노출」).
|
||||||
|
*
|
||||||
|
* 권한 검사와 작업 로그는 `fn-admin-write` · `fn-admin-set-role` 과 같은 방식이다 —
|
||||||
|
* 커스텀 클레임만 보고, 누가 무엇을 왜 게시했는지 `adminActionLogs` 에 남긴다.
|
||||||
|
* 규칙 층은 거부만 할 수 있고 로그를 남기지 못한다.
|
||||||
|
*
|
||||||
|
* 게시와 동시에 팬덤의 AI Briefing 토픽으로 푸시를 보낸다. 발송이 실패해도 게시는
|
||||||
|
* 되돌리지 않는다 — 카드가 이미 노출 대상이 된 상태에서 롤백하면 관리자가 본
|
||||||
|
* 결과와 사용자가 보는 화면이 갈린다.
|
||||||
|
*/
|
||||||
|
export const fnBriefingPublish = onCall(async (req) => {
|
||||||
|
const actorUid = callerUid(req);
|
||||||
|
const role = requireRole(req, ['master', 'ops']);
|
||||||
|
const fandomId = str(req.data, 'fandomId');
|
||||||
|
const day = str(req.data, 'dayKey');
|
||||||
|
const db = getFirestore();
|
||||||
|
|
||||||
|
const drafts = await db.collection('briefings')
|
||||||
|
.where('fandomId', '==', fandomId)
|
||||||
|
.where('dayKey', '==', day)
|
||||||
|
.where('status', '==', 'draft')
|
||||||
|
.get();
|
||||||
|
if (drafts.empty) {
|
||||||
|
fail(ERR.permissionDenied, '게시할 브리핑 초안이 없습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const batch = db.batch();
|
||||||
|
for (const draft of drafts.docs) {
|
||||||
|
batch.set(draft.ref, {
|
||||||
|
status: 'published',
|
||||||
|
publishedAt: FieldValue.serverTimestamp(),
|
||||||
|
publishedBy: actorUid,
|
||||||
|
}, { merge: true });
|
||||||
|
}
|
||||||
|
await batch.commit();
|
||||||
|
|
||||||
|
// 섹션별 건수를 함께 남긴다. 「News 만 승인했는가」를 로그에서 되짚을 수 있어야
|
||||||
|
// B-47 의 섹션별 노출 판정을 사후에 대조할 수 있다.
|
||||||
|
const bySection: Record<string, number> = {};
|
||||||
|
for (const draft of drafts.docs) {
|
||||||
|
const section = (draft.get('section') as string | undefined) ?? 'unknown';
|
||||||
|
bySection[section] = (bySection[section] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.collection('adminActionLogs').add({
|
||||||
|
actorUid,
|
||||||
|
role,
|
||||||
|
targetPath: `briefings/${fandomId}_${day}`,
|
||||||
|
targetType: 'briefing',
|
||||||
|
reason: 'BRIEFING_PUBLISH',
|
||||||
|
patch: { status: 'published', count: drafts.size, bySection },
|
||||||
|
createdAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 앱은 당일(KST) 게시분만 본다. 어제 날짜를 게시하면 푸시를 받고도 카드가
|
||||||
|
// 없는 상태가 되므로 보내지 않는다(B-47 「전일 콘텐츠 자동 재사용 없음」).
|
||||||
|
const isToday = day === dayKey(new Date());
|
||||||
|
const pushed = isToday && await sendFandomPush({
|
||||||
|
fandomId,
|
||||||
|
category: 'briefing',
|
||||||
|
title: '오늘의 AI 브리핑이 도착했습니다',
|
||||||
|
body: (drafts.docs.find((d) => d.get('rank') === 1)?.get('headline') as string | undefined)
|
||||||
|
?? '홈에서 오늘의 브리핑을 확인해 보세요.',
|
||||||
|
data: { kind: 'briefing', dayKey: day },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
fandomId, dayKey: day, published: drafts.size, bySection,
|
||||||
|
pushed: pushed === true,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { HttpsError, onCall } from 'firebase-functions/v2/https';
|
||||||
|
import { logger } from 'firebase-functions';
|
||||||
|
import { getFirestore, FieldValue, Timestamp } from 'firebase-admin/firestore';
|
||||||
|
|
||||||
|
import { callerUid, num, str } from '../core/callable.js';
|
||||||
|
import { ERR, fail, positiveInt } from '../core/errors.js';
|
||||||
|
import { txIdOf } from '../core/ids.js';
|
||||||
|
import { refundSplit, spendSplit } from '../ledger/ledger.js';
|
||||||
|
import { verifiedQuestCountOf } from '../ledger/fpi.js';
|
||||||
|
import { publicNameOf } from '../core/names.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `fn-campaign-create` — 사용자 캠페인 개설(원장 B-56 자격 · B-57 등록비).
|
||||||
|
*
|
||||||
|
* 예전에는 앱이 `campaigns` 에 `draft` 로 직접 썼는데 개설자는 status 를 바꿀 수 없어
|
||||||
|
* 심사(`review`)로 넘어갈 길이 없었다. 개설 · 등록비 차감 · `review` 진입을
|
||||||
|
* 한 트랜잭션에서 하고, 승인은 그대로 `fn-campaign-admin-review` 가 한다.
|
||||||
|
*
|
||||||
|
* 등록비는 겸용이다(B-57 · B-06) — 일반 POP 을 먼저 쓰고 부족분만 기여 POP 에서 뺀다
|
||||||
|
* (`spendSplit`, 원장 최대 2건). 그 거래 ID 들을 `feeTxIds` 에 남겨 반환(B-58)이 행마다
|
||||||
|
* 원래 타입으로 돌린다. 등록비는 성장 · FPI 원천이 아니다(C-30).
|
||||||
|
*
|
||||||
|
* 🟡 운영값(등록비 · 가입 경과일 · 동시 진행 수 · 검증 활동 횟수)은 `config/campaign` 이 덮는다.
|
||||||
|
* 「검증 활동」은 서버가 인정한 Quest 완료 누적 수다(해석 확인 요청 Campaign 개설자격 A안 · 5회).
|
||||||
|
*/
|
||||||
|
const DEFAULTS = { feePop: 100, minAccountDays: 7, maxActive: 1, minVerifiedQuests: 5 };
|
||||||
|
const MAX_DAYS = 365;
|
||||||
|
|
||||||
|
export const fnCampaignCreate = onCall(async (req) => {
|
||||||
|
const data = req.data as Record<string, unknown> | null;
|
||||||
|
return createCampaign({
|
||||||
|
uid: callerUid(req),
|
||||||
|
clientSeq: str(data, 'clientSeq'),
|
||||||
|
title: str(data, 'title'),
|
||||||
|
body: str(data, 'body'),
|
||||||
|
targetPop: positiveInt(num(data, 'targetPop'), 'targetPop'),
|
||||||
|
days: positiveInt(num(data, 'days'), 'days'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function createCampaign(input: {
|
||||||
|
uid: string; clientSeq: string; title: string; body: string; targetPop: number; days: number;
|
||||||
|
}): Promise<{ campaignId: string; status: 'review' }> {
|
||||||
|
const { uid, clientSeq } = input;
|
||||||
|
const title = input.title.trim();
|
||||||
|
const body = input.body.trim();
|
||||||
|
if (!title || title.length > 100) throw new HttpsError('invalid-argument', '제목은 1~100자입니다.');
|
||||||
|
if (!body || body.length > 5000) throw new HttpsError('invalid-argument', '내용은 1~5000자입니다.');
|
||||||
|
if (input.days > MAX_DAYS) throw new HttpsError('invalid-argument', `진행 기간은 ${MAX_DAYS}일 이하입니다.`);
|
||||||
|
|
||||||
|
const db = getFirestore();
|
||||||
|
const key = `campaign:${uid}:${clientSeq}`;
|
||||||
|
const campaignRef = db.doc(`campaigns/${txIdOf(key)}`);
|
||||||
|
// 같은 요청의 재시도 — 등록비도 두 번 빼지 않는다(spendPop 도 같은 키로 멱등).
|
||||||
|
if ((await campaignRef.get()).exists) return { campaignId: campaignRef.id, status: 'review' };
|
||||||
|
|
||||||
|
const cfg = { ...DEFAULTS, ...((await db.doc('config/campaign').get()).data() ?? {}) };
|
||||||
|
const user = await db.doc(`users/${uid}`).get();
|
||||||
|
const fandomId = user.get('fandomId') as string | undefined;
|
||||||
|
if (!fandomId) fail(ERR.permissionDenied, '팬덤에 가입한 뒤 캠페인을 만들 수 있습니다.');
|
||||||
|
if ((user.get('status') ?? 'active') !== 'active') {
|
||||||
|
fail(ERR.permissionDenied, '이용이 제한된 계정은 캠페인을 만들 수 없습니다.');
|
||||||
|
}
|
||||||
|
const joined = user.get('createdAt') as Timestamp | undefined;
|
||||||
|
if (!joined || Date.now() - joined.toMillis() < cfg.minAccountDays * 86_400_000) {
|
||||||
|
fail(ERR.permissionDenied, `가입 ${cfg.minAccountDays}일이 지나야 캠페인을 만들 수 있습니다.`);
|
||||||
|
}
|
||||||
|
const need = Number(cfg.minVerifiedQuests);
|
||||||
|
const done = need > 0 ? await verifiedQuestCountOf(uid) : 0;
|
||||||
|
if (done < need) {
|
||||||
|
fail(ERR.campaignNotEligible, `퀘스트 완료 ${done}/${need} — 퀘스트를 ${need - done}번 더 완료하면 캠페인을 열 수 있습니다.`, { done, need });
|
||||||
|
}
|
||||||
|
// 단일 필드 조건만 써서 복합 색인 없이 센다. 개설자 한 명의 캠페인 수는 작다.
|
||||||
|
const mine = await db.collection('campaigns').where('creatorUid', '==', uid).get();
|
||||||
|
const active = mine.docs.filter((d) => ['review', 'funding'].includes(d.get('status'))).length;
|
||||||
|
if (active >= cfg.maxActive) {
|
||||||
|
fail(ERR.permissionDenied, `진행 중인 캠페인은 ${cfg.maxActive}건까지입니다.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await spendSplit({
|
||||||
|
uid,
|
||||||
|
amount: cfg.feePop,
|
||||||
|
reasonCode: 'CAMPAIGN_FEE',
|
||||||
|
idempotencyKey: `campaignFee:${uid}:${clientSeq}`,
|
||||||
|
refPath: campaignRef.path,
|
||||||
|
alsoWrite: (tx, parts) => tx.create(campaignRef, {
|
||||||
|
fandomId,
|
||||||
|
creatorUid: uid,
|
||||||
|
creatorName: publicNameOf(user),
|
||||||
|
title,
|
||||||
|
summary: '',
|
||||||
|
body,
|
||||||
|
targetPop: input.targetPop,
|
||||||
|
endsAt: Timestamp.fromMillis(Date.now() + input.days * 86_400_000),
|
||||||
|
status: 'review',
|
||||||
|
raisedPop: 0,
|
||||||
|
achievedRate: 0,
|
||||||
|
donorCount: 0,
|
||||||
|
refundState: 'none',
|
||||||
|
feePop: cfg.feePop,
|
||||||
|
feeTxIds: parts.map((p) => p.txId),
|
||||||
|
createdAt: FieldValue.serverTimestamp(),
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return { campaignId: campaignRef.id, status: 'review' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 등록비 반환 — B-58 「회사귀책 · 반려는 등록비 반환」 · 공개 전 철회(B-57 「공개 확정 시 Burn」).
|
||||||
|
*
|
||||||
|
* 반환이 정해진 캠페인은 상태 전이와 같은 쓰기에서 `feeRefund: 'due'` 를 받는다. 여기서
|
||||||
|
* 행마다 원래 타입으로 돌려준 뒤 `done` 으로 닫는다. 캠페인 하나에 멱등키 하나라 중간에
|
||||||
|
* 실패해도 다시 부르면 두 번 돌려주지 않는다. `due` 가 아니면 이미 남긴 결과만 돌려준다.
|
||||||
|
*/
|
||||||
|
export async function settleFeeRefund(campaignId: string): Promise<string[]> {
|
||||||
|
const ref = getFirestore().doc(`campaigns/${campaignId}`);
|
||||||
|
const snap = await ref.get();
|
||||||
|
if (snap.get('feeRefund') !== 'due') {
|
||||||
|
return (snap.get('feeRefundTxIds') as string[] | undefined) ?? [];
|
||||||
|
}
|
||||||
|
// 분할 차감 전의 옛 캠페인은 `feeTxId` 한 건이다. 둘 다 없으면 면제(Official 등)다.
|
||||||
|
const legacy = snap.get('feeTxId') as string | undefined;
|
||||||
|
const txIds = (snap.get('feeTxIds') as string[] | undefined) ?? (legacy ? [legacy] : []);
|
||||||
|
const refunded = txIds.length === 0 ? [] : (await refundSplit({
|
||||||
|
uid: snap.get('creatorUid') as string,
|
||||||
|
originalTxIds: txIds,
|
||||||
|
idempotencyKey: `campaignFeeRefund:${campaignId}`,
|
||||||
|
reasonCode: 'CAMPAIGN_FEE_REFUND',
|
||||||
|
})).map((r) => r.txId);
|
||||||
|
await ref.update({
|
||||||
|
feeRefund: 'done',
|
||||||
|
feeRefundTxIds: refunded,
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
return refunded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 반환이 정해졌는데 끝나지 않은 캠페인을 다시 돈다 — `fn-campaign-settle` 회차마다.
|
||||||
|
* 상태 전이 뒤 반환 전에 죽은 호출을 복구하는 경로다. 실패한 건은 다음 회차가 다시 집는다.
|
||||||
|
*/
|
||||||
|
export async function retryFeeRefunds(): Promise<number> {
|
||||||
|
const due = await getFirestore().collection('campaigns').where('feeRefund', '==', 'due').get();
|
||||||
|
let done = 0;
|
||||||
|
for (const doc of due.docs) {
|
||||||
|
try {
|
||||||
|
await settleFeeRefund(doc.id);
|
||||||
|
done += 1;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[campaign] 등록비 반환 실패 — 다음 회차 재시도', { campaignId: doc.id, error: String(error) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return done;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `fn-campaign-withdraw` — 개설자 철회(B-58).
|
||||||
|
*
|
||||||
|
* - 공개 전(`draft` · `review`) — 철회하고 등록비를 돌려준다. 등록비는 공개 확정 때
|
||||||
|
* Burn 되므로(B-57) 아직 확정되지 않았다.
|
||||||
|
* - 공개 후 첫 후원 전(`funding` · 후원 0건) — 철회는 되지만 **생성자 변심이라 등록비는
|
||||||
|
* 비환불**이다.
|
||||||
|
* - 첫 후원 뒤 — 개설자 단독으로는 못 한다. 운영 승인 경로다(E-PERM-01).
|
||||||
|
*
|
||||||
|
* 판정과 상태 전이를 한 트랜잭션에 둔다. 후원(`fn-campaign-fund`)도 같은 문서의 `status`
|
||||||
|
* 를 트랜잭션으로 읽으므로 「후원 0건이라 철회」와 「첫 후원」이 함께 서지 않는다.
|
||||||
|
*/
|
||||||
|
export const fnCampaignWithdraw = onCall(async (req) =>
|
||||||
|
withdrawCampaign(callerUid(req), str(req.data, 'campaignId')));
|
||||||
|
|
||||||
|
export async function withdrawCampaign(
|
||||||
|
uid: string, campaignId: string,
|
||||||
|
): Promise<{ status: 'withdrawn'; feeRefundTxIds: string[] }> {
|
||||||
|
const db = getFirestore();
|
||||||
|
const ref = db.doc(`campaigns/${campaignId}`);
|
||||||
|
await db.runTransaction(async (tx) => {
|
||||||
|
const snap = await tx.get(ref);
|
||||||
|
if (!snap.exists) fail(ERR.campaignNotApproved, '캠페인을 찾을 수 없습니다.');
|
||||||
|
if (snap.get('creatorUid') !== uid) fail(ERR.permissionDenied, '개설자만 철회할 수 있습니다.');
|
||||||
|
const status = snap.get('status') as string | undefined;
|
||||||
|
if (status === 'withdrawn') return; // 재시도 — 남은 반환만 아래에서 마저 한다.
|
||||||
|
const beforePublish = status === 'draft' || status === 'review';
|
||||||
|
const noDonation = status === 'funding'
|
||||||
|
&& ((snap.get('donorCount') as number | undefined) ?? 0) === 0
|
||||||
|
&& ((snap.get('raisedPop') as number | undefined) ?? 0) === 0;
|
||||||
|
if (!beforePublish && !noDonation) {
|
||||||
|
fail(ERR.permissionDenied, '첫 후원 뒤에는 운영 승인 없이 철회할 수 없습니다.');
|
||||||
|
}
|
||||||
|
tx.update(ref, {
|
||||||
|
status: 'withdrawn',
|
||||||
|
withdrawnAt: FieldValue.serverTimestamp(),
|
||||||
|
...(beforePublish ? { feeRefund: 'due' } : {}),
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return { status: 'withdrawn', feeRefundTxIds: await settleFeeRefund(campaignId) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,564 @@
|
|||||||
|
import { HttpsError, onCall } from 'firebase-functions/v2/https';
|
||||||
|
import { onSchedule } from 'firebase-functions/v2/scheduler';
|
||||||
|
import {
|
||||||
|
getFirestore, FieldPath, FieldValue, Timestamp, type DocumentSnapshot,
|
||||||
|
} from 'firebase-admin/firestore';
|
||||||
|
import { getStorage } from 'firebase-admin/storage';
|
||||||
|
import { logger } from 'firebase-functions';
|
||||||
|
|
||||||
|
import { callerUid, requireRole, str } from '../core/callable.js';
|
||||||
|
import { CatalogError, ERR, fail } from '../core/errors.js';
|
||||||
|
import { refundPop } from '../ledger/ledger.js';
|
||||||
|
import { retryFeeRefunds, settleFeeRefund } from './create.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캠페인 정산과 환불 — 별첨 1 ⑤.
|
||||||
|
*
|
||||||
|
* 판정 기준이 「성공/실패 분기 정확 · 자동환불 무결성(**부분 실패 방치·중복 환불
|
||||||
|
* 0건**) · 결과보고 게시 확인」이다. 부분 실패를 방치하지 않는 것이 핵심이라
|
||||||
|
* 실패한 건을 세어 남기고 Admin 큐로 넘긴다.
|
||||||
|
*
|
||||||
|
* 환불 기한은 정책 §P-09 가 **마감 후 72시간**으로 확정했다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const REFUND_DEADLINE_HOURS = 72;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동 환불 패스 상한. 실패가 남은 패스가 이만큼 쌓이면 Admin Exception Queue 로 넘기고
|
||||||
|
* 자동 재시도를 멈춘다(원장 B-36 「부분 실패는 자동 재시도 후 Admin Exception Queue」).
|
||||||
|
*/
|
||||||
|
export const MAX_AUTO_REFUND_ATTEMPTS = 3;
|
||||||
|
|
||||||
|
/** 한 회차에 처리하는 후원 수. 넘으면 커서를 남기고 다음 회차가 이어 간다. */
|
||||||
|
export const REFUND_PAGE = 200;
|
||||||
|
|
||||||
|
/** 함수 제한 시간과 리스. 리스가 제한 시간보다 길어야 죽은 회차의 잠금이 풀린 뒤에만 겹친다. */
|
||||||
|
const TIMEOUT_SECONDS = 540;
|
||||||
|
const LEASE_MS = (TIMEOUT_SECONDS + 60) * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 후원을 돌려주는 캠페인 상태. `fail` 이 규칙·앱의 표기이고 `failed` 는 예전 정산이 쓴
|
||||||
|
* 값이다. `cancelled` 는 회사귀책 취소(B-58 · `fn-campaign-admin-cancel`)다.
|
||||||
|
*/
|
||||||
|
const FAILED = ['fail', 'failed', 'cancelled'];
|
||||||
|
|
||||||
|
/** 규칙·설계서의 스키마 이름은 `targetPop` 이다. `goalPop` 은 옛 이름이라 보조로만 읽는다. */
|
||||||
|
const goalOf = (snap: DocumentSnapshot): number =>
|
||||||
|
(snap.get('targetPop') as number | undefined)
|
||||||
|
?? (snap.get('goalPop') as number | undefined) ?? 0;
|
||||||
|
const raisedOf = (snap: DocumentSnapshot): number =>
|
||||||
|
(snap.get('raisedPop') as number | undefined) ?? 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `fn-campaign-settle` — 마감을 지난 캠페인을 성공·실패로 가르고, 실패면 **같은 회차에서**
|
||||||
|
* 환불을 시작한다(B-36 「즉시 환불 시작」). 부분 실패·미완 캠페인도 여기서 이어 돈다.
|
||||||
|
*
|
||||||
|
* 스케줄을 10분으로 둔 이유는 마감 직후의 대기 시간을 줄이기 위해서다.
|
||||||
|
* 그보다 촘촘히 돌려도 마감 시각이 분 단위라 얻는 것이 없다.
|
||||||
|
*/
|
||||||
|
export const fnCampaignSettle = onSchedule(
|
||||||
|
{ schedule: 'every 10 minutes', timeoutSeconds: TIMEOUT_SECONDS },
|
||||||
|
async () => {
|
||||||
|
const settled = await settleDueCampaigns();
|
||||||
|
const retried = await retryPendingRefunds();
|
||||||
|
const fees = await retryFeeRefunds();
|
||||||
|
logger.info('[campaign] 정산 완료', { settled, retried, fees });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마감이 지난 `funding` 캠페인 판정. 판정한 건수를 돌려준다.
|
||||||
|
*
|
||||||
|
* **캠페인마다 트랜잭션으로 다시 읽고 판정한다.** 쿼리 결과로 판정하면 그 사이 들어온
|
||||||
|
* 후원이 빠진 모금액으로 성공·실패를 가른다. 후원(`fn-campaign-fund`)도 같은 문서를
|
||||||
|
* 트랜잭션으로 읽고 마감 뒤 후원을 거부하므로 판정 뒤에 모금액이 움직이지 않는다.
|
||||||
|
*/
|
||||||
|
export async function settleDueCampaigns(now: Timestamp = Timestamp.now()): Promise<number> {
|
||||||
|
const db = getFirestore();
|
||||||
|
const due = await db
|
||||||
|
.collection('campaigns')
|
||||||
|
.where('status', '==', 'funding')
|
||||||
|
.where('endsAt', '<=', now)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
let settled = 0;
|
||||||
|
for (const doc of due.docs) {
|
||||||
|
const success = await db.runTransaction(async (tx) => {
|
||||||
|
const snap = await tx.get(doc.ref);
|
||||||
|
const endsAt = snap.get('endsAt') as Timestamp | undefined;
|
||||||
|
if (snap.get('status') !== 'funding' || !endsAt || endsAt.toMillis() > now.toMillis()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const goal = goalOf(snap);
|
||||||
|
const ok = goal > 0 && raisedOf(snap) >= goal;
|
||||||
|
tx.update(doc.ref, {
|
||||||
|
// 규칙 `campaignIsPublic()` · 앱과 같은 표기.
|
||||||
|
status: ok ? 'success' : 'fail',
|
||||||
|
settledAt: FieldValue.serverTimestamp(),
|
||||||
|
// 실패면 환불 기한을 함께 박아 둔다. 나중에 계산하면 기준이 흔들린다.
|
||||||
|
...(ok
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
refundDueAt: Timestamp.fromMillis(
|
||||||
|
now.toMillis() + REFUND_DEADLINE_HOURS * 60 * 60 * 1000,
|
||||||
|
),
|
||||||
|
refundState: 'pending',
|
||||||
|
refundAttempts: 0,
|
||||||
|
refundPassFailed: 0,
|
||||||
|
}),
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
return ok;
|
||||||
|
});
|
||||||
|
if (success === null) continue;
|
||||||
|
settled += 1;
|
||||||
|
logger.info('[campaign] 정산', { id: doc.id, success });
|
||||||
|
}
|
||||||
|
return settled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 환불이 끝나지 않은 실패 캠페인을 돌린다 — 방금 실패로 판정된 건도 여기서 첫 환불을 받는다.
|
||||||
|
* 자동 시도 상한을 넘긴 건은 Admin 큐에 있으므로 건드리지 않는다. 실제로 돈 건수를 돌려준다.
|
||||||
|
*/
|
||||||
|
export async function retryPendingRefunds(): Promise<number> {
|
||||||
|
// 단일 필드 조건이라 복합 색인이 필요 없다. 상태는 아래에서 거른다.
|
||||||
|
const open = await getFirestore()
|
||||||
|
.collection('campaigns')
|
||||||
|
.where('refundState', 'in', ['pending', 'partial'])
|
||||||
|
.get();
|
||||||
|
let ran = 0;
|
||||||
|
for (const doc of open.docs) {
|
||||||
|
if (!FAILED.includes(doc.get('status') as string)) continue;
|
||||||
|
if (((doc.get('refundAttempts') as number | undefined) ?? 0) >= MAX_AUTO_REFUND_ATTEMPTS) continue;
|
||||||
|
const result = await refundCampaign(doc.id, { manual: false });
|
||||||
|
if (!result.skipped) ran += 1;
|
||||||
|
}
|
||||||
|
return ran;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RefundFailure = { donationId: string; error: string };
|
||||||
|
|
||||||
|
export interface RefundResult {
|
||||||
|
/** 이 회차에 새로 돌려준 건수. */
|
||||||
|
refunded: number;
|
||||||
|
failed: RefundFailure[];
|
||||||
|
/** 한 바퀴를 다 돌았는가. `false` 면 커서가 남아 다음 회차가 이어 간다. */
|
||||||
|
done: boolean;
|
||||||
|
/** 돌지 않은 이유 — 다른 회차가 잡고 있거나, 옛 정산이 실패로 잘못 적은 캠페인. */
|
||||||
|
skipped?: 'locked' | 'legacy-goal-met';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 실패 캠페인 환불 한 회차. 자동(스케줄)과 수동(Admin) 이 같은 코어를 지난다.
|
||||||
|
*
|
||||||
|
* - **리스** — `lockedUntil` 을 트랜잭션으로 잡은 회차만 돈다. 겹친 회차는 건너뛴다.
|
||||||
|
* - **분할** — 한 회차에 [REFUND_PAGE] 건. 커서(`refundCursor`)와 이번 바퀴의 실패 수
|
||||||
|
* (`refundPassFailed`)를 매 회차 남긴다.
|
||||||
|
* - **중복 환불 0건** — 후원 한 건의 멱등 키가 `refund:{donationId}` 하나다. 다른 경로
|
||||||
|
* (`fn-pop-refund`)가 먼저 돌려준 원거래는 그 환불을 받아들여 끝난 건으로 본다.
|
||||||
|
* - **부분 실패를 성공으로 덮지 않는다.** 실패가 남은 바퀴는 `partial` 로 남기고 자동 시도를
|
||||||
|
* 센다(`refundAttempts`). 상한에 닿거나 Admin 이 돌려도 남으면 Exception Queue
|
||||||
|
* (`adminActionLogs`)에 적는다(E-CMP-02).
|
||||||
|
* - **옛 데이터** — `refundAttempts` 가 없는 문서는 goalPop 만 읽던 예전 정산이 판정했다.
|
||||||
|
* `targetPop` 으로 다시 판정해 목표를 채운 캠페인은 돌려주지 않는다.
|
||||||
|
*/
|
||||||
|
export async function refundCampaign(
|
||||||
|
campaignId: string,
|
||||||
|
opts: { manual: boolean },
|
||||||
|
): Promise<RefundResult> {
|
||||||
|
const db = getFirestore();
|
||||||
|
const campaignRef = db.doc(`campaigns/${campaignId}`);
|
||||||
|
|
||||||
|
const lease = await db.runTransaction(async (tx) => {
|
||||||
|
const snap = await tx.get(campaignRef);
|
||||||
|
if (!snap.exists) fail(ERR.campaignNotApproved, '캠페인을 찾을 수 없습니다.');
|
||||||
|
if (!FAILED.includes(snap.get('status') as string)) {
|
||||||
|
fail(ERR.campaignNotApproved, '실패하거나 취소된 캠페인만 환불할 수 있습니다.');
|
||||||
|
}
|
||||||
|
const locked = snap.get('lockedUntil') as Timestamp | undefined;
|
||||||
|
if (locked && locked.toMillis() > Date.now()) return { skipped: 'locked' as const };
|
||||||
|
const legacy = typeof snap.get('refundAttempts') !== 'number';
|
||||||
|
if (legacy) {
|
||||||
|
const goal = goalOf(snap);
|
||||||
|
if (goal > 0 && raisedOf(snap) >= goal) return { skipped: 'legacy-goal-met' as const };
|
||||||
|
}
|
||||||
|
tx.update(campaignRef, {
|
||||||
|
lockedUntil: Timestamp.fromMillis(Date.now() + LEASE_MS),
|
||||||
|
// 다시 판정해도 실패인 옛 문서는 지금 표기로 맞춘다.
|
||||||
|
...(legacy ? { status: 'fail', refundAttempts: 0, refundPassFailed: 0 } : {}),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
title: (snap.get('title') as string | undefined) ?? '캠페인',
|
||||||
|
cancelled: snap.get('status') === 'cancelled',
|
||||||
|
cursor: (snap.get('refundCursor') as string | undefined) ?? null,
|
||||||
|
attempts: legacy ? 0 : (snap.get('refundAttempts') as number),
|
||||||
|
passFailed: legacy ? 0 : ((snap.get('refundPassFailed') as number | undefined) ?? 0),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if ('skipped' in lease) {
|
||||||
|
logger.warn('[campaign] 환불 건너뜀', { campaignId, reason: lease.skipped });
|
||||||
|
return { refunded: 0, failed: [], done: false, skipped: lease.skipped };
|
||||||
|
}
|
||||||
|
|
||||||
|
let query = campaignRef.collection('donations')
|
||||||
|
.orderBy(FieldPath.documentId())
|
||||||
|
.limit(REFUND_PAGE);
|
||||||
|
if (lease.cursor) query = query.startAfter(lease.cursor);
|
||||||
|
const page = await query.get();
|
||||||
|
|
||||||
|
const failed: RefundFailure[] = [];
|
||||||
|
let refunded = 0;
|
||||||
|
for (const donation of page.docs) {
|
||||||
|
const uid = donation.get('uid') as string | undefined;
|
||||||
|
let refundTxId = donation.get('refundTxId') as string | undefined;
|
||||||
|
if (!refundTxId) {
|
||||||
|
const txId = donation.get('txId') as string | undefined;
|
||||||
|
if (!uid || !txId) {
|
||||||
|
failed.push({ donationId: donation.id, error: 'missing-uid-or-tx' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
refundTxId = (await refundPop({
|
||||||
|
uid,
|
||||||
|
originalTxId: txId,
|
||||||
|
// 후원 한 건당 한 번. 재실행해도 두 번 돌려주지 않는다.
|
||||||
|
idempotencyKey: `refund:${donation.id}`,
|
||||||
|
reasonCode: 'CAMPAIGN_REFUND',
|
||||||
|
})).txId;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof CatalogError && error.catalogCode === ERR.popDuplicate) {
|
||||||
|
// 다른 경로가 이미 돌려준 원거래 — 그 환불을 이 후원의 환불로 받아들인다.
|
||||||
|
refundTxId = (await db.doc(`users/${uid}/popTransactions/${txId}`).get())
|
||||||
|
.get('refundedBy') as string;
|
||||||
|
} else {
|
||||||
|
failed.push({ donationId: donation.id, error: String(error) });
|
||||||
|
await donation.ref.set({ refundError: String(error) }, { merge: true });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await donation.ref.set({
|
||||||
|
refundTxId,
|
||||||
|
refundedAt: FieldValue.serverTimestamp(),
|
||||||
|
refundError: FieldValue.delete(),
|
||||||
|
}, { merge: true });
|
||||||
|
refunded += 1;
|
||||||
|
}
|
||||||
|
// 환불과 알림 사이에 죽어도 다음 회차가 알림만 다시 보낸다(문서 ID = 환불 거래).
|
||||||
|
if (uid && !donation.get('refundNotifiedAt')) {
|
||||||
|
await notifyOnce(uid, `campaignRefund_${refundTxId}`, {
|
||||||
|
title: lease.cancelled
|
||||||
|
? `「${lease.title}」 캠페인이 취소되어 후원 POP 을 돌려드렸습니다`
|
||||||
|
: `「${lease.title}」 목표 미달로 후원 POP 을 돌려드렸습니다`,
|
||||||
|
campaignId,
|
||||||
|
});
|
||||||
|
await donation.ref.set({ refundNotifiedAt: FieldValue.serverTimestamp() }, { merge: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const passFailed = lease.passFailed + failed.length;
|
||||||
|
if (page.size === REFUND_PAGE) {
|
||||||
|
// 아직 남았다 — 진행 상황만 남기고 잠금을 푼다. 다음 회차가 커서부터 잇는다.
|
||||||
|
await campaignRef.update({
|
||||||
|
refundCursor: page.docs[page.docs.length - 1]!.id,
|
||||||
|
refundPassFailed: passFailed,
|
||||||
|
refundLastRunAt: FieldValue.serverTimestamp(),
|
||||||
|
lockedUntil: FieldValue.delete(),
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
return { refunded, failed, done: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const counted = passFailed > 0 && !opts.manual ? 1 : 0;
|
||||||
|
const toQueue = passFailed > 0
|
||||||
|
&& (opts.manual || lease.attempts + counted >= MAX_AUTO_REFUND_ATTEMPTS);
|
||||||
|
const refundedCount = (await campaignRef.collection('donations')
|
||||||
|
.where('refundTxId', '!=', null).count().get()).data().count;
|
||||||
|
await campaignRef.update({
|
||||||
|
refundState: passFailed === 0 ? 'done' : 'partial',
|
||||||
|
refundedCount,
|
||||||
|
refundFailedCount: passFailed,
|
||||||
|
refundAttempts: FieldValue.increment(counted),
|
||||||
|
refundCursor: FieldValue.delete(),
|
||||||
|
refundPassFailed: 0,
|
||||||
|
refundLastRunAt: FieldValue.serverTimestamp(),
|
||||||
|
lockedUntil: FieldValue.delete(),
|
||||||
|
...(passFailed === 0 ? { refundedAt: FieldValue.serverTimestamp() } : {}),
|
||||||
|
...(toQueue ? { refundQueuedAt: FieldValue.serverTimestamp() } : {}),
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (toQueue) {
|
||||||
|
await db.collection('adminActionLogs').add({
|
||||||
|
actorUid: 'system',
|
||||||
|
targetPath: campaignRef.path,
|
||||||
|
reason: '캠페인 환불 부분 실패 — 수동 복구 필요',
|
||||||
|
// 건별 사유는 후원 문서의 `refundError` 에 남아 있다.
|
||||||
|
patch: { failedCount: passFailed, attempts: lease.attempts + counted, lastRun: failed },
|
||||||
|
createdAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
logger.error('[campaign] 환불 부분 실패 — Admin 큐', { campaignId, passFailed });
|
||||||
|
} else if (passFailed > 0) {
|
||||||
|
logger.warn('[campaign] 환불 부분 실패 — 자동 재시도 예정', { campaignId, passFailed });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { refunded, failed, done: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 알림함 한 건. 문서 ID 를 고정해 재실행이 같은 알림을 두 번 쌓지 않는다.
|
||||||
|
* `type` 은 B-77 수신 설정 축, `eventType` 은 알림함 이동 축이다(앱 `NotificationKind`).
|
||||||
|
*/
|
||||||
|
async function notifyOnce(
|
||||||
|
uid: string,
|
||||||
|
id: string,
|
||||||
|
data: { title: string; campaignId: string },
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await getFirestore().doc(`users/${uid}/notifications/${id}`).create({
|
||||||
|
type: 'essential',
|
||||||
|
eventType: 'campaign',
|
||||||
|
...data,
|
||||||
|
read: false,
|
||||||
|
createdAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// 이미 보낸 알림(ALREADY_EXISTS = 6)은 정상이다. 그 밖의 실패도 환불을 되돌리지 않는다.
|
||||||
|
if ((error as { code?: number }).code !== 6) {
|
||||||
|
logger.error('[campaign] 알림 기록 실패', { uid, id, message: (error as Error).message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `fn-campaign-refund-batch` — Admin 수동 복구. 환불은 정산이 자동으로 시작하므로
|
||||||
|
* 이 호출은 Exception Queue 에 오른 건을 다시 돌리는 경로다. `done: false` 면 다시 부른다.
|
||||||
|
*/
|
||||||
|
export const fnCampaignRefundBatch = onCall({ timeoutSeconds: TIMEOUT_SECONDS }, async (req) => {
|
||||||
|
callerUid(req);
|
||||||
|
requireRole(req, ['master', 'ops']);
|
||||||
|
return refundCampaign(str(req.data, 'campaignId'), { manual: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 결과보고 사진·영수증 상한 — 원장 B-44 「이미지 5장 · 10MB」. 용량·형식은 Storage 규칙이 본다. */
|
||||||
|
export const REPORT_MAX_PHOTOS = 5;
|
||||||
|
const REPORT_MAX_CHARS = 5000;
|
||||||
|
const BUDGET_MAX_LINES = 50;
|
||||||
|
const FILE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
||||||
|
const CAMPAIGN_ID = /^[A-Za-z0-9_-]{1,128}$/;
|
||||||
|
|
||||||
|
export interface BudgetLine { label: string; amount: number }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `fn-campaign-report-submit` — 성공 캠페인 결과보고(원장 B-35 · FR-ADM-05 · FR-CMP-06).
|
||||||
|
*
|
||||||
|
* **운영(master·ops)만 올린다.** B-35 는 funded 이후 집행·증빙·결과보고를 Admin 수동 운영으로
|
||||||
|
* 두었다. 집행 사진 · 영수증 · 예산 사용 내역을 함께 받아 보고와 상태 `report` 를 한
|
||||||
|
* 트랜잭션에 쓰고, 후원자 전원의 알림함에 안내를 남긴다.
|
||||||
|
*
|
||||||
|
* 파일은 URL 이 아니라 **Storage 경로**로 받는다. 경로가 정확히 `campaignReports/{id}/{파일}`
|
||||||
|
* 이고(상위 이동 · 하위 폴더 없음) 실제로 올라가 있어야 한다.
|
||||||
|
*/
|
||||||
|
export const fnCampaignReportSubmit = onCall({ timeoutSeconds: 120 }, async (req) => {
|
||||||
|
const uid = callerUid(req);
|
||||||
|
requireRole(req, ['master', 'ops']);
|
||||||
|
const data = req.data as Record<string, unknown> | null;
|
||||||
|
return submitCampaignReport({
|
||||||
|
uid,
|
||||||
|
campaignId: str(data, 'campaignId'),
|
||||||
|
body: str(data, 'body'),
|
||||||
|
photoPaths: data?.photoPaths,
|
||||||
|
receiptPaths: data?.receiptPaths,
|
||||||
|
budget: data?.budget,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const storageExists = async (path: string): Promise<boolean> =>
|
||||||
|
(await getStorage().bucket().file(path).exists())[0];
|
||||||
|
|
||||||
|
function reportPaths(raw: unknown, campaignId: string, what: string): string[] {
|
||||||
|
const prefix = `campaignReports/${campaignId}/`;
|
||||||
|
const ok = Array.isArray(raw) && raw.length >= 1 && raw.length <= REPORT_MAX_PHOTOS
|
||||||
|
&& new Set(raw).size === raw.length
|
||||||
|
&& raw.every((p) => typeof p === 'string' && p.startsWith(prefix)
|
||||||
|
&& FILE_NAME.test(p.slice(prefix.length)));
|
||||||
|
if (!ok) throw new HttpsError('invalid-argument', `${what}을 1~${REPORT_MAX_PHOTOS}장 올려 주세요.`);
|
||||||
|
return raw as string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function budgetLines(raw: unknown): BudgetLine[] {
|
||||||
|
const ok = Array.isArray(raw) && raw.length >= 1 && raw.length <= BUDGET_MAX_LINES
|
||||||
|
&& raw.every((l) => typeof l?.label === 'string'
|
||||||
|
&& l.label.trim().length >= 1 && l.label.trim().length <= 100
|
||||||
|
&& Number.isSafeInteger(l.amount) && l.amount > 0);
|
||||||
|
if (!ok) {
|
||||||
|
throw new HttpsError('invalid-argument', `예산 사용 내역을 1~${BUDGET_MAX_LINES}줄(항목 · 금액) 적어 주세요.`);
|
||||||
|
}
|
||||||
|
return (raw as BudgetLine[]).map((l) => ({ label: l.label.trim(), amount: l.amount }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitCampaignReport(
|
||||||
|
input: {
|
||||||
|
uid: string; campaignId: string; body: string;
|
||||||
|
photoPaths: unknown; receiptPaths: unknown; budget: unknown;
|
||||||
|
},
|
||||||
|
exists: (path: string) => Promise<boolean> = storageExists,
|
||||||
|
): Promise<{ duplicate: boolean }> {
|
||||||
|
const { uid, campaignId } = input;
|
||||||
|
if (!CAMPAIGN_ID.test(campaignId)) throw new HttpsError('invalid-argument', 'campaignId 가 올바르지 않습니다.');
|
||||||
|
const body = input.body.trim();
|
||||||
|
if (body.length === 0 || body.length > REPORT_MAX_CHARS) {
|
||||||
|
throw new HttpsError('invalid-argument', `본문은 1~${REPORT_MAX_CHARS}자입니다.`);
|
||||||
|
}
|
||||||
|
const photoPaths = reportPaths(input.photoPaths, campaignId, '집행 사진');
|
||||||
|
const receiptPaths = reportPaths(input.receiptPaths, campaignId, '영수증');
|
||||||
|
const budget = budgetLines(input.budget);
|
||||||
|
for (const path of [...photoPaths, ...receiptPaths]) {
|
||||||
|
if (!(await exists(path))) throw new HttpsError('invalid-argument', '올라가지 않은 파일이 있습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getFirestore();
|
||||||
|
const campaignRef = db.doc(`campaigns/${campaignId}`);
|
||||||
|
const reportRef = campaignRef.collection('reports').doc('main');
|
||||||
|
const duplicate = await db.runTransaction(async (tx) => {
|
||||||
|
const campaign = await tx.get(campaignRef);
|
||||||
|
if (!campaign.exists) fail(ERR.campaignNotApproved, '캠페인을 찾을 수 없습니다.');
|
||||||
|
if (campaign.get('status') === 'report') return true;
|
||||||
|
if (campaign.get('status') !== 'success') {
|
||||||
|
fail(ERR.campaignNotApproved, '성공한 캠페인만 결과보고를 올릴 수 있습니다.');
|
||||||
|
}
|
||||||
|
tx.create(reportRef, {
|
||||||
|
authorUid: uid,
|
||||||
|
body,
|
||||||
|
photoPaths,
|
||||||
|
receiptPaths,
|
||||||
|
budget,
|
||||||
|
budgetTotal: budget.reduce((sum, l) => sum + l.amount, 0),
|
||||||
|
createdAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
tx.update(campaignRef, {
|
||||||
|
status: 'report',
|
||||||
|
reportedAt: FieldValue.serverTimestamp(),
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 게시와 알림 사이에 죽었으면 재호출이 알림만 다시 보낸다(문서 ID = 보고 ID).
|
||||||
|
const report = await reportRef.get();
|
||||||
|
if (!report.get('notifiedAt')) {
|
||||||
|
const title = ((await campaignRef.get()).get('title') as string | undefined) ?? '캠페인';
|
||||||
|
const donations = await campaignRef.collection('donations').get();
|
||||||
|
const donors = new Set(donations.docs
|
||||||
|
.map((d) => d.get('uid') as string | undefined)
|
||||||
|
.filter((u): u is string => !!u));
|
||||||
|
for (const donor of donors) {
|
||||||
|
await notifyOnce(donor, `campaignReport_${campaignId}_${reportRef.id}`, {
|
||||||
|
title: `후원하신 「${title}」 결과보고가 올라왔습니다`,
|
||||||
|
campaignId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await reportRef.update({ notifiedAt: FieldValue.serverTimestamp() });
|
||||||
|
}
|
||||||
|
return { duplicate };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `fn-campaign-admin-review` — 승인·반려.
|
||||||
|
*
|
||||||
|
* **승인 전에는 모금이 열리지 않는다**(`fn-campaign-fund` 가 E-CMP-03 로 막는다).
|
||||||
|
* 여기서 `funding` 으로 올려야 후원이 시작된다. 반려는 등록비를 원래 타입으로 돌려준다
|
||||||
|
* (B-58). 반환이 중간에 실패하면 같은 요청의 재시도가 마저 한다.
|
||||||
|
*/
|
||||||
|
export const fnCampaignAdminReview = onCall(async (req) => {
|
||||||
|
const actorUid = callerUid(req);
|
||||||
|
requireRole(req, ['master', 'ops']);
|
||||||
|
const campaignId = str(req.data, 'campaignId');
|
||||||
|
const requestId = str(req.data, 'requestId');
|
||||||
|
const approve = (req.data as Record<string, unknown>)?.approve === true;
|
||||||
|
const memo = str(req.data, 'memo');
|
||||||
|
|
||||||
|
const db = getFirestore();
|
||||||
|
const seen = db.doc(`campaigns/${campaignId}/reviews/${requestId}`);
|
||||||
|
const campaignRef = db.doc(`campaigns/${campaignId}`);
|
||||||
|
// 앞선 반려의 반환이 중간에 끊겼으면 여기서 마저 한다 — 상태 판정보다 먼저.
|
||||||
|
await settleFeeRefund(campaignId);
|
||||||
|
|
||||||
|
// 판정과 전이를 한 트랜잭션에 둔다. 개설자 철회(`fn-campaign-withdraw`)도 같은 문서를
|
||||||
|
// 트랜잭션으로 읽으므로 승인과 철회가 함께 서지 않는다.
|
||||||
|
const result = await db.runTransaction(async (tx) => {
|
||||||
|
const [review, campaign] = await Promise.all([tx.get(seen), tx.get(campaignRef)]);
|
||||||
|
// 같은 요청이 두 번 들어와도 판정을 뒤집지 않는다.
|
||||||
|
if (review.exists) return { duplicate: true };
|
||||||
|
if (!campaign.exists) fail(ERR.campaignNotApproved, '캠페인을 찾을 수 없습니다.');
|
||||||
|
if (campaign.get('status') !== 'review') {
|
||||||
|
fail(ERR.campaignNotApproved, '검토 대기 상태가 아닙니다.');
|
||||||
|
}
|
||||||
|
tx.update(campaignRef, {
|
||||||
|
status: approve ? 'funding' : 'rejected',
|
||||||
|
// 반려는 등록비 반환(B-58). 전이와 같은 쓰기에 표시해 두면 반환이 끊겨도 스케줄이 잇는다.
|
||||||
|
...(approve ? {} : { feeRefund: 'due' }),
|
||||||
|
reviewedBy: actorUid,
|
||||||
|
reviewMemo: memo,
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
tx.create(seen, { actorUid, approve, memo, createdAt: FieldValue.serverTimestamp() });
|
||||||
|
return { duplicate: false, status: approve ? 'funding' : 'rejected' };
|
||||||
|
});
|
||||||
|
if (!approve) await settleFeeRefund(campaignId);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `fn-campaign-admin-cancel` — 회사귀책 취소(B-58).
|
||||||
|
*
|
||||||
|
* 심사 중 · 모금 중 캠페인을 `cancelled` 로 닫고 **등록비와 후원 전액**을 돌려준다.
|
||||||
|
* 후원 환불은 실패 캠페인과 같은 코어(`refundCampaign` — 건당 멱등 · 분할 · 부분 실패 큐)를
|
||||||
|
* 지나며, 한 회차에 끝나지 않으면 `fn-campaign-settle` 이 이어 돈다. 개설자에게 알린다.
|
||||||
|
* 이미 취소된 캠페인에 다시 부르면 남은 반환만 이어 간다.
|
||||||
|
*/
|
||||||
|
export const fnCampaignAdminCancel = onCall({ timeoutSeconds: TIMEOUT_SECONDS }, async (req) => {
|
||||||
|
const actorUid = callerUid(req);
|
||||||
|
requireRole(req, ['master', 'ops']);
|
||||||
|
return cancelCampaign(str(req.data, 'campaignId'), actorUid, str(req.data, 'reason'));
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function cancelCampaign(
|
||||||
|
campaignId: string, actorUid: string, reason: string,
|
||||||
|
): Promise<{ status: 'cancelled'; refund: RefundResult }> {
|
||||||
|
const db = getFirestore();
|
||||||
|
const campaignRef = db.doc(`campaigns/${campaignId}`);
|
||||||
|
const creator = await db.runTransaction(async (tx) => {
|
||||||
|
const snap = await tx.get(campaignRef);
|
||||||
|
if (!snap.exists) fail(ERR.campaignNotApproved, '캠페인을 찾을 수 없습니다.');
|
||||||
|
const status = snap.get('status') as string | undefined;
|
||||||
|
if (status !== 'cancelled') {
|
||||||
|
if (status !== 'review' && status !== 'funding') {
|
||||||
|
fail(ERR.campaignNotApproved, '심사 중이거나 모금 중인 캠페인만 취소할 수 있습니다.');
|
||||||
|
}
|
||||||
|
tx.update(campaignRef, {
|
||||||
|
status: 'cancelled',
|
||||||
|
cancelReason: reason,
|
||||||
|
cancelledBy: actorUid,
|
||||||
|
cancelledAt: FieldValue.serverTimestamp(),
|
||||||
|
feeRefund: 'due',
|
||||||
|
refundState: 'pending',
|
||||||
|
refundAttempts: 0,
|
||||||
|
refundPassFailed: 0,
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
uid: snap.get('creatorUid') as string | undefined,
|
||||||
|
title: (snap.get('title') as string | undefined) ?? '캠페인',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await settleFeeRefund(campaignId);
|
||||||
|
if (creator.uid) {
|
||||||
|
await notifyOnce(creator.uid, `campaignCancel_${campaignId}`, {
|
||||||
|
title: `「${creator.title}」 캠페인이 운영 사유로 취소되어 등록비를 돌려드렸습니다`,
|
||||||
|
campaignId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { status: 'cancelled', refund: await refundCampaign(campaignId, { manual: false }) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { getFirestore } from 'firebase-admin/firestore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 운영값 읽기 — 설계서 §10 「미확정 수치는 하드코딩하지 않고 `config` 컬렉션으로 뺐습니다」.
|
||||||
|
*
|
||||||
|
* 정책 설계서에서 아직 확정되지 않은 값은 전부 이 경로를 거친다. 코드에 박으면
|
||||||
|
* 발주사 회신이 올 때마다 재배포해야 하고, 그 사이 값이 문서와 어긋난다.
|
||||||
|
*/
|
||||||
|
export interface PolicyConfig {
|
||||||
|
/** 출석 1회 보상 활동 POP. */
|
||||||
|
attendPop: number;
|
||||||
|
/**
|
||||||
|
* 사용자 생성 Quest 의 광고 추가 보상 — 원장 **C-02** 고정형 **기여 POP** / 회.
|
||||||
|
* 기본 보상액에 비례하지 않고 회사 재원이다(B-28).
|
||||||
|
*/
|
||||||
|
adRewardPop: number;
|
||||||
|
/**
|
||||||
|
* 운영(관리자) Quest 의 광고 배수 — 원장 **B-03** 기본 x2. 지정 Quest 는
|
||||||
|
* `quests/{id}.adMultiplier`(예: 3)가 덮는다. 지급은 **증분만**(기본 보상 × (배수 − 1))
|
||||||
|
* 기여 POP 이다.
|
||||||
|
*/
|
||||||
|
adMultiplier: number;
|
||||||
|
/**
|
||||||
|
* POP 묶음 상품(`pop.support.*`) 판매 스위치 — 원장 **B-02** 「POP 자체 직접 판매 없음」
|
||||||
|
* 이라 기본 false. 켜지 않으면 `fn-iap-verify` 가 E-PAY-01 로 거부한다.
|
||||||
|
*/
|
||||||
|
popPackSalesEnabled: boolean;
|
||||||
|
/** Focus 구독 일일 Claim 기여 POP — 원장 **B-04** 확정 40. */
|
||||||
|
focusDailyPop: number;
|
||||||
|
/** 광고 보상 일일 상한 횟수 — E-AD-02 판정 기준. */
|
||||||
|
adDailyLimit: number;
|
||||||
|
/** 만보기 완주 걸음 수. `stepTiers` 가 없는 문서를 위한 하위 호환값이다. */
|
||||||
|
stepsGoal: number;
|
||||||
|
/** 만보기 완주 보상 활동 POP. 같은 이유로 남긴다. */
|
||||||
|
stepsPop: number;
|
||||||
|
/**
|
||||||
|
* 걸음 구간표 — 정책 원장 **B-26** 확정값.
|
||||||
|
*
|
||||||
|
* *1 천/5 천/1 만/1.5 만보 = 일반 POP 5/6/7/8, 전 단계 순차클리어.* 구간을 건너뛰고
|
||||||
|
* 윗 구간만 받는 일이 없도록 **아랫 구간부터 차례로** 지급한다. 🟡 보상액은 운영값이라
|
||||||
|
* `config/policy` 가 덮는다.
|
||||||
|
*/
|
||||||
|
stepTiers: StepTier[];
|
||||||
|
/** Zap 1회 기여 POP — Figma 「Zap 버튼」이 어드민 설정값이라 명시한 값. */
|
||||||
|
zapPop: number;
|
||||||
|
/** FPI 집계 창 — 설계서 §4.2 최근 7일 Rolling Window. */
|
||||||
|
fpiWindowDays: number;
|
||||||
|
/**
|
||||||
|
* FPI 지표별 가중치.
|
||||||
|
*
|
||||||
|
* **초기값은 전부 1.0 이다** — 정책 설계서 §P-03 「초기 MVP는 단순 합산」(PRD-03).
|
||||||
|
* 가중 평균이 아니므로 합이 1일 필요가 없다. 시즌 고도화 때 Admin 에서 조정한다.
|
||||||
|
*/
|
||||||
|
fpiWeights: Record<FpiAxis, number>;
|
||||||
|
/** 검증 통과 번역 1건 보상 일반 POP — 정책 원장 B-63 확정값 5. 🟡 금액은 운영값. */
|
||||||
|
translationPop: number;
|
||||||
|
/** 번역 보상 일일 상한(건). B-63 「일일상한은 운영설정」 — 초과분은 등록되지만 보상 0. */
|
||||||
|
translationDailyLimit: number;
|
||||||
|
/** 확정에 필요한 최소 유효 마킹 수(작성자 제외). 🟡 발주사 회신 전 제안값 1. */
|
||||||
|
translationMinValidMarks: number;
|
||||||
|
/** 이 시간이 지나도 확정 조건을 못 채우면 보상을 해제하고 닫는다. 🟡 제안값 72. */
|
||||||
|
translationMaxPendingHours: number;
|
||||||
|
/** INSIDE 번역 마킹을 팬덤 밖 회원에게도 여는가. 🟡 Q-TR-04 권고 B안 true · A안이면 false. */
|
||||||
|
translationInsideAnyMember: boolean;
|
||||||
|
/** 사용자 생성 Quest 최대 기간(일) — 공개일을 1일째로 센다(C-06). 🟡 운영값. */
|
||||||
|
ugcMaxDays: number;
|
||||||
|
/** 종료일 선택 시 공개 시각부터 확보해야 하는 최소 시간(C-06). 🟡 운영값. */
|
||||||
|
ugcMinHours: number;
|
||||||
|
/** 수수료율 · 최소 수수료(B-30 — Pool 의 10%, 최소 10 POP). */
|
||||||
|
ugcFeeRate: number;
|
||||||
|
ugcFeeMin: number;
|
||||||
|
/**
|
||||||
|
* 보상 없는 Quest(Pool 0)에도 최소 수수료를 받는가 — 해석 확인 요청 **Q-UQ-02**.
|
||||||
|
* 🟡 회신 전 권고안(A안 · 0 POP)으로 false. B안(문언대로 최소 10)은 true.
|
||||||
|
*/
|
||||||
|
ugcFeeOnNoReward: boolean;
|
||||||
|
/** 보상 없는 Quest 의 1인 하루 생성 상한 — 수수료 0 의 도배 방지. 🟡 운영값 3. */
|
||||||
|
ugcFreeQuestDailyCap: number;
|
||||||
|
/** 생성 자격 — 가입 경과 시간(B-29 · 24h). 검증 활동 1회는 원장에서 본다. */
|
||||||
|
ugcMinAccountHours: number;
|
||||||
|
/**
|
||||||
|
* 생성 자격 — 「검증 활동」(서버가 인정한 Quest 완료 · 보상 무관 · 누적) 최소 횟수(B-29 · 1회).
|
||||||
|
* 정의는 해석 확인 요청 Campaign 개설자격 A안. Campaign 쪽 5회는 `config/campaign.minVerifiedQuests`.
|
||||||
|
*/
|
||||||
|
ugcMinVerifiedQuests: number;
|
||||||
|
/** 사람 검토 목표 시간(C-08 · 주말 포함 24h). 지연 안내 기준이며 자동 승인은 없다. */
|
||||||
|
ugcReviewHours: number;
|
||||||
|
/** 긴급 돌발 타임어택 1회 보상 상한(활동 POP). 🟡 기준자료에 수치가 없어 보수적 출발점 1,000. */
|
||||||
|
timeAttackMaxPop: number;
|
||||||
|
/**
|
||||||
|
* 타임어택 참여 1건의 FPI 축 — 해석 확인 요청 **Q-TA-05**. 🟡 회신 전 권고안(A안) `community`
|
||||||
|
* (출석과 같은 축 · C-35). null 이면 B안 — FPI 에 넣지 않고 User EXP 만 준다.
|
||||||
|
*/
|
||||||
|
timeAttackFpiAxis: FpiAxis | null;
|
||||||
|
/**
|
||||||
|
* HUD 「LIVE」 = 최근 이 분(分) 안에 앱을 쓴 팬덤 멤버 수 — 해석 확인 요청 **Q-GM-03** A안. 🟡 운영값 5.
|
||||||
|
*/
|
||||||
|
liveWindowMinutes: number;
|
||||||
|
/** 추천 보상 — 원장 **B-69** 확정 추천인 일반 POP 100 · 피추천인 20. 피추천인 최초 검증 활동 뒤 지급. */
|
||||||
|
referralReferrerPop: number;
|
||||||
|
referralReferredPop: number;
|
||||||
|
/** 추천인 1명이 하루에 보상 대상으로 받을 수 있는 귀속 수(B-71 어뷰징). 넘으면 `capped`. 🟡 운영값 5. */
|
||||||
|
referralDailyCapPerReferrer: number;
|
||||||
|
/** 가입 후 추천 코드를 등록할 수 있는 기간(일). 🟡 운영값 7. */
|
||||||
|
referralAttachDays: number;
|
||||||
|
/** 긴급 돌발 타임어택 지속 시간 상한(분) — 원장 「최대 24시간」. 🟡 운영값. */
|
||||||
|
timeAttackMaxMinutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FPI 산출 지표 — 정책 설계서 §P-03 확정값(답변서 v1.1 Q3).
|
||||||
|
*
|
||||||
|
* 2주차 설계서가 적었던 `action·content·contribution·community·growth` 를
|
||||||
|
* **대체한다.** 두 이름이 공존하면 집계 축과 화면 표기가 갈린다.
|
||||||
|
*/
|
||||||
|
/** 걸음 구간 하나 — 도달 걸음 수와 그 구간의 보상. */
|
||||||
|
export interface StepTier {
|
||||||
|
steps: number;
|
||||||
|
pop: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FpiAxis = 'bpm' | 'health' | 'referral' | 'community' | 'contribution';
|
||||||
|
|
||||||
|
export const FPI_AXES: readonly FpiAxis[] = [
|
||||||
|
'bpm', 'health', 'referral', 'community', 'contribution',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기본값. **정책 확정값이 아니라 config 문서가 없을 때의 안전한 출발점**이다.
|
||||||
|
* 운영에서는 `/config/policy` 문서가 항상 이 값을 덮는다.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_POLICY: PolicyConfig = {
|
||||||
|
attendPop: 10,
|
||||||
|
adRewardPop: 10,
|
||||||
|
adMultiplier: 2,
|
||||||
|
popPackSalesEnabled: false,
|
||||||
|
focusDailyPop: 40,
|
||||||
|
adDailyLimit: 5,
|
||||||
|
stepsGoal: 10_000,
|
||||||
|
stepsPop: 50,
|
||||||
|
stepTiers: [
|
||||||
|
{ steps: 1_000, pop: 5 },
|
||||||
|
{ steps: 5_000, pop: 6 },
|
||||||
|
{ steps: 10_000, pop: 7 },
|
||||||
|
{ steps: 15_000, pop: 8 },
|
||||||
|
],
|
||||||
|
zapPop: 10,
|
||||||
|
fpiWindowDays: 7,
|
||||||
|
// 단순 합산이 초기 정책이므로 전부 1.0 이다.
|
||||||
|
fpiWeights: {
|
||||||
|
bpm: 1, health: 1, referral: 1, community: 1, contribution: 1,
|
||||||
|
},
|
||||||
|
translationPop: 5,
|
||||||
|
// 🟡 운영값. 원장이 수치를 정하지 않아 보수적으로 둔 출발점이다.
|
||||||
|
translationDailyLimit: 10,
|
||||||
|
translationMinValidMarks: 1,
|
||||||
|
translationMaxPendingHours: 72,
|
||||||
|
translationInsideAnyMember: true,
|
||||||
|
ugcMaxDays: 30,
|
||||||
|
ugcMinHours: 1,
|
||||||
|
ugcFeeRate: 0.1,
|
||||||
|
ugcFeeMin: 10,
|
||||||
|
ugcFeeOnNoReward: false,
|
||||||
|
ugcFreeQuestDailyCap: 3,
|
||||||
|
ugcMinAccountHours: 24,
|
||||||
|
ugcMinVerifiedQuests: 1,
|
||||||
|
ugcReviewHours: 24,
|
||||||
|
timeAttackMaxPop: 1000,
|
||||||
|
timeAttackFpiAxis: 'community',
|
||||||
|
liveWindowMinutes: 5,
|
||||||
|
referralReferrerPop: 100,
|
||||||
|
referralReferredPop: 20,
|
||||||
|
referralDailyCapPerReferrer: 5,
|
||||||
|
referralAttachDays: 7,
|
||||||
|
timeAttackMaxMinutes: 1440,
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function loadPolicy(): Promise<PolicyConfig> {
|
||||||
|
const snap = await getFirestore().doc('config/policy').get();
|
||||||
|
if (!snap.exists) return DEFAULT_POLICY;
|
||||||
|
const raw = snap.data() ?? {};
|
||||||
|
const weights = { ...DEFAULT_POLICY.fpiWeights, ...(raw.fpiWeights ?? {}) };
|
||||||
|
return {
|
||||||
|
...DEFAULT_POLICY,
|
||||||
|
...raw,
|
||||||
|
// 가중치는 부분 지정이 흔하므로 축별로 병합한다. 통째로 덮으면 축이 사라진다.
|
||||||
|
fpiWeights: weights,
|
||||||
|
// 구간표는 통째로 덮는다. 섞으면 운영에서 지운 구간이 되살아난다.
|
||||||
|
stepTiers: normalizeTiers(raw.stepTiers) ?? DEFAULT_POLICY.stepTiers,
|
||||||
|
} as PolicyConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 운영 문서의 구간표를 쓸 수 있는 형태로 만든다.
|
||||||
|
*
|
||||||
|
* **오름차순을 보장한다.** 순서가 뒤섞인 표를 그대로 쓰면 「아랫 구간부터 순차
|
||||||
|
* 클리어」가 깨져 윗 구간이 먼저 지급된다(B-26 검증 기준).
|
||||||
|
*/
|
||||||
|
export function normalizeTiers(raw: unknown): StepTier[] | null {
|
||||||
|
if (!Array.isArray(raw) || raw.length === 0) return null;
|
||||||
|
const tiers: StepTier[] = [];
|
||||||
|
for (const row of raw) {
|
||||||
|
const steps = (row as StepTier)?.steps;
|
||||||
|
const pop = (row as StepTier)?.pop;
|
||||||
|
if (typeof steps !== 'number' || typeof pop !== 'number') return null;
|
||||||
|
if (!Number.isFinite(steps) || steps <= 0 || pop < 0) return null;
|
||||||
|
tiers.push({ steps, pop });
|
||||||
|
}
|
||||||
|
return tiers.sort((a, b) => a.steps - b.steps);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 가중치가 쓸 수 있는 값인지 확인한다.
|
||||||
|
*
|
||||||
|
* 합이 1인지는 보지 않는다 — 단순 합산 정책에서는 합이 5다. 대신 **축이 빠졌거나
|
||||||
|
* 음수인지**를 본다. 음수 가중치는 활동할수록 점수가 깎이는 뒤집힌 랭킹을 만든다.
|
||||||
|
*/
|
||||||
|
export function weightsAreUsable(weights: Record<FpiAxis, number>): boolean {
|
||||||
|
return FPI_AXES.every((axis) => {
|
||||||
|
const w = weights[axis];
|
||||||
|
return typeof w === 'number' && Number.isFinite(w) && w >= 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -7,8 +7,42 @@ import { HttpsError } from 'firebase-functions/v2/https';
|
|||||||
* 카탈로그에 먼저 등재한 뒤 여기에 더한다.
|
* 카탈로그에 먼저 등재한 뒤 여기에 더한다.
|
||||||
*/
|
*/
|
||||||
export const ERR = {
|
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',
|
permissionDenied: 'E-PERM-01',
|
||||||
authFailed: 'E-AUTH-03',
|
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;
|
} as const;
|
||||||
|
|
||||||
export type ErrorCode = (typeof ERR)[keyof typeof ERR];
|
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';
|
| 'permission-denied' | 'not-found' | 'resource-exhausted' | 'internal';
|
||||||
|
|
||||||
const STATUS: Record<ErrorCode, Status> = {
|
const STATUS: Record<ErrorCode, Status> = {
|
||||||
|
'E-POP-01': 'failed-precondition',
|
||||||
|
'E-POP-02': 'internal',
|
||||||
|
'E-POP-03': 'already-exists',
|
||||||
|
'E-QST-01': 'failed-precondition',
|
||||||
|
'E-QST-02': 'permission-denied',
|
||||||
|
'E-QST-03': 'failed-precondition',
|
||||||
|
'E-QST-04': 'resource-exhausted',
|
||||||
|
'E-AD-01': 'internal',
|
||||||
|
'E-AD-02': 'resource-exhausted',
|
||||||
|
'E-ECL-01': 'failed-precondition',
|
||||||
|
'E-CMP-01': 'failed-precondition',
|
||||||
|
'E-CMP-02': 'internal',
|
||||||
|
'E-CMP-03': 'failed-precondition',
|
||||||
|
'E-CMP-04': 'failed-precondition',
|
||||||
|
'E-QNA-01': 'failed-precondition',
|
||||||
|
'E-QNA-02': 'failed-precondition',
|
||||||
|
'E-QNA-03': 'failed-precondition',
|
||||||
|
'E-PAY-01': 'invalid-argument',
|
||||||
|
'E-PAY-02': 'failed-precondition',
|
||||||
|
'E-PAY-03': 'failed-precondition',
|
||||||
|
'E-FDM-01': 'already-exists',
|
||||||
|
'E-FDM-02': 'resource-exhausted',
|
||||||
|
'E-FDM-03': 'failed-precondition',
|
||||||
|
'E-FDM-04': 'failed-precondition',
|
||||||
|
'E-FDM-05': 'failed-precondition',
|
||||||
'E-PERM-01': 'permission-denied',
|
'E-PERM-01': 'permission-denied',
|
||||||
'E-AUTH-03': '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` 가 계약면이다. */
|
/** 카탈로그 ID 를 그대로 실은 오류. `details.code` 가 계약면이다. */
|
||||||
export class CatalogError extends HttpsError {
|
export class CatalogError extends HttpsError {
|
||||||
constructor(readonly catalogCode: ErrorCode, message: string) {
|
constructor(readonly catalogCode: ErrorCode, message: string, extra: Record<string, unknown> = {}) {
|
||||||
super(STATUS[catalogCode], message, { code: catalogCode });
|
super(STATUS[catalogCode], message, { ...extra, code: catalogCode });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,8 +100,8 @@ export class CatalogError extends HttpsError {
|
|||||||
* => {...}` 형태로는 호출 이후를 도달 불가로 좁히지 못한다. 그러면 호출 뒤의
|
* => {...}` 형태로는 호출 이후를 도달 불가로 좁히지 못한다. 그러면 호출 뒤의
|
||||||
* 코드가 「값이 없을 수도 있다」로 남아 불필요한 널 검사가 번진다.
|
* 코드가 「값이 없을 수도 있다」로 남아 불필요한 널 검사가 번진다.
|
||||||
*/
|
*/
|
||||||
export function fail(code: ErrorCode, message: string): never {
|
export function fail(code: ErrorCode, message: string, extra?: Record<string, unknown>): never {
|
||||||
throw new CatalogError(code, message);
|
throw new CatalogError(code, message, extra);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* 일일 한도의 리셋 단위 — 설계서 `questProgress.dayKey` 「`YYYY-MM-DD`(사용자 시간대)」.
|
||||||
|
*
|
||||||
|
* 서버 UTC 로 끊으면 한국 사용자는 오전 9시에 하루가 바뀐다. 그래서 분 단위
|
||||||
|
* offset 을 받아 사용자 시간대의 날짜를 만든다.
|
||||||
|
*/
|
||||||
|
export function dayKey(at: Date, utcOffsetMinutes = 540): string {
|
||||||
|
const shifted = new Date(at.getTime() + utcOffsetMinutes * 60_000);
|
||||||
|
return shifted.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 시간 조작 탐지 — 단말 시각이 서버 시각에서 너무 벗어나면 증거로 쓰지 않는다. */
|
||||||
|
export function isPlausibleOccurredAt(
|
||||||
|
occurredAt: Date,
|
||||||
|
now: Date,
|
||||||
|
toleranceMinutes = 15,
|
||||||
|
): boolean {
|
||||||
|
const drift = Math.abs(now.getTime() - occurredAt.getTime());
|
||||||
|
return drift <= toleranceMinutes * 60_000;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Timestamp, type DocumentReference } from 'firebase-admin/firestore';
|
||||||
|
import { dayKey } from '../core/time.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 2026-09-16 EC02·EC05: 누락된 집계를 0으로 채우지 않는다.
|
||||||
|
* 원장 C-26 `target = max(1, ceil(avgDau × rate))` · C-27 생성 후 완료 집계일 ≤ [windowDays] 일 평균 ·
|
||||||
|
* C-21 집계 기간은 운영값(기본 7일).
|
||||||
|
*/
|
||||||
|
export async function eclipseTarget(
|
||||||
|
fandom: DocumentReference,
|
||||||
|
createdAt: unknown,
|
||||||
|
day: string,
|
||||||
|
rate = 0.05,
|
||||||
|
windowDays = 7,
|
||||||
|
): Promise<
|
||||||
|
{ target: number; averageDau: number | null; dauDays: string[] }
|
||||||
|
| { result: 'cancelled_error'; cancelReason: string }
|
||||||
|
> {
|
||||||
|
const invalid = (cancelReason: string) => ({ result: 'cancelled_error' as const, cancelReason });
|
||||||
|
if (!Number.isFinite(rate) || rate <= 0 || rate > 1) return invalid('INVALID_TARGET_RATE');
|
||||||
|
if (!Number.isSafeInteger(windowDays) || windowDays < 1) return invalid('INVALID_DAU_WINDOW');
|
||||||
|
if (!(createdAt instanceof Timestamp)) return invalid('MISSING_FANDOM_CREATED_AT');
|
||||||
|
// 기존 scheduler/dayKey와 같은 날짜 키로 완료된 일별 집계를 조회한다.
|
||||||
|
const createdDay = dayKey(createdAt.toDate());
|
||||||
|
if (createdDay > day) return invalid('FUTURE_FANDOM_CREATED_AT');
|
||||||
|
if (createdDay === day) return { target: 1, averageDau: null, dauDays: [] };
|
||||||
|
const days: string[] = [];
|
||||||
|
for (let i = 1; i <= windowDays; i++) {
|
||||||
|
const previous = new Date(`${day}T00:00:00Z`);
|
||||||
|
previous.setUTCDate(previous.getUTCDate() - i);
|
||||||
|
const key = previous.toISOString().slice(0, 10);
|
||||||
|
if (key < createdDay) break;
|
||||||
|
days.push(key);
|
||||||
|
}
|
||||||
|
const docs = await fandom.firestore.getAll(
|
||||||
|
...days.map((key) => fandom.collection('fandomDailyStats').doc(key)),
|
||||||
|
);
|
||||||
|
if (docs.some((doc) => !doc.exists || !Number.isSafeInteger(doc.get('dau')) || doc.get('dau') < 0)) {
|
||||||
|
return invalid('MISSING_OR_INVALID_DAU');
|
||||||
|
}
|
||||||
|
const sum = docs.reduce((total, doc) => total + (doc.get('dau') as number), 0);
|
||||||
|
if (!Number.isSafeInteger(sum)) return invalid('INVALID_DAU_TOTAL');
|
||||||
|
const averageDau = sum / days.length;
|
||||||
|
return { target: Math.max(1, Math.ceil(averageDau * rate)), averageDau, dauDays: days };
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { onDocumentUpdated } from 'firebase-functions/v2/firestore';
|
||||||
|
|
||||||
|
/** v1.5 §2.1/§7: 확정 Stage 상승을 보존한다. 개인 참여/보상을 추정하지 않는다. */
|
||||||
|
export const fnPlanetEvolutionRecord = onDocumentUpdated(
|
||||||
|
{ document: 'fandoms/{fandomId}', retry: true },
|
||||||
|
async (event) => {
|
||||||
|
const change = event.data;
|
||||||
|
if (!change) return;
|
||||||
|
const from = change.before.get('stage');
|
||||||
|
const to = change.after.get('stage');
|
||||||
|
if (!Number.isInteger(from) || !Number.isInteger(to) ||
|
||||||
|
from < 0 || to > 6 || to <= from) return;
|
||||||
|
|
||||||
|
// 이벤트 ID는 재전달에도 같다. 처리 시각 대신 실제 문서 확정 시각을 보존한다.
|
||||||
|
const history = change.after.ref.collection('stateHistory')
|
||||||
|
.doc(`evolution-${encodeURIComponent(event.id)}`);
|
||||||
|
try {
|
||||||
|
await history.create({
|
||||||
|
kind: 'evolution',
|
||||||
|
occurredAt: change.after.updateTime,
|
||||||
|
fromStage: from,
|
||||||
|
toStage: to,
|
||||||
|
reasonCode: 'STAGE_INCREASED',
|
||||||
|
source: 'fnPlanetEvolutionRecord',
|
||||||
|
sourceEventId: event.id,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const code = (error as { code?: number | string }).code;
|
||||||
|
if (code !== 6 && code !== 'already-exists') throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,162 @@
|
|||||||
|
import { onCall } from 'firebase-functions/v2/https';
|
||||||
|
import { getFirestore, FieldValue, Timestamp } from 'firebase-admin/firestore';
|
||||||
|
|
||||||
|
import { callerUid, str } from '../core/callable.js';
|
||||||
|
import { txIdOf } from '../core/ids.js';
|
||||||
|
import { ERR, fail } from '../core/errors.js';
|
||||||
|
import { grantPop, spendPop } from '../ledger/ledger.js';
|
||||||
|
import { publicNameOf } from '../core/names.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 영웅의 선물 — 한 사람이 쏘고 팬덤이 나눠 받는다.
|
||||||
|
*
|
||||||
|
* 총량은 서버 운영값, 시간·1인 상한은 B-41의 기본 10분·Pool 5%를 적용한다.
|
||||||
|
* 이벤트 생성 때 운영 비율을 정수 POP 상한으로 고정한다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface DropConfig {
|
||||||
|
totalPop: number;
|
||||||
|
perUserCap: number;
|
||||||
|
perUserCapPercent: number;
|
||||||
|
durationSec: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 총량 하나에 운영값(1인 상한 % · 시간)을 붙여 Drop 규격을 만든다.
|
||||||
|
*
|
||||||
|
* [totalPop] 을 받으면 운영 기본 총량 대신 그 값을 쓴다 — Hero Gift 구매는 상품별
|
||||||
|
* Admin 확정값이 총량이다(원장 B-84 · B-85).
|
||||||
|
*/
|
||||||
|
export async function dropConfig(totalPop?: number): Promise<DropConfig> {
|
||||||
|
const snap = await getFirestore().doc('config/policy').get();
|
||||||
|
const raw = (snap.get('herodrop') ?? {}) as Partial<DropConfig>;
|
||||||
|
const total = totalPop ?? raw.totalPop;
|
||||||
|
const percent = raw.perUserCapPercent ?? 5;
|
||||||
|
const duration = raw.durationSec ?? 600;
|
||||||
|
if (
|
||||||
|
typeof total !== 'number' || !Number.isSafeInteger(total) || total <= 0 ||
|
||||||
|
typeof percent !== 'number' || !Number.isFinite(percent) || percent <= 0 || percent > 100 ||
|
||||||
|
!Number.isSafeInteger(duration) || duration <= 0
|
||||||
|
) {
|
||||||
|
fail(
|
||||||
|
ERR.popVerifyFailed,
|
||||||
|
'영웅의 선물 운영값이 아직 설정되지 않았습니다.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const cap = Math.floor(total * percent / 100);
|
||||||
|
if (cap < 1) fail(ERR.popVerifyFailed, '수령 가능한 정수 POP 상한이 없습니다.');
|
||||||
|
return { totalPop: total, perUserCap: cap, perUserCapPercent: percent, durationSec: duration };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop 문서 본문. 발동 경로(POP 차감 · IAP 구매)가 같은 모양을 쓴다. */
|
||||||
|
export function dropDocument(
|
||||||
|
config: DropConfig,
|
||||||
|
fields: { triggeredBy: string; heroNickname: string; productId: string },
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
triggeredBy: fields.triggeredBy,
|
||||||
|
...(fields.heroNickname ? { heroNickname: fields.heroNickname } : {}),
|
||||||
|
productId: fields.productId,
|
||||||
|
totalPop: config.totalPop,
|
||||||
|
perUserCap: config.perUserCap,
|
||||||
|
perUserCapPercent: config.perUserCapPercent,
|
||||||
|
remainingPop: config.totalPop,
|
||||||
|
claimedCount: 0,
|
||||||
|
endsAt: Timestamp.fromMillis(Date.now() + config.durationSec * 1000),
|
||||||
|
createdAt: FieldValue.serverTimestamp(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 공개 닉네임만 보존한다. 실명/displayName/email을 대체값으로 쓰지 않는다 — 없으면 「팬」. */
|
||||||
|
export function publicNickname(user: FirebaseFirestore.DocumentSnapshot): string {
|
||||||
|
return publicNameOf(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `fn-herodrop-trigger` — 발동.
|
||||||
|
*
|
||||||
|
* 결제로 사는 아이템이라 영수증 검증을 지난 뒤에 불린다. 여기서는 **지원 POP
|
||||||
|
* 차감**만 하고, 영수증 자체는 `fn-iap-verify` 가 본다.
|
||||||
|
*/
|
||||||
|
export const fnHeroDropTrigger = onCall(async (req) => {
|
||||||
|
const uid = callerUid(req);
|
||||||
|
const fandomId = str(req.data, 'fandomId');
|
||||||
|
const productId = str(req.data, 'productId');
|
||||||
|
const clientSeq = str(req.data, 'clientSeq');
|
||||||
|
const config = await dropConfig();
|
||||||
|
const db = getFirestore();
|
||||||
|
|
||||||
|
const idempotencyKey = `herodrop:trigger:${uid}:${clientSeq}`;
|
||||||
|
const dropId = txIdOf(idempotencyKey);
|
||||||
|
const heroNickname = publicNickname(await db.doc(`users/${uid}`).get());
|
||||||
|
const result = await spendPop({
|
||||||
|
uid,
|
||||||
|
popType: 'support',
|
||||||
|
amount: config.totalPop,
|
||||||
|
reasonCode: 'HERO_DROP_TRIGGER',
|
||||||
|
idempotencyKey,
|
||||||
|
fandomId,
|
||||||
|
alsoWrite: (tx) => tx.create(
|
||||||
|
db.doc(`fandoms/${fandomId}/heroDrops/${dropId}`),
|
||||||
|
dropDocument(config, { triggeredBy: uid, heroNickname, productId }),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { dropId, txId: result.txId, balanceAfter: result.balanceAfter };
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `fn-herodrop-claim` — 수령.
|
||||||
|
*
|
||||||
|
* **중복 수령은 문서 ID 로 막는다.** 수령 기록의 ID 가 `uid` 라 조건문 없이
|
||||||
|
* 구조로 차단된다(설계서 §2.1 「중복을 막아야 하는 곳은 문서 ID 를 자연키로」).
|
||||||
|
* 남은 총량은 트랜잭션에서 줄인다 — 조회 후 차감으로 나누면 동시 수령에서 초과한다.
|
||||||
|
*/
|
||||||
|
export const fnHeroDropClaim = onCall(async (req) => {
|
||||||
|
const uid = callerUid(req);
|
||||||
|
const fandomId = str(req.data, 'fandomId');
|
||||||
|
const dropId = str(req.data, 'dropId');
|
||||||
|
const db = getFirestore();
|
||||||
|
const dropRef = db.doc(`fandoms/${fandomId}/heroDrops/${dropId}`);
|
||||||
|
const claimRef = dropRef.collection('claims').doc(uid);
|
||||||
|
|
||||||
|
return db.runTransaction(async (tx) => {
|
||||||
|
const existing = await tx.get(claimRef);
|
||||||
|
const drop = await tx.get(dropRef);
|
||||||
|
if (!drop.exists) fail(ERR.popVerifyFailed, '선물을 찾을 수 없습니다.');
|
||||||
|
const endsAt = drop.get('endsAt');
|
||||||
|
const remaining = drop.get('remainingPop');
|
||||||
|
const cap = drop.get('perUserCap');
|
||||||
|
// 이미 확정한 수령은 종료 후에도 같은 원장 결과를 반환한다.
|
||||||
|
if (!existing.exists) {
|
||||||
|
if (!(endsAt instanceof Timestamp) || endsAt.toMillis() <= Date.now()) {
|
||||||
|
fail(ERR.popVerifyFailed, '수령 시간이 끝났습니다.');
|
||||||
|
}
|
||||||
|
if (!Number.isSafeInteger(remaining) || remaining <= 0 ||
|
||||||
|
!Number.isSafeInteger(cap) || cap <= 0) {
|
||||||
|
fail(ERR.popVerifyFailed, '수령 가능한 선물이 없습니다.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const share = existing.exists ? existing.get('amount') : Math.min(cap, remaining);
|
||||||
|
if (!Number.isSafeInteger(share) || share <= 0) {
|
||||||
|
fail(ERR.popVerifyFailed, '수령 기록을 확인할 수 없습니다.');
|
||||||
|
}
|
||||||
|
const result = await grantPop({
|
||||||
|
uid,
|
||||||
|
popType: 'activity',
|
||||||
|
amount: share,
|
||||||
|
reasonCode: 'HERO_DROP',
|
||||||
|
idempotencyKey: `herodrop:${uid}:${dropId}`,
|
||||||
|
refPath: dropRef.path,
|
||||||
|
alsoWrite: (t) => {
|
||||||
|
if (existing.exists) return;
|
||||||
|
t.create(claimRef, { uid, amount: share, createdAt: FieldValue.serverTimestamp() });
|
||||||
|
t.update(dropRef, {
|
||||||
|
remainingPop: FieldValue.increment(-share),
|
||||||
|
claimedCount: FieldValue.increment(1),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}, tx);
|
||||||
|
return { claimedPop: share, txId: result.txId, balanceAfter: result.balanceAfter };
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { Timestamp } from 'firebase-admin/firestore';
|
||||||
|
|
||||||
|
interface HibernationSettings {
|
||||||
|
enterFpi: number;
|
||||||
|
enterSeconds: number;
|
||||||
|
wakeFpi: number;
|
||||||
|
wakeSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** v1.6.1: FPI < 10, 30일. 복귀 수치는 원본에 없어 운영 설정을 필수로 받는다. */
|
||||||
|
export function hibernationSettings(data: FirebaseFirestore.DocumentData): HibernationSettings | null {
|
||||||
|
if (data.enabled !== true) return null;
|
||||||
|
const settings = {
|
||||||
|
enterFpi: data.enterFpi ?? 10,
|
||||||
|
enterSeconds: data.enterSeconds ?? 30 * 24 * 60 * 60,
|
||||||
|
wakeFpi: data.wakeFpi,
|
||||||
|
wakeSeconds: data.wakeSeconds,
|
||||||
|
};
|
||||||
|
if (!Object.values(settings).every((value) => typeof value === 'number' && Number.isFinite(value)) ||
|
||||||
|
settings.enterFpi <= 0 || settings.wakeFpi <= settings.enterFpi ||
|
||||||
|
!Number.isSafeInteger(settings.enterSeconds) || settings.enterSeconds <= 0 ||
|
||||||
|
!Number.isSafeInteger(settings.wakeSeconds) || settings.wakeSeconds < 0) {
|
||||||
|
throw new Error('Invalid hibernation settings: explicit wake threshold and duration required');
|
||||||
|
}
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FPI 집계와 같은 트랜잭션에 기록한다. 재실행·과거 실행이 유지시간을 되돌리지 않는다. */
|
||||||
|
export function hibernationPatch(
|
||||||
|
current: FirebaseFirestore.DocumentData,
|
||||||
|
fpi: number,
|
||||||
|
settings: HibernationSettings,
|
||||||
|
now: Timestamp,
|
||||||
|
activity?: { id: string; at: Timestamp },
|
||||||
|
): FirebaseFirestore.DocumentData {
|
||||||
|
if (!Number.isFinite(fpi)) return {};
|
||||||
|
const evaluated = current.hibernationEvaluatedAt;
|
||||||
|
if (evaluated instanceof Timestamp && evaluated.toMillis() >= now.toMillis()) return {};
|
||||||
|
const policy = JSON.stringify(settings);
|
||||||
|
const unchangedPolicy = current.hibernationPolicy === policy;
|
||||||
|
let asleep = current.isHibernating === true;
|
||||||
|
const wasAsleep = asleep;
|
||||||
|
let lowSince: Timestamp | null = null;
|
||||||
|
let wakeSince: Timestamp | null = null;
|
||||||
|
const since = (value: unknown) => unchangedPolicy && value instanceof Timestamp &&
|
||||||
|
value.toMillis() <= now.toMillis() ? value : now;
|
||||||
|
if (!asleep && fpi < settings.enterFpi) {
|
||||||
|
lowSince = since(current.hibernationLowSince);
|
||||||
|
asleep = now.toMillis() - lowSince.toMillis() >= settings.enterSeconds * 1000;
|
||||||
|
} else if (asleep && fpi >= settings.wakeFpi &&
|
||||||
|
activity && current.hibernationChangedAt instanceof Timestamp &&
|
||||||
|
activity.at.toMillis() > current.hibernationChangedAt.toMillis() &&
|
||||||
|
activity.at.toMillis() <= now.toMillis()) {
|
||||||
|
wakeSince = since(current.hibernationWakeSince);
|
||||||
|
asleep = now.toMillis() - wakeSince.toMillis() < settings.wakeSeconds * 1000;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
isHibernating: asleep,
|
||||||
|
hibernationLowSince: lowSince,
|
||||||
|
hibernationWakeSince: wakeSince,
|
||||||
|
hibernationEvaluatedAt: now,
|
||||||
|
hibernationPolicy: policy,
|
||||||
|
...(wasAsleep !== asleep ? { hibernationChangedAt: now } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { HttpsError, onCall } from 'firebase-functions/v2/https';
|
||||||
|
import { getFirestore, Timestamp } from 'firebase-admin/firestore';
|
||||||
|
import { callerUid, str } from '../core/callable.js';
|
||||||
|
|
||||||
|
/** PASSPORT에서 쓸 팬덤 역사와 호출자 본인의 참여 근거만 반환한다. */
|
||||||
|
export const fnPlanetHistory = onCall(async (request) => {
|
||||||
|
const uid = callerUid(request);
|
||||||
|
const fandomId = str(request.data, 'fandomId');
|
||||||
|
if (fandomId.includes('/') || fandomId.length > 512) {
|
||||||
|
throw new HttpsError('invalid-argument', '팬덤을 확인해 주세요.');
|
||||||
|
}
|
||||||
|
const scope = request.data?.scope;
|
||||||
|
if (scope != null && scope !== 'fandom' && scope !== 'personal') {
|
||||||
|
throw new HttpsError('invalid-argument', '조회 범위를 확인해 주세요.');
|
||||||
|
}
|
||||||
|
const personal = scope === 'personal';
|
||||||
|
const db = getFirestore();
|
||||||
|
const history = db.collection(personal ? `users/${uid}/growthEvents` : `fandoms/${fandomId}/stateHistory`);
|
||||||
|
const dateField = personal ? 'createdAt' : 'occurredAt';
|
||||||
|
let query = (personal ? history.where('fandomIdAtPayout', '==', fandomId) : history)
|
||||||
|
.orderBy(dateField, 'desc').limit(21);
|
||||||
|
if (request.data?.cursor != null) {
|
||||||
|
const cursor = str(request.data, 'cursor');
|
||||||
|
if (cursor.includes('/') || cursor.length > 1500) {
|
||||||
|
throw new HttpsError('invalid-argument', '조회 위치를 확인해 주세요.');
|
||||||
|
}
|
||||||
|
const previous = await history.doc(cursor).get();
|
||||||
|
if (!previous.exists || !(previous.get(dateField) instanceof Timestamp) ||
|
||||||
|
(personal && previous.get('fandomIdAtPayout') !== fandomId)) {
|
||||||
|
throw new HttpsError('invalid-argument', '조회 위치를 확인해 주세요.');
|
||||||
|
}
|
||||||
|
query = query.startAfter(previous);
|
||||||
|
}
|
||||||
|
const [page, contribution] = await Promise.all([
|
||||||
|
query.get(),
|
||||||
|
// 성장 이벤트는 실제 지급/기여 원장과 같은 트랜잭션에서 생성된다.
|
||||||
|
personal ? Promise.resolve(null) : db.collection(`users/${uid}/growthEvents`)
|
||||||
|
.where('fandomIdAtPayout', '==', fandomId).orderBy('createdAt').limit(1).get(),
|
||||||
|
]);
|
||||||
|
const first = contribution?.docs[0];
|
||||||
|
const firstAt = first?.get('createdAt');
|
||||||
|
const hasProof = firstAt instanceof Timestamp && Number.isSafeInteger(first?.get('amount')) &&
|
||||||
|
first?.get('amount') > 0 &&
|
||||||
|
first?.get('reversalOf') == null &&
|
||||||
|
['quest_reward', 'company_ad_bonus', 'zap', 'funding'].includes(first?.get('sourceType'));
|
||||||
|
const rows = page.docs.slice(0, 20);
|
||||||
|
const items = rows.flatMap((doc) => {
|
||||||
|
const kind = doc.get(personal ? 'sourceType' : 'kind');
|
||||||
|
const occurredAt = doc.get(dateField);
|
||||||
|
const allowed = personal ? ['quest_reward', 'company_ad_bonus', 'zap', 'funding']
|
||||||
|
: ['evolution', 'hibernation', 'wake_up'];
|
||||||
|
const amount = doc.get('amount');
|
||||||
|
if (!(occurredAt instanceof Timestamp) || !allowed.includes(kind) ||
|
||||||
|
(personal && (!Number.isSafeInteger(amount) || amount <= 0 || doc.get('reversalOf') != null))) return [];
|
||||||
|
const from = doc.get('fromStage');
|
||||||
|
const to = doc.get('toStage');
|
||||||
|
if (kind === 'evolution' && (!Number.isInteger(from) || !Number.isInteger(to) ||
|
||||||
|
from < 0 || to > 6 || to <= from)) return [];
|
||||||
|
return [{
|
||||||
|
id: doc.id, kind, occurredAt: occurredAt.toDate().toISOString(),
|
||||||
|
...(personal ? { amount } : {}),
|
||||||
|
...(kind === 'evolution' ? { fromStage: from, toStage: to } : {}),
|
||||||
|
participationConfirmed: kind === 'evolution' && hasProof &&
|
||||||
|
firstAt.toMillis() <= occurredAt.toMillis(),
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
// 원인 이벤트 ID·운영 조건·원장·다른 사용자의 식별자는 공개 응답에 포함하지 않는다.
|
||||||
|
return { items, nextCursor: page.size > 20 ? rows.at(-1)!.id : null };
|
||||||
|
});
|
||||||
@@ -4,6 +4,50 @@
|
|||||||
import './core/bootstrap.js';
|
import './core/bootstrap.js';
|
||||||
|
|
||||||
export {
|
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';
|
} 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';
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { getFirestore, Timestamp } from 'firebase-admin/firestore';
|
||||||
|
import { getStorage } from 'firebase-admin/storage';
|
||||||
|
import { onDocumentDeleted, onDocumentWritten } from 'firebase-functions/v2/firestore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 라운지 글의 좋아요 · 댓글 수 — 설계서 §2.6 `likeCount` · `commentCount`(집계).
|
||||||
|
*
|
||||||
|
* 규칙이 글 문서의 두 수를 작성자에게도 닫아 두었으므로(firestore.rules `posts`)
|
||||||
|
* 수를 적는 곳은 여기뿐이다. **증감이 아니라 다시 센다.** 트리거는 적어도 한 번
|
||||||
|
* 전달되므로 `increment` 는 재전달마다 수가 부푼다.
|
||||||
|
*
|
||||||
|
* **늦게 센 값이 새 값을 덮지 않는다.** 센 시점(집계 질의의 `readTime`)을
|
||||||
|
* `likeCountAt` · `commentCountAt` 에 함께 적고, 트랜잭션 안에서 저장된 시점보다
|
||||||
|
* 새로울 때만 쓴다. 동시 반응 두 건의 트리거가 순서를 바꿔 끝나도 마지막 상태가 남는다.
|
||||||
|
*/
|
||||||
|
export function recountPost(postId: string, kind: 'likes' | 'comments'): Promise<void> {
|
||||||
|
return recount(`posts/${postId}`, kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [recountPost] 의 본체 — 부모 문서 경로만 받는다. 캠페인 응원 게시판도 같은 셈을 쓴다. */
|
||||||
|
export async function recount(parentPath: string, kind: 'likes' | 'comments'): Promise<void> {
|
||||||
|
const db = getFirestore();
|
||||||
|
const post = db.doc(parentPath);
|
||||||
|
const sub = post.collection(kind);
|
||||||
|
// 숨김 댓글은 목록에 안 뜨므로 수에서도 뺀다. 좋아요는 숨김 상태가 없다.
|
||||||
|
const query = kind === 'comments' ? sub.where('visibility', '==', 'visible') : sub;
|
||||||
|
const snap = await query.count().get();
|
||||||
|
const count = snap.data().count;
|
||||||
|
const countField = kind === 'likes' ? 'likeCount' : 'commentCount';
|
||||||
|
const atField = `${countField}At`;
|
||||||
|
await db.runTransaction(async (tx) => {
|
||||||
|
const current = await tx.get(post);
|
||||||
|
// 글이 먼저 지워졌으면 적을 곳이 없다 — 다시 만들지 않는다.
|
||||||
|
if (!current.exists) return;
|
||||||
|
const at = current.get(atField);
|
||||||
|
if (at instanceof Timestamp && at.toMillis() >= snap.readTime.toMillis()) return;
|
||||||
|
tx.update(post, { [countField]: count, [atField]: snap.readTime });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fnPostLikeCount = onDocumentWritten(
|
||||||
|
{ document: 'posts/{postId}/likes/{uid}', retry: true },
|
||||||
|
(event) => recountPost(event.params.postId, 'likes'),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const fnPostCommentCount = onDocumentWritten(
|
||||||
|
{ document: 'posts/{postId}/comments/{commentId}', retry: true },
|
||||||
|
async (event) => {
|
||||||
|
const before = event.data?.before;
|
||||||
|
const after = event.data?.after;
|
||||||
|
// 본문만 고친 수정은 수를 바꾸지 않는다 — 다시 세지 않는다.
|
||||||
|
if (before?.exists && after?.exists &&
|
||||||
|
before.get('visibility') === after.get('visibility')) return;
|
||||||
|
await recountPost(event.params.postId, 'comments');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캠페인 응원 게시판 댓글 수 — 라운지와 같은 셈. 규칙이 캠페인 문서의
|
||||||
|
* `commentCount` 를 개설자 · 운영 수정 목록에서 빼 두어 위조 경로가 없다.
|
||||||
|
*/
|
||||||
|
export const fnCampaignCommentCount = onDocumentWritten(
|
||||||
|
{ document: 'campaigns/{campaignId}/comments/{commentId}', retry: true },
|
||||||
|
async (event) => {
|
||||||
|
const before = event.data?.before;
|
||||||
|
const after = event.data?.after;
|
||||||
|
if (before?.exists && after?.exists &&
|
||||||
|
before.get('visibility') === after.get('visibility')) return;
|
||||||
|
await recount(`campaigns/${event.params.campaignId}`, 'comments');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 응원 게시판 댓글 좋아요 수(시안 `527:21192`) — 댓글 문서의 `likeCount`. */
|
||||||
|
export const fnCampaignCommentLikeCount = onDocumentWritten(
|
||||||
|
{ document: 'campaigns/{campaignId}/comments/{commentId}/likes/{uid}', retry: true },
|
||||||
|
(event) => recount(
|
||||||
|
`campaigns/${event.params.campaignId}/comments/${event.params.commentId}`, 'likes',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 글의 사진 파일을 지운다. 테스트가 Storage 없이 돌도록 바꿔 끼울 수 있게 뺐다. */
|
||||||
|
export type DeleteFiles = (prefix: string) => Promise<void>;
|
||||||
|
|
||||||
|
const deleteStorageFiles: DeleteFiles = (prefix) =>
|
||||||
|
getStorage().bucket().deleteFiles({ prefix });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 글이 지워지면 딸린 것도 지운다 — 하위 `likes` · `comments` 와
|
||||||
|
* `users/{작성자}/lounge/{글}-*` 사진. 앱은 글 문서만 지우고 나머지는 여기가 맡는다.
|
||||||
|
* 둘 다 다시 돌아도 같은 결과라 재전달에 안전하다.
|
||||||
|
*/
|
||||||
|
export async function cleanupPost(
|
||||||
|
postId: string,
|
||||||
|
authorUid: unknown,
|
||||||
|
deleteFiles: DeleteFiles = deleteStorageFiles,
|
||||||
|
): Promise<void> {
|
||||||
|
const db = getFirestore();
|
||||||
|
// 문서가 이미 없어도 recursiveDelete 는 하위 컬렉션을 지운다.
|
||||||
|
await db.recursiveDelete(db.doc(`posts/${postId}`));
|
||||||
|
if (typeof authorUid === 'string' && authorUid.length > 0) {
|
||||||
|
await deleteFiles(`users/${authorUid}/lounge/${postId}-`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fnPostCleanup = onDocumentDeleted(
|
||||||
|
{ document: 'posts/{postId}', retry: true },
|
||||||
|
(event) => cleanupPost(event.params.postId, event.data?.get('authorUid')),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 응원 게시판 댓글이 지워지면 하위 `likes` 도 지운다(fnPostCleanup 과 같은 방식).
|
||||||
|
* 번역 댓글은 라운지 글 정리와 마찬가지로 남긴다. 좋아요 수 트리거는 댓글이
|
||||||
|
* 없으면 다시 만들지 않는다(`recount`).
|
||||||
|
*/
|
||||||
|
export async function cleanupCampaignComment(campaignId: string, commentId: string): Promise<void> {
|
||||||
|
const db = getFirestore();
|
||||||
|
await db.recursiveDelete(db.doc(`campaigns/${campaignId}/comments/${commentId}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fnCampaignCommentCleanup = onDocumentDeleted(
|
||||||
|
{ document: 'campaigns/{campaignId}/comments/{commentId}', retry: true },
|
||||||
|
(event) => cleanupCampaignComment(event.params.campaignId, event.params.commentId),
|
||||||
|
);
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
import { HttpsError, onCall } from 'firebase-functions/v2/https';
|
||||||
|
import { onSchedule } from 'firebase-functions/v2/scheduler';
|
||||||
|
import { onDocumentCreated } from 'firebase-functions/v2/firestore';
|
||||||
|
import { getFirestore, FieldValue, Timestamp } from 'firebase-admin/firestore';
|
||||||
|
import { logger } from 'firebase-functions';
|
||||||
|
|
||||||
|
import { callerUid, num, str } from '../core/callable.js';
|
||||||
|
import { ERR, fail, positiveInt } from '../core/errors.js';
|
||||||
|
import { txIdOf } from '../core/ids.js';
|
||||||
|
import { grantPop, refundSplit, spendSplit } from '../ledger/ledger.js';
|
||||||
|
import { publicNameOf } from '../core/names.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현상금 Q&A — 별첨 1 ④.
|
||||||
|
*
|
||||||
|
* 판정 기준이 「현상금 **예치·전송·환불** 원장 정합성 오류 0건」이다. 세 갈래가
|
||||||
|
* 모두 원장을 지나야 하므로 화면에서 POP 을 옮기는 경로를 두지 않는다.
|
||||||
|
*
|
||||||
|
* 채택 기한은 정책 원장 B-39 가 7일로 확정했고 운영값이다. 코드에 박지 않고
|
||||||
|
* `config/policy.qnaAcceptDays` 에서 읽는다. 현상금 하한도 `config/policy.qnaMinBountyPop`.
|
||||||
|
*
|
||||||
|
* 질문 상태: `open` → `accepted`(채택 · 번복 불가) 또는 `refunding` → `expired`(환불 완료).
|
||||||
|
* 환불이 [MAX_REFUND_ATTEMPTS] 번 실패하면 `refund_failed` 로 멈추고 운영 로그에 올린다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DEFAULT_ACCEPT_DAYS = 7;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현상금 하한 기본값 — 기능 명세 2.60-3 「100POP 이상 및 보유 POP 구간 사이 입력 가능」.
|
||||||
|
* 화면만 막으면 앱을 고쳐 우회할 수 있으므로 서버도 같은 값으로 끊는다.
|
||||||
|
* 운영값이라 `config/policy.qnaMinBountyPop` 이 덮는다.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_MIN_BOUNTY_POP = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 제목 · 본문 상한. 기능 명세 2.60 은 길이를 정하지 않았다 — 앱 입력칸(60 · 2,000)보다
|
||||||
|
* 넉넉한 서버 안전 상한이며 정책값이 아니다.
|
||||||
|
*/
|
||||||
|
const MAX_TITLE = 100;
|
||||||
|
const MAX_BODY = 5_000;
|
||||||
|
|
||||||
|
/** 만료 환불 재시도 한도. 넘으면 자동 재시도를 멈추고 수동 복구로 넘긴다. */
|
||||||
|
export const MAX_REFUND_ATTEMPTS = 3;
|
||||||
|
|
||||||
|
async function qnaPolicy(): Promise<{ acceptDays: number; minBounty: number }> {
|
||||||
|
const snap = await getFirestore().doc('config/policy').get();
|
||||||
|
const positive = (key: string, fallback: number) => {
|
||||||
|
const value = snap.get(key);
|
||||||
|
return typeof value === 'number' && value > 0 ? value : fallback;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
acceptDays: positive('qnaAcceptDays', DEFAULT_ACCEPT_DAYS),
|
||||||
|
minBounty: positive('qnaMinBountyPop', DEFAULT_MIN_BOUNTY_POP),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `fn-question-create` — 현상금을 예치하고 질문을 연다.
|
||||||
|
*
|
||||||
|
* 예치와 질문 문서를 **한 트랜잭션**에 둔다. 따로 쓰면 그 사이 실패에서 「POP 은
|
||||||
|
* 빠졌는데 질문이 없다」가 남는다. 목록이 팬덤으로 거르므로 `fandomId` 와 표시명을
|
||||||
|
* 사용자 문서에서 복제한다 — 클라이언트가 보낸 값은 믿지 않는다.
|
||||||
|
*
|
||||||
|
* 예치는 겸용이다(B-39 · B-06) — 일반(activity) POP 을 먼저 쓰고 부족분만 기여(support)
|
||||||
|
* POP 에서 뺀다. 두 재화가 함께 쓰이면 원장 2건이며, 그 거래 ID 들을 `bountyTxIds` 에
|
||||||
|
* 남겨 만료 환불이 행마다 원래 타입으로 돌린다.
|
||||||
|
*/
|
||||||
|
export const fnQuestionCreate = onCall(async (req) => {
|
||||||
|
const uid = callerUid(req);
|
||||||
|
const title = str(req.data, 'title');
|
||||||
|
const body = str(req.data, 'body');
|
||||||
|
const clientSeq = str(req.data, 'clientSeq');
|
||||||
|
if (title.length > MAX_TITLE || body.length > MAX_BODY) {
|
||||||
|
throw new HttpsError('invalid-argument', `제목은 ${MAX_TITLE}자, 본문은 ${MAX_BODY}자까지입니다.`);
|
||||||
|
}
|
||||||
|
const bounty = positiveInt(num(req.data, 'bountyPop'), 'bountyPop');
|
||||||
|
const { acceptDays: days, minBounty } = await qnaPolicy();
|
||||||
|
if (bounty < minBounty) {
|
||||||
|
throw new HttpsError('invalid-argument', `현상금은 ${minBounty} POP 이상이어야 합니다.`);
|
||||||
|
}
|
||||||
|
const db = getFirestore();
|
||||||
|
const user = (await db.doc(`users/${uid}`).get()).data() ?? {};
|
||||||
|
|
||||||
|
const idempotencyKey = `bounty:${uid}:${clientSeq}`;
|
||||||
|
// 질문 ID 는 예치 거래 ID 와 같다. 재시도가 질문을 둘 만들지 않는다.
|
||||||
|
const questionId = txIdOf(idempotencyKey);
|
||||||
|
const parts = await spendSplit({
|
||||||
|
uid,
|
||||||
|
amount: bounty,
|
||||||
|
reasonCode: 'QNA_BOUNTY',
|
||||||
|
idempotencyKey,
|
||||||
|
refPath: `questions/${questionId}`,
|
||||||
|
alsoWrite: (tx, written) => {
|
||||||
|
tx.create(db.doc(`questions/${questionId}`), {
|
||||||
|
authorUid: uid,
|
||||||
|
authorName: publicNameOf({ get: (k: string) => user[k] }),
|
||||||
|
fandomId: typeof user.fandomId === 'string' ? user.fandomId : '',
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
bountyPop: bounty,
|
||||||
|
bountyTxIds: written.map((p) => p.txId),
|
||||||
|
status: 'open',
|
||||||
|
answerCount: 0,
|
||||||
|
expiresAt: Timestamp.fromMillis(Date.now() + days * 24 * 60 * 60 * 1000),
|
||||||
|
createdAt: FieldValue.serverTimestamp(),
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
questionId,
|
||||||
|
txIds: parts.map((p) => p.txId),
|
||||||
|
parts: parts.map((p) => ({ popType: p.popType, amount: p.amount })),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `fn-answer-accept` — 채택. 예치한 현상금이 답변자에게 **일반 POP** 으로 간다(B-39).
|
||||||
|
*
|
||||||
|
* 명세가 정한 둘을 지킨다 — **자기 답변 채택 금지**(E-QNA-01)와 **번복 불가**.
|
||||||
|
* 판정 · 지급 · 상태 전환을 한 트랜잭션에 둔다. 만료 배치도 질문 문서를 먼저
|
||||||
|
* 점유하므로(`refunding`) 채택과 환불이 함께 일어나지 않는다.
|
||||||
|
*/
|
||||||
|
export const fnAnswerAccept = onCall(async (req) => {
|
||||||
|
const uid = callerUid(req);
|
||||||
|
const questionId = str(req.data, 'questionId');
|
||||||
|
const answerId = str(req.data, 'answerId');
|
||||||
|
const db = getFirestore();
|
||||||
|
const questionRef = db.doc(`questions/${questionId}`);
|
||||||
|
const answerRef = questionRef.collection('answers').doc(answerId);
|
||||||
|
|
||||||
|
return db.runTransaction(async (tx) => {
|
||||||
|
const question = await tx.get(questionRef);
|
||||||
|
if (!question.exists) throw new HttpsError('not-found', '질문을 찾을 수 없습니다.');
|
||||||
|
if (question.get('authorUid') !== uid) {
|
||||||
|
fail(ERR.permissionDenied, '질문을 올린 분만 채택할 수 있습니다.');
|
||||||
|
}
|
||||||
|
const status = question.get('status') as string | undefined;
|
||||||
|
if (status === 'accepted') {
|
||||||
|
fail(ERR.qnaAlreadyAccepted, '이미 채택한 질문입니다. 채택은 바꿀 수 없습니다.');
|
||||||
|
}
|
||||||
|
const expiresAt = question.get('expiresAt') as Timestamp | undefined;
|
||||||
|
if (status !== 'open' || (expiresAt && expiresAt.toMillis() <= Date.now())) {
|
||||||
|
// 환불이 실제로 끝났을 때만 「환불되었습니다」라고 말한다.
|
||||||
|
fail(ERR.qnaBountyExpired, status === 'expired'
|
||||||
|
? '채택 기한이 지나 현상금이 환불되었습니다.'
|
||||||
|
: '채택 기한이 지나 현상금이 환불될 예정입니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const answer = await tx.get(answerRef);
|
||||||
|
if (!answer.exists) throw new HttpsError('not-found', '답변을 찾을 수 없습니다.');
|
||||||
|
const answerUid = answer.get('authorUid') as string | undefined;
|
||||||
|
if (!answerUid) throw new HttpsError('failed-precondition', '답변자를 알 수 없습니다.');
|
||||||
|
if (answerUid === uid) {
|
||||||
|
fail(ERR.qnaSelfAnswer, '자기 답변은 채택할 수 없습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const bounty = (question.get('bountyPop') as number | undefined) ?? 0;
|
||||||
|
const result = await grantPop({
|
||||||
|
uid: answerUid,
|
||||||
|
popType: 'activity',
|
||||||
|
amount: bounty,
|
||||||
|
reasonCode: 'QNA_REWARD',
|
||||||
|
// 질문 하나에 한 번. 재시도가 두 번 지급하지 않는다.
|
||||||
|
idempotencyKey: `accept:${questionId}`,
|
||||||
|
refPath: questionRef.path,
|
||||||
|
}, tx);
|
||||||
|
|
||||||
|
tx.update(questionRef, {
|
||||||
|
status: 'accepted',
|
||||||
|
acceptedAnswerId: answerId,
|
||||||
|
acceptedUid: answerUid,
|
||||||
|
acceptTxId: result.txId,
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
tx.update(answerRef, { accepted: true, updatedAt: FieldValue.serverTimestamp() });
|
||||||
|
|
||||||
|
return { txId: result.txId, to: answerUid, amount: bounty };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 답변 수 — 규칙이 답변 등록은 클라이언트에 열어 두었지만(재화 이동 없음) 질문
|
||||||
|
* 문서는 잠겨 있다. 인기순 정렬 필드(`answerCount`)를 서버가 올린다.
|
||||||
|
*
|
||||||
|
* 트리거는 재전달될 수 있으므로 `answerCounted/{answerId}` 표식과 증가를 한 트랜잭션에
|
||||||
|
* 둔다 — 표식이 이미 있으면 세지 않는다. 질문이 없으면 건너뛴다.
|
||||||
|
*/
|
||||||
|
export async function countAnswer(questionId: string, answerId: string): Promise<boolean> {
|
||||||
|
const db = getFirestore();
|
||||||
|
const questionRef = db.doc(`questions/${questionId}`);
|
||||||
|
const markerRef = questionRef.collection('answerCounted').doc(answerId);
|
||||||
|
return db.runTransaction(async (tx) => {
|
||||||
|
const [question, marker] = await Promise.all([tx.get(questionRef), tx.get(markerRef)]);
|
||||||
|
if (!question.exists || marker.exists) return false;
|
||||||
|
tx.create(markerRef, { createdAt: FieldValue.serverTimestamp() });
|
||||||
|
tx.update(questionRef, { answerCount: FieldValue.increment(1) });
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fnAnswerOnCreate = onDocumentCreated(
|
||||||
|
'questions/{questionId}/answers/{answerId}',
|
||||||
|
async (event) => {
|
||||||
|
await countAnswer(event.params.questionId, event.params.answerId);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `fn-question-bounty-expire` — 채택 기한이 지난 현상금을 돌려준다(E-QNA-02).
|
||||||
|
* 예치한 원거래의 타입 그대로 환불된다(`refundSplit` 이 행마다 원거래 `popType` 을 따른다).
|
||||||
|
*/
|
||||||
|
export const fnQuestionBountyExpire = onSchedule('every 1 hours', async () => {
|
||||||
|
await expireBounties();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 만료 처리 본체. 질문을 트랜잭션으로 `refunding` 에 점유한 뒤 환불하고 `expired`
|
||||||
|
* 로 닫는다. 환불이 실패한 건은 `refunding` 으로 남아 다음 회차가 다시 집는다.
|
||||||
|
* [MAX_REFUND_ATTEMPTS] 번 실패했거나 환불 근거(질문자 · 예치 거래)가 없으면
|
||||||
|
* `refund_failed` 로 멈추고 `adminActionLogs` 에 수동 복구 건으로 올린다
|
||||||
|
* (캠페인 환불 부분 실패와 같은 운영 경로).
|
||||||
|
*/
|
||||||
|
export async function expireBounties(): Promise<{ scanned: number; refunded: number }> {
|
||||||
|
const db = getFirestore();
|
||||||
|
const [expired, pending] = await Promise.all([
|
||||||
|
db.collection('questions')
|
||||||
|
.where('status', '==', 'open')
|
||||||
|
.where('expiresAt', '<=', Timestamp.now())
|
||||||
|
.get(),
|
||||||
|
db.collection('questions').where('status', '==', 'refunding').get(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const giveUp = async (ref: FirebaseFirestore.DocumentReference, attempts: number, error: string) => {
|
||||||
|
await ref.update({
|
||||||
|
status: 'refund_failed',
|
||||||
|
refundAttempts: attempts,
|
||||||
|
refundError: error,
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
await db.collection('adminActionLogs').add({
|
||||||
|
actorUid: 'system',
|
||||||
|
targetPath: ref.path,
|
||||||
|
reason: 'Q&A 현상금 만료 환불 실패 — 수동 복구 필요',
|
||||||
|
patch: { attempts, error },
|
||||||
|
createdAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
logger.error('[qna] 현상금 환불 중단', { questionId: ref.id, attempts, error });
|
||||||
|
};
|
||||||
|
|
||||||
|
let refunded = 0;
|
||||||
|
for (const doc of [...expired.docs, ...pending.docs]) {
|
||||||
|
const claimed = await db.runTransaction(async (tx) => {
|
||||||
|
const fresh = await tx.get(doc.ref);
|
||||||
|
const status = fresh.get('status');
|
||||||
|
if (status === 'refunding') return true;
|
||||||
|
const expiresAt = fresh.get('expiresAt') as Timestamp | undefined;
|
||||||
|
if (status !== 'open' || !expiresAt || expiresAt.toMillis() > Date.now()) return false;
|
||||||
|
tx.update(doc.ref, { status: 'refunding', updatedAt: FieldValue.serverTimestamp() });
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (!claimed) continue;
|
||||||
|
|
||||||
|
const uid = doc.get('authorUid') as string | undefined;
|
||||||
|
// 분할 예치 전의 옛 질문은 `bountyTxId` 한 건이다.
|
||||||
|
const legacy = doc.get('bountyTxId') as string | undefined;
|
||||||
|
const txIds = (doc.get('bountyTxIds') as string[] | undefined) ?? (legacy ? [legacy] : []);
|
||||||
|
if (!uid || txIds.length === 0) {
|
||||||
|
await giveUp(doc.ref, 0, '질문자 또는 예치 거래 ID 가 없습니다.');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const results = await refundSplit({
|
||||||
|
uid,
|
||||||
|
originalTxIds: txIds,
|
||||||
|
idempotencyKey: `qnarefund:${doc.id}`,
|
||||||
|
reasonCode: 'QNA_REFUND',
|
||||||
|
});
|
||||||
|
await doc.ref.update({
|
||||||
|
status: 'expired',
|
||||||
|
refundTxIds: results.map((r) => r.txId),
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
refunded += 1;
|
||||||
|
} catch (error) {
|
||||||
|
const attempts = ((doc.get('refundAttempts') as number | undefined) ?? 0) + 1;
|
||||||
|
if (attempts >= MAX_REFUND_ATTEMPTS) {
|
||||||
|
await giveUp(doc.ref, attempts, String(error));
|
||||||
|
} else {
|
||||||
|
await doc.ref.update({ refundAttempts: attempts, refundError: String(error) });
|
||||||
|
logger.error('[qna] 현상금 환불 실패', { questionId: doc.id, attempts, error: String(error) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const scanned = expired.size + pending.size;
|
||||||
|
logger.info('[qna] 기한 만료 처리', { scanned, refunded });
|
||||||
|
return { scanned, refunded };
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { getFirestore, FieldValue } from 'firebase-admin/firestore';
|
||||||
|
import type { DocumentSnapshot, Transaction } from 'firebase-admin/firestore';
|
||||||
|
|
||||||
|
import type { FpiAxis } from '../core/config.js';
|
||||||
|
import { levelOf, passportTierOf, type PassportTier } from '../core/level.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FPI 원천 이벤트 — 정책 원장 **C-34 · C-35 · G-05**, 설계서 §4.1.1.
|
||||||
|
*
|
||||||
|
* 축 값은 **인정된 활동 단위**다. 지급 POP 은 원천이 아니므로 이 문서에 POP 필드를
|
||||||
|
* 두지 않는다 — 보상 750 POP 인 Quest 와 1 POP 인 Quest 의 같은 활동은 같은 점수다.
|
||||||
|
*
|
||||||
|
* 축 매핑(C-35): 출석 · 성지/생일 팝업/응원 현장 방문 = community · 걸음/운동 = health ·
|
||||||
|
* Music Shake = bpm · 추천 = referral · Zap/후원 = contribution.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 활동 1 단위의 축 값. S01 확정 전 유지 기준(설계서 §4.1.1 「축별 계수 초기값 1.0」). */
|
||||||
|
export const FPI_UNIT_FACTOR = 1.0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 귀속 팬덤은 서버가 정한다 — `primaryFandomId ?? fandomId`.
|
||||||
|
*
|
||||||
|
* 클라이언트가 보낸 `fandomId` 는 쓰지 않는다. 남의 행성 축을 올리는 길이 된다.
|
||||||
|
* 성장 귀속(`fandomIdAtPayout`)도 같은 판정을 쓴다(C-33).
|
||||||
|
*/
|
||||||
|
export function fandomOfUser(user: DocumentSnapshot | null | undefined): string | null {
|
||||||
|
const id = user?.get('primaryFandomId') || user?.get('fandomId');
|
||||||
|
return typeof id === 'string' && id.length > 0 && !id.includes('/') ? id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FpiActivity {
|
||||||
|
uid: string;
|
||||||
|
fandomId: string | null;
|
||||||
|
axis: FpiAxis;
|
||||||
|
/** 인정 활동 단위 수(걸음은 새로 도달한 구간 수, Zap 은 누른 횟수). */
|
||||||
|
units: number;
|
||||||
|
/** 활동 종류 — 무효 정정 때 원 활동을 찾는 표식. */
|
||||||
|
activity: string;
|
||||||
|
/** 원 활동 ID. 문서 ID 가 `{uid}_{sourceId}` 라 같은 활동은 한 번만 남는다. */
|
||||||
|
sourceId: string;
|
||||||
|
occurredAt: Date;
|
||||||
|
refPath?: string;
|
||||||
|
/**
|
||||||
|
* User EXP 를 주는가. 기본 true. 반복 비용이 낮아 파밍되는 활동(Zap · 후원 — 1 POP 단위로
|
||||||
|
* 쪼갤 수 있다)과 본인 행위가 아닌 활동(추천인 귀속)은 false 다.
|
||||||
|
*/
|
||||||
|
grantsExp?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인정 활동 1건의 User EXP — 정책 원장 **C-18**.
|
||||||
|
*
|
||||||
|
* S01 확정 전 임시값. 누적 기여 POP 을 레벨로 치환하지 않도록(C-18) 금액이 아니라 **서버가
|
||||||
|
* 인정한 활동 건수**로 쌓는다 — 보상 750 POP 출석과 1 POP 출석이 같은 1건이다. 대상은 서버가
|
||||||
|
* 한도 · 1회성을 판정하는 Quest 활동(출석 · 걸음 구간 · Music Shake · 현장 방문)뿐이다.
|
||||||
|
* Zap · 후원 · 추천인 귀속은 `grantsExp: false`(파밍 · 본인 행위 아님).
|
||||||
|
*/
|
||||||
|
export const EXP_PER_ACTIVITY = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원 활동과 **같은 트랜잭션에서** FPI 이벤트와 User EXP 를 남긴다.
|
||||||
|
*
|
||||||
|
* 따로 쓰면 지급은 됐는데 FPI 는 빠진 상태가 남고, 재시도는 일일 한도에 막혀 영영
|
||||||
|
* 채워지지 않는다. `create` 라 같은 활동을 두 번 적으면 트랜잭션째 실패한다.
|
||||||
|
*
|
||||||
|
* `user` 는 **이 트랜잭션에서 이미 읽은** 활동 주체의 사용자 문서다(읽기가 쓰기보다 앞서야
|
||||||
|
* 한다). 레벨 · Passport Tier 는 EXP 의 파생값이라 함께 갱신하고 Passport 는 내려가지 않는다.
|
||||||
|
* 팬덤이 없어도 EXP 는 쌓인다 — 레벨은 개인 체계다(B-66).
|
||||||
|
*/
|
||||||
|
export function writeFpiEvent(
|
||||||
|
tx: Transaction, event: FpiActivity, user: DocumentSnapshot | null,
|
||||||
|
): void {
|
||||||
|
if (event.units <= 0) return;
|
||||||
|
if (event.grantsExp !== false) writeUserExp(tx, event.uid, user);
|
||||||
|
if (!event.fandomId) return;
|
||||||
|
tx.create(
|
||||||
|
getFirestore().doc(`fandoms/${event.fandomId}/fpiEvents/${event.uid}_${event.sourceId}`),
|
||||||
|
{
|
||||||
|
axis: event.axis,
|
||||||
|
value: event.units * FPI_UNIT_FACTOR,
|
||||||
|
units: event.units,
|
||||||
|
activity: event.activity,
|
||||||
|
uid: event.uid,
|
||||||
|
verified: true,
|
||||||
|
occurredAt: event.occurredAt,
|
||||||
|
sourceId: event.sourceId,
|
||||||
|
// 일별 DAU(Eclipse 목표 입력)는 본인이 직접 한 인정 활동만 센다. 추천인 귀속은 제외.
|
||||||
|
dauEligible: event.activity !== 'referral',
|
||||||
|
...(event.refPath ? { refPath: event.refPath } : {}),
|
||||||
|
createdAt: FieldValue.serverTimestamp(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인정 활동 1건의 User EXP(C-18) — 레벨 · Passport Tier 함께. FPI 축 없이 EXP 만 줄 때
|
||||||
|
* (타임어택 FPI 축이 꺼졌을 때) 직접 부른다.
|
||||||
|
*/
|
||||||
|
export function writeUserExp(tx: Transaction, uid: string, user: DocumentSnapshot | null): void {
|
||||||
|
const exp = ((user?.get('userExp') as number | undefined) ?? 0) + EXP_PER_ACTIVITY;
|
||||||
|
const level = levelOf(exp);
|
||||||
|
tx.set(getFirestore().doc(`users/${uid}`), {
|
||||||
|
userExp: exp,
|
||||||
|
userLevel: level,
|
||||||
|
passportTier: passportTierOf(level, user?.get('passportTier') as PassportTier | undefined),
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
}, { merge: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「검증 활동」 1회 — 해석 확인 요청 Campaign 개설자격 **A안** · 집계 규칙(2026-09-28 결정).
|
||||||
|
*
|
||||||
|
* 서버가 인정한 Quest 완료를 **완료 단위 표식** `users/{uid}/verifiedCompletions/{id}` 하나로 남긴다.
|
||||||
|
* `id` 가 곧 「하루 1회」 경계다 — 출석 · 걸음(구간 무관) · 방문 · UGC · 타임어택은
|
||||||
|
* `{questId}_{dayKey}`, Music Shake 는 완주마다 `{questId}_{dayKey}_{n}`. 같은 날 같은 표식을
|
||||||
|
* 다시 써도(구간 · 재시도) `merge` 라 하나로 모인다. 보상 유무 · 팬덤 유무와 무관하다.
|
||||||
|
*
|
||||||
|
* 무효 정정은 원장 행(`txIds`) 또는 제출 건(`submissionId`)으로 찾는다([voidCompletions]).
|
||||||
|
*/
|
||||||
|
export function markCompletion(
|
||||||
|
tx: Transaction, uid: string, id: string,
|
||||||
|
ref: { questId: string; txId?: string; submissionId?: string },
|
||||||
|
): void {
|
||||||
|
tx.set(getFirestore().doc(`users/${uid}/verifiedCompletions/${id}`), {
|
||||||
|
questId: ref.questId,
|
||||||
|
...(ref.txId ? { txIds: FieldValue.arrayUnion(ref.txId) } : {}),
|
||||||
|
...(ref.submissionId ? { submissionId: ref.submissionId } : {}),
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
}, { merge: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「검증 활동」 누적 수 = 표식 수 − 무효 표식 수. 기간 창 없음. Quest 생성자격 1회 · Campaign
|
||||||
|
* 생성자격 5회가 같은 값을 읽는다. 저장 카운터가 아니라 표식에서 세므로 음수 · 이중 차감이 없다.
|
||||||
|
*/
|
||||||
|
export async function verifiedQuestCountOf(uid: string): Promise<number> {
|
||||||
|
const col = getFirestore().collection(`users/${uid}/verifiedCompletions`);
|
||||||
|
const [all, voided] = await Promise.all([
|
||||||
|
col.count().get(), col.where('voided', '==', true).count().get(),
|
||||||
|
]);
|
||||||
|
return Math.max(0, all.data().count - voided.data().count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 원장 행 또는 제출 건에 걸린 표식을 무효로 — 트랜잭션 안에서 **읽기 단계**에 부른다. 쓰기는 반환 함수로. */
|
||||||
|
export async function voidCompletions(
|
||||||
|
tx: Transaction, uid: string, key: { txId?: string; submissionId?: string },
|
||||||
|
): Promise<(tx: Transaction) => number> {
|
||||||
|
const col = getFirestore().collection(`users/${uid}/verifiedCompletions`);
|
||||||
|
const snap = key.txId
|
||||||
|
? await tx.get(col.where('txIds', 'array-contains', key.txId))
|
||||||
|
: key.submissionId ? await tx.get(col.where('submissionId', '==', key.submissionId)) : null;
|
||||||
|
const live = (snap?.docs ?? []).filter((d) => d.get('voided') !== true);
|
||||||
|
return (w) => {
|
||||||
|
for (const d of live) w.update(d.ref, { voided: true, voidedAt: FieldValue.serverTimestamp() });
|
||||||
|
return live.length;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공동 순위 — 정책 원장 **C-19**.
|
||||||
|
*
|
||||||
|
* 실제 산정값(반올림 전) 내림차순, 동점은 같은 순위이고 다음 순위는 건너뛴다(1·1·3).
|
||||||
|
* 동점 안의 배치는 `key` 오름차순으로 고정한다(S01 확정 전 유지 기준 — 2차 정렬키
|
||||||
|
* `fandomId`). 최근 활동 시각은 쓰지 않는다.
|
||||||
|
*/
|
||||||
|
export function rankWithTies<T>(
|
||||||
|
items: readonly T[], value: (item: T) => number, key: (item: T) => string,
|
||||||
|
): Array<{ item: T; rank: number }> {
|
||||||
|
const sorted = [...items].sort((a, b) =>
|
||||||
|
value(b) - value(a) || (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0));
|
||||||
|
let rank = 0;
|
||||||
|
return sorted.map((item, i) => {
|
||||||
|
if (i === 0 || value(item) !== value(sorted[i - 1]!)) rank = i + 1;
|
||||||
|
return { item, rank };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,831 @@
|
|||||||
|
import { getFirestore, FieldValue, Timestamp } from 'firebase-admin/firestore';
|
||||||
|
import type { DocumentReference, Firestore, Transaction } from 'firebase-admin/firestore';
|
||||||
|
|
||||||
|
import { ERR, fail } from '../core/errors.js';
|
||||||
|
import { lotIdOf, txIdOf } from '../core/ids.js';
|
||||||
|
import { levelOf, passportRank, passportTierOf, type PassportTier } from '../core/level.js';
|
||||||
|
import { fandomOfUser, voidCompletions, writeFpiEvent } from './fpi.js';
|
||||||
|
import { publicNameOf } from '../core/names.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POP 원장 — 별첨 1 「조건부 적합 제외 항목 ② POP/FPI 데이터 정합성」의 구현부.
|
||||||
|
*
|
||||||
|
* 이 파일이 계약 검수의 핵심이다. 정합성 오류가 나면 조건부 적합이 아니라
|
||||||
|
* **부적합(재검수)** 이므로, 원장을 건드리는 모든 경로가 여기를 지나게 했다.
|
||||||
|
* 화면이나 다른 함수가 지갑 문서를 직접 쓰는 일은 없어야 한다.
|
||||||
|
*
|
||||||
|
* 지키는 것 셋
|
||||||
|
* 1. **append-only** — 과거 원장 행을 고치지 않는다. 정정은 반대 부호의 새 행이다.
|
||||||
|
* 2. **멱등** — `txId = SHA-256(idempotencyKey)[0..20]` 이고 항상 `create()` 한다.
|
||||||
|
* 재시도가 겹쳐도 두 번째가 `ALREADY_EXISTS` 로 떨어진다.
|
||||||
|
* 3. **혼합 차감 금지** — 원장 한 행은 `popType` 하나다. 활동(일반) POP 과 지원(기여)
|
||||||
|
* POP 은 회계가 다르므로 한 행이 둘을 함께 건드릴 수 없다. 겸용 사용처(B-06)는
|
||||||
|
* `spendSplit` 이 타입별 행 2건을 한 트랜잭션에 쓴다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type PopType = 'activity' | 'support';
|
||||||
|
export type TxType = 'grant' | 'spend' | 'refund' | 'void';
|
||||||
|
|
||||||
|
export type ReasonCode =
|
||||||
|
| 'QUEST_STEPS' | 'QUEST_MUSIC_SHAKE' | 'QUEST_PLACE' | 'QUEST_ATTEND' | 'QUEST_TIMEATTACK'
|
||||||
|
| 'AD_REWARD' | 'HERO_DROP' | 'TRANSLATION' | 'IAP_PURCHASE' | 'FOCUS_DAILY'
|
||||||
|
| 'ADMIN_GRANT' | 'PREREG_BONUS' | 'QNA_REWARD' | 'REFERRAL_REWARD'
|
||||||
|
| 'CAMPAIGN_FUND' | 'QNA_BOUNTY' | 'ECLIPSE_DEFENSE' | 'HERO_DROP_TRIGGER'
|
||||||
|
| 'ZAP_CONTRIBUTION' | 'CAMPAIGN_FEE'
|
||||||
|
| 'CAMPAIGN_REFUND' | 'QNA_REFUND' | 'ADMIN_CLAWBACK'
|
||||||
|
// 등록비 반환(B-58 반려 · 회사귀책 · 공개 전 철회). MY 표기 「캠페인 개설 수수료 환불」.
|
||||||
|
| 'CAMPAIGN_FEE_REFUND'
|
||||||
|
// 사용자 생성 Quest(C-02 · C-09 · B-30) — 참여 보상 · 수수료(Burn) · Pool 예치 · 미사용분 반환 ·
|
||||||
|
// 공개 전 실패 시 예치 원타입 환불.
|
||||||
|
| 'QUEST_UGC' | 'QUEST_FEE' | 'QUEST_POOL_DEPOSIT' | 'QUEST_POOL_RETURN' | 'QUEST_POOL_REFUND';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기여로 집계하는 차감 사유 — `user.totalContributedPop` 의 모집단(§3.6).
|
||||||
|
*
|
||||||
|
* `ZAP_CONTRIBUTION` 은 **행성 성장 기여**다(별첨 B **B-23** — *ZAP 차감액은 누적 기여
|
||||||
|
* POP 에 실제 값으로 반영되고, 행성 성장용 Growth 진행도에만 가중을 둔다*). 가중은
|
||||||
|
* 원장과 sFPI 에 적용하지 않으므로 여기서는 실제 차감액 그대로 쌓인다.
|
||||||
|
*
|
||||||
|
* `ECLIPSE_DEFENSE` 는 남겨 두지만 **더 이상 쓰이지 않는다** — 이클립스 방어는 POP 을
|
||||||
|
* 소모하지 않는 공동 Shake 참여로 확정되었다(설계서 v1.1 §9-#5). 과거 원장 행을
|
||||||
|
* 읽는 쪽이 있어 사유 코드 자체는 지우지 않는다(카탈로그는 append-only).
|
||||||
|
*/
|
||||||
|
export const CONTRIBUTION_REASONS: readonly ReasonCode[] = [
|
||||||
|
'CAMPAIGN_FUND', 'QNA_BOUNTY', 'ECLIPSE_DEFENSE', 'ZAP_CONTRIBUTION',
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface LotRef { lotId: string; delta: number }
|
||||||
|
|
||||||
|
export interface GrantInput {
|
||||||
|
uid: string;
|
||||||
|
popType: PopType;
|
||||||
|
amount: number;
|
||||||
|
reasonCode: ReasonCode;
|
||||||
|
idempotencyKey: string;
|
||||||
|
refPath?: string;
|
||||||
|
actorUid?: string;
|
||||||
|
/** 검수 대기 지급(정책설계서 P-02). 잔액이 아니라 `pendingActivity` 에 쌓인다. */
|
||||||
|
pending?: boolean;
|
||||||
|
/** 검증된 보상 경로가 지정한다. 일반 광고/관리자 지급은 자동 포함하지 않는다. */
|
||||||
|
growthSource?: 'quest_reward' | 'company_ad_bonus';
|
||||||
|
/**
|
||||||
|
* 지급과 **같은 트랜잭션에서** 함께 쓸 것.
|
||||||
|
*
|
||||||
|
* 퀘스트 진행 카운터가 대표적이다. 지급 뒤에 따로 쓰면 그 사이 실패가 났을 때
|
||||||
|
* 「POP 은 나갔는데 일일 한도는 0」인 상태가 남고, 그 상태에서 한 번 더 부르면
|
||||||
|
* 같은 날 두 번 지급된다. 같은 트랜잭션이면 둘이 함께 서거나 함께 없다.
|
||||||
|
*
|
||||||
|
* 멱등키가 같은 재시도에서는 부르지 않는다 — 첫 호출이 이미 썼다.
|
||||||
|
* `user` 는 이 트랜잭션에서 읽은 사용자 문서다(`growthSource` 지급일 때). FPI 이벤트와
|
||||||
|
* EXP(`writeFpiEvent`)가 이 값으로 레벨을 다시 매긴다.
|
||||||
|
*/
|
||||||
|
alsoWrite?: (tx: Transaction, user: FirebaseFirestore.DocumentSnapshot | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpendInput {
|
||||||
|
/**
|
||||||
|
* 차감 전에 **같은 트랜잭션에서** 읽고 판정할 것(예: 캠페인 목표 잔여분). 던지면 차감도
|
||||||
|
* 없다. 모든 읽기가 쓰기보다 앞서야 하므로 원장 쓰기 전에 부른다. 재시도에서는 부르지 않는다.
|
||||||
|
*/
|
||||||
|
precheck?: (tx: Transaction) => Promise<void>;
|
||||||
|
/** 차감과 함께 확정할 이벤트. 재시도에서는 다시 쓰지 않는다. `parts` 는 이번에 쓴 원장 행이다. */
|
||||||
|
alsoWrite?: (tx: Transaction, parts: SpendPart[]) => void;
|
||||||
|
uid: string;
|
||||||
|
popType: PopType;
|
||||||
|
amount: number;
|
||||||
|
reasonCode: ReasonCode;
|
||||||
|
idempotencyKey: string;
|
||||||
|
refPath?: string;
|
||||||
|
fandomId?: string;
|
||||||
|
/**
|
||||||
|
* 한 행이 몇 번의 행위를 묶은 것인가. 기본값은 두지 않는다 — 없으면 적지 않는다.
|
||||||
|
*
|
||||||
|
* ZAP 연타처럼 화면이 여러 번의 누름을 한 호출로 묶으면 `amount` 만으로는 횟수를
|
||||||
|
* 복원할 수 없다. MY 의 POP 내역이 「Zap N 회」를 **행 수로 세면 실제보다 작게**
|
||||||
|
* 나오므로, 묶은 횟수를 원장 행에 그대로 남긴다.
|
||||||
|
*/
|
||||||
|
count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LedgerResult {
|
||||||
|
txId: string;
|
||||||
|
balanceAfter: number;
|
||||||
|
lotRefs: LotRef[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const balanceField = (t: PopType) =>
|
||||||
|
t === 'activity' ? 'activityBalance' : 'supportBalance';
|
||||||
|
|
||||||
|
const walletRef = (db: Firestore, uid: string) =>
|
||||||
|
db.doc(`users/${uid}/wallet/main`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 지갑을 읽는다. 없으면 빈 지갑으로 본다.
|
||||||
|
*
|
||||||
|
* 트리거가 만들기 전에 첫 지급이 도착할 수 있으므로(경합) 여기서 없다고 실패시키지
|
||||||
|
* 않는다. 같은 트랜잭션 안에서 `set(merge)` 로 만들면 된다.
|
||||||
|
*/
|
||||||
|
async function readWallet(tx: Transaction, db: Firestore, uid: string) {
|
||||||
|
const ref = walletRef(db, uid);
|
||||||
|
const snap = await tx.get(ref);
|
||||||
|
const data = snap.data() ?? {};
|
||||||
|
return {
|
||||||
|
ref,
|
||||||
|
exists: snap.exists,
|
||||||
|
activityBalance: (data.activityBalance as number | undefined) ?? 0,
|
||||||
|
supportBalance: (data.supportBalance as number | undefined) ?? 0,
|
||||||
|
pendingActivity: (data.pendingActivity as number | undefined) ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 원장과 같은 트랜잭션에서만 호출한다. 사용자별 원 거래 ID가 성장 멱등 키다. */
|
||||||
|
function writeGrowth(
|
||||||
|
tx: Transaction, db: Firestore, uid: string, txId: string,
|
||||||
|
fandomId: string, amount: number,
|
||||||
|
sourceType: 'quest_reward' | 'company_ad_bonus' | 'zap' | 'funding',
|
||||||
|
): void {
|
||||||
|
tx.create(db.doc(`users/${uid}/growthEvents/${txId}`), {
|
||||||
|
sourceEventId: txId,
|
||||||
|
sourceType,
|
||||||
|
fandomIdAtPayout: fandomId,
|
||||||
|
amount,
|
||||||
|
reversalOf: null,
|
||||||
|
reason: null,
|
||||||
|
createdAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
tx.set(db.doc(`fandoms/${fandomId}`), {
|
||||||
|
growthPop: FieldValue.increment(amount),
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
}, { merge: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 지급 — `fn-pop-grant` 의 코어.
|
||||||
|
*
|
||||||
|
* lot 하나와 원장 한 행을 만들고 지갑 잔액을 올린다. 셋이 한 트랜잭션이라
|
||||||
|
* 부분 반영이 남지 않는다.
|
||||||
|
*/
|
||||||
|
export async function grantPop(input: GrantInput, transaction?: Transaction): Promise<LedgerResult> {
|
||||||
|
const db = getFirestore();
|
||||||
|
const txId = txIdOf(input.idempotencyKey);
|
||||||
|
const lotId = lotIdOf(txId);
|
||||||
|
const pending = input.pending === true;
|
||||||
|
|
||||||
|
if (pending && input.popType !== 'activity') {
|
||||||
|
// 지원 POP 은 결제 확정분이라 검수 대기 상태가 존재하지 않는다.
|
||||||
|
fail(ERR.popVerifyFailed, '지원 POP 은 보류 지급 대상이 아닙니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const write = async (tx: Transaction): Promise<LedgerResult> => {
|
||||||
|
const wallet = await readWallet(tx, db, input.uid);
|
||||||
|
const txRef = db.doc(`users/${input.uid}/popTransactions/${txId}`);
|
||||||
|
const existing = await tx.get(txRef);
|
||||||
|
if (existing.exists) {
|
||||||
|
// 같은 멱등키의 재시도. 원장을 두 번 쓰지 않고 기존 결과를 그대로 돌려준다.
|
||||||
|
const prev = existing.data() ?? {};
|
||||||
|
return {
|
||||||
|
txId,
|
||||||
|
balanceAfter: (prev.balanceAfter as number | undefined) ?? 0,
|
||||||
|
lotRefs: (prev.lotRefs as LotRef[] | undefined) ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 지급 순간 귀속을 원장에 고정한다. 재시도는 위 existing 분기에서 끝난다.
|
||||||
|
const growthUser = input.growthSource && !pending
|
||||||
|
? await tx.get(db.doc(`users/${input.uid}`)) : null;
|
||||||
|
const fandomIdAtPayout = fandomOfUser(growthUser);
|
||||||
|
|
||||||
|
const current = input.popType === 'activity'
|
||||||
|
? wallet.activityBalance
|
||||||
|
: wallet.supportBalance;
|
||||||
|
const balanceAfter = pending ? current : current + input.amount;
|
||||||
|
const lotRefs: LotRef[] = [{ lotId, delta: input.amount }];
|
||||||
|
|
||||||
|
tx.create(db.doc(`users/${input.uid}/popLots/${lotId}`), {
|
||||||
|
popType: input.popType,
|
||||||
|
grantedAmount: input.amount,
|
||||||
|
remaining: input.amount,
|
||||||
|
grantedAt: FieldValue.serverTimestamp(),
|
||||||
|
status: pending ? 'pending' : 'active',
|
||||||
|
sourceTxId: txId,
|
||||||
|
reasonCode: input.reasonCode,
|
||||||
|
createdAt: FieldValue.serverTimestamp(),
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
|
||||||
|
tx.create(txRef, {
|
||||||
|
type: 'grant',
|
||||||
|
...(input.growthSource ? { fandomIdAtPayout, growthSource: input.growthSource } : {}),
|
||||||
|
popType: input.popType,
|
||||||
|
amount: input.amount,
|
||||||
|
reasonCode: input.reasonCode,
|
||||||
|
idempotencyKey: input.idempotencyKey,
|
||||||
|
...(input.refPath ? { refPath: input.refPath } : {}),
|
||||||
|
...(input.actorUid ? { actorUid: input.actorUid } : {}),
|
||||||
|
lotRefs,
|
||||||
|
balanceAfter,
|
||||||
|
// 검증 없는 지급 경로가 없다는 것을 원장 자체에 남긴다 — 별첨1 ② 판정 근거.
|
||||||
|
serverVerified: true,
|
||||||
|
pending,
|
||||||
|
createdAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (input.growthSource && !pending && fandomIdAtPayout && input.amount > 0) {
|
||||||
|
writeGrowth(tx, db, input.uid, txId, fandomIdAtPayout, input.amount, input.growthSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
// FPI 이벤트 · EXP 를 적는 콜백은 이 트랜잭션에서 읽은 사용자 문서가 필요하다.
|
||||||
|
input.alsoWrite?.(tx, growthUser);
|
||||||
|
|
||||||
|
tx.set(wallet.ref, {
|
||||||
|
[balanceField(input.popType)]: pending
|
||||||
|
? current
|
||||||
|
: FieldValue.increment(input.amount),
|
||||||
|
pendingActivity: pending
|
||||||
|
? FieldValue.increment(input.amount)
|
||||||
|
: wallet.pendingActivity,
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
...(wallet.exists ? {} : {
|
||||||
|
activityBalance: input.popType === 'activity' && !pending ? input.amount : 0,
|
||||||
|
supportBalance: input.popType === 'support' ? input.amount : 0,
|
||||||
|
pendingActivity: pending ? input.amount : 0,
|
||||||
|
reconcileStatus: 'ok',
|
||||||
|
createdAt: FieldValue.serverTimestamp(),
|
||||||
|
}),
|
||||||
|
}, { merge: true });
|
||||||
|
|
||||||
|
return { txId, balanceAfter, lotRefs };
|
||||||
|
};
|
||||||
|
return transaction ? write(transaction) : db.runTransaction(write);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 차감 한 행의 결과. 겸용 사용처에서 두 재화가 함께 쓰이면 두 행이다. */
|
||||||
|
export interface SpendPart extends LedgerResult {
|
||||||
|
popType: PopType;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 보류 지급 정산 — `grantPop({ pending: true })` 로 잡아 둔 금액을 **확정**하거나 **해제**한다.
|
||||||
|
*
|
||||||
|
* 원장은 append-only 라 원 보류 행을 고치지 않고 정산 행을 하나 더 쓴다. 정산 행 ID 가
|
||||||
|
* `{원 멱등키}:settle` 로 고정이라 확정과 해제가 둘 다 일어날 수 없고, 재시도는 기존 결과를 돌려준다.
|
||||||
|
* - confirm: lot `pending → active`, `pendingActivity -= n`, `activityBalance += n`, 정산 행 `grant`(n).
|
||||||
|
* - release: lot `pending → void`(잔량 0), `pendingActivity -= n`, 정산 행 `void`(0).
|
||||||
|
* 어느 쪽이든 `총 보유 = 예약(pendingActivity) + 사용 가능` 등식이 유지된다(QA 시험 사례 2-29).
|
||||||
|
*/
|
||||||
|
export async function settlePendingGrant(params: {
|
||||||
|
uid: string;
|
||||||
|
idempotencyKey: string;
|
||||||
|
outcome: 'confirm' | 'release';
|
||||||
|
}): Promise<LedgerResult> {
|
||||||
|
const db = getFirestore();
|
||||||
|
const originalTxId = txIdOf(params.idempotencyKey);
|
||||||
|
const settleTxId = txIdOf(`${params.idempotencyKey}:settle`);
|
||||||
|
const lotId = lotIdOf(originalTxId);
|
||||||
|
|
||||||
|
return db.runTransaction(async (tx) => {
|
||||||
|
const wallet = await readWallet(tx, db, params.uid);
|
||||||
|
const originalRef = db.doc(`users/${params.uid}/popTransactions/${originalTxId}`);
|
||||||
|
const settleRef = db.doc(`users/${params.uid}/popTransactions/${settleTxId}`);
|
||||||
|
const lotRef = db.doc(`users/${params.uid}/popLots/${lotId}`);
|
||||||
|
const [original, settled, lot] = await Promise.all([
|
||||||
|
tx.get(originalRef), tx.get(settleRef), tx.get(lotRef),
|
||||||
|
]);
|
||||||
|
if (settled.exists) {
|
||||||
|
const prev = settled.data() ?? {};
|
||||||
|
return {
|
||||||
|
txId: settleTxId,
|
||||||
|
balanceAfter: (prev.balanceAfter as number | undefined) ?? 0,
|
||||||
|
lotRefs: (prev.lotRefs as LotRef[] | undefined) ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!original.exists || original.get('pending') !== true || !lot.exists) {
|
||||||
|
fail(ERR.popVerifyFailed, '정산할 보류 지급이 없습니다.');
|
||||||
|
}
|
||||||
|
const amount = (original.get('amount') as number | undefined) ?? 0;
|
||||||
|
const confirm = params.outcome === 'confirm';
|
||||||
|
const balanceAfter = wallet.activityBalance + (confirm ? amount : 0);
|
||||||
|
const lotRefs: LotRef[] = [{ lotId, delta: confirm ? amount : 0 }];
|
||||||
|
|
||||||
|
tx.update(lotRef, confirm
|
||||||
|
? { status: 'active', updatedAt: FieldValue.serverTimestamp() }
|
||||||
|
: { status: 'void', remaining: 0, updatedAt: FieldValue.serverTimestamp() });
|
||||||
|
tx.create(settleRef, {
|
||||||
|
type: confirm ? 'grant' : 'void',
|
||||||
|
popType: 'activity',
|
||||||
|
amount: confirm ? amount : 0,
|
||||||
|
reasonCode: original.get('reasonCode'),
|
||||||
|
idempotencyKey: `${params.idempotencyKey}:settle`,
|
||||||
|
settlesTxId: originalTxId,
|
||||||
|
...(original.get('refPath') ? { refPath: original.get('refPath') } : {}),
|
||||||
|
lotRefs,
|
||||||
|
balanceAfter,
|
||||||
|
serverVerified: true,
|
||||||
|
pending: false,
|
||||||
|
createdAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
tx.set(wallet.ref, {
|
||||||
|
activityBalance: confirm ? FieldValue.increment(amount) : wallet.activityBalance,
|
||||||
|
pendingActivity: FieldValue.increment(-amount),
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
}, { merge: true });
|
||||||
|
return { txId: settleTxId, balanceAfter, lotRefs };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 차감 — `fn-pop-spend` 의 코어. 한 재화만 건드린다.
|
||||||
|
*
|
||||||
|
* 잔액이 모자라면 E-POP-01 로 끊는다. 같은 타입 안에서는 `grantedAt` 오름차순
|
||||||
|
* (선입선출)으로 lot 을 소진한다(설계서 §3.2 ②).
|
||||||
|
*/
|
||||||
|
export async function spendPop(input: SpendInput): Promise<LedgerResult> {
|
||||||
|
const [part] = await spendParts(input, [input.popType]);
|
||||||
|
return { txId: part!.txId, balanceAfter: part!.balanceAfter, lotRefs: part!.lotRefs };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 겸용 사용처 차감 — 정책 원장 **B-06** · 설계서 §3.2.
|
||||||
|
*
|
||||||
|
* 일반(activity) POP 을 먼저 소진하고 부족분만 기여(support) POP 에서 뺀다. 두 재화가
|
||||||
|
* 함께 쓰이면 원장을 `popType` 별로 **2건** 쓰되 한 트랜잭션이라 함께 서거나 함께 없다
|
||||||
|
* (혼합 차감 금지 불변식은 행 단위로 그대로다). 합계가 모자라면 E-POP-01 이고 행은 0건이다.
|
||||||
|
*
|
||||||
|
* 멱등키 하나가 두 행을 묶는다 — 일반 행은 `txIdOf(key)`, 기여 행은 `txIdOf(key:support)`.
|
||||||
|
* 재시도는 이미 있는 행을 그대로 돌려준다. 환불은 [refundSplit] 이 행마다 원래 타입으로 돌린다.
|
||||||
|
*/
|
||||||
|
export async function spendSplit(input: Omit<SpendInput, 'popType'>): Promise<SpendPart[]> {
|
||||||
|
return spendParts(input, ['activity', 'support']);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function spendParts(
|
||||||
|
input: Omit<SpendInput, 'popType'>, types: PopType[],
|
||||||
|
): Promise<SpendPart[]> {
|
||||||
|
if (!Number.isSafeInteger(input.amount) || input.amount <= 0) {
|
||||||
|
fail(ERR.popVerifyFailed, '차감액은 양의 정수여야 합니다.');
|
||||||
|
}
|
||||||
|
const db = getFirestore();
|
||||||
|
const txIds = types.map((t, i) =>
|
||||||
|
txIdOf(i === 0 ? input.idempotencyKey : `${input.idempotencyKey}:${t}`));
|
||||||
|
|
||||||
|
// lot 부족이 드러난 시도. 트랜잭션 안의 쓰기는 실패와 함께 되돌려지므로 대사 표시는
|
||||||
|
// 트랜잭션이 끝난 뒤 따로 쓴다.
|
||||||
|
let mismatch = false;
|
||||||
|
try {
|
||||||
|
return await db.runTransaction((tx) => {
|
||||||
|
mismatch = false;
|
||||||
|
return spendInTx(tx);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (mismatch) {
|
||||||
|
await walletRef(db, input.uid).set({
|
||||||
|
reconcileStatus: 'mismatch',
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
}, { merge: true });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function spendInTx(tx: Transaction): Promise<SpendPart[]> {
|
||||||
|
const txRefs = txIds.map((id) => db.doc(`users/${input.uid}/popTransactions/${id}`));
|
||||||
|
const prior = (await Promise.all(txRefs.map((ref) => tx.get(ref)))).filter((s) => s.exists);
|
||||||
|
if (prior.length > 0) {
|
||||||
|
// 같은 멱등키의 재시도. 모든 행을 한 트랜잭션에서 썼으므로 있는 것이 전부다.
|
||||||
|
return prior.map((s) => ({
|
||||||
|
txId: s.id,
|
||||||
|
popType: s.get('popType') as PopType,
|
||||||
|
amount: Math.abs((s.get('amount') as number | undefined) ?? 0),
|
||||||
|
balanceAfter: (s.get('balanceAfter') as number | undefined) ?? 0,
|
||||||
|
lotRefs: (s.get('lotRefs') as LotRef[] | undefined) ?? [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
await input.precheck?.(tx);
|
||||||
|
const wallet = await readWallet(tx, db, input.uid);
|
||||||
|
// 음수 잔액(대사 전의 어긋난 지갑)을 분할 몫으로 쓰지 않는다.
|
||||||
|
const balanceOf = (t: PopType) =>
|
||||||
|
Math.max(0, t === 'activity' ? wallet.activityBalance : wallet.supportBalance);
|
||||||
|
if (types.reduce((sum, t) => sum + balanceOf(t), 0) < input.amount) {
|
||||||
|
fail(ERR.popInsufficient, 'POP 잔액이 부족합니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 앞 타입부터 채우고 부족분만 다음 타입(B-06).
|
||||||
|
let need = input.amount;
|
||||||
|
const plan = types.map((popType, i) => {
|
||||||
|
const amount = Math.min(balanceOf(popType), need);
|
||||||
|
need -= amount;
|
||||||
|
return { popType, amount, txId: txIds[i]!, ref: txRefs[i]! };
|
||||||
|
}).filter((p) => p.amount > 0);
|
||||||
|
|
||||||
|
// 기여 차감이면 EXP·FPI 귀속을 정해야 하므로 사용자 문서를 함께 읽는다.
|
||||||
|
// 트랜잭션은 모든 읽기가 쓰기보다 앞서야 하므로 여기서 읽어 둔다.
|
||||||
|
const userSnap = CONTRIBUTION_REASONS.includes(input.reasonCode)
|
||||||
|
? await tx.get(db.doc(`users/${input.uid}`))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const lotsSnaps = await Promise.all(plan.map((p) => tx.get(
|
||||||
|
db.collection(`users/${input.uid}/popLots`)
|
||||||
|
.where('popType', '==', p.popType)
|
||||||
|
.where('status', '==', 'active')
|
||||||
|
.orderBy('grantedAt', 'asc'),
|
||||||
|
)));
|
||||||
|
|
||||||
|
const parts: SpendPart[] = [];
|
||||||
|
const lotWrites: Array<[DocumentReference, number]> = [];
|
||||||
|
plan.forEach((p, i) => {
|
||||||
|
let left = p.amount;
|
||||||
|
const lotRefs: LotRef[] = [];
|
||||||
|
for (const doc of lotsSnaps[i]!.docs) {
|
||||||
|
if (left === 0) break;
|
||||||
|
const remaining = (doc.get('remaining') as number | undefined) ?? 0;
|
||||||
|
if (remaining <= 0) continue;
|
||||||
|
const take = Math.min(remaining, left);
|
||||||
|
left -= take;
|
||||||
|
lotRefs.push({ lotId: doc.id, delta: -take });
|
||||||
|
lotWrites.push([doc.ref, remaining - take]);
|
||||||
|
}
|
||||||
|
if (left > 0) {
|
||||||
|
// 지갑 잔액은 충분한데 lot 합이 모자란 상태. 원장이 이미 어긋나 있다는 뜻이라
|
||||||
|
// 차감을 진행하지 않고 대사 대상으로 표시한다 — 자동 정정은 원인을 덮는다(§3.6).
|
||||||
|
mismatch = true;
|
||||||
|
fail(ERR.popVerifyFailed, 'POP 잔액과 획득 내역이 일치하지 않습니다.');
|
||||||
|
}
|
||||||
|
parts.push({
|
||||||
|
txId: p.txId, popType: p.popType, amount: p.amount,
|
||||||
|
balanceAfter: balanceOf(p.popType) - p.amount, lotRefs,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [ref, remaining] of lotWrites) {
|
||||||
|
tx.update(ref, {
|
||||||
|
remaining,
|
||||||
|
status: remaining === 0 ? 'exhausted' : 'active',
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.forEach((part, i) => {
|
||||||
|
tx.create(plan[i]!.ref, {
|
||||||
|
type: 'spend',
|
||||||
|
popType: part.popType,
|
||||||
|
// 부호 있는 값 — 차감은 음수(§3.3). 합산만으로 잔액이 재현되어야 한다.
|
||||||
|
amount: -part.amount,
|
||||||
|
reasonCode: input.reasonCode,
|
||||||
|
// 분할 차감의 두 행은 같은 멱등키를 적는다 — 한 요청이었음을 원장에서 묶어 본다.
|
||||||
|
idempotencyKey: input.idempotencyKey,
|
||||||
|
...(input.refPath ? { refPath: input.refPath } : {}),
|
||||||
|
// 묶음 횟수. 없으면 적지 않는다 — 읽는 쪽이 「없으면 1 회」로 셀 수 있다.
|
||||||
|
...(input.count !== undefined ? { count: input.count } : {}),
|
||||||
|
lotRefs: part.lotRefs,
|
||||||
|
balanceAfter: part.balanceAfter,
|
||||||
|
serverVerified: true,
|
||||||
|
// 규범 확정(B-06) 뒤의 행임을 남긴다. 과거 행은 'grantedAt-asc-nonnormative'.
|
||||||
|
lotOrder: 'general-first-fifo',
|
||||||
|
createdAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
tx.set(wallet.ref, {
|
||||||
|
[balanceField(part.popType)]: FieldValue.increment(-part.amount),
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
}, { merge: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Zap 의 귀속 팬덤은 **서버가 이 트랜잭션에서 읽은 대표 팬덤**이다(FPI 와 같은 판정).
|
||||||
|
// 클라이언트 `fandomId` 를 믿으면 남의 행성 성장을 올린다. 후원은 호출자(fundCampaign)가
|
||||||
|
// 캠페인 문서에서 정한 팬덤을 넘긴다.
|
||||||
|
const fandomId = input.reasonCode === 'ZAP_CONTRIBUTION' ? fandomOfUser(userSnap) : input.fandomId;
|
||||||
|
if (CONTRIBUTION_REASONS.includes(input.reasonCode)) {
|
||||||
|
// 누적 기여 POP 은 Passport 입력 · 기여 실적 표시용으로 유지한다. 등급 문턱은
|
||||||
|
// 더 이상 여기서 매기지 않는다(C-18 — 누적 기여 POP 5등급 치환 폐기).
|
||||||
|
tx.set(db.doc(`users/${input.uid}`), {
|
||||||
|
totalContributedPop: FieldValue.increment(input.amount),
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
}, { merge: true });
|
||||||
|
if (fandomId) {
|
||||||
|
tx.set(db.doc(`fandoms/${fandomId}/members/${input.uid}`), {
|
||||||
|
contributedPop: FieldValue.increment(input.amount),
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
}, { merge: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 예치(QNA_BOUNTY) · 등록비(CAMPAIGN_FEE) 등 다른 차감은 성장 · EXP · FPI 로 확대하지
|
||||||
|
// 않는다(C-30). 기여 POP 으로 냈어도 같다 — 원천을 가르는 것은 재화가 아니라 사유다.
|
||||||
|
const growthSpend = input.reasonCode === 'ZAP_CONTRIBUTION' || input.reasonCode === 'CAMPAIGN_FUND';
|
||||||
|
const sourceId = parts[0]!.txId;
|
||||||
|
if (growthSpend) {
|
||||||
|
// FPI 는 기여 **행위 단위**다(C-34). 금액이 아니라 Zap 횟수(`fn-pop-spend` 가 서버에서
|
||||||
|
// 금액 ÷ zapPop 으로 계산) · 후원 1건. EXP 는 주지 않는다 — POP 을 쓰는 행위는 1 POP
|
||||||
|
// 단위로 쪼개 반복할 수 있어 건당 EXP 가 파밍된다(C-18 · 리뷰 B1).
|
||||||
|
writeFpiEvent(tx, {
|
||||||
|
uid: input.uid, fandomId: fandomOfUser(userSnap), axis: 'contribution',
|
||||||
|
units: input.reasonCode === 'ZAP_CONTRIBUTION' ? input.count ?? 1 : 1,
|
||||||
|
activity: input.reasonCode === 'ZAP_CONTRIBUTION' ? 'zap' : 'campaign_fund',
|
||||||
|
sourceId, occurredAt: new Date(),
|
||||||
|
...(input.refPath ? { refPath: input.refPath } : {}),
|
||||||
|
grantsExp: false,
|
||||||
|
}, userSnap);
|
||||||
|
}
|
||||||
|
if (fandomId && growthSpend) {
|
||||||
|
writeGrowth(tx, db, input.uid, sourceId, fandomId, input.amount,
|
||||||
|
input.reasonCode === 'ZAP_CONTRIBUTION' ? 'zap' : 'funding');
|
||||||
|
tx.set(db.doc(`fandoms/${fandomId}`), {
|
||||||
|
lastContributorName: publicNameOf(userSnap),
|
||||||
|
lastContributedPop: input.amount,
|
||||||
|
}, { merge: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
input.alsoWrite?.(tx, parts);
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type RefundReason = Extract<ReasonCode,
|
||||||
|
'CAMPAIGN_REFUND' | 'QNA_REFUND' | 'CAMPAIGN_FEE_REFUND' | 'QUEST_POOL_REFUND'>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 환불 — `fn-pop-refund` 의 코어.
|
||||||
|
*
|
||||||
|
* 원 거래를 되돌리는 것이 아니라 **반대 부호의 새 원장**을 쓴다. 원장이 진실이라는
|
||||||
|
* 원칙(§3.3)을 지키려면 과거 행이 그대로 남아 있어야 한다. 다른 멱등키로 이미 환불된
|
||||||
|
* 원거래는 E-POP-03 이다 — 호출자가 그 환불(`refundedBy`)을 받아들일지 정한다.
|
||||||
|
*/
|
||||||
|
export async function refundPop(params: {
|
||||||
|
uid: string;
|
||||||
|
originalTxId: string;
|
||||||
|
idempotencyKey: string;
|
||||||
|
reasonCode: RefundReason;
|
||||||
|
}): Promise<LedgerResult> {
|
||||||
|
const [result] = await refundParts(
|
||||||
|
params.uid, [params.originalTxId], params.idempotencyKey, params.reasonCode, false);
|
||||||
|
return result!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 분할 차감([spendSplit])의 환불 — 행마다 **원래 타입 그대로** 돌린다(B-39 「원타입 환불」).
|
||||||
|
*
|
||||||
|
* 모든 행을 한 트랜잭션에서 돌려주므로 일부만 환불된 상태가 남지 않는다. 멱등키 하나가
|
||||||
|
* 전체를 묶는다 — 첫 행은 `txIdOf(key)`, 나머지는 `txIdOf(key:원거래ID)`. 같은 원거래 ID 는
|
||||||
|
* 한 번만 센다. 다른 경로가 이미 돌려준 행은 다시 돌려주지 않고 그 환불 ID 를 결과로 쓴다.
|
||||||
|
*/
|
||||||
|
export async function refundSplit(params: {
|
||||||
|
uid: string;
|
||||||
|
originalTxIds: string[];
|
||||||
|
idempotencyKey: string;
|
||||||
|
reasonCode: RefundReason;
|
||||||
|
}): Promise<LedgerResult[]> {
|
||||||
|
return refundParts(params.uid, params.originalTxIds, params.idempotencyKey, params.reasonCode, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refundParts(
|
||||||
|
uid: string, originalTxIds: string[], idempotencyKey: string, reasonCode: RefundReason,
|
||||||
|
reuseRefunded: boolean,
|
||||||
|
): Promise<LedgerResult[]> {
|
||||||
|
const ids = [...new Set(originalTxIds)];
|
||||||
|
if (ids.length === 0) fail(ERR.popVerifyFailed, '환불할 원거래가 없습니다.');
|
||||||
|
const db = getFirestore();
|
||||||
|
const txIds = ids.map((orig, i) =>
|
||||||
|
txIdOf(i === 0 ? idempotencyKey : `${idempotencyKey}:${orig}`));
|
||||||
|
const rowOf = (s: FirebaseFirestore.DocumentSnapshot): LedgerResult => ({
|
||||||
|
txId: s.id,
|
||||||
|
balanceAfter: (s.get('balanceAfter') as number | undefined) ?? 0,
|
||||||
|
lotRefs: (s.get('lotRefs') as LotRef[] | undefined) ?? [],
|
||||||
|
});
|
||||||
|
|
||||||
|
return db.runTransaction(async (tx) => {
|
||||||
|
const txRefs = txIds.map((id) => db.doc(`users/${uid}/popTransactions/${id}`));
|
||||||
|
const originalRefs = ids.map((id) => db.doc(`users/${uid}/popTransactions/${id}`));
|
||||||
|
const [own, originals] = await Promise.all([
|
||||||
|
Promise.all(txRefs.map((ref) => tx.get(ref))),
|
||||||
|
Promise.all(originalRefs.map((ref) => tx.get(ref))),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 이 키로 이미 쓴 행은 그대로(재시도), 다른 환불이 먼저 간 행은 그 환불을 쓴다.
|
||||||
|
const results: Array<LedgerResult | null> = own.map((s) => (s.exists ? rowOf(s) : null));
|
||||||
|
const todo: number[] = [];
|
||||||
|
for (const [i, original] of originals.entries()) {
|
||||||
|
if (results[i]) continue;
|
||||||
|
if (!original.exists) {
|
||||||
|
fail(ERR.popVerifyFailed, '환불할 원거래를 찾을 수 없습니다.');
|
||||||
|
}
|
||||||
|
if (original.get('type') !== 'spend') {
|
||||||
|
fail(ERR.popVerifyFailed, '차감 거래만 환불할 수 있습니다.');
|
||||||
|
}
|
||||||
|
const refundedBy = original.get('refundedBy') as string | undefined;
|
||||||
|
if (!refundedBy) {
|
||||||
|
todo.push(i);
|
||||||
|
} else if (!reuseRefunded) {
|
||||||
|
fail(ERR.popDuplicate, '이미 환불된 거래입니다.');
|
||||||
|
} else {
|
||||||
|
results[i] = rowOf(await tx.get(db.doc(`users/${uid}/popTransactions/${refundedBy}`)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (todo.length === 0) return results as LedgerResult[];
|
||||||
|
|
||||||
|
const wallet = await readWallet(tx, db, uid);
|
||||||
|
const running = { activity: wallet.activityBalance, support: wallet.supportBalance };
|
||||||
|
|
||||||
|
let contributed = 0;
|
||||||
|
for (const i of todo) {
|
||||||
|
const original = originals[i]!;
|
||||||
|
const popType = original.get('popType') as PopType;
|
||||||
|
const amount = Math.abs(original.get('amount') as number);
|
||||||
|
running[popType] += amount;
|
||||||
|
const balanceAfter = running[popType];
|
||||||
|
const txId = txIds[i]!;
|
||||||
|
const lotId = lotIdOf(txId);
|
||||||
|
|
||||||
|
tx.create(db.doc(`users/${uid}/popLots/${lotId}`), {
|
||||||
|
popType,
|
||||||
|
grantedAmount: amount,
|
||||||
|
remaining: amount,
|
||||||
|
grantedAt: FieldValue.serverTimestamp(),
|
||||||
|
status: 'active',
|
||||||
|
sourceTxId: txId,
|
||||||
|
reasonCode,
|
||||||
|
createdAt: FieldValue.serverTimestamp(),
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
|
||||||
|
tx.create(txRefs[i]!, {
|
||||||
|
type: 'refund',
|
||||||
|
popType,
|
||||||
|
amount,
|
||||||
|
reasonCode,
|
||||||
|
idempotencyKey,
|
||||||
|
refPath: originalRefs[i]!.path,
|
||||||
|
lotRefs: [{ lotId, delta: amount }],
|
||||||
|
balanceAfter,
|
||||||
|
serverVerified: true,
|
||||||
|
createdAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 원 거래에는 환불 사실만 표시한다. 금액·부호는 손대지 않는다(append-only).
|
||||||
|
tx.update(originalRefs[i]!, { refundedBy: txId });
|
||||||
|
|
||||||
|
tx.set(wallet.ref, {
|
||||||
|
[balanceField(popType)]: FieldValue.increment(amount),
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
}, { merge: true });
|
||||||
|
|
||||||
|
if (CONTRIBUTION_REASONS.includes(original.get('reasonCode') as ReasonCode)) contributed += amount;
|
||||||
|
results[i] = { txId, balanceAfter, lotRefs: [{ lotId, delta: amount }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contributed > 0) {
|
||||||
|
// 반환은 Growth · EXP 를 만들지도 되돌리지도 않는다(C-30 · C-32).
|
||||||
|
tx.set(db.doc(`users/${uid}`), {
|
||||||
|
totalContributedPop: FieldValue.increment(-contributed),
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
}, { merge: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return results as LedgerResult[];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 성장 역분개 — 정책 원장 **C-32**.
|
||||||
|
*
|
||||||
|
* 허위 참여 · 중복 지급 · 오지급이 **확정**된 뒤 운영자가 부르는 경로다. 원 growth
|
||||||
|
* 이벤트를 참조하는 음수 행을 새로 쓰고 `fandoms.growthPop` 을 그만큼 줄인다(append-only).
|
||||||
|
* 잘못 반영된 몫만 정정하므로 `amount` 는 원 이벤트 이하다. 원 이벤트 하나에 역분개는
|
||||||
|
* 한 번뿐이다(문서 ID `rev-{원 이벤트}`) — 재시도는 기존 결과를 돌려준다.
|
||||||
|
*
|
||||||
|
* Quest 종료 · 취소 · 의심 단계는 이 경로를 부르지 않는다. 지갑 회수 · EXP · 과거
|
||||||
|
* FPI/sFPI 재산정과도 분리한다.
|
||||||
|
*/
|
||||||
|
export async function reverseGrowth(params: {
|
||||||
|
uid: string; sourceEventId: string; amount: number; reason: string; actorUid: string;
|
||||||
|
}): Promise<{ reversalId: string; fandomId: string; amount: number }> {
|
||||||
|
const db = getFirestore();
|
||||||
|
const reversalId = `rev-${params.sourceEventId}`;
|
||||||
|
return db.runTransaction(async (tx) => {
|
||||||
|
const originalRef = db.doc(`users/${params.uid}/growthEvents/${params.sourceEventId}`);
|
||||||
|
const reversalRef = db.doc(`users/${params.uid}/growthEvents/${reversalId}`);
|
||||||
|
const [original, existing] = await Promise.all([tx.get(originalRef), tx.get(reversalRef)]);
|
||||||
|
if (existing.exists) {
|
||||||
|
return {
|
||||||
|
reversalId,
|
||||||
|
fandomId: existing.get('fandomIdAtPayout') as string,
|
||||||
|
amount: -(existing.get('amount') as number),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const originalAmount = original.get('amount');
|
||||||
|
const fandomId = original.get('fandomIdAtPayout');
|
||||||
|
if (!original.exists || original.get('reversalOf') != null ||
|
||||||
|
typeof originalAmount !== 'number' || originalAmount <= 0 || typeof fandomId !== 'string') {
|
||||||
|
fail(ERR.popVerifyFailed, '역분개할 성장 기록을 찾을 수 없습니다.');
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(params.amount) || params.amount <= 0 || params.amount > originalAmount) {
|
||||||
|
fail(ERR.popVerifyFailed, '역분개 수치는 원 성장량 이하의 양의 정수여야 합니다.');
|
||||||
|
}
|
||||||
|
// Quest 보상 전액 역분개 = 그 완료의 사후 무효 → 원장 행에 걸린 검증 활동 표식을 무효로
|
||||||
|
// (Campaign 개설자격 A안). 일부 정정은 금액 오류라 완료 자체는 유효하다. 읽기라 쓰기보다 앞선다.
|
||||||
|
const voidWrite = original.get('sourceType') === 'quest_reward' && params.amount === originalAmount
|
||||||
|
? await voidCompletions(tx, params.uid, { txId: original.get('sourceEventId') ?? params.sourceEventId })
|
||||||
|
: null;
|
||||||
|
tx.create(reversalRef, {
|
||||||
|
sourceEventId: original.get('sourceEventId') ?? params.sourceEventId,
|
||||||
|
sourceType: original.get('sourceType'),
|
||||||
|
fandomIdAtPayout: fandomId,
|
||||||
|
amount: -params.amount,
|
||||||
|
reversalOf: params.sourceEventId,
|
||||||
|
reason: params.reason,
|
||||||
|
actorUid: params.actorUid,
|
||||||
|
createdAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
tx.set(db.doc(`fandoms/${fandomId}`), {
|
||||||
|
growthPop: FieldValue.increment(-params.amount),
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
}, { merge: true });
|
||||||
|
voidWrite?.(tx);
|
||||||
|
return { reversalId, fandomId, amount: params.amount };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 대사 결과 한 사용자분(§3.6). */
|
||||||
|
export interface ReconcileRow {
|
||||||
|
uid: string;
|
||||||
|
ok: boolean;
|
||||||
|
checks: Array<{ name: string; expected: number; actual: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 잔액 대조 — 별첨 1 ② 검수 방법의 3자 교차 대조.
|
||||||
|
*
|
||||||
|
* **자동으로 고치지 않는다.** 감지하고 표시만 한다 — 자동 정정은 원인을 덮는다.
|
||||||
|
*/
|
||||||
|
export async function reconcileUser(uid: string): Promise<ReconcileRow> {
|
||||||
|
const db = getFirestore();
|
||||||
|
const [walletSnap, lotsSnap, txSnap, userSnap] = await Promise.all([
|
||||||
|
walletRef(db, uid).get(),
|
||||||
|
db.collection(`users/${uid}/popLots`).where('status', '==', 'active').get(),
|
||||||
|
db.collection(`users/${uid}/popTransactions`).get(),
|
||||||
|
db.doc(`users/${uid}`).get(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const wallet = walletSnap.data() ?? {};
|
||||||
|
const checks: ReconcileRow['checks'] = [];
|
||||||
|
|
||||||
|
for (const t of ['activity', 'support'] as const) {
|
||||||
|
const lotSum = lotsSnap.docs
|
||||||
|
.filter((d) => d.get('popType') === t)
|
||||||
|
.reduce((acc, d) => acc + ((d.get('remaining') as number) ?? 0), 0);
|
||||||
|
const txSum = txSnap.docs
|
||||||
|
.filter((d) => d.get('popType') === t && d.get('pending') !== true)
|
||||||
|
.reduce((acc, d) => acc + ((d.get('amount') as number) ?? 0), 0);
|
||||||
|
const balance = (wallet[balanceField(t)] as number | undefined) ?? 0;
|
||||||
|
checks.push({ name: `${t}:balance-vs-lot`, expected: lotSum, actual: balance });
|
||||||
|
checks.push({ name: `${t}:balance-vs-ledger`, expected: txSum, actual: balance });
|
||||||
|
}
|
||||||
|
|
||||||
|
const contributionSpends = txSnap.docs.filter((d) =>
|
||||||
|
d.get('type') === 'spend' &&
|
||||||
|
CONTRIBUTION_REASONS.includes(d.get('reasonCode') as ReasonCode));
|
||||||
|
const contributed = contributionSpends
|
||||||
|
.reduce((acc, d) => acc + Math.abs((d.get('amount') as number) ?? 0), 0);
|
||||||
|
// 기여가 아닌 차감(등록비 등)의 환불은 누적 기여를 줄이지 않는다 — refundParts 와 같은 판정.
|
||||||
|
const contributionPaths = new Set(contributionSpends.map((d) => d.ref.path));
|
||||||
|
const refunded = txSnap.docs
|
||||||
|
.filter((d) => d.get('type') === 'refund' && contributionPaths.has(d.get('refPath') as string))
|
||||||
|
.reduce((acc, d) => acc + ((d.get('amount') as number) ?? 0), 0);
|
||||||
|
const statsActual =
|
||||||
|
(userSnap.get('totalContributedPop') as number | undefined) ?? 0;
|
||||||
|
checks.push({
|
||||||
|
name: 'ledger-vs-stats',
|
||||||
|
expected: contributed - refunded,
|
||||||
|
actual: statsActual,
|
||||||
|
});
|
||||||
|
|
||||||
|
// User Level ↔ EXP, Passport Tier 단조(설계서 §3.4 · B-65 · B-66). 두 체계를 따로 본다.
|
||||||
|
const storedLevel = userSnap.get('userLevel') as number | undefined;
|
||||||
|
if (storedLevel !== undefined) {
|
||||||
|
checks.push({
|
||||||
|
name: 'level-vs-exp',
|
||||||
|
expected: levelOf((userSnap.get('userExp') as number | undefined) ?? 0),
|
||||||
|
actual: storedLevel,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const storedPassport = userSnap.get('passportTier') as PassportTier | undefined;
|
||||||
|
if (storedPassport !== undefined) {
|
||||||
|
// 레벨이 요구하는 등급보다 낮게 저장돼 있으면(강등 · 누락) 불일치다.
|
||||||
|
checks.push({
|
||||||
|
name: 'passport-monotonic',
|
||||||
|
// 레벨 위조가 이 검사까지 오염시키지 않도록 저장된 레벨이 아니라 EXP 로 본다.
|
||||||
|
expected: passportRank(passportTierOf(
|
||||||
|
levelOf((userSnap.get('userExp') as number | undefined) ?? 0), storedPassport)),
|
||||||
|
actual: passportRank(storedPassport),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = checks.every((c) => c.expected === c.actual);
|
||||||
|
await walletRef(db, uid).set({
|
||||||
|
lastReconciledAt: Timestamp.now(),
|
||||||
|
reconcileStatus: ok ? 'ok' : 'mismatch',
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
}, { merge: true });
|
||||||
|
|
||||||
|
return { uid, ok, checks };
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { getMessaging } from 'firebase-admin/messaging';
|
||||||
|
import { Timestamp, type DocumentSnapshot } from 'firebase-admin/firestore';
|
||||||
|
import { logger } from 'firebase-functions';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FCM 발송 — 정책 원장 B-77 「Push 알림」 5개 구분의 서버 쪽 절반.
|
||||||
|
*
|
||||||
|
* **구분별로 토픽을 따로 둔다.** 한 팬덤 토픽에 다섯 구분을 몰아 보내면 사용자가
|
||||||
|
* 「AI Briefing 만 끄기」를 골랐을 때 서버가 그것을 걸러 낼 방법이 없다. FCM 토픽
|
||||||
|
* 발송은 수신자 목록을 서버가 만들지 않기 때문이다. 구분마다 토픽을 나누고 앱이
|
||||||
|
* 자기 설정대로 구독·해지하면 **OFF 구분의 발송은 구조적으로 0 건**이 된다
|
||||||
|
* (B-77 검증 기준 「OFF 카테고리 발송 0건」).
|
||||||
|
*
|
||||||
|
* `marketing` 은 앱이 기본 OFF 로 두어 구독하지 않는다(별첨 B 「별도 동의 기본 OFF」).
|
||||||
|
*/
|
||||||
|
export const PUSH_CATEGORIES = [
|
||||||
|
'essential', 'fandomEvent', 'briefing', 'community', 'marketing',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type PushCategory = typeof PUSH_CATEGORIES[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FCM 토픽 이름은 `[a-zA-Z0-9-_.~%]+` 만 받는다. 팬덤 ID 에 그 밖의 문자가 섞이면
|
||||||
|
* 발송이 `invalid-argument` 로 떨어지는데, 오류 문구가 토픽을 가리키지 않아
|
||||||
|
* 원인을 찾기 어렵다. 그래서 여기서 한 번 걸러 둔다.
|
||||||
|
*/
|
||||||
|
export function fandomTopic(fandomId: string, category: PushCategory): string {
|
||||||
|
return `fandom_${fandomId.replace(/[^a-zA-Z0-9\-_.~%]/g, '-')}_${category}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PushMessage {
|
||||||
|
fandomId: string;
|
||||||
|
category: PushCategory;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
/** 앱이 탭 시 어디로 갈지 판단하는 값. 문자열만 실을 수 있다(FCM 규약). */
|
||||||
|
data?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 팬덤 토픽 한 건 발송.
|
||||||
|
*
|
||||||
|
* **발송 실패가 호출자를 깨뜨리지 않게 한다.** 브리핑 게시와 이클립스 집계가
|
||||||
|
* 푸시 실패로 롤백되면 데이터는 맞는데 화면이 비는 상태가 된다. 실패는 로그로
|
||||||
|
* 남기고 `false` 를 돌려준다.
|
||||||
|
*/
|
||||||
|
export async function sendFandomPush(message: PushMessage): Promise<boolean> {
|
||||||
|
const topic = fandomTopic(message.fandomId, message.category);
|
||||||
|
try {
|
||||||
|
const id = await getMessaging().send({
|
||||||
|
topic,
|
||||||
|
notification: { title: message.title, body: message.body },
|
||||||
|
data: { category: message.category, fandomId: message.fandomId, ...message.data },
|
||||||
|
android: { priority: 'high' },
|
||||||
|
// iOS 는 APNs 키가 들어오기 전까지 실제로 전달되지 않는다(별첨 A 계정 이전
|
||||||
|
// 대기). 코드 경로는 지금 맞춰 두어야 키가 들어온 날 수정이 없다.
|
||||||
|
apns: { payload: { aps: { sound: 'default' } } },
|
||||||
|
});
|
||||||
|
logger.info('[push] 발송', { topic, messageId: id });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[push] 발송 실패', { topic, message: (error as Error).message });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이클립스 사전 알림 — 원장 **C-22** 「발생 3분 전 푸시」 · C-24 「푸시 = 시작 − 3분」.
|
||||||
|
*
|
||||||
|
* `fn-eclipse-trigger` 가 회차의 `warnAt`(시작 − `warnSeconds`) 이 지난 첫 점검에서
|
||||||
|
* 부른다. 활성화 뒤에 보내면 예고가 아니라 사후 통지가 된다.
|
||||||
|
*
|
||||||
|
* - `alertPushedAt` 이 있으면 보내지 않는다 — 1 분 주기 점검이 도배하지 않는다.
|
||||||
|
* - 시작 시각이 이미 지났으면 보내지 않고 표시만 남긴다 — 배포 직후 첫 점검이나
|
||||||
|
* 스케줄러 지연으로 늦게 본 회차에 「곧 시작」을 소급 발송하지 않는다.
|
||||||
|
* - 취소된 회차(C-28)에는 보내지 않는다.
|
||||||
|
*
|
||||||
|
* `alertPushedAt` 에 **판정 시각 [now]** 를 적는다 — 발송 시각 = 시작 − 3분을
|
||||||
|
* 회차 문서만으로 검증할 수 있게 한다.
|
||||||
|
*/
|
||||||
|
export type EclipseAlertOutcome =
|
||||||
|
/** 예고를 보냈다. */
|
||||||
|
| 'sent'
|
||||||
|
/** 보내려 했으나 FCM 발송이 실패했다. 표시는 남겼다. */
|
||||||
|
| 'failed'
|
||||||
|
/** 이미 보냈거나 보낼 대상이 아니다(없음 · 취소). */
|
||||||
|
| 'already'
|
||||||
|
/** 예고 시각 · 시작 시각을 몰라 보내지 않았다. */
|
||||||
|
| 'no-start'
|
||||||
|
/** 아직 예고 시각 전이다. */
|
||||||
|
| 'too-early'
|
||||||
|
/** 시작 시각이 지나 예고로서 의미가 없다. */
|
||||||
|
| 'too-old';
|
||||||
|
|
||||||
|
export async function notifyEclipseWarning(
|
||||||
|
eclipse: DocumentSnapshot,
|
||||||
|
now: Date = new Date(),
|
||||||
|
): Promise<EclipseAlertOutcome> {
|
||||||
|
if (!eclipse.exists || eclipse.get('alertPushedAt') || eclipse.get('status') === 'cancelled') {
|
||||||
|
return 'already';
|
||||||
|
}
|
||||||
|
const warnAt = eclipse.get('warnAt') as Timestamp | undefined;
|
||||||
|
const startAt = eclipse.get('startAt') as Timestamp | undefined;
|
||||||
|
if (!warnAt || !startAt) {
|
||||||
|
logger.debug('[push] 예고 시각이 없어 보내지 않습니다', { path: eclipse.ref.path });
|
||||||
|
return 'no-start';
|
||||||
|
}
|
||||||
|
if (now.getTime() < warnAt.toMillis()) return 'too-early';
|
||||||
|
const mark = () => eclipse.ref.set({ alertPushedAt: Timestamp.fromDate(now) }, { merge: true });
|
||||||
|
if (now.getTime() >= startAt.toMillis()) {
|
||||||
|
await mark();
|
||||||
|
logger.info('[push] 시작 시각이 지나 예고를 건너뜁니다', { path: eclipse.ref.path });
|
||||||
|
return 'too-old';
|
||||||
|
}
|
||||||
|
|
||||||
|
// `fandoms/{fandomId}/eclipseRounds/{roundId}` 이므로 조부모가 팬덤 문서다.
|
||||||
|
const fandomRef = eclipse.ref.parent.parent;
|
||||||
|
if (!fandomRef) {
|
||||||
|
logger.error('[push] 이클립스 경로가 팬덤 아래가 아닙니다', { path: eclipse.ref.path });
|
||||||
|
return 'no-start';
|
||||||
|
}
|
||||||
|
const name = ((await fandomRef.get()).get('name') as string | undefined) ?? fandomRef.id;
|
||||||
|
const minutes = Math.max(1, Math.round((startAt.toMillis() - now.getTime()) / 60_000));
|
||||||
|
|
||||||
|
const sent = await sendFandomPush({
|
||||||
|
fandomId: fandomRef.id,
|
||||||
|
category: 'fandomEvent',
|
||||||
|
title: '이클립스가 곧 시작됩니다',
|
||||||
|
body: `${minutes}분 뒤 ${name} 행성에 이클립스가 찾아옵니다. 함께 흔들어 행성을 지켜 주세요.`,
|
||||||
|
data: { kind: 'eclipse', eclipseId: eclipse.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 발송 성공 여부와 무관하게 표시한다. 실패를 이유로 남겨 두면 1 분마다 같은
|
||||||
|
// 실패를 반복하고, 복구된 순간 지난 이클립스 예고가 한꺼번에 나간다.
|
||||||
|
await mark();
|
||||||
|
return sent ? 'sent' : 'failed';
|
||||||
|
}
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
import { onCall } from 'firebase-functions/v2/https';
|
||||||
|
import { getFirestore, FieldValue, Timestamp } from 'firebase-admin/firestore';
|
||||||
|
import { logger } from 'firebase-functions';
|
||||||
|
|
||||||
|
import { callerUid, optionalStr, requireRole, str } from '../core/callable.js';
|
||||||
|
import { txIdOf } from '../core/ids.js';
|
||||||
|
import { dayKey } from '../core/time.js';
|
||||||
|
import { dropConfig, dropDocument, publicNickname } from '../fandom/herodrop.js';
|
||||||
|
import { loadPolicy } from '../core/config.js';
|
||||||
|
import { ERR, fail } from '../core/errors.js';
|
||||||
|
import { grantPop, spendPop } from '../ledger/ledger.js';
|
||||||
|
import {
|
||||||
|
popForProduct,
|
||||||
|
StoreReceiptVerifier,
|
||||||
|
SUBSCRIPTION_PRODUCTS,
|
||||||
|
type Platform,
|
||||||
|
type ReceiptClaim,
|
||||||
|
type ReceiptVerifier,
|
||||||
|
} from './receipt.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 결제 — 별첨 1 ⑥ 「영수증 서버검증 통과(위조 차단)」·「환불 결제 건 지급 회수」.
|
||||||
|
*
|
||||||
|
* 세 가지를 지킨다.
|
||||||
|
* 1. **금액은 서버가 정한다.** 클라이언트가 보낸 수량을 쓰지 않고 상품 ID 로 표를 본다.
|
||||||
|
* 2. **멱등키는 스토어 거래 ID 다.** 같은 구매가 두 번 지급되지 않는다.
|
||||||
|
* 3. **환불은 회수한다.** 잔액이 모자라면 음수 잔액을 만들지 않고 회수 대기로 남긴다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let verifier: ReceiptVerifier = new StoreReceiptVerifier();
|
||||||
|
|
||||||
|
/** 테스트에서 스토어 호출을 대역으로 바꾼다. */
|
||||||
|
export function setReceiptVerifier(next: ReceiptVerifier): void {
|
||||||
|
verifier = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLATFORMS: readonly Platform[] = ['ios', 'android'];
|
||||||
|
|
||||||
|
function platformOf(data: unknown): Platform {
|
||||||
|
const value = str(data, 'platform');
|
||||||
|
if (!PLATFORMS.includes(value as Platform)) {
|
||||||
|
fail(ERR.payInvalidReceipt, 'platform 은 ios 또는 android 여야 합니다.');
|
||||||
|
}
|
||||||
|
return value as Platform;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 스토어 거래 ID 를 원장 멱등키로 옮긴다 — 설계서 §3.3 의 IAP 키 구성. */
|
||||||
|
const iapKey = (claim: ReceiptClaim) =>
|
||||||
|
`iap:${claim.platform}:${claim.transactionId}`;
|
||||||
|
|
||||||
|
/** POP 묶음 상품 ID 접두 — 원장 B-02 로 기본 판매 중지(`popPackSalesEnabled`). */
|
||||||
|
const POP_PACK_PREFIX = 'pop.support.';
|
||||||
|
|
||||||
|
const PAY_METHOD: Readonly<Record<Platform, string>> = {
|
||||||
|
android: 'Google Play',
|
||||||
|
ios: 'App Store',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface HeroGiftProduct { name: string; buyerPop: number; dropPop: number }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hero Gift 상품 Catalog — 원장 B-85. 문서 ID 가 스토어 상품 ID 다.
|
||||||
|
*
|
||||||
|
* Admin `관리 마스터_아이템 관리` 가 `fnAdminWrite` 로 쓴다. 상품을 늘려도 앱을
|
||||||
|
* 다시 배포하지 않는다(B-40 검증 기준). Catalog 에 없으면 null — 일반 상품 표로 넘어간다.
|
||||||
|
*/
|
||||||
|
async function heroGiftProduct(productId: string): Promise<HeroGiftProduct | null> {
|
||||||
|
const snap = await getFirestore().doc(`heroGiftProducts/${productId}`).get();
|
||||||
|
if (!snap.exists) return null;
|
||||||
|
if (snap.get('active') !== true) {
|
||||||
|
fail(ERR.payInvalidReceipt, '판매가 끝난 상품입니다.');
|
||||||
|
}
|
||||||
|
const buyerPop = snap.get('buyerPop');
|
||||||
|
const dropPop = snap.get('dropPop');
|
||||||
|
if (!Number.isSafeInteger(buyerPop) || buyerPop <= 0 ||
|
||||||
|
!Number.isSafeInteger(dropPop) || dropPop <= 0) {
|
||||||
|
// Admin 확정값이 없으면 지급량을 지어내지 않는다(B-84 「Admin 저장값과 정확 일치」).
|
||||||
|
fail(ERR.payVerifyFailed, '상품 지급량이 아직 설정되지 않았습니다.');
|
||||||
|
}
|
||||||
|
if (buyerPop !== dropPop) {
|
||||||
|
// B-84 — 구매자 기여 POP 과 팬덤 Drop Pool 일반 POP 은 **같은 수량**이다. 어긋난 Catalog
|
||||||
|
// 값으로 지급하지 않는다. Admin 이 고치면 다음 재전송에서 지급된다(재시도 가능한 E-PAY-02).
|
||||||
|
fail(ERR.payVerifyFailed, '상품 지급량이 정책(구매자 = Drop Pool 동일 수량)과 다릅니다.');
|
||||||
|
}
|
||||||
|
const name = snap.get('name');
|
||||||
|
return { name: typeof name === 'string' ? name : productId, buyerPop, dropPop };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 거래 ID 를 한 계정에 묶는다.
|
||||||
|
*
|
||||||
|
* 원장 멱등키는 사용자 아래에 있어서, 같은 영수증을 **다른 계정**이 보내면 그
|
||||||
|
* 계정 원장에는 키가 없어 한 번 더 지급된다. 거래 ID 의 주인을 전역 문서로 고정한다.
|
||||||
|
*/
|
||||||
|
async function claimTransaction(purchaseId: string, uid: string): Promise<void> {
|
||||||
|
const ref = getFirestore().doc(`iapTransactions/${purchaseId}`);
|
||||||
|
try {
|
||||||
|
await ref.create({ uid, createdAt: FieldValue.serverTimestamp() });
|
||||||
|
} catch (e) {
|
||||||
|
const code = (e as { code?: number | string }).code;
|
||||||
|
if (code !== 6 && code !== 'already-exists') throw e;
|
||||||
|
if ((await ref.get()).get('uid') !== uid) {
|
||||||
|
fail(ERR.payInvalidReceipt, '다른 계정에서 이미 사용된 결제입니다.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 화면 표시용 값. 앱이 스토어에서 받은 가격·이름이라 **지급 판단에 쓰지 않는다.** */
|
||||||
|
function displayFields(data: unknown, platform: Platform) {
|
||||||
|
const raw = (data ?? {}) as Record<string, unknown>;
|
||||||
|
const price = raw.priceAmount;
|
||||||
|
const currency = raw.currency;
|
||||||
|
const title = raw.title;
|
||||||
|
return {
|
||||||
|
method: PAY_METHOD[platform],
|
||||||
|
...(typeof price === 'number' && Number.isFinite(price) && price >= 0
|
||||||
|
? { priceAmount: price } : {}),
|
||||||
|
...(typeof currency === 'string' && /^[A-Z]{3}$/.test(currency) ? { currency } : {}),
|
||||||
|
title: typeof title === 'string' ? title.slice(0, 60) : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `fn-iap-verify` — 구매 검증 후 혜택 지급.
|
||||||
|
*
|
||||||
|
* 상품 ID 로 세 갈래를 가른다.
|
||||||
|
* - 구독(Focus) — POP 없이 구독 상태를 켠다(B-04 광고 제거).
|
||||||
|
* - Hero Gift Catalog — 구매자 기여 POP + 소속 팬덤 Drop Pool(B-84). 둘이 한 트랜잭션.
|
||||||
|
* 알려진 한계: 이 구매가 환불되면 `fn-iap-refund-webhook` 은 구매자 기여 POP 만 회수하고
|
||||||
|
* **이미 열린 Drop Pool(다른 팬이 받았을 수 있는 일반 POP)은 회수하지 않는다.**
|
||||||
|
* - 그 밖의 상품 표 — 기여 POP. 단 POP 묶음(`pop.support.*`)은 B-02 로 `popPackSalesEnabled`
|
||||||
|
* 가 켜졌을 때만 지급한다(기본 거부 · E-PAY-01).
|
||||||
|
*
|
||||||
|
* 오류 코드 규약(앱이 이것으로 거래를 닫을지 정한다): E-PAY-01 은 재시도해도 결과가 같은
|
||||||
|
* 거부(판매 중지 · 다른 계정 · 소속 행성 없음), E-PAY-02 는 설정·검증이 준비되면 통과할 수 있는 실패다.
|
||||||
|
*
|
||||||
|
* 지급 전에 구매 기록을 먼저 남긴다. 지급이 실패해도 「검증은 됐는데 지급이
|
||||||
|
* 안 된 구매」가 추적 가능해야 한다. 앱은 성공 응답을 받은 뒤에만 스토어에 구매
|
||||||
|
* 완료를 알린다 — 실패하면 다음 실행에서 다시 보낸다.
|
||||||
|
*/
|
||||||
|
export const fnIapVerify = onCall(async (req) => {
|
||||||
|
const uid = callerUid(req);
|
||||||
|
const platform = platformOf(req.data);
|
||||||
|
const receipt = str(req.data, 'receipt');
|
||||||
|
|
||||||
|
const claim = await verifier.verify(platform, receipt, {
|
||||||
|
productId: optionalStr(req.data, 'productId'),
|
||||||
|
transactionId: optionalStr(req.data, 'transactionId'),
|
||||||
|
});
|
||||||
|
if (claim.revoked) {
|
||||||
|
fail(ERR.payVerifyFailed, '이미 취소된 결제입니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getFirestore();
|
||||||
|
const purchaseId = `${claim.platform}_${claim.transactionId}`;
|
||||||
|
const purchaseRef = db.doc(`users/${uid}/purchases/${purchaseId}`);
|
||||||
|
await claimTransaction(purchaseId, uid);
|
||||||
|
|
||||||
|
const existing = await purchaseRef.get();
|
||||||
|
const priorState = existing.get('state') as string | undefined;
|
||||||
|
if (existing.exists && priorState !== 'verified') {
|
||||||
|
// 재전송(복원 · 앱 재시작 · 응답 유실)은 첫 결과를 돌려준다.
|
||||||
|
// 환불 요청 · 회수 상태를 「지급 완료」로 되돌리지 않는다.
|
||||||
|
return {
|
||||||
|
duplicate: true,
|
||||||
|
state: priorState,
|
||||||
|
granted: (existing.get('amountPop') as number | undefined) ?? 0,
|
||||||
|
txId: (existing.get('txId') as string | undefined) ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, ...display } = displayFields(req.data, platform);
|
||||||
|
const base = {
|
||||||
|
uid,
|
||||||
|
platform: claim.platform,
|
||||||
|
transactionId: claim.transactionId,
|
||||||
|
productId: claim.productId,
|
||||||
|
purchasedAt: Timestamp.fromMillis(claim.purchasedAtMillis),
|
||||||
|
...display,
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
...(existing.exists ? {} : { createdAt: FieldValue.serverTimestamp() }),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (SUBSCRIPTION_PRODUCTS.includes(claim.productId)) {
|
||||||
|
// 만료 시각이 미래일 때만 활성이다. 스토어가 만료를 주지 않으면 활성으로 보지 않는다.
|
||||||
|
const expiresAt = claim.expiresAtMillis;
|
||||||
|
const active = typeof expiresAt === 'number' && expiresAt > Date.now();
|
||||||
|
const subRef = db.doc(`users/${uid}/subscription/current`);
|
||||||
|
const current = (await subRef.get()).get('expiresAt');
|
||||||
|
// 옛 거래(복원 재전송)가 더 늦게 끝나는 최신 구독 상태를 덮지 않는다.
|
||||||
|
const stale = current instanceof Timestamp &&
|
||||||
|
(typeof expiresAt !== 'number' || current.toMillis() > expiresAt);
|
||||||
|
const batch = db.batch();
|
||||||
|
if (!stale) {
|
||||||
|
batch.set(subRef, {
|
||||||
|
platform: claim.platform,
|
||||||
|
productId: claim.productId,
|
||||||
|
transactionId: claim.transactionId,
|
||||||
|
state: active ? 'active' : 'expired',
|
||||||
|
adsRemoved: active,
|
||||||
|
...(typeof expiresAt === 'number' ? { expiresAt: Timestamp.fromMillis(expiresAt) } : {}),
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
}, { merge: true });
|
||||||
|
}
|
||||||
|
batch.set(purchaseRef, {
|
||||||
|
...base,
|
||||||
|
kind: 'subscription',
|
||||||
|
itemName: title || 'Focus 구독',
|
||||||
|
amountPop: 0,
|
||||||
|
state: 'granted',
|
||||||
|
...(typeof expiresAt === 'number' ? { expiresAt: Timestamp.fromMillis(expiresAt) } : {}),
|
||||||
|
}, { merge: true });
|
||||||
|
await batch.commit();
|
||||||
|
return { granted: 0, subscription: active ? 'active' : 'expired' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const gift = await heroGiftProduct(claim.productId);
|
||||||
|
if (!gift && claim.productId.startsWith(POP_PACK_PREFIX) &&
|
||||||
|
(await loadPolicy()).popPackSalesEnabled !== true) {
|
||||||
|
// B-02 — POP 자체 판매 상품은 두지 않는다. 스토어에 남아 있어도 지급하지 않는다.
|
||||||
|
fail(ERR.payInvalidReceipt, '판매하지 않는 상품입니다.');
|
||||||
|
}
|
||||||
|
const amount = gift ? gift.buyerPop : popForProduct(claim.productId);
|
||||||
|
|
||||||
|
// Hero Gift 는 구매자의 소속 팬덤에 Drop Pool 을 연다. 팬덤은 앱이 아니라 사용자 문서에서 읽는다.
|
||||||
|
let drop: { fandomId: string; dropId: string; doc: ReturnType<typeof dropDocument> } | null = null;
|
||||||
|
if (gift) {
|
||||||
|
const user = await db.doc(`users/${uid}`).get();
|
||||||
|
// 재시도(지급 전 실패)는 첫 시도에 적어 둔 팬덤을 쓴다 — 그 사이 행성을 옮겨도 Drop 이 가는 곳은 같다.
|
||||||
|
const fandomId = existing.get('fandomId') ?? user.get('fandomId');
|
||||||
|
if (typeof fandomId !== 'string' || fandomId.length === 0) {
|
||||||
|
// 재시도해도 바뀌지 않는 거부라 E-PAY-01 — 앱이 거래를 닫는다.
|
||||||
|
fail(ERR.payInvalidReceipt, '소속 행성이 있어야 영웅의 선물을 보낼 수 있습니다.');
|
||||||
|
}
|
||||||
|
drop = {
|
||||||
|
fandomId,
|
||||||
|
dropId: txIdOf(`herodrop:${iapKey(claim)}`),
|
||||||
|
doc: dropDocument(await dropConfig(gift.dropPop), {
|
||||||
|
triggeredBy: uid,
|
||||||
|
heroNickname: publicNickname(user),
|
||||||
|
productId: claim.productId,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await purchaseRef.set({
|
||||||
|
...base,
|
||||||
|
kind: gift ? 'hero_gift' : 'pop_benefit',
|
||||||
|
itemName: gift?.name ?? (title || claim.productId),
|
||||||
|
amountPop: amount,
|
||||||
|
state: 'verified',
|
||||||
|
...(drop ? { fandomId: drop.fandomId, dropId: drop.dropId, dropPop: gift!.dropPop } : {}),
|
||||||
|
}, { merge: true });
|
||||||
|
|
||||||
|
const result = await grantPop({
|
||||||
|
uid,
|
||||||
|
popType: 'support',
|
||||||
|
amount,
|
||||||
|
reasonCode: 'IAP_PURCHASE',
|
||||||
|
idempotencyKey: iapKey(claim),
|
||||||
|
refPath: purchaseRef.path,
|
||||||
|
// Drop 은 지급과 같은 트랜잭션에서 한 번만 만든다. 재시도는 원장 멱등으로 건너뛴다.
|
||||||
|
...(drop ? {
|
||||||
|
alsoWrite: (tx) => tx.create(
|
||||||
|
db.doc(`fandoms/${drop.fandomId}/heroDrops/${drop.dropId}`), drop.doc),
|
||||||
|
} : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await purchaseRef.set({
|
||||||
|
state: 'granted',
|
||||||
|
txId: result.txId,
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
}, { merge: true });
|
||||||
|
|
||||||
|
return {
|
||||||
|
txId: result.txId,
|
||||||
|
balanceAfter: result.balanceAfter,
|
||||||
|
granted: amount,
|
||||||
|
...(drop ? { dropId: drop.dropId } : {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `fn-iap-refund-webhook` — 스토어 환불·취소 알림 처리.
|
||||||
|
*
|
||||||
|
* 별첨 1 ⑥ 예외 처리 「환불 결제 건 지급 회수」.
|
||||||
|
*
|
||||||
|
* **잔액이 모자라도 음수 잔액을 만들지 않는다.** 이미 쓴 POP 을 강제로 빼면
|
||||||
|
* 원장 합과 잔액이 어긋나고, 사용자는 쓴 적 없는 마이너스를 보게 된다.
|
||||||
|
* 대신 회수 대기로 남기고 Admin 큐로 넘긴다.
|
||||||
|
*/
|
||||||
|
export const fnIapRefundWebhook = onCall(async (req) => {
|
||||||
|
// 스토어 서명 검증 어댑터가 연결되기 전에는 신뢰된 관리자만 호출할 수 있다.
|
||||||
|
requireRole(req, ['master']);
|
||||||
|
const uid = str(req.data, 'uid');
|
||||||
|
const platform = platformOf(req.data);
|
||||||
|
const transactionId = str(req.data, 'transactionId');
|
||||||
|
const db = getFirestore();
|
||||||
|
const purchaseId = `${platform}_${transactionId}`;
|
||||||
|
const purchaseRef = db.doc(`users/${uid}/purchases/${purchaseId}`);
|
||||||
|
|
||||||
|
const purchase = await purchaseRef.get();
|
||||||
|
if (!purchase.exists) {
|
||||||
|
fail(ERR.payVerifyFailed, '해당 결제를 찾을 수 없습니다.');
|
||||||
|
}
|
||||||
|
if (purchase.get('state') === 'clawed-back') {
|
||||||
|
// 스토어 알림은 중복 도착이 흔하다. 두 번 회수하지 않는다.
|
||||||
|
return { state: 'clawed-back', recovered: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const amount = (purchase.get('amountPop') as number | undefined) ?? 0;
|
||||||
|
const wallet = await db.doc(`users/${uid}/wallet/main`).get();
|
||||||
|
const balance = (wallet.get('supportBalance') as number | undefined) ?? 0;
|
||||||
|
|
||||||
|
if (balance < amount) {
|
||||||
|
await purchaseRef.set({
|
||||||
|
state: 'clawback-pending',
|
||||||
|
shortfall: amount - balance,
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
}, { merge: true });
|
||||||
|
await db.collection('adminActionLogs').add({
|
||||||
|
actorUid: 'system',
|
||||||
|
targetPath: purchaseRef.path,
|
||||||
|
reason: 'IAP 환불 회수 보류 — 잔액 부족',
|
||||||
|
patch: { amount, balance },
|
||||||
|
createdAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
logger.warn('[iap] 회수 보류', { uid, purchaseId, amount, balance });
|
||||||
|
return { state: 'clawback-pending', recovered: 0, shortfall: amount - balance };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await spendPop({
|
||||||
|
uid,
|
||||||
|
popType: 'support',
|
||||||
|
amount,
|
||||||
|
reasonCode: 'ADMIN_CLAWBACK',
|
||||||
|
idempotencyKey: `clawback:${platform}:${transactionId}`,
|
||||||
|
refPath: purchaseRef.path,
|
||||||
|
});
|
||||||
|
await purchaseRef.set({
|
||||||
|
state: 'clawed-back',
|
||||||
|
clawbackTxId: result.txId,
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
}, { merge: true });
|
||||||
|
|
||||||
|
return { state: 'clawed-back', recovered: amount, txId: result.txId };
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 이벤트 → 상태. `adsRemoved` 가 혜택이 살아 있는지를 나타낸다. */
|
||||||
|
const STATE_BY_EVENT: Readonly<
|
||||||
|
Record<string, { state: string; adsRemoved: boolean }>
|
||||||
|
> = {
|
||||||
|
purchased: { state: 'active', adsRemoved: true },
|
||||||
|
renewed: { state: 'active', adsRemoved: true },
|
||||||
|
// 해지해도 만료 전까지는 혜택이 남는다.
|
||||||
|
canceled: { state: 'canceled', adsRemoved: true },
|
||||||
|
expired: { state: 'expired', adsRemoved: false },
|
||||||
|
revoked: { state: 'revoked', adsRemoved: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `fn-subscription-webhook` — 구독 상태 전이.
|
||||||
|
*
|
||||||
|
* 결제 · 갱신 · 해지 · 만료 넷을 받는다. **해지와 만료를 가른다** — 해지는
|
||||||
|
* 갱신을 끄는 것이고 남은 기간 동안 혜택이 살아 있다. 둘을 합치면 사용자가
|
||||||
|
* 돈을 낸 기간에 광고가 다시 붙는다.
|
||||||
|
*/
|
||||||
|
export const fnSubscriptionWebhook = onCall(async (req) => {
|
||||||
|
requireRole(req, ['master']);
|
||||||
|
const uid = str(req.data, 'uid');
|
||||||
|
const platform = platformOf(req.data);
|
||||||
|
const productId = str(req.data, 'productId');
|
||||||
|
const event = str(req.data, 'event');
|
||||||
|
const notificationId = str(req.data, 'notificationId');
|
||||||
|
|
||||||
|
if (!SUBSCRIPTION_PRODUCTS.includes(productId)) {
|
||||||
|
fail(ERR.paySubscription, '구독 상품이 아닙니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = STATE_BY_EVENT[event];
|
||||||
|
if (!next) fail(ERR.paySubscription, '알 수 없는 구독 이벤트입니다.');
|
||||||
|
|
||||||
|
const db = getFirestore();
|
||||||
|
const seen = db.doc(`users/${uid}/subscriptionEvents/${notificationId}`);
|
||||||
|
if ((await seen.get()).exists) {
|
||||||
|
// 스토어 알림 ID 로 멱등을 잡는다. 재전송이 상태를 되돌리면 안 된다.
|
||||||
|
return { state: next.state, duplicate: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresAtMillis = (req.data as Record<string, unknown>)?.expiresAtMillis;
|
||||||
|
await db.doc(`users/${uid}/subscription/current`).set({
|
||||||
|
platform,
|
||||||
|
productId,
|
||||||
|
state: next.state,
|
||||||
|
adsRemoved: next.adsRemoved,
|
||||||
|
...(typeof expiresAtMillis === 'number'
|
||||||
|
? { expiresAt: Timestamp.fromMillis(expiresAtMillis) }
|
||||||
|
: {}),
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
}, { merge: true });
|
||||||
|
await seen.set({
|
||||||
|
event,
|
||||||
|
productId,
|
||||||
|
createdAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { state: next.state, duplicate: false };
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `fn-focus-daily-claim` — Focus 구독 혜택, 원장 **B-04**.
|
||||||
|
*
|
||||||
|
* *광고 제거 + 일 40 기여 POP, 당일 Claim, 미수령 누적 없음.* 하루는 서버 일 단위(KST
|
||||||
|
* `dayKey`, 퀘스트 한도와 같은 경계)다. 멱등키가 `focus:{uid}:{day}` 라 같은 날 두 번 지급되지
|
||||||
|
* 않고, 지난 날 몫은 키가 달라도 **오늘 부르는 것만** 지급하므로 이월이 없다.
|
||||||
|
*
|
||||||
|
* 성장 반영은 하지 않는다 — C-31 「Focus 일 40 기여 POP 등 다른 보너스에는 자동 적용하지
|
||||||
|
* 않음(적용 여부는 §19 S01)」. S01 회신 전까지 지급 시 성장 이벤트를 만들지 않는다.
|
||||||
|
*/
|
||||||
|
export const fnFocusDailyClaim = onCall(async (req) => {
|
||||||
|
const uid = callerUid(req);
|
||||||
|
const db = getFirestore();
|
||||||
|
const subRef = db.doc(`users/${uid}/subscription/current`);
|
||||||
|
const sub = await subRef.get();
|
||||||
|
const expiresAt = sub.get('expiresAt');
|
||||||
|
if (!(expiresAt instanceof Timestamp) || expiresAt.toMillis() <= Date.now() ||
|
||||||
|
!['active', 'canceled'].includes(sub.get('state') as string)) {
|
||||||
|
// 해지(canceled)는 만료 전까지 혜택이 남는다(fnSubscriptionWebhook 과 같은 규칙).
|
||||||
|
fail(ERR.paySubscription, 'Focus 구독 중일 때만 받을 수 있습니다.');
|
||||||
|
}
|
||||||
|
const day = dayKey(new Date());
|
||||||
|
const idempotencyKey = `focus:${uid}:${day}`;
|
||||||
|
const prior = await db.doc(`users/${uid}/popTransactions/${txIdOf(idempotencyKey)}`).get();
|
||||||
|
const amount = (await loadPolicy()).focusDailyPop;
|
||||||
|
const result = await grantPop({
|
||||||
|
uid,
|
||||||
|
popType: 'support',
|
||||||
|
amount,
|
||||||
|
reasonCode: 'FOCUS_DAILY',
|
||||||
|
idempotencyKey,
|
||||||
|
refPath: subRef.path,
|
||||||
|
// 앱이 「오늘 받음」을 그리는 값. 원장과 같은 트랜잭션에서 쓴다.
|
||||||
|
alsoWrite: (tx) => tx.set(subRef, { focusClaimedDay: day }, { merge: true }),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
txId: result.txId,
|
||||||
|
balanceAfter: result.balanceAfter,
|
||||||
|
granted: prior.exists ? ((prior.get('amount') as number | undefined) ?? amount) : amount,
|
||||||
|
day,
|
||||||
|
duplicate: prior.exists,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { ERR, fail } from '../core/errors.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 영수증 검증 경계.
|
||||||
|
*
|
||||||
|
* **스토어 서버 호출만 여기 뒤에 둔다.** 검증에는 Apple 의 App Store Server API
|
||||||
|
* 키와 Google 의 서비스 계정이 필요한데, 둘 다 스토어 계정이 열려야 발급된다
|
||||||
|
* (계정·권한 이전 별첨 B-1·C-1). 그 자격증명을 기다리는 동안 지급·회수 로직이
|
||||||
|
* 통째로 멈추지 않도록 **검증만 인터페이스로 빼고 나머지는 지금 완성한다.**
|
||||||
|
*
|
||||||
|
* 자격증명이 들어오면 [StoreReceiptVerifier] 안쪽만 채우면 되고, 이 파일을
|
||||||
|
* 쓰는 함수들은 손대지 않는다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Platform = 'ios' | 'android';
|
||||||
|
|
||||||
|
export interface ReceiptClaim {
|
||||||
|
platform: Platform;
|
||||||
|
/** 스토어가 이 구매에 부여한 고유 ID. 멱등키의 뼈대다. */
|
||||||
|
transactionId: string;
|
||||||
|
productId: string;
|
||||||
|
/** 소비성 구매인가. 구독은 false. */
|
||||||
|
consumable: boolean;
|
||||||
|
purchasedAtMillis: number;
|
||||||
|
/** 스토어가 이미 환불·취소 처리한 건인가. */
|
||||||
|
revoked: boolean;
|
||||||
|
/** 구독 만료 시각. 구독이 아니거나 스토어가 주지 않았으면 없다. */
|
||||||
|
expiresAtMillis?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조회 키. **지급 기준으로 쓰지 않는다** — 지급은 스토어가 돌려준 [ReceiptClaim] 으로 정한다.
|
||||||
|
* - `productId`: Google Play 구매 조회 API 가 토큰과 함께 요구한다.
|
||||||
|
* - `transactionId`: iOS 앱 영수증은 여러 거래를 담는다. 앱이 방금 받은 거래를 이 ID 로 고른다.
|
||||||
|
*/
|
||||||
|
export interface ReceiptHint {
|
||||||
|
productId?: string | undefined;
|
||||||
|
transactionId?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReceiptVerifier {
|
||||||
|
verify(platform: Platform, receipt: string, hint?: ReceiptHint): Promise<ReceiptClaim>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상품 표 — 상품 ID 하나가 지원 POP 얼마인지.
|
||||||
|
*
|
||||||
|
* **클라이언트가 보낸 금액을 믿지 않는다.** 앱이 「10만 POP 짜리를 샀다」고 말해도
|
||||||
|
* 서버는 상품 ID 로 이 표를 본다. 표에 없는 상품 ID 는 지급하지 않는다.
|
||||||
|
*/
|
||||||
|
export const PRODUCT_POP: Readonly<Record<string, number>> = {
|
||||||
|
'pop.support.1000': 1_000,
|
||||||
|
'pop.support.5500': 5_500,
|
||||||
|
'pop.support.12000': 12_000,
|
||||||
|
'pop.support.30000': 30_000,
|
||||||
|
'pop.support.65000': 65_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 구독 상품. POP 을 주지 않고 광고 제거 상태를 바꾼다. */
|
||||||
|
export const SUBSCRIPTION_PRODUCTS: readonly string[] = [
|
||||||
|
'focus.monthly',
|
||||||
|
'focus.yearly',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function popForProduct(productId: string): number {
|
||||||
|
const amount = PRODUCT_POP[productId];
|
||||||
|
if (amount === undefined) {
|
||||||
|
// 표에 없는 상품 ID — 위조이거나 스토어 상품 등록과 코드가 어긋난 상태다.
|
||||||
|
// 둘 다 지급하면 안 된다.
|
||||||
|
fail(ERR.payInvalidReceipt, '알 수 없는 상품입니다.');
|
||||||
|
}
|
||||||
|
return amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스토어 실호출 구현 — **자격증명 대기 중이라 아직 비어 있다.**
|
||||||
|
*
|
||||||
|
* 비워 두되 조용히 통과시키지 않는다. 자격증명 없이 부르면 `E-PAY-02` 로 끊어
|
||||||
|
* **검증 없는 지급 경로가 생기지 않게** 한다. 「나중에 채우자」는 주석만 두면
|
||||||
|
* 그 자리가 열린 채로 배포된다.
|
||||||
|
*/
|
||||||
|
export class StoreReceiptVerifier implements ReceiptVerifier {
|
||||||
|
async verify(_platform: Platform, _receipt: string, _hint?: ReceiptHint): Promise<ReceiptClaim> {
|
||||||
|
return fail(ERR.payVerifyFailed, '결제 검증 설정이 아직 준비되지 않았습니다.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import { lookup } from 'node:dns';
|
||||||
|
import type { LookupAddress, LookupOptions } from 'node:dns';
|
||||||
|
import { request as httpRequest } from 'node:http';
|
||||||
|
import { request as httpsRequest } from 'node:https';
|
||||||
|
import { isIP } from 'node:net';
|
||||||
|
import { HttpsError, onCall } from 'firebase-functions/v2/https';
|
||||||
|
import { getFirestore, Timestamp } from 'firebase-admin/firestore';
|
||||||
|
|
||||||
|
import { callerUid, str } from '../core/callable.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `fn-link-preview` — 링크 기반 Quest 의 원본 제목 · 썸네일 · 서비스명(원장 **C-05** · R10).
|
||||||
|
*
|
||||||
|
* 서버가 사용자가 준 URL 을 대신 여는 경로라 **SSRF 방어가 이 파일의 본체**다.
|
||||||
|
* - http / https 만, 계정 정보(`user:pass@`) · 기본 외 포트 거부
|
||||||
|
* - 이름 풀이 결과가 사설 · 루프백 · 링크 로컬 · 예약 대역이면 거부 — **연결 시점의 lookup 을
|
||||||
|
* 갈아 끼워** 판정과 실제 연결이 같은 주소를 본다(DNS rebinding 차단)
|
||||||
|
* - 리다이렉트는 3회까지, 매 단계 같은 검사
|
||||||
|
* - 전체 5초 · 본문 512KB 상한
|
||||||
|
*
|
||||||
|
* 결과 `status` — `ok`(조회 성공) · `preview_failed`(형식은 유효한데 조회만 실패 → 기본 카드 +
|
||||||
|
* 제목 최소 입력) · `invalid`(저장 거부 · 수정 요청).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type LinkStatus = 'ok' | 'preview_failed' | 'invalid';
|
||||||
|
|
||||||
|
export interface LinkPreview {
|
||||||
|
url: string;
|
||||||
|
status: LinkStatus;
|
||||||
|
title?: string | undefined;
|
||||||
|
thumbnail?: string | undefined;
|
||||||
|
provider?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIMEOUT_MS = 5_000;
|
||||||
|
const MAX_BYTES = 512 * 1024;
|
||||||
|
const MAX_REDIRECTS = 3;
|
||||||
|
|
||||||
|
/** 공개 http(s) URL 이면 URL, 아니면 null. 이름 풀이는 하지 않는다. */
|
||||||
|
export function parsePublicUrl(raw: string): URL | null {
|
||||||
|
if (raw.length > 2048) return null;
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(raw.trim());
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
||||||
|
if (url.username || url.password || !url.hostname) return null;
|
||||||
|
if (url.port && url.port !== '80' && url.port !== '443') return null;
|
||||||
|
const host = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
||||||
|
if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.internal')) return null;
|
||||||
|
if (isIP(host) && isPrivateAddress(host)) return null;
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function v4Octets(ip: string): number[] | null {
|
||||||
|
const parts = ip.split('.').map(Number);
|
||||||
|
return parts.length === 4 && parts.every((n) => Number.isInteger(n) && n >= 0 && n <= 255)
|
||||||
|
? parts : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 공인 인터넷 주소가 아니면 true — 모르는 형식도 true(닫힌 쪽으로 판정). */
|
||||||
|
export function isPrivateAddress(ip: string): boolean {
|
||||||
|
const v4 = v4Octets(ip);
|
||||||
|
if (v4) {
|
||||||
|
const [a, b] = v4 as [number, number, number, number];
|
||||||
|
return a === 0 || a === 10 || a === 127 || a >= 224
|
||||||
|
|| (a === 100 && b >= 64 && b <= 127)
|
||||||
|
|| (a === 169 && b === 254)
|
||||||
|
|| (a === 172 && b >= 16 && b <= 31)
|
||||||
|
|| (a === 192 && b === 168)
|
||||||
|
|| (a === 192 && b === 0)
|
||||||
|
|| (a === 198 && (b === 18 || b === 19))
|
||||||
|
|| (a === 198 && b === 51 && v4[2] === 100) // TEST-NET-2
|
||||||
|
|| (a === 203 && b === 0 && v4[2] === 113); // TEST-NET-3
|
||||||
|
}
|
||||||
|
if (isIP(ip) !== 6) return true;
|
||||||
|
const low = ip.toLowerCase();
|
||||||
|
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(low);
|
||||||
|
if (mapped) return isPrivateAddress(mapped[1]!);
|
||||||
|
return low === '::' || low === '::1'
|
||||||
|
|| /^f[cd]/.test(low) // fc00::/7
|
||||||
|
|| /^fe[89ab]/.test(low) // fe80::/10
|
||||||
|
|| low.startsWith('ff') // 멀티캐스트
|
||||||
|
|| low.startsWith('64:ff9b:') // NAT64 — 안쪽 v4 를 가릴 수 없다
|
||||||
|
|| low.startsWith('2001:db8:') // 문서용
|
||||||
|
|| low.startsWith('2002:') // 6to4 — 안쪽 v4 를 가릴 수 없다
|
||||||
|
|| low.startsWith('::'); // v4 호환 등 나머지 예약
|
||||||
|
}
|
||||||
|
|
||||||
|
class BlockedAddress extends Error {}
|
||||||
|
|
||||||
|
/** 연결에 쓰는 lookup — 풀이 결과 중 하나라도 사설이면 연결 자체를 막는다. */
|
||||||
|
function safeLookup(
|
||||||
|
hostname: string,
|
||||||
|
options: LookupOptions,
|
||||||
|
callback: (err: NodeJS.ErrnoException | null, address: string | LookupAddress[], family?: number) => void,
|
||||||
|
): void {
|
||||||
|
lookup(hostname, { ...options, all: true }, (err, addresses) => {
|
||||||
|
if (err) return callback(err, '');
|
||||||
|
const list = addresses as LookupAddress[];
|
||||||
|
if (list.length === 0 || list.some((a) => isPrivateAddress(a.address))) {
|
||||||
|
return callback(new BlockedAddress('blocked address'), '');
|
||||||
|
}
|
||||||
|
if (options.all) return callback(null, list);
|
||||||
|
return callback(null, list[0]!.address, list[0]!.family);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Fetched { status: number; location?: string | undefined; body: string; contentType: string }
|
||||||
|
|
||||||
|
function fetchOnce(url: URL, deadline: number): Promise<Fetched> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const left = deadline - Date.now();
|
||||||
|
if (left <= 0) return reject(new Error('timeout'));
|
||||||
|
const send = url.protocol === 'https:' ? httpsRequest : httpRequest;
|
||||||
|
const req = send(url, {
|
||||||
|
method: 'GET',
|
||||||
|
lookup: safeLookup as never,
|
||||||
|
timeout: left,
|
||||||
|
headers: { 'user-agent': 'FANUZU-LinkPreview/1.0', accept: 'text/html,application/json' },
|
||||||
|
}, (res) => {
|
||||||
|
const status = res.statusCode ?? 0;
|
||||||
|
const location = typeof res.headers.location === 'string' ? res.headers.location : undefined;
|
||||||
|
const contentType = String(res.headers['content-type'] ?? '');
|
||||||
|
if (status >= 300 && status < 400) {
|
||||||
|
res.resume();
|
||||||
|
return resolve({ status, location, body: '', contentType });
|
||||||
|
}
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let size = 0;
|
||||||
|
res.on('data', (chunk: Buffer) => {
|
||||||
|
size += chunk.length;
|
||||||
|
// 상한까지만 읽는다. 메타 태그는 문서 앞쪽에 있다.
|
||||||
|
if (size > MAX_BYTES) {
|
||||||
|
chunks.push(chunk.subarray(0, chunk.length - (size - MAX_BYTES)));
|
||||||
|
req.destroy();
|
||||||
|
resolve({ status, body: Buffer.concat(chunks).toString('utf8'), contentType });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push(chunk);
|
||||||
|
});
|
||||||
|
res.on('end', () => resolve({ status, body: Buffer.concat(chunks).toString('utf8'), contentType }));
|
||||||
|
res.on('error', reject);
|
||||||
|
});
|
||||||
|
// `timeout` 은 유휴 시간이다. 조금씩 흘려보내는 응답도 전체 시한에서 끊는다.
|
||||||
|
const timer = setTimeout(() => req.destroy(new Error('timeout')), left);
|
||||||
|
req.on('close', () => clearTimeout(timer));
|
||||||
|
req.on('timeout', () => req.destroy(new Error('timeout')));
|
||||||
|
req.on('error', reject);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 리다이렉트를 따라가며 매 단계 URL 을 다시 검사한다. */
|
||||||
|
async function fetchPublic(url: URL): Promise<Fetched> {
|
||||||
|
const deadline = Date.now() + TIMEOUT_MS;
|
||||||
|
let current = url;
|
||||||
|
for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
|
||||||
|
const res = await fetchOnce(current, deadline);
|
||||||
|
if (res.status < 300 || res.status >= 400) return res;
|
||||||
|
const next = res.location ? parsePublicUrl(new URL(res.location, current).toString()) : null;
|
||||||
|
if (!next) throw new BlockedAddress('blocked redirect');
|
||||||
|
current = next;
|
||||||
|
}
|
||||||
|
throw new Error('too many redirects');
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeEntities(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&#(\d+);/g, (_, n: string) => String.fromCodePoint(Number(n)))
|
||||||
|
.replace(/&#x([0-9a-f]+);/gi, (_, n: string) => String.fromCodePoint(parseInt(n, 16)))
|
||||||
|
.replace(/"/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 메타 → 제목 · 썸네일 · 서비스명. 없으면 `<title>` 로 떨어진다.
|
||||||
|
*
|
||||||
|
* 입력은 공격자가 고른 HTML 이다. `</head>` 또는 앞 64KB 까지만 보고, 태그 단위로 길이를 묶은
|
||||||
|
* 정규식으로 잘라 낸 뒤 속성을 따로 읽는다 — 한 정규식에 대안 · 무제한 반복을 겹치면 역추적 폭발(ReDoS).
|
||||||
|
*/
|
||||||
|
export function parseHtmlMeta(html: string): Omit<LinkPreview, 'url' | 'status'> {
|
||||||
|
const headEnd = html.search(/<\/head>/i);
|
||||||
|
const head = html.slice(0, Math.min(headEnd < 0 ? html.length : headEnd, 64 * 1024));
|
||||||
|
const metas: Record<string, string> = {};
|
||||||
|
for (const [tag] of head.matchAll(/<meta\b[^>]{0,2048}>/gi)) {
|
||||||
|
const key = (attr(tag, 'property') ?? attr(tag, 'name'))?.toLowerCase();
|
||||||
|
const content = attr(tag, 'content');
|
||||||
|
if (key && content !== undefined && !(key in metas)) metas[key] = decodeEntities(content);
|
||||||
|
}
|
||||||
|
const titleTag = /<title[^>]{0,256}>([^<]{0,512})<\/title>/i.exec(head)?.[1];
|
||||||
|
const title = metas['og:title'] || (titleTag ? decodeEntities(titleTag) : '') || undefined;
|
||||||
|
const image = metas['og:image'];
|
||||||
|
return {
|
||||||
|
title: title?.slice(0, 200),
|
||||||
|
thumbnail: image && parsePublicUrl(image) ? image : undefined,
|
||||||
|
provider: metas['og:site_name']?.slice(0, 100) || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const YOUTUBE = /(^|\.)(youtube\.com|youtu\.be)$/i;
|
||||||
|
|
||||||
|
/** URL 하나의 미리보기. 네트워크 실패는 `preview_failed`, 형식 · 주소 위반은 `invalid`. */
|
||||||
|
export async function linkPreview(raw: string): Promise<LinkPreview> {
|
||||||
|
const url = parsePublicUrl(raw);
|
||||||
|
if (!url) return { url: raw, status: 'invalid' };
|
||||||
|
const href = url.toString();
|
||||||
|
try {
|
||||||
|
if (YOUTUBE.test(url.hostname)) {
|
||||||
|
const oembed = new URL('https://www.youtube.com/oembed');
|
||||||
|
oembed.searchParams.set('url', href);
|
||||||
|
oembed.searchParams.set('format', 'json');
|
||||||
|
const res = await fetchPublic(oembed);
|
||||||
|
if (res.status !== 200) return { url: href, status: 'preview_failed' };
|
||||||
|
const data = JSON.parse(res.body) as Record<string, unknown>;
|
||||||
|
const title = typeof data.title === 'string' ? data.title.slice(0, 200) : '';
|
||||||
|
const thumb = typeof data.thumbnail_url === 'string' && parsePublicUrl(data.thumbnail_url)
|
||||||
|
? data.thumbnail_url : undefined;
|
||||||
|
if (!title) return { url: href, status: 'preview_failed' };
|
||||||
|
return { url: href, status: 'ok', title, thumbnail: thumb, provider: 'YouTube' };
|
||||||
|
}
|
||||||
|
const res = await fetchPublic(url);
|
||||||
|
if (res.status !== 200 || !/html/i.test(res.contentType)) return { url: href, status: 'preview_failed' };
|
||||||
|
const meta = parseHtmlMeta(res.body);
|
||||||
|
if (!meta.title) return { url: href, status: 'preview_failed' };
|
||||||
|
return {
|
||||||
|
url: href, status: 'ok', title: meta.title, thumbnail: meta.thumbnail,
|
||||||
|
provider: meta.provider ?? url.hostname.replace(/^www\./, ''),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
// 사설 주소로 풀리는 이름 · 사설로 튀는 리다이렉트는 공개 링크가 아니다.
|
||||||
|
return { url: href, status: error instanceof BlockedAddress ? 'invalid' : 'preview_failed' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `fn-link-preview` — 생성 화면의 미리보기. 저장 시에는 `fn-quest-create` 가 다시 조회한다. */
|
||||||
|
export const fnLinkPreview = onCall(async (req) => {
|
||||||
|
const uid = callerUid(req);
|
||||||
|
const url = str(req.data, 'url');
|
||||||
|
await takeLinkPreviewSlot(uid);
|
||||||
|
return linkPreview(url);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 사용자당 분당 호출 상한 — 서버를 대신 여는 경로라 남용을 막는다. */
|
||||||
|
export const LINK_PREVIEW_PER_MINUTE = 30;
|
||||||
|
|
||||||
|
export async function takeLinkPreviewSlot(uid: string, now = Date.now()): Promise<void> {
|
||||||
|
const db = getFirestore();
|
||||||
|
const ref = db.doc(`users/${uid}/rateLimits/linkPreview`);
|
||||||
|
await db.runTransaction(async (tx) => {
|
||||||
|
const snap = await tx.get(ref);
|
||||||
|
const start = snap.get('windowStart');
|
||||||
|
const fresh = !(start instanceof Timestamp) || now - start.toMillis() >= 60_000;
|
||||||
|
const count = fresh ? 0 : ((snap.get('count') as number | undefined) ?? 0);
|
||||||
|
if (count >= LINK_PREVIEW_PER_MINUTE) throw new HttpsError('resource-exhausted', '잠시 후 다시 시도해 주세요.');
|
||||||
|
tx.set(ref, { windowStart: fresh ? Timestamp.fromMillis(now) : start, count: count + 1 });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
import { onCall } from 'firebase-functions/v2/https';
|
||||||
|
import { getFirestore, FieldValue, Timestamp } from 'firebase-admin/firestore';
|
||||||
|
|
||||||
|
import { callerUid, num, optionalStr, str } from '../core/callable.js';
|
||||||
|
import { ERR, fail, positiveInt } from '../core/errors.js';
|
||||||
|
import { deviceHashOf, txIdOf } from '../core/ids.js';
|
||||||
|
import { dayKey, isPlausibleOccurredAt } from '../core/time.js';
|
||||||
|
import { loadPolicy, type PolicyConfig } from '../core/config.js';
|
||||||
|
import { grantPop } from '../ledger/ledger.js';
|
||||||
|
import { fandomOfUser, markCompletion, writeFpiEvent } from '../ledger/fpi.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 퀘스트 검증 — 별첨 1 ③ 「서버검증 필터」의 구현부.
|
||||||
|
*
|
||||||
|
* 공통 원칙: 클라이언트가 보낸 값은 **증거일 뿐 판정 결과가 아니다.** 보상은
|
||||||
|
* 서버 판정이 통과한 뒤에만 원장을 거쳐 나간다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 단말 시각을 읽고 조작 여부를 본다. `occurredAt` 은 ISO 8601 문자열로 받는다. */
|
||||||
|
function occurredAt(data: unknown, now: Date): Date {
|
||||||
|
const raw = optionalStr(data, 'occurredAt');
|
||||||
|
if (!raw) return now;
|
||||||
|
const at = new Date(raw);
|
||||||
|
if (Number.isNaN(at.getTime())) {
|
||||||
|
fail(ERR.questAbnormal, '활동 시각이 올바르지 않습니다.');
|
||||||
|
}
|
||||||
|
if (!isPlausibleOccurredAt(at, now)) {
|
||||||
|
// 별첨 1 ③ 예외 처리 「시간 조작 — 서버검증 필터 차단」.
|
||||||
|
fail(ERR.questAbnormal, '활동 시각이 서버 시각과 크게 어긋납니다.');
|
||||||
|
}
|
||||||
|
return at;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 진행 문서의 ID 는 **퀘스트 문서 ID** 다 — 설계서 §2.1 `users/{uid}/questProgress/{questId}`.
|
||||||
|
*
|
||||||
|
* 예전에는 `attend`·`steps` 같은 고정 문자열에 썼는데, 앱은 `quests` 컬렉션의 문서 ID 로
|
||||||
|
* 읽는다. 두 이름이 어긋나면 **지급은 되는데 화면은 영원히 「시작하기」** 로 남고, 일일
|
||||||
|
* 한도도 퀘스트마다 따로 서지 않는다. 호출자가 주지 않으면 유형 이름으로 떨어뜨린다.
|
||||||
|
*/
|
||||||
|
function questIdOf(data: unknown, fallback: string): string {
|
||||||
|
return optionalStr(data, 'questId') ?? fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일일 한도 — **유일한 원본은 `quests/{questId}.dailyLimit`** 이다(설계서 §2.8).
|
||||||
|
*
|
||||||
|
* 문서가 없으면 1 로 본다. 한도를 모르는 채 무제한으로 열면 지급이 무한해진다.
|
||||||
|
*/
|
||||||
|
export async function dailyLimitOf(questId: string): Promise<number> {
|
||||||
|
const snap = await getFirestore().doc(`quests/${questId}`).get();
|
||||||
|
const limit = snap.get('dailyLimit') as number | undefined;
|
||||||
|
return typeof limit === 'number' && limit > 0 ? limit : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 참여할 퀘스트 문서를 확인한다 — 리뷰 H2 · B2.
|
||||||
|
*
|
||||||
|
* **문서가 없거나 유형이 다르면 거부한다.** 예전에는 `questId` 가 진행 문서 ID 로만 쓰여
|
||||||
|
* 있지도 않은 ID 를 바꿔 가며 부르면 일일 한도 · 1회 제한이 매번 새로 열렸다.
|
||||||
|
* 정책 원장 **C-16** — 현재 대표 팬덤의 Quest 와 일반 Quest(`fandomId` 없음)만 참여할 수 있다.
|
||||||
|
*/
|
||||||
|
export async function questOf(
|
||||||
|
uid: string, questId: string, types: readonly string[],
|
||||||
|
): Promise<FirebaseFirestore.DocumentSnapshot> {
|
||||||
|
const db = getFirestore();
|
||||||
|
const [quest, user] = await Promise.all([
|
||||||
|
db.doc(`quests/${questId}`).get(), db.doc(`users/${uid}`).get(),
|
||||||
|
]);
|
||||||
|
const type = String(quest.get('type') ?? '').toLowerCase();
|
||||||
|
if (!quest.exists || !types.includes(type)) {
|
||||||
|
fail(ERR.questIncomplete, '참여할 수 있는 퀘스트가 아닙니다.');
|
||||||
|
}
|
||||||
|
// 사용자 생성 Quest 는 공개된 동안만(`status`), 모든 Quest 는 **서버 시각**으로 마감 전까지만(G-04).
|
||||||
|
const status = quest.get('status');
|
||||||
|
const endsAt = quest.get('endsAt');
|
||||||
|
if ((status !== undefined && status !== 'published')
|
||||||
|
|| (endsAt instanceof Timestamp && Date.now() >= endsAt.toMillis())) {
|
||||||
|
fail(ERR.questIncomplete, '마감된 퀘스트입니다.');
|
||||||
|
}
|
||||||
|
const questFandom = quest.get('fandomId');
|
||||||
|
if (typeof questFandom === 'string' && questFandom && questFandom !== fandomOfUser(user)) {
|
||||||
|
fail(ERR.permissionDenied, '현재 대표 팬덤의 퀘스트만 참여할 수 있습니다.');
|
||||||
|
}
|
||||||
|
return quest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 당일 진행 문서. 일일 한도는 여기서만 판정한다. */
|
||||||
|
export async function progress(uid: string, questId: string, key: string) {
|
||||||
|
const ref = getFirestore().doc(`users/${uid}/questProgress/${questId}`);
|
||||||
|
const snap = await ref.get();
|
||||||
|
const sameDay = snap.get('dayKey') === key;
|
||||||
|
return {
|
||||||
|
ref,
|
||||||
|
count: sameDay ? ((snap.get('count') as number | undefined) ?? 0) : 0,
|
||||||
|
rewardedPop: sameDay ? ((snap.get('rewardedPop') as number | undefined) ?? 0) : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 진행 카운터를 **지급과 같은 트랜잭션에서** 쓴다.
|
||||||
|
*
|
||||||
|
* 지급 뒤에 따로 쓰면 그 사이 실패가 났을 때 「POP 은 나갔는데 한도는 0」 상태가 남고,
|
||||||
|
* 한 번 더 부르면 같은 날 두 번 지급된다.
|
||||||
|
*/
|
||||||
|
export function progressWriter(
|
||||||
|
ref: FirebaseFirestore.DocumentReference,
|
||||||
|
questId: string,
|
||||||
|
key: string,
|
||||||
|
count: number,
|
||||||
|
rewardedPop: number,
|
||||||
|
) {
|
||||||
|
return (tx: FirebaseFirestore.Transaction) => {
|
||||||
|
tx.set(ref, {
|
||||||
|
questId,
|
||||||
|
dayKey: key,
|
||||||
|
count,
|
||||||
|
rewardedPop,
|
||||||
|
completedAt: FieldValue.serverTimestamp(),
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
}, { merge: true });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `fn-quest-attend` — 출석. 한도는 `quests/{questId}.dailyLimit` 이 정한다. */
|
||||||
|
export const fnQuestAttend = onCall(async (req) => {
|
||||||
|
const uid = callerUid(req);
|
||||||
|
const now = new Date();
|
||||||
|
const key = dayKey(now);
|
||||||
|
const questId = questIdOf(req.data, 'attend');
|
||||||
|
await questOf(uid, questId, ['attend', 'checkin']);
|
||||||
|
const policy = await loadPolicy();
|
||||||
|
const limit = await dailyLimitOf(questId);
|
||||||
|
const state = await progress(uid, questId, key);
|
||||||
|
if (state.count >= limit) {
|
||||||
|
fail(ERR.popDuplicate, '오늘 출석은 이미 완료했습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = state.count + 1;
|
||||||
|
const rewardedPop = state.rewardedPop + policy.attendPop;
|
||||||
|
const writeProgress = progressWriter(state.ref, questId, key, count, rewardedPop);
|
||||||
|
const result = await grantPop({
|
||||||
|
uid,
|
||||||
|
popType: 'activity',
|
||||||
|
amount: policy.attendPop,
|
||||||
|
reasonCode: 'QUEST_ATTEND',
|
||||||
|
growthSource: 'quest_reward',
|
||||||
|
// 퀘스트마다 따로 세야 하므로 멱등키에도 퀘스트 ID 를 넣는다.
|
||||||
|
idempotencyKey: `attend:${uid}:${questId}:${key}:${count}`,
|
||||||
|
alsoWrite: (tx, user) => {
|
||||||
|
writeProgress(tx);
|
||||||
|
// 출석은 FPI **COMMUNITY** 축 1 단위다(C-35). 보상액과 무관하다(C-34).
|
||||||
|
writeFpiEvent(tx, {
|
||||||
|
uid, fandomId: fandomOfUser(user), axis: 'community', units: 1, activity: 'attend',
|
||||||
|
sourceId: `attend:${questId}:${key}:${count}`, occurredAt: now,
|
||||||
|
}, user);
|
||||||
|
// 검증 활동 — 출석은 하루 1회(Campaign 개설자격 A안 집계 규칙).
|
||||||
|
markCompletion(tx, uid, `${questId}_${key}`, {
|
||||||
|
questId, txId: txIdOf(`attend:${uid}:${questId}:${key}:${count}`),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
txId: result.txId,
|
||||||
|
balanceAfter: result.balanceAfter,
|
||||||
|
rewarded: policy.attendPop,
|
||||||
|
questId,
|
||||||
|
remaining: limit - count,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `fn-quest-verify-steps` — 만보기.
|
||||||
|
*
|
||||||
|
* 걸음 수를 그대로 믿지 않는다. 완주 목표 미달이면 E-QST-01, 사람이 하루에 낼 수
|
||||||
|
* 없는 값이면 E-QST-02 로 끊고 24시간 쿨다운을 남긴다.
|
||||||
|
*/
|
||||||
|
export const fnQuestVerifySteps = onCall(async (req) => {
|
||||||
|
const uid = callerUid(req);
|
||||||
|
const now = new Date();
|
||||||
|
const at = occurredAt(req.data, now);
|
||||||
|
const key = dayKey(at);
|
||||||
|
// 퀘스트 문서가 있어야 하고 걸음 유형이어야 한다. 없는 ID 로 바꿔 부르면 진행 문서 · 멱등키가
|
||||||
|
// 새로 열려 구간 보상이 다시 나간다(리뷰 재검토 blocker). 대표 팬덤 자격(C-16)도 본다.
|
||||||
|
const questId = str(req.data, 'questId');
|
||||||
|
await questOf(uid, questId, ['walk', 'steps', 'health']);
|
||||||
|
const steps = positiveInt(num(req.data, 'steps'), 'steps');
|
||||||
|
const policy = await loadPolicy();
|
||||||
|
const db = getFirestore();
|
||||||
|
|
||||||
|
const logRef = db.collection(`users/${uid}/activityLogs`).doc();
|
||||||
|
const deviceHash = optionalStr(req.data, 'deviceRaw')
|
||||||
|
? deviceHashOf(str(req.data, 'deviceRaw'))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// 사람이 하루에 걷는 상한을 크게 잡아도 10만 보를 넘지 않는다. 넘으면 증거를
|
||||||
|
// 남기되 보상하지 않는다 — 판정 근거가 사라지면 어뷰징 분석이 불가능하다.
|
||||||
|
const abnormal = steps > 100_000;
|
||||||
|
// 정책 원장 **B-26** — 1 천/5 천/1 만/1.5 만보 구간. 걸음 수는 **그 날 누적**이므로
|
||||||
|
// 5 천보를 보고한 사용자는 1 천보 구간도 지난 것이다. 그래도 지급은 아랫 구간부터
|
||||||
|
// 차례로 한 건씩 남긴다 — 원장만 봐도 어느 구간이 얼마에 나갔는지 보여야 한다.
|
||||||
|
const tiers = policy.stepTiers;
|
||||||
|
const cleared = tiers.filter((tier) => steps >= tier.steps).length;
|
||||||
|
const state = await progress(uid, questId, key);
|
||||||
|
|
||||||
|
await logRef.set({
|
||||||
|
type: 'steps',
|
||||||
|
fandomId: optionalStr(req.data, 'fandomId') ?? '',
|
||||||
|
value: steps,
|
||||||
|
evidence: { steps, clearedTier: cleared },
|
||||||
|
questId,
|
||||||
|
verified: cleared > state.count && !abnormal,
|
||||||
|
rejectReason: abnormal
|
||||||
|
? ERR.questAbnormal
|
||||||
|
: cleared > state.count ? null : ERR.questIncomplete,
|
||||||
|
deviceHash,
|
||||||
|
occurredAt: at,
|
||||||
|
createdAt: FieldValue.serverTimestamp(),
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (abnormal) fail(ERR.questAbnormal, '비정상적인 활동으로 판정했습니다.');
|
||||||
|
if (cleared === 0) {
|
||||||
|
fail(ERR.questIncomplete, `아직 ${tiers[0]!.steps.toLocaleString()}보에 도달하지 않았습니다.`);
|
||||||
|
}
|
||||||
|
if (cleared <= state.count) {
|
||||||
|
// 오늘 도달한 구간은 이미 전부 받았다. 같은 구간을 두 번 주지 않는다.
|
||||||
|
fail(ERR.popDuplicate, '이 구간의 보상은 이미 받았습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
let rewarded = 0;
|
||||||
|
let balanceAfter = 0;
|
||||||
|
let txId = '';
|
||||||
|
for (let index = state.count; index < cleared; index += 1) {
|
||||||
|
const tier = tiers[index]!;
|
||||||
|
rewarded += tier.pop;
|
||||||
|
const result = await grantPop({
|
||||||
|
uid,
|
||||||
|
popType: 'activity',
|
||||||
|
amount: tier.pop,
|
||||||
|
reasonCode: 'QUEST_STEPS',
|
||||||
|
growthSource: 'quest_reward',
|
||||||
|
// 구간마다 원장 한 행. 같은 날 같은 구간은 한 번만 나간다.
|
||||||
|
idempotencyKey: `quest:${uid}:${questId}:t${index + 1}:${key}`,
|
||||||
|
refPath: logRef.path,
|
||||||
|
alsoWrite: (tx, user) => {
|
||||||
|
progressWriter(state.ref, questId, key, index + 1, state.rewardedPop + rewarded)(tx);
|
||||||
|
// 걸음은 FPI **HEALTH** 축이다(C-35). 새로 도달한 구간 1개 = 1 단위 —
|
||||||
|
// 구간 보상액(5/6/7/8 POP)은 축 값에 들어가지 않는다(C-34).
|
||||||
|
writeFpiEvent(tx, {
|
||||||
|
uid, fandomId: fandomOfUser(user), axis: 'health', units: 1, activity: 'steps',
|
||||||
|
sourceId: `steps:${questId}:${key}:t${index + 1}`, occurredAt: at, refPath: logRef.path,
|
||||||
|
}, user);
|
||||||
|
// 검증 활동 — 걸음은 구간 수와 무관하게 하루 1회. 같은 날 구간은 같은 표식으로 모인다.
|
||||||
|
markCompletion(tx, uid, `${questId}_${key}`, {
|
||||||
|
questId, txId: txIdOf(`quest:${uid}:${questId}:t${index + 1}:${key}`),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
balanceAfter = result.balanceAfter;
|
||||||
|
txId = result.txId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
txId,
|
||||||
|
balanceAfter,
|
||||||
|
rewarded,
|
||||||
|
questId,
|
||||||
|
clearedTier: cleared,
|
||||||
|
// 다음 구간 문턱. 다 받았으면 0 이다.
|
||||||
|
nextTierSteps: cleared < tiers.length ? tiers[cleared]!.steps : 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 광고 증분 — 원장 B-03 · C-02(B-28).
|
||||||
|
*
|
||||||
|
* - 사용자 생성 Quest(`authorUid` 있음): 고정 [PolicyConfig.adRewardPop] 기여 POP.
|
||||||
|
* - 운영 Quest: 기본 보상 × (배수 − 1) 기여 POP. 배수는 Quest 지정값(`adMultiplier`,
|
||||||
|
* 예: 3) 또는 운영 기본값(x2).
|
||||||
|
*
|
||||||
|
* 기본 보상은 **원장 행에서** 읽는다 — 앱이 보낸 금액을 쓰지 않는다.
|
||||||
|
*/
|
||||||
|
export function adIncrement(
|
||||||
|
quest: { authorUid?: unknown; adMultiplier?: unknown },
|
||||||
|
basePop: number,
|
||||||
|
policy: Pick<PolicyConfig, 'adRewardPop' | 'adMultiplier'>,
|
||||||
|
): number {
|
||||||
|
if (typeof quest.authorUid === 'string' && quest.authorUid.length > 0) {
|
||||||
|
return policy.adRewardPop;
|
||||||
|
}
|
||||||
|
const multiplier = typeof quest.adMultiplier === 'number' && quest.adMultiplier >= 1
|
||||||
|
? quest.adMultiplier
|
||||||
|
: policy.adMultiplier;
|
||||||
|
return Math.floor(basePop * (multiplier - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 기본 보상 원장 행의 멱등키에서 퀘스트 ID 를 꺼낸다(`quest:` · `attend:` 접두 공통 3번째 칸). */
|
||||||
|
function questIdOfKey(key: unknown): string | null {
|
||||||
|
if (typeof key !== 'string') return null;
|
||||||
|
const [prefix, , questId] = key.split(':');
|
||||||
|
return (prefix === 'quest' || prefix === 'attend') && questId ? questId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `fn-ad-reward` — 보상형 광고 증분.
|
||||||
|
*
|
||||||
|
* 앱은 방금 받은 **기본 보상의 원장 txId**(`baseTxId`)와 노출 ID 를 보낸다. 서버는 그
|
||||||
|
* 원장 행이 본인 · 오늘 · 퀘스트 보상인지 확인하고 증분을 기여 POP 으로 지급한다.
|
||||||
|
* 기본 보상 한 건에 광고 보상은 한 번이다(`users/{uid}/adBonuses/{baseTxId}`).
|
||||||
|
*
|
||||||
|
* 일일 한도를 넘으면 E-AD-02. 같은 노출 ID 의 재전송은 첫 지급 결과를 그대로 돌려준다(`duplicate`).
|
||||||
|
*/
|
||||||
|
export const fnAdReward = onCall(async (req) => {
|
||||||
|
const uid = callerUid(req);
|
||||||
|
const impressionId = str(req.data, 'adImpressionId');
|
||||||
|
const network = optionalStr(req.data, 'network') ?? 'unknown';
|
||||||
|
const now = new Date();
|
||||||
|
const key = dayKey(now);
|
||||||
|
const policy = await loadPolicy();
|
||||||
|
const db = getFirestore();
|
||||||
|
|
||||||
|
const logRef = getFirestore().doc(`adRewardLogs/${impressionId}`);
|
||||||
|
// 성공한 호출의 재전송(응답 유실)은 오류가 아니라 **첫 지급 결과**로 돌려준다.
|
||||||
|
const prior = await logRef.get();
|
||||||
|
if (prior.exists) {
|
||||||
|
if (prior.get('uid') !== uid || typeof prior.get('txId') !== 'string') {
|
||||||
|
fail(ERR.popDuplicate, '이미 보상받은 광고입니다.');
|
||||||
|
}
|
||||||
|
const tx = await getFirestore().doc(`users/${uid}/popTransactions/${prior.get('txId')}`).get();
|
||||||
|
return {
|
||||||
|
txId: prior.get('txId') as string,
|
||||||
|
balanceAfter: (tx.get('balanceAfter') as number | undefined) ?? 0,
|
||||||
|
rewarded: (tx.get('amount') as number | undefined) ?? 0,
|
||||||
|
remaining: Math.max(0, policy.adDailyLimit - (await progress(uid, 'ad', key)).count),
|
||||||
|
duplicate: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 멱등 단위는 **그날 n 번째 광고 보상**이다. 예전에는 노출 ID(클라이언트 값)를 키로 써서
|
||||||
|
// 서로 다른 노출 ID 로 동시에 부르면 모두 한도 판정을 통과했다(리뷰 재검토 · ID 전수 점검).
|
||||||
|
// 같은 n 은 같은 키로 모여 한 번만 지급되고, 노출 로그는 지급과 같은 트랜잭션에서 `create`
|
||||||
|
// 해 같은 노출을 두 번 쓰지 못한다.
|
||||||
|
const baseTxId = str(req.data, 'baseTxId');
|
||||||
|
const base = await db.doc(`users/${uid}/popTransactions/${baseTxId}`).get();
|
||||||
|
const questId = questIdOfKey(base.get('idempotencyKey'));
|
||||||
|
const baseAt = base.get('createdAt');
|
||||||
|
if (!base.exists || base.get('type') !== 'grant' || questId === null ||
|
||||||
|
!String(base.get('reasonCode') ?? '').startsWith('QUEST_') ||
|
||||||
|
!(baseAt instanceof Timestamp) || dayKey(baseAt.toDate()) !== key) {
|
||||||
|
fail(ERR.questIncomplete, '광고 추가 보상은 오늘 받은 퀘스트 보상에만 붙습니다.');
|
||||||
|
}
|
||||||
|
const bonusRef = db.doc(`users/${uid}/adBonuses/${baseTxId}`);
|
||||||
|
if ((await bonusRef.get()).exists) {
|
||||||
|
fail(ERR.popDuplicate, '이 퀘스트 보상의 광고 추가 보상은 이미 받았습니다.');
|
||||||
|
}
|
||||||
|
const quest = (await db.doc(`quests/${questId}`).get()).data() ?? {};
|
||||||
|
const amount = adIncrement(quest, (base.get('amount') as number | undefined) ?? 0, policy);
|
||||||
|
if (!Number.isSafeInteger(amount) || amount <= 0) {
|
||||||
|
fail(ERR.questIncomplete, '광고 추가 보상 대상이 아닌 퀘스트입니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = await progress(uid, 'ad', key);
|
||||||
|
if (state.count >= policy.adDailyLimit) {
|
||||||
|
fail(ERR.adLimitExceeded, '오늘 광고 보상 한도를 모두 사용했습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const idempotencyKey = `ad:${uid}:${key}:${state.count + 1}`;
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await grantPop({
|
||||||
|
uid,
|
||||||
|
// B-03 「광고로 늘어난 증분만 기여 POP」 · B-28 「FANUZU 재원」.
|
||||||
|
popType: 'support',
|
||||||
|
amount,
|
||||||
|
reasonCode: 'AD_REWARD',
|
||||||
|
growthSource: 'company_ad_bonus',
|
||||||
|
idempotencyKey,
|
||||||
|
refPath: logRef.path,
|
||||||
|
// 한도 카운터도 지급과 같은 트랜잭션이다. 따로 쓰면 「POP 은 나갔는데 한도는
|
||||||
|
// 그대로」인 창이 열려 광고 보상이 한도를 넘어 나간다.
|
||||||
|
alsoWrite: (tx) => {
|
||||||
|
progressWriter(
|
||||||
|
state.ref, 'ad', key,
|
||||||
|
state.count + 1, state.rewardedPop + amount,
|
||||||
|
)(tx);
|
||||||
|
tx.create(logRef, {
|
||||||
|
uid, network, dayKey: key, txId: txIdOf(idempotencyKey), baseTxId, questId,
|
||||||
|
createdAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
tx.create(bonusRef, { impressionId, amount, createdAt: FieldValue.serverTimestamp() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
const code = (e as { code?: number | string }).code;
|
||||||
|
if (code === 6 || code === 'already-exists') fail(ERR.popDuplicate, '이미 보상받은 광고입니다.');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
txId: result.txId,
|
||||||
|
balanceAfter: result.balanceAfter,
|
||||||
|
// 앱이 보상 화면에 띄우는 값. 운영값이 바뀌어도 화면이 원장과 같은 수를 그린다.
|
||||||
|
rewarded: amount,
|
||||||
|
remaining: policy.adDailyLimit - (state.count + 1),
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { onCall } from 'firebase-functions/v2/https';
|
||||||
|
import { getFirestore, FieldValue, Timestamp } from 'firebase-admin/firestore';
|
||||||
|
|
||||||
|
import { callerUid, num, requireRole, str } from '../core/callable.js';
|
||||||
|
import { ERR, fail, positiveInt } from '../core/errors.js';
|
||||||
|
import { txIdOf } from '../core/ids.js';
|
||||||
|
import { dayKey } from '../core/time.js';
|
||||||
|
import { FPI_AXES, loadPolicy } from '../core/config.js';
|
||||||
|
import { fandomOfUser, markCompletion, writeFpiEvent, writeUserExp } from '../ledger/fpi.js';
|
||||||
|
import { grantPop } from '../ledger/ledger.js';
|
||||||
|
import { sendFandomPush } from '../notify/push.js';
|
||||||
|
import { questOf } from './quest.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 긴급 돌발 타임어택 — 별첨 1 ③-4 「참여 → 검증 → 보상 지급」 · FR-QST-06
|
||||||
|
* 「운영이 지정한 시간대에 돌발 퀘스트를 노출하고 제한 시간 내 참여 → 검증 → 보상 지급」.
|
||||||
|
*
|
||||||
|
* 기준자료에 있는 정의는 이 두 줄뿐이다(화면 대조 원장 `S-H-TIMEATTACK` 「Figma 화면 없음」).
|
||||||
|
* 그래서 **가장 보수적인 해석**으로 만든다 — 해석 확인 요청 대상은 보고서에 남겼다.
|
||||||
|
* - 발동은 운영(master)만. 팬덤 한 곳 · 지금부터 N분.
|
||||||
|
* - 참여 = 창 안에서 서버에 참여를 올리는 것. 판정은 **서버 시각**만 본다.
|
||||||
|
* - 보상은 퀘스트 문서의 `rewardPop` · 1인 1회 · 원장 멱등.
|
||||||
|
* - 보상 상한은 `config/policy.timeAttackMaxPop`(기본 1,000). 지속 시간 상한은
|
||||||
|
* `config/policy.timeAttackMaxMinutes`(기본 1,440 = 24시간).
|
||||||
|
* - 인정 활동이라 User EXP(C-18)를 주고, FPI 는 `config/policy.timeAttackFpiAxis` 축 1 단위
|
||||||
|
* (해석 확인 요청 Q-TA-05 권고 A안 · 기본 COMMUNITY). null 이면 FPI 에 넣지 않는다(B안).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 제목 길이 상한 — 푸시 제목 · 홈 카드 한 줄에 들어가는 길이. */
|
||||||
|
const MAX_TITLE = 60;
|
||||||
|
|
||||||
|
/** 서버 시각이 창 안인가. 끝 시각은 포함하지 않는다. */
|
||||||
|
export function inWindow(
|
||||||
|
now: Date, from: Timestamp | undefined, to: Timestamp | undefined,
|
||||||
|
): boolean {
|
||||||
|
return !!from && !!to && now.getTime() >= from.toMillis() && now.getTime() < to.toMillis();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `fn-quest-timeattack-start` — 운영 발동.
|
||||||
|
*
|
||||||
|
* `quests` 는 master 전용 문서라(`fn-admin-write` MASTER_ONLY) 같은 선을 긋는다.
|
||||||
|
* 재전송은 `requestId` 로 모은다 — 같은 발동이 두 번 눌려 퀘스트 · 푸시가 둘이 되지 않게.
|
||||||
|
*/
|
||||||
|
export const fnQuestTimeAttackStart = onCall(async (req) => {
|
||||||
|
const actorUid = callerUid(req);
|
||||||
|
requireRole(req, ['master']);
|
||||||
|
const fandomId = str(req.data, 'fandomId');
|
||||||
|
const title = str(req.data, 'title');
|
||||||
|
const requestId = str(req.data, 'requestId');
|
||||||
|
const rewardPop = positiveInt(num(req.data, 'rewardPop'), 'rewardPop');
|
||||||
|
const minutes = positiveInt(num(req.data, 'durationMinutes'), 'durationMinutes');
|
||||||
|
const { timeAttackMaxPop, timeAttackMaxMinutes } = await loadPolicy();
|
||||||
|
if (minutes > timeAttackMaxMinutes) {
|
||||||
|
fail(ERR.questIncomplete, `타임어택은 ${timeAttackMaxMinutes}분을 넘길 수 없습니다.`);
|
||||||
|
}
|
||||||
|
// `/` 가 들어가면 문서 경로가 갈라져 다른 문서를 가리킨다.
|
||||||
|
if (fandomId.includes('/')) fail(ERR.questIncomplete, '팬덤 ID 가 올바르지 않습니다.');
|
||||||
|
if (title.length > MAX_TITLE) fail(ERR.questIncomplete, `제목은 ${MAX_TITLE}자 이내입니다.`);
|
||||||
|
if (rewardPop > timeAttackMaxPop) {
|
||||||
|
fail(ERR.questIncomplete, `타임어택 보상은 ${timeAttackMaxPop} POP 을 넘길 수 없습니다.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getFirestore();
|
||||||
|
const fandom = await db.doc(`fandoms/${fandomId}`).get();
|
||||||
|
if (!fandom.exists) fail(ERR.questIncomplete, '없는 팬덤입니다.');
|
||||||
|
|
||||||
|
const questId = `timeattack_${txIdOf(`timeattack:${actorUid}:${requestId}`)}`;
|
||||||
|
const ref = db.doc(`quests/${questId}`);
|
||||||
|
const now = new Date();
|
||||||
|
const activeTo = Timestamp.fromMillis(now.getTime() + minutes * 60_000);
|
||||||
|
try {
|
||||||
|
await ref.create({
|
||||||
|
type: 'timeattack',
|
||||||
|
title,
|
||||||
|
fandomId,
|
||||||
|
rewardPop,
|
||||||
|
dailyLimit: 1,
|
||||||
|
active: true,
|
||||||
|
activeFrom: Timestamp.fromDate(now),
|
||||||
|
activeTo,
|
||||||
|
createdBy: actorUid,
|
||||||
|
createdAt: FieldValue.serverTimestamp(),
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
const code = (e as { code?: number | string }).code;
|
||||||
|
if (code !== 6 && code !== 'already-exists') throw e;
|
||||||
|
const prior = await ref.get();
|
||||||
|
return {
|
||||||
|
questId,
|
||||||
|
activeTo: (prior.get('activeTo') as Timestamp).toDate().toISOString(),
|
||||||
|
pushed: false,
|
||||||
|
duplicate: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.collection('adminActionLogs').add({
|
||||||
|
actorUid, role: 'master', targetPath: ref.path, reason: 'timeattack-start',
|
||||||
|
patch: { fandomId, title, rewardPop, durationMinutes: minutes },
|
||||||
|
existedBefore: false, createdAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 팬덤 이벤트 구분(B-77). 앱은 이 알림을 홈으로 연다 — 타임어택은 홈 TODAY'S QUEST 맨 앞이다.
|
||||||
|
const pushed = await sendFandomPush({
|
||||||
|
fandomId,
|
||||||
|
category: 'fandomEvent',
|
||||||
|
title: '긴급 돌발 타임어택',
|
||||||
|
body: `${title} — ${minutes}분 안에 참여하면 ${rewardPop} POP 을 받습니다.`,
|
||||||
|
data: { kind: 'timeattack', questId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { questId, activeTo: activeTo.toDate().toISOString(), pushed };
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `fn-quest-timeattack` — 참여 · 검증 · 지급.
|
||||||
|
*
|
||||||
|
* 판정은 세 가지다. 대표 팬덤 자격(C-16, [questOf]) · **서버 시각** 창 · 1인 1회.
|
||||||
|
* 멱등키에 날짜를 넣지 않는다 — 창이 자정을 넘어도 두 번 받지 못한다. 접두 `quest:` 는
|
||||||
|
* `fnAdReward` 가 기본 보상 행을 알아보는 규칙이다.
|
||||||
|
*
|
||||||
|
* 이미 받은 사용자의 재호출(응답 유실 재시도)은 오류가 아니라 **첫 지급 결과**를
|
||||||
|
* `duplicate: true` 로 돌려준다 — 마감 뒤 재시도여도 같다. 새 지급은 없다.
|
||||||
|
*/
|
||||||
|
export const fnQuestTimeAttack = onCall(async (req) => {
|
||||||
|
const uid = callerUid(req);
|
||||||
|
const questId = str(req.data, 'questId');
|
||||||
|
const quest = await questOf(uid, questId, ['timeattack']);
|
||||||
|
const { timeAttackFpiAxis: axis } = await loadPolicy();
|
||||||
|
const now = new Date();
|
||||||
|
const idempotencyKey = `quest:${uid}:${questId}:1`;
|
||||||
|
const progressRef = getFirestore().doc(`users/${uid}/questProgress/${questId}`);
|
||||||
|
if (((await progressRef.get()).get('count') as number | undefined ?? 0) >= 1) {
|
||||||
|
const prior = await getFirestore()
|
||||||
|
.doc(`users/${uid}/popTransactions/${txIdOf(idempotencyKey)}`).get();
|
||||||
|
if (!prior.exists) fail(ERR.popDuplicate, '이미 참여한 타임어택입니다.');
|
||||||
|
return {
|
||||||
|
txId: prior.id,
|
||||||
|
balanceAfter: (prior.get('balanceAfter') as number | undefined) ?? 0,
|
||||||
|
rewarded: (prior.get('amount') as number | undefined) ?? 0,
|
||||||
|
questId,
|
||||||
|
duplicate: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (quest.get('active') === false ||
|
||||||
|
!inWindow(now, quest.get('activeFrom') as Timestamp | undefined,
|
||||||
|
quest.get('activeTo') as Timestamp | undefined)) {
|
||||||
|
fail(ERR.questIncomplete, '타임어택 참여 시간이 아닙니다.');
|
||||||
|
}
|
||||||
|
const amount = quest.get('rewardPop');
|
||||||
|
if (!Number.isSafeInteger(amount) || (amount as number) <= 0) {
|
||||||
|
fail(ERR.questIncomplete, '보상이 설정되지 않은 타임어택입니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const result = await grantPop({
|
||||||
|
uid,
|
||||||
|
popType: 'activity',
|
||||||
|
amount: amount as number,
|
||||||
|
reasonCode: 'QUEST_TIMEATTACK',
|
||||||
|
growthSource: 'quest_reward',
|
||||||
|
idempotencyKey,
|
||||||
|
refPath: quest.ref.path,
|
||||||
|
alsoWrite: (tx, user) => {
|
||||||
|
// 인정 활동 1건 = User EXP(C-18) + FPI 축 1 단위(Q-TA-05). 축이 꺼졌거나 모르는 값이면 EXP 만.
|
||||||
|
if (axis && FPI_AXES.includes(axis)) {
|
||||||
|
writeFpiEvent(tx, {
|
||||||
|
uid, fandomId: fandomOfUser(user), axis, units: 1, activity: 'timeattack',
|
||||||
|
sourceId: `timeattack:${questId}`, occurredAt: now, refPath: quest.ref.path,
|
||||||
|
}, user);
|
||||||
|
} else {
|
||||||
|
writeUserExp(tx, uid, user);
|
||||||
|
}
|
||||||
|
markCompletion(tx, uid, `${questId}_${dayKey(now)}`, { questId, txId: txIdOf(idempotencyKey) });
|
||||||
|
tx.set(progressRef, {
|
||||||
|
questId, dayKey: dayKey(now), count: 1, rewardedPop: amount,
|
||||||
|
completedAt: FieldValue.serverTimestamp(), updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
}, { merge: true });
|
||||||
|
// 참여 인원 — 카드의 「참여 인원」이 읽는 필드. 지급과 같은 트랜잭션이라 중복 집계가 없다.
|
||||||
|
tx.update(quest.ref, { participantCount: FieldValue.increment(1) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
txId: result.txId,
|
||||||
|
balanceAfter: result.balanceAfter,
|
||||||
|
rewarded: amount,
|
||||||
|
questId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `fn-server-time` — 서버 시각(ms). 앱이 단말 시계와의 차이를 재어 타임어택 카운트다운 ·
|
||||||
|
* 노출 판정을 서버 시각에 맞춘다. 단말 시계를 돌려도 창이 늘어나 보이지 않게 한다.
|
||||||
|
*/
|
||||||
|
export const fnServerTime = onCall((req) => {
|
||||||
|
callerUid(req);
|
||||||
|
return { now: Date.now() };
|
||||||
|
});
|
||||||
@@ -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]) };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
import { onCall } from 'firebase-functions/v2/https';
|
||||||
|
import { getFirestore, FieldValue, GeoPoint, Timestamp } from 'firebase-admin/firestore';
|
||||||
|
|
||||||
|
import { callerUid, num, optionalStr, str } from '../core/callable.js';
|
||||||
|
import { ERR, fail, positiveInt } from '../core/errors.js';
|
||||||
|
import { deviceHashOf, txIdOf } from '../core/ids.js';
|
||||||
|
import { dayKey, isPlausibleOccurredAt } from '../core/time.js';
|
||||||
|
import { loadPolicy } from '../core/config.js';
|
||||||
|
import { grantPop, type GrantInput } from '../ledger/ledger.js';
|
||||||
|
import { fandomOfUser, markCompletion, writeFpiEvent } from '../ledger/fpi.js';
|
||||||
|
import { dailyLimitOf, progress, progressWriter, questOf } from './quest.js';
|
||||||
|
import { payFromPool } from './ugc.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 검증이 필요한 퀘스트 둘 — Music Shake 와 위치.
|
||||||
|
*
|
||||||
|
* 별첨 1 ③ 판정 기준이 「부정 시도 필터 차단(중복 참여·반복 요청·비정상 패턴)」과
|
||||||
|
* 「보상 전량 서버검증 경유」를 요구한다. **클라이언트가 보낸 판정 결과를 쓰지
|
||||||
|
* 않는 것**이 두 함수의 공통 원칙이다 — 증거만 받고 판정은 서버가 다시 한다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 두 좌표 사이 거리(m). 지구를 구로 보는 하버사인 식이다. */
|
||||||
|
export function distanceMeters(
|
||||||
|
lat1: number, lon1: number, lat2: number, lon2: number,
|
||||||
|
): number {
|
||||||
|
const R = 6_371_000;
|
||||||
|
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||||
|
const dLat = toRad(lat2 - lat1);
|
||||||
|
const dLon = toRad(lon2 - lon1);
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) ** 2 +
|
||||||
|
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||||
|
return 2 * R * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Music Shake 판정 — 설계서 E-02.
|
||||||
|
*
|
||||||
|
* 클라이언트는 구간별 판정표를 증거로 보낸다. 서버는 **그 표가 스스로
|
||||||
|
* 모순되지 않는지**와 **완주했는지**를 본다. 판정 등급을 클라이언트가 정해
|
||||||
|
* 보내면 그대로 믿게 되므로, 등급은 서버가 다시 매긴다.
|
||||||
|
*/
|
||||||
|
export interface ShakeJudgement {
|
||||||
|
perfect: number;
|
||||||
|
good: number;
|
||||||
|
miss: number;
|
||||||
|
maxCombo: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ShakeGrade = 'S' | 'A' | 'B' | 'C' | 'F';
|
||||||
|
|
||||||
|
export function gradeShake(
|
||||||
|
judgement: ShakeJudgement,
|
||||||
|
totalSegments: number,
|
||||||
|
): ShakeGrade {
|
||||||
|
const hit = judgement.perfect + judgement.good;
|
||||||
|
if (totalSegments <= 0 || hit + judgement.miss !== totalSegments) return 'F';
|
||||||
|
const accuracy = judgement.perfect / totalSegments;
|
||||||
|
if (judgement.miss === 0 && accuracy >= 0.95) return 'S';
|
||||||
|
if (accuracy >= 0.8) return 'A';
|
||||||
|
if (accuracy >= 0.6) return 'B';
|
||||||
|
if (hit / totalSegments >= 0.5) return 'C';
|
||||||
|
return 'F';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 등급별 보상 배수. 기본 보상에 곱한다. */
|
||||||
|
const GRADE_MULTIPLIER: Readonly<Record<ShakeGrade, number>> = {
|
||||||
|
S: 2, A: 1.5, B: 1.2, C: 1, F: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** `fn-quest-verify-music-shake` */
|
||||||
|
export const fnQuestVerifyMusicShake = onCall(async (req) => {
|
||||||
|
const uid = callerUid(req);
|
||||||
|
const trackId = str(req.data, 'trackId');
|
||||||
|
const now = new Date();
|
||||||
|
const data = (req.data ?? {}) as Record<string, unknown>;
|
||||||
|
// 곡과 하루 한도는 **퀘스트 문서**가 정한다. 예전 멱등키는 `trackId · 날짜` 라 곡 ID 만
|
||||||
|
// 바꿔 부르면 하루에 무제한 지급됐다(리뷰 재검토 blocker). 곡은 퀘스트의 카탈로그와 대조하고,
|
||||||
|
// 구간 수도 퀘스트에 정의돼 있으면 그 값만 받는다 — 판정표를 줄여 등급을 올리지 못하게.
|
||||||
|
const questId = str(req.data, 'questId');
|
||||||
|
const quest = await questOf(uid, questId, ['musicshake', 'music_shake', 'shake']);
|
||||||
|
const trackIds = Array.isArray(quest.get('trackIds'))
|
||||||
|
? quest.get('trackIds') as unknown[] : [quest.get('trackId')];
|
||||||
|
if (!trackIds.includes(trackId)) {
|
||||||
|
fail(ERR.questIncomplete, '이 퀘스트의 곡이 아닙니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = data.occurredAt;
|
||||||
|
const at = typeof raw === 'string' ? new Date(raw) : now;
|
||||||
|
if (Number.isNaN(at.getTime()) || !isPlausibleOccurredAt(at, now)) {
|
||||||
|
fail(ERR.questAbnormal, '활동 시각이 서버 시각과 크게 어긋납니다.');
|
||||||
|
}
|
||||||
|
const key = dayKey(at);
|
||||||
|
|
||||||
|
const judgement = data.judgement as ShakeJudgement | undefined;
|
||||||
|
const totalSegments = positiveInt(num(req.data, 'totalSegments'), 'totalSegments');
|
||||||
|
if (!judgement) fail(ERR.questIncomplete, '판정 결과가 없습니다.');
|
||||||
|
const questSegments = quest.get('totalSegments');
|
||||||
|
if (typeof questSegments === 'number' && questSegments !== totalSegments) {
|
||||||
|
fail(ERR.questAbnormal, '곡의 구간 수가 퀘스트 정의와 다릅니다.');
|
||||||
|
}
|
||||||
|
const limit = await dailyLimitOf(questId);
|
||||||
|
const state = await progress(uid, questId, key);
|
||||||
|
if (state.count >= limit) {
|
||||||
|
fail(ERR.popDuplicate, '오늘 이 퀘스트의 보상은 이미 받았습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const grade = gradeShake(judgement, totalSegments);
|
||||||
|
const db = getFirestore();
|
||||||
|
const logRef = db.collection(`users/${uid}/activityLogs`).doc();
|
||||||
|
|
||||||
|
await logRef.set({
|
||||||
|
type: 'musicShake',
|
||||||
|
fandomId: optionalStr(req.data, 'fandomId') ?? '',
|
||||||
|
value: judgement.perfect,
|
||||||
|
evidence: { musicShake: { trackId, totalSegments, judgements: judgement } },
|
||||||
|
questId,
|
||||||
|
verified: grade !== 'F',
|
||||||
|
rejectReason: grade === 'F' ? ERR.questIncomplete : null,
|
||||||
|
grade,
|
||||||
|
deviceHash: optionalStr(req.data, 'deviceRaw')
|
||||||
|
? deviceHashOf(str(req.data, 'deviceRaw'))
|
||||||
|
: null,
|
||||||
|
occurredAt: at,
|
||||||
|
createdAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (grade === 'F') {
|
||||||
|
// 판정표가 스스로 모순되거나 완주하지 못한 경우다. 증거는 남기고 보상은 없다.
|
||||||
|
fail(ERR.questIncomplete, '완주하지 못했습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const policy = await loadPolicy();
|
||||||
|
const amount = Math.round(policy.stepsPop * GRADE_MULTIPLIER[grade]);
|
||||||
|
const result = await grantPop({
|
||||||
|
uid,
|
||||||
|
popType: 'activity',
|
||||||
|
amount,
|
||||||
|
reasonCode: 'QUEST_MUSIC_SHAKE',
|
||||||
|
growthSource: 'quest_reward',
|
||||||
|
// 하루 한도는 곡이 아니라 **퀘스트 단위**다(`quests/{id}.dailyLimit`). n 번째 인정이 멱등 단위.
|
||||||
|
idempotencyKey: `quest:${uid}:${questId}:${key}:${state.count + 1}`,
|
||||||
|
refPath: logRef.path,
|
||||||
|
alsoWrite: (tx, user) => {
|
||||||
|
progressWriter(state.ref, questId, key, state.count + 1, state.rewardedPop + amount)(tx);
|
||||||
|
// Music Shake 완주 1회 = FPI **BPM** 1 단위(C-35). 등급 배수 · 보상액과 무관(C-34).
|
||||||
|
writeFpiEvent(tx, {
|
||||||
|
uid, fandomId: fandomOfUser(user), axis: 'bpm', units: 1, activity: 'music_shake',
|
||||||
|
sourceId: `music:${questId}:${key}:${state.count + 1}`, occurredAt: at, refPath: logRef.path,
|
||||||
|
}, user);
|
||||||
|
// 검증 활동 — Music Shake 는 완주마다 1회.
|
||||||
|
markCompletion(tx, uid, `${questId}_${key}_${state.count + 1}`, {
|
||||||
|
questId, txId: txIdOf(`quest:${uid}:${questId}:${key}:${state.count + 1}`),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { grade, txId: result.txId, balanceAfter: result.balanceAfter, rewarded: amount };
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `fn-quest-verify-place` — 방문 인증. 설계서 E-06.
|
||||||
|
*
|
||||||
|
* 정책 원장 **C-03** — 현장 방문 조건은 **GPS / Geofence 가 필수**이고 QR · 사진은
|
||||||
|
* Location 의 **추가 인증 수단(선택)** 이다. 예전 구현은 인증 수단이 없으면 보상을
|
||||||
|
* 막았는데, 앱이 좌표만 보내므로 위치 Quest 가 **한 번도 지급되지 않았다.** 인증 수단이
|
||||||
|
* 오면 판정을 함께 남기고 1회성만 확인한다. 반경은 Admin 운영값이다(D-08).
|
||||||
|
*
|
||||||
|
* 참여 횟수는 **C-12** — 동일 Quest · 동일 사용자 **기본 1회.** 운영자가 허용한 반복형
|
||||||
|
* (`quests.repeat.allowed`)만 직전 **유효** 인증부터 `intervalHours`(기본 24) 간격으로
|
||||||
|
* `maxCount` 까지. 실패는 간격을 다시 시작하지 않는다(진행 문서는 지급과 함께만 쓴다).
|
||||||
|
*/
|
||||||
|
export const fnQuestVerifyPlace = onCall(async (req) => {
|
||||||
|
const uid = callerUid(req);
|
||||||
|
const placeId = str(req.data, 'placeId');
|
||||||
|
const lat = num(req.data, 'lat');
|
||||||
|
const lng = num(req.data, 'lng');
|
||||||
|
// 참여 횟수는 Quest 단위다(C-12 — 같은 장소의 다른 Quest 는 별개). 그래서 퀘스트 문서가
|
||||||
|
// 반드시 있어야 하고 **그 퀘스트의 장소**여야 한다 — 없는 ID 로 바꿔 부르면 1회 제한이
|
||||||
|
// 매번 새로 열린다(리뷰 B2). 대표 팬덤 자격(C-16)도 여기서 본다.
|
||||||
|
const questId = str(req.data, 'questId');
|
||||||
|
const questDoc = await questOf(uid, questId, ['place', 'location']);
|
||||||
|
if (questDoc.get('authorUid') === uid) fail(ERR.permissionDenied, '내가 만든 퀘스트에는 참여할 수 없습니다.');
|
||||||
|
const conditions = questDoc.get('conditions') as { placeId?: unknown } | undefined;
|
||||||
|
const questPlace = questDoc.get('placeId') ?? conditions?.placeId;
|
||||||
|
if (questPlace !== placeId) {
|
||||||
|
fail(ERR.questOutOfRange, '이 퀘스트의 인증 장소가 아닙니다.');
|
||||||
|
}
|
||||||
|
// 인증 수단은 **선택 입력**이다(C-03).
|
||||||
|
const proofKind = optionalStr(req.data, 'proofKind') ?? '';
|
||||||
|
const proof = optionalStr(req.data, 'proof') ?? '';
|
||||||
|
const mocked = (req.data as Record<string, unknown>)?.mocked === true;
|
||||||
|
const accuracyM = (req.data as Record<string, unknown>)?.accuracyM;
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const db = getFirestore();
|
||||||
|
const place = await db.doc(`places/${placeId}`).get();
|
||||||
|
if (!place.exists) fail(ERR.questOutOfRange, '등록되지 않은 장소입니다.');
|
||||||
|
|
||||||
|
const geo = place.get('geo') as GeoPoint | undefined;
|
||||||
|
if (!geo) fail(ERR.questOutOfRange, '장소 좌표가 없습니다.');
|
||||||
|
// 반경은 Admin 운영값이다(D-08 · 별첨 B **B-27** 기본 100m). 문서에 값이 없을 때만
|
||||||
|
// 기본값을 쓰고, 앱에는 두지 않는다.
|
||||||
|
const radius = (place.get('radiusM') as number | undefined) ?? 100;
|
||||||
|
const gap = distanceMeters(lat, lng, geo.latitude, geo.longitude);
|
||||||
|
|
||||||
|
const logRef = db.collection(`users/${uid}/activityLogs`).doc();
|
||||||
|
const inRange = gap <= radius;
|
||||||
|
const proofGiven = (proofKind === 'photo' || proofKind === 'qr') && proof.length > 0;
|
||||||
|
// 판정 순서는 **조작 → 반경**이다. 모의 위치는 좌표 자체를 믿을 수 없다는 뜻이라
|
||||||
|
// 반경 판정보다 앞선다.
|
||||||
|
const reject = mocked
|
||||||
|
? ERR.questAbnormal
|
||||||
|
: !inRange
|
||||||
|
? ERR.questOutOfRange
|
||||||
|
: null;
|
||||||
|
|
||||||
|
await logRef.set({
|
||||||
|
type: proofKind === 'qr' ? 'placeQr' : proofKind === 'photo' ? 'placePhoto' : 'placeGps',
|
||||||
|
fandomId: optionalStr(req.data, 'fandomId') ?? '',
|
||||||
|
value: Math.round(gap),
|
||||||
|
evidence: {
|
||||||
|
place: {
|
||||||
|
placeId,
|
||||||
|
// 정확도와 모의 위치 여부를 함께 남긴다 — 설계서 §2.1 「위치 사진 경로·GPS
|
||||||
|
// 좌표·정확도」와 §8 「mock location 차단」의 판정 입력이다.
|
||||||
|
gps: { lat, lng, ...(typeof accuracyM === 'number' ? { accuracyM } : {}), mocked },
|
||||||
|
...(proofGiven ? { proof: { kind: proofKind, ref: proof } } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
questId,
|
||||||
|
verified: reject === null,
|
||||||
|
rejectReason: reject,
|
||||||
|
occurredAt: now,
|
||||||
|
createdAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mocked) {
|
||||||
|
// 별첨 1 ③ 예외 처리 「위치 스푸핑 — 서버검증 필터 차단」.
|
||||||
|
fail(ERR.questAbnormal, '모의 위치로 판정해 보상을 지급하지 않았습니다.');
|
||||||
|
}
|
||||||
|
if (!inRange) {
|
||||||
|
fail(ERR.questOutOfRange, '해당 장소에서 너무 멀리 떨어져 있습니다.');
|
||||||
|
}
|
||||||
|
// C-12 참여 횟수 · 간격. 판정은 진행 문서의 **직전 유효 인증**만 본다.
|
||||||
|
const progressSnap = await db.doc(`users/${uid}/questProgress/${questId}`).get();
|
||||||
|
const repeat = (questDoc.get('repeat') ?? {}) as { allowed?: unknown; intervalHours?: unknown; maxCount?: unknown };
|
||||||
|
const repeatable = repeat.allowed === true;
|
||||||
|
const intervalHours = typeof repeat.intervalHours === 'number' && repeat.intervalHours > 0
|
||||||
|
? repeat.intervalHours : 24;
|
||||||
|
const maxCount = !repeatable ? 1
|
||||||
|
: typeof repeat.maxCount === 'number' && repeat.maxCount > 0 ? repeat.maxCount : Infinity;
|
||||||
|
const count = (progressSnap.get('count') as number | undefined) ?? 0;
|
||||||
|
const lastAt = progressSnap.get('lastVerifiedAt') as Timestamp | undefined;
|
||||||
|
if (count >= maxCount) {
|
||||||
|
fail(ERR.popDuplicate, '이미 참여한 퀘스트입니다.');
|
||||||
|
}
|
||||||
|
if (lastAt && now.getTime() - lastAt.toMillis() < intervalHours * 3_600_000) {
|
||||||
|
fail(ERR.popDuplicate, `다음 인증은 직전 인증 ${intervalHours}시간 뒤부터 가능합니다.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 추가 인증 수단이 왔으면 한 번만 쓴다. 재사용은 같은 방문을 여러 번 인정하는 셈이다.
|
||||||
|
if (proofGiven) {
|
||||||
|
try {
|
||||||
|
await db.doc(`placeProofs/${proofKind}_${proof}`).create({
|
||||||
|
uid, placeId, createdAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
fail(ERR.popDuplicate, '이미 사용한 인증입니다.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용자 생성 Quest 는 생성자 Reward Pool 에서 Quest 의 보상액을 지급한다(C-02 · C-12 예치 한도).
|
||||||
|
const author = questDoc.get('authorUid');
|
||||||
|
const ugc = typeof author === 'string' && author.length > 0;
|
||||||
|
const policy = await loadPolicy();
|
||||||
|
const amount = ugc
|
||||||
|
? (questDoc.get('rewardPop') as number | undefined) ?? 0
|
||||||
|
: (place.get('rewardPop') as number | undefined) ?? policy.attendPop;
|
||||||
|
const grant: GrantInput = {
|
||||||
|
uid,
|
||||||
|
popType: 'activity',
|
||||||
|
amount,
|
||||||
|
reasonCode: 'QUEST_PLACE',
|
||||||
|
growthSource: 'quest_reward',
|
||||||
|
// n 번째 유효 인증이 멱등 단위다. 동시 요청은 같은 키로 모여 한 번만 지급된다.
|
||||||
|
idempotencyKey: `quest:${uid}:${questId}:${count + 1}`,
|
||||||
|
refPath: logRef.path,
|
||||||
|
alsoWrite: (tx, user) => {
|
||||||
|
tx.set(progressSnap.ref, {
|
||||||
|
questId, count: count + 1, lastVerifiedAt: Timestamp.fromDate(now),
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
}, { merge: true });
|
||||||
|
// 성지 · 생일 팝업 · 응원 현장 방문 = FPI **COMMUNITY** 1 단위(C-35). 장소 보상액과 무관(C-34).
|
||||||
|
writeFpiEvent(tx, {
|
||||||
|
uid, fandomId: fandomOfUser(user), axis: 'community', units: 1, activity: 'place',
|
||||||
|
sourceId: `place:${questId}:${count + 1}`, occurredAt: now, refPath: logRef.path,
|
||||||
|
}, user);
|
||||||
|
// 검증 활동 — 방문 1회(같은 날 반복형은 하루 1회로 모인다).
|
||||||
|
markCompletion(tx, uid, `${questId}_${dayKey(now)}`, {
|
||||||
|
questId, txId: txIdOf(`quest:${uid}:${questId}:${count + 1}`),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const result = ugc ? await payFromPool(questDoc.ref, amount, false, grant) : await grantPop(grant);
|
||||||
|
|
||||||
|
return {
|
||||||
|
txId: result.txId,
|
||||||
|
balanceAfter: result.balanceAfter,
|
||||||
|
rewarded: amount,
|
||||||
|
distanceM: Math.round(gap),
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -5,7 +5,7 @@ import { fnAdminWrite } from '../src/admin/admin.js';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* `fn-admin-write` 권한 경계 — firestore.rules 에서 master 전용인 문서를 ops 가 이 함수로
|
* `fn-admin-write` 권한 경계 — firestore.rules 에서 master 전용인 문서를 ops 가 이 함수로
|
||||||
* 우회해 쓰지 못한다. 거부도 `adminActionLogs` 에 남는다(FR-ADM-01).
|
* 우회해 쓰지 못한다(S05 조사). 거부도 `adminActionLogs` 에 남는다(FR-ADM-01).
|
||||||
*/
|
*/
|
||||||
const run = (role: string | undefined, targetPath: string,
|
const run = (role: string | undefined, targetPath: string,
|
||||||
patch: Record<string, unknown> = { attendPop: 999 }) =>
|
patch: Record<string, unknown> = { attendPop: 999 }) =>
|
||||||
@@ -46,7 +46,7 @@ it('ops 는 master 전용이 아닌 문서는 그대로 쓴다 · 역할 없는
|
|||||||
expect(await denials('admin-none')).toBe(1);
|
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>) =>
|
const denied = (role: string, path: string, patch?: Record<string, unknown>) =>
|
||||||
expect(run(role, path, patch)).rejects.toMatchObject({ details: { code: 'E-PERM-01' } });
|
expect(run(role, path, patch)).rejects.toMatchObject({ details: { code: 'E-PERM-01' } });
|
||||||
await denied('master', 'fandoms/f1/fpiEvents/e1');
|
await denied('master', 'fandoms/f1/fpiEvents/e1');
|
||||||
|
|||||||
@@ -0,0 +1,367 @@
|
|||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { getFirestore, Timestamp } from 'firebase-admin/firestore';
|
||||||
|
import type { CallableRequest } from 'firebase-functions/v2/https';
|
||||||
|
|
||||||
|
import { bootAdmin, shutdownAdmin } from './support.js';
|
||||||
|
import {
|
||||||
|
candidateDocId, fnBriefingIngest, fnBriefingPublish, validateCandidate,
|
||||||
|
} from '../src/briefing/briefing.js';
|
||||||
|
import { dayKey } from '../src/core/time.js';
|
||||||
|
import { fandomTopic, notifyEclipseWarning } from '../src/notify/push.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI Daily Briefing 검증 — 정책 원장 B-47 · B-48 · B-75.
|
||||||
|
*
|
||||||
|
* 보는 것은 셋이다.
|
||||||
|
* - **수신이 5 요소를 강제하는가**(B-75 「메타 누락 건은 Draft 단계에서 배제」)
|
||||||
|
* - **수신이 draft 로만 끝나는가**(B-47 「AI Draft 자동게시 금지」)
|
||||||
|
* - **승인 권한이 함수에 실제로 붙어 있는가**
|
||||||
|
*
|
||||||
|
* 실제 FCM 발송은 확인하지 않는다 — 배포 전이고 에뮬레이터에 FCM 이 없다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const FANDOM = 'test-brief-fandom';
|
||||||
|
|
||||||
|
/** 5 요소를 모두 갖춘 정상 후보. 각 검증이 여기서 한 값만 빼서 쓴다. */
|
||||||
|
function candidate(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
fandomId: FANDOM,
|
||||||
|
section: 'News',
|
||||||
|
title: '데뷔 10주년 단독 콘서트 확정',
|
||||||
|
sourceName: '연합뉴스',
|
||||||
|
sourceAt: '2026-09-14T01:20:00Z',
|
||||||
|
sourceUrl: 'https://example.com/news/1',
|
||||||
|
articleUrl: 'https://example.com/news/1',
|
||||||
|
aiSummary: '서울 공연 이틀로 확정되었고 예매는 다음 주에 열립니다.',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = (role: string | undefined, data: unknown) => ({
|
||||||
|
auth: role ? { uid: 'admin-1', token: { role } } : undefined,
|
||||||
|
data,
|
||||||
|
}) as unknown as CallableRequest;
|
||||||
|
|
||||||
|
/** 섹션별 rank 목록. 정렬해 두어야 비교가 흔들리지 않는다. */
|
||||||
|
function ranksBySection(
|
||||||
|
docs: FirebaseFirestore.QueryDocumentSnapshot[],
|
||||||
|
): Record<string, number[]> {
|
||||||
|
const bySection: Record<string, number[]> = {};
|
||||||
|
for (const doc of docs) {
|
||||||
|
const section = doc.get('section') as string;
|
||||||
|
bySection[section] = [...(bySection[section] ?? []), doc.get('rank') as number];
|
||||||
|
}
|
||||||
|
for (const key of Object.keys(bySection)) bySection[key]!.sort((a, b) => a - b);
|
||||||
|
return bySection;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => { bootAdmin(); });
|
||||||
|
afterAll(async () => { await shutdownAdmin(); });
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const db = getFirestore();
|
||||||
|
const briefs = await db.collection('briefings')
|
||||||
|
.where('fandomId', '==', FANDOM).get();
|
||||||
|
await Promise.all(briefs.docs.map((d) => d.ref.delete()));
|
||||||
|
// 작업 로그도 지운다. 남겨 두면 게시 검증의 로그 건수가 회차마다 늘어난다.
|
||||||
|
const logs = await db.collection('adminActionLogs')
|
||||||
|
.where('targetType', '==', 'briefing').get();
|
||||||
|
await Promise.all(logs.docs.map((d) => d.ref.delete()));
|
||||||
|
const eclipses = await db.collection(`fandoms/${FANDOM}/eclipseRounds`).get();
|
||||||
|
await Promise.all(eclipses.docs.map((d) => d.ref.delete()));
|
||||||
|
await db.doc(`fandoms/${FANDOM}`).set({ name: '테스트행성', stage: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('B-75 5 요소 검증', () => {
|
||||||
|
it('다 갖춘 후보를 통과시킨다', () => {
|
||||||
|
expect(validateCandidate(candidate()).ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('5 요소 중 하나만 빠져도 거부한다', () => {
|
||||||
|
for (const key of [
|
||||||
|
'title', 'sourceName', 'sourceAt', 'sourceUrl', 'articleUrl', 'aiSummary',
|
||||||
|
]) {
|
||||||
|
const result = validateCandidate(candidate({ [key]: undefined }));
|
||||||
|
expect(result.ok, key).toBe(false);
|
||||||
|
if (!result.ok) expect(result.reason, key).toContain(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('빈 문자열과 공백을 누락으로 본다 — 수집기가 흔히 채워 보낸다', () => {
|
||||||
|
for (const value of ['', ' ']) {
|
||||||
|
expect(validateCandidate(candidate({ sourceName: value })).ok).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('빠진 것을 한 번에 모아서 알린다', () => {
|
||||||
|
const result = validateCandidate(
|
||||||
|
candidate({ sourceUrl: undefined, aiSummary: undefined }),
|
||||||
|
);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.reason).toContain('sourceUrl');
|
||||||
|
expect(result.reason).toContain('aiSummary');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('섹션은 News 와 SNS 만 받는다', () => {
|
||||||
|
expect(validateCandidate(candidate({ section: 'SNS' })).ok).toBe(true);
|
||||||
|
for (const bad of ['activity', 'news', '', undefined]) {
|
||||||
|
expect(validateCandidate(candidate({ section: bad })).ok, String(bad)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('시각 형식과 URL 형태까지 본다', () => {
|
||||||
|
expect(validateCandidate(candidate({ sourceAt: '어제' })).ok).toBe(false);
|
||||||
|
expect(validateCandidate(candidate({ articleUrl: 'javascript:alert(1)' })).ok)
|
||||||
|
.toBe(false);
|
||||||
|
expect(validateCandidate(candidate({ sourceUrl: 'example.com/1' })).ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('같은 원본 URL 은 같은 문서 ID 로 모인다', () => {
|
||||||
|
const a = validateCandidate(candidate());
|
||||||
|
const b = validateCandidate(candidate({ title: '제목만 바뀐 같은 기사' }));
|
||||||
|
expect(a.ok && b.ok).toBe(true);
|
||||||
|
if (a.ok && b.ok) {
|
||||||
|
expect(candidateDocId(a.value, '2026-09-14'))
|
||||||
|
.toBe(candidateDocId(b.value, '2026-09-14'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('수신 — fn-briefing-ingest (export 직접 호출)', () => {
|
||||||
|
it('통과 건만 저장하고 거부 사유를 함께 돌려준다', async () => {
|
||||||
|
const result = await fnBriefingIngest.run(req('ops', {
|
||||||
|
items: [
|
||||||
|
candidate(),
|
||||||
|
candidate({ section: 'SNS', sourceUrl: 'https://example.com/sns/1' }),
|
||||||
|
candidate({ sourceName: undefined, sourceUrl: 'https://example.com/news/2' }),
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(result.accepted).toBe(2);
|
||||||
|
expect(result.rejected).toHaveLength(1);
|
||||||
|
expect(result.rejected[0]!.index).toBe(2);
|
||||||
|
expect(result.rejected[0]!.reason).toContain('sourceName');
|
||||||
|
|
||||||
|
const docs = await getFirestore().collection('briefings')
|
||||||
|
.where('fandomId', '==', FANDOM).get();
|
||||||
|
expect(docs.size).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('수신은 언제나 draft 로 끝난다 — 자동 게시 0 건', async () => {
|
||||||
|
await fnBriefingIngest.run(req('master', { items: [candidate()] }));
|
||||||
|
const docs = await getFirestore().collection('briefings')
|
||||||
|
.where('fandomId', '==', FANDOM).get();
|
||||||
|
expect(docs.size).toBe(1);
|
||||||
|
const doc = docs.docs[0]!;
|
||||||
|
expect(doc.get('status')).toBe('draft');
|
||||||
|
expect(doc.get('aiGenerated')).toBe(true);
|
||||||
|
expect(doc.get('dayKey')).toBe(dayKey(new Date()));
|
||||||
|
// B-75 5 요소가 문서에 전건 남아 있어야 화면이 그릴 수 있다.
|
||||||
|
for (const field of [
|
||||||
|
'sourceName', 'sourceAt', 'sourceUrl', 'articleUrl', 'aiSummary',
|
||||||
|
]) {
|
||||||
|
expect(doc.get(field), field).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rank 를 섹션마다 따로 매긴다', async () => {
|
||||||
|
await fnBriefingIngest.run(req('ops', {
|
||||||
|
items: [
|
||||||
|
candidate({ sourceUrl: 'https://example.com/n/1' }),
|
||||||
|
candidate({ sourceUrl: 'https://example.com/n/2' }),
|
||||||
|
candidate({ section: 'SNS', sourceUrl: 'https://example.com/s/1' }),
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
const docs = await getFirestore().collection('briefings')
|
||||||
|
.where('fandomId', '==', FANDOM).get();
|
||||||
|
expect(ranksBySection(docs.docs)).toEqual({ News: [1, 2], SNS: [1] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('같은 회차를 두 번 받아도 문서가 늘지 않는다', async () => {
|
||||||
|
const items = [candidate()];
|
||||||
|
await fnBriefingIngest.run(req('ops', { items }));
|
||||||
|
await fnBriefingIngest.run(req('ops', { items }));
|
||||||
|
const docs = await getFirestore().collection('briefings')
|
||||||
|
.where('fandomId', '==', FANDOM).get();
|
||||||
|
expect(docs.size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('빈 목록과 권한 없는 호출을 끊는다', async () => {
|
||||||
|
await expect(fnBriefingIngest.run(req('ops', { items: [] })))
|
||||||
|
.rejects.toThrowError('수신할 브리핑 항목이 없습니다.');
|
||||||
|
await expect(fnBriefingIngest.run(req('viewer', { items: [candidate()] })))
|
||||||
|
.rejects.toThrowError('권한이 없습니다.');
|
||||||
|
await expect(fnBriefingIngest.run(req(undefined, { items: [candidate()] })))
|
||||||
|
.rejects.toThrowError('로그인이 필요합니다.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('전건 거부면 저장이 없다', async () => {
|
||||||
|
const result = await fnBriefingIngest.run(req('ops', {
|
||||||
|
items: [candidate({ aiSummary: undefined })],
|
||||||
|
}));
|
||||||
|
expect(result.accepted).toBe(0);
|
||||||
|
const docs = await getFirestore().collection('briefings')
|
||||||
|
.where('fandomId', '==', FANDOM).get();
|
||||||
|
expect(docs.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('승인 — fn-briefing-publish (export 직접 호출)', () => {
|
||||||
|
/** 당일 초안 3 건(News 2 · SNS 1)을 수신 경로로 심는다. */
|
||||||
|
async function seedDrafts(): Promise<void> {
|
||||||
|
await fnBriefingIngest.run(req('ops', {
|
||||||
|
items: [
|
||||||
|
candidate({ sourceUrl: 'https://example.com/n/1' }),
|
||||||
|
candidate({ sourceUrl: 'https://example.com/n/2' }),
|
||||||
|
candidate({ section: 'SNS', sourceUrl: 'https://example.com/s/1' }),
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
it('viewer 는 게시하지 못하고 초안이 그대로 남는다', async () => {
|
||||||
|
await seedDrafts();
|
||||||
|
await expect(
|
||||||
|
fnBriefingPublish.run(req('viewer', {
|
||||||
|
fandomId: FANDOM, dayKey: dayKey(new Date()),
|
||||||
|
})),
|
||||||
|
).rejects.toThrowError('권한이 없습니다.');
|
||||||
|
|
||||||
|
const docs = await getFirestore().collection('briefings')
|
||||||
|
.where('fandomId', '==', FANDOM).get();
|
||||||
|
expect(docs.docs.every((d) => d.get('status') === 'draft')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('로그인하지 않은 호출은 끊는다', async () => {
|
||||||
|
await expect(
|
||||||
|
fnBriefingPublish.run(req(undefined, {
|
||||||
|
fandomId: FANDOM, dayKey: dayKey(new Date()),
|
||||||
|
})),
|
||||||
|
).rejects.toThrowError('로그인이 필요합니다.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('초안이 없으면 게시할 것이 없다고 알린다', async () => {
|
||||||
|
await expect(
|
||||||
|
fnBriefingPublish.run(req('master', {
|
||||||
|
fandomId: FANDOM, dayKey: dayKey(new Date()),
|
||||||
|
})),
|
||||||
|
).rejects.toThrowError('게시할 브리핑 초안이 없습니다.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('master 는 게시하고 섹션별 건수까지 로그에 남긴다', async () => {
|
||||||
|
const db = getFirestore();
|
||||||
|
const day = dayKey(new Date());
|
||||||
|
await seedDrafts();
|
||||||
|
|
||||||
|
const result = await fnBriefingPublish.run(
|
||||||
|
req('master', { fandomId: FANDOM, dayKey: day }),
|
||||||
|
);
|
||||||
|
expect(result.published).toBe(3);
|
||||||
|
expect(result.bySection).toEqual({ News: 2, SNS: 1 });
|
||||||
|
// FCM 자격 증명이 없는 환경이라 발송은 실패한다. **게시는 되돌리지 않는다** —
|
||||||
|
// 관리자가 본 결과와 사용자가 보는 화면이 갈리지 않게 한 설계 그대로다.
|
||||||
|
expect(result.pushed).toBe(false);
|
||||||
|
|
||||||
|
const docs = await db.collection('briefings')
|
||||||
|
.where('fandomId', '==', FANDOM).get();
|
||||||
|
expect(docs.docs.every((d) => d.get('status') === 'published')).toBe(true);
|
||||||
|
expect(docs.docs.every((d) => d.get('publishedBy') === 'admin-1')).toBe(true);
|
||||||
|
|
||||||
|
const logs = await db.collection('adminActionLogs')
|
||||||
|
.where('targetPath', '==', `briefings/${FANDOM}_${day}`).get();
|
||||||
|
expect(logs.size).toBe(1);
|
||||||
|
expect(logs.docs[0]!.get('reason')).toBe('BRIEFING_PUBLISH');
|
||||||
|
expect(logs.docs[0]!.get('role')).toBe('master');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('당일 필터 — 앱이 읽는 질의', () => {
|
||||||
|
it('게시된 당일분만 고르고 초안과 전일분은 제외한다', async () => {
|
||||||
|
const db = getFirestore();
|
||||||
|
const today = dayKey(new Date());
|
||||||
|
const yesterday = dayKey(new Date(Date.now() - 24 * 60 * 60 * 1000));
|
||||||
|
await db.doc(`briefings/${FANDOM}_${today}_News_a`).set({
|
||||||
|
fandomId: FANDOM, dayKey: today, section: 'News', rank: 1,
|
||||||
|
status: 'published', headline: '오늘 게시분',
|
||||||
|
});
|
||||||
|
await db.doc(`briefings/${FANDOM}_${today}_News_b`).set({
|
||||||
|
fandomId: FANDOM, dayKey: today, section: 'News', rank: 2,
|
||||||
|
status: 'draft', headline: '오늘 초안',
|
||||||
|
});
|
||||||
|
await db.doc(`briefings/${FANDOM}_${yesterday}_News_a`).set({
|
||||||
|
fandomId: FANDOM, dayKey: yesterday, section: 'News', rank: 1,
|
||||||
|
status: 'published', headline: '어제 게시분',
|
||||||
|
});
|
||||||
|
|
||||||
|
const visible = await db.collection('briefings')
|
||||||
|
.where('fandomId', '==', FANDOM)
|
||||||
|
.where('status', '==', 'published')
|
||||||
|
.where('dayKey', '==', today)
|
||||||
|
.orderBy('rank')
|
||||||
|
.get();
|
||||||
|
expect(visible.docs.map((d) => d.get('headline'))).toEqual(['오늘 게시분']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('푸시 토픽', () => {
|
||||||
|
it('구분마다 토픽을 나눈다 — OFF 구분은 구독 자체가 없다', () => {
|
||||||
|
expect(fandomTopic('bts', 'briefing')).toBe('fandom_bts_briefing');
|
||||||
|
expect(fandomTopic('bts', 'marketing')).toBe('fandom_bts_marketing');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FCM 이 받지 않는 문자를 걸러 낸다', () => {
|
||||||
|
expect(fandomTopic('b t/s', 'briefing')).toBe('fandom_b-t-s_briefing');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('이클립스 사전 알림 — 시작 − 3분 (원장 C-22)', () => {
|
||||||
|
/// 예약 회차 한 건을 심는다. [startInMin] 분 뒤 시작, 예고는 그 3분 전이다.
|
||||||
|
async function round(id: string, startInMin: number | null, extra: Record<string, unknown> = {}) {
|
||||||
|
const ref = getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${id}`);
|
||||||
|
const start = Date.now() + (startInMin ?? 0) * 60_000;
|
||||||
|
await ref.set({
|
||||||
|
status: 'scheduled',
|
||||||
|
...(startInMin === null ? {} : {
|
||||||
|
startAt: Timestamp.fromMillis(start),
|
||||||
|
warnAt: Timestamp.fromMillis(start - 180_000),
|
||||||
|
}),
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('예고 시각이 지나고 시작 전이면 보내려 하고 판정 시각을 남긴다', async () => {
|
||||||
|
const ref = await round('fresh', 3);
|
||||||
|
const now = new Date();
|
||||||
|
// FCM 자격 증명이 없는 환경이라 발송 자체는 실패한다. 판정이 발송까지 갔다는 것이 확인 대상이다.
|
||||||
|
expect(await notifyEclipseWarning(await ref.get(), now)).toBe('failed');
|
||||||
|
expect((await ref.get()).get('alertPushedAt').toMillis()).toBe(now.getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('예고 시각 전에는 보내지 않고 표시도 남기지 않는다', async () => {
|
||||||
|
const ref = await round('early', 5);
|
||||||
|
expect(await notifyEclipseWarning(await ref.get())).toBe('too-early');
|
||||||
|
expect((await ref.get()).get('alertPushedAt')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('시작이 지난 회차에는 예고를 소급 발송하지 않는다', async () => {
|
||||||
|
const ref = await round('stale', -1);
|
||||||
|
expect(await notifyEclipseWarning(await ref.get())).toBe('too-old');
|
||||||
|
expect((await ref.get()).get('alertPushedAt')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('시각을 모르거나 취소된 회차에는 보내지 않는다', async () => {
|
||||||
|
const unknown = await round('unknown', null);
|
||||||
|
expect(await notifyEclipseWarning(await unknown.get())).toBe('no-start');
|
||||||
|
expect((await unknown.get()).get('alertPushedAt')).toBeUndefined();
|
||||||
|
const cancelled = await round('cancelled', 3, { status: 'cancelled' });
|
||||||
|
expect(await notifyEclipseWarning(await cancelled.get())).toBe('already');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('같은 회차에 두 번 보내지 않는다 — 1 분 주기 점검이 도배하지 않는다', async () => {
|
||||||
|
const ref = await round('once', 3);
|
||||||
|
await notifyEclipseWarning(await ref.get());
|
||||||
|
expect(await notifyEclipseWarning(await ref.get())).toBe('already');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,494 @@
|
|||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { FieldValue, getFirestore, Timestamp } from 'firebase-admin/firestore';
|
||||||
|
|
||||||
|
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
|
||||||
|
import { grantPop, refundPop } from '../src/ledger/ledger.js';
|
||||||
|
import { fundCampaign } from '../src/fandom/fandom.js';
|
||||||
|
import { createCampaign, retryFeeRefunds, withdrawCampaign } from '../src/campaign/create.js';
|
||||||
|
import {
|
||||||
|
MAX_AUTO_REFUND_ATTEMPTS, cancelCampaign, fnCampaignAdminReview, fnCampaignReportSubmit, refundCampaign,
|
||||||
|
retryPendingRefunds, settleDueCampaigns, submitCampaignReport,
|
||||||
|
} from '../src/campaign/settle.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캠페인 개설 · 정산 · 자동 환불 · 결과보고 — 별첨 1 ⑤ 판정 기준
|
||||||
|
* 「성공/실패 분기 정확 · 자동환불 무결성(부분 실패 방치·중복 환불 0건) · 결과보고 게시 확인」.
|
||||||
|
*
|
||||||
|
* 기간 단축은 `endsAt` 을 과거로 옮기고 정산 코어를 직접 부르는 것으로 한다 — 10분 스케줄을
|
||||||
|
* 기다리지 않고 같은 코드가 도는지를 본다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CREATOR = 'test-cmp-creator';
|
||||||
|
|
||||||
|
/** 검증 활동(Quest 완료) 표식 n건 — Campaign 생성자격 5회(A안). */
|
||||||
|
async function completions(uid: string, n: number) {
|
||||||
|
for (let i = 0; i < n; i++) await getFirestore().doc(`users/${uid}/verifiedCompletions/q${i}_2026-09-0${i + 1}`).set({ questId: `q${i}` });
|
||||||
|
}
|
||||||
|
const A = 'test-cmp-donor-a';
|
||||||
|
const B = 'test-cmp-donor-b';
|
||||||
|
const db = () => getFirestore();
|
||||||
|
|
||||||
|
beforeAll(() => { bootAdmin(); });
|
||||||
|
afterAll(async () => { await shutdownAdmin(); });
|
||||||
|
beforeEach(async () => {
|
||||||
|
for (const uid of [CREATOR, A, B]) {
|
||||||
|
await db().recursiveDelete(db().collection(`users/${uid}/notifications`));
|
||||||
|
await wipeUser(uid);
|
||||||
|
}
|
||||||
|
// 정산·재시도는 컬렉션 전체를 훑으므로 다른 회차가 남긴 캠페인을 지운다.
|
||||||
|
await db().recursiveDelete(db().collection('campaigns'));
|
||||||
|
await db().recursiveDelete(db().collection('adminActionLogs'));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function code(run: () => Promise<unknown>): Promise<string> {
|
||||||
|
try {
|
||||||
|
await run();
|
||||||
|
} catch (error) {
|
||||||
|
const e = error as { details?: { code?: string }; code?: string };
|
||||||
|
return e.details?.code ?? e.code ?? String(error);
|
||||||
|
}
|
||||||
|
throw new Error('실패해야 하는 호출이 성공했습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 모금 중 캠페인과 후원들을 만들고 마감을 지난 시각으로 옮긴다(기간 단축). */
|
||||||
|
async function fundedCampaign(id: string, target: number, gifts: Array<[string, number]>) {
|
||||||
|
await db().doc(`campaigns/${id}`).set({
|
||||||
|
creatorUid: CREATOR, title: `캠페인 ${id}`, status: 'funding',
|
||||||
|
targetPop: target, raisedPop: 0, donorCount: 0, refundState: 'none',
|
||||||
|
endsAt: Timestamp.fromMillis(Date.now() + 60_000),
|
||||||
|
});
|
||||||
|
for (const [uid, amount] of gifts) {
|
||||||
|
await grantPop({
|
||||||
|
uid, popType: 'support', amount: 1_000,
|
||||||
|
reasonCode: 'ADMIN_GRANT', idempotencyKey: `seed:${id}:${uid}`,
|
||||||
|
});
|
||||||
|
await fundCampaign({ uid, campaignId: id, clientSeq: '1', amount });
|
||||||
|
}
|
||||||
|
await db().doc(`campaigns/${id}`).update({ endsAt: Timestamp.fromMillis(Date.now() - 1_000) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const supportOf = async (uid: string) =>
|
||||||
|
(await db().doc(`users/${uid}/wallet/main`).get()).get('supportBalance') as number;
|
||||||
|
|
||||||
|
const refundRows = async (uid: string) => (await db()
|
||||||
|
.collection(`users/${uid}/popTransactions`)
|
||||||
|
.where('reasonCode', '==', 'CAMPAIGN_REFUND').get()).size;
|
||||||
|
|
||||||
|
const feeRefundRows = async (uid: string) => (await db()
|
||||||
|
.collection(`users/${uid}/popTransactions`)
|
||||||
|
.where('reasonCode', '==', 'CAMPAIGN_FEE_REFUND').get()).size;
|
||||||
|
|
||||||
|
const notes = async (uid: string) =>
|
||||||
|
(await db().collection(`users/${uid}/notifications`).get()).docs.map((d) => d.id);
|
||||||
|
|
||||||
|
describe('개설 — fn-campaign-create', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await db().doc(`users/${CREATOR}`).set({
|
||||||
|
displayName: '김실명', nickname: '개설자', fandomId: 'f-cmp', status: 'active',
|
||||||
|
createdAt: Timestamp.fromMillis(Date.now() - 30 * 86_400_000),
|
||||||
|
});
|
||||||
|
await completions(CREATOR, 5);
|
||||||
|
});
|
||||||
|
const draft = { uid: CREATOR, title: '지하철 광고', body: '5주년', targetPop: 500, days: 7 };
|
||||||
|
|
||||||
|
it('등록비 100 POP(일반)을 빼고 review 로 올린다 · 같은 요청 재시도는 한 건', async () => {
|
||||||
|
await grantPop({ uid: CREATOR, popType: 'activity', amount: 150, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'g:fee' });
|
||||||
|
|
||||||
|
const first = await createCampaign({ ...draft, clientSeq: 's1' });
|
||||||
|
const again = await createCampaign({ ...draft, clientSeq: 's1' });
|
||||||
|
expect(again.campaignId).toBe(first.campaignId);
|
||||||
|
|
||||||
|
const doc = await db().doc(`campaigns/${first.campaignId}`).get();
|
||||||
|
expect(doc.get('status')).toBe('review');
|
||||||
|
expect(doc.get('fandomId')).toBe('f-cmp');
|
||||||
|
expect(doc.get('targetPop')).toBe(500);
|
||||||
|
// 개설자 이름은 공개 닉네임 — 공급자 실명(displayName)을 싣지 않는다(M-7).
|
||||||
|
expect(doc.get('creatorName')).toBe('개설자');
|
||||||
|
expect((await db().doc(`users/${CREATOR}/wallet/main`).get()).get('activityBalance')).toBe(50);
|
||||||
|
const fees = await db().collection(`users/${CREATOR}/popTransactions`)
|
||||||
|
.where('reasonCode', '==', 'CAMPAIGN_FEE').get();
|
||||||
|
expect(fees.size).toBe(1);
|
||||||
|
|
||||||
|
// 진행 중 1건 보유 시 2건째 차단(B-56).
|
||||||
|
expect(await code(() => createCampaign({ ...draft, clientSeq: 's2' }))).toBe('E-PERM-01');
|
||||||
|
|
||||||
|
// 운영 승인으로 모금이 열린다.
|
||||||
|
await fnCampaignAdminReview.run({
|
||||||
|
data: { campaignId: first.campaignId, requestId: 'r1', approve: true, memo: 'ok' },
|
||||||
|
auth: { uid: 'ops-user', token: { role: 'ops' } },
|
||||||
|
} as never);
|
||||||
|
expect((await db().doc(`campaigns/${first.campaignId}`).get()).get('status')).toBe('funding');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('검증 활동(Quest 완료) 5회 미만은 E-CMP-04 · 남은 횟수를 싣는다(Campaign 개설자격 A안)', async () => {
|
||||||
|
await grantPop({ uid: CREATOR, popType: 'activity', amount: 150, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'g:elig' });
|
||||||
|
await db().recursiveDelete(db().collection(`users/${CREATOR}/verifiedCompletions`));
|
||||||
|
await completions(CREATOR, 3);
|
||||||
|
const err = await createCampaign({ ...draft, clientSeq: 'e1' }).then(() => null, (e: unknown) => e);
|
||||||
|
expect((err as { details?: Record<string, unknown> }).details).toEqual({ code: 'E-CMP-04', done: 3, need: 5 });
|
||||||
|
expect((await db().collection('campaigns').get()).size).toBe(0);
|
||||||
|
// 기준 횟수는 운영값이다.
|
||||||
|
await db().doc('config/campaign').set({ minVerifiedQuests: 3 }, { merge: true });
|
||||||
|
try {
|
||||||
|
expect((await createCampaign({ ...draft, clientSeq: 'e2' })).status).toBe('review');
|
||||||
|
} finally {
|
||||||
|
await db().doc('config/campaign').update({ minVerifiedQuests: FieldValue.delete() });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('잔액 부족 · 가입 7일 미만은 거부하고 캠페인을 남기지 않는다', async () => {
|
||||||
|
expect(await code(() => createCampaign({ ...draft, clientSeq: 'poor' }))).toBe('E-POP-01');
|
||||||
|
await db().doc(`users/${CREATOR}`).update({ createdAt: Timestamp.now() });
|
||||||
|
expect(await code(() => createCampaign({ ...draft, clientSeq: 'new' }))).toBe('E-PERM-01');
|
||||||
|
expect((await db().collection('campaigns').get()).size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('등록비 분할 · 반환 (B-57 · B-58)', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await db().doc(`users/${CREATOR}`).set({
|
||||||
|
displayName: '개설자', fandomId: 'f-cmp', status: 'active',
|
||||||
|
createdAt: Timestamp.fromMillis(Date.now() - 30 * 86_400_000),
|
||||||
|
});
|
||||||
|
await completions(CREATOR, 5);
|
||||||
|
await grantPop({ uid: CREATOR, popType: 'activity', amount: 40, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'g:fee:a' });
|
||||||
|
await grantPop({ uid: CREATOR, popType: 'support', amount: 500, reasonCode: 'IAP_PURCHASE', idempotencyKey: 'g:fee:s' });
|
||||||
|
});
|
||||||
|
const draft = { uid: CREATOR, title: '광고', body: '본문', targetPop: 500, days: 7 };
|
||||||
|
const walletOf = async () => {
|
||||||
|
const w = await db().doc(`users/${CREATOR}/wallet/main`).get();
|
||||||
|
return [w.get('activityBalance'), w.get('supportBalance')];
|
||||||
|
};
|
||||||
|
const review = (campaignId: string, requestId: string, approve: boolean) => fnCampaignAdminReview.run({
|
||||||
|
data: { campaignId, requestId, approve, memo: 'm' },
|
||||||
|
auth: { uid: 'ops-user', token: { role: 'ops' } },
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
it('일반 40 + 기여 60 으로 나눠 내고, 반려되면 각 타입으로 한 번만 돌려받는다', async () => {
|
||||||
|
const { campaignId } = await createCampaign({ ...draft, clientSeq: 'split' });
|
||||||
|
expect(await walletOf()).toEqual([0, 440]);
|
||||||
|
const fee = await db().doc(`campaigns/${campaignId}`).get();
|
||||||
|
expect((fee.get('feeTxIds') as string[]).length).toBe(2);
|
||||||
|
|
||||||
|
await review(campaignId, 'r-reject', false);
|
||||||
|
await review(campaignId, 'r-reject', false);
|
||||||
|
const doc = await db().doc(`campaigns/${campaignId}`).get();
|
||||||
|
expect(doc.get('status')).toBe('rejected');
|
||||||
|
expect(doc.get('feeRefund')).toBe('done');
|
||||||
|
expect(await walletOf()).toEqual([40, 500]);
|
||||||
|
expect(await feeRefundRows(CREATOR)).toBe(2);
|
||||||
|
expect(await refundRows(CREATOR)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('반려 전이 뒤 반환 전에 끊겨도 — 새 요청 ID 의 재시도와 스케줄이 마저 돌려준다', async () => {
|
||||||
|
const { campaignId } = await createCampaign({ ...draft, clientSeq: 'crash' });
|
||||||
|
// 트랜잭션은 섰고 반환 호출 전에 죽은 상태.
|
||||||
|
await db().doc(`campaigns/${campaignId}`).update({ status: 'rejected', feeRefund: 'due' });
|
||||||
|
expect(await code(() => review(campaignId, 'r-new', false))).toBe('E-CMP-03');
|
||||||
|
expect(await walletOf()).toEqual([40, 500]);
|
||||||
|
expect((await db().doc(`campaigns/${campaignId}`).get()).get('feeRefund')).toBe('done');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('스케줄(retryFeeRefunds)이 남은 반환을 한 번만 돌린다 · 옛 feeTxId 한 건도', async () => {
|
||||||
|
const { campaignId } = await createCampaign({ ...draft, clientSeq: 'sweep' });
|
||||||
|
const ref = db().doc(`campaigns/${campaignId}`);
|
||||||
|
const [general] = (await ref.get()).get('feeTxIds') as string[];
|
||||||
|
// 분할 전 데이터 모양 — 단일 feeTxId(일반 몫만 남긴다).
|
||||||
|
await ref.update({ status: 'rejected', feeRefund: 'due', feeTxIds: FieldValue.delete(), feeTxId: general });
|
||||||
|
expect(await retryFeeRefunds()).toBe(1);
|
||||||
|
expect(await retryFeeRefunds()).toBe(0);
|
||||||
|
expect(await walletOf()).toEqual([40, 440]);
|
||||||
|
expect(await feeRefundRows(CREATOR)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('승인과 철회가 겹치면 한쪽 판정만 선다', async () => {
|
||||||
|
const { campaignId } = await createCampaign({ ...draft, clientSeq: 'race' });
|
||||||
|
const [approved] = await Promise.allSettled([
|
||||||
|
review(campaignId, 'r-race', true),
|
||||||
|
withdrawCampaign(CREATOR, campaignId),
|
||||||
|
]);
|
||||||
|
const doc = await db().doc(`campaigns/${campaignId}`).get();
|
||||||
|
if (approved.status === 'fulfilled') {
|
||||||
|
// 승인이 먼저 — 철회는 공개 후 첫 후원 전 철회라 비환불.
|
||||||
|
expect(['funding', 'withdrawn']).toContain(doc.get('status'));
|
||||||
|
expect(await feeRefundRows(CREATOR)).toBe(0);
|
||||||
|
} else {
|
||||||
|
expect(doc.get('status')).toBe('withdrawn');
|
||||||
|
expect(await feeRefundRows(CREATOR)).toBe(2);
|
||||||
|
expect((await db().collection(`campaigns/${campaignId}/reviews`).get()).size).toBe(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('공개 전 철회는 등록비를 돌려주고 재시도해도 한 번뿐이다', async () => {
|
||||||
|
const { campaignId } = await createCampaign({ ...draft, clientSeq: 'wd' });
|
||||||
|
expect((await withdrawCampaign(CREATOR, campaignId)).status).toBe('withdrawn');
|
||||||
|
await withdrawCampaign(CREATOR, campaignId);
|
||||||
|
expect(await walletOf()).toEqual([40, 500]);
|
||||||
|
expect(await feeRefundRows(CREATOR)).toBe(2);
|
||||||
|
// 철회한 캠페인은 진행 중 1건에 세지 않는다.
|
||||||
|
await createCampaign({ ...draft, clientSeq: 'wd2' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('공개 후 첫 후원 전 철회는 되지만 등록비는 비환불 · 첫 후원 뒤에는 E-PERM-01', async () => {
|
||||||
|
const { campaignId } = await createCampaign({ ...draft, clientSeq: 'pub' });
|
||||||
|
await review(campaignId, 'r-ok', true);
|
||||||
|
expect(await code(() => withdrawCampaign('someone-else', campaignId))).toBe('E-PERM-01');
|
||||||
|
await withdrawCampaign(CREATOR, campaignId);
|
||||||
|
expect((await db().doc(`campaigns/${campaignId}`).get()).get('status')).toBe('withdrawn');
|
||||||
|
expect(await walletOf()).toEqual([0, 440]);
|
||||||
|
expect(await feeRefundRows(CREATOR)).toBe(0);
|
||||||
|
|
||||||
|
await fundedCampaign('donated', 1_000, [[A, 10]]);
|
||||||
|
await db().doc('campaigns/donated').update({ endsAt: Timestamp.fromMillis(Date.now() + 60_000) });
|
||||||
|
expect(await code(() => withdrawCampaign(CREATOR, 'donated'))).toBe('E-PERM-01');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('회사귀책 취소 — fn-campaign-admin-cancel (B-58)', () => {
|
||||||
|
it('등록비와 후원 전액을 한 번만 돌려주고 개설자 · 후원자에게 알린다', async () => {
|
||||||
|
await db().doc(`users/${CREATOR}`).set({
|
||||||
|
displayName: '개설자', fandomId: 'f-cmp', status: 'active',
|
||||||
|
createdAt: Timestamp.fromMillis(Date.now() - 30 * 86_400_000),
|
||||||
|
});
|
||||||
|
await completions(CREATOR, 5);
|
||||||
|
await grantPop({ uid: CREATOR, popType: 'activity', amount: 100, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'g:cancel' });
|
||||||
|
const { campaignId } = await createCampaign({
|
||||||
|
uid: CREATOR, title: '광고', body: '본문', targetPop: 1_000, days: 7, clientSeq: 'cancel',
|
||||||
|
});
|
||||||
|
await fnCampaignAdminReview.run({
|
||||||
|
data: { campaignId, requestId: 'ok', approve: true, memo: 'm' },
|
||||||
|
auth: { uid: 'ops-user', token: { role: 'ops' } },
|
||||||
|
} as never);
|
||||||
|
for (const [uid, amount] of [[A, 30], [B, 50]] as const) {
|
||||||
|
await grantPop({ uid, popType: 'support', amount: 100, reasonCode: 'ADMIN_GRANT', idempotencyKey: `seed:cancel:${uid}` });
|
||||||
|
await fundCampaign({ uid, campaignId, clientSeq: '1', amount });
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = await cancelCampaign(campaignId, 'ops-user', '운영 오류');
|
||||||
|
expect(first.refund.refunded).toBe(2);
|
||||||
|
await cancelCampaign(campaignId, 'ops-user', '운영 오류');
|
||||||
|
|
||||||
|
const doc = await db().doc(`campaigns/${campaignId}`).get();
|
||||||
|
expect(doc.get('status')).toBe('cancelled');
|
||||||
|
expect(doc.get('refundState')).toBe('done');
|
||||||
|
expect(doc.get('feeRefund')).toBe('done');
|
||||||
|
expect(await supportOf(A)).toBe(100);
|
||||||
|
expect(await supportOf(B)).toBe(100);
|
||||||
|
expect(await refundRows(A)).toBe(1);
|
||||||
|
expect(await refundRows(B)).toBe(1);
|
||||||
|
expect(await feeRefundRows(CREATOR)).toBe(1);
|
||||||
|
expect((await db().doc(`users/${CREATOR}/wallet/main`).get()).get('activityBalance')).toBe(100);
|
||||||
|
expect(await notes(CREATOR)).toEqual([`campaignCancel_${campaignId}`]);
|
||||||
|
expect((await notes(A)).length).toBe(1);
|
||||||
|
// 환불 알림 링크가 취소 문서를 열도록 규칙이 보는 후원자 표식(backers/{uid}).
|
||||||
|
const backers = await db().collection(`campaigns/${campaignId}/backers`).get();
|
||||||
|
expect(backers.docs.map((d) => d.id).sort()).toEqual([A, B].sort());
|
||||||
|
|
||||||
|
// 성공 · 실패로 끝난 캠페인은 취소 대상이 아니다.
|
||||||
|
await db().doc('campaigns/done').set({ creatorUid: CREATOR, status: 'success' });
|
||||||
|
expect(await code(() => cancelCampaign('done', 'ops-user', 'x'))).toBe('E-CMP-03');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('정산 분기', () => {
|
||||||
|
it('목표(targetPop) 달성은 success, 미달은 fail 이다', async () => {
|
||||||
|
await fundedCampaign('cmp-ok', 500, [[A, 300], [B, 200]]);
|
||||||
|
await fundedCampaign('cmp-short', 1_000, [[A, 100]]);
|
||||||
|
|
||||||
|
expect(await settleDueCampaigns()).toBe(2);
|
||||||
|
expect(await settleDueCampaigns()).toBe(0); // 두 번 판정하지 않는다.
|
||||||
|
|
||||||
|
const ok = await db().doc('campaigns/cmp-ok').get();
|
||||||
|
const short = await db().doc('campaigns/cmp-short').get();
|
||||||
|
expect(ok.get('status')).toBe('success');
|
||||||
|
expect(short.get('status')).toBe('fail');
|
||||||
|
expect(short.get('refundState')).toBe('pending');
|
||||||
|
expect(short.get('refundDueAt').toMillis() - Date.now()).toBeGreaterThan(71 * 3600_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('마감이 지난 캠페인은 정산 전이라도 후원을 받지 않는다', async () => {
|
||||||
|
await fundedCampaign('cmp-late', 1_000, [[A, 100]]);
|
||||||
|
expect(await code(() => fundCampaign({ uid: A, campaignId: 'cmp-late', clientSeq: '2', amount: 50 })))
|
||||||
|
.toBe('E-CMP-03');
|
||||||
|
expect((await db().doc('campaigns/cmp-late').get()).get('raisedPop')).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('자동 환불', () => {
|
||||||
|
it('실패 판정 뒤 관리자 호출 없이 전건을 돌려주고 알린다 · 재실행해도 중복 0', async () => {
|
||||||
|
await fundedCampaign('cmp-fail', 1_000, [[A, 300], [B, 200]]);
|
||||||
|
await settleDueCampaigns();
|
||||||
|
await retryPendingRefunds();
|
||||||
|
|
||||||
|
const campaign = await db().doc('campaigns/cmp-fail').get();
|
||||||
|
expect(campaign.get('refundState')).toBe('done');
|
||||||
|
expect(campaign.get('refundedCount')).toBe(2);
|
||||||
|
expect(campaign.get('lockedUntil')).toBeUndefined();
|
||||||
|
expect(await supportOf(A)).toBe(1_000);
|
||||||
|
expect(await supportOf(B)).toBe(1_000);
|
||||||
|
const donation = (await db().collection('campaigns/cmp-fail/donations').where('uid', '==', A).get()).docs[0]!;
|
||||||
|
expect(await notes(A)).toEqual([`campaignRefund_${donation.get('refundTxId')}`]);
|
||||||
|
|
||||||
|
expect(await retryPendingRefunds()).toBe(0);
|
||||||
|
await refundCampaign('cmp-fail', { manual: true });
|
||||||
|
expect(await refundRows(A)).toBe(1);
|
||||||
|
expect(await notes(A)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('스케줄과 Admin 배치가 겹쳐도 후원 한 건당 한 번만 돌려준다', async () => {
|
||||||
|
await fundedCampaign('cmp-race', 1_000, [[A, 300], [B, 200]]);
|
||||||
|
await settleDueCampaigns();
|
||||||
|
|
||||||
|
const [auto, manual] = await Promise.all([
|
||||||
|
retryPendingRefunds(),
|
||||||
|
refundCampaign('cmp-race', { manual: true }),
|
||||||
|
]);
|
||||||
|
expect(auto + (manual.skipped ? 0 : 1)).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(await refundRows(A)).toBe(1);
|
||||||
|
expect(await refundRows(B)).toBe(1);
|
||||||
|
expect(await supportOf(A)).toBe(1_000);
|
||||||
|
expect((await db().doc('campaigns/cmp-race').get()).get('refundState')).toBe('done');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('잠금이 살아 있으면 건너뛴다', async () => {
|
||||||
|
await fundedCampaign('cmp-lock', 1_000, [[A, 300]]);
|
||||||
|
await settleDueCampaigns();
|
||||||
|
await db().doc('campaigns/cmp-lock').update({ lockedUntil: Timestamp.fromMillis(Date.now() + 60_000) });
|
||||||
|
expect((await refundCampaign('cmp-lock', { manual: true })).skipped).toBe('locked');
|
||||||
|
expect(await refundRows(A)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('다른 경로로 이미 돌려준 원거래는 환불 끝으로 보고, 알림이 빠졌으면 다시 보낸다', async () => {
|
||||||
|
await fundedCampaign('cmp-dup', 1_000, [[A, 300]]);
|
||||||
|
await settleDueCampaigns();
|
||||||
|
const donation = (await db().collection('campaigns/cmp-dup/donations').get()).docs[0]!;
|
||||||
|
await refundPop({
|
||||||
|
uid: A, originalTxId: donation.get('txId'),
|
||||||
|
idempotencyKey: 'manual-other-key', reasonCode: 'CAMPAIGN_REFUND',
|
||||||
|
});
|
||||||
|
|
||||||
|
await retryPendingRefunds();
|
||||||
|
expect((await db().doc('campaigns/cmp-dup').get()).get('refundState')).toBe('done');
|
||||||
|
expect(await refundRows(A)).toBe(1);
|
||||||
|
expect(await supportOf(A)).toBe(1_000);
|
||||||
|
expect(await notes(A)).toHaveLength(1);
|
||||||
|
|
||||||
|
// 환불 표시는 남고 알림 전에 죽은 경우 — 알림만 다시 간다.
|
||||||
|
await db().recursiveDelete(db().collection(`users/${A}/notifications`));
|
||||||
|
await donation.ref.update({ refundNotifiedAt: null });
|
||||||
|
await db().doc('campaigns/cmp-dup').update({ refundState: 'partial' });
|
||||||
|
await retryPendingRefunds();
|
||||||
|
expect(await notes(A)).toHaveLength(1);
|
||||||
|
expect(await refundRows(A)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('부분 실패는 partial 로 남아 자동 재시도하고, 상한에서 Admin 큐로 간다', async () => {
|
||||||
|
await fundedCampaign('cmp-partial', 1_000, [[A, 300]]);
|
||||||
|
await db().doc('campaigns/cmp-partial/donations/broken').set({ uid: B, amount: 50 });
|
||||||
|
await settleDueCampaigns();
|
||||||
|
|
||||||
|
await retryPendingRefunds();
|
||||||
|
let campaign = await db().doc('campaigns/cmp-partial').get();
|
||||||
|
expect(campaign.get('refundState')).toBe('partial');
|
||||||
|
expect(campaign.get('refundFailedCount')).toBe(1);
|
||||||
|
expect(campaign.get('refundAttempts')).toBe(1);
|
||||||
|
expect(await supportOf(A)).toBe(1_000);
|
||||||
|
expect((await db().collection('adminActionLogs').get()).size).toBe(0);
|
||||||
|
|
||||||
|
for (let i = 1; i < MAX_AUTO_REFUND_ATTEMPTS; i++) await retryPendingRefunds();
|
||||||
|
campaign = await db().doc('campaigns/cmp-partial').get();
|
||||||
|
expect(campaign.get('refundAttempts')).toBe(MAX_AUTO_REFUND_ATTEMPTS);
|
||||||
|
expect(campaign.get('refundQueuedAt')).toBeTruthy();
|
||||||
|
const queue = await db().collection('adminActionLogs').get();
|
||||||
|
expect(queue.size).toBe(1);
|
||||||
|
expect(queue.docs[0]?.get('targetPath')).toBe('campaigns/cmp-partial');
|
||||||
|
|
||||||
|
expect(await retryPendingRefunds()).toBe(0);
|
||||||
|
expect(await refundRows(A)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('옛 정산의 failed 는 targetPop 으로 다시 판정한다 — 목표를 채웠으면 돌려주지 않는다', async () => {
|
||||||
|
// 예전 정산은 goalPop 만 읽어 목표를 채운 캠페인도 failed 로 적었다(refundAttempts 없음).
|
||||||
|
await fundedCampaign('cmp-legacy-met', 500, [[A, 500]]);
|
||||||
|
await fundedCampaign('cmp-legacy-short', 1_000, [[B, 200]]);
|
||||||
|
for (const id of ['cmp-legacy-met', 'cmp-legacy-short']) {
|
||||||
|
await db().doc(`campaigns/${id}`).update({ status: 'failed', refundState: 'pending' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await retryPendingRefunds();
|
||||||
|
const met = await db().doc('campaigns/cmp-legacy-met').get();
|
||||||
|
expect(met.get('status')).toBe('failed');
|
||||||
|
expect(met.get('refundState')).toBe('pending');
|
||||||
|
expect(await refundRows(A)).toBe(0);
|
||||||
|
expect(await supportOf(A)).toBe(500);
|
||||||
|
expect((await refundCampaign('cmp-legacy-met', { manual: true })).skipped).toBe('legacy-goal-met');
|
||||||
|
|
||||||
|
const short = await db().doc('campaigns/cmp-legacy-short').get();
|
||||||
|
expect(short.get('status')).toBe('fail');
|
||||||
|
expect(short.get('refundState')).toBe('done');
|
||||||
|
expect(await supportOf(B)).toBe(1_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('결과보고 — 운영 전용 (B-35)', () => {
|
||||||
|
const file = (id: string, name: string) => `campaignReports/${id}/${name}`;
|
||||||
|
const uploaded = async () => true;
|
||||||
|
const report = (id: string) => ({
|
||||||
|
uid: 'ops-user', campaignId: id, body: '광고 집행 완료',
|
||||||
|
photoPaths: [file(id, 'p1.png')],
|
||||||
|
receiptPaths: [file(id, 'r1.png')],
|
||||||
|
budget: [{ label: '광고 집행', amount: 450 }, { label: '제작비', amount: 50 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
it('성공 캠페인에 올리면 report 로 바뀌고 후원자에게 알린다 · 재호출은 중복이고 빠진 알림만 보낸다', async () => {
|
||||||
|
await fundedCampaign('cmp-rep', 500, [[A, 500]]);
|
||||||
|
await settleDueCampaigns();
|
||||||
|
|
||||||
|
expect(await submitCampaignReport(report('cmp-rep'), uploaded)).toEqual({ duplicate: false });
|
||||||
|
expect((await db().doc('campaigns/cmp-rep').get()).get('status')).toBe('report');
|
||||||
|
const doc = await db().doc('campaigns/cmp-rep/reports/main').get();
|
||||||
|
expect(doc.get('receiptPaths')).toEqual(['campaignReports/cmp-rep/r1.png']);
|
||||||
|
expect(doc.get('budgetTotal')).toBe(500);
|
||||||
|
expect(await notes(A)).toEqual(['campaignReport_cmp-rep_main']);
|
||||||
|
|
||||||
|
// 게시 뒤 알림 전에 죽은 경우.
|
||||||
|
await db().recursiveDelete(db().collection(`users/${A}/notifications`));
|
||||||
|
await doc.ref.update({ notifiedAt: null });
|
||||||
|
expect(await submitCampaignReport(report('cmp-rep'), uploaded)).toEqual({ duplicate: true });
|
||||||
|
expect(await notes(A)).toEqual(['campaignReport_cmp-rep_main']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('운영이 아니면 거부한다(개설자 포함)', async () => {
|
||||||
|
await fundedCampaign('cmp-rep-role', 500, [[A, 500]]);
|
||||||
|
await settleDueCampaigns();
|
||||||
|
expect(await code(() => fnCampaignReportSubmit.run({
|
||||||
|
data: { ...report('cmp-rep-role') }, auth: { uid: CREATOR, token: {} },
|
||||||
|
} as never))).toBe('E-PERM-01');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('실패 캠페인 · 경로 우회 · URL · 올라가지 않은 파일 · 빈 영수증·예산은 거부한다', async () => {
|
||||||
|
await fundedCampaign('cmp-rep2', 500, [[A, 500]]);
|
||||||
|
await fundedCampaign('cmp-rep-fail', 1_000, [[B, 100]]);
|
||||||
|
await settleDueCampaigns();
|
||||||
|
const r = report('cmp-rep2');
|
||||||
|
|
||||||
|
expect(await code(() => submitCampaignReport(report('cmp-rep-fail'), uploaded))).toBe('E-CMP-03');
|
||||||
|
for (const bad of [
|
||||||
|
'campaignReports/cmp-rep2/../other/p.png',
|
||||||
|
'campaignReports/cmp-rep2/sub/p.png',
|
||||||
|
'campaignReports/other/p.png',
|
||||||
|
'users/ops-user/campaignReports/cmp-rep2/p.png',
|
||||||
|
'https://firebasestorage.googleapis.com/v0/b/x/o/campaignReports%2Fcmp-rep2%2Fp.png',
|
||||||
|
'campaignReports/cmp-rep2/..',
|
||||||
|
]) {
|
||||||
|
expect(await code(() => submitCampaignReport({ ...r, photoPaths: [bad] }, uploaded))).toBe('invalid-argument');
|
||||||
|
}
|
||||||
|
expect(await code(() => submitCampaignReport(r, async () => false))).toBe('invalid-argument');
|
||||||
|
expect(await code(() => submitCampaignReport({ ...r, receiptPaths: [] }, uploaded))).toBe('invalid-argument');
|
||||||
|
expect(await code(() => submitCampaignReport({ ...r, budget: [] }, uploaded))).toBe('invalid-argument');
|
||||||
|
expect(await code(() => submitCampaignReport(
|
||||||
|
{ ...r, budget: [{ label: '광고', amount: -1 }] }, uploaded,
|
||||||
|
))).toBe('invalid-argument');
|
||||||
|
expect((await db().doc('campaigns/cmp-rep2').get()).get('status')).toBe('success');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,488 @@
|
|||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { getFirestore, FieldValue, Timestamp, Transaction } from 'firebase-admin/firestore';
|
||||||
|
|
||||||
|
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
|
||||||
|
import { grantPop } from '../src/ledger/ledger.js';
|
||||||
|
import { aggregateActiveEclipses, defendEclipse } from '../src/fandom/fandom.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이클립스 방어 = **팬덤 공동 Shake 참여** · POP 소모 없음.
|
||||||
|
*
|
||||||
|
* 근거 — 정책 원장 **B-55** 비고 · 정책설계서 v1.1 255~264행 · Firestore 설계서 v1.1
|
||||||
|
* §9-#5: *방어는 Shake 참여로 이루어지며 1 회당 POP 소모를 두지 않는다*. 별첨 1 ①
|
||||||
|
* 시나리오 4 의 「번개 연타·오토 도네이션」은 별첨 B 가 상위 기준이라 따르지 않는다
|
||||||
|
* (공통 기준 2항).
|
||||||
|
*
|
||||||
|
* 보는 것
|
||||||
|
* 1. **POP 이 움직이지 않는다** — 지갑과 원장이 그대로다.
|
||||||
|
* 2. **분산 카운터 유실 0** — 샤드를 흩어도 합이 참여 총량과 같다(별첨 1 ① 예외 처리).
|
||||||
|
* 3. **멱등** — 재시도가 샤드·방어 로그·티커 이벤트를 두 번 올리지 않는다(검수 D1·D6).
|
||||||
|
* 4. **서버 검증** — 단말이 보낸 흔들기 수를 상한과 속도로 다시 판정한다(별첨 1 ②).
|
||||||
|
* 5. **즉시 성공 판정** — 목표에 닿은 순간 `defended` 가 선다(검수 D2).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const FANDOM = 'test-ecl-fandom';
|
||||||
|
const ECLIPSE = 'test-ecl-1';
|
||||||
|
const A = 'test-ecl-user-a';
|
||||||
|
const B = 'test-ecl-user-b';
|
||||||
|
|
||||||
|
/** 지갑 기준액. 방어가 POP 을 쓰지 않으므로 끝까지 이 값이어야 한다. */
|
||||||
|
const WALLET = 10_000;
|
||||||
|
|
||||||
|
beforeAll(() => { bootAdmin(); });
|
||||||
|
// 남긴 팬덤은 다음 파일의 팬덤 전수 질의에 섞인다. 끝나고 치운다.
|
||||||
|
afterAll(async () => {
|
||||||
|
await wipeUser(A);
|
||||||
|
await wipeUser(B);
|
||||||
|
await wipeEclipse();
|
||||||
|
await shutdownAdmin();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function wipeEclipse(): Promise<void> {
|
||||||
|
const db = getFirestore();
|
||||||
|
const root = db.doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`);
|
||||||
|
for (const sub of ['shards', 'defenseLogs', 'participants']) {
|
||||||
|
const snap = await root.collection(sub).get();
|
||||||
|
await Promise.all(snap.docs.map((d) => d.ref.delete()));
|
||||||
|
}
|
||||||
|
const live = await db.collection(`fandoms/${FANDOM}/liveEvents`).get();
|
||||||
|
await Promise.all(live.docs.map((d) => d.ref.delete()));
|
||||||
|
await root.delete();
|
||||||
|
await db.doc(`fandoms/${FANDOM}`).delete();
|
||||||
|
await db.doc('config/eclipse').delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 활성 이클립스 하나와 사용자 둘을 세운다.
|
||||||
|
*
|
||||||
|
* 기본 설정은 **속도 제한을 끈다**(`minIntervalMs: 0`). 테스트는 같은 사용자로 연속
|
||||||
|
* 호출해 유실과 멱등을 보는데, 속도 제한이 켜져 있으면 그 확인 자체가 막힌다.
|
||||||
|
* 속도 제한은 전용 테스트에서 따로 켜서 본다.
|
||||||
|
*/
|
||||||
|
async function seed(overrides: Record<string, unknown> = {}): Promise<void> {
|
||||||
|
const db = getFirestore();
|
||||||
|
const target = (overrides.target as number | undefined) ?? 1_000_000;
|
||||||
|
await db.doc('config/eclipse').set({
|
||||||
|
enabled: true,
|
||||||
|
perCallMaxShakes: 50,
|
||||||
|
perUserMaxDefense: 500,
|
||||||
|
minIntervalMs: 0,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
await db.doc(`fandoms/${FANDOM}`).set({ name: 'BTS', fandomName: 'BTS' });
|
||||||
|
await db.doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`).set({
|
||||||
|
status: 'active',
|
||||||
|
target,
|
||||||
|
erosionRate: 100,
|
||||||
|
defense: 0,
|
||||||
|
defenseCount: 0,
|
||||||
|
defended: false,
|
||||||
|
startAt: FieldValue.serverTimestamp(),
|
||||||
|
endsAt: Timestamp.fromMillis(Date.now() + 30 * 60 * 1000),
|
||||||
|
});
|
||||||
|
const people: [string, string][] = [[A, '아미보이'], [B, '버니즈']];
|
||||||
|
for (const [uid, nickname] of people) {
|
||||||
|
await db.doc(`users/${uid}`).set({ uid, nickname, fandomId: FANDOM });
|
||||||
|
await grantPop({
|
||||||
|
uid, popType: 'activity', amount: WALLET,
|
||||||
|
reasonCode: 'ADMIN_GRANT', idempotencyKey: `g:ecl:${uid}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 샤드 합. 「분산 카운터로 카운트 유실 없음」의 확인 수단이다. */
|
||||||
|
async function shardSum(): Promise<{ defense: number; calls: number }> {
|
||||||
|
const snap = await getFirestore()
|
||||||
|
.collection(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}/shards`).get();
|
||||||
|
return {
|
||||||
|
defense: snap.docs.reduce((a, d) => a + ((d.get('defense') as number) ?? 0), 0),
|
||||||
|
calls: snap.docs.reduce((a, d) => a + ((d.get('defenseCount') as number) ?? 0), 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const countOf = async (path: string): Promise<number> =>
|
||||||
|
(await getFirestore().collection(path).get()).size;
|
||||||
|
|
||||||
|
const logCount = () =>
|
||||||
|
countOf(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}/defenseLogs`);
|
||||||
|
const tickerCount = () => countOf(`fandoms/${FANDOM}/liveEvents`);
|
||||||
|
|
||||||
|
async function balanceOf(uid: string): Promise<number> {
|
||||||
|
const snap = await getFirestore().doc(`users/${uid}/wallet/main`).get();
|
||||||
|
return (snap.get('activityBalance') as number | undefined) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await wipeUser(A);
|
||||||
|
await wipeUser(B);
|
||||||
|
await wipeEclipse();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('방어는 POP 을 소모하지 않는다 (설계서 v1.1 §9-#5)', () => {
|
||||||
|
it('참여해도 지갑과 원장이 그대로다', async () => {
|
||||||
|
await seed();
|
||||||
|
const db = getFirestore();
|
||||||
|
|
||||||
|
await defendEclipse({
|
||||||
|
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 's1', shakes: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 지갑이 한 푼도 움직이지 않는다.
|
||||||
|
expect(await balanceOf(A)).toBe(WALLET);
|
||||||
|
// 방어로 원장 행이 생기지 않는다 — 지급 1 건(ADMIN_GRANT)뿐이다.
|
||||||
|
const ledger = await db.collection(`users/${A}/popTransactions`).get();
|
||||||
|
expect(ledger.size).toBe(1);
|
||||||
|
expect(ledger.docs[0]!.get('reasonCode')).toBe('ADMIN_GRANT');
|
||||||
|
// 누적 기여도 움직이지 않는다 — 방어는 기여 POP 이 아니다.
|
||||||
|
const user = await db.doc(`users/${A}`).get();
|
||||||
|
expect(user.get('totalContributedPop') ?? 0).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('참여량은 흔들기 수로 쌓인다', async () => {
|
||||||
|
await seed();
|
||||||
|
const result = await defendEclipse({
|
||||||
|
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 's1', shakes: 20,
|
||||||
|
});
|
||||||
|
expect(result.accepted).toBe(20);
|
||||||
|
expect(result.clamped).toBe(0);
|
||||||
|
expect(result.myTotal).toBe(20);
|
||||||
|
expect(await shardSum()).toEqual({ defense: 20, calls: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('분산 카운터 — 카운트 유실 0 (별첨 1 ① 예외 처리)', () => {
|
||||||
|
it('샤드를 흩어 적어도 합이 참여 총량과 같다', async () => {
|
||||||
|
await seed();
|
||||||
|
const shakes = [1, 2, 3, 1, 5, 1, 2, 1, 3, 1];
|
||||||
|
for (const [i, n] of shakes.entries()) {
|
||||||
|
await defendEclipse({
|
||||||
|
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: `seq-${i}`, shakes: n,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const total = shakes.reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
|
expect(await shardSum()).toEqual({ defense: total, calls: shakes.length });
|
||||||
|
|
||||||
|
await aggregateActiveEclipses();
|
||||||
|
const summary = await getFirestore()
|
||||||
|
.doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`).get();
|
||||||
|
expect(summary.get('defense')).toBe(total);
|
||||||
|
expect(summary.get('defenseCount')).toBe(shakes.length);
|
||||||
|
expect(summary.get('erosionRate')).toBe(
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('두 사용자가 동시에 참여해도 집계가 일치한다', async () => {
|
||||||
|
await seed();
|
||||||
|
await Promise.all([
|
||||||
|
...Array.from({ length: 10 }, (_, i) => defendEclipse({
|
||||||
|
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: `a-${i}`, shakes: 3,
|
||||||
|
})),
|
||||||
|
...Array.from({ length: 10 }, (_, i) => defendEclipse({
|
||||||
|
uid: B, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: `b-${i}`, shakes: 3,
|
||||||
|
})),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await aggregateActiveEclipses();
|
||||||
|
const summary = await getFirestore()
|
||||||
|
.doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`).get();
|
||||||
|
expect(summary.get('defense')).toBe(60);
|
||||||
|
expect(summary.get('defenseCount')).toBe(20);
|
||||||
|
expect(await logCount()).toBe(20);
|
||||||
|
expect(await tickerCount()).toBe(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('멱등 — 재시도가 이중 계산되지 않는다 (검수 D1·D6)', () => {
|
||||||
|
it('같은 clientSeq 를 두 번 보내도 샤드·로그·티커가 한 번만 늘어난다', async () => {
|
||||||
|
await seed();
|
||||||
|
const first = await defendEclipse({
|
||||||
|
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'same', shakes: 30,
|
||||||
|
});
|
||||||
|
const again = await defendEclipse({
|
||||||
|
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'same', shakes: 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(first.duplicate).toBe(false);
|
||||||
|
expect(again.duplicate).toBe(true);
|
||||||
|
expect(again.accepted).toBe(first.accepted);
|
||||||
|
|
||||||
|
// 여기가 검수 지적의 자리다 — 예전에는 60/2/2 가 되었다.
|
||||||
|
expect(await shardSum()).toEqual({ defense: 30, calls: 1 });
|
||||||
|
expect(await logCount()).toBe(1);
|
||||||
|
expect(await tickerCount()).toBe(1);
|
||||||
|
// 참여자 누적도 한 번만 오른다.
|
||||||
|
const me = await getFirestore()
|
||||||
|
.doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}/participants/${A}`).get();
|
||||||
|
expect(me.get('validShakes')).toBe(30);
|
||||||
|
expect(me.get('calls')).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('같은 키를 동시에 다섯 번 보내도 한 번만 쌓인다', async () => {
|
||||||
|
await seed();
|
||||||
|
// 경합이 문서 ID 충돌로 판정되는지 본다. 조회 후 쓰기라면 여기서 여러 번 쌓인다.
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
Array.from({ length: 5 }, () => defendEclipse({
|
||||||
|
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'race', shakes: 10,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
expect(results.some((r) => r.status === 'fulfilled')).toBe(true);
|
||||||
|
|
||||||
|
expect(await shardSum()).toEqual({ defense: 10, calls: 1 });
|
||||||
|
expect(await logCount()).toBe(1);
|
||||||
|
expect(await tickerCount()).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('서버 검증 — 단말 값을 믿지 않는다 (별첨 1 ②)', () => {
|
||||||
|
it('한 호출 상한을 넘긴 흔들기 수를 깎는다', async () => {
|
||||||
|
await seed({ perCallMaxShakes: 10 });
|
||||||
|
const result = await defendEclipse({
|
||||||
|
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 's1', shakes: 9_999,
|
||||||
|
});
|
||||||
|
expect(result.accepted).toBe(10);
|
||||||
|
expect(result.clamped).toBe(9_989);
|
||||||
|
expect((await shardSum()).defense).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('사용자당 상한에 닿으면 더 쌓이지 않는다', async () => {
|
||||||
|
await seed({ perCallMaxShakes: 50, perUserMaxDefense: 60 });
|
||||||
|
await defendEclipse({
|
||||||
|
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 's1', shakes: 50,
|
||||||
|
});
|
||||||
|
const second = await defendEclipse({
|
||||||
|
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 's2', shakes: 50,
|
||||||
|
});
|
||||||
|
// 남은 10 만 받는다 — 통째로 버리면 정상 참여의 마지막 묶음이 사라진다.
|
||||||
|
expect(second.accepted).toBe(10);
|
||||||
|
expect(second.clamped).toBe(40);
|
||||||
|
|
||||||
|
const third = await defendEclipse({
|
||||||
|
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 's3', shakes: 10,
|
||||||
|
});
|
||||||
|
expect(third.accepted).toBe(0);
|
||||||
|
expect((await shardSum()).defense).toBe(60);
|
||||||
|
// 받아들인 것이 없으면 티커에도 올리지 않는다 — 빈 참여가 흐르면 안 된다.
|
||||||
|
expect(await tickerCount()).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('속도 제한보다 빠른 연속 호출을 끊는다', async () => {
|
||||||
|
await seed({ minIntervalMs: 60_000 });
|
||||||
|
await defendEclipse({
|
||||||
|
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 's1', shakes: 5,
|
||||||
|
});
|
||||||
|
await expect(defendEclipse({
|
||||||
|
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 's2', shakes: 5,
|
||||||
|
})).rejects.toMatchObject({ details: { code: 'E-QST-02' } });
|
||||||
|
expect((await shardSum()).defense).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('다른 사용자는 서로의 속도 제한에 걸리지 않는다', async () => {
|
||||||
|
await seed({ minIntervalMs: 60_000 });
|
||||||
|
await defendEclipse({
|
||||||
|
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'a1', shakes: 5,
|
||||||
|
});
|
||||||
|
await defendEclipse({
|
||||||
|
uid: B, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'b1', shakes: 5,
|
||||||
|
});
|
||||||
|
expect((await shardSum()).defense).toBe(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('티커 이벤트 — Global Ticker 모양 (별첨 B)', () => {
|
||||||
|
it('참여마다 종류·닉네임·참여량·우선순위·이동처를 남긴다', async () => {
|
||||||
|
await seed();
|
||||||
|
await defendEclipse({
|
||||||
|
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 't1', shakes: 7,
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = await getFirestore()
|
||||||
|
.collection(`fandoms/${FANDOM}/liveEvents`).get();
|
||||||
|
expect(events.size).toBe(1);
|
||||||
|
const event = events.docs[0]!;
|
||||||
|
// 다른 종류(Artist Hot Time · Hero Gift · Campaign · 공지)가 뒤에 들어올 자리다.
|
||||||
|
expect(event.get('kind')).toBe('eclipse_defense');
|
||||||
|
expect(event.get('nickname')).toBe('아미보이');
|
||||||
|
expect(event.get('defense')).toBe(7);
|
||||||
|
expect(event.get('eclipseId')).toBe(ECLIPSE);
|
||||||
|
expect(typeof event.get('priority')).toBe('number');
|
||||||
|
expect(event.get('linkTo')).toBe(`/eclipse/${FANDOM}/${ECLIPSE}`);
|
||||||
|
// 티커가 최신순으로 읽으므로 시각이 없으면 정렬 자체가 불가능하다.
|
||||||
|
expect(event.get('createdAt')).toBeTruthy();
|
||||||
|
// TTL 정책의 입력. 없으면 컬렉션이 무한히 쌓인다.
|
||||||
|
expect((event.get('expiresAt') as Timestamp).toMillis())
|
||||||
|
.toBeGreaterThan(Date.now());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('닉네임이 없는 사용자도 문구가 비지 않는다', async () => {
|
||||||
|
await seed();
|
||||||
|
await getFirestore().doc(`users/${A}`).set({ nickname: '' }, { merge: true });
|
||||||
|
await defendEclipse({
|
||||||
|
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 't2', shakes: 1,
|
||||||
|
});
|
||||||
|
const events = await getFirestore()
|
||||||
|
.collection(`fandoms/${FANDOM}/liveEvents`).get();
|
||||||
|
expect(events.docs[0]!.get('nickname')).toBe('팬');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('성공 판정 — 목표에 닿은 즉시 (검수 D2)', () => {
|
||||||
|
it('한 사람의 추가 Shake는 목표 두 명을 채울 수 없고 두 번째 60회에서만 성공한다', async () => {
|
||||||
|
await seed({ target: 2, perCallMaxShakes: 60 });
|
||||||
|
const ref = getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`);
|
||||||
|
const send = (uid: string, clientSeq: string, shakes: number) => defendEclipse({
|
||||||
|
uid, clientSeq, shakes, fandomId: FANDOM, eclipseId: ECLIPSE,
|
||||||
|
});
|
||||||
|
await send(A, 'one', 60);
|
||||||
|
await send(A, 'extra', 60);
|
||||||
|
await send(B, 'under', 59);
|
||||||
|
await aggregateActiveEclipses();
|
||||||
|
expect((await ref.get()).data()).toMatchObject({
|
||||||
|
qualifiedParticipants: 1, defended: false, erosionRate: 50,
|
||||||
|
});
|
||||||
|
await send(B, 'two', 1);
|
||||||
|
const result = await ref.get();
|
||||||
|
expect(result.get('defended')).toBe(true);
|
||||||
|
expect(result.get('qualifiedParticipants')).toBe(2);
|
||||||
|
const at = result.get('defendedAt');
|
||||||
|
await aggregateActiveEclipses();
|
||||||
|
expect((await ref.get()).get('defendedAt')).toEqual(at);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('인원 목표를 판정할 수 없으면 옛 Shake 목표로 성공시키지 않고 취소한다', async () => {
|
||||||
|
await seed();
|
||||||
|
const ref = getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`);
|
||||||
|
await ref.update({ target: FieldValue.delete(), targetPop: 1 });
|
||||||
|
await aggregateActiveEclipses();
|
||||||
|
expect((await ref.get()).data()).toMatchObject({
|
||||||
|
status: 'cancelled', result: 'cancelled_error', defended: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('집계를 기다리지 않고 방어 호출이 defended 를 세운다', async () => {
|
||||||
|
await seed({ target: 1, perCallMaxShakes: 60 });
|
||||||
|
await defendEclipse({
|
||||||
|
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'w1', shakes: 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 집계(aggregateActiveEclipses)를 한 번도 돌리지 않았다.
|
||||||
|
const snap = await getFirestore()
|
||||||
|
.doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`).get();
|
||||||
|
expect(snap.get('defended')).toBe(true);
|
||||||
|
expect(snap.get('erosionRate')).toBe(0);
|
||||||
|
// 활성 질의에서 빠지면 화면이 성공한 순간을 놓친다 — status 는 그대로다.
|
||||||
|
expect(snap.get('status')).toBe('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('성공한 이클립스는 더 받지 않는다 (E-ECL-01)', async () => {
|
||||||
|
await seed({ target: 1, perCallMaxShakes: 60 });
|
||||||
|
await defendEclipse({
|
||||||
|
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'w1', shakes: 60,
|
||||||
|
});
|
||||||
|
await expect(defendEclipse({
|
||||||
|
uid: B, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'w2', shakes: 5,
|
||||||
|
})).rejects.toMatchObject({ details: { code: 'E-ECL-01' } });
|
||||||
|
expect((await shardSum()).defense).toBe(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('종료된 이클립스는 참여를 받지 않는다 (E-ECL-01)', async () => {
|
||||||
|
await seed();
|
||||||
|
await getFirestore()
|
||||||
|
.doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`)
|
||||||
|
.set({ status: 'ended' }, { merge: true });
|
||||||
|
|
||||||
|
await expect(defendEclipse({
|
||||||
|
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'c1', shakes: 1,
|
||||||
|
})).rejects.toMatchObject({ details: { code: 'E-ECL-01' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('마감이 지난 이클립스는 참여를 받지 않는다 (E-ECL-01)', async () => {
|
||||||
|
await seed();
|
||||||
|
await getFirestore()
|
||||||
|
.doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`)
|
||||||
|
.set({ endsAt: Timestamp.fromMillis(Date.now() - 1_000) }, { merge: true });
|
||||||
|
|
||||||
|
await expect(defendEclipse({
|
||||||
|
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'c2', shakes: 1,
|
||||||
|
})).rejects.toMatchObject({ details: { code: 'E-ECL-01' } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe('v1.5 유효 참여의 원자적 기록', () => {
|
||||||
|
it('59회는 미인정, 60회는 한 명이며 추가 참여·동시 요청·재전송도 중복 인정하지 않는다', async () => {
|
||||||
|
await seed();
|
||||||
|
const ref = getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`);
|
||||||
|
const send = (uid: string, clientSeq: string, shakes: number) => defendEclipse({
|
||||||
|
uid, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq, shakes,
|
||||||
|
});
|
||||||
|
const qualifiedCount = async () => (await ref.collection('shards').get()).docs
|
||||||
|
.reduce((sum, doc) => sum + (doc.get('qualifiedParticipants') ?? 0), 0);
|
||||||
|
await send(A, 'q50', 50);
|
||||||
|
await send(A, 'q59', 9);
|
||||||
|
expect(await qualifiedCount()).toBe(0);
|
||||||
|
expect((await ref.collection('participants').doc(A).get()).get('qualified')).toBeUndefined();
|
||||||
|
await Promise.all([send(A, 'q60', 1), send(A, 'q60', 1)]);
|
||||||
|
const qualifiedAt = (await ref.collection('participants').doc(A).get()).get('qualifiedAt');
|
||||||
|
expect(qualifiedAt).toBeInstanceOf(Timestamp);
|
||||||
|
expect(await qualifiedCount()).toBe(1);
|
||||||
|
// 120회를 흔들어도 1명이다(C-25).
|
||||||
|
await Promise.all([send(A, 'extra1', 20), send(A, 'extra2', 20), send(A, 'extra3', 20)]);
|
||||||
|
await send(B, 'b50', 50);
|
||||||
|
await send(B, 'b60', 10);
|
||||||
|
expect(await qualifiedCount()).toBe(2);
|
||||||
|
expect((await ref.collection('participants').doc(A).get()).get('qualifiedAt')).toEqual(qualifiedAt);
|
||||||
|
expect((await ref.collection('participants').doc(A).get()).get('validShakes')).toBe(120);
|
||||||
|
expect((await send(A, 'q60', 1)).myTotal).toBe(60);
|
||||||
|
await aggregateActiveEclipses();
|
||||||
|
expect((await ref.get()).get('qualifiedParticipants')).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('마지막 티커 저장이 실패하면 로그·개인 누적·샤드까지 취소하고 같은 요청으로 복구한다', async () => {
|
||||||
|
await seed();
|
||||||
|
const db = getFirestore();
|
||||||
|
const ref = db.doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`);
|
||||||
|
const input = { uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'atomic', shakes: 30 };
|
||||||
|
const original = Transaction.prototype.create;
|
||||||
|
let calls = 0;
|
||||||
|
const spy = vi.spyOn(Transaction.prototype, 'create').mockImplementation(function (this: Transaction, ...args) {
|
||||||
|
if (++calls === 2) throw new Error('ticker write failed');
|
||||||
|
return original.apply(this, args);
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await expect(defendEclipse(input)).rejects.toThrow('ticker write failed');
|
||||||
|
} finally { spy.mockRestore(); }
|
||||||
|
for (const sub of ['defenseLogs', 'participants', 'shards']) {
|
||||||
|
expect((await ref.collection(sub).get()).size).toBe(0);
|
||||||
|
}
|
||||||
|
expect((await db.collection(`fandoms/${FANDOM}/liveEvents`).get()).size).toBe(0);
|
||||||
|
expect((await defendEclipse(input)).duplicate).toBe(false);
|
||||||
|
expect(await shardSum()).toEqual({ defense: 30, calls: 1 });
|
||||||
|
expect((await db.collection(`fandoms/${FANDOM}/liveEvents`).get()).size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('회차가 닫혀도 확정된 요청은 재확인할 수 있고 신규 요청은 거절한다', async () => {
|
||||||
|
await seed();
|
||||||
|
const input = { uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'lost-response', shakes: 10 };
|
||||||
|
await defendEclipse(input);
|
||||||
|
const ref = getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`);
|
||||||
|
await ref.update({ status: 'ended', endsAt: Timestamp.fromMillis(0) });
|
||||||
|
expect(await defendEclipse(input)).toEqual({ accepted: 10, clamped: 0, myTotal: 10, duplicate: true });
|
||||||
|
await expect(defendEclipse({ ...input, clientSeq: 'late' })).rejects.toMatchObject({ details: { code: 'E-ECL-01' } });
|
||||||
|
expect(await shardSum()).toEqual({ defense: 10, calls: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('개인 인정 횟수는 운영값이다 (원장 C-25)', () => {
|
||||||
|
it('회차에 고정된 인정 횟수로 판정하고 운영값 문서만 바꿔서는 진행 중 회차 기준이 바뀌지 않는다', async () => {
|
||||||
|
await seed({ target: 1, perCallMaxShakes: 60, qualifyShakes: 60 });
|
||||||
|
const ref = getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`);
|
||||||
|
await ref.update({ qualifyShakes: 30 });
|
||||||
|
await getFirestore().doc('config/eclipse').set({ qualifyShakes: 90 }, { merge: true });
|
||||||
|
await defendEclipse({ uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'q29', shakes: 29 });
|
||||||
|
expect((await ref.get()).get('defended')).toBe(false);
|
||||||
|
await defendEclipse({ uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'q30', shakes: 1 });
|
||||||
|
expect((await ref.get()).get('defended')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { afterAll, beforeAll, expect, it } from 'vitest';
|
||||||
|
import { getFirestore, Timestamp } from 'firebase-admin/firestore';
|
||||||
|
import { eclipseTarget } from '../src/fandom/eclipse_target.js';
|
||||||
|
import { bootAdmin, shutdownAdmin } from './support.js';
|
||||||
|
|
||||||
|
beforeAll(bootAdmin);
|
||||||
|
afterAll(async () => {
|
||||||
|
await getFirestore().recursiveDelete(getFirestore().doc('fandoms/test-eclipse-target'));
|
||||||
|
await shutdownAdmin();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('생성일·0명·누락·최근 7일·목표율을 구분해 고유 참여자 목표를 산출한다', async () => {
|
||||||
|
const ref = getFirestore().doc('fandoms/test-eclipse-target');
|
||||||
|
const created = (date: string) => Timestamp.fromDate(new Date(`${date}T00:00:00+09:00`));
|
||||||
|
const stats = ref.collection('fandomDailyStats');
|
||||||
|
expect(await eclipseTarget(ref, created('2026-09-22'), '2026-09-22')).toEqual({
|
||||||
|
target: 1, averageDau: null, dauDays: [],
|
||||||
|
});
|
||||||
|
expect(await eclipseTarget(ref, undefined, '2026-09-22')).toMatchObject({ result: 'cancelled_error' });
|
||||||
|
expect(await eclipseTarget(ref, created('2026-09-23'), '2026-09-22')).toMatchObject({ result: 'cancelled_error' });
|
||||||
|
expect(await eclipseTarget(ref, created('2026-09-21'), '2026-09-22')).toMatchObject({ result: 'cancelled_error' });
|
||||||
|
await stats.doc('2026-09-21').set({ dau: 0, active30: 10000 });
|
||||||
|
expect(await eclipseTarget(ref, created('2026-09-21'), '2026-09-22')).toMatchObject({ target: 1, averageDau: 0 });
|
||||||
|
await stats.doc('2026-09-20').set({ dau: 30 });
|
||||||
|
await stats.doc('2026-09-19').set({ dau: 60 });
|
||||||
|
expect(await eclipseTarget(ref, created('2026-09-19'), '2026-09-22')).toEqual({
|
||||||
|
target: 2, averageDau: 30, dauDays: ['2026-09-21', '2026-09-20', '2026-09-19'],
|
||||||
|
});
|
||||||
|
expect(await eclipseTarget(ref, created('2026-09-19'), '2026-09-22', 0.1)).toMatchObject({ target: 3 });
|
||||||
|
for (const rate of [0, -1, Number.NaN, Number.POSITIVE_INFINITY, 1.1]) {
|
||||||
|
expect(await eclipseTarget(ref, created('2026-09-19'), '2026-09-22', rate)).toMatchObject({ result: 'cancelled_error' });
|
||||||
|
}
|
||||||
|
for (let d = 15; d <= 21; d++) await stats.doc(`2026-09-${d}`).set({ dau: 30 });
|
||||||
|
await stats.doc('2026-09-14').set({ dau: 100000 });
|
||||||
|
await stats.doc('2026-09-22').set({ dau: 100000 });
|
||||||
|
expect(await eclipseTarget(ref, created('2026-09-01'), '2026-09-22')).toMatchObject({ target: 2, averageDau: 30 });
|
||||||
|
// C-21 집계 기간은 운영값 — 8일로 늘리면 14일의 값이 분모에 들어온다.
|
||||||
|
expect(await eclipseTarget(ref, created('2026-09-01'), '2026-09-22', 0.05, 8)).toMatchObject({
|
||||||
|
target: Math.ceil((30 * 7 + 100000) / 8 * 0.05), averageDau: (30 * 7 + 100000) / 8,
|
||||||
|
});
|
||||||
|
expect(await eclipseTarget(ref, created('2026-09-01'), '2026-09-22', 0.05, 0)).toMatchObject({ result: 'cancelled_error' });
|
||||||
|
for (const invalid of [-1, 0.5, '30', null]) {
|
||||||
|
await stats.doc('2026-09-20').set({ dau: invalid });
|
||||||
|
expect(await eclipseTarget(ref, created('2026-09-01'), '2026-09-22')).toMatchObject({ result: 'cancelled_error' });
|
||||||
|
}
|
||||||
|
await stats.doc('2026-09-20').delete();
|
||||||
|
expect(await eclipseTarget(ref, created('2026-09-01'), '2026-09-22')).toMatchObject({ result: 'cancelled_error' });
|
||||||
|
});
|
||||||
@@ -0,0 +1,476 @@
|
|||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { getFirestore, FieldValue, Timestamp, Transaction } from 'firebase-admin/firestore';
|
||||||
|
|
||||||
|
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
|
||||||
|
import { grantPop } from '../src/ledger/ledger.js';
|
||||||
|
import {
|
||||||
|
aggregateActiveEclipses,
|
||||||
|
defendEclipse,
|
||||||
|
runEclipseSchedule,
|
||||||
|
fnFpiAggregate,
|
||||||
|
} from '../src/fandom/fandom.js';
|
||||||
|
|
||||||
|
/** v1.5 §5: 운영 일정, DAU 오류 취소, 시작 스냅샷, 실패 확정 차감과 경합을 검증한다. */
|
||||||
|
|
||||||
|
const FANDOM = 'test-trig-fandom';
|
||||||
|
const A = 'test-trig-user-a';
|
||||||
|
|
||||||
|
/** 침식률 — 수치표 v1.4 「침식 0.25% FPI」. */
|
||||||
|
const EROSION_PERCENT = 0.25;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 방어 목표(팬덤 공동 Shake 누적). **POP 이 아니다** — 방어는 POP 을 소모하지 않는다
|
||||||
|
* (설계서 v1.1 §9-#5). 한 번의 참여로 채울 수 있게 작게 잡았다.
|
||||||
|
*/
|
||||||
|
const TARGET = 60;
|
||||||
|
|
||||||
|
/** 축별 기준 FPI. 0.25% 가 정수로 떨어지도록 400의 배수로 잡았다. */
|
||||||
|
const BASE_AXES: Record<string, number> = {
|
||||||
|
bpm: 400_000,
|
||||||
|
health: 200_000,
|
||||||
|
referral: 80_000,
|
||||||
|
community: 40_000,
|
||||||
|
contribution: 120_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
const AXES = Object.keys(BASE_AXES);
|
||||||
|
|
||||||
|
/** 기준 FPI 총합. 침식 전후를 이 값으로 견준다. */
|
||||||
|
const BASE_TOTAL = Object.values(BASE_AXES).reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
|
/** KST 시각을 절대 시각으로. 발동 시각 판정이 KST 기준이다. */
|
||||||
|
const kst = (day: string, hhmm: string) => new Date(`${day}T${hhmm}:00+09:00`);
|
||||||
|
|
||||||
|
const DAY = '2026-09-20';
|
||||||
|
const FIRST = '13:00';
|
||||||
|
const SECOND = '21:00';
|
||||||
|
const WARN_SECONDS = 300;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
// 발동과 방어가 같은 시각을 보도록 Date만 고정한다. Firestore 타이머는 실제로 돈다.
|
||||||
|
vi.useFakeTimers({ toFake: ['Date'] });
|
||||||
|
vi.setSystemTime(kst(DAY, '13:10'));
|
||||||
|
bootAdmin();
|
||||||
|
});
|
||||||
|
// **끝나고도 치운다.** 남겨 두면 다음 파일의 전역 질의(`fn-fpi-aggregate` ·
|
||||||
|
// `fn-eclipse-trigger` 는 팬덤 전수를 돈다)가 이 팬덤까지 집어 값이 흔들린다.
|
||||||
|
afterAll(async () => {
|
||||||
|
await wipeUser(A);
|
||||||
|
await wipeFandom();
|
||||||
|
await shutdownAdmin();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function wipeFandom(): Promise<void> {
|
||||||
|
const db = getFirestore();
|
||||||
|
const root = db.doc(`fandoms/${FANDOM}`);
|
||||||
|
const eclipses = await root.collection('eclipseRounds').get();
|
||||||
|
for (const doc of eclipses.docs) {
|
||||||
|
for (const sub of ['shards', 'defenseLogs', 'participants']) {
|
||||||
|
const snap = await doc.ref.collection(sub).get();
|
||||||
|
await Promise.all(snap.docs.map((d) => d.ref.delete()));
|
||||||
|
}
|
||||||
|
await doc.ref.delete();
|
||||||
|
}
|
||||||
|
for (const sub of ['fpiEvents', 'liveEvents', 'members', 'fandomDailyStats']) {
|
||||||
|
const snap = await root.collection(sub).get();
|
||||||
|
await Promise.all(snap.docs.map((d) => d.ref.delete()));
|
||||||
|
}
|
||||||
|
await root.delete();
|
||||||
|
await db.doc('config/eclipse').delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 팬덤 하나에 FPI 원장을 세운다.
|
||||||
|
*
|
||||||
|
* **`fpiAxes` 를 직접 쓰지 않고 이벤트로 만든다.** 집계가 이벤트에서 요약을 다시
|
||||||
|
* 만들므로, 요약만 심어 두면 첫 집계에서 전부 0 이 되어 침식할 것이 없어진다.
|
||||||
|
*/
|
||||||
|
async function seedFandom(overrides: Record<string, unknown> = {}): Promise<void> {
|
||||||
|
const db = getFirestore();
|
||||||
|
await db.doc('config/eclipse').set({
|
||||||
|
enabled: true,
|
||||||
|
schedule: [{ hhmm: FIRST }, { hhmm: SECOND }],
|
||||||
|
erosionPercent: EROSION_PERCENT,
|
||||||
|
warnSeconds: WARN_SECONDS,
|
||||||
|
perCallMaxShakes: 60,
|
||||||
|
windowSec: 1800,
|
||||||
|
// 같은 사용자로 연속 참여해 보므로 속도 제한을 끈다. 제한 자체는
|
||||||
|
// eclipse.test.ts 의 전용 테스트가 본다.
|
||||||
|
minIntervalMs: 0,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
await db.doc(`fandoms/${FANDOM}`).set({
|
||||||
|
name: 'BTS', stage: 1, fandomName: 'BTS',
|
||||||
|
createdAt: Timestamp.fromDate(kst(DAY, '00:00')),
|
||||||
|
});
|
||||||
|
// 활동 이벤트는 발동 시각보다 앞서고 7 일 창 안이다.
|
||||||
|
const occurredAt = Timestamp.fromDate(kst(DAY, '09:00'));
|
||||||
|
for (const axis of AXES) {
|
||||||
|
await db.doc(`fandoms/${FANDOM}/fpiEvents/seed-${axis}`).set({
|
||||||
|
axis, value: BASE_AXES[axis], verified: true, occurredAt,
|
||||||
|
reasonCode: 'SEED',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await db.doc(`users/${A}`).set({ uid: A, nickname: '아미보이', fandomId: FANDOM });
|
||||||
|
await grantPop({
|
||||||
|
uid: A, popType: 'activity', amount: 10_000,
|
||||||
|
reasonCode: 'ADMIN_GRANT', idempotencyKey: `g:trig:${A}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 집계를 실제로 한 번 돌린다. 스케줄 래퍼를 직접 호출한다. */
|
||||||
|
const aggregateFpi = () =>
|
||||||
|
(fnFpiAggregate.run as (e: unknown) => Promise<void>)({});
|
||||||
|
|
||||||
|
/** 가중치와 실패 차감까지 반영한 현재 FPI 총점. */
|
||||||
|
async function currentFpiTotal(): Promise<number> {
|
||||||
|
const snap = await getFirestore().doc(`fandoms/${FANDOM}`).get();
|
||||||
|
return snap.get('fpiScore') as number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 사유 코드별 FPI 이벤트 수. 「감소 경로가 Eclipse 뿐인가」의 확인 수단이다. */
|
||||||
|
async function fpiEventsByReason(): Promise<Record<string, number>> {
|
||||||
|
const snap = await getFirestore()
|
||||||
|
.collection(`fandoms/${FANDOM}/fpiEvents`).get();
|
||||||
|
const out: Record<string, number> = {};
|
||||||
|
for (const doc of snap.docs) {
|
||||||
|
const reason = (doc.get('reasonCode') as string | undefined) ?? '(없음)';
|
||||||
|
out[reason] = (out[reason] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await wipeUser(A);
|
||||||
|
await wipeFandom();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('발동 — 하루 정확히 2 회 (원장 B-55)', () => {
|
||||||
|
it('DAU 누락 회차는 취소 사유를 남기고 활성화·FPI 차감 없이 종료한다', async () => {
|
||||||
|
await seedFandom();
|
||||||
|
await aggregateFpi();
|
||||||
|
const db = getFirestore();
|
||||||
|
await db.doc(`fandoms/${FANDOM}`).update({ createdAt: Timestamp.fromDate(kst('2026-09-19', '00:00')) });
|
||||||
|
await runEclipseSchedule(kst(DAY, FIRST));
|
||||||
|
const ref = db.doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-1`);
|
||||||
|
expect((await ref.get()).data()).toMatchObject({
|
||||||
|
status: 'cancelled', result: 'cancelled_error', cancelReason: 'MISSING_OR_INVALID_DAU',
|
||||||
|
});
|
||||||
|
expect((await ref.get()).get('activatedAt')).toBeUndefined();
|
||||||
|
expect((await fpiEventsByReason())['ECLIPSE_EROSION'] ?? 0).toBe(0);
|
||||||
|
expect(await currentFpiTotal()).toBe(BASE_TOTAL);
|
||||||
|
await runEclipseSchedule(kst(DAY, FIRST));
|
||||||
|
expect((await ref.get()).get('status')).toBe('cancelled');
|
||||||
|
await db.doc(`fandoms/${FANDOM}/fandomDailyStats/2026-09-19`).set({ dau: 30 });
|
||||||
|
await runEclipseSchedule(kst(DAY, FIRST));
|
||||||
|
expect((await ref.get()).get('status')).toBe('cancelled');
|
||||||
|
await runEclipseSchedule(kst(DAY, SECOND));
|
||||||
|
expect((await db.doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-2`).get()).data()).toMatchObject({
|
||||||
|
status: 'active', target: 2, averageDau: 30,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('v1.5 기본값으로 07:47/17:47 예고하고 07:50/17:50부터 60초 방어한다', async () => {
|
||||||
|
const db = getFirestore();
|
||||||
|
await db.doc('config/eclipse').set({ enabled: true });
|
||||||
|
await db.doc(`fandoms/${FANDOM}`).set({ name: '기본 운영값 검증', fpiScore: 0, createdAt: Timestamp.fromDate(kst(DAY, '00:00')) });
|
||||||
|
for (const [round, warning, start] of [[1, '07:47', '07:50'], [2, '17:47', '17:50']] as const) {
|
||||||
|
const ref = db.doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-${round}`);
|
||||||
|
await runEclipseSchedule(new Date(kst(DAY, warning).getTime() - 1));
|
||||||
|
expect((await ref.get()).exists).toBe(false);
|
||||||
|
await runEclipseSchedule(kst(DAY, warning));
|
||||||
|
const scheduled = await ref.get();
|
||||||
|
expect(scheduled.get('status')).toBe('scheduled');
|
||||||
|
expect(scheduled.get('target')).toBe(1);
|
||||||
|
expect(scheduled.get('dauDays')).toEqual([]);
|
||||||
|
expect(scheduled.get('warnAt').toMillis()).toBe(kst(DAY, warning).getTime());
|
||||||
|
expect(scheduled.get('startAt').toMillis()).toBe(kst(DAY, start).getTime());
|
||||||
|
expect(scheduled.get('endsAt').toMillis() - scheduled.get('startAt').toMillis()).toBe(60_000);
|
||||||
|
expect(scheduled.get('windowSec')).toBe(60);
|
||||||
|
expect(scheduled.get('qualifyShakes')).toBe(60);
|
||||||
|
// C-22 「푸시 발송 시각 = 시작 − 3분」 — 예고 판정 시각이 회차 문서에 남는다.
|
||||||
|
expect(scheduled.get('alertPushedAt').toMillis()).toBe(kst(DAY, warning).getTime());
|
||||||
|
await runEclipseSchedule(kst(DAY, start));
|
||||||
|
expect((await ref.get()).get('status')).toBe('active');
|
||||||
|
await runEclipseSchedule(new Date(kst(DAY, start).getTime() + 60_000));
|
||||||
|
expect((await ref.get()).get('status')).toBe('ended');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('사전 알림 시각에 예약되고 발동 시각에 활성이 된다', async () => {
|
||||||
|
await seedFandom();
|
||||||
|
await aggregateFpi();
|
||||||
|
const db = getFirestore();
|
||||||
|
const ref = db.doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-1`);
|
||||||
|
|
||||||
|
// **반환 카운터를 단정하지 않는다.** `runEclipseSchedule` 은 모든 팬덤을 돌아
|
||||||
|
// 전역 건수를 돌려주므로, 다른 테스트가 남긴 팬덤이 있으면 값이 흔들린다.
|
||||||
|
// 이 테스트가 볼 것은 「이 팬덤의 이 회차 문서가 어떤 상태인가」다.
|
||||||
|
// 사전 알림 전에는 문서가 없다.
|
||||||
|
await runEclipseSchedule(kst(DAY, '12:50'));
|
||||||
|
expect((await ref.get()).exists).toBe(false);
|
||||||
|
|
||||||
|
// 사전 알림 시각(발동 300초 전 = 12:55)에 예약된다.
|
||||||
|
await runEclipseSchedule(kst(DAY, '12:56'));
|
||||||
|
let snap = await ref.get();
|
||||||
|
expect(snap.get('status')).toBe('scheduled');
|
||||||
|
// 푸시 워커가 읽을 사전 알림 시각. 예약 시점보다 앞이면 예고가 불가능하다.
|
||||||
|
expect((snap.get('warnAt') as Timestamp).toMillis())
|
||||||
|
.toBe(kst(DAY, '12:55').getTime());
|
||||||
|
expect((snap.get('startAt') as Timestamp).toMillis())
|
||||||
|
.toBe(kst(DAY, FIRST).getTime());
|
||||||
|
|
||||||
|
// 발동 시각에 활성이 된다.
|
||||||
|
await runEclipseSchedule(kst(DAY, '13:00'));
|
||||||
|
snap = await ref.get();
|
||||||
|
expect(snap.get('status')).toBe('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('하루에 1차·2차 딱 둘만 만든다', async () => {
|
||||||
|
await seedFandom();
|
||||||
|
await aggregateFpi();
|
||||||
|
|
||||||
|
// 하루를 1분 간격이 아니라 구간별로 훑는다.
|
||||||
|
for (const at of ['00:10', '12:56', '13:00', '13:20', '20:56', '21:00', '23:50']) {
|
||||||
|
await runEclipseSchedule(kst(DAY, at));
|
||||||
|
}
|
||||||
|
|
||||||
|
const eclipses = await getFirestore()
|
||||||
|
.collection(`fandoms/${FANDOM}/eclipseRounds`).get();
|
||||||
|
expect(eclipses.size).toBe(2);
|
||||||
|
expect(eclipses.docs.map((d) => d.id).sort()).toEqual([`${DAY}-1`, `${DAY}-2`]);
|
||||||
|
expect(eclipses.docs.map((d) => d.get('round')).sort()).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('같은 회차를 여러 번 돌려도 중복 발동 0 건이다', async () => {
|
||||||
|
await seedFandom();
|
||||||
|
await aggregateFpi();
|
||||||
|
|
||||||
|
// 같은 분을 열 번 돌린다. 조회 후 쓰기로 막았다면 여기서 새 문서가 생긴다.
|
||||||
|
for (let i = 0; i < 10; i++) await runEclipseSchedule(kst(DAY, '13:05'));
|
||||||
|
// 동시에도 돌려 본다 — 스케줄 인스턴스가 겹치는 상황이다.
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: 5 }, () => runEclipseSchedule(kst(DAY, '13:06'))),
|
||||||
|
);
|
||||||
|
|
||||||
|
const eclipses = await getFirestore()
|
||||||
|
.collection(`fandoms/${FANDOM}/eclipseRounds`).get();
|
||||||
|
expect(eclipses.size).toBe(1);
|
||||||
|
expect(eclipses.docs[0]!.get('status')).toBe('active');
|
||||||
|
|
||||||
|
// 시작 시점에는 차감 이벤트가 없어야 한다.
|
||||||
|
const events = await getFirestore()
|
||||||
|
.collection(`fandoms/${FANDOM}/fpiEvents`).get();
|
||||||
|
const erosions = events.docs
|
||||||
|
.filter((d) => d.get('reasonCode') === 'ECLIPSE_EROSION');
|
||||||
|
expect(erosions).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('활성화가 꺼진 팬덤은 발동하지 않는다', async () => {
|
||||||
|
await seedFandom({ enabled: false });
|
||||||
|
await aggregateFpi();
|
||||||
|
|
||||||
|
await runEclipseSchedule(kst(DAY, '13:05'));
|
||||||
|
const eclipses = await getFirestore()
|
||||||
|
.collection(`fandoms/${FANDOM}/eclipseRounds`).get();
|
||||||
|
expect(eclipses.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('발동 시각·침식률을 운영값으로 바꾸면 재배포 없이 따라간다', async () => {
|
||||||
|
// 원장 B-55 검증 기준 「방어 수치가 Admin 에서 앱 재배포 없이 변경됨」.
|
||||||
|
await seedFandom({ schedule: [{ hhmm: '06:30' }], erosionPercent: 1, warnSeconds: 60 });
|
||||||
|
await aggregateFpi();
|
||||||
|
|
||||||
|
await runEclipseSchedule(kst(DAY, '06:20'));
|
||||||
|
expect((await getFirestore()
|
||||||
|
.collection(`fandoms/${FANDOM}/eclipseRounds`).get()).size).toBe(0);
|
||||||
|
|
||||||
|
await runEclipseSchedule(kst(DAY, '06:31'));
|
||||||
|
const snap = await getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-1`).get();
|
||||||
|
expect(snap.get('status')).toBe('active');
|
||||||
|
expect(snap.get('erosionPercent')).toBe(1);
|
||||||
|
expect(snap.get('fpiAtStart')).toBe(BASE_TOTAL);
|
||||||
|
expect(snap.get('erodedTotal')).toBeUndefined();
|
||||||
|
expect(await currentFpiTotal()).toBe(BASE_TOTAL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('v0.3.0 팬덤별 firstAt 설정도 하위호환으로 읽는다', async () => {
|
||||||
|
await seedFandom();
|
||||||
|
await aggregateFpi();
|
||||||
|
await getFirestore().doc(`fandoms/${FANDOM}`)
|
||||||
|
.set({ eclipseSettings: { firstAt: '08:00' } }, { merge: true });
|
||||||
|
|
||||||
|
await runEclipseSchedule(kst(DAY, '08:01'));
|
||||||
|
const snap = await getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-1`).get();
|
||||||
|
expect((snap.get('startAt') as Timestamp).toMillis())
|
||||||
|
.toBe(kst(DAY, '08:00').getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('방어 창이 지나면 닫는다', async () => {
|
||||||
|
await seedFandom();
|
||||||
|
await aggregateFpi();
|
||||||
|
await runEclipseSchedule(kst(DAY, '13:01'));
|
||||||
|
|
||||||
|
// 창은 30분이다.
|
||||||
|
await runEclipseSchedule(kst(DAY, '13:20'));
|
||||||
|
expect((await getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-1`).get())
|
||||||
|
.get('status')).toBe('active');
|
||||||
|
|
||||||
|
await runEclipseSchedule(kst(DAY, '13:31'));
|
||||||
|
// 검수 D3 — 마감이 지나면 `ended` 로 닫아 배너 질의에서 빠진다.
|
||||||
|
expect((await getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-1`).get())
|
||||||
|
.get('status')).toBe('ended');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe('일정 — 배열 길이 = 하루 회차 수 · 팬덤별 우선 (원장 C-24)', () => {
|
||||||
|
const ids = async () => (await getFirestore().collection(`fandoms/${FANDOM}/eclipseRounds`).get())
|
||||||
|
.docs.map((d) => [d.id, (d.get('startAt') as Timestamp).toMillis()]).sort();
|
||||||
|
|
||||||
|
it('공통 일정 3회를 설정하면 하루 3회차를 만든다', async () => {
|
||||||
|
await seedFandom({ schedule: [{ hhmm: '07:50' }, { hhmm: '12:00' }, { hhmm: '17:50' }] });
|
||||||
|
await aggregateFpi();
|
||||||
|
for (const at of ['07:50', '12:00', '17:50', '23:59']) await runEclipseSchedule(kst(DAY, at));
|
||||||
|
expect(await ids()).toEqual([
|
||||||
|
[`${DAY}-1`, kst(DAY, '07:50').getTime()],
|
||||||
|
[`${DAY}-2`, kst(DAY, '12:00').getTime()],
|
||||||
|
[`${DAY}-3`, kst(DAY, '17:50').getTime()],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('팬덤별 eclipseSchedule 이 있으면 공통 일정 대신 그것을 쓰고 푸시·종료를 다시 계산한다', async () => {
|
||||||
|
await seedFandom({ warnSeconds: 180, windowSec: 60 });
|
||||||
|
await aggregateFpi();
|
||||||
|
await getFirestore().doc(`fandoms/${FANDOM}`)
|
||||||
|
.set({ eclipseSchedule: [{ hhmm: '09:10', tz: 'Asia/Seoul' }] }, { merge: true });
|
||||||
|
for (const at of ['09:07', '09:10', '13:00', '21:00', '23:59']) await runEclipseSchedule(kst(DAY, at));
|
||||||
|
expect(await ids()).toEqual([[`${DAY}-1`, kst(DAY, '09:10').getTime()]]);
|
||||||
|
const snap = await getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-1`).get();
|
||||||
|
expect(snap.get('warnAt').toMillis()).toBe(kst(DAY, '09:07').getTime());
|
||||||
|
expect(snap.get('alertPushedAt').toMillis()).toBe(kst(DAY, '09:07').getTime());
|
||||||
|
expect(snap.get('endsAt').toMillis()).toBe(kst(DAY, '09:10').getTime() + 60_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('v1.5 실패 확정 시에만 시작 FPI 기준으로 1회 차감', () => {
|
||||||
|
const round = () => getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-1`);
|
||||||
|
async function start() {
|
||||||
|
await seedFandom();
|
||||||
|
await aggregateFpi();
|
||||||
|
await getFirestore().doc(`fandoms/${FANDOM}`).update({ sfpi: 12345 });
|
||||||
|
await runEclipseSchedule(kst(DAY, '13:00'));
|
||||||
|
}
|
||||||
|
const penalty = BASE_TOTAL * EROSION_PERCENT / 100;
|
||||||
|
|
||||||
|
it('시작과 방어 중에는 차감·복구 이벤트 없이 시작값만 보관한다', async () => {
|
||||||
|
await start();
|
||||||
|
expect((await round().get()).get('fpiAtStart')).toBe(BASE_TOTAL);
|
||||||
|
await aggregateFpi();
|
||||||
|
expect(await currentFpiTotal()).toBe(BASE_TOTAL);
|
||||||
|
expect(await fpiEventsByReason()).toEqual({ SEED: 5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('성공은 마감 후에도 무차감이며 재집계가 보너스 FPI를 만들지 않는다', async () => {
|
||||||
|
await start();
|
||||||
|
await defendEclipse({ uid: A, fandomId: FANDOM, eclipseId: `${DAY}-1`, clientSeq: 'win', shakes: TARGET });
|
||||||
|
await runEclipseSchedule(kst(DAY, '13:30'));
|
||||||
|
await aggregateActiveEclipses();
|
||||||
|
await aggregateFpi();
|
||||||
|
expect((await round().get()).data()).toMatchObject({ result: 'success', appliedAt: null });
|
||||||
|
expect(await currentFpiTotal()).toBe(BASE_TOTAL);
|
||||||
|
expect(await fpiEventsByReason()).toEqual({ SEED: 5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('새 활동을 보존하고 마감 경계부터 실패 1건만 차감하며 과거 sFPI는 그대로다', async () => {
|
||||||
|
await start();
|
||||||
|
const db = getFirestore();
|
||||||
|
await db.doc(`fandoms/${FANDOM}/fpiEvents/new-activity`).set({
|
||||||
|
axis: 'bpm', value: 1000000, verified: true, reasonCode: 'SEED',
|
||||||
|
occurredAt: Timestamp.fromDate(kst(DAY, '13:05')),
|
||||||
|
});
|
||||||
|
await aggregateFpi();
|
||||||
|
await runEclipseSchedule(new Date(kst(DAY, '13:30').getTime() - 1));
|
||||||
|
expect(await currentFpiTotal()).toBe(BASE_TOTAL + 1000000);
|
||||||
|
await Promise.all([runEclipseSchedule(kst(DAY, '13:30')), runEclipseSchedule(kst(DAY, '13:30'))]);
|
||||||
|
expect((await round().get()).data()).toMatchObject({ result: 'failed', penalty });
|
||||||
|
expect((await round().get()).get('appliedAt').toMillis()).toBe(kst(DAY, '13:30').getTime());
|
||||||
|
// 이미 확정된 회차를 다시 돌려도 차감은 1회다(G-02).
|
||||||
|
await runEclipseSchedule(kst(DAY, '13:31'));
|
||||||
|
expect(await currentFpiTotal()).toBe(BASE_TOTAL + 1000000 - penalty);
|
||||||
|
await aggregateFpi();
|
||||||
|
await aggregateFpi();
|
||||||
|
expect(await currentFpiTotal()).toBe(BASE_TOTAL + 1000000 - penalty);
|
||||||
|
expect((await db.doc(`fandoms/${FANDOM}`).get()).get('sfpi')).toBe(12345);
|
||||||
|
expect((await fpiEventsByReason())['ECLIPSE_EROSION']).toBe(1);
|
||||||
|
const event = await db.doc(`fandoms/${FANDOM}/fpiEvents/eclipse-${DAY}-1-penalty`).get();
|
||||||
|
expect(event.get('occurredAt').toMillis()).toBe(kst(DAY, '13:30').getTime());
|
||||||
|
expect(event.get('value')).toBe(-penalty);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('차감 기록 저장 실패는 회차 결과와 FPI를 함께 취소하며 재시도로 복구한다', async () => {
|
||||||
|
await start();
|
||||||
|
const spy = vi.spyOn(Transaction.prototype, 'create').mockImplementationOnce(() => { throw new Error('penalty write failed'); });
|
||||||
|
try {
|
||||||
|
await expect(runEclipseSchedule(kst(DAY, '13:30'))).rejects.toThrow('penalty write failed');
|
||||||
|
} finally { spy.mockRestore(); }
|
||||||
|
expect((await round().get()).get('status')).toBe('active');
|
||||||
|
expect(await currentFpiTotal()).toBe(BASE_TOTAL);
|
||||||
|
expect((await fpiEventsByReason())['ECLIPSE_EROSION']).toBeUndefined();
|
||||||
|
await runEclipseSchedule(kst(DAY, '13:30'));
|
||||||
|
expect(await currentFpiTotal()).toBe(BASE_TOTAL - penalty);
|
||||||
|
expect((await round().get()).get('result')).toBe('failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('결과 집계가 오염되면 실패로 처리하지 않고 무차감 취소한다', async () => {
|
||||||
|
await start();
|
||||||
|
await round().collection('shards').doc('invalid').set({ qualifiedParticipants: '1' });
|
||||||
|
await runEclipseSchedule(kst(DAY, '13:30'));
|
||||||
|
expect((await round().get()).data()).toMatchObject({ result: 'cancelled_error', status: 'cancelled' });
|
||||||
|
expect(await currentFpiTotal()).toBe(BASE_TOTAL);
|
||||||
|
expect(await fpiEventsByReason()).toEqual({ SEED: 5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('축별 가중치와 소수 차감에도 시작 FPI의 정확한 비율을 유지한다', async () => {
|
||||||
|
const db = getFirestore();
|
||||||
|
const config = db.doc('config/policy');
|
||||||
|
const original = (await config.get()).data();
|
||||||
|
try {
|
||||||
|
await config.set({ fpiWeights: { bpm: 2, health: 0.5, referral: 1, community: 1, contribution: 1 } }, { merge: true });
|
||||||
|
await seedFandom();
|
||||||
|
await db.doc(`fandoms/${FANDOM}/fpiEvents/seed-bpm`).update({ value: BASE_AXES.bpm! + 1 });
|
||||||
|
await aggregateFpi();
|
||||||
|
const startFpi = await currentFpiTotal();
|
||||||
|
expect(startFpi).toBe(1140002);
|
||||||
|
await runEclipseSchedule(kst(DAY, '13:00'));
|
||||||
|
await runEclipseSchedule(kst(DAY, '13:30'));
|
||||||
|
const loss = startFpi * 0.0025;
|
||||||
|
expect((await round().get()).get('penalty')).toBe(loss);
|
||||||
|
expect(await currentFpiTotal()).toBeCloseTo(startFpi - loss, 8);
|
||||||
|
await aggregateFpi();
|
||||||
|
expect(await currentFpiTotal()).toBeCloseTo(startFpi - loss, 8);
|
||||||
|
} finally {
|
||||||
|
if (original) await config.set(original);
|
||||||
|
else await config.delete();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('마감 전 읽은 FPI 집계가 뒤늦게 저장돼도 확정 차감을 덮어쓰지 않는다', async () => {
|
||||||
|
await start();
|
||||||
|
const original = Transaction.prototype.get;
|
||||||
|
let closed = false;
|
||||||
|
const spy = vi.spyOn(Transaction.prototype, 'get').mockImplementation(async function (this: Transaction, ...args) {
|
||||||
|
if (!closed && 'path' in args[0] && args[0].path === `fandoms/${FANDOM}`) {
|
||||||
|
closed = true;
|
||||||
|
await runEclipseSchedule(kst(DAY, '13:30'));
|
||||||
|
}
|
||||||
|
return original.apply(this, args);
|
||||||
|
});
|
||||||
|
try { await aggregateFpi(); } finally { spy.mockRestore(); }
|
||||||
|
expect(closed).toBe(true);
|
||||||
|
expect(await currentFpiTotal()).toBe(BASE_TOTAL - penalty);
|
||||||
|
await aggregateFpi();
|
||||||
|
expect(await currentFpiTotal()).toBe(BASE_TOTAL - penalty);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { afterAll, beforeAll, expect, it, vi } from 'vitest';
|
||||||
|
import { DocumentReference, getFirestore } from 'firebase-admin/firestore';
|
||||||
|
import { fnPlanetEvolutionRecord } from '../src/fandom/evolution.js';
|
||||||
|
import { bootAdmin, shutdownAdmin } from './support.js';
|
||||||
|
|
||||||
|
const id = 'test-evolution-history';
|
||||||
|
beforeAll(() => { bootAdmin(); });
|
||||||
|
afterAll(async () => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
await getFirestore().recursiveDelete(getFirestore().doc(`fandoms/${id}`));
|
||||||
|
await shutdownAdmin();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function event(beforeStage: unknown, afterStage: unknown, eventId: string) {
|
||||||
|
const db = getFirestore();
|
||||||
|
const ref = db.doc(`fandoms/${id}`);
|
||||||
|
await ref.set({ ...(beforeStage === undefined ? {} : { stage: beforeStage }) });
|
||||||
|
const before = (await db.collection('fandoms').where('__name__', '==', id).get()).docs[0]!;
|
||||||
|
await ref.set({ stage: afterStage });
|
||||||
|
const after = (await db.collection('fandoms').where('__name__', '==', id).get()).docs[0]!;
|
||||||
|
return {
|
||||||
|
specversion: '1.0' as const, id: eventId, source: 'firestore',
|
||||||
|
type: 'google.cloud.firestore.document.v1.updated', time: new Date().toISOString(),
|
||||||
|
data: { before, after }, params: { fandomId: id },
|
||||||
|
location: 'asia-northeast3', project: 'demo-fanuzu', database: '(default)',
|
||||||
|
namespace: '', document: ref.path,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('저장 실패를 재시도하고 동시 재전달에도 확정 진화를 한 번만 남긴다', async () => {
|
||||||
|
const input = await event(1, 3, 'confirmed/event');
|
||||||
|
const create = vi.spyOn(DocumentReference.prototype, 'create').mockRejectedValueOnce(new Error('write failed'));
|
||||||
|
try {
|
||||||
|
await expect(fnPlanetEvolutionRecord.run(input)).rejects.toThrow('write failed');
|
||||||
|
} finally {
|
||||||
|
create.mockRestore();
|
||||||
|
}
|
||||||
|
await Promise.all([fnPlanetEvolutionRecord.run(input), fnPlanetEvolutionRecord.run(input)]);
|
||||||
|
const records = await getFirestore().collection(`fandoms/${id}/stateHistory`).get();
|
||||||
|
expect(records.size).toBe(1);
|
||||||
|
const record = records.docs[0]!;
|
||||||
|
expect(record.get('kind')).toBe('evolution');
|
||||||
|
expect(record.get('fromStage')).toBe(1);
|
||||||
|
expect(record.get('toStage')).toBe(3);
|
||||||
|
expect(record.get('occurredAt').isEqual(input.data.after.updateTime)).toBe(true);
|
||||||
|
expect(record.get('participantIds')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('동일 Stage·하락·누락·범위 밖 수치를 진화로 기록하지 않는다', async () => {
|
||||||
|
const history = getFirestore().collection(`fandoms/${id}/stateHistory`);
|
||||||
|
const count = (await history.get()).size;
|
||||||
|
for (const [before, after] of [[3, 3], [3, 2], [undefined, 3], [-1, 1], [6, 7], [1, '2']]) {
|
||||||
|
await fnPlanetEvolutionRecord.run(await event(before, after, `skip-${before}-${after}`));
|
||||||
|
}
|
||||||
|
expect((await history.get()).size).toBe(count);
|
||||||
|
});
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { getFirestore, FieldValue, Timestamp } from 'firebase-admin/firestore';
|
||||||
|
|
||||||
|
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
|
||||||
|
import { distanceMeters, gradeShake } from '../src/quest/verify.js';
|
||||||
|
import { grantPop, refundPop, spendPop } from '../src/ledger/ledger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 5~10주차 함수의 불변식 검증.
|
||||||
|
*
|
||||||
|
* 화면 없이도 확인할 수 있는 것만 본다 — 판정식, 중복 차단, 환불 무결성.
|
||||||
|
* 스케줄 함수의 전체 왕복은 배포 뒤 확인한다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const UID = 'test-ext-user';
|
||||||
|
const OTHER = 'test-ext-other';
|
||||||
|
|
||||||
|
beforeAll(() => { bootAdmin(); });
|
||||||
|
afterAll(async () => { await shutdownAdmin(); });
|
||||||
|
beforeEach(async () => {
|
||||||
|
await wipeUser(UID);
|
||||||
|
await wipeUser(OTHER);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Music Shake 판정 (E-02)', () => {
|
||||||
|
it('판정표가 스스로 모순되면 실격이다', () => {
|
||||||
|
// perfect+good+miss 가 구간 수와 다르면 표가 조작되었거나 깨진 것이다.
|
||||||
|
expect(gradeShake({ perfect: 10, good: 0, miss: 0, maxCombo: 10 }, 20)).toBe('F');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('빈틈없이 맞히면 S 다', () => {
|
||||||
|
expect(gradeShake({ perfect: 20, good: 0, miss: 0, maxCombo: 20 }, 20)).toBe('S');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('한 번이라도 놓치면 S 가 아니다', () => {
|
||||||
|
expect(gradeShake({ perfect: 19, good: 0, miss: 1, maxCombo: 19 }, 20)).toBe('A');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('절반도 못 맞히면 실격이다', () => {
|
||||||
|
expect(gradeShake({ perfect: 5, good: 2, miss: 13, maxCombo: 3 }, 20)).toBe('F');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('구간이 0이면 실격이다', () => {
|
||||||
|
expect(gradeShake({ perfect: 0, good: 0, miss: 0, maxCombo: 0 }, 0)).toBe('F');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('방문 거리 (E-06)', () => {
|
||||||
|
it('같은 좌표는 0 이다', () => {
|
||||||
|
expect(distanceMeters(37.5, 127.0, 37.5, 127.0)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('서울 안 두 지점의 거리를 백 미터 단위로 맞춘다', () => {
|
||||||
|
// 강남역 ↔ 역삼역, 실측 약 1.1km.
|
||||||
|
const d = distanceMeters(37.4979, 127.0276, 37.5006, 127.0365);
|
||||||
|
expect(d).toBeGreaterThan(800);
|
||||||
|
expect(d).toBeLessThan(1400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('위도 1도는 약 111km 다', () => {
|
||||||
|
const d = distanceMeters(37.0, 127.0, 38.0, 127.0);
|
||||||
|
expect(d).toBeGreaterThan(110_000);
|
||||||
|
expect(d).toBeLessThan(112_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('현상금 — 예치·전송·환불 (별첨 1 ④)', () => {
|
||||||
|
it('예치·전송·환불이 모두 원장을 지나 잔액이 재현된다', async () => {
|
||||||
|
await grantPop({
|
||||||
|
uid: UID, popType: 'activity', amount: 1000,
|
||||||
|
reasonCode: 'ADMIN_GRANT', idempotencyKey: 'g:qna',
|
||||||
|
});
|
||||||
|
const deposit = await spendPop({
|
||||||
|
uid: UID, popType: 'activity', amount: 500,
|
||||||
|
reasonCode: 'QNA_BOUNTY', idempotencyKey: 'bounty:u:1',
|
||||||
|
});
|
||||||
|
expect(deposit.balanceAfter).toBe(500);
|
||||||
|
|
||||||
|
// 채택 — 답변자에게 지급.
|
||||||
|
const paid = await grantPop({
|
||||||
|
uid: OTHER, popType: 'activity', amount: 500,
|
||||||
|
reasonCode: 'QUEST_ATTEND', idempotencyKey: 'accept:q1',
|
||||||
|
});
|
||||||
|
expect(paid.balanceAfter).toBe(500);
|
||||||
|
|
||||||
|
const db = getFirestore();
|
||||||
|
const mine = await db.doc(`users/${UID}/wallet/main`).get();
|
||||||
|
const theirs = await db.doc(`users/${OTHER}/wallet/main`).get();
|
||||||
|
// 예치한 만큼 정확히 옮겨 갔다 — 만들어지거나 사라진 POP 이 없다.
|
||||||
|
expect(mine.get('activityBalance') + theirs.get('activityBalance')).toBe(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('기한 만료 환불은 한 번만 나간다', async () => {
|
||||||
|
await grantPop({
|
||||||
|
uid: UID, popType: 'activity', amount: 1000,
|
||||||
|
reasonCode: 'ADMIN_GRANT', idempotencyKey: 'g:qna2',
|
||||||
|
});
|
||||||
|
const deposit = await spendPop({
|
||||||
|
uid: UID, popType: 'activity', amount: 300,
|
||||||
|
reasonCode: 'QNA_BOUNTY', idempotencyKey: 'bounty:u:2',
|
||||||
|
});
|
||||||
|
await refundPop({
|
||||||
|
uid: UID, originalTxId: deposit.txId,
|
||||||
|
idempotencyKey: 'qnarefund:q2', reasonCode: 'QNA_REFUND',
|
||||||
|
});
|
||||||
|
await expect(refundPop({
|
||||||
|
uid: UID, originalTxId: deposit.txId,
|
||||||
|
idempotencyKey: 'qnarefund:q2-again', reasonCode: 'QNA_REFUND',
|
||||||
|
})).rejects.toMatchObject({ details: { code: 'E-POP-03' } });
|
||||||
|
|
||||||
|
const wallet = await getFirestore().doc(`users/${UID}/wallet/main`).get();
|
||||||
|
expect(wallet.get('activityBalance')).toBe(1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('캠페인 환불 — 부분 실패를 덮지 않는다 (별첨 1 ⑤)', () => {
|
||||||
|
it('후원 한 건당 한 번만 돌려준다', async () => {
|
||||||
|
await grantPop({
|
||||||
|
uid: UID, popType: 'activity', amount: 1000,
|
||||||
|
reasonCode: 'ADMIN_GRANT', idempotencyKey: 'g:cmp',
|
||||||
|
});
|
||||||
|
const donation = await spendPop({
|
||||||
|
uid: UID, popType: 'activity', amount: 400,
|
||||||
|
reasonCode: 'CAMPAIGN_FUND', idempotencyKey: 'fund:cmp:1',
|
||||||
|
});
|
||||||
|
|
||||||
|
const key = 'refund:donation-1';
|
||||||
|
const first = await refundPop({
|
||||||
|
uid: UID, originalTxId: donation.txId,
|
||||||
|
idempotencyKey: key, reasonCode: 'CAMPAIGN_REFUND',
|
||||||
|
});
|
||||||
|
// 배치를 다시 돌려도 같은 거래로 판정된다.
|
||||||
|
const again = await refundPop({
|
||||||
|
uid: UID, originalTxId: donation.txId,
|
||||||
|
idempotencyKey: key, reasonCode: 'CAMPAIGN_REFUND',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(again.txId).toBe(first.txId);
|
||||||
|
const wallet = await getFirestore().doc(`users/${UID}/wallet/main`).get();
|
||||||
|
expect(wallet.get('activityBalance')).toBe(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('환불하면 기여 통계도 되돌아간다', async () => {
|
||||||
|
await grantPop({
|
||||||
|
uid: UID, popType: 'activity', amount: 1000,
|
||||||
|
reasonCode: 'ADMIN_GRANT', idempotencyKey: 'g:cmp2',
|
||||||
|
});
|
||||||
|
const donation = await spendPop({
|
||||||
|
uid: UID, popType: 'activity', amount: 400,
|
||||||
|
reasonCode: 'CAMPAIGN_FUND', idempotencyKey: 'fund:cmp:2',
|
||||||
|
fandomId: 'f1',
|
||||||
|
});
|
||||||
|
const db = getFirestore();
|
||||||
|
expect((await db.doc(`users/${UID}`).get()).get('totalContributedPop')).toBe(400);
|
||||||
|
|
||||||
|
await refundPop({
|
||||||
|
uid: UID, originalTxId: donation.txId,
|
||||||
|
idempotencyKey: 'refund:donation-2', reasonCode: 'CAMPAIGN_REFUND',
|
||||||
|
});
|
||||||
|
expect((await db.doc(`users/${UID}`).get()).get('totalContributedPop')).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('영웅의 선물 — 문서 ID 로 중복을 막는다', () => {
|
||||||
|
it('같은 사람이 두 번 수령하지 못한다', async () => {
|
||||||
|
const db = getFirestore();
|
||||||
|
const dropRef = db.doc('fandoms/f1/heroDrops/d1');
|
||||||
|
await dropRef.set({
|
||||||
|
totalPop: 1000, perUserCap: 100, remainingPop: 1000,
|
||||||
|
endsAt: Timestamp.fromMillis(Date.now() + 60_000),
|
||||||
|
createdAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
const claimRef = dropRef.collection('claims').doc(UID);
|
||||||
|
await claimRef.create({ uid: UID, amount: 100 });
|
||||||
|
|
||||||
|
// 조건문이 아니라 구조로 막힌다 — 동시 요청에서도 뚫리지 않는다.
|
||||||
|
await expect(claimRef.create({ uid: UID, amount: 100 })).rejects.toThrow();
|
||||||
|
|
||||||
|
await dropRef.delete();
|
||||||
|
await claimRef.delete();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('추천 — 자기 추천과 중복 전환', () => {
|
||||||
|
it('같은 사람이 같은 코드로 두 번 전환되지 않는다', async () => {
|
||||||
|
const db = getFirestore();
|
||||||
|
const ref = db.doc(`referrals/${UID}_CODE1`);
|
||||||
|
await ref.create({ referredUid: UID, referrerUid: OTHER, code: 'CODE1' });
|
||||||
|
await expect(
|
||||||
|
ref.create({ referredUid: UID, referrerUid: OTHER, code: 'CODE1' }),
|
||||||
|
).rejects.toThrow();
|
||||||
|
await ref.delete();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('번역 — 자기검증 제외', () => {
|
||||||
|
it('자기 마킹은 유효 표에서 빠진다', () => {
|
||||||
|
const author = 'author';
|
||||||
|
const markings = [
|
||||||
|
{ id: author, valid: true },
|
||||||
|
{ id: 'a', valid: true },
|
||||||
|
{ id: 'b', valid: false },
|
||||||
|
{ id: 'c', valid: false },
|
||||||
|
];
|
||||||
|
const valid = markings.filter((m) => m.id !== author && m.valid).length;
|
||||||
|
const invalid = markings.filter((m) => m.id !== author && !m.valid).length;
|
||||||
|
// 자기 마킹을 세면 2:2 로 통과하지만, 빼면 1:2 로 무효다.
|
||||||
|
expect(valid).toBe(1);
|
||||||
|
expect(invalid).toBe(2);
|
||||||
|
expect(invalid > valid).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { afterAll, beforeAll, beforeEach, expect, it, vi } from 'vitest';
|
||||||
|
import { FieldValue, getFirestore } from 'firebase-admin/firestore';
|
||||||
|
import { aggregateLive, closeDay, fnFpiAggregate, fnFpiDailySnapshot } from '../src/fandom/fandom.js';
|
||||||
|
import { Timestamp } from 'firebase-admin/firestore';
|
||||||
|
import { bootAdmin, shutdownAdmin } from './support.js';
|
||||||
|
|
||||||
|
const id = 'test-atomic-sfpi';
|
||||||
|
const run = () => fnFpiDailySnapshot.run({ scheduleTime: new Date().toISOString() });
|
||||||
|
beforeAll(() => { bootAdmin(); });
|
||||||
|
beforeEach(async () => {
|
||||||
|
const ref = getFirestore().doc(`fandoms/${id}`);
|
||||||
|
await getFirestore().recursiveDelete(ref);
|
||||||
|
await ref.set({ fpiScore: 125, sfpi: 50 });
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
await getFirestore().recursiveDelete(getFirestore().doc(`fandoms/${id}`));
|
||||||
|
await shutdownAdmin();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('누적 쓰기 준비가 실패해도 스냅샷만 남지 않고 재시도로 하루치를 복구한다', async () => {
|
||||||
|
const increment = vi.spyOn(FieldValue, 'increment').mockImplementationOnce(() => {
|
||||||
|
throw new Error('injected write failure');
|
||||||
|
});
|
||||||
|
await expect(run()).rejects.toThrow('injected write failure');
|
||||||
|
increment.mockRestore();
|
||||||
|
const ref = getFirestore().doc(`fandoms/${id}`);
|
||||||
|
expect((await ref.collection('fpiSnapshots').get()).size).toBe(0);
|
||||||
|
expect((await ref.get()).get('sfpi')).toBe(50);
|
||||||
|
await run();
|
||||||
|
await Promise.all([run(), run()]);
|
||||||
|
const snapshots = await ref.collection('fpiSnapshots').get();
|
||||||
|
expect(snapshots.size).toBe(1);
|
||||||
|
expect(snapshots.docs[0]!.get('fpiScore')).toBe(125);
|
||||||
|
expect((await ref.get()).get('sfpi')).toBe(175);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('KST 자정 실행은 전날 키로 스냅샷 · DAU 를 남기고 DAU 는 인정 활동의 고유 사용자 수다', async () => {
|
||||||
|
const ref = getFirestore().doc(`fandoms/${id}`);
|
||||||
|
const at = (iso: string) => Timestamp.fromDate(new Date(iso));
|
||||||
|
const events = ref.collection('fpiEvents');
|
||||||
|
// 9/20 KST 하루: 사용자 a 2건 · b 1건 · 미인정 c · uid 없는 Eclipse 차감 · 경계 밖 d.
|
||||||
|
await events.add({ uid: 'a', verified: true, axis: 'community', value: 1, occurredAt: at('2026-09-20T00:00:00+09:00') });
|
||||||
|
await events.add({ uid: 'a', verified: true, axis: 'health', value: 1, occurredAt: at('2026-09-20T12:00:00+09:00') });
|
||||||
|
await events.add({ uid: 'b', verified: true, axis: 'bpm', value: 1, occurredAt: at('2026-09-20T23:59:59+09:00') });
|
||||||
|
await events.add({ uid: 'c', verified: false, axis: 'bpm', value: 1, occurredAt: at('2026-09-20T10:00:00+09:00') });
|
||||||
|
await events.add({ kind: 'score_adjustment', verified: true, value: -1, occurredAt: at('2026-09-20T17:51:00+09:00') });
|
||||||
|
await events.add({ uid: 'd', verified: true, axis: 'bpm', value: 1, occurredAt: at('2026-09-21T00:00:00+09:00') });
|
||||||
|
|
||||||
|
const result = await closeDay(new Date('2026-09-21T00:00:30+09:00'));
|
||||||
|
expect(result.day).toBe('2026-09-20');
|
||||||
|
expect((await ref.collection('fandomDailyStats').doc('2026-09-20').get()).get('dau')).toBe(2);
|
||||||
|
expect((await ref.collection('fpiSnapshots').doc('2026-09-20').get()).exists).toBe(true);
|
||||||
|
expect((await ref.collection('fpiSnapshots').doc('2026-09-21').get()).exists).toBe(false);
|
||||||
|
|
||||||
|
// 활동 0명인 날도 0 으로 남는다(C-27). 재실행은 스냅샷 · sFPI 를 한 번만 반영한다.
|
||||||
|
await closeDay(new Date('2026-09-22T00:00:30+09:00'));
|
||||||
|
await closeDay(new Date('2026-09-22T00:00:30+09:00'));
|
||||||
|
expect((await ref.collection('fandomDailyStats').doc('2026-09-21').get()).get('dau')).toBe(1);
|
||||||
|
await closeDay(new Date('2026-09-23T00:00:30+09:00'));
|
||||||
|
expect((await ref.collection('fandomDailyStats').doc('2026-09-22').get()).get('dau')).toBe(0);
|
||||||
|
expect((await ref.get()).get('sfpi')).toBe(50 + 125 * 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Eclipse 차감만 있는 팬덤은 FPI 0 아래로 내려가지 않고 랭킹 모집단에 들지 않는다(리뷰 M2)', async () => {
|
||||||
|
const ref = getFirestore().doc(`fandoms/${id}`);
|
||||||
|
await ref.set({ stage: 1 }, { merge: true });
|
||||||
|
await ref.collection('fpiEvents').add({
|
||||||
|
kind: 'score_adjustment', reasonCode: 'ECLIPSE_EROSION', value: -5, verified: true,
|
||||||
|
occurredAt: Timestamp.now(),
|
||||||
|
});
|
||||||
|
await fnFpiAggregate.run({ scheduleTime: new Date().toISOString() });
|
||||||
|
const doc = await ref.get();
|
||||||
|
expect(doc.get('fpiScore')).toBe(0);
|
||||||
|
expect(doc.get('rankingEligible')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('LIVE — 최근 창(분) 안에 앱을 쓴 사용자를 fandomIds 마다 세고 빠진 팬덤은 0 으로 내린다(Q-GM-03 A안)', async () => {
|
||||||
|
const db = getFirestore();
|
||||||
|
const now = Date.now();
|
||||||
|
const seed: Array<[string, number, string[]]> = [
|
||||||
|
['test-live-1', now - 60_000, ['test-live-f1', 'test-live-f2']],
|
||||||
|
['test-live-2', now - 10 * 60_000, ['test-live-f1']], // 창 밖
|
||||||
|
['test-live-3', now - 1_000, ['test-live-f1', 'test-live-gone']], // 없는 팬덤은 건너뛴다
|
||||||
|
];
|
||||||
|
try {
|
||||||
|
await db.doc('fandoms/test-live-f1').set({ onlineCount: 0 });
|
||||||
|
await db.doc('fandoms/test-live-f2').set({ onlineCount: 0 });
|
||||||
|
await db.doc('fandoms/test-live-f3').set({ onlineCount: 7 }); // 지난 집계의 값
|
||||||
|
for (const [u, at, fandomIds] of seed) await db.doc(`users/${u}`).set({ lastSeenAt: Timestamp.fromMillis(at), fandomIds });
|
||||||
|
await aggregateLive(now);
|
||||||
|
const online = async (id: string) => (await db.doc(`fandoms/${id}`).get()).get('onlineCount');
|
||||||
|
expect([await online('test-live-f1'), await online('test-live-f2'), await online('test-live-f3')]).toEqual([2, 1, 0]);
|
||||||
|
expect((await db.doc('fandoms/test-live-gone').get()).exists).toBe(false);
|
||||||
|
} finally {
|
||||||
|
for (const [u] of seed) await db.recursiveDelete(db.doc(`users/${u}`));
|
||||||
|
for (const f of ['f1', 'f2', 'f3']) await db.doc(`fandoms/test-live-${f}`).delete();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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`));
|
||||||
|
});
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import type { CallableRequest } from 'firebase-functions/v2/https';
|
|
||||||
|
|
||||||
import { callerUid, requireRole } from '../src/core/callable.js';
|
|
||||||
import { fnAdminSetRole, fnConsentRecord } from '../src/account/account.js';
|
|
||||||
import { fnAdminWrite } from '../src/admin/admin.js';
|
|
||||||
|
|
||||||
/** 에뮬레이터 없이 도는 인증 · 권한 관문 검증. 데이터에 닿기 전에 끊겨야 한다. */
|
|
||||||
|
|
||||||
const req = (auth?: { uid: string; token: Record<string, unknown> }) =>
|
|
||||||
({ data: {}, auth } as unknown as CallableRequest);
|
|
||||||
|
|
||||||
describe('인증 관문', () => {
|
|
||||||
it('익명 호출은 E-AUTH-03 으로 끊는다', () => {
|
|
||||||
expect(() => callerUid(req())).toThrow(expect.objectContaining({ details: { code: 'E-AUTH-03' } }));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('로그인한 호출은 UID 를 돌려준다', () => {
|
|
||||||
expect(callerUid(req({ uid: 'u1', token: {} }))).toBe('u1');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('운영자 관문', () => {
|
|
||||||
it('허용 목록에 없는 역할은 E-PERM-01 로 끊는다', () => {
|
|
||||||
expect(() => requireRole(req({ uid: 'v', token: { role: 'viewer' } }), ['master', 'ops']))
|
|
||||||
.toThrow(expect.objectContaining({ details: { code: 'E-PERM-01' } }));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('허용된 역할은 그대로 돌려준다', () => {
|
|
||||||
expect(requireRole(req({ uid: 'o', token: { role: 'ops' } }), ['master', 'ops'])).toBe('ops');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Admin 쓰기 · 권한 부여는 일반 사용자와 조회 운영자를 데이터 접근 전에 거부한다', async () => {
|
|
||||||
for (const fn of [fnAdminWrite, fnAdminSetRole]) {
|
|
||||||
for (const auth of [{ uid: 'user', token: {} }, { uid: 'viewer', token: { role: 'viewer' } }]) {
|
|
||||||
await expect(fn.run(req(auth) as Parameters<typeof fn.run>[0]))
|
|
||||||
.rejects.toMatchObject({ details: { code: 'E-PERM-01' } });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('약관 동의 기록은 익명 호출을 거부한다', async () => {
|
|
||||||
await expect(fnConsentRecord.run(req() as Parameters<typeof fnConsentRecord.run>[0]))
|
|
||||||
.rejects.toMatchObject({ details: { code: 'E-AUTH-03' } });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { afterAll, afterEach, beforeAll, beforeEach, expect, it, vi } from 'vitest';
|
||||||
|
import { getFirestore, Timestamp } from 'firebase-admin/firestore';
|
||||||
|
import { bootAdmin, shutdownAdmin } from './support.js';
|
||||||
|
import * as ledger from '../src/ledger/ledger.js';
|
||||||
|
import { txIdOf } from '../src/core/ids.js';
|
||||||
|
import { fnHeroDropTrigger, fnHeroDropClaim } from '../src/fandom/herodrop.js';
|
||||||
|
const uid = 'hero-atomic-test';
|
||||||
|
const fandomId = 'hero-atomic-test';
|
||||||
|
async function call(fn: { run(request: never): unknown }, data: Record<string, unknown>) {
|
||||||
|
return await fn.run({ data, auth: { uid, token: {} } } as never) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
async function clean() {
|
||||||
|
for (const path of [`users/${uid}`, `fandoms/${fandomId}`])
|
||||||
|
await getFirestore().recursiveDelete(getFirestore().doc(path));
|
||||||
|
}
|
||||||
|
beforeAll(() => { bootAdmin(); });
|
||||||
|
beforeEach(async () => {
|
||||||
|
await clean();
|
||||||
|
await getFirestore().doc('config/policy').set({ herodrop: { totalPop: 100, perUserCap: 5, perUserCapPercent: 5, durationSec: 600 } }, { merge: true });
|
||||||
|
await getFirestore().doc(`users/${uid}`).set({ nickname: '공개팬', displayName: '비공개이름', email: 'private@example.com' });
|
||||||
|
await ledger.grantPop({ uid, amount: 200, popType: 'support', reasonCode: 'ADMIN_GRANT', idempotencyKey: 'hero-seed' });
|
||||||
|
});
|
||||||
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
afterAll(async () => { await clean(); await shutdownAdmin(); });
|
||||||
|
const trigger = () => call(fnHeroDropTrigger, { fandomId, productId: 'gift', clientSeq: '1', heroNickname: '위조닉네임' });
|
||||||
|
it('발동 재시도는 소진된 Pool과 종료시각을 다시 쓰지 않는다', async () => {
|
||||||
|
const first = await trigger();
|
||||||
|
const ref = getFirestore().doc(`fandoms/${fandomId}/heroDrops/${first.dropId}`);
|
||||||
|
const before = (await ref.get()).get('endsAt');
|
||||||
|
expect((await ref.get()).get('heroNickname')).toBe('공개팬');
|
||||||
|
expect((await ref.get()).get('email')).toBeUndefined();
|
||||||
|
await ref.update({ remainingPop: 0, claimedCount: 20 });
|
||||||
|
await Promise.all([trigger(), trigger()]);
|
||||||
|
const after = await ref.get();
|
||||||
|
expect(after.get('remainingPop')).toBe(0);
|
||||||
|
expect(after.get('claimedCount')).toBe(20);
|
||||||
|
expect(after.get('endsAt')).toEqual(before);
|
||||||
|
expect((await getFirestore().doc(`users/${uid}/wallet/main`).get()).get('supportBalance')).toBe(100);
|
||||||
|
});
|
||||||
|
it('수령 지급 실패는 Pool과 수령 기록도 남기지 않는다', async () => {
|
||||||
|
const { dropId } = await trigger();
|
||||||
|
const ref = getFirestore().doc(`fandoms/${fandomId}/heroDrops/${dropId}`);
|
||||||
|
vi.spyOn(ledger, 'grantPop').mockRejectedValueOnce(new Error('grant unavailable'));
|
||||||
|
await expect(call(fnHeroDropClaim, { fandomId, dropId })).rejects.toThrow('grant unavailable');
|
||||||
|
expect((await ref.get()).get('remainingPop')).toBe(100);
|
||||||
|
expect((await ref.collection('claims').get()).size).toBe(0);
|
||||||
|
});
|
||||||
|
it('동시 수령과 종료 후 응답 재확인은 같은 지급 결과를 돌려준다', async () => {
|
||||||
|
const { dropId } = await trigger();
|
||||||
|
const results = await Promise.all([
|
||||||
|
call(fnHeroDropClaim, { fandomId, dropId }), call(fnHeroDropClaim, { fandomId, dropId }),
|
||||||
|
]);
|
||||||
|
expect(results[0]).toEqual(results[1]);
|
||||||
|
const ref = getFirestore().doc(`fandoms/${fandomId}/heroDrops/${dropId}`);
|
||||||
|
await ref.update({ endsAt: Timestamp.fromMillis(0) });
|
||||||
|
expect(await call(fnHeroDropClaim, { fandomId, dropId })).toEqual(results[0]);
|
||||||
|
expect((await ref.get()).get('remainingPop')).toBe(95);
|
||||||
|
expect((await ref.get()).get('claimedCount')).toBe(1);
|
||||||
|
expect((await getFirestore().doc(`users/${uid}/wallet/main`).get()).get('activityBalance')).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('이벤트 생성 실패 시 지원 POP 차감도 되돌린다', async () => {
|
||||||
|
const dropId = txIdOf(`herodrop:trigger:${uid}:1`);
|
||||||
|
await getFirestore().doc(`fandoms/${fandomId}/heroDrops/${dropId}`).create({ remainingPop: 7 });
|
||||||
|
await expect(trigger()).rejects.toThrow();
|
||||||
|
expect((await getFirestore().doc(`users/${uid}/wallet/main`).get()).get('supportBalance')).toBe(200);
|
||||||
|
expect((await getFirestore().doc(`users/${uid}/popTransactions/${dropId}`).get()).exists).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('공개 닉네임이 없으면 비공개 이름이나 이메일로 대체하지 않고 「팬」으로 싣는다', async () => {
|
||||||
|
await getFirestore().doc(`users/${uid}`).set({ displayName: '비공개이름', email: 'private@example.com' });
|
||||||
|
const { dropId } = await trigger();
|
||||||
|
const drop = await getFirestore().doc(`fandoms/${fandomId}/heroDrops/${dropId}`).get();
|
||||||
|
expect(drop.get('heroNickname')).toBe('팬');
|
||||||
|
expect(drop.get('displayName')).toBeUndefined();
|
||||||
|
expect(drop.get('email')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('기본 5% 상한과 10분을 적용하고 기존 고정 POP 상한은 사용하지 않는다', async () => {
|
||||||
|
await getFirestore().doc('config/policy').update({ herodrop: { totalPop: 200, perUserCap: 150 } });
|
||||||
|
const before = Date.now();
|
||||||
|
const { dropId } = await trigger();
|
||||||
|
const drop = await getFirestore().doc(`fandoms/${fandomId}/heroDrops/${dropId}`).get();
|
||||||
|
expect(drop.get('perUserCap')).toBe(10);
|
||||||
|
expect(drop.get('perUserCapPercent')).toBe(5);
|
||||||
|
expect(drop.get('endsAt').toMillis()).toBeGreaterThanOrEqual(before + 600000);
|
||||||
|
const claim = await call(fnHeroDropClaim, { fandomId, dropId });
|
||||||
|
expect(claim.claimedPop).toBe(10);
|
||||||
|
});
|
||||||
|
it('운영 상한 비율을 이벤트에 고정하고 소수 POP은 상한을 넘겨 올리지 않는다', async () => {
|
||||||
|
await getFirestore().doc('config/policy').update({ herodrop: {
|
||||||
|
totalPop: 101, perUserCapPercent: 3, durationSec: 120,
|
||||||
|
} });
|
||||||
|
const { dropId } = await trigger();
|
||||||
|
const drop = await getFirestore().doc(`fandoms/${fandomId}/heroDrops/${dropId}`).get();
|
||||||
|
expect(drop.get('perUserCap')).toBe(3);
|
||||||
|
await getFirestore().doc('config/policy').update({ 'herodrop.perUserCapPercent': 10 });
|
||||||
|
expect((await call(fnHeroDropClaim, { fandomId, dropId })).claimedPop).toBe(3);
|
||||||
|
});
|
||||||
|
it('유효하지 않은 운영 비율은 차감 전에 거부한다', async () => {
|
||||||
|
for (const cap of [0, -1, 101, '5']) {
|
||||||
|
await getFirestore().doc('config/policy').update({ 'herodrop.perUserCapPercent': cap });
|
||||||
|
await expect(trigger()).rejects.toThrow();
|
||||||
|
}
|
||||||
|
expect((await getFirestore().doc(`users/${uid}/wallet/main`).get()).get('supportBalance')).toBe(200);
|
||||||
|
});
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { afterAll, beforeAll, expect, it, vi } from 'vitest';
|
||||||
|
import { getFirestore, Timestamp, Transaction } from 'firebase-admin/firestore';
|
||||||
|
import { hibernationPatch, hibernationSettings } from '../src/fandom/hibernation.js';
|
||||||
|
import { fnFpiAggregate } from '../src/fandom/fandom.js';
|
||||||
|
import { bootAdmin, shutdownAdmin } from './support.js';
|
||||||
|
|
||||||
|
const config = { enabled: true, wakeFpi: 20, wakeSeconds: 60 };
|
||||||
|
const settings = hibernationSettings(config)!;
|
||||||
|
const day = 86_400_000;
|
||||||
|
const at = (ms: number) => Timestamp.fromMillis(ms);
|
||||||
|
let originalConfig: FirebaseFirestore.DocumentData | undefined;
|
||||||
|
beforeAll(async () => {
|
||||||
|
bootAdmin();
|
||||||
|
originalConfig = (await getFirestore().doc('config/hibernation').get()).data();
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
const db = getFirestore();
|
||||||
|
const ref = db.doc('config/hibernation');
|
||||||
|
if (originalConfig) await ref.set(originalConfig);
|
||||||
|
else await ref.delete();
|
||||||
|
await db.recursiveDelete(db.doc('fandoms/test-hibernation'));
|
||||||
|
await shutdownAdmin();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('진입 기본값만 원문에서 가져오고 복귀 설정 누락·잘못된 값은 거부한다', () => {
|
||||||
|
expect(settings.enterFpi).toBe(10);
|
||||||
|
expect(settings.enterSeconds).toBe(30 * 86400);
|
||||||
|
expect(hibernationSettings({})).toBeNull();
|
||||||
|
for (const invalid of [
|
||||||
|
{ enabled: true }, { ...config, wakeFpi: 10 },
|
||||||
|
{ ...config, wakeSeconds: -1 }, { ...config, enterFpi: '10' },
|
||||||
|
]) expect(() => hibernationSettings(invalid)).toThrow();
|
||||||
|
expect(hibernationPatch({}, Number.NaN, settings, at(0))).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('30일 연속 미만·경계값·복귀 유지시간·재시도·정책 변경을 구분한다', () => {
|
||||||
|
let state = hibernationPatch({}, 9, settings, at(0));
|
||||||
|
state = { ...state, ...hibernationPatch(state, 9, settings, at(30 * day - 1)) };
|
||||||
|
expect(state.isHibernating).toBe(false);
|
||||||
|
const interrupted = hibernationPatch(state, 10, settings, at(30 * day));
|
||||||
|
expect(interrupted.hibernationLowSince).toBeNull();
|
||||||
|
expect(interrupted.isHibernating).toBe(false);
|
||||||
|
const changed = hibernationPatch(state, 9, { ...settings, enterFpi: 11 }, at(30 * day));
|
||||||
|
expect(changed.isHibernating).toBe(false);
|
||||||
|
state = { ...state, ...hibernationPatch(state, 9, settings, at(30 * day)) };
|
||||||
|
expect(state.isHibernating).toBe(true);
|
||||||
|
expect(hibernationPatch(state, 99, settings, at(0))).toEqual({});
|
||||||
|
const noActivity = hibernationPatch(state, 99, settings, at(31 * day));
|
||||||
|
expect(noActivity.isHibernating).toBe(true);
|
||||||
|
expect(noActivity.hibernationWakeSince).toBeNull();
|
||||||
|
const activity = { id: 'new-activity', at: at(31 * day) };
|
||||||
|
state = { ...state, ...hibernationPatch(state, 20, settings, at(31 * day), activity) };
|
||||||
|
expect(state.isHibernating).toBe(true);
|
||||||
|
state = { ...state, ...hibernationPatch(state, 19, settings, at(31 * day + 30_000), activity) };
|
||||||
|
expect(state.hibernationWakeSince).toBeNull();
|
||||||
|
state = { ...state, ...hibernationPatch(state, 20, settings, at(32 * day), activity) };
|
||||||
|
expect(state.isHibernating).toBe(true);
|
||||||
|
state = { ...state, ...hibernationPatch(state, 20, settings, at(32 * day + 60_000), activity) };
|
||||||
|
expect(state.isHibernating).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('실제 FPI 집계가 휴면을 함께 확정하며 sFPI나 별도 점수를 차감하지 않는다', async () => {
|
||||||
|
const db = getFirestore();
|
||||||
|
const ref = db.doc('fandoms/test-hibernation');
|
||||||
|
await db.doc('config/hibernation').set(config);
|
||||||
|
await ref.set({
|
||||||
|
stage: 1, sfpi: 100, fpiScore: 99,
|
||||||
|
...hibernationPatch({}, 0, settings, at(Date.now() - 31 * day)),
|
||||||
|
});
|
||||||
|
const create = vi.spyOn(Transaction.prototype, 'create').mockImplementationOnce(() => {
|
||||||
|
throw new Error('history write failed');
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await expect(fnFpiAggregate.run({ scheduleTime: new Date().toISOString() }))
|
||||||
|
.rejects.toThrow('history write failed');
|
||||||
|
} finally {
|
||||||
|
create.mockRestore();
|
||||||
|
}
|
||||||
|
expect((await ref.get()).get('isHibernating')).toBe(false);
|
||||||
|
expect((await ref.get()).get('fpiScore')).toBe(99);
|
||||||
|
expect((await ref.collection('stateHistory').get()).size).toBe(0);
|
||||||
|
await fnFpiAggregate.run({ scheduleTime: new Date().toISOString() });
|
||||||
|
const result = await ref.get();
|
||||||
|
expect(result.get('isHibernating')).toBe(true);
|
||||||
|
expect(result.get('fpiScore')).toBe(0);
|
||||||
|
expect(result.get('sfpi')).toBe(100);
|
||||||
|
expect((await ref.collection('stateHistory').get()).size).toBe(1);
|
||||||
|
await fnFpiAggregate.run({ scheduleTime: new Date().toISOString() });
|
||||||
|
expect((await ref.get()).get('hibernationChangedAt').toMillis())
|
||||||
|
.toBe(result.get('hibernationChangedAt').toMillis());
|
||||||
|
await db.doc('config/hibernation').set({ ...config, wakeSeconds: 0 });
|
||||||
|
const event = ref.collection('fpiEvents').doc('wake-activity');
|
||||||
|
await event.set({ axis: 'contribution', value: 100, verified: true,
|
||||||
|
occurredAt: Timestamp.now(), reasonCode: 'ECLIPSE_RESTORE' });
|
||||||
|
await fnFpiAggregate.run({ scheduleTime: new Date().toISOString() });
|
||||||
|
expect((await ref.get()).get('isHibernating')).toBe(true);
|
||||||
|
await event.update({ reasonCode: 'QUEST_ACTIVITY' });
|
||||||
|
await fnFpiAggregate.run({ scheduleTime: new Date().toISOString() });
|
||||||
|
const awake = await ref.get();
|
||||||
|
expect(awake.get('isHibernating')).toBe(false);
|
||||||
|
const history = await ref.collection('stateHistory').orderBy('occurredAt').get();
|
||||||
|
expect(history.size).toBe(2);
|
||||||
|
expect(history.docs.map((entry) => entry.get('kind'))).toEqual(['hibernation', 'wake_up']);
|
||||||
|
expect(history.docs[0]!.get('fpiScore')).toBe(0);
|
||||||
|
expect(history.docs[1]!.get('sourceEventId')).toBe('wake-activity');
|
||||||
|
expect(history.docs[1]!.get('settings').wakeSeconds).toBe(0);
|
||||||
|
expect(awake.get('hibernationWakeEventId')).toBeUndefined();
|
||||||
|
await Promise.all([
|
||||||
|
fnFpiAggregate.run({ scheduleTime: new Date().toISOString() }),
|
||||||
|
fnFpiAggregate.run({ scheduleTime: new Date().toISOString() }),
|
||||||
|
]);
|
||||||
|
expect((await ref.collection('stateHistory').get()).size).toBe(2);
|
||||||
|
expect(awake.get('sfpi')).toBe(100);
|
||||||
|
});
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { getFirestore, Timestamp } from 'firebase-admin/firestore';
|
||||||
|
|
||||||
|
import { bootAdmin, shutdownAdmin } from './support.js';
|
||||||
|
import { fnFocusDailyClaim, fnIapVerify, setReceiptVerifier } from '../src/payment/iap.js';
|
||||||
|
import {
|
||||||
|
StoreReceiptVerifier,
|
||||||
|
type Platform, type ReceiptClaim, type ReceiptHint, type ReceiptVerifier,
|
||||||
|
} from '../src/payment/receipt.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `fn-iap-verify` 콜러블 — 별첨 1 ⑥ 샌드박스 연결분.
|
||||||
|
*
|
||||||
|
* 스토어 왕복은 대역으로 두고, 상품 ID 에 따른 세 갈래(구독 · Hero Gift · 상품 표)와
|
||||||
|
* 재전송 · 계정 간 영수증 재사용을 본다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const UID = 'iap-verify-user';
|
||||||
|
const OTHER = 'iap-verify-other';
|
||||||
|
const FANDOM = 'iap-verify-fandom';
|
||||||
|
const GIFT = 'herogift.test.basic';
|
||||||
|
|
||||||
|
let claim: Partial<ReceiptClaim> = {};
|
||||||
|
let lastHint: ReceiptHint | undefined;
|
||||||
|
|
||||||
|
class FakeVerifier implements ReceiptVerifier {
|
||||||
|
async verify(platform: Platform, _receipt: string, hint?: ReceiptHint): Promise<ReceiptClaim> {
|
||||||
|
lastHint = hint;
|
||||||
|
return {
|
||||||
|
platform,
|
||||||
|
transactionId: 'gpa.1',
|
||||||
|
productId: 'pop.support.5500',
|
||||||
|
consumable: true,
|
||||||
|
purchasedAtMillis: 1_788_000_000_000,
|
||||||
|
revoked: false,
|
||||||
|
...claim,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = () => getFirestore();
|
||||||
|
|
||||||
|
async function call(uid: string, data: Record<string, unknown>) {
|
||||||
|
return await fnIapVerify.run(
|
||||||
|
{ data: { platform: 'android', receipt: 'token', ...data }, auth: { uid, token: {} } } as never,
|
||||||
|
) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function codeOf(fn: () => Promise<unknown>): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
} catch (e) {
|
||||||
|
return (e as { details?: { code?: string } }).details?.code;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clean() {
|
||||||
|
for (const path of [`users/${UID}`, `users/${OTHER}`, `fandoms/${FANDOM}`, `heroGiftProducts/${GIFT}`]) {
|
||||||
|
await db().recursiveDelete(db().doc(path));
|
||||||
|
}
|
||||||
|
const owners = await db().collection('iapTransactions').get();
|
||||||
|
await Promise.all(owners.docs.map((d) => d.ref.delete()));
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => { bootAdmin(); });
|
||||||
|
beforeEach(async () => {
|
||||||
|
claim = {};
|
||||||
|
setReceiptVerifier(new FakeVerifier());
|
||||||
|
await clean();
|
||||||
|
await db().doc(`users/${UID}`).set({ nickname: '영웅팬', fandomId: FANDOM });
|
||||||
|
await db().doc('config/policy').set({
|
||||||
|
herodrop: { totalPop: 100, perUserCapPercent: 5, durationSec: 600 },
|
||||||
|
popPackSalesEnabled: false,
|
||||||
|
focusDailyPop: 40,
|
||||||
|
}, { merge: true });
|
||||||
|
await db().doc(`heroGiftProducts/${GIFT}`).set(
|
||||||
|
{ name: '베이직 드랍', buyerPop: 500, dropPop: 500, active: true });
|
||||||
|
});
|
||||||
|
afterEach(() => { setReceiptVerifier(new StoreReceiptVerifier()); });
|
||||||
|
afterAll(async () => { await clean(); await shutdownAdmin(); });
|
||||||
|
|
||||||
|
describe('Hero Gift — 구매자 기여 POP + 팬덤 Drop Pool (B-84)', () => {
|
||||||
|
it('Catalog 값대로 구매자에게 기여 POP, 소속 팬덤에 같은 규격의 Drop 을 연다', async () => {
|
||||||
|
claim = { productId: GIFT };
|
||||||
|
const result = await call(UID, { productId: GIFT });
|
||||||
|
|
||||||
|
expect(result.granted).toBe(500);
|
||||||
|
const wallet = await db().doc(`users/${UID}/wallet/main`).get();
|
||||||
|
expect(wallet.get('supportBalance')).toBe(500);
|
||||||
|
const drop = await db().doc(`fandoms/${FANDOM}/heroDrops/${result.dropId}`).get();
|
||||||
|
expect(drop.get('totalPop')).toBe(500);
|
||||||
|
expect(drop.get('remainingPop')).toBe(500);
|
||||||
|
expect(drop.get('perUserCap')).toBe(25);
|
||||||
|
expect(drop.get('heroNickname')).toBe('영웅팬');
|
||||||
|
const purchase = await db().doc(`users/${UID}/purchases/android_gpa.1`).get();
|
||||||
|
expect(purchase.get('state')).toBe('granted');
|
||||||
|
expect(purchase.get('kind')).toBe('hero_gift');
|
||||||
|
expect(purchase.get('itemName')).toBe('베이직 드랍');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('같은 구매를 다시 보내도 지급과 Drop 은 한 번이다', async () => {
|
||||||
|
claim = { productId: GIFT };
|
||||||
|
const first = await call(UID, { productId: GIFT });
|
||||||
|
const again = await call(UID, { productId: GIFT });
|
||||||
|
|
||||||
|
expect(again.duplicate).toBe(true);
|
||||||
|
expect(again.txId).toBe(first.txId);
|
||||||
|
const drops = await db().collection(`fandoms/${FANDOM}/heroDrops`).get();
|
||||||
|
expect(drops.size).toBe(1);
|
||||||
|
const wallet = await db().doc(`users/${UID}/wallet/main`).get();
|
||||||
|
expect(wallet.get('supportBalance')).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('구매자 POP 과 Drop Pool 이 다른 Catalog 값은 지급하지 않는다 (B-84 동일 수량)', async () => {
|
||||||
|
await db().doc(`heroGiftProducts/${GIFT}`).update({ buyerPop: 100 });
|
||||||
|
claim = { productId: GIFT };
|
||||||
|
expect(await codeOf(() => call(UID, { productId: GIFT }))).toBe('E-PAY-02');
|
||||||
|
expect((await db().doc(`users/${UID}/wallet/main`).get()).exists).toBe(false);
|
||||||
|
expect((await db().collection(`fandoms/${FANDOM}/heroDrops`).get()).size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('지급 전 실패 뒤 재시도는 첫 시도의 팬덤으로 Drop 을 연다', async () => {
|
||||||
|
claim = { productId: GIFT };
|
||||||
|
await db().doc(`users/${UID}/purchases/android_gpa.1`).set({ state: 'verified', fandomId: FANDOM });
|
||||||
|
await db().doc(`users/${UID}`).set({ nickname: '영웅팬', fandomId: 'moved-away' });
|
||||||
|
const result = await call(UID, { productId: GIFT });
|
||||||
|
expect((await db().doc(`fandoms/${FANDOM}/heroDrops/${result.dropId}`).get()).exists).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('소속 행성이 없으면 지급도 Drop 도 없이 E-PAY-01(재시도 불가)로 끊는다', async () => {
|
||||||
|
await db().doc(`users/${UID}`).set({ nickname: '영웅팬' });
|
||||||
|
claim = { productId: GIFT };
|
||||||
|
expect(await codeOf(() => call(UID, { productId: GIFT }))).toBe('E-PAY-01');
|
||||||
|
expect((await db().doc(`users/${UID}/wallet/main`).get()).exists).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('판매를 끈 상품은 지급하지 않는다', async () => {
|
||||||
|
await db().doc(`heroGiftProducts/${GIFT}`).update({ active: false });
|
||||||
|
claim = { productId: GIFT };
|
||||||
|
expect(await codeOf(() => call(UID, { productId: GIFT }))).toBe('E-PAY-01');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('영수증 재사용 · 재전송', () => {
|
||||||
|
it('다른 계정이 같은 거래를 보내면 E-PAY-01 이고 지급하지 않는다', async () => {
|
||||||
|
await db().doc('config/policy').set({ popPackSalesEnabled: true }, { merge: true });
|
||||||
|
await call(UID, {});
|
||||||
|
expect(await codeOf(() => call(OTHER, {}))).toBe('E-PAY-01');
|
||||||
|
expect((await db().doc(`users/${OTHER}/wallet/main`).get()).exists).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('환불 요청 뒤 복원 재전송이 상태를 「지급 완료」로 되돌리지 않는다', async () => {
|
||||||
|
await db().doc('config/policy').set({ popPackSalesEnabled: true }, { merge: true });
|
||||||
|
await call(UID, {});
|
||||||
|
const ref = db().doc(`users/${UID}/purchases/android_gpa.1`);
|
||||||
|
await ref.update({ state: 'refund-requested' });
|
||||||
|
const again = await call(UID, {});
|
||||||
|
expect(again.duplicate).toBe(true);
|
||||||
|
expect((await ref.get()).get('state')).toBe('refund-requested');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('표시용 가격은 저장하되 지급량은 상품 표를 따른다', async () => {
|
||||||
|
await db().doc('config/policy').set({ popPackSalesEnabled: true }, { merge: true });
|
||||||
|
const result = await call(UID, { priceAmount: 99, currency: 'KRW', title: '서포터 팩' });
|
||||||
|
expect(result.granted).toBe(5_500);
|
||||||
|
const purchase = await db().doc(`users/${UID}/purchases/android_gpa.1`).get();
|
||||||
|
expect(purchase.get('priceAmount')).toBe(99);
|
||||||
|
expect(purchase.get('itemName')).toBe('서포터 팩');
|
||||||
|
expect(purchase.get('method')).toBe('Google Play');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POP 직접 판매 금지 (B-02)', () => {
|
||||||
|
it('스위치가 꺼져 있으면(기본) POP 묶음 상품을 E-PAY-01 로 거부하고 지급하지 않는다', async () => {
|
||||||
|
expect(await codeOf(() => call(UID, {}))).toBe('E-PAY-01');
|
||||||
|
expect((await db().doc(`users/${UID}/wallet/main`).get()).exists).toBe(false);
|
||||||
|
expect((await db().doc(`users/${UID}/purchases/android_gpa.1`).get()).exists).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('popPackSalesEnabled 를 켜면 상품 표대로 지급한다', async () => {
|
||||||
|
await db().doc('config/policy').set({ popPackSalesEnabled: true }, { merge: true });
|
||||||
|
expect((await call(UID, {})).granted).toBe(5_500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Focus 구독 — 광고 제거 (B-04)', () => {
|
||||||
|
const future = () => Date.now() + 30 * 86_400_000;
|
||||||
|
|
||||||
|
it('만료가 미래면 구독을 켜고 만료 시각을 저장하며 POP 은 주지 않는다', async () => {
|
||||||
|
const expires = future();
|
||||||
|
claim = { productId: 'focus.monthly', consumable: false, expiresAtMillis: expires };
|
||||||
|
const result = await call(UID, { productId: 'focus.monthly', transactionId: 'gpa.1' });
|
||||||
|
expect(result.subscription).toBe('active');
|
||||||
|
const sub = await db().doc(`users/${UID}/subscription/current`).get();
|
||||||
|
expect(sub.get('adsRemoved')).toBe(true);
|
||||||
|
expect((sub.get('expiresAt') as Timestamp).toMillis()).toBe(expires);
|
||||||
|
expect((await db().doc(`users/${UID}/wallet/main`).get()).exists).toBe(false);
|
||||||
|
// iOS 영수증에서 이 거래를 고르도록 거래 ID 를 검증기에 넘긴다.
|
||||||
|
expect(lastHint).toEqual({ productId: 'focus.monthly', transactionId: 'gpa.1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('만료가 지났거나 없으면 활성으로 보지 않는다', async () => {
|
||||||
|
claim = { productId: 'focus.monthly', consumable: false, expiresAtMillis: Date.now() - 1000 };
|
||||||
|
expect((await call(UID, {})).subscription).toBe('expired');
|
||||||
|
const sub = await db().doc(`users/${UID}/subscription/current`).get();
|
||||||
|
expect(sub.get('adsRemoved')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Focus 일일 Claim — 일 40 기여 POP · 당일 · 이월 없음 (B-04)', () => {
|
||||||
|
const claimFocus = async () => await fnFocusDailyClaim.run(
|
||||||
|
{ data: {}, auth: { uid: UID, token: {} } } as never,
|
||||||
|
) as Record<string, unknown>;
|
||||||
|
const subscribe = (expiresAtMillis: number, state = 'active') =>
|
||||||
|
db().doc(`users/${UID}/subscription/current`).set({
|
||||||
|
state, adsRemoved: true, expiresAt: Timestamp.fromMillis(expiresAtMillis),
|
||||||
|
});
|
||||||
|
|
||||||
|
it('구독 중이면 하루 한 번 40 기여 POP 을 주고 같은 날 재요청은 첫 결과다', async () => {
|
||||||
|
await subscribe(Date.now() + 86_400_000);
|
||||||
|
const first = await claimFocus();
|
||||||
|
expect(first.granted).toBe(40);
|
||||||
|
const again = await claimFocus();
|
||||||
|
expect(again.duplicate).toBe(true);
|
||||||
|
expect(again.txId).toBe(first.txId);
|
||||||
|
const wallet = await db().doc(`users/${UID}/wallet/main`).get();
|
||||||
|
expect(wallet.get('supportBalance')).toBe(40);
|
||||||
|
const sub = await db().doc(`users/${UID}/subscription/current`).get();
|
||||||
|
expect(sub.get('focusClaimedDay')).toBe(first.day);
|
||||||
|
// C-31 — 구독 보너스는 성장 반영을 자동 적용하지 않는다.
|
||||||
|
expect((await db().collection(`users/${UID}/growthEvents`).get()).size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('해지했어도 만료 전이면 받는다', async () => {
|
||||||
|
await subscribe(Date.now() + 86_400_000, 'canceled');
|
||||||
|
expect((await claimFocus()).granted).toBe(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('구독이 없거나 만료됐으면 E-PAY-03 이고 지급하지 않는다', async () => {
|
||||||
|
expect(await codeOf(claimFocus)).toBe('E-PAY-03');
|
||||||
|
await subscribe(Date.now() - 1000);
|
||||||
|
expect(await codeOf(claimFocus)).toBe('E-PAY-03');
|
||||||
|
expect((await db().doc(`users/${UID}/wallet/main`).get()).exists).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('운영값 focusDailyPop 을 따른다', async () => {
|
||||||
|
await db().doc('config/policy').set({ focusDailyPop: 55 }, { merge: true });
|
||||||
|
await subscribe(Date.now() + 86_400_000);
|
||||||
|
expect((await claimFocus()).granted).toBe(55);
|
||||||
|
await db().doc('config/policy').set({ focusDailyPop: 40 }, { merge: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('검증 구현이 비어 있는 동안', () => {
|
||||||
|
it('콜러블도 지급 없이 E-PAY-02 로 끊는다', async () => {
|
||||||
|
setReceiptVerifier(new StoreReceiptVerifier());
|
||||||
|
expect(await codeOf(() => call(UID, {}))).toBe('E-PAY-02');
|
||||||
|
expect((await db().doc(`users/${UID}/wallet/main`).get()).exists).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { getFirestore } from 'firebase-admin/firestore';
|
||||||
|
|
||||||
|
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
|
||||||
|
import {
|
||||||
|
grantPop, spendPop, refundPop, reconcileUser,
|
||||||
|
} from '../src/ledger/ledger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POP 원장 검증 — 별첨 1 「조건부 적합 제외 항목 ② POP/FPI 데이터 정합성」.
|
||||||
|
*
|
||||||
|
* 이 파일이 통과하지 못하면 검수는 조건부 적합이 아니라 부적합이다. 그래서
|
||||||
|
* 「값이 맞는가」가 아니라 **「불변식이 깨지지 않는가」**를 본다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const UID = 'test-ledger-user';
|
||||||
|
|
||||||
|
beforeAll(() => { bootAdmin(); });
|
||||||
|
afterAll(async () => { await shutdownAdmin(); });
|
||||||
|
beforeEach(async () => { await wipeUser(UID); });
|
||||||
|
|
||||||
|
describe('지급', () => {
|
||||||
|
it('lot·원장·지갑을 한 번에 만든다', async () => {
|
||||||
|
const r = await grantPop({
|
||||||
|
uid: UID, popType: 'activity', amount: 100,
|
||||||
|
reasonCode: 'QUEST_ATTEND', idempotencyKey: 'attend:test:2026-09-01',
|
||||||
|
});
|
||||||
|
expect(r.balanceAfter).toBe(100);
|
||||||
|
|
||||||
|
const db = getFirestore();
|
||||||
|
const wallet = await db.doc(`users/${UID}/wallet/main`).get();
|
||||||
|
expect(wallet.get('activityBalance')).toBe(100);
|
||||||
|
|
||||||
|
const lots = await db.collection(`users/${UID}/popLots`).get();
|
||||||
|
expect(lots.size).toBe(1);
|
||||||
|
expect(lots.docs[0]!.get('remaining')).toBe(100);
|
||||||
|
expect(lots.docs[0]!.get('status')).toBe('active');
|
||||||
|
|
||||||
|
const tx = await db.doc(`users/${UID}/popTransactions/${r.txId}`).get();
|
||||||
|
expect(tx.get('amount')).toBe(100);
|
||||||
|
expect(tx.get('serverVerified')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('같은 멱등키로 두 번 불러도 한 번만 지급한다', async () => {
|
||||||
|
const key = 'attend:test:2026-09-02';
|
||||||
|
const first = await grantPop({
|
||||||
|
uid: UID, popType: 'activity', amount: 50,
|
||||||
|
reasonCode: 'QUEST_ATTEND', idempotencyKey: key,
|
||||||
|
});
|
||||||
|
const second = await grantPop({
|
||||||
|
uid: UID, popType: 'activity', amount: 50,
|
||||||
|
reasonCode: 'QUEST_ATTEND', idempotencyKey: key,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(second.txId).toBe(first.txId);
|
||||||
|
const db = getFirestore();
|
||||||
|
const wallet = await db.doc(`users/${UID}/wallet/main`).get();
|
||||||
|
expect(wallet.get('activityBalance')).toBe(50);
|
||||||
|
const txs = await db.collection(`users/${UID}/popTransactions`).get();
|
||||||
|
expect(txs.size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('활동 POP 과 지원 POP 의 잔액을 섞지 않는다', async () => {
|
||||||
|
await grantPop({
|
||||||
|
uid: UID, popType: 'activity', amount: 100,
|
||||||
|
reasonCode: 'QUEST_ATTEND', idempotencyKey: 'a:1',
|
||||||
|
});
|
||||||
|
await grantPop({
|
||||||
|
uid: UID, popType: 'support', amount: 300,
|
||||||
|
reasonCode: 'IAP_PURCHASE', idempotencyKey: 's:1',
|
||||||
|
});
|
||||||
|
const wallet = await getFirestore().doc(`users/${UID}/wallet/main`).get();
|
||||||
|
expect(wallet.get('activityBalance')).toBe(100);
|
||||||
|
expect(wallet.get('supportBalance')).toBe(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('보류 지급은 잔액이 아니라 확정 대기에 쌓인다', async () => {
|
||||||
|
await grantPop({
|
||||||
|
uid: UID, popType: 'activity', amount: 70,
|
||||||
|
reasonCode: 'TRANSLATION', idempotencyKey: 'trn:1', pending: true,
|
||||||
|
});
|
||||||
|
const db = getFirestore();
|
||||||
|
const wallet = await db.doc(`users/${UID}/wallet/main`).get();
|
||||||
|
expect(wallet.get('activityBalance')).toBe(0);
|
||||||
|
expect(wallet.get('pendingActivity')).toBe(70);
|
||||||
|
const lots = await db.collection(`users/${UID}/popLots`).get();
|
||||||
|
expect(lots.docs[0]!.get('status')).toBe('pending');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('차감', () => {
|
||||||
|
it('잔액이 모자라면 E-POP-01 로 끊는다', async () => {
|
||||||
|
await expect(spendPop({
|
||||||
|
uid: UID, popType: 'activity', amount: 10,
|
||||||
|
reasonCode: 'CAMPAIGN_FUND', idempotencyKey: 'fund:none',
|
||||||
|
})).rejects.toMatchObject({ details: { code: 'E-POP-01' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('원장에 음수로 남고 lot 이 줄어든다', async () => {
|
||||||
|
await grantPop({
|
||||||
|
uid: UID, popType: 'activity', amount: 100,
|
||||||
|
reasonCode: 'QUEST_ATTEND', idempotencyKey: 'g:1',
|
||||||
|
});
|
||||||
|
const r = await spendPop({
|
||||||
|
uid: UID, popType: 'activity', amount: 40,
|
||||||
|
reasonCode: 'CAMPAIGN_FUND', idempotencyKey: 'fund:1',
|
||||||
|
fandomId: 'fandom-a',
|
||||||
|
});
|
||||||
|
expect(r.balanceAfter).toBe(60);
|
||||||
|
|
||||||
|
const db = getFirestore();
|
||||||
|
const tx = await db.doc(`users/${UID}/popTransactions/${r.txId}`).get();
|
||||||
|
expect(tx.get('amount')).toBe(-40);
|
||||||
|
const lots = await db.collection(`users/${UID}/popLots`).get();
|
||||||
|
expect(lots.docs[0]!.get('remaining')).toBe(60);
|
||||||
|
|
||||||
|
// 기여 사유의 차감은 통계와 팬덤 멤버 기여도에 함께 반영된다.
|
||||||
|
const user = await db.doc(`users/${UID}`).get();
|
||||||
|
expect(user.get('totalContributedPop')).toBe(40);
|
||||||
|
// 기여 POP 문턱 등급은 폐기됐다(C-18). 후원 · Zap 은 1 POP 단위로 쪼개 반복할 수 있어
|
||||||
|
// EXP 를 주지 않는다(리뷰 B1) — EXP 는 Quest 인정 활동에서만 쌓인다.
|
||||||
|
expect(user.get('tier')).toBeUndefined();
|
||||||
|
expect(user.get('userExp')).toBeUndefined();
|
||||||
|
const member = await db.doc(`fandoms/fandom-a/members/${UID}`).get();
|
||||||
|
expect(member.get('contributedPop')).toBe(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lot 여러 개에 걸쳐 차감하고 소진된 lot 을 닫는다', async () => {
|
||||||
|
await grantPop({
|
||||||
|
uid: UID, popType: 'activity', amount: 30,
|
||||||
|
reasonCode: 'QUEST_ATTEND', idempotencyKey: 'g:a',
|
||||||
|
});
|
||||||
|
await grantPop({
|
||||||
|
uid: UID, popType: 'activity', amount: 30,
|
||||||
|
reasonCode: 'AD_REWARD', idempotencyKey: 'g:b',
|
||||||
|
});
|
||||||
|
const r = await spendPop({
|
||||||
|
uid: UID, popType: 'activity', amount: 45,
|
||||||
|
reasonCode: 'QNA_BOUNTY', idempotencyKey: 'bounty:1',
|
||||||
|
});
|
||||||
|
expect(r.balanceAfter).toBe(15);
|
||||||
|
expect(r.lotRefs).toHaveLength(2);
|
||||||
|
|
||||||
|
const lots = await getFirestore()
|
||||||
|
.collection(`users/${UID}/popLots`).orderBy('grantedAt').get();
|
||||||
|
const statuses = lots.docs.map((d) => d.get('status'));
|
||||||
|
expect(statuses).toContain('exhausted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('같은 멱등키의 재시도는 두 번 차감하지 않는다', async () => {
|
||||||
|
await grantPop({
|
||||||
|
uid: UID, popType: 'activity', amount: 100,
|
||||||
|
reasonCode: 'QUEST_ATTEND', idempotencyKey: 'g:2',
|
||||||
|
});
|
||||||
|
const key = 'fund:dup';
|
||||||
|
await spendPop({
|
||||||
|
uid: UID, popType: 'activity', amount: 25,
|
||||||
|
reasonCode: 'CAMPAIGN_FUND', idempotencyKey: key,
|
||||||
|
});
|
||||||
|
const again = await spendPop({
|
||||||
|
uid: UID, popType: 'activity', amount: 25,
|
||||||
|
reasonCode: 'CAMPAIGN_FUND', idempotencyKey: key,
|
||||||
|
});
|
||||||
|
expect(again.balanceAfter).toBe(75);
|
||||||
|
const wallet = await getFirestore().doc(`users/${UID}/wallet/main`).get();
|
||||||
|
expect(wallet.get('activityBalance')).toBe(75);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('환불', () => {
|
||||||
|
it('원 거래를 고치지 않고 반대 부호의 새 원장을 쓴다', async () => {
|
||||||
|
await grantPop({
|
||||||
|
uid: UID, popType: 'activity', amount: 100,
|
||||||
|
reasonCode: 'QUEST_ATTEND', idempotencyKey: 'g:3',
|
||||||
|
});
|
||||||
|
const spent = await spendPop({
|
||||||
|
uid: UID, popType: 'activity', amount: 60,
|
||||||
|
reasonCode: 'CAMPAIGN_FUND', idempotencyKey: 'fund:refundable',
|
||||||
|
});
|
||||||
|
const refunded = await refundPop({
|
||||||
|
uid: UID, originalTxId: spent.txId,
|
||||||
|
idempotencyKey: `refund:${spent.txId}`, reasonCode: 'CAMPAIGN_REFUND',
|
||||||
|
});
|
||||||
|
expect(refunded.balanceAfter).toBe(100);
|
||||||
|
|
||||||
|
const db = getFirestore();
|
||||||
|
const original = await db.doc(`users/${UID}/popTransactions/${spent.txId}`).get();
|
||||||
|
// 금액은 그대로 남아 있어야 한다 — append-only.
|
||||||
|
expect(original.get('amount')).toBe(-60);
|
||||||
|
expect(original.get('refundedBy')).toBe(refunded.txId);
|
||||||
|
const user = await db.doc(`users/${UID}`).get();
|
||||||
|
expect(user.get('totalContributedPop')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('같은 거래를 두 번 환불하지 않는다', async () => {
|
||||||
|
await grantPop({
|
||||||
|
uid: UID, popType: 'activity', amount: 100,
|
||||||
|
reasonCode: 'QUEST_ATTEND', idempotencyKey: 'g:4',
|
||||||
|
});
|
||||||
|
const spent = await spendPop({
|
||||||
|
uid: UID, popType: 'activity', amount: 60,
|
||||||
|
reasonCode: 'QNA_BOUNTY', idempotencyKey: 'bounty:2',
|
||||||
|
});
|
||||||
|
await refundPop({
|
||||||
|
uid: UID, originalTxId: spent.txId,
|
||||||
|
idempotencyKey: 'refund:once', reasonCode: 'QNA_REFUND',
|
||||||
|
});
|
||||||
|
await expect(refundPop({
|
||||||
|
uid: UID, originalTxId: spent.txId,
|
||||||
|
idempotencyKey: 'refund:twice', reasonCode: 'QNA_REFUND',
|
||||||
|
})).rejects.toMatchObject({ details: { code: 'E-POP-03' } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('대사 — 별첨 1 ② 3자 교차 대조', () => {
|
||||||
|
it('정상 거래 뒤에는 잔액·lot·원장이 모두 맞는다', async () => {
|
||||||
|
await grantPop({
|
||||||
|
uid: UID, popType: 'activity', amount: 100,
|
||||||
|
reasonCode: 'QUEST_ATTEND', idempotencyKey: 'g:5',
|
||||||
|
});
|
||||||
|
await spendPop({
|
||||||
|
uid: UID, popType: 'activity', amount: 30,
|
||||||
|
reasonCode: 'CAMPAIGN_FUND', idempotencyKey: 'fund:2',
|
||||||
|
});
|
||||||
|
const row = await reconcileUser(UID);
|
||||||
|
expect(row.ok).toBe(true);
|
||||||
|
const wallet = await getFirestore().doc(`users/${UID}/wallet/main`).get();
|
||||||
|
expect(wallet.get('reconcileStatus')).toBe('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('레벨과 Passport 위조를 서로 오염시키지 않고 따로 잡아낸다', async () => {
|
||||||
|
await grantPop({
|
||||||
|
uid: UID, popType: 'activity', amount: 5_000,
|
||||||
|
reasonCode: 'ADMIN_GRANT', idempotencyKey: 'g:rec-tier',
|
||||||
|
});
|
||||||
|
await spendPop({
|
||||||
|
uid: UID, popType: 'activity', amount: 3_000,
|
||||||
|
reasonCode: 'CAMPAIGN_FUND', idempotencyKey: 'fund:rec-tier',
|
||||||
|
});
|
||||||
|
const failedNames = async () => (await reconcileUser(UID)).checks
|
||||||
|
.filter((c) => c.expected !== c.actual).map((c) => c.name);
|
||||||
|
expect(await failedNames()).toEqual([]);
|
||||||
|
|
||||||
|
// 원장을 거치지 않은 레벨 위조.
|
||||||
|
await getFirestore().doc(`users/${UID}`).set({ userLevel: 50 }, { merge: true });
|
||||||
|
expect(await failedNames()).toEqual(['level-vs-exp']);
|
||||||
|
|
||||||
|
// 레벨을 되돌리고 EXP 를 Lv.30 이상으로 올렸는데 Passport 가 SILVER 로 남은 상태(강등 · 누락).
|
||||||
|
await getFirestore().doc(`users/${UID}`).set({
|
||||||
|
userExp: 500_000, userLevel: 31, passportTier: 'SILVER',
|
||||||
|
}, { merge: true });
|
||||||
|
expect(await failedNames()).toEqual(['passport-monotonic']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('지갑을 몰래 고치면 대사가 잡아낸다', async () => {
|
||||||
|
await grantPop({
|
||||||
|
uid: UID, popType: 'activity', amount: 100,
|
||||||
|
reasonCode: 'QUEST_ATTEND', idempotencyKey: 'g:6',
|
||||||
|
});
|
||||||
|
// 원장을 거치지 않은 잔액 변경 — 정확히 이것을 잡아내야 한다.
|
||||||
|
await getFirestore().doc(`users/${UID}/wallet/main`)
|
||||||
|
.set({ activityBalance: 999 }, { merge: true });
|
||||||
|
|
||||||
|
const row = await reconcileUser(UID);
|
||||||
|
expect(row.ok).toBe(false);
|
||||||
|
const failed = row.checks.filter((c) => c.expected !== c.actual);
|
||||||
|
expect(failed.length).toBeGreaterThan(0);
|
||||||
|
const wallet = await getFirestore().doc(`users/${UID}/wallet/main`).get();
|
||||||
|
// 자동으로 고치지 않는다 — 감지하고 표시만 한다(§3.6).
|
||||||
|
expect(wallet.get('activityBalance')).toBe(999);
|
||||||
|
expect(wallet.get('reconcileStatus')).toBe('mismatch');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('User EXP · Level · Passport 갱신 (C-18 · B-65)', () => {
|
||||||
|
it('기여 차감은 EXP · 등급을 만들지 않는다 · 기여 POP 문턱 승급 없음', async () => {
|
||||||
|
await grantPop({
|
||||||
|
uid: UID, popType: 'activity', amount: 5_000,
|
||||||
|
reasonCode: 'ADMIN_GRANT', idempotencyKey: 'g:tier',
|
||||||
|
});
|
||||||
|
await spendPop({
|
||||||
|
uid: UID, popType: 'activity', amount: 3_000,
|
||||||
|
reasonCode: 'CAMPAIGN_FUND', idempotencyKey: 'fund:tier',
|
||||||
|
});
|
||||||
|
const user = await getFirestore().doc(`users/${UID}`).get();
|
||||||
|
expect(user.get('totalContributedPop')).toBe(3_000);
|
||||||
|
// 3,000 POP 후원도 EXP 를 만들지 않는다(C-18 기여 POP 치환 금지 · 리뷰 B1).
|
||||||
|
expect(user.get('userExp')).toBeUndefined();
|
||||||
|
expect(user.get('passportTier')).toBeUndefined();
|
||||||
|
expect(user.get('tier')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('환불은 기여 통계만 되돌리고 EXP · Passport 는 내리지 않는다', async () => {
|
||||||
|
await getFirestore().doc(`users/${UID}`).set({ passportTier: 'GOLD' }, { merge: true });
|
||||||
|
await grantPop({
|
||||||
|
uid: UID, popType: 'activity', amount: 5_000,
|
||||||
|
reasonCode: 'ADMIN_GRANT', idempotencyKey: 'g:tier2',
|
||||||
|
});
|
||||||
|
const spent = await spendPop({
|
||||||
|
uid: UID, popType: 'activity', amount: 3_000,
|
||||||
|
reasonCode: 'CAMPAIGN_FUND', idempotencyKey: 'fund:tier2',
|
||||||
|
});
|
||||||
|
await refundPop({
|
||||||
|
uid: UID, originalTxId: spent.txId,
|
||||||
|
idempotencyKey: 'refund:tier2', reasonCode: 'CAMPAIGN_REFUND',
|
||||||
|
});
|
||||||
|
const user = await getFirestore().doc(`users/${UID}`).get();
|
||||||
|
expect(user.get('totalContributedPop')).toBe(0);
|
||||||
|
expect(user.get('userExp')).toBeUndefined();
|
||||||
|
// 레벨이 낮아도 이미 GOLD 였으므로 강등하지 않는다.
|
||||||
|
expect(user.get('passportTier')).toBe('GOLD');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('기여가 아닌 차감은 EXP 를 건드리지 않는다', async () => {
|
||||||
|
await grantPop({
|
||||||
|
uid: UID, popType: 'support', amount: 1_000,
|
||||||
|
reasonCode: 'IAP_PURCHASE', idempotencyKey: 'g:tier3',
|
||||||
|
});
|
||||||
|
await spendPop({
|
||||||
|
uid: UID, popType: 'support', amount: 500,
|
||||||
|
reasonCode: 'HERO_DROP_TRIGGER', idempotencyKey: 'spend:tier3',
|
||||||
|
});
|
||||||
|
const user = await getFirestore().doc(`users/${UID}`).get();
|
||||||
|
expect(user.get('userExp')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { getFirestore } from 'firebase-admin/firestore';
|
||||||
|
|
||||||
|
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
|
||||||
|
import {
|
||||||
|
grantPop, reconcileUser, refundPop, refundSplit, spendSplit, type PopType,
|
||||||
|
} from '../src/ledger/ledger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 겸용 사용처 분할 차감 — 정책 원장 B-06 · 설계서 §3.2.
|
||||||
|
* 일반(activity) 우선, 부족분만 기여(support). 두 재화면 원장 2건 · 한 트랜잭션 · 멱등키 하나.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const UID = 'test-ledger-split';
|
||||||
|
const db = () => getFirestore();
|
||||||
|
|
||||||
|
beforeAll(() => { bootAdmin(); });
|
||||||
|
afterAll(async () => { await shutdownAdmin(); });
|
||||||
|
beforeEach(async () => {
|
||||||
|
await wipeUser(UID);
|
||||||
|
await db().recursiveDelete(db().doc('fandoms/f-split'));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function seed(general: number, contribution: number) {
|
||||||
|
if (general > 0) {
|
||||||
|
await grantPop({ uid: UID, popType: 'activity', amount: general, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'seed:g' });
|
||||||
|
}
|
||||||
|
if (contribution > 0) {
|
||||||
|
await grantPop({ uid: UID, popType: 'support', amount: contribution, reasonCode: 'IAP_PURCHASE', idempotencyKey: 'seed:c' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wallet() {
|
||||||
|
const w = await db().doc(`users/${UID}/wallet/main`).get();
|
||||||
|
return { general: w.get('activityBalance') as number, contribution: w.get('supportBalance') as number };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function spendRows() {
|
||||||
|
const snap = await db().collection(`users/${UID}/popTransactions`).where('type', '==', 'spend').get();
|
||||||
|
return snap.docs.map((d) => ({ popType: d.get('popType') as PopType, amount: d.get('amount') as number }))
|
||||||
|
.sort((a, b) => a.popType.localeCompare(b.popType));
|
||||||
|
}
|
||||||
|
|
||||||
|
const spend = (amount: number, key = 'fee:1') =>
|
||||||
|
spendSplit({ uid: UID, amount, reasonCode: 'CAMPAIGN_FEE', idempotencyKey: key });
|
||||||
|
|
||||||
|
describe('spendSplit', () => {
|
||||||
|
it('일반 잔액이 딱 맞으면 일반 1건만', async () => {
|
||||||
|
await seed(100, 50);
|
||||||
|
const parts = await spend(100);
|
||||||
|
expect(parts.map((p) => [p.popType, p.amount])).toEqual([['activity', 100]]);
|
||||||
|
expect(await wallet()).toEqual({ general: 0, contribution: 50 });
|
||||||
|
expect(await spendRows()).toEqual([{ popType: 'activity', amount: -100 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('일반만 있으면 일반에서', async () => {
|
||||||
|
await seed(300, 0);
|
||||||
|
await spend(120);
|
||||||
|
expect((await wallet()).general).toBe(180);
|
||||||
|
expect(await spendRows()).toEqual([{ popType: 'activity', amount: -120 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('기여만 있으면 기여에서', async () => {
|
||||||
|
await seed(0, 300);
|
||||||
|
const parts = await spend(120);
|
||||||
|
expect(parts.map((p) => p.popType)).toEqual(['support']);
|
||||||
|
expect((await wallet()).contribution).toBe(180);
|
||||||
|
expect(await spendRows()).toEqual([{ popType: 'support', amount: -120 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('섞이면 일반을 다 쓰고 부족분만 기여 — 원장 2건 · 같은 멱등키 · lotOrder 규범', async () => {
|
||||||
|
await seed(30, 200);
|
||||||
|
const parts = await spend(100);
|
||||||
|
expect(parts.map((p) => [p.popType, p.amount, p.balanceAfter]))
|
||||||
|
.toEqual([['activity', 30, 0], ['support', 70, 130]]);
|
||||||
|
expect(await wallet()).toEqual({ general: 0, contribution: 130 });
|
||||||
|
expect(await spendRows()).toEqual([
|
||||||
|
{ popType: 'activity', amount: -30 }, { popType: 'support', amount: -70 },
|
||||||
|
]);
|
||||||
|
const rows = await db().collection(`users/${UID}/popTransactions`).where('type', '==', 'spend').get();
|
||||||
|
for (const r of rows.docs) {
|
||||||
|
expect(r.get('idempotencyKey')).toBe('fee:1');
|
||||||
|
expect(r.get('lotOrder')).toBe('general-first-fifo');
|
||||||
|
}
|
||||||
|
expect((await reconcileUser(UID)).ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('합계가 모자라면 E-POP-01 이고 원장 행이 없다', async () => {
|
||||||
|
await seed(30, 20);
|
||||||
|
await expect(spend(100)).rejects.toMatchObject({ details: { code: 'E-POP-01' } });
|
||||||
|
expect(await spendRows()).toEqual([]);
|
||||||
|
expect(await wallet()).toEqual({ general: 30, contribution: 20 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('같은 멱등키 재시도는 어느 행도 두 번 쓰지 않는다(동시 포함)', async () => {
|
||||||
|
await seed(30, 200);
|
||||||
|
const first = await spend(100);
|
||||||
|
const [again, ...racing] = await Promise.all([spend(100), spend(100), spend(100)]);
|
||||||
|
expect(again!.map((p) => p.txId).sort()).toEqual(first.map((p) => p.txId).sort());
|
||||||
|
for (const r of racing) expect(r.length).toBe(2);
|
||||||
|
expect(await spendRows()).toHaveLength(2);
|
||||||
|
expect(await wallet()).toEqual({ general: 0, contribution: 130 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('기여 POP 으로 낸 예치 · 등록비는 성장 · FPI 원천이 아니다(C-30)', async () => {
|
||||||
|
await db().doc(`users/${UID}`).set({ fandomId: 'f-split', primaryFandomId: 'f-split' });
|
||||||
|
await seed(0, 500);
|
||||||
|
await spend(100, 'fee:growth');
|
||||||
|
await spendSplit({ uid: UID, amount: 100, reasonCode: 'QNA_BOUNTY', idempotencyKey: 'bounty:growth' });
|
||||||
|
expect((await db().collection(`users/${UID}/growthEvents`).get()).size).toBe(0);
|
||||||
|
expect((await db().collection('fandoms/f-split/fpiEvents').get()).size).toBe(0);
|
||||||
|
expect((await db().doc('fandoms/f-split').get()).get('growthPop')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('lot 부족', () => {
|
||||||
|
it('기여 몫의 lot 이 모자라면 E-POP-02 · 행 0건 · 지갑은 트랜잭션 밖에서 mismatch 표시', async () => {
|
||||||
|
await seed(30, 200);
|
||||||
|
const lot = (await db().collection(`users/${UID}/popLots`).where('popType', '==', 'support').get()).docs[0]!;
|
||||||
|
await lot.ref.update({ remaining: 10 });
|
||||||
|
await expect(spend(100)).rejects.toMatchObject({ details: { code: 'E-POP-02' } });
|
||||||
|
expect(await spendRows()).toEqual([]);
|
||||||
|
const w = await db().doc(`users/${UID}/wallet/main`).get();
|
||||||
|
expect(w.get('reconcileStatus')).toBe('mismatch');
|
||||||
|
expect(w.get('activityBalance')).toBe(30);
|
||||||
|
const general = (await db().collection(`users/${UID}/popLots`).where('popType', '==', 'activity').get()).docs[0]!;
|
||||||
|
expect(general.get('remaining')).toBe(30);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('refundSplit', () => {
|
||||||
|
it('섞인 차감을 행마다 원래 타입으로 돌리고 재시도는 한 번뿐이다', async () => {
|
||||||
|
await seed(30, 200);
|
||||||
|
const parts = await spend(100);
|
||||||
|
const refund = () => refundSplit({
|
||||||
|
uid: UID, originalTxIds: parts.map((p) => p.txId),
|
||||||
|
idempotencyKey: 'feeRefund:1', reasonCode: 'CAMPAIGN_REFUND',
|
||||||
|
});
|
||||||
|
const first = await refund();
|
||||||
|
const again = await refund();
|
||||||
|
expect(again.map((r) => r.txId).sort()).toEqual(first.map((r) => r.txId).sort());
|
||||||
|
|
||||||
|
expect(await wallet()).toEqual({ general: 30, contribution: 200 });
|
||||||
|
const refunds = await db().collection(`users/${UID}/popTransactions`).where('type', '==', 'refund').get();
|
||||||
|
expect(refunds.docs.map((d) => [d.get('popType'), d.get('amount')]).sort())
|
||||||
|
.toEqual([['activity', 30], ['support', 70]]);
|
||||||
|
for (const p of parts) {
|
||||||
|
expect((await db().doc(`users/${UID}/popTransactions/${p.txId}`).get()).get('refundedBy')).toBeTruthy();
|
||||||
|
}
|
||||||
|
expect((await reconcileUser(UID)).ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('이미 다른 경로로 환불된 행은 그 환불을 쓰고, 같은 원거래 ID 는 한 번만 센다', async () => {
|
||||||
|
await seed(30, 200);
|
||||||
|
const parts = await spend(100);
|
||||||
|
const [general, contribution] = parts.map((p) => p.txId);
|
||||||
|
const earlier = await refundPop({
|
||||||
|
uid: UID, originalTxId: contribution!, idempotencyKey: 'other-path', reasonCode: 'CAMPAIGN_REFUND',
|
||||||
|
});
|
||||||
|
const results = await refundSplit({
|
||||||
|
uid: UID, originalTxIds: [general!, contribution!, general!],
|
||||||
|
idempotencyKey: 'feeRefund:reuse', reasonCode: 'CAMPAIGN_FEE_REFUND',
|
||||||
|
});
|
||||||
|
expect(results).toHaveLength(2);
|
||||||
|
expect(results[1]!.txId).toBe(earlier.txId);
|
||||||
|
expect(await wallet()).toEqual({ general: 30, contribution: 200 });
|
||||||
|
const refunds = await db().collection(`users/${UID}/popTransactions`).where('type', '==', 'refund').get();
|
||||||
|
expect(refunds.size).toBe(2);
|
||||||
|
expect((await reconcileUser(UID)).ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('QNA_BOUNTY 분할 예치 → 환불 → 대사가 맞는다(누적 기여도 0 으로 돌아온다)', async () => {
|
||||||
|
await seed(30, 200);
|
||||||
|
const parts = await spendSplit({ uid: UID, amount: 100, reasonCode: 'QNA_BOUNTY', idempotencyKey: 'bounty:mixed' });
|
||||||
|
expect((await db().doc(`users/${UID}`).get()).get('totalContributedPop')).toBe(100);
|
||||||
|
expect((await reconcileUser(UID)).ok).toBe(true);
|
||||||
|
await refundSplit({
|
||||||
|
uid: UID, originalTxIds: parts.map((p) => p.txId), idempotencyKey: 'qnarefund:mixed', reasonCode: 'QNA_REFUND',
|
||||||
|
});
|
||||||
|
expect((await db().doc(`users/${UID}`).get()).get('totalContributedPop')).toBe(0);
|
||||||
|
expect(await wallet()).toEqual({ general: 30, contribution: 200 });
|
||||||
|
expect((await reconcileUser(UID)).ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('원거래 하나라도 없으면 아무 행도 쓰지 않는다', async () => {
|
||||||
|
await seed(30, 200);
|
||||||
|
const parts = await spend(100);
|
||||||
|
await expect(refundSplit({
|
||||||
|
uid: UID, originalTxIds: [parts[0]!.txId, 'missing'],
|
||||||
|
idempotencyKey: 'feeRefund:bad', reasonCode: 'CAMPAIGN_REFUND',
|
||||||
|
})).rejects.toMatchObject({ details: { code: 'E-POP-02' } });
|
||||||
|
expect((await db().collection(`users/${UID}/popTransactions`).where('type', '==', 'refund').get()).size).toBe(0);
|
||||||
|
expect(await wallet()).toEqual({ general: 0, contribution: 130 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { getFirestore } from 'firebase-admin/firestore';
|
||||||
|
|
||||||
|
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
|
||||||
|
import { grantPop, spendPop } from '../src/ledger/ledger.js';
|
||||||
|
import {
|
||||||
|
popForProduct, PRODUCT_POP, SUBSCRIPTION_PRODUCTS,
|
||||||
|
type Platform, type ReceiptClaim, type ReceiptVerifier,
|
||||||
|
} from '../src/payment/receipt.js';
|
||||||
|
import { StoreReceiptVerifier } from '../src/payment/receipt.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 결제 검증 — 별첨 1 ⑥ 「영수증 서버검증 통과(위조 차단)」.
|
||||||
|
*
|
||||||
|
* **스토어 왕복은 여기서 재지 않는다.** 자격증명이 스토어 계정에 묶여 있어
|
||||||
|
* 샌드박스 실행은 계정이 열린 뒤다. 대신 **금액 결정 · 멱등 · 환불 회수**를
|
||||||
|
* 지금 검증한다 — 계정이 열려도 이 세 가지가 틀리면 검수를 통과하지 못한다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const UID = 'test-pay-user';
|
||||||
|
|
||||||
|
class FakeVerifier implements ReceiptVerifier {
|
||||||
|
constructor(private readonly claim: Partial<ReceiptClaim> = {}) {}
|
||||||
|
|
||||||
|
async verify(platform: Platform, _receipt: string): Promise<ReceiptClaim> {
|
||||||
|
return {
|
||||||
|
platform,
|
||||||
|
transactionId: 'tx-1',
|
||||||
|
productId: 'pop.support.5500',
|
||||||
|
consumable: true,
|
||||||
|
purchasedAtMillis: 1_788_000_000_000,
|
||||||
|
revoked: false,
|
||||||
|
...this.claim,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => { bootAdmin(); });
|
||||||
|
afterAll(async () => { await shutdownAdmin(); });
|
||||||
|
beforeEach(async () => { await wipeUser(UID); });
|
||||||
|
|
||||||
|
describe('상품 표 — 금액은 서버가 정한다', () => {
|
||||||
|
it('등록된 상품만 지급 수량을 낸다', () => {
|
||||||
|
expect(popForProduct('pop.support.5500')).toBe(5_500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('표에 없는 상품 ID 는 E-PAY-01 로 끊는다', () => {
|
||||||
|
// 위조이거나 스토어 상품 등록과 코드가 어긋난 상태다. 둘 다 지급하면 안 된다.
|
||||||
|
expect(() => popForProduct('pop.support.999999'))
|
||||||
|
.toThrowError(expect.objectContaining({ details: { code: 'E-PAY-01' } }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('구독 상품은 POP 표에 없다', () => {
|
||||||
|
for (const product of SUBSCRIPTION_PRODUCTS) {
|
||||||
|
expect(PRODUCT_POP[product]).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('모든 상품 수량이 양의 정수다', () => {
|
||||||
|
for (const amount of Object.values(PRODUCT_POP)) {
|
||||||
|
expect(Number.isInteger(amount)).toBe(true);
|
||||||
|
expect(amount).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('검증 구현이 비어 있는 동안', () => {
|
||||||
|
it('자격증명 없이 부르면 지급하지 않고 E-PAY-02 로 끊는다', async () => {
|
||||||
|
// 「나중에 채우자」로 비워 두면 검증 없는 지급 경로가 열린 채 배포된다.
|
||||||
|
await expect(new StoreReceiptVerifier().verify('ios', 'receipt'))
|
||||||
|
.rejects.toMatchObject({ details: { code: 'E-PAY-02' } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('지급 — 스토어 거래 ID 가 멱등키다', () => {
|
||||||
|
it('같은 거래로 두 번 지급하지 않는다', async () => {
|
||||||
|
const claim = await new FakeVerifier().verify('ios', 'r');
|
||||||
|
const key = `iap:${claim.platform}:${claim.transactionId}`;
|
||||||
|
|
||||||
|
const first = await grantPop({
|
||||||
|
uid: UID, popType: 'support', amount: popForProduct(claim.productId),
|
||||||
|
reasonCode: 'IAP_PURCHASE', idempotencyKey: key,
|
||||||
|
});
|
||||||
|
const second = await grantPop({
|
||||||
|
uid: UID, popType: 'support', amount: popForProduct(claim.productId),
|
||||||
|
reasonCode: 'IAP_PURCHASE', idempotencyKey: key,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(second.txId).toBe(first.txId);
|
||||||
|
const wallet = await getFirestore().doc(`users/${UID}/wallet/main`).get();
|
||||||
|
expect(wallet.get('supportBalance')).toBe(5_500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('플랫폼이 다르면 다른 거래로 본다', async () => {
|
||||||
|
const ios = await new FakeVerifier().verify('ios', 'r');
|
||||||
|
const android = await new FakeVerifier().verify('android', 'r');
|
||||||
|
expect(`iap:${ios.platform}:${ios.transactionId}`)
|
||||||
|
.not.toBe(`iap:${android.platform}:${android.transactionId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('지원 POP 으로만 들어가고 활동 POP 은 그대로다', async () => {
|
||||||
|
await grantPop({
|
||||||
|
uid: UID, popType: 'support', amount: 5_500,
|
||||||
|
reasonCode: 'IAP_PURCHASE', idempotencyKey: 'iap:ios:tx-a',
|
||||||
|
});
|
||||||
|
const wallet = await getFirestore().doc(`users/${UID}/wallet/main`).get();
|
||||||
|
expect(wallet.get('supportBalance')).toBe(5_500);
|
||||||
|
expect(wallet.get('activityBalance')).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('환불 회수 — 별첨 1 ⑥ 예외 처리', () => {
|
||||||
|
it('잔액이 남아 있으면 회수한다', async () => {
|
||||||
|
await grantPop({
|
||||||
|
uid: UID, popType: 'support', amount: 5_500,
|
||||||
|
reasonCode: 'IAP_PURCHASE', idempotencyKey: 'iap:ios:tx-b',
|
||||||
|
});
|
||||||
|
const clawed = await spendPop({
|
||||||
|
uid: UID, popType: 'support', amount: 5_500,
|
||||||
|
reasonCode: 'ADMIN_CLAWBACK', idempotencyKey: 'clawback:ios:tx-b',
|
||||||
|
});
|
||||||
|
expect(clawed.balanceAfter).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('이미 써 버려 잔액이 모자라면 음수 잔액을 만들지 않는다', async () => {
|
||||||
|
await grantPop({
|
||||||
|
uid: UID, popType: 'support', amount: 5_500,
|
||||||
|
reasonCode: 'IAP_PURCHASE', idempotencyKey: 'iap:ios:tx-c',
|
||||||
|
});
|
||||||
|
await spendPop({
|
||||||
|
uid: UID, popType: 'support', amount: 5_000,
|
||||||
|
reasonCode: 'CAMPAIGN_FUND', idempotencyKey: 'fund:pay-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 사용자는 쓴 적 없는 마이너스를 보면 안 되고, 원장 합과 잔액이 어긋나서도 안 된다.
|
||||||
|
await expect(spendPop({
|
||||||
|
uid: UID, popType: 'support', amount: 5_500,
|
||||||
|
reasonCode: 'ADMIN_CLAWBACK', idempotencyKey: 'clawback:ios:tx-c',
|
||||||
|
})).rejects.toMatchObject({ details: { code: 'E-POP-01' } });
|
||||||
|
|
||||||
|
const wallet = await getFirestore().doc(`users/${UID}/wallet/main`).get();
|
||||||
|
expect(wallet.get('supportBalance')).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('회수도 멱등이다 — 스토어 알림은 중복 도착한다', async () => {
|
||||||
|
await grantPop({
|
||||||
|
uid: UID, popType: 'support', amount: 5_500,
|
||||||
|
reasonCode: 'IAP_PURCHASE', idempotencyKey: 'iap:ios:tx-d',
|
||||||
|
});
|
||||||
|
const key = 'clawback:ios:tx-d';
|
||||||
|
await spendPop({
|
||||||
|
uid: UID, popType: 'support', amount: 5_500,
|
||||||
|
reasonCode: 'ADMIN_CLAWBACK', idempotencyKey: key,
|
||||||
|
});
|
||||||
|
const again = await spendPop({
|
||||||
|
uid: UID, popType: 'support', amount: 5_500,
|
||||||
|
reasonCode: 'ADMIN_CLAWBACK', idempotencyKey: key,
|
||||||
|
});
|
||||||
|
expect(again.balanceAfter).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('회수는 기여 통계를 건드리지 않는다', async () => {
|
||||||
|
await grantPop({
|
||||||
|
uid: UID, popType: 'support', amount: 5_500,
|
||||||
|
reasonCode: 'IAP_PURCHASE', idempotencyKey: 'iap:ios:tx-e',
|
||||||
|
});
|
||||||
|
await spendPop({
|
||||||
|
uid: UID, popType: 'support', amount: 5_500,
|
||||||
|
reasonCode: 'ADMIN_CLAWBACK', idempotencyKey: 'clawback:ios:tx-e',
|
||||||
|
});
|
||||||
|
const user = await getFirestore().doc(`users/${UID}`).get();
|
||||||
|
// 결제 회수는 팬덤 기여가 아니다.
|
||||||
|
expect(user.get('totalContributedPop')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { FieldValue, getFirestore, Timestamp } from 'firebase-admin/firestore';
|
||||||
|
|
||||||
|
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
|
||||||
|
import {
|
||||||
|
fnFandomJoin, fnFandomLeave, fnNebulaCreate, fnPlanetCreate, fnPrimaryFandomSet, isValidNebulaName,
|
||||||
|
} from '../src/account/account.js';
|
||||||
|
|
||||||
|
/** 성운 생성 → 가입 · 대표 팬덤 → 행성 탄생 — 원장 B-08 · B-09 · B-10 · C-15 · C-50. */
|
||||||
|
const UID = 'test-planet-create';
|
||||||
|
const OTHER = 'test-planet-create-other';
|
||||||
|
const db = () => getFirestore();
|
||||||
|
|
||||||
|
/** 이 파일이 만든 것만 지운다 — 같은 에뮬레이터를 쓰는 다른 테스트의 문서를 건드리지 않는다. */
|
||||||
|
const created = { fandoms: new Set<string>(), names: new Set<string>() };
|
||||||
|
|
||||||
|
async function call(fn: { run(request: never): unknown }, data: Record<string, unknown>, uid = UID) {
|
||||||
|
const out = (await fn.run({ data, auth: { uid, token: {} } } as unknown as never)) as Record<string, unknown>;
|
||||||
|
if (fn === fnNebulaCreate) {
|
||||||
|
created.fandoms.add(out.fandomId as string);
|
||||||
|
created.names.add(String(data.name).toLowerCase());
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function codeOf(run: () => Promise<unknown>): Promise<string> {
|
||||||
|
try {
|
||||||
|
await run();
|
||||||
|
} catch (error) {
|
||||||
|
return (error as { details?: { code?: string } }).details?.code ?? (error as { code?: string }).code ?? '?';
|
||||||
|
}
|
||||||
|
throw new Error('실패해야 하는 호출이 성공했습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = async (uid = UID) => (await db().doc(`users/${uid}`).get()).data() ?? {};
|
||||||
|
const fandom = async (id: string) => (await db().doc(`fandoms/${id}`).get()).data() ?? {};
|
||||||
|
|
||||||
|
beforeAll(() => { bootAdmin(); });
|
||||||
|
afterAll(async () => { await shutdownAdmin(); });
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const uid of [UID, OTHER]) {
|
||||||
|
await wipeUser(uid);
|
||||||
|
await db().recursiveDelete(db().collection(`users/${uid}/nebulaCreates`));
|
||||||
|
await db().recursiveDelete(db().collection(`users/${uid}/memberships`));
|
||||||
|
await db().doc(`users/${uid}`).delete();
|
||||||
|
}
|
||||||
|
for (const id of created.fandoms) await db().recursiveDelete(db().doc(`fandoms/${id}`));
|
||||||
|
for (const name of created.names) await db().doc(`fandomNames/${name}`).delete();
|
||||||
|
created.fandoms.clear();
|
||||||
|
created.names.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('성운 생성', () => {
|
||||||
|
it('Stage0 공용 성운을 만들고 만든 사람이 창립 멤버 #001 · 대표 팬덤이 된다', async () => {
|
||||||
|
const { fandomId, primaryChanged } = await call(fnNebulaCreate, { name: 'BTS RM' });
|
||||||
|
const f = await fandom(fandomId as string);
|
||||||
|
expect(f).toMatchObject({ name: 'BTS RM', stage: 0, founderUid: UID, memberCount: 1, growthPop: 0 });
|
||||||
|
expect(f.nebulaDeadline.toMillis() - Date.now()).toBeGreaterThan(6.9 * 86_400_000);
|
||||||
|
expect((await db().doc(`fandoms/${fandomId}/members/${UID}`).get()).get('memberNo')).toBe(1);
|
||||||
|
expect((await db().doc(`users/${UID}/memberships/${fandomId}`).get()).exists).toBe(true);
|
||||||
|
expect(primaryChanged).toBe(true);
|
||||||
|
expect(await user()).toMatchObject({ primaryFandomId: fandomId, fandomId });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('대표 변경 30일 대기 중이면 성운 · 멤버십만 만들고 대표는 그대로 둔다(C-15)', async () => {
|
||||||
|
await db().doc(`users/${UID}`).set({
|
||||||
|
primaryFandomId: 'old', fandomId: 'old', primaryChangedAt: Timestamp.fromMillis(Date.now() - 86_400_000),
|
||||||
|
});
|
||||||
|
const { fandomId, primaryChanged } = await call(fnNebulaCreate, { name: 'Cooldown' });
|
||||||
|
expect(primaryChanged).toBe(false);
|
||||||
|
expect(await user()).toMatchObject({ primaryFandomId: 'old', fandomId: 'old' });
|
||||||
|
expect((await db().doc(`fandoms/${fandomId}/members/${UID}`).get()).exists).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('같은 이름(대소문자 무시)은 E-FDM-01 · 하루 두 번째 생성은 E-FDM-02', async () => {
|
||||||
|
await call(fnNebulaCreate, { name: 'Aurora' });
|
||||||
|
expect(await codeOf(() => call(fnNebulaCreate, { name: 'aurora' }, OTHER))).toBe('E-FDM-01');
|
||||||
|
expect(await codeOf(() => call(fnNebulaCreate, { name: 'Borealis' }))).toBe('E-FDM-02');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('이름 규칙 — 2~20자 · 한글/영문/숫자/공백 · 연속·앞뒤 공백 불가', () => {
|
||||||
|
expect(isValidNebulaName('방탄 소년단1')).toBe(true);
|
||||||
|
for (const bad of ['A', ' AB', 'AB ', 'A B', 'AB!', 'x'.repeat(21)]) {
|
||||||
|
expect(isValidNebulaName(bad)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('가입 · 탈퇴 · 대표 팬덤', () => {
|
||||||
|
it('두 번 가입해도 한 번만 센다 · 탈퇴도 한 번만 내린다 · 첫 가입 팬덤이 대표가 된다', async () => {
|
||||||
|
const { fandomId } = await call(fnNebulaCreate, { name: 'Joinable' });
|
||||||
|
const id = fandomId as string;
|
||||||
|
expect((await call(fnFandomJoin, { fandomId: id }, OTHER)).joined).toBe(true);
|
||||||
|
expect((await call(fnFandomJoin, { fandomId: id }, OTHER)).joined).toBe(false);
|
||||||
|
expect(await fandom(id)).toMatchObject({ memberCount: 2, memberSeq: 2 });
|
||||||
|
expect((await db().doc(`fandoms/${id}/members/${OTHER}`).get()).get('memberNo')).toBe(2);
|
||||||
|
expect(await user(OTHER)).toMatchObject({ primaryFandomId: id });
|
||||||
|
|
||||||
|
expect((await call(fnFandomLeave, { fandomId: id }, OTHER)).left).toBe(true);
|
||||||
|
expect((await call(fnFandomLeave, { fandomId: id }, OTHER)).left).toBe(false);
|
||||||
|
expect((await fandom(id)).memberCount).toBe(1);
|
||||||
|
expect(await user(OTHER)).toMatchObject({ primaryFandomId: null, fandomId: '' });
|
||||||
|
expect((await db().doc(`users/${OTHER}/memberships/${id}`).get()).exists).toBe(false);
|
||||||
|
// LIVE 집계용 가입 목록 비정규화 — 가입 · 탈퇴 트랜잭션이 함께 유지한다.
|
||||||
|
expect((await user(OTHER)).fandomIds).toEqual([]);
|
||||||
|
expect((await user()).fandomIds).toEqual([id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('없는 팬덤 가입 · 가입하지 않은 팬덤을 대표로 지정은 거부한다', async () => {
|
||||||
|
expect(await codeOf(() => call(fnFandomJoin, { fandomId: 'missing-fandom' }))).toBe('E-PERM-01');
|
||||||
|
const { fandomId } = await call(fnNebulaCreate, { name: 'NotMine' }, OTHER);
|
||||||
|
expect(await codeOf(() => call(fnPrimaryFandomSet, { fandomId }))).toBe('E-PERM-01');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('대표 변경은 무료 · 30일 안 재변경은 E-FDM-04 · 대기가 지나면 허용', async () => {
|
||||||
|
const a = (await call(fnNebulaCreate, { name: 'Primary A' }, OTHER)).fandomId as string;
|
||||||
|
await call(fnFandomJoin, { fandomId: a });
|
||||||
|
// 대표가 없던 계정의 첫 지정(가입)은 변경이 아니라 대기 시각을 찍지 않는다.
|
||||||
|
expect((await user()).primaryChangedAt).toBeUndefined();
|
||||||
|
// 성운 생성 = 대표 변경(A → B). 여기서 대기가 시작된다.
|
||||||
|
const b = (await call(fnNebulaCreate, { name: 'Primary B' })).fandomId as string;
|
||||||
|
expect(await user()).toMatchObject({ primaryFandomId: b });
|
||||||
|
expect((await user()).primaryChangedAt).toBeInstanceOf(Timestamp);
|
||||||
|
expect(await codeOf(() => call(fnPrimaryFandomSet, { fandomId: a }))).toBe('E-FDM-04');
|
||||||
|
|
||||||
|
await db().doc(`users/${UID}`).update({ primaryChangedAt: Timestamp.fromMillis(Date.now() - 31 * 86_400_000) });
|
||||||
|
expect((await call(fnPrimaryFandomSet, { fandomId: a })).changed).toBe(true);
|
||||||
|
expect(await user()).toMatchObject({ primaryFandomId: a, fandomId: a });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('창립자 탈퇴 · 대표 팬덤 자동 지정(Q-PN-03 · Q-PN-04 권고안)', () => {
|
||||||
|
const setPolicy = (patch: Record<string, unknown>) => db().doc('config/policy').set(patch, { merge: true });
|
||||||
|
afterEach(async () => {
|
||||||
|
await db().doc('config/policy').set({
|
||||||
|
nebulaFounderLeaveBlocked: FieldValue.delete(), primaryOnLeave: FieldValue.delete(),
|
||||||
|
}, { merge: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('성운 창립자는 탄생 전 탈퇴 E-FDM-05 · 탄생 뒤에는 탈퇴 가능 · 설정을 끄면 막지 않는다', async () => {
|
||||||
|
const id = (await call(fnNebulaCreate, { name: 'Founder Stay' })).fandomId as string;
|
||||||
|
expect(await codeOf(() => call(fnFandomLeave, { fandomId: id }))).toBe('E-FDM-05');
|
||||||
|
expect((await fandom(id)).memberCount).toBe(1);
|
||||||
|
|
||||||
|
await db().doc(`fandoms/${id}`).update({ stage: 1 });
|
||||||
|
expect((await call(fnFandomLeave, { fandomId: id })).left).toBe(true);
|
||||||
|
|
||||||
|
const other = (await call(fnNebulaCreate, { name: 'Founder Go' }, OTHER)).fandomId as string;
|
||||||
|
await setPolicy({ nebulaFounderLeaveBlocked: false });
|
||||||
|
expect((await call(fnFandomLeave, { fandomId: other }, OTHER)).left).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A(대표) · B · C 에 가입 — 가입 시각은 B < C 로 못박는다. */
|
||||||
|
async function joinThree(): Promise<[string, string, string]> {
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (const name of ['leave-a', 'leave-b', 'leave-c']) {
|
||||||
|
// 성운 생성은 하루 1회라 정식 행성 문서를 직접 둔다.
|
||||||
|
const id = `test-planet-create-${name}`;
|
||||||
|
await db().doc(`fandoms/${id}`).set({ name, stage: 1, founderUid: OTHER, memberCount: 0 });
|
||||||
|
created.fandoms.add(id);
|
||||||
|
await call(fnFandomJoin, { fandomId: id });
|
||||||
|
ids.push(id);
|
||||||
|
}
|
||||||
|
const [a, b, c] = ids as [string, string, string];
|
||||||
|
await db().doc(`users/${UID}/memberships/${b}`).update({ joinedAt: Timestamp.fromMillis(Date.now() - 2 * 86_400_000) });
|
||||||
|
await db().doc(`users/${UID}/memberships/${c}`).update({ joinedAt: Timestamp.fromMillis(Date.now() - 86_400_000) });
|
||||||
|
expect(await user()).toMatchObject({ primaryFandomId: a });
|
||||||
|
return [a, b, c];
|
||||||
|
}
|
||||||
|
|
||||||
|
it('대표 팬덤을 떠나면 가장 최근 가입 팬덤이 대표 · 대기에 세지 않는다 · 남은 팬덤이 없으면 비운다', async () => {
|
||||||
|
const [a, b, c] = await joinThree();
|
||||||
|
expect((await call(fnFandomLeave, { fandomId: a })).primaryFandomId).toBe(c);
|
||||||
|
expect(await user()).toMatchObject({ primaryFandomId: c, fandomId: c });
|
||||||
|
expect((await user()).primaryChangedAt).toBeUndefined();
|
||||||
|
// 자동 지정 직후에도 사용자가 바로 바꿀 수 있다.
|
||||||
|
expect((await call(fnPrimaryFandomSet, { fandomId: b })).changed).toBe(true);
|
||||||
|
|
||||||
|
await call(fnFandomLeave, { fandomId: c }); // 대표가 아니면 대표는 그대로
|
||||||
|
expect(await user()).toMatchObject({ primaryFandomId: b });
|
||||||
|
await call(fnFandomLeave, { fandomId: b });
|
||||||
|
expect(await user()).toMatchObject({ primaryFandomId: null, fandomId: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('primaryOnLeave — earliest 는 가장 먼저 가입한 팬덤 · none 은 비워 둔다', async () => {
|
||||||
|
const [a, b] = await joinThree();
|
||||||
|
await setPolicy({ primaryOnLeave: 'earliest' });
|
||||||
|
await call(fnFandomLeave, { fandomId: a });
|
||||||
|
expect(await user()).toMatchObject({ primaryFandomId: b });
|
||||||
|
await setPolicy({ primaryOnLeave: 'none' });
|
||||||
|
await call(fnFandomLeave, { fandomId: b });
|
||||||
|
expect(await user()).toMatchObject({ primaryFandomId: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('행성 탄생', () => {
|
||||||
|
async function nebula(members: number, pop: number): Promise<string> {
|
||||||
|
const { fandomId } = await call(fnNebulaCreate, { name: `N${members}x${pop}` });
|
||||||
|
await db().doc(`fandoms/${fandomId}`).update({ memberCount: members, growthPop: pop });
|
||||||
|
return fandomId as string;
|
||||||
|
}
|
||||||
|
const pick = (fandomId: string) => ({ fandomId, planetType: 'shape2', colorKey: 'gold' });
|
||||||
|
|
||||||
|
it('없는 성운은 거부 · 한쪽 조건만 채우면 E-FDM-03(B-10)', async () => {
|
||||||
|
expect(await codeOf(() => call(fnPlanetCreate, pick('missing-nebula')))).toBe('E-PERM-01');
|
||||||
|
const id = await nebula(9, 5_000);
|
||||||
|
expect(await codeOf(() => call(fnPlanetCreate, pick(id)))).toBe('E-FDM-03');
|
||||||
|
await db().doc(`fandoms/${id}`).update({ memberCount: 10, growthPop: 999 });
|
||||||
|
expect(await codeOf(() => call(fnPlanetCreate, pick(id)))).toBe('E-FDM-03');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('7일 목표는 설정이 켜졌을 때만 막는다(nebulaConversionDeadlineEnforced)', async () => {
|
||||||
|
const id = await nebula(10, 1_000);
|
||||||
|
await db().doc(`fandoms/${id}`).update({ nebulaDeadline: Timestamp.fromMillis(Date.now() - 1_000) });
|
||||||
|
await db().doc('config/policy').set({ nebulaConversionDeadlineEnforced: true }, { merge: true });
|
||||||
|
try {
|
||||||
|
expect(await codeOf(() => call(fnPlanetCreate, pick(id)))).toBe('E-FDM-03');
|
||||||
|
} finally {
|
||||||
|
await db().doc('config/policy').update({ nebulaConversionDeadlineEnforced: FieldValue.delete() });
|
||||||
|
}
|
||||||
|
await call(fnPlanetCreate, pick(id));
|
||||||
|
expect((await fandom(id)).stage).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('10명 + 1,000 POP 이면 Stage1 이 되고 형태·컬러는 한 번만 저장된다(C-50)', async () => {
|
||||||
|
const id = await nebula(10, 1_000);
|
||||||
|
expect(await codeOf(() => call(fnPlanetCreate, pick(id), OTHER))).toBe('E-PERM-01');
|
||||||
|
await call(fnPlanetCreate, pick(id));
|
||||||
|
expect(await fandom(id)).toMatchObject({ stage: 1, planetType: 'shape2', colorKey: 'gold' });
|
||||||
|
expect((await db().doc(`fandoms/${id}/planets/planet`).get()).get('ownerUid')).toBe(UID);
|
||||||
|
// 두 번째 생성(= 외형 변경 시도)은 거부된다.
|
||||||
|
expect(await codeOf(() => call(fnPlanetCreate, { ...pick(id), colorKey: 'red' }))).toBe('E-POP-03');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('팔레트 밖 형태·색은 받지 않는다', async () => {
|
||||||
|
const id = await nebula(10, 1_000);
|
||||||
|
expect(await codeOf(() => call(fnPlanetCreate, { ...pick(id), colorKey: '#123456' })))
|
||||||
|
.toBe('invalid-argument');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { afterAll, beforeAll, expect, it } from 'vitest';
|
||||||
|
import { getFirestore, Timestamp } from 'firebase-admin/firestore';
|
||||||
|
import { fnPlanetHistory } from '../src/fandom/history.js';
|
||||||
|
import { bootAdmin, shutdownAdmin } from './support.js';
|
||||||
|
|
||||||
|
const fandomId = 'history-test';
|
||||||
|
const uid = 'history-reader';
|
||||||
|
const call = (data: Record<string, unknown>, user = uid) =>
|
||||||
|
fnPlanetHistory.run({ data, ...(user ? { auth: { uid: user, token: {} } } : {}) } as never);
|
||||||
|
beforeAll(async () => {
|
||||||
|
bootAdmin();
|
||||||
|
const db = getFirestore();
|
||||||
|
for (let i = 1; i <= 22; i++) {
|
||||||
|
await db.doc(`fandoms/${fandomId}/stateHistory/${i}`).set({
|
||||||
|
kind: 'evolution', fromStage: 1, toStage: 2,
|
||||||
|
occurredAt: Timestamp.fromMillis(i * 1000), sourceEventId: 'private-source',
|
||||||
|
settings: { private: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await db.doc(`users/${uid}/growthEvents/one`).set({
|
||||||
|
fandomIdAtPayout: fandomId, amount: 10, sourceType: 'zap', reversalOf: null,
|
||||||
|
createdAt: Timestamp.fromMillis(10_000),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
const db = getFirestore();
|
||||||
|
await db.recursiveDelete(db.doc(`fandoms/${fandomId}`));
|
||||||
|
await db.recursiveDelete(db.doc(`users/${uid}`));
|
||||||
|
await shutdownAdmin();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('자신의 과거 기여만 확인하며 다른 사용자 ID나 내부 기록을 노출하지 않는다', async () => {
|
||||||
|
const result = await call({ fandomId });
|
||||||
|
expect(result.items).toHaveLength(20);
|
||||||
|
expect(result.items[0]!.participationConfirmed).toBe(true);
|
||||||
|
expect(result.items.at(-1)!.participationConfirmed).toBe(false);
|
||||||
|
expect(JSON.stringify(result)).not.toContain('private');
|
||||||
|
const stranger = await call({ fandomId, uid }, 'other-user');
|
||||||
|
expect(stranger.items.every((item) => item.participationConfirmed === false)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('커서 페이지가 겹치지 않으며 인증·경로·커서 오류를 거부한다', async () => {
|
||||||
|
const first = await call({ fandomId });
|
||||||
|
const second = await call({ fandomId, cursor: first.nextCursor });
|
||||||
|
expect(second.items).toHaveLength(2);
|
||||||
|
expect(second.nextCursor).toBeNull();
|
||||||
|
expect(new Set([...first.items, ...second.items].map((item) => item.id)).size).toBe(22);
|
||||||
|
await expect(call({ fandomId }, '')).rejects.toThrow();
|
||||||
|
await expect(call({ fandomId: '../users/private' })).rejects.toThrow();
|
||||||
|
await expect(call({ fandomId, cursor: 'missing' })).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('수량이 잘못된 기록은 개인 참여 증거로 쓰지 않는다', async () => {
|
||||||
|
await getFirestore().doc(`users/${uid}/growthEvents/one`).update({ amount: '10' });
|
||||||
|
const result = await call({ fandomId });
|
||||||
|
expect(result.items.every((item) => item.participationConfirmed === false)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('내 활동은 호출자와 지급 당시 팬덤으로 제한하고 다른 팬덤 커서를 거부한다', async () => {
|
||||||
|
const events = getFirestore().collection(`users/${uid}/growthEvents`);
|
||||||
|
await events.doc('one').update({ amount: 10 });
|
||||||
|
for (const [index, sourceType] of ['quest_reward', 'company_ad_bonus', 'zap', 'funding'].entries()) {
|
||||||
|
await events.doc(`activity-${index}`).set({
|
||||||
|
fandomIdAtPayout: fandomId, sourceType, amount: 10 + index, reversalOf: null,
|
||||||
|
createdAt: Timestamp.fromMillis(30_000 + index), sourceEventId: 'private-ledger',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await events.doc('other-fandom').set({ fandomIdAtPayout: 'other-fandom', sourceType: 'zap',
|
||||||
|
amount: 999, createdAt: Timestamp.fromMillis(40_000) });
|
||||||
|
const result = await call({ fandomId, scope: 'personal' });
|
||||||
|
expect(result.items).toHaveLength(5);
|
||||||
|
expect(result.items[0]!.kind).toBe('funding');
|
||||||
|
expect(result.items[0]!.amount).toBe(13);
|
||||||
|
expect(JSON.stringify(result)).not.toContain('private-ledger');
|
||||||
|
expect((await call({ fandomId, scope: 'personal', uid }, 'other-user')).items).toEqual([]);
|
||||||
|
await expect(call({ fandomId, scope: 'personal', cursor: 'other-fandom' })).rejects.toThrow();
|
||||||
|
await expect(call({ fandomId, scope: 'all-users' })).rejects.toThrow();
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { FieldValue, getFirestore, Timestamp } from 'firebase-admin/firestore';
|
||||||
|
|
||||||
|
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
|
||||||
|
import { grantPop } from '../src/ledger/ledger.js';
|
||||||
|
import {
|
||||||
|
countAnswer, expireBounties, fnAnswerAccept, fnQuestionCreate, MAX_REFUND_ATTEMPTS,
|
||||||
|
} from '../src/inside/qna.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현상금 Q&A 왕복 — 별첨 1 ④ 「예치·전송·환불 원장 정합성 오류 0건」.
|
||||||
|
* 핸들러를 `CallableFunction.run` 으로 그대로 돌린다(quest_e2e 와 같은 방식).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ASKER = 'test-qna-asker';
|
||||||
|
const ANSWERER = 'test-qna-answerer';
|
||||||
|
|
||||||
|
type Reply = Record<string, unknown>;
|
||||||
|
|
||||||
|
async function call(
|
||||||
|
fn: { run(request: never): unknown },
|
||||||
|
uid: string,
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
): Promise<Reply> {
|
||||||
|
const request = { data, auth: { uid, token: {} } } as unknown as never;
|
||||||
|
return (await fn.run(request)) as Reply;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function codeOf(run: () => Promise<unknown>): Promise<string> {
|
||||||
|
try {
|
||||||
|
await run();
|
||||||
|
} catch (error) {
|
||||||
|
const e = error as { details?: { code?: string }; code?: string };
|
||||||
|
return e.details?.code ?? e.code ?? String(error);
|
||||||
|
}
|
||||||
|
throw new Error('실패해야 하는 호출이 성공했습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function balance(uid: string): Promise<number> {
|
||||||
|
const w = await getFirestore().doc(`users/${uid}/wallet/main`).get();
|
||||||
|
return (w.get('activityBalance') as number | undefined) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let seq = 0;
|
||||||
|
async function ask(bountyPop: number): Promise<string> {
|
||||||
|
const reply = await call(fnQuestionCreate, ASKER, {
|
||||||
|
title: '질문', body: '본문', bountyPop, clientSeq: `s${++seq}-${Date.now()}`,
|
||||||
|
});
|
||||||
|
return reply.questionId as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function answer(questionId: string, uid: string): Promise<string> {
|
||||||
|
const ref = await getFirestore().collection(`questions/${questionId}/answers`).add({
|
||||||
|
authorUid: uid, authorName: uid, body: '답', accepted: false,
|
||||||
|
});
|
||||||
|
return ref.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => { bootAdmin(); });
|
||||||
|
afterAll(async () => { await shutdownAdmin(); });
|
||||||
|
beforeEach(async () => {
|
||||||
|
await wipeUser(ASKER);
|
||||||
|
await wipeUser(ANSWERER);
|
||||||
|
const old = await getFirestore().collection('questions').where('authorUid', '==', ASKER).get();
|
||||||
|
await Promise.all(old.docs.map((d) => getFirestore().recursiveDelete(d.ref)));
|
||||||
|
await getFirestore().doc(`users/${ASKER}`).set({ displayName: '김실명', nickname: '질문자', fandomId: 'f-qna' });
|
||||||
|
await grantPop({
|
||||||
|
uid: ASKER, popType: 'activity', amount: 1000,
|
||||||
|
reasonCode: 'ADMIN_GRANT', idempotencyKey: `g:qna:${Date.now()}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fn-question-create', () => {
|
||||||
|
it('예치와 질문이 함께 서고, 목록이 읽는 팬덤·표시명이 복제된다', async () => {
|
||||||
|
const id = await ask(300);
|
||||||
|
const q = await getFirestore().doc(`questions/${id}`).get();
|
||||||
|
expect(q.get('status')).toBe('open');
|
||||||
|
expect(q.get('fandomId')).toBe('f-qna');
|
||||||
|
expect(q.get('authorName')).toBe('질문자');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('닉네임이 없으면 공급자 실명 대신 「팬」을 싣는다(M-7)', async () => {
|
||||||
|
await getFirestore().doc(`users/${ASKER}`).update({ nickname: FieldValue.delete() });
|
||||||
|
const q = await getFirestore().doc(`questions/${await ask(300)}`).get();
|
||||||
|
expect(q.get('authorName')).toBe('팬');
|
||||||
|
expect(q.get('bountyPop')).toBe(300);
|
||||||
|
expect(await balance(ASKER)).toBe(700);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('제목 · 본문 길이 상한을 서버가 끊는다', async () => {
|
||||||
|
const run = (title: string, body: string) => call(fnQuestionCreate, ASKER, {
|
||||||
|
title, body, bountyPop: 100, clientSeq: `len-${Date.now()}`,
|
||||||
|
});
|
||||||
|
expect(await codeOf(() => run('가'.repeat(101), '본문'))).toBe('invalid-argument');
|
||||||
|
expect(await codeOf(() => run('제목', '가'.repeat(5_001)))).toBe('invalid-argument');
|
||||||
|
expect(await balance(ASKER)).toBe(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('현상금 100 POP 미만은 서버가 거부한다', async () => {
|
||||||
|
expect(await codeOf(() => ask(99))).toBe('invalid-argument');
|
||||||
|
expect(await balance(ASKER)).toBe(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('현상금 하한은 config/policy.qnaMinBountyPop 이 덮는다', async () => {
|
||||||
|
const policy = getFirestore().doc('config/policy');
|
||||||
|
await policy.set({ qnaMinBountyPop: 200 }, { merge: true });
|
||||||
|
try {
|
||||||
|
expect(await codeOf(() => ask(150))).toBe('invalid-argument');
|
||||||
|
expect(await ask(200)).toBeTruthy();
|
||||||
|
} finally {
|
||||||
|
await policy.set({ qnaMinBountyPop: FieldValue.delete() }, { merge: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('일반이 모자라면 부족분만 기여 POP 으로 예치하고, 만료 환불은 각 타입으로 돌아간다(B-39)', async () => {
|
||||||
|
await grantPop({
|
||||||
|
uid: ASKER, popType: 'support', amount: 500,
|
||||||
|
reasonCode: 'IAP_PURCHASE', idempotencyKey: `s:qna:${Date.now()}`,
|
||||||
|
});
|
||||||
|
const support = async () =>
|
||||||
|
((await getFirestore().doc(`users/${ASKER}/wallet/main`).get()).get('supportBalance') as number);
|
||||||
|
const id = await ask(1200);
|
||||||
|
expect(await balance(ASKER)).toBe(0);
|
||||||
|
expect(await support()).toBe(300);
|
||||||
|
const q = await getFirestore().doc(`questions/${id}`).get();
|
||||||
|
expect((q.get('bountyTxIds') as string[]).length).toBe(2);
|
||||||
|
|
||||||
|
await q.ref.update({ expiresAt: Timestamp.fromMillis(Date.now() - 1000) });
|
||||||
|
await expireBounties();
|
||||||
|
await expireBounties();
|
||||||
|
expect((await q.ref.get()).get('status')).toBe('expired');
|
||||||
|
expect(await balance(ASKER)).toBe(1000);
|
||||||
|
expect(await support()).toBe(500);
|
||||||
|
const refunds = await getFirestore().collection(`users/${ASKER}/popTransactions`)
|
||||||
|
.where('reasonCode', '==', 'QNA_REFUND').get();
|
||||||
|
expect(refunds.docs.map((d) => [d.get('popType'), d.get('amount')]).sort())
|
||||||
|
.toEqual([['activity', 1000], ['support', 200]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('분할 전 옛 질문(bountyTxId 한 건)도 만료 환불된다', async () => {
|
||||||
|
const id = await ask(300);
|
||||||
|
const ref = getFirestore().doc(`questions/${id}`);
|
||||||
|
const [txId] = (await ref.get()).get('bountyTxIds') as string[];
|
||||||
|
await ref.update({
|
||||||
|
bountyTxIds: FieldValue.delete(), bountyTxId: txId,
|
||||||
|
expiresAt: Timestamp.fromMillis(Date.now() - 1000),
|
||||||
|
});
|
||||||
|
await expireBounties();
|
||||||
|
expect((await ref.get()).get('status')).toBe('expired');
|
||||||
|
expect(await balance(ASKER)).toBe(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('잔액이 모자라면 질문이 남지 않는다', async () => {
|
||||||
|
expect(await codeOf(() => ask(5000))).toBe('E-POP-01');
|
||||||
|
const left = await getFirestore().collection('questions')
|
||||||
|
.where('authorUid', '==', ASKER).where('bountyPop', '==', 5000).get();
|
||||||
|
expect(left.empty).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fn-answer-accept', () => {
|
||||||
|
it('채택하면 현상금이 답변자에게 일반 POP 으로 가고 번복되지 않는다', async () => {
|
||||||
|
const id = await ask(400);
|
||||||
|
const a = await answer(id, ANSWERER);
|
||||||
|
await call(fnAnswerAccept, ASKER, { questionId: id, answerId: a });
|
||||||
|
|
||||||
|
expect(await balance(ANSWERER)).toBe(400);
|
||||||
|
expect((await balance(ASKER)) + (await balance(ANSWERER))).toBe(1000);
|
||||||
|
const q = await getFirestore().doc(`questions/${id}`).get();
|
||||||
|
expect(q.get('status')).toBe('accepted');
|
||||||
|
const ans = await getFirestore().doc(`questions/${id}/answers/${a}`).get();
|
||||||
|
expect(ans.get('accepted')).toBe(true);
|
||||||
|
|
||||||
|
const b = await answer(id, 'someone-else');
|
||||||
|
expect(await codeOf(() => call(fnAnswerAccept, ASKER, { questionId: id, answerId: b })))
|
||||||
|
.toBe('E-QNA-03');
|
||||||
|
expect(await balance(ANSWERER)).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('자기 답변은 채택할 수 없다(E-QNA-01)', async () => {
|
||||||
|
const id = await ask(200);
|
||||||
|
const mine = await answer(id, ASKER);
|
||||||
|
expect(await codeOf(() => call(fnAnswerAccept, ASKER, { questionId: id, answerId: mine })))
|
||||||
|
.toBe('E-QNA-01');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('질문자가 아니면 채택할 수 없다', async () => {
|
||||||
|
const id = await ask(200);
|
||||||
|
const a = await answer(id, ANSWERER);
|
||||||
|
expect(await codeOf(() => call(fnAnswerAccept, ANSWERER, { questionId: id, answerId: a })))
|
||||||
|
.toBe('E-PERM-01');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fn-question-bounty-expire', () => {
|
||||||
|
it('기한이 지나면 원타입으로 한 번만 환불되고, 이후 채택은 E-QNA-02', async () => {
|
||||||
|
const id = await ask(500);
|
||||||
|
const a = await answer(id, ANSWERER);
|
||||||
|
await getFirestore().doc(`questions/${id}`).update({
|
||||||
|
expiresAt: Timestamp.fromMillis(Date.now() - 1000),
|
||||||
|
});
|
||||||
|
|
||||||
|
const early = await call(fnAnswerAccept, ASKER, { questionId: id, answerId: a })
|
||||||
|
.catch((e: { details?: { code?: string }; message: string }) => e);
|
||||||
|
expect(early).toMatchObject({ details: { code: 'E-QNA-02' } });
|
||||||
|
expect((early as { message: string }).message).toContain('환불될 예정');
|
||||||
|
|
||||||
|
await expireBounties();
|
||||||
|
await expireBounties();
|
||||||
|
const q = await getFirestore().doc(`questions/${id}`).get();
|
||||||
|
expect(q.get('status')).toBe('expired');
|
||||||
|
expect(await balance(ASKER)).toBe(1000);
|
||||||
|
expect(await balance(ANSWERER)).toBe(0);
|
||||||
|
const late = await call(fnAnswerAccept, ASKER, { questionId: id, answerId: a })
|
||||||
|
.catch((e: { message: string }) => e);
|
||||||
|
expect((late as { message: string }).message).toContain('환불되었습니다');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 두 사람 사이의 원장 행 수(초기 지급 제외). 예치 1 + 채택 또는 환불 1 이어야 한다. */
|
||||||
|
async function movements(): Promise<string[]> {
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const uid of [ASKER, ANSWERER]) {
|
||||||
|
const rows = await getFirestore().collection(`users/${uid}/popTransactions`).get();
|
||||||
|
for (const r of rows.docs) {
|
||||||
|
const code = r.get('reasonCode') as string;
|
||||||
|
if (code !== 'ADMIN_GRANT') out.push(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('채택과 만료 환불의 경합', () => {
|
||||||
|
it('동시에 돌아도 채택 또는 환불 중 정확히 하나만 원장을 움직인다', async () => {
|
||||||
|
const id = await ask(500);
|
||||||
|
const a = await answer(id, ANSWERER);
|
||||||
|
// 기한 직전에 둘이 겹치는 상황 — 만료 시각을 막 지난 값으로 둔다.
|
||||||
|
await getFirestore().doc(`questions/${id}`).update({
|
||||||
|
expiresAt: Timestamp.fromMillis(Date.now() + 300),
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
await Promise.allSettled([
|
||||||
|
call(fnAnswerAccept, ASKER, { questionId: id, answerId: a }),
|
||||||
|
expireBounties(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const moved = (await movements()).filter((c) => c !== 'QNA_BOUNTY');
|
||||||
|
expect(moved).toHaveLength(1);
|
||||||
|
expect((await balance(ASKER)) + (await balance(ANSWERER))).toBe(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('기한 전 동시 채택은 한쪽만 지급된다', async () => {
|
||||||
|
const id = await ask(500);
|
||||||
|
const a = await answer(id, ANSWERER);
|
||||||
|
await Promise.allSettled([
|
||||||
|
call(fnAnswerAccept, ASKER, { questionId: id, answerId: a }),
|
||||||
|
expireBounties(),
|
||||||
|
]);
|
||||||
|
expect(await movements()).toEqual(['QNA_BOUNTY', 'QNA_REWARD']);
|
||||||
|
expect(await balance(ANSWERER)).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('환불 실패 처리', () => {
|
||||||
|
it(`${MAX_REFUND_ATTEMPTS}번 실패하면 refund_failed 로 멈추고 운영 로그를 남긴다`, async () => {
|
||||||
|
const id = await ask(200);
|
||||||
|
// 원거래를 가리키는 ID 를 깨뜨려 환불이 계속 실패하게 한다.
|
||||||
|
await getFirestore().doc(`questions/${id}`).update({
|
||||||
|
bountyTxIds: ['missing-tx'],
|
||||||
|
expiresAt: Timestamp.fromMillis(Date.now() - 1000),
|
||||||
|
});
|
||||||
|
for (let i = 0; i < MAX_REFUND_ATTEMPTS + 1; i++) await expireBounties();
|
||||||
|
|
||||||
|
const q = await getFirestore().doc(`questions/${id}`).get();
|
||||||
|
expect(q.get('status')).toBe('refund_failed');
|
||||||
|
expect(q.get('refundAttempts')).toBe(MAX_REFUND_ATTEMPTS);
|
||||||
|
const logs = await getFirestore().collection('adminActionLogs')
|
||||||
|
.where('targetPath', '==', `questions/${id}`).get();
|
||||||
|
expect(logs.size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('질문자 정보가 없으면 바로 refund_failed 다', async () => {
|
||||||
|
const id = await ask(200);
|
||||||
|
await getFirestore().doc(`questions/${id}`).update({
|
||||||
|
bountyTxIds: [],
|
||||||
|
expiresAt: Timestamp.fromMillis(Date.now() - 1000),
|
||||||
|
});
|
||||||
|
await expireBounties();
|
||||||
|
expect((await getFirestore().doc(`questions/${id}`).get()).get('status')).toBe('refund_failed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('답변 수 트리거', () => {
|
||||||
|
it('같은 답변이 재전달되어도 한 번만 센다', async () => {
|
||||||
|
const id = await ask(100);
|
||||||
|
const a = await answer(id, ANSWERER);
|
||||||
|
expect(await countAnswer(id, a)).toBe(true);
|
||||||
|
expect(await countAnswer(id, a)).toBe(false);
|
||||||
|
const b = await answer(id, 'someone-else');
|
||||||
|
await Promise.all([countAnswer(id, b), countAnswer(id, b)]);
|
||||||
|
const q = await getFirestore().doc(`questions/${id}`).get();
|
||||||
|
expect(q.get('answerCount')).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('질문이 없으면 건너뛴다', async () => {
|
||||||
|
expect(await countAnswer('no-such-question', 'x')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,628 @@
|
|||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { getFirestore, FieldValue, GeoPoint, Timestamp } from 'firebase-admin/firestore';
|
||||||
|
import type { CallableRequest } from 'firebase-functions/v2/https';
|
||||||
|
|
||||||
|
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
|
||||||
|
import { grantPop } from '../src/ledger/ledger.js';
|
||||||
|
import { verifiedQuestCountOf } from '../src/ledger/fpi.js';
|
||||||
|
import { fnAdminCompletionVoid } from '../src/admin/admin.js';
|
||||||
|
import { fnAdReward, fnQuestAttend, fnQuestVerifySteps } from '../src/quest/quest.js';
|
||||||
|
import { fnQuestVerifyMusicShake, fnQuestVerifyPlace } from '../src/quest/verify.js';
|
||||||
|
import { fnCampaignFund } from '../src/fandom/fandom.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 퀘스트 E2E — 별첨 1 ③ 「퀘스트별 수행 → 검증 → 보상」 판정 절차.
|
||||||
|
*
|
||||||
|
* 별첨 1 ② 완료 시나리오 1 「활동 보상 → 무료 POP 지급 → 원장 기록 → 행성 성장 반영」과
|
||||||
|
* ③ 「만보기·위치·출석 → 검증 → 보상」을 한 파일에서 끝까지 돈다. 화면 없이 도는 것은
|
||||||
|
* **서버가 판정 주체**이기 때문이다 — 앱은 증거만 싣는다.
|
||||||
|
*
|
||||||
|
* 확인하는 것
|
||||||
|
* 1. 출석 1회 → 원장 행 1건 → 잔액 증가 → 같은 날 2회차 거부(E-POP-03)
|
||||||
|
* 2. 걸음 미달(E-QST-01) · 비정상(E-QST-02) · 완주 지급 · 당일 재지급 거부
|
||||||
|
* 3. 방문 반경 밖(E-QST-03) · 모의 위치(E-QST-02) · 인증 수단 없음 · 정상 지급 · 재사용 차단
|
||||||
|
* 4. 받은 POP 을 캠페인에 쓰면 **화면이 읽는 필드**가 움직인다
|
||||||
|
* (`users.totalContributedPop` · `users.userExp/userLevel` · `members.contributedPop`)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const UID = 'test-quest-e2e';
|
||||||
|
const FANDOM = 'fandom-quest-e2e';
|
||||||
|
const PLACE = 'place-quest-e2e';
|
||||||
|
|
||||||
|
/// 앱이 읽는 것과 같은 **퀘스트 문서 ID**. 서버가 진행 문서를 이 ID 로 써야 한다.
|
||||||
|
const ATTEND_QUEST = 'daily-checkin';
|
||||||
|
const STEPS_QUEST = 'daily-steps';
|
||||||
|
const PLACE_QUEST = 'place-visit';
|
||||||
|
|
||||||
|
/** 카탈로그 코드만 꺼낸다. 화면도 상태가 아니라 이 코드로 분기한다. */
|
||||||
|
async function codeOf(run: () => Promise<unknown>): Promise<string> {
|
||||||
|
try {
|
||||||
|
await run();
|
||||||
|
} catch (error) {
|
||||||
|
const details = (error as { details?: { code?: string } }).details;
|
||||||
|
return details?.code ?? `(코드 없음) ${(error as Error).message}`;
|
||||||
|
}
|
||||||
|
throw new Error('실패해야 하는 호출이 성공했습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 서버 응답 한 건. 값의 형이 섞여 있어 판정에 필요한 것만 꺼내 쓴다. */
|
||||||
|
type Reply = Record<string, unknown>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배포하지 않고 핸들러를 그대로 실행한다(`CallableFunction.run`).
|
||||||
|
*
|
||||||
|
* 배포·HTTP 왕복 없이도 **판정 로직 전체**가 도는 경로다. 인증 문맥만 흉내 내고
|
||||||
|
* 나머지는 실제 코드가 그대로 돈다 — 에뮬레이터의 Firestore 를 쓰므로 트랜잭션과
|
||||||
|
* 문서 ID 멱등까지 실제 동작이다.
|
||||||
|
*/
|
||||||
|
async function call(
|
||||||
|
fn: { run(request: never): unknown },
|
||||||
|
data: Record<string, unknown> = {},
|
||||||
|
): Promise<Reply> {
|
||||||
|
const request = { data, auth: { uid: UID, token: {} } } as unknown as never;
|
||||||
|
return (await fn.run(request)) as Reply;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 문서 하나를 꺼낸다. 없으면 그 자리에서 끊는다 — 뒤에서 undefined 로 새지 않게. */
|
||||||
|
function only<T>(docs: T[]): T {
|
||||||
|
const first = docs[0];
|
||||||
|
if (first === undefined) throw new Error('문서가 없습니다.');
|
||||||
|
return first;
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = () => getFirestore();
|
||||||
|
|
||||||
|
beforeAll(() => { bootAdmin(); });
|
||||||
|
afterAll(async () => { await shutdownAdmin(); });
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await wipeUser(UID);
|
||||||
|
for (const sub of ['activityLogs']) {
|
||||||
|
const snap = await db().collection(`users/${UID}/${sub}`).get();
|
||||||
|
await Promise.all(snap.docs.map((d) => d.ref.delete()));
|
||||||
|
}
|
||||||
|
const proofs = await db().collection('placeProofs').get();
|
||||||
|
await Promise.all(proofs.docs.map((d) => d.ref.delete()));
|
||||||
|
const events = await db().collection(`fandoms/${FANDOM}/fpiEvents`).get();
|
||||||
|
await Promise.all(events.docs.map((d) => d.ref.delete()));
|
||||||
|
await db().doc(`users/${UID}`).set({ uid: UID, fandomId: FANDOM, totalContributedPop: 0 });
|
||||||
|
// 운영값은 문서로 둔다 — 코드에 박으면 회신마다 재배포다(설계서 §10).
|
||||||
|
await db().doc('config/policy').set({
|
||||||
|
attendPop: 10, stepsGoal: 10_000, stepsPop: 50, adRewardPop: 10, adMultiplier: 2, adDailyLimit: 5,
|
||||||
|
// 정책 원장 **B-26** 확정 구간. 운영값이라 코드가 아니라 문서에 둔다.
|
||||||
|
stepTiers: [
|
||||||
|
{ steps: 1_000, pop: 5 },
|
||||||
|
{ steps: 5_000, pop: 6 },
|
||||||
|
{ steps: 10_000, pop: 7 },
|
||||||
|
{ steps: 15_000, pop: 8 },
|
||||||
|
],
|
||||||
|
}, { merge: true });
|
||||||
|
// 퀘스트 정의. **일일 한도의 유일한 원본**이며(설계서 §2.8) 진행 문서 ID 도 이 ID 다.
|
||||||
|
await db().doc(`quests/${ATTEND_QUEST}`).set({
|
||||||
|
type: 'attend', title: '오늘의 출석', rewardPop: 10, dailyLimit: 1, active: true,
|
||||||
|
});
|
||||||
|
await db().doc(`quests/${STEPS_QUEST}`).set({
|
||||||
|
type: 'walk', title: '만보기 챌린지', rewardPop: 0, dailyLimit: 1, active: true,
|
||||||
|
});
|
||||||
|
// 위치 퀘스트는 장소를 가리킨다. 서버는 이 문서의 장소와 요청 장소가 같아야 지급한다.
|
||||||
|
for (const id of [PLACE_QUEST, 'place-quest-once', 'place-quest-other', 'place-quest-reuse']) {
|
||||||
|
await db().doc(`quests/${id}`).set({ type: 'location', placeId: PLACE, dailyLimit: 1 });
|
||||||
|
}
|
||||||
|
await db().doc('quests/place-quest-repeat').set({
|
||||||
|
type: 'location', placeId: PLACE, repeat: { allowed: true, intervalHours: 24, maxCount: 2 },
|
||||||
|
});
|
||||||
|
await db().doc(`places/${PLACE}`).set({
|
||||||
|
name: '홍대 팝업스토어',
|
||||||
|
geo: new GeoPoint(37.5563, 126.9236),
|
||||||
|
// 별첨 B **B-27** 기본 반경 100m. Admin 운영값이라 문서에 둔다.
|
||||||
|
radiusM: 100,
|
||||||
|
rewardPop: 100,
|
||||||
|
verifyMethods: ['gps', 'qr'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('출석 — S-H-ATTEND (fn-quest-attend)', () => {
|
||||||
|
it('없는 퀘스트 · 출석이 아닌 퀘스트 ID 로는 지급하지 않는다(리뷰 H2)', async () => {
|
||||||
|
expect(await codeOf(() => call(fnQuestAttend, { questId: 'no-such-quest' }))).toBe('E-QST-01');
|
||||||
|
expect(await codeOf(() => call(fnQuestAttend, { questId: STEPS_QUEST }))).toBe('E-QST-01');
|
||||||
|
expect((await db().collection(`users/${UID}/popTransactions`).get()).size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('참여 1회로 원장 1행·잔액·진행 카운터가 함께 생긴다', async () => {
|
||||||
|
const result = await call(fnQuestAttend, { questId: ATTEND_QUEST });
|
||||||
|
expect(result.rewarded).toBe(10);
|
||||||
|
expect(result.balanceAfter).toBe(10);
|
||||||
|
|
||||||
|
const ledger = await db().collection(`users/${UID}/popTransactions`).get();
|
||||||
|
expect(ledger.size).toBe(1);
|
||||||
|
expect(only(ledger.docs).get('reasonCode')).toBe('QUEST_ATTEND');
|
||||||
|
// 검증 없는 지급 경로가 없다는 것을 원장 자체에 남긴다 — 별첨 1 ② 판정 근거.
|
||||||
|
expect(only(ledger.docs).get('serverVerified')).toBe(true);
|
||||||
|
expect(only(ledger.docs).get('amount')).toBe(10);
|
||||||
|
|
||||||
|
const wallet = await db().doc(`users/${UID}/wallet/main`).get();
|
||||||
|
expect(wallet.get('activityBalance')).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('앱이 읽는 경로(퀘스트 문서 ID)에 진행이 완료로 남는다', async () => {
|
||||||
|
await call(fnQuestAttend, { questId: ATTEND_QUEST });
|
||||||
|
|
||||||
|
// 앱은 `users/{uid}/questProgress/{퀘스트 문서 ID}` 를 읽는다. 서버가 유형 이름에
|
||||||
|
// 쓰면 지급은 되는데 화면은 영원히 「시작하기」로 남는다.
|
||||||
|
const progress = await db().doc(`users/${UID}/questProgress/${ATTEND_QUEST}`).get();
|
||||||
|
expect(progress.exists).toBe(true);
|
||||||
|
expect(progress.get('questId')).toBe(ATTEND_QUEST);
|
||||||
|
expect(progress.get('count')).toBe(1);
|
||||||
|
expect(progress.get('rewardedPop')).toBe(10);
|
||||||
|
|
||||||
|
// 일일 한도는 `quests/{id}.dailyLimit` 이 정한다. 1 이면 여기서 완료로 보인다.
|
||||||
|
const quest = await db().doc(`quests/${ATTEND_QUEST}`).get();
|
||||||
|
expect(progress.get('count')).toBeGreaterThanOrEqual(quest.get('dailyLimit') as number);
|
||||||
|
|
||||||
|
// 유형 이름으로 쓰던 예전 자리에는 아무것도 남지 않는다.
|
||||||
|
const legacy = await db().doc(`users/${UID}/questProgress/attend`).get();
|
||||||
|
expect(legacy.exists).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('같은 날 두 번째 참여는 서버가 거부한다', async () => {
|
||||||
|
await call(fnQuestAttend, { questId: ATTEND_QUEST });
|
||||||
|
expect(await codeOf(() => call(fnQuestAttend, { questId: ATTEND_QUEST })))
|
||||||
|
.toBe('E-POP-03');
|
||||||
|
// 거부된 호출이 원장을 늘리지 않는다.
|
||||||
|
const ledger = await db().collection(`users/${UID}/popTransactions`).get();
|
||||||
|
expect(ledger.size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('한도를 2회로 올린 퀘스트는 두 번까지 받는다', async () => {
|
||||||
|
// 한도의 유일한 원본이 퀘스트 문서라는 것을 값으로 보인다.
|
||||||
|
await db().doc(`quests/${ATTEND_QUEST}`).set({ dailyLimit: 2 }, { merge: true });
|
||||||
|
await call(fnQuestAttend, { questId: ATTEND_QUEST });
|
||||||
|
const second = await call(fnQuestAttend, { questId: ATTEND_QUEST });
|
||||||
|
expect(second.balanceAfter).toBe(20);
|
||||||
|
expect(await codeOf(() => call(fnQuestAttend, { questId: ATTEND_QUEST })))
|
||||||
|
.toBe('E-POP-03');
|
||||||
|
|
||||||
|
const progress = await db().doc(`users/${UID}/questProgress/${ATTEND_QUEST}`).get();
|
||||||
|
expect(progress.get('count')).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('만보기 — S-H-WALK (fn-quest-verify-steps · 정책 원장 B-26)', () => {
|
||||||
|
const walk = (steps: number) => call(fnQuestVerifySteps, { questId: STEPS_QUEST, steps });
|
||||||
|
|
||||||
|
it('첫 구간에 못 미치면 지급하지 않고 사유를 증거에 남긴다', async () => {
|
||||||
|
expect(await codeOf(() => walk(800))).toBe('E-QST-01');
|
||||||
|
|
||||||
|
const logs = await db().collection(`users/${UID}/activityLogs`).get();
|
||||||
|
expect(logs.size).toBe(1);
|
||||||
|
expect(only(logs.docs).get('verified')).toBe(false);
|
||||||
|
expect(only(logs.docs).get('rejectReason')).toBe('E-QST-01');
|
||||||
|
expect(only(logs.docs).get('value')).toBe(800);
|
||||||
|
const ledger = await db().collection(`users/${UID}/popTransactions`).get();
|
||||||
|
expect(ledger.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('사람이 낼 수 없는 걸음 수는 비정상으로 끊는다', async () => {
|
||||||
|
expect(await codeOf(() => walk(150_000))).toBe('E-QST-02');
|
||||||
|
const logs = await db().collection(`users/${UID}/activityLogs`).get();
|
||||||
|
expect(only(logs.docs).get('rejectReason')).toBe('E-QST-02');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('구간별 보상이 5/6/7/8 로 정확히 나간다', async () => {
|
||||||
|
// 1 천보 — 첫 구간.
|
||||||
|
const first = await walk(1_200);
|
||||||
|
expect(first.rewarded).toBe(5);
|
||||||
|
expect(first.clearedTier).toBe(1);
|
||||||
|
expect(first.nextTierSteps).toBe(5_000);
|
||||||
|
|
||||||
|
// 5 천보 — 둘째 구간만 추가로 나간다.
|
||||||
|
const second = await walk(5_200);
|
||||||
|
expect(second.rewarded).toBe(6);
|
||||||
|
expect(second.balanceAfter).toBe(11);
|
||||||
|
|
||||||
|
// 1 만보 · 1.5 만보.
|
||||||
|
expect((await walk(10_100)).rewarded).toBe(7);
|
||||||
|
const last = await walk(15_500);
|
||||||
|
expect(last.rewarded).toBe(8);
|
||||||
|
expect(last.nextTierSteps).toBe(0);
|
||||||
|
|
||||||
|
// 원장은 구간마다 한 행이다. 합계 26 POP.
|
||||||
|
const ledger = await db()
|
||||||
|
.collection(`users/${UID}/popTransactions`)
|
||||||
|
.where('reasonCode', '==', 'QUEST_STEPS')
|
||||||
|
.get();
|
||||||
|
expect(ledger.docs.map((d) => d.get('amount') as number).sort((a, b) => a - b))
|
||||||
|
.toEqual([5, 6, 7, 8]);
|
||||||
|
const wallet = await db().doc(`users/${UID}/wallet/main`).get();
|
||||||
|
expect(wallet.get('activityBalance')).toBe(26);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('아랫 구간을 건너뛰고 윗 구간만 지급하지 않는다', async () => {
|
||||||
|
// 한 번에 1.5 만보를 보고해도 1 천보부터 차례로 네 구간이 나간다.
|
||||||
|
const result = await walk(16_000);
|
||||||
|
expect(result.rewarded).toBe(26);
|
||||||
|
expect(result.clearedTier).toBe(4);
|
||||||
|
|
||||||
|
const ledger = await db()
|
||||||
|
.collection(`users/${UID}/popTransactions`)
|
||||||
|
.where('reasonCode', '==', 'QUEST_STEPS')
|
||||||
|
.get();
|
||||||
|
expect(ledger.size).toBe(4);
|
||||||
|
// 1 천보 구간(5 POP)이 빠진 채 윗 구간만 나간 경우가 없다.
|
||||||
|
expect(ledger.docs.some((d) => d.get('amount') === 5)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('같은 구간을 두 번 받지 못한다', async () => {
|
||||||
|
await walk(5_200);
|
||||||
|
expect(await codeOf(() => walk(5_400))).toBe('E-POP-03');
|
||||||
|
const ledger = await db().collection(`users/${UID}/popTransactions`).get();
|
||||||
|
expect(ledger.size).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('진행 카운터가 받은 구간 수와 함께 간다', async () => {
|
||||||
|
await walk(10_100);
|
||||||
|
const progress = await db().doc(`users/${UID}/questProgress/${STEPS_QUEST}`).get();
|
||||||
|
expect(progress.get('questId')).toBe(STEPS_QUEST);
|
||||||
|
// 세 구간을 받았다. 지급과 같은 트랜잭션에서 쓰므로 지급 수와 어긋나지 않는다.
|
||||||
|
expect(progress.get('count')).toBe(3);
|
||||||
|
expect(progress.get('rewardedPop')).toBe(18);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('단말 시각이 서버와 크게 어긋나면 증거로 쓰지 않는다', async () => {
|
||||||
|
const drifted = new Date(Date.now() + 3 * 60 * 60 * 1000).toISOString();
|
||||||
|
expect(await codeOf(
|
||||||
|
() => call(fnQuestVerifySteps, { questId: STEPS_QUEST, steps: 10_400, occurredAt: drifted }),
|
||||||
|
)).toBe('E-QST-02');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FPI 집계가 읽는 HEALTH 축 이벤트를 남긴다', async () => {
|
||||||
|
await call(fnQuestVerifySteps, {
|
||||||
|
questId: STEPS_QUEST, steps: 5_200, fandomId: FANDOM,
|
||||||
|
});
|
||||||
|
|
||||||
|
// `fn-fpi-aggregate` 가 읽는 것은 `fpiEvents` 다. 이 문서가 없으면 퀘스트를 깨도
|
||||||
|
// FPI 가 움직이지 않고 sFPI 를 거쳐 행성 성장에 닿는 경로도 열리지 않는다.
|
||||||
|
const events = await db().collection(`fandoms/${FANDOM}/fpiEvents`).get();
|
||||||
|
// 5,200보 = 1 천 · 5 천 두 구간. 구간 1개 = 1 단위이며 보상액(5·6 POP)은 쓰지 않는다(C-34).
|
||||||
|
expect(events.size).toBe(2);
|
||||||
|
for (const ev of events.docs) {
|
||||||
|
expect(ev.get('axis')).toBe('health');
|
||||||
|
expect(ev.get('value')).toBe(1);
|
||||||
|
expect(ev.get('verified')).toBe(true);
|
||||||
|
expect(ev.get('uid')).toBe(UID);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FPI 귀속은 클라이언트 fandomId 가 아니라 서버의 대표 팬덤이다', async () => {
|
||||||
|
await call(fnQuestVerifySteps, { questId: STEPS_QUEST, steps: 1_200, fandomId: 'someone-else' });
|
||||||
|
expect((await db().collection('fandoms/someone-else/fpiEvents').get()).size).toBe(0);
|
||||||
|
expect((await db().collection(`fandoms/${FANDOM}/fpiEvents`).get()).size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('판정에 실패하면 FPI 이벤트도 남기지 않는다', async () => {
|
||||||
|
expect(await codeOf(
|
||||||
|
() => call(fnQuestVerifySteps, { questId: STEPS_QUEST, steps: 800, fandomId: FANDOM }),
|
||||||
|
)).toBe('E-QST-01');
|
||||||
|
const events = await db().collection(`fandoms/${FANDOM}/fpiEvents`).get();
|
||||||
|
expect(events.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('클라이언트가 고른 ID 로 한도를 다시 열 수 없다 (리뷰 재검토)', () => {
|
||||||
|
it('걸음: 없는 퀘스트 · 걸음이 아닌 퀘스트 ID 로는 지급하지 않는다', async () => {
|
||||||
|
expect(await codeOf(() => call(fnQuestVerifySteps, { questId: 'no-such-walk', steps: 5_200 })))
|
||||||
|
.toBe('E-QST-01');
|
||||||
|
expect(await codeOf(() => call(fnQuestVerifySteps, { questId: ATTEND_QUEST, steps: 5_200 })))
|
||||||
|
.toBe('E-QST-01');
|
||||||
|
expect((await db().collection(`users/${UID}/popTransactions`).get()).size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Music Shake: 곡은 퀘스트 카탈로그와 대조하고 하루 한도는 곡이 아니라 퀘스트 단위다', async () => {
|
||||||
|
await db().doc('quests/music-e2e').set({
|
||||||
|
type: 'musicShake', trackId: 't1', totalSegments: 10, dailyLimit: 1,
|
||||||
|
});
|
||||||
|
const shake = (trackId: string, extra: Record<string, unknown> = {}) => call(fnQuestVerifyMusicShake, {
|
||||||
|
questId: 'music-e2e', trackId, totalSegments: 10,
|
||||||
|
judgement: { perfect: 10, good: 0, miss: 0, maxCombo: 10 }, ...extra,
|
||||||
|
});
|
||||||
|
expect(await codeOf(() => shake('t1', { questId: 'no-such-music' }))).toBe('E-QST-01');
|
||||||
|
expect(await codeOf(() => shake('t2'))).toBe('E-QST-01');
|
||||||
|
expect(await codeOf(() => shake('t1', {
|
||||||
|
totalSegments: 1, judgement: { perfect: 1, good: 0, miss: 0, maxCombo: 1 },
|
||||||
|
}))).toBe('E-QST-02');
|
||||||
|
// 동시 요청도 하루 1회. 곡 ID 를 바꿔도 다시 열리지 않는다.
|
||||||
|
await Promise.allSettled([shake('t1'), shake('t1')]);
|
||||||
|
expect(await codeOf(() => shake('t1'))).toBe('E-POP-03');
|
||||||
|
const ledger = await db().collection(`users/${UID}/popTransactions`).get();
|
||||||
|
expect(ledger.size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 오늘의 출석으로 기본 보상 원장 행을 만든다 — 광고 증분의 기준 행이다. */
|
||||||
|
async function baseReward(attendPop = 10): Promise<string> {
|
||||||
|
await db().doc('config/policy').set({ attendPop }, { merge: true });
|
||||||
|
return (await call(fnQuestAttend, { questId: ATTEND_QUEST })).txId as string;
|
||||||
|
}
|
||||||
|
const adRows = async () => (await db().collection(`users/${UID}/popTransactions`)
|
||||||
|
.where('reasonCode', '==', 'AD_REWARD').get()).docs;
|
||||||
|
|
||||||
|
it('광고: 노출 ID 를 바꿔 동시에 불러도 일일 한도를 넘지 않고, 같은 노출 재전송은 첫 결과를 돌려준다', async () => {
|
||||||
|
const baseTxId = await baseReward();
|
||||||
|
await db().doc('config/policy').set({ adDailyLimit: 1 }, { merge: true });
|
||||||
|
await Promise.allSettled([
|
||||||
|
call(fnAdReward, { adImpressionId: `imp-a-${Date.now()}`, baseTxId }),
|
||||||
|
call(fnAdReward, { adImpressionId: `imp-b-${Date.now()}`, baseTxId }),
|
||||||
|
]);
|
||||||
|
const ledger = await adRows();
|
||||||
|
expect(ledger.length).toBe(1);
|
||||||
|
await db().doc('config/policy').set({ adDailyLimit: 5 }, { merge: true });
|
||||||
|
// 성공한 호출의 재전송(응답 유실) — 오류가 아니라 같은 txId 로 성공, 지급은 늘지 않는다.
|
||||||
|
const used = ledger[0]!.get('refPath') as string;
|
||||||
|
const again = await call(fnAdReward, { adImpressionId: used.split('/')[1], baseTxId });
|
||||||
|
expect(again.duplicate).toBe(true);
|
||||||
|
expect(again.txId).toBe(ledger[0]!.id);
|
||||||
|
// 앱은 이 값을 보상 화면에 띄운다 — 재전송에서도 원장 행의 실제 지급액이다.
|
||||||
|
expect(again.rewarded).toBe(ledger[0]!.get('amount'));
|
||||||
|
expect((await adRows()).length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('광고: 운영 Quest 는 기본 x2 증분만 기여 POP 으로 지급한다 (B-03)', async () => {
|
||||||
|
const baseTxId = await baseReward(30);
|
||||||
|
const reply = await call(fnAdReward, { adImpressionId: 'imp-x2', baseTxId });
|
||||||
|
expect(reply.rewarded).toBe(30);
|
||||||
|
const row = only(await adRows());
|
||||||
|
expect(row.get('popType')).toBe('support');
|
||||||
|
expect(row.get('amount')).toBe(30);
|
||||||
|
// 기본 보상은 일반 POP 그대로 — 원장 2건으로 분리된다.
|
||||||
|
const wallet = await db().doc(`users/${UID}/wallet/main`).get();
|
||||||
|
expect(wallet.get('activityBalance')).toBe(30);
|
||||||
|
expect(wallet.get('supportBalance')).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('광고: 지정 Quest 의 x3 은 기본 보상의 2배를 증분으로 준다 (B-03)', async () => {
|
||||||
|
await db().doc(`quests/${ATTEND_QUEST}`).update({ adMultiplier: 3 });
|
||||||
|
const baseTxId = await baseReward(30);
|
||||||
|
expect((await call(fnAdReward, { adImpressionId: 'imp-x3', baseTxId })).rewarded).toBe(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('광고: 사용자 생성 Quest 는 기본 보상과 무관한 고정 10 기여 POP 이다 (C-02 · B-28)', async () => {
|
||||||
|
await db().doc(`quests/${ATTEND_QUEST}`).update({ authorUid: 'creator-1' });
|
||||||
|
const baseTxId = await baseReward(30);
|
||||||
|
expect((await call(fnAdReward, { adImpressionId: 'imp-ugc', baseTxId })).rewarded).toBe(10);
|
||||||
|
const row = only(await adRows());
|
||||||
|
expect(row.get('popType')).toBe('support');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('광고: 기본 보상 한 건에 광고 보상은 한 번 · 퀘스트 보상이 아닌 행에는 붙지 않는다', async () => {
|
||||||
|
const baseTxId = await baseReward();
|
||||||
|
await call(fnAdReward, { adImpressionId: 'imp-once-1', baseTxId });
|
||||||
|
expect(await codeOf(() => call(fnAdReward, { adImpressionId: 'imp-once-2', baseTxId })))
|
||||||
|
.toBe('E-POP-03');
|
||||||
|
expect(await codeOf(() => call(fnAdReward, { adImpressionId: 'imp-none', baseTxId: 'no-such-tx' })))
|
||||||
|
.toBe('E-QST-01');
|
||||||
|
const adTx = only(await adRows()).id;
|
||||||
|
expect(await codeOf(() => call(fnAdReward, { adImpressionId: 'imp-self', baseTxId: adTx })))
|
||||||
|
.toBe('E-QST-01');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('방문 — S-H-PLACE (fn-quest-verify-place)', () => {
|
||||||
|
const inRange = { questId: PLACE_QUEST, placeId: PLACE, lat: 37.5564, lng: 126.9237 };
|
||||||
|
const farAway = { questId: PLACE_QUEST, placeId: PLACE, lat: 37.4979, lng: 127.0276 };
|
||||||
|
|
||||||
|
it('반경 밖이면 거부하고 거리를 증거에 남긴다', async () => {
|
||||||
|
expect(await codeOf(
|
||||||
|
() => call(fnQuestVerifyPlace, { ...farAway, proofKind: 'qr', proof: 'tok-far' }),
|
||||||
|
)).toBe('E-QST-03');
|
||||||
|
const logs = await db().collection(`users/${UID}/activityLogs`).get();
|
||||||
|
expect(only(logs.docs).get('verified')).toBe(false);
|
||||||
|
expect(only(logs.docs).get('value')).toBeGreaterThan(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('모의 위치는 반경 안이어도 비정상으로 끊는다', async () => {
|
||||||
|
expect(await codeOf(
|
||||||
|
() => call(fnQuestVerifyPlace, {
|
||||||
|
...inRange, mocked: true, proofKind: 'qr', proof: 'tok-mock',
|
||||||
|
}),
|
||||||
|
)).toBe('E-QST-02');
|
||||||
|
const logs = await db().collection(`users/${UID}/activityLogs`).get();
|
||||||
|
expect(only(logs.docs).get('rejectReason')).toBe('E-QST-02');
|
||||||
|
const evidence = only(logs.docs).get('evidence') as
|
||||||
|
{ place: { gps: { mocked: boolean } } };
|
||||||
|
expect(evidence.place.gps.mocked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('좌표만으로도 반경 안이면 지급한다 — QR · 사진은 추가 수단(C-03)', async () => {
|
||||||
|
const result = await call(fnQuestVerifyPlace, inRange);
|
||||||
|
expect(result.rewarded).toBe(100);
|
||||||
|
const logs = await db().collection(`users/${UID}/activityLogs`).get();
|
||||||
|
expect(only(logs.docs).get('type')).toBe('placeGps');
|
||||||
|
expect(only(logs.docs).get('verified')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('같은 Quest 는 기본 1회다 — 다른 날에도, 동시 요청에도(C-12)', async () => {
|
||||||
|
const q = { ...inRange, questId: 'place-quest-once' };
|
||||||
|
await Promise.all([call(fnQuestVerifyPlace, q), call(fnQuestVerifyPlace, q)]);
|
||||||
|
expect(await codeOf(() => call(fnQuestVerifyPlace, q))).toBe('E-POP-03');
|
||||||
|
const ledger = await db().collection(`users/${UID}/popTransactions`).get();
|
||||||
|
expect(ledger.size).toBe(1);
|
||||||
|
// 같은 장소의 다른 Quest 는 별개 조건이다.
|
||||||
|
await call(fnQuestVerifyPlace, { ...inRange, questId: 'place-quest-other' });
|
||||||
|
expect((await db().collection(`users/${UID}/popTransactions`).get()).size).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('반복형은 직전 유효 인증부터 간격이 지나야 하고 최대 횟수까지다(C-12)', async () => {
|
||||||
|
const q = { ...inRange, questId: 'place-quest-repeat' };
|
||||||
|
await call(fnQuestVerifyPlace, q);
|
||||||
|
expect(await codeOf(() => call(fnQuestVerifyPlace, q))).toBe('E-POP-03');
|
||||||
|
// 간격이 지난 것처럼 직전 인증 시각을 25시간 앞으로 옮긴다.
|
||||||
|
const progress = db().doc(`users/${UID}/questProgress/place-quest-repeat`);
|
||||||
|
await progress.update({ lastVerifiedAt: Timestamp.fromMillis(Date.now() - 25 * 3_600_000) });
|
||||||
|
await call(fnQuestVerifyPlace, q);
|
||||||
|
await progress.update({ lastVerifiedAt: Timestamp.fromMillis(Date.now() - 25 * 3_600_000) });
|
||||||
|
expect(await codeOf(() => call(fnQuestVerifyPlace, q))).toBe('E-POP-03');
|
||||||
|
expect((await progress.get()).get('count')).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('없는 퀘스트 · 다른 장소의 퀘스트 · 다른 팬덤 퀘스트로는 지급하지 않는다(리뷰 B2 · C-16)', async () => {
|
||||||
|
expect(await codeOf(() => call(fnQuestVerifyPlace, { ...inRange, questId: 'no-such-quest' })))
|
||||||
|
.toBe('E-QST-01');
|
||||||
|
await db().doc('quests/place-elsewhere').set({ type: 'location', placeId: 'other-place' });
|
||||||
|
expect(await codeOf(() => call(fnQuestVerifyPlace, { ...inRange, questId: 'place-elsewhere' })))
|
||||||
|
.toBe('E-QST-03');
|
||||||
|
await db().doc('quests/place-other-fandom').set({ type: 'location', placeId: PLACE, fandomId: 'another' });
|
||||||
|
expect(await codeOf(() => call(fnQuestVerifyPlace, { ...inRange, questId: 'place-other-fandom' })))
|
||||||
|
.toBe('E-PERM-01');
|
||||||
|
// 대표 팬덤 Quest 는 허용한다.
|
||||||
|
await db().doc('quests/place-my-fandom').set({ type: 'location', placeId: PLACE, fandomId: FANDOM });
|
||||||
|
await call(fnQuestVerifyPlace, { ...inRange, questId: 'place-my-fandom' });
|
||||||
|
expect((await db().collection(`users/${UID}/popTransactions`).get()).size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('반경 안 + QR 이면 장소 보상값으로 지급하고 인증 재사용을 막는다', async () => {
|
||||||
|
const result = await call(
|
||||||
|
fnQuestVerifyPlace, { ...inRange, proofKind: 'qr', proof: 'tok-ok' },
|
||||||
|
);
|
||||||
|
expect(result.rewarded).toBe(100);
|
||||||
|
expect(result.distanceM).toBeLessThanOrEqual(100);
|
||||||
|
|
||||||
|
const logs = await db().collection(`users/${UID}/activityLogs`).get();
|
||||||
|
expect(only(logs.docs).get('verified')).toBe(true);
|
||||||
|
|
||||||
|
// 같은 QR 토큰을 다른 Quest 에 다시 쓰는 시도.
|
||||||
|
expect(await codeOf(
|
||||||
|
() => call(fnQuestVerifyPlace, { ...inRange, questId: 'place-quest-reuse', proofKind: 'qr', proof: 'tok-ok' }),
|
||||||
|
)).toBe('E-POP-03');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('검증 활동 집계 규칙 — Quest 완료 1건 · 하루 1회 (Campaign 개설자격 A안)', () => {
|
||||||
|
const count = () => verifiedQuestCountOf(UID);
|
||||||
|
|
||||||
|
it('출석은 한도 2회여도 하루 1회 · 걸음은 구간 수와 무관하게 하루 1회', async () => {
|
||||||
|
await db().doc(`quests/${ATTEND_QUEST}`).set({ dailyLimit: 2 }, { merge: true });
|
||||||
|
await call(fnQuestAttend, { questId: ATTEND_QUEST });
|
||||||
|
await call(fnQuestAttend, { questId: ATTEND_QUEST });
|
||||||
|
expect(await count()).toBe(1);
|
||||||
|
await call(fnQuestVerifySteps, { questId: STEPS_QUEST, steps: 16_000 }); // 네 구간
|
||||||
|
expect(await count()).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Music Shake 는 완주마다 1회 · 방문은 1회', async () => {
|
||||||
|
await db().doc('quests/music-count').set({ type: 'musicShake', trackId: 't1', totalSegments: 10, dailyLimit: 2 });
|
||||||
|
const shake = () => call(fnQuestVerifyMusicShake, {
|
||||||
|
questId: 'music-count', trackId: 't1', totalSegments: 10,
|
||||||
|
judgement: { perfect: 10, good: 0, miss: 0, maxCombo: 10 },
|
||||||
|
});
|
||||||
|
await shake();
|
||||||
|
await shake();
|
||||||
|
expect(await count()).toBe(2);
|
||||||
|
await call(fnQuestVerifyPlace, { questId: PLACE_QUEST, placeId: PLACE, lat: 37.5564, lng: 126.9237 });
|
||||||
|
expect(await count()).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('무효 처리는 원장 행으로 찾아 한 번만 빠진다 · 운영자만', async () => {
|
||||||
|
const { txId } = await call(fnQuestAttend, { questId: ATTEND_QUEST });
|
||||||
|
const run = (token: Record<string, unknown>) => fnAdminCompletionVoid.run(
|
||||||
|
{ data: { uid: UID, txId, reason: '허위 출석' }, auth: { uid: 'ops-1', token } } as never) as Promise<Reply>;
|
||||||
|
expect(await codeOf(() => run({}))).toBe('E-PERM-01');
|
||||||
|
expect((await run({ role: 'ops' })).voided).toBe(1);
|
||||||
|
expect((await run({ role: 'ops' })).voided).toBe(0);
|
||||||
|
expect(await count()).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('FPI 원천 = 인정 활동 (C-34 · C-35)', () => {
|
||||||
|
const fpiOf = async () => (await db().collection(`fandoms/${FANDOM}/fpiEvents`).get()).docs;
|
||||||
|
|
||||||
|
it('출석은 COMMUNITY 축 1 단위이고 보상 750 · 1 POP 이 같은 점수다', async () => {
|
||||||
|
await db().doc('config/policy').set({ attendPop: 750 }, { merge: true });
|
||||||
|
await call(fnQuestAttend, { questId: ATTEND_QUEST });
|
||||||
|
await db().doc(`quests/${ATTEND_QUEST}`).update({ dailyLimit: 2 });
|
||||||
|
await db().doc('config/policy').set({ attendPop: 1 }, { merge: true });
|
||||||
|
await call(fnQuestAttend, { questId: ATTEND_QUEST });
|
||||||
|
|
||||||
|
const events = await fpiOf();
|
||||||
|
expect(events.map((e) => [e.get('axis'), e.get('value')])).toEqual([['community', 1], ['community', 1]]);
|
||||||
|
// 지급 POP 필드가 FPI 원천에 없다(G-05).
|
||||||
|
expect(events.every((e) => e.get('amount') === undefined && e.get('pop') === undefined)).toBe(true);
|
||||||
|
// 원장에는 두 보상액이 그대로 남는다 — FPI 와 분리.
|
||||||
|
const ledger = await db().collection(`users/${UID}/popTransactions`).get();
|
||||||
|
expect(ledger.docs.map((d) => d.get('amount')).sort((a, b) => a - b)).toEqual([1, 750]);
|
||||||
|
// User EXP 도 금액이 아니라 인정 활동 건수다 — 750 POP 과 1 POP 출석이 각각 100(C-18).
|
||||||
|
expect((await db().doc(`users/${UID}`).get()).get('userExp')).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('현장 방문은 COMMUNITY 축 1 단위다(장소 보상 100 POP 과 무관)', async () => {
|
||||||
|
await call(fnQuestVerifyPlace, { questId: PLACE_QUEST, placeId: PLACE, lat: 37.5564, lng: 126.9237, proofKind: 'qr', proof: 'tok-fpi' });
|
||||||
|
const events = await fpiOf();
|
||||||
|
expect(events.map((e) => [e.get('axis'), e.get('value')])).toEqual([['community', 1]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('보상이 0 이어도 인정된 기여 활동은 FPI 에 반영된다', async () => {
|
||||||
|
// Zap 은 POP 을 쓰는 기여다. 지급 보상은 없고, 누른 횟수가 CONTRIBUTION 단위다.
|
||||||
|
await grantPop({ uid: UID, popType: 'activity', amount: 30, reasonCode: 'ADMIN_GRANT', idempotencyKey: `seed-zap:${UID}` });
|
||||||
|
const { spendPop } = await import('../src/ledger/ledger.js');
|
||||||
|
await spendPop({ uid: UID, popType: 'activity', amount: 30, reasonCode: 'ZAP_CONTRIBUTION',
|
||||||
|
fandomId: FANDOM, count: 3, idempotencyKey: `zap:${UID}:1` });
|
||||||
|
const events = await fpiOf();
|
||||||
|
expect(events.map((e) => [e.get('axis'), e.get('value')])).toEqual([['contribution', 3]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POP → 행성 반영 (별첨 1 ② 완료 시나리오 1)', () => {
|
||||||
|
it('출석으로 받은 POP 을 캠페인에 쓰면 화면이 읽는 필드가 함께 움직인다', async () => {
|
||||||
|
// 0. 화면이 읽을 자리 — 팬덤·멤버 문서. 성운 생성·행성 탄생 경로는 planet_create.test.ts.
|
||||||
|
await db().doc(`fandoms/${FANDOM}`).set({
|
||||||
|
name: '퀘스트 루프 팬덤', fpiScore: 0, memberCount: 1, growthPop: 0,
|
||||||
|
}, { merge: true });
|
||||||
|
await db().doc(`fandoms/${FANDOM}/members/${UID}`).set({ uid: UID, fandomId: FANDOM, contributedPop: 0 });
|
||||||
|
// 다음 단계 문턱은 마스터 데이터다(설계서 §2.3). 값은 발주사 수치표 v1.4
|
||||||
|
// 「Stage Summary」의 누계이며, 운영에서는 `firebase/seed-demo.py` 가 같은 표를 넣는다.
|
||||||
|
await db().doc('planetStages/2').set({
|
||||||
|
stageNo: 2, levelFrom: 21, levelTo: 40, requiredPop: 100_000, requiredSfpi: 15_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1. 활동 보상 — 무료 POP 지급.
|
||||||
|
await call(fnQuestAttend, { questId: ATTEND_QUEST });
|
||||||
|
// 후원할 금액을 만든다. 지갑만 올리지 않고 원장을 지난다 — 3자 대조가 깨지면
|
||||||
|
// 차감이 E-POP-02 로 막힌다.
|
||||||
|
await grantPop({
|
||||||
|
// 후원은 기여 POP 만 쓴다(원장 B-33) — 기여 잔액을 만든다.
|
||||||
|
uid: UID, popType: 'support', amount: 3_000,
|
||||||
|
reasonCode: 'ADMIN_GRANT', idempotencyKey: `seed:${UID}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 받은 POP 을 캠페인에 쓴다 — 기여는 **쓸 때** 집계된다(설계서 §3.6).
|
||||||
|
await db().doc('campaigns/campaign-quest-e2e').set({
|
||||||
|
fandomId: FANDOM, status: 'funding', goalPop: 10_000, raisedPop: 0,
|
||||||
|
endsAt: Timestamp.fromMillis(Date.now() + 86_400_000),
|
||||||
|
createdAt: FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
await call(fnCampaignFund, {
|
||||||
|
campaignId: 'campaign-quest-e2e', amountPop: 3_000,
|
||||||
|
clientSeq: '1',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. HOME·MY 가 읽는 필드가 실제로 움직였는가.
|
||||||
|
const user = await db().doc(`users/${UID}`).get();
|
||||||
|
expect(user.get('totalContributedPop')).toBe(3_000);
|
||||||
|
// EXP 는 원장이 움직일 때 함께 갱신된다 — 출석 보상 10 + 후원 3,000(C-18 단순안).
|
||||||
|
expect(user.get('tier')).toBeUndefined();
|
||||||
|
// EXP 는 출석(인정 활동 1건)만 — 후원은 EXP 대상이 아니다(리뷰 B1).
|
||||||
|
expect(user.get('userExp')).toBe(100);
|
||||||
|
expect(user.get('userLevel')).toBe(1);
|
||||||
|
const member = await db().doc(`fandoms/${FANDOM}/members/${UID}`).get();
|
||||||
|
expect(member.get('contributedPop')).toBe(3_000);
|
||||||
|
// 보상 10 + 후원 3,000. 관리자 지급은 성장에 포함하지 않는다.
|
||||||
|
expect((await db().doc(`fandoms/${FANDOM}`).get()).get('growthPop')).toBe(3_010);
|
||||||
|
expect((await db().collection(`users/${UID}/growthEvents`).get()).size).toBe(2);
|
||||||
|
|
||||||
|
// 4. 잔액은 출석 보상 10 만 남는다. 활동 POP 과 기여 집계가 섞이지 않는다.
|
||||||
|
const wallet = await db().doc(`users/${UID}/wallet/main`).get();
|
||||||
|
expect(wallet.get('activityBalance')).toBe(10);
|
||||||
|
|
||||||
|
// 5. 앱이 이 상태를 읽어 HOME·MY 에 그리는지는 기기에서 잰다 —
|
||||||
|
// `integration_test/pop_loop_emulator_test.dart` 가 같은 에뮬레이터를 물고
|
||||||
|
// 여기서 남긴 문서를 그대로 읽는다. 그래서 이 테스트는 상태를 지우지 않는다.
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { getFirestore, GeoPoint, Timestamp } from 'firebase-admin/firestore';
|
||||||
|
|
||||||
|
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
|
||||||
|
import { DEFAULT_POLICY } from '../src/core/config.js';
|
||||||
|
import { grantPop } from '../src/ledger/ledger.js';
|
||||||
|
import { verifiedQuestCountOf } from '../src/ledger/fpi.js';
|
||||||
|
import { fnAdReward } from '../src/quest/quest.js';
|
||||||
|
import { fnQuestVerifyPlace } from '../src/quest/verify.js';
|
||||||
|
import {
|
||||||
|
isPrivateAddress, linkPreview, parseHtmlMeta, parsePublicUrl,
|
||||||
|
} from '../src/quest/link_preview.js';
|
||||||
|
import {
|
||||||
|
DEFAULT_QUEST_CATALOG, DRAFT_TTL_MS, createQuest, editQuest, endsAtOf, feeOf, fnQuestEdit,
|
||||||
|
reviewSubmission, settleQuestPool, submitQuest, sweepDrafts,
|
||||||
|
} from '../src/quest/ugc.js';
|
||||||
|
import { LINK_PREVIEW_PER_MINUTE, takeLinkPreviewSlot } from '../src/quest/link_preview.js';
|
||||||
|
import { spendSplit } from '../src/ledger/ledger.js';
|
||||||
|
import { txIdOf } from '../src/core/ids.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 생성 Quest — QA 체크리스트 v1.2 시험 사례 3-16 ~ 3-28(C-01 ~ C-12 · C-16 · C-17 · G-01 · G-03 · G-05).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CREATOR = 'test-ugc-creator';
|
||||||
|
const PLAYER = 'test-ugc-player';
|
||||||
|
const FAN = 'f-ugc-a';
|
||||||
|
const OTHER = 'f-ugc-b';
|
||||||
|
const PLACE = 'place-ugc';
|
||||||
|
const BAD_HOST = 'https://quest-ugc.invalid/page'; // 형식은 유효 · 이름 풀이 실패 → preview_failed
|
||||||
|
|
||||||
|
const db = () => getFirestore();
|
||||||
|
const HOUR = 3_600_000;
|
||||||
|
|
||||||
|
async function codeOf(run: () => Promise<unknown>): Promise<string> {
|
||||||
|
try {
|
||||||
|
await run();
|
||||||
|
} catch (error) {
|
||||||
|
const e = error as { details?: { code?: string }; code?: string };
|
||||||
|
return e.details?.code ?? e.code ?? 'unknown';
|
||||||
|
}
|
||||||
|
throw new Error('실패해야 하는 호출이 성공했습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const call = (fn: { run(request: never): unknown }, uid: string, data: Record<string, unknown>) =>
|
||||||
|
fn.run({ data, auth: { uid, token: {} } } as unknown as never) as Promise<Record<string, unknown>>;
|
||||||
|
|
||||||
|
async function user(uid: string, fandom: string, ageHours = 48) {
|
||||||
|
await db().doc(`users/${uid}`).set({
|
||||||
|
fandomId: fandom, primaryFandomId: fandom, nickname: uid, status: 'active',
|
||||||
|
createdAt: Timestamp.fromMillis(Date.now() - ageHours * HOUR),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wallet(uid: string) {
|
||||||
|
const w = await db().doc(`users/${uid}/wallet/main`).get();
|
||||||
|
return { general: (w.get('activityBalance') as number) ?? 0, contribution: (w.get('supportBalance') as number) ?? 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
let seq = 0;
|
||||||
|
const create = (over: Record<string, unknown> = {}) => createQuest({
|
||||||
|
uid: CREATOR, clientSeq: `s${++seq}`, activityKey: 'vote', title: '투표 인증', description: '인증샷',
|
||||||
|
rewardPop: 5, capacity: 10, utcOffsetMinutes: 540, url: BAD_HOST, manualTitle: '뮤직어워드 투표',
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const quest = async (id: string) => (await db().doc(`quests/${id}`).get()).data()!;
|
||||||
|
|
||||||
|
beforeAll(() => { bootAdmin(); });
|
||||||
|
afterAll(async () => { await shutdownAdmin(); });
|
||||||
|
beforeEach(async () => {
|
||||||
|
for (const uid of [CREATOR, PLAYER]) await wipeUser(uid);
|
||||||
|
for (const c of ['quests', 'questSubmissions', 'posts', 'adRewardLogs', 'questPostUses']) {
|
||||||
|
await db().recursiveDelete(db().collection(c));
|
||||||
|
}
|
||||||
|
await db().recursiveDelete(db().collection(`users/${CREATOR}/rateLimits`));
|
||||||
|
await db().doc('config/questCatalog').delete();
|
||||||
|
await user(CREATOR, FAN);
|
||||||
|
await user(PLAYER, FAN);
|
||||||
|
// 생성 자격(B-29) — 검증 활동 1회 + 예치 잔액(일반 40 · 기여 100).
|
||||||
|
await grantPop({ uid: CREATOR, popType: 'activity', amount: 40, reasonCode: 'QUEST_ATTEND', idempotencyKey: 'ugc:seed:g', growthSource: 'quest_reward' });
|
||||||
|
await db().doc(`users/${CREATOR}/verifiedCompletions/seed_2026-09-01`).set({ questId: 'seed' });
|
||||||
|
await grantPop({ uid: CREATOR, popType: 'support', amount: 100, reasonCode: 'IAP_PURCHASE', idempotencyKey: 'ugc:seed:c' });
|
||||||
|
await db().doc(`places/${PLACE}`).set({ name: '홍대', geo: new GeoPoint(37.55, 126.92), radiusM: 100 });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('순수 규칙', () => {
|
||||||
|
const policy = DEFAULT_POLICY;
|
||||||
|
it('C-06 기간 — 기본 +24h · 생성자 시간대 23:59:59 · 31일째 · 1시간 미만 거부', () => {
|
||||||
|
const at = new Date('2026-09-28T14:30:00Z'); // KST 23:30
|
||||||
|
expect(endsAtOf(at, undefined, 540, policy).getTime()).toBe(at.getTime() + 24 * HOUR);
|
||||||
|
expect(() => endsAtOf(at, '2026-09-28', 540, policy)).toThrow(/최소 1시간/);
|
||||||
|
expect(endsAtOf(at, '2026-09-29', 540, policy).toISOString()).toBe('2026-09-29T14:59:59.000Z');
|
||||||
|
expect(endsAtOf(at, '2026-10-27', 540, policy).toISOString()).toBe('2026-10-27T14:59:59.000Z'); // 30일째
|
||||||
|
expect(() => endsAtOf(at, '2026-10-28', 540, policy)).toThrow(/30일/); // 31일째
|
||||||
|
expect(() => endsAtOf(at, '2026-09-27', 540, policy)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('B-30 수수료 — Pool 50 → 10 · 500 → 50 · 무보상(Pool 0)은 0(Q-UQ-02 A안) · 설정을 켜면 최소 10', () => {
|
||||||
|
expect(feeOf(50, policy)).toBe(10);
|
||||||
|
expect(feeOf(500, policy)).toBe(50);
|
||||||
|
expect(feeOf(0, policy)).toBe(0);
|
||||||
|
expect(feeOf(0, { ...policy, ugcFeeOnNoReward: true })).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('C-01 기본 카탈로그 — 4 카테고리 ↔ 10 활동 · ugcAllowed 초기값', () => {
|
||||||
|
const map = Object.fromEntries(DEFAULT_QUEST_CATALOG.categories.map((c) => [c.key,
|
||||||
|
DEFAULT_QUEST_CATALOG.activities.filter((a) => a.categoryKey === c.key).map((a) => a.key)]));
|
||||||
|
expect(map).toEqual({
|
||||||
|
health: ['walk', 'shake'], planet: ['streaming', 'youtube', 'vote', 'sns'],
|
||||||
|
community: ['community'], location: ['location', 'qr', 'nfc'],
|
||||||
|
});
|
||||||
|
const open = DEFAULT_QUEST_CATALOG.activities.filter((a) => a.ugcAllowed).map((a) => a.key);
|
||||||
|
expect(open).toEqual(['vote', 'sns', 'community', 'location']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('C-05 SSRF — http(s) 공개 주소만', async () => {
|
||||||
|
expect(parsePublicUrl('https://youtu.be/abc')).not.toBeNull();
|
||||||
|
for (const bad of ['ftp://x.com', 'javascript:alert(1)', 'http://127.0.0.1/', 'http://localhost/',
|
||||||
|
'http://10.0.0.1', 'http://[::1]/', 'http://u:p@x.com', 'http://x.com:8080/', 'not a url']) {
|
||||||
|
expect(parsePublicUrl(bad)).toBeNull();
|
||||||
|
}
|
||||||
|
for (const ip of ['169.254.169.254', '192.168.1.1', '172.20.0.1', '100.64.0.1', '::ffff:127.0.0.1', 'fd00::1', 'fe80::1']) {
|
||||||
|
expect(isPrivateAddress(ip)).toBe(true);
|
||||||
|
}
|
||||||
|
expect(isPrivateAddress('8.8.8.8')).toBe(false);
|
||||||
|
expect((await linkPreview('http://169.254.169.254/latest')).status).toBe('invalid');
|
||||||
|
expect((await linkPreview(BAD_HOST)).status).toBe('preview_failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('C-05 메타 파싱은 악성 입력에서도 선형 — ReDoS', () => {
|
||||||
|
const t0 = performance.now();
|
||||||
|
parseHtmlMeta('<meta'.repeat(100_000));
|
||||||
|
parseHtmlMeta(`<meta property="og:title" content="${'a'.repeat(200_000)}`);
|
||||||
|
parseHtmlMeta(`<title>${'<title>'.repeat(50_000)}`);
|
||||||
|
expect(performance.now() - t0).toBeLessThan(200);
|
||||||
|
// </head> 뒤 · 64KB 뒤는 보지 않는다.
|
||||||
|
expect(parseHtmlMeta('<head></head><meta property="og:title" content="late">').title).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('LOW 추가 차단 대역 · 링크 미리보기 분당 상한', async () => {
|
||||||
|
for (const ip of ['198.51.100.7', '203.0.113.9', '2002:c000:0204::1']) expect(isPrivateAddress(ip)).toBe(true);
|
||||||
|
for (let i = 0; i < LINK_PREVIEW_PER_MINUTE; i += 1) await takeLinkPreviewSlot(CREATOR);
|
||||||
|
expect(await codeOf(() => takeLinkPreviewSlot(CREATOR))).toBe('resource-exhausted');
|
||||||
|
await takeLinkPreviewSlot(CREATOR, Date.now() + 61_000); // 다음 창
|
||||||
|
});
|
||||||
|
|
||||||
|
it('C-05 OG 메타 파싱', () => {
|
||||||
|
const meta = parseHtmlMeta('<meta property="og:title" content="A & B"><meta content="https://img.x/1.png" property="og:image"><meta property="og:site_name" content="X">');
|
||||||
|
expect(meta).toEqual({ title: 'A & B', thumbnail: 'https://img.x/1.png', provider: 'X' });
|
||||||
|
expect(parseHtmlMeta('<title>Only</title><meta property="og:image" content="http://10.0.0.1/a.png">'))
|
||||||
|
.toEqual({ title: 'Only', thumbnail: undefined, provider: undefined });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('생성', () => {
|
||||||
|
it('3-20 무효 URL 저장 0건 · 조회 실패는 제목 입력 시 기본 카드로 저장', async () => {
|
||||||
|
expect(await codeOf(() => create({ url: 'ftp://nope' }))).toBe('invalid-argument');
|
||||||
|
expect(await codeOf(() => create({ manualTitle: undefined }))).toBe('invalid-argument');
|
||||||
|
expect((await db().collection('quests').get()).size).toBe(0);
|
||||||
|
|
||||||
|
const { questId } = await create();
|
||||||
|
const q = await quest(questId);
|
||||||
|
expect(q.status).toBe('published');
|
||||||
|
expect(q.link).toMatchObject({ status: 'preview_failed', title: '뮤직어워드 투표', url: BAD_HOST });
|
||||||
|
expect(q.userDescription).toBe('인증샷');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('B-30 · B-06 예치 50 + 수수료 10 — 일반 우선 · 성장 원천 아님', async () => {
|
||||||
|
const { questId } = await create();
|
||||||
|
expect(await wallet(CREATOR)).toEqual({ general: 0, contribution: 80 });
|
||||||
|
const q = await quest(questId);
|
||||||
|
expect(q.pool).toMatchObject({ deposited: 50, depositSupport: 10, paid: 0, reserved: 0, returned: 0 });
|
||||||
|
expect(q.feePop).toBe(10);
|
||||||
|
expect(q.fandomId).toBe(FAN);
|
||||||
|
const growth = await db().collection(`users/${CREATOR}/growthEvents`).get();
|
||||||
|
expect(growth.size).toBe(1); // 시드의 quest_reward 1건뿐
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Q-UQ-02 무보상 Quest 는 수수료 없이 공개 · 생성 자격은 보상 없는 Quest 완료도 센다', async () => {
|
||||||
|
await db().doc('config/questCatalog').set({
|
||||||
|
...DEFAULT_QUEST_CATALOG,
|
||||||
|
activities: DEFAULT_QUEST_CATALOG.activities.map((a) => (a.key === 'youtube' ? { ...a, ugcAllowed: true } : a)),
|
||||||
|
});
|
||||||
|
const make = () => createQuest({
|
||||||
|
uid: PLAYER, clientSeq: `nr${++seq}`, activityKey: 'youtube', title: '뮤비 시청', description: '',
|
||||||
|
rewardPop: 0, capacity: 0, utcOffsetMinutes: 540, url: BAD_HOST, manualTitle: '뮤비',
|
||||||
|
});
|
||||||
|
// 보상 원장도 검증 활동도 없으면 자격 미달.
|
||||||
|
expect(await codeOf(make)).toBe('E-PERM-01');
|
||||||
|
await db().doc(`users/${PLAYER}/verifiedCompletions/seed_2026-09-01`).set({ questId: 'seed' });
|
||||||
|
const { questId, status } = await make();
|
||||||
|
expect(status).toBe('published');
|
||||||
|
expect(await quest(questId)).toMatchObject({ rewardMode: 'none', feePop: 0, feeTxIds: [] });
|
||||||
|
expect((await db().collection(`users/${PLAYER}/popTransactions`).get()).size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('보상 없는 Quest 는 1인 하루 ugcFreeQuestDailyCap(기본 3)개까지 · E-QST-04', async () => {
|
||||||
|
await db().doc('config/questCatalog').set({
|
||||||
|
...DEFAULT_QUEST_CATALOG,
|
||||||
|
activities: DEFAULT_QUEST_CATALOG.activities.map((a) => (a.key === 'youtube' ? { ...a, ugcAllowed: true } : a)),
|
||||||
|
});
|
||||||
|
const make = () => createQuest({
|
||||||
|
uid: CREATOR, clientSeq: `cap${++seq}`, activityKey: 'youtube', title: '뮤비', description: '',
|
||||||
|
rewardPop: 0, capacity: 0, utcOffsetMinutes: 540, url: BAD_HOST, manualTitle: '뮤비',
|
||||||
|
});
|
||||||
|
for (let i = 0; i < 3; i++) expect((await make()).status).toBe('published');
|
||||||
|
const err = await make().then(() => null, (e: unknown) => e);
|
||||||
|
expect((err as { details?: Record<string, unknown> }).details).toEqual({ code: 'E-QST-04', cap: 3 });
|
||||||
|
// 보상형은 상한과 무관하다.
|
||||||
|
expect((await create()).status).toBe('published');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('3-16 · 3-18 카탈로그 — ugcAllowed false · 위치 무관 QR 거부 · Admin 변경 즉시 반영', async () => {
|
||||||
|
expect(await codeOf(() => create({ activityKey: 'walk' }))).toBe('E-PERM-01');
|
||||||
|
expect(await codeOf(() => create({ activityKey: 'qr' }))).toBe('E-PERM-01');
|
||||||
|
await db().doc('config/questCatalog').set({
|
||||||
|
...DEFAULT_QUEST_CATALOG,
|
||||||
|
activities: DEFAULT_QUEST_CATALOG.activities.map((a) => (a.key === 'vote' ? { ...a, ugcAllowed: false } : a)),
|
||||||
|
});
|
||||||
|
expect(await codeOf(() => create())).toBe('E-PERM-01');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('MEDIUM 3 재시도는 Draft 에 굳힌 값으로만 공개한다', async () => {
|
||||||
|
const seqKey = 'retry-1';
|
||||||
|
const ref = db().doc(`quests/${txIdOf(`quest:${CREATOR}:${seqKey}`)}`);
|
||||||
|
// 예치까지 끝나고 공개 전에 끊긴 Draft 를 흉내 낸다.
|
||||||
|
const parts = await spendSplit({ uid: CREATOR, amount: 50, reasonCode: 'QUEST_POOL_DEPOSIT', idempotencyKey: `questPool:${CREATOR}:${seqKey}` });
|
||||||
|
await ref.set({
|
||||||
|
type: 'vote', authorUid: CREATOR, fandomId: FAN, rewardPop: 5, capacity: 10, feePop: 10,
|
||||||
|
endsAt: Timestamp.fromMillis(Date.now() + 5 * HOUR), status: 'draft', createdAt: Timestamp.now(),
|
||||||
|
depositTxIds: parts.map((p) => p.txId),
|
||||||
|
pool: { deposited: 50, depositSupport: 10, paid: 0, reserved: 0, returned: 0, returnedSupport: 0 },
|
||||||
|
});
|
||||||
|
const res = await createQuest({
|
||||||
|
uid: CREATOR, clientSeq: seqKey, activityKey: 'vote', title: '딴 제목', description: '',
|
||||||
|
rewardPop: 1, capacity: 1, utcOffsetMinutes: 540, url: BAD_HOST, manualTitle: 'x', endDate: '2099-01-01',
|
||||||
|
});
|
||||||
|
expect(res.status).toBe('published');
|
||||||
|
const q = await quest(ref.id);
|
||||||
|
expect(q).toMatchObject({ status: 'published', rewardPop: 5, capacity: 10, feePop: 10 });
|
||||||
|
expect((q.endsAt as Timestamp).toMillis()).toBeLessThan(Date.now() + 6 * HOUR);
|
||||||
|
expect(await wallet(CREATOR)).toEqual({ general: 0, contribution: 80 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('MEDIUM 4 15분 넘은 Draft 는 예치를 원타입으로 돌려주고 abandoned', async () => {
|
||||||
|
const ref = db().doc('quests/stale-draft');
|
||||||
|
const parts = await spendSplit({ uid: CREATOR, amount: 60, reasonCode: 'QUEST_POOL_DEPOSIT', idempotencyKey: 'stale:1' });
|
||||||
|
await ref.set({
|
||||||
|
authorUid: CREATOR, status: 'draft', depositTxIds: parts.map((p) => p.txId),
|
||||||
|
createdAt: Timestamp.fromMillis(Date.now() - DRAFT_TTL_MS - 1_000),
|
||||||
|
});
|
||||||
|
await db().doc('quests/fresh-draft').set({ authorUid: CREATOR, status: 'draft', depositTxIds: [], createdAt: Timestamp.now() });
|
||||||
|
expect(await wallet(CREATOR)).toEqual({ general: 0, contribution: 80 });
|
||||||
|
expect(await sweepDrafts()).toBe(1);
|
||||||
|
expect(await quest('stale-draft')).toMatchObject({ status: 'abandoned', poolRefund: 'done' });
|
||||||
|
expect((await quest('fresh-draft')).status).toBe('draft');
|
||||||
|
expect(await wallet(CREATOR)).toEqual({ general: 40, contribution: 100 });
|
||||||
|
expect(await sweepDrafts()).toBe(0); // 두 번 돌려주지 않는다
|
||||||
|
expect(await wallet(CREATOR)).toEqual({ general: 40, contribution: 100 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('MEDIUM 7 시간대는 UTC−12 ~ UTC+14', async () => {
|
||||||
|
expect(await codeOf(() => create({ utcOffsetMinutes: -13 * 60 }))).toBe('invalid-argument');
|
||||||
|
expect(await codeOf(() => create({ utcOffsetMinutes: 15 * 60 }))).toBe('invalid-argument');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('B-29 가입 24h 미만 · 잔액 부족은 예치도 없다', async () => {
|
||||||
|
await user(CREATOR, FAN, 2);
|
||||||
|
expect(await codeOf(() => create())).toBe('E-PERM-01');
|
||||||
|
await user(CREATOR, FAN);
|
||||||
|
expect(await codeOf(() => create({ rewardPop: 50, capacity: 10 }))).toBe('E-POP-01');
|
||||||
|
expect(await wallet(CREATOR)).toEqual({ general: 40, contribution: 100 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('3-23 공개 후 잠금 필드 거부 · 제목 · 설명 이력', async () => {
|
||||||
|
const { questId } = await create();
|
||||||
|
expect(await codeOf(() => call(fnQuestEdit, CREATOR, { questId, rewardPop: 1 }))).toBe('E-PERM-01');
|
||||||
|
expect(await codeOf(() => call(fnQuestEdit, CREATOR, { questId, endsAt: 1 }))).toBe('E-PERM-01');
|
||||||
|
expect(await codeOf(() => editQuest(PLAYER, questId, { title: 'x' }))).toBe('E-PERM-01');
|
||||||
|
expect((await editQuest(CREATOR, questId, { title: '투표 인증!', description: '인증샷 1장' })).edited)
|
||||||
|
.toEqual(['title', 'description']);
|
||||||
|
const edits = await db().collection(`quests/${questId}/edits`).get();
|
||||||
|
expect(edits.docs.map((d) => [d.get('field'), d.get('before'), d.get('after')]).sort())
|
||||||
|
.toEqual([['description', '인증샷', '인증샷 1장'], ['title', '투표 인증', '투표 인증!']]);
|
||||||
|
expect((await quest(questId)).rewardPop).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('참여 · 검토 · 종료', () => {
|
||||||
|
it('3-27 대표 팬덤이 아닌 팬덤 Quest 거부 · 본인 Quest 거부', async () => {
|
||||||
|
const { questId } = await create();
|
||||||
|
await user(PLAYER, OTHER);
|
||||||
|
expect(await codeOf(() => submitQuest(PLAYER, questId, { text: 'done' }))).toBe('E-PERM-01');
|
||||||
|
expect(await codeOf(() => submitQuest(CREATOR, questId, { text: 'done' }))).toBe('E-PERM-01');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('3-28 · 3-17 제출 → 대표 팬덤 변경 → 승인 지급 · 4요소 보존 · 광고 보상은 회사 재원', async () => {
|
||||||
|
const { questId } = await create();
|
||||||
|
await submitQuest(PLAYER, questId, { url: 'https://x.com/p/1', text: '투표함' });
|
||||||
|
expect((await quest(questId)).pool.reserved).toBe(5);
|
||||||
|
await user(PLAYER, OTHER); // 대표 팬덤 변경(C-17)
|
||||||
|
const subId = `${questId}_${PLAYER}`;
|
||||||
|
expect((await db().doc(`questSubmissions/${subId}`).get()).get('status')).toBe('submitted');
|
||||||
|
|
||||||
|
expect(await codeOf(() => reviewSubmission({ actorUid: PLAYER, isOps: false, submissionId: subId, action: 'approve' })))
|
||||||
|
.toBe('E-PERM-01');
|
||||||
|
const paid = await reviewSubmission({ actorUid: CREATOR, isOps: false, submissionId: subId, action: 'approve' });
|
||||||
|
expect(paid.status).toBe('paid');
|
||||||
|
const sub = (await db().doc(`questSubmissions/${subId}`).get()).data()!;
|
||||||
|
expect(sub).toMatchObject({ status: 'paid', payoutStatus: 'succeeded', fandomIdAtSubmit: FAN });
|
||||||
|
expect(sub.submittedAt).toBeInstanceOf(Timestamp);
|
||||||
|
expect(sub.evidence).toHaveLength(1);
|
||||||
|
expect(sub.reviewLog.map((r: { action: string }) => r.action)).toEqual(['submit', 'approve']);
|
||||||
|
expect((await wallet(PLAYER)).general).toBe(5);
|
||||||
|
// 운영 검토 통과 = 검증 활동 1회(Campaign 개설자격 A안).
|
||||||
|
expect(await verifiedQuestCountOf(PLAYER)).toBe(1);
|
||||||
|
|
||||||
|
const ad = await call(fnAdReward, PLAYER, { adImpressionId: 'imp-ugc-1', baseTxId: paid.txId });
|
||||||
|
expect(ad.rewarded).toBe(DEFAULT_POLICY.adRewardPop);
|
||||||
|
const q = await quest(questId);
|
||||||
|
expect(q.pool).toMatchObject({ paid: 5, reserved: 0 }); // 광고 보상은 Pool 무관
|
||||||
|
});
|
||||||
|
|
||||||
|
it('3-24 · G-01 반려 — 사유 · 재제출 가능 시 예약 유지 · 보완은 같은 건 · 최종 반려 시 해제', async () => {
|
||||||
|
const { questId } = await create();
|
||||||
|
const subId = `${questId}_${PLAYER}`;
|
||||||
|
await submitQuest(PLAYER, questId, { text: '1차' });
|
||||||
|
expect(await codeOf(() => reviewSubmission({ actorUid: CREATOR, isOps: false, submissionId: subId, action: 'reject' })))
|
||||||
|
.toBe('invalid-argument');
|
||||||
|
await reviewSubmission({ actorUid: CREATOR, isOps: false, submissionId: subId, action: 'reject', reason: '사진 불명확', resubmitAllowed: true });
|
||||||
|
let sub = (await db().doc(`questSubmissions/${subId}`).get()).data()!;
|
||||||
|
expect(sub).toMatchObject({ status: 'rejected', rejectReason: '사진 불명확', resubmitAllowed: true });
|
||||||
|
expect((await quest(questId)).pool.reserved).toBe(5);
|
||||||
|
|
||||||
|
await submitQuest(PLAYER, questId, { text: '보완' });
|
||||||
|
sub = (await db().doc(`questSubmissions/${subId}`).get()).data()!;
|
||||||
|
expect(sub.status).toBe('submitted');
|
||||||
|
expect(sub.evidence).toHaveLength(2);
|
||||||
|
|
||||||
|
await reviewSubmission({ actorUid: 'ops-1', isOps: true, submissionId: subId, action: 'reject', reason: '조건 불충족' });
|
||||||
|
expect((await quest(questId)).pool.reserved).toBe(0);
|
||||||
|
expect(await codeOf(() => submitQuest(PLAYER, questId, { text: '다시' }))).toBe('E-POP-03');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('3-25 · 3-22 종료 — 서버 시각 마감 · 즉시 반환 · 예약 유지 · 자동 승인 0 · Pool 등식', async () => {
|
||||||
|
const { questId } = await create();
|
||||||
|
await submitQuest(PLAYER, questId, { text: '제출' });
|
||||||
|
const ref = db().doc(`quests/${questId}`);
|
||||||
|
// 25시간 방치 — 자동 승인 경로가 없으므로 여전히 검토 대기.
|
||||||
|
await db().doc(`questSubmissions/${questId}_${PLAYER}`).update({ submittedAt: Timestamp.fromMillis(Date.now() - 25 * HOUR) });
|
||||||
|
|
||||||
|
await ref.update({ endsAt: Timestamp.fromMillis(Date.now() + 1_000) });
|
||||||
|
expect(await settleQuestPool(questId)).toBe(0); // 마감 −1초: 아직 열림
|
||||||
|
await ref.update({ endsAt: Timestamp.fromMillis(Date.now() - 1_000) });
|
||||||
|
expect(await codeOf(() => submitQuest('someone', questId, { text: 'late' }))).toBe('E-QST-01'); // +1초 거부
|
||||||
|
|
||||||
|
expect(await settleQuestPool(questId)).toBe(45);
|
||||||
|
let q = await quest(questId);
|
||||||
|
expect(q.status).toBe('ended');
|
||||||
|
expect(q.pool).toMatchObject({ deposited: 50, paid: 0, reserved: 5, returned: 45, returnedSupport: 10 });
|
||||||
|
expect((await db().doc(`questSubmissions/${questId}_${PLAYER}`).get()).get('status')).toBe('submitted');
|
||||||
|
expect(await wallet(CREATOR)).toEqual({ general: 35, contribution: 90 });
|
||||||
|
expect(await settleQuestPool(questId)).toBe(0); // 재실행 멱등
|
||||||
|
|
||||||
|
await reviewSubmission({ actorUid: CREATOR, isOps: false, submissionId: `${questId}_${PLAYER}`, action: 'reject', reason: '불충족' });
|
||||||
|
q = await quest(questId);
|
||||||
|
expect(q.pool.paid + q.pool.reserved + q.pool.returned).toBe(q.pool.deposited);
|
||||||
|
expect(q.pool.returned).toBe(50);
|
||||||
|
const returns = await db().collection(`users/${CREATOR}/popTransactions`).where('reasonCode', '==', 'QUEST_POOL_RETURN').get();
|
||||||
|
expect(returns.docs.map((d) => d.get('amount')).reduce((a: number, b: number) => a + b, 0)).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('HIGH 2 마감 뒤 보완 대기 반려 건은 정산이 최종 반려로 닫고 예약을 반환한다', async () => {
|
||||||
|
const { questId } = await create();
|
||||||
|
const subId = `${questId}_${PLAYER}`;
|
||||||
|
await submitQuest(PLAYER, questId, { text: '1차' });
|
||||||
|
await reviewSubmission({ actorUid: CREATOR, isOps: false, submissionId: subId, action: 'reject', reason: '보완 필요', resubmitAllowed: true });
|
||||||
|
// 마감 전에는 검토자가 닫을 수 없다(보완 대기).
|
||||||
|
expect(await codeOf(() => reviewSubmission({ actorUid: CREATOR, isOps: false, submissionId: subId, action: 'reject', reason: '끝' })))
|
||||||
|
.toBe('E-POP-03');
|
||||||
|
await db().doc(`quests/${questId}`).update({ endsAt: Timestamp.fromMillis(Date.now() - 1_000) });
|
||||||
|
expect(await codeOf(() => submitQuest(PLAYER, questId, { text: '늦은 보완' }))).toBe('E-QST-01');
|
||||||
|
|
||||||
|
expect(await settleQuestPool(questId)).toBe(50);
|
||||||
|
const sub = (await db().doc(`questSubmissions/${subId}`).get()).data()!;
|
||||||
|
expect(sub).toMatchObject({ status: 'rejected_final', resubmitAllowed: false, payoutStatus: 'released' });
|
||||||
|
const q = await quest(questId);
|
||||||
|
expect(q.pool).toMatchObject({ reserved: 0, returned: 50 });
|
||||||
|
expect(q.pool.paid + q.pool.reserved + q.pool.returned).toBe(q.pool.deposited);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('HIGH 2 마감 뒤 검토자가 보완 대기 건을 직접 최종 반려할 수 있다', async () => {
|
||||||
|
const { questId } = await create();
|
||||||
|
const subId = `${questId}_${PLAYER}`;
|
||||||
|
await submitQuest(PLAYER, questId, { text: '1차' });
|
||||||
|
await reviewSubmission({ actorUid: CREATOR, isOps: false, submissionId: subId, action: 'reject', reason: '보완', resubmitAllowed: true });
|
||||||
|
await db().doc(`quests/${questId}`).update({ endsAt: Timestamp.fromMillis(Date.now() - 1_000) });
|
||||||
|
const res = await reviewSubmission({ actorUid: 'ops-1', isOps: true, submissionId: subId, action: 'reject', reason: '기한 경과' });
|
||||||
|
expect(res.status).toBe('rejected_final');
|
||||||
|
expect((await quest(questId)).pool).toMatchObject({ reserved: 0, returned: 50 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('3-26 위치 UGC — Pool 에서 보상액 지급 · 기본 1회', async () => {
|
||||||
|
const { questId } = await create({ activityKey: 'location', url: undefined, placeId: PLACE });
|
||||||
|
const verify = () => call(fnQuestVerifyPlace, PLAYER, { questId, placeId: PLACE, lat: 37.55, lng: 126.92 });
|
||||||
|
expect((await verify()).rewarded).toBe(5);
|
||||||
|
expect(await codeOf(verify)).toBe('E-POP-03');
|
||||||
|
expect((await quest(questId)).pool).toMatchObject({ paid: 5 });
|
||||||
|
expect((await quest(questId)).verifyMethods).toEqual(['gps']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('C-11 Community — 다른 회원 반응이 달린 응원글이면 즉시 지급 · 본인 반응 제외', async () => {
|
||||||
|
const { questId } = await create({ activityKey: 'community', url: undefined });
|
||||||
|
const post = db().doc('posts/ugc-post');
|
||||||
|
// 공개 전에 쓴 글은 세지 않는다.
|
||||||
|
await db().doc('posts/old-post').set({
|
||||||
|
fandomId: FAN, authorUid: PLAYER, visibility: 'visible', likeCount: 5,
|
||||||
|
createdAt: Timestamp.fromMillis(Date.now() - 2 * HOUR),
|
||||||
|
});
|
||||||
|
// likeCount 는 라운지 트리거처럼 본인 반응을 포함해 센다.
|
||||||
|
await post.set({ fandomId: FAN, authorUid: PLAYER, visibility: 'visible', createdAt: Timestamp.now(), likeCount: 1 });
|
||||||
|
await post.collection('likes').doc(PLAYER).set({ createdAt: Timestamp.now() });
|
||||||
|
expect(await codeOf(() => submitQuest(PLAYER, questId, {}))).toBe('E-QST-01');
|
||||||
|
await post.collection('likes').doc('someone-else').set({ createdAt: Timestamp.now() });
|
||||||
|
await post.update({ likeCount: 2 });
|
||||||
|
const res = await submitQuest(PLAYER, questId, {});
|
||||||
|
expect(res).toMatchObject({ status: 'paid', rewarded: 5 });
|
||||||
|
expect(await codeOf(() => submitQuest(PLAYER, questId, {}))).toBe('E-POP-03');
|
||||||
|
expect((await quest(questId)).usedPostIds).toEqual(['ugc-post']);
|
||||||
|
|
||||||
|
// 같은 글로 다른 Quest 를 채우지 못한다.
|
||||||
|
const second = await create({ activityKey: 'community', url: undefined });
|
||||||
|
expect(await codeOf(() => submitQuest(PLAYER, second.questId, {}))).toBe('E-QST-01');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { getFirestore, Timestamp } from 'firebase-admin/firestore';
|
||||||
|
|
||||||
|
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
|
||||||
|
import {
|
||||||
|
fnInviteCodeGet, fnReferralAttribute, fnReferralRewardOnCompletion, fnReferralSettle,
|
||||||
|
inviteCodeOf, onReferralCompletionVoided, payReferralReward, settleReferral,
|
||||||
|
} from '../src/account/referral.js';
|
||||||
|
|
||||||
|
/** 추천 — 초대 코드 발급 · 귀속 · 보류 지급 · 24h 정산 · 무효 시 해제/회수(B-69 · B-71). */
|
||||||
|
|
||||||
|
const A = 'test-ref-a';
|
||||||
|
const B = 'test-ref-b';
|
||||||
|
const C = 'test-ref-c';
|
||||||
|
const USERS = [A, B, C];
|
||||||
|
const FANDOM = 'test-ref-fandom';
|
||||||
|
|
||||||
|
const db = () => getFirestore();
|
||||||
|
type AttrReq = Parameters<typeof fnReferralAttribute.run>[0];
|
||||||
|
const attribute = (uid: string, referralCode: string) =>
|
||||||
|
fnReferralAttribute.run({ data: { referralCode, deviceRaw: 'device-1' }, auth: { uid, token: {} } } as AttrReq);
|
||||||
|
const codeOf = async (p: Promise<unknown>) => {
|
||||||
|
try { await p; return 'ok'; } catch (e) { return (e as { details?: { code?: string } }).details?.code; }
|
||||||
|
};
|
||||||
|
const wallet = async (uid: string) => (await db().doc(`users/${uid}/wallet/main`).get()).data() ?? {};
|
||||||
|
const lock = async (uid: string) => (await db().doc(`referralsByUser/${uid}`).get()).data() ?? {};
|
||||||
|
const complete = async (uid: string, id = 'q_2026-09-28') => {
|
||||||
|
await db().doc(`users/${uid}/verifiedCompletions/${id}`).set({ questId: 'q' });
|
||||||
|
await fnReferralRewardOnCompletion.run({ params: { uid, completionId: id } } as never);
|
||||||
|
};
|
||||||
|
const fpiEvents = async () => (await db().collection(`fandoms/${FANDOM}/fpiEvents`).get()).docs;
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
const owned = await db().collection('inviteCodeOwners').where('uid', 'in', USERS).get();
|
||||||
|
for (const d of owned.docs) await Promise.all([d.ref.delete(), db().doc(`inviteCodes/${d.id}`).delete()]);
|
||||||
|
for (const u of USERS) {
|
||||||
|
await db().doc(`referralsByUser/${u}`).delete();
|
||||||
|
const rows = await db().collection('referrals').where('referredUid', '==', u).get();
|
||||||
|
await Promise.all(rows.docs.map((d) => d.ref.delete()));
|
||||||
|
}
|
||||||
|
const daily = await db().collection('referralDaily').get();
|
||||||
|
await Promise.all(daily.docs.filter((d) => USERS.some((u) => d.id.startsWith(`${u}_`))).map((d) => d.ref.delete()));
|
||||||
|
await db().recursiveDelete(db().doc(`fandoms/${FANDOM}`));
|
||||||
|
await db().doc('config/policy').delete();
|
||||||
|
for (const u of USERS) await wipeUser(u);
|
||||||
|
for (const u of USERS) {
|
||||||
|
await db().doc(`users/${u}`).set({ uid: u, fandomId: FANDOM, createdAt: Timestamp.now() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => { bootAdmin(); });
|
||||||
|
afterAll(async () => { await cleanup(); await shutdownAdmin(); });
|
||||||
|
beforeEach(cleanup);
|
||||||
|
|
||||||
|
describe('초대 코드 발급', () => {
|
||||||
|
it('사용자당 1개 · 10자 32진 · 공개 문서는 {active} 만, 주인은 서버 전용 문서', async () => {
|
||||||
|
const [x, y] = await Promise.all([inviteCodeOf(A), inviteCodeOf(A)]);
|
||||||
|
const again = (await fnInviteCodeGet.run({ data: {}, auth: { uid: A, token: {} } } as
|
||||||
|
Parameters<typeof fnInviteCodeGet.run>[0])).code;
|
||||||
|
expect(new Set([x, y, again]).size).toBe(1);
|
||||||
|
expect(x).toMatch(/^[A-HJ-NP-Z2-9]{10}$/);
|
||||||
|
expect((await db().doc(`inviteCodes/${x}`).get()).data()).toEqual({ active: true });
|
||||||
|
expect((await db().doc(`inviteCodeOwners/${x}`).get()).get('uid')).toBe(A);
|
||||||
|
expect(await inviteCodeOf(B)).not.toBe(x);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('형식이 틀린 코드는 조회 전에 거부한다', async () => {
|
||||||
|
for (const bad of ['short', 'ABCDEFGHI1', 'abcdefgh23', 'ABCDEFGH23/x']) {
|
||||||
|
expect(await codeOf(attribute(B, bad))).toBe('E-POP-02');
|
||||||
|
}
|
||||||
|
// 형식은 맞지만 없는 코드.
|
||||||
|
expect(await codeOf(attribute(B, 'ZZZZZZZZZ2'))).toBe('E-POP-02');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('귀속 제한', () => {
|
||||||
|
it('자기 추천 · 서로 추천 · 가입 7일 경과 · 가입자당 1회', async () => {
|
||||||
|
const codeA = await inviteCodeOf(A);
|
||||||
|
const codeB = await inviteCodeOf(B);
|
||||||
|
expect(await codeOf(attribute(A, codeA))).toBe('E-QST-02');
|
||||||
|
expect((await attribute(B, codeA)).reward).toBe('pending');
|
||||||
|
expect((await db().doc(`referrals/${B}_${codeA}`).get()).get('deviceHash')).toMatch(/^[0-9a-f]{32}$/);
|
||||||
|
expect(await codeOf(attribute(A, codeB))).toBe('E-QST-02'); // B→A 가 있으니 A→B 불가
|
||||||
|
expect(await codeOf(attribute(B, await inviteCodeOf(C)))).toBe('E-POP-03');
|
||||||
|
await db().doc(`users/${C}`).update({ createdAt: Timestamp.fromMillis(Date.now() - 8 * 86_400_000) });
|
||||||
|
expect(await codeOf(attribute(C, codeA))).toBe('E-POP-02');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('서로 다른 코드 두 개를 동시에 보내도 귀속은 1건이다', async () => {
|
||||||
|
const [ca, cb] = [await inviteCodeOf(A), await inviteCodeOf(B)];
|
||||||
|
const settled = await Promise.allSettled([attribute(C, ca), attribute(C, cb)]);
|
||||||
|
expect(settled.filter((r) => r.status === 'fulfilled')).toHaveLength(1);
|
||||||
|
expect((await db().collection('referrals').where('referredUid', '==', C).get()).size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('추천인 하루 상한(referralDailyCapPerReferrer)을 넘으면 귀속만 남고 capped', async () => {
|
||||||
|
await db().doc('config/policy').set({ referralDailyCapPerReferrer: 1 });
|
||||||
|
const codeA = await inviteCodeOf(A);
|
||||||
|
expect((await attribute(B, codeA)).reward).toBe('pending');
|
||||||
|
expect((await attribute(C, codeA)).reward).toBe('capped');
|
||||||
|
await complete(C);
|
||||||
|
expect((await wallet(A)).pendingActivity ?? 0).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('귀속 전에 검증 활동이 있으면 ineligible — 지급 없음', async () => {
|
||||||
|
await db().doc(`users/${B}/verifiedCompletions/q_2026-09-27`).set({ questId: 'q' });
|
||||||
|
expect((await attribute(B, await inviteCodeOf(A))).reward).toBe('ineligible');
|
||||||
|
await complete(B, 'q_2026-09-28');
|
||||||
|
expect((await wallet(A)).pendingActivity ?? 0).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('경합 — 잠금보다 먼저 생긴 완료로는 지급하지 않는다', async () => {
|
||||||
|
await db().doc(`users/${B}/verifiedCompletions/q_early`).set({ questId: 'q' });
|
||||||
|
// 귀속 트랜잭션이 완료를 못 본 채 잠금을 쓴 상황을 그대로 만든다.
|
||||||
|
await db().doc(`referrals/${B}_RACE`).set({ referredUid: B, referrerUid: A, reward: 'pending' });
|
||||||
|
await db().doc(`referralsByUser/${B}`).set({ attributionId: `${B}_RACE`, referrerUid: A, reward: 'pending' });
|
||||||
|
expect(await payReferralReward(B, 'q_early')).toBe(false);
|
||||||
|
await db().doc(`users/${B}/verifiedCompletions/q_late`).set({ questId: 'q' });
|
||||||
|
expect(await payReferralReward(B, 'q_late')).toBe(true);
|
||||||
|
await db().doc(`referrals/${B}_RACE`).delete();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('보류 지급 → 24h 정산 → 무효 처리', () => {
|
||||||
|
it('첫 검증 활동은 보류 지급 100 · 20, 정산 뒤 확정 · FPI 1 단위, 재실행해도 한 번', async () => {
|
||||||
|
await attribute(B, await inviteCodeOf(A));
|
||||||
|
expect(await wallet(A)).toEqual({});
|
||||||
|
await complete(B);
|
||||||
|
await complete(B, 'q_2026-09-29');
|
||||||
|
expect((await wallet(A)).pendingActivity).toBe(100);
|
||||||
|
expect((await wallet(B)).pendingActivity).toBe(20);
|
||||||
|
expect((await wallet(A)).activityBalance).toBe(0);
|
||||||
|
expect((await lock(B)).reward).toBe('held');
|
||||||
|
expect(await fpiEvents()).toHaveLength(0);
|
||||||
|
|
||||||
|
// 보류 시간이 안 지났으면 정산 작업이 건드리지 않는다.
|
||||||
|
const run = () => fnReferralSettle.run({ scheduleTime: new Date().toISOString() } as never);
|
||||||
|
await run();
|
||||||
|
expect((await lock(B)).reward).toBe('held');
|
||||||
|
await db().doc(`referralsByUser/${B}`).update({ settleAfter: Timestamp.fromMillis(Date.now() - 1) });
|
||||||
|
await run();
|
||||||
|
await run();
|
||||||
|
expect((await lock(B)).reward).toBe('paid');
|
||||||
|
expect(await wallet(A)).toMatchObject({ activityBalance: 100, pendingActivity: 0 });
|
||||||
|
expect(await wallet(B)).toMatchObject({ activityBalance: 20, pendingActivity: 0 });
|
||||||
|
const events = await fpiEvents();
|
||||||
|
expect(events.map((e) => [e.get('axis'), e.get('dauEligible')])).toEqual([['referral', false]]);
|
||||||
|
expect((await db().doc(`users/${A}`).get()).get('userExp')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('정산 전에 유일한 완료가 무효가 되면 두 보류 지급을 해제한다', async () => {
|
||||||
|
await attribute(B, await inviteCodeOf(A));
|
||||||
|
await complete(B);
|
||||||
|
await db().doc(`users/${B}/verifiedCompletions/q_2026-09-28`).update({ voided: true });
|
||||||
|
expect(await onReferralCompletionVoided(B)).toBe('released');
|
||||||
|
expect(await wallet(A)).toMatchObject({ activityBalance: 0, pendingActivity: 0 });
|
||||||
|
expect(await wallet(B)).toMatchObject({ activityBalance: 0, pendingActivity: 0 });
|
||||||
|
expect(await settleReferral(B)).toBeNull();
|
||||||
|
expect(await fpiEvents()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('다른 유효 완료가 남아 있으면 무효 하나로는 건드리지 않는다', async () => {
|
||||||
|
await attribute(B, await inviteCodeOf(A));
|
||||||
|
await complete(B, 'q1');
|
||||||
|
await complete(B, 'q2');
|
||||||
|
await db().doc(`users/${B}/verifiedCompletions/q1`).update({ voided: true });
|
||||||
|
expect(await onReferralCompletionVoided(B)).toBeNull();
|
||||||
|
expect((await lock(B)).reward).toBe('held');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('정산 뒤 무효면 ADMIN_CLAWBACK 으로 회수하고 FPI 이벤트를 집계에서 뺀다(멱등)', async () => {
|
||||||
|
await attribute(B, await inviteCodeOf(A));
|
||||||
|
await complete(B);
|
||||||
|
expect(await settleReferral(B)).toBe('paid');
|
||||||
|
await db().doc(`users/${B}/verifiedCompletions/q_2026-09-28`).update({ voided: true });
|
||||||
|
expect(await onReferralCompletionVoided(B)).toBe('clawedback');
|
||||||
|
expect(await onReferralCompletionVoided(B)).toBeNull();
|
||||||
|
expect((await wallet(A)).activityBalance).toBe(0);
|
||||||
|
expect((await wallet(B)).activityBalance).toBe(0);
|
||||||
|
const reasons = (await db().collection(`users/${A}/popTransactions`).get()).docs.map((d) => d.get('reasonCode'));
|
||||||
|
expect(reasons.filter((r) => r === 'ADMIN_CLAWBACK')).toHaveLength(1);
|
||||||
|
expect((await fpiEvents()).map((e) => e.get('verified'))).toEqual([false]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('이상 표시(flagged)가 있으면 정산 때 해제한다', async () => {
|
||||||
|
await attribute(B, await inviteCodeOf(A));
|
||||||
|
await complete(B);
|
||||||
|
await db().doc(`referralsByUser/${B}`).update({ flagged: true });
|
||||||
|
expect(await settleReferral(B)).toBe('released');
|
||||||
|
expect((await wallet(A)).activityBalance).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,3 +23,29 @@ export function bootAdmin(): App {
|
|||||||
export async function shutdownAdmin(): Promise<void> {
|
export async function shutdownAdmin(): Promise<void> {
|
||||||
for (const app of getApps()) await deleteApp(app);
|
for (const app of getApps()) await deleteApp(app);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그 사용자가 남긴 문서를 전부 지운다.
|
||||||
|
*
|
||||||
|
* **`users/{uid}` 아래만 지우면 모자란다.** 기여 차감은 팬덤 멤버 문서에도
|
||||||
|
* 누적되므로, 거기를 남겨 두면 다음 테스트에서 값이 겹쳐 올라간다. 실제로
|
||||||
|
* 그 때문에 「기여 40」을 기대하는 검증이 회차가 쌓이며 120 을 보았다.
|
||||||
|
*/
|
||||||
|
export async function wipeUser(uid: string): Promise<void> {
|
||||||
|
const db = getFirestore();
|
||||||
|
for (const sub of [
|
||||||
|
'popLots', 'popTransactions', 'growthEvents', 'questProgress', 'wallet',
|
||||||
|
'consents', 'purchases', 'subscription', 'subscriptionEvents', 'adBonuses',
|
||||||
|
'verifiedCompletions', 'rateLimits',
|
||||||
|
]) {
|
||||||
|
const snap = await db.collection(`users/${uid}/${sub}`).get();
|
||||||
|
await Promise.all(snap.docs.map((d) => d.ref.delete()));
|
||||||
|
}
|
||||||
|
await db.doc(`users/${uid}`).delete();
|
||||||
|
|
||||||
|
// 이 사용자가 멤버로 올라간 팬덤 문서까지 지운다.
|
||||||
|
const members = await db.collectionGroup('members').get();
|
||||||
|
await Promise.all(
|
||||||
|
members.docs.filter((d) => d.id === uid).map((d) => d.ref.delete()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { FieldValue, getFirestore, Timestamp } from 'firebase-admin/firestore';
|
||||||
|
|
||||||
|
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
|
||||||
|
import { fnQuestTimeAttack, fnQuestTimeAttackStart, fnServerTime } from '../src/quest/timeattack.js';
|
||||||
|
import { verifiedQuestCountOf } from '../src/ledger/fpi.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 긴급 돌발 타임어택 — 별첨 1 ③-4 · FR-QST-06 검증 기준
|
||||||
|
* 「발동·마감 경계 시각의 참여 수락/거부 동작 확인」.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const UID = 'test-timeattack';
|
||||||
|
const FANDOM = 'fandom-timeattack';
|
||||||
|
const db = () => getFirestore();
|
||||||
|
|
||||||
|
type Reply = Record<string, unknown>;
|
||||||
|
const call = async (
|
||||||
|
fn: { run(request: never): unknown },
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
token: Record<string, unknown> = {},
|
||||||
|
uid = UID,
|
||||||
|
): Promise<Reply> =>
|
||||||
|
(await fn.run({ data, auth: { uid, token } } as unknown as never)) as Reply;
|
||||||
|
|
||||||
|
async function codeOf(run: () => Promise<unknown>): Promise<string> {
|
||||||
|
try {
|
||||||
|
await run();
|
||||||
|
} catch (error) {
|
||||||
|
return (error as { details?: { code?: string } }).details?.code ?? (error as Error).message;
|
||||||
|
}
|
||||||
|
throw new Error('실패해야 하는 호출이 성공했습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 창을 직접 박은 퀘스트. [fromMs] · [toMs] 는 지금 기준 상대 ms. */
|
||||||
|
async function quest(id: string, fromMs: number, toMs: number, extra: Reply = {}) {
|
||||||
|
await db().doc(`quests/${id}`).set({
|
||||||
|
type: 'timeattack', title: '돌발', fandomId: FANDOM, rewardPop: 30, dailyLimit: 1, active: true,
|
||||||
|
activeFrom: Timestamp.fromMillis(Date.now() + fromMs),
|
||||||
|
activeTo: Timestamp.fromMillis(Date.now() + toMs),
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => { bootAdmin(); });
|
||||||
|
afterAll(async () => { await shutdownAdmin(); });
|
||||||
|
beforeEach(async () => {
|
||||||
|
await wipeUser(UID);
|
||||||
|
await db().doc(`users/${UID}`).set({ uid: UID, fandomId: FANDOM });
|
||||||
|
await db().doc(`fandoms/${FANDOM}`).set({ name: '타임어택 행성' }, { merge: true });
|
||||||
|
await db().recursiveDelete(db().collection(`fandoms/${FANDOM}/fpiEvents`));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('서버 시각 — fn-server-time', () => {
|
||||||
|
it('서버 시각(ms)을 돌려준다', async () => {
|
||||||
|
const before = Date.now();
|
||||||
|
const reply = await call(fnServerTime, {});
|
||||||
|
expect(reply.now as number).toBeGreaterThanOrEqual(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('타임어택 발동 — fn-quest-timeattack-start', () => {
|
||||||
|
const start = { fandomId: FANDOM, title: '10분 돌발', rewardPop: 30, durationMinutes: 10 };
|
||||||
|
|
||||||
|
it('master 가 아니면 거부한다', async () => {
|
||||||
|
expect(await codeOf(() => call(fnQuestTimeAttackStart, { ...start, requestId: 'r0' }))).toBe('E-PERM-01');
|
||||||
|
expect(await codeOf(() => call(fnQuestTimeAttackStart, { ...start, requestId: 'r0' }, { role: 'ops' })))
|
||||||
|
.toBe('E-PERM-01');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('창을 서버 시각으로 열고 같은 요청의 재전송은 한 건으로 모은다', async () => {
|
||||||
|
const before = Date.now();
|
||||||
|
const first = await call(fnQuestTimeAttackStart, { ...start, requestId: 'r1' }, { role: 'master' });
|
||||||
|
const again = await call(fnQuestTimeAttackStart, { ...start, requestId: 'r1' }, { role: 'master' });
|
||||||
|
expect(again.questId).toBe(first.questId);
|
||||||
|
expect(again.duplicate).toBe(true);
|
||||||
|
|
||||||
|
const doc = await db().doc(`quests/${first.questId as string}`).get();
|
||||||
|
expect(doc.get('type')).toBe('timeattack');
|
||||||
|
expect(doc.get('fandomId')).toBe(FANDOM);
|
||||||
|
const span = (doc.get('activeTo') as Timestamp).toMillis() - (doc.get('activeFrom') as Timestamp).toMillis();
|
||||||
|
expect(span).toBe(10 * 60_000);
|
||||||
|
expect((doc.get('activeFrom') as Timestamp).toMillis()).toBeGreaterThanOrEqual(before - 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('보상 상한(config/policy.timeAttackMaxPop) · 경로 문자 · 긴 제목을 거부한다', async () => {
|
||||||
|
const saved = (await db().doc('config/policy').get()).data();
|
||||||
|
await db().doc('config/policy').set({ timeAttackMaxPop: 100 });
|
||||||
|
try {
|
||||||
|
expect(await codeOf(() => call(fnQuestTimeAttackStart,
|
||||||
|
{ ...start, rewardPop: 101, requestId: 'r4' }, { role: 'master' }))).toBe('E-QST-01');
|
||||||
|
const ok = await call(fnQuestTimeAttackStart,
|
||||||
|
{ ...start, rewardPop: 100, requestId: 'r5' }, { role: 'master' });
|
||||||
|
expect(ok.questId).toBeTruthy();
|
||||||
|
} finally {
|
||||||
|
await db().doc('config/policy').delete();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// 문서가 없으면 기본 상한 1,000.
|
||||||
|
expect(await codeOf(() => call(fnQuestTimeAttackStart,
|
||||||
|
{ ...start, rewardPop: 1001, requestId: 'r6' }, { role: 'master' }))).toBe('E-QST-01');
|
||||||
|
} finally {
|
||||||
|
if (saved) await db().doc('config/policy').set(saved);
|
||||||
|
}
|
||||||
|
expect(await codeOf(() => call(fnQuestTimeAttackStart,
|
||||||
|
{ ...start, fandomId: 'a/b', requestId: 'r7' }, { role: 'master' }))).toBe('E-QST-01');
|
||||||
|
expect(await codeOf(() => call(fnQuestTimeAttackStart,
|
||||||
|
{ ...start, title: 'x'.repeat(61), requestId: 'r8' }, { role: 'master' }))).toBe('E-QST-01');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('하루를 넘는 창 · 없는 팬덤은 거부한다', async () => {
|
||||||
|
expect(await codeOf(() => call(fnQuestTimeAttackStart,
|
||||||
|
{ ...start, durationMinutes: 24 * 60 + 1, requestId: 'r2' }, { role: 'master' }))).toBe('E-QST-01');
|
||||||
|
expect(await codeOf(() => call(fnQuestTimeAttackStart,
|
||||||
|
{ ...start, fandomId: 'no-such-fandom', requestId: 'r3' }, { role: 'master' }))).toBe('E-QST-01');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('지속 시간 상한은 config/policy.timeAttackMaxMinutes 를 따른다', async () => {
|
||||||
|
const saved = (await db().doc('config/policy').get()).data();
|
||||||
|
await db().doc('config/policy').set({ timeAttackMaxMinutes: 30 });
|
||||||
|
try {
|
||||||
|
expect(await codeOf(() => call(fnQuestTimeAttackStart,
|
||||||
|
{ ...start, durationMinutes: 31, requestId: 'r9' }, { role: 'master' }))).toBe('E-QST-01');
|
||||||
|
const ok = await call(fnQuestTimeAttackStart,
|
||||||
|
{ ...start, durationMinutes: 30, requestId: 'r10' }, { role: 'master' });
|
||||||
|
expect(ok.questId).toBeTruthy();
|
||||||
|
} finally {
|
||||||
|
await db().doc('config/policy').delete();
|
||||||
|
if (saved) await db().doc('config/policy').set(saved);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('타임어택 참여 — fn-quest-timeattack', () => {
|
||||||
|
it('마감 뒤 재시도여도 이미 받은 사용자는 첫 결과를 받는다', async () => {
|
||||||
|
await quest('ta-retry', -60_000, 60_000);
|
||||||
|
const first = await call(fnQuestTimeAttack, { questId: 'ta-retry' });
|
||||||
|
await db().doc('quests/ta-retry').update({ activeTo: Timestamp.fromMillis(Date.now() - 1) });
|
||||||
|
const again = await call(fnQuestTimeAttack, { questId: 'ta-retry' });
|
||||||
|
expect(again.txId).toBe(first.txId);
|
||||||
|
expect(again.duplicate).toBe(true);
|
||||||
|
expect((await db().collection(`users/${UID}/popTransactions`).get()).size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('창 안 참여는 원장 1행 · EXP · FPI COMMUNITY 1 단위로 남고 재호출은 첫 결과를 돌려준다', async () => {
|
||||||
|
await quest('ta-open', -60_000, 60_000);
|
||||||
|
const reply = await call(fnQuestTimeAttack, { questId: 'ta-open' });
|
||||||
|
expect(reply.rewarded).toBe(30);
|
||||||
|
expect(reply.balanceAfter).toBe(30);
|
||||||
|
const again = await call(fnQuestTimeAttack, { questId: 'ta-open' });
|
||||||
|
expect(again).toMatchObject({ txId: reply.txId, balanceAfter: 30, rewarded: 30, duplicate: true });
|
||||||
|
// 인정 활동 1건 = EXP 100(C-18) · 검증 활동 1회 · FPI COMMUNITY 1 단위(Q-TA-05 권고 A안).
|
||||||
|
expect((await db().doc(`users/${UID}`).get()).get('userExp')).toBe(100);
|
||||||
|
expect(await verifiedQuestCountOf(UID)).toBe(1);
|
||||||
|
const fpi = await db().collection(`fandoms/${FANDOM}/fpiEvents`).get();
|
||||||
|
expect(fpi.docs.map((d) => [d.get('axis'), d.get('units'), d.get('activity')]))
|
||||||
|
.toEqual([['community', 1, 'timeattack']]);
|
||||||
|
|
||||||
|
const ledger = await db().collection(`users/${UID}/popTransactions`).get();
|
||||||
|
expect(ledger.size).toBe(1);
|
||||||
|
expect(ledger.docs[0]!.get('reasonCode')).toBe('QUEST_TIMEATTACK');
|
||||||
|
expect((await db().doc('quests/ta-open').get()).get('participantCount')).toBe(1);
|
||||||
|
expect((await db().doc(`users/${UID}/questProgress/ta-open`).get()).get('count')).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('timeAttackFpiAxis 가 null 이면 FPI 없이 EXP 만(Q-TA-05 B안)', async () => {
|
||||||
|
await db().doc('config/policy').set({ timeAttackFpiAxis: null }, { merge: true });
|
||||||
|
try {
|
||||||
|
await quest('ta-no-fpi', -60_000, 60_000);
|
||||||
|
await call(fnQuestTimeAttack, { questId: 'ta-no-fpi' });
|
||||||
|
} finally {
|
||||||
|
await db().doc('config/policy').update({ timeAttackFpiAxis: FieldValue.delete() });
|
||||||
|
}
|
||||||
|
expect((await db().doc(`users/${UID}`).get()).get('userExp')).toBe(100);
|
||||||
|
expect((await db().collection(`fandoms/${FANDOM}/fpiEvents`).get()).size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('시작 전 · 마감 후(끝 시각 포함)는 거부한다', async () => {
|
||||||
|
await quest('ta-early', 60_000, 120_000);
|
||||||
|
await quest('ta-late', -120_000, -1);
|
||||||
|
expect(await codeOf(() => call(fnQuestTimeAttack, { questId: 'ta-early' }))).toBe('E-QST-01');
|
||||||
|
expect(await codeOf(() => call(fnQuestTimeAttack, { questId: 'ta-late' }))).toBe('E-QST-01');
|
||||||
|
expect((await db().collection(`users/${UID}/popTransactions`).get()).size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('다른 팬덤 · 다른 유형 퀘스트로는 받지 못한다', async () => {
|
||||||
|
await quest('ta-other', -60_000, 60_000, { fandomId: 'fandom-else' });
|
||||||
|
await db().doc('quests/ta-attend').set({ type: 'attend', active: true });
|
||||||
|
expect(await codeOf(() => call(fnQuestTimeAttack, { questId: 'ta-other' }))).toBe('E-PERM-01');
|
||||||
|
expect(await codeOf(() => call(fnQuestTimeAttack, { questId: 'ta-attend' }))).toBe('E-QST-01');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { FieldValue, getFirestore, Timestamp } from 'firebase-admin/firestore';
|
||||||
|
|
||||||
|
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
|
||||||
|
import {
|
||||||
|
fnTranslationMark, fnTranslationSettle, fnTranslationSubmit,
|
||||||
|
} from '../src/account/translation.js';
|
||||||
|
import { grantPop, reconcileUser, settlePendingGrant } from '../src/ledger/ledger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 참여형 번역 왕복 — 등록(보류 지급) → 마킹(자격 판정) → 24시간 뒤 정산(확정/해제).
|
||||||
|
* 원장 B-63: 1건 5 POP · 24시간 확정대기 · 자기 마킹 제외 · 중복 0 · 일일상한.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const AUTHOR = 'test-trn-author';
|
||||||
|
const COPIER = 'test-trn-copier';
|
||||||
|
const MARKER = 'test-trn-marker';
|
||||||
|
const NEWBIE = 'test-trn-newbie';
|
||||||
|
const OUTSIDER = 'test-trn-outsider';
|
||||||
|
const USERS = [AUTHOR, COPIER, MARKER, NEWBIE, OUTSIDER];
|
||||||
|
const TARGET = 'questions/test-trn-q1';
|
||||||
|
const POST = 'posts/test-trn-p1';
|
||||||
|
const CAMPAIGN = 'campaigns/test-trn-c1';
|
||||||
|
const HIDDEN_POST = 'posts/test-trn-hidden';
|
||||||
|
const REVIEW_CAMPAIGN = 'campaigns/test-trn-review';
|
||||||
|
const NO_FANDOM_CAMPAIGN = 'campaigns/test-trn-nofandom';
|
||||||
|
const DAY = 24 * 3600 * 1000;
|
||||||
|
|
||||||
|
type SubmitReq = Parameters<typeof fnTranslationSubmit.run>[0];
|
||||||
|
type MarkReq = Parameters<typeof fnTranslationMark.run>[0];
|
||||||
|
const submit = (uid: string, body: string, targetPath = TARGET) => fnTranslationSubmit.run({
|
||||||
|
data: { targetPath, locale: 'en', body },
|
||||||
|
auth: { uid, token: {} },
|
||||||
|
} as SubmitReq);
|
||||||
|
const mark = (uid: string, entryId: string, valid = true) => fnTranslationMark.run({
|
||||||
|
data: { entryId, valid },
|
||||||
|
auth: { uid, token: {} },
|
||||||
|
} as MarkReq);
|
||||||
|
const settle = () => fnTranslationSettle.run(
|
||||||
|
{ scheduleTime: new Date().toISOString() } as Parameters<typeof fnTranslationSettle.run>[0],
|
||||||
|
);
|
||||||
|
const age = (entryId: string, hours: number) => getFirestore()
|
||||||
|
.doc(`translationEntries/${entryId}`)
|
||||||
|
.update({ createdAt: Timestamp.fromMillis(Date.now() - hours * 3600 * 1000) });
|
||||||
|
const wallet = async (uid: string) =>
|
||||||
|
(await getFirestore().doc(`users/${uid}/wallet/main`).get()).data() ?? {};
|
||||||
|
|
||||||
|
beforeAll(() => { bootAdmin(); });
|
||||||
|
afterAll(async () => { await shutdownAdmin(); });
|
||||||
|
beforeEach(async () => {
|
||||||
|
const db = getFirestore();
|
||||||
|
await Promise.all(USERS.map(wipeUser));
|
||||||
|
const old = await db.collection('translationEntries')
|
||||||
|
.where('targetPath', 'in', [TARGET, POST, `${POST}/comments/c1`, `${POST}/comments/hidden`, CAMPAIGN, NO_FANDOM_CAMPAIGN]).get();
|
||||||
|
for (const d of old.docs) {
|
||||||
|
const marks = await d.ref.collection('markings').get();
|
||||||
|
await Promise.all(marks.docs.map((m) => m.ref.delete()));
|
||||||
|
await d.ref.delete();
|
||||||
|
}
|
||||||
|
const counters = await db.collection('translationDaily').get();
|
||||||
|
await Promise.all(counters.docs.filter((d) => USERS.includes(d.get('uid'))).map((d) => d.ref.delete()));
|
||||||
|
const old2 = Timestamp.fromMillis(Date.now() - 2 * DAY);
|
||||||
|
await Promise.all([
|
||||||
|
db.doc(TARGET).set({ title: '질문', fandomId: 'trn-f1' }),
|
||||||
|
db.doc(POST).set({ body: '글', fandomId: 'trn-f1', visibility: 'visible' }),
|
||||||
|
db.doc(`${POST}/comments/c1`).set({ body: '댓글', visibility: 'visible' }),
|
||||||
|
db.doc(`${POST}/comments/hidden`).set({ body: '가린 댓글', visibility: 'hidden' }),
|
||||||
|
db.doc(HIDDEN_POST).set({ body: '가린 글', fandomId: 'trn-f1', visibility: 'hidden' }),
|
||||||
|
db.doc(CAMPAIGN).set({ title: '캠페인', fandomId: 'trn-f1', status: 'funding' }),
|
||||||
|
db.doc(REVIEW_CAMPAIGN).set({ title: '검수 중', fandomId: 'trn-f1', status: 'review' }),
|
||||||
|
db.doc(NO_FANDOM_CAMPAIGN).set({ title: '팬덤 없음', status: 'funding' }),
|
||||||
|
db.doc(`users/${AUTHOR}`).set({ createdAt: old2, fandomId: 'trn-f1', displayName: '김실명', nickname: '호떡이' }),
|
||||||
|
db.doc(`users/${COPIER}`).set({ createdAt: old2, fandomId: 'trn-f1' }),
|
||||||
|
db.doc(`users/${MARKER}`).set({ createdAt: old2, fandomId: 'trn-f1' }),
|
||||||
|
db.doc(`users/${NEWBIE}`).set({ createdAt: Timestamp.now(), fandomId: 'trn-f1' }),
|
||||||
|
db.doc(`users/${OUTSIDER}`).set({ createdAt: old2, fandomId: 'trn-f2' }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('번역 등록', () => {
|
||||||
|
it('보상분은 예약 잔액으로 잡히고(G-05) 일일 카운터가 오른다', async () => {
|
||||||
|
const first = await submit(AUTHOR, 'Hello fandom');
|
||||||
|
expect(first).toMatchObject({ rewardPop: 5, excludedReason: null });
|
||||||
|
const w = await wallet(AUTHOR);
|
||||||
|
expect(w.activityBalance ?? 0).toBe(0);
|
||||||
|
expect(w.pendingActivity).toBe(5);
|
||||||
|
const today = (await getFirestore().collection('translationDaily')
|
||||||
|
.where('uid', '==', AUTHOR).get()).docs[0]!;
|
||||||
|
expect(today.get('count')).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('같은 대상·언어 재등록은 E-TRN-05 · 남의 문장 복사는 보상 0 · 예약 없음', async () => {
|
||||||
|
await submit(AUTHOR, 'Hello fandom');
|
||||||
|
await expect(submit(AUTHOR, 'Hello again')).rejects.toMatchObject({ details: { code: 'E-TRN-05' } });
|
||||||
|
expect(await submit(COPIER, 'Hello fandom')).toMatchObject({ rewardPop: 0, excludedReason: 'duplicate' });
|
||||||
|
expect((await wallet(COPIER)).pendingActivity ?? 0).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('번역 댓글은 글 경로(rootPath)와 작성자 표시명을 함께 남긴다 — 댓글 · 캠페인 대상 포함', async () => {
|
||||||
|
const onComment = await submit(AUTHOR, 'Nice comment', `${POST}/comments/c1`);
|
||||||
|
const onCampaign = await submit(AUTHOR, 'Cheer up', CAMPAIGN);
|
||||||
|
const db = getFirestore();
|
||||||
|
const a = await db.doc(`translationEntries/${onComment.entryId}`).get();
|
||||||
|
expect(a.get('rootPath')).toBe(POST);
|
||||||
|
expect(a.get('fandomId')).toBe('trn-f1');
|
||||||
|
expect(a.get('authorName')).toBe('호떡이');
|
||||||
|
const b = await db.doc(`translationEntries/${onCampaign.entryId}`).get();
|
||||||
|
expect(b.get('rootPath')).toBe(CAMPAIGN);
|
||||||
|
expect(b.get('fandomId')).toBe('trn-f1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('작성자 닉네임이 없으면 공급자 실명 대신 「팬」을 싣는다(M-7)', async () => {
|
||||||
|
await getFirestore().doc(`users/${AUTHOR}`).update({ nickname: FieldValue.delete() });
|
||||||
|
const { entryId } = await submit(AUTHOR, 'Hello fandom');
|
||||||
|
const e = await getFirestore().doc(`translationEntries/${entryId}`).get();
|
||||||
|
expect(e.get('authorName')).toBe('팬');
|
||||||
|
expect(e.get('validCount')).toBe(0);
|
||||||
|
expect(e.get('invalidCount')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('숨긴 글 · 숨긴 댓글 · 공개 전 캠페인은 없는 콘텐츠와 같은 E-TRN-03 이다', async () => {
|
||||||
|
for (const target of [HIDDEN_POST, `${POST}/comments/hidden`, REVIEW_CAMPAIGN, `${HIDDEN_POST}/comments/x`]) {
|
||||||
|
await expect(submit(AUTHOR, 'x', target)).rejects.toMatchObject({ details: { code: 'E-TRN-03' } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('없는 콘텐츠와 허용 밖 경로는 같은 E-TRN-03 이다', async () => {
|
||||||
|
await expect(submit(AUTHOR, 'x', 'questions/none-such')).rejects.toMatchObject({ details: { code: 'E-TRN-03' } });
|
||||||
|
await expect(submit(AUTHOR, 'x', `users/${MARKER}`)).rejects.toMatchObject({ details: { code: 'E-TRN-03' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('일일상한 3배(기본 30건)를 넘기면 E-TRN-02', async () => {
|
||||||
|
const db = getFirestore();
|
||||||
|
const { dayKey } = await import('../src/core/time.js');
|
||||||
|
await db.doc(`translationDaily/${AUTHOR}_${dayKey(new Date())}`).set({ uid: AUTHOR, count: 30 });
|
||||||
|
await expect(submit(AUTHOR, 'Hello')).rejects.toMatchObject({ details: { code: 'E-TRN-02' } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('번역 마킹', () => {
|
||||||
|
it('본인 E-TRN-01 · 가입 24시간 미만 · 다른 팬덤(라운지) E-TRN-04 · 두 번째 E-TRN-05', async () => {
|
||||||
|
const { entryId } = await submit(AUTHOR, 'Hello fandom', POST);
|
||||||
|
await expect(mark(AUTHOR, entryId)).rejects.toMatchObject({ details: { code: 'E-TRN-01' } });
|
||||||
|
await expect(mark(NEWBIE, entryId)).rejects.toMatchObject({ details: { code: 'E-TRN-04' } });
|
||||||
|
await expect(mark(OUTSIDER, entryId)).rejects.toMatchObject({ details: { code: 'E-TRN-04' } });
|
||||||
|
await expect(mark(MARKER, entryId)).resolves.toMatchObject({ valid: true });
|
||||||
|
await expect(mark(MARKER, entryId, false)).rejects.toMatchObject({ details: { code: 'E-TRN-05' } });
|
||||||
|
// 마킹 수는 같은 트랜잭션에서 번역 문서에 오른다 — 실패한 두 번째 마킹은 세지 않는다.
|
||||||
|
const e = await getFirestore().doc(`translationEntries/${entryId}`).get();
|
||||||
|
expect(e.get('validCount')).toBe(1);
|
||||||
|
expect(e.get('invalidCount')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('무효 마킹은 invalidCount 를 올린다 · 동시에 두 번 눌러도 한 번만 센다', async () => {
|
||||||
|
const { entryId } = await submit(AUTHOR, 'Hello fandom', POST);
|
||||||
|
const results = await Promise.allSettled([mark(MARKER, entryId, false), mark(MARKER, entryId, false)]);
|
||||||
|
expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1);
|
||||||
|
const e = await getFirestore().doc(`translationEntries/${entryId}`).get();
|
||||||
|
expect(e.get('invalidCount')).toBe(1);
|
||||||
|
expect(e.get('validCount')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('등록 뒤 가려진 글의 번역은 마킹하지 못한다(E-TRN-03)', async () => {
|
||||||
|
const { entryId } = await submit(AUTHOR, 'Hello fandom', `${POST}/comments/c1`);
|
||||||
|
await getFirestore().doc(POST).update({ visibility: 'hidden' });
|
||||||
|
await expect(mark(MARKER, entryId)).rejects.toMatchObject({ details: { code: 'E-TRN-03' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('팬덤이 없는 캠페인의 번역은 아무도 마킹하지 못한다(E-TRN-04)', async () => {
|
||||||
|
const { entryId } = await submit(AUTHOR, 'Cheer', NO_FANDOM_CAMPAIGN);
|
||||||
|
await expect(mark(MARKER, entryId)).rejects.toMatchObject({ details: { code: 'E-TRN-04' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('캠페인 번역은 그 팬덤 멤버만 마킹한다', async () => {
|
||||||
|
const { entryId } = await submit(AUTHOR, 'Cheer', CAMPAIGN);
|
||||||
|
await expect(mark(OUTSIDER, entryId)).rejects.toMatchObject({ details: { code: 'E-TRN-04' } });
|
||||||
|
await expect(mark(MARKER, entryId)).resolves.toMatchObject({ valid: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('INSIDE 번역은 다른 팬덤 회원도 마킹한다(Q-TR-04 권고 B안) · 가입 24시간은 그대로', async () => {
|
||||||
|
const { entryId } = await submit(AUTHOR, 'Hello fandom');
|
||||||
|
await expect(mark(NEWBIE, entryId)).rejects.toMatchObject({ details: { code: 'E-TRN-04' } });
|
||||||
|
await expect(mark(OUTSIDER, entryId)).resolves.toMatchObject({ valid: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('운영값을 끄면(A안) INSIDE 도 그 팬덤 멤버만 마킹한다', async () => {
|
||||||
|
const policy = getFirestore().doc('config/policy');
|
||||||
|
const before = await policy.get();
|
||||||
|
await policy.set({ translationInsideAnyMember: false }, { merge: true });
|
||||||
|
try {
|
||||||
|
const { entryId } = await submit(AUTHOR, 'Hello fandom');
|
||||||
|
await expect(mark(OUTSIDER, entryId)).rejects.toMatchObject({ details: { code: 'E-TRN-04' } });
|
||||||
|
} finally {
|
||||||
|
if (before.exists) await policy.set(before.data()!);
|
||||||
|
else await policy.delete();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('팬덤 멤버 문서가 있으면 users.fandomId 가 달라도 마킹할 수 있다', async () => {
|
||||||
|
const { entryId } = await submit(AUTHOR, 'Hello fandom', POST);
|
||||||
|
await getFirestore().doc(`fandoms/trn-f1/members/${OUTSIDER}`).set({ uid: OUTSIDER });
|
||||||
|
await expect(mark(OUTSIDER, entryId)).resolves.toMatchObject({ valid: true });
|
||||||
|
await getFirestore().doc(`fandoms/trn-f1/members/${OUTSIDER}`).delete();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('번역 정산', () => {
|
||||||
|
it('유효 마킹 1건 이상이면 예약이 사용 가능으로 옮겨지고 대사가 맞는다', async () => {
|
||||||
|
const { entryId } = await submit(AUTHOR, 'Hello fandom');
|
||||||
|
await mark(MARKER, entryId);
|
||||||
|
await age(entryId, 25);
|
||||||
|
await settle();
|
||||||
|
expect((await getFirestore().doc(`translationEntries/${entryId}`).get()).get('status')).toBe('confirmed');
|
||||||
|
const w = await wallet(AUTHOR);
|
||||||
|
expect(w.activityBalance).toBe(5);
|
||||||
|
expect(w.pendingActivity).toBe(0);
|
||||||
|
expect((await reconcileUser(AUTHOR)).ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('24시간 전이거나 마킹이 모자라면 pending 을 유지한다', async () => {
|
||||||
|
const { entryId } = await submit(AUTHOR, 'Hello fandom');
|
||||||
|
await settle();
|
||||||
|
await age(entryId, 25);
|
||||||
|
await settle();
|
||||||
|
expect((await getFirestore().doc(`translationEntries/${entryId}`).get()).get('status')).toBe('pending');
|
||||||
|
expect((await wallet(AUTHOR)).pendingActivity).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('최대 대기(72시간)를 넘기면 예약을 해제하고 excluded 로 닫는다', async () => {
|
||||||
|
const { entryId } = await submit(AUTHOR, 'Hello fandom');
|
||||||
|
await age(entryId, 73);
|
||||||
|
await settle();
|
||||||
|
const entry = await getFirestore().doc(`translationEntries/${entryId}`).get();
|
||||||
|
expect(entry.get('status')).toBe('excluded');
|
||||||
|
expect(entry.get('excludedReason')).toBe('unverified');
|
||||||
|
const w = await wallet(AUTHOR);
|
||||||
|
expect(w.activityBalance ?? 0).toBe(0);
|
||||||
|
expect(w.pendingActivity).toBe(0);
|
||||||
|
expect((await reconcileUser(AUTHOR)).ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('보상 0 건은 예약 없이 excluded 로 닫는다', async () => {
|
||||||
|
await submit(AUTHOR, 'Hello fandom');
|
||||||
|
const { entryId } = await submit(COPIER, 'Hello fandom');
|
||||||
|
await age(entryId, 25);
|
||||||
|
await settle();
|
||||||
|
expect((await getFirestore().doc(`translationEntries/${entryId}`).get()).get('status')).toBe('excluded');
|
||||||
|
expect((await wallet(COPIER)).activityBalance ?? 0).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('보류 지급 정산 — settlePendingGrant', () => {
|
||||||
|
const key = 'trn:ledger-unit';
|
||||||
|
|
||||||
|
it('확정은 한 번뿐이고, 확정 뒤 해제 요청은 기존 결과를 돌려준다', async () => {
|
||||||
|
await grantPop({ uid: AUTHOR, popType: 'activity', amount: 7, reasonCode: 'TRANSLATION', idempotencyKey: key, pending: true });
|
||||||
|
await settlePendingGrant({ uid: AUTHOR, idempotencyKey: key, outcome: 'confirm' });
|
||||||
|
await settlePendingGrant({ uid: AUTHOR, idempotencyKey: key, outcome: 'release' });
|
||||||
|
const w = await wallet(AUTHOR);
|
||||||
|
expect(w.activityBalance).toBe(7);
|
||||||
|
expect(w.pendingActivity).toBe(0);
|
||||||
|
expect((await reconcileUser(AUTHOR)).ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('보류가 아닌 지급은 정산할 수 없다', async () => {
|
||||||
|
await grantPop({ uid: AUTHOR, popType: 'activity', amount: 7, reasonCode: 'TRANSLATION', idempotencyKey: `${key}:x` });
|
||||||
|
await expect(settlePendingGrant({ uid: AUTHOR, idempotencyKey: `${key}:x`, outcome: 'confirm' }))
|
||||||
|
.rejects.toMatchObject({ details: { code: 'E-POP-02' } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('캠페인 응원 게시판 댓글 번역', () => {
|
||||||
|
const OPEN = 'campaigns/test-trn-c2';
|
||||||
|
const DRAFT = 'campaigns/test-trn-c3';
|
||||||
|
beforeEach(async () => {
|
||||||
|
const db = getFirestore();
|
||||||
|
const old = await db.collection('translationEntries')
|
||||||
|
.where('targetPath', '==', `${OPEN}/comments/ok`).get();
|
||||||
|
await Promise.all(old.docs.map((d) => d.ref.delete()));
|
||||||
|
await Promise.all([
|
||||||
|
db.doc(OPEN).set({ status: 'funding', fandomId: 'trn-f1' }),
|
||||||
|
db.doc(`${OPEN}/comments/ok`).set({ body: '응원', visibility: 'visible' }),
|
||||||
|
db.doc(`${OPEN}/comments/hidden`).set({ body: '숨김', visibility: 'hidden' }),
|
||||||
|
db.doc(DRAFT).set({ status: 'review', fandomId: 'trn-f1' }),
|
||||||
|
db.doc(`${DRAFT}/comments/ok`).set({ body: '응원', visibility: 'visible' }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('공개 캠페인의 보이는 댓글에만 달고, 루트는 캠페인이다', async () => {
|
||||||
|
const { entryId } = await submit(AUTHOR, 'Cheer', `${OPEN}/comments/ok`);
|
||||||
|
const entry = await getFirestore().doc(`translationEntries/${entryId}`).get();
|
||||||
|
expect(entry.get('rootPath')).toBe(OPEN);
|
||||||
|
expect(entry.get('fandomId')).toBe('trn-f1');
|
||||||
|
for (const path of [`${OPEN}/comments/hidden`, `${DRAFT}/comments/ok`, `${OPEN}/comments/none`]) {
|
||||||
|
await expect(submit(AUTHOR, 'x', path)).rejects.toMatchObject({ details: { code: 'E-TRN-03' } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user