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
+53
View File
@@ -21,6 +21,24 @@ repositories {
mavenCentral()
}
sourceSets {
installer {
java.srcDirs = ['src/installer/java']
resources.srcDirs = ['src/installer/resources']
}
installerTest {
java.srcDirs = ['src/installerTest/java']
resources.srcDirs = ['src/installerTest/resources']
compileClasspath += sourceSets.installer.output
runtimeClasspath += sourceSets.installer.output
}
}
configurations {
installerTestImplementation.extendsFrom testImplementation, installerImplementation
installerTestRuntimeOnly.extendsFrom testRuntimeOnly, installerRuntimeOnly
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
@@ -48,12 +66,47 @@ dependencies {
implementation 'org.jsoup:jsoup:1.22.2'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
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'
}
tasks.named('test') {
useJUnitPlatform()
}
tasks.named('processInstallerResources') {
from('src/main/resources') {
include 'db/migration/postgresql/**'
}
}
tasks.register('installerTest', Test) {
description = 'Runs tests for the standalone web installer.'
group = 'verification'
testClassesDirs = sourceSets.installerTest.output.classesDirs
classpath = sourceSets.installerTest.runtimeClasspath
useJUnitPlatform()
}
tasks.register('installerBootJar', org.springframework.boot.gradle.tasks.bundling.BootJar) {
description = 'Builds the standalone first-run web installer.'
group = 'build'
archiveBaseName = 'kaidi-erp-installer'
archiveVersion = project.version
mainClass = 'com.kaidi.oa.install.InstallerApplication'
targetJavaVersion = JavaVersion.VERSION_17
classpath = sourceSets.installer.runtimeClasspath
dependsOn tasks.named('installerClasses')
}
tasks.named('check') {
dependsOn tasks.named('installerTest')
}
springBoot {
buildInfo()
}
@@ -0,0 +1,23 @@
package com.kaidi.oa.install;
import org.springframework.http.HttpStatus;
public final class InstallApiException extends RuntimeException {
private final HttpStatus status;
private final int code;
public InstallApiException(HttpStatus status, int code, String message) {
super(message);
this.status = status;
this.code = code;
}
public HttpStatus status() {
return status;
}
public int code() {
return code;
}
}
@@ -0,0 +1,44 @@
package com.kaidi.oa.install;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.resource.NoResourceFoundException;
@RestControllerAdvice
public class InstallApiExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(InstallApiExceptionHandler.class);
@ExceptionHandler(InstallApiException.class)
public ResponseEntity<InstallApiResponse<Void>> handleInstallError(InstallApiException exception) {
return ResponseEntity.status(exception.status())
.body(InstallApiResponse.error(exception.code(), exception.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<InstallApiResponse<Void>> handleValidation(MethodArgumentNotValidException exception) {
String message = exception.getBindingResult().getFieldErrors().stream()
.findFirst()
.map(error -> error.getDefaultMessage() == null ? "安装参数不正确" : error.getDefaultMessage())
.orElse("安装参数不正确");
return ResponseEntity.badRequest().body(InstallApiResponse.error(40001, message));
}
@ExceptionHandler(NoResourceFoundException.class)
public ResponseEntity<InstallApiResponse<Void>> handleMissingResource() {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(InstallApiResponse.error(40400, "资源不存在"));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<InstallApiResponse<Void>> handleUnexpected(Exception exception) {
log.error("Installer operation failed ({})", exception.getClass().getSimpleName());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(InstallApiResponse.error(50000, "安装操作失败,请检查配置后重试"));
}
}
@@ -0,0 +1,12 @@
package com.kaidi.oa.install;
public record InstallApiResponse<T>(int code, String message, T data) {
public static <T> InstallApiResponse<T> ok(T data) {
return new InstallApiResponse<>(0, "ok", data);
}
public static InstallApiResponse<Void> error(int code, String message) {
return new InstallApiResponse<>(code, message, null);
}
}
@@ -0,0 +1,54 @@
package com.kaidi.oa.install;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
public final class InstallRequest {
private InstallRequest() {
}
public record Database(
@NotBlank(message = "请填写 PostgreSQL 地址")
@Size(max = 253, message = "PostgreSQL 地址过长")
@Pattern(regexp = "[A-Za-z0-9._:\\-\\[\\]]+", message = "PostgreSQL 地址格式不正确")
String host,
@Min(value = 1, message = "PostgreSQL 端口不正确")
@Max(value = 65535, message = "PostgreSQL 端口不正确")
int port,
@NotBlank(message = "请填写数据库名")
@Pattern(regexp = "[A-Za-z_][A-Za-z0-9_-]{0,62}", message = "数据库名格式不正确")
String database,
@NotBlank(message = "请填写数据库账号")
@Pattern(regexp = "[A-Za-z_][A-Za-z0-9_.-]{0,127}", message = "数据库账号格式不正确")
String username,
@NotBlank(message = "请填写数据库密码")
@Size(max = 500, message = "数据库密码过长")
String password,
@NotBlank(message = "请选择 SSL 模式")
@Pattern(regexp = "disable|allow|prefer|require|verify-ca|verify-full", message = "SSL 模式不正确")
String sslMode) {
}
public record Administrator(
@NotBlank(message = "请填写管理员账号")
@Pattern(regexp = "[A-Za-z][A-Za-z0-9_.-]{2,63}", message = "管理员账号需以字母开头,长度为 3-64 位")
String loginName,
@NotBlank(message = "请填写管理员姓名")
@Size(max = 100, message = "管理员姓名不能超过 100 个字符")
String displayName,
@NotBlank(message = "请填写管理员密码")
@Size(min = 8, max = 200, message = "管理员密码长度需为 8-200 位")
String password) {
}
public record Complete(
@NotNull(message = "缺少 PostgreSQL 配置") @Valid Database database,
@NotNull(message = "缺少管理员配置") @Valid Administrator administrator) {
}
}
@@ -0,0 +1,19 @@
package com.kaidi.oa.install;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
@SpringBootApplication(exclude = {
DataSourceAutoConfiguration.class,
FlywayAutoConfiguration.class
})
@EnableConfigurationProperties(InstallerProperties.class)
public class InstallerApplication {
public static void main(String[] args) {
SpringApplication.run(InstallerApplication.class, args);
}
}
@@ -0,0 +1,38 @@
package com.kaidi.oa.install;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/api/install")
public class InstallerController {
private final InstallerService installerService;
public InstallerController(InstallerService installerService) {
this.installerService = installerService;
}
@GetMapping("/status")
public InstallApiResponse<Map<String, Object>> status() {
return InstallApiResponse.ok(installerService.status());
}
@PostMapping("/test-database")
public InstallApiResponse<Map<String, Object>> testDatabase(
@Valid @RequestBody InstallRequest.Database database) {
return InstallApiResponse.ok(installerService.testDatabase(database));
}
@PostMapping("/complete")
public InstallApiResponse<Map<String, Object>> complete(
@Valid @RequestBody InstallRequest.Complete request) {
return InstallApiResponse.ok(installerService.complete(request));
}
}
@@ -0,0 +1,35 @@
package com.kaidi.oa.install;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import java.util.Base64;
final class InstallerPasswordHasher {
private static final int ITERATIONS = 120_000;
private static final int KEY_LENGTH = 256;
private static final int SALT_BYTES = 16;
private static final SecureRandom RANDOM = new SecureRandom();
private InstallerPasswordHasher() {
}
static String hash(String raw) {
byte[] salt = new byte[SALT_BYTES];
RANDOM.nextBytes(salt);
try {
PBEKeySpec spec = new PBEKeySpec(raw.toCharArray(), salt, ITERATIONS, KEY_LENGTH);
byte[] derived = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
.generateSecret(spec)
.getEncoded();
spec.clearPassword();
return "pbkdf2$" + ITERATIONS + "$"
+ Base64.getEncoder().encodeToString(salt) + "$"
+ Base64.getEncoder().encodeToString(derived);
} catch (GeneralSecurityException exception) {
throw new IllegalStateException("PBKDF2 is not available", exception);
}
}
}
@@ -0,0 +1,21 @@
package com.kaidi.oa.install;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
import java.nio.file.Path;
@Validated
@ConfigurationProperties(prefix = "erp.install")
public record InstallerProperties(
@NotBlank String token,
@NotNull Path installRoot,
@NotNull Path configFile,
@NotNull Path pendingFile,
@NotNull Path lockFile,
@NotNull Path operationLockFile,
@NotBlank String version,
boolean exitAfterComplete) {
}
@@ -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) {
}
}
@@ -0,0 +1,44 @@
package com.kaidi.oa.install;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
@Component
public class SetupTokenFilter extends OncePerRequestFilter {
private final byte[] expectedToken;
public SetupTokenFilter(InstallerProperties properties) {
this.expectedToken = properties.token().getBytes(StandardCharsets.UTF_8);
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !request.getRequestURI().startsWith("/api/install/")
|| "OPTIONS".equalsIgnoreCase(request.getMethod());
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
String supplied = request.getHeader("X-Setup-Token");
byte[] candidate = supplied == null ? new byte[0] : supplied.getBytes(StandardCharsets.UTF_8);
if (!MessageDigest.isEqual(expectedToken, candidate)) {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write("{\"code\":40301,\"message\":\"安装链接无效或已过期\",\"data\":null}");
return;
}
chain.doFilter(request, response);
}
}
@@ -0,0 +1,27 @@
server:
port: ${SERVER_PORT:8091}
shutdown: graceful
spring:
application:
name: kaidi-erp-installer
lifecycle:
timeout-per-shutdown-phase: 5s
jackson:
default-property-inclusion: non_null
erp:
install:
token: ${ERP_SETUP_TOKEN:}
install-root: ${ERP_INSTALL_ROOT:./runtime}
config-file: ${ERP_CONFIG_FILE:./runtime/config/erp.env}
pending-file: ${ERP_INSTALL_PENDING_FILE:./runtime/install.pending}
lock-file: ${ERP_INSTALL_LOCK_FILE:./runtime/install.lock}
operation-lock-file: ${ERP_INSTALL_OPERATION_LOCK_FILE:./runtime/install-operation.lock}
version: ${ERP_RELEASE_VERSION:development}
exit-after-complete: ${ERP_INSTALL_EXIT_AFTER_COMPLETE:true}
logging:
level:
root: INFO
com.kaidi.oa.install: INFO
@@ -0,0 +1,571 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex,nofollow">
<title>凯迪 ERP 安装向导</title>
<style>
:root {
color-scheme: light;
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, sans-serif;
color: #172033;
background: #f3f5f8;
font-synthesis: none;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; background: #f3f5f8; }
button, input, select { font: inherit; letter-spacing: 0; }
button { cursor: pointer; }
.topbar {
height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 32px;
color: #fff;
background: #1d3557;
border-bottom: 3px solid #2f6fed;
}
.brand { display: flex; align-items: center; gap: 12px; min-width: 0; }
.brand-mark {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border: 1px solid #91b3e7;
border-radius: 6px;
background: #fff;
color: #1d3557;
font-size: 13px;
font-weight: 800;
}
.brand-name { font-size: 16px; font-weight: 700; white-space: nowrap; }
.topbar-label { color: #dbe7f8; font-size: 13px; white-space: nowrap; }
.shell {
width: min(1040px, calc(100% - 40px));
margin: 36px auto 48px;
display: grid;
grid-template-columns: 232px minmax(0, 1fr);
gap: 24px;
align-items: start;
}
.steps {
padding: 18px 0;
background: #fff;
border: 1px solid #dfe4ec;
border-radius: 8px;
}
.step {
position: relative;
display: grid;
grid-template-columns: 30px 1fr;
gap: 11px;
min-height: 62px;
padding: 8px 18px;
color: #748096;
}
.step:not(:last-child)::after {
content: "";
position: absolute;
left: 32px;
top: 42px;
width: 1px;
height: 28px;
background: #d8dee8;
}
.step-index {
width: 28px;
height: 28px;
display: grid;
place-items: center;
border: 1px solid #c9d1de;
border-radius: 50%;
background: #fff;
font-size: 12px;
font-weight: 700;
}
.step-copy { padding-top: 4px; }
.step-title { display: block; color: #4b586d; font-size: 14px; font-weight: 650; }
.step-state { display: block; margin-top: 4px; font-size: 12px; }
.step.active .step-index { border-color: #2f6fed; background: #2f6fed; color: #fff; }
.step.active .step-title { color: #183a72; }
.step.done .step-index { border-color: #25805a; background: #e8f5ee; color: #187149; }
.panel {
min-height: 540px;
padding: 30px 34px 28px;
background: #fff;
border: 1px solid #dfe4ec;
border-radius: 8px;
box-shadow: 0 6px 18px rgba(24, 38, 61, 0.06);
}
.panel-head { padding-bottom: 20px; border-bottom: 1px solid #e8ebf0; }
h1 { margin: 0; color: #172033; font-size: 24px; line-height: 1.3; letter-spacing: 0; }
.subtitle { margin: 8px 0 0; color: #69758a; font-size: 14px; line-height: 1.65; }
.section { padding-top: 24px; }
.check-list { display: grid; gap: 10px; }
.check-row {
min-height: 52px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 14px;
border: 1px solid #e1e6ed;
border-radius: 6px;
background: #fafbfc;
}
.check-label { font-size: 14px; font-weight: 600; }
.check-detail { margin-top: 3px; color: #778398; font-size: 12px; }
.badge {
flex: 0 0 auto;
padding: 4px 8px;
border-radius: 4px;
background: #e8f5ee;
color: #187149;
font-size: 12px;
font-weight: 650;
}
.badge.wait { background: #eef1f5; color: #667287; }
.form-grid { display: grid; grid-template-columns: 1fr 150px; gap: 18px 16px; }
.field.full { grid-column: 1 / -1; }
.field label { display: block; margin-bottom: 7px; color: #334057; font-size: 13px; font-weight: 650; }
.required::after { content: " *"; color: #bd3131; }
input, select {
width: 100%;
height: 42px;
padding: 0 12px;
border: 1px solid #cbd3df;
border-radius: 5px;
outline: none;
background: #fff;
color: #172033;
transition: border-color .15s, box-shadow .15s;
}
input:focus, select:focus { border-color: #2f6fed; box-shadow: 0 0 0 3px rgba(47, 111, 237, .12); }
input.invalid { border-color: #bd3131; }
.hint { margin-top: 6px; color: #7b8799; font-size: 12px; line-height: 1.5; }
.notice {
margin-top: 18px;
padding: 12px 14px;
border: 1px solid #bcdcca;
border-radius: 6px;
background: #f0f8f4;
color: #176542;
font-size: 13px;
line-height: 1.55;
}
.notice.error { border-color: #ebc1c1; background: #fff5f5; color: #a52a2a; }
.notice.info { border-color: #c8d7ee; background: #f3f7fd; color: #315b91; }
.actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 28px;
padding-top: 20px;
border-top: 1px solid #e8ebf0;
}
.btn {
min-width: 104px;
height: 40px;
padding: 0 17px;
border: 1px solid #c8d0dc;
border-radius: 5px;
background: #fff;
color: #344056;
font-weight: 650;
}
.btn:hover { border-color: #8fa1ba; background: #f7f8fa; }
.btn.primary { border-color: #2f6fed; background: #2f6fed; color: #fff; }
.btn.primary:hover { border-color: #245fcf; background: #245fcf; }
.btn:disabled { cursor: not-allowed; border-color: #d7dce4; background: #e9edf2; color: #939dad; }
.summary { display: grid; gap: 1px; overflow: hidden; border: 1px solid #dfe4ec; border-radius: 6px; background: #dfe4ec; }
.summary-row { display: grid; grid-template-columns: 148px 1fr; gap: 16px; padding: 13px 15px; background: #fff; font-size: 13px; }
.summary-key { color: #707c90; }
.summary-value { min-width: 0; overflow-wrap: anywhere; color: #263248; font-weight: 600; }
.progress-list { display: grid; gap: 12px; margin-top: 8px; }
.progress-item { display: flex; align-items: center; gap: 12px; color: #6b7689; font-size: 14px; }
.progress-dot { width: 10px; height: 10px; border: 2px solid #aab3c1; border-radius: 50%; }
.progress-item.running { color: #254e86; font-weight: 600; }
.progress-item.running .progress-dot { border-color: #2f6fed; background: #2f6fed; box-shadow: 0 0 0 4px #eaf1fd; }
.progress-item.done { color: #187149; }
.progress-item.done .progress-dot { border-color: #25805a; background: #25805a; }
.complete-mark {
width: 58px;
height: 58px;
display: grid;
place-items: center;
margin-bottom: 18px;
border-radius: 50%;
background: #e8f5ee;
color: #187149;
font-size: 28px;
font-weight: 800;
}
.hidden { display: none !important; }
@media (max-width: 760px) {
.topbar { height: 58px; padding: 0 18px; }
.topbar-label { display: none; }
.shell { width: calc(100% - 24px); margin: 18px auto 30px; display: block; }
.steps { display: grid; grid-template-columns: repeat(4, 1fr); margin-bottom: 14px; padding: 10px 4px; }
.step { display: flex; min-height: auto; padding: 4px; flex-direction: column; align-items: center; gap: 5px; text-align: center; }
.step:not(:last-child)::after { left: calc(50% + 18px); top: 18px; width: calc(100% - 36px); height: 1px; }
.step-copy { padding: 0; }
.step-title { font-size: 11px; }
.step-state { display: none; }
.panel { min-height: 0; padding: 23px 18px 20px; }
h1 { font-size: 21px; }
.form-grid { grid-template-columns: 1fr; gap: 15px; }
.field.full { grid-column: auto; }
.summary-row { grid-template-columns: 1fr; gap: 5px; }
.actions { flex-wrap: wrap-reverse; }
.btn { flex: 1 1 120px; }
}
</style>
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="brand-mark">KD</span>
<span class="brand-name">凯迪 ERP + OA</span>
</div>
<span class="topbar-label">首次安装向导</span>
</header>
<main class="shell">
<nav class="steps" aria-label="安装步骤">
<div class="step active" data-step-nav="1"><span class="step-index">1</span><span class="step-copy"><span class="step-title">环境检查</span><span class="step-state">运行环境</span></span></div>
<div class="step" data-step-nav="2"><span class="step-index">2</span><span class="step-copy"><span class="step-title">数据库</span><span class="step-state">PostgreSQL</span></span></div>
<div class="step" data-step-nav="3"><span class="step-index">3</span><span class="step-copy"><span class="step-title">管理员</span><span class="step-state">初始账号</span></span></div>
<div class="step" data-step-nav="4"><span class="step-index">4</span><span class="step-copy"><span class="step-title">安装</span><span class="step-state">初始化系统</span></span></div>
</nav>
<section class="panel">
<div id="fatal" class="hidden">
<div class="panel-head"><h1>安装链接不可用</h1><p class="subtitle">请回到服务器终端,使用安装程序输出的完整链接重新访问。</p></div>
<div class="notice error" id="fatal-message">链接缺少一次性安装令牌。</div>
</div>
<div id="view-1">
<div class="panel-head"><h1>环境检查</h1><p class="subtitle">确认安装程序和服务器运行环境已经准备就绪。</p></div>
<div class="section check-list">
<div class="check-row"><div><div class="check-label">Java 运行环境</div><div class="check-detail" id="java-detail">正在检测</div></div><span class="badge wait" id="java-badge">检测中</span></div>
<div class="check-row"><div><div class="check-label">安装程序</div><div class="check-detail" id="installer-detail">正在读取版本</div></div><span class="badge wait" id="installer-badge">检测中</span></div>
<div class="check-row"><div><div class="check-label">生产数据库要求</div><div class="check-detail">PostgreSQL 15 或更高版本</div></div><span class="badge">支持</span></div>
<div class="check-row"><div><div class="check-label">Redis</div><div class="check-detail">当前版本没有 Redis 运行依赖</div></div><span class="badge">无需配置</span></div>
</div>
<div id="status-message" class="notice info">正在连接本机安装服务。</div>
<div class="actions"><button class="btn primary" id="start-button" disabled>开始配置</button></div>
</div>
<div id="view-2" class="hidden">
<div class="panel-head"><h1>连接 PostgreSQL</h1><p class="subtitle">填写生产数据库连接信息,并完成真实连接测试。</p></div>
<form id="database-form" class="section form-grid" autocomplete="off">
<div class="field"><label class="required" for="db-host">服务器地址</label><input id="db-host" name="host" value="127.0.0.1" maxlength="253" required></div>
<div class="field"><label class="required" for="db-port">端口</label><input id="db-port" name="port" type="number" min="1" max="65535" value="5432" required></div>
<div class="field"><label class="required" for="db-name">数据库名</label><input id="db-name" name="database" value="kaidi_erp" maxlength="63" required></div>
<div class="field"><label class="required" for="db-ssl">SSL 模式</label><select id="db-ssl" name="sslMode"><option value="prefer">prefer</option><option value="require">require</option><option value="verify-full">verify-full</option><option value="verify-ca">verify-ca</option><option value="disable">disable</option><option value="allow">allow</option></select></div>
<div class="field full"><label class="required" for="db-user">数据库账号</label><input id="db-user" name="username" maxlength="128" autocomplete="username" required></div>
<div class="field full"><label class="required" for="db-password">数据库密码</label><input id="db-password" name="password" type="password" maxlength="500" autocomplete="new-password" required><div class="hint">密码只提交给当前服务器上的安装程序,不会显示在确认页。</div></div>
</form>
<div id="db-message" class="notice info">连接测试通过后才能继续。</div>
<div class="actions"><button class="btn" data-back="1">上一步</button><button class="btn" id="test-db-button">测试连接</button><button class="btn primary" id="database-next" disabled>下一步</button></div>
</div>
<div id="view-3" class="hidden">
<div class="panel-head"><h1>设置管理员</h1><p class="subtitle">创建系统的第一个管理员账号。</p></div>
<form id="admin-form" class="section form-grid" autocomplete="off">
<div class="field full"><label class="required" for="admin-login">管理员账号</label><input id="admin-login" name="loginName" value="admin" maxlength="64" autocomplete="username" required><div class="hint">以字母开头,可使用字母、数字、点、下划线和短横线。</div></div>
<div class="field full"><label class="required" for="admin-name">管理员姓名</label><input id="admin-name" name="displayName" maxlength="100" required></div>
<div class="field"><label class="required" for="admin-password">管理员密码</label><input id="admin-password" name="password" type="password" minlength="8" maxlength="200" autocomplete="new-password" required></div>
<div class="field"><label class="required" for="admin-confirm">确认密码</label><input id="admin-confirm" name="confirmPassword" type="password" minlength="8" maxlength="200" autocomplete="new-password" required></div>
</form>
<div id="admin-message" class="notice info">密码至少 8 位,请使用仅管理员本人知道的强密码。</div>
<div class="actions"><button class="btn" data-back="2">上一步</button><button class="btn primary" id="admin-next">下一步</button></div>
</div>
<div id="view-4" class="hidden">
<div id="confirm-view">
<div class="panel-head"><h1>确认并安装</h1><p class="subtitle">确认连接目标和管理员账号。安装开始后会执行数据库迁移。</p></div>
<div class="section summary" id="summary"></div>
<div class="notice info">安装不会覆盖已经包含业务用户的数据库。检测到现有系统时会立即停止。</div>
<div class="actions"><button class="btn" data-back="3">上一步</button><button class="btn primary" id="install-button">开始安装</button></div>
</div>
<div id="installing-view" class="hidden">
<div class="panel-head"><h1>正在安装</h1><p class="subtitle">请保持当前页面打开。</p></div>
<div class="section progress-list">
<div class="progress-item running" data-progress="1"><span class="progress-dot"></span><span>验证 PostgreSQL 连接</span></div>
<div class="progress-item" data-progress="2"><span class="progress-dot"></span><span>执行数据库迁移</span></div>
<div class="progress-item" data-progress="3"><span class="progress-dot"></span><span>创建管理员账号</span></div>
<div class="progress-item" data-progress="4"><span class="progress-dot"></span><span>启动正式服务并写入安装锁</span></div>
</div>
<div id="install-message" class="notice info">正在初始化,数据库规模较大时可能需要几分钟。</div>
</div>
<div id="complete-view" class="hidden">
<div class="complete-mark"></div>
<div class="panel-head"><h1>安装完成</h1><p class="subtitle">正式服务已经通过健康检查,安装向导已被删除。</p></div>
<div class="notice">管理员账号已创建,可以进入系统登录。</div>
<div class="actions"><button class="btn primary" id="enter-button">进入系统</button></div>
</div>
</div>
</section>
</main>
<script>
(() => {
const query = new URLSearchParams(location.search);
const queryToken = query.get('token') || '';
if (queryToken) sessionStorage.setItem('kaidi.setup.token', queryToken);
const token = queryToken || sessionStorage.getItem('kaidi.setup.token') || '';
if (queryToken) {
query.delete('token');
const clean = location.pathname + (query.toString() ? `?${query}` : '') + location.hash;
history.replaceState(null, '', clean);
}
const state = { step: 1, database: null, databaseFingerprint: '', administrator: null, installing: false };
const $ = (selector) => document.querySelector(selector);
const $$ = (selector) => Array.from(document.querySelectorAll(selector));
function showFatal(message) {
['#view-1', '#view-2', '#view-3', '#view-4'].forEach((id) => $(id).classList.add('hidden'));
$('#fatal').classList.remove('hidden');
$('#fatal-message').textContent = message;
}
async function api(path, options = {}) {
const response = await fetch(path, {
...options,
headers: { 'Content-Type': 'application/json', 'X-Setup-Token': token, ...(options.headers || {}) },
});
let payload;
try { payload = await response.json(); } catch { throw new Error('安装服务响应无效'); }
if (!response.ok || payload.code !== 0) throw new Error(payload.message || '请求失败');
return payload.data;
}
function go(step) {
state.step = step;
for (let i = 1; i <= 4; i += 1) {
$(`#view-${i}`).classList.toggle('hidden', i !== step);
const nav = $(`[data-step-nav="${i}"]`);
nav.classList.toggle('active', i === step);
nav.classList.toggle('done', i < step);
nav.querySelector('.step-index').textContent = i < step ? '✓' : String(i);
}
window.scrollTo({ top: 0, behavior: 'smooth' });
}
function databaseValue() {
const data = new FormData($('#database-form'));
return {
host: String(data.get('host') || '').trim(),
port: Number(data.get('port')),
database: String(data.get('database') || '').trim(),
username: String(data.get('username') || '').trim(),
password: String(data.get('password') || ''),
sslMode: String(data.get('sslMode') || ''),
};
}
function fingerprint(database) { return JSON.stringify(database); }
function invalidateDatabaseTest() {
if (state.databaseFingerprint && fingerprint(databaseValue()) !== state.databaseFingerprint) {
state.databaseFingerprint = '';
state.database = null;
$('#database-next').disabled = true;
$('#db-message').className = 'notice info';
$('#db-message').textContent = '连接信息已修改,请重新测试。';
}
}
function administratorValue() {
const data = new FormData($('#admin-form'));
return {
loginName: String(data.get('loginName') || '').trim(),
displayName: String(data.get('displayName') || '').trim(),
password: String(data.get('password') || ''),
confirmPassword: String(data.get('confirmPassword') || ''),
};
}
function renderSummary() {
const rows = [
['PostgreSQL', `${state.database.host}:${state.database.port}`],
['数据库', state.database.database],
['数据库账号', state.database.username],
['SSL 模式', state.database.sslMode],
['管理员账号', state.administrator.loginName],
['管理员姓名', state.administrator.displayName],
];
$('#summary').replaceChildren(...rows.map(([key, value]) => {
const row = document.createElement('div');
row.className = 'summary-row';
const label = document.createElement('span');
label.className = 'summary-key';
label.textContent = key;
const content = document.createElement('span');
content.className = 'summary-value';
content.textContent = value;
row.append(label, content);
return row;
}));
}
function setProgress(index) {
$$('[data-progress]').forEach((item) => {
const value = Number(item.dataset.progress);
item.classList.toggle('done', value < index);
item.classList.toggle('running', value === index);
});
}
async function pollFormalService() {
setProgress(4);
$('#install-message').textContent = '初始化完成,正在等待正式服务通过健康检查。';
const deadline = Date.now() + 240000;
while (Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 2000));
try {
const response = await fetch('/api/oa/health', { cache: 'no-store' });
const body = await response.json();
if (response.ok && (body?.data?.status === 'UP' || body?.status === 'UP')) {
$$('[data-progress]').forEach((item) => { item.classList.remove('running'); item.classList.add('done'); });
$('#installing-view').classList.add('hidden');
$('#complete-view').classList.remove('hidden');
sessionStorage.removeItem('kaidi.setup.token');
return;
}
} catch { /* Service is changing from installer to the formal application. */ }
}
$('#install-message').className = 'notice error';
$('#install-message').textContent = '正式服务尚未就绪。请在服务器查看 kaidi-erp 服务日志,安装文件和安装锁不会被误删。';
}
$('#start-button').addEventListener('click', () => go(2));
$$('[data-back]').forEach((button) => button.addEventListener('click', () => go(Number(button.dataset.back))));
$('#database-form').addEventListener('input', invalidateDatabaseTest);
$('#database-form').addEventListener('change', invalidateDatabaseTest);
$('#test-db-button').addEventListener('click', async () => {
const form = $('#database-form');
if (!form.reportValidity()) return;
const button = $('#test-db-button');
const database = databaseValue();
button.disabled = true;
button.textContent = '测试中';
$('#db-message').className = 'notice info';
$('#db-message').textContent = '正在连接 PostgreSQL 并检查 pg_trgm 扩展。';
try {
const result = await api('/api/install/test-database', { method: 'POST', body: JSON.stringify(database) });
state.database = database;
state.databaseFingerprint = fingerprint(database);
$('#database-next').disabled = false;
$('#db-message').className = 'notice';
$('#db-message').textContent = `${result.message}${result.postgresVersion}`;
} catch (error) {
state.database = null;
state.databaseFingerprint = '';
$('#database-next').disabled = true;
$('#db-message').className = 'notice error';
$('#db-message').textContent = error.message;
} finally {
button.disabled = false;
button.textContent = '测试连接';
}
});
$('#database-next').addEventListener('click', () => {
if (!state.database || fingerprint(databaseValue()) !== state.databaseFingerprint) return invalidateDatabaseTest();
go(3);
});
$('#admin-next').addEventListener('click', () => {
const form = $('#admin-form');
if (!form.reportValidity()) return;
const administrator = administratorValue();
if (administrator.password !== administrator.confirmPassword) {
$('#admin-message').className = 'notice error';
$('#admin-message').textContent = '两次输入的管理员密码不一致。';
return;
}
state.administrator = {
loginName: administrator.loginName,
displayName: administrator.displayName,
password: administrator.password,
};
$('#admin-message').className = 'notice info';
$('#admin-message').textContent = '管理员信息已填写。';
renderSummary();
go(4);
});
$('#install-button').addEventListener('click', async () => {
if (state.installing || !state.database || !state.administrator) return;
state.installing = true;
$('#confirm-view').classList.add('hidden');
$('#installing-view').classList.remove('hidden');
setProgress(1);
try {
const request = { database: state.database, administrator: state.administrator };
setTimeout(() => setProgress(2), 700);
try {
await api('/api/install/complete', { method: 'POST', body: JSON.stringify(request) });
} catch (error) {
// The installer intentionally exits immediately after committing. A
// browser may see the TCP connection close before the JSON response
// is flushed; the formal health probe is the authoritative result.
if (!(error instanceof TypeError) && error.message !== '安装服务响应无效') throw error;
}
setProgress(3);
state.administrator.password = '';
state.database.password = '';
await pollFormalService();
} catch (error) {
state.installing = false;
$('#install-message').className = 'notice error';
$('#install-message').textContent = error.message;
const actions = document.createElement('div');
actions.className = 'actions';
const back = document.createElement('button');
back.className = 'btn';
back.textContent = '返回检查';
back.addEventListener('click', () => location.reload());
actions.append(back);
$('#installing-view').append(actions);
}
});
$('#enter-button').addEventListener('click', () => location.assign('/'));
async function boot() {
if (!token) return showFatal('链接缺少一次性安装令牌。请使用服务器终端输出的完整链接。');
try {
const status = await api('/api/install/status');
if (status.locked) return showFatal('系统已经完成安装,安装向导已锁定。');
if (status.pending) {
go(4);
$('#confirm-view').classList.add('hidden');
$('#installing-view').classList.remove('hidden');
return pollFormalService();
}
$('#java-detail').textContent = `Java ${status.javaVersion},最低要求 Java ${status.minimumJava}`;
$('#java-badge').className = 'badge';
$('#java-badge').textContent = '通过';
$('#installer-detail').textContent = `安装包版本 ${status.version}`;
$('#installer-badge').className = 'badge';
$('#installer-badge').textContent = '通过';
$('#status-message').className = 'notice';
$('#status-message').textContent = '服务器环境检查通过,可以开始配置。';
$('#start-button').disabled = false;
} catch (error) {
showFatal(error.message);
}
}
boot();
})();
</script>
</body>
</html>
@@ -0,0 +1,48 @@
package com.kaidi.oa.install;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.forwardedUrl;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest(
classes = InstallerApplication.class,
properties = {
"erp.install.token=test-token",
"erp.install.version=test",
"erp.install.exit-after-complete=false",
"erp.install.install-root=build/installer-test",
"erp.install.config-file=build/installer-test/config/erp.env",
"erp.install.pending-file=build/installer-test/state/install.pending",
"erp.install.lock-file=build/installer-test/state/install.lock",
"erp.install.operation-lock-file=build/installer-test/state/install-operation.lock"
})
@AutoConfigureMockMvc
class InstallerApiTest {
@Autowired
private MockMvc mockMvc;
@Test
void setupApiRequiresOneTimeToken() throws Exception {
mockMvc.perform(get("/api/install/status"))
.andExpect(status().isForbidden())
.andExpect(content().json("{\"code\":40301}"));
}
@Test
void setupStatusAndStaticPageAreAvailableWithToken() throws Exception {
mockMvc.perform(get("/api/install/status").header("X-Setup-Token", "test-token"))
.andExpect(status().isOk())
.andExpect(content().json("{\"code\":0,\"data\":{\"minimumPostgres\":15,\"redisRequired\":false,\"locked\":false}}", false));
mockMvc.perform(get("/"))
.andExpect(status().isOk())
.andExpect(forwardedUrl("index.html"));
}
}
@@ -4,7 +4,6 @@ import com.kaidi.oa.domain.QualElecLicense;
import com.kaidi.oa.repository.QualElecLicenseRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.time.LocalDate;
@@ -17,7 +16,7 @@ import java.time.LocalDate;
*
* @Order(225) 在所有主数据 Seeder(最高 220)之后运行;幂等(count > 0 跳过)。
*/
@Component
@DemoSeed
@Order(225)
public class AdminQualElecSeeder implements CommandLineRunner {
@@ -14,7 +14,6 @@ import com.kaidi.oa.repository.StaffDossierItemRepository;
import com.kaidi.oa.repository.StaffDossierRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.time.Instant;
@@ -26,7 +25,7 @@ import java.time.LocalDate;
* 幂等 + 非破坏:以各表是否已有数据为哨兵,仅在空表时灌入少量演示数据,让深水页面首屏不空。
* 日期以"当前日期 +/- 偏移"动态生成,使到期分级预警在任何时间运行都有 即将到期/逾期 样本。
*/
@Component
@DemoSeed
@Order(6)
public class AdminQualSeeder implements CommandLineRunner {
@@ -6,7 +6,6 @@ import com.kaidi.oa.repository.ArchiveRepository;
import com.kaidi.oa.repository.ArchiveVersionRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.time.Instant;
@@ -26,7 +25,7 @@ import java.time.Instant;
* <p>幂等:同 sourceType 在 archive 表已有记录则跳过整批(count 检查)。
* @Order(205) 在所有基础 Seeder 后运行。
*/
@Component
@DemoSeed
@Order(205)
public class ArchiveCrossModuleSeeder implements CommandLineRunner {
@@ -11,7 +11,6 @@ import com.kaidi.oa.repository.LegalRegulationRepository;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@@ -31,7 +30,7 @@ import java.time.temporal.ChronoUnit;
* 幂等:各表 count > 0 即跳过。
* @Order(202) 在 FertPkgAccountingSeeder(@Order 201) 之后运行。
*/
@Component
@DemoSeed
@Order(202)
public class AuditDeptSeeder implements ApplicationRunner {
@@ -5,7 +5,6 @@ import com.kaidi.oa.repository.BidTenderCollabRepository;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.time.Instant;
@@ -16,7 +15,7 @@ import java.time.Instant;
* 让前端看板活体可见且流程闭环可演示。
* 幂等:若已有数据则跳过,防止重启重复写入。
*/
@Component
@DemoSeed
@Order(215)
public class BidTenderCollabSeeder implements ApplicationRunner {
@@ -8,7 +8,6 @@ import com.kaidi.oa.repository.BidRepository;
import com.kaidi.oa.repository.BidTenderDocRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.time.LocalDate;
@@ -18,7 +17,7 @@ import java.time.LocalDate;
*
* @Order(215) 在所有主数据 Seeder 之后运行;幂等(count > 0 则跳过)。
*/
@Component
@DemoSeed
@Order(215)
public class BidTenderDocSeeder implements CommandLineRunner {
@@ -8,7 +8,6 @@ import com.kaidi.oa.repository.OrgBranchRepository;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.util.ArrayList;
@@ -32,7 +31,7 @@ import java.util.List;
*
* @Order(15) 在 DataSeeder(@Order 1) 之后独立运行。
*/
@Component
@DemoSeed
@Order(15)
public class BranchFinDemoSeeder implements ApplicationRunner {
@@ -7,7 +7,6 @@ import com.kaidi.oa.repository.EmployeeShareMaterialRepository;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.util.ArrayList;
@@ -21,7 +20,7 @@ import java.util.List;
*
* @Order(220) 在所有主数据 Seeder 之后运行;幂等(count > 0 则跳过)。
*/
@Component
@DemoSeed
@Order(220)
public class BrandCultureGapSeeder implements ApplicationRunner {
@@ -8,7 +8,6 @@ import com.kaidi.oa.repository.ContentTaskRepository;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.util.ArrayList;
@@ -20,7 +19,7 @@ import java.util.List;
* 补全两个"活体返回空/无数据"缺口,让前端物料管理与任务分派功能可见可演示。
* @Order(22),在 DataSeeder(@Order 1) 之后运行,幂等(count > 0 跳过)。
*/
@Component
@DemoSeed
@Order(22)
public class BrandCultureSeeder implements ApplicationRunner {
@@ -12,7 +12,6 @@ import com.kaidi.oa.repository.StandardCostRepository;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@@ -37,7 +36,7 @@ import java.time.temporal.ChronoUnit;
* 幂等:各表 count() > 0 即跳过,重启不重复插入。
* @Order(9) 在 DataSeeder(@Order 1) 之后独立运行。
*/
@Component
@DemoSeed
@Order(9)
public class CostCtrlDemoSeeder implements ApplicationRunner {
@@ -20,7 +20,6 @@ import com.kaidi.oa.repository.RdEbomRepository;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@@ -50,7 +49,7 @@ import java.time.temporal.ChronoUnit;
*
* 幂等:各表 count() > 0 则跳过,重启安全。
*/
@Component
@DemoSeed
@Order(215)
public class CostCtrlGapSeeder implements ApplicationRunner {
@@ -8,7 +8,6 @@ import com.kaidi.oa.repository.CslTimesheetRepository;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@@ -21,7 +20,7 @@ import java.time.Instant;
*
* 幂等:按 code 查重后跳过,重启不重复插入。
*/
@Component
@DemoSeed
@Order(7)
public class CslDemoSeeder implements ApplicationRunner {
@@ -7,7 +7,6 @@ import com.kaidi.oa.repository.HrCultureKpiRepository;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.util.ArrayList;
@@ -21,7 +20,7 @@ import java.util.List;
*
* @Order(225) 在 BrandCultureGapSeeder(@Order 220) 之后运行,幂等(count>0跳过)。
*/
@Component
@DemoSeed
@Order(225)
public class CultureBriefingKpiSeeder implements ApplicationRunner {
@@ -9,7 +9,6 @@ import com.kaidi.oa.repository.HrEmpSatisfactionSurveyRepository;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.time.Instant;
@@ -28,7 +27,7 @@ import java.util.List;
* @Order(50),在 DataSeeder(@Order 1) 及 BrandCultureSeeder(@Order 22) 之后运行。
* 幂等:CultureGoal 有数据时跳过全部种子。
*/
@Component
@DemoSeed
@Order(50)
public class CultureProjectAssessSeeder implements ApplicationRunner {
@@ -158,7 +158,6 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.time.Instant;
@@ -171,7 +170,7 @@ import java.util.List;
* in-flight form instances, plus org/users/roles, meetings, documents,
* announcements and schedule events.
*/
@Component
@DemoSeed
@Order(1)
public class DataSeeder implements CommandLineRunner {
@@ -7,7 +7,6 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.time.Instant;
@@ -15,7 +14,7 @@ import java.time.Instant;
* 申报成果库演示数据补种(Gap 4:活体 decl-achievements 0条,功能可达但未预置演示数据)。
* Order(3)=在 DataSeeder(Order 1)/AdminQualSeeder 之后运行,避免依赖竞争。
*/
@Component
@DemoSeed
@Order(3)
public class DeclAchievementSeeder implements CommandLineRunner {
@@ -0,0 +1,18 @@
package com.kaidi.oa.seed;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component
@ConditionalOnProperty(name = "oa.seed.demo", havingValue = "true", matchIfMissing = true)
public @interface DemoSeed {
}
@@ -10,7 +10,6 @@ import com.kaidi.oa.repository.FertPkgMaterialLedgerRepository;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@@ -27,7 +26,7 @@ import java.time.Instant;
* 幂等:按唯一编码查重后跳过,重启不重复插入。
* Order(201) 保证在 DataSeeder(200) 之后执行。
*/
@Component
@DemoSeed
@Order(201)
public class FertPkgAccountingSeeder implements ApplicationRunner {
@@ -14,7 +14,6 @@ import com.kaidi.oa.repository.FinRdTimesheetRepository;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@@ -35,7 +34,7 @@ import java.time.temporal.ChronoUnit;
*
* @Order(13) 在 FinDeptSeeder(@Order 12) 之后运行。
*/
@Component
@DemoSeed
@Order(13)
public class FinDeptDeepSeeder implements ApplicationRunner {
@@ -8,7 +8,6 @@ import com.kaidi.oa.repository.TaxFilingRepository;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@@ -26,7 +25,7 @@ import java.time.temporal.ChronoUnit;
*
* @Order(215) 在既有 FinDeptSeeder(@Order 12)、BranchFinDemoSeeder(@Order 15) 之后。
*/
@Component
@DemoSeed
@Order(215)
public class FinDeptGapSeeder implements ApplicationRunner {
@@ -14,7 +14,6 @@ import com.kaidi.oa.repository.FinVoucherTemplateRepository;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@@ -35,7 +34,7 @@ import java.time.temporal.ChronoUnit;
*
* @Order(12) 在 DataSeeder(@Order 1) 之后运行。
*/
@Component
@DemoSeed
@Order(12)
public class FinDeptSeeder implements ApplicationRunner {
@@ -15,7 +15,6 @@ import com.kaidi.oa.repository.FinVoucherTemplateRepository;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.time.Instant;
@@ -36,7 +35,7 @@ import java.util.List;
*
* @Order(20) DataSeeder(@Order 1) BranchFinDemoSeeder(@Order 15) 之后运行
*/
@Component
@DemoSeed
@Order(20)
public class FinPaymentDemoSeeder implements ApplicationRunner {
@@ -13,7 +13,6 @@ import com.kaidi.oa.repository.PmtBankRelationRepository;
import com.kaidi.oa.repository.RepaymentPlanRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.time.Instant;
@@ -24,7 +23,7 @@ import java.time.Instant;
* 只在对应表为空时执行防止重复@Order(20) DataSeeder(@Order(1)) 之后运行
*/
@Order(20)
@Component
@DemoSeed
public class FinancingDomainSeeder implements CommandLineRunner {
private final PmtBankRelationRepository bankRelRepo;
@@ -4,7 +4,6 @@ import com.kaidi.oa.domain.HrLaborTemplate;
import com.kaidi.oa.repository.HrLaborTemplateRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.time.LocalDate;
@@ -17,7 +16,7 @@ import java.time.LocalDate;
*
* 幂等count > 0 跳过@Order(226) AdminQualElecSeeder(225) 之后运行
*/
@Component
@DemoSeed
@Order(226)
public class HrLaborTemplateSeeder implements CommandLineRunner {
@@ -10,7 +10,6 @@ import com.kaidi.oa.repository.HrRecruitCandidateRepository;
import com.kaidi.oa.repository.HrRecruitJobRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.time.LocalDate;
@@ -25,7 +24,7 @@ import java.time.LocalDate;
* - HrInterviewQuestion 5 条面试题AI 生成标记
* - HrAiDocDraft 4 AI 生成文档入职资料/岗位说明/员工手册/转正考核多状态
*/
@Component
@DemoSeed
@Order(203)
public class HrRecruitSeeder implements CommandLineRunner {
@@ -15,7 +15,6 @@ import com.kaidi.oa.repository.ItSecPolicyConfigRepository;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.time.Instant;
@@ -27,7 +26,7 @@ import java.time.ZoneOffset;
* @Order(204) 在所有主数据Seeder后运行幂等count > 0 则跳过
*/
@Order(204)
@Component
@DemoSeed
public class ItDeptDemoSeeder implements ApplicationRunner {
private final ItMonitorItemRepository monitorRepo;
@@ -6,7 +6,6 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
@@ -19,7 +18,7 @@ import java.time.temporal.ChronoUnit;
*
* @Order(205) 在所有主数据 Seeder 之后幂等count > 0 则跳过
*/
@Component
@DemoSeed
@Order(205)
public class LabElnAccessLogSeeder implements CommandLineRunner {
@@ -6,7 +6,6 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.time.Instant;
@@ -16,7 +15,7 @@ import java.time.Instant;
* labkanban.vue 活体展示多状态卡片
* Order(8) = 在所有基础 Seeder 之后FtsIndexRunner(200) 之前
*/
@Component
@DemoSeed
@Order(8)
public class LabTestKanbanSeeder implements CommandLineRunner {
@@ -11,7 +11,6 @@ import com.kaidi.oa.repository.LegalCreditInvestRepository;
import com.kaidi.oa.repository.LitigationCaseRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.time.Instant;
@@ -27,7 +26,7 @@ import java.time.Instant;
* @Order(20) DataSeeder(@Order 1) 之后运行幂等count>0 则跳过
*/
@Order(20)
@Component
@DemoSeed
public class LegalDemoSeeder implements CommandLineRunner {
private final LitigationCaseRepository caseRepo;
@@ -6,7 +6,6 @@ import com.kaidi.oa.repository.ComplianceAnonReportRepository;
import com.kaidi.oa.repository.LegalPolicyRevisionRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.time.Instant;
@@ -17,7 +16,7 @@ import java.time.Instant;
* @Order(21) LegalDemoSeeder(@Order 20) 之后幂等
*/
@Order(21)
@Component
@DemoSeed
public class LegalPolicyAnonSeeder implements CommandLineRunner {
private final LegalPolicyRevisionRepository revisionRepo;
@@ -6,7 +6,6 @@ import com.kaidi.oa.repository.MarketCampaignRepository;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.util.ArrayList;
@@ -23,7 +22,7 @@ import java.util.List;
*
* @Order(10) DataSeeder(@Order 1) 之后独立运行幂等count > 0 则跳过
*/
@Component
@DemoSeed
@Order(10)
public class MarketCampaignSeeder implements ApplicationRunner {
@@ -7,7 +7,6 @@ import com.kaidi.oa.repository.SysDeptRepository;
import com.kaidi.oa.repository.SysUserRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
@@ -23,7 +22,7 @@ import java.util.Map;
* Idempotent + non-destructive: runs after the base seeder, only adds users that are
* missing (guard on a sentinel login). All accounts use password 123456.
*/
@Component
@DemoSeed
@Order(2)
public class OrgEnrichmentSeeder implements CommandLineRunner {
@@ -7,7 +7,6 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.time.Instant;
@@ -21,7 +20,7 @@ import java.time.Instant;
* 注意 seeder 独立于 DataSeederOrder=2 DataSeeder Order=1 之后运行
* 不修改 DataSeeder 的大构造函数
*/
@Component
@DemoSeed
@Order(2)
public class RdDeclPlanSeeder implements CommandLineRunner {
@@ -7,7 +7,6 @@ import com.kaidi.oa.repository.SewagePlantBenchmarkRepository;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@@ -23,7 +22,7 @@ import java.time.Instant;
*
* 幂等各表 count == 0 才插入
*/
@Component
@DemoSeed
@Order(206)
public class SewageBenchmarkSeeder implements ApplicationRunner {
@@ -8,7 +8,6 @@ import com.kaidi.oa.repository.SewageExtLabCommissionRepository;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
@@ -22,7 +21,7 @@ import java.time.temporal.ChronoUnit;
*
* 幂等linkageRepo.count() == 0 才插入commRepo.count() == 0 才插入
*/
@Component
@DemoSeed
@Order(30)
public class SewageCrossDeptSeeder implements ApplicationRunner {
@@ -11,7 +11,6 @@ import com.kaidi.oa.repository.SupervisionProjectRepository;
import com.kaidi.oa.repository.SvIndepClaimRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.util.List;
@@ -32,7 +31,7 @@ import java.util.List;
* <p>@Order(210) DataSeeder(@Order 1) 之后运行幂等SvIndepClaim.count>0 则跳过
*/
@Order(210)
@Component
@DemoSeed
public class SvSupervisionMinuteSeeder implements CommandLineRunner {
private final SvIndepClaimRepository claimRepo;
@@ -8,7 +8,6 @@ import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.stereotype.Component;
import java.io.InputStream;
@@ -21,7 +20,7 @@ import java.io.InputStream;
* JSON file under resources/seed-templates and rebuilding adds templates without
* any code change. This is how 凯迪科技's real approval processes are catalogued.
*/
@Component
@DemoSeed
@Order(100)
public class TemplateJsonSeeder implements CommandLineRunner {
@@ -10,7 +10,6 @@ import com.kaidi.oa.repository.WwMsdsFileRepository;
import com.kaidi.oa.repository.WwSamplingTaskRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.time.Instant;
@@ -24,7 +23,7 @@ import java.time.Instant;
* 4. WwSamplingTask废水采样任务演示数据覆盖不同状态和超标场景 Gap-11 桥接演示
*/
@Order(9)
@Component
@DemoSeed
public class WwOpsSeeder implements CommandLineRunner {
private final WwMsdsFileRepository msdsRepo;
@@ -1,19 +1,41 @@
package com.kaidi.oa.web;
import com.kaidi.oa.common.ApiResp;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.Map;
/** Liveness probe. */
/** Readiness probe used by the installer and online-update rollback gate. */
@RestController
@RequestMapping("/api/oa")
public class HealthController {
private final DataSource dataSource;
public HealthController(DataSource dataSource) {
this.dataSource = dataSource;
}
@GetMapping("/health")
public ApiResp<Map<String, String>> health() {
return ApiResp.ok(Map.of("status", "UP"));
public ResponseEntity<ApiResp<Map<String, String>>> health() {
try (Connection connection = dataSource.getConnection();
Statement statement = connection.createStatement();
ResultSet result = statement.executeQuery("SELECT 1")) {
if (connection.isValid(2) && result.next() && result.getInt(1) == 1) {
return ResponseEntity.ok(ApiResp.ok(Map.of("status", "UP", "database", "UP")));
}
} catch (Exception ignored) {
// Health responses deliberately avoid exposing connection details.
}
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(new ApiResp<>(50301, "database unavailable", Map.of("status", "DOWN", "database", "DOWN")));
}
}
@@ -54,6 +54,8 @@ logging:
# 要真正产出 AI 结果:把 api-key 填上,或设置环境变量 ANTHROPIC_API_KEY。
# ---------------------------------------------------------------------------
oa:
seed:
demo: ${OA_SEED_DEMO:true}
ai:
api-key: ""
model: claude-haiku-4-5-20251001