526 lines
25 KiB
Java
526 lines
25 KiB
Java
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;
|
|
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 Logger log = LoggerFactory.getLogger(InstallerService.class);
|
|
|
|
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) {
|
|
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,
|
|
"数据库迁移失败,请确认数据库为空且账号拥有建表权限");
|
|
}
|
|
|
|
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 或更高版本");
|
|
}
|
|
requireDatabaseOwnership(connection);
|
|
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");
|
|
}
|
|
}
|
|
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,
|
|
"无法连接 PostgreSQL,请检查地址、账号、密码、SSL 和网络设置");
|
|
}
|
|
}
|
|
|
|
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(
|
|
"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) {
|
|
log.error(
|
|
"Administrator initialization failed for database {} as user {}",
|
|
database.database().strip(),
|
|
database.username().strip(),
|
|
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_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());
|
|
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) {
|
|
}
|
|
}
|