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 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 status() { LinkedHashMap 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 testDatabase(InstallRequest.Database database) { ensureUnlocked(); DatabaseCheck check = verifyDatabase(database); LinkedHashMap 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 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 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 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 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 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) { } }