d3ro-voice/docs/phases/phase-11-monetization.md
Yun Chan a31f96bbb8 Phase 10~11 전체 구현: 킬러 피처 5종 + 수익화 시스템
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)
- 커맨드 팝업 "선택 해제" 항목 추가
2026-04-05 21:36:09 +09:00

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 통과