Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83f1bf82d6 | ||
|
|
12d78b4c86 | ||
|
|
8e615b895e | ||
|
|
1d45be4e97 | ||
|
|
6b04d8dd0a | ||
|
|
fae8225299 | ||
|
|
de63fcffff | ||
|
|
12d9c46323 | ||
|
|
1230116a6f | ||
|
|
8c6c10eb85 | ||
|
|
227c4b0129 | ||
|
|
8dc1163333 | ||
|
|
1a2d472b5c | ||
|
|
7cf71ba7c3 | ||
|
|
ec939fa1fd | ||
|
|
36f1a8b00d | ||
|
|
69f738465e | ||
|
|
2712c5b8c1 | ||
|
|
5a48993d26 | ||
|
|
0b91e1fc5f |
@@ -10,8 +10,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
# The repository runner advertises Gitea's default ubuntu-latest label.
|
||||
# Using an unavailable custom label leaves every release permanently queued.
|
||||
# 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
|
||||
@@ -20,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:
|
||||
@@ -57,7 +66,11 @@ 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
|
||||
|
||||
@@ -85,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"
|
||||
@@ -100,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.RELEASE_GITEA_TOKEN }}
|
||||
GITEA_SERVER_URL: ${{ github.server_url }}
|
||||
GITEA_REPOSITORY: ${{ github.repository }}
|
||||
GITEA_REF_NAME: ${{ github.ref_name }}
|
||||
GITEA_SHA: ${{ github.sha }}
|
||||
# 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
|
||||
|
||||
@@ -12,6 +12,8 @@ frontend/test-results-dist/
|
||||
*.log
|
||||
.env.local
|
||||
runtime/
|
||||
.runner
|
||||
.runner.lock
|
||||
/dist/
|
||||
test-results/
|
||||
*signing-private.pem
|
||||
|
||||
@@ -8,7 +8,14 @@
|
||||
|
||||
## 本地开发
|
||||
|
||||
本地开发数据库由环境变量显式指定,启动 Java 服务:
|
||||
本地开发数据库由环境变量显式指定,启动 Java 服务。仓库中的 `deploy/compose.yaml` 只是可选的本地开发夹具;
|
||||
需要它时必须显式启用 `local-db` profile,生产安装器不会执行 Compose、创建 MySQL 或安装 MySQL 客户端:
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/compose.yaml --profile local-db up -d
|
||||
```
|
||||
|
||||
随后启动 Java 服务:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
@@ -59,23 +66,25 @@ PostgreSQL 18 兼容工作继续冻结。
|
||||
- **生产机不执行 `git clone`、`git pull` 或远程脚本拼接。** 源码仓库是
|
||||
`https://git.awaioi.com/ERP-Team/kaidi.git`,生产安装和后台更新使用同一仓库生成的公开 Gitea Release。
|
||||
- 安装器先读取 Gitea Release API,再下载 `release-manifest.json`、签名、公钥和压缩包,校验固定公钥指纹、RSA 签名、版本、文件名和 SHA-256;校验失败不会安装。
|
||||
- 后台“获取版本”只查询 Release;“下载”才下载并校验;“立即更新并重启”才备份数据库、切换 `current` 并重启服务。更新器下载的是 Release 制品,不是 Git 工作树。
|
||||
- 后台“获取版本”只查询 Release;“下载”才下载并校验;“立即更新并重启”才切换 `current` 并重启服务。更新器默认不访问数据库;如明确设置 `KAIDI_DB_BACKUP_MODE=mysqldump`,才会调用主机已有的备份工具。更新器下载的是 Release 制品,不是 Git 工作树。
|
||||
- MySQL 是**已有数据库**,应用只通过向导写入的 `DB_URL`、`DB_USERNAME`、`DB_PASSWORD` 连接它;安装器不安装 MySQL、不创建数据库、不修改数据库服务。填写 `127.0.0.1` 表示 MySQL 与 Java 应用在同一台服务器,端口以实际监听端口为准,不默认假设 `3307`。
|
||||
- 安装阶段只校验 JDBC 配置格式,不调用 `mysql`/`mariadb` 客户端,也不执行 `CREATE`、`INSERT`、`UPDATE`、`DELETE` 或 `DROP`。首次向导点击“完成安装”后,应用才会在**专用空 schema**中运行 Flyway 建表并初始化管理员;这是业务初始化,不是安装 MySQL 服务。在线更新默认跳过数据库备份;需要备份时由运维显式设置 `KAIDI_DB_BACKUP_MODE=mysqldump`,更新器只调用已有工具,不会安装数据库。
|
||||
- `KAIDI_APP_PORT` 只决定 Java 回环监听端口,默认 `18080`;反向代理必须指向安装器输出的 `PROXY_TARGET`。安装器不会替你修改 Nginx 或宝塔站点配置。
|
||||
- 发布归档使用无顶层目录的 `tar.gz`,只包含 `app.jar`、`public/`、`ops/`、`VERSION`;打包阶段禁用 macOS 扩展属性并拒绝开发数据库回退值。
|
||||
- 发布归档使用无顶层目录的 `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 位系统无法提供该客户端,应先关闭在线更新或从独立备份机执行数据库备份,不要在更新过程中临时安装数据库。
|
||||
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.34/install.sh | sudo env KAIDI_APP_PORT=18080 bash
|
||||
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.50/install.sh | sudo env KAIDI_APP_PORT=18080 bash
|
||||
```
|
||||
|
||||
该命令只安装程序运行所需的 systemd 单元,自动创建 `kaidi` 用户并检测现有 Java 17(包括
|
||||
@@ -88,37 +97,39 @@ curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-previe
|
||||
随后执行一键清理:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.34/purge.sh \
|
||||
-o /tmp/kaidi-purge.sh \
|
||||
&& printf '%s %s\n' 44dbf10a9540aa9235bb8aff2dec603f1febbd45072cd8c4f6c40b25a6127801 /tmp/kaidi-purge.sh \
|
||||
| sha256sum -c - \
|
||||
&& sudo env KAIDI_PURGE_CONFIRM=DELETE_LOCAL_KAIDI_INSTALLATION bash /tmp/kaidi-purge.sh \
|
||||
&& rm -f /tmp/kaidi-purge.sh
|
||||
curl -fsSL 'https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.50/purge.sh' | sudo env KAIDI_PURGE_CONFIRM=DELETE_LOCAL_KAIDI_INSTALLATION bash
|
||||
```
|
||||
|
||||
上面是一条完整命令:脚本通过管道直接交给 root 执行,不创建临时安装文件,避免终端自动换行导致 `-o` 参数丢失。
|
||||
如果需要连同本地恢复备份一起删除(确认不再需要回滚后再执行),在同一条命令中增加
|
||||
`KAIDI_PURGE_DELETE_BACKUP=true`。卸载前必须先停止并删除宝塔中的 `kaidi-finance` Java 项目;宝塔的项目元数据、
|
||||
Nginx/反向代理和外部 MySQL 属于外部资源,卸载程序不会误删它们。
|
||||
|
||||
清理脚本会先停止 systemd 和遗留 Kaidi 进程,将旧配置、文件存储、安装码以及已有数据库备份归档到
|
||||
`/root/kaidi-reinstall-backups/`,再删除 `/opt/kaidi`、`/www/wwwroot/kaidi`、`/etc/kaidi`、
|
||||
`/var/lib/kaidi`、`/var/lib/kaidi-update`、`/var/log/kaidi` 和三个 systemd 单元。恢复包权限固定为
|
||||
`0600`,确认新安装及数据无误后再由 root 删除。脚本不删除外部 MySQL、系统 Java、Nginx 或宝塔站点配置;
|
||||
新安装必须连接一个新的空 MySQL 数据库,旧数据库保留用于回滚。
|
||||
|
||||
### Preview.31 直链与宝塔手动部署
|
||||
### Preview.50 直链与宝塔手动部署
|
||||
|
||||
本版不使用一键安装脚本。发布物是一个不带顶层目录的压缩包,下载后直接解压到版本目录,再由宝塔面板创建
|
||||
本版提供 systemd 一键安装脚本和宝塔手动部署两种互斥方式。手动部署使用一个不带顶层目录的压缩包,下载后直接解压到版本目录,再由宝塔面板创建
|
||||
Spring Boot 项目。数据库、JDK、Nginx 和宝塔本身都由运维人员准备;程序不会自动安装 MySQL 或任何第三方服务。
|
||||
|
||||
下载地址:
|
||||
|
||||
`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.34/kaidi-finance-1.0.0-preview.34.tar.gz`
|
||||
`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.50/kaidi-finance-1.0.0-preview.50.tar.gz`
|
||||
|
||||
校验文件:`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.34/SHA256SUMS`
|
||||
校验文件:`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.50/SHA256SUMS`
|
||||
|
||||
服务器要求:Linux + systemd、Java 17(宝塔项目选择 JDK 17)、可访问外部 MySQL 8.4.x;32 位 Linux 需要宿主机
|
||||
服务器要求:Linux、Java 17(宝塔项目选择 JDK 17)、可访问外部 MySQL 8.4.x;**systemd 一键安装**还需要
|
||||
systemd/systemd-analyze,**宝塔手动部署**不要求 systemd。32 位 Linux 需要宿主机
|
||||
已经提供可运行的 32 位 Java 17。程序只连接已有数据库,不安装数据库客户端或数据库服务。初始化只需要
|
||||
`bash`、`openssl`、`sha256sum` 和基础 Linux 工具;选择 systemd 在线更新前,另行准备 `curl`、`jq`、`flock`、`gzip`、`runuser`
|
||||
以及与外部 MySQL 兼容的 `mysqldump`。安装器和更新器会优先使用 PATH 中的工具,也会探测宝塔常见的
|
||||
`/www/server/mysql/bin/` 路径;如工具安装在其他位置,可分别设置 `KAIDI_MYSQL_CLIENT` 和
|
||||
`bash`、`openssl`、`sha256sum` 和基础 Linux 工具;systemd 在线更新默认不需要数据库客户端。只有将
|
||||
`KAIDI_DB_BACKUP_MODE` 设为 `mysqldump` 时,才需要 `curl`、`jq`、`flock`、`gzip`、`runuser` 和兼容的
|
||||
`mysqldump`;安装器和更新器会优先使用 PATH 中的工具,也会探测宝塔常见的 `/www/server/mysql/bin/` 路径;如备份工具安装在其他位置,可设置
|
||||
`KAIDI_MYSQLDUMP_BIN`,程序不会替你安装这些依赖。
|
||||
更新器默认只下载、验签、切换和健康检查,不调用 Docker、不进入容器,也不创建或管理 MySQL 服务。
|
||||
|
||||
#### 1. 下载、校验、解压
|
||||
|
||||
@@ -126,7 +137,7 @@ Spring Boot 项目。数据库、JDK、Nginx 和宝塔本身都由运维人员
|
||||
必须直接位于 `RELEASE_ROOT`,不能再嵌套一层目录。
|
||||
|
||||
```bash
|
||||
VERSION=1.0.0-preview.34
|
||||
VERSION=1.0.0-preview.50
|
||||
APP_ROOT=/www/wwwroot/kaidi
|
||||
RELEASE_ROOT="$APP_ROOT/releases/$VERSION"
|
||||
sudo install -d -m 0755 "$RELEASE_ROOT"
|
||||
@@ -150,6 +161,9 @@ sudo "$RELEASE_ROOT/ops/baota-init.sh" 18080
|
||||
sudo cat /root/kaidi-first-login.txt
|
||||
```
|
||||
|
||||
初始化不会安装或启用 `kaidi-update.service`/`kaidi-update.path`,避免宝塔守护进程与
|
||||
systemd 更新器同时管理同一应用;宝塔模式只支持手动替换 Release,后台在线更新请使用 systemd 安装模式。
|
||||
|
||||
初始化失败会回滚本次写入的配置、unit 和 `current` 链接,可以直接修正原因后重试;已经存在正式安装时不要重复执行,先按“彻底清理旧安装”流程处理。
|
||||
|
||||
#### 3. 宝塔 Spring Boot 项目字段
|
||||
@@ -214,29 +228,49 @@ curl -fsS http://127.0.0.1:18080/ | head
|
||||
不要在宝塔项目仍运行时点击“系统治理 → 系统更新”,也不要让宝塔守护和 `kaidi-finance.service` 同时管理同一端口。
|
||||
需要后台点击“获取版本 → 下载 → 立即更新并重启”的稳定流程时,使用上一节的 systemd 一键安装方式。
|
||||
|
||||
手动升级前请先由运维人员完成外部 MySQL 备份;程序不会安装 MySQL 或客户端,也不会修改反向代理配置。
|
||||
手动升级由运维人员决定是否先做外部 MySQL 备份;程序不会安装 MySQL 或客户端,也不会修改反向代理配置。
|
||||
|
||||
后续版本仍按同样方式直接下载并解压到新的 `releases/<VERSION>`,保留可回滚的旧目录。
|
||||
|
||||
### Linux 32 位
|
||||
|
||||
压缩包本身不绑定 CPU 架构,关键是宿主机提供 glibc、systemd 和可运行的 32 位 Java 17。32 位主机不能在本机运行 64 位 JDK,也不应尝试在本机安装
|
||||
MySQL 8.4;提前准备外部 MySQL,完成上述向导即可。选择 systemd 在线更新前,还必须确认宿主机能执行与数据库版本兼容的
|
||||
`mysqldump`;宝塔手动模式只做手动制品替换和人工数据库备份。
|
||||
MySQL 8.4;提前准备外部 MySQL,完成上述向导即可。systemd 在线更新默认不访问数据库;只有显式启用
|
||||
`KAIDI_DB_BACKUP_MODE=mysqldump` 时才需要确认宿主机能执行与数据库版本兼容的工具。宝塔手动模式只做手动制品替换和人工数据库备份。
|
||||
|
||||
### 停用并移除程序
|
||||
### 一键卸载
|
||||
|
||||
以下命令移除应用程序和服务,但保留 `/var/lib/kaidi`、`/var/lib/kaidi-update`、`/etc/kaidi` 以及数据库,
|
||||
便于审计、备份或重新安装。确认数据备份前不要删除这些保留目录或外部 MySQL 数据。
|
||||
反向代理由运维人员独立管理,停用程序不会修改其配置。
|
||||
`purge.sh` 就是本项目的卸载程序:它会停止 Kaidi 进程、移除 systemd 单元、删除本地程序/配置/运行状态、
|
||||
清理受管临时压缩包,并删除本次安装创建的 `kaidi` 服务账号;不会触碰外部 MySQL、Nginx、反向代理或系统 Java。
|
||||
默认先把本地配置和运行数据保存到 root-only 恢复包,再删除受管路径。确认不需要回滚时,可在同一条命令中设置
|
||||
`KAIDI_PURGE_DELETE_BACKUP=true`,实现本地受管文件和恢复包一并清除:
|
||||
|
||||
```bash
|
||||
sudo systemctl disable --now kaidi-update.path kaidi-update.service kaidi-finance.service
|
||||
sudo rm -f /etc/systemd/system/kaidi-finance.service /etc/systemd/system/kaidi-update.service /etc/systemd/system/kaidi-update.path
|
||||
sudo systemctl daemon-reload
|
||||
sudo rm -rf /opt/kaidi
|
||||
curl -fsSL 'https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.50/purge.sh' | sudo env KAIDI_PURGE_CONFIRM=DELETE_LOCAL_KAIDI_INSTALLATION KAIDI_PURGE_DELETE_BACKUP=true bash
|
||||
```
|
||||
|
||||
执行前先在宝塔停止并删除 `kaidi-finance` Java 项目;宝塔面板元数据属于外部资源,必须由面板先停用,
|
||||
否则守护进程会重新拉起 Java,卸载程序会明确报出原因而不误删其他服务。
|
||||
|
||||
## 全状态演示数据
|
||||
|
||||
线上数据库为空时,可导入 [`deploy/demo-data-mysql8-v073.sql`](deploy/demo-data-mysql8-v073.sql)。该文件按
|
||||
MySQL 8.4 和 Flyway **兼容版本 V073/V074** 编写,并已在 Preview.49 的完整迁移库中验证。它会生成 14 类 OA 表单、
|
||||
项目阶段、主数据、合同成本、收款发票、付款、记账、档案借阅、报表导出、幂等和审计等演示记录,覆盖主要正常、
|
||||
待办、退回、驳回、作废、失败、完成和冻结状态。
|
||||
|
||||
导入前先备份现有数据库,确认 `/setup` 已完成且 `flyway_schema_history` 当前版本为 `073` 或 `074`。推荐使用 MySQL
|
||||
命令行客户端执行;命令只建立数据库连接,不安装、不启动也不修改 MySQL 服务:
|
||||
|
||||
```bash
|
||||
mysql --default-character-set=utf8mb4 -h HOST -P PORT -u USER -p DATABASE < deploy/demo-data-mysql8-v073.sql
|
||||
```
|
||||
|
||||
脚本使用单个事务,导入成功后输出各模块数量和状态摘要;完成标记会阻止同一数据库重复导入。它不会创建新的
|
||||
可登录账号,只会创建 3 个禁用的演示操作人。附件和导出文件只有状态元数据,没有真实文件字节;银行账户也只
|
||||
提供脱敏展示值,因此文件下载和敏感账号解密不属于本演示数据的验证范围。未来迁移高于 V073 时,应使用与新
|
||||
迁移版本匹配的数据文件,不要绕过前置检查强行导入。
|
||||
|
||||
## Release 与在线更新
|
||||
|
||||
首次建立发布仓库时生成一次签名密钥:
|
||||
@@ -254,11 +288,15 @@ Actions 页面显示 “No matching online runner”,先启动并注册该标
|
||||
|
||||
工作流先建立不可见草稿,再显式上传并核对 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.34
|
||||
git push origin v1.0.0-preview.34
|
||||
git tag v1.0.0-preview.50
|
||||
git push origin v1.0.0-preview.50
|
||||
```
|
||||
|
||||
在线更新仍使用独立的 TDesign 页面:系统管理员进入“系统治理 → 系统更新”。更新源由 root 在
|
||||
@@ -273,11 +311,11 @@ git push origin v1.0.0-preview.34
|
||||
2. 发现新版本后显示“立即更新”;管理员点击后才排队 `DOWNLOAD`。更新器下载 manifest、签名和应用包,执行 RSA、SHA-256、版本、
|
||||
文件名和压缩包路径校验,通过后缓存到 `/var/lib/kaidi-update/cache/<version>`,业务服务继续运行。
|
||||
3. 页面实时轮询并依次显示下载已排队、下载中、校验中和 `READY/下载完成`;只有下载完成后才显示“立即更新并重启”。
|
||||
4. 管理员点击“立即更新并重启”,更新器预先调用宿主机已有的 `mysqldump`,备份权限为 `0600`,默认保留最近 5 份;程序不会安装 MySQL 或客户端。
|
||||
4. 管理员点击“立即更新并重启”,更新器默认不访问数据库;如运维已将 `KAIDI_DB_BACKUP_MODE=mysqldump` 写入 `/etc/kaidi/update.env`,才会调用宿主机已有的 `mysqldump`,备份权限为 `0600`,默认保留最近 5 份。程序不会安装 MySQL 或客户端。
|
||||
5. 更新器安装并保护新版本目录,原子切换 `/opt/kaidi/current`(宝塔手动部署才使用
|
||||
`/www/wwwroot/kaidi/current`),停止并重启 systemd 管理的应用服务。
|
||||
6. 健康端点、静态首页和运行版本全部通过后,页面等待 10 秒自动刷新;期间断线或命令响应丢失由前端状态重同步恢复。任一检查失败则切回上一应用版本,
|
||||
保留数据库备份和事务证据供人工处理。
|
||||
如果启用了 `mysqldump` 模式则保留数据库备份;否则保留事务证据供人工处理。
|
||||
|
||||
忙碌期间检查、下载和安装按钮保持禁用,防止重复请求;10 秒只用于成功后的页面刷新,不延迟服务端切换。数据库迁移必须至少保持一个版本向后兼容。
|
||||
查看状态和日志:
|
||||
@@ -310,7 +348,7 @@ cat /var/lib/kaidi-update/status.json
|
||||
```bash
|
||||
KAIDI_RELEASE_SIGNING_KEY=/secure/release-signing-private.pem \
|
||||
KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \
|
||||
./scripts/package-release.sh 1.0.0-preview.34
|
||||
./scripts/package-release.sh 1.0.0-preview.50
|
||||
KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \
|
||||
./scripts/verify-release.sh dist/release
|
||||
```
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.kaidi.finance.dashboard.api;
|
||||
|
||||
import com.kaidi.finance.dashboard.application.DashboardApplicationService;
|
||||
import com.kaidi.finance.shared.api.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/dashboard")
|
||||
public class DashboardController {
|
||||
|
||||
private final DashboardApplicationService dashboardService;
|
||||
|
||||
public DashboardController(DashboardApplicationService dashboardService) {
|
||||
this.dashboardService = dashboardService;
|
||||
}
|
||||
|
||||
@GetMapping("/overview")
|
||||
@Operation(operationId = "getDashboardOverview", summary = "查询当前身份可见的系统仪表盘")
|
||||
@PreAuthorize("@authorizationService.hasPermission('dashboard:overview:view')")
|
||||
public ApiResponse<DashboardOverviewView> overview(
|
||||
@RequestParam(defaultValue = "CNY") String currency
|
||||
) {
|
||||
return ApiResponse.ok(dashboardService.overview(currency));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.kaidi.finance.dashboard.api;
|
||||
|
||||
public record DashboardDistributionView(
|
||||
String code,
|
||||
String label,
|
||||
long value,
|
||||
boolean available,
|
||||
String targetRoute
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.kaidi.finance.dashboard.api;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record DashboardGeographyView(
|
||||
boolean available,
|
||||
String message,
|
||||
List<DashboardRegionView> regions
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.kaidi.finance.dashboard.api;
|
||||
|
||||
public record DashboardMetricView(
|
||||
String code,
|
||||
String label,
|
||||
String kind,
|
||||
String value,
|
||||
String unit,
|
||||
boolean available,
|
||||
String targetRoute
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.kaidi.finance.dashboard.api;
|
||||
|
||||
import com.kaidi.finance.workbench.api.WorkbenchActivityView;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public record DashboardOverviewView(
|
||||
String title,
|
||||
Instant refreshedAt,
|
||||
String currency,
|
||||
DashboardSystemView system,
|
||||
List<DashboardMetricView> metrics,
|
||||
DashboardTrendView trend,
|
||||
List<DashboardDistributionView> lifecycle,
|
||||
List<DashboardRiskView> risks,
|
||||
DashboardGeographyView geography,
|
||||
List<WorkbenchActivityView> activities
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.kaidi.finance.dashboard.api;
|
||||
|
||||
public record DashboardRegionView(
|
||||
String regionCode,
|
||||
String regionName,
|
||||
long projectCount,
|
||||
String amount,
|
||||
String currency
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.kaidi.finance.dashboard.api;
|
||||
|
||||
public record DashboardRiskView(
|
||||
String code,
|
||||
String label,
|
||||
String severity,
|
||||
long count,
|
||||
String detail,
|
||||
boolean available,
|
||||
String targetRoute
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.kaidi.finance.dashboard.api;
|
||||
|
||||
public record DashboardServiceStatusView(
|
||||
String code,
|
||||
String label,
|
||||
String status,
|
||||
String detail
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.kaidi.finance.dashboard.api;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record DashboardSystemView(
|
||||
String overallStatus,
|
||||
String currentVersion,
|
||||
long uptimeSeconds,
|
||||
List<DashboardServiceStatusView> services
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.kaidi.finance.dashboard.api;
|
||||
|
||||
public record DashboardTrendPointView(
|
||||
String period,
|
||||
String receivedAmount,
|
||||
String paidAmount,
|
||||
String invoicedAmount
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.kaidi.finance.dashboard.api;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record DashboardTrendView(
|
||||
String currency,
|
||||
boolean receiptsAvailable,
|
||||
boolean paymentsAvailable,
|
||||
boolean invoicesAvailable,
|
||||
List<DashboardTrendPointView> points
|
||||
) {
|
||||
}
|
||||
+281
@@ -0,0 +1,281 @@
|
||||
package com.kaidi.finance.dashboard.application;
|
||||
|
||||
import com.kaidi.finance.dashboard.api.DashboardDistributionView;
|
||||
import com.kaidi.finance.dashboard.api.DashboardGeographyView;
|
||||
import com.kaidi.finance.dashboard.api.DashboardMetricView;
|
||||
import com.kaidi.finance.dashboard.api.DashboardOverviewView;
|
||||
import com.kaidi.finance.dashboard.api.DashboardRiskView;
|
||||
import com.kaidi.finance.dashboard.api.DashboardServiceStatusView;
|
||||
import com.kaidi.finance.dashboard.api.DashboardSystemView;
|
||||
import com.kaidi.finance.dashboard.api.DashboardTrendPointView;
|
||||
import com.kaidi.finance.dashboard.api.DashboardTrendView;
|
||||
import com.kaidi.finance.dashboard.infrastructure.DashboardMapper;
|
||||
import com.kaidi.finance.iam.domain.FinancePrincipal;
|
||||
import com.kaidi.finance.shared.api.BusinessException;
|
||||
import com.kaidi.finance.shared.api.ErrorCode;
|
||||
import com.kaidi.finance.shared.file.FileStorageProperties;
|
||||
import com.kaidi.finance.shared.security.AuthorizationService;
|
||||
import com.kaidi.finance.shared.security.IdentityContext;
|
||||
import com.kaidi.finance.update.application.SystemUpdateProperties;
|
||||
import com.kaidi.finance.workbench.api.WorkbenchActivityView;
|
||||
import com.kaidi.finance.workbench.infrastructure.WorkbenchMapper;
|
||||
import java.lang.management.ManagementFactory;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.InvalidPathException;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.YearMonth;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.Supplier;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class DashboardApplicationService {
|
||||
|
||||
private static final Set<String> SUPPORTED_CURRENCIES = Set.of("CNY", "USD", "EUR", "HKD", "JPY", "GBP");
|
||||
private static final List<String> STAGES = List.of(
|
||||
"INITIATION", "CONTRACTING", "EXECUTION", "ACCEPTANCE", "SETTLEMENT", "CLOSED"
|
||||
);
|
||||
|
||||
private final DashboardMapper mapper;
|
||||
private final WorkbenchMapper workbenchMapper;
|
||||
private final IdentityContext identityContext;
|
||||
private final AuthorizationService authorizationService;
|
||||
private final SystemUpdateProperties updateProperties;
|
||||
private final FileStorageProperties storageProperties;
|
||||
|
||||
public DashboardApplicationService(
|
||||
DashboardMapper mapper,
|
||||
WorkbenchMapper workbenchMapper,
|
||||
IdentityContext identityContext,
|
||||
AuthorizationService authorizationService,
|
||||
SystemUpdateProperties updateProperties,
|
||||
FileStorageProperties storageProperties
|
||||
) {
|
||||
this.mapper = mapper;
|
||||
this.workbenchMapper = workbenchMapper;
|
||||
this.identityContext = identityContext;
|
||||
this.authorizationService = authorizationService;
|
||||
this.updateProperties = updateProperties;
|
||||
this.storageProperties = storageProperties;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public DashboardOverviewView overview(String requestedCurrency) {
|
||||
Set<String> permissions = authorizationService.activePermissions();
|
||||
if (!permissions.contains("dashboard:overview:view")) {
|
||||
authorizationService.requirePermission("dashboard:overview:view");
|
||||
}
|
||||
FinancePrincipal actor = identityContext.requirePrincipal();
|
||||
String role = identityContext.requireActiveRole();
|
||||
String currency = normalizeCurrency(requestedCurrency);
|
||||
|
||||
boolean canProjects = permissions.contains("project:project:view");
|
||||
boolean canContracts = permissions.contains("masterdata:contract:view");
|
||||
boolean canReceipts = permissions.contains("receivable:receipt:view");
|
||||
boolean canPayments = permissions.contains("payment:request:view");
|
||||
boolean canInvoices = permissions.contains("receivable:invoice:view");
|
||||
boolean canWorkflow = permissions.contains("workflow:task:view");
|
||||
boolean canRisks = permissions.contains("project:risk-flag:view");
|
||||
boolean canArchives = permissions.contains("archive:package:view");
|
||||
boolean canFiles = permissions.contains("archive:file:view");
|
||||
|
||||
long projectCount = count(canProjects, () -> mapper.countProjects(actor.userId(), role));
|
||||
BigDecimal contractAmount = amount(canContracts,
|
||||
() -> mapper.sumContracts(actor.userId(), role, currency));
|
||||
BigDecimal receiptAmount = amount(canReceipts,
|
||||
() -> mapper.sumReceipts(actor.userId(), role, currency));
|
||||
BigDecimal paymentAmount = amount(canPayments,
|
||||
() -> mapper.sumPayments(actor.userId(), role, currency));
|
||||
long pendingTasks = count(canWorkflow, () -> workbenchMapper.countPendingTasks(actor.userId(), role));
|
||||
long activeRisks = count(canRisks, () -> mapper.countActiveRisks(actor.userId(), role));
|
||||
|
||||
List<DashboardMetricView> metrics = List.of(
|
||||
countMetric("ACTIVE_PROJECTS", "进行中项目", projectCount, canProjects, "/projects"),
|
||||
moneyMetric("CONTRACT_AMOUNT", "合同总额", contractAmount, currency, canContracts,
|
||||
"/finance/contracts-costs"),
|
||||
moneyMetric("RECEIPT_AMOUNT", "累计收款", receiptAmount, currency, canReceipts,
|
||||
"/finance/receipts-invoices?resource=receipts"),
|
||||
moneyMetric("PAYMENT_AMOUNT", "累计付款", paymentAmount, currency, canPayments,
|
||||
"/finance/payments"),
|
||||
countMetric("PENDING_TASKS", "待办审批", pendingTasks, canWorkflow, "/tasks?view=TODO"),
|
||||
countMetric("ACTIVE_RISKS", "风险事项", activeRisks, canRisks, "/projects?riskStatus=ACTIVE")
|
||||
);
|
||||
|
||||
List<DashboardRiskView> risks = List.of(
|
||||
risk("PROJECT_RISK", "项目风险", "DANGER", activeRisks, "当前权限范围内的有效风险标记",
|
||||
canRisks, "/projects?riskStatus=ACTIVE"),
|
||||
risk("OVERDUE_TASK", "审批超时", "DANGER",
|
||||
count(canWorkflow, () -> workbenchMapper.countOverdueTasks(actor.userId(), role)),
|
||||
"超过处理时限的审批任务", canWorkflow, "/tasks?view=TODO"),
|
||||
risk("PAYMENT_BLOCK", "付款阻断", "WARNING",
|
||||
count(canPayments, () -> mapper.countPaymentBlocks(actor.userId(), role)),
|
||||
"付款检查中仍未解除的阻断项", canPayments,
|
||||
"/finance/payments?tab=finance-check&riskCode=BLOCK"),
|
||||
risk("ARCHIVE_MISSING", "档案缺件", "WARNING",
|
||||
count(canArchives, () -> workbenchMapper.countArchiveMissing(actor.userId(), role)),
|
||||
"待补充的归档材料", canArchives, "/archives/projects?completeness=INCOMPLETE"),
|
||||
risk("QUARANTINED_FILE", "隔离文件", "INFO",
|
||||
count(canFiles, () -> workbenchMapper.countFiles("QUARANTINED", actor.userId(), role)),
|
||||
"等待处理的隔离文件", canFiles, "/archives/files?scanStatus=QUARANTINED")
|
||||
);
|
||||
|
||||
List<WorkbenchActivityView> activities = workbenchMapper.listRecentActivities(actor.publicId(), 8).stream()
|
||||
.map(row -> new WorkbenchActivityView(row.publicId(), row.actionCode(), row.objectType(),
|
||||
row.objectPublicId(), row.title(), row.resultCode(), instant(row.occurredAt()),
|
||||
activityRoute(row.objectType(), row.objectPublicId())))
|
||||
.toList();
|
||||
|
||||
return new DashboardOverviewView(
|
||||
"系统经营仪表盘",
|
||||
Instant.now(),
|
||||
currency,
|
||||
systemView(),
|
||||
metrics,
|
||||
trend(actor.userId(), role, currency, canReceipts, canPayments, canInvoices),
|
||||
lifecycle(actor.userId(), role, canProjects),
|
||||
risks,
|
||||
new DashboardGeographyView(false, "项目尚未配置统一的省市维度,当前以项目阶段分布替代地图。", List.of()),
|
||||
activities
|
||||
);
|
||||
}
|
||||
|
||||
private DashboardTrendView trend(long userId, String role, String currency,
|
||||
boolean canReceipts, boolean canPayments, boolean canInvoices) {
|
||||
YearMonth firstMonth = YearMonth.now(ZoneOffset.UTC).minusMonths(5);
|
||||
LocalDate fromDate = firstMonth.atDay(1);
|
||||
Map<String, BigDecimal> receipts = amountMap(canReceipts
|
||||
? mapper.receiptTrend(userId, role, currency, fromDate) : List.of());
|
||||
Map<String, BigDecimal> payments = amountMap(canPayments
|
||||
? mapper.paymentTrend(userId, role, currency, fromDate) : List.of());
|
||||
Map<String, BigDecimal> invoices = amountMap(canInvoices
|
||||
? mapper.invoiceTrend(userId, role, currency, fromDate) : List.of());
|
||||
List<DashboardTrendPointView> points = new ArrayList<>();
|
||||
for (int index = 0; index < 6; index++) {
|
||||
String period = firstMonth.plusMonths(index).toString();
|
||||
points.add(new DashboardTrendPointView(period, plain(receipts.get(period)),
|
||||
plain(payments.get(period)), plain(invoices.get(period))));
|
||||
}
|
||||
return new DashboardTrendView(currency, canReceipts, canPayments, canInvoices, points);
|
||||
}
|
||||
|
||||
private List<DashboardDistributionView> lifecycle(long userId, String role, boolean available) {
|
||||
Map<String, Long> counts = new LinkedHashMap<>();
|
||||
if (available) {
|
||||
mapper.countProjectStages(userId, role).forEach(row -> counts.put(row.code(), row.itemCount()));
|
||||
}
|
||||
return STAGES.stream()
|
||||
.map(stage -> new DashboardDistributionView(stage, stageLabel(stage), counts.getOrDefault(stage, 0L),
|
||||
available, "/projects?stage=" + stage))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private DashboardSystemView systemView() {
|
||||
List<DashboardServiceStatusView> services = new ArrayList<>();
|
||||
services.add(new DashboardServiceStatusView("APPLICATION", "应用服务", "UP", "服务运行正常"));
|
||||
services.add(new DashboardServiceStatusView("DATABASE", "数据库连接", "UP", "业务数据库连接正常"));
|
||||
String storageStatus = storageStatus();
|
||||
services.add(new DashboardServiceStatusView("FILE_STORAGE", "文件存储", storageStatus,
|
||||
"UP".equals(storageStatus) ? "存储目录可读写" : "存储目录待初始化或当前不可写"));
|
||||
services.add(new DashboardServiceStatusView("UPDATE", "更新服务", updateProperties.enabled() ? "UP" : "DISABLED",
|
||||
updateProperties.enabled() ? "在线更新服务已启用" : "在线更新服务未启用"));
|
||||
String overall = services.stream().anyMatch(item -> "DOWN".equals(item.status())) ? "DOWN"
|
||||
: services.stream().anyMatch(item -> "WARNING".equals(item.status())) ? "WARNING" : "UP";
|
||||
return new DashboardSystemView(overall, updateProperties.currentVersion(),
|
||||
ManagementFactory.getRuntimeMXBean().getUptime() / 1000, List.copyOf(services));
|
||||
}
|
||||
|
||||
private String storageStatus() {
|
||||
try {
|
||||
Path path = storageProperties.rootPath();
|
||||
return Files.isDirectory(path) && Files.isReadable(path) && Files.isWritable(path) ? "UP" : "WARNING";
|
||||
} catch (InvalidPathException | NullPointerException exception) {
|
||||
return "WARNING";
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, BigDecimal> amountMap(List<DashboardMapper.AmountRow> rows) {
|
||||
Map<String, BigDecimal> values = new LinkedHashMap<>();
|
||||
rows.forEach(row -> values.merge(row.period(), value(row.amount()), BigDecimal::add));
|
||||
return values;
|
||||
}
|
||||
|
||||
private DashboardMetricView countMetric(String code, String label, long value, boolean available,
|
||||
String route) {
|
||||
return new DashboardMetricView(code, label, "COUNT", available ? Long.toString(value) : "0", "项",
|
||||
available, available ? route : null);
|
||||
}
|
||||
|
||||
private DashboardMetricView moneyMetric(String code, String label, BigDecimal value, String currency,
|
||||
boolean available, String route) {
|
||||
return new DashboardMetricView(code, label, "MONEY", available ? plain(value) : "0", currency,
|
||||
available, available ? route : null);
|
||||
}
|
||||
|
||||
private DashboardRiskView risk(String code, String label, String severity, long count, String detail,
|
||||
boolean available, String route) {
|
||||
return new DashboardRiskView(code, label, severity, count, detail, available, available ? route : null);
|
||||
}
|
||||
|
||||
private long count(boolean available, Supplier<Long> supplier) {
|
||||
return available ? supplier.get() : 0;
|
||||
}
|
||||
|
||||
private BigDecimal amount(boolean available, Supplier<BigDecimal> supplier) {
|
||||
return available ? value(supplier.get()) : BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
private BigDecimal value(BigDecimal value) {
|
||||
return value == null ? BigDecimal.ZERO : value;
|
||||
}
|
||||
|
||||
private String plain(BigDecimal value) {
|
||||
return value(value).stripTrailingZeros().toPlainString();
|
||||
}
|
||||
|
||||
private String normalizeCurrency(String requested) {
|
||||
String currency = requested == null ? "CNY" : requested.trim().toUpperCase(Locale.ROOT);
|
||||
if (!SUPPORTED_CURRENCIES.contains(currency)) {
|
||||
throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED,
|
||||
"币种仅支持 CNY、USD、EUR、HKD、JPY 或 GBP");
|
||||
}
|
||||
return currency;
|
||||
}
|
||||
|
||||
private String stageLabel(String stage) {
|
||||
return switch (stage) {
|
||||
case "INITIATION" -> "立项";
|
||||
case "CONTRACTING" -> "合同签订";
|
||||
case "EXECUTION" -> "执行中";
|
||||
case "ACCEPTANCE" -> "验收";
|
||||
case "SETTLEMENT" -> "结算";
|
||||
case "CLOSED" -> "已关闭";
|
||||
default -> stage;
|
||||
};
|
||||
}
|
||||
|
||||
private String activityRoute(String objectType, String objectPublicId) {
|
||||
if (objectPublicId == null || objectPublicId.isBlank()) return "/reports";
|
||||
return switch (objectType) {
|
||||
case "PROJECT" -> "/projects/%s".formatted(objectPublicId);
|
||||
case "PAYMENT", "PAYMENT_REQUEST" -> "/finance/payments?keyword=%s".formatted(objectPublicId);
|
||||
case "VOUCHER", "ACCOUNTING" -> "/finance/accounting?keyword=%s".formatted(objectPublicId);
|
||||
case "ARCHIVE_PACKAGE", "ARCHIVE" -> "/archives/projects";
|
||||
default -> "/reports";
|
||||
};
|
||||
}
|
||||
|
||||
private Instant instant(java.time.LocalDateTime value) {
|
||||
return value == null ? null : value.toInstant(ZoneOffset.UTC);
|
||||
}
|
||||
}
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
package com.kaidi.finance.dashboard.infrastructure;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@org.apache.ibatis.annotations.Mapper
|
||||
public interface DashboardMapper {
|
||||
|
||||
@Select("""
|
||||
SELECT COUNT(*)
|
||||
FROM md_project project
|
||||
JOIN md_company company ON company.id = project.company_id
|
||||
WHERE project.status IN ('ACTIVE', 'SUSPENDED')
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM iam_scope scope
|
||||
JOIN iam_role role ON role.id = scope.role_id
|
||||
JOIN iam_permission permission ON permission.id = scope.permission_id
|
||||
WHERE scope.user_id = #{userId} AND role.code = #{roleCode}
|
||||
AND permission.code = 'project:project:view' AND scope.status = 'ACTIVE'
|
||||
AND scope.valid_from <= UTC_TIMESTAMP(3)
|
||||
AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3))
|
||||
AND (scope.scope_type = 'GLOBAL'
|
||||
OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id)
|
||||
OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id))
|
||||
)
|
||||
""")
|
||||
long countProjects(@Param("userId") long userId, @Param("roleCode") String roleCode);
|
||||
|
||||
@Select("""
|
||||
SELECT COALESCE(SUM(contract.original_amount + contract.approved_change_amount), 0)
|
||||
FROM md_contract contract
|
||||
JOIN md_project project ON project.id = contract.project_id
|
||||
JOIN md_company company ON company.id = contract.company_id
|
||||
WHERE contract.status NOT IN ('VOID', 'DISABLED')
|
||||
AND contract.currency = #{currency}
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM iam_scope scope
|
||||
JOIN iam_role role ON role.id = scope.role_id
|
||||
JOIN iam_permission permission ON permission.id = scope.permission_id
|
||||
WHERE scope.user_id = #{userId} AND role.code = #{roleCode}
|
||||
AND permission.code = 'masterdata:contract:view' AND scope.status = 'ACTIVE'
|
||||
AND scope.valid_from <= UTC_TIMESTAMP(3)
|
||||
AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3))
|
||||
AND (scope.scope_type = 'GLOBAL'
|
||||
OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id)
|
||||
OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id))
|
||||
)
|
||||
""")
|
||||
BigDecimal sumContracts(@Param("userId") long userId, @Param("roleCode") String roleCode,
|
||||
@Param("currency") String currency);
|
||||
|
||||
@Select("""
|
||||
SELECT COALESCE(SUM(receipt.amount), 0)
|
||||
FROM fin_receipt receipt
|
||||
JOIN md_company company ON company.id = receipt.company_id
|
||||
LEFT JOIN md_project project ON project.id = receipt.project_id
|
||||
WHERE receipt.status NOT IN ('DRAFT', 'VOID')
|
||||
AND receipt.currency = #{currency}
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM iam_scope scope
|
||||
JOIN iam_role role ON role.id = scope.role_id
|
||||
JOIN iam_permission permission ON permission.id = scope.permission_id
|
||||
WHERE scope.user_id = #{userId} AND role.code = #{roleCode}
|
||||
AND permission.code = 'receivable:receipt:view' AND scope.status = 'ACTIVE'
|
||||
AND scope.valid_from <= UTC_TIMESTAMP(3)
|
||||
AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3))
|
||||
AND (scope.scope_type = 'GLOBAL'
|
||||
OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id)
|
||||
OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id))
|
||||
)
|
||||
""")
|
||||
BigDecimal sumReceipts(@Param("userId") long userId, @Param("roleCode") String roleCode,
|
||||
@Param("currency") String currency);
|
||||
|
||||
@Select("""
|
||||
SELECT COALESCE(SUM(COALESCE(payment.approved_amount, payment.requested_amount)), 0)
|
||||
FROM fin_payment_request payment
|
||||
JOIN md_company company ON company.id = payment.company_id
|
||||
JOIN md_project project ON project.id = payment.project_id
|
||||
WHERE payment.status = 'PAID'
|
||||
AND payment.currency = #{currency}
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM iam_scope scope
|
||||
JOIN iam_role role ON role.id = scope.role_id
|
||||
JOIN iam_permission permission ON permission.id = scope.permission_id
|
||||
WHERE scope.user_id = #{userId} AND role.code = #{roleCode}
|
||||
AND permission.code = 'payment:request:view' AND scope.status = 'ACTIVE'
|
||||
AND scope.valid_from <= UTC_TIMESTAMP(3)
|
||||
AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3))
|
||||
AND (scope.scope_type = 'GLOBAL'
|
||||
OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id)
|
||||
OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id))
|
||||
)
|
||||
""")
|
||||
BigDecimal sumPayments(@Param("userId") long userId, @Param("roleCode") String roleCode,
|
||||
@Param("currency") String currency);
|
||||
|
||||
@Select("""
|
||||
SELECT project.stage AS code, COUNT(*) AS item_count
|
||||
FROM md_project project
|
||||
JOIN md_company company ON company.id = project.company_id
|
||||
WHERE project.status IN ('ACTIVE', 'SUSPENDED', 'CLOSED', 'ARCHIVED')
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM iam_scope scope
|
||||
JOIN iam_role role ON role.id = scope.role_id
|
||||
JOIN iam_permission permission ON permission.id = scope.permission_id
|
||||
WHERE scope.user_id = #{userId} AND role.code = #{roleCode}
|
||||
AND permission.code = 'project:project:view' AND scope.status = 'ACTIVE'
|
||||
AND scope.valid_from <= UTC_TIMESTAMP(3)
|
||||
AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3))
|
||||
AND (scope.scope_type = 'GLOBAL'
|
||||
OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id)
|
||||
OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id))
|
||||
)
|
||||
GROUP BY project.stage
|
||||
ORDER BY FIELD(project.stage, 'INITIATION', 'CONTRACTING', 'EXECUTION',
|
||||
'ACCEPTANCE', 'SETTLEMENT', 'CLOSED')
|
||||
""")
|
||||
List<CountRow> countProjectStages(@Param("userId") long userId, @Param("roleCode") String roleCode);
|
||||
|
||||
@Select("""
|
||||
SELECT DATE_FORMAT(receipt.receipt_date, '%Y-%m') AS period,
|
||||
COALESCE(SUM(receipt.amount), 0) AS amount
|
||||
FROM fin_receipt receipt
|
||||
JOIN md_company company ON company.id = receipt.company_id
|
||||
LEFT JOIN md_project project ON project.id = receipt.project_id
|
||||
WHERE receipt.status NOT IN ('DRAFT', 'VOID')
|
||||
AND receipt.currency = #{currency}
|
||||
AND receipt.receipt_date >= #{fromDate}
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM iam_scope scope
|
||||
JOIN iam_role role ON role.id = scope.role_id
|
||||
JOIN iam_permission permission ON permission.id = scope.permission_id
|
||||
WHERE scope.user_id = #{userId} AND role.code = #{roleCode}
|
||||
AND permission.code = 'receivable:receipt:view' AND scope.status = 'ACTIVE'
|
||||
AND scope.valid_from <= UTC_TIMESTAMP(3)
|
||||
AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3))
|
||||
AND (scope.scope_type = 'GLOBAL'
|
||||
OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id)
|
||||
OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id))
|
||||
)
|
||||
GROUP BY DATE_FORMAT(receipt.receipt_date, '%Y-%m')
|
||||
ORDER BY period
|
||||
""")
|
||||
List<AmountRow> receiptTrend(@Param("userId") long userId, @Param("roleCode") String roleCode,
|
||||
@Param("currency") String currency, @Param("fromDate") LocalDate fromDate);
|
||||
|
||||
@Select("""
|
||||
SELECT DATE_FORMAT(payment.requested_date, '%Y-%m') AS period,
|
||||
COALESCE(SUM(COALESCE(payment.approved_amount, payment.requested_amount)), 0) AS amount
|
||||
FROM fin_payment_request payment
|
||||
JOIN md_company company ON company.id = payment.company_id
|
||||
JOIN md_project project ON project.id = payment.project_id
|
||||
WHERE payment.status = 'PAID'
|
||||
AND payment.currency = #{currency}
|
||||
AND payment.requested_date >= #{fromDate}
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM iam_scope scope
|
||||
JOIN iam_role role ON role.id = scope.role_id
|
||||
JOIN iam_permission permission ON permission.id = scope.permission_id
|
||||
WHERE scope.user_id = #{userId} AND role.code = #{roleCode}
|
||||
AND permission.code = 'payment:request:view' AND scope.status = 'ACTIVE'
|
||||
AND scope.valid_from <= UTC_TIMESTAMP(3)
|
||||
AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3))
|
||||
AND (scope.scope_type = 'GLOBAL'
|
||||
OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id)
|
||||
OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id))
|
||||
)
|
||||
GROUP BY DATE_FORMAT(payment.requested_date, '%Y-%m')
|
||||
ORDER BY period
|
||||
""")
|
||||
List<AmountRow> paymentTrend(@Param("userId") long userId, @Param("roleCode") String roleCode,
|
||||
@Param("currency") String currency, @Param("fromDate") LocalDate fromDate);
|
||||
|
||||
@Select("""
|
||||
SELECT DATE_FORMAT(COALESCE(invoice.issued_date, invoice.requested_date), '%Y-%m') AS period,
|
||||
COALESCE(SUM(invoice.amount), 0) AS amount
|
||||
FROM fin_invoice invoice
|
||||
JOIN md_company company ON company.id = invoice.company_id
|
||||
JOIN md_project project ON project.id = invoice.project_id
|
||||
WHERE invoice.status = 'ISSUED'
|
||||
AND project.currency = #{currency}
|
||||
AND COALESCE(invoice.issued_date, invoice.requested_date) >= #{fromDate}
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM iam_scope scope
|
||||
JOIN iam_role role ON role.id = scope.role_id
|
||||
JOIN iam_permission permission ON permission.id = scope.permission_id
|
||||
WHERE scope.user_id = #{userId} AND role.code = #{roleCode}
|
||||
AND permission.code = 'receivable:invoice:view' AND scope.status = 'ACTIVE'
|
||||
AND scope.valid_from <= UTC_TIMESTAMP(3)
|
||||
AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3))
|
||||
AND (scope.scope_type = 'GLOBAL'
|
||||
OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id)
|
||||
OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id))
|
||||
)
|
||||
GROUP BY DATE_FORMAT(COALESCE(invoice.issued_date, invoice.requested_date), '%Y-%m')
|
||||
ORDER BY period
|
||||
""")
|
||||
List<AmountRow> invoiceTrend(@Param("userId") long userId, @Param("roleCode") String roleCode,
|
||||
@Param("currency") String currency, @Param("fromDate") LocalDate fromDate);
|
||||
|
||||
@Select("""
|
||||
SELECT COUNT(*)
|
||||
FROM project_risk_flag risk
|
||||
JOIN md_project project ON project.id = risk.project_id
|
||||
JOIN md_company company ON company.id = project.company_id
|
||||
WHERE risk.status = 'ACTIVE'
|
||||
AND risk.effective_from <= UTC_TIMESTAMP(3)
|
||||
AND (risk.effective_until IS NULL OR risk.effective_until >= UTC_TIMESTAMP(3))
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM iam_scope scope
|
||||
JOIN iam_role role ON role.id = scope.role_id
|
||||
JOIN iam_permission permission ON permission.id = scope.permission_id
|
||||
WHERE scope.user_id = #{userId} AND role.code = #{roleCode}
|
||||
AND permission.code = 'project:risk-flag:view' AND scope.status = 'ACTIVE'
|
||||
AND scope.valid_from <= UTC_TIMESTAMP(3)
|
||||
AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3))
|
||||
AND (scope.scope_type = 'GLOBAL'
|
||||
OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id)
|
||||
OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id))
|
||||
)
|
||||
""")
|
||||
long countActiveRisks(@Param("userId") long userId, @Param("roleCode") String roleCode);
|
||||
|
||||
@Select("""
|
||||
SELECT COUNT(DISTINCT payment.id)
|
||||
FROM fin_payment_check payment_check
|
||||
JOIN fin_payment_request payment ON payment.id = payment_check.payment_id
|
||||
JOIN md_company company ON company.id = payment.company_id
|
||||
JOIN md_project project ON project.id = payment.project_id
|
||||
WHERE payment_check.result = 'BLOCK'
|
||||
AND payment.status NOT IN ('VOID', 'PAID', 'REFUNDED')
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM iam_scope scope
|
||||
JOIN iam_role role ON role.id = scope.role_id
|
||||
JOIN iam_permission permission ON permission.id = scope.permission_id
|
||||
WHERE scope.user_id = #{userId} AND role.code = #{roleCode}
|
||||
AND permission.code = 'payment:request:view' AND scope.status = 'ACTIVE'
|
||||
AND scope.valid_from <= UTC_TIMESTAMP(3)
|
||||
AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3))
|
||||
AND (scope.scope_type = 'GLOBAL'
|
||||
OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id)
|
||||
OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id))
|
||||
)
|
||||
""")
|
||||
long countPaymentBlocks(@Param("userId") long userId, @Param("roleCode") String roleCode);
|
||||
|
||||
record CountRow(String code, long itemCount) {
|
||||
}
|
||||
|
||||
record AmountRow(String period, BigDecimal amount) {
|
||||
}
|
||||
}
|
||||
@@ -376,7 +376,7 @@ public class AuthApplicationService {
|
||||
case "PROJECT_MANAGER" -> "/workbench/project";
|
||||
case "FINANCE_MANAGER" -> "/workbench/finance";
|
||||
case "ARCHIVE_MANAGER" -> "/workbench/archive";
|
||||
case "SYSTEM_ADMIN" -> "/governance/settings";
|
||||
case "SYSTEM_ADMIN" -> "/dashboard";
|
||||
default -> "/role-select";
|
||||
};
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ public class SetupService {
|
||||
public SetupService(SetupProperties properties) {
|
||||
this.properties = properties;
|
||||
if (properties.tokenSha256() == null || !properties.tokenSha256().matches("(?i)[0-9a-f]{64}")) {
|
||||
throw new IllegalStateException("FINANCE_SETUP_TOKEN_SHA256 must be a 64-character SHA-256 value");
|
||||
throw new IllegalStateException("FINANCE_SETUP_TOKEN_SHA256 必须是 64 位 SHA-256 值");
|
||||
}
|
||||
if (Files.exists(Path.of(properties.markerFile()))) {
|
||||
locked.set(true);
|
||||
@@ -79,15 +79,14 @@ public class SetupService {
|
||||
// Connection testing is useful for diagnostics, but the current business SQL/migration
|
||||
// baseline is MySQL-specific. Do not allow an apparently successful test to produce a
|
||||
// database that the main application cannot start against.
|
||||
ConnectionResult connection = testConnection(settings);
|
||||
ConnectionResult connection = testConnection(settings, false);
|
||||
return new SetupViews.Connection(connection.successful(), settings.type().name(), connection.version(),
|
||||
false, "PostgreSQL 连接可用,但当前 Preview 的业务迁移仅支持 MySQL 8.4");
|
||||
}
|
||||
ConnectionResult connection = testConnection(settings);
|
||||
prepareDatabaseForInstallation(settings);
|
||||
validateSchemaForInstallation(settings);
|
||||
return new SetupViews.Connection(true, settings.type().name(), connection.version(), true,
|
||||
"MySQL 8.4 连接、排序规则和完整迁移权限验证通过");
|
||||
ConnectionResult connection = testConnection(settings, false);
|
||||
LOGGER.info("MySQL 只读连接测试通过,未执行数据库写操作,等待管理员确认完成安装");
|
||||
return new SetupViews.Connection(true, settings.type().name(), connection.version(), false,
|
||||
"MySQL 8.4 只读连接验证通过;尚未修改数据库,点击完成安装后才会执行迁移");
|
||||
}
|
||||
|
||||
public SetupViews.Completed complete(SetupContracts.CompleteRequest request) {
|
||||
@@ -105,7 +104,10 @@ public class SetupService {
|
||||
}
|
||||
synchronized (this) {
|
||||
ensureOpen();
|
||||
ConnectionResult connection = testConnection(settings);
|
||||
// The explicit completion action is the point at which the operator
|
||||
// authorizes schema changes and migration privilege checks.
|
||||
LOGGER.info("管理员已确认完成安装,开始执行 MySQL 迁移和管理员初始化");
|
||||
ConnectionResult connection = testConnection(settings, true);
|
||||
if (!connection.successful()) {
|
||||
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
"DATABASE_CONNECTION_FAILED", "数据库连接失败,请检查地址、端口、库名和账号");
|
||||
@@ -145,7 +147,7 @@ public class SetupService {
|
||||
if (exception instanceof SetupException setupException) {
|
||||
throw setupException;
|
||||
}
|
||||
LOGGER.error("Database migration or administrator initialization failed", exception);
|
||||
LOGGER.error("数据库迁移或管理员初始化失败", exception);
|
||||
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
"DATABASE_INITIALIZATION_FAILED", databaseInitializationFailureMessage(exception));
|
||||
}
|
||||
@@ -167,14 +169,14 @@ public class SetupService {
|
||||
"DATABASE_COLLATION_UNSUPPORTED", "数据库字符集或排序规则未能统一为 "
|
||||
+ MYSQL_CHARACTER_SET + "/" + MYSQL_COLLATION);
|
||||
}
|
||||
LOGGER.info("Normalized database {} from {}/{} to {}/{}", settings.database(),
|
||||
LOGGER.info("已将数据库 {} 的字符集/排序规则从 {}/{} 统一为 {}/{}", settings.database(),
|
||||
current.characterSet(), current.collation(), updated.characterSet(), updated.collation());
|
||||
}
|
||||
recoverV068CollationFailure(jdbc, settings.database());
|
||||
} catch (SetupException exception) {
|
||||
throw exception;
|
||||
} catch (RuntimeException exception) {
|
||||
LOGGER.error("Database collation preparation failed", exception);
|
||||
LOGGER.error("数据库字符集和排序规则准备失败", exception);
|
||||
SQLException sqlException = findSqlException(exception);
|
||||
String detail = sqlException == null
|
||||
? "数据库字符集和排序规则初始化失败:" + safeErrorMessage(exception)
|
||||
@@ -242,7 +244,7 @@ public class SetupService {
|
||||
throw new SetupException(org.springframework.http.HttpStatus.CONFLICT,
|
||||
"DATABASE_MIGRATION_RECOVERY_CONFLICT", "V068 迁移恢复状态已变化,请重新测试数据库连接");
|
||||
}
|
||||
LOGGER.warn("Removed the recoverable failed {} history row after database collation normalization",
|
||||
LOGGER.warn("数据库字符集统一后已移除可恢复的失败迁移记录 {}",
|
||||
RECOVERABLE_V068_SCRIPT);
|
||||
}
|
||||
|
||||
@@ -282,16 +284,16 @@ public class SetupService {
|
||||
} catch (SetupException exception) {
|
||||
throw exception;
|
||||
} catch (FlywayException exception) {
|
||||
LOGGER.error("Database migration history validation failed", exception);
|
||||
LOGGER.error("数据库迁移历史校验失败", exception);
|
||||
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
"DATABASE_MIGRATION_STATE_INVALID",
|
||||
"数据库迁移历史校验失败,请使用空数据库,或先修复已有迁移状态:" + safeErrorMessage(exception));
|
||||
} catch (RuntimeException exception) {
|
||||
LOGGER.error("Database schema inspection failed", exception);
|
||||
LOGGER.error("数据库结构检查失败", exception);
|
||||
SQLException sqlException = findSqlException(exception);
|
||||
String detail = sqlException == null
|
||||
? "数据库结构检查失败:" + safeErrorMessage(exception)
|
||||
: databaseConnectionFailureMessage(sqlException);
|
||||
: databaseConnectionFailureMessage(sqlException, true);
|
||||
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
"DATABASE_SCHEMA_INSPECTION_FAILED", detail);
|
||||
}
|
||||
@@ -410,37 +412,46 @@ public class SetupService {
|
||||
}
|
||||
}
|
||||
|
||||
private ConnectionResult testConnection(DatabaseSettings settings) {
|
||||
private ConnectionResult testConnection(DatabaseSettings settings, boolean verifyPrivileges) {
|
||||
try (Connection connection = DriverManager.getConnection(settings.jdbcUrl(), settings.username(),
|
||||
settings.password()); Statement statement = connection.createStatement()) {
|
||||
statement.setQueryTimeout(10);
|
||||
String version;
|
||||
try (ResultSet result = statement.executeQuery("SELECT VERSION()")) {
|
||||
result.next();
|
||||
version = result.getString(1);
|
||||
settings.password())) {
|
||||
// A connection test must not create or mutate database objects. JDBC metadata
|
||||
// is supplied by the handshake and avoids issuing SELECT/DDL/DML in the test step.
|
||||
String version = connection.getMetaData().getDatabaseProductVersion();
|
||||
if (version == null || version.isBlank()) {
|
||||
version = "unknown";
|
||||
}
|
||||
if (settings.type() == DatabaseType.MYSQL && !version.startsWith("8.4.")) {
|
||||
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
"MYSQL_VERSION_UNSUPPORTED", "当前版本要求 MySQL 8.4.x,检测到 " + version);
|
||||
}
|
||||
verifyMigrationPrivileges(connection, statement, settings.type());
|
||||
if (verifyPrivileges) {
|
||||
try (Statement statement = connection.createStatement()) {
|
||||
statement.setQueryTimeout(10);
|
||||
verifyMigrationPrivileges(connection, statement, settings.type());
|
||||
}
|
||||
}
|
||||
return new ConnectionResult(true, version);
|
||||
} catch (SQLException exception) {
|
||||
LOGGER.warn("Database connection or migration privilege verification failed: SQL state={}, errorCode={}",
|
||||
LOGGER.warn(verifyPrivileges
|
||||
? "数据库连接或迁移权限验证失败:SQLState={},错误码={}"
|
||||
: "数据库只读连接测试失败:SQLState={},错误码={}",
|
||||
exception.getSQLState(), exception.getErrorCode(), exception);
|
||||
throw new SetupException(org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
"DATABASE_CONNECTION_FAILED", databaseConnectionFailureMessage(exception));
|
||||
"DATABASE_CONNECTION_FAILED", databaseConnectionFailureMessage(exception, verifyPrivileges));
|
||||
}
|
||||
}
|
||||
|
||||
private String databaseConnectionFailureMessage(SQLException exception) {
|
||||
private String databaseConnectionFailureMessage(SQLException exception, boolean verifyPrivileges) {
|
||||
if (isPermissionError(exception)) {
|
||||
return "数据库账号缺少完整迁移权限" + sqlErrorSummary(exception);
|
||||
return (verifyPrivileges ? "数据库账号缺少完整迁移权限" : "数据库账号无权建立连接")
|
||||
+ sqlErrorSummary(exception);
|
||||
}
|
||||
if (exception.getSQLState() != null && exception.getSQLState().startsWith("08")) {
|
||||
return "数据库连接超时或中断" + sqlErrorSummary(exception);
|
||||
}
|
||||
return "数据库连接或完整迁移权限验证失败" + sqlErrorSummary(exception);
|
||||
return (verifyPrivileges ? "数据库连接或完整迁移权限验证失败" : "数据库只读连接测试失败")
|
||||
+ sqlErrorSummary(exception);
|
||||
}
|
||||
|
||||
private String databaseInitializationFailureMessage(Throwable exception) {
|
||||
@@ -510,14 +521,14 @@ public class SetupService {
|
||||
try (Statement call = connection.createStatement();
|
||||
ResultSet result = call.executeQuery("CALL " + routine + "()")) {
|
||||
if (!result.next() || result.getInt(1) != 1) {
|
||||
throw new SQLException("Migration privilege routine probe returned an invalid result");
|
||||
throw new SQLException("迁移权限存储过程探针返回了无效结果");
|
||||
}
|
||||
}
|
||||
statement.execute("INSERT INTO " + parentTable + " (id) VALUES (1)");
|
||||
statement.execute("INSERT INTO " + childTable + " (id, parent_id, note) VALUES (1, 1, 'probe')");
|
||||
try (ResultSet result = statement.executeQuery("SELECT note FROM " + childTable + " WHERE id = 1")) {
|
||||
if (!result.next() || !"probe".equals(result.getString(1))) {
|
||||
throw new SQLException("Migration privilege SELECT probe returned an invalid result");
|
||||
throw new SQLException("迁移权限 SELECT 探针返回了无效结果");
|
||||
}
|
||||
}
|
||||
statement.execute("CREATE TEMPORARY TABLE " + temporaryTable
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -10,8 +10,9 @@ import java.security.NoSuchAlgorithmException;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -47,6 +48,17 @@ public class AuthorizationService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Loads the active role's permissions once for request-level capability decisions. */
|
||||
public Set<String> activePermissions() {
|
||||
FinancePrincipal principal = identityContext.requirePrincipal();
|
||||
if (principal.mustChangePassword()) {
|
||||
throw new BusinessException(HttpStatus.FORBIDDEN, ErrorCode.AUTH_PASSWORD_CHANGE_REQUIRED,
|
||||
"首次登录必须先修改密码");
|
||||
}
|
||||
String role = identityContext.activeRole();
|
||||
return role == null ? Set.of() : Set.copyOf(userAccountMapper.findPermissions(principal.userId(), role));
|
||||
}
|
||||
|
||||
public void requireGlobalScope(String permissionCode) {
|
||||
requireScope(permissionCode, null, null, null);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.kaidi.finance.shared.web;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
/** Adds long-lived browser caching only to fingerprinted frontend assets. */
|
||||
@Component
|
||||
@Order(Ordered.LOWEST_PRECEDENCE)
|
||||
public class StaticAssetCacheFilter extends OncePerRequestFilter {
|
||||
|
||||
static final String IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable";
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
if (isStaticAssetRequest(request)) {
|
||||
// Set this before the response is committed. Spring Security keeps an existing cache
|
||||
// policy instead of replacing it with its default no-store response headers.
|
||||
response.setHeader(HttpHeaders.CACHE_CONTROL, IMMUTABLE_CACHE_CONTROL);
|
||||
}
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
private boolean isStaticAssetRequest(HttpServletRequest request) {
|
||||
if (!"GET".equalsIgnoreCase(request.getMethod()) && !"HEAD".equalsIgnoreCase(request.getMethod())) {
|
||||
return false;
|
||||
}
|
||||
String path = request.getRequestURI();
|
||||
String contextPath = request.getContextPath();
|
||||
if (contextPath != null && !contextPath.isEmpty() && path.startsWith(contextPath)) {
|
||||
path = path.substring(contextPath.length());
|
||||
}
|
||||
return path.startsWith("/assets/");
|
||||
}
|
||||
}
|
||||
+140
-17
@@ -34,15 +34,21 @@ 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;
|
||||
@@ -71,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,
|
||||
@@ -90,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() {
|
||||
@@ -99,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;
|
||||
@@ -120,6 +131,7 @@ public class SystemUpdateApplicationService {
|
||||
throw validation("当前已是相同或更高版本");
|
||||
}
|
||||
UpdateStatus currentStatus = readStatus();
|
||||
persistTerminalAudit(currentStatus);
|
||||
if ("READY".equals(currentStatus.state()) && requested.equals(currentStatus.targetVersion())) {
|
||||
throw validation("该版本已经下载并通过校验,请确认安装");
|
||||
}
|
||||
@@ -137,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("该版本尚未完成下载和签名校验");
|
||||
}
|
||||
@@ -369,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("系统更新队列锁定失败");
|
||||
@@ -406,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() {
|
||||
@@ -431,7 +501,9 @@ public class SystemUpdateApplicationService {
|
||||
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));
|
||||
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);
|
||||
}
|
||||
@@ -453,9 +525,13 @@ public class SystemUpdateApplicationService {
|
||||
&& 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(),
|
||||
available, status.state(), status.message(), releaseNotes,
|
||||
publishedAt, lastCheckedAt, status.updatedAt(),
|
||||
status.downloadedBytes(), status.totalBytes(), status.bytesPerSecond(), status.downloadPercent(),
|
||||
status.restartExpectedSeconds(), readUpdateEvents(), List.copyOf(actions));
|
||||
}
|
||||
@@ -525,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);
|
||||
}
|
||||
@@ -631,6 +710,40 @@ public class SystemUpdateApplicationService {
|
||||
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);
|
||||
}
|
||||
@@ -647,11 +760,21 @@ public class SystemUpdateApplicationService {
|
||||
String releaseNotes) {
|
||||
}
|
||||
|
||||
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) {
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.kaidi.setup;
|
||||
|
||||
import com.kaidi.finance.setup.SetupProperties;
|
||||
import com.kaidi.finance.shared.web.SpaForwardFilter;
|
||||
import com.kaidi.finance.shared.web.StaticAssetCacheFilter;
|
||||
import org.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
@@ -30,7 +31,7 @@ import org.springframework.context.annotation.Import;
|
||||
}
|
||||
)
|
||||
@EnableConfigurationProperties(SetupProperties.class)
|
||||
@Import(SpaForwardFilter.class)
|
||||
@Import({SpaForwardFilter.class, StaticAssetCacheFilter.class})
|
||||
public class SetupApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
@@ -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);
|
||||
@@ -0,0 +1,40 @@
|
||||
INSERT INTO iam_permission (code, name)
|
||||
VALUES ('dashboard:overview:view', '查看系统仪表盘')
|
||||
ON DUPLICATE KEY UPDATE name = VALUES(name);
|
||||
|
||||
-- The isolated system administrator is the only role that receives the new
|
||||
-- entry automatically. Business roles can be granted the permission from
|
||||
-- "权限与配置" according to the customer's actual responsibility matrix.
|
||||
INSERT IGNORE INTO iam_role_permission (role_id, permission_id)
|
||||
SELECT role.id, permission.id
|
||||
FROM iam_role role
|
||||
JOIN iam_permission permission ON permission.code = 'dashboard:overview:view'
|
||||
WHERE role.code = 'SYSTEM_ADMIN'
|
||||
AND role.enabled = TRUE;
|
||||
|
||||
-- Keep the server-side permission and data-scope model aligned for existing
|
||||
-- administrator accounts. Front-end super-admin bypasses are not considered
|
||||
-- an authorization boundary.
|
||||
INSERT IGNORE INTO iam_scope (
|
||||
user_id,
|
||||
role_id,
|
||||
permission_id,
|
||||
scope_type,
|
||||
company_public_id,
|
||||
project_public_id,
|
||||
amount_limit,
|
||||
status
|
||||
)
|
||||
SELECT user_role.user_id,
|
||||
user_role.role_id,
|
||||
permission.id,
|
||||
'GLOBAL',
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
'ACTIVE'
|
||||
FROM iam_user_role user_role
|
||||
JOIN iam_role role ON role.id = user_role.role_id
|
||||
JOIN iam_permission permission ON permission.code = 'dashboard:overview:view'
|
||||
WHERE role.code = 'SYSTEM_ADMIN'
|
||||
AND role.enabled = TRUE;
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
package com.kaidi.finance.dashboard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
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.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.kaidi.finance.dashboard.api.DashboardMetricView;
|
||||
import com.kaidi.finance.dashboard.application.DashboardApplicationService;
|
||||
import com.kaidi.finance.dashboard.infrastructure.DashboardMapper;
|
||||
import com.kaidi.finance.iam.domain.FinancePrincipal;
|
||||
import com.kaidi.finance.iam.domain.RoleAssignment;
|
||||
import com.kaidi.finance.shared.api.BusinessException;
|
||||
import com.kaidi.finance.shared.file.FileStorageProperties;
|
||||
import com.kaidi.finance.shared.security.AuthorizationService;
|
||||
import com.kaidi.finance.shared.security.IdentityContext;
|
||||
import com.kaidi.finance.update.application.SystemUpdateProperties;
|
||||
import com.kaidi.finance.workbench.infrastructure.WorkbenchMapper;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class DashboardApplicationServiceTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
private DashboardMapper mapper;
|
||||
private WorkbenchMapper workbenchMapper;
|
||||
private IdentityContext identity;
|
||||
private AuthorizationService authorization;
|
||||
private DashboardApplicationService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mapper = mock(DashboardMapper.class);
|
||||
workbenchMapper = mock(WorkbenchMapper.class);
|
||||
identity = mock(IdentityContext.class);
|
||||
authorization = mock(AuthorizationService.class);
|
||||
when(identity.requirePrincipal()).thenReturn(new FinancePrincipal(
|
||||
7, "01J00000000000000000000007", "admin", "系统管理员", "财务部", false,
|
||||
List.of(new RoleAssignment(1, "SYSTEM_ADMIN", "超级管理员", "系统治理"))));
|
||||
when(identity.requireActiveRole()).thenReturn("SYSTEM_ADMIN");
|
||||
when(authorization.activePermissions()).thenReturn(Set.of(
|
||||
"dashboard:overview:view",
|
||||
"project:project:view",
|
||||
"masterdata:contract:view",
|
||||
"receivable:receipt:view",
|
||||
"payment:request:view",
|
||||
"receivable:invoice:view",
|
||||
"workflow:task:view",
|
||||
"project:risk-flag:view",
|
||||
"archive:package:view",
|
||||
"archive:file:view"
|
||||
));
|
||||
when(workbenchMapper.countPendingTasks(anyLong(), anyString())).thenReturn(3L);
|
||||
when(workbenchMapper.countOverdueTasks(anyLong(), anyString())).thenReturn(1L);
|
||||
when(workbenchMapper.countArchiveMissing(anyLong(), anyString())).thenReturn(2L);
|
||||
when(workbenchMapper.countFiles(anyString(), anyLong(), anyString())).thenReturn(1L);
|
||||
when(workbenchMapper.listRecentActivities(anyString(), anyInt())).thenReturn(List.of());
|
||||
when(mapper.countProjectStages(anyLong(), anyString())).thenReturn(List.of(
|
||||
new DashboardMapper.CountRow("INITIATION", 2L),
|
||||
new DashboardMapper.CountRow("EXECUTION", 1L)));
|
||||
when(mapper.receiptTrend(anyLong(), anyString(), anyString(), any())).thenReturn(List.of());
|
||||
when(mapper.paymentTrend(anyLong(), anyString(), anyString(), any())).thenReturn(List.of());
|
||||
when(mapper.invoiceTrend(anyLong(), anyString(), anyString(), any())).thenReturn(List.of());
|
||||
when(mapper.countProjects(anyLong(), anyString())).thenReturn(3L);
|
||||
when(mapper.sumContracts(anyLong(), anyString(), anyString())).thenReturn(new BigDecimal("12345.67"));
|
||||
when(mapper.sumReceipts(anyLong(), anyString(), anyString())).thenReturn(new BigDecimal("2345.67"));
|
||||
when(mapper.sumPayments(anyLong(), anyString(), anyString())).thenReturn(new BigDecimal("345.67"));
|
||||
when(mapper.countActiveRisks(anyLong(), anyString())).thenReturn(4L);
|
||||
when(mapper.countPaymentBlocks(anyLong(), anyString())).thenReturn(1L);
|
||||
service = new DashboardApplicationService(
|
||||
mapper,
|
||||
workbenchMapper,
|
||||
identity,
|
||||
authorization,
|
||||
new SystemUpdateProperties(true, "1.0.0-preview.48", tempDir.resolve("VERSION"),
|
||||
"https://git.example.invalid/releases", "https://git.example.invalid/api/latest", "",
|
||||
tempDir.resolve("request.json"), tempDir.resolve("status.json"), Duration.ofSeconds(1),
|
||||
Duration.ofSeconds(1), false),
|
||||
new FileStorageProperties(tempDir.toString(), tempDir.resolve("tmp").toString(), 100_000L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildsPermissionAwareOverviewWithMoneyAndLifecycleData() {
|
||||
var view = service.overview("usd");
|
||||
|
||||
assertEquals("USD", view.currency());
|
||||
assertEquals("1.0.0-preview.48", view.system().currentVersion());
|
||||
assertEquals("UP", view.system().overallStatus());
|
||||
assertEquals(6, view.trend().points().size());
|
||||
assertEquals(2L, view.lifecycle().stream()
|
||||
.filter(item -> item.code().equals("INITIATION"))
|
||||
.findFirst().orElseThrow().value());
|
||||
DashboardMetricView contract = view.metrics().stream()
|
||||
.filter(item -> item.code().equals("CONTRACT_AMOUNT"))
|
||||
.findFirst().orElseThrow();
|
||||
assertEquals("12345.67", contract.value());
|
||||
assertTrue(contract.available());
|
||||
assertFalse(view.geography().available());
|
||||
verify(authorization).activePermissions();
|
||||
}
|
||||
|
||||
@Test
|
||||
void hidesBusinessWidgetsWhenTheirUnderlyingPermissionIsMissing() {
|
||||
when(authorization.activePermissions()).thenReturn(Set.of("dashboard:overview:view"));
|
||||
|
||||
var view = service.overview("CNY");
|
||||
|
||||
assertTrue(view.metrics().stream().noneMatch(DashboardMetricView::available));
|
||||
assertTrue(view.lifecycle().stream().noneMatch(item -> item.available()));
|
||||
assertTrue(view.risks().stream().noneMatch(item -> item.available()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnsupportedCurrencyBeforeQueryingBusinessData() {
|
||||
assertThrows(BusinessException.class, () -> service.overview("RMB"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void requiresTheDedicatedDashboardPermission() {
|
||||
when(authorization.activePermissions()).thenReturn(Set.of());
|
||||
doThrow(new BusinessException(org.springframework.http.HttpStatus.FORBIDDEN,
|
||||
com.kaidi.finance.shared.api.ErrorCode.PERMISSION_DENIED, "无仪表盘权限"))
|
||||
.when(authorization).requirePermission("dashboard:overview:view");
|
||||
|
||||
assertThrows(BusinessException.class, () -> service.overview("CNY"));
|
||||
}
|
||||
}
|
||||
@@ -179,7 +179,7 @@ class AuthIntegrationTest {
|
||||
.andExpect(jsonPath("$.data.roles.length()").value(1))
|
||||
.andExpect(jsonPath("$.data.roles[0].code").value("SYSTEM_ADMIN"))
|
||||
.andExpect(jsonPath("$.data.roles[0].name").value("超级管理员"))
|
||||
.andExpect(jsonPath("$.data.roles[0].workbenchRoute").value("/governance/settings"))
|
||||
.andExpect(jsonPath("$.data.roles[0].workbenchRoute").value("/dashboard"))
|
||||
.andReturn();
|
||||
|
||||
ClientSession session = clientSession(login);
|
||||
|
||||
@@ -80,38 +80,16 @@ class SetupApplicationIntegrationTest {
|
||||
new HttpEntity<>(database), JsonNode.class);
|
||||
assertThat(tested.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(tested.getBody().path("data").path("successful").asBoolean()).isTrue();
|
||||
assertThat(tested.getBody().path("data").path("schemaReady").asBoolean()).isTrue();
|
||||
assertThat(tested.getBody().path("data").path("schemaReady").asBoolean()).isFalse();
|
||||
assertThat(tested.getBody().path("data").path("message").asText())
|
||||
.contains("排序规则和完整迁移权限验证通过");
|
||||
.contains("只读连接验证通过");
|
||||
|
||||
try (Connection connection = DriverManager.getConnection(MYSQL.getJdbcUrl(), MYSQL.getUsername(),
|
||||
MYSQL.getPassword()); Statement statement = connection.createStatement()) {
|
||||
try (ResultSet result = statement.executeQuery("""
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE() AND table_name LIKE 'kaidi_setup_probe_%')
|
||||
+
|
||||
(SELECT COUNT(*) FROM information_schema.routines
|
||||
WHERE routine_schema = DATABASE() AND routine_name LIKE 'kaidi_setup_probe_%')
|
||||
""")) {
|
||||
try (ResultSet result = statement.executeQuery("SELECT default_collation_name FROM information_schema.schemata WHERE schema_name = DATABASE()")) {
|
||||
result.next();
|
||||
assertThat(result.getInt(1)).isZero();
|
||||
}
|
||||
try (ResultSet result = statement.executeQuery("""
|
||||
SELECT default_collation_name
|
||||
FROM information_schema.schemata
|
||||
WHERE schema_name = DATABASE()
|
||||
""")) {
|
||||
result.next();
|
||||
assertThat(result.getString(1)).isEqualTo("utf8mb4_0900_ai_ci");
|
||||
}
|
||||
try (ResultSet result = statement.executeQuery("""
|
||||
SELECT COUNT(*)
|
||||
FROM flyway_schema_history
|
||||
WHERE success = FALSE
|
||||
""")) {
|
||||
result.next();
|
||||
assertThat(result.getInt(1)).isZero();
|
||||
// A read-only connection test must not normalize the operator's schema.
|
||||
assertThat(result.getString(1)).isNotEqualTo("utf8mb4_0900_ai_ci");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.client.TestRestTemplate;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||
@@ -57,6 +58,10 @@ class SetupContextSmokeTest {
|
||||
ResponseEntity<String> asset = rest.getForEntity(url("/assets/app.js"), String.class);
|
||||
assertThat(asset.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(asset.getBody()).contains("kaidi-spa-fixture");
|
||||
assertThat(asset.getHeaders().getFirst(HttpHeaders.CACHE_CONTROL))
|
||||
.isEqualTo("public, max-age=31536000, immutable");
|
||||
assertThat(setupPage.getHeaders().getFirst(HttpHeaders.CACHE_CONTROL))
|
||||
.isNotEqualTo("public, max-age=31536000, immutable");
|
||||
|
||||
ResponseEntity<JsonNode> business = rest.getForEntity(url("/api/v1/auth/session"), JsonNode.class);
|
||||
assertThat(business.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.kaidi.finance.shared.web;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
|
||||
class StaticAssetCacheFilterTest {
|
||||
|
||||
private final StaticAssetCacheFilter filter = new StaticAssetCacheFilter();
|
||||
|
||||
@Test
|
||||
void cachesFingerprintAssetsForOneYear() throws Exception {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/assets/index-a1b2c3.js");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
AtomicBoolean continued = new AtomicBoolean();
|
||||
|
||||
filter.doFilter(request, response, (ignoredRequest, ignoredResponse) -> continued.set(true));
|
||||
|
||||
assertThat(continued).isTrue();
|
||||
assertThat(response.getHeader(HttpHeaders.CACHE_CONTROL))
|
||||
.isEqualTo(StaticAssetCacheFilter.IMMUTABLE_CACHE_CONTROL);
|
||||
}
|
||||
|
||||
@Test
|
||||
void supportsContextPathAndHeadRequests() throws Exception {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("HEAD", "/finance/assets/index-a1b2c3.css");
|
||||
request.setContextPath("/finance");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
filter.doFilter(request, response, (ignoredRequest, ignoredResponse) -> { });
|
||||
|
||||
assertThat(response.getHeader(HttpHeaders.CACHE_CONTROL))
|
||||
.isEqualTo(StaticAssetCacheFilter.IMMUTABLE_CACHE_CONTROL);
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotCacheHtmlApiOrWriteRequests() throws Exception {
|
||||
assertNotCached("GET", "/index.html");
|
||||
assertNotCached("GET", "/api/v1/dashboard/overview");
|
||||
assertNotCached("POST", "/assets/index-a1b2c3.js");
|
||||
}
|
||||
|
||||
private void assertNotCached(String method, String uri) throws Exception {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest(method, uri);
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
filter.doFilter(request, response, (ignoredRequest, ignoredResponse) -> { });
|
||||
|
||||
assertThat(response.getHeader(HttpHeaders.CACHE_CONTROL)).isNull();
|
||||
}
|
||||
}
|
||||
+103
@@ -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;
|
||||
@@ -286,6 +288,105 @@ class SystemUpdateApplicationServiceTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void persistsCorrelatedTerminalUpdateAuditOnlyOncePerApplicationProcess() throws Exception {
|
||||
Path inbox = Files.createDirectory(tempDir.resolve("terminal-inbox"));
|
||||
Path statusFile = tempDir.resolve("terminal-status.json");
|
||||
Files.writeString(statusFile, """
|
||||
{"state":"SUCCEEDED","message":"新版本已通过健康检查","targetVersion":"1.0.0-preview.44",
|
||||
"updatedAt":"2026-08-19T00:10:00Z","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);
|
||||
@@ -322,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"));
|
||||
|
||||
|
||||
+79
-52
@@ -9,19 +9,53 @@ CONFIG_ROOT=/etc/kaidi
|
||||
STATE_ROOT=/var/lib/kaidi
|
||||
UPDATE_STATE_ROOT=/var/lib/kaidi-update
|
||||
LOG_ROOT=/var/log/kaidi
|
||||
UPDATER_ROOT=/opt/kaidi/bin
|
||||
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=()
|
||||
|
||||
log() { printf '[kaidi-baota-init] %s\n' "$*"; }
|
||||
die() { printf '[kaidi-baota-init] ERROR: %s\n' "$*" >&2; exit 1; }
|
||||
localize_message() {
|
||||
local message=$1
|
||||
[ "$LOG_LOCALE" = en ] && { printf '%s' "$message"; return; }
|
||||
case "$message" in
|
||||
"Run with sudo or as root") printf '请使用 sudo 或 root 运行' ;;
|
||||
"Baota initialization only supports Linux") printf '宝塔初始化仅支持 Linux' ;;
|
||||
"Extract the release"*) printf '请先将发布包解压到 APP_ROOT/releases/VERSION' ;;
|
||||
"The supported Baota project root"*) printf '宝塔项目根目录必须是 /www/wwwroot/kaidi' ;;
|
||||
"The extracted release is incomplete") printf '解压后的发布包不完整' ;;
|
||||
"The extracted operations scripts are incomplete") printf '发布包中的运维脚本不完整' ;;
|
||||
"The release public key is missing") printf '缺少发布公钥' ;;
|
||||
"The release public-key fingerprint is invalid") printf '发布公钥指纹不匹配' ;;
|
||||
"Existing user "*" has unexpected home directory "*) printf '现有用户目录不符合 Kaidi 约定:%s' "${message#Existing user }" ;;
|
||||
"Existing user "*" is not a member"*) printf '现有 Kaidi 用户不属于服务用户组' ;;
|
||||
"A nologin shell is required") printf '服务用户必须使用 nologin shell' ;;
|
||||
*" contains a line break") printf '配置项包含换行符,已拒绝:%s' "${message%% contains a line break*}" ;;
|
||||
"find is required"|"openssl is required"|"sha256sum is required") printf '缺少初始化依赖命令:%s' "${message%% is required}" ;;
|
||||
"Kaidi is already initialized"*) printf 'Kaidi 已初始化,请先执行卸载流程再重新安装' ;;
|
||||
"The old kaidi-finance.service"*) printf '检测到旧 kaidi-finance.service,请先执行卸载流程' ;;
|
||||
"Old Kaidi update units"*) printf '检测到旧 Kaidi 更新单元,请先执行卸载流程' ;;
|
||||
"The Baota current release link"*) printf '宝塔 current 发布链接已存在,请先执行卸载流程' ;;
|
||||
"Port must be an integer"*) printf '端口必须是 1024 到 65535 的整数' ;;
|
||||
"Initialization failed"*) printf '初始化失败,本次创建的文件已回滚,可以修正原因后重试' ;;
|
||||
"Manual deployment is initialized"*) printf '宝塔手动部署初始化完成:%s' "${message#Manual deployment is initialized for Kaidi Finance }" ;;
|
||||
"Create the Baota Spring Boot project"*) printf '请在宝塔创建 Spring Boot 项目,项目路径:%s' "${message#Create the Baota Spring Boot project with }" ;;
|
||||
"Baota environment variables: leave empty") printf '宝塔环境变量请全部留空' ;;
|
||||
"Setup code: "*) printf '安装码位置:%s' "${message#Setup code: }" ;;
|
||||
*) printf '%s' "$message" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
log() { printf '[kaidi-baota-init] %s\n' "$(localize_message "$*")"; }
|
||||
die() { printf '[kaidi-baota-init] 错误:%s\n' "$(localize_message "$*")" >&2; exit 1; }
|
||||
|
||||
rollback_initialization() {
|
||||
local rc=$?
|
||||
local rc=$? index path service_uid
|
||||
trap - EXIT HUP INT TERM
|
||||
if [ "$rc" -ne 0 ] && [ "$INIT_ARMED" = true ] && [ "$INIT_COMMITTED" != true ]; then
|
||||
systemctl disable --now kaidi-update.path >/dev/null 2>&1 || true
|
||||
@@ -29,13 +63,25 @@ rollback_initialization() {
|
||||
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" "$UPDATER_ROOT/update.sh" \
|
||||
"$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"
|
||||
@@ -49,6 +95,10 @@ sha256_file() {
|
||||
sha256sum "$1" | awk '{print $1}'
|
||||
}
|
||||
|
||||
record_path() {
|
||||
[ -e "$1" ] || [ -L "$1" ] || CREATED_PATHS+=("$1")
|
||||
}
|
||||
|
||||
random_secret() {
|
||||
openssl rand -base64 36 | tr -d '\n/+=' | cut -c1-36
|
||||
}
|
||||
@@ -74,6 +124,7 @@ ensure_service_identity() {
|
||||
local existing_home nologin_path
|
||||
if ! getent group "$SERVICE_GROUP" >/dev/null 2>&1; then
|
||||
groupadd --system "$SERVICE_GROUP"
|
||||
SERVICE_GROUP_CREATED=true
|
||||
fi
|
||||
if id "$SERVICE_USER" >/dev/null 2>&1; then
|
||||
existing_home=$(getent passwd "$SERVICE_USER" | awk -F: '{print $6}')
|
||||
@@ -87,15 +138,14 @@ ensure_service_identity() {
|
||||
[ -n "$nologin_path" ] || nologin_path=/usr/sbin/nologin
|
||||
[ -x "$nologin_path" ] || die "A nologin shell is required"
|
||||
useradd --system --gid "$SERVICE_GROUP" --home-dir "$STATE_ROOT" --shell "$nologin_path" "$SERVICE_USER"
|
||||
SERVICE_USER_CREATED=true
|
||||
}
|
||||
|
||||
main() {
|
||||
local script_dir release_root releases_root app_root version app_port field_key setup_code setup_hash
|
||||
[ "$(id -u)" -eq 0 ] || die "Run with sudo or as root"
|
||||
[ "$(uname -s)" = Linux ] || die "Baota initialization only supports Linux"
|
||||
command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ] \
|
||||
|| die "systemd is required for the privileged background updater"
|
||||
for command in find openssl setsid sha256sum; do
|
||||
for command in find openssl sha256sum; do
|
||||
command -v "$command" >/dev/null 2>&1 || die "$command is required"
|
||||
done
|
||||
|
||||
@@ -123,7 +173,7 @@ main() {
|
||||
[ ! -e "$CONFIG_ROOT/kaidi.env" ] && [ ! -e "$CONFIG_ROOT/update.env" ] \
|
||||
&& [ ! -e "$STATE_ROOT/setup/locked" ] \
|
||||
|| die "Kaidi is already initialized; run the published purge workflow before a fresh installation"
|
||||
! systemctl cat kaidi-finance.service >/dev/null 2>&1 \
|
||||
[ ! -e /etc/systemd/system/kaidi-finance.service ] \
|
||||
|| die "The old kaidi-finance.service still exists; run the published purge workflow first"
|
||||
[ ! -e /etc/systemd/system/kaidi-update.service ] \
|
||||
&& [ ! -e /etc/systemd/system/kaidi-update.path ] \
|
||||
@@ -137,7 +187,11 @@ main() {
|
||||
|
||||
INIT_ARMED=true
|
||||
ensure_service_identity
|
||||
install -d -o root -g "$SERVICE_GROUP" -m 0750 "$app_root" "$releases_root" "$UPDATER_ROOT"
|
||||
for path in "$app_root" "$releases_root" "$STATE_ROOT" "$STATE_ROOT/files" "$STATE_ROOT/tmp" \
|
||||
"$LOG_ROOT" "$STATE_ROOT/setup" "$UPDATE_STATE_ROOT" "$UPDATE_STATE_ROOT/inbox" "$CONFIG_ROOT"; do
|
||||
record_path "$path"
|
||||
done
|
||||
install -d -o root -g "$SERVICE_GROUP" -m 0750 "$app_root" "$releases_root"
|
||||
install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0750 \
|
||||
"$STATE_ROOT" "$STATE_ROOT/files" "$STATE_ROOT/tmp" "$LOG_ROOT"
|
||||
install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0700 "$STATE_ROOT/setup"
|
||||
@@ -158,14 +212,16 @@ main() {
|
||||
# The Baota process manager does not own a restartable application unit.
|
||||
# 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 'jdbc:mysql://setup.invalid:3306/kaidi_finance' \
|
||||
DB_USERNAME setup_pending \
|
||||
DB_PASSWORD setup_pending \
|
||||
DB_URL '' \
|
||||
DB_USERNAME '' \
|
||||
DB_PASSWORD '' \
|
||||
FIELD_ENCRYPTION_KEY "$field_key" \
|
||||
FILE_STORAGE_ROOT "$STATE_ROOT/files" \
|
||||
FILE_STORAGE_TEMP "$STATE_ROOT/tmp" \
|
||||
@@ -187,50 +243,21 @@ main() {
|
||||
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"
|
||||
install -m 0755 "$release_root/ops/update.sh" "$UPDATER_ROOT/update.sh"
|
||||
write_env_file "$CONFIG_ROOT/update.env" \
|
||||
UPDATE_RELEASE_BASE_URL '' \
|
||||
UPDATE_RELEASE_API_URL "$RELEASE_API_URL" \
|
||||
UPDATE_RELEASE_TOKEN '' \
|
||||
UPDATE_REQUEST_FILE "$UPDATE_STATE_ROOT/inbox/request.json" \
|
||||
UPDATE_STATUS_FILE "$UPDATE_STATE_ROOT/status.json" \
|
||||
UPDATE_PUBLIC_KEY "$CONFIG_ROOT/release-public.pem" \
|
||||
KAIDI_APP_ROOT "$app_root" \
|
||||
KAIDI_UPDATE_STATE_ROOT "$UPDATE_STATE_ROOT" \
|
||||
KAIDI_PROCESS_MANAGER baota \
|
||||
KAIDI_PID_FILE "$STATE_ROOT/kaidi.pid" \
|
||||
KAIDI_RUNTIME_ENV_FILE "$STATE_ROOT/setup/application.env" \
|
||||
KAIDI_UPDATER_PATH "$UPDATER_ROOT/update.sh" \
|
||||
KAIDI_SERVICE_USER "$SERVICE_USER" \
|
||||
KAIDI_SERVICE_GROUP "$SERVICE_GROUP" \
|
||||
KAIDI_UPDATE_PATH_NAME kaidi-update.path \
|
||||
KAIDI_HEALTH_URL "http://127.0.0.1:$app_port/actuator/health" \
|
||||
KAIDI_APP_INDEX_URL "http://127.0.0.1:$app_port/" \
|
||||
KAIDI_DB_HOST setup.invalid \
|
||||
KAIDI_DB_PORT 3306 \
|
||||
KAIDI_DB_NAME kaidi_finance \
|
||||
KAIDI_DB_USERNAME setup_pending \
|
||||
KAIDI_DB_PASSWORD setup_pending
|
||||
chmod 0600 "$CONFIG_ROOT/update.env"
|
||||
|
||||
install -m 0644 "$release_root/ops/kaidi-update.service" /etc/systemd/system/kaidi-update.service
|
||||
install -m 0644 "$release_root/ops/kaidi-update.path" /etc/systemd/system/kaidi-update.path
|
||||
systemd-analyze verify /etc/systemd/system/kaidi-update.service /etc/systemd/system/kaidi-update.path >/dev/null \
|
||||
|| die "The privileged update units failed validation"
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now kaidi-update.path
|
||||
|
||||
printf '{"state":"CURRENT","message":"Baota release is prepared","targetVersion":"%s","updatedAt":"%s"}\n' \
|
||||
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
|
||||
Project path: $app_root/current
|
||||
Start command: $app_root/current/ops/baota-start.sh
|
||||
Reverse proxy target: http://127.0.0.1:$app_port
|
||||
Setup URL: /setup
|
||||
Setup code: $setup_code
|
||||
Version: $version
|
||||
项目路径:$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
|
||||
|
||||
|
||||
+29
-1
@@ -2,9 +2,37 @@
|
||||
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] ERROR: %s\n' "$1" >&2
|
||||
printf '[kaidi-baota] 错误:%s\n' "$(localize_message "$1")" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,7 @@ FILE_SCANNER_ENABLED=true
|
||||
FINANCE_BOOTSTRAP_ENABLED=false
|
||||
FINANCE_BOOTSTRAP_PASSWORD=
|
||||
|
||||
APP_VERSION=1.0.0-preview.34
|
||||
APP_VERSION=1.0.0-preview.50
|
||||
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.
|
||||
|
||||
@@ -5,13 +5,31 @@ umask 077
|
||||
|
||||
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
INSTALLER="$ROOT/deploy/install.sh"
|
||||
INSTALLER_SHA256=${KAIDI_INSTALLER_SHA256:-6a454aff209e62d7ac75b7f97670d700ec05be710854a02136f41f55e82f9fa9}
|
||||
INSTALLER_SHA256=${KAIDI_INSTALLER_SHA256:-5aadfab9bdeef9279aeab6eee9baaae7182b2f3338fa61a558b3c073b69ad396}
|
||||
RELEASE_API_URL=${KAIDI_RELEASE_API_URL:-https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest}
|
||||
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"
|
||||
|
||||
+211
-86
@@ -38,9 +38,125 @@ JAVA_ACTIVATED=false
|
||||
SERVICE_USER=kaidi
|
||||
SERVICE_GROUP=kaidi
|
||||
HOST_ARCH=
|
||||
LOG_LOCALE=${KAIDI_LOG_LOCALE:-zh-CN}
|
||||
SERVICE_USER_CREATED=false
|
||||
SERVICE_GROUP_CREATED=false
|
||||
CREATED_LAYOUT_PATHS=()
|
||||
|
||||
log() { printf '[kaidi-install] %s\n' "$*"; }
|
||||
die() { printf '[kaidi-install] ERROR: %s\n' "$*" >&2; exit 1; }
|
||||
localize_message() {
|
||||
local message=$1
|
||||
[ "$LOG_LOCALE" = en ] && { printf '%s' "$message"; return; }
|
||||
case "$message" in
|
||||
"Unsupported CPU architecture: "*) printf '不支持的 CPU 架构:%s' "${message#Unsupported CPU architecture: }" ;;
|
||||
"The installer only supports Linux") printf '安装程序仅支持 Linux' ;;
|
||||
"Custom installation roots"*) printf '打包版 systemd 不支持自定义安装目录' ;;
|
||||
"systemd is required") printf '需要 systemd' ;;
|
||||
"systemd-analyze is required") printf '需要 systemd-analyze' ;;
|
||||
"getconf is required") printf '需要 getconf' ;;
|
||||
"systemd is not running as PID 1") printf 'systemd 当前不是 PID 1,无法托管服务' ;;
|
||||
"/etc/systemd/system is missing") printf '缺少 /etc/systemd/system' ;;
|
||||
"CPU architecture and userspace word size"*) printf 'CPU 架构与用户空间位数不匹配:%s' "${message#*: }" ;;
|
||||
"32-bit Linux deployment requires glibc"*) printf '32 位 Linux 需要 glibc;当前系统不兼容 i686 Java' ;;
|
||||
"32-bit Linux deployment requires the glibc loader"*) printf '32 位 Linux 缺少 glibc 加载器 /lib/ld-linux.so.2' ;;
|
||||
"Set KAIDI_RELEASE_PUBLIC_KEY_SHA256"*) printf '请设置受信任的发布公钥 SHA-256:KAIDI_RELEASE_PUBLIC_KEY_SHA256' ;;
|
||||
"Run with sudo or as root") printf '请使用 sudo 或 root 运行' ;;
|
||||
"Set only one of KAIDI_RELEASE_TOKEN"*) printf 'KAIDI_RELEASE_TOKEN 与 KAIDI_RELEASE_TOKEN_FILE 只能设置一个' ;;
|
||||
"KAIDI_RELEASE_TOKEN_FILE must be a regular file") printf 'KAIDI_RELEASE_TOKEN_FILE 必须是普通文件' ;;
|
||||
"KAIDI_RELEASE_TOKEN_FILE is too large") printf 'KAIDI_RELEASE_TOKEN_FILE 过大' ;;
|
||||
"KAIDI_REINSTALL must be true or false") printf 'KAIDI_REINSTALL 只能是 true 或 false' ;;
|
||||
"KAIDI_SETUP_WIZARD must be true or false") printf 'KAIDI_SETUP_WIZARD 只能是 true 或 false' ;;
|
||||
"A setup-wizard repair needs"*) printf '安装向导修复需要已有配置文件:%s' "${message#A setup-wizard repair needs the existing }" ;;
|
||||
"The existing installation is already in production mode"*) printf '现有安装已是正式模式,请执行普通修复重装' ;;
|
||||
"The setup wizard is already locked"*) printf '安装向导已锁定,请执行普通修复重装' ;;
|
||||
"Kaidi Finance is already installed"*) printf 'Kaidi 财务系统已安装,请从系统更新页面执行更新或先卸载' ;;
|
||||
"Set KAIDI_RELEASE_BASE_URL"*) printf '请设置 Gitea Release 源:KAIDI_RELEASE_BASE_URL 或 KAIDI_RELEASE_API_URL' ;;
|
||||
"KAIDI_RELEASE_TOKEN is invalid") printf 'KAIDI_RELEASE_TOKEN 无效' ;;
|
||||
"Download failed: "*) printf '下载失败:%s' "${message#Download failed: }" ;;
|
||||
"Release URLs must use HTTPS") printf '发布地址必须使用 HTTPS' ;;
|
||||
"Release authentication header is unavailable") printf '发布认证请求头不可用' ;;
|
||||
"Release asset "*" is missing") printf '缺少发布资产:%s' "${message#Release asset }" ;;
|
||||
"Gitea Release tag is invalid") printf 'Gitea Release 标签无效' ;;
|
||||
"KAIDI_RELEASE_API_URL must be a Gitea"*) printf 'KAIDI_RELEASE_API_URL 必须是 Gitea Release API 地址' ;;
|
||||
"Required command is missing"*) printf '依赖命令缺失:%s' "${message#*: }" ;;
|
||||
"Supported package managers"*) printf '仅支持 apt、dnf 或 yum' ;;
|
||||
"Using existing Java"*) printf '使用现有 Java:%s' "${message#Using existing Java 17+ runtime at }" ;;
|
||||
"Local Java verified: "*) printf '本机 Java 校验通过:%s' "${message#Local Java verified: }" ;;
|
||||
"No local Java"*) printf '未找到可用 Java 17,正在下载 Azul Java 17 运行时' ;;
|
||||
"Java 17 runtime download URL"*) printf 'Java 17 运行时下载地址无效' ;;
|
||||
"Downloaded Java verified: "*) printf '下载的 Java 校验通过:%s' "${message#Downloaded Java verified: }" ;;
|
||||
"The existing Java runtime cannot start"*) printf '现有 Java 运行时无法启动,请检查架构和权限' ;;
|
||||
"No Java 17 runtime is published"*) printf '当前 Linux 架构没有可用的 Java 17 运行时' ;;
|
||||
"Java 17 runtime SHA-256 verification failed") printf 'Java 17 运行时 SHA-256 校验失败' ;;
|
||||
"Downloaded Java runtime architecture"*) printf '下载的 Java 运行时架构与主机不匹配' ;;
|
||||
"The downloaded Java runtime cannot start"*) printf '下载的 Java 运行时无法启动,请检查 glibc 和主机架构' ;;
|
||||
"Java 17 runtime SHA-256 is unavailable") printf 'Java 17 运行时缺少 SHA-256 摘要' ;;
|
||||
"Existing user "*" has unexpected home directory "*) printf '现有用户目录不符合 Kaidi 约定:%s' "${message#Existing user }" ;;
|
||||
"Service-user filesystem and Java access checks passed") printf '服务用户文件系统与 Java 访问检查通过' ;;
|
||||
*" cannot traverse or read the active release") printf '服务用户无法进入或读取当前发布目录' ;;
|
||||
"The selected Java runtime is unavailable"*) printf '选定的 Java 运行时不可用:%s' "${message#The selected Java runtime is unavailable at }" ;;
|
||||
"The selected Java runtime cannot execute"*) printf '服务用户无法执行选定的 Java 运行时' ;;
|
||||
*" cannot execute the selected Java runtime") printf '服务用户无法执行选定的 Java 运行时' ;;
|
||||
*" cannot execute as "*) printf '服务用户无法启动 Java 运行时' ;;
|
||||
*" cannot write the file-storage directory") printf '服务用户无法写入文件存储目录' ;;
|
||||
*" cannot write the update inbox") printf '服务用户无法写入更新请求目录' ;;
|
||||
"SELinux context restoration reported"*) printf 'SELinux 上下文恢复有提示,将继续进行服务访问检查' ;;
|
||||
"No interactive terminal detected"*) printf '未检测到交互终端,使用应用端口 %s' "${message##*port }" ;;
|
||||
"Application port "*" is already held"*) printf '应用端口已由现有 Kaidi 服务占用:%s' "${message#Application port }" ;;
|
||||
"Application port "*" is already in use"*) printf '应用端口已被占用,请使用 KAIDI_APP_PORT 更换端口' ;;
|
||||
"Application will bind "*) printf '应用监听地址:%s' "${message#Application will bind }" ;;
|
||||
"Reverse proxy target: "*) printf '反向代理目标:%s' "${message#Reverse proxy target: }" ;;
|
||||
"KAIDI_SERVER_ADDRESS contains"*) printf 'KAIDI_SERVER_ADDRESS 含有不支持的字符' ;;
|
||||
"KAIDI_APP_PORT must be an integer"*) printf 'KAIDI_APP_PORT 必须是 1024 到 65535 的整数' ;;
|
||||
"Do not pass database credentials"*) printf '向导模式不要在命令行传数据库账号密码,请在浏览器向导中填写' ;;
|
||||
"KAIDI_DB_USERNAME and KAIDI_DB_PASSWORD"*) printf '设置 KAIDI_DB_URL 时必须同时提供数据库用户名和密码' ;;
|
||||
"An external MySQL 8.4 database is required"*) printf '需要连接用户预先准备的 MySQL 8.4 数据库:请设置 KAIDI_DB_URL、KAIDI_DB_USERNAME、KAIDI_DB_PASSWORD' ;;
|
||||
"KAIDI_REINSTALL needs"*) printf 'KAIDI_REINSTALL 需要读取已有安装配置或提供数据库配置' ;;
|
||||
"The database host is empty") printf '数据库主机不能为空' ;;
|
||||
"The database port is invalid") printf '数据库端口无效' ;;
|
||||
"The database name is invalid") printf '数据库名无效,只能包含字母、数字、下划线和美元符号' ;;
|
||||
"KAIDI_DB_URL must start with jdbc:mysql://") printf 'KAIDI_DB_URL 必须以 jdbc:mysql:// 开头' ;;
|
||||
"KAIDI_DB_USERNAME is empty") printf 'KAIDI_DB_USERNAME 不能为空' ;;
|
||||
"KAIDI_DB_PASSWORD is empty") printf 'KAIDI_DB_PASSWORD 不能为空' ;;
|
||||
"KAIDI_DB_BACKUP_MODE must be skip or mysqldump") printf 'KAIDI_DB_BACKUP_MODE 只能是 skip 或 mysqldump' ;;
|
||||
"Waiting for application startup"*) printf '正在等待应用启动:%s' "${message#Waiting for application startup at }" ;;
|
||||
"Application health check is UP") printf '应用健康检查通过(UP)' ;;
|
||||
"Application is still starting"*) printf '应用仍在启动:%s' "${message#Application is still starting }" ;;
|
||||
"Application service failed"*) printf '应用服务启动失败:%s' "${message#Application service failed during startup }" ;;
|
||||
"Application health check did not become UP") printf '应用健康检查未变为 UP,请查看服务日志' ;;
|
||||
"Application frontend entry point is unavailable") printf '应用前端入口不可访问,请检查服务和端口' ;;
|
||||
"Installation failed; restoring"*) printf '安装失败,正在恢复原有运行状态' ;;
|
||||
"ERROR: rollback was incomplete"*) printf '错误:回滚未完成,请检查 systemd 状态' ;;
|
||||
*" contains a line break") printf '配置项包含换行符,已拒绝:%s' "${message%% contains a line break*}" ;;
|
||||
"Kaidi Finance "*" is installed") printf 'Kaidi 财务系统 %s 已安装' "${message#Kaidi Finance }" ;;
|
||||
"Configure your reverse proxy"*) printf '请将反向代理指向:%s' "${message#Configure your reverse proxy to }" ;;
|
||||
"Open /setup"*) printf '请通过反向代理域名打开 /setup,完成首次安装向导' ;;
|
||||
"Setup code: "*) printf '安装码位置:%s' "${message#Setup code: }" ;;
|
||||
"Open the reverse-proxy domain"*) printf '请打开反向代理域名并使用管理员账号登录' ;;
|
||||
"Temporary credentials: "*) printf '临时凭据位置:%s' "${message#Temporary credentials: }" ;;
|
||||
"Change the temporary password"*) printf '首次登录后请立即修改临时密码' ;;
|
||||
"Release public-key SHA-256 does not match"*) printf '发布公钥 SHA-256 与受信指纹不一致' ;;
|
||||
"Release manifest signature verification failed") printf '发布清单签名校验失败' ;;
|
||||
"Release version is invalid") printf '发布版本无效' ;;
|
||||
"Release artifact name is invalid") printf '发布包文件名无效' ;;
|
||||
"Release SHA-256 is invalid") printf '发布包 SHA-256 无效' ;;
|
||||
"Release artifact SHA-256 verification failed") printf '发布包 SHA-256 校验失败' ;;
|
||||
"Release archive contains an unsafe path") printf '发布归档包含不安全路径,已拒绝' ;;
|
||||
"Release archive must not contain symbolic links") printf '发布归档不能包含符号链接,已拒绝' ;;
|
||||
"Release app.jar is missing") printf '发布包缺少 app.jar' ;;
|
||||
"Release frontend is missing") printf '发布包缺少前端文件' ;;
|
||||
"Release version mismatch") printf '发布包版本与清单不一致' ;;
|
||||
"Release updater is missing") printf '发布包缺少更新脚本' ;;
|
||||
"Baota Spring Boot launcher is missing") printf '发布包缺少宝塔启动脚本' ;;
|
||||
"Baota Spring Boot launcher is invalid") printf '宝塔启动脚本语法无效' ;;
|
||||
"The existing FIELD_ENCRYPTION_KEY is missing") printf '已有安装缺少 FIELD_ENCRYPTION_KEY' ;;
|
||||
"Installed systemd units failed validation") printf '已安装的 systemd 单元校验失败' ;;
|
||||
"Environment verified: "*) printf '环境检查通过:%s' "${message#Environment verified: }" ;;
|
||||
*) printf '%s' "$message" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
log() { printf '[kaidi-install] %s\n' "$(localize_message "$*")"; }
|
||||
die() { printf '[kaidi-install] 错误:%s\n' "$(localize_message "$*")" >&2; exit 1; }
|
||||
|
||||
sha256_file() {
|
||||
sha256sum "$1" | awk '{print $1}'
|
||||
@@ -208,15 +324,23 @@ download_release_asset() {
|
||||
install_packages() {
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq ca-certificates coreutils curl findutils gzip jq openssl tar util-linux
|
||||
apt-get update -qq >/dev/null 2>&1 \
|
||||
|| die "系统软件源更新失败,请检查网络和 apt 配置"
|
||||
apt-get install -y -qq ca-certificates coreutils curl findutils gzip jq openssl tar util-linux \
|
||||
>/dev/null 2>&1 \
|
||||
|| die "系统依赖安装失败;安装器不会安装 MySQL 或 Docker"
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
dnf install -y ca-certificates coreutils curl findutils gzip jq openssl tar util-linux
|
||||
dnf install -y ca-certificates coreutils curl findutils gzip jq openssl tar util-linux \
|
||||
>/dev/null 2>&1 \
|
||||
|| die "系统依赖安装失败;安装器不会安装 MySQL 或 Docker"
|
||||
elif command -v yum >/dev/null 2>&1; then
|
||||
yum install -y ca-certificates coreutils curl findutils gzip jq openssl tar util-linux
|
||||
yum install -y ca-certificates coreutils curl findutils gzip jq openssl tar util-linux \
|
||||
>/dev/null 2>&1 \
|
||||
|| die "系统依赖安装失败;安装器不会安装 MySQL 或 Docker"
|
||||
else
|
||||
die "Supported package managers are apt, dnf, and yum"
|
||||
fi
|
||||
log "系统依赖检查完成(未安装 MySQL、MariaDB、Docker)"
|
||||
}
|
||||
|
||||
preflight_runtime_commands() {
|
||||
@@ -230,31 +354,6 @@ preflight_runtime_commands() {
|
||||
done
|
||||
}
|
||||
|
||||
mysql_client_bin() {
|
||||
local candidate
|
||||
if [ -n "${KAIDI_MYSQL_CLIENT:-}" ]; then
|
||||
case "$KAIDI_MYSQL_CLIENT" in
|
||||
*/*) [ -x "$KAIDI_MYSQL_CLIENT" ] && printf '%s\n' "$KAIDI_MYSQL_CLIENT" && return 0 ;;
|
||||
*) candidate=$(command -v "$KAIDI_MYSQL_CLIENT" 2>/dev/null || true); [ -n "$candidate" ] && printf '%s\n' "$candidate" && return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
fi
|
||||
candidate=$(command -v mysql 2>/dev/null || command -v mariadb 2>/dev/null || true)
|
||||
if [ -n "$candidate" ]; then
|
||||
printf '%s\n' "$candidate"
|
||||
return 0
|
||||
fi
|
||||
for candidate in \
|
||||
/www/server/mysql/bin/mysql /www/server/mysql/bin/mariadb \
|
||||
/usr/bin/mysql /usr/bin/mariadb /usr/local/mysql/bin/mysql /opt/mysql/bin/mysql; do
|
||||
if [ -x "$candidate" ]; then
|
||||
printf '%s\n' "$candidate"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
azul_arch() {
|
||||
case "${HOST_ARCH:-$(normalized_host_arch)}" in
|
||||
x86_64) printf x86 ;;
|
||||
@@ -327,15 +426,15 @@ system_java_home() {
|
||||
}
|
||||
|
||||
prepare_java() {
|
||||
local api java_metadata package_metadata package_uuid java_url java_sha256 actual_sha256 system_home java_line
|
||||
local api java_metadata package_metadata package_uuid java_url java_sha256 actual_sha256 system_home java_line java_version
|
||||
JAVA_BIN=
|
||||
JAVA_STAGED_DIR=
|
||||
if system_home=$(system_java_home); then
|
||||
JAVA_BIN="$system_home/bin/java"
|
||||
java_line=$("$JAVA_BIN" -version 2>&1 | head -n 1) \
|
||||
|| die "The existing Java runtime cannot start on this host"
|
||||
log "Using existing Java 17+ runtime at $system_home"
|
||||
log "Local Java verified: $java_line"
|
||||
java_version=$(printf '%s\n' "$java_line" | sed -n 's/.*version "\([0-9][0-9.]*\).*/\1/p')
|
||||
log "使用现有 Java 运行时:$system_home(版本 ${java_version:-未知})"
|
||||
return 0
|
||||
fi
|
||||
log "No local Java 17 runtime found; downloading the official Azul Java 17 runtime"
|
||||
@@ -362,7 +461,8 @@ prepare_java() {
|
||||
java_line=$("$JAVA_STAGED_DIR/bin/java" -version 2>&1 | head -n 1) \
|
||||
|| die "The downloaded Java runtime cannot start; verify glibc and the host architecture"
|
||||
JAVA_BIN="$APP_ROOT/runtime/java/bin/java"
|
||||
log "Downloaded Java verified: $java_line"
|
||||
java_version=$(printf '%s\n' "$java_line" | sed -n 's/.*version "\([0-9][0-9.]*\).*/\1/p')
|
||||
log "下载的 Java 运行时校验通过(版本 ${java_version:-未知})"
|
||||
}
|
||||
|
||||
activate_java() {
|
||||
@@ -383,6 +483,7 @@ ensure_service_identity() {
|
||||
command -v getent >/dev/null 2>&1 || die "getent is required"
|
||||
if ! getent group "$SERVICE_GROUP" >/dev/null 2>&1; then
|
||||
groupadd --system "$SERVICE_GROUP"
|
||||
SERVICE_GROUP_CREATED=true
|
||||
fi
|
||||
if id "$SERVICE_USER" >/dev/null 2>&1; then
|
||||
existing_home=$(getent passwd "$SERVICE_USER" | awk -F: '{print $6}')
|
||||
@@ -397,10 +498,27 @@ ensure_service_identity() {
|
||||
[ -n "$nologin_path" ] || nologin_path=/usr/sbin/nologin
|
||||
[ -x "$nologin_path" ] || die "A nologin shell is required"
|
||||
useradd --system --gid "$SERVICE_GROUP" --home-dir "$STATE_ROOT" --shell "$nologin_path" "$SERVICE_USER"
|
||||
SERVICE_USER_CREATED=true
|
||||
}
|
||||
|
||||
record_layout_path() {
|
||||
[ -e "$1" ] || [ -L "$1" ] || CREATED_LAYOUT_PATHS+=("$1")
|
||||
}
|
||||
|
||||
prepare_managed_layout() {
|
||||
command -v runuser >/dev/null 2>&1 || die "runuser is required"
|
||||
record_layout_path "$APP_ROOT"
|
||||
record_layout_path "$APP_ROOT/releases"
|
||||
record_layout_path "$APP_ROOT/runtime"
|
||||
record_layout_path "$APP_ROOT/bin"
|
||||
record_layout_path "$STATE_ROOT"
|
||||
record_layout_path "$STATE_ROOT/files"
|
||||
record_layout_path "$STATE_ROOT/tmp"
|
||||
record_layout_path /var/log/kaidi
|
||||
record_layout_path "$STATE_ROOT/setup"
|
||||
record_layout_path "$UPDATE_STATE_ROOT"
|
||||
record_layout_path "$UPDATE_STATE_ROOT/inbox"
|
||||
record_layout_path "$CONFIG_ROOT"
|
||||
install -d -o root -g "$SERVICE_GROUP" -m 0750 \
|
||||
"$APP_ROOT" "$APP_ROOT/releases" "$APP_ROOT/runtime" "$APP_ROOT/bin"
|
||||
install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0750 \
|
||||
@@ -529,7 +647,7 @@ configure_app_port() {
|
||||
default_port=$(read_reinstall_env SERVER_PORT || true)
|
||||
[[ "$default_port" =~ ^[0-9]{1,5}$ ]] || default_port=18080
|
||||
if [ -t 1 ] && [ -r /dev/tty ]; then
|
||||
printf '[kaidi-install] Application port [%s]: ' "$default_port" > /dev/tty
|
||||
printf '[kaidi-install] 应用端口 [%s]:' "$default_port" > /dev/tty
|
||||
if IFS= read -r entered < /dev/tty; then
|
||||
APP_PORT=${entered:-$default_port}
|
||||
else
|
||||
@@ -586,9 +704,11 @@ 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
|
||||
# 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
|
||||
@@ -613,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%%:*}}"
|
||||
@@ -620,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##*:}
|
||||
@@ -629,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
|
||||
validate_database_configuration() {
|
||||
[ "$SETUP_WIZARD" != true ] || return 0
|
||||
case "$DB_URL" in
|
||||
jdbc:mysql://*) ;;
|
||||
*) die "KAIDI_DB_URL must start with jdbc:mysql://" ;;
|
||||
esac
|
||||
client=$(mysql_client_bin || true)
|
||||
[ -n "$client" ] || die "A MySQL command-line client is required; set KAIDI_MYSQL_CLIENT or add mysql/mariadb to PATH"
|
||||
host=$(database_host)
|
||||
port=$(database_port)
|
||||
name=$(database_name)
|
||||
[ -n "$host" ] || die "The database host is empty"
|
||||
[[ "$port" =~ ^[0-9]{1,5}$ ]] && [ "$port" -ge 1 ] && [ "$port" -le 65535 ] \
|
||||
[ -n "$DB_USERNAME" ] || die "KAIDI_DB_USERNAME is empty"
|
||||
[ -n "$DB_PASSWORD" ] || die "KAIDI_DB_PASSWORD is empty"
|
||||
[ -n "$(database_host)" ] || die "The database host is empty"
|
||||
[[ "$(database_port)" =~ ^[0-9]{1,5}$ ]] \
|
||||
&& [ "$(database_port)" -ge 1 ] && [ "$(database_port)" -le 65535 ] \
|
||||
|| die "The database port is invalid"
|
||||
[[ "$name" =~ ^[A-Za-z0-9_$]+$ ]] || die "The database name is invalid"
|
||||
[[ "$(database_name)" =~ ^[A-Za-z0-9_$]+$ ]] || die "The database name is invalid"
|
||||
# The installer deliberately does not invoke mysql/mariadb and does not
|
||||
# execute DDL/DML. The application performs Flyway migrations only after
|
||||
# it has connected to the operator-provided schema.
|
||||
log "已记录外部 MySQL 连接配置;安装器不安装 MySQL、不连接数据库、不执行 SQL"
|
||||
}
|
||||
|
||||
version=$(MYSQL_PWD="$DB_PASSWORD" "$client" --protocol=TCP --connect-timeout=10 \
|
||||
-h "$host" -P "$port" -u "$DB_USERNAME" "$name" --batch --skip-column-names \
|
||||
-e 'SELECT VERSION()' 2>/dev/null) || die "Cannot connect to the configured MySQL database"
|
||||
case "$version" in
|
||||
8.4.*) ;;
|
||||
*) die "MySQL 8.4.x is required; server reported $version" ;;
|
||||
# Kept as a compatibility name for older local fixtures and wrappers. It is
|
||||
# now a pure configuration check and has no database side effects.
|
||||
preflight_database() {
|
||||
validate_database_configuration
|
||||
}
|
||||
|
||||
validate_database_backup_mode() {
|
||||
case "${KAIDI_DB_BACKUP_MODE:-skip}" in
|
||||
skip|mysqldump) ;;
|
||||
*) die "KAIDI_DB_BACKUP_MODE must be skip or mysqldump" ;;
|
||||
esac
|
||||
|
||||
table="kaidi_install_preflight_$$"
|
||||
if ! MYSQL_PWD="$DB_PASSWORD" "$client" --protocol=TCP --connect-timeout=10 \
|
||||
-h "$host" -P "$port" -u "$DB_USERNAME" "$name" >/dev/null <<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() {
|
||||
@@ -835,7 +946,7 @@ restore_managed_path() {
|
||||
}
|
||||
|
||||
rollback_install() {
|
||||
local index=0 path rollback_failed=false
|
||||
local index=0 path rollback_failed=false layout_path service_uid
|
||||
set +e
|
||||
log "Installation failed; restoring the previous managed state"
|
||||
systemctl stop kaidi-update.path kaidi-finance.service >/dev/null 2>&1 || true
|
||||
@@ -853,6 +964,19 @@ rollback_install() {
|
||||
elif [ "$JAVA_ACTIVATED" = true ]; then
|
||||
rm -rf -- "$APP_ROOT/runtime/java" || rollback_failed=true
|
||||
fi
|
||||
if [ "$SERVICE_USER_CREATED" = true ]; then
|
||||
service_uid=$(id -u "$SERVICE_USER" 2>/dev/null || true)
|
||||
if [ -n "$service_uid" ]; then
|
||||
userdel --force "$SERVICE_USER" >/dev/null 2>&1 || rollback_failed=true
|
||||
fi
|
||||
fi
|
||||
if [ "$SERVICE_GROUP_CREATED" = true ] && getent group "$SERVICE_GROUP" >/dev/null 2>&1; then
|
||||
groupdel "$SERVICE_GROUP" >/dev/null 2>&1 || rollback_failed=true
|
||||
fi
|
||||
for ((index=${#CREATED_LAYOUT_PATHS[@]} - 1; index >= 0; index--)); do
|
||||
layout_path=${CREATED_LAYOUT_PATHS[$index]}
|
||||
rmdir -- "$layout_path" >/dev/null 2>&1 || true
|
||||
done
|
||||
systemctl daemon-reload >/dev/null 2>&1 || rollback_failed=true
|
||||
restore_unit_state kaidi-finance.service "$PREVIOUS_APP_ENABLED" "$PREVIOUS_APP_ACTIVE" \
|
||||
|| rollback_failed=true
|
||||
@@ -877,6 +1001,7 @@ main() {
|
||||
trap cleanup EXIT
|
||||
|
||||
validate_inputs
|
||||
validate_database_backup_mode
|
||||
configure_app_port
|
||||
preflight_host
|
||||
install_packages
|
||||
@@ -1020,13 +1145,13 @@ write_env_file "$CONFIG_ROOT/update.env" \
|
||||
KAIDI_JAVA_BIN "$JAVA_BIN" \
|
||||
KAIDI_HEALTH_URL "$HEALTH_URL" \
|
||||
KAIDI_APP_INDEX_URL "$APP_INDEX_URL" \
|
||||
KAIDI_DB_CONTAINER "${KAIDI_DB_CONTAINER:-}" \
|
||||
KAIDI_DB_HOST "$(database_host)" \
|
||||
KAIDI_DB_PORT "$(database_port)" \
|
||||
KAIDI_DB_NAME "$(database_name)" \
|
||||
KAIDI_DB_USERNAME "$DB_USERNAME" \
|
||||
KAIDI_DB_PASSWORD "$DB_PASSWORD" \
|
||||
KAIDI_MYSQLDUMP_BIN "${KAIDI_MYSQLDUMP_BIN:-}"
|
||||
KAIDI_MYSQLDUMP_BIN "${KAIDI_MYSQLDUMP_BIN:-}" \
|
||||
KAIDI_DB_BACKUP_MODE "${KAIDI_DB_BACKUP_MODE:-skip}"
|
||||
chmod 0600 "$CONFIG_ROOT/update.env"
|
||||
|
||||
install -m 0644 "$RELEASE_DIR/ops/kaidi-finance.service" /etc/systemd/system/kaidi-finance.service
|
||||
@@ -1052,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
|
||||
|
||||
+57
-3
@@ -16,9 +16,40 @@ 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}
|
||||
|
||||
log() { printf '[kaidi-purge] %s\n' "$*"; }
|
||||
die() { printf '[kaidi-purge] ERROR: %s\n' "$*" >&2; exit 1; }
|
||||
localize_message() {
|
||||
local message=$1
|
||||
[ "$LOG_LOCALE" = en ] && { printf '%s' "$message"; return; }
|
||||
case "$message" in
|
||||
"Run with sudo or as root") printf '请使用 sudo 或 root 运行' ;;
|
||||
"The purge script only supports Linux") printf '卸载程序仅支持 Linux' ;;
|
||||
"Set KAIDI_PURGE_CONFIRM="*) printf '请设置确认变量:KAIDI_PURGE_CONFIRM=%s' "${message#Set KAIDI_PURGE_CONFIRM=}" ;;
|
||||
"KAIDI_PURGE_BACKUP_ROOT must be an absolute path") printf 'KAIDI_PURGE_BACKUP_ROOT 必须是绝对路径' ;;
|
||||
"KAIDI_PURGE_DELETE_BACKUP must be true or false") printf 'KAIDI_PURGE_DELETE_BACKUP 只能是 true 或 false' ;;
|
||||
"The recovery backup must be outside"*) printf '恢复备份目录必须位于 Kaidi 管理目录之外' ;;
|
||||
"The recovery backup root must not be a symbolic link") printf '恢复备份目录不能是符号链接' ;;
|
||||
"Failed to stop "*) printf '停止服务失败:%s' "${message#Failed to stop }" ;;
|
||||
"Stopping processes owned by"*) printf '正在停止专用服务账号进程:%s' "${message##*: }" ;;
|
||||
"Stopping remaining Kaidi processes: "*) printf '正在停止剩余 Kaidi 进程:%s' "${message#Stopping remaining Kaidi processes: }" ;;
|
||||
"A process manager restarted"*) printf '检测到宝塔等进程管理器正在重新拉起 Kaidi;请先停止并删除宝塔中的 Kaidi 项目,再重试卸载' ;;
|
||||
"No local configuration or data"*) printf '没有需要备份的本地配置或数据' ;;
|
||||
"Creating a root-only recovery backup at "*) printf '正在创建仅 root 可读的恢复备份:%s' "${message#Creating a root-only recovery backup at }" ;;
|
||||
"Failed to remove the dedicated"*) printf '删除 Kaidi 专用服务账号失败' ;;
|
||||
"Processes owned by the removed"*) printf '删除服务账号后仍有进程运行' ;;
|
||||
"Keeping pre-existing user"*) printf '保留预先存在的用户:%s' "${message#Keeping pre-existing user }" ;;
|
||||
"Keeping group "*) printf '保留仍被其他账号使用的用户组:%s' "${message#Keeping group }" ;;
|
||||
"External MySQL data and reverse-proxy configuration will not be modified") printf '不会修改外部 MySQL 数据和反向代理配置' ;;
|
||||
"Local Kaidi installation state has been removed") printf '本地 Kaidi 安装文件、服务和运行状态已删除' ;;
|
||||
"Recovery backup: "*) printf '恢复备份:%s' "${message#Recovery backup: }" ;;
|
||||
"Use a new empty MySQL database for the next installation") printf '重新安装时请使用新的空 MySQL 数据库' ;;
|
||||
*) printf '%s' "$message" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
log() { printf '[kaidi-purge] %s\n' "$(localize_message "$*")"; }
|
||||
die() { printf '[kaidi-purge] 错误:%s\n' "$(localize_message "$*")" >&2; exit 1; }
|
||||
|
||||
systemd_available() {
|
||||
command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]
|
||||
@@ -40,6 +71,10 @@ validate_inputs() {
|
||||
;;
|
||||
esac
|
||||
[ ! -L "$BACKUP_ROOT" ] || die "The recovery backup root must not be a symbolic link"
|
||||
case "$DELETE_RECOVERY_BACKUP" in
|
||||
true|false) ;;
|
||||
*) die "KAIDI_PURGE_DELETE_BACKUP must be true or false" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
stop_systemd_units() {
|
||||
@@ -206,7 +241,20 @@ remove_managed_paths() {
|
||||
}
|
||||
|
||||
remove_download_archives() {
|
||||
rm -f -- /tmp/kaidi-finance-*.tar.gz
|
||||
rm -f -- \
|
||||
/tmp/kaidi-finance-*.tar.gz \
|
||||
/tmp/kaidi-purge.sh \
|
||||
/tmp/kaidi-SHA256SUMS \
|
||||
/tmp/kaidi-install.sh
|
||||
}
|
||||
|
||||
verify_managed_paths_removed() {
|
||||
local path
|
||||
for path in "$APP_ROOT" "$BAOTA_ROOT" "$CONFIG_ROOT" "$STATE_ROOT" \
|
||||
"$UPDATE_STATE_ROOT" "$LOG_ROOT" "$FIRST_LOGIN_FILE"; do
|
||||
[ ! -e "$path" ] && [ ! -L "$path" ] \
|
||||
|| die "卸载后仍发现受管路径:$path"
|
||||
done
|
||||
}
|
||||
|
||||
remove_service_identity() {
|
||||
@@ -258,9 +306,15 @@ main() {
|
||||
remove_download_archives
|
||||
remove_service_identity
|
||||
stop_managed_processes false
|
||||
verify_managed_paths_removed
|
||||
log "Local Kaidi installation state has been removed"
|
||||
if [ -n "$BACKUP_ARCHIVE" ]; then
|
||||
log "Recovery backup: $BACKUP_ARCHIVE"
|
||||
if [ "$DELETE_RECOVERY_BACKUP" = true ]; then
|
||||
rm -f -- "$BACKUP_ARCHIVE"
|
||||
rmdir -- "$BACKUP_ROOT" >/dev/null 2>&1 || true
|
||||
log "已按 KAIDI_PURGE_DELETE_BACKUP=true 删除恢复备份"
|
||||
fi
|
||||
fi
|
||||
log "Use a new empty MySQL database for the next installation"
|
||||
}
|
||||
|
||||
+223
-40
@@ -30,6 +30,7 @@ HEALTH_URL=${KAIDI_HEALTH_URL:-http://127.0.0.1:18080/actuator/health}
|
||||
APP_INDEX_URL=${KAIDI_APP_INDEX_URL:-http://127.0.0.1:18080/}
|
||||
LOCK_FILE=$STATE_ROOT/update.lock
|
||||
BACKUP_ROOT=${KAIDI_BACKUP_ROOT:-$STATE_ROOT/backups}
|
||||
DB_BACKUP_MODE=${KAIDI_DB_BACKUP_MODE:-skip}
|
||||
UPDATER_PATH=${KAIDI_UPDATER_PATH:-$APP_ROOT/bin/update.sh}
|
||||
SYSTEMD_ROOT=${KAIDI_SYSTEMD_ROOT:-/etc/systemd/system}
|
||||
LOG_ROOT=${KAIDI_LOG_ROOT:-/var/log/kaidi}
|
||||
@@ -40,38 +41,176 @@ WORK_DIR=
|
||||
CACHE_TEMP=
|
||||
RELEASE_AUTH_HEADER_FILE=
|
||||
TARGET_VERSION=
|
||||
REQUEST_ID=
|
||||
REQUEST_ACTION=
|
||||
RELEASE_NOTES=
|
||||
RELEASE_PUBLISHED_AT=
|
||||
TERMINAL_STATUS_WRITTEN=false
|
||||
DOWNLOAD_PID=
|
||||
DOWNLOAD_PGID=
|
||||
LAST_DOWNLOAD_SPEED=0
|
||||
SURFACE_FAILURE=
|
||||
LOG_LOCALE=${KAIDI_LOG_LOCALE:-zh-CN}
|
||||
|
||||
localize_message() {
|
||||
message=$1
|
||||
[ "$LOG_LOCALE" = en ] && { printf '%s' "$message"; return; }
|
||||
# A rollback failure must keep the manual-recovery signal visible even when
|
||||
# the original reason also contains a more specific error prefix.
|
||||
case "$message" in
|
||||
*"rollback is incomplete and manual recovery is required"*)
|
||||
printf '新版本应用验证失败,回滚未完成,需要人工恢复'
|
||||
return
|
||||
;;
|
||||
esac
|
||||
case "$message" in
|
||||
"Update health-check settings must be non-negative integers") printf '更新健康检查参数必须是非负整数' ;;
|
||||
"Update health-check attempts must be at least 1") printf '更新健康检查次数至少为 1' ;;
|
||||
"KAIDI_PROCESS_MANAGER must be systemd or baota") printf 'KAIDI_PROCESS_MANAGER 只能是 systemd 或 baota' ;;
|
||||
"KAIDI_UPDATE_SHUTDOWN_ATTEMPTS must be a positive integer") printf 'KAIDI_UPDATE_SHUTDOWN_ATTEMPTS 必须是正整数' ;;
|
||||
"KAIDI_UPDATE_SHUTDOWN_ATTEMPTS must be at least 1") printf 'KAIDI_UPDATE_SHUTDOWN_ATTEMPTS 至少为 1' ;;
|
||||
"KAIDI_PID_FILE must be an absolute path") printf 'KAIDI_PID_FILE 必须是绝对路径' ;;
|
||||
"KAIDI_RUNTIME_ENV_FILE must be an absolute path") printf 'KAIDI_RUNTIME_ENV_FILE 必须是绝对路径' ;;
|
||||
"KAIDI_DB_BACKUP_MODE must be skip or mysqldump") printf 'KAIDI_DB_BACKUP_MODE 只能是 skip 或 mysqldump' ;;
|
||||
"Setup runtime environment must be a regular file") printf '安装向导运行时配置必须是普通文件' ;;
|
||||
"Setup runtime environment is not readable") printf '安装向导运行时配置不可读' ;;
|
||||
"Setup runtime environment is too large") printf '安装向导运行时配置过大' ;;
|
||||
"Setup runtime environment has an unexpected owner") printf '安装向导运行时配置属主不正确' ;;
|
||||
"Setup runtime environment must not be writable"*) printf '安装向导运行时配置不能被组或其他用户写入' ;;
|
||||
"Setup runtime environment contains an invalid line") printf '安装向导运行时配置包含无效行' ;;
|
||||
"Setup runtime environment "*) printf '安装向导运行时配置错误,请检查 application.env' ;;
|
||||
"Setup runtime database port is invalid") printf '安装向导数据库端口无效' ;;
|
||||
"Database configuration is invalid: DB_URL is missing"*) printf '数据库配置无效:缺少 DB_URL,未重启应用' ;;
|
||||
"Database configuration is invalid: database host and name are missing"*) printf '数据库配置无效:缺少数据库主机或库名,未重启应用' ;;
|
||||
"Database configuration is invalid: DB_URL does not match"*) printf '数据库配置无效:DB_URL 与主机、端口、库名不一致,未重启应用' ;;
|
||||
"UPDATE_RELEASE_TOKEN is invalid") printf 'UPDATE_RELEASE_TOKEN 无效' ;;
|
||||
"UPDATE_RELEASE_BASE_URL or UPDATE_RELEASE_API_URL is not configured") printf '未配置更新源地址' ;;
|
||||
"Service user "*" is missing") printf '缺少服务用户:%s' "$(printf '%s' "${message#Service user }" | sed 's/ is missing$//')" ;;
|
||||
"Service user "*" is not a member"*) printf '服务用户不属于指定服务组' ;;
|
||||
"runuser is required") printf '需要 runuser' ;;
|
||||
"setsid is required") printf '需要 setsid' ;;
|
||||
"Managed application or update directories could not be prepared") printf '应用或更新目录准备失败' ;;
|
||||
"Writable update directories could not be prepared") printf '可写更新目录准备失败' ;;
|
||||
"Private update directories could not be prepared") printf '私有更新目录准备失败' ;;
|
||||
"Private update queue directories could not be prepared") printf '私有更新队列目录准备失败' ;;
|
||||
"Downloading signed release "*) printf '正在下载已签名版本:%s' "${message#Downloading signed release }" ;;
|
||||
"Release "*" download completed") printf '版本下载完成:%s' "${message#Release }" ;;
|
||||
"Validating signed release "*) printf '正在校验签名版本:%s' "${message#Validating signed release }" ;;
|
||||
"Revalidating cached release "*) printf '正在重新校验已缓存版本:%s' "${message#Revalidating cached release }" ;;
|
||||
"Release "*" is downloaded and verified; confirm installation") printf '版本已下载并校验,请确认安装:%s' "${message#Release }" ;;
|
||||
"Release "*" is running") printf '版本运行中:%s' "${message#Release }" ;;
|
||||
*"automatic update watcher could not be started"*) printf '自动更新监听器未能启动' ;;
|
||||
"Version "*" is already installed") printf '版本已安装:%s' "${message#Version }" ;;
|
||||
"Starting and verifying release "*) printf '正在启动并验证版本:%s' "${message#Starting and verifying release }" ;;
|
||||
"Validating current release before switching to "*) printf '切换前正在验证当前版本(目标:%s)' "${message#Validating current release before switching to }" ;;
|
||||
"Backing up database before installing "*) printf '安装前正在备份数据库(目标:%s)' "${message#Backing up database before installing }" ;;
|
||||
"Database backup skipped; updater does not access MySQL") printf '已跳过数据库备份;更新器不会访问 MySQL' ;;
|
||||
"Installing release "*) printf '正在安装版本:%s' "${message#Installing release }" ;;
|
||||
"Update process was interrupted"*) printf '更新进程被中断,系统将执行自动恢复' ;;
|
||||
"Update transaction evidence could not be quarantined"*) printf '更新事务证据无法隔离,已停止自动更新' ;;
|
||||
"Update request could not be archived"*) printf '更新请求无法归档,已停止自动更新' ;;
|
||||
"Update service is locked for manual recovery"*) printf '更新服务已锁定,需要人工恢复;新请求已拒绝' ;;
|
||||
"Processing update request must be a regular file") printf '处理中更新请求必须是普通文件' ;;
|
||||
"Update request must be a regular file") printf '更新请求必须是普通文件' ;;
|
||||
"Claimed update request must be a regular file") printf '已领取的更新请求必须是普通文件' ;;
|
||||
"Verified release cache is missing") printf '缺少已校验的发布缓存' ;;
|
||||
"Cached release manifest is missing") printf '缺少缓存的发布清单' ;;
|
||||
"Cached release signature is missing") printf '缺少缓存的发布签名' ;;
|
||||
"Cached release artifact is missing") printf '缺少缓存的发布包' ;;
|
||||
"Update request version is invalid") printf '更新请求版本无效' ;;
|
||||
"Update request action is invalid") printf '更新请求动作无效' ;;
|
||||
"Release manifest "*" is missing") printf '缺少发布清单资产' ;;
|
||||
"Release manifest download failed") printf '发布清单下载失败' ;;
|
||||
"Release manifest signature download failed") printf '发布清单签名下载失败' ;;
|
||||
"Gitea latest Release lookup failed") printf 'Gitea 最新 Release 查询失败' ;;
|
||||
"Release manifest signature verification failed") printf '发布清单签名校验失败' ;;
|
||||
"Requested version is no longer the latest signed release") printf '请求版本已不是最新签名版本,请重新获取版本' ;;
|
||||
"Release artifact download failed") printf '发布包下载失败' ;;
|
||||
"Release artifact asset is missing") printf '缺少发布包资产' ;;
|
||||
"Release artifact SHA-256 verification failed") printf '发布包 SHA-256 校验失败' ;;
|
||||
"Release artifact size verification failed") printf '发布包大小校验失败' ;;
|
||||
"Release archive contains an unsafe path") printf '发布归档包含不安全路径,已拒绝' ;;
|
||||
"Release archive must not contain symbolic links") printf '发布归档不能包含符号链接,已拒绝' ;;
|
||||
"Release application restart failed") printf '新版本应用重启失败' ;;
|
||||
"Release application verification failed"*) printf '新版本应用验证失败:%s' "${message#Release application verification failed }" ;;
|
||||
"Current release preflight failed"*) printf '当前版本预检查失败,未重启应用:%s' "${message#Current release preflight failed }" ;;
|
||||
"Current release path is invalid"*) printf '当前版本路径无效,未重启应用' ;;
|
||||
"Service user cannot access the release or selected Java runtime") printf '服务用户无法访问发布目录或 Java 运行时' ;;
|
||||
"Release operations files could not be backed up") printf '旧版本运维文件备份失败' ;;
|
||||
"Existing operations files could not be backed up") printf '已有运维文件备份失败' ;;
|
||||
"Release directory is already active") printf '目标版本目录已处于 active 状态' ;;
|
||||
"Failed release staging directory could not be cleaned") printf '失败版本暂存目录清理失败' ;;
|
||||
"Release directory could not be activated") printf '目标版本目录无法启用' ;;
|
||||
"Release ownership or permissions could not be secured") printf '目标版本目录权限保护失败' ;;
|
||||
"Release operations validation failed") printf '发布运维文件校验失败' ;;
|
||||
"Release public key is missing") printf '发布公钥缺失' ;;
|
||||
"Release verification public key is missing") printf '缺少发布校验公钥' ;;
|
||||
"Release manifest version is invalid") printf '发布清单版本无效' ;;
|
||||
"Release artifact name is invalid") printf '发布包文件名无效' ;;
|
||||
"Release SHA-256 is invalid") printf '发布包 SHA-256 无效' ;;
|
||||
"Release artifact size is invalid") printf '发布包大小无效' ;;
|
||||
"Release archive could not be listed") printf '发布归档无法读取' ;;
|
||||
"Release archive could not be extracted") printf '发布归档无法解压' ;;
|
||||
"Release app.jar is missing") printf '发布包缺少 app.jar' ;;
|
||||
"Release frontend is missing") printf '发布包缺少前端入口' ;;
|
||||
"Release version file mismatch") printf '发布包 VERSION 与目标版本不一致' ;;
|
||||
"Release updater is missing") printf '发布包缺少更新脚本' ;;
|
||||
"Release Baota launcher is missing") printf '发布包缺少宝塔启动脚本' ;;
|
||||
"Release Baota initializer is missing") printf '发布包缺少宝塔初始化脚本' ;;
|
||||
"Release application unit is missing") printf '发布包缺少应用 systemd 单元' ;;
|
||||
"Release updater unit is missing") printf '发布包缺少更新 systemd 单元' ;;
|
||||
"Release updater path unit is missing") printf '发布包缺少更新监听单元' ;;
|
||||
"Verified release cache path is unsafe") printf '已校验发布缓存路径不安全' ;;
|
||||
"Verified release cache could not be activated") printf '已校验发布缓存无法启用' ;;
|
||||
"Database backup failed") printf '数据库备份失败' ;;
|
||||
"Database backup is empty") printf '数据库备份为空' ;;
|
||||
"Database backup compression failed") printf '数据库备份压缩失败' ;;
|
||||
"mysqldump is required"*) printf '更新前需要 mysqldump;请配置 KAIDI_MYSQLDUMP_BIN 或安装客户端' ;;
|
||||
"Release operations files could not be activated") printf '发布运维文件无法切换' ;;
|
||||
"Release application link could not be activated") printf '应用 current 链接无法切换' ;;
|
||||
*"previous application release was restored and verified; database backup was retained"*) printf '已恢复并验证旧版本;数据库备份已保留' ;;
|
||||
*"previous application release was restored and verified; no database backup was created"*) printf '已恢复并验证旧版本;本次未创建数据库备份' ;;
|
||||
*"previous application release is running"*) printf '旧版本正在运行,但运维文件需要人工检查' ;;
|
||||
*) printf '%s' "$message" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
case "$HEALTH_ATTEMPTS:$HEALTH_INTERVAL_SECONDS" in
|
||||
*[!0-9:]* | :* | *:) printf '%s\n' "Update health-check settings must be non-negative integers" >&2; exit 1 ;;
|
||||
*[!0-9:]* | :* | *:) printf '%s\n' "$(localize_message 'Update health-check settings must be non-negative integers')" >&2; exit 1 ;;
|
||||
esac
|
||||
[ "$HEALTH_ATTEMPTS" -ge 1 ] || { printf '%s\n' "Update health-check attempts must be at least 1" >&2; exit 1; }
|
||||
[ "$HEALTH_ATTEMPTS" -ge 1 ] || { printf '%s\n' "$(localize_message 'Update health-check attempts must be at least 1')" >&2; exit 1; }
|
||||
case "$PROCESS_MANAGER" in
|
||||
systemd|baota) ;;
|
||||
*) printf '%s\n' "KAIDI_PROCESS_MANAGER must be systemd or baota" >&2; exit 1 ;;
|
||||
*) printf '%s\n' "$(localize_message 'KAIDI_PROCESS_MANAGER must be systemd or baota')" >&2; exit 1 ;;
|
||||
esac
|
||||
case "$SHUTDOWN_ATTEMPTS" in
|
||||
''|*[!0-9]*) printf '%s\n' "KAIDI_UPDATE_SHUTDOWN_ATTEMPTS must be a positive integer" >&2; exit 1 ;;
|
||||
''|*[!0-9]*) printf '%s\n' "$(localize_message 'KAIDI_UPDATE_SHUTDOWN_ATTEMPTS must be a positive integer')" >&2; exit 1 ;;
|
||||
esac
|
||||
[ "$SHUTDOWN_ATTEMPTS" -ge 1 ] || { printf '%s\n' "KAIDI_UPDATE_SHUTDOWN_ATTEMPTS must be at least 1" >&2; exit 1; }
|
||||
[ "$SHUTDOWN_ATTEMPTS" -ge 1 ] || { printf '%s\n' "$(localize_message 'KAIDI_UPDATE_SHUTDOWN_ATTEMPTS must be at least 1')" >&2; exit 1; }
|
||||
case "$PID_FILE" in
|
||||
/*) ;;
|
||||
*) printf '%s\n' "KAIDI_PID_FILE must be an absolute path" >&2; exit 1 ;;
|
||||
*) printf '%s\n' "$(localize_message 'KAIDI_PID_FILE must be an absolute path')" >&2; exit 1 ;;
|
||||
esac
|
||||
case "$RUNTIME_ENV_FILE" in
|
||||
/*) ;;
|
||||
*) printf '%s\n' "KAIDI_RUNTIME_ENV_FILE must be an absolute path" >&2; exit 1 ;;
|
||||
*) printf '%s\n' "$(localize_message 'KAIDI_RUNTIME_ENV_FILE must be an absolute path')" >&2; exit 1 ;;
|
||||
esac
|
||||
case "${KAIDI_SKIP_DB_BACKUP:-}" in
|
||||
true) DB_BACKUP_MODE=skip ;;
|
||||
false)
|
||||
[ -n "${KAIDI_DB_BACKUP_MODE:-}" ] || DB_BACKUP_MODE=mysqldump
|
||||
;;
|
||||
esac
|
||||
case "$DB_BACKUP_MODE" in
|
||||
skip|mysqldump) ;;
|
||||
*) printf '%s\n' "$(localize_message 'KAIDI_DB_BACKUP_MODE must be skip or mysqldump')" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
bootstrap_die() {
|
||||
if [ -f "$PROCESSING_FILE" ] && [ ! -L "$PROCESSING_FILE" ]; then
|
||||
fail "$1"
|
||||
fi
|
||||
printf '%s\n' "$1" >&2
|
||||
printf '%s\n' "$(localize_message "$1")" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -149,7 +288,7 @@ validate_runtime_database_config() {
|
||||
|
||||
status() {
|
||||
state=$1
|
||||
message=$2
|
||||
message=$(localize_message "$2")
|
||||
version=${3:-}
|
||||
downloaded_bytes=${4:-}
|
||||
total_bytes=${5:-}
|
||||
@@ -158,10 +297,34 @@ status() {
|
||||
restart_expected_seconds=${8:-}
|
||||
previous_state=
|
||||
previous_message=
|
||||
previous_request_id=
|
||||
previous_action=
|
||||
previous_release_notes=
|
||||
previous_published_at=
|
||||
if [ -f "$STATUS_FILE" ] && [ ! -L "$STATUS_FILE" ]; then
|
||||
previous_state=$(jq -r '.state // empty' "$STATUS_FILE" 2>/dev/null || true)
|
||||
previous_message=$(jq -r '.message // empty' "$STATUS_FILE" 2>/dev/null || true)
|
||||
previous_request_id=$(jq -r '.requestId // empty | strings | select(length <= 64)' \
|
||||
"$STATUS_FILE" 2>/dev/null || true)
|
||||
previous_action=$(jq -r '.action // empty | strings | ascii_upcase \
|
||||
| select(. == "DOWNLOAD" or . == "INSTALL")' "$STATUS_FILE" 2>/dev/null || true)
|
||||
previous_release_notes=$(jq -r '(.releaseNotes // "") | strings | .[0:4000]' \
|
||||
"$STATUS_FILE" 2>/dev/null || true)
|
||||
previous_published_at=$(jq -r '(.publishedAt // "") | strings | .[0:128]' \
|
||||
"$STATUS_FILE" 2>/dev/null || true)
|
||||
fi
|
||||
status_request_id=
|
||||
status_action=
|
||||
if [ -f "$PROCESSING_FILE" ] && [ ! -L "$PROCESSING_FILE" ]; then
|
||||
status_request_id=$(jq -r '.requestId // empty | strings | select(length > 0 and length <= 64)' \
|
||||
"$PROCESSING_FILE" 2>/dev/null || true)
|
||||
status_action=$(jq -r '.action // empty | strings | ascii_upcase \
|
||||
| select(. == "DOWNLOAD" or . == "INSTALL")' "$PROCESSING_FILE" 2>/dev/null || true)
|
||||
fi
|
||||
[ -n "$status_request_id" ] || status_request_id=${REQUEST_ID:-$previous_request_id}
|
||||
[ -n "$status_action" ] || status_action=${REQUEST_ACTION:-$previous_action}
|
||||
status_release_notes=${RELEASE_NOTES:-$previous_release_notes}
|
||||
status_published_at=${RELEASE_PUBLISHED_AT:-$previous_published_at}
|
||||
tmp="$STATUS_FILE.tmp.$$"
|
||||
jq -n \
|
||||
--arg state "$state" \
|
||||
@@ -172,9 +335,17 @@ status() {
|
||||
--arg bytesPerSecond "$bytes_per_second" \
|
||||
--arg downloadPercent "$download_percent" \
|
||||
--arg restartExpectedSeconds "$restart_expected_seconds" \
|
||||
--arg requestId "$status_request_id" \
|
||||
--arg action "$status_action" \
|
||||
--arg releaseNotes "$status_release_notes" \
|
||||
--arg publishedAt "$status_published_at" \
|
||||
--arg updatedAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
'{state:$state,message:$message,updatedAt:$updatedAt}
|
||||
+ (if ($version | length) > 0 then {targetVersion:$version} else {} end)
|
||||
+ (if ($requestId | length) > 0 then {requestId:$requestId} else {} end)
|
||||
+ (if ($action == "DOWNLOAD" or $action == "INSTALL") then {action:$action} else {} end)
|
||||
+ (if ($releaseNotes | length) > 0 then {releaseNotes:$releaseNotes} else {} end)
|
||||
+ (if ($publishedAt | length) > 0 then {publishedAt:$publishedAt} else {} end)
|
||||
+ (if ($downloadedBytes | test("^[0-9]+$")) then {downloadedBytes:($downloadedBytes | tonumber)} else {} end)
|
||||
+ (if ($totalBytes | test("^[0-9]+$")) then {totalBytes:($totalBytes | tonumber)} else {} end)
|
||||
+ (if ($bytesPerSecond | test("^[0-9]+$")) then {bytesPerSecond:($bytesPerSecond | tonumber)} else {} end)
|
||||
@@ -227,9 +398,10 @@ complete_request() {
|
||||
|
||||
fail() {
|
||||
TERMINAL_STATUS_WRITTEN=true
|
||||
status FAILED "$1" "${TARGET_VERSION:-}"
|
||||
message=$(localize_message "$1")
|
||||
status FAILED "$message" "${TARGET_VERSION:-}"
|
||||
archive_processing_request || true
|
||||
printf '%s\n' "$1" >&2
|
||||
printf '%s\n' "$message" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -601,7 +773,10 @@ mysqldump_bin() {
|
||||
|
||||
backup_database() {
|
||||
DATABASE_BACKUP=
|
||||
[ "${KAIDI_SKIP_DB_BACKUP:-false}" = "true" ] && return
|
||||
if [ "$DB_BACKUP_MODE" = skip ]; then
|
||||
event INFO BACKING_UP '数据库备份已跳过;更新器不会访问 MySQL'
|
||||
return 0
|
||||
fi
|
||||
mkdir -p "$BACKUP_ROOT"
|
||||
chmod 0700 "$BACKUP_ROOT"
|
||||
dump_file=$(mktemp "$BACKUP_ROOT/.mysql-dump.XXXXXX.sql")
|
||||
@@ -609,26 +784,16 @@ backup_database() {
|
||||
backup_tmp="$backup.tmp.$$"
|
||||
chmod 0600 "$dump_file"
|
||||
|
||||
if [ -n "${KAIDI_DB_CONTAINER:-}" ] && command -v docker >/dev/null 2>&1 \
|
||||
&& docker inspect "$KAIDI_DB_CONTAINER" >/dev/null 2>&1; then
|
||||
if ! docker exec -e MYSQL_PWD="${KAIDI_DB_PASSWORD:-}" "$KAIDI_DB_CONTAINER" \
|
||||
mysqldump --single-transaction --routines --triggers \
|
||||
-u "${KAIDI_DB_USERNAME:-kaidi}" "${KAIDI_DB_NAME:-kaidi_finance}" > "$dump_file"; then
|
||||
rm -f "$dump_file" "$backup_tmp"
|
||||
fail "Database backup failed"
|
||||
fi
|
||||
else
|
||||
dump_bin=$(mysqldump_bin || true)
|
||||
[ -n "$dump_bin" ] || {
|
||||
rm -f "$dump_file" "$backup_tmp"
|
||||
fail "mysqldump is required before installing an update; set KAIDI_MYSQLDUMP_BIN or install it in a standard MySQL bin directory"
|
||||
}
|
||||
if ! MYSQL_PWD=${KAIDI_DB_PASSWORD:-} "$dump_bin" --single-transaction --routines --triggers \
|
||||
-h "${KAIDI_DB_HOST:-127.0.0.1}" -P "${KAIDI_DB_PORT:-3306}" \
|
||||
-u "${KAIDI_DB_USERNAME:-kaidi}" "${KAIDI_DB_NAME:-kaidi_finance}" > "$dump_file"; then
|
||||
rm -f "$dump_file" "$backup_tmp"
|
||||
fail "Database backup failed"
|
||||
fi
|
||||
dump_bin=$(mysqldump_bin || true)
|
||||
[ -n "$dump_bin" ] || {
|
||||
rm -f "$dump_file" "$backup_tmp"
|
||||
fail "mysqldump is required before installing an update; set KAIDI_MYSQLDUMP_BIN or install it in a standard MySQL bin directory"
|
||||
}
|
||||
if ! MYSQL_PWD=${KAIDI_DB_PASSWORD:-} "$dump_bin" --single-transaction --routines --triggers \
|
||||
-h "${KAIDI_DB_HOST:-127.0.0.1}" -P "${KAIDI_DB_PORT:-3306}" \
|
||||
-u "${KAIDI_DB_USERNAME:-kaidi}" "${KAIDI_DB_NAME:-kaidi_finance}" > "$dump_file"; then
|
||||
rm -f "$dump_file" "$backup_tmp"
|
||||
fail "Database backup failed"
|
||||
fi
|
||||
|
||||
[ -s "$dump_file" ] || {
|
||||
@@ -643,8 +808,12 @@ backup_database() {
|
||||
mv -f "$backup_tmp" "$backup"
|
||||
DATABASE_BACKUP=$backup
|
||||
rm -f "$dump_file"
|
||||
find "$BACKUP_ROOT" -type f -name 'mysql-*.sql.gz' -printf '%T@ %p\n' \
|
||||
| sort -nr | awk 'NR > 5 {sub(/^[^ ]+ /, ""); print}' | xargs -r rm -f
|
||||
# Keep the newest five backups without relying on GNU find's -printf; the
|
||||
# updater is also exercised on BSD/macOS fixtures during release checks.
|
||||
# shellcheck disable=SC2012 # Generated backup names contain no whitespace.
|
||||
LC_ALL=C ls -1t "$BACKUP_ROOT"/mysql-*.sql.gz 2>/dev/null \
|
||||
| awk 'NR > 5' \
|
||||
| while IFS= read -r old_backup; do rm -f -- "$old_backup"; done
|
||||
}
|
||||
|
||||
atomic_install() {
|
||||
@@ -796,26 +965,30 @@ rollback_active_transaction() {
|
||||
&& verify_app_surface "$previous_target"; then
|
||||
remove_failed_release "$failed_release"
|
||||
if [ "$operations_restored" = true ]; then
|
||||
database_backup_record=$(transaction_value database-backup)
|
||||
rm -rf "$ACTIVE_TRANSACTION"
|
||||
fail "$reason; previous application release was restored and verified; database backup was retained"
|
||||
if [ -n "$database_backup_record" ] || [ -n "${DATABASE_BACKUP:-}" ]; then
|
||||
fail "$reason; previous application release was restored and verified; database backup was retained"
|
||||
fi
|
||||
fail "$reason; previous application release was restored and verified; no database backup was created"
|
||||
fi
|
||||
fail "$reason; previous application release is running, but operations restoration requires manual review; transaction evidence was retained"
|
||||
fi
|
||||
TERMINAL_STATUS_WRITTEN=true
|
||||
recovery_guard_failed=false
|
||||
if ! archive_processing_request; then
|
||||
printf '%s\n' "Update request could not be archived after an incomplete rollback" >&2
|
||||
printf '%s\n' "$(localize_message 'Update request could not be archived after an incomplete rollback')" >&2
|
||||
recovery_guard_failed=true
|
||||
fi
|
||||
if ! quarantine_active_transaction; then
|
||||
printf '%s\n' "Update transaction evidence could not be quarantined after an incomplete rollback" >&2
|
||||
printf '%s\n' "$(localize_message 'Update transaction evidence could not be quarantined after an incomplete rollback')" >&2
|
||||
recovery_guard_failed=true
|
||||
fi
|
||||
if [ "$recovery_guard_failed" = true ]; then
|
||||
systemctl stop "$UPDATE_PATH_NAME" >/dev/null 2>&1 || true
|
||||
fi
|
||||
status RECOVERY_REQUIRED "$reason; rollback is incomplete and manual recovery is required" "${TARGET_VERSION:-}"
|
||||
printf '%s\n' "$reason; rollback is incomplete and manual recovery is required" >&2
|
||||
printf '%s\n' "$(localize_message "$reason; rollback is incomplete and manual recovery is required")" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -885,7 +1058,7 @@ if [ -f "$STATUS_FILE" ] && [ ! -L "$STATUS_FILE" ] \
|
||||
if ! archive_processing_request; then
|
||||
systemctl stop "$UPDATE_PATH_NAME" >/dev/null 2>&1 || true
|
||||
fi
|
||||
printf '%s\n' "Update service is locked for manual recovery; new requests are rejected" >&2
|
||||
printf '%s\n' "$(localize_message 'Update service is locked for manual recovery; new requests are rejected')" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -898,6 +1071,8 @@ TARGET_VERSION=$REQUESTED_VERSION
|
||||
REQUEST_ACTION=$(jq -er '(.action // "INSTALL") | strings | ascii_upcase
|
||||
| select(. == "DOWNLOAD" or . == "INSTALL")' "$PROCESSING_FILE") \
|
||||
|| fail "Update request action is invalid"
|
||||
REQUEST_ID=$(jq -r '.requestId // empty | strings | select(length > 0 and length <= 64)' \
|
||||
"$PROCESSING_FILE" 2>/dev/null || true)
|
||||
prepare_update_layout
|
||||
reset_event_log
|
||||
load_runtime_database_env
|
||||
@@ -958,6 +1133,10 @@ EXPECTED_SHA=$(jq -er '.sha256 | strings | ascii_downcase | select(test("^[0-9a-
|
||||
"$WORK_DIR/release-manifest.json") || fail "Release SHA-256 is invalid"
|
||||
EXPECTED_SIZE=$(jq -er '(.artifactSizeBytes // 0) | numbers | floor | select(. >= 0)' \
|
||||
"$WORK_DIR/release-manifest.json") || fail "Release artifact size is invalid"
|
||||
RELEASE_NOTES=$(jq -r '(.releaseNotes // "") | strings | .[0:4000]' \
|
||||
"$WORK_DIR/release-manifest.json") || fail "Release notes are invalid"
|
||||
RELEASE_PUBLISHED_AT=$(jq -r '(.publishedAt // "") | strings | .[0:128]' \
|
||||
"$WORK_DIR/release-manifest.json") || fail "Release publish time is invalid"
|
||||
|
||||
CURRENT_VERSION=$(cat "$APP_ROOT/current/VERSION" 2>/dev/null || true)
|
||||
if [ "$CURRENT_VERSION" = "$TARGET_VERSION" ]; then
|
||||
@@ -1053,7 +1232,11 @@ write_transaction_value previous-target "$PREVIOUS_TARGET"
|
||||
write_transaction_value release-dir "$RELEASE_DIR"
|
||||
write_transaction_value phase PREPARED
|
||||
|
||||
status BACKING_UP "Backing up database before installing $TARGET_VERSION" "$TARGET_VERSION"
|
||||
if [ "$DB_BACKUP_MODE" = mysqldump ]; then
|
||||
status BACKING_UP "Backing up database before installing $TARGET_VERSION" "$TARGET_VERSION"
|
||||
else
|
||||
status BACKING_UP "Database backup skipped; updater does not access MySQL" "$TARGET_VERSION"
|
||||
fi
|
||||
backup_database
|
||||
[ -z "${DATABASE_BACKUP:-}" ] || write_transaction_value database-backup "$DATABASE_BACKUP"
|
||||
if ! backup_operations; then
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
# 系统经营仪表盘设计与验收说明
|
||||
|
||||
## 1. 交付目标
|
||||
|
||||
本轮新增 `PAGE-24 /dashboard`,作为登录并选择工作身份后的默认业务首页。页面采用“日常经营驾驶舱 + 系统运行监控 + 全屏故事巡航”的混合形态,同时遵循以下边界:
|
||||
|
||||
- 仪表盘是独立权限节点 `dashboard:overview:view`;未授权身份看不到菜单,也不能直接访问接口。
|
||||
- 超级管理员默认拥有仪表盘权限;业务身份由“权限与配置”按岗位需要授予。
|
||||
- 每个指标仍校验原业务权限和 `GLOBAL / COMPANY / PROJECT` 数据范围,不能因为拥有仪表盘权限而扩大数据可见范围。
|
||||
- 金额按币种分别查询,不做跨币种相加。
|
||||
- 当前项目没有可靠省市字段,因此不伪造地图点位;地图区域明确显示数据能力说明,后续补齐项目地域主数据后再接真实地图。
|
||||
- 大屏使用现有 Vue、TDesign 和 ECharts 实现,不增加不透明的第三方大屏运行时依赖。
|
||||
|
||||
## 2. 页面形态
|
||||
|
||||
```text
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ 系统经营仪表盘 币种[CNY⌄] 刷新数据 进入大屏 │
|
||||
├──────────────────────────────────────────────────────────────────────┤
|
||||
│ ● 应用正常 ● 数据库正常 ● 文件存储 ● 更新服务 版本/运行时长 │
|
||||
├──────────┬──────────┬──────────┬──────────┬──────────┬───────────┤
|
||||
│进行中项目│ 合同总额 │ 累计收款 │ 累计付款 │ 待办审批 │ 风险事项 │
|
||||
├──────────────────────────────────┬───────────────────────────────────┤
|
||||
│ 近六个月资金趋势(收款/付款/开票)│ 项目生命周期分布(环形图) │
|
||||
├──────────────────────────────────┼───────────────────────────────────┤
|
||||
│ 风险与异常(项目/审批/付款/档案) │ 地域能力区(无真实地域时明确降级)│
|
||||
├──────────────────────────────────┴───────────────────────────────────┤
|
||||
│ 最近业务活动 │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
全屏巡航:
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ 系统经营态势 章节 1/3 退出全屏 / Esc │
|
||||
├──────────────────────────────────────────────────────────────────────┤
|
||||
│ 第一幕:资金态势 → 第二幕:项目进程 → 第三幕:风险与系统健康 │
|
||||
│ 每 8 秒自动切换,可手动选择章节;保留真实图表和数据来源说明 │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 3. 功能清单与实现方式
|
||||
|
||||
| 功能 | 最终形态 | 实现方式 |
|
||||
| --- | --- | --- |
|
||||
| 默认首页 | 超级管理员及已授权身份进入 `/dashboard` | 后端角色工作台地址、前端回退地址和 Logo 返回地址统一使用仪表盘 |
|
||||
| 菜单首项 | 左侧菜单第一行“仪表盘” | PAGE-24 路由合同 + `orderNo=1` 单菜单组 |
|
||||
| 权限节点 | 可按角色授予 `dashboard:overview:view` | V074 写入权限;超级管理员自动获得角色权限和 GLOBAL scope |
|
||||
| 系统状态条 | 应用、数据库、文件存储、更新服务、版本、运行时长 | 后端聚合只返回允许公开的健康摘要,不暴露 Actuator 明细 |
|
||||
| 核心指标 | 进行中项目、合同额、收款、付款、待办、风险 | 每项独立检查底层业务权限;无权限时显示不可用状态而不是伪造零值 |
|
||||
| 资金趋势 | 最近六个月收款、付款、开票折线/柱形组合图 | 服务端按月、币种、授权范围聚合;ECharts 模块化加载 |
|
||||
| 生命周期 | 立项、合同、执行、验收、结算、关闭分布 | 服务端状态分桶;环形图和可跳转明细 |
|
||||
| 风险异常 | 项目风险、审批超时、付款阻断、档案缺件、隔离文件 | 复用现有业务口径和权限范围,显示级别、数量和跳转入口 |
|
||||
| 地图区域 | 高级视觉容器 + 数据能力说明 | 当前 `available=false`,明确不展示虚假地图;预留地域 DTO |
|
||||
| 最近活动 | 最近 8 条业务审计活动 | 使用当前用户审计活动,只返回必要标题、结果和目标路由 |
|
||||
| 自动刷新 | 60 秒自动刷新,也可手动刷新 | 页面定时器;离开页面时清理,避免后台重复请求 |
|
||||
| 大屏巡航 | 三章节全屏故事模式 | 深色全屏层、8 秒巡航、手动切换、Esc 退出、减少动态效果兼容 |
|
||||
| 响应式 | 1920、1440、1366、窄屏均不横向溢出 | 分段网格、弹性卡片、图表 ResizeObserver、窄屏重排 |
|
||||
| 图表生命周期 | 主题/尺寸变化后正确重建 | ECharts `ResizeObserver`、销毁 `dispose()`、ARIA 描述 |
|
||||
|
||||
## 4. 后端接口合同
|
||||
|
||||
```http
|
||||
GET /api/v1/dashboard/overview?currency=CNY
|
||||
Permission: dashboard:overview:view
|
||||
```
|
||||
|
||||
响应包含:
|
||||
|
||||
- `system`:总体状态、版本、运行时长、服务状态列表;
|
||||
- `metrics`:六项核心指标,包含 `available` 和目标路由;
|
||||
- `trend`:六个月资金趋势及各序列可见性;
|
||||
- `lifecycle`:项目阶段分布;
|
||||
- `risks`:风险异常列表;
|
||||
- `geography`:地域可用性、说明和区域数据;
|
||||
- `activities`:最近业务活动。
|
||||
|
||||
币种首期允许 `CNY / USD / EUR / HKD / JPY / GBP`。非法币种返回 422。接口、OpenAPI、前端 operation 合同保持一一对应。
|
||||
|
||||
## 5. 状态与异常规则
|
||||
|
||||
- 首次加载显示骨架屏;刷新时保留已有内容并显示加载状态。
|
||||
- 网络或后端失败显示中文错误提示和重试按钮。
|
||||
- 指标底层权限不足时只隐藏该指标的真实值与跳转,不影响其他已授权模块。
|
||||
- 无趋势数据时显示真实零序列和空态,不构造演示数值。
|
||||
- 文件目录不可读写时系统状态显示警告;在线更新关闭时显示“未启用”,不误判为服务宕机。
|
||||
- 地域数据未配置时展示明确说明,不加载虚假坐标、地图轮廓或项目点位。
|
||||
|
||||
## 6. 验收标准
|
||||
|
||||
### 6.1 权限与路由
|
||||
|
||||
- PAGE-24 存在于 routes、components、operations 三类合同中。
|
||||
- 仪表盘是左侧第一项;无 `dashboard:overview:view` 时菜单不可见、直达返回 403。
|
||||
- 业务角色获得该权限后可访问;超级管理员无须额外配置。
|
||||
- 超级管理员选择身份、点击文字 Logo、结果页返回首页时均进入 `/dashboard`。
|
||||
|
||||
### 6.2 数据正确性
|
||||
|
||||
- 所有查询同时使用当前用户、当前角色、权限码、有效期和数据范围。
|
||||
- 没有底层权限的组件返回 `available=false`,不能泄露金额、数量或跳转地址。
|
||||
- 金额保留数据库精度并按所选币种返回;不得跨币种合计。
|
||||
- 生命周期固定补齐六个阶段;没有数据的阶段返回 0。
|
||||
- 非法币种必须返回业务校验错误。
|
||||
|
||||
### 6.3 视觉与交互
|
||||
|
||||
- 日常驾驶舱、系统状态条、六个 KPI、两类图表、风险区、地域降级区、活动区完整显示。
|
||||
- 大屏支持自动巡航、手动章节切换、按钮退出和 Esc 退出。
|
||||
- 1366×768、1440×900、1920×1080 以及 390px 窄屏无页面横向溢出。
|
||||
- 图表存在可访问名称,容器变化后正确缩放,路由离开后释放实例。
|
||||
- 严重和致命级可访问性问题为 0,浏览器控制台无页面错误。
|
||||
|
||||
### 6.4 工程门禁
|
||||
|
||||
- 后端单元测试、架构授权扫描、Maven `verify` 通过。
|
||||
- 前端 ESLint、Stylelint、Vue 类型检查、Vitest、生产构建和产物卫生扫描通过。
|
||||
- Playwright 全页面回归、PAGE-24 专项、多视口、可访问性和生产产物冒烟通过。
|
||||
- OpenAPI 合同检查和 `git diff --check` 通过。
|
||||
|
||||
## 7. 本轮自验记录
|
||||
|
||||
| 验收项 | 结果 |
|
||||
| --- | --- |
|
||||
| 后端 `mvn verify` | 通过;本机无 Docker,Testcontainers 的 MySQL 集成类按项目既有配置跳过 |
|
||||
| Dashboard 后端单元测试与 Controller 授权扫描 | 通过 |
|
||||
| 前端 ESLint / Stylelint | 通过 |
|
||||
| Vitest | 13 个文件、64 项通过 |
|
||||
| Vue 类型检查 + release build + dist hygiene | 通过 |
|
||||
| PAGE-24 专项 Playwright | 三个桌面视口通过 |
|
||||
| PAGE-24 axe / 页面溢出 | 通过 |
|
||||
| 全页面 Playwright | 246 项均取得通过证据;三并发全量复跑出现 5 项环境性超时,改为单 worker 逐项复测后 5/5 通过 |
|
||||
| release dist 冒烟 | 1 项通过 |
|
||||
| OpenAPI / diff whitespace | 通过 |
|
||||
|
||||
## 8. 后续数据能力
|
||||
|
||||
地图进入真实交付前需为项目增加规范化地域字段(至少省、市、行政区代码),完成历史数据清洗、权限范围验证和坐标映射。地域字段未完成前继续保持当前降级形态,避免用项目名称、地址自由文本或随机坐标推断业务位置。
|
||||
+10
-10
@@ -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 或命令 |
|
||||
| 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 |
|
||||
| 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 密钥生成并独立验收恰好 10 个资产;先创建不可见草稿、逐项上传并核对名称/大小,最后发布为可被 `/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` 向导,数据库密码在浏览器中提交;安装器不安装 MySQL、不运行 MySQL 客户端,点击完成安装后由应用在专用 schema 中执行迁移 |
|
||||
| 更新可靠性 | 下载和安装拆为两个持久动作;下载阶段不停止业务服务,安装阶段只接受同版本 `READY` 缓存并重新验签。公共 Gitea 默认不使用 Token;如改接私有镜像,Token 仅从权限为 `0600` 的 root 配置/临时文件读取,不进入 `curl` 参数、页面、状态或日志,且只允许同源 API/资产使用;请求原子领取到持久 `processing`,systemd 限制失败重试;数据库备份默认为 `skip`,只有显式配置 `KAIDI_DB_BACKUP_MODE=mysqldump` 才调用已有工具;新旧版本健康和回滚均有独立门禁,跨来源拒绝、成功、健康回滚、下载失败、备份失败和不安全请求夹具纳入 CI |
|
||||
| 阶段口径 | 把“开发前冻结”更新为“R1 开发中”;明确模块级验收与整套 R1 交付是两个层级,避免一页完成后虚报整套系统完成 |
|
||||
| PAGE-13 页面 | 完成合同执行汇总、筛选、服务端分页、来源下钻、CSV 导出、合同详情及变更/结算/应付操作;页面仅使用现有 TDesign Starter 和 TDesign 组件 |
|
||||
| PAGE-13 后端 | 新增 `contractcost:payable:create`,项目经理可登记但不可确认;同人登记/确认返回 `422 SOD_VIOLATION`;应付必须关联已批准 OA-04,结算必须关联已批准 OA-11,同一来源不得重复消费 |
|
||||
@@ -2200,9 +2200,9 @@ staging/production 使用 Linux 容器或等价受控服务:反向代理仅对
|
||||
|
||||
运行日志在线保留 180 天;审计、导出和敏感访问日志首期按 10 年保留且不自动删除,最终保管期限由 D-09 确认。归档或到期处理必须由授权管理员显式执行、先导出校验并记录审批和哈希;处于项目档案、争议或审计冻结的记录不得处理。
|
||||
|
||||
第一版 Preview 的固定交付路径为私有 Gitea `ERP-Team/kaidi` 的 Git tag → Gitea Actions 全量门禁 → RSA 签名 Release → Linux 安装器。CI 先创建 draft,上传并核对恰好 9 个资产后再发布,生产服务器只读 Release API/资产,不执行 `git pull` 或现场编译。64 位 Linux 可由安装器创建 MySQL 8.4 容器;i386/i486/i586/i686 必须先准备外部 MySQL 8.4.x、数据库和具备本库 DDL/DML 权限的账号。两类主机均只运行一组经安装器 SHA-256、固定公钥指纹和只读 Gitea Token 保护的 curl 命令,具体可复制命令、占位符、建库、健康检查、停用和排障命令以仓库根 `README.md` 为准。
|
||||
第一版 Preview 的固定交付路径为私有 Gitea `ERP-Team/kaidi` 的 Git tag → Gitea Actions 全量门禁 → RSA 签名 Release → Linux 安装器。CI 先创建 draft,上传并核对签名资产后再发布,生产服务器只读 Release API/资产,不执行 `git pull` 或现场编译。所有架构均必须先准备外部 MySQL 8.4.x、专用空 schema 和具备本库迁移所需权限的账号;安装器不创建 MySQL 容器、不安装数据库、不调用 MySQL 客户端,也不执行 DDL/DML。点击首次向导的完成安装后,应用才在用户指定 schema 中运行 Flyway 并初始化管理员。在线更新默认不访问数据库;仅在显式设置 `KAIDI_DB_BACKUP_MODE=mysqldump` 时调用宿主机已有工具生成备份,不调用 Docker 或管理数据库服务。两类主机均只运行经安装器 SHA-256、固定公钥指纹保护的 curl 命令,具体可复制命令、健康检查、停用和排障命令以仓库根 `README.md` 为准。
|
||||
|
||||
在线更新是部署运维能力,不是 OA、银行、税务或财务业务第三方集成。它只允许访问 root 配置中固定的 HTTPS Gitea Release API 和同源资产,页面不能改变地址;生产可按网络策略禁用。下载缓存、请求领取、运维备份和事务阶段保存在 `/var/lib/kaidi-update`,主机重启后先恢复未提交事务;安装前必须确认 `mysqldump` 成功并生成 root-only 压缩备份。签名包内的应用、updater、systemd 单元和 Nginx 配置以临时文件加原子移动切换;配置、新版本直连、Nginx 健康、更新 path unit 和静态首页任一失败时恢复旧应用和旧运维文件,并再次验证旧版本健康。数据库迁移继续遵守至少一个版本向后兼容,数据库恢复须按备份恢复演练单独执行。
|
||||
在线更新是部署运维能力,不是 OA、银行、税务或财务业务第三方集成。它只允许访问 root 配置中固定的 HTTPS Gitea Release API 和同源资产,页面不能改变地址;生产可按网络策略禁用。下载缓存、请求领取、运维备份和事务阶段保存在 `/var/lib/kaidi-update`,主机重启后先恢复未提交事务;数据库备份默认为 `skip`,只有显式配置 `KAIDI_DB_BACKUP_MODE=mysqldump` 才生成 root-only 压缩备份。签名包内的应用、updater、systemd 单元和 Nginx 配置以临时文件加原子移动切换;配置、新版本直连、Nginx 健康、更新 path unit 和静态首页任一失败时恢复旧应用和旧运维文件,并再次验证旧版本健康。数据库迁移继续遵守至少一个版本向后兼容,若未启用备份则由运维在更新前自行完成外部备份。
|
||||
|
||||
### 11.15 全栈工程硬规范
|
||||
|
||||
@@ -2891,7 +2891,7 @@ PAGE-16 全类回归曾暴露测试证据文件路径硬编码与应用 `finance
|
||||
| 合同与供应链 | OpenAPI、Actionlint、ShellCheck 通过;npm 完整依赖树 `0 vulnerabilities` | CI 仍在 tag 发布时从干净环境重新执行全部门禁 |
|
||||
| 更新可靠性 | 成功切换、健康失败回滚、Release 下载失败、`mysqldump` 失败和不安全请求拒绝五类夹具通过 | 覆盖持久 processing/transaction、普通文件门禁、有效失败状态、0600 备份、应用、updater、systemd、Nginx、新旧健康验证和失败 Release 清理;真实 Linux systemd 主机仍须冒烟 |
|
||||
| Release 制品 | `1.0.0-preview.1` 应用 tar、RSA 3072 签名 manifest、公钥、SHA-256 清单、安装器、前后端 CycloneDX SBOM 统一生成并互相绑定;签名清单同时绑定应用、SBOM、安装器、公钥、bootstrap 摘要和源码修订,Maven、npm、JAR、SBOM 与 manifest 严格同版本,tag CI 强制干净来源 | 应用包 SHA-256=`45c44070b4b0857f202ea5767441f684bdda28095276e0ea1c75cd31dcddf39b`,安装器 SHA-256=`bf50b8ca0fcd1aebf2c05f0d80fa362a482f46ecf6e07f7cf5aa5d05d6370c21`,公钥 SHA-256=`807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9`;当前本地候选清单明确为 `source.ref=local`、`source.dirty=true`,实际 Git Release 仍待目标仓库 |
|
||||
| Linux 32 位 | 安装器 fixture 验证 i386/i486/i586/i686 均选择 Java 17 i686 JRE;外部 MySQL 8.4.x 先做连接、版本、DDL/DML 预检 | 属于“外部数据库已预置后的单命令安装”,不是 32 位主机内置 MySQL;正式 i686 systemd 主机仍须实装 |
|
||||
| Linux 32 位 | 安装器 fixture 验证 i386/i486/i586/i686 均选择 Java 17 i686 JRE;安装阶段只验证 JDBC 配置格式,数据库连接和迁移在首次向导完成安装时执行 | 属于“外部数据库已预置后的单命令安装”,不是 32 位主机内置 MySQL;正式 i686 systemd 主机仍须实装 |
|
||||
|
||||
**Preview 放行结论**:代码和本地签名制品满足第一版 Preview 候选条件,P0 代码阻断为零。实际对外 Release 只剩目标 GitHub 仓库、Release URL、CI 签名 Secret、公钥指纹 Variable 和服务器地址等部署输入;这些输入未配置前,不把本地候选包描述成已经公网部署。
|
||||
|
||||
@@ -2997,7 +2997,7 @@ PAGE-16 全类回归曾暴露测试证据文件路径硬编码与应用 `finance
|
||||
| 项目 | 结果 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 首次访问 | 已实现 | `KAIDI_SETUP_WIZARD=true` 时正式业务 DataSource、Flyway、JDBC Session 和业务路由不启动;`GET /api/v1/setup/status` 在无业务数据库时返回 `required=true`,前端 `/setup` 为未登录公共页 |
|
||||
| 数据库配置 | 已实现 | 首版只开放 MySQL 8.4.x;向导先执行版本和临时表 CREATE/INSERT/UPDATE/DROP 权限验证,再执行 72 个 Flyway 迁移;非空且无 Flyway 历史的数据库被拒绝 |
|
||||
| 数据库配置 | 已实现 | 首版只开放 MySQL 8.4.x;向导的“测试连接”只读取版本,不修改数据库;管理员明确点击“完成安装”后才执行迁移权限检查、字符集准备、Flyway 迁移和管理员初始化;非空或已有不一致迁移历史的数据库会在完成阶段给出可定位错误 |
|
||||
| 管理员初始化 | 已实现 | 操作者填写账号、显示名称和密码;创建 `SYSTEM_ADMIN`、逐项全局权限范围,并用 `kaidi_setup_installation` 单例状态支持提交后重试,避免重复创建超级管理员 |
|
||||
| 锁定与恢复 | 已实现 | 一次性安装码只保存 SHA-256;完成后原子写运行时环境和锁定标记,systemd 重启进入正式模式;向导未完成时可用 `REINSTALL=true + KAIDI_SETUP_WIZARD=true` 恢复并重新生成安装码,已锁定/正式模式拒绝该路径 |
|
||||
| 32 位 curl | 已实现 | i386/i486/i586/i686 只下载 i686 Java 17 JRE,不传数据库密码;管理员预建 MySQL 8.4 数据库后在 `/setup` 输入连接信息 |
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
{ "pageId": "PAGE-20", "required": ["Form", "Table", "Drawer", "Tag"] },
|
||||
{ "pageId": "PAGE-21", "required": ["Tabs", "Tree", "Table", "Form", "Dialog"] },
|
||||
{ "pageId": "PAGE-22", "required": ["Steps", "Table", "Alert", "Tag"] },
|
||||
{ "pageId": "PAGE-23", "required": ["Steps", "Form", "Input", "Button", "Alert"] }
|
||||
{ "pageId": "PAGE-23", "required": ["Steps", "Form", "Input", "Button", "Alert"] },
|
||||
{ "pageId": "PAGE-24", "required": ["Card", "Statistic", "Tag", "Select", "Timeline", "Empty", "Alert"] }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1861,6 +1861,16 @@
|
||||
"module": "system-update",
|
||||
"export": "installSystemUpdate"
|
||||
}
|
||||
},
|
||||
{
|
||||
"operationId": "getDashboardOverview",
|
||||
"method": "GET",
|
||||
"path": "/api/v1/dashboard/overview",
|
||||
"pages": ["PAGE-24"],
|
||||
"api": {
|
||||
"module": "dashboard",
|
||||
"export": "getDashboardOverview"
|
||||
}
|
||||
}
|
||||
],
|
||||
"infrastructure": [
|
||||
|
||||
@@ -276,6 +276,18 @@
|
||||
"breadcrumb": ["系统治理", "系统更新"],
|
||||
"roles": ["SYSTEM_ADMIN"],
|
||||
"permission": "admin:update:view"
|
||||
},
|
||||
{
|
||||
"pageId": "PAGE-24",
|
||||
"path": "/dashboard",
|
||||
"component": "src/pages/overview/DashboardPage.vue",
|
||||
"pageType": "business",
|
||||
"pageTemplate": "operations-dashboard",
|
||||
"requiresAuth": true,
|
||||
"description": "统一展示经营指标、资金趋势、项目生命周期、风险异常和系统运行状态,并提供大屏巡航模式。",
|
||||
"breadcrumb": ["仪表盘"],
|
||||
"roles": [],
|
||||
"permission": "dashboard:overview:view"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ const routes = (
|
||||
).routes;
|
||||
|
||||
const projectId = '01KZRXMHB04HR8Y23HTH1F7DXV';
|
||||
const visibleHeadingPageIds = new Set(['PAGE-01', 'PAGE-02', 'PAGE-08', 'PAGE-10', 'PAGE-23']);
|
||||
const visibleHeadingPageIds = new Set(['PAGE-01', 'PAGE-02', 'PAGE-08', 'PAGE-10', 'PAGE-23', 'PAGE-24']);
|
||||
|
||||
const roleNames: Record<string, string> = {
|
||||
PROJECT_MANAGER: '项目管理人员',
|
||||
@@ -42,6 +42,7 @@ function expectedHeading(route: ContractRoute) {
|
||||
if (route.pageId === 'PAGE-03') return '个人中心';
|
||||
if (route.pageId === 'PAGE-08') return '自动化验收项目';
|
||||
if (route.pageId === 'PAGE-17') return '项目档案包';
|
||||
if (route.pageId === 'PAGE-24') return '系统经营仪表盘';
|
||||
return route.breadcrumb.at(-1) || '';
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import type { Page, Route } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const adminSession = {
|
||||
authenticated: true,
|
||||
user: {
|
||||
publicId: '01M00000000000000000000001',
|
||||
username: 'dashboard-e2e',
|
||||
displayName: '仪表盘验收管理员',
|
||||
departmentName: '测试组',
|
||||
mustChangePassword: false,
|
||||
},
|
||||
roles: [{ code: 'SYSTEM_ADMIN', name: '超级管理员', workbenchRoute: '/dashboard' }],
|
||||
activeRole: 'SYSTEM_ADMIN',
|
||||
permissions: [
|
||||
'dashboard:overview:view',
|
||||
'project:project:view',
|
||||
'masterdata:contract:view',
|
||||
'receivable:receipt:view',
|
||||
'receivable:invoice:view',
|
||||
'payment:request:view',
|
||||
'workflow:task:view',
|
||||
'project:risk-flag:view',
|
||||
'archive:package:view',
|
||||
'archive:file:view',
|
||||
],
|
||||
};
|
||||
|
||||
const dashboard = {
|
||||
title: '系统经营仪表盘',
|
||||
refreshedAt: '2026-08-19T04:00:00Z',
|
||||
currency: 'CNY',
|
||||
system: {
|
||||
overallStatus: 'UP',
|
||||
currentVersion: '1.0.0-preview.48',
|
||||
uptimeSeconds: 7260,
|
||||
services: [
|
||||
{ code: 'APPLICATION', label: '应用服务', status: 'UP', detail: '服务运行正常' },
|
||||
{ code: 'DATABASE', label: '数据库连接', status: 'UP', detail: '业务数据库连接正常' },
|
||||
{ code: 'FILE_STORAGE', label: '文件存储', status: 'UP', detail: '存储目录可读写' },
|
||||
{ code: 'UPDATE', label: '更新服务', status: 'UP', detail: '在线更新服务已启用' },
|
||||
],
|
||||
},
|
||||
metrics: [
|
||||
{
|
||||
code: 'ACTIVE_PROJECTS',
|
||||
label: '进行中项目',
|
||||
kind: 'COUNT',
|
||||
value: '8',
|
||||
unit: '项',
|
||||
available: true,
|
||||
targetRoute: '/projects',
|
||||
},
|
||||
{
|
||||
code: 'CONTRACT_AMOUNT',
|
||||
label: '合同总额',
|
||||
kind: 'MONEY',
|
||||
value: '1234567.89',
|
||||
unit: 'CNY',
|
||||
available: true,
|
||||
targetRoute: '/finance/contracts-costs',
|
||||
},
|
||||
{
|
||||
code: 'RECEIPT_AMOUNT',
|
||||
label: '累计收款',
|
||||
kind: 'MONEY',
|
||||
value: '456789.00',
|
||||
unit: 'CNY',
|
||||
available: true,
|
||||
targetRoute: '/finance/receipts-invoices',
|
||||
},
|
||||
{
|
||||
code: 'PAYMENT_AMOUNT',
|
||||
label: '累计付款',
|
||||
kind: 'MONEY',
|
||||
value: '234567.00',
|
||||
unit: 'CNY',
|
||||
available: true,
|
||||
targetRoute: '/finance/payments',
|
||||
},
|
||||
{
|
||||
code: 'PENDING_TASKS',
|
||||
label: '待办审批',
|
||||
kind: 'COUNT',
|
||||
value: '4',
|
||||
unit: '项',
|
||||
available: true,
|
||||
targetRoute: '/tasks',
|
||||
},
|
||||
{
|
||||
code: 'ACTIVE_RISKS',
|
||||
label: '风险事项',
|
||||
kind: 'COUNT',
|
||||
value: '2',
|
||||
unit: '项',
|
||||
available: true,
|
||||
targetRoute: '/projects?riskStatus=ACTIVE',
|
||||
},
|
||||
],
|
||||
trend: {
|
||||
currency: 'CNY',
|
||||
receiptsAvailable: true,
|
||||
paymentsAvailable: true,
|
||||
invoicesAvailable: true,
|
||||
points: [
|
||||
{ period: '2026-03', receivedAmount: '100', paidAmount: '80', invoicedAmount: '90' },
|
||||
{ period: '2026-04', receivedAmount: '200', paidAmount: '120', invoicedAmount: '180' },
|
||||
{ period: '2026-05', receivedAmount: '320', paidAmount: '180', invoicedAmount: '260' },
|
||||
{ period: '2026-06', receivedAmount: '410', paidAmount: '220', invoicedAmount: '330' },
|
||||
{ period: '2026-07', receivedAmount: '500', paidAmount: '280', invoicedAmount: '420' },
|
||||
{ period: '2026-08', receivedAmount: '620', paidAmount: '360', invoicedAmount: '510' },
|
||||
],
|
||||
},
|
||||
lifecycle: [
|
||||
{ code: 'INITIATION', label: '立项', value: 2, available: true, targetRoute: '/projects?stage=INITIATION' },
|
||||
{ code: 'EXECUTION', label: '执行中', value: 4, available: true, targetRoute: '/projects?stage=EXECUTION' },
|
||||
{ code: 'SETTLEMENT', label: '结算', value: 2, available: true, targetRoute: '/projects?stage=SETTLEMENT' },
|
||||
],
|
||||
risks: [
|
||||
{
|
||||
code: 'PROJECT_RISK',
|
||||
label: '项目风险',
|
||||
severity: 'DANGER',
|
||||
count: 2,
|
||||
detail: '当前权限范围内的有效风险标记',
|
||||
available: true,
|
||||
targetRoute: '/projects?riskStatus=ACTIVE',
|
||||
},
|
||||
],
|
||||
geography: { available: false, message: '项目尚未配置统一的省市维度,当前以项目阶段分布替代地图。', regions: [] },
|
||||
activities: [],
|
||||
};
|
||||
|
||||
async function installFixture(page: Page) {
|
||||
const requests = { setupStatus: 0 };
|
||||
await page.route('**/api/v1/**', async (route: Route) => {
|
||||
const request = route.request();
|
||||
const pathname = new URL(request.url()).pathname;
|
||||
if (pathname === '/api/v1/setup/status') {
|
||||
requests.setupStatus += 1;
|
||||
await route.fulfill({ json: { data: { required: false, locked: true } } });
|
||||
return;
|
||||
}
|
||||
if (pathname === '/api/v1/auth/session') {
|
||||
await route.fulfill({ json: { data: adminSession } });
|
||||
return;
|
||||
}
|
||||
if (pathname === '/api/v1/auth/profile') {
|
||||
await route.fulfill({ json: { data: adminSession.user } });
|
||||
return;
|
||||
}
|
||||
if (pathname === '/api/v1/dashboard/overview') {
|
||||
await route.fulfill({ json: { data: dashboard } });
|
||||
return;
|
||||
}
|
||||
await route.fulfill({ json: { data: [], meta: { page: 1, size: 20, totalElements: 0, totalPages: 0 } } });
|
||||
});
|
||||
return requests;
|
||||
}
|
||||
|
||||
test('hybrid dashboard renders the cockpit, safe fallback and big-screen mode', async ({ page }) => {
|
||||
await installFixture(page);
|
||||
await page.goto('/dashboard');
|
||||
|
||||
await expect(page.getByRole('heading', { name: '系统经营仪表盘', exact: true })).toBeVisible();
|
||||
await expect(page.getByText('进行中项目')).toBeVisible();
|
||||
await expect(page.getByText('项目尚未配置统一的省市维度,当前以项目阶段分布替代地图。')).toBeVisible();
|
||||
await expect(page.getByTestId('dashboard-chart').first()).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '进入大屏' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: '进入大屏' }).click();
|
||||
await expect(page.getByText('当前章节:经营总览')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '退出大屏' })).toBeVisible();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByRole('button', { name: '进入大屏' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('installed setup status is reused across authenticated route navigation', async ({ page }) => {
|
||||
const requests = await installFixture(page);
|
||||
await page.goto('/dashboard');
|
||||
await expect(page.getByRole('heading', { name: '系统经营仪表盘', exact: true })).toBeVisible();
|
||||
|
||||
await page.getByText('系统治理', { exact: true }).click();
|
||||
await page.getByText('权限与配置', { exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/governance\/settings$/);
|
||||
|
||||
expect(requests.setupStatus).toBe(1);
|
||||
});
|
||||
@@ -27,3 +27,90 @@ test('release build boots the setup wizard without runtime errors', async ({ pag
|
||||
expect(pageErrors).toEqual([]);
|
||||
expect(consoleErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test('release build boots the dashboard and initializes charts 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/**', async (route) => {
|
||||
const pathname = new URL(route.request().url()).pathname;
|
||||
if (pathname === '/api/v1/setup/status') {
|
||||
await route.fulfill({ json: { data: { required: false, locked: true } } });
|
||||
return;
|
||||
}
|
||||
if (pathname === '/api/v1/auth/session') {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: {
|
||||
authenticated: true,
|
||||
user: {
|
||||
publicId: '01M00000000000000000000001',
|
||||
username: 'dist-dashboard',
|
||||
displayName: '构建验收管理员',
|
||||
mustChangePassword: false,
|
||||
},
|
||||
roles: [{ code: 'SYSTEM_ADMIN', name: '超级管理员', workbenchRoute: '/dashboard' }],
|
||||
activeRole: 'SYSTEM_ADMIN',
|
||||
permissions: ['dashboard:overview:view'],
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (pathname === '/api/v1/auth/profile') {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: {
|
||||
publicId: '01M00000000000000000000001',
|
||||
username: 'dist-dashboard',
|
||||
displayName: '构建验收管理员',
|
||||
mustChangePassword: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (pathname === '/api/v1/dashboard/overview') {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: {
|
||||
title: '系统经营仪表盘',
|
||||
refreshedAt: '2026-08-19T04:00:00Z',
|
||||
currency: 'CNY',
|
||||
system: {
|
||||
overallStatus: 'UP',
|
||||
currentVersion: '1.0.0-preview.49',
|
||||
uptimeSeconds: 60,
|
||||
services: [],
|
||||
},
|
||||
metrics: [],
|
||||
trend: {
|
||||
currency: 'CNY',
|
||||
receiptsAvailable: true,
|
||||
paymentsAvailable: true,
|
||||
invoicesAvailable: true,
|
||||
points: [{ period: '2026-08', receivedAmount: '100', paidAmount: '80', invoicedAmount: '90' }],
|
||||
},
|
||||
lifecycle: [],
|
||||
risks: [],
|
||||
geography: { available: false, message: '暂无地域数据', regions: [] },
|
||||
activities: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fulfill({ json: { data: [], meta: { page: 1, size: 20, totalElements: 0, totalPages: 0 } } });
|
||||
});
|
||||
|
||||
const response = await page.goto('/dashboard');
|
||||
expect(response?.ok()).toBe(true);
|
||||
await expect(page.getByRole('heading', { name: '系统经营仪表盘', exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId('dashboard-chart').first()).toBeVisible();
|
||||
expect(pageErrors).toEqual([]);
|
||||
expect(consoleErrors).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -32,8 +32,8 @@ test('first-run wizard tests MySQL, creates the administrator and stays responsi
|
||||
successful: true,
|
||||
databaseType: 'MYSQL',
|
||||
serverVersion: '8.4.6',
|
||||
schemaReady: true,
|
||||
message: 'MySQL 8.4 连接、排序规则和完整迁移权限验证通过',
|
||||
schemaReady: false,
|
||||
message: 'MySQL 8.4 只读连接验证通过;尚未修改数据库,点击完成安装后才会执行迁移',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -61,7 +61,11 @@ test('first-run wizard tests MySQL, creates the administrator and stays responsi
|
||||
await page.getByLabel('数据库密码').fill('fixture-db-password');
|
||||
await page.getByLabel('安装码').fill('fixture-setup-code');
|
||||
await page.getByRole('button', { name: '测试连接' }).click();
|
||||
await expect(page.getByText('MySQL 8.4 连接、排序规则和完整迁移权限验证通过')).toBeVisible();
|
||||
await expect(
|
||||
page.locator('.t-alert__description').filter({
|
||||
hasText: 'MySQL 8.4 只读连接验证通过;尚未修改数据库,点击完成安装后才会执行迁移',
|
||||
}),
|
||||
).toBeVisible();
|
||||
await page.getByRole('button', { name: '下一步' }).click();
|
||||
|
||||
await page.getByLabel('管理员密码').fill('SetupAdmin@2026Strong');
|
||||
@@ -110,8 +114,8 @@ test('confirms completion after the setup response is interrupted by a service r
|
||||
successful: true,
|
||||
databaseType: 'MYSQL',
|
||||
serverVersion: '8.4.6',
|
||||
schemaReady: true,
|
||||
message: 'MySQL 8.4 连接、排序规则和完整迁移权限验证通过',
|
||||
schemaReady: false,
|
||||
message: 'MySQL 8.4 只读连接验证通过;尚未修改数据库,点击完成安装后才会执行迁移',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -169,8 +173,8 @@ test('leaves the completed wizard when post-completion status polling is unavail
|
||||
successful: true,
|
||||
databaseType: 'MYSQL',
|
||||
serverVersion: '8.4.6',
|
||||
schemaReady: true,
|
||||
message: 'MySQL 8.4 连接、排序规则和完整迁移权限验证通过',
|
||||
schemaReady: false,
|
||||
message: 'MySQL 8.4 只读连接验证通过;尚未修改数据库,点击完成安装后才会执行迁移',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
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' };
|
||||
@@ -111,8 +117,13 @@ async function installFixture(
|
||||
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,
|
||||
@@ -123,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: [],
|
||||
});
|
||||
@@ -156,6 +175,10 @@ async function installFixture(
|
||||
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';
|
||||
@@ -192,6 +215,10 @@ async function installFixture(
|
||||
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,
|
||||
@@ -357,7 +384,7 @@ test('brand logo returns the active identity to its workbench', async ({ page })
|
||||
|
||||
await page.locator('.brand-logo').click();
|
||||
|
||||
await expect(page).toHaveURL(/\/governance\/settings$/);
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await expect(page.getByText('选择工作身份', { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -393,6 +420,21 @@ test('successful installation starts the ten-second automatic refresh countdown'
|
||||
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 }) => {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { request } from '@/utils/request';
|
||||
|
||||
import type { WorkbenchActivity } from './workbench';
|
||||
|
||||
export type DashboardStatus = 'UP' | 'WARNING' | 'DOWN' | 'DISABLED';
|
||||
|
||||
export interface DashboardServiceStatus {
|
||||
code: string;
|
||||
label: string;
|
||||
status: DashboardStatus;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface DashboardSystem {
|
||||
overallStatus: DashboardStatus;
|
||||
currentVersion: string;
|
||||
uptimeSeconds: number;
|
||||
services: DashboardServiceStatus[];
|
||||
}
|
||||
|
||||
export interface DashboardMetric {
|
||||
code: string;
|
||||
label: string;
|
||||
kind: 'COUNT' | 'MONEY';
|
||||
value: string;
|
||||
unit: string;
|
||||
available: boolean;
|
||||
targetRoute?: string;
|
||||
}
|
||||
|
||||
export interface DashboardTrendPoint {
|
||||
period: string;
|
||||
receivedAmount: string;
|
||||
paidAmount: string;
|
||||
invoicedAmount: string;
|
||||
}
|
||||
|
||||
export interface DashboardTrend {
|
||||
currency: string;
|
||||
receiptsAvailable: boolean;
|
||||
paymentsAvailable: boolean;
|
||||
invoicesAvailable: boolean;
|
||||
points: DashboardTrendPoint[];
|
||||
}
|
||||
|
||||
export interface DashboardDistribution {
|
||||
code: string;
|
||||
label: string;
|
||||
value: number;
|
||||
available: boolean;
|
||||
targetRoute?: string;
|
||||
}
|
||||
|
||||
export interface DashboardRisk {
|
||||
code: string;
|
||||
label: string;
|
||||
severity: 'DANGER' | 'WARNING' | 'INFO';
|
||||
count: number;
|
||||
detail: string;
|
||||
available: boolean;
|
||||
targetRoute?: string;
|
||||
}
|
||||
|
||||
export interface DashboardRegion {
|
||||
regionCode: string;
|
||||
regionName: string;
|
||||
projectCount: number;
|
||||
amount: string;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface DashboardGeography {
|
||||
available: boolean;
|
||||
message: string;
|
||||
regions: DashboardRegion[];
|
||||
}
|
||||
|
||||
export interface DashboardOverview {
|
||||
title: string;
|
||||
refreshedAt: string;
|
||||
currency: string;
|
||||
system: DashboardSystem;
|
||||
metrics: DashboardMetric[];
|
||||
trend: DashboardTrend;
|
||||
lifecycle: DashboardDistribution[];
|
||||
risks: DashboardRisk[];
|
||||
geography: DashboardGeography;
|
||||
activities: WorkbenchActivity[];
|
||||
}
|
||||
|
||||
const array = <T>(value: T[] | null | undefined): T[] => (Array.isArray(value) ? value : []);
|
||||
|
||||
export function normalizeDashboard(view: Partial<DashboardOverview> | null | undefined): DashboardOverview {
|
||||
return {
|
||||
title: String(view?.title || '系统经营仪表盘'),
|
||||
refreshedAt: String(view?.refreshedAt || ''),
|
||||
currency: String(view?.currency || 'CNY'),
|
||||
system: {
|
||||
overallStatus: view?.system?.overallStatus || 'WARNING',
|
||||
currentVersion: String(view?.system?.currentVersion || '-'),
|
||||
uptimeSeconds: Number(view?.system?.uptimeSeconds || 0),
|
||||
services: array(view?.system?.services),
|
||||
},
|
||||
metrics: array(view?.metrics),
|
||||
trend: {
|
||||
currency: String(view?.trend?.currency || view?.currency || 'CNY'),
|
||||
receiptsAvailable: Boolean(view?.trend?.receiptsAvailable),
|
||||
paymentsAvailable: Boolean(view?.trend?.paymentsAvailable),
|
||||
invoicesAvailable: Boolean(view?.trend?.invoicesAvailable),
|
||||
points: array(view?.trend?.points),
|
||||
},
|
||||
lifecycle: array(view?.lifecycle),
|
||||
risks: array(view?.risks),
|
||||
geography: {
|
||||
available: Boolean(view?.geography?.available),
|
||||
message: String(view?.geography?.message || '暂无地域数据'),
|
||||
regions: array(view?.geography?.regions),
|
||||
},
|
||||
activities: array(view?.activities),
|
||||
};
|
||||
}
|
||||
|
||||
export function getDashboardOverview(currency = 'CNY', signal?: AbortSignal) {
|
||||
return request
|
||||
.get<DashboardOverview>({ url: '/dashboard/overview', params: { currency }, signal })
|
||||
.then(normalizeDashboard);
|
||||
}
|
||||
@@ -94,6 +94,6 @@ const getPath = (item: ListItemType) => {
|
||||
return active.value;
|
||||
}
|
||||
|
||||
return item.meta?.single ? item.redirect : item.path;
|
||||
return item.meta?.single ? item.redirect || item.path : item.path;
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -2,14 +2,18 @@
|
||||
<div class="system-update-page">
|
||||
<h1 class="page-title-sr-only">系统更新</h1>
|
||||
|
||||
<header class="page-header page-header--actions-only" aria-label="页面操作">
|
||||
<t-button variant="outline" :loading="statusLoading || historyLoading" @click="refreshPage">
|
||||
<template #icon><t-icon name="refresh" /></template>
|
||||
刷新状态
|
||||
</t-button>
|
||||
</header>
|
||||
|
||||
<section class="update-panel" aria-label="系统版本与更新操作">
|
||||
<header class="update-toolbar" aria-label="系统更新工具栏">
|
||||
<div class="update-toolbar__copy">
|
||||
<div class="update-toolbar__title">系统更新</div>
|
||||
<p>获取版本、下载校验并安全重启应用</p>
|
||||
</div>
|
||||
<t-button variant="outline" :loading="statusLoading || historyLoading" @click="refreshPage">
|
||||
<template #icon><t-icon name="refresh" /></template>
|
||||
刷新状态
|
||||
</t-button>
|
||||
</header>
|
||||
|
||||
<div class="update-heading">
|
||||
<div class="version-grid">
|
||||
<div class="version-item">
|
||||
@@ -107,9 +111,9 @@
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>历史更新记录</h2>
|
||||
<p>记录版本获取、下载和重启安装操作。</p>
|
||||
<p>按版本展示签名 Release 提供的更新日志与最终结果。</p>
|
||||
</div>
|
||||
<span>最近 {{ historyRows.length }} 条</span>
|
||||
<span>已记录 {{ historyTimeline.length }} 个版本</span>
|
||||
</div>
|
||||
|
||||
<t-alert v-if="historyError" theme="error" :close-btn="false">
|
||||
@@ -118,23 +122,47 @@
|
||||
<t-link theme="primary" @click="() => loadHistory()">重新加载</t-link>
|
||||
</div>
|
||||
</t-alert>
|
||||
<t-table
|
||||
v-if="historyLoading || historyRows.length"
|
||||
:columns="historyColumns"
|
||||
:data="historyRows"
|
||||
row-key="publicId"
|
||||
:loading="historyLoading"
|
||||
table-layout="fixed"
|
||||
:horizontal-scroll-affixed-bottom="true"
|
||||
>
|
||||
<template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template>
|
||||
<template #actionCode="{ row }">{{ actionLabel(row.actionCode) }}</template>
|
||||
<template #version="{ row }">{{ historyVersion(row) }}</template>
|
||||
<template #resultCode="{ row }">
|
||||
<t-tag :theme="resultTheme(row.resultCode)" variant="light">{{ resultLabel(row.resultCode) }}</t-tag>
|
||||
</template>
|
||||
<template #reason="{ row }">{{ row.reason || '-' }}</template>
|
||||
</t-table>
|
||||
<div v-if="historyLoading" class="history-loading" aria-live="polite">
|
||||
<t-loading text="正在加载历史更新记录" />
|
||||
</div>
|
||||
<div v-else-if="historyTimeline.length" class="update-timeline" aria-label="按版本排列的更新时间线">
|
||||
<article
|
||||
v-for="item in historyTimeline"
|
||||
:key="item.key"
|
||||
class="update-timeline__item"
|
||||
:data-version="item.version"
|
||||
>
|
||||
<div class="update-timeline__rail" aria-hidden="true">
|
||||
<span class="update-timeline__marker" :class="`update-timeline__marker--${resultTheme(item.resultCode)}`">
|
||||
<t-icon :name="item.resultCode === 'SUCCESS' ? 'check' : 'error-circle'" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="timeline-card">
|
||||
<div class="timeline-card__header">
|
||||
<div class="timeline-card__identity">
|
||||
<strong>{{ item.version }}</strong>
|
||||
<span>{{ formatDateTime(item.occurredAt) }} · {{ item.username || '系统' }}</span>
|
||||
</div>
|
||||
<t-tag :theme="resultTheme(item.resultCode)" variant="light">{{ resultLabel(item.resultCode) }}</t-tag>
|
||||
</div>
|
||||
|
||||
<div class="timeline-card__notes">
|
||||
<span>更新日志</span>
|
||||
<p>{{ item.releaseNotes || '该版本的签名 Release 未提供更新日志。' }}</p>
|
||||
</div>
|
||||
<p class="timeline-card__reason">{{ item.reason || '更新结果已记录。' }}</p>
|
||||
<div class="timeline-card__published">
|
||||
<span v-if="item.publishedAt">发布时间:{{ formatDateTime(item.publishedAt) }}</span>
|
||||
<span>记录时间:{{ formatDateTime(item.occurredAt) }} · {{ item.username || '系统' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="timeline-card__footer">
|
||||
<span>目标版本 {{ item.version }}</span>
|
||||
<t-button variant="text" size="small" @click="openHistoryDetail(item)">查看更新日志</t-button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<t-empty v-else description="暂无系统更新记录" />
|
||||
</section>
|
||||
|
||||
@@ -219,11 +247,41 @@
|
||||
</div>
|
||||
</div>
|
||||
</t-dialog>
|
||||
|
||||
<t-dialog
|
||||
v-model:visible="historyDetailVisible"
|
||||
header="更新日志详情"
|
||||
aria-label="更新日志详情"
|
||||
width="min(680px, calc(100vw - 32px))"
|
||||
:footer="false"
|
||||
>
|
||||
<div v-if="selectedHistory" class="history-detail" role="dialog" aria-label="更新日志详情" aria-modal="true">
|
||||
<div class="history-detail__header">
|
||||
<div>
|
||||
<span>目标版本</span>
|
||||
<strong>{{ selectedHistory.version }}</strong>
|
||||
</div>
|
||||
<t-tag :theme="resultTheme(selectedHistory.resultCode)" variant="light">
|
||||
{{ resultLabel(selectedHistory.resultCode) }}
|
||||
</t-tag>
|
||||
</div>
|
||||
<div class="history-detail__metadata">
|
||||
<span v-if="selectedHistory.publishedAt">发布时间:{{ formatDateTime(selectedHistory.publishedAt) }}</span>
|
||||
<span
|
||||
>记录时间:{{ formatDateTime(selectedHistory.occurredAt) }} · {{ selectedHistory.username || '系统' }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="history-detail__notes">
|
||||
<span>更新日志</span>
|
||||
<p>{{ selectedHistory.releaseNotes || '该版本的签名 Release 未提供更新日志。' }}</p>
|
||||
</div>
|
||||
<p class="history-detail__reason">{{ selectedHistory.reason || '更新结果已记录。' }}</p>
|
||||
</div>
|
||||
</t-dialog>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { isAxiosError } from 'axios';
|
||||
import type { PrimaryTableCol } from 'tdesign-vue-next';
|
||||
import { MessagePlugin } from 'tdesign-vue-next';
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
|
||||
@@ -240,6 +298,17 @@ import {
|
||||
defineOptions({ name: 'SystemUpdatePage' });
|
||||
|
||||
type TagTheme = 'default' | 'primary' | 'success' | 'warning' | 'danger';
|
||||
interface HistoryTimelineItem {
|
||||
key: string;
|
||||
version: string;
|
||||
occurredAt: string;
|
||||
username: string;
|
||||
resultCode: string;
|
||||
reason: string;
|
||||
releaseNotes: string;
|
||||
publishedAt: string;
|
||||
}
|
||||
|
||||
const UPDATE_PENDING_KEY = 'kaidi-system-update-pending';
|
||||
|
||||
const updateStatus = ref<SystemUpdateView | null>(null);
|
||||
@@ -255,6 +324,8 @@ const historyError = ref('');
|
||||
const updateRefreshSeconds = ref<number | null>(null);
|
||||
const restartCountdown = ref<number | null>(null);
|
||||
const updateDialogVisible = ref(false);
|
||||
const historyDetailVisible = ref(false);
|
||||
const selectedHistory = ref<HistoryTimelineItem | null>(null);
|
||||
const connectionInterrupted = ref(false);
|
||||
const localEvents = ref<SystemUpdateEvent[]>([]);
|
||||
const awaitingRefresh = ref(false);
|
||||
@@ -383,14 +454,37 @@ const updateStateTheme = computed<TagTheme>(() => {
|
||||
return 'default';
|
||||
});
|
||||
|
||||
const historyColumns: PrimaryTableCol[] = [
|
||||
{ colKey: 'occurredAt', title: '操作时间', width: 180 },
|
||||
{ colKey: 'version', title: '版本', width: 170, ellipsis: true },
|
||||
{ colKey: 'actionCode', title: '操作', width: 150 },
|
||||
{ colKey: 'username', title: '操作人', width: 130, ellipsis: true },
|
||||
{ colKey: 'resultCode', title: '结果', width: 100 },
|
||||
{ colKey: 'reason', title: '说明', minWidth: 220, ellipsis: true },
|
||||
];
|
||||
const historyTimeline = computed<HistoryTimelineItem[]>(() => {
|
||||
const grouped = new Map<string, AuditLog[]>();
|
||||
for (const row of historyRows.value) {
|
||||
const version = historyVersion(row);
|
||||
const key = version === '-' ? `unknown:${row.requestId || row.publicId}` : `version:${version}`;
|
||||
const rows = grouped.get(key) || [];
|
||||
rows.push(row);
|
||||
grouped.set(key, rows);
|
||||
}
|
||||
|
||||
return [...grouped.entries()]
|
||||
.map(([key, rows]) => {
|
||||
const newestFirst = rows.slice().sort((left, right) => compareHistoryDates(right.occurredAt, left.occurredAt));
|
||||
const terminal = newestFirst.find((row) => isTerminalHistoryAction(row.actionCode));
|
||||
const representative = terminal || newestFirst[0];
|
||||
const releaseNotes = newestFirst.map((row) => historyReleaseNotes(row)).find(Boolean) || '';
|
||||
const publishedAt = newestFirst.map((row) => historyPublishedAt(row)).find(Boolean) || '';
|
||||
|
||||
return {
|
||||
key,
|
||||
version: representative ? historyVersion(representative) : '-',
|
||||
occurredAt: representative?.occurredAt || '',
|
||||
username: representative?.username || '',
|
||||
resultCode: representative?.resultCode || 'UNKNOWN',
|
||||
reason: representative?.reason || '',
|
||||
releaseNotes,
|
||||
publishedAt,
|
||||
};
|
||||
})
|
||||
.sort((left, right) => compareHistoryDates(right.occurredAt, left.occurredAt));
|
||||
});
|
||||
|
||||
async function loadStatus(silent = false) {
|
||||
if (!silent) {
|
||||
@@ -442,10 +536,11 @@ async function loadHistory(silent = false) {
|
||||
}
|
||||
try {
|
||||
const result = await getAuditLogs({
|
||||
occurredFrom: '1970-01-01T00:00:00Z',
|
||||
objectType: 'SYSTEM_UPDATE',
|
||||
sort: 'occurredAt,desc',
|
||||
page: 1,
|
||||
size: 20,
|
||||
size: 100,
|
||||
});
|
||||
historyRows.value = result.items;
|
||||
} catch (error) {
|
||||
@@ -700,28 +795,64 @@ function eventStageLabel(stage: string) {
|
||||
);
|
||||
}
|
||||
|
||||
function compareHistoryDates(left: string, right: string) {
|
||||
const leftTime = Date.parse(left);
|
||||
const rightTime = Date.parse(right);
|
||||
if (Number.isNaN(leftTime) || Number.isNaN(rightTime)) return left.localeCompare(right);
|
||||
return leftTime - rightTime;
|
||||
}
|
||||
|
||||
function isTerminalHistoryAction(action: string) {
|
||||
return ['SYSTEM_UPDATE_SUCCEEDED', 'SYSTEM_UPDATE_FAILED', 'SYSTEM_UPDATE_RECOVERY_REQUIRED'].includes(action);
|
||||
}
|
||||
|
||||
function parseHistoryPayload(source?: string | null) {
|
||||
if (!source) return null;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(source);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function historyVersion(row: AuditLog) {
|
||||
for (const source of [row.afterJson, row.beforeJson]) {
|
||||
if (!source) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(source) as Record<string, unknown>;
|
||||
const version = parsed.targetVersion || parsed.latestVersion || parsed.currentVersion || parsed.version;
|
||||
if (version) return String(version);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const parsed = parseHistoryPayload(source);
|
||||
const version = parsed?.targetVersion || parsed?.latestVersion || parsed?.currentVersion || parsed?.version;
|
||||
if (version !== undefined && version !== null && String(version).trim()) return String(version);
|
||||
}
|
||||
return '-';
|
||||
}
|
||||
|
||||
function actionLabel(action: string) {
|
||||
return (
|
||||
{
|
||||
SYSTEM_UPDATE_CHECK: '获取版本',
|
||||
SYSTEM_UPDATE_DOWNLOAD_REQUEST: '下载更新包',
|
||||
SYSTEM_UPDATE_REQUEST: '立即更新并重启',
|
||||
}[action] || action
|
||||
);
|
||||
function historyReleaseNotes(row: AuditLog) {
|
||||
for (const source of [row.afterJson, row.beforeJson]) {
|
||||
const parsed = parseHistoryPayload(source);
|
||||
for (const key of ['releaseNotes', 'changelog', 'updateLog', 'notes']) {
|
||||
const value = parsed?.[key];
|
||||
if (typeof value === 'string' && value.trim()) return value.trim();
|
||||
if (Array.isArray(value)) {
|
||||
const lines = value
|
||||
.filter((item): item is string => typeof item === 'string' && Boolean(item.trim()))
|
||||
.map((item) => item.trim());
|
||||
if (lines.length) return lines.join('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function historyPublishedAt(row: AuditLog) {
|
||||
for (const source of [row.afterJson, row.beforeJson]) {
|
||||
const parsed = parseHistoryPayload(source);
|
||||
if (typeof parsed?.publishedAt === 'string' && parsed.publishedAt.trim()) return parsed.publishedAt;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function openHistoryDetail(item: HistoryTimelineItem) {
|
||||
selectedHistory.value = item;
|
||||
historyDetailVisible.value = true;
|
||||
}
|
||||
|
||||
function resultLabel(result: string) {
|
||||
@@ -782,7 +913,7 @@ onBeforeUnmount(() => {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.page-header,
|
||||
.update-toolbar,
|
||||
.update-heading,
|
||||
.update-actions,
|
||||
.section-heading,
|
||||
@@ -791,10 +922,6 @@ onBeforeUnmount(() => {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.update-panel,
|
||||
.history-panel {
|
||||
width: 100%;
|
||||
@@ -818,6 +945,31 @@ onBeforeUnmount(() => {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.update-toolbar {
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
min-width: 0;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.update-toolbar__copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.update-toolbar__title {
|
||||
margin: 0;
|
||||
color: var(--td-text-color-primary);
|
||||
font-size: 18px;
|
||||
line-height: 26px;
|
||||
}
|
||||
|
||||
.update-toolbar__copy p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.version-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(150px, 1fr));
|
||||
@@ -897,6 +1049,192 @@ onBeforeUnmount(() => {
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.history-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
min-height: 160px;
|
||||
padding: 32px 0;
|
||||
}
|
||||
|
||||
.update-timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.update-timeline__item {
|
||||
display: grid;
|
||||
grid-template-columns: 24px minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.update-timeline__rail {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.update-timeline__rail::after {
|
||||
position: absolute;
|
||||
top: 30px;
|
||||
bottom: -18px;
|
||||
width: 1px;
|
||||
background: var(--td-component-border);
|
||||
content: '';
|
||||
}
|
||||
|
||||
.update-timeline__item:last-child .update-timeline__rail::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.update-timeline__marker {
|
||||
z-index: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: var(--td-text-color-secondary);
|
||||
background: var(--td-bg-color-container);
|
||||
border: 2px solid var(--td-component-border);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.update-timeline__marker--success {
|
||||
color: #fff;
|
||||
background: var(--td-success-color);
|
||||
border-color: var(--td-success-color);
|
||||
}
|
||||
|
||||
.update-timeline__marker--danger {
|
||||
color: #fff;
|
||||
background: var(--td-error-color);
|
||||
border-color: var(--td-error-color);
|
||||
}
|
||||
|
||||
.update-timeline__marker--warning {
|
||||
color: #fff;
|
||||
background: var(--td-warning-color);
|
||||
border-color: var(--td-warning-color);
|
||||
}
|
||||
|
||||
.timeline-card {
|
||||
min-width: 0;
|
||||
padding: 16px;
|
||||
background: var(--td-bg-color-container);
|
||||
border: 1px solid var(--td-component-border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.timeline-card__header,
|
||||
.timeline-card__footer,
|
||||
.history-detail__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.timeline-card__header,
|
||||
.timeline-card__footer,
|
||||
.history-detail__header {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.timeline-card__identity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.timeline-card__identity strong {
|
||||
overflow: hidden;
|
||||
color: var(--td-text-color-primary);
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.timeline-card__identity span,
|
||||
.timeline-card__footer,
|
||||
.history-detail__header span,
|
||||
.history-detail__metadata {
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.timeline-card__reason,
|
||||
.history-detail__reason {
|
||||
margin: 12px 0 0;
|
||||
color: var(--td-text-color-primary);
|
||||
line-height: 22px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.timeline-card__notes,
|
||||
.history-detail__notes {
|
||||
display: grid;
|
||||
grid-template-columns: 72px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--td-component-border);
|
||||
}
|
||||
|
||||
.timeline-card__notes > span,
|
||||
.history-detail__notes > span {
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.timeline-card__notes p,
|
||||
.history-detail__notes p {
|
||||
margin: 0;
|
||||
color: var(--td-text-color-primary);
|
||||
line-height: 22px;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.timeline-card__published,
|
||||
.history-detail__metadata {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 16px;
|
||||
margin-top: 10px;
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.timeline-card__footer {
|
||||
margin-top: 14px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--td-component-border);
|
||||
}
|
||||
|
||||
.history-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.history-detail__header > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.history-detail__header strong {
|
||||
color: var(--td-text-color-primary);
|
||||
font-size: 18px;
|
||||
line-height: 26px;
|
||||
}
|
||||
|
||||
.alert-content {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
@@ -1022,6 +1360,16 @@ onBeforeUnmount(() => {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.update-toolbar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.update-toolbar :deep(.t-button) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.update-actions,
|
||||
.update-actions :deep(.t-button) {
|
||||
width: 100%;
|
||||
@@ -1038,6 +1386,30 @@ onBeforeUnmount(() => {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.update-timeline__item {
|
||||
grid-template-columns: 18px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.update-timeline__marker {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.update-timeline__rail::after {
|
||||
top: 24px;
|
||||
}
|
||||
|
||||
.timeline-card {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.timeline-card__notes,
|
||||
.history-detail__notes {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.download-progress__meta,
|
||||
.dialog-status-line,
|
||||
.dialog-actions {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div
|
||||
ref="container"
|
||||
class="dashboard-chart"
|
||||
:style="{ height }"
|
||||
:aria-label="label"
|
||||
role="img"
|
||||
data-testid="dashboard-chart"
|
||||
></div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { BarChart, LineChart, PieChart } from 'echarts/charts';
|
||||
import { AriaComponent, GridComponent, LegendComponent, TitleComponent, TooltipComponent } from 'echarts/components';
|
||||
import type { EChartsCoreOption } from 'echarts/core';
|
||||
import * as echarts from 'echarts/core';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
option: EChartsCoreOption;
|
||||
height?: string;
|
||||
label: string;
|
||||
dark?: boolean;
|
||||
}>(),
|
||||
{ height: '300px', dark: false },
|
||||
);
|
||||
|
||||
echarts.use([
|
||||
AriaComponent,
|
||||
BarChart,
|
||||
CanvasRenderer,
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
LineChart,
|
||||
PieChart,
|
||||
TitleComponent,
|
||||
TooltipComponent,
|
||||
]);
|
||||
|
||||
const container = ref<HTMLDivElement>();
|
||||
let chart: echarts.ECharts | null = null;
|
||||
let observer: ResizeObserver | null = null;
|
||||
let chartDark: boolean | null = null;
|
||||
|
||||
const render = async () => {
|
||||
await nextTick();
|
||||
if (!container.value) return;
|
||||
if (!chart) {
|
||||
chart = echarts.init(container.value, props.dark ? 'dark' : undefined, { renderer: 'canvas' });
|
||||
chartDark = props.dark;
|
||||
}
|
||||
chart.setOption(props.option, { notMerge: true, lazyUpdate: false });
|
||||
chart.resize();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
observer = new ResizeObserver(() => chart?.resize());
|
||||
if (container.value) observer.observe(container.value);
|
||||
void render();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [props.option, props.dark] as const,
|
||||
() => {
|
||||
if (chart && chartDark !== props.dark) {
|
||||
chart.dispose();
|
||||
chart = null;
|
||||
chartDark = null;
|
||||
}
|
||||
void render();
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
observer?.disconnect();
|
||||
observer = null;
|
||||
chart?.dispose();
|
||||
chart = null;
|
||||
chartDark = null;
|
||||
});
|
||||
</script>
|
||||
<style scoped>
|
||||
.dashboard-chart {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -21,14 +21,7 @@ defineOptions({
|
||||
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const workbenchRoute =
|
||||
userStore.currentRole === 'PROJECT_MANAGER'
|
||||
? '/workbench/project'
|
||||
: userStore.currentRole === 'FINANCE_MANAGER'
|
||||
? '/workbench/finance'
|
||||
: userStore.currentRole === 'ARCHIVE_MANAGER'
|
||||
? '/workbench/archive'
|
||||
: '/governance/settings';
|
||||
const workbenchRoute = userStore.workbenchRoute;
|
||||
</script>
|
||||
<style lang="less" scoped>
|
||||
.result-success {
|
||||
|
||||
@@ -23,14 +23,7 @@ defineOptions({
|
||||
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const workbenchRoute =
|
||||
userStore.currentRole === 'PROJECT_MANAGER'
|
||||
? '/workbench/project'
|
||||
: userStore.currentRole === 'FINANCE_MANAGER'
|
||||
? '/workbench/finance'
|
||||
: userStore.currentRole === 'ARCHIVE_MANAGER'
|
||||
? '/workbench/archive'
|
||||
: '/governance/settings';
|
||||
const workbenchRoute = userStore.workbenchRoute;
|
||||
</script>
|
||||
<style lang="less" scoped>
|
||||
.result-success {
|
||||
|
||||
@@ -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">
|
||||
@@ -290,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, () => {
|
||||
@@ -308,7 +310,7 @@ async function testConnection(context?: SubmitContext) {
|
||||
const result = await testSetupConnection({ ...databaseForm });
|
||||
connectionResult.value = result;
|
||||
verifiedFingerprint.value = databaseFingerprint.value;
|
||||
MessagePlugin.success('数据库连接验证通过');
|
||||
MessagePlugin.success(result.message || '数据库只读连接验证通过');
|
||||
} catch (error) {
|
||||
connectionResult.value = null;
|
||||
errorMessage.value = (error as Error).message || '数据库连接验证失败';
|
||||
@@ -469,6 +471,13 @@ h1 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.setup-hint {
|
||||
margin: -4px 0 20px;
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.setup-content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -14,17 +14,28 @@ NProgress.configure({ showSpinner: false });
|
||||
const isPublic = (to: RouteLocationNormalized) =>
|
||||
to.path === '/login' || to.path === '/result/404' || to.path === '/setup';
|
||||
|
||||
const INSTALLED_SETUP_CACHE_MS = 5 * 60 * 1000;
|
||||
let setupStatusRequest: ReturnType<typeof getSetupStatus> | null = null;
|
||||
let installedSetupStatus: Awaited<ReturnType<typeof getSetupStatus>> | null = null;
|
||||
let installedSetupStatusExpiresAt = 0;
|
||||
|
||||
function loadSetupStatus() {
|
||||
if (installedSetupStatus && Date.now() < installedSetupStatusExpiresAt) {
|
||||
return Promise.resolve(installedSetupStatus);
|
||||
}
|
||||
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.
|
||||
// A required setup is intentionally never cached because completion changes
|
||||
// it to locked during the same browser session. The locked state is stable,
|
||||
// so a short cache avoids blocking every authenticated route on this probe.
|
||||
const request = getSetupStatus();
|
||||
setupStatusRequest = request;
|
||||
request.then(
|
||||
() => {
|
||||
(status) => {
|
||||
if (!status.required) {
|
||||
installedSetupStatus = status;
|
||||
installedSetupStatusExpiresAt = Date.now() + INSTALLED_SETUP_CACHE_MS;
|
||||
}
|
||||
if (setupStatusRequest === request) setupStatusRequest = null;
|
||||
},
|
||||
() => {
|
||||
@@ -38,6 +49,14 @@ router.beforeEach(async (to) => {
|
||||
NProgress.start();
|
||||
const userStore = useUserStore();
|
||||
const permissionStore = getPermissionStore();
|
||||
const publicRoute = isPublic(to);
|
||||
const sessionRequest =
|
||||
!publicRoute && !userStore.authenticated
|
||||
? userStore.restoreSession().then(
|
||||
() => ({ ok: true as const }),
|
||||
(error: unknown) => ({ ok: false as const, error }),
|
||||
)
|
||||
: null;
|
||||
|
||||
let setupRequired = false;
|
||||
let setupStatusAvailable = false;
|
||||
@@ -55,12 +74,15 @@ router.beforeEach(async (to) => {
|
||||
if (setupStatusAvailable && setupRequired && to.path !== '/setup') return '/setup';
|
||||
if (to.path === '/setup') return setupStatusAvailable && setupRequired ? true : '/login';
|
||||
|
||||
if (isPublic(to)) {
|
||||
if (publicRoute) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!userStore.authenticated) await userStore.restoreSession();
|
||||
if (sessionRequest) {
|
||||
const sessionResult = await sessionRequest;
|
||||
if (!sessionResult.ok) throw sessionResult.error;
|
||||
}
|
||||
permissionStore.initRoutes();
|
||||
} catch {
|
||||
userStore.clearSession();
|
||||
|
||||
@@ -22,6 +22,7 @@ const reportsPage = () => import('@/pages/reports/ReportsPage.vue');
|
||||
const auditLogsPage = () => import('@/pages/governance/AuditLogsPage.vue');
|
||||
const systemSettingsPage = () => import('@/pages/governance/SystemSettingsPage.vue');
|
||||
const systemUpdatePage = () => import('@/pages/governance/SystemUpdatePage.vue');
|
||||
const dashboardPage = () => import('@/pages/overview/DashboardPage.vue');
|
||||
const workbench = (name: 'project' | 'finance' | 'archive') => {
|
||||
if (name === 'project') return () => import('@/pages/workbench/project.vue');
|
||||
if (name === 'finance') return () => import('@/pages/workbench/finance.vue');
|
||||
@@ -77,6 +78,28 @@ const businessContract = (
|
||||
};
|
||||
|
||||
export const financeRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'Dashboard',
|
||||
component: LAYOUT,
|
||||
meta: {
|
||||
title: { zh_CN: '仪表盘', en_US: 'Dashboard' },
|
||||
titleText: '仪表盘',
|
||||
icon: shallowRef(ChartBubbleIcon),
|
||||
orderNo: 1,
|
||||
single: true,
|
||||
},
|
||||
children: [
|
||||
businessContract(
|
||||
'PAGE-24',
|
||||
'',
|
||||
'DashboardOverview',
|
||||
'仪表盘',
|
||||
{ dataKind: 'dashboard', breadcrumb: ['仪表盘'] },
|
||||
dashboardPage,
|
||||
),
|
||||
],
|
||||
},
|
||||
group('/workbench', 'Workbench', '工作台', ChartBubbleIcon, 10, [
|
||||
{
|
||||
path: 'project',
|
||||
|
||||
@@ -29,7 +29,10 @@ 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),
|
||||
workbenchRoute: (state) =>
|
||||
hasPermissionAccess(state.currentRole, state.permissions, 'dashboard:overview:view')
|
||||
? '/dashboard'
|
||||
: 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),
|
||||
|
||||
@@ -4,7 +4,7 @@ const fallbackWorkbenchRoutes: Record<string, string> = {
|
||||
PROJECT_MANAGER: '/workbench/project',
|
||||
FINANCE_MANAGER: '/workbench/finance',
|
||||
ARCHIVE_MANAGER: '/workbench/archive',
|
||||
SYSTEM_ADMIN: '/governance/settings',
|
||||
SYSTEM_ADMIN: '/dashboard',
|
||||
};
|
||||
|
||||
export function resolveWorkbenchRoute(currentRole: string | null, roles: RoleView[]): string {
|
||||
|
||||
@@ -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,7 +343,7 @@ 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,
|
||||
|
||||
@@ -171,12 +171,12 @@ describe('frontend machine contracts', () => {
|
||||
expect(paymentApi).toContain("responseType: 'blob'");
|
||||
});
|
||||
|
||||
it('declares every PAGE-01 through PAGE-23 exactly once', () => {
|
||||
const expected = Array.from({ length: 23 }, (_, index) => `PAGE-${String(index + 1).padStart(2, '0')}`);
|
||||
it('declares every PAGE-01 through PAGE-24 exactly once', () => {
|
||||
const expected = Array.from({ length: 24 }, (_, index) => `PAGE-${String(index + 1).padStart(2, '0')}`);
|
||||
const routePageIds = routesContract.routes.map((route) => route.pageId);
|
||||
const componentPageIds = componentsContract.pages.map((page) => page.pageId);
|
||||
expect([...routePageIds].sort()).toEqual([...expected].sort());
|
||||
expect(new Set(routePageIds).size).toBe(23);
|
||||
expect(new Set(routePageIds).size).toBe(24);
|
||||
expect([...componentPageIds].sort()).toEqual([...expected].sort());
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { normalizeDashboard } from '../src/api/dashboard';
|
||||
|
||||
describe('pAGE-24 dashboard contracts', () => {
|
||||
const pageSource = readFileSync(resolve(process.cwd(), 'src/pages/overview/DashboardPage.vue'), 'utf8');
|
||||
const chartSource = readFileSync(resolve(process.cwd(), 'src/pages/overview/components/DashboardChart.vue'), 'utf8');
|
||||
|
||||
it('normalizes incomplete server data without creating fake business values', () => {
|
||||
const view = normalizeDashboard({
|
||||
system: { services: null } as any,
|
||||
metrics: null,
|
||||
trend: { points: null } as any,
|
||||
lifecycle: undefined,
|
||||
risks: null,
|
||||
geography: { available: false, regions: null } as any,
|
||||
activities: undefined,
|
||||
} as unknown as Partial<ReturnType<typeof normalizeDashboard>>);
|
||||
|
||||
expect(view.metrics).toEqual([]);
|
||||
expect(view.trend.points).toEqual([]);
|
||||
expect(view.lifecycle).toEqual([]);
|
||||
expect(view.risks).toEqual([]);
|
||||
expect(view.activities).toEqual([]);
|
||||
expect(view.geography.available).toBe(false);
|
||||
expect(view.geography.regions).toEqual([]);
|
||||
});
|
||||
|
||||
it('defines the hybrid cockpit and explicit permission-safe geography fallback', () => {
|
||||
expect(pageSource).toContain('data-scene');
|
||||
expect(pageSource).toContain('进入大屏');
|
||||
expect(pageSource).toContain('退出大屏');
|
||||
expect(pageSource).toContain('dashboard.geography.available');
|
||||
expect(pageSource).toContain('补齐项目省、市编码后,将自动启用地图');
|
||||
expect(pageSource).toContain('当前身份没有查看仪表盘或相关业务数据的权限');
|
||||
expect(pageSource).toContain('onActivated(activateDashboard)');
|
||||
expect(pageSource).toContain('onDeactivated(deactivateDashboard)');
|
||||
expect(pageSource).toContain('dashboardAbortController?.abort()');
|
||||
expect(pageSource).toContain('setInterval(() => void loadDashboard(true), 60000)');
|
||||
expect(pageSource).not.toMatch(/<button\b|<input\b|<select\b/);
|
||||
});
|
||||
|
||||
it('uses modular ECharts with resize and disposal lifecycle', () => {
|
||||
expect(chartSource).toContain("from 'echarts/core'");
|
||||
expect(chartSource).toContain('ResizeObserver');
|
||||
expect(chartSource).toContain('chart.dispose()');
|
||||
expect(chartSource).toContain('aria-label="label"');
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import { canAccess, filterMenu, hasRoleAccess } from '../src/store/modules/menu-
|
||||
describe('router and TDesign menu contracts', () => {
|
||||
it('keeps every PAGE route declared and the static deep-link fallback in the router', () => {
|
||||
expect(routesContract.routes.map((route) => route.pageId).sort()).toEqual(
|
||||
Array.from({ length: 23 }, (_, index) => `PAGE-${String(index + 1).padStart(2, '0')}`),
|
||||
Array.from({ length: 24 }, (_, index) => `PAGE-${String(index + 1).padStart(2, '0')}`),
|
||||
);
|
||||
expect(routesContract.routes.find((route) => route.pageId === 'PAGE-15')?.path).toBe('/finance/payments');
|
||||
expect(readFileSync(resolve(process.cwd(), 'src/router/index.ts'), 'utf8')).toContain('createWebHistory');
|
||||
@@ -32,12 +32,31 @@ describe('router and TDesign menu contracts', () => {
|
||||
}
|
||||
};
|
||||
collectPaths(allRoutes);
|
||||
expect(flattenedPaths).toContain('/dashboard');
|
||||
expect(flattenedPaths).toContain('/projects/');
|
||||
expect(flattenedPaths).not.toContain('/dashboard/base');
|
||||
expect(flattenedPaths).not.toContain('/detail/base');
|
||||
expect(flattenedPaths).not.toContain('/list/base');
|
||||
});
|
||||
|
||||
it('places the dashboard first and hides it without its dedicated permission', () => {
|
||||
const dashboard = allRoutes.find((route) => route.path === '/dashboard');
|
||||
expect(dashboard?.meta?.orderNo).toBe(1);
|
||||
expect(dashboard?.children?.[0]?.meta?.permission).toBe('dashboard:overview:view');
|
||||
|
||||
const withoutDashboard = filterMenu(allRoutes, {
|
||||
currentRole: 'FINANCE_MANAGER',
|
||||
permissions: ['workflow:task:view'],
|
||||
});
|
||||
expect(withoutDashboard.some((route) => route.path === '/dashboard')).toBe(false);
|
||||
|
||||
const withDashboard = filterMenu(allRoutes, {
|
||||
currentRole: 'CUSTOM_ROLE',
|
||||
permissions: ['dashboard:overview:view'],
|
||||
});
|
||||
expect(withDashboard[0]?.path).toBe('/dashboard');
|
||||
});
|
||||
|
||||
it('filters menu leaves with the same role and permission rules used by route access', () => {
|
||||
const projectIdentity = { currentRole: 'PROJECT_MANAGER', permissions: ['project:project:view'] };
|
||||
const financeIdentity = {
|
||||
|
||||
@@ -4,13 +4,13 @@ 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: 'SYSTEM_ADMIN', name: '系统管理员', workbenchRoute: '/dashboard' },
|
||||
{ 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('SYSTEM_ADMIN', roles)).toBe('/dashboard');
|
||||
expect(resolveWorkbenchRoute('FINANCE_MANAGER', roles)).toBe('/workbench/finance');
|
||||
});
|
||||
|
||||
|
||||
@@ -56,6 +56,16 @@ export default ({ mode }: ConfigEnv): UserConfig => {
|
||||
test: /node_modules[\\/]tdesign-(?:vue-next|icons-vue-next)[\\/]/,
|
||||
priority: 30,
|
||||
},
|
||||
{
|
||||
// ECharts has internal class inheritance across modules. Keeping
|
||||
// ECharts and zrender in one chunk avoids Rolldown splitting a
|
||||
// base class and its derived series into independently evaluated
|
||||
// chunks, which can fail in a production browser with
|
||||
// "Class extends value undefined".
|
||||
name: 'echarts-vendor',
|
||||
test: /node_modules[\\/](?:echarts|zrender)[\\/]/,
|
||||
priority: 25,
|
||||
},
|
||||
{
|
||||
name: 'vue-vendor',
|
||||
test: /node_modules[\\/](?:vue|vue-router|pinia|vue-i18n|@vueuse)[\\/]/,
|
||||
|
||||
+194
@@ -980,6 +980,16 @@ components:
|
||||
requestId:
|
||||
type: string
|
||||
type: object
|
||||
ApiResponseDashboardOverviewView:
|
||||
properties:
|
||||
data:
|
||||
"$ref": "#/components/schemas/DashboardOverviewView"
|
||||
meta:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
requestId:
|
||||
type: string
|
||||
type: object
|
||||
ApiResponseWorkflowTaskDetailView:
|
||||
properties:
|
||||
data:
|
||||
@@ -6482,6 +6492,163 @@ components:
|
||||
title:
|
||||
type: string
|
||||
type: object
|
||||
DashboardOverviewView:
|
||||
properties:
|
||||
activities:
|
||||
items:
|
||||
"$ref": "#/components/schemas/WorkbenchActivityView"
|
||||
type: array
|
||||
currency:
|
||||
type: string
|
||||
geography:
|
||||
"$ref": "#/components/schemas/DashboardGeographyView"
|
||||
lifecycle:
|
||||
items:
|
||||
"$ref": "#/components/schemas/DashboardDistributionView"
|
||||
type: array
|
||||
metrics:
|
||||
items:
|
||||
"$ref": "#/components/schemas/DashboardMetricView"
|
||||
type: array
|
||||
refreshedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
risks:
|
||||
items:
|
||||
"$ref": "#/components/schemas/DashboardRiskView"
|
||||
type: array
|
||||
system:
|
||||
"$ref": "#/components/schemas/DashboardSystemView"
|
||||
title:
|
||||
type: string
|
||||
trend:
|
||||
"$ref": "#/components/schemas/DashboardTrendView"
|
||||
type: object
|
||||
DashboardSystemView:
|
||||
properties:
|
||||
currentVersion:
|
||||
type: string
|
||||
overallStatus:
|
||||
type: string
|
||||
services:
|
||||
items:
|
||||
"$ref": "#/components/schemas/DashboardServiceStatusView"
|
||||
type: array
|
||||
uptimeSeconds:
|
||||
format: int64
|
||||
type: integer
|
||||
type: object
|
||||
DashboardServiceStatusView:
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
detail:
|
||||
type: string
|
||||
label:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
type: object
|
||||
DashboardMetricView:
|
||||
properties:
|
||||
available:
|
||||
type: boolean
|
||||
code:
|
||||
type: string
|
||||
kind:
|
||||
type: string
|
||||
label:
|
||||
type: string
|
||||
targetRoute:
|
||||
type: string
|
||||
unit:
|
||||
type: string
|
||||
value:
|
||||
type: string
|
||||
type: object
|
||||
DashboardTrendView:
|
||||
properties:
|
||||
currency:
|
||||
type: string
|
||||
invoicesAvailable:
|
||||
type: boolean
|
||||
paymentsAvailable:
|
||||
type: boolean
|
||||
points:
|
||||
items:
|
||||
"$ref": "#/components/schemas/DashboardTrendPointView"
|
||||
type: array
|
||||
receiptsAvailable:
|
||||
type: boolean
|
||||
type: object
|
||||
DashboardTrendPointView:
|
||||
properties:
|
||||
invoicedAmount:
|
||||
type: string
|
||||
paidAmount:
|
||||
type: string
|
||||
period:
|
||||
type: string
|
||||
receivedAmount:
|
||||
type: string
|
||||
type: object
|
||||
DashboardDistributionView:
|
||||
properties:
|
||||
available:
|
||||
type: boolean
|
||||
code:
|
||||
type: string
|
||||
label:
|
||||
type: string
|
||||
targetRoute:
|
||||
type: string
|
||||
value:
|
||||
format: int64
|
||||
type: integer
|
||||
type: object
|
||||
DashboardRiskView:
|
||||
properties:
|
||||
available:
|
||||
type: boolean
|
||||
code:
|
||||
type: string
|
||||
count:
|
||||
format: int64
|
||||
type: integer
|
||||
detail:
|
||||
type: string
|
||||
label:
|
||||
type: string
|
||||
severity:
|
||||
type: string
|
||||
targetRoute:
|
||||
type: string
|
||||
type: object
|
||||
DashboardGeographyView:
|
||||
properties:
|
||||
available:
|
||||
type: boolean
|
||||
message:
|
||||
type: string
|
||||
regions:
|
||||
items:
|
||||
"$ref": "#/components/schemas/DashboardRegionView"
|
||||
type: array
|
||||
type: object
|
||||
DashboardRegionView:
|
||||
properties:
|
||||
amount:
|
||||
type: string
|
||||
currency:
|
||||
type: string
|
||||
projectCount:
|
||||
format: int64
|
||||
type: integer
|
||||
regionCode:
|
||||
type: string
|
||||
regionName:
|
||||
type: string
|
||||
type: object
|
||||
WorkbenchArchiveItemView:
|
||||
properties:
|
||||
completeness:
|
||||
@@ -14157,6 +14324,33 @@ paths:
|
||||
description: OK
|
||||
tags:
|
||||
- workbench-controller
|
||||
"/api/v1/dashboard/overview":
|
||||
get:
|
||||
operationId: getDashboardOverview
|
||||
parameters:
|
||||
- in: query
|
||||
name: currency
|
||||
required: false
|
||||
schema:
|
||||
default: CNY
|
||||
enum:
|
||||
- CNY
|
||||
- USD
|
||||
- EUR
|
||||
- HKD
|
||||
- JPY
|
||||
- GBP
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
"$ref": "#/components/schemas/ApiResponseDashboardOverviewView"
|
||||
description: OK
|
||||
summary: 查询当前身份可见的系统仪表盘
|
||||
tags:
|
||||
- dashboard-controller
|
||||
"/api/v1/workbenches/finance":
|
||||
get:
|
||||
operationId: finance
|
||||
|
||||
@@ -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 制品完成应用切换。
|
||||
@@ -0,0 +1,14 @@
|
||||
更新日志(Preview 49)
|
||||
|
||||
本版本新增系统经营仪表盘,并将其设为已授权用户的首要业务入口。
|
||||
|
||||
• 新增“仪表盘”菜单和独立权限节点 dashboard:overview:view;超级管理员默认拥有完整访问权限,其他岗位可按角色授权。
|
||||
• 新增经营驾驶舱,集中展示进行中项目、合同金额、收款、付款、待办审批、风险事项和最近业务活动。
|
||||
• 新增近六个月资金趋势、项目生命周期分布和风险异常图表;所有指标继续遵守原业务权限与 GLOBAL、COMPANY、PROJECT 数据范围。
|
||||
• 新增应用、数据库、文件存储和在线更新服务状态摘要,并展示当前版本与运行时长。
|
||||
• 新增三章节全屏大屏巡航模式,支持自动切换、手动切换和 Esc 退出,同时完成 1366、1440、1920 视口适配。
|
||||
• 当前业务数据尚无统一省市字段,地图区域采用明确的数据能力降级提示,不生成虚假地域和随机坐标。
|
||||
• 超级管理员选择身份、点击文字 Logo 或从结果页返回首页时,统一进入新仪表盘,不再默认打开“权限与配置”。
|
||||
• 演示数据脚本兼容 Flyway V073 与本版本新增的 V074 仪表盘权限迁移。
|
||||
|
||||
升级说明:本版本新增 V074 权限迁移,只写入仪表盘权限及超级管理员的授权范围,不安装或管理 MySQL,也不改变现有财务业务记录、审批规则和外部服务配置。
|
||||
@@ -0,0 +1,13 @@
|
||||
更新日志(Preview 50)
|
||||
|
||||
本版本集中优化线上仪表盘的首次打开速度、重复进入速度和生产构建稳定性。
|
||||
|
||||
• 修复生产构建中 ECharts 与 zrender 被错误拆分后可能出现的空白页;图表依赖现在作为完整模块加载,并新增真实发布包启动回归测试。
|
||||
• 为带内容指纹的 `/assets/` 静态资源启用一年浏览器缓存,重复访问不再重新下载大型 TDesign、ECharts 与页面资源;HTML、接口和安装页面状态继续禁止使用该长期缓存策略。
|
||||
• 登录会话恢复与安装状态检查改为并行执行,减少首次进入仪表盘前的串行等待。
|
||||
• 已完成安装的状态在前端短时复用,页面间切换不再每次等待一次安装状态接口;仍需安装的状态不缓存,不影响首次安装向导切换。
|
||||
• 仪表盘离开页面后会取消未完成请求并清理自动刷新、时钟和大屏巡航定时器,重新进入时按数据新鲜度刷新,避免重复请求和后台资源占用。
|
||||
• 仪表盘后端改为一次读取当前身份的权限快照,再按权限决定指标可见性,减少重复权限查询,同时保持原角色、数据范围和超级管理员规则不变。
|
||||
• 补充三档桌面视口的路由缓存验收、静态资源缓存集成测试、仪表盘生产包图表启动测试及后端权限快照测试。
|
||||
|
||||
升级说明:本版本不新增数据库迁移,不安装或修改 MySQL,不改变财务业务数据、审批规则、端口和反向代理配置。更新完成后浏览器首次获取新指纹资源,后续访问将直接复用缓存。
|
||||
@@ -241,6 +241,15 @@ ruby -ryaml -e '
|
||||
abort "system update response drifted" unless update_response ==
|
||||
"#/components/schemas/ApiResponseSystemUpdateView"
|
||||
|
||||
dashboard = paths.fetch("/api/v1/dashboard/overview").fetch("get")
|
||||
abort "dashboard operationId drifted" unless dashboard["operationId"] == "getDashboardOverview"
|
||||
dashboard_currency = dashboard.fetch("parameters").find { |parameter| parameter["name"] == "currency" }
|
||||
abort "dashboard currency whitelist drifted" unless dashboard_currency&.dig("schema", "enum") ==
|
||||
%w[CNY USD EUR HKD JPY GBP]
|
||||
dashboard_response = dashboard.dig("responses", "200", "content", "application/json", "schema", "$ref")
|
||||
abort "dashboard response drifted" unless dashboard_response ==
|
||||
"#/components/schemas/ApiResponseDashboardOverviewView"
|
||||
|
||||
governance_list = paths.fetch("/api/v1/admin/{resource}").fetch("get")
|
||||
parameter_names = governance_list.fetch("parameters").map { |parameter| parameter.fetch("name") }.sort
|
||||
expected_parameter_names = %w[keyword page resource size]
|
||||
|
||||
@@ -94,6 +94,22 @@ if grep -Eq '127\.0\.0\.1:3307|kaidi_local_2026' \
|
||||
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"
|
||||
STAGE="$WORK/stage"
|
||||
@@ -112,6 +128,15 @@ cp "$ROOT/deploy/systemd/kaidi-finance.service" "$STAGE/ops/kaidi-finance.servic
|
||||
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" "$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
|
||||
@@ -187,9 +212,9 @@ 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" 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" PURGER_SHA256="$PURGER_SHA256" \
|
||||
@@ -202,7 +227,8 @@ const manifest = {
|
||||
artifact: process.env.ARTIFACT,
|
||||
sha256: process.env.SHA256,
|
||||
artifactSizeBytes: Number(process.env.ARTIFACT_SIZE_BYTES),
|
||||
publishedAt: new Date().toISOString(),
|
||||
publishedAt: process.env.PUBLISHED_AT,
|
||||
releaseMetadata: 'release-metadata.json',
|
||||
minimumJava: 17,
|
||||
source: {
|
||||
revision: process.env.SOURCE_REVISION,
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -146,7 +146,7 @@ if KAIDI_JAVA_BIN="$WORK/java" \
|
||||
"$RELEASE/ops/baota-start.sh" > "$WORK/missing-db.out" 2>&1; then
|
||||
fail 'launcher accepted a production configuration without DB_URL'
|
||||
fi
|
||||
grep -Fq 'DB_URL is missing from the protected Kaidi configuration' "$WORK/missing-db.out" \
|
||||
grep -Fq '受保护的 Kaidi 配置缺少:DB_URL' "$WORK/missing-db.out" \
|
||||
|| fail 'launcher did not report the missing database URL'
|
||||
[ ! -e "$WORK/missing-db.log" ] || fail 'launcher started Java after configuration validation failed'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -29,6 +29,7 @@ for name in \
|
||||
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
|
||||
|
||||
@@ -24,6 +24,11 @@ mode_of() {
|
||||
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"
|
||||
@@ -37,16 +42,27 @@ mode_of() {
|
||||
sed -n '/^release_asset_url()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||
sed -n '/^download_release_asset()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||
sed -n '/^install_packages()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||
sed -n '/^mysql_client_bin()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||
sed -n '/^preflight_database()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||
sed -n '/^secure_release_tree()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||
sed -n '/^restore_unit_state()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||
} > "$WORK/helpers.sh"
|
||||
# shellcheck disable=SC1090,SC1091
|
||||
source "$WORK/helpers.sh"
|
||||
# Avoid invoking the host's macOS `log` utility while exercising extracted
|
||||
# installer helpers.
|
||||
# shellcheck disable=SC2329 # Referenced by the sourced installer helper functions.
|
||||
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'
|
||||
@@ -54,10 +70,21 @@ cat > "$WORK/mysql-bin/mysql" <<'SH'
|
||||
exit 0
|
||||
SH
|
||||
chmod 0755 "$WORK/mysql-bin/mysql"
|
||||
export KAIDI_MYSQL_CLIENT="$WORK/mysql-bin/mysql"
|
||||
[ "$(mysql_client_bin)" = "$WORK/mysql-bin/mysql" ] \
|
||||
|| fail 'installer did not honor the explicit MySQL client path'
|
||||
unset KAIDI_MYSQL_CLIENT
|
||||
# Production-mode validation is intentionally side-effect free: it validates
|
||||
# the JDBC shape but never invokes a MySQL client or emits DDL/DML.
|
||||
SETUP_WIZARD=false
|
||||
DB_URL='jdbc:mysql://127.0.0.1:3306/kaidi_finance?useUnicode=true'
|
||||
DB_USERNAME='fixture-user'
|
||||
DB_PASSWORD='fixture-password'
|
||||
mysql_probe_marker="$WORK/mysql-probe-called"
|
||||
cat > "$WORK/mysql-bin/mysql" <<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"
|
||||
@@ -299,12 +326,24 @@ 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.
|
||||
@@ -423,8 +462,11 @@ grep -Fq 'load_runtime_database_env' "$ROOT/deploy/update.sh" \
|
||||
# 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 'kaidi-finance-1.0.0-preview.34.tar.gz' "$ROOT/README.md" \
|
||||
|| fail 'README does not document the public manual-deployment artifact'
|
||||
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" \
|
||||
|
||||
@@ -46,7 +46,7 @@ if KAIDI_APP_ROOT="$WORK/bootstrap/app" \
|
||||
sh "$ROOT/deploy/update.sh" > "$WORK/bootstrap.log" 2>&1; then
|
||||
fail 'updater accepted a missing service identity during bootstrap'
|
||||
fi
|
||||
grep -Fq "Service user $missing_service_user is missing" "$WORK/bootstrap.log" \
|
||||
grep -Fq '缺少服务用户' "$WORK/bootstrap.log" \
|
||||
|| fail 'updater bootstrap failure did not preserve its diagnostic'
|
||||
[ "$(jq -r '.state' "$WORK/bootstrap/state/status.json")" = FAILED ] \
|
||||
|| fail 'updater bootstrap failure did not persist FAILED'
|
||||
@@ -297,9 +297,12 @@ 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() {
|
||||
@@ -309,6 +312,13 @@ download_and_prepare_install() {
|
||||
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" ] \
|
||||
@@ -330,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" \
|
||||
@@ -353,6 +364,7 @@ run_update() {
|
||||
KAIDI_UPDATER_PATH="$fixture/app/bin/update.sh" \
|
||||
KAIDI_RUNTIME_ENV_FILE="$fixture/runtime.env" \
|
||||
KAIDI_SKIP_DB_BACKUP="$skip_backup" \
|
||||
KAIDI_DB_BACKUP_MODE="$backup_mode" \
|
||||
KAIDI_UPDATE_HEALTH_ATTEMPTS=1 \
|
||||
KAIDI_UPDATE_HEALTH_INTERVAL_SECONDS=0 \
|
||||
UPDATE_RELEASE_BASE_URL="${FIXTURE_RELEASE_BASE_URL-https://release.fixture.invalid}" \
|
||||
@@ -366,6 +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'
|
||||
@@ -431,6 +457,11 @@ 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'
|
||||
@@ -476,7 +507,7 @@ assert_update_path_failure_keeps_application_case() {
|
||||
|| fail 'path watcher failure rolled back a healthy application'
|
||||
[ "$(jq -r '.state' "$fixture/state/status.json")" = SUCCEEDED ] \
|
||||
|| fail 'path watcher failure did not preserve successful application state'
|
||||
grep -Fq 'automatic update watcher could not be started' "$fixture/state/status.json" \
|
||||
grep -Fq '自动更新监听器未能启动' "$fixture/state/status.json" \
|
||||
|| fail 'path watcher failure did not preserve its diagnostic'
|
||||
}
|
||||
|
||||
@@ -492,7 +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 application release was restored and verified' "$fixture/update.log" \
|
||||
grep -q '已恢复并验证旧版本' "$fixture/update.log" \
|
||||
|| fail 'rollback case did not report a complete restoration'
|
||||
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/1.0.0-preview.1" ] \
|
||||
|| fail 'rollback case did not restore the previous application'
|
||||
@@ -506,6 +537,9 @@ assert_rollback_case() {
|
||||
|| 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 . \
|
||||
@@ -524,7 +558,7 @@ assert_incomplete_rollback_requires_manual_recovery_case() {
|
||||
if run_update "$fixture" fail-after-first > "$fixture/update.log" 2>&1; then
|
||||
fail 'incomplete rollback case unexpectedly succeeded'
|
||||
fi
|
||||
grep -Fq 'manual recovery is required' "$fixture/update.log" \
|
||||
grep -Fq '需要人工恢复' "$fixture/update.log" \
|
||||
|| fail 'incomplete rollback case did not require explicit recovery'
|
||||
[ ! -e "$fixture/state/processing/request.json" ] \
|
||||
|| fail 'incomplete rollback case left an automatically retriggered processing request'
|
||||
@@ -534,6 +568,9 @@ assert_incomplete_rollback_requires_manual_recovery_case() {
|
||||
|| 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'
|
||||
|
||||
@@ -602,7 +639,7 @@ assert_unhealthy_baseline_blocks_restart_case() {
|
||||
if run_update "$fixture" fail > "$fixture/update.log" 2>&1; then
|
||||
fail 'unhealthy baseline unexpectedly reached installation'
|
||||
fi
|
||||
grep -Fq 'Current release preflight failed' "$fixture/update.log" \
|
||||
grep -Fq '当前版本预检查失败' "$fixture/update.log" \
|
||||
|| fail 'unhealthy baseline did not preserve its preflight diagnostic'
|
||||
! grep -Eq '^(start|restart|stop) kaidi-finance.service$' "$fixture/systemctl.log" \
|
||||
|| fail 'unhealthy baseline restarted the application'
|
||||
@@ -633,7 +670,7 @@ EOF
|
||||
if run_update "$fixture" success > "$fixture/update.log" 2>&1; then
|
||||
fail 'invalid database URL unexpectedly reached installation'
|
||||
fi
|
||||
grep -Fq 'DB_URL does not match the configured MySQL host, port, and database' "$fixture/update.log" \
|
||||
grep -Fq 'DB_URL 与主机、端口、库名不一致' "$fixture/update.log" \
|
||||
|| fail 'structurally invalid JDBC URL did not preserve its diagnostic'
|
||||
! grep -q '^restart kaidi-finance.service$' "$fixture/systemctl.log" \
|
||||
|| fail 'invalid database URL restarted the application'
|
||||
@@ -655,7 +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'
|
||||
@@ -679,7 +716,7 @@ assert_install_without_verified_cache_rejected() {
|
||||
if run_update "$fixture" success > "$fixture/update.log" 2>&1; then
|
||||
fail 'install without cache unexpectedly succeeded'
|
||||
fi
|
||||
grep -q 'Verified release cache is missing' "$fixture/update.log" \
|
||||
grep -q '缺少已校验的发布缓存' "$fixture/update.log" \
|
||||
|| fail 'install without cache did not report the missing verified cache'
|
||||
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/1.0.0-preview.1" ] \
|
||||
|| fail 'install without cache changed the active application'
|
||||
@@ -699,6 +736,8 @@ printf '[update-fixture] download-failure\n'
|
||||
assert_download_failure_case
|
||||
printf '[update-fixture] database-backup-failure\n'
|
||||
assert_database_failure_case
|
||||
printf '[update-fixture] database-backup-opt-in\n'
|
||||
assert_database_backup_opt_in_case
|
||||
printf '[update-fixture] unhealthy-baseline\n'
|
||||
assert_unhealthy_baseline_blocks_restart_case
|
||||
printf '[update-fixture] invalid-database-url\n'
|
||||
|
||||
@@ -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" ] \
|
||||
@@ -161,6 +166,27 @@ 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"
|
||||
|
||||
Reference in New Issue
Block a user