Browse Source

fix: support MySQL 5.7 module migrations

master
Zhang Shun 3 weeks ago
parent
commit
f966dfa291
  1. 90
      mydreamjob-server/src/main/java/com/mydreamjob/config/MySql57MigrationConfiguration.java
  2. 6
      mydreamjob-server/src/main/resources/db/mysql57/V49__skill_cloud_boolean_type.sql
  3. 73
      mydreamjob-server/src/test/java/com/mydreamjob/config/MySql57MigrationConfigurationTest.java
  4. 10
      mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqIndependentMySqlFunctionalVerificationTest.java
  5. 74
      mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySql57IntegrationTest.java
  6. 77
      mydreamjob-server/src/test/java/com/mydreamjob/integration/MySql57FailedMigrationRecoveryTest.java

90
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<String, LoadableResource> 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<LoadableResource> getResources(String prefix, String[] suffixes) {
return resources.values().stream().filter(resource -> resource.getFilename().startsWith(prefix)
&& Arrays.stream(suffixes).anyMatch(resource.getFilename()::endsWith)).toList();
}
};
}
}

6
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;

73
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);
}
}

10
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 = @org.springframework.test.context.TestPropertySource(properties =
"app.salary-publication.approved-policies.110000=2026-09-20-v1") "app.salary-publication.approved-policies.110000=2026-09-20-v1")
class LxqIndependentMySqlFunctionalVerificationTest { 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") .withDatabaseName("lxq_independent").withUsername("independent_test")
.withPassword("independent-test-only"); .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) { @DynamicPropertySource static void properties(DynamicPropertyRegistry r) {
r.add("spring.datasource.url", MYSQL::getJdbcUrl); r.add("spring.datasource.url", MYSQL::getJdbcUrl);
r.add("spring.datasource.username", MYSQL::getUsername); r.add("spring.datasource.username", MYSQL::getUsername);

74
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<String, Object> 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<String> operations(JsonNode document) {
Set<String> 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;
}
}

77
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<String> 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<String> 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<String> 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<String> snapshot(String sql) throws Exception {
List<String> 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;
}
}
Loading…
Cancel
Save