fix: harden first-run database setup
Release / release (push) Canceled after 0s

This commit is contained in:
Qiufeng
2026-08-17 22:05:10 +08:00
parent 52403c6f95
commit 271eac382e
10 changed files with 429 additions and 50 deletions
@@ -17,6 +17,6 @@ public class SetupLockedController {
@GetMapping("/status")
@PreAuthorize("true")
public ApiResponse<SetupViews.Status> status() {
return ApiResponse.ok(new SetupViews.Status(false, true, List.of("MYSQL"), "系统已完成安装"));
return ApiResponse.ok(new SetupViews.Status(false, true, true, List.of("MYSQL"), "系统已完成安装"));
}
}
@@ -25,6 +25,9 @@ import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.FlywayException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
@@ -37,8 +40,12 @@ import org.springframework.transaction.support.TransactionTemplate;
@ConditionalOnProperty(name = "finance.setup.enabled", havingValue = "true")
public class SetupService {
private static final Logger LOGGER = LoggerFactory.getLogger(SetupService.class);
private static final String MYSQL_PREFIX = "jdbc:mysql://";
private static final List<String> SUPPORTED_TYPES = List.of(DatabaseType.MYSQL.name());
private static final Set<Integer> MYSQL_PERMISSION_ERROR_CODES = Set.of(1044, 1045, 1142, 1227, 1370);
private static final long RESTART_TRIGGER_DELAY_MILLIS = 2_000L;
private static final int LOGIN_REDIRECT_DELAY_SECONDS = 10;
private final SetupProperties properties;
private final AtomicBoolean locked = new AtomicBoolean(false);
@@ -56,7 +63,7 @@ public class SetupService {
public SetupViews.Status status() {
boolean isLocked = locked.get() || Files.exists(Path.of(properties.markerFile()));
return new SetupViews.Status(!isLocked, isLocked, SUPPORTED_TYPES,
return new SetupViews.Status(!isLocked, isLocked, false, SUPPORTED_TYPES,
isLocked ? "安装向导已锁定" : "请完成数据库和管理员初始化");
}
@@ -74,8 +81,9 @@ public class SetupService {
false, "PostgreSQL 连接可用,但当前 Preview 的业务迁移仅支持 MySQL 8.4");
}
ConnectionResult connection = testConnection(settings);
validateSchemaForInstallation(settings);
return new SetupViews.Connection(true, settings.type().name(), connection.version(), true,
"MySQL 8.4 连接和 DDL/DML 权限验证通过");
"MySQL 8.4 连接和完整迁移权限验证通过");
}
public SetupViews.Completed complete(SetupContracts.CompleteRequest request) {
@@ -106,7 +114,7 @@ public class SetupService {
if (properties.restartAfterComplete()) {
Thread restart = new Thread(() -> {
try {
Thread.sleep(750L);
Thread.sleep(RESTART_TRIGGER_DELAY_MILLIS);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
}
@@ -116,18 +124,13 @@ public class SetupService {
restart.start();
}
return new SetupViews.Completed(true, request.adminUsername(),
"安装完成,系统正在切换到正式模式", 10);
"安装完成,系统正在切换到正式模式", LOGIN_REDIRECT_DELAY_SECONDS);
}
private void migrateAndCreateAdministrator(DatabaseSettings settings, SetupContracts.CompleteRequest request) {
try {
ensureCompatibleSchema(settings);
Flyway.configure()
.dataSource(settings.jdbcUrl(), settings.username(), settings.password())
.locations("classpath:db/migration")
.cleanDisabled(true)
.load()
.migrate();
validateSchemaForInstallation(settings);
flyway(settings).migrate();
DriverManagerDataSource dataSource = new DriverManagerDataSource(settings.jdbcUrl(), settings.username(),
settings.password());
JdbcTemplate jdbc = new JdbcTemplate(dataSource);
@@ -137,12 +140,13 @@ public class SetupService {
if (exception instanceof SetupException setupException) {
throw setupException;
}
LOGGER.error("Database migration or administrator initialization failed", exception);
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
"DATABASE_INITIALIZATION_FAILED", "数据库迁移或管理员初始化失败,请检查账号的 DDL/DML 权限");
"DATABASE_INITIALIZATION_FAILED", databaseInitializationFailureMessage(exception));
}
}
private void ensureCompatibleSchema(DatabaseSettings settings) {
private boolean ensureCompatibleSchema(DatabaseSettings settings) {
JdbcTemplate jdbc = new JdbcTemplate(new DriverManagerDataSource(settings.jdbcUrl(), settings.username(),
settings.password()));
Integer tableCount = jdbc.queryForObject("""
@@ -160,7 +164,42 @@ public class SetupService {
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
"DATABASE_NOT_EMPTY", "请选择空数据库,或提供已有 Kaidi Flyway 数据库");
}
return true;
}
return false;
}
private void validateSchemaForInstallation(DatabaseSettings settings) {
try {
if (!ensureCompatibleSchema(settings)) {
return;
}
flyway(settings).validate();
} catch (SetupException exception) {
throw exception;
} catch (FlywayException exception) {
LOGGER.error("Database migration history validation failed", 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);
SQLException sqlException = findSqlException(exception);
String detail = sqlException == null
? "数据库结构检查失败:" + safeErrorMessage(exception)
: databaseConnectionFailureMessage(sqlException);
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
"DATABASE_SCHEMA_INSPECTION_FAILED", detail);
}
}
private Flyway flyway(DatabaseSettings settings) {
return Flyway.configure()
.dataSource(settings.jdbcUrl(), settings.username(), settings.password())
.locations("classpath:db/migration")
.ignoreMigrationPatterns("*:pending")
.cleanDisabled(true)
.load();
}
private void initializeInstallation(JdbcTemplate jdbc, SetupContracts.CompleteRequest request) {
@@ -280,18 +319,148 @@ public class SetupService {
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
"MYSQL_VERSION_UNSUPPORTED", "当前版本要求 MySQL 8.4.x,检测到 " + version);
}
String probe = "kaidi_setup_probe_" + UUID.randomUUID().toString().replace("-", "");
try {
statement.execute("CREATE TABLE " + probe + " (id INT NOT NULL PRIMARY KEY)");
statement.execute("INSERT INTO " + probe + " (id) VALUES (1)");
statement.execute("UPDATE " + probe + " SET id = 2 WHERE id = 1");
} finally {
statement.execute("DROP TABLE IF EXISTS " + probe);
}
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={}",
exception.getSQLState(), exception.getErrorCode(), exception);
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
"DATABASE_CONNECTION_FAILED", "数据库连接或 DDL/DML 权限验证失败");
"DATABASE_CONNECTION_FAILED", databaseConnectionFailureMessage(exception));
}
}
private String databaseConnectionFailureMessage(SQLException exception) {
if (isPermissionError(exception)) {
return "数据库账号缺少完整迁移权限" + sqlErrorSummary(exception);
}
if (exception.getSQLState() != null && exception.getSQLState().startsWith("08")) {
return "数据库连接超时或中断" + sqlErrorSummary(exception);
}
return "数据库连接或完整迁移权限验证失败" + sqlErrorSummary(exception);
}
private String databaseInitializationFailureMessage(Throwable exception) {
SQLException sqlException = findSqlException(exception);
if (sqlException != null) {
if (isPermissionError(sqlException)) {
return "数据库账号缺少完成迁移所需权限" + sqlErrorSummary(sqlException);
}
if (sqlException.getSQLState() != null && sqlException.getSQLState().startsWith("08")) {
return "数据库迁移期间连接超时或中断" + sqlErrorSummary(sqlException);
}
return "数据库迁移或管理员初始化失败" + sqlErrorSummary(sqlException);
}
return "数据库迁移或管理员初始化失败:" + safeErrorMessage(exception);
}
private SQLException findSqlException(Throwable exception) {
Throwable current = exception;
while (current != null) {
if (current instanceof SQLException sqlException) {
return sqlException;
}
current = current.getCause();
}
return null;
}
private boolean isPermissionError(SQLException exception) {
return MYSQL_PERMISSION_ERROR_CODES.contains(exception.getErrorCode());
}
private String sqlErrorSummary(SQLException exception) {
String state = exception.getSQLState() == null ? "UNKNOWN" : exception.getSQLState();
return "(SQLState " + state + ",错误码 " + exception.getErrorCode() + "):"
+ safeErrorMessage(exception);
}
private String safeErrorMessage(Throwable exception) {
Throwable current = exception;
while (current.getCause() != null) {
current = current.getCause();
}
String message = current.getMessage() == null ? current.getClass().getSimpleName() : current.getMessage();
String sanitized = message.replaceAll("(?i)(password|pwd)=([^\\s&;]+)", "$1=***")
.replaceAll("\\s+", " ").trim();
return sanitized.length() <= 240 ? sanitized : sanitized.substring(0, 240) + "...";
}
private void verifyMigrationPrivileges(Connection connection, Statement statement, DatabaseType databaseType)
throws SQLException {
String suffix = UUID.randomUUID().toString().replace("-", "");
String parentTable = "kaidi_setup_probe_parent_" + suffix;
String childTable = "kaidi_setup_probe_child_" + suffix;
String foreignKey = "fk_setup_probe_" + suffix;
String index = "idx_setup_probe_" + suffix;
String routine = "kaidi_setup_probe_routine_" + suffix;
String temporaryTable = "kaidi_setup_probe_temp_" + suffix;
try {
statement.execute("CREATE TABLE " + parentTable + " (id INT NOT NULL PRIMARY KEY)");
if (databaseType == DatabaseType.MYSQL) {
statement.execute("CREATE TABLE " + childTable
+ " (id INT NOT NULL PRIMARY KEY, parent_id INT NOT NULL, CONSTRAINT " + foreignKey
+ " FOREIGN KEY (parent_id) REFERENCES " + parentTable + " (id))");
statement.execute("ALTER TABLE " + childTable + " ADD COLUMN note VARCHAR(32) NULL");
statement.execute("CREATE INDEX " + index + " ON " + childTable + " (parent_id)");
statement.execute("CREATE PROCEDURE " + routine + "() SELECT 1 AS probe_value");
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");
}
}
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");
}
}
statement.execute("CREATE TEMPORARY TABLE " + temporaryTable
+ " (id INT NOT NULL PRIMARY KEY)");
statement.execute("INSERT INTO " + temporaryTable + " (id) VALUES (1)");
statement.execute("DROP TEMPORARY TABLE " + temporaryTable);
statement.execute("UPDATE " + childTable + " SET note = 'verified' WHERE id = 1");
statement.execute("DELETE FROM " + childTable + " WHERE id = 1");
statement.execute("DELETE FROM " + parentTable + " WHERE id = 1");
} else {
statement.execute("INSERT INTO " + parentTable + " (id) VALUES (1)");
statement.execute("UPDATE " + parentTable + " SET id = 2 WHERE id = 1");
statement.execute("DELETE FROM " + parentTable + " WHERE id = 2");
}
} catch (SQLException exception) {
cleanupProbeObjects(statement, databaseType, routine, temporaryTable, childTable, parentTable, exception);
throw exception;
}
cleanupProbeObjects(statement, databaseType, routine, temporaryTable, childTable, parentTable, null);
}
private void cleanupProbeObjects(Statement statement, DatabaseType databaseType, String routine,
String temporaryTable, String childTable, String parentTable,
SQLException original)
throws SQLException {
SQLException cleanupFailure = null;
List<String> cleanupStatements = databaseType == DatabaseType.MYSQL
? List.of("DROP PROCEDURE IF EXISTS " + routine, "DROP TEMPORARY TABLE IF EXISTS " + temporaryTable,
"DROP TABLE IF EXISTS " + childTable, "DROP TABLE IF EXISTS " + parentTable)
: List.of("DROP TABLE IF EXISTS " + childTable, "DROP TABLE IF EXISTS " + parentTable);
for (String sql : cleanupStatements) {
try {
statement.execute(sql);
} catch (SQLException exception) {
if (cleanupFailure == null) {
cleanupFailure = exception;
} else {
cleanupFailure.addSuppressed(exception);
}
}
}
if (cleanupFailure != null) {
if (original != null) {
original.addSuppressed(cleanupFailure);
} else {
throw cleanupFailure;
}
}
}
@@ -10,6 +10,7 @@ public final class SetupViews {
public record Status(
boolean required,
boolean locked,
boolean ready,
List<String> supportedDatabaseTypes,
String message
) {
@@ -63,6 +63,7 @@ class SetupApplicationIntegrationTest {
ResponseEntity<JsonNode> initial = rest.getForEntity(url("/api/v1/setup/status"), JsonNode.class);
assertThat(initial.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(initial.getBody().path("data").path("required").asBoolean()).isTrue();
assertThat(initial.getBody().path("data").path("ready").asBoolean()).isFalse();
Map<String, Object> database = databaseRequest("wrong-code");
ResponseEntity<JsonNode> denied = rest.postForEntity(url("/api/v1/setup/test-connection"),
@@ -76,6 +77,22 @@ class SetupApplicationIntegrationTest {
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("message").asText()).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_%')
""")) {
result.next();
assertThat(result.getInt(1)).isZero();
}
}
Map<String, Object> complete = new LinkedHashMap<>(database);
complete.put("adminUsername", "setup-admin");
@@ -121,6 +138,7 @@ class SetupApplicationIntegrationTest {
ResponseEntity<JsonNode> locked = rest.getForEntity(url("/api/v1/setup/status"), JsonNode.class);
assertThat(locked.getBody().path("data").path("required").asBoolean()).isFalse();
assertThat(locked.getBody().path("data").path("locked").asBoolean()).isTrue();
assertThat(locked.getBody().path("data").path("ready").asBoolean()).isFalse();
}
private Map<String, Object> databaseRequest(String setupCode) {
@@ -0,0 +1,31 @@
package com.kaidi.finance.setup;
import static org.assertj.core.api.Assertions.assertThat;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class SetupReadinessTest {
private static final String TOKEN_SHA256 =
"9158568c96987884c8141d363daceffda86bb17083054b36105934090aa7e166";
@TempDir
private Path stateRoot;
@Test
void distinguishesLockedSetupModeFromReadyApplicationMode() {
SetupProperties properties = new SetupProperties(true, TOKEN_SHA256,
stateRoot.resolve("application.env").toString(), stateRoot.resolve("locked").toString(), false);
SetupViews.Status setupMode = new SetupService(properties).status();
SetupViews.Status applicationMode = new SetupLockedController().status().data();
assertThat(setupMode.required()).isTrue();
assertThat(setupMode.ready()).isFalse();
assertThat(applicationMode.required()).isFalse();
assertThat(applicationMode.locked()).isTrue();
assertThat(applicationMode.ready()).isTrue();
}
}