SaaS [9] — 실증 A/B/C/D 전부 통과, 로컬→클라우드 push 최초 성공(pushed=1). ## Bug 4: 로컬 nanoid PK vs Supabase UUID PK 불일치 - 증상: Push history failed: invalid input syntax for type uuid: "fvy6bIzr..." - 원인: 로컬 drizzle schema는 text PK + nanoid() 생성, Supabase는 uuid PK. 빅뱅 사이클 내내 push가 한 번도 성공한 적 없었음 (지난 pushed=0은 데이터 0건이라서). - 픽스: 로컬을 UUID로 통일 (근본 해결, 땜질 금지). 14개 서비스 20곳 nanoid() → crypto.randomUUID() 일괄 교체. nanoid 의존성 + electron.vite.config exclude 제거. drizzle schema는 text PK 그대로 유지 (SQLite는 UUID 문자열 저장 가능). ## Bug 5: supabase_realtime publication 누락 - 증상: 로그인 직후 Realtime 채널 상태: TIMED_OUT - 원인: initial_schema.sql이 transcripts 테이블만 publication에 추가. 데스크톱이 구독하는 meetings/history/dictionary는 누락 → postgres_changes 흐르지 않음. - 픽스: 20260411000002_realtime_publication.sql 신규. pg_publication_tables 카탈로그 체크 + 조건부 ADD TABLE (meetings/meeting_memos/ meeting_documents/history/dictionary 5개). supabase db push 적용. ## Bug 6: persistSession:false에서 realtime.setAuth 자동 전파 안 됨 (부분 픽스) - 픽스: CloudSyncService.startRealtime()에 client.realtime.setAuth(access_token) 명시 호출 (채널 구성 이전). - ⚠️ Bug 5+6 적용 후에도 Realtime 여전히 TIMED_OUT. 후속 조사 필요. 블로커 아님 — 주기 pull + Phase 3.3 auto push로 최종 일관성 유지. ## 실증 결과 - A 세션 자동 복원: Restored session for yunchan8804@gmail.com → DB 재오픈 - B push 경로: HistoryService created 56a767ac-... → Sync complete pushed=1 errors=0 - C 로그아웃 복귀: Realtime 종료 → users/_local/d3ro.db 복귀 → local mode - D 재로그인 복원: 실증 A의 restore 경로와 동일, 같은 uuid DB 파일 보존 - E 웹 크로스 디바이스: Phase 3.3 이후로 지연 (Realtime 이슈 별건) 검증: desktop tsc --noEmit ✅, dev 재기동 ✅, push 최초 성공 ✅
256 lines
7.1 KiB
TypeScript
256 lines
7.1 KiB
TypeScript
// src/main/services/HistoryService.ts
|
|
// SQLite 기반 전사/명령 이력 저장. 설계서 01/03 IHistoryService 구현.
|
|
|
|
import { eq, desc, like, and, sql, count } from 'drizzle-orm'
|
|
import { getDatabase } from '../db'
|
|
import { history, stats } from '../db/schema'
|
|
import type { History, NewHistory } from '../db/schema'
|
|
import { getLogger } from './LoggerService'
|
|
import type {
|
|
HistoryEntry,
|
|
HistoryQueryParams,
|
|
HistoryPage,
|
|
HistorySearchParams,
|
|
StatsSummary
|
|
} from '@d3ro/core/types'
|
|
|
|
const logger = getLogger('HistoryService')
|
|
|
|
class HistoryService {
|
|
create(input: Omit<NewHistory, 'id' | 'createdAt' | 'updatedAt'>): HistoryEntry {
|
|
const db = getDatabase()
|
|
const now = Date.now()
|
|
const id = crypto.randomUUID()
|
|
|
|
const entry: NewHistory = {
|
|
id,
|
|
...input,
|
|
createdAt: now,
|
|
updatedAt: now
|
|
}
|
|
|
|
db.insert(history).values(entry).run()
|
|
|
|
// stats 싱글톤 업데이트
|
|
this._updateStats(input.duration, input.wordCount)
|
|
|
|
logger.info(`History entry created: ${id}`)
|
|
|
|
// 비동기로 LLM 타이틀 자동 생성 (fire-and-forget)
|
|
this.generateTitle(id).catch(() => { /* ignore */ })
|
|
|
|
return this._toEntry(entry as History)
|
|
}
|
|
|
|
getById(id: string): HistoryEntry | null {
|
|
const db = getDatabase()
|
|
const row = db.select().from(history).where(eq(history.id, id)).get()
|
|
return row ? this._toEntry(row) : null
|
|
}
|
|
|
|
list(params: HistoryQueryParams): HistoryPage {
|
|
const db = getDatabase()
|
|
const { page, pageSize } = params
|
|
|
|
const totalResult = db.select({ count: count() }).from(history).get()
|
|
const total = totalResult?.count ?? 0
|
|
|
|
const entries = db
|
|
.select()
|
|
.from(history)
|
|
.orderBy(desc(history.createdAt))
|
|
.limit(pageSize)
|
|
.offset(page * pageSize)
|
|
.all()
|
|
|
|
return {
|
|
entries: entries.map((e) => this._toEntry(e)),
|
|
total,
|
|
page,
|
|
pageSize,
|
|
totalPages: Math.ceil(total / pageSize)
|
|
}
|
|
}
|
|
|
|
search(params: HistorySearchParams): HistoryPage {
|
|
const db = getDatabase()
|
|
const { query, page, pageSize } = params
|
|
const pattern = `%${query}%`
|
|
|
|
const conditions = and(
|
|
like(history.originalText, pattern)
|
|
)
|
|
|
|
const totalResult = db
|
|
.select({ count: count() })
|
|
.from(history)
|
|
.where(conditions)
|
|
.get()
|
|
const total = totalResult?.count ?? 0
|
|
|
|
const entries = db
|
|
.select()
|
|
.from(history)
|
|
.where(conditions)
|
|
.orderBy(desc(history.createdAt))
|
|
.limit(pageSize)
|
|
.offset(page * pageSize)
|
|
.all()
|
|
|
|
return {
|
|
entries: entries.map((e) => this._toEntry(e)),
|
|
total,
|
|
page,
|
|
pageSize,
|
|
totalPages: Math.ceil(total / pageSize)
|
|
}
|
|
}
|
|
|
|
delete(id: string): boolean {
|
|
const db = getDatabase()
|
|
const result = db.delete(history).where(eq(history.id, id)).run()
|
|
return result.changes > 0
|
|
}
|
|
|
|
deleteAll(): void {
|
|
const db = getDatabase()
|
|
db.delete(history).run()
|
|
logger.info('All history entries deleted')
|
|
}
|
|
|
|
getStats(): StatsSummary {
|
|
const db = getDatabase()
|
|
const row = db.select().from(stats).where(eq(stats.id, 1)).get()
|
|
|
|
const todayStart = new Date()
|
|
todayStart.setHours(0, 0, 0, 0)
|
|
const todayMs = todayStart.getTime()
|
|
|
|
const todayResult = db
|
|
.select({
|
|
sessions: count(),
|
|
duration: sql<number>`COALESCE(SUM(duration), 0)`,
|
|
words: sql<number>`COALESCE(SUM(word_count), 0)`
|
|
})
|
|
.from(history)
|
|
.where(
|
|
and(
|
|
sql`created_at >= ${todayMs}`,
|
|
eq(history.status, 'completed')
|
|
)
|
|
)
|
|
.get()
|
|
|
|
return {
|
|
totalRecordingTimeMs: (row?.totalDuration ?? 0) * 1000,
|
|
totalWordCount: row?.totalWords ?? 0,
|
|
totalSessionCount: row?.sessionCount ?? 0,
|
|
todayRecordingTimeMs: (todayResult?.duration ?? 0) * 1000,
|
|
todayWordCount: todayResult?.words ?? 0,
|
|
todaySessionCount: todayResult?.sessions ?? 0,
|
|
streakDays: row?.streakDays ?? 0
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 보존 정책에 따라 오래된 항목 정리 (30일 초과)
|
|
*/
|
|
runRetentionCleanup(): number {
|
|
const db = getDatabase()
|
|
const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000
|
|
const result = db.delete(history).where(sql`created_at < ${cutoff}`).run()
|
|
if (result.changes > 0) {
|
|
logger.info(`Retention cleanup: ${result.changes} old entries removed`)
|
|
}
|
|
return result.changes
|
|
}
|
|
|
|
dispose(): void {
|
|
logger.info('HistoryService disposed')
|
|
}
|
|
|
|
private _updateStats(duration: number, wordCount: number): void {
|
|
const db = getDatabase()
|
|
const now = Date.now()
|
|
|
|
db.update(stats)
|
|
.set({
|
|
totalDuration: sql`total_duration + ${duration}`,
|
|
totalWords: sql`total_words + ${wordCount}`,
|
|
sessionCount: sql`session_count + 1`,
|
|
lastSessionAt: now,
|
|
lastUpdated: now
|
|
})
|
|
.where(eq(stats.id, 1))
|
|
.run()
|
|
}
|
|
|
|
/** 전사 완료 후 LLM으로 자동 타이틀 생성 */
|
|
async generateTitle(id: string): Promise<string | null> {
|
|
const entry = this.getById(id)
|
|
if (!entry) return null
|
|
|
|
const text = entry.polishedText || entry.originalText
|
|
if (!text || text.length < 10) return null
|
|
|
|
try {
|
|
const { getLocalLLMService } = await import('./LocalLLMService')
|
|
const llm = getLocalLLMService()
|
|
const result = await llm.generate(
|
|
text.slice(0, 2000),
|
|
{
|
|
systemPrompt: '다음 텍스트의 핵심 내용을 10단어 이내의 짧은 제목으로 만들어주세요. 제목만 출력하세요. 따옴표나 마침표를 붙이지 마세요.',
|
|
temperature: 0.3,
|
|
maxTokens: 50,
|
|
},
|
|
)
|
|
const title = result.text.trim().replace(/^["']|["']$/g, '').slice(0, 80)
|
|
if (title) {
|
|
const db = getDatabase()
|
|
db.update(history).set({ title, updatedAt: Date.now() }).where(eq(history.id, id)).run()
|
|
logger.info(`Auto title generated: ${id} → "${title}"`)
|
|
return title
|
|
}
|
|
} catch (err) {
|
|
logger.warn(`Auto title generation failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
}
|
|
return null
|
|
}
|
|
|
|
private _toEntry(row: History): HistoryEntry {
|
|
return {
|
|
id: row.id,
|
|
title: row.title ?? null,
|
|
originalText: row.originalText,
|
|
polishedText: row.polishedText,
|
|
focusedApp: row.focusedApp,
|
|
focusedAppName: row.focusedAppName,
|
|
focusedAppWindowTitle: row.focusedAppWindowTitle,
|
|
mode: row.mode as HistoryEntry['mode'],
|
|
status: row.status as HistoryEntry['status'],
|
|
errorCode: row.errorCode,
|
|
audioLocalPath: row.audioLocalPath,
|
|
duration: row.duration,
|
|
detectedLanguage: row.detectedLanguage,
|
|
micDevice: row.micDevice,
|
|
wordCount: row.wordCount,
|
|
sttModel: row.sttModel,
|
|
llmModel: row.llmModel,
|
|
sttLatencyMs: row.sttLatencyMs,
|
|
llmLatencyMs: row.llmLatencyMs,
|
|
createdAt: row.createdAt,
|
|
updatedAt: row.updatedAt,
|
|
appVersion: row.appVersion,
|
|
summaryText: row.summaryText ?? null,
|
|
}
|
|
}
|
|
}
|
|
|
|
let instance: HistoryService | null = null
|
|
|
|
export function getHistoryService(): HistoryService {
|
|
if (!instance) {
|
|
instance = new HistoryService()
|
|
}
|
|
return instance
|
|
}
|