diff --git a/apps/desktop/src/main/services/CloudSyncService.ts b/apps/desktop/src/main/services/CloudSyncService.ts index 0cb628a..7d1d57f 100644 --- a/apps/desktop/src/main/services/CloudSyncService.ts +++ b/apps/desktop/src/main/services/CloudSyncService.ts @@ -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 { + 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') diff --git a/apps/desktop/src/main/services/LicenseService.ts b/apps/desktop/src/main/services/LicenseService.ts index bbad399..25bebc7 100644 --- a/apps/desktop/src/main/services/LicenseService.ts +++ b/apps/desktop/src/main/services/LicenseService.ts @@ -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>> = { 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 = { + // ── 로컬 기능 (전부 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 = new Set([ + Feature.PREMIUM_LLM, + Feature.CLOUD_SYNC, +]) + // ── 히스토리 보존 기간 (일) ──────────────────────────────── export const HISTORY_RETENTION_DAYS: Record = { 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 통과 후 실제 사용 시 호출. diff --git a/memory/project_status.md b/memory/project_status.md index 182818d..66edbaf 100644 --- a/memory/project_status.md +++ b/memory/project_status.md @@ -208,6 +208,52 @@ OAuth provider도 아직 Supabase에 설정 안 된 상태. 강제 게이트는 - `~/Library/Application Support/d3ro-voice/users/${uuid}/d3ro.db` 파일 생성 확인 - 기존 `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` 신규 — 익명 로컬 모드 감지용 + - `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) > 기존 `OnboardingModal`을 로컬 모드 entry point에 맞게 정리 + Cloud Sync 티저 step 추가. diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index d827823..b706191 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -875,11 +875,9 @@ export type LicenseTier = 'free' | 'pro' | 'pro_plus' /** 기능 게이팅 대상 */ export enum Feature { - // 쿼터 제한 기능 (Free에서 횟수 제한) + // ── 로컬 기능 (무료, 무제한 — 빅뱅 Phase 4에서 전부 free로 해방) ── DICTATION = 'dictation', LLM_PROCESS = 'llm_process', - - // Pro 이상 HISTORY_UNLIMITED = 'history_unlimited', HISTORY_EXPORT = 'history_export', CUSTOM_INSTRUCTION_CREATE = 'custom_instruction_create', @@ -888,14 +886,18 @@ export enum Feature { VOICE_MEMO = 'voice_memo', VOICE_COMMAND = 'voice_command', LLM_CHAIN = 'llm_chain', - - // Pro+ 이상 FILE_TRANSCRIPTION = 'file_transcription', VOICE_CONVERSATION = 'voice_conversation', DICTATION_TEMPLATE = 'dictation_template', MEETING_SUMMARY = 'meeting_summary', LOCAL_RAG = 'local_rag', 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에 저장) */ @@ -929,7 +931,11 @@ export interface UsageQuota { /** 기능 접근 결과 */ export interface FeatureAccess { 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 quota?: UsageQuota }