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:
윤찬 2026-04-11 10:23:50 +09:00
parent f1321df83c
commit 46b77f1118
4 changed files with 208 additions and 35 deletions

View file

@ -17,6 +17,7 @@ import { configGet, isSupabaseBuildTimeConfigured } from './ConfigService'
import { getDatabase, openForUser, openLocal, closeCurrent } from '../db' import { getDatabase, openForUser, openLocal, closeCurrent } from '../db'
import { history, dictionary, meetingSessions, meetingMemos, meetingDocuments } from '../db/schema' import { history, dictionary, meetingSessions, meetingMemos, meetingDocuments } from '../db/schema'
import { D3ROError, ErrorCode } from '@d3ro/core/errors' import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type { LicenseTier } from '@d3ro/core/types'
const logger = getLogger('CloudSyncService') const logger = getLogger('CloudSyncService')
@ -149,10 +150,24 @@ class CloudSyncService extends EventEmitter {
return 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 }) this.emit('auth-changed', { user: session.user })
// 3) Realtime 구독 자동 시작 // 4) Realtime 구독 자동 시작
void this.startRealtime().catch((err) => { void this.startRealtime().catch((err) => {
logger.warn( logger.warn(
`Realtime 자동 시작 실패: ${err instanceof Error ? err.message : String(err)}` `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 / . * signOut in-flight / .
* VoiceMode/MeetingMode/Caption , Realtime stop, * VoiceMode/MeetingMode/Caption , Realtime stop,
@ -219,7 +264,18 @@ class CloudSyncService extends EventEmitter {
this._session = null this._session = null
this._clearStoredRefreshToken() 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 철학) // — 로그아웃 후에도 앱은 익명 로컬 모드로 계속 동작 (entry point 철학)
closeCurrent() closeCurrent()
try { try {
@ -231,7 +287,7 @@ class CloudSyncService extends EventEmitter {
) )
} }
// 6) renderer에 auth-changed(null) emit — Settings는 다시 로그인 화면으로, // 7) renderer에 auth-changed(null) emit — Settings는 다시 로그인 화면으로,
// 메인 UI는 로컬 모드로 계속 동작 // 메인 UI는 로컬 모드로 계속 동작
this.emit('auth-changed', { user: null }) this.emit('auth-changed', { user: null })
logger.info('Signed out — continuing in local mode') logger.info('Signed out — continuing in local mode')

View file

@ -6,7 +6,7 @@ import { createHash } from 'crypto'
import os from 'os' import os from 'os'
import { eq, and } from 'drizzle-orm' import { eq, and } from 'drizzle-orm'
import { getLogger } from './LoggerService' import { getLogger } from './LoggerService'
import { getDatabase } from '../db' import { getDatabase, isLocalMode } from '../db'
import { dailyUsage } from '../db/schema' import { dailyUsage } from '../db/schema'
import { D3ROError, ErrorCode } from '@d3ro/core/errors' import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type { import type {
@ -29,38 +29,53 @@ function generateMachineId(): string {
} }
// ── 티어별 쿼터 한도 ────────────────────────────────────── // ── 티어별 쿼터 한도 ──────────────────────────────────────
// -1 = 무제한, 값이 있으면 일일 한도 // -1 = 무제한, 값이 있으면 일일 한도.
// 빅뱅 Phase 4: 로컬 기능은 전부 무제한. 클라우드 기능만 quota 적용.
const QUOTA_LIMITS: Record<LicenseTier, Partial<Record<Feature, number>>> = { const QUOTA_LIMITS: Record<LicenseTier, Partial<Record<Feature, number>>> = {
free: { free: {
[Feature.DICTATION]: 20, // PREMIUM_LLM은 로그인한 free 사용자에게 하루 5회 맛보기
[Feature.LLM_PROCESS]: 10, [Feature.PREMIUM_LLM]: 5,
},
pro: {
// pro는 Premium LLM 무제한이지만 fair-use cap
[Feature.PREMIUM_LLM]: 500,
}, },
pro: {},
pro_plus: {}, pro_plus: {},
} }
// ── 기능별 최소 필요 티어 ────────────────────────────────── // ── 기능별 최소 필요 티어 ──────────────────────────────────
// 빅뱅 Phase 4: 모든 로컬 기능을 'free'로 해방.
// 클라우드 기능(PREMIUM_LLM, CLOUD_SYNC)만 로그인 요구 + 티어 gate.
const FEATURE_MIN_TIER: Record<Feature, LicenseTier> = { const FEATURE_MIN_TIER: Record<Feature, LicenseTier> = {
// ── 로컬 기능 (전부 free) ──
[Feature.DICTATION]: 'free', [Feature.DICTATION]: 'free',
[Feature.LLM_PROCESS]: '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', // ── 클라우드 기능 (로그인 필요 + 일부는 pro gate) ──
[Feature.HISTORY_EXPORT]: 'pro', [Feature.PREMIUM_LLM]: 'free', // 로그인하면 free도 5회/일, pro는 500/일, pro_plus는 무제한
[Feature.CUSTOM_INSTRUCTION_CREATE]: 'pro', [Feature.CLOUD_SYNC]: 'free', // 로그인만 하면 free도 사용 가능
[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',
} }
// ── 클라우드 기능 집합 (익명 로컬 모드에서는 login_required) ──
const CLOUD_FEATURES: Set<Feature> = new Set([
Feature.PREMIUM_LLM,
Feature.CLOUD_SYNC,
])
// ── 히스토리 보존 기간 (일) ──────────────────────────────── // ── 히스토리 보존 기간 (일) ────────────────────────────────
export const HISTORY_RETENTION_DAYS: Record<LicenseTier, number> = { export const HISTORY_RETENTION_DAYS: Record<LicenseTier, number> = {
free: 3, 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 { canUse(feature: Feature): FeatureAccess {
// 익명 로컬 모드에서 클라우드 기능 요청 → 로그인 필요
if (CLOUD_FEATURES.has(feature) && isLocalMode()) {
return {
allowed: false,
reason: 'login_required',
requiredTier: 'free',
}
}
const minTier = FEATURE_MIN_TIER[feature] const minTier = FEATURE_MIN_TIER[feature]
// 티어 체크 // 티어 체크 (현재 로컬 기능은 전부 'free'라 익명도 통과)
if (!tierAtLeast(this._info.tier, minTier)) { if (!tierAtLeast(this._info.tier, minTier)) {
return { return {
allowed: false, allowed: false,
@ -253,7 +282,7 @@ class LicenseService extends EventEmitter {
} }
} }
// 쿼터 체크 (쿼터가 있는 기능만) // 쿼터 체크 (쿼터가 있는 기능만 — 현재는 PREMIUM_LLM만 해당)
const tierLimits = QUOTA_LIMITS[this._info.tier] const tierLimits = QUOTA_LIMITS[this._info.tier]
const limit = tierLimits[feature] const limit = tierLimits[feature]
if (limit !== undefined) { if (limit !== undefined) {
@ -262,7 +291,7 @@ class LicenseService extends EventEmitter {
return { return {
allowed: false, allowed: false,
reason: 'quota_exceeded', reason: 'quota_exceeded',
requiredTier: 'pro', requiredTier: this._info.tier === 'free' ? 'pro' : 'pro_plus',
quota, quota,
} }
} }
@ -271,6 +300,42 @@ class LicenseService extends EventEmitter {
return { allowed: true, reason: 'ok' } 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 . * canUse .

View file

@ -208,6 +208,52 @@ OAuth provider도 아직 Supabase에 설정 안 된 상태. 강제 게이트는
- `~/Library/Application Support/d3ro-voice/users/${uuid}/d3ro.db` 파일 생성 확인 - `~/Library/Application Support/d3ro-voice/users/${uuid}/d3ro.db` 파일 생성 확인
- 기존 `d3ro-voice.db`가 있는 환경에서 archive rename 동작 확인 - 기존 `d3ro-voice.db`가 있는 환경에서 archive rename 동작 확인
### SaaS [5] 티어 enforcement — 로컬 해방 + 클라우드 gate (Phase 4, 2026-04-11)
> 사용자 표현: "로컬 사용자는 풀어줘야해! 클라우드 동기화 같은 것과 고급 api가 막히는거지"
>
> 기존 LicenseService는 DICTATION 20/일, LLM_PROCESS 10/일 quota + LIVE_CAPTION/VOICE_MEMO/SCREEN_CONTEXT/... 대부분이 pro lock 이었음. 로컬 entry point 철학과 정면 충돌 → 전면 교체.
**정책 매트릭스 (Phase 4 확정)**
| Feature 종류 | 익명 로컬 `_local` | 로그인 free | pro | pro_plus |
|---|---|---|---|---|
| 로컬 기능 16종 | ✅ 무제한 | ✅ 무제한 | ✅ 무제한 | ✅ 무제한 |
| PREMIUM_LLM (신규) | ❌ login_required | 🔸 5/일 | 🔸 500/일 | ✅ 무제한 |
| CLOUD_SYNC (신규) | ❌ login_required | ✅ | ✅ | ✅ |
- `packages/core/src/types.ts`:
- `Feature` enum에 `PREMIUM_LLM`, `CLOUD_SYNC` 추가
- `FeatureAccess.reason``'login_required'` 추가 (익명 로컬 모드에서 cloud feature 요청 시)
- `LicenseService.ts`:
- `QUOTA_LIMITS.free` — DICTATION/LLM_PROCESS 20/10 삭제, `PREMIUM_LLM: 5`만 남김
- `QUOTA_LIMITS.pro``PREMIUM_LLM: 500` (fair use cap)
- `FEATURE_MIN_TIER` — 기존 16개 feature 전부 `'free'`로 완화. 신규 `PREMIUM_LLM`/`CLOUD_SYNC``'free'` (로그인만 하면 접근 가능, 티어는 quota로만 구분)
- `CLOUD_FEATURES: Set<Feature>` 신규 — 익명 로컬 모드 감지용
- `canUse()``CLOUD_FEATURES.has(feature) && isLocalMode()``login_required` 반환. 그 외는 기존 tier/quota 로직
- `syncFromCloud(tier)` 신규 — Supabase `subscriptions` 티어를 electron-store에 캐시 + `tier-changed` emit
- `resetToFree()` 신규 — 로그아웃 시 호출. licenseKey/activatedAt/lastVerifiedAt/offlineGraceUntil 모두 null로 리셋
- `db/index.ts``isLocalMode()` import 추가
- `CloudSyncService.ts`:
- `_fetchSubscriptionTier(userId)` 신규 — `subscriptions` 테이블에서 `{tier, status: 'active'}` 조회. 없으면 free, 비정상 티어도 free 폴백
- `_onAuthenticated()`:
- DB 오픈 → **subscription tier fetch → LicenseService.syncFromCloud(tier)** → auth-changed emit → Realtime
- fetch 실패 시 warn만 찍고 현재 티어 유지 (오프라인 grace)
- `_onSignOut()`:
- Voice/Meeting/Caption stop → Realtime stop → auth.signOut → token clear → **LicenseService.resetToFree()** → closeCurrent → openLocal → auth-changed(null) emit
- **검증**
- desktop `tsc --noEmit`
- desktop `npm run build` ✅ (main bundle 정상)
- 익명 로컬 사용자는 이제 기존 기능(LIVE_CAPTION/VOICE_MEMO/MEETING_SUMMARY/FILE_TRANSCRIPTION 등 16개) 전부 사용 가능 — **pro lock 완전 제거**
- 실측 검증 대기:
- 기존 녹음/후처리가 quota 메시지 없이 계속 작동
- PREMIUM_LLM 호출 경로는 Phase 3에서 추가 예정 → 지금은 feature 정의만
**Phase 4에서 의도적으로 뺀 것**
- PREMIUM_LLM 실제 호출 경로 — Phase 3(cloud-first)에서 Edge Function `llm-proxy` 호출 로직과 함께
- daily_usage를 Supabase로 동기화 — 현재는 로컬 SQLite `daily_usage` 테이블로만 추적 (사용자별 격리 DB라 문제 없음)
- UpgradePromptModal 텍스트 업데이트 — `login_required` 케이스용 새 메시지는 Phase 3/5에서
### SaaS [4] 회원가입 / 온보딩 (Phase 2, 2026-04-11) ### SaaS [4] 회원가입 / 온보딩 (Phase 2, 2026-04-11)
> 기존 `OnboardingModal`을 로컬 모드 entry point에 맞게 정리 + Cloud Sync 티저 step 추가. > 기존 `OnboardingModal`을 로컬 모드 entry point에 맞게 정리 + Cloud Sync 티저 step 추가.

View file

@ -875,11 +875,9 @@ export type LicenseTier = 'free' | 'pro' | 'pro_plus'
/** 기능 게이팅 대상 */ /** 기능 게이팅 대상 */
export enum Feature { export enum Feature {
// 쿼터 제한 기능 (Free에서 횟수 제한) // ── 로컬 기능 (무료, 무제한 — 빅뱅 Phase 4에서 전부 free로 해방) ──
DICTATION = 'dictation', DICTATION = 'dictation',
LLM_PROCESS = 'llm_process', LLM_PROCESS = 'llm_process',
// Pro 이상
HISTORY_UNLIMITED = 'history_unlimited', HISTORY_UNLIMITED = 'history_unlimited',
HISTORY_EXPORT = 'history_export', HISTORY_EXPORT = 'history_export',
CUSTOM_INSTRUCTION_CREATE = 'custom_instruction_create', CUSTOM_INSTRUCTION_CREATE = 'custom_instruction_create',
@ -888,14 +886,18 @@ export enum Feature {
VOICE_MEMO = 'voice_memo', VOICE_MEMO = 'voice_memo',
VOICE_COMMAND = 'voice_command', VOICE_COMMAND = 'voice_command',
LLM_CHAIN = 'llm_chain', LLM_CHAIN = 'llm_chain',
// Pro+ 이상
FILE_TRANSCRIPTION = 'file_transcription', FILE_TRANSCRIPTION = 'file_transcription',
VOICE_CONVERSATION = 'voice_conversation', VOICE_CONVERSATION = 'voice_conversation',
DICTATION_TEMPLATE = 'dictation_template', DICTATION_TEMPLATE = 'dictation_template',
MEETING_SUMMARY = 'meeting_summary', MEETING_SUMMARY = 'meeting_summary',
LOCAL_RAG = 'local_rag', LOCAL_RAG = 'local_rag',
OS_AUTOMATION = 'os_automation', OS_AUTOMATION = 'os_automation',
// ── 클라우드 기능 (로그인 + 티어 gate — 빅뱅 Phase 4 신규) ──
/** Anthropic/OpenAI 등 SaaS LLM 프록시 (Edge Function: llm-proxy) */
PREMIUM_LLM = 'premium_llm',
/** 멀티 디바이스 동기화 — 로그인만 하면 free도 사용 가능 */
CLOUD_SYNC = 'cloud_sync',
} }
/** 라이센스 정보 (electron-store에 저장) */ /** 라이센스 정보 (electron-store에 저장) */
@ -929,7 +931,11 @@ export interface UsageQuota {
/** 기능 접근 결과 */ /** 기능 접근 결과 */
export interface FeatureAccess { export interface FeatureAccess {
allowed: boolean allowed: boolean
reason: 'ok' | 'quota_exceeded' | 'tier_required' | 'license_expired' /**
* 'login_required' Phase 4 Cloud feature를
* . free .
*/
reason: 'ok' | 'quota_exceeded' | 'tier_required' | 'license_expired' | 'login_required'
requiredTier?: LicenseTier requiredTier?: LicenseTier
quota?: UsageQuota quota?: UsageQuota
} }