라이브 티커 · 갤럭시 검색 색인 · 주간 랭킹 스냅샷 · 연속 ZAP 상한 · 가입 행성 Zap · 성운 목표 · 랭킹 갱신 안내 · 행성 터치 반응

This commit is contained in:
Sn-Kinos
2026-10-05 22:38:42 +09:00
parent 000c058097
commit 4bb92c2ee7
44 changed files with 727 additions and 78 deletions
+10
View File
@@ -43,6 +43,14 @@ export interface PolicyConfig {
stepTiers: StepTier[];
/** Zap 1회 기여 POP — Figma 「Zap 버튼」이 어드민 설정값이라 명시한 값. */
zapPop: number;
/**
* 연속 ZAP Rate Limit — UX·FX v1.5 §3 「연속 ZAP … Rate Limit 적용」 · PRD-03 「반복 클릭 FPI 제외」.
* 🟡 **임시값 — 회신 대기**(해석 확인 요청 2026-10-07 영역A Q-A12): 수치는 자료에 없다. 한 호출(앱이 연타를
* 묶음)의 최대 Zap 횟수와 같은 사용자의 호출 간 최소 간격(ms). 앱은 연타를 450ms 창으로 묶어 보내므로
* 간격 400ms 는 정상 연타를 막지 않는다(`zap_controller.dart`).
*/
zapMaxPerCall: number;
zapMinIntervalMs: number;
/** FPI 집계 창 — 설계서 §4.2 최근 7일 Rolling Window. */
fpiWindowDays: number;
/**
@@ -179,6 +187,8 @@ export const DEFAULT_POLICY: PolicyConfig = {
{ steps: 15_000, pop: 8 },
],
zapPop: 10,
zapMaxPerCall: 50,
zapMinIntervalMs: 400,
fpiWindowDays: 7,
// 단순 합산이 초기 정책이므로 전부 1.0 이다.
fpiWeights: {
+6 -2
View File
@@ -187,10 +187,13 @@ export async function defendEclipse(input: {
qualifiedParticipants: FieldValue.increment(qualified ? 1 : 0),
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
}
// 라이브 소셜 프루프는 **참여 합류 1회**만 — 개별 Shake 횟수를 방송하지 않는다(UX·FX v1.5 §1.1 · §3.1 · §5.1
// 「다른 팬의 행동은 개별 Shake 횟수를 모두 노출하지 않고 참여자 수 변화 중심」). 집약 수는 화면의 유효 참여자 표시.
if (accepted > 0 && myTotal === 0) {
tx.create(tickerRef, {
kind: TICKER_KIND_ECLIPSE_DEFENSE, eclipseId, uid,
nickname: publicNameOf(user),
defense: accepted,
linkTo: `/eclipse/${fandomId}/${eclipseId}`,
priority: TICKER_PRIORITY[TICKER_KIND_ECLIPSE_DEFENSE] ?? 50,
createdAt: FieldValue.serverTimestamp(),
@@ -1032,7 +1035,8 @@ export async function aggregateRankings(now = Date.now()): Promise<{ fandoms: nu
for (const axis of FPI_AXES) total[axis] += patch.fpiAxes[axis];
totals.set(code, total);
}
void writer.update(fandom.ref, { memberRanked: true });
// 사용자 랭킹 화면의 「마지막 갱신」 — 별첨 1 ② 예외처리.
void writer.update(fandom.ref, { memberRanked: true, memberRankedAt: FieldValue.serverTimestamp() });
memberTotal += members.size;
}
await writer.close();
@@ -0,0 +1,58 @@
import { onSchedule } from 'firebase-functions/v2/scheduler';
import { logger } from 'firebase-functions';
import { FieldValue, getFirestore } from 'firebase-admin/firestore';
import { rankWithTies } from '../ledger/fpi.js';
/**
* Weekly Ranking Snapshot — 별첨 B 「Ranking Snapshot — 주 1 회 Snapshot 영구 Archive」(B-20) ·
* 「Weekly Ranking — 주간 공식 Snapshot 은 영구 Archive」 · 9/16 EC06 「주간 Snapshot 유지」.
*
* - 모집단 · 순위는 FPI 랭킹과 같다(별첨 B 「FPI 랭킹」 — 정식 행성 중 최근 7일 유효 활동, 1·1·3 공동 순위).
* - **영구 Archive** — `create` 라 한 주에 한 번만 생기고 다시 쓰지 않는다. 규칙도 클라이언트 쓰기를 막는다.
* - Ranking Campaign Draft 자동 생성 · Admin 승인 게시는 Campaign 범위(3차)라 여기서 하지 않는다.
*
* 🟡 **임시값 — 회신 대기**(해석 확인 요청 2026-10-07 영역A Q-A11): 스냅샷 시각은 자료에 없다. KST 월요일 00:10
* (일일 마감 00:00 뒤)으로 두고, 그 시점의 집계값을 지난주(월~일) 공식 순위로 남긴다.
*/
export function weekKeyOf(at: Date): string {
// KST 기준 ISO 주차. 월요일 00:10 에 돌면 「방금 끝난 주」를 가리키도록 하루 전으로 계산한다.
const kst = new Date(at.getTime() + 9 * 3_600_000 - 86_400_000);
const day = (kst.getUTCDay() + 6) % 7;
const thursday = new Date(Date.UTC(kst.getUTCFullYear(), kst.getUTCMonth(), kst.getUTCDate() - day + 3));
const firstThursday = new Date(Date.UTC(thursday.getUTCFullYear(), 0, 4));
const week = 1 + Math.round(((thursday.getTime() - firstThursday.getTime()) / 86_400_000
- 3 + ((firstThursday.getUTCDay() + 6) % 7)) / 7);
return `${thursday.getUTCFullYear()}-W${String(week).padStart(2, '0')}`;
}
export async function snapshotWeeklyRanking(now = new Date()): Promise<{ week: string; created: boolean }> {
const db = getFirestore();
const week = weekKeyOf(now);
const fandoms = await db.collection('fandoms').where('rankingEligible', '==', true)
.select('name', 'fpiScore', 'fpiAxes', 'stage', 'level').get();
const planets = rankWithTies(fandoms.docs, (d) => Number(d.get('fpiScore') ?? 0), (d) => d.id)
.map(({ item, rank }) => ({
fandomId: item.id, name: item.get('name') ?? item.id, rank,
fpiScore: Number(item.get('fpiScore') ?? 0), fpiAxes: item.get('fpiAxes') ?? {},
stage: item.get('stage') ?? null, level: item.get('level') ?? null,
}));
try {
await db.doc(`rankingSnapshots/${week}`).create({
week, kind: 'weekly_fpi', planets, population: planets.length,
createdAt: FieldValue.serverTimestamp(),
});
return { week, created: true };
} catch (error) {
const code = (error as { code?: number | string }).code;
if (code !== 6 && code !== 'already-exists') throw error;
return { week, created: false };
}
}
export const fnWeeklyRankingSnapshot = onSchedule(
{ schedule: '10 0 * * 1', timeZone: 'Asia/Seoul' },
async () => {
logger.info('[ranking] 주간 Snapshot', await snapshotWeeklyRanking());
},
);
+1
View File
@@ -52,4 +52,5 @@ export { fnBriefingIngest, fnBriefingPublish } from './briefing/briefing.js';
export { fnPlanetEvolutionRecord, fnAdminPlanetEvolutionRun } from './fandom/evolution.js';
export { fnPlanetHistory } from './fandom/history.js';
export { fnWeeklyRankingSnapshot } from './fandom/weekly.js';
export { fnPassportOnUserUpdate, fnPassportOnMemberWrite, fnPassportDaily } from './account/passport.js';
+12 -2
View File
@@ -1,6 +1,6 @@
import { onCall } from 'firebase-functions/v2/https';
import { onSchedule } from 'firebase-functions/v2/scheduler';
import { getFirestore } from 'firebase-admin/firestore';
import { getFirestore, Timestamp } from 'firebase-admin/firestore';
import { logger } from 'firebase-functions';
import { callerUid, num, optionalStr, requireRole, str } from '../core/callable.js';
@@ -66,11 +66,14 @@ export const fnPopSpend = onCall(async (req) => {
fail(ERR.permissionDenied, '이 경로로 쓸 수 없는 사유입니다.');
}
const amount = positiveInt(num(req.data, 'amount'), 'amount');
const { zapPop } = await loadPolicy();
const { zapPop, zapMaxPerCall, zapMinIntervalMs } = await loadPolicy();
const count = Math.floor(amount / zapPop);
if (!Number.isSafeInteger(count) || count < 1 || count * zapPop !== amount) {
fail(ERR.popVerifyFailed, `Zap 은 ${zapPop} POP 단위로만 보낼 수 있습니다.`);
}
// 연속 ZAP Rate Limit(FX §3) — 한 호출 상한과 호출 간격. 차감과 같은 트랜잭션에서 판정 · 기록한다.
if (count > zapMaxPerCall) fail(ERR.questAbnormal, '한 번에 보낼 수 있는 Zap 횟수를 넘었습니다.');
const limitRef = getFirestore().doc(`users/${uid}/rateLimits/zap`);
return spendPop({
uid,
popType: popType(req.data),
@@ -83,6 +86,13 @@ export const fnPopSpend = onCall(async (req) => {
...(optionalStr(req.data, 'fandomId') ? { fandomId: str(req.data, 'fandomId') } : {}),
// 묶음 횟수. 원장 행에 남아 「Zap N 회」를 세고, FPI 단위가 된다.
count,
precheck: async (tx) => {
const last = (await tx.get(limitRef)).get('lastAt') as Timestamp | undefined;
if (last && Date.now() - last.toMillis() < zapMinIntervalMs) {
fail(ERR.questAbnormal, '너무 빠르게 Zap 했습니다. 잠시 뒤 다시 시도해 주세요.');
}
},
alsoWrite: (tx) => tx.set(limitRef, { lastAt: Timestamp.now() }),
});
});