d3ro-voice/apps/admin/src/lib/subscription-metrics.ts
Yun Chan 5a34f66981 feat(admin): 예전/최신 어드민 통합 — 실데이터 복원 + 인증 아키텍처 정리
예전 배포본(HEAD)의 상세 기능을 새 아키텍처(인증=.NET 백엔드, 데이터=Supabase)
위에 실데이터로 복원. 예전 HEAD는 서명 쿠키를 거부하고 위조 쿠키는 통과시키는
인증 결함이 있었고, 삭제된 페이지 다수는 백엔드 호출 0인 하드코딩 목업이었음.

인증/세션
- 로그인 이메일 전용화(username 폐지), 에러 키별 안내 메시지
- ADMIN_COOKIE_SECURE 옵션: TLS 없는 LAN HTTP 배포에서 Secure 쿠키 유실로
  로그인이 유지되지 않던 문제 해결 (login/logout route, admin-session, compose, .env.example)
- Supabase 미설정 시 우아한 저하: isSupabaseAdminConfigured + UnavailableAdminPanel

기능 복원 (실데이터)
- Release Hub: Forgejo API 실데이터(다운로드 수/SHA-256 체크섬/릴리스 이력)
- Ad Monetization: 데스크톱 미디에이션 10개 어댑터 로스터(fail-closed) + ad_reward_claims 통계
- License Issuer: 서버사이드 Ed25519 서명(/api/admin/license, super_admin 전용),
  개인키는 ADMIN_LICENSE_PRIVATE_KEY env로만, 발급 감사를 .NET AdminAuditEntries에 기록
- Service Models: STT 7종/LLM 5종 프리셋 드롭다운 + 자동채움
- 대시보드 ARR/MRR KPI: Supabase 구독 실집계(티어 월단가 기반)
- 사용자 상세 티어별 기능 배지(pro_plus 조건부)

.NET
- SuperAdminOnly 정책 추가, /api/admin/license-audit 엔드포인트, LicenseAuditDto
2026-08-23 23:38:08 +09:00

57 lines
1.8 KiB
TypeScript

import 'server-only'
import { getSupabaseAdminClient } from './supabase-admin'
/** Monthly list price per tier, in USD. */
const TIER_MONTHLY_USD: Record<'pro' | 'pro_plus' | 'free', number> = {
pro: 9.9,
pro_plus: 19.9,
free: 0,
}
export interface SubscriptionRevenue {
mrrUsd: number
arrUsd: number
activeCount: number
tierBreakdown: { pro: number; pro_plus: number; free: number }
}
/**
* Aggregates real subscription revenue from Supabase.
* Only status === 'active' subscriptions contribute to MRR/ARR.
* MRR = Σ(monthly price of each active subscription's tier); ARR = MRR * 12.
*/
export async function fetchSubscriptionRevenue(): Promise<SubscriptionRevenue> {
const supabase = await getSupabaseAdminClient('manager')
const rows: Array<Record<string, unknown>> = []
for (let page = 0; page < 100; page += 1) {
const { data, error } = await supabase
.from('subscriptions')
.select('tier, status')
.range(page * 1000, page * 1000 + 999)
if (error) throw new Error('Supabase subscription revenue query failed')
rows.push(...((data ?? []) as Array<Record<string, unknown>>))
if ((data?.length ?? 0) < 1000) break
if (page === 99) throw new Error('Supabase subscription directory exceeds the supported administrative window')
}
const tierBreakdown = { pro: 0, pro_plus: 0, free: 0 }
let mrrUsd = 0
let activeCount = 0
for (const row of rows) {
if (row.status !== 'active') continue
activeCount += 1
const tier = row.tier
if (tier === 'pro' || tier === 'pro_plus' || tier === 'free') {
tierBreakdown[tier] += 1
mrrUsd += TIER_MONTHLY_USD[tier]
}
}
mrrUsd = Math.round(mrrUsd * 100) / 100
const arrUsd = Math.round(mrrUsd * 12 * 100) / 100
return { mrrUsd, arrUsd, activeCount, tierBreakdown }
}