Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d45be4e97 | ||
|
|
6b04d8dd0a | ||
|
|
fae8225299 | ||
|
|
de63fcffff | ||
|
|
12d9c46323 | ||
|
|
1230116a6f | ||
|
|
8c6c10eb85 | ||
|
|
227c4b0129 | ||
|
|
8dc1163333 | ||
|
|
1a2d472b5c | ||
|
|
7cf71ba7c3 | ||
|
|
ec939fa1fd | ||
|
|
36f1a8b00d | ||
|
|
69f738465e | ||
|
|
2712c5b8c1 | ||
|
|
5a48993d26 | ||
|
|
0b91e1fc5f | ||
|
|
95cd1daeb8 | ||
|
|
d6e97f1b4a | ||
|
|
a975c75827 | ||
|
|
8eb1cbad83 | ||
|
|
cea7048f6c | ||
|
|
10d2e2f0d1 | ||
|
|
bb5e63aaf0 | ||
|
|
2cf29c030a | ||
|
|
6d4ae00ae0 | ||
|
|
3c7847c8f5 | ||
|
|
d61bdf9da4 | ||
|
|
7237792424 | ||
|
|
cfdc0fed44 | ||
|
|
fa4517c263 | ||
|
|
2c73ad7eb0 | ||
|
|
c35ef3d383 | ||
|
|
cea46b494d | ||
|
|
b679381946 | ||
|
|
3480d810d4 | ||
|
|
271eac382e | ||
|
|
52403c6f95 | ||
|
|
4f626da9db | ||
|
|
dc35a48b44 | ||
|
|
e5b419920e | ||
|
|
8d63d60df5 | ||
|
|
74585a5f07 | ||
|
|
b19716aef9 |
@@ -10,7 +10,9 @@ permissions:
|
||||
|
||||
jobs:
|
||||
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:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -18,16 +20,25 @@ jobs:
|
||||
|
||||
- name: Validate release tag
|
||||
id: release_meta
|
||||
env:
|
||||
GITEA_REF_NAME: ${{ github.ref_name }}
|
||||
GITEA_SHA: ${{ github.sha }}
|
||||
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}
|
||||
test "$GITEA_REF_NAME" = "v$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 -z "$(git status --porcelain --untracked-files=all)"
|
||||
printf 'version=%s\n' "$VERSION" >> "$GITHUB_OUTPUT"
|
||||
test -z "$(git ls-files --others --exclude-standard)"
|
||||
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
|
||||
with:
|
||||
@@ -55,8 +66,13 @@ jobs:
|
||||
npm test
|
||||
npm run audit:dependencies
|
||||
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:dist
|
||||
|
||||
- name: Verify release contracts and scripts
|
||||
run: |
|
||||
@@ -64,12 +80,17 @@ jobs:
|
||||
./scripts/test-install-fixture.sh
|
||||
./scripts/test-git-install-fixture.sh
|
||||
./scripts/test-update-fixture.sh
|
||||
./scripts/test-baota-start-fixture.sh
|
||||
./scripts/test-purge-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/generate-release-key.sh scripts/test-install-fixture.sh \
|
||||
scripts/test-git-install-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
|
||||
|
||||
- name: Build and sign release assets
|
||||
@@ -77,14 +98,18 @@ jobs:
|
||||
RELEASE_SIGNING_KEY_B64: ${{ secrets.RELEASE_SIGNING_KEY_B64 }}
|
||||
RELEASE_VERSION: ${{ steps.release_meta.outputs.version }}
|
||||
KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256: ${{ vars.KAIDI_RELEASE_PUBLIC_KEY_SHA256 }}
|
||||
KAIDI_RELEASE_NOTES: Kaidi Finance ${{ github.ref_name }}
|
||||
KAIDI_SOURCE_REVISION: ${{ github.sha }}
|
||||
KAIDI_SOURCE_REF: ${{ github.ref_name }}
|
||||
KAIDI_REQUIRE_RELEASE_NOTES: 'true'
|
||||
KAIDI_SOURCE_REVISION: ${{ steps.release_meta.outputs.revision }}
|
||||
KAIDI_SOURCE_REF: ${{ steps.release_meta.outputs.ref }}
|
||||
KAIDI_SOURCE_DIRTY: 'false'
|
||||
run: |
|
||||
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
|
||||
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"
|
||||
KAIDI_RELEASE_SIGNING_KEY="$RUNNER_TEMP/release-key.pem" \
|
||||
./scripts/package-release.sh "$RELEASE_VERSION"
|
||||
@@ -92,18 +117,20 @@ jobs:
|
||||
- name: Verify signed release assets
|
||||
env:
|
||||
KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256: ${{ vars.KAIDI_RELEASE_PUBLIC_KEY_SHA256 }}
|
||||
KAIDI_EXPECTED_SOURCE_REVISION: ${{ github.sha }}
|
||||
KAIDI_EXPECTED_SOURCE_REF: ${{ github.ref_name }}
|
||||
KAIDI_EXPECTED_SOURCE_REVISION: ${{ steps.release_meta.outputs.revision }}
|
||||
KAIDI_EXPECTED_SOURCE_REF: ${{ steps.release_meta.outputs.ref }}
|
||||
KAIDI_EXPECTED_SOURCE_DIRTY: 'false'
|
||||
run: ./scripts/verify-release.sh dist/release
|
||||
|
||||
- name: Publish Gitea release
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
GITEA_SERVER_URL: ${{ github.server_url }}
|
||||
GITEA_REPOSITORY: ${{ github.repository }}
|
||||
GITEA_REF_NAME: ${{ github.ref_name }}
|
||||
GITEA_SHA: ${{ github.sha }}
|
||||
GITEA_TOKEN: ${{ secrets.RELEASE_GITEA_TOKEN }}
|
||||
# Gitea's runner leaves github.server_url/repository empty on some
|
||||
# host-mode tag events; keep the release destination explicit.
|
||||
GITEA_SERVER_URL: https://git.awaioi.com
|
||||
GITEA_REPOSITORY: ERP-Team/kaidi
|
||||
GITEA_REF_NAME: ${{ steps.release_meta.outputs.ref }}
|
||||
GITEA_SHA: ${{ steps.release_meta.outputs.revision }}
|
||||
run: ./scripts/publish-gitea-release.sh
|
||||
|
||||
- name: Upload Playwright report after failure
|
||||
|
||||
@@ -4,12 +4,16 @@ frontend/dist/
|
||||
frontend/node_modules/
|
||||
frontend/playwright-report/
|
||||
frontend/test-results/
|
||||
frontend/playwright-report-dist/
|
||||
frontend/test-results-dist/
|
||||
.idea/
|
||||
.vscode/
|
||||
*.iml
|
||||
*.log
|
||||
.env.local
|
||||
runtime/
|
||||
.runner
|
||||
.runner.lock
|
||||
/dist/
|
||||
test-results/
|
||||
*signing-private.pem
|
||||
|
||||
@@ -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
|
||||
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
|
||||
```
|
||||
|
||||
@@ -41,151 +51,206 @@ npm run build
|
||||
./scripts/export-openapi.sh http://127.0.0.1:18080
|
||||
```
|
||||
|
||||
## R1 Preview 一键安装
|
||||
## R1 Preview 部署方式
|
||||
|
||||
代码仓库和 Release 已公开,Linux 服务器不需要 Gitea Token。安装器读取
|
||||
`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.11 发布范围。
|
||||
代码仓库和 Release 已公开,提供两种互斥方式:
|
||||
|
||||
执行命令的机器需预装 `bash`、`sudo`、`curl`、`mktemp` 和 `sha256sum`,并能访问目标 Gitea;`jq`、Java
|
||||
和数据库客户端由安装器补齐。应用固定安装到 `/opt/kaidi`、`/var/lib/kaidi`、
|
||||
`/var/lib/kaidi-update` 和 `/etc/kaidi`。安装器不安装、不启动也不修改 Nginx、Caddy、宝塔或其他反向代理。
|
||||
1. **systemd 一键安装(推荐)**:程序独占 `/opt/kaidi` 和 `kaidi-finance.service`,不创建宝塔 Java 项目。
|
||||
2. **宝塔手动部署**:下载压缩包后解压并运行本地初始化脚本,再创建 Spring Boot 项目。
|
||||
|
||||
### 直接 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` 和签名绑定的
|
||||
`release-metadata.json`;打包阶段禁用 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
|
||||
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.11/install.sh | sudo bash
|
||||
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.48/install.sh | sudo env KAIDI_APP_PORT=18080 bash
|
||||
```
|
||||
|
||||
这条命令会提示填写 Java 应用端口,直接回车使用 `18080`;随后安装最新签名 Release,并默认进入 `/setup`
|
||||
安装向导。Java 默认只监听 `127.0.0.1:所选端口`,前端页面、API 和健康检查均由同一端口提供。无人值守安装可直接指定:
|
||||
该命令只安装程序运行所需的 systemd 单元,自动创建 `kaidi` 用户并检测现有 Java 17(包括
|
||||
`/www/server/java/*/bin/java`);没有可用 Java 时才下载匹配架构的运行时。它不会安装 MySQL,也不会要求宝塔面板提供
|
||||
`kaidi` 用户。安装完成后查看 `/root/kaidi-first-login.txt`,通过反向代理域名进入 `/setup`。
|
||||
|
||||
### 彻底清理旧安装
|
||||
|
||||
重新安装前先在宝塔面板停止并删除名为 `kaidi-finance` 的 Java 项目,避免面板守护进程重新拉起旧 Java。
|
||||
随后执行一键清理:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.11/install.sh \
|
||||
| sudo env KAIDI_APP_PORT=19090 bash
|
||||
curl -fsSL 'https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.48/purge.sh' | sudo env KAIDI_PURGE_CONFIRM=DELETE_LOCAL_KAIDI_INSTALLATION bash
|
||||
```
|
||||
|
||||
端口必须在 `1024-65535` 范围且未被其他程序监听;安装器会在安装前检查冲突。只有明确需要让其他主机直连 Java
|
||||
时才设置 `KAIDI_SERVER_ADDRESS=0.0.0.0`,通常应保持默认回环绑定并由本机反向代理访问。需要在执行前独立校验安装脚本时使用:
|
||||
上面是一条完整命令:脚本通过管道直接交给 root 执行,不创建临时安装文件,避免终端自动换行导致 `-o` 参数丢失。
|
||||
如果需要连同本地恢复备份一起删除(确认不再需要回滚后再执行),在同一条命令中增加
|
||||
`KAIDI_PURGE_DELETE_BACKUP=true`。卸载前必须先停止并删除宝塔中的 `kaidi-finance` Java 项目;宝塔的项目元数据、
|
||||
Nginx/反向代理和外部 MySQL 属于外部资源,卸载程序不会误删它们。
|
||||
|
||||
清理脚本会先停止 systemd 和遗留 Kaidi 进程,将旧配置、文件存储、安装码以及已有数据库备份归档到
|
||||
`/root/kaidi-reinstall-backups/`,再删除 `/opt/kaidi`、`/www/wwwroot/kaidi`、`/etc/kaidi`、
|
||||
`/var/lib/kaidi`、`/var/lib/kaidi-update`、`/var/log/kaidi` 和三个 systemd 单元。恢复包权限固定为
|
||||
`0600`,确认新安装及数据无误后再由 root 删除。脚本不删除外部 MySQL、系统 Java、Nginx 或宝塔站点配置;
|
||||
新安装必须连接一个新的空 MySQL 数据库,旧数据库保留用于回滚。
|
||||
|
||||
### Preview.43 直链与宝塔手动部署
|
||||
|
||||
本版提供 systemd 一键安装脚本和宝塔手动部署两种互斥方式。手动部署使用一个不带顶层目录的压缩包,下载后直接解压到版本目录,再由宝塔面板创建
|
||||
Spring Boot 项目。数据库、JDK、Nginx 和宝塔本身都由运维人员准备;程序不会自动安装 MySQL 或任何第三方服务。
|
||||
|
||||
下载地址:
|
||||
|
||||
`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.48/kaidi-finance-1.0.0-preview.48.tar.gz`
|
||||
|
||||
校验文件:`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.48/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
|
||||
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.11/install.sh -o /tmp/kaidi-install.sh
|
||||
printf '%s %s\n' 78bd7a89acc71fc016d1c55816abd466173194bfd71a8f095c73f6deb86babd9 /tmp/kaidi-install.sh | sha256sum -c -
|
||||
sudo bash /tmp/kaidi-install.sh
|
||||
rm -f /tmp/kaidi-install.sh
|
||||
VERSION=1.0.0-preview.48
|
||||
APP_ROOT=/www/wwwroot/kaidi
|
||||
RELEASE_ROOT="$APP_ROOT/releases/$VERSION"
|
||||
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,再运行仓库内包装器。公共仓库不要求凭据,
|
||||
包装器会在 `sudo` 前校验 `deploy/install.sh` 的固定 SHA-256,再按同一公钥信任链安装最新签名 Release。
|
||||
#### 2. 执行本地初始化
|
||||
|
||||
初始化脚本来自已下载的归档,并在本机以 root 运行。它创建 `kaidi` 启动用户、受保护配置和一次性安装码;不会下载
|
||||
Java、安装 MySQL 或修改反向代理。宝塔手动模式不会开放在线更新按钮。端口参数可改为 `1024-65535` 中的空闲端口,默认 `18080`。
|
||||
|
||||
```bash
|
||||
git clone --branch v1.0.0-preview.11 --depth 1 https://git.awaioi.com/ERP-Team/kaidi.git kaidi-preview
|
||||
cd kaidi-preview
|
||||
./deploy/install-from-git.sh
|
||||
sudo "$RELEASE_ROOT/ops/baota-init.sh" 18080
|
||||
sudo cat /root/kaidi-first-login.txt
|
||||
```
|
||||
|
||||
首次安装使用向导时不要传 `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。
|
||||
- 不安装或创建数据库;在 `/setup` 中连接运维人员预先准备的外部 MySQL 8.4.x。
|
||||
- 识别 `x86_64`、`aarch64`、`armv7` 或 32 位 `i386/i486/i586/i686`,校验 SHA-256 后安装对应的 Azul Java 17 JRE。
|
||||
- 使用安装器内置的 SHA-256 指纹校验 Release 公钥,再用该公钥验证发布清单 RSA 签名。
|
||||
- 首次安装默认启用 `/setup` 向导,不在命令行保存数据库密码;向导只接受 MySQL 8.4.x,并在提交前验证 DDL/DML 权限。
|
||||
- 安装签名 Release 到 `/opt/kaidi/releases/<version>`,以 `/opt/kaidi/current` 原子切换当前版本。
|
||||
- 安装 `kaidi-finance.service`、更新监听服务和健康检查;Java 同时托管 TDesign 前端静态资源及 Vue 路由回退。
|
||||
- 向导只初始化一个由操作者填写的 `SYSTEM_ADMIN` 管理员,不创建项目、财务、资料或演示账号。
|
||||
- 安装器把一次性安装码写入仅 root 可读的 `/root/kaidi-first-login.txt`;完成向导后写入锁定标记并切换正式应用。
|
||||
#### 3. 宝塔 Spring Boot 项目字段
|
||||
|
||||
安装完成后先执行 `sudo cat /root/kaidi-first-login.txt`,其中会显示准确的反向代理目标和一次性安装码。配置反向代理后,
|
||||
通过域名的 `/setup` 完成数据库和管理员配置,再访问域名根路径登录。Preview 使用 HTTP 时安装器默认设置
|
||||
`SESSION_COOKIE_SECURE=false`;配置 HTTPS 反向代理后,应在 `/etc/kaidi/kaidi.env` 改为
|
||||
`SESSION_COOKIE_SECURE=true` 并执行 `sudo systemctl restart kaidi-finance`。
|
||||
在宝塔面板创建项目,类型选择 **Spring Boot**,不要选择 Tomcat。填写如下:
|
||||
|
||||
以安装端口 `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
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:19090;
|
||||
proxy_pass http://127.0.0.1:18080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 120s;
|
||||
client_max_body_size 500m;
|
||||
}
|
||||
```
|
||||
|
||||
宝塔、Caddy 或云网关使用同一个上游地址 `http://127.0.0.1:所选端口`。安装后执行以下命令确认应用和首次登录信息:
|
||||
HTTPS 启用后,在 `/etc/kaidi/kaidi.env` 将 `SESSION_COOKIE_SECURE` 设为 `true`,再从宝塔面板重启项目。健康检查:
|
||||
|
||||
```bash
|
||||
curl -fsS http://127.0.0.1:19090/actuator/health | jq -e '.status == "UP"'
|
||||
sudo systemctl --no-pager --full status kaidi-finance kaidi-update.path
|
||||
sudo cat /root/kaidi-first-login.txt
|
||||
curl -fsS http://127.0.0.1:18080/actuator/health
|
||||
curl -fsS http://127.0.0.1:18080/ | head
|
||||
```
|
||||
|
||||
#### 5. 手动部署模式的更新边界
|
||||
|
||||
宝塔手动部署不启用 Preview 的在线更新契约。需要升级时,先停止宝塔项目,下载并校验新的 Release 压缩包,
|
||||
解压到新的 `releases/<VERSION>`,再由宝塔项目切换路径并启动;确认健康检查通过后再删除旧版本。
|
||||
不要在宝塔项目仍运行时点击“系统治理 → 系统更新”,也不要让宝塔守护和 `kaidi-finance.service` 同时管理同一端口。
|
||||
需要后台点击“获取版本 → 下载 → 立即更新并重启”的稳定流程时,使用上一节的 systemd 一键安装方式。
|
||||
|
||||
手动升级由运维人员决定是否先做外部 MySQL 备份;程序不会安装 MySQL 或客户端,也不会修改反向代理配置。
|
||||
|
||||
后续版本仍按同样方式直接下载并解压到新的 `releases/<VERSION>`,保留可回滚的旧目录。
|
||||
|
||||
### Linux 32 位
|
||||
|
||||
应用已按 Java 17 字节码构建,安装器会在 32 位 Linux 下载 `i686` JRE。MySQL 8.4 没有可用于该部署方式的
|
||||
32 位服务端镜像,因此 32 位主机需要预先连接一台 MySQL 8.4 数据库,之后仍然只执行一个安装命令:
|
||||
压缩包本身不绑定 CPU 架构,关键是宿主机提供 glibc、systemd 和可运行的 32 位 Java 17。32 位主机不能在本机运行 64 位 JDK,也不应尝试在本机安装
|
||||
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
|
||||
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.11/install.sh | sudo bash
|
||||
curl -fsSL 'https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.48/purge.sh' | sudo env KAIDI_PURGE_CONFIRM=DELETE_LOCAL_KAIDI_INSTALLATION KAIDI_PURGE_DELETE_BACKUP=true bash
|
||||
```
|
||||
|
||||
32 位主机不能运行安装器自动创建的 MySQL 容器,因此在打开向导前,需要预先创建 `kaidi_finance`,并授予安装账号该库的
|
||||
DDL、DML 权限。向导会连接数据库、核验 MySQL 8.4.x 版本并用临时表验证权限,全部通过后 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.11/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.11/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
|
||||
```
|
||||
执行前先在宝塔停止并删除 `kaidi-finance` Java 项目;宝塔面板元数据属于外部资源,必须由面板先停用,
|
||||
否则守护进程会重新拉起 Java,卸载程序会明确报出原因而不误删其他服务。
|
||||
|
||||
## Release 与在线更新
|
||||
|
||||
@@ -199,75 +264,80 @@ base64 < release-signing-private.pem | tr -d '\n'
|
||||
将私钥的 Base64 内容保存为 Gitea Actions Secret `RELEASE_SIGNING_KEY_B64`,并将命令输出的公钥 SHA-256
|
||||
保存为 Gitea Actions Variable `KAIDI_RELEASE_PUBLIC_KEY_SHA256`。Gitea 发布 Token 只用于该工作流创建
|
||||
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`;
|
||||
Preview 属性由 SemVer 版本名表达。之后推送 tag 即会构建、测试、签名并发布:
|
||||
Preview 属性由 SemVer 版本名表达。每个版本必须先在 `release-notes/<version>.md` 写好面向用户的更新日志。
|
||||
打包器会把同一份内容写入签名 `release-manifest.json`,并把同一份签名元数据随应用制品写入
|
||||
`release-metadata.json`。后台在线检查优先读取 Release 清单;应用重启后若旧版更新器没有把说明写入状态文件,
|
||||
则从当前已验签制品中的元数据恢复,再写入终态审计,因此不依赖重启后的 Gitea 网络请求。发布脚本也会用
|
||||
清单生成 Gitea Release 正文,避免页面、清单和制品三处内容不一致。之后推送 tag 即会构建、测试、签名并发布:
|
||||
|
||||
```bash
|
||||
git tag v1.0.0-preview.11
|
||||
git push origin v1.0.0-preview.11
|
||||
git tag v1.0.0-preview.48
|
||||
git push origin v1.0.0-preview.48
|
||||
```
|
||||
|
||||
在线更新使用独立的 TDesign 页面:隔离的系统管理员进入“系统治理 → 系统更新”。权限与配置页只管理用户、角色、数据范围、表单模板和参数版本,不配置系统名称或域名。
|
||||
更新源由 root 在 `/etc/kaidi/update.env` 固定为公共 Gitea Latest Release API;页面和普通 API 不能提交
|
||||
URL、Token、脚本或命令。更新流程固定为:
|
||||
在线更新仍使用独立的 TDesign 页面:系统管理员进入“系统治理 → 系统更新”。更新源由 root 在
|
||||
`/etc/kaidi/update.env` 固定为公共 Gitea Latest Release API;页面和普通 API 不能提交 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 份。
|
||||
6. 校验更新脚本和 systemd 单元后,原子切换 updater、systemd 和应用版本,不修改反向代理。
|
||||
7. 同时检查 Java 健康端点、更新 path unit 和 Java 托管的静态首页。全部通过后页面显示 10 秒倒计时并自动
|
||||
刷新;刷新或短暂断线发生在安装中时,页面会恢复 3 秒轮询。任一检查失败则恢复并验证上一版本。
|
||||
更新流程固定为:
|
||||
|
||||
忙碌期间检查、下载和安装按钮保持禁用,防止重复请求;这就是更新执行冷却。10 秒只用于成功后的页面刷新,
|
||||
不会延迟服务端切换。systemd 在 300 秒内连续失败 3 次后停止自动重试,避免失败任务空跑。
|
||||
1. 点击“获取版本”,只实时请求 Gitea Latest Release 版本、发布时间和发布说明,不排队下载。
|
||||
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`,两者权限均为
|
||||
`0600`:前者供 Java 后端“检查更新”读取,后者供 root 更新器下载资产。默认 Token 为空;如改接私有镜像,
|
||||
可由运维人员在这两个文件中配置独立只读 Token,Token 不写入页面、状态 JSON、审计参数或更新日志。
|
||||
|
||||
数据库迁移必须保持至少一个版本的向后兼容。查看状态和日志:
|
||||
忙碌期间检查、下载和安装按钮保持禁用,防止重复请求;10 秒只用于成功后的页面刷新,不延迟服务端切换。数据库迁移必须至少保持一个版本向后兼容。
|
||||
查看状态和日志:
|
||||
|
||||
```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
|
||||
cat /var/lib/kaidi-update/status.json
|
||||
```
|
||||
|
||||
如果 `status.json` 显示 `FAILED` 且日志提示回滚未完成,不要删除
|
||||
`/var/lib/kaidi-update/processing/request.json` 或活动事务目录。systemd 在 300 秒内连续失败 3 次后会停止自动重试,
|
||||
修复日志所示的磁盘、权限或旧版本健康问题后执行:
|
||||
进程被中断时,`kaidi-update.path` 会根据 `processing/request.json` 或 `transactions/active` 自动恢复一次。
|
||||
如果 `status.json` 显示 `RECOVERY_REQUIRED`,失败请求会归档到 `/var/lib/kaidi-update/failed`,事务证据会移到
|
||||
`transactions/recovery-required*` 并退出自动触发路径,新下载和安装请求也会被拒绝。修复日志所示的磁盘、权限或
|
||||
旧版本健康问题后,将唯一一份待恢复事务移回 `active`,再明确执行一次:
|
||||
|
||||
```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 start kaidi-update.service
|
||||
sudo journalctl -u kaidi-update.service -n 100 --no-pager
|
||||
cat /var/lib/kaidi-update/status.json
|
||||
```
|
||||
|
||||
只有状态恢复为 `SUCCEEDED`、`CURRENT` 或确定性的终态 `FAILED`,且 `transactions/active` 已处理完成后,
|
||||
才算本次恢复结束。后台会保留真实失败状态,不会把待恢复的 processing 请求误显示成普通排队。
|
||||
如果目录带时间后缀,先通过 `ls -1d /var/lib/kaidi-update/transactions/recovery-required*` 确认唯一目录,再替换上面
|
||||
命令中的源路径。只有状态恢复为 `SUCCEEDED`、`CURRENT` 或确定性的终态 `FAILED`,且 `transactions/active` 已处理
|
||||
完成后,才算本次恢复结束。后台会保留真实失败状态,不会把待恢复请求误显示成普通排队。
|
||||
|
||||
## 手工生成 Release
|
||||
|
||||
```bash
|
||||
KAIDI_RELEASE_SIGNING_KEY=/secure/release-signing-private.pem \
|
||||
KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \
|
||||
./scripts/package-release.sh 1.0.0-preview.11
|
||||
./scripts/package-release.sh 1.0.0-preview.48
|
||||
KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \
|
||||
./scripts/verify-release.sh dist/release
|
||||
```
|
||||
|
||||
输出位于 `dist/release/`,包含安装脚本、签名清单、公钥、SHA-256 清单、前后端 CycloneDX SBOM、
|
||||
首次安装指纹文件和完整应用压缩包。
|
||||
输出位于 `dist/release/`,包含安装脚本、彻底清理脚本、签名清单、公钥、SHA-256 清单、前后端 CycloneDX
|
||||
SBOM、首次安装指纹文件和完整应用压缩包。
|
||||
打包脚本会把 Maven `revision` 与 npm 包版本临时绑定到 Release SemVer,构建结束后恢复工作区源文件;
|
||||
JAR、两个 SBOM、签名清单或 tag 的版本只要有一项不一致,发布即失败。
|
||||
签名清单同时绑定应用包、两份 SBOM、安装器、公钥、bootstrap 指纹和 Git 源码修订;本地脏工作区会明确记录
|
||||
`source.dirty=true`,tag 工作流只接受干净 checkout 并记录 `source.dirty=false`。
|
||||
发布命令同时在终端输出 `Trusted release public-key SHA-256` 与 `Installer SHA-256`;把这两个值写入
|
||||
受控部署记录,再替换上述一键安装命令中的占位符。
|
||||
发布命令同时输出 Release 公钥和归档校验值;将压缩包 SHA-256 写入直链部署说明后再发布。
|
||||
|
||||
@@ -17,6 +17,6 @@ public class SetupLockedController {
|
||||
@GetMapping("/status")
|
||||
@PreAuthorize("true")
|
||||
public ApiResponse<SetupViews.Status> status() {
|
||||
return ApiResponse.ok(new SetupViews.Status(false, true, List.of("MYSQL"), "系统已完成安装"));
|
||||
return ApiResponse.ok(new SetupViews.Status(false, true, true, List.of("MYSQL"), "系统已完成安装"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@ import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.flywaydb.core.api.FlywayException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
@@ -37,8 +40,15 @@ import org.springframework.transaction.support.TransactionTemplate;
|
||||
@ConditionalOnProperty(name = "finance.setup.enabled", havingValue = "true")
|
||||
public class SetupService {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(SetupService.class);
|
||||
private static final String MYSQL_PREFIX = "jdbc:mysql://";
|
||||
private static final String MYSQL_CHARACTER_SET = "utf8mb4";
|
||||
private static final String MYSQL_COLLATION = "utf8mb4_0900_ai_ci";
|
||||
private static final String RECOVERABLE_V068_SCRIPT = "V068__source_table_column_validation_metadata.sql";
|
||||
private static final List<String> SUPPORTED_TYPES = List.of(DatabaseType.MYSQL.name());
|
||||
private static final Set<Integer> MYSQL_PERMISSION_ERROR_CODES = Set.of(1044, 1045, 1142, 1227, 1370);
|
||||
private static final long RESTART_TRIGGER_DELAY_MILLIS = 2_000L;
|
||||
private static final int LOGIN_REDIRECT_DELAY_SECONDS = 10;
|
||||
|
||||
private final SetupProperties properties;
|
||||
private final AtomicBoolean locked = new AtomicBoolean(false);
|
||||
@@ -47,7 +57,7 @@ public class SetupService {
|
||||
public SetupService(SetupProperties properties) {
|
||||
this.properties = properties;
|
||||
if (properties.tokenSha256() == null || !properties.tokenSha256().matches("(?i)[0-9a-f]{64}")) {
|
||||
throw new IllegalStateException("FINANCE_SETUP_TOKEN_SHA256 must be a 64-character SHA-256 value");
|
||||
throw new IllegalStateException("FINANCE_SETUP_TOKEN_SHA256 必须是 64 位 SHA-256 值");
|
||||
}
|
||||
if (Files.exists(Path.of(properties.markerFile()))) {
|
||||
locked.set(true);
|
||||
@@ -56,7 +66,7 @@ public class SetupService {
|
||||
|
||||
public SetupViews.Status status() {
|
||||
boolean isLocked = locked.get() || Files.exists(Path.of(properties.markerFile()));
|
||||
return new SetupViews.Status(!isLocked, isLocked, SUPPORTED_TYPES,
|
||||
return new SetupViews.Status(!isLocked, isLocked, false, SUPPORTED_TYPES,
|
||||
isLocked ? "安装向导已锁定" : "请完成数据库和管理员初始化");
|
||||
}
|
||||
|
||||
@@ -69,13 +79,14 @@ public class SetupService {
|
||||
// Connection testing is useful for diagnostics, but the current business SQL/migration
|
||||
// baseline is MySQL-specific. Do not allow an apparently successful test to produce a
|
||||
// database that the main application cannot start against.
|
||||
ConnectionResult connection = testConnection(settings);
|
||||
ConnectionResult connection = testConnection(settings, false);
|
||||
return new SetupViews.Connection(connection.successful(), settings.type().name(), connection.version(),
|
||||
false, "PostgreSQL 连接可用,但当前 Preview 的业务迁移仅支持 MySQL 8.4");
|
||||
}
|
||||
ConnectionResult connection = testConnection(settings);
|
||||
return new SetupViews.Connection(true, settings.type().name(), connection.version(), true,
|
||||
"MySQL 8.4 连接和 DDL/DML 权限验证通过");
|
||||
ConnectionResult connection = testConnection(settings, false);
|
||||
LOGGER.info("MySQL 只读连接测试通过,未执行数据库写操作,等待管理员确认完成安装");
|
||||
return new SetupViews.Connection(true, settings.type().name(), connection.version(), false,
|
||||
"MySQL 8.4 只读连接验证通过;尚未修改数据库,点击完成安装后才会执行迁移");
|
||||
}
|
||||
|
||||
public SetupViews.Completed complete(SetupContracts.CompleteRequest request) {
|
||||
@@ -93,7 +104,10 @@ public class SetupService {
|
||||
}
|
||||
synchronized (this) {
|
||||
ensureOpen();
|
||||
ConnectionResult connection = testConnection(settings);
|
||||
// The explicit completion action is the point at which the operator
|
||||
// authorizes schema changes and migration privilege checks.
|
||||
LOGGER.info("管理员已确认完成安装,开始执行 MySQL 迁移和管理员初始化");
|
||||
ConnectionResult connection = testConnection(settings, true);
|
||||
if (!connection.successful()) {
|
||||
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
"DATABASE_CONNECTION_FAILED", "数据库连接失败,请检查地址、端口、库名和账号");
|
||||
@@ -106,7 +120,7 @@ public class SetupService {
|
||||
if (properties.restartAfterComplete()) {
|
||||
Thread restart = new Thread(() -> {
|
||||
try {
|
||||
Thread.sleep(750L);
|
||||
Thread.sleep(RESTART_TRIGGER_DELAY_MILLIS);
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
@@ -116,18 +130,14 @@ public class SetupService {
|
||||
restart.start();
|
||||
}
|
||||
return new SetupViews.Completed(true, request.adminUsername(),
|
||||
"安装完成,系统正在切换到正式模式", 10);
|
||||
"安装完成,系统正在切换到正式模式", LOGIN_REDIRECT_DELAY_SECONDS);
|
||||
}
|
||||
|
||||
private void migrateAndCreateAdministrator(DatabaseSettings settings, SetupContracts.CompleteRequest request) {
|
||||
try {
|
||||
ensureCompatibleSchema(settings);
|
||||
Flyway.configure()
|
||||
.dataSource(settings.jdbcUrl(), settings.username(), settings.password())
|
||||
.locations("classpath:db/migration")
|
||||
.cleanDisabled(true)
|
||||
.load()
|
||||
.migrate();
|
||||
prepareDatabaseForInstallation(settings);
|
||||
validateSchemaForInstallation(settings);
|
||||
flyway(settings).migrate();
|
||||
DriverManagerDataSource dataSource = new DriverManagerDataSource(settings.jdbcUrl(), settings.username(),
|
||||
settings.password());
|
||||
JdbcTemplate jdbc = new JdbcTemplate(dataSource);
|
||||
@@ -137,12 +147,112 @@ public class SetupService {
|
||||
if (exception instanceof SetupException setupException) {
|
||||
throw setupException;
|
||||
}
|
||||
LOGGER.error("数据库迁移或管理员初始化失败", exception);
|
||||
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
"DATABASE_INITIALIZATION_FAILED", "数据库迁移或管理员初始化失败,请检查账号的 DDL/DML 权限");
|
||||
"DATABASE_INITIALIZATION_FAILED", databaseInitializationFailureMessage(exception));
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureCompatibleSchema(DatabaseSettings settings) {
|
||||
private void prepareDatabaseForInstallation(DatabaseSettings settings) {
|
||||
JdbcTemplate jdbc = new JdbcTemplate(new DriverManagerDataSource(settings.jdbcUrl(), settings.username(),
|
||||
settings.password()));
|
||||
try {
|
||||
DatabaseCollation current = databaseCollation(jdbc, settings.database());
|
||||
if (!MYSQL_CHARACTER_SET.equalsIgnoreCase(current.characterSet())
|
||||
|| !MYSQL_COLLATION.equalsIgnoreCase(current.collation())) {
|
||||
jdbc.execute("ALTER DATABASE " + quoteIdentifier(settings.database())
|
||||
+ " CHARACTER SET " + MYSQL_CHARACTER_SET + " COLLATE " + MYSQL_COLLATION);
|
||||
DatabaseCollation updated = databaseCollation(jdbc, settings.database());
|
||||
if (!MYSQL_CHARACTER_SET.equalsIgnoreCase(updated.characterSet())
|
||||
|| !MYSQL_COLLATION.equalsIgnoreCase(updated.collation())) {
|
||||
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
"DATABASE_COLLATION_UNSUPPORTED", "数据库字符集或排序规则未能统一为 "
|
||||
+ MYSQL_CHARACTER_SET + "/" + MYSQL_COLLATION);
|
||||
}
|
||||
LOGGER.info("已将数据库 {} 的字符集/排序规则从 {}/{} 统一为 {}/{}", settings.database(),
|
||||
current.characterSet(), current.collation(), updated.characterSet(), updated.collation());
|
||||
}
|
||||
recoverV068CollationFailure(jdbc, settings.database());
|
||||
} catch (SetupException exception) {
|
||||
throw exception;
|
||||
} catch (RuntimeException exception) {
|
||||
LOGGER.error("数据库字符集和排序规则准备失败", exception);
|
||||
SQLException sqlException = findSqlException(exception);
|
||||
String detail = sqlException == null
|
||||
? "数据库字符集和排序规则初始化失败:" + safeErrorMessage(exception)
|
||||
: "数据库字符集和排序规则初始化失败" + sqlErrorSummary(sqlException);
|
||||
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
"DATABASE_COLLATION_PREPARATION_FAILED", detail);
|
||||
}
|
||||
}
|
||||
|
||||
private DatabaseCollation databaseCollation(JdbcTemplate jdbc, String database) {
|
||||
return jdbc.queryForObject("""
|
||||
SELECT default_character_set_name, default_collation_name
|
||||
FROM information_schema.schemata
|
||||
WHERE schema_name = ?
|
||||
""", (result, row) -> new DatabaseCollation(
|
||||
result.getString("default_character_set_name"), result.getString("default_collation_name")),
|
||||
database);
|
||||
}
|
||||
|
||||
private void recoverV068CollationFailure(JdbcTemplate jdbc, String database) {
|
||||
Integer historyCount = jdbc.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = ? AND table_name = 'flyway_schema_history'
|
||||
""", Integer.class, database);
|
||||
if (historyCount == null || historyCount == 0) {
|
||||
return;
|
||||
}
|
||||
List<FailedMigration> failures = jdbc.query("""
|
||||
SELECT installed_rank, version, script
|
||||
FROM flyway_schema_history
|
||||
WHERE success = FALSE
|
||||
ORDER BY installed_rank
|
||||
""", (result, row) -> new FailedMigration(result.getInt("installed_rank"),
|
||||
result.getString("version"), result.getString("script")));
|
||||
if (failures.size() != 1) {
|
||||
return;
|
||||
}
|
||||
FailedMigration failure = failures.get(0);
|
||||
if (!("68".equals(failure.version()) || "068".equals(failure.version()))
|
||||
|| !RECOVERABLE_V068_SCRIPT.equals(failure.script())) {
|
||||
return;
|
||||
}
|
||||
Integer predecessorCount = jdbc.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM flyway_schema_history
|
||||
WHERE version IN ('67', '067') AND success = TRUE
|
||||
""", Integer.class);
|
||||
if (predecessorCount == null || predecessorCount != 1) {
|
||||
return;
|
||||
}
|
||||
Integer laterSuccessCount = jdbc.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM flyway_schema_history
|
||||
WHERE installed_rank > ? AND success = TRUE
|
||||
""", Integer.class, failure.installedRank());
|
||||
if (laterSuccessCount != null && laterSuccessCount > 0) {
|
||||
return;
|
||||
}
|
||||
int deleted = jdbc.update("""
|
||||
DELETE FROM flyway_schema_history
|
||||
WHERE installed_rank = ? AND version = ? AND script = ? AND success = FALSE
|
||||
""", failure.installedRank(), failure.version(), failure.script());
|
||||
if (deleted != 1) {
|
||||
throw new SetupException(org.springframework.http.HttpStatus.CONFLICT,
|
||||
"DATABASE_MIGRATION_RECOVERY_CONFLICT", "V068 迁移恢复状态已变化,请重新测试数据库连接");
|
||||
}
|
||||
LOGGER.warn("数据库字符集统一后已移除可恢复的失败迁移记录 {}",
|
||||
RECOVERABLE_V068_SCRIPT);
|
||||
}
|
||||
|
||||
private String quoteIdentifier(String identifier) {
|
||||
return "`" + identifier.replace("`", "``") + "`";
|
||||
}
|
||||
|
||||
private boolean ensureCompatibleSchema(DatabaseSettings settings) {
|
||||
JdbcTemplate jdbc = new JdbcTemplate(new DriverManagerDataSource(settings.jdbcUrl(), settings.username(),
|
||||
settings.password()));
|
||||
Integer tableCount = jdbc.queryForObject("""
|
||||
@@ -160,7 +270,42 @@ public class SetupService {
|
||||
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
"DATABASE_NOT_EMPTY", "请选择空数据库,或提供已有 Kaidi Flyway 数据库");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void validateSchemaForInstallation(DatabaseSettings settings) {
|
||||
try {
|
||||
if (!ensureCompatibleSchema(settings)) {
|
||||
return;
|
||||
}
|
||||
flyway(settings).validate();
|
||||
} catch (SetupException exception) {
|
||||
throw exception;
|
||||
} catch (FlywayException exception) {
|
||||
LOGGER.error("数据库迁移历史校验失败", exception);
|
||||
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
"DATABASE_MIGRATION_STATE_INVALID",
|
||||
"数据库迁移历史校验失败,请使用空数据库,或先修复已有迁移状态:" + safeErrorMessage(exception));
|
||||
} catch (RuntimeException exception) {
|
||||
LOGGER.error("数据库结构检查失败", exception);
|
||||
SQLException sqlException = findSqlException(exception);
|
||||
String detail = sqlException == null
|
||||
? "数据库结构检查失败:" + safeErrorMessage(exception)
|
||||
: databaseConnectionFailureMessage(sqlException, true);
|
||||
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
"DATABASE_SCHEMA_INSPECTION_FAILED", detail);
|
||||
}
|
||||
}
|
||||
|
||||
private Flyway flyway(DatabaseSettings settings) {
|
||||
return Flyway.configure()
|
||||
.dataSource(settings.jdbcUrl(), settings.username(), settings.password())
|
||||
.locations("classpath:db/migration")
|
||||
.ignoreMigrationPatterns("*:pending")
|
||||
.cleanDisabled(true)
|
||||
.load();
|
||||
}
|
||||
|
||||
private void initializeInstallation(JdbcTemplate jdbc, SetupContracts.CompleteRequest request) {
|
||||
@@ -267,31 +412,170 @@ public class SetupService {
|
||||
}
|
||||
}
|
||||
|
||||
private ConnectionResult testConnection(DatabaseSettings settings) {
|
||||
private ConnectionResult testConnection(DatabaseSettings settings, boolean verifyPrivileges) {
|
||||
try (Connection connection = DriverManager.getConnection(settings.jdbcUrl(), settings.username(),
|
||||
settings.password()); Statement statement = connection.createStatement()) {
|
||||
statement.setQueryTimeout(10);
|
||||
String version;
|
||||
try (ResultSet result = statement.executeQuery("SELECT VERSION()")) {
|
||||
result.next();
|
||||
version = result.getString(1);
|
||||
settings.password())) {
|
||||
// A connection test must not create or mutate database objects. JDBC metadata
|
||||
// is supplied by the handshake and avoids issuing SELECT/DDL/DML in the test step.
|
||||
String version = connection.getMetaData().getDatabaseProductVersion();
|
||||
if (version == null || version.isBlank()) {
|
||||
version = "unknown";
|
||||
}
|
||||
if (settings.type() == DatabaseType.MYSQL && !version.startsWith("8.4.")) {
|
||||
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
"MYSQL_VERSION_UNSUPPORTED", "当前版本要求 MySQL 8.4.x,检测到 " + version);
|
||||
}
|
||||
String probe = "kaidi_setup_probe_" + UUID.randomUUID().toString().replace("-", "");
|
||||
try {
|
||||
statement.execute("CREATE TABLE " + probe + " (id INT NOT NULL PRIMARY KEY)");
|
||||
statement.execute("INSERT INTO " + probe + " (id) VALUES (1)");
|
||||
statement.execute("UPDATE " + probe + " SET id = 2 WHERE id = 1");
|
||||
} finally {
|
||||
statement.execute("DROP TABLE IF EXISTS " + probe);
|
||||
if (verifyPrivileges) {
|
||||
try (Statement statement = connection.createStatement()) {
|
||||
statement.setQueryTimeout(10);
|
||||
verifyMigrationPrivileges(connection, statement, settings.type());
|
||||
}
|
||||
}
|
||||
return new ConnectionResult(true, version);
|
||||
} catch (SQLException exception) {
|
||||
LOGGER.warn(verifyPrivileges
|
||||
? "数据库连接或迁移权限验证失败:SQLState={},错误码={}"
|
||||
: "数据库只读连接测试失败:SQLState={},错误码={}",
|
||||
exception.getSQLState(), exception.getErrorCode(), exception);
|
||||
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
"DATABASE_CONNECTION_FAILED", "数据库连接或 DDL/DML 权限验证失败");
|
||||
"DATABASE_CONNECTION_FAILED", databaseConnectionFailureMessage(exception, verifyPrivileges));
|
||||
}
|
||||
}
|
||||
|
||||
private String databaseConnectionFailureMessage(SQLException exception, boolean verifyPrivileges) {
|
||||
if (isPermissionError(exception)) {
|
||||
return (verifyPrivileges ? "数据库账号缺少完整迁移权限" : "数据库账号无权建立连接")
|
||||
+ sqlErrorSummary(exception);
|
||||
}
|
||||
if (exception.getSQLState() != null && exception.getSQLState().startsWith("08")) {
|
||||
return "数据库连接超时或中断" + sqlErrorSummary(exception);
|
||||
}
|
||||
return (verifyPrivileges ? "数据库连接或完整迁移权限验证失败" : "数据库只读连接测试失败")
|
||||
+ sqlErrorSummary(exception);
|
||||
}
|
||||
|
||||
private String databaseInitializationFailureMessage(Throwable exception) {
|
||||
SQLException sqlException = findSqlException(exception);
|
||||
if (sqlException != null) {
|
||||
if (isPermissionError(sqlException)) {
|
||||
return "数据库账号缺少完成迁移所需权限" + sqlErrorSummary(sqlException);
|
||||
}
|
||||
if (sqlException.getSQLState() != null && sqlException.getSQLState().startsWith("08")) {
|
||||
return "数据库迁移期间连接超时或中断" + sqlErrorSummary(sqlException);
|
||||
}
|
||||
return "数据库迁移或管理员初始化失败" + sqlErrorSummary(sqlException);
|
||||
}
|
||||
return "数据库迁移或管理员初始化失败:" + safeErrorMessage(exception);
|
||||
}
|
||||
|
||||
private SQLException findSqlException(Throwable exception) {
|
||||
Throwable current = exception;
|
||||
while (current != null) {
|
||||
if (current instanceof SQLException sqlException) {
|
||||
return sqlException;
|
||||
}
|
||||
current = current.getCause();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean isPermissionError(SQLException exception) {
|
||||
return MYSQL_PERMISSION_ERROR_CODES.contains(exception.getErrorCode());
|
||||
}
|
||||
|
||||
private String sqlErrorSummary(SQLException exception) {
|
||||
String state = exception.getSQLState() == null ? "UNKNOWN" : exception.getSQLState();
|
||||
return "(SQLState " + state + ",错误码 " + exception.getErrorCode() + "):"
|
||||
+ safeErrorMessage(exception);
|
||||
}
|
||||
|
||||
private String safeErrorMessage(Throwable exception) {
|
||||
Throwable current = exception;
|
||||
while (current.getCause() != null) {
|
||||
current = current.getCause();
|
||||
}
|
||||
String message = current.getMessage() == null ? current.getClass().getSimpleName() : current.getMessage();
|
||||
String sanitized = message.replaceAll("(?i)(password|pwd)=([^\\s&;]+)", "$1=***")
|
||||
.replaceAll("\\s+", " ").trim();
|
||||
return sanitized.length() <= 240 ? sanitized : sanitized.substring(0, 240) + "...";
|
||||
}
|
||||
|
||||
private void verifyMigrationPrivileges(Connection connection, Statement statement, DatabaseType databaseType)
|
||||
throws SQLException {
|
||||
String suffix = UUID.randomUUID().toString().replace("-", "");
|
||||
String parentTable = "kaidi_setup_probe_parent_" + suffix;
|
||||
String childTable = "kaidi_setup_probe_child_" + suffix;
|
||||
String foreignKey = "fk_setup_probe_" + suffix;
|
||||
String index = "idx_setup_probe_" + suffix;
|
||||
String routine = "kaidi_setup_probe_routine_" + suffix;
|
||||
String temporaryTable = "kaidi_setup_probe_temp_" + suffix;
|
||||
try {
|
||||
statement.execute("CREATE TABLE " + parentTable + " (id INT NOT NULL PRIMARY KEY)");
|
||||
if (databaseType == DatabaseType.MYSQL) {
|
||||
statement.execute("CREATE TABLE " + childTable
|
||||
+ " (id INT NOT NULL PRIMARY KEY, parent_id INT NOT NULL, CONSTRAINT " + foreignKey
|
||||
+ " FOREIGN KEY (parent_id) REFERENCES " + parentTable + " (id))");
|
||||
statement.execute("ALTER TABLE " + childTable + " ADD COLUMN note VARCHAR(32) NULL");
|
||||
statement.execute("CREATE INDEX " + index + " ON " + childTable + " (parent_id)");
|
||||
statement.execute("CREATE PROCEDURE " + routine + "() SELECT 1 AS probe_value");
|
||||
try (Statement call = connection.createStatement();
|
||||
ResultSet result = call.executeQuery("CALL " + routine + "()")) {
|
||||
if (!result.next() || result.getInt(1) != 1) {
|
||||
throw new SQLException("迁移权限存储过程探针返回了无效结果");
|
||||
}
|
||||
}
|
||||
statement.execute("INSERT INTO " + parentTable + " (id) VALUES (1)");
|
||||
statement.execute("INSERT INTO " + childTable + " (id, parent_id, note) VALUES (1, 1, 'probe')");
|
||||
try (ResultSet result = statement.executeQuery("SELECT note FROM " + childTable + " WHERE id = 1")) {
|
||||
if (!result.next() || !"probe".equals(result.getString(1))) {
|
||||
throw new SQLException("迁移权限 SELECT 探针返回了无效结果");
|
||||
}
|
||||
}
|
||||
statement.execute("CREATE TEMPORARY TABLE " + temporaryTable
|
||||
+ " (id INT NOT NULL PRIMARY KEY)");
|
||||
statement.execute("INSERT INTO " + temporaryTable + " (id) VALUES (1)");
|
||||
statement.execute("DROP TEMPORARY TABLE " + temporaryTable);
|
||||
statement.execute("UPDATE " + childTable + " SET note = 'verified' WHERE id = 1");
|
||||
statement.execute("DELETE FROM " + childTable + " WHERE id = 1");
|
||||
statement.execute("DELETE FROM " + parentTable + " WHERE id = 1");
|
||||
} else {
|
||||
statement.execute("INSERT INTO " + parentTable + " (id) VALUES (1)");
|
||||
statement.execute("UPDATE " + parentTable + " SET id = 2 WHERE id = 1");
|
||||
statement.execute("DELETE FROM " + parentTable + " WHERE id = 2");
|
||||
}
|
||||
} catch (SQLException exception) {
|
||||
cleanupProbeObjects(statement, databaseType, routine, temporaryTable, childTable, parentTable, exception);
|
||||
throw exception;
|
||||
}
|
||||
cleanupProbeObjects(statement, databaseType, routine, temporaryTable, childTable, parentTable, null);
|
||||
}
|
||||
|
||||
private void cleanupProbeObjects(Statement statement, DatabaseType databaseType, String routine,
|
||||
String temporaryTable, String childTable, String parentTable,
|
||||
SQLException original)
|
||||
throws SQLException {
|
||||
SQLException cleanupFailure = null;
|
||||
List<String> cleanupStatements = databaseType == DatabaseType.MYSQL
|
||||
? List.of("DROP PROCEDURE IF EXISTS " + routine, "DROP TEMPORARY TABLE IF EXISTS " + temporaryTable,
|
||||
"DROP TABLE IF EXISTS " + childTable, "DROP TABLE IF EXISTS " + parentTable)
|
||||
: List.of("DROP TABLE IF EXISTS " + childTable, "DROP TABLE IF EXISTS " + parentTable);
|
||||
for (String sql : cleanupStatements) {
|
||||
try {
|
||||
statement.execute(sql);
|
||||
} catch (SQLException exception) {
|
||||
if (cleanupFailure == null) {
|
||||
cleanupFailure = exception;
|
||||
} else {
|
||||
cleanupFailure.addSuppressed(exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cleanupFailure != null) {
|
||||
if (original != null) {
|
||||
original.addSuppressed(cleanupFailure);
|
||||
} else {
|
||||
throw cleanupFailure;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,6 +695,12 @@ public class SetupService {
|
||||
private record ConnectionResult(boolean successful, String version) {
|
||||
}
|
||||
|
||||
private record DatabaseCollation(String characterSet, String collation) {
|
||||
}
|
||||
|
||||
private record FailedMigration(int installedRank, String version, String script) {
|
||||
}
|
||||
|
||||
private record InstallationState(String setupTokenSha256, long administratorUserId,
|
||||
String administratorUsername) {
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ public final class SetupViews {
|
||||
public record Status(
|
||||
boolean required,
|
||||
boolean locked,
|
||||
boolean ready,
|
||||
List<String> supportedDatabaseTypes,
|
||||
String message
|
||||
) {
|
||||
|
||||
@@ -81,10 +81,10 @@ public class GlobalExceptionHandler {
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ProblemDetail> handleUnexpected(Exception exception, HttpServletRequest request) {
|
||||
if (isLockFailure(exception)) {
|
||||
log.warn("Concurrent database modification requestId={}", RequestContext.requestId(), exception);
|
||||
log.warn("并发数据库修改 requestId={}", RequestContext.requestId(), exception);
|
||||
return concurrentModification(request);
|
||||
}
|
||||
log.error("Unhandled request failure requestId={}", RequestContext.requestId(), exception);
|
||||
log.error("未处理的请求失败 requestId={}", RequestContext.requestId(), exception);
|
||||
return response(HttpStatus.INTERNAL_SERVER_ERROR, ErrorCode.INTERNAL_ERROR.name(),
|
||||
"系统处理失败,请使用请求编号联系管理员", Map.of(), request);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,54 @@ public interface AuditMapper {
|
||||
""")
|
||||
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 created_at, ip_address, user_agent
|
||||
FROM audit_log
|
||||
@@ -36,4 +84,9 @@ public interface AuditMapper {
|
||||
|
||||
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 jakarta.servlet.http.HttpServletRequest;
|
||||
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.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -54,6 +64,86 @@ public class AuditService {
|
||||
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) {
|
||||
return recordSystemUpdateTerminal(updateRequestId, updateAction, state, targetVersion, message, updatedAt,
|
||||
null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists a terminal update event together with the release metadata that was verified before the switch.
|
||||
* Keeping the signed release notes in the audit snapshot lets the history page work after a restart or when
|
||||
* the release host is temporarily unavailable.
|
||||
*/
|
||||
public String recordSystemUpdateTerminal(String updateRequestId, String updateAction, String state,
|
||||
String targetVersion, String message, Instant updatedAt,
|
||||
String releaseNotes, Instant publishedAt) {
|
||||
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);
|
||||
String normalizedNotes = clean(releaseNotes, 4000);
|
||||
if (normalizedNotes != null) terminal.put("releaseNotes", normalizedNotes);
|
||||
if (publishedAt != null) terminal.put("publishedAt", publishedAt);
|
||||
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,
|
||||
String objectType, String objectPublicId, String result, String reason,
|
||||
Object before, Object after) {
|
||||
@@ -76,6 +166,49 @@ public class AuditService {
|
||||
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) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
|
||||
@@ -39,7 +39,7 @@ public class DeniedAccessAuditService {
|
||||
try {
|
||||
writer.record(method, uri, status.value(), errorCode);
|
||||
} catch (RuntimeException exception) {
|
||||
log.error("Denied access audit failed requestId={} method={} uri={} code={}",
|
||||
log.error("拒绝访问审计写入失败 requestId={} method={} uri={} code={}",
|
||||
RequestContext.requestId(), method, uri, errorCode, exception);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,7 +372,7 @@ public class FileApplicationService {
|
||||
try {
|
||||
Files.delete(source);
|
||||
} catch (IOException exception) {
|
||||
LOGGER.warn("Failed to remove duplicate quarantine source for file {}", plan.publicId(), exception);
|
||||
LOGGER.warn("删除文件 {} 的重复隔离源失败", plan.publicId(), exception);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -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 checkedAt,
|
||||
Instant statusUpdatedAt,
|
||||
Long downloadedBytes,
|
||||
Long totalBytes,
|
||||
Long bytesPerSecond,
|
||||
Integer downloadPercent,
|
||||
Integer restartExpectedSeconds,
|
||||
List<SystemUpdateEventView> events,
|
||||
List<String> allowedActions
|
||||
) {
|
||||
}
|
||||
|
||||
+215
-20
@@ -11,6 +11,7 @@ import com.kaidi.finance.shared.security.AuthorizationService;
|
||||
import com.kaidi.finance.shared.security.IdentityContext;
|
||||
import com.kaidi.finance.update.api.SystemUpdateContracts.DownloadUpdateRequest;
|
||||
import com.kaidi.finance.update.api.SystemUpdateContracts.InstallUpdateRequest;
|
||||
import com.kaidi.finance.update.api.SystemUpdateEventView;
|
||||
import com.kaidi.finance.update.api.SystemUpdateView;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
@@ -33,17 +34,25 @@ import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
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_RELEASE_METADATA_BYTES = 16 * 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_EVENT_LOG_BYTES = 256 * 1024;
|
||||
private static final int MAX_EVENT_COUNT = 120;
|
||||
private static final int MAX_REDIRECTS = 3;
|
||||
private static final String PRERELEASE_IDENTIFIER =
|
||||
"(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)";
|
||||
@@ -57,8 +66,8 @@ public class SystemUpdateApplicationService {
|
||||
"^(.*/)?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 List<String> BUSY_STATES = List.of(
|
||||
"QUEUED", "DOWNLOAD_QUEUED", "INSTALL_QUEUED", "VERIFYING", "DOWNLOADING", "BACKING_UP",
|
||||
"INSTALLING", "RUNNING");
|
||||
"QUEUED", "DOWNLOAD_QUEUED", "INSTALL_QUEUED", "PRECHECKING", "VERIFYING", "DOWNLOADING",
|
||||
"BACKING_UP", "INSTALLING", "RUNNING");
|
||||
|
||||
private final SystemUpdateProperties properties;
|
||||
private final AuthorizationService authorizationService;
|
||||
@@ -68,6 +77,7 @@ public class SystemUpdateApplicationService {
|
||||
private final HttpClient httpClient;
|
||||
private volatile ReleaseManifest lastManifest;
|
||||
private volatile Instant lastCheckedAt;
|
||||
private volatile String lastTerminalAuditFingerprint;
|
||||
|
||||
public SystemUpdateApplicationService(SystemUpdateProperties properties,
|
||||
AuthorizationService authorizationService,
|
||||
@@ -87,7 +97,9 @@ public class SystemUpdateApplicationService {
|
||||
|
||||
public SystemUpdateView status() {
|
||||
requireIsolatedSystemAdministrator();
|
||||
return view(lastManifest, readStatus());
|
||||
UpdateStatus status = readStatus();
|
||||
persistTerminalAudit(status);
|
||||
return view(lastManifest, status);
|
||||
}
|
||||
|
||||
public SystemUpdateView check() {
|
||||
@@ -96,7 +108,9 @@ public class SystemUpdateApplicationService {
|
||||
ReleaseManifest manifest = fetchManifest();
|
||||
lastManifest = manifest;
|
||||
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,
|
||||
result);
|
||||
return result;
|
||||
@@ -117,6 +131,7 @@ public class SystemUpdateApplicationService {
|
||||
throw validation("当前已是相同或更高版本");
|
||||
}
|
||||
UpdateStatus currentStatus = readStatus();
|
||||
persistTerminalAudit(currentStatus);
|
||||
if ("READY".equals(currentStatus.state()) && requested.equals(currentStatus.targetVersion())) {
|
||||
throw validation("该版本已经下载并通过校验,请确认安装");
|
||||
}
|
||||
@@ -134,6 +149,7 @@ public class SystemUpdateApplicationService {
|
||||
requireConfigured();
|
||||
String requested = request.version().trim();
|
||||
UpdateStatus ready = readStatus();
|
||||
persistTerminalAudit(ready);
|
||||
if (!"READY".equals(ready.state()) || !requested.equals(ready.targetVersion())) {
|
||||
throw validation("该版本尚未完成下载和签名校验");
|
||||
}
|
||||
@@ -353,6 +369,10 @@ public class SystemUpdateApplicationService {
|
||||
try (FileChannel channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE);
|
||||
FileLock ignored = channel.lock()) {
|
||||
UpdateStatus currentStatus = readStatus();
|
||||
if ("RECOVERY_REQUIRED".equals(currentStatus.state())) {
|
||||
throw new BusinessException(HttpStatus.CONFLICT, ErrorCode.COMMAND_IN_PROGRESS,
|
||||
"更新服务需要人工恢复,暂不接受新的更新请求");
|
||||
}
|
||||
if (BUSY_STATES.contains(currentStatus.state())) {
|
||||
throw new BusinessException(HttpStatus.CONFLICT, ErrorCode.COMMAND_IN_PROGRESS,
|
||||
"已有系统更新任务正在执行");
|
||||
@@ -362,7 +382,8 @@ public class SystemUpdateApplicationService {
|
||||
"已有系统更新请求等待执行");
|
||||
}
|
||||
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);
|
||||
} catch (IOException exception) {
|
||||
throw storage("系统更新队列锁定失败");
|
||||
@@ -399,15 +420,71 @@ public class SystemUpdateApplicationService {
|
||||
}
|
||||
|
||||
private UpdateStatus readStatus() {
|
||||
UpdateStatus resolved;
|
||||
if (!effectiveEnabled()) {
|
||||
return new UpdateStatus("DISABLED", "在线更新未配置", null, null);
|
||||
resolved = new UpdateStatus("DISABLED", "在线更新未配置", null, null);
|
||||
} else {
|
||||
UpdateStatus persisted = readPersistedStatus();
|
||||
if (persisted != null && BUSY_STATES.contains(persisted.state())) {
|
||||
resolved = persisted;
|
||||
} else if (persisted != null && "FAILED".equals(persisted.state()) && hasProcessingRequest()) {
|
||||
resolved = persisted;
|
||||
} else {
|
||||
UpdateStatus queued = pendingStatus();
|
||||
resolved = queued != null
|
||||
? queued
|
||||
: (persisted == null ? new UpdateStatus("IDLE", "尚未执行在线更新", null, null) : persisted);
|
||||
}
|
||||
}
|
||||
return enrichWithEmbeddedReleaseMetadata(resolved);
|
||||
}
|
||||
|
||||
/**
|
||||
* The updater which performed an older release switch may not have known about release notes yet.
|
||||
* New artifacts therefore carry the signed notes inside the release directory as well. Reading that
|
||||
* immutable, artifact-hashed file lets the first application process after an upgrade reconstruct the
|
||||
* terminal audit snapshot without a network request or a database migration.
|
||||
*/
|
||||
private UpdateStatus enrichWithEmbeddedReleaseMetadata(UpdateStatus status) {
|
||||
if (status == null) return null;
|
||||
ReleaseMetadata metadata = readEmbeddedReleaseMetadata();
|
||||
if (metadata == null) return status;
|
||||
String current = currentVersion();
|
||||
boolean matchesCurrent = metadata.version().equals(current);
|
||||
boolean matchesTarget = status.targetVersion() != null && metadata.version().equals(status.targetVersion());
|
||||
if (!matchesCurrent && !matchesTarget) return status;
|
||||
String notes = status.releaseNotes() == null || status.releaseNotes().isBlank()
|
||||
? metadata.releaseNotes() : status.releaseNotes();
|
||||
Instant publishedAt = status.publishedAt() == null ? metadata.publishedAt() : status.publishedAt();
|
||||
if (notes.equals(status.releaseNotes()) && Objects.equals(publishedAt, status.publishedAt())) return status;
|
||||
return new UpdateStatus(status.state(), status.message(), status.targetVersion(), status.updatedAt(),
|
||||
status.downloadedBytes(), status.totalBytes(), status.bytesPerSecond(), status.downloadPercent(),
|
||||
status.restartExpectedSeconds(), status.requestId(), status.action(), notes, publishedAt);
|
||||
}
|
||||
|
||||
private ReleaseMetadata readEmbeddedReleaseMetadata() {
|
||||
Path versionFile = properties.currentVersionFile();
|
||||
if (versionFile == null) return null;
|
||||
Path releaseDirectory = versionFile.toAbsolutePath().normalize().getParent();
|
||||
if (releaseDirectory == null) return null;
|
||||
Path metadataFile = releaseDirectory.resolve("release-metadata.json").normalize();
|
||||
if (!metadataFile.startsWith(releaseDirectory)
|
||||
|| !Files.isRegularFile(metadataFile, LinkOption.NOFOLLOW_LINKS)
|
||||
|| Files.isSymbolicLink(metadataFile)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
if (Files.size(metadataFile) > MAX_RELEASE_METADATA_BYTES) return null;
|
||||
JsonNode root = objectMapper.readTree(Files.readAllBytes(metadataFile));
|
||||
String version = blank(root.path("version").asText(null));
|
||||
String notes = boundedText(root, "releaseNotes", 4000);
|
||||
Instant publishedAt = parseInstant(root.path("publishedAt").asText(null));
|
||||
if (version == null || !VERSION.matcher(version).matches() || notes == null) return null;
|
||||
return new ReleaseMetadata(version, notes, publishedAt);
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
log.debug("嵌入式 Release 更新日志读取失败:{}", metadataFile, exception);
|
||||
return null;
|
||||
}
|
||||
UpdateStatus persisted = readPersistedStatus();
|
||||
if (persisted != null && BUSY_STATES.contains(persisted.state())) return persisted;
|
||||
if (persisted != null && "FAILED".equals(persisted.state()) && hasProcessingRequest()) return persisted;
|
||||
UpdateStatus queued = pendingStatus();
|
||||
if (queued != null) return queued;
|
||||
return persisted == null ? new UpdateStatus("IDLE", "尚未执行在线更新", null, null) : persisted;
|
||||
}
|
||||
|
||||
private UpdateStatus readPersistedStatus() {
|
||||
@@ -421,7 +498,12 @@ public class SystemUpdateApplicationService {
|
||||
String state = root.path("state").asText("UNKNOWN").toUpperCase(Locale.ROOT);
|
||||
if (!state.matches("^[A-Z_]{2,32}$")) state = "UNKNOWN";
|
||||
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)),
|
||||
boundedText(root, "releaseNotes", 4000), parseInstant(root.path("publishedAt").asText(null)));
|
||||
} catch (IOException exception) {
|
||||
return new UpdateStatus("UNKNOWN", "更新状态读取失败", null, null);
|
||||
}
|
||||
@@ -433,17 +515,64 @@ public class SystemUpdateApplicationService {
|
||||
String candidateVersion = manifest == null ? status.targetVersion() : manifest.version();
|
||||
boolean available = candidateVersion != null && compareVersions(candidateVersion, current) > 0;
|
||||
boolean busy = BUSY_STATES.contains(status.state());
|
||||
boolean recoveryRequired = "RECOVERY_REQUIRED".equals(status.state());
|
||||
boolean pending = hasPendingRequest(properties.requestFile().toAbsolutePath().normalize());
|
||||
boolean ready = "READY".equals(status.state()) && candidateVersion != null
|
||||
&& candidateVersion.equals(status.targetVersion());
|
||||
List<String> actions = new ArrayList<>();
|
||||
if (enabled && !busy && !pending) actions.add("CHECK");
|
||||
if (enabled && available && !busy && !pending && authorizationService.hasPermission("admin:update:execute")) {
|
||||
if (enabled && !busy && !pending && !recoveryRequired) actions.add("CHECK");
|
||||
if (enabled && available && !busy && !pending && !recoveryRequired
|
||||
&& authorizationService.hasPermission("admin:update:execute")) {
|
||||
actions.add(ready ? "INSTALL" : "DOWNLOAD");
|
||||
}
|
||||
String releaseNotes = manifest != null && manifest.releaseNotes() != null && !manifest.releaseNotes().isBlank()
|
||||
? manifest.releaseNotes() : status.releaseNotes();
|
||||
Instant publishedAt = manifest != null && manifest.publishedAt() != null
|
||||
? manifest.publishedAt() : status.publishedAt();
|
||||
return new SystemUpdateView(enabled, current, candidateVersion,
|
||||
available, status.state(), status.message(), manifest == null ? null : manifest.releaseNotes(),
|
||||
manifest == null ? null : manifest.publishedAt(), lastCheckedAt, status.updatedAt(), List.copyOf(actions));
|
||||
available, status.state(), status.message(), releaseNotes,
|
||||
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) {
|
||||
@@ -472,10 +601,13 @@ public class SystemUpdateApplicationService {
|
||||
String version = blank(root.path("version").asText(null));
|
||||
String action = root.path("action").asText("INSTALL").toUpperCase(Locale.ROOT);
|
||||
Instant requestedAt = parseInstant(root.path("requestedAt").asText(null));
|
||||
String requestId = boundedText(root, "requestId", 64);
|
||||
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) {
|
||||
return new UpdateStatus("UNKNOWN", "更新请求读取失败", null, null);
|
||||
}
|
||||
@@ -564,6 +696,54 @@ 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(),
|
||||
status.releaseNotes(), status.publishedAt());
|
||||
if (persistedKey != null) lastTerminalAuditFingerprint = fingerprint;
|
||||
} catch (RuntimeException exception) {
|
||||
log.warn("系统更新终态审计写入失败:state={}, targetVersion={}", status.state(),
|
||||
status.targetVersion(), exception);
|
||||
}
|
||||
}
|
||||
|
||||
private BusinessException validation(String message) {
|
||||
return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED, message);
|
||||
}
|
||||
@@ -580,7 +760,22 @@ public class SystemUpdateApplicationService {
|
||||
String releaseNotes) {
|
||||
}
|
||||
|
||||
private record UpdateStatus(String state, String message, String targetVersion, Instant updatedAt) {
|
||||
private record ReleaseMetadata(String version, String releaseNotes, Instant publishedAt) {
|
||||
}
|
||||
|
||||
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, String releaseNotes, Instant publishedAt) {
|
||||
private UpdateStatus(String state, String message, String targetVersion, Instant updatedAt) {
|
||||
this(state, message, targetVersion, updatedAt, null, null, 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,
|
||||
null, null);
|
||||
}
|
||||
}
|
||||
|
||||
private record QueueTransition(UpdateStatus previous, UpdateStatus queued) {
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
spring:
|
||||
datasource:
|
||||
url: ${DB_URL}
|
||||
username: ${DB_USERNAME}
|
||||
password: ${DB_PASSWORD}
|
||||
|
||||
server:
|
||||
servlet:
|
||||
session:
|
||||
|
||||
@@ -17,11 +17,8 @@ spring:
|
||||
resources:
|
||||
static-locations: ${FINANCE_STATIC_LOCATIONS:file:./public/}
|
||||
profiles:
|
||||
default: local
|
||||
default: production
|
||||
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:
|
||||
maximum-pool-size: 20
|
||||
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);
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.kaidi.finance.setup;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.kaidi.setup.SetupApplication;
|
||||
@@ -13,6 +14,7 @@ import java.sql.Statement;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
@@ -60,9 +62,12 @@ class SetupApplicationIntegrationTest {
|
||||
|
||||
@Test
|
||||
void firstRunTestsDatabaseCreatesAdministratorAndLocksWizard() throws Exception {
|
||||
seedRecoverableV068CollationFailure();
|
||||
|
||||
ResponseEntity<JsonNode> initial = rest.getForEntity(url("/api/v1/setup/status"), JsonNode.class);
|
||||
assertThat(initial.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(initial.getBody().path("data").path("required").asBoolean()).isTrue();
|
||||
assertThat(initial.getBody().path("data").path("ready").asBoolean()).isFalse();
|
||||
|
||||
Map<String, Object> database = databaseRequest("wrong-code");
|
||||
ResponseEntity<JsonNode> denied = rest.postForEntity(url("/api/v1/setup/test-connection"),
|
||||
@@ -75,7 +80,18 @@ class SetupApplicationIntegrationTest {
|
||||
new HttpEntity<>(database), JsonNode.class);
|
||||
assertThat(tested.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(tested.getBody().path("data").path("successful").asBoolean()).isTrue();
|
||||
assertThat(tested.getBody().path("data").path("schemaReady").asBoolean()).isTrue();
|
||||
assertThat(tested.getBody().path("data").path("schemaReady").asBoolean()).isFalse();
|
||||
assertThat(tested.getBody().path("data").path("message").asText())
|
||||
.contains("只读连接验证通过");
|
||||
|
||||
try (Connection connection = DriverManager.getConnection(MYSQL.getJdbcUrl(), MYSQL.getUsername(),
|
||||
MYSQL.getPassword()); Statement statement = connection.createStatement()) {
|
||||
try (ResultSet result = statement.executeQuery("SELECT default_collation_name FROM information_schema.schemata WHERE schema_name = DATABASE()")) {
|
||||
result.next();
|
||||
// A read-only connection test must not normalize the operator's schema.
|
||||
assertThat(result.getString(1)).isNotEqualTo("utf8mb4_0900_ai_ci");
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> complete = new LinkedHashMap<>(database);
|
||||
complete.put("adminUsername", "setup-admin");
|
||||
@@ -121,6 +137,34 @@ class SetupApplicationIntegrationTest {
|
||||
ResponseEntity<JsonNode> locked = rest.getForEntity(url("/api/v1/setup/status"), JsonNode.class);
|
||||
assertThat(locked.getBody().path("data").path("required").asBoolean()).isFalse();
|
||||
assertThat(locked.getBody().path("data").path("locked").asBoolean()).isTrue();
|
||||
assertThat(locked.getBody().path("data").path("ready").asBoolean()).isFalse();
|
||||
}
|
||||
|
||||
private void seedRecoverableV068CollationFailure() throws Exception {
|
||||
try (Connection connection = DriverManager.getConnection(MYSQL.getJdbcUrl(), MYSQL.getUsername(),
|
||||
MYSQL.getPassword()); Statement statement = connection.createStatement()) {
|
||||
statement.execute("ALTER DATABASE `" + MYSQL.getDatabaseName()
|
||||
+ "` CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci");
|
||||
}
|
||||
assertThatThrownBy(() -> Flyway.configure()
|
||||
.dataSource(MYSQL.getJdbcUrl(), MYSQL.getUsername(), MYSQL.getPassword())
|
||||
.locations("classpath:db/migration")
|
||||
.target("68")
|
||||
.load()
|
||||
.migrate())
|
||||
.hasMessageContaining("V068__source_table_column_validation_metadata.sql");
|
||||
try (Connection connection = DriverManager.getConnection(MYSQL.getJdbcUrl(), MYSQL.getUsername(),
|
||||
MYSQL.getPassword()); Statement statement = connection.createStatement();
|
||||
ResultSet result = statement.executeQuery("""
|
||||
SELECT COUNT(*)
|
||||
FROM flyway_schema_history
|
||||
WHERE version = '68'
|
||||
AND script = 'V068__source_table_column_validation_metadata.sql'
|
||||
AND success = FALSE
|
||||
""")) {
|
||||
result.next();
|
||||
assertThat(result.getInt(1)).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> databaseRequest(String setupCode) {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.kaidi.finance.setup;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class SetupReadinessTest {
|
||||
|
||||
private static final String TOKEN_SHA256 =
|
||||
"9158568c96987884c8141d363daceffda86bb17083054b36105934090aa7e166";
|
||||
|
||||
@TempDir
|
||||
private Path stateRoot;
|
||||
|
||||
@Test
|
||||
void distinguishesLockedSetupModeFromReadyApplicationMode() {
|
||||
SetupProperties properties = new SetupProperties(true, TOKEN_SHA256,
|
||||
stateRoot.resolve("application.env").toString(), stateRoot.resolve("locked").toString(), false);
|
||||
|
||||
SetupViews.Status setupMode = new SetupService(properties).status();
|
||||
SetupViews.Status applicationMode = new SetupLockedController().status().data();
|
||||
|
||||
assertThat(setupMode.required()).isTrue();
|
||||
assertThat(setupMode.ready()).isFalse();
|
||||
assertThat(applicationMode.required()).isFalse();
|
||||
assertThat(applicationMode.locked()).isTrue();
|
||||
assertThat(applicationMode.ready()).isTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
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\","
|
||||
+ "\"releaseNotes\":\"Preview update\"}",
|
||||
"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,
|
||||
"Preview update", Instant.parse("2026-08-16T00:00:00Z"));
|
||||
String secondKey = service.recordSystemUpdateTerminal("01M00000000000000000000092", "INSTALL",
|
||||
"SUCCEEDED", "1.0.0-preview.44", "新版本已通过健康检查", completedAt,
|
||||
"Preview update", Instant.parse("2026-08-16T00:00:00Z"));
|
||||
|
||||
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"));
|
||||
assertTrue(persisted.afterJson().contains("Preview update"));
|
||||
assertTrue(persisted.afterJson().contains("publishedAt"));
|
||||
}
|
||||
|
||||
@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());
|
||||
}
|
||||
}
|
||||
+178
@@ -6,6 +6,8 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
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 com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -211,6 +213,180 @@ 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","releaseNotes":"Preview update",
|
||||
"publishedAt":"2026-08-16T00:00: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"),
|
||||
"Preview update", java.time.Instant.parse("2026-08-16T00:00:00Z")))
|
||||
.thenReturn("SYSUPD:terminal");
|
||||
SystemUpdateApplicationService service = serviceForStatus(inbox, statusFile, auditService);
|
||||
|
||||
assertEquals("SUCCEEDED", service.status().state());
|
||||
assertEquals("Preview update", service.status().releaseNotes());
|
||||
assertEquals(java.time.Instant.parse("2026-08-16T00:00:00Z"), service.status().publishedAt());
|
||||
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"), "Preview update",
|
||||
java.time.Instant.parse("2026-08-16T00:00: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"), null, null))
|
||||
.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"), null, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoresEmbeddedReleaseNotesAfterLegacyUpdaterSwitch() throws Exception {
|
||||
Path inbox = Files.createDirectory(tempDir.resolve("embedded-notes-inbox"));
|
||||
Path versionFile = tempDir.resolve("current/VERSION");
|
||||
Files.createDirectories(versionFile.getParent());
|
||||
Files.writeString(versionFile, "1.0.0-preview.47\n");
|
||||
Files.writeString(versionFile.resolveSibling("release-metadata.json"), """
|
||||
{"version":"1.0.0-preview.47","publishedAt":"2026-08-19T00:28:41.701Z",
|
||||
"releaseNotes":"从已签名制品恢复的更新日志"}
|
||||
""");
|
||||
Path statusFile = tempDir.resolve("embedded-notes-status.json");
|
||||
Files.writeString(statusFile, """
|
||||
{"state":"SUCCEEDED","message":"Release 1.0.0-preview.47 is running",
|
||||
"targetVersion":"1.0.0-preview.47","updatedAt":"2026-08-19T00:30:00Z"}
|
||||
""");
|
||||
AuditService auditService = mock(AuditService.class);
|
||||
when(auditService.recordSystemUpdateTerminal(null, null, "SUCCEEDED", "1.0.0-preview.47",
|
||||
"Release 1.0.0-preview.47 is running", java.time.Instant.parse("2026-08-19T00:30:00Z"),
|
||||
"从已签名制品恢复的更新日志", java.time.Instant.parse("2026-08-19T00:28:41.701Z")))
|
||||
.thenReturn("SYSUPD:embedded-notes");
|
||||
SystemUpdateApplicationService service = serviceForStatus(inbox, statusFile, auditService, versionFile);
|
||||
|
||||
var status = service.status();
|
||||
|
||||
assertEquals("从已签名制品恢复的更新日志", status.releaseNotes());
|
||||
assertEquals(java.time.Instant.parse("2026-08-19T00:28:41.701Z"), status.publishedAt());
|
||||
verify(auditService).recordSystemUpdateTerminal(null, null, "SUCCEEDED", "1.0.0-preview.47",
|
||||
"Release 1.0.0-preview.47 is running", java.time.Instant.parse("2026-08-19T00:30:00Z"),
|
||||
"从已签名制品恢复的更新日志", java.time.Instant.parse("2026-08-19T00:28:41.701Z"));
|
||||
}
|
||||
|
||||
private SystemUpdateApplicationService serviceForStatus(Path inbox, Path statusFile, AuditService auditService) {
|
||||
return serviceForStatus(inbox, statusFile, auditService, null);
|
||||
}
|
||||
|
||||
private SystemUpdateApplicationService serviceForStatus(Path inbox, Path statusFile, AuditService auditService,
|
||||
Path versionFile) {
|
||||
SystemUpdateProperties properties = new SystemUpdateProperties(true, "1.0.0-preview.43", versionFile,
|
||||
"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
|
||||
void checksConfiguredManifestAndQueuesOnlyItsLatestVersion() throws Exception {
|
||||
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
@@ -247,6 +423,8 @@ class SystemUpdateApplicationServiceTest {
|
||||
var checked = service.check();
|
||||
assertTrue(checked.updateAvailable());
|
||||
assertEquals("1.0.0-preview.2+build.7", checked.latestVersion());
|
||||
assertEquals("Preview update", checked.releaseNotes());
|
||||
assertEquals(java.time.Instant.parse("2026-08-16T00:00:00Z"), checked.publishedAt());
|
||||
assertTrue(checked.allowedActions().contains("DOWNLOAD"));
|
||||
assertFalse(checked.allowedActions().contains("INSTALL"));
|
||||
|
||||
|
||||
Executable
+271
@@ -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 "$@"
|
||||
Executable
+182
@@ -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"
|
||||
@@ -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.
|
||||
@@ -1,5 +1,8 @@
|
||||
services:
|
||||
mysql:
|
||||
# Local-development fixture only. Production installers never invoke
|
||||
# Compose and never create this service.
|
||||
profiles: [local-db]
|
||||
image: mysql:8.4
|
||||
container_name: kaidi-finance-mysql
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
SPRING_PROFILES_ACTIVE=production
|
||||
SERVER_PORT=18080
|
||||
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_USERNAME=kaidi
|
||||
DB_URL=jdbc:mysql://DB_HOST:DB_PORT/DB_NAME?useUnicode=true&characterEncoding=utf8&connectionTimeZone=UTC&serverTimezone=UTC
|
||||
DB_USERNAME=DB_USERNAME
|
||||
DB_PASSWORD=CHANGE_ME
|
||||
FIELD_ENCRYPTION_KEY=BASE64_32_BYTE_KEY
|
||||
|
||||
FILE_STORAGE_ROOT=/var/lib/kaidi/files
|
||||
FILE_STORAGE_TEMP=/var/lib/kaidi/tmp
|
||||
FILE_SCANNER_ENABLED=false
|
||||
FILE_SCANNER_ENABLED=true
|
||||
|
||||
FINANCE_BOOTSTRAP_ENABLED=false
|
||||
FINANCE_BOOTSTRAP_PASSWORD=
|
||||
|
||||
APP_VERSION=1.0.0-preview.9
|
||||
APP_VERSION=1.0.0-preview.48
|
||||
UPDATE_CURRENT_VERSION_FILE=/opt/kaidi/current/VERSION
|
||||
FINANCE_UPDATE_ENABLED=true
|
||||
# Use either a stable direct asset base URL or the public Gitea latest-release API.
|
||||
UPDATE_RELEASE_BASE_URL=
|
||||
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_STATUS_FILE=/var/lib/kaidi-update/status.json
|
||||
|
||||
@@ -5,13 +5,31 @@ umask 077
|
||||
|
||||
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
INSTALLER="$ROOT/deploy/install.sh"
|
||||
INSTALLER_SHA256=${KAIDI_INSTALLER_SHA256:-78bd7a89acc71fc016d1c55816abd466173194bfd71a8f095c73f6deb86babd9}
|
||||
INSTALLER_SHA256=${KAIDI_INSTALLER_SHA256:-5aadfab9bdeef9279aeab6eee9baaae7182b2f3338fa61a558b3c073b69ad396}
|
||||
RELEASE_API_URL=${KAIDI_RELEASE_API_URL:-https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest}
|
||||
PUBLIC_KEY_SHA256=${KAIDI_RELEASE_PUBLIC_KEY_SHA256:-807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9}
|
||||
TOKEN_FILE=${KAIDI_RELEASE_TOKEN_FILE:-}
|
||||
|
||||
log() { printf '[kaidi-git-install] %s\n' "$*"; }
|
||||
die() { printf '[kaidi-git-install] ERROR: %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
localize_message() {
|
||||
local message=$1
|
||||
[ "${KAIDI_LOG_LOCALE:-zh-CN}" = en ] && { printf '%s' "$message"; return; }
|
||||
case "$message" in
|
||||
"deploy/install.sh is missing"*) printf '缺少 deploy/install.sh,请在 Kaidi Git 工作区运行此命令' ;;
|
||||
"sha256sum is required") printf '需要 sha256sum' ;;
|
||||
"The installer SHA-256 is invalid") printf '安装器 SHA-256 无效' ;;
|
||||
"The release public-key SHA-256 is invalid") printf '发布公钥 SHA-256 无效' ;;
|
||||
"deploy/install.sh does not match"*) printf 'deploy/install.sh 与该 Git 标签的受信摘要不一致' ;;
|
||||
"sudo is required when not running as root") printf '非 root 用户运行时需要 sudo' ;;
|
||||
"The Gitea token file is not a regular file") printf 'Gitea Token 文件必须是普通文件' ;;
|
||||
"The Gitea token file must contain 1 to 512 bytes") printf 'Gitea Token 文件必须包含 1 到 512 字节' ;;
|
||||
"The Gitea token file contains invalid control characters") printf 'Gitea Token 文件包含无效控制字符' ;;
|
||||
*) printf '%s' "$message" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
die() { printf '[kaidi-git-install] 错误:%s\n' "$(localize_message "$*")" >&2; exit 1; }
|
||||
|
||||
[ -f "$INSTALLER" ] || die "deploy/install.sh is missing; run this command from the Kaidi Git checkout"
|
||||
command -v sha256sum >/dev/null 2>&1 || die "sha256sum is required"
|
||||
@@ -52,19 +70,19 @@ KAIDI_DB_PASSWORD=${KAIDI_DB_PASSWORD:-}
|
||||
KAIDI_DB_HOST=${KAIDI_DB_HOST:-}
|
||||
KAIDI_DB_PORT=${KAIDI_DB_PORT:-}
|
||||
KAIDI_DB_NAME=${KAIDI_DB_NAME:-}
|
||||
KAIDI_DB_CONTAINER=${KAIDI_DB_CONTAINER:-}
|
||||
KAIDI_SESSION_COOKIE_SECURE=${KAIDI_SESSION_COOKIE_SECURE:-}
|
||||
KAIDI_FILE_SCANNER_ENABLED=${KAIDI_FILE_SCANNER_ENABLED:-}
|
||||
KAIDI_DB_BACKUP_MODE=${KAIDI_DB_BACKUP_MODE:-}
|
||||
|
||||
for name in KAIDI_REINSTALL KAIDI_SETUP_WIZARD KAIDI_APP_PORT KAIDI_SERVER_ADDRESS \
|
||||
KAIDI_DB_URL KAIDI_DB_USERNAME KAIDI_DB_PASSWORD \
|
||||
KAIDI_DB_HOST KAIDI_DB_PORT KAIDI_DB_NAME KAIDI_DB_CONTAINER \
|
||||
KAIDI_SESSION_COOKIE_SECURE KAIDI_FILE_SCANNER_ENABLED; do
|
||||
KAIDI_DB_HOST KAIDI_DB_PORT KAIDI_DB_NAME \
|
||||
KAIDI_SESSION_COOKIE_SECURE KAIDI_FILE_SCANNER_ENABLED KAIDI_DB_BACKUP_MODE; do
|
||||
value=${!name}
|
||||
if [ -n "$value" ]; then
|
||||
install_env+=("$name=$value")
|
||||
fi
|
||||
done
|
||||
|
||||
log "Installing the latest signed release from $RELEASE_API_URL"
|
||||
log "正在从 Gitea Release API 安装最新签名版本:$RELEASE_API_URL"
|
||||
"${root_command[@]}" "${install_env[@]}" bash "$INSTALLER"
|
||||
|
||||
+512
-115
@@ -18,32 +18,195 @@ SERVER_ADDRESS=${KAIDI_SERVER_ADDRESS:-127.0.0.1}
|
||||
PROXY_TARGET=
|
||||
TRUSTED_PUBLIC_KEY_SHA256=${KAIDI_RELEASE_PUBLIC_KEY_SHA256:-807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9}
|
||||
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=
|
||||
RELEASE_AUTH_HEADER_FILE=
|
||||
INSTALL_TRANSACTION_ARMED=false
|
||||
INSTALL_COMMITTED=false
|
||||
RELEASE_DIR=
|
||||
BACKUP_DIR=
|
||||
JAVA_BIN=
|
||||
JAVA_STAGED_DIR=
|
||||
JAVA_PREVIOUS_DIR=
|
||||
JAVA_ACTIVATED=false
|
||||
SERVICE_USER=kaidi
|
||||
SERVICE_GROUP=kaidi
|
||||
HOST_ARCH=
|
||||
LOG_LOCALE=${KAIDI_LOG_LOCALE:-zh-CN}
|
||||
SERVICE_USER_CREATED=false
|
||||
SERVICE_GROUP_CREATED=false
|
||||
CREATED_LAYOUT_PATHS=()
|
||||
|
||||
log() { printf '[kaidi-install] %s\n' "$*"; }
|
||||
die() { printf '[kaidi-install] ERROR: %s\n' "$*" >&2; exit 1; }
|
||||
localize_message() {
|
||||
local message=$1
|
||||
[ "$LOG_LOCALE" = en ] && { printf '%s' "$message"; return; }
|
||||
case "$message" in
|
||||
"Unsupported CPU architecture: "*) printf '不支持的 CPU 架构:%s' "${message#Unsupported CPU architecture: }" ;;
|
||||
"The installer only supports Linux") printf '安装程序仅支持 Linux' ;;
|
||||
"Custom installation roots"*) printf '打包版 systemd 不支持自定义安装目录' ;;
|
||||
"systemd is required") printf '需要 systemd' ;;
|
||||
"systemd-analyze is required") printf '需要 systemd-analyze' ;;
|
||||
"getconf is required") printf '需要 getconf' ;;
|
||||
"systemd is not running as PID 1") printf 'systemd 当前不是 PID 1,无法托管服务' ;;
|
||||
"/etc/systemd/system is missing") printf '缺少 /etc/systemd/system' ;;
|
||||
"CPU architecture and userspace word size"*) printf 'CPU 架构与用户空间位数不匹配:%s' "${message#*: }" ;;
|
||||
"32-bit Linux deployment requires glibc"*) printf '32 位 Linux 需要 glibc;当前系统不兼容 i686 Java' ;;
|
||||
"32-bit Linux deployment requires the glibc loader"*) printf '32 位 Linux 缺少 glibc 加载器 /lib/ld-linux.so.2' ;;
|
||||
"Set KAIDI_RELEASE_PUBLIC_KEY_SHA256"*) printf '请设置受信任的发布公钥 SHA-256:KAIDI_RELEASE_PUBLIC_KEY_SHA256' ;;
|
||||
"Run with sudo or as root") printf '请使用 sudo 或 root 运行' ;;
|
||||
"Set only one of KAIDI_RELEASE_TOKEN"*) printf 'KAIDI_RELEASE_TOKEN 与 KAIDI_RELEASE_TOKEN_FILE 只能设置一个' ;;
|
||||
"KAIDI_RELEASE_TOKEN_FILE must be a regular file") printf 'KAIDI_RELEASE_TOKEN_FILE 必须是普通文件' ;;
|
||||
"KAIDI_RELEASE_TOKEN_FILE is too large") printf 'KAIDI_RELEASE_TOKEN_FILE 过大' ;;
|
||||
"KAIDI_REINSTALL must be true or false") printf 'KAIDI_REINSTALL 只能是 true 或 false' ;;
|
||||
"KAIDI_SETUP_WIZARD must be true or false") printf 'KAIDI_SETUP_WIZARD 只能是 true 或 false' ;;
|
||||
"A setup-wizard repair needs"*) printf '安装向导修复需要已有配置文件:%s' "${message#A setup-wizard repair needs the existing }" ;;
|
||||
"The existing installation is already in production mode"*) printf '现有安装已是正式模式,请执行普通修复重装' ;;
|
||||
"The setup wizard is already locked"*) printf '安装向导已锁定,请执行普通修复重装' ;;
|
||||
"Kaidi Finance is already installed"*) printf 'Kaidi 财务系统已安装,请从系统更新页面执行更新或先卸载' ;;
|
||||
"Set KAIDI_RELEASE_BASE_URL"*) printf '请设置 Gitea Release 源:KAIDI_RELEASE_BASE_URL 或 KAIDI_RELEASE_API_URL' ;;
|
||||
"KAIDI_RELEASE_TOKEN is invalid") printf 'KAIDI_RELEASE_TOKEN 无效' ;;
|
||||
"Download failed: "*) printf '下载失败:%s' "${message#Download failed: }" ;;
|
||||
"Release URLs must use HTTPS") printf '发布地址必须使用 HTTPS' ;;
|
||||
"Release authentication header is unavailable") printf '发布认证请求头不可用' ;;
|
||||
"Release asset "*" is missing") printf '缺少发布资产:%s' "${message#Release asset }" ;;
|
||||
"Gitea Release tag is invalid") printf 'Gitea Release 标签无效' ;;
|
||||
"KAIDI_RELEASE_API_URL must be a Gitea"*) printf 'KAIDI_RELEASE_API_URL 必须是 Gitea Release API 地址' ;;
|
||||
"Required command is missing"*) printf '依赖命令缺失:%s' "${message#*: }" ;;
|
||||
"Supported package managers"*) printf '仅支持 apt、dnf 或 yum' ;;
|
||||
"Using existing Java"*) printf '使用现有 Java:%s' "${message#Using existing Java 17+ runtime at }" ;;
|
||||
"Local Java verified: "*) printf '本机 Java 校验通过:%s' "${message#Local Java verified: }" ;;
|
||||
"No local Java"*) printf '未找到可用 Java 17,正在下载 Azul Java 17 运行时' ;;
|
||||
"Java 17 runtime download URL"*) printf 'Java 17 运行时下载地址无效' ;;
|
||||
"Downloaded Java verified: "*) printf '下载的 Java 校验通过:%s' "${message#Downloaded Java verified: }" ;;
|
||||
"The existing Java runtime cannot start"*) printf '现有 Java 运行时无法启动,请检查架构和权限' ;;
|
||||
"No Java 17 runtime is published"*) printf '当前 Linux 架构没有可用的 Java 17 运行时' ;;
|
||||
"Java 17 runtime SHA-256 verification failed") printf 'Java 17 运行时 SHA-256 校验失败' ;;
|
||||
"Downloaded Java runtime architecture"*) printf '下载的 Java 运行时架构与主机不匹配' ;;
|
||||
"The downloaded Java runtime cannot start"*) printf '下载的 Java 运行时无法启动,请检查 glibc 和主机架构' ;;
|
||||
"Java 17 runtime SHA-256 is unavailable") printf 'Java 17 运行时缺少 SHA-256 摘要' ;;
|
||||
"Existing user "*" has unexpected home directory "*) printf '现有用户目录不符合 Kaidi 约定:%s' "${message#Existing user }" ;;
|
||||
"Service-user filesystem and Java access checks passed") printf '服务用户文件系统与 Java 访问检查通过' ;;
|
||||
*" cannot traverse or read the active release") printf '服务用户无法进入或读取当前发布目录' ;;
|
||||
"The selected Java runtime is unavailable"*) printf '选定的 Java 运行时不可用:%s' "${message#The selected Java runtime is unavailable at }" ;;
|
||||
"The selected Java runtime cannot execute"*) printf '服务用户无法执行选定的 Java 运行时' ;;
|
||||
*" cannot execute the selected Java runtime") printf '服务用户无法执行选定的 Java 运行时' ;;
|
||||
*" cannot execute as "*) printf '服务用户无法启动 Java 运行时' ;;
|
||||
*" cannot write the file-storage directory") printf '服务用户无法写入文件存储目录' ;;
|
||||
*" cannot write the update inbox") printf '服务用户无法写入更新请求目录' ;;
|
||||
"SELinux context restoration reported"*) printf 'SELinux 上下文恢复有提示,将继续进行服务访问检查' ;;
|
||||
"No interactive terminal detected"*) printf '未检测到交互终端,使用应用端口 %s' "${message##*port }" ;;
|
||||
"Application port "*" is already held"*) printf '应用端口已由现有 Kaidi 服务占用:%s' "${message#Application port }" ;;
|
||||
"Application port "*" is already in use"*) printf '应用端口已被占用,请使用 KAIDI_APP_PORT 更换端口' ;;
|
||||
"Application will bind "*) printf '应用监听地址:%s' "${message#Application will bind }" ;;
|
||||
"Reverse proxy target: "*) printf '反向代理目标:%s' "${message#Reverse proxy target: }" ;;
|
||||
"KAIDI_SERVER_ADDRESS contains"*) printf 'KAIDI_SERVER_ADDRESS 含有不支持的字符' ;;
|
||||
"KAIDI_APP_PORT must be an integer"*) printf 'KAIDI_APP_PORT 必须是 1024 到 65535 的整数' ;;
|
||||
"Do not pass database credentials"*) printf '向导模式不要在命令行传数据库账号密码,请在浏览器向导中填写' ;;
|
||||
"KAIDI_DB_USERNAME and KAIDI_DB_PASSWORD"*) printf '设置 KAIDI_DB_URL 时必须同时提供数据库用户名和密码' ;;
|
||||
"An external MySQL 8.4 database is required"*) printf '需要连接用户预先准备的 MySQL 8.4 数据库:请设置 KAIDI_DB_URL、KAIDI_DB_USERNAME、KAIDI_DB_PASSWORD' ;;
|
||||
"KAIDI_REINSTALL needs"*) printf 'KAIDI_REINSTALL 需要读取已有安装配置或提供数据库配置' ;;
|
||||
"The database host is empty") printf '数据库主机不能为空' ;;
|
||||
"The database port is invalid") printf '数据库端口无效' ;;
|
||||
"The database name is invalid") printf '数据库名无效,只能包含字母、数字、下划线和美元符号' ;;
|
||||
"KAIDI_DB_URL must start with jdbc:mysql://") printf 'KAIDI_DB_URL 必须以 jdbc:mysql:// 开头' ;;
|
||||
"KAIDI_DB_USERNAME is empty") printf 'KAIDI_DB_USERNAME 不能为空' ;;
|
||||
"KAIDI_DB_PASSWORD is empty") printf 'KAIDI_DB_PASSWORD 不能为空' ;;
|
||||
"KAIDI_DB_BACKUP_MODE must be skip or mysqldump") printf 'KAIDI_DB_BACKUP_MODE 只能是 skip 或 mysqldump' ;;
|
||||
"Waiting for application startup"*) printf '正在等待应用启动:%s' "${message#Waiting for application startup at }" ;;
|
||||
"Application health check is UP") printf '应用健康检查通过(UP)' ;;
|
||||
"Application is still starting"*) printf '应用仍在启动:%s' "${message#Application is still starting }" ;;
|
||||
"Application service failed"*) printf '应用服务启动失败:%s' "${message#Application service failed during startup }" ;;
|
||||
"Application health check did not become UP") printf '应用健康检查未变为 UP,请查看服务日志' ;;
|
||||
"Application frontend entry point is unavailable") printf '应用前端入口不可访问,请检查服务和端口' ;;
|
||||
"Installation failed; restoring"*) printf '安装失败,正在恢复原有运行状态' ;;
|
||||
"ERROR: rollback was incomplete"*) printf '错误:回滚未完成,请检查 systemd 状态' ;;
|
||||
*" contains a line break") printf '配置项包含换行符,已拒绝:%s' "${message%% contains a line break*}" ;;
|
||||
"Kaidi Finance "*" is installed") printf 'Kaidi 财务系统 %s 已安装' "${message#Kaidi Finance }" ;;
|
||||
"Configure your reverse proxy"*) printf '请将反向代理指向:%s' "${message#Configure your reverse proxy to }" ;;
|
||||
"Open /setup"*) printf '请通过反向代理域名打开 /setup,完成首次安装向导' ;;
|
||||
"Setup code: "*) printf '安装码位置:%s' "${message#Setup code: }" ;;
|
||||
"Open the reverse-proxy domain"*) printf '请打开反向代理域名并使用管理员账号登录' ;;
|
||||
"Temporary credentials: "*) printf '临时凭据位置:%s' "${message#Temporary credentials: }" ;;
|
||||
"Change the temporary password"*) printf '首次登录后请立即修改临时密码' ;;
|
||||
"Release public-key SHA-256 does not match"*) printf '发布公钥 SHA-256 与受信指纹不一致' ;;
|
||||
"Release manifest signature verification failed") printf '发布清单签名校验失败' ;;
|
||||
"Release version is invalid") printf '发布版本无效' ;;
|
||||
"Release artifact name is invalid") printf '发布包文件名无效' ;;
|
||||
"Release SHA-256 is invalid") printf '发布包 SHA-256 无效' ;;
|
||||
"Release artifact SHA-256 verification failed") printf '发布包 SHA-256 校验失败' ;;
|
||||
"Release archive contains an unsafe path") printf '发布归档包含不安全路径,已拒绝' ;;
|
||||
"Release archive must not contain symbolic links") printf '发布归档不能包含符号链接,已拒绝' ;;
|
||||
"Release app.jar is missing") printf '发布包缺少 app.jar' ;;
|
||||
"Release frontend is missing") printf '发布包缺少前端文件' ;;
|
||||
"Release version mismatch") printf '发布包版本与清单不一致' ;;
|
||||
"Release updater is missing") printf '发布包缺少更新脚本' ;;
|
||||
"Baota Spring Boot launcher is missing") printf '发布包缺少宝塔启动脚本' ;;
|
||||
"Baota Spring Boot launcher is invalid") printf '宝塔启动脚本语法无效' ;;
|
||||
"The existing FIELD_ENCRYPTION_KEY is missing") printf '已有安装缺少 FIELD_ENCRYPTION_KEY' ;;
|
||||
"Installed systemd units failed validation") printf '已安装的 systemd 单元校验失败' ;;
|
||||
"Environment verified: "*) printf '环境检查通过:%s' "${message#Environment verified: }" ;;
|
||||
*) printf '%s' "$message" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
log() { printf '[kaidi-install] %s\n' "$(localize_message "$*")"; }
|
||||
die() { printf '[kaidi-install] 错误:%s\n' "$(localize_message "$*")" >&2; exit 1; }
|
||||
|
||||
sha256_file() {
|
||||
sha256sum "$1" | awk '{print $1}'
|
||||
}
|
||||
|
||||
normalized_host_arch() {
|
||||
local machine host_bits
|
||||
machine=$(uname -m)
|
||||
host_bits=$(getconf LONG_BIT 2>/dev/null || true)
|
||||
case "$machine" in
|
||||
x86_64|amd64)
|
||||
[ "$host_bits" = 32 ] && printf x86 || printf x86_64
|
||||
;;
|
||||
i386|i486|i586|i686) printf x86 ;;
|
||||
aarch64|arm64)
|
||||
[ "$host_bits" = 32 ] && printf arm || printf aarch64
|
||||
;;
|
||||
armv7l|armv6l) printf arm ;;
|
||||
*) die "Unsupported CPU architecture: $machine" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
preflight_host() {
|
||||
local host_bits expected_bits
|
||||
[ "$(uname -s)" = Linux ] || die "The installer only supports Linux"
|
||||
[ "$APP_ROOT" = /opt/kaidi ] && [ "$STATE_ROOT" = /var/lib/kaidi ] \
|
||||
&& [ "$UPDATE_STATE_ROOT" = /var/lib/kaidi-update ] && [ "$CONFIG_ROOT" = /etc/kaidi ] \
|
||||
|| die "Custom installation roots are not supported by the packaged systemd configuration"
|
||||
command -v systemctl >/dev/null 2>&1 || die "systemd is required"
|
||||
command -v systemd-analyze >/dev/null 2>&1 || die "systemd-analyze is required"
|
||||
command -v getconf >/dev/null 2>&1 || die "getconf is required"
|
||||
[ -d /run/systemd/system ] || die "systemd is not running as PID 1"
|
||||
[ -d /etc/systemd/system ] || die "/etc/systemd/system is missing"
|
||||
HOST_ARCH=$(normalized_host_arch)
|
||||
host_bits=$(getconf LONG_BIT 2>/dev/null || true)
|
||||
case "$HOST_ARCH" in
|
||||
x86_64|aarch64) expected_bits=64 ;;
|
||||
x86|arm) expected_bits=32 ;;
|
||||
esac
|
||||
[ "$host_bits" = "$expected_bits" ] \
|
||||
|| 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}$ ]] \
|
||||
|| die "Set KAIDI_RELEASE_PUBLIC_KEY_SHA256 to the trusted release public-key SHA-256"
|
||||
case "$REINSTALL" in
|
||||
@@ -62,40 +225,47 @@ preflight_host() {
|
||||
[ ! -e "$STATE_ROOT/setup/locked" ] \
|
||||
|| die "The setup wizard is already locked; use a normal repair reinstall"
|
||||
fi
|
||||
log "Environment verified: $(uname -sr), architecture=$HOST_ARCH, userspace=${host_bits}-bit, systemd"
|
||||
}
|
||||
|
||||
[ "$(id -u)" -eq 0 ] || die "Run with sudo or as root"
|
||||
[ -z "$RELEASE_TOKEN" ] || [ -z "$RELEASE_TOKEN_FILE" ] \
|
||||
|| die "Set only one of KAIDI_RELEASE_TOKEN or KAIDI_RELEASE_TOKEN_FILE"
|
||||
if [ -n "$RELEASE_TOKEN_FILE" ]; then
|
||||
[ -f "$RELEASE_TOKEN_FILE" ] && [ ! -L "$RELEASE_TOKEN_FILE" ] \
|
||||
|| die "KAIDI_RELEASE_TOKEN_FILE must be a regular file"
|
||||
[ "$(wc -c < "$RELEASE_TOKEN_FILE" | tr -d '[:space:]')" -le 512 ] \
|
||||
|| die "KAIDI_RELEASE_TOKEN_FILE is too large"
|
||||
RELEASE_TOKEN=$(cat "$RELEASE_TOKEN_FILE")
|
||||
fi
|
||||
[ "$REINSTALL" = "true" ] || [ ! -e "$APP_ROOT/current" ] \
|
||||
|| die "Kaidi Finance is already installed; use the system update page"
|
||||
if [ -z "$RELEASE_API_URL" ]; then
|
||||
case "$RELEASE_BASE_URL" in
|
||||
*OWNER/REPO*) die "Set KAIDI_RELEASE_BASE_URL or KAIDI_RELEASE_API_URL to the Git release source" ;;
|
||||
esac
|
||||
fi
|
||||
[ -z "$RELEASE_TOKEN" ] || { [ "${#RELEASE_TOKEN}" -le 512 ] \
|
||||
&& ! printf '%s' "$RELEASE_TOKEN" | grep -q '[[:cntrl:]]'; } \
|
||||
|| die "KAIDI_RELEASE_TOKEN is invalid"
|
||||
validate_inputs() {
|
||||
[ "$(id -u)" -eq 0 ] || die "Run with sudo or as root"
|
||||
[ -z "$RELEASE_TOKEN" ] || [ -z "$RELEASE_TOKEN_FILE" ] \
|
||||
|| die "Set only one of KAIDI_RELEASE_TOKEN or KAIDI_RELEASE_TOKEN_FILE"
|
||||
if [ -n "$RELEASE_TOKEN_FILE" ]; then
|
||||
[ -f "$RELEASE_TOKEN_FILE" ] && [ ! -L "$RELEASE_TOKEN_FILE" ] \
|
||||
|| die "KAIDI_RELEASE_TOKEN_FILE must be a regular file"
|
||||
[ "$(wc -c < "$RELEASE_TOKEN_FILE" | tr -d '[:space:]')" -le 512 ] \
|
||||
|| die "KAIDI_RELEASE_TOKEN_FILE is too large"
|
||||
RELEASE_TOKEN=$(cat "$RELEASE_TOKEN_FILE")
|
||||
fi
|
||||
[ "$REINSTALL" = "true" ] || [ ! -e "$APP_ROOT/current" ] \
|
||||
|| die "Kaidi Finance is already installed; use the system update page"
|
||||
if [ -z "$RELEASE_API_URL" ]; then
|
||||
case "$RELEASE_BASE_URL" in
|
||||
*OWNER/REPO*) die "Set KAIDI_RELEASE_BASE_URL or KAIDI_RELEASE_API_URL to the Git release source" ;;
|
||||
esac
|
||||
fi
|
||||
[ -z "$RELEASE_TOKEN" ] || { [ "${#RELEASE_TOKEN}" -le 512 ] \
|
||||
&& ! printf '%s' "$RELEASE_TOKEN" | grep -q '[[:cntrl:]]'; } \
|
||||
|| die "KAIDI_RELEASE_TOKEN is invalid"
|
||||
}
|
||||
|
||||
download() {
|
||||
local url=$1 output=$2
|
||||
case "$url" in
|
||||
https://*)
|
||||
curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' \
|
||||
--tlsv1.2 "$url" -o "$output"
|
||||
if ! curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' \
|
||||
--tlsv1.2 --connect-timeout 15 --max-time 600 "$url" -o "$output"; then
|
||||
die "Download failed: $url"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
[ "${KAIDI_ALLOW_INSECURE_UPDATE:-false}" = "true" ] || die "Release URLs must use HTTPS"
|
||||
curl --fail --silent --show-error --location --proto '=http,https' \
|
||||
--proto-redir '=http,https' "$url" -o "$output"
|
||||
if ! curl --fail --silent --show-error --location --proto '=http,https' \
|
||||
--proto-redir '=http,https' --connect-timeout 15 --max-time 600 "$url" -o "$output"; then
|
||||
die "Download failed: $url"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
@@ -154,29 +324,120 @@ download_release_asset() {
|
||||
install_packages() {
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq ca-certificates coreutils curl findutils gzip jq openssl tar util-linux default-mysql-client
|
||||
apt-get update -qq >/dev/null 2>&1 \
|
||||
|| die "系统软件源更新失败,请检查网络和 apt 配置"
|
||||
apt-get install -y -qq ca-certificates coreutils curl findutils gzip jq openssl tar util-linux \
|
||||
>/dev/null 2>&1 \
|
||||
|| die "系统依赖安装失败;安装器不会安装 MySQL 或 Docker"
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
dnf install -y ca-certificates coreutils curl findutils gzip jq openssl tar util-linux mysql
|
||||
dnf install -y ca-certificates coreutils curl findutils gzip jq openssl tar util-linux \
|
||||
>/dev/null 2>&1 \
|
||||
|| die "系统依赖安装失败;安装器不会安装 MySQL 或 Docker"
|
||||
elif command -v yum >/dev/null 2>&1; then
|
||||
yum install -y ca-certificates coreutils curl findutils gzip jq openssl tar util-linux mysql
|
||||
yum install -y ca-certificates coreutils curl findutils gzip jq openssl tar util-linux \
|
||||
>/dev/null 2>&1 \
|
||||
|| die "系统依赖安装失败;安装器不会安装 MySQL 或 Docker"
|
||||
else
|
||||
die "Supported package managers are apt, dnf, and yum"
|
||||
fi
|
||||
log "系统依赖检查完成(未安装 MySQL、MariaDB、Docker)"
|
||||
}
|
||||
|
||||
preflight_runtime_commands() {
|
||||
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() {
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) printf x86 ;;
|
||||
i386|i486|i586|i686) printf i686 ;;
|
||||
aarch64|arm64) printf arm_64 ;;
|
||||
armv7l|armv6l) printf arm ;;
|
||||
*) die "Unsupported CPU architecture: $(uname -m)" ;;
|
||||
case "${HOST_ARCH:-$(normalized_host_arch)}" in
|
||||
x86_64) printf x86 ;;
|
||||
x86) printf i686 ;;
|
||||
aarch64) printf arm_64 ;;
|
||||
arm) printf arm ;;
|
||||
esac
|
||||
}
|
||||
|
||||
java_arch_matches_host() {
|
||||
local java_bin=$1 java_arch host_arch
|
||||
host_arch=${HOST_ARCH:-$(normalized_host_arch)}
|
||||
java_arch=$(
|
||||
"$java_bin" -XshowSettings:properties -version 2>&1 \
|
||||
| sed -n 's/^[[:space:]]*os\.arch = [[:space:]]*//p' \
|
||||
| head -n 1
|
||||
)
|
||||
case "$host_arch:$java_arch" in
|
||||
x86_64:amd64|x86_64:x86_64|x86:x86|x86:i386|x86:i486|x86:i586|x86:i686|\
|
||||
aarch64:aarch64|aarch64:arm64|arm:arm) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
java_home_from_bin() {
|
||||
local java_bin=$1 resolved_java java_major home
|
||||
[ -x "$java_bin" ] || return 1
|
||||
java_major=$(
|
||||
"$java_bin" -version 2>&1 \
|
||||
| sed -n 's/.*version "\([0-9][0-9]*\).*/\1/p' \
|
||||
| head -n 1
|
||||
)
|
||||
[[ "$java_major" =~ ^[0-9]+$ ]] && [ "$java_major" -ge 17 ] || return 1
|
||||
java_arch_matches_host "$java_bin" || return 1
|
||||
resolved_java=$(readlink -f "$java_bin" 2>/dev/null || printf '%s' "$java_bin")
|
||||
case "$resolved_java" in
|
||||
*/bin/java) home=${resolved_java%/bin/java} ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
[ -x "$home/bin/java" ] || return 1
|
||||
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() {
|
||||
local api java_metadata package_metadata package_uuid java_url java_sha256 actual_sha256
|
||||
local api java_metadata package_metadata package_uuid java_url java_sha256 actual_sha256 system_home java_line java_version
|
||||
JAVA_BIN=
|
||||
JAVA_STAGED_DIR=
|
||||
if system_home=$(system_java_home); then
|
||||
JAVA_BIN="$system_home/bin/java"
|
||||
java_line=$("$JAVA_BIN" -version 2>&1 | head -n 1) \
|
||||
|| die "The existing Java runtime cannot start on this host"
|
||||
java_version=$(printf '%s\n' "$java_line" | sed -n 's/.*version "\([0-9][0-9.]*\).*/\1/p')
|
||||
log "使用现有 Java 运行时:$system_home(版本 ${java_version:-未知})"
|
||||
return 0
|
||||
fi
|
||||
log "No local Java 17 runtime found; downloading the official Azul Java 17 runtime"
|
||||
api="https://api.azul.com/metadata/v1/zulu/packages/?java_version=17&os=linux&arch=$(azul_arch)&archive_type=tar.gz&java_package_type=jre&release_status=ga&availability_types=CA&latest=true"
|
||||
java_metadata="$WORK_DIR/zulu-metadata.json"
|
||||
download "$api" "$java_metadata"
|
||||
@@ -195,18 +456,112 @@ prepare_java() {
|
||||
[ "$actual_sha256" = "$java_sha256" ] || die "Java 17 runtime SHA-256 verification failed"
|
||||
mkdir -p "$JAVA_STAGED_DIR"
|
||||
tar -xzf "$WORK_DIR/java.tar.gz" --strip-components=1 -C "$JAVA_STAGED_DIR"
|
||||
"$JAVA_STAGED_DIR/bin/java" -version
|
||||
java_arch_matches_host "$JAVA_STAGED_DIR/bin/java" \
|
||||
|| die "Downloaded Java runtime architecture does not match $HOST_ARCH"
|
||||
java_line=$("$JAVA_STAGED_DIR/bin/java" -version 2>&1 | head -n 1) \
|
||||
|| die "The downloaded Java runtime cannot start; verify glibc and the host architecture"
|
||||
JAVA_BIN="$APP_ROOT/runtime/java/bin/java"
|
||||
java_version=$(printf '%s\n' "$java_line" | sed -n 's/.*version "\([0-9][0-9.]*\).*/\1/p')
|
||||
log "下载的 Java 运行时校验通过(版本 ${java_version:-未知})"
|
||||
}
|
||||
|
||||
activate_java() {
|
||||
[ -n "$JAVA_STAGED_DIR" ] || return 0
|
||||
mkdir -p "$APP_ROOT/runtime"
|
||||
JAVA_PREVIOUS_DIR="$WORK_DIR/java.previous"
|
||||
if [ -e "$APP_ROOT/runtime/java" ]; then
|
||||
if [ -e "$APP_ROOT/runtime/java" ] || [ -L "$APP_ROOT/runtime/java" ]; then
|
||||
mv "$APP_ROOT/runtime/java" "$JAVA_PREVIOUS_DIR"
|
||||
fi
|
||||
mv "$JAVA_STAGED_DIR" "$APP_ROOT/runtime/java"
|
||||
chown -R root:"$SERVICE_GROUP" "$APP_ROOT/runtime/java"
|
||||
chmod -R u=rwX,g=rX,o= "$APP_ROOT/runtime/java"
|
||||
JAVA_ACTIVATED=true
|
||||
"$APP_ROOT/runtime/java/bin/java" -version
|
||||
}
|
||||
|
||||
ensure_service_identity() {
|
||||
local existing_home nologin_path
|
||||
command -v getent >/dev/null 2>&1 || die "getent is required"
|
||||
if ! getent group "$SERVICE_GROUP" >/dev/null 2>&1; then
|
||||
groupadd --system "$SERVICE_GROUP"
|
||||
SERVICE_GROUP_CREATED=true
|
||||
fi
|
||||
if id "$SERVICE_USER" >/dev/null 2>&1; then
|
||||
existing_home=$(getent passwd "$SERVICE_USER" | awk -F: '{print $6}')
|
||||
[ "$existing_home" = "$STATE_ROOT" ] \
|
||||
|| die "Existing user $SERVICE_USER has unexpected home directory $existing_home"
|
||||
if ! id -nG "$SERVICE_USER" | tr ' ' '\n' | grep -Fxq "$SERVICE_GROUP"; then
|
||||
usermod --append --groups "$SERVICE_GROUP" "$SERVICE_USER"
|
||||
fi
|
||||
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
|
||||
}
|
||||
|
||||
record_layout_path() {
|
||||
[ -e "$1" ] || [ -L "$1" ] || CREATED_LAYOUT_PATHS+=("$1")
|
||||
}
|
||||
|
||||
prepare_managed_layout() {
|
||||
command -v runuser >/dev/null 2>&1 || die "runuser is required"
|
||||
record_layout_path "$APP_ROOT"
|
||||
record_layout_path "$APP_ROOT/releases"
|
||||
record_layout_path "$APP_ROOT/runtime"
|
||||
record_layout_path "$APP_ROOT/bin"
|
||||
record_layout_path "$STATE_ROOT"
|
||||
record_layout_path "$STATE_ROOT/files"
|
||||
record_layout_path "$STATE_ROOT/tmp"
|
||||
record_layout_path /var/log/kaidi
|
||||
record_layout_path "$STATE_ROOT/setup"
|
||||
record_layout_path "$UPDATE_STATE_ROOT"
|
||||
record_layout_path "$UPDATE_STATE_ROOT/inbox"
|
||||
record_layout_path "$CONFIG_ROOT"
|
||||
install -d -o root -g "$SERVICE_GROUP" -m 0750 \
|
||||
"$APP_ROOT" "$APP_ROOT/releases" "$APP_ROOT/runtime" "$APP_ROOT/bin"
|
||||
install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0750 \
|
||||
"$STATE_ROOT" "$STATE_ROOT/files" "$STATE_ROOT/tmp" /var/log/kaidi
|
||||
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 root -m 0700 "$CONFIG_ROOT"
|
||||
}
|
||||
|
||||
secure_release_tree() {
|
||||
local release_dir=$1
|
||||
chown -R root:"$SERVICE_GROUP" "$release_dir"
|
||||
find "$release_dir" -type d -exec chmod 0750 {} +
|
||||
find "$release_dir" -type f -exec chmod 0640 {} +
|
||||
chmod 0750 "$release_dir/ops/update.sh" "$release_dir/ops/baota-start.sh"
|
||||
}
|
||||
|
||||
restore_security_contexts() {
|
||||
command -v restorecon >/dev/null 2>&1 || return 0
|
||||
restorecon -RF "$APP_ROOT" "$STATE_ROOT" "$UPDATE_STATE_ROOT" /var/log/kaidi \
|
||||
/etc/systemd/system/kaidi-finance.service \
|
||||
/etc/systemd/system/kaidi-update.service \
|
||||
/etc/systemd/system/kaidi-update.path >/dev/null 2>&1 \
|
||||
|| log "SELinux context restoration reported a warning; service access checks will decide whether installation can continue"
|
||||
}
|
||||
|
||||
verify_service_access() {
|
||||
# shellcheck disable=SC2016 # Positional parameters are expanded by the child shell.
|
||||
runuser -u "$SERVICE_USER" -- sh -c \
|
||||
'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"
|
||||
[ -n "$JAVA_BIN" ] && [ -x "$JAVA_BIN" ] \
|
||||
|| die "The selected Java runtime is unavailable at $JAVA_BIN"
|
||||
runuser -u "$SERVICE_USER" -- test -x "$JAVA_BIN" \
|
||||
|| 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" \
|
||||
|| die "$SERVICE_USER cannot write the file-storage directory"
|
||||
runuser -u "$SERVICE_USER" -- test -w "$UPDATE_STATE_ROOT/inbox" \
|
||||
|| die "$SERVICE_USER cannot write the update inbox"
|
||||
log "Service-user filesystem and Java access checks passed"
|
||||
}
|
||||
|
||||
random_secret() { openssl rand -base64 36 | tr -d '\n/+=' | cut -c1-36; }
|
||||
@@ -292,7 +647,7 @@ configure_app_port() {
|
||||
default_port=$(read_reinstall_env SERVER_PORT || true)
|
||||
[[ "$default_port" =~ ^[0-9]{1,5}$ ]] || default_port=18080
|
||||
if [ -t 1 ] && [ -r /dev/tty ]; then
|
||||
printf '[kaidi-install] Application port [%s]: ' "$default_port" > /dev/tty
|
||||
printf '[kaidi-install] 应用端口 [%s]:' "$default_port" > /dev/tty
|
||||
if IFS= read -r entered < /dev/tty; then
|
||||
APP_PORT=${entered:-$default_port}
|
||||
else
|
||||
@@ -349,10 +704,12 @@ configure_database() {
|
||||
if [ "$SETUP_WIZARD" = true ]; then
|
||||
[ -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"
|
||||
DB_URL='jdbc:mysql://setup.invalid:3306/kaidi_finance'
|
||||
DB_USERNAME=setup_pending
|
||||
DB_PASSWORD=setup_pending
|
||||
return
|
||||
# First-run mode does not create a datasource. Keep database fields empty
|
||||
# so hosting panels cannot mistake a placeholder for a local MySQL service.
|
||||
DB_URL=
|
||||
DB_USERNAME=
|
||||
DB_PASSWORD=
|
||||
return 0
|
||||
fi
|
||||
if [ "$REINSTALL" = true ]; then
|
||||
[ -s "$CONFIG_ROOT/kaidi.env" ] || die "KAIDI_REINSTALL needs the existing $CONFIG_ROOT/kaidi.env"
|
||||
@@ -367,7 +724,7 @@ configure_database() {
|
||||
if [ -n "$DB_URL" ]; then
|
||||
[ -n "$DB_USERNAME" ] && [ -n "$DB_PASSWORD" ] \
|
||||
|| die "KAIDI_DB_USERNAME and KAIDI_DB_PASSWORD are required with KAIDI_DB_URL"
|
||||
return
|
||||
return 0
|
||||
fi
|
||||
[ "$REINSTALL" != true ] \
|
||||
|| die "KAIDI_REINSTALL needs database credentials in the existing installation or command environment"
|
||||
@@ -376,6 +733,7 @@ configure_database() {
|
||||
|
||||
database_host() {
|
||||
local endpoint host_port
|
||||
[ -n "$DB_URL" ] || { printf ''; return; }
|
||||
endpoint=${DB_URL#jdbc:mysql://}
|
||||
host_port=${endpoint%%/*}
|
||||
printf '%s' "${KAIDI_DB_HOST:-${host_port%%:*}}"
|
||||
@@ -383,6 +741,7 @@ database_host() {
|
||||
|
||||
database_port() {
|
||||
local endpoint host_port candidate
|
||||
[ -n "$DB_URL" ] || { printf ''; return; }
|
||||
endpoint=${DB_URL#jdbc:mysql://}
|
||||
host_port=${endpoint%%/*}
|
||||
candidate=${host_port##*:}
|
||||
@@ -392,54 +751,43 @@ database_port() {
|
||||
|
||||
database_name() {
|
||||
local endpoint name
|
||||
[ -n "$DB_URL" ] || { printf ''; return; }
|
||||
endpoint=${DB_URL#jdbc:mysql://}
|
||||
name=${endpoint#*/}
|
||||
name=${name%%\?*}
|
||||
printf '%s' "${KAIDI_DB_NAME:-$name}"
|
||||
}
|
||||
|
||||
preflight_database() {
|
||||
local client host port name version table
|
||||
[ "$SETUP_WIZARD" != true ] || return
|
||||
validate_database_configuration() {
|
||||
[ "$SETUP_WIZARD" != true ] || return 0
|
||||
case "$DB_URL" in
|
||||
jdbc:mysql://*) ;;
|
||||
*) die "KAIDI_DB_URL must start with jdbc:mysql://" ;;
|
||||
esac
|
||||
client=$(command -v mysql || command -v mariadb || true)
|
||||
[ -n "$client" ] || die "A MySQL command-line client is required"
|
||||
host=$(database_host)
|
||||
port=$(database_port)
|
||||
name=$(database_name)
|
||||
[ -n "$host" ] || die "The database host is empty"
|
||||
[[ "$port" =~ ^[0-9]{1,5}$ ]] && [ "$port" -ge 1 ] && [ "$port" -le 65535 ] \
|
||||
[ -n "$DB_USERNAME" ] || die "KAIDI_DB_USERNAME is empty"
|
||||
[ -n "$DB_PASSWORD" ] || die "KAIDI_DB_PASSWORD is empty"
|
||||
[ -n "$(database_host)" ] || die "The database host is empty"
|
||||
[[ "$(database_port)" =~ ^[0-9]{1,5}$ ]] \
|
||||
&& [ "$(database_port)" -ge 1 ] && [ "$(database_port)" -le 65535 ] \
|
||||
|| die "The database port is invalid"
|
||||
[[ "$name" =~ ^[A-Za-z0-9_$]+$ ]] || die "The database name is invalid"
|
||||
[[ "$(database_name)" =~ ^[A-Za-z0-9_$]+$ ]] || die "The database name is invalid"
|
||||
# The installer deliberately does not invoke mysql/mariadb and does not
|
||||
# execute DDL/DML. The application performs Flyway migrations only after
|
||||
# it has connected to the operator-provided schema.
|
||||
log "已记录外部 MySQL 连接配置;安装器不安装 MySQL、不连接数据库、不执行 SQL"
|
||||
}
|
||||
|
||||
version=$(MYSQL_PWD="$DB_PASSWORD" "$client" --protocol=TCP --connect-timeout=10 \
|
||||
-h "$host" -P "$port" -u "$DB_USERNAME" "$name" --batch --skip-column-names \
|
||||
-e 'SELECT VERSION()' 2>/dev/null) || die "Cannot connect to the configured MySQL database"
|
||||
case "$version" in
|
||||
8.4.*) ;;
|
||||
*) die "MySQL 8.4.x is required; server reported $version" ;;
|
||||
# Kept as a compatibility name for older local fixtures and wrappers. It is
|
||||
# now a pure configuration check and has no database side effects.
|
||||
preflight_database() {
|
||||
validate_database_configuration
|
||||
}
|
||||
|
||||
validate_database_backup_mode() {
|
||||
case "${KAIDI_DB_BACKUP_MODE:-skip}" in
|
||||
skip|mysqldump) ;;
|
||||
*) die "KAIDI_DB_BACKUP_MODE must be skip or mysqldump" ;;
|
||||
esac
|
||||
|
||||
table="kaidi_install_preflight_$$"
|
||||
if ! MYSQL_PWD="$DB_PASSWORD" "$client" --protocol=TCP --connect-timeout=10 \
|
||||
-h "$host" -P "$port" -u "$DB_USERNAME" "$name" >/dev/null <<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() {
|
||||
@@ -502,12 +850,25 @@ write_env_file_preserving_unknown() {
|
||||
}
|
||||
|
||||
wait_for_health() {
|
||||
local attempts=0
|
||||
local attempts=0 active_state restart_count
|
||||
log "Waiting for application startup at $HEALTH_URL"
|
||||
while [ "$attempts" -lt 60 ]; do
|
||||
if curl -fsS "$HEALTH_URL" | jq -e '.status == "UP"' >/dev/null 2>&1; then
|
||||
return
|
||||
if curl --fail --silent --connect-timeout 1 --max-time 2 "$HEALTH_URL" 2>/dev/null \
|
||||
| jq -e '.status == "UP"' >/dev/null 2>&1; then
|
||||
log "Application health check is UP"
|
||||
return 0
|
||||
fi
|
||||
active_state=$(systemctl show kaidi-finance.service --property=ActiveState --value 2>/dev/null || printf unknown)
|
||||
restart_count=$(systemctl show kaidi-finance.service --property=NRestarts --value 2>/dev/null || printf 0)
|
||||
[[ "$restart_count" =~ ^[0-9]+$ ]] || restart_count=0
|
||||
if [ "$active_state" = failed ] || [ "$restart_count" -ge 3 ]; then
|
||||
journalctl -u kaidi-finance.service -n 80 --no-pager >&2 || true
|
||||
die "Application service failed during startup (state=$active_state, restarts=$restart_count)"
|
||||
fi
|
||||
attempts=$((attempts + 1))
|
||||
if [ $((attempts % 5)) -eq 0 ]; then
|
||||
log "Application is still starting (state=$active_state, elapsed=$((attempts * 2))s)"
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
journalctl -u kaidi-finance.service -n 80 --no-pager >&2 || true
|
||||
@@ -537,15 +898,25 @@ backup_managed_state() {
|
||||
fi
|
||||
index=$((index + 1))
|
||||
done
|
||||
systemctl is-active --quiet kaidi-finance.service && PREVIOUS_APP_ACTIVE=true || PREVIOUS_APP_ACTIVE=false
|
||||
systemctl is-enabled --quiet kaidi-finance.service && PREVIOUS_APP_ENABLED=true || PREVIOUS_APP_ENABLED=false
|
||||
systemctl is-active --quiet kaidi-update.path && PREVIOUS_UPDATE_ACTIVE=true || PREVIOUS_UPDATE_ACTIVE=false
|
||||
systemctl is-enabled --quiet kaidi-update.path && PREVIOUS_UPDATE_ENABLED=true || PREVIOUS_UPDATE_ENABLED=false
|
||||
systemctl is-active --quiet kaidi-finance.service >/dev/null 2>&1 \
|
||||
&& PREVIOUS_APP_ACTIVE=true || PREVIOUS_APP_ACTIVE=false
|
||||
systemctl is-enabled --quiet kaidi-finance.service >/dev/null 2>&1 \
|
||||
&& PREVIOUS_APP_ENABLED=true || PREVIOUS_APP_ENABLED=false
|
||||
systemctl is-active --quiet kaidi-update.path >/dev/null 2>&1 \
|
||||
&& PREVIOUS_UPDATE_ACTIVE=true || PREVIOUS_UPDATE_ACTIVE=false
|
||||
systemctl is-enabled --quiet kaidi-update.path >/dev/null 2>&1 \
|
||||
&& PREVIOUS_UPDATE_ENABLED=true || PREVIOUS_UPDATE_ENABLED=false
|
||||
INSTALL_TRANSACTION_ARMED=true
|
||||
}
|
||||
|
||||
restore_unit_state() {
|
||||
local unit=$1 was_enabled=$2 was_active=$3
|
||||
if ! systemctl cat "$unit" >/dev/null 2>&1; then
|
||||
systemctl reset-failed "$unit" >/dev/null 2>&1 || true
|
||||
[ "$was_enabled" = false ] && [ "$was_active" = false ]
|
||||
return
|
||||
fi
|
||||
systemctl reset-failed "$unit" >/dev/null 2>&1 || true
|
||||
if [ "$was_enabled" = true ]; then
|
||||
systemctl enable "$unit" >/dev/null 2>&1
|
||||
else
|
||||
@@ -575,10 +946,10 @@ restore_managed_path() {
|
||||
}
|
||||
|
||||
rollback_install() {
|
||||
local index=0 path rollback_failed=false
|
||||
local index=0 path rollback_failed=false layout_path service_uid
|
||||
set +e
|
||||
log "Installation failed; restoring the previous managed state"
|
||||
systemctl stop kaidi-update.path kaidi-finance.service >/dev/null 2>&1 || rollback_failed=true
|
||||
systemctl stop kaidi-update.path kaidi-finance.service >/dev/null 2>&1 || true
|
||||
for path in "${MANAGED_PATHS[@]}"; do
|
||||
restore_managed_path "$path" "$index" || rollback_failed=true
|
||||
index=$((index + 1))
|
||||
@@ -586,13 +957,26 @@ rollback_install() {
|
||||
if [ -n "$RELEASE_DIR" ]; then
|
||||
rm -rf -- "$RELEASE_DIR" || rollback_failed=true
|
||||
fi
|
||||
if [ -d "$JAVA_PREVIOUS_DIR" ]; then
|
||||
if [ -d "$JAVA_PREVIOUS_DIR" ] || [ -L "$JAVA_PREVIOUS_DIR" ]; then
|
||||
rm -rf -- "$APP_ROOT/runtime/java" || rollback_failed=true
|
||||
mkdir -p "$APP_ROOT/runtime" || rollback_failed=true
|
||||
mv "$JAVA_PREVIOUS_DIR" "$APP_ROOT/runtime/java" || rollback_failed=true
|
||||
elif [ "$JAVA_ACTIVATED" = true ]; then
|
||||
rm -rf -- "$APP_ROOT/runtime/java" || rollback_failed=true
|
||||
fi
|
||||
if [ "$SERVICE_USER_CREATED" = true ]; then
|
||||
service_uid=$(id -u "$SERVICE_USER" 2>/dev/null || true)
|
||||
if [ -n "$service_uid" ]; then
|
||||
userdel --force "$SERVICE_USER" >/dev/null 2>&1 || rollback_failed=true
|
||||
fi
|
||||
fi
|
||||
if [ "$SERVICE_GROUP_CREATED" = true ] && getent group "$SERVICE_GROUP" >/dev/null 2>&1; then
|
||||
groupdel "$SERVICE_GROUP" >/dev/null 2>&1 || rollback_failed=true
|
||||
fi
|
||||
for ((index=${#CREATED_LAYOUT_PATHS[@]} - 1; index >= 0; index--)); do
|
||||
layout_path=${CREATED_LAYOUT_PATHS[$index]}
|
||||
rmdir -- "$layout_path" >/dev/null 2>&1 || true
|
||||
done
|
||||
systemctl daemon-reload >/dev/null 2>&1 || rollback_failed=true
|
||||
restore_unit_state kaidi-finance.service "$PREVIOUS_APP_ENABLED" "$PREVIOUS_APP_ACTIVE" \
|
||||
|| rollback_failed=true
|
||||
@@ -613,11 +997,15 @@ cleanup() {
|
||||
exit "$result"
|
||||
}
|
||||
|
||||
main() {
|
||||
trap cleanup EXIT
|
||||
|
||||
validate_inputs
|
||||
validate_database_backup_mode
|
||||
configure_app_port
|
||||
preflight_host
|
||||
install_packages
|
||||
preflight_runtime_commands
|
||||
|
||||
BASE=${RELEASE_BASE_URL%/}
|
||||
WORK_DIR=$(mktemp -d)
|
||||
@@ -660,26 +1048,21 @@ find "$WORK_DIR/extracted" -type l -print -quit | grep -q . \
|
||||
[ -s "$WORK_DIR/extracted/public/index.html" ] || die "Release frontend is missing"
|
||||
[ "$(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/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"
|
||||
|
||||
prepare_java
|
||||
configure_database
|
||||
preflight_database
|
||||
|
||||
id kaidi >/dev/null 2>&1 || useradd --system --home "$STATE_ROOT" --shell /usr/sbin/nologin kaidi
|
||||
mkdir -p "$APP_ROOT/releases" "$APP_ROOT/bin" "$STATE_ROOT/files" "$STATE_ROOT/tmp" \
|
||||
"$STATE_ROOT/setup" "$UPDATE_STATE_ROOT/inbox" "$CONFIG_ROOT" /var/log/kaidi
|
||||
chown -R kaidi:kaidi "$STATE_ROOT" "$UPDATE_STATE_ROOT/inbox" /var/log/kaidi
|
||||
chown root:kaidi "$UPDATE_STATE_ROOT"
|
||||
chmod 0750 "$STATE_ROOT" "$STATE_ROOT/files" "$STATE_ROOT/tmp" "$UPDATE_STATE_ROOT" "$UPDATE_STATE_ROOT/inbox"
|
||||
chmod 0700 "$STATE_ROOT/setup"
|
||||
ensure_service_identity
|
||||
prepare_managed_layout
|
||||
prepare_java
|
||||
|
||||
RELEASE_DIR="$APP_ROOT/releases/$VERSION"
|
||||
[ ! -e "$RELEASE_DIR" ] || RELEASE_DIR="$APP_ROOT/releases/${VERSION}-reinstall-$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
backup_managed_state
|
||||
activate_java
|
||||
mv "$WORK_DIR/extracted" "$RELEASE_DIR"
|
||||
chown -R root:root "$RELEASE_DIR"
|
||||
chmod -R go-w "$RELEASE_DIR"
|
||||
secure_release_tree "$RELEASE_DIR"
|
||||
ln -sfn "$RELEASE_DIR" "$APP_ROOT/current.next"
|
||||
mv -Tf "$APP_ROOT/current.next" "$APP_ROOT/current"
|
||||
install -m 0755 "$RELEASE_DIR/ops/update.sh" "$APP_ROOT/bin/update.sh"
|
||||
@@ -721,6 +1104,7 @@ if [ "$SETUP_WIZARD" = true ]; then
|
||||
fi
|
||||
write_env_file_preserving_unknown "$CONFIG_ROOT/kaidi.env" \
|
||||
SPRING_PROFILES_ACTIVE production \
|
||||
KAIDI_JAVA_BIN "$JAVA_BIN" \
|
||||
SERVER_PORT "$APP_PORT" \
|
||||
SERVER_ADDRESS "$SERVER_ADDRESS" \
|
||||
SESSION_COOKIE_SECURE "$SESSION_COOKIE_SECURE" \
|
||||
@@ -758,20 +1142,30 @@ write_env_file "$CONFIG_ROOT/update.env" \
|
||||
KAIDI_APP_ROOT "$APP_ROOT" \
|
||||
KAIDI_UPDATE_STATE_ROOT "$UPDATE_STATE_ROOT" \
|
||||
KAIDI_SERVICE_NAME kaidi-finance.service \
|
||||
KAIDI_JAVA_BIN "$JAVA_BIN" \
|
||||
KAIDI_HEALTH_URL "$HEALTH_URL" \
|
||||
KAIDI_APP_INDEX_URL "$APP_INDEX_URL" \
|
||||
KAIDI_DB_CONTAINER "${KAIDI_DB_CONTAINER:-}" \
|
||||
KAIDI_DB_HOST "$(database_host)" \
|
||||
KAIDI_DB_PORT "$(database_port)" \
|
||||
KAIDI_DB_NAME "$(database_name)" \
|
||||
KAIDI_DB_USERNAME "$DB_USERNAME" \
|
||||
KAIDI_DB_PASSWORD "$DB_PASSWORD"
|
||||
KAIDI_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"
|
||||
|
||||
install -m 0644 "$RELEASE_DIR/ops/kaidi-finance.service" /etc/systemd/system/kaidi-finance.service
|
||||
install -m 0644 "$RELEASE_DIR/ops/kaidi-update.service" /etc/systemd/system/kaidi-update.service
|
||||
install -m 0644 "$RELEASE_DIR/ops/kaidi-update.path" /etc/systemd/system/kaidi-update.path
|
||||
restore_security_contexts
|
||||
verify_service_access
|
||||
systemd-analyze verify \
|
||||
/etc/systemd/system/kaidi-finance.service \
|
||||
/etc/systemd/system/kaidi-update.service \
|
||||
/etc/systemd/system/kaidi-update.path >/dev/null \
|
||||
|| die "Installed systemd units failed validation"
|
||||
systemctl daemon-reload
|
||||
systemctl reset-failed kaidi-finance.service >/dev/null 2>&1 || true
|
||||
systemctl enable kaidi-finance.service
|
||||
systemctl restart kaidi-finance.service
|
||||
wait_for_health
|
||||
@@ -783,26 +1177,26 @@ if [ "$SETUP_WIZARD" != true ]; then
|
||||
sed -i 's/^FINANCE_BOOTSTRAP_PASSWORD=.*$/FINANCE_BOOTSTRAP_PASSWORD=""/' "$CONFIG_ROOT/kaidi.env"
|
||||
fi
|
||||
jq -n --arg version "$VERSION" --arg updatedAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
'{state:"CURRENT",message:"Initial release is running",targetVersion:$version,updatedAt:$updatedAt}' \
|
||||
'{state:"CURRENT",message:"初始版本正在运行",targetVersion:$version,updatedAt:$updatedAt}' \
|
||||
> "$UPDATE_STATE_ROOT/status.json"
|
||||
chmod 0644 "$UPDATE_STATE_ROOT/status.json"
|
||||
systemctl enable --now kaidi-update.path
|
||||
|
||||
if [ "$SETUP_WIZARD" = true ]; then
|
||||
cat > /root/kaidi-first-login.txt <<EOF
|
||||
URL after reverse proxy: http://SERVER_IP/setup
|
||||
Reverse proxy target: $PROXY_TARGET
|
||||
Setup code: $SETUP_CODE
|
||||
Version: $VERSION
|
||||
反向代理访问地址:http://SERVER_IP/setup
|
||||
反向代理目标:$PROXY_TARGET
|
||||
安装码:$SETUP_CODE
|
||||
版本:$VERSION
|
||||
EOF
|
||||
chmod 0600 /root/kaidi-first-login.txt
|
||||
elif [ "$REINSTALL" != true ]; then
|
||||
cat > /root/kaidi-first-login.txt <<EOF
|
||||
URL after reverse proxy: http://SERVER_IP/
|
||||
Reverse proxy target: $PROXY_TARGET
|
||||
Username: admin
|
||||
Temporary password: $ADMIN_PASSWORD
|
||||
Version: $VERSION
|
||||
反向代理访问地址:http://SERVER_IP/
|
||||
反向代理目标:$PROXY_TARGET
|
||||
管理员账号:admin
|
||||
临时密码:$ADMIN_PASSWORD
|
||||
版本:$VERSION
|
||||
EOF
|
||||
chmod 0600 /root/kaidi-first-login.txt
|
||||
fi
|
||||
@@ -822,3 +1216,6 @@ if [ "$REINSTALL" != true ] && [ "$SETUP_WIZARD" != true ]; then
|
||||
log "Temporary credentials: /root/kaidi-first-login.txt"
|
||||
log "Change the temporary password immediately after first sign-in"
|
||||
fi
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
Executable
+322
@@ -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 "$@"
|
||||
@@ -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,6 +2,8 @@
|
||||
Description=Kaidi Finance System
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
StartLimitIntervalSec=60
|
||||
StartLimitBurst=3
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
@@ -9,8 +11,9 @@ User=kaidi
|
||||
Group=kaidi
|
||||
EnvironmentFile=/etc/kaidi/kaidi.env
|
||||
EnvironmentFile=-/var/lib/kaidi/setup/application.env
|
||||
Environment=KAIDI_ENV_PRELOADED=true
|
||||
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
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
@@ -4,8 +4,8 @@ Description=Watch for Kaidi Finance update requests
|
||||
[Path]
|
||||
PathChanged=/var/lib/kaidi-update/inbox
|
||||
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/transactions/active
|
||||
Unit=kaidi-update.service
|
||||
|
||||
[Install]
|
||||
|
||||
@@ -10,15 +10,13 @@ Type=oneshot
|
||||
User=root
|
||||
Group=root
|
||||
EnvironmentFile=/etc/kaidi/update.env
|
||||
EnvironmentFile=-/var/lib/kaidi/setup/application.env
|
||||
ExecStart=/opt/kaidi/bin/update.sh
|
||||
Restart=on-failure
|
||||
RestartSec=15
|
||||
Restart=no
|
||||
Nice=10
|
||||
IOSchedulingClass=best-effort
|
||||
IOSchedulingPriority=6
|
||||
PrivateTmp=true
|
||||
ProtectHome=true
|
||||
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
|
||||
|
||||
+783
-68
File diff suppressed because it is too large
Load Diff
+35
-24
@@ -4,7 +4,7 @@
|
||||
> 版本: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>
|
||||
> 编制依据: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 中原位增加菜单、页面、组件和真实功能
|
||||
> 文档性质:本项目唯一 PRD、SPEC、开发任务书、测试验收与交付基线<br>
|
||||
> 文档用途:甲方需求确认、产品设计、开发拆分、测试验收和交付培训<br>
|
||||
@@ -68,7 +68,7 @@
|
||||
| --- | --- |
|
||||
| 页面层级 | PAGE-03~21 以路由合同面包屑作为静态页面名称的唯一可见来源;工作台、列表、台账、报表和配置页删除内容区重复的模块名、页面名及功能说明,只保留读屏标题和紧凑操作栏。项目详情、表单详情继续显示不与面包屑重复的业务编号、对象名称和状态 |
|
||||
| Preview.6 | 在 Preview.5 基础上,窄屏表格固定列统一降级为横向滚动列,顶部账号文本增加省略保护;通过 TypeScript、ESLint、Stylelint、Vitest `10 files / 56 tests`、production build/hygiene、完整 Playwright `198/198`(含 390px 固定列几何回归);真实 Edge 复测权限与配置、系统更新、财务工作台和记账准备页面无整体横向溢出或控件重叠 |
|
||||
| Preview.8 安装向导 | 新增 PAGE-23 独立无库启动上下文;首次访问 `/setup` 以一次性安装码验证 MySQL 8.4 连接及 DDL/DML,执行 Flyway、创建自定义 `SYSTEM_ADMIN` 和全局权限,写入受限运行时配置并锁定向导;32 位 curl 路径不再把数据库密码放进命令行,安装完成后的修复重装优先读取运行时覆盖文件;通过 Java 编译、无库 HTTP smoke、TDesign 输入可访问性和 setup wizard Playwright 回归 |
|
||||
| Preview.8 安装向导 | 新增 PAGE-23 独立无库启动上下文;安装器只保存端口和向导启动配置,不连接数据库、不执行 SQL;首次访问 `/setup` 以一次性安装码接收外部 MySQL 连接信息,点击完成后由应用执行 Flyway、创建自定义 `SYSTEM_ADMIN` 和全局权限,写入受限运行时配置并锁定向导;32 位 curl 路径不再把数据库密码放进命令行,安装完成后的修复重装优先读取运行时覆盖文件;通过 Java 编译、无库 HTTP smoke、TDesign 输入可访问性和 setup wizard Playwright 回归 |
|
||||
| 超级管理员权限 | V071 将 `SYSTEM_ADMIN` 固定为隔离超级管理员,逐条授予全部 `iam_permission` 和无额度 `GLOBAL` scope,并由启动同步补齐后续权限;前端菜单、直链和按钮统一放行全部 PAGE-04~21。管理员可管理跨人员表单/导入/付款草稿,PAGE-15 增加仅管理员可见的“全部付款”,三类业务工作台显示跨人员项目、资金、档案和全部待办;审批实际处理人、SOD、状态机、资金、附件安全和审计继续强制执行 |
|
||||
| OA-06 付款投影 | V070 发布 OA-06 v2,新增生效合同、供应商和已确认应付稳定引用;财务终审在来源审批事务内生成 `OFFLINE_PENDING` 付款,执行付款检查、冻结项目资金并追加资金流水。余额不足等投影失败时来源状态、审批任务、付款、资金控制和流水整体回滚;不产生银行付款指令,不接网银、U 盾或第三方平台 |
|
||||
| OA 动态附件矩阵 | OA-04/OA-06 的条件附件行支持 TDesign 布尔开关、长文本、不涉及原因和嵌套文件上传;仅 `required=true` 的行要求文件或原因,可选空行不阻断。上传状态按字段和表格单元隔离,上传期间锁定刷新、保存、校验和提交;更新请求不再携带创建专用字段,校验前自动保存当前草稿 |
|
||||
@@ -76,10 +76,10 @@
|
||||
| 第一版 Preview | PAGE-01~22 页面和菜单框架齐备,真实前后端核心链保持可用;Preview 上线与整套 R1 最终验收分开计量 |
|
||||
| 来源表格校验 | V068 为 14 类 OA 的 33 个 TABLE 字段补齐嵌套类型、必填和文件引用规则;V069 保留 OA-02 v1 历史账户表格契约,避免 v2 字段追溯污染 |
|
||||
| PAGE-20 审计 | 增加不可变事件序号、四种稳定排序、URL 查询状态、同排序 CSV 导出、脱敏详情及 OpenAPI `422` 参数门禁 |
|
||||
| PAGE-22 在线更新 | 更新源固定为公共 Gitea Latest Release API,默认不使用 Token;系统管理员点击“获取更新”后自动检查并下载,验签缓存进入 `READY` 后显示“立即重启”。安装健康检查成功后页面从 10 秒倒计时自动刷新;刷新或短暂断线发生在安装中时恢复轮询。后台只写固定结构请求,由 root oneshot 服务验签、验哈希、备份、切换和回滚,不接受页面传入地址、Token 或命令 |
|
||||
| PAGE-22 在线更新 | 更新源固定为公共 Gitea Latest Release API,默认不使用 Token;“获取版本”只读取最新版本信息,发现新版本后由管理员点击“立即更新”开始下载。下载、验签完成进入 `READY/下载完成` 后显示“立即更新并重启”。安装健康检查成功后页面从 10 秒倒计时自动刷新;刷新、短暂断线或命令响应丢失时恢复状态轮询。后台只写固定结构请求,由 root oneshot 服务验签、验哈希、按配置选择是否调用已有 `mysqldump`、切换和回滚,不接受页面传入地址、Token 或命令 |
|
||||
| Release 工程 | `.gitea/workflows/release.yml` 监听严格 SemVer `v*` tag;Maven `revision`、npm、JAR、前后端 SBOM、签名 manifest 与 tag 必须同版本。工作流执行 Java/前端/OpenAPI/Playwright/依赖审计,使用至少 3072 位 RSA 密钥生成并独立验收恰好 9 个资产;先创建不可见草稿、逐项上传并核对名称/大小,最后发布为可被 `/releases/latest` 读取的正式 Release |
|
||||
| Linux 32 位 | i386/i486/i586/i686 下载 Java 17 i686 JRE;JRE 元数据和归档均使用仅允许 HTTPS/TLS 1.2 及以上的受限下载器;由于 MySQL 8.4 无对应服务端镜像,要求预置外部 MySQL 8.4.x,curl 安装只开启 `/setup` 向导,数据库密码在浏览器中提交并完成连接、版本及 DDL/DML 预检 |
|
||||
| 更新可靠性 | 下载和安装拆为两个持久动作;下载阶段不停止业务服务,安装阶段只接受同版本 `READY` 缓存并重新验签。公共 Gitea 默认不使用 Token;如改接私有镜像,Token 仅从权限为 `0600` 的 root 配置/临时文件读取,不进入 `curl` 参数、页面、状态或日志,且只允许同源 API/资产使用;请求原子领取到持久 `processing`,systemd 限制失败重试;`mysqldump`、新旧版本健康和回滚均有独立门禁,跨来源拒绝、成功、健康回滚、下载失败、备份失败和不安全请求夹具纳入 CI |
|
||||
| Linux 32 位 | i386/i486/i586/i686 下载 Java 17 i686 JRE;JRE 元数据和归档均使用仅允许 HTTPS/TLS 1.2 及以上的受限下载器;由于 MySQL 8.4 无对应服务端镜像,要求预置外部 MySQL 8.4.x,curl 安装只开启 `/setup` 向导,数据库密码在浏览器中提交;安装器不安装 MySQL、不运行 MySQL 客户端,点击完成安装后由应用在专用 schema 中执行迁移 |
|
||||
| 更新可靠性 | 下载和安装拆为两个持久动作;下载阶段不停止业务服务,安装阶段只接受同版本 `READY` 缓存并重新验签。公共 Gitea 默认不使用 Token;如改接私有镜像,Token 仅从权限为 `0600` 的 root 配置/临时文件读取,不进入 `curl` 参数、页面、状态或日志,且只允许同源 API/资产使用;请求原子领取到持久 `processing`,systemd 限制失败重试;数据库备份默认为 `skip`,只有显式配置 `KAIDI_DB_BACKUP_MODE=mysqldump` 才调用已有工具;新旧版本健康和回滚均有独立门禁,跨来源拒绝、成功、健康回滚、下载失败、备份失败和不安全请求夹具纳入 CI |
|
||||
| 阶段口径 | 把“开发前冻结”更新为“R1 开发中”;明确模块级验收与整套 R1 交付是两个层级,避免一页完成后虚报整套系统完成 |
|
||||
| PAGE-13 页面 | 完成合同执行汇总、筛选、服务端分页、来源下钻、CSV 导出、合同详情及变更/结算/应付操作;页面仅使用现有 TDesign Starter 和 TDesign 组件 |
|
||||
| PAGE-13 后端 | 新增 `contractcost:payable:create`,项目经理可登记但不可确认;同人登记/确认返回 `422 SOD_VIOLATION`;应付必须关联已批准 OA-04,结算必须关联已批准 OA-11,同一来源不得重复消费 |
|
||||
@@ -637,7 +637,7 @@ OA 导入路线不重新判断原 OA 的业务审批结论,但财务金额、
|
||||
|
||||
### 7.3 Starter 菜单、路由与页面映射
|
||||
|
||||
登录后的整体框架直接使用 TDesign Starter 的左侧菜单、顶部栏、面包屑和内容区。菜单名称、顺序和路由在第一轮固定如下;详情、编辑等隐藏路由不出现在左侧菜单,但必须保留面包屑和返回路径。
|
||||
登录后的整体框架直接使用 TDesign Starter 的左侧菜单、顶部栏、页签和内容区。菜单层级已经表达业务归属,首期不在内容区显示面包屑,避免与菜单和页签重复;详情、编辑等隐藏路由必须保留安全返回路径。
|
||||
|
||||
| 一级菜单/入口 | 二级菜单 | 页面 | 路由 | 默认可见身份 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
@@ -664,7 +664,7 @@ OA 导入路线不重新判断原 OA 的业务审批结论,但财务金额、
|
||||
| 系统治理 | 权限与配置 | PAGE-21 | `/governance/settings` | 系统管理员 |
|
||||
| 系统治理 | 系统更新 | 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` 并写审计日志。
|
||||
|
||||
@@ -695,7 +695,7 @@ OA 导入路线不重新判断原 OA 的业务审批结论,但财务金额、
|
||||
| PAGE-19 | 查询与报表页 | 三类角色按权限 | 项目台账、合同、收付款、流程、附件完整性和导出 |
|
||||
| PAGE-20 | 审计日志页 | 审计/授权管理员 | 查询登录、查看、修改、审批、导入、导出和结果登记记录 |
|
||||
| PAGE-21 | 权限与配置页 | 系统管理员 | 用户、角色、数据范围、模板和参数版本 |
|
||||
| PAGE-22 | 系统更新页 | 系统管理员 | 当前/最新版本、更新包获取、立即重启和历史更新记录 |
|
||||
| PAGE-22 | 系统更新页 | 系统管理员 | 当前/最新版本、显式下载、下载完成确认、安装重启和历史更新记录 |
|
||||
|
||||
### 8.2 PAGE-01 登录页
|
||||
|
||||
@@ -1002,9 +1002,9 @@ PAGE-21 只向超级管理员开放。超级管理员账号不得混授普通业
|
||||
|
||||
### 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 校验、可验证数据库备份、持久事务、应用与运维文件原子切换、新旧版本健康检查和失败回滚。
|
||||
|
||||
@@ -1045,7 +1045,7 @@ PAGE-21 只向超级管理员开放。超级管理员账号不得混授普通业
|
||||
| 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-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-20 | 默认最近 24 小时授权范围内审计记录 | 按用户/对象/动作/请求编号查询 → 查看前后值或导出 | 可用请求编号还原完整动作链;前后值按敏感规则脱敏 |
|
||||
| PAGE-21 | 默认用户管理页签,只向系统管理员开放 | 编辑草稿 → 校验冲突 → 保存/发布新配置版本 | 新版本有生效时间和发布人;权限变更使受影响旧会话立即失效 |
|
||||
| PAGE-22 | 当前版本、最新版本、更新状态和最近更新记录 | 获取更新 → 自动下载验签 → 立即重启 → 等待健康检查 | 健康检查成功后倒计时 10 秒自动刷新;失败状态和审计历史可查询 |
|
||||
| PAGE-22 | 当前版本、最新版本、更新状态和最近更新记录 | 获取版本 → 立即更新并下载验签 → 下载完成 → 立即更新并重启 → 等待健康检查 | 健康检查成功后倒计时 10 秒自动刷新;失败状态和审计历史可查询 |
|
||||
|
||||
所有列表默认每页 20 条;默认排序已在上表写明,未单独写明时使用 `updatedAt,desc`。无功能权限的动作隐藏;有功能权限但当前状态、资料或岗位互斥不允许的动作禁用并说明原因。任何写操作成功后都必须重新读取服务端对象和允许动作,不能只修改前端状态;失败时保留尚未提交成功的用户输入。
|
||||
|
||||
@@ -1109,7 +1109,7 @@ PAGE-21 只向超级管理员开放。超级管理员账号不得混授普通业
|
||||
| PAGE-19 查询与报表 | `reportCode` 加该报表冻结的公司/项目/对象/状态/日期条件;默认当前月 | 每个报表只开放其列代码,最终补业务编号 | 六类报表列以 D-08 为准;汇总、下钻、导出必须使用同一 filter hash 和口径版本 |
|
||||
| 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-22 系统更新 | 固定读取当前状态与 `objectType=SYSTEM_UPDATE` 历史 | 历史按 `occurredAt,desc` | 当前/最新版本、状态、发布说明、操作时间、目标版本、动作、操作人和结果;获取更新、立即重启 |
|
||||
| PAGE-22 系统更新 | 固定读取当前状态与 `objectType=SYSTEM_UPDATE` 历史 | 历史按 `occurredAt,desc` | 当前/最新版本、状态、发布说明、操作时间、目标版本、动作、操作人和结果;获取版本、立即更新、立即更新并重启 |
|
||||
|
||||
列代码、筛选参数、排序字段和导出字段在 OpenAPI 中逐项枚举,前端不得把任意对象属性透传为查询字段。金额汇总由后端在同一筛选下返回,前端只格式化显示;当前页合计与全量合计必须使用不同标签。每个工作台快捷入口都要有 Playwright 断言,证明目标路由、初始筛选、返回恢复和数据数量一致。
|
||||
|
||||
@@ -1625,12 +1625,12 @@ Pull Request 只有同时满足以下条件才能合并:页面/操作合同完
|
||||
| 区域 | 固定规则 |
|
||||
| --- | --- |
|
||||
| 登录/身份选择 | PAGE-01、PAGE-02 使用 Starter `BlankLayout`;保留 TDesign 登录视觉结构,使用甲方本地品牌素材,不显示业务侧栏 |
|
||||
| 登录后框架 | PAGE-03~PAGE-22 固定使用 Starter `side` 布局:左侧菜单、顶部栏、面包屑、页签栏和内容区 |
|
||||
| 登录后框架 | PAGE-03~PAGE-22 固定使用 Starter `side` 布局:左侧菜单、顶部栏、页签栏和内容区;不渲染面包屑 |
|
||||
| 左侧菜单 | 使用 7.3 节固定层级和顺序;支持 Starter 默认展开/收起;一级菜单图标来自 TDesign Icons,名称不得由开发人员自行缩写 |
|
||||
| 顶部栏 | 显示当前身份、身份切换、系统通知和用户菜单;删除代码仓库、外部帮助、语言切换、主题切换和布局设置入口 |
|
||||
| 面包屑 | 以 `routes.json` 页面合同为唯一层级来源,从一级菜单到当前页面完整显示;隐藏详情路由必须显示来源列表和当前页面,任一级可安全返回 |
|
||||
| 面包屑 | 首期删除可见面包屑;菜单和页签承担层级识别,隐藏详情路由通过显式返回按钮或安全返回路径返回来源列表 |
|
||||
| 页签栏 | 保留 Starter 路由页签;工作台固定不可关闭,详情页签显示“页面名 + 业务编号”;切换身份时清空旧身份页签和缓存 |
|
||||
| 内容头 | 静态列表、工作台、台账和配置页只显示顶部面包屑,不再在内容区重复模块名、页面名或功能说明;保留读屏可识别的隐藏 `h1`,刷新、新建、导入、导出等动作进入右侧紧凑工具栏。项目详情、表单详情等动态页面可以显示业务编号、对象名称和状态,但不得重复面包屑中的静态标题 |
|
||||
| 内容头 | 静态列表、工作台、台账和配置页直接进入紧凑操作栏、查询区或指标区,不显示面包屑,也不重复堆叠模块名、页面名和功能说明;保留读屏可识别的隐藏 `h1`,刷新、新建、导入、导出等动作进入右侧紧凑工具栏。项目详情、表单详情等动态页面显示业务编号、对象名称和状态,并提供明确返回路径 |
|
||||
| 页面滚动 | 页面主体纵向滚动;宽表格只在表格区域横向滚动;弹窗、抽屉和固定操作栏不得覆盖页面标题或分页 |
|
||||
|
||||
主题固定为 TDesign 浅色模式和已确认的品牌主色;首期不交付深色模式、多语言、顶部导航、混合导航或用户自定义主题。允许本地保存的只有菜单收起、非敏感表格列显示等界面偏好;身份、权限、业务记录、查询结果和敏感字段不得持久化到浏览器。
|
||||
@@ -1643,12 +1643,12 @@ PAGE-01~PAGE-22 必须从以下模板组合,不允许每个开发人员各
|
||||
| --- | --- | --- | --- |
|
||||
| 公共入口 | PAGE-01~02 | 本地品牌区 + 登录表单或三张身份卡 + 明确错误反馈 | `Form`、`Input`、`Button`、`Alert`、`Loading` |
|
||||
| 工作台 | 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-10、PAGE-12 的编辑状态 | 分组表单 + 动态明细 + 附件 + 校验结果 + 底部固定操作栏 | `Form`、`Row`、`Col`、`Input`/`MoneyInput`、仅整数 `InputNumber`、`Select`、`DatePicker`、`Upload`、`Alert` |
|
||||
| 审批处理 | PAGE-11、PAGE-17 | 待办列表 + 业务摘要 + 附件/校验 + 审批时间线 + 当前节点动作 | `Table`、`Descriptions`、`Steps`、`Timeline`、`Dialog`、`Textarea` |
|
||||
| 配置管理 | 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` |
|
||||
|
||||
工作台不是营销首页,不使用大幅 Hero、插画横幅或仅作装饰的图表。图表必须回答一个业务问题,并提供同口径数字和可下钻明细;无有效业务含义时使用表格。
|
||||
@@ -2200,9 +2200,9 @@ staging/production 使用 Linux 容器或等价受控服务:反向代理仅对
|
||||
|
||||
运行日志在线保留 180 天;审计、导出和敏感访问日志首期按 10 年保留且不自动删除,最终保管期限由 D-09 确认。归档或到期处理必须由授权管理员显式执行、先导出校验并记录审批和哈希;处于项目档案、争议或审计冻结的记录不得处理。
|
||||
|
||||
第一版 Preview 的固定交付路径为私有 Gitea `ERP-Team/kaidi` 的 Git tag → Gitea Actions 全量门禁 → RSA 签名 Release → Linux 安装器。CI 先创建 draft,上传并核对恰好 9 个资产后再发布,生产服务器只读 Release API/资产,不执行 `git pull` 或现场编译。64 位 Linux 可由安装器创建 MySQL 8.4 容器;i386/i486/i586/i686 必须先准备外部 MySQL 8.4.x、数据库和具备本库 DDL/DML 权限的账号。两类主机均只运行一组经安装器 SHA-256、固定公钥指纹和只读 Gitea Token 保护的 curl 命令,具体可复制命令、占位符、建库、健康检查、停用和排障命令以仓库根 `README.md` 为准。
|
||||
第一版 Preview 的固定交付路径为私有 Gitea `ERP-Team/kaidi` 的 Git tag → Gitea Actions 全量门禁 → RSA 签名 Release → Linux 安装器。CI 先创建 draft,上传并核对签名资产后再发布,生产服务器只读 Release API/资产,不执行 `git pull` 或现场编译。所有架构均必须先准备外部 MySQL 8.4.x、专用空 schema 和具备本库迁移所需权限的账号;安装器不创建 MySQL 容器、不安装数据库、不调用 MySQL 客户端,也不执行 DDL/DML。点击首次向导的完成安装后,应用才在用户指定 schema 中运行 Flyway 并初始化管理员。在线更新默认不访问数据库;仅在显式设置 `KAIDI_DB_BACKUP_MODE=mysqldump` 时调用宿主机已有工具生成备份,不调用 Docker 或管理数据库服务。两类主机均只运行经安装器 SHA-256、固定公钥指纹保护的 curl 命令,具体可复制命令、健康检查、停用和排障命令以仓库根 `README.md` 为准。
|
||||
|
||||
在线更新是部署运维能力,不是 OA、银行、税务或财务业务第三方集成。它只允许访问 root 配置中固定的 HTTPS Gitea Release API 和同源资产,页面不能改变地址;生产可按网络策略禁用。下载缓存、请求领取、运维备份和事务阶段保存在 `/var/lib/kaidi-update`,主机重启后先恢复未提交事务;安装前必须确认 `mysqldump` 成功并生成 root-only 压缩备份。签名包内的应用、updater、systemd 单元和 Nginx 配置以临时文件加原子移动切换;配置、新版本直连、Nginx 健康、更新 path unit 和静态首页任一失败时恢复旧应用和旧运维文件,并再次验证旧版本健康。数据库迁移继续遵守至少一个版本向后兼容,数据库恢复须按备份恢复演练单独执行。
|
||||
在线更新是部署运维能力,不是 OA、银行、税务或财务业务第三方集成。它只允许访问 root 配置中固定的 HTTPS Gitea Release API 和同源资产,页面不能改变地址;生产可按网络策略禁用。下载缓存、请求领取、运维备份和事务阶段保存在 `/var/lib/kaidi-update`,主机重启后先恢复未提交事务;数据库备份默认为 `skip`,只有显式配置 `KAIDI_DB_BACKUP_MODE=mysqldump` 才生成 root-only 压缩备份。签名包内的应用、updater、systemd 单元和 Nginx 配置以临时文件加原子移动切换;配置、新版本直连、Nginx 健康、更新 path unit 和静态首页任一失败时恢复旧应用和旧运维文件,并再次验证旧版本健康。数据库迁移继续遵守至少一个版本向后兼容,若未启用备份则由运维在更新前自行完成外部备份。
|
||||
|
||||
### 11.15 全栈工程硬规范
|
||||
|
||||
@@ -2891,7 +2891,7 @@ PAGE-16 全类回归曾暴露测试证据文件路径硬编码与应用 `finance
|
||||
| 合同与供应链 | OpenAPI、Actionlint、ShellCheck 通过;npm 完整依赖树 `0 vulnerabilities` | CI 仍在 tag 发布时从干净环境重新执行全部门禁 |
|
||||
| 更新可靠性 | 成功切换、健康失败回滚、Release 下载失败、`mysqldump` 失败和不安全请求拒绝五类夹具通过 | 覆盖持久 processing/transaction、普通文件门禁、有效失败状态、0600 备份、应用、updater、systemd、Nginx、新旧健康验证和失败 Release 清理;真实 Linux systemd 主机仍须冒烟 |
|
||||
| Release 制品 | `1.0.0-preview.1` 应用 tar、RSA 3072 签名 manifest、公钥、SHA-256 清单、安装器、前后端 CycloneDX SBOM 统一生成并互相绑定;签名清单同时绑定应用、SBOM、安装器、公钥、bootstrap 摘要和源码修订,Maven、npm、JAR、SBOM 与 manifest 严格同版本,tag CI 强制干净来源 | 应用包 SHA-256=`45c44070b4b0857f202ea5767441f684bdda28095276e0ea1c75cd31dcddf39b`,安装器 SHA-256=`bf50b8ca0fcd1aebf2c05f0d80fa362a482f46ecf6e07f7cf5aa5d05d6370c21`,公钥 SHA-256=`807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9`;当前本地候选清单明确为 `source.ref=local`、`source.dirty=true`,实际 Git Release 仍待目标仓库 |
|
||||
| Linux 32 位 | 安装器 fixture 验证 i386/i486/i586/i686 均选择 Java 17 i686 JRE;外部 MySQL 8.4.x 先做连接、版本、DDL/DML 预检 | 属于“外部数据库已预置后的单命令安装”,不是 32 位主机内置 MySQL;正式 i686 systemd 主机仍须实装 |
|
||||
| Linux 32 位 | 安装器 fixture 验证 i386/i486/i586/i686 均选择 Java 17 i686 JRE;安装阶段只验证 JDBC 配置格式,数据库连接和迁移在首次向导完成安装时执行 | 属于“外部数据库已预置后的单命令安装”,不是 32 位主机内置 MySQL;正式 i686 systemd 主机仍须实装 |
|
||||
|
||||
**Preview 放行结论**:代码和本地签名制品满足第一版 Preview 候选条件,P0 代码阻断为零。实际对外 Release 只剩目标 GitHub 仓库、Release URL、CI 签名 Secret、公钥指纹 Variable 和服务器地址等部署输入;这些输入未配置前,不把本地候选包描述成已经公网部署。
|
||||
|
||||
@@ -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 发布权限分离 |
|
||||
| 两阶段交互 | 管理员点击“获取更新”后自动完成检查和下载;下载、RSA 验签、SHA-256 和压缩包检查完成后进入 `READY`,业务服务在下载阶段不停机;只有 `READY` 状态才能显示“立即重启”并提交安装 | 检查、下载、安装的忙碌状态互斥,防止重复排队;未经“立即重启”确认不切换版本 |
|
||||
| 分阶段交互 | 管理员点击“获取版本”只检查发布信息;发现新版本后点击“立即更新”才下载。下载、RSA 验签、SHA-256 和压缩包检查完成后进入 `READY/下载完成`,业务服务在下载阶段不停机;只有 `READY` 状态才能显示“立即更新并重启”并提交安装 | 检查、下载、安装的忙碌状态互斥,防止重复排队;未经“立即更新并重启”确认不切换版本 |
|
||||
| 安装与页面恢复 | 安装阶段重新验签、备份、原子切换并执行后端直连、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` |
|
||||
| 接口与自动化 | 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 | 签名包内 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` 为未登录公共页 |
|
||||
| 数据库配置 | 已实现 | 首版只开放 MySQL 8.4.x;向导先执行版本和临时表 CREATE/INSERT/UPDATE/DROP 权限验证,再执行 72 个 Flyway 迁移;非空且无 Flyway 历史的数据库被拒绝 |
|
||||
| 数据库配置 | 已实现 | 首版只开放 MySQL 8.4.x;向导的“测试连接”只读取版本,不修改数据库;管理员明确点击“完成安装”后才执行迁移权限检查、字符集准备、Flyway 迁移和管理员初始化;非空或已有不一致迁移历史的数据库会在完成阶段给出可定位错误 |
|
||||
| 管理员初始化 | 已实现 | 操作者填写账号、显示名称和密码;创建 `SYSTEM_ADMIN`、逐项全局权限范围,并用 `kaidi_setup_installation` 单例状态支持提交后重试,避免重复创建超级管理员 |
|
||||
| 锁定与恢复 | 已实现 | 一次性安装码只保存 SHA-256;完成后原子写运行时环境和锁定标记,systemd 重启进入正式模式;向导未完成时可用 `REINSTALL=true + KAIDI_SETUP_WIZARD=true` 恢复并重新生成安装码,已锁定/正式模式拒绝该路径 |
|
||||
| 32 位 curl | 已实现 | i386/i486/i586/i686 只下载 i686 Java 17 JRE,不传数据库密码;管理员预建 MySQL 8.4 数据库后在 `/setup` 输入连接信息 |
|
||||
@@ -3005,6 +3005,17 @@ PAGE-16 全类回归曾暴露测试证据文件路径硬编码与应用 `finance
|
||||
|
||||
**本轮结论**: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 第三方权限和自动化负向验收
|
||||
|
||||
**当前状态**:`PENDING_EVIDENCE`。以下五项是发布前必须执行并归档的负向验收,不代表当前已经完成;证据包须记录代码/依赖/配置扫描、断网运行、人工登记审计断言、执行时间、版本和可复查结果。
|
||||
|
||||
@@ -272,7 +272,7 @@
|
||||
"pageType": "business",
|
||||
"pageTemplate": "system-update",
|
||||
"requiresAuth": true,
|
||||
"description": "查看当前版本、获取签名更新包、立即重启并查询历史更新记录。",
|
||||
"description": "查看当前版本、独立获取版本信息、显式下载签名更新包、下载完成后安装重启并查询历史更新记录。",
|
||||
"breadcrumb": ["系统治理", "系统更新"],
|
||||
"roles": ["SYSTEM_ADMIN"],
|
||||
"permission": "admin:update:view"
|
||||
|
||||
@@ -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:not(.page-title-sr-only)')).toHaveCount(0);
|
||||
}
|
||||
if (!['PAGE-01', 'PAGE-02', 'PAGE-23'].includes(route.pageId)) {
|
||||
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 expect(page.locator('.tdesign-breadcrumb')).toHaveCount(0);
|
||||
await page.waitForLoadState('networkidle');
|
||||
if (route.pageId === 'PAGE-09') {
|
||||
await expect(page.locator('.t-input-number__decrease, .t-input-number__increase')).toHaveCount(0);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test('release build boots the setup wizard without runtime errors', async ({ page }) => {
|
||||
const pageErrors: string[] = [];
|
||||
const consoleErrors: string[] = [];
|
||||
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message));
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') consoleErrors.push(message.text());
|
||||
});
|
||||
await page.route('**/api/v1/setup/status', async (route) => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: {
|
||||
required: true,
|
||||
locked: false,
|
||||
supportedDatabaseTypes: ['MYSQL'],
|
||||
message: '请完成安装',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const response = await page.goto('/setup');
|
||||
expect(response?.ok()).toBe(true);
|
||||
await expect(page.getByRole('heading', { name: '安装向导', exact: true })).toBeVisible();
|
||||
expect(pageErrors).toEqual([]);
|
||||
expect(consoleErrors).toEqual([]);
|
||||
});
|
||||
@@ -11,6 +11,9 @@ test('first-run wizard tests MySQL, creates the administrator and stays responsi
|
||||
data: {
|
||||
required: !completed,
|
||||
locked: completed,
|
||||
// Production status is locked after setup; readiness is not the
|
||||
// completion signal used by the redirect flow.
|
||||
ready: false,
|
||||
supportedDatabaseTypes: ['MYSQL'],
|
||||
message: completed ? '系统已完成安装' : '请完成安装',
|
||||
},
|
||||
@@ -29,8 +32,8 @@ test('first-run wizard tests MySQL, creates the administrator and stays responsi
|
||||
successful: true,
|
||||
databaseType: 'MYSQL',
|
||||
serverVersion: '8.4.6',
|
||||
schemaReady: true,
|
||||
message: 'MySQL 8.4 连接和 DDL/DML 权限验证通过',
|
||||
schemaReady: false,
|
||||
message: 'MySQL 8.4 只读连接验证通过;尚未修改数据库,点击完成安装后才会执行迁移',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -58,7 +61,11 @@ test('first-run wizard tests MySQL, creates the administrator and stays responsi
|
||||
await page.getByLabel('数据库密码').fill('fixture-db-password');
|
||||
await page.getByLabel('安装码').fill('fixture-setup-code');
|
||||
await page.getByRole('button', { name: '测试连接' }).click();
|
||||
await expect(page.getByText('MySQL 8.4 连接和 DDL/DML 权限验证通过')).toBeVisible();
|
||||
await expect(
|
||||
page.locator('.t-alert__description').filter({
|
||||
hasText: 'MySQL 8.4 只读连接验证通过;尚未修改数据库,点击完成安装后才会执行迁移',
|
||||
}),
|
||||
).toBeVisible();
|
||||
await page.getByRole('button', { name: '下一步' }).click();
|
||||
|
||||
await page.getByLabel('管理员密码').fill('SetupAdmin@2026Strong');
|
||||
@@ -68,9 +75,135 @@ test('first-run wizard tests MySQL, creates the administrator and stays responsi
|
||||
await expect(page.getByText('安装完成', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('安装完成,系统正在切换到正式模式')).toBeVisible();
|
||||
|
||||
await expect(page).toHaveURL(/\/login$/, { timeout: 15_000 });
|
||||
|
||||
const layout = await page.evaluate(() => ({
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
}));
|
||||
expect(layout.scrollWidth).toBeLessThanOrEqual(layout.clientWidth);
|
||||
});
|
||||
|
||||
test('confirms completion after the setup response is interrupted by a service restart', async ({ page }) => {
|
||||
let completionStarted = false;
|
||||
let recoveryChecks = 0;
|
||||
await page.route('**/api/v1/setup/**', async (route) => {
|
||||
const request = route.request();
|
||||
const pathname = new URL(request.url()).pathname;
|
||||
if (pathname === '/api/v1/setup/status') {
|
||||
if (completionStarted) recoveryChecks += 1;
|
||||
const ready = completionStarted && recoveryChecks >= 2;
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: {
|
||||
required: !completionStarted,
|
||||
locked: completionStarted,
|
||||
ready,
|
||||
supportedDatabaseTypes: ['MYSQL'],
|
||||
message: ready ? '系统已完成安装' : '请完成安装',
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
const body = request.postDataJSON() as Record<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.abort('connectionreset');
|
||||
});
|
||||
|
||||
await page.goto('/setup');
|
||||
await page.getByLabel('数据库密码').fill('fixture-db-password');
|
||||
await page.getByLabel('安装码').fill('fixture-setup-code');
|
||||
await page.getByRole('button', { name: '测试连接' }).click();
|
||||
await page.getByRole('button', { name: '下一步' }).click();
|
||||
await page.getByLabel('管理员密码').fill('SetupAdmin@2026Strong');
|
||||
await page.getByLabel('确认密码').fill('SetupAdmin@2026Strong');
|
||||
await page.getByRole('button', { name: '完成安装' }).click();
|
||||
await page.getByRole('button', { name: '确定', exact: true }).click();
|
||||
|
||||
await expect(page.getByText('安装完成', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('安装已完成,系统正在切换到正式模式')).toBeVisible();
|
||||
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 });
|
||||
});
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import type { Page, Route } 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) {
|
||||
return { data, requestId: '01M00000000000000000000090' };
|
||||
}
|
||||
|
||||
function session() {
|
||||
function session(canExecute = true) {
|
||||
return {
|
||||
authenticated: true,
|
||||
user: {
|
||||
@@ -19,14 +25,16 @@ function session() {
|
||||
},
|
||||
roles: [{ code: 'SYSTEM_ADMIN', name: '系统管理员', workbenchRoute: '/governance/settings' }],
|
||||
activeRole: 'SYSTEM_ADMIN',
|
||||
permissions,
|
||||
permissions: canExecute ? permissions : permissions.filter((permission) => permission !== 'admin:update:execute'),
|
||||
};
|
||||
}
|
||||
|
||||
function updateView(state: string, checked: boolean, enabled = true, recoveryPending = false) {
|
||||
const busy = [
|
||||
'QUEUED',
|
||||
'DOWNLOAD_QUEUED',
|
||||
'INSTALL_QUEUED',
|
||||
'PRECHECKING',
|
||||
'VERIFYING',
|
||||
'DOWNLOADING',
|
||||
'BACKING_UP',
|
||||
@@ -58,6 +66,14 @@ function updateView(state: string, checked: boolean, enabled = true, recoveryPen
|
||||
publishedAt: checked ? '2026-08-16T00:00:00Z' : null,
|
||||
checkedAt: checked ? '2026-08-16T00:01:00Z' : 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:
|
||||
!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(
|
||||
page: Page,
|
||||
downloadBodies: unknown[],
|
||||
@@ -78,11 +103,27 @@ async function installFixture(
|
||||
completeAfterInstall = false,
|
||||
initialState?: string,
|
||||
completeBusyAfterFirstRead = false,
|
||||
options: FixtureOptions = {},
|
||||
) {
|
||||
const {
|
||||
canExecute = true,
|
||||
checkResponseState,
|
||||
downloadProgressReads = 0,
|
||||
failDownloadResponse = false,
|
||||
failInstallResponse = false,
|
||||
restartOfflineReads = 0,
|
||||
} = options;
|
||||
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 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({
|
||||
publicId: `01M00000000000000000000${String(history.length + 1).padStart(3, '0')}`,
|
||||
eventSequence: history.length + 1,
|
||||
@@ -93,9 +134,17 @@ async function installFixture(
|
||||
objectType: 'SYSTEM_UPDATE',
|
||||
objectPublicId: 'SYSTEM_UPDATE',
|
||||
resultCode: 'SUCCESS',
|
||||
reason: actionCode === 'SYSTEM_UPDATE_REQUEST' ? '管理员确认立即重启' : null,
|
||||
reason,
|
||||
beforeJson: null,
|
||||
afterJson: JSON.stringify({ targetVersion }),
|
||||
afterJson: JSON.stringify({
|
||||
targetVersion,
|
||||
...(actionCode === 'SYSTEM_UPDATE_CHECK'
|
||||
? { releaseNotes: 'Preview update', publishedAt: '2026-08-16T00:00:00Z' }
|
||||
: {}),
|
||||
...(actionCode === 'SYSTEM_UPDATE_SUCCEEDED'
|
||||
? { state: 'SUCCEEDED', releaseNotes: 'Preview update', publishedAt: '2026-08-16T00:00:00Z' }
|
||||
: {}),
|
||||
}),
|
||||
occurredAt: '2026-08-16T00:02:00Z',
|
||||
allowedActions: [],
|
||||
});
|
||||
@@ -103,9 +152,9 @@ async function installFixture(
|
||||
await page.route('**/api/v1/**', async (route: Route) => {
|
||||
const request = route.request();
|
||||
const pathname = new URL(request.url()).pathname;
|
||||
if (pathname === '/api/v1/auth/session') return route.fulfill({ json: envelope(session()) });
|
||||
if (pathname === '/api/v1/auth/profile') return route.fulfill({ json: envelope(session().user) });
|
||||
if (pathname === '/api/v1/auth/roles') return route.fulfill({ json: envelope(session().roles) });
|
||||
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(canExecute).user) });
|
||||
if (pathname === '/api/v1/auth/roles') return route.fulfill({ json: envelope(session(canExecute).roles) });
|
||||
if (pathname === '/api/v1/auth/csrf') {
|
||||
return route.fulfill({
|
||||
headers: { 'set-cookie': 'XSRF-TOKEN=update-csrf; Path=/; SameSite=Lax' },
|
||||
@@ -122,29 +171,54 @@ async function installFixture(
|
||||
});
|
||||
}
|
||||
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);
|
||||
if (state === 'DOWNLOADING' && Date.now() >= progressDeadline) state = 'READY';
|
||||
if (completeBusyAfterFirstRead && state === 'INSTALLING') state = 'SUCCEEDED';
|
||||
return route.fulfill({ json: envelope(response) });
|
||||
}
|
||||
if (pathname === '/api/v1/admin/system-update/check' && request.method() === 'POST') {
|
||||
checked = true;
|
||||
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') {
|
||||
downloadBodies.push(request.postDataJSON());
|
||||
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)) });
|
||||
}
|
||||
if (pathname === '/api/v1/admin/system-update/install' && request.method() === 'POST') {
|
||||
installBodies.push(request.postDataJSON());
|
||||
recordHistory('SYSTEM_UPDATE_REQUEST');
|
||||
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) });
|
||||
}
|
||||
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');
|
||||
expect(query.get('size')).toBe('100');
|
||||
return route.fulfill({
|
||||
json: {
|
||||
data: history,
|
||||
@@ -158,7 +232,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 installBodies: unknown[] = [];
|
||||
await installFixture(page, downloadBodies, installBodies);
|
||||
@@ -166,16 +240,152 @@ 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('.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.getByRole('button', { name: '立即重启', exact: true })).toBeVisible({ timeout: 5_000 });
|
||||
expect(downloadBodies).toEqual([{ version: '1.0.0-preview.2' }]);
|
||||
await expect(page.getByText('下载更新包', { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '立即更新', exact: true })).toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.getByRole('button', { name: '立即更新并重启', exact: true })).toHaveCount(0);
|
||||
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();
|
||||
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 }) => {
|
||||
await installFixture(page, [], []);
|
||||
await page.goto('/governance/update');
|
||||
|
||||
await page.locator('.brand-logo').click();
|
||||
|
||||
await expect(page).toHaveURL(/\/governance\/settings$/);
|
||||
await expect(page.getByText('选择工作身份', { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('disabled online updates expose status without an executable check action', async ({ page }) => {
|
||||
@@ -183,8 +393,9 @@ test('disabled online updates expose status without an executable check action',
|
||||
await page.goto('/governance/update');
|
||||
|
||||
await expect(page.getByText('在线更新未配置', { exact: true })).toBeVisible();
|
||||
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 })).toBeDisabled();
|
||||
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 }) => {
|
||||
@@ -192,26 +403,47 @@ test('rollback recovery keeps the failure visible without an install action', as
|
||||
await page.goto('/governance/update');
|
||||
|
||||
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 }) => {
|
||||
await installFixture(page, [], [], true, false, true);
|
||||
await page.goto('/governance/update');
|
||||
|
||||
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();
|
||||
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({
|
||||
timeout: 5_000,
|
||||
});
|
||||
const historyPanel = page.locator('.history-panel');
|
||||
const timelineItem = historyPanel.locator('.update-timeline__item[data-version="1.0.0-preview.2"]');
|
||||
await expect(timelineItem).toHaveCount(1);
|
||||
await expect(timelineItem).toContainText('成功');
|
||||
await expect(timelineItem).toContainText('新版本已通过健康检查');
|
||||
await expect(timelineItem).toContainText('Preview update');
|
||||
await timelineItem.getByRole('button', { name: '查看更新日志', exact: true }).click();
|
||||
const historyDialog = page.getByRole('dialog', { name: '更新日志详情' });
|
||||
await expect(historyDialog).toContainText('更新日志');
|
||||
await expect(historyDialog).toContainText('Preview update');
|
||||
await expect(historyDialog).toContainText('发布时间');
|
||||
await expect(historyDialog.locator('.history-detail__steps')).toHaveCount(0);
|
||||
await expect(historyDialog).not.toContainText('获取版本');
|
||||
await expect(historyDialog).not.toContainText('下载更新包');
|
||||
await expect(historyDialog).not.toContainText('立即更新并重启');
|
||||
});
|
||||
|
||||
test('reloading during installation resumes polling and starts the refresh countdown', async ({ page }) => {
|
||||
await installFixture(page, [], [], true, false, false, 'INSTALLING', true);
|
||||
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({
|
||||
timeout: 6_000,
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"site:preview": "npm run build && cp -r dist _site",
|
||||
"test": "vitest run",
|
||||
"test:e2e": "playwright test",
|
||||
"test:dist": "playwright test --config playwright.dist.config.ts",
|
||||
"test:a11y": "playwright test e2e/accessibility.e2e.ts",
|
||||
"audit:dependencies": "npm audit --audit-level=high --registry=https://registry.npmjs.org",
|
||||
"check:production": "node scripts/check-production-hygiene.mjs",
|
||||
|
||||
@@ -6,6 +6,7 @@ const baseURL = `http://127.0.0.1:${port}`;
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
testMatch: '**/*.e2e.ts',
|
||||
testIgnore: 'dist-smoke.e2e.ts',
|
||||
outputDir: 'test-results',
|
||||
timeout: 30_000,
|
||||
expect: { timeout: 8_000 },
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
const port = Number(process.env.PLAYWRIGHT_DIST_PORT || 3012);
|
||||
const baseURL = `http://127.0.0.1:${port}`;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
testMatch: 'dist-smoke.e2e.ts',
|
||||
outputDir: 'test-results-dist',
|
||||
timeout: 30_000,
|
||||
expect: { timeout: 8_000 },
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
forbidOnly: Boolean(process.env.CI),
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
reporter: process.env.CI ? [['list'], ['html', { open: 'never', outputFolder: 'playwright-report-dist' }]] : 'list',
|
||||
use: {
|
||||
baseURL,
|
||||
locale: 'zh-CN',
|
||||
timezoneId: 'Asia/Shanghai',
|
||||
screenshot: 'only-on-failure',
|
||||
trace: 'retain-on-failure',
|
||||
},
|
||||
webServer: {
|
||||
command: `npm run preview -- --host 127.0.0.1 --port ${port}`,
|
||||
url: baseURL,
|
||||
reuseExistingServer: false,
|
||||
timeout: 120_000,
|
||||
},
|
||||
projects: [{ name: 'chromium-dist', use: { browserName: 'chromium', viewport: { width: 1366, height: 768 } } }],
|
||||
});
|
||||
@@ -1,8 +1,11 @@
|
||||
import { request } from '@/utils/request';
|
||||
|
||||
const SETUP_COMPLETION_TIMEOUT_MS = 120_000;
|
||||
|
||||
export interface SetupStatus {
|
||||
required: boolean;
|
||||
locked: boolean;
|
||||
ready: boolean;
|
||||
supportedDatabaseTypes: string[];
|
||||
message: string;
|
||||
}
|
||||
@@ -43,6 +46,7 @@ function normalizeStatus(value: SetupStatus): SetupStatus {
|
||||
return {
|
||||
required: Boolean(value?.required),
|
||||
locked: Boolean(value?.locked),
|
||||
ready: Boolean(value?.ready),
|
||||
supportedDatabaseTypes: Array.isArray(value?.supportedDatabaseTypes) ? value.supportedDatabaseTypes : [],
|
||||
message: value?.message || '',
|
||||
};
|
||||
@@ -57,5 +61,7 @@ export function testSetupConnection(data: SetupDatabaseRequest) {
|
||||
}
|
||||
|
||||
export function completeSetup(data: SetupCompleteRequest) {
|
||||
return request.post<SetupCompleted>({ url: '/setup/complete', data }).then((value) => value);
|
||||
return request
|
||||
.post<SetupCompleted>({ url: '/setup/complete', data, timeout: SETUP_COMPLETION_TIMEOUT_MS })
|
||||
.then((value) => value);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,13 @@ import { request } from '@/utils/request';
|
||||
|
||||
import { commandHeaders } from './command';
|
||||
|
||||
export interface SystemUpdateEvent {
|
||||
occurredAt: string;
|
||||
level: 'INFO' | 'WARN' | 'ERROR';
|
||||
stage: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface SystemUpdateView {
|
||||
enabled: boolean;
|
||||
currentVersion: string;
|
||||
@@ -13,6 +20,12 @@ export interface SystemUpdateView {
|
||||
publishedAt?: string | null;
|
||||
checkedAt?: string | null;
|
||||
statusUpdatedAt?: string | null;
|
||||
downloadedBytes?: number | null;
|
||||
totalBytes?: number | null;
|
||||
bytesPerSecond?: number | null;
|
||||
downloadPercent?: number | null;
|
||||
restartExpectedSeconds?: number | null;
|
||||
events: SystemUpdateEvent[];
|
||||
allowedActions: string[];
|
||||
}
|
||||
|
||||
@@ -21,6 +34,7 @@ function normalize(value: SystemUpdateView): SystemUpdateView {
|
||||
...value,
|
||||
enabled: Boolean(value?.enabled),
|
||||
updateAvailable: Boolean(value?.updateAvailable),
|
||||
events: Array.isArray(value?.events) ? value.events : [],
|
||||
allowedActions: Array.isArray(value?.allowedActions) ? value.allowedActions : [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export default {
|
||||
showFooter: false,
|
||||
isSidebarCompact: false,
|
||||
showBreadcrumb: true,
|
||||
menuAutoCollapsed: false,
|
||||
mode: 'light',
|
||||
layout: 'side',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div :class="layoutCls">
|
||||
<t-head-menu :class="menuCls" :theme="menuTheme" expand-type="popup" :value="active">
|
||||
<template #logo>
|
||||
<span v-if="showLogo" class="header-logo-container" @click="handleNav('/role-select')">
|
||||
<span v-if="showLogo" class="header-logo-container" @click="handleNav(user.workbenchRoute)">
|
||||
<span class="header-brand">财务系统</span>
|
||||
</span>
|
||||
<div v-else class="header-operate-left">
|
||||
|
||||
@@ -61,7 +61,6 @@
|
||||
</t-tab-panel>
|
||||
</t-tabs>
|
||||
<t-content :class="`${prefix}-content-layout`">
|
||||
<l-breadcrumb v-if="settingStore.showBreadcrumb" />
|
||||
<l-content />
|
||||
</t-content>
|
||||
<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 type { TRouterInfo, TTabRemoveOptions } from '@/types/interface';
|
||||
|
||||
import LBreadcrumb from './Breadcrumb.vue';
|
||||
import LContent from './Content.vue';
|
||||
import LFooter from './Footer.vue';
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ import { useRouter } from 'vue-router';
|
||||
import { prefix } from '@/config/global';
|
||||
import { t } from '@/locales';
|
||||
import { getActive } from '@/router';
|
||||
import { useSettingStore } from '@/store';
|
||||
import { useSettingStore, useUserStore } from '@/store';
|
||||
import type { MenuRoute, ModeType } from '@/types/interface';
|
||||
|
||||
import pgk from '../../../package.json';
|
||||
@@ -155,6 +155,7 @@ const menuCls = computed(() => {
|
||||
|
||||
const router = useRouter();
|
||||
const settingStore = useSettingStore();
|
||||
const userStore = useUserStore();
|
||||
|
||||
const autoCollapsed = () => {
|
||||
const isCompact = window.innerWidth <= MIN_POINT;
|
||||
@@ -175,7 +176,7 @@ onUnmounted(() => {
|
||||
});
|
||||
|
||||
const goHome = () => {
|
||||
router.push('/role-select');
|
||||
router.push(userStore.workbenchRoute);
|
||||
};
|
||||
</script>
|
||||
<style lang="less" scoped>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -73,16 +73,7 @@ const choose = async (roleCode: string) => {
|
||||
try {
|
||||
await userStore.selectRole(roleCode);
|
||||
permissionStore.initRoutes();
|
||||
const target = userStore.roles.find((role) => role.code === roleCode)?.workbenchRoute;
|
||||
const fallback =
|
||||
roleCode === 'PROJECT_MANAGER'
|
||||
? '/workbench/project'
|
||||
: roleCode === 'ARCHIVE_MANAGER'
|
||||
? '/workbench/archive'
|
||||
: roleCode === 'SYSTEM_ADMIN'
|
||||
? '/governance/settings'
|
||||
: '/workbench/finance';
|
||||
await router.replace(target || fallback);
|
||||
await router.replace(userStore.workbenchRoute);
|
||||
} catch (error) {
|
||||
MessagePlugin.error((error as Error).message || '身份选择失败,请重试');
|
||||
} finally {
|
||||
|
||||
@@ -103,6 +103,10 @@
|
||||
<t-alert v-if="connectionResult" class="connection-result" theme="success" :close-btn="false">
|
||||
{{ connectionResult.message }}({{ connectionResult.serverVersion }})
|
||||
</t-alert>
|
||||
<p class="setup-hint">
|
||||
测试连接只读取 MySQL
|
||||
版本,不创建或修改数据库对象;点击“完成安装”后,系统才会在指定的专用空库执行迁移并创建管理员。
|
||||
</p>
|
||||
|
||||
<div class="setup-actions">
|
||||
<t-button theme="primary" variant="outline" type="submit" :loading="testingConnection">
|
||||
@@ -162,6 +166,10 @@
|
||||
</t-form-item>
|
||||
</div>
|
||||
|
||||
<t-alert v-if="completing" class="setup-progress" theme="info" :close-btn="false">
|
||||
{{ completionProgressMessage }}
|
||||
</t-alert>
|
||||
|
||||
<div class="setup-actions">
|
||||
<t-button variant="outline" :disabled="completing" @click="currentStep = 0">
|
||||
<template #icon><t-icon name="chevron-left" /></template>
|
||||
@@ -223,8 +231,13 @@ const errorMessage = ref('');
|
||||
const connectionResult = ref<SetupConnection | null>(null);
|
||||
const verifiedFingerprint = ref('');
|
||||
const completionMessage = ref('系统正在重启,请稍候。');
|
||||
const completionProgressMessage = ref('正在初始化数据库结构并创建管理员,请稍候。');
|
||||
const countdown = ref(10);
|
||||
let countdownTimer: number | undefined;
|
||||
let unmounted = false;
|
||||
|
||||
const SETUP_CONFIRMATION_TIMEOUT_MS = 60_000;
|
||||
const SETUP_CONFIRMATION_INTERVAL_MS = 2_000;
|
||||
|
||||
const databaseForm = reactive<SetupDatabaseRequest>({
|
||||
setupCode: '',
|
||||
@@ -281,9 +294,7 @@ const adminRules = computed<Record<string, FormRule[]>>(() => ({
|
||||
|
||||
const databaseFingerprint = computed(() => JSON.stringify(databaseForm));
|
||||
const connectionVerified = computed(
|
||||
() =>
|
||||
Boolean(connectionResult.value?.successful && connectionResult.value.schemaReady) &&
|
||||
verifiedFingerprint.value === databaseFingerprint.value,
|
||||
() => Boolean(connectionResult.value?.successful) && verifiedFingerprint.value === databaseFingerprint.value,
|
||||
);
|
||||
|
||||
watch(databaseFingerprint, () => {
|
||||
@@ -299,7 +310,7 @@ async function testConnection(context?: SubmitContext) {
|
||||
const result = await testSetupConnection({ ...databaseForm });
|
||||
connectionResult.value = result;
|
||||
verifiedFingerprint.value = databaseFingerprint.value;
|
||||
MessagePlugin.success('数据库连接验证通过');
|
||||
MessagePlugin.success(result.message || '数据库只读连接验证通过');
|
||||
} catch (error) {
|
||||
connectionResult.value = null;
|
||||
errorMessage.value = (error as Error).message || '数据库连接验证失败';
|
||||
@@ -317,21 +328,51 @@ async function finishSetup(context?: SubmitContext) {
|
||||
return;
|
||||
}
|
||||
completing.value = true;
|
||||
completionProgressMessage.value = '正在初始化数据库结构并创建管理员,请稍候。';
|
||||
errorMessage.value = '';
|
||||
try {
|
||||
const result = await completeSetup({ ...databaseForm, ...adminForm });
|
||||
if (!result.completed) throw new Error('安装服务未确认完成,请稍后重试');
|
||||
completionMessage.value = result.message;
|
||||
countdown.value = Math.max(1, result.restartAfterSeconds || 10);
|
||||
currentStep.value = 2;
|
||||
startCountdown();
|
||||
showCompleted(result.message, result.restartAfterSeconds);
|
||||
} catch (error) {
|
||||
errorMessage.value = (error as Error).message || '安装初始化失败';
|
||||
const requestError = error as Error & { status?: number };
|
||||
if (requestError.status === undefined || requestError.status >= 500) {
|
||||
completionProgressMessage.value = '连接暂时中断,正在确认安装结果。';
|
||||
if (await waitForSetupCompletion()) {
|
||||
showCompleted('安装已完成,系统正在切换到正式模式', 10);
|
||||
return;
|
||||
}
|
||||
errorMessage.value = '安装结果暂未确认,请稍后重新打开安装页面;若问题持续,请检查服务日志。';
|
||||
} else {
|
||||
errorMessage.value = requestError.message || '安装初始化失败';
|
||||
}
|
||||
} finally {
|
||||
completing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForSetupCompletion() {
|
||||
const deadline = Date.now() + SETUP_CONFIRMATION_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
if (unmounted) return false;
|
||||
try {
|
||||
const status = await getSetupStatus();
|
||||
if (!status.required && (status.ready || status.locked)) return true;
|
||||
} catch {
|
||||
// A short connection failure is expected while systemd restarts the service.
|
||||
}
|
||||
await new Promise((resolve) => window.setTimeout(resolve, SETUP_CONFIRMATION_INTERVAL_MS));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function showCompleted(message: string, restartAfterSeconds: number) {
|
||||
completionMessage.value = message;
|
||||
countdown.value = Math.max(1, restartAfterSeconds || 10);
|
||||
currentStep.value = 2;
|
||||
startCountdown();
|
||||
}
|
||||
|
||||
function confirmSetup() {
|
||||
void finishSetup();
|
||||
}
|
||||
@@ -339,14 +380,18 @@ function confirmSetup() {
|
||||
function startCountdown() {
|
||||
window.clearInterval(countdownTimer);
|
||||
countdownTimer = window.setInterval(() => {
|
||||
countdown.value -= 1;
|
||||
if (countdown.value <= 0) openLogin();
|
||||
countdown.value = Math.max(0, countdown.value - 1);
|
||||
if (countdown.value === 0) openLogin();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function openLogin() {
|
||||
window.clearInterval(countdownTimer);
|
||||
window.location.assign('/login');
|
||||
countdown.value = 0;
|
||||
completionMessage.value = '正在进入登录页。';
|
||||
// A full navigation clears the setup-mode SPA state retained across the
|
||||
// service restart. The login route guard performs the final setup check.
|
||||
window.location.replace('/login');
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -364,7 +409,10 @@ onMounted(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => window.clearInterval(countdownTimer));
|
||||
onBeforeUnmount(() => {
|
||||
unmounted = true;
|
||||
window.clearInterval(countdownTimer);
|
||||
});
|
||||
</script>
|
||||
<style scoped>
|
||||
.setup-page {
|
||||
@@ -382,8 +430,7 @@ onBeforeUnmount(() => window.clearInterval(countdownTimer));
|
||||
}
|
||||
|
||||
.setup-panel {
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
place-self: center;
|
||||
width: min(760px, 100%);
|
||||
min-width: 0;
|
||||
padding: 32px;
|
||||
@@ -424,6 +471,13 @@ h1 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.setup-hint {
|
||||
margin: -4px 0 20px;
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.setup-content {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -445,6 +499,10 @@ h1 {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.setup-progress {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.setup-complete {
|
||||
min-height: 280px;
|
||||
display: flex;
|
||||
|
||||
@@ -17,7 +17,20 @@ const isPublic = (to: RouteLocationNormalized) =>
|
||||
let setupStatusRequest: ReturnType<typeof getSetupStatus> | null = null;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as authApi from '@/api/auth';
|
||||
import { usePermissionStore } from '@/store';
|
||||
|
||||
import { hasPermissionAccess } from './menu-access';
|
||||
import { resolveWorkbenchRoute } from './workbench-route';
|
||||
|
||||
const emptyUser: UserView = {
|
||||
publicId: '',
|
||||
@@ -28,6 +29,7 @@ export const useUserStore = defineStore('user', {
|
||||
getters: {
|
||||
displayName: (state) => state.user.displayName || state.user.username,
|
||||
roleName: (state) => state.roles.find((role) => role.code === state.currentRole)?.name || '',
|
||||
workbenchRoute: (state) => resolveWorkbenchRoute(state.currentRole, state.roles),
|
||||
hasRole: (state) => (roleCode: string) => state.roles.some((role) => role.code === roleCode),
|
||||
hasPermission: (state) => (permission: string) =>
|
||||
hasPermissionAccess(state.currentRole, state.permissions, permission),
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { RoleView } from '@/api/auth';
|
||||
|
||||
const fallbackWorkbenchRoutes: Record<string, string> = {
|
||||
PROJECT_MANAGER: '/workbench/project',
|
||||
FINANCE_MANAGER: '/workbench/finance',
|
||||
ARCHIVE_MANAGER: '/workbench/archive',
|
||||
SYSTEM_ADMIN: '/governance/settings',
|
||||
};
|
||||
|
||||
export function resolveWorkbenchRoute(currentRole: string | null, roles: RoleView[]): string {
|
||||
const configured = roles.find((role) => role.code === currentRole)?.workbenchRoute?.trim();
|
||||
return configured || fallbackWorkbenchRoutes[currentRole || ''] || '/role-select';
|
||||
}
|
||||
@@ -87,7 +87,7 @@
|
||||
z-index: 100;
|
||||
}
|
||||
&-tabs-nav + .@{starter-prefix}-content-layout {
|
||||
padding-top: var(--td-comp-paddingTB-xxl);
|
||||
padding-top: var(--td-comp-paddingTB-l);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
|
||||
@@ -19,6 +19,31 @@ import type { AxiosRequestConfigRetry, RequestOptions, Result } from '@/types/ax
|
||||
import { AxiosCanceler } from './AxiosCancel';
|
||||
import type { CreateAxiosOptions } from './AxiosTransform';
|
||||
|
||||
function localizeApiError(code: string | undefined, detail: string | undefined, status?: number): string {
|
||||
const messages: Record<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 模块
|
||||
*/
|
||||
@@ -318,13 +343,14 @@ export class VAxios {
|
||||
const problem = e.response?.data as
|
||||
{ code?: string; detail?: string; requestId?: string; fieldErrors?: Record<string, string> } | undefined;
|
||||
const requestId = problem?.requestId || e.response?.headers?.['x-request-id'];
|
||||
const message = problem?.detail || (e.response?.status === 401 ? '登录会话已失效' : '网络请求失败');
|
||||
const message = localizeApiError(problem?.code, problem?.detail, e.response?.status);
|
||||
const error = new Error(requestId ? `${message}(请求编号:${requestId})` : message);
|
||||
Object.assign(error, {
|
||||
code: problem?.code,
|
||||
status: e.response?.status,
|
||||
requestId,
|
||||
fieldErrors: problem?.fieldErrors,
|
||||
transportFailure: !e.response,
|
||||
});
|
||||
if (e.response?.status === 401 && window.location.pathname !== '/login') {
|
||||
window.dispatchEvent(new CustomEvent('kaidi:session-expired'));
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const requestMocks = vi.hoisted(() => ({
|
||||
post: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/request', () => ({
|
||||
request: {
|
||||
post: requestMocks.post,
|
||||
},
|
||||
}));
|
||||
|
||||
import { completeSetup } from '../src/api/setup';
|
||||
|
||||
describe('setup API', () => {
|
||||
beforeEach(() => {
|
||||
requestMocks.post.mockReset();
|
||||
requestMocks.post.mockResolvedValue({ completed: true });
|
||||
});
|
||||
|
||||
it('allows enough time for the first database migration', async () => {
|
||||
const request = {
|
||||
setupCode: 'fixture-code',
|
||||
databaseType: 'MYSQL' as const,
|
||||
host: '127.0.0.1',
|
||||
port: 3306,
|
||||
database: 'kaidi_finance',
|
||||
username: 'kaidi',
|
||||
password: 'fixture-password',
|
||||
adminUsername: 'admin',
|
||||
adminDisplayName: '系统管理员',
|
||||
adminPassword: 'SetupAdmin@2026Strong',
|
||||
adminPasswordConfirmation: 'SetupAdmin@2026Strong',
|
||||
};
|
||||
|
||||
await completeSetup(request);
|
||||
|
||||
expect(requestMocks.post).toHaveBeenCalledWith({
|
||||
url: '/setup/complete',
|
||||
data: request,
|
||||
timeout: 120_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { RoleView } from '../src/api/auth';
|
||||
import { resolveWorkbenchRoute } from '../src/store/modules/workbench-route';
|
||||
|
||||
const roles: RoleView[] = [
|
||||
{ code: 'SYSTEM_ADMIN', name: '系统管理员', workbenchRoute: '/governance/settings' },
|
||||
{ code: 'FINANCE_MANAGER', name: '财务管理人员', workbenchRoute: '/workbench/finance' },
|
||||
];
|
||||
|
||||
describe('active identity workbench route', () => {
|
||||
it('uses the route supplied for the active role', () => {
|
||||
expect(resolveWorkbenchRoute('SYSTEM_ADMIN', roles)).toBe('/governance/settings');
|
||||
expect(resolveWorkbenchRoute('FINANCE_MANAGER', roles)).toBe('/workbench/finance');
|
||||
});
|
||||
|
||||
it('falls back to the built-in role workbench when the server route is absent', () => {
|
||||
expect(resolveWorkbenchRoute('PROJECT_MANAGER', [])).toBe('/workbench/project');
|
||||
expect(resolveWorkbenchRoute('ARCHIVE_MANAGER', [])).toBe('/workbench/archive');
|
||||
});
|
||||
|
||||
it('keeps role selection for sessions without an active identity', () => {
|
||||
expect(resolveWorkbenchRoute(null, roles)).toBe('/role-select');
|
||||
});
|
||||
});
|
||||
@@ -55,7 +55,6 @@ export default ({ mode }: ConfigEnv): UserConfig => {
|
||||
name: 'tdesign-vendor',
|
||||
test: /node_modules[\\/]tdesign-(?:vue-next|icons-vue-next)[\\/]/,
|
||||
priority: 30,
|
||||
maxSize: 480 * 1024,
|
||||
},
|
||||
{
|
||||
name: 'vue-vendor',
|
||||
|
||||
@@ -5967,6 +5967,18 @@ components:
|
||||
format: int64
|
||||
type: integer
|
||||
type: object
|
||||
SystemUpdateEventView:
|
||||
properties:
|
||||
level:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
occurredAt:
|
||||
format: date-time
|
||||
type: string
|
||||
stage:
|
||||
type: string
|
||||
type: object
|
||||
SystemUpdateView:
|
||||
properties:
|
||||
allowedActions:
|
||||
@@ -5976,10 +5988,23 @@ components:
|
||||
checkedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
bytesPerSecond:
|
||||
format: int64
|
||||
type: integer
|
||||
currentVersion:
|
||||
type: string
|
||||
enabled:
|
||||
type: boolean
|
||||
downloadPercent:
|
||||
format: int32
|
||||
type: integer
|
||||
downloadedBytes:
|
||||
format: int64
|
||||
type: integer
|
||||
events:
|
||||
items:
|
||||
"$ref": "#/components/schemas/SystemUpdateEventView"
|
||||
type: array
|
||||
latestVersion:
|
||||
type: string
|
||||
message:
|
||||
@@ -5989,11 +6014,17 @@ components:
|
||||
type: string
|
||||
releaseNotes:
|
||||
type: string
|
||||
restartExpectedSeconds:
|
||||
format: int32
|
||||
type: integer
|
||||
state:
|
||||
type: string
|
||||
statusUpdatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
totalBytes:
|
||||
format: int64
|
||||
type: integer
|
||||
updateAvailable:
|
||||
type: boolean
|
||||
type: object
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
更新日志(Preview 47)
|
||||
|
||||
本版本聚焦在线更新的可追溯性与发布说明展示,不改变财务业务数据和审批规则。
|
||||
|
||||
• 更新历史改为按版本展示 Release 更新日志,不再把获取、下载、重启等内部执行步骤当作历史内容。
|
||||
• 历史详情直接展示签名 Release 的发布说明、发布时间、目标版本和最终结果。
|
||||
• 更新器在验签后把 releaseNotes 与 publishedAt 写入状态文件;应用重启后仍能显示完整发布说明。
|
||||
• 终态审计记录保存发布说明并保持幂等,历史记录不再只显示“更新成功”一条信息。
|
||||
• 发布打包、验签和 Gitea Release 正文统一读取 release-notes/<version>.md,避免页面、清单和 Release 描述不一致。
|
||||
|
||||
升级说明:本版本不要求安装 MySQL,也不会自动修改数据库服务;更新器默认跳过数据库备份,仅使用已验签的 Release 制品完成应用切换。
|
||||
@@ -0,0 +1,11 @@
|
||||
更新日志(Preview 48)
|
||||
|
||||
本版本修复在线更新完成后后台看不到 Release 更新日志的问题。
|
||||
|
||||
• 更新日志继续以签名 release-manifest.json 为唯一发布来源,Gitea Release 正文与清单使用同一份内容。
|
||||
• 发布制品新增 release-metadata.json,并由签名制品携带版本、发布时间和完整更新说明。
|
||||
• 应用重启后会从当前已验签制品恢复更新说明;即使由旧版更新器完成版本切换,也不会再丢失日志。
|
||||
• 更新完成后的终态审计会保存 Release 更新说明,系统更新页按版本显示完整日志,而不是只显示“更新成功”。
|
||||
• 增加发布清单与制品内元数据的一致性校验,避免版本、发布时间和更新说明发生漂移。
|
||||
|
||||
升级说明:本版本不要求安装 MySQL,也不会自动修改数据库服务;更新器默认跳过数据库备份,仅使用已验签的 Release 制品完成应用切换。
|
||||
@@ -75,13 +75,40 @@ JAR="$ROOT/backend/target/finance-system-$VERSION.jar"
|
||||
JAR_METADATA_DIR="$WORK/jar-metadata"
|
||||
mkdir -p "$JAR_METADATA_DIR"
|
||||
(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_METADATA_DIR/META-INF/maven/com.kaidi/finance-system/pom.properties")
|
||||
JAR_IMPLEMENTATION_VERSION=$(sed -n 's/^Implementation-Version: //p' \
|
||||
"$JAR_METADATA_DIR/META-INF/MANIFEST.MF" | tr -d '\r')
|
||||
[ "$JAR_POM_VERSION" = "$VERSION" ] && [ "$JAR_IMPLEMENTATION_VERSION" = "$VERSION" ] \
|
||||
|| { 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
|
||||
|
||||
RELEASE_NOTES_FILE=${KAIDI_RELEASE_NOTES_FILE:-$ROOT/release-notes/$VERSION.md}
|
||||
if [ -n "${KAIDI_RELEASE_NOTES+x}" ]; then
|
||||
RELEASE_NOTES_VALUE=$KAIDI_RELEASE_NOTES
|
||||
elif [ -f "$RELEASE_NOTES_FILE" ] && [ ! -L "$RELEASE_NOTES_FILE" ]; then
|
||||
RELEASE_NOTES_VALUE=$(sed 's/\r$//' "$RELEASE_NOTES_FILE")
|
||||
elif [ "$SOURCE_REF" != local ] || [ "${KAIDI_REQUIRE_RELEASE_NOTES:-false}" = true ]; then
|
||||
printf 'Release notes file is missing: %s\n' "$RELEASE_NOTES_FILE" >&2
|
||||
exit 1
|
||||
else
|
||||
RELEASE_NOTES_VALUE="Kaidi Finance Preview $VERSION"
|
||||
fi
|
||||
RELEASE_NOTES_BYTES=$(printf '%s' "$RELEASE_NOTES_VALUE" | wc -c | tr -d '[:space:]')
|
||||
[ "$RELEASE_NOTES_BYTES" -gt 0 ] && [ "$RELEASE_NOTES_BYTES" -le 4000 ] \
|
||||
|| { printf 'Release notes must contain 1 to 4000 bytes\n' >&2; exit 1; }
|
||||
PUBLISHED_AT=$(node -e 'process.stdout.write(new Date().toISOString())')
|
||||
|
||||
rm -rf "$OUTPUT_DIR"
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
@@ -91,16 +118,39 @@ cp "$JAR" "$STAGE/app.jar"
|
||||
cp -R "$ROOT/frontend/dist/." "$STAGE/public/"
|
||||
printf '%s\n' "$VERSION" > "$STAGE/VERSION"
|
||||
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.
|
||||
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-update.service" "$STAGE/ops/kaidi-update.service"
|
||||
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"
|
||||
VERSION="$VERSION" RELEASE_NOTES="$RELEASE_NOTES_VALUE" PUBLISHED_AT="$PUBLISHED_AT" \
|
||||
node <<'NODE' > "$STAGE/release-metadata.json"
|
||||
const metadata = {
|
||||
version: process.env.VERSION,
|
||||
publishedAt: process.env.PUBLISHED_AT,
|
||||
releaseNotes: process.env.RELEASE_NOTES,
|
||||
};
|
||||
process.stdout.write(`${JSON.stringify(metadata, null, 2)}\n`);
|
||||
NODE
|
||||
|
||||
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"
|
||||
COPYFILE_DISABLE=1 tar -czf "$OUTPUT_DIR/$ARTIFACT" -C "$STAGE" .
|
||||
COPYFILE_DISABLE=1 tar --format=ustar -czf "$OUTPUT_DIR/$ARTIFACT" -C "$STAGE" .
|
||||
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"
|
||||
(cd "$ROOT/frontend" && npm sbom --omit=dev --package-lock-only \
|
||||
--sbom-format cyclonedx --sbom-type application) > "$OUTPUT_DIR/frontend-sbom.cdx.json"
|
||||
@@ -141,8 +191,10 @@ else
|
||||
fi
|
||||
|
||||
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")
|
||||
INSTALLER_SHA256=$(sha256_file "$OUTPUT_DIR/install.sh")
|
||||
PURGER_SHA256=$(sha256_file "$OUTPUT_DIR/purge.sh")
|
||||
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" =~ ^[0-9a-f]{64}$ ]] \
|
||||
@@ -156,14 +208,17 @@ fi
|
||||
cat > "$OUTPUT_DIR/bootstrap-checksums.txt" <<EOF
|
||||
KAIDI_RELEASE_PUBLIC_KEY_SHA256=$PUBLIC_KEY_SHA256
|
||||
KAIDI_INSTALLER_SHA256=$INSTALLER_SHA256
|
||||
KAIDI_PURGER_SHA256=$PURGER_SHA256
|
||||
EOF
|
||||
BOOTSTRAP_SHA256=$(sha256_file "$OUTPUT_DIR/bootstrap-checksums.txt")
|
||||
|
||||
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" \
|
||||
PUBLISHED_AT="$PUBLISHED_AT" \
|
||||
BACKEND_SBOM_SHA256="$BACKEND_SBOM_SHA256" FRONTEND_SBOM_SHA256="$FRONTEND_SBOM_SHA256" \
|
||||
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" \
|
||||
SOURCE_DIRTY="$SOURCE_DIRTY" \
|
||||
node <<'NODE' > "$OUTPUT_DIR/release-manifest.json"
|
||||
@@ -171,7 +226,9 @@ const manifest = {
|
||||
version: process.env.VERSION,
|
||||
artifact: process.env.ARTIFACT,
|
||||
sha256: process.env.SHA256,
|
||||
publishedAt: new Date().toISOString(),
|
||||
artifactSizeBytes: Number(process.env.ARTIFACT_SIZE_BYTES),
|
||||
publishedAt: process.env.PUBLISHED_AT,
|
||||
releaseMetadata: 'release-metadata.json',
|
||||
minimumJava: 17,
|
||||
source: {
|
||||
revision: process.env.SOURCE_REVISION,
|
||||
@@ -190,6 +247,8 @@ const manifest = {
|
||||
bootstrap: {
|
||||
installer: 'install.sh',
|
||||
installerSha256: process.env.INSTALLER_SHA256,
|
||||
purger: 'purge.sh',
|
||||
purgerSha256: process.env.PURGER_SHA256,
|
||||
publicKey: 'release-public.pem',
|
||||
publicKeySha256: process.env.PUBLIC_KEY_SHA256,
|
||||
checksums: 'bootstrap-checksums.txt',
|
||||
@@ -209,14 +268,15 @@ fi
|
||||
cd "$OUTPUT_DIR"
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
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
|
||||
else
|
||||
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
|
||||
fi
|
||||
)
|
||||
printf 'Release assets created in %s\n' "$OUTPUT_DIR"
|
||||
printf 'Trusted release public-key SHA-256: %s\n' "$PUBLIC_KEY_SHA256"
|
||||
printf 'Installer SHA-256: %s\n' "$INSTALLER_SHA256"
|
||||
printf 'Purger SHA-256: %s\n' "$PURGER_SHA256"
|
||||
|
||||
@@ -10,9 +10,7 @@ SOURCE_SHA=${GITEA_SHA:-}
|
||||
TOKEN=${GITEA_TOKEN:-}
|
||||
RELEASE_DIR=${KAIDI_RELEASE_DIR:-dist/release}
|
||||
RELEASE_NAME=${KAIDI_RELEASE_NAME:-Kaidi Finance $TAG}
|
||||
RELEASE_BODY=${KAIDI_RELEASE_BODY:-Kaidi Finance $TAG
|
||||
|
||||
Source: $SOURCE_SHA}
|
||||
RELEASE_BODY=${KAIDI_RELEASE_BODY-}
|
||||
WORK=$(mktemp -d)
|
||||
AUTH_HEADER=$WORK/gitea-auth-header
|
||||
|
||||
@@ -38,6 +36,15 @@ esac
|
||||
|| fail 'GITEA_TOKEN is invalid'
|
||||
[ -d "$RELEASE_DIR" ] || fail 'release directory is missing'
|
||||
|
||||
if [ -z "${KAIDI_RELEASE_BODY+x}" ]; then
|
||||
RELEASE_NOTES=$(jq -er '.releaseNotes | strings | select(length > 0 and length <= 4000)' \
|
||||
"$RELEASE_DIR/release-manifest.json") \
|
||||
|| fail 'release manifest notes are missing or invalid'
|
||||
RELEASE_BODY="$RELEASE_NOTES
|
||||
|
||||
Source: $SOURCE_SHA"
|
||||
fi
|
||||
|
||||
printf 'Authorization: token %s\nAccept: application/json\n' "$TOKEN" > "$AUTH_HEADER"
|
||||
chmod 0600 "$AUTH_HEADER"
|
||||
|
||||
@@ -48,6 +55,7 @@ request() {
|
||||
local method=$1 url=$2 output=$3 input=${4:-} status
|
||||
local args=(
|
||||
--silent --show-error --proto '=https' --tlsv1.2
|
||||
--connect-timeout 15 --max-time 600 --retry 5 --retry-all-errors --retry-delay 2
|
||||
--request "$method" --header "@$AUTH_HEADER"
|
||||
--output "$output" --write-out '%{http_code}'
|
||||
)
|
||||
@@ -86,6 +94,7 @@ ASSETS=(
|
||||
release-manifest.sig
|
||||
release-public.pem
|
||||
install.sh
|
||||
purge.sh
|
||||
bootstrap-checksums.txt
|
||||
SHA256SUMS
|
||||
)
|
||||
@@ -99,6 +108,7 @@ for name in "${ASSETS[@]}"; do
|
||||
>> "$WORK/expected-assets.jsonl"
|
||||
encoded_name=$(jq -rn --arg value "$name" '$value | @uri')
|
||||
upload_status=$(curl --silent --show-error --proto '=https' --tlsv1.2 \
|
||||
--connect-timeout 15 --max-time 1800 --retry 6 --retry-all-errors --retry-delay 2 \
|
||||
--header "@$AUTH_HEADER" \
|
||||
--form "attachment=@$path;type=application/octet-stream" \
|
||||
--output "$WORK/upload.json" --write-out '%{http_code}' \
|
||||
@@ -122,7 +132,7 @@ jq -n \
|
||||
> "$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"
|
||||
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'
|
||||
|
||||
printf 'Published Gitea release %s with nine verified assets\n' "$TAG"
|
||||
printf 'Published Gitea release %s with ten verified assets\n' "$TAG"
|
||||
|
||||
Executable
+153
@@ -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'
|
||||
@@ -46,6 +46,10 @@ chmod 0755 "$FIXTURE/bin/sudo" "$FIXTURE/repo/deploy/install.sh" "$FIXTURE/repo/
|
||||
printf '%s' 'fixture-read-token' > "$FIXTURE/token"
|
||||
chmod 0600 "$FIXTURE/token"
|
||||
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" \
|
||||
EXPECT_PORT=true \
|
||||
@@ -81,6 +85,6 @@ if PATH="$FIXTURE/bin:$PATH" \
|
||||
printf 'Mismatched installer SHA-256 was accepted\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -q 'does not match the trusted SHA-256' "$FIXTURE/hash-mismatch.log"
|
||||
grep -q '与该 Git 标签的受信摘要不一致' "$FIXTURE/hash-mismatch.log"
|
||||
|
||||
printf 'Git checkout installation wrapper fixture passed\n'
|
||||
|
||||
@@ -24,10 +24,12 @@ for name in \
|
||||
release-manifest.sig \
|
||||
release-public.pem \
|
||||
install.sh \
|
||||
purge.sh \
|
||||
bootstrap-checksums.txt \
|
||||
SHA256SUMS; do
|
||||
printf 'fixture asset %s\n' "$name" > "$RELEASE_DIR/$name"
|
||||
done
|
||||
printf '%s\n' '{"releaseNotes":"Fixture release notes"}' > "$RELEASE_DIR/release-manifest.json"
|
||||
|
||||
cat > "$MOCK_BIN/curl" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
@@ -102,11 +104,11 @@ GITEA_TOKEN="$TOKEN" \
|
||||
KAIDI_RELEASE_DIR="$RELEASE_DIR" \
|
||||
"$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'
|
||||
[ "$(wc -l < "$WORK/assets.jsonl" | tr -d '[:space:]')" -eq 9 ] \
|
||||
|| fail 'publish script did not upload exactly nine assets'
|
||||
[ "$(wc -l < "$WORK/assets.jsonl" | tr -d '[:space:]')" -eq 10 ] \
|
||||
|| fail 'publish script did not upload exactly ten assets'
|
||||
! grep -Fq "$TOKEN" "$WORK/curl.log" \
|
||||
|| 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'
|
||||
|
||||
@@ -10,6 +10,10 @@ fail() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
mode_of() {
|
||||
stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1"
|
||||
}
|
||||
|
||||
# Load only pure helper functions. The installer itself must never run in this fixture.
|
||||
{
|
||||
sed -n '/^decode_env_value()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||
@@ -20,17 +24,96 @@ fail() {
|
||||
sed -n '/^port_is_listening()/,/^}/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_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 '/^write_env_file_preserving_unknown()/,/^}/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 '/^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 '/^sha256_file()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||
sed -n '/^prepare_java()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||
sed -n '/^download_release_url()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||
sed -n '/^release_asset_url()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||
sed -n '/^download_release_asset()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||
sed -n '/^install_packages()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||
sed -n '/^preflight_database()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||
sed -n '/^secure_release_tree()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||
sed -n '/^restore_unit_state()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||
} > "$WORK/helpers.sh"
|
||||
# shellcheck disable=SC1090,SC1091
|
||||
source "$WORK/helpers.sh"
|
||||
# Avoid invoking the host's macOS `log` utility while exercising extracted
|
||||
# installer helpers.
|
||||
log() { printf '%s\n' "$*" >/dev/null; }
|
||||
|
||||
export SETUP_WIZARD=true
|
||||
preflight_database || fail 'setup wizard database preflight returned a failure status'
|
||||
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"
|
||||
mkdir -p "$release_permissions/public" "$release_permissions/ops"
|
||||
printf 'jar\n' > "$release_permissions/app.jar"
|
||||
printf 'html\n' > "$release_permissions/public/index.html"
|
||||
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"
|
||||
(
|
||||
# shellcheck disable=SC2329 # Invoked indirectly by the sourced installer helper.
|
||||
chown() { return 0; }
|
||||
export SERVICE_GROUP=fixture
|
||||
secure_release_tree "$release_permissions"
|
||||
)
|
||||
[ "$(mode_of "$release_permissions")" = 750 ] || fail 'release root mode is not 0750'
|
||||
[ "$(mode_of "$release_permissions/public")" = 750 ] || fail 'release directory mode is not 0750'
|
||||
[ "$(mode_of "$release_permissions/app.jar")" = 640 ] || fail 'release file mode is not 0640'
|
||||
[ "$(mode_of "$release_permissions/ops/update.sh")" = 750 ] || fail 'release updater mode is not 0750'
|
||||
|
||||
(
|
||||
# shellcheck disable=SC2329 # Invoked indirectly by the sourced installer helper.
|
||||
systemctl() {
|
||||
case "$1" in
|
||||
cat) return 1 ;;
|
||||
reset-failed) return 0 ;;
|
||||
*) return 97 ;;
|
||||
esac
|
||||
}
|
||||
restore_unit_state missing.service false false
|
||||
) || fail 'rollback treated an absent first-install unit as an incomplete restoration'
|
||||
|
||||
# shellcheck disable=SC2034 # Referenced by the extracted installer helper.
|
||||
REINSTALL=true
|
||||
@@ -131,11 +214,24 @@ for arch in i386 i486 i586 i686; do
|
||||
ARCH_FIXTURE=$arch
|
||||
[ "$(azul_arch)" = i686 ] || fail "$arch did not map to the Azul i686 runtime"
|
||||
done
|
||||
(
|
||||
ARCH_FIXTURE=x86_64
|
||||
# shellcheck disable=SC2329 # Invoked indirectly by the sourced architecture helper.
|
||||
getconf() { printf '32\n'; }
|
||||
[ "$(normalized_host_arch)" = x86 ] \
|
||||
|| fail '32-bit userspace on an x86_64 kernel was not normalized to x86'
|
||||
export HOST_ARCH=x86
|
||||
[ "$(azul_arch)" = i686 ] \
|
||||
|| fail '32-bit userspace on an x86_64 kernel did not select the i686 Java runtime'
|
||||
)
|
||||
|
||||
mkdir -p "$WORK/fake-jre/bin"
|
||||
cat > "$WORK/fake-jre/bin/java" <<'JAVA'
|
||||
#!/usr/bin/env sh
|
||||
printf 'openjdk version "17-fixture"\n' >&2
|
||||
if [ "${1:-}" = '-XshowSettings:properties' ]; then
|
||||
printf ' os.arch = x86\n' >&2
|
||||
fi
|
||||
printf 'openjdk version "17.0.8"\n' >&2
|
||||
JAVA
|
||||
chmod 0755 "$WORK/fake-jre/bin/java"
|
||||
tar -czf "$WORK/java-fixture.tar.gz" -C "$WORK" fake-jre
|
||||
@@ -156,8 +252,22 @@ download() {
|
||||
esac
|
||||
}
|
||||
ARCH_FIXTURE=i686
|
||||
APP_ROOT="$WORK/app"
|
||||
export KAIDI_JAVA_HOME="$WORK/missing-java-home"
|
||||
prepare_java >/dev/null 2>&1
|
||||
[ -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"
|
||||
JAVA_STAGED_DIR=
|
||||
export KAIDI_JAVA_HOME="$WORK/fake-jre"
|
||||
prepare_java >/dev/null 2>&1
|
||||
[ -z "$JAVA_STAGED_DIR" ] \
|
||||
|| fail 'installer copied an existing server Java runtime into application storage'
|
||||
EXPECTED_LOCAL_JAVA=$(readlink -f "$WORK/fake-jre/bin/java" 2>/dev/null \
|
||||
|| 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
|
||||
RELEASE_TOKEN=fixture-read-only-token
|
||||
@@ -215,6 +325,26 @@ grep -Fq '[ "$APP_ROOT" = /opt/kaidi ]' "$ROOT/deploy/install.sh" \
|
||||
|| fail 'installer no longer rejects unsupported custom roots'
|
||||
grep -Fq '8.4.*) ;;' "$ROOT/deploy/install.sh" \
|
||||
|| 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.
|
||||
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'
|
||||
@@ -224,21 +354,86 @@ grep -Fq 'download "$api" "$java_metadata"' "$ROOT/deploy/install.sh" \
|
||||
# shellcheck disable=SC2016 # Match literal installer source.
|
||||
grep -Fq 'java_sha256=$(jq -er' "$ROOT/deploy/install.sh" \
|
||||
|| fail 'installer no longer obtains the Java runtime SHA-256'
|
||||
grep -Fq 'Using existing Java 17+ runtime' "$ROOT/deploy/install.sh" \
|
||||
|| fail 'installer no longer reuses a local Java 17 runtime'
|
||||
# shellcheck disable=SC2016 # Match literal installer source.
|
||||
grep -Fq 'JAVA_BIN="$system_home/bin/java"' "$ROOT/deploy/install.sh" \
|
||||
|| fail 'installer no longer records the selected server Java path'
|
||||
# shellcheck disable=SC2016 # Match literal installer source.
|
||||
! grep -Fq 'cp -R "$system_home/."' "$ROOT/deploy/install.sh" \
|
||||
|| fail 'installer copied an existing server Java runtime into managed storage'
|
||||
# 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" \
|
||||
|| fail 'installer no longer performs a quiet bounded health check'
|
||||
grep -Fq 'restart_count" -ge 3' "$ROOT/deploy/install.sh" \
|
||||
|| fail 'installer no longer stops early after repeated service restarts'
|
||||
# shellcheck disable=SC2016 # Match literal installer source.
|
||||
grep -Fq 'install -d -o root -g "$SERVICE_GROUP" -m 0750' "$ROOT/deploy/install.sh" \
|
||||
|| fail 'installer no longer creates traversable root-owned application directories'
|
||||
# shellcheck disable=SC2016 # Match literal installer source.
|
||||
grep -Fq 'find "$release_dir" -type d -exec chmod 0750 {} +' "$ROOT/deploy/install.sh" \
|
||||
|| fail 'installer no longer secures release directory traversal permissions'
|
||||
# shellcheck disable=SC2016 # Match literal installer source.
|
||||
grep -Fq 'runuser -u "$SERVICE_USER" -- sh -c' "$ROOT/deploy/install.sh" \
|
||||
|| fail 'installer no longer validates the release as the service user'
|
||||
grep -Fq 'systemctl reset-failed kaidi-finance.service' "$ROOT/deploy/install.sh" \
|
||||
|| fail 'installer no longer resets stale systemd failure state'
|
||||
[ "$(tail -n 1 "$ROOT/deploy/install.sh")" = 'main "$@"' ] \
|
||||
|| fail 'installer can execute before the complete curl stream is parsed'
|
||||
grep -Fqx 'StartLimitBurst=3' "$ROOT/deploy/systemd/kaidi-finance.service" \
|
||||
|| 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.
|
||||
grep -Fq '[ "$actual_sha256" = "$java_sha256" ]' "$ROOT/deploy/install.sh" \
|
||||
|| 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" \
|
||||
|| 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.
|
||||
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'
|
||||
# shellcheck disable=SC2016 # Match literal installer source.
|
||||
grep -Fq 'SETUP_WIZARD=${KAIDI_SETUP_WIZARD:-true}' "$ROOT/deploy/install.sh" \
|
||||
|| fail 'installer does not enable the first-run setup wizard by default'
|
||||
grep -Fq 'if [ "${KAIDI_SETUP_WIZARD+x}" = x ]; then' "$ROOT/deploy/install.sh" \
|
||||
|| 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" \
|
||||
|| fail 'installer must not create a MySQL container'
|
||||
! grep -Fq 'install_docker' "$ROOT/deploy/install.sh" \
|
||||
|| fail 'installer must not install Docker or MySQL automatically'
|
||||
package_log="$WORK/package-manager.log"
|
||||
for package_manager in apt-get dnf yum; do
|
||||
package_bin="$WORK/package-manager-$package_manager"
|
||||
mkdir "$package_bin"
|
||||
cat > "$package_bin/$package_manager" <<'SH'
|
||||
#!/bin/sh
|
||||
printf '%s %s\n' "${0##*/}" "$*" >> "$PACKAGE_LOG"
|
||||
SH
|
||||
chmod +x "$package_bin/$package_manager"
|
||||
: > "$package_log"
|
||||
(
|
||||
PATH="$package_bin"
|
||||
PACKAGE_LOG="$package_log"
|
||||
export PATH PACKAGE_LOG
|
||||
install_packages
|
||||
)
|
||||
if grep -Eqi '(^|[[:space:]])(default-mysql-client|mysql(-client|-server)?|mariadb(-client|-server)?)([[:space:]]|$)' "$package_log"; then
|
||||
fail "installer asked $package_manager to install a database package"
|
||||
fi
|
||||
done
|
||||
grep -Fq 'An external MySQL 8.4 database is required' "$ROOT/deploy/install.sh" \
|
||||
|| fail 'installer does not require an operator-managed external MySQL database'
|
||||
grep -Fq 'KAIDI_RELEASE_TOKEN_FILE' "$ROOT/deploy/install.sh" \
|
||||
@@ -258,13 +453,22 @@ grep -Fq 'SERVER_ADDRESS "$SERVER_ADDRESS"' "$ROOT/deploy/install.sh" \
|
||||
grep -Fq 'EnvironmentFile=-/var/lib/kaidi/setup/application.env' \
|
||||
"$ROOT/deploy/systemd/kaidi-finance.service" \
|
||||
|| 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" \
|
||||
|| fail 'update service no longer loads setup database overrides'
|
||||
# shellcheck disable=SC2016 # Match the literal installer source.
|
||||
grep -Fq 'chown root:kaidi "$UPDATE_STATE_ROOT"' "$ROOT/deploy/install.sh" \
|
||||
|| 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.
|
||||
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'
|
||||
grep -Fq 'install.sh | sudo bash' "$ROOT/README.md" \
|
||||
|| fail 'README does not document the public one-line setup-wizard install path'
|
||||
manual_version=$(sed -n 's/^VERSION=\(1\.0\.0-preview\.[0-9][0-9]*\)$/\1/p' "$ROOT/README.md" | sed -n '1p')
|
||||
[ -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'
|
||||
|
||||
Executable
+95
@@ -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'
|
||||
+334
-15
@@ -11,6 +11,10 @@ fail() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
mode_of() {
|
||||
stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1"
|
||||
}
|
||||
|
||||
sed -n '/^is_semver()/,/^}/p' "$ROOT/deploy/update.sh" > "$WORK/update-semver.sh"
|
||||
# shellcheck disable=SC1090,SC1091
|
||||
source "$WORK/update-semver.sh"
|
||||
@@ -21,6 +25,37 @@ for version in 01.2.3 1.02.3 1.2.03 1.2.3-01 1.2.3-alpha..1; do
|
||||
! is_semver "$version" || fail "updater accepted invalid SemVer $version"
|
||||
done
|
||||
|
||||
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" \
|
||||
KAIDI_UPDATE_STATE_ROOT="$WORK/bootstrap/state" \
|
||||
KAIDI_LOG_ROOT="$WORK/bootstrap/log" \
|
||||
KAIDI_SERVICE_USER="$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
|
||||
fail 'updater accepted a missing service identity during bootstrap'
|
||||
fi
|
||||
grep -Fq '缺少服务用户' "$WORK/bootstrap.log" \
|
||||
|| fail 'updater bootstrap failure did not preserve its diagnostic'
|
||||
[ "$(jq -r '.state' "$WORK/bootstrap/state/status.json")" = FAILED ] \
|
||||
|| fail 'updater bootstrap failure did not persist FAILED'
|
||||
[ ! -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() {
|
||||
local mock_bin=$1
|
||||
mkdir -p "$mock_bin"
|
||||
@@ -30,10 +65,14 @@ write_mock_commands() {
|
||||
output=
|
||||
url=
|
||||
header_file=
|
||||
write_out=
|
||||
http_status=200
|
||||
printf '%s\n' "$*" >> "${MOCK_CURL_LOG:-/dev/null}"
|
||||
while [ "$#" -gt 0 ]; do
|
||||
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)
|
||||
shift
|
||||
case "${1:-}" in @*) header_file=${1#@} ;; esac
|
||||
@@ -55,6 +94,26 @@ esac
|
||||
if [ -n "$output" ]; then
|
||||
if [ -n "${MOCK_RELEASE_API_URL:-}" ] && [ "$url" = "$MOCK_RELEASE_API_URL" ]; then
|
||||
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
|
||||
cp "$MOCK_APP_ROOT/current/public/index.html" "$output"
|
||||
else
|
||||
@@ -69,17 +128,24 @@ elif [ "${MOCK_HEALTH:-success}" = fail-new ] \
|
||||
else
|
||||
exit 22
|
||||
fi
|
||||
[ -z "$write_out" ] || printf '%s' "$http_status"
|
||||
SH
|
||||
|
||||
cat > "$mock_bin/systemctl" <<'SH'
|
||||
#!/bin/sh
|
||||
printf '%s\n' "$*" >> "$MOCK_SYSTEMCTL_LOG"
|
||||
[ "${MOCK_UPDATE_PATH_START:-success}:$*" != 'fail:start kaidi-update.path' ] || exit 1
|
||||
exit 0
|
||||
SH
|
||||
|
||||
cat > "$mock_bin/flock" <<'SH'
|
||||
#!/bin/sh
|
||||
exit 0
|
||||
SH
|
||||
|
||||
cat > "$mock_bin/setsid" <<'SH'
|
||||
#!/bin/sh
|
||||
exec "$@"
|
||||
SH
|
||||
|
||||
cat > "$mock_bin/systemd-analyze" <<'SH'
|
||||
@@ -99,6 +165,33 @@ SH
|
||||
cat > "$mock_bin/chown" <<'SH'
|
||||
#!/bin/sh
|
||||
exit 0
|
||||
SH
|
||||
|
||||
cat > "$mock_bin/runuser" <<'SH'
|
||||
#!/bin/sh
|
||||
[ "${1:-}" = -u ] || exit 2
|
||||
shift 2
|
||||
[ "${1:-}" = -- ] && shift
|
||||
exec "$@"
|
||||
SH
|
||||
|
||||
cat > "$mock_bin/install" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
if [[ " $* " != *" -d "* ]]; then
|
||||
exec /usr/bin/install "$@"
|
||||
fi
|
||||
mode=0755
|
||||
paths=()
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
-d) shift ;;
|
||||
-o|-g) shift 2 ;;
|
||||
-m) mode=$2; shift 2 ;;
|
||||
*) paths+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
mkdir -p "${paths[@]}"
|
||||
chmod "$mode" "${paths[@]}"
|
||||
SH
|
||||
|
||||
cat > "$mock_bin/mv" <<'SH'
|
||||
@@ -125,28 +218,40 @@ SH
|
||||
build_release() {
|
||||
local fixture=$1
|
||||
local version=$2
|
||||
local unit_prefix=${3:-new}
|
||||
local stage="$fixture/stage"
|
||||
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 '<!doctype html><title>new</title>\n' > "$stage/public/index.html"
|
||||
printf '%s\n' "$version" > "$stage/VERSION"
|
||||
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/baota-start.sh"
|
||||
chmod 0755 "$stage/ops/baota-init.sh"
|
||||
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
|
||||
|
||||
local artifact="kaidi-finance-$version.tar.gz"
|
||||
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}')
|
||||
jq -n --arg version "$version" --arg artifact "$artifact" --arg sha "$sha" \
|
||||
'{version:$version,artifact:$artifact,sha256:$sha,publishedAt:"2026-08-16T00:00:00Z",releaseNotes:"fixture"}' \
|
||||
size=$(wc -c < "$fixture/release/$artifact" | tr -d '[:space:]')
|
||||
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"
|
||||
"$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" \
|
||||
-out "$fixture/release/release-manifest.sig" "$fixture/release/release-manifest.json"
|
||||
}
|
||||
@@ -170,13 +275,18 @@ prepare_installation() {
|
||||
local fixture=$1
|
||||
local version=$2
|
||||
mkdir -p "$fixture/app/releases/1.0.0-preview.1/public" "$fixture/app/bin" \
|
||||
"$fixture/state/inbox" "$fixture/systemd" "$fixture/log"
|
||||
"$fixture/app/runtime/java/bin" "$fixture/state/inbox" "$fixture/systemd" "$fixture/log"
|
||||
printf 'old application\n' > "$fixture/app/releases/1.0.0-preview.1/app.jar"
|
||||
printf '<!doctype html><title>old</title>\n' > "$fixture/app/releases/1.0.0-preview.1/public/index.html"
|
||||
printf '1.0.0-preview.1\n' > "$fixture/app/releases/1.0.0-preview.1/VERSION"
|
||||
ln -s "$fixture/app/releases/1.0.0-preview.1" "$fixture/app/current"
|
||||
printf 'old update.sh\n' > "$fixture/app/bin/update.sh"
|
||||
chmod 0755 "$fixture/app/bin/update.sh"
|
||||
cat > "$fixture/app/runtime/java/bin/java" <<'SH'
|
||||
#!/bin/sh
|
||||
exit 0
|
||||
SH
|
||||
chmod 0755 "$fixture/app/runtime/java/bin/java"
|
||||
for name in kaidi-finance.service kaidi-update.service kaidi-update.path; do
|
||||
printf 'old %s\n' "$name" > "$fixture/systemd/$name"
|
||||
done
|
||||
@@ -187,21 +297,42 @@ write_request() {
|
||||
local fixture=$1
|
||||
local version=$2
|
||||
local action=$3
|
||||
local request_id=01M00000000000000000000091
|
||||
[ "$action" = INSTALL ] && request_id=01M00000000000000000000092
|
||||
mkdir -p "$fixture/state/inbox"
|
||||
jq -n --arg action "$action" --arg version "$version" \
|
||||
'{action:$action,version:$version,reason:"fixture"}' > "$fixture/state/inbox/request.json"
|
||||
jq -n --arg action "$action" --arg version "$version" --arg requestId "$request_id" \
|
||||
'{action:$action,version:$version,reason:"fixture",requestId:$requestId,
|
||||
requestedAt:"2026-08-19T00:00:00Z"}' > "$fixture/state/inbox/request.json"
|
||||
}
|
||||
|
||||
download_and_prepare_install() {
|
||||
local fixture=$1
|
||||
local version=$2
|
||||
truncate -s 0 "$fixture/systemctl.log"
|
||||
run_update "$fixture" success
|
||||
[ "$(jq -r '.state' "$fixture/state/status.json")" = 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'
|
||||
[ "$(jq -r '.releaseNotes' "$fixture/state/status.json")" = fixture ] \
|
||||
|| fail 'download phase did not persist the signed release notes'
|
||||
[ "$(jq -r '.publishedAt' "$fixture/state/status.json")" = 2026-08-16T00:00:00Z ] \
|
||||
|| fail 'download phase did not persist the signed release publish time'
|
||||
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/1.0.0-preview.1" ] \
|
||||
|| fail 'download phase changed the active application'
|
||||
[ -s "$fixture/state/cache/$version/release.tar.gz" ] \
|
||||
|| 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
|
||||
}
|
||||
|
||||
@@ -209,6 +340,7 @@ run_update() {
|
||||
local fixture=$1
|
||||
local health=$2
|
||||
local skip_backup=${3:-true}
|
||||
local backup_mode=${4:-}
|
||||
env \
|
||||
PATH="$fixture/mock-bin:$PATH" \
|
||||
REAL_OPENSSL="$REAL_OPENSSL" \
|
||||
@@ -222,12 +354,17 @@ run_update() {
|
||||
MOCK_RELEASE_ORIGIN="${FIXTURE_RELEASE_ORIGIN:-https://release.fixture.invalid}" \
|
||||
MOCK_EXPECT_RELEASE_TOKEN="${FIXTURE_RELEASE_TOKEN-}" \
|
||||
MOCK_SYSTEMCTL_LOG="$fixture/systemctl.log" \
|
||||
MOCK_UPDATE_PATH_START="${MOCK_UPDATE_PATH_START:-success}" \
|
||||
KAIDI_SERVICE_USER="$(id -un)" \
|
||||
KAIDI_SERVICE_GROUP="$(id -gn)" \
|
||||
KAIDI_APP_ROOT="$fixture/app" \
|
||||
KAIDI_UPDATE_STATE_ROOT="$fixture/state" \
|
||||
KAIDI_LOG_ROOT="$fixture/log" \
|
||||
KAIDI_SYSTEMD_ROOT="$fixture/systemd" \
|
||||
KAIDI_UPDATER_PATH="$fixture/app/bin/update.sh" \
|
||||
KAIDI_RUNTIME_ENV_FILE="$fixture/runtime.env" \
|
||||
KAIDI_SKIP_DB_BACKUP="$skip_backup" \
|
||||
KAIDI_DB_BACKUP_MODE="$backup_mode" \
|
||||
KAIDI_UPDATE_HEALTH_ATTEMPTS=1 \
|
||||
KAIDI_UPDATE_HEALTH_INTERVAL_SECONDS=0 \
|
||||
UPDATE_RELEASE_BASE_URL="${FIXTURE_RELEASE_BASE_URL-https://release.fixture.invalid}" \
|
||||
@@ -241,6 +378,20 @@ run_update() {
|
||||
"$ROOT/deploy/update.sh"
|
||||
}
|
||||
|
||||
assert_database_backup_opt_in_case() {
|
||||
local fixture="$WORK/database-backup-opt-in"
|
||||
local version='1.0.0-preview.2'
|
||||
mkdir -p "$fixture"
|
||||
write_mock_commands "$fixture/mock-bin"
|
||||
build_release "$fixture" "$version"
|
||||
prepare_installation "$fixture" "$version"
|
||||
download_and_prepare_install "$fixture" "$version"
|
||||
|
||||
run_update "$fixture" success false mysqldump
|
||||
find "$fixture/state/backups" -type f -name 'mysql-*.sql.gz' -print -quit | grep -q . \
|
||||
|| fail 'explicit mysqldump mode did not create a database backup'
|
||||
}
|
||||
|
||||
assert_private_gitea_release_case() {
|
||||
local fixture="$WORK/private-gitea"
|
||||
local version='1.0.0-preview.2'
|
||||
@@ -306,10 +457,58 @@ assert_success_case() {
|
||||
|| fail 'success case did not activate the signed updater'
|
||||
[ "$(jq -r '.state' "$fixture/state/status.json")" = 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'
|
||||
[ "$(jq -r '.releaseNotes' "$fixture/state/status.json")" = fixture ] \
|
||||
|| fail 'success case did not retain the signed release notes'
|
||||
[ ! -e "$fixture/state/processing/request.json" ] \
|
||||
|| fail 'success case left a claimed request behind'
|
||||
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'
|
||||
[ "$(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/current/app.jar")" = 640 ] || fail 'release file mode is not 0640'
|
||||
[ "$(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() {
|
||||
@@ -324,7 +523,7 @@ assert_rollback_case() {
|
||||
if run_update "$fixture" fail-new > "$fixture/update.log" 2>&1; then
|
||||
fail 'rollback case unexpectedly succeeded'
|
||||
fi
|
||||
grep -q 'previous release was restored and verified' "$fixture/update.log" \
|
||||
grep -q '已恢复并验证旧版本' "$fixture/update.log" \
|
||||
|| fail 'rollback case did not report a complete restoration'
|
||||
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/1.0.0-preview.1" ] \
|
||||
|| fail 'rollback case did not restore the previous application'
|
||||
@@ -332,14 +531,62 @@ assert_rollback_case() {
|
||||
|| fail 'rollback case did not restore the previous updater'
|
||||
grep -qx 'old kaidi-update.path' "$fixture/systemd/kaidi-update.path" \
|
||||
|| 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 ] \
|
||||
|| 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" ] \
|
||||
|| fail 'rollback case left the failed release installed'
|
||||
find "$fixture/state/failed" -type f -name 'request-*.json' -print -quit | grep -q . \
|
||||
|| 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() {
|
||||
local fixture="$WORK/download-failure"
|
||||
local version='1.0.0-preview.2'
|
||||
@@ -379,6 +626,58 @@ assert_database_failure_case() {
|
||||
|| 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() {
|
||||
local fixture="$WORK/symlink-request"
|
||||
local version='1.0.0-preview.2'
|
||||
@@ -393,7 +692,7 @@ assert_symlink_request_rejected() {
|
||||
if run_update "$fixture" success > "$fixture/update.log" 2>&1; then
|
||||
fail 'symlink request case unexpectedly succeeded'
|
||||
fi
|
||||
grep -q 'Update request must be a regular file' "$fixture/update.log" \
|
||||
grep -q '更新请求必须是普通文件' "$fixture/update.log" \
|
||||
|| fail 'symlink request case did not report the unsafe request'
|
||||
[ "$(cat "$fixture/sentinel")" = 'operator-owned sentinel' ] \
|
||||
|| fail 'symlink request case changed the link target'
|
||||
@@ -417,18 +716,38 @@ assert_install_without_verified_cache_rejected() {
|
||||
if run_update "$fixture" success > "$fixture/update.log" 2>&1; then
|
||||
fail 'install without cache unexpectedly succeeded'
|
||||
fi
|
||||
grep -q 'Verified release cache is missing' "$fixture/update.log" \
|
||||
grep -q '缺少已校验的发布缓存' "$fixture/update.log" \
|
||||
|| fail 'install without cache did not report the missing verified cache'
|
||||
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/1.0.0-preview.1" ] \
|
||||
|| fail 'install without cache changed the active application'
|
||||
}
|
||||
|
||||
printf '[update-fixture] success\n'
|
||||
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
|
||||
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
|
||||
printf '[update-fixture] database-backup-failure\n'
|
||||
assert_database_failure_case
|
||||
printf '[update-fixture] database-backup-opt-in\n'
|
||||
assert_database_backup_opt_in_case
|
||||
printf '[update-fixture] unhealthy-baseline\n'
|
||||
assert_unhealthy_baseline_blocks_restart_case
|
||||
printf '[update-fixture] invalid-database-url\n'
|
||||
assert_invalid_database_url_blocks_restart_case
|
||||
printf '[update-fixture] symlink-request\n'
|
||||
assert_symlink_request_rejected
|
||||
printf '[update-fixture] missing-verified-cache\n'
|
||||
assert_install_without_verified_cache_rejected
|
||||
printf '[update-fixture] private-gitea\n'
|
||||
assert_private_gitea_release_case
|
||||
printf '[update-fixture] cross-origin-gitea\n'
|
||||
assert_cross_origin_gitea_browser_url_ignored
|
||||
printf 'Online update download, confirmation, success, rollback, failure, and unsafe-request fixtures passed\n'
|
||||
|
||||
@@ -30,6 +30,11 @@ openssl dgst -sha256 -verify release-public.pem \
|
||||
VERSION=$(jq -er '.version | strings | select(length > 0)' release-manifest.json)
|
||||
"$ROOT/scripts/check-semver.sh" "$VERSION" \
|
||||
|| { printf 'Release version is not valid SemVer\n' >&2; exit 1; }
|
||||
RELEASE_NOTES=$(jq -er '.releaseNotes | strings | select(length > 0 and length <= 4000)' release-manifest.json) \
|
||||
|| { printf 'Release manifest must contain 1 to 4000 bytes of release notes\n' >&2; exit 1; }
|
||||
RELEASE_NOTES_BYTES=$(printf '%s' "$RELEASE_NOTES" | wc -c | tr -d '[:space:]')
|
||||
[ "$RELEASE_NOTES_BYTES" -le 4000 ] \
|
||||
|| { printf 'Release manifest release notes exceed 4000 bytes\n' >&2; exit 1; }
|
||||
ARTIFACT=$(jq -er '.artifact | strings | select(length > 0)' release-manifest.json)
|
||||
[ -s "$ARTIFACT" ] || { printf 'Release artifact is missing\n' >&2; exit 1; }
|
||||
[ "$ARTIFACT" = "kaidi-finance-$VERSION.tar.gz" ] \
|
||||
@@ -40,6 +45,7 @@ printf '%s\n' \
|
||||
bootstrap-checksums.txt \
|
||||
frontend-sbom.cdx.json \
|
||||
install.sh \
|
||||
purge.sh \
|
||||
"$ARTIFACT" \
|
||||
release-manifest.json \
|
||||
release-manifest.sig \
|
||||
@@ -49,12 +55,16 @@ for path in ./* ./.[!.]* ./..?*; do
|
||||
basename "$path"
|
||||
done | LC_ALL=C sort > "$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 \
|
||||
'.sha256 | strings | ascii_downcase | select(test("^[0-9a-f]{64}$"))' \
|
||||
release-manifest.json)
|
||||
[ "$(sha256_file "$ARTIFACT")" = "$EXPECTED_ARTIFACT_SHA256" ] \
|
||||
|| { 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 ] \
|
||||
|| { printf 'Release manifest must bind two SBOMs\n' >&2; exit 1; }
|
||||
jq -e --arg version "$VERSION" \
|
||||
@@ -109,16 +119,21 @@ done
|
||||
|
||||
PUBLIC_KEY_SHA256=$(sha256_file release-public.pem)
|
||||
INSTALLER_SHA256=$(sha256_file install.sh)
|
||||
PURGER_SHA256=$(sha256_file purge.sh)
|
||||
BOOTSTRAP_SHA256=$(sha256_file bootstrap-checksums.txt)
|
||||
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_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 ] \
|
||||
|| { 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" \
|
||||
'.bootstrap.installerSha256 == $installer
|
||||
and .bootstrap.purgerSha256 == $purger
|
||||
and .bootstrap.publicKeySha256 == $publicKey
|
||||
and .bootstrap.checksumsSha256 == $bootstrap' release-manifest.json >/dev/null \
|
||||
|| { printf 'Bootstrap assets do not match the signed manifest\n' >&2; exit 1; }
|
||||
@@ -131,6 +146,8 @@ NORMALIZED_TRUSTED_PUBLIC_KEY_SHA256=$(printf '%s' "$TRUSTED_PUBLIC_KEY_SHA256"
|
||||
|| { printf 'Bootstrap public-key fingerprint does not match\n' >&2; exit 1; }
|
||||
[ "$(sed -n 's/^KAIDI_INSTALLER_SHA256=//p' bootstrap-checksums.txt)" = "$INSTALLER_SHA256" ] \
|
||||
|| { 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")
|
||||
if grep -Eq '(^/|(^|/)\.\.(/|$))' <<< "$ARCHIVE_LIST"; then
|
||||
@@ -141,17 +158,57 @@ if grep -Eq '(^|/)(signing-private|private-key|id_rsa)' <<< "$ARCHIVE_LIST"; the
|
||||
printf 'Release archive contains private key material\n' >&2
|
||||
exit 1
|
||||
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
|
||||
RELEASE_METADATA_PATH=$(jq -r '.releaseMetadata // empty' release-manifest.json)
|
||||
if [ -n "$RELEASE_METADATA_PATH" ]; then
|
||||
[ "$RELEASE_METADATA_PATH" = "release-metadata.json" ] \
|
||||
|| { printf 'Release metadata path is invalid\n' >&2; exit 1; }
|
||||
tar -xOf "$ARTIFACT" "./$RELEASE_METADATA_PATH" > "$WORK/release-metadata.json" \
|
||||
|| { printf 'Release archive is missing embedded release metadata\n' >&2; exit 1; }
|
||||
METADATA_VERSION=$(jq -er '.version | strings | select(length > 0)' "$WORK/release-metadata.json") \
|
||||
|| { printf 'Embedded release metadata version is invalid\n' >&2; exit 1; }
|
||||
METADATA_NOTES=$(jq -er '.releaseNotes | strings | select(length > 0 and length <= 4000)' \
|
||||
"$WORK/release-metadata.json") \
|
||||
|| { printf 'Embedded release metadata notes are invalid\n' >&2; exit 1; }
|
||||
METADATA_PUBLISHED_AT=$(jq -er '.publishedAt | strings | select(length > 0)' \
|
||||
"$WORK/release-metadata.json") \
|
||||
|| { printf 'Embedded release metadata publish time is invalid\n' >&2; exit 1; }
|
||||
MANIFEST_PUBLISHED_AT=$(jq -er '.publishedAt | strings | select(length > 0)' release-manifest.json) \
|
||||
|| { printf 'Release manifest publish time is invalid\n' >&2; exit 1; }
|
||||
[ "$METADATA_VERSION" = "$VERSION" ] \
|
||||
&& [ "$METADATA_NOTES" = "$RELEASE_NOTES" ] \
|
||||
&& [ "$METADATA_PUBLISHED_AT" = "$MANIFEST_PUBLISHED_AT" ] \
|
||||
|| { printf 'Embedded release metadata does not match the signed manifest\n' >&2; exit 1; }
|
||||
fi
|
||||
[ "$(tar -xOf "$ARTIFACT" ./VERSION)" = "$VERSION" ] \
|
||||
|| { printf 'Release archive version does not match the manifest\n' >&2; exit 1; }
|
||||
tar -xOf "$ARTIFACT" ./app.jar > "$WORK/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' \
|
||||
"$WORK/META-INF/maven/com.kaidi/finance-system/pom.properties")
|
||||
JAR_IMPLEMENTATION_VERSION=$(sed -n 's/^Implementation-Version: //p' \
|
||||
"$WORK/META-INF/MANIFEST.MF" | tr -d '\r')
|
||||
[ "$JAR_POM_VERSION" = "$VERSION" ] && [ "$JAR_IMPLEMENTATION_VERSION" = "$VERSION" ] \
|
||||
|| { 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() {
|
||||
archive_path=$1
|
||||
@@ -160,11 +217,24 @@ compare_archive_file() {
|
||||
}
|
||||
|
||||
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.service deploy/systemd/kaidi-update.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
|
||||
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" \
|
||||
|| { 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"
|
||||
|
||||
Reference in New Issue
Block a user