mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-11-02 14:11:49 +08:00
fix(db): require explicit opt-in for Flyway history repair (#657)
Contributed by RobinZhiBin <robinzhi@gmail.com> (@RobinZhiBin). Preserve migration history validation by default and document controlled recovery. Reviewed with the related community fixes; 71 backend regression tests passed.
This commit is contained in:
parent
3b5f7de119
commit
0394ba2803
@ -2,30 +2,29 @@ package vip.mate.config;
|
|||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.flywaydb.core.Flyway;
|
import org.flywaydb.core.Flyway;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.boot.autoconfigure.flyway.FlywayMigrationInitializer;
|
import org.springframework.boot.autoconfigure.flyway.FlywayMigrationInitializer;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Flyway auto-repair configuration.
|
* Strict migration startup by default. Repair is an explicit operator opt-in:
|
||||||
* <p>
|
* changing applied checksums must never silently rewrite migration history.
|
||||||
* Replaces the default {@link FlywayMigrationInitializer} with one that
|
|
||||||
* calls {@code flyway.repair()} before {@code flyway.migrate()}.
|
|
||||||
* This handles failed migrations and checksum mismatches transparently
|
|
||||||
* during version upgrades — especially important for Desktop app users
|
|
||||||
* who cannot manually run CLI commands.
|
|
||||||
*
|
|
||||||
* @author MateClaw Team
|
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Configuration
|
@Configuration
|
||||||
public class FlywayRepairConfig {
|
public class FlywayRepairConfig {
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public FlywayMigrationInitializer flywayInitializer(Flyway flyway) {
|
public FlywayMigrationInitializer flywayInitializer(Flyway flyway,
|
||||||
|
@Value("${mateclaw.flyway.auto-repair:false}") boolean autoRepair) {
|
||||||
return new FlywayMigrationInitializer(flyway, f -> {
|
return new FlywayMigrationInitializer(flyway, f -> {
|
||||||
log.info("[Flyway] Running repair before migrate (auto-fix failed/changed migrations)...");
|
if (autoRepair) {
|
||||||
f.repair();
|
log.warn("[Flyway] Explicit auto-repair is enabled; applied migration history may be changed");
|
||||||
|
f.repair();
|
||||||
|
}
|
||||||
|
// Let Flyway validate before migration. In particular, checksum drift
|
||||||
|
// must fail startup rather than be hidden by an automatic repair.
|
||||||
f.migrate();
|
f.migrate();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -43,8 +43,8 @@ spring:
|
|||||||
# 迁移前确保目标 schema 存在(Kingbase 不会自动创建非 public schema)
|
# 迁移前确保目标 schema 存在(Kingbase 不会自动创建非 public schema)
|
||||||
init-sqls:
|
init-sqls:
|
||||||
- CREATE SCHEMA IF NOT EXISTS mateclaw
|
- CREATE SCHEMA IF NOT EXISTS mateclaw
|
||||||
# 首次迁移或脚本变更后跳过 checksum 校验,避免因脚本转换导致的校验失败卡住
|
# Inherit strict checksum validation from application.yml.
|
||||||
validate-on-migrate: false
|
# Use explicit, reviewed history repair when recovering an older database.
|
||||||
|
|
||||||
h2:
|
h2:
|
||||||
console:
|
console:
|
||||||
|
|||||||
@ -40,8 +40,8 @@ spring:
|
|||||||
# auto-create a non-public schema).
|
# auto-create a non-public schema).
|
||||||
init-sqls:
|
init-sqls:
|
||||||
- CREATE SCHEMA IF NOT EXISTS mateclaw
|
- CREATE SCHEMA IF NOT EXISTS mateclaw
|
||||||
# Skip checksum validation so an in-place migration edit doesn't block startup.
|
# Inherit strict checksum validation from application.yml.
|
||||||
validate-on-migrate: false
|
# Use explicit, reviewed history repair when recovering an older database.
|
||||||
|
|
||||||
h2:
|
h2:
|
||||||
console:
|
console:
|
||||||
|
|||||||
@ -266,7 +266,7 @@ MateClaw uses **MyBatis Plus** (not JPA) for database access. Conventions:
|
|||||||
- `snake_case` columns, `camelCase` Java fields, auto-mapped
|
- `snake_case` columns, `camelCase` Java fields, auto-mapped
|
||||||
- Every table has `create_time`, `update_time`, `deleted` (logical delete)
|
- Every table has `create_time`, `update_time`, `deleted` (logical delete)
|
||||||
- **Flyway** manages schema migrations — `db/migration/h2/` and `db/migration/mysql/` hold dialect-specific scripts, auto-selected on startup
|
- **Flyway** manages schema migrations — `db/migration/h2/` and `db/migration/mysql/` hold dialect-specific scripts, auto-selected on startup
|
||||||
- `FlywayRepairConfig` runs `repair()` before `migrate()` on every boot, self-healing checksum drift and partially-failed migrations
|
- `FlywayRepairConfig` runs `migrate()` with validation enabled by default; history repair requires an explicit operator opt-in. See [database recovery](./config#database-schema-init) before enabling it
|
||||||
- Seed data loaded by `DatabaseBootstrapRunner` from `db/data-*.sql`, idempotent
|
- Seed data loaded by `DatabaseBootstrapRunner` from `db/data-*.sql`, idempotent
|
||||||
|
|
||||||
### Table groups
|
### Table groups
|
||||||
|
|||||||
@ -267,7 +267,18 @@ MateClaw uses **Flyway** for schema migrations:
|
|||||||
2. `db/migration/mysql/V*__*.sql` — MySQL-dialect migration scripts
|
2. `db/migration/mysql/V*__*.sql` — MySQL-dialect migration scripts
|
||||||
3. After migrations, seed data is loaded from `db/data-*.sql` — idempotent
|
3. After migrations, seed data is loaded from `db/data-*.sql` — idempotent
|
||||||
|
|
||||||
Flyway auto-selects the correct dialect path based on the active Spring profile. Every startup runs a `repair` before `migrate`, self-healing checksum drift and partially-failed migrations (especially important for desktop users upgrading offline).
|
Flyway selects the migration dialect from the active Spring profile. Startup runs `migrate` with checksum validation enabled by default in all bundled profiles. Applied migration files must not be edited: checksum drift or a failed migration stops startup rather than silently changing the schema history.
|
||||||
|
|
||||||
|
**Desktop upgrade compatibility:** an existing desktop database whose applied scripts differ from the new release can now fail to start. A new database and an unchanged migration history need no repair. Keep a backup that can be opened with the previous application version; do not delete the database to clear a validation error.
|
||||||
|
|
||||||
|
If validation fails:
|
||||||
|
|
||||||
|
1. Stop the application and back up the database. Keep the exact previous and target release artifacts, and inspect the failing migration and schema history.
|
||||||
|
2. Compare the old and new SQL with the actual schema/data. Prefer restoring the expected migration file or a maintainer-provided forward migration. `repair` changes migration metadata; it does **not** execute changed DDL, undo partial DDL/data changes, or prove the schema is correct. Resolve any partial migration effects before proceeding.
|
||||||
|
3. Only after confirming that history repair is appropriate, set `mateclaw.flyway.auto-repair=true` (environment variable `MATECLAW_FLYWAY_AUTOREPAIR=true`) for one controlled startup. Repair uses the configured migration locations and is followed by normal migration. Keep validation enabled.
|
||||||
|
4. Remove the opt-in immediately afterwards, restart normally, and verify validation and application behavior before allowing writes. If recovery fails, restore the backup with its matching application version.
|
||||||
|
|
||||||
|
The opt-in defaults to `false`, including Desktop. It is not an unattended upgrade policy. `spring.flyway.validate-on-migrate` remains externally configurable, but setting it to `false` bypasses the validation guard and is not a repair procedure.
|
||||||
|
|
||||||
### Table conventions
|
### Table conventions
|
||||||
|
|
||||||
|
|||||||
@ -266,7 +266,7 @@ MateClaw 用 **MyBatis Plus**(不是 JPA)做数据库访问。约定:
|
|||||||
- `snake_case` 列、`camelCase` Java 字段、自动映射
|
- `snake_case` 列、`camelCase` Java 字段、自动映射
|
||||||
- 每张表有 `create_time`、`update_time`、`deleted`(逻辑删除)
|
- 每张表有 `create_time`、`update_time`、`deleted`(逻辑删除)
|
||||||
- **Flyway** 管理 schema 迁移——`db/migration/h2/` 和 `db/migration/mysql/` 各有一套方言脚本,启动时自动选择
|
- **Flyway** 管理 schema 迁移——`db/migration/h2/` 和 `db/migration/mysql/` 各有一套方言脚本,启动时自动选择
|
||||||
- `FlywayRepairConfig` 在每次启动时先 `repair()` 再 `migrate()`,checksum 变更和部分失败的迁移自动修复
|
- `FlywayRepairConfig` 默认执行开启校验的 `migrate()`;修复历史记录必须显式开启,操作前参阅[数据库恢复说明](./config#数据库-schema-初始化)
|
||||||
- 种子数据由 `DatabaseBootstrapRunner` 从 `db/data-*.sql` 加载,幂等执行
|
- 种子数据由 `DatabaseBootstrapRunner` 从 `db/data-*.sql` 加载,幂等执行
|
||||||
|
|
||||||
### 表分组
|
### 表分组
|
||||||
|
|||||||
@ -267,7 +267,18 @@ MateClaw 用 **Flyway** 管理 schema 迁移:
|
|||||||
2. `db/migration/mysql/V*__*.sql`——MySQL 方言的迁移脚本
|
2. `db/migration/mysql/V*__*.sql`——MySQL 方言的迁移脚本
|
||||||
3. 迁移完成后加载种子数据(`db/data-*.sql`),幂等执行
|
3. 迁移完成后加载种子数据(`db/data-*.sql`),幂等执行
|
||||||
|
|
||||||
启动时 Flyway 根据 active profile 自动选择正确的方言路径。每次启动时先做一次 `repair`,再 `migrate`——checksum 变更和部分失败的迁移自动修复(对桌面端离线升级用户尤其重要)。
|
Flyway 根据 active profile 选择迁移方言。启动时执行 `migrate`,所有内置 profile 默认开启 checksum 校验。已执行的迁移脚本不应修改;校验不匹配或迁移失败会阻止启动,不会自动改写历史记录。
|
||||||
|
|
||||||
|
**桌面版升级兼容性:** 如果旧数据库记录的脚本与新版本不同,升级后可能无法启动。新数据库和迁移历史一致的数据库无需 repair。保留可用旧版应用打开的数据库备份,不要通过删除数据库来消除校验错误。
|
||||||
|
|
||||||
|
校验失败时:
|
||||||
|
|
||||||
|
1. 停止应用并备份数据库。保留准确的旧版、新版安装包,检查出错的迁移及 schema history。
|
||||||
|
2. 对比新旧 SQL 与实际表结构、数据,优先恢复正确的迁移文件或使用维护者提供的向前迁移。`repair` 只修正迁移元数据,**不会**执行被修改的 DDL、撤销部分执行的 DDL/数据变更,也不能证明表结构正确。应先处理部分迁移造成的影响。
|
||||||
|
3. 只有确认适合修复历史记录后,才为一次受控启动设置 `mateclaw.flyway.auto-repair=true`(环境变量 `MATECLAW_FLYWAY_AUTOREPAIR=true`)。repair 使用当前配置的迁移路径,随后正常迁移;保持校验开启。
|
||||||
|
4. 立即移除该开关,正常重启,确认校验及业务功能通过后再允许写入。恢复失败时,使用匹配的旧版应用和数据库备份一起回退。
|
||||||
|
|
||||||
|
该开关默认 `false`,桌面版也一样,不应作为无人值守升级策略。仍可通过外部配置覆盖 `spring.flyway.validate-on-migrate`,但设为 `false` 会绕过校验保护,不能代替修复。
|
||||||
|
|
||||||
### 表约定
|
### 表约定
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,174 @@
|
|||||||
|
package vip.mate.config;
|
||||||
|
|
||||||
|
import org.flywaydb.core.Flyway;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
|
import org.junit.jupiter.params.provider.ValueSource;
|
||||||
|
import org.springframework.boot.test.context.ConfigDataApplicationContextInitializer;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.core.env.Environment;
|
||||||
|
import org.springframework.core.env.SystemEnvironmentPropertySource;
|
||||||
|
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.sql.DriverManager;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class FlywayRepairConfigTest {
|
||||||
|
@TempDir Path migrations;
|
||||||
|
|
||||||
|
private String databaseUrl() {
|
||||||
|
return "jdbc:h2:mem:flyway-guard-" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1";
|
||||||
|
}
|
||||||
|
|
||||||
|
private Flyway flyway(String url) {
|
||||||
|
return Flyway.configure().dataSource(url, "sa", "")
|
||||||
|
.locations("filesystem:" + migrations.toAbsolutePath())
|
||||||
|
.validateOnMigrate(true).load();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ApplicationContextRunner context(String url) {
|
||||||
|
return new ApplicationContextRunner().withUserConfiguration(FlywayRepairConfig.class)
|
||||||
|
.withBean(Flyway.class, () -> flyway(url));
|
||||||
|
}
|
||||||
|
|
||||||
|
private int historyChecksum(String url) throws Exception {
|
||||||
|
try (var connection = DriverManager.getConnection(url, "sa", "");
|
||||||
|
var statement = connection.createStatement();
|
||||||
|
var rows = statement.executeQuery("SELECT \"checksum\" FROM \"flyway_schema_history\" WHERE \"version\" = '1'")) {
|
||||||
|
assertThat(rows.next()).isTrue();
|
||||||
|
return rows.getInt(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void defaultInitializerMigratesFreshDatabaseAndRepeatedStartupIsIdempotent() throws Exception {
|
||||||
|
Files.writeString(migrations.resolve("V1__fixture.sql"), "CREATE TABLE upgrade_fixture(id INT PRIMARY KEY);\n");
|
||||||
|
String url = databaseUrl();
|
||||||
|
context(url).run(started -> assertThat(started).hasNotFailed());
|
||||||
|
int appliedChecksum = historyChecksum(url);
|
||||||
|
context(url).run(started -> assertThat(started).hasNotFailed());
|
||||||
|
assertThat(historyChecksum(url)).isEqualTo(appliedChecksum);
|
||||||
|
assertThat(flyway(url).info().applied()).hasSize(1);
|
||||||
|
assertThat(flyway(url).migrate().migrationsExecuted).isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void defaultInitializerRejectsChangedAppliedSqlWithoutRepairingHistory() throws Exception {
|
||||||
|
Path script = migrations.resolve("V1__fixture.sql");
|
||||||
|
Files.writeString(script, "CREATE TABLE upgrade_fixture(id INT PRIMARY KEY);\n");
|
||||||
|
String url = databaseUrl();
|
||||||
|
context(url).run(started -> assertThat(started).hasNotFailed());
|
||||||
|
int appliedChecksum = historyChecksum(url);
|
||||||
|
Files.writeString(script, "CREATE TABLE upgrade_fixture(id INT PRIMARY KEY, changed_column INT);\n");
|
||||||
|
Files.writeString(migrations.resolve("V2__must_not_run.sql"), "CREATE TABLE should_not_exist(id INT);\n");
|
||||||
|
|
||||||
|
context(url).run(started -> {
|
||||||
|
assertThat(started).hasFailed();
|
||||||
|
assertThat(started.getStartupFailure()).hasStackTraceContaining("Migration checksum mismatch for migration version 1");
|
||||||
|
});
|
||||||
|
assertThat(historyChecksum(url)).isEqualTo(appliedChecksum);
|
||||||
|
try (var connection = DriverManager.getConnection(url, "sa", "");
|
||||||
|
var statement = connection.createStatement();
|
||||||
|
var rows = statement.executeQuery("SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'SHOULD_NOT_EXIST'")) {
|
||||||
|
assertThat(rows.next()).isTrue();
|
||||||
|
assertThat(rows.getInt(1)).isZero();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void explicitRepairOptInIsRequiredToChangeHistory() throws Exception {
|
||||||
|
Path script = migrations.resolve("V1__fixture.sql");
|
||||||
|
Files.writeString(script, "CREATE TABLE upgrade_fixture(id INT PRIMARY KEY);\n");
|
||||||
|
String url = databaseUrl();
|
||||||
|
context(url).run(started -> assertThat(started).hasNotFailed());
|
||||||
|
int original = historyChecksum(url);
|
||||||
|
Files.writeString(script, "-- audited comment change\nCREATE TABLE upgrade_fixture(id INT PRIMARY KEY);\n");
|
||||||
|
context(url).withPropertyValues("mateclaw.flyway.auto-repair=true")
|
||||||
|
.run(started -> assertThat(started).hasNotFailed());
|
||||||
|
assertThat(historyChecksum(url)).isNotEqualTo(original);
|
||||||
|
assertThat(flyway(url).validateWithResult().validationSuccessful).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ApplicationContextRunner profileContext(String url, String profile) {
|
||||||
|
return new ApplicationContextRunner()
|
||||||
|
.withInitializer(new ConfigDataApplicationContextInitializer())
|
||||||
|
.withUserConfiguration(FlywayRepairConfig.class, TestDatabaseConfiguration.class)
|
||||||
|
.withPropertyValues("spring.profiles.active=" + profile,
|
||||||
|
"test.database-url=" + url,
|
||||||
|
"test.migration-location=filesystem:" + migrations.toAbsolutePath());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Configuration(proxyBeanMethods = false)
|
||||||
|
static class TestDatabaseConfiguration {
|
||||||
|
@Bean
|
||||||
|
Flyway flyway(Environment environment) {
|
||||||
|
// Use real application/profile configuration for validation, but an
|
||||||
|
// isolated H2 fixture: no profile's external datasource is opened.
|
||||||
|
return Flyway.configure()
|
||||||
|
.dataSource(environment.getRequiredProperty("test.database-url"), "sa", "")
|
||||||
|
.locations(environment.getRequiredProperty("test.migration-location"))
|
||||||
|
.validateOnMigrate(environment.getRequiredProperty(
|
||||||
|
"spring.flyway.validate-on-migrate", Boolean.class))
|
||||||
|
.load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = {"default", "mysql", "postgres", "kingbase"})
|
||||||
|
void allShippedProfilesRejectChecksumDrift(String profile) throws Exception {
|
||||||
|
Path script = migrations.resolve("V1__fixture.sql");
|
||||||
|
Files.writeString(script, "CREATE TABLE profile_fixture(id INT PRIMARY KEY);\n");
|
||||||
|
String url = databaseUrl();
|
||||||
|
profileContext(url, profile).run(started -> {
|
||||||
|
assertThat(started).hasNotFailed();
|
||||||
|
assertThat(started.getBean(Flyway.class).getConfiguration().isValidateOnMigrate()).isTrue();
|
||||||
|
});
|
||||||
|
int original = historyChecksum(url);
|
||||||
|
Files.writeString(script, "CREATE TABLE profile_fixture(id INT PRIMARY KEY, changed_column INT);\n");
|
||||||
|
profileContext(url, profile).run(started -> {
|
||||||
|
assertThat(started).hasFailed();
|
||||||
|
assertThat(started.getStartupFailure()).hasStackTraceContaining(
|
||||||
|
"Migration checksum mismatch for migration version 1");
|
||||||
|
});
|
||||||
|
assertThat(historyChecksum(url)).isEqualTo(original);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = {"default", "mysql", "postgres", "kingbase"})
|
||||||
|
void explicitValidationOverrideRemainsAvailable(String profile) throws Exception {
|
||||||
|
Files.writeString(migrations.resolve("V1__fixture.sql"),
|
||||||
|
"CREATE TABLE profile_fixture(id INT PRIMARY KEY);\n");
|
||||||
|
profileContext(databaseUrl(), profile)
|
||||||
|
.withPropertyValues("spring.flyway.validate-on-migrate=false")
|
||||||
|
.run(started -> {
|
||||||
|
assertThat(started).hasNotFailed();
|
||||||
|
assertThat(started.getBean(Flyway.class).getConfiguration().isValidateOnMigrate()).isFalse();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void documentedEnvironmentOptInRepairsReviewedHistoryChange() throws Exception {
|
||||||
|
Path script = migrations.resolve("V1__fixture.sql");
|
||||||
|
Files.writeString(script, "CREATE TABLE profile_fixture(id INT PRIMARY KEY);\n");
|
||||||
|
String url = databaseUrl();
|
||||||
|
profileContext(url, "default").run(started -> assertThat(started).hasNotFailed());
|
||||||
|
int original = historyChecksum(url);
|
||||||
|
Files.writeString(script, "-- reviewed comment change\nCREATE TABLE profile_fixture(id INT PRIMARY KEY);\n");
|
||||||
|
profileContext(url, "default")
|
||||||
|
.withInitializer(context -> context.getEnvironment().getPropertySources().addFirst(
|
||||||
|
new SystemEnvironmentPropertySource("systemEnvironment",
|
||||||
|
Map.of("MATECLAW_FLYWAY_AUTOREPAIR", "true"))))
|
||||||
|
.run(started -> assertThat(started).hasNotFailed());
|
||||||
|
assertThat(historyChecksum(url)).isNotEqualTo(original);
|
||||||
|
assertThat(flyway(url).validateWithResult().validationSuccessful).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user