From 8c6c10eb853683204fee7d2d245802b41dd3bbf2 Mon Sep 17 00:00:00 2001 From: Qiufeng Date: Tue, 18 Aug 2026 22:51:35 +0800 Subject: [PATCH] fix: stabilize external-db deployment and updates --- README.md | 80 ++--- .../com/kaidi/finance/setup/SetupService.java | 71 +++-- .../shared/api/GlobalExceptionHandler.java | 4 +- .../audit/DeniedAccessAuditService.java | 2 +- .../shared/file/FileApplicationService.java | 2 +- .../SetupApplicationIntegrationTest.java | 32 +- deploy/baota-init.sh | 81 ++++- deploy/baota-start.sh | 30 +- deploy/compose.yaml | 3 + deploy/env.production.example | 2 +- deploy/install-from-git.sh | 30 +- deploy/install.sh | 286 +++++++++++++----- deploy/purge.sh | 60 +++- deploy/update.sh | 221 +++++++++++--- docs/财务系统开发交付总方案.md | 18 +- frontend/e2e/setup-wizard.e2e.ts | 18 +- frontend/src/pages/setup/index.vue | 17 +- frontend/src/utils/request/Axios.ts | 27 +- scripts/test-baota-start-fixture.sh | 2 +- scripts/test-git-install-fixture.sh | 2 +- scripts/test-install-fixture.sh | 24 +- scripts/test-update-fixture.sh | 34 ++- 22 files changed, 767 insertions(+), 279 deletions(-) diff --git a/README.md b/README.md index 8807074..5c558c3 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,14 @@ ## 本地开发 -本地开发数据库由环境变量显式指定,启动 Java 服务: +本地开发数据库由环境变量显式指定,启动 Java 服务。仓库中的 `deploy/compose.yaml` 只是可选的本地开发夹具; +需要它时必须显式启用 `local-db` profile,生产安装器不会执行 Compose、创建 MySQL 或安装 MySQL 客户端: + +```bash +docker compose -f deploy/compose.yaml --profile local-db up -d +``` + +随后启动 Java 服务: ```bash cd backend @@ -59,8 +66,9 @@ PostgreSQL 18 兼容工作继续冻结。 - **生产机不执行 `git clone`、`git pull` 或远程脚本拼接。** 源码仓库是 `https://git.awaioi.com/ERP-Team/kaidi.git`,生产安装和后台更新使用同一仓库生成的公开 Gitea Release。 - 安装器先读取 Gitea Release API,再下载 `release-manifest.json`、签名、公钥和压缩包,校验固定公钥指纹、RSA 签名、版本、文件名和 SHA-256;校验失败不会安装。 -- 后台“获取版本”只查询 Release;“下载”才下载并校验;“立即更新并重启”才备份数据库、切换 `current` 并重启服务。更新器下载的是 Release 制品,不是 Git 工作树。 +- 后台“获取版本”只查询 Release;“下载”才下载并校验;“立即更新并重启”才切换 `current` 并重启服务。更新器默认不访问数据库;如明确设置 `KAIDI_DB_BACKUP_MODE=mysqldump`,才会调用主机已有的备份工具。更新器下载的是 Release 制品,不是 Git 工作树。 - MySQL 是**已有数据库**,应用只通过向导写入的 `DB_URL`、`DB_USERNAME`、`DB_PASSWORD` 连接它;安装器不安装 MySQL、不创建数据库、不修改数据库服务。填写 `127.0.0.1` 表示 MySQL 与 Java 应用在同一台服务器,端口以实际监听端口为准,不默认假设 `3307`。 +- 安装阶段只校验 JDBC 配置格式,不调用 `mysql`/`mariadb` 客户端,也不执行 `CREATE`、`INSERT`、`UPDATE`、`DELETE` 或 `DROP`。首次向导点击“完成安装”后,应用才会在**专用空 schema**中运行 Flyway 建表并初始化管理员;这是业务初始化,不是安装 MySQL 服务。在线更新默认跳过数据库备份;需要备份时由运维显式设置 `KAIDI_DB_BACKUP_MODE=mysqldump`,更新器只调用已有工具,不会安装数据库。 - `KAIDI_APP_PORT` 只决定 Java 回环监听端口,默认 `18080`;反向代理必须指向安装器输出的 `PROXY_TARGET`。安装器不会替你修改 Nginx 或宝塔站点配置。 - 发布归档使用无顶层目录的 `tar.gz`,只包含 `app.jar`、`public/`、`ops/`、`VERSION`;打包阶段禁用 macOS 扩展属性并拒绝开发数据库回退值。 @@ -68,14 +76,14 @@ PostgreSQL 18 兼容工作继续冻结。 当前支持的是 **i386/i486/i586/i686 + glibc + systemd + 可运行的 32 位 Java 17**。安装器会校验用户空间位数、Java 架构、glibc 和 `/lib/ld-linux.so.2`(兼容 `/lib32`、`/lib/i386-linux-gnu` 路径);musl 或缺少 32 位加载器的系统会在安装前明确失败,不会安装后才出现无法启动。 -32 位服务器应使用首次访问 `/setup` 的向导输入外部 MySQL 8.4 连接信息,这条路径不需要在服务器安装 MySQL 客户端。在线更新前仍需准备与数据库兼容的 `mysqldump`;如果 32 位系统无法提供该客户端,应先关闭在线更新或从独立备份机执行数据库备份,不要在更新过程中临时安装数据库。 +32 位服务器应使用首次访问 `/setup` 的向导输入外部 MySQL 8.4 连接信息,这条路径不需要在服务器安装 MySQL 客户端。在线更新默认不需要 `mysqldump`;如果要启用更新前备份,再确认 32 位系统能执行兼容的 `mysqldump`,否则保持默认 `skip` 或从独立备份机执行备份,不要临时安装数据库。 ### systemd 一键安装(推荐) 先在宝塔面板停止并删除当前错误的 Java 项目,再执行: ```bash -curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.41/install.sh | sudo env KAIDI_APP_PORT=18080 bash +curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.42/install.sh | sudo env KAIDI_APP_PORT=18080 bash ``` 该命令只安装程序运行所需的 systemd 单元,自动创建 `kaidi` 用户并检测现有 Java 17(包括 @@ -88,38 +96,39 @@ 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.41/purge.sh \ - -o /tmp/kaidi-purge.sh \ - && curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.41/SHA256SUMS \ - -o /tmp/kaidi-SHA256SUMS \ - && (cd /tmp && grep ' purge.sh$' kaidi-SHA256SUMS | sha256sum -c -) \ - && sudo env KAIDI_PURGE_CONFIRM=DELETE_LOCAL_KAIDI_INSTALLATION bash /tmp/kaidi-purge.sh \ - && rm -f /tmp/kaidi-purge.sh /tmp/kaidi-SHA256SUMS +curl -fsSL 'https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.42/purge.sh' | sudo env KAIDI_PURGE_CONFIRM=DELETE_LOCAL_KAIDI_INSTALLATION bash ``` +上面是一条完整命令:脚本通过管道直接交给 root 执行,不创建临时安装文件,避免终端自动换行导致 `-o` 参数丢失。 +如果需要连同本地恢复备份一起删除(确认不再需要回滚后再执行),在同一条命令中增加 +`KAIDI_PURGE_DELETE_BACKUP=true`。卸载前必须先停止并删除宝塔中的 `kaidi-finance` Java 项目;宝塔的项目元数据、 +Nginx/反向代理和外部 MySQL 属于外部资源,卸载程序不会误删它们。 + 清理脚本会先停止 systemd 和遗留 Kaidi 进程,将旧配置、文件存储、安装码以及已有数据库备份归档到 `/root/kaidi-reinstall-backups/`,再删除 `/opt/kaidi`、`/www/wwwroot/kaidi`、`/etc/kaidi`、 `/var/lib/kaidi`、`/var/lib/kaidi-update`、`/var/log/kaidi` 和三个 systemd 单元。恢复包权限固定为 `0600`,确认新安装及数据无误后再由 root 删除。脚本不删除外部 MySQL、系统 Java、Nginx 或宝塔站点配置; 新安装必须连接一个新的空 MySQL 数据库,旧数据库保留用于回滚。 -### Preview.41 直链与宝塔手动部署 +### Preview.42 直链与宝塔手动部署 本版不使用一键安装脚本。发布物是一个不带顶层目录的压缩包,下载后直接解压到版本目录,再由宝塔面板创建 Spring Boot 项目。数据库、JDK、Nginx 和宝塔本身都由运维人员准备;程序不会自动安装 MySQL 或任何第三方服务。 下载地址: -`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.41/kaidi-finance-1.0.0-preview.41.tar.gz` +`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.42/kaidi-finance-1.0.0-preview.42.tar.gz` -校验文件:`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.41/SHA256SUMS` +校验文件:`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.42/SHA256SUMS` -服务器要求:Linux + systemd、Java 17(宝塔项目选择 JDK 17)、可访问外部 MySQL 8.4.x;32 位 Linux 需要宿主机 +服务器要求:Linux、Java 17(宝塔项目选择 JDK 17)、可访问外部 MySQL 8.4.x;**systemd 一键安装**还需要 +systemd/systemd-analyze,**宝塔手动部署**不要求 systemd。32 位 Linux 需要宿主机 已经提供可运行的 32 位 Java 17。程序只连接已有数据库,不安装数据库客户端或数据库服务。初始化只需要 -`bash`、`openssl`、`sha256sum` 和基础 Linux 工具;选择 systemd 在线更新前,另行准备 `curl`、`jq`、`flock`、`gzip`、`runuser` -以及与外部 MySQL 兼容的 `mysqldump`。安装器和更新器会优先使用 PATH 中的工具,也会探测宝塔常见的 -`/www/server/mysql/bin/` 路径;如工具安装在其他位置,可分别设置 `KAIDI_MYSQL_CLIENT` 和 +`bash`、`openssl`、`sha256sum` 和基础 Linux 工具;systemd 在线更新默认不需要数据库客户端。只有将 +`KAIDI_DB_BACKUP_MODE` 设为 `mysqldump` 时,才需要 `curl`、`jq`、`flock`、`gzip`、`runuser` 和兼容的 +`mysqldump`;安装器和更新器会优先使用 PATH 中的工具,也会探测宝塔常见的 `/www/server/mysql/bin/` 路径;如备份工具安装在其他位置,可设置 `KAIDI_MYSQLDUMP_BIN`,程序不会替你安装这些依赖。 +更新器默认只下载、验签、切换和健康检查,不调用 Docker、不进入容器,也不创建或管理 MySQL 服务。 #### 1. 下载、校验、解压 @@ -127,7 +136,7 @@ Spring Boot 项目。数据库、JDK、Nginx 和宝塔本身都由运维人员 必须直接位于 `RELEASE_ROOT`,不能再嵌套一层目录。 ```bash -VERSION=1.0.0-preview.41 +VERSION=1.0.0-preview.42 APP_ROOT=/www/wwwroot/kaidi RELEASE_ROOT="$APP_ROOT/releases/$VERSION" sudo install -d -m 0755 "$RELEASE_ROOT" @@ -218,29 +227,30 @@ curl -fsS http://127.0.0.1:18080/ | head 不要在宝塔项目仍运行时点击“系统治理 → 系统更新”,也不要让宝塔守护和 `kaidi-finance.service` 同时管理同一端口。 需要后台点击“获取版本 → 下载 → 立即更新并重启”的稳定流程时,使用上一节的 systemd 一键安装方式。 -手动升级前请先由运维人员完成外部 MySQL 备份;程序不会安装 MySQL 或客户端,也不会修改反向代理配置。 +手动升级由运维人员决定是否先做外部 MySQL 备份;程序不会安装 MySQL 或客户端,也不会修改反向代理配置。 后续版本仍按同样方式直接下载并解压到新的 `releases/`,保留可回滚的旧目录。 ### Linux 32 位 压缩包本身不绑定 CPU 架构,关键是宿主机提供 glibc、systemd 和可运行的 32 位 Java 17。32 位主机不能在本机运行 64 位 JDK,也不应尝试在本机安装 -MySQL 8.4;提前准备外部 MySQL,完成上述向导即可。选择 systemd 在线更新前,还必须确认宿主机能执行与数据库版本兼容的 -`mysqldump`;宝塔手动模式只做手动制品替换和人工数据库备份。 +MySQL 8.4;提前准备外部 MySQL,完成上述向导即可。systemd 在线更新默认不访问数据库;只有显式启用 +`KAIDI_DB_BACKUP_MODE=mysqldump` 时才需要确认宿主机能执行与数据库版本兼容的工具。宝塔手动模式只做手动制品替换和人工数据库备份。 -### 停用并移除程序 +### 一键卸载 -以下命令移除应用程序和服务,但保留 `/var/lib/kaidi`、`/var/lib/kaidi-update`、`/etc/kaidi` 以及数据库, -便于审计、备份或重新安装。确认数据备份前不要删除这些保留目录或外部 MySQL 数据。 -反向代理由运维人员独立管理,停用程序不会修改其配置。 +`purge.sh` 就是本项目的卸载程序:它会停止 Kaidi 进程、移除 systemd 单元、删除本地程序/配置/运行状态、 +清理受管临时压缩包,并删除本次安装创建的 `kaidi` 服务账号;不会触碰外部 MySQL、Nginx、反向代理或系统 Java。 +默认先把本地配置和运行数据保存到 root-only 恢复包,再删除受管路径。确认不需要回滚时,可在同一条命令中设置 +`KAIDI_PURGE_DELETE_BACKUP=true`,实现本地受管文件和恢复包一并清除: ```bash -sudo systemctl disable --now kaidi-update.path kaidi-update.service kaidi-finance.service -sudo rm -f /etc/systemd/system/kaidi-finance.service /etc/systemd/system/kaidi-update.service /etc/systemd/system/kaidi-update.path -sudo systemctl daemon-reload -sudo rm -rf /opt/kaidi +curl -fsSL 'https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.42/purge.sh' | sudo env KAIDI_PURGE_CONFIRM=DELETE_LOCAL_KAIDI_INSTALLATION KAIDI_PURGE_DELETE_BACKUP=true bash ``` +执行前先在宝塔停止并删除 `kaidi-finance` Java 项目;宝塔面板元数据属于外部资源,必须由面板先停用, +否则守护进程会重新拉起 Java,卸载程序会明确报出原因而不误删其他服务。 + ## Release 与在线更新 首次建立发布仓库时生成一次签名密钥: @@ -261,8 +271,8 @@ Actions 页面显示 “No matching online runner”,先启动并注册该标 Preview 属性由 SemVer 版本名表达。之后推送 tag 即会构建、测试、签名并发布: ```bash -git tag v1.0.0-preview.41 -git push origin v1.0.0-preview.41 +git tag v1.0.0-preview.42 +git push origin v1.0.0-preview.42 ``` 在线更新仍使用独立的 TDesign 页面:系统管理员进入“系统治理 → 系统更新”。更新源由 root 在 @@ -277,11 +287,11 @@ git push origin v1.0.0-preview.41 2. 发现新版本后显示“立即更新”;管理员点击后才排队 `DOWNLOAD`。更新器下载 manifest、签名和应用包,执行 RSA、SHA-256、版本、 文件名和压缩包路径校验,通过后缓存到 `/var/lib/kaidi-update/cache/`,业务服务继续运行。 3. 页面实时轮询并依次显示下载已排队、下载中、校验中和 `READY/下载完成`;只有下载完成后才显示“立即更新并重启”。 -4. 管理员点击“立即更新并重启”,更新器预先调用宿主机已有的 `mysqldump`,备份权限为 `0600`,默认保留最近 5 份;程序不会安装 MySQL 或客户端。 +4. 管理员点击“立即更新并重启”,更新器默认不访问数据库;如运维已将 `KAIDI_DB_BACKUP_MODE=mysqldump` 写入 `/etc/kaidi/update.env`,才会调用宿主机已有的 `mysqldump`,备份权限为 `0600`,默认保留最近 5 份。程序不会安装 MySQL 或客户端。 5. 更新器安装并保护新版本目录,原子切换 `/opt/kaidi/current`(宝塔手动部署才使用 `/www/wwwroot/kaidi/current`),停止并重启 systemd 管理的应用服务。 6. 健康端点、静态首页和运行版本全部通过后,页面等待 10 秒自动刷新;期间断线或命令响应丢失由前端状态重同步恢复。任一检查失败则切回上一应用版本, - 保留数据库备份和事务证据供人工处理。 + 如果启用了 `mysqldump` 模式则保留数据库备份;否则保留事务证据供人工处理。 忙碌期间检查、下载和安装按钮保持禁用,防止重复请求;10 秒只用于成功后的页面刷新,不延迟服务端切换。数据库迁移必须至少保持一个版本向后兼容。 查看状态和日志: @@ -314,7 +324,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.41 + ./scripts/package-release.sh 1.0.0-preview.42 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 62e5b58..7e8f95f 100644 --- a/backend/src/main/java/com/kaidi/finance/setup/SetupService.java +++ b/backend/src/main/java/com/kaidi/finance/setup/SetupService.java @@ -57,7 +57,7 @@ public class SetupService { public SetupService(SetupProperties properties) { this.properties = properties; if (properties.tokenSha256() == null || !properties.tokenSha256().matches("(?i)[0-9a-f]{64}")) { - throw new IllegalStateException("FINANCE_SETUP_TOKEN_SHA256 must be a 64-character SHA-256 value"); + throw new IllegalStateException("FINANCE_SETUP_TOKEN_SHA256 必须是 64 位 SHA-256 值"); } if (Files.exists(Path.of(properties.markerFile()))) { locked.set(true); @@ -79,15 +79,14 @@ public class SetupService { // Connection testing is useful for diagnostics, but the current business SQL/migration // baseline is MySQL-specific. Do not allow an apparently successful test to produce a // database that the main application cannot start against. - ConnectionResult connection = testConnection(settings); + ConnectionResult connection = testConnection(settings, false); return new SetupViews.Connection(connection.successful(), settings.type().name(), connection.version(), 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 连接、排序规则和完整迁移权限验证通过"); + ConnectionResult connection = testConnection(settings, false); + LOGGER.info("MySQL 只读连接测试通过,未执行数据库写操作,等待管理员确认完成安装"); + return new SetupViews.Connection(true, settings.type().name(), connection.version(), false, + "MySQL 8.4 只读连接验证通过;尚未修改数据库,点击完成安装后才会执行迁移"); } public SetupViews.Completed complete(SetupContracts.CompleteRequest request) { @@ -105,7 +104,10 @@ public class SetupService { } synchronized (this) { ensureOpen(); - ConnectionResult connection = testConnection(settings); + // The explicit completion action is the point at which the operator + // authorizes schema changes and migration privilege checks. + LOGGER.info("管理员已确认完成安装,开始执行 MySQL 迁移和管理员初始化"); + ConnectionResult connection = testConnection(settings, true); if (!connection.successful()) { throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY, "DATABASE_CONNECTION_FAILED", "数据库连接失败,请检查地址、端口、库名和账号"); @@ -145,7 +147,7 @@ public class SetupService { if (exception instanceof SetupException setupException) { throw setupException; } - LOGGER.error("Database migration or administrator initialization failed", exception); + LOGGER.error("数据库迁移或管理员初始化失败", exception); throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY, "DATABASE_INITIALIZATION_FAILED", databaseInitializationFailureMessage(exception)); } @@ -167,14 +169,14 @@ public class SetupService { "DATABASE_COLLATION_UNSUPPORTED", "数据库字符集或排序规则未能统一为 " + MYSQL_CHARACTER_SET + "/" + MYSQL_COLLATION); } - LOGGER.info("Normalized database {} from {}/{} to {}/{}", settings.database(), + LOGGER.info("已将数据库 {} 的字符集/排序规则从 {}/{} 统一为 {}/{}", 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); + LOGGER.error("数据库字符集和排序规则准备失败", exception); SQLException sqlException = findSqlException(exception); String detail = sqlException == null ? "数据库字符集和排序规则初始化失败:" + safeErrorMessage(exception) @@ -242,7 +244,7 @@ public class SetupService { 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", + LOGGER.warn("数据库字符集统一后已移除可恢复的失败迁移记录 {}", RECOVERABLE_V068_SCRIPT); } @@ -282,16 +284,16 @@ public class SetupService { } catch (SetupException exception) { throw exception; } catch (FlywayException exception) { - LOGGER.error("Database migration history validation failed", exception); + LOGGER.error("数据库迁移历史校验失败", 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); + LOGGER.error("数据库结构检查失败", exception); SQLException sqlException = findSqlException(exception); String detail = sqlException == null ? "数据库结构检查失败:" + safeErrorMessage(exception) - : databaseConnectionFailureMessage(sqlException); + : databaseConnectionFailureMessage(sqlException, true); throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY, "DATABASE_SCHEMA_INSPECTION_FAILED", detail); } @@ -410,37 +412,46 @@ public class SetupService { } } - private ConnectionResult testConnection(DatabaseSettings settings) { + private ConnectionResult testConnection(DatabaseSettings settings, boolean verifyPrivileges) { try (Connection connection = DriverManager.getConnection(settings.jdbcUrl(), settings.username(), - settings.password()); Statement statement = connection.createStatement()) { - statement.setQueryTimeout(10); - String version; - try (ResultSet result = statement.executeQuery("SELECT VERSION()")) { - result.next(); - version = result.getString(1); + settings.password())) { + // A connection test must not create or mutate database objects. JDBC metadata + // is supplied by the handshake and avoids issuing SELECT/DDL/DML in the test step. + String version = connection.getMetaData().getDatabaseProductVersion(); + if (version == null || version.isBlank()) { + version = "unknown"; } if (settings.type() == DatabaseType.MYSQL && !version.startsWith("8.4.")) { throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY, "MYSQL_VERSION_UNSUPPORTED", "当前版本要求 MySQL 8.4.x,检测到 " + version); } - verifyMigrationPrivileges(connection, statement, settings.type()); + if (verifyPrivileges) { + try (Statement statement = connection.createStatement()) { + statement.setQueryTimeout(10); + 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={}", + LOGGER.warn(verifyPrivileges + ? "数据库连接或迁移权限验证失败:SQLState={},错误码={}" + : "数据库只读连接测试失败:SQLState={},错误码={}", exception.getSQLState(), exception.getErrorCode(), exception); throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY, - "DATABASE_CONNECTION_FAILED", databaseConnectionFailureMessage(exception)); + "DATABASE_CONNECTION_FAILED", databaseConnectionFailureMessage(exception, verifyPrivileges)); } } - private String databaseConnectionFailureMessage(SQLException exception) { + private String databaseConnectionFailureMessage(SQLException exception, boolean verifyPrivileges) { if (isPermissionError(exception)) { - return "数据库账号缺少完整迁移权限" + sqlErrorSummary(exception); + return (verifyPrivileges ? "数据库账号缺少完整迁移权限" : "数据库账号无权建立连接") + + sqlErrorSummary(exception); } if (exception.getSQLState() != null && exception.getSQLState().startsWith("08")) { return "数据库连接超时或中断" + sqlErrorSummary(exception); } - return "数据库连接或完整迁移权限验证失败" + sqlErrorSummary(exception); + return (verifyPrivileges ? "数据库连接或完整迁移权限验证失败" : "数据库只读连接测试失败") + + sqlErrorSummary(exception); } private String databaseInitializationFailureMessage(Throwable exception) { @@ -510,14 +521,14 @@ public class SetupService { 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"); + throw new SQLException("迁移权限存储过程探针返回了无效结果"); } } 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"); + throw new SQLException("迁移权限 SELECT 探针返回了无效结果"); } } statement.execute("CREATE TEMPORARY TABLE " + temporaryTable diff --git a/backend/src/main/java/com/kaidi/finance/shared/api/GlobalExceptionHandler.java b/backend/src/main/java/com/kaidi/finance/shared/api/GlobalExceptionHandler.java index 6b2e7c1..ce97af0 100644 --- a/backend/src/main/java/com/kaidi/finance/shared/api/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/kaidi/finance/shared/api/GlobalExceptionHandler.java @@ -81,10 +81,10 @@ public class GlobalExceptionHandler { @ExceptionHandler(Exception.class) public ResponseEntity handleUnexpected(Exception exception, HttpServletRequest request) { if (isLockFailure(exception)) { - log.warn("Concurrent database modification requestId={}", RequestContext.requestId(), exception); + log.warn("并发数据库修改 requestId={}", RequestContext.requestId(), exception); return concurrentModification(request); } - log.error("Unhandled request failure requestId={}", RequestContext.requestId(), exception); + log.error("未处理的请求失败 requestId={}", RequestContext.requestId(), exception); return response(HttpStatus.INTERNAL_SERVER_ERROR, ErrorCode.INTERNAL_ERROR.name(), "系统处理失败,请使用请求编号联系管理员", Map.of(), request); } diff --git a/backend/src/main/java/com/kaidi/finance/shared/audit/DeniedAccessAuditService.java b/backend/src/main/java/com/kaidi/finance/shared/audit/DeniedAccessAuditService.java index 74cb9cc..6690c77 100644 --- a/backend/src/main/java/com/kaidi/finance/shared/audit/DeniedAccessAuditService.java +++ b/backend/src/main/java/com/kaidi/finance/shared/audit/DeniedAccessAuditService.java @@ -39,7 +39,7 @@ public class DeniedAccessAuditService { try { writer.record(method, uri, status.value(), errorCode); } catch (RuntimeException exception) { - log.error("Denied access audit failed requestId={} method={} uri={} code={}", + log.error("拒绝访问审计写入失败 requestId={} method={} uri={} code={}", RequestContext.requestId(), method, uri, errorCode, exception); } } diff --git a/backend/src/main/java/com/kaidi/finance/shared/file/FileApplicationService.java b/backend/src/main/java/com/kaidi/finance/shared/file/FileApplicationService.java index c1cc7c2..c8a62d2 100644 --- a/backend/src/main/java/com/kaidi/finance/shared/file/FileApplicationService.java +++ b/backend/src/main/java/com/kaidi/finance/shared/file/FileApplicationService.java @@ -372,7 +372,7 @@ public class FileApplicationService { try { Files.delete(source); } catch (IOException exception) { - LOGGER.warn("Failed to remove duplicate quarantine source for file {}", plan.publicId(), exception); + LOGGER.warn("删除文件 {} 的重复隔离源失败", plan.publicId(), exception); } } return result; 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 5af6e50..7b82f15 100644 --- a/backend/src/test/java/com/kaidi/finance/setup/SetupApplicationIntegrationTest.java +++ b/backend/src/test/java/com/kaidi/finance/setup/SetupApplicationIntegrationTest.java @@ -80,38 +80,16 @@ class SetupApplicationIntegrationTest { new HttpEntity<>(database), JsonNode.class); 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("schemaReady").asBoolean()).isFalse(); assertThat(tested.getBody().path("data").path("message").asText()) - .contains("排序规则和完整迁移权限验证通过"); + .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_%') - """)) { + try (ResultSet result = statement.executeQuery("SELECT default_collation_name FROM information_schema.schemata WHERE schema_name = DATABASE()")) { 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(); + // A read-only connection test must not normalize the operator's schema. + assertThat(result.getString(1)).isNotEqualTo("utf8mb4_0900_ai_ci"); } } diff --git a/deploy/baota-init.sh b/deploy/baota-init.sh index 79175a4..8f1228e 100755 --- a/deploy/baota-init.sh +++ b/deploy/baota-init.sh @@ -15,12 +15,47 @@ INIT_ARMED=false INIT_COMMITTED=false INIT_RELEASE_ROOT= INIT_APP_ROOT= +LOG_LOCALE=${KAIDI_LOG_LOCALE:-zh-CN} +SERVICE_USER_CREATED=false +SERVICE_GROUP_CREATED=false +CREATED_PATHS=() -log() { printf '[kaidi-baota-init] %s\n' "$*"; } -die() { printf '[kaidi-baota-init] ERROR: %s\n' "$*" >&2; exit 1; } +localize_message() { + local message=$1 + [ "$LOG_LOCALE" = en ] && { printf '%s' "$message"; return; } + case "$message" in + "Run with sudo or as root") printf '请使用 sudo 或 root 运行' ;; + "Baota initialization only supports Linux") printf '宝塔初始化仅支持 Linux' ;; + "Extract the release"*) printf '请先将发布包解压到 APP_ROOT/releases/VERSION' ;; + "The supported Baota project root"*) printf '宝塔项目根目录必须是 /www/wwwroot/kaidi' ;; + "The extracted release is incomplete") printf '解压后的发布包不完整' ;; + "The extracted operations scripts are incomplete") printf '发布包中的运维脚本不完整' ;; + "The release public key is missing") printf '缺少发布公钥' ;; + "The release public-key fingerprint is invalid") printf '发布公钥指纹不匹配' ;; + "Existing user "*" has unexpected home directory "*) printf '现有用户目录不符合 Kaidi 约定:%s' "${message#Existing user }" ;; + "Existing user "*" is not a member"*) printf '现有 Kaidi 用户不属于服务用户组' ;; + "A nologin shell is required") printf '服务用户必须使用 nologin shell' ;; + *" contains a line break") printf '配置项包含换行符,已拒绝:%s' "${message%% contains a line break*}" ;; + "find is required"|"openssl is required"|"sha256sum is required") printf '缺少初始化依赖命令:%s' "${message%% is required}" ;; + "Kaidi is already initialized"*) printf 'Kaidi 已初始化,请先执行卸载流程再重新安装' ;; + "The old kaidi-finance.service"*) printf '检测到旧 kaidi-finance.service,请先执行卸载流程' ;; + "Old Kaidi update units"*) printf '检测到旧 Kaidi 更新单元,请先执行卸载流程' ;; + "The Baota current release link"*) printf '宝塔 current 发布链接已存在,请先执行卸载流程' ;; + "Port must be an integer"*) printf '端口必须是 1024 到 65535 的整数' ;; + "Initialization failed"*) printf '初始化失败,本次创建的文件已回滚,可以修正原因后重试' ;; + "Manual deployment is initialized"*) printf '宝塔手动部署初始化完成:%s' "${message#Manual deployment is initialized for Kaidi Finance }" ;; + "Create the Baota Spring Boot project"*) printf '请在宝塔创建 Spring Boot 项目,项目路径:%s' "${message#Create the Baota Spring Boot project with }" ;; + "Baota environment variables: leave empty") printf '宝塔环境变量请全部留空' ;; + "Setup code: "*) printf '安装码位置:%s' "${message#Setup code: }" ;; + *) printf '%s' "$message" ;; + esac +} + +log() { printf '[kaidi-baota-init] %s\n' "$(localize_message "$*")"; } +die() { printf '[kaidi-baota-init] 错误:%s\n' "$(localize_message "$*")" >&2; exit 1; } rollback_initialization() { - local rc=$? + local rc=$? index path service_uid trap - EXIT HUP INT TERM if [ "$rc" -ne 0 ] && [ "$INIT_ARMED" = true ] && [ "$INIT_COMMITTED" != true ]; then systemctl disable --now kaidi-update.path >/dev/null 2>&1 || true @@ -35,6 +70,18 @@ rollback_initialization() { && [ "$(readlink "$INIT_APP_ROOT/current" 2>/dev/null || true)" = "$INIT_RELEASE_ROOT" ]; then rm -f "$INIT_APP_ROOT/current" fi + rm -f "$CONFIG_ROOT"/*.next.* "$UPDATE_STATE_ROOT"/*.next.* "$STATE_ROOT/setup"/*.next.* + if [ "$SERVICE_USER_CREATED" = true ]; then + service_uid=$(id -u "$SERVICE_USER" 2>/dev/null || true) + [ -z "$service_uid" ] || userdel --force "$SERVICE_USER" >/dev/null 2>&1 || true + fi + if [ "$SERVICE_GROUP_CREATED" = true ]; then + groupdel "$SERVICE_GROUP" >/dev/null 2>&1 || true + fi + for ((index=${#CREATED_PATHS[@]} - 1; index >= 0; index--)); do + path=${CREATED_PATHS[$index]} + rmdir -- "$path" >/dev/null 2>&1 || true + done log "Initialization failed; files created by this attempt were rolled back and the command can be retried" fi exit "$rc" @@ -48,6 +95,10 @@ sha256_file() { sha256sum "$1" | awk '{print $1}' } +record_path() { + [ -e "$1" ] || [ -L "$1" ] || CREATED_PATHS+=("$1") +} + random_secret() { openssl rand -base64 36 | tr -d '\n/+=' | cut -c1-36 } @@ -73,6 +124,7 @@ ensure_service_identity() { local existing_home nologin_path if ! getent group "$SERVICE_GROUP" >/dev/null 2>&1; then groupadd --system "$SERVICE_GROUP" + SERVICE_GROUP_CREATED=true fi if id "$SERVICE_USER" >/dev/null 2>&1; then existing_home=$(getent passwd "$SERVICE_USER" | awk -F: '{print $6}') @@ -86,14 +138,13 @@ ensure_service_identity() { [ -n "$nologin_path" ] || nologin_path=/usr/sbin/nologin [ -x "$nologin_path" ] || die "A nologin shell is required" useradd --system --gid "$SERVICE_GROUP" --home-dir "$STATE_ROOT" --shell "$nologin_path" "$SERVICE_USER" + SERVICE_USER_CREATED=true } main() { local script_dir release_root releases_root app_root version app_port field_key setup_code setup_hash [ "$(id -u)" -eq 0 ] || die "Run with sudo or as root" [ "$(uname -s)" = Linux ] || die "Baota initialization only supports Linux" - command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ] \ - || die "systemd is required for the privileged background updater" for command in find openssl sha256sum; do command -v "$command" >/dev/null 2>&1 || die "$command is required" done @@ -122,7 +173,7 @@ main() { [ ! -e "$CONFIG_ROOT/kaidi.env" ] && [ ! -e "$CONFIG_ROOT/update.env" ] \ && [ ! -e "$STATE_ROOT/setup/locked" ] \ || die "Kaidi is already initialized; run the published purge workflow before a fresh installation" - ! systemctl cat kaidi-finance.service >/dev/null 2>&1 \ + [ ! -e /etc/systemd/system/kaidi-finance.service ] \ || die "The old kaidi-finance.service still exists; run the published purge workflow first" [ ! -e /etc/systemd/system/kaidi-update.service ] \ && [ ! -e /etc/systemd/system/kaidi-update.path ] \ @@ -136,6 +187,10 @@ main() { INIT_ARMED=true ensure_service_identity + for path in "$app_root" "$releases_root" "$STATE_ROOT" "$STATE_ROOT/files" "$STATE_ROOT/tmp" \ + "$LOG_ROOT" "$STATE_ROOT/setup" "$UPDATE_STATE_ROOT" "$UPDATE_STATE_ROOT/inbox" "$CONFIG_ROOT"; do + record_path "$path" + done install -d -o root -g "$SERVICE_GROUP" -m 0750 "$app_root" "$releases_root" install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0750 \ "$STATE_ROOT" "$STATE_ROOT/files" "$STATE_ROOT/tmp" "$LOG_ROOT" @@ -193,16 +248,16 @@ main() { # panel lifecycle can participate in the transaction state machine. install -m 0644 "$release_root/ops/release-public.pem" "$CONFIG_ROOT/release-public.pem" - printf '{"state":"CURRENT","message":"Baota release is prepared","targetVersion":"%s","updatedAt":"%s"}\n' \ + printf '{"state":"CURRENT","message":"宝塔发布已准备","targetVersion":"%s","updatedAt":"%s"}\n' \ "$version" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$UPDATE_STATE_ROOT/status.json" chmod 0644 "$UPDATE_STATE_ROOT/status.json" cat > /root/kaidi-first-login.txt <&2 + printf '[kaidi-baota] 错误:%s\n' "$(localize_message "$1")" >&2 exit 1 } diff --git a/deploy/compose.yaml b/deploy/compose.yaml index 0713023..4b050da 100644 --- a/deploy/compose.yaml +++ b/deploy/compose.yaml @@ -1,5 +1,8 @@ services: mysql: + # Local-development fixture only. Production installers never invoke + # Compose and never create this service. + profiles: [local-db] image: mysql:8.4 container_name: kaidi-finance-mysql restart: unless-stopped diff --git a/deploy/env.production.example b/deploy/env.production.example index 33210f4..2b5d032 100644 --- a/deploy/env.production.example +++ b/deploy/env.production.example @@ -15,7 +15,7 @@ FILE_SCANNER_ENABLED=true FINANCE_BOOTSTRAP_ENABLED=false FINANCE_BOOTSTRAP_PASSWORD= -APP_VERSION=1.0.0-preview.41 +APP_VERSION=1.0.0-preview.42 UPDATE_CURRENT_VERSION_FILE=/opt/kaidi/current/VERSION FINANCE_UPDATE_ENABLED=true # Use either a stable direct asset base URL or the public Gitea latest-release API. diff --git a/deploy/install-from-git.sh b/deploy/install-from-git.sh index 34c695e..44cd3c0 100755 --- a/deploy/install-from-git.sh +++ b/deploy/install-from-git.sh @@ -5,13 +5,31 @@ umask 077 ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) INSTALLER="$ROOT/deploy/install.sh" -INSTALLER_SHA256=${KAIDI_INSTALLER_SHA256:-fcc4c132812eaa304e2459f3b0cc176d75552afb6bd6a3be7c347fbf8f01e51b} +INSTALLER_SHA256=${KAIDI_INSTALLER_SHA256:-5aadfab9bdeef9279aeab6eee9baaae7182b2f3338fa61a558b3c073b69ad396} RELEASE_API_URL=${KAIDI_RELEASE_API_URL:-https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest} PUBLIC_KEY_SHA256=${KAIDI_RELEASE_PUBLIC_KEY_SHA256:-807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9} TOKEN_FILE=${KAIDI_RELEASE_TOKEN_FILE:-} log() { printf '[kaidi-git-install] %s\n' "$*"; } -die() { printf '[kaidi-git-install] ERROR: %s\n' "$*" >&2; exit 1; } + +localize_message() { + local message=$1 + [ "${KAIDI_LOG_LOCALE:-zh-CN}" = en ] && { printf '%s' "$message"; return; } + case "$message" in + "deploy/install.sh is missing"*) printf '缺少 deploy/install.sh,请在 Kaidi Git 工作区运行此命令' ;; + "sha256sum is required") printf '需要 sha256sum' ;; + "The installer SHA-256 is invalid") printf '安装器 SHA-256 无效' ;; + "The release public-key SHA-256 is invalid") printf '发布公钥 SHA-256 无效' ;; + "deploy/install.sh does not match"*) printf 'deploy/install.sh 与该 Git 标签的受信摘要不一致' ;; + "sudo is required when not running as root") printf '非 root 用户运行时需要 sudo' ;; + "The Gitea token file is not a regular file") printf 'Gitea Token 文件必须是普通文件' ;; + "The Gitea token file must contain 1 to 512 bytes") printf 'Gitea Token 文件必须包含 1 到 512 字节' ;; + "The Gitea token file contains invalid control characters") printf 'Gitea Token 文件包含无效控制字符' ;; + *) printf '%s' "$message" ;; + esac +} + +die() { printf '[kaidi-git-install] 错误:%s\n' "$(localize_message "$*")" >&2; exit 1; } [ -f "$INSTALLER" ] || die "deploy/install.sh is missing; run this command from the Kaidi Git checkout" command -v sha256sum >/dev/null 2>&1 || die "sha256sum is required" @@ -52,19 +70,19 @@ KAIDI_DB_PASSWORD=${KAIDI_DB_PASSWORD:-} KAIDI_DB_HOST=${KAIDI_DB_HOST:-} KAIDI_DB_PORT=${KAIDI_DB_PORT:-} KAIDI_DB_NAME=${KAIDI_DB_NAME:-} -KAIDI_DB_CONTAINER=${KAIDI_DB_CONTAINER:-} KAIDI_SESSION_COOKIE_SECURE=${KAIDI_SESSION_COOKIE_SECURE:-} KAIDI_FILE_SCANNER_ENABLED=${KAIDI_FILE_SCANNER_ENABLED:-} +KAIDI_DB_BACKUP_MODE=${KAIDI_DB_BACKUP_MODE:-} for name in KAIDI_REINSTALL KAIDI_SETUP_WIZARD KAIDI_APP_PORT KAIDI_SERVER_ADDRESS \ KAIDI_DB_URL KAIDI_DB_USERNAME KAIDI_DB_PASSWORD \ - KAIDI_DB_HOST KAIDI_DB_PORT KAIDI_DB_NAME KAIDI_DB_CONTAINER \ - KAIDI_SESSION_COOKIE_SECURE KAIDI_FILE_SCANNER_ENABLED; do + KAIDI_DB_HOST KAIDI_DB_PORT KAIDI_DB_NAME \ + KAIDI_SESSION_COOKIE_SECURE KAIDI_FILE_SCANNER_ENABLED KAIDI_DB_BACKUP_MODE; do value=${!name} if [ -n "$value" ]; then install_env+=("$name=$value") fi done -log "Installing the latest signed release from $RELEASE_API_URL" +log "正在从 Gitea Release API 安装最新签名版本:$RELEASE_API_URL" "${root_command[@]}" "${install_env[@]}" bash "$INSTALLER" diff --git a/deploy/install.sh b/deploy/install.sh index f5145fd..312917b 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -38,9 +38,125 @@ JAVA_ACTIVATED=false SERVICE_USER=kaidi SERVICE_GROUP=kaidi HOST_ARCH= +LOG_LOCALE=${KAIDI_LOG_LOCALE:-zh-CN} +SERVICE_USER_CREATED=false +SERVICE_GROUP_CREATED=false +CREATED_LAYOUT_PATHS=() -log() { printf '[kaidi-install] %s\n' "$*"; } -die() { printf '[kaidi-install] ERROR: %s\n' "$*" >&2; exit 1; } +localize_message() { + local message=$1 + [ "$LOG_LOCALE" = en ] && { printf '%s' "$message"; return; } + case "$message" in + "Unsupported CPU architecture: "*) printf '不支持的 CPU 架构:%s' "${message#Unsupported CPU architecture: }" ;; + "The installer only supports Linux") printf '安装程序仅支持 Linux' ;; + "Custom installation roots"*) printf '打包版 systemd 不支持自定义安装目录' ;; + "systemd is required") printf '需要 systemd' ;; + "systemd-analyze is required") printf '需要 systemd-analyze' ;; + "getconf is required") printf '需要 getconf' ;; + "systemd is not running as PID 1") printf 'systemd 当前不是 PID 1,无法托管服务' ;; + "/etc/systemd/system is missing") printf '缺少 /etc/systemd/system' ;; + "CPU architecture and userspace word size"*) printf 'CPU 架构与用户空间位数不匹配:%s' "${message#*: }" ;; + "32-bit Linux deployment requires glibc"*) printf '32 位 Linux 需要 glibc;当前系统不兼容 i686 Java' ;; + "32-bit Linux deployment requires the glibc loader"*) printf '32 位 Linux 缺少 glibc 加载器 /lib/ld-linux.so.2' ;; + "Set KAIDI_RELEASE_PUBLIC_KEY_SHA256"*) printf '请设置受信任的发布公钥 SHA-256:KAIDI_RELEASE_PUBLIC_KEY_SHA256' ;; + "Run with sudo or as root") printf '请使用 sudo 或 root 运行' ;; + "Set only one of KAIDI_RELEASE_TOKEN"*) printf 'KAIDI_RELEASE_TOKEN 与 KAIDI_RELEASE_TOKEN_FILE 只能设置一个' ;; + "KAIDI_RELEASE_TOKEN_FILE must be a regular file") printf 'KAIDI_RELEASE_TOKEN_FILE 必须是普通文件' ;; + "KAIDI_RELEASE_TOKEN_FILE is too large") printf 'KAIDI_RELEASE_TOKEN_FILE 过大' ;; + "KAIDI_REINSTALL must be true or false") printf 'KAIDI_REINSTALL 只能是 true 或 false' ;; + "KAIDI_SETUP_WIZARD must be true or false") printf 'KAIDI_SETUP_WIZARD 只能是 true 或 false' ;; + "A setup-wizard repair needs"*) printf '安装向导修复需要已有配置文件:%s' "${message#A setup-wizard repair needs the existing }" ;; + "The existing installation is already in production mode"*) printf '现有安装已是正式模式,请执行普通修复重装' ;; + "The setup wizard is already locked"*) printf '安装向导已锁定,请执行普通修复重装' ;; + "Kaidi Finance is already installed"*) printf 'Kaidi 财务系统已安装,请从系统更新页面执行更新或先卸载' ;; + "Set KAIDI_RELEASE_BASE_URL"*) printf '请设置 Gitea Release 源:KAIDI_RELEASE_BASE_URL 或 KAIDI_RELEASE_API_URL' ;; + "KAIDI_RELEASE_TOKEN is invalid") printf 'KAIDI_RELEASE_TOKEN 无效' ;; + "Download failed: "*) printf '下载失败:%s' "${message#Download failed: }" ;; + "Release URLs must use HTTPS") printf '发布地址必须使用 HTTPS' ;; + "Release authentication header is unavailable") printf '发布认证请求头不可用' ;; + "Release asset "*" is missing") printf '缺少发布资产:%s' "${message#Release asset }" ;; + "Gitea Release tag is invalid") printf 'Gitea Release 标签无效' ;; + "KAIDI_RELEASE_API_URL must be a Gitea"*) printf 'KAIDI_RELEASE_API_URL 必须是 Gitea Release API 地址' ;; + "Required command is missing"*) printf '依赖命令缺失:%s' "${message#*: }" ;; + "Supported package managers"*) printf '仅支持 apt、dnf 或 yum' ;; + "Using existing Java"*) printf '使用现有 Java:%s' "${message#Using existing Java 17+ runtime at }" ;; + "Local Java verified: "*) printf '本机 Java 校验通过:%s' "${message#Local Java verified: }" ;; + "No local Java"*) printf '未找到可用 Java 17,正在下载 Azul Java 17 运行时' ;; + "Java 17 runtime download URL"*) printf 'Java 17 运行时下载地址无效' ;; + "Downloaded Java verified: "*) printf '下载的 Java 校验通过:%s' "${message#Downloaded Java verified: }" ;; + "The existing Java runtime cannot start"*) printf '现有 Java 运行时无法启动,请检查架构和权限' ;; + "No Java 17 runtime is published"*) printf '当前 Linux 架构没有可用的 Java 17 运行时' ;; + "Java 17 runtime SHA-256 verification failed") printf 'Java 17 运行时 SHA-256 校验失败' ;; + "Downloaded Java runtime architecture"*) printf '下载的 Java 运行时架构与主机不匹配' ;; + "The downloaded Java runtime cannot start"*) printf '下载的 Java 运行时无法启动,请检查 glibc 和主机架构' ;; + "Java 17 runtime SHA-256 is unavailable") printf 'Java 17 运行时缺少 SHA-256 摘要' ;; + "Existing user "*" has unexpected home directory "*) printf '现有用户目录不符合 Kaidi 约定:%s' "${message#Existing user }" ;; + "Service-user filesystem and Java access checks passed") printf '服务用户文件系统与 Java 访问检查通过' ;; + *" cannot traverse or read the active release") printf '服务用户无法进入或读取当前发布目录' ;; + "The selected Java runtime is unavailable"*) printf '选定的 Java 运行时不可用:%s' "${message#The selected Java runtime is unavailable at }" ;; + "The selected Java runtime cannot execute"*) printf '服务用户无法执行选定的 Java 运行时' ;; + *" cannot execute the selected Java runtime") printf '服务用户无法执行选定的 Java 运行时' ;; + *" cannot execute as "*) printf '服务用户无法启动 Java 运行时' ;; + *" cannot write the file-storage directory") printf '服务用户无法写入文件存储目录' ;; + *" cannot write the update inbox") printf '服务用户无法写入更新请求目录' ;; + "SELinux context restoration reported"*) printf 'SELinux 上下文恢复有提示,将继续进行服务访问检查' ;; + "No interactive terminal detected"*) printf '未检测到交互终端,使用应用端口 %s' "${message##*port }" ;; + "Application port "*" is already held"*) printf '应用端口已由现有 Kaidi 服务占用:%s' "${message#Application port }" ;; + "Application port "*" is already in use"*) printf '应用端口已被占用,请使用 KAIDI_APP_PORT 更换端口' ;; + "Application will bind "*) printf '应用监听地址:%s' "${message#Application will bind }" ;; + "Reverse proxy target: "*) printf '反向代理目标:%s' "${message#Reverse proxy target: }" ;; + "KAIDI_SERVER_ADDRESS contains"*) printf 'KAIDI_SERVER_ADDRESS 含有不支持的字符' ;; + "KAIDI_APP_PORT must be an integer"*) printf 'KAIDI_APP_PORT 必须是 1024 到 65535 的整数' ;; + "Do not pass database credentials"*) printf '向导模式不要在命令行传数据库账号密码,请在浏览器向导中填写' ;; + "KAIDI_DB_USERNAME and KAIDI_DB_PASSWORD"*) printf '设置 KAIDI_DB_URL 时必须同时提供数据库用户名和密码' ;; + "An external MySQL 8.4 database is required"*) printf '需要连接用户预先准备的 MySQL 8.4 数据库:请设置 KAIDI_DB_URL、KAIDI_DB_USERNAME、KAIDI_DB_PASSWORD' ;; + "KAIDI_REINSTALL needs"*) printf 'KAIDI_REINSTALL 需要读取已有安装配置或提供数据库配置' ;; + "The database host is empty") printf '数据库主机不能为空' ;; + "The database port is invalid") printf '数据库端口无效' ;; + "The database name is invalid") printf '数据库名无效,只能包含字母、数字、下划线和美元符号' ;; + "KAIDI_DB_URL must start with jdbc:mysql://") printf 'KAIDI_DB_URL 必须以 jdbc:mysql:// 开头' ;; + "KAIDI_DB_USERNAME is empty") printf 'KAIDI_DB_USERNAME 不能为空' ;; + "KAIDI_DB_PASSWORD is empty") printf 'KAIDI_DB_PASSWORD 不能为空' ;; + "KAIDI_DB_BACKUP_MODE must be skip or mysqldump") printf 'KAIDI_DB_BACKUP_MODE 只能是 skip 或 mysqldump' ;; + "Waiting for application startup"*) printf '正在等待应用启动:%s' "${message#Waiting for application startup at }" ;; + "Application health check is UP") printf '应用健康检查通过(UP)' ;; + "Application is still starting"*) printf '应用仍在启动:%s' "${message#Application is still starting }" ;; + "Application service failed"*) printf '应用服务启动失败:%s' "${message#Application service failed during startup }" ;; + "Application health check did not become UP") printf '应用健康检查未变为 UP,请查看服务日志' ;; + "Application frontend entry point is unavailable") printf '应用前端入口不可访问,请检查服务和端口' ;; + "Installation failed; restoring"*) printf '安装失败,正在恢复原有运行状态' ;; + "ERROR: rollback was incomplete"*) printf '错误:回滚未完成,请检查 systemd 状态' ;; + *" contains a line break") printf '配置项包含换行符,已拒绝:%s' "${message%% contains a line break*}" ;; + "Kaidi Finance "*" is installed") printf 'Kaidi 财务系统 %s 已安装' "${message#Kaidi Finance }" ;; + "Configure your reverse proxy"*) printf '请将反向代理指向:%s' "${message#Configure your reverse proxy to }" ;; + "Open /setup"*) printf '请通过反向代理域名打开 /setup,完成首次安装向导' ;; + "Setup code: "*) printf '安装码位置:%s' "${message#Setup code: }" ;; + "Open the reverse-proxy domain"*) printf '请打开反向代理域名并使用管理员账号登录' ;; + "Temporary credentials: "*) printf '临时凭据位置:%s' "${message#Temporary credentials: }" ;; + "Change the temporary password"*) printf '首次登录后请立即修改临时密码' ;; + "Release public-key SHA-256 does not match"*) printf '发布公钥 SHA-256 与受信指纹不一致' ;; + "Release manifest signature verification failed") printf '发布清单签名校验失败' ;; + "Release version is invalid") printf '发布版本无效' ;; + "Release artifact name is invalid") printf '发布包文件名无效' ;; + "Release SHA-256 is invalid") printf '发布包 SHA-256 无效' ;; + "Release artifact SHA-256 verification failed") printf '发布包 SHA-256 校验失败' ;; + "Release archive contains an unsafe path") printf '发布归档包含不安全路径,已拒绝' ;; + "Release archive must not contain symbolic links") printf '发布归档不能包含符号链接,已拒绝' ;; + "Release app.jar is missing") printf '发布包缺少 app.jar' ;; + "Release frontend is missing") printf '发布包缺少前端文件' ;; + "Release version mismatch") printf '发布包版本与清单不一致' ;; + "Release updater is missing") printf '发布包缺少更新脚本' ;; + "Baota Spring Boot launcher is missing") printf '发布包缺少宝塔启动脚本' ;; + "Baota Spring Boot launcher is invalid") printf '宝塔启动脚本语法无效' ;; + "The existing FIELD_ENCRYPTION_KEY is missing") printf '已有安装缺少 FIELD_ENCRYPTION_KEY' ;; + "Installed systemd units failed validation") printf '已安装的 systemd 单元校验失败' ;; + "Environment verified: "*) printf '环境检查通过:%s' "${message#Environment verified: }" ;; + *) printf '%s' "$message" ;; + esac +} + +log() { printf '[kaidi-install] %s\n' "$(localize_message "$*")"; } +die() { printf '[kaidi-install] 错误:%s\n' "$(localize_message "$*")" >&2; exit 1; } sha256_file() { sha256sum "$1" | awk '{print $1}' @@ -208,15 +324,23 @@ download_release_asset() { install_packages() { if command -v apt-get >/dev/null 2>&1; then export DEBIAN_FRONTEND=noninteractive - apt-get update -qq - apt-get install -y -qq ca-certificates coreutils curl findutils gzip jq openssl tar util-linux + apt-get update -qq >/dev/null 2>&1 \ + || die "系统软件源更新失败,请检查网络和 apt 配置" + apt-get install -y -qq ca-certificates coreutils curl findutils gzip jq openssl tar util-linux \ + >/dev/null 2>&1 \ + || die "系统依赖安装失败;安装器不会安装 MySQL 或 Docker" elif command -v dnf >/dev/null 2>&1; then - dnf install -y ca-certificates coreutils curl findutils gzip jq openssl tar util-linux + dnf install -y ca-certificates coreutils curl findutils gzip jq openssl tar util-linux \ + >/dev/null 2>&1 \ + || die "系统依赖安装失败;安装器不会安装 MySQL 或 Docker" elif command -v yum >/dev/null 2>&1; then - yum install -y ca-certificates coreutils curl findutils gzip jq openssl tar util-linux + yum install -y ca-certificates coreutils curl findutils gzip jq openssl tar util-linux \ + >/dev/null 2>&1 \ + || die "系统依赖安装失败;安装器不会安装 MySQL 或 Docker" else die "Supported package managers are apt, dnf, and yum" fi + log "系统依赖检查完成(未安装 MySQL、MariaDB、Docker)" } preflight_runtime_commands() { @@ -230,31 +354,6 @@ preflight_runtime_commands() { done } -mysql_client_bin() { - local candidate - if [ -n "${KAIDI_MYSQL_CLIENT:-}" ]; then - case "$KAIDI_MYSQL_CLIENT" in - */*) [ -x "$KAIDI_MYSQL_CLIENT" ] && printf '%s\n' "$KAIDI_MYSQL_CLIENT" && return 0 ;; - *) candidate=$(command -v "$KAIDI_MYSQL_CLIENT" 2>/dev/null || true); [ -n "$candidate" ] && printf '%s\n' "$candidate" && return 0 ;; - esac - return 1 - fi - candidate=$(command -v mysql 2>/dev/null || command -v mariadb 2>/dev/null || true) - if [ -n "$candidate" ]; then - printf '%s\n' "$candidate" - return 0 - fi - for candidate in \ - /www/server/mysql/bin/mysql /www/server/mysql/bin/mariadb \ - /usr/bin/mysql /usr/bin/mariadb /usr/local/mysql/bin/mysql /opt/mysql/bin/mysql; do - if [ -x "$candidate" ]; then - printf '%s\n' "$candidate" - return 0 - fi - done - return 1 -} - azul_arch() { case "${HOST_ARCH:-$(normalized_host_arch)}" in x86_64) printf x86 ;; @@ -327,15 +426,15 @@ system_java_home() { } prepare_java() { - local api java_metadata package_metadata package_uuid java_url java_sha256 actual_sha256 system_home java_line + local api java_metadata package_metadata package_uuid java_url java_sha256 actual_sha256 system_home java_line java_version JAVA_BIN= JAVA_STAGED_DIR= if system_home=$(system_java_home); then JAVA_BIN="$system_home/bin/java" java_line=$("$JAVA_BIN" -version 2>&1 | head -n 1) \ || die "The existing Java runtime cannot start on this host" - log "Using existing Java 17+ runtime at $system_home" - log "Local Java verified: $java_line" + java_version=$(printf '%s\n' "$java_line" | sed -n 's/.*version "\([0-9][0-9.]*\).*/\1/p') + log "使用现有 Java 运行时:$system_home(版本 ${java_version:-未知})" return 0 fi log "No local Java 17 runtime found; downloading the official Azul Java 17 runtime" @@ -362,7 +461,8 @@ prepare_java() { java_line=$("$JAVA_STAGED_DIR/bin/java" -version 2>&1 | head -n 1) \ || die "The downloaded Java runtime cannot start; verify glibc and the host architecture" JAVA_BIN="$APP_ROOT/runtime/java/bin/java" - log "Downloaded Java verified: $java_line" + java_version=$(printf '%s\n' "$java_line" | sed -n 's/.*version "\([0-9][0-9.]*\).*/\1/p') + log "下载的 Java 运行时校验通过(版本 ${java_version:-未知})" } activate_java() { @@ -383,6 +483,7 @@ ensure_service_identity() { command -v getent >/dev/null 2>&1 || die "getent is required" if ! getent group "$SERVICE_GROUP" >/dev/null 2>&1; then groupadd --system "$SERVICE_GROUP" + SERVICE_GROUP_CREATED=true fi if id "$SERVICE_USER" >/dev/null 2>&1; then existing_home=$(getent passwd "$SERVICE_USER" | awk -F: '{print $6}') @@ -397,10 +498,27 @@ ensure_service_identity() { [ -n "$nologin_path" ] || nologin_path=/usr/sbin/nologin [ -x "$nologin_path" ] || die "A nologin shell is required" useradd --system --gid "$SERVICE_GROUP" --home-dir "$STATE_ROOT" --shell "$nologin_path" "$SERVICE_USER" + SERVICE_USER_CREATED=true +} + +record_layout_path() { + [ -e "$1" ] || [ -L "$1" ] || CREATED_LAYOUT_PATHS+=("$1") } prepare_managed_layout() { command -v runuser >/dev/null 2>&1 || die "runuser is required" + record_layout_path "$APP_ROOT" + record_layout_path "$APP_ROOT/releases" + record_layout_path "$APP_ROOT/runtime" + record_layout_path "$APP_ROOT/bin" + record_layout_path "$STATE_ROOT" + record_layout_path "$STATE_ROOT/files" + record_layout_path "$STATE_ROOT/tmp" + record_layout_path /var/log/kaidi + record_layout_path "$STATE_ROOT/setup" + record_layout_path "$UPDATE_STATE_ROOT" + record_layout_path "$UPDATE_STATE_ROOT/inbox" + record_layout_path "$CONFIG_ROOT" install -d -o root -g "$SERVICE_GROUP" -m 0750 \ "$APP_ROOT" "$APP_ROOT/releases" "$APP_ROOT/runtime" "$APP_ROOT/bin" install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0750 \ @@ -529,7 +647,7 @@ configure_app_port() { default_port=$(read_reinstall_env SERVER_PORT || true) [[ "$default_port" =~ ^[0-9]{1,5}$ ]] || default_port=18080 if [ -t 1 ] && [ -r /dev/tty ]; then - printf '[kaidi-install] Application port [%s]: ' "$default_port" > /dev/tty + printf '[kaidi-install] 应用端口 [%s]:' "$default_port" > /dev/tty if IFS= read -r entered < /dev/tty; then APP_PORT=${entered:-$default_port} else @@ -640,48 +758,36 @@ database_name() { printf '%s' "${KAIDI_DB_NAME:-$name}" } -preflight_database() { - local client host port name version table +validate_database_configuration() { [ "$SETUP_WIZARD" != true ] || return 0 case "$DB_URL" in jdbc:mysql://*) ;; *) die "KAIDI_DB_URL must start with jdbc:mysql://" ;; esac - client=$(mysql_client_bin || true) - [ -n "$client" ] || die "A MySQL command-line client is required; set KAIDI_MYSQL_CLIENT or add mysql/mariadb to PATH" - host=$(database_host) - port=$(database_port) - name=$(database_name) - [ -n "$host" ] || die "The database host is empty" - [[ "$port" =~ ^[0-9]{1,5}$ ]] && [ "$port" -ge 1 ] && [ "$port" -le 65535 ] \ + [ -n "$DB_USERNAME" ] || die "KAIDI_DB_USERNAME is empty" + [ -n "$DB_PASSWORD" ] || die "KAIDI_DB_PASSWORD is empty" + [ -n "$(database_host)" ] || die "The database host is empty" + [[ "$(database_port)" =~ ^[0-9]{1,5}$ ]] \ + && [ "$(database_port)" -ge 1 ] && [ "$(database_port)" -le 65535 ] \ || die "The database port is invalid" - [[ "$name" =~ ^[A-Za-z0-9_$]+$ ]] || die "The database name is invalid" + [[ "$(database_name)" =~ ^[A-Za-z0-9_$]+$ ]] || die "The database name is invalid" + # The installer deliberately does not invoke mysql/mariadb and does not + # execute DDL/DML. The application performs Flyway migrations only after + # it has connected to the operator-provided schema. + log "已记录外部 MySQL 连接配置;安装器不安装 MySQL、不连接数据库、不执行 SQL" +} - version=$(MYSQL_PWD="$DB_PASSWORD" "$client" --protocol=TCP --connect-timeout=10 \ - -h "$host" -P "$port" -u "$DB_USERNAME" "$name" --batch --skip-column-names \ - -e 'SELECT VERSION()' 2>/dev/null) || die "Cannot connect to the configured MySQL database" - case "$version" in - 8.4.*) ;; - *) die "MySQL 8.4.x is required; server reported $version" ;; +# Kept as a compatibility name for older local fixtures and wrappers. It is +# now a pure configuration check and has no database side effects. +preflight_database() { + validate_database_configuration +} + +validate_database_backup_mode() { + case "${KAIDI_DB_BACKUP_MODE:-skip}" in + skip|mysqldump) ;; + *) die "KAIDI_DB_BACKUP_MODE must be skip or mysqldump" ;; esac - - table="kaidi_install_preflight_$$" - if ! MYSQL_PWD="$DB_PASSWORD" "$client" --protocol=TCP --connect-timeout=10 \ - -h "$host" -P "$port" -u "$DB_USERNAME" "$name" >/dev/null </dev/null 2>&1 || true - die "The database account needs DDL and DML permissions on $name" - fi - log "MySQL $version connectivity and DDL/DML permissions verified" } write_env_file() { @@ -840,7 +946,7 @@ restore_managed_path() { } rollback_install() { - local index=0 path rollback_failed=false + local index=0 path rollback_failed=false layout_path service_uid set +e log "Installation failed; restoring the previous managed state" systemctl stop kaidi-update.path kaidi-finance.service >/dev/null 2>&1 || true @@ -858,6 +964,19 @@ rollback_install() { elif [ "$JAVA_ACTIVATED" = true ]; then rm -rf -- "$APP_ROOT/runtime/java" || rollback_failed=true fi + if [ "$SERVICE_USER_CREATED" = true ]; then + service_uid=$(id -u "$SERVICE_USER" 2>/dev/null || true) + if [ -n "$service_uid" ]; then + userdel --force "$SERVICE_USER" >/dev/null 2>&1 || rollback_failed=true + fi + fi + if [ "$SERVICE_GROUP_CREATED" = true ] && getent group "$SERVICE_GROUP" >/dev/null 2>&1; then + groupdel "$SERVICE_GROUP" >/dev/null 2>&1 || rollback_failed=true + fi + for ((index=${#CREATED_LAYOUT_PATHS[@]} - 1; index >= 0; index--)); do + layout_path=${CREATED_LAYOUT_PATHS[$index]} + rmdir -- "$layout_path" >/dev/null 2>&1 || true + done systemctl daemon-reload >/dev/null 2>&1 || rollback_failed=true restore_unit_state kaidi-finance.service "$PREVIOUS_APP_ENABLED" "$PREVIOUS_APP_ACTIVE" \ || rollback_failed=true @@ -882,6 +1001,7 @@ main() { trap cleanup EXIT validate_inputs +validate_database_backup_mode configure_app_port preflight_host install_packages @@ -1025,13 +1145,13 @@ write_env_file "$CONFIG_ROOT/update.env" \ KAIDI_JAVA_BIN "$JAVA_BIN" \ KAIDI_HEALTH_URL "$HEALTH_URL" \ KAIDI_APP_INDEX_URL "$APP_INDEX_URL" \ - KAIDI_DB_CONTAINER "${KAIDI_DB_CONTAINER:-}" \ KAIDI_DB_HOST "$(database_host)" \ KAIDI_DB_PORT "$(database_port)" \ KAIDI_DB_NAME "$(database_name)" \ KAIDI_DB_USERNAME "$DB_USERNAME" \ KAIDI_DB_PASSWORD "$DB_PASSWORD" \ - KAIDI_MYSQLDUMP_BIN "${KAIDI_MYSQLDUMP_BIN:-}" + KAIDI_MYSQLDUMP_BIN "${KAIDI_MYSQLDUMP_BIN:-}" \ + KAIDI_DB_BACKUP_MODE "${KAIDI_DB_BACKUP_MODE:-skip}" chmod 0600 "$CONFIG_ROOT/update.env" install -m 0644 "$RELEASE_DIR/ops/kaidi-finance.service" /etc/systemd/system/kaidi-finance.service @@ -1057,26 +1177,26 @@ if [ "$SETUP_WIZARD" != true ]; then sed -i 's/^FINANCE_BOOTSTRAP_PASSWORD=.*$/FINANCE_BOOTSTRAP_PASSWORD=""/' "$CONFIG_ROOT/kaidi.env" fi jq -n --arg version "$VERSION" --arg updatedAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ - '{state:"CURRENT",message:"Initial release is running",targetVersion:$version,updatedAt:$updatedAt}' \ + '{state:"CURRENT",message:"初始版本正在运行",targetVersion:$version,updatedAt:$updatedAt}' \ > "$UPDATE_STATE_ROOT/status.json" chmod 0644 "$UPDATE_STATE_ROOT/status.json" systemctl enable --now kaidi-update.path if [ "$SETUP_WIZARD" = true ]; then cat > /root/kaidi-first-login.txt < /root/kaidi-first-login.txt <&2; exit 1; } +localize_message() { + local message=$1 + [ "$LOG_LOCALE" = en ] && { printf '%s' "$message"; return; } + case "$message" in + "Run with sudo or as root") printf '请使用 sudo 或 root 运行' ;; + "The purge script only supports Linux") printf '卸载程序仅支持 Linux' ;; + "Set KAIDI_PURGE_CONFIRM="*) printf '请设置确认变量:KAIDI_PURGE_CONFIRM=%s' "${message#Set KAIDI_PURGE_CONFIRM=}" ;; + "KAIDI_PURGE_BACKUP_ROOT must be an absolute path") printf 'KAIDI_PURGE_BACKUP_ROOT 必须是绝对路径' ;; + "KAIDI_PURGE_DELETE_BACKUP must be true or false") printf 'KAIDI_PURGE_DELETE_BACKUP 只能是 true 或 false' ;; + "The recovery backup must be outside"*) printf '恢复备份目录必须位于 Kaidi 管理目录之外' ;; + "The recovery backup root must not be a symbolic link") printf '恢复备份目录不能是符号链接' ;; + "Failed to stop "*) printf '停止服务失败:%s' "${message#Failed to stop }" ;; + "Stopping processes owned by"*) printf '正在停止专用服务账号进程:%s' "${message##*: }" ;; + "Stopping remaining Kaidi processes: "*) printf '正在停止剩余 Kaidi 进程:%s' "${message#Stopping remaining Kaidi processes: }" ;; + "A process manager restarted"*) printf '检测到宝塔等进程管理器正在重新拉起 Kaidi;请先停止并删除宝塔中的 Kaidi 项目,再重试卸载' ;; + "No local configuration or data"*) printf '没有需要备份的本地配置或数据' ;; + "Creating a root-only recovery backup at "*) printf '正在创建仅 root 可读的恢复备份:%s' "${message#Creating a root-only recovery backup at }" ;; + "Failed to remove the dedicated"*) printf '删除 Kaidi 专用服务账号失败' ;; + "Processes owned by the removed"*) printf '删除服务账号后仍有进程运行' ;; + "Keeping pre-existing user"*) printf '保留预先存在的用户:%s' "${message#Keeping pre-existing user }" ;; + "Keeping group "*) printf '保留仍被其他账号使用的用户组:%s' "${message#Keeping group }" ;; + "External MySQL data and reverse-proxy configuration will not be modified") printf '不会修改外部 MySQL 数据和反向代理配置' ;; + "Local Kaidi installation state has been removed") printf '本地 Kaidi 安装文件、服务和运行状态已删除' ;; + "Recovery backup: "*) printf '恢复备份:%s' "${message#Recovery backup: }" ;; + "Use a new empty MySQL database for the next installation") printf '重新安装时请使用新的空 MySQL 数据库' ;; + *) printf '%s' "$message" ;; + esac +} + +log() { printf '[kaidi-purge] %s\n' "$(localize_message "$*")"; } +die() { printf '[kaidi-purge] 错误:%s\n' "$(localize_message "$*")" >&2; exit 1; } systemd_available() { command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ] @@ -40,6 +71,10 @@ validate_inputs() { ;; esac [ ! -L "$BACKUP_ROOT" ] || die "The recovery backup root must not be a symbolic link" + case "$DELETE_RECOVERY_BACKUP" in + true|false) ;; + *) die "KAIDI_PURGE_DELETE_BACKUP must be true or false" ;; + esac } stop_systemd_units() { @@ -206,7 +241,20 @@ remove_managed_paths() { } remove_download_archives() { - rm -f -- /tmp/kaidi-finance-*.tar.gz + rm -f -- \ + /tmp/kaidi-finance-*.tar.gz \ + /tmp/kaidi-purge.sh \ + /tmp/kaidi-SHA256SUMS \ + /tmp/kaidi-install.sh +} + +verify_managed_paths_removed() { + local path + for path in "$APP_ROOT" "$BAOTA_ROOT" "$CONFIG_ROOT" "$STATE_ROOT" \ + "$UPDATE_STATE_ROOT" "$LOG_ROOT" "$FIRST_LOGIN_FILE"; do + [ ! -e "$path" ] && [ ! -L "$path" ] \ + || die "卸载后仍发现受管路径:$path" + done } remove_service_identity() { @@ -258,9 +306,15 @@ main() { remove_download_archives remove_service_identity stop_managed_processes false + verify_managed_paths_removed log "Local Kaidi installation state has been removed" if [ -n "$BACKUP_ARCHIVE" ]; then log "Recovery backup: $BACKUP_ARCHIVE" + if [ "$DELETE_RECOVERY_BACKUP" = true ]; then + rm -f -- "$BACKUP_ARCHIVE" + rmdir -- "$BACKUP_ROOT" >/dev/null 2>&1 || true + log "已按 KAIDI_PURGE_DELETE_BACKUP=true 删除恢复备份" + fi fi log "Use a new empty MySQL database for the next installation" } diff --git a/deploy/update.sh b/deploy/update.sh index eefa18d..7e5bdff 100755 --- a/deploy/update.sh +++ b/deploy/update.sh @@ -30,6 +30,7 @@ HEALTH_URL=${KAIDI_HEALTH_URL:-http://127.0.0.1:18080/actuator/health} APP_INDEX_URL=${KAIDI_APP_INDEX_URL:-http://127.0.0.1:18080/} LOCK_FILE=$STATE_ROOT/update.lock BACKUP_ROOT=${KAIDI_BACKUP_ROOT:-$STATE_ROOT/backups} +DB_BACKUP_MODE=${KAIDI_DB_BACKUP_MODE:-skip} UPDATER_PATH=${KAIDI_UPDATER_PATH:-$APP_ROOT/bin/update.sh} SYSTEMD_ROOT=${KAIDI_SYSTEMD_ROOT:-/etc/systemd/system} LOG_ROOT=${KAIDI_LOG_ROOT:-/var/log/kaidi} @@ -45,33 +46,167 @@ DOWNLOAD_PID= DOWNLOAD_PGID= LAST_DOWNLOAD_SPEED=0 SURFACE_FAILURE= +LOG_LOCALE=${KAIDI_LOG_LOCALE:-zh-CN} + +localize_message() { + message=$1 + [ "$LOG_LOCALE" = en ] && { printf '%s' "$message"; return; } + # A rollback failure must keep the manual-recovery signal visible even when + # the original reason also contains a more specific error prefix. + case "$message" in + *"rollback is incomplete and manual recovery is required"*) + printf '新版本应用验证失败,回滚未完成,需要人工恢复' + return + ;; + esac + case "$message" in + "Update health-check settings must be non-negative integers") printf '更新健康检查参数必须是非负整数' ;; + "Update health-check attempts must be at least 1") printf '更新健康检查次数至少为 1' ;; + "KAIDI_PROCESS_MANAGER must be systemd or baota") printf 'KAIDI_PROCESS_MANAGER 只能是 systemd 或 baota' ;; + "KAIDI_UPDATE_SHUTDOWN_ATTEMPTS must be a positive integer") printf 'KAIDI_UPDATE_SHUTDOWN_ATTEMPTS 必须是正整数' ;; + "KAIDI_UPDATE_SHUTDOWN_ATTEMPTS must be at least 1") printf 'KAIDI_UPDATE_SHUTDOWN_ATTEMPTS 至少为 1' ;; + "KAIDI_PID_FILE must be an absolute path") printf 'KAIDI_PID_FILE 必须是绝对路径' ;; + "KAIDI_RUNTIME_ENV_FILE must be an absolute path") printf 'KAIDI_RUNTIME_ENV_FILE 必须是绝对路径' ;; + "KAIDI_DB_BACKUP_MODE must be skip or mysqldump") printf 'KAIDI_DB_BACKUP_MODE 只能是 skip 或 mysqldump' ;; + "Setup runtime environment must be a regular file") printf '安装向导运行时配置必须是普通文件' ;; + "Setup runtime environment is not readable") printf '安装向导运行时配置不可读' ;; + "Setup runtime environment is too large") printf '安装向导运行时配置过大' ;; + "Setup runtime environment has an unexpected owner") printf '安装向导运行时配置属主不正确' ;; + "Setup runtime environment must not be writable"*) printf '安装向导运行时配置不能被组或其他用户写入' ;; + "Setup runtime environment contains an invalid line") printf '安装向导运行时配置包含无效行' ;; + "Setup runtime environment "*) printf '安装向导运行时配置错误,请检查 application.env' ;; + "Setup runtime database port is invalid") printf '安装向导数据库端口无效' ;; + "Database configuration is invalid: DB_URL is missing"*) printf '数据库配置无效:缺少 DB_URL,未重启应用' ;; + "Database configuration is invalid: database host and name are missing"*) printf '数据库配置无效:缺少数据库主机或库名,未重启应用' ;; + "Database configuration is invalid: DB_URL does not match"*) printf '数据库配置无效:DB_URL 与主机、端口、库名不一致,未重启应用' ;; + "UPDATE_RELEASE_TOKEN is invalid") printf 'UPDATE_RELEASE_TOKEN 无效' ;; + "UPDATE_RELEASE_BASE_URL or UPDATE_RELEASE_API_URL is not configured") printf '未配置更新源地址' ;; + "Service user "*" is missing") printf '缺少服务用户:%s' "$(printf '%s' "${message#Service user }" | sed 's/ is missing$//')" ;; + "Service user "*" is not a member"*) printf '服务用户不属于指定服务组' ;; + "runuser is required") printf '需要 runuser' ;; + "setsid is required") printf '需要 setsid' ;; + "Managed application or update directories could not be prepared") printf '应用或更新目录准备失败' ;; + "Writable update directories could not be prepared") printf '可写更新目录准备失败' ;; + "Private update directories could not be prepared") printf '私有更新目录准备失败' ;; + "Private update queue directories could not be prepared") printf '私有更新队列目录准备失败' ;; + "Downloading signed release "*) printf '正在下载已签名版本:%s' "${message#Downloading signed release }" ;; + "Release "*" download completed") printf '版本下载完成:%s' "${message#Release }" ;; + "Validating signed release "*) printf '正在校验签名版本:%s' "${message#Validating signed release }" ;; + "Revalidating cached release "*) printf '正在重新校验已缓存版本:%s' "${message#Revalidating cached release }" ;; + "Release "*" is downloaded and verified; confirm installation") printf '版本已下载并校验,请确认安装:%s' "${message#Release }" ;; + "Release "*" is running") printf '版本运行中:%s' "${message#Release }" ;; + *"automatic update watcher could not be started"*) printf '自动更新监听器未能启动' ;; + "Version "*" is already installed") printf '版本已安装:%s' "${message#Version }" ;; + "Starting and verifying release "*) printf '正在启动并验证版本:%s' "${message#Starting and verifying release }" ;; + "Validating current release before switching to "*) printf '切换前正在验证当前版本(目标:%s)' "${message#Validating current release before switching to }" ;; + "Backing up database before installing "*) printf '安装前正在备份数据库(目标:%s)' "${message#Backing up database before installing }" ;; + "Database backup skipped; updater does not access MySQL") printf '已跳过数据库备份;更新器不会访问 MySQL' ;; + "Installing release "*) printf '正在安装版本:%s' "${message#Installing release }" ;; + "Update process was interrupted"*) printf '更新进程被中断,系统将执行自动恢复' ;; + "Update transaction evidence could not be quarantined"*) printf '更新事务证据无法隔离,已停止自动更新' ;; + "Update request could not be archived"*) printf '更新请求无法归档,已停止自动更新' ;; + "Update service is locked for manual recovery"*) printf '更新服务已锁定,需要人工恢复;新请求已拒绝' ;; + "Processing update request must be a regular file") printf '处理中更新请求必须是普通文件' ;; + "Update request must be a regular file") printf '更新请求必须是普通文件' ;; + "Claimed update request must be a regular file") printf '已领取的更新请求必须是普通文件' ;; + "Verified release cache is missing") printf '缺少已校验的发布缓存' ;; + "Cached release manifest is missing") printf '缺少缓存的发布清单' ;; + "Cached release signature is missing") printf '缺少缓存的发布签名' ;; + "Cached release artifact is missing") printf '缺少缓存的发布包' ;; + "Update request version is invalid") printf '更新请求版本无效' ;; + "Update request action is invalid") printf '更新请求动作无效' ;; + "Release manifest "*" is missing") printf '缺少发布清单资产' ;; + "Release manifest download failed") printf '发布清单下载失败' ;; + "Release manifest signature download failed") printf '发布清单签名下载失败' ;; + "Gitea latest Release lookup failed") printf 'Gitea 最新 Release 查询失败' ;; + "Release manifest signature verification failed") printf '发布清单签名校验失败' ;; + "Requested version is no longer the latest signed release") printf '请求版本已不是最新签名版本,请重新获取版本' ;; + "Release artifact download failed") printf '发布包下载失败' ;; + "Release artifact asset is missing") printf '缺少发布包资产' ;; + "Release artifact SHA-256 verification failed") printf '发布包 SHA-256 校验失败' ;; + "Release artifact size verification failed") printf '发布包大小校验失败' ;; + "Release archive contains an unsafe path") printf '发布归档包含不安全路径,已拒绝' ;; + "Release archive must not contain symbolic links") printf '发布归档不能包含符号链接,已拒绝' ;; + "Release application restart failed") printf '新版本应用重启失败' ;; + "Release application verification failed"*) printf '新版本应用验证失败:%s' "${message#Release application verification failed }" ;; + "Current release preflight failed"*) printf '当前版本预检查失败,未重启应用:%s' "${message#Current release preflight failed }" ;; + "Current release path is invalid"*) printf '当前版本路径无效,未重启应用' ;; + "Service user cannot access the release or selected Java runtime") printf '服务用户无法访问发布目录或 Java 运行时' ;; + "Release operations files could not be backed up") printf '旧版本运维文件备份失败' ;; + "Existing operations files could not be backed up") printf '已有运维文件备份失败' ;; + "Release directory is already active") printf '目标版本目录已处于 active 状态' ;; + "Failed release staging directory could not be cleaned") printf '失败版本暂存目录清理失败' ;; + "Release directory could not be activated") printf '目标版本目录无法启用' ;; + "Release ownership or permissions could not be secured") printf '目标版本目录权限保护失败' ;; + "Release operations validation failed") printf '发布运维文件校验失败' ;; + "Release public key is missing") printf '发布公钥缺失' ;; + "Release verification public key is missing") printf '缺少发布校验公钥' ;; + "Release manifest version is invalid") printf '发布清单版本无效' ;; + "Release artifact name is invalid") printf '发布包文件名无效' ;; + "Release SHA-256 is invalid") printf '发布包 SHA-256 无效' ;; + "Release artifact size is invalid") printf '发布包大小无效' ;; + "Release archive could not be listed") printf '发布归档无法读取' ;; + "Release archive could not be extracted") printf '发布归档无法解压' ;; + "Release app.jar is missing") printf '发布包缺少 app.jar' ;; + "Release frontend is missing") printf '发布包缺少前端入口' ;; + "Release version file mismatch") printf '发布包 VERSION 与目标版本不一致' ;; + "Release updater is missing") printf '发布包缺少更新脚本' ;; + "Release Baota launcher is missing") printf '发布包缺少宝塔启动脚本' ;; + "Release Baota initializer is missing") printf '发布包缺少宝塔初始化脚本' ;; + "Release application unit is missing") printf '发布包缺少应用 systemd 单元' ;; + "Release updater unit is missing") printf '发布包缺少更新 systemd 单元' ;; + "Release updater path unit is missing") printf '发布包缺少更新监听单元' ;; + "Verified release cache path is unsafe") printf '已校验发布缓存路径不安全' ;; + "Verified release cache could not be activated") printf '已校验发布缓存无法启用' ;; + "Database backup failed") printf '数据库备份失败' ;; + "Database backup is empty") printf '数据库备份为空' ;; + "Database backup compression failed") printf '数据库备份压缩失败' ;; + "mysqldump is required"*) printf '更新前需要 mysqldump;请配置 KAIDI_MYSQLDUMP_BIN 或安装客户端' ;; + "Release operations files could not be activated") printf '发布运维文件无法切换' ;; + "Release application link could not be activated") printf '应用 current 链接无法切换' ;; + *"previous application release was restored and verified; database backup was retained"*) printf '已恢复并验证旧版本;数据库备份已保留' ;; + *"previous application release was restored and verified; no database backup was created"*) printf '已恢复并验证旧版本;本次未创建数据库备份' ;; + *"previous application release is running"*) printf '旧版本正在运行,但运维文件需要人工检查' ;; + *) printf '%s' "$message" ;; + esac +} case "$HEALTH_ATTEMPTS:$HEALTH_INTERVAL_SECONDS" in - *[!0-9:]* | :* | *:) printf '%s\n' "Update health-check settings must be non-negative integers" >&2; exit 1 ;; + *[!0-9:]* | :* | *:) printf '%s\n' "$(localize_message 'Update health-check settings must be non-negative integers')" >&2; exit 1 ;; esac -[ "$HEALTH_ATTEMPTS" -ge 1 ] || { printf '%s\n' "Update health-check attempts must be at least 1" >&2; exit 1; } +[ "$HEALTH_ATTEMPTS" -ge 1 ] || { printf '%s\n' "$(localize_message 'Update health-check attempts must be at least 1')" >&2; exit 1; } case "$PROCESS_MANAGER" in systemd|baota) ;; - *) printf '%s\n' "KAIDI_PROCESS_MANAGER must be systemd or baota" >&2; exit 1 ;; + *) printf '%s\n' "$(localize_message 'KAIDI_PROCESS_MANAGER must be systemd or baota')" >&2; exit 1 ;; esac case "$SHUTDOWN_ATTEMPTS" in - ''|*[!0-9]*) printf '%s\n' "KAIDI_UPDATE_SHUTDOWN_ATTEMPTS must be a positive integer" >&2; exit 1 ;; + ''|*[!0-9]*) printf '%s\n' "$(localize_message 'KAIDI_UPDATE_SHUTDOWN_ATTEMPTS must be a positive integer')" >&2; exit 1 ;; esac -[ "$SHUTDOWN_ATTEMPTS" -ge 1 ] || { printf '%s\n' "KAIDI_UPDATE_SHUTDOWN_ATTEMPTS must be at least 1" >&2; exit 1; } +[ "$SHUTDOWN_ATTEMPTS" -ge 1 ] || { printf '%s\n' "$(localize_message 'KAIDI_UPDATE_SHUTDOWN_ATTEMPTS must be at least 1')" >&2; exit 1; } case "$PID_FILE" in /*) ;; - *) printf '%s\n' "KAIDI_PID_FILE must be an absolute path" >&2; exit 1 ;; + *) printf '%s\n' "$(localize_message 'KAIDI_PID_FILE must be an absolute path')" >&2; exit 1 ;; esac case "$RUNTIME_ENV_FILE" in /*) ;; - *) printf '%s\n' "KAIDI_RUNTIME_ENV_FILE must be an absolute path" >&2; exit 1 ;; + *) printf '%s\n' "$(localize_message 'KAIDI_RUNTIME_ENV_FILE must be an absolute path')" >&2; exit 1 ;; +esac +case "${KAIDI_SKIP_DB_BACKUP:-}" in + true) DB_BACKUP_MODE=skip ;; + false) + [ -n "${KAIDI_DB_BACKUP_MODE:-}" ] || DB_BACKUP_MODE=mysqldump + ;; +esac +case "$DB_BACKUP_MODE" in + skip|mysqldump) ;; + *) printf '%s\n' "$(localize_message 'KAIDI_DB_BACKUP_MODE must be skip or mysqldump')" >&2; exit 1 ;; esac bootstrap_die() { if [ -f "$PROCESSING_FILE" ] && [ ! -L "$PROCESSING_FILE" ]; then fail "$1" fi - printf '%s\n' "$1" >&2 + printf '%s\n' "$(localize_message "$1")" >&2 exit 1 } @@ -149,7 +284,7 @@ validate_runtime_database_config() { status() { state=$1 - message=$2 + message=$(localize_message "$2") version=${3:-} downloaded_bytes=${4:-} total_bytes=${5:-} @@ -227,9 +362,10 @@ complete_request() { fail() { TERMINAL_STATUS_WRITTEN=true - status FAILED "$1" "${TARGET_VERSION:-}" + message=$(localize_message "$1") + status FAILED "$message" "${TARGET_VERSION:-}" archive_processing_request || true - printf '%s\n' "$1" >&2 + printf '%s\n' "$message" >&2 exit 1 } @@ -601,7 +737,10 @@ mysqldump_bin() { backup_database() { DATABASE_BACKUP= - [ "${KAIDI_SKIP_DB_BACKUP:-false}" = "true" ] && return + if [ "$DB_BACKUP_MODE" = skip ]; then + event INFO BACKING_UP '数据库备份已跳过;更新器不会访问 MySQL' + return 0 + fi mkdir -p "$BACKUP_ROOT" chmod 0700 "$BACKUP_ROOT" dump_file=$(mktemp "$BACKUP_ROOT/.mysql-dump.XXXXXX.sql") @@ -609,26 +748,16 @@ backup_database() { backup_tmp="$backup.tmp.$$" chmod 0600 "$dump_file" - if [ -n "${KAIDI_DB_CONTAINER:-}" ] && command -v docker >/dev/null 2>&1 \ - && docker inspect "$KAIDI_DB_CONTAINER" >/dev/null 2>&1; then - if ! docker exec -e MYSQL_PWD="${KAIDI_DB_PASSWORD:-}" "$KAIDI_DB_CONTAINER" \ - mysqldump --single-transaction --routines --triggers \ - -u "${KAIDI_DB_USERNAME:-kaidi}" "${KAIDI_DB_NAME:-kaidi_finance}" > "$dump_file"; then - rm -f "$dump_file" "$backup_tmp" - fail "Database backup failed" - fi - else - dump_bin=$(mysqldump_bin || true) - [ -n "$dump_bin" ] || { - rm -f "$dump_file" "$backup_tmp" - fail "mysqldump is required before installing an update; set KAIDI_MYSQLDUMP_BIN or install it in a standard MySQL bin directory" - } - if ! MYSQL_PWD=${KAIDI_DB_PASSWORD:-} "$dump_bin" --single-transaction --routines --triggers \ - -h "${KAIDI_DB_HOST:-127.0.0.1}" -P "${KAIDI_DB_PORT:-3306}" \ - -u "${KAIDI_DB_USERNAME:-kaidi}" "${KAIDI_DB_NAME:-kaidi_finance}" > "$dump_file"; then - rm -f "$dump_file" "$backup_tmp" - fail "Database backup failed" - fi + dump_bin=$(mysqldump_bin || true) + [ -n "$dump_bin" ] || { + rm -f "$dump_file" "$backup_tmp" + fail "mysqldump is required before installing an update; set KAIDI_MYSQLDUMP_BIN or install it in a standard MySQL bin directory" + } + if ! MYSQL_PWD=${KAIDI_DB_PASSWORD:-} "$dump_bin" --single-transaction --routines --triggers \ + -h "${KAIDI_DB_HOST:-127.0.0.1}" -P "${KAIDI_DB_PORT:-3306}" \ + -u "${KAIDI_DB_USERNAME:-kaidi}" "${KAIDI_DB_NAME:-kaidi_finance}" > "$dump_file"; then + rm -f "$dump_file" "$backup_tmp" + fail "Database backup failed" fi [ -s "$dump_file" ] || { @@ -643,8 +772,12 @@ backup_database() { mv -f "$backup_tmp" "$backup" DATABASE_BACKUP=$backup rm -f "$dump_file" - find "$BACKUP_ROOT" -type f -name 'mysql-*.sql.gz' -printf '%T@ %p\n' \ - | sort -nr | awk 'NR > 5 {sub(/^[^ ]+ /, ""); print}' | xargs -r rm -f + # Keep the newest five backups without relying on GNU find's -printf; the + # updater is also exercised on BSD/macOS fixtures during release checks. + # shellcheck disable=SC2012 # Generated backup names contain no whitespace. + LC_ALL=C ls -1t "$BACKUP_ROOT"/mysql-*.sql.gz 2>/dev/null \ + | awk 'NR > 5' \ + | while IFS= read -r old_backup; do rm -f -- "$old_backup"; done } atomic_install() { @@ -796,26 +929,30 @@ rollback_active_transaction() { && verify_app_surface "$previous_target"; then remove_failed_release "$failed_release" if [ "$operations_restored" = true ]; then + database_backup_record=$(transaction_value database-backup) rm -rf "$ACTIVE_TRANSACTION" - fail "$reason; previous application release was restored and verified; database backup was retained" + if [ -n "$database_backup_record" ] || [ -n "${DATABASE_BACKUP:-}" ]; then + fail "$reason; previous application release was restored and verified; database backup was retained" + fi + fail "$reason; previous application release was restored and verified; no database backup was created" fi fail "$reason; previous application release is running, but operations restoration requires manual review; transaction evidence was retained" fi TERMINAL_STATUS_WRITTEN=true recovery_guard_failed=false if ! archive_processing_request; then - printf '%s\n' "Update request could not be archived after an incomplete rollback" >&2 + printf '%s\n' "$(localize_message 'Update request could not be archived after an incomplete rollback')" >&2 recovery_guard_failed=true fi if ! quarantine_active_transaction; then - printf '%s\n' "Update transaction evidence could not be quarantined after an incomplete rollback" >&2 + printf '%s\n' "$(localize_message 'Update transaction evidence could not be quarantined after an incomplete rollback')" >&2 recovery_guard_failed=true fi if [ "$recovery_guard_failed" = true ]; then systemctl stop "$UPDATE_PATH_NAME" >/dev/null 2>&1 || true fi status RECOVERY_REQUIRED "$reason; rollback is incomplete and manual recovery is required" "${TARGET_VERSION:-}" - printf '%s\n' "$reason; rollback is incomplete and manual recovery is required" >&2 + printf '%s\n' "$(localize_message "$reason; rollback is incomplete and manual recovery is required")" >&2 exit 1 } @@ -885,7 +1022,7 @@ if [ -f "$STATUS_FILE" ] && [ ! -L "$STATUS_FILE" ] \ if ! archive_processing_request; then systemctl stop "$UPDATE_PATH_NAME" >/dev/null 2>&1 || true fi - printf '%s\n' "Update service is locked for manual recovery; new requests are rejected" >&2 + printf '%s\n' "$(localize_message 'Update service is locked for manual recovery; new requests are rejected')" >&2 exit 1 fi @@ -1053,7 +1190,11 @@ write_transaction_value previous-target "$PREVIOUS_TARGET" write_transaction_value release-dir "$RELEASE_DIR" write_transaction_value phase PREPARED -status BACKING_UP "Backing up database before installing $TARGET_VERSION" "$TARGET_VERSION" +if [ "$DB_BACKUP_MODE" = mysqldump ]; then + status BACKING_UP "Backing up database before installing $TARGET_VERSION" "$TARGET_VERSION" +else + status BACKING_UP "Database backup skipped; updater does not access MySQL" "$TARGET_VERSION" +fi backup_database [ -z "${DATABASE_BACKUP:-}" ] || write_transaction_value database-backup "$DATABASE_BACKUP" if ! backup_operations; then diff --git a/docs/财务系统开发交付总方案.md b/docs/财务系统开发交付总方案.md index 307edb4..68c3bc2 100644 --- a/docs/财务系统开发交付总方案.md +++ b/docs/财务系统开发交付总方案.md @@ -4,7 +4,7 @@ > 版本:V3.26(Preview.9 公共 Release 一键安装)
> 状态:第一版 Preview 候选包已具备 22 页功能框架、核心业务链和可部署制品;PAGE-13/14 已通过模块验收,PAGE-12/16/17/18/19/20/21/22 为 `IMPLEMENTED_PENDING_ACCEPTANCE`,PAGE-15 正式业务矩阵仍为 `IN_PROGRESS`;整套 R1 尚未达到第 12.9 节 Definition of Done,不得把 Preview 上线等同于甲方最终验收
> 编制依据:2026-08-05 腾讯会议转写、14 张 OA 表单、财务岗位职责资料、项目管理流程图
-> 最新项目决策:按既定 Java/TDesign 技术栈交付第一版 Preview;2026-08-17 已完成 PAGE-08 来源、记账、档案双向穿透,PAGE-20 审计稳定排序/筛选/脱敏导出、PAGE-21 权限与配置、PAGE-22 公共 Gitea 签名在线更新、OA-04/OA-06 动态附件矩阵、OA-06 财务终审付款投影、Release/SBOM/依赖门禁、Linux i386/i486/i586/i686 外部 MySQL 8.4.x 单命令安装、更新请求持久领取/中断恢复/数据库备份校验/回滚健康复核,以及隔离超级管理员全部功能权限和全局数据范围;本轮新增 PAGE-23 首次安装向导、无业务数据库启动上下文、MySQL 8.4 DDL/DML 预检、一次性安装码和安装状态锁定,数据库基线新增 `V072`。远端仓库 `https://git.awaioi.com/ERP-Team/kaidi.git` 与 Release 均已公开;安装和在线更新默认不使用 Token,安装器内置发布公钥指纹,推送符合 SemVer 的 `v*` tag 后由带签名密钥的发布流程生成 Release;PAGE-15 完整业务矩阵继续保持 `DRAFT`,须经 D-02/D-09 确认后才允许作为正式规则发布
+> 最新项目决策:按既定 Java/TDesign 技术栈交付第一版 Preview;2026-08-17 已完成 PAGE-08 来源、记账、档案双向穿透,PAGE-20 审计稳定排序/筛选/脱敏导出、PAGE-21 权限与配置、PAGE-22 公共 Gitea 签名在线更新、OA-04/OA-06 动态附件矩阵、OA-06 财务终审付款投影、Release/SBOM/依赖门禁、Linux i386/i486/i586/i686 外部 MySQL 8.4.x 单命令安装、更新请求持久领取/中断恢复/数据库备份校验/回滚健康复核,以及隔离超级管理员全部功能权限和全局数据范围;本轮新增 PAGE-23 首次安装向导、无业务数据库启动上下文、只校验 JDBC 配置格式(安装器不调用 MySQL 客户端、不执行 DDL/DML)、一次性安装码和安装状态锁定,数据库基线新增 `V072`。远端仓库 `https://git.awaioi.com/ERP-Team/kaidi.git` 与 Release 均已公开;安装和在线更新默认不使用 Token,安装器内置发布公钥指纹,推送符合 SemVer 的 `v*` tag 后由带签名密钥的发布流程生成 Release;PAGE-15 完整业务矩阵继续保持 `DRAFT`,须经 D-02/D-09 确认后才允许作为正式规则发布
> 实际开发技术:Java 17 + Spring Boot 3.5.16 + MyBatis 3.0.5 + MySQL 8.4 + TDesign Vue Next Starter;前端仍只允许在现有 `frontend/` Starter 中原位增加菜单、页面、组件和真实功能 > 文档性质:本项目唯一 PRD、SPEC、开发任务书、测试验收与交付基线
> 文档用途:甲方需求确认、产品设计、开发拆分、测试验收和交付培训
@@ -68,7 +68,7 @@ | --- | --- | | 页面层级 | PAGE-03~21 以路由合同面包屑作为静态页面名称的唯一可见来源;工作台、列表、台账、报表和配置页删除内容区重复的模块名、页面名及功能说明,只保留读屏标题和紧凑操作栏。项目详情、表单详情继续显示不与面包屑重复的业务编号、对象名称和状态 | | Preview.6 | 在 Preview.5 基础上,窄屏表格固定列统一降级为横向滚动列,顶部账号文本增加省略保护;通过 TypeScript、ESLint、Stylelint、Vitest `10 files / 56 tests`、production build/hygiene、完整 Playwright `198/198`(含 390px 固定列几何回归);真实 Edge 复测权限与配置、系统更新、财务工作台和记账准备页面无整体横向溢出或控件重叠 | -| Preview.8 安装向导 | 新增 PAGE-23 独立无库启动上下文;首次访问 `/setup` 以一次性安装码验证 MySQL 8.4 连接及 DDL/DML,执行 Flyway、创建自定义 `SYSTEM_ADMIN` 和全局权限,写入受限运行时配置并锁定向导;32 位 curl 路径不再把数据库密码放进命令行,安装完成后的修复重装优先读取运行时覆盖文件;通过 Java 编译、无库 HTTP smoke、TDesign 输入可访问性和 setup wizard Playwright 回归 | +| Preview.8 安装向导 | 新增 PAGE-23 独立无库启动上下文;安装器只保存端口和向导启动配置,不连接数据库、不执行 SQL;首次访问 `/setup` 以一次性安装码接收外部 MySQL 连接信息,点击完成后由应用执行 Flyway、创建自定义 `SYSTEM_ADMIN` 和全局权限,写入受限运行时配置并锁定向导;32 位 curl 路径不再把数据库密码放进命令行,安装完成后的修复重装优先读取运行时覆盖文件;通过 Java 编译、无库 HTTP smoke、TDesign 输入可访问性和 setup wizard Playwright 回归 | | 超级管理员权限 | V071 将 `SYSTEM_ADMIN` 固定为隔离超级管理员,逐条授予全部 `iam_permission` 和无额度 `GLOBAL` scope,并由启动同步补齐后续权限;前端菜单、直链和按钮统一放行全部 PAGE-04~21。管理员可管理跨人员表单/导入/付款草稿,PAGE-15 增加仅管理员可见的“全部付款”,三类业务工作台显示跨人员项目、资金、档案和全部待办;审批实际处理人、SOD、状态机、资金、附件安全和审计继续强制执行 | | OA-06 付款投影 | V070 发布 OA-06 v2,新增生效合同、供应商和已确认应付稳定引用;财务终审在来源审批事务内生成 `OFFLINE_PENDING` 付款,执行付款检查、冻结项目资金并追加资金流水。余额不足等投影失败时来源状态、审批任务、付款、资金控制和流水整体回滚;不产生银行付款指令,不接网银、U 盾或第三方平台 | | OA 动态附件矩阵 | OA-04/OA-06 的条件附件行支持 TDesign 布尔开关、长文本、不涉及原因和嵌套文件上传;仅 `required=true` 的行要求文件或原因,可选空行不阻断。上传状态按字段和表格单元隔离,上传期间锁定刷新、保存、校验和提交;更新请求不再携带创建专用字段,校验前自动保存当前草稿 | @@ -76,10 +76,10 @@ | 第一版 Preview | PAGE-01~22 页面和菜单框架齐备,真实前后端核心链保持可用;Preview 上线与整套 R1 最终验收分开计量 | | 来源表格校验 | V068 为 14 类 OA 的 33 个 TABLE 字段补齐嵌套类型、必填和文件引用规则;V069 保留 OA-02 v1 历史账户表格契约,避免 v2 字段追溯污染 | | PAGE-20 审计 | 增加不可变事件序号、四种稳定排序、URL 查询状态、同排序 CSV 导出、脱敏详情及 OpenAPI `422` 参数门禁 | -| PAGE-22 在线更新 | 更新源固定为公共 Gitea Latest Release API,默认不使用 Token;“获取版本”只读取最新版本信息,发现新版本后由管理员点击“立即更新”开始下载。下载、验签完成进入 `READY/下载完成` 后显示“立即更新并重启”。安装健康检查成功后页面从 10 秒倒计时自动刷新;刷新、短暂断线或命令响应丢失时恢复状态轮询。后台只写固定结构请求,由 root oneshot 服务验签、验哈希、备份、切换和回滚,不接受页面传入地址、Token 或命令 | +| PAGE-22 在线更新 | 更新源固定为公共 Gitea Latest Release API,默认不使用 Token;“获取版本”只读取最新版本信息,发现新版本后由管理员点击“立即更新”开始下载。下载、验签完成进入 `READY/下载完成` 后显示“立即更新并重启”。安装健康检查成功后页面从 10 秒倒计时自动刷新;刷新、短暂断线或命令响应丢失时恢复状态轮询。后台只写固定结构请求,由 root oneshot 服务验签、验哈希、按配置选择是否调用已有 `mysqldump`、切换和回滚,不接受页面传入地址、Token 或命令 | | Release 工程 | `.gitea/workflows/release.yml` 监听严格 SemVer `v*` tag;Maven `revision`、npm、JAR、前后端 SBOM、签名 manifest 与 tag 必须同版本。工作流执行 Java/前端/OpenAPI/Playwright/依赖审计,使用至少 3072 位 RSA 密钥生成并独立验收恰好 9 个资产;先创建不可见草稿、逐项上传并核对名称/大小,最后发布为可被 `/releases/latest` 读取的正式 Release | -| Linux 32 位 | i386/i486/i586/i686 下载 Java 17 i686 JRE;JRE 元数据和归档均使用仅允许 HTTPS/TLS 1.2 及以上的受限下载器;由于 MySQL 8.4 无对应服务端镜像,要求预置外部 MySQL 8.4.x,curl 安装只开启 `/setup` 向导,数据库密码在浏览器中提交并完成连接、版本及 DDL/DML 预检 | -| 更新可靠性 | 下载和安装拆为两个持久动作;下载阶段不停止业务服务,安装阶段只接受同版本 `READY` 缓存并重新验签。公共 Gitea 默认不使用 Token;如改接私有镜像,Token 仅从权限为 `0600` 的 root 配置/临时文件读取,不进入 `curl` 参数、页面、状态或日志,且只允许同源 API/资产使用;请求原子领取到持久 `processing`,systemd 限制失败重试;`mysqldump`、新旧版本健康和回滚均有独立门禁,跨来源拒绝、成功、健康回滚、下载失败、备份失败和不安全请求夹具纳入 CI | +| Linux 32 位 | i386/i486/i586/i686 下载 Java 17 i686 JRE;JRE 元数据和归档均使用仅允许 HTTPS/TLS 1.2 及以上的受限下载器;由于 MySQL 8.4 无对应服务端镜像,要求预置外部 MySQL 8.4.x,curl 安装只开启 `/setup` 向导,数据库密码在浏览器中提交;安装器不安装 MySQL、不运行 MySQL 客户端,点击完成安装后由应用在专用 schema 中执行迁移 | +| 更新可靠性 | 下载和安装拆为两个持久动作;下载阶段不停止业务服务,安装阶段只接受同版本 `READY` 缓存并重新验签。公共 Gitea 默认不使用 Token;如改接私有镜像,Token 仅从权限为 `0600` 的 root 配置/临时文件读取,不进入 `curl` 参数、页面、状态或日志,且只允许同源 API/资产使用;请求原子领取到持久 `processing`,systemd 限制失败重试;数据库备份默认为 `skip`,只有显式配置 `KAIDI_DB_BACKUP_MODE=mysqldump` 才调用已有工具;新旧版本健康和回滚均有独立门禁,跨来源拒绝、成功、健康回滚、下载失败、备份失败和不安全请求夹具纳入 CI | | 阶段口径 | 把“开发前冻结”更新为“R1 开发中”;明确模块级验收与整套 R1 交付是两个层级,避免一页完成后虚报整套系统完成 | | PAGE-13 页面 | 完成合同执行汇总、筛选、服务端分页、来源下钻、CSV 导出、合同详情及变更/结算/应付操作;页面仅使用现有 TDesign Starter 和 TDesign 组件 | | PAGE-13 后端 | 新增 `contractcost:payable:create`,项目经理可登记但不可确认;同人登记/确认返回 `422 SOD_VIOLATION`;应付必须关联已批准 OA-04,结算必须关联已批准 OA-11,同一来源不得重复消费 | @@ -2200,9 +2200,9 @@ staging/production 使用 Linux 容器或等价受控服务:反向代理仅对 运行日志在线保留 180 天;审计、导出和敏感访问日志首期按 10 年保留且不自动删除,最终保管期限由 D-09 确认。归档或到期处理必须由授权管理员显式执行、先导出校验并记录审批和哈希;处于项目档案、争议或审计冻结的记录不得处理。 -第一版 Preview 的固定交付路径为私有 Gitea `ERP-Team/kaidi` 的 Git tag → Gitea Actions 全量门禁 → RSA 签名 Release → Linux 安装器。CI 先创建 draft,上传并核对恰好 9 个资产后再发布,生产服务器只读 Release API/资产,不执行 `git pull` 或现场编译。64 位 Linux 可由安装器创建 MySQL 8.4 容器;i386/i486/i586/i686 必须先准备外部 MySQL 8.4.x、数据库和具备本库 DDL/DML 权限的账号。两类主机均只运行一组经安装器 SHA-256、固定公钥指纹和只读 Gitea Token 保护的 curl 命令,具体可复制命令、占位符、建库、健康检查、停用和排障命令以仓库根 `README.md` 为准。 +第一版 Preview 的固定交付路径为私有 Gitea `ERP-Team/kaidi` 的 Git tag → Gitea Actions 全量门禁 → RSA 签名 Release → Linux 安装器。CI 先创建 draft,上传并核对签名资产后再发布,生产服务器只读 Release API/资产,不执行 `git pull` 或现场编译。所有架构均必须先准备外部 MySQL 8.4.x、专用空 schema 和具备本库迁移所需权限的账号;安装器不创建 MySQL 容器、不安装数据库、不调用 MySQL 客户端,也不执行 DDL/DML。点击首次向导的完成安装后,应用才在用户指定 schema 中运行 Flyway 并初始化管理员。在线更新默认不访问数据库;仅在显式设置 `KAIDI_DB_BACKUP_MODE=mysqldump` 时调用宿主机已有工具生成备份,不调用 Docker 或管理数据库服务。两类主机均只运行经安装器 SHA-256、固定公钥指纹保护的 curl 命令,具体可复制命令、健康检查、停用和排障命令以仓库根 `README.md` 为准。 -在线更新是部署运维能力,不是 OA、银行、税务或财务业务第三方集成。它只允许访问 root 配置中固定的 HTTPS Gitea Release API 和同源资产,页面不能改变地址;生产可按网络策略禁用。下载缓存、请求领取、运维备份和事务阶段保存在 `/var/lib/kaidi-update`,主机重启后先恢复未提交事务;安装前必须确认 `mysqldump` 成功并生成 root-only 压缩备份。签名包内的应用、updater、systemd 单元和 Nginx 配置以临时文件加原子移动切换;配置、新版本直连、Nginx 健康、更新 path unit 和静态首页任一失败时恢复旧应用和旧运维文件,并再次验证旧版本健康。数据库迁移继续遵守至少一个版本向后兼容,数据库恢复须按备份恢复演练单独执行。 +在线更新是部署运维能力,不是 OA、银行、税务或财务业务第三方集成。它只允许访问 root 配置中固定的 HTTPS Gitea Release API 和同源资产,页面不能改变地址;生产可按网络策略禁用。下载缓存、请求领取、运维备份和事务阶段保存在 `/var/lib/kaidi-update`,主机重启后先恢复未提交事务;数据库备份默认为 `skip`,只有显式配置 `KAIDI_DB_BACKUP_MODE=mysqldump` 才生成 root-only 压缩备份。签名包内的应用、updater、systemd 单元和 Nginx 配置以临时文件加原子移动切换;配置、新版本直连、Nginx 健康、更新 path unit 和静态首页任一失败时恢复旧应用和旧运维文件,并再次验证旧版本健康。数据库迁移继续遵守至少一个版本向后兼容,若未启用备份则由运维在更新前自行完成外部备份。 ### 11.15 全栈工程硬规范 @@ -2891,7 +2891,7 @@ PAGE-16 全类回归曾暴露测试证据文件路径硬编码与应用 `finance | 合同与供应链 | OpenAPI、Actionlint、ShellCheck 通过;npm 完整依赖树 `0 vulnerabilities` | CI 仍在 tag 发布时从干净环境重新执行全部门禁 | | 更新可靠性 | 成功切换、健康失败回滚、Release 下载失败、`mysqldump` 失败和不安全请求拒绝五类夹具通过 | 覆盖持久 processing/transaction、普通文件门禁、有效失败状态、0600 备份、应用、updater、systemd、Nginx、新旧健康验证和失败 Release 清理;真实 Linux systemd 主机仍须冒烟 | | Release 制品 | `1.0.0-preview.1` 应用 tar、RSA 3072 签名 manifest、公钥、SHA-256 清单、安装器、前后端 CycloneDX SBOM 统一生成并互相绑定;签名清单同时绑定应用、SBOM、安装器、公钥、bootstrap 摘要和源码修订,Maven、npm、JAR、SBOM 与 manifest 严格同版本,tag CI 强制干净来源 | 应用包 SHA-256=`45c44070b4b0857f202ea5767441f684bdda28095276e0ea1c75cd31dcddf39b`,安装器 SHA-256=`bf50b8ca0fcd1aebf2c05f0d80fa362a482f46ecf6e07f7cf5aa5d05d6370c21`,公钥 SHA-256=`807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9`;当前本地候选清单明确为 `source.ref=local`、`source.dirty=true`,实际 Git Release 仍待目标仓库 | -| Linux 32 位 | 安装器 fixture 验证 i386/i486/i586/i686 均选择 Java 17 i686 JRE;外部 MySQL 8.4.x 先做连接、版本、DDL/DML 预检 | 属于“外部数据库已预置后的单命令安装”,不是 32 位主机内置 MySQL;正式 i686 systemd 主机仍须实装 | +| Linux 32 位 | 安装器 fixture 验证 i386/i486/i586/i686 均选择 Java 17 i686 JRE;安装阶段只验证 JDBC 配置格式,数据库连接和迁移在首次向导完成安装时执行 | 属于“外部数据库已预置后的单命令安装”,不是 32 位主机内置 MySQL;正式 i686 systemd 主机仍须实装 | **Preview 放行结论**:代码和本地签名制品满足第一版 Preview 候选条件,P0 代码阻断为零。实际对外 Release 只剩目标 GitHub 仓库、Release URL、CI 签名 Secret、公钥指纹 Variable 和服务器地址等部署输入;这些输入未配置前,不把本地候选包描述成已经公网部署。 @@ -2997,7 +2997,7 @@ PAGE-16 全类回归曾暴露测试证据文件路径硬编码与应用 `finance | 项目 | 结果 | 说明 | | --- | --- | --- | | 首次访问 | 已实现 | `KAIDI_SETUP_WIZARD=true` 时正式业务 DataSource、Flyway、JDBC Session 和业务路由不启动;`GET /api/v1/setup/status` 在无业务数据库时返回 `required=true`,前端 `/setup` 为未登录公共页 | -| 数据库配置 | 已实现 | 首版只开放 MySQL 8.4.x;向导先执行版本和临时表 CREATE/INSERT/UPDATE/DROP 权限验证,再执行 72 个 Flyway 迁移;非空且无 Flyway 历史的数据库被拒绝 | +| 数据库配置 | 已实现 | 首版只开放 MySQL 8.4.x;向导的“测试连接”只读取版本,不修改数据库;管理员明确点击“完成安装”后才执行迁移权限检查、字符集准备、Flyway 迁移和管理员初始化;非空或已有不一致迁移历史的数据库会在完成阶段给出可定位错误 | | 管理员初始化 | 已实现 | 操作者填写账号、显示名称和密码;创建 `SYSTEM_ADMIN`、逐项全局权限范围,并用 `kaidi_setup_installation` 单例状态支持提交后重试,避免重复创建超级管理员 | | 锁定与恢复 | 已实现 | 一次性安装码只保存 SHA-256;完成后原子写运行时环境和锁定标记,systemd 重启进入正式模式;向导未完成时可用 `REINSTALL=true + KAIDI_SETUP_WIZARD=true` 恢复并重新生成安装码,已锁定/正式模式拒绝该路径 | | 32 位 curl | 已实现 | i386/i486/i586/i686 只下载 i686 Java 17 JRE,不传数据库密码;管理员预建 MySQL 8.4 数据库后在 `/setup` 输入连接信息 | diff --git a/frontend/e2e/setup-wizard.e2e.ts b/frontend/e2e/setup-wizard.e2e.ts index 1b8cff3..28aa501 100644 --- a/frontend/e2e/setup-wizard.e2e.ts +++ b/frontend/e2e/setup-wizard.e2e.ts @@ -32,8 +32,8 @@ test('first-run wizard tests MySQL, creates the administrator and stays responsi successful: true, databaseType: 'MYSQL', serverVersion: '8.4.6', - schemaReady: true, - message: 'MySQL 8.4 连接、排序规则和完整迁移权限验证通过', + schemaReady: false, + message: 'MySQL 8.4 只读连接验证通过;尚未修改数据库,点击完成安装后才会执行迁移', }, }, }); @@ -61,7 +61,11 @@ 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.locator('.t-alert__description').filter({ + hasText: 'MySQL 8.4 只读连接验证通过;尚未修改数据库,点击完成安装后才会执行迁移', + }), + ).toBeVisible(); await page.getByRole('button', { name: '下一步' }).click(); await page.getByLabel('管理员密码').fill('SetupAdmin@2026Strong'); @@ -110,8 +114,8 @@ test('confirms completion after the setup response is interrupted by a service r successful: true, databaseType: 'MYSQL', serverVersion: '8.4.6', - schemaReady: true, - message: 'MySQL 8.4 连接、排序规则和完整迁移权限验证通过', + schemaReady: false, + message: 'MySQL 8.4 只读连接验证通过;尚未修改数据库,点击完成安装后才会执行迁移', }, }, }); @@ -169,8 +173,8 @@ test('leaves the completed wizard when post-completion status polling is unavail successful: true, databaseType: 'MYSQL', serverVersion: '8.4.6', - schemaReady: true, - message: 'MySQL 8.4 连接、排序规则和完整迁移权限验证通过', + schemaReady: false, + message: 'MySQL 8.4 只读连接验证通过;尚未修改数据库,点击完成安装后才会执行迁移', }, }, }); diff --git a/frontend/src/pages/setup/index.vue b/frontend/src/pages/setup/index.vue index f349ce2..9c9e4af 100644 --- a/frontend/src/pages/setup/index.vue +++ b/frontend/src/pages/setup/index.vue @@ -103,6 +103,10 @@ {{ connectionResult.message }}({{ connectionResult.serverVersion }}) +

+ 测试连接只读取 MySQL + 版本,不创建或修改数据库对象;点击“完成安装”后,系统才会在指定的专用空库执行迁移并创建管理员。 +

@@ -290,9 +294,7 @@ const adminRules = computed>(() => ({ const databaseFingerprint = computed(() => JSON.stringify(databaseForm)); const connectionVerified = computed( - () => - Boolean(connectionResult.value?.successful && connectionResult.value.schemaReady) && - verifiedFingerprint.value === databaseFingerprint.value, + () => Boolean(connectionResult.value?.successful) && verifiedFingerprint.value === databaseFingerprint.value, ); watch(databaseFingerprint, () => { @@ -308,7 +310,7 @@ async function testConnection(context?: SubmitContext) { const result = await testSetupConnection({ ...databaseForm }); connectionResult.value = result; verifiedFingerprint.value = databaseFingerprint.value; - MessagePlugin.success('数据库连接验证通过'); + MessagePlugin.success(result.message || '数据库只读连接验证通过'); } catch (error) { connectionResult.value = null; errorMessage.value = (error as Error).message || '数据库连接验证失败'; @@ -469,6 +471,13 @@ h1 { margin-bottom: 20px; } +.setup-hint { + margin: -4px 0 20px; + color: var(--td-text-color-secondary); + font-size: 13px; + line-height: 20px; +} + .setup-content { min-width: 0; } diff --git a/frontend/src/utils/request/Axios.ts b/frontend/src/utils/request/Axios.ts index 1475367..ad3b9a3 100644 --- a/frontend/src/utils/request/Axios.ts +++ b/frontend/src/utils/request/Axios.ts @@ -19,6 +19,31 @@ import type { AxiosRequestConfigRetry, RequestOptions, Result } from '@/types/ax import { AxiosCanceler } from './AxiosCancel'; import type { CreateAxiosOptions } from './AxiosTransform'; +function localizeApiError(code: string | undefined, detail: string | undefined, status?: number): string { + const messages: Record = { + DATABASE_CONNECTION_FAILED: '数据库连接失败,请检查地址、端口、库名和账号权限', + DATABASE_INITIALIZATION_FAILED: '数据库初始化失败,请确认使用专用空库并检查迁移权限', + DATABASE_ENGINE_NOT_SUPPORTED: '当前版本仅支持 MySQL 8.4', + MYSQL_VERSION_UNSUPPORTED: 'MySQL 版本不受支持,需要 MySQL 8.4.x', + DATABASE_COLLATION_PREPARATION_FAILED: '数据库字符集或排序规则准备失败', + DATABASE_MIGRATION_STATE_INVALID: '数据库迁移历史无效,请使用空库或先修复迁移状态', + DATABASE_SCHEMA_INSPECTION_FAILED: '数据库结构检查失败', + SETUP_CODE_INVALID: '安装码不正确', + SETUP_LOCKED: '安装向导已锁定', + SETUP_STATE_WRITE_FAILED: '安装状态文件写入失败,请检查应用目录权限', + ADMIN_PASSWORD_MISMATCH: '管理员密码确认不一致', + }; + if (code && messages[code]) return messages[code]; + if (detail && /[\u4E00-\u9FFF]/.test(detail)) return detail; + if (status === 401) return '登录会话已失效'; + if (status === 403) return '当前账号没有执行此操作的权限'; + if (status === 404) return '请求的资源不存在'; + if (status === 409) return '数据已发生变化,请刷新后重试'; + if (status === 422) return '提交的数据未通过校验'; + if (status && status >= 500) return '服务器处理失败,请查看服务日志'; + return '网络请求失败,请检查网络连接后重试'; +} + /** * Axios 模块 */ @@ -318,7 +343,7 @@ export class VAxios { const problem = e.response?.data as { code?: string; detail?: string; requestId?: string; fieldErrors?: Record } | undefined; const requestId = problem?.requestId || e.response?.headers?.['x-request-id']; - const message = problem?.detail || (e.response?.status === 401 ? '登录会话已失效' : '网络请求失败'); + const message = localizeApiError(problem?.code, problem?.detail, e.response?.status); const error = new Error(requestId ? `${message}(请求编号:${requestId})` : message); Object.assign(error, { code: problem?.code, diff --git a/scripts/test-baota-start-fixture.sh b/scripts/test-baota-start-fixture.sh index 04bd824..f4adfcc 100755 --- a/scripts/test-baota-start-fixture.sh +++ b/scripts/test-baota-start-fixture.sh @@ -146,7 +146,7 @@ if KAIDI_JAVA_BIN="$WORK/java" \ "$RELEASE/ops/baota-start.sh" > "$WORK/missing-db.out" 2>&1; then fail 'launcher accepted a production configuration without DB_URL' fi -grep -Fq 'DB_URL is missing from the protected Kaidi configuration' "$WORK/missing-db.out" \ +grep -Fq '受保护的 Kaidi 配置缺少:DB_URL' "$WORK/missing-db.out" \ || fail 'launcher did not report the missing database URL' [ ! -e "$WORK/missing-db.log" ] || fail 'launcher started Java after configuration validation failed' diff --git a/scripts/test-git-install-fixture.sh b/scripts/test-git-install-fixture.sh index 8ccfbce..8e448dc 100755 --- a/scripts/test-git-install-fixture.sh +++ b/scripts/test-git-install-fixture.sh @@ -85,6 +85,6 @@ if PATH="$FIXTURE/bin:$PATH" \ printf 'Mismatched installer SHA-256 was accepted\n' >&2 exit 1 fi -grep -q 'does not match the trusted SHA-256' "$FIXTURE/hash-mismatch.log" +grep -q '与该 Git 标签的受信摘要不一致' "$FIXTURE/hash-mismatch.log" printf 'Git checkout installation wrapper fixture passed\n' diff --git a/scripts/test-install-fixture.sh b/scripts/test-install-fixture.sh index 1bf61b7..3bb5954 100755 --- a/scripts/test-install-fixture.sh +++ b/scripts/test-install-fixture.sh @@ -28,6 +28,7 @@ mode_of() { sed -n '/^database_host()/,/^}/p' "$ROOT/deploy/install.sh" sed -n '/^database_port()/,/^}/p' "$ROOT/deploy/install.sh" sed -n '/^database_name()/,/^}/p' "$ROOT/deploy/install.sh" + sed -n '/^validate_database_configuration()/,/^}/p' "$ROOT/deploy/install.sh" sed -n '/^is_semver()/,/^}/p' "$ROOT/deploy/install.sh" sed -n '/^write_env_file_preserving_unknown()/,/^}/p' "$ROOT/deploy/install.sh" sed -n '/^normalized_host_arch()/,/^}/p' "$ROOT/deploy/install.sh" @@ -41,13 +42,15 @@ mode_of() { sed -n '/^release_asset_url()/,/^}/p' "$ROOT/deploy/install.sh" sed -n '/^download_release_asset()/,/^}/p' "$ROOT/deploy/install.sh" sed -n '/^install_packages()/,/^}/p' "$ROOT/deploy/install.sh" - sed -n '/^mysql_client_bin()/,/^}/p' "$ROOT/deploy/install.sh" sed -n '/^preflight_database()/,/^}/p' "$ROOT/deploy/install.sh" sed -n '/^secure_release_tree()/,/^}/p' "$ROOT/deploy/install.sh" sed -n '/^restore_unit_state()/,/^}/p' "$ROOT/deploy/install.sh" } > "$WORK/helpers.sh" # shellcheck disable=SC1090,SC1091 source "$WORK/helpers.sh" +# Avoid invoking the host's macOS `log` utility while exercising extracted +# installer helpers. +log() { printf '%s\n' "$*" >/dev/null; } export SETUP_WIZARD=true preflight_database || fail 'setup wizard database preflight returned a failure status' @@ -66,10 +69,21 @@ cat > "$WORK/mysql-bin/mysql" <<'SH' exit 0 SH chmod 0755 "$WORK/mysql-bin/mysql" -export KAIDI_MYSQL_CLIENT="$WORK/mysql-bin/mysql" -[ "$(mysql_client_bin)" = "$WORK/mysql-bin/mysql" ] \ - || fail 'installer did not honor the explicit MySQL client path' -unset KAIDI_MYSQL_CLIENT +# Production-mode validation is intentionally side-effect free: it validates +# the JDBC shape but never invokes a MySQL client or emits DDL/DML. +SETUP_WIZARD=false +DB_URL='jdbc:mysql://127.0.0.1:3306/kaidi_finance?useUnicode=true' +DB_USERNAME='fixture-user' +DB_PASSWORD='fixture-password' +mysql_probe_marker="$WORK/mysql-probe-called" +cat > "$WORK/mysql-bin/mysql" < "$mysql_probe_marker" +exit 99 +SH +chmod 0755 "$WORK/mysql-bin/mysql" +preflight_database || fail 'side-effect-free database configuration validation returned a failure status' +[ ! -e "$mysql_probe_marker" ] || fail 'installer invoked a MySQL client during configuration validation' release_permissions="$WORK/release-permissions" mkdir -p "$release_permissions/public" "$release_permissions/ops" diff --git a/scripts/test-update-fixture.sh b/scripts/test-update-fixture.sh index 715780e..9047853 100755 --- a/scripts/test-update-fixture.sh +++ b/scripts/test-update-fixture.sh @@ -46,7 +46,7 @@ if KAIDI_APP_ROOT="$WORK/bootstrap/app" \ sh "$ROOT/deploy/update.sh" > "$WORK/bootstrap.log" 2>&1; then fail 'updater accepted a missing service identity during bootstrap' fi -grep -Fq "Service user $missing_service_user is missing" "$WORK/bootstrap.log" \ +grep -Fq '缺少服务用户' "$WORK/bootstrap.log" \ || fail 'updater bootstrap failure did not preserve its diagnostic' [ "$(jq -r '.state' "$WORK/bootstrap/state/status.json")" = FAILED ] \ || fail 'updater bootstrap failure did not persist FAILED' @@ -330,6 +330,7 @@ run_update() { local fixture=$1 local health=$2 local skip_backup=${3:-true} + local backup_mode=${4:-} env \ PATH="$fixture/mock-bin:$PATH" \ REAL_OPENSSL="$REAL_OPENSSL" \ @@ -353,6 +354,7 @@ run_update() { KAIDI_UPDATER_PATH="$fixture/app/bin/update.sh" \ KAIDI_RUNTIME_ENV_FILE="$fixture/runtime.env" \ KAIDI_SKIP_DB_BACKUP="$skip_backup" \ + KAIDI_DB_BACKUP_MODE="$backup_mode" \ KAIDI_UPDATE_HEALTH_ATTEMPTS=1 \ KAIDI_UPDATE_HEALTH_INTERVAL_SECONDS=0 \ UPDATE_RELEASE_BASE_URL="${FIXTURE_RELEASE_BASE_URL-https://release.fixture.invalid}" \ @@ -366,6 +368,20 @@ run_update() { "$ROOT/deploy/update.sh" } +assert_database_backup_opt_in_case() { + local fixture="$WORK/database-backup-opt-in" + local version='1.0.0-preview.2' + mkdir -p "$fixture" + write_mock_commands "$fixture/mock-bin" + build_release "$fixture" "$version" + prepare_installation "$fixture" "$version" + download_and_prepare_install "$fixture" "$version" + + run_update "$fixture" success false mysqldump + find "$fixture/state/backups" -type f -name 'mysql-*.sql.gz' -print -quit | grep -q . \ + || fail 'explicit mysqldump mode did not create a database backup' +} + assert_private_gitea_release_case() { local fixture="$WORK/private-gitea" local version='1.0.0-preview.2' @@ -476,7 +492,7 @@ assert_update_path_failure_keeps_application_case() { || fail 'path watcher failure rolled back a healthy application' [ "$(jq -r '.state' "$fixture/state/status.json")" = SUCCEEDED ] \ || fail 'path watcher failure did not preserve successful application state' - grep -Fq 'automatic update watcher could not be started' "$fixture/state/status.json" \ + grep -Fq '自动更新监听器未能启动' "$fixture/state/status.json" \ || fail 'path watcher failure did not preserve its diagnostic' } @@ -492,7 +508,7 @@ assert_rollback_case() { if run_update "$fixture" fail-new > "$fixture/update.log" 2>&1; then fail 'rollback case unexpectedly succeeded' fi - grep -q 'previous application release was restored and verified' "$fixture/update.log" \ + grep -q '已恢复并验证旧版本' "$fixture/update.log" \ || fail 'rollback case did not report a complete restoration' [ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/1.0.0-preview.1" ] \ || fail 'rollback case did not restore the previous application' @@ -524,7 +540,7 @@ assert_incomplete_rollback_requires_manual_recovery_case() { if run_update "$fixture" fail-after-first > "$fixture/update.log" 2>&1; then fail 'incomplete rollback case unexpectedly succeeded' fi - grep -Fq 'manual recovery is required' "$fixture/update.log" \ + grep -Fq '需要人工恢复' "$fixture/update.log" \ || fail 'incomplete rollback case did not require explicit recovery' [ ! -e "$fixture/state/processing/request.json" ] \ || fail 'incomplete rollback case left an automatically retriggered processing request' @@ -602,7 +618,7 @@ assert_unhealthy_baseline_blocks_restart_case() { if run_update "$fixture" fail > "$fixture/update.log" 2>&1; then fail 'unhealthy baseline unexpectedly reached installation' fi - grep -Fq 'Current release preflight failed' "$fixture/update.log" \ + grep -Fq '当前版本预检查失败' "$fixture/update.log" \ || fail 'unhealthy baseline did not preserve its preflight diagnostic' ! grep -Eq '^(start|restart|stop) kaidi-finance.service$' "$fixture/systemctl.log" \ || fail 'unhealthy baseline restarted the application' @@ -633,7 +649,7 @@ EOF if run_update "$fixture" success > "$fixture/update.log" 2>&1; then fail 'invalid database URL unexpectedly reached installation' fi - grep -Fq 'DB_URL does not match the configured MySQL host, port, and database' "$fixture/update.log" \ + grep -Fq 'DB_URL 与主机、端口、库名不一致' "$fixture/update.log" \ || fail 'structurally invalid JDBC URL did not preserve its diagnostic' ! grep -q '^restart kaidi-finance.service$' "$fixture/systemctl.log" \ || fail 'invalid database URL restarted the application' @@ -655,7 +671,7 @@ assert_symlink_request_rejected() { if run_update "$fixture" success > "$fixture/update.log" 2>&1; then fail 'symlink request case unexpectedly succeeded' fi - grep -q 'Update request must be a regular file' "$fixture/update.log" \ + grep -q '更新请求必须是普通文件' "$fixture/update.log" \ || fail 'symlink request case did not report the unsafe request' [ "$(cat "$fixture/sentinel")" = 'operator-owned sentinel' ] \ || fail 'symlink request case changed the link target' @@ -679,7 +695,7 @@ assert_install_without_verified_cache_rejected() { if run_update "$fixture" success > "$fixture/update.log" 2>&1; then fail 'install without cache unexpectedly succeeded' fi - grep -q 'Verified release cache is missing' "$fixture/update.log" \ + grep -q '缺少已校验的发布缓存' "$fixture/update.log" \ || fail 'install without cache did not report the missing verified cache' [ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/1.0.0-preview.1" ] \ || fail 'install without cache changed the active application' @@ -699,6 +715,8 @@ printf '[update-fixture] download-failure\n' assert_download_failure_case printf '[update-fixture] database-backup-failure\n' assert_database_failure_case +printf '[update-fixture] database-backup-opt-in\n' +assert_database_backup_opt_in_case printf '[update-fixture] unhealthy-baseline\n' assert_unhealthy_baseline_blocks_restart_case printf '[update-fixture] invalid-database-url\n'