From 271eac382ee0aeee569cdd661c2b7a6b0d759de9 Mon Sep 17 00:00:00 2001 From: Qiufeng Date: Mon, 17 Aug 2026 22:05:10 +0800 Subject: [PATCH] fix: harden first-run database setup --- README.md | 27 +-- .../finance/setup/SetupLockedController.java | 2 +- .../com/kaidi/finance/setup/SetupService.java | 213 ++++++++++++++++-- .../com/kaidi/finance/setup/SetupViews.java | 1 + .../SetupApplicationIntegrationTest.java | 18 ++ .../finance/setup/SetupReadinessTest.java | 31 +++ frontend/e2e/setup-wizard.e2e.ts | 63 +++++- frontend/src/api/setup.ts | 8 +- frontend/src/pages/setup/index.vue | 72 +++++- frontend/tests/setup-api.test.ts | 44 ++++ 10 files changed, 429 insertions(+), 50 deletions(-) create mode 100644 backend/src/test/java/com/kaidi/finance/setup/SetupReadinessTest.java create mode 100644 frontend/tests/setup-api.test.ts diff --git a/README.md b/README.md index cc82a32..446dcb5 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ npm run build Gitea API 只用于确认 Release tag 和资产名称;下载地址由受信 API 域名、仓库路径和 tag 重新构造,不采用 Gitea 响应中可能错误指向内网地址的 `browser_download_url`。 本版只支持由运维人员预先准备的外部 MySQL 8.4.x;安装器不会安装 MySQL、创建数据库容器或修改现有 -PostgreSQL 18。PostgreSQL 兼容开发已冻结,不属于本次 Preview.12 发布范围。 +PostgreSQL 18。PostgreSQL 兼容开发已冻结,不属于本次 Preview 发布范围。 执行命令的机器需预装 `bash`、`sudo`、`curl`、`mktemp` 和 `sha256sum`,并能访问目标 Gitea;`jq` 和 Java 由安装器补齐;Java 17 优先使用服务器已有运行时,没有合适版本时才从官方 Azul 下载。数据库服务和数据库客户端均不会被安装。默认首次安装向导直接使用 JDBC 连接浏览器中填写的 @@ -59,14 +59,14 @@ PostgreSQL 18。PostgreSQL 兼容开发已冻结,不属于本次 Preview.12 ### 直接 curl 安装 ```bash -curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.17/install.sh | sudo bash +curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.18/install.sh | sudo bash ``` 这条命令会提示填写 Java 应用端口,直接回车使用 `18080`;随后安装最新签名 Release,并默认进入 `/setup` 安装向导。Java 默认只监听 `127.0.0.1:所选端口`,前端页面、API 和健康检查均由同一端口提供。无人值守安装可直接指定: ```bash -curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.17/install.sh \ +curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.18/install.sh \ | sudo env KAIDI_APP_PORT=19090 bash ``` @@ -74,7 +74,7 @@ curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-previe 时才设置 `KAIDI_SERVER_ADDRESS=0.0.0.0`,通常应保持默认回环绑定并由本机反向代理访问。需要在执行前独立校验安装脚本时使用: ```bash -curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.17/install.sh -o /tmp/kaidi-install.sh +curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.18/install.sh -o /tmp/kaidi-install.sh printf '%s %s\n' bc9001197af843dc323907a98023064ccc4f184e000543d2086b8a6f8161dbc7 /tmp/kaidi-install.sh | sha256sum -c - sudo bash /tmp/kaidi-install.sh rm -f /tmp/kaidi-install.sh @@ -86,7 +86,7 @@ rm -f /tmp/kaidi-install.sh 包装器会在 `sudo` 前校验 `deploy/install.sh` 的固定 SHA-256,再按同一公钥信任链安装最新签名 Release。 ```bash -git clone --branch v1.0.0-preview.17 --depth 1 https://git.awaioi.com/ERP-Team/kaidi.git kaidi-preview +git clone --branch v1.0.0-preview.18 --depth 1 https://git.awaioi.com/ERP-Team/kaidi.git kaidi-preview cd kaidi-preview ./deploy/install-from-git.sh ``` @@ -100,7 +100,7 @@ cd kaidi-preview - 不安装或创建数据库;在 `/setup` 中连接运维人员预先准备的外部 MySQL 8.4.x。 - 识别 `x86_64`、`aarch64`、`armv7` 或 32 位 `i386/i486/i586/i686`;已有 Java 17 直接复用,否则校验 SHA-256 后下载对应的官方 Azul Java 17 JRE。 - 使用安装器内置的 SHA-256 指纹校验 Release 公钥,再用该公钥验证发布清单 RSA 签名。 -- 首次安装默认启用 `/setup` 向导,不在命令行保存数据库密码;向导只接受 MySQL 8.4.x,并在提交前验证 DDL/DML 权限。 +- 首次安装默认启用 `/setup` 向导,不在命令行保存数据库密码;向导只接受 MySQL 8.4.x,并在提交前验证完整迁移权限。 - 安装签名 Release 到 `/opt/kaidi/releases/`,以 `/opt/kaidi/current` 原子切换当前版本。 - 安装 `kaidi-finance.service`、更新监听服务和健康检查;Java 同时托管 TDesign 前端静态资源及 Vue 路由回退。 - 向导只初始化一个由操作者填写的 `SYSTEM_ADMIN` 管理员,不创建项目、财务、资料或演示账号。 @@ -141,11 +141,12 @@ sudo cat /root/kaidi-first-login.txt 32 位服务端镜像,因此 32 位主机需要预先连接一台 MySQL 8.4 数据库,之后仍然只执行一个安装命令: ```bash -curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.17/install.sh | sudo bash +curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.18/install.sh | sudo bash ``` 无论主机架构如何,安装器都不会安装 MySQL、数据库客户端或创建数据库容器。在打开向导前,需要预先创建 `kaidi_finance`,并授予安装账号该库的 -DDL、DML 权限。向导会连接数据库、核验 MySQL 8.4.x 版本并用临时表验证权限,全部通过后 Flyway 才会建表。 +完整迁移权限。向导会连接数据库、核验 MySQL 8.4.x 版本,并实际探测建表、改表、索引、外键、存储过程、 +临时表及读写权限,全部通过后 Flyway 才会建表。 数据库管理员可在 MySQL 8.4 中按实际应用服务器地址执行以下基线 SQL: ```sql @@ -161,7 +162,7 @@ GRANT ALL PRIVILEGES ON kaidi_finance.* TO 'kaidi'@'KAIDI_SERVER_IP'; 管理员数据和运维人员新增的环境变量: ```bash -curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.17/install.sh \ +curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.18/install.sh \ | sudo env KAIDI_REINSTALL=true KAIDI_SETUP_WIZARD=false bash ``` @@ -171,7 +172,7 @@ curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-previe 如果安装器已完成但向导尚未提交,可执行下面的命令重新生成一次性安装码;该恢复路径只接受仍处于向导模式且未锁定的安装,正式模式不会被覆盖。 ```bash -curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.17/install.sh \ +curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.18/install.sh \ | sudo env KAIDI_REINSTALL=true bash ``` @@ -207,8 +208,8 @@ act_runner 提供 `ubuntu-24.04` 标签,并在 tag 发布时执行后端、前 Preview 属性由 SemVer 版本名表达。之后推送 tag 即会构建、测试、签名并发布: ```bash -git tag v1.0.0-preview.17 -git push origin v1.0.0-preview.17 +git tag v1.0.0-preview.18 +git push origin v1.0.0-preview.18 ``` 在线更新使用独立的 TDesign 页面:隔离的系统管理员进入“系统治理 → 系统更新”。权限与配置页只管理用户、角色、数据范围、表单模板和参数版本,不配置系统名称或域名。 @@ -260,7 +261,7 @@ cat /var/lib/kaidi-update/status.json ```bash KAIDI_RELEASE_SIGNING_KEY=/secure/release-signing-private.pem \ KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \ - ./scripts/package-release.sh 1.0.0-preview.17 + ./scripts/package-release.sh 1.0.0-preview.18 KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \ ./scripts/verify-release.sh dist/release ``` diff --git a/backend/src/main/java/com/kaidi/finance/setup/SetupLockedController.java b/backend/src/main/java/com/kaidi/finance/setup/SetupLockedController.java index e7f0bcb..d93cedf 100644 --- a/backend/src/main/java/com/kaidi/finance/setup/SetupLockedController.java +++ b/backend/src/main/java/com/kaidi/finance/setup/SetupLockedController.java @@ -17,6 +17,6 @@ public class SetupLockedController { @GetMapping("/status") @PreAuthorize("true") public ApiResponse status() { - return ApiResponse.ok(new SetupViews.Status(false, true, List.of("MYSQL"), "系统已完成安装")); + return ApiResponse.ok(new SetupViews.Status(false, true, true, List.of("MYSQL"), "系统已完成安装")); } } diff --git a/backend/src/main/java/com/kaidi/finance/setup/SetupService.java b/backend/src/main/java/com/kaidi/finance/setup/SetupService.java index 27dad16..a76a4a8 100644 --- a/backend/src/main/java/com/kaidi/finance/setup/SetupService.java +++ b/backend/src/main/java/com/kaidi/finance/setup/SetupService.java @@ -25,6 +25,9 @@ import java.util.Set; import java.util.UUID; import java.util.concurrent.atomic.AtomicBoolean; import org.flywaydb.core.Flyway; +import org.flywaydb.core.api.FlywayException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.datasource.DataSourceTransactionManager; @@ -37,8 +40,12 @@ import org.springframework.transaction.support.TransactionTemplate; @ConditionalOnProperty(name = "finance.setup.enabled", havingValue = "true") public class SetupService { + private static final Logger LOGGER = LoggerFactory.getLogger(SetupService.class); private static final String MYSQL_PREFIX = "jdbc:mysql://"; private static final List SUPPORTED_TYPES = List.of(DatabaseType.MYSQL.name()); + private static final Set MYSQL_PERMISSION_ERROR_CODES = Set.of(1044, 1045, 1142, 1227, 1370); + private static final long RESTART_TRIGGER_DELAY_MILLIS = 2_000L; + private static final int LOGIN_REDIRECT_DELAY_SECONDS = 10; private final SetupProperties properties; private final AtomicBoolean locked = new AtomicBoolean(false); @@ -56,7 +63,7 @@ public class SetupService { public SetupViews.Status status() { boolean isLocked = locked.get() || Files.exists(Path.of(properties.markerFile())); - return new SetupViews.Status(!isLocked, isLocked, SUPPORTED_TYPES, + return new SetupViews.Status(!isLocked, isLocked, false, SUPPORTED_TYPES, isLocked ? "安装向导已锁定" : "请完成数据库和管理员初始化"); } @@ -74,8 +81,9 @@ public class SetupService { false, "PostgreSQL 连接可用,但当前 Preview 的业务迁移仅支持 MySQL 8.4"); } ConnectionResult connection = testConnection(settings); + validateSchemaForInstallation(settings); return new SetupViews.Connection(true, settings.type().name(), connection.version(), true, - "MySQL 8.4 连接和 DDL/DML 权限验证通过"); + "MySQL 8.4 连接和完整迁移权限验证通过"); } public SetupViews.Completed complete(SetupContracts.CompleteRequest request) { @@ -106,7 +114,7 @@ public class SetupService { if (properties.restartAfterComplete()) { Thread restart = new Thread(() -> { try { - Thread.sleep(750L); + Thread.sleep(RESTART_TRIGGER_DELAY_MILLIS); } catch (InterruptedException exception) { Thread.currentThread().interrupt(); } @@ -116,18 +124,13 @@ public class SetupService { restart.start(); } return new SetupViews.Completed(true, request.adminUsername(), - "安装完成,系统正在切换到正式模式", 10); + "安装完成,系统正在切换到正式模式", LOGIN_REDIRECT_DELAY_SECONDS); } private void migrateAndCreateAdministrator(DatabaseSettings settings, SetupContracts.CompleteRequest request) { try { - ensureCompatibleSchema(settings); - Flyway.configure() - .dataSource(settings.jdbcUrl(), settings.username(), settings.password()) - .locations("classpath:db/migration") - .cleanDisabled(true) - .load() - .migrate(); + validateSchemaForInstallation(settings); + flyway(settings).migrate(); DriverManagerDataSource dataSource = new DriverManagerDataSource(settings.jdbcUrl(), settings.username(), settings.password()); JdbcTemplate jdbc = new JdbcTemplate(dataSource); @@ -137,12 +140,13 @@ public class SetupService { if (exception instanceof SetupException setupException) { throw setupException; } + LOGGER.error("Database migration or administrator initialization failed", exception); throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY, - "DATABASE_INITIALIZATION_FAILED", "数据库迁移或管理员初始化失败,请检查账号的 DDL/DML 权限"); + "DATABASE_INITIALIZATION_FAILED", databaseInitializationFailureMessage(exception)); } } - private void ensureCompatibleSchema(DatabaseSettings settings) { + private boolean ensureCompatibleSchema(DatabaseSettings settings) { JdbcTemplate jdbc = new JdbcTemplate(new DriverManagerDataSource(settings.jdbcUrl(), settings.username(), settings.password())); Integer tableCount = jdbc.queryForObject(""" @@ -160,7 +164,42 @@ public class SetupService { throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY, "DATABASE_NOT_EMPTY", "请选择空数据库,或提供已有 Kaidi Flyway 数据库"); } + return true; } + return false; + } + + private void validateSchemaForInstallation(DatabaseSettings settings) { + try { + if (!ensureCompatibleSchema(settings)) { + return; + } + flyway(settings).validate(); + } catch (SetupException exception) { + throw exception; + } catch (FlywayException exception) { + LOGGER.error("Database migration history validation failed", exception); + throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY, + "DATABASE_MIGRATION_STATE_INVALID", + "数据库迁移历史校验失败,请使用空数据库,或先修复已有迁移状态:" + safeErrorMessage(exception)); + } catch (RuntimeException exception) { + LOGGER.error("Database schema inspection failed", exception); + SQLException sqlException = findSqlException(exception); + String detail = sqlException == null + ? "数据库结构检查失败:" + safeErrorMessage(exception) + : databaseConnectionFailureMessage(sqlException); + throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY, + "DATABASE_SCHEMA_INSPECTION_FAILED", detail); + } + } + + private Flyway flyway(DatabaseSettings settings) { + return Flyway.configure() + .dataSource(settings.jdbcUrl(), settings.username(), settings.password()) + .locations("classpath:db/migration") + .ignoreMigrationPatterns("*:pending") + .cleanDisabled(true) + .load(); } private void initializeInstallation(JdbcTemplate jdbc, SetupContracts.CompleteRequest request) { @@ -280,18 +319,148 @@ public class SetupService { throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY, "MYSQL_VERSION_UNSUPPORTED", "当前版本要求 MySQL 8.4.x,检测到 " + version); } - String probe = "kaidi_setup_probe_" + UUID.randomUUID().toString().replace("-", ""); - try { - statement.execute("CREATE TABLE " + probe + " (id INT NOT NULL PRIMARY KEY)"); - statement.execute("INSERT INTO " + probe + " (id) VALUES (1)"); - statement.execute("UPDATE " + probe + " SET id = 2 WHERE id = 1"); - } finally { - statement.execute("DROP TABLE IF EXISTS " + probe); - } + verifyMigrationPrivileges(connection, statement, settings.type()); return new ConnectionResult(true, version); } catch (SQLException exception) { + LOGGER.warn("Database connection or migration privilege verification failed: SQL state={}, errorCode={}", + exception.getSQLState(), exception.getErrorCode(), exception); throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY, - "DATABASE_CONNECTION_FAILED", "数据库连接或 DDL/DML 权限验证失败"); + "DATABASE_CONNECTION_FAILED", databaseConnectionFailureMessage(exception)); + } + } + + private String databaseConnectionFailureMessage(SQLException exception) { + if (isPermissionError(exception)) { + return "数据库账号缺少完整迁移权限" + sqlErrorSummary(exception); + } + if (exception.getSQLState() != null && exception.getSQLState().startsWith("08")) { + return "数据库连接超时或中断" + sqlErrorSummary(exception); + } + return "数据库连接或完整迁移权限验证失败" + sqlErrorSummary(exception); + } + + private String databaseInitializationFailureMessage(Throwable exception) { + SQLException sqlException = findSqlException(exception); + if (sqlException != null) { + if (isPermissionError(sqlException)) { + return "数据库账号缺少完成迁移所需权限" + sqlErrorSummary(sqlException); + } + if (sqlException.getSQLState() != null && sqlException.getSQLState().startsWith("08")) { + return "数据库迁移期间连接超时或中断" + sqlErrorSummary(sqlException); + } + return "数据库迁移或管理员初始化失败" + sqlErrorSummary(sqlException); + } + return "数据库迁移或管理员初始化失败:" + safeErrorMessage(exception); + } + + private SQLException findSqlException(Throwable exception) { + Throwable current = exception; + while (current != null) { + if (current instanceof SQLException sqlException) { + return sqlException; + } + current = current.getCause(); + } + return null; + } + + private boolean isPermissionError(SQLException exception) { + return MYSQL_PERMISSION_ERROR_CODES.contains(exception.getErrorCode()); + } + + private String sqlErrorSummary(SQLException exception) { + String state = exception.getSQLState() == null ? "UNKNOWN" : exception.getSQLState(); + return "(SQLState " + state + ",错误码 " + exception.getErrorCode() + "):" + + safeErrorMessage(exception); + } + + private String safeErrorMessage(Throwable exception) { + Throwable current = exception; + while (current.getCause() != null) { + current = current.getCause(); + } + String message = current.getMessage() == null ? current.getClass().getSimpleName() : current.getMessage(); + String sanitized = message.replaceAll("(?i)(password|pwd)=([^\\s&;]+)", "$1=***") + .replaceAll("\\s+", " ").trim(); + return sanitized.length() <= 240 ? sanitized : sanitized.substring(0, 240) + "..."; + } + + private void verifyMigrationPrivileges(Connection connection, Statement statement, DatabaseType databaseType) + throws SQLException { + String suffix = UUID.randomUUID().toString().replace("-", ""); + String parentTable = "kaidi_setup_probe_parent_" + suffix; + String childTable = "kaidi_setup_probe_child_" + suffix; + String foreignKey = "fk_setup_probe_" + suffix; + String index = "idx_setup_probe_" + suffix; + String routine = "kaidi_setup_probe_routine_" + suffix; + String temporaryTable = "kaidi_setup_probe_temp_" + suffix; + try { + statement.execute("CREATE TABLE " + parentTable + " (id INT NOT NULL PRIMARY KEY)"); + if (databaseType == DatabaseType.MYSQL) { + statement.execute("CREATE TABLE " + childTable + + " (id INT NOT NULL PRIMARY KEY, parent_id INT NOT NULL, CONSTRAINT " + foreignKey + + " FOREIGN KEY (parent_id) REFERENCES " + parentTable + " (id))"); + statement.execute("ALTER TABLE " + childTable + " ADD COLUMN note VARCHAR(32) NULL"); + statement.execute("CREATE INDEX " + index + " ON " + childTable + " (parent_id)"); + statement.execute("CREATE PROCEDURE " + routine + "() SELECT 1 AS probe_value"); + try (Statement call = connection.createStatement(); + ResultSet result = call.executeQuery("CALL " + routine + "()")) { + if (!result.next() || result.getInt(1) != 1) { + throw new SQLException("Migration privilege routine probe returned an invalid result"); + } + } + statement.execute("INSERT INTO " + parentTable + " (id) VALUES (1)"); + statement.execute("INSERT INTO " + childTable + " (id, parent_id, note) VALUES (1, 1, 'probe')"); + try (ResultSet result = statement.executeQuery("SELECT note FROM " + childTable + " WHERE id = 1")) { + if (!result.next() || !"probe".equals(result.getString(1))) { + throw new SQLException("Migration privilege SELECT probe returned an invalid result"); + } + } + statement.execute("CREATE TEMPORARY TABLE " + temporaryTable + + " (id INT NOT NULL PRIMARY KEY)"); + statement.execute("INSERT INTO " + temporaryTable + " (id) VALUES (1)"); + statement.execute("DROP TEMPORARY TABLE " + temporaryTable); + statement.execute("UPDATE " + childTable + " SET note = 'verified' WHERE id = 1"); + statement.execute("DELETE FROM " + childTable + " WHERE id = 1"); + statement.execute("DELETE FROM " + parentTable + " WHERE id = 1"); + } else { + statement.execute("INSERT INTO " + parentTable + " (id) VALUES (1)"); + statement.execute("UPDATE " + parentTable + " SET id = 2 WHERE id = 1"); + statement.execute("DELETE FROM " + parentTable + " WHERE id = 2"); + } + } catch (SQLException exception) { + cleanupProbeObjects(statement, databaseType, routine, temporaryTable, childTable, parentTable, exception); + throw exception; + } + cleanupProbeObjects(statement, databaseType, routine, temporaryTable, childTable, parentTable, null); + } + + private void cleanupProbeObjects(Statement statement, DatabaseType databaseType, String routine, + String temporaryTable, String childTable, String parentTable, + SQLException original) + throws SQLException { + SQLException cleanupFailure = null; + List cleanupStatements = databaseType == DatabaseType.MYSQL + ? List.of("DROP PROCEDURE IF EXISTS " + routine, "DROP TEMPORARY TABLE IF EXISTS " + temporaryTable, + "DROP TABLE IF EXISTS " + childTable, "DROP TABLE IF EXISTS " + parentTable) + : List.of("DROP TABLE IF EXISTS " + childTable, "DROP TABLE IF EXISTS " + parentTable); + for (String sql : cleanupStatements) { + try { + statement.execute(sql); + } catch (SQLException exception) { + if (cleanupFailure == null) { + cleanupFailure = exception; + } else { + cleanupFailure.addSuppressed(exception); + } + } + } + if (cleanupFailure != null) { + if (original != null) { + original.addSuppressed(cleanupFailure); + } else { + throw cleanupFailure; + } } } diff --git a/backend/src/main/java/com/kaidi/finance/setup/SetupViews.java b/backend/src/main/java/com/kaidi/finance/setup/SetupViews.java index aba2fbc..55ddc8c 100644 --- a/backend/src/main/java/com/kaidi/finance/setup/SetupViews.java +++ b/backend/src/main/java/com/kaidi/finance/setup/SetupViews.java @@ -10,6 +10,7 @@ public final class SetupViews { public record Status( boolean required, boolean locked, + boolean ready, List supportedDatabaseTypes, String message ) { diff --git a/backend/src/test/java/com/kaidi/finance/setup/SetupApplicationIntegrationTest.java b/backend/src/test/java/com/kaidi/finance/setup/SetupApplicationIntegrationTest.java index 631def9..f602c92 100644 --- a/backend/src/test/java/com/kaidi/finance/setup/SetupApplicationIntegrationTest.java +++ b/backend/src/test/java/com/kaidi/finance/setup/SetupApplicationIntegrationTest.java @@ -63,6 +63,7 @@ class SetupApplicationIntegrationTest { ResponseEntity initial = rest.getForEntity(url("/api/v1/setup/status"), JsonNode.class); assertThat(initial.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(initial.getBody().path("data").path("required").asBoolean()).isTrue(); + assertThat(initial.getBody().path("data").path("ready").asBoolean()).isFalse(); Map database = databaseRequest("wrong-code"); ResponseEntity denied = rest.postForEntity(url("/api/v1/setup/test-connection"), @@ -76,6 +77,22 @@ class SetupApplicationIntegrationTest { assertThat(tested.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(tested.getBody().path("data").path("successful").asBoolean()).isTrue(); assertThat(tested.getBody().path("data").path("schemaReady").asBoolean()).isTrue(); + assertThat(tested.getBody().path("data").path("message").asText()).contains("完整迁移权限验证通过"); + + try (Connection connection = DriverManager.getConnection(MYSQL.getJdbcUrl(), MYSQL.getUsername(), + MYSQL.getPassword()); Statement statement = connection.createStatement()) { + try (ResultSet result = statement.executeQuery(""" + SELECT + (SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name LIKE 'kaidi_setup_probe_%') + + + (SELECT COUNT(*) FROM information_schema.routines + WHERE routine_schema = DATABASE() AND routine_name LIKE 'kaidi_setup_probe_%') + """)) { + result.next(); + assertThat(result.getInt(1)).isZero(); + } + } Map complete = new LinkedHashMap<>(database); complete.put("adminUsername", "setup-admin"); @@ -121,6 +138,7 @@ class SetupApplicationIntegrationTest { ResponseEntity locked = rest.getForEntity(url("/api/v1/setup/status"), JsonNode.class); assertThat(locked.getBody().path("data").path("required").asBoolean()).isFalse(); assertThat(locked.getBody().path("data").path("locked").asBoolean()).isTrue(); + assertThat(locked.getBody().path("data").path("ready").asBoolean()).isFalse(); } private Map databaseRequest(String setupCode) { diff --git a/backend/src/test/java/com/kaidi/finance/setup/SetupReadinessTest.java b/backend/src/test/java/com/kaidi/finance/setup/SetupReadinessTest.java new file mode 100644 index 0000000..0756d3f --- /dev/null +++ b/backend/src/test/java/com/kaidi/finance/setup/SetupReadinessTest.java @@ -0,0 +1,31 @@ +package com.kaidi.finance.setup; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class SetupReadinessTest { + + private static final String TOKEN_SHA256 = + "9158568c96987884c8141d363daceffda86bb17083054b36105934090aa7e166"; + + @TempDir + private Path stateRoot; + + @Test + void distinguishesLockedSetupModeFromReadyApplicationMode() { + SetupProperties properties = new SetupProperties(true, TOKEN_SHA256, + stateRoot.resolve("application.env").toString(), stateRoot.resolve("locked").toString(), false); + + SetupViews.Status setupMode = new SetupService(properties).status(); + SetupViews.Status applicationMode = new SetupLockedController().status().data(); + + assertThat(setupMode.required()).isTrue(); + assertThat(setupMode.ready()).isFalse(); + assertThat(applicationMode.required()).isFalse(); + assertThat(applicationMode.locked()).isTrue(); + assertThat(applicationMode.ready()).isTrue(); + } +} diff --git a/frontend/e2e/setup-wizard.e2e.ts b/frontend/e2e/setup-wizard.e2e.ts index 7a7355d..35047d6 100644 --- a/frontend/e2e/setup-wizard.e2e.ts +++ b/frontend/e2e/setup-wizard.e2e.ts @@ -11,6 +11,7 @@ test('first-run wizard tests MySQL, creates the administrator and stays responsi data: { required: !completed, locked: completed, + ready: completed, supportedDatabaseTypes: ['MYSQL'], message: completed ? '系统已完成安装' : '请完成安装', }, @@ -30,7 +31,7 @@ test('first-run wizard tests MySQL, creates the administrator and stays responsi databaseType: 'MYSQL', serverVersion: '8.4.6', schemaReady: true, - message: 'MySQL 8.4 连接和 DDL/DML 权限验证通过', + message: 'MySQL 8.4 连接和完整迁移权限验证通过', }, }, }); @@ -58,7 +59,7 @@ test('first-run wizard tests MySQL, creates the administrator and stays responsi await page.getByLabel('数据库密码').fill('fixture-db-password'); await page.getByLabel('安装码').fill('fixture-setup-code'); await page.getByRole('button', { name: '测试连接' }).click(); - await expect(page.getByText('MySQL 8.4 连接和 DDL/DML 权限验证通过')).toBeVisible(); + await expect(page.getByText('MySQL 8.4 连接和完整迁移权限验证通过')).toBeVisible(); await page.getByRole('button', { name: '下一步' }).click(); await page.getByLabel('管理员密码').fill('SetupAdmin@2026Strong'); @@ -74,3 +75,61 @@ test('first-run wizard tests MySQL, creates the administrator and stays responsi })); expect(layout.scrollWidth).toBeLessThanOrEqual(layout.clientWidth); }); + +test('confirms completion after the setup response is interrupted by a service restart', async ({ page }) => { + let completionStarted = false; + let recoveryChecks = 0; + await page.route('**/api/v1/setup/**', async (route) => { + const request = route.request(); + const pathname = new URL(request.url()).pathname; + if (pathname === '/api/v1/setup/status') { + if (completionStarted) recoveryChecks += 1; + const ready = completionStarted && recoveryChecks >= 2; + await route.fulfill({ + json: { + data: { + required: !completionStarted, + locked: completionStarted, + ready, + supportedDatabaseTypes: ['MYSQL'], + message: ready ? '系统已完成安装' : '请完成安装', + }, + }, + }); + return; + } + const body = request.postDataJSON() as Record; + if (pathname.endsWith('/test-connection')) { + await route.fulfill({ + json: { + data: { + successful: true, + databaseType: 'MYSQL', + serverVersion: '8.4.6', + schemaReady: true, + message: 'MySQL 8.4 连接和完整迁移权限验证通过', + }, + }, + }); + return; + } + expect(body.adminUsername).toBe('admin'); + completionStarted = true; + await route.abort('connectionreset'); + }); + + await page.goto('/setup'); + await page.getByLabel('数据库密码').fill('fixture-db-password'); + await page.getByLabel('安装码').fill('fixture-setup-code'); + await page.getByRole('button', { name: '测试连接' }).click(); + await page.getByRole('button', { name: '下一步' }).click(); + await page.getByLabel('管理员密码').fill('SetupAdmin@2026Strong'); + await page.getByLabel('确认密码').fill('SetupAdmin@2026Strong'); + await page.getByRole('button', { name: '完成安装' }).click(); + await page.getByRole('button', { name: '确定', exact: true }).click(); + + await expect(page.getByText('连接暂时中断,正在确认安装结果。')).toBeVisible(); + await expect(page.getByText('安装完成', { exact: true })).toBeVisible(); + await expect(page.getByText('安装已完成,系统正在切换到正式模式')).toBeVisible(); + await expect(page.getByText('网络请求失败')).toHaveCount(0); +}); diff --git a/frontend/src/api/setup.ts b/frontend/src/api/setup.ts index a162562..34c120f 100644 --- a/frontend/src/api/setup.ts +++ b/frontend/src/api/setup.ts @@ -1,8 +1,11 @@ import { request } from '@/utils/request'; +const SETUP_COMPLETION_TIMEOUT_MS = 120_000; + export interface SetupStatus { required: boolean; locked: boolean; + ready: boolean; supportedDatabaseTypes: string[]; message: string; } @@ -43,6 +46,7 @@ function normalizeStatus(value: SetupStatus): SetupStatus { return { required: Boolean(value?.required), locked: Boolean(value?.locked), + ready: Boolean(value?.ready), supportedDatabaseTypes: Array.isArray(value?.supportedDatabaseTypes) ? value.supportedDatabaseTypes : [], message: value?.message || '', }; @@ -57,5 +61,7 @@ export function testSetupConnection(data: SetupDatabaseRequest) { } export function completeSetup(data: SetupCompleteRequest) { - return request.post({ url: '/setup/complete', data }).then((value) => value); + return request + .post({ url: '/setup/complete', data, timeout: SETUP_COMPLETION_TIMEOUT_MS }) + .then((value) => value); } diff --git a/frontend/src/pages/setup/index.vue b/frontend/src/pages/setup/index.vue index 14572e7..f731e3d 100644 --- a/frontend/src/pages/setup/index.vue +++ b/frontend/src/pages/setup/index.vue @@ -162,6 +162,10 @@ + + {{ completionProgressMessage }} + +
@@ -223,8 +227,13 @@ const errorMessage = ref(''); const connectionResult = ref(null); const verifiedFingerprint = ref(''); const completionMessage = ref('系统正在重启,请稍候。'); +const completionProgressMessage = ref('正在初始化数据库结构并创建管理员,请稍候。'); const countdown = ref(10); let countdownTimer: number | undefined; +let unmounted = false; + +const SETUP_CONFIRMATION_TIMEOUT_MS = 60_000; +const SETUP_CONFIRMATION_INTERVAL_MS = 2_000; const databaseForm = reactive({ setupCode: '', @@ -317,21 +326,51 @@ async function finishSetup(context?: SubmitContext) { return; } completing.value = true; + completionProgressMessage.value = '正在初始化数据库结构并创建管理员,请稍候。'; errorMessage.value = ''; try { const result = await completeSetup({ ...databaseForm, ...adminForm }); if (!result.completed) throw new Error('安装服务未确认完成,请稍后重试'); - completionMessage.value = result.message; - countdown.value = Math.max(1, result.restartAfterSeconds || 10); - currentStep.value = 2; - startCountdown(); + showCompleted(result.message, result.restartAfterSeconds); } catch (error) { - errorMessage.value = (error as Error).message || '安装初始化失败'; + const requestError = error as Error & { status?: number }; + if (requestError.status === undefined || requestError.status >= 500) { + completionProgressMessage.value = '连接暂时中断,正在确认安装结果。'; + if (await waitForSetupCompletion()) { + showCompleted('安装已完成,系统正在切换到正式模式', 10); + return; + } + errorMessage.value = '安装结果暂未确认,请稍后重新打开安装页面;若问题持续,请检查服务日志。'; + } else { + errorMessage.value = requestError.message || '安装初始化失败'; + } } finally { completing.value = false; } } +async function waitForSetupCompletion() { + const deadline = Date.now() + SETUP_CONFIRMATION_TIMEOUT_MS; + while (Date.now() < deadline) { + if (unmounted) return false; + try { + const status = await getSetupStatus(); + if (status.ready) return true; + } catch { + // A short connection failure is expected while systemd restarts the service. + } + await new Promise((resolve) => window.setTimeout(resolve, SETUP_CONFIRMATION_INTERVAL_MS)); + } + return false; +} + +function showCompleted(message: string, restartAfterSeconds: number) { + completionMessage.value = message; + countdown.value = Math.max(1, restartAfterSeconds || 10); + currentStep.value = 2; + startCountdown(); +} + function confirmSetup() { void finishSetup(); } @@ -340,13 +379,18 @@ function startCountdown() { window.clearInterval(countdownTimer); countdownTimer = window.setInterval(() => { countdown.value -= 1; - if (countdown.value <= 0) openLogin(); + if (countdown.value <= 0) void openLogin(); }, 1000); } -function openLogin() { +async function openLogin() { window.clearInterval(countdownTimer); - window.location.assign('/login'); + completionMessage.value = '系统正在启动,请稍候。'; + if (await waitForSetupCompletion()) { + window.location.assign('/login'); + return; + } + completionMessage.value = '系统启动时间较长,请稍后刷新页面。'; } onMounted(async () => { @@ -364,7 +408,10 @@ onMounted(async () => { } }); -onBeforeUnmount(() => window.clearInterval(countdownTimer)); +onBeforeUnmount(() => { + unmounted = true; + window.clearInterval(countdownTimer); +});