fix: stabilize external-db deployment and updates
Release / release (push) Canceled after 0s

This commit is contained in:
Qiufeng
2026-08-18 22:51:35 +08:00
parent 227c4b0129
commit 8c6c10eb85
22 changed files with 767 additions and 279 deletions
@@ -57,7 +57,7 @@ public class SetupService {
public SetupService(SetupProperties properties) {
this.properties = properties;
if (properties.tokenSha256() == null || !properties.tokenSha256().matches("(?i)[0-9a-f]{64}")) {
throw new IllegalStateException("FINANCE_SETUP_TOKEN_SHA256 must be a 64-character SHA-256 value");
throw new IllegalStateException("FINANCE_SETUP_TOKEN_SHA256 必须是 64 位 SHA-256 值");
}
if (Files.exists(Path.of(properties.markerFile()))) {
locked.set(true);
@@ -79,15 +79,14 @@ public class SetupService {
// Connection testing is useful for diagnostics, but the current business SQL/migration
// baseline is MySQL-specific. Do not allow an apparently successful test to produce a
// database that the main application cannot start against.
ConnectionResult connection = testConnection(settings);
ConnectionResult connection = testConnection(settings, false);
return new SetupViews.Connection(connection.successful(), settings.type().name(), connection.version(),
false, "PostgreSQL 连接可用,但当前 Preview 的业务迁移仅支持 MySQL 8.4");
}
ConnectionResult connection = testConnection(settings);
prepareDatabaseForInstallation(settings);
validateSchemaForInstallation(settings);
return new SetupViews.Connection(true, settings.type().name(), connection.version(), true,
"MySQL 8.4 连接、排序规则和完整迁移权限验证通过");
ConnectionResult connection = testConnection(settings, false);
LOGGER.info("MySQL 只读连接测试通过,未执行数据库写操作,等待管理员确认完成安装");
return new SetupViews.Connection(true, settings.type().name(), connection.version(), false,
"MySQL 8.4 只读连接验证通过;尚未修改数据库,点击完成安装后才会执行迁移");
}
public SetupViews.Completed complete(SetupContracts.CompleteRequest request) {
@@ -105,7 +104,10 @@ public class SetupService {
}
synchronized (this) {
ensureOpen();
ConnectionResult connection = testConnection(settings);
// The explicit completion action is the point at which the operator
// authorizes schema changes and migration privilege checks.
LOGGER.info("管理员已确认完成安装,开始执行 MySQL 迁移和管理员初始化");
ConnectionResult connection = testConnection(settings, true);
if (!connection.successful()) {
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
"DATABASE_CONNECTION_FAILED", "数据库连接失败,请检查地址、端口、库名和账号");
@@ -145,7 +147,7 @@ public class SetupService {
if (exception instanceof SetupException setupException) {
throw setupException;
}
LOGGER.error("Database migration or administrator initialization failed", exception);
LOGGER.error("数据库迁移或管理员初始化失败", exception);
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
"DATABASE_INITIALIZATION_FAILED", databaseInitializationFailureMessage(exception));
}
@@ -167,14 +169,14 @@ public class SetupService {
"DATABASE_COLLATION_UNSUPPORTED", "数据库字符集或排序规则未能统一为 "
+ MYSQL_CHARACTER_SET + "/" + MYSQL_COLLATION);
}
LOGGER.info("Normalized database {} from {}/{} to {}/{}", settings.database(),
LOGGER.info("已将数据库 {} 的字符集/排序规则从 {}/{} 统一为 {}/{}", settings.database(),
current.characterSet(), current.collation(), updated.characterSet(), updated.collation());
}
recoverV068CollationFailure(jdbc, settings.database());
} catch (SetupException exception) {
throw exception;
} catch (RuntimeException exception) {
LOGGER.error("Database collation preparation failed", exception);
LOGGER.error("数据库字符集和排序规则准备失败", exception);
SQLException sqlException = findSqlException(exception);
String detail = sqlException == null
? "数据库字符集和排序规则初始化失败:" + safeErrorMessage(exception)
@@ -242,7 +244,7 @@ public class SetupService {
throw new SetupException(org.springframework.http.HttpStatus.CONFLICT,
"DATABASE_MIGRATION_RECOVERY_CONFLICT", "V068 迁移恢复状态已变化,请重新测试数据库连接");
}
LOGGER.warn("Removed the recoverable failed {} history row after database collation normalization",
LOGGER.warn("数据库字符集统一后已移除可恢复的失败迁移记录 {}",
RECOVERABLE_V068_SCRIPT);
}
@@ -282,16 +284,16 @@ public class SetupService {
} catch (SetupException exception) {
throw exception;
} catch (FlywayException exception) {
LOGGER.error("Database migration history validation failed", exception);
LOGGER.error("数据库迁移历史校验失败", exception);
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
"DATABASE_MIGRATION_STATE_INVALID",
"数据库迁移历史校验失败,请使用空数据库,或先修复已有迁移状态:" + safeErrorMessage(exception));
} catch (RuntimeException exception) {
LOGGER.error("Database schema inspection failed", exception);
LOGGER.error("数据库结构检查失败", exception);
SQLException sqlException = findSqlException(exception);
String detail = sqlException == null
? "数据库结构检查失败:" + safeErrorMessage(exception)
: databaseConnectionFailureMessage(sqlException);
: databaseConnectionFailureMessage(sqlException, true);
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
"DATABASE_SCHEMA_INSPECTION_FAILED", detail);
}
@@ -410,37 +412,46 @@ public class SetupService {
}
}
private ConnectionResult testConnection(DatabaseSettings settings) {
private ConnectionResult testConnection(DatabaseSettings settings, boolean verifyPrivileges) {
try (Connection connection = DriverManager.getConnection(settings.jdbcUrl(), settings.username(),
settings.password()); Statement statement = connection.createStatement()) {
statement.setQueryTimeout(10);
String version;
try (ResultSet result = statement.executeQuery("SELECT VERSION()")) {
result.next();
version = result.getString(1);
settings.password())) {
// A connection test must not create or mutate database objects. JDBC metadata
// is supplied by the handshake and avoids issuing SELECT/DDL/DML in the test step.
String version = connection.getMetaData().getDatabaseProductVersion();
if (version == null || version.isBlank()) {
version = "unknown";
}
if (settings.type() == DatabaseType.MYSQL && !version.startsWith("8.4.")) {
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
"MYSQL_VERSION_UNSUPPORTED", "当前版本要求 MySQL 8.4.x,检测到 " + version);
}
verifyMigrationPrivileges(connection, statement, settings.type());
if (verifyPrivileges) {
try (Statement statement = connection.createStatement()) {
statement.setQueryTimeout(10);
verifyMigrationPrivileges(connection, statement, settings.type());
}
}
return new ConnectionResult(true, version);
} catch (SQLException exception) {
LOGGER.warn("Database connection or migration privilege verification failed: SQL state={}, errorCode={}",
LOGGER.warn(verifyPrivileges
? "数据库连接或迁移权限验证失败:SQLState={},错误码={}"
: "数据库只读连接测试失败:SQLState={},错误码={}",
exception.getSQLState(), exception.getErrorCode(), exception);
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
"DATABASE_CONNECTION_FAILED", databaseConnectionFailureMessage(exception));
"DATABASE_CONNECTION_FAILED", databaseConnectionFailureMessage(exception, verifyPrivileges));
}
}
private String databaseConnectionFailureMessage(SQLException exception) {
private String databaseConnectionFailureMessage(SQLException exception, boolean verifyPrivileges) {
if (isPermissionError(exception)) {
return "数据库账号缺少完整迁移权限" + sqlErrorSummary(exception);
return (verifyPrivileges ? "数据库账号缺少完整迁移权限" : "数据库账号无权建立连接")
+ sqlErrorSummary(exception);
}
if (exception.getSQLState() != null && exception.getSQLState().startsWith("08")) {
return "数据库连接超时或中断" + sqlErrorSummary(exception);
}
return "数据库连接或完整迁移权限验证失败" + sqlErrorSummary(exception);
return (verifyPrivileges ? "数据库连接或完整迁移权限验证失败" : "数据库只读连接测试失败")
+ sqlErrorSummary(exception);
}
private String databaseInitializationFailureMessage(Throwable exception) {
@@ -510,14 +521,14 @@ public class SetupService {
try (Statement call = connection.createStatement();
ResultSet result = call.executeQuery("CALL " + routine + "()")) {
if (!result.next() || result.getInt(1) != 1) {
throw new SQLException("Migration privilege routine probe returned an invalid result");
throw new SQLException("迁移权限存储过程探针返回了无效结果");
}
}
statement.execute("INSERT INTO " + parentTable + " (id) VALUES (1)");
statement.execute("INSERT INTO " + childTable + " (id, parent_id, note) VALUES (1, 1, 'probe')");
try (ResultSet result = statement.executeQuery("SELECT note FROM " + childTable + " WHERE id = 1")) {
if (!result.next() || !"probe".equals(result.getString(1))) {
throw new SQLException("Migration privilege SELECT probe returned an invalid result");
throw new SQLException("迁移权限 SELECT 探针返回了无效结果");
}
}
statement.execute("CREATE TEMPORARY TABLE " + temporaryTable
@@ -81,10 +81,10 @@ public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class)
public ResponseEntity<ProblemDetail> handleUnexpected(Exception exception, HttpServletRequest request) {
if (isLockFailure(exception)) {
log.warn("Concurrent database modification requestId={}", RequestContext.requestId(), exception);
log.warn("并发数据库修改 requestId={}", RequestContext.requestId(), exception);
return concurrentModification(request);
}
log.error("Unhandled request failure requestId={}", RequestContext.requestId(), exception);
log.error("未处理的请求失败 requestId={}", RequestContext.requestId(), exception);
return response(HttpStatus.INTERNAL_SERVER_ERROR, ErrorCode.INTERNAL_ERROR.name(),
"系统处理失败,请使用请求编号联系管理员", Map.of(), request);
}
@@ -39,7 +39,7 @@ public class DeniedAccessAuditService {
try {
writer.record(method, uri, status.value(), errorCode);
} catch (RuntimeException exception) {
log.error("Denied access audit failed requestId={} method={} uri={} code={}",
log.error("拒绝访问审计写入失败 requestId={} method={} uri={} code={}",
RequestContext.requestId(), method, uri, errorCode, exception);
}
}
@@ -372,7 +372,7 @@ public class FileApplicationService {
try {
Files.delete(source);
} catch (IOException exception) {
LOGGER.warn("Failed to remove duplicate quarantine source for file {}", plan.publicId(), exception);
LOGGER.warn("删除文件 {} 的重复隔离源失败", plan.publicId(), exception);
}
}
return result;
@@ -80,38 +80,16 @@ class SetupApplicationIntegrationTest {
new HttpEntity<>(database), JsonNode.class);
assertThat(tested.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(tested.getBody().path("data").path("successful").asBoolean()).isTrue();
assertThat(tested.getBody().path("data").path("schemaReady").asBoolean()).isTrue();
assertThat(tested.getBody().path("data").path("schemaReady").asBoolean()).isFalse();
assertThat(tested.getBody().path("data").path("message").asText())
.contains("排序规则和完整迁移权限验证通过");
.contains("只读连接验证通过");
try (Connection connection = DriverManager.getConnection(MYSQL.getJdbcUrl(), MYSQL.getUsername(),
MYSQL.getPassword()); Statement statement = connection.createStatement()) {
try (ResultSet result = statement.executeQuery("""
SELECT
(SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name LIKE 'kaidi_setup_probe_%')
+
(SELECT COUNT(*) FROM information_schema.routines
WHERE routine_schema = DATABASE() AND routine_name LIKE 'kaidi_setup_probe_%')
""")) {
try (ResultSet result = statement.executeQuery("SELECT default_collation_name FROM information_schema.schemata WHERE schema_name = DATABASE()")) {
result.next();
assertThat(result.getInt(1)).isZero();
}
try (ResultSet result = statement.executeQuery("""
SELECT default_collation_name
FROM information_schema.schemata
WHERE schema_name = DATABASE()
""")) {
result.next();
assertThat(result.getString(1)).isEqualTo("utf8mb4_0900_ai_ci");
}
try (ResultSet result = statement.executeQuery("""
SELECT COUNT(*)
FROM flyway_schema_history
WHERE success = FALSE
""")) {
result.next();
assertThat(result.getInt(1)).isZero();
// A read-only connection test must not normalize the operator's schema.
assertThat(result.getString(1)).isNotEqualTo("utf8mb4_0900_ai_ci");
}
}