Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c172350200 | ||
|
|
5d7112ce31 | ||
|
|
670dfb0c7a | ||
|
|
abba079dde |
@@ -73,7 +73,7 @@ Administrator -> System Update UI -> erp-update helper
|
|||||||
克隆仓库并进入开发分支:
|
克隆仓库并进入开发分支:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone git@38.76.196.225:awaioi/ERP.git
|
git clone https://git.awaioi.com/awaioi/ERP.git
|
||||||
cd ERP
|
cd ERP
|
||||||
git switch dev
|
git switch dev
|
||||||
git config core.hooksPath .githooks
|
git config core.hooksPath .githooks
|
||||||
@@ -111,7 +111,7 @@ PostgreSQL-only 正式构建:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd oa-backend
|
cd oa-backend
|
||||||
./gradlew clean bootJar -PreleaseVersion=0.3.5 -PproductionBuild=true
|
./gradlew clean bootJar -PreleaseVersion=0.3.8 -PproductionBuild=true
|
||||||
```
|
```
|
||||||
|
|
||||||
正式 JAR 必须包含 PostgreSQL 驱动,并且不得包含 `sqlite-jdbc` 或 `hibernate-community-dialects`。
|
正式 JAR 必须包含 PostgreSQL 驱动,并且不得包含 `sqlite-jdbc` 或 `hibernate-community-dialects`。
|
||||||
@@ -168,15 +168,14 @@ https://resonant-elated-launder.ngrok-free.dev/
|
|||||||
|
|
||||||
### Linux 一键安装
|
### Linux 一键安装
|
||||||
|
|
||||||
生产环境应先为 Gitea 配置 HTTPS:
|
官方 Gitea 已启用 HTTPS,Linux 可直接执行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://git.example.com/awaioi/ERP/raw/branch/main/install.sh \
|
curl -fsSL https://git.awaioi.com/awaioi/ERP/raw/branch/main/install.sh | sudo -E bash
|
||||||
| sudo -E bash -s -- \
|
|
||||||
--gitea-url https://git.example.com \
|
|
||||||
--repository awaioi/ERP
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
安装器默认使用 `https://git.awaioi.com` 和 `awaioi/ERP`;私有镜像或分叉仓库仍可通过 `--gitea-url` 和 `--repository` 覆盖。
|
||||||
|
|
||||||
命令行只做环境准备:优先使用已有的 Java 17+,缺少时通过当前系统的 `apt-get`、`dnf`、`yum`、`zypper` 或 Homebrew 安装 Java、curl、tar、Python 3 和 OpenSSL 3,然后下载并启动独立安装器。数据库信息不在命令行输入。
|
命令行只做环境准备:优先使用已有的 Java 17+,缺少时通过当前系统的 `apt-get`、`dnf`、`yum`、`zypper` 或 Homebrew 安装 Java、curl、tar、Python 3 和 OpenSSL 3,然后下载并启动独立安装器。数据库信息不在命令行输入。
|
||||||
|
|
||||||
Linux 生产服务要求主机使用 systemd;没有 systemd 的容器、WSL 或精简系统只能显式使用 `--no-service` 做开发验收,在线更新也会保持关闭。
|
Linux 生产服务要求主机使用 systemd;没有 systemd 的容器、WSL 或精简系统只能显式使用 `--no-service` 做开发验收,在线更新也会保持关闭。
|
||||||
@@ -184,7 +183,7 @@ Linux 生产服务要求主机使用 systemd;没有 systemd 的容器、WSL
|
|||||||
安装器启动后会输出带一次性令牌的访问地址。优先级依次为:命令行 `--public-url`(或 `ERP_PUBLIC_URL`)、HTTPS 服务探测到的公网 IP、局域网 IP。无论使用哪一种方式,都会同时输出仅服务器本机可用的 `Local URL`;公网探测失败时还会明确提示正在回退局域网地址。公网服务器建议显式传入地址,避免 NAT、多网卡或代理环境识别错误:
|
安装器启动后会输出带一次性令牌的访问地址。优先级依次为:命令行 `--public-url`(或 `ERP_PUBLIC_URL`)、HTTPS 服务探测到的公网 IP、局域网 IP。无论使用哪一种方式,都会同时输出仅服务器本机可用的 `Local URL`;公网探测失败时还会明确提示正在回退局域网地址。公网服务器建议显式传入地址,避免 NAT、多网卡或代理环境识别错误:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Setup URL: http://38.76.196.225:8091/?token=<one-time-token>
|
Setup URL: https://erp.example.com/?token=<one-time-token>
|
||||||
Local URL: http://127.0.0.1:8091/?token=<one-time-token>
|
Local URL: http://127.0.0.1:8091/?token=<one-time-token>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -199,33 +198,25 @@ PostgreSQL 必须使用专用空数据库,网页中填写的账号必须是该
|
|||||||
macOS 需要 Homebrew,并使用已有 PostgreSQL:
|
macOS 需要 Homebrew,并使用已有 PostgreSQL:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://git.example.com/awaioi/ERP/raw/branch/main/install.sh \
|
curl -fsSL https://git.awaioi.com/awaioi/ERP/raw/branch/main/install.sh | bash
|
||||||
| bash -s -- \
|
|
||||||
--gitea-url https://git.example.com \
|
|
||||||
--repository awaioi/ERP
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 当前 HTTP 测试服务器
|
### 当前正式版本
|
||||||
|
|
||||||
当前 Gitea 地址 `http://38.76.196.225:10099` 只允许用于开发验收:
|
需要固定安装 `v0.3.8` 并验证引导脚本 SHA-256 时:
|
||||||
|
|
||||||
```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.5/install.sh -o "$tmp"
|
curl -fsSL https://git.awaioi.com/awaioi/ERP/raw/tag/v0.3.8/install.sh -o "$tmp"
|
||||||
printf '%s %s\n' '76917da519895bec815bb5492b12f0894e8cff26d6754cb9b2569b45e52ed83c' "$tmp" | sha256sum -c -
|
printf '%s %s\n' 'ae6a6613d4abe37ba24b41e1901eaa3b29cc32939583302c7f62277922fbbe9b' "$tmp" | sha256sum -c -
|
||||||
sudo -E bash "$tmp" \
|
sudo -E bash "$tmp" --version 0.3.8
|
||||||
--gitea-url http://38.76.196.225:10099 \
|
|
||||||
--repository awaioi/ERP \
|
|
||||||
--version 0.3.5 \
|
|
||||||
--public-url http://38.76.196.225:8091 \
|
|
||||||
--allow-insecure
|
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
只有 `v0.3.5` Release 发布后这条命令才可下载安装包。固定 tag 和 SHA-256 只用于保护当前 HTTP 引导脚本不被传输途中篡改;Release 资产仍会继续执行 Ed25519 和 SHA-256 双重校验。HTTP 会暴露请求、Release 元数据和可能使用的访问令牌,不得作为长期生产方案。
|
只有 `v0.3.8` Release 发布后这条命令才可下载安装包。引导脚本通过固定 tag 和 SHA-256 校验,Release 资产继续执行 Ed25519 和 SHA-256 双重校验。
|
||||||
|
|
||||||
### 完整卸载后重装
|
### 完整卸载后重装
|
||||||
|
|
||||||
@@ -236,7 +227,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.5/uninstall.sh -o "$tmp"
|
curl -fsSL https://git.awaioi.com/awaioi/ERP/raw/tag/v0.3.8/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
|
||||||
)
|
)
|
||||||
@@ -289,9 +280,9 @@ location / {
|
|||||||
应用定制平台 -> 系统更新
|
应用定制平台 -> 系统更新
|
||||||
```
|
```
|
||||||
|
|
||||||
入口只对 `ADMIN` 角色显示,对应前端路由为 `/appdev/update`,后端 API 为 `/api/oa/system-update/*`。页面会显示当前版本、最新版本、检查时间、Release 更新日志、发布日期、发布文件、Ed25519/SHA-256 校验方式,以及下载、验签、安装、重启和自动回滚阶段。顶部和手机入口发现新版本时会显示版本提示。
|
入口只对 `ADMIN` 角色显示,对应前端路由为 `/appdev/update`,后端 API 为 `/api/oa/system-update/*`。页面只显示当前版本、在线最新版本、检查时间、最新版本更新日志、历史正式版本,以及下载、验签、安装、重启和自动回滚进度。顶部和手机入口发现新版本时会显示版本提示。
|
||||||
|
|
||||||
“更新源设置”可在线修改启用状态、Gitea 地址、`owner/repository`、正式版/预览版通道、私有仓库 Token 和 HTTP 测试开关。设置原子写回 `/etc/kaidi-erp/erp.env`,只允许修改 `OA_UPDATE_*` 白名单,不会覆盖 PostgreSQL 密码等其他配置;Token 永不通过 API 回显,页面只显示“Token 已配置”。公开仓库无需填写 Token,正式环境应使用 HTTPS。
|
更新源由安装器写入服务器的 `/etc/kaidi-erp/erp.env`,后台页面不会要求管理员重复填写 Gitea 地址、仓库、Token、通道或 HTTP 开关。`v0.3.8` 启动时会把旧官方地址 `http://38.76.196.225:10099` 自动迁移到 `https://git.awaioi.com`;其他自定义地址保持不变。需要变更基础设施配置时由服务器运维人员修改 `OA_UPDATE_*` 环境变量并重启服务;公开仓库无需 Token,正式环境应使用 HTTPS。
|
||||||
|
|
||||||
更新过程如下:
|
更新过程如下:
|
||||||
|
|
||||||
@@ -301,13 +292,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.3.5
|
/opt/kaidi-erp/current/bin/erp-update install 0.3.8
|
||||||
```
|
```
|
||||||
|
|
||||||
应用回滚不等于数据库回滚。包含不可逆 Flyway 迁移的版本必须先保证旧应用仍兼容新结构,并建议在安装配置中启用:
|
应用回滚不等于数据库回滚。包含不可逆 Flyway 迁移的版本必须先保证旧应用仍兼容新结构,并建议在安装配置中启用:
|
||||||
@@ -335,7 +326,7 @@ Gitea 1.27 仓库需要启用 Actions,并配置带 `ubuntu-latest` 标签的
|
|||||||
流水线使用 Gitea 内置短期 `GITEA_TOKEN`,权限限定为代码只读、当前仓库 Release 可写。仓库设置只需添加:
|
流水线使用 Gitea 内置短期 `GITEA_TOKEN`,权限限定为代码只读、当前仓库 Release 可写。仓库设置只需添加:
|
||||||
|
|
||||||
- Secret `RELEASE_PRIVATE_KEY_B64`
|
- Secret `RELEASE_PRIVATE_KEY_B64`
|
||||||
- 当前纯 HTTP 测试服务器额外添加 Variable `ERP_RELEASE_ALLOW_INSECURE_HTTP=1`(Gitea 禁止变量名以保留前缀 `GITEA_` 或 `GITHUB_` 开头)
|
- `ERP_RELEASE_ALLOW_INSECURE_HTTP` 保持未设置;正式 Release API 和资产下载固定使用 HTTPS
|
||||||
|
|
||||||
签名私钥不得提交到 Git。生成 Secret 值:
|
签名私钥不得提交到 Git。生成 Secret 值:
|
||||||
|
|
||||||
@@ -348,8 +339,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.3.5 -m 'Kaidi ERP v0.3.5'
|
git tag -a v0.3.8 -m 'Kaidi ERP v0.3.8'
|
||||||
git push origin v0.3.5
|
git push origin v0.3.8
|
||||||
```
|
```
|
||||||
|
|
||||||
发布完成后必须确认 Release 页面存在四个资产,并使用仓库中的 `distribution/release-public-key.pem` 验证签名。私钥与该公钥不匹配时打包脚本会直接失败。
|
发布完成后必须确认 Release 页面存在四个资产,并使用仓库中的 `distribution/release-public-key.pem` 验证签名。私钥与该公钥不匹配时打包脚本会直接失败。
|
||||||
@@ -358,7 +349,7 @@ git push origin v0.3.5
|
|||||||
|
|
||||||
```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.3.5
|
bash scripts/package-release.sh 0.3.8
|
||||||
```
|
```
|
||||||
|
|
||||||
## 配置参考
|
## 配置参考
|
||||||
@@ -372,7 +363,7 @@ ERP_RELEASE_PRIVATE_KEY_FILE="$HOME/.config/kaidi-erp/release-signing-key.pem" \
|
|||||||
| `OA_DB_POOL_MAX` | 最大连接池 | `20` |
|
| `OA_DB_POOL_MAX` | 最大连接池 | `20` |
|
||||||
| `OA_DB_POOL_MIN` | 最小空闲连接 | `2` |
|
| `OA_DB_POOL_MIN` | 最小空闲连接 | `2` |
|
||||||
| `OA_UPDATE_ENABLED` | 启用管理后台在线更新 | 安装服务时为 `true` |
|
| `OA_UPDATE_ENABLED` | 启用管理后台在线更新 | 安装服务时为 `true` |
|
||||||
| `OA_UPDATE_GITEA_BASE_URL` | Gitea 外部地址 | 正式安装时写入 |
|
| `OA_UPDATE_GITEA_BASE_URL` | Gitea 外部地址 | `https://git.awaioi.com` |
|
||||||
| `OA_UPDATE_REPOSITORY` | Release 仓库 | `awaioi/ERP` |
|
| `OA_UPDATE_REPOSITORY` | Release 仓库 | `awaioi/ERP` |
|
||||||
| `OA_UPDATE_CHANNEL` | 更新通道 | `stable` |
|
| `OA_UPDATE_CHANNEL` | 更新通道 | `stable` |
|
||||||
| `OA_UPDATE_TOKEN` | 私有仓库下载令牌 | 空;公开仓库不需要 |
|
| `OA_UPDATE_TOKEN` | 私有仓库下载令牌 | 空;公开仓库不需要 |
|
||||||
@@ -467,7 +458,7 @@ Gitea 仓库尚未发布首个可安装版本,或 Release 缺少四个必需
|
|||||||
远程仓库:
|
远程仓库:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
git@38.76.196.225:awaioi/ERP.git
|
https://git.awaioi.com/awaioi/ERP.git
|
||||||
```
|
```
|
||||||
|
|
||||||
## 延伸文档
|
## 延伸文档
|
||||||
|
|||||||
@@ -8,6 +8,44 @@ CONFIG_FILE="${ERP_CONFIG_FILE:-$INSTALL_ROOT/config/erp.env}"
|
|||||||
say() { printf '[ERP] %s\n' "$*"; }
|
say() { printf '[ERP] %s\n' "$*"; }
|
||||||
fail() { printf '[ERP] ERROR: %s\n' "$*" >&2; exit 1; }
|
fail() { printf '[ERP] ERROR: %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
migrate_legacy_update_source() {
|
||||||
|
local legacy_url='http://38.76.196.225:10099'
|
||||||
|
local official_url='https://git.awaioi.com'
|
||||||
|
local configured_url="${OA_UPDATE_GITEA_BASE_URL:-}"
|
||||||
|
[[ "${configured_url%/}" == "$legacy_url" ]] || return 0
|
||||||
|
|
||||||
|
local temporary
|
||||||
|
temporary="$(mktemp "${CONFIG_FILE}.migration.XXXXXX")" \
|
||||||
|
|| fail 'unable to create the update-source migration file'
|
||||||
|
if awk -v official_url="$official_url" '
|
||||||
|
BEGIN { base_url_written = 0; insecure_written = 0 }
|
||||||
|
/^OA_UPDATE_GITEA_BASE_URL=/ {
|
||||||
|
if (!base_url_written) print "OA_UPDATE_GITEA_BASE_URL=\047" official_url "\047"
|
||||||
|
base_url_written = 1
|
||||||
|
next
|
||||||
|
}
|
||||||
|
/^OA_UPDATE_ALLOW_INSECURE_HTTP=/ {
|
||||||
|
if (!insecure_written) print "OA_UPDATE_ALLOW_INSECURE_HTTP=\047false\047"
|
||||||
|
insecure_written = 1
|
||||||
|
next
|
||||||
|
}
|
||||||
|
{ print }
|
||||||
|
END {
|
||||||
|
if (!base_url_written) print "OA_UPDATE_GITEA_BASE_URL=\047" official_url "\047"
|
||||||
|
if (!insecure_written) print "OA_UPDATE_ALLOW_INSECURE_HTTP=\047false\047"
|
||||||
|
}
|
||||||
|
' "$CONFIG_FILE" > "$temporary" && chmod 600 "$temporary" && mv -f "$temporary" "$CONFIG_FILE"; then
|
||||||
|
say "Update source migrated to $official_url"
|
||||||
|
else
|
||||||
|
rm -f "$temporary"
|
||||||
|
say "WARNING: could not persist the update source migration; using $official_url for this process"
|
||||||
|
fi
|
||||||
|
|
||||||
|
OA_UPDATE_GITEA_BASE_URL="$official_url"
|
||||||
|
OA_UPDATE_ALLOW_INSECURE_HTTP=false
|
||||||
|
export OA_UPDATE_GITEA_BASE_URL OA_UPDATE_ALLOW_INSECURE_HTTP
|
||||||
|
}
|
||||||
|
|
||||||
load_configuration() {
|
load_configuration() {
|
||||||
[[ -r "$CONFIG_FILE" ]] || fail "configuration not readable: $CONFIG_FILE"
|
[[ -r "$CONFIG_FILE" ]] || fail "configuration not readable: $CONFIG_FILE"
|
||||||
set -a
|
set -a
|
||||||
@@ -15,6 +53,7 @@ load_configuration() {
|
|||||||
# shellcheck disable=SC1090
|
# shellcheck disable=SC1090
|
||||||
source "$CONFIG_FILE"
|
source "$CONFIG_FILE"
|
||||||
set +a
|
set +a
|
||||||
|
migrate_legacy_update_source
|
||||||
|
|
||||||
RUN_DIR="${ERP_RUN_DIR:-$INSTALL_ROOT/run}"
|
RUN_DIR="${ERP_RUN_DIR:-$INSTALL_ROOT/run}"
|
||||||
JAR_PATH="${ERP_JAR_PATH:-$INSTALL_ROOT/current/app/kaidi-erp.jar}"
|
JAR_PATH="${ERP_JAR_PATH:-$INSTALL_ROOT/current/app/kaidi-erp.jar}"
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ REPOSITORY="${OA_UPDATE_REPOSITORY:-awaioi/ERP}"
|
|||||||
CHANNEL="${OA_UPDATE_CHANNEL:-stable}"
|
CHANNEL="${OA_UPDATE_CHANNEL:-stable}"
|
||||||
TOKEN="${OA_UPDATE_TOKEN:-}"
|
TOKEN="${OA_UPDATE_TOKEN:-}"
|
||||||
ALLOW_INSECURE="${OA_UPDATE_ALLOW_INSECURE_HTTP:-false}"
|
ALLOW_INSECURE="${OA_UPDATE_ALLOW_INSECURE_HTTP:-false}"
|
||||||
|
if [[ "${GITEA_BASE_URL%/}" == "http://38.76.196.225:10099" ]]; then
|
||||||
|
GITEA_BASE_URL="https://git.awaioi.com"
|
||||||
|
ALLOW_INSECURE=false
|
||||||
|
fi
|
||||||
PUBLIC_KEY_FILE="${ERP_UPDATE_PUBLIC_KEY_FILE:-$INSTALL_ROOT/config/release-public-key.pem}"
|
PUBLIC_KEY_FILE="${ERP_UPDATE_PUBLIC_KEY_FILE:-$INSTALL_ROOT/config/release-public-key.pem}"
|
||||||
REQUIRE_SIGNATURE="${ERP_UPDATE_REQUIRE_SIGNATURE:-true}"
|
REQUIRE_SIGNATURE="${ERP_UPDATE_REQUIRE_SIGNATURE:-true}"
|
||||||
BACKUP_MODE="${ERP_UPDATE_BACKUP_MODE:-none}"
|
BACKUP_MODE="${ERP_UPDATE_BACKUP_MODE:-none}"
|
||||||
@@ -216,12 +220,15 @@ if channel.lower() == "stable" and release.get("prerelease"):
|
|||||||
raise SystemExit("prerelease rejected on stable channel")
|
raise SystemExit("prerelease rejected on stable channel")
|
||||||
if channel.lower() == "stable" and "-" in version.split("+", 1)[0]:
|
if channel.lower() == "stable" and "-" in version.split("+", 1)[0]:
|
||||||
raise SystemExit("prerelease tag rejected on stable channel")
|
raise SystemExit("prerelease tag rejected on stable channel")
|
||||||
assets = {str(item.get("name")): str(item.get("browser_download_url") or "") for item in release.get("assets", [])}
|
assets = {
|
||||||
|
str(item.get("name") or "")
|
||||||
|
for item in release.get("assets", [])
|
||||||
|
if isinstance(item, dict)
|
||||||
|
}
|
||||||
names = [f"kaidi-erp-{version}.tar.gz", "SHA256SUMS", "SHA256SUMS.sig"]
|
names = [f"kaidi-erp-{version}.tar.gz", "SHA256SUMS", "SHA256SUMS.sig"]
|
||||||
urls = [assets.get(name, "") for name in names]
|
if any(name not in assets for name in names):
|
||||||
if any(not value or any(ch.isspace() for ch in value) for value in urls):
|
|
||||||
raise SystemExit("release assets are incomplete")
|
raise SystemExit("release assets are incomplete")
|
||||||
print("\t".join([version, *urls]))
|
print("\t".join([version, tag]))
|
||||||
PY
|
PY
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,7 +374,8 @@ install_release() {
|
|||||||
|| fail "健康检查超时时间配置无效"
|
|| fail "健康检查超时时间配置无效"
|
||||||
[[ "$HEALTH_POLL_SECONDS" =~ ^[1-9][0-9]*$ ]] \
|
[[ "$HEALTH_POLL_SECONDS" =~ ^[1-9][0-9]*$ ]] \
|
||||||
|| fail "健康检查间隔配置无效"
|
|| fail "健康检查间隔配置无效"
|
||||||
[[ "$REPOSITORY" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]] || fail "更新仓库配置无效"
|
[[ "$REPOSITORY" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,99}/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$ ]] \
|
||||||
|
|| fail "更新仓库配置无效"
|
||||||
require_command curl
|
require_command curl
|
||||||
require_command python3
|
require_command python3
|
||||||
require_command tar
|
require_command tar
|
||||||
@@ -382,9 +390,13 @@ install_release() {
|
|||||||
|
|
||||||
local selection="$TMP_DIR/selection"
|
local selection="$TMP_DIR/selection"
|
||||||
select_release "$release_json" "$requested" "$selection" || fail "Release 元数据验证失败" 10
|
select_release "$release_json" "$requested" "$selection" || fail "Release 元数据验证失败" 10
|
||||||
local archive_url sums_url signature_url
|
local release_tag release_base archive_url sums_url signature_url
|
||||||
IFS=$'\t' read -r TARGET_VERSION archive_url sums_url signature_url < "$selection"
|
IFS=$'\t' read -r TARGET_VERSION release_tag < "$selection"
|
||||||
local archive_name="kaidi-erp-${TARGET_VERSION}.tar.gz"
|
local archive_name="kaidi-erp-${TARGET_VERSION}.tar.gz"
|
||||||
|
release_base="${GITEA_BASE_URL%/}/$owner/$repo/releases/download/$release_tag"
|
||||||
|
archive_url="$release_base/$archive_name"
|
||||||
|
sums_url="$release_base/SHA256SUMS"
|
||||||
|
signature_url="$release_base/SHA256SUMS.sig"
|
||||||
local archive="$TMP_DIR/$archive_name" sums="$TMP_DIR/SHA256SUMS" signature="$TMP_DIR/SHA256SUMS.sig"
|
local archive="$TMP_DIR/$archive_name" sums="$TMP_DIR/SHA256SUMS" signature="$TMP_DIR/SHA256SUMS.sig"
|
||||||
|
|
||||||
write_state DOWNLOADING 20 "正在下载版本 ${TARGET_VERSION}" "$TARGET_VERSION"
|
write_state DOWNLOADING 20 "正在下载版本 ${TARGET_VERSION}" "$TARGET_VERSION"
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Gitea Actions runner 需要预装 Java 17 或更高版本、Node.js/npm、Python
|
|||||||
流水线使用 Gitea 1.27 提供的短期 `GITEA_TOKEN`,权限限定为代码只读、当前仓库 Release 可写,不需要创建个人访问令牌。仓库 Actions 设置只需创建:
|
流水线使用 Gitea 1.27 提供的短期 `GITEA_TOKEN`,权限限定为代码只读、当前仓库 Release 可写,不需要创建个人访问令牌。仓库 Actions 设置只需创建:
|
||||||
|
|
||||||
- Secret `RELEASE_PRIVATE_KEY_B64`:Ed25519 私钥的单行 Base64 内容。
|
- Secret `RELEASE_PRIVATE_KEY_B64`:Ed25519 私钥的单行 Base64 内容。
|
||||||
- Variable `ERP_RELEASE_ALLOW_INSECURE_HTTP`:生产环境不要设置;当前 HTTP 测试服务器必须显式设为 `1`。Gitea 不允许仓库变量名以保留前缀 `GITEA_` 或 `GITHUB_` 开头。
|
- Variable `ERP_RELEASE_ALLOW_INSECURE_HTTP`:正式 HTTPS 服务器保持未设置;仅隔离的 HTTP 开发镜像才允许设为 `1`。Gitea 不允许仓库变量名以保留前缀 `GITEA_` 或 `GITHUB_` 开头。
|
||||||
|
|
||||||
本机现有签名私钥位于 `~/.config/kaidi-erp/release-signing-key.pem`,不得提交到 Git。macOS 可用以下命令生成 Secret 值:
|
本机现有签名私钥位于 `~/.config/kaidi-erp/release-signing-key.pem`,不得提交到 Git。macOS 可用以下命令生成 Secret 值:
|
||||||
|
|
||||||
@@ -24,46 +24,43 @@ 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.3.5
|
git switch main
|
||||||
git push origin v0.3.5
|
git pull --ff-only origin main
|
||||||
|
git tag -a v0.3.8 -m 'Kaidi ERP v0.3.8'
|
||||||
|
git push origin v0.3.8
|
||||||
```
|
```
|
||||||
|
|
||||||
## 首次安装
|
## 首次安装
|
||||||
|
|
||||||
推荐给 Gitea 配置 HTTPS 域名,然后执行:
|
官方 Gitea 已启用 HTTPS,Linux 可直接执行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://git.example.com/awaioi/ERP/raw/branch/main/install.sh \
|
curl -fsSL https://git.awaioi.com/awaioi/ERP/raw/branch/main/install.sh | sudo -E bash
|
||||||
| sudo -E bash -s -- \
|
|
||||||
--gitea-url https://git.example.com \
|
|
||||||
--repository awaioi/ERP
|
|
||||||
```
|
```
|
||||||
|
|
||||||
命令行只检查并安装 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 覆盖显式公网地址。
|
安装器默认从 `https://git.awaioi.com/awaioi/ERP` 获取签名 Release。安装地址优先使用 `--public-url`(或 `ERP_PUBLIC_URL`);未指定时依次尝试探测公网 IP、回退局域网 IP,并始终额外输出 `Local URL`。ERP 已绑定独立业务域名时可显式传入例如 `--public-url https://erp.example.com`。参数支持 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,不应作为生产部署方式。
|
正式安装和更新不得启用 `--allow-insecure`。需要锁定版本时,应从固定 tag 下载引导脚本并验证本版本记录的 SHA-256。
|
||||||
|
|
||||||
当前 `v0.3.5` 安装命令:
|
当前 `v0.3.8` 安装命令:
|
||||||
|
|
||||||
```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.5/install.sh -o "$tmp"
|
curl -fsSL https://git.awaioi.com/awaioi/ERP/raw/tag/v0.3.8/install.sh -o "$tmp"
|
||||||
printf '%s %s\n' '76917da519895bec815bb5492b12f0894e8cff26d6754cb9b2569b45e52ed83c' "$tmp" | sha256sum -c -
|
printf '%s %s\n' 'ae6a6613d4abe37ba24b41e1901eaa3b29cc32939583302c7f62277922fbbe9b' "$tmp" | sha256sum -c -
|
||||||
sudo -E bash "$tmp" --gitea-url http://38.76.196.225:10099 \
|
sudo -E bash "$tmp" --version 0.3.8
|
||||||
--repository awaioi/ERP --version 0.3.5 \
|
|
||||||
--public-url http://38.76.196.225:8091 --allow-insecure
|
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -74,7 +71,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.5/uninstall.sh -o "$tmp"
|
curl -fsSL https://git.awaioi.com/awaioi/ERP/raw/tag/v0.3.8/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
|
||||||
)
|
)
|
||||||
@@ -86,20 +83,20 @@ Linux 生产服务要求主机使用 systemd;没有 systemd 的容器、WSL
|
|||||||
|
|
||||||
管理员可从顶部工具栏、用户菜单、手机导航抽屉或“应用定制平台 -> 系统更新”进入 `/appdev/update`。入口只对 `ADMIN` 角色显示;发现新版本时顶部和手机入口会显示版本提示。
|
管理员可从顶部工具栏、用户菜单、手机导航抽屉或“应用定制平台 -> 系统更新”进入 `/appdev/update`。入口只对 `ADMIN` 角色显示;发现新版本时顶部和手机入口会显示版本提示。
|
||||||
|
|
||||||
页面的“更新源设置”可保存启用状态、Gitea 地址、`owner/repository`、正式版/预览版通道、可选 Token 和 HTTP 测试开关。配置原子写回 `ERP_CONFIG_FILE` 指向的 `erp.env`,只修改 `OA_UPDATE_*` 白名单;数据库密码等字段保持不变。Token 不会通过 API 回显,公开仓库可以留空。
|
更新源由首次安装器写入 `ERP_CONFIG_FILE` 指向的 `erp.env`。更新页面不会要求管理员重复填写 Gitea 地址、仓库、Token、通道或 HTTP 开关。`v0.3.8` 会把旧官方 HTTP 地址自动迁移到 `https://git.awaioi.com`,但不会覆盖其他自定义源;需要变更基础设施参数时,由服务器运维人员修改 `OA_UPDATE_*` 环境变量并重启服务。
|
||||||
|
|
||||||
点击“保存并检查”或“检查更新”后,页面会展示当前/最新版本、发布日期、Release 更新日志、发布资产和签名校验方式。安装时持续显示下载、验签、安装、重启和回滚进度;服务重启短暂断开期间页面会自动重连。后端启动独立更新助手,更新助手会:
|
点击“检查更新”后,页面会展示当前版本、在线最新版本、发布日期、最新版本更新日志和历史正式版本记录。点击“安装并重启”后持续显示下载、验签、安装、重启和回滚进度;服务重启短暂断开期间页面会自动重连。后端启动独立更新助手,更新助手会:
|
||||||
|
|
||||||
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.3.5
|
/opt/kaidi-erp/current/bin/erp-update install 0.3.8
|
||||||
```
|
```
|
||||||
|
|
||||||
在线更新依赖安装器注册的 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` 调整。
|
||||||
|
|||||||
+21
-9
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
GITEA_BASE_URL="${ERP_GITEA_BASE_URL:-}"
|
GITEA_BASE_URL="${ERP_GITEA_BASE_URL:-https://git.awaioi.com}"
|
||||||
REPOSITORY="${ERP_UPDATE_REPOSITORY:-awaioi/ERP}"
|
REPOSITORY="${ERP_UPDATE_REPOSITORY:-awaioi/ERP}"
|
||||||
REQUESTED_VERSION="${ERP_INSTALL_VERSION:-}"
|
REQUESTED_VERSION="${ERP_INSTALL_VERSION:-}"
|
||||||
INSTALL_ROOT="${ERP_INSTALL_ROOT:-}"
|
INSTALL_ROOT="${ERP_INSTALL_ROOT:-}"
|
||||||
@@ -20,7 +20,7 @@ fail() { printf '[ERP Install] ERROR: %s\n' "$*" >&2; exit 1; }
|
|||||||
usage() {
|
usage() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
Usage: install.sh [options]
|
Usage: install.sh [options]
|
||||||
--gitea-url URL Gitea public base URL
|
--gitea-url URL Gitea public base URL (default: https://git.awaioi.com)
|
||||||
--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
|
||||||
@@ -200,7 +200,8 @@ download() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
download_release() {
|
download_release() {
|
||||||
[[ "$REPOSITORY" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]] || fail 'invalid Gitea repository'
|
[[ "$REPOSITORY" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,99}/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$ ]] \
|
||||||
|
|| fail 'invalid Gitea repository'
|
||||||
local owner="${REPOSITORY%%/*}" repo="${REPOSITORY#*/}"
|
local owner="${REPOSITORY%%/*}" repo="${REPOSITORY#*/}"
|
||||||
local release_json="$TMP_DIR/release.json"
|
local release_json="$TMP_DIR/release.json"
|
||||||
download "${GITEA_BASE_URL%/}/api/v1/repos/$owner/$repo/releases/latest" "$release_json" \
|
download "${GITEA_BASE_URL%/}/api/v1/repos/$owner/$repo/releases/latest" "$release_json" \
|
||||||
@@ -223,17 +224,20 @@ if "-" in version.split("+", 1)[0]:
|
|||||||
requested = sys.argv[2].removeprefix("v")
|
requested = sys.argv[2].removeprefix("v")
|
||||||
if requested and requested != version:
|
if requested and requested != version:
|
||||||
raise SystemExit("requested version does not match latest release")
|
raise SystemExit("requested version does not match latest release")
|
||||||
assets = {str(a.get("name")): str(a.get("browser_download_url") or "") for a in release.get("assets", [])}
|
assets = {
|
||||||
|
str(a.get("name") or "")
|
||||||
|
for a in release.get("assets", [])
|
||||||
|
if isinstance(a, dict)
|
||||||
|
}
|
||||||
names = [
|
names = [
|
||||||
f"kaidi-erp-{version}.tar.gz",
|
f"kaidi-erp-{version}.tar.gz",
|
||||||
f"kaidi-erp-installer-{version}.jar",
|
f"kaidi-erp-installer-{version}.jar",
|
||||||
"SHA256SUMS",
|
"SHA256SUMS",
|
||||||
"SHA256SUMS.sig",
|
"SHA256SUMS.sig",
|
||||||
]
|
]
|
||||||
urls = [assets.get(name, "") for name in names]
|
if any(name not in assets for name in names):
|
||||||
if any(not url or any(ch.isspace() for ch in url) for url in urls):
|
|
||||||
raise SystemExit("release assets missing")
|
raise SystemExit("release assets missing")
|
||||||
print("\t".join([version, *urls]))
|
print("\t".join([version, tag]))
|
||||||
PY
|
PY
|
||||||
then
|
then
|
||||||
local reason="invalid Release metadata"
|
local reason="invalid Release metadata"
|
||||||
@@ -241,10 +245,15 @@ PY
|
|||||||
fail "$reason"
|
fail "$reason"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local archive_url installer_url sums_url signature_url
|
local release_tag release_base archive_url installer_url sums_url signature_url
|
||||||
IFS=$'\t' read -r VERSION archive_url installer_url sums_url signature_url < "$selection"
|
IFS=$'\t' read -r VERSION release_tag < "$selection"
|
||||||
ARCHIVE_NAME="kaidi-erp-${VERSION}.tar.gz"
|
ARCHIVE_NAME="kaidi-erp-${VERSION}.tar.gz"
|
||||||
INSTALLER_NAME="kaidi-erp-installer-${VERSION}.jar"
|
INSTALLER_NAME="kaidi-erp-installer-${VERSION}.jar"
|
||||||
|
release_base="${GITEA_BASE_URL%/}/$owner/$repo/releases/download/$release_tag"
|
||||||
|
archive_url="$release_base/$ARCHIVE_NAME"
|
||||||
|
installer_url="$release_base/$INSTALLER_NAME"
|
||||||
|
sums_url="$release_base/SHA256SUMS"
|
||||||
|
signature_url="$release_base/SHA256SUMS.sig"
|
||||||
ARCHIVE_PATH="$TMP_DIR/$ARCHIVE_NAME"
|
ARCHIVE_PATH="$TMP_DIR/$ARCHIVE_NAME"
|
||||||
INSTALLER_PATH="$TMP_DIR/$INSTALLER_NAME"
|
INSTALLER_PATH="$TMP_DIR/$INSTALLER_NAME"
|
||||||
download "$archive_url" "$ARCHIVE_PATH"
|
download "$archive_url" "$ARCHIVE_PATH"
|
||||||
@@ -435,6 +444,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
|
||||||
@@ -443,6 +454,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"
|
||||||
|
|||||||
@@ -409,7 +409,7 @@ public class InstallerService {
|
|||||||
values.put("OA_DB_POOL_MIN", environment("OA_DB_POOL_MIN", "2"));
|
values.put("OA_DB_POOL_MIN", environment("OA_DB_POOL_MIN", "2"));
|
||||||
values.put("OA_SEED_DEMO", "false");
|
values.put("OA_SEED_DEMO", "false");
|
||||||
values.put("OA_UPDATE_ENABLED", environment("OA_UPDATE_ENABLED", "true"));
|
values.put("OA_UPDATE_ENABLED", environment("OA_UPDATE_ENABLED", "true"));
|
||||||
values.put("OA_UPDATE_GITEA_BASE_URL", environment("OA_UPDATE_GITEA_BASE_URL", ""));
|
values.put("OA_UPDATE_GITEA_BASE_URL", environment("OA_UPDATE_GITEA_BASE_URL", "https://git.awaioi.com"));
|
||||||
values.put("OA_UPDATE_REPOSITORY", environment("OA_UPDATE_REPOSITORY", "awaioi/ERP"));
|
values.put("OA_UPDATE_REPOSITORY", environment("OA_UPDATE_REPOSITORY", "awaioi/ERP"));
|
||||||
values.put("OA_UPDATE_CHANNEL", environment("OA_UPDATE_CHANNEL", "stable"));
|
values.put("OA_UPDATE_CHANNEL", environment("OA_UPDATE_CHANNEL", "stable"));
|
||||||
values.put("OA_UPDATE_TOKEN", environment("OA_UPDATE_TOKEN", ""));
|
values.put("OA_UPDATE_TOKEN", environment("OA_UPDATE_TOKEN", ""));
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import org.springframework.stereotype.Component;
|
|||||||
public class UpdateProperties {
|
public class UpdateProperties {
|
||||||
|
|
||||||
private boolean enabled;
|
private boolean enabled;
|
||||||
private String giteaBaseUrl = "";
|
private String giteaBaseUrl = "https://git.awaioi.com";
|
||||||
private String repository = "awaioi/ERP";
|
private String repository = "awaioi/ERP";
|
||||||
private String channel = "stable";
|
private String channel = "stable";
|
||||||
private String token = "";
|
private String token = "";
|
||||||
|
|||||||
@@ -47,9 +47,9 @@ public class SystemUpdateService {
|
|||||||
+ "(?:\\+([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_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_RELEASE_ASSETS = 32;
|
||||||
private static final int MAX_ASSET_NAME_CHARS = 255;
|
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;
|
||||||
@@ -138,6 +138,24 @@ public class SystemUpdateService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)) {
|
||||||
@@ -197,13 +215,67 @@ 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 URI releaseAssetUri(String tag, String name) {
|
||||||
|
String[] repository = properties.getRepository().split("/", 2);
|
||||||
|
if (repository.length != 2 || repository[0].isBlank() || repository[1].isBlank()) {
|
||||||
|
throw new ApiException(500, "更新仓库配置无效");
|
||||||
|
}
|
||||||
|
String base = properties.getGiteaBaseUrl().replaceAll("/+$", "");
|
||||||
|
return URI.create(base + "/" + encode(repository[0]) + "/" + encode(repository[1])
|
||||||
|
+ "/releases/download/" + encode(tag) + "/" + encode(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
@@ -221,7 +293,7 @@ public class SystemUpdateService {
|
|||||||
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, "检查更新被中断");
|
||||||
@@ -231,6 +303,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)) {
|
||||||
@@ -239,11 +327,7 @@ 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) {
|
if (!root.path("assets").isArray() || root.path("assets").size() > MAX_RELEASE_ASSETS) {
|
||||||
@@ -252,15 +336,12 @@ public class SystemUpdateService {
|
|||||||
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 name = text(node, "name");
|
||||||
String downloadUrl = text(node, "browser_download_url");
|
if (name.isBlank() || name.length() > MAX_ASSET_NAME_CHARS) {
|
||||||
if (name.isBlank() || name.length() > MAX_ASSET_NAME_CHARS
|
|
||||||
|| downloadUrl.isBlank() || downloadUrl.length() > MAX_ASSET_URL_CHARS) {
|
|
||||||
throw new ApiException(502, "Release 文件信息无效");
|
throw new ApiException(502, "Release 文件信息无效");
|
||||||
}
|
}
|
||||||
validateAssetUrl(downloadUrl);
|
|
||||||
assets.add(new ReleaseAsset(
|
assets.add(new ReleaseAsset(
|
||||||
name,
|
name,
|
||||||
downloadUrl,
|
releaseAssetUri(tag, name).toString(),
|
||||||
node.path("size").asLong(0)
|
node.path("size").asLong(0)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -268,20 +349,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String releaseNotes(JsonNode root) {
|
||||||
String notes = text(root, "body");
|
String notes = text(root, "body");
|
||||||
if (notes.length() > MAX_RELEASE_NOTES_CHARS) {
|
if (notes.length() > MAX_RELEASE_NOTES_CHARS) {
|
||||||
notes = notes.substring(0, MAX_RELEASE_NOTES_CHARS) + "\n\n[发布说明过长,已截断]";
|
return notes.substring(0, MAX_RELEASE_NOTES_CHARS) + "\n\n[发布说明过长,已截断]";
|
||||||
}
|
}
|
||||||
return new ReleaseInfo(version, notes, publishedAt, List.copyOf(assets));
|
return notes;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void requireConfigured() {
|
private void requireConfigured() {
|
||||||
@@ -604,6 +701,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,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.kaidi.oa.service.SystemUpdateConfigService;
|
|||||||
import com.kaidi.oa.service.SystemUpdateConfigService.UpdateConfig;
|
import com.kaidi.oa.service.SystemUpdateConfigService.UpdateConfig;
|
||||||
import com.kaidi.oa.service.SystemUpdateConfigService.UpdateConfigRequest;
|
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;
|
||||||
@@ -15,6 +16,8 @@ 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")
|
||||||
@@ -46,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());
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ oa:
|
|||||||
model: claude-haiku-4-5-20251001
|
model: claude-haiku-4-5-20251001
|
||||||
update:
|
update:
|
||||||
enabled: ${OA_UPDATE_ENABLED:false}
|
enabled: ${OA_UPDATE_ENABLED:false}
|
||||||
gitea-base-url: ${OA_UPDATE_GITEA_BASE_URL:}
|
gitea-base-url: ${OA_UPDATE_GITEA_BASE_URL:https://git.awaioi.com}
|
||||||
repository: ${OA_UPDATE_REPOSITORY:awaioi/ERP}
|
repository: ${OA_UPDATE_REPOSITORY:awaioi/ERP}
|
||||||
channel: ${OA_UPDATE_CHANNEL:stable}
|
channel: ${OA_UPDATE_CHANNEL:stable}
|
||||||
token: ${OA_UPDATE_TOKEN:}
|
token: ${OA_UPDATE_TOKEN:}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class SystemUpdateConfigServiceTest {
|
|||||||
SystemUpdateConfigService.UpdateConfig saved = service.save(
|
SystemUpdateConfigService.UpdateConfig saved = service.save(
|
||||||
new SystemUpdateConfigService.UpdateConfigRequest(
|
new SystemUpdateConfigService.UpdateConfigRequest(
|
||||||
true,
|
true,
|
||||||
"http://38.76.196.225:10099/",
|
"http://gitea.internal.test:10099/",
|
||||||
"awaioi/ERP",
|
"awaioi/ERP",
|
||||||
"stable",
|
"stable",
|
||||||
"new-token_123",
|
"new-token_123",
|
||||||
@@ -44,7 +44,7 @@ class SystemUpdateConfigServiceTest {
|
|||||||
|
|
||||||
String persisted = Files.readString(configFile);
|
String persisted = Files.readString(configFile);
|
||||||
assertThat(persisted).contains("OA_DB_PASSWORD='keep-this-secret'");
|
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_GITEA_BASE_URL='http://gitea.internal.test:10099'");
|
||||||
assertThat(persisted).contains("OA_UPDATE_TOKEN='new-token_123'");
|
assertThat(persisted).contains("OA_UPDATE_TOKEN='new-token_123'");
|
||||||
assertThat(saved.tokenConfigured()).isTrue();
|
assertThat(saved.tokenConfigured()).isTrue();
|
||||||
assertThat(saved.toString()).doesNotContain("new-token_123");
|
assertThat(saved.toString()).doesNotContain("new-token_123");
|
||||||
|
|||||||
@@ -14,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;
|
||||||
@@ -42,8 +43,7 @@ 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 -> {
|
||||||
String origin = "http://127.0.0.1:" + exchange.getLocalAddress().getPort();
|
byte[] body = """
|
||||||
byte[] body = ("""
|
|
||||||
{
|
{
|
||||||
"tag_name": "v0.2.0",
|
"tag_name": "v0.2.0",
|
||||||
"body": "PostgreSQL production release",
|
"body": "PostgreSQL production release",
|
||||||
@@ -51,12 +51,12 @@ class SystemUpdateServiceTest {
|
|||||||
"prerelease": false,
|
"prerelease": false,
|
||||||
"published_at": "2026-08-03T10:00:00Z",
|
"published_at": "2026-08-03T10:00:00Z",
|
||||||
"assets": [
|
"assets": [
|
||||||
{"name":"kaidi-erp-0.2.0.tar.gz","browser_download_url":"%s/app","size":123},
|
{"name":"kaidi-erp-0.2.0.tar.gz","browser_download_url":"http://legacy.invalid/app","size":123},
|
||||||
{"name":"SHA256SUMS","browser_download_url":"%s/sums","size":64},
|
{"name":"SHA256SUMS","browser_download_url":"http://legacy.invalid/sums","size":64},
|
||||||
{"name":"SHA256SUMS.sig","browser_download_url":"%s/sig","size":64}
|
{"name":"SHA256SUMS.sig","browser_download_url":"http://legacy.invalid/sig","size":64}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
""").formatted(origin, origin, origin).getBytes(StandardCharsets.UTF_8);
|
""".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 +74,89 @@ class SystemUpdateServiceTest {
|
|||||||
assertThat(status.latestVersion()).isEqualTo("0.2.0");
|
assertThat(status.latestVersion()).isEqualTo("0.2.0");
|
||||||
assertThat(status.assets()).extracting(SystemUpdateService.ReleaseAsset::name)
|
assertThat(status.assets()).extracting(SystemUpdateService.ReleaseAsset::name)
|
||||||
.containsExactly("kaidi-erp-0.2.0.tar.gz", "SHA256SUMS", "SHA256SUMS.sig");
|
.containsExactly("kaidi-erp-0.2.0.tar.gz", "SHA256SUMS", "SHA256SUMS.sig");
|
||||||
|
assertThat(status.assets()).extracting(SystemUpdateService.ReleaseAsset::downloadUrl)
|
||||||
|
.allMatch(url -> url.startsWith("http://127.0.0.1:" + server.getAddress().getPort()
|
||||||
|
+ "/awaioi/ERP/releases/download/v0.2.0/"));
|
||||||
|
} finally {
|
||||||
|
server.stop(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 {
|
} finally {
|
||||||
server.stop(0);
|
server.stop(0);
|
||||||
}
|
}
|
||||||
@@ -117,7 +200,7 @@ class SystemUpdateServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rejectsReleaseAssetsFromAnotherOrigin() throws Exception {
|
void ignoresReleaseAssetOriginsAndBuildsTrustedDownloadUrls() 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 = """
|
byte[] body = """
|
||||||
@@ -133,9 +216,11 @@ class SystemUpdateServiceTest {
|
|||||||
server.start();
|
server.start();
|
||||||
try {
|
try {
|
||||||
SystemUpdateService service = service(configuredProperties(server.getAddress().getPort()), "0.3.0");
|
SystemUpdateService service = service(configuredProperties(server.getAddress().getPort()), "0.3.0");
|
||||||
assertThatThrownBy(service::check)
|
SystemUpdateService.UpdateStatus status = service.check();
|
||||||
.isInstanceOf(ApiException.class)
|
|
||||||
.hasMessageContaining("未受信任");
|
assertThat(status.assets()).extracting(SystemUpdateService.ReleaseAsset::downloadUrl)
|
||||||
|
.allMatch(url -> url.startsWith("http://127.0.0.1:" + server.getAddress().getPort()
|
||||||
|
+ "/awaioi/ERP/releases/download/v0.4.0/"));
|
||||||
} finally {
|
} finally {
|
||||||
server.stop(0);
|
server.stop(0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -189,9 +189,8 @@ export const oaApi = {
|
|||||||
// 统一预警(全平台期限/异常聚合)
|
// 统一预警(全平台期限/异常聚合)
|
||||||
listAlerts: alertsApi.listAlerts,
|
listAlerts: alertsApi.listAlerts,
|
||||||
// 系统更新
|
// 系统更新
|
||||||
getSystemUpdateConfig: updateApi.getSystemUpdateConfig,
|
|
||||||
saveSystemUpdateConfig: updateApi.saveSystemUpdateConfig,
|
|
||||||
getSystemUpdateStatus: updateApi.getSystemUpdateStatus,
|
getSystemUpdateStatus: updateApi.getSystemUpdateStatus,
|
||||||
|
getSystemUpdateReleases: updateApi.getSystemUpdateReleases,
|
||||||
checkSystemUpdate: updateApi.checkSystemUpdate,
|
checkSystemUpdate: updateApi.checkSystemUpdate,
|
||||||
installSystemUpdate: updateApi.installSystemUpdate
|
installSystemUpdate: updateApi.installSystemUpdate
|
||||||
}
|
}
|
||||||
@@ -219,7 +218,7 @@ export type {
|
|||||||
} from './reportdefs'
|
} from './reportdefs'
|
||||||
export type { Alert } from './alerts'
|
export type { Alert } from './alerts'
|
||||||
export type {
|
export type {
|
||||||
SystemUpdateStatus, SystemUpdateConfig, SystemUpdateConfigInput, UpdatePhase, ReleaseAsset
|
SystemUpdateStatus, ReleaseHistoryItem, UpdatePhase, ReleaseAsset
|
||||||
} from './update'
|
} from './update'
|
||||||
export type {
|
export type {
|
||||||
CompanySubject, Contract, Supplier, Customer, BankAccount, Seal, Invoice, ContractMilestone
|
CompanySubject, Contract, Supplier, Customer, BankAccount, Seal, Invoice, ContractMilestone
|
||||||
|
|||||||
@@ -36,37 +36,22 @@ export interface SystemUpdateStatus {
|
|||||||
error: string | null
|
error: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SystemUpdateConfig {
|
export interface ReleaseHistoryItem {
|
||||||
enabled: boolean
|
version: string
|
||||||
giteaBaseUrl: string
|
publishedAt: string | null
|
||||||
repository: string
|
releaseNotes: string
|
||||||
channel: 'stable' | 'preview'
|
current: boolean
|
||||||
tokenConfigured: boolean
|
latest: boolean
|
||||||
allowInsecureHttp: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SystemUpdateConfigInput {
|
|
||||||
enabled: boolean
|
|
||||||
giteaBaseUrl: string
|
|
||||||
repository: string
|
|
||||||
channel: 'stable' | 'preview'
|
|
||||||
token: string
|
|
||||||
clearToken: boolean
|
|
||||||
allowInsecureHttp: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSystemUpdateConfig() {
|
|
||||||
return http.get<SystemUpdateConfig>('/system-update/config')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveSystemUpdateConfig(input: SystemUpdateConfigInput) {
|
|
||||||
return http.put<SystemUpdateConfig>('/system-update/config', input)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSystemUpdateStatus() {
|
export function getSystemUpdateStatus() {
|
||||||
return http.get<SystemUpdateStatus>('/system-update/status')
|
return http.get<SystemUpdateStatus>('/system-update/status')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,21 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import {
|
import { Check, Download, Refresh, Warning } from '@element-plus/icons-vue'
|
||||||
Check,
|
|
||||||
Connection,
|
|
||||||
Download,
|
|
||||||
Refresh,
|
|
||||||
Setting,
|
|
||||||
Warning
|
|
||||||
} from '@element-plus/icons-vue'
|
|
||||||
import ErpPageHeader from '../../../components/erp/ErpPageHeader.vue'
|
import ErpPageHeader from '../../../components/erp/ErpPageHeader.vue'
|
||||||
import {
|
import {
|
||||||
oaApi,
|
oaApi,
|
||||||
OaApiError,
|
OaApiError,
|
||||||
type SystemUpdateConfig,
|
type ReleaseHistoryItem,
|
||||||
type SystemUpdateConfigInput,
|
|
||||||
type SystemUpdateStatus,
|
type SystemUpdateStatus,
|
||||||
type UpdatePhase
|
type UpdatePhase
|
||||||
} from '../../api'
|
} from '../../api'
|
||||||
@@ -26,19 +18,10 @@ const session = useSession()
|
|||||||
const isAdmin = computed(() => session.user.value?.roles?.includes('ADMIN') === true)
|
const isAdmin = computed(() => session.user.value?.roles?.includes('ADMIN') === true)
|
||||||
|
|
||||||
const status = ref<SystemUpdateStatus | null>(null)
|
const status = ref<SystemUpdateStatus | null>(null)
|
||||||
const config = ref<SystemUpdateConfig | null>(null)
|
const releases = ref<ReleaseHistoryItem[]>([])
|
||||||
const sourceForm = reactive<SystemUpdateConfigInput>({
|
|
||||||
enabled: true,
|
|
||||||
giteaBaseUrl: '',
|
|
||||||
repository: 'awaioi/ERP',
|
|
||||||
channel: 'stable',
|
|
||||||
token: '',
|
|
||||||
clearToken: false,
|
|
||||||
allowInsecureHttp: false
|
|
||||||
})
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const historyLoading = ref(false)
|
||||||
const checking = ref(false)
|
const checking = ref(false)
|
||||||
const saving = ref(false)
|
|
||||||
const installing = ref(false)
|
const installing = ref(false)
|
||||||
const reconnecting = ref(false)
|
const reconnecting = ref(false)
|
||||||
const pollFailures = ref(0)
|
const pollFailures = ref(0)
|
||||||
@@ -66,13 +49,33 @@ const phaseLabels: Record<UpdatePhase, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const busy = computed(() => !!status.value && activePhases.has(status.value.phase))
|
const busy = computed(() => !!status.value && activePhases.has(status.value.phase))
|
||||||
const configLocked = computed(() => busy.value || reconnecting.value)
|
|
||||||
const canCheck = computed(() => Boolean(
|
const canCheck = computed(() => Boolean(
|
||||||
sourceForm.enabled && sourceForm.giteaBaseUrl && !configLocked.value
|
status.value?.configured
|
||||||
|
&& !busy.value
|
||||||
|
&& !reconnecting.value
|
||||||
|
&& !checking.value
|
||||||
|
&& !installing.value
|
||||||
))
|
))
|
||||||
const canInstall = computed(() => Boolean(
|
const canInstall = computed(() => Boolean(
|
||||||
status.value?.configured && status.value.updateAvailable && status.value.latestVersion && !busy.value
|
status.value?.configured
|
||||||
|
&& status.value.updateAvailable
|
||||||
|
&& status.value.latestVersion
|
||||||
|
&& !busy.value
|
||||||
|
&& !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 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'
|
||||||
@@ -96,36 +99,9 @@ function formatDate(value: string | null | undefined) {
|
|||||||
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`
|
|
||||||
}
|
|
||||||
|
|
||||||
function verificationLabel(name: string) {
|
|
||||||
if (name === 'SHA256SUMS.sig') return 'Ed25519 签名'
|
|
||||||
if (name === 'SHA256SUMS') return 'SHA-256 清单'
|
|
||||||
if (name.endsWith('.tar.gz') || name.endsWith('.jar')) return '签名清单校验'
|
|
||||||
return '-'
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyConfig(value: SystemUpdateConfig) {
|
|
||||||
config.value = value
|
|
||||||
sourceForm.enabled = value.enabled
|
|
||||||
sourceForm.giteaBaseUrl = value.giteaBaseUrl
|
|
||||||
sourceForm.repository = value.repository
|
|
||||||
sourceForm.channel = value.channel
|
|
||||||
sourceForm.token = ''
|
|
||||||
sourceForm.clearToken = false
|
|
||||||
sourceForm.allowInsecureHttp = value.allowInsecureHttp
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadConfig(silent = false) {
|
|
||||||
try {
|
|
||||||
applyConfig(await oaApi.getSystemUpdateConfig())
|
|
||||||
} catch (error) {
|
|
||||||
if (!silent) ElMessage.error(apiMessage(error, '更新源加载失败'))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadStatus(silent = false) {
|
async function loadStatus(silent = false) {
|
||||||
@@ -146,66 +122,33 @@ async function loadStatus(silent = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadReleases(silent = false) {
|
||||||
|
historyLoading.value = true
|
||||||
|
try {
|
||||||
|
releases.value = await oaApi.getSystemUpdateReleases()
|
||||||
|
} catch (error) {
|
||||||
|
if (!silent) ElMessage.error(apiMessage(error, '历史版本加载失败'))
|
||||||
|
} finally {
|
||||||
|
historyLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadPage() {
|
async function loadPage() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
await Promise.all([loadConfig(), loadStatus()])
|
await loadStatus()
|
||||||
|
if (status.value?.configured) await loadReleases()
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateSource() {
|
|
||||||
const baseUrl = sourceForm.giteaBaseUrl.trim()
|
|
||||||
const repository = sourceForm.repository.trim()
|
|
||||||
if (sourceForm.enabled && !baseUrl) {
|
|
||||||
ElMessage.warning('请填写 Gitea 地址')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (baseUrl && !/^https?:\/\//i.test(baseUrl)) {
|
|
||||||
ElMessage.warning('Gitea 地址必须以 https:// 或 http:// 开头')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}\/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(repository)) {
|
|
||||||
ElMessage.warning('仓库格式必须为 owner/repository')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (baseUrl.startsWith('http://') && !sourceForm.allowInsecureHttp) {
|
|
||||||
ElMessage.warning('HTTP 更新源必须显式开启允许 HTTP')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveSource(checkAfterSave: boolean) {
|
|
||||||
if (configLocked.value) {
|
|
||||||
ElMessage.warning('更新任务执行期间不能修改更新源')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!validateSource()) return
|
|
||||||
saving.value = true
|
|
||||||
try {
|
|
||||||
const saved = await oaApi.saveSystemUpdateConfig({
|
|
||||||
...sourceForm,
|
|
||||||
giteaBaseUrl: sourceForm.giteaBaseUrl.trim(),
|
|
||||||
repository: sourceForm.repository.trim(),
|
|
||||||
token: sourceForm.token.trim()
|
|
||||||
})
|
|
||||||
applyConfig(saved)
|
|
||||||
ElMessage.success('更新源已保存')
|
|
||||||
await loadStatus(true)
|
|
||||||
if (checkAfterSave && saved.enabled) await checkUpdate()
|
|
||||||
} catch (error) {
|
|
||||||
ElMessage.error(apiMessage(error, '更新源保存失败'))
|
|
||||||
} finally {
|
|
||||||
saving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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, '检查更新失败'))
|
||||||
@@ -216,18 +159,20 @@ 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
|
reconnecting.value = false
|
||||||
@@ -278,13 +223,13 @@ onBeforeUnmount(stopPolling)
|
|||||||
<ErpPageHeader
|
<ErpPageHeader
|
||||||
title="系统更新"
|
title="系统更新"
|
||||||
:crumbs="['应用定制平台', '系统更新']"
|
:crumbs="['应用定制平台', '系统更新']"
|
||||||
:description="`当前版本 ${status?.currentVersion || '-'}`"
|
:description="`当前版本 ${formatVersion(status?.currentVersion)}`"
|
||||||
>
|
>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<el-button
|
<el-button
|
||||||
:icon="Refresh"
|
:icon="Refresh"
|
||||||
:loading="checking"
|
:loading="checking"
|
||||||
:disabled="!canCheck || saving"
|
:disabled="!canCheck"
|
||||||
@click="checkUpdate"
|
@click="checkUpdate"
|
||||||
>
|
>
|
||||||
检查更新
|
检查更新
|
||||||
@@ -302,7 +247,16 @@ onBeforeUnmount(stopPolling)
|
|||||||
</ErpPageHeader>
|
</ErpPageHeader>
|
||||||
|
|
||||||
<el-alert
|
<el-alert
|
||||||
v-if="reconnecting"
|
v-if="status && !status.configured"
|
||||||
|
type="warning"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
title="服务器尚未启用在线更新"
|
||||||
|
description="请通过安装器完成正式服务配置。"
|
||||||
|
class="update-alert"
|
||||||
|
/>
|
||||||
|
<el-alert
|
||||||
|
v-else-if="reconnecting"
|
||||||
type="info"
|
type="info"
|
||||||
:closable="false"
|
:closable="false"
|
||||||
show-icon
|
show-icon
|
||||||
@@ -331,11 +285,11 @@ onBeforeUnmount(stopPolling)
|
|||||||
<section class="update-summary" aria-label="版本状态">
|
<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>
|
||||||
@@ -344,77 +298,20 @@ 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) }}</span>
|
<span>{{ formatDate(status?.checkedAt) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="source-section">
|
<section
|
||||||
<div class="section-heading">
|
v-if="status && (busy || status.phase === 'FAILED' || status.phase === 'ROLLED_BACK' || status.phase === 'SUCCEEDED')"
|
||||||
<div class="heading-title"><el-icon><Setting /></el-icon><h2>更新源设置</h2></div>
|
class="update-section"
|
||||||
<el-tag v-if="config?.tokenConfigured" type="success" effect="plain" size="small">Token 已配置</el-tag>
|
|
||||||
<el-tag v-else type="info" effect="plain" size="small">公开仓库</el-tag>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<el-form :disabled="configLocked" label-position="top" class="source-form" @submit.prevent>
|
|
||||||
<el-form-item label="在线更新">
|
|
||||||
<el-switch v-model="sourceForm.enabled" active-text="启用" inactive-text="停用" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="Gitea 地址" class="source-form__wide">
|
|
||||||
<el-input v-model="sourceForm.giteaBaseUrl" placeholder="https://git.example.com" clearable />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="仓库">
|
|
||||||
<el-input v-model="sourceForm.repository" placeholder="owner/repository" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="更新通道">
|
|
||||||
<el-select v-model="sourceForm.channel" style="width: 100%">
|
|
||||||
<el-option label="正式版" value="stable" />
|
|
||||||
<el-option label="预览版" value="preview" />
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="Gitea Token" class="source-form__wide">
|
|
||||||
<el-input
|
|
||||||
v-model="sourceForm.token"
|
|
||||||
type="password"
|
|
||||||
show-password
|
|
||||||
autocomplete="new-password"
|
|
||||||
:disabled="configLocked || sourceForm.clearToken"
|
|
||||||
:placeholder="config?.tokenConfigured ? '已配置,留空保持不变' : '公开仓库可留空'"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="HTTP 更新源">
|
|
||||||
<el-switch v-model="sourceForm.allowInsecureHttp" active-text="允许" inactive-text="禁止" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item v-if="config?.tokenConfigured" label="凭据操作">
|
|
||||||
<el-checkbox
|
|
||||||
v-model="sourceForm.clearToken"
|
|
||||||
:disabled="configLocked || Boolean(sourceForm.token)"
|
|
||||||
>
|
>
|
||||||
清除现有 Token
|
|
||||||
</el-checkbox>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
|
|
||||||
<div class="source-actions">
|
|
||||||
<el-button :loading="saving" :disabled="configLocked || checking" @click="saveSource(false)">保存设置</el-button>
|
|
||||||
<el-button
|
|
||||||
type="primary"
|
|
||||||
:icon="Connection"
|
|
||||||
:loading="saving || checking"
|
|
||||||
:disabled="configLocked"
|
|
||||||
@click="saveSource(true)"
|
|
||||||
>
|
|
||||||
保存并检查
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section v-if="status && (busy || status.phase === 'FAILED' || status.phase === 'ROLLED_BACK' || status.phase === 'SUCCEEDED')" class="update-progress">
|
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div class="heading-title">
|
<div class="heading-title">
|
||||||
<el-icon v-if="status.phase === 'FAILED' || status.phase === 'ROLLED_BACK'"><Warning /></el-icon>
|
<el-icon v-if="status.phase === 'FAILED' || status.phase === 'ROLLED_BACK'"><Warning /></el-icon>
|
||||||
<el-icon v-else><Check /></el-icon>
|
<el-icon v-else><Check /></el-icon>
|
||||||
<h2>执行结果</h2>
|
<h2>更新进度</h2>
|
||||||
</div>
|
</div>
|
||||||
<span>{{ status.progress }}%</span>
|
<span>{{ status.progress }}%</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -427,35 +324,36 @@ onBeforeUnmount(stopPolling)
|
|||||||
<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">
|
||||||
<div class="heading-title"><h2>版本更新日志</h2></div>
|
<div class="heading-title"><h2>最新版本更新日志</h2></div>
|
||||||
<span>发布日期 {{ formatDate(status?.publishedAt) }}</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"><div class="heading-title"><h2>发布文件与校验</h2></div></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="校验方式" min-width="160">
|
<article v-for="release in releases" :key="release.version" class="release-entry">
|
||||||
<template #default="{ row }">
|
<div class="release-version">
|
||||||
<el-tag
|
<strong>{{ formatVersion(release.version) }}</strong>
|
||||||
v-if="verificationLabel(row.name) !== '-'"
|
<div class="release-tags">
|
||||||
:type="row.name === 'SHA256SUMS.sig' ? 'success' : 'info'"
|
<el-tag v-if="release.current" type="primary" effect="plain" size="small">当前版本</el-tag>
|
||||||
effect="plain"
|
<el-tag v-if="release.latest" type="success" effect="plain" size="small">最新版本</el-tag>
|
||||||
size="small"
|
</div>
|
||||||
>
|
<time>{{ formatDate(release.publishedAt) }}</time>
|
||||||
{{ verificationLabel(row.name) }}
|
</div>
|
||||||
</el-tag>
|
<div class="release-notes release-notes--history">
|
||||||
<span v-else>-</span>
|
{{ release.releaseNotes || '暂无发布说明' }}
|
||||||
</template>
|
</div>
|
||||||
</el-table-column>
|
</article>
|
||||||
</el-table>
|
</div>
|
||||||
|
<el-empty v-else description="暂无历史版本" :image-size="72" />
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -504,16 +402,14 @@ onBeforeUnmount(stopPolling)
|
|||||||
font-size: var(--erp-font-size-xs);
|
font-size: var(--erp-font-size-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.source-section,
|
.update-section {
|
||||||
.update-progress,
|
|
||||||
.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,
|
.heading-title,
|
||||||
.source-actions {
|
.release-tags {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
@@ -540,24 +436,8 @@ onBeforeUnmount(stopPolling)
|
|||||||
font-size: var(--erp-font-size-xs);
|
font-size: var(--erp-font-size-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.source-form {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 140px minmax(260px, 2fr) minmax(200px, 1fr) 160px;
|
|
||||||
gap: 0 var(--erp-space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.source-form__wide {
|
|
||||||
min-width: 0;
|
|
||||||
grid-column: span 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.source-actions {
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: var(--erp-space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.release-notes {
|
.release-notes {
|
||||||
min-height: 96px;
|
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.75;
|
line-height: 1.75;
|
||||||
@@ -580,14 +460,47 @@ onBeforeUnmount(stopPolling)
|
|||||||
font-size: var(--erp-font-size-sm);
|
font-size: var(--erp-font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1080px) {
|
.release-history {
|
||||||
.source-form {
|
border-top: 1px solid var(--erp-color-border-soft);
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.source-form__wide {
|
.release-entry {
|
||||||
grid-column: span 1;
|
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) {
|
||||||
@@ -605,8 +518,7 @@ onBeforeUnmount(stopPolling)
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
.update-summary,
|
.update-summary {
|
||||||
.source-form {
|
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -625,14 +537,9 @@ onBeforeUnmount(stopPolling)
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.source-actions {
|
.release-entry {
|
||||||
align-items: stretch;
|
grid-template-columns: 1fr;
|
||||||
flex-direction: column;
|
gap: var(--erp-space-3);
|
||||||
}
|
|
||||||
|
|
||||||
.source-actions .el-button {
|
|
||||||
width: 100%;
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -Eeuo pipefail
|
set -Eeuo pipefail
|
||||||
|
|
||||||
TARGET=/root/kaidi-erp-reinstall-v031.sh
|
TARGET=/root/kaidi-erp-reinstall-v038.sh
|
||||||
URL=http://38.76.196.225:10099/awaioi/ERP/raw/tag/recovery-v0.3.1-1/reinstall-centos9.sh
|
URL=https://git.awaioi.com/awaioi/ERP/raw/tag/v0.3.8/reinstall-centos9.sh
|
||||||
SHA256=0370fb17926f3b53d5ae849310fdc6cbbf019150415365a464b0972b8edf5d9a
|
SHA256=a8496345fa5aaf6fb55180805098fb0e05bdc68b21c455b1c0b1901bba57fedd
|
||||||
|
|
||||||
curl -fsSL --connect-timeout 15 --max-time 300 "$URL" -o "$TARGET"
|
curl -fsSL --connect-timeout 15 --max-time 300 "$URL" -o "$TARGET"
|
||||||
printf '%s %s\n' "$SHA256" "$TARGET" | sha256sum -c -
|
printf '%s %s\n' "$SHA256" "$TARGET" | sha256sum -c -
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -Eeuo pipefail
|
set -Eeuo pipefail
|
||||||
|
|
||||||
GITEA_BASE_URL="http://38.76.196.225:10099"
|
GITEA_BASE_URL="https://git.awaioi.com"
|
||||||
REPOSITORY="awaioi/ERP"
|
REPOSITORY="awaioi/ERP"
|
||||||
VERSION="0.3.4"
|
VERSION="0.3.8"
|
||||||
TAG="v${VERSION}"
|
TAG="v${VERSION}"
|
||||||
INSTALL_SHA256="89a3c45e76f500c9475cb596ea29e3518bfafdc59f36dd3c7b316ed3cdd0448c"
|
INSTALL_SHA256="ae6a6613d4abe37ba24b41e1901eaa3b29cc32939583302c7f62277922fbbe9b"
|
||||||
UNINSTALL_SHA256="98c56fed2fd4d01874e4ab5a1a4f3ec42ec3e29b315ffd87385a95488d587546"
|
UNINSTALL_SHA256="98c56fed2fd4d01874e4ab5a1a4f3ec42ec3e29b315ffd87385a95488d587546"
|
||||||
PG_ROOT="${ERP_RECOVERY_PG_ROOT:-/www/server/pgsql}"
|
PG_ROOT="${ERP_RECOVERY_PG_ROOT:-/www/server/pgsql}"
|
||||||
PG_DATA="${ERP_RECOVERY_PG_DATA:-/www/server/pgsql/data}"
|
PG_DATA="${ERP_RECOVERY_PG_DATA:-/www/server/pgsql/data}"
|
||||||
@@ -96,8 +96,7 @@ main() {
|
|||||||
bash "$installer" \
|
bash "$installer" \
|
||||||
--gitea-url "$GITEA_BASE_URL" \
|
--gitea-url "$GITEA_BASE_URL" \
|
||||||
--repository "$REPOSITORY" \
|
--repository "$REPOSITORY" \
|
||||||
--version "$VERSION" \
|
--version "$VERSION"
|
||||||
--allow-insecure
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if [[ "${BASH_SOURCE[0]:-$0}" == "$0" ]]; then
|
if [[ "${BASH_SOURCE[0]:-$0}" == "$0" ]]; then
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
set -Eeuo pipefail
|
set -Eeuo pipefail
|
||||||
|
|
||||||
GITEA_URL="${GITEA_URL:-http://38.76.196.225:10099/}"
|
GITEA_URL="${GITEA_URL:-https://git.awaioi.com/}"
|
||||||
GITEA_REPOSITORY="${GITEA_REPOSITORY:-awaioi/ERP}"
|
GITEA_REPOSITORY="${GITEA_REPOSITORY:-awaioi/ERP}"
|
||||||
RUNNER_IMAGE="${RUNNER_IMAGE:-}"
|
RUNNER_IMAGE="${RUNNER_IMAGE:-}"
|
||||||
RUNNER_CONTAINER="${RUNNER_CONTAINER:-gitea-runner}"
|
RUNNER_CONTAINER="${RUNNER_CONTAINER:-gitea-runner}"
|
||||||
|
|||||||
@@ -56,6 +56,34 @@ test_erp_run_preserves_java_option_arguments() (
|
|||||||
grep -Fqx -- "$tmp/current/app/kaidi-erp.jar" <<< "$output"
|
grep -Fqx -- "$tmp/current/app/kaidi-erp.jar" <<< "$output"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
test_erp_run_migrates_legacy_update_source() (
|
||||||
|
local tmp
|
||||||
|
tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-source-migration.XXXXXX")" || return 1
|
||||||
|
trap 'rm -rf "$tmp"' EXIT
|
||||||
|
mkdir -p "$tmp/current/app" "$tmp/run" "$tmp/state"
|
||||||
|
: > "$tmp/current/app/kaidi-erp.jar"
|
||||||
|
: > "$tmp/state/install.lock"
|
||||||
|
printf '%s\n' \
|
||||||
|
'#!/usr/bin/env bash' \
|
||||||
|
'if [[ "${1:-}" == "-version" ]]; then printf '\''openjdk version "17.0.12"\n'\'' >&2; fi' \
|
||||||
|
'exit 0' > "$tmp/java"
|
||||||
|
chmod +x "$tmp/java"
|
||||||
|
{
|
||||||
|
printf 'ERP_JAVA_BIN=%q\n' "$tmp/java"
|
||||||
|
printf 'ERP_RUN_DIR=%q\n' "$tmp/run"
|
||||||
|
printf "OA_UPDATE_GITEA_BASE_URL='http://38.76.196.225:10099/'\n"
|
||||||
|
printf "OA_UPDATE_ALLOW_INSECURE_HTTP='true'\n"
|
||||||
|
} > "$tmp/erp.env"
|
||||||
|
chmod 600 "$tmp/erp.env"
|
||||||
|
|
||||||
|
ERP_INSTALL_ROOT="$tmp" ERP_CONFIG_FILE="$tmp/erp.env" \
|
||||||
|
"$PROJECT_ROOT/distribution/bin/erp-run" >/dev/null 2>&1 || return 1
|
||||||
|
|
||||||
|
grep -Fqx "OA_UPDATE_GITEA_BASE_URL='https://git.awaioi.com'" "$tmp/erp.env" || return 1
|
||||||
|
grep -Fqx "OA_UPDATE_ALLOW_INSECURE_HTTP='false'" "$tmp/erp.env" || return 1
|
||||||
|
! grep -Fq '38.76.196.225' "$tmp/erp.env"
|
||||||
|
)
|
||||||
|
|
||||||
test_erp_run_finalizes_healthy_pending_install() (
|
test_erp_run_finalizes_healthy_pending_install() (
|
||||||
local tmp
|
local tmp
|
||||||
tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-handoff-success.XXXXXX")" || return 1
|
tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-handoff-success.XXXXXX")" || return 1
|
||||||
@@ -295,22 +323,23 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
if self.path == "/api/v1/repos/awaioi/ERP/releases/latest":
|
if self.path == "/api/v1/repos/awaioi/ERP/releases/latest":
|
||||||
base = f"http://127.0.0.1:{self.server.server_port}/assets"
|
legacy = "http://legacy.invalid/assets"
|
||||||
payload = {
|
payload = {
|
||||||
"tag_name": "v" + version,
|
"tag_name": "v" + version,
|
||||||
"draft": False,
|
"draft": False,
|
||||||
"prerelease": False,
|
"prerelease": False,
|
||||||
"assets": [
|
"assets": [
|
||||||
{"name": archive, "browser_download_url": base + "/" + archive},
|
{"name": archive, "browser_download_url": legacy + "/" + archive},
|
||||||
{"name": f"kaidi-erp-installer-{version}.jar", "browser_download_url": base + f"/kaidi-erp-installer-{version}.jar"},
|
{"name": f"kaidi-erp-installer-{version}.jar", "browser_download_url": legacy + f"/kaidi-erp-installer-{version}.jar"},
|
||||||
{"name": "SHA256SUMS", "browser_download_url": base + "/SHA256SUMS"},
|
{"name": "SHA256SUMS", "browser_download_url": legacy + "/SHA256SUMS"},
|
||||||
{"name": "SHA256SUMS.sig", "browser_download_url": base + "/SHA256SUMS.sig"},
|
{"name": "SHA256SUMS.sig", "browser_download_url": legacy + "/SHA256SUMS.sig"},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
self.send_bytes(200, json.dumps(payload).encode(), "application/json")
|
self.send_bytes(200, json.dumps(payload).encode(), "application/json")
|
||||||
return
|
return
|
||||||
if self.path.startswith("/assets/"):
|
release_prefix = f"/awaioi/ERP/releases/download/v{version}/"
|
||||||
name = self.path.removeprefix("/assets/")
|
if self.path.startswith(release_prefix):
|
||||||
|
name = self.path.removeprefix(release_prefix)
|
||||||
if name not in {archive, f"kaidi-erp-installer-{version}.jar", "SHA256SUMS", "SHA256SUMS.sig"}:
|
if name not in {archive, f"kaidi-erp-installer-{version}.jar", "SHA256SUMS", "SHA256SUMS.sig"}:
|
||||||
self.send_bytes(404, b"not found", "text/plain")
|
self.send_bytes(404, b"not found", "text/plain")
|
||||||
return
|
return
|
||||||
@@ -409,15 +438,15 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
if self.path == "/api/v1/repos/awaioi/ERP/releases/latest":
|
if self.path == "/api/v1/repos/awaioi/ERP/releases/latest":
|
||||||
base = f"http://127.0.0.1:{self.server.server_port}"
|
legacy = "http://legacy.invalid"
|
||||||
payload = {
|
payload = {
|
||||||
"tag_name": "v2.0.0",
|
"tag_name": "v2.0.0",
|
||||||
"draft": False,
|
"draft": False,
|
||||||
"prerelease": False,
|
"prerelease": False,
|
||||||
"assets": [
|
"assets": [
|
||||||
{"name": "kaidi-erp-2.0.0.tar.gz", "browser_download_url": base + "/assets/kaidi-erp-2.0.0.tar.gz"},
|
{"name": "kaidi-erp-2.0.0.tar.gz", "browser_download_url": legacy + "/assets/kaidi-erp-2.0.0.tar.gz"},
|
||||||
{"name": "SHA256SUMS", "browser_download_url": base + "/assets/SHA256SUMS"},
|
{"name": "SHA256SUMS", "browser_download_url": legacy + "/assets/SHA256SUMS"},
|
||||||
{"name": "SHA256SUMS.sig", "browser_download_url": base + "/assets/SHA256SUMS.sig"},
|
{"name": "SHA256SUMS.sig", "browser_download_url": legacy + "/assets/SHA256SUMS.sig"},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
self.send_bytes(200, json.dumps(payload).encode(), "application/json")
|
self.send_bytes(200, json.dumps(payload).encode(), "application/json")
|
||||||
@@ -427,8 +456,9 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
healthy = current == "2.0.0" if mode == "success" else current == "1.0.0"
|
healthy = current == "2.0.0" if mode == "success" else current == "1.0.0"
|
||||||
self.send_bytes(200 if healthy else 503, b'{"status":"UP"}' if healthy else b'{"status":"DOWN"}', "application/json")
|
self.send_bytes(200 if healthy else 503, b'{"status":"UP"}' if healthy else b'{"status":"DOWN"}', "application/json")
|
||||||
return
|
return
|
||||||
if self.path.startswith("/assets/"):
|
release_prefix = "/awaioi/ERP/releases/download/v2.0.0/"
|
||||||
name = self.path.removeprefix("/assets/")
|
if self.path.startswith(release_prefix):
|
||||||
|
name = self.path.removeprefix(release_prefix)
|
||||||
if name not in {"kaidi-erp-2.0.0.tar.gz", "SHA256SUMS", "SHA256SUMS.sig"}:
|
if name not in {"kaidi-erp-2.0.0.tar.gz", "SHA256SUMS", "SHA256SUMS.sig"}:
|
||||||
self.send_bytes(404, b"not found", "text/plain")
|
self.send_bytes(404, b"not found", "text/plain")
|
||||||
return
|
return
|
||||||
@@ -562,17 +592,15 @@ test_installer_moves_database_setup_to_web_wizard() (
|
|||||||
[[ "$status" -ne 0 && "$output" == *'unknown option: --db-mode'* ]]
|
[[ "$status" -ne 0 && "$output" == *'unknown option: --db-mode'* ]]
|
||||||
)
|
)
|
||||||
|
|
||||||
test_installer_requires_explicit_gitea_url() (
|
test_installer_defaults_to_official_gitea_url() (
|
||||||
local output status=0
|
local output
|
||||||
output="$(ERP_GITEA_BASE_URL= bash -c '
|
output="$(ERP_GITEA_BASE_URL= bash -c '
|
||||||
root="$1"
|
root="$1"
|
||||||
set --
|
set --
|
||||||
source "$root/install.sh"
|
source "$root/install.sh"
|
||||||
GITEA_BASE_URL=
|
printf "%s\n" "$GITEA_BASE_URL"
|
||||||
NO_SERVICE=1
|
' _ "$PROJECT_ROOT")" || return 1
|
||||||
main
|
[[ "$output" == 'https://git.awaioi.com' ]]
|
||||||
' _ "$PROJECT_ROOT" 2>&1)" || status=$?
|
|
||||||
[[ "$status" -ne 0 && "$output" == *'Gitea URL is required'* ]]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
test_linux_service_preflight_requires_systemd() (
|
test_linux_service_preflight_requires_systemd() (
|
||||||
@@ -593,6 +621,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")"
|
||||||
@@ -732,7 +767,8 @@ test_release_workflow_uses_scoped_job_token() (
|
|||||||
|
|
||||||
test_installer_runs_when_piped_to_bash() (
|
test_installer_runs_when_piped_to_bash() (
|
||||||
local output status=0
|
local output status=0
|
||||||
output="$(ERP_GITEA_BASE_URL= bash -s -- --no-service < "$PROJECT_ROOT/install.sh" 2>&1)" || status=$?
|
output="$(bash -s -- --no-service --gitea-url invalid://example.test \
|
||||||
|
< "$PROJECT_ROOT/install.sh" 2>&1)" || status=$?
|
||||||
[[ "$status" -ne 0 ]]
|
[[ "$status" -ne 0 ]]
|
||||||
[[ "$output" == *'[ERP Install] ERROR:'* ]]
|
[[ "$output" == *'[ERP Install] ERROR:'* ]]
|
||||||
[[ "$output" != *'BASH_SOURCE'* ]]
|
[[ "$output" != *'BASH_SOURCE'* ]]
|
||||||
@@ -756,6 +792,7 @@ test_app_honors_reverse_proxy_headers() (
|
|||||||
)
|
)
|
||||||
|
|
||||||
run_test 'erp-run preserves Java option arguments' test_erp_run_preserves_java_option_arguments
|
run_test 'erp-run preserves Java option arguments' test_erp_run_preserves_java_option_arguments
|
||||||
|
run_test 'erp-run migrates the legacy Gitea update source' test_erp_run_migrates_legacy_update_source
|
||||||
run_test 'erp-run finalizes a healthy pending installation' test_erp_run_finalizes_healthy_pending_install
|
run_test 'erp-run finalizes a healthy pending installation' test_erp_run_finalizes_healthy_pending_install
|
||||||
run_test 'erp-run preserves a failed pending installation' test_erp_run_preserves_failed_pending_install
|
run_test 'erp-run preserves a failed pending installation' test_erp_run_preserves_failed_pending_install
|
||||||
run_test 'installer accepts a correctly signed archive' test_installer_verifies_signed_safe_archive
|
run_test 'installer accepts a correctly signed archive' test_installer_verifies_signed_safe_archive
|
||||||
@@ -768,9 +805,10 @@ run_test 'update helper installs a healthy release end to end' test_update_helpe
|
|||||||
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
|
||||||
run_test 'update helper rejects a concurrent process' test_update_lock_rejects_concurrent_process
|
run_test 'update helper rejects a concurrent process' test_update_lock_rejects_concurrent_process
|
||||||
run_test 'installer moves database setup to the web wizard' test_installer_moves_database_setup_to_web_wizard
|
run_test 'installer moves database setup to the web wizard' test_installer_moves_database_setup_to_web_wizard
|
||||||
run_test 'installer requires an explicit Gitea URL' test_installer_requires_explicit_gitea_url
|
run_test 'installer defaults to the official Gitea URL' test_installer_defaults_to_official_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