Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
670dfb0c7a | ||
|
|
abba079dde | ||
|
|
d3892320dd |
@@ -60,7 +60,7 @@ 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"
|
||||||
@@ -71,6 +71,23 @@ jobs:
|
|||||||
)
|
)
|
||||||
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"
|
||||||
@@ -80,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=(",", ":")))
|
||||||
|
|||||||
@@ -111,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.7 -PproductionBuild=true
|
||||||
```
|
```
|
||||||
|
|
||||||
正式 JAR 必须包含 PostgreSQL 驱动,并且不得包含 `sqlite-jdbc` 或 `hibernate-community-dialects`。
|
正式 JAR 必须包含 PostgreSQL 驱动,并且不得包含 `sqlite-jdbc` 或 `hibernate-community-dialects`。
|
||||||
@@ -181,12 +181,15 @@ 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` 权限不足以完成迁移。
|
PostgreSQL 必须使用专用空数据库,网页中填写的账号必须是该数据库的所有者。该约束保证账号拥有 `public` schema 建表权限,并能持有安装器创建的 `pg_trgm` 扩展;只授予 `CONNECT` 权限不足以完成迁移。
|
||||||
@@ -211,16 +214,18 @@ curl -fsSL https://git.example.com/awaioi/ERP/raw/branch/main/install.sh \
|
|||||||
set -e
|
set -e
|
||||||
tmp="$(mktemp)"
|
tmp="$(mktemp)"
|
||||||
trap 'rm -f -- "$tmp"' EXIT
|
trap 'rm -f -- "$tmp"' EXIT
|
||||||
curl -fsSL http://38.76.196.225:10099/awaioi/ERP/raw/tag/v0.3.1/install.sh -o "$tmp"
|
curl -fsSL http://38.76.196.225:10099/awaioi/ERP/raw/tag/v0.3.7/install.sh -o "$tmp"
|
||||||
printf '%s %s\n' '89a3c45e76f500c9475cb596ea29e3518bfafdc59f36dd3c7b316ed3cdd0448c' "$tmp" | sha256sum -c -
|
printf '%s %s\n' '88328b3086ece360d3c05d4a22bee06b03ed3639a454c1fe7b9c04b1da80a980' "$tmp" | sha256sum -c -
|
||||||
sudo -E bash "$tmp" \
|
sudo -E bash "$tmp" \
|
||||||
--gitea-url http://38.76.196.225:10099 \
|
--gitea-url http://38.76.196.225:10099 \
|
||||||
--repository awaioi/ERP \
|
--repository awaioi/ERP \
|
||||||
|
--version 0.3.7 \
|
||||||
|
--public-url http://38.76.196.225:8091 \
|
||||||
--allow-insecure
|
--allow-insecure
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
只有 `v0.3.1` Release 发布后这条命令才可下载安装包。固定 tag 和 SHA-256 只用于保护当前 HTTP 引导脚本不被传输途中篡改;Release 资产仍会继续执行 Ed25519 和 SHA-256 双重校验。HTTP 会暴露请求、Release 元数据和可能使用的访问令牌,不得作为生产方案。
|
只有 `v0.3.7` Release 发布后这条命令才可下载安装包。固定 tag 和 SHA-256 只用于保护当前 HTTP 引导脚本不被传输途中篡改;Release 资产仍会继续执行 Ed25519 和 SHA-256 双重校验。HTTP 会暴露请求、Release 元数据和可能使用的访问令牌,不得作为长期生产方案。
|
||||||
|
|
||||||
### 完整卸载后重装
|
### 完整卸载后重装
|
||||||
|
|
||||||
@@ -231,7 +236,7 @@ curl -fsSL https://git.example.com/awaioi/ERP/raw/branch/main/install.sh \
|
|||||||
set -e
|
set -e
|
||||||
tmp="$(mktemp)"
|
tmp="$(mktemp)"
|
||||||
trap 'rm -f -- "$tmp"' EXIT
|
trap 'rm -f -- "$tmp"' EXIT
|
||||||
curl -fsSL http://38.76.196.225:10099/awaioi/ERP/raw/tag/v0.3.1/uninstall.sh -o "$tmp"
|
curl -fsSL http://38.76.196.225:10099/awaioi/ERP/raw/tag/v0.3.7/uninstall.sh -o "$tmp"
|
||||||
printf '%s %s\n' '98c56fed2fd4d01874e4ab5a1a4f3ec42ec3e29b315ffd87385a95488d587546' "$tmp" | sha256sum -c -
|
printf '%s %s\n' '98c56fed2fd4d01874e4ab5a1a4f3ec42ec3e29b315ffd87385a95488d587546' "$tmp" | sha256sum -c -
|
||||||
sudo -E bash "$tmp" --purge-database --yes
|
sudo -E bash "$tmp" --purge-database --yes
|
||||||
)
|
)
|
||||||
@@ -256,11 +261,11 @@ Linux 默认路径:
|
|||||||
|
|
||||||
### HTTPS 反向代理
|
### HTTPS 反向代理
|
||||||
|
|
||||||
正式服务监听 `8090`。Nginx、宝塔、Caddy 或 CDN 终止 HTTPS 后,必须把公网协议和主机转发给 Spring Boot;否则浏览器的同源 API 请求会被误判为跨域,并收到纯文本 `403 Invalid CORS request`,前端表现为“响应非 JSON (HTTP 403)”。Nginx 的代理位置至少包含:
|
正式安装默认监听 `8091`(可通过 `ERP_SERVER_PORT` 覆盖)。Nginx、宝塔、Caddy 或 CDN 终止 HTTPS 后,必须把公网协议和主机转发给 Spring Boot;否则浏览器的同源 API 请求会被误判为跨域,并收到纯文本 `403 Invalid CORS request`,前端表现为“响应非 JSON (HTTP 403)”。Nginx 的代理位置至少包含:
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:8090;
|
proxy_pass http://127.0.0.1:8091;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Forwarded-Host $host;
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
@@ -275,13 +280,20 @@ location / {
|
|||||||
|
|
||||||
## 在线更新与回滚
|
## 在线更新与回滚
|
||||||
|
|
||||||
管理员登录后进入:
|
管理员登录后可从以下任一入口进入:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
顶部工具栏 -> 系统更新
|
||||||
|
用户菜单 -> 系统更新
|
||||||
|
手机导航抽屉 -> 系统更新
|
||||||
应用定制平台 -> 系统更新
|
应用定制平台 -> 系统更新
|
||||||
```
|
```
|
||||||
|
|
||||||
对应前端路由为 `/appdev/update`,后端 API 为 `/api/oa/system-update/*`。更新过程如下:
|
入口只对 `ADMIN` 角色显示,对应前端路由为 `/appdev/update`,后端 API 为 `/api/oa/system-update/*`。页面只显示当前版本、在线最新版本、检查时间、最新版本更新日志、历史正式版本,以及下载、验签、安装、重启和自动回滚进度。顶部和手机入口发现新版本时会显示版本提示。
|
||||||
|
|
||||||
|
更新源由安装器写入服务器的 `/etc/kaidi-erp/erp.env`,后台页面不会要求管理员重复填写 Gitea 地址、仓库、Token、通道或 HTTP 开关。需要变更基础设施配置时由服务器运维人员修改 `OA_UPDATE_*` 环境变量并重启服务;公开仓库无需 Token,正式环境应使用 HTTPS。
|
||||||
|
|
||||||
|
更新过程如下:
|
||||||
|
|
||||||
1. 从 Gitea 读取 stable channel 的最新 Release。
|
1. 从 Gitea 读取 stable channel 的最新 Release。
|
||||||
2. 下载归档、`SHA256SUMS` 和 Ed25519 签名(Release 里的独立安装器资产只用于首次安装)。
|
2. 下载归档、`SHA256SUMS` 和 Ed25519 签名(Release 里的独立安装器资产只用于首次安装)。
|
||||||
@@ -289,13 +301,13 @@ location / {
|
|||||||
4. 校验 `manifest.json` 中的版本、`database=postgresql` 和 `rollbackCompatible=true`。
|
4. 校验 `manifest.json` 中的版本、`database=postgresql` 和 `rollbackCompatible=true`。
|
||||||
5. 可选执行 `pg_dump`,将新版本写入独立目录。
|
5. 可选执行 `pg_dump`,将新版本写入独立目录。
|
||||||
6. 原子切换 `current` 符号链接,并停止旧 Java 进程。
|
6. 原子切换 `current` 符号链接,并停止旧 Java 进程。
|
||||||
7. systemd/launchd 拉起新版本,更新助手等待新的 PID 和 `/api/oa/health`。
|
7. systemd/launchd 拉起新版本,更新助手等待新的 PID 和 `/api/oa/health`;Linux unit 使用 `KillMode=process`,确保更新助手不会随旧 Java 进程一起被 systemd 清理。
|
||||||
8. 新版本不健康时切回上一链接,终止故障进程并再次验证旧版本健康状态。
|
8. 新版本不健康时切回上一链接,终止故障进程并再次验证旧版本健康状态。
|
||||||
|
|
||||||
同一安装目录使用操作系统文件锁,不能并发执行两个更新任务。也可以手工触发:
|
同一安装目录使用操作系统文件锁,不能并发执行两个更新任务。也可以手工触发:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
/opt/kaidi-erp/current/bin/erp-update install 0.2.0
|
/opt/kaidi-erp/current/bin/erp-update install 0.3.7
|
||||||
```
|
```
|
||||||
|
|
||||||
应用回滚不等于数据库回滚。包含不可逆 Flyway 迁移的版本必须先保证旧应用仍兼容新结构,并建议在安装配置中启用:
|
应用回滚不等于数据库回滚。包含不可逆 Flyway 迁移的版本必须先保证旧应用仍兼容新结构,并建议在安装配置中启用:
|
||||||
@@ -336,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.7 -m 'Kaidi ERP v0.3.7'
|
||||||
git push origin v0.2.0
|
git push origin v0.3.7
|
||||||
```
|
```
|
||||||
|
|
||||||
发布完成后必须确认 Release 页面存在四个资产,并使用仓库中的 `distribution/release-public-key.pem` 验证签名。私钥与该公钥不匹配时打包脚本会直接失败。
|
发布完成后必须确认 Release 页面存在四个资产,并使用仓库中的 `distribution/release-public-key.pem` 验证签名。私钥与该公钥不匹配时打包脚本会直接失败。
|
||||||
@@ -346,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.7
|
||||||
```
|
```
|
||||||
|
|
||||||
## 配置参考
|
## 配置参考
|
||||||
@@ -365,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` |
|
||||||
@@ -438,7 +451,7 @@ Gitea 仓库尚未发布首个可安装版本,或 Release 缺少四个必需
|
|||||||
|
|
||||||
### 反向代理后提示“响应非 JSON (HTTP 403)”
|
### 反向代理后提示“响应非 JSON (HTTP 403)”
|
||||||
|
|
||||||
应用自身的 401/403 权限错误始终是 JSON。该提示表示 Nginx、WAF 或 Spring CORS 层提前返回了纯文本/HTML。先确认代理目标为 `http://127.0.0.1:8090`,再按“HTTPS 反向代理”一节补齐 `Host` 和 `X-Forwarded-*` 请求头;响应正文为 `Invalid CORS request` 时即可确认是协议/主机转发不完整。
|
应用自身的 401/403 权限错误始终是 JSON。该提示表示 Nginx、WAF 或 Spring CORS 层提前返回了纯文本/HTML。先确认代理目标为正式安装端口(默认 `http://127.0.0.1:8091`),再按“HTTPS 反向代理”一节补齐 `Host` 和 `X-Forwarded-*` 请求头;响应正文为 `Invalid CORS request` 时即可确认是协议/主机转发不完整。
|
||||||
|
|
||||||
### 签名验证失败
|
### 签名验证失败
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -24,11 +24,13 @@ Gitea Actions runner 需要预装 Java 17 或更高版本、Node.js/npm、Python
|
|||||||
base64 < ~/.config/kaidi-erp/release-signing-key.pem | tr -d '\n'
|
base64 < ~/.config/kaidi-erp/release-signing-key.pem | tr -d '\n'
|
||||||
```
|
```
|
||||||
|
|
||||||
发布稳定版本:
|
发布稳定版本(先确认发布提交已经同步到 `main` 和 `dev`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git tag v0.2.0
|
git switch main
|
||||||
git push origin v0.2.0
|
git pull --ff-only origin main
|
||||||
|
git tag -a v0.3.7 -m 'Kaidi ERP v0.3.7'
|
||||||
|
git push origin v0.3.7
|
||||||
```
|
```
|
||||||
|
|
||||||
## 首次安装
|
## 首次安装
|
||||||
@@ -44,23 +46,26 @@ curl -fsSL https://git.example.com/awaioi/ERP/raw/branch/main/install.sh \
|
|||||||
|
|
||||||
命令行只检查并安装 Java 17+、curl、tar、Python 3 和 OpenSSL 3,然后启动独立安装器并输出带一次性 token 的网页地址。数据库、管理员和密码全部在首次网页向导填写;安装器会真实测试 PostgreSQL 15+、数据库所有权、`public` schema 建表权限和 `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 迁移后才显示笼统错误。
|
目标 PostgreSQL 必须是专用空数据库,网页中填写的账号必须是该数据库的所有者。只拥有连接权限的账号会在网页连接测试阶段被拒绝,不再等到 Flyway 迁移后才显示笼统错误。
|
||||||
|
|
||||||
Linux 生产服务要求主机使用 systemd;没有 systemd 的容器、WSL 或精简系统只能显式使用 `--no-service` 做开发验收,在线更新也会保持关闭。
|
Linux 生产服务要求主机使用 systemd;没有 systemd 的容器、WSL 或精简系统只能显式使用 `--no-service` 做开发验收,在线更新也会保持关闭。
|
||||||
|
|
||||||
当前 `http://38.76.196.225:10099` 仅用于开发测试,安装器必须同时传入 `--allow-insecure`。在没有 HTTPS 的情况下,必须从固定 tag 下载引导脚本并验证本版本记录的 SHA-256,禁止把可变的 `main` 分支脚本直接管道给 root。HTTP 仍会暴露请求、Release 元数据和 Gitea token,不应作为生产部署方式。
|
当前 `http://38.76.196.225:10099` 仅用于开发测试,安装器必须同时传入 `--allow-insecure`。在没有 HTTPS 的情况下,必须从固定 tag 下载引导脚本并验证本版本记录的 SHA-256,禁止把可变的 `main` 分支脚本直接管道给 root。HTTP 仍会暴露请求、Release 元数据和 Gitea token,不应作为生产部署方式。
|
||||||
|
|
||||||
当前 `v0.3.1` 安装命令:
|
当前 `v0.3.7` 安装命令:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
(
|
(
|
||||||
set -e
|
set -e
|
||||||
tmp="$(mktemp)"
|
tmp="$(mktemp)"
|
||||||
trap 'rm -f -- "$tmp"' EXIT
|
trap 'rm -f -- "$tmp"' EXIT
|
||||||
curl -fsSL http://38.76.196.225:10099/awaioi/ERP/raw/tag/v0.3.1/install.sh -o "$tmp"
|
curl -fsSL http://38.76.196.225:10099/awaioi/ERP/raw/tag/v0.3.7/install.sh -o "$tmp"
|
||||||
printf '%s %s\n' '89a3c45e76f500c9475cb596ea29e3518bfafdc59f36dd3c7b316ed3cdd0448c' "$tmp" | sha256sum -c -
|
printf '%s %s\n' '88328b3086ece360d3c05d4a22bee06b03ed3639a454c1fe7b9c04b1da80a980' "$tmp" | sha256sum -c -
|
||||||
sudo -E bash "$tmp" --gitea-url http://38.76.196.225:10099 \
|
sudo -E bash "$tmp" --gitea-url http://38.76.196.225:10099 \
|
||||||
--repository awaioi/ERP --allow-insecure
|
--repository awaioi/ERP --version 0.3.7 \
|
||||||
|
--public-url http://38.76.196.225:8091 --allow-insecure
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -71,7 +76,7 @@ Linux 生产服务要求主机使用 systemd;没有 systemd 的容器、WSL
|
|||||||
set -e
|
set -e
|
||||||
tmp="$(mktemp)"
|
tmp="$(mktemp)"
|
||||||
trap 'rm -f -- "$tmp"' EXIT
|
trap 'rm -f -- "$tmp"' EXIT
|
||||||
curl -fsSL http://38.76.196.225:10099/awaioi/ERP/raw/tag/v0.3.1/uninstall.sh -o "$tmp"
|
curl -fsSL http://38.76.196.225:10099/awaioi/ERP/raw/tag/v0.3.7/uninstall.sh -o "$tmp"
|
||||||
printf '%s %s\n' '98c56fed2fd4d01874e4ab5a1a4f3ec42ec3e29b315ffd87385a95488d587546' "$tmp" | sha256sum -c -
|
printf '%s %s\n' '98c56fed2fd4d01874e4ab5a1a4f3ec42ec3e29b315ffd87385a95488d587546' "$tmp" | sha256sum -c -
|
||||||
sudo -E bash "$tmp" --purge-database --yes
|
sudo -E bash "$tmp" --purge-database --yes
|
||||||
)
|
)
|
||||||
@@ -81,18 +86,22 @@ Linux 生产服务要求主机使用 systemd;没有 systemd 的容器、WSL
|
|||||||
|
|
||||||
## 在线更新
|
## 在线更新
|
||||||
|
|
||||||
管理员进入“应用定制平台 -> 系统更新”,点击“检查更新”,确认版本和 Release notes 后执行更新。后端启动独立更新助手,更新助手会:
|
管理员可从顶部工具栏、用户菜单、手机导航抽屉或“应用定制平台 -> 系统更新”进入 `/appdev/update`。入口只对 `ADMIN` 角色显示;发现新版本时顶部和手机入口会显示版本提示。
|
||||||
|
|
||||||
|
更新源由首次安装器写入 `ERP_CONFIG_FILE` 指向的 `erp.env`。更新页面不会要求管理员重复填写 Gitea 地址、仓库、Token、通道或 HTTP 开关;需要变更这些基础设施参数时,由服务器运维人员修改 `OA_UPDATE_*` 环境变量并重启服务。
|
||||||
|
|
||||||
|
点击“检查更新”后,页面会展示当前版本、在线最新版本、发布日期、最新版本更新日志和历史正式版本记录。点击“安装并重启”后持续显示下载、验签、安装、重启和回滚进度;服务重启短暂断开期间页面会自动重连。后端启动独立更新助手,更新助手会:
|
||||||
|
|
||||||
1. 下载正式归档、`SHA256SUMS` 和签名并验证 Ed25519/SHA-256;独立安装器资产只在首次安装使用。
|
1. 下载正式归档、`SHA256SUMS` 和签名并验证 Ed25519/SHA-256;独立安装器资产只在首次安装使用。
|
||||||
2. 拒绝路径穿越、符号链接和结构不完整的安装包。
|
2. 拒绝路径穿越、符号链接和结构不完整的安装包。
|
||||||
3. 可选执行 `pg_dump`,再写入独立版本目录。
|
3. 可选执行 `pg_dump`,再写入独立版本目录。
|
||||||
4. 原子切换 `current` 链接并终止旧进程,由 systemd 或 launchd 拉起新版本。
|
4. 原子切换 `current` 链接并终止旧进程,由 systemd 或 launchd 拉起新版本;Linux unit 使用 `KillMode=process`,让更新助手继续执行健康检查和必要的回滚。
|
||||||
5. 等待健康检查;失败时切回上一版本并再次验证健康状态。
|
5. 等待健康检查;失败时切回上一版本并再次验证健康状态。
|
||||||
|
|
||||||
更新过程使用操作系统文件锁,同一安装目录同时只允许一个更新任务。手动触发可执行:
|
更新过程使用操作系统文件锁,同一安装目录同时只允许一个更新任务。手动触发可执行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
/opt/kaidi-erp/current/bin/erp-update install 0.2.0
|
/opt/kaidi-erp/current/bin/erp-update install 0.3.7
|
||||||
```
|
```
|
||||||
|
|
||||||
在线更新依赖安装器注册的 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` 调整。
|
||||||
|
|||||||
+100
-4
@@ -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 ;;
|
||||||
@@ -432,6 +435,8 @@ start_service() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$PLATFORM" == "linux" ]]; then
|
if [[ "$PLATFORM" == "linux" ]]; then
|
||||||
|
# The updater shares this service cgroup and must outlive the Java process
|
||||||
|
# to verify the restarted release and roll back a failed health check.
|
||||||
cat > /etc/systemd/system/kaidi-erp.service <<EOF
|
cat > /etc/systemd/system/kaidi-erp.service <<EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Kaidi ERP
|
Description=Kaidi ERP
|
||||||
@@ -440,6 +445,7 @@ Wants=network-online.target
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
|
KillMode=process
|
||||||
User=$ERP_USER
|
User=$ERP_USER
|
||||||
Group=$ERP_GROUP
|
Group=$ERP_GROUP
|
||||||
Environment="ERP_INSTALL_ROOT=$INSTALL_ROOT"
|
Environment="ERP_INSTALL_ROOT=$INSTALL_ROOT"
|
||||||
@@ -519,6 +525,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
|
||||||
@@ -531,9 +605,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...'
|
||||||
@@ -545,11 +628,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.'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,11 @@ 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_HISTORY = 20;
|
||||||
|
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 +84,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,13 +129,34 @@ 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<ReleaseHistoryItem> releases() {
|
||||||
|
requireConfigured();
|
||||||
|
List<ReleaseInfo> releases = new ArrayList<>(fetchReleaseHistory());
|
||||||
|
releases.sort((left, right) -> compareVersions(right.version(), left.version()));
|
||||||
|
String latestVersion = releases.isEmpty() ? null : releases.get(0).version();
|
||||||
|
List<ReleaseHistoryItem> history = new ArrayList<>(releases.size());
|
||||||
|
for (ReleaseInfo release : releases) {
|
||||||
|
history.add(new ReleaseHistoryItem(
|
||||||
|
release.version(),
|
||||||
|
release.publishedAt(),
|
||||||
|
release.notes(),
|
||||||
|
sameVersion(release.version(), currentVersion),
|
||||||
|
latestVersion != null && sameVersion(release.version(), latestVersion)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return List.copyOf(history);
|
||||||
|
}
|
||||||
|
|
||||||
public UpdateStatus install(String requestedVersion) {
|
public UpdateStatus install(String requestedVersion) {
|
||||||
requireConfigured();
|
requireConfigured();
|
||||||
if (!isVersion(requestedVersion)) {
|
if (!isVersion(requestedVersion)) {
|
||||||
@@ -150,6 +200,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) {
|
||||||
@@ -165,13 +216,57 @@ public class SystemUpdateService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private ReleaseInfo fetchLatestRelease() {
|
private ReleaseInfo fetchLatestRelease() {
|
||||||
|
JsonNode latest = fetchReleaseJson(releaseApiUri("/latest"));
|
||||||
|
if (!isStableChannel() || !isPrerelease(latest)) {
|
||||||
|
return parseRelease(latest);
|
||||||
|
}
|
||||||
|
|
||||||
|
ReleaseInfo selected = null;
|
||||||
|
JsonNode selectedNode = null;
|
||||||
|
for (JsonNode node : fetchReleaseList()) {
|
||||||
|
ReleaseInfo candidate = parseReleaseHistoryItem(node);
|
||||||
|
if (candidate != null && (selected == null
|
||||||
|
|| compareVersions(candidate.version(), selected.version()) > 0)) {
|
||||||
|
selected = candidate;
|
||||||
|
selectedNode = node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (selectedNode == null) {
|
||||||
|
throw new ApiException(404, "Gitea 尚未发布正式版本");
|
||||||
|
}
|
||||||
|
return parseRelease(selectedNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ReleaseInfo> fetchReleaseHistory() {
|
||||||
|
List<ReleaseInfo> releases = new ArrayList<>();
|
||||||
|
for (JsonNode node : fetchReleaseList()) {
|
||||||
|
ReleaseInfo release = parseReleaseHistoryItem(node);
|
||||||
|
if (release != null) {
|
||||||
|
releases.add(release);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return List.copyOf(releases);
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode fetchReleaseList() {
|
||||||
|
JsonNode root = fetchReleaseJson(releaseApiUri("?limit=" + MAX_RELEASE_HISTORY + "&page=1"));
|
||||||
|
if (!root.isArray() || root.size() > MAX_RELEASE_HISTORY) {
|
||||||
|
throw new ApiException(502, "Gitea Release 历史数据无效");
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
private URI releaseApiUri(String suffix) {
|
||||||
String[] repository = properties.getRepository().split("/", 2);
|
String[] repository = properties.getRepository().split("/", 2);
|
||||||
if (repository.length != 2 || repository[0].isBlank() || repository[1].isBlank()) {
|
if (repository.length != 2 || repository[0].isBlank() || repository[1].isBlank()) {
|
||||||
throw new ApiException(500, "更新仓库配置无效");
|
throw new ApiException(500, "更新仓库配置无效");
|
||||||
}
|
}
|
||||||
String base = properties.getGiteaBaseUrl().replaceAll("/+$", "");
|
String base = properties.getGiteaBaseUrl().replaceAll("/+$", "");
|
||||||
URI uri = URI.create(base + "/api/v1/repos/" + encode(repository[0]) + "/"
|
return URI.create(base + "/api/v1/repos/" + encode(repository[0]) + "/"
|
||||||
+ encode(repository[1]) + "/releases/latest");
|
+ encode(repository[1]) + "/releases" + suffix);
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode fetchReleaseJson(URI uri) {
|
||||||
HttpRequest.Builder builder = HttpRequest.newBuilder(uri)
|
HttpRequest.Builder builder = HttpRequest.newBuilder(uri)
|
||||||
.timeout(Duration.ofSeconds(Math.max(1, properties.getRequestTimeoutSeconds())))
|
.timeout(Duration.ofSeconds(Math.max(1, properties.getRequestTimeoutSeconds())))
|
||||||
.header("Accept", "application/json")
|
.header("Accept", "application/json")
|
||||||
@@ -182,13 +277,14 @@ 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");
|
||||||
}
|
}
|
||||||
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
||||||
throw new ApiException(502, "Gitea Release API 返回 HTTP " + response.statusCode());
|
throw new ApiException(502, "Gitea Release API 返回 HTTP " + response.statusCode());
|
||||||
}
|
}
|
||||||
return parseRelease(objectMapper.readTree(response.body()));
|
return objectMapper.readTree(response.body());
|
||||||
} catch (InterruptedException e) {
|
} catch (InterruptedException e) {
|
||||||
Thread.currentThread().interrupt();
|
Thread.currentThread().interrupt();
|
||||||
throw new ApiException(503, "检查更新被中断");
|
throw new ApiException(503, "检查更新被中断");
|
||||||
@@ -198,6 +294,22 @@ public class SystemUpdateService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ReleaseInfo parseReleaseHistoryItem(JsonNode root) {
|
||||||
|
String tag = text(root, "tag_name");
|
||||||
|
if (!isVersion(tag) || root.path("draft").asBoolean(false)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (isPrerelease(root) && isStableChannel()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new ReleaseInfo(
|
||||||
|
normalizeVersion(tag),
|
||||||
|
releaseNotes(root),
|
||||||
|
releasePublishedAt(root),
|
||||||
|
List.of()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private ReleaseInfo parseRelease(JsonNode root) {
|
private ReleaseInfo parseRelease(JsonNode root) {
|
||||||
String tag = text(root, "tag_name");
|
String tag = text(root, "tag_name");
|
||||||
if (!isVersion(tag)) {
|
if (!isVersion(tag)) {
|
||||||
@@ -206,18 +318,24 @@ public class SystemUpdateService {
|
|||||||
if (root.path("draft").asBoolean(false)) {
|
if (root.path("draft").asBoolean(false)) {
|
||||||
throw new ApiException(502, "最新 Release 仍是草稿");
|
throw new ApiException(502, "最新 Release 仍是草稿");
|
||||||
}
|
}
|
||||||
Matcher versionMatcher = VERSION_PATTERN.matcher(tag);
|
if (isPrerelease(root) && isStableChannel()) {
|
||||||
versionMatcher.matches();
|
|
||||||
boolean taggedPrerelease = versionMatcher.group(4) != null;
|
|
||||||
if ((root.path("prerelease").asBoolean(false) || taggedPrerelease)
|
|
||||||
&& "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)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -225,16 +343,36 @@ public class SystemUpdateService {
|
|||||||
requireAsset(assets, "kaidi-erp-" + version + ".tar.gz");
|
requireAsset(assets, "kaidi-erp-" + version + ".tar.gz");
|
||||||
requireAsset(assets, "SHA256SUMS");
|
requireAsset(assets, "SHA256SUMS");
|
||||||
requireAsset(assets, "SHA256SUMS.sig");
|
requireAsset(assets, "SHA256SUMS.sig");
|
||||||
Instant publishedAt = null;
|
return new ReleaseInfo(version, releaseNotes(root), releasePublishedAt(root), List.copyOf(assets));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isStableChannel() {
|
||||||
|
return "stable".equalsIgnoreCase(properties.getChannel());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isPrerelease(JsonNode root) {
|
||||||
|
Matcher matcher = VERSION_PATTERN.matcher(text(root, "tag_name"));
|
||||||
|
return root.path("prerelease").asBoolean(false) || (matcher.matches() && matcher.group(4) != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Instant releasePublishedAt(JsonNode root) {
|
||||||
String published = text(root, "published_at");
|
String published = text(root, "published_at");
|
||||||
if (!published.isBlank()) {
|
if (published.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
publishedAt = Instant.parse(published);
|
return Instant.parse(published);
|
||||||
} catch (RuntimeException ignore) {
|
} catch (RuntimeException ignore) {
|
||||||
// An invalid optional timestamp must not hide an otherwise valid release.
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return new ReleaseInfo(version, text(root, "body"), publishedAt, List.copyOf(assets));
|
|
||||||
|
private static String releaseNotes(JsonNode root) {
|
||||||
|
String notes = text(root, "body");
|
||||||
|
if (notes.length() > MAX_RELEASE_NOTES_CHARS) {
|
||||||
|
return notes.substring(0, MAX_RELEASE_NOTES_CHARS) + "\n\n[发布说明过长,已截断]";
|
||||||
|
}
|
||||||
|
return notes;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void requireConfigured() {
|
private void requireConfigured() {
|
||||||
@@ -244,16 +382,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 +409,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 +425,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 +442,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() : "";
|
||||||
@@ -422,6 +695,15 @@ public class SystemUpdateService {
|
|||||||
public record ReleaseAsset(String name, String downloadUrl, long size) {
|
public record ReleaseAsset(String name, String downloadUrl, long size) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record ReleaseHistoryItem(
|
||||||
|
String version,
|
||||||
|
Instant publishedAt,
|
||||||
|
String releaseNotes,
|
||||||
|
boolean current,
|
||||||
|
boolean latest
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
public enum UpdatePhase {
|
public enum UpdatePhase {
|
||||||
IDLE,
|
IDLE,
|
||||||
CHECKING,
|
CHECKING,
|
||||||
|
|||||||
@@ -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,25 +1,47 @@
|
|||||||
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.ReleaseHistoryItem;
|
||||||
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;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/** Administrator-only API for checking and installing signed Gitea releases. */
|
/** Administrator-only API for checking and installing signed Gitea releases. */
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/oa/system-update")
|
@RequestMapping("/api/oa/system-update")
|
||||||
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")
|
||||||
@@ -27,6 +49,11 @@ public class SystemUpdateController {
|
|||||||
return ApiResp.ok(updateService.status());
|
return ApiResp.ok(updateService.status());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/releases")
|
||||||
|
public ApiResp<List<ReleaseHistoryItem>> releases() {
|
||||||
|
return ApiResp.ok(updateService.releases());
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/check")
|
@PostMapping("/check")
|
||||||
public ApiResp<UpdateStatus> check() {
|
public ApiResp<UpdateStatus> check() {
|
||||||
return ApiResp.ok(updateService.check());
|
return ApiResp.ok(updateService.check());
|
||||||
|
|||||||
@@ -70,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;
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ 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.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Properties;
|
import java.util.Properties;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
@@ -22,6 +24,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 +43,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 +52,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 +80,148 @@ class SystemUpdateServiceTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stableChannelFallsBackToNewestStableReleaseWhenLatestIsPrerelease() 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.3.7-rc.1","draft":false,"prerelease":true,"assets":[]}
|
||||||
|
""".getBytes(StandardCharsets.UTF_8);
|
||||||
|
exchange.sendResponseHeaders(200, body.length);
|
||||||
|
exchange.getResponseBody().write(body);
|
||||||
|
exchange.close();
|
||||||
|
});
|
||||||
|
server.createContext("/api/v1/repos/awaioi/ERP/releases", exchange -> {
|
||||||
|
String origin = "http://127.0.0.1:" + exchange.getLocalAddress().getPort();
|
||||||
|
byte[] body = ("""
|
||||||
|
[
|
||||||
|
{"tag_name":"v0.3.7-rc.1","draft":false,"prerelease":true,"assets":[]},
|
||||||
|
{"tag_name":"v0.3.5","draft":false,"prerelease":false,"assets":[]},
|
||||||
|
{"tag_name":"v0.3.6","body":"Latest stable","draft":false,"prerelease":false,
|
||||||
|
"published_at":"2026-08-04T10:00:00Z","assets":[
|
||||||
|
{"name":"kaidi-erp-0.3.6.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 {
|
||||||
|
SystemUpdateService service = service(configuredProperties(server.getAddress().getPort()), "0.3.5");
|
||||||
|
|
||||||
|
SystemUpdateService.UpdateStatus status = service.check();
|
||||||
|
|
||||||
|
assertThat(status.latestVersion()).isEqualTo("0.3.6");
|
||||||
|
assertThat(status.updateAvailable()).isTrue();
|
||||||
|
assertThat(status.releaseNotes()).isEqualTo("Latest stable");
|
||||||
|
} finally {
|
||||||
|
server.stop(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listsStableReleaseHistoryNewestFirstAndMarksCurrentVersion() throws Exception {
|
||||||
|
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||||
|
server.createContext("/api/v1/repos/awaioi/ERP/releases", exchange -> {
|
||||||
|
byte[] body = """
|
||||||
|
[
|
||||||
|
{"tag_name":"v0.3.4","body":"Older fixes","draft":false,"prerelease":false,
|
||||||
|
"published_at":"2026-08-03T10:00:00Z"},
|
||||||
|
{"tag_name":"v0.3.6","body":"Simplified updater","draft":false,"prerelease":false,
|
||||||
|
"published_at":"2026-08-04T10:00:00Z"},
|
||||||
|
{"tag_name":"v0.3.7-rc.1","body":"Preview","draft":false,"prerelease":true},
|
||||||
|
{"tag_name":"v0.3.8","body":"Draft","draft":true,"prerelease":false},
|
||||||
|
{"tag_name":"not-a-version","body":"Invalid","draft":false,"prerelease":false}
|
||||||
|
]
|
||||||
|
""".getBytes(StandardCharsets.UTF_8);
|
||||||
|
exchange.getResponseHeaders().add("Content-Type", "application/json");
|
||||||
|
exchange.sendResponseHeaders(200, body.length);
|
||||||
|
exchange.getResponseBody().write(body);
|
||||||
|
exchange.close();
|
||||||
|
});
|
||||||
|
server.start();
|
||||||
|
try {
|
||||||
|
SystemUpdateService service = service(configuredProperties(server.getAddress().getPort()), "0.3.4");
|
||||||
|
|
||||||
|
List<SystemUpdateService.ReleaseHistoryItem> releases = service.releases();
|
||||||
|
|
||||||
|
assertThat(releases).extracting(SystemUpdateService.ReleaseHistoryItem::version)
|
||||||
|
.containsExactly("0.3.6", "0.3.4");
|
||||||
|
assertThat(releases.get(0).latest()).isTrue();
|
||||||
|
assertThat(releases.get(0).current()).isFalse();
|
||||||
|
assertThat(releases.get(1).current()).isTrue();
|
||||||
|
assertThat(releases.get(1).releaseNotes()).isEqualTo("Older fixes");
|
||||||
|
} finally {
|
||||||
|
server.stop(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 +260,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
: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-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. */
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ export const oaApi = {
|
|||||||
listAlerts: alertsApi.listAlerts,
|
listAlerts: alertsApi.listAlerts,
|
||||||
// 系统更新
|
// 系统更新
|
||||||
getSystemUpdateStatus: updateApi.getSystemUpdateStatus,
|
getSystemUpdateStatus: updateApi.getSystemUpdateStatus,
|
||||||
|
getSystemUpdateReleases: updateApi.getSystemUpdateReleases,
|
||||||
checkSystemUpdate: updateApi.checkSystemUpdate,
|
checkSystemUpdate: updateApi.checkSystemUpdate,
|
||||||
installSystemUpdate: updateApi.installSystemUpdate
|
installSystemUpdate: updateApi.installSystemUpdate
|
||||||
}
|
}
|
||||||
@@ -216,7 +217,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, ReleaseHistoryItem, 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,10 +36,22 @@ export interface SystemUpdateStatus {
|
|||||||
error: string | null
|
error: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReleaseHistoryItem {
|
||||||
|
version: string
|
||||||
|
publishedAt: string | null
|
||||||
|
releaseNotes: string
|
||||||
|
current: boolean
|
||||||
|
latest: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export function getSystemUpdateStatus() {
|
export function getSystemUpdateStatus() {
|
||||||
return http.get<SystemUpdateStatus>('/system-update/status')
|
return http.get<SystemUpdateStatus>('/system-update/status')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getSystemUpdateReleases() {
|
||||||
|
return http.get<ReleaseHistoryItem[]>('/system-update/releases', undefined, { timeoutMs: 30000 })
|
||||||
|
}
|
||||||
|
|
||||||
export function checkSystemUpdate() {
|
export function checkSystemUpdate() {
|
||||||
return http.post<SystemUpdateStatus>('/system-update/check', undefined, { timeoutMs: 30000 })
|
return http.post<SystemUpdateStatus>('/system-update/check', undefined, { timeoutMs: 30000 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,32 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, 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, Download, Refresh, 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 ReleaseHistoryItem,
|
||||||
|
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 releases = ref<ReleaseHistoryItem[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const historyLoading = ref(false)
|
||||||
const checking = ref(false)
|
const checking = 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,55 +44,111 @@ 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 canInstall = computed(() => Boolean(
|
const canCheck = computed(() => Boolean(
|
||||||
status.value?.configured && status.value.updateAvailable && status.value.latestVersion && !busy.value
|
status.value?.configured
|
||||||
|
&& !busy.value
|
||||||
|
&& !reconnecting.value
|
||||||
|
&& !checking.value
|
||||||
|
&& !installing.value
|
||||||
))
|
))
|
||||||
const phaseTone = computed(() => {
|
const canInstall = computed(() => Boolean(
|
||||||
|
status.value?.configured
|
||||||
|
&& status.value.updateAvailable
|
||||||
|
&& status.value.latestVersion
|
||||||
|
&& !busy.value
|
||||||
|
&& !reconnecting.value
|
||||||
|
&& !checking.value
|
||||||
|
&& !installing.value
|
||||||
|
))
|
||||||
|
const remoteLatest = computed(() => releases.value.find(release => release.latest) || releases.value[0] || null)
|
||||||
|
const latestVersion = computed(() => status.value?.latestVersion || remoteLatest.value?.version || null)
|
||||||
|
const latestRelease = computed(() => {
|
||||||
|
const checkedVersion = status.value?.latestVersion
|
||||||
|
if (checkedVersion) {
|
||||||
|
return releases.value.find(release => release.version === checkedVersion) || remoteLatest.value
|
||||||
|
}
|
||||||
|
return remoteLatest.value
|
||||||
|
})
|
||||||
|
const latestPublishedAt = computed(() => status.value?.publishedAt || latestRelease.value?.publishedAt || null)
|
||||||
|
const latestReleaseNotes = computed(() => status.value?.releaseNotes || latestRelease.value?.releaseNotes || '')
|
||||||
|
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 })
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatBytes(size: number) {
|
function formatVersion(value: string | null | undefined) {
|
||||||
if (size <= 0) return '-'
|
if (!value) return '-'
|
||||||
if (size < 1024 * 1024) return `${Math.ceil(size / 1024)} KB`
|
return /^v/i.test(value) ? value : `v${value}`
|
||||||
return `${(size / 1024 / 1024).toFixed(1)} MB`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 loadReleases(silent = false) {
|
||||||
|
historyLoading.value = true
|
||||||
|
try {
|
||||||
|
releases.value = await oaApi.getSystemUpdateReleases()
|
||||||
|
} catch (error) {
|
||||||
|
if (!silent) ElMessage.error(apiMessage(error, '历史版本加载失败'))
|
||||||
} finally {
|
} finally {
|
||||||
if (!silent) loading.value = false
|
historyLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPage() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await loadStatus()
|
||||||
|
if (status.value?.configured) await loadReleases()
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkUpdate() {
|
async function checkUpdate() {
|
||||||
|
if (!canCheck.value) return
|
||||||
checking.value = true
|
checking.value = true
|
||||||
try {
|
try {
|
||||||
status.value = await oaApi.checkSystemUpdate()
|
status.value = await oaApi.checkSystemUpdate()
|
||||||
|
await loadReleases(true)
|
||||||
ElMessage.success(status.value.updateAvailable ? '发现新版本' : '当前已是最新版本')
|
ElMessage.success(status.value.updateAvailable ? '发现新版本' : '当前已是最新版本')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(apiMessage(error, '检查更新失败'))
|
ElMessage.error(apiMessage(error, '检查更新失败'))
|
||||||
@@ -86,20 +159,24 @@ async function checkUpdate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function installUpdate() {
|
async function installUpdate() {
|
||||||
|
if (!canInstall.value) return
|
||||||
const version = status.value?.latestVersion
|
const version = status.value?.latestVersion
|
||||||
if (!version) return
|
if (!version) return
|
||||||
|
installing.value = true
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
`确认安装 ${version} 并重启服务?`,
|
`确认安装 ${formatVersion(version)}?服务将自动重启,健康检查失败会切回上一版本。`,
|
||||||
'安装更新',
|
'安装正式更新',
|
||||||
{ type: 'warning', confirmButtonText: '安装并重启', cancelButtonText: '取消' }
|
{ type: 'warning', confirmButtonText: '安装并重启', cancelButtonText: '取消' }
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
|
installing.value = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
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 +188,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 +205,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="`当前版本 ${formatVersion(status?.currentVersion)}`"
|
||||||
>
|
>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<el-button :icon="Refresh" :loading="checking" @click="checkUpdate">检查更新</el-button>
|
<el-button
|
||||||
|
:icon="Refresh"
|
||||||
|
:loading="checking"
|
||||||
|
:disabled="!canCheck"
|
||||||
|
@click="checkUpdate"
|
||||||
|
>
|
||||||
|
检查更新
|
||||||
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
type="primary"
|
type="primary"
|
||||||
:icon="Download"
|
:icon="Download"
|
||||||
@@ -150,17 +251,45 @@ onBeforeUnmount(stopPolling)
|
|||||||
type="warning"
|
type="warning"
|
||||||
:closable="false"
|
:closable="false"
|
||||||
show-icon
|
show-icon
|
||||||
title="在线更新尚未配置"
|
title="服务器尚未启用在线更新"
|
||||||
|
description="请通过安装器完成正式服务配置。"
|
||||||
|
class="update-alert"
|
||||||
|
/>
|
||||||
|
<el-alert
|
||||||
|
v-else-if="reconnecting"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
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>{{ formatVersion(status?.currentVersion) }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="version-block">
|
<div class="version-block">
|
||||||
<span class="field-label">最新版本</span>
|
<span class="field-label">在线最新版本</span>
|
||||||
<strong>{{ status?.latestVersion || '-' }}</strong>
|
<strong>{{ formatVersion(latestVersion) }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="version-block">
|
<div class="version-block">
|
||||||
<span class="field-label">更新状态</span>
|
<span class="field-label">更新状态</span>
|
||||||
@@ -169,47 +298,62 @@ onBeforeUnmount(stopPolling)
|
|||||||
</el-tag>
|
</el-tag>
|
||||||
</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
|
||||||
|
v-if="status && (busy || status.phase === 'FAILED' || status.phase === 'ROLLED_BACK' || status.phase === 'SUCCEEDED')"
|
||||||
|
class="update-section"
|
||||||
|
>
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<h3>{{ status.message }}</h3>
|
<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="update-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(latestPublishedAt) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="release-notes">{{ status?.releaseNotes || '暂无发布说明' }}</div>
|
<div class="release-notes">{{ latestReleaseNotes || '暂无发布说明' }}</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="release-section">
|
<section class="update-section history-section">
|
||||||
<div class="section-heading"><h3>发布文件</h3></div>
|
<div class="section-heading">
|
||||||
<el-table :data="status?.assets || []" size="small" border empty-text="暂无发布文件">
|
<div class="heading-title"><h2>历史版本</h2></div>
|
||||||
<el-table-column prop="name" label="文件" min-width="280" />
|
<span>{{ releases.length ? `共 ${releases.length} 个正式版本` : '暂无记录' }}</span>
|
||||||
<el-table-column label="大小" width="120">
|
</div>
|
||||||
<template #default="{ row }">{{ formatBytes(row.size) }}</template>
|
<el-skeleton v-if="historyLoading" :rows="4" animated />
|
||||||
</el-table-column>
|
<div v-else-if="releases.length" class="release-history">
|
||||||
<el-table-column label="校验" width="140">
|
<article v-for="release in releases" :key="release.version" class="release-entry">
|
||||||
<template #default="{ row }">
|
<div class="release-version">
|
||||||
<el-tag v-if="row.name === 'SHA256SUMS.sig'" type="success" effect="plain" size="small">Ed25519</el-tag>
|
<strong>{{ formatVersion(release.version) }}</strong>
|
||||||
<el-tag v-else-if="row.name === 'SHA256SUMS'" type="info" effect="plain" size="small">SHA-256</el-tag>
|
<div class="release-tags">
|
||||||
<span v-else>-</span>
|
<el-tag v-if="release.current" type="primary" effect="plain" size="small">当前版本</el-tag>
|
||||||
</template>
|
<el-tag v-if="release.latest" type="success" effect="plain" size="small">最新版本</el-tag>
|
||||||
</el-table-column>
|
</div>
|
||||||
</el-table>
|
<time>{{ formatDate(release.publishedAt) }}</time>
|
||||||
|
</div>
|
||||||
|
<div class="release-notes release-notes--history">
|
||||||
|
{{ release.releaseNotes || '暂无发布说明' }}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<el-empty v-else description="暂无历史版本" :image-size="72" />
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -220,6 +364,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 +386,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 +402,55 @@ onBeforeUnmount(stopPolling)
|
|||||||
font-size: var(--erp-font-size-xs);
|
font-size: var(--erp-font-size-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.update-progress,
|
.update-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,
|
||||||
|
.release-tags {
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
.release-notes {
|
.release-notes {
|
||||||
min-height: 80px;
|
min-height: 72px;
|
||||||
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 +460,49 @@ onBeforeUnmount(stopPolling)
|
|||||||
font-size: var(--erp-font-size-sm);
|
font-size: var(--erp-font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.release-history {
|
||||||
|
border-top: 1px solid var(--erp-color-border-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-entry {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 190px minmax(0, 1fr);
|
||||||
|
gap: var(--erp-space-5);
|
||||||
|
padding: var(--erp-space-4) 0;
|
||||||
|
border-bottom: 1px solid var(--erp-color-border-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-entry:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-version {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-version > strong {
|
||||||
|
display: block;
|
||||||
|
color: var(--erp-color-text);
|
||||||
|
font-size: var(--erp-font-size-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-tags {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--erp-space-1);
|
||||||
|
margin-top: var(--erp-space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-version time {
|
||||||
|
display: block;
|
||||||
|
margin-top: var(--erp-space-2);
|
||||||
|
color: var(--erp-color-text-subtle);
|
||||||
|
font-size: var(--erp-font-size-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-notes--history {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
@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));
|
||||||
@@ -322,5 +531,15 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-entry {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--erp-space-3);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -202,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"
|
||||||
@@ -316,6 +354,7 @@ PY
|
|||||||
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
|
||||||
|
|
||||||
@@ -328,7 +367,9 @@ PY
|
|||||||
[[ "$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() (
|
||||||
@@ -552,6 +593,13 @@ test_systemd_unit_uses_compatible_protection() (
|
|||||||
! grep -Fq 'ProtectSystem=strict' "$PROJECT_ROOT/install.sh"
|
! grep -Fq 'ProtectSystem=strict' "$PROJECT_ROOT/install.sh"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
test_systemd_unit_preserves_update_helper_during_restart() (
|
||||||
|
local unit
|
||||||
|
unit="$(sed -n '/^ cat > \/etc\/systemd\/system\/kaidi-erp.service/,/^ systemctl daemon-reload/p' "$PROJECT_ROOT/install.sh")"
|
||||||
|
grep -Fqx 'KillMode=process' <<< "$unit"
|
||||||
|
! grep -Fq 'KillMode=control-group' <<< "$unit"
|
||||||
|
)
|
||||||
|
|
||||||
test_systemd_unit_uses_unquoted_legacy_paths() (
|
test_systemd_unit_uses_unquoted_legacy_paths() (
|
||||||
local unit
|
local unit
|
||||||
unit="$(sed -n '/^ cat > \/etc\/systemd\/system\/kaidi-erp.service/,/^ systemctl daemon-reload/p' "$PROJECT_ROOT/install.sh")"
|
unit="$(sed -n '/^ cat > \/etc\/systemd\/system\/kaidi-erp.service/,/^ systemctl daemon-reload/p' "$PROJECT_ROOT/install.sh")"
|
||||||
@@ -683,6 +731,8 @@ test_release_workflow_uses_scoped_job_token() (
|
|||||||
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 'uses: actions/setup-java@v4' "$workflow"
|
||||||
grep -Fq 'java-version: "17"' "$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"
|
||||||
)
|
)
|
||||||
@@ -718,6 +768,8 @@ run_test 'erp-run preserves a failed pending installation' test_erp_run_preserve
|
|||||||
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
|
||||||
@@ -726,6 +778,7 @@ run_test 'installer moves database setup to the web wizard' test_installer_moves
|
|||||||
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 compatible protection' test_systemd_unit_uses_compatible_protection
|
||||||
|
run_test 'systemd unit preserves update helper during restart' test_systemd_unit_preserves_update_helper_during_restart
|
||||||
run_test 'systemd unit uses unquoted legacy paths' test_systemd_unit_uses_unquoted_legacy_paths
|
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 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 rejects unsafe paths' test_uninstaller_rejects_unsafe_paths
|
||||||
|
|||||||
Reference in New Issue
Block a user