This commit is contained in:
@@ -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) {
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user