Passport Tier 팬덤 단위 · 활동 Badge · 진화 Emblem · Eclipse 참여 기록

This commit is contained in:
Sn-Kinos
2026-10-05 22:09:10 +09:00
parent 71ec17dc3c
commit cc0f2fb292
36 changed files with 877 additions and 81 deletions
@@ -0,0 +1,253 @@
import { onDocumentUpdated, onDocumentWritten } from 'firebase-functions/v2/firestore';
import { onSchedule } from 'firebase-functions/v2/scheduler';
import { logger } from 'firebase-functions';
import { FieldValue, getFirestore, Timestamp } from 'firebase-admin/firestore';
import { FPI_AXES, type FpiAxis } from '../core/config.js';
import { passportRank, type PassportTier } from '../core/level.js';
/**
* 팬덤별 Passport Tier · 활동 Badge — 상세정의 v1.6.1 **4장** · 별첨 B 「Passport Tier」「사용자 Level/Badge」
* 「팬덤 최초 100명」 · 9/21 회신 **5절** · 10/3 통합회신 **#10** · UX·FX 정렬기준 v1.5 **§7**.
*
* - **Tier 는 사용자 × 팬덤 단위**다(10/3 #10). User Level · Badge · Role 과 별개이며 강등하지 않고
* 탈퇴 후에도 보존한다. 멤버 문서(`fandoms/{f}/members/{uid}`)는 탈퇴 때 지워지므로 Passport 는
* 지우지 않는 `users/{uid}/passports/{fandomId}` 에 둔다 — 재가입하면 같은 문서를 이어 쓴다(9/21 5절).
* - **Badge 는 활동 카테고리별 7단계**(상세정의 4장, 초기 임계치 10/100/500/1,500/4,000/10,000/30,000,
* Badge 별 조정). 사용자 공통 값이다 — `users/{uid}/badges/{badgeId}`.
* - 진화 기념 · 최초 100명 기념 Badge 는 POP/FPI/권한 보너스가 없다(별첨 B).
*/
/** 상세정의 4장 「Badge 는 활동 카테고리별 7단계. 초기 임계치」. Badge 마스터가 덮는다. */
export const BADGE_STAGE_THRESHOLDS = [10, 100, 500, 1_500, 4_000, 10_000, 30_000] as const;
export interface ActivityBadgeDef {
id: string;
/** 집계 카테고리. `users.activityCounts.{axis}` 를 센다. */
axis: FpiAxis;
thresholds: number[];
}
/**
* 🟡 **임시값 — 회신 대기**(해석 확인 요청 2026-10-07 영역A **Q-A08**). 상세정의 4장은 「활동 카테고리별」
* 이라고만 하고 카테고리 목록을 주지 않는다. 서버가 인정한 활동이 모이는 FPI 5축(별첨 B 「FPI 5축」)을
* 카테고리로 두었다. `badges/{id}`(kind `activity`) 마스터가 있으면 그것이 우선한다(Admin 「Badge 관리」).
*/
export const DEFAULT_ACTIVITY_BADGES: ActivityBadgeDef[] = FPI_AXES.map((axis) => ({
id: `activity_${axis}`, axis, thresholds: [...BADGE_STAGE_THRESHOLDS],
}));
/** 누적 [count] 가 도달한 단계(0 = 미획득, 1~7). */
export function badgeStageOf(thresholds: readonly number[], count: number): number {
let stage = 0;
for (const t of thresholds) {
if (count >= t) stage += 1;
else break;
}
return stage;
}
export interface TierRule {
tier: Exclude<PassportTier, 'SILVER'>;
minUserLevel: number;
/** 그 팬덤 첫 가입 이후 일수. */
minActiveDays: number;
/** [minBadgeStage] 이상인 Badge 수. */
minBadges: number;
minBadgeStage: number;
/** 그 팬덤 누적 기여 POP. */
minContributedPop: number;
enabled: boolean;
}
/**
* Passport Tier 승급 규칙 — `config/passportTierRules.rules` 가 덮는다(Firestore 설계서 §2.17 · C-133).
*
* 🟡 **임시값 — 회신 대기**(영역A **Q-A07**). 상세정의 4장 초기 기준 「Gold Lv.30 이상 + 활동기간 + 핵심 Badge /
* Diamond Lv.70 이상 + 장기활동 + 복수 상위 Badge + 누적기여. 세부조건 Admin 조정」 중 Level 만 수치가 있다.
* 활동기간 30/180일 · 누적기여 100,000 POP 은 스위치 제안값이며 10/3 #10 이 「확정하지 않음 · Admin 운영값으로
* 구현하고 별도 확정」으로 남겼다. 「핵심 Badge」 1개 · 「복수 상위 Badge」 3단계 이상 2개는 우리가 둔 값이다.
*/
export const DEFAULT_TIER_RULES: TierRule[] = [
{ tier: 'GOLD', minUserLevel: 30, minActiveDays: 30, minBadges: 1, minBadgeStage: 1,
minContributedPop: 0, enabled: true },
{ tier: 'DIAMOND', minUserLevel: 70, minActiveDays: 180, minBadges: 2, minBadgeStage: 3,
minContributedPop: 100_000, enabled: true },
];
export interface TierInputs {
userLevel: number;
activeDays: number;
badgeStages: number[];
contributedPop: number;
}
/** 규칙을 만족하는 가장 높은 Tier. **강등하지 않는다** — [current] 보다 낮으면 [current]. */
export function tierOf(rules: TierRule[], inputs: TierInputs, current?: string | null): PassportTier {
let best: PassportTier = 'SILVER';
for (const rule of rules) {
if (!rule.enabled) continue;
const badges = inputs.badgeStages.filter((s) => s >= rule.minBadgeStage).length;
const ok = inputs.userLevel >= rule.minUserLevel && inputs.activeDays >= rule.minActiveDays &&
badges >= rule.minBadges && inputs.contributedPop >= rule.minContributedPop;
if (ok && passportRank(rule.tier) > passportRank(best)) best = rule.tier;
}
return passportRank(current) > passportRank(best) ? current as PassportTier : best;
}
async function loadTierRules(): Promise<TierRule[]> {
const raw = (await getFirestore().doc('config/passportTierRules').get()).get('rules');
if (!Array.isArray(raw) || raw.length === 0) return DEFAULT_TIER_RULES;
return raw.filter((r) => r && (r.tier === 'GOLD' || r.tier === 'DIAMOND')).map((r) => ({
...DEFAULT_TIER_RULES.find((d) => d.tier === r.tier)!, ...r,
}));
}
async function loadActivityBadges(): Promise<ActivityBadgeDef[]> {
const snap = await getFirestore().collection('badges').where('kind', '==', 'activity').get();
const defs = snap.docs.flatMap((d) => {
const axis = d.get('axis');
const thresholds = d.get('thresholds');
return FPI_AXES.includes(axis) && Array.isArray(thresholds) && thresholds.every(Number.isFinite)
? [{ id: d.id, axis, thresholds }] : [];
});
return defs.length > 0 ? defs : DEFAULT_ACTIVITY_BADGES;
}
const DAY_MS = 86_400_000;
/** 기념 Badge 한 건 — 이미 있으면 그대로 둔다(재전달 · 재가입에도 한 번). */
export async function awardBadge(
uid: string, badgeId: string, data: Record<string, unknown>,
): Promise<void> {
try {
await getFirestore().doc(`users/${uid}/badges/${badgeId}`).create({
badgeId, stage: 1, ...data,
awardedAt: FieldValue.serverTimestamp(), updatedAt: FieldValue.serverTimestamp(),
});
} catch (error) {
const code = (error as { code?: number | string }).code;
if (code !== 6 && code !== 'already-exists') throw error;
}
}
/** 활동 카운터로 활동 Badge 단계를 올린다. 내리지 않는다. */
export async function awardActivityBadges(uid: string, counts: Record<string, unknown>): Promise<void> {
const db = getFirestore();
const defs = await loadActivityBadges();
await Promise.all(defs.map((def) => db.runTransaction(async (tx) => {
const ref = db.doc(`users/${uid}/badges/${def.id}`);
const count = Number(counts[def.axis] ?? 0);
const stage = badgeStageOf(def.thresholds, Number.isFinite(count) ? count : 0);
const snap = await tx.get(ref);
const prev = Number(snap.get('stage') ?? 0);
if (stage <= prev) return;
tx.set(ref, {
badgeId: def.id, kind: 'activity', axis: def.axis, stage, count,
...(snap.exists ? {} : { awardedAt: FieldValue.serverTimestamp() }),
stageChangedAt: FieldValue.serverTimestamp(), updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
})));
}
/**
* 한 팬덤의 Passport 를 다시 산출한다. 가입 중일 때만 Tier 를 올린다(탈퇴 중에는 보존만).
* 처음이면 첫 가입 시각 · 순번을 남긴다 — 재가입해도 새 Passport 로 초기화하지 않는다(9/21 5절).
*/
export async function refreshPassport(uid: string, fandomId: string): Promise<PassportTier | null> {
const db = getFirestore();
const [rules, badges] = await Promise.all([
loadTierRules(), db.collection(`users/${uid}/badges`).get(),
]);
const badgeStages = badges.docs.map((d) => Number(d.get('stage') ?? 0));
return db.runTransaction(async (tx) => {
const passportRef = db.doc(`users/${uid}/passports/${fandomId}`);
const [user, member, passport] = await Promise.all([
tx.get(db.doc(`users/${uid}`)), tx.get(db.doc(`fandoms/${fandomId}/members/${uid}`)), tx.get(passportRef),
]);
if (!member.exists) return (passport.get('tier') as PassportTier | undefined) ?? null;
const joinedAt = member.get('joinedAt');
const firstJoinedAt = passport.get('firstJoinedAt') instanceof Timestamp
? passport.get('firstJoinedAt') as Timestamp
: joinedAt instanceof Timestamp ? joinedAt : Timestamp.now();
// ponytail: 첫 가입부터 센다(탈퇴 기간 포함). 탈퇴 기간을 빼려면 가입 · 탈퇴 이력이 필요하다.
const activeDays = Math.floor((Date.now() - firstJoinedAt.toMillis()) / DAY_MS);
const current = passport.get('tier') as string | undefined;
const contributedPop = Number(member.get('contributedPop') ?? 0);
const tier = tierOf(rules, {
userLevel: Number(user.get('userLevel') ?? 1),
activeDays, badgeStages, contributedPop,
}, current);
if (passport.exists && current === tier && passport.get('activeDays') === activeDays &&
passport.get('contributedPop') === contributedPop) return tier;
// 누적 기여도 남긴다 — 멤버 문서는 탈퇴 때 지워지지만 Passport 기록은 보존한다(상세정의 4장 · 5장).
tx.set(passportRef, {
fandomId, tier, activeDays, contributedPop,
...(passport.exists ? {} : {
firstJoinedAt, memberNo: member.get('memberNo') ?? null, createdAt: FieldValue.serverTimestamp(),
}),
...(current !== tier ? { tierChangedAt: FieldValue.serverTimestamp() } : {}),
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
return tier;
});
}
/** 레벨 · 활동 카운터가 바뀌면 Badge 를 올리고 가입한 모든 팬덤의 Passport 를 다시 본다. */
export const fnPassportOnUserUpdate = onDocumentUpdated(
{ document: 'users/{uid}', retry: true },
async (event) => {
const change = event.data;
if (!change) return;
const uid = event.params.uid as string;
const countsChanged = JSON.stringify(change.before.get('activityCounts') ?? {}) !==
JSON.stringify(change.after.get('activityCounts') ?? {});
const levelChanged = change.before.get('userLevel') !== change.after.get('userLevel');
if (!countsChanged && !levelChanged) return;
if (countsChanged) await awardActivityBadges(uid, change.after.get('activityCounts') ?? {});
const fandomIds = change.after.get('fandomIds');
if (!Array.isArray(fandomIds)) return;
for (const fandomId of fandomIds) {
if (typeof fandomId === 'string' && fandomId && !fandomId.includes('/')) await refreshPassport(uid, fandomId);
}
},
);
/**
* 가입 · 기여가 바뀌면 그 팬덤 Passport 를 다시 본다. 처음 가입이면서 순번이 #001~#100 이면
* 최초 100명 기념 Badge(별첨 B 「팬덤 최초 100명」 · 10/3 회신 성운 「기념 Badge 기준」).
*/
export const fnPassportOnMemberWrite = onDocumentWritten(
{ document: 'fandoms/{fandomId}/members/{uid}', retry: true },
async (event) => {
const change = event.data;
if (!change?.after.exists) return;
const { fandomId, uid } = event.params as { fandomId: string; uid: string };
const created = !change.before.exists;
if (!created && change.before.get('contributedPop') === change.after.get('contributedPop')) return;
if (created) {
const memberNo = Number(change.after.get('memberNo'));
if (Number.isInteger(memberNo) && memberNo >= 1 && memberNo <= 100) {
await awardBadge(uid, `founder100_${fandomId}`, {
kind: 'founder100', fandomId, memberNo,
stageAtJoin: (await getFirestore().doc(`fandoms/${fandomId}`).get()).get('stage') ?? null,
});
}
}
await refreshPassport(uid, fandomId);
},
);
/**
* 활동기간 조건은 시간이 지나야 채워진다 — 하루 한 번 가입 중인 모든 멤버십의 Passport 를 다시 본다.
* 배포 전부터 있던 멤버십(Passport 문서 없음)도 첫 실행에서 만들어진다.
*/
export const fnPassportDaily = onSchedule({ schedule: 'every day 04:30', timeZone: 'Asia/Seoul' }, async () => {
// ponytail: 전수 스캔. 사용자가 늘면 일자별 샤딩으로 나눈다.
const snap = await getFirestore().collectionGroup('members').select().get();
for (const doc of snap.docs) {
const fandomId = doc.ref.parent.parent?.id;
if (fandomId) await refreshPassport(doc.id, fandomId);
}
logger.info('[passport] 일일 재산출', { scanned: snap.size });
});