From e0ade9087d2966dfd5f47e557d1804c13a0c1f55 Mon Sep 17 00:00:00 2001 From: digua Date: Thu, 25 Dec 2025 00:27:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=BA=93=E5=8D=87=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- electron/main/database/core.ts | 234 ++++++++++--------- electron/main/database/migrations.ts | 169 ++++++++++++++ electron/main/ipc/chat.ts | 51 ++++ electron/preload/index.ts | 31 +++ src/pages/home/components/MigrationModal.vue | 84 +++++++ src/pages/home/index.vue | 4 + src/stores/session.ts | 87 +++++++ 8 files changed, 551 insertions(+), 111 deletions(-) create mode 100644 electron/main/database/migrations.ts create mode 100644 src/pages/home/components/MigrationModal.vue diff --git a/README.md b/README.md index e5f82eb..31a1bd7 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ - 🔒 **保护隐私**:聊天记录和配置都存在你的本地数据库,所有分析都在本地进行(AI 功能例外)。 - 🤖 **智能 AI Agent**:集成 10+ Function Calling 工具,支持动态调度,深度挖掘聊天记录中的更多有趣。 - 📊 **多维数据可视化**:提供活跃度趋势、时间规律分布、成员排行等多个维度的直观分析图表。 -- 🧩 **格式标准化**:通过强大的数据抽象层,抹平不同社交平台的格式差异,任何聊天记录都能分析。 +- 🧩 **格式标准化**:通过强大的数据抽象层,抹平不同聊天软件的格式差异,任何聊天记录都能分析。 ## 使用指南 diff --git a/electron/main/database/core.ts b/electron/main/database/core.ts index 34ac296..86f9f6d 100644 --- a/electron/main/database/core.ts +++ b/electron/main/database/core.ts @@ -8,6 +8,7 @@ import { app } from 'electron' import * as fs from 'fs' import * as path from 'path' import type { DbMeta, ParseResult, AnalysisSession } from '../../../src/types/base' +import { migrateDatabase, needsMigration, CURRENT_SCHEMA_VERSION } from './migrations' // 数据库存储目录 let DB_DIR: string | null = null @@ -72,7 +73,9 @@ function createDatabase(sessionId: string): Database.Database { type TEXT NOT NULL, imported_at INTEGER NOT NULL, group_id TEXT, - group_avatar TEXT + group_avatar TEXT, + owner_id TEXT, + schema_version INTEGER DEFAULT ${CURRENT_SCHEMA_VERSION} ); CREATE TABLE IF NOT EXISTS member ( @@ -115,17 +118,37 @@ function createDatabase(sessionId: string): Database.Database { /** * 打开已存在的数据库 + * @param readonly 是否只读模式(默认 true) */ -export function openDatabase(sessionId: string): Database.Database | null { +export function openDatabase(sessionId: string, readonly = true): Database.Database | null { const dbPath = getDbPath(sessionId) if (!fs.existsSync(dbPath)) { return null } - const db = new Database(dbPath, { readonly: true }) + const db = new Database(dbPath, { readonly }) db.pragma('journal_mode = WAL') return db } +/** + * 打开数据库并执行迁移(如果需要) + * 用于需要写入的场景 + */ +export function openDatabaseWithMigration(sessionId: string): Database.Database | null { + const dbPath = getDbPath(sessionId) + if (!fs.existsSync(dbPath)) { + return null + } + + const db = new Database(dbPath) + db.pragma('journal_mode = WAL') + + // 执行迁移 + migrateDatabase(db) + + return db +} + /** * 导入解析后的数据到数据库 */ @@ -137,8 +160,8 @@ export function importData(parseResult: ParseResult): string { try { const importTransaction = db.transaction(() => { const insertMeta = db.prepare(` - INSERT INTO meta (name, platform, type, imported_at, group_id, group_avatar) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO meta (name, platform, type, imported_at, group_id, group_avatar, owner_id) + VALUES (?, ?, ?, ?, ?, ?, ?) `) insertMeta.run( parseResult.meta.name, @@ -146,7 +169,8 @@ export function importData(parseResult: ParseResult): string { parseResult.meta.type, Math.floor(Date.now() / 1000), parseResult.meta.groupId || null, - parseResult.meta.groupAvatar || null + parseResult.meta.groupAvatar || null, + parseResult.meta.ownerId || null ) const insertMember = db.prepare(` @@ -159,7 +183,12 @@ export function importData(parseResult: ParseResult): string { const memberIdMap = new Map() for (const member of parseResult.members) { - insertMember.run(member.platformId, member.accountName || null, member.groupNickname || null, member.avatar || null) + insertMember.run( + member.platformId, + member.accountName || null, + member.groupNickname || null, + member.avatar || null + ) const row = getMemberId.get(member.platformId) as { id: number } memberIdMap.set(member.platformId, row.id) } @@ -268,113 +297,22 @@ export function importData(parseResult: ParseResult): string { } /** - * 获取所有分析会话列表 + * 更新会话的 ownerId */ -export function getAllSessions(): AnalysisSession[] { - ensureDbDir() - const sessions: AnalysisSession[] = [] - const dbDir = getDbDir() - const allFiles = fs.readdirSync(dbDir) - const files = allFiles.filter((f) => f.endsWith('.db')) - - for (const file of files) { - const sessionId = file.replace('.db', '') - const dbPath = getDbPath(sessionId) - - try { - const db = new Database(dbPath) - db.pragma('journal_mode = WAL') - - const meta = db.prepare('SELECT * FROM meta LIMIT 1').get() as DbMeta | undefined - - if (meta) { - const messageCount = ( - db - .prepare( - `SELECT COUNT(*) as count - FROM message msg - JOIN member m ON msg.sender_id = m.id - WHERE COALESCE(m.account_name, '') != '系统消息'` - ) - .get() as { count: number } - ).count - const memberCount = ( - db - .prepare( - `SELECT COUNT(*) as count - FROM member - WHERE COALESCE(account_name, '') != '系统消息'` - ) - .get() as { count: number } - ).count - - sessions.push({ - id: sessionId, - name: meta.name, - platform: meta.platform as AnalysisSession['platform'], - type: meta.type as AnalysisSession['type'], - importedAt: meta.imported_at, - messageCount, - memberCount, - dbPath, - groupId: meta.group_id || null, - groupAvatar: meta.group_avatar || null, - }) - } - - db.close() - } catch (error) { - console.error(`[Database] Failed to read database \${file}:`, error) - } +export function updateSessionOwnerId(sessionId: string, ownerId: string | null): boolean { + // 使用带迁移的打开方式,确保 owner_id 列存在 + const db = openDatabaseWithMigration(sessionId) + if (!db) { + return false } - return sessions.sort((a, b) => b.importedAt - a.importedAt) -} - -/** - * 获取单个会话信息 - */ -export function getSession(sessionId: string): AnalysisSession | null { - const db = openDatabase(sessionId) - if (!db) return null - try { - const meta = db.prepare('SELECT * FROM meta LIMIT 1').get() as DbMeta | undefined - if (!meta) return null - - const messageCount = ( - db - .prepare( - `SELECT COUNT(*) as count - FROM message msg - JOIN member m ON msg.sender_id = m.id - WHERE COALESCE(m.account_name, '') != '系统消息'` - ) - .get() as { count: number } - ).count - - const memberCount = ( - db - .prepare( - `SELECT COUNT(*) as count - FROM member - WHERE COALESCE(account_name, '') != '系统消息'` - ) - .get() as { count: number } - ).count - - return { - id: sessionId, - name: meta.name, - platform: meta.platform as AnalysisSession['platform'], - type: meta.type as AnalysisSession['type'], - importedAt: meta.imported_at, - messageCount, - memberCount, - dbPath: getDbPath(sessionId), - groupId: meta.group_id || null, - groupAvatar: meta.group_avatar || null, - } + const stmt = db.prepare('UPDATE meta SET owner_id = ?') + stmt.run(ownerId) + return true + } catch (error) { + console.error('[Database] Failed to update session ownerId:', error) + return false } finally { db.close() } @@ -436,3 +374,79 @@ export function getDbDirectory(): string { ensureDbDir() return getDbDir() } + +/** + * 检查是否有数据库需要迁移 + * @returns 需要迁移的数据库数量和最低版本 + */ +export function checkMigrationNeeded(): { count: number; sessionIds: string[]; lowestVersion: number } { + ensureDbDir() + const dbDir = getDbDir() + const files = fs.readdirSync(dbDir).filter((f) => f.endsWith('.db')) + const needsMigrationList: string[] = [] + let lowestVersion = CURRENT_SCHEMA_VERSION + + for (const file of files) { + const sessionId = file.replace('.db', '') + const dbPath = getDbPath(sessionId) + + try { + const db = new Database(dbPath, { readonly: true }) + db.pragma('journal_mode = WAL') + + if (needsMigration(db)) { + needsMigrationList.push(sessionId) + // 获取这个数据库的版本 + const tableInfo = db.prepare('PRAGMA table_info(meta)').all() as Array<{ name: string }> + const hasVersionColumn = tableInfo.some((col) => col.name === 'schema_version') + let dbVersion = 0 + if (hasVersionColumn) { + const result = db.prepare('SELECT schema_version FROM meta LIMIT 1').get() as + | { schema_version: number | null } + | undefined + dbVersion = result?.schema_version ?? 0 + } + lowestVersion = Math.min(lowestVersion, dbVersion) + } + + db.close() + } catch (error) { + console.error(`[Database] Failed to check migration for ${file}:`, error) + } + } + + return { count: needsMigrationList.length, sessionIds: needsMigrationList, lowestVersion } +} + +/** + * 执行所有数据库的迁移 + * @returns 迁移结果 + */ +export function migrateAllDatabases(): { success: boolean; migratedCount: number; error?: string } { + const { sessionIds } = checkMigrationNeeded() + + if (sessionIds.length === 0) { + return { success: true, migratedCount: 0 } + } + + let migratedCount = 0 + + for (const sessionId of sessionIds) { + try { + const db = openDatabaseWithMigration(sessionId) + if (db) { + db.close() + migratedCount++ + } + } catch (error) { + console.error(`[Database] Failed to migrate ${sessionId}:`, error) + return { + success: false, + migratedCount, + error: `迁移 ${sessionId} 失败: ${error instanceof Error ? error.message : String(error)}`, + } + } + } + + return { success: true, migratedCount } +} diff --git a/electron/main/database/migrations.ts b/electron/main/database/migrations.ts new file mode 100644 index 0000000..ffd2930 --- /dev/null +++ b/electron/main/database/migrations.ts @@ -0,0 +1,169 @@ +/** + * 数据库迁移系统 + * + * 用于管理数据库 schema 的版本升级。 + * 每次添加新字段或修改表结构时,创建一个新的迁移脚本。 + * + * 使用方式: + * 1. 在 migrations 数组中添加新的迁移对象 + * 2. version 必须递增 + * 3. up 函数执行迁移逻辑 + */ + +import type Database from 'better-sqlite3' + +/** 迁移脚本接口 */ +interface Migration { + /** 版本号(必须递增) */ + version: number + /** 迁移描述(技术说明) */ + description: string + /** 用户可读的升级原因(显示在弹窗中) */ + userMessage: string + /** 迁移执行函数 */ + up: (db: Database.Database) => void +} + +/** 导出给前端使用的迁移信息 */ +export interface MigrationInfo { + version: number + /** 技术描述(面向开发者) */ + description: string + /** 用户可读的升级原因(显示在弹窗中) */ + userMessage: string +} + +/** 当前 schema 版本(最新迁移的版本号) */ +export const CURRENT_SCHEMA_VERSION = 1 + +/** + * 迁移脚本列表 + * 注意:版本号必须递增,每个迁移只执行一次 + */ +const migrations: Migration[] = [ + { + version: 1, + description: '添加 owner_id 字段到 meta 表', + userMessage: '支持「Owner」功能,可在成员列表中设置自己的身份', + up: (db) => { + // 检查 owner_id 列是否已存在(防止重复执行) + const tableInfo = db.prepare('PRAGMA table_info(meta)').all() as Array<{ name: string }> + const hasOwnerIdColumn = tableInfo.some((col) => col.name === 'owner_id') + if (!hasOwnerIdColumn) { + db.exec('ALTER TABLE meta ADD COLUMN owner_id TEXT') + } + }, + }, + // 未来的迁移示例: + // { + // version: 2, + // description: '添加 xxx 字段', + // userMessage: '新功能说明', + // up: (db) => { + // db.exec('ALTER TABLE xxx ADD COLUMN yyy TEXT') + // }, + // }, +] + +/** + * 获取数据库的 schema 版本 + * 如果没有版本信息,返回 0 + */ +function getSchemaVersion(db: Database.Database): number { + try { + // 检查 meta 表是否有 schema_version 列 + const tableInfo = db.prepare('PRAGMA table_info(meta)').all() as Array<{ name: string }> + const hasVersionColumn = tableInfo.some((col) => col.name === 'schema_version') + + if (!hasVersionColumn) { + return 0 + } + + const result = db.prepare('SELECT schema_version FROM meta LIMIT 1').get() as + | { schema_version: number | null } + | undefined + return result?.schema_version ?? 0 + } catch { + return 0 + } +} + +/** + * 设置数据库的 schema 版本 + */ +function setSchemaVersion(db: Database.Database, version: number): void { + // 检查 schema_version 列是否存在 + const tableInfo = db.prepare('PRAGMA table_info(meta)').all() as Array<{ name: string }> + const hasVersionColumn = tableInfo.some((col) => col.name === 'schema_version') + + if (!hasVersionColumn) { + // 添加 schema_version 列 + db.exec('ALTER TABLE meta ADD COLUMN schema_version INTEGER DEFAULT 0') + } + + // 更新版本号 + db.prepare('UPDATE meta SET schema_version = ?').run(version) +} + +/** + * 执行数据库迁移 + * 自动检测当前版本并执行所有需要的迁移 + * + * @param db 数据库连接 + * @returns 是否执行了迁移 + */ +export function migrateDatabase(db: Database.Database): boolean { + const currentVersion = getSchemaVersion(db) + + // 已是最新版本,无需迁移 + if (currentVersion >= CURRENT_SCHEMA_VERSION) { + return false + } + + // 获取需要执行的迁移 + const pendingMigrations = migrations.filter((m) => m.version > currentVersion) + + if (pendingMigrations.length === 0) { + return false + } + + console.log( + `[Migration] 数据库版本 ${currentVersion} -> ${CURRENT_SCHEMA_VERSION},执行 ${pendingMigrations.length} 个迁移` + ) + + // 在事务中执行所有迁移 + const migrate = db.transaction(() => { + for (const migration of pendingMigrations) { + console.log(`[Migration] 执行迁移 v${migration.version}: ${migration.description}`) + migration.up(db) + setSchemaVersion(db, migration.version) + } + }) + + migrate() + + console.log(`[Migration] 迁移完成,当前版本: ${CURRENT_SCHEMA_VERSION}`) + return true +} + +/** + * 检查数据库是否需要迁移 + */ +export function needsMigration(db: Database.Database): boolean { + const currentVersion = getSchemaVersion(db) + return currentVersion < CURRENT_SCHEMA_VERSION +} + +/** + * 获取待执行的迁移信息(用户可读) + * @param fromVersion 起始版本(不含) + */ +export function getPendingMigrationInfos(fromVersion = 0): MigrationInfo[] { + return migrations + .filter((m) => m.version > fromVersion) + .map((m) => ({ + version: m.version, + description: m.description, + userMessage: m.userMessage, + })) +} diff --git a/electron/main/ipc/chat.ts b/electron/main/ipc/chat.ts index 604083c..b821f52 100644 --- a/electron/main/ipc/chat.ts +++ b/electron/main/ipc/chat.ts @@ -8,6 +8,7 @@ import * as worker from '../worker/workerManager' import * as parser from '../parser' import { detectFormat, type ParseProgress } from '../parser' import type { IpcContext } from './types' +import { CURRENT_SCHEMA_VERSION, getPendingMigrationInfos, type MigrationInfo } from '../database/migrations' /** * 注册聊天记录相关 IPC 处理器 @@ -15,6 +16,41 @@ import type { IpcContext } from './types' export function registerChatHandlers(ctx: IpcContext): void { const { win } = ctx + // ==================== 数据库迁移 ==================== + + /** + * 检查是否需要数据库迁移 + */ + ipcMain.handle('chat:checkMigration', async () => { + try { + const result = databaseCore.checkMigrationNeeded() + // 获取待执行的迁移信息(从最低版本开始) + const pendingMigrations = getPendingMigrationInfos(result.lowestVersion) + return { + needsMigration: result.count > 0, + count: result.count, + currentVersion: CURRENT_SCHEMA_VERSION, + pendingMigrations, + } + } catch (error) { + console.error('[IpcMain] 检查迁移失败:', error) + return { needsMigration: false, count: 0, currentVersion: CURRENT_SCHEMA_VERSION, pendingMigrations: [] } + } + }) + + /** + * 执行数据库迁移 + */ + ipcMain.handle('chat:runMigration', async () => { + try { + const result = databaseCore.migrateAllDatabases() + return result + } catch (error) { + console.error('[IpcMain] 执行迁移失败:', error) + return { success: false, migratedCount: 0, error: String(error) } + } + }) + // ==================== 聊天记录导入与分析 ==================== /** @@ -522,6 +558,21 @@ export function registerChatHandlers(ctx: IpcContext): void { } }) + /** + * 更新会话的所有者(ownerId) + */ + ipcMain.handle('chat:updateSessionOwnerId', async (_, sessionId: string, ownerId: string | null) => { + try { + // 先关闭数据库连接 + await worker.closeDatabase(sessionId) + // 执行更新 + return databaseCore.updateSessionOwnerId(sessionId, ownerId) + } catch (error) { + console.error('更新会话所有者失败:', error) + return false + } + }) + // ==================== SQL 实验室 ==================== /** diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 01155b2..da7acf5 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -65,6 +65,29 @@ const api = { // Chat Analysis API const chatApi = { + // ==================== 数据库迁移 ==================== + + /** + * 检查是否需要数据库迁移 + */ + checkMigration: (): Promise<{ + needsMigration: boolean + count: number + currentVersion: number + pendingMigrations: Array<{ version: number; userMessage: string }> + }> => { + return ipcRenderer.invoke('chat:checkMigration') + }, + + /** + * 执行数据库迁移 + */ + runMigration: (): Promise<{ success: boolean; migratedCount: number; error?: string }> => { + return ipcRenderer.invoke('chat:runMigration') + }, + + // ==================== 聊天记录导入与分析 ==================== + /** * 选择聊天记录文件 */ @@ -318,6 +341,14 @@ const chatApi = { return ipcRenderer.invoke('chat:deleteMember', sessionId, memberId) }, + /** + * 更新会话的所有者(ownerId) + * @param ownerId 成员的 platformId,设置为 null 则清除 + */ + updateSessionOwnerId: (sessionId: string, ownerId: string | null): Promise => { + return ipcRenderer.invoke('chat:updateSessionOwnerId', sessionId, ownerId) + }, + // ==================== SQL 实验室 ==================== /** diff --git a/src/pages/home/components/MigrationModal.vue b/src/pages/home/components/MigrationModal.vue new file mode 100644 index 0000000..46f906f --- /dev/null +++ b/src/pages/home/components/MigrationModal.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/src/pages/home/index.vue b/src/pages/home/index.vue index ebbead3..df98fc9 100644 --- a/src/pages/home/index.vue +++ b/src/pages/home/index.vue @@ -4,6 +4,7 @@ import { storeToRefs } from 'pinia' import { ref } from 'vue' import { useRouter } from 'vue-router' import AgreementModal from './components/AgreementModal.vue' +import MigrationModal from './components/MigrationModal.vue' import { useSessionStore } from '@/stores/session' const sessionStore = useSessionStore() @@ -250,5 +251,8 @@ function getProgressDetail(): string { + + + diff --git a/src/stores/session.ts b/src/stores/session.ts index f18def7..72ef3c8 100644 --- a/src/stores/session.ts +++ b/src/stores/session.ts @@ -2,6 +2,23 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import type { AnalysisSession, ImportProgress } from '@/types/base' +/** 迁移信息 */ +export interface MigrationInfo { + version: number + /** 技术描述(面向开发者) */ + description: string + /** 用户可读的升级原因(显示在弹窗中) */ + userMessage: string +} + +/** 迁移检查结果 */ +export interface MigrationCheckResult { + needsMigration: boolean + count: number + currentVersion: number + pendingMigrations: MigrationInfo[] +} + /** * 会话与导入相关的全局状态 */ @@ -24,6 +41,48 @@ export const useSessionStore = defineStore( return sessions.value.find((s) => s.id === currentSessionId.value) || null }) + // 迁移相关状态 + const migrationNeeded = ref(false) + const migrationCount = ref(0) + const pendingMigrations = ref([]) + const isMigrating = ref(false) + + /** + * 检查是否需要数据库迁移 + */ + async function checkMigration(): Promise { + try { + const result = await window.chatApi.checkMigration() + migrationNeeded.value = result.needsMigration + migrationCount.value = result.count + pendingMigrations.value = result.pendingMigrations || [] + return result + } catch (error) { + console.error('检查迁移失败:', error) + return { needsMigration: false, count: 0, currentVersion: 0, pendingMigrations: [] } + } + } + + /** + * 执行数据库迁移 + */ + async function runMigration(): Promise<{ success: boolean; error?: string }> { + isMigrating.value = true + try { + const result = await window.chatApi.runMigration() + if (result.success) { + migrationNeeded.value = false + migrationCount.value = 0 + } + return result + } catch (error) { + console.error('执行迁移失败:', error) + return { success: false, error: String(error) } + } finally { + isMigrating.value = false + } + } + /** * 从数据库加载会话列表 */ @@ -199,6 +258,25 @@ export const useSessionStore = defineStore( currentSessionId.value = null } + /** + * 更新会话的所有者 + */ + async function updateSessionOwnerId(id: string, ownerId: string | null): Promise { + try { + const success = await window.chatApi.updateSessionOwnerId(id, ownerId) + if (success) { + const session = sessions.value.find((s) => s.id === id) + if (session) { + session.ownerId = ownerId + } + } + return success + } catch (error) { + console.error('更新会话所有者失败:', error) + return false + } + } + return { sessions, currentSessionId, @@ -206,6 +284,14 @@ export const useSessionStore = defineStore( importProgress, isInitialized, currentSession, + // 迁移相关 + migrationNeeded, + migrationCount, + pendingMigrations, + isMigrating, + checkMigration, + runMigration, + // 会话操作 loadSessions, importFile, importFileFromPath, @@ -213,6 +299,7 @@ export const useSessionStore = defineStore( deleteSession, renameSession, clearSelection, + updateSessionOwnerId, } }, {