diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/config/MySql57MigrationConfiguration.java b/mydreamjob-server/src/main/java/com/mydreamjob/config/MySql57MigrationConfiguration.java new file mode 100644 index 0000000..b8c8cd0 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/config/MySql57MigrationConfiguration.java @@ -0,0 +1,90 @@ +package com.mydreamjob.config; + +import java.io.IOException; +import java.io.Reader; +import java.io.StringReader; +import java.nio.charset.Charset; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.SQLException; +import java.util.Arrays; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Map; +import org.flywaydb.core.api.ResourceProvider; +import org.flywaydb.core.api.configuration.FluentConfiguration; +import org.flywaydb.core.api.resource.LoadableResource; +import org.springframework.boot.autoconfigure.flyway.FlywayConfigurationCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; + +/** 仅替换 MySQL 5.7 的 V49 资源;既有 SQL、其他数据库及历史 checksum 不改写。 */ +@Configuration(proxyBeanMethods = false) +public class MySql57MigrationConfiguration { + private static final String V49 = "V49__skill_cloud_boolean_type.sql"; + + @Bean + FlywayConfigurationCustomizer mysql57MigrationResources() { + return MySql57MigrationConfiguration::configure; + } + + public static void configure(FluentConfiguration configuration) { + try (Connection connection = configuration.getDataSource().getConnection()) { + DatabaseMetaData metadata = connection.getMetaData(); + if (!"MySQL".equals(metadata.getDatabaseProductName()) + || metadata.getDatabaseMajorVersion() != 5 + || metadata.getDatabaseMinorVersion() != 7) { + return; + } + // 不静默忽略调用者的自定义迁移路径或资源提供器。 + if (configuration.getResourceProvider() != null + || configuration.getLocations().length != 1 + || !"classpath:db/migration".equals(configuration.getLocations()[0].getDescriptor())) { + throw new IllegalStateException("MySQL 5.7 migration configuration requires the standard migration location"); + } + configuration.resourceProvider(resources(configuration.getEncoding())); + } catch (SQLException | IOException ex) { + // 不把连接 URL、账号、驱动异常原文带进启动日志。 + throw new IllegalStateException("Database migration compatibility initialization failed"); + } + } + + private static ResourceProvider resources(Charset encoding) throws IOException { + Map resources = new LinkedHashMap<>(); + Resource[] originals = new PathMatchingResourcePatternResolver() + .getResources("classpath*:db/migration/*"); + for (Resource original : originals) { + String filename = original.getFilename(); + if (filename == null || !original.isReadable()) continue; + Resource selected = V49.equals(filename) + ? new ClassPathResource("db/mysql57/" + V49) : original; + String text = selected.getContentAsString(encoding); + String path = selected.getURL().toExternalForm(); + LoadableResource resource = new LoadableResource() { + @Override public Reader read() { return new StringReader(text); } + @Override public String getAbsolutePath() { return path; } + @Override public String getAbsolutePathOnDisk() { return path; } + @Override public String getFilename() { return filename; } + @Override public String getRelativePath() { return "db/migration/" + filename; } + }; + if (resources.putIfAbsent(filename, resource) != null) { + throw new IllegalStateException("Duplicate database migration resource"); + } + } + if (!resources.containsKey(V49)) throw new IllegalStateException("Required database migration resource missing"); + return new ResourceProvider() { + @Override public LoadableResource getResource(String name) { + return resources.values().stream().filter(resource -> name.equals(resource.getFilename()) + || name.equals(resource.getRelativePath()) || name.equals(resource.getAbsolutePath())) + .findFirst().orElse(null); + } + @Override public Collection getResources(String prefix, String[] suffixes) { + return resources.values().stream().filter(resource -> resource.getFilename().startsWith(prefix) + && Arrays.stream(suffixes).anyMatch(resource.getFilename()::endsWith)).toList(); + } + }; + } +} diff --git a/mydreamjob-server/src/main/resources/db/mysql57/V49__skill_cloud_boolean_type.sql b/mydreamjob-server/src/main/resources/db/mysql57/V49__skill_cloud_boolean_type.sql new file mode 100644 index 0000000..ca6aae7 --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/mysql57/V49__skill_cloud_boolean_type.sql @@ -0,0 +1,6 @@ +-- MySQL 5.7 专用 V49:CHECK 在该版本被忽略,不存在可删除的 CHECK 约束。 +-- BOOLEAN 是 TINYINT(1) 的别名;显式规范化类型,不执行不支持的 DROP CONSTRAINT。 +-- 原 db/migration/V49 保持不变,H2 / MySQL 8.4 继续执行原版本。 +-- 本变体具有独立 checksum,未来数据库大版本升级必须单独审核迁移历史。 +ALTER TABLE occupation_skills + MODIFY COLUMN importance_is_proxy BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/config/MySql57MigrationConfigurationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/config/MySql57MigrationConfigurationTest.java new file mode 100644 index 0000000..3e3bac8 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/config/MySql57MigrationConfigurationTest.java @@ -0,0 +1,73 @@ +package com.mydreamjob.config; + +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import javax.sql.DataSource; +import org.flywaydb.core.Flyway; +import org.flywaydb.core.api.configuration.FluentConfiguration; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +class MySql57MigrationConfigurationTest { + @Test void h2AndMysql8KeepDefaultResources() throws Exception { + for (String product : new String[]{"H2", "MySQL"}) { + FluentConfiguration config = configuration(product, 8, 4); + MySql57MigrationConfiguration.configure(config); + assertThat(config.getResourceProvider()).isNull(); + } + } + + @Test void mysql57ReplacesOnlyV49AndRetainsOriginalResourceNamesAndContents() throws Exception { + FluentConfiguration config = configuration("MySQL", 5, 7); + MySql57MigrationConfiguration.configure(config); + var provider = config.getResourceProvider(); + assertThat(provider).isNotNull(); + var resources = provider.getResources("V", new String[]{".sql"}); + assertThat(resources).hasSize(53); + for (var resource : resources) { + String path = "V49__skill_cloud_boolean_type.sql".equals(resource.getFilename()) + ? "db/mysql57/" : "db/migration/"; + String actual; + try (var reader = new java.io.BufferedReader(resource.read())) { + actual = reader.lines().collect(java.util.stream.Collectors.joining("\n")); + } + String expected = new ClassPathResource(path + resource.getFilename()) + .getContentAsString(StandardCharsets.UTF_8).replace("\r\n", "\n").stripTrailing(); + assertThat(actual.stripTrailing()).as(resource.getFilename()).isEqualTo(expected); + assertThat(provider.getResource(resource.getFilename())).isSameAs(resource); + } + } + + @Test void customLocationsAndProvidersFailClosedRatherThanDisappear() throws Exception { + FluentConfiguration config = configuration("MySQL", 5, 7).locations("classpath:other"); + assertThatThrownBy(() -> MySql57MigrationConfiguration.configure(config)) + .isInstanceOf(IllegalStateException.class).hasMessageContaining("standard migration location"); + FluentConfiguration custom = configuration("MySQL", 5, 7) + .resourceProvider(mock(org.flywaydb.core.api.ResourceProvider.class)); + assertThatThrownBy(() -> MySql57MigrationConfiguration.configure(custom)) + .isInstanceOf(IllegalStateException.class); + } + + @Test void connectionFailureHasFixedSafeMessage() throws Exception { + DataSource dataSource = mock(DataSource.class); + when(dataSource.getConnection()).thenThrow(new java.sql.SQLException("private-connection-value")); + assertThatThrownBy(() -> MySql57MigrationConfiguration.configure(Flyway.configure().dataSource(dataSource))) + .hasMessage("Database migration compatibility initialization failed").hasNoCause(); + } + + private FluentConfiguration configuration(String product, int major, int minor) throws Exception { + DataSource dataSource = mock(DataSource.class); + Connection connection = mock(Connection.class); + DatabaseMetaData metadata = mock(DatabaseMetaData.class); + when(dataSource.getConnection()).thenReturn(connection); + when(connection.getMetaData()).thenReturn(metadata); + when(metadata.getDatabaseProductName()).thenReturn(product); + when(metadata.getDatabaseMajorVersion()).thenReturn(major); + when(metadata.getDatabaseMinorVersion()).thenReturn(minor); + return Flyway.configure().dataSource(dataSource); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqIndependentMySqlFunctionalVerificationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqIndependentMySqlFunctionalVerificationTest.java index f37fb4d..e4c5a98 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqIndependentMySqlFunctionalVerificationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqIndependentMySqlFunctionalVerificationTest.java @@ -54,9 +54,17 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. @org.springframework.test.context.TestPropertySource(properties = "app.salary-publication.approved-policies.110000=2026-09-20-v1") class LxqIndependentMySqlFunctionalVerificationTest { - @Container static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + // 单独用 -Dmodule.mysql.image=mysql:5.7.36 重跑同一组功能/并发/回滚断言。 + @Container static final MySQLContainer MYSQL = new MySQLContainer<>(mysqlImage()) + .withCommand("--character-set-server=utf8mb4", "--collation-server=utf8mb4_unicode_ci") .withDatabaseName("lxq_independent").withUsername("independent_test") .withPassword("independent-test-only"); + private static String mysqlImage() { + String image = System.getProperty("module.mysql.image", "mysql:8.4"); + if (!Set.of("mysql:8.4", "mysql:5.7.36").contains(image)) + throw new IllegalArgumentException("Unsupported module test database version"); + return image; + } @DynamicPropertySource static void properties(DynamicPropertyRegistry r) { r.add("spring.datasource.url", MYSQL::getJdbcUrl); r.add("spring.datasource.username", MYSQL::getUsername); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySql57IntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySql57IntegrationTest.java new file mode 100644 index 0000000..cb6bec3 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySql57IntegrationTest.java @@ -0,0 +1,74 @@ +package com.mydreamjob.integration; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** 服务器真实版本的全迁移、Hibernate validate 和接口集合门禁,不能静默跳过。 */ +@Testcontainers(disabledWithoutDocker = false) +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("mysql-it") +class LxqModulesMySql57IntegrationTest { + @Container static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:5.7.36") + .withCommand("--character-set-server=utf8mb4", "--collation-server=utf8mb4_unicode_ci") + .withDatabaseName("module57_test") + .withUsername("module_test").withPassword("module-test-only"); + @DynamicPropertySource static void properties(DynamicPropertyRegistry r) { + r.add("spring.datasource.url", MYSQL::getJdbcUrl); + r.add("spring.datasource.username", MYSQL::getUsername); + r.add("spring.datasource.password", MYSQL::getPassword); + } + @MockBean RedisTemplate redis; + @Autowired JdbcTemplate jdbc; + @Autowired MockMvc mvc; + @Autowired ObjectMapper mapper; + + @Test void fullMigrationAndSeedsValidateOnActualServerVersion() { + assertThat(jdbc.queryForObject("select version()", String.class)).startsWith("5.7.36"); + assertThat(jdbc.queryForObject("select count(*) from flyway_schema_history where success=0", Long.class)).isZero(); + assertThat(jdbc.queryForObject("select count(*) from flyway_schema_history where version in ('47','48','49','50','51','52','53') and success=1", Long.class)).isEqualTo(7); + assertThat(jdbc.queryForObject("select count(*) from salary_city_policy", Long.class)).isEqualTo(359); + assertThat(jdbc.queryForObject("select count(*) from occupation_skills where importance_is_proxy not in (0,1)", Long.class)).isZero(); + assertThat(jdbc.queryForObject("select count(*) from onboarding_checklist_items", Long.class)).isEqualTo(12); + assertThat(jdbc.queryForObject("select count(*) from salary_calculation_history", Long.class)).isZero(); + } + + @Test void runtimeAndStaticOperationsRemainIdentical() throws Exception { + JsonNode runtime = mapper.readTree(mvc.perform(get("/v3/api-docs")) + .andExpect(status().isOk()).andReturn().getResponse().getContentAsByteArray()); + JsonNode contract = mapper.readTree(Files.readString(Path.of("../docs/openapi-rc.json"))); + assertThat(operations(runtime)).hasSize(239).isEqualTo(operations(contract)); + assertThat(runtime.path("paths").size()).isEqualTo(196); + } + + private Set operations(JsonNode document) { + Set result = new HashSet<>(); + document.path("paths").fields().forEachRemaining(path -> + path.getValue().fieldNames().forEachRemaining(method -> { + if (Set.of("get", "post", "put", "patch", "delete", "head", "options", "trace").contains(method)) + result.add(method + " " + path.getKey()); + })); + return result; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/integration/MySql57FailedMigrationRecoveryTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/integration/MySql57FailedMigrationRecoveryTest.java new file mode 100644 index 0000000..b9a5feb --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/integration/MySql57FailedMigrationRecoveryTest.java @@ -0,0 +1,77 @@ +package com.mydreamjob.integration; + +import com.mydreamjob.config.MySql57MigrationConfiguration; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.util.ArrayList; +import java.util.List; +import org.flywaydb.core.Flyway; +import org.flywaydb.core.api.configuration.FluentConfiguration; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** 仅在一次性容器中演练精确失败记录恢复;生产启动绝不自动 repair。 */ +@Testcontainers(disabledWithoutDocker = false) +class MySql57FailedMigrationRecoveryTest { + @Container static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:5.7.36") + .withCommand("--character-set-server=utf8mb4", "--collation-server=utf8mb4_unicode_ci") + .withDatabaseName("recovery_test").withUsername("recovery_test").withPassword("recovery-test-only"); + + @Test void exactFailedV49RecoveryPreservesSuccessfulHistoryAndEverySkillRow() throws Exception { + original().target("48").load().migrate(); + List history = snapshot("select installed_rank,version,description,type,script,checksum,installed_by,installed_on,execution_time,success from flyway_schema_history order by installed_rank"); + List skills = snapshot("select * from occupation_skills order by id"); + assertThatThrownBy(() -> original().load().migrate()).hasMessageContaining("V49__skill_cloud_boolean_type.sql"); + assertThat(snapshot("select version,success from flyway_schema_history where success=0")) + .containsExactly("49|0|"); + assertThat(snapshot("select * from occupation_skills order by id")).isEqualTo(skills); + + FluentConfiguration compatible = original(); + MySql57MigrationConfiguration.configure(compatible); + // 新应用不绕过验证,更不会顺手删除失败历史。 + assertThatThrownBy(() -> compatible.load().migrate()).hasMessageContaining("failed migration"); + assertThat(snapshot("select version,success from flyway_schema_history where success=0")) + .containsExactly("49|0|"); + try (Connection connection = DriverManager.getConnection(MYSQL.getJdbcUrl(), MYSQL.getUsername(), MYSQL.getPassword()); + var statement = connection.prepareStatement("delete from flyway_schema_history where version=? and success=0 and script=?")) { + statement.setString(1, "49"); + statement.setString(2, "V49__skill_cloud_boolean_type.sql"); + assertThat(statement.executeUpdate()).isEqualTo(1); + } + compatible.load().migrate(); + compatible.load().validate(); + assertThat(snapshot("select installed_rank,version,description,type,script,checksum,installed_by,installed_on,execution_time,success from flyway_schema_history where cast(version as unsigned)<=48 order by installed_rank")) + .isEqualTo(history); + assertThat(snapshot("select * from occupation_skills order by id")).isEqualTo(skills); + assertThat(snapshot("select count(*) from flyway_schema_history where success=0")).containsExactly("0|"); + assertThat(snapshot("select count(*) from salary_city_policy")).containsExactly("359|"); + assertThat(snapshot("select count(*) from flyway_schema_history where version='49' and success=1")).containsExactly("1|"); + List completed = snapshot("select * from flyway_schema_history order by installed_rank"); + assertThat(compatible.load().migrate().migrationsExecuted).isZero(); + assertThat(snapshot("select * from flyway_schema_history order by installed_rank")).isEqualTo(completed); + } + + private FluentConfiguration original() { + return Flyway.configure().dataSource(MYSQL.getJdbcUrl(), MYSQL.getUsername(), MYSQL.getPassword()); + } + + private List snapshot(String sql) throws Exception { + List rows = new ArrayList<>(); + try (Connection connection = DriverManager.getConnection(MYSQL.getJdbcUrl(), MYSQL.getUsername(), MYSQL.getPassword()); + var statement = connection.createStatement(); ResultSet result = statement.executeQuery(sql)) { + while (result.next()) { + StringBuilder row = new StringBuilder(); + for (int column = 1; column <= result.getMetaData().getColumnCount(); column++) { + row.append(result.getString(column)).append('|'); + } + rows.add(row.toString()); + } + } + return rows; + } +}