feat: add PostgreSQL web installation wizard
Signed Release / release (push) Failing after 5m1s

This commit is contained in:
Qiufeng
2026-08-04 07:42:20 +08:00
parent b714f9851e
commit 1affcd9a5e
61 changed files with 1992 additions and 431 deletions
@@ -0,0 +1,468 @@
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.springframework.boot.SpringApplication;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.PosixFilePermission;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.time.Instant;
import java.util.EnumSet;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
@Service
public class InstallerService {
private static final Set<PosixFilePermission> OWNER_ONLY = EnumSet.of(
PosixFilePermission.OWNER_READ,
PosixFilePermission.OWNER_WRITE);
private final InstallerProperties properties;
private final ConfigurableApplicationContext applicationContext;
private final ObjectMapper objectMapper;
public InstallerService(
InstallerProperties properties,
ConfigurableApplicationContext applicationContext,
ObjectMapper objectMapper) {
this.properties = properties;
this.applicationContext = applicationContext;
this.objectMapper = objectMapper;
}
public Map<String, Object> status() {
LinkedHashMap<String, Object> result = new LinkedHashMap<>();
result.put("version", properties.version());
result.put("javaVersion", System.getProperty("java.version"));
result.put("minimumJava", 17);
result.put("minimumPostgres", 15);
result.put("redisRequired", false);
result.put("locked", Files.exists(properties.lockFile()));
result.put("pending", Files.exists(properties.pendingFile()));
result.put("ready", !Files.exists(properties.lockFile()) && !Files.exists(properties.pendingFile()));
return result;
}
public Map<String, Object> testDatabase(InstallRequest.Database database) {
ensureUnlocked();
DatabaseCheck check = verifyDatabase(database);
LinkedHashMap<String, Object> result = new LinkedHashMap<>();
result.put("connected", true);
result.put("postgresVersion", check.productVersion());
result.put("postgresMajor", check.majorVersion());
result.put("pgTrgm", true);
result.put("message", "PostgreSQL 连接和扩展检查通过");
return result;
}
public Map<String, Object> complete(InstallRequest.Complete request) {
ensureUnlocked();
Path operationLock = properties.operationLockFile().toAbsolutePath().normalize();
try {
Files.createDirectories(operationLock.getParent());
try (FileChannel channel = FileChannel.open(
operationLock,
StandardOpenOption.CREATE,
StandardOpenOption.WRITE);
FileLock ignored = tryLock(channel)) {
if (ignored == null) {
throw new InstallApiException(HttpStatus.CONFLICT, 40901, "安装正在进行,请勿重复提交");
}
return completeLocked(request);
}
} catch (InstallApiException exception) {
throw exception;
} catch (IOException exception) {
throw new InstallApiException(HttpStatus.INTERNAL_SERVER_ERROR, 50011, "无法创建安装锁,请检查目录权限");
}
}
private Map<String, Object> completeLocked(InstallRequest.Complete request) {
ensureUnlocked();
DatabaseCheck check = verifyDatabase(request.database());
rejectExistingInstallation(request.database());
writeRuntimeConfiguration(request.database());
MigrateResult migration;
try {
migration = Flyway.configure()
.dataSource(jdbcUrl(request.database()), request.database().username(), request.database().password())
.locations("classpath:db/migration/postgresql")
.cleanDisabled(true)
.validateOnMigrate(true)
.baselineOnMigrate(false)
.load()
.migrate();
} catch (RuntimeException exception) {
throw new InstallApiException(
HttpStatus.UNPROCESSABLE_ENTITY,
42203,
"数据库迁移失败,请确认数据库为空且账号拥有建表权限");
}
createAdministrator(request.database(), request.administrator());
writePendingMarker(check, migration.migrationsExecuted, request.administrator().loginName());
scheduleShutdown();
LinkedHashMap<String, Object> result = new LinkedHashMap<>();
result.put("installed", true);
result.put("restarting", true);
result.put("version", properties.version());
result.put("migrationsExecuted", migration.migrationsExecuted);
result.put("message", "初始化完成,正在启动正式服务");
return result;
}
private FileLock tryLock(FileChannel channel) throws IOException {
try {
return channel.tryLock();
} catch (OverlappingFileLockException exception) {
return null;
}
}
private void ensureUnlocked() {
if (Files.exists(properties.lockFile())) {
throw new InstallApiException(HttpStatus.GONE, 41001, "系统已经完成安装");
}
if (Files.exists(properties.pendingFile())) {
throw new InstallApiException(HttpStatus.CONFLICT, 40902, "系统正在启动正式服务,请稍候");
}
}
private DatabaseCheck verifyDatabase(InstallRequest.Database database) {
try (Connection connection = openConnection(database)) {
int versionNumber;
try (Statement statement = connection.createStatement();
ResultSet result = statement.executeQuery("SHOW server_version_num")) {
if (!result.next()) {
throw new SQLException("PostgreSQL did not report a version");
}
versionNumber = Integer.parseInt(result.getString(1));
}
int major = versionNumber / 10_000;
if (major < 15) {
throw new InstallApiException(
HttpStatus.UNPROCESSABLE_ENTITY,
42201,
"PostgreSQL 版本过低,需要 15 或更高版本");
}
try (Statement statement = connection.createStatement()) {
statement.execute("SELECT 1");
statement.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm");
}
try (PreparedStatement statement = connection.prepareStatement(
"SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_trgm')");
ResultSet result = statement.executeQuery()) {
if (!result.next() || !result.getBoolean(1)) {
throw new SQLException("pg_trgm is unavailable");
}
}
return new DatabaseCheck(major, connection.getMetaData().getDatabaseProductVersion());
} catch (InstallApiException exception) {
throw exception;
} catch (SQLException | NumberFormatException exception) {
throw new InstallApiException(
HttpStatus.UNPROCESSABLE_ENTITY,
42202,
"无法连接 PostgreSQL,请检查地址、账号、密码、SSL 和网络设置");
}
}
private void rejectExistingInstallation(InstallRequest.Database database) {
try (Connection connection = openConnection(database);
PreparedStatement tableQuery = connection.prepareStatement(
"SELECT to_regclass('public.sys_user') IS NOT NULL");
ResultSet tableResult = tableQuery.executeQuery()) {
if (!tableResult.next() || !tableResult.getBoolean(1)) {
return;
}
try (Statement countQuery = connection.createStatement();
ResultSet countResult = countQuery.executeQuery("SELECT COUNT(*) FROM public.sys_user")) {
if (countResult.next() && countResult.getLong(1) > 0) {
throw new InstallApiException(
HttpStatus.CONFLICT,
40903,
"该数据库已经包含业务用户,为防止覆盖现有系统已停止安装");
}
}
} catch (InstallApiException exception) {
throw exception;
} catch (SQLException exception) {
throw new InstallApiException(HttpStatus.UNPROCESSABLE_ENTITY, 42204, "无法检查数据库现有数据");
}
}
private void createAdministrator(
InstallRequest.Database database,
InstallRequest.Administrator administrator) {
try (Connection connection = openConnection(database)) {
connection.setAutoCommit(false);
connection.setTransactionIsolation(Connection.TRANSACTION_SERIALIZABLE);
try {
try (Statement statement = connection.createStatement()) {
statement.execute("LOCK TABLE public.sys_user IN SHARE ROW EXCLUSIVE MODE");
}
try (Statement statement = connection.createStatement();
ResultSet result = statement.executeQuery("SELECT COUNT(*) FROM public.sys_user")) {
if (!result.next() || result.getLong(1) != 0) {
throw new InstallApiException(
HttpStatus.CONFLICT,
40903,
"该数据库已经包含业务用户,为防止覆盖现有系统已停止安装");
}
}
long adminRoleId = upsertRole(connection, "ADMIN", "系统管理员", "系统内置管理员角色");
upsertRole(connection, "USER", "普通用户", "系统内置普通用户角色");
upsertRole(connection, "APPROVER", "审批人", "系统内置审批角色");
long userId;
try (PreparedStatement statement = connection.prepareStatement(
"INSERT INTO public.sys_user "
+ "(enabled, display_name, login_name, password, dept_id, email, phone, title) "
+ "VALUES (TRUE, ?, ?, ?, NULL, NULL, NULL, NULL) RETURNING id")) {
statement.setString(1, administrator.displayName().strip());
statement.setString(2, administrator.loginName().strip());
statement.setString(3, InstallerPasswordHasher.hash(administrator.password()));
try (ResultSet result = statement.executeQuery()) {
if (!result.next()) {
throw new SQLException("administrator insert returned no id");
}
userId = result.getLong(1);
}
}
try (PreparedStatement statement = connection.prepareStatement(
"INSERT INTO public.sys_user_role (user_id, role_id) VALUES (?, ?)")) {
statement.setLong(1, userId);
statement.setLong(2, adminRoleId);
statement.executeUpdate();
}
connection.commit();
} catch (Exception exception) {
connection.rollback();
if (exception instanceof InstallApiException installApiException) {
throw installApiException;
}
throw exception;
}
} catch (InstallApiException exception) {
throw exception;
} catch (Exception exception) {
throw new InstallApiException(HttpStatus.UNPROCESSABLE_ENTITY, 42205, "管理员账号初始化失败");
}
}
private long upsertRole(Connection connection, String code, String name, String description) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(
"INSERT INTO public.sys_role (system, code, description, name) VALUES (TRUE, ?, ?, ?) "
+ "ON CONFLICT (code) DO UPDATE SET system = TRUE, description = EXCLUDED.description, "
+ "name = EXCLUDED.name RETURNING id")) {
statement.setString(1, code);
statement.setString(2, description);
statement.setString(3, name);
try (ResultSet result = statement.executeQuery()) {
if (!result.next()) {
throw new SQLException("role insert returned no id");
}
return result.getLong(1);
}
}
}
private Connection openConnection(InstallRequest.Database database) throws SQLException {
Properties connectionProperties = new Properties();
connectionProperties.setProperty("user", database.username().strip());
connectionProperties.setProperty("password", database.password());
return DriverManager.getConnection(jdbcUrl(database), connectionProperties);
}
private String jdbcUrl(InstallRequest.Database database) {
String host = database.host().strip();
if (host.startsWith("[") && host.endsWith("]")) {
host = host.substring(1, host.length() - 1);
}
if (host.contains(":")) {
host = "[" + host + "]";
}
return "jdbc:postgresql://" + host + ":" + database.port() + "/" + database.database().strip()
+ "?sslmode=" + database.sslMode()
+ "&connectTimeout=10&socketTimeout=20&ApplicationName=KaidiERPInstaller";
}
private void writeRuntimeConfiguration(InstallRequest.Database database) {
LinkedHashMap<String, String> values = new LinkedHashMap<>();
Path installRoot = properties.installRoot().toAbsolutePath().normalize();
Path stateRoot = properties.pendingFile().toAbsolutePath().normalize().getParent();
int port = integerEnvironment("SERVER_PORT", 8091);
values.put("ERP_INSTALL_ROOT", installRoot.toString());
values.put("ERP_CONFIG_FILE", properties.configFile().toAbsolutePath().normalize().toString());
values.put("ERP_RUN_DIR", installRoot.resolve("run").toString());
values.put("ERP_STATE_FILE", environment("ERP_STATE_FILE", stateRoot.resolve("update-state.json").toString()));
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_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());
values.put("ERP_JAVA_BIN", environment("ERP_JAVA_BIN", ""));
values.put("ERP_JAVA_OPTS", environment("ERP_JAVA_OPTS", "-Xms512m -Xmx2g"));
values.put("ERP_HEALTH_URL", "http://127.0.0.1:" + port + "/api/oa/health");
values.put("ERP_UPDATE_PUBLIC_KEY_FILE", environment(
"ERP_UPDATE_PUBLIC_KEY_FILE",
properties.configFile().toAbsolutePath().normalize().getParent().resolve("release-public-key.pem").toString()));
values.put("ERP_UPDATE_REQUIRE_SIGNATURE", environment("ERP_UPDATE_REQUIRE_SIGNATURE", "true"));
values.put("ERP_UPDATE_BACKUP_MODE", environment("ERP_UPDATE_BACKUP_MODE", "none"));
values.put("ERP_UPDATE_HEALTH_TIMEOUT_SECONDS", environment("ERP_UPDATE_HEALTH_TIMEOUT_SECONDS", "120"));
values.put("ERP_UPDATE_HEALTH_POLL_SECONDS", environment("ERP_UPDATE_HEALTH_POLL_SECONDS", "2"));
values.put("ERP_PGHOST", database.host().strip());
values.put("ERP_PGPORT", Integer.toString(database.port()));
values.put("ERP_PGDATABASE", database.database().strip());
values.put("ERP_PGSSLMODE", database.sslMode());
values.put("SPRING_PROFILES_ACTIVE", "postgres");
values.put("SERVER_PORT", Integer.toString(port));
values.put("OA_DB_URL", jdbcUrl(database));
values.put("OA_DB_USERNAME", database.username().strip());
values.put("OA_DB_PASSWORD", database.password());
values.put("OA_DB_POOL_MAX", environment("OA_DB_POOL_MAX", "20"));
values.put("OA_DB_POOL_MIN", environment("OA_DB_POOL_MIN", "2"));
values.put("OA_SEED_DEMO", "false");
values.put("OA_UPDATE_ENABLED", environment("OA_UPDATE_ENABLED", "true"));
values.put("OA_UPDATE_GITEA_BASE_URL", environment("OA_UPDATE_GITEA_BASE_URL", ""));
values.put("OA_UPDATE_REPOSITORY", environment("OA_UPDATE_REPOSITORY", "awaioi/ERP"));
values.put("OA_UPDATE_CHANNEL", environment("OA_UPDATE_CHANNEL", "stable"));
values.put("OA_UPDATE_TOKEN", environment("OA_UPDATE_TOKEN", ""));
values.put("OA_UPDATE_HELPER_COMMAND", installRoot.resolve("current/bin/erp-update").toString());
values.put("OA_UPDATE_STATE_FILE", environment(
"OA_UPDATE_STATE_FILE",
stateRoot.resolve("update-state.json").toString()));
values.put("OA_UPDATE_ALLOW_INSECURE_HTTP", environment("OA_UPDATE_ALLOW_INSECURE_HTTP", "false"));
StringBuilder content = new StringBuilder();
values.forEach((key, value) -> content.append(key)
.append("='")
.append(shellSingleQuote(value))
.append("'\n"));
atomicWrite(properties.configFile(), content.toString().getBytes(StandardCharsets.UTF_8));
}
private void writePendingMarker(DatabaseCheck check, int migrations, String loginName) {
LinkedHashMap<String, Object> marker = new LinkedHashMap<>();
marker.put("status", "PENDING");
marker.put("version", properties.version());
marker.put("createdAt", Instant.now().toString());
marker.put("postgresMajor", check.majorVersion());
marker.put("migrationsExecuted", migrations);
marker.put("administrator", loginName.strip());
try {
atomicWrite(properties.pendingFile(), objectMapper.writeValueAsBytes(marker));
} catch (IOException exception) {
throw new InstallApiException(HttpStatus.INTERNAL_SERVER_ERROR, 50012, "无法写入安装状态,请检查目录权限");
}
}
private void atomicWrite(Path target, byte[] content) {
Path absolute = target.toAbsolutePath().normalize();
Path parent = absolute.getParent();
Path temporary = null;
try {
Files.createDirectories(parent);
temporary = Files.createTempFile(parent, "." + absolute.getFileName() + "-", ".tmp");
setOwnerOnly(temporary);
try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.WRITE)) {
channel.write(ByteBuffer.wrap(content));
channel.force(true);
}
try {
Files.move(temporary, absolute, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException exception) {
Files.move(temporary, absolute, StandardCopyOption.REPLACE_EXISTING);
}
setOwnerOnly(absolute);
temporary = null;
} catch (IOException exception) {
throw new InstallApiException(HttpStatus.INTERNAL_SERVER_ERROR, 50010, "无法安全写入安装配置,请检查目录权限");
} finally {
if (temporary != null) {
try {
Files.deleteIfExists(temporary);
} catch (IOException ignored) {
// Best effort cleanup of a file containing protected configuration.
}
}
}
}
private void setOwnerOnly(Path path) throws IOException {
try {
Files.setPosixFilePermissions(path, OWNER_ONLY);
} catch (UnsupportedOperationException ignored) {
// Windows is not a supported deployment target; this keeps local tests portable.
}
}
private String shellSingleQuote(String value) {
if (value.indexOf('\0') >= 0 || value.indexOf('\n') >= 0 || value.indexOf('\r') >= 0) {
throw new InstallApiException(HttpStatus.BAD_REQUEST, 40002, "配置值不能包含换行符");
}
return value.replace("'", "'\\''");
}
private String environment(String name, String fallback) {
String value = System.getenv(name);
return value == null ? fallback : value;
}
private int integerEnvironment(String name, int fallback) {
String value = environment(name, Integer.toString(fallback));
try {
int parsed = Integer.parseInt(value);
return parsed >= 1 && parsed <= 65535 ? parsed : fallback;
} catch (NumberFormatException exception) {
return fallback;
}
}
private void scheduleShutdown() {
if (!properties.exitAfterComplete()) {
return;
}
Thread shutdown = new Thread(() -> {
try {
Thread.sleep(1_500L);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
}
System.exit(SpringApplication.exit(applicationContext, () -> 0));
}, "installer-complete-shutdown");
shutdown.setDaemon(false);
shutdown.start();
}
private record DatabaseCheck(int majorVersion, String productVersion) {
}
}