feat(desktop): SaaS [5] 티어 enforcement — 로컬 해방 + 클라우드 gate (빅뱅 Phase 4)
비즈니스 모델 확정: - 익명 로컬 사용자 → 로컬 기능 16개 전부 무제한 (entry point) - 클라우드 기능 (PREMIUM_LLM, CLOUD_SYNC) → 로그인 필수 - 로그인 free → PREMIUM_LLM 5/일, CLOUD_SYNC 허용 - 로그인 pro → PREMIUM_LLM 500/일 (fair use cap) - 로그인 pro_plus → PREMIUM_LLM 무제한 기존 정책 폐기: - DICTATION 20/일 quota 삭제 - LLM_PROCESS 10/일 quota 삭제 - LIVE_CAPTION/VOICE_MEMO/SCREEN_CONTEXT/VOICE_COMMAND/LLM_CHAIN pro lock 해제 → free - FILE_TRANSCRIPTION/VOICE_CONVERSATION/DICTATION_TEMPLATE/ MEETING_SUMMARY/LOCAL_RAG/OS_AUTOMATION pro_plus lock 해제 → free types.ts: - Feature enum: PREMIUM_LLM, CLOUD_SYNC 추가 - FeatureAccess.reason: 'login_required' 추가 LicenseService.ts: - QUOTA_LIMITS / FEATURE_MIN_TIER 전면 재작성 - CLOUD_FEATURES Set 신규 - canUse(): CLOUD_FEATURES && isLocalMode() → login_required 조기 반환 - syncFromCloud(tier): Supabase subscriptions 티어를 electron-store에 캐시 + tier-changed emit - resetToFree(): 로그아웃 시 pro/pro_plus 캐시 + 라이센스 필드 전부 null - isLocalMode() import 추가 CloudSyncService.ts: - _fetchSubscriptionTier(userId): subscriptions 테이블에서 활성 티어 조회 (row 없음 / 비정상 값 → free 폴백) - _onAuthenticated: DB open → tier fetch → syncFromCloud → auth-changed → Realtime - _onSignOut: Voice/Meeting/Caption stop → Realtime stop → auth.signOut → token clear → license.resetToFree → closeCurrent → openLocal → emit null 검증: - desktop tsc --noEmit ✅ - desktop npm run build ✅ - 로컬 사용자 모든 기존 기능 접근 가능 (pro lock 완전 제거)
This commit is contained in:
parent
f1321df83c
commit
46b77f1118
4 changed files with 208 additions and 35 deletions
|
|
@ -6,7 +6,7 @@ import { createHash } from 'crypto'
|
|||
import os from 'os'
|
||||
import { eq, and } from 'drizzle-orm'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { getDatabase } from '../db'
|
||||
import { getDatabase, isLocalMode } from '../db'
|
||||
import { dailyUsage } from '../db/schema'
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import type {
|
||||
|
|
@ -29,38 +29,53 @@ function generateMachineId(): string {
|
|||
}
|
||||
|
||||
// ── 티어별 쿼터 한도 ──────────────────────────────────────
|
||||
// -1 = 무제한, 값이 있으면 일일 한도
|
||||
// -1 = 무제한, 값이 있으면 일일 한도.
|
||||
// 빅뱅 Phase 4: 로컬 기능은 전부 무제한. 클라우드 기능만 quota 적용.
|
||||
const QUOTA_LIMITS: Record<LicenseTier, Partial<Record<Feature, number>>> = {
|
||||
free: {
|
||||
[Feature.DICTATION]: 20,
|
||||
[Feature.LLM_PROCESS]: 10,
|
||||
// PREMIUM_LLM은 로그인한 free 사용자에게 하루 5회 맛보기
|
||||
[Feature.PREMIUM_LLM]: 5,
|
||||
},
|
||||
pro: {
|
||||
// pro는 Premium LLM 무제한이지만 fair-use cap
|
||||
[Feature.PREMIUM_LLM]: 500,
|
||||
},
|
||||
pro: {},
|
||||
pro_plus: {},
|
||||
}
|
||||
|
||||
// ── 기능별 최소 필요 티어 ──────────────────────────────────
|
||||
// 빅뱅 Phase 4: 모든 로컬 기능을 'free'로 해방.
|
||||
// 클라우드 기능(PREMIUM_LLM, CLOUD_SYNC)만 로그인 요구 + 티어 gate.
|
||||
const FEATURE_MIN_TIER: Record<Feature, LicenseTier> = {
|
||||
// ── 로컬 기능 (전부 free) ──
|
||||
[Feature.DICTATION]: 'free',
|
||||
[Feature.LLM_PROCESS]: 'free',
|
||||
[Feature.HISTORY_UNLIMITED]: 'free',
|
||||
[Feature.HISTORY_EXPORT]: 'free',
|
||||
[Feature.CUSTOM_INSTRUCTION_CREATE]: 'free',
|
||||
[Feature.LIVE_CAPTION]: 'free',
|
||||
[Feature.SCREEN_CONTEXT]: 'free',
|
||||
[Feature.VOICE_MEMO]: 'free',
|
||||
[Feature.VOICE_COMMAND]: 'free',
|
||||
[Feature.LLM_CHAIN]: 'free',
|
||||
[Feature.FILE_TRANSCRIPTION]: 'free',
|
||||
[Feature.VOICE_CONVERSATION]: 'free',
|
||||
[Feature.DICTATION_TEMPLATE]: 'free',
|
||||
[Feature.MEETING_SUMMARY]: 'free',
|
||||
[Feature.LOCAL_RAG]: 'free',
|
||||
[Feature.OS_AUTOMATION]: 'free',
|
||||
|
||||
[Feature.HISTORY_UNLIMITED]: 'pro',
|
||||
[Feature.HISTORY_EXPORT]: 'pro',
|
||||
[Feature.CUSTOM_INSTRUCTION_CREATE]: 'pro',
|
||||
[Feature.LIVE_CAPTION]: 'pro',
|
||||
[Feature.SCREEN_CONTEXT]: 'pro',
|
||||
[Feature.VOICE_MEMO]: 'pro',
|
||||
[Feature.VOICE_COMMAND]: 'pro',
|
||||
[Feature.LLM_CHAIN]: 'pro',
|
||||
|
||||
[Feature.FILE_TRANSCRIPTION]: 'pro_plus',
|
||||
[Feature.VOICE_CONVERSATION]: 'pro_plus',
|
||||
[Feature.DICTATION_TEMPLATE]: 'pro_plus',
|
||||
[Feature.MEETING_SUMMARY]: 'pro_plus',
|
||||
[Feature.LOCAL_RAG]: 'pro_plus',
|
||||
[Feature.OS_AUTOMATION]: 'pro_plus',
|
||||
// ── 클라우드 기능 (로그인 필요 + 일부는 pro gate) ──
|
||||
[Feature.PREMIUM_LLM]: 'free', // 로그인하면 free도 5회/일, pro는 500/일, pro_plus는 무제한
|
||||
[Feature.CLOUD_SYNC]: 'free', // 로그인만 하면 free도 사용 가능
|
||||
}
|
||||
|
||||
// ── 클라우드 기능 집합 (익명 로컬 모드에서는 login_required) ──
|
||||
const CLOUD_FEATURES: Set<Feature> = new Set([
|
||||
Feature.PREMIUM_LLM,
|
||||
Feature.CLOUD_SYNC,
|
||||
])
|
||||
|
||||
// ── 히스토리 보존 기간 (일) ────────────────────────────────
|
||||
export const HISTORY_RETENTION_DAYS: Record<LicenseTier, number> = {
|
||||
free: 3,
|
||||
|
|
@ -238,13 +253,27 @@ class LicenseService extends EventEmitter {
|
|||
|
||||
/**
|
||||
* 기능 사용 가능 여부 확인.
|
||||
* 티어 잠금 -> FeatureAccess { allowed: false, reason: 'tier_required' }
|
||||
* 쿼터 초과 -> FeatureAccess { allowed: false, reason: 'quota_exceeded' }
|
||||
*
|
||||
* 빅뱅 Phase 4 정책:
|
||||
* - 로컬 기능 → 전부 free 무제한 (로컬 entry point 철학)
|
||||
* - 클라우드 기능 (PREMIUM_LLM, CLOUD_SYNC) + 익명 로컬 모드
|
||||
* → `login_required` (로그인 필요)
|
||||
* - 클라우드 기능 + 로그인 free → quota 체크 (예: PREMIUM_LLM 5/일)
|
||||
* - 클라우드 기능 + 로그인 pro/pro_plus → 허용 (더 높은 quota)
|
||||
*/
|
||||
canUse(feature: Feature): FeatureAccess {
|
||||
// 익명 로컬 모드에서 클라우드 기능 요청 → 로그인 필요
|
||||
if (CLOUD_FEATURES.has(feature) && isLocalMode()) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'login_required',
|
||||
requiredTier: 'free',
|
||||
}
|
||||
}
|
||||
|
||||
const minTier = FEATURE_MIN_TIER[feature]
|
||||
|
||||
// 티어 체크
|
||||
// 티어 체크 (현재 로컬 기능은 전부 'free'라 익명도 통과)
|
||||
if (!tierAtLeast(this._info.tier, minTier)) {
|
||||
return {
|
||||
allowed: false,
|
||||
|
|
@ -253,7 +282,7 @@ class LicenseService extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
// 쿼터 체크 (쿼터가 있는 기능만)
|
||||
// 쿼터 체크 (쿼터가 있는 기능만 — 현재는 PREMIUM_LLM만 해당)
|
||||
const tierLimits = QUOTA_LIMITS[this._info.tier]
|
||||
const limit = tierLimits[feature]
|
||||
if (limit !== undefined) {
|
||||
|
|
@ -262,7 +291,7 @@ class LicenseService extends EventEmitter {
|
|||
return {
|
||||
allowed: false,
|
||||
reason: 'quota_exceeded',
|
||||
requiredTier: 'pro',
|
||||
requiredTier: this._info.tier === 'free' ? 'pro' : 'pro_plus',
|
||||
quota,
|
||||
}
|
||||
}
|
||||
|
|
@ -271,6 +300,42 @@ class LicenseService extends EventEmitter {
|
|||
return { allowed: true, reason: 'ok' }
|
||||
}
|
||||
|
||||
/**
|
||||
* 빅뱅 Phase 4: Supabase `subscriptions` 테이블에서 받은 티어를 로컬 캐시에 반영.
|
||||
* CloudSyncService._onAuthenticated에서 호출.
|
||||
*/
|
||||
syncFromCloud(tier: LicenseTier): void {
|
||||
if (this._info.tier === tier) return
|
||||
const previous = this._info.tier
|
||||
this._info.tier = tier
|
||||
this._info.lastVerifiedAt = Date.now()
|
||||
this._writeStoredField('licenseTier', tier)
|
||||
this._writeStoredField('licenseLastVerifiedAt', this._info.lastVerifiedAt)
|
||||
this.emit('tier-changed', this.getInfo())
|
||||
logger.info(`License tier synced from cloud: ${previous} → ${tier}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 빅뱅 Phase 4: 로그아웃 시 로컬 모드로 복귀 — 캐시된 pro/pro_plus 티어를 free로 리셋.
|
||||
* CloudSyncService._onSignOut에서 호출.
|
||||
*/
|
||||
resetToFree(): void {
|
||||
if (this._info.tier === 'free') return
|
||||
const previous = this._info.tier
|
||||
this._info.tier = 'free'
|
||||
this._info.licenseKey = null
|
||||
this._info.activatedAt = null
|
||||
this._info.lastVerifiedAt = null
|
||||
this._info.offlineGraceUntil = null
|
||||
this._writeStoredField('licenseTier', 'free')
|
||||
this._writeStoredField('licenseKey', null)
|
||||
this._writeStoredField('licenseActivatedAt', null)
|
||||
this._writeStoredField('licenseLastVerifiedAt', null)
|
||||
this._writeStoredField('licenseOfflineGraceUntil', null)
|
||||
this.emit('tier-changed', this.getInfo())
|
||||
logger.info(`License tier reset to free (was ${previous}) — local mode`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 기능 사용 소비 (쿼터 차감).
|
||||
* canUse 통과 후 실제 사용 시 호출.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue