fix: harden first-run install and clean reinstall
Signed Release / release (push) Failing after 33s

This commit is contained in:
Qiufeng
2026-08-04 12:59:24 +08:00
parent 4d6a9307e5
commit f9545a9d0f
10 changed files with 566 additions and 17 deletions
+8 -6
View File
@@ -8,6 +8,8 @@ group = 'com.kaidi'
version = providers.gradleProperty('releaseVersion')
.orElse(System.getenv('ERP_RELEASE_VERSION') ?: '0.1.0')
.get()
def flywayVersion = '11.20.3'
def postgresqlDriverVersion = '42.7.13'
def productionBuild = providers.gradleProperty('productionBuild')
.map { it.toBoolean() }
.orElse(false)
@@ -47,9 +49,9 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-websocket'
// Production database and versioned schema migrations.
implementation 'org.flywaydb:flyway-core:10.22.0'
runtimeOnly 'org.flywaydb:flyway-database-postgresql:10.22.0'
runtimeOnly 'org.postgresql:postgresql'
implementation "org.flywaydb:flyway-core:$flywayVersion"
runtimeOnly "org.flywaydb:flyway-database-postgresql:$flywayVersion"
runtimeOnly "org.postgresql:postgresql:$postgresqlDriverVersion"
// SQLite remains available to source-tree development and tests, but is
// deliberately absent from PostgreSQL-only production release artifacts.
@@ -69,9 +71,9 @@ dependencies {
installerImplementation 'org.springframework.boot:spring-boot-starter-web'
installerImplementation 'org.springframework.boot:spring-boot-starter-validation'
installerImplementation 'org.flywaydb:flyway-core:10.22.0'
installerRuntimeOnly 'org.flywaydb:flyway-database-postgresql:10.22.0'
installerRuntimeOnly 'org.postgresql:postgresql'
installerImplementation "org.flywaydb:flyway-core:$flywayVersion"
installerRuntimeOnly "org.flywaydb:flyway-database-postgresql:$flywayVersion"
installerRuntimeOnly "org.postgresql:postgresql:$postgresqlDriverVersion"
}
tasks.named('test') {
@@ -3,6 +3,8 @@ package com.kaidi.oa.install;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.output.MigrateResult;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.SpringApplication;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.http.HttpStatus;
@@ -36,6 +38,8 @@ import java.util.Set;
@Service
public class InstallerService {
private static final Logger log = LoggerFactory.getLogger(InstallerService.class);
private static final Set<PosixFilePermission> OWNER_ONLY = EnumSet.of(
PosixFilePermission.OWNER_READ,
PosixFilePermission.OWNER_WRITE);
@@ -117,6 +121,11 @@ public class InstallerService {
.load()
.migrate();
} catch (RuntimeException exception) {
log.error(
"PostgreSQL migration failed for database {} as user {}",
request.database().database().strip(),
request.database().username().strip(),
exception);
throw new InstallApiException(
HttpStatus.UNPROCESSABLE_ENTITY,
42203,
@@ -170,6 +179,7 @@ public class InstallerService {
42201,
"PostgreSQL 版本过低,需要 15 或更高版本");
}
requireDatabaseOwnership(connection);
try (Statement statement = connection.createStatement()) {
statement.execute("SELECT 1");
statement.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm");
@@ -181,10 +191,16 @@ public class InstallerService {
throw new SQLException("pg_trgm is unavailable");
}
}
requirePgTrgmOwnership(connection);
return new DatabaseCheck(major, connection.getMetaData().getDatabaseProductVersion());
} catch (InstallApiException exception) {
throw exception;
} catch (SQLException | NumberFormatException exception) {
log.warn(
"PostgreSQL verification failed for database {} as user {}: {}",
database.database().strip(),
database.username().strip(),
exception.getMessage());
throw new InstallApiException(
HttpStatus.UNPROCESSABLE_ENTITY,
42202,
@@ -192,6 +208,40 @@ public class InstallerService {
}
}
private void requireDatabaseOwnership(Connection connection) throws SQLException {
try (Statement statement = connection.createStatement();
ResultSet result = statement.executeQuery(
"SELECT "
+ "pg_get_userbyid(datdba) = current_user AS owns_database, "
+ "has_schema_privilege(current_user, 'public', 'USAGE') AS can_use_schema, "
+ "has_schema_privilege(current_user, 'public', 'CREATE') AS can_create_in_schema "
+ "FROM pg_database WHERE datname = current_database()")) {
if (!result.next()
|| !result.getBoolean("owns_database")
|| !result.getBoolean("can_use_schema")
|| !result.getBoolean("can_create_in_schema")) {
throw new InstallApiException(
HttpStatus.UNPROCESSABLE_ENTITY,
42206,
"数据库账号必须是该空数据库的所有者,并拥有 public schema 建表权限");
}
}
}
private void requirePgTrgmOwnership(Connection connection) throws SQLException {
try (Statement statement = connection.createStatement();
ResultSet result = statement.executeQuery(
"SELECT pg_get_userbyid(extowner) = current_user "
+ "FROM pg_extension WHERE extname = 'pg_trgm'")) {
if (!result.next() || !result.getBoolean(1)) {
throw new InstallApiException(
HttpStatus.UNPROCESSABLE_ENTITY,
42207,
"数据库账号必须拥有 pg_trgm 扩展;请由管理员删除预建扩展后重新测试");
}
}
}
private void rejectExistingInstallation(InstallRequest.Database database) {
try (Connection connection = openConnection(database);
PreparedStatement tableQuery = connection.prepareStatement(
@@ -272,6 +322,11 @@ public class InstallerService {
} catch (InstallApiException exception) {
throw exception;
} catch (Exception exception) {
log.error(
"Administrator initialization failed for database {} as user {}",
database.database().strip(),
database.username().strip(),
exception);
throw new InstallApiException(HttpStatus.UNPROCESSABLE_ENTITY, 42205, "管理员账号初始化失败");
}
}
@@ -326,6 +381,8 @@ public class InstallerService {
values.put("ERP_INSTALL_PENDING_FILE", properties.pendingFile().toAbsolutePath().normalize().toString());
values.put("ERP_INSTALL_LOCK_FILE", properties.lockFile().toAbsolutePath().normalize().toString());
values.put("ERP_INSTALL_OPERATION_LOCK_FILE", properties.operationLockFile().toAbsolutePath().normalize().toString());
values.put("ERP_INSTALL_LOG_FILE", stateRoot.resolve("install-formal.log").toString());
values.put("ERP_INSTALL_HEALTH_TIMEOUT_SECONDS", environment("ERP_INSTALL_HEALTH_TIMEOUT_SECONDS", "240"));
values.put("ERP_SETUP_TOKEN", properties.token());
values.put("ERP_INSTALLER_JAR", installRoot.resolve("installer/kaidi-erp-installer.jar").toString());
values.put("ERP_JAR_PATH", installRoot.resolve("current/app/kaidi-erp.jar").toString());
@@ -440,7 +440,7 @@
} catch { /* Service is changing from installer to the formal application. */ }
}
$('#install-message').className = 'notice error';
$('#install-message').textContent = '正式服务尚未就绪。请在服务器查看 kaidi-erp 服务日志,安装文件和安装锁不会被误删。';
$('#install-message').textContent = '正式服务尚未就绪。请查看 systemctl 日志或 /var/lib/kaidi-erp/install-formal.log;安装文件和待确认状态均已保留。';
}
$('#start-button').addEventListener('click', () => go(2));