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
Qiufeng 69f738465e ci: tolerate checkout line-ending normalization
Release / release (push) Failing after 19m3s
2026-08-18 19:40:28 +08:00
Qiufeng 2712c5b8c1 ci: resolve release tag from checked out refs
Release / release (push) Failing after 7s
2026-08-18 19:33:06 +08:00
Qiufeng 5a48993d26 ci: use registered release runner label
Release / release (push) Failing after 10s
2026-08-18 19:30:25 +08:00
Qiufeng 0b91e1fc5f ci: isolate release runner and derive tag metadata
Release / release (push) Canceled after 0s
2026-08-18 17:54:23 +08:00
Qiufeng 95cd1daeb8 ci: use valid release token secret name
Release / release (push) Canceled after 0s
2026-08-18 17:38:40 +08:00
Qiufeng d6e97f1b4a fix: discover existing MySQL client tools
Release / release (push) Canceled after 0s
2026-08-18 17:31:51 +08:00
Qiufeng a975c75827 ci: use available Gitea runner label
Release / release (push) Canceled after 0s
2026-08-18 16:38:56 +08:00
Qiufeng 8eb1cbad83 fix: stabilize systemd deployment and release updates
Release / release (push) Canceled after 0s
2026-08-18 16:25:41 +08:00
Qiufeng cea7048f6c fix: make online updates observable and recoverable
Release / release (push) Canceled after 0s
2026-08-18 12:56:14 +08:00
Qiufeng 10d2e2f0d1 fix: leave setup wizard after completion
Release / release (push) Canceled after 0s
2026-08-18 11:38:28 +08:00
Qiufeng bb5e63aaf0 fix: force-stop service account during purge 2026-08-18 11:24:57 +08:00
Qiufeng 2cf29c030a fix: recover stalled update requests
Release / release (push) Canceled after 0s
2026-08-18 09:33:06 +08:00
Qiufeng 6d4ae00ae0 feat: split online update into explicit stages
Release / release (push) Canceled after 0s
2026-08-18 09:01:34 +08:00
Qiufeng 3c7847c8f5 docs: point preview install example to latest release 2026-08-18 08:05:40 +08:00
Qiufeng d61bdf9da4 fix: redirect after setup and remove redundant breadcrumbs
Release / release (push) Canceled after 0s
2026-08-18 08:01:57 +08:00
Qiufeng 7237792424 fix: discover panel Java runtimes in one-click installer
Release / release (push) Canceled after 0s
2026-08-18 07:19:17 +08:00
Qiufeng cfdc0fed44 feat: support Baota manual deployment and managed updates
Release / release (push) Canceled after 0s
2026-08-18 03:08:28 +08:00
Qiufeng fa4517c263 feat: add verified clean reinstall workflow
Release / release (push) Canceled after 0s
2026-08-18 01:13:05 +08:00
Qiufeng 2c73ad7eb0 fix: support panel-managed deployment safely
Release / release (push) Canceled after 0s
2026-08-18 00:02:45 +08:00
Qiufeng c35ef3d383 fix: default repair installs to production mode
Release / release (push) Canceled after 0s
2026-08-17 23:43:57 +08:00
Qiufeng cea46b494d fix: keep online update recovery available
Release / release (push) Canceled after 0s
2026-08-17 23:36:37 +08:00
54 changed files with 4487 additions and 616 deletions
+45 -19
View File
@@ -10,7 +10,9 @@ permissions:
jobs: jobs:
release: release:
runs-on: ubuntu-24.04 # Production releases use the repository's standard Linux runner label.
# The Gitea act_runner must advertise ubuntu-latest before tagging.
runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
@@ -18,16 +20,25 @@ jobs:
- name: Validate release tag - name: Validate release tag
id: release_meta id: release_meta
env:
GITEA_REF_NAME: ${{ github.ref_name }}
GITEA_SHA: ${{ github.sha }}
run: | run: |
GITEA_REF_NAME=${GITHUB_REF_NAME:-}
if [ -z "$GITEA_REF_NAME" ]; then GITEA_REF_NAME=${GITHUB_REF#refs/tags/}; fi
if [ -z "$GITEA_REF_NAME" ] || [ "$GITEA_REF_NAME" = "$GITHUB_REF" ]; then
GITEA_REF_NAME=$(git tag --points-at HEAD | awk '/^v[0-9A-Za-z][0-9A-Za-z._+-]*$/{print; exit}')
fi
VERSION=${GITEA_REF_NAME#v} VERSION=${GITEA_REF_NAME#v}
test "$GITEA_REF_NAME" = "v$VERSION" test "$GITEA_REF_NAME" = "v$VERSION"
./scripts/check-semver.sh "$VERSION" ./scripts/check-semver.sh "$VERSION"
GITEA_SHA=$(git rev-parse HEAD)
test "$(git rev-parse "refs/tags/$GITEA_REF_NAME^{commit}")" = "$GITEA_SHA" test "$(git rev-parse "refs/tags/$GITEA_REF_NAME^{commit}")" = "$GITEA_SHA"
test -z "$(git status --porcelain --untracked-files=all)" test -z "$(git ls-files --others --exclude-standard)"
printf 'version=%s\n' "$VERSION" >> "$GITHUB_OUTPUT" git diff --quiet --ignore-space-at-eol --ignore-cr-at-eol \
|| { printf 'Release checkout has non-whitespace tracked changes:\n' >&2; git diff --stat >&2; exit 1; }
{
printf 'version=%s\n' "$VERSION"
printf 'ref=%s\n' "$GITEA_REF_NAME"
printf 'revision=%s\n' "$GITEA_SHA"
} >> "$GITHUB_OUTPUT"
- uses: actions/setup-java@v4 - uses: actions/setup-java@v4
with: with:
@@ -55,7 +66,11 @@ jobs:
npm test npm test
npm run audit:dependencies npm run audit:dependencies
npm run build npm run build
npx playwright install --with-deps chromium if [ "${RUNNER_OS:-Linux}" = macOS ]; then
npx playwright install chromium
else
npx playwright install --with-deps chromium
fi
npm run test:e2e npm run test:e2e
npm run test:dist npm run test:dist
@@ -65,12 +80,17 @@ jobs:
./scripts/test-install-fixture.sh ./scripts/test-install-fixture.sh
./scripts/test-git-install-fixture.sh ./scripts/test-git-install-fixture.sh
./scripts/test-update-fixture.sh ./scripts/test-update-fixture.sh
./scripts/test-baota-start-fixture.sh
./scripts/test-purge-fixture.sh
./scripts/test-gitea-publish-fixture.sh ./scripts/test-gitea-publish-fixture.sh
shellcheck deploy/install.sh deploy/install-from-git.sh deploy/update.sh scripts/check-semver.sh \ shellcheck deploy/install.sh deploy/install-from-git.sh deploy/update.sh deploy/baota-start.sh deploy/baota-init.sh \
deploy/purge.sh \
scripts/check-semver.sh \
scripts/package-release.sh scripts/publish-gitea-release.sh \ scripts/package-release.sh scripts/publish-gitea-release.sh \
scripts/generate-release-key.sh scripts/test-install-fixture.sh \ scripts/generate-release-key.sh scripts/test-install-fixture.sh \
scripts/test-git-install-fixture.sh \ scripts/test-git-install-fixture.sh \
scripts/test-update-fixture.sh scripts/test-gitea-publish-fixture.sh \ scripts/test-update-fixture.sh scripts/test-gitea-publish-fixture.sh \
scripts/test-baota-start-fixture.sh scripts/test-purge-fixture.sh \
scripts/verify-release.sh scripts/verify-release.sh
- name: Build and sign release assets - name: Build and sign release assets
@@ -78,14 +98,18 @@ jobs:
RELEASE_SIGNING_KEY_B64: ${{ secrets.RELEASE_SIGNING_KEY_B64 }} RELEASE_SIGNING_KEY_B64: ${{ secrets.RELEASE_SIGNING_KEY_B64 }}
RELEASE_VERSION: ${{ steps.release_meta.outputs.version }} RELEASE_VERSION: ${{ steps.release_meta.outputs.version }}
KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256: ${{ vars.KAIDI_RELEASE_PUBLIC_KEY_SHA256 }} KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256: ${{ vars.KAIDI_RELEASE_PUBLIC_KEY_SHA256 }}
KAIDI_RELEASE_NOTES: Kaidi Finance ${{ github.ref_name }} KAIDI_RELEASE_NOTES: Kaidi Finance ${{ steps.release_meta.outputs.ref }}
KAIDI_SOURCE_REVISION: ${{ github.sha }} KAIDI_SOURCE_REVISION: ${{ steps.release_meta.outputs.revision }}
KAIDI_SOURCE_REF: ${{ github.ref_name }} KAIDI_SOURCE_REF: ${{ steps.release_meta.outputs.ref }}
KAIDI_SOURCE_DIRTY: 'false' KAIDI_SOURCE_DIRTY: 'false'
run: | run: |
test -n "$RELEASE_SIGNING_KEY_B64" test -n "$RELEASE_SIGNING_KEY_B64"
trap 'shred -u "$RUNNER_TEMP/release-key.pem" 2>/dev/null || rm -f "$RUNNER_TEMP/release-key.pem"' EXIT trap 'shred -u "$RUNNER_TEMP/release-key.pem" 2>/dev/null || rm -f "$RUNNER_TEMP/release-key.pem"' EXIT
printf '%s' "$RELEASE_SIGNING_KEY_B64" | base64 --decode > "$RUNNER_TEMP/release-key.pem" if base64 --decode </dev/null >/dev/null 2>&1; then
printf '%s' "$RELEASE_SIGNING_KEY_B64" | base64 --decode > "$RUNNER_TEMP/release-key.pem"
else
printf '%s' "$RELEASE_SIGNING_KEY_B64" | base64 -D > "$RUNNER_TEMP/release-key.pem"
fi
chmod 600 "$RUNNER_TEMP/release-key.pem" chmod 600 "$RUNNER_TEMP/release-key.pem"
KAIDI_RELEASE_SIGNING_KEY="$RUNNER_TEMP/release-key.pem" \ KAIDI_RELEASE_SIGNING_KEY="$RUNNER_TEMP/release-key.pem" \
./scripts/package-release.sh "$RELEASE_VERSION" ./scripts/package-release.sh "$RELEASE_VERSION"
@@ -93,18 +117,20 @@ jobs:
- name: Verify signed release assets - name: Verify signed release assets
env: env:
KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256: ${{ vars.KAIDI_RELEASE_PUBLIC_KEY_SHA256 }} KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256: ${{ vars.KAIDI_RELEASE_PUBLIC_KEY_SHA256 }}
KAIDI_EXPECTED_SOURCE_REVISION: ${{ github.sha }} KAIDI_EXPECTED_SOURCE_REVISION: ${{ steps.release_meta.outputs.revision }}
KAIDI_EXPECTED_SOURCE_REF: ${{ github.ref_name }} KAIDI_EXPECTED_SOURCE_REF: ${{ steps.release_meta.outputs.ref }}
KAIDI_EXPECTED_SOURCE_DIRTY: 'false' KAIDI_EXPECTED_SOURCE_DIRTY: 'false'
run: ./scripts/verify-release.sh dist/release run: ./scripts/verify-release.sh dist/release
- name: Publish Gitea release - name: Publish Gitea release
env: env:
GITEA_TOKEN: ${{ secrets.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_REF_NAME: ${{ github.ref_name }} GITEA_SERVER_URL: https://git.awaioi.com
GITEA_SHA: ${{ github.sha }} GITEA_REPOSITORY: ERP-Team/kaidi
GITEA_REF_NAME: ${{ steps.release_meta.outputs.ref }}
GITEA_SHA: ${{ steps.release_meta.outputs.revision }}
run: ./scripts/publish-gitea-release.sh run: ./scripts/publish-gitea-release.sh
- name: Upload Playwright report after failure - name: Upload Playwright report after failure
+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
+198 -137
View File
@@ -8,10 +8,20 @@
## 本地开发 ## 本地开发
后端默认连接本机 MySQL `127.0.0.1:3307`,启动 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
SPRING_PROFILES_ACTIVE=local \
DB_URL='jdbc:mysql://127.0.0.1:3307/kaidi_finance?useUnicode=true&characterEncoding=utf8&connectionTimeZone=UTC&serverTimezone=UTC' \
DB_USERNAME=kaidi DB_PASSWORD=kaidi_local_2026 \
./mvnw spring-boot:run ./mvnw spring-boot:run
``` ```
@@ -41,154 +51,205 @@ npm run build
./scripts/export-openapi.sh http://127.0.0.1:18080 ./scripts/export-openapi.sh http://127.0.0.1:18080
``` ```
## R1 Preview 一键安装 ## R1 Preview 部署方式
代码仓库和 Release 已公开,Linux 服务器不需要 Gitea Token。安装器读取 代码仓库和 Release 已公开,提供两种互斥方式:
`https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest`,生产机不执行 `git pull` 或现场编译。
安装脚本内置固定的发布公钥指纹,下载的应用包、清单、SBOM 和更新脚本仍须通过 RSA 签名与 SHA-256 校验。
Gitea API 只用于确认 Release tag 和资产名称;下载地址由受信 API 域名、仓库路径和 tag 重新构造,不采用
Gitea 响应中可能错误指向内网地址的 `browser_download_url`。
本版只支持由运维人员预先准备的外部 MySQL 8.4.x;安装器不会安装 MySQL、创建数据库容器或修改现有
PostgreSQL 18。PostgreSQL 兼容开发已冻结,不属于本次 Preview 发布范围。
执行命令的机器需预装 `bash`、`sudo`、`curl`、`mktemp` 和 `sha256sum`,并能访问目标 Gitea;`jq` 和 Java 1. **systemd 一键安装(推荐)**:程序独占 `/opt/kaidi` 和 `kaidi-finance.service`,不创建宝塔 Java 项目。
由安装器补齐;Java 17 优先使用服务器已有运行时,没有合适版本时才从官方 Azul 下载。数据库服务和数据库客户端均不会被安装。默认首次安装向导直接使用 JDBC 连接浏览器中填写的 2. **宝塔手动部署**:下载压缩包后解压并运行本地初始化脚本,再创建 Spring Boot 项目。
外部数据库,不需要服务器安装 MySQL CLI。应用固定安装到 `/opt/kaidi`、`/var/lib/kaidi`、
`/var/lib/kaidi-update` 和 `/etc/kaidi`。安装器不安装、不启动也不修改 Nginx、Caddy、宝塔或其他反向代理。
### 直接 curl 安装 两种方式不能同时运行在同一个端口。程序不会安装 MySQL,数据库只支持运维人员预先准备的外部 MySQL 8.4.x,
PostgreSQL 18 兼容工作继续冻结。
### 生产部署契约(先看这里)
- **生产机不执行 `git clone`、`git pull` 或远程脚本拼接。** 源码仓库是
`https://git.awaioi.com/ERP-Team/kaidi.git`,生产安装和后台更新使用同一仓库生成的公开 Gitea Release。
- 安装器先读取 Gitea Release API,再下载 `release-manifest.json`、签名、公钥和压缩包,校验固定公钥指纹、RSA 签名、版本、文件名和 SHA-256;校验失败不会安装。
- 后台“获取版本”只查询 Release;“下载”才下载并校验;“立即更新并重启”才切换 `current` 并重启服务。更新器默认不访问数据库;如明确设置 `KAIDI_DB_BACKUP_MODE=mysqldump`,才会调用主机已有的备份工具。更新器下载的是 Release 制品,不是 Git 工作树。
- MySQL 是**已有数据库**,应用只通过向导写入的 `DB_URL`、`DB_USERNAME`、`DB_PASSWORD` 连接它;安装器不安装 MySQL、不创建数据库、不修改数据库服务。填写 `127.0.0.1` 表示 MySQL 与 Java 应用在同一台服务器,端口以实际监听端口为准,不默认假设 `3307`。
- 安装阶段只校验 JDBC 配置格式,不调用 `mysql`/`mariadb` 客户端,也不执行 `CREATE`、`INSERT`、`UPDATE`、`DELETE` 或 `DROP`。首次向导点击“完成安装”后,应用才会在**专用空 schema**中运行 Flyway 建表并初始化管理员;这是业务初始化,不是安装 MySQL 服务。在线更新默认跳过数据库备份;需要备份时由运维显式设置 `KAIDI_DB_BACKUP_MODE=mysqldump`,更新器只调用已有工具,不会安装数据库。
- `KAIDI_APP_PORT` 只决定 Java 回环监听端口,默认 `18080`;反向代理必须指向安装器输出的 `PROXY_TARGET`。安装器不会替你修改 Nginx 或宝塔站点配置。
- 发布归档使用无顶层目录的 `tar.gz`,只包含 `app.jar`、`public/`、`ops/`、`VERSION`;打包阶段禁用 macOS 扩展属性并拒绝开发数据库回退值。
### 32 位 Linux 支持边界
当前支持的是 **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 位系统能执行兼容的 `mysqldump`,否则保持默认 `skip` 或从独立备份机执行备份,不要临时安装数据库。
### systemd 一键安装(推荐)
先在宝塔面板停止并删除当前错误的 Java 项目,再执行:
```bash ```bash
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.19/install.sh | sudo 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
``` ```
这条命令会提示填写 Java 应用端口,直接回车使用 `18080`;随后安装最新签名 Release,并默认进入 `/setup` 该命令只安装程序运行所需的 systemd 单元,自动创建 `kaidi` 用户并检测现有 Java 17(包括
安装向导。Java 默认只监听 `127.0.0.1:所选端口`,前端页面、API 和健康检查均由同一端口提供。无人值守安装可直接指定: `/www/server/java/*/bin/java`);没有可用 Java 时才下载匹配架构的运行时。它不会安装 MySQL,也不会要求宝塔面板提供
`kaidi` 用户。安装完成后查看 `/root/kaidi-first-login.txt`,通过反向代理域名进入 `/setup`。
### 彻底清理旧安装
重新安装前先在宝塔面板停止并删除名为 `kaidi-finance` 的 Java 项目,避免面板守护进程重新拉起旧 Java。
随后执行一键清理:
```bash ```bash
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.19/install.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
| sudo env KAIDI_APP_PORT=19090 bash
``` ```
端口必须在 `1024-65535` 范围且未被其他程序监听;安装器会在安装前检查冲突。只有明确需要让其他主机直连 Java 上面是一条完整命令:脚本通过管道直接交给 root 执行,不创建临时安装文件,避免终端自动换行导致 `-o` 参数丢失。
时才设置 `KAIDI_SERVER_ADDRESS=0.0.0.0`,通常应保持默认回环绑定并由本机反向代理访问。需要在执行前独立校验安装脚本时使用: 如果需要连同本地恢复备份一起删除(确认不再需要回滚后再执行),在同一条命令中增加
`KAIDI_PURGE_DELETE_BACKUP=true`。卸载前必须先停止并删除宝塔中的 `kaidi-finance` Java 项目;宝塔的项目元数据、
Nginx/反向代理和外部 MySQL 属于外部资源,卸载程序不会误删它们。
清理脚本会先停止 systemd 和遗留 Kaidi 进程,将旧配置、文件存储、安装码以及已有数据库备份归档到
`/root/kaidi-reinstall-backups/`,再删除 `/opt/kaidi`、`/www/wwwroot/kaidi`、`/etc/kaidi`、
`/var/lib/kaidi`、`/var/lib/kaidi-update`、`/var/log/kaidi` 和三个 systemd 单元。恢复包权限固定为
`0600`,确认新安装及数据无误后再由 root 删除。脚本不删除外部 MySQL、系统 Java、Nginx 或宝塔站点配置;
新安装必须连接一个新的空 MySQL 数据库,旧数据库保留用于回滚。
### Preview.43 直链与宝塔手动部署
本版提供 systemd 一键安装脚本和宝塔手动部署两种互斥方式。手动部署使用一个不带顶层目录的压缩包,下载后直接解压到版本目录,再由宝塔面板创建
Spring Boot 项目。数据库、JDK、Nginx 和宝塔本身都由运维人员准备;程序不会自动安装 MySQL 或任何第三方服务。
下载地址:
`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.44/SHA256SUMS`
服务器要求:Linux、Java 17(宝塔项目选择 JDK 17)、可访问外部 MySQL 8.4.x;**systemd 一键安装**还需要
systemd/systemd-analyze,**宝塔手动部署**不要求 systemd。32 位 Linux 需要宿主机
已经提供可运行的 32 位 Java 17。程序只连接已有数据库,不安装数据库客户端或数据库服务。初始化只需要
`bash`、`openssl`、`sha256sum` 和基础 Linux 工具;systemd 在线更新默认不需要数据库客户端。只有将
`KAIDI_DB_BACKUP_MODE` 设为 `mysqldump` 时,才需要 `curl`、`jq`、`flock`、`gzip`、`runuser` 和兼容的
`mysqldump`;安装器和更新器会优先使用 PATH 中的工具,也会探测宝塔常见的 `/www/server/mysql/bin/` 路径;如备份工具安装在其他位置,可设置
`KAIDI_MYSQLDUMP_BIN`,程序不会替你安装这些依赖。
更新器默认只下载、验签、切换和健康检查,不调用 Docker、不进入容器,也不创建或管理 MySQL 服务。
#### 1. 下载、校验、解压
以下命令只做文件下载和解压,不执行远程脚本。`VERSION` 必须替换成发布版本;归档内的 `app.jar`、`public/`、`ops/`
必须直接位于 `RELEASE_ROOT`,不能再嵌套一层目录。
```bash ```bash
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.19/install.sh -o /tmp/kaidi-install.sh VERSION=1.0.0-preview.44
printf '%s %s\n' bc9001197af843dc323907a98023064ccc4f184e000543d2086b8a6f8161dbc7 /tmp/kaidi-install.sh | sha256sum -c - APP_ROOT=/www/wwwroot/kaidi
sudo bash /tmp/kaidi-install.sh RELEASE_ROOT="$APP_ROOT/releases/$VERSION"
rm -f /tmp/kaidi-install.sh sudo install -d -m 0755 "$RELEASE_ROOT"
wget -O "/tmp/kaidi-finance-$VERSION.tar.gz" \
"https://git.awaioi.com/ERP-Team/kaidi/releases/download/v$VERSION/kaidi-finance-$VERSION.tar.gz"
wget -O /tmp/kaidi-SHA256SUMS \
"https://git.awaioi.com/ERP-Team/kaidi/releases/download/v$VERSION/SHA256SUMS"
(cd /tmp && grep " kaidi-finance-$VERSION.tar.gz$" kaidi-SHA256SUMS | sha256sum -c -)
sudo tar -xzf "/tmp/kaidi-finance-$VERSION.tar.gz" -C "$RELEASE_ROOT"
``` ```
### 从固定 Git tag 拉取后安装 如果服务器没有 `wget`,可使用浏览器下载后上传同一文件;`curl -fL URL -o FILE` 也只用于下载压缩包,不能替代初始化脚本。
需要保留源码快照时,可以拉取与 Release 对应的固定 tag,再运行仓库内包装器。公共仓库不要求凭据, #### 2. 执行本地初始化
包装器会在 `sudo` 前校验 `deploy/install.sh` 的固定 SHA-256,再按同一公钥信任链安装最新签名 Release。
初始化脚本来自已下载的归档,并在本机以 root 运行。它创建 `kaidi` 启动用户、受保护配置和一次性安装码;不会下载
Java、安装 MySQL 或修改反向代理。宝塔手动模式不会开放在线更新按钮。端口参数可改为 `1024-65535` 中的空闲端口,默认 `18080`。
```bash ```bash
git clone --branch v1.0.0-preview.19 --depth 1 https://git.awaioi.com/ERP-Team/kaidi.git kaidi-preview sudo "$RELEASE_ROOT/ops/baota-init.sh" 18080
cd kaidi-preview sudo cat /root/kaidi-first-login.txt
./deploy/install-from-git.sh
``` ```
首次安装使用向导时不要传 `KAIDI_DB_URL`、`KAIDI_DB_USERNAME` 或 `KAIDI_DB_PASSWORD`;这些值在浏览器中填写。 初始化不会安装或启用 `kaidi-update.service`/`kaidi-update.path`,避免宝塔守护进程与
只有已完成安装的修复性重装才从运行时配置读取数据库值,详见下文。 systemd 更新器同时管理同一应用;宝塔模式只支持手动替换 Release,后台在线更新请使用 systemd 安装模式。
安装器会完成以下动作: 初始化失败会回滚本次写入的配置、unit 和 `current` 链接,可以直接修正原因后重试;已经存在正式安装时不要重复执行,先按“彻底清理旧安装”流程处理。
- 仅在 Linux + systemd 环境执行;首版 32 位支持基线为带 systemd 的 Debian/Ubuntu x86 32 位 Linux。 #### 3. 宝塔 Spring Boot 项目字段
- 不安装或创建数据库;在 `/setup` 中连接运维人员预先准备的外部 MySQL 8.4.x。
- 识别 `x86_64`、`aarch64`、`armv7` 或 32 位 `i386/i486/i586/i686`;已有 Java 17 直接复用,否则校验 SHA-256 后下载对应的官方 Azul Java 17 JRE。
- 使用安装器内置的 SHA-256 指纹校验 Release 公钥,再用该公钥验证发布清单 RSA 签名。
- 首次安装默认启用 `/setup` 向导,不在命令行保存数据库密码;向导只接受 MySQL 8.4.x,并在提交前验证完整迁移权限。
- 安装签名 Release 到 `/opt/kaidi/releases/<version>`,以 `/opt/kaidi/current` 原子切换当前版本。
- 安装 `kaidi-finance.service`、更新监听服务和健康检查;Java 同时托管 TDesign 前端静态资源及 Vue 路由回退。
- 向导只初始化一个由操作者填写的 `SYSTEM_ADMIN` 管理员,不创建项目、财务、资料或演示账号。
- 安装器把一次性安装码写入仅 root 可读的 `/root/kaidi-first-login.txt`;完成向导后写入锁定标记并切换正式应用。
安装完成后先执行 `sudo cat /root/kaidi-first-login.txt`,其中会显示准确的反向代理目标和一次性安装码。配置反向代理后, 在宝塔面板创建项目,类型选择 **Spring Boot**,不要选择 Tomcat。填写如下:
通过域名的 `/setup` 完成数据库和管理员配置,再访问域名根路径登录。Preview 使用 HTTP 时安装器默认设置
`SESSION_COOKIE_SECURE=false`;配置 HTTPS 反向代理后,应在 `/etc/kaidi/kaidi.env` 改为
`SESSION_COOKIE_SECURE=true` 并执行 `sudo systemctl restart kaidi-finance`。
以安装端口 `19090` 为例,Nginx 只需代理到 Java,不需要单独托管前端文件: | 字段 | 填写值 |
| --- | --- |
| 项目路径 | `/www/wwwroot/kaidi/current` |
| 项目名称 | `kaidi-finance` |
| 项目 JDK | `JDK 17` |
| 项目启动命令 | `/www/wwwroot/kaidi/current/ops/baota-start.sh` |
| 启动用户 | `kaidi` |
| 项目端口 | `18080`(与初始化参数一致) |
| 绑定域名 | 业务域名,例如 `fi.awaioi.com` |
| 后端路由 | `/` |
| 前端资源 | 留空,Spring Boot 直接提供 `public/` |
| 进程守护 | 开启 |
| 项目意外重启 | 勾选 |
**环境变量全部留空,不要创建 DB_URL、DB_USERNAME、DB_PASSWORD 或 FIELD_ENCRYPTION_KEY。**
`ops/baota.env.example` 仅是说明文件,不能把其中的占位值粘贴到面板。启动器先读取 `/etc/kaidi/kaidi.env`,向导完成后
再读取 `/var/lib/kaidi/setup/application.env`;宝塔只有填写了非空覆盖值时才会覆盖受保护配置。数据库密码不会出现在
Java 启动命令或 `ps` 参数中。
#### 4. 首次向导与反向代理
启动宝塔项目后,访问 `https://业务域名/setup`,输入 `/root/kaidi-first-login.txt` 中的一次性安装码,填写外部 MySQL
连接信息和系统管理员账号密码。迁移完成后应用会退出一次,宝塔守护进程会自动拉起正式模式;再次访问域名根路径即可登录。
首次配置数据库密码必须使用 HTTPS;没有域名时通过 SSH 隧道访问回环端口:
```bash
ssh -L 18080:127.0.0.1:18080 root@SERVER_IP
```
本机浏览器打开 `http://127.0.0.1:18080/setup`。反向代理只需把整个 `/` 转发到 `http://127.0.0.1:18080`,并保留以下请求头:
```nginx ```nginx
location / { location / {
proxy_pass http://127.0.0.1:19090; proxy_pass http://127.0.0.1:18080;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 10s;
proxy_read_timeout 120s; proxy_read_timeout 120s;
client_max_body_size 500m; client_max_body_size 500m;
} }
``` ```
宝塔、Caddy 或云网关使用同一个上游地址 `http://127.0.0.1:所选端口`。安装后执行以下命令确认应用和首次登录信息: HTTPS 启用后,在 `/etc/kaidi/kaidi.env` 将 `SESSION_COOKIE_SECURE` 设为 `true`,再从宝塔面板重启项目。健康检查:
```bash ```bash
curl -fsS http://127.0.0.1:19090/actuator/health | jq -e '.status == "UP"' curl -fsS http://127.0.0.1:18080/actuator/health
sudo systemctl --no-pager --full status kaidi-finance kaidi-update.path curl -fsS http://127.0.0.1:18080/ | head
sudo cat /root/kaidi-first-login.txt
``` ```
#### 5. 手动部署模式的更新边界
宝塔手动部署不启用 Preview 的在线更新契约。需要升级时,先停止宝塔项目,下载并校验新的 Release 压缩包,
解压到新的 `releases/<VERSION>`,再由宝塔项目切换路径并启动;确认健康检查通过后再删除旧版本。
不要在宝塔项目仍运行时点击“系统治理 → 系统更新”,也不要让宝塔守护和 `kaidi-finance.service` 同时管理同一端口。
需要后台点击“获取版本 → 下载 → 立即更新并重启”的稳定流程时,使用上一节的 systemd 一键安装方式。
手动升级由运维人员决定是否先做外部 MySQL 备份;程序不会安装 MySQL 或客户端,也不会修改反向代理配置。
后续版本仍按同样方式直接下载并解压到新的 `releases/<VERSION>`,保留可回滚的旧目录。
### Linux 32 位 ### Linux 32 位
应用已按 Java 17 字节码构建,安装器会在 32 位 Linux 下载 `i686` JRE。MySQL 8.4 没有可用于该部署方式的 压缩包本身不绑定 CPU 架构,关键是宿主机提供 glibc、systemd 和可运行的 32 位 Java 17。32 位主机不能在本机运行 64 位 JDK,也不应尝试在本机安装
32 位服务端镜像,因此 32 位主机需要预先连接一台 MySQL 8.4 数据库,之后仍然只执行一个安装命令: MySQL 8.4;提前准备外部 MySQL,完成上述向导即可。systemd 在线更新默认不访问数据库;只有显式启用
`KAIDI_DB_BACKUP_MODE=mysqldump` 时才需要确认宿主机能执行与数据库版本兼容的工具。宝塔手动模式只做手动制品替换和人工数据库备份。
### 一键卸载
`purge.sh` 就是本项目的卸载程序:它会停止 Kaidi 进程、移除 systemd 单元、删除本地程序/配置/运行状态、
清理受管临时压缩包,并删除本次安装创建的 `kaidi` 服务账号;不会触碰外部 MySQL、Nginx、反向代理或系统 Java。
默认先把本地配置和运行数据保存到 root-only 恢复包,再删除受管路径。确认不需要回滚时,可在同一条命令中设置
`KAIDI_PURGE_DELETE_BACKUP=true`,实现本地受管文件和恢复包一并清除:
```bash ```bash
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.19/install.sh | sudo bash 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
``` ```
无论主机架构如何,安装器都不会安装 MySQL、数据库客户端或创建数据库容器。在打开向导前,需要预先创建 `kaidi_finance`,并授予安装账号该库的 执行前先在宝塔停止并删除 `kaidi-finance` Java 项目;宝塔面板元数据属于外部资源,必须由面板先停用,
完整迁移权限。向导会连接数据库、核验 MySQL 8.4.x 版本,并实际探测建表、改表、索引、外键、存储过程、 否则守护进程会重新拉起 Java,卸载程序会明确报出原因而不误删其他服务。
临时表及读写权限;专用数据库不是 `utf8mb4/utf8mb4_0900_ai_ci` 时,向导会在迁移前自动统一默认字符集和
排序规则,全部通过后 Flyway 才会建表。
数据库管理员可在 MySQL 8.4 中按实际应用服务器地址执行以下基线 SQL:
```sql
CREATE DATABASE kaidi_finance CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
CREATE USER 'kaidi'@'KAIDI_SERVER_IP' IDENTIFIED BY 'DB_PASSWORD';
GRANT ALL PRIVILEGES ON kaidi_finance.* TO 'kaidi'@'KAIDI_SERVER_IP';
```
### 修复性重装
正常升级统一使用后台“在线更新”。只有安装文件损坏且后台更新不可用时,才在原服务器执行修复性重装;
安装器会优先读取向导完成后生成的 `/var/lib/kaidi/setup/application.env`,再回退到 `/etc/kaidi/kaidi.env`,保留数据库、字段加密密钥、
管理员数据和运维人员新增的环境变量:
```bash
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.19/install.sh \
| sudo env KAIDI_REINSTALL=true KAIDI_SETUP_WIZARD=false bash
```
没有显式设置 `KAIDI_APP_PORT` 时,修复性重装会沿用 `/etc/kaidi/kaidi.env` 中的原端口。重装失败会恢复原应用链接、Java 运行时、环境文件和 systemd 单元;脚本会明确报告回滚不完整,
不会把恢复失败吞掉。
如果安装器已完成但向导尚未提交,可执行下面的命令重新生成一次性安装码;该恢复路径只接受仍处于向导模式且未锁定的安装,正式模式不会被覆盖。
```bash
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.19/install.sh \
| sudo env KAIDI_REINSTALL=true bash
```
### 停用并移除程序
以下命令移除应用程序和服务,但保留 `/var/lib/kaidi`、`/var/lib/kaidi-update`、`/etc/kaidi` 以及数据库,
便于审计、备份或重新安装。确认数据备份前不要删除这些保留目录或外部 MySQL 数据。
反向代理由运维人员独立管理,停用程序不会修改其配置。
```bash
sudo systemctl disable --now kaidi-update.path kaidi-update.service kaidi-finance.service
sudo rm -f /etc/systemd/system/kaidi-finance.service /etc/systemd/system/kaidi-update.service /etc/systemd/system/kaidi-update.path
sudo systemctl daemon-reload
sudo rm -rf /opt/kaidi
```
## Release 与在线更新 ## Release 与在线更新
@@ -202,76 +263,76 @@ base64 < release-signing-private.pem | tr -d '\n'
将私钥的 Base64 内容保存为 Gitea Actions Secret `RELEASE_SIGNING_KEY_B64`,并将命令输出的公钥 SHA-256 将私钥的 Base64 内容保存为 Gitea Actions Secret `RELEASE_SIGNING_KEY_B64`,并将命令输出的公钥 SHA-256
保存为 Gitea Actions Variable `KAIDI_RELEASE_PUBLIC_KEY_SHA256`。Gitea 发布 Token 只用于该工作流创建 保存为 Gitea Actions Variable `KAIDI_RELEASE_PUBLIC_KEY_SHA256`。Gitea 发布 Token 只用于该工作流创建
Release;生产服务器从公共 Release 下载,不保存 Token。私钥不得提交到 Git。`.gitea/workflows/release.yml` 要求 Release;生产服务器从公共 Release 下载,不保存 Token。私钥不得提交到 Git。`.gitea/workflows/release.yml` 要求
act_runner 提供 `ubuntu-24.04` 标签,并在 tag 发布时执行后端、前端、OpenAPI、Shell、安装/更新和浏览器门禁。 act_runner 提供 `ubuntu-latest` 标签,并在 tag 发布时执行后端、前端、OpenAPI、Shell、安装/更新和浏览器门禁;如果
Actions 页面显示 “No matching online runner”,先启动并注册该标签的 act_runner。
工作流先建立不可见草稿,再显式上传并核对 9 个资产的名称和大小,最后才发布为 `/releases/latest`。Gitea 的 工作流先建立不可见草稿,再显式上传并核对 10 个资产的名称和大小,最后才发布为 `/releases/latest`。Gitea 的
`latest` 会排除 `prerelease=true`,因此即使 tag 名含 `preview`,发布记录的 `prerelease` 也固定为 `false`; `latest` 会排除 `prerelease=true`,因此即使 tag 名含 `preview`,发布记录的 `prerelease` 也固定为 `false`;
Preview 属性由 SemVer 版本名表达。之后推送 tag 即会构建、测试、签名并发布: Preview 属性由 SemVer 版本名表达。之后推送 tag 即会构建、测试、签名并发布:
```bash ```bash
git tag v1.0.0-preview.19 git tag v1.0.0-preview.44
git push origin v1.0.0-preview.19 git push origin v1.0.0-preview.44
``` ```
在线更新使用独立的 TDesign 页面:隔离的系统管理员进入“系统治理 → 系统更新”。权限与配置页只管理用户、角色、数据范围、表单模板和参数版本,不配置系统名称或域名。 在线更新仍使用独立的 TDesign 页面:系统管理员进入“系统治理 → 系统更新”。更新源由 root 在
更新源由 root 在 `/etc/kaidi/update.env` 固定为公共 Gitea Latest Release API;页面和普通 API 不能提交 `/etc/kaidi/update.env` 固定为公共 Gitea Latest Release API;页面和普通 API 不能提交 URL、Token、脚本或命令。
URL、Token、脚本或命令。更新流程固定为: **Preview 的稳定在线更新契约只支持 systemd 一键安装模式**:更新器由
`kaidi-update.service` 执行,直接停止、切换并重启 `kaidi-finance.service`,不依赖宝塔面板的意外重启。
宝塔手动部署仅用于手动启动和反向代理;其面板进程管理与 systemd 更新器不是同一套生命周期,暂不作为在线更新的稳定路径。
1. 点击“获取更新”,后端先读取 Release 元数据,再自动排队 `DOWNLOAD`;root 更新器从 Gitea 下载 manifest、签名和应用包,执行 RSA、 更新流程固定为:
SHA-256、版本、文件名和压缩包路径校验后缓存到 `/var/lib/kaidi-update/cache/<version>`。业务服务不停机。
3. 页面显示 `READY/等待重启` 后才出现“立即重启”;管理员点击后提交安装。未缓存或版本不一致
的包不能进入安装。
4. 安装请求持久领取到 `/var/lib/kaidi-update/processing`;进程或主机中断后由 systemd 恢复未完成事务。
5. root 更新器重新验签和验哈希,确认 `mysqldump` 成功并生成权限为 `0600` 的备份,默认保留最近 5 份。安装器不安装
`mysqldump`;启用在线更新前,运维人员须自行在应用服务器预装与外部 MySQL 兼容的 `mysqldump`。
6. 校验更新脚本和 systemd 单元后,原子切换 updater、systemd 和应用版本,不修改反向代理。
7. 同时检查 Java 健康端点、更新 path unit 和 Java 托管的静态首页。全部通过后页面显示 10 秒倒计时并自动
刷新;刷新或短暂断线发生在安装中时,页面会恢复 3 秒轮询。任一检查失败则恢复并验证上一版本。
忙碌期间检查、下载和安装按钮保持禁用,防止重复请求;这就是更新执行冷却。10 秒只用于成功后的页面刷新, 1. 点击“获取版本”,只实时请求 Gitea Latest Release 版本、发布时间和发布说明,不排队下载。
不会延迟服务端切换。systemd 在 300 秒内连续失败 3 次后停止自动重试,避免失败任务空跑。 2. 发现新版本后显示“立即更新”;管理员点击后才排队 `DOWNLOAD`。更新器下载 manifest、签名和应用包,执行 RSA、SHA-256、版本、
文件名和压缩包路径校验,通过后缓存到 `/var/lib/kaidi-update/cache/<version>`,业务服务继续运行。
3. 页面实时轮询并依次显示下载已排队、下载中、校验中和 `READY/下载完成`;只有下载完成后才显示“立即更新并重启”。
4. 管理员点击“立即更新并重启”,更新器默认不访问数据库;如运维已将 `KAIDI_DB_BACKUP_MODE=mysqldump` 写入 `/etc/kaidi/update.env`,才会调用宿主机已有的 `mysqldump`,备份权限为 `0600`,默认保留最近 5 份。程序不会安装 MySQL 或客户端。
5. 更新器安装并保护新版本目录,原子切换 `/opt/kaidi/current`(宝塔手动部署才使用
`/www/wwwroot/kaidi/current`),停止并重启 systemd 管理的应用服务。
6. 健康端点、静态首页和运行版本全部通过后,页面等待 10 秒自动刷新;期间断线或命令响应丢失由前端状态重同步恢复。任一检查失败则切回上一应用版本,
如果启用了 `mysqldump` 模式则保留数据库备份;否则保留事务证据供人工处理。
安装器会把公共 API 地址写入 root-only 的 `/etc/kaidi/kaidi.env` 与 `/etc/kaidi/update.env`,两者权限均为 忙碌期间检查、下载和安装按钮保持禁用,防止重复请求;10 秒只用于成功后的页面刷新,不延迟服务端切换。数据库迁移必须至少保持一个版本向后兼容。
`0600`:前者供 Java 后端“检查更新”读取,后者供 root 更新器下载资产。默认 Token 为空;如改接私有镜像, 查看状态和日志:
可由运维人员在这两个文件中配置独立只读 Token,Token 不写入页面、状态 JSON、审计参数或更新日志。
数据库迁移必须保持至少一个版本的向后兼容。查看状态和日志:
```bash ```bash
sudo systemctl status kaidi-finance kaidi-update.path sudo systemctl status kaidi-update.path kaidi-update.service
sudo journalctl -u kaidi-update.service -n 100 --no-pager sudo journalctl -u kaidi-update.service -n 100 --no-pager
cat /var/lib/kaidi-update/status.json cat /var/lib/kaidi-update/status.json
``` ```
如果 `status.json` 显示 `FAILED` 且日志提示回滚未完成,不要删除 进程被中断时,`kaidi-update.path` 会根据 `processing/request.json` 或 `transactions/active` 自动恢复一次。
`/var/lib/kaidi-update/processing/request.json` 或活动事务目录。systemd 在 300 秒内连续失败 3 次后会停止自动重试, 如果 `status.json` 显示 `RECOVERY_REQUIRED`,失败请求会归档到 `/var/lib/kaidi-update/failed`,事务证据会移到
修复日志所示的磁盘、权限或旧版本健康问题后执行: `transactions/recovery-required*` 并退出自动触发路径,新下载和安装请求也会被拒绝。修复日志所示的磁盘、权限或
旧版本健康问题后,将唯一一份待恢复事务移回 `active`,再明确执行一次:
```bash ```bash
sudo mv /var/lib/kaidi-update/transactions/recovery-required /var/lib/kaidi-update/transactions/active
sudo systemctl reset-failed kaidi-update.service kaidi-update.path sudo systemctl reset-failed kaidi-update.service kaidi-update.path
sudo systemctl start kaidi-update.service sudo systemctl start kaidi-update.service
sudo journalctl -u kaidi-update.service -n 100 --no-pager sudo journalctl -u kaidi-update.service -n 100 --no-pager
cat /var/lib/kaidi-update/status.json cat /var/lib/kaidi-update/status.json
``` ```
只有状态恢复为 `SUCCEEDED`、`CURRENT` 或确定性的终态 `FAILED`,且 `transactions/active` 已处理完成后, 如果目录带时间后缀,先通过 `ls -1d /var/lib/kaidi-update/transactions/recovery-required*` 确认唯一目录,再替换上面
才算本次恢复结束。后台会保留真实失败状态,不会把待恢复的 processing 请求误显示成普通排队。 命令中的源路径。只有状态恢复为 `SUCCEEDED`、`CURRENT` 或确定性的终态 `FAILED`,且 `transactions/active` 已处理
完成后,才算本次恢复结束。后台会保留真实失败状态,不会把待恢复请求误显示成普通排队。
## 手工生成 Release ## 手工生成 Release
```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.19 ./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
``` ```
输出位于 `dist/release/`,包含安装脚本、签名清单、公钥、SHA-256 清单、前后端 CycloneDX SBOM、 输出位于 `dist/release/`,包含安装脚本、彻底清理脚本、签名清单、公钥、SHA-256 清单、前后端 CycloneDX
首次安装指纹文件和完整应用压缩包。 SBOM、首次安装指纹文件和完整应用压缩包。
打包脚本会把 Maven `revision` 与 npm 包版本临时绑定到 Release SemVer,构建结束后恢复工作区源文件; 打包脚本会把 Maven `revision` 与 npm 包版本临时绑定到 Release SemVer,构建结束后恢复工作区源文件;
JAR、两个 SBOM、签名清单或 tag 的版本只要有一项不一致,发布即失败。 JAR、两个 SBOM、签名清单或 tag 的版本只要有一项不一致,发布即失败。
签名清单同时绑定应用包、两份 SBOM、安装器、公钥、bootstrap 指纹和 Git 源码修订;本地脏工作区会明确记录 签名清单同时绑定应用包、两份 SBOM、安装器、公钥、bootstrap 指纹和 Git 源码修订;本地脏工作区会明确记录
`source.dirty=true`,tag 工作流只接受干净 checkout 并记录 `source.dirty=false`。 `source.dirty=true`,tag 工作流只接受干净 checkout 并记录 `source.dirty=false`。
发布命令同时在终端输出 `Trusted release public-key SHA-256` 与 `Installer SHA-256`;把这两个值写入 发布命令同时输出 Release 公钥和归档校验值;将压缩包 SHA-256 写入直链部署说明后再发布。
受控部署记录,再替换上述一键安装命令中的占位符。
@@ -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;
@@ -0,0 +1,11 @@
package com.kaidi.finance.update.api;
import java.time.Instant;
public record SystemUpdateEventView(
Instant occurredAt,
String level,
String stage,
String message
) {
}
@@ -14,6 +14,12 @@ public record SystemUpdateView(
Instant publishedAt, Instant publishedAt,
Instant checkedAt, Instant checkedAt,
Instant statusUpdatedAt, Instant statusUpdatedAt,
Long downloadedBytes,
Long totalBytes,
Long bytesPerSecond,
Integer downloadPercent,
Integer restartExpectedSeconds,
List<SystemUpdateEventView> events,
List<String> allowedActions List<String> allowedActions
) { ) {
} }
@@ -11,6 +11,7 @@ import com.kaidi.finance.shared.security.AuthorizationService;
import com.kaidi.finance.shared.security.IdentityContext; import com.kaidi.finance.shared.security.IdentityContext;
import com.kaidi.finance.update.api.SystemUpdateContracts.DownloadUpdateRequest; import com.kaidi.finance.update.api.SystemUpdateContracts.DownloadUpdateRequest;
import com.kaidi.finance.update.api.SystemUpdateContracts.InstallUpdateRequest; import com.kaidi.finance.update.api.SystemUpdateContracts.InstallUpdateRequest;
import com.kaidi.finance.update.api.SystemUpdateEventView;
import com.kaidi.finance.update.api.SystemUpdateView; import com.kaidi.finance.update.api.SystemUpdateView;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
@@ -35,15 +36,21 @@ 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;
private static final int MAX_EVENT_LOG_BYTES = 256 * 1024;
private static final int MAX_EVENT_COUNT = 120;
private static final int MAX_REDIRECTS = 3; private static final int MAX_REDIRECTS = 3;
private static final String PRERELEASE_IDENTIFIER = private static final String PRERELEASE_IDENTIFIER =
"(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)"; "(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)";
@@ -57,8 +64,8 @@ public class SystemUpdateApplicationService {
"^(.*/)?api/v1/repos/([A-Za-z0-9._-]+)/([A-Za-z0-9._-]+)/releases/(?:latest|tags/[^/?#]+)$"); "^(.*/)?api/v1/repos/([A-Za-z0-9._-]+)/([A-Za-z0-9._-]+)/releases/(?:latest|tags/[^/?#]+)$");
private static final Pattern GITEA_RELEASE_TAG = Pattern.compile("^v[0-9A-Za-z][0-9A-Za-z._+-]{0,127}$"); private static final Pattern GITEA_RELEASE_TAG = Pattern.compile("^v[0-9A-Za-z][0-9A-Za-z._+-]{0,127}$");
private static final List<String> BUSY_STATES = List.of( private static final List<String> BUSY_STATES = List.of(
"QUEUED", "DOWNLOAD_QUEUED", "INSTALL_QUEUED", "VERIFYING", "DOWNLOADING", "BACKING_UP", "QUEUED", "DOWNLOAD_QUEUED", "INSTALL_QUEUED", "PRECHECKING", "VERIFYING", "DOWNLOADING",
"INSTALLING", "RUNNING"); "BACKING_UP", "INSTALLING", "RUNNING");
private final SystemUpdateProperties properties; private final SystemUpdateProperties properties;
private final AuthorizationService authorizationService; private final AuthorizationService authorizationService;
@@ -68,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,
@@ -87,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() {
@@ -96,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;
@@ -117,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("该版本已经下载并通过校验,请确认安装");
} }
@@ -134,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("该版本尚未完成下载和签名校验");
} }
@@ -353,6 +367,10 @@ public class SystemUpdateApplicationService {
try (FileChannel channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE); try (FileChannel channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE);
FileLock ignored = channel.lock()) { FileLock ignored = channel.lock()) {
UpdateStatus currentStatus = readStatus(); UpdateStatus currentStatus = readStatus();
if ("RECOVERY_REQUIRED".equals(currentStatus.state())) {
throw new BusinessException(HttpStatus.CONFLICT, ErrorCode.COMMAND_IN_PROGRESS,
"更新服务需要人工恢复,暂不接受新的更新请求");
}
if (BUSY_STATES.contains(currentStatus.state())) { if (BUSY_STATES.contains(currentStatus.state())) {
throw new BusinessException(HttpStatus.CONFLICT, ErrorCode.COMMAND_IN_PROGRESS, throw new BusinessException(HttpStatus.CONFLICT, ErrorCode.COMMAND_IN_PROGRESS,
"已有系统更新任务正在执行"); "已有系统更新任务正在执行");
@@ -362,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("系统更新队列锁定失败");
@@ -421,7 +440,11 @@ public class SystemUpdateApplicationService {
String state = root.path("state").asText("UNKNOWN").toUpperCase(Locale.ROOT); String state = root.path("state").asText("UNKNOWN").toUpperCase(Locale.ROOT);
if (!state.matches("^[A-Z_]{2,32}$")) state = "UNKNOWN"; if (!state.matches("^[A-Z_]{2,32}$")) state = "UNKNOWN";
return new UpdateStatus(state, root.path("message").asText(""), return new UpdateStatus(state, root.path("message").asText(""),
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, "bytesPerSecond"), boundedInteger(root, "downloadPercent", 0, 100),
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);
} }
@@ -433,17 +456,60 @@ public class SystemUpdateApplicationService {
String candidateVersion = manifest == null ? status.targetVersion() : manifest.version(); String candidateVersion = manifest == null ? status.targetVersion() : manifest.version();
boolean available = candidateVersion != null && compareVersions(candidateVersion, current) > 0; boolean available = candidateVersion != null && compareVersions(candidateVersion, current) > 0;
boolean busy = BUSY_STATES.contains(status.state()); boolean busy = BUSY_STATES.contains(status.state());
boolean recoveryRequired = "RECOVERY_REQUIRED".equals(status.state());
boolean pending = hasPendingRequest(properties.requestFile().toAbsolutePath().normalize()); boolean pending = hasPendingRequest(properties.requestFile().toAbsolutePath().normalize());
boolean ready = "READY".equals(status.state()) && candidateVersion != null boolean ready = "READY".equals(status.state()) && candidateVersion != null
&& candidateVersion.equals(status.targetVersion()); && candidateVersion.equals(status.targetVersion());
List<String> actions = new ArrayList<>(); List<String> actions = new ArrayList<>();
if (enabled && !busy && !pending) actions.add("CHECK"); if (enabled && !busy && !pending && !recoveryRequired) actions.add("CHECK");
if (enabled && available && !busy && !pending && authorizationService.hasPermission("admin:update:execute")) { if (enabled && available && !busy && !pending && !recoveryRequired
&& authorizationService.hasPermission("admin:update:execute")) {
actions.add(ready ? "INSTALL" : "DOWNLOAD"); actions.add(ready ? "INSTALL" : "DOWNLOAD");
} }
return new SystemUpdateView(enabled, current, candidateVersion, return new SystemUpdateView(enabled, current, candidateVersion,
available, status.state(), status.message(), manifest == null ? null : manifest.releaseNotes(), available, status.state(), status.message(), manifest == null ? null : manifest.releaseNotes(),
manifest == null ? null : manifest.publishedAt(), lastCheckedAt, status.updatedAt(), List.copyOf(actions)); manifest == null ? null : manifest.publishedAt(), lastCheckedAt, status.updatedAt(),
status.downloadedBytes(), status.totalBytes(), status.bytesPerSecond(), status.downloadPercent(),
status.restartExpectedSeconds(), readUpdateEvents(), List.copyOf(actions));
}
private List<SystemUpdateEventView> readUpdateEvents() {
Path statusFile = properties.statusFile().toAbsolutePath().normalize();
Path parent = statusFile.getParent();
if (parent == null) return List.of();
Path eventFile = parent.resolve("events.jsonl").normalize();
if (!eventFile.startsWith(parent) || !Files.isRegularFile(eventFile, LinkOption.NOFOLLOW_LINKS)
|| Files.isSymbolicLink(eventFile)) {
return List.of();
}
try {
if (Files.size(eventFile) > MAX_EVENT_LOG_BYTES) return List.of();
List<String> lines = Files.readAllLines(eventFile, StandardCharsets.UTF_8);
int first = Math.max(0, lines.size() - MAX_EVENT_COUNT);
List<SystemUpdateEventView> events = new ArrayList<>();
for (int index = first; index < lines.size(); index++) {
String line = lines.get(index);
if (line.isBlank() || line.length() > 4096) continue;
try {
JsonNode event = objectMapper.readTree(line);
Instant occurredAt = parseInstant(event.path("occurredAt").asText(null));
String level = event.path("level").asText("INFO").toUpperCase(Locale.ROOT);
String stage = event.path("stage").asText("UNKNOWN").toUpperCase(Locale.ROOT);
String message = event.path("message").asText("");
if (occurredAt == null || !level.matches("^(INFO|WARN|ERROR)$")
|| !stage.matches("^[A-Z_]{2,32}$") || message.isBlank()) {
continue;
}
events.add(new SystemUpdateEventView(occurredAt, level, stage,
message.length() > 1000 ? message.substring(0, 1000) : message));
} catch (IOException ignored) {
// Ignore a partially written event line.
}
}
return List.copyOf(events);
} catch (IOException exception) {
return List.of();
}
} }
private boolean hasPendingRequest(Path requestFile) { private boolean hasPendingRequest(Path requestFile) {
@@ -472,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);
} }
@@ -564,6 +633,53 @@ public class SystemUpdateApplicationService {
} }
} }
private static Long nonNegativeLong(JsonNode root, String field) {
JsonNode value = root.path(field);
if (!value.canConvertToLong()) return null;
long parsed = value.asLong();
return parsed < 0 ? null : parsed;
}
private static Integer boundedInteger(JsonNode root, String field, int minimum, int maximum) {
JsonNode value = root.path(field);
if (!value.canConvertToInt()) return null;
int parsed = value.asInt();
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);
} }
@@ -580,7 +696,18 @@ public class SystemUpdateApplicationService {
String releaseNotes) { String releaseNotes) {
} }
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,
Integer downloadPercent, Integer restartExpectedSeconds,
String requestId, String action) {
private UpdateStatus(String state, String message, String targetVersion, Instant updatedAt) {
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);
}
} }
private record QueueTransition(UpdateStatus previous, UpdateStatus queued) { private record QueueTransition(UpdateStatus previous, UpdateStatus queued) {
@@ -1,3 +1,9 @@
spring:
datasource:
url: ${DB_URL}
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
server: server:
servlet: servlet:
session: session:
+1 -4
View File
@@ -17,11 +17,8 @@ spring:
resources: resources:
static-locations: ${FINANCE_STATIC_LOCATIONS:file:./public/} static-locations: ${FINANCE_STATIC_LOCATIONS:file:./public/}
profiles: profiles:
default: local default: production
datasource: datasource:
url: ${DB_URL:jdbc:mysql://127.0.0.1:3307/kaidi_finance?useUnicode=true&characterEncoding=utf8&connectionTimeZone=UTC&serverTimezone=UTC}
username: ${DB_USERNAME:kaidi}
password: ${DB_PASSWORD:kaidi_local_2026}
hikari: hikari:
maximum-pool-size: 20 maximum-pool-size: 20
minimum-idle: 2 minimum-idle: 2
@@ -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;
@@ -211,6 +213,139 @@ class SystemUpdateApplicationServiceTest {
} }
} }
@Test
void restoresDownloadMetricsAndRuntimeEventsFromUpdaterState() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
Path inbox = Files.createDirectory(tempDir.resolve("progress-inbox"));
Path statusFile = tempDir.resolve("progress-status.json");
Files.writeString(statusFile, """
{"state":"DOWNLOADING","message":"Downloading signed release","targetVersion":"1.0.0-preview.2",
"updatedAt":"2026-08-18T03:00:00Z","downloadedBytes":5242880,"totalBytes":10485760,
"bytesPerSecond":1048576,"downloadPercent":50,"restartExpectedSeconds":10}
""");
Files.writeString(tempDir.resolve("events.jsonl"), """
{"occurredAt":"2026-08-18T02:59:59Z","level":"INFO","stage":"VERIFYING","message":"签名校验开始"}
{"occurredAt":"2026-08-18T03:00:00Z","level":"INFO","stage":"DOWNLOADING","message":"更新包下载中"}
""");
SystemUpdateApplicationService service = serviceForGitea(server, inbox, statusFile);
var status = service.status();
assertEquals("DOWNLOADING", status.state());
assertEquals(5_242_880L, status.downloadedBytes());
assertEquals(10_485_760L, status.totalBytes());
assertEquals(1_048_576L, status.bytesPerSecond());
assertEquals(50, status.downloadPercent());
assertEquals(10, status.restartExpectedSeconds());
assertEquals(2, status.events().size());
assertEquals("DOWNLOADING", status.events().get(1).stage());
assertFalse(status.allowedActions().contains("DOWNLOAD"));
}
@Test
void recoveryRequiredStateBlocksEveryUpdateAction() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/api/v1/repos/ERP-Team/kaidi/releases/latest", exchange -> {
String assetUrl = "http://127.0.0.1:" + server.getAddress().getPort()
+ "/ERP-Team/kaidi/releases/download/v1.0.0-preview.2/release-manifest.json";
byte[] body = """
{"tag_name":"v1.0.0-preview.2","assets":[
{"name":"release-manifest.json","browser_download_url":"%s"}]}
""".formatted(assetUrl).getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
});
server.createContext("/ERP-Team/kaidi/releases/download/v1.0.0-preview.2/release-manifest.json",
exchange -> {
byte[] body = """
{"version":"1.0.0-preview.2","artifact":"kaidi-finance-1.0.0-preview.2.tar.gz",
"sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
""".getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
});
server.start();
Path inbox = Files.createDirectory(tempDir.resolve("recovery-inbox"));
Path statusFile = tempDir.resolve("recovery-status.json");
Files.writeString(statusFile, """
{"state":"RECOVERY_REQUIRED","message":"自动回滚未完成","targetVersion":"1.0.0-preview.2",
"updatedAt":"2026-08-18T03:10:00Z"}
""");
SystemUpdateApplicationService service = serviceForGitea(server, inbox, statusFile);
try {
var status = service.status();
assertEquals("RECOVERY_REQUIRED", status.state());
assertTrue(status.allowedActions().isEmpty());
assertThrows(BusinessException.class,
() -> service.download(new DownloadUpdateRequest("1.0.0-preview.2")));
assertFalse(Files.exists(inbox.resolve("request.json")));
} finally {
server.stop(0);
}
}
@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);
+271
View File
@@ -0,0 +1,271 @@
#!/usr/bin/env bash
set -Eeuo pipefail
umask 077
SERVICE_USER=kaidi
SERVICE_GROUP=kaidi
CONFIG_ROOT=/etc/kaidi
STATE_ROOT=/var/lib/kaidi
UPDATE_STATE_ROOT=/var/lib/kaidi-update
LOG_ROOT=/var/log/kaidi
PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9
RELEASE_API_URL=https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest
INIT_ARMED=false
INIT_COMMITTED=false
INIT_RELEASE_ROOT=
INIT_APP_ROOT=
LOG_LOCALE=${KAIDI_LOG_LOCALE:-zh-CN}
SERVICE_USER_CREATED=false
SERVICE_GROUP_CREATED=false
CREATED_PATHS=()
localize_message() {
local message=$1
[ "$LOG_LOCALE" = en ] && { printf '%s' "$message"; return; }
case "$message" in
"Run with sudo or as root") printf '请使用 sudo 或 root 运行' ;;
"Baota initialization only supports Linux") printf '宝塔初始化仅支持 Linux' ;;
"Extract the release"*) printf '请先将发布包解压到 APP_ROOT/releases/VERSION' ;;
"The supported Baota project root"*) printf '宝塔项目根目录必须是 /www/wwwroot/kaidi' ;;
"The extracted release is incomplete") printf '解压后的发布包不完整' ;;
"The extracted operations scripts are incomplete") printf '发布包中的运维脚本不完整' ;;
"The release public key is missing") printf '缺少发布公钥' ;;
"The release public-key fingerprint is invalid") printf '发布公钥指纹不匹配' ;;
"Existing user "*" has unexpected home directory "*) printf '现有用户目录不符合 Kaidi 约定:%s' "${message#Existing user }" ;;
"Existing user "*" is not a member"*) printf '现有 Kaidi 用户不属于服务用户组' ;;
"A nologin shell is required") printf '服务用户必须使用 nologin shell' ;;
*" contains a line break") printf '配置项包含换行符,已拒绝:%s' "${message%% contains a line break*}" ;;
"find is required"|"openssl is required"|"sha256sum is required") printf '缺少初始化依赖命令:%s' "${message%% is required}" ;;
"Kaidi is already initialized"*) printf 'Kaidi 已初始化,请先执行卸载流程再重新安装' ;;
"The old kaidi-finance.service"*) printf '检测到旧 kaidi-finance.service,请先执行卸载流程' ;;
"Old Kaidi update units"*) printf '检测到旧 Kaidi 更新单元,请先执行卸载流程' ;;
"The Baota current release link"*) printf '宝塔 current 发布链接已存在,请先执行卸载流程' ;;
"Port must be an integer"*) printf '端口必须是 1024 到 65535 的整数' ;;
"Initialization failed"*) printf '初始化失败,本次创建的文件已回滚,可以修正原因后重试' ;;
"Manual deployment is initialized"*) printf '宝塔手动部署初始化完成:%s' "${message#Manual deployment is initialized for Kaidi Finance }" ;;
"Create the Baota Spring Boot project"*) printf '请在宝塔创建 Spring Boot 项目,项目路径:%s' "${message#Create the Baota Spring Boot project with }" ;;
"Baota environment variables: leave empty") printf '宝塔环境变量请全部留空' ;;
"Setup code: "*) printf '安装码位置:%s' "${message#Setup code: }" ;;
*) printf '%s' "$message" ;;
esac
}
log() { printf '[kaidi-baota-init] %s\n' "$(localize_message "$*")"; }
die() { printf '[kaidi-baota-init] 错误:%s\n' "$(localize_message "$*")" >&2; exit 1; }
rollback_initialization() {
local rc=$? index path service_uid
trap - EXIT HUP INT TERM
if [ "$rc" -ne 0 ] && [ "$INIT_ARMED" = true ] && [ "$INIT_COMMITTED" != true ]; then
systemctl disable --now kaidi-update.path >/dev/null 2>&1 || true
systemctl stop kaidi-update.service >/dev/null 2>&1 || true
rm -f /etc/systemd/system/kaidi-update.service /etc/systemd/system/kaidi-update.path
systemctl daemon-reload >/dev/null 2>&1 || true
rm -f "$CONFIG_ROOT/kaidi.env" "$CONFIG_ROOT/update.env" \
"$CONFIG_ROOT/release-public.pem" \
"$UPDATE_STATE_ROOT/status.json" /root/kaidi-first-login.txt
rm -f "$INIT_APP_ROOT/current.next"
if [ -n "$INIT_RELEASE_ROOT" ] \
&& [ "$(readlink "$INIT_APP_ROOT/current" 2>/dev/null || true)" = "$INIT_RELEASE_ROOT" ]; then
rm -f "$INIT_APP_ROOT/current"
fi
rm -f "$CONFIG_ROOT"/*.next.* "$UPDATE_STATE_ROOT"/*.next.* "$STATE_ROOT/setup"/*.next.*
if [ "$SERVICE_USER_CREATED" = true ]; then
service_uid=$(id -u "$SERVICE_USER" 2>/dev/null || true)
[ -z "$service_uid" ] || userdel --force "$SERVICE_USER" >/dev/null 2>&1 || true
fi
if [ "$SERVICE_GROUP_CREATED" = true ]; then
groupdel "$SERVICE_GROUP" >/dev/null 2>&1 || true
fi
for ((index=${#CREATED_PATHS[@]} - 1; index >= 0; index--)); do
path=${CREATED_PATHS[$index]}
rmdir -- "$path" >/dev/null 2>&1 || true
done
log "Initialization failed; files created by this attempt were rolled back and the command can be retried"
fi
exit "$rc"
}
trap rollback_initialization EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
sha256_file() {
sha256sum "$1" | awk '{print $1}'
}
record_path() {
[ -e "$1" ] || [ -L "$1" ] || CREATED_PATHS+=("$1")
}
random_secret() {
openssl rand -base64 36 | tr -d '\n/+=' | cut -c1-36
}
write_env_file() {
local output=$1 temporary name value escaped
shift
temporary="${output}.next.$$"
: > "$temporary"
while [ "$#" -gt 0 ]; do
name=$1
value=$2
shift 2
case "$value" in *$'\n'*|*$'\r'*) die "$name contains a line break" ;; esac
escaped=${value//\\/\\\\}
escaped=${escaped//\"/\\\"}
printf '%s="%s"\n' "$name" "$escaped" >> "$temporary"
done
mv -f "$temporary" "$output"
}
ensure_service_identity() {
local existing_home nologin_path
if ! getent group "$SERVICE_GROUP" >/dev/null 2>&1; then
groupadd --system "$SERVICE_GROUP"
SERVICE_GROUP_CREATED=true
fi
if id "$SERVICE_USER" >/dev/null 2>&1; then
existing_home=$(getent passwd "$SERVICE_USER" | awk -F: '{print $6}')
[ "$existing_home" = "$STATE_ROOT" ] \
|| die "Existing user $SERVICE_USER has unexpected home directory $existing_home"
id -nG "$SERVICE_USER" | tr ' ' '\n' | grep -Fxq "$SERVICE_GROUP" \
|| die "Existing user $SERVICE_USER is not a member of group $SERVICE_GROUP"
return 0
fi
nologin_path=$(command -v nologin 2>/dev/null || true)
[ -n "$nologin_path" ] || nologin_path=/usr/sbin/nologin
[ -x "$nologin_path" ] || die "A nologin shell is required"
useradd --system --gid "$SERVICE_GROUP" --home-dir "$STATE_ROOT" --shell "$nologin_path" "$SERVICE_USER"
SERVICE_USER_CREATED=true
}
main() {
local script_dir release_root releases_root app_root version app_port field_key setup_code setup_hash
[ "$(id -u)" -eq 0 ] || die "Run with sudo or as root"
[ "$(uname -s)" = Linux ] || die "Baota initialization only supports Linux"
for command in find openssl sha256sum; do
command -v "$command" >/dev/null 2>&1 || die "$command is required"
done
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)
release_root=$(cd "$script_dir/.." && pwd -P)
releases_root=$(dirname "$release_root")
app_root=$(dirname "$releases_root")
[ "$(basename "$releases_root")" = releases ] \
|| die "Extract the release into APP_ROOT/releases/VERSION before initialization"
[ "$app_root" = /www/wwwroot/kaidi ] \
|| die "The supported Baota project root is /www/wwwroot/kaidi"
INIT_RELEASE_ROOT=$release_root
INIT_APP_ROOT=$app_root
version=$(tr -d '\r\n' < "$release_root/VERSION")
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.+-]+)?$ ]] \
&& [ -s "$release_root/app.jar" ] && [ -s "$release_root/public/index.html" ] \
|| die "The extracted release is incomplete"
[ -x "$release_root/ops/update.sh" ] && [ -x "$release_root/ops/baota-start.sh" ] \
&& [ -x "$release_root/ops/baota-init.sh" ] \
|| die "The extracted operations scripts are incomplete"
[ -s "$release_root/ops/release-public.pem" ] \
|| die "The release public key is missing"
[ "$(sha256_file "$release_root/ops/release-public.pem")" = "$PUBLIC_KEY_SHA256" ] \
|| die "The release public-key fingerprint is invalid"
[ ! -e "$CONFIG_ROOT/kaidi.env" ] && [ ! -e "$CONFIG_ROOT/update.env" ] \
&& [ ! -e "$STATE_ROOT/setup/locked" ] \
|| die "Kaidi is already initialized; run the published purge workflow before a fresh installation"
[ ! -e /etc/systemd/system/kaidi-finance.service ] \
|| die "The old kaidi-finance.service still exists; run the published purge workflow first"
[ ! -e /etc/systemd/system/kaidi-update.service ] \
&& [ ! -e /etc/systemd/system/kaidi-update.path ] \
|| die "Old Kaidi update units still exist; run the published purge workflow first"
[ ! -e "$app_root/current" ] && [ ! -L "$app_root/current" ] \
|| die "The Baota current release link already exists; run the published purge workflow first"
app_port=${1:-18080}
[[ "$app_port" =~ ^[0-9]{1,5}$ ]] && [ "$app_port" -ge 1024 ] && [ "$app_port" -le 65535 ] \
|| die "Port must be an integer between 1024 and 65535"
INIT_ARMED=true
ensure_service_identity
for path in "$app_root" "$releases_root" "$STATE_ROOT" "$STATE_ROOT/files" "$STATE_ROOT/tmp" \
"$LOG_ROOT" "$STATE_ROOT/setup" "$UPDATE_STATE_ROOT" "$UPDATE_STATE_ROOT/inbox" "$CONFIG_ROOT"; do
record_path "$path"
done
install -d -o root -g "$SERVICE_GROUP" -m 0750 "$app_root" "$releases_root"
install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0750 \
"$STATE_ROOT" "$STATE_ROOT/files" "$STATE_ROOT/tmp" "$LOG_ROOT"
install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0700 "$STATE_ROOT/setup"
install -d -o root -g "$SERVICE_GROUP" -m 0750 "$UPDATE_STATE_ROOT"
install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0750 "$UPDATE_STATE_ROOT/inbox"
install -d -o root -g "$SERVICE_GROUP" -m 0750 "$CONFIG_ROOT"
chown -R root:"$SERVICE_GROUP" "$release_root"
find "$release_root" -type d -exec chmod 0750 {} +
find "$release_root" -type f -exec chmod 0640 {} +
chmod 0750 "$release_root/ops/update.sh" "$release_root/ops/baota-start.sh" "$release_root/ops/baota-init.sh"
ln -sfn "$release_root" "$app_root/current.next"
mv -Tf "$app_root/current.next" "$app_root/current"
field_key=$(openssl rand -base64 32 | tr -d '\n')
setup_code="KD-$(random_secret)"
setup_hash=$(printf '%s' "$setup_code" | sha256sum | awk '{print $1}')
# The Baota process manager does not own a restartable application unit.
# Keep its updater files for diagnostics, but do not expose online update
# 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" \
SPRING_PROFILES_ACTIVE production \
SERVER_ADDRESS 127.0.0.1 \
SERVER_PORT "$app_port" \
SESSION_COOKIE_SECURE false \
DB_URL '' \
DB_USERNAME '' \
DB_PASSWORD '' \
FIELD_ENCRYPTION_KEY "$field_key" \
FILE_STORAGE_ROOT "$STATE_ROOT/files" \
FILE_STORAGE_TEMP "$STATE_ROOT/tmp" \
FILE_SCANNER_ENABLED false \
FINANCE_BOOTSTRAP_ENABLED false \
FINANCE_BOOTSTRAP_PASSWORD '' \
FINANCE_SETUP_ENABLED true \
FINANCE_SETUP_TOKEN_SHA256 "$setup_hash" \
FINANCE_SETUP_ENV_FILE "$STATE_ROOT/setup/application.env" \
FINANCE_SETUP_MARKER_FILE "$STATE_ROOT/setup/locked" \
FINANCE_SETUP_RESTART_AFTER_COMPLETE true \
FINANCE_UPDATE_ENABLED false \
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" \
KAIDI_PID_FILE "$STATE_ROOT/kaidi.pid"
chown root:"$SERVICE_GROUP" "$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"
printf '{"state":"CURRENT","message":"宝塔发布已准备","targetVersion":"%s","updatedAt":"%s"}\n' \
"$version" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$UPDATE_STATE_ROOT/status.json"
chmod 0644 "$UPDATE_STATE_ROOT/status.json"
cat > /root/kaidi-first-login.txt <<EOF
项目路径:$app_root/current
启动命令:$app_root/current/ops/baota-start.sh
反向代理目标:http://127.0.0.1:$app_port
安装向导地址:/setup
安装码:$setup_code
版本:$version
EOF
chmod 0600 /root/kaidi-first-login.txt
INIT_COMMITTED=true
log "Manual deployment is initialized for Kaidi Finance $version"
log "Create the Baota Spring Boot project with $app_root/current"
log "Baota environment variables: leave empty"
log "Setup code: /root/kaidi-first-login.txt"
}
main "$@"
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env bash
set -Eeuo pipefail
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() {
printf '[kaidi-baota] 错误:%s\n' "$(localize_message "$1")" >&2
exit 1
}
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
RELEASE_ROOT=$(cd "$SCRIPT_DIR/.." && pwd -P)
APP_JAR="$RELEASE_ROOT/app.jar"
PUBLIC_INDEX="$RELEASE_ROOT/public/index.html"
VERSION_FILE="$RELEASE_ROOT/VERSION"
CONFIG_FILE=${KAIDI_CONFIG_FILE:-/etc/kaidi/kaidi.env}
RUNTIME_ENV_FILE=${KAIDI_RUNTIME_ENV_FILE:-/var/lib/kaidi/setup/application.env}
decode_env_value() {
local raw=$1 result='' char next index=0 length
if [[ "$raw" == \"*\" && ${#raw} -ge 2 ]]; then
raw=${raw:1:${#raw}-2}
length=${#raw}
while [ "$index" -lt "$length" ]; do
char=${raw:index:1}
if [ "$char" = "\\" ] && [ $((index + 1)) -lt "$length" ]; then
next=${raw:index+1:1}
if [ "$next" = "\\" ] || [ "$next" = '"' ]; then
result+="$next"
index=$((index + 2))
continue
fi
fi
result+="$char"
index=$((index + 1))
done
printf '%s' "$result"
elif [[ "$raw" == \'*\' && ${#raw} -ge 2 ]]; then
printf '%s' "${raw:1:${#raw}-2}"
else
printf '%s' "$raw"
fi
}
is_managed_env_name() {
case "$1" in
SPRING_PROFILES_ACTIVE|SERVER_ADDRESS|SERVER_PORT|SESSION_COOKIE_SECURE|\
DB_URL|DB_USERNAME|DB_PASSWORD|FIELD_ENCRYPTION_KEY|\
FILE_STORAGE_ROOT|FILE_STORAGE_TEMP|FILE_SCANNER_ENABLED|FILE_SCANNER_HOST|FILE_SCANNER_PORT|\
FINANCE_BOOTSTRAP_ENABLED|FINANCE_BOOTSTRAP_PASSWORD|FINANCE_SETUP_ENABLED|\
FINANCE_SETUP_TOKEN_SHA256|FINANCE_SETUP_ENV_FILE|FINANCE_SETUP_MARKER_FILE|\
FINANCE_SETUP_RESTART_AFTER_COMPLETE|FINANCE_UPDATE_ENABLED|\
UPDATE_RELEASE_BASE_URL|UPDATE_RELEASE_API_URL|UPDATE_RELEASE_TOKEN|\
UPDATE_REQUEST_FILE|UPDATE_STATUS_FILE|UPDATE_CURRENT_VERSION_FILE|APP_VERSION|KAIDI_PID_FILE|KAIDI_JAVA_BIN)
return 0
;;
*) return 1 ;;
esac
}
load_env_file() {
local file=$1 line name raw value size
[ -e "$file" ] || return 0
[ -f "$file" ] && [ ! -L "$file" ] || die "Configuration file is not a regular file: $file"
[ -r "$file" ] || die "Startup user cannot read configuration file: $file"
size=$(wc -c < "$file" | tr -d '[:space:]')
[ "$size" -le 65536 ] || die "Configuration file is too large: $file"
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in ''|'#'*) continue ;; esac
[[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]] \
|| die "Configuration file contains an invalid line: $file"
name=${BASH_REMATCH[1]}
raw=${BASH_REMATCH[2]}
is_managed_env_name "$name" || continue
[ -n "${!name:-}" ] && continue
value=$(decode_env_value "$raw")
printf -v "$name" '%s' "$value"
export "${name?}"
done < "$file"
}
[ -s "$APP_JAR" ] || die "app.jar is missing from $RELEASE_ROOT"
[ -s "$PUBLIC_INDEX" ] || die "public/index.html is missing from $RELEASE_ROOT"
[ -s "$VERSION_FILE" ] || die "VERSION is missing from $RELEASE_ROOT"
# The setup-generated runtime file has precedence over the base file. Non-empty
# variables supplied by Baota have precedence over both; empty panel fields do not
# mask the protected configuration written by the setup wizard. The systemd unit
# loads both files itself as root, then sets KAIDI_ENV_PRELOADED so the service
# user never needs traversal permission for /etc/kaidi (which is intentionally
# root-only).
if [ "${KAIDI_ENV_PRELOADED:-false}" != true ]; then
load_env_file "$RUNTIME_ENV_FILE"
load_env_file "$CONFIG_FILE"
fi
JAVA_BIN=${KAIDI_JAVA_BIN:-}
if [ -z "$JAVA_BIN" ] && [ -n "${JAVA_HOME:-}" ]; then
JAVA_BIN="$JAVA_HOME/bin/java"
fi
if [ -z "$JAVA_BIN" ] && [ -x /opt/kaidi/runtime/java/bin/java ]; then
# Compatibility for installations created before external Java paths were persisted.
JAVA_BIN=/opt/kaidi/runtime/java/bin/java
fi
if [ -z "$JAVA_BIN" ]; then
JAVA_BIN=$(command -v java 2>/dev/null || true)
fi
[ -n "$JAVA_BIN" ] || die "Java 17 or newer was not found"
if [[ "$JAVA_BIN" == */* ]]; then
[ -x "$JAVA_BIN" ] || die "Java executable is not available at $JAVA_BIN"
else
command -v "$JAVA_BIN" >/dev/null 2>&1 || die "Java executable $JAVA_BIN is not available"
fi
JAVA_VERSION=$("$JAVA_BIN" -version 2>&1 | sed -n 's/.*version "\([0-9][0-9]*\).*/\1/p' | head -n 1)
[[ "$JAVA_VERSION" =~ ^[0-9]+$ ]] && [ "$JAVA_VERSION" -ge 17 ] \
|| die "Java 17 or newer is required"
export SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE:-production}
export SERVER_ADDRESS=${SERVER_ADDRESS:-127.0.0.1}
export SERVER_PORT=${SERVER_PORT:-18080}
export FINANCE_SETUP_ENABLED=${FINANCE_SETUP_ENABLED:-false}
export FINANCE_BOOTSTRAP_ENABLED=${FINANCE_BOOTSTRAP_ENABLED:-false}
export FINANCE_UPDATE_ENABLED=${FINANCE_UPDATE_ENABLED:-true}
export FILE_SCANNER_ENABLED=${FILE_SCANNER_ENABLED:-false}
export FINANCE_STATIC_LOCATIONS=${FINANCE_STATIC_LOCATIONS:-file:$RELEASE_ROOT/public/}
export UPDATE_CURRENT_VERSION_FILE=${UPDATE_CURRENT_VERSION_FILE:-$VERSION_FILE}
export APP_VERSION=${APP_VERSION:-$(tr -d '\r\n' < "$VERSION_FILE")}
export KAIDI_PID_FILE=${KAIDI_PID_FILE:-/var/lib/kaidi/kaidi.pid}
if [ "$FINANCE_SETUP_ENABLED" != true ]; then
for name in DB_URL DB_USERNAME DB_PASSWORD FIELD_ENCRYPTION_KEY FILE_STORAGE_ROOT FILE_STORAGE_TEMP; do
[ -n "${!name:-}" ] || die "$name is missing from the protected Kaidi configuration"
done
for directory in "$FILE_STORAGE_ROOT" "$FILE_STORAGE_TEMP"; do
[ -d "$directory" ] || die "Storage directory $directory does not exist"
[ -w "$directory" ] || die "Startup user cannot write storage directory $directory"
done
fi
case "$KAIDI_PID_FILE" in /*) ;; *) die "KAIDI_PID_FILE must be an absolute path" ;; esac
[ -d "$(dirname "$KAIDI_PID_FILE")" ] || die "PID directory does not exist"
[ -w "$(dirname "$KAIDI_PID_FILE")" ] || die "Startup user cannot write the PID directory"
[ ! -L "$KAIDI_PID_FILE" ] || die "PID file must not be a symbolic link"
PID_START_TIME=$(awk '{print $22}' "/proc/$$/stat" 2>/dev/null || true)
[[ "$PID_START_TIME" =~ ^[0-9]+$ ]] || die "Process start time could not be read from /proc"
PID_TEMP="${KAIDI_PID_FILE}.next.$$"
printf '%s %s\n' "$$" "$PID_START_TIME" > "$PID_TEMP"
chmod 0640 "$PID_TEMP"
mv -f "$PID_TEMP" "$KAIDI_PID_FILE"
cd "$RELEASE_ROOT"
exec "$JAVA_BIN" -XX:MaxRAMPercentage=70 -Dfile.encoding=UTF-8 \
"-Dkaidi.release.path=$RELEASE_ROOT" "-Dkaidi.release.version=$APP_VERSION" \
-jar "$APP_JAR"
+11
View File
@@ -0,0 +1,11 @@
# Baota project environment variables are intentionally empty.
# Do not paste database passwords or placeholder values into the panel.
# ops/baota-start.sh loads the protected files created by ops/baota-init.sh:
# /etc/kaidi/kaidi.env
# /var/lib/kaidi/setup/application.env (after the setup wizard completes)
#
# Optional non-secret overrides may be added deliberately, for example:
# SERVER_PORT=18080
# SESSION_COOKIE_SECURE=true
# Never override DB_URL, DB_USERNAME, DB_PASSWORD, FIELD_ENCRYPTION_KEY,
# FINANCE_SETUP_TOKEN_SHA256, or the update paths from the panel.
+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
+6 -6
View File
@@ -1,26 +1,26 @@
SPRING_PROFILES_ACTIVE=production SPRING_PROFILES_ACTIVE=production
SERVER_PORT=18080 SERVER_PORT=18080
SERVER_ADDRESS=127.0.0.1 SERVER_ADDRESS=127.0.0.1
SESSION_COOKIE_SECURE=false SESSION_COOKIE_SECURE=true
DB_URL=jdbc:mysql://127.0.0.1:3307/kaidi_finance?useUnicode=true&characterEncoding=utf8&connectionTimeZone=UTC&serverTimezone=UTC DB_URL=jdbc:mysql://DB_HOST:DB_PORT/DB_NAME?useUnicode=true&characterEncoding=utf8&connectionTimeZone=UTC&serverTimezone=UTC
DB_USERNAME=kaidi DB_USERNAME=DB_USERNAME
DB_PASSWORD=CHANGE_ME DB_PASSWORD=CHANGE_ME
FIELD_ENCRYPTION_KEY=BASE64_32_BYTE_KEY FIELD_ENCRYPTION_KEY=BASE64_32_BYTE_KEY
FILE_STORAGE_ROOT=/var/lib/kaidi/files FILE_STORAGE_ROOT=/var/lib/kaidi/files
FILE_STORAGE_TEMP=/var/lib/kaidi/tmp FILE_STORAGE_TEMP=/var/lib/kaidi/tmp
FILE_SCANNER_ENABLED=false FILE_SCANNER_ENABLED=true
FINANCE_BOOTSTRAP_ENABLED=false FINANCE_BOOTSTRAP_ENABLED=false
FINANCE_BOOTSTRAP_PASSWORD= FINANCE_BOOTSTRAP_PASSWORD=
APP_VERSION=1.0.0-preview.9 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.
UPDATE_RELEASE_BASE_URL= UPDATE_RELEASE_BASE_URL=
UPDATE_RELEASE_API_URL=https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest UPDATE_RELEASE_API_URL=https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest
UPDATE_RELEASE_TOKEN=READ_ONLY_GITEA_TOKEN UPDATE_RELEASE_TOKEN=
UPDATE_REQUEST_FILE=/var/lib/kaidi-update/inbox/request.json UPDATE_REQUEST_FILE=/var/lib/kaidi-update/inbox/request.json
UPDATE_STATUS_FILE=/var/lib/kaidi-update/status.json UPDATE_STATUS_FILE=/var/lib/kaidi-update/status.json
+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:-bc9001197af843dc323907a98023064ccc4f184e000543d2086b8a6f8161dbc7} 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"
+291 -80
View File
@@ -18,22 +18,145 @@ SERVER_ADDRESS=${KAIDI_SERVER_ADDRESS:-127.0.0.1}
PROXY_TARGET= PROXY_TARGET=
TRUSTED_PUBLIC_KEY_SHA256=${KAIDI_RELEASE_PUBLIC_KEY_SHA256:-807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9} TRUSTED_PUBLIC_KEY_SHA256=${KAIDI_RELEASE_PUBLIC_KEY_SHA256:-807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9}
REINSTALL=${KAIDI_REINSTALL:-false} REINSTALL=${KAIDI_REINSTALL:-false}
SETUP_WIZARD=${KAIDI_SETUP_WIZARD:-true} if [ "${KAIDI_SETUP_WIZARD+x}" = x ]; then
SETUP_WIZARD=$KAIDI_SETUP_WIZARD
elif [ "$REINSTALL" = true ]; then
SETUP_WIZARD=false
else
SETUP_WIZARD=true
fi
WORK_DIR= WORK_DIR=
RELEASE_AUTH_HEADER_FILE= RELEASE_AUTH_HEADER_FILE=
INSTALL_TRANSACTION_ARMED=false INSTALL_TRANSACTION_ARMED=false
INSTALL_COMMITTED=false INSTALL_COMMITTED=false
RELEASE_DIR= RELEASE_DIR=
BACKUP_DIR= BACKUP_DIR=
JAVA_BIN=
JAVA_STAGED_DIR= JAVA_STAGED_DIR=
JAVA_PREVIOUS_DIR= JAVA_PREVIOUS_DIR=
JAVA_ACTIVATED=false 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}'
@@ -75,6 +198,15 @@ preflight_host() {
esac esac
[ "$host_bits" = "$expected_bits" ] \ [ "$host_bits" = "$expected_bits" ] \
|| die "CPU architecture and userspace word size do not match: $HOST_ARCH/$host_bits-bit" || die "CPU architecture and userspace word size do not match: $HOST_ARCH/$host_bits-bit"
if [ "$HOST_ARCH" = x86 ]; then
getconf GNU_LIBC_VERSION >/dev/null 2>&1 \
|| die "32-bit Linux deployment requires glibc; the downloaded i686 Java runtime is not compatible with musl"
if ! for loader in /lib/ld-linux.so.2 /lib32/ld-linux.so.2 /lib/i386-linux-gnu/ld-linux.so.2; do
[ -x "$loader" ] && break
done; then
die "32-bit Linux deployment requires the glibc loader /lib/ld-linux.so.2"
fi
fi
[[ "$TRUSTED_PUBLIC_KEY_SHA256" =~ ^[0-9A-Fa-f]{64}$ ]] \ [[ "$TRUSTED_PUBLIC_KEY_SHA256" =~ ^[0-9A-Fa-f]{64}$ ]] \
|| die "Set KAIDI_RELEASE_PUBLIC_KEY_SHA256 to the trusted release public-key SHA-256" || die "Set KAIDI_RELEASE_PUBLIC_KEY_SHA256 to the trusted release public-key SHA-256"
case "$REINSTALL" in case "$REINSTALL" in
@@ -192,15 +324,34 @@ 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() {
local command_name
for command_name in \
awk bash chmod chown cp curl cut date find getconf getent grep gzip head id install jq journalctl \
ln mktemp mv nologin openssl readlink rm runuser sed sha256sum sleep sort systemctl \
systemd-analyze tar tr useradd usermod groupadd wc flock setsid; do
command -v "$command_name" >/dev/null 2>&1 \
|| die "Required command is missing after dependency preparation: $command_name"
done
} }
azul_arch() { azul_arch() {
@@ -227,14 +378,8 @@ java_arch_matches_host() {
esac esac
} }
system_java_home() { java_home_from_bin() {
local java_bin resolved_java java_major home local java_bin=$1 resolved_java java_major home
if [ -n "${KAIDI_JAVA_HOME:-}" ]; then
home=${KAIDI_JAVA_HOME%/}
java_bin="$home/bin/java"
else
java_bin=$(command -v java 2>/dev/null || true)
fi
[ -x "$java_bin" ] || return 1 [ -x "$java_bin" ] || return 1
java_major=$( java_major=$(
"$java_bin" -version 2>&1 \ "$java_bin" -version 2>&1 \
@@ -252,15 +397,44 @@ system_java_home() {
printf '%s\n' "$home" printf '%s\n' "$home"
} }
system_java_home() {
local candidate home
if [ -n "${KAIDI_JAVA_HOME:-}" ]; then
candidate="${KAIDI_JAVA_HOME%/}/bin/java"
java_home_from_bin "$candidate" && return 0
return 1
fi
candidate=$(command -v java 2>/dev/null || true)
if [ -n "$candidate" ] && home=$(java_home_from_bin "$candidate"); then
printf '%s\n' "$home"
return 0
fi
# Baota commonly keeps JDKs outside PATH. Prefer an existing Java 17 there
# before consulting the remote Azul metadata endpoint.
for candidate in \
/www/server/java/*/bin/java \
/www/server/java/*/jre/bin/java \
/usr/lib/jvm/*/bin/java \
/usr/java/*/bin/java; do
[ -x "$candidate" ] || continue
if home=$(java_home_from_bin "$candidate"); then
printf '%s\n' "$home"
return 0
fi
done
return 1
}
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_STAGED_DIR=
if system_home=$(system_java_home); then if system_home=$(system_java_home); then
JAVA_STAGED_DIR="$WORK_DIR/java.next" JAVA_BIN="$system_home/bin/java"
mkdir -p "$JAVA_STAGED_DIR" java_line=$("$JAVA_BIN" -version 2>&1 | head -n 1) \
log "Copying existing Java 17 runtime from $system_home into the managed application directory" || die "The existing Java runtime cannot start on this host"
COPYFILE_DISABLE=1 cp -R "$system_home/." "$JAVA_STAGED_DIR/" java_version=$(printf '%s\n' "$java_line" | sed -n 's/.*version "\([0-9][0-9.]*\).*/\1/p')
java_line=$("$JAVA_STAGED_DIR/bin/java" -version 2>&1 | head -n 1) log "使用现有 Java 运行时:$system_home(版本 ${java_version:-未知})"
log "Local Java verified: $java_line"
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"
@@ -284,11 +458,15 @@ prepare_java() {
tar -xzf "$WORK_DIR/java.tar.gz" --strip-components=1 -C "$JAVA_STAGED_DIR" tar -xzf "$WORK_DIR/java.tar.gz" --strip-components=1 -C "$JAVA_STAGED_DIR"
java_arch_matches_host "$JAVA_STAGED_DIR/bin/java" \ java_arch_matches_host "$JAVA_STAGED_DIR/bin/java" \
|| die "Downloaded Java runtime architecture does not match $HOST_ARCH" || die "Downloaded Java runtime architecture does not match $HOST_ARCH"
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) \
log "Downloaded Java verified: $java_line" || die "The downloaded Java runtime cannot start; verify glibc and the host architecture"
JAVA_BIN="$APP_ROOT/runtime/java/bin/java"
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() {
[ -n "$JAVA_STAGED_DIR" ] || return 0
mkdir -p "$APP_ROOT/runtime" mkdir -p "$APP_ROOT/runtime"
JAVA_PREVIOUS_DIR="$WORK_DIR/java.previous" JAVA_PREVIOUS_DIR="$WORK_DIR/java.previous"
if [ -e "$APP_ROOT/runtime/java" ] || [ -L "$APP_ROOT/runtime/java" ]; then if [ -e "$APP_ROOT/runtime/java" ] || [ -L "$APP_ROOT/runtime/java" ]; then
@@ -305,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}')
@@ -319,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 \
@@ -338,7 +534,7 @@ secure_release_tree() {
chown -R root:"$SERVICE_GROUP" "$release_dir" chown -R root:"$SERVICE_GROUP" "$release_dir"
find "$release_dir" -type d -exec chmod 0750 {} + find "$release_dir" -type d -exec chmod 0750 {} +
find "$release_dir" -type f -exec chmod 0640 {} + find "$release_dir" -type f -exec chmod 0640 {} +
chmod 0750 "$release_dir/ops/update.sh" chmod 0750 "$release_dir/ops/update.sh" "$release_dir/ops/baota-start.sh"
} }
restore_security_contexts() { restore_security_contexts() {
@@ -355,10 +551,12 @@ verify_service_access() {
runuser -u "$SERVICE_USER" -- sh -c \ runuser -u "$SERVICE_USER" -- sh -c \
'cd "$1" && test -r app.jar && test -r public/index.html' sh "$APP_ROOT/current" \ 'cd "$1" && test -r app.jar && test -r public/index.html' sh "$APP_ROOT/current" \
|| die "$SERVICE_USER cannot traverse or read the active release" || die "$SERVICE_USER cannot traverse or read the active release"
runuser -u "$SERVICE_USER" -- test -x "$APP_ROOT/runtime/java/bin/java" \ [ -n "$JAVA_BIN" ] && [ -x "$JAVA_BIN" ] \
|| die "$SERVICE_USER cannot execute the managed Java runtime" || die "The selected Java runtime is unavailable at $JAVA_BIN"
runuser -u "$SERVICE_USER" -- "$APP_ROOT/runtime/java/bin/java" -version >/dev/null 2>&1 \ runuser -u "$SERVICE_USER" -- test -x "$JAVA_BIN" \
|| die "The managed Java runtime cannot execute as $SERVICE_USER" || die "$SERVICE_USER cannot execute the selected Java runtime"
runuser -u "$SERVICE_USER" -- "$JAVA_BIN" -version >/dev/null 2>&1 \
|| die "The selected Java runtime cannot execute as $SERVICE_USER"
runuser -u "$SERVICE_USER" -- test -w "$STATE_ROOT/files" \ runuser -u "$SERVICE_USER" -- test -w "$STATE_ROOT/files" \
|| die "$SERVICE_USER cannot write the file-storage directory" || die "$SERVICE_USER cannot write the file-storage directory"
runuser -u "$SERVICE_USER" -- test -w "$UPDATE_STATE_ROOT/inbox" \ runuser -u "$SERVICE_USER" -- test -w "$UPDATE_STATE_ROOT/inbox" \
@@ -449,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
@@ -506,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
@@ -533,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%%:*}}"
@@ -540,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##*:}
@@ -549,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=$(command -v mysql || command -v mariadb || true) [ -n "$DB_USERNAME" ] || die "KAIDI_DB_USERNAME is empty"
[ -n "$client" ] || die "A MySQL command-line client is required" [ -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() {
@@ -755,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
@@ -773,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
@@ -797,9 +1001,11 @@ 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
preflight_runtime_commands
BASE=${RELEASE_BASE_URL%/} BASE=${RELEASE_BASE_URL%/}
WORK_DIR=$(mktemp -d) WORK_DIR=$(mktemp -d)
@@ -842,6 +1048,8 @@ find "$WORK_DIR/extracted" -type l -print -quit | grep -q . \
[ -s "$WORK_DIR/extracted/public/index.html" ] || die "Release frontend is missing" [ -s "$WORK_DIR/extracted/public/index.html" ] || die "Release frontend is missing"
[ "$(cat "$WORK_DIR/extracted/VERSION")" = "$VERSION" ] || die "Release version mismatch" [ "$(cat "$WORK_DIR/extracted/VERSION")" = "$VERSION" ] || die "Release version mismatch"
[ -x "$WORK_DIR/extracted/ops/update.sh" ] || die "Release updater is missing" [ -x "$WORK_DIR/extracted/ops/update.sh" ] || die "Release updater is missing"
[ -x "$WORK_DIR/extracted/ops/baota-start.sh" ] || die "Baota Spring Boot launcher is missing"
bash -n "$WORK_DIR/extracted/ops/baota-start.sh" || die "Baota Spring Boot launcher is invalid"
configure_database configure_database
preflight_database preflight_database
@@ -896,6 +1104,7 @@ if [ "$SETUP_WIZARD" = true ]; then
fi fi
write_env_file_preserving_unknown "$CONFIG_ROOT/kaidi.env" \ write_env_file_preserving_unknown "$CONFIG_ROOT/kaidi.env" \
SPRING_PROFILES_ACTIVE production \ SPRING_PROFILES_ACTIVE production \
KAIDI_JAVA_BIN "$JAVA_BIN" \
SERVER_PORT "$APP_PORT" \ SERVER_PORT "$APP_PORT" \
SERVER_ADDRESS "$SERVER_ADDRESS" \ SERVER_ADDRESS "$SERVER_ADDRESS" \
SESSION_COOKIE_SECURE "$SESSION_COOKIE_SECURE" \ SESSION_COOKIE_SECURE "$SESSION_COOKIE_SECURE" \
@@ -933,14 +1142,16 @@ write_env_file "$CONFIG_ROOT/update.env" \
KAIDI_APP_ROOT "$APP_ROOT" \ KAIDI_APP_ROOT "$APP_ROOT" \
KAIDI_UPDATE_STATE_ROOT "$UPDATE_STATE_ROOT" \ KAIDI_UPDATE_STATE_ROOT "$UPDATE_STATE_ROOT" \
KAIDI_SERVICE_NAME kaidi-finance.service \ KAIDI_SERVICE_NAME kaidi-finance.service \
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_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
@@ -966,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
+322
View File
@@ -0,0 +1,322 @@
#!/usr/bin/env bash
set -Eeuo pipefail
umask 077
APP_ROOT=/opt/kaidi
BAOTA_ROOT=/www/wwwroot/kaidi
CONFIG_ROOT=/etc/kaidi
STATE_ROOT=/var/lib/kaidi
UPDATE_STATE_ROOT=/var/lib/kaidi-update
LOG_ROOT=/var/log/kaidi
FIRST_LOGIN_FILE=/root/kaidi-first-login.txt
BACKUP_ROOT=${KAIDI_PURGE_BACKUP_ROOT:-/root/kaidi-reinstall-backups}
CONFIRMATION=${KAIDI_PURGE_CONFIRM:-}
REQUIRED_CONFIRMATION=DELETE_LOCAL_KAIDI_INSTALLATION
SERVICE_USER=kaidi
SERVICE_GROUP=kaidi
BACKUP_ARCHIVE=
LOG_LOCALE=${KAIDI_LOG_LOCALE:-zh-CN}
DELETE_RECOVERY_BACKUP=${KAIDI_PURGE_DELETE_BACKUP:-false}
localize_message() {
local message=$1
[ "$LOG_LOCALE" = en ] && { printf '%s' "$message"; return; }
case "$message" in
"Run with sudo or as root") printf '请使用 sudo 或 root 运行' ;;
"The purge script only supports Linux") printf '卸载程序仅支持 Linux' ;;
"Set KAIDI_PURGE_CONFIRM="*) printf '请设置确认变量:KAIDI_PURGE_CONFIRM=%s' "${message#Set KAIDI_PURGE_CONFIRM=}" ;;
"KAIDI_PURGE_BACKUP_ROOT must be an absolute path") printf 'KAIDI_PURGE_BACKUP_ROOT 必须是绝对路径' ;;
"KAIDI_PURGE_DELETE_BACKUP must be true or false") printf 'KAIDI_PURGE_DELETE_BACKUP 只能是 true 或 false' ;;
"The recovery backup must be outside"*) printf '恢复备份目录必须位于 Kaidi 管理目录之外' ;;
"The recovery backup root must not be a symbolic link") printf '恢复备份目录不能是符号链接' ;;
"Failed to stop "*) printf '停止服务失败:%s' "${message#Failed to stop }" ;;
"Stopping processes owned by"*) printf '正在停止专用服务账号进程:%s' "${message##*: }" ;;
"Stopping remaining Kaidi processes: "*) printf '正在停止剩余 Kaidi 进程:%s' "${message#Stopping remaining Kaidi processes: }" ;;
"A process manager restarted"*) printf '检测到宝塔等进程管理器正在重新拉起 Kaidi;请先停止并删除宝塔中的 Kaidi 项目,再重试卸载' ;;
"No local configuration or data"*) printf '没有需要备份的本地配置或数据' ;;
"Creating a root-only recovery backup at "*) printf '正在创建仅 root 可读的恢复备份:%s' "${message#Creating a root-only recovery backup at }" ;;
"Failed to remove the dedicated"*) printf '删除 Kaidi 专用服务账号失败' ;;
"Processes owned by the removed"*) printf '删除服务账号后仍有进程运行' ;;
"Keeping pre-existing user"*) printf '保留预先存在的用户:%s' "${message#Keeping pre-existing user }" ;;
"Keeping group "*) printf '保留仍被其他账号使用的用户组:%s' "${message#Keeping group }" ;;
"External MySQL data and reverse-proxy configuration will not be modified") printf '不会修改外部 MySQL 数据和反向代理配置' ;;
"Local Kaidi installation state has been removed") printf '本地 Kaidi 安装文件、服务和运行状态已删除' ;;
"Recovery backup: "*) printf '恢复备份:%s' "${message#Recovery backup: }" ;;
"Use a new empty MySQL database for the next installation") printf '重新安装时请使用新的空 MySQL 数据库' ;;
*) printf '%s' "$message" ;;
esac
}
log() { printf '[kaidi-purge] %s\n' "$(localize_message "$*")"; }
die() { printf '[kaidi-purge] 错误:%s\n' "$(localize_message "$*")" >&2; exit 1; }
systemd_available() {
command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]
}
validate_inputs() {
[ "$(id -u)" -eq 0 ] || die "Run with sudo or as root"
[ "$(uname -s)" = Linux ] || die "The purge script only supports Linux"
[ "$CONFIRMATION" = "$REQUIRED_CONFIRMATION" ] \
|| die "Set KAIDI_PURGE_CONFIRM=$REQUIRED_CONFIRMATION to confirm local removal"
case "$BACKUP_ROOT" in
/*) ;;
*) die "KAIDI_PURGE_BACKUP_ROOT must be an absolute path" ;;
esac
case "$BACKUP_ROOT/" in
"$APP_ROOT/"*|"$BAOTA_ROOT/"*|"$CONFIG_ROOT/"*|"$STATE_ROOT/"*|\
"$UPDATE_STATE_ROOT/"*|"$LOG_ROOT/"*)
die "The recovery backup must be outside every managed Kaidi directory"
;;
esac
[ ! -L "$BACKUP_ROOT" ] || die "The recovery backup root must not be a symbolic link"
case "$DELETE_RECOVERY_BACKUP" in
true|false) ;;
*) die "KAIDI_PURGE_DELETE_BACKUP must be true or false" ;;
esac
}
stop_systemd_units() {
local unit
systemd_available || return 0
for unit in kaidi-update.path kaidi-update.service kaidi-finance.service; do
if systemctl cat "$unit" >/dev/null 2>&1; then
systemctl stop "$unit" >/dev/null 2>&1 \
|| die "Failed to stop $unit"
systemctl disable "$unit" >/dev/null 2>&1 || true
fi
done
}
related_pids() {
local proc pid command_line
for proc in /proc/[0-9]*; do
[ -r "$proc/cmdline" ] || continue
pid=${proc##*/}
[ "$pid" != "$$" ] && [ "$pid" != "$PPID" ] || continue
command_line=$(tr '\000' ' ' < "$proc/cmdline" 2>/dev/null || true)
case "$command_line" in
*"$APP_ROOT/"*|*"$BAOTA_ROOT/"*) printf '%s\n' "$pid" ;;
esac
done
}
managed_service_account() {
local entry home shell
entry=$(getent passwd "$SERVICE_USER" 2>/dev/null || true)
[ -n "$entry" ] || return 1
home=$(printf '%s\n' "$entry" | awk -F: '{print $6}')
shell=$(printf '%s\n' "$entry" | awk -F: '{print $7}')
case "$home:$shell" in
"$STATE_ROOT:"*/nologin|"$STATE_ROOT:"*/false) return 0 ;;
*) return 1 ;;
esac
}
service_user_pids() {
local uid=$1 proc pid owner_uid
for proc in /proc/[0-9]*; do
[ -d "$proc" ] || continue
pid=${proc##*/}
owner_uid=$(stat -c '%u' "$proc" 2>/dev/null || true)
[ "$owner_uid" = "$uid" ] && printf '%s\n' "$pid"
done
}
terminate_uid_processes() {
local uid=$1 deadline
local -a pids=()
mapfile -t pids < <(service_user_pids "$uid")
if [ "${#pids[@]}" -gt 0 ]; then
log "Stopping processes owned by the dedicated $SERVICE_USER account: ${pids[*]}"
kill -TERM "${pids[@]}" 2>/dev/null || true
fi
deadline=$((SECONDS + 5))
while [ "$SECONDS" -lt "$deadline" ]; do
mapfile -t pids < <(service_user_pids "$uid")
[ "${#pids[@]}" -gt 0 ] || return 0
sleep 1
done
kill -KILL "${pids[@]}" 2>/dev/null || true
sleep 1
mapfile -t pids < <(service_user_pids "$uid")
[ "${#pids[@]}" -eq 0 ]
}
all_pids_owned_by_service_user() {
local service_uid pid owner_uid
managed_service_account || return 1
service_uid=$(id -u "$SERVICE_USER")
for pid in "$@"; do
[ -d "/proc/$pid" ] || continue
owner_uid=$(stat -c '%u' "/proc/$pid" 2>/dev/null || true)
[ "$owner_uid" = "$service_uid" ] || return 1
done
}
stop_managed_processes() {
local allow_service_restart=${1:-false} deadline
local -a pids=()
mapfile -t pids < <(related_pids)
if [ "${#pids[@]}" -gt 0 ]; then
log "Stopping remaining Kaidi processes: ${pids[*]}"
kill -TERM "${pids[@]}" 2>/dev/null || true
fi
deadline=$((SECONDS + 5))
while [ "$SECONDS" -lt "$deadline" ]; do
mapfile -t pids < <(related_pids)
[ "${#pids[@]}" -gt 0 ] || return 0
sleep 1
done
kill -KILL "${pids[@]}" 2>/dev/null || true
sleep 1
mapfile -t pids < <(related_pids)
[ "${#pids[@]}" -eq 0 ] && return 0
if [ "$allow_service_restart" = true ] && all_pids_owned_by_service_user "${pids[@]}"; then
log "A process manager restarted the dedicated $SERVICE_USER account; forced account cleanup will stop it"
return 0
fi
die "A process manager is restarting Kaidi; remove the Kaidi project from Baota and run this command again"
}
create_recovery_backup() {
local path relative temporary timestamp
local -a paths=()
for path in \
"$CONFIG_ROOT" \
"$STATE_ROOT" \
"$UPDATE_STATE_ROOT/backups" \
"$UPDATE_STATE_ROOT/failed" \
"$UPDATE_STATE_ROOT/transactions" \
"$FIRST_LOGIN_FILE"; do
if [ -e "$path" ] || [ -L "$path" ]; then
relative=${path#/}
paths+=("$relative")
fi
done
if [ "${#paths[@]}" -eq 0 ]; then
log "No local configuration or data needs a recovery backup"
return 0
fi
install -d -o root -g root -m 0700 "$BACKUP_ROOT"
timestamp=$(date -u +%Y%m%dT%H%M%SZ)
BACKUP_ARCHIVE="$BACKUP_ROOT/kaidi-local-state-$timestamp.tar.gz"
temporary="$BACKUP_ARCHIVE.next.$$"
log "Creating a root-only recovery backup at $BACKUP_ARCHIVE"
tar -czf "$temporary" -C / -- "${paths[@]}"
tar -tzf "$temporary" >/dev/null
chmod 0600 "$temporary"
mv -f "$temporary" "$BACKUP_ARCHIVE"
}
remove_systemd_units() {
local unit
rm -rf \
/etc/systemd/system/kaidi-finance.service.d \
/etc/systemd/system/kaidi-update.service.d \
/etc/systemd/system/kaidi-update.path.d
rm -f \
/etc/systemd/system/kaidi-finance.service \
/etc/systemd/system/kaidi-update.service \
/etc/systemd/system/kaidi-update.path \
/etc/systemd/system/multi-user.target.wants/kaidi-finance.service \
/etc/systemd/system/multi-user.target.wants/kaidi-update.path
systemd_available || return 0
systemctl daemon-reload
for unit in kaidi-finance.service kaidi-update.service kaidi-update.path; do
systemctl reset-failed "$unit" >/dev/null 2>&1 || true
done
}
remove_managed_paths() {
rm -rf -- \
"$APP_ROOT" \
"$BAOTA_ROOT" \
"$CONFIG_ROOT" \
"$STATE_ROOT" \
"$UPDATE_STATE_ROOT" \
"$LOG_ROOT"
rm -f -- "$FIRST_LOGIN_FILE"
}
remove_download_archives() {
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() {
local entry home shell service_uid group_entry gid members primary_users
entry=$(getent passwd "$SERVICE_USER" 2>/dev/null || true)
if [ -n "$entry" ]; then
home=$(printf '%s\n' "$entry" | awk -F: '{print $6}')
shell=$(printf '%s\n' "$entry" | awk -F: '{print $7}')
case "$home:$shell" in
"$STATE_ROOT:"*/nologin|"$STATE_ROOT:"*/false)
service_uid=$(id -u "$SERVICE_USER")
terminate_uid_processes "$service_uid" || true
if ! userdel --force "$SERVICE_USER"; then
terminate_uid_processes "$service_uid" || true
userdel --force "$SERVICE_USER" \
|| die "Failed to remove the dedicated $SERVICE_USER service account"
fi
terminate_uid_processes "$service_uid" \
|| die "Processes owned by the removed $SERVICE_USER account are still running"
rm -rf "/run/user/$service_uid"
;;
*)
log "Keeping pre-existing user $SERVICE_USER because its home or shell is not Kaidi-managed"
;;
esac
fi
group_entry=$(getent group "$SERVICE_GROUP" 2>/dev/null || true)
[ -n "$group_entry" ] || return 0
gid=$(printf '%s\n' "$group_entry" | awk -F: '{print $3}')
members=$(printf '%s\n' "$group_entry" | awk -F: '{print $4}')
primary_users=$(getent passwd | awk -F: -v gid="$gid" '$4 == gid { print $1 }')
if [ -z "$members" ] && [ -z "$primary_users" ]; then
groupdel "$SERVICE_GROUP" \
|| die "Failed to remove the dedicated $SERVICE_GROUP service group"
else
log "Keeping group $SERVICE_GROUP because another account still uses it"
fi
}
main() {
validate_inputs
log "External MySQL data and reverse-proxy configuration will not be modified"
stop_systemd_units
stop_managed_processes true
create_recovery_backup
remove_systemd_units
remove_managed_paths
remove_download_archives
remove_service_identity
stop_managed_processes false
verify_managed_paths_removed
log "Local Kaidi installation state has been removed"
if [ -n "$BACKUP_ARCHIVE" ]; then
log "Recovery backup: $BACKUP_ARCHIVE"
if [ "$DELETE_RECOVERY_BACKUP" = true ]; then
rm -f -- "$BACKUP_ARCHIVE"
rmdir -- "$BACKUP_ROOT" >/dev/null 2>&1 || true
log "已按 KAIDI_PURGE_DELETE_BACKUP=true 删除恢复备份"
fi
fi
log "Use a new empty MySQL database for the next installation"
}
main "$@"
+11
View File
@@ -0,0 +1,11 @@
-----BEGIN PUBLIC KEY-----
MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAwnPqkRpYUTBYbBaSnxBs
LqDHFPrxX6DINIiCyRJ/0f1c0noRr1X0bJSpTAR+yUwzWYTqIKAhZ4NSouYjaxtE
zuoW4cC6v+S/C310xM8qy+lE52GyFHpr7H6GqdQgxK2LmfS0jjCR9i8MEmi6QMk4
zdDtI1NOl+D50vk1nrMryplyfvqDpyPA+fiqXVo2NrqX9iaamTVkq55INk3X451I
u96/HBlSMw2EvoDPurzcjxV16swyJcuQQpBUbBmgp8i2enNyWONoBq/myYReTDem
CbrD+DTvqa+tDMQ+DAL2wyPa8JdoGOLhcftYWsyfpgTq6olNSnqgUV+Gp+NYpWuk
AxMi6TTDX4ByMkS65UGH2Opg483bxtl9bGCmjFfqFGO2NjxAsh6LaBApS2PNBKpu
06+Qni/Eq7u847egw89bL1xwtHIZBWDiG4tQMqyqW3M5WqoYoaB7PcZnfyRVChA4
pb+2yJll0cJ4RvKWeVrOAzb5JOn0BWjllm/csfwv9IeTAgMBAAE=
-----END PUBLIC KEY-----
+2 -1
View File
@@ -11,8 +11,9 @@ User=kaidi
Group=kaidi Group=kaidi
EnvironmentFile=/etc/kaidi/kaidi.env EnvironmentFile=/etc/kaidi/kaidi.env
EnvironmentFile=-/var/lib/kaidi/setup/application.env EnvironmentFile=-/var/lib/kaidi/setup/application.env
Environment=KAIDI_ENV_PRELOADED=true
WorkingDirectory=/opt/kaidi/current WorkingDirectory=/opt/kaidi/current
ExecStart=/opt/kaidi/runtime/java/bin/java -XX:MaxRAMPercentage=70 -Dfile.encoding=UTF-8 -jar /opt/kaidi/current/app.jar ExecStart=/opt/kaidi/current/ops/baota-start.sh
SuccessExitStatus=143 SuccessExitStatus=143
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
+1 -1
View File
@@ -4,8 +4,8 @@ Description=Watch for Kaidi Finance update requests
[Path] [Path]
PathChanged=/var/lib/kaidi-update/inbox PathChanged=/var/lib/kaidi-update/inbox
PathExists=/var/lib/kaidi-update/inbox/request.json PathExists=/var/lib/kaidi-update/inbox/request.json
PathChanged=/var/lib/kaidi-update/processing
PathExists=/var/lib/kaidi-update/processing/request.json PathExists=/var/lib/kaidi-update/processing/request.json
PathExists=/var/lib/kaidi-update/transactions/active
Unit=kaidi-update.service Unit=kaidi-update.service
[Install] [Install]
+2 -4
View File
@@ -10,15 +10,13 @@ Type=oneshot
User=root User=root
Group=root Group=root
EnvironmentFile=/etc/kaidi/update.env EnvironmentFile=/etc/kaidi/update.env
EnvironmentFile=-/var/lib/kaidi/setup/application.env
ExecStart=/opt/kaidi/bin/update.sh ExecStart=/opt/kaidi/bin/update.sh
Restart=on-failure Restart=no
RestartSec=15
Nice=10 Nice=10
IOSchedulingClass=best-effort IOSchedulingClass=best-effort
IOSchedulingPriority=6 IOSchedulingPriority=6
PrivateTmp=true PrivateTmp=true
ProtectHome=true ProtectHome=true
ProtectSystem=full ProtectSystem=full
ReadWritePaths=/opt/kaidi /var/lib/kaidi /var/lib/kaidi-update /var/log/kaidi /etc/systemd/system ReadWritePaths=/opt/kaidi -/www/wwwroot/kaidi /var/lib/kaidi /var/lib/kaidi-update /var/log/kaidi /etc/systemd/system
UMask=0077 UMask=0077
+722 -73
View File
File diff suppressed because it is too large Load Diff
+35 -24
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,同一来源不得重复消费 |
@@ -637,7 +637,7 @@ OA 导入路线不重新判断原 OA 的业务审批结论,但财务金额、
### 7.3 Starter 菜单、路由与页面映射 ### 7.3 Starter 菜单、路由与页面映射
登录后的整体框架直接使用 TDesign Starter 的左侧菜单、顶部栏、面包屑和内容区。菜单名称、顺序和路由在第一轮固定如下;详情、编辑等隐藏路由不出现在左侧菜单,但必须保留面包屑和返回路径。 登录后的整体框架直接使用 TDesign Starter 的左侧菜单、顶部栏、页签和内容区。菜单层级已经表达业务归属,首期不在内容区显示面包屑,避免与菜单和页签重复;详情、编辑等隐藏路由必须保留安全返回路径。
| 一级菜单/入口 | 二级菜单 | 页面 | 路由 | 默认可见身份 | | 一级菜单/入口 | 二级菜单 | 页面 | 路由 | 默认可见身份 |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
@@ -664,7 +664,7 @@ OA 导入路线不重新判断原 OA 的业务审批结论,但财务金额、
| 系统治理 | 权限与配置 | PAGE-21 | `/governance/settings` | 系统管理员 | | 系统治理 | 权限与配置 | PAGE-21 | `/governance/settings` | 系统管理员 |
| 系统治理 | 系统更新 | PAGE-22 | `/governance/update` | 系统管理员 | | 系统治理 | 系统更新 | PAGE-22 | `/governance/update` | 系统管理员 |
本地路由表固定声明组件映射,后端只返回当前身份、权限码和数据范围,前端据此过滤菜单和按钮;后端不得下发任意组件文件路径。业务页面路由的 `meta` 至少包含 `pageId`、`pageType`、`pageTemplate`、`title`、`permission`、`requiresAuth` 和面包屑信息;出现在菜单中的路由组沿用 Starter 的 `orderNo` 排序字段,不再另造 `menuOrder`。S0 页面追溯表还要逐路由冻结本地 `componentPath`、默认入口、URL query 白名单、首个读取 operationId(没有接口时明确记 `N/A` 和原因)及证据 ID;菜单、路由和页面不得出现三份不同配置。 本地路由表固定声明组件映射,后端只返回当前身份、权限码和数据范围,前端据此过滤菜单和按钮;后端不得下发任意组件文件路径。业务页面路由的 `meta` 至少包含 `pageId`、`pageType`、`pageTemplate`、`title`、`permission` 和 `requiresAuth`;历史 `breadcrumb` 字段可保留作路由合同和返回路径元数据,但不得渲染为可见导航。出现在菜单中的路由组沿用 Starter 的 `orderNo` 排序字段,不再另造 `menuOrder`。S0 页面追溯表还要逐路由冻结本地 `componentPath`、默认入口、URL query 白名单、首个读取 operationId(没有接口时明确记 `N/A` 和原因)及证据 ID;菜单、路由和页面不得出现三份不同配置。
同一账号切换身份时必须清空当前身份的页签、页面缓存、待办查询和按钮权限,再加载新身份菜单;不能沿用上一个身份打开的详情页。手工输入隐藏路由或无权路由时统一进入 Starter 的 403 页面,后端接口同时返回 `403` 并写审计日志。 同一账号切换身份时必须清空当前身份的页签、页面缓存、待办查询和按钮权限,再加载新身份菜单;不能沿用上一个身份打开的详情页。手工输入隐藏路由或无权路由时统一进入 Starter 的 403 页面,后端接口同时返回 `403` 并写审计日志。
@@ -695,7 +695,7 @@ OA 导入路线不重新判断原 OA 的业务审批结论,但财务金额、
| PAGE-19 | 查询与报表页 | 三类角色按权限 | 项目台账、合同、收付款、流程、附件完整性和导出 | | PAGE-19 | 查询与报表页 | 三类角色按权限 | 项目台账、合同、收付款、流程、附件完整性和导出 |
| PAGE-20 | 审计日志页 | 审计/授权管理员 | 查询登录、查看、修改、审批、导入、导出和结果登记记录 | | PAGE-20 | 审计日志页 | 审计/授权管理员 | 查询登录、查看、修改、审批、导入、导出和结果登记记录 |
| PAGE-21 | 权限与配置页 | 系统管理员 | 用户、角色、数据范围、模板和参数版本 | | PAGE-21 | 权限与配置页 | 系统管理员 | 用户、角色、数据范围、模板和参数版本 |
| PAGE-22 | 系统更新页 | 系统管理员 | 当前/最新版本、更新包获取、立即重启和历史更新记录 | | PAGE-22 | 系统更新页 | 系统管理员 | 当前/最新版本、显式下载、下载完成确认、安装重启和历史更新记录 |
### 8.2 PAGE-01 登录页 ### 8.2 PAGE-01 登录页
@@ -1002,9 +1002,9 @@ PAGE-21 只向超级管理员开放。超级管理员账号不得混授普通业
### 8.22.1 PAGE-22 系统更新页 ### 8.22.1 PAGE-22 系统更新页
系统更新使用独立入口 `/governance/update`,同时显示当前版本、最新版本、更新状态、四步进度和最近更新记录。拥有 `admin:update:view` 的隔离系统管理员可进入页面;下载与重启安装仍由 `admin:update:execute` 和服务端 `allowedActions` 共同控制。未启用或 Release 地址无效时显示明确禁用状态并禁用“获取更新”。 系统更新使用独立入口 `/governance/update`,同时显示当前版本、最新版本、更新状态、四步进度和最近更新记录。拥有 `admin:update:view` 的隔离系统管理员可进入页面;下载与重启安装仍由 `admin:update:execute` 和服务端 `allowedActions` 共同控制。未启用或 Release 地址无效时显示明确禁用状态并禁用“获取版本”。
更新固定分两阶段。管理员点击“获取更新”后,前端先检查最新版本,再自动提交 `DOWNLOAD` 请求;root oneshot 从固定 Gitea Latest Release API 下载 manifest、签名和应用包,验签/验哈希后写入 root-only 版本缓存并进入 `READY`,期间当前业务版本继续运行。进入 `READY` 后页面显示“立即重启”,点击后只允许安装同版本缓存,并在切换前重新验证。健康检查成功后页面显示 10 秒倒计时并自动刷新;若页面在 `INSTALL_QUEUED/BACKING_UP/INSTALLING/RUNNING` 时刷新或短暂断线,重新进入后自动恢复 3 秒轮询。失败时保留明确状态并恢复已验证的上一版本。 更新固定为“检查、下载、安装”三个服务端阶段和四个明确用户反馈。管理员点击“获取版本”时只请求最新版本、发布时间和发布说明,不提交下载;发现新版本后显示“立即更新”,再次确认后才提交 `DOWNLOAD`。root oneshot 从固定 Gitea Latest Release API 下载 manifest、签名和应用包,验签/验哈希后写入 root-only 版本缓存并进入 `READY/下载完成`,期间当前业务版本继续运行。此时页面显示“立即更新并重启”,点击后只允许安装同版本缓存,并在切换前重新验证。页面以 3 秒轮询显示排队、下载、校验、备份、安装和重启状态;命令已经被服务端接受但 HTTP 响应丢失时也会主动重读状态。健康检查成功后显示 10 秒倒计时并自动刷新;失败时保留明确状态并恢复已验证的上一版本。
页面只提交目标版本和原因,不接收下载地址、Token、脚本或任意命令。公共 Gitea 默认不配置 Token;如改接私有镜像,只读 Token 仅存在于权限为 `0600` 的 root 环境文件和临时 Header 文件,不进入进程参数、DTO、状态 JSON、审计参数或页面响应;API 和资产必须同 scheme、host、port,认证请求不跟随跨来源重定向。文件锁和 inbox/processing 双路径防止并发覆盖,独立 root oneshot 执行 RSA/SHA-256 校验、可验证数据库备份、持久事务、应用与运维文件原子切换、新旧版本健康检查和失败回滚。 页面只提交目标版本和原因,不接收下载地址、Token、脚本或任意命令。公共 Gitea 默认不配置 Token;如改接私有镜像,只读 Token 仅存在于权限为 `0600` 的 root 环境文件和临时 Header 文件,不进入进程参数、DTO、状态 JSON、审计参数或页面响应;API 和资产必须同 scheme、host、port,认证请求不跟随跨来源重定向。文件锁和 inbox/processing 双路径防止并发覆盖,独立 root oneshot 执行 RSA/SHA-256 校验、可验证数据库备份、持久事务、应用与运维文件原子切换、新旧版本健康检查和失败回滚。
@@ -1045,7 +1045,7 @@ PAGE-21 只向超级管理员开放。超级管理员账号不得混授普通业
| PAGE-19 查询报表 | `pages/reports` | `Tabs`、`Form`、`Table`、按需 `ECharts` | `/api/v1/reports/{reportCode}`、`drilldown`、`exports` | 六类报表同口径汇总、下钻、导出;不以硬编码数字或前端合计冒充结果 | | PAGE-19 查询报表 | `pages/reports` | `Tabs`、`Form`、`Table`、按需 `ECharts` | `/api/v1/reports/{reportCode}`、`drilldown`、`exports` | 六类报表同口径汇总、下钻、导出;不以硬编码数字或前端合计冒充结果 |
| PAGE-20 审计日志 | `pages/governance/audit` | `Form`、`Table`、`Drawer`、`Tag` | `/api/v1/audit/logs`、`/api/v1/audit/exports` | 授权审计人员按请求编号还原关键动作;日志不可由业务页面修改/删除 | | PAGE-20 审计日志 | `pages/governance/audit` | `Form`、`Table`、`Drawer`、`Tag` | `/api/v1/audit/logs`、`/api/v1/audit/exports` | 授权审计人员按请求编号还原关键动作;日志不可由业务页面修改/删除 |
| PAGE-21 权限与配置 | `pages/governance/settings` | `Tabs`、`Tree`、`Table`、`Form`、`Dialog` | `/api/v1/admin/users`、`roles`、`scopes`、`templates`、`parameters` | 仅超级管理员可用;用户、角色、范围、模板和参数按版本与动作白名单管理 | | PAGE-21 权限与配置 | `pages/governance/settings` | `Tabs`、`Tree`、`Table`、`Form`、`Dialog` | `/api/v1/admin/users`、`roles`、`scopes`、`templates`、`parameters` | 仅超级管理员可用;用户、角色、范围、模板和参数按版本与动作白名单管理 |
| PAGE-22 系统更新 | `pages/governance/update` | `Steps`、`Table`、`Alert`、`Tag`、`Button` | `/api/v1/admin/system-update/{check,download,install}`、`/api/v1/audit/logs` | 获取更新自动完成检查和下载;验签完成后显示立即重启;健康后 10 秒刷新;历史操作来自不可变审计日志 | | PAGE-22 系统更新 | `pages/governance/update` | `Steps`、`Table`、`Alert`、`Tag`、`Button` | `/api/v1/admin/system-update/{check,download,install}`、`/api/v1/audit/logs` | 获取版本只检查;立即更新才下载;下载完成后显示立即更新并重启;健康后 10 秒刷新;历史操作来自不可变审计日志 |
每个页面只有同时满足以下条件才算完成: 每个页面只有同时满足以下条件才算完成:
@@ -1083,7 +1083,7 @@ PAGE-21 只向超级管理员开放。超级管理员账号不得混授普通业
| PAGE-19 | 默认项目综合台账和当前月份/授权范围 | 筛选 → 汇总 → 点击金额下钻 → 导出 | 汇总、明细和导出同一口径;每个数字可到来源对象 | | PAGE-19 | 默认项目综合台账和当前月份/授权范围 | 筛选 → 汇总 → 点击金额下钻 → 导出 | 汇总、明细和导出同一口径;每个数字可到来源对象 |
| PAGE-20 | 默认最近 24 小时授权范围内审计记录 | 按用户/对象/动作/请求编号查询 → 查看前后值或导出 | 可用请求编号还原完整动作链;前后值按敏感规则脱敏 | | PAGE-20 | 默认最近 24 小时授权范围内审计记录 | 按用户/对象/动作/请求编号查询 → 查看前后值或导出 | 可用请求编号还原完整动作链;前后值按敏感规则脱敏 |
| PAGE-21 | 默认用户管理页签,只向系统管理员开放 | 编辑草稿 → 校验冲突 → 保存/发布新配置版本 | 新版本有生效时间和发布人;权限变更使受影响旧会话立即失效 | | PAGE-21 | 默认用户管理页签,只向系统管理员开放 | 编辑草稿 → 校验冲突 → 保存/发布新配置版本 | 新版本有生效时间和发布人;权限变更使受影响旧会话立即失效 |
| PAGE-22 | 当前版本、最新版本、更新状态和最近更新记录 | 获取更新 → 自动下载验签 → 立即重启 → 等待健康检查 | 健康检查成功后倒计时 10 秒自动刷新;失败状态和审计历史可查询 | | PAGE-22 | 当前版本、最新版本、更新状态和最近更新记录 | 获取版本 → 立即更新并下载验签 → 下载完成 → 立即更新并重启 → 等待健康检查 | 健康检查成功后倒计时 10 秒自动刷新;失败状态和审计历史可查询 |
所有列表默认每页 20 条;默认排序已在上表写明,未单独写明时使用 `updatedAt,desc`。无功能权限的动作隐藏;有功能权限但当前状态、资料或岗位互斥不允许的动作禁用并说明原因。任何写操作成功后都必须重新读取服务端对象和允许动作,不能只修改前端状态;失败时保留尚未提交成功的用户输入。 所有列表默认每页 20 条;默认排序已在上表写明,未单独写明时使用 `updatedAt,desc`。无功能权限的动作隐藏;有功能权限但当前状态、资料或岗位互斥不允许的动作禁用并说明原因。任何写操作成功后都必须重新读取服务端对象和允许动作,不能只修改前端状态;失败时保留尚未提交成功的用户输入。
@@ -1109,7 +1109,7 @@ PAGE-21 只向超级管理员开放。超级管理员账号不得混授普通业
| PAGE-19 查询与报表 | `reportCode` 加该报表冻结的公司/项目/对象/状态/日期条件;默认当前月 | 每个报表只开放其列代码,最终补业务编号 | 六类报表列以 D-08 为准;汇总、下钻、导出必须使用同一 filter hash 和口径版本 | | PAGE-19 查询与报表 | `reportCode` 加该报表冻结的公司/项目/对象/状态/日期条件;默认当前月 | 每个报表只开放其列代码,最终补业务编号 | 六类报表列以 D-08 为准;汇总、下钻、导出必须使用同一 filter hash 和口径版本 |
| PAGE-20 审计日志 | `occurredFrom/To`(默认近 24 小时)、`actorId`、`identityCode`、`objectType`、`objectId`、`action`、`requestId`、`result` | `occurredAt`(默认倒序)、`eventSequence` | 时间、用户、身份、范围摘要、对象、动作、结果、原因、请求编号;查看脱敏前后值、导出;没有修改/删除 | | PAGE-20 审计日志 | `occurredFrom/To`(默认近 24 小时)、`actorId`、`identityCode`、`objectType`、`objectId`、`action`、`requestId`、`result` | `occurredAt`(默认倒序)、`eventSequence` | 时间、用户、身份、范围摘要、对象、动作、结果、原因、请求编号;查看脱敏前后值、导出;没有修改/删除 |
| PAGE-21 权限与配置 | `resource`、`keyword`、`status`、`versionStatus`、`effectiveDate` | `name/code/status/updatedAt/effectiveAt` | 用户/角色/范围/模板/参数的代码、名称、版本、状态、生效时间、发布人;新增、编辑、启停、发布,按资源动作白名单执行 | | PAGE-21 权限与配置 | `resource`、`keyword`、`status`、`versionStatus`、`effectiveDate` | `name/code/status/updatedAt/effectiveAt` | 用户/角色/范围/模板/参数的代码、名称、版本、状态、生效时间、发布人;新增、编辑、启停、发布,按资源动作白名单执行 |
| PAGE-22 系统更新 | 固定读取当前状态与 `objectType=SYSTEM_UPDATE` 历史 | 历史按 `occurredAt,desc` | 当前/最新版本、状态、发布说明、操作时间、目标版本、动作、操作人和结果;获取更新、立即重启 | | PAGE-22 系统更新 | 固定读取当前状态与 `objectType=SYSTEM_UPDATE` 历史 | 历史按 `occurredAt,desc` | 当前/最新版本、状态、发布说明、操作时间、目标版本、动作、操作人和结果;获取版本、立即更新、立即更新并重启 |
列代码、筛选参数、排序字段和导出字段在 OpenAPI 中逐项枚举,前端不得把任意对象属性透传为查询字段。金额汇总由后端在同一筛选下返回,前端只格式化显示;当前页合计与全量合计必须使用不同标签。每个工作台快捷入口都要有 Playwright 断言,证明目标路由、初始筛选、返回恢复和数据数量一致。 列代码、筛选参数、排序字段和导出字段在 OpenAPI 中逐项枚举,前端不得把任意对象属性透传为查询字段。金额汇总由后端在同一筛选下返回,前端只格式化显示;当前页合计与全量合计必须使用不同标签。每个工作台快捷入口都要有 Playwright 断言,证明目标路由、初始筛选、返回恢复和数据数量一致。
@@ -1625,12 +1625,12 @@ Pull Request 只有同时满足以下条件才能合并:页面/操作合同完
| 区域 | 固定规则 | | 区域 | 固定规则 |
| --- | --- | | --- | --- |
| 登录/身份选择 | PAGE-01、PAGE-02 使用 Starter `BlankLayout`;保留 TDesign 登录视觉结构,使用甲方本地品牌素材,不显示业务侧栏 | | 登录/身份选择 | PAGE-01、PAGE-02 使用 Starter `BlankLayout`;保留 TDesign 登录视觉结构,使用甲方本地品牌素材,不显示业务侧栏 |
| 登录后框架 | PAGE-03~PAGE-22 固定使用 Starter `side` 布局:左侧菜单、顶部栏、面包屑、页签栏和内容区 | | 登录后框架 | PAGE-03~PAGE-22 固定使用 Starter `side` 布局:左侧菜单、顶部栏、页签栏和内容区;不渲染面包屑 |
| 左侧菜单 | 使用 7.3 节固定层级和顺序;支持 Starter 默认展开/收起;一级菜单图标来自 TDesign Icons,名称不得由开发人员自行缩写 | | 左侧菜单 | 使用 7.3 节固定层级和顺序;支持 Starter 默认展开/收起;一级菜单图标来自 TDesign Icons,名称不得由开发人员自行缩写 |
| 顶部栏 | 显示当前身份、身份切换、系统通知和用户菜单;删除代码仓库、外部帮助、语言切换、主题切换和布局设置入口 | | 顶部栏 | 显示当前身份、身份切换、系统通知和用户菜单;删除代码仓库、外部帮助、语言切换、主题切换和布局设置入口 |
| 面包屑 | 以 `routes.json` 页面合同为唯一层级来源,从一级菜单到当前页面完整显示;隐藏详情路由必须显示来源列表和当前页面,任一级可安全返回 | | 面包屑 | 首期删除可见面包屑;菜单和页签承担层级识别,隐藏详情路由通过显式返回按钮或安全返回路径返回来源列表 |
| 页签栏 | 保留 Starter 路由页签;工作台固定不可关闭,详情页签显示“页面名 + 业务编号”;切换身份时清空旧身份页签和缓存 | | 页签栏 | 保留 Starter 路由页签;工作台固定不可关闭,详情页签显示“页面名 + 业务编号”;切换身份时清空旧身份页签和缓存 |
| 内容头 | 静态列表、工作台、台账和配置页只显示顶部面包屑,不再在内容区重复模块名、页面名或功能说明;保留读屏可识别的隐藏 `h1`,刷新、新建、导入、导出等动作进入右侧紧凑工具栏。项目详情、表单详情等动态页面可以显示业务编号、对象名称和状态,但不得重复面包屑中的静态标题 | | 内容头 | 静态列表、工作台、台账和配置页直接进入紧凑操作栏、查询区或指标区,不显示面包屑,也不重复堆叠模块名、页面名和功能说明;保留读屏可识别的隐藏 `h1`,刷新、新建、导入、导出等动作进入右侧紧凑工具栏。项目详情、表单详情等动态页面显示业务编号、对象名称和状态,并提供明确返回路径 |
| 页面滚动 | 页面主体纵向滚动;宽表格只在表格区域横向滚动;弹窗、抽屉和固定操作栏不得覆盖页面标题或分页 | | 页面滚动 | 页面主体纵向滚动;宽表格只在表格区域横向滚动;弹窗、抽屉和固定操作栏不得覆盖页面标题或分页 |
主题固定为 TDesign 浅色模式和已确认的品牌主色;首期不交付深色模式、多语言、顶部导航、混合导航或用户自定义主题。允许本地保存的只有菜单收起、非敏感表格列显示等界面偏好;身份、权限、业务记录、查询结果和敏感字段不得持久化到浏览器。 主题固定为 TDesign 浅色模式和已确认的品牌主色;首期不交付深色模式、多语言、顶部导航、混合导航或用户自定义主题。允许本地保存的只有菜单收起、非敏感表格列显示等界面偏好;身份、权限、业务记录、查询结果和敏感字段不得持久化到浏览器。
@@ -1643,12 +1643,12 @@ PAGE-01~PAGE-22 必须从以下模板组合,不允许每个开发人员各
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 公共入口 | PAGE-01~02 | 本地品牌区 + 登录表单或三张身份卡 + 明确错误反馈 | `Form`、`Input`、`Button`、`Alert`、`Loading` | | 公共入口 | PAGE-01~02 | 本地品牌区 + 登录表单或三张身份卡 + 明确错误反馈 | `Form`、`Input`、`Button`、`Alert`、`Loading` |
| 工作台 | PAGE-04~06 | 紧凑指标行 + 待办/异常主列表 + 常用入口 + 最近动态;下一屏内容在首屏可见 | `Statistic`、`Table`、`Tabs`、`Tag`、`Timeline`、必要的 `Card` | | 工作台 | PAGE-04~06 | 紧凑指标行 + 待办/异常主列表 + 常用入口 + 最近动态;下一屏内容在首屏可见 | `Statistic`、`Table`、`Tabs`、`Tag`、`Timeline`、必要的 `Card` |
| 列表/台账 | PAGE-07、09、13~16、18~20 | 合同面包屑 + 紧凑操作栏 + 查询栏 + 数据表格 + 分页;内容区不重复静态页名,详情通过路由或抽屉进入 | `Form`、`Input`、`Select`、`DateRangePicker`、`Table`、`Pagination`、`Dropdown` | | 列表/台账 | PAGE-07、09、13~16、18~20 | 紧凑操作栏 + 查询栏 + 数据表格 + 分页;不额外渲染面包屑或重复静态页名,详情通过路由或抽屉进入 | `Form`、`Input`、`Select`、`DateRangePicker`、`Table`、`Pagination`、`Dropdown` |
| 业务详情 | PAGE-08 | 项目摘要 + 状态/主操作 + 页签 + 描述信息/明细表/时间线;跨对象都有返回路径 | `Descriptions`、`Tabs`、`Table`、`Timeline`、`Tag`、`Drawer` | | 业务详情 | PAGE-08 | 项目摘要 + 状态/主操作 + 页签 + 描述信息/明细表/时间线;跨对象都有返回路径 | `Descriptions`、`Tabs`、`Table`、`Timeline`、`Tag`、`Drawer` |
| 录入/导入 | PAGE-10、PAGE-12 的编辑状态 | 分组表单 + 动态明细 + 附件 + 校验结果 + 底部固定操作栏 | `Form`、`Row`、`Col`、`Input`/`MoneyInput`、仅整数 `InputNumber`、`Select`、`DatePicker`、`Upload`、`Alert` | | 录入/导入 | PAGE-10、PAGE-12 的编辑状态 | 分组表单 + 动态明细 + 附件 + 校验结果 + 底部固定操作栏 | `Form`、`Row`、`Col`、`Input`/`MoneyInput`、仅整数 `InputNumber`、`Select`、`DatePicker`、`Upload`、`Alert` |
| 审批处理 | PAGE-11、PAGE-17 | 待办列表 + 业务摘要 + 附件/校验 + 审批时间线 + 当前节点动作 | `Table`、`Descriptions`、`Steps`、`Timeline`、`Dialog`、`Textarea` | | 审批处理 | PAGE-11、PAGE-17 | 待办列表 + 业务摘要 + 附件/校验 + 审批时间线 + 当前节点动作 | `Table`、`Descriptions`、`Steps`、`Timeline`、`Dialog`、`Textarea` |
| 配置管理 | PAGE-21 | 配置分类 + 版本化列表/详情 + 新增编辑弹窗;生效操作和业务操作分离 | `Tabs`、`Tree`、`Table`、`Form`、`Dialog`、`Popconfirm` | | 配置管理 | PAGE-21 | 配置分类 + 版本化列表/详情 + 新增编辑弹窗;生效操作和业务操作分离 | `Tabs`、`Tree`、`Table`、`Form`、`Dialog`、`Popconfirm` |
| 系统更新 | PAGE-22 | 版本摘要 + 获取/下载进度 + 立即重启 + 审计历史 | `Steps`、`Table`、`Alert`、`Tag`、`Button` | | 系统更新 | PAGE-22 | 版本摘要 + 独立检查 + 下载进度/完成确认 + 安装重启 + 审计历史 | `Steps`、`Table`、`Alert`、`Tag`、`Button` |
| 结果/异常 | 全部页面 | 明确状态、可理解原因、请求编号和下一步动作 | `Result`、`Empty`、`Alert`、`Button`、`Skeleton` | | 结果/异常 | 全部页面 | 明确状态、可理解原因、请求编号和下一步动作 | `Result`、`Empty`、`Alert`、`Button`、`Skeleton` |
工作台不是营销首页,不使用大幅 Hero、插画横幅或仅作装饰的图表。图表必须回答一个业务问题,并提供同口径数字和可下钻明细;无有效业务含义时使用表格。 工作台不是营销首页,不使用大幅 Hero、插画横幅或仅作装饰的图表。图表必须回答一个业务问题,并提供同口径数字和可下钻明细;无有效业务含义时使用表格。
@@ -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 和服务器地址等部署输入;这些输入未配置前,不把本地候选包描述成已经公网部署。
@@ -2932,11 +2932,11 @@ PAGE-16 全类回归曾暴露测试证据文件路径硬编码与应用 `finance
| 检查项 | 本轮结果 | 证据边界 | | 检查项 | 本轮结果 | 证据边界 |
| --- | --- | --- | | --- | --- | --- |
| 固定更新源 | Git remote 已绑定 `https://git.awaioi.com/ERP-Team/kaidi.git`;生产读取 `https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest`,资产 URL 必须与 API 的 scheme、host、port 完全同源 | 页面不能修改更新地址、Token、脚本或命令;生产只读 Token 与 Gitea Actions 发布权限分离 | | 固定更新源 | Git remote 已绑定 `https://git.awaioi.com/ERP-Team/kaidi.git`;生产读取 `https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest`,资产 URL 必须与 API 的 scheme、host、port 完全同源 | 页面不能修改更新地址、Token、脚本或命令;生产只读 Token 与 Gitea Actions 发布权限分离 |
| 两阶段交互 | 管理员点击“获取更新”后自动完成检查和下载;下载、RSA 验签、SHA-256 和压缩包检查完成后进入 `READY`,业务服务在下载阶段不停机;只有 `READY` 状态才能显示“立即重启”并提交安装 | 检查、下载、安装的忙碌状态互斥,防止重复排队;未经“立即重启”确认不切换版本 | | 分阶段交互 | 管理员点击“获取版本”只检查发布信息;发现新版本后点击“立即更新”才下载。下载、RSA 验签、SHA-256 和压缩包检查完成后进入 `READY/下载完成`,业务服务在下载阶段不停机;只有 `READY` 状态才能显示“立即更新并重启”并提交安装 | 检查、下载、安装的忙碌状态互斥,防止重复排队;未经“立即更新并重启”确认不切换版本 |
| 安装与页面恢复 | 安装阶段重新验签、备份、原子切换并执行后端直连、Nginx、更新 path unit 和静态首页健康检查;成功后页面倒计时 10 秒自动刷新,安装中刷新或短暂断线会恢复 3 秒轮询;失败自动恢复并复验上一版本 | 10 秒是页面刷新等待,不延迟服务端安装;真实 Linux systemd 主机仍须执行发布后冒烟和备份恢复演练 | | 安装与页面恢复 | 安装阶段重新验签、备份、原子切换并执行后端直连、Nginx、更新 path unit 和静态首页健康检查;成功后页面倒计时 10 秒自动刷新,安装中刷新或短暂断线会恢复 3 秒轮询;失败自动恢复并复验上一版本 | 10 秒是页面刷新等待,不延迟服务端安装;真实 Linux systemd 主机仍须执行发布后冒烟和备份恢复演练 |
| 凭据保护 | Token 只从权限 `0600` 的 root 配置/Token 文件读取,curl 通过临时 Header 文件使用;认证请求不跟随重定向,Token 不进入进程参数、页面、状态 JSON、审计参数或日志 | Token 轮换时必须同步更新 `/etc/kaidi/kaidi.env` 和 `/etc/kaidi/update.env` | | 凭据保护 | Token 只从权限 `0600` 的 root 配置/Token 文件读取,curl 通过临时 Header 文件使用;认证请求不跟随重定向,Token 不进入进程参数、页面、状态 JSON、审计参数或日志 | Token 轮换时必须同步更新 `/etc/kaidi/kaidi.env` 和 `/etc/kaidi/update.env` |
| 接口与自动化 | OpenAPI 为 `206 paths / 287 schemas`;后端全量 `211/211`,Vitest `53/53`、完整 Playwright `183/183`、更新专项 Playwright `15/15` 通过;ShellCheck、Actionlint、安装、更新和 Gitea draft/恰好 9 资产/发布 fixture 通过 | 更新专项覆盖下载确认、安装弹窗、10 秒刷新、安装中刷新恢复、禁用状态和失败可见性 | | 接口与自动化 | OpenAPI 为 `206 paths / 287 schemas`;后端全量 `211/211`,Vitest `53/53`、完整 Playwright `183/183`、更新专项 Playwright `15/15` 通过;ShellCheck、Actionlint、安装、更新和 Gitea draft/恰好 9 资产/发布 fixture 通过 | 更新专项覆盖下载确认、安装弹窗、10 秒刷新、安装中刷新恢复、禁用状态和失败可见性 |
| Gitea 发布原子性 | tag 工作流先创建 `draft=true, prerelease=false` 的 Release,显式上传并核对恰好 9 个签名资产的名称、数量和大小,全部一致后才发布;失败或资产不齐时 `/latest` 不可见 | 还需在 Gitea 配置 `RELEASE_SIGNING_KEY_B64`、`KAIDI_RELEASE_PUBLIC_KEY_SHA256` 和可用的 `ubuntu-24.04` act_runner | | Gitea 发布原子性 | tag 工作流先创建 `draft=true, prerelease=false` 的 Release,显式上传并核对恰好 10 个签名资产的名称、数量和大小,全部一致后才发布;失败或资产不齐时 `/latest` 不可见 | 还需在 Gitea 配置 `RELEASE_SIGNING_KEY_B64`、`KAIDI_RELEASE_PUBLIC_KEY_SHA256` 和可用的 `ubuntu-latest` act_runner |
| Preview.2 制品 | `1.0.0-preview.2` 已完成构建及独立验签;应用包 SHA-256=`180168d4481044d3803eef81a88b301642a55cb5519e50401915e1cc131e10c7`,安装器 SHA-256=`6a881dcfd5476e795a6e181f8a8b234184523478f20312a669c6748889be1288`,公钥 SHA-256=`807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9` | 当前清单绑定 revision `47403fd337cfd4544e45d6c851984e4a40bcd8f3`、`source.ref=local`、`source.dirty=true`;正式 tag Release 必须从干净提交重新构建,摘要将以 CI 输出为准 | | Preview.2 制品 | `1.0.0-preview.2` 已完成构建及独立验签;应用包 SHA-256=`180168d4481044d3803eef81a88b301642a55cb5519e50401915e1cc131e10c7`,安装器 SHA-256=`6a881dcfd5476e795a6e181f8a8b234184523478f20312a669c6748889be1288`,公钥 SHA-256=`807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9` | 当前清单绑定 revision `47403fd337cfd4544e45d6c851984e4a40bcd8f3`、`source.ref=local`、`source.dirty=true`;正式 tag Release 必须从干净提交重新构建,摘要将以 CI 输出为准 |
| 本地 Preview.2 | 签名包内 JAR 在 `http://127.0.0.1:18095` 返回 `UP`,前端 `http://127.0.0.1:3014` 代理真实 API;系统管理员登录后,系统配置页显示当前版本 `1.0.0-preview.2` 和完整四步更新面板,页面无横向溢出,Console warning/error 为 0 | 本地未注入生产只读 Token,因此页面显示“在线更新未配置”;这证明禁用态,不替代真实 Gitea 下载和 Linux 安装冒烟 | | 本地 Preview.2 | 签名包内 JAR 在 `http://127.0.0.1:18095` 返回 `UP`,前端 `http://127.0.0.1:3014` 代理真实 API;系统管理员登录后,系统配置页显示当前版本 `1.0.0-preview.2` 和完整四步更新面板,页面无横向溢出,Console warning/error 为 0 | 本地未注入生产只读 Token,因此页面显示“在线更新未配置”;这证明禁用态,不替代真实 Gitea 下载和 Linux 安装冒烟 |
@@ -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` 输入连接信息 |
@@ -3005,6 +3005,17 @@ PAGE-16 全类回归曾暴露测试证据文件路径硬编码与应用 `finance
**本轮结论**:Preview.8 具备可部署的首次安装和 32 位向导框架;生产录入安装码、数据库密码和管理员密码前必须先配置 HTTPS 反向代理或可信内网隧道,HTTP 仅用于隔离 Preview 验证。 **本轮结论**:Preview.8 具备可部署的首次安装和 32 位向导框架;生产录入安装码、数据库密码和管理员密码前必须先配置 HTTPS 反向代理或可信内网隧道,HTTP 仅用于隔离 Preview 验证。
### 13.1.17 Preview.27 安装跳转与内容区导航收敛记录(2026-08-18)
| 项目 | 结果 | 说明 |
| --- | --- | --- |
| 安装完成跳转 | 已修复 | 完成安装后以 `required=false` 且 `locked/ready` 状态确认服务已切换,再通过 Vue Router 进入 `/login`;不再等待永远为 `false` 的 setup `ready` 标志,也不再复用安装前缓存的 `required=true` 状态 |
| 面包屑 | 已删除可见渲染 | 菜单和路由页签已经表达层级,业务内容区不再显示“表单审批 → 表单中心”等重复导航;历史 `breadcrumb` 合同字段仅保留作元数据,不生成 DOM |
| 内容区留白 | 已收紧 | 移除面包屑占位和底部间距,页签下内容区顶部间距固定为 16px;列表、工作台和配置页直接进入操作栏/指标/查询区 |
| 自动化证据 | 通过 | TypeScript、ESLint、Stylelint、Vitest `12 files / 60 tests`、安装向导 Playwright `6/6`、全量页面可访问性与布局 Playwright `78/78`、production build/hygiene 均通过 |
**本轮结论**:业务页面不再把菜单层级重复显示为面包屑;安装完成后会稳定进入登录页,异常重启场景也通过状态确认后自动跳转。
### 13.2 第三方权限和自动化负向验收 ### 13.2 第三方权限和自动化负向验收
**当前状态**:`PENDING_EVIDENCE`。以下五项是发布前必须执行并归档的负向验收,不代表当前已经完成;证据包须记录代码/依赖/配置扫描、断网运行、人工登记审计断言、执行时间、版本和可复查结果。 **当前状态**:`PENDING_EVIDENCE`。以下五项是发布前必须执行并归档的负向验收,不代表当前已经完成;证据包须记录代码/依赖/配置扫描、断网运行、人工登记审计断言、执行时间、版本和可复查结果。
+1 -1
View File
@@ -272,7 +272,7 @@
"pageType": "business", "pageType": "business",
"pageTemplate": "system-update", "pageTemplate": "system-update",
"requiresAuth": true, "requiresAuth": true,
"description": "查看当前版本、获取签名更新包、立即重启并查询历史更新记录。", "description": "查看当前版本、独立获取版本信息、显式下载签名更新包、下载完成后安装重启并查询历史更新记录。",
"breadcrumb": ["系统治理", "系统更新"], "breadcrumb": ["系统治理", "系统更新"],
"roles": ["SYSTEM_ADMIN"], "roles": ["SYSTEM_ADMIN"],
"permission": "admin:update:view" "permission": "admin:update:view"
+1 -7
View File
@@ -260,13 +260,7 @@ for (const route of routes) {
await expect(page.locator('h1.page-title-sr-only')).toHaveCount(1); await expect(page.locator('h1.page-title-sr-only')).toHaveCount(1);
await expect(page.locator('h1:not(.page-title-sr-only)')).toHaveCount(0); await expect(page.locator('h1:not(.page-title-sr-only)')).toHaveCount(0);
} }
if (!['PAGE-01', 'PAGE-02', 'PAGE-23'].includes(route.pageId)) { await expect(page.locator('.tdesign-breadcrumb')).toHaveCount(0);
const breadcrumb = page.locator('.tdesign-breadcrumb');
await expect(breadcrumb).toBeVisible();
for (const label of route.breadcrumb) {
await expect(breadcrumb.getByText(label, { exact: true })).toBeVisible();
}
}
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
if (route.pageId === 'PAGE-09') { if (route.pageId === 'PAGE-09') {
await expect(page.locator('.t-input-number__decrease, .t-input-number__increase')).toHaveCount(0); await expect(page.locator('.t-input-number__decrease, .t-input-number__increase')).toHaveCount(0);
+81 -7
View File
@@ -11,7 +11,9 @@ test('first-run wizard tests MySQL, creates the administrator and stays responsi
data: { data: {
required: !completed, required: !completed,
locked: completed, locked: completed,
ready: completed, // Production status is locked after setup; readiness is not the
// completion signal used by the redirect flow.
ready: false,
supportedDatabaseTypes: ['MYSQL'], supportedDatabaseTypes: ['MYSQL'],
message: completed ? '系统已完成安装' : '请完成安装', message: completed ? '系统已完成安装' : '请完成安装',
}, },
@@ -30,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 只读连接验证通过;尚未修改数据库,点击完成安装后才会执行迁移',
}, },
}, },
}); });
@@ -59,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');
@@ -69,6 +75,8 @@ test('first-run wizard tests MySQL, creates the administrator and stays responsi
await expect(page.getByText('安装完成', { exact: true })).toBeVisible(); await expect(page.getByText('安装完成', { exact: true })).toBeVisible();
await expect(page.getByText('安装完成,系统正在切换到正式模式')).toBeVisible(); await expect(page.getByText('安装完成,系统正在切换到正式模式')).toBeVisible();
await expect(page).toHaveURL(/\/login$/, { timeout: 15_000 });
const layout = await page.evaluate(() => ({ const layout = await page.evaluate(() => ({
clientWidth: document.documentElement.clientWidth, clientWidth: document.documentElement.clientWidth,
scrollWidth: document.documentElement.scrollWidth, scrollWidth: document.documentElement.scrollWidth,
@@ -106,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 只读连接验证通过;尚未修改数据库,点击完成安装后才会执行迁移',
}, },
}, },
}); });
@@ -128,8 +136,74 @@ test('confirms completion after the setup response is interrupted by a service r
await page.getByRole('button', { name: '完成安装' }).click(); await page.getByRole('button', { name: '完成安装' }).click();
await page.getByRole('button', { name: '确定', exact: true }).click(); await page.getByRole('button', { name: '确定', exact: true }).click();
await expect(page.getByText('连接暂时中断,正在确认安装结果。')).toBeVisible();
await expect(page.getByText('安装完成', { exact: true })).toBeVisible(); await expect(page.getByText('安装完成', { exact: true })).toBeVisible();
await expect(page.getByText('安装已完成,系统正在切换到正式模式')).toBeVisible(); await expect(page.getByText('安装已完成,系统正在切换到正式模式')).toBeVisible();
await expect(page.getByText('网络请求失败')).toHaveCount(0); await expect(page.getByText('网络请求失败')).toHaveCount(0);
await expect(page).toHaveURL(/\/login$/, { timeout: 15_000 });
});
test('leaves the completed wizard when post-completion status polling is unavailable', async ({ page }) => {
let completionStarted = false;
await page.route('**/api/v1/setup/**', async (route) => {
const request = route.request();
const pathname = new URL(request.url()).pathname;
if (pathname === '/api/v1/setup/status') {
if (completionStarted) {
await route.abort('connectionreset');
return;
}
await route.fulfill({
json: {
data: {
required: true,
locked: false,
ready: false,
supportedDatabaseTypes: ['MYSQL'],
message: '请完成安装',
},
},
});
return;
}
const body = request.postDataJSON() as Record<string, unknown>;
if (pathname.endsWith('/test-connection')) {
await route.fulfill({
json: {
data: {
successful: true,
databaseType: 'MYSQL',
serverVersion: '8.4.6',
schemaReady: false,
message: 'MySQL 8.4 只读连接验证通过;尚未修改数据库,点击完成安装后才会执行迁移',
},
},
});
return;
}
expect(body.adminUsername).toBe('admin');
completionStarted = true;
await route.fulfill({
json: {
data: {
completed: true,
username: 'admin',
message: '安装完成,系统正在切换到正式模式',
restartAfterSeconds: 1,
},
},
});
});
await page.goto('/setup');
await page.getByLabel('数据库密码').fill('fixture-db-password');
await page.getByLabel('安装码').fill('fixture-setup-code');
await page.getByRole('button', { name: '测试连接' }).click();
await page.getByRole('button', { name: '下一步' }).click();
await page.getByLabel('管理员密码').fill('SetupAdmin@2026Strong');
await page.getByLabel('确认密码').fill('SetupAdmin@2026Strong');
await page.getByRole('button', { name: '完成安装' }).click();
await page.getByRole('button', { name: '确定', exact: true }).click();
await expect(page.getByText('安装完成', { exact: true })).toBeVisible();
await expect(page).toHaveURL(/\/login$/, { timeout: 5_000 });
}); });
+231 -26
View File
@@ -1,13 +1,19 @@
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' };
} }
function session() { function session(canExecute = true) {
return { return {
authenticated: true, authenticated: true,
user: { user: {
@@ -19,14 +25,16 @@ function session() {
}, },
roles: [{ code: 'SYSTEM_ADMIN', name: '系统管理员', workbenchRoute: '/governance/settings' }], roles: [{ code: 'SYSTEM_ADMIN', name: '系统管理员', workbenchRoute: '/governance/settings' }],
activeRole: 'SYSTEM_ADMIN', activeRole: 'SYSTEM_ADMIN',
permissions, permissions: canExecute ? permissions : permissions.filter((permission) => permission !== 'admin:update:execute'),
}; };
} }
function updateView(state: string, checked: boolean, enabled = true, recoveryPending = false) { function updateView(state: string, checked: boolean, enabled = true, recoveryPending = false) {
const busy = [ const busy = [
'QUEUED',
'DOWNLOAD_QUEUED', 'DOWNLOAD_QUEUED',
'INSTALL_QUEUED', 'INSTALL_QUEUED',
'PRECHECKING',
'VERIFYING', 'VERIFYING',
'DOWNLOADING', 'DOWNLOADING',
'BACKING_UP', 'BACKING_UP',
@@ -58,6 +66,14 @@ function updateView(state: string, checked: boolean, enabled = true, recoveryPen
publishedAt: checked ? '2026-08-16T00:00:00Z' : null, publishedAt: checked ? '2026-08-16T00:00:00Z' : null,
checkedAt: checked ? '2026-08-16T00:01:00Z' : null, checkedAt: checked ? '2026-08-16T00:01:00Z' : null,
statusUpdatedAt: null, statusUpdatedAt: null,
downloadedBytes: state === 'READY' ? 10_485_760 : state === 'DOWNLOADING' ? 5_242_880 : 0,
totalBytes: ['DOWNLOAD_QUEUED', 'DOWNLOADING', 'VERIFYING', 'READY'].includes(state) ? 10_485_760 : null,
bytesPerSecond: state === 'DOWNLOADING' ? 1_048_576 : 0,
downloadPercent: state === 'READY' ? 100 : state === 'DOWNLOADING' ? 50 : 0,
restartExpectedSeconds: state === 'RUNNING' ? 10 : null,
events: checked
? [{ occurredAt: '2026-08-16T00:01:00Z', level: 'INFO', stage: state, message: `状态进入 ${state}` }]
: [],
allowedActions: allowedActions:
!enabled || recoveryPending || busy !enabled || recoveryPending || busy
? [] ? []
@@ -69,6 +85,15 @@ function updateView(state: string, checked: boolean, enabled = true, recoveryPen
}; };
} }
interface FixtureOptions {
canExecute?: boolean;
checkResponseState?: string;
downloadProgressReads?: number;
failDownloadResponse?: boolean;
failInstallResponse?: boolean;
restartOfflineReads?: number;
}
async function installFixture( async function installFixture(
page: Page, page: Page,
downloadBodies: unknown[], downloadBodies: unknown[],
@@ -78,11 +103,27 @@ async function installFixture(
completeAfterInstall = false, completeAfterInstall = false,
initialState?: string, initialState?: string,
completeBusyAfterFirstRead = false, completeBusyAfterFirstRead = false,
options: FixtureOptions = {},
) { ) {
const {
canExecute = true,
checkResponseState,
downloadProgressReads = 0,
failDownloadResponse = false,
failInstallResponse = false,
restartOfflineReads = 0,
} = options;
let checked = recoveryPending || Boolean(initialState); let checked = recoveryPending || Boolean(initialState);
let state = recoveryPending ? 'FAILED' : initialState || 'IDLE'; let state = recoveryPending ? 'RECOVERY_REQUIRED' : initialState || 'IDLE';
let progressDeadline = 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,
@@ -93,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: [],
}); });
@@ -103,9 +147,9 @@ async function installFixture(
await page.route('**/api/v1/**', async (route: Route) => { await page.route('**/api/v1/**', async (route: Route) => {
const request = route.request(); const request = route.request();
const pathname = new URL(request.url()).pathname; const pathname = new URL(request.url()).pathname;
if (pathname === '/api/v1/auth/session') return route.fulfill({ json: envelope(session()) }); if (pathname === '/api/v1/auth/session') return route.fulfill({ json: envelope(session(canExecute)) });
if (pathname === '/api/v1/auth/profile') return route.fulfill({ json: envelope(session().user) }); if (pathname === '/api/v1/auth/profile') return route.fulfill({ json: envelope(session(canExecute).user) });
if (pathname === '/api/v1/auth/roles') return route.fulfill({ json: envelope(session().roles) }); if (pathname === '/api/v1/auth/roles') return route.fulfill({ json: envelope(session(canExecute).roles) });
if (pathname === '/api/v1/auth/csrf') { if (pathname === '/api/v1/auth/csrf') {
return route.fulfill({ return route.fulfill({
headers: { 'set-cookie': 'XSRF-TOKEN=update-csrf; Path=/; SameSite=Lax' }, headers: { 'set-cookie': 'XSRF-TOKEN=update-csrf; Path=/; SameSite=Lax' },
@@ -122,29 +166,53 @@ async function installFixture(
}); });
} }
if (pathname === '/api/v1/admin/system-update' && request.method() === 'GET') { if (pathname === '/api/v1/admin/system-update' && request.method() === 'GET') {
if (state === 'RUNNING' && Date.now() < restartDeadline) {
return route.abort('connectionrefused');
}
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 (completeBusyAfterFirstRead && state === 'INSTALLING') state = 'SUCCEEDED'; if (completeBusyAfterFirstRead && state === 'INSTALLING') state = 'SUCCEEDED';
return route.fulfill({ json: envelope(response) }); return route.fulfill({ json: envelope(response) });
} }
if (pathname === '/api/v1/admin/system-update/check' && request.method() === 'POST') { if (pathname === '/api/v1/admin/system-update/check' && request.method() === 'POST') {
checked = true; checked = true;
recordHistory('SYSTEM_UPDATE_CHECK'); recordHistory('SYSTEM_UPDATE_CHECK');
return route.fulfill({ json: envelope(updateView(state, checked, enabled, recoveryPending)) }); if (checkResponseState) state = checkResponseState;
const response = updateView(state, checked, enabled, recoveryPending);
if (!canExecute) response.allowedActions = ['CHECK'];
return route.fulfill({ json: envelope(response) });
} }
if (pathname === '/api/v1/admin/system-update/download' && request.method() === 'POST') { if (pathname === '/api/v1/admin/system-update/download' && request.method() === 'POST') {
downloadBodies.push(request.postDataJSON()); downloadBodies.push(request.postDataJSON());
recordHistory('SYSTEM_UPDATE_DOWNLOAD_REQUEST'); recordHistory('SYSTEM_UPDATE_DOWNLOAD_REQUEST');
state = 'READY'; state = downloadProgressReads > 0 ? 'DOWNLOADING' : 'READY';
progressDeadline = Date.now() + downloadProgressReads * 1000;
if (failDownloadResponse) return route.abort('connectionreset');
return route.fulfill({ json: envelope(updateView('DOWNLOAD_QUEUED', true, enabled)) }); return route.fulfill({ json: envelope(updateView('DOWNLOAD_QUEUED', true, enabled)) });
} }
if (pathname === '/api/v1/admin/system-update/install' && request.method() === 'POST') { if (pathname === '/api/v1/admin/system-update/install' && request.method() === 'POST') {
installBodies.push(request.postDataJSON()); installBodies.push(request.postDataJSON());
recordHistory('SYSTEM_UPDATE_REQUEST'); recordHistory('SYSTEM_UPDATE_REQUEST');
const queued = updateView('INSTALL_QUEUED', true, enabled); const queued = updateView('INSTALL_QUEUED', true, enabled);
state = completeAfterInstall ? 'SUCCEEDED' : 'INSTALL_QUEUED'; restartDeadline = Date.now() + restartOfflineReads * 1000;
state =
restartOfflineReads > 0
? 'RUNNING'
: failInstallResponse || completeAfterInstall
? 'SUCCEEDED'
: 'INSTALL_QUEUED';
if (failInstallResponse) return route.abort('connectionreset');
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,
@@ -158,7 +226,7 @@ async function installFixture(
}); });
} }
test('system administrator checks and queues a signed online update', async ({ page }) => { test('system administrator checks, downloads, and explicitly installs a signed online update', async ({ page }) => {
const downloadBodies: unknown[] = []; const downloadBodies: unknown[] = [];
const installBodies: unknown[] = []; const installBodies: unknown[] = [];
await installFixture(page, downloadBodies, installBodies); await installFixture(page, downloadBodies, installBodies);
@@ -166,16 +234,142 @@ test('system administrator checks and queues a signed online update', async ({ p
await expect(page.locator('h1.page-title-sr-only')).toHaveText('系统更新'); await expect(page.locator('h1.page-title-sr-only')).toHaveText('系统更新');
await expect(page.locator('.version-item').filter({ hasText: '当前版本' })).toContainText('1.0.0-preview.1'); await expect(page.locator('.version-item').filter({ hasText: '当前版本' })).toContainText('1.0.0-preview.1');
await page.getByRole('button', { name: '获取更新', exact: true }).click(); await page.getByRole('button', { name: '获取版本', exact: true }).click();
await expect(page.locator('.version-item').filter({ hasText: '最新版本' })).toContainText('1.0.0-preview.2'); await expect(page.locator('.version-item').filter({ hasText: '最新版本' })).toContainText('1.0.0-preview.2');
await expect(page.getByRole('button', { name: '立即重启', exact: true })).toBeVisible({ timeout: 5_000 }); await expect(page.getByRole('button', { name: '立即更新', exact: true })).toBeVisible({ timeout: 5_000 });
expect(downloadBodies).toEqual([{ version: '1.0.0-preview.2' }]); await expect(page.getByRole('button', { name: '立即更新并重启', exact: true })).toHaveCount(0);
await expect(page.getByText('下载更新包', { exact: true })).toBeVisible(); expect(downloadBodies).toEqual([]);
await page.getByRole('button', { name: '立即重启', exact: true }).click(); await page.getByRole('button', { name: '立即更新', exact: true }).click();
expect(downloadBodies).toEqual([{ version: '1.0.0-preview.2' }]);
const updateState = page.locator('.version-item').filter({ hasText: '更新状态' });
await expect(updateState.getByText('下载已排队', { exact: true })).toBeVisible();
await expect(page.getByText('已发现新版本,但当前账号没有下载权限', { exact: true })).toHaveCount(0);
await expect(updateState.getByText('下载完成', { exact: true })).toBeVisible({ timeout: 8_000 });
await expect(page.getByRole('button', { name: '立即更新并重启', exact: true })).toBeVisible();
await page.getByRole('button', { name: '立即更新并重启', exact: true }).click();
await expect(page.getByText('安装请求已排队', { exact: true })).toBeVisible(); await expect(page.getByText('安装请求已排队', { exact: true })).toBeVisible();
expect(installBodies).toEqual([{ version: '1.0.0-preview.2', reason: '管理员确认立即重启' }]); expect(installBodies).toEqual([{ version: '1.0.0-preview.2', reason: '管理员确认立即更新并重启' }]);
});
test('download dialog shows real byte progress and transfer speed without a false permission warning', async ({
page,
}) => {
await installFixture(page, [], [], true, false, false, undefined, false, { downloadProgressReads: 8 });
await page.goto('/governance/update');
await page.getByRole('button', { name: '获取版本', exact: true }).click();
await page.getByRole('button', { name: '立即更新', exact: true }).click();
const dialog = page.getByRole('dialog', { name: '系统更新' });
await expect(dialog.getByText('50%', { exact: true })).toBeVisible({ timeout: 5_000 });
await expect(dialog.getByText('1.0 MB/s', { exact: true })).toBeVisible();
await expect(dialog.getByText('已发现新版本,但当前账号没有下载权限', { exact: true })).toHaveCount(0);
await expect(dialog.getByRole('button', { name: '立即更新并重启', exact: true })).toBeVisible({ timeout: 12_000 });
});
test('restart dialog keeps counting down and reconnects while the backend is temporarily offline', async ({ page }) => {
await installFixture(page, [], [], true, false, false, 'READY', false, { restartOfflineReads: 8 });
await page.goto('/governance/update');
await page.getByRole('button', { name: '立即更新并重启', exact: true }).click();
const dialog = page.getByRole('dialog', { name: '系统更新' });
await expect(dialog.getByText('服务正在重启,页面会持续重连,不需要手动刷新。', { exact: false })).toBeVisible({
timeout: 5_000,
});
await expect(dialog.getByText('应用服务连接中断,正在等待进程重启', { exact: true })).toBeVisible();
await expect(dialog.getByText(/新版本已通过健康检查,页面将在 \d+ 秒后自动刷新。/)).toBeVisible({
timeout: 15_000,
});
});
test('rechecking a downloaded package prompts installation instead of downloading again', async ({ page }) => {
await installFixture(page, [], [], true, false, false, 'READY');
await page.goto('/governance/update');
await page.getByRole('button', { name: '获取版本', exact: true }).click();
await expect(page.getByText('更新包已下载完成,请点击“立即更新并重启”安装新版本', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: '立即更新', exact: true })).toHaveCount(0);
await expect(page.getByRole('button', { name: '立即更新并重启', exact: true })).toBeVisible();
});
test('view-only administrators see a download permission message', async ({ page }) => {
await installFixture(page, [], [], true, false, false, undefined, false, { canExecute: false });
await page.goto('/governance/update');
await page.getByRole('button', { name: '获取版本', exact: true }).click();
await expect(page.getByText('发现新版本,但当前账号没有下载权限', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: '立即更新', exact: true })).toHaveCount(0);
await expect(page.getByRole('button', { name: '立即更新并重启', exact: true })).toHaveCount(0);
});
test('a check response that is already queued is treated as busy instead of missing permission', async ({ page }) => {
await installFixture(page, [], [], true, false, false, undefined, false, {
checkResponseState: 'DOWNLOAD_QUEUED',
});
await page.goto('/governance/update');
await page.getByRole('button', { name: '获取版本', exact: true }).click();
await expect(page.getByRole('dialog', { name: '系统更新' })).toBeVisible();
await expect(page.getByText('发现新版本,但当前账号没有下载权限', { exact: true })).toHaveCount(0);
await expect(page.getByText('下载请求已排队', { exact: true }).first()).toBeVisible();
});
test('download and install continue polling after an accepted command loses its response', async ({ page }) => {
await installFixture(page, [], [], true, false, false, undefined, false, {
failDownloadResponse: true,
});
await page.goto('/governance/update');
await page.getByRole('button', { name: '获取版本', exact: true }).click();
await page.getByRole('button', { name: '立即更新', exact: true }).click();
await expect(
page.locator('.version-item').filter({ hasText: '更新状态' }).getByText('下载完成', { exact: true }),
).toBeVisible({ timeout: 8_000 });
await expect(page.getByRole('button', { name: '立即更新并重启', exact: true })).toBeVisible();
});
test('accepted install continues to the refresh countdown after its response is lost', async ({ page }) => {
await installFixture(page, [], [], true, false, false, undefined, false, {
failInstallResponse: true,
});
await page.goto('/governance/update');
await page.getByRole('button', { name: '获取版本', exact: true }).click();
await page.getByRole('button', { name: '立即更新', exact: true }).click();
await expect(
page.locator('.version-item').filter({ hasText: '更新状态' }).getByText('下载完成', { exact: true }),
).toBeVisible({ timeout: 8_000 });
await page.getByRole('button', { name: '立即更新并重启', exact: true }).click();
await expect(page.getByText('新版本已通过健康检查,页面将在 10 秒后自动刷新。', { exact: true })).toBeVisible({
timeout: 8_000,
});
});
test('a lost install response stays pending while the application restarts', async ({ page }) => {
await installFixture(page, [], [], true, false, false, 'READY', false, {
failInstallResponse: true,
restartOfflineReads: 4,
});
await page.goto('/governance/update');
await page.getByRole('button', { name: '立即更新并重启', exact: true }).click();
const dialog = page.getByRole('dialog', { name: '系统更新' });
await expect(dialog.getByText('立即更新并重启失败', { exact: false })).toHaveCount(0);
await expect(dialog.getByText('服务正在重启,页面会持续重连,不需要手动刷新。', { exact: false })).toBeVisible({
timeout: 5_000,
});
await expect(dialog.getByText(/新版本已通过健康检查,页面将在 \d+ 秒后自动刷新。/)).toBeVisible({
timeout: 10_000,
});
}); });
test('brand logo returns the active identity to its workbench', async ({ page }) => { test('brand logo returns the active identity to its workbench', async ({ page }) => {
@@ -193,8 +387,9 @@ test('disabled online updates expose status without an executable check action',
await page.goto('/governance/update'); await page.goto('/governance/update');
await expect(page.getByText('在线更新未配置', { exact: true })).toBeVisible(); await expect(page.getByText('在线更新未配置', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: '获取更新', exact: true })).toBeDisabled(); await expect(page.getByRole('button', { name: '获取版本', exact: true })).toBeDisabled();
await expect(page.getByRole('button', { name: '立即重启', exact: true })).toHaveCount(0); await expect(page.getByRole('button', { name: '立即更新', exact: true })).toHaveCount(0);
await expect(page.getByRole('button', { name: '立即更新并重启', exact: true })).toHaveCount(0);
}); });
test('rollback recovery keeps the failure visible without an install action', async ({ page }) => { test('rollback recovery keeps the failure visible without an install action', async ({ page }) => {
@@ -202,26 +397,36 @@ test('rollback recovery keeps the failure visible without an install action', as
await page.goto('/governance/update'); await page.goto('/governance/update');
await expect(page.getByText('回滚未完成,更新服务将重试', { exact: true })).toBeVisible(); await expect(page.getByText('回滚未完成,更新服务将重试', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: '立即重启', exact: true })).toHaveCount(0); await expect(page.getByRole('button', { name: '立即更新并重启', exact: true })).toHaveCount(0);
}); });
test('successful installation starts the ten-second automatic refresh countdown', async ({ page }) => { test('successful installation starts the ten-second automatic refresh countdown', async ({ page }) => {
await installFixture(page, [], [], true, false, true); await installFixture(page, [], [], true, false, true);
await page.goto('/governance/update'); await page.goto('/governance/update');
await page.getByRole('button', { name: '获取更新', exact: true }).click(); await page.getByRole('button', { name: '获取版本', exact: true }).click();
await page.getByRole('button', { name: '立即重启', exact: true }).click({ timeout: 5_000 }); await page.getByRole('button', { name: '立即更新', exact: true }).click({ timeout: 5_000 });
await expect(
page.locator('.version-item').filter({ hasText: '更新状态' }).getByText('下载完成', { exact: true }),
).toBeVisible({ timeout: 8_000 });
await page.getByRole('button', { name: '立即更新并重启', exact: true }).click();
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 }) => {
await installFixture(page, [], [], true, false, false, 'INSTALLING', true); await installFixture(page, [], [], true, false, false, 'INSTALLING', true);
await page.goto('/governance/update'); await page.goto('/governance/update');
await expect(page.getByText('安装中', { exact: true })).toBeVisible(); await expect(
page.locator('.version-item').filter({ hasText: '更新状态' }).getByText('安装中', { exact: true }),
).toBeVisible();
await expect(page.getByText('新版本已通过健康检查,页面将在 10 秒后自动刷新。', { exact: true })).toBeVisible({ await expect(page.getByText('新版本已通过健康检查,页面将在 10 秒后自动刷新。', { exact: true })).toBeVisible({
timeout: 6_000, timeout: 6_000,
}); });
+14
View File
@@ -2,6 +2,13 @@ import { request } from '@/utils/request';
import { commandHeaders } from './command'; import { commandHeaders } from './command';
export interface SystemUpdateEvent {
occurredAt: string;
level: 'INFO' | 'WARN' | 'ERROR';
stage: string;
message: string;
}
export interface SystemUpdateView { export interface SystemUpdateView {
enabled: boolean; enabled: boolean;
currentVersion: string; currentVersion: string;
@@ -13,6 +20,12 @@ export interface SystemUpdateView {
publishedAt?: string | null; publishedAt?: string | null;
checkedAt?: string | null; checkedAt?: string | null;
statusUpdatedAt?: string | null; statusUpdatedAt?: string | null;
downloadedBytes?: number | null;
totalBytes?: number | null;
bytesPerSecond?: number | null;
downloadPercent?: number | null;
restartExpectedSeconds?: number | null;
events: SystemUpdateEvent[];
allowedActions: string[]; allowedActions: string[];
} }
@@ -21,6 +34,7 @@ function normalize(value: SystemUpdateView): SystemUpdateView {
...value, ...value,
enabled: Boolean(value?.enabled), enabled: Boolean(value?.enabled),
updateAvailable: Boolean(value?.updateAvailable), updateAvailable: Boolean(value?.updateAvailable),
events: Array.isArray(value?.events) ? value.events : [],
allowedActions: Array.isArray(value?.allowedActions) ? value.allowedActions : [], allowedActions: Array.isArray(value?.allowedActions) ? value.allowedActions : [],
}; };
} }
-1
View File
@@ -1,7 +1,6 @@
export default { export default {
showFooter: false, showFooter: false,
isSidebarCompact: false, isSidebarCompact: false,
showBreadcrumb: true,
menuAutoCollapsed: false, menuAutoCollapsed: false,
mode: 'light', mode: 'light',
layout: 'side', layout: 'side',
@@ -61,7 +61,6 @@
</t-tab-panel> </t-tab-panel>
</t-tabs> </t-tabs>
<t-content :class="`${prefix}-content-layout`"> <t-content :class="`${prefix}-content-layout`">
<l-breadcrumb v-if="settingStore.showBreadcrumb" />
<l-content /> <l-content />
</t-content> </t-content>
<t-footer v-if="settingStore.showFooter" :class="`${prefix}-footer-layout`"> <t-footer v-if="settingStore.showFooter" :class="`${prefix}-footer-layout`">
@@ -81,7 +80,6 @@ import { useLocale } from '@/locales/useLocale';
import { useSettingStore, useTabsRouterStore } from '@/store'; import { useSettingStore, useTabsRouterStore } from '@/store';
import type { TRouterInfo, TTabRemoveOptions } from '@/types/interface'; import type { TRouterInfo, TTabRemoveOptions } from '@/types/interface';
import LBreadcrumb from './Breadcrumb.vue';
import LContent from './Content.vue'; import LContent from './Content.vue';
import LFooter from './Footer.vue'; import LFooter from './Footer.vue';
@@ -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">
@@ -29,45 +33,73 @@
<div class="update-actions"> <div class="update-actions">
<t-button <t-button
variant="outline" variant="outline"
:loading="fetchingUpdate" :loading="checkingUpdate"
:disabled="!canFetchUpdate || updateBusy || restarting" :disabled="!canCheckUpdate || checkingUpdate || downloadingUpdate || installingUpdate"
@click="fetchUpdate" @click="checkLatest"
> >
<template #icon><t-icon name="cloud-download" /></template> <template #icon><t-icon name="refresh" /></template>
获取更新 获取版本
</t-button> </t-button>
<t-button <t-button
v-if="canRestart" v-if="canDownloadUpdate"
theme="primary" theme="primary"
:loading="restarting" :loading="downloadingUpdate"
:disabled="updateBusy || fetchingUpdate" :disabled="checkingUpdate || downloadingUpdate || installingUpdate"
@click="restartNow" @click="downloadNow"
>
<template #icon><t-icon name="cloud-download" /></template>
立即更新
</t-button>
<t-button
v-if="canInstall && !updateDialogVisible"
theme="primary"
:loading="installingUpdate"
:disabled="checkingUpdate || downloadingUpdate || installingUpdate"
@click="installNow"
> >
<template #icon><t-icon name="poweroff" /></template> <template #icon><t-icon name="poweroff" /></template>
立即重启 立即更新并重启
</t-button> </t-button>
</div> </div>
</div> </div>
<t-steps :current="updateStep" :layout="stepLayout" status="process" class="update-steps"> <t-steps :current="updateStep" :layout="stepLayout" status="process" class="update-steps">
<t-step-item title="获取版本" content="检查最新发布" /> <t-step-item title="获取版本" content="检查最新发布" />
<t-step-item title="下载校验" content="下载并验证签名" /> <t-step-item title="下载更新" content="下载并验证签名" />
<t-step-item title="立即重启" content="备份并切换版本" /> <t-step-item title="安装重启" content="备份并切换版本" />
<t-step-item title="完成更新" content="健康检查后刷新" /> <t-step-item title="完成更新" content="健康检查后刷新" />
</t-steps> </t-steps>
<t-alert v-if="statusError" theme="error" :close-btn="false"> <div v-if="showDownloadProgress && !updateDialogVisible" class="download-progress" aria-live="polite">
<div class="alert-content"> <div class="download-progress__heading">
<span>{{ statusError }}</span> <span>更新包下载进度</span>
<t-link theme="primary" @click="refreshPage">重新加载</t-link> <strong>{{ updateProgress }}%</strong>
</div> </div>
</t-alert> <t-progress :percentage="updateProgress" :label="false" />
<t-alert v-else-if="updateRefreshSeconds !== null" theme="success" :close-btn="false" aria-live="polite"> <div class="download-progress__meta">
新版本已通过健康检查,页面将在 {{ updateRefreshSeconds }} 秒后自动刷新。 <span>{{ formatBytes(updateStatus?.downloadedBytes) }} / {{ formatBytes(updateStatus?.totalBytes) }}</span>
</t-alert> <span>{{ downloadSpeedLabel }}</span>
<t-alert v-else :theme="updateStatus?.enabled === false ? 'warning' : 'info'" :close-btn="false"> </div>
{{ updateStatus?.message || '正在读取系统版本状态' }} </div>
</t-alert>
<template v-if="!updateDialogVisible">
<t-alert v-if="statusError" theme="error" :close-btn="false">
<div class="alert-content">
<span>{{ statusError }}</span>
<t-link theme="primary" @click="refreshPage">重新加载</t-link>
</div>
</t-alert>
<t-alert v-else-if="updateRefreshSeconds !== null" theme="success" :close-btn="false" aria-live="polite">
新版本已通过健康检查,页面将在 {{ updateRefreshSeconds }} 秒后自动刷新。
</t-alert>
<t-alert v-else :theme="updateStatus?.enabled === false ? 'warning' : 'info'" :close-btn="false">
<div class="update-status-message">
<strong>{{ updateStateLabel }}</strong>
<span>{{ updateStatus?.message || '正在读取系统版本状态' }}</span>
<small v-if="updateActionHint">{{ updateActionHint }}</small>
</div>
</t-alert>
</template>
<div v-if="updateStatus?.releaseNotes" class="release-notes"> <div v-if="updateStatus?.releaseNotes" class="release-notes">
<span>版本说明</span> <span>版本说明</span>
@@ -79,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>
@@ -109,16 +141,99 @@
</t-table> </t-table>
<t-empty v-else description="暂无系统更新记录" /> <t-empty v-else description="暂无系统更新记录" />
</section> </section>
<t-dialog
v-model:visible="updateDialogVisible"
aria-label="系统更新"
header="系统更新"
width="min(720px, calc(100vw - 32px))"
:footer="false"
:close-btn="dialogClosable"
:close-on-overlay-click="false"
:close-on-esc-keydown="dialogClosable"
>
<div class="update-dialog" role="dialog" aria-label="系统更新" aria-live="polite" aria-modal="true">
<t-steps :current="updateStep" :layout="stepLayout" status="process">
<t-step-item title="获取版本" content="确认目标版本" />
<t-step-item title="下载校验" content="下载并验证签名" />
<t-step-item title="安装重启" content="备份、切换、重启" />
<t-step-item title="完成" content="健康检查与刷新" />
</t-steps>
<div class="dialog-status-line">
<t-tag :theme="updateStateTheme" variant="light">{{ updateStateLabel }}</t-tag>
<span>{{ updateStatus?.message || '等待更新服务返回状态' }}</span>
</div>
<t-alert v-if="statusError" theme="error" :close-btn="false">{{ statusError }}</t-alert>
<t-alert v-else-if="installRequestUncertain" theme="warning" :close-btn="false">
安装请求连接中断,正在查询服务器确认是否已受理,请勿重复提交。
</t-alert>
<div v-if="showDownloadProgress" class="download-progress download-progress--dialog">
<div class="download-progress__heading">
<span>正在下载 {{ updateStatus?.latestVersion || updateStatus?.currentVersion }}</span>
<strong>{{ updateProgress }}%</strong>
</div>
<t-progress :percentage="updateProgress" :label="false" />
<div class="download-progress__meta">
<span>{{ formatBytes(updateStatus?.downloadedBytes) }} / {{ formatBytes(updateStatus?.totalBytes) }}</span>
<span>{{ downloadSpeedLabel }}</span>
</div>
</div>
<t-alert v-if="connectionInterrupted" theme="warning" :close-btn="false">
服务正在重启,页面会持续重连,不需要手动刷新。
<span v-if="restartCountdown !== null && restartCountdown > 0">预计 {{ restartCountdown }} 秒后恢复。</span>
<span v-else>预计时间已到,仍在等待服务恢复。</span>
</t-alert>
<t-alert v-else-if="restartCountdown !== null && installInProgress" theme="info" :close-btn="false">
应用进程正在切换,预计 {{ restartCountdown }} 秒后恢复访问。
</t-alert>
<t-alert v-else-if="updateRefreshSeconds !== null" theme="success" :close-btn="false">
新版本已通过健康检查,页面将在 {{ updateRefreshSeconds }} 秒后自动刷新。
</t-alert>
<t-alert v-else-if="updateStatus?.state === 'RECOVERY_REQUIRED'" theme="error" :close-btn="false">
自动回滚未恢复应用,已停止接受新的更新任务,请先完成服务器恢复。
</t-alert>
<section class="runtime-log" aria-label="更新运行日志">
<div class="runtime-log__heading">
<span>运行日志</span>
<small>{{ updateEvents.length }} 条</small>
</div>
<div class="runtime-log__body" role="log">
<div v-for="(event, index) in updateEvents" :key="`${event.occurredAt}-${index}`" class="runtime-log__row">
<time>{{ formatLogTime(event.occurredAt) }}</time>
<t-tag size="small" :theme="eventTheme(event.level)" variant="light">{{
eventStageLabel(event.stage)
}}</t-tag>
<span>{{ event.message }}</span>
</div>
<t-empty v-if="!updateEvents.length" description="等待更新服务输出日志" />
</div>
</section>
<div class="dialog-actions">
<t-button v-if="canInstall" theme="primary" :loading="installingUpdate" @click="installNow">
<template #icon><t-icon name="poweroff" /></template>
立即更新并重启
</t-button>
<t-button v-if="dialogClosable" variant="outline" @click="updateDialogVisible = false">关闭</t-button>
</div>
</div>
</t-dialog>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { isAxiosError } from 'axios';
import type { PrimaryTableCol } from 'tdesign-vue-next'; import type { PrimaryTableCol } from 'tdesign-vue-next';
import { MessagePlugin } from 'tdesign-vue-next'; import { MessagePlugin } from 'tdesign-vue-next';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'; import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import type { AuditLog } from '@/api/audit'; import type { AuditLog } from '@/api/audit';
import { getAuditLogs } from '@/api/audit'; import { getAuditLogs } from '@/api/audit';
import type { SystemUpdateView } from '@/api/system-update'; import type { SystemUpdateEvent, SystemUpdateView } from '@/api/system-update';
import { import {
checkSystemUpdate, checkSystemUpdate,
downloadSystemUpdate, downloadSystemUpdate,
@@ -129,35 +244,63 @@ import {
defineOptions({ name: 'SystemUpdatePage' }); defineOptions({ name: 'SystemUpdatePage' });
type TagTheme = 'default' | 'primary' | 'success' | 'warning' | 'danger'; type TagTheme = 'default' | 'primary' | 'success' | 'warning' | 'danger';
const UPDATE_PENDING_KEY = 'kaidi-system-update-pending';
const updateStatus = ref<SystemUpdateView | null>(null); const updateStatus = ref<SystemUpdateView | null>(null);
const isNarrow = ref(false); const isNarrow = ref(false);
const statusLoading = ref(false); const statusLoading = ref(false);
const fetchingUpdate = ref(false); const checkingUpdate = ref(false);
const restarting = ref(false); const downloadingUpdate = ref(false);
const installingUpdate = ref(false);
const statusError = ref(''); const statusError = ref('');
const historyRows = ref<AuditLog[]>([]); const historyRows = ref<AuditLog[]>([]);
const historyLoading = ref(false); const historyLoading = ref(false);
const historyError = ref(''); const historyError = ref('');
const updateRefreshSeconds = ref<number | null>(null); const updateRefreshSeconds = ref<number | null>(null);
const restartCountdown = ref<number | null>(null);
const updateDialogVisible = ref(false);
const connectionInterrupted = ref(false);
const localEvents = ref<SystemUpdateEvent[]>([]);
const awaitingRefresh = ref(false); const awaitingRefresh = ref(false);
const installRequestUncertain = ref(false);
let pollTimer: ReturnType<typeof setInterval> | undefined; let pollTimer: ReturnType<typeof setInterval> | undefined;
let refreshTimer: ReturnType<typeof setInterval> | undefined; let refreshTimer: ReturnType<typeof setInterval> | undefined;
let restartTimer: ReturnType<typeof setInterval> | undefined;
const busyStates = new Set([ const busyStates = new Set([
'QUEUED', 'QUEUED',
'DOWNLOAD_QUEUED', 'DOWNLOAD_QUEUED',
'INSTALL_QUEUED', 'INSTALL_QUEUED',
'PRECHECKING',
'VERIFYING', 'VERIFYING',
'DOWNLOADING', 'DOWNLOADING',
'BACKING_UP', 'BACKING_UP',
'INSTALLING', 'INSTALLING',
'RUNNING', 'RUNNING',
]); ]);
const installStates = new Set(['INSTALL_QUEUED', 'BACKING_UP', 'INSTALLING', 'RUNNING']); const installStates = new Set(['INSTALL_QUEUED', 'PRECHECKING', 'BACKING_UP', 'INSTALLING', 'RUNNING']);
const updateBusy = computed(() => busyStates.has(updateStatus.value?.state || '')); const updateBusy = computed(() => busyStates.has(updateStatus.value?.state || ''));
const installInProgress = computed(
() => installStates.has(updateStatus.value?.state || '') || connectionInterrupted.value,
);
const dialogClosable = computed(() => !updateBusy.value && !connectionInterrupted.value);
const showDownloadProgress = computed(() =>
['DOWNLOAD_QUEUED', 'DOWNLOADING', 'VERIFYING', 'READY'].includes(updateStatus.value?.state || ''),
);
const updateProgress = computed(() => {
const value = Number(updateStatus.value?.downloadPercent ?? 0);
return Math.min(100, Math.max(0, Number.isFinite(value) ? Math.round(value) : 0));
});
const downloadSpeedLabel = computed(() =>
updateStatus.value?.state === 'READY' ? '下载完成' : formatSpeed(updateStatus.value?.bytesPerSecond),
);
const updateEvents = computed(() =>
[...(updateStatus.value?.events || []), ...localEvents.value]
.sort((left, right) => left.occurredAt.localeCompare(right.occurredAt))
.slice(-120),
);
const stepLayout = computed(() => (isNarrow.value ? 'vertical' : 'horizontal')); const stepLayout = computed(() => (isNarrow.value ? 'vertical' : 'horizontal'));
const currentIsLatest = computed( const currentIsLatest = computed(
() => () =>
@@ -165,16 +308,29 @@ const currentIsLatest = computed(
Boolean(updateStatus.value.latestVersion) && Boolean(updateStatus.value.latestVersion) &&
!updateStatus.value.updateAvailable && !updateStatus.value.updateAvailable &&
!updateBusy.value && !updateBusy.value &&
!['FAILED', 'UNKNOWN'].includes(updateStatus.value.state), !['FAILED', 'RECOVERY_REQUIRED', 'UNKNOWN'].includes(updateStatus.value.state),
); );
const canFetchUpdate = computed( const canCheckUpdate = computed(
() => () =>
updateStatus.value?.enabled !== false && updateStatus.value?.enabled !== false &&
updateStatus.value?.state !== 'READY' && !updateBusy.value &&
(updateStatus.value?.allowedActions.includes('CHECK') === true || updateStatus.value?.allowedActions.includes('CHECK') === true,
updateStatus.value?.allowedActions.includes('DOWNLOAD') === true), );
const canDownloadUpdate = computed(
() =>
updateStatus.value?.enabled !== false &&
!updateBusy.value &&
Boolean(updateStatus.value?.latestVersion) &&
updateStatus.value?.updateAvailable === true &&
updateStatus.value?.allowedActions.includes('DOWNLOAD') === true,
);
const canInstall = computed(
() =>
updateStatus.value?.enabled !== false &&
!updateBusy.value &&
updateStatus.value?.state === 'READY' &&
updateStatus.value?.allowedActions.includes('INSTALL') === true,
); );
const canRestart = computed(() => updateStatus.value?.allowedActions.includes('INSTALL') === true);
const updateStep = computed(() => { const updateStep = computed(() => {
const state = updateStatus.value?.state || 'IDLE'; const state = updateStatus.value?.state || 'IDLE';
if (state === 'SUCCEEDED' || state === 'CURRENT' || currentIsLatest.value) return 3; if (state === 'SUCCEEDED' || state === 'CURRENT' || currentIsLatest.value) return 3;
@@ -190,26 +346,43 @@ const updateStateLabel = computed(() => {
DISABLED: '未启用', DISABLED: '未启用',
CURRENT: '已是最新', CURRENT: '已是最新',
QUEUED: '已排队', QUEUED: '已排队',
DOWNLOAD_QUEUED: '等待下载', DOWNLOAD_QUEUED: '下载已排队',
INSTALL_QUEUED: '等待重启', INSTALL_QUEUED: '等待重启',
PRECHECKING: '安装预检',
VERIFYING: '校验中', VERIFYING: '校验中',
DOWNLOADING: '下载中', DOWNLOADING: '下载中',
READY: '等待重启', READY: '下载完成',
BACKING_UP: '备份中', BACKING_UP: '备份中',
INSTALLING: '安装中', INSTALLING: '安装中',
RUNNING: '重启中', RUNNING: '重启中',
SUCCEEDED: '更新成功', SUCCEEDED: '更新成功',
FAILED: '更新失败', FAILED: '更新失败',
RECOVERY_REQUIRED: '需要人工恢复',
UNKNOWN: '状态未知', UNKNOWN: '状态未知',
}[updateStatus.value?.state || 'IDLE'] || }[updateStatus.value?.state || 'IDLE'] ||
updateStatus.value?.state || updateStatus.value?.state ||
'待获取' '待获取'
); );
}); });
const updateActionHint = computed(() => {
const state = updateStatus.value?.state || 'IDLE';
if (['DOWNLOAD_QUEUED', 'VERIFYING', 'DOWNLOADING'].includes(state)) return '更新包正在下载和校验,请稍候。';
if (['INSTALL_QUEUED', 'PRECHECKING', 'BACKING_UP', 'INSTALLING', 'RUNNING'].includes(state)) {
return '更新包已确认,系统正在预检、备份、切换并重启。';
}
if (state === 'RECOVERY_REQUIRED') return '自动回滚未完成,新的更新操作已锁定。';
if (state === 'READY') {
return canInstall.value ? '下载完成,请点击“立即更新并重启”安装新版本。' : '下载完成,但当前账号没有安装权限。';
}
if (canDownloadUpdate.value) return '已发现新版本,请点击“立即更新”开始下载。';
if (updateStatus.value?.updateAvailable) return '已发现新版本,但当前账号没有下载权限。';
if (state === 'IDLE' && !updateStatus.value?.latestVersion) return '点击“获取版本”检查最新发布。';
return '';
});
const updateStateTheme = computed<TagTheme>(() => { const updateStateTheme = computed<TagTheme>(() => {
const state = updateStatus.value?.state || 'IDLE'; const state = updateStatus.value?.state || 'IDLE';
if (['SUCCEEDED', 'CURRENT', 'READY'].includes(state) || currentIsLatest.value) return 'success'; if (['SUCCEEDED', 'CURRENT', 'READY'].includes(state) || currentIsLatest.value) return 'success';
if (['FAILED', 'UNKNOWN'].includes(state)) return 'danger'; if (['FAILED', 'RECOVERY_REQUIRED', 'UNKNOWN'].includes(state)) return 'danger';
if (updateBusy.value) return 'warning'; if (updateBusy.value) return 'warning';
return 'default'; return 'default';
}); });
@@ -230,13 +403,37 @@ async function loadStatus(silent = false) {
} }
try { try {
const status = await getSystemUpdateStatus(); const status = await getSystemUpdateStatus();
if (connectionInterrupted.value) {
appendLocalEvent('INFO', 'RUNNING', '应用服务连接已恢复,正在确认更新结果');
connectionInterrupted.value = false;
}
updateStatus.value = status; updateStatus.value = status;
if (installStates.has(status.state)) awaitingRefresh.value = true; if (installStates.has(status.state)) {
awaitingRefresh.value = true;
updateDialogVisible.value = true;
installRequestUncertain.value = false;
statusError.value = '';
}
if (status.state === 'RUNNING') {
startRestartCountdown(status.restartExpectedSeconds ?? 10);
} else if (!installStates.has(status.state)) {
stopRestartCountdown();
}
if (status.state === 'RECOVERY_REQUIRED') updateDialogVisible.value = true;
handleCompletion(status); handleCompletion(status);
if (busyStates.has(status.state)) startPolling(); if (busyStates.has(status.state) || downloadingUpdate.value || installingUpdate.value) startPolling();
else stopPolling(); else stopPolling();
} catch (error) { } catch (error) {
if (!silent) statusError.value = friendlyError(error, '系统版本状态读取失败'); const restartExpected = awaitingRefresh.value || installStates.has(updateStatus.value?.state || '');
if (restartExpected) {
if (!connectionInterrupted.value) appendLocalEvent('WARN', 'RUNNING', '应用服务连接中断,正在等待进程重启');
connectionInterrupted.value = true;
updateDialogVisible.value = true;
startRestartCountdown(updateStatus.value?.restartExpectedSeconds ?? 10);
startPolling();
} else if (!silent) {
statusError.value = friendlyError(error, '系统版本状态读取失败');
}
} finally { } finally {
if (!silent) statusLoading.value = false; if (!silent) statusLoading.value = false;
} }
@@ -249,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,
@@ -266,9 +464,9 @@ async function refreshPage() {
await Promise.all([loadStatus(), loadHistory()]); await Promise.all([loadStatus(), loadHistory()]);
} }
async function fetchUpdate() { async function checkLatest() {
if (fetchingUpdate.value || updateBusy.value || !canFetchUpdate.value) return; if (checkingUpdate.value || updateBusy.value || !canCheckUpdate.value) return;
fetchingUpdate.value = true; checkingUpdate.value = true;
statusError.value = ''; statusError.value = '';
try { try {
const checked = await checkSystemUpdate(); const checked = await checkSystemUpdate();
@@ -278,42 +476,88 @@ async function fetchUpdate() {
MessagePlugin.info('当前已是最新版本'); MessagePlugin.info('当前已是最新版本');
return; return;
} }
if (!checked.latestVersion || !checked.allowedActions.includes('DOWNLOAD')) { if (checked.state === 'READY') {
throw new Error('发现新版本,但当前账号没有下载更新包的权限'); MessagePlugin.success(
checked.allowedActions.includes('INSTALL')
? '更新包已下载完成,请点击“立即更新并重启”安装新版本'
: '更新包已下载完成,但当前账号没有安装权限',
);
} else if (busyStates.has(checked.state)) {
updateDialogVisible.value = true;
startPolling();
MessagePlugin.info(checked.message || '更新任务正在处理中,请查看当前进度');
} else if (!checked.allowedActions.includes('DOWNLOAD')) {
MessagePlugin.info('发现新版本,但当前账号没有下载权限');
} else {
MessagePlugin.success(`发现新版本 ${checked.latestVersion},请点击“立即更新”开始下载`);
} }
updateStatus.value = await downloadSystemUpdate(checked.latestVersion);
await loadHistory(true);
MessagePlugin.success('更新包已开始下载,下载和签名校验完成后可立即重启');
startPolling();
} catch (error) { } catch (error) {
statusError.value = friendlyError(error, '获取更新失败'); statusError.value = friendlyError(error, '获取版本失败');
} finally { } finally {
fetchingUpdate.value = false; checkingUpdate.value = false;
} }
} }
async function restartNow() { async function downloadNow() {
const version = updateStatus.value?.latestVersion; const version = updateStatus.value?.latestVersion;
if (!version || restarting.value || !canRestart.value) return; if (!version || downloadingUpdate.value || !canDownloadUpdate.value) return;
restarting.value = true; downloadingUpdate.value = true;
updateDialogVisible.value = true;
localEvents.value = [];
statusError.value = '';
startPolling();
try {
updateStatus.value = await downloadSystemUpdate(version);
appendLocalEvent('INFO', 'DOWNLOAD_QUEUED', `版本 ${version} 的下载请求已提交`);
MessagePlugin.success('更新包已开始下载,完成后可点击“立即更新并重启”');
void loadHistory(true);
} catch (error) {
statusError.value = friendlyError(error, '下载更新包失败');
void loadStatus(true);
} finally {
downloadingUpdate.value = false;
startPolling();
}
}
async function installNow() {
const version = updateStatus.value?.latestVersion;
if (!version || installingUpdate.value || !canInstall.value) return;
installingUpdate.value = true;
updateDialogVisible.value = true;
statusError.value = ''; statusError.value = '';
stopRefreshCountdown(); stopRefreshCountdown();
stopRestartCountdown();
awaitingRefresh.value = true;
installRequestUncertain.value = false;
setPendingUpdate(true);
startPolling();
try { try {
updateStatus.value = await installSystemUpdate(version, '管理员确认立即重启'); updateStatus.value = await installSystemUpdate(version, '管理员确认立即更新并重启');
awaitingRefresh.value = true; appendLocalEvent('INFO', 'INSTALL_QUEUED', `已确认安装 ${version},等待更新服务执行`);
MessagePlugin.success('重启任务已提交,系统恢复后页面将自动刷新'); MessagePlugin.success('更新任务已提交,系统完成切换并重启后页面将自动刷新');
await loadHistory(true); void loadHistory(true);
startPolling();
} catch (error) { } catch (error) {
statusError.value = friendlyError(error, '立即重启失败'); if (responseMayHaveBeenLost(error)) {
statusError.value = '';
installRequestUncertain.value = true;
appendLocalEvent('WARN', 'INSTALL_QUEUED', '安装请求连接中断,正在确认服务器是否已受理');
} else {
awaitingRefresh.value = false;
installRequestUncertain.value = false;
setPendingUpdate(false);
statusError.value = friendlyError(error, '立即更新并重启失败');
}
void loadStatus(true);
} finally { } finally {
restarting.value = false; installingUpdate.value = false;
startPolling();
} }
} }
function startPolling() { function startPolling() {
if (pollTimer) return; if (pollTimer) return;
pollTimer = setInterval(() => void loadStatus(true), 3000); pollTimer = setInterval(() => void loadStatus(true), 1000);
} }
function stopPolling() { function stopPolling() {
@@ -323,18 +567,66 @@ function stopPolling() {
function handleCompletion(status: SystemUpdateView) { function handleCompletion(status: SystemUpdateView) {
if (!awaitingRefresh.value) return; if (!awaitingRefresh.value) return;
if (status.state === 'SUCCEEDED') { if (status.state === 'READY') {
awaitingRefresh.value = false; awaitingRefresh.value = false;
installRequestUncertain.value = false;
setPendingUpdate(false);
stopRestartCountdown();
return;
}
if (status.state === 'SUCCEEDED') {
statusError.value = '';
awaitingRefresh.value = false;
connectionInterrupted.value = false;
installRequestUncertain.value = false;
setPendingUpdate(false);
stopRestartCountdown();
startRefreshCountdown(); startRefreshCountdown();
void loadHistory(true); void loadHistory(true);
return; return;
} }
if (['FAILED', 'CURRENT', 'DISABLED', 'UNKNOWN'].includes(status.state)) { if (['FAILED', 'RECOVERY_REQUIRED', 'CURRENT', 'DISABLED', 'UNKNOWN'].includes(status.state)) {
awaitingRefresh.value = false; awaitingRefresh.value = false;
connectionInterrupted.value = false;
installRequestUncertain.value = false;
setPendingUpdate(false);
stopRestartCountdown();
stopRefreshCountdown(); stopRefreshCountdown();
} }
} }
function startRestartCountdown(seconds: number) {
if (restartTimer) return;
restartCountdown.value = Math.max(0, Math.min(300, Math.round(seconds)));
restartTimer = setInterval(() => {
if (restartCountdown.value === null || restartCountdown.value <= 0) return;
restartCountdown.value -= 1;
}, 1000);
}
function stopRestartCountdown() {
if (restartTimer) clearInterval(restartTimer);
restartTimer = undefined;
restartCountdown.value = null;
}
function setPendingUpdate(pending: boolean) {
try {
if (pending) sessionStorage.setItem(UPDATE_PENDING_KEY, 'true');
else sessionStorage.removeItem(UPDATE_PENDING_KEY);
} catch {
// The in-memory state still keeps the current page resilient when storage is unavailable.
}
}
function hasPendingUpdate() {
try {
return sessionStorage.getItem(UPDATE_PENDING_KEY) === 'true';
} catch {
return false;
}
}
function startRefreshCountdown() { function startRefreshCountdown() {
if (refreshTimer) return; if (refreshTimer) return;
updateRefreshSeconds.value = 10; updateRefreshSeconds.value = 10;
@@ -355,6 +647,64 @@ function stopRefreshCountdown() {
updateRefreshSeconds.value = null; updateRefreshSeconds.value = null;
} }
function appendLocalEvent(level: SystemUpdateEvent['level'], stage: string, message: string) {
const latest = localEvents.value.at(-1);
if (latest?.stage === stage && latest.message === message) return;
localEvents.value = [...localEvents.value, { occurredAt: new Date().toISOString(), level, stage, message }].slice(
-30,
);
}
function formatBytes(value?: number | null) {
if (value === null || value === undefined || !Number.isFinite(value) || value < 0) return '待获取';
if (value < 1024) return `${Math.round(value)} B`;
const units = ['KB', 'MB', 'GB', 'TB'];
let amount = value / 1024;
let unit = 0;
while (amount >= 1024 && unit < units.length - 1) {
amount /= 1024;
unit += 1;
}
return `${amount >= 100 ? amount.toFixed(0) : amount.toFixed(1)} ${units[unit]}`;
}
function formatSpeed(value?: number | null) {
if (value === null || value === undefined || value <= 0) return '正在计算网速';
return `${formatBytes(value)}/s`;
}
function formatLogTime(value: string) {
const date = new Date(value);
return Number.isNaN(date.getTime())
? value
: date.toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function eventTheme(level: SystemUpdateEvent['level']): TagTheme {
if (level === 'ERROR') return 'danger';
if (level === 'WARN') return 'warning';
return 'primary';
}
function eventStageLabel(stage: string) {
return (
{
DOWNLOAD_QUEUED: '排队',
VERIFYING: '校验',
DOWNLOADING: '下载',
READY: '就绪',
INSTALL_QUEUED: '确认',
PRECHECKING: '预检',
BACKING_UP: '备份',
INSTALLING: '安装',
RUNNING: '重启',
SUCCEEDED: '完成',
FAILED: '失败',
RECOVERY_REQUIRED: '恢复',
}[stage] || stage
);
}
function historyVersion(row: AuditLog) { function historyVersion(row: AuditLog) {
for (const source of [row.afterJson, row.beforeJson]) { for (const source of [row.afterJson, row.beforeJson]) {
if (!source) continue; if (!source) continue;
@@ -374,7 +724,10 @@ 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
); );
} }
@@ -400,6 +753,12 @@ function friendlyError(error: unknown, fallback: string) {
return (error as Error)?.message || fallback; return (error as Error)?.message || fallback;
} }
function responseMayHaveBeenLost(error: unknown) {
return (
(isAxiosError(error) && !error.response) || (error as { transportFailure?: boolean })?.transportFailure === true
);
}
function updateViewportMode() { function updateViewportMode() {
isNarrow.value = window.innerWidth <= 640; isNarrow.value = window.innerWidth <= 640;
} }
@@ -407,11 +766,18 @@ function updateViewportMode() {
onMounted(() => { onMounted(() => {
updateViewportMode(); updateViewportMode();
window.addEventListener('resize', updateViewportMode); window.addEventListener('resize', updateViewportMode);
if (hasPendingUpdate()) {
awaitingRefresh.value = true;
updateDialogVisible.value = true;
startRestartCountdown(10);
startPolling();
}
void refreshPage(); void refreshPage();
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
stopPolling(); stopPolling();
stopRefreshCountdown(); stopRefreshCountdown();
stopRestartCountdown();
window.removeEventListener('resize', updateViewportMode); window.removeEventListener('resize', updateViewportMode);
}); });
</script> </script>
@@ -424,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,
@@ -433,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%;
@@ -460,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));
@@ -503,6 +890,17 @@ onBeforeUnmount(() => {
border-top: 1px solid var(--td-component-border); border-top: 1px solid var(--td-component-border);
} }
.update-status-message {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 4px 10px;
}
.update-status-message small {
color: var(--td-text-color-secondary);
}
.release-notes span, .release-notes span,
.section-heading p, .section-heading p,
.section-heading > span { .section-heading > span {
@@ -534,6 +932,103 @@ onBeforeUnmount(() => {
width: 100%; width: 100%;
} }
.download-progress {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px 16px;
background: var(--td-bg-color-secondarycontainer);
border: 1px solid var(--td-component-border);
border-radius: 6px;
}
.download-progress__heading,
.download-progress__meta,
.runtime-log__heading,
.dialog-status-line,
.dialog-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.download-progress__heading strong {
font-variant-numeric: tabular-nums;
}
.download-progress__meta {
color: var(--td-text-color-secondary);
font-size: 13px;
}
.update-dialog {
display: flex;
flex-direction: column;
gap: 18px;
min-width: 0;
}
.dialog-status-line {
justify-content: flex-start;
min-width: 0;
}
.dialog-status-line > span:last-child {
min-width: 0;
overflow-wrap: anywhere;
}
.runtime-log {
min-width: 0;
}
.runtime-log__heading {
margin-bottom: 8px;
}
.runtime-log__heading small {
color: var(--td-text-color-secondary);
}
.runtime-log__body {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 120px;
max-height: 240px;
padding: 12px;
overflow: auto;
background: var(--td-bg-color-secondarycontainer);
border: 1px solid var(--td-component-border);
border-radius: 6px;
}
.runtime-log__row {
display: grid;
grid-template-columns: 72px 58px minmax(0, 1fr);
align-items: start;
gap: 8px;
color: var(--td-text-color-primary);
font-size: 13px;
line-height: 22px;
}
.runtime-log__row time {
color: var(--td-text-color-secondary);
font-variant-numeric: tabular-nums;
}
.runtime-log__row > span:last-child {
min-width: 0;
overflow-wrap: anywhere;
}
.dialog-actions {
justify-content: flex-end;
padding-top: 4px;
}
@media (width <= 900px) { @media (width <= 900px) {
.update-heading { .update-heading {
align-items: stretch; align-items: stretch;
@@ -556,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%;
@@ -571,5 +1076,23 @@ onBeforeUnmount(() => {
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
} }
.download-progress__meta,
.dialog-status-line,
.dialog-actions {
align-items: flex-start;
flex-direction: column;
}
.dialog-actions,
.dialog-actions :deep(.t-button) {
width: 100%;
}
.runtime-log__row {
grid-template-columns: 64px 52px minmax(0, 1fr);
gap: 6px;
font-size: 12px;
}
} }
</style> </style>
+22 -14
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 || '数据库连接验证失败';
@@ -355,7 +357,7 @@ async function waitForSetupCompletion() {
if (unmounted) return false; if (unmounted) return false;
try { try {
const status = await getSetupStatus(); const status = await getSetupStatus();
if (status.ready) return true; if (!status.required && (status.ready || status.locked)) return true;
} catch { } catch {
// A short connection failure is expected while systemd restarts the service. // A short connection failure is expected while systemd restarts the service.
} }
@@ -378,19 +380,18 @@ function confirmSetup() {
function startCountdown() { function startCountdown() {
window.clearInterval(countdownTimer); window.clearInterval(countdownTimer);
countdownTimer = window.setInterval(() => { countdownTimer = window.setInterval(() => {
countdown.value -= 1; countdown.value = Math.max(0, countdown.value - 1);
if (countdown.value <= 0) void openLogin(); if (countdown.value === 0) openLogin();
}, 1000); }, 1000);
} }
async function openLogin() { function openLogin() {
window.clearInterval(countdownTimer); window.clearInterval(countdownTimer);
completionMessage.value = '系统正在启动,请稍候。'; countdown.value = 0;
if (await waitForSetupCompletion()) { completionMessage.value = '正在进入登录页。';
window.location.assign('/login'); // A full navigation clears the setup-mode SPA state retained across the
return; // service restart. The login route guard performs the final setup check.
} window.location.replace('/login');
completionMessage.value = '系统启动时间较长,请稍后刷新页面。';
} }
onMounted(async () => { onMounted(async () => {
@@ -470,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;
} }
+14 -1
View File
@@ -17,7 +17,20 @@ const isPublic = (to: RouteLocationNormalized) =>
let setupStatusRequest: ReturnType<typeof getSetupStatus> | null = null; let setupStatusRequest: ReturnType<typeof getSetupStatus> | null = null;
function loadSetupStatus() { function loadSetupStatus() {
setupStatusRequest ||= getSetupStatus(); if (setupStatusRequest) return setupStatusRequest;
// Share only an in-flight request. Keeping the resolved setup state would
// send the user back to /setup after the installation marker is written.
const request = getSetupStatus();
setupStatusRequest = request;
request.then(
() => {
if (setupStatusRequest === request) setupStatusRequest = null;
},
() => {
if (setupStatusRequest === request) setupStatusRequest = null;
},
);
return setupStatusRequest; return setupStatusRequest;
} }
+1 -1
View File
@@ -87,7 +87,7 @@
z-index: 100; z-index: 100;
} }
&-tabs-nav + .@{starter-prefix}-content-layout { &-tabs-nav + .@{starter-prefix}-content-layout {
padding-top: var(--td-comp-paddingTB-xxl); padding-top: var(--td-comp-paddingTB-l);
} }
&::-webkit-scrollbar { &::-webkit-scrollbar {
+27 -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,13 +343,14 @@ 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,
status: e.response?.status, status: e.response?.status,
requestId, requestId,
fieldErrors: problem?.fieldErrors, fieldErrors: problem?.fieldErrors,
transportFailure: !e.response,
}); });
if (e.response?.status === 401 && window.location.pathname !== '/login') { if (e.response?.status === 401 && window.location.pathname !== '/login') {
window.dispatchEvent(new CustomEvent('kaidi:session-expired')); window.dispatchEvent(new CustomEvent('kaidi:session-expired'));
+31
View File
@@ -5967,6 +5967,18 @@ components:
format: int64 format: int64
type: integer type: integer
type: object type: object
SystemUpdateEventView:
properties:
level:
type: string
message:
type: string
occurredAt:
format: date-time
type: string
stage:
type: string
type: object
SystemUpdateView: SystemUpdateView:
properties: properties:
allowedActions: allowedActions:
@@ -5976,10 +5988,23 @@ components:
checkedAt: checkedAt:
format: date-time format: date-time
type: string type: string
bytesPerSecond:
format: int64
type: integer
currentVersion: currentVersion:
type: string type: string
enabled: enabled:
type: boolean type: boolean
downloadPercent:
format: int32
type: integer
downloadedBytes:
format: int64
type: integer
events:
items:
"$ref": "#/components/schemas/SystemUpdateEventView"
type: array
latestVersion: latestVersion:
type: string type: string
message: message:
@@ -5989,11 +6014,17 @@ components:
type: string type: string
releaseNotes: releaseNotes:
type: string type: string
restartExpectedSeconds:
format: int32
type: integer
state: state:
type: string type: string
statusUpdatedAt: statusUpdatedAt:
format: date-time format: date-time
type: string type: string
totalBytes:
format: int64
type: integer
updateAvailable: updateAvailable:
type: boolean type: boolean
type: object type: object
+40 -6
View File
@@ -75,13 +75,24 @@ JAR="$ROOT/backend/target/finance-system-$VERSION.jar"
JAR_METADATA_DIR="$WORK/jar-metadata" JAR_METADATA_DIR="$WORK/jar-metadata"
mkdir -p "$JAR_METADATA_DIR" mkdir -p "$JAR_METADATA_DIR"
(cd "$JAR_METADATA_DIR" && jar -xf "$JAR" \ (cd "$JAR_METADATA_DIR" && jar -xf "$JAR" \
META-INF/MANIFEST.MF META-INF/maven/com.kaidi/finance-system/pom.properties) META-INF/MANIFEST.MF META-INF/maven/com.kaidi/finance-system/pom.properties \
BOOT-INF/classes/application.yml BOOT-INF/classes/application-local.yml \
BOOT-INF/classes/application-production.yml)
JAR_POM_VERSION=$(sed -n 's/^version=//p' \ JAR_POM_VERSION=$(sed -n 's/^version=//p' \
"$JAR_METADATA_DIR/META-INF/maven/com.kaidi/finance-system/pom.properties") "$JAR_METADATA_DIR/META-INF/maven/com.kaidi/finance-system/pom.properties")
JAR_IMPLEMENTATION_VERSION=$(sed -n 's/^Implementation-Version: //p' \ JAR_IMPLEMENTATION_VERSION=$(sed -n 's/^Implementation-Version: //p' \
"$JAR_METADATA_DIR/META-INF/MANIFEST.MF" | tr -d '\r') "$JAR_METADATA_DIR/META-INF/MANIFEST.MF" | tr -d '\r')
[ "$JAR_POM_VERSION" = "$VERSION" ] && [ "$JAR_IMPLEMENTATION_VERSION" = "$VERSION" ] \ [ "$JAR_POM_VERSION" = "$VERSION" ] && [ "$JAR_IMPLEMENTATION_VERSION" = "$VERSION" ] \
|| { printf 'JAR metadata versions do not match release %s\n' "$VERSION" >&2; exit 1; } || { printf 'JAR metadata versions do not match release %s\n' "$VERSION" >&2; exit 1; }
grep -Fq 'default: production' "$JAR_METADATA_DIR/BOOT-INF/classes/application.yml" \
|| { printf 'Release JAR must default to the production profile\n' >&2; exit 1; }
if grep -Eq '127\.0\.0\.1:3307|kaidi_local_2026' \
"$JAR_METADATA_DIR/BOOT-INF/classes/application.yml" \
"$JAR_METADATA_DIR/BOOT-INF/classes/application-local.yml" \
"$JAR_METADATA_DIR/BOOT-INF/classes/application-production.yml"; then
printf 'Release JAR contains a development database fallback\n' >&2
exit 1
fi
rm -rf "$OUTPUT_DIR" rm -rf "$OUTPUT_DIR"
mkdir -p "$OUTPUT_DIR" mkdir -p "$OUTPUT_DIR"
@@ -91,16 +102,30 @@ cp "$JAR" "$STAGE/app.jar"
cp -R "$ROOT/frontend/dist/." "$STAGE/public/" cp -R "$ROOT/frontend/dist/." "$STAGE/public/"
printf '%s\n' "$VERSION" > "$STAGE/VERSION" printf '%s\n' "$VERSION" > "$STAGE/VERSION"
cp "$ROOT/deploy/update.sh" "$STAGE/ops/update.sh" cp "$ROOT/deploy/update.sh" "$STAGE/ops/update.sh"
cp "$ROOT/deploy/baota-start.sh" "$STAGE/ops/baota-start.sh"
cp "$ROOT/deploy/baota-init.sh" "$STAGE/ops/baota-init.sh"
cp "$ROOT/deploy/release-public.pem" "$STAGE/ops/release-public.pem"
cp "$ROOT/deploy/baota.env.example" "$STAGE/ops/baota.env.example"
# Kept in the archive so Preview.9's updater can complete the one-time transition. # Kept in the archive so Preview.9's updater can complete the one-time transition.
cp "$ROOT/deploy/nginx/kaidi-finance.conf" "$STAGE/ops/kaidi-finance.conf" cp "$ROOT/deploy/nginx/kaidi-finance.conf" "$STAGE/ops/kaidi-finance.conf"
cp "$ROOT/deploy/systemd/kaidi-finance.service" "$STAGE/ops/kaidi-finance.service" cp "$ROOT/deploy/systemd/kaidi-finance.service" "$STAGE/ops/kaidi-finance.service"
cp "$ROOT/deploy/systemd/kaidi-update.service" "$STAGE/ops/kaidi-update.service" cp "$ROOT/deploy/systemd/kaidi-update.service" "$STAGE/ops/kaidi-update.service"
cp "$ROOT/deploy/systemd/kaidi-update.path" "$STAGE/ops/kaidi-update.path" cp "$ROOT/deploy/systemd/kaidi-update.path" "$STAGE/ops/kaidi-update.path"
chmod 0755 "$STAGE/ops/update.sh" chmod 0755 "$STAGE/ops/update.sh" "$STAGE/ops/baota-start.sh" "$STAGE/ops/baota-init.sh"
if find "$STAGE" -type l -print -quit | grep -q .; then
printf 'Release stage contains a symbolic link\n' >&2
exit 1
fi
if find "$STAGE" -type f -print | grep -Eq '(^|/)(\.DS_Store|__MACOSX|[^/]*\._[^/]*)$'; then
printf 'Release stage contains macOS metadata\n' >&2
exit 1
fi
ARTIFACT="kaidi-finance-$VERSION.tar.gz" ARTIFACT="kaidi-finance-$VERSION.tar.gz"
COPYFILE_DISABLE=1 tar --format=ustar -czf "$OUTPUT_DIR/$ARTIFACT" -C "$STAGE" . COPYFILE_DISABLE=1 tar --format=ustar -czf "$OUTPUT_DIR/$ARTIFACT" -C "$STAGE" .
SHA256=$(sha256_file "$OUTPUT_DIR/$ARTIFACT") SHA256=$(sha256_file "$OUTPUT_DIR/$ARTIFACT")
ARTIFACT_SIZE_BYTES=$(wc -c < "$OUTPUT_DIR/$ARTIFACT" | tr -d '[:space:]')
cp "$ROOT/backend/target/backend-sbom.json" "$OUTPUT_DIR/backend-sbom.cdx.json" cp "$ROOT/backend/target/backend-sbom.json" "$OUTPUT_DIR/backend-sbom.cdx.json"
(cd "$ROOT/frontend" && npm sbom --omit=dev --package-lock-only \ (cd "$ROOT/frontend" && npm sbom --omit=dev --package-lock-only \
--sbom-format cyclonedx --sbom-type application) > "$OUTPUT_DIR/frontend-sbom.cdx.json" --sbom-format cyclonedx --sbom-type application) > "$OUTPUT_DIR/frontend-sbom.cdx.json"
@@ -141,8 +166,10 @@ else
fi fi
install -m 0755 "$ROOT/deploy/install.sh" "$OUTPUT_DIR/install.sh" install -m 0755 "$ROOT/deploy/install.sh" "$OUTPUT_DIR/install.sh"
install -m 0755 "$ROOT/deploy/purge.sh" "$OUTPUT_DIR/purge.sh"
PUBLIC_KEY_SHA256=$(sha256_file "$OUTPUT_DIR/release-public.pem") PUBLIC_KEY_SHA256=$(sha256_file "$OUTPUT_DIR/release-public.pem")
INSTALLER_SHA256=$(sha256_file "$OUTPUT_DIR/install.sh") INSTALLER_SHA256=$(sha256_file "$OUTPUT_DIR/install.sh")
PURGER_SHA256=$(sha256_file "$OUTPUT_DIR/purge.sh")
if [ -n "$TRUSTED_PUBLIC_KEY_SHA256" ]; then if [ -n "$TRUSTED_PUBLIC_KEY_SHA256" ]; then
NORMALIZED_TRUSTED_PUBLIC_KEY_SHA256=$(printf '%s' "$TRUSTED_PUBLIC_KEY_SHA256" | tr '[:upper:]' '[:lower:]') NORMALIZED_TRUSTED_PUBLIC_KEY_SHA256=$(printf '%s' "$TRUSTED_PUBLIC_KEY_SHA256" | tr '[:upper:]' '[:lower:]')
[[ "$NORMALIZED_TRUSTED_PUBLIC_KEY_SHA256" =~ ^[0-9a-f]{64}$ ]] \ [[ "$NORMALIZED_TRUSTED_PUBLIC_KEY_SHA256" =~ ^[0-9a-f]{64}$ ]] \
@@ -156,14 +183,17 @@ fi
cat > "$OUTPUT_DIR/bootstrap-checksums.txt" <<EOF cat > "$OUTPUT_DIR/bootstrap-checksums.txt" <<EOF
KAIDI_RELEASE_PUBLIC_KEY_SHA256=$PUBLIC_KEY_SHA256 KAIDI_RELEASE_PUBLIC_KEY_SHA256=$PUBLIC_KEY_SHA256
KAIDI_INSTALLER_SHA256=$INSTALLER_SHA256 KAIDI_INSTALLER_SHA256=$INSTALLER_SHA256
KAIDI_PURGER_SHA256=$PURGER_SHA256
EOF EOF
BOOTSTRAP_SHA256=$(sha256_file "$OUTPUT_DIR/bootstrap-checksums.txt") BOOTSTRAP_SHA256=$(sha256_file "$OUTPUT_DIR/bootstrap-checksums.txt")
RELEASE_NOTES_VALUE=${KAIDI_RELEASE_NOTES:-"Kaidi Finance Preview $VERSION"} RELEASE_NOTES_VALUE=${KAIDI_RELEASE_NOTES:-"Kaidi Finance Preview $VERSION"}
VERSION="$VERSION" ARTIFACT="$ARTIFACT" SHA256="$SHA256" RELEASE_NOTES="$RELEASE_NOTES_VALUE" \ VERSION="$VERSION" ARTIFACT="$ARTIFACT" SHA256="$SHA256" ARTIFACT_SIZE_BYTES="$ARTIFACT_SIZE_BYTES" \
RELEASE_NOTES="$RELEASE_NOTES_VALUE" \
BACKEND_SBOM_SHA256="$BACKEND_SBOM_SHA256" FRONTEND_SBOM_SHA256="$FRONTEND_SBOM_SHA256" \ BACKEND_SBOM_SHA256="$BACKEND_SBOM_SHA256" FRONTEND_SBOM_SHA256="$FRONTEND_SBOM_SHA256" \
BACKEND_BUILD_VERSION="$BACKEND_BUILD_VERSION" FRONTEND_BUILD_VERSION="$FRONTEND_BUILD_VERSION" \ BACKEND_BUILD_VERSION="$BACKEND_BUILD_VERSION" FRONTEND_BUILD_VERSION="$FRONTEND_BUILD_VERSION" \
INSTALLER_SHA256="$INSTALLER_SHA256" PUBLIC_KEY_SHA256="$PUBLIC_KEY_SHA256" \ INSTALLER_SHA256="$INSTALLER_SHA256" PURGER_SHA256="$PURGER_SHA256" \
PUBLIC_KEY_SHA256="$PUBLIC_KEY_SHA256" \
BOOTSTRAP_SHA256="$BOOTSTRAP_SHA256" SOURCE_REVISION="$SOURCE_REVISION" SOURCE_REF="$SOURCE_REF" \ BOOTSTRAP_SHA256="$BOOTSTRAP_SHA256" SOURCE_REVISION="$SOURCE_REVISION" SOURCE_REF="$SOURCE_REF" \
SOURCE_DIRTY="$SOURCE_DIRTY" \ SOURCE_DIRTY="$SOURCE_DIRTY" \
node <<'NODE' > "$OUTPUT_DIR/release-manifest.json" node <<'NODE' > "$OUTPUT_DIR/release-manifest.json"
@@ -171,6 +201,7 @@ const manifest = {
version: process.env.VERSION, version: process.env.VERSION,
artifact: process.env.ARTIFACT, artifact: process.env.ARTIFACT,
sha256: process.env.SHA256, sha256: process.env.SHA256,
artifactSizeBytes: Number(process.env.ARTIFACT_SIZE_BYTES),
publishedAt: new Date().toISOString(), publishedAt: new Date().toISOString(),
minimumJava: 17, minimumJava: 17,
source: { source: {
@@ -190,6 +221,8 @@ const manifest = {
bootstrap: { bootstrap: {
installer: 'install.sh', installer: 'install.sh',
installerSha256: process.env.INSTALLER_SHA256, installerSha256: process.env.INSTALLER_SHA256,
purger: 'purge.sh',
purgerSha256: process.env.PURGER_SHA256,
publicKey: 'release-public.pem', publicKey: 'release-public.pem',
publicKeySha256: process.env.PUBLIC_KEY_SHA256, publicKeySha256: process.env.PUBLIC_KEY_SHA256,
checksums: 'bootstrap-checksums.txt', checksums: 'bootstrap-checksums.txt',
@@ -209,14 +242,15 @@ fi
cd "$OUTPUT_DIR" cd "$OUTPUT_DIR"
if command -v sha256sum >/dev/null 2>&1; then if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$ARTIFACT" backend-sbom.cdx.json frontend-sbom.cdx.json \ sha256sum "$ARTIFACT" backend-sbom.cdx.json frontend-sbom.cdx.json \
release-manifest.json release-manifest.sig release-public.pem install.sh \ release-manifest.json release-manifest.sig release-public.pem install.sh purge.sh \
bootstrap-checksums.txt > SHA256SUMS bootstrap-checksums.txt > SHA256SUMS
else else
shasum -a 256 "$ARTIFACT" backend-sbom.cdx.json frontend-sbom.cdx.json \ shasum -a 256 "$ARTIFACT" backend-sbom.cdx.json frontend-sbom.cdx.json \
release-manifest.json release-manifest.sig release-public.pem install.sh \ release-manifest.json release-manifest.sig release-public.pem install.sh purge.sh \
bootstrap-checksums.txt > SHA256SUMS bootstrap-checksums.txt > SHA256SUMS
fi fi
) )
printf 'Release assets created in %s\n' "$OUTPUT_DIR" printf 'Release assets created in %s\n' "$OUTPUT_DIR"
printf 'Trusted release public-key SHA-256: %s\n' "$PUBLIC_KEY_SHA256" printf 'Trusted release public-key SHA-256: %s\n' "$PUBLIC_KEY_SHA256"
printf 'Installer SHA-256: %s\n' "$INSTALLER_SHA256" printf 'Installer SHA-256: %s\n' "$INSTALLER_SHA256"
printf 'Purger SHA-256: %s\n' "$PURGER_SHA256"
+3 -2
View File
@@ -87,6 +87,7 @@ ASSETS=(
release-manifest.sig release-manifest.sig
release-public.pem release-public.pem
install.sh install.sh
purge.sh
bootstrap-checksums.txt bootstrap-checksums.txt
SHA256SUMS SHA256SUMS
) )
@@ -124,7 +125,7 @@ jq -n \
> "$WORK/publish.json" > "$WORK/publish.json"
publish_status=$(request PATCH "$API/releases/$RELEASE_ID" "$WORK/published.json" "$WORK/publish.json") publish_status=$(request PATCH "$API/releases/$RELEASE_ID" "$WORK/published.json" "$WORK/publish.json")
[ "$publish_status" = 200 ] || fail "release publication returned HTTP $publish_status" [ "$publish_status" = 200 ] || fail "release publication returned HTTP $publish_status"
jq -e '.draft == false and .prerelease == false and (.assets | length) == 9' \ jq -e '.draft == false and .prerelease == false and (.assets | length) == 10' \
"$WORK/published.json" >/dev/null || fail 'published release state is invalid' "$WORK/published.json" >/dev/null || fail 'published release state is invalid'
printf 'Published Gitea release %s with nine verified assets\n' "$TAG" printf 'Published Gitea release %s with ten verified assets\n' "$TAG"
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env bash
set -Eeuo pipefail
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
WORK=$(mktemp -d)
EXECUTION_MARKER=/tmp/kaidi-baota-fixture-executed-$$
trap 'rm -rf "$WORK" "$EXECUTION_MARKER"' EXIT
fail() {
printf 'Baota start fixture failed: %s\n' "$1" >&2
[ ! -f "$WORK/java.log" ] || sed -n '1,40p' "$WORK/java.log" >&2
exit 1
}
RELEASE="$WORK/release"
mkdir -p "$RELEASE/ops" "$RELEASE/public" "$WORK/files" "$WORK/tmp" "$WORK/state" "$WORK/mock-bin"
RELEASE_PHYSICAL=$(cd "$RELEASE" && pwd -P)
printf 'fixture jar\n' > "$RELEASE/app.jar"
printf '<!doctype html>\n' > "$RELEASE/public/index.html"
printf '1.0.0-preview.25\n' > "$RELEASE/VERSION"
cp "$ROOT/deploy/baota-start.sh" "$RELEASE/ops/baota-start.sh"
chmod 0755 "$RELEASE/ops/baota-start.sh"
cat > "$WORK/mock-bin/awk" <<'SH'
#!/usr/bin/env bash
case "${2:-}" in
/proc/*/stat) printf '424242\n' ;;
*) exec /usr/bin/awk "$@" ;;
esac
SH
chmod 0755 "$WORK/mock-bin/awk"
cat > "$WORK/base.env" <<EOF
SPRING_PROFILES_ACTIVE="production"
SERVER_ADDRESS="127.0.0.1"
SERVER_PORT="18080"
FIELD_ENCRYPTION_KEY="fixture-field-key"
FILE_STORAGE_ROOT="$WORK/files"
FILE_STORAGE_TEMP="$WORK/tmp"
FINANCE_UPDATE_ENABLED="true"
KAIDI_PID_FILE="$WORK/state/kaidi.pid"
EOF
cat > "$WORK/runtime.env" <<EOF
FINANCE_SETUP_ENABLED="false"
SERVER_PORT="19090"
DB_URL="jdbc:mysql://db.fixture/kaidi_finance"
DB_USERNAME="fixture-user"
DB_PASSWORD='\$(touch $EXECUTION_MARKER)'
EOF
cat > "$WORK/java" <<'SH'
#!/usr/bin/env bash
set -Eeuo pipefail
if [ "${1:-}" = -version ]; then
printf 'openjdk version "17-fixture"\n' >&2
exit 0
fi
{
printf 'cwd=%s\n' "$PWD"
printf 'args=%s\n' "$*"
printf 'version=%s\n' "$APP_VERSION"
printf 'address=%s\n' "$SERVER_ADDRESS"
printf 'port=%s\n' "$SERVER_PORT"
printf 'update=%s\n' "$FINANCE_UPDATE_ENABLED"
printf 'static=%s\n' "$FINANCE_STATIC_LOCATIONS"
printf 'username=%s\n' "$DB_USERNAME"
printf 'password=%s\n' "$DB_PASSWORD"
printf 'pid-record=%s\n' "$(cat "$KAIDI_PID_FILE")"
printf 'self-pid=%s\n' "$$"
} > "$MOCK_JAVA_LOG"
SH
chmod 0755 "$WORK/java"
MOCK_JAVA_LOG="$WORK/java.log" \
PATH="$WORK/mock-bin:$PATH" \
KAIDI_JAVA_BIN="$WORK/java" \
KAIDI_CONFIG_FILE="$WORK/base.env" \
KAIDI_RUNTIME_ENV_FILE="$WORK/runtime.env" \
DB_URL='' DB_USERNAME='' DB_PASSWORD='' \
"$RELEASE/ops/baota-start.sh"
grep -Fqx "cwd=$RELEASE_PHYSICAL" "$WORK/java.log" || fail 'launcher did not use the physical release directory'
grep -Fqx "args=-XX:MaxRAMPercentage=70 -Dfile.encoding=UTF-8 -Dkaidi.release.path=$RELEASE_PHYSICAL -Dkaidi.release.version=1.0.0-preview.25 -jar $RELEASE_PHYSICAL/app.jar" "$WORK/java.log" \
|| fail 'launcher did not identify and execute the packaged Spring Boot release'
grep -Fqx 'version=1.0.0-preview.25' "$WORK/java.log" || fail 'launcher did not read VERSION'
grep -Fqx 'address=127.0.0.1' "$WORK/java.log" || fail 'launcher did not default to loopback'
grep -Fqx 'port=19090' "$WORK/java.log" || fail 'runtime environment did not override the base port'
grep -Fqx 'update=true' "$WORK/java.log" || fail 'launcher did not keep online update enabled'
grep -Fqx "static=file:$RELEASE_PHYSICAL/public/" "$WORK/java.log" \
|| fail 'launcher did not bind static resources to the release directory'
grep -Fqx 'username=fixture-user' "$WORK/java.log" || fail 'launcher did not load setup database values'
grep -Fqx "password=\$(touch $EXECUTION_MARKER)" "$WORK/java.log" \
|| fail 'launcher changed a literal password while parsing the protected environment'
[ ! -e "$EXECUTION_MARKER" ] || fail 'launcher executed command syntax from a database password'
PID_RECORD=$(sed -n 's/^pid-record=//p' "$WORK/java.log")
SELF_PID=$(sed -n 's/^self-pid=//p' "$WORK/java.log")
[ "$PID_RECORD" = "$SELF_PID 424242" ] || fail 'PID file did not bind the PID to its proc start time'
# systemd reads the root-only EnvironmentFile before dropping to User=kaidi.
# The launcher must therefore be able to start with those files intentionally
# unreadable by the service user.
chmod 000 "$WORK/base.env" "$WORK/runtime.env"
MOCK_JAVA_LOG="$WORK/preloaded.log" \
PATH="$WORK/mock-bin:$PATH" \
KAIDI_ENV_PRELOADED=true \
KAIDI_JAVA_BIN="$WORK/java" \
SPRING_PROFILES_ACTIVE=production \
SERVER_ADDRESS=127.0.0.1 \
SERVER_PORT=21000 \
DB_URL='jdbc:mysql://db.fixture/kaidi_finance' \
DB_USERNAME=fixture-user \
DB_PASSWORD='fixture-password' \
FIELD_ENCRYPTION_KEY=fixture-field-key \
FILE_STORAGE_ROOT="$WORK/files" \
FILE_STORAGE_TEMP="$WORK/tmp" \
FINANCE_SETUP_ENABLED=false \
FINANCE_UPDATE_ENABLED=true \
FILE_SCANNER_ENABLED=false \
KAIDI_PID_FILE="$WORK/state/preloaded.pid" \
KAIDI_CONFIG_FILE="$WORK/base.env" \
KAIDI_RUNTIME_ENV_FILE="$WORK/runtime.env" \
"$RELEASE/ops/baota-start.sh"
grep -Fqx 'port=21000' "$WORK/preloaded.log" \
|| fail 'launcher still depended on the root-only configuration files under systemd'
chmod 0644 "$WORK/base.env" "$WORK/runtime.env"
MOCK_JAVA_LOG="$WORK/explicit.log" \
PATH="$WORK/mock-bin:$PATH" \
KAIDI_JAVA_BIN="$WORK/java" \
KAIDI_CONFIG_FILE="$WORK/base.env" \
KAIDI_RUNTIME_ENV_FILE="$WORK/runtime.env" \
SERVER_PORT=20000 \
"$RELEASE/ops/baota-start.sh"
grep -Fqx 'port=20000' "$WORK/explicit.log" || fail 'non-empty Baota environment did not take precedence'
cat > "$WORK/missing-db.env" <<EOF
FIELD_ENCRYPTION_KEY="fixture-field-key"
FILE_STORAGE_ROOT="$WORK/files"
FILE_STORAGE_TEMP="$WORK/tmp"
KAIDI_PID_FILE="$WORK/state/missing.pid"
EOF
if KAIDI_JAVA_BIN="$WORK/java" \
KAIDI_CONFIG_FILE="$WORK/missing-db.env" \
KAIDI_RUNTIME_ENV_FILE="$WORK/absent.env" \
MOCK_JAVA_LOG="$WORK/missing-db.log" \
"$RELEASE/ops/baota-start.sh" > "$WORK/missing-db.out" 2>&1; then
fail 'launcher accepted a production configuration without DB_URL'
fi
grep -Fq '受保护的 Kaidi 配置缺少:DB_URL' "$WORK/missing-db.out" \
|| fail 'launcher did not report the missing database URL'
[ ! -e "$WORK/missing-db.log" ] || fail 'launcher started Java after configuration validation failed'
printf 'Baota Spring Boot launcher configuration, PID identity, and injection fixtures passed\n'
+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'
+5 -4
View File
@@ -24,6 +24,7 @@ for name in \
release-manifest.sig \ release-manifest.sig \
release-public.pem \ release-public.pem \
install.sh \ install.sh \
purge.sh \
bootstrap-checksums.txt \ bootstrap-checksums.txt \
SHA256SUMS; do SHA256SUMS; do
printf 'fixture asset %s\n' "$name" > "$RELEASE_DIR/$name" printf 'fixture asset %s\n' "$name" > "$RELEASE_DIR/$name"
@@ -102,11 +103,11 @@ GITEA_TOKEN="$TOKEN" \
KAIDI_RELEASE_DIR="$RELEASE_DIR" \ KAIDI_RELEASE_DIR="$RELEASE_DIR" \
"$ROOT/scripts/publish-gitea-release.sh" > "$WORK/publish.log" "$ROOT/scripts/publish-gitea-release.sh" > "$WORK/publish.log"
grep -Fqx "Published Gitea release $TAG with nine verified assets" "$WORK/publish.log" \ grep -Fqx "Published Gitea release $TAG with ten verified assets" "$WORK/publish.log" \
|| fail 'publish script did not report success' || fail 'publish script did not report success'
[ "$(wc -l < "$WORK/assets.jsonl" | tr -d '[:space:]')" -eq 9 ] \ [ "$(wc -l < "$WORK/assets.jsonl" | tr -d '[:space:]')" -eq 10 ] \
|| fail 'publish script did not upload exactly nine assets' || fail 'publish script did not upload exactly ten assets'
! grep -Fq "$TOKEN" "$WORK/curl.log" \ ! grep -Fq "$TOKEN" "$WORK/curl.log" \
|| fail 'Gitea write token leaked into curl process arguments' || fail 'Gitea write token leaked into curl process arguments'
printf 'Gitea draft, nine-asset verification, and publication fixture passed\n' printf 'Gitea draft, ten-asset verification, and publication fixture passed\n'
+108 -16
View File
@@ -24,11 +24,17 @@ 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"
sed -n '/^azul_arch()/,/^}/p' "$ROOT/deploy/install.sh" sed -n '/^azul_arch()/,/^}/p' "$ROOT/deploy/install.sh"
sed -n '/^java_arch_matches_host()/,/^}/p' "$ROOT/deploy/install.sh" sed -n '/^java_arch_matches_host()/,/^}/p' "$ROOT/deploy/install.sh"
sed -n '/^java_home_from_bin()/,/^}/p' "$ROOT/deploy/install.sh"
sed -n '/^system_java_home()/,/^}/p' "$ROOT/deploy/install.sh" sed -n '/^system_java_home()/,/^}/p' "$ROOT/deploy/install.sh"
sed -n '/^sha256_file()/,/^}/p' "$ROOT/deploy/install.sh" sed -n '/^sha256_file()/,/^}/p' "$ROOT/deploy/install.sh"
sed -n '/^prepare_java()/,/^}/p' "$ROOT/deploy/install.sh" sed -n '/^prepare_java()/,/^}/p' "$ROOT/deploy/install.sh"
@@ -42,15 +48,49 @@ mode_of() {
} > "$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"
cat > "$WORK/mysql-bin/mysql" <<'SH'
#!/bin/sh
exit 0
SH
chmod 0755 "$WORK/mysql-bin/mysql"
# Production-mode validation is intentionally side-effect free: it validates
# the JDBC shape but never invokes a MySQL client or emits DDL/DML.
SETUP_WIZARD=false
DB_URL='jdbc:mysql://127.0.0.1:3306/kaidi_finance?useUnicode=true'
DB_USERNAME='fixture-user'
DB_PASSWORD='fixture-password'
mysql_probe_marker="$WORK/mysql-probe-called"
cat > "$WORK/mysql-bin/mysql" <<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"
printf 'jar\n' > "$release_permissions/app.jar" printf 'jar\n' > "$release_permissions/app.jar"
printf 'html\n' > "$release_permissions/public/index.html" printf 'html\n' > "$release_permissions/public/index.html"
printf '#!/bin/sh\n' > "$release_permissions/ops/update.sh" printf '#!/bin/sh\n' > "$release_permissions/ops/update.sh"
printf '#!/usr/bin/env bash\n' > "$release_permissions/ops/baota-start.sh"
chmod -R 0777 "$release_permissions" chmod -R 0777 "$release_permissions"
( (
# shellcheck disable=SC2329 # Invoked indirectly by the sourced installer helper. # shellcheck disable=SC2329 # Invoked indirectly by the sourced installer helper.
@@ -191,7 +231,7 @@ cat > "$WORK/fake-jre/bin/java" <<'JAVA'
if [ "${1:-}" = '-XshowSettings:properties' ]; then if [ "${1:-}" = '-XshowSettings:properties' ]; then
printf ' os.arch = x86\n' >&2 printf ' os.arch = x86\n' >&2
fi fi
printf 'openjdk version "17-fixture"\n' >&2 printf 'openjdk version "17.0.8"\n' >&2
JAVA JAVA
chmod 0755 "$WORK/fake-jre/bin/java" chmod 0755 "$WORK/fake-jre/bin/java"
tar -czf "$WORK/java-fixture.tar.gz" -C "$WORK" fake-jre tar -czf "$WORK/java-fixture.tar.gz" -C "$WORK" fake-jre
@@ -212,16 +252,22 @@ download() {
esac esac
} }
ARCH_FIXTURE=i686 ARCH_FIXTURE=i686
APP_ROOT="$WORK/app"
export KAIDI_JAVA_HOME="$WORK/missing-java-home" export KAIDI_JAVA_HOME="$WORK/missing-java-home"
prepare_java >/dev/null 2>&1 prepare_java >/dev/null 2>&1
[ -x "$JAVA_STAGED_DIR/bin/java" ] || fail 'verified i686 Java runtime was not staged' [ -x "$JAVA_STAGED_DIR/bin/java" ] || fail 'verified i686 Java runtime was not staged'
[ "$JAVA_BIN" = "$APP_ROOT/runtime/java/bin/java" ] \
|| fail 'downloaded Java did not select the managed fallback path'
rm -rf "$JAVA_STAGED_DIR" rm -rf "$JAVA_STAGED_DIR"
JAVA_STAGED_DIR=
export KAIDI_JAVA_HOME="$WORK/fake-jre" export KAIDI_JAVA_HOME="$WORK/fake-jre"
prepare_java >/dev/null 2>&1 prepare_java >/dev/null 2>&1
[ -d "$JAVA_STAGED_DIR" ] && [ ! -L "$JAVA_STAGED_DIR" ] \ [ -z "$JAVA_STAGED_DIR" ] \
|| fail 'existing Java 17 runtime was not copied into managed storage' || fail 'installer copied an existing server Java runtime into application storage'
cmp -s "$WORK/fake-jre/bin/java" "$JAVA_STAGED_DIR/bin/java" \ EXPECTED_LOCAL_JAVA=$(readlink -f "$WORK/fake-jre/bin/java" 2>/dev/null \
|| fail 'installer staged an unexpected local Java runtime' || printf '%s' "$WORK/fake-jre/bin/java")
[ "$JAVA_BIN" = "$EXPECTED_LOCAL_JAVA" ] \
|| fail 'installer did not select the existing server Java executable directly'
export RELEASE_API_URL=https://gitea.fixture.invalid/api/v1/repos/ERP-Team/kaidi/releases/latest export RELEASE_API_URL=https://gitea.fixture.invalid/api/v1/repos/ERP-Team/kaidi/releases/latest
RELEASE_TOKEN=fixture-read-only-token RELEASE_TOKEN=fixture-read-only-token
@@ -279,6 +325,26 @@ 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" \
|| 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" \
|| fail 'installer does not reject a 32-bit host without the glibc loader'
grep -Fq 'preflight_runtime_commands' "$ROOT/deploy/install.sh" \
|| 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" \
|| 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.
grep -Fq 'write_env_file_preserving_unknown "$CONFIG_ROOT/kaidi.env"' "$ROOT/deploy/install.sh" \ grep -Fq 'write_env_file_preserving_unknown "$CONFIG_ROOT/kaidi.env"' "$ROOT/deploy/install.sh" \
|| fail 'installer no longer uses the reinstall-safe environment writer' || fail 'installer no longer uses the reinstall-safe environment writer'
@@ -288,14 +354,14 @@ grep -Fq 'download "$api" "$java_metadata"' "$ROOT/deploy/install.sh" \
# shellcheck disable=SC2016 # Match literal installer source. # shellcheck disable=SC2016 # Match literal installer source.
grep -Fq 'java_sha256=$(jq -er' "$ROOT/deploy/install.sh" \ grep -Fq 'java_sha256=$(jq -er' "$ROOT/deploy/install.sh" \
|| fail 'installer no longer obtains the Java runtime SHA-256' || fail 'installer no longer obtains the Java runtime SHA-256'
grep -Fq 'Copying existing Java 17 runtime' "$ROOT/deploy/install.sh" \ grep -Fq 'Using existing Java 17+ runtime' "$ROOT/deploy/install.sh" \
|| fail 'installer no longer reuses a local Java 17 runtime' || fail 'installer no longer reuses a local Java 17 runtime'
# shellcheck disable=SC2016 # Match literal installer source. # shellcheck disable=SC2016 # Match literal installer source.
grep -Fq 'cp -R "$system_home/." "$JAVA_STAGED_DIR/"' "$ROOT/deploy/install.sh" \ grep -Fq 'JAVA_BIN="$system_home/bin/java"' "$ROOT/deploy/install.sh" \
|| fail 'installer no longer copies a local Java runtime into managed storage' || fail 'installer no longer records the selected server Java path'
# shellcheck disable=SC2016 # Match literal installer source. # shellcheck disable=SC2016 # Match literal installer source.
! grep -Fq 'ln -s "$system_home"' "$ROOT/deploy/install.sh" \ ! grep -Fq 'cp -R "$system_home/."' "$ROOT/deploy/install.sh" \
|| fail 'installer must not link the service to an externally managed Java directory' || fail 'installer copied an existing server Java runtime into managed storage'
# shellcheck disable=SC2016 # Match the literal installer command. # shellcheck disable=SC2016 # Match the literal installer command.
grep -Fq -- '--connect-timeout 1 --max-time 2 "$HEALTH_URL" 2>/dev/null' "$ROOT/deploy/install.sh" \ grep -Fq -- '--connect-timeout 1 --max-time 2 "$HEALTH_URL" 2>/dev/null' "$ROOT/deploy/install.sh" \
|| fail 'installer no longer performs a quiet bounded health check' || fail 'installer no longer performs a quiet bounded health check'
@@ -316,17 +382,34 @@ grep -Fq 'systemctl reset-failed kaidi-finance.service' "$ROOT/deploy/install.sh
|| fail 'installer can execute before the complete curl stream is parsed' || fail 'installer can execute before the complete curl stream is parsed'
grep -Fqx 'StartLimitBurst=3' "$ROOT/deploy/systemd/kaidi-finance.service" \ grep -Fqx 'StartLimitBurst=3' "$ROOT/deploy/systemd/kaidi-finance.service" \
|| fail 'application service no longer has a bounded restart burst' || fail 'application service no longer has a bounded restart burst'
grep -Fqx 'ExecStart=/opt/kaidi/current/ops/baota-start.sh' "$ROOT/deploy/systemd/kaidi-finance.service" \
|| fail 'application service bypasses the Java-selecting launcher'
grep -Fqx 'Environment=KAIDI_ENV_PRELOADED=true' "$ROOT/deploy/systemd/kaidi-finance.service" \
|| fail 'systemd service user would need to read the root-only configuration directory'
grep -Fqx 'Restart=no' "$ROOT/deploy/systemd/kaidi-update.service" \
|| fail 'update service can automatically repeat a failed switching transaction'
grep -Fq -- '-/www/wwwroot/kaidi' "$ROOT/deploy/systemd/kaidi-update.service" \
|| fail 'update service requires the Baota application path on a systemd-only installation'
grep -Fqx 'PathExists=/var/lib/kaidi-update/processing/request.json' "$ROOT/deploy/systemd/kaidi-update.path" \
|| fail 'update path does not resume an interrupted claimed request'
grep -Fqx 'PathExists=/var/lib/kaidi-update/transactions/active' "$ROOT/deploy/systemd/kaidi-update.path" \
|| fail 'update path does not resume an interrupted switching transaction'
# shellcheck disable=SC2016 # Match literal installer source. # shellcheck disable=SC2016 # Match literal installer source.
grep -Fq '[ "$actual_sha256" = "$java_sha256" ]' "$ROOT/deploy/install.sh" \ grep -Fq '[ "$actual_sha256" = "$java_sha256" ]' "$ROOT/deploy/install.sh" \
|| fail 'installer no longer verifies the Java runtime SHA-256' || fail 'installer no longer verifies the Java runtime SHA-256'
grep -Fq 'https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest' "$ROOT/deploy/install.sh" \ grep -Fq 'https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest' "$ROOT/deploy/install.sh" \
|| fail 'installer default release source is not the public Gitea latest API' || fail 'installer default release source is not the public Gitea latest API'
grep -Fq '/www/server/java/*/bin/java' "$ROOT/deploy/install.sh" \
|| fail 'installer does not search the common Baota Java installation path'
# shellcheck disable=SC2016 # Match literal installer source. # shellcheck disable=SC2016 # Match literal installer source.
grep -Fq 'TRUSTED_PUBLIC_KEY_SHA256=${KAIDI_RELEASE_PUBLIC_KEY_SHA256:-807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9}' "$ROOT/deploy/install.sh" \ grep -Fq 'TRUSTED_PUBLIC_KEY_SHA256=${KAIDI_RELEASE_PUBLIC_KEY_SHA256:-807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9}' "$ROOT/deploy/install.sh" \
|| fail 'installer does not pin the default release public-key fingerprint' || fail 'installer does not pin the default release public-key fingerprint'
# shellcheck disable=SC2016 # Match literal installer source. # shellcheck disable=SC2016 # Match literal installer source.
grep -Fq 'SETUP_WIZARD=${KAIDI_SETUP_WIZARD:-true}' "$ROOT/deploy/install.sh" \ grep -Fq 'if [ "${KAIDI_SETUP_WIZARD+x}" = x ]; then' "$ROOT/deploy/install.sh" \
|| fail 'installer does not enable the first-run setup wizard by default' || fail 'installer no longer distinguishes an explicit setup-wizard selection'
# shellcheck disable=SC2016 # Match literal installer source.
grep -Fq 'elif [ "$REINSTALL" = true ]; then' "$ROOT/deploy/install.sh" \
|| fail 'repair reinstall no longer defaults to production mode'
! grep -Fq 'docker run' "$ROOT/deploy/install.sh" \ ! grep -Fq 'docker run' "$ROOT/deploy/install.sh" \
|| fail 'installer must not create a MySQL container' || fail 'installer must not create a MySQL container'
! grep -Fq 'install_docker' "$ROOT/deploy/install.sh" \ ! grep -Fq 'install_docker' "$ROOT/deploy/install.sh" \
@@ -370,13 +453,22 @@ grep -Fq 'SERVER_ADDRESS "$SERVER_ADDRESS"' "$ROOT/deploy/install.sh" \
grep -Fq 'EnvironmentFile=-/var/lib/kaidi/setup/application.env' \ grep -Fq 'EnvironmentFile=-/var/lib/kaidi/setup/application.env' \
"$ROOT/deploy/systemd/kaidi-finance.service" \ "$ROOT/deploy/systemd/kaidi-finance.service" \
|| fail 'application service no longer loads the setup-completion environment' || fail 'application service no longer loads the setup-completion environment'
grep -Fq 'EnvironmentFile=-/var/lib/kaidi/setup/application.env' \ ! grep -Fq 'EnvironmentFile=-/var/lib/kaidi/setup/application.env' \
"$ROOT/deploy/systemd/kaidi-update.service" \ "$ROOT/deploy/systemd/kaidi-update.service" \
|| fail 'update service no longer loads setup database overrides' || fail 'root update service must not load an application-owned environment file'
grep -Fq 'load_runtime_database_env' "$ROOT/deploy/update.sh" \
|| fail 'update script no longer imports only the setup database fields'
# 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 'install.sh | sudo bash' "$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 one-line setup-wizard install path' [ -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" \
|| fail 'README does not document the local Baota initialization command'
grep -Fq 'FINANCE_UPDATE_ENABLED false' "$ROOT/deploy/baota-init.sh" \
|| fail 'Baota manual mode still exposes the unstable online updater'
printf 'Install configuration, custom port, public Gitea, optional private token, i686, setup wizard, and MySQL 8.4 fixtures passed\n' printf 'Install configuration, custom port, public Gitea, optional private token, i686, setup wizard, and MySQL 8.4 fixtures passed\n'
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
set -Eeuo pipefail
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
fail() {
printf 'Purge fixture failed: %s\n' "$1" >&2
exit 1
}
sed '$d' "$ROOT/deploy/purge.sh" > "$WORK/purge-functions.sh"
# shellcheck disable=SC1090,SC1091
source "$WORK/purge-functions.sh"
install() {
local -a arguments=()
while [ "$#" -gt 0 ]; do
case "$1" in
-o|-g) shift 2 ;;
*) arguments+=("$1"); shift ;;
esac
done
command install "${arguments[@]}"
}
APP_ROOT="$WORK/opt/kaidi"
BAOTA_ROOT="$WORK/www/kaidi"
CONFIG_ROOT="$WORK/etc/kaidi"
STATE_ROOT="$WORK/var/lib/kaidi"
UPDATE_STATE_ROOT="$WORK/var/lib/kaidi-update"
LOG_ROOT="$WORK/var/log/kaidi"
FIRST_LOGIN_FILE="$WORK/root/kaidi-first-login.txt"
# shellcheck disable=SC2034 # Referenced by the sourced purge helper.
BACKUP_ROOT="$WORK/recovery"
BACKUP_ARCHIVE=
mkdir -p \
"$APP_ROOT" \
"$BAOTA_ROOT" \
"$CONFIG_ROOT" \
"$STATE_ROOT/files" \
"$UPDATE_STATE_ROOT/backups" \
"$UPDATE_STATE_ROOT/failed" \
"$UPDATE_STATE_ROOT/transactions" \
"$LOG_ROOT" \
"$(dirname "$FIRST_LOGIN_FILE")" \
"$WORK/external-mysql" \
"$WORK/reverse-proxy"
printf 'DB_PASSWORD="secret"\n' > "$CONFIG_ROOT/kaidi.env"
printf 'document\n' > "$STATE_ROOT/files/document.txt"
printf 'dump\n' > "$UPDATE_STATE_ROOT/backups/mysql.sql"
printf 'setup code\n' > "$FIRST_LOGIN_FILE"
printf 'database sentinel\n' > "$WORK/external-mysql/keep"
printf 'proxy sentinel\n' > "$WORK/reverse-proxy/keep"
create_recovery_backup
[ -s "$BACKUP_ARCHIVE" ] || fail 'root-only recovery archive was not created'
[ "$(stat -c '%a' "$BACKUP_ARCHIVE" 2>/dev/null || stat -f '%Lp' "$BACKUP_ARCHIVE")" = 600 ] \
|| fail 'recovery archive mode is not 0600'
archive_list=$(tar -tzf "$BACKUP_ARCHIVE")
grep -Fq "${CONFIG_ROOT#/}/kaidi.env" <<< "$archive_list" \
|| fail 'configuration was omitted from the recovery archive'
grep -Fq "${STATE_ROOT#/}/files/document.txt" <<< "$archive_list" \
|| fail 'file storage was omitted from the recovery archive'
grep -Fq "${UPDATE_STATE_ROOT#/}/backups/mysql.sql" <<< "$archive_list" \
|| fail 'database backup was omitted from the recovery archive'
remove_managed_paths
for path in "$APP_ROOT" "$BAOTA_ROOT" "$CONFIG_ROOT" "$STATE_ROOT" "$UPDATE_STATE_ROOT" "$LOG_ROOT"; do
[ ! -e "$path" ] || fail "managed path was not removed: $path"
done
[ ! -e "$FIRST_LOGIN_FILE" ] || fail 'first-login file was not removed'
[ -f "$WORK/external-mysql/keep" ] || fail 'external database state was removed'
[ -f "$WORK/reverse-proxy/keep" ] || fail 'reverse-proxy state was removed'
[ -s "$BACKUP_ARCHIVE" ] || fail 'recovery archive was removed with the installation'
[ "$(tail -n 1 "$ROOT/deploy/purge.sh")" = 'main "$@"' ] \
|| fail 'purge actions can execute before a complete curl stream is parsed'
# shellcheck disable=SC2016 # Match the literal guard in the purge source.
grep -Fq 'KAIDI_PURGE_CONFIRM=$REQUIRED_CONFIRMATION' "$ROOT/deploy/purge.sh" \
|| fail 'destructive removal no longer requires explicit confirmation'
grep -Fq 'External MySQL data and reverse-proxy configuration will not be modified' "$ROOT/deploy/purge.sh" \
|| fail 'purge boundary is no longer explicit'
# shellcheck disable=SC2016 # Match literal service-account cleanup calls.
grep -Fq 'terminate_uid_processes "$service_uid"' "$ROOT/deploy/purge.sh" \
|| fail 'dedicated service-account processes are not terminated before account removal'
# shellcheck disable=SC2016 # Match literal forced account removal.
grep -Fq 'userdel --force "$SERVICE_USER"' "$ROOT/deploy/purge.sh" \
|| fail 'dedicated service-account removal is not resilient to a restarting panel process'
grep -Fq '/etc/systemd/system/kaidi-update.service.d' "$ROOT/deploy/purge.sh" \
|| fail 'updater systemd drop-ins are not removed'
printf 'Local purge and recovery fixture passed\n'
+274 -17
View File
@@ -26,18 +26,35 @@ for version in 01.2.3 1.02.3 1.2.03 1.2.3-01 1.2.3-alpha..1; do
done done
missing_service_user="kaidi-fixture-missing-$$" missing_service_user="kaidi-fixture-missing-$$"
mkdir -p "$WORK/bootstrap/app" "$WORK/bootstrap/state/inbox" \
"$WORK/bootstrap/state/processing" "$WORK/bootstrap/state/failed" "$WORK/bootstrap/log" \
"$WORK/bootstrap/bin"
cat > "$WORK/bootstrap/bin/flock" <<'SH'
#!/bin/sh
exit 0
SH
chmod 0755 "$WORK/bootstrap/bin/flock"
jq -n \
'{action:"DOWNLOAD",version:"1.0.0-preview.2",reason:"bootstrap fixture"}' \
> "$WORK/bootstrap/state/inbox/request.json"
if KAIDI_APP_ROOT="$WORK/bootstrap/app" \ if KAIDI_APP_ROOT="$WORK/bootstrap/app" \
KAIDI_UPDATE_STATE_ROOT="$WORK/bootstrap/state" \ KAIDI_UPDATE_STATE_ROOT="$WORK/bootstrap/state" \
KAIDI_LOG_ROOT="$WORK/bootstrap/log" \ KAIDI_LOG_ROOT="$WORK/bootstrap/log" \
KAIDI_SERVICE_USER="$missing_service_user" \ KAIDI_SERVICE_USER="$missing_service_user" \
KAIDI_SERVICE_GROUP="$missing_service_user" \ KAIDI_SERVICE_GROUP="$missing_service_user" \
PATH="$WORK/bootstrap/bin:$PATH" \
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'
! grep -Eq 'No such file|nonexistent directory|cannot create' "$WORK/bootstrap.log" \ [ "$(jq -r '.state' "$WORK/bootstrap/state/status.json")" = FAILED ] \
|| fail 'updater bootstrap failure was masked by an unavailable status directory' || fail 'updater bootstrap failure did not persist FAILED'
[ ! -e "$WORK/bootstrap/state/inbox/request.json" ] \
&& [ ! -e "$WORK/bootstrap/state/processing/request.json" ] \
|| fail 'updater bootstrap failure left a request permanently queued'
find "$WORK/bootstrap/state/failed" -type f -name 'request-*.json' -print -quit | grep -q . \
|| fail 'updater bootstrap failure did not archive the claimed request'
write_mock_commands() { write_mock_commands() {
local mock_bin=$1 local mock_bin=$1
@@ -48,10 +65,14 @@ write_mock_commands() {
output= output=
url= url=
header_file= header_file=
write_out=
http_status=200
printf '%s\n' "$*" >> "${MOCK_CURL_LOG:-/dev/null}" printf '%s\n' "$*" >> "${MOCK_CURL_LOG:-/dev/null}"
while [ "$#" -gt 0 ]; do while [ "$#" -gt 0 ]; do
case "$1" in case "$1" in
-o) shift; output=$1 ;; -o|--output) shift; output=$1 ;;
--write-out) shift; write_out=$1 ;;
--connect-timeout|--max-time) shift ;;
--header|-H) --header|-H)
shift shift
case "${1:-}" in @*) header_file=${1#@} ;; esac case "${1:-}" in @*) header_file=${1#@} ;; esac
@@ -73,6 +94,26 @@ esac
if [ -n "$output" ]; then if [ -n "$output" ]; then
if [ -n "${MOCK_RELEASE_API_URL:-}" ] && [ "$url" = "$MOCK_RELEASE_API_URL" ]; then if [ -n "${MOCK_RELEASE_API_URL:-}" ] && [ "$url" = "$MOCK_RELEASE_API_URL" ]; then
cp "$FIXTURE_RELEASE_ROOT/release-api.json" "$output" cp "$FIXTURE_RELEASE_ROOT/release-api.json" "$output"
elif [ "$url" = "${KAIDI_HEALTH_URL:-http://127.0.0.1:18080/actuator/health}" ]; then
health_ok=false
case "${MOCK_HEALTH:-success}" in
success) health_ok=true ;;
fail-new)
[ "$(cat "$MOCK_APP_ROOT/current/VERSION" 2>/dev/null)" = '1.0.0-preview.1' ] && health_ok=true
;;
fail-after-first)
health_count=$(cat "$MOCK_APP_ROOT/health-count" 2>/dev/null || printf 0)
health_count=$((health_count + 1))
printf '%s\n' "$health_count" > "$MOCK_APP_ROOT/health-count"
[ "$health_count" -eq 1 ] && health_ok=true
;;
esac
if [ "$health_ok" = true ]; then
printf '{"status":"UP"}\n' > "$output"
else
printf '{"status":"DOWN"}\n' > "$output"
http_status=503
fi
elif [ "$url" = "${KAIDI_APP_INDEX_URL:-http://127.0.0.1:18080/}" ]; then elif [ "$url" = "${KAIDI_APP_INDEX_URL:-http://127.0.0.1:18080/}" ]; then
cp "$MOCK_APP_ROOT/current/public/index.html" "$output" cp "$MOCK_APP_ROOT/current/public/index.html" "$output"
else else
@@ -87,17 +128,24 @@ elif [ "${MOCK_HEALTH:-success}" = fail-new ] \
else else
exit 22 exit 22
fi fi
[ -z "$write_out" ] || printf '%s' "$http_status"
SH SH
cat > "$mock_bin/systemctl" <<'SH' cat > "$mock_bin/systemctl" <<'SH'
#!/bin/sh #!/bin/sh
printf '%s\n' "$*" >> "$MOCK_SYSTEMCTL_LOG" printf '%s\n' "$*" >> "$MOCK_SYSTEMCTL_LOG"
[ "${MOCK_UPDATE_PATH_START:-success}:$*" != 'fail:start kaidi-update.path' ] || exit 1
exit 0 exit 0
SH SH
cat > "$mock_bin/flock" <<'SH' cat > "$mock_bin/flock" <<'SH'
#!/bin/sh #!/bin/sh
exit 0 exit 0
SH
cat > "$mock_bin/setsid" <<'SH'
#!/bin/sh
exec "$@"
SH SH
cat > "$mock_bin/systemd-analyze" <<'SH' cat > "$mock_bin/systemd-analyze" <<'SH'
@@ -170,28 +218,40 @@ SH
build_release() { build_release() {
local fixture=$1 local fixture=$1
local version=$2 local version=$2
local unit_prefix=${3:-new}
local stage="$fixture/stage" local stage="$fixture/stage"
mkdir -p "$fixture/release" "$stage/public" "$stage/ops" mkdir -p "$fixture/release" "$stage/public" "$stage/ops"
if [ ! -s "$WORK/fixture-private.pem" ]; then
"$REAL_OPENSSL" genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \
-out "$WORK/fixture-private.pem" >/dev/null 2>&1
"$REAL_OPENSSL" pkey -in "$WORK/fixture-private.pem" -pubout \
-out "$WORK/fixture-public.pem" >/dev/null 2>&1
fi
cp "$WORK/fixture-private.pem" "$fixture/private.pem"
cp "$WORK/fixture-public.pem" "$fixture/release-public.pem"
printf 'new application\n' > "$stage/app.jar" printf 'new application\n' > "$stage/app.jar"
printf '<!doctype html><title>new</title>\n' > "$stage/public/index.html" printf '<!doctype html><title>new</title>\n' > "$stage/public/index.html"
printf '%s\n' "$version" > "$stage/VERSION" printf '%s\n' "$version" > "$stage/VERSION"
printf '#!/bin/sh\nprintf "new updater\\n"\n' > "$stage/ops/update.sh" printf '#!/bin/sh\nprintf "new updater\\n"\n' > "$stage/ops/update.sh"
printf '#!/usr/bin/env bash\nexit 0\n' > "$stage/ops/baota-start.sh"
printf '#!/usr/bin/env bash\nexit 0\n' > "$stage/ops/baota-init.sh"
cp "$fixture/release-public.pem" "$stage/ops/release-public.pem"
chmod 0755 "$stage/ops/update.sh" chmod 0755 "$stage/ops/update.sh"
chmod 0755 "$stage/ops/baota-start.sh"
chmod 0755 "$stage/ops/baota-init.sh"
for name in kaidi-finance.service kaidi-update.service kaidi-update.path; do for name in kaidi-finance.service kaidi-update.service kaidi-update.path; do
printf 'new %s\n' "$name" > "$stage/ops/$name" printf '%s %s\n' "$unit_prefix" "$name" > "$stage/ops/$name"
done done
local artifact="kaidi-finance-$version.tar.gz" local artifact="kaidi-finance-$version.tar.gz"
COPYFILE_DISABLE=1 tar -czf "$fixture/release/$artifact" -C "$stage" . COPYFILE_DISABLE=1 tar -czf "$fixture/release/$artifact" -C "$stage" .
local sha local sha size
sha=$($REAL_OPENSSL dgst -sha256 "$fixture/release/$artifact" | awk '{print $NF}') sha=$($REAL_OPENSSL dgst -sha256 "$fixture/release/$artifact" | awk '{print $NF}')
jq -n --arg version "$version" --arg artifact "$artifact" --arg sha "$sha" \ size=$(wc -c < "$fixture/release/$artifact" | tr -d '[:space:]')
'{version:$version,artifact:$artifact,sha256:$sha,publishedAt:"2026-08-16T00:00:00Z",releaseNotes:"fixture"}' \ jq -n --arg version "$version" --arg artifact "$artifact" --arg sha "$sha" --argjson size "$size" \
'{version:$version,artifact:$artifact,sha256:$sha,artifactSizeBytes:$size,
publishedAt:"2026-08-16T00:00:00Z",releaseNotes:"fixture"}' \
> "$fixture/release/release-manifest.json" > "$fixture/release/release-manifest.json"
"$REAL_OPENSSL" genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \
-out "$fixture/private.pem" >/dev/null 2>&1
"$REAL_OPENSSL" pkey -in "$fixture/private.pem" -pubout \
-out "$fixture/release-public.pem" >/dev/null 2>&1
"$REAL_OPENSSL" dgst -sha256 -sign "$fixture/private.pem" \ "$REAL_OPENSSL" dgst -sha256 -sign "$fixture/private.pem" \
-out "$fixture/release/release-manifest.sig" "$fixture/release/release-manifest.json" -out "$fixture/release/release-manifest.sig" "$fixture/release/release-manifest.json"
} }
@@ -237,21 +297,38 @@ 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() {
local fixture=$1 local fixture=$1
local version=$2 local version=$2
truncate -s 0 "$fixture/systemctl.log"
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" ] \
|| fail 'download phase did not persist the verified artifact cache' || fail 'download phase did not persist the verified artifact cache'
[ "$(jq -r '.downloadPercent' "$fixture/state/status.json")" -eq 100 ] \
|| fail 'download phase did not persist 100 percent progress'
[ "$(jq -r '.totalBytes' "$fixture/state/status.json")" -gt 0 ] \
|| fail 'download phase did not persist artifact bytes'
[ "$(jq -r '.bytesPerSecond' "$fixture/state/status.json")" -gt 0 ] \
|| fail 'download phase did not persist a measured transfer speed'
grep -Fq '"stage":"DOWNLOADING"' "$fixture/state/events.jsonl" \
|| fail 'download phase did not persist structured runtime events'
! grep -Eq '^(start|restart|stop) kaidi-finance.service$' "$fixture/systemctl.log" \
|| fail 'download phase changed the application service'
write_request "$fixture" "$version" INSTALL write_request "$fixture" "$version" INSTALL
} }
@@ -259,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" \
@@ -272,6 +350,7 @@ run_update() {
MOCK_RELEASE_ORIGIN="${FIXTURE_RELEASE_ORIGIN:-https://release.fixture.invalid}" \ MOCK_RELEASE_ORIGIN="${FIXTURE_RELEASE_ORIGIN:-https://release.fixture.invalid}" \
MOCK_EXPECT_RELEASE_TOKEN="${FIXTURE_RELEASE_TOKEN-}" \ MOCK_EXPECT_RELEASE_TOKEN="${FIXTURE_RELEASE_TOKEN-}" \
MOCK_SYSTEMCTL_LOG="$fixture/systemctl.log" \ MOCK_SYSTEMCTL_LOG="$fixture/systemctl.log" \
MOCK_UPDATE_PATH_START="${MOCK_UPDATE_PATH_START:-success}" \
KAIDI_SERVICE_USER="$(id -un)" \ KAIDI_SERVICE_USER="$(id -un)" \
KAIDI_SERVICE_GROUP="$(id -gn)" \ KAIDI_SERVICE_GROUP="$(id -gn)" \
KAIDI_APP_ROOT="$fixture/app" \ KAIDI_APP_ROOT="$fixture/app" \
@@ -279,7 +358,9 @@ run_update() {
KAIDI_LOG_ROOT="$fixture/log" \ KAIDI_LOG_ROOT="$fixture/log" \
KAIDI_SYSTEMD_ROOT="$fixture/systemd" \ KAIDI_SYSTEMD_ROOT="$fixture/systemd" \
KAIDI_UPDATER_PATH="$fixture/app/bin/update.sh" \ KAIDI_UPDATER_PATH="$fixture/app/bin/update.sh" \
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}" \
@@ -293,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'
@@ -358,9 +453,16 @@ 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'
grep -qx 'restart kaidi-finance.service' "$fixture/systemctl.log" \
|| fail 'success case did not restart the application after the atomic switch'
! grep -qx 'stop kaidi-finance.service' "$fixture/systemctl.log" \
|| fail 'success case stopped the application before switching releases'
! grep -qi nginx "$fixture/systemctl.log" || fail 'updater unexpectedly managed Nginx' ! grep -qi nginx "$fixture/systemctl.log" || fail 'updater unexpectedly managed Nginx'
[ "$(mode_of "$fixture/app")" = 750 ] || fail 'application root is not traversable by the service group' [ "$(mode_of "$fixture/app")" = 750 ] || fail 'application root is not traversable by the service group'
[ "$(mode_of "$fixture/app/releases/$version")" = 750 ] || fail 'release root mode is not 0750' [ "$(mode_of "$fixture/app/releases/$version")" = 750 ] || fail 'release root mode is not 0750'
@@ -368,6 +470,41 @@ assert_success_case() {
[ "$(mode_of "$fixture/app/current/ops/update.sh")" = 750 ] || fail 'release updater mode is not 0750' [ "$(mode_of "$fixture/app/current/ops/update.sh")" = 750 ] || fail 'release updater mode is not 0750'
} }
assert_identical_systemd_operations_case() {
local fixture="$WORK/identical-systemd"
local version='1.0.0-preview.2'
mkdir -p "$fixture"
write_mock_commands "$fixture/mock-bin"
build_release "$fixture" "$version" old
prepare_installation "$fixture" "$version"
download_and_prepare_install "$fixture" "$version"
truncate -s 0 "$fixture/systemctl.log"
run_update "$fixture" success
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/$version" ] \
|| fail 'identical systemd case did not activate the new application'
! grep -qx 'daemon-reload' "$fixture/systemctl.log" \
|| fail 'identical systemd units triggered an unnecessary daemon reload'
}
assert_update_path_failure_keeps_application_case() {
local fixture="$WORK/path-watcher-failure"
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"
MOCK_UPDATE_PATH_START=fail run_update "$fixture" success
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/$version" ] \
|| fail 'path watcher failure rolled back a healthy application'
[ "$(jq -r '.state' "$fixture/state/status.json")" = SUCCEEDED ] \
|| fail 'path watcher failure did not preserve successful application state'
grep -Fq '自动更新监听器未能启动' "$fixture/state/status.json" \
|| fail 'path watcher failure did not preserve its diagnostic'
}
assert_rollback_case() { assert_rollback_case() {
local fixture="$WORK/rollback" local fixture="$WORK/rollback"
local version='1.0.0-preview.2' local version='1.0.0-preview.2'
@@ -380,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 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'
@@ -388,14 +525,62 @@ assert_rollback_case() {
|| fail 'rollback case did not restore the previous updater' || fail 'rollback case did not restore the previous updater'
grep -qx 'old kaidi-update.path' "$fixture/systemd/kaidi-update.path" \ grep -qx 'old kaidi-update.path' "$fixture/systemd/kaidi-update.path" \
|| fail 'rollback case did not restore the previous path unit' || fail 'rollback case did not restore the previous path unit'
! grep -qx 'stop kaidi-finance.service' "$fixture/systemctl.log" \
|| fail 'rollback case stopped the application before restoring the previous release'
[ "$(grep -c '^restart kaidi-finance.service$' "$fixture/systemctl.log")" -ge 2 ] \
|| 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 . \
|| fail 'rollback case did not archive the failed request' || fail 'rollback case did not archive the failed request'
} }
assert_incomplete_rollback_requires_manual_recovery_case() {
local fixture="$WORK/incomplete-rollback"
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"
if run_update "$fixture" fail-after-first > "$fixture/update.log" 2>&1; then
fail 'incomplete rollback case unexpectedly succeeded'
fi
grep -Fq '需要人工恢复' "$fixture/update.log" \
|| fail 'incomplete rollback case did not require explicit recovery'
[ ! -e "$fixture/state/processing/request.json" ] \
|| fail 'incomplete rollback case left an automatically retriggered processing request'
[ ! -e "$fixture/state/transactions/active" ] \
|| fail 'incomplete rollback case left transaction evidence on the automatic recovery path'
[ -d "$fixture/state/transactions/recovery-required" ] \
|| fail 'incomplete rollback case did not quarantine transaction evidence'
[ "$(jq -r '.state' "$fixture/state/status.json")" = RECOVERY_REQUIRED ] \
|| 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 . \
|| fail 'incomplete rollback case did not archive its claimed request'
truncate -s 0 "$fixture/systemctl.log"
write_request "$fixture" "$version" DOWNLOAD
if run_update "$fixture" success > "$fixture/retry.log" 2>&1; then
fail 'recovery-locked updater accepted a new download request'
fi
[ "$(jq -r '.state' "$fixture/state/status.json")" = RECOVERY_REQUIRED ] \
|| fail 'recovery-locked updater replaced the manual recovery status'
! grep -Eq '^(start|restart|stop) kaidi-finance.service$' "$fixture/systemctl.log" \
|| fail 'recovery-locked download request changed the application service'
[ ! -e "$fixture/state/inbox/request.json" ] && [ ! -e "$fixture/state/processing/request.json" ] \
|| fail 'recovery-locked updater left a request on an automatic trigger path'
}
assert_download_failure_case() { assert_download_failure_case() {
local fixture="$WORK/download-failure" local fixture="$WORK/download-failure"
local version='1.0.0-preview.2' local version='1.0.0-preview.2'
@@ -435,6 +620,58 @@ assert_database_failure_case() {
|| fail 'database failure did not persist FAILED' || fail 'database failure did not persist FAILED'
} }
assert_unhealthy_baseline_blocks_restart_case() {
local fixture="$WORK/unhealthy-baseline"
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"
truncate -s 0 "$fixture/systemctl.log"
if run_update "$fixture" fail > "$fixture/update.log" 2>&1; then
fail 'unhealthy baseline unexpectedly reached installation'
fi
grep -Fq '当前版本预检查失败' "$fixture/update.log" \
|| fail 'unhealthy baseline did not preserve its preflight diagnostic'
! grep -Eq '^(start|restart|stop) kaidi-finance.service$' "$fixture/systemctl.log" \
|| fail 'unhealthy baseline restarted the application'
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/1.0.0-preview.1" ] \
|| fail 'unhealthy baseline changed the active release'
[ ! -e "$fixture/state/transactions/active" ] \
|| fail 'unhealthy baseline created a switching transaction'
}
assert_invalid_database_url_blocks_restart_case() {
local fixture="$WORK/invalid-database-url"
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"
cat > "$fixture/runtime.env" <<'EOF'
DB_URL="jdbc:mysql://"
KAIDI_DB_HOST="127.0.0.1"
KAIDI_DB_PORT="3306"
KAIDI_DB_NAME="kaidi_finance"
KAIDI_DB_USERNAME="kaidi"
KAIDI_DB_PASSWORD="fixture"
EOF
truncate -s 0 "$fixture/systemctl.log"
if run_update "$fixture" success > "$fixture/update.log" 2>&1; then
fail 'invalid database URL unexpectedly reached installation'
fi
grep -Fq 'DB_URL 与主机、端口、库名不一致' "$fixture/update.log" \
|| fail 'structurally invalid JDBC URL did not preserve its diagnostic'
! grep -q '^restart kaidi-finance.service$' "$fixture/systemctl.log" \
|| fail 'invalid database URL restarted the application'
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/1.0.0-preview.1" ] \
|| fail 'invalid database URL changed the active release'
}
assert_symlink_request_rejected() { assert_symlink_request_rejected() {
local fixture="$WORK/symlink-request" local fixture="$WORK/symlink-request"
local version='1.0.0-preview.2' local version='1.0.0-preview.2'
@@ -449,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'
@@ -473,18 +710,38 @@ 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'
} }
printf '[update-fixture] success\n'
assert_success_case assert_success_case
printf '[update-fixture] identical-systemd-operations\n'
assert_identical_systemd_operations_case
printf '[update-fixture] path-watcher-failure\n'
assert_update_path_failure_keeps_application_case
printf '[update-fixture] rollback\n'
assert_rollback_case assert_rollback_case
printf '[update-fixture] incomplete-rollback-recovery-lock\n'
assert_incomplete_rollback_requires_manual_recovery_case
printf '[update-fixture] download-failure\n'
assert_download_failure_case assert_download_failure_case
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'
assert_unhealthy_baseline_blocks_restart_case
printf '[update-fixture] invalid-database-url\n'
assert_invalid_database_url_blocks_restart_case
printf '[update-fixture] symlink-request\n'
assert_symlink_request_rejected assert_symlink_request_rejected
printf '[update-fixture] missing-verified-cache\n'
assert_install_without_verified_cache_rejected assert_install_without_verified_cache_rejected
printf '[update-fixture] private-gitea\n'
assert_private_gitea_release_case assert_private_gitea_release_case
printf '[update-fixture] cross-origin-gitea\n'
assert_cross_origin_gitea_browser_url_ignored assert_cross_origin_gitea_browser_url_ignored
printf 'Online update download, confirmation, success, rollback, failure, and unsafe-request fixtures passed\n' printf 'Online update download, confirmation, success, rollback, failure, and unsafe-request fixtures passed\n'
+48 -4
View File
@@ -40,6 +40,7 @@ printf '%s\n' \
bootstrap-checksums.txt \ bootstrap-checksums.txt \
frontend-sbom.cdx.json \ frontend-sbom.cdx.json \
install.sh \ install.sh \
purge.sh \
"$ARTIFACT" \ "$ARTIFACT" \
release-manifest.json \ release-manifest.json \
release-manifest.sig \ release-manifest.sig \
@@ -49,12 +50,16 @@ for path in ./* ./.[!.]* ./..?*; do
basename "$path" basename "$path"
done | LC_ALL=C sort > "$WORK/actual-assets.txt" done | LC_ALL=C sort > "$WORK/actual-assets.txt"
diff -u "$WORK/expected-assets.txt" "$WORK/actual-assets.txt" \ diff -u "$WORK/expected-assets.txt" "$WORK/actual-assets.txt" \
|| { printf 'Release directory must contain exactly the nine published assets\n' >&2; exit 1; } || { printf 'Release directory must contain exactly the ten published assets\n' >&2; exit 1; }
EXPECTED_ARTIFACT_SHA256=$(jq -er \ EXPECTED_ARTIFACT_SHA256=$(jq -er \
'.sha256 | strings | ascii_downcase | select(test("^[0-9a-f]{64}$"))' \ '.sha256 | strings | ascii_downcase | select(test("^[0-9a-f]{64}$"))' \
release-manifest.json) release-manifest.json)
[ "$(sha256_file "$ARTIFACT")" = "$EXPECTED_ARTIFACT_SHA256" ] \ [ "$(sha256_file "$ARTIFACT")" = "$EXPECTED_ARTIFACT_SHA256" ] \
|| { printf 'Release artifact digest does not match the signed manifest\n' >&2; exit 1; } || { printf 'Release artifact digest does not match the signed manifest\n' >&2; exit 1; }
EXPECTED_ARTIFACT_SIZE=$(jq -er '.artifactSizeBytes | numbers | floor | select(. > 0)' \
release-manifest.json)
[ "$(wc -c < "$ARTIFACT" | tr -d '[:space:]')" -eq "$EXPECTED_ARTIFACT_SIZE" ] \
|| { printf 'Release artifact size does not match the signed manifest\n' >&2; exit 1; }
[ "$(jq '.sboms | length' release-manifest.json)" -eq 2 ] \ [ "$(jq '.sboms | length' release-manifest.json)" -eq 2 ] \
|| { printf 'Release manifest must bind two SBOMs\n' >&2; exit 1; } || { printf 'Release manifest must bind two SBOMs\n' >&2; exit 1; }
jq -e --arg version "$VERSION" \ jq -e --arg version "$VERSION" \
@@ -109,16 +114,21 @@ done
PUBLIC_KEY_SHA256=$(sha256_file release-public.pem) PUBLIC_KEY_SHA256=$(sha256_file release-public.pem)
INSTALLER_SHA256=$(sha256_file install.sh) INSTALLER_SHA256=$(sha256_file install.sh)
PURGER_SHA256=$(sha256_file purge.sh)
BOOTSTRAP_SHA256=$(sha256_file bootstrap-checksums.txt) BOOTSTRAP_SHA256=$(sha256_file bootstrap-checksums.txt)
SIGNED_INSTALLER=$(jq -er '.bootstrap.installer' release-manifest.json) SIGNED_INSTALLER=$(jq -er '.bootstrap.installer' release-manifest.json)
SIGNED_PURGER=$(jq -er '.bootstrap.purger' release-manifest.json)
SIGNED_PUBLIC_KEY=$(jq -er '.bootstrap.publicKey' release-manifest.json) SIGNED_PUBLIC_KEY=$(jq -er '.bootstrap.publicKey' release-manifest.json)
SIGNED_BOOTSTRAP=$(jq -er '.bootstrap.checksums' release-manifest.json) SIGNED_BOOTSTRAP=$(jq -er '.bootstrap.checksums' release-manifest.json)
[ "$SIGNED_INSTALLER" = install.sh ] && [ "$SIGNED_PUBLIC_KEY" = release-public.pem ] \ [ "$SIGNED_INSTALLER" = install.sh ] && [ "$SIGNED_PURGER" = purge.sh ] \
&& [ "$SIGNED_PUBLIC_KEY" = release-public.pem ] \
&& [ "$SIGNED_BOOTSTRAP" = bootstrap-checksums.txt ] \ && [ "$SIGNED_BOOTSTRAP" = bootstrap-checksums.txt ] \
|| { printf 'Release manifest bootstrap asset names are invalid\n' >&2; exit 1; } || { printf 'Release manifest bootstrap asset names are invalid\n' >&2; exit 1; }
jq -e --arg installer "$INSTALLER_SHA256" --arg publicKey "$PUBLIC_KEY_SHA256" \ jq -e --arg installer "$INSTALLER_SHA256" --arg purger "$PURGER_SHA256" \
--arg publicKey "$PUBLIC_KEY_SHA256" \
--arg bootstrap "$BOOTSTRAP_SHA256" \ --arg bootstrap "$BOOTSTRAP_SHA256" \
'.bootstrap.installerSha256 == $installer '.bootstrap.installerSha256 == $installer
and .bootstrap.purgerSha256 == $purger
and .bootstrap.publicKeySha256 == $publicKey and .bootstrap.publicKeySha256 == $publicKey
and .bootstrap.checksumsSha256 == $bootstrap' release-manifest.json >/dev/null \ and .bootstrap.checksumsSha256 == $bootstrap' release-manifest.json >/dev/null \
|| { printf 'Bootstrap assets do not match the signed manifest\n' >&2; exit 1; } || { printf 'Bootstrap assets do not match the signed manifest\n' >&2; exit 1; }
@@ -131,6 +141,8 @@ NORMALIZED_TRUSTED_PUBLIC_KEY_SHA256=$(printf '%s' "$TRUSTED_PUBLIC_KEY_SHA256"
|| { printf 'Bootstrap public-key fingerprint does not match\n' >&2; exit 1; } || { printf 'Bootstrap public-key fingerprint does not match\n' >&2; exit 1; }
[ "$(sed -n 's/^KAIDI_INSTALLER_SHA256=//p' bootstrap-checksums.txt)" = "$INSTALLER_SHA256" ] \ [ "$(sed -n 's/^KAIDI_INSTALLER_SHA256=//p' bootstrap-checksums.txt)" = "$INSTALLER_SHA256" ] \
|| { printf 'Bootstrap installer digest does not match\n' >&2; exit 1; } || { printf 'Bootstrap installer digest does not match\n' >&2; exit 1; }
[ "$(sed -n 's/^KAIDI_PURGER_SHA256=//p' bootstrap-checksums.txt)" = "$PURGER_SHA256" ] \
|| { printf 'Bootstrap purger digest does not match\n' >&2; exit 1; }
ARCHIVE_LIST=$(tar -tzf "$ARTIFACT") ARCHIVE_LIST=$(tar -tzf "$ARTIFACT")
if grep -Eq '(^/|(^|/)\.\.(/|$))' <<< "$ARCHIVE_LIST"; then if grep -Eq '(^/|(^|/)\.\.(/|$))' <<< "$ARCHIVE_LIST"; then
@@ -141,17 +153,36 @@ if grep -Eq '(^|/)(signing-private|private-key|id_rsa)' <<< "$ARCHIVE_LIST"; the
printf 'Release archive contains private key material\n' >&2 printf 'Release archive contains private key material\n' >&2
exit 1 exit 1
fi fi
if grep -Eq '(^|/)(\.DS_Store|__MACOSX|[^/]*\._[^/]*)$' <<< "$ARCHIVE_LIST"; then
printf 'Release archive contains macOS metadata\n' >&2
exit 1
fi
if tar -tvzf "$ARTIFACT" | grep -Eq '^l'; then
printf 'Release archive contains a symbolic link\n' >&2
exit 1
fi
[ "$(tar -xOf "$ARTIFACT" ./VERSION)" = "$VERSION" ] \ [ "$(tar -xOf "$ARTIFACT" ./VERSION)" = "$VERSION" ] \
|| { printf 'Release archive version does not match the manifest\n' >&2; exit 1; } || { printf 'Release archive version does not match the manifest\n' >&2; exit 1; }
tar -xOf "$ARTIFACT" ./app.jar > "$WORK/app.jar" tar -xOf "$ARTIFACT" ./app.jar > "$WORK/app.jar"
(cd "$WORK" && jar -xf app.jar \ (cd "$WORK" && jar -xf app.jar \
META-INF/MANIFEST.MF META-INF/maven/com.kaidi/finance-system/pom.properties) META-INF/MANIFEST.MF META-INF/maven/com.kaidi/finance-system/pom.properties \
BOOT-INF/classes/application.yml BOOT-INF/classes/application-local.yml \
BOOT-INF/classes/application-production.yml)
JAR_POM_VERSION=$(sed -n 's/^version=//p' \ JAR_POM_VERSION=$(sed -n 's/^version=//p' \
"$WORK/META-INF/maven/com.kaidi/finance-system/pom.properties") "$WORK/META-INF/maven/com.kaidi/finance-system/pom.properties")
JAR_IMPLEMENTATION_VERSION=$(sed -n 's/^Implementation-Version: //p' \ JAR_IMPLEMENTATION_VERSION=$(sed -n 's/^Implementation-Version: //p' \
"$WORK/META-INF/MANIFEST.MF" | tr -d '\r') "$WORK/META-INF/MANIFEST.MF" | tr -d '\r')
[ "$JAR_POM_VERSION" = "$VERSION" ] && [ "$JAR_IMPLEMENTATION_VERSION" = "$VERSION" ] \ [ "$JAR_POM_VERSION" = "$VERSION" ] && [ "$JAR_IMPLEMENTATION_VERSION" = "$VERSION" ] \
|| { printf 'Release JAR metadata versions do not match the manifest\n' >&2; exit 1; } || { printf 'Release JAR metadata versions do not match the manifest\n' >&2; exit 1; }
grep -Fq 'default: production' "$WORK/BOOT-INF/classes/application.yml" \
|| { printf 'Release JAR does not default to the production profile\n' >&2; exit 1; }
if grep -Eq '127\.0\.0\.1:3307|kaidi_local_2026' \
"$WORK/BOOT-INF/classes/application.yml" \
"$WORK/BOOT-INF/classes/application-local.yml" \
"$WORK/BOOT-INF/classes/application-production.yml"; then
printf 'Release JAR contains a development database fallback\n' >&2
exit 1
fi
compare_archive_file() { compare_archive_file() {
archive_path=$1 archive_path=$1
@@ -160,11 +191,24 @@ compare_archive_file() {
} }
compare_archive_file ./ops/update.sh deploy/update.sh compare_archive_file ./ops/update.sh deploy/update.sh
compare_archive_file ./ops/baota-start.sh deploy/baota-start.sh
compare_archive_file ./ops/baota-init.sh deploy/baota-init.sh
compare_archive_file ./ops/release-public.pem deploy/release-public.pem
compare_archive_file ./ops/baota.env.example deploy/baota.env.example
compare_archive_file ./ops/kaidi-update.path deploy/systemd/kaidi-update.path compare_archive_file ./ops/kaidi-update.path deploy/systemd/kaidi-update.path
compare_archive_file ./ops/kaidi-update.service deploy/systemd/kaidi-update.service compare_archive_file ./ops/kaidi-update.service deploy/systemd/kaidi-update.service
compare_archive_file ./ops/kaidi-finance.service deploy/systemd/kaidi-finance.service compare_archive_file ./ops/kaidi-finance.service deploy/systemd/kaidi-finance.service
compare_archive_file ./ops/kaidi-finance.conf deploy/nginx/kaidi-finance.conf compare_archive_file ./ops/kaidi-finance.conf deploy/nginx/kaidi-finance.conf
tar -tvzf "$ARTIFACT" ./ops/baota-start.sh | grep -Eq '^-.{2}x.{2}x.{2}x' \
|| { printf 'Baota Spring Boot launcher is not executable\n' >&2; exit 1; }
tar -tvzf "$ARTIFACT" ./ops/baota-init.sh | grep -Eq '^-.{2}x.{2}x.{2}x' \
|| { printf 'Baota initializer is not executable\n' >&2; exit 1; }
bash -n "$ROOT/deploy/baota-start.sh"
bash -n "$ROOT/deploy/baota-init.sh"
cmp install.sh "$ROOT/deploy/install.sh" \ cmp install.sh "$ROOT/deploy/install.sh" \
|| { printf 'Release installer does not match deploy/install.sh\n' >&2; exit 1; } || { printf 'Release installer does not match deploy/install.sh\n' >&2; exit 1; }
bash -n "$ROOT/deploy/purge.sh"
cmp purge.sh "$ROOT/deploy/purge.sh" \
|| { printf 'Release purger does not match deploy/purge.sh\n' >&2; exit 1; }
printf 'Release %s verified; installer SHA-256: %s\n' "$VERSION" "$INSTALLER_SHA256" printf 'Release %s verified; installer SHA-256: %s\n' "$VERSION" "$INSTALLER_SHA256"