- LicenseService: LS API 상수/인터페이스/메서드 4개 삭제 - activate(): 로컬 키 검증 전용으로 단순화 - deactivate(): LS API 호출 제거 - initialize(): _tryPeriodicValidation 제거 - site/ taxNote: LemonSqueezy → Payple (10 locale)
678 lines
22 KiB
TypeScript
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()
|
|
}
|