라이브 티커 · 갤럭시 검색 색인 · 주간 랭킹 스냅샷 · 연속 ZAP 상한 · 가입 행성 Zap · 성운 목표 · 랭킹 갱신 안내 · 행성 터치 반응
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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());
|
||||
},
|
||||
);
|
||||
@@ -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';
|
||||
|
||||
@@ -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() }),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -190,7 +190,8 @@ describe('분산 카운터 — 카운트 유실 0 (별첨 1 ① 예외 처리)',
|
||||
expect(summary.get('defense')).toBe(60);
|
||||
expect(summary.get('defenseCount')).toBe(20);
|
||||
expect(await logCount()).toBe(20);
|
||||
expect(await tickerCount()).toBe(20);
|
||||
// 티커는 사람당 합류 1회 — 개별 Shake 를 방송하지 않는다(FX §5.1).
|
||||
expect(await tickerCount()).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -263,8 +264,8 @@ describe('서버 검증 — 단말 값을 믿지 않는다 (별첨 1 ②)', () =
|
||||
});
|
||||
expect(third.accepted).toBe(0);
|
||||
expect((await shardSum()).defense).toBe(60);
|
||||
// 받아들인 것이 없으면 티커에도 올리지 않는다 — 빈 참여가 흐르면 안 된다.
|
||||
expect(await tickerCount()).toBe(2);
|
||||
// 티커는 합류 1회뿐이다 — 묶음마다 · 빈 참여마다 흐르지 않는다(FX §1.1 · §5.1).
|
||||
expect(await tickerCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('속도 제한보다 빠른 연속 호출을 끊는다', async () => {
|
||||
@@ -301,7 +302,7 @@ describe('서버 검증 — 단말 값을 믿지 않는다 (별첨 1 ②)', () =
|
||||
});
|
||||
|
||||
describe('티커 이벤트 — Global Ticker 모양 (별첨 B)', () => {
|
||||
it('참여마다 종류·닉네임·참여량·우선순위·이동처를 남긴다', async () => {
|
||||
it('합류할 때 종류·닉네임·우선순위·이동처를 남기고 Shake 횟수는 싣지 않는다', async () => {
|
||||
await seed();
|
||||
await defendEclipse({
|
||||
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 't1', shakes: 7,
|
||||
@@ -314,7 +315,7 @@ describe('티커 이벤트 — Global Ticker 모양 (별첨 B)', () => {
|
||||
// 다른 종류(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('defense')).toBeUndefined();
|
||||
expect(event.get('eclipseId')).toBe(ECLIPSE);
|
||||
expect(typeof event.get('priority')).toBe('number');
|
||||
expect(event.get('linkTo')).toBe(`/eclipse/${FANDOM}/${ECLIPSE}`);
|
||||
|
||||
@@ -160,7 +160,7 @@ it('fn-pop-spend 는 Zap 만 받고 횟수를 서버가 세며 Zap 한 가입
|
||||
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 db.doc('config/policy').set({ zapPop: 10, zapMinIntervalMs: 0 }, { 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' }))
|
||||
@@ -192,3 +192,22 @@ it('fn-pop-spend 는 Zap 만 받고 횟수를 서버가 세며 Zap 한 가입
|
||||
await db.recursiveDelete(db.collection(`fandoms/${a}/fpiEvents`));
|
||||
await db.recursiveDelete(db.collection(`fandoms/${b}/fpiEvents`));
|
||||
});
|
||||
|
||||
it('연속 ZAP Rate Limit — 한 호출 상한 · 호출 간격(운영값, FX §3)', 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, zapMaxPerCall: 3, zapMinIntervalMs: 60_000 }, { merge: true });
|
||||
await grantPop({ uid, popType: 'activity', amount: 100, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'rl:seed' });
|
||||
await expect(run({ popType: 'activity', amount: 40, reasonCode: 'ZAP_CONTRIBUTION', idempotencyKey: 'rl:4' }))
|
||||
.rejects.toMatchObject({ details: { code: 'E-QST-02' } });
|
||||
await run({ popType: 'activity', amount: 30, reasonCode: 'ZAP_CONTRIBUTION', idempotencyKey: 'rl:1' });
|
||||
// 같은 요청의 재시도는 간격에 걸리지 않는다(멱등).
|
||||
await run({ popType: 'activity', amount: 30, reasonCode: 'ZAP_CONTRIBUTION', idempotencyKey: 'rl:1' });
|
||||
await expect(run({ popType: 'activity', amount: 10, reasonCode: 'ZAP_CONTRIBUTION', idempotencyKey: 'rl:2' }))
|
||||
.rejects.toMatchObject({ details: { code: 'E-QST-02' } });
|
||||
expect((await db.doc(`users/${uid}/wallet/main`).get()).get('activityBalance')).toBe(70);
|
||||
await db.doc('config/policy').set({ zapMaxPerCall: 50, zapMinIntervalMs: 400 }, { merge: true });
|
||||
await db.recursiveDelete(db.collection(`fandoms/${a}/fpiEvents`));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { afterAll, beforeAll, expect, it } from 'vitest';
|
||||
import { getFirestore } from 'firebase-admin/firestore';
|
||||
import { bootAdmin, shutdownAdmin } from './support.js';
|
||||
import { snapshotWeeklyRanking, weekKeyOf } from '../src/fandom/weekly.js';
|
||||
|
||||
/** 별첨 B 「Ranking Snapshot — 주 1회 영구 Archive」. */
|
||||
const ids = ['test-week-a', 'test-week-b', 'test-week-c', 'test-week-out'];
|
||||
beforeAll(async () => {
|
||||
bootAdmin();
|
||||
const db = getFirestore();
|
||||
await db.doc('fandoms/test-week-a').set({ name: 'A', fpiScore: 300, rankingEligible: true });
|
||||
await db.doc('fandoms/test-week-b').set({ name: 'B', fpiScore: 300, rankingEligible: true });
|
||||
await db.doc('fandoms/test-week-c').set({ name: 'C', fpiScore: 100, rankingEligible: true });
|
||||
await db.doc('fandoms/test-week-out').set({ name: 'X', fpiScore: 999, rankingEligible: false });
|
||||
});
|
||||
afterAll(async () => {
|
||||
const db = getFirestore();
|
||||
for (const id of ids) await db.recursiveDelete(db.doc(`fandoms/${id}`));
|
||||
await db.recursiveDelete(db.collection('rankingSnapshots'));
|
||||
await shutdownAdmin();
|
||||
});
|
||||
|
||||
it('월요일 00:10 KST 에 돌면 방금 끝난 주를 가리킨다', () => {
|
||||
// 2026-10-05(월) 00:10 KST → 9/28~10/4 = 2026-W40.
|
||||
expect(weekKeyOf(new Date('2026-10-05T00:10:00+09:00'))).toBe('2026-W40');
|
||||
expect(weekKeyOf(new Date('2027-01-04T00:10:00+09:00'))).toBe('2026-W53');
|
||||
});
|
||||
|
||||
it('모집단 행성의 공동 순위를 한 번만 영구 보관한다', async () => {
|
||||
const db = getFirestore();
|
||||
const at = new Date('2026-10-05T00:10:00+09:00');
|
||||
expect(await snapshotWeeklyRanking(at)).toEqual({ week: '2026-W40', created: true });
|
||||
const snap = (await db.doc('rankingSnapshots/2026-W40').get()).data()!;
|
||||
const ours = (snap.planets as Array<{ fandomId: string; rank: number }>)
|
||||
.filter((p) => ids.includes(p.fandomId)).map((p) => [p.fandomId, p.rank]);
|
||||
expect(ours.map(([id]) => id)).toEqual(['test-week-a', 'test-week-b', 'test-week-c']);
|
||||
// 같은 점수는 같은 순위(1·1·3) — 다른 테스트 행성이 섞여도 상대 순서는 같다.
|
||||
expect(ours[0]![1]).toBe(ours[1]![1]);
|
||||
// 다시 돌아도 덮지 않는다.
|
||||
await db.doc('fandoms/test-week-c').update({ fpiScore: 9_999 });
|
||||
expect(await snapshotWeeklyRanking(at)).toEqual({ week: '2026-W40', created: false });
|
||||
const again = (await db.doc('rankingSnapshots/2026-W40').get()).data()!;
|
||||
expect(again.planets).toEqual(snap.planets);
|
||||
});
|
||||
Reference in New Issue
Block a user