Zap·후원 FPI 귀속 · 이클립스 방어 Shake 전용 · 가입 팬덤만 방어

This commit is contained in:
Sn-Kinos
2026-10-05 21:53:20 +09:00
parent 17c658b272
commit 71ec17dc3c
21 changed files with 196 additions and 134 deletions
+7 -1
View File
@@ -9,7 +9,7 @@ import { callerUid, num, str } from '../core/callable.js';
import { ERR, fail, positiveInt } from '../core/errors.js';
import { FPI_AXES, loadPolicy, weightsAreUsable, type FpiAxis } from '../core/config.js';
import { spendPop } from '../ledger/ledger.js';
import { rankWithTies } from '../ledger/fpi.js';
import { isMemberOf, rankWithTies } from '../ledger/fpi.js';
import { txIdOf } from '../core/ids.js';
import { dayKey } from '../core/time.js';
import { notifyEclipseWarning } from '../notify/push.js';
@@ -106,6 +106,10 @@ export async function defendEclipse(input: {
db.doc(`users/${uid}`).get(),
loadEclipseSettings(),
]);
// 방어는 그 팬덤의 활동이다 — 가입하지 않은 행성의 이클립스는 막을 수 없다(10/3 회신 #10).
if (!isMemberOf(user, fandomId)) {
fail(ERR.permissionDenied, '가입한 팬덤의 이클립스에만 참여할 수 있습니다.');
}
const idempotencyKey = `defend:${uid}:${eclipseId}:${clientSeq}`;
const logRef = eclipseRef.collection('defenseLogs').doc(txIdOf(idempotencyKey));
@@ -327,6 +331,8 @@ export const DEFAULT_ECLIPSE: EclipseSettings = {
dauWindowDays: 7,
windowSec: 60,
qualifyShakes: 60,
// 🟡 임시값 — 회신 대기(해석 확인 요청 2026-10-07 영역A Q-A10). 아래 세 값은 자료 근거가 없다
// (9/16 회신 3절 4항 「센서 판정 수치는 근거 없이 추가 확정하지 않음」). `config/eclipse` 운영값이 덮는다.
perCallMaxShakes: 50,
perUserMaxDefense: 500,
minIntervalMs: 300,
+7
View File
@@ -28,6 +28,13 @@ export function fandomOfUser(user: DocumentSnapshot | null | undefined): string
return typeof id === 'string' && id.length > 0 && !id.includes('/') ? id : null;
}
/** 사용자가 그 팬덤에 가입해 있는가 — `users.fandomIds`(가입 · 탈퇴 트랜잭션이 유지) 또는 대표 팬덤. */
export function isMemberOf(user: DocumentSnapshot | null | undefined, fandomId: string): boolean {
const joined = user?.get('fandomIds');
return fandomId === fandomOfUser(user) || fandomId === user?.get('fandomId') ||
(Array.isArray(joined) && joined.includes(fandomId));
}
export interface FpiActivity {
uid: string;
fandomId: string | null;
+17 -7
View File
@@ -1,10 +1,10 @@
import { getFirestore, FieldValue, Timestamp } from 'firebase-admin/firestore';
import type { DocumentReference, Firestore, Transaction } from 'firebase-admin/firestore';
import type { DocumentReference, DocumentSnapshot, 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 { fandomOfUser, isMemberOf, voidCompletions, writeFpiEvent } from './fpi.js';
import { publicNameOf } from '../core/names.js';
/**
@@ -491,10 +491,13 @@ async function spendParts(
}, { merge: true });
});
// Zap 의 귀속 팬덤은 **서버가 이 트랜잭션에서 읽은 대표 팬덤**이다(FPI 와 같은 판정).
// 클라이언트 `fandomId` 를 믿으면 남의 행성 성장을 올린다. 후원은 호출자(fundCampaign)가
// 캠페인 문서에서 정한 팬덤을 넘긴다.
const fandomId = input.reasonCode === 'ZAP_CONTRIBUTION' ? fandomOfUser(userSnap) : input.fandomId;
// Zap 은 **Zap 한 행성**에 귀속한다(상세정의 5장 「B팬덤 Zap 은 B행성에 귀속」 · 10/3 회신 #10).
// 남의 행성 성장을 올리지 못하게 대상이 **이 트랜잭션에서 읽은 가입 팬덤**인지 서버가
// 확인한다. 대상이 없으면(구 클라이언트) 대표 팬덤. 후원은 호출자(fundCampaign)가 캠페인
// 문서에서 정한 팬덤을 넘긴다 — 성장 · FPI 모두 그 팬덤이다.
const fandomId = input.reasonCode === 'ZAP_CONTRIBUTION'
? zapFandomOf(userSnap, input.fandomId)
: input.fandomId;
if (CONTRIBUTION_REASONS.includes(input.reasonCode)) {
// 누적 기여 POP 은 Passport 입력 · 기여 실적 표시용으로 유지한다. 등급 문턱은
// 더 이상 여기서 매기지 않는다(C-18 — 누적 기여 POP 5등급 치환 폐기).
@@ -519,7 +522,7 @@ async function spendParts(
// 금액 ÷ zapPop 으로 계산) · 후원 1건. EXP 는 주지 않는다 — POP 을 쓰는 행위는 1 POP
// 단위로 쪼개 반복할 수 있어 건당 EXP 가 파밍된다(C-18 · 리뷰 B1).
writeFpiEvent(tx, {
uid: input.uid, fandomId: fandomOfUser(userSnap), axis: 'contribution',
uid: input.uid, fandomId: fandomId ?? null, axis: 'contribution',
units: input.reasonCode === 'ZAP_CONTRIBUTION' ? input.count ?? 1 : 1,
activity: input.reasonCode === 'ZAP_CONTRIBUTION' ? 'zap' : 'campaign_fund',
sourceId, occurredAt: new Date(),
@@ -541,6 +544,13 @@ async function spendParts(
}
}
/** Zap 대상 팬덤 — 가입 팬덤(`users.fandomIds` 또는 대표 팬덤)일 때만. 없으면 대표 팬덤. */
function zapFandomOf(user: DocumentSnapshot | null, target: string | undefined): string | null {
if (!target) return fandomOfUser(user);
if (isMemberOf(user, target)) return target;
fail(ERR.permissionDenied, '가입한 팬덤의 행성에만 Zap 할 수 있습니다.');
}
type RefundReason = Extract<ReasonCode,
'CAMPAIGN_REFUND' | 'QNA_REFUND' | 'CAMPAIGN_FEE_REFUND' | 'QUEST_POOL_REFUND'>;
+4 -2
View File
@@ -55,8 +55,9 @@ const USER_SPEND_REASONS: readonly ReasonCode[] = ['ZAP_CONTRIBUTION'];
* `fn-pop-spend` — 본인 계정에서만 차감한다. 지금 열린 사유는 Zap 하나다.
*
* **Zap 횟수는 서버가 센다** — `금액 ÷ zapPop`(운영값). 클라이언트 `count` 를 믿으면
* 1 POP 으로 FPI CONTRIBUTION 축을 무제한 올린다(리뷰 B1). 귀속 팬덤도 원장이 사용자
* 문서에서 정한다 — 클라이언트 `fandomId` 는 받지 않는다(리뷰 M1).
* 1 POP 으로 FPI CONTRIBUTION 축을 무제한 올린다(리뷰 B1). 귀속 팬덤은 Zap 한 행성
* `fandomId` 다(상세정의 5장 · 10/3 #10). 원장이 같은 트랜잭션에서 가입 여부를 확인하므로
* 가입하지 않은 행성은 거부된다(리뷰 M1 — 남의 행성 성장을 올리는 길을 막는다).
*/
export const fnPopSpend = onCall(async (req) => {
const uid = callerUid(req);
@@ -79,6 +80,7 @@ export const fnPopSpend = onCall(async (req) => {
// (`bounty:…` · `campaignFee:…`)를 먼저 차지해 그 거래를 막을 수 있다.
idempotencyKey: `user:${uid}:${str(req.data, 'idempotencyKey')}`,
...(optionalStr(req.data, 'refPath') ? { refPath: str(req.data, 'refPath') } : {}),
...(optionalStr(req.data, 'fandomId') ? { fandomId: str(req.data, 'fandomId') } : {}),
// 묶음 횟수. 원장 행에 남아 「Zap N 회」를 세고, FPI 단위가 된다.
count,
});
@@ -278,6 +278,16 @@ describe('서버 검증 — 단말 값을 믿지 않는다 (별첨 1 ②)', () =
expect((await shardSum()).defense).toBe(5);
});
it('가입하지 않은 팬덤의 이클립스는 막을 수 없다(10/3 #10)', async () => {
await seed();
await getFirestore().doc('users/test-eclipse-outsider').set({ nickname: '외부', fandomId: 'other' });
await expect(defendEclipse({
uid: 'test-eclipse-outsider', fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'o1', shakes: 5,
})).rejects.toMatchObject({ details: { code: 'E-PERM-01' } });
expect((await shardSum()).defense).toBe(0);
await getFirestore().doc('users/test-eclipse-outsider').delete();
});
it('다른 사용자는 서로의 속도 제한에 걸리지 않는다', async () => {
await seed({ minIntervalMs: 60_000 });
await defendEclipse({
+23 -6
View File
@@ -146,12 +146,16 @@ it('캠페인 후원은 기여 POP 만 쓰고 targetPop 초과를 막으며 캠
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();
// FPI 도 캠페인 팬덤 b 로 — 성장과 갈라지지 않는다(10/3 #10).
expect((await db.collection(`fandoms/${b}/fpiEvents`).get()).size).toBe(1);
expect((await db.collection(`fandoms/${a}/fpiEvents`).get()).size).toBe(0);
await db.recursiveDelete(db.collection(`fandoms/${b}/fpiEvents`));
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 () => {
it('fn-pop-spend 는 Zap 만 받고 횟수를 서버가 세며 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(
@@ -164,14 +168,27 @@ it('fn-pop-spend 는 Zap 만 받고 횟수 · 귀속 팬덤을 서버가 정하
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 로 적는다.
// 가입하지 않은 행성 b 에는 Zap 할 수 없다 — 남의 행성 성장을 올리지 못한다(리뷰 M1).
await expect(run({ popType: 'activity', amount: 30, fandomId: b,
reasonCode: 'ZAP_CONTRIBUTION', idempotencyKey: 'zap:b0' }))
.rejects.toMatchObject({ details: { code: 'E-PERM-01' } });
// b 에 가입한 뒤에는 **Zap 한 행성 b** 에 성장 · FPI · 기여가 귀속된다(상세정의 5장 · 10/3 #10).
// 대표 팬덤은 a 그대로다. 클라이언트 count 1e9 는 무시하고 30 ÷ 10 = 3 회로 적는다.
await db.doc(`users/${uid}`).update({ fandomIds: [a, b] });
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();
const events = await db.collection(`fandoms/${b}/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.collection(`fandoms/${a}/fpiEvents`).get()).size).toBe(0);
expect((await db.doc(`fandoms/${b}`).get()).get('growthPop')).toBe(30);
expect((await db.doc(`fandoms/${a}`).get()).get('growthPop')).toBeUndefined();
expect((await db.doc(`fandoms/${b}/members/${uid}`).get()).get('contributedPop')).toBe(30);
// 대상이 없는 구 클라이언트는 대표 팬덤 a 로 간다.
await run({ popType: 'activity', amount: 10, reasonCode: 'ZAP_CONTRIBUTION', idempotencyKey: 'zap:a1' });
expect((await db.doc(`fandoms/${a}`).get()).get('growthPop')).toBe(10);
expect((await db.doc(`users/${uid}`).get()).get('userExp')).toBeUndefined();
await db.recursiveDelete(db.collection(`fandoms/${a}/fpiEvents`));
await db.recursiveDelete(db.collection(`fandoms/${b}/fpiEvents`));
});