Procházet zdrojové kódy

feat(backend): 为数据库迁移增加备份和事务回滚

wangkangyjy před 2 týdny
rodič
revize
0ea42bccaf

+ 30 - 10
backend/src/main/java/com/yimeng/okr/config/DatabaseMigrationRunner.java

@@ -1,11 +1,13 @@
 package com.yimeng.okr.config;
 
+import com.yimeng.okr.service.DatabaseBackupService;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.boot.CommandLineRunner;
 import org.springframework.core.annotation.Order;
 import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.stereotype.Component;
+import org.springframework.transaction.support.TransactionTemplate;
 
 import java.util.List;
 import java.util.function.Consumer;
@@ -17,6 +19,8 @@ import java.util.function.Consumer;
 public class DatabaseMigrationRunner implements CommandLineRunner {
 
     private final JdbcTemplate jdbcTemplate;
+    private final TransactionTemplate transactionTemplate;
+    private final DatabaseBackupService databaseBackupService;
 
     @Override
     public void run(String... args) {
@@ -28,18 +32,34 @@ public class DatabaseMigrationRunner implements CommandLineRunner {
                 )
                 """);
 
+        List<DatabaseMigration> pending = migrations().stream()
+                .filter(m -> !isApplied(m.version()))
+                .toList();
+
+        if (pending.isEmpty()) {
+            return;
+        }
+
+        int firstVersion = pending.get(0).version();
+        log.info("Preparing backup before migration v{}", firstVersion);
+        databaseBackupService.createBackup("pre-migration-v" + firstVersion);
+
         MigrationContext context = new MigrationContext(jdbcTemplate);
-        for (DatabaseMigration migration : migrations()) {
-            if (isApplied(migration.version())) {
-                continue;
-            }
+        for (DatabaseMigration migration : pending) {
             log.info("Applying database migration {} - {}", migration.version(), migration.description());
-            migration.apply(context);
-            jdbcTemplate.update("""
-                    INSERT INTO schema_migration(version, description, applied_at)
-                    VALUES (?, ?, datetime('now'))
-                    """, migration.version(), migration.description());
-            log.info("Database migration {} applied", migration.version());
+            try {
+                transactionTemplate.executeWithoutResult(status -> {
+                    migration.apply(context);
+                    jdbcTemplate.update("""
+                            INSERT INTO schema_migration(version, description, applied_at)
+                            VALUES (?, ?, datetime('now'))
+                            """, migration.version(), migration.description());
+                });
+                log.info("Database migration {} applied", migration.version());
+            } catch (Exception e) {
+                log.error("Database migration {} failed and was rolled back: {}", migration.version(), e.getMessage());
+                throw new RuntimeException("Database migration v" + migration.version() + " failed: " + e.getMessage(), e);
+            }
         }
     }
 

+ 15 - 0
backend/src/main/java/com/yimeng/okr/config/MybatisPlusConfig.java

@@ -5,6 +5,11 @@ import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
 import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
+import org.springframework.jdbc.datasource.DataSourceTransactionManager;
+import org.springframework.transaction.PlatformTransactionManager;
+import org.springframework.transaction.support.TransactionTemplate;
+
+import javax.sql.DataSource;
 
 @Configuration
 public class MybatisPlusConfig {
@@ -15,4 +20,14 @@ public class MybatisPlusConfig {
         interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.SQLITE));
         return interceptor;
     }
+
+    @Bean
+    public PlatformTransactionManager transactionManager(DataSource dataSource) {
+        return new DataSourceTransactionManager(dataSource);
+    }
+
+    @Bean
+    public TransactionTemplate transactionTemplate(PlatformTransactionManager transactionManager) {
+        return new TransactionTemplate(transactionManager);
+    }
 }

+ 6 - 1
backend/src/main/java/com/yimeng/okr/service/DatabaseBackupService.java

@@ -24,6 +24,10 @@ public class DatabaseBackupService {
     }
 
     public Path createBackup() {
+        return createBackup("okr-performance");
+    }
+
+    public Path createBackup(String label) {
         Path dbPath = resolveSqlitePath(datasourceUrl);
         if (!Files.exists(dbPath)) {
             throw new BusinessException("数据库文件不存在,无法备份: " + dbPath);
@@ -31,7 +35,8 @@ public class DatabaseBackupService {
         try {
             Files.createDirectories(Path.of(backupDir));
             String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"));
-            Path target = Path.of(backupDir).resolve("okr-performance-" + timestamp + ".db");
+            String safeLabel = label.replaceAll("[^a-zA-Z0-9\\-_.]", "-");
+            Path target = Path.of(backupDir).resolve(safeLabel + "-" + timestamp + ".db");
             Files.copy(dbPath, target);
             return target;
         } catch (IOException e) {

+ 105 - 21
backend/src/test/java/com/yimeng/okr/config/DatabaseMigrationRunnerTest.java

@@ -1,17 +1,119 @@
 package com.yimeng.okr.config;
 
+import com.yimeng.okr.service.DatabaseBackupService;
+import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
 import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.datasource.DataSourceTransactionManager;
 import org.springframework.jdbc.datasource.SingleConnectionDataSource;
+import org.springframework.transaction.support.TransactionTemplate;
 
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertTrue;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static org.junit.jupiter.api.Assertions.*;
 
 class DatabaseMigrationRunnerTest {
 
+    private JdbcTemplate jdbcTemplate;
+    private TransactionTemplate transactionTemplate;
+    private DatabaseBackupService backupService;
+    private Path tempDir;
+
+    @BeforeEach
+    void setUp(@TempDir Path tempDir) throws Exception {
+        this.tempDir = tempDir;
+        // Create temp db file for backup service to find
+        Path dbFile = tempDir.resolve("test.db");
+        Files.createFile(dbFile);
+
+        SingleConnectionDataSource dataSource = new SingleConnectionDataSource();
+        dataSource.setDriverClassName("org.sqlite.JDBC");
+        dataSource.setUrl("jdbc:sqlite::memory:");
+        dataSource.setSuppressClose(true);
+        jdbcTemplate = new JdbcTemplate(dataSource);
+        DataSourceTransactionManager txManager = new DataSourceTransactionManager(dataSource);
+        transactionTemplate = new TransactionTemplate(txManager);
+        backupService = new DatabaseBackupService(
+                "jdbc:sqlite:" + dbFile.toString(),
+                tempDir.resolve("backups").toString());
+    }
+
     @Test
     void runAppliesVersionedMigrationsOnlyOnce() {
-        JdbcTemplate jdbcTemplate = jdbcTemplate();
+        createBaseTables();
+
+        DatabaseMigrationRunner runner = new DatabaseMigrationRunner(jdbcTemplate, transactionTemplate, backupService);
+        runner.run();
+        runner.run();
+
+        assertColumnExists(jdbcTemplate, "sys_user", "status");
+        assertColumnExists(jdbcTemplate, "performance_score", "published_at");
+        assertTableExists(jdbcTemplate, "period_participant");
+        assertEquals(1, jdbcTemplate.queryForObject(
+                "SELECT COUNT(*) FROM schema_migration WHERE version = 1", Integer.class));
+    }
+
+    @Test
+    void shouldRollbackMigrationAndNotRecordVersionWhenMigrationFails() {
+        createBaseTables();
+
+        // Create a custom runner with a failing migration
+        DatabaseMigrationRunner runner = new DatabaseMigrationRunner(jdbcTemplate, transactionTemplate, backupService) {
+            @Override
+            public void run(String... args) {
+                jdbcTemplate.execute("""
+                        CREATE TABLE IF NOT EXISTS schema_migration (
+                            version INTEGER PRIMARY KEY,
+                            description TEXT NOT NULL,
+                            applied_at TEXT NOT NULL
+                        )
+                        """);
+
+                // Apply migration 1 first (succeeds)
+                MigrationContext ctx = new MigrationContext(jdbcTemplate);
+                transactionTemplate.executeWithoutResult(status -> {
+                    jdbcTemplate.execute("ALTER TABLE sys_user ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE'");
+                    jdbcTemplate.update(
+                            "INSERT INTO schema_migration(version, description, applied_at) VALUES (?, ?, datetime('now'))",
+                            1, "test migration 1");
+                });
+
+                // Now try a failing migration 2
+                try {
+                    transactionTemplate.executeWithoutResult(status -> {
+                        jdbcTemplate.execute("ALTER TABLE sys_user ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE'");
+                        // Force failure
+                        jdbcTemplate.execute("THIS IS INVALID SQL");
+                        jdbcTemplate.update(
+                                "INSERT INTO schema_migration(version, description, applied_at) VALUES (?, ?, datetime('now'))",
+                                2, "should not be recorded");
+                    });
+                    fail("Expected exception was not thrown");
+                } catch (Exception e) {
+                    // Expected — migration failed
+                }
+            }
+        };
+
+        try {
+            runner.run();
+        } catch (Exception e) {
+            // May throw RuntimeException wrapping the migration failure
+        }
+
+        // Migration 1 should be recorded
+        assertEquals(1, jdbcTemplate.queryForObject(
+                "SELECT COUNT(*) FROM schema_migration WHERE version = 1", Integer.class));
+        // Migration 2 should NOT be recorded
+        assertEquals(0, jdbcTemplate.queryForObject(
+                "SELECT COUNT(*) FROM schema_migration WHERE version = 2", Integer.class));
+        // Column should exist (from migration 1, not rolled back)
+        assertColumnExists(jdbcTemplate, "sys_user", "status");
+    }
+
+    private void createBaseTables() {
         jdbcTemplate.execute("""
                 CREATE TABLE sys_user (
                     id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -55,24 +157,6 @@ class DatabaseMigrationRunnerTest {
                     updated_at TEXT NOT NULL
                 )
                 """);
-
-        DatabaseMigrationRunner runner = new DatabaseMigrationRunner(jdbcTemplate);
-        runner.run();
-        runner.run();
-
-        assertColumnExists(jdbcTemplate, "sys_user", "status");
-        assertColumnExists(jdbcTemplate, "performance_score", "published_at");
-        assertTableExists(jdbcTemplate, "period_participant");
-        assertEquals(1, jdbcTemplate.queryForObject(
-                "SELECT COUNT(*) FROM schema_migration WHERE version = 1", Integer.class));
-    }
-
-    private JdbcTemplate jdbcTemplate() {
-        SingleConnectionDataSource dataSource = new SingleConnectionDataSource();
-        dataSource.setDriverClassName("org.sqlite.JDBC");
-        dataSource.setUrl("jdbc:sqlite::memory:");
-        dataSource.setSuppressClose(true);
-        return new JdbcTemplate(dataSource);
     }
 
     private void assertColumnExists(JdbcTemplate jdbcTemplate, String tableName, String columnName) {