254 lines
12 KiB
TypeScript
254 lines
12 KiB
TypeScript
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 });
|
||
});
|