From 1c554fd6d782dc20fd0a76c7c5a0e632f1f179b2 Mon Sep 17 00:00:00 2001 From: MystHiru Date: Mon, 31 Aug 2026 04:51:17 +0000 Subject: [PATCH] =?UTF-8?q?fix(db):=20=E5=85=BC=E5=AE=B9=E6=97=A7=2041-43?= =?UTF-8?q?=20=E7=BC=96=E5=8F=B7=EF=BC=8C=E9=81=BF=E5=85=8D=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=20ADD=20COLUMN=20=E5=AF=BC=E8=87=B4=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E5=B4=A9=E6=BA=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1.10.1 曾把 session_parent/proxy/sort 编成 41/42/43,随后又在中间插入 thinking_blocks 与 cache_creation,把这三项改成 43/44/45。已升到旧 43 的库再跑新 44 会 duplicate column name: proxyEnabled,同时永远跳过 41/42 的新列。 让 ADD COLUMN 对 duplicate column 幂等,并在迁移/打开库时补齐缺失列。 --- .../com/aicode/core/db/MigrationLoader.kt | 30 +++++-- .../java/com/aicode/core/db/SchemaCatchUp.kt | 85 +++++++++++++++++++ .../main/java/com/aicode/di/AgentModule.kt | 6 ++ .../com/aicode/core/db/FileMigrationTest.kt | 28 ++++++ 4 files changed, 140 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/com/aicode/core/db/SchemaCatchUp.kt diff --git a/app/src/main/java/com/aicode/core/db/MigrationLoader.kt b/app/src/main/java/com/aicode/core/db/MigrationLoader.kt index e5517858..8a3242a6 100644 --- a/app/src/main/java/com/aicode/core/db/MigrationLoader.kt +++ b/app/src/main/java/com/aicode/core/db/MigrationLoader.kt @@ -18,12 +18,24 @@ class FileMigration( "executed_at INTEGER)" ) for (sql in sqlStatements) { - if (sql.isNotBlank()) { + if (sql.isBlank()) continue + try { db.execSQL(sql) + } catch (e: Exception) { + // 旧包把 proxy/sort 编成 42/43,新包改成 44/45 后会重复 ADD COLUMN。 + if (SchemaCatchUp.isDuplicateColumnMessage(e.message)) { + FileLogger.w( + "MigrationLoader", + "Skip already-applied column in $scriptName: $sql" + ) + } else { + throw e + } } } + SchemaCatchUp.ensure(db) db.execSQL( - "INSERT INTO migration_history (version, script_name, executed_at) VALUES (?, ?, ?)", + "INSERT OR REPLACE INTO migration_history (version, script_name, executed_at) VALUES (?, ?, ?)", arrayOf(version, scriptName, System.currentTimeMillis()) ) FileLogger.i("MigrationLoader", "Applied migration: $scriptName") @@ -35,27 +47,27 @@ object MigrationLoader { val assetManager = context.assets val migrationsDir = "migrations" val files = runCatching { assetManager.list(migrationsDir) }.getOrNull() ?: emptyArray() - + val migrations = mutableListOf() - + // File format: {version}_{description}.sql, e.g., "7_add_workspace_path.sql" for (fileName in files) { if (!fileName.endsWith(".sql")) continue - + val versionStr = fileName.substringBefore('_') val version = versionStr.toIntOrNull() ?: continue - + val sqlContent = runCatching { assetManager.open("$migrationsDir/$fileName").bufferedReader().use { it.readText() } }.getOrNull() ?: continue - + val statements = sqlContent.split(";") .map { it.trim() } .filter { it.isNotEmpty() } - + migrations.add(FileMigration(version, fileName, statements)) } - + return migrations.toTypedArray() } } diff --git a/app/src/main/java/com/aicode/core/db/SchemaCatchUp.kt b/app/src/main/java/com/aicode/core/db/SchemaCatchUp.kt new file mode 100644 index 00000000..5d702010 --- /dev/null +++ b/app/src/main/java/com/aicode/core/db/SchemaCatchUp.kt @@ -0,0 +1,85 @@ +package com.aicode.core.db + +import androidx.sqlite.db.SupportSQLiteDatabase +import com.aicode.core.util.FileLogger + +/** + * 补齐历史上被改号/重排过的加法迁移列。 + * + * 1.10.1 曾把 session_parent / provider_proxy / provider_sort_order + * 编成 41/42/43,随后又在中间插入 thinking_blocks / cache_creation, + * 把这三项改成 43/44/45。已升到旧 43 的库再跑新 44 会 + * `duplicate column name: proxyEnabled`,同时永远跳过 41/42 的新列。 + */ +object SchemaCatchUp { + + data class AdditiveColumn( + val table: String, + val name: String, + val definition: String + ) + + val requiredColumns: List = listOf( + AdditiveColumn("agent_messages", "thinkingBlocksJson", "TEXT DEFAULT NULL"), + AdditiveColumn("llm_call_records", "cacheCreationTokens", "INTEGER NOT NULL DEFAULT 0"), + AdditiveColumn("chat_sessions", "parentId", "TEXT DEFAULT NULL"), + AdditiveColumn("chat_sessions", "subagentType", "TEXT DEFAULT NULL"), + AdditiveColumn("ai_providers", "proxyEnabled", "INTEGER NOT NULL DEFAULT 0"), + AdditiveColumn("ai_providers", "proxyType", "TEXT NOT NULL DEFAULT 'HTTP'"), + AdditiveColumn("ai_providers", "proxyHost", "TEXT NOT NULL DEFAULT ''"), + AdditiveColumn("ai_providers", "proxyPort", "INTEGER NOT NULL DEFAULT 0"), + AdditiveColumn("ai_providers", "proxyUsername", "TEXT NOT NULL DEFAULT ''"), + AdditiveColumn("ai_providers", "proxyPassword", "TEXT NOT NULL DEFAULT ''"), + AdditiveColumn("ai_providers", "sortOrder", "INTEGER NOT NULL DEFAULT 0") + ) + + fun isDuplicateColumnMessage(message: String?): Boolean { + return message.orEmpty().contains("duplicate column name", ignoreCase = true) + } + + fun existingColumns(db: SupportSQLiteDatabase, table: String): Set { + val result = mutableSetOf() + db.query("PRAGMA table_info(`$table`)").use { cursor -> + val nameIndex = cursor.getColumnIndex("name") + while (cursor.moveToNext()) { + if (nameIndex >= 0) { + result.add(cursor.getString(nameIndex)) + } + } + } + return result + } + + fun tableExists(db: SupportSQLiteDatabase, table: String): Boolean { + db.query( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1", + arrayOf(table) + ).use { cursor -> + return cursor.moveToFirst() + } + } + + fun ensure(db: SupportSQLiteDatabase) { + for ((table, columns) in requiredColumns.groupBy { it.table }) { + if (!tableExists(db, table)) continue + val existing = existingColumns(db, table) + for (column in columns) { + if (column.name in existing) continue + val sql = "ALTER TABLE `$table` ADD COLUMN `${column.name}` ${column.definition}" + try { + db.execSQL(sql) + FileLogger.i("SchemaCatchUp", "Added missing column ${table}.${column.name}") + } catch (e: Exception) { + if (isDuplicateColumnMessage(e.message)) { + FileLogger.w( + "SchemaCatchUp", + "Column already present: ${table}.${column.name}" + ) + } else { + throw e + } + } + } + } + } +} diff --git a/app/src/main/java/com/aicode/di/AgentModule.kt b/app/src/main/java/com/aicode/di/AgentModule.kt index 5ec4fa7d..98242e17 100644 --- a/app/src/main/java/com/aicode/di/AgentModule.kt +++ b/app/src/main/java/com/aicode/di/AgentModule.kt @@ -68,6 +68,7 @@ import javax.inject.Named import javax.inject.Singleton import com.aicode.core.db.MigrationLoader +import com.aicode.core.db.SchemaCatchUp import com.aicode.feature.agent.domain.checkpoint.CheckpointManager import com.aicode.feature.agent.domain.session.MessagePersistenceUseCase import com.aicode.feature.agent.domain.session.SessionUseCase @@ -97,6 +98,11 @@ object AgentModule { AgentDatabase::class.java, "aicode_agent_db" ).addMigrations(*MigrationLoader.loadMigrations(context)) + .addCallback(object : androidx.room.RoomDatabase.Callback() { + override fun onOpen(db: SupportSQLiteDatabase) { + SchemaCatchUp.ensure(db) + } + }) .fallbackToDestructiveMigration(dropAllTables = false) .build() } diff --git a/app/src/test/java/com/aicode/core/db/FileMigrationTest.kt b/app/src/test/java/com/aicode/core/db/FileMigrationTest.kt index 58cbf756..f1922024 100644 --- a/app/src/test/java/com/aicode/core/db/FileMigrationTest.kt +++ b/app/src/test/java/com/aicode/core/db/FileMigrationTest.kt @@ -1,6 +1,8 @@ package com.aicode.core.db import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test class FileMigrationTest { @@ -52,4 +54,30 @@ class FileMigrationTest { ) assertEquals("-- 注释\nALTER TABLE sample ADD COLUMN data TEXT", statements[1]) } + + @Test + fun duplicateColumnMessage_matchesSqliteError() { + assertTrue( + SchemaCatchUp.isDuplicateColumnMessage( + "duplicate column name: proxyEnabled (code 1 SQLITE_ERROR)" + ) + ) + assertTrue( + SchemaCatchUp.isDuplicateColumnMessage( + "SQLiteException: Duplicate column name: sortOrder" + ) + ) + assertFalse(SchemaCatchUp.isDuplicateColumnMessage("no such table: ai_providers")) + assertFalse(SchemaCatchUp.isDuplicateColumnMessage(null)) + } + + @Test + fun requiredColumns_coverRenumberedMigrations() { + val names = SchemaCatchUp.requiredColumns.map { "${it.table}.${it.name}" }.toSet() + assertTrue(names.contains("agent_messages.thinkingBlocksJson")) + assertTrue(names.contains("llm_call_records.cacheCreationTokens")) + assertTrue(names.contains("chat_sessions.parentId")) + assertTrue(names.contains("ai_providers.proxyEnabled")) + assertTrue(names.contains("ai_providers.sortOrder")) + } }