Zap·후원 FPI 귀속 · 이클립스 방어 Shake 전용 · 가입 팬덤만 방어
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'>;
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user