Phase 10 킬러 피처: - MemoService: 태그 CRUD + 마크다운 내보내기 (memo_tags DB) - VoiceCommandService: 키워드→명령어 매칭, 프리셋 4종 - ScreenContextService: PowerShell 활성 윈도우 + Ctrl+C 선택 텍스트 - ChainService: LLM 명령어 순차 실행 파이프라인 - CaptionService: 6초 청크 연속 전사 + 시스템 오디오 루프백 VoiceModeService 파이프라인 통합: - 녹음 시작 → 컨텍스트 캡처 → STT → 키워드 매칭 → LLM(체인/컨텍스트 주입) → 삽입 시스템 오디오 캡처: - setDisplayMediaRequestHandler + audio: 'loopback' (IPC 브릿지) - electron-audio-loopback 패키지 contextIsolation 호환 불가 → 직접 구현 Phase 11 수익화: - LicenseService: Free/Pro/Pro+ 3티어, LemonSqueezy API - Feature Gate: requireFeature/checkFeature/consumeFeature - 일일 쿼터: Free dictation 20/일, LLM 10/일 (SQLite daily_usage) - LicenseModal, ProBadge, UpgradePromptModal UI 디자인 보강: - d3roTypo(13종), d3roShadow(10종), d3roRadius(7종) 토큰 시스템 - ScreenPanel, ButtonGroup DS 컴포넌트 신규 - PhosphorText 4→13종 변형, MetalDial conic-gradient 광택 - 공유 컴포넌트: EmptyStateCard, SearchInput, PageHeader, HistoryEntryCard 기타: - 자막 핫키 SSOT 전체 연동 (Config→Hotkey→VoiceMode→Caption→Settings) - StatusBar 자막 LED + 효과음, 자막 로딩 UI - LLM 상태 이벤트 전파 수정 (폴링 제거 → onStatusChanged) - 커맨드 팝업 "선택 해제" 항목 추가
447 lines
14 KiB
Markdown
447 lines
14 KiB
Markdown
# Phase 11: 수익화 기반 — LicenseService + Feature Gating
|
|
|
|
## 목표
|
|
Freemium 모델 구현. Free/Pro/Pro+ 3단계 티어, 일일 사용량 제한, 라이센스 키 활성화, 업그레이드 유도 UI.
|
|
로컬 앱이므로 오프라인 우선 설계 — 키 검증은 최초 1회 온라인, 이후 로컬 검증.
|
|
|
|
---
|
|
|
|
## 1. 티어 정의
|
|
|
|
| 기능 | Free | Pro (₩39,000) | Pro+ (₩69,000) |
|
|
|------|------|---------------|-----------------|
|
|
| 받아쓰기 | 15회/일 | 무제한 | 무제한 |
|
|
| LLM 다듬기 | 3회/일 | 무제한 | 무제한 |
|
|
| 히스토리 보존 | 3일 | 무제한 | 무제한 |
|
|
| 커스텀 명령어 | 프리셋만 | 무제한 생성 | 무제한 생성 |
|
|
| 히스토리 내보내기 | X | O | O |
|
|
| 실시간 자막 | X | O | O |
|
|
| 스크린 컨텍스트 | X | O | O |
|
|
| 음성 메모/태그 | X | O | O |
|
|
| 음성 단축키 | X | O | O |
|
|
| LLM 체인 | X | O | O |
|
|
| 파일 전사 | X | X | O |
|
|
| 음성 대화 모드 | X | X | O |
|
|
| 딕테이션 템플릿 | X | X | O |
|
|
| 회의록 자동 요약 | X | X | O |
|
|
| 로컬 RAG | X | X | O |
|
|
| OS 자동화 | X | X | O |
|
|
|
|
---
|
|
|
|
## 2. Feature 열거형
|
|
|
|
```typescript
|
|
/** 기능 게이팅 대상 */
|
|
export enum Feature {
|
|
// 쿼터 제한 기능 (Free에서 횟수 제한)
|
|
DICTATION = 'dictation',
|
|
LLM_PROCESS = 'llm_process',
|
|
|
|
// Pro 기능 (Free에서 잠금)
|
|
HISTORY_UNLIMITED = 'history_unlimited',
|
|
HISTORY_EXPORT = 'history_export',
|
|
CUSTOM_INSTRUCTION_CREATE = 'custom_instruction_create',
|
|
LIVE_CAPTION = 'live_caption',
|
|
SCREEN_CONTEXT = 'screen_context',
|
|
VOICE_MEMO = 'voice_memo',
|
|
VOICE_COMMAND = 'voice_command',
|
|
LLM_CHAIN = 'llm_chain',
|
|
|
|
// Pro+ 기능 (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',
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 3. 타입 정의
|
|
|
|
```typescript
|
|
/** 라이센스 티어 */
|
|
export type LicenseTier = 'free' | 'pro' | 'pro_plus'
|
|
|
|
/** 라이센스 정보 (electron-store에 저장) */
|
|
export interface LicenseInfo {
|
|
tier: LicenseTier
|
|
licenseKey: string | null
|
|
activatedAt: number | null
|
|
machineId: string
|
|
/** 마지막 온라인 검증 시각 */
|
|
lastVerifiedAt: number | null
|
|
/** 오프라인 유예 만료 (lastVerifiedAt + 30일) */
|
|
offlineGraceUntil: number | null
|
|
}
|
|
|
|
/** 일일 사용량 */
|
|
export interface DailyUsage {
|
|
date: string // 'YYYY-MM-DD'
|
|
dictationCount: number
|
|
llmProcessCount: number
|
|
}
|
|
|
|
/** 쿼터 정보 */
|
|
export interface UsageQuota {
|
|
feature: Feature
|
|
used: number
|
|
limit: number // -1 = 무제한
|
|
remaining: number // -1 = 무제한
|
|
resetAt: string // 다음 리셋 시각 (내일 00:00) ISO 8601
|
|
}
|
|
|
|
/** 기능 접근 결과 */
|
|
export interface FeatureAccess {
|
|
allowed: boolean
|
|
reason?: 'ok' | 'quota_exceeded' | 'tier_required' | 'license_expired'
|
|
requiredTier?: LicenseTier
|
|
quota?: UsageQuota
|
|
}
|
|
|
|
/** 업그레이드 유도 이벤트 */
|
|
export interface UpgradePromptEvent {
|
|
feature: Feature
|
|
reason: 'quota_exceeded' | 'tier_required'
|
|
currentTier: LicenseTier
|
|
requiredTier: LicenseTier
|
|
quota?: UsageQuota
|
|
}
|
|
|
|
/** 라이센스 활성화 파라미터 */
|
|
export interface ActivateLicenseParams {
|
|
licenseKey: string
|
|
}
|
|
|
|
/** 라이센스 활성화 결과 */
|
|
export interface ActivateLicenseResult {
|
|
success: boolean
|
|
tier: LicenseTier
|
|
message: string
|
|
}
|
|
|
|
/** 티어별 기능 비교 */
|
|
export interface TierComparison {
|
|
feature: string
|
|
featureLabel: string
|
|
free: boolean | string
|
|
pro: boolean | string
|
|
proPlus: boolean | string
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 4. DB 스키마
|
|
|
|
```sql
|
|
CREATE TABLE daily_usage (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
date TEXT NOT NULL, -- 'YYYY-MM-DD'
|
|
feature TEXT NOT NULL, -- Feature enum value
|
|
count INTEGER NOT NULL DEFAULT 0,
|
|
UNIQUE(date, feature)
|
|
);
|
|
|
|
CREATE INDEX idx_daily_usage_date ON daily_usage(date);
|
|
```
|
|
|
|
```typescript
|
|
// drizzle-orm 스키마
|
|
export const dailyUsage = sqliteTable('daily_usage', {
|
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
date: text('date').notNull(),
|
|
feature: text('feature').notNull(),
|
|
count: integer('count').notNull().default(0),
|
|
}, (table) => [
|
|
uniqueIndex('idx_daily_usage_date_feature').on(table.date, table.feature),
|
|
index('idx_daily_usage_date').on(table.date),
|
|
]);
|
|
```
|
|
|
|
---
|
|
|
|
## 5. IPC 채널
|
|
|
|
```typescript
|
|
LICENSE: {
|
|
GET_INFO: 'license:getInfo',
|
|
ACTIVATE: 'license:activate',
|
|
DEACTIVATE: 'license:deactivate',
|
|
CHECK_FEATURE: 'license:checkFeature',
|
|
GET_USAGE: 'license:getUsage',
|
|
GET_ALL_USAGE: 'license:getAllUsage',
|
|
GET_TIER_COMPARISON: 'license:getTierComparison',
|
|
// Main → Renderer events
|
|
UPGRADE_PROMPT: 'license:upgradePrompt',
|
|
TIER_CHANGED: 'license:tierChanged',
|
|
}
|
|
```
|
|
|
|
| 채널 | 방향 | 파라미터 | 반환 | 설명 |
|
|
|------|------|---------|------|------|
|
|
| `license:getInfo` | handle | void | `LicenseInfo` | 현재 라이센스 정보 |
|
|
| `license:activate` | handle | `ActivateLicenseParams` | `ActivateLicenseResult` | 키 활성화 |
|
|
| `license:deactivate` | handle | void | void | 키 비활성화 (Free로 복귀) |
|
|
| `license:checkFeature` | handle | `{ feature: Feature }` | `FeatureAccess` | 기능 접근 가능 여부 |
|
|
| `license:getUsage` | handle | `{ feature: Feature }` | `UsageQuota` | 특정 기능 사용량 |
|
|
| `license:getAllUsage` | handle | void | `UsageQuota[]` | 전체 기능 사용량 |
|
|
| `license:getTierComparison` | handle | void | `TierComparison[]` | 티어 비교표 |
|
|
| `license:upgradePrompt` | send | — | `UpgradePromptEvent` | 업그레이드 유도 이벤트 |
|
|
| `license:tierChanged` | send | — | `LicenseInfo` | 티어 변경 알림 |
|
|
|
|
---
|
|
|
|
## 6. LicenseService 인터페이스
|
|
|
|
```typescript
|
|
interface ILicenseService {
|
|
/** 현재 티어 */
|
|
readonly tier: LicenseTier
|
|
|
|
/** 라이센스 정보 */
|
|
getInfo(): LicenseInfo
|
|
|
|
/**
|
|
* 기능 사용 가능 여부 확인.
|
|
* 쿼터 기능: 남은 횟수 체크.
|
|
* 티어 잠금 기능: 현재 티어로 접근 가능한지.
|
|
*/
|
|
canUse(feature: Feature): FeatureAccess
|
|
|
|
/**
|
|
* 기능 사용 소비 (쿼터 차감).
|
|
* canUse 통과 후 실제 사용 시 호출.
|
|
* 쿼터 초과 시 D3ROError(QuotaExceeded) throw.
|
|
*/
|
|
consumeQuota(feature: Feature): void
|
|
|
|
/** 일일 사용량 조회 */
|
|
getUsage(feature: Feature): UsageQuota
|
|
|
|
/** 전체 사용량 조회 */
|
|
getAllUsage(): UsageQuota[]
|
|
|
|
/** 라이센스 키 활성화 */
|
|
activate(key: string): Promise<ActivateLicenseResult>
|
|
|
|
/** 라이센스 비활성화 */
|
|
deactivate(): void
|
|
|
|
/** 티어 비교표 */
|
|
getTierComparison(): TierComparison[]
|
|
|
|
/** 업그레이드 유도 이벤트 발생 (내부 + IPC 전파) */
|
|
promptUpgrade(feature: Feature, reason: UpgradePromptEvent['reason']): void
|
|
}
|
|
```
|
|
|
|
### 쿼터 한도
|
|
|
|
```typescript
|
|
const QUOTA_LIMITS: Record<LicenseTier, Partial<Record<Feature, number>>> = {
|
|
free: {
|
|
[Feature.DICTATION]: 15,
|
|
[Feature.LLM_PROCESS]: 3,
|
|
},
|
|
pro: {}, // 무제한
|
|
pro_plus: {}, // 무제한
|
|
}
|
|
```
|
|
|
|
### 티어별 기능 접근
|
|
|
|
```typescript
|
|
const FEATURE_TIERS: Record<Feature, LicenseTier> = {
|
|
// 모든 티어 (쿼터만 다름)
|
|
[Feature.DICTATION]: 'free',
|
|
[Feature.LLM_PROCESS]: 'free',
|
|
|
|
// Pro 이상
|
|
[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',
|
|
|
|
// 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',
|
|
}
|
|
```
|
|
|
|
### 히스토리 보존 정책
|
|
|
|
```typescript
|
|
const HISTORY_RETENTION_DAYS: Record<LicenseTier, number> = {
|
|
free: 3,
|
|
pro: -1, // 무제한
|
|
pro_plus: -1, // 무제한
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 7. 라이센스 키 검증
|
|
|
|
### 오프라인 우선 설계
|
|
|
|
```
|
|
최초 활성화 (온라인 필수):
|
|
1. 사용자가 키 입력
|
|
2. LemonSqueezy API 검증: POST /v1/licenses/activate
|
|
3. 응답에서 tier 추출 (meta.tier 또는 variant_id 매핑)
|
|
4. electron-store에 저장: { tier, key, activatedAt, machineId, lastVerifiedAt }
|
|
5. 오프라인 유예: lastVerifiedAt + 30일
|
|
|
|
이후 앱 실행 시:
|
|
1. electron-store에서 라이센스 정보 로드
|
|
2. tier !== 'free' → 로컬 검증 (machineId 일치, 유예 기간 내)
|
|
3. 30일 경과 → 백그라운드 재검증 시도
|
|
4. 재검증 실패 → 7일 추가 유예 후 Free로 다운그레이드
|
|
5. 재검증 성공 → lastVerifiedAt 갱신
|
|
```
|
|
|
|
### machineId 생성
|
|
|
|
```typescript
|
|
import { machineIdSync } from 'node-machine-id'
|
|
const machineId = machineIdSync(true) // 해시된 하드웨어 ID
|
|
```
|
|
|
|
또는 electron-store에 저장된 UUID (하드웨어 변경에 더 관대):
|
|
|
|
```typescript
|
|
import { randomUUID } from 'crypto'
|
|
// 최초 실행 시 1회 생성 후 저장
|
|
const machineId = store.get('machineId') ?? randomUUID()
|
|
```
|
|
|
|
---
|
|
|
|
## 8. UI 명세
|
|
|
|
### 8.1 UpgradePromptModal
|
|
|
|
쿼터 소진 또는 잠긴 기능 접근 시 표시.
|
|
|
|
```
|
|
┌─────────────────────────────────────────────┐
|
|
│ 🔒 오늘의 받아쓰기를 모두 사용했습니다 │
|
|
│ │
|
|
│ Free: 15회/일 → Pro: 무제한 │
|
|
│ │
|
|
│ Pro로 업그레이드하면: │
|
|
│ ✓ 무제한 받아쓰기 │
|
|
│ ✓ AI 텍스트 다듬기 무제한 │
|
|
│ ✓ 실시간 자막 │
|
|
│ ✓ 히스토리 무제한 보존 │
|
|
│ │
|
|
│ [내일 다시 사용하기] [Pro 알아보기 →] │
|
|
└─────────────────────────────────────────────┘
|
|
```
|
|
|
|
### 8.2 UsageIndicator (StatusBar 또는 Dashboard)
|
|
|
|
```
|
|
[🔵🔵🔵🔵🔵🔵🔵🔵🔵🔵🔵🔵⚪⚪⚪ 12/15 받아쓰기]
|
|
```
|
|
|
|
### 8.3 FeatureGate 래퍼 컴포넌트
|
|
|
|
```tsx
|
|
<FeatureGate feature={Feature.LIVE_CAPTION} fallback={<LockedFeatureCard />}>
|
|
<CaptionControls />
|
|
</FeatureGate>
|
|
```
|
|
|
|
### 8.4 Settings License 탭
|
|
|
|
```
|
|
┌─ License ──────────────────────────────────────┐
|
|
│ │
|
|
│ 현재 플랜: FREE │
|
|
│ │
|
|
│ ┌─ 라이센스 키 ──────────────────────────────┐ │
|
|
│ │ [____________________________] [활성화] │ │
|
|
│ └────────────────────────────────────────────┘ │
|
|
│ │
|
|
│ ┌─ 플랜 비교 ────────────────────────────────┐ │
|
|
│ │ Free Pro Pro+ │ │
|
|
│ │ 받아쓰기 15회/일 무제한 무제한 │ │
|
|
│ │ AI다듬기 3회/일 무제한 무제한 │ │
|
|
│ │ 자막 ✗ ✓ ✓ │ │
|
|
│ │ 파일전사 ✗ ✗ ✓ │ │
|
|
│ │ 대화모드 ✗ ✗ ✓ │ │
|
|
│ │ ... │ │
|
|
│ └────────────────────────────────────────────┘ │
|
|
│ │
|
|
│ 오늘 사용량: │
|
|
│ 받아쓰기: ████████░░░░░░░ 8/15 │
|
|
│ AI 다듬기: ██░░░░░░░░░░░░ 1/3 │
|
|
└─────────────────────────────────────────────────┘
|
|
```
|
|
|
|
---
|
|
|
|
## 9. Feature Gating 통합 포인트
|
|
|
|
| 서비스 | 체크 위치 | Feature |
|
|
|--------|----------|---------|
|
|
| VoiceModeService.startSession() | 세션 시작 전 | DICTATION |
|
|
| LocalLLMService.process() | 처리 시작 전 | LLM_PROCESS |
|
|
| CaptionService.start() | 자막 시작 전 | LIVE_CAPTION |
|
|
| ScreenContextService.capture() | 캡처 전 | SCREEN_CONTEXT |
|
|
| MemoService.addTag() | 태그 추가 전 | VOICE_MEMO |
|
|
| VoiceCommandService.match() | 매칭 전 | VOICE_COMMAND |
|
|
| ChainService.execute() | 체인 실행 전 | LLM_CHAIN |
|
|
| HistoryService.export() | 내보내기 전 | HISTORY_EXPORT |
|
|
| CustomInstructionService.create() | 생성 전 | CUSTOM_INSTRUCTION_CREATE |
|
|
|
|
---
|
|
|
|
## 10. 에러 코드
|
|
|
|
```typescript
|
|
// 850-869: License
|
|
LicenseKeyInvalid = 850,
|
|
LicenseKeyExpired = 851,
|
|
LicenseActivationFailed = 852,
|
|
LicenseDeactivationFailed = 853,
|
|
LicenseMachineIdMismatch = 854,
|
|
LicenseOfflineGraceExpired = 855,
|
|
LicenseVerificationFailed = 856,
|
|
FeatureNotAvailable = 860,
|
|
QuotaExceeded = 861,
|
|
TierRequired = 862,
|
|
```
|
|
|
|
---
|
|
|
|
## Speakly RE 참조
|
|
- Speakly는 클라우드 인증(AuthService) + 서버 라이센스 모델 사용
|
|
- D3RO는 오프라인 우선 → LemonSqueezy 라이센스 키 + 로컬 검증
|
|
- 쿼터 추적은 Speakly의 RecordStatsService 패턴 차용
|
|
|
|
## 완료 조건
|
|
- [ ] LicenseService 싱글톤 구현 + 초기화
|
|
- [ ] Feature enum + 티어별 접근 매핑
|
|
- [ ] daily_usage 테이블 + 쿼터 추적
|
|
- [ ] 라이센스 키 활성화/비활성화
|
|
- [ ] IPC 핸들러 + preload API
|
|
- [ ] UpgradePromptModal UI
|
|
- [ ] Settings License 탭
|
|
- [ ] Dashboard 사용량 표시
|
|
- [ ] 기존 서비스에 canUse() 체크 통합
|
|
- [ ] i18n 키 추가 (ko/en)
|
|
- [ ] typecheck 통과
|