12 Commits
Author SHA1 Message Date
Qiufeng d3892320dd feat: add managed online updates
Signed Release / release (push) Successful in 9m24s
2026-08-04 20:05:50 +08:00
Qiufeng 4f2ea26e8e ops: point CentOS recovery at v0.3.4 2026-08-04 17:44:25 +08:00
Qiufeng e295ad8b96 ci: provision Java for signed releases
Signed Release / release (push) Successful in 17m17s
2026-08-04 17:18:25 +08:00
Qiufeng 99b8126b59 test: isolate release checks across platforms
Signed Release / release (push) Failing after 35s
2026-08-04 17:11:45 +08:00
Qiufeng b472581622 fix: pull runner from Gitea registry 2026-08-04 16:14:59 +08:00
Qiufeng 8fe558f6c1 ops: add one-command Gitea runner setup 2026-08-04 15:59:52 +08:00
Qiufeng 5e6df4b323 fix: surface install progress behind reverse proxies
Signed Release / release (push) Failing after 32s
2026-08-04 15:07:39 +08:00
Qiufeng c9d5d678dc fix: add short CentOS recovery bootstrap 2026-08-04 14:27:38 +08:00
Qiufeng e21978c25c fix: add CentOS 9 reinstall recovery 2026-08-04 14:15:43 +08:00
Qiufeng f9545a9d0f fix: harden first-run install and clean reinstall
Signed Release / release (push) Failing after 33s
2026-08-04 12:59:24 +08:00
Qiufeng 4d6a9307e5 fix: support legacy systemd path parsing 2026-08-04 10:03:18 +08:00
Qiufeng b792f3f21b fix: support older systemd hardening settings 2026-08-04 09:43:34 +08:00
31 changed files with 2524 additions and 131 deletions
+37 -4
View File
@@ -25,6 +25,12 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up Java 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- name: Build, sign, and publish release - name: Build, sign, and publish release
shell: bash shell: bash
run: | run: |
@@ -54,12 +60,34 @@ jobs:
umask 077 umask 077
key_file="${RUNNER_TEMP:-/tmp}/kaidi-erp-release-key.pem" key_file="${RUNNER_TEMP:-/tmp}/kaidi-erp-release-key.pem"
cleanup() { rm -f "$key_file" release.json release-payload.json; } cleanup() { rm -f "$key_file" release.json release-payload.json release-notes.md; }
trap cleanup EXIT trap cleanup EXIT
printf '%s' "$RELEASE_PRIVATE_KEY_B64" | base64 --decode > "$key_file" printf '%s' "$RELEASE_PRIVATE_KEY_B64" | base64 --decode > "$key_file"
export ERP_RELEASE_PRIVATE_KEY_FILE="$key_file" export ERP_RELEASE_PRIVATE_KEY_FILE="$key_file"
bash tests/release-scripts.test.sh
(
cd oa-backend
./gradlew test installerTest
)
bash scripts/package-release.sh "$version" bash scripts/package-release.sh "$version"
previous_tag="$(git describe --tags --match 'v[0-9]*' --abbrev=0 "${tag}^" 2>/dev/null || true)"
change_range="$tag"
[[ -z "$previous_tag" ]] || change_range="$previous_tag..$tag"
{
printf '## 更新内容\n\n'
if ! git log --no-merges --format='- %s (`%h`)' "$change_range"; then
printf -- '- Kaidi ERP %s 正式发布\n' "$version"
fi
printf '\n## 安全校验\n\n'
printf -- '- 安装包:`kaidi-erp-%s.tar.gz`\n' "$version"
printf -- '- 完整性:SHA-256\n'
printf -- '- 发布签名:Ed25519\n'
if [[ -n "$previous_tag" ]]; then
printf '\n上一个正式版本:`%s`\n' "$previous_tag"
fi
} > release-notes.md
owner="${repository%%/*}" owner="${repository%%/*}"
repo="${repository#*/}" repo="${repository#*/}"
release_api="${api_base%/}/api/v1/repos/$owner/$repo/releases" release_api="${api_base%/}/api/v1/repos/$owner/$repo/releases"
@@ -69,13 +97,15 @@ jobs:
--header "$auth_header" --header 'Accept: application/json' \ --header "$auth_header" --header 'Accept: application/json' \
"$release_api/tags/$tag")" "$release_api/tags/$tag")"
python3 - "$tag" > release-payload.json <<'PY' python3 - "$tag" release-notes.md > release-payload.json <<'PY'
import json, sys import json, sys
tag = sys.argv[1] tag = sys.argv[1]
with open(sys.argv[2], encoding="utf-8") as handle:
notes = handle.read().strip()
print(json.dumps({ print(json.dumps({
"tag_name": tag, "tag_name": tag,
"name": tag, "name": f"Kaidi ERP {tag}",
"body": f"Kaidi ERP {tag}", "body": notes,
"draft": False, "draft": False,
"prerelease": "-" in tag.split("+", 1)[0], "prerelease": "-" in tag.split("+", 1)[0],
}, separators=(",", ":"))) }, separators=(",", ":")))
@@ -97,6 +127,9 @@ jobs:
fi fi
release_id="$(python3 -c 'import json; print(json.load(open("release.json"))["id"])')" release_id="$(python3 -c 'import json; print(json.load(open("release.json"))["id"])')"
curl "${curl_protocols[@]}" --fail-with-body --silent --show-error --location --retry 3 \
--header "$auth_header" --header 'Accept: application/json' \
--output release.json "$release_api/$release_id"
for asset in "dist/kaidi-erp-$version.tar.gz" "dist/kaidi-erp-installer-$version.jar" dist/SHA256SUMS dist/SHA256SUMS.sig; do for asset in "dist/kaidi-erp-$version.tar.gz" "dist/kaidi-erp-installer-$version.jar" dist/SHA256SUMS dist/SHA256SUMS.sig; do
name="$(basename "$asset")" name="$(basename "$asset")"
encoded_name="$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$name")" encoded_name="$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$name")"
+78 -16
View File
@@ -49,6 +49,7 @@ Administrator -> System Update UI -> erp-update helper
|- README.md |- README.md
|- run.command # macOS 本地预览与 ngrok 启动器 |- run.command # macOS 本地预览与 ngrok 启动器
|- install.sh # 非 Docker 一键安装器 |- install.sh # 非 Docker 一键安装器
|- uninstall.sh # Linux 完整卸载与数据库 schema 重置
|- distribution/ |- distribution/
| |- bin/erp-run # 正式环境应用启动器 | |- bin/erp-run # 正式环境应用启动器
| `- bin/erp-update # 下载、校验、切换和回滚助手 | `- bin/erp-update # 下载、校验、切换和回滚助手
@@ -110,7 +111,7 @@ PostgreSQL-only 正式构建:
```bash ```bash
cd oa-backend cd oa-backend
./gradlew clean bootJar -PreleaseVersion=0.2.0 -PproductionBuild=true ./gradlew clean bootJar -PreleaseVersion=0.3.5 -PproductionBuild=true
``` ```
正式 JAR 必须包含 PostgreSQL 驱动,并且不得包含 `sqlite-jdbc``hibernate-community-dialects` 正式 JAR 必须包含 PostgreSQL 驱动,并且不得包含 `sqlite-jdbc``hibernate-community-dialects`
@@ -180,14 +181,19 @@ curl -fsSL https://git.example.com/awaioi/ERP/raw/branch/main/install.sh \
Linux 生产服务要求主机使用 systemd;没有 systemd 的容器、WSL 或精简系统只能显式使用 `--no-service` 做开发验收,在线更新也会保持关闭。 Linux 生产服务要求主机使用 systemd;没有 systemd 的容器、WSL 或精简系统只能显式使用 `--no-service` 做开发验收,在线更新也会保持关闭。
安装器启动后会输出带一次性令牌的局域网地址,例如 安装器启动后会输出带一次性令牌的访问地址。优先级依次为:命令行 `--public-url`(或 `ERP_PUBLIC_URL`)、HTTPS 服务探测到的公网 IP、局域网 IP。无论使用哪一种方式,都会同时输出仅服务器本机可用的 `Local URL`;公网探测失败时还会明确提示正在回退局域网地址。公网服务器建议显式传入地址,避免 NAT、多网卡或代理环境识别错误
```text ```text
Setup URL: http://192.168.1.20:8091/?token=<one-time-token> Setup URL: http://38.76.196.225:8091/?token=<one-time-token>
Local URL: http://127.0.0.1:8091/?token=<one-time-token>
``` ```
`--public-url` 支持域名、端口、路径和已有查询参数,安装器会安全追加一次性 `token`。使用公网 IP 直连时需要在防火墙或安全组放行 ERP 端口;通过 HTTPS 反向代理安装时,应将公开域名作为 `--public-url`
首次打开该地址进入网页向导,依次完成环境检查、PostgreSQL 地址/端口/库名/账号/密码/SSL 测试、管理员账号/姓名/密码设置、数据库迁移和初始化。项目当前没有 Redis 依赖,因此向导不会显示 Redis 配置项。正式服务真实健康检查通过后,启动器才会原子写入安装锁并物理删除 `installer/``install.pending` 首次打开该地址进入网页向导,依次完成环境检查、PostgreSQL 地址/端口/库名/账号/密码/SSL 测试、管理员账号/姓名/密码设置、数据库迁移和初始化。项目当前没有 Redis 依赖,因此向导不会显示 Redis 配置项。正式服务真实健康检查通过后,启动器才会原子写入安装锁并物理删除 `installer/``install.pending`
PostgreSQL 必须使用专用空数据库,网页中填写的账号必须是该数据库的所有者。该约束保证账号拥有 `public` schema 建表权限,并能持有安装器创建的 `pg_trgm` 扩展;只授予 `CONNECT` 权限不足以完成迁移。
### macOS ### macOS
macOS 需要 Homebrew,并使用已有 PostgreSQL macOS 需要 Homebrew,并使用已有 PostgreSQL
@@ -204,14 +210,39 @@ curl -fsSL https://git.example.com/awaioi/ERP/raw/branch/main/install.sh \
当前 Gitea 地址 `http://38.76.196.225:10099` 只允许用于开发验收: 当前 Gitea 地址 `http://38.76.196.225:10099` 只允许用于开发验收:
```bash ```bash
curl -fsSL http://38.76.196.225:10099/awaioi/ERP/raw/branch/main/install.sh \ (
| sudo -E bash -s -- \ set -e
--gitea-url http://38.76.196.225:10099 \ tmp="$(mktemp)"
--repository awaioi/ERP \ trap 'rm -f -- "$tmp"' EXIT
--allow-insecure curl -fsSL http://38.76.196.225:10099/awaioi/ERP/raw/tag/v0.3.5/install.sh -o "$tmp"
printf '%s %s\n' '76917da519895bec815bb5492b12f0894e8cff26d6754cb9b2569b45e52ed83c' "$tmp" | sha256sum -c -
sudo -E bash "$tmp" \
--gitea-url http://38.76.196.225:10099 \
--repository awaioi/ERP \
--version 0.3.5 \
--public-url http://38.76.196.225:8091 \
--allow-insecure
)
``` ```
只有首个 Release 发布后这条命令才可下载安装包。HTTP 会暴露请求、Release 元数据和可能使用的访问令牌,不得作为生产方案。 只有 `v0.3.5` Release 发布后这条命令才可下载安装包。固定 tag 和 SHA-256 只用于保护当前 HTTP 引导脚本不被传输途中篡改;Release 资产仍会继续执行 Ed25519 和 SHA-256 双重校验。HTTP 会暴露请求、Release 元数据和可能使用的访问令牌,不得作为长期生产方案。
### 完整卸载后重装
以下命令具有破坏性:它会先停止服务,使用现有配置中的 ERP 数据库账号删除并重建目标数据库的 `public` schema,然后删除 systemd unit、程序、配置、状态和日志。脚本只允许数据库所有者执行 schema 清理,并拒绝 `postgres``template0``template1` 和危险文件路径。
```bash
(
set -e
tmp="$(mktemp)"
trap 'rm -f -- "$tmp"' EXIT
curl -fsSL http://38.76.196.225:10099/awaioi/ERP/raw/tag/v0.3.5/uninstall.sh -o "$tmp"
printf '%s %s\n' '98c56fed2fd4d01874e4ab5a1a4f3ec42ec3e29b315ffd87385a95488d587546' "$tmp" | sha256sum -c -
sudo -E bash "$tmp" --purge-database --yes
)
```
卸载器会先核对安装配置、路径和 systemd 停止状态,再清理数据库和文件。卸载成功后,再执行上面的 Linux 一键安装命令。不要对包含其他系统数据的共享数据库运行此命令。
### 正式安装目录 ### 正式安装目录
@@ -228,15 +259,41 @@ Linux 默认路径:
使用 `--no-service` 只用于开发验收:启动器会在当前用户下运行,但默认设置 `OA_UPDATE_ENABLED=false`。生产环境应使用 systemd/launchd,让在线更新可以在 Java 进程退出后自动拉起新版本。 使用 `--no-service` 只用于开发验收:启动器会在当前用户下运行,但默认设置 `OA_UPDATE_ENABLED=false`。生产环境应使用 systemd/launchd,让在线更新可以在 Java 进程退出后自动拉起新版本。
### HTTPS 反向代理
正式安装默认监听 `8091`(可通过 `ERP_SERVER_PORT` 覆盖)。Nginx、宝塔、Caddy 或 CDN 终止 HTTPS 后,必须把公网协议和主机转发给 Spring Boot;否则浏览器的同源 API 请求会被误判为跨域,并收到纯文本 `403 Invalid CORS request`,前端表现为“响应非 JSON (HTTP 403)”。Nginx 的代理位置至少包含:
```nginx
location / {
proxy_pass http://127.0.0.1:8091;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
```
如果 Nginx 前面还有 Cloudflare 等上游代理,`X-Forwarded-Proto` 必须保留浏览器实际使用的 `https`,不能被内层 HTTP 链路覆盖。前端与 `/api/oa/*` 推荐使用同一个公网域名;不要在 Nginx 中附加 `Access-Control-Allow-Origin *`
## 在线更新与回滚 ## 在线更新与回滚
管理员登录后进入: 管理员登录后可从以下任一入口进入:
```text ```text
顶部工具栏 -> 系统更新
用户菜单 -> 系统更新
手机导航抽屉 -> 系统更新
应用定制平台 -> 系统更新 应用定制平台 -> 系统更新
``` ```
对应前端路由为 `/appdev/update`,后端 API 为 `/api/oa/system-update/*`更新过程如下: 入口只对 `ADMIN` 角色显示,对应前端路由为 `/appdev/update`,后端 API 为 `/api/oa/system-update/*`页面会显示当前版本、最新版本、检查时间、Release 更新日志、发布日期、发布文件、Ed25519/SHA-256 校验方式,以及下载、验签、安装、重启和自动回滚阶段。顶部和手机入口发现新版本时会显示版本提示。
“更新源设置”可在线修改启用状态、Gitea 地址、`owner/repository`、正式版/预览版通道、私有仓库 Token 和 HTTP 测试开关。设置原子写回 `/etc/kaidi-erp/erp.env`,只允许修改 `OA_UPDATE_*` 白名单,不会覆盖 PostgreSQL 密码等其他配置;Token 永不通过 API 回显,页面只显示“Token 已配置”。公开仓库无需填写 Token,正式环境应使用 HTTPS。
更新过程如下:
1. 从 Gitea 读取 stable channel 的最新 Release。 1. 从 Gitea 读取 stable channel 的最新 Release。
2. 下载归档、`SHA256SUMS` 和 Ed25519 签名(Release 里的独立安装器资产只用于首次安装)。 2. 下载归档、`SHA256SUMS` 和 Ed25519 签名(Release 里的独立安装器资产只用于首次安装)。
@@ -250,7 +307,7 @@ Linux 默认路径:
同一安装目录使用操作系统文件锁,不能并发执行两个更新任务。也可以手工触发: 同一安装目录使用操作系统文件锁,不能并发执行两个更新任务。也可以手工触发:
```bash ```bash
/opt/kaidi-erp/current/bin/erp-update install 0.2.0 /opt/kaidi-erp/current/bin/erp-update install 0.3.5
``` ```
应用回滚不等于数据库回滚。包含不可逆 Flyway 迁移的版本必须先保证旧应用仍兼容新结构,并建议在安装配置中启用: 应用回滚不等于数据库回滚。包含不可逆 Flyway 迁移的版本必须先保证旧应用仍兼容新结构,并建议在安装配置中启用:
@@ -263,7 +320,7 @@ ERP_UPDATE_BACKUP_MODE=pg_dump
## Gitea Release 发布 ## Gitea Release 发布
推送 `v*` tag 会触发 `.gitea/workflows/release.yml`。流水线会构建前端、生成 PostgreSQL-only JAR、打包、签名,并创建或更新对应 Gitea Release。 推送 `v*` tag 会触发 `.gitea/workflows/release.yml`。流水线会先执行 shell、后端和独立安装器测试,再构建前端、生成 PostgreSQL-only JAR、打包、签名,并创建或更新对应 Gitea Release。
### Actions 前置配置 ### Actions 前置配置
@@ -291,8 +348,8 @@ base64 < ~/.config/kaidi-erp/release-signing-key.pem | tr -d '\n'
```bash ```bash
git switch main git switch main
git pull --ff-only origin main git pull --ff-only origin main
git tag -a v0.2.0 -m 'Kaidi ERP v0.2.0' git tag -a v0.3.5 -m 'Kaidi ERP v0.3.5'
git push origin v0.2.0 git push origin v0.3.5
``` ```
发布完成后必须确认 Release 页面存在四个资产,并使用仓库中的 `distribution/release-public-key.pem` 验证签名。私钥与该公钥不匹配时打包脚本会直接失败。 发布完成后必须确认 Release 页面存在四个资产,并使用仓库中的 `distribution/release-public-key.pem` 验证签名。私钥与该公钥不匹配时打包脚本会直接失败。
@@ -301,7 +358,7 @@ git push origin v0.2.0
```bash ```bash
ERP_RELEASE_PRIVATE_KEY_FILE="$HOME/.config/kaidi-erp/release-signing-key.pem" \ ERP_RELEASE_PRIVATE_KEY_FILE="$HOME/.config/kaidi-erp/release-signing-key.pem" \
bash scripts/package-release.sh 0.2.0 bash scripts/package-release.sh 0.3.5
``` ```
## 配置参考 ## 配置参考
@@ -320,6 +377,7 @@ ERP_RELEASE_PRIVATE_KEY_FILE="$HOME/.config/kaidi-erp/release-signing-key.pem" \
| `OA_UPDATE_CHANNEL` | 更新通道 | `stable` | | `OA_UPDATE_CHANNEL` | 更新通道 | `stable` |
| `OA_UPDATE_TOKEN` | 私有仓库下载令牌 | 空;公开仓库不需要 | | `OA_UPDATE_TOKEN` | 私有仓库下载令牌 | 空;公开仓库不需要 |
| `OA_UPDATE_ALLOW_INSECURE_HTTP` | 允许 HTTP 更新地址 | `false` | | `OA_UPDATE_ALLOW_INSECURE_HTTP` | 允许 HTTP 更新地址 | `false` |
| `ERP_PUBLIC_URL` | 首次安装向导的公网 URL,等价于 `--public-url` | 自动探测公网 IP |
| `OA_SEED_DEMO` | 是否生成演示数据 | 正式安装为 `false` | | `OA_SEED_DEMO` | 是否生成演示数据 | 正式安装为 `false` |
| `ERP_UPDATE_BACKUP_MODE` | 更新前数据库备份 | `none`,可设 `pg_dump` | | `ERP_UPDATE_BACKUP_MODE` | 更新前数据库备份 | `none`,可设 `pg_dump` |
| `ERP_UPDATE_HEALTH_TIMEOUT_SECONDS` | 新旧版本健康检查超时 | `120` | | `ERP_UPDATE_HEALTH_TIMEOUT_SECONDS` | 新旧版本健康检查超时 | `120` |
@@ -391,6 +449,10 @@ Gitea 仓库尚未发布首个可安装版本,或 Release 缺少四个必需
运行 `ngrok config check` 确认配置有效,并检查固定域名是否属于当前 ngrok 账号。可以通过 `ERP_RUN_NGROK_BIN``ERP_RUN_NGROK_CONFIG``ERP_RUN_NGROK_API_PORT` 覆盖路径与端口。 运行 `ngrok config check` 确认配置有效,并检查固定域名是否属于当前 ngrok 账号。可以通过 `ERP_RUN_NGROK_BIN``ERP_RUN_NGROK_CONFIG``ERP_RUN_NGROK_API_PORT` 覆盖路径与端口。
### 反向代理后提示“响应非 JSON (HTTP 403)”
应用自身的 401/403 权限错误始终是 JSON。该提示表示 Nginx、WAF 或 Spring CORS 层提前返回了纯文本/HTML。先确认代理目标为正式安装端口(默认 `http://127.0.0.1:8091`),再按“HTTPS 反向代理”一节补齐 `Host``X-Forwarded-*` 请求头;响应正文为 `Invalid CORS request` 时即可确认是协议/主机转发不完整。
### 签名验证失败 ### 签名验证失败
不要跳过验证。确认 Release 的四个资产来自同一次构建、`SHA256SUMS` 未被改写、签名私钥与 `distribution/release-public-key.pem` 匹配。 不要跳过验证。确认 Release 的四个资产来自同一次构建、`SHA256SUMS` 未被改写、签名私钥与 `distribution/release-public-key.pem` 匹配。
+8 -1
View File
@@ -21,6 +21,7 @@ load_configuration() {
INSTALLER_JAR="${ERP_INSTALLER_JAR:-$INSTALL_ROOT/installer/kaidi-erp-installer.jar}" INSTALLER_JAR="${ERP_INSTALLER_JAR:-$INSTALL_ROOT/installer/kaidi-erp-installer.jar}"
PENDING_FILE="${ERP_INSTALL_PENDING_FILE:-$INSTALL_ROOT/state/install.pending}" PENDING_FILE="${ERP_INSTALL_PENDING_FILE:-$INSTALL_ROOT/state/install.pending}"
INSTALL_LOCK_FILE="${ERP_INSTALL_LOCK_FILE:-$INSTALL_ROOT/state/install.lock}" INSTALL_LOCK_FILE="${ERP_INSTALL_LOCK_FILE:-$INSTALL_ROOT/state/install.lock}"
INSTALL_LOG_FILE="${ERP_INSTALL_LOG_FILE:-$(dirname "$PENDING_FILE")/install-formal.log}"
HEALTH_URL="${ERP_HEALTH_URL:-http://127.0.0.1:${SERVER_PORT:-8091}/api/oa/health}" HEALTH_URL="${ERP_HEALTH_URL:-http://127.0.0.1:${SERVER_PORT:-8091}/api/oa/health}"
HEALTH_TIMEOUT_SECONDS="${ERP_INSTALL_HEALTH_TIMEOUT_SECONDS:-240}" HEALTH_TIMEOUT_SECONDS="${ERP_INSTALL_HEALTH_TIMEOUT_SECONDS:-240}"
HEALTH_POLL_SECONDS="${ERP_UPDATE_HEALTH_POLL_SECONDS:-2}" HEALTH_POLL_SECONDS="${ERP_UPDATE_HEALTH_POLL_SECONDS:-2}"
@@ -98,10 +99,14 @@ run_pending_formal_application() {
[[ "$HEALTH_POLL_SECONDS" =~ ^[1-9][0-9]*$ ]] || fail 'invalid health poll interval' [[ "$HEALTH_POLL_SECONDS" =~ ^[1-9][0-9]*$ ]] || fail 'invalid health poll interval'
say 'Starting the formal PostgreSQL application' say 'Starting the formal PostgreSQL application'
mkdir -p "$(dirname "$INSTALL_LOG_FILE")"
: > "$INSTALL_LOG_FILE"
chmod 600 "$INSTALL_LOG_FILE"
"$JAVA_BIN" "${JAVA_OPTS[@]}" \ "$JAVA_BIN" "${JAVA_OPTS[@]}" \
-jar "$JAR_PATH" \ -jar "$JAR_PATH" \
--spring.profiles.active=postgres \ --spring.profiles.active=postgres \
--server.port="${SERVER_PORT:-8091}" & --server.port="${SERVER_PORT:-8091}" \
> >(tee -a "$INSTALL_LOG_FILE") 2>&1 &
APP_PID=$! APP_PID=$!
write_pid "$APP_PID" write_pid "$APP_PID"
@@ -118,6 +123,7 @@ run_pending_formal_application() {
local status=$? local status=$?
set -e set -e
clear_pid "$APP_PID" clear_pid "$APP_PID"
say "Formal application diagnostics: $INSTALL_LOG_FILE"
fail "formal application exited before becoming healthy (status $status)" fail "formal application exited before becoming healthy (status $status)"
fi fi
if curl -fsS --connect-timeout 2 --max-time 5 "$HEALTH_URL" >/dev/null 2>&1; then if curl -fsS --connect-timeout 2 --max-time 5 "$HEALTH_URL" >/dev/null 2>&1; then
@@ -137,6 +143,7 @@ run_pending_formal_application() {
wait "$APP_PID" wait "$APP_PID"
set -e set -e
clear_pid "$APP_PID" clear_pid "$APP_PID"
say "Formal application diagnostics: $INSTALL_LOG_FILE"
fail "formal application did not become healthy within ${HEALTH_TIMEOUT_SECONDS} seconds" fail "formal application did not become healthy within ${HEALTH_TIMEOUT_SECONDS} seconds"
} }
+11 -2
View File
@@ -44,14 +44,23 @@ import json, os, sys, tempfile
from datetime import datetime, timezone from datetime import datetime, timezone
path, phase, progress, message, version, error = sys.argv[1:] path, phase, progress, message, version, error = sys.argv[1:]
payload = { payload = {}
try:
if os.path.getsize(path) <= 64 * 1024:
with open(path, encoding="utf-8") as handle:
existing = json.load(handle)
if isinstance(existing, dict):
payload = existing
except (OSError, json.JSONDecodeError, TypeError, ValueError):
pass
payload.update({
"phase": phase, "phase": phase,
"progress": int(progress), "progress": int(progress),
"message": message, "message": message,
"version": version or None, "version": version or None,
"error": error or None, "error": error or None,
"updatedAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), "updatedAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
} })
parent = os.path.dirname(path) or "." parent = os.path.dirname(path) or "."
fd, tmp = tempfile.mkstemp(prefix=".update-state-", dir=parent, text=True) fd, tmp = tempfile.mkstemp(prefix=".update-state-", dir=parent, text=True)
try: try:
+44 -6
View File
@@ -27,8 +27,8 @@ base64 < ~/.config/kaidi-erp/release-signing-key.pem | tr -d '\n'
发布稳定版本: 发布稳定版本:
```bash ```bash
git tag v0.2.0 git tag v0.3.5
git push origin v0.2.0 git push origin v0.3.5
``` ```
## 首次安装 ## 首次安装
@@ -42,15 +42,53 @@ curl -fsSL https://git.example.com/awaioi/ERP/raw/branch/main/install.sh \
--repository awaioi/ERP --repository awaioi/ERP
``` ```
命令行只检查并安装 Java 17+、curl、tar、Python 3 和 OpenSSL 3,然后启动独立安装器并输出带一次性 token 的网页地址。数据库、管理员和密码全部在首次网页向导填写;安装器会真实测试 PostgreSQL 15+ `pg_trgm`,迁移完成并确认正式服务健康后才写 `install.lock`,随后物理删除安装器目录。 命令行只检查并安装 Java 17+、curl、tar、Python 3 和 OpenSSL 3,然后启动独立安装器并输出带一次性 token 的网页地址。数据库、管理员和密码全部在首次网页向导填写;安装器会真实测试 PostgreSQL 15+、数据库所有权、`public` schema 建表权限`pg_trgm` 所有权,迁移完成并确认正式服务健康后才写 `install.lock`,随后物理删除安装器目录。
安装地址优先使用 `--public-url`(或 `ERP_PUBLIC_URL`);未指定时依次尝试探测公网 IP、回退局域网 IP,并始终额外输出 `Local URL`。公网服务器建议显式传入例如 `--public-url http://38.76.196.225:8091`。参数支持 HTTPS 域名、端口、路径和已有查询参数,安装器会安全追加 token,不会用局域网 IP 覆盖显式公网地址。
目标 PostgreSQL 必须是专用空数据库,网页中填写的账号必须是该数据库的所有者。只拥有连接权限的账号会在网页连接测试阶段被拒绝,不再等到 Flyway 迁移后才显示笼统错误。
Linux 生产服务要求主机使用 systemd;没有 systemd 的容器、WSL 或精简系统只能显式使用 `--no-service` 做开发验收,在线更新也会保持关闭。 Linux 生产服务要求主机使用 systemd;没有 systemd 的容器、WSL 或精简系统只能显式使用 `--no-service` 做开发验收,在线更新也会保持关闭。
当前 `http://38.76.196.225:10099` 仅用于开发测试,必须同时传入 `--allow-insecure`HTTP 会暴露安装脚本、Release 元数据和 Gitea token,不应作为生产部署方式。 当前 `http://38.76.196.225:10099` 仅用于开发测试,安装器必须同时传入 `--allow-insecure`在没有 HTTPS 的情况下,必须从固定 tag 下载引导脚本并验证本版本记录的 SHA-256,禁止把可变的 `main` 分支脚本直接管道给 root。HTTP 仍会暴露请求、Release 元数据和 Gitea token,不应作为生产部署方式。
当前 `v0.3.5` 安装命令:
```bash
(
set -e
tmp="$(mktemp)"
trap 'rm -f -- "$tmp"' EXIT
curl -fsSL http://38.76.196.225:10099/awaioi/ERP/raw/tag/v0.3.5/install.sh -o "$tmp"
printf '%s %s\n' '76917da519895bec815bb5492b12f0894e8cff26d6754cb9b2569b45e52ed83c' "$tmp" | sha256sum -c -
sudo -E bash "$tmp" --gitea-url http://38.76.196.225:10099 \
--repository awaioi/ERP --version 0.3.5 \
--public-url http://38.76.196.225:8091 --allow-insecure
)
```
完整卸载并清空本项目数据库 schema 后重装:
```bash
(
set -e
tmp="$(mktemp)"
trap 'rm -f -- "$tmp"' EXIT
curl -fsSL http://38.76.196.225:10099/awaioi/ERP/raw/tag/v0.3.5/uninstall.sh -o "$tmp"
printf '%s %s\n' '98c56fed2fd4d01874e4ab5a1a4f3ec42ec3e29b315ffd87385a95488d587546' "$tmp" | sha256sum -c -
sudo -E bash "$tmp" --purge-database --yes
)
```
该命令会先确认安装路径与 `erp.env` 一致、systemd 服务已经停止,再删除目标数据库 `public` schema 中的全部对象以及 `/opt/kaidi-erp``/etc/kaidi-erp``/var/lib/kaidi-erp``/var/log/kaidi-erp`。只允许对 Kaidi ERP 专用数据库执行。
## 在线更新 ## 在线更新
管理员进入“应用定制平台 -> 系统更新”,点击“检查更新”,确认版本和 Release notes 后执行更新。后端启动独立更新助手,更新助手会: 管理员可从顶部工具栏、用户菜单、手机导航抽屉或“应用定制平台 -> 系统更新”进入 `/appdev/update`。入口只对 `ADMIN` 角色显示;发现新版本时顶部和手机入口会显示版本提示。
页面的“更新源设置”可保存启用状态、Gitea 地址、`owner/repository`、正式版/预览版通道、可选 Token 和 HTTP 测试开关。配置原子写回 `ERP_CONFIG_FILE` 指向的 `erp.env`,只修改 `OA_UPDATE_*` 白名单;数据库密码等字段保持不变。Token 不会通过 API 回显,公开仓库可以留空。
点击“保存并检查”或“检查更新”后,页面会展示当前/最新版本、发布日期、Release 更新日志、发布资产和签名校验方式。安装时持续显示下载、验签、安装、重启和回滚进度;服务重启短暂断开期间页面会自动重连。后端启动独立更新助手,更新助手会:
1. 下载正式归档、`SHA256SUMS` 和签名并验证 Ed25519/SHA-256;独立安装器资产只在首次安装使用。 1. 下载正式归档、`SHA256SUMS` 和签名并验证 Ed25519/SHA-256;独立安装器资产只在首次安装使用。
2. 拒绝路径穿越、符号链接和结构不完整的安装包。 2. 拒绝路径穿越、符号链接和结构不完整的安装包。
@@ -61,7 +99,7 @@ Linux 生产服务要求主机使用 systemd;没有 systemd 的容器、WSL
更新过程使用操作系统文件锁,同一安装目录同时只允许一个更新任务。手动触发可执行: 更新过程使用操作系统文件锁,同一安装目录同时只允许一个更新任务。手动触发可执行:
```bash ```bash
/opt/kaidi-erp/current/bin/erp-update install 0.2.0 /opt/kaidi-erp/current/bin/erp-update install 0.3.5
``` ```
在线更新依赖安装器注册的 systemd 或 launchd 服务来拉起新旧版本。使用 `--no-service` 时后台更新默认关闭;如由其他进程管理器接管,须先确认它会在 ERP 进程退出后自动重启,再手工启用 `OA_UPDATE_ENABLED=true`。健康检查默认最多等待 120 秒、每 2 秒轮询一次,可分别通过 `ERP_UPDATE_HEALTH_TIMEOUT_SECONDS``ERP_UPDATE_HEALTH_POLL_SECONDS` 调整。 在线更新依赖安装器注册的 systemd 或 launchd 服务来拉起新旧版本。使用 `--no-service` 时后台更新默认关闭;如由其他进程管理器接管,须先确认它会在 ERP 进程退出后自动重启,再手工启用 `OA_UPDATE_ENABLED=true`。健康检查默认最多等待 120 秒、每 2 秒轮询一次,可分别通过 `ERP_UPDATE_HEALTH_TIMEOUT_SECONDS``ERP_UPDATE_HEALTH_POLL_SECONDS` 调整。
+107 -8
View File
@@ -8,6 +8,7 @@ INSTALL_ROOT="${ERP_INSTALL_ROOT:-}"
ALLOW_INSECURE="${ERP_UPDATE_ALLOW_INSECURE_HTTP:-0}" ALLOW_INSECURE="${ERP_UPDATE_ALLOW_INSECURE_HTTP:-0}"
NO_SERVICE="${ERP_INSTALL_NO_SERVICE:-0}" NO_SERVICE="${ERP_INSTALL_NO_SERVICE:-0}"
TOKEN="${ERP_GITEA_TOKEN:-${OA_UPDATE_TOKEN:-}}" TOKEN="${ERP_GITEA_TOKEN:-${OA_UPDATE_TOKEN:-}}"
PUBLIC_URL="${ERP_PUBLIC_URL:-}"
PUBLIC_KEY='-----BEGIN PUBLIC KEY----- PUBLIC_KEY='-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAaErhcY8WZIZvPILmYnfjndVBAdOuWkvhaoHIWqNNdxI= MCowBQYDK2VwAyEAaErhcY8WZIZvPILmYnfjndVBAdOuWkvhaoHIWqNNdxI=
-----END PUBLIC KEY-----' -----END PUBLIC KEY-----'
@@ -23,6 +24,7 @@ Usage: install.sh [options]
--repository O/R Release repository (default: awaioi/ERP) --repository O/R Release repository (default: awaioi/ERP)
--version VERSION Install one exact stable release --version VERSION Install one exact stable release
--install-root PATH Override installation directory --install-root PATH Override installation directory
--public-url URL Public URL used for the one-time web setup link
--allow-insecure Development only: allow plain HTTP release URLs --allow-insecure Development only: allow plain HTTP release URLs
--no-service Start without systemd/launchd; online update stays disabled --no-service Start without systemd/launchd; online update stays disabled
EOF EOF
@@ -34,6 +36,7 @@ while [[ $# -gt 0 ]]; do
--repository) [[ $# -ge 2 ]] || fail '--repository requires a value'; REPOSITORY="$2"; shift 2 ;; --repository) [[ $# -ge 2 ]] || fail '--repository requires a value'; REPOSITORY="$2"; shift 2 ;;
--version) [[ $# -ge 2 ]] || fail '--version requires a value'; REQUESTED_VERSION="$2"; shift 2 ;; --version) [[ $# -ge 2 ]] || fail '--version requires a value'; REQUESTED_VERSION="$2"; shift 2 ;;
--install-root) [[ $# -ge 2 ]] || fail '--install-root requires a value'; INSTALL_ROOT="$2"; shift 2 ;; --install-root) [[ $# -ge 2 ]] || fail '--install-root requires a value'; INSTALL_ROOT="$2"; shift 2 ;;
--public-url) [[ $# -ge 2 ]] || fail '--public-url requires a value'; PUBLIC_URL="$2"; shift 2 ;;
--allow-insecure) ALLOW_INSECURE=1; shift ;; --allow-insecure) ALLOW_INSECURE=1; shift ;;
--no-service) NO_SERVICE=1; shift ;; --no-service) NO_SERVICE=1; shift ;;
-h|--help) usage; exit 0 ;; -h|--help) usage; exit 0 ;;
@@ -394,6 +397,8 @@ write_bootstrap_configuration() {
shell_setting ERP_INSTALL_PENDING_FILE "$PENDING_FILE" shell_setting ERP_INSTALL_PENDING_FILE "$PENDING_FILE"
shell_setting ERP_INSTALL_LOCK_FILE "$INSTALL_LOCK_FILE" shell_setting ERP_INSTALL_LOCK_FILE "$INSTALL_LOCK_FILE"
shell_setting ERP_INSTALL_OPERATION_LOCK_FILE "$OPERATION_LOCK_FILE" shell_setting ERP_INSTALL_OPERATION_LOCK_FILE "$OPERATION_LOCK_FILE"
shell_setting ERP_INSTALL_LOG_FILE "$STATE_ROOT/install-formal.log"
shell_setting ERP_INSTALL_HEALTH_TIMEOUT_SECONDS "${ERP_INSTALL_HEALTH_TIMEOUT_SECONDS:-240}"
shell_setting ERP_SETUP_TOKEN "$SETUP_TOKEN" shell_setting ERP_SETUP_TOKEN "$SETUP_TOKEN"
shell_setting ERP_INSTALLER_JAR "$INSTALL_ROOT/installer/kaidi-erp-installer.jar" shell_setting ERP_INSTALLER_JAR "$INSTALL_ROOT/installer/kaidi-erp-installer.jar"
shell_setting ERP_JAR_PATH "$INSTALL_ROOT/current/app/kaidi-erp.jar" shell_setting ERP_JAR_PATH "$INSTALL_ROOT/current/app/kaidi-erp.jar"
@@ -442,8 +447,8 @@ User=$ERP_USER
Group=$ERP_GROUP Group=$ERP_GROUP
Environment="ERP_INSTALL_ROOT=$INSTALL_ROOT" Environment="ERP_INSTALL_ROOT=$INSTALL_ROOT"
Environment="ERP_CONFIG_FILE=$CONFIG_FILE" Environment="ERP_CONFIG_FILE=$CONFIG_FILE"
WorkingDirectory="$INSTALL_ROOT" WorkingDirectory=$INSTALL_ROOT
ExecStart="$INSTALL_ROOT/current/bin/erp-run" ExecStart=$INSTALL_ROOT/current/bin/erp-run
Restart=always Restart=always
RestartSec=3 RestartSec=3
TimeoutStopSec=90 TimeoutStopSec=90
@@ -451,14 +456,18 @@ SuccessExitStatus=143
UMask=0077 UMask=0077
NoNewPrivileges=true NoNewPrivileges=true
PrivateTmp=true PrivateTmp=true
ProtectSystem=strict ProtectSystem=full
ProtectHome=true ProtectHome=true
ReadWritePaths="$INSTALL_ROOT" "$CONFIG_ROOT" "$STATE_ROOT" "$LOG_ROOT" ReadWritePaths=$INSTALL_ROOT $CONFIG_ROOT $STATE_ROOT $LOG_ROOT
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
EOF EOF
systemctl daemon-reload systemctl daemon-reload
if command -v systemd-analyze >/dev/null 2>&1; then
systemd-analyze verify /etc/systemd/system/kaidi-erp.service \
|| fail 'generated systemd unit is invalid; run systemd-analyze verify /etc/systemd/system/kaidi-erp.service'
fi
systemctl enable kaidi-erp.service systemctl enable kaidi-erp.service
systemctl restart kaidi-erp.service systemctl restart kaidi-erp.service
else else
@@ -513,6 +522,74 @@ detect_lan_address() {
printf '%s' "${address:-127.0.0.1}" printf '%s' "${address:-127.0.0.1}"
} }
is_ip_address() {
python3 - "$1" <<'PY' >/dev/null 2>&1
import ipaddress, sys
ipaddress.ip_address(sys.argv[1])
PY
}
detect_public_address() {
local endpoint address
for endpoint in \
https://api.ipify.org \
https://ifconfig.me/ip \
https://icanhazip.com; do
address="$(curl --silent --show-error --fail --location \
--proto '=https' --proto-redir '=https' \
--connect-timeout 3 --max-time 5 "$endpoint" 2>/dev/null \
| tr -d '[:space:]' | head -c 128 || true)"
if [[ -n "$address" ]] && is_ip_address "$address"; then
printf '%s' "$address"
return 0
fi
done
return 1
}
normalize_public_url() {
python3 - "$1" <<'PY'
import sys
from urllib.parse import urlsplit, urlunsplit
value = sys.argv[1].strip()
try:
parsed = urlsplit(value)
port = parsed.port
except ValueError:
raise SystemExit("invalid public URL")
if parsed.scheme.lower() not in {"http", "https"} or not parsed.hostname:
raise SystemExit("public URL must use http or https")
if parsed.username is not None or parsed.password is not None:
raise SystemExit("public URL must not contain credentials")
if port is not None and not 1 <= port <= 65535:
raise SystemExit("invalid public URL port")
path = parsed.path or "/"
print(urlunsplit((parsed.scheme.lower(), parsed.netloc, path, parsed.query, parsed.fragment)))
PY
}
ip_setup_base_url() {
python3 - "$1" "$2" <<'PY'
import ipaddress, sys
address = ipaddress.ip_address(sys.argv[1])
host = f"[{address}]" if address.version == 6 else str(address)
print(f"http://{host}:{int(sys.argv[2])}/")
PY
}
append_setup_token() {
python3 - "$1" "$2" <<'PY'
import sys
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
parsed = urlsplit(sys.argv[1])
query = [(key, value) for key, value in parse_qsl(parsed.query, keep_blank_values=True) if key != "token"]
query.append(("token", sys.argv[2]))
print(urlunsplit((parsed.scheme, parsed.netloc, parsed.path or "/", urlencode(query), parsed.fragment)))
PY
}
main() { main() {
detect_platform detect_platform
validate_service_manager validate_service_manager
@@ -525,9 +602,18 @@ main() {
fi fi
[[ -n "$GITEA_BASE_URL" ]] || fail 'Gitea URL is required; use --gitea-url or ERP_GITEA_BASE_URL' [[ -n "$GITEA_BASE_URL" ]] || fail 'Gitea URL is required; use --gitea-url or ERP_GITEA_BASE_URL'
validate_download_url "$GITEA_BASE_URL" validate_download_url "$GITEA_BASE_URL"
if [[ -n "$PUBLIC_URL" ]]; then
case "$PUBLIC_URL" in
http://*|https://*) ;;
*) fail 'public URL must start with http:// or https://' ;;
esac
fi
say "Detected $PLATFORM/$ARCH" say "Detected $PLATFORM/$ARCH"
check_and_install_dependencies check_and_install_dependencies
if [[ -n "$PUBLIC_URL" ]]; then
PUBLIC_URL="$(normalize_public_url "$PUBLIC_URL")" || fail 'invalid public URL'
fi
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/kaidi-erp-install.XXXXXX")" TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/kaidi-erp-install.XXXXXX")"
create_curl_config create_curl_config
say 'Downloading and verifying the signed release...' say 'Downloading and verifying the signed release...'
@@ -539,11 +625,24 @@ main() {
start_service start_service
wait_for_installer wait_for_installer
local port="${ERP_SERVER_PORT:-8091}" address local port="${ERP_SERVER_PORT:-8091}" lan_address public_address setup_base local_base lan_base
address="$(detect_lan_address)" lan_address="$(detect_lan_address)"
local_base="http://127.0.0.1:${port}/"
lan_base="$(ip_setup_base_url "$lan_address" "$port")"
if [[ -n "$PUBLIC_URL" ]]; then
setup_base="$PUBLIC_URL"
elif public_address="$(detect_public_address)"; then
setup_base="$(ip_setup_base_url "$public_address" "$port")"
else
setup_base="$lan_base"
say 'Public IP detection was unavailable; using the LAN address'
fi
say "Kaidi ERP $VERSION installer is running" say "Kaidi ERP $VERSION installer is running"
say "Setup URL: http://${address}:${port}/?token=${SETUP_TOKEN}" say "Setup URL: $(append_setup_token "$setup_base" "$SETUP_TOKEN")"
say "Local URL: http://127.0.0.1:${port}/?token=${SETUP_TOKEN}" say "Local URL: $(append_setup_token "$local_base" "$SETUP_TOKEN")"
if [[ "$lan_address" != "127.0.0.1" && "$lan_base" != "$setup_base" ]]; then
say "LAN URL: $(append_setup_token "$lan_base" "$SETUP_TOKEN")"
fi
say 'Complete PostgreSQL and administrator setup in the browser. The installer will remove itself after the formal service is healthy.' say 'Complete PostgreSQL and administrator setup in the browser. The installer will remove itself after the formal service is healthy.'
} }
+8 -6
View File
@@ -8,6 +8,8 @@ group = 'com.kaidi'
version = providers.gradleProperty('releaseVersion') version = providers.gradleProperty('releaseVersion')
.orElse(System.getenv('ERP_RELEASE_VERSION') ?: '0.1.0') .orElse(System.getenv('ERP_RELEASE_VERSION') ?: '0.1.0')
.get() .get()
def flywayVersion = '11.20.3'
def postgresqlDriverVersion = '42.7.13'
def productionBuild = providers.gradleProperty('productionBuild') def productionBuild = providers.gradleProperty('productionBuild')
.map { it.toBoolean() } .map { it.toBoolean() }
.orElse(false) .orElse(false)
@@ -47,9 +49,9 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-websocket' implementation 'org.springframework.boot:spring-boot-starter-websocket'
// Production database and versioned schema migrations. // Production database and versioned schema migrations.
implementation 'org.flywaydb:flyway-core:10.22.0' implementation "org.flywaydb:flyway-core:$flywayVersion"
runtimeOnly 'org.flywaydb:flyway-database-postgresql:10.22.0' runtimeOnly "org.flywaydb:flyway-database-postgresql:$flywayVersion"
runtimeOnly 'org.postgresql:postgresql' runtimeOnly "org.postgresql:postgresql:$postgresqlDriverVersion"
// SQLite remains available to source-tree development and tests, but is // SQLite remains available to source-tree development and tests, but is
// deliberately absent from PostgreSQL-only production release artifacts. // deliberately absent from PostgreSQL-only production release artifacts.
@@ -69,9 +71,9 @@ dependencies {
installerImplementation 'org.springframework.boot:spring-boot-starter-web' installerImplementation 'org.springframework.boot:spring-boot-starter-web'
installerImplementation 'org.springframework.boot:spring-boot-starter-validation' installerImplementation 'org.springframework.boot:spring-boot-starter-validation'
installerImplementation 'org.flywaydb:flyway-core:10.22.0' installerImplementation "org.flywaydb:flyway-core:$flywayVersion"
installerRuntimeOnly 'org.flywaydb:flyway-database-postgresql:10.22.0' installerRuntimeOnly "org.flywaydb:flyway-database-postgresql:$flywayVersion"
installerRuntimeOnly 'org.postgresql:postgresql' installerRuntimeOnly "org.postgresql:postgresql:$postgresqlDriverVersion"
} }
tasks.named('test') { tasks.named('test') {
@@ -3,6 +3,8 @@ package com.kaidi.oa.install;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.flywaydb.core.Flyway; import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.output.MigrateResult; import org.flywaydb.core.api.output.MigrateResult;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
@@ -36,6 +38,8 @@ import java.util.Set;
@Service @Service
public class InstallerService { public class InstallerService {
private static final Logger log = LoggerFactory.getLogger(InstallerService.class);
private static final Set<PosixFilePermission> OWNER_ONLY = EnumSet.of( private static final Set<PosixFilePermission> OWNER_ONLY = EnumSet.of(
PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_READ,
PosixFilePermission.OWNER_WRITE); PosixFilePermission.OWNER_WRITE);
@@ -117,6 +121,11 @@ public class InstallerService {
.load() .load()
.migrate(); .migrate();
} catch (RuntimeException exception) { } catch (RuntimeException exception) {
log.error(
"PostgreSQL migration failed for database {} as user {}",
request.database().database().strip(),
request.database().username().strip(),
exception);
throw new InstallApiException( throw new InstallApiException(
HttpStatus.UNPROCESSABLE_ENTITY, HttpStatus.UNPROCESSABLE_ENTITY,
42203, 42203,
@@ -170,6 +179,7 @@ public class InstallerService {
42201, 42201,
"PostgreSQL 版本过低,需要 15 或更高版本"); "PostgreSQL 版本过低,需要 15 或更高版本");
} }
requireDatabaseOwnership(connection);
try (Statement statement = connection.createStatement()) { try (Statement statement = connection.createStatement()) {
statement.execute("SELECT 1"); statement.execute("SELECT 1");
statement.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm"); statement.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm");
@@ -181,10 +191,16 @@ public class InstallerService {
throw new SQLException("pg_trgm is unavailable"); throw new SQLException("pg_trgm is unavailable");
} }
} }
requirePgTrgmOwnership(connection);
return new DatabaseCheck(major, connection.getMetaData().getDatabaseProductVersion()); return new DatabaseCheck(major, connection.getMetaData().getDatabaseProductVersion());
} catch (InstallApiException exception) { } catch (InstallApiException exception) {
throw exception; throw exception;
} catch (SQLException | NumberFormatException exception) { } catch (SQLException | NumberFormatException exception) {
log.warn(
"PostgreSQL verification failed for database {} as user {}: {}",
database.database().strip(),
database.username().strip(),
exception.getMessage());
throw new InstallApiException( throw new InstallApiException(
HttpStatus.UNPROCESSABLE_ENTITY, HttpStatus.UNPROCESSABLE_ENTITY,
42202, 42202,
@@ -192,6 +208,40 @@ public class InstallerService {
} }
} }
private void requireDatabaseOwnership(Connection connection) throws SQLException {
try (Statement statement = connection.createStatement();
ResultSet result = statement.executeQuery(
"SELECT "
+ "pg_get_userbyid(datdba) = current_user AS owns_database, "
+ "has_schema_privilege(current_user, 'public', 'USAGE') AS can_use_schema, "
+ "has_schema_privilege(current_user, 'public', 'CREATE') AS can_create_in_schema "
+ "FROM pg_database WHERE datname = current_database()")) {
if (!result.next()
|| !result.getBoolean("owns_database")
|| !result.getBoolean("can_use_schema")
|| !result.getBoolean("can_create_in_schema")) {
throw new InstallApiException(
HttpStatus.UNPROCESSABLE_ENTITY,
42206,
"数据库账号必须是该空数据库的所有者,并拥有 public schema 建表权限");
}
}
}
private void requirePgTrgmOwnership(Connection connection) throws SQLException {
try (Statement statement = connection.createStatement();
ResultSet result = statement.executeQuery(
"SELECT pg_get_userbyid(extowner) = current_user "
+ "FROM pg_extension WHERE extname = 'pg_trgm'")) {
if (!result.next() || !result.getBoolean(1)) {
throw new InstallApiException(
HttpStatus.UNPROCESSABLE_ENTITY,
42207,
"数据库账号必须拥有 pg_trgm 扩展;请由管理员删除预建扩展后重新测试");
}
}
}
private void rejectExistingInstallation(InstallRequest.Database database) { private void rejectExistingInstallation(InstallRequest.Database database) {
try (Connection connection = openConnection(database); try (Connection connection = openConnection(database);
PreparedStatement tableQuery = connection.prepareStatement( PreparedStatement tableQuery = connection.prepareStatement(
@@ -272,6 +322,11 @@ public class InstallerService {
} catch (InstallApiException exception) { } catch (InstallApiException exception) {
throw exception; throw exception;
} catch (Exception exception) { } catch (Exception exception) {
log.error(
"Administrator initialization failed for database {} as user {}",
database.database().strip(),
database.username().strip(),
exception);
throw new InstallApiException(HttpStatus.UNPROCESSABLE_ENTITY, 42205, "管理员账号初始化失败"); throw new InstallApiException(HttpStatus.UNPROCESSABLE_ENTITY, 42205, "管理员账号初始化失败");
} }
} }
@@ -326,6 +381,8 @@ public class InstallerService {
values.put("ERP_INSTALL_PENDING_FILE", properties.pendingFile().toAbsolutePath().normalize().toString()); values.put("ERP_INSTALL_PENDING_FILE", properties.pendingFile().toAbsolutePath().normalize().toString());
values.put("ERP_INSTALL_LOCK_FILE", properties.lockFile().toAbsolutePath().normalize().toString()); values.put("ERP_INSTALL_LOCK_FILE", properties.lockFile().toAbsolutePath().normalize().toString());
values.put("ERP_INSTALL_OPERATION_LOCK_FILE", properties.operationLockFile().toAbsolutePath().normalize().toString()); values.put("ERP_INSTALL_OPERATION_LOCK_FILE", properties.operationLockFile().toAbsolutePath().normalize().toString());
values.put("ERP_INSTALL_LOG_FILE", stateRoot.resolve("install-formal.log").toString());
values.put("ERP_INSTALL_HEALTH_TIMEOUT_SECONDS", environment("ERP_INSTALL_HEALTH_TIMEOUT_SECONDS", "240"));
values.put("ERP_SETUP_TOKEN", properties.token()); values.put("ERP_SETUP_TOKEN", properties.token());
values.put("ERP_INSTALLER_JAR", installRoot.resolve("installer/kaidi-erp-installer.jar").toString()); values.put("ERP_INSTALLER_JAR", installRoot.resolve("installer/kaidi-erp-installer.jar").toString());
values.put("ERP_JAR_PATH", installRoot.resolve("current/app/kaidi-erp.jar").toString()); values.put("ERP_JAR_PATH", installRoot.resolve("current/app/kaidi-erp.jar").toString());
@@ -1,6 +1,7 @@
server: server:
port: ${SERVER_PORT:8091} port: ${SERVER_PORT:8091}
shutdown: graceful shutdown: graceful
forward-headers-strategy: framework
spring: spring:
application: application:
@@ -190,6 +190,44 @@
.progress-item.running .progress-dot { border-color: #2f6fed; background: #2f6fed; box-shadow: 0 0 0 4px #eaf1fd; } .progress-item.running .progress-dot { border-color: #2f6fed; background: #2f6fed; box-shadow: 0 0 0 4px #eaf1fd; }
.progress-item.done { color: #187149; } .progress-item.done { color: #187149; }
.progress-item.done .progress-dot { border-color: #25805a; background: #25805a; } .progress-item.done .progress-dot { border-color: #25805a; background: #25805a; }
.install-progress { margin-top: 22px; padding-top: 18px; border-top: 1px solid #e8ebf0; }
.install-progress-head, .install-progress-meta, .install-console-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.install-progress-label { color: #334057; font-size: 13px; font-weight: 650; }
.install-progress-value { color: #1d3557; font-size: 14px; font-variant-numeric: tabular-nums; }
.install-progress-track {
height: 10px;
margin-top: 10px;
overflow: hidden;
border-radius: 4px;
background: #e5eaf1;
}
.install-progress-bar {
width: 0;
height: 100%;
background: #2f6fed;
transition: width .35s ease;
}
.install-progress-meta { margin-top: 8px; color: #707c90; font-size: 12px; }
.install-console { margin-top: 14px; overflow: hidden; border: 1px solid #28364a; border-radius: 6px; background: #182233; }
.install-console-head { min-height: 36px; padding: 0 12px; border-bottom: 1px solid #314158; background: #111a28; }
.install-console-title { color: #dce5f2; font-size: 12px; font-weight: 650; }
.install-console-state { color: #7dd3a8; font-size: 12px; }
.install-log {
height: 148px;
margin: 0;
overflow: auto;
padding: 10px 12px;
color: #c9d5e5;
font: 12px/1.65 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
letter-spacing: 0;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.complete-mark { .complete-mark {
width: 58px; width: 58px;
height: 58px; height: 58px;
@@ -220,6 +258,8 @@
.summary-row { grid-template-columns: 1fr; gap: 5px; } .summary-row { grid-template-columns: 1fr; gap: 5px; }
.actions { flex-wrap: wrap-reverse; } .actions { flex-wrap: wrap-reverse; }
.btn { flex: 1 1 120px; } .btn { flex: 1 1 120px; }
.install-progress-meta { align-items: flex-start; flex-direction: column; gap: 4px; }
.install-log { height: 172px; }
} }
</style> </style>
</head> </head>
@@ -299,6 +339,23 @@
<div class="progress-item" data-progress="3"><span class="progress-dot"></span><span>创建管理员账号</span></div> <div class="progress-item" data-progress="3"><span class="progress-dot"></span><span>创建管理员账号</span></div>
<div class="progress-item" data-progress="4"><span class="progress-dot"></span><span>启动正式服务并写入安装锁</span></div> <div class="progress-item" data-progress="4"><span class="progress-dot"></span><span>启动正式服务并写入安装锁</span></div>
</div> </div>
<div class="install-progress">
<div class="install-progress-head">
<span class="install-progress-label" id="install-phase">准备安装</span>
<strong class="install-progress-value" id="install-percent">0%</strong>
</div>
<div class="install-progress-track" id="install-progress-track" role="progressbar" aria-label="安装进度" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<div class="install-progress-bar" id="install-progress-bar"></div>
</div>
<div class="install-progress-meta">
<span id="install-elapsed">本页已等待 0 秒</span>
<span id="install-check">等待安装任务</span>
</div>
</div>
<div class="install-console">
<div class="install-console-head"><span class="install-console-title">安装状态</span><span class="install-console-state" id="install-console-state">运行中</span></div>
<pre class="install-log" id="install-log" aria-live="polite"></pre>
</div>
<div id="install-message" class="notice info">正在初始化,数据库规模较大时可能需要几分钟。</div> <div id="install-message" class="notice info">正在初始化,数据库规模较大时可能需要几分钟。</div>
</div> </div>
<div id="complete-view" class="hidden"> <div id="complete-view" class="hidden">
@@ -323,7 +380,18 @@
history.replaceState(null, '', clean); history.replaceState(null, '', clean);
} }
const state = { step: 1, database: null, databaseFingerprint: '', administrator: null, installing: false }; const state = {
step: 1,
database: null,
databaseFingerprint: '',
administrator: null,
installing: false,
installStartedAt: 0,
installPercent: 0,
installLogEntries: [],
healthPolling: false,
};
const FORMAL_SERVICE_TIMEOUT_MS = 240000;
const $ = (selector) => document.querySelector(selector); const $ = (selector) => document.querySelector(selector);
const $$ = (selector) => Array.from(document.querySelectorAll(selector)); const $$ = (selector) => Array.from(document.querySelectorAll(selector));
@@ -421,26 +489,97 @@
}); });
} }
function formatElapsed(milliseconds) {
const seconds = Math.max(0, Math.floor(milliseconds / 1000));
if (seconds < 60) return `${seconds}`;
return `${Math.floor(seconds / 60)}${seconds % 60}`;
}
function setInstallMeter(percent, phase, detail) {
const value = Math.max(state.installPercent, Math.max(0, Math.min(100, Math.round(percent))));
state.installPercent = value;
$('#install-progress-bar').style.width = `${value}%`;
$('#install-percent').textContent = `${value}%`;
$('#install-phase').textContent = phase;
$('#install-check').textContent = detail;
$('#install-elapsed').textContent = `本页已等待 ${formatElapsed(Date.now() - state.installStartedAt)}`;
const track = $('#install-progress-track');
track.setAttribute('aria-valuenow', String(value));
track.setAttribute('aria-valuetext', `${phase}${value}%`);
}
function appendInstallLog(message) {
const timestamp = new Date().toLocaleTimeString('zh-CN', { hour12: false });
state.installLogEntries.push(`[${timestamp}] ${message}`);
state.installLogEntries = state.installLogEntries.slice(-16);
const output = $('#install-log');
output.textContent = state.installLogEntries.join('\n');
output.scrollTop = output.scrollHeight;
}
function beginInstallTracking(resumed) {
state.installStartedAt = Date.now();
state.installLogEntries = [];
$('#install-message').className = 'notice info';
$('#install-console-state').textContent = '运行中';
if (resumed) {
setInstallMeter(70, '恢复安装任务', '准备检查正式服务');
appendInstallLog('检测到尚未完成的安装任务,继续等待正式服务。');
} else {
setInstallMeter(5, '提交安装任务', '准备验证数据库');
appendInstallLog('安装请求已提交,正在验证 PostgreSQL 连接和权限。');
}
}
async function pollFormalService() { async function pollFormalService() {
if (state.healthPolling) return;
state.healthPolling = true;
if (!state.installStartedAt) beginInstallTracking(true);
setProgress(4); setProgress(4);
$('#install-message').textContent = '初始化完成,正在等待正式服务通过健康检查'; setInstallMeter(Math.max(state.installPercent, 72), '启动正式服务', '等待第 1 次健康检查');
const deadline = Date.now() + 240000; appendInstallLog('数据库初始化完成,安装器正在切换到正式服务。');
$('#install-message').textContent = '初始化完成,正在等待正式服务通过健康检查。页面会持续显示检查进度。';
const healthStartedAt = Date.now();
const deadline = healthStartedAt + FORMAL_SERVICE_TIMEOUT_MS;
let attempt = 0;
while (Date.now() < deadline) { while (Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 2000)); await new Promise((resolve) => setTimeout(resolve, 2000));
attempt += 1;
const healthElapsed = Date.now() - healthStartedAt;
const estimated = 72 + Math.floor((healthElapsed / FORMAL_SERVICE_TIMEOUT_MS) * 23);
setInstallMeter(Math.min(95, estimated), '等待正式服务健康检查', `${attempt} 次检查`);
try { try {
const response = await fetch('/api/oa/health', { cache: 'no-store' }); const response = await fetch('/api/oa/health', { cache: 'no-store' });
const body = await response.json(); const body = await response.json();
if (response.ok && (body?.data?.status === 'UP' || body?.status === 'UP')) { const serviceStatus = body?.data?.status || body?.status || `HTTP ${response.status}`;
const installLocked = body?.data?.installLocked ?? body?.installLocked;
if (response.ok && serviceStatus === 'UP' && (installLocked === true || installLocked === 'true')) {
setInstallMeter(100, '安装完成', '正式服务已就绪');
appendInstallLog(`健康检查第 ${attempt} 次通过,安装锁已确认。`);
$('#install-console-state').textContent = '已完成';
$$('[data-progress]').forEach((item) => { item.classList.remove('running'); item.classList.add('done'); }); $$('[data-progress]').forEach((item) => { item.classList.remove('running'); item.classList.add('done'); });
$('#installing-view').classList.add('hidden'); $('#installing-view').classList.add('hidden');
$('#complete-view').classList.remove('hidden'); $('#complete-view').classList.remove('hidden');
sessionStorage.removeItem('kaidi.setup.token'); sessionStorage.removeItem('kaidi.setup.token');
state.healthPolling = false;
return; return;
} }
} catch { /* Service is changing from installer to the formal application. */ } if (response.ok && serviceStatus === 'UP') {
setInstallMeter(98, '正式服务已通过健康检查', '等待写入安装锁');
appendInstallLog(`健康检查第 ${attempt} 次通过,正在等待后台写入安装锁。`);
continue;
}
appendInstallLog(`健康检查第 ${attempt} 次:服务状态 ${serviceStatus}`);
} catch {
appendInstallLog(`健康检查第 ${attempt} 次:服务端口正在切换,继续等待。`);
}
} }
state.healthPolling = false;
setInstallMeter(95, '正式服务尚未就绪', `${Math.round(FORMAL_SERVICE_TIMEOUT_MS / 1000)} 秒检查超时`);
appendInstallLog('健康检查超时,安装状态已保留,未写入完成锁。');
$('#install-console-state').textContent = '需要检查';
$('#install-message').className = 'notice error'; $('#install-message').className = 'notice error';
$('#install-message').textContent = '正式服务尚未就绪。请在服务器查看 kaidi-erp 服务日志,安装文件和安装锁不会被误删。'; $('#install-message').textContent = '正式服务尚未就绪。请查看 systemctl 日志或 /var/lib/kaidi-erp/install-formal.log;安装文件和待确认状态均已保留。';
} }
$('#start-button').addEventListener('click', () => go(2)); $('#start-button').addEventListener('click', () => go(2));
@@ -506,10 +645,16 @@
state.installing = true; state.installing = true;
$('#confirm-view').classList.add('hidden'); $('#confirm-view').classList.add('hidden');
$('#installing-view').classList.remove('hidden'); $('#installing-view').classList.remove('hidden');
beginInstallTracking(false);
setProgress(1); setProgress(1);
let migrationTimer;
try { try {
const request = { database: state.database, administrator: state.administrator }; const request = { database: state.database, administrator: state.administrator };
setTimeout(() => setProgress(2), 700); migrationTimer = setTimeout(() => {
setProgress(2);
setInstallMeter(28, '执行数据库初始化', '迁移结构和基础数据');
appendInstallLog('数据库连接验证通过,正在执行结构迁移和基础数据初始化。');
}, 700);
try { try {
await api('/api/install/complete', { method: 'POST', body: JSON.stringify(request) }); await api('/api/install/complete', { method: 'POST', body: JSON.stringify(request) });
} catch (error) { } catch (error) {
@@ -518,12 +663,20 @@
// is flushed; the formal health probe is the authoritative result. // is flushed; the formal health probe is the authoritative result.
if (!(error instanceof TypeError) && error.message !== '安装服务响应无效') throw error; if (!(error instanceof TypeError) && error.message !== '安装服务响应无效') throw error;
} }
clearTimeout(migrationTimer);
setProgress(3); setProgress(3);
setInstallMeter(68, '创建管理员并切换服务', '数据库初始化已完成');
appendInstallLog('数据库迁移和管理员初始化已提交,准备启动正式服务。');
state.administrator.password = ''; state.administrator.password = '';
state.database.password = ''; state.database.password = '';
await pollFormalService(); await pollFormalService();
} catch (error) { } catch (error) {
clearTimeout(migrationTimer);
state.installing = false; state.installing = false;
state.healthPolling = false;
setInstallMeter(state.installPercent, '安装已中断', '请处理错误后重试');
appendInstallLog(`安装中断:${error.message}`);
$('#install-console-state').textContent = '已中断';
$('#install-message').className = 'notice error'; $('#install-message').className = 'notice error';
$('#install-message').textContent = error.message; $('#install-message').textContent = error.message;
const actions = document.createElement('div'); const actions = document.createElement('div');
@@ -548,6 +701,7 @@
go(4); go(4);
$('#confirm-view').classList.add('hidden'); $('#confirm-view').classList.add('hidden');
$('#installing-view').classList.remove('hidden'); $('#installing-view').classList.remove('hidden');
beginInstallTracking(true);
return pollFormalService(); return pollFormalService();
} }
$('#java-detail').textContent = `Java ${status.javaVersion},最低要求 Java ${status.minimumJava}`; $('#java-detail').textContent = `Java ${status.javaVersion},最低要求 Java ${status.minimumJava}`;
@@ -15,6 +15,7 @@ public class UpdateProperties {
private String token = ""; private String token = "";
private String helperCommand = ""; private String helperCommand = "";
private String stateFile = "./runtime/update-state.json"; private String stateFile = "./runtime/update-state.json";
private String configFile = "";
private boolean allowInsecureHttp; private boolean allowInsecureHttp;
private int requestTimeoutSeconds = 15; private int requestTimeoutSeconds = 15;
@@ -74,6 +75,14 @@ public class UpdateProperties {
this.stateFile = stateFile; this.stateFile = stateFile;
} }
public String getConfigFile() {
return configFile;
}
public void setConfigFile(String configFile) {
this.configFile = configFile;
}
public boolean isAllowInsecureHttp() { public boolean isAllowInsecureHttp() {
return allowInsecureHttp; return allowInsecureHttp;
} }
@@ -0,0 +1,256 @@
package com.kaidi.oa.service;
import com.kaidi.oa.common.ApiException;
import com.kaidi.oa.config.UpdateProperties;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.net.URI;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.PosixFilePermission;
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.regex.Pattern;
/** Persists the administrator-managed updater source without exposing credentials. */
@Service
public class SystemUpdateConfigService {
private static final long MAX_CONFIG_BYTES = 1024 * 1024;
private static final Pattern REPOSITORY_PATTERN = Pattern.compile(
"^[A-Za-z0-9][A-Za-z0-9._-]{0,99}/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$");
private static final Pattern TOKEN_PATTERN = Pattern.compile("^[A-Za-z0-9._-]{1,512}$");
private static final Set<String> CHANNELS = Set.of("stable", "preview");
private final UpdateProperties properties;
public SystemUpdateConfigService(UpdateProperties properties) {
this.properties = properties;
}
public synchronized UpdateConfig get() {
return new UpdateConfig(
properties.isEnabled(),
value(properties.getGiteaBaseUrl()),
value(properties.getRepository()),
normalizedChannel(properties.getChannel()),
properties.getToken() != null && !properties.getToken().isBlank(),
properties.isAllowInsecureHttp()
);
}
public synchronized UpdateConfig save(UpdateConfigRequest request) {
if (request == null) {
throw new ApiException(400, "更新配置不能为空");
}
String baseUrl = normalizeBaseUrl(request.giteaBaseUrl(), request.allowInsecureHttp(), request.enabled());
String repository = normalizeRepository(request.repository());
String channel = normalizedChannel(request.channel());
String token = value(properties.getToken());
String suppliedToken = value(request.token());
if (request.clearToken() && !suppliedToken.isBlank()) {
throw new ApiException(400, "不能同时清除并设置 Gitea Token");
}
if (request.clearToken()) {
token = "";
} else if (!suppliedToken.isBlank()) {
if (!TOKEN_PATTERN.matcher(suppliedToken).matches()) {
throw new ApiException(400, "Gitea Token 格式无效");
}
token = suppliedToken;
}
LinkedHashMap<String, String> changes = new LinkedHashMap<>();
changes.put("OA_UPDATE_ENABLED", Boolean.toString(request.enabled()));
changes.put("OA_UPDATE_GITEA_BASE_URL", baseUrl);
changes.put("OA_UPDATE_REPOSITORY", repository);
changes.put("OA_UPDATE_CHANNEL", channel);
changes.put("OA_UPDATE_TOKEN", token);
changes.put("OA_UPDATE_ALLOW_INSECURE_HTTP", Boolean.toString(request.allowInsecureHttp()));
persist(changes);
properties.setEnabled(request.enabled());
properties.setGiteaBaseUrl(baseUrl);
properties.setRepository(repository);
properties.setChannel(channel);
properties.setToken(token);
properties.setAllowInsecureHttp(request.allowInsecureHttp());
return get();
}
private void persist(Map<String, String> changes) {
String configuredPath = value(properties.getConfigFile());
if (configuredPath.isBlank()) {
throw new ApiException(503, "当前运行方式未提供可写的 ERP_CONFIG_FILE");
}
Path target = Path.of(configuredPath).toAbsolutePath().normalize();
try {
if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS) || !Files.isWritable(target)) {
throw new ApiException(503, "ERP 配置文件不存在或不可写");
}
if (Files.size(target) > MAX_CONFIG_BYTES) {
throw new ApiException(503, "ERP 配置文件异常过大");
}
BasicFileAttributes before = Files.readAttributes(
target, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
List<String> original = Files.readAllLines(target, StandardCharsets.UTF_8);
List<String> updated = replaceWhitelistedSettings(original, changes);
byte[] content = (String.join("\n", updated) + "\n").getBytes(StandardCharsets.UTF_8);
atomicWrite(target, content, before);
} catch (ApiException exception) {
throw exception;
} catch (IOException | RuntimeException exception) {
throw new ApiException(503, "无法保存更新配置,请检查 ERP 配置文件权限");
}
}
private List<String> replaceWhitelistedSettings(List<String> original, Map<String, String> changes) {
List<String> result = new ArrayList<>(original.size() + changes.size());
Set<String> written = new java.util.HashSet<>();
for (String line : original) {
String matched = null;
for (String key : changes.keySet()) {
if (line.startsWith(key + "=")) {
matched = key;
break;
}
}
if (matched == null) {
result.add(line);
} else if (written.add(matched)) {
result.add(setting(matched, changes.get(matched)));
}
}
for (Map.Entry<String, String> entry : changes.entrySet()) {
if (written.add(entry.getKey())) {
result.add(setting(entry.getKey(), entry.getValue()));
}
}
return result;
}
private void atomicWrite(Path target, byte[] content, BasicFileAttributes before) throws IOException {
Path parent = target.getParent();
if (parent == null || !Files.isDirectory(parent, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("configuration parent is unavailable");
}
Path temporary = Files.createTempFile(parent, ".erp.env-", ".tmp");
try {
try {
Set<PosixFilePermission> permissions = Files.getPosixFilePermissions(
target, LinkOption.NOFOLLOW_LINKS);
Files.setPosixFilePermissions(temporary, permissions);
} catch (UnsupportedOperationException ignored) {
// Production targets POSIX systems; keep local non-POSIX tests portable.
}
try (FileChannel channel = FileChannel.open(
temporary, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) {
channel.write(ByteBuffer.wrap(content));
channel.force(true);
}
BasicFileAttributes current = Files.readAttributes(
target, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
if (current.size() != before.size() || !current.lastModifiedTime().equals(before.lastModifiedTime())) {
throw new IOException("configuration changed concurrently");
}
try {
Files.move(temporary, target,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException exception) {
throw new IOException("atomic configuration replacement is unavailable", exception);
}
} finally {
Files.deleteIfExists(temporary);
}
}
private static String normalizeBaseUrl(String input, boolean allowInsecureHttp, boolean required) {
String value = value(input).replaceAll("/+$", "");
if (value.isBlank()) {
if (required) {
throw new ApiException(400, "启用在线更新时必须填写 Gitea 地址");
}
return "";
}
URI uri;
try {
uri = URI.create(value);
} catch (IllegalArgumentException exception) {
throw new ApiException(400, "Gitea 地址格式无效");
}
String scheme = value(uri.getScheme()).toLowerCase(Locale.ROOT);
if (!("https".equals(scheme) || (allowInsecureHttp && "http".equals(scheme)))) {
throw new ApiException(400, "Gitea 地址必须使用 HTTPS");
}
if (uri.getHost() == null || uri.getUserInfo() != null || uri.getRawQuery() != null
|| uri.getRawFragment() != null || uri.getPort() > 65535) {
throw new ApiException(400, "Gitea 地址格式无效");
}
return value;
}
private static String normalizeRepository(String input) {
String repository = value(input);
if (!REPOSITORY_PATTERN.matcher(repository).matches()) {
throw new ApiException(400, "仓库格式必须为 owner/repository");
}
return repository;
}
private static String normalizedChannel(String input) {
String channel = value(input).toLowerCase(Locale.ROOT);
if (channel.isBlank()) {
return "stable";
}
if (!CHANNELS.contains(channel)) {
throw new ApiException(400, "更新通道只支持 stable 或 preview");
}
return channel;
}
private static String setting(String key, String value) {
if (value.indexOf('\0') >= 0 || value.indexOf('\n') >= 0 || value.indexOf('\r') >= 0) {
throw new ApiException(400, "更新配置不能包含换行符");
}
return key + "='" + value.replace("'", "'\\''") + "'";
}
private static String value(String value) {
return value == null ? "" : value.trim();
}
public record UpdateConfig(
boolean enabled,
String giteaBaseUrl,
String repository,
String channel,
boolean tokenConfigured,
boolean allowInsecureHttp
) {
}
public record UpdateConfigRequest(
boolean enabled,
String giteaBaseUrl,
String repository,
String channel,
String token,
boolean clearToken,
boolean allowInsecureHttp
) {
}
}
@@ -21,13 +21,16 @@ import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption; import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.PosixFilePermission;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Objects; import java.util.Objects;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Matcher; import java.util.regex.Matcher;
@@ -43,6 +46,10 @@ public class SystemUpdateService {
+ "(?:-([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?" + "(?:-([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?"
+ "(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$"); + "(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$");
private static final long MAX_STATE_BYTES = 64 * 1024; private static final long MAX_STATE_BYTES = 64 * 1024;
private static final int MAX_RELEASE_NOTES_CHARS = 32_000;
private static final int MAX_RELEASE_ASSETS = 32;
private static final int MAX_ASSET_NAME_CHARS = 255;
private static final int MAX_ASSET_URL_CHARS = 4_096;
private final UpdateProperties properties; private final UpdateProperties properties;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
@@ -76,12 +83,33 @@ public class SystemUpdateService {
public UpdateStatus status() { public UpdateStatus status() {
UpdateStatus persisted = readHelperState(); UpdateStatus persisted = readHelperState();
return persisted == null ? state.get() : persisted; if (persisted != null) {
state.set(persisted);
return persisted;
}
return state.get();
}
public void ensureConfigurationMutable() {
if (isActivePhase(status().phase())) {
throw new ApiException(409, "更新进行中,暂时不能修改更新源");
}
}
public synchronized void configurationChanged() {
UpdateStatus idle = UpdateStatus.idle(isConfigured(), currentVersion);
state.set(idle);
persistState(idle);
} }
public synchronized UpdateStatus check() { public synchronized UpdateStatus check() {
requireConfigured(); requireConfigured();
state.set(state.get().withPhase(UpdatePhase.CHECKING, 5, "正在检查 Gitea Release")); if (isActivePhase(status().phase())) {
throw new ApiException(409, "更新任务正在执行");
}
UpdateStatus checking = state.get().withPhase(UpdatePhase.CHECKING, 5, "正在检查 Gitea Release");
state.set(checking);
persistState(checking);
try { try {
ReleaseInfo release = fetchLatestRelease(); ReleaseInfo release = fetchLatestRelease();
boolean available = compareVersions(release.version(), currentVersion) > 0; boolean available = compareVersions(release.version(), currentVersion) > 0;
@@ -100,9 +128,12 @@ public class SystemUpdateService {
null null
); );
state.set(checked); state.set(checked);
persistState(checked);
return checked; return checked;
} catch (RuntimeException e) { } catch (RuntimeException e) {
state.set(state.get().failed(safeMessage(e))); UpdateStatus failed = state.get().failed(safeMessage(e));
state.set(failed);
persistState(failed);
throw e; throw e;
} }
} }
@@ -150,6 +181,7 @@ public class SystemUpdateService {
UpdateStatus starting = checked.withPhase(UpdatePhase.STARTING, 1, "更新助手已启动"); UpdateStatus starting = checked.withPhase(UpdatePhase.STARTING, 1, "更新助手已启动");
state.set(starting); state.set(starting);
persistState(starting);
process.onExit().thenAccept(completed -> { process.onExit().thenAccept(completed -> {
installRunning.set(false); installRunning.set(false);
if (completed.exitValue() != 0) { if (completed.exitValue() != 0) {
@@ -182,6 +214,7 @@ public class SystemUpdateService {
try { try {
HttpResponse<String> response = httpClient.send(builder.build(), HttpResponse<String> response = httpClient.send(builder.build(),
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
requireSameOrigin(response.uri(), configuredBaseUri(), "Gitea Release API");
if (response.statusCode() == 404) { if (response.statusCode() == 404) {
throw new ApiException(404, "Gitea 尚未发布 Release"); throw new ApiException(404, "Gitea 尚未发布 Release");
} }
@@ -213,11 +246,21 @@ public class SystemUpdateService {
&& "stable".equalsIgnoreCase(properties.getChannel())) { && "stable".equalsIgnoreCase(properties.getChannel())) {
throw new ApiException(502, "稳定频道拒绝预发布版本"); throw new ApiException(502, "稳定频道拒绝预发布版本");
} }
if (!root.path("assets").isArray() || root.path("assets").size() > MAX_RELEASE_ASSETS) {
throw new ApiException(502, "Release 文件列表无效");
}
List<ReleaseAsset> assets = new ArrayList<>(); List<ReleaseAsset> assets = new ArrayList<>();
for (JsonNode node : root.path("assets")) { for (JsonNode node : root.path("assets")) {
String name = text(node, "name");
String downloadUrl = text(node, "browser_download_url");
if (name.isBlank() || name.length() > MAX_ASSET_NAME_CHARS
|| downloadUrl.isBlank() || downloadUrl.length() > MAX_ASSET_URL_CHARS) {
throw new ApiException(502, "Release 文件信息无效");
}
validateAssetUrl(downloadUrl);
assets.add(new ReleaseAsset( assets.add(new ReleaseAsset(
text(node, "name"), name,
text(node, "browser_download_url"), downloadUrl,
node.path("size").asLong(0) node.path("size").asLong(0)
)); ));
} }
@@ -234,7 +277,11 @@ public class SystemUpdateService {
// An invalid optional timestamp must not hide an otherwise valid release. // An invalid optional timestamp must not hide an otherwise valid release.
} }
} }
return new ReleaseInfo(version, text(root, "body"), publishedAt, List.copyOf(assets)); String notes = text(root, "body");
if (notes.length() > MAX_RELEASE_NOTES_CHARS) {
notes = notes.substring(0, MAX_RELEASE_NOTES_CHARS) + "\n\n[发布说明过长,已截断]";
}
return new ReleaseInfo(version, notes, publishedAt, List.copyOf(assets));
} }
private void requireConfigured() { private void requireConfigured() {
@@ -244,16 +291,15 @@ public class SystemUpdateService {
if (properties.getGiteaBaseUrl() == null || properties.getGiteaBaseUrl().isBlank()) { if (properties.getGiteaBaseUrl() == null || properties.getGiteaBaseUrl().isBlank()) {
throw new ApiException(503, "尚未配置 Gitea 地址"); throw new ApiException(503, "尚未配置 Gitea 地址");
} }
URI uri; URI uri = configuredBaseUri();
try {
uri = URI.create(properties.getGiteaBaseUrl().trim());
} catch (IllegalArgumentException e) {
throw new ApiException(500, "Gitea 地址格式无效");
}
String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(Locale.ROOT); String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(Locale.ROOT);
if (!"https".equals(scheme) && !(properties.isAllowInsecureHttp() && "http".equals(scheme))) { if (!"https".equals(scheme) && !(properties.isAllowInsecureHttp() && "http".equals(scheme))) {
throw new ApiException(503, "更新服务器必须使用 HTTPS"); throw new ApiException(503, "更新服务器必须使用 HTTPS");
} }
String repository = properties.getRepository() == null ? "" : properties.getRepository().trim();
if (!repository.matches("^[A-Za-z0-9][A-Za-z0-9._-]{0,99}/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$")) {
throw new ApiException(500, "更新仓库配置无效");
}
} }
private UpdateStatus readHelperState() { private UpdateStatus readHelperState() {
@@ -272,10 +318,15 @@ public class SystemUpdateService {
String helperVersion = blankToNull(text(root, "version")); String helperVersion = blankToNull(text(root, "version"));
boolean updateAvailable = helperVersion != null boolean updateAvailable = helperVersion != null
&& phase != UpdatePhase.SUCCEEDED && phase != UpdatePhase.SUCCEEDED
&& phase != UpdatePhase.UP_TO_DATE
&& compareVersions(helperVersion, currentVersion) > 0; && compareVersions(helperVersion, currentVersion) > 0;
Instant helperUpdatedAt = parseInstant(text(root, "updatedAt")); Instant helperUpdatedAt = parseInstant(text(root, "updatedAt"));
Instant publishedAt = parseInstant(text(root, "publishedAt"));
String releaseNotes = root.path("releaseNotes").isTextual()
? root.path("releaseNotes").asText() : memory.releaseNotes();
List<ReleaseAsset> assets = readAssets(root.path("assets"), memory.assets());
return new UpdateStatus( return new UpdateStatus(
properties.isEnabled(), isConfigured(),
currentVersion, currentVersion,
helperVersion, helperVersion,
updateAvailable, updateAvailable,
@@ -283,9 +334,9 @@ public class SystemUpdateService {
Math.max(0, Math.min(100, root.path("progress").asInt(0))), Math.max(0, Math.min(100, root.path("progress").asInt(0))),
text(root, "message"), text(root, "message"),
helperUpdatedAt == null ? memory.checkedAt() : helperUpdatedAt, helperUpdatedAt == null ? memory.checkedAt() : helperUpdatedAt,
memory.publishedAt(), publishedAt == null ? memory.publishedAt() : publishedAt,
memory.releaseNotes(), releaseNotes,
memory.assets(), assets,
blankToNull(text(root, "error")) blankToNull(text(root, "error"))
); );
} catch (Exception e) { } catch (Exception e) {
@@ -300,6 +351,137 @@ public class SystemUpdateService {
.toAbsolutePath().normalize(); .toAbsolutePath().normalize();
} }
private void persistState(UpdateStatus status) {
Path target = statePath();
Path parent = target.getParent();
Path temporary = null;
try {
if (parent != null) {
Files.createDirectories(parent);
}
byte[] content = objectMapper.writeValueAsBytes(java.util.Map.ofEntries(
java.util.Map.entry("phase", status.phase().name()),
java.util.Map.entry("progress", status.progress()),
java.util.Map.entry("message", Objects.requireNonNullElse(status.message(), "")),
java.util.Map.entry("version", Objects.requireNonNullElse(status.latestVersion(), "")),
java.util.Map.entry("error", Objects.requireNonNullElse(status.error(), "")),
java.util.Map.entry("updatedAt", Instant.now().toString()),
java.util.Map.entry("publishedAt", status.publishedAt() == null ? "" : status.publishedAt().toString()),
java.util.Map.entry("releaseNotes", Objects.requireNonNullElse(status.releaseNotes(), "")),
java.util.Map.entry("assets", Objects.requireNonNullElse(status.assets(), List.of()))
));
if (content.length > MAX_STATE_BYTES) {
throw new IOException("update state is too large");
}
Path directory = parent == null ? Path.of(".").toAbsolutePath().normalize() : parent;
temporary = Files.createTempFile(directory, ".update-state-", ".tmp");
Files.write(temporary, content, StandardOpenOption.TRUNCATE_EXISTING);
try {
Files.setPosixFilePermissions(temporary, Set.of(
PosixFilePermission.OWNER_READ,
PosixFilePermission.OWNER_WRITE,
PosixFilePermission.GROUP_READ));
} catch (UnsupportedOperationException ignored) {
// Keep local non-POSIX tests portable.
}
Files.move(temporary, target,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
temporary = null;
} catch (Exception exception) {
log.warn("Unable to persist update state {}: {}", target, exception.getMessage());
} finally {
if (temporary != null) {
try {
Files.deleteIfExists(temporary);
} catch (IOException ignored) {
// Best effort cleanup only.
}
}
}
}
private List<ReleaseAsset> readAssets(JsonNode node, List<ReleaseAsset> fallback) {
if (!node.isArray()) {
return fallback == null ? List.of() : fallback;
}
if (node.size() > MAX_RELEASE_ASSETS) {
return List.of();
}
List<ReleaseAsset> assets = new ArrayList<>();
for (JsonNode asset : node) {
String name = text(asset, "name");
String downloadUrl = text(asset, "downloadUrl");
if (name.isBlank() || downloadUrl.isBlank()) {
continue;
}
validateAssetUrl(downloadUrl);
assets.add(new ReleaseAsset(name, downloadUrl, asset.path("size").asLong(0)));
}
return List.copyOf(assets);
}
private URI configuredBaseUri() {
URI uri;
try {
uri = URI.create(Objects.requireNonNullElse(properties.getGiteaBaseUrl(), "").trim());
} catch (IllegalArgumentException exception) {
throw new ApiException(500, "Gitea 地址格式无效");
}
if (uri.getHost() == null || uri.getUserInfo() != null || uri.getRawQuery() != null
|| uri.getRawFragment() != null || uri.getPort() > 65535) {
throw new ApiException(500, "Gitea 地址格式无效");
}
return uri;
}
private void validateAssetUrl(String value) {
URI asset;
try {
asset = URI.create(value);
} catch (IllegalArgumentException exception) {
throw new ApiException(502, "Release 文件地址无效");
}
if (asset.getUserInfo() != null || asset.getRawFragment() != null) {
throw new ApiException(502, "Release 文件地址无效");
}
requireSameOrigin(asset, configuredBaseUri(), "Release 文件");
}
private static void requireSameOrigin(URI actual, URI expected, String label) {
String actualScheme = Objects.requireNonNullElse(actual.getScheme(), "").toLowerCase(Locale.ROOT);
String expectedScheme = Objects.requireNonNullElse(expected.getScheme(), "").toLowerCase(Locale.ROOT);
String actualHost = Objects.requireNonNullElse(actual.getHost(), "").toLowerCase(Locale.ROOT);
String expectedHost = Objects.requireNonNullElse(expected.getHost(), "").toLowerCase(Locale.ROOT);
if (!actualScheme.equals(expectedScheme) || !actualHost.equals(expectedHost)
|| effectivePort(actual) != effectivePort(expected)) {
throw new ApiException(502, label + "跳转到了未受信任的服务器");
}
}
private static int effectivePort(URI uri) {
if (uri.getPort() >= 0) {
return uri.getPort();
}
return "https".equalsIgnoreCase(uri.getScheme()) ? 443 : 80;
}
private boolean isConfigured() {
return properties.isEnabled()
&& properties.getGiteaBaseUrl() != null
&& !properties.getGiteaBaseUrl().isBlank()
&& properties.getRepository() != null
&& !properties.getRepository().isBlank();
}
private static boolean isActivePhase(UpdatePhase phase) {
return phase == UpdatePhase.STARTING
|| phase == UpdatePhase.DOWNLOADING
|| phase == UpdatePhase.VERIFYING
|| phase == UpdatePhase.INSTALLING
|| phase == UpdatePhase.RESTARTING
|| phase == UpdatePhase.ROLLING_BACK;
}
private static String text(JsonNode node, String field) { private static String text(JsonNode node, String field) {
JsonNode value = node.path(field); JsonNode value = node.path(field);
return value.isTextual() ? value.asText().trim() : ""; return value.isTextual() ? value.asText().trim() : "";
@@ -4,6 +4,7 @@ import com.kaidi.oa.common.ApiException;
import com.kaidi.oa.common.ApiResp; import com.kaidi.oa.common.ApiResp;
import com.kaidi.oa.domain.SysUser; import com.kaidi.oa.domain.SysUser;
import com.kaidi.oa.service.AuthService; import com.kaidi.oa.service.AuthService;
import com.kaidi.oa.service.AuthorizationService;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -14,6 +15,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.Map; import java.util.Map;
import java.util.TreeSet;
/** /**
* Authentication endpoints. Issues an opaque token on login; the frontend sends * Authentication endpoints. Issues an opaque token on login; the frontend sends
@@ -25,10 +27,13 @@ public class AuthController {
private final AuthService authService; private final AuthService authService;
private final CurrentUserResolver currentUser; private final CurrentUserResolver currentUser;
private final AuthorizationService authorizationService;
public AuthController(AuthService authService, CurrentUserResolver currentUser) { public AuthController(AuthService authService, CurrentUserResolver currentUser,
AuthorizationService authorizationService) {
this.authService = authService; this.authService = authService;
this.currentUser = currentUser; this.currentUser = currentUser;
this.authorizationService = authorizationService;
} }
public record LoginRequest(@NotBlank String loginName, @NotBlank String password) { public record LoginRequest(@NotBlank String loginName, @NotBlank String password) {
@@ -65,6 +70,7 @@ public class AuthController {
map.put("deptId", user.getDeptId()); map.put("deptId", user.getDeptId());
map.put("title", user.getTitle()); map.put("title", user.getTitle());
map.put("email", user.getEmail()); map.put("email", user.getEmail());
map.put("roles", new TreeSet<>(authorizationService.roleCodesOf(user.getId())));
return map; return map;
} }
} }
@@ -1,6 +1,7 @@
package com.kaidi.oa.web; package com.kaidi.oa.web;
import com.kaidi.oa.common.ApiResp; import com.kaidi.oa.common.ApiResp;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -8,6 +9,9 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import javax.sql.DataSource; import javax.sql.DataSource;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.sql.Connection; import java.sql.Connection;
import java.sql.ResultSet; import java.sql.ResultSet;
import java.sql.Statement; import java.sql.Statement;
@@ -19,9 +23,12 @@ import java.util.Map;
public class HealthController { public class HealthController {
private final DataSource dataSource; private final DataSource dataSource;
private final Path installLockFile;
public HealthController(DataSource dataSource) { public HealthController(DataSource dataSource,
@Value("${ERP_INSTALL_LOCK_FILE:}") String installLockFile) {
this.dataSource = dataSource; this.dataSource = dataSource;
this.installLockFile = resolveInstallLockFile(installLockFile);
} }
@GetMapping("/health") @GetMapping("/health")
@@ -30,12 +37,31 @@ public class HealthController {
Statement statement = connection.createStatement(); Statement statement = connection.createStatement();
ResultSet result = statement.executeQuery("SELECT 1")) { ResultSet result = statement.executeQuery("SELECT 1")) {
if (connection.isValid(2) && result.next() && result.getInt(1) == 1) { if (connection.isValid(2) && result.next() && result.getInt(1) == 1) {
return ResponseEntity.ok(ApiResp.ok(Map.of("status", "UP", "database", "UP"))); return ResponseEntity.ok(ApiResp.ok(readiness("UP", "UP")));
} }
} catch (Exception ignored) { } catch (Exception ignored) {
// Health responses deliberately avoid exposing connection details. // Health responses deliberately avoid exposing connection details.
} }
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(new ApiResp<>(50301, "database unavailable", Map.of("status", "DOWN", "database", "DOWN"))); .body(new ApiResp<>(50301, "database unavailable", readiness("DOWN", "DOWN")));
}
private Map<String, String> readiness(String status, String database) {
return Map.of(
"status", status,
"database", database,
"installLocked", Boolean.toString(installLockFile != null && Files.isRegularFile(installLockFile))
);
}
private static Path resolveInstallLockFile(String value) {
if (value == null || value.isBlank()) {
return null;
}
try {
return Path.of(value).toAbsolutePath().normalize();
} catch (InvalidPathException ignored) {
return null;
}
} }
} }
@@ -1,12 +1,16 @@
package com.kaidi.oa.web; package com.kaidi.oa.web;
import com.kaidi.oa.common.ApiResp; import com.kaidi.oa.common.ApiResp;
import com.kaidi.oa.service.SystemUpdateConfigService;
import com.kaidi.oa.service.SystemUpdateConfigService.UpdateConfig;
import com.kaidi.oa.service.SystemUpdateConfigService.UpdateConfigRequest;
import com.kaidi.oa.service.SystemUpdateService; import com.kaidi.oa.service.SystemUpdateService;
import com.kaidi.oa.service.SystemUpdateService.UpdateStatus; import com.kaidi.oa.service.SystemUpdateService.UpdateStatus;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@@ -17,9 +21,24 @@ import org.springframework.web.bind.annotation.RestController;
public class SystemUpdateController { public class SystemUpdateController {
private final SystemUpdateService updateService; private final SystemUpdateService updateService;
private final SystemUpdateConfigService configService;
public SystemUpdateController(SystemUpdateService updateService) { public SystemUpdateController(SystemUpdateService updateService, SystemUpdateConfigService configService) {
this.updateService = updateService; this.updateService = updateService;
this.configService = configService;
}
@GetMapping("/config")
public ApiResp<UpdateConfig> config() {
return ApiResp.ok(configService.get());
}
@PutMapping("/config")
public ApiResp<UpdateConfig> saveConfig(@Valid @RequestBody UpdateConfigRequest request) {
updateService.ensureConfigurationMutable();
UpdateConfig saved = configService.save(request);
updateService.configurationChanged();
return ApiResp.ok(saved);
} }
@GetMapping("/status") @GetMapping("/status")
@@ -1,5 +1,8 @@
server: server:
port: 8090 port: 8090
# Reconstruct the public scheme/host when running behind Nginx, Caddy or a
# tunnel so same-origin HTTPS requests are not rejected as cross-origin.
forward-headers-strategy: framework
spring: spring:
application: application:
@@ -67,4 +70,5 @@ oa:
token: ${OA_UPDATE_TOKEN:} token: ${OA_UPDATE_TOKEN:}
helper-command: ${OA_UPDATE_HELPER_COMMAND:} helper-command: ${OA_UPDATE_HELPER_COMMAND:}
state-file: ${OA_UPDATE_STATE_FILE:./runtime/update-state.json} state-file: ${OA_UPDATE_STATE_FILE:./runtime/update-state.json}
config-file: ${ERP_CONFIG_FILE:}
allow-insecure-http: ${OA_UPDATE_ALLOW_INSECURE_HTTP:false} allow-insecure-http: ${OA_UPDATE_ALLOW_INSECURE_HTTP:false}
@@ -0,0 +1,98 @@
package com.kaidi.oa.service;
import com.kaidi.oa.common.ApiException;
import com.kaidi.oa.config.UpdateProperties;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class SystemUpdateConfigServiceTest {
@TempDir
Path tempDir;
@Test
void atomicallyUpdatesOnlyUpdaterSettingsAndNeverReturnsToken() throws Exception {
Path configFile = tempDir.resolve("erp.env");
Files.writeString(configFile, """
OA_DB_PASSWORD='keep-this-secret'
OA_UPDATE_ENABLED='true'
OA_UPDATE_GITEA_BASE_URL='https://old.example'
OA_UPDATE_REPOSITORY='old/ERP'
OA_UPDATE_CHANNEL='stable'
OA_UPDATE_TOKEN='old-token'
OA_UPDATE_ALLOW_INSECURE_HTTP='false'
""");
UpdateProperties properties = properties(configFile);
properties.setToken("old-token");
SystemUpdateConfigService service = new SystemUpdateConfigService(properties);
SystemUpdateConfigService.UpdateConfig saved = service.save(
new SystemUpdateConfigService.UpdateConfigRequest(
true,
"http://38.76.196.225:10099/",
"awaioi/ERP",
"stable",
"new-token_123",
false,
true));
String persisted = Files.readString(configFile);
assertThat(persisted).contains("OA_DB_PASSWORD='keep-this-secret'");
assertThat(persisted).contains("OA_UPDATE_GITEA_BASE_URL='http://38.76.196.225:10099'");
assertThat(persisted).contains("OA_UPDATE_TOKEN='new-token_123'");
assertThat(saved.tokenConfigured()).isTrue();
assertThat(saved.toString()).doesNotContain("new-token_123");
assertThat(properties.getToken()).isEqualTo("new-token_123");
}
@Test
void blankTokenKeepsExistingTokenAndExplicitClearRemovesIt() throws Exception {
Path configFile = tempDir.resolve("erp.env");
Files.writeString(configFile, "OA_DB_PASSWORD='database-secret'\n");
UpdateProperties properties = properties(configFile);
properties.setToken("existing-token");
SystemUpdateConfigService service = new SystemUpdateConfigService(properties);
service.save(request("" , false));
assertThat(properties.getToken()).isEqualTo("existing-token");
assertThat(Files.readString(configFile)).contains("OA_UPDATE_TOKEN='existing-token'");
SystemUpdateConfigService.UpdateConfig cleared = service.save(request("", true));
assertThat(cleared.tokenConfigured()).isFalse();
assertThat(properties.getToken()).isEmpty();
assertThat(Files.readString(configFile)).contains("OA_UPDATE_TOKEN=''");
}
@Test
void rejectsPlainHttpWithoutExplicitOptIn() throws Exception {
Path configFile = tempDir.resolve("erp.env");
Files.writeString(configFile, "OA_UPDATE_ENABLED='false'\n");
SystemUpdateConfigService service = new SystemUpdateConfigService(properties(configFile));
assertThatThrownBy(() -> service.save(new SystemUpdateConfigService.UpdateConfigRequest(
true, "http://gitea.example", "awaioi/ERP", "stable", "", false, false)))
.isInstanceOf(ApiException.class)
.hasMessageContaining("HTTPS");
}
private UpdateProperties properties(Path configFile) {
UpdateProperties properties = new UpdateProperties();
properties.setEnabled(true);
properties.setGiteaBaseUrl("https://old.example");
properties.setRepository("awaioi/ERP");
properties.setChannel("stable");
properties.setConfigFile(configFile.toString());
return properties;
}
private SystemUpdateConfigService.UpdateConfigRequest request(String token, boolean clearToken) {
return new SystemUpdateConfigService.UpdateConfigRequest(
true, "https://gitea.example", "awaioi/ERP", "stable", token, clearToken, false);
}
}
@@ -5,6 +5,7 @@ import com.kaidi.oa.common.ApiException;
import com.kaidi.oa.config.UpdateProperties; import com.kaidi.oa.config.UpdateProperties;
import com.sun.net.httpserver.HttpServer; import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.info.BuildProperties; import org.springframework.boot.info.BuildProperties;
@@ -22,6 +23,9 @@ import static org.mockito.Mockito.when;
class SystemUpdateServiceTest { class SystemUpdateServiceTest {
@TempDir
Path tempDir;
@Test @Test
void comparesReleaseVersionsWithoutLexicographicMistakes() { void comparesReleaseVersionsWithoutLexicographicMistakes() {
assertThat(SystemUpdateService.compareVersions("v0.10.0", "0.9.9")).isPositive(); assertThat(SystemUpdateService.compareVersions("v0.10.0", "0.9.9")).isPositive();
@@ -38,7 +42,8 @@ class SystemUpdateServiceTest {
void checksLatestGiteaReleaseAndRequiresSignedAssetSet() throws Exception { void checksLatestGiteaReleaseAndRequiresSignedAssetSet() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/api/v1/repos/awaioi/ERP/releases/latest", exchange -> { server.createContext("/api/v1/repos/awaioi/ERP/releases/latest", exchange -> {
byte[] body = """ String origin = "http://127.0.0.1:" + exchange.getLocalAddress().getPort();
byte[] body = ("""
{ {
"tag_name": "v0.2.0", "tag_name": "v0.2.0",
"body": "PostgreSQL production release", "body": "PostgreSQL production release",
@@ -46,12 +51,12 @@ class SystemUpdateServiceTest {
"prerelease": false, "prerelease": false,
"published_at": "2026-08-03T10:00:00Z", "published_at": "2026-08-03T10:00:00Z",
"assets": [ "assets": [
{"name":"kaidi-erp-0.2.0.tar.gz","browser_download_url":"https://example.test/app","size":123}, {"name":"kaidi-erp-0.2.0.tar.gz","browser_download_url":"%s/app","size":123},
{"name":"SHA256SUMS","browser_download_url":"https://example.test/sums","size":64}, {"name":"SHA256SUMS","browser_download_url":"%s/sums","size":64},
{"name":"SHA256SUMS.sig","browser_download_url":"https://example.test/sig","size":64} {"name":"SHA256SUMS.sig","browser_download_url":"%s/sig","size":64}
] ]
} }
""".getBytes(StandardCharsets.UTF_8); """).formatted(origin, origin, origin).getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "application/json"); exchange.getResponseHeaders().add("Content-Type", "application/json");
exchange.sendResponseHeaders(200, body.length); exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body); exchange.getResponseBody().write(body);
@@ -74,6 +79,68 @@ class SystemUpdateServiceTest {
} }
} }
@Test
void persistsReleaseMetadataAcrossServiceRestart() throws Exception {
Path stateFile = Files.createTempFile("system-update-metadata-", ".json");
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/api/v1/repos/awaioi/ERP/releases/latest", exchange -> {
String origin = "http://127.0.0.1:" + exchange.getLocalAddress().getPort();
byte[] body = ("""
{"tag_name":"v0.4.0","body":"Fix updater UI","draft":false,"prerelease":false,
"published_at":"2026-08-04T09:30:00Z","assets":[
{"name":"kaidi-erp-0.4.0.tar.gz","browser_download_url":"%s/app","size":123},
{"name":"SHA256SUMS","browser_download_url":"%s/sums","size":64},
{"name":"SHA256SUMS.sig","browser_download_url":"%s/sig","size":64}]}
""").formatted(origin, origin, origin).getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
});
server.start();
try {
UpdateProperties properties = configuredProperties(server.getAddress().getPort());
properties.setStateFile(stateFile.toString());
SystemUpdateService first = service(properties, "0.3.0");
first.check();
SystemUpdateService restarted = service(properties, "0.3.0");
SystemUpdateService.UpdateStatus restored = restarted.status();
assertThat(restored.releaseNotes()).isEqualTo("Fix updater UI");
assertThat(restored.publishedAt()).isEqualTo(Instant.parse("2026-08-04T09:30:00Z"));
assertThat(restored.assets()).hasSize(3);
assertThat(restored.updateAvailable()).isTrue();
} finally {
server.stop(0);
Files.deleteIfExists(stateFile);
}
}
@Test
void rejectsReleaseAssetsFromAnotherOrigin() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/api/v1/repos/awaioi/ERP/releases/latest", exchange -> {
byte[] body = """
{"tag_name":"v0.4.0","draft":false,"prerelease":false,"assets":[
{"name":"kaidi-erp-0.4.0.tar.gz","browser_download_url":"https://evil.example/app","size":123},
{"name":"SHA256SUMS","browser_download_url":"https://evil.example/sums","size":64},
{"name":"SHA256SUMS.sig","browser_download_url":"https://evil.example/sig","size":64}]}
""".getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
});
server.start();
try {
SystemUpdateService service = service(configuredProperties(server.getAddress().getPort()), "0.3.0");
assertThatThrownBy(service::check)
.isInstanceOf(ApiException.class)
.hasMessageContaining("未受信任");
} finally {
server.stop(0);
}
}
@Test @Test
void rejectsPlainHttpUnlessDevelopmentOverrideIsExplicit() { void rejectsPlainHttpUnlessDevelopmentOverrideIsExplicit() {
UpdateProperties properties = configuredProperties(1); UpdateProperties properties = configuredProperties(1);
@@ -112,12 +179,13 @@ class SystemUpdateServiceTest {
} }
} }
private static UpdateProperties configuredProperties(int port) { private UpdateProperties configuredProperties(int port) {
UpdateProperties properties = new UpdateProperties(); UpdateProperties properties = new UpdateProperties();
properties.setEnabled(true); properties.setEnabled(true);
properties.setAllowInsecureHttp(true); properties.setAllowInsecureHttp(true);
properties.setGiteaBaseUrl("http://127.0.0.1:" + port); properties.setGiteaBaseUrl("http://127.0.0.1:" + port);
properties.setRepository("awaioi/ERP"); properties.setRepository("awaioi/ERP");
properties.setStateFile(tempDir.resolve("update-state-" + System.nanoTime() + ".json").toString());
return properties; return properties;
} }
@@ -0,0 +1,56 @@
package com.kaidi.oa.web;
import com.kaidi.oa.common.ApiResp;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.http.ResponseEntity;
import javax.sql.DataSource;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class HealthControllerTest {
@TempDir
Path tempDir;
@Test
void reportsTheActualInstallationLockState() throws Exception {
DataSource dataSource = mock(DataSource.class);
Connection connection = mock(Connection.class);
Statement statement = mock(Statement.class);
ResultSet resultSet = mock(ResultSet.class);
when(dataSource.getConnection()).thenReturn(connection);
when(connection.createStatement()).thenReturn(statement);
when(connection.isValid(2)).thenReturn(true);
when(statement.executeQuery("SELECT 1")).thenReturn(resultSet);
when(resultSet.next()).thenReturn(true);
when(resultSet.getInt(1)).thenReturn(1);
Path lockFile = tempDir.resolve("install.lock");
HealthController controller = new HealthController(dataSource, lockFile.toString());
assertThat(healthData(controller.health())).containsEntry("installLocked", "false");
Files.writeString(lockFile, "locked\n");
assertThat(healthData(controller.health()))
.containsEntry("status", "UP")
.containsEntry("database", "UP")
.containsEntry("installLocked", "true");
}
private static Map<String, String> healthData(
ResponseEntity<ApiResp<Map<String, String>>> response) {
assertThat(response.getBody()).isNotNull();
return response.getBody().data();
}
}
@@ -49,6 +49,7 @@ export type OaMenuItem = {
/** page archetype the builder implements: list | form | detail | portal | tree | calendar | board | settings | report */ /** page archetype the builder implements: list | form | detail | portal | tree | calendar | board | settings | report */
kind: 'list' | 'form' | 'detail' | 'portal' | 'tree' | 'calendar' | 'board' | 'settings' | 'report' kind: 'list' | 'form' | 'detail' | 'portal' | 'tree' | 'calendar' | 'board' | 'settings' | 'report'
path: string path: string
adminOnly?: boolean
} }
export type OaModule = { export type OaModule = {
@@ -98,7 +99,7 @@ export const oaModules: OaModule[] = [
{ key: 'workbench', label: '工作台', kind: 'portal', path: '/appdev/workbench' }, { key: 'workbench', label: '工作台', kind: 'portal', path: '/appdev/workbench' },
{ key: 'appmgr', label: '应用管理中心', kind: 'list', path: '/appdev/appmgr' }, { key: 'appmgr', label: '应用管理中心', kind: 'list', path: '/appdev/appmgr' },
{ key: 'ops', label: '运维中心', kind: 'settings', path: '/appdev/ops' }, { key: 'ops', label: '运维中心', kind: 'settings', path: '/appdev/ops' },
{ key: 'update', label: '系统更新', kind: 'settings', path: '/appdev/update' }, { key: 'update', label: '系统更新', kind: 'settings', path: '/appdev/update', adminOnly: true },
{ key: 'monitor', label: '监测中心', kind: 'report', path: '/appdev/monitor' }, { key: 'monitor', label: '监测中心', kind: 'report', path: '/appdev/monitor' },
{ key: 'aiassist', label: 'AI 助手', kind: 'portal', path: '/appdev/aiassist' }, { key: 'aiassist', label: 'AI 助手', kind: 'portal', path: '/appdev/aiassist' },
{ key: 'ruleconfig', label: '联动规则配置', kind: 'list', path: '/appdev/ruleconfig' } { key: 'ruleconfig', label: '联动规则配置', kind: 'list', path: '/appdev/ruleconfig' }
@@ -26,13 +26,20 @@ import {
userShortcuts userShortcuts
} from './shortcuts' } from './shortcuts'
import { useSession, currentUserName, currentDeptName } from './session' import { useSession, currentUserName, currentDeptName } from './session'
import { oaApi, type OaMessage } from './api' import { oaApi, type OaMessage, type SystemUpdateStatus } from './api'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const session = useSession() const session = useSession()
const userInitial = computed(() => currentUserName.value.slice(0, 1) || '我') const userInitial = computed(() => currentUserName.value.slice(0, 1) || '我')
const isAdmin = computed(() => session.user.value?.roles?.includes('ADMIN') === true)
const visibleModules = computed<OaModule[]>(() => oaModules
.map((module) => ({
...module,
children: module.children.filter((child) => !child.adminOnly || isAdmin.value)
}))
.filter((module) => module.children.length > 0))
async function handleLogout() { async function handleLogout() {
await session.logout() await session.logout()
ElMessage.success('已退出登录') ElMessage.success('已退出登录')
@@ -42,6 +49,10 @@ async function handleLogout() {
const unreadCount = ref(0) const unreadCount = ref(0)
const messages = ref<OaMessage[]>([]) const messages = ref<OaMessage[]>([])
let unreadTimer: ReturnType<typeof setInterval> | undefined let unreadTimer: ReturnType<typeof setInterval> | undefined
let updateTimer: ReturnType<typeof setInterval> | undefined
const updateStatus = ref<SystemUpdateStatus | null>(null)
const updateAvailable = computed(() => updateStatus.value?.updateAvailable === true)
const updateVersion = computed(() => updateStatus.value?.latestVersion || '')
async function loadUnread() { async function loadUnread() {
try { try {
@@ -58,6 +69,14 @@ async function loadMessages() {
messages.value = [] messages.value = []
} }
} }
async function loadUpdateStatus() {
if (!isAdmin.value) return
try {
updateStatus.value = await oaApi.getSystemUpdateStatus()
} catch {
// The updater must never block the application shell.
}
}
async function readAllMessages() { async function readAllMessages() {
try { try {
await oaApi.markAllMessagesRead() await oaApi.markAllMessagesRead()
@@ -108,11 +127,14 @@ function msgTagType(type: string): 'warning' | 'danger' | 'success' | 'info' {
onMounted(() => { onMounted(() => {
void loadUnread() void loadUnread()
void loadUpdateStatus()
// 轻量轮询未读数(30s),让铃铛徽标随新待办/退回/办结即时更新。 // 轻量轮询未读数(30s),让铃铛徽标随新待办/退回/办结即时更新。
unreadTimer = setInterval(() => void loadUnread(), 30000) unreadTimer = setInterval(() => void loadUnread(), 30000)
updateTimer = setInterval(() => void loadUpdateStatus(), 10 * 60 * 1000)
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (unreadTimer) clearInterval(unreadTimer) if (unreadTimer) clearInterval(unreadTimer)
if (updateTimer) clearInterval(updateTimer)
}) })
const globalQuery = ref('') const globalQuery = ref('')
@@ -157,14 +179,14 @@ const currentSpaceLabel = computed(
// The active top module is whichever module owns the current route path. // The active top module is whichever module owns the current route path.
const activeModuleId = computed(() => { const activeModuleId = computed(() => {
const match = oaModules.find((module) => route.path.startsWith(module.path)) const match = visibleModules.value.find((module) => route.path.startsWith(module.path))
return match?.id || '' return match?.id || ''
}) })
// ----- 侧边导航(el-menu----- // ----- 侧边导航(el-menu-----
// 当前高亮项:优先精确命中某子页路径,否则取最长前缀匹配(兼容 /collab/handle?id= 等子路由)。 // 当前高亮项:优先精确命中某子页路径,否则取最长前缀匹配(兼容 /collab/handle?id= 等子路由)。
const activeMenu = computed(() => { const activeMenu = computed(() => {
const paths = oaModules.flatMap((module) => module.children.map((child) => child.path)) const paths = visibleModules.value.flatMap((module) => module.children.map((child) => child.path))
if (paths.includes(route.path)) return route.path if (paths.includes(route.path)) return route.path
const prefixHit = paths const prefixHit = paths
.filter((p) => route.path.startsWith(p)) .filter((p) => route.path.startsWith(p))
@@ -253,7 +275,7 @@ function runGlobalSearch() {
<div class="oa-appcenter__head">应用中心</div> <div class="oa-appcenter__head">应用中心</div>
<div class="oa-appcenter__grid"> <div class="oa-appcenter__grid">
<button <button
v-for="module in oaModules" v-for="module in visibleModules"
:key="module.id" :key="module.id"
type="button" type="button"
class="oa-appcenter__item" class="oa-appcenter__item"
@@ -315,9 +337,18 @@ function runGlobalSearch() {
</el-scrollbar> </el-scrollbar>
</div> </div>
</el-popover> </el-popover>
<el-tooltip content="设置" placement="bottom"> <el-badge
<el-button class="oa-tools__btn" :icon="Setting" text circle @click="navigate('/hr/worktime')" /> v-if="isAdmin"
</el-tooltip> :is-dot="updateAvailable"
:hidden="!updateAvailable"
class="oa-tools__update-badge"
>
<el-tooltip :content="updateAvailable ? `发现新版本 ${updateVersion}` : '系统更新'" placement="bottom">
<el-button class="oa-tools__btn oa-update-entry" :icon="Setting" text @click="navigate('/appdev/update')">
系统更新
</el-button>
</el-tooltip>
</el-badge>
<el-dropdown trigger="click"> <el-dropdown trigger="click">
<span class="oa-avatar"> <span class="oa-avatar">
<el-avatar :size="30" class="oa-avatar__img">{{ userInitial }}</el-avatar> <el-avatar :size="30" class="oa-avatar__img">{{ userInitial }}</el-avatar>
@@ -327,6 +358,9 @@ function runGlobalSearch() {
<el-dropdown-item disabled>{{ currentUserName }}{{ currentDeptName ? ' · ' + currentDeptName : '' }}</el-dropdown-item> <el-dropdown-item disabled>{{ currentUserName }}{{ currentDeptName ? ' · ' + currentDeptName : '' }}</el-dropdown-item>
<el-dropdown-item divided @click="navigate('/')">个人空间</el-dropdown-item> <el-dropdown-item divided @click="navigate('/')">个人空间</el-dropdown-item>
<el-dropdown-item @click="navigate('/contacts')">通讯录</el-dropdown-item> <el-dropdown-item @click="navigate('/contacts')">通讯录</el-dropdown-item>
<el-dropdown-item v-if="isAdmin" @click="navigate('/appdev/update')">
{{ updateAvailable ? `系统更新 · ${updateVersion}` : '系统更新' }}
</el-dropdown-item>
<el-dropdown-item divided @click="handleLogout">退出登录</el-dropdown-item> <el-dropdown-item divided @click="handleLogout">退出登录</el-dropdown-item>
</el-dropdown-menu> </el-dropdown-menu>
</template> </template>
@@ -346,7 +380,7 @@ function runGlobalSearch() {
unique-opened unique-opened
@select="onMenuSelect" @select="onMenuSelect"
> >
<el-sub-menu v-for="module in oaModules" :key="module.id" :index="module.id"> <el-sub-menu v-for="module in visibleModules" :key="module.id" :index="module.id">
<template #title> <template #title>
<el-icon class="oa-menu__icon"><component :is="module.icon" /></el-icon> <el-icon class="oa-menu__icon"><component :is="module.icon" /></el-icon>
<span class="oa-menu__label">{{ module.label }}</span> <span class="oa-menu__label">{{ module.label }}</span>
@@ -457,8 +491,19 @@ function runGlobalSearch() {
</button> </button>
</div> </div>
<button
v-if="isAdmin"
type="button"
class="oa-mobilenav__update"
@click="goMobile('/appdev/update')"
>
<el-icon><Setting /></el-icon>
<span>系统更新</span>
<el-tag v-if="updateAvailable" type="warning" effect="plain" size="small">{{ updateVersion }}</el-tag>
</button>
<el-collapse class="oa-mobilenav__modules" accordion> <el-collapse class="oa-mobilenav__modules" accordion>
<el-collapse-item v-for="module in oaModules" :key="module.id" :name="module.id"> <el-collapse-item v-for="module in visibleModules" :key="module.id" :name="module.id">
<template #title> <template #title>
<el-icon class="oa-mobilenav__mod-icon"><component :is="module.icon" /></el-icon> <el-icon class="oa-mobilenav__mod-icon"><component :is="module.icon" /></el-icon>
<span class="oa-mobilenav__mod-label">{{ module.label }}</span> <span class="oa-mobilenav__mod-label">{{ module.label }}</span>
@@ -569,6 +614,16 @@ function runGlobalSearch() {
right: 10px; right: 10px;
} }
.oa-tools__update-badge :deep(.el-badge__content.is-dot) {
top: 6px;
right: 8px;
}
.oa-update-entry {
padding-inline: var(--erp-space-2);
font-size: var(--erp-font-size-sm);
}
.oa-search__hint { .oa-search__hint {
margin: var(--erp-space-2) 0 0; margin: var(--erp-space-2) 0 0;
color: var(--erp-color-text-subtle); color: var(--erp-color-text-subtle);
@@ -977,6 +1032,27 @@ function runGlobalSearch() {
border-top: 0; border-top: 0;
} }
.oa-mobilenav__update {
display: flex;
align-items: center;
gap: var(--erp-space-2);
width: 100%;
min-height: 44px;
padding: var(--erp-space-2) var(--erp-space-3);
margin: var(--erp-space-3) 0;
color: var(--erp-color-text);
font-size: var(--erp-font-size-sm);
font-weight: 650;
text-align: left;
background: var(--erp-color-surface-muted);
border: 1px solid var(--erp-color-border-soft);
border-radius: var(--erp-radius-sm);
}
.oa-mobilenav__update span {
flex: 1;
}
.oa-mobilenav__mod-icon { .oa-mobilenav__mod-icon {
margin-right: var(--erp-space-2); margin-right: var(--erp-space-2);
color: var(--erp-color-primary); color: var(--erp-color-primary);
@@ -1036,6 +1112,14 @@ function runGlobalSearch() {
.oa-tools { .oa-tools {
gap: 0; gap: 0;
} }
.oa-update-entry {
width: 36px;
padding: 0;
font-size: 0;
}
.oa-update-entry :deep(.el-icon) {
font-size: var(--erp-font-size-lg);
}
.oa-avatar { .oa-avatar {
margin-left: var(--erp-space-1); margin-left: var(--erp-space-1);
} }
@@ -11,6 +11,7 @@ export interface OaSession {
deptId: number | null deptId: number | null
title: string | null title: string | null
email: string | null email: string | null
roles: string[]
} }
/** POST /auth/login -> stores the token and returns the session. */ /** POST /auth/login -> stores the token and returns the session. */
@@ -189,6 +189,8 @@ export const oaApi = {
// 统一预警(全平台期限/异常聚合) // 统一预警(全平台期限/异常聚合)
listAlerts: alertsApi.listAlerts, listAlerts: alertsApi.listAlerts,
// 系统更新 // 系统更新
getSystemUpdateConfig: updateApi.getSystemUpdateConfig,
saveSystemUpdateConfig: updateApi.saveSystemUpdateConfig,
getSystemUpdateStatus: updateApi.getSystemUpdateStatus, getSystemUpdateStatus: updateApi.getSystemUpdateStatus,
checkSystemUpdate: updateApi.checkSystemUpdate, checkSystemUpdate: updateApi.checkSystemUpdate,
installSystemUpdate: updateApi.installSystemUpdate installSystemUpdate: updateApi.installSystemUpdate
@@ -216,7 +218,9 @@ export type {
ReportRunRow, ReportRunResult ReportRunRow, ReportRunResult
} from './reportdefs' } from './reportdefs'
export type { Alert } from './alerts' export type { Alert } from './alerts'
export type { SystemUpdateStatus, UpdatePhase, ReleaseAsset } from './update' export type {
SystemUpdateStatus, SystemUpdateConfig, SystemUpdateConfigInput, UpdatePhase, ReleaseAsset
} from './update'
export type { export type {
CompanySubject, Contract, Supplier, Customer, BankAccount, Seal, Invoice, ContractMilestone CompanySubject, Contract, Supplier, Customer, BankAccount, Seal, Invoice, ContractMilestone
} from './masterdata' } from './masterdata'
@@ -36,6 +36,33 @@ export interface SystemUpdateStatus {
error: string | null error: string | null
} }
export interface SystemUpdateConfig {
enabled: boolean
giteaBaseUrl: string
repository: string
channel: 'stable' | 'preview'
tokenConfigured: boolean
allowInsecureHttp: boolean
}
export interface SystemUpdateConfigInput {
enabled: boolean
giteaBaseUrl: string
repository: string
channel: 'stable' | 'preview'
token: string
clearToken: boolean
allowInsecureHttp: boolean
}
export function getSystemUpdateConfig() {
return http.get<SystemUpdateConfig>('/system-update/config')
}
export function saveSystemUpdateConfig(input: SystemUpdateConfigInput) {
return http.put<SystemUpdateConfig>('/system-update/config', input)
}
export function getSystemUpdateStatus() { export function getSystemUpdateStatus() {
return http.get<SystemUpdateStatus>('/system-update/status') return http.get<SystemUpdateStatus>('/system-update/status')
} }
@@ -1,15 +1,49 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Download, Refresh, Warning } from '@element-plus/icons-vue' import {
Check,
Connection,
Download,
Refresh,
Setting,
Warning
} from '@element-plus/icons-vue'
import ErpPageHeader from '../../../components/erp/ErpPageHeader.vue' import ErpPageHeader from '../../../components/erp/ErpPageHeader.vue'
import { oaApi, OaApiError, type SystemUpdateStatus, type UpdatePhase } from '../../api' import {
oaApi,
OaApiError,
type SystemUpdateConfig,
type SystemUpdateConfigInput,
type SystemUpdateStatus,
type UpdatePhase
} from '../../api'
import { useSession } from '../../session'
const router = useRouter()
const session = useSession()
const isAdmin = computed(() => session.user.value?.roles?.includes('ADMIN') === true)
const status = ref<SystemUpdateStatus | null>(null) const status = ref<SystemUpdateStatus | null>(null)
const config = ref<SystemUpdateConfig | null>(null)
const sourceForm = reactive<SystemUpdateConfigInput>({
enabled: true,
giteaBaseUrl: '',
repository: 'awaioi/ERP',
channel: 'stable',
token: '',
clearToken: false,
allowInsecureHttp: false
})
const loading = ref(false) const loading = ref(false)
const checking = ref(false) const checking = ref(false)
const saving = ref(false)
const installing = ref(false) const installing = ref(false)
const reconnecting = ref(false)
const pollFailures = ref(0)
let pollTimer: number | undefined let pollTimer: number | undefined
let pollRequestRunning = false
const activePhases = new Set<UpdatePhase>([ const activePhases = new Set<UpdatePhase>([
'STARTING', 'DOWNLOADING', 'VERIFYING', 'INSTALLING', 'RESTARTING', 'ROLLING_BACK' 'STARTING', 'DOWNLOADING', 'VERIFYING', 'INSTALLING', 'RESTARTING', 'ROLLING_BACK'
@@ -27,27 +61,36 @@ const phaseLabels: Record<UpdatePhase, string> = {
RESTARTING: '正在重启', RESTARTING: '正在重启',
SUCCEEDED: '更新完成', SUCCEEDED: '更新完成',
ROLLING_BACK: '正在回滚', ROLLING_BACK: '正在回滚',
ROLLED_BACK: '已回滚', ROLLED_BACK: '已自动回滚',
FAILED: '更新失败' FAILED: '更新失败'
} }
const busy = computed(() => !!status.value && activePhases.has(status.value.phase)) const busy = computed(() => !!status.value && activePhases.has(status.value.phase))
const configLocked = computed(() => busy.value || reconnecting.value)
const canCheck = computed(() => Boolean(
sourceForm.enabled && sourceForm.giteaBaseUrl && !configLocked.value
))
const canInstall = computed(() => Boolean( const canInstall = computed(() => Boolean(
status.value?.configured && status.value.updateAvailable && status.value.latestVersion && !busy.value status.value?.configured && status.value.updateAvailable && status.value.latestVersion && !busy.value
)) ))
const phaseTone = computed(() => { const phaseTone = computed<'success' | 'warning' | 'danger' | 'info'>(() => {
const phase = status.value?.phase const phase = status.value?.phase
if (phase === 'FAILED' || phase === 'ROLLED_BACK') return 'danger' if (phase === 'FAILED' || phase === 'ROLLED_BACK') return 'danger'
if (phase === 'AVAILABLE') return 'warning' if (phase === 'AVAILABLE') return 'warning'
if (phase === 'SUCCEEDED' || phase === 'UP_TO_DATE') return 'success' if (phase === 'SUCCEEDED' || phase === 'UP_TO_DATE') return 'success'
return 'info' return 'info'
}) })
const progressStatus = computed<'success' | 'exception' | undefined>(() => {
if (status.value?.phase === 'SUCCEEDED') return 'success'
if (status.value?.phase === 'FAILED' || status.value?.phase === 'ROLLED_BACK') return 'exception'
return undefined
})
function apiMessage(error: unknown, fallback: string) { function apiMessage(error: unknown, fallback: string) {
return error instanceof OaApiError ? error.message : fallback return error instanceof OaApiError ? error.message : fallback
} }
function formatDate(value: string | null) { function formatDate(value: string | null | undefined) {
if (!value) return '-' if (!value) return '-'
const date = new Date(value) const date = new Date(value)
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false }) return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false })
@@ -59,16 +102,103 @@ function formatBytes(size: number) {
return `${(size / 1024 / 1024).toFixed(1)} MB` return `${(size / 1024 / 1024).toFixed(1)} MB`
} }
function verificationLabel(name: string) {
if (name === 'SHA256SUMS.sig') return 'Ed25519 签名'
if (name === 'SHA256SUMS') return 'SHA-256 清单'
if (name.endsWith('.tar.gz') || name.endsWith('.jar')) return '签名清单校验'
return '-'
}
function applyConfig(value: SystemUpdateConfig) {
config.value = value
sourceForm.enabled = value.enabled
sourceForm.giteaBaseUrl = value.giteaBaseUrl
sourceForm.repository = value.repository
sourceForm.channel = value.channel
sourceForm.token = ''
sourceForm.clearToken = false
sourceForm.allowInsecureHttp = value.allowInsecureHttp
}
async function loadConfig(silent = false) {
try {
applyConfig(await oaApi.getSystemUpdateConfig())
} catch (error) {
if (!silent) ElMessage.error(apiMessage(error, '更新源加载失败'))
}
}
async function loadStatus(silent = false) { async function loadStatus(silent = false) {
if (!silent) loading.value = true
try { try {
status.value = await oaApi.getSystemUpdateStatus() status.value = await oaApi.getSystemUpdateStatus()
reconnecting.value = false
pollFailures.value = 0
if (busy.value) startPolling() if (busy.value) startPolling()
else stopPolling() else stopPolling()
} catch (error) { } catch (error) {
if (busy.value || status.value?.phase === 'RESTARTING' || reconnecting.value) {
reconnecting.value = true
pollFailures.value += 1
startPolling()
return
}
if (!silent) ElMessage.error(apiMessage(error, '更新状态加载失败')) if (!silent) ElMessage.error(apiMessage(error, '更新状态加载失败'))
}
}
async function loadPage() {
loading.value = true
try {
await Promise.all([loadConfig(), loadStatus()])
} finally { } finally {
if (!silent) loading.value = false loading.value = false
}
}
function validateSource() {
const baseUrl = sourceForm.giteaBaseUrl.trim()
const repository = sourceForm.repository.trim()
if (sourceForm.enabled && !baseUrl) {
ElMessage.warning('请填写 Gitea 地址')
return false
}
if (baseUrl && !/^https?:\/\//i.test(baseUrl)) {
ElMessage.warning('Gitea 地址必须以 https:// 或 http:// 开头')
return false
}
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}\/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(repository)) {
ElMessage.warning('仓库格式必须为 owner/repository')
return false
}
if (baseUrl.startsWith('http://') && !sourceForm.allowInsecureHttp) {
ElMessage.warning('HTTP 更新源必须显式开启允许 HTTP')
return false
}
return true
}
async function saveSource(checkAfterSave: boolean) {
if (configLocked.value) {
ElMessage.warning('更新任务执行期间不能修改更新源')
return
}
if (!validateSource()) return
saving.value = true
try {
const saved = await oaApi.saveSystemUpdateConfig({
...sourceForm,
giteaBaseUrl: sourceForm.giteaBaseUrl.trim(),
repository: sourceForm.repository.trim(),
token: sourceForm.token.trim()
})
applyConfig(saved)
ElMessage.success('更新源已保存')
await loadStatus(true)
if (checkAfterSave && saved.enabled) await checkUpdate()
} catch (error) {
ElMessage.error(apiMessage(error, '更新源保存失败'))
} finally {
saving.value = false
} }
} }
@@ -90,8 +220,8 @@ async function installUpdate() {
if (!version) return if (!version) return
try { try {
await ElMessageBox.confirm( await ElMessageBox.confirm(
`确认安装 ${version} 并重启服务?`, `确认安装 ${version}?服务将自动重启,健康检查失败会切回上一版本。`,
'安装更新', '安装正式更新',
{ type: 'warning', confirmButtonText: '安装并重启', cancelButtonText: '取消' } { type: 'warning', confirmButtonText: '安装并重启', cancelButtonText: '取消' }
) )
} catch { } catch {
@@ -100,6 +230,8 @@ async function installUpdate() {
installing.value = true installing.value = true
try { try {
status.value = await oaApi.installSystemUpdate(version) status.value = await oaApi.installSystemUpdate(version)
reconnecting.value = false
pollFailures.value = 0
ElMessage.success('更新任务已启动') ElMessage.success('更新任务已启动')
startPolling() startPolling()
} catch (error) { } catch (error) {
@@ -111,7 +243,15 @@ async function installUpdate() {
function startPolling() { function startPolling() {
if (pollTimer !== undefined) return if (pollTimer !== undefined) return
pollTimer = window.setInterval(() => loadStatus(true), 3000) pollTimer = window.setInterval(async () => {
if (pollRequestRunning) return
pollRequestRunning = true
try {
await loadStatus(true)
} finally {
pollRequestRunning = false
}
}, 2500)
} }
function stopPolling() { function stopPolling() {
@@ -120,19 +260,35 @@ function stopPolling() {
pollTimer = undefined pollTimer = undefined
} }
onMounted(() => loadStatus()) async function initializePage() {
if (!isAdmin.value) {
ElMessage.error('系统更新仅限管理员访问')
await router.replace('/')
return
}
await loadPage()
}
onMounted(initializePage)
onBeforeUnmount(stopPolling) onBeforeUnmount(stopPolling)
</script> </script>
<template> <template>
<div class="update-page" v-loading="loading"> <div v-if="isAdmin" class="update-page" v-loading="loading">
<ErpPageHeader <ErpPageHeader
title="系统更新" title="系统更新"
:crumbs="['应用定制平台', '系统更新']" :crumbs="['应用定制平台', '系统更新']"
description="正式版本" :description="`当前版本 ${status?.currentVersion || '-'}`"
> >
<template #actions> <template #actions>
<el-button :icon="Refresh" :loading="checking" @click="checkUpdate">检查更新</el-button> <el-button
:icon="Refresh"
:loading="checking"
:disabled="!canCheck || saving"
@click="checkUpdate"
>
检查更新
</el-button>
<el-button <el-button
type="primary" type="primary"
:icon="Download" :icon="Download"
@@ -146,14 +302,33 @@ onBeforeUnmount(stopPolling)
</ErpPageHeader> </ErpPageHeader>
<el-alert <el-alert
v-if="status && !status.configured" v-if="reconnecting"
type="warning" type="info"
:closable="false" :closable="false"
show-icon show-icon
title="在线更新尚未配置" title="正式服务正在重启,页面会自动重新连接"
:description="`已重试 ${pollFailures} 次`"
class="update-alert"
/>
<el-alert
v-else-if="status?.phase === 'SUCCEEDED'"
type="success"
:closable="false"
show-icon
title="新版本已启动并通过健康检查"
class="update-alert"
/>
<el-alert
v-else-if="status?.phase === 'ROLLED_BACK'"
type="error"
:closable="false"
show-icon
title="新版本健康检查失败,系统已自动切回上一版本"
:description="status.error || undefined"
class="update-alert"
/> />
<section class="update-summary"> <section class="update-summary" aria-label="版本状态">
<div class="version-block"> <div class="version-block">
<span class="field-label">当前版本</span> <span class="field-label">当前版本</span>
<strong>{{ status?.currentVersion || '-' }}</strong> <strong>{{ status?.currentVersion || '-' }}</strong>
@@ -170,42 +345,113 @@ onBeforeUnmount(stopPolling)
</div> </div>
<div class="version-block"> <div class="version-block">
<span class="field-label">检查时间</span> <span class="field-label">检查时间</span>
<span>{{ formatDate(status?.checkedAt || null) }}</span> <span>{{ formatDate(status?.checkedAt) }}</span>
</div> </div>
</section> </section>
<section v-if="status && (busy || status.phase === 'FAILED' || status.phase === 'ROLLED_BACK')" class="update-progress"> <section class="source-section">
<div class="section-heading"> <div class="section-heading">
<h3>{{ status.message }}</h3> <div class="heading-title"><el-icon><Setting /></el-icon><h2>更新源设置</h2></div>
<el-tag v-if="config?.tokenConfigured" type="success" effect="plain" size="small">Token 已配置</el-tag>
<el-tag v-else type="info" effect="plain" size="small">公开仓库</el-tag>
</div>
<el-form :disabled="configLocked" label-position="top" class="source-form" @submit.prevent>
<el-form-item label="在线更新">
<el-switch v-model="sourceForm.enabled" active-text="启用" inactive-text="停用" />
</el-form-item>
<el-form-item label="Gitea 地址" class="source-form__wide">
<el-input v-model="sourceForm.giteaBaseUrl" placeholder="https://git.example.com" clearable />
</el-form-item>
<el-form-item label="仓库">
<el-input v-model="sourceForm.repository" placeholder="owner/repository" />
</el-form-item>
<el-form-item label="更新通道">
<el-select v-model="sourceForm.channel" style="width: 100%">
<el-option label="正式版" value="stable" />
<el-option label="预览版" value="preview" />
</el-select>
</el-form-item>
<el-form-item label="Gitea Token" class="source-form__wide">
<el-input
v-model="sourceForm.token"
type="password"
show-password
autocomplete="new-password"
:disabled="configLocked || sourceForm.clearToken"
:placeholder="config?.tokenConfigured ? '已配置,留空保持不变' : '公开仓库可留空'"
/>
</el-form-item>
<el-form-item label="HTTP 更新源">
<el-switch v-model="sourceForm.allowInsecureHttp" active-text="允许" inactive-text="禁止" />
</el-form-item>
<el-form-item v-if="config?.tokenConfigured" label="凭据操作">
<el-checkbox
v-model="sourceForm.clearToken"
:disabled="configLocked || Boolean(sourceForm.token)"
>
清除现有 Token
</el-checkbox>
</el-form-item>
</el-form>
<div class="source-actions">
<el-button :loading="saving" :disabled="configLocked || checking" @click="saveSource(false)">保存设置</el-button>
<el-button
type="primary"
:icon="Connection"
:loading="saving || checking"
:disabled="configLocked"
@click="saveSource(true)"
>
保存并检查
</el-button>
</div>
</section>
<section v-if="status && (busy || status.phase === 'FAILED' || status.phase === 'ROLLED_BACK' || status.phase === 'SUCCEEDED')" class="update-progress">
<div class="section-heading">
<div class="heading-title">
<el-icon v-if="status.phase === 'FAILED' || status.phase === 'ROLLED_BACK'"><Warning /></el-icon>
<el-icon v-else><Check /></el-icon>
<h2>执行结果</h2>
</div>
<span>{{ status.progress }}%</span> <span>{{ status.progress }}%</span>
</div> </div>
<el-progress <el-progress
:percentage="status.progress" :percentage="status.progress"
:status="status.phase === 'FAILED' || status.phase === 'ROLLED_BACK' ? 'exception' : undefined" :status="progressStatus"
:stroke-width="10" :stroke-width="10"
/> />
<p class="progress-message">{{ status.message }}</p>
<p v-if="status.error" class="error-line"><el-icon><Warning /></el-icon>{{ status.error }}</p> <p v-if="status.error" class="error-line"><el-icon><Warning /></el-icon>{{ status.error }}</p>
</section> </section>
<section class="release-section"> <section class="release-section">
<div class="section-heading"> <div class="section-heading">
<h3>版本信息</h3> <div class="heading-title"><h2>版本更新日志</h2></div>
<span>{{ formatDate(status?.publishedAt || null) }}</span> <span>发布日期 {{ formatDate(status?.publishedAt) }}</span>
</div> </div>
<div class="release-notes">{{ status?.releaseNotes || '暂无发布说明' }}</div> <div class="release-notes">{{ status?.releaseNotes || '暂无发布说明' }}</div>
</section> </section>
<section class="release-section"> <section class="release-section">
<div class="section-heading"><h3>发布文件</h3></div> <div class="section-heading"><div class="heading-title"><h2>发布文件与校验</h2></div></div>
<el-table :data="status?.assets || []" size="small" border empty-text="暂无发布文件"> <el-table :data="status?.assets || []" size="small" border empty-text="暂无发布文件">
<el-table-column prop="name" label="文件" min-width="280" /> <el-table-column prop="name" label="文件" min-width="280" />
<el-table-column label="大小" width="120"> <el-table-column label="大小" width="120">
<template #default="{ row }">{{ formatBytes(row.size) }}</template> <template #default="{ row }">{{ formatBytes(row.size) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="校验" width="140"> <el-table-column label="校验方式" min-width="160">
<template #default="{ row }"> <template #default="{ row }">
<el-tag v-if="row.name === 'SHA256SUMS.sig'" type="success" effect="plain" size="small">Ed25519</el-tag> <el-tag
<el-tag v-else-if="row.name === 'SHA256SUMS'" type="info" effect="plain" size="small">SHA-256</el-tag> v-if="verificationLabel(row.name) !== '-'"
:type="row.name === 'SHA256SUMS.sig' ? 'success' : 'info'"
effect="plain"
size="small"
>
{{ verificationLabel(row.name) }}
</el-tag>
<span v-else>-</span> <span v-else>-</span>
</template> </template>
</el-table-column> </el-table-column>
@@ -220,6 +466,10 @@ onBeforeUnmount(stopPolling)
margin: 0 auto; margin: 0 auto;
} }
.update-alert {
margin-bottom: var(--erp-space-4);
}
.update-summary { .update-summary {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -238,8 +488,11 @@ onBeforeUnmount(stopPolling)
} }
.version-block strong, .version-block strong,
.version-block > span:last-child { .version-block > span:last-child,
.version-block > .el-tag {
display: block; display: block;
width: fit-content;
max-width: 100%;
margin-top: var(--erp-space-2); margin-top: var(--erp-space-2);
overflow-wrap: anywhere; overflow-wrap: anywhere;
color: var(--erp-color-text); color: var(--erp-color-text);
@@ -251,40 +504,73 @@ onBeforeUnmount(stopPolling)
font-size: var(--erp-font-size-xs); font-size: var(--erp-font-size-xs);
} }
.source-section,
.update-progress, .update-progress,
.release-section { .release-section {
padding: var(--erp-space-5) 0; padding: var(--erp-space-5) 0;
border-bottom: 1px solid var(--erp-color-border-soft); border-bottom: 1px solid var(--erp-color-border-soft);
} }
.section-heading { .section-heading,
.heading-title,
.source-actions {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
gap: var(--erp-space-3);
margin-bottom: var(--erp-space-3);
} }
.section-heading h3 { .section-heading {
margin: 0; justify-content: space-between;
gap: var(--erp-space-3);
margin-bottom: var(--erp-space-4);
}
.heading-title {
gap: var(--erp-space-2);
min-width: 0;
color: var(--erp-color-text); color: var(--erp-color-text);
}
.heading-title h2 {
margin: 0;
font-size: var(--erp-font-size-base); font-size: var(--erp-font-size-base);
} }
.section-heading span { .section-heading > span {
color: var(--erp-color-text-subtle); color: var(--erp-color-text-subtle);
font-size: var(--erp-font-size-xs); font-size: var(--erp-font-size-xs);
} }
.source-form {
display: grid;
grid-template-columns: 140px minmax(260px, 2fr) minmax(200px, 1fr) 160px;
gap: 0 var(--erp-space-4);
}
.source-form__wide {
min-width: 0;
grid-column: span 2;
}
.source-actions {
justify-content: flex-end;
gap: var(--erp-space-2);
}
.release-notes { .release-notes {
min-height: 80px; min-height: 96px;
color: var(--erp-color-text-muted); color: var(--erp-color-text-muted);
font-size: var(--erp-font-size-sm); font-size: var(--erp-font-size-sm);
line-height: 1.7; line-height: 1.75;
white-space: pre-wrap; white-space: pre-wrap;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.progress-message {
margin: var(--erp-space-2) 0 0;
color: var(--erp-color-text-muted);
font-size: var(--erp-font-size-sm);
}
.error-line { .error-line {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -294,6 +580,16 @@ onBeforeUnmount(stopPolling)
font-size: var(--erp-font-size-sm); font-size: var(--erp-font-size-sm);
} }
@media (max-width: 1080px) {
.source-form {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.source-form__wide {
grid-column: span 1;
}
}
@media (max-width: 900px) { @media (max-width: 900px) {
.update-summary { .update-summary {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -309,7 +605,8 @@ onBeforeUnmount(stopPolling)
} }
@media (max-width: 560px) { @media (max-width: 560px) {
.update-summary { .update-summary,
.source-form {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -322,5 +619,20 @@ onBeforeUnmount(stopPolling)
.version-block:last-child { .version-block:last-child {
border-bottom: 0; border-bottom: 0;
} }
.section-heading {
align-items: flex-start;
flex-wrap: wrap;
}
.source-actions {
align-items: stretch;
flex-direction: column;
}
.source-actions .el-button {
width: 100%;
margin-left: 0;
}
} }
</style> </style>
Executable
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -Eeuo pipefail
TARGET=/root/kaidi-erp-reinstall-v031.sh
URL=http://38.76.196.225:10099/awaioi/ERP/raw/tag/recovery-v0.3.1-1/reinstall-centos9.sh
SHA256=0370fb17926f3b53d5ae849310fdc6cbbf019150415365a464b0972b8edf5d9a
curl -fsSL --connect-timeout 15 --max-time 300 "$URL" -o "$TARGET"
printf '%s %s\n' "$SHA256" "$TARGET" | sha256sum -c -
exec bash "$TARGET"
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env bash
set -Eeuo pipefail
GITEA_BASE_URL="http://38.76.196.225:10099"
REPOSITORY="awaioi/ERP"
VERSION="0.3.4"
TAG="v${VERSION}"
INSTALL_SHA256="89a3c45e76f500c9475cb596ea29e3518bfafdc59f36dd3c7b316ed3cdd0448c"
UNINSTALL_SHA256="98c56fed2fd4d01874e4ab5a1a4f3ec42ec3e29b315ffd87385a95488d587546"
PG_ROOT="${ERP_RECOVERY_PG_ROOT:-/www/server/pgsql}"
PG_DATA="${ERP_RECOVERY_PG_DATA:-/www/server/pgsql/data}"
PG_INIT_SCRIPT="${ERP_RECOVERY_PG_INIT_SCRIPT:-/etc/init.d/pgsql}"
TMP_DIR=""
say() { printf '[ERP Recovery] %s\n' "$*"; }
fail() { printf '[ERP Recovery] ERROR: %s\n' "$*" >&2; exit 1; }
cleanup() {
[[ -z "$TMP_DIR" || ! -d "$TMP_DIR" ]] || rm -rf -- "$TMP_DIR"
}
trap cleanup EXIT
postgres_port_open() {
timeout 2 bash -c 'exec 3<>/dev/tcp/127.0.0.1/5432' >/dev/null 2>&1
}
wait_for_postgres() {
local _
for _ in {1..60}; do
postgres_port_open && return 0
sleep 1
done
return 1
}
start_erp_postgres() {
if postgres_port_open; then
say 'PostgreSQL is already listening on 127.0.0.1:5432'
return 0
fi
[[ -f "$PG_DATA/PG_VERSION" ]] \
|| fail "PostgreSQL data directory is missing: $PG_DATA"
if [[ -x "$PG_INIT_SCRIPT" ]]; then
say "Starting PostgreSQL with $PG_INIT_SCRIPT"
if ! "$PG_INIT_SCRIPT" start; then
say 'The init script returned an error; checking whether PostgreSQL still started'
fi
else
local pg_ctl pg_user
pg_ctl="$(find "$PG_ROOT" -type f -name pg_ctl -print -quit 2>/dev/null || true)"
[[ -n "$pg_ctl" && -x "$pg_ctl" ]] || fail "pg_ctl was not found below $PG_ROOT"
pg_user="$(stat -c '%U' "$PG_DATA")" || fail 'Unable to determine the PostgreSQL owner'
id "$pg_user" >/dev/null 2>&1 || fail "PostgreSQL owner does not exist: $pg_user"
say "Starting PostgreSQL as $pg_user"
runuser -u "$pg_user" -- "$pg_ctl" \
-D "$PG_DATA" \
-l "$PG_DATA/startup.log" \
-w -t 60 start
fi
if ! wait_for_postgres; then
[[ ! -r "$PG_DATA/startup.log" ]] || tail -n 100 "$PG_DATA/startup.log" >&2
fail 'PostgreSQL did not listen on 127.0.0.1:5432 within 60 seconds'
fi
say 'PostgreSQL is ready on 127.0.0.1:5432'
}
download_verified_script() {
local name="$1" expected="$2" destination
destination="$TMP_DIR/$name"
curl -fsSL --connect-timeout 15 --max-time 300 \
"$GITEA_BASE_URL/$REPOSITORY/raw/tag/$TAG/$name" \
-o "$destination"
printf '%s %s\n' "$expected" "$destination" | sha256sum -c - >&2
printf '%s' "$destination"
}
main() {
[[ "$(id -u)" == "0" ]] || fail 'Run this recovery command as root'
for command in bash curl find runuser sha256sum stat timeout; do
command -v "$command" >/dev/null 2>&1 || fail "Missing command: $command"
done
start_erp_postgres
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/kaidi-erp-recovery.XXXXXX")"
local uninstaller installer
uninstaller="$(download_verified_script uninstall.sh "$UNINSTALL_SHA256")"
say 'Removing the pending installation and its PostgreSQL schema safely'
bash "$uninstaller" --purge-database --yes
installer="$(download_verified_script install.sh "$INSTALL_SHA256")"
say "Installing Kaidi ERP $VERSION"
bash "$installer" \
--gitea-url "$GITEA_BASE_URL" \
--repository "$REPOSITORY" \
--version "$VERSION" \
--allow-insecure
}
if [[ "${BASH_SOURCE[0]:-$0}" == "$0" ]]; then
main "$@"
fi
+164
View File
@@ -0,0 +1,164 @@
#!/usr/bin/env bash
set -Eeuo pipefail
GITEA_URL="${GITEA_URL:-http://38.76.196.225:10099/}"
GITEA_REPOSITORY="${GITEA_REPOSITORY:-awaioi/ERP}"
RUNNER_IMAGE="${RUNNER_IMAGE:-}"
RUNNER_CONTAINER="${RUNNER_CONTAINER:-gitea-runner}"
RUNNER_VOLUME="${RUNNER_VOLUME:-gitea-runner-data}"
RUNNER_LABELS="${RUNNER_LABELS:-ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest}"
log() {
printf '[Gitea Runner] %s\n' "$*"
}
fail() {
printf '[Gitea Runner] ERROR: %s\n' "$*" >&2
exit 1
}
command -v docker >/dev/null 2>&1 || fail "docker is not installed"
docker info >/dev/null 2>&1 || fail "docker is not running or the current user cannot access it"
find_gitea_container() {
local container_id image name
while read -r container_id image name; do
case "$image" in
gitea/gitea:* | gitea/gitea@* | */gitea/gitea:* | */gitea/gitea@* | docker.gitea.com/gitea:* | docker.gitea.com/gitea@*)
printf '%s\n' "$container_id"
return 0
;;
esac
done < <(docker ps --format '{{.ID}} {{.Image}} {{.Names}}')
while read -r container_id name; do
case "$name" in
*runner*) ;;
*gitea*)
printf '%s\n' "$container_id"
return 0
;;
esac
done < <(docker ps --format '{{.ID}} {{.Names}}')
return 1
}
pull_runner_image() {
local candidate
local -a candidates
if [[ -n "$RUNNER_IMAGE" ]]; then
candidates=("$RUNNER_IMAGE")
else
candidates=(
"docker.gitea.com/runner:3.0.2"
"docker.gitea.com/act_runner:3.0.2"
"gitea/runner:3.0.2"
)
fi
for candidate in "${candidates[@]}"; do
log "Pulling $candidate"
if docker pull "$candidate"; then
RUNNER_IMAGE="$candidate"
return 0
fi
log "Image source failed; trying the next source"
done
fail "all official Gitea Runner image sources failed"
}
GITEA_CONTAINER="$(find_gitea_container || true)"
[[ -n "$GITEA_CONTAINER" ]] || fail "no running Gitea container was found"
TMP_DIR="$(mktemp -d)"
TOKEN_FILE="$TMP_DIR/runner-token"
BOOTSTRAP_RUNNING=0
cleanup() {
if [[ "$BOOTSTRAP_RUNNING" == "1" ]]; then
docker rm -f "$RUNNER_CONTAINER" >/dev/null 2>&1 || true
fi
if [[ -f "$TOKEN_FILE" ]]; then
: >"$TOKEN_FILE"
fi
rm -rf -- "$TMP_DIR"
}
trap cleanup EXIT
log "Generating a repository-scoped registration token"
if ! docker exec -u git "$GITEA_CONTAINER" gitea actions generate-runner-token \
--scope "$GITEA_REPOSITORY" | tr -d '\r' | tail -n 1 >"$TOKEN_FILE"; then
fail "Gitea could not generate a runner token"
fi
[[ -s "$TOKEN_FILE" ]] || fail "Gitea returned an empty runner token"
chmod 600 "$TOKEN_FILE"
pull_runner_image
docker rm -f "$RUNNER_CONTAINER" >/dev/null 2>&1 || true
docker volume create "$RUNNER_VOLUME" >/dev/null
docker run --rm --entrypoint /bin/rm \
-v "$RUNNER_VOLUME:/data" \
"$RUNNER_IMAGE" -f /data/.runner
log "Registering the runner"
docker run -d \
--name "$RUNNER_CONTAINER" \
-v "$RUNNER_VOLUME:/data" \
-v "$TOKEN_FILE:/run/secrets/gitea_runner_token:ro,Z" \
-v /var/run/docker.sock:/var/run/docker.sock \
-e "GITEA_INSTANCE_URL=$GITEA_URL" \
-e GITEA_RUNNER_REGISTRATION_TOKEN_FILE=/run/secrets/gitea_runner_token \
-e "GITEA_RUNNER_NAME=erp-release-$(hostname -s)" \
-e "GITEA_RUNNER_LABELS=$RUNNER_LABELS" \
-e GITEA_MAX_REG_ATTEMPTS=24 \
"$RUNNER_IMAGE" >/dev/null
BOOTSTRAP_RUNNING=1
READY=0
for ((attempt = 1; attempt <= 60; attempt++)); do
if docker exec "$RUNNER_CONTAINER" test -s /data/.runner 2>/dev/null; then
READY=1
break
fi
if [[ "$(docker inspect -f '{{.State.Running}}' "$RUNNER_CONTAINER" 2>/dev/null || true)" != "true" ]]; then
break
fi
sleep 2
done
if [[ "$READY" != "1" ]]; then
docker logs --tail 100 "$RUNNER_CONTAINER" >&2 || true
fail "runner registration failed"
fi
docker logs --tail 30 "$RUNNER_CONTAINER" || true
docker rm -f "$RUNNER_CONTAINER" >/dev/null
BOOTSTRAP_RUNNING=0
: >"$TOKEN_FILE"
log "Starting the persistent runner without the registration token"
docker run -d \
--name "$RUNNER_CONTAINER" \
--restart unless-stopped \
-v "$RUNNER_VOLUME:/data" \
-v /var/run/docker.sock:/var/run/docker.sock \
-e "GITEA_INSTANCE_URL=$GITEA_URL" \
-e "GITEA_RUNNER_NAME=erp-release-$(hostname -s)" \
-e "GITEA_RUNNER_LABELS=$RUNNER_LABELS" \
"$RUNNER_IMAGE" >/dev/null
sleep 5
[[ "$(docker inspect -f '{{.State.Running}}' "$RUNNER_CONTAINER")" == "true" ]] || {
docker logs --tail 100 "$RUNNER_CONTAINER" >&2 || true
fail "runner container stopped after registration"
}
log "Runner is online; queued release jobs can now continue"
docker ps --filter "name=^/${RUNNER_CONTAINER}$" \
--format 'table {{.Names}}\t{{.Image}}\t{{.Status}}'
docker logs --tail 80 "$RUNNER_CONTAINER" || true
+260 -3
View File
@@ -56,6 +56,83 @@ test_erp_run_preserves_java_option_arguments() (
grep -Fqx -- "$tmp/current/app/kaidi-erp.jar" <<< "$output" grep -Fqx -- "$tmp/current/app/kaidi-erp.jar" <<< "$output"
) )
test_erp_run_finalizes_healthy_pending_install() (
local tmp
tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-handoff-success.XXXXXX")" || return 1
trap 'rm -rf "$tmp"' EXIT
mkdir -p "$tmp/current/app" "$tmp/run" "$tmp/state" "$tmp/installer" "$tmp/bin"
: > "$tmp/current/app/kaidi-erp.jar"
: > "$tmp/installer/kaidi-erp-installer.jar"
printf 'pending\n' > "$tmp/state/install.pending"
printf '%s\n' \
'#!/usr/bin/env bash' \
'if [[ "${1:-}" == "-version" ]]; then printf '\''openjdk version "17.0.12"\n'\'' >&2; exit 0; fi' \
'printf '\''formal application output\n'\''' \
'sleep 0.1' \
'exit 0' > "$tmp/java"
printf '%s\n' '#!/usr/bin/env bash' 'exit 0' > "$tmp/bin/curl"
chmod +x "$tmp/java" "$tmp/bin/curl"
{
printf 'ERP_JAVA_BIN=%q\n' "$tmp/java"
printf 'ERP_RUN_DIR=%q\n' "$tmp/run"
printf 'ERP_JAR_PATH=%q\n' "$tmp/current/app/kaidi-erp.jar"
printf 'ERP_INSTALLER_JAR=%q\n' "$tmp/installer/kaidi-erp-installer.jar"
printf 'ERP_INSTALL_PENDING_FILE=%q\n' "$tmp/state/install.pending"
printf 'ERP_INSTALL_LOCK_FILE=%q\n' "$tmp/state/install.lock"
printf 'ERP_INSTALL_LOG_FILE=%q\n' "$tmp/state/install-formal.log"
printf 'ERP_INSTALL_HEALTH_TIMEOUT_SECONDS=3\n'
printf 'ERP_UPDATE_HEALTH_POLL_SECONDS=1\n'
} > "$tmp/erp.env"
PATH="$tmp/bin:$PATH" ERP_INSTALL_ROOT="$tmp" ERP_CONFIG_FILE="$tmp/erp.env" \
"$PROJECT_ROOT/distribution/bin/erp-run" > "$tmp/run.log" 2>&1 || return 1
[[ -f "$tmp/state/install.lock" ]] || return 1
[[ ! -e "$tmp/state/install.pending" ]] || return 1
[[ ! -d "$tmp/installer" ]] || return 1
[[ ! -e "$tmp/run/app.pid" ]] || return 1
grep -Fq 'formal application output' "$tmp/state/install-formal.log"
)
test_erp_run_preserves_failed_pending_install() (
local tmp output status=0
tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-handoff-failure.XXXXXX")" || return 1
trap 'rm -rf "$tmp"' EXIT
mkdir -p "$tmp/current/app" "$tmp/run" "$tmp/state" "$tmp/installer" "$tmp/bin"
: > "$tmp/current/app/kaidi-erp.jar"
: > "$tmp/installer/kaidi-erp-installer.jar"
printf 'pending\n' > "$tmp/state/install.pending"
printf '%s\n' \
'#!/usr/bin/env bash' \
'if [[ "${1:-}" == "-version" ]]; then printf '\''openjdk version "17.0.12"\n'\'' >&2; exit 0; fi' \
'printf '\''formal application exploded\n'\'' >&2' \
'sleep 0.1' \
'exit 23' > "$tmp/java"
printf '%s\n' '#!/usr/bin/env bash' 'exit 1' > "$tmp/bin/curl"
chmod +x "$tmp/java" "$tmp/bin/curl"
{
printf 'ERP_JAVA_BIN=%q\n' "$tmp/java"
printf 'ERP_RUN_DIR=%q\n' "$tmp/run"
printf 'ERP_JAR_PATH=%q\n' "$tmp/current/app/kaidi-erp.jar"
printf 'ERP_INSTALLER_JAR=%q\n' "$tmp/installer/kaidi-erp-installer.jar"
printf 'ERP_INSTALL_PENDING_FILE=%q\n' "$tmp/state/install.pending"
printf 'ERP_INSTALL_LOCK_FILE=%q\n' "$tmp/state/install.lock"
printf 'ERP_INSTALL_LOG_FILE=%q\n' "$tmp/state/install-formal.log"
printf 'ERP_INSTALL_HEALTH_TIMEOUT_SECONDS=3\n'
printf 'ERP_UPDATE_HEALTH_POLL_SECONDS=1\n'
} > "$tmp/erp.env"
output="$(PATH="$tmp/bin:$PATH" ERP_INSTALL_ROOT="$tmp" ERP_CONFIG_FILE="$tmp/erp.env" \
"$PROJECT_ROOT/distribution/bin/erp-run" 2>&1)" || status=$?
[[ "$status" -ne 0 ]] || return 1
[[ -f "$tmp/state/install.pending" ]] || return 1
[[ ! -e "$tmp/state/install.lock" ]] || return 1
[[ -d "$tmp/installer" ]] || return 1
grep -Fq 'formal application exploded' "$tmp/state/install-formal.log" || return 1
[[ "$output" == *"Formal application diagnostics: $tmp/state/install-formal.log"* ]]
)
prepare_signed_archive() { prepare_signed_archive() {
local tmp="$1" unsafe="${2:-0}" local tmp="$1" unsafe="${2:-0}"
mkdir -p "$tmp/package/kaidi-erp-1.2.3/app" mkdir -p "$tmp/package/kaidi-erp-1.2.3/app"
@@ -125,6 +202,44 @@ test_stable_channel_rejects_prerelease_tag() (
! select_release "$tmp/release.json" '' "$tmp/selection" >/dev/null 2>&1 ! select_release "$tmp/release.json" '' "$tmp/selection" >/dev/null 2>&1
) )
test_update_helper_preserves_release_metadata() (
local tmp
tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-update-metadata.XXXXXX")" || return 1
trap 'rm -rf "$tmp"' EXIT
mkdir -p "$tmp/state"
printf 'OA_UPDATE_GITEA_BASE_URL=https://example.test\nOA_UPDATE_STATE_FILE=%q\n' \
"$tmp/state/update.json" > "$tmp/erp.env"
printf '%s\n' \
'{"phase":"AVAILABLE","releaseNotes":"Important fixes","publishedAt":"2026-08-04T10:00:00Z",' \
'"assets":[{"name":"SHA256SUMS","downloadUrl":"https://example.test/sums","size":64}]}' \
> "$tmp/state/update.json"
ERP_INSTALL_ROOT="$tmp" ERP_CONFIG_FILE="$tmp/erp.env" source "$PROJECT_ROOT/distribution/bin/erp-update"
trap 'rm -rf "$tmp"' EXIT
write_state DOWNLOADING 25 'downloading' 1.2.3
python3 - "$tmp/state/update.json" <<'PY'
import json, sys
with open(sys.argv[1], encoding="utf-8") as handle:
state = json.load(handle)
assert state["phase"] == "DOWNLOADING"
assert state["releaseNotes"] == "Important fixes"
assert state["publishedAt"] == "2026-08-04T10:00:00Z"
assert state["assets"][0]["name"] == "SHA256SUMS"
PY
)
test_installer_builds_public_setup_urls() (
source "$PROJECT_ROOT/install.sh"
local normalized setup ipv6
normalized="$(normalize_public_url 'https://erp.example.com:8443/setup?tenant=kaidi#start')" || return 1
setup="$(append_setup_token "$normalized" 'one-time-token')" || return 1
ipv6="$(ip_setup_base_url '2001:db8::8' 8091)" || return 1
[[ "$setup" == 'https://erp.example.com:8443/setup?tenant=kaidi&token=one-time-token#start' ]] || return 1
[[ "$ipv6" == 'http://[2001:db8::8]:8091/' ]]
)
prepare_update_release_archive() { prepare_update_release_archive() {
local tmp="$1" version="$2" local tmp="$1" version="$2"
local root="$tmp/package/kaidi-erp-$version" local root="$tmp/package/kaidi-erp-$version"
@@ -235,10 +350,11 @@ PY
NO_SERVICE=0 NO_SERVICE=0
PUBLIC_KEY="$(<"$tmp/public.pem")" PUBLIC_KEY="$(<"$tmp/public.pem")"
detect_platform() { PLATFORM=darwin; } detect_platform() { PLATFORM=darwin; ARCH=amd64; }
check_and_install_dependencies() { JAVA_BIN="$(command -v java)"; return 0; } check_and_install_dependencies() { JAVA_BIN="$(command -v java)"; return 0; }
start_service() { return 0; } start_service() { return 0; }
wait_for_installer() { return 0; } wait_for_installer() { return 0; }
detect_public_address() { printf '203.0.113.10'; }
main > "$tmp/install.log" 2>&1 || return 1 main > "$tmp/install.log" 2>&1 || return 1
@@ -246,12 +362,14 @@ PY
[[ -r "$INSTALL_ROOT/current/app/kaidi-erp.jar" ]] || return 1 [[ -r "$INSTALL_ROOT/current/app/kaidi-erp.jar" ]] || return 1
( (
source "$ERP_CONFIG_ROOT/erp.env" source "$ERP_CONFIG_ROOT/erp.env"
[[ "$SPRING_PROFILES_ACTIVE" == "postgres" ]] [[ -z "${SPRING_PROFILES_ACTIVE:-}" ]]
[[ "$OA_UPDATE_ENABLED" == "true" ]] [[ "$OA_UPDATE_ENABLED" == "true" ]]
[[ "$OA_UPDATE_GITEA_BASE_URL" == "http://127.0.0.1:$port" ]] [[ "$OA_UPDATE_GITEA_BASE_URL" == "http://127.0.0.1:$port" ]]
[[ "$ERP_SETUP_TOKEN" =~ ^[0-9a-f]{64}$ ]] [[ "$ERP_SETUP_TOKEN" =~ ^[0-9a-f]{64}$ ]]
[[ -z "${OA_DB_URL:-}" ]] [[ -z "${OA_DB_URL:-}" ]]
) ) || return 1
grep -Eq '^\[ERP Install\] Setup URL: http://203\.0\.113\.10:8091/\?token=[0-9a-f]{64}$' \
"$tmp/install.log"
) )
run_update_scenario() ( run_update_scenario() (
@@ -451,6 +569,7 @@ test_installer_requires_explicit_gitea_url() (
set -- set --
source "$root/install.sh" source "$root/install.sh"
GITEA_BASE_URL= GITEA_BASE_URL=
NO_SERVICE=1
main main
' _ "$PROJECT_ROOT" 2>&1)" || status=$? ' _ "$PROJECT_ROOT" 2>&1)" || status=$?
[[ "$status" -ne 0 && "$output" == *'Gitea URL is required'* ]] [[ "$status" -ne 0 && "$output" == *'Gitea URL is required'* ]]
@@ -469,6 +588,112 @@ test_linux_service_preflight_requires_systemd() (
NO_SERVICE=1 PATH="$tmp/bin" validate_service_manager NO_SERVICE=1 PATH="$tmp/bin" validate_service_manager
) )
test_systemd_unit_uses_compatible_protection() (
grep -Fqx 'ProtectSystem=full' <(sed -n '/^ cat > \/etc\/systemd\/system\/kaidi-erp.service/,/^ systemctl daemon-reload/p' "$PROJECT_ROOT/install.sh")
! grep -Fq 'ProtectSystem=strict' "$PROJECT_ROOT/install.sh"
)
test_systemd_unit_uses_unquoted_legacy_paths() (
local unit
unit="$(sed -n '/^ cat > \/etc\/systemd\/system\/kaidi-erp.service/,/^ systemctl daemon-reload/p' "$PROJECT_ROOT/install.sh")"
grep -Fqx 'WorkingDirectory=$INSTALL_ROOT' <<< "$unit"
grep -Fqx 'ExecStart=$INSTALL_ROOT/current/bin/erp-run' <<< "$unit"
grep -Fqx 'ReadWritePaths=$INSTALL_ROOT $CONFIG_ROOT $STATE_ROOT $LOG_ROOT' <<< "$unit"
! grep -Fq 'WorkingDirectory="$INSTALL_ROOT"' <<< "$unit"
)
test_uninstaller_purges_database_before_removing_files() (
local tmp
tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-uninstall-test.XXXXXX")" || return 1
trap 'rm -rf "$tmp"' EXIT
mkdir -p "$tmp/bin" "$tmp/install" "$tmp/config" "$tmp/state" "$tmp/logs"
: > "$tmp/unit.service"
{
printf 'ERP_PGHOST=127.0.0.1\n'
printf 'ERP_PGPORT=5432\n'
printf 'ERP_PGDATABASE=kaidi_test\n'
printf 'ERP_PGSSLMODE=disable\n'
printf 'OA_DB_USERNAME=kaidi_test\n'
printf 'OA_DB_PASSWORD='\''test-password'\''\n'
printf 'ERP_INSTALL_ROOT=%q\n' "$tmp/install"
printf 'ERP_INSTALL_LOCK_FILE=%q\n' "$tmp/state/install.lock"
} > "$tmp/config/erp.env"
printf '%s\n' \
'#!/usr/bin/env bash' \
'printf '\''%s\n'\'' "$*" >> "$MOCK_SYSTEMCTL_LOG"' \
'if [[ "${1:-}" == "is-active" ]]; then exit 3; fi' \
'exit 0' > "$tmp/bin/systemctl"
printf '%s\n' \
'#!/usr/bin/env bash' \
'printf '\''ARGS %s\n'\'' "$*" >> "$MOCK_PSQL_LOG"' \
'if [[ " $* " == *" -c "* ]]; then printf '\''t\n'\''; exit 0; fi' \
'cat >> "$MOCK_PSQL_LOG"' \
'exit 0' > "$tmp/bin/psql"
chmod +x "$tmp/bin/systemctl" "$tmp/bin/psql"
PATH="$tmp/bin:$PATH" \
MOCK_SYSTEMCTL_LOG="$tmp/systemctl.log" \
MOCK_PSQL_LOG="$tmp/psql.log" \
ERP_UNINSTALL_TEST_MODE=1 \
ERP_UNINSTALL_SYSTEMD_UNIT_FILE="$tmp/unit.service" \
"$PROJECT_ROOT/uninstall.sh" --purge-database --yes \
--config-file "$tmp/config/erp.env" \
--install-root "$tmp/install" \
--config-root "$tmp/config" \
--state-root "$tmp/state" \
--log-root "$tmp/logs" > "$tmp/uninstall.log" 2>&1 || return 1
[[ ! -e "$tmp/install" && ! -e "$tmp/config" && ! -e "$tmp/state" && ! -e "$tmp/logs" ]] || return 1
[[ ! -e "$tmp/unit.service" ]] || return 1
grep -Fq 'DROP SCHEMA IF EXISTS public CASCADE;' "$tmp/psql.log" || return 1
grep -Fq 'stop kaidi-erp.service' "$tmp/systemctl.log" || return 1
grep -Fq 'disable kaidi-erp.service' "$tmp/systemctl.log"
)
test_uninstaller_rejects_unsafe_paths() (
! (source "$PROJECT_ROOT/uninstall.sh"; validate_remove_path /etc) >/dev/null 2>&1 || return 1
! (source "$PROJECT_ROOT/uninstall.sh"; validate_remove_path /etc/ssh) >/dev/null 2>&1 || return 1
! (source "$PROJECT_ROOT/uninstall.sh"; validate_remove_path /opt/kaidi-erp/../../etc) >/dev/null 2>&1
)
test_uninstaller_aborts_when_service_remains_active() (
local tmp output status=0
tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-uninstall-active.XXXXXX")" || return 1
trap 'rm -rf "$tmp"' EXIT
mkdir -p "$tmp/bin" "$tmp/install" "$tmp/config" "$tmp/state" "$tmp/logs"
{
printf 'ERP_PGHOST=127.0.0.1\n'
printf 'ERP_PGPORT=5432\n'
printf 'ERP_PGDATABASE=kaidi_test\n'
printf 'ERP_PGSSLMODE=disable\n'
printf 'OA_DB_USERNAME=kaidi_test\n'
printf 'OA_DB_PASSWORD=test-password\n'
printf 'ERP_INSTALL_ROOT=%q\n' "$tmp/install"
printf 'ERP_INSTALL_LOCK_FILE=%q\n' "$tmp/state/install.lock"
} > "$tmp/config/erp.env"
printf '%s\n' \
'#!/usr/bin/env bash' \
'if [[ "${1:-}" == "stop" ]]; then exit 1; fi' \
'if [[ "${1:-}" == "show" ]]; then printf '\''loaded\n'\''; exit 0; fi' \
'if [[ "${1:-}" == "is-active" ]]; then exit 0; fi' \
'exit 0' > "$tmp/bin/systemctl"
printf '%s\n' '#!/usr/bin/env bash' 'touch "$MOCK_PSQL_CALLED"' 'exit 0' > "$tmp/bin/psql"
chmod +x "$tmp/bin/systemctl" "$tmp/bin/psql"
output="$(PATH="$tmp/bin:$PATH" MOCK_PSQL_CALLED="$tmp/psql.called" \
ERP_UNINSTALL_TEST_MODE=1 ERP_UNINSTALL_SYSTEMD_UNIT_FILE="$tmp/unit.service" \
"$PROJECT_ROOT/uninstall.sh" --purge-database --yes \
--config-file "$tmp/config/erp.env" \
--install-root "$tmp/install" \
--config-root "$tmp/config" \
--state-root "$tmp/state" \
--log-root "$tmp/logs" 2>&1)" || status=$?
[[ "$status" -ne 0 && "$output" == *'unable to stop kaidi-erp.service'* ]] || return 1
[[ ! -e "$tmp/psql.called" ]] || return 1
[[ -d "$tmp/install" && -d "$tmp/config" && -d "$tmp/state" && -d "$tmp/logs" ]]
)
test_no_service_install_disables_online_update() ( test_no_service_install_disables_online_update() (
local tmp local tmp
tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-no-service-test.XXXXXX")" || return 1 tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-no-service-test.XXXXXX")" || return 1
@@ -497,6 +722,10 @@ test_release_workflow_uses_scoped_job_token() (
grep -Fqx ' releases: write' "$workflow" grep -Fqx ' releases: write' "$workflow"
grep -Fq 'GITEA_BASE_URL: ${{ github.server_url }}' "$workflow" grep -Fq 'GITEA_BASE_URL: ${{ github.server_url }}' "$workflow"
grep -Fq 'GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}' "$workflow" grep -Fq 'GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}' "$workflow"
grep -Fq 'uses: actions/setup-java@v4' "$workflow"
grep -Fq 'java-version: "17"' "$workflow"
grep -Fq "git log --no-merges --format='- %s (\`%h\`)'" "$workflow"
grep -Fq 'release-notes.md' "$workflow"
! grep -Fq 'RELEASE_GITEA_TOKEN' "$workflow" ! grep -Fq 'RELEASE_GITEA_TOKEN' "$workflow"
! grep -Fq 'vars.GITEA_BASE_URL' "$workflow" ! grep -Fq 'vars.GITEA_BASE_URL' "$workflow"
) )
@@ -509,10 +738,31 @@ test_installer_runs_when_piped_to_bash() (
[[ "$output" != *'BASH_SOURCE'* ]] [[ "$output" != *'BASH_SOURCE'* ]]
) )
test_installer_shows_live_health_progress() (
local page="$PROJECT_ROOT/oa-backend/src/installer/resources/static/index.html"
grep -Fq 'role="progressbar"' "$page"
grep -Fq 'id="install-log"' "$page"
grep -Fq 'Math.min(95, estimated)' "$page"
grep -Fq '健康检查第 ${attempt} 次' "$page"
grep -Fq '本页已等待 ${formatElapsed' "$page"
grep -Fq 'installLocked' "$page"
)
test_app_honors_reverse_proxy_headers() (
grep -Fqx ' forward-headers-strategy: framework' \
"$PROJECT_ROOT/oa-backend/src/main/resources/application.yml"
grep -Fqx ' forward-headers-strategy: framework' \
"$PROJECT_ROOT/oa-backend/src/installer/resources/application.yml"
)
run_test 'erp-run preserves Java option arguments' test_erp_run_preserves_java_option_arguments run_test 'erp-run preserves Java option arguments' test_erp_run_preserves_java_option_arguments
run_test 'erp-run finalizes a healthy pending installation' test_erp_run_finalizes_healthy_pending_install
run_test 'erp-run preserves a failed pending installation' test_erp_run_preserves_failed_pending_install
run_test 'installer accepts a correctly signed archive' test_installer_verifies_signed_safe_archive run_test 'installer accepts a correctly signed archive' test_installer_verifies_signed_safe_archive
run_test 'installer rejects symlinks in release archives' test_installer_rejects_archive_symlinks run_test 'installer rejects symlinks in release archives' test_installer_rejects_archive_symlinks
run_test 'stable update channel rejects prerelease tags' test_stable_channel_rejects_prerelease_tag run_test 'stable update channel rejects prerelease tags' test_stable_channel_rejects_prerelease_tag
run_test 'update helper preserves release metadata' test_update_helper_preserves_release_metadata
run_test 'installer builds public setup URLs safely' test_installer_builds_public_setup_urls
run_test 'installer downloads and installs a signed release end to end' test_installer_downloads_and_installs_signed_release run_test 'installer downloads and installs a signed release end to end' test_installer_downloads_and_installs_signed_release
run_test 'update helper installs a healthy release end to end' test_update_helper_installs_healthy_release run_test 'update helper installs a healthy release end to end' test_update_helper_installs_healthy_release
run_test 'update helper rolls back an unhealthy release end to end' test_update_helper_rolls_back_unhealthy_release run_test 'update helper rolls back an unhealthy release end to end' test_update_helper_rolls_back_unhealthy_release
@@ -520,9 +770,16 @@ run_test 'update helper rejects a concurrent process' test_update_lock_rejects_c
run_test 'installer moves database setup to the web wizard' test_installer_moves_database_setup_to_web_wizard run_test 'installer moves database setup to the web wizard' test_installer_moves_database_setup_to_web_wizard
run_test 'installer requires an explicit Gitea URL' test_installer_requires_explicit_gitea_url run_test 'installer requires an explicit Gitea URL' test_installer_requires_explicit_gitea_url
run_test 'Linux production install requires systemd' test_linux_service_preflight_requires_systemd run_test 'Linux production install requires systemd' test_linux_service_preflight_requires_systemd
run_test 'systemd unit uses compatible protection' test_systemd_unit_uses_compatible_protection
run_test 'systemd unit uses unquoted legacy paths' test_systemd_unit_uses_unquoted_legacy_paths
run_test 'uninstaller purges database before removing files' test_uninstaller_purges_database_before_removing_files
run_test 'uninstaller rejects unsafe paths' test_uninstaller_rejects_unsafe_paths
run_test 'uninstaller aborts while the service remains active' test_uninstaller_aborts_when_service_remains_active
run_test 'no-service install disables online update' test_no_service_install_disables_online_update run_test 'no-service install disables online update' test_no_service_install_disables_online_update
run_test 'release workflow uses the scoped Gitea job token' test_release_workflow_uses_scoped_job_token run_test 'release workflow uses the scoped Gitea job token' test_release_workflow_uses_scoped_job_token
run_test 'installer starts correctly when piped to bash' test_installer_runs_when_piped_to_bash run_test 'installer starts correctly when piped to bash' test_installer_runs_when_piped_to_bash
run_test 'installer shows live formal-service health progress' test_installer_shows_live_health_progress
run_test 'application honors standard reverse-proxy headers' test_app_honors_reverse_proxy_headers
printf 'RESULT pass=%s fail=%s\n' "$PASS" "$FAIL" printf 'RESULT pass=%s fail=%s\n' "$PASS" "$FAIL"
[[ "$FAIL" -eq 0 ]] [[ "$FAIL" -eq 0 ]]
Executable
+242
View File
@@ -0,0 +1,242 @@
#!/usr/bin/env bash
set -euo pipefail
CONFIG_FILE="${ERP_CONFIG_FILE:-/etc/kaidi-erp/erp.env}"
INSTALL_ROOT="${ERP_INSTALL_ROOT:-/opt/kaidi-erp}"
CONFIG_ROOT="${ERP_CONFIG_ROOT:-/etc/kaidi-erp}"
STATE_ROOT="${ERP_STATE_ROOT:-/var/lib/kaidi-erp}"
LOG_ROOT="${ERP_LOG_ROOT:-/var/log/kaidi-erp}"
PURGE_DATABASE=0
ASSUME_YES=0
say() { printf '[ERP Uninstall] %s\n' "$*"; }
fail() { printf '[ERP Uninstall] ERROR: %s\n' "$*" >&2; exit 1; }
usage() {
cat <<'EOF'
Usage: uninstall.sh [options]
--purge-database Drop and recreate the public schema using the configured ERP account
--yes Confirm destructive removal without an interactive prompt
--config-file PATH Installer-generated erp.env file
--install-root PATH Installation root (default: /opt/kaidi-erp)
--config-root PATH Configuration root (default: /etc/kaidi-erp)
--state-root PATH State root (default: /var/lib/kaidi-erp)
--log-root PATH Log root (default: /var/log/kaidi-erp)
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--purge-database) PURGE_DATABASE=1; shift ;;
--yes) ASSUME_YES=1; shift ;;
--config-file) [[ $# -ge 2 ]] || fail '--config-file requires a value'; CONFIG_FILE="$2"; shift 2 ;;
--install-root) [[ $# -ge 2 ]] || fail '--install-root requires a value'; INSTALL_ROOT="$2"; shift 2 ;;
--config-root) [[ $# -ge 2 ]] || fail '--config-root requires a value'; CONFIG_ROOT="$2"; shift 2 ;;
--state-root) [[ $# -ge 2 ]] || fail '--state-root requires a value'; STATE_ROOT="$2"; shift 2 ;;
--log-root) [[ $# -ge 2 ]] || fail '--log-root requires a value'; LOG_ROOT="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) fail "unknown option: $1" ;;
esac
done
require_root() {
if [[ "${ERP_UNINSTALL_TEST_MODE:-0}" != "1" && "$(id -u)" != "0" ]]; then
fail 'Linux uninstall requires root'
fi
}
require_confirmation() {
[[ "$PURGE_DATABASE" == "1" ]] || fail '--purge-database is required for a clean reinstall'
[[ "$ASSUME_YES" == "1" ]] || fail '--yes is required to confirm database and file removal'
}
validate_remove_path() {
local path="$1"
[[ "$path" == /* && "$path" != "/" && ${#path} -ge 8 ]] \
|| fail "refusing unsafe removal path: $path"
[[ "$path" =~ ^/[A-Za-z0-9._/@:+-]+$ ]] \
|| fail "refusing removal path with unsafe characters: $path"
local resolved
resolved="$(python3 - "$path" <<'PY'
import os
import sys
print(os.path.realpath(sys.argv[1]), end="")
PY
)" || fail "unable to resolve removal path: $path"
if [[ "${ERP_UNINSTALL_TEST_MODE:-0}" != "1" && "$resolved" != "$path" ]]; then
fail "removal path must be canonical and cannot traverse links: $path"
fi
case "$resolved" in
/bin|/boot|/dev|/etc|/home|/lib|/lib64|/opt|/proc|/root|/run|/sbin|/srv|/sys|/tmp|/usr|/var)
fail "refusing unsafe removal path: $path"
;;
esac
if [[ "${ERP_UNINSTALL_TEST_MODE:-0}" != "1" ]]; then
case "$resolved" in
/etc/kaidi-erp|/var/lib/kaidi-erp|/var/log/kaidi-erp) ;;
/bin/*|/boot/*|/dev/*|/etc/*|/lib/*|/lib64/*|/proc/*|/root/*|/run/*|/sbin/*|/sys/*|/tmp/*|/usr/*|/var/*)
fail "refusing removal below a protected system directory: $path"
;;
esac
fi
printf '%s' "$resolved"
}
prepare_paths() {
command -v python3 >/dev/null 2>&1 || fail 'python3 is required'
INSTALL_ROOT="$(validate_remove_path "$INSTALL_ROOT")"
CONFIG_ROOT="$(validate_remove_path "$CONFIG_ROOT")"
STATE_ROOT="$(validate_remove_path "$STATE_ROOT")"
LOG_ROOT="$(validate_remove_path "$LOG_ROOT")"
local config_resolved
config_resolved="$(python3 - "$CONFIG_FILE" <<'PY'
import os
import sys
print(os.path.realpath(sys.argv[1]), end="")
PY
)" || fail "unable to resolve configuration path: $CONFIG_FILE"
[[ "$config_resolved" == "$CONFIG_ROOT/erp.env" ]] \
|| fail "configuration file must be $CONFIG_ROOT/erp.env"
CONFIG_FILE="$config_resolved"
}
read_config_value() {
local key="$1"
python3 - "$CONFIG_FILE" "$key" <<'PY'
import re
import shlex
import sys
path, wanted = sys.argv[1:]
pattern = re.compile(r"[A-Z][A-Z0-9_]*")
seen = set()
matches = []
with open(path, encoding="utf-8") as handle:
for raw in handle:
line = raw.rstrip("\r\n")
if not line or line.startswith("#") or "=" not in line:
continue
key, encoded = line.split("=", 1)
if not pattern.fullmatch(key):
raise SystemExit("invalid configuration key")
if key in seen:
raise SystemExit(f"duplicate configuration key: {key}")
seen.add(key)
if key != wanted:
continue
values = shlex.split(encoded, posix=True)
if len(values) != 1:
raise SystemExit(f"invalid value for {wanted}")
matches.append(values[0])
if len(matches) != 1:
raise SystemExit(f"missing configuration value: {wanted}")
print(matches[0], end="")
PY
}
verify_installation_identity() {
[[ -f "$CONFIG_FILE" && ! -L "$CONFIG_FILE" ]] \
|| fail "configuration file is missing or unsafe: $CONFIG_FILE"
local configured_root configured_lock configured_state
configured_root="$(read_config_value ERP_INSTALL_ROOT)" \
|| fail 'unable to verify the configured installation root'
configured_lock="$(read_config_value ERP_INSTALL_LOCK_FILE)" \
|| fail 'unable to verify the configured installation state'
configured_root="$(validate_remove_path "$configured_root")"
configured_state="$(validate_remove_path "$(dirname "$configured_lock")")"
[[ "$configured_root" == "$INSTALL_ROOT" ]] \
|| fail 'requested installation root does not match erp.env'
[[ "$configured_state" == "$STATE_ROOT" ]] \
|| fail 'requested state root does not match erp.env'
}
purge_database() {
command -v psql >/dev/null 2>&1 || fail 'PostgreSQL client psql is required'
local host port database sslmode username password owner_check
host="$(read_config_value ERP_PGHOST)"
port="$(read_config_value ERP_PGPORT)"
database="$(read_config_value ERP_PGDATABASE)"
sslmode="$(read_config_value ERP_PGSSLMODE)"
username="$(read_config_value OA_DB_USERNAME)"
password="$(read_config_value OA_DB_PASSWORD)"
[[ "$host" != *[[:space:]]* && "$port" =~ ^[0-9]+$ && "$port" -ge 1 && "$port" -le 65535 ]] \
|| fail 'invalid database endpoint in configuration'
[[ "$database" =~ ^[A-Za-z_][A-Za-z0-9_-]{0,62}$ ]] || fail 'invalid database name in configuration'
[[ "$username" =~ ^[A-Za-z_][A-Za-z0-9_.-]{0,127}$ ]] || fail 'invalid database user in configuration'
case "$database" in postgres|template0|template1) fail "refusing to purge protected database: $database" ;; esac
case "$sslmode" in disable|allow|prefer|require|verify-ca|verify-full) ;; *) fail 'invalid database SSL mode' ;; esac
owner_check="$(PGPASSWORD="$password" PGSSLMODE="$sslmode" psql -XAt \
-h "$host" -p "$port" -U "$username" -d "$database" -v ON_ERROR_STOP=1 \
-c "SELECT pg_get_userbyid(datdba) = current_user FROM pg_database WHERE datname = current_database()")" \
|| fail 'unable to verify database ownership'
[[ "$owner_check" == "t" ]] \
|| fail 'configured ERP account is not the database owner; database was not changed'
say "Purging PostgreSQL schema $database/public"
PGPASSWORD="$password" PGSSLMODE="$sslmode" psql -X \
-h "$host" -p "$port" -U "$username" -d "$database" -v ON_ERROR_STOP=1 <<'SQL'
DROP SCHEMA IF EXISTS public CASCADE;
SELECT format('CREATE SCHEMA public AUTHORIZATION %I', current_user) \gexec
SQL
}
stop_service() {
if command -v systemctl >/dev/null 2>&1; then
local load_state
if ! systemctl stop kaidi-erp.service >/dev/null 2>&1; then
load_state="$(systemctl show kaidi-erp.service --property=LoadState --value 2>/dev/null || true)"
[[ "$load_state" == "not-found" ]] \
|| fail 'unable to stop kaidi-erp.service; database and files were not changed'
fi
if systemctl is-active --quiet kaidi-erp.service; then
fail 'kaidi-erp.service is still active; database and files were not changed'
fi
fi
}
remove_service() {
if command -v systemctl >/dev/null 2>&1; then
systemctl disable kaidi-erp.service >/dev/null 2>&1 || true
fi
local unit_file="${ERP_UNINSTALL_SYSTEMD_UNIT_FILE:-/etc/systemd/system/kaidi-erp.service}"
if [[ "${ERP_UNINSTALL_TEST_MODE:-0}" != "1" && "$unit_file" != "/etc/systemd/system/kaidi-erp.service" ]]; then
fail 'custom systemd unit path is only available in test mode'
fi
rm -f -- "$unit_file"
if command -v systemctl >/dev/null 2>&1; then
systemctl daemon-reload >/dev/null 2>&1 || true
systemctl reset-failed kaidi-erp.service >/dev/null 2>&1 || true
fi
}
remove_files() {
local path
for path in "$INSTALL_ROOT" "$CONFIG_ROOT" "$STATE_ROOT" "$LOG_ROOT"; do
[[ ! -e "$path" && ! -L "$path" ]] || rm -rf -- "$path"
done
}
main() {
require_root
require_confirmation
prepare_paths
verify_installation_identity
stop_service
purge_database
remove_service
remove_files
say 'Kaidi ERP service, files, state, logs, and public database schema were removed'
}
if [[ "${BASH_SOURCE[0]:-$0}" == "$0" ]]; then
main "$@"
fi