feat: 支持数据库升级

This commit is contained in:
digua
2025-12-25 00:27:36 +08:00
parent 8175d175b5
commit e0ade9087d
8 changed files with 551 additions and 111 deletions
+124 -110
View File
@@ -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<string, number>()
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 }
}
+169
View File
@@ -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,
}))
}