Compare commits

...
Author SHA1 Message Date
Qiufeng 12d9c46323 fix: persist system update history
Release / release (push) Canceled after 0s
2026-08-19 00:22:23 +08:00
Qiufeng 1230116a6f fix: rebalance system update toolbar layout
Release / release (push) Canceled after 0s
2026-08-18 23:41:42 +08:00
Qiufeng 8c6c10eb85 fix: stabilize external-db deployment and updates
Release / release (push) Canceled after 0s
2026-08-18 22:51:35 +08:00
Qiufeng 227c4b0129 ignore local release runner state 2026-08-18 20:21:26 +08:00
Qiufeng 8dc1163333 restore standard release runner configuration 2026-08-18 20:19:34 +08:00
Qiufeng 1a2d472b5c fix deployment setup and release contract
Release / release (push) Successful in 18m56s
2026-08-18 20:14:45 +08:00
Qiufeng 7cf71ba7c3 ci: restore standard Linux release runner label 2026-08-18 20:01:51 +08:00
Qiufeng ec939fa1fd docs: publish stable release and Git install contract 2026-08-18 19:56:26 +08:00
Qiufeng 36f1a8b00d fix: keep setup database config empty and isolate Baota mode
Release / release (push) Successful in 19m31s
2026-08-18 19:50:14 +08:00
32 changed files with 1334 additions and 356 deletions
+7 -6
View File
@@ -10,10 +10,9 @@ permissions:
jobs: jobs:
release: release:
# The temporary release runner is isolated from stale queued jobs that use # Production releases use the repository's standard Linux runner label.
# the old ubuntu-24.04 label. Restore ubuntu-latest after this release when # The Gitea act_runner must advertise ubuntu-latest before tagging.
# the repository's normal Linux runner is online again. runs-on: ubuntu-latest
runs-on: kaidi-release
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
@@ -126,8 +125,10 @@ jobs:
- name: Publish Gitea release - name: Publish Gitea release
env: env:
GITEA_TOKEN: ${{ secrets.RELEASE_GITEA_TOKEN }} GITEA_TOKEN: ${{ secrets.RELEASE_GITEA_TOKEN }}
GITEA_SERVER_URL: ${{ github.server_url }} # Gitea's runner leaves github.server_url/repository empty on some
GITEA_REPOSITORY: ${{ github.repository }} # host-mode tag events; keep the release destination explicit.
GITEA_SERVER_URL: https://git.awaioi.com
GITEA_REPOSITORY: ERP-Team/kaidi
GITEA_REF_NAME: ${{ steps.release_meta.outputs.ref }} GITEA_REF_NAME: ${{ steps.release_meta.outputs.ref }}
GITEA_SHA: ${{ steps.release_meta.outputs.revision }} GITEA_SHA: ${{ steps.release_meta.outputs.revision }}
run: ./scripts/publish-gitea-release.sh run: ./scripts/publish-gitea-release.sh
+2
View File
@@ -12,6 +12,8 @@ frontend/test-results-dist/
*.log *.log
.env.local .env.local
runtime/ runtime/
.runner
.runner.lock
/dist/ /dist/
test-results/ test-results/
*signing-private.pem *signing-private.pem
+49 -35
View File
@@ -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 ```bash
cd backend cd backend
@@ -59,8 +66,9 @@ PostgreSQL 18 兼容工作继续冻结。
- **生产机不执行 `git clone`、`git pull` 或远程脚本拼接。** 源码仓库是 - **生产机不执行 `git clone`、`git pull` 或远程脚本拼接。** 源码仓库是
`https://git.awaioi.com/ERP-Team/kaidi.git`,生产安装和后台更新使用同一仓库生成的公开 Gitea Release。 `https://git.awaioi.com/ERP-Team/kaidi.git`,生产安装和后台更新使用同一仓库生成的公开 Gitea Release。
- 安装器先读取 Gitea Release API,再下载 `release-manifest.json`、签名、公钥和压缩包,校验固定公钥指纹、RSA 签名、版本、文件名和 SHA-256;校验失败不会安装。 - 安装器先读取 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`。 - 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 或宝塔站点配置。 - `KAIDI_APP_PORT` 只决定 Java 回环监听端口,默认 `18080`;反向代理必须指向安装器输出的 `PROXY_TARGET`。安装器不会替你修改 Nginx 或宝塔站点配置。
- 发布归档使用无顶层目录的 `tar.gz`,只包含 `app.jar`、`public/`、`ops/`、`VERSION`;打包阶段禁用 macOS 扩展属性并拒绝开发数据库回退值。 - 发布归档使用无顶层目录的 `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 位加载器的系统会在安装前明确失败,不会安装后才出现无法启动。 当前支持的是 **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 一键安装(推荐) ### systemd 一键安装(推荐)
先在宝塔面板停止并删除当前错误的 Java 项目,再执行: 先在宝塔面板停止并删除当前错误的 Java 项目,再执行:
```bash ```bash
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.34/install.sh | sudo env KAIDI_APP_PORT=18080 bash curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.44/install.sh | sudo env KAIDI_APP_PORT=18080 bash
``` ```
该命令只安装程序运行所需的 systemd 单元,自动创建 `kaidi` 用户并检测现有 Java 17(包括 该命令只安装程序运行所需的 systemd 单元,自动创建 `kaidi` 用户并检测现有 Java 17(包括
@@ -88,37 +96,39 @@ curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-previe
随后执行一键清理: 随后执行一键清理:
```bash ```bash
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.34/purge.sh \ curl -fsSL 'https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.44/purge.sh' | sudo env KAIDI_PURGE_CONFIRM=DELETE_LOCAL_KAIDI_INSTALLATION bash
-o /tmp/kaidi-purge.sh \
&& printf '%s %s\n' 44dbf10a9540aa9235bb8aff2dec603f1febbd45072cd8c4f6c40b25a6127801 /tmp/kaidi-purge.sh \
| sha256sum -c - \
&& sudo env KAIDI_PURGE_CONFIRM=DELETE_LOCAL_KAIDI_INSTALLATION bash /tmp/kaidi-purge.sh \
&& rm -f /tmp/kaidi-purge.sh
``` ```
上面是一条完整命令:脚本通过管道直接交给 root 执行,不创建临时安装文件,避免终端自动换行导致 `-o` 参数丢失。
如果需要连同本地恢复备份一起删除(确认不再需要回滚后再执行),在同一条命令中增加
`KAIDI_PURGE_DELETE_BACKUP=true`。卸载前必须先停止并删除宝塔中的 `kaidi-finance` Java 项目;宝塔的项目元数据、
Nginx/反向代理和外部 MySQL 属于外部资源,卸载程序不会误删它们。
清理脚本会先停止 systemd 和遗留 Kaidi 进程,将旧配置、文件存储、安装码以及已有数据库备份归档到 清理脚本会先停止 systemd 和遗留 Kaidi 进程,将旧配置、文件存储、安装码以及已有数据库备份归档到
`/root/kaidi-reinstall-backups/`,再删除 `/opt/kaidi`、`/www/wwwroot/kaidi`、`/etc/kaidi`、 `/root/kaidi-reinstall-backups/`,再删除 `/opt/kaidi`、`/www/wwwroot/kaidi`、`/etc/kaidi`、
`/var/lib/kaidi`、`/var/lib/kaidi-update`、`/var/log/kaidi` 和三个 systemd 单元。恢复包权限固定为 `/var/lib/kaidi`、`/var/lib/kaidi-update`、`/var/log/kaidi` 和三个 systemd 单元。恢复包权限固定为
`0600`,确认新安装及数据无误后再由 root 删除。脚本不删除外部 MySQL、系统 Java、Nginx 或宝塔站点配置; `0600`,确认新安装及数据无误后再由 root 删除。脚本不删除外部 MySQL、系统 Java、Nginx 或宝塔站点配置;
新安装必须连接一个新的空 MySQL 数据库,旧数据库保留用于回滚。 新安装必须连接一个新的空 MySQL 数据库,旧数据库保留用于回滚。
### Preview.31 直链与宝塔手动部署 ### Preview.43 直链与宝塔手动部署
本版不使用一键安装脚本。发布物是一个不带顶层目录的压缩包,下载后直接解压到版本目录,再由宝塔面板创建 本版提供 systemd 一键安装脚本和宝塔手动部署两种互斥方式。手动部署使用一个不带顶层目录的压缩包,下载后直接解压到版本目录,再由宝塔面板创建
Spring Boot 项目。数据库、JDK、Nginx 和宝塔本身都由运维人员准备;程序不会自动安装 MySQL 或任何第三方服务。 Spring Boot 项目。数据库、JDK、Nginx 和宝塔本身都由运维人员准备;程序不会自动安装 MySQL 或任何第三方服务。
下载地址: 下载地址:
`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.34/kaidi-finance-1.0.0-preview.34.tar.gz` `https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.44/kaidi-finance-1.0.0-preview.44.tar.gz`
校验文件:`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.34/SHA256SUMS` 校验文件:`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.44/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。程序只连接已有数据库,不安装数据库客户端或数据库服务。初始化只需要 已经提供可运行的 32 位 Java 17。程序只连接已有数据库,不安装数据库客户端或数据库服务。初始化只需要
`bash`、`openssl`、`sha256sum` 和基础 Linux 工具;选择 systemd 在线更新前,另行准备 `curl`、`jq`、`flock`、`gzip`、`runuser` `bash`、`openssl`、`sha256sum` 和基础 Linux 工具;systemd 在线更新默认不需要数据库客户端。只有将
以及与外部 MySQL 兼容的 `mysqldump`。安装器和更新器会优先使用 PATH 中的工具,也会探测宝塔常见的 `KAIDI_DB_BACKUP_MODE` 设为 `mysqldump` 时,才需要 `curl`、`jq`、`flock`、`gzip`、`runuser` 和兼容的
`/www/server/mysql/bin/` 路径;如工具安装在其他位置,可分别设置 `KAIDI_MYSQL_CLIENT` 和 `mysqldump`;安装器和更新器会优先使用 PATH 中的工具,也会探测宝塔常见的 `/www/server/mysql/bin/` 路径;如备份工具安装在其他位置,可设置
`KAIDI_MYSQLDUMP_BIN`,程序不会替你安装这些依赖。 `KAIDI_MYSQLDUMP_BIN`,程序不会替你安装这些依赖。
更新器默认只下载、验签、切换和健康检查,不调用 Docker、不进入容器,也不创建或管理 MySQL 服务。
#### 1. 下载、校验、解压 #### 1. 下载、校验、解压
@@ -126,7 +136,7 @@ Spring Boot 项目。数据库、JDK、Nginx 和宝塔本身都由运维人员
必须直接位于 `RELEASE_ROOT`,不能再嵌套一层目录。 必须直接位于 `RELEASE_ROOT`,不能再嵌套一层目录。
```bash ```bash
VERSION=1.0.0-preview.34 VERSION=1.0.0-preview.44
APP_ROOT=/www/wwwroot/kaidi APP_ROOT=/www/wwwroot/kaidi
RELEASE_ROOT="$APP_ROOT/releases/$VERSION" RELEASE_ROOT="$APP_ROOT/releases/$VERSION"
sudo install -d -m 0755 "$RELEASE_ROOT" sudo install -d -m 0755 "$RELEASE_ROOT"
@@ -150,6 +160,9 @@ sudo "$RELEASE_ROOT/ops/baota-init.sh" 18080
sudo cat /root/kaidi-first-login.txt sudo cat /root/kaidi-first-login.txt
``` ```
初始化不会安装或启用 `kaidi-update.service`/`kaidi-update.path`,避免宝塔守护进程与
systemd 更新器同时管理同一应用;宝塔模式只支持手动替换 Release,后台在线更新请使用 systemd 安装模式。
初始化失败会回滚本次写入的配置、unit 和 `current` 链接,可以直接修正原因后重试;已经存在正式安装时不要重复执行,先按“彻底清理旧安装”流程处理。 初始化失败会回滚本次写入的配置、unit 和 `current` 链接,可以直接修正原因后重试;已经存在正式安装时不要重复执行,先按“彻底清理旧安装”流程处理。
#### 3. 宝塔 Spring Boot 项目字段 #### 3. 宝塔 Spring Boot 项目字段
@@ -214,29 +227,30 @@ curl -fsS http://127.0.0.1:18080/ | head
不要在宝塔项目仍运行时点击“系统治理 → 系统更新”,也不要让宝塔守护和 `kaidi-finance.service` 同时管理同一端口。 不要在宝塔项目仍运行时点击“系统治理 → 系统更新”,也不要让宝塔守护和 `kaidi-finance.service` 同时管理同一端口。
需要后台点击“获取版本 → 下载 → 立即更新并重启”的稳定流程时,使用上一节的 systemd 一键安装方式。 需要后台点击“获取版本 → 下载 → 立即更新并重启”的稳定流程时,使用上一节的 systemd 一键安装方式。
手动升级前请先由运维人员完成外部 MySQL 备份;程序不会安装 MySQL 或客户端,也不会修改反向代理配置。 手动升级由运维人员决定是否先做外部 MySQL 备份;程序不会安装 MySQL 或客户端,也不会修改反向代理配置。
后续版本仍按同样方式直接下载并解压到新的 `releases/<VERSION>`,保留可回滚的旧目录。 后续版本仍按同样方式直接下载并解压到新的 `releases/<VERSION>`,保留可回滚的旧目录。
### Linux 32 位 ### Linux 32 位
压缩包本身不绑定 CPU 架构,关键是宿主机提供 glibc、systemd 和可运行的 32 位 Java 17。32 位主机不能在本机运行 64 位 JDK,也不应尝试在本机安装 压缩包本身不绑定 CPU 架构,关键是宿主机提供 glibc、systemd 和可运行的 32 位 Java 17。32 位主机不能在本机运行 64 位 JDK,也不应尝试在本机安装
MySQL 8.4;提前准备外部 MySQL,完成上述向导即可。选择 systemd 在线更新前,还必须确认宿主机能执行与数据库版本兼容的 MySQL 8.4;提前准备外部 MySQL,完成上述向导即可。systemd 在线更新默认不访问数据库;只有显式启用
`mysqldump`;宝塔手动模式只做手动制品替换和人工数据库备份。 `KAIDI_DB_BACKUP_MODE=mysqldump` 时才需要确认宿主机能执行与数据库版本兼容的工具。宝塔手动模式只做手动制品替换和人工数据库备份。
### 停用并移除程序 ### 一键卸载
以下命令移除应用程序和服务,但保留 `/var/lib/kaidi`、`/var/lib/kaidi-update`、`/etc/kaidi` 以及数据库, `purge.sh` 就是本项目的卸载程序:它会停止 Kaidi 进程、移除 systemd 单元、删除本地程序/配置/运行状态、
便于审计、备份或重新安装。确认数据备份前不要删除这些保留目录或外部 MySQL 数据。 清理受管临时压缩包,并删除本次安装创建的 `kaidi` 服务账号;不会触碰外部 MySQL、Nginx、反向代理或系统 Java。
反向代理由运维人员独立管理,停用程序不会修改其配置。 默认先把本地配置和运行数据保存到 root-only 恢复包,再删除受管路径。确认不需要回滚时,可在同一条命令中设置
`KAIDI_PURGE_DELETE_BACKUP=true`,实现本地受管文件和恢复包一并清除:
```bash ```bash
sudo systemctl disable --now kaidi-update.path kaidi-update.service kaidi-finance.service curl -fsSL 'https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.44/purge.sh' | sudo env KAIDI_PURGE_CONFIRM=DELETE_LOCAL_KAIDI_INSTALLATION KAIDI_PURGE_DELETE_BACKUP=true bash
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
``` ```
执行前先在宝塔停止并删除 `kaidi-finance` Java 项目;宝塔面板元数据属于外部资源,必须由面板先停用,
否则守护进程会重新拉起 Java,卸载程序会明确报出原因而不误删其他服务。
## Release 与在线更新 ## Release 与在线更新
首次建立发布仓库时生成一次签名密钥: 首次建立发布仓库时生成一次签名密钥:
@@ -257,8 +271,8 @@ Actions 页面显示 “No matching online runner”,先启动并注册该标
Preview 属性由 SemVer 版本名表达。之后推送 tag 即会构建、测试、签名并发布: Preview 属性由 SemVer 版本名表达。之后推送 tag 即会构建、测试、签名并发布:
```bash ```bash
git tag v1.0.0-preview.34 git tag v1.0.0-preview.44
git push origin v1.0.0-preview.34 git push origin v1.0.0-preview.44
``` ```
在线更新仍使用独立的 TDesign 页面:系统管理员进入“系统治理 → 系统更新”。更新源由 root 在 在线更新仍使用独立的 TDesign 页面:系统管理员进入“系统治理 → 系统更新”。更新源由 root 在
@@ -273,11 +287,11 @@ git push origin v1.0.0-preview.34
2. 发现新版本后显示“立即更新”;管理员点击后才排队 `DOWNLOAD`。更新器下载 manifest、签名和应用包,执行 RSA、SHA-256、版本、 2. 发现新版本后显示“立即更新”;管理员点击后才排队 `DOWNLOAD`。更新器下载 manifest、签名和应用包,执行 RSA、SHA-256、版本、
文件名和压缩包路径校验,通过后缓存到 `/var/lib/kaidi-update/cache/<version>`,业务服务继续运行。 文件名和压缩包路径校验,通过后缓存到 `/var/lib/kaidi-update/cache/<version>`,业务服务继续运行。
3. 页面实时轮询并依次显示下载已排队、下载中、校验中和 `READY/下载完成`;只有下载完成后才显示“立即更新并重启”。 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`(宝塔手动部署才使用 5. 更新器安装并保护新版本目录,原子切换 `/opt/kaidi/current`(宝塔手动部署才使用
`/www/wwwroot/kaidi/current`),停止并重启 systemd 管理的应用服务。 `/www/wwwroot/kaidi/current`),停止并重启 systemd 管理的应用服务。
6. 健康端点、静态首页和运行版本全部通过后,页面等待 10 秒自动刷新;期间断线或命令响应丢失由前端状态重同步恢复。任一检查失败则切回上一应用版本, 6. 健康端点、静态首页和运行版本全部通过后,页面等待 10 秒自动刷新;期间断线或命令响应丢失由前端状态重同步恢复。任一检查失败则切回上一应用版本,
保留数据库备份和事务证据供人工处理。 如果启用了 `mysqldump` 模式则保留数据库备份;否则保留事务证据供人工处理。
忙碌期间检查、下载和安装按钮保持禁用,防止重复请求;10 秒只用于成功后的页面刷新,不延迟服务端切换。数据库迁移必须至少保持一个版本向后兼容。 忙碌期间检查、下载和安装按钮保持禁用,防止重复请求;10 秒只用于成功后的页面刷新,不延迟服务端切换。数据库迁移必须至少保持一个版本向后兼容。
查看状态和日志: 查看状态和日志:
@@ -310,7 +324,7 @@ cat /var/lib/kaidi-update/status.json
```bash ```bash
KAIDI_RELEASE_SIGNING_KEY=/secure/release-signing-private.pem \ KAIDI_RELEASE_SIGNING_KEY=/secure/release-signing-private.pem \
KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \ KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \
./scripts/package-release.sh 1.0.0-preview.34 ./scripts/package-release.sh 1.0.0-preview.44
KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \ KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \
./scripts/verify-release.sh dist/release ./scripts/verify-release.sh dist/release
``` ```
@@ -57,7 +57,7 @@ public class SetupService {
public SetupService(SetupProperties properties) { public SetupService(SetupProperties properties) {
this.properties = properties; this.properties = properties;
if (properties.tokenSha256() == null || !properties.tokenSha256().matches("(?i)[0-9a-f]{64}")) { 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()))) { if (Files.exists(Path.of(properties.markerFile()))) {
locked.set(true); locked.set(true);
@@ -79,15 +79,14 @@ public class SetupService {
// Connection testing is useful for diagnostics, but the current business SQL/migration // 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 // baseline is MySQL-specific. Do not allow an apparently successful test to produce a
// database that the main application cannot start against. // 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(), return new SetupViews.Connection(connection.successful(), settings.type().name(), connection.version(),
false, "PostgreSQL 连接可用,但当前 Preview 的业务迁移仅支持 MySQL 8.4"); false, "PostgreSQL 连接可用,但当前 Preview 的业务迁移仅支持 MySQL 8.4");
} }
ConnectionResult connection = testConnection(settings); ConnectionResult connection = testConnection(settings, false);
prepareDatabaseForInstallation(settings); LOGGER.info("MySQL 只读连接测试通过,未执行数据库写操作,等待管理员确认完成安装");
validateSchemaForInstallation(settings); return new SetupViews.Connection(true, settings.type().name(), connection.version(), false,
return new SetupViews.Connection(true, settings.type().name(), connection.version(), true, "MySQL 8.4 只读连接验证通过;尚未修改数据库,点击完成安装后才会执行迁移");
"MySQL 8.4 连接、排序规则和完整迁移权限验证通过");
} }
public SetupViews.Completed complete(SetupContracts.CompleteRequest request) { public SetupViews.Completed complete(SetupContracts.CompleteRequest request) {
@@ -105,7 +104,10 @@ public class SetupService {
} }
synchronized (this) { synchronized (this) {
ensureOpen(); 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()) { if (!connection.successful()) {
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY, throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
"DATABASE_CONNECTION_FAILED", "数据库连接失败,请检查地址、端口、库名和账号"); "DATABASE_CONNECTION_FAILED", "数据库连接失败,请检查地址、端口、库名和账号");
@@ -145,7 +147,7 @@ public class SetupService {
if (exception instanceof SetupException setupException) { if (exception instanceof SetupException setupException) {
throw setupException; throw setupException;
} }
LOGGER.error("Database migration or administrator initialization failed", exception); LOGGER.error("数据库迁移或管理员初始化失败", exception);
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY, throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
"DATABASE_INITIALIZATION_FAILED", databaseInitializationFailureMessage(exception)); "DATABASE_INITIALIZATION_FAILED", databaseInitializationFailureMessage(exception));
} }
@@ -167,14 +169,14 @@ public class SetupService {
"DATABASE_COLLATION_UNSUPPORTED", "数据库字符集或排序规则未能统一为 " "DATABASE_COLLATION_UNSUPPORTED", "数据库字符集或排序规则未能统一为 "
+ MYSQL_CHARACTER_SET + "/" + MYSQL_COLLATION); + 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()); current.characterSet(), current.collation(), updated.characterSet(), updated.collation());
} }
recoverV068CollationFailure(jdbc, settings.database()); recoverV068CollationFailure(jdbc, settings.database());
} catch (SetupException exception) { } catch (SetupException exception) {
throw exception; throw exception;
} catch (RuntimeException exception) { } catch (RuntimeException exception) {
LOGGER.error("Database collation preparation failed", exception); LOGGER.error("数据库字符集和排序规则准备失败", exception);
SQLException sqlException = findSqlException(exception); SQLException sqlException = findSqlException(exception);
String detail = sqlException == null String detail = sqlException == null
? "数据库字符集和排序规则初始化失败:" + safeErrorMessage(exception) ? "数据库字符集和排序规则初始化失败:" + safeErrorMessage(exception)
@@ -242,7 +244,7 @@ public class SetupService {
throw new SetupException(org.springframework.http.HttpStatus.CONFLICT, throw new SetupException(org.springframework.http.HttpStatus.CONFLICT,
"DATABASE_MIGRATION_RECOVERY_CONFLICT", "V068 迁移恢复状态已变化,请重新测试数据库连接"); "DATABASE_MIGRATION_RECOVERY_CONFLICT", "V068 迁移恢复状态已变化,请重新测试数据库连接");
} }
LOGGER.warn("Removed the recoverable failed {} history row after database collation normalization", LOGGER.warn("数据库字符集统一后已移除可恢复的失败迁移记录 {}",
RECOVERABLE_V068_SCRIPT); RECOVERABLE_V068_SCRIPT);
} }
@@ -282,16 +284,16 @@ public class SetupService {
} catch (SetupException exception) { } catch (SetupException exception) {
throw exception; throw exception;
} catch (FlywayException exception) { } catch (FlywayException exception) {
LOGGER.error("Database migration history validation failed", exception); LOGGER.error("数据库迁移历史校验失败", exception);
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY, throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
"DATABASE_MIGRATION_STATE_INVALID", "DATABASE_MIGRATION_STATE_INVALID",
"数据库迁移历史校验失败,请使用空数据库,或先修复已有迁移状态:" + safeErrorMessage(exception)); "数据库迁移历史校验失败,请使用空数据库,或先修复已有迁移状态:" + safeErrorMessage(exception));
} catch (RuntimeException exception) { } catch (RuntimeException exception) {
LOGGER.error("Database schema inspection failed", exception); LOGGER.error("数据库结构检查失败", exception);
SQLException sqlException = findSqlException(exception); SQLException sqlException = findSqlException(exception);
String detail = sqlException == null String detail = sqlException == null
? "数据库结构检查失败:" + safeErrorMessage(exception) ? "数据库结构检查失败:" + safeErrorMessage(exception)
: databaseConnectionFailureMessage(sqlException); : databaseConnectionFailureMessage(sqlException, true);
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY, throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
"DATABASE_SCHEMA_INSPECTION_FAILED", detail); "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(), try (Connection connection = DriverManager.getConnection(settings.jdbcUrl(), settings.username(),
settings.password()); Statement statement = connection.createStatement()) { settings.password())) {
statement.setQueryTimeout(10); // A connection test must not create or mutate database objects. JDBC metadata
String version; // is supplied by the handshake and avoids issuing SELECT/DDL/DML in the test step.
try (ResultSet result = statement.executeQuery("SELECT VERSION()")) { String version = connection.getMetaData().getDatabaseProductVersion();
result.next(); if (version == null || version.isBlank()) {
version = result.getString(1); version = "unknown";
} }
if (settings.type() == DatabaseType.MYSQL && !version.startsWith("8.4.")) { if (settings.type() == DatabaseType.MYSQL && !version.startsWith("8.4.")) {
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY, throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
"MYSQL_VERSION_UNSUPPORTED", "当前版本要求 MySQL 8.4.x,检测到 " + version); "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); return new ConnectionResult(true, version);
} catch (SQLException exception) { } 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); exception.getSQLState(), exception.getErrorCode(), exception);
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY, 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)) { if (isPermissionError(exception)) {
return "数据库账号缺少完整迁移权限" + sqlErrorSummary(exception); return (verifyPrivileges ? "数据库账号缺少完整迁移权限" : "数据库账号无权建立连接")
+ sqlErrorSummary(exception);
} }
if (exception.getSQLState() != null && exception.getSQLState().startsWith("08")) { if (exception.getSQLState() != null && exception.getSQLState().startsWith("08")) {
return "数据库连接超时或中断" + sqlErrorSummary(exception); return "数据库连接超时或中断" + sqlErrorSummary(exception);
} }
return "数据库连接或完整迁移权限验证失败" + sqlErrorSummary(exception); return (verifyPrivileges ? "数据库连接或完整迁移权限验证失败" : "数据库只读连接测试失败")
+ sqlErrorSummary(exception);
} }
private String databaseInitializationFailureMessage(Throwable exception) { private String databaseInitializationFailureMessage(Throwable exception) {
@@ -510,14 +521,14 @@ public class SetupService {
try (Statement call = connection.createStatement(); try (Statement call = connection.createStatement();
ResultSet result = call.executeQuery("CALL " + routine + "()")) { ResultSet result = call.executeQuery("CALL " + routine + "()")) {
if (!result.next() || result.getInt(1) != 1) { 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 " + parentTable + " (id) VALUES (1)");
statement.execute("INSERT INTO " + childTable + " (id, parent_id, note) VALUES (1, 1, 'probe')"); 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")) { try (ResultSet result = statement.executeQuery("SELECT note FROM " + childTable + " WHERE id = 1")) {
if (!result.next() || !"probe".equals(result.getString(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 statement.execute("CREATE TEMPORARY TABLE " + temporaryTable
@@ -81,10 +81,10 @@ public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class) @ExceptionHandler(Exception.class)
public ResponseEntity<ProblemDetail> handleUnexpected(Exception exception, HttpServletRequest request) { public ResponseEntity<ProblemDetail> handleUnexpected(Exception exception, HttpServletRequest request) {
if (isLockFailure(exception)) { if (isLockFailure(exception)) {
log.warn("Concurrent database modification requestId={}", RequestContext.requestId(), exception); log.warn("并发数据库修改 requestId={}", RequestContext.requestId(), exception);
return concurrentModification(request); 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(), return response(HttpStatus.INTERNAL_SERVER_ERROR, ErrorCode.INTERNAL_ERROR.name(),
"系统处理失败,请使用请求编号联系管理员", Map.of(), request); "系统处理失败,请使用请求编号联系管理员", Map.of(), request);
} }
@@ -22,6 +22,54 @@ public interface AuditMapper {
""") """)
int insert(AuditEntry entry); int insert(AuditEntry entry);
@Select("""
SELECT request_id, user_public_id, username, active_role, company_public_id, project_public_id,
CAST(after_json AS CHAR) AS after_json, ip_address, user_agent
FROM audit_log
WHERE object_type = 'SYSTEM_UPDATE'
AND action_code = #{actionCode}
AND request_id = #{requestId}
ORDER BY id DESC
LIMIT 1
""")
SystemUpdateAuditSource findSystemUpdateSourceByRequestId(@Param("requestId") String requestId,
@Param("actionCode") String actionCode);
@Select("""
SELECT request_id, user_public_id, username, active_role, company_public_id, project_public_id,
CAST(after_json AS CHAR) AS after_json, ip_address, user_agent
FROM audit_log
WHERE object_type = 'SYSTEM_UPDATE'
AND action_code = #{actionCode}
AND (
JSON_UNQUOTE(JSON_EXTRACT(after_json, '$.targetVersion')) = #{targetVersion}
OR JSON_UNQUOTE(JSON_EXTRACT(after_json, '$.latestVersion')) = #{targetVersion}
)
AND created_at >= DATE_SUB(#{completedAt}, INTERVAL 7 DAY)
AND created_at <= DATE_ADD(#{completedAt}, INTERVAL 5 MINUTE)
ORDER BY id DESC
LIMIT 1
""")
SystemUpdateAuditSource findSystemUpdateSourceByVersion(@Param("targetVersion") String targetVersion,
@Param("actionCode") String actionCode,
@Param("completedAt") LocalDateTime completedAt);
@Insert("""
INSERT INTO audit_log (
public_id, request_id, user_public_id, username, active_role, company_public_id,
project_public_id, action_code, object_type, object_public_id, result_code, reason,
before_json, after_json, ip_address, user_agent, dedupe_key, created_at
) VALUES (
#{entry.publicId}, #{entry.requestId}, #{entry.userPublicId}, #{entry.username}, #{entry.activeRole},
#{entry.companyPublicId}, #{entry.projectPublicId}, #{entry.actionCode}, #{entry.objectType},
#{entry.objectPublicId}, #{entry.resultCode}, #{entry.reason}, #{entry.beforeJson}, #{entry.afterJson},
#{entry.ipAddress}, #{entry.userAgent}, #{dedupeKey}, #{occurredAt}
)
ON DUPLICATE KEY UPDATE dedupe_key = #{dedupeKey}
""")
int insertSystemUpdateTerminal(@Param("entry") AuditEntry entry, @Param("dedupeKey") String dedupeKey,
@Param("occurredAt") LocalDateTime occurredAt);
@Select(""" @Select("""
SELECT created_at, ip_address, user_agent SELECT created_at, ip_address, user_agent
FROM audit_log FROM audit_log
@@ -36,4 +84,9 @@ public interface AuditMapper {
record LoginAuditRow(LocalDateTime occurredAt, String ipAddress, String userAgent) { record LoginAuditRow(LocalDateTime occurredAt, String ipAddress, String userAgent) {
} }
record SystemUpdateAuditSource(String requestId, String userPublicId, String username, String activeRole,
String companyPublicId, String projectPublicId, String afterJson,
String ipAddress, String userAgent) {
}
} }
@@ -7,6 +7,16 @@ import com.kaidi.finance.shared.id.UlidGenerator;
import com.kaidi.finance.shared.infrastructure.RequestContext; import com.kaidi.finance.shared.infrastructure.RequestContext;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession; import jakarta.servlet.http.HttpSession;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -54,6 +64,71 @@ public class AuditService {
null, null); null, null);
} }
/**
* Persists the updater's terminal state as a separate immutable audit event.
*
* The shell updater finishes while the application is restarting, so this method is invoked by the first
* successful status read after restart. A database-level dedupe key makes repeated polling and application
* restarts idempotent. When possible the terminal event inherits the actor and request id from the original
* download/install request; legacy status files without a request id fall back to the most recent matching
* target version.
*
* @return the stable dedupe key, or {@code null} when the supplied state is not persistable
*/
public String recordSystemUpdateTerminal(String updateRequestId, String updateAction, String state,
String targetVersion, String message, Instant updatedAt) {
String normalizedState = normalizeTerminalState(state);
String normalizedVersion = clean(targetVersion, 128);
if (normalizedState == null || normalizedVersion == null || updatedAt == null) return null;
String normalizedRequestId = validRequestId(updateRequestId) ? updateRequestId : null;
String normalizedAction = clean(updateAction, 16);
if (normalizedAction != null) normalizedAction = normalizedAction.toUpperCase(Locale.ROOT);
String sourceAction = sourceAction(normalizedState, normalizedAction);
AuditMapper.SystemUpdateAuditSource source = findSystemUpdateSource(
normalizedRequestId, normalizedVersion, sourceAction, updatedAt);
if (source == null && normalizedAction == null && !"SYSTEM_UPDATE_DOWNLOAD_REQUEST".equals(sourceAction)) {
source = findSystemUpdateSource(normalizedRequestId, normalizedVersion,
"SYSTEM_UPDATE_DOWNLOAD_REQUEST", updatedAt);
}
String correlation = normalizedRequestId == null
? normalizedVersion + '|' + normalizedState + '|' + updatedAt
: normalizedRequestId + '|' + normalizedState;
String dedupeKey = "SYSUPD:" + sha256(correlation);
String terminalAction = switch (normalizedState) {
case "SUCCEEDED" -> "SYSTEM_UPDATE_SUCCEEDED";
case "RECOVERY_REQUIRED" -> "SYSTEM_UPDATE_RECOVERY_REQUIRED";
default -> "SYSTEM_UPDATE_FAILED";
};
String result = "SUCCEEDED".equals(normalizedState)
? "SUCCESS" : ("RECOVERY_REQUIRED".equals(normalizedState) ? "BLOCKED" : "FAILED");
String terminalReason = truncate(sanitizeReason(clean(message, 1000)), 500);
Map<String, Object> terminal = new LinkedHashMap<>();
terminal.put("state", normalizedState);
terminal.put("targetVersion", normalizedVersion);
terminal.put("message", terminalReason == null ? "" : terminalReason);
terminal.put("updatedAt", updatedAt);
if (normalizedAction != null) terminal.put("action", normalizedAction);
if (normalizedRequestId != null) terminal.put("requestId", normalizedRequestId);
Actor actor = source == null
? currentActor() : new Actor(source.userPublicId(), source.username(), source.activeRole());
AuditEntry entry = new AuditEntry(
ulidGenerator.next(), source == null ? RequestContext.requestId() : source.requestId(),
actor.publicId(), actor.username(), actor.activeRole(),
source == null ? null : source.companyPublicId(), source == null ? null : source.projectPublicId(),
terminalAction, "SYSTEM_UPDATE", "SYSTEM_UPDATE", result, terminalReason,
source == null ? null : source.afterJson(), json(terminal),
source == null ? clientIp() : source.ipAddress(), source == null
? truncate(request.getHeader("User-Agent"), 500) : source.userAgent()
);
auditMapper.insertSystemUpdateTerminal(entry, dedupeKey,
LocalDateTime.ofInstant(updatedAt, ZoneOffset.UTC));
return dedupeKey;
}
private void insert(Actor actor, String companyPublicId, String projectPublicId, String action, private void insert(Actor actor, String companyPublicId, String projectPublicId, String action,
String objectType, String objectPublicId, String result, String reason, String objectType, String objectPublicId, String result, String reason,
Object before, Object after) { Object before, Object after) {
@@ -76,6 +151,49 @@ public class AuditService {
return new Actor(null, null, null); return new Actor(null, null, null);
} }
private AuditMapper.SystemUpdateAuditSource findSystemUpdateSource(String requestId, String targetVersion,
String actionCode, Instant completedAt) {
AuditMapper.SystemUpdateAuditSource source = requestId == null
? null : auditMapper.findSystemUpdateSourceByRequestId(requestId, actionCode);
return source == null ? auditMapper.findSystemUpdateSourceByVersion(targetVersion, actionCode,
LocalDateTime.ofInstant(completedAt, ZoneOffset.UTC)) : source;
}
private static String normalizeTerminalState(String state) {
String normalized = clean(state, 32);
if (normalized == null) return null;
normalized = normalized.toUpperCase(Locale.ROOT);
return switch (normalized) {
case "SUCCEEDED", "FAILED", "RECOVERY_REQUIRED" -> normalized;
default -> null;
};
}
private static String sourceAction(String state, String action) {
if ("SUCCEEDED".equals(state) || "INSTALL".equals(action)) return "SYSTEM_UPDATE_REQUEST";
if ("DOWNLOAD".equals(action)) return "SYSTEM_UPDATE_DOWNLOAD_REQUEST";
return "SYSTEM_UPDATE_REQUEST";
}
private static boolean validRequestId(String requestId) {
return requestId != null && requestId.matches("^[0-9A-HJKMNP-TV-Z]{26}$");
}
private static String clean(String value, int max) {
if (value == null || value.isBlank()) return null;
String cleaned = value.trim();
return cleaned.length() <= max ? cleaned : cleaned.substring(0, max);
}
private static String sha256(String value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 is unavailable", exception);
}
}
private String json(Object value) { private String json(Object value) {
if (value == null) { if (value == null) {
return null; return null;
@@ -39,7 +39,7 @@ public class DeniedAccessAuditService {
try { try {
writer.record(method, uri, status.value(), errorCode); writer.record(method, uri, status.value(), errorCode);
} catch (RuntimeException exception) { } 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); RequestContext.requestId(), method, uri, errorCode, exception);
} }
} }
@@ -372,7 +372,7 @@ public class FileApplicationService {
try { try {
Files.delete(source); Files.delete(source);
} catch (IOException exception) { } catch (IOException exception) {
LOGGER.warn("Failed to remove duplicate quarantine source for file {}", plan.publicId(), exception); LOGGER.warn("删除文件 {} 的重复隔离源失败", plan.publicId(), exception);
} }
} }
return result; return result;
@@ -36,12 +36,16 @@ import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@Service @Service
public class SystemUpdateApplicationService { public class SystemUpdateApplicationService {
private static final Logger log = LoggerFactory.getLogger(SystemUpdateApplicationService.class);
private static final int MAX_MANIFEST_BYTES = 64 * 1024; private static final int MAX_MANIFEST_BYTES = 64 * 1024;
private static final int MAX_RELEASE_API_BYTES = 256 * 1024; private static final int MAX_RELEASE_API_BYTES = 256 * 1024;
private static final int MAX_STATUS_BYTES = 64 * 1024; private static final int MAX_STATUS_BYTES = 64 * 1024;
@@ -71,6 +75,7 @@ public class SystemUpdateApplicationService {
private final HttpClient httpClient; private final HttpClient httpClient;
private volatile ReleaseManifest lastManifest; private volatile ReleaseManifest lastManifest;
private volatile Instant lastCheckedAt; private volatile Instant lastCheckedAt;
private volatile String lastTerminalAuditFingerprint;
public SystemUpdateApplicationService(SystemUpdateProperties properties, public SystemUpdateApplicationService(SystemUpdateProperties properties,
AuthorizationService authorizationService, AuthorizationService authorizationService,
@@ -90,7 +95,9 @@ public class SystemUpdateApplicationService {
public SystemUpdateView status() { public SystemUpdateView status() {
requireIsolatedSystemAdministrator(); requireIsolatedSystemAdministrator();
return view(lastManifest, readStatus()); UpdateStatus status = readStatus();
persistTerminalAudit(status);
return view(lastManifest, status);
} }
public SystemUpdateView check() { public SystemUpdateView check() {
@@ -99,7 +106,9 @@ public class SystemUpdateApplicationService {
ReleaseManifest manifest = fetchManifest(); ReleaseManifest manifest = fetchManifest();
lastManifest = manifest; lastManifest = manifest;
lastCheckedAt = Instant.now(); lastCheckedAt = Instant.now();
SystemUpdateView result = view(manifest, readStatus()); UpdateStatus status = readStatus();
persistTerminalAudit(status);
SystemUpdateView result = view(manifest, status);
auditService.record("SYSTEM_UPDATE_CHECK", "SYSTEM_UPDATE", "SYSTEM_UPDATE", "SUCCESS", null, null, auditService.record("SYSTEM_UPDATE_CHECK", "SYSTEM_UPDATE", "SYSTEM_UPDATE", "SUCCESS", null, null,
result); result);
return result; return result;
@@ -120,6 +129,7 @@ public class SystemUpdateApplicationService {
throw validation("当前已是相同或更高版本"); throw validation("当前已是相同或更高版本");
} }
UpdateStatus currentStatus = readStatus(); UpdateStatus currentStatus = readStatus();
persistTerminalAudit(currentStatus);
if ("READY".equals(currentStatus.state()) && requested.equals(currentStatus.targetVersion())) { if ("READY".equals(currentStatus.state()) && requested.equals(currentStatus.targetVersion())) {
throw validation("该版本已经下载并通过校验,请确认安装"); throw validation("该版本已经下载并通过校验,请确认安装");
} }
@@ -137,6 +147,7 @@ public class SystemUpdateApplicationService {
requireConfigured(); requireConfigured();
String requested = request.version().trim(); String requested = request.version().trim();
UpdateStatus ready = readStatus(); UpdateStatus ready = readStatus();
persistTerminalAudit(ready);
if (!"READY".equals(ready.state()) || !requested.equals(ready.targetVersion())) { if (!"READY".equals(ready.state()) || !requested.equals(ready.targetVersion())) {
throw validation("该版本尚未完成下载和签名校验"); throw validation("该版本尚未完成下载和签名校验");
} }
@@ -369,7 +380,8 @@ public class SystemUpdateApplicationService {
"已有系统更新请求等待执行"); "已有系统更新请求等待执行");
} }
writeRequest(requestFile, version, reason, action); writeRequest(requestFile, version, reason, action);
UpdateStatus queued = new UpdateStatus(queuedState, queuedMessage, version, Instant.now()); UpdateStatus queued = new UpdateStatus(queuedState, queuedMessage, version, Instant.now(),
RequestContext.requestId(), action);
return new QueueTransition(currentStatus, queued); return new QueueTransition(currentStatus, queued);
} catch (IOException exception) { } catch (IOException exception) {
throw storage("系统更新队列锁定失败"); throw storage("系统更新队列锁定失败");
@@ -431,7 +443,8 @@ public class SystemUpdateApplicationService {
blank(root.path("targetVersion").asText(null)), parseInstant(root.path("updatedAt").asText(null)), blank(root.path("targetVersion").asText(null)), parseInstant(root.path("updatedAt").asText(null)),
nonNegativeLong(root, "downloadedBytes"), nonNegativeLong(root, "totalBytes"), nonNegativeLong(root, "downloadedBytes"), nonNegativeLong(root, "totalBytes"),
nonNegativeLong(root, "bytesPerSecond"), boundedInteger(root, "downloadPercent", 0, 100), nonNegativeLong(root, "bytesPerSecond"), boundedInteger(root, "downloadPercent", 0, 100),
boundedInteger(root, "restartExpectedSeconds", 0, 300)); boundedInteger(root, "restartExpectedSeconds", 0, 300),
boundedText(root, "requestId", 64), updateAction(root.path("action").asText(null)));
} catch (IOException exception) { } catch (IOException exception) {
return new UpdateStatus("UNKNOWN", "更新状态读取失败", null, null); return new UpdateStatus("UNKNOWN", "更新状态读取失败", null, null);
} }
@@ -525,10 +538,13 @@ public class SystemUpdateApplicationService {
String version = blank(root.path("version").asText(null)); String version = blank(root.path("version").asText(null));
String action = root.path("action").asText("INSTALL").toUpperCase(Locale.ROOT); String action = root.path("action").asText("INSTALL").toUpperCase(Locale.ROOT);
Instant requestedAt = parseInstant(root.path("requestedAt").asText(null)); Instant requestedAt = parseInstant(root.path("requestedAt").asText(null));
String requestId = boundedText(root, "requestId", 64);
if ("DOWNLOAD".equals(action)) { if ("DOWNLOAD".equals(action)) {
return new UpdateStatus("DOWNLOAD_QUEUED", "下载请求已排队,等待更新服务处理", version, requestedAt); return new UpdateStatus("DOWNLOAD_QUEUED", "下载请求已排队,等待更新服务处理", version,
requestedAt, requestId, action);
} }
return new UpdateStatus("INSTALL_QUEUED", "安装请求已排队,等待更新服务处理", version, requestedAt); return new UpdateStatus("INSTALL_QUEUED", "安装请求已排队,等待更新服务处理", version,
requestedAt, requestId, action);
} catch (IOException exception) { } catch (IOException exception) {
return new UpdateStatus("UNKNOWN", "更新请求读取失败", null, null); return new UpdateStatus("UNKNOWN", "更新请求读取失败", null, null);
} }
@@ -631,6 +647,39 @@ public class SystemUpdateApplicationService {
return parsed < minimum || parsed > maximum ? null : parsed; return parsed < minimum || parsed > maximum ? null : parsed;
} }
private static String boundedText(JsonNode root, String field, int maximum) {
JsonNode value = root.path(field);
if (!value.isTextual()) return null;
String parsed = blank(value.asText(null));
return parsed != null && parsed.length() <= maximum ? parsed : null;
}
private static String updateAction(String value) {
String action = blank(value);
if (action == null) return null;
action = action.toUpperCase(Locale.ROOT);
return "DOWNLOAD".equals(action) || "INSTALL".equals(action) ? action : null;
}
private void persistTerminalAudit(UpdateStatus status) {
if (status == null || status.updatedAt() == null || status.targetVersion() == null
|| !("SUCCEEDED".equals(status.state()) || "FAILED".equals(status.state())
|| "RECOVERY_REQUIRED".equals(status.state()))) {
return;
}
String fingerprint = String.join("|", status.state(), String.valueOf(status.requestId()),
status.targetVersion(), status.updatedAt().toString(), String.valueOf(status.action()));
if (fingerprint.equals(lastTerminalAuditFingerprint)) return;
try {
String persistedKey = auditService.recordSystemUpdateTerminal(status.requestId(), status.action(),
status.state(), status.targetVersion(), status.message(), status.updatedAt());
if (persistedKey != null) lastTerminalAuditFingerprint = fingerprint;
} catch (RuntimeException exception) {
log.warn("系统更新终态审计写入失败:state={}, targetVersion={}", status.state(),
status.targetVersion(), exception);
}
}
private BusinessException validation(String message) { private BusinessException validation(String message) {
return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED, message); return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED, message);
} }
@@ -649,9 +698,15 @@ public class SystemUpdateApplicationService {
private record UpdateStatus(String state, String message, String targetVersion, Instant updatedAt, private record UpdateStatus(String state, String message, String targetVersion, Instant updatedAt,
Long downloadedBytes, Long totalBytes, Long bytesPerSecond, Long downloadedBytes, Long totalBytes, Long bytesPerSecond,
Integer downloadPercent, Integer restartExpectedSeconds) { Integer downloadPercent, Integer restartExpectedSeconds,
String requestId, String action) {
private UpdateStatus(String state, String message, String targetVersion, Instant updatedAt) { private UpdateStatus(String state, String message, String targetVersion, Instant updatedAt) {
this(state, message, targetVersion, updatedAt, null, null, null, null, null); this(state, message, targetVersion, updatedAt, null, null, null, null, null, null, null);
}
private UpdateStatus(String state, String message, String targetVersion, Instant updatedAt,
String requestId, String action) {
this(state, message, targetVersion, updatedAt, null, null, null, null, null, requestId, action);
} }
} }
@@ -0,0 +1,3 @@
ALTER TABLE audit_log
ADD COLUMN dedupe_key VARCHAR(96) NULL AFTER user_agent,
ADD UNIQUE KEY uk_audit_log_dedupe_key (dedupe_key);
@@ -80,38 +80,16 @@ class SetupApplicationIntegrationTest {
new HttpEntity<>(database), JsonNode.class); new HttpEntity<>(database), JsonNode.class);
assertThat(tested.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(tested.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(tested.getBody().path("data").path("successful").asBoolean()).isTrue(); 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()) assertThat(tested.getBody().path("data").path("message").asText())
.contains("排序规则和完整迁移权限验证通过"); .contains("只读连接验证通过");
try (Connection connection = DriverManager.getConnection(MYSQL.getJdbcUrl(), MYSQL.getUsername(), try (Connection connection = DriverManager.getConnection(MYSQL.getJdbcUrl(), MYSQL.getUsername(),
MYSQL.getPassword()); Statement statement = connection.createStatement()) { MYSQL.getPassword()); Statement statement = connection.createStatement()) {
try (ResultSet result = statement.executeQuery(""" try (ResultSet result = statement.executeQuery("SELECT default_collation_name FROM information_schema.schemata WHERE schema_name = DATABASE()")) {
SELECT
(SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name LIKE 'kaidi_setup_probe_%')
+
(SELECT COUNT(*) FROM information_schema.routines
WHERE routine_schema = DATABASE() AND routine_name LIKE 'kaidi_setup_probe_%')
""")) {
result.next(); 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");
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();
} }
} }
@@ -0,0 +1,83 @@
package com.kaidi.finance.shared.audit;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kaidi.finance.shared.id.UlidGenerator;
import java.time.Instant;
import java.time.LocalDateTime;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.mock.web.MockHttpServletRequest;
class AuditServiceTest {
@Test
void terminalUpdateInheritsTheOriginalActorAndProducesAStableDedupeKey() {
AuditMapper mapper = mock(AuditMapper.class);
UlidGenerator ulids = mock(UlidGenerator.class);
when(ulids.next()).thenReturn("01M00000000000000000000999");
when(mapper.findSystemUpdateSourceByRequestId(
"01M00000000000000000000092", "SYSTEM_UPDATE_REQUEST"))
.thenReturn(new AuditMapper.SystemUpdateAuditSource(
"01M00000000000000000000092", "01M00000000000000000000001", "admin", "SYSTEM_ADMIN",
null, null, "{\"state\":\"INSTALL_QUEUED\",\"targetVersion\":\"1.0.0-preview.44\"}",
"127.0.0.1", "fixture-agent"));
when(mapper.insertSystemUpdateTerminal(any(), anyString(), any())).thenReturn(1);
AuditService service = new AuditService(mapper, ulids, new ObjectMapper().findAndRegisterModules(),
new MockHttpServletRequest("GET", "/api/v1/admin/system-update"));
Instant completedAt = Instant.parse("2026-08-19T00:10:00Z");
String firstKey = service.recordSystemUpdateTerminal("01M00000000000000000000092", "INSTALL",
"SUCCEEDED", "1.0.0-preview.44", "新版本已通过健康检查", completedAt);
String secondKey = service.recordSystemUpdateTerminal("01M00000000000000000000092", "INSTALL",
"SUCCEEDED", "1.0.0-preview.44", "新版本已通过健康检查", completedAt);
assertEquals(firstKey, secondKey);
assertTrue(firstKey.startsWith("SYSUPD:"));
ArgumentCaptor<AuditEntry> entry = ArgumentCaptor.forClass(AuditEntry.class);
verify(mapper, org.mockito.Mockito.times(2)).insertSystemUpdateTerminal(entry.capture(),
org.mockito.ArgumentMatchers.eq(firstKey), any());
AuditEntry persisted = entry.getAllValues().get(0);
assertEquals("01M00000000000000000000092", persisted.requestId());
assertEquals("01M00000000000000000000001", persisted.userPublicId());
assertEquals("SYSTEM_UPDATE_SUCCEEDED", persisted.actionCode());
assertEquals("SUCCESS", persisted.resultCode());
assertTrue(persisted.beforeJson().contains("INSTALL_QUEUED"));
assertTrue(persisted.afterJson().contains("1.0.0-preview.44"));
assertTrue(persisted.afterJson().contains("SUCCEEDED"));
}
@Test
void legacyTerminalStatusFindsTheRecentInstallRequestByTargetVersion() {
AuditMapper mapper = mock(AuditMapper.class);
UlidGenerator ulids = mock(UlidGenerator.class);
when(ulids.next()).thenReturn("01M00000000000000000000998");
LocalDateTime completedAt = LocalDateTime.parse("2026-08-18T23:50:00");
when(mapper.findSystemUpdateSourceByVersion(
"1.0.0-preview.43", "SYSTEM_UPDATE_REQUEST", completedAt))
.thenReturn(new AuditMapper.SystemUpdateAuditSource(
"01M00000000000000000000088", "01M00000000000000000000001", "admin", "SYSTEM_ADMIN",
null, null, "{\"state\":\"INSTALL_QUEUED\",\"targetVersion\":\"1.0.0-preview.43\"}",
"127.0.0.1", "fixture-agent"));
when(mapper.insertSystemUpdateTerminal(any(), anyString(), any())).thenReturn(1);
AuditService service = new AuditService(mapper, ulids, new ObjectMapper().findAndRegisterModules(),
new MockHttpServletRequest("GET", "/api/v1/admin/system-update"));
service.recordSystemUpdateTerminal(null, null, "SUCCEEDED", "1.0.0-preview.43",
"Release 1.0.0-preview.43 is running", Instant.parse("2026-08-18T23:50:00Z"));
verify(mapper).findSystemUpdateSourceByVersion(
"1.0.0-preview.43", "SYSTEM_UPDATE_REQUEST", completedAt);
ArgumentCaptor<AuditEntry> entry = ArgumentCaptor.forClass(AuditEntry.class);
verify(mapper).insertSystemUpdateTerminal(entry.capture(), anyString(), any());
assertEquals("01M00000000000000000000088", entry.getValue().requestId());
assertEquals("SYSTEM_UPDATE_SUCCEEDED", entry.getValue().actionCode());
}
}
@@ -6,6 +6,8 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
@@ -286,6 +288,64 @@ class SystemUpdateApplicationServiceTest {
} }
} }
@Test
void persistsCorrelatedTerminalUpdateAuditOnlyOncePerApplicationProcess() throws Exception {
Path inbox = Files.createDirectory(tempDir.resolve("terminal-inbox"));
Path statusFile = tempDir.resolve("terminal-status.json");
Files.writeString(statusFile, """
{"state":"SUCCEEDED","message":"新版本已通过健康检查","targetVersion":"1.0.0-preview.44",
"updatedAt":"2026-08-19T00:10:00Z","requestId":"01M00000000000000000000092",
"action":"INSTALL"}
""");
AuditService auditService = mock(AuditService.class);
when(auditService.recordSystemUpdateTerminal("01M00000000000000000000092", "INSTALL", "SUCCEEDED",
"1.0.0-preview.44", "新版本已通过健康检查", java.time.Instant.parse("2026-08-19T00:10:00Z")))
.thenReturn("SYSUPD:terminal");
SystemUpdateApplicationService service = serviceForStatus(inbox, statusFile, auditService);
assertEquals("SUCCEEDED", service.status().state());
assertEquals("SUCCEEDED", service.status().state());
verify(auditService, times(1)).recordSystemUpdateTerminal("01M00000000000000000000092", "INSTALL",
"SUCCEEDED", "1.0.0-preview.44", "新版本已通过健康检查",
java.time.Instant.parse("2026-08-19T00:10:00Z"));
}
@Test
void persistsLegacyTerminalStatusWithoutRequestCorrelation() throws Exception {
Path inbox = Files.createDirectory(tempDir.resolve("legacy-terminal-inbox"));
Path statusFile = tempDir.resolve("legacy-terminal-status.json");
Files.writeString(statusFile, """
{"state":"SUCCEEDED","message":"Release 1.0.0-preview.43 is running",
"targetVersion":"1.0.0-preview.43","updatedAt":"2026-08-18T23:50:00Z"}
""");
AuditService auditService = mock(AuditService.class);
when(auditService.recordSystemUpdateTerminal(null, null, "SUCCEEDED", "1.0.0-preview.43",
"Release 1.0.0-preview.43 is running", java.time.Instant.parse("2026-08-18T23:50:00Z")))
.thenReturn("SYSUPD:legacy");
SystemUpdateApplicationService service = serviceForStatus(inbox, statusFile, auditService);
assertEquals("SUCCEEDED", service.status().state());
verify(auditService).recordSystemUpdateTerminal(null, null, "SUCCEEDED", "1.0.0-preview.43",
"Release 1.0.0-preview.43 is running", java.time.Instant.parse("2026-08-18T23:50:00Z"));
}
private SystemUpdateApplicationService serviceForStatus(Path inbox, Path statusFile, AuditService auditService) {
SystemUpdateProperties properties = new SystemUpdateProperties(true, "1.0.0-preview.43", null,
"https://release.fixture.invalid/", null, null, inbox.resolve("request.json"), statusFile,
Duration.ofSeconds(2), Duration.ofSeconds(2), true);
AuthorizationService authorization = mock(AuthorizationService.class);
when(authorization.hasPermission(any())).thenReturn(true);
IdentityContext identity = mock(IdentityContext.class);
when(identity.requireActiveRole()).thenReturn("SYSTEM_ADMIN");
when(identity.requirePrincipal()).thenReturn(new FinancePrincipal(1, "01M00000000000000000000001",
"admin", "系统管理员", "信息中心", false,
List.of(new RoleAssignment(1, "SYSTEM_ADMIN", "系统管理员", "系统治理"))));
return new SystemUpdateApplicationService(properties, authorization, identity, auditService,
new ObjectMapper());
}
@Test @Test
void checksConfiguredManifestAndQueuesOnlyItsLatestVersion() throws Exception { void checksConfiguredManifestAndQueuesOnlyItsLatestVersion() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+79 -52
View File
@@ -9,19 +9,53 @@ CONFIG_ROOT=/etc/kaidi
STATE_ROOT=/var/lib/kaidi STATE_ROOT=/var/lib/kaidi
UPDATE_STATE_ROOT=/var/lib/kaidi-update UPDATE_STATE_ROOT=/var/lib/kaidi-update
LOG_ROOT=/var/log/kaidi LOG_ROOT=/var/log/kaidi
UPDATER_ROOT=/opt/kaidi/bin
PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9
RELEASE_API_URL=https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest RELEASE_API_URL=https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest
INIT_ARMED=false INIT_ARMED=false
INIT_COMMITTED=false INIT_COMMITTED=false
INIT_RELEASE_ROOT= INIT_RELEASE_ROOT=
INIT_APP_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' "$*"; } localize_message() {
die() { printf '[kaidi-baota-init] ERROR: %s\n' "$*" >&2; exit 1; } 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() { rollback_initialization() {
local rc=$? local rc=$? index path service_uid
trap - EXIT HUP INT TERM trap - EXIT HUP INT TERM
if [ "$rc" -ne 0 ] && [ "$INIT_ARMED" = true ] && [ "$INIT_COMMITTED" != true ]; then if [ "$rc" -ne 0 ] && [ "$INIT_ARMED" = true ] && [ "$INIT_COMMITTED" != true ]; then
systemctl disable --now kaidi-update.path >/dev/null 2>&1 || true systemctl disable --now kaidi-update.path >/dev/null 2>&1 || true
@@ -29,13 +63,25 @@ rollback_initialization() {
rm -f /etc/systemd/system/kaidi-update.service /etc/systemd/system/kaidi-update.path rm -f /etc/systemd/system/kaidi-update.service /etc/systemd/system/kaidi-update.path
systemctl daemon-reload >/dev/null 2>&1 || true systemctl daemon-reload >/dev/null 2>&1 || true
rm -f "$CONFIG_ROOT/kaidi.env" "$CONFIG_ROOT/update.env" \ rm -f "$CONFIG_ROOT/kaidi.env" "$CONFIG_ROOT/update.env" \
"$CONFIG_ROOT/release-public.pem" "$UPDATER_ROOT/update.sh" \ "$CONFIG_ROOT/release-public.pem" \
"$UPDATE_STATE_ROOT/status.json" /root/kaidi-first-login.txt "$UPDATE_STATE_ROOT/status.json" /root/kaidi-first-login.txt
rm -f "$INIT_APP_ROOT/current.next" rm -f "$INIT_APP_ROOT/current.next"
if [ -n "$INIT_RELEASE_ROOT" ] \ if [ -n "$INIT_RELEASE_ROOT" ] \
&& [ "$(readlink "$INIT_APP_ROOT/current" 2>/dev/null || true)" = "$INIT_RELEASE_ROOT" ]; then && [ "$(readlink "$INIT_APP_ROOT/current" 2>/dev/null || true)" = "$INIT_RELEASE_ROOT" ]; then
rm -f "$INIT_APP_ROOT/current" rm -f "$INIT_APP_ROOT/current"
fi 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" log "Initialization failed; files created by this attempt were rolled back and the command can be retried"
fi fi
exit "$rc" exit "$rc"
@@ -49,6 +95,10 @@ sha256_file() {
sha256sum "$1" | awk '{print $1}' sha256sum "$1" | awk '{print $1}'
} }
record_path() {
[ -e "$1" ] || [ -L "$1" ] || CREATED_PATHS+=("$1")
}
random_secret() { random_secret() {
openssl rand -base64 36 | tr -d '\n/+=' | cut -c1-36 openssl rand -base64 36 | tr -d '\n/+=' | cut -c1-36
} }
@@ -74,6 +124,7 @@ ensure_service_identity() {
local existing_home nologin_path local existing_home nologin_path
if ! getent group "$SERVICE_GROUP" >/dev/null 2>&1; then if ! getent group "$SERVICE_GROUP" >/dev/null 2>&1; then
groupadd --system "$SERVICE_GROUP" groupadd --system "$SERVICE_GROUP"
SERVICE_GROUP_CREATED=true
fi fi
if id "$SERVICE_USER" >/dev/null 2>&1; then if id "$SERVICE_USER" >/dev/null 2>&1; then
existing_home=$(getent passwd "$SERVICE_USER" | awk -F: '{print $6}') existing_home=$(getent passwd "$SERVICE_USER" | awk -F: '{print $6}')
@@ -87,15 +138,14 @@ ensure_service_identity() {
[ -n "$nologin_path" ] || nologin_path=/usr/sbin/nologin [ -n "$nologin_path" ] || nologin_path=/usr/sbin/nologin
[ -x "$nologin_path" ] || die "A nologin shell is required" [ -x "$nologin_path" ] || die "A nologin shell is required"
useradd --system --gid "$SERVICE_GROUP" --home-dir "$STATE_ROOT" --shell "$nologin_path" "$SERVICE_USER" useradd --system --gid "$SERVICE_GROUP" --home-dir "$STATE_ROOT" --shell "$nologin_path" "$SERVICE_USER"
SERVICE_USER_CREATED=true
} }
main() { main() {
local script_dir release_root releases_root app_root version app_port field_key setup_code setup_hash 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" [ "$(id -u)" -eq 0 ] || die "Run with sudo or as root"
[ "$(uname -s)" = Linux ] || die "Baota initialization only supports Linux" [ "$(uname -s)" = Linux ] || die "Baota initialization only supports Linux"
command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ] \ for command in find openssl sha256sum; do
|| die "systemd is required for the privileged background updater"
for command in find openssl setsid sha256sum; do
command -v "$command" >/dev/null 2>&1 || die "$command is required" command -v "$command" >/dev/null 2>&1 || die "$command is required"
done done
@@ -123,7 +173,7 @@ main() {
[ ! -e "$CONFIG_ROOT/kaidi.env" ] && [ ! -e "$CONFIG_ROOT/update.env" ] \ [ ! -e "$CONFIG_ROOT/kaidi.env" ] && [ ! -e "$CONFIG_ROOT/update.env" ] \
&& [ ! -e "$STATE_ROOT/setup/locked" ] \ && [ ! -e "$STATE_ROOT/setup/locked" ] \
|| die "Kaidi is already initialized; run the published purge workflow before a fresh installation" || 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" || 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.service ] \
&& [ ! -e /etc/systemd/system/kaidi-update.path ] \ && [ ! -e /etc/systemd/system/kaidi-update.path ] \
@@ -137,7 +187,11 @@ main() {
INIT_ARMED=true INIT_ARMED=true
ensure_service_identity ensure_service_identity
install -d -o root -g "$SERVICE_GROUP" -m 0750 "$app_root" "$releases_root" "$UPDATER_ROOT" 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 \ install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0750 \
"$STATE_ROOT" "$STATE_ROOT/files" "$STATE_ROOT/tmp" "$LOG_ROOT" "$STATE_ROOT" "$STATE_ROOT/files" "$STATE_ROOT/tmp" "$LOG_ROOT"
install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0700 "$STATE_ROOT/setup" install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0700 "$STATE_ROOT/setup"
@@ -158,14 +212,16 @@ main() {
# The Baota process manager does not own a restartable application unit. # The Baota process manager does not own a restartable application unit.
# Keep its updater files for diagnostics, but do not expose online update # Keep its updater files for diagnostics, but do not expose online update
# actions until the panel lifecycle is integrated with the transaction state machine. # actions until the panel lifecycle is integrated with the transaction state machine.
# The setup context has no datasource. Empty values avoid Baota treating
# a development placeholder as a real local MySQL dependency.
write_env_file "$CONFIG_ROOT/kaidi.env" \ write_env_file "$CONFIG_ROOT/kaidi.env" \
SPRING_PROFILES_ACTIVE production \ SPRING_PROFILES_ACTIVE production \
SERVER_ADDRESS 127.0.0.1 \ SERVER_ADDRESS 127.0.0.1 \
SERVER_PORT "$app_port" \ SERVER_PORT "$app_port" \
SESSION_COOKIE_SECURE false \ SESSION_COOKIE_SECURE false \
DB_URL 'jdbc:mysql://setup.invalid:3306/kaidi_finance' \ DB_URL '' \
DB_USERNAME setup_pending \ DB_USERNAME '' \
DB_PASSWORD setup_pending \ DB_PASSWORD '' \
FIELD_ENCRYPTION_KEY "$field_key" \ FIELD_ENCRYPTION_KEY "$field_key" \
FILE_STORAGE_ROOT "$STATE_ROOT/files" \ FILE_STORAGE_ROOT "$STATE_ROOT/files" \
FILE_STORAGE_TEMP "$STATE_ROOT/tmp" \ FILE_STORAGE_TEMP "$STATE_ROOT/tmp" \
@@ -187,50 +243,21 @@ main() {
chown root:"$SERVICE_GROUP" "$CONFIG_ROOT/kaidi.env" chown root:"$SERVICE_GROUP" "$CONFIG_ROOT/kaidi.env"
chmod 0640 "$CONFIG_ROOT/kaidi.env" chmod 0640 "$CONFIG_ROOT/kaidi.env"
# Baota owns the Java process in this mode. Do not install a second
# systemd updater; online update is intentionally systemd-only until the
# panel lifecycle can participate in the transaction state machine.
install -m 0644 "$release_root/ops/release-public.pem" "$CONFIG_ROOT/release-public.pem" install -m 0644 "$release_root/ops/release-public.pem" "$CONFIG_ROOT/release-public.pem"
install -m 0755 "$release_root/ops/update.sh" "$UPDATER_ROOT/update.sh"
write_env_file "$CONFIG_ROOT/update.env" \
UPDATE_RELEASE_BASE_URL '' \
UPDATE_RELEASE_API_URL "$RELEASE_API_URL" \
UPDATE_RELEASE_TOKEN '' \
UPDATE_REQUEST_FILE "$UPDATE_STATE_ROOT/inbox/request.json" \
UPDATE_STATUS_FILE "$UPDATE_STATE_ROOT/status.json" \
UPDATE_PUBLIC_KEY "$CONFIG_ROOT/release-public.pem" \
KAIDI_APP_ROOT "$app_root" \
KAIDI_UPDATE_STATE_ROOT "$UPDATE_STATE_ROOT" \
KAIDI_PROCESS_MANAGER baota \
KAIDI_PID_FILE "$STATE_ROOT/kaidi.pid" \
KAIDI_RUNTIME_ENV_FILE "$STATE_ROOT/setup/application.env" \
KAIDI_UPDATER_PATH "$UPDATER_ROOT/update.sh" \
KAIDI_SERVICE_USER "$SERVICE_USER" \
KAIDI_SERVICE_GROUP "$SERVICE_GROUP" \
KAIDI_UPDATE_PATH_NAME kaidi-update.path \
KAIDI_HEALTH_URL "http://127.0.0.1:$app_port/actuator/health" \
KAIDI_APP_INDEX_URL "http://127.0.0.1:$app_port/" \
KAIDI_DB_HOST setup.invalid \
KAIDI_DB_PORT 3306 \
KAIDI_DB_NAME kaidi_finance \
KAIDI_DB_USERNAME setup_pending \
KAIDI_DB_PASSWORD setup_pending
chmod 0600 "$CONFIG_ROOT/update.env"
install -m 0644 "$release_root/ops/kaidi-update.service" /etc/systemd/system/kaidi-update.service printf '{"state":"CURRENT","message":"宝塔发布已准备","targetVersion":"%s","updatedAt":"%s"}\n' \
install -m 0644 "$release_root/ops/kaidi-update.path" /etc/systemd/system/kaidi-update.path
systemd-analyze verify /etc/systemd/system/kaidi-update.service /etc/systemd/system/kaidi-update.path >/dev/null \
|| die "The privileged update units failed validation"
systemctl daemon-reload
systemctl enable --now kaidi-update.path
printf '{"state":"CURRENT","message":"Baota release is prepared","targetVersion":"%s","updatedAt":"%s"}\n' \
"$version" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$UPDATE_STATE_ROOT/status.json" "$version" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$UPDATE_STATE_ROOT/status.json"
chmod 0644 "$UPDATE_STATE_ROOT/status.json" chmod 0644 "$UPDATE_STATE_ROOT/status.json"
cat > /root/kaidi-first-login.txt <<EOF cat > /root/kaidi-first-login.txt <<EOF
Project path: $app_root/current 项目路径:$app_root/current
Start command: $app_root/current/ops/baota-start.sh 启动命令:$app_root/current/ops/baota-start.sh
Reverse proxy target: http://127.0.0.1:$app_port 反向代理目标:http://127.0.0.1:$app_port
Setup URL: /setup 安装向导地址:/setup
Setup code: $setup_code 安装码:$setup_code
Version: $version 版本:$version
EOF EOF
chmod 0600 /root/kaidi-first-login.txt chmod 0600 /root/kaidi-first-login.txt
+29 -1
View File
@@ -2,9 +2,37 @@
set -Eeuo pipefail set -Eeuo pipefail
umask 027 umask 027
LOG_LOCALE=${KAIDI_LOG_LOCALE:-zh-CN}
localize_message() {
local message=$1
[ "$LOG_LOCALE" = en ] && { printf '%s' "$message"; return; }
case "$message" in
"Configuration file is not a regular file: "*) printf '配置文件不是普通文件:%s' "${message#Configuration file is not a regular file: }" ;;
"Startup user cannot read configuration file: "*) printf '启动用户无法读取配置文件:%s' "${message#Startup user cannot read configuration file: }" ;;
"Configuration file is too large: "*) printf '配置文件过大:%s' "${message#Configuration file is too large: }" ;;
"Configuration file contains an invalid line: "*) printf '配置文件包含无效行:%s' "${message#Configuration file contains an invalid line: }" ;;
"app.jar is missing from "*) printf '缺少 app.jar:%s' "${message#app.jar is missing from }" ;;
"public/index.html is missing from "*) printf '缺少前端入口 public/index.html:%s' "${message#public/index.html is missing from }" ;;
"VERSION is missing from "*) printf '缺少 VERSION:%s' "${message#VERSION is missing from }" ;;
"Java 17 or newer was not found") printf '未找到 Java 17 或更高版本' ;;
"Java executable is not available at "*) printf 'Java 可执行文件不可用:%s' "${message#Java executable is not available at }" ;;
"Java executable "*" is not available") printf 'Java 可执行文件不可用' ;;
"Java 17 or newer is required") printf '需要 Java 17 或更高版本' ;;
*" is missing from the protected Kaidi configuration") printf '受保护的 Kaidi 配置缺少:%s' "${message% is missing from the protected Kaidi configuration}" ;;
"Storage directory "*" does not exist") printf '存储目录不存在:%s' "${message#Storage directory }" ;;
"Startup user cannot write storage directory "*) printf '启动用户无法写入存储目录:%s' "${message#Startup user cannot write storage directory }" ;;
"PID directory does not exist") printf 'PID 目录不存在' ;;
"Startup user cannot write the PID directory") printf '启动用户无法写入 PID 目录' ;;
"KAIDI_PID_FILE must be an absolute path") printf 'KAIDI_PID_FILE 必须是绝对路径' ;;
"PID file must not be a symbolic link") printf 'PID 文件不能是符号链接' ;;
"Process start time could not be read"*) printf '无法读取进程启动时间' ;;
*) printf '%s' "$message" ;;
esac
}
die() { die() {
printf '[kaidi-baota] ERROR: %s\n' "$1" >&2 printf '[kaidi-baota] 错误:%s\n' "$(localize_message "$1")" >&2
exit 1 exit 1
} }
+3
View File
@@ -1,5 +1,8 @@
services: services:
mysql: mysql:
# Local-development fixture only. Production installers never invoke
# Compose and never create this service.
profiles: [local-db]
image: mysql:8.4 image: mysql:8.4
container_name: kaidi-finance-mysql container_name: kaidi-finance-mysql
restart: unless-stopped restart: unless-stopped
+1 -1
View File
@@ -15,7 +15,7 @@ FILE_SCANNER_ENABLED=true
FINANCE_BOOTSTRAP_ENABLED=false FINANCE_BOOTSTRAP_ENABLED=false
FINANCE_BOOTSTRAP_PASSWORD= FINANCE_BOOTSTRAP_PASSWORD=
APP_VERSION=1.0.0-preview.34 APP_VERSION=1.0.0-preview.44
UPDATE_CURRENT_VERSION_FILE=/opt/kaidi/current/VERSION UPDATE_CURRENT_VERSION_FILE=/opt/kaidi/current/VERSION
FINANCE_UPDATE_ENABLED=true FINANCE_UPDATE_ENABLED=true
# Use either a stable direct asset base URL or the public Gitea latest-release API. # Use either a stable direct asset base URL or the public Gitea latest-release API.
+24 -6
View File
@@ -5,13 +5,31 @@ umask 077
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
INSTALLER="$ROOT/deploy/install.sh" INSTALLER="$ROOT/deploy/install.sh"
INSTALLER_SHA256=${KAIDI_INSTALLER_SHA256:-6a454aff209e62d7ac75b7f97670d700ec05be710854a02136f41f55e82f9fa9} 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} 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} PUBLIC_KEY_SHA256=${KAIDI_RELEASE_PUBLIC_KEY_SHA256:-807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9}
TOKEN_FILE=${KAIDI_RELEASE_TOKEN_FILE:-} TOKEN_FILE=${KAIDI_RELEASE_TOKEN_FILE:-}
log() { printf '[kaidi-git-install] %s\n' "$*"; } 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" [ -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" 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_HOST=${KAIDI_DB_HOST:-}
KAIDI_DB_PORT=${KAIDI_DB_PORT:-} KAIDI_DB_PORT=${KAIDI_DB_PORT:-}
KAIDI_DB_NAME=${KAIDI_DB_NAME:-} KAIDI_DB_NAME=${KAIDI_DB_NAME:-}
KAIDI_DB_CONTAINER=${KAIDI_DB_CONTAINER:-}
KAIDI_SESSION_COOKIE_SECURE=${KAIDI_SESSION_COOKIE_SECURE:-} KAIDI_SESSION_COOKIE_SECURE=${KAIDI_SESSION_COOKIE_SECURE:-}
KAIDI_FILE_SCANNER_ENABLED=${KAIDI_FILE_SCANNER_ENABLED:-} 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 \ 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_URL KAIDI_DB_USERNAME KAIDI_DB_PASSWORD \
KAIDI_DB_HOST KAIDI_DB_PORT KAIDI_DB_NAME KAIDI_DB_CONTAINER \ KAIDI_DB_HOST KAIDI_DB_PORT KAIDI_DB_NAME \
KAIDI_SESSION_COOKIE_SECURE KAIDI_FILE_SCANNER_ENABLED; do KAIDI_SESSION_COOKIE_SECURE KAIDI_FILE_SCANNER_ENABLED KAIDI_DB_BACKUP_MODE; do
value=${!name} value=${!name}
if [ -n "$value" ]; then if [ -n "$value" ]; then
install_env+=("$name=$value") install_env+=("$name=$value")
fi fi
done done
log "Installing the latest signed release from $RELEASE_API_URL" log "正在从 Gitea Release API 安装最新签名版本:$RELEASE_API_URL"
"${root_command[@]}" "${install_env[@]}" bash "$INSTALLER" "${root_command[@]}" "${install_env[@]}" bash "$INSTALLER"
+211 -86
View File
@@ -38,9 +38,125 @@ JAVA_ACTIVATED=false
SERVICE_USER=kaidi SERVICE_USER=kaidi
SERVICE_GROUP=kaidi SERVICE_GROUP=kaidi
HOST_ARCH= 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' "$*"; } localize_message() {
die() { printf '[kaidi-install] ERROR: %s\n' "$*" >&2; exit 1; } 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() { sha256_file() {
sha256sum "$1" | awk '{print $1}' sha256sum "$1" | awk '{print $1}'
@@ -208,15 +324,23 @@ download_release_asset() {
install_packages() { install_packages() {
if command -v apt-get >/dev/null 2>&1; then if command -v apt-get >/dev/null 2>&1; then
export DEBIAN_FRONTEND=noninteractive export DEBIAN_FRONTEND=noninteractive
apt-get update -qq apt-get update -qq >/dev/null 2>&1 \
apt-get install -y -qq ca-certificates coreutils curl findutils gzip jq openssl tar util-linux || 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 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 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 else
die "Supported package managers are apt, dnf, and yum" die "Supported package managers are apt, dnf, and yum"
fi fi
log "系统依赖检查完成(未安装 MySQL、MariaDB、Docker)"
} }
preflight_runtime_commands() { preflight_runtime_commands() {
@@ -230,31 +354,6 @@ preflight_runtime_commands() {
done 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() { azul_arch() {
case "${HOST_ARCH:-$(normalized_host_arch)}" in case "${HOST_ARCH:-$(normalized_host_arch)}" in
x86_64) printf x86 ;; x86_64) printf x86 ;;
@@ -327,15 +426,15 @@ system_java_home() {
} }
prepare_java() { 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_BIN=
JAVA_STAGED_DIR= JAVA_STAGED_DIR=
if system_home=$(system_java_home); then if system_home=$(system_java_home); then
JAVA_BIN="$system_home/bin/java" JAVA_BIN="$system_home/bin/java"
java_line=$("$JAVA_BIN" -version 2>&1 | head -n 1) \ java_line=$("$JAVA_BIN" -version 2>&1 | head -n 1) \
|| die "The existing Java runtime cannot start on this host" || die "The existing Java runtime cannot start on this host"
log "Using existing Java 17+ runtime at $system_home" java_version=$(printf '%s\n' "$java_line" | sed -n 's/.*version "\([0-9][0-9.]*\).*/\1/p')
log "Local Java verified: $java_line" log "使用现有 Java 运行时:$system_home(版本 ${java_version:-未知})"
return 0 return 0
fi fi
log "No local Java 17 runtime found; downloading the official Azul Java 17 runtime" 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) \ 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" || die "The downloaded Java runtime cannot start; verify glibc and the host architecture"
JAVA_BIN="$APP_ROOT/runtime/java/bin/java" 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() { activate_java() {
@@ -383,6 +483,7 @@ ensure_service_identity() {
command -v getent >/dev/null 2>&1 || die "getent is required" command -v getent >/dev/null 2>&1 || die "getent is required"
if ! getent group "$SERVICE_GROUP" >/dev/null 2>&1; then if ! getent group "$SERVICE_GROUP" >/dev/null 2>&1; then
groupadd --system "$SERVICE_GROUP" groupadd --system "$SERVICE_GROUP"
SERVICE_GROUP_CREATED=true
fi fi
if id "$SERVICE_USER" >/dev/null 2>&1; then if id "$SERVICE_USER" >/dev/null 2>&1; then
existing_home=$(getent passwd "$SERVICE_USER" | awk -F: '{print $6}') 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 [ -n "$nologin_path" ] || nologin_path=/usr/sbin/nologin
[ -x "$nologin_path" ] || die "A nologin shell is required" [ -x "$nologin_path" ] || die "A nologin shell is required"
useradd --system --gid "$SERVICE_GROUP" --home-dir "$STATE_ROOT" --shell "$nologin_path" "$SERVICE_USER" 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() { prepare_managed_layout() {
command -v runuser >/dev/null 2>&1 || die "runuser is required" 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 \ install -d -o root -g "$SERVICE_GROUP" -m 0750 \
"$APP_ROOT" "$APP_ROOT/releases" "$APP_ROOT/runtime" "$APP_ROOT/bin" "$APP_ROOT" "$APP_ROOT/releases" "$APP_ROOT/runtime" "$APP_ROOT/bin"
install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0750 \ 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=$(read_reinstall_env SERVER_PORT || true)
[[ "$default_port" =~ ^[0-9]{1,5}$ ]] || default_port=18080 [[ "$default_port" =~ ^[0-9]{1,5}$ ]] || default_port=18080
if [ -t 1 ] && [ -r /dev/tty ]; then 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 if IFS= read -r entered < /dev/tty; then
APP_PORT=${entered:-$default_port} APP_PORT=${entered:-$default_port}
else else
@@ -586,9 +704,11 @@ configure_database() {
if [ "$SETUP_WIZARD" = true ]; then if [ "$SETUP_WIZARD" = true ]; then
[ -z "${KAIDI_DB_URL:-}${KAIDI_DB_USERNAME:-}${KAIDI_DB_PASSWORD:-}" ] \ [ -z "${KAIDI_DB_URL:-}${KAIDI_DB_USERNAME:-}${KAIDI_DB_PASSWORD:-}" ] \
|| die "Do not pass database credentials when KAIDI_SETUP_WIZARD=true; enter them in the browser wizard" || die "Do not pass database credentials when KAIDI_SETUP_WIZARD=true; enter them in the browser wizard"
DB_URL='jdbc:mysql://setup.invalid:3306/kaidi_finance' # First-run mode does not create a datasource. Keep database fields empty
DB_USERNAME=setup_pending # so hosting panels cannot mistake a placeholder for a local MySQL service.
DB_PASSWORD=setup_pending DB_URL=
DB_USERNAME=
DB_PASSWORD=
return 0 return 0
fi fi
if [ "$REINSTALL" = true ]; then if [ "$REINSTALL" = true ]; then
@@ -613,6 +733,7 @@ configure_database() {
database_host() { database_host() {
local endpoint host_port local endpoint host_port
[ -n "$DB_URL" ] || { printf ''; return; }
endpoint=${DB_URL#jdbc:mysql://} endpoint=${DB_URL#jdbc:mysql://}
host_port=${endpoint%%/*} host_port=${endpoint%%/*}
printf '%s' "${KAIDI_DB_HOST:-${host_port%%:*}}" printf '%s' "${KAIDI_DB_HOST:-${host_port%%:*}}"
@@ -620,6 +741,7 @@ database_host() {
database_port() { database_port() {
local endpoint host_port candidate local endpoint host_port candidate
[ -n "$DB_URL" ] || { printf ''; return; }
endpoint=${DB_URL#jdbc:mysql://} endpoint=${DB_URL#jdbc:mysql://}
host_port=${endpoint%%/*} host_port=${endpoint%%/*}
candidate=${host_port##*:} candidate=${host_port##*:}
@@ -629,54 +751,43 @@ database_port() {
database_name() { database_name() {
local endpoint name local endpoint name
[ -n "$DB_URL" ] || { printf ''; return; }
endpoint=${DB_URL#jdbc:mysql://} endpoint=${DB_URL#jdbc:mysql://}
name=${endpoint#*/} name=${endpoint#*/}
name=${name%%\?*} name=${name%%\?*}
printf '%s' "${KAIDI_DB_NAME:-$name}" printf '%s' "${KAIDI_DB_NAME:-$name}"
} }
preflight_database() { validate_database_configuration() {
local client host port name version table
[ "$SETUP_WIZARD" != true ] || return 0 [ "$SETUP_WIZARD" != true ] || return 0
case "$DB_URL" in case "$DB_URL" in
jdbc:mysql://*) ;; jdbc:mysql://*) ;;
*) die "KAIDI_DB_URL must start with jdbc:mysql://" ;; *) die "KAIDI_DB_URL must start with jdbc:mysql://" ;;
esac esac
client=$(mysql_client_bin || true) [ -n "$DB_USERNAME" ] || die "KAIDI_DB_USERNAME is empty"
[ -n "$client" ] || die "A MySQL command-line client is required; set KAIDI_MYSQL_CLIENT or add mysql/mariadb to PATH" [ -n "$DB_PASSWORD" ] || die "KAIDI_DB_PASSWORD is empty"
host=$(database_host) [ -n "$(database_host)" ] || die "The database host is empty"
port=$(database_port) [[ "$(database_port)" =~ ^[0-9]{1,5}$ ]] \
name=$(database_name) && [ "$(database_port)" -ge 1 ] && [ "$(database_port)" -le 65535 ] \
[ -n "$host" ] || die "The database host is empty"
[[ "$port" =~ ^[0-9]{1,5}$ ]] && [ "$port" -ge 1 ] && [ "$port" -le 65535 ] \
|| die "The database port is invalid" || 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 \ # Kept as a compatibility name for older local fixtures and wrappers. It is
-h "$host" -P "$port" -u "$DB_USERNAME" "$name" --batch --skip-column-names \ # now a pure configuration check and has no database side effects.
-e 'SELECT VERSION()' 2>/dev/null) || die "Cannot connect to the configured MySQL database" preflight_database() {
case "$version" in validate_database_configuration
8.4.*) ;; }
*) die "MySQL 8.4.x is required; server reported $version" ;;
validate_database_backup_mode() {
case "${KAIDI_DB_BACKUP_MODE:-skip}" in
skip|mysqldump) ;;
*) die "KAIDI_DB_BACKUP_MODE must be skip or mysqldump" ;;
esac 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 <<SQL
DROP TABLE IF EXISTS $table;
CREATE TABLE $table (id INT NOT NULL PRIMARY KEY);
INSERT INTO $table (id) VALUES (1);
UPDATE $table SET id = 2 WHERE id = 1;
DELETE FROM $table WHERE id = 2;
DROP TABLE $table;
SQL
then
MYSQL_PWD="$DB_PASSWORD" "$client" --protocol=TCP --connect-timeout=10 \
-h "$host" -P "$port" -u "$DB_USERNAME" "$name" \
-e "DROP TABLE IF EXISTS $table" >/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() { write_env_file() {
@@ -835,7 +946,7 @@ restore_managed_path() {
} }
rollback_install() { rollback_install() {
local index=0 path rollback_failed=false local index=0 path rollback_failed=false layout_path service_uid
set +e set +e
log "Installation failed; restoring the previous managed state" log "Installation failed; restoring the previous managed state"
systemctl stop kaidi-update.path kaidi-finance.service >/dev/null 2>&1 || true systemctl stop kaidi-update.path kaidi-finance.service >/dev/null 2>&1 || true
@@ -853,6 +964,19 @@ rollback_install() {
elif [ "$JAVA_ACTIVATED" = true ]; then elif [ "$JAVA_ACTIVATED" = true ]; then
rm -rf -- "$APP_ROOT/runtime/java" || rollback_failed=true rm -rf -- "$APP_ROOT/runtime/java" || rollback_failed=true
fi 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 systemctl daemon-reload >/dev/null 2>&1 || rollback_failed=true
restore_unit_state kaidi-finance.service "$PREVIOUS_APP_ENABLED" "$PREVIOUS_APP_ACTIVE" \ restore_unit_state kaidi-finance.service "$PREVIOUS_APP_ENABLED" "$PREVIOUS_APP_ACTIVE" \
|| rollback_failed=true || rollback_failed=true
@@ -877,6 +1001,7 @@ main() {
trap cleanup EXIT trap cleanup EXIT
validate_inputs validate_inputs
validate_database_backup_mode
configure_app_port configure_app_port
preflight_host preflight_host
install_packages install_packages
@@ -1020,13 +1145,13 @@ write_env_file "$CONFIG_ROOT/update.env" \
KAIDI_JAVA_BIN "$JAVA_BIN" \ KAIDI_JAVA_BIN "$JAVA_BIN" \
KAIDI_HEALTH_URL "$HEALTH_URL" \ KAIDI_HEALTH_URL "$HEALTH_URL" \
KAIDI_APP_INDEX_URL "$APP_INDEX_URL" \ KAIDI_APP_INDEX_URL "$APP_INDEX_URL" \
KAIDI_DB_CONTAINER "${KAIDI_DB_CONTAINER:-}" \
KAIDI_DB_HOST "$(database_host)" \ KAIDI_DB_HOST "$(database_host)" \
KAIDI_DB_PORT "$(database_port)" \ KAIDI_DB_PORT "$(database_port)" \
KAIDI_DB_NAME "$(database_name)" \ KAIDI_DB_NAME "$(database_name)" \
KAIDI_DB_USERNAME "$DB_USERNAME" \ KAIDI_DB_USERNAME "$DB_USERNAME" \
KAIDI_DB_PASSWORD "$DB_PASSWORD" \ 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" chmod 0600 "$CONFIG_ROOT/update.env"
install -m 0644 "$RELEASE_DIR/ops/kaidi-finance.service" /etc/systemd/system/kaidi-finance.service install -m 0644 "$RELEASE_DIR/ops/kaidi-finance.service" /etc/systemd/system/kaidi-finance.service
@@ -1052,26 +1177,26 @@ if [ "$SETUP_WIZARD" != true ]; then
sed -i 's/^FINANCE_BOOTSTRAP_PASSWORD=.*$/FINANCE_BOOTSTRAP_PASSWORD=""/' "$CONFIG_ROOT/kaidi.env" sed -i 's/^FINANCE_BOOTSTRAP_PASSWORD=.*$/FINANCE_BOOTSTRAP_PASSWORD=""/' "$CONFIG_ROOT/kaidi.env"
fi fi
jq -n --arg version "$VERSION" --arg updatedAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ 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" > "$UPDATE_STATE_ROOT/status.json"
chmod 0644 "$UPDATE_STATE_ROOT/status.json" chmod 0644 "$UPDATE_STATE_ROOT/status.json"
systemctl enable --now kaidi-update.path systemctl enable --now kaidi-update.path
if [ "$SETUP_WIZARD" = true ]; then if [ "$SETUP_WIZARD" = true ]; then
cat > /root/kaidi-first-login.txt <<EOF cat > /root/kaidi-first-login.txt <<EOF
URL after reverse proxy: http://SERVER_IP/setup 反向代理访问地址:http://SERVER_IP/setup
Reverse proxy target: $PROXY_TARGET 反向代理目标:$PROXY_TARGET
Setup code: $SETUP_CODE 安装码:$SETUP_CODE
Version: $VERSION 版本:$VERSION
EOF EOF
chmod 0600 /root/kaidi-first-login.txt chmod 0600 /root/kaidi-first-login.txt
elif [ "$REINSTALL" != true ]; then elif [ "$REINSTALL" != true ]; then
cat > /root/kaidi-first-login.txt <<EOF cat > /root/kaidi-first-login.txt <<EOF
URL after reverse proxy: http://SERVER_IP/ 反向代理访问地址:http://SERVER_IP/
Reverse proxy target: $PROXY_TARGET 反向代理目标:$PROXY_TARGET
Username: admin 管理员账号:admin
Temporary password: $ADMIN_PASSWORD 临时密码:$ADMIN_PASSWORD
Version: $VERSION 版本:$VERSION
EOF EOF
chmod 0600 /root/kaidi-first-login.txt chmod 0600 /root/kaidi-first-login.txt
fi fi
+57 -3
View File
@@ -16,9 +16,40 @@ REQUIRED_CONFIRMATION=DELETE_LOCAL_KAIDI_INSTALLATION
SERVICE_USER=kaidi SERVICE_USER=kaidi
SERVICE_GROUP=kaidi SERVICE_GROUP=kaidi
BACKUP_ARCHIVE= BACKUP_ARCHIVE=
LOG_LOCALE=${KAIDI_LOG_LOCALE:-zh-CN}
DELETE_RECOVERY_BACKUP=${KAIDI_PURGE_DELETE_BACKUP:-false}
log() { printf '[kaidi-purge] %s\n' "$*"; } localize_message() {
die() { printf '[kaidi-purge] ERROR: %s\n' "$*" >&2; exit 1; } 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() { systemd_available() {
command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ] command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]
@@ -40,6 +71,10 @@ validate_inputs() {
;; ;;
esac esac
[ ! -L "$BACKUP_ROOT" ] || die "The recovery backup root must not be a symbolic link" [ ! -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() { stop_systemd_units() {
@@ -206,7 +241,20 @@ remove_managed_paths() {
} }
remove_download_archives() { 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() { remove_service_identity() {
@@ -258,9 +306,15 @@ main() {
remove_download_archives remove_download_archives
remove_service_identity remove_service_identity
stop_managed_processes false stop_managed_processes false
verify_managed_paths_removed
log "Local Kaidi installation state has been removed" log "Local Kaidi installation state has been removed"
if [ -n "$BACKUP_ARCHIVE" ]; then if [ -n "$BACKUP_ARCHIVE" ]; then
log "Recovery backup: $BACKUP_ARCHIVE" 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 fi
log "Use a new empty MySQL database for the next installation" log "Use a new empty MySQL database for the next installation"
} }
+205 -40
View File
@@ -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/} APP_INDEX_URL=${KAIDI_APP_INDEX_URL:-http://127.0.0.1:18080/}
LOCK_FILE=$STATE_ROOT/update.lock LOCK_FILE=$STATE_ROOT/update.lock
BACKUP_ROOT=${KAIDI_BACKUP_ROOT:-$STATE_ROOT/backups} 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} UPDATER_PATH=${KAIDI_UPDATER_PATH:-$APP_ROOT/bin/update.sh}
SYSTEMD_ROOT=${KAIDI_SYSTEMD_ROOT:-/etc/systemd/system} SYSTEMD_ROOT=${KAIDI_SYSTEMD_ROOT:-/etc/systemd/system}
LOG_ROOT=${KAIDI_LOG_ROOT:-/var/log/kaidi} LOG_ROOT=${KAIDI_LOG_ROOT:-/var/log/kaidi}
@@ -40,38 +41,174 @@ WORK_DIR=
CACHE_TEMP= CACHE_TEMP=
RELEASE_AUTH_HEADER_FILE= RELEASE_AUTH_HEADER_FILE=
TARGET_VERSION= TARGET_VERSION=
REQUEST_ID=
REQUEST_ACTION=
TERMINAL_STATUS_WRITTEN=false TERMINAL_STATUS_WRITTEN=false
DOWNLOAD_PID= DOWNLOAD_PID=
DOWNLOAD_PGID= DOWNLOAD_PGID=
LAST_DOWNLOAD_SPEED=0 LAST_DOWNLOAD_SPEED=0
SURFACE_FAILURE= 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 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 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 case "$PROCESS_MANAGER" in
systemd|baota) ;; 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 esac
case "$SHUTDOWN_ATTEMPTS" in 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 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 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 esac
case "$RUNTIME_ENV_FILE" in 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 esac
bootstrap_die() { bootstrap_die() {
if [ -f "$PROCESSING_FILE" ] && [ ! -L "$PROCESSING_FILE" ]; then if [ -f "$PROCESSING_FILE" ] && [ ! -L "$PROCESSING_FILE" ]; then
fail "$1" fail "$1"
fi fi
printf '%s\n' "$1" >&2 printf '%s\n' "$(localize_message "$1")" >&2
exit 1 exit 1
} }
@@ -149,7 +286,7 @@ validate_runtime_database_config() {
status() { status() {
state=$1 state=$1
message=$2 message=$(localize_message "$2")
version=${3:-} version=${3:-}
downloaded_bytes=${4:-} downloaded_bytes=${4:-}
total_bytes=${5:-} total_bytes=${5:-}
@@ -158,10 +295,26 @@ status() {
restart_expected_seconds=${8:-} restart_expected_seconds=${8:-}
previous_state= previous_state=
previous_message= previous_message=
previous_request_id=
previous_action=
if [ -f "$STATUS_FILE" ] && [ ! -L "$STATUS_FILE" ]; then if [ -f "$STATUS_FILE" ] && [ ! -L "$STATUS_FILE" ]; then
previous_state=$(jq -r '.state // empty' "$STATUS_FILE" 2>/dev/null || true) previous_state=$(jq -r '.state // empty' "$STATUS_FILE" 2>/dev/null || true)
previous_message=$(jq -r '.message // empty' "$STATUS_FILE" 2>/dev/null || true) previous_message=$(jq -r '.message // empty' "$STATUS_FILE" 2>/dev/null || true)
previous_request_id=$(jq -r '.requestId // empty | strings | select(length <= 64)' \
"$STATUS_FILE" 2>/dev/null || true)
previous_action=$(jq -r '.action // empty | strings | ascii_upcase \
| select(. == "DOWNLOAD" or . == "INSTALL")' "$STATUS_FILE" 2>/dev/null || true)
fi fi
status_request_id=
status_action=
if [ -f "$PROCESSING_FILE" ] && [ ! -L "$PROCESSING_FILE" ]; then
status_request_id=$(jq -r '.requestId // empty | strings | select(length > 0 and length <= 64)' \
"$PROCESSING_FILE" 2>/dev/null || true)
status_action=$(jq -r '.action // empty | strings | ascii_upcase \
| select(. == "DOWNLOAD" or . == "INSTALL")' "$PROCESSING_FILE" 2>/dev/null || true)
fi
[ -n "$status_request_id" ] || status_request_id=${REQUEST_ID:-$previous_request_id}
[ -n "$status_action" ] || status_action=${REQUEST_ACTION:-$previous_action}
tmp="$STATUS_FILE.tmp.$$" tmp="$STATUS_FILE.tmp.$$"
jq -n \ jq -n \
--arg state "$state" \ --arg state "$state" \
@@ -172,9 +325,13 @@ status() {
--arg bytesPerSecond "$bytes_per_second" \ --arg bytesPerSecond "$bytes_per_second" \
--arg downloadPercent "$download_percent" \ --arg downloadPercent "$download_percent" \
--arg restartExpectedSeconds "$restart_expected_seconds" \ --arg restartExpectedSeconds "$restart_expected_seconds" \
--arg requestId "$status_request_id" \
--arg action "$status_action" \
--arg updatedAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ --arg updatedAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{state:$state,message:$message,updatedAt:$updatedAt} '{state:$state,message:$message,updatedAt:$updatedAt}
+ (if ($version | length) > 0 then {targetVersion:$version} else {} end) + (if ($version | length) > 0 then {targetVersion:$version} else {} end)
+ (if ($requestId | length) > 0 then {requestId:$requestId} else {} end)
+ (if ($action == "DOWNLOAD" or $action == "INSTALL") then {action:$action} else {} end)
+ (if ($downloadedBytes | test("^[0-9]+$")) then {downloadedBytes:($downloadedBytes | tonumber)} else {} end) + (if ($downloadedBytes | test("^[0-9]+$")) then {downloadedBytes:($downloadedBytes | tonumber)} else {} end)
+ (if ($totalBytes | test("^[0-9]+$")) then {totalBytes:($totalBytes | tonumber)} else {} end) + (if ($totalBytes | test("^[0-9]+$")) then {totalBytes:($totalBytes | tonumber)} else {} end)
+ (if ($bytesPerSecond | test("^[0-9]+$")) then {bytesPerSecond:($bytesPerSecond | tonumber)} else {} end) + (if ($bytesPerSecond | test("^[0-9]+$")) then {bytesPerSecond:($bytesPerSecond | tonumber)} else {} end)
@@ -227,9 +384,10 @@ complete_request() {
fail() { fail() {
TERMINAL_STATUS_WRITTEN=true TERMINAL_STATUS_WRITTEN=true
status FAILED "$1" "${TARGET_VERSION:-}" message=$(localize_message "$1")
status FAILED "$message" "${TARGET_VERSION:-}"
archive_processing_request || true archive_processing_request || true
printf '%s\n' "$1" >&2 printf '%s\n' "$message" >&2
exit 1 exit 1
} }
@@ -601,7 +759,10 @@ mysqldump_bin() {
backup_database() { backup_database() {
DATABASE_BACKUP= 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" mkdir -p "$BACKUP_ROOT"
chmod 0700 "$BACKUP_ROOT" chmod 0700 "$BACKUP_ROOT"
dump_file=$(mktemp "$BACKUP_ROOT/.mysql-dump.XXXXXX.sql") dump_file=$(mktemp "$BACKUP_ROOT/.mysql-dump.XXXXXX.sql")
@@ -609,26 +770,16 @@ backup_database() {
backup_tmp="$backup.tmp.$$" backup_tmp="$backup.tmp.$$"
chmod 0600 "$dump_file" chmod 0600 "$dump_file"
if [ -n "${KAIDI_DB_CONTAINER:-}" ] && command -v docker >/dev/null 2>&1 \ dump_bin=$(mysqldump_bin || true)
&& docker inspect "$KAIDI_DB_CONTAINER" >/dev/null 2>&1; then [ -n "$dump_bin" ] || {
if ! docker exec -e MYSQL_PWD="${KAIDI_DB_PASSWORD:-}" "$KAIDI_DB_CONTAINER" \ rm -f "$dump_file" "$backup_tmp"
mysqldump --single-transaction --routines --triggers \ fail "mysqldump is required before installing an update; set KAIDI_MYSQLDUMP_BIN or install it in a standard MySQL bin directory"
-u "${KAIDI_DB_USERNAME:-kaidi}" "${KAIDI_DB_NAME:-kaidi_finance}" > "$dump_file"; then }
rm -f "$dump_file" "$backup_tmp" if ! MYSQL_PWD=${KAIDI_DB_PASSWORD:-} "$dump_bin" --single-transaction --routines --triggers \
fail "Database backup failed" -h "${KAIDI_DB_HOST:-127.0.0.1}" -P "${KAIDI_DB_PORT:-3306}" \
fi -u "${KAIDI_DB_USERNAME:-kaidi}" "${KAIDI_DB_NAME:-kaidi_finance}" > "$dump_file"; then
else rm -f "$dump_file" "$backup_tmp"
dump_bin=$(mysqldump_bin || true) fail "Database backup failed"
[ -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
fi fi
[ -s "$dump_file" ] || { [ -s "$dump_file" ] || {
@@ -643,8 +794,12 @@ backup_database() {
mv -f "$backup_tmp" "$backup" mv -f "$backup_tmp" "$backup"
DATABASE_BACKUP=$backup DATABASE_BACKUP=$backup
rm -f "$dump_file" rm -f "$dump_file"
find "$BACKUP_ROOT" -type f -name 'mysql-*.sql.gz' -printf '%T@ %p\n' \ # Keep the newest five backups without relying on GNU find's -printf; the
| sort -nr | awk 'NR > 5 {sub(/^[^ ]+ /, ""); print}' | xargs -r rm -f # 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() { atomic_install() {
@@ -796,26 +951,30 @@ rollback_active_transaction() {
&& verify_app_surface "$previous_target"; then && verify_app_surface "$previous_target"; then
remove_failed_release "$failed_release" remove_failed_release "$failed_release"
if [ "$operations_restored" = true ]; then if [ "$operations_restored" = true ]; then
database_backup_record=$(transaction_value database-backup)
rm -rf "$ACTIVE_TRANSACTION" 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 fi
fail "$reason; previous application release is running, but operations restoration requires manual review; transaction evidence was retained" fail "$reason; previous application release is running, but operations restoration requires manual review; transaction evidence was retained"
fi fi
TERMINAL_STATUS_WRITTEN=true TERMINAL_STATUS_WRITTEN=true
recovery_guard_failed=false recovery_guard_failed=false
if ! archive_processing_request; then 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 recovery_guard_failed=true
fi fi
if ! quarantine_active_transaction; then 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 recovery_guard_failed=true
fi fi
if [ "$recovery_guard_failed" = true ]; then if [ "$recovery_guard_failed" = true ]; then
systemctl stop "$UPDATE_PATH_NAME" >/dev/null 2>&1 || true systemctl stop "$UPDATE_PATH_NAME" >/dev/null 2>&1 || true
fi fi
status RECOVERY_REQUIRED "$reason; rollback is incomplete and manual recovery is required" "${TARGET_VERSION:-}" 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 exit 1
} }
@@ -885,7 +1044,7 @@ if [ -f "$STATUS_FILE" ] && [ ! -L "$STATUS_FILE" ] \
if ! archive_processing_request; then if ! archive_processing_request; then
systemctl stop "$UPDATE_PATH_NAME" >/dev/null 2>&1 || true systemctl stop "$UPDATE_PATH_NAME" >/dev/null 2>&1 || true
fi 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 exit 1
fi fi
@@ -898,6 +1057,8 @@ TARGET_VERSION=$REQUESTED_VERSION
REQUEST_ACTION=$(jq -er '(.action // "INSTALL") | strings | ascii_upcase REQUEST_ACTION=$(jq -er '(.action // "INSTALL") | strings | ascii_upcase
| select(. == "DOWNLOAD" or . == "INSTALL")' "$PROCESSING_FILE") \ | select(. == "DOWNLOAD" or . == "INSTALL")' "$PROCESSING_FILE") \
|| fail "Update request action is invalid" || fail "Update request action is invalid"
REQUEST_ID=$(jq -r '.requestId // empty | strings | select(length > 0 and length <= 64)' \
"$PROCESSING_FILE" 2>/dev/null || true)
prepare_update_layout prepare_update_layout
reset_event_log reset_event_log
load_runtime_database_env load_runtime_database_env
@@ -1053,7 +1214,11 @@ write_transaction_value previous-target "$PREVIOUS_TARGET"
write_transaction_value release-dir "$RELEASE_DIR" write_transaction_value release-dir "$RELEASE_DIR"
write_transaction_value phase PREPARED 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 backup_database
[ -z "${DATABASE_BACKUP:-}" ] || write_transaction_value database-backup "$DATABASE_BACKUP" [ -z "${DATABASE_BACKUP:-}" ] || write_transaction_value database-backup "$DATABASE_BACKUP"
if ! backup_operations; then if ! backup_operations; then
+9 -9
View File
@@ -4,7 +4,7 @@
> 版本:V3.26(Preview.9 公共 Release 一键安装)<br> > 版本:V3.26(Preview.9 公共 Release 一键安装)<br>
> 状态:第一版 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 上线等同于甲方最终验收<br> > 状态:第一版 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 上线等同于甲方最终验收<br>
> 编制依据:2026-08-05 腾讯会议转写、14 张 OA 表单、财务岗位职责资料、项目管理流程图<br> > 编制依据:2026-08-05 腾讯会议转写、14 张 OA 表单、财务岗位职责资料、项目管理流程图<br>
> 最新项目决策:按既定 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 确认后才允许作为正式规则发布<br> > 最新项目决策:按既定 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 确认后才允许作为正式规则发布<br>
> 实际开发技术:Java 17 + Spring Boot 3.5.16 + MyBatis 3.0.5 + MySQL 8.4 + TDesign Vue Next Starter;前端仍只允许在现有 `frontend/` Starter 中原位增加菜单、页面、组件和真实功能 > 实际开发技术:Java 17 + Spring Boot 3.5.16 + MyBatis 3.0.5 + MySQL 8.4 + TDesign Vue Next Starter;前端仍只允许在现有 `frontend/` Starter 中原位增加菜单、页面、组件和真实功能
> 文档性质:本项目唯一 PRD、SPEC、开发任务书、测试验收与交付基线<br> > 文档性质:本项目唯一 PRD、SPEC、开发任务书、测试验收与交付基线<br>
> 文档用途:甲方需求确认、产品设计、开发拆分、测试验收和交付培训<br> > 文档用途:甲方需求确认、产品设计、开发拆分、测试验收和交付培训<br>
@@ -68,7 +68,7 @@
| --- | --- | | --- | --- |
| 页面层级 | PAGE-03~21 以路由合同面包屑作为静态页面名称的唯一可见来源;工作台、列表、台账、报表和配置页删除内容区重复的模块名、页面名及功能说明,只保留读屏标题和紧凑操作栏。项目详情、表单详情继续显示不与面包屑重复的业务编号、对象名称和状态 | | 页面层级 | PAGE-03~21 以路由合同面包屑作为静态页面名称的唯一可见来源;工作台、列表、台账、报表和配置页删除内容区重复的模块名、页面名及功能说明,只保留读屏标题和紧凑操作栏。项目详情、表单详情继续显示不与面包屑重复的业务编号、对象名称和状态 |
| Preview.6 | 在 Preview.5 基础上,窄屏表格固定列统一降级为横向滚动列,顶部账号文本增加省略保护;通过 TypeScript、ESLint、Stylelint、Vitest `10 files / 56 tests`、production build/hygiene、完整 Playwright `198/198`(含 390px 固定列几何回归);真实 Edge 复测权限与配置、系统更新、财务工作台和记账准备页面无整体横向溢出或控件重叠 | | 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、状态机、资金、附件安全和审计继续强制执行 | | 超级管理员权限 | V071 将 `SYSTEM_ADMIN` 固定为隔离超级管理员,逐条授予全部 `iam_permission` 和无额度 `GLOBAL` scope,并由启动同步补齐后续权限;前端菜单、直链和按钮统一放行全部 PAGE-04~21。管理员可管理跨人员表单/导入/付款草稿,PAGE-15 增加仅管理员可见的“全部付款”,三类业务工作台显示跨人员项目、资金、档案和全部待办;审批实际处理人、SOD、状态机、资金、附件安全和审计继续强制执行 |
| OA-06 付款投影 | V070 发布 OA-06 v2,新增生效合同、供应商和已确认应付稳定引用;财务终审在来源审批事务内生成 `OFFLINE_PENDING` 付款,执行付款检查、冻结项目资金并追加资金流水。余额不足等投影失败时来源状态、审批任务、付款、资金控制和流水整体回滚;不产生银行付款指令,不接网银、U 盾或第三方平台 | | OA-06 付款投影 | V070 发布 OA-06 v2,新增生效合同、供应商和已确认应付稳定引用;财务终审在来源审批事务内生成 `OFFLINE_PENDING` 付款,执行付款检查、冻结项目资金并追加资金流水。余额不足等投影失败时来源状态、审批任务、付款、资金控制和流水整体回滚;不产生银行付款指令,不接网银、U 盾或第三方平台 |
| OA 动态附件矩阵 | OA-04/OA-06 的条件附件行支持 TDesign 布尔开关、长文本、不涉及原因和嵌套文件上传;仅 `required=true` 的行要求文件或原因,可选空行不阻断。上传状态按字段和表格单元隔离,上传期间锁定刷新、保存、校验和提交;更新请求不再携带创建专用字段,校验前自动保存当前草稿 | | OA 动态附件矩阵 | OA-04/OA-06 的条件附件行支持 TDesign 布尔开关、长文本、不涉及原因和嵌套文件上传;仅 `required=true` 的行要求文件或原因,可选空行不阻断。上传状态按字段和表格单元隔离,上传期间锁定刷新、保存、校验和提交;更新请求不再携带创建专用字段,校验前自动保存当前草稿 |
@@ -76,10 +76,10 @@
| 第一版 Preview | PAGE-01~22 页面和菜单框架齐备,真实前后端核心链保持可用;Preview 上线与整套 R1 最终验收分开计量 | | 第一版 Preview | PAGE-01~22 页面和菜单框架齐备,真实前后端核心链保持可用;Preview 上线与整套 R1 最终验收分开计量 |
| 来源表格校验 | V068 为 14 类 OA 的 33 个 TABLE 字段补齐嵌套类型、必填和文件引用规则;V069 保留 OA-02 v1 历史账户表格契约,避免 v2 字段追溯污染 | | 来源表格校验 | V068 为 14 类 OA 的 33 个 TABLE 字段补齐嵌套类型、必填和文件引用规则;V069 保留 OA-02 v1 历史账户表格契约,避免 v2 字段追溯污染 |
| PAGE-20 审计 | 增加不可变事件序号、四种稳定排序、URL 查询状态、同排序 CSV 导出、脱敏详情及 OpenAPI `422` 参数门禁 | | 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 | | 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 预检 | | 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 限制失败重试;`mysqldump`、新旧版本健康和回滚均有独立门禁,跨来源拒绝、成功、健康回滚、下载失败、备份失败和不安全请求夹具纳入 CI | | 更新可靠性 | 下载和安装拆为两个持久动作;下载阶段不停止业务服务,安装阶段只接受同版本 `READY` 缓存并重新验签。公共 Gitea 默认不使用 Token;如改接私有镜像,Token 仅从权限为 `0600` 的 root 配置/临时文件读取,不进入 `curl` 参数、页面、状态或日志,且只允许同源 API/资产使用;请求原子领取到持久 `processing`,systemd 限制失败重试;数据库备份默认为 `skip`,只有显式配置 `KAIDI_DB_BACKUP_MODE=mysqldump` 才调用已有工具;新旧版本健康和回滚均有独立门禁,跨来源拒绝、成功、健康回滚、下载失败、备份失败和不安全请求夹具纳入 CI |
| 阶段口径 | 把“开发前冻结”更新为“R1 开发中”;明确模块级验收与整套 R1 交付是两个层级,避免一页完成后虚报整套系统完成 | | 阶段口径 | 把“开发前冻结”更新为“R1 开发中”;明确模块级验收与整套 R1 交付是两个层级,避免一页完成后虚报整套系统完成 |
| PAGE-13 页面 | 完成合同执行汇总、筛选、服务端分页、来源下钻、CSV 导出、合同详情及变更/结算/应付操作;页面仅使用现有 TDesign Starter 和 TDesign 组件 | | PAGE-13 页面 | 完成合同执行汇总、筛选、服务端分页、来源下钻、CSV 导出、合同详情及变更/结算/应付操作;页面仅使用现有 TDesign Starter 和 TDesign 组件 |
| PAGE-13 后端 | 新增 `contractcost:payable:create`,项目经理可登记但不可确认;同人登记/确认返回 `422 SOD_VIOLATION`;应付必须关联已批准 OA-04,结算必须关联已批准 OA-11,同一来源不得重复消费 | | PAGE-13 后端 | 新增 `contractcost:payable:create`,项目经理可登记但不可确认;同人登记/确认返回 `422 SOD_VIOLATION`;应付必须关联已批准 OA-04,结算必须关联已批准 OA-11,同一来源不得重复消费 |
@@ -2200,9 +2200,9 @@ staging/production 使用 Linux 容器或等价受控服务:反向代理仅对
运行日志在线保留 180 天;审计、导出和敏感访问日志首期按 10 年保留且不自动删除,最终保管期限由 D-09 确认。归档或到期处理必须由授权管理员显式执行、先导出校验并记录审批和哈希;处于项目档案、争议或审计冻结的记录不得处理。 运行日志在线保留 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 全栈工程硬规范 ### 11.15 全栈工程硬规范
@@ -2891,7 +2891,7 @@ PAGE-16 全类回归曾暴露测试证据文件路径硬编码与应用 `finance
| 合同与供应链 | OpenAPI、Actionlint、ShellCheck 通过;npm 完整依赖树 `0 vulnerabilities` | CI 仍在 tag 发布时从干净环境重新执行全部门禁 | | 合同与供应链 | OpenAPI、Actionlint、ShellCheck 通过;npm 完整依赖树 `0 vulnerabilities` | CI 仍在 tag 发布时从干净环境重新执行全部门禁 |
| 更新可靠性 | 成功切换、健康失败回滚、Release 下载失败、`mysqldump` 失败和不安全请求拒绝五类夹具通过 | 覆盖持久 processing/transaction、普通文件门禁、有效失败状态、0600 备份、应用、updater、systemd、Nginx、新旧健康验证和失败 Release 清理;真实 Linux systemd 主机仍须冒烟 | | 更新可靠性 | 成功切换、健康失败回滚、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 仍待目标仓库 | | 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 和服务器地址等部署输入;这些输入未配置前,不把本地候选包描述成已经公网部署。 **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` 为未登录公共页 | | 首次访问 | 已实现 | `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` 单例状态支持提交后重试,避免重复创建超级管理员 | | 管理员初始化 | 已实现 | 操作者填写账号、显示名称和密码;创建 `SYSTEM_ADMIN`、逐项全局权限范围,并用 `kaidi_setup_installation` 单例状态支持提交后重试,避免重复创建超级管理员 |
| 锁定与恢复 | 已实现 | 一次性安装码只保存 SHA-256;完成后原子写运行时环境和锁定标记,systemd 重启进入正式模式;向导未完成时可用 `REINSTALL=true + KAIDI_SETUP_WIZARD=true` 恢复并重新生成安装码,已锁定/正式模式拒绝该路径 | | 锁定与恢复 | 已实现 | 一次性安装码只保存 SHA-256;完成后原子写运行时环境和锁定标记,systemd 重启进入正式模式;向导未完成时可用 `REINSTALL=true + KAIDI_SETUP_WIZARD=true` 恢复并重新生成安装码,已锁定/正式模式拒绝该路径 |
| 32 位 curl | 已实现 | i386/i486/i586/i686 只下载 i686 Java 17 JRE,不传数据库密码;管理员预建 MySQL 8.4 数据库后在 `/setup` 输入连接信息 | | 32 位 curl | 已实现 | i386/i486/i586/i686 只下载 i686 Java 17 JRE,不传数据库密码;管理员预建 MySQL 8.4 数据库后在 `/setup` 输入连接信息 |
+11 -7
View File
@@ -32,8 +32,8 @@ test('first-run wizard tests MySQL, creates the administrator and stays responsi
successful: true, successful: true,
databaseType: 'MYSQL', databaseType: 'MYSQL',
serverVersion: '8.4.6', serverVersion: '8.4.6',
schemaReady: true, schemaReady: false,
message: 'MySQL 8.4 连接、排序规则和完整迁移权限验证通过', 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-db-password');
await page.getByLabel('安装码').fill('fixture-setup-code'); await page.getByLabel('安装码').fill('fixture-setup-code');
await page.getByRole('button', { name: '测试连接' }).click(); 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.getByRole('button', { name: '下一步' }).click();
await page.getByLabel('管理员密码').fill('SetupAdmin@2026Strong'); 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, successful: true,
databaseType: 'MYSQL', databaseType: 'MYSQL',
serverVersion: '8.4.6', serverVersion: '8.4.6',
schemaReady: true, schemaReady: false,
message: 'MySQL 8.4 连接、排序规则和完整迁移权限验证通过', message: 'MySQL 8.4 只读连接验证通过;尚未修改数据库,点击完成安装后才会执行迁移',
}, },
}, },
}); });
@@ -169,8 +173,8 @@ test('leaves the completed wizard when post-completion status polling is unavail
successful: true, successful: true,
databaseType: 'MYSQL', databaseType: 'MYSQL',
serverVersion: '8.4.6', serverVersion: '8.4.6',
schemaReady: true, schemaReady: false,
message: 'MySQL 8.4 连接、排序规则和完整迁移权限验证通过', message: 'MySQL 8.4 只读连接验证通过;尚未修改数据库,点击完成安装后才会执行迁移',
}, },
}, },
}); });
+29 -4
View File
@@ -1,7 +1,13 @@
import type { Page, Route } from '@playwright/test'; import type { Page, Route } from '@playwright/test';
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
const permissions = ['admin:user:view', 'admin:user:create', 'admin:update:view', 'admin:update:execute']; const permissions = [
'admin:user:view',
'admin:user:create',
'admin:update:view',
'admin:update:execute',
'audit:log:view',
];
function envelope(data: unknown) { function envelope(data: unknown) {
return { data, requestId: '01M00000000000000000000090' }; return { data, requestId: '01M00000000000000000000090' };
@@ -111,8 +117,13 @@ async function installFixture(
let state = recoveryPending ? 'RECOVERY_REQUIRED' : initialState || 'IDLE'; let state = recoveryPending ? 'RECOVERY_REQUIRED' : initialState || 'IDLE';
let progressDeadline = 0; let progressDeadline = 0;
let restartDeadline = 0; let restartDeadline = 0;
let terminalHistoryRecorded = false;
const history: Array<Record<string, unknown>> = []; const history: Array<Record<string, unknown>> = [];
const recordHistory = (actionCode: string, targetVersion = '1.0.0-preview.2') => { const recordHistory = (
actionCode: string,
targetVersion = '1.0.0-preview.2',
reason = actionCode === 'SYSTEM_UPDATE_REQUEST' ? '管理员确认立即更新并重启' : null,
) => {
history.unshift({ history.unshift({
publicId: `01M00000000000000000000${String(history.length + 1).padStart(3, '0')}`, publicId: `01M00000000000000000000${String(history.length + 1).padStart(3, '0')}`,
eventSequence: history.length + 1, eventSequence: history.length + 1,
@@ -123,9 +134,12 @@ async function installFixture(
objectType: 'SYSTEM_UPDATE', objectType: 'SYSTEM_UPDATE',
objectPublicId: 'SYSTEM_UPDATE', objectPublicId: 'SYSTEM_UPDATE',
resultCode: 'SUCCESS', resultCode: 'SUCCESS',
reason: actionCode === 'SYSTEM_UPDATE_REQUEST' ? '管理员确认立即更新并重启' : null, reason,
beforeJson: null, beforeJson: null,
afterJson: JSON.stringify({ targetVersion }), afterJson: JSON.stringify({
targetVersion,
...(actionCode === 'SYSTEM_UPDATE_SUCCEEDED' ? { state: 'SUCCEEDED' } : {}),
}),
occurredAt: '2026-08-16T00:02:00Z', occurredAt: '2026-08-16T00:02:00Z',
allowedActions: [], allowedActions: [],
}); });
@@ -156,6 +170,10 @@ async function installFixture(
return route.abort('connectionrefused'); return route.abort('connectionrefused');
} }
if (state === 'RUNNING' && restartDeadline > 0) state = 'SUCCEEDED'; if (state === 'RUNNING' && restartDeadline > 0) state = 'SUCCEEDED';
if (state === 'SUCCEEDED' && !terminalHistoryRecorded) {
recordHistory('SYSTEM_UPDATE_SUCCEEDED', '1.0.0-preview.2', '新版本已通过健康检查');
terminalHistoryRecorded = true;
}
const response = updateView(state, checked, enabled, recoveryPending); const response = updateView(state, checked, enabled, recoveryPending);
if (state === 'DOWNLOADING' && Date.now() >= progressDeadline) state = 'READY'; if (state === 'DOWNLOADING' && Date.now() >= progressDeadline) state = 'READY';
if (completeBusyAfterFirstRead && state === 'INSTALLING') state = 'SUCCEEDED'; if (completeBusyAfterFirstRead && state === 'INSTALLING') state = 'SUCCEEDED';
@@ -192,6 +210,9 @@ async function installFixture(
return route.fulfill({ json: envelope(queued) }); return route.fulfill({ json: envelope(queued) });
} }
if (pathname === '/api/v1/audit/logs' && request.method() === 'GET') { if (pathname === '/api/v1/audit/logs' && request.method() === 'GET') {
const query = new URL(request.url()).searchParams;
expect(query.get('occurredFrom')).toBe('1970-01-01T00:00:00Z');
expect(query.get('objectType')).toBe('SYSTEM_UPDATE');
return route.fulfill({ return route.fulfill({
json: { json: {
data: history, data: history,
@@ -393,6 +414,10 @@ test('successful installation starts the ten-second automatic refresh countdown'
await expect(page.getByText('新版本已通过健康检查,页面将在 10 秒后自动刷新。', { exact: true })).toBeVisible({ await expect(page.getByText('新版本已通过健康检查,页面将在 10 秒后自动刷新。', { exact: true })).toBeVisible({
timeout: 5_000, timeout: 5_000,
}); });
const historyPanel = page.locator('.history-panel');
const completedRow = historyPanel.locator('tr').filter({ hasText: '更新完成' });
await expect(completedRow).toContainText('1.0.0-preview.2');
await expect(completedRow).toContainText('新版本已通过健康检查');
}); });
test('reloading during installation resumes polling and starts the refresh countdown', async ({ page }) => { test('reloading during installation resumes polling and starts the refresh countdown', async ({ page }) => {
@@ -2,14 +2,18 @@
<div class="system-update-page"> <div class="system-update-page">
<h1 class="page-title-sr-only">系统更新</h1> <h1 class="page-title-sr-only">系统更新</h1>
<header class="page-header page-header--actions-only" aria-label="页面操作">
<t-button variant="outline" :loading="statusLoading || historyLoading" @click="refreshPage">
<template #icon><t-icon name="refresh" /></template>
刷新状态
</t-button>
</header>
<section class="update-panel" aria-label="系统版本与更新操作"> <section class="update-panel" aria-label="系统版本与更新操作">
<header class="update-toolbar" aria-label="系统更新工具栏">
<div class="update-toolbar__copy">
<h2>系统更新</h2>
<p>获取版本、下载校验并安全重启应用</p>
</div>
<t-button variant="outline" :loading="statusLoading || historyLoading" @click="refreshPage">
<template #icon><t-icon name="refresh" /></template>
刷新状态
</t-button>
</header>
<div class="update-heading"> <div class="update-heading">
<div class="version-grid"> <div class="version-grid">
<div class="version-item"> <div class="version-item">
@@ -107,7 +111,7 @@
<div class="section-heading"> <div class="section-heading">
<div> <div>
<h2>历史更新记录</h2> <h2>历史更新记录</h2>
<p>记录版本获取、下载和重启安装操作。</p> <p>记录版本获取、下载、重启安装和最终执行结果。</p>
</div> </div>
<span>最近 {{ historyRows.length }} 条</span> <span>最近 {{ historyRows.length }} 条</span>
</div> </div>
@@ -442,6 +446,7 @@ async function loadHistory(silent = false) {
} }
try { try {
const result = await getAuditLogs({ const result = await getAuditLogs({
occurredFrom: '1970-01-01T00:00:00Z',
objectType: 'SYSTEM_UPDATE', objectType: 'SYSTEM_UPDATE',
sort: 'occurredAt,desc', sort: 'occurredAt,desc',
page: 1, page: 1,
@@ -720,6 +725,9 @@ function actionLabel(action: string) {
SYSTEM_UPDATE_CHECK: '获取版本', SYSTEM_UPDATE_CHECK: '获取版本',
SYSTEM_UPDATE_DOWNLOAD_REQUEST: '下载更新包', SYSTEM_UPDATE_DOWNLOAD_REQUEST: '下载更新包',
SYSTEM_UPDATE_REQUEST: '立即更新并重启', SYSTEM_UPDATE_REQUEST: '立即更新并重启',
SYSTEM_UPDATE_SUCCEEDED: '更新完成',
SYSTEM_UPDATE_FAILED: '更新失败',
SYSTEM_UPDATE_RECOVERY_REQUIRED: '更新需人工恢复',
}[action] || action }[action] || action
); );
} }
@@ -782,7 +790,7 @@ onBeforeUnmount(() => {
min-width: 0; min-width: 0;
} }
.page-header, .update-toolbar,
.update-heading, .update-heading,
.update-actions, .update-actions,
.section-heading, .section-heading,
@@ -791,10 +799,6 @@ onBeforeUnmount(() => {
align-items: center; align-items: center;
} }
.page-header {
justify-content: flex-end;
}
.update-panel, .update-panel,
.history-panel { .history-panel {
width: 100%; width: 100%;
@@ -818,6 +822,31 @@ onBeforeUnmount(() => {
min-width: 0; min-width: 0;
} }
.update-toolbar {
justify-content: space-between;
gap: 20px;
min-width: 0;
padding-bottom: 2px;
}
.update-toolbar__copy {
min-width: 0;
}
.update-toolbar__copy h2 {
margin: 0;
color: var(--td-text-color-primary);
font-size: 18px;
line-height: 26px;
}
.update-toolbar__copy p {
margin: 4px 0 0;
color: var(--td-text-color-secondary);
font-size: 13px;
line-height: 20px;
}
.version-grid { .version-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(150px, 1fr)); grid-template-columns: repeat(3, minmax(150px, 1fr));
@@ -1022,6 +1051,16 @@ onBeforeUnmount(() => {
gap: 12px; gap: 12px;
} }
.update-toolbar {
align-items: flex-start;
flex-direction: column;
gap: 10px;
}
.update-toolbar :deep(.t-button) {
width: 100%;
}
.update-actions, .update-actions,
.update-actions :deep(.t-button) { .update-actions :deep(.t-button) {
width: 100%; width: 100%;
+13 -4
View File
@@ -103,6 +103,10 @@
<t-alert v-if="connectionResult" class="connection-result" theme="success" :close-btn="false"> <t-alert v-if="connectionResult" class="connection-result" theme="success" :close-btn="false">
{{ connectionResult.message }}({{ connectionResult.serverVersion }}) {{ connectionResult.message }}({{ connectionResult.serverVersion }})
</t-alert> </t-alert>
<p class="setup-hint">
测试连接只读取 MySQL
版本,不创建或修改数据库对象;点击“完成安装”后,系统才会在指定的专用空库执行迁移并创建管理员。
</p>
<div class="setup-actions"> <div class="setup-actions">
<t-button theme="primary" variant="outline" type="submit" :loading="testingConnection"> <t-button theme="primary" variant="outline" type="submit" :loading="testingConnection">
@@ -290,9 +294,7 @@ const adminRules = computed<Record<string, FormRule[]>>(() => ({
const databaseFingerprint = computed(() => JSON.stringify(databaseForm)); const databaseFingerprint = computed(() => JSON.stringify(databaseForm));
const connectionVerified = computed( const connectionVerified = computed(
() => () => Boolean(connectionResult.value?.successful) && verifiedFingerprint.value === databaseFingerprint.value,
Boolean(connectionResult.value?.successful && connectionResult.value.schemaReady) &&
verifiedFingerprint.value === databaseFingerprint.value,
); );
watch(databaseFingerprint, () => { watch(databaseFingerprint, () => {
@@ -308,7 +310,7 @@ async function testConnection(context?: SubmitContext) {
const result = await testSetupConnection({ ...databaseForm }); const result = await testSetupConnection({ ...databaseForm });
connectionResult.value = result; connectionResult.value = result;
verifiedFingerprint.value = databaseFingerprint.value; verifiedFingerprint.value = databaseFingerprint.value;
MessagePlugin.success('数据库连接验证通过'); MessagePlugin.success(result.message || '数据库只读连接验证通过');
} catch (error) { } catch (error) {
connectionResult.value = null; connectionResult.value = null;
errorMessage.value = (error as Error).message || '数据库连接验证失败'; errorMessage.value = (error as Error).message || '数据库连接验证失败';
@@ -469,6 +471,13 @@ h1 {
margin-bottom: 20px; margin-bottom: 20px;
} }
.setup-hint {
margin: -4px 0 20px;
color: var(--td-text-color-secondary);
font-size: 13px;
line-height: 20px;
}
.setup-content { .setup-content {
min-width: 0; min-width: 0;
} }
+26 -1
View File
@@ -19,6 +19,31 @@ import type { AxiosRequestConfigRetry, RequestOptions, Result } from '@/types/ax
import { AxiosCanceler } from './AxiosCancel'; import { AxiosCanceler } from './AxiosCancel';
import type { CreateAxiosOptions } from './AxiosTransform'; import type { CreateAxiosOptions } from './AxiosTransform';
function localizeApiError(code: string | undefined, detail: string | undefined, status?: number): string {
const messages: Record<string, string> = {
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 模块 * Axios 模块
*/ */
@@ -318,7 +343,7 @@ export class VAxios {
const problem = e.response?.data as const problem = e.response?.data as
{ code?: string; detail?: string; requestId?: string; fieldErrors?: Record<string, string> } | undefined; { code?: string; detail?: string; requestId?: string; fieldErrors?: Record<string, string> } | undefined;
const requestId = problem?.requestId || e.response?.headers?.['x-request-id']; 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); const error = new Error(requestId ? `${message}(请求编号:${requestId})` : message);
Object.assign(error, { Object.assign(error, {
code: problem?.code, code: problem?.code,
+1 -1
View File
@@ -146,7 +146,7 @@ if KAIDI_JAVA_BIN="$WORK/java" \
"$RELEASE/ops/baota-start.sh" > "$WORK/missing-db.out" 2>&1; then "$RELEASE/ops/baota-start.sh" > "$WORK/missing-db.out" 2>&1; then
fail 'launcher accepted a production configuration without DB_URL' fail 'launcher accepted a production configuration without DB_URL'
fi 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' || fail 'launcher did not report the missing database URL'
[ ! -e "$WORK/missing-db.log" ] || fail 'launcher started Java after configuration validation failed' [ ! -e "$WORK/missing-db.log" ] || fail 'launcher started Java after configuration validation failed'
+5 -1
View File
@@ -46,6 +46,10 @@ chmod 0755 "$FIXTURE/bin/sudo" "$FIXTURE/repo/deploy/install.sh" "$FIXTURE/repo/
printf '%s' 'fixture-read-token' > "$FIXTURE/token" printf '%s' 'fixture-read-token' > "$FIXTURE/token"
chmod 0600 "$FIXTURE/token" chmod 0600 "$FIXTURE/token"
installer_sha256=$(sha256sum "$FIXTURE/repo/deploy/install.sh" | awk '{print $1}') installer_sha256=$(sha256sum "$FIXTURE/repo/deploy/install.sh" | awk '{print $1}')
default_installer_sha256=$(sed -n 's/^INSTALLER_SHA256=.*:-\([0-9A-Fa-f]*\)}$/\1/p' \
"$ROOT/deploy/install-from-git.sh")
[ "$default_installer_sha256" = "$(sha256sum "$ROOT/deploy/install.sh" | awk '{print $1}')" ] \
|| { printf 'Git installer default checksum is stale\n' >&2; exit 1; }
PATH="$FIXTURE/bin:$PATH" \ PATH="$FIXTURE/bin:$PATH" \
EXPECT_PORT=true \ EXPECT_PORT=true \
@@ -81,6 +85,6 @@ if PATH="$FIXTURE/bin:$PATH" \
printf 'Mismatched installer SHA-256 was accepted\n' >&2 printf 'Mismatched installer SHA-256 was accepted\n' >&2
exit 1 exit 1
fi 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' printf 'Git checkout installation wrapper fixture passed\n'
+48 -7
View File
@@ -24,6 +24,11 @@ mode_of() {
sed -n '/^port_is_listening()/,/^}/p' "$ROOT/deploy/install.sh" sed -n '/^port_is_listening()/,/^}/p' "$ROOT/deploy/install.sh"
sed -n '/^valid_app_port()/,/^}/p' "$ROOT/deploy/install.sh" sed -n '/^valid_app_port()/,/^}/p' "$ROOT/deploy/install.sh"
sed -n '/^configure_app_port()/,/^}/p' "$ROOT/deploy/install.sh" sed -n '/^configure_app_port()/,/^}/p' "$ROOT/deploy/install.sh"
sed -n '/^configure_database()/,/^}/p' "$ROOT/deploy/install.sh"
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 '/^is_semver()/,/^}/p' "$ROOT/deploy/install.sh"
sed -n '/^write_env_file_preserving_unknown()/,/^}/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" sed -n '/^normalized_host_arch()/,/^}/p' "$ROOT/deploy/install.sh"
@@ -37,16 +42,26 @@ mode_of() {
sed -n '/^release_asset_url()/,/^}/p' "$ROOT/deploy/install.sh" sed -n '/^release_asset_url()/,/^}/p' "$ROOT/deploy/install.sh"
sed -n '/^download_release_asset()/,/^}/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 '/^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 '/^preflight_database()/,/^}/p' "$ROOT/deploy/install.sh"
sed -n '/^secure_release_tree()/,/^}/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" sed -n '/^restore_unit_state()/,/^}/p' "$ROOT/deploy/install.sh"
} > "$WORK/helpers.sh" } > "$WORK/helpers.sh"
# shellcheck disable=SC1090,SC1091 # shellcheck disable=SC1090,SC1091
source "$WORK/helpers.sh" 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 export SETUP_WIZARD=true
preflight_database || fail 'setup wizard database preflight returned a failure status' preflight_database || fail 'setup wizard database preflight returned a failure status'
REINSTALL=false
# shellcheck disable=SC2034 # Consumed by the extracted configure_database helper.
KAIDI_DB_URL='' KAIDI_DB_USERNAME='' KAIDI_DB_PASSWORD=''
configure_database
[ -z "$DB_URL$DB_USERNAME$DB_PASSWORD" ] \
|| fail 'setup wizard retained a fake database configuration'
[ -z "$(database_host)$(database_port)$(database_name)" ] \
|| fail 'setup wizard exposed placeholder database coordinates to the updater'
mkdir -p "$WORK/mysql-bin" mkdir -p "$WORK/mysql-bin"
cat > "$WORK/mysql-bin/mysql" <<'SH' cat > "$WORK/mysql-bin/mysql" <<'SH'
@@ -54,10 +69,21 @@ cat > "$WORK/mysql-bin/mysql" <<'SH'
exit 0 exit 0
SH SH
chmod 0755 "$WORK/mysql-bin/mysql" chmod 0755 "$WORK/mysql-bin/mysql"
export KAIDI_MYSQL_CLIENT="$WORK/mysql-bin/mysql" # Production-mode validation is intentionally side-effect free: it validates
[ "$(mysql_client_bin)" = "$WORK/mysql-bin/mysql" ] \ # the JDBC shape but never invokes a MySQL client or emits DDL/DML.
|| fail 'installer did not honor the explicit MySQL client path' SETUP_WIZARD=false
unset KAIDI_MYSQL_CLIENT 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" <<SH
#!/bin/sh
printf 'called\n' > "$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" release_permissions="$WORK/release-permissions"
mkdir -p "$release_permissions/public" "$release_permissions/ops" mkdir -p "$release_permissions/public" "$release_permissions/ops"
@@ -299,12 +325,24 @@ grep -Fq '[ "$APP_ROOT" = /opt/kaidi ]' "$ROOT/deploy/install.sh" \
|| fail 'installer no longer rejects unsupported custom roots' || fail 'installer no longer rejects unsupported custom roots'
grep -Fq '8.4.*) ;;' "$ROOT/deploy/install.sh" \ grep -Fq '8.4.*) ;;' "$ROOT/deploy/install.sh" \
|| fail 'installer no longer enforces MySQL 8.4.x' || fail 'installer no longer enforces MySQL 8.4.x'
! grep -Fq 'jdbc:mysql://setup.invalid' "$ROOT/deploy/install.sh" \
|| fail 'installer still writes a fake setup database URL'
! grep -Fq 'jdbc:mysql://setup.invalid' "$ROOT/deploy/baota-init.sh" \
|| fail 'Baota initializer still writes a fake setup database URL'
! grep -Fq 'KAIDI_DB_HOST setup.invalid' "$ROOT/deploy/baota-init.sh" \
|| fail 'Baota updater still points at a fake setup database host'
! grep -Fq 'systemctl enable --now kaidi-update.path' "$ROOT/deploy/baota-init.sh" \
|| fail 'Baota initializer still enables the systemd updater'
# shellcheck disable=SC2016 # Match literal initializer source.
! grep -Fq 'install -m 0644 "$release_root/ops/kaidi-update.service"' "$ROOT/deploy/baota-init.sh" \
|| fail 'Baota initializer still installs the systemd updater unit'
grep -Fq '32-bit Linux deployment requires glibc' "$ROOT/deploy/install.sh" \ grep -Fq '32-bit Linux deployment requires glibc' "$ROOT/deploy/install.sh" \
|| fail 'installer does not reject unsupported musl 32-bit hosts before downloading Java' || fail 'installer does not reject unsupported musl 32-bit hosts before downloading Java'
grep -Fq '32-bit Linux deployment requires the glibc loader' "$ROOT/deploy/install.sh" \ grep -Fq '32-bit Linux deployment requires the glibc loader' "$ROOT/deploy/install.sh" \
|| fail 'installer does not reject a 32-bit host without the glibc loader' || fail 'installer does not reject a 32-bit host without the glibc loader'
grep -Fq 'preflight_runtime_commands' "$ROOT/deploy/install.sh" \ grep -Fq 'preflight_runtime_commands' "$ROOT/deploy/install.sh" \
|| fail 'installer does not validate required runtime commands' || fail 'installer does not validate required runtime commands'
# shellcheck disable=SC2016 # Match literal installer source.
grep -Fq 'KAIDI_MYSQLDUMP_BIN "${KAIDI_MYSQLDUMP_BIN:-}"' "$ROOT/deploy/install.sh" \ grep -Fq 'KAIDI_MYSQLDUMP_BIN "${KAIDI_MYSQLDUMP_BIN:-}"' "$ROOT/deploy/install.sh" \
|| fail 'installer does not preserve a custom mysqldump path for online updates' || fail 'installer does not preserve a custom mysqldump path for online updates'
# shellcheck disable=SC2016 # Match literal installer source. # shellcheck disable=SC2016 # Match literal installer source.
@@ -423,8 +461,11 @@ grep -Fq 'load_runtime_database_env' "$ROOT/deploy/update.sh" \
# shellcheck disable=SC2016 # Match literal installer source. # shellcheck disable=SC2016 # Match literal installer source.
grep -Fq 'install -d -o root -g "$SERVICE_GROUP" -m 0750 "$UPDATE_STATE_ROOT"' "$ROOT/deploy/install.sh" \ grep -Fq 'install -d -o root -g "$SERVICE_GROUP" -m 0750 "$UPDATE_STATE_ROOT"' "$ROOT/deploy/install.sh" \
|| fail 'update state parent is not group-accessible to the application user' || fail 'update state parent is not group-accessible to the application user'
grep -Fq 'kaidi-finance-1.0.0-preview.34.tar.gz' "$ROOT/README.md" \ manual_version=$(sed -n 's/^VERSION=\(1\.0\.0-preview\.[0-9][0-9]*\)$/\1/p' "$ROOT/README.md" | sed -n '1p')
|| fail 'README does not document the public manual-deployment artifact' [ -n "$manual_version" ] \
|| fail 'README does not declare a preview version for the public manual-deployment artifact'
grep -Fq "kaidi-finance-$manual_version.tar.gz" "$ROOT/README.md" \
|| fail 'README does not document the public manual-deployment artifact for its declared version'
grep -Fq 'ops/baota-init.sh' "$ROOT/README.md" \ grep -Fq 'ops/baota-init.sh' "$ROOT/README.md" \
|| fail 'README does not document the local Baota initialization command' || fail 'README does not document the local Baota initialization command'
grep -Fq 'FINANCE_UPDATE_ENABLED false' "$ROOT/deploy/baota-init.sh" \ grep -Fq 'FINANCE_UPDATE_ENABLED false' "$ROOT/deploy/baota-init.sh" \
+43 -10
View File
@@ -46,7 +46,7 @@ if KAIDI_APP_ROOT="$WORK/bootstrap/app" \
sh "$ROOT/deploy/update.sh" > "$WORK/bootstrap.log" 2>&1; then sh "$ROOT/deploy/update.sh" > "$WORK/bootstrap.log" 2>&1; then
fail 'updater accepted a missing service identity during bootstrap' fail 'updater accepted a missing service identity during bootstrap'
fi 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' || fail 'updater bootstrap failure did not preserve its diagnostic'
[ "$(jq -r '.state' "$WORK/bootstrap/state/status.json")" = FAILED ] \ [ "$(jq -r '.state' "$WORK/bootstrap/state/status.json")" = FAILED ] \
|| fail 'updater bootstrap failure did not persist FAILED' || fail 'updater bootstrap failure did not persist FAILED'
@@ -297,9 +297,12 @@ write_request() {
local fixture=$1 local fixture=$1
local version=$2 local version=$2
local action=$3 local action=$3
local request_id=01M00000000000000000000091
[ "$action" = INSTALL ] && request_id=01M00000000000000000000092
mkdir -p "$fixture/state/inbox" mkdir -p "$fixture/state/inbox"
jq -n --arg action "$action" --arg version "$version" \ jq -n --arg action "$action" --arg version "$version" --arg requestId "$request_id" \
'{action:$action,version:$version,reason:"fixture"}' > "$fixture/state/inbox/request.json" '{action:$action,version:$version,reason:"fixture",requestId:$requestId,
requestedAt:"2026-08-19T00:00:00Z"}' > "$fixture/state/inbox/request.json"
} }
download_and_prepare_install() { download_and_prepare_install() {
@@ -309,6 +312,9 @@ download_and_prepare_install() {
run_update "$fixture" success run_update "$fixture" success
[ "$(jq -r '.state' "$fixture/state/status.json")" = READY ] \ [ "$(jq -r '.state' "$fixture/state/status.json")" = READY ] \
|| fail 'download phase did not persist READY' || fail 'download phase did not persist READY'
[ "$(jq -r '.requestId' "$fixture/state/status.json")" = 01M00000000000000000000091 ] \
&& [ "$(jq -r '.action' "$fixture/state/status.json")" = DOWNLOAD ] \
|| fail 'download phase did not preserve request correlation'
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/1.0.0-preview.1" ] \ [ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/1.0.0-preview.1" ] \
|| fail 'download phase changed the active application' || fail 'download phase changed the active application'
[ -s "$fixture/state/cache/$version/release.tar.gz" ] \ [ -s "$fixture/state/cache/$version/release.tar.gz" ] \
@@ -330,6 +336,7 @@ run_update() {
local fixture=$1 local fixture=$1
local health=$2 local health=$2
local skip_backup=${3:-true} local skip_backup=${3:-true}
local backup_mode=${4:-}
env \ env \
PATH="$fixture/mock-bin:$PATH" \ PATH="$fixture/mock-bin:$PATH" \
REAL_OPENSSL="$REAL_OPENSSL" \ REAL_OPENSSL="$REAL_OPENSSL" \
@@ -353,6 +360,7 @@ run_update() {
KAIDI_UPDATER_PATH="$fixture/app/bin/update.sh" \ KAIDI_UPDATER_PATH="$fixture/app/bin/update.sh" \
KAIDI_RUNTIME_ENV_FILE="$fixture/runtime.env" \ KAIDI_RUNTIME_ENV_FILE="$fixture/runtime.env" \
KAIDI_SKIP_DB_BACKUP="$skip_backup" \ KAIDI_SKIP_DB_BACKUP="$skip_backup" \
KAIDI_DB_BACKUP_MODE="$backup_mode" \
KAIDI_UPDATE_HEALTH_ATTEMPTS=1 \ KAIDI_UPDATE_HEALTH_ATTEMPTS=1 \
KAIDI_UPDATE_HEALTH_INTERVAL_SECONDS=0 \ KAIDI_UPDATE_HEALTH_INTERVAL_SECONDS=0 \
UPDATE_RELEASE_BASE_URL="${FIXTURE_RELEASE_BASE_URL-https://release.fixture.invalid}" \ UPDATE_RELEASE_BASE_URL="${FIXTURE_RELEASE_BASE_URL-https://release.fixture.invalid}" \
@@ -366,6 +374,20 @@ run_update() {
"$ROOT/deploy/update.sh" "$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() { assert_private_gitea_release_case() {
local fixture="$WORK/private-gitea" local fixture="$WORK/private-gitea"
local version='1.0.0-preview.2' local version='1.0.0-preview.2'
@@ -431,6 +453,9 @@ assert_success_case() {
|| fail 'success case did not activate the signed updater' || fail 'success case did not activate the signed updater'
[ "$(jq -r '.state' "$fixture/state/status.json")" = SUCCEEDED ] \ [ "$(jq -r '.state' "$fixture/state/status.json")" = SUCCEEDED ] \
|| fail 'success case did not persist SUCCEEDED' || fail 'success case did not persist SUCCEEDED'
[ "$(jq -r '.requestId' "$fixture/state/status.json")" = 01M00000000000000000000092 ] \
&& [ "$(jq -r '.action' "$fixture/state/status.json")" = INSTALL ] \
|| fail 'success case did not preserve install request correlation'
[ ! -e "$fixture/state/processing/request.json" ] \ [ ! -e "$fixture/state/processing/request.json" ] \
|| fail 'success case left a claimed request behind' || fail 'success case left a claimed request behind'
grep -qx 'daemon-reload' "$fixture/systemctl.log" || fail 'systemd units were not reloaded' grep -qx 'daemon-reload' "$fixture/systemctl.log" || fail 'systemd units were not reloaded'
@@ -476,7 +501,7 @@ assert_update_path_failure_keeps_application_case() {
|| fail 'path watcher failure rolled back a healthy application' || fail 'path watcher failure rolled back a healthy application'
[ "$(jq -r '.state' "$fixture/state/status.json")" = SUCCEEDED ] \ [ "$(jq -r '.state' "$fixture/state/status.json")" = SUCCEEDED ] \
|| fail 'path watcher failure did not preserve successful application state' || 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' || fail 'path watcher failure did not preserve its diagnostic'
} }
@@ -492,7 +517,7 @@ assert_rollback_case() {
if run_update "$fixture" fail-new > "$fixture/update.log" 2>&1; then if run_update "$fixture" fail-new > "$fixture/update.log" 2>&1; then
fail 'rollback case unexpectedly succeeded' fail 'rollback case unexpectedly succeeded'
fi 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' || fail 'rollback case did not report a complete restoration'
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/1.0.0-preview.1" ] \ [ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/1.0.0-preview.1" ] \
|| fail 'rollback case did not restore the previous application' || fail 'rollback case did not restore the previous application'
@@ -506,6 +531,9 @@ assert_rollback_case() {
|| fail 'rollback case did not restart both the candidate and restored releases' || fail 'rollback case did not restart both the candidate and restored releases'
[ "$(jq -r '.state' "$fixture/state/status.json")" = FAILED ] \ [ "$(jq -r '.state' "$fixture/state/status.json")" = FAILED ] \
|| fail 'rollback case did not persist FAILED' || fail 'rollback case did not persist FAILED'
[ "$(jq -r '.requestId' "$fixture/state/status.json")" = 01M00000000000000000000092 ] \
&& [ "$(jq -r '.action' "$fixture/state/status.json")" = INSTALL ] \
|| fail 'rollback case did not preserve install request correlation'
[ ! -e "$fixture/app/releases/$version" ] \ [ ! -e "$fixture/app/releases/$version" ] \
|| fail 'rollback case left the failed release installed' || fail 'rollback case left the failed release installed'
find "$fixture/state/failed" -type f -name 'request-*.json' -print -quit | grep -q . \ find "$fixture/state/failed" -type f -name 'request-*.json' -print -quit | grep -q . \
@@ -524,7 +552,7 @@ assert_incomplete_rollback_requires_manual_recovery_case() {
if run_update "$fixture" fail-after-first > "$fixture/update.log" 2>&1; then if run_update "$fixture" fail-after-first > "$fixture/update.log" 2>&1; then
fail 'incomplete rollback case unexpectedly succeeded' fail 'incomplete rollback case unexpectedly succeeded'
fi fi
grep -Fq 'manual recovery is required' "$fixture/update.log" \ grep -Fq '需要人工恢复' "$fixture/update.log" \
|| fail 'incomplete rollback case did not require explicit recovery' || fail 'incomplete rollback case did not require explicit recovery'
[ ! -e "$fixture/state/processing/request.json" ] \ [ ! -e "$fixture/state/processing/request.json" ] \
|| fail 'incomplete rollback case left an automatically retriggered processing request' || fail 'incomplete rollback case left an automatically retriggered processing request'
@@ -534,6 +562,9 @@ assert_incomplete_rollback_requires_manual_recovery_case() {
|| fail 'incomplete rollback case did not quarantine transaction evidence' || fail 'incomplete rollback case did not quarantine transaction evidence'
[ "$(jq -r '.state' "$fixture/state/status.json")" = RECOVERY_REQUIRED ] \ [ "$(jq -r '.state' "$fixture/state/status.json")" = RECOVERY_REQUIRED ] \
|| fail 'incomplete rollback case did not lock the updater for recovery' || fail 'incomplete rollback case did not lock the updater for recovery'
[ "$(jq -r '.requestId' "$fixture/state/status.json")" = 01M00000000000000000000092 ] \
&& [ "$(jq -r '.action' "$fixture/state/status.json")" = INSTALL ] \
|| fail 'incomplete rollback case did not preserve install request correlation'
find "$fixture/state/failed" -type f -name 'request-*.json' -print -quit | grep -q . \ find "$fixture/state/failed" -type f -name 'request-*.json' -print -quit | grep -q . \
|| fail 'incomplete rollback case did not archive its claimed request' || fail 'incomplete rollback case did not archive its claimed request'
@@ -602,7 +633,7 @@ assert_unhealthy_baseline_blocks_restart_case() {
if run_update "$fixture" fail > "$fixture/update.log" 2>&1; then if run_update "$fixture" fail > "$fixture/update.log" 2>&1; then
fail 'unhealthy baseline unexpectedly reached installation' fail 'unhealthy baseline unexpectedly reached installation'
fi fi
grep -Fq 'Current release preflight failed' "$fixture/update.log" \ grep -Fq '当前版本预检查失败' "$fixture/update.log" \
|| fail 'unhealthy baseline did not preserve its preflight diagnostic' || fail 'unhealthy baseline did not preserve its preflight diagnostic'
! grep -Eq '^(start|restart|stop) kaidi-finance.service$' "$fixture/systemctl.log" \ ! grep -Eq '^(start|restart|stop) kaidi-finance.service$' "$fixture/systemctl.log" \
|| fail 'unhealthy baseline restarted the application' || fail 'unhealthy baseline restarted the application'
@@ -633,7 +664,7 @@ EOF
if run_update "$fixture" success > "$fixture/update.log" 2>&1; then if run_update "$fixture" success > "$fixture/update.log" 2>&1; then
fail 'invalid database URL unexpectedly reached installation' fail 'invalid database URL unexpectedly reached installation'
fi 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' || fail 'structurally invalid JDBC URL did not preserve its diagnostic'
! grep -q '^restart kaidi-finance.service$' "$fixture/systemctl.log" \ ! grep -q '^restart kaidi-finance.service$' "$fixture/systemctl.log" \
|| fail 'invalid database URL restarted the application' || fail 'invalid database URL restarted the application'
@@ -655,7 +686,7 @@ assert_symlink_request_rejected() {
if run_update "$fixture" success > "$fixture/update.log" 2>&1; then if run_update "$fixture" success > "$fixture/update.log" 2>&1; then
fail 'symlink request case unexpectedly succeeded' fail 'symlink request case unexpectedly succeeded'
fi 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' || fail 'symlink request case did not report the unsafe request'
[ "$(cat "$fixture/sentinel")" = 'operator-owned sentinel' ] \ [ "$(cat "$fixture/sentinel")" = 'operator-owned sentinel' ] \
|| fail 'symlink request case changed the link target' || fail 'symlink request case changed the link target'
@@ -679,7 +710,7 @@ assert_install_without_verified_cache_rejected() {
if run_update "$fixture" success > "$fixture/update.log" 2>&1; then if run_update "$fixture" success > "$fixture/update.log" 2>&1; then
fail 'install without cache unexpectedly succeeded' fail 'install without cache unexpectedly succeeded'
fi 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' || fail 'install without cache did not report the missing verified cache'
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/1.0.0-preview.1" ] \ [ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/1.0.0-preview.1" ] \
|| fail 'install without cache changed the active application' || fail 'install without cache changed the active application'
@@ -699,6 +730,8 @@ printf '[update-fixture] download-failure\n'
assert_download_failure_case assert_download_failure_case
printf '[update-fixture] database-backup-failure\n' printf '[update-fixture] database-backup-failure\n'
assert_database_failure_case assert_database_failure_case
printf '[update-fixture] database-backup-opt-in\n'
assert_database_backup_opt_in_case
printf '[update-fixture] unhealthy-baseline\n' printf '[update-fixture] unhealthy-baseline\n'
assert_unhealthy_baseline_blocks_restart_case assert_unhealthy_baseline_blocks_restart_case
printf '[update-fixture] invalid-database-url\n' printf '[update-fixture] invalid-database-url\n'