6 changed files with 329 additions and 1 deletions
@ -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(); |
|||
} |
|||
}; |
|||
} |
|||
} |
|||
@ -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; |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
} |
|||
@ -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…
Reference in new issue