diff --git a/README.md b/README.md index 446dcb5..14e53ed 100644 --- a/README.md +++ b/README.md @@ -59,14 +59,14 @@ PostgreSQL 18。PostgreSQL 兼容开发已冻结,不属于本次 Preview 发 ### 直接 curl 安装 ```bash -curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.18/install.sh | sudo bash +curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.19/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.18/install.sh \ +curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.19/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.18/install.sh -o /tmp/kaidi-install.sh +curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.19/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.18 --depth 1 https://git.awaioi.com/ERP-Team/kaidi.git kaidi-preview +git clone --branch v1.0.0-preview.19 --depth 1 https://git.awaioi.com/ERP-Team/kaidi.git kaidi-preview cd kaidi-preview ./deploy/install-from-git.sh ``` @@ -141,12 +141,13 @@ 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.18/install.sh | sudo bash +curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.19/install.sh | sudo bash ``` 无论主机架构如何,安装器都不会安装 MySQL、数据库客户端或创建数据库容器。在打开向导前,需要预先创建 `kaidi_finance`,并授予安装账号该库的 完整迁移权限。向导会连接数据库、核验 MySQL 8.4.x 版本,并实际探测建表、改表、索引、外键、存储过程、 -临时表及读写权限,全部通过后 Flyway 才会建表。 +临时表及读写权限;专用数据库不是 `utf8mb4/utf8mb4_0900_ai_ci` 时,向导会在迁移前自动统一默认字符集和 +排序规则,全部通过后 Flyway 才会建表。 数据库管理员可在 MySQL 8.4 中按实际应用服务器地址执行以下基线 SQL: ```sql @@ -162,7 +163,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.18/install.sh \ +curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.19/install.sh \ | sudo env KAIDI_REINSTALL=true KAIDI_SETUP_WIZARD=false bash ``` @@ -172,7 +173,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.18/install.sh \ +curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.19/install.sh \ | sudo env KAIDI_REINSTALL=true bash ``` @@ -208,8 +209,8 @@ act_runner 提供 `ubuntu-24.04` 标签,并在 tag 发布时执行后端、前 Preview 属性由 SemVer 版本名表达。之后推送 tag 即会构建、测试、签名并发布: ```bash -git tag v1.0.0-preview.18 -git push origin v1.0.0-preview.18 +git tag v1.0.0-preview.19 +git push origin v1.0.0-preview.19 ``` 在线更新使用独立的 TDesign 页面:隔离的系统管理员进入“系统治理 → 系统更新”。权限与配置页只管理用户、角色、数据范围、表单模板和参数版本,不配置系统名称或域名。 @@ -261,7 +262,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.18 + ./scripts/package-release.sh 1.0.0-preview.19 KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \ ./scripts/verify-release.sh dist/release ``` 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 a76a4a8..62e5b58 100644 --- a/backend/src/main/java/com/kaidi/finance/setup/SetupService.java +++ b/backend/src/main/java/com/kaidi/finance/setup/SetupService.java @@ -42,6 +42,9 @@ public class SetupService { private static final Logger LOGGER = LoggerFactory.getLogger(SetupService.class); private static final String MYSQL_PREFIX = "jdbc:mysql://"; + private static final String MYSQL_CHARACTER_SET = "utf8mb4"; + private static final String MYSQL_COLLATION = "utf8mb4_0900_ai_ci"; + private static final String RECOVERABLE_V068_SCRIPT = "V068__source_table_column_validation_metadata.sql"; 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; @@ -81,9 +84,10 @@ public class SetupService { false, "PostgreSQL 连接可用,但当前 Preview 的业务迁移仅支持 MySQL 8.4"); } ConnectionResult connection = testConnection(settings); + prepareDatabaseForInstallation(settings); validateSchemaForInstallation(settings); return new SetupViews.Connection(true, settings.type().name(), connection.version(), true, - "MySQL 8.4 连接和完整迁移权限验证通过"); + "MySQL 8.4 连接、排序规则和完整迁移权限验证通过"); } public SetupViews.Completed complete(SetupContracts.CompleteRequest request) { @@ -129,6 +133,7 @@ public class SetupService { private void migrateAndCreateAdministrator(DatabaseSettings settings, SetupContracts.CompleteRequest request) { try { + prepareDatabaseForInstallation(settings); validateSchemaForInstallation(settings); flyway(settings).migrate(); DriverManagerDataSource dataSource = new DriverManagerDataSource(settings.jdbcUrl(), settings.username(), @@ -146,6 +151,105 @@ public class SetupService { } } + private void prepareDatabaseForInstallation(DatabaseSettings settings) { + JdbcTemplate jdbc = new JdbcTemplate(new DriverManagerDataSource(settings.jdbcUrl(), settings.username(), + settings.password())); + try { + DatabaseCollation current = databaseCollation(jdbc, settings.database()); + if (!MYSQL_CHARACTER_SET.equalsIgnoreCase(current.characterSet()) + || !MYSQL_COLLATION.equalsIgnoreCase(current.collation())) { + jdbc.execute("ALTER DATABASE " + quoteIdentifier(settings.database()) + + " CHARACTER SET " + MYSQL_CHARACTER_SET + " COLLATE " + MYSQL_COLLATION); + DatabaseCollation updated = databaseCollation(jdbc, settings.database()); + if (!MYSQL_CHARACTER_SET.equalsIgnoreCase(updated.characterSet()) + || !MYSQL_COLLATION.equalsIgnoreCase(updated.collation())) { + throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY, + "DATABASE_COLLATION_UNSUPPORTED", "数据库字符集或排序规则未能统一为 " + + MYSQL_CHARACTER_SET + "/" + MYSQL_COLLATION); + } + LOGGER.info("Normalized database {} from {}/{} to {}/{}", settings.database(), + current.characterSet(), current.collation(), updated.characterSet(), updated.collation()); + } + recoverV068CollationFailure(jdbc, settings.database()); + } catch (SetupException exception) { + throw exception; + } catch (RuntimeException exception) { + LOGGER.error("Database collation preparation failed", exception); + SQLException sqlException = findSqlException(exception); + String detail = sqlException == null + ? "数据库字符集和排序规则初始化失败:" + safeErrorMessage(exception) + : "数据库字符集和排序规则初始化失败" + sqlErrorSummary(sqlException); + throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY, + "DATABASE_COLLATION_PREPARATION_FAILED", detail); + } + } + + private DatabaseCollation databaseCollation(JdbcTemplate jdbc, String database) { + return jdbc.queryForObject(""" + SELECT default_character_set_name, default_collation_name + FROM information_schema.schemata + WHERE schema_name = ? + """, (result, row) -> new DatabaseCollation( + result.getString("default_character_set_name"), result.getString("default_collation_name")), + database); + } + + private void recoverV068CollationFailure(JdbcTemplate jdbc, String database) { + Integer historyCount = jdbc.queryForObject(""" + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = ? AND table_name = 'flyway_schema_history' + """, Integer.class, database); + if (historyCount == null || historyCount == 0) { + return; + } + List failures = jdbc.query(""" + SELECT installed_rank, version, script + FROM flyway_schema_history + WHERE success = FALSE + ORDER BY installed_rank + """, (result, row) -> new FailedMigration(result.getInt("installed_rank"), + result.getString("version"), result.getString("script"))); + if (failures.size() != 1) { + return; + } + FailedMigration failure = failures.get(0); + if (!("68".equals(failure.version()) || "068".equals(failure.version())) + || !RECOVERABLE_V068_SCRIPT.equals(failure.script())) { + return; + } + Integer predecessorCount = jdbc.queryForObject(""" + SELECT COUNT(*) + FROM flyway_schema_history + WHERE version IN ('67', '067') AND success = TRUE + """, Integer.class); + if (predecessorCount == null || predecessorCount != 1) { + return; + } + Integer laterSuccessCount = jdbc.queryForObject(""" + SELECT COUNT(*) + FROM flyway_schema_history + WHERE installed_rank > ? AND success = TRUE + """, Integer.class, failure.installedRank()); + if (laterSuccessCount != null && laterSuccessCount > 0) { + return; + } + int deleted = jdbc.update(""" + DELETE FROM flyway_schema_history + WHERE installed_rank = ? AND version = ? AND script = ? AND success = FALSE + """, failure.installedRank(), failure.version(), failure.script()); + if (deleted != 1) { + throw new SetupException(org.springframework.http.HttpStatus.CONFLICT, + "DATABASE_MIGRATION_RECOVERY_CONFLICT", "V068 迁移恢复状态已变化,请重新测试数据库连接"); + } + LOGGER.warn("Removed the recoverable failed {} history row after database collation normalization", + RECOVERABLE_V068_SCRIPT); + } + + private String quoteIdentifier(String identifier) { + return "`" + identifier.replace("`", "``") + "`"; + } + private boolean ensureCompatibleSchema(DatabaseSettings settings) { JdbcTemplate jdbc = new JdbcTemplate(new DriverManagerDataSource(settings.jdbcUrl(), settings.username(), settings.password())); @@ -580,6 +684,12 @@ public class SetupService { private record ConnectionResult(boolean successful, String version) { } + private record DatabaseCollation(String characterSet, String collation) { + } + + private record FailedMigration(int installedRank, String version, String script) { + } + private record InstallationState(String setupTokenSha256, long administratorUserId, String administratorUsername) { } 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 f602c92..5af6e50 100644 --- a/backend/src/test/java/com/kaidi/finance/setup/SetupApplicationIntegrationTest.java +++ b/backend/src/test/java/com/kaidi/finance/setup/SetupApplicationIntegrationTest.java @@ -1,6 +1,7 @@ package com.kaidi.finance.setup; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.fasterxml.jackson.databind.JsonNode; import com.kaidi.setup.SetupApplication; @@ -13,6 +14,7 @@ import java.sql.Statement; import java.util.LinkedHashMap; import java.util.Map; import java.util.UUID; +import org.flywaydb.core.Flyway; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @@ -60,6 +62,8 @@ class SetupApplicationIntegrationTest { @Test void firstRunTestsDatabaseCreatesAdministratorAndLocksWizard() throws Exception { + seedRecoverableV068CollationFailure(); + 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(); @@ -77,7 +81,8 @@ 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("完整迁移权限验证通过"); + assertThat(tested.getBody().path("data").path("message").asText()) + .contains("排序规则和完整迁移权限验证通过"); try (Connection connection = DriverManager.getConnection(MYSQL.getJdbcUrl(), MYSQL.getUsername(), MYSQL.getPassword()); Statement statement = connection.createStatement()) { @@ -92,6 +97,22 @@ class SetupApplicationIntegrationTest { result.next(); assertThat(result.getInt(1)).isZero(); } + try (ResultSet result = statement.executeQuery(""" + SELECT default_collation_name + FROM information_schema.schemata + WHERE schema_name = DATABASE() + """)) { + result.next(); + assertThat(result.getString(1)).isEqualTo("utf8mb4_0900_ai_ci"); + } + try (ResultSet result = statement.executeQuery(""" + SELECT COUNT(*) + FROM flyway_schema_history + WHERE success = FALSE + """)) { + result.next(); + assertThat(result.getInt(1)).isZero(); + } } Map complete = new LinkedHashMap<>(database); @@ -141,6 +162,33 @@ class SetupApplicationIntegrationTest { assertThat(locked.getBody().path("data").path("ready").asBoolean()).isFalse(); } + private void seedRecoverableV068CollationFailure() throws Exception { + try (Connection connection = DriverManager.getConnection(MYSQL.getJdbcUrl(), MYSQL.getUsername(), + MYSQL.getPassword()); Statement statement = connection.createStatement()) { + statement.execute("ALTER DATABASE `" + MYSQL.getDatabaseName() + + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci"); + } + assertThatThrownBy(() -> Flyway.configure() + .dataSource(MYSQL.getJdbcUrl(), MYSQL.getUsername(), MYSQL.getPassword()) + .locations("classpath:db/migration") + .target("68") + .load() + .migrate()) + .hasMessageContaining("V068__source_table_column_validation_metadata.sql"); + try (Connection connection = DriverManager.getConnection(MYSQL.getJdbcUrl(), MYSQL.getUsername(), + MYSQL.getPassword()); Statement statement = connection.createStatement(); + ResultSet result = statement.executeQuery(""" + SELECT COUNT(*) + FROM flyway_schema_history + WHERE version = '68' + AND script = 'V068__source_table_column_validation_metadata.sql' + AND success = FALSE + """)) { + result.next(); + assertThat(result.getInt(1)).isEqualTo(1); + } + } + private Map databaseRequest(String setupCode) { Map request = new LinkedHashMap<>(); request.put("setupCode", setupCode); diff --git a/frontend/e2e/setup-wizard.e2e.ts b/frontend/e2e/setup-wizard.e2e.ts index 35047d6..192f7cc 100644 --- a/frontend/e2e/setup-wizard.e2e.ts +++ b/frontend/e2e/setup-wizard.e2e.ts @@ -31,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 连接和完整迁移权限验证通过', + message: 'MySQL 8.4 连接、排序规则和完整迁移权限验证通过', }, }, }); @@ -59,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 连接和完整迁移权限验证通过')).toBeVisible(); + await expect(page.getByText('MySQL 8.4 连接、排序规则和完整迁移权限验证通过')).toBeVisible(); await page.getByRole('button', { name: '下一步' }).click(); await page.getByLabel('管理员密码').fill('SetupAdmin@2026Strong'); @@ -107,7 +107,7 @@ test('confirms completion after the setup response is interrupted by a service r databaseType: 'MYSQL', serverVersion: '8.4.6', schemaReady: true, - message: 'MySQL 8.4 连接和完整迁移权限验证通过', + message: 'MySQL 8.4 连接、排序规则和完整迁移权限验证通过', }, }, });