// src/main/services/LicenseService.ts // Phase 11: Freemium 라이센스 관리 — Feature Gating + 사용량 추적 import { EventEmitter } from 'events' import { createHash } from 'crypto' import os from 'os' import { eq, and } from 'drizzle-orm' import { getLogger } from './LoggerService' import { getDatabase, isLocalMode } from '../db' import { dailyUsage } from '../db/schema' import { D3ROError, ErrorCode } from '@d3ro/core/errors' import type { LicenseTier, LicenseInfo, UsageQuota, FeatureAccess, UpgradePromptEvent, ActivateLicenseResult, TierComparison, } from '@d3ro/core/types' import { Feature } from '@d3ro/core/types' const logger = getLogger('license') // ── 머신 ID 생성 ───────────────────────────────────────── function generateMachineId(): string { const raw = `${os.hostname()}-${os.cpus()[0]?.model ?? 'unknown'}-${os.platform()}-${os.arch()}` return createHash('sha256').update(raw).digest('hex').substring(0, 32) } // ── 티어별 쿼터 한도 ────────────────────────────────────── // -1 = 무제한, 값이 있으면 일일 한도. // 빅뱅 Phase 4: 로컬 기능은 전부 무제한. 클라우드 기능만 quota 적용. // Phase 3.2: 엔트리 흡수 전략으로 free 쿼터 대폭 상향 (5 → 250). // 오버리지 크레딧은 서버 subscriptions.overage_credits 컬럼에서 별도 관리. // Phase 3.2: 모델별 쿼터. 서버(quota.ts)와 동기화. // 클라이언트에서는 PREMIUM_LLM feature로 묶어서 canUse() 체크하고, // 실제 모델별 세분화는 서버 llm-proxy가 담당. // 여기의 값은 Settings UI 표시용 + upgrade 유도 시점 판단용. const QUOTA_LIMITS: Record>> = { free: { // Haiku만, 250/주간. 클라이언트에서는 대략적 일환산(~36/일)으로 표시. [Feature.PREMIUM_LLM]: 250, }, pro: { // 모델별: Haiku 1500 + Sonnet 300 + Opus 50 = 합산 표시 [Feature.PREMIUM_LLM]: 1850, }, pro_plus: { // Haiku 무제한 + Sonnet 1500 + Opus 300 }, } // ── 기능별 최소 필요 티어 ────────────────────────────────── // 빅뱅 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', // ── 클라우드 기능 (로그인 필요 + 일부는 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, pro: -1, pro_plus: -1, } // ── 티어 순서 (비교용) ──────────────────────────────────── const TIER_ORDER: Record = { free: 0, pro: 1, pro_plus: 2, } /** 오프라인 유예 기간: 30일 */ const OFFLINE_GRACE_PERIOD_MS = 30 * 24 * 60 * 60 * 1000 /** 온라인 재검증 주기: 30일 */ const REVERIFY_INTERVAL_MS = 30 * 24 * 60 * 60 * 1000 function tierAtLeast(current: LicenseTier, required: LicenseTier): boolean { return TIER_ORDER[current] >= TIER_ORDER[required] } function getTodayDate(): string { const now = new Date() const y = now.getFullYear() const m = String(now.getMonth() + 1).padStart(2, '0') const d = String(now.getDate()).padStart(2, '0') return `${y}-${m}-${d}` } function getTomorrowMidnight(): string { const tomorrow = new Date() tomorrow.setDate(tomorrow.getDate() + 1) tomorrow.setHours(0, 0, 0, 0) return tomorrow.toISOString() } // ── LicenseService 싱글톤 ────────────────────────────────── class LicenseService extends EventEmitter { private _info: LicenseInfo private _initialized = false constructor() { super() this._info = { tier: 'free', licenseKey: null, activatedAt: null, machineId: '', lastVerifiedAt: null, offlineGraceUntil: null, } } /** 서비스 초기화 — bootstrap에서 호출 */ initialize(): void { // machineId: 하드웨어 기반 해시 생성 const storedMachineId = this._readStoredField('licenseMachineId') const generatedId = generateMachineId() if (storedMachineId && storedMachineId === generatedId) { this._info.machineId = storedMachineId } else if (storedMachineId) { // 하드웨어가 바뀐 경우 — 기존 ID 유지 (이미 활성화된 키와 연결) this._info.machineId = storedMachineId logger.warn('Hardware changed but keeping existing machineId for license continuity') } else { this._info.machineId = generatedId this._writeStoredField('licenseMachineId', this._info.machineId) } // 저장된 라이센스 정보 로드 const storedTier = this._readStoredField('licenseTier') const storedKey = this._readStoredField('licenseKey') const storedActivatedAt = this._readStoredField('licenseActivatedAt') const storedLastVerified = this._readStoredField('licenseLastVerifiedAt') const storedGrace = this._readStoredField('licenseOfflineGraceUntil') if (storedTier && storedTier !== 'free' && storedKey) { this._info.tier = storedTier this._info.licenseKey = storedKey this._info.activatedAt = storedActivatedAt ?? null this._info.lastVerifiedAt = storedLastVerified ?? null this._info.offlineGraceUntil = storedGrace ?? null // 오프라인 유예 기간 체크 if (this._info.offlineGraceUntil && Date.now() > this._info.offlineGraceUntil) { logger.warn('Offline grace period expired, downgrading to free') this._downgradeToFree() } } this._initialized = true logger.info(`LicenseService initialized: tier=${this._info.tier}, machineId=${this._info.machineId.substring(0, 8)}...`) } // ── Public API ────────────────────────────────────────── get tier(): LicenseTier { return this._info.tier } getInfo(): LicenseInfo { return { ...this._info } } /** * 기능 사용 가능 여부 확인. * * 빅뱅 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, reason: 'tier_required', requiredTier: minTier, } } // 쿼터 체크 (쿼터가 있는 기능만 — 현재는 PREMIUM_LLM만 해당) const tierLimits = QUOTA_LIMITS[this._info.tier] const limit = tierLimits[feature] if (limit !== undefined) { const quota = this.getUsage(feature) if (quota.remaining === 0) { return { allowed: false, reason: 'quota_exceeded', requiredTier: this._info.tier === 'free' ? 'pro' : 'pro_plus', quota, } } } 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 통과 후 실제 사용 시 호출. */ consumeQuota(feature: Feature): void { const access = this.canUse(feature) if (!access.allowed) { if (access.reason === 'quota_exceeded') { this.promptUpgrade(feature, 'quota_exceeded') throw new D3ROError( ErrorCode.QuotaExceeded, `Daily quota exceeded for ${feature}`, { feature, quota: access.quota } ) } if (access.reason === 'tier_required') { this.promptUpgrade(feature, 'tier_required') throw new D3ROError( ErrorCode.TierRequired, `Feature ${feature} requires ${access.requiredTier} tier`, { feature, requiredTier: access.requiredTier } ) } if (access.reason === 'login_required') { this.promptUpgrade(feature, 'login_required') throw new D3ROError( ErrorCode.TierRequired, `Feature ${feature} requires sign in`, { feature } ) } } // 쿼터 있는 기능만 DB에 기록 const tierLimits = QUOTA_LIMITS[this._info.tier] if (tierLimits[feature] !== undefined) { this._incrementUsage(feature) } } /** 일일 사용량 조회 */ getUsage(feature: Feature): UsageQuota { const tierLimits = QUOTA_LIMITS[this._info.tier] const limit = tierLimits[feature] // 쿼터 없는 기능 (무제한) if (limit === undefined) { return { feature, used: 0, limit: -1, remaining: -1, resetAt: getTomorrowMidnight(), } } const today = getTodayDate() const used = this._getUsageCount(today, feature) return { feature, used, limit, remaining: Math.max(0, limit - used), resetAt: getTomorrowMidnight(), } } /** 전체 쿼터 기능 사용량 조회 */ getAllUsage(): UsageQuota[] { const quotaFeatures = [Feature.DICTATION, Feature.LLM_PROCESS] return quotaFeatures.map((f) => this.getUsage(f)) } /** * 라이센스 키 활성화 (로컬 키 검증 전용). * D3RO-PRO-XXXX-XXXX / D3RO-PLUS-XXXX-XXXX 패턴으로 개발/테스트용. * 프로덕션 결제는 Payple 웹 결제 → CloudSync 티어 갱신 경로 사용. */ async activate(key: string): Promise { const trimmedKey = key.trim() if (!trimmedKey) { return { success: false, tier: 'free', message: 'License key is empty' } } const localTier = this._validateKeyLocally(trimmedKey) if (!localTier) { return { success: false, tier: 'free', message: 'Invalid license key' } } logger.info(`Local key validated: tier=${localTier}`) const now = Date.now() this._info = { ...this._info, tier: localTier, licenseKey: trimmedKey, activatedAt: now, lastVerifiedAt: now, offlineGraceUntil: now + OFFLINE_GRACE_PERIOD_MS, } this._persistLicenseInfo() this._sendToRenderer(IPC_CHANNELS.LICENSE.TIER_CHANGED, this._info) return { success: true, tier: localTier, message: `Activated ${localTier} license (local)` } } /** 라이센스 비활성화 (Free로 복귀) */ async deactivate(): Promise { this._downgradeToFree() this.emit('tier-changed', this.getInfo()) logger.info('License deactivated, reverted to free') } /** 업그레이드 유도 이벤트 발생 */ promptUpgrade(feature: Feature, reason: UpgradePromptEvent['reason']): void { const minTier = FEATURE_MIN_TIER[feature] // 쿼터 초과: 현재 tier 상위. login_required: free(로그인하면 바로 사용 가능). 그 외: feature의 최소 tier. const requiredTier: LicenseTier = reason === 'quota_exceeded' ? this._info.tier === 'free' ? 'pro' : 'pro_plus' : reason === 'login_required' ? 'free' : minTier const event: UpgradePromptEvent = { feature, reason, currentTier: this._info.tier, requiredTier, quota: reason === 'quota_exceeded' ? this.getUsage(feature) : undefined, } this.emit('upgrade-prompt', event) } /** 티어 비교표 생성 */ getTierComparison(): TierComparison[] { return [ { feature: Feature.DICTATION, featureLabel: 'license.feature.dictation', free: '20/day', pro: 'unlimited', proPlus: 'unlimited', }, { feature: Feature.LLM_PROCESS, featureLabel: 'license.feature.llmProcess', free: '10/day', pro: 'unlimited', proPlus: 'unlimited', }, { feature: Feature.HISTORY_UNLIMITED, featureLabel: 'license.feature.historyUnlimited', free: false, pro: true, proPlus: true, }, { feature: Feature.LIVE_CAPTION, featureLabel: 'license.feature.liveCaption', free: false, pro: true, proPlus: true, }, { feature: Feature.SCREEN_CONTEXT, featureLabel: 'license.feature.screenContext', free: false, pro: true, proPlus: true, }, { feature: Feature.VOICE_MEMO, featureLabel: 'license.feature.voiceMemo', free: false, pro: true, proPlus: true, }, { feature: Feature.VOICE_COMMAND, featureLabel: 'license.feature.voiceCommand', free: false, pro: true, proPlus: true, }, { feature: Feature.LLM_CHAIN, featureLabel: 'license.feature.llmChain', free: false, pro: true, proPlus: true, }, { feature: Feature.CUSTOM_INSTRUCTION_CREATE, featureLabel: 'license.feature.customInstruction', free: false, pro: true, proPlus: true, }, { feature: Feature.HISTORY_EXPORT, featureLabel: 'license.feature.historyExport', free: false, pro: true, proPlus: true, }, { feature: Feature.FILE_TRANSCRIPTION, featureLabel: 'license.feature.fileTranscription', free: false, pro: false, proPlus: true, }, { feature: Feature.VOICE_CONVERSATION, featureLabel: 'license.feature.voiceConversation', free: false, pro: false, proPlus: true, }, { feature: Feature.MEETING_SUMMARY, featureLabel: 'license.feature.meetingSummary', free: false, pro: false, proPlus: true, }, { feature: Feature.DICTATION_TEMPLATE, featureLabel: 'license.feature.dictationTemplate', free: false, pro: false, proPlus: true, }, { feature: Feature.LOCAL_RAG, featureLabel: 'license.feature.localRag', free: false, pro: false, proPlus: true, }, { feature: Feature.OS_AUTOMATION, featureLabel: 'license.feature.osAutomation', free: false, pro: false, proPlus: true, }, ] } // ── Private helpers ──────────────────────────────────── /** * 로컬 키 검증 (개발/테스트용). * * 키 포맷 규칙: * D3RO-PRO-XXXX-XXXX -> pro * D3RO-PLUS-XXXX-XXXX -> pro_plus */ private _validateKeyLocally(key: string): LicenseTier | null { if (key.startsWith('D3RO-PRO-') && key.length >= 18) { return 'pro' } if (key.startsWith('D3RO-PLUS-') && key.length >= 19) { return 'pro_plus' } return null } private _downgradeToFree(): void { this._info.tier = 'free' this._info.licenseKey = null this._info.activatedAt = null this._info.lastVerifiedAt = null this._info.offlineGraceUntil = null this._persistLicenseInfo() } private _persistLicenseInfo(): void { this._writeStoredField('licenseTier', this._info.tier) this._writeStoredField('licenseKey', this._info.licenseKey) this._writeStoredField('licenseActivatedAt', this._info.activatedAt) this._writeStoredField('licenseLastVerifiedAt', this._info.lastVerifiedAt) this._writeStoredField('licenseOfflineGraceUntil', this._info.offlineGraceUntil) } private _getUsageCount(date: string, feature: Feature): number { try { const db = getDatabase() const rows = db .select() .from(dailyUsage) .where(and(eq(dailyUsage.date, date), eq(dailyUsage.feature, feature))) .all() return rows.length > 0 ? rows[0].count : 0 } catch { logger.warn(`Failed to get usage count for ${feature}`) return 0 } } private _incrementUsage(feature: Feature): void { try { const db = getDatabase() const today = getTodayDate() // UPSERT: 있으면 count+1, 없으면 insert const existing = db .select() .from(dailyUsage) .where(and(eq(dailyUsage.date, today), eq(dailyUsage.feature, feature))) .all() if (existing.length > 0) { db.update(dailyUsage) .set({ count: existing[0].count + 1 }) .where(eq(dailyUsage.id, existing[0].id)) .run() } else { db.insert(dailyUsage) .values({ date: today, feature, count: 1 }) .run() } } catch (err) { logger.warn(`Failed to increment usage for ${feature}: ${err}`) } } // ── 라이센스 전용 파일 기반 저장소 ────────────────────── // AppConfig에 라이센스 필드가 없으므로 별도 JSON 파일 사용 private _licenseStore: Map = new Map() private _licenseStoreLoaded = false private _ensureLicenseStore(): void { if (this._licenseStoreLoaded) return try { const fs = require('fs') as typeof import('fs') const path = require('path') as typeof import('path') const electron = require('electron') as typeof import('electron') const filePath = path.join(electron.app.getPath('userData'), 'd3ro-license.json') if (fs.existsSync(filePath)) { const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as Record for (const [k, v] of Object.entries(data)) { this._licenseStore.set(k, v) } } } catch { // 파일 없으면 빈 상태로 시작 } this._licenseStoreLoaded = true } private _saveLicenseStore(): void { try { const fs = require('fs') as typeof import('fs') const path = require('path') as typeof import('path') const electron = require('electron') as typeof import('electron') const filePath = path.join(electron.app.getPath('userData'), 'd3ro-license.json') const obj: Record = {} for (const [k, v] of this._licenseStore.entries()) { obj[k] = v } fs.writeFileSync(filePath, JSON.stringify(obj, null, 2), 'utf-8') } catch (err) { logger.warn(`Failed to save license store: ${err}`) } } private _readStoredField(key: string): T | null { this._ensureLicenseStore() const val = this._licenseStore.get(key) return (val as T) ?? null } private _writeStoredField(key: string, value: unknown): void { this._ensureLicenseStore() this._licenseStore.set(key, value) this._saveLicenseStore() } } // ── 싱글톤 ──────────────────────────────────────────────── let instance: LicenseService | null = null export function getLicenseService(): LicenseService { if (!instance) { instance = new LicenseService() } return instance } export function initLicenseService(): void { getLicenseService().initialize() }