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,
});