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
|
|
@ -17,6 +17,7 @@ import { configGet, isSupabaseBuildTimeConfigured } from './ConfigService'
|
|||
import { getDatabase, openForUser, openLocal, closeCurrent } from '../db'
|
||||
import { history, dictionary, meetingSessions, meetingMemos, meetingDocuments } from '../db/schema'
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import type { LicenseTier } from '@d3ro/core/types'
|
||||
|
||||
const logger = getLogger('CloudSyncService')
|
||||
|
||||
|
|
@ -149,10 +150,24 @@ class CloudSyncService extends EventEmitter {
|
|||
return
|
||||
}
|
||||
|
||||
// 2) renderer 게이트 해제 — DB가 열린 뒤에 emit
|
||||
// 2) Supabase `subscriptions` 테이블에서 현재 티어 가져와 LicenseService에 동기화
|
||||
// (빅뱅 Phase 4 — 티어 enforcement)
|
||||
try {
|
||||
const tier = await this._fetchSubscriptionTier(userId)
|
||||
const { getLicenseService } = await import('./LicenseService')
|
||||
getLicenseService().syncFromCloud(tier)
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`[auth:${opts.reason}] Subscription tier sync failed (falling back to free): ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`
|
||||
)
|
||||
}
|
||||
|
||||
// 3) renderer 게이트 해제 — DB가 열리고 티어가 반영된 뒤에 emit
|
||||
this.emit('auth-changed', { user: session.user })
|
||||
|
||||
// 3) Realtime 구독 자동 시작
|
||||
// 4) Realtime 구독 자동 시작
|
||||
void this.startRealtime().catch((err) => {
|
||||
logger.warn(
|
||||
`Realtime 자동 시작 실패: ${err instanceof Error ? err.message : String(err)}`
|
||||
|
|
@ -160,6 +175,36 @@ class CloudSyncService extends EventEmitter {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 빅뱅 Phase 4: Supabase `subscriptions` 테이블에서 사용자의 현재 티어를 조회.
|
||||
* - row 없음 → 'free' (auth trigger가 가입 시 자동 생성하므로 거의 발생 안 함)
|
||||
* - tier 값 비정상 → 'free'
|
||||
* - 네트워크 실패 → throw (caller가 warn + 현재 티어 유지)
|
||||
*/
|
||||
private async _fetchSubscriptionTier(userId: string): Promise<LicenseTier> {
|
||||
if (!this._client) {
|
||||
throw new D3ROError(ErrorCode.LLMServerUnreachable, 'Supabase client not initialized')
|
||||
}
|
||||
const { data, error } = await this._client
|
||||
.from('subscriptions')
|
||||
.select('tier, status')
|
||||
.eq('user_id', userId)
|
||||
.eq('status', 'active')
|
||||
.maybeSingle()
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message)
|
||||
}
|
||||
if (!data) return 'free'
|
||||
|
||||
const tier = data.tier as string
|
||||
if (tier === 'pro' || tier === 'pro_plus' || tier === 'free') {
|
||||
return tier
|
||||
}
|
||||
// 'team' 등 미지원 tier는 free로 폴백
|
||||
return 'free'
|
||||
}
|
||||
|
||||
/**
|
||||
* signOut 전 in-flight 세션/녹음을 안전하게 중단시키는 단일 진입점.
|
||||
* VoiceMode/MeetingMode/Caption 진행 중이면 먼저 멈추고, 그 다음 Realtime stop,
|
||||
|
|
@ -219,7 +264,18 @@ class CloudSyncService extends EventEmitter {
|
|||
this._session = null
|
||||
this._clearStoredRefreshToken()
|
||||
|
||||
// 5) 사용자 DB 닫고 로컬 모드 DB로 복귀
|
||||
// 5) LicenseService 티어를 free로 리셋 (빅뱅 Phase 4 — 익명 로컬 모드)
|
||||
// pro/pro_plus 캐시가 남아있으면 premium feature gate가 이상하게 동작
|
||||
try {
|
||||
const { getLicenseService } = await import('./LicenseService')
|
||||
getLicenseService().resetToFree()
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`[signOut] License reset failed: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
}
|
||||
|
||||
// 6) 사용자 DB 닫고 로컬 모드 DB로 복귀
|
||||
// — 로그아웃 후에도 앱은 익명 로컬 모드로 계속 동작 (entry point 철학)
|
||||
closeCurrent()
|
||||
try {
|
||||
|
|
@ -231,7 +287,7 @@ class CloudSyncService extends EventEmitter {
|
|||
)
|
||||
}
|
||||
|
||||
// 6) renderer에 auth-changed(null) emit — Settings는 다시 로그인 화면으로,
|
||||
// 7) renderer에 auth-changed(null) emit — Settings는 다시 로그인 화면으로,
|
||||
// 메인 UI는 로컬 모드로 계속 동작
|
||||
this.emit('auth-changed', { user: null })
|
||||
logger.info('Signed out — continuing in local mode')
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue