Files
fanuzu/app/firebase/functions/src/account/passport.ts
T

254 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 });
});