d3ro-voice/apps/desktop/src/main/services/LicenseService.ts
윤찬 36031acda9 refactor(desktop): LemonSqueezy API 코드 완전 제거 (~150줄)
- LicenseService: LS API 상수/인터페이스/메서드 4개 삭제
- activate(): 로컬 키 검증 전용으로 단순화
- deactivate(): LS API 호출 제거
- initialize(): _tryPeriodicValidation 제거
- site/ taxNote: LemonSqueezy → Payple (10 locale)
2026-04-12 20:15:40 +09:00

678 lines
22 KiB
TypeScript

// 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<LicenseTier, Partial<Record<Feature, number>>> = {
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<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',
// ── 클라우드 기능 (로그인 필요 + 일부는 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,
pro: -1,
pro_plus: -1,
}
// ── 티어 순서 (비교용) ────────────────────────────────────
const TIER_ORDER: Record<LicenseTier, number> = {
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<string>('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>('licenseTier')
const storedKey = this._readStoredField<string>('licenseKey')
const storedActivatedAt = this._readStoredField<number>('licenseActivatedAt')
const storedLastVerified = this._readStoredField<number>('licenseLastVerifiedAt')
const storedGrace = this._readStoredField<number>('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<ActivateLicenseResult> {
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<void> {
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<string, unknown> = 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<string, unknown>
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<string, unknown> = {}
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<T>(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()
}