22 Commits
Author SHA1 Message Date
Qiufeng 670dfb0c7a fix: preserve updater across systemd restart
Signed Release / release (push) Successful in 10m18s
2026-08-04 22:31:20 +08:00
Qiufeng abba079dde feat: simplify online update center
Signed Release / release (push) Successful in 9m41s
2026-08-04 21:25:43 +08:00
Qiufeng d3892320dd feat: add managed online updates
Signed Release / release (push) Successful in 9m24s
2026-08-04 20:05:50 +08:00
Qiufeng 4f2ea26e8e ops: point CentOS recovery at v0.3.4 2026-08-04 17:44:25 +08:00
Qiufeng e295ad8b96 ci: provision Java for signed releases
Signed Release / release (push) Successful in 17m17s
2026-08-04 17:18:25 +08:00
Qiufeng 99b8126b59 test: isolate release checks across platforms
Signed Release / release (push) Failing after 35s
2026-08-04 17:11:45 +08:00
Qiufeng b472581622 fix: pull runner from Gitea registry 2026-08-04 16:14:59 +08:00
Qiufeng 8fe558f6c1 ops: add one-command Gitea runner setup 2026-08-04 15:59:52 +08:00
Qiufeng 5e6df4b323 fix: surface install progress behind reverse proxies
Signed Release / release (push) Failing after 32s
2026-08-04 15:07:39 +08:00
Qiufeng c9d5d678dc fix: add short CentOS recovery bootstrap 2026-08-04 14:27:38 +08:00
Qiufeng e21978c25c fix: add CentOS 9 reinstall recovery 2026-08-04 14:15:43 +08:00
Qiufeng f9545a9d0f fix: harden first-run install and clean reinstall
Signed Release / release (push) Failing after 33s
2026-08-04 12:59:24 +08:00
Qiufeng 4d6a9307e5 fix: support legacy systemd path parsing 2026-08-04 10:03:18 +08:00
Qiufeng b792f3f21b fix: support older systemd hardening settings 2026-08-04 09:43:34 +08:00
Qiufeng 76a0f1ffbd docs: document Linux service requirement
Signed Release / release (push) Failing after 23s
2026-08-04 08:42:48 +08:00
Qiufeng 70dd8e1d6b test: cover installer asset download
Signed Release / release (push) Failing after 20s
2026-08-04 08:37:52 +08:00
Qiufeng 6e22b24996 fix: preflight Linux service manager
Signed Release / release (push) Failing after 23s
2026-08-04 08:28:15 +08:00
Qiufeng 1affcd9a5e feat: add PostgreSQL web installation wizard
Signed Release / release (push) Failing after 5m1s
2026-08-04 07:42:20 +08:00
Qiufeng b714f9851e fix: use valid Gitea actions variable 2026-08-04 06:27:50 +08:00
Qiufeng 3e0adfc0bf docs: add project README
Signed Release / release (push) Failing after 20s
2026-08-04 05:58:48 +08:00
Qiufeng 882d8f559c fix: support piped installer execution 2026-08-04 00:33:16 +08:00
Qiufeng a604460542 ci: use scoped Gitea release token 2026-08-04 00:06:55 +08:00
80 changed files with 5061 additions and 533 deletions
+46 -9
View File
@@ -5,14 +5,18 @@ on:
tags:
- "v*"
permissions:
contents: read
releases: write
jobs:
release:
runs-on: ubuntu-latest
env:
GITEA_BASE_URL: ${{ vars.GITEA_BASE_URL }}
GITEA_BASE_URL: ${{ github.server_url }}
GITEA_REPOSITORY: ${{ github.repository }}
GITEA_ALLOW_INSECURE_HTTP: ${{ vars.GITEA_ALLOW_INSECURE_HTTP }}
GITEA_TOKEN: ${{ secrets.RELEASE_GITEA_TOKEN }}
GITEA_ALLOW_INSECURE_HTTP: ${{ vars.ERP_RELEASE_ALLOW_INSECURE_HTTP }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
RELEASE_PRIVATE_KEY_B64: ${{ secrets.RELEASE_PRIVATE_KEY_B64 }}
NODE_OPTIONS: --max-old-space-size=8192
steps:
@@ -21,6 +25,12 @@ jobs:
with:
fetch-depth: 0
- name: Set up Java 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- name: Build, sign, and publish release
shell: bash
run: |
@@ -44,18 +54,40 @@ jobs:
esac
[[ "$repository" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]] \
|| { printf 'invalid GITEA_REPOSITORY\n' >&2; exit 1; }
[[ -n "${GITEA_TOKEN:-}" ]] || { printf 'missing RELEASE_GITEA_TOKEN secret\n' >&2; exit 1; }
[[ -n "${GITEA_TOKEN:-}" ]] || { printf 'missing built-in GITEA_TOKEN\n' >&2; exit 1; }
[[ -n "${RELEASE_PRIVATE_KEY_B64:-}" ]] \
|| { printf 'missing RELEASE_PRIVATE_KEY_B64 secret\n' >&2; exit 1; }
umask 077
key_file="${RUNNER_TEMP:-/tmp}/kaidi-erp-release-key.pem"
cleanup() { rm -f "$key_file" release.json release-payload.json; }
cleanup() { rm -f "$key_file" release.json release-payload.json release-notes.md; }
trap cleanup EXIT
printf '%s' "$RELEASE_PRIVATE_KEY_B64" | base64 --decode > "$key_file"
export ERP_RELEASE_PRIVATE_KEY_FILE="$key_file"
bash tests/release-scripts.test.sh
(
cd oa-backend
./gradlew test installerTest
)
bash scripts/package-release.sh "$version"
previous_tag="$(git describe --tags --match 'v[0-9]*' --abbrev=0 "${tag}^" 2>/dev/null || true)"
change_range="$tag"
[[ -z "$previous_tag" ]] || change_range="$previous_tag..$tag"
{
printf '## 更新内容\n\n'
if ! git log --no-merges --format='- %s (`%h`)' "$change_range"; then
printf -- '- Kaidi ERP %s 正式发布\n' "$version"
fi
printf '\n## 安全校验\n\n'
printf -- '- 安装包:`kaidi-erp-%s.tar.gz`\n' "$version"
printf -- '- 完整性:SHA-256\n'
printf -- '- 发布签名:Ed25519\n'
if [[ -n "$previous_tag" ]]; then
printf '\n上一个正式版本:`%s`\n' "$previous_tag"
fi
} > release-notes.md
owner="${repository%%/*}"
repo="${repository#*/}"
release_api="${api_base%/}/api/v1/repos/$owner/$repo/releases"
@@ -65,13 +97,15 @@ jobs:
--header "$auth_header" --header 'Accept: application/json' \
"$release_api/tags/$tag")"
python3 - "$tag" > release-payload.json <<'PY'
python3 - "$tag" release-notes.md > release-payload.json <<'PY'
import json, sys
tag = sys.argv[1]
with open(sys.argv[2], encoding="utf-8") as handle:
notes = handle.read().strip()
print(json.dumps({
"tag_name": tag,
"name": tag,
"body": f"Kaidi ERP {tag}",
"name": f"Kaidi ERP {tag}",
"body": notes,
"draft": False,
"prerelease": "-" in tag.split("+", 1)[0],
}, separators=(",", ":")))
@@ -93,7 +127,10 @@ jobs:
fi
release_id="$(python3 -c 'import json; print(json.load(open("release.json"))["id"])')"
for asset in "dist/kaidi-erp-$version.tar.gz" dist/SHA256SUMS dist/SHA256SUMS.sig; do
curl "${curl_protocols[@]}" --fail-with-body --silent --show-error --location --retry 3 \
--header "$auth_header" --header 'Accept: application/json' \
--output release.json "$release_api/$release_id"
for asset in "dist/kaidi-erp-$version.tar.gz" "dist/kaidi-erp-installer-$version.jar" dist/SHA256SUMS dist/SHA256SUMS.sig; do
name="$(basename "$asset")"
encoded_name="$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$name")"
existing_id="$(python3 - "$name" <<'PY'
+482
View File
@@ -0,0 +1,482 @@
# 凯迪 ERP + OA 一体化平台
凯迪 ERP + OA 是面向企业内部运营的综合业务平台,覆盖组织协同、审批、合同、采购、库存、财务、人力、项目、制造、运营、档案、审计和系统管理等工作域。前端采用 Vue 3 + TypeScript + Element Plus,后端采用 Spring Boot 3.2 + Spring Data JPA。
> 生产部署固定使用 PostgreSQL 15 或更高版本,不使用 Docker。源码目录保留的 SQLite 配置只用于本地开发和测试;正式 Release JAR 会排除 SQLite 驱动和社区方言。
## 项目现状
- 业务范围覆盖 29 个机构/部门,包含约 700 个 Vue 业务页面和大规模 REST/JPA 模型。
- 当前有效后端位于 `oa-backend/`
- 当前有效前端位于 `ofbiz-framework/plugins/modern-ui/app/`;其上层旧 OFBiz 运行框架已经弃用。
- 前端构建产物直接写入后端 `src/main/resources/static/`,由同一个 Spring Boot JAR 提供页面与 `/api/oa/*` API。
- 已实现非 Docker 一键安装、PostgreSQL Flyway 迁移、独立网页安装向导、Gitea Release 更新、Ed25519/SHA-256 校验、服务重启、健康检查和自动回滚。
- 业务功能和外部系统接入仍需按项目需求验收;技术部署链路通过不等于可以跳过生产安全、备份和数据迁移评审。
## 两种运行模式
| 模式 | 数据库 | 用途 | 关键约束 |
|---|---|---|---|
| 源码开发 | SQLite 或 PostgreSQL | 本机开发、界面调试、自动化测试 | SQLite 仅为零配置开发选项,不代表在线环境 |
| 正式安装 | PostgreSQL 15+ | 服务器部署、在线更新 | 使用签名 Release、systemd/launchd,生产 JAR 不含 SQLite |
## 架构
```text
Browser / Mobile Web
|
v
Spring Boot 3.2
|- Vue 3 static application
|- /api/oa/* REST API
|- authentication and permission gates
|- system update administration API
|
v
PostgreSQL 15+ <--- Flyway migrations
Administrator -> System Update UI -> erp-update helper
|- Gitea Release API
|- Ed25519 + SHA-256 verification
|- atomic current symlink switch
`- health check and rollback
```
## 仓库结构
```text
.
|- README.md
|- run.command # macOS 本地预览与 ngrok 启动器
|- install.sh # 非 Docker 一键安装器
|- uninstall.sh # Linux 完整卸载与数据库 schema 重置
|- distribution/
| |- bin/erp-run # 正式环境应用启动器
| `- bin/erp-update # 下载、校验、切换和回滚助手
|- scripts/package-release.sh # PostgreSQL-only Release 打包与签名
|- .gitea/workflows/release.yml # v* tag 发布流水线
|- oa-backend/ # Spring Boot 后端
| `- src/main/resources/
| |- application.yml # 源码开发默认配置
| |- application-postgres.yml # 正式 PostgreSQL profile
| `- db/migration/postgresql/ # Flyway 迁移
|- ofbiz-framework/plugins/modern-ui/app/
| |- src/data/oaModules.ts # 导航与路由数据源
| `- src/oa/pages/ # Vue 业务页面
|- tests/ # 启动器和 Release 脚本测试
|- docs/ # 部署、设计和实现文档
`- requirements/ # 原始需求与合规审计材料
```
## 本地构建
克隆仓库并进入开发分支:
```bash
git clone git@38.76.196.225:awaioi/ERP.git
cd ERP
git switch dev
git config core.hooksPath .githooks
```
### 环境要求
- Java 17 或更高版本
- Node.js 和 npm
- Python 3、curl、lsof
- 使用公网预览时需要已登录并配置好的 ngrok 3
- PostgreSQL 模式需要 PostgreSQL 15+ 以及可连接的数据库账号
### 1. 构建前端
```bash
cd ofbiz-framework/plugins/modern-ui/app
npm ci
NODE_OPTIONS=--max-old-space-size=8192 npm run build
```
Vite 会清空并重新生成 `oa-backend/src/main/resources/static/`。修改前端后必须先执行前端构建,再执行 `bootJar`,否则 JAR 中仍是旧页面。
### 2. 构建后端
开发构建保留 SQLite 运行库:
```bash
cd oa-backend
./gradlew test
./gradlew bootJar
```
PostgreSQL-only 正式构建:
```bash
cd oa-backend
./gradlew clean bootJar -PreleaseVersion=0.3.7 -PproductionBuild=true
```
正式 JAR 必须包含 PostgreSQL 驱动,并且不得包含 `sqlite-jdbc``hibernate-community-dialects`
## 一键启动本地预览
根目录的 `run.command` 会检查环境、启动或复用 ERP 后端、启动或复用固定 ngrok 隧道、验证本地登录和公网页面,然后打开浏览器。它不会安装 PostgreSQL、构建前端或生成 JAR。
```bash
chmod +x run.command
./run.command
```
macOS 也可以在 Finder 中双击 `run.command`。终端保持运行用于监控服务,按 `Ctrl+C` 只会停止本次启动器自己创建的进程,不会批量终止其他 Java 进程。
常用覆盖项:
| 环境变量 | 默认值 | 说明 |
|---|---|---|
| `ERP_RUN_BACKEND_PORT` | `8091` | 本地 Spring Boot 端口 |
| `ERP_RUN_JAR_PATH` | 最新的 `oa-backend-*.jar` | 指定要运行的 JAR |
| `ERP_RUN_PROFILE` | 当前环境的 Spring profile | 正式环境设为 `postgres` |
| `ERP_RUN_CONFIG_FILE` | 空 | 读取安装器生成的 `erp.env` |
| `ERP_RUN_PUBLIC_URL` | 固定 ngrok 域名 | 公网预览地址 |
| `ERP_RUN_NGROK_API_PORT` | `4040` | ngrok 本地管理端口 |
| `ERP_RUN_NO_OPEN` | `0` | 设为 `1` 时不自动打开浏览器 |
直接以 PostgreSQL profile 运行源码构建时:
```bash
export SPRING_PROFILES_ACTIVE=postgres
export OA_DB_URL='jdbc:postgresql://127.0.0.1:5432/kaidi_erp?sslmode=disable'
export OA_DB_USERNAME='kaidi_erp'
export OA_DB_PASSWORD='replace-with-a-strong-password'
./run.command
```
默认本地地址为 `http://127.0.0.1:8091`。开发预览固定域名为:
```text
https://resonant-elated-launder.ngrok-free.dev/
```
演示环境初始账号为 `admin / 123456`。任何共享或生产环境都必须立即更换默认密码,并限制公网访问。
## 非 Docker 正式安装
安装器支持 64 位 Linux `amd64/arm64` 和 macOS `arm64/amd64`。Linux 安装需要 rootmacOS 不应使用 sudo 启动 LaunchAgent。正式安装前必须已有包含下列四个资产的 Gitea Release
- `kaidi-erp-<version>.tar.gz`
- `kaidi-erp-installer-<version>.jar`
- `SHA256SUMS`
- `SHA256SUMS.sig`
### Linux 一键安装
生产环境应先为 Gitea 配置 HTTPS
```bash
curl -fsSL https://git.example.com/awaioi/ERP/raw/branch/main/install.sh \
| sudo -E bash -s -- \
--gitea-url https://git.example.com \
--repository awaioi/ERP
```
命令行只做环境准备:优先使用已有的 Java 17+,缺少时通过当前系统的 `apt-get``dnf``yum``zypper` 或 Homebrew 安装 Java、curl、tar、Python 3 和 OpenSSL 3,然后下载并启动独立安装器。数据库信息不在命令行输入。
Linux 生产服务要求主机使用 systemd;没有 systemd 的容器、WSL 或精简系统只能显式使用 `--no-service` 做开发验收,在线更新也会保持关闭。
安装器启动后会输出带一次性令牌的访问地址。优先级依次为:命令行 `--public-url`(或 `ERP_PUBLIC_URL`)、HTTPS 服务探测到的公网 IP、局域网 IP。无论使用哪一种方式,都会同时输出仅服务器本机可用的 `Local URL`;公网探测失败时还会明确提示正在回退局域网地址。公网服务器建议显式传入地址,避免 NAT、多网卡或代理环境识别错误:
```text
Setup URL: http://38.76.196.225:8091/?token=<one-time-token>
Local URL: http://127.0.0.1:8091/?token=<one-time-token>
```
`--public-url` 支持域名、端口、路径和已有查询参数,安装器会安全追加一次性 `token`。使用公网 IP 直连时需要在防火墙或安全组放行 ERP 端口;通过 HTTPS 反向代理安装时,应将公开域名作为 `--public-url`
首次打开该地址进入网页向导,依次完成环境检查、PostgreSQL 地址/端口/库名/账号/密码/SSL 测试、管理员账号/姓名/密码设置、数据库迁移和初始化。项目当前没有 Redis 依赖,因此向导不会显示 Redis 配置项。正式服务真实健康检查通过后,启动器才会原子写入安装锁并物理删除 `installer/``install.pending`
PostgreSQL 必须使用专用空数据库,网页中填写的账号必须是该数据库的所有者。该约束保证账号拥有 `public` schema 建表权限,并能持有安装器创建的 `pg_trgm` 扩展;只授予 `CONNECT` 权限不足以完成迁移。
### macOS
macOS 需要 Homebrew,并使用已有 PostgreSQL
```bash
curl -fsSL https://git.example.com/awaioi/ERP/raw/branch/main/install.sh \
| bash -s -- \
--gitea-url https://git.example.com \
--repository awaioi/ERP
```
### 当前 HTTP 测试服务器
当前 Gitea 地址 `http://38.76.196.225:10099` 只允许用于开发验收:
```bash
(
set -e
tmp="$(mktemp)"
trap 'rm -f -- "$tmp"' EXIT
curl -fsSL http://38.76.196.225:10099/awaioi/ERP/raw/tag/v0.3.7/install.sh -o "$tmp"
printf '%s %s\n' '88328b3086ece360d3c05d4a22bee06b03ed3639a454c1fe7b9c04b1da80a980' "$tmp" | sha256sum -c -
sudo -E bash "$tmp" \
--gitea-url http://38.76.196.225:10099 \
--repository awaioi/ERP \
--version 0.3.7 \
--public-url http://38.76.196.225:8091 \
--allow-insecure
)
```
只有 `v0.3.7` Release 发布后这条命令才可下载安装包。固定 tag 和 SHA-256 只用于保护当前 HTTP 引导脚本不被传输途中篡改;Release 资产仍会继续执行 Ed25519 和 SHA-256 双重校验。HTTP 会暴露请求、Release 元数据和可能使用的访问令牌,不得作为长期生产方案。
### 完整卸载后重装
以下命令具有破坏性:它会先停止服务,使用现有配置中的 ERP 数据库账号删除并重建目标数据库的 `public` schema,然后删除 systemd unit、程序、配置、状态和日志。脚本只允许数据库所有者执行 schema 清理,并拒绝 `postgres``template0``template1` 和危险文件路径。
```bash
(
set -e
tmp="$(mktemp)"
trap 'rm -f -- "$tmp"' EXIT
curl -fsSL http://38.76.196.225:10099/awaioi/ERP/raw/tag/v0.3.7/uninstall.sh -o "$tmp"
printf '%s %s\n' '98c56fed2fd4d01874e4ab5a1a4f3ec42ec3e29b315ffd87385a95488d587546' "$tmp" | sha256sum -c -
sudo -E bash "$tmp" --purge-database --yes
)
```
卸载器会先核对安装配置、路径和 systemd 停止状态,再清理数据库和文件。卸载成功后,再执行上面的 Linux 一键安装命令。不要对包含其他系统数据的共享数据库运行此命令。
### 正式安装目录
Linux 默认路径:
| 内容 | 路径 |
|---|---|
| 程序和版本目录 | `/opt/kaidi-erp` |
| 环境配置 | `/etc/kaidi-erp/erp.env` |
| 更新状态 | `/var/lib/kaidi-erp/update-state.json` |
| 安装状态 | `/var/lib/kaidi-erp/install.pending``/var/lib/kaidi-erp/install.lock` |
| 首次安装器 | `/opt/kaidi-erp/installer/`(健康后自动删除) |
| systemd 服务 | `kaidi-erp.service` |
使用 `--no-service` 只用于开发验收:启动器会在当前用户下运行,但默认设置 `OA_UPDATE_ENABLED=false`。生产环境应使用 systemd/launchd,让在线更新可以在 Java 进程退出后自动拉起新版本。
### HTTPS 反向代理
正式安装默认监听 `8091`(可通过 `ERP_SERVER_PORT` 覆盖)。Nginx、宝塔、Caddy 或 CDN 终止 HTTPS 后,必须把公网协议和主机转发给 Spring Boot;否则浏览器的同源 API 请求会被误判为跨域,并收到纯文本 `403 Invalid CORS request`,前端表现为“响应非 JSON (HTTP 403)”。Nginx 的代理位置至少包含:
```nginx
location / {
proxy_pass http://127.0.0.1:8091;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
```
如果 Nginx 前面还有 Cloudflare 等上游代理,`X-Forwarded-Proto` 必须保留浏览器实际使用的 `https`,不能被内层 HTTP 链路覆盖。前端与 `/api/oa/*` 推荐使用同一个公网域名;不要在 Nginx 中附加 `Access-Control-Allow-Origin *`
## 在线更新与回滚
管理员登录后可从以下任一入口进入:
```text
顶部工具栏 -> 系统更新
用户菜单 -> 系统更新
手机导航抽屉 -> 系统更新
应用定制平台 -> 系统更新
```
入口只对 `ADMIN` 角色显示,对应前端路由为 `/appdev/update`,后端 API 为 `/api/oa/system-update/*`。页面只显示当前版本、在线最新版本、检查时间、最新版本更新日志、历史正式版本,以及下载、验签、安装、重启和自动回滚进度。顶部和手机入口发现新版本时会显示版本提示。
更新源由安装器写入服务器的 `/etc/kaidi-erp/erp.env`,后台页面不会要求管理员重复填写 Gitea 地址、仓库、Token、通道或 HTTP 开关。需要变更基础设施配置时由服务器运维人员修改 `OA_UPDATE_*` 环境变量并重启服务;公开仓库无需 Token,正式环境应使用 HTTPS。
更新过程如下:
1. 从 Gitea 读取 stable channel 的最新 Release。
2. 下载归档、`SHA256SUMS` 和 Ed25519 签名(Release 里的独立安装器资产只用于首次安装)。
3. 先验证签名和 SHA-256,再拒绝路径穿越、绝对路径、符号链接、硬链接和结构不完整的归档。
4. 校验 `manifest.json` 中的版本、`database=postgresql``rollbackCompatible=true`
5. 可选执行 `pg_dump`,将新版本写入独立目录。
6. 原子切换 `current` 符号链接,并停止旧 Java 进程。
7. systemd/launchd 拉起新版本,更新助手等待新的 PID 和 `/api/oa/health`Linux unit 使用 `KillMode=process`,确保更新助手不会随旧 Java 进程一起被 systemd 清理。
8. 新版本不健康时切回上一链接,终止故障进程并再次验证旧版本健康状态。
同一安装目录使用操作系统文件锁,不能并发执行两个更新任务。也可以手工触发:
```bash
/opt/kaidi-erp/current/bin/erp-update install 0.3.7
```
应用回滚不等于数据库回滚。包含不可逆 Flyway 迁移的版本必须先保证旧应用仍兼容新结构,并建议在安装配置中启用:
```bash
ERP_UPDATE_BACKUP_MODE=pg_dump
```
更新助手不会自动覆盖生产数据库。需要恢复数据库时,应由管理员确认后使用 `pg_restore`
## Gitea Release 发布
推送 `v*` tag 会触发 `.gitea/workflows/release.yml`。流水线会先执行 shell、后端和独立安装器测试,再构建前端、生成 PostgreSQL-only JAR、打包、签名,并创建或更新对应 Gitea Release。
### Actions 前置配置
Gitea 1.27 仓库需要启用 Actions,并配置带 `ubuntu-latest` 标签的在线 Runner。Runner 必须提供:
- Java 17+
- Node.js 和 npm
- Python 3
- curl、tar
- OpenSSL 3,且支持 Ed25519
流水线使用 Gitea 内置短期 `GITEA_TOKEN`,权限限定为代码只读、当前仓库 Release 可写。仓库设置只需添加:
- Secret `RELEASE_PRIVATE_KEY_B64`
- 当前纯 HTTP 测试服务器额外添加 Variable `ERP_RELEASE_ALLOW_INSECURE_HTTP=1`Gitea 禁止变量名以保留前缀 `GITEA_``GITHUB_` 开头)
签名私钥不得提交到 Git。生成 Secret 值:
```bash
base64 < ~/.config/kaidi-erp/release-signing-key.pem | tr -d '\n'
```
发布稳定版本:
```bash
git switch main
git pull --ff-only origin main
git tag -a v0.3.7 -m 'Kaidi ERP v0.3.7'
git push origin v0.3.7
```
发布完成后必须确认 Release 页面存在四个资产,并使用仓库中的 `distribution/release-public-key.pem` 验证签名。私钥与该公钥不匹配时打包脚本会直接失败。
本地手工生成签名资产:
```bash
ERP_RELEASE_PRIVATE_KEY_FILE="$HOME/.config/kaidi-erp/release-signing-key.pem" \
bash scripts/package-release.sh 0.3.7
```
## 配置参考
| 环境变量 | 说明 | 默认值 |
|---|---|---|
| `SPRING_PROFILES_ACTIVE` | 正式环境必须为 `postgres` | 源码默认 profile |
| `OA_DB_URL` | JDBC PostgreSQL URL | 正式环境必填 |
| `OA_DB_USERNAME` | PostgreSQL 用户 | 正式环境必填 |
| `OA_DB_PASSWORD` | PostgreSQL 密码 | 正式环境必填 |
| `OA_DB_POOL_MAX` | 最大连接池 | `20` |
| `OA_DB_POOL_MIN` | 最小空闲连接 | `2` |
| `OA_UPDATE_ENABLED` | 启用管理后台在线更新 | 安装服务时为 `true` |
| `OA_UPDATE_GITEA_BASE_URL` | Gitea 外部地址 | 正式安装时写入 |
| `OA_UPDATE_REPOSITORY` | Release 仓库 | `awaioi/ERP` |
| `OA_UPDATE_CHANNEL` | 更新通道 | `stable` |
| `OA_UPDATE_TOKEN` | 私有仓库下载令牌 | 空;公开仓库不需要 |
| `OA_UPDATE_ALLOW_INSECURE_HTTP` | 允许 HTTP 更新地址 | `false` |
| `ERP_PUBLIC_URL` | 首次安装向导的公网 URL,等价于 `--public-url` | 自动探测公网 IP |
| `OA_SEED_DEMO` | 是否生成演示数据 | 正式安装为 `false` |
| `ERP_UPDATE_BACKUP_MODE` | 更新前数据库备份 | `none`,可设 `pg_dump` |
| `ERP_UPDATE_HEALTH_TIMEOUT_SECONDS` | 新旧版本健康检查超时 | `120` |
| `ERP_UPDATE_HEALTH_POLL_SECONDS` | 健康轮询间隔 | `2` |
## 测试与验收
### 后端
```bash
cd oa-backend
./gradlew test
```
### 前端类型检查与构建
```bash
cd ofbiz-framework/plugins/modern-ui/app
npm ci
NODE_OPTIONS=--max-old-space-size=8192 npm run build
```
### 启动器与发布脚本
```bash
bash tests/run-command.test.sh
bash tests/release-scripts.test.sh
```
### 已运行服务的 OA 冒烟和集成测试
```bash
OA=http://127.0.0.1:8091 bash oa-smoke.sh
OA=http://127.0.0.1:8091 bash oa-itest.sh
```
测试脚本会创建业务测试数据,应使用测试数据库,不要直接对生产数据库运行写入型集成测试。
## 安全与运维注意事项
- 正式 Gitea、安装和更新流量必须使用 HTTPS;`--allow-insecure` 仅限隔离测试环境。
- `RELEASE_PRIVATE_KEY_B64` 和 PostgreSQL 密码不得写入 Git、日志或聊天记录。
- 新增返回金额、个人信息或机密汇总的 API 时,必须复核 `AuthInterceptor` 的敏感读取权限前缀。
- 金额字段和计算统一使用 `BigDecimal`,禁止使用 `double` 处理货币。
- 不要使用 `pkill java``killall java`;只停止明确属于本项目的 PID。
- 修改前端后始终先执行前端构建,再打包后端。
- 每次包含数据库结构变更的 Release 都必须新增 Flyway 迁移并做新库首次迁移、旧库升级和回滚兼容性验证。
- 默认演示账号只能用于开发环境,生产环境需要更换密码并实施最小权限、备份、监控和审计策略。
## 常见问题
### 安装器提示没有 Release
Gitea 仓库尚未发布首个可安装版本,或 Release 缺少四个必需资产。先检查 `/api/v1/repos/awaioi/ERP/releases/latest` 和 Release 页面。
### 如何填写数据库
数据库连接信息只在首次网页向导填写。安装器会执行 `SELECT 1`、检查 PostgreSQL 15+ 并创建/验证 `pg_trgm`,错误凭据不会启动正式应用,也不会把密码写入响应或日志。数据库中已经有业务用户时,安装器会拒绝接管。
### PostgreSQL profile 启动失败
确认 `SPRING_PROFILES_ACTIVE=postgres`,并检查 `OA_DB_URL``OA_DB_USERNAME``OA_DB_PASSWORD`。正式 profile 会执行 Flyway,再使用 Hibernate `ddl-auto=validate` 校验实体与数据库结构。
### `run.command` 提示端口被占用
启动器只会复用能够通过项目健康检查的监听进程。其他程序占用端口时不会被自动终止;请停止对应程序或设置新的 `ERP_RUN_BACKEND_PORT`
### ngrok 无法启动
运行 `ngrok config check` 确认配置有效,并检查固定域名是否属于当前 ngrok 账号。可以通过 `ERP_RUN_NGROK_BIN``ERP_RUN_NGROK_CONFIG``ERP_RUN_NGROK_API_PORT` 覆盖路径与端口。
### 反向代理后提示“响应非 JSON (HTTP 403)”
应用自身的 401/403 权限错误始终是 JSON。该提示表示 Nginx、WAF 或 Spring CORS 层提前返回了纯文本/HTML。先确认代理目标为正式安装端口(默认 `http://127.0.0.1:8091`),再按“HTTPS 反向代理”一节补齐 `Host``X-Forwarded-*` 请求头;响应正文为 `Invalid CORS request` 时即可确认是协议/主机转发不完整。
### 签名验证失败
不要跳过验证。确认 Release 的四个资产来自同一次构建、`SHA256SUMS` 未被改写、签名私钥与 `distribution/release-public-key.pem` 匹配。
## Git 协作
- `dev` 是日常开发分支。
- `main` 只接收已经通过构建、测试、启动和冒烟验证的稳定提交。
- 新环境执行 `git config core.hooksPath .githooks` 启用仓库 hooks。
- 不要在脏工作区执行破坏性 reset;先确认哪些修改属于正在进行的开发。
远程仓库:
```text
git@38.76.196.225:awaioi/ERP.git
```
## 延伸文档
- [在线安装与更新](docs/online-install-and-update.md)
- [项目交接文档](go.md)
- [设计原则](DESIGN.md)
- [后端 API](oa-backend/API.md)
- [后端安全说明](oa-backend/SECURITY.md)
- [端点目录](go-endpoints.md)
- [实体目录](go-entities.md)
- [数据库目录](go-database.md)
+149 -31
View File
@@ -5,48 +5,166 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
INSTALL_ROOT="${ERP_INSTALL_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd -P)}"
CONFIG_FILE="${ERP_CONFIG_FILE:-$INSTALL_ROOT/config/erp.env}"
if [[ ! -r "$CONFIG_FILE" ]]; then
printf '[ERP] configuration not readable: %s\n' "$CONFIG_FILE" >&2
exit 1
fi
say() { printf '[ERP] %s\n' "$*"; }
fail() { printf '[ERP] ERROR: %s\n' "$*" >&2; exit 1; }
set -a
# The installer creates this file with shell-escaped values and mode 0640.
# shellcheck disable=SC1090
source "$CONFIG_FILE"
set +a
load_configuration() {
[[ -r "$CONFIG_FILE" ]] || fail "configuration not readable: $CONFIG_FILE"
set -a
# The installer writes this file with POSIX shell-safe single-quoted values.
# shellcheck disable=SC1090
source "$CONFIG_FILE"
set +a
RUN_DIR="${ERP_RUN_DIR:-$INSTALL_ROOT/run}"
JAR_PATH="${ERP_JAR_PATH:-$INSTALL_ROOT/current/app/kaidi-erp.jar}"
JAVA_BIN="${ERP_JAVA_BIN:-}"
RUN_DIR="${ERP_RUN_DIR:-$INSTALL_ROOT/run}"
JAR_PATH="${ERP_JAR_PATH:-$INSTALL_ROOT/current/app/kaidi-erp.jar}"
INSTALLER_JAR="${ERP_INSTALLER_JAR:-$INSTALL_ROOT/installer/kaidi-erp-installer.jar}"
PENDING_FILE="${ERP_INSTALL_PENDING_FILE:-$INSTALL_ROOT/state/install.pending}"
INSTALL_LOCK_FILE="${ERP_INSTALL_LOCK_FILE:-$INSTALL_ROOT/state/install.lock}"
INSTALL_LOG_FILE="${ERP_INSTALL_LOG_FILE:-$(dirname "$PENDING_FILE")/install-formal.log}"
HEALTH_URL="${ERP_HEALTH_URL:-http://127.0.0.1:${SERVER_PORT:-8091}/api/oa/health}"
HEALTH_TIMEOUT_SECONDS="${ERP_INSTALL_HEALTH_TIMEOUT_SECONDS:-240}"
HEALTH_POLL_SECONDS="${ERP_UPDATE_HEALTH_POLL_SECONDS:-2}"
}
if [[ -z "$JAVA_BIN" ]]; then
resolve_java() {
JAVA_BIN="${ERP_JAVA_BIN:-}"
if [[ -z "$JAVA_BIN" ]]; then
if [[ -n "${JAVA_HOME:-}" && -x "$JAVA_HOME/bin/java" ]]; then
JAVA_BIN="$JAVA_HOME/bin/java"
else
JAVA_BIN="$(command -v java || true)"
fi
fi
fi
[[ -n "$JAVA_BIN" && -x "$JAVA_BIN" ]] || fail 'Java 17+ is not installed'
local java_major
java_major="$("$JAVA_BIN" -version 2>&1 | awk -F'[".]' '/version/ {print $2; exit}')"
[[ "$java_major" =~ ^[0-9]+$ && "$java_major" -ge 17 ]] || fail 'Java 17+ is required'
}
if [[ -z "$JAVA_BIN" || ! -x "$JAVA_BIN" ]]; then
printf '[ERP] Java 17+ is not installed\n' >&2
exit 1
fi
JAVA_MAJOR="$("$JAVA_BIN" -version 2>&1 | awk -F'[".]' '/version/ {print $2; exit}')"
if [[ ! "$JAVA_MAJOR" =~ ^[0-9]+$ || "$JAVA_MAJOR" -lt 17 ]]; then
printf '[ERP] Java 17+ is required\n' >&2
exit 1
fi
if [[ ! -r "$JAR_PATH" ]]; then
printf '[ERP] application jar not readable: %s\n' "$JAR_PATH" >&2
exit 1
fi
split_java_options() {
IFS=$' \t' read -r -a JAVA_OPTS <<< "${ERP_JAVA_OPTS:--Xms512m -Xmx2g}"
IFS=$' \t' read -r -a INSTALLER_JAVA_OPTS <<< "${ERP_INSTALLER_JAVA_OPTS:--Xms128m -Xmx512m}"
}
mkdir -p "$RUN_DIR"
printf '%s\n' "$$" > "$RUN_DIR/app.pid"
write_pid() {
mkdir -p "$RUN_DIR"
printf '%s\n' "$1" > "$RUN_DIR/app.pid"
}
IFS=$' \t' read -r -a JAVA_OPTS <<< "${ERP_JAVA_OPTS:--Xms512m -Xmx2g}"
exec "$JAVA_BIN" "${JAVA_OPTS[@]}" \
clear_pid() {
local expected="$1"
if [[ -r "$RUN_DIR/app.pid" && "$(<"$RUN_DIR/app.pid")" == "$expected" ]]; then
rm -f "$RUN_DIR/app.pid"
fi
}
run_formal_application_direct() {
[[ -r "$JAR_PATH" ]] || fail "application jar not readable: $JAR_PATH"
write_pid "$$"
exec "$JAVA_BIN" "${JAVA_OPTS[@]}" \
-jar "$JAR_PATH" \
--spring.profiles.active=postgres \
--server.port="${SERVER_PORT:-8091}"
}
run_web_installer() {
[[ -r "$INSTALLER_JAR" ]] || fail "installer jar not readable: $INSTALLER_JAR"
say "Starting first-run installer on port ${SERVER_PORT:-8091}"
printf '%s\n' "$$" > "$RUN_DIR/installer.pid"
set +e
"$JAVA_BIN" "${INSTALLER_JAVA_OPTS[@]}" \
-jar "$INSTALLER_JAR" \
--server.port="${SERVER_PORT:-8091}"
local status=$?
set -e
rm -f "$RUN_DIR/installer.pid"
[[ "$status" == "0" ]] || fail "web installer exited with status $status"
[[ -f "$PENDING_FILE" ]] || fail 'web installer exited before installation was completed'
}
finalize_installation() {
[[ -f "$PENDING_FILE" ]] || fail "pending installation marker is missing: $PENDING_FILE"
[[ ! -e "$INSTALL_LOCK_FILE" ]] || fail "installation lock already exists: $INSTALL_LOCK_FILE"
mkdir -p "$(dirname "$INSTALL_LOCK_FILE")"
mv "$PENDING_FILE" "$INSTALL_LOCK_FILE"
chmod 600 "$INSTALL_LOCK_FILE"
rm -rf "$INSTALL_ROOT/installer"
say "Installation lock written; the web installer has been removed"
}
run_pending_formal_application() {
[[ -r "$JAR_PATH" ]] || fail "application jar not readable: $JAR_PATH"
[[ "$HEALTH_TIMEOUT_SECONDS" =~ ^[1-9][0-9]*$ ]] || fail 'invalid installation health timeout'
[[ "$HEALTH_POLL_SECONDS" =~ ^[1-9][0-9]*$ ]] || fail 'invalid health poll interval'
say 'Starting the formal PostgreSQL application'
mkdir -p "$(dirname "$INSTALL_LOG_FILE")"
: > "$INSTALL_LOG_FILE"
chmod 600 "$INSTALL_LOG_FILE"
"$JAVA_BIN" "${JAVA_OPTS[@]}" \
-jar "$JAR_PATH" \
--spring.profiles.active=postgres \
--server.port="${SERVER_PORT:-8091}" \
> >(tee -a "$INSTALL_LOG_FILE") 2>&1 &
APP_PID=$!
write_pid "$APP_PID"
forward_signal() {
kill -TERM "$APP_PID" 2>/dev/null || true
}
trap forward_signal TERM INT
local deadline=$((SECONDS + HEALTH_TIMEOUT_SECONDS))
while (( SECONDS < deadline )); do
if ! kill -0 "$APP_PID" 2>/dev/null; then
set +e
wait "$APP_PID"
local status=$?
set -e
clear_pid "$APP_PID"
say "Formal application diagnostics: $INSTALL_LOG_FILE"
fail "formal application exited before becoming healthy (status $status)"
fi
if curl -fsS --connect-timeout 2 --max-time 5 "$HEALTH_URL" >/dev/null 2>&1; then
finalize_installation
set +e
wait "$APP_PID"
local status=$?
set -e
clear_pid "$APP_PID"
return "$status"
fi
sleep "$HEALTH_POLL_SECONDS"
done
kill -TERM "$APP_PID" 2>/dev/null || true
set +e
wait "$APP_PID"
set -e
clear_pid "$APP_PID"
say "Formal application diagnostics: $INSTALL_LOG_FILE"
fail "formal application did not become healthy within ${HEALTH_TIMEOUT_SECONDS} seconds"
}
main() {
load_configuration
resolve_java
split_java_options
mkdir -p "$RUN_DIR"
if [[ -f "$INSTALL_LOCK_FILE" ]]; then
run_formal_application_direct
fi
if [[ ! -f "$PENDING_FILE" ]]; then
run_web_installer
load_configuration
resolve_java
split_java_options
fi
run_pending_formal_application
}
main "$@"
+11 -2
View File
@@ -44,14 +44,23 @@ import json, os, sys, tempfile
from datetime import datetime, timezone
path, phase, progress, message, version, error = sys.argv[1:]
payload = {
payload = {}
try:
if os.path.getsize(path) <= 64 * 1024:
with open(path, encoding="utf-8") as handle:
existing = json.load(handle)
if isinstance(existing, dict):
payload = existing
except (OSError, json.JSONDecodeError, TypeError, ValueError):
pass
payload.update({
"phase": phase,
"progress": int(progress),
"message": message,
"version": version or None,
"error": error or None,
"updatedAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
}
})
parent = os.path.dirname(path) or "."
fd, tmp = tempfile.mkstemp(prefix=".update-state-", dir=parent, text=True)
try:
+46 -26
View File
@@ -7,17 +7,16 @@ Kaidi ERP 的生产安装不依赖 Docker。安装器支持 64 位 Linux 和 mac
推送 `v*` Git tag 后,[release.yml](../.gitea/workflows/release.yml) 会完成前端构建、Spring Boot 打包、Ed25519 签名,并在 Gitea 中创建或更新对应 Release。每个可安装 Release 必须包含:
- `kaidi-erp-<version>.tar.gz`
- `kaidi-erp-installer-<version>.jar`
- `SHA256SUMS`
- `SHA256SUMS.sig`
Gitea Actions runner 需要预装 Java 17 或更高版本、Node.js/npm、Python 3、tar、curl 和 OpenSSL 3。
仓库 Actions 设置创建:
流水线使用 Gitea 1.27 提供的短期 `GITEA_TOKEN`,权限限定为代码只读、当前仓库 Release 可写,不需要创建个人访问令牌。仓库 Actions 设置只需创建:
- Secret `RELEASE_GITEA_TOKEN`:具备当前仓库 Release 写权限的 Gitea token。
- Secret `RELEASE_PRIVATE_KEY_B64`Ed25519 私钥的单行 Base64 内容。
- Variable `GITEA_BASE_URL`:生产 Gitea 的 HTTPS 外部地址
- Variable `GITEA_ALLOW_INSECURE_HTTP`:生产环境不要设置;当前 HTTP 测试服务器必须显式设为 `1`
- Variable `ERP_RELEASE_ALLOW_INSECURE_HTTP`:生产环境不要设置;当前 HTTP 测试服务器必须显式设为 `1`。Gitea 不允许仓库变量名以保留前缀 `GITEA_``GITHUB_` 开头
本机现有签名私钥位于 `~/.config/kaidi-erp/release-signing-key.pem`,不得提交到 Git。macOS 可用以下命令生成 Secret 值:
@@ -25,11 +24,13 @@ Gitea Actions runner 需要预装 Java 17 或更高版本、Node.js/npm、Python
base64 < ~/.config/kaidi-erp/release-signing-key.pem | tr -d '\n'
```
发布稳定版本:
发布稳定版本(先确认发布提交已经同步到 `main``dev`
```bash
git tag v0.2.0
git push origin v0.2.0
git switch main
git pull --ff-only origin main
git tag -a v0.3.7 -m 'Kaidi ERP v0.3.7'
git push origin v0.3.7
```
## 首次安装
@@ -43,45 +44,64 @@ curl -fsSL https://git.example.com/awaioi/ERP/raw/branch/main/install.sh \
--repository awaioi/ERP
```
安装器会从终端读取现有 PostgreSQL 的主机、端口、数据库、用户和密码。通过管道运行时也会直接读取 `/dev/tty`,不会把管道中的脚本内容误当成密码
命令行只检查并安装 Java 17+、curl、tar、Python 3 和 OpenSSL 3,然后启动独立安装器并输出带一次性 token 的网页地址。数据库、管理员和密码全部在首次网页向导填写;安装器会真实测试 PostgreSQL 15+、数据库所有权、`public` schema 建表权限和 `pg_trgm` 所有权,迁移完成并确认正式服务健康后才写 `install.lock`,随后物理删除安装器目录
无人值守安装示例:
安装地址优先使用 `--public-url`(或 `ERP_PUBLIC_URL`);未指定时依次尝试探测公网 IP、回退局域网 IP,并始终额外输出 `Local URL`。公网服务器建议显式传入例如 `--public-url http://38.76.196.225:8091`。参数支持 HTTPS 域名、端口、路径和已有查询参数,安装器会安全追加 token,不会用局域网 IP 覆盖显式公网地址。
目标 PostgreSQL 必须是专用空数据库,网页中填写的账号必须是该数据库的所有者。只拥有连接权限的账号会在网页连接测试阶段被拒绝,不再等到 Flyway 迁移后才显示笼统错误。
Linux 生产服务要求主机使用 systemd;没有 systemd 的容器、WSL 或精简系统只能显式使用 `--no-service` 做开发验收,在线更新也会保持关闭。
当前 `http://38.76.196.225:10099` 仅用于开发测试,安装器必须同时传入 `--allow-insecure`。在没有 HTTPS 的情况下,必须从固定 tag 下载引导脚本并验证本版本记录的 SHA-256,禁止把可变的 `main` 分支脚本直接管道给 root。HTTP 仍会暴露请求、Release 元数据和 Gitea token,不应作为生产部署方式。
当前 `v0.3.7` 安装命令:
```bash
export ERP_DB_HOST=127.0.0.1
export ERP_DB_PORT=5432
export ERP_DB_NAME=kaidi_erp
export ERP_DB_USER=kaidi_erp
export ERP_DB_PASSWORD='replace-with-a-strong-password'
export ERP_DB_SSLMODE=require
curl -fsSL https://git.example.com/awaioi/ERP/raw/branch/main/install.sh \
| sudo -E bash -s -- --non-interactive --gitea-url https://git.example.com
(
set -e
tmp="$(mktemp)"
trap 'rm -f -- "$tmp"' EXIT
curl -fsSL http://38.76.196.225:10099/awaioi/ERP/raw/tag/v0.3.7/install.sh -o "$tmp"
printf '%s %s\n' '88328b3086ece360d3c05d4a22bee06b03ed3639a454c1fe7b9c04b1da80a980' "$tmp" | sha256sum -c -
sudo -E bash "$tmp" --gitea-url http://38.76.196.225:10099 \
--repository awaioi/ERP --version 0.3.7 \
--public-url http://38.76.196.225:8091 --allow-insecure
)
```
在 apt 系 Linux 上也可以让安装器创建本机 PostgreSQL
完整卸载并清空本项目数据库 schema 后重装
```bash
curl -fsSL https://git.example.com/awaioi/ERP/raw/branch/main/install.sh \
| sudo -E bash -s -- --db-mode local --gitea-url https://git.example.com
(
set -e
tmp="$(mktemp)"
trap 'rm -f -- "$tmp"' EXIT
curl -fsSL http://38.76.196.225:10099/awaioi/ERP/raw/tag/v0.3.7/uninstall.sh -o "$tmp"
printf '%s %s\n' '98c56fed2fd4d01874e4ab5a1a4f3ec42ec3e29b315ffd87385a95488d587546' "$tmp" | sha256sum -c -
sudo -E bash "$tmp" --purge-database --yes
)
```
当前 `http://38.76.196.225:10099` 仅用于开发测试,必须同时传入 `--allow-insecure`。HTTP 会暴露安装脚本、Release 元数据和 Gitea token,不应作为生产部署方式
该命令会先确认安装路径与 `erp.env` 一致、systemd 服务已经停止,再删除目标数据库 `public` schema 中的全部对象以及 `/opt/kaidi-erp``/etc/kaidi-erp``/var/lib/kaidi-erp``/var/log/kaidi-erp`。只允许对 Kaidi ERP 专用数据库执行
## 在线更新
管理员进入“应用定制平台 -> 系统更新”,点击“检查更新”,确认版本和 Release notes 后执行更新。后端启动独立更新助手,更新助手会:
管理员可从顶部工具栏、用户菜单、手机导航抽屉或“应用定制平台 -> 系统更新”进入 `/appdev/update`。入口只对 `ADMIN` 角色显示;发现新版本时顶部和手机入口会显示版本提示。
1. 下载三个 Release 资产并验证 Ed25519 签名和 SHA-256
更新源由首次安装器写入 `ERP_CONFIG_FILE` 指向的 `erp.env`。更新页面不会要求管理员重复填写 Gitea 地址、仓库、Token、通道或 HTTP 开关;需要变更这些基础设施参数时,由服务器运维人员修改 `OA_UPDATE_*` 环境变量并重启服务
点击“检查更新”后,页面会展示当前版本、在线最新版本、发布日期、最新版本更新日志和历史正式版本记录。点击“安装并重启”后持续显示下载、验签、安装、重启和回滚进度;服务重启短暂断开期间页面会自动重连。后端启动独立更新助手,更新助手会:
1. 下载正式归档、`SHA256SUMS` 和签名并验证 Ed25519/SHA-256;独立安装器资产只在首次安装使用。
2. 拒绝路径穿越、符号链接和结构不完整的安装包。
3. 可选执行 `pg_dump`,再写入独立版本目录。
4. 原子切换 `current` 链接并终止旧进程,由 systemd 或 launchd 拉起新版本。
4. 原子切换 `current` 链接并终止旧进程,由 systemd 或 launchd 拉起新版本Linux unit 使用 `KillMode=process`,让更新助手继续执行健康检查和必要的回滚
5. 等待健康检查;失败时切回上一版本并再次验证健康状态。
更新过程使用操作系统文件锁,同一安装目录同时只允许一个更新任务。手动触发可执行:
```bash
/opt/kaidi-erp/current/bin/erp-update install 0.2.0
/opt/kaidi-erp/current/bin/erp-update install 0.3.7
```
在线更新依赖安装器注册的 systemd 或 launchd 服务来拉起新旧版本。使用 `--no-service` 时后台更新默认关闭;如由其他进程管理器接管,须先确认它会在 ERP 进程退出后自动重启,再手工启用 `OA_UPDATE_ENABLED=true`。健康检查默认最多等待 120 秒、每 2 秒轮询一次,可分别通过 `ERP_UPDATE_HEALTH_TIMEOUT_SECONDS``ERP_UPDATE_HEALTH_POLL_SECONDS` 调整。
+350 -221
View File
@@ -5,11 +5,10 @@ GITEA_BASE_URL="${ERP_GITEA_BASE_URL:-}"
REPOSITORY="${ERP_UPDATE_REPOSITORY:-awaioi/ERP}"
REQUESTED_VERSION="${ERP_INSTALL_VERSION:-}"
INSTALL_ROOT="${ERP_INSTALL_ROOT:-}"
DB_MODE="${ERP_DB_MODE:-existing}"
NON_INTERACTIVE="${ERP_INSTALL_NON_INTERACTIVE:-0}"
ALLOW_INSECURE="${ERP_UPDATE_ALLOW_INSECURE_HTTP:-0}"
NO_SERVICE="${ERP_INSTALL_NO_SERVICE:-0}"
TOKEN="${ERP_GITEA_TOKEN:-${OA_UPDATE_TOKEN:-}}"
PUBLIC_URL="${ERP_PUBLIC_URL:-}"
PUBLIC_KEY='-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAaErhcY8WZIZvPILmYnfjndVBAdOuWkvhaoHIWqNNdxI=
-----END PUBLIC KEY-----'
@@ -22,13 +21,12 @@ usage() {
cat <<'EOF'
Usage: install.sh [options]
--gitea-url URL Gitea public base URL
--repository O/R Release repository
--version VERSION Install one exact release
--repository O/R Release repository (default: awaioi/ERP)
--version VERSION Install one exact stable release
--install-root PATH Override installation directory
--db-mode MODE existing or local (local is Linux only)
--non-interactive Read all database values from ERP_DB_* variables
--public-url URL Public URL used for the one-time web setup link
--allow-insecure Development only: allow plain HTTP release URLs
--no-service Install files without registering/starting a service
--no-service Start without systemd/launchd; online update stays disabled
EOF
}
@@ -38,8 +36,7 @@ while [[ $# -gt 0 ]]; do
--repository) [[ $# -ge 2 ]] || fail '--repository requires a value'; REPOSITORY="$2"; shift 2 ;;
--version) [[ $# -ge 2 ]] || fail '--version requires a value'; REQUESTED_VERSION="$2"; shift 2 ;;
--install-root) [[ $# -ge 2 ]] || fail '--install-root requires a value'; INSTALL_ROOT="$2"; shift 2 ;;
--db-mode) [[ $# -ge 2 ]] || fail '--db-mode requires a value'; DB_MODE="$2"; shift 2 ;;
--non-interactive) NON_INTERACTIVE=1; shift ;;
--public-url) [[ $# -ge 2 ]] || fail '--public-url requires a value'; PUBLIC_URL="$2"; shift 2 ;;
--allow-insecure) ALLOW_INSECURE=1; shift ;;
--no-service) NO_SERVICE=1; shift ;;
-h|--help) usage; exit 0 ;;
@@ -54,10 +51,6 @@ cleanup() {
}
trap cleanup EXIT
require_command() {
command -v "$1" >/dev/null 2>&1 || fail "missing command: $1"
}
detect_platform() {
case "$(uname -s)" in
Linux) PLATFORM=linux ;;
@@ -74,138 +67,114 @@ detect_platform() {
fi
}
install_dependencies() {
if [[ "$PLATFORM" == "darwin" ]] && command -v brew >/dev/null 2>&1; then
local java_prefix="" libpq_prefix="" openssl_prefix=""
java_prefix="$(brew --prefix openjdk@17 2>/dev/null || true)"
libpq_prefix="$(brew --prefix libpq 2>/dev/null || true)"
openssl_prefix="$(brew --prefix openssl@3 2>/dev/null || true)"
[[ -z "$java_prefix" ]] || export PATH="$java_prefix/bin:$PATH"
[[ -z "$libpq_prefix" ]] || export PATH="$libpq_prefix/bin:$PATH"
[[ -z "$openssl_prefix" ]] || export PATH="$openssl_prefix/bin:$PATH"
fi
local need=0
for command in curl tar python3 psql openssl java; do
command -v "$command" >/dev/null 2>&1 || need=1
done
if [[ "$need" == "1" && "$PLATFORM" == "linux" ]]; then
command -v apt-get >/dev/null 2>&1 || fail 'automatic dependency installation currently supports apt-based Linux only'
say 'Installing runtime dependencies...'
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y ca-certificates curl tar python3 openssl openjdk-17-jre-headless postgresql-client
elif [[ "$need" == "1" ]]; then
command -v brew >/dev/null 2>&1 || fail 'Homebrew is required to install missing macOS dependencies'
say 'Installing runtime dependencies...'
brew install openjdk@17 libpq openssl@3
export PATH="$(brew --prefix openjdk@17)/bin:$(brew --prefix libpq)/bin:$(brew --prefix openssl@3)/bin:$PATH"
fi
for command in curl tar python3 psql openssl java; do require_command "$command"; done
local java_major
java_major="$(java -version 2>&1 | awk -F'[".]' '/version/ {print $2; exit}')"
[[ "$java_major" =~ ^[0-9]+$ && "$java_major" -ge 17 ]] || fail 'Java 17 or newer is required'
local openssl_version algorithms
openssl_version="$(openssl version 2>/dev/null || true)"
[[ "$openssl_version" =~ ^OpenSSL[[:space:]]3\. ]] \
|| fail 'OpenSSL 3 with Ed25519 support is required'
algorithms="$(openssl list -public-key-algorithms 2>/dev/null || true)"
grep -qi ED25519 <<< "$algorithms" \
|| fail 'OpenSSL 3 with Ed25519 support is required'
validate_service_manager() {
[[ "$PLATFORM" != "linux" || "$NO_SERVICE" == "1" ]] && return 0
command -v systemctl >/dev/null 2>&1 \
|| fail 'systemd is required for a production Linux install; use --no-service only for development'
[[ -d /run/systemd/system ]] \
|| fail 'systemd is not running on this Linux host; use --no-service only for development'
}
validate_https_url() {
java_major() {
local java_bin="${1:-java}"
"$java_bin" -version 2>&1 | awk -F'[".]' '/version/ {print $2; exit}'
}
has_java_17() {
command -v java >/dev/null 2>&1 || return 1
local major
major="$(java_major "$(command -v java)")"
[[ "$major" =~ ^[0-9]+$ && "$major" -ge 17 ]]
}
has_openssl_3_ed25519() {
command -v openssl >/dev/null 2>&1 || return 1
local version algorithms
version="$(openssl version 2>/dev/null || true)"
[[ "$version" =~ ^OpenSSL[[:space:]]3\. ]] || return 1
algorithms="$(openssl list -public-key-algorithms 2>/dev/null || true)"
grep -qi ED25519 <<< "$algorithms"
}
install_linux_dependencies() {
local need_java="$1"
local packages=(ca-certificates)
command -v curl >/dev/null 2>&1 || packages+=(curl)
command -v tar >/dev/null 2>&1 || packages+=(tar)
command -v python3 >/dev/null 2>&1 || packages+=(python3)
has_openssl_3_ed25519 || packages+=(openssl)
if command -v apt-get >/dev/null 2>&1; then
[[ "$need_java" == "0" ]] || packages+=(openjdk-17-jre-headless)
say 'Installing missing runtime packages with apt-get...'
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y "${packages[@]}"
elif command -v dnf >/dev/null 2>&1; then
[[ "$need_java" == "0" ]] || packages+=(java-17-openjdk-headless)
say 'Installing missing runtime packages with dnf...'
dnf install -y "${packages[@]}"
elif command -v yum >/dev/null 2>&1; then
[[ "$need_java" == "0" ]] || packages+=(java-17-openjdk-headless)
say 'Installing missing runtime packages with yum...'
yum install -y "${packages[@]}"
elif command -v zypper >/dev/null 2>&1; then
[[ "$need_java" == "0" ]] || packages+=(java-17-openjdk-headless)
say 'Installing missing runtime packages with zypper...'
zypper --non-interactive install "${packages[@]}"
else
fail 'no supported package manager found (apt-get, dnf, yum, or zypper is required)'
fi
}
install_macos_dependencies() {
command -v brew >/dev/null 2>&1 || fail 'Homebrew is required to install missing macOS packages'
local packages=()
has_java_17 || packages+=(openjdk@17)
command -v python3 >/dev/null 2>&1 || packages+=(python@3)
has_openssl_3_ed25519 || packages+=(openssl@3)
if (( ${#packages[@]} > 0 )); then
say 'Installing missing runtime packages with Homebrew...'
brew install "${packages[@]}"
fi
local java_prefix openssl_prefix
java_prefix="$(brew --prefix openjdk@17 2>/dev/null || true)"
openssl_prefix="$(brew --prefix openssl@3 2>/dev/null || true)"
[[ -z "$java_prefix" ]] || export PATH="$java_prefix/bin:$PATH"
[[ -z "$openssl_prefix" ]] || export PATH="$openssl_prefix/bin:$PATH"
}
check_and_install_dependencies() {
local need_install=0 need_java=0
has_java_17 || { need_install=1; need_java=1; }
command -v curl >/dev/null 2>&1 || need_install=1
command -v tar >/dev/null 2>&1 || need_install=1
command -v python3 >/dev/null 2>&1 || need_install=1
has_openssl_3_ed25519 || need_install=1
if [[ "$need_install" == "1" ]]; then
if [[ "$PLATFORM" == "linux" ]]; then
install_linux_dependencies "$need_java"
else
install_macos_dependencies
fi
fi
for command in curl tar python3 openssl java; do
command -v "$command" >/dev/null 2>&1 || fail "missing command after installation: $command"
done
has_java_17 || fail 'Java 17 or newer is required and could not be installed'
has_openssl_3_ed25519 || fail 'OpenSSL 3 with Ed25519 support is required and could not be installed'
JAVA_BIN="$(command -v java)"
say "Using Java $(java_major "$JAVA_BIN") at $JAVA_BIN"
}
validate_download_url() {
case "$1" in
https://*) return 0 ;;
http://*) [[ "$ALLOW_INSECURE" == "1" || "$ALLOW_INSECURE" == "true" ]] && return 0 ;;
esac
fail 'release server must use HTTPS (use --allow-insecure only for local development)'
}
prompt_value() {
local variable="$1" label="$2" default="$3" secret="${4:-0}" value="${!variable:-}"
if [[ -z "$value" && "$NON_INTERACTIVE" != "1" ]]; then
[[ -r /dev/tty && -w /dev/tty ]] \
|| fail "cannot prompt for $label; use --non-interactive with ERP_DB_* variables"
if [[ "$secret" == "1" ]]; then
IFS= read -r -s -p "$label: " value </dev/tty \
|| fail "cannot read $label; use --non-interactive with ERP_DB_* variables"
printf '\n' >/dev/tty
else
IFS= read -r -p "$label [$default]: " value </dev/tty \
|| fail "cannot read $label; use --non-interactive with ERP_DB_* variables"
fi
fi
value="${value:-$default}"
printf -v "$variable" '%s' "$value"
}
run_as_postgres() {
if [[ "$(id -un)" == "postgres" ]]; then
psql "$@"
elif command -v runuser >/dev/null 2>&1; then
runuser -u postgres -- psql "$@"
elif command -v sudo >/dev/null 2>&1; then
sudo -u postgres -- psql "$@"
else
fail 'cannot switch to the postgres operating-system user (runuser or sudo is required)'
fi
}
configure_local_postgres() {
[[ "$PLATFORM" == "linux" ]] || fail 'automatic local PostgreSQL provisioning is currently Linux-only'
command -v apt-get >/dev/null 2>&1 || fail 'local PostgreSQL provisioning requires apt'
require_command systemctl
apt-get update
apt-get install -y postgresql postgresql-contrib
systemctl enable --now postgresql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_NAME="${ERP_DB_NAME:-kaidi_erp}"
DB_USER="${ERP_DB_USER:-kaidi_erp}"
DB_PASSWORD="${ERP_DB_PASSWORD:-}"
if [[ -z "$DB_PASSWORD" ]]; then
DB_PASSWORD="$(openssl rand -hex 24)" || fail 'unable to generate a PostgreSQL password'
fi
DB_SSLMODE=disable
[[ "$DB_NAME" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]] || fail 'invalid local database name'
[[ "$DB_USER" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]] || fail 'invalid local database user'
run_as_postgres -v ON_ERROR_STOP=1 -v role="$DB_USER" -v password="$DB_PASSWORD" <<'SQL'
SELECT format('CREATE ROLE %I LOGIN PASSWORD %L', :'role', :'password')
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = :'role') \gexec
SELECT format('ALTER ROLE %I LOGIN PASSWORD %L', :'role', :'password') \gexec
SQL
run_as_postgres -v ON_ERROR_STOP=1 -v db="$DB_NAME" -v role="$DB_USER" <<'SQL'
SELECT format('CREATE DATABASE %I OWNER %I', :'db', :'role')
WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = :'db') \gexec
SQL
}
configure_existing_postgres() {
prompt_value DB_HOST 'PostgreSQL host' "${ERP_DB_HOST:-127.0.0.1}"
prompt_value DB_PORT 'PostgreSQL port' "${ERP_DB_PORT:-5432}"
prompt_value DB_NAME 'Database name' "${ERP_DB_NAME:-kaidi_erp}"
prompt_value DB_USER 'Database user' "${ERP_DB_USER:-kaidi_erp}"
prompt_value DB_PASSWORD 'Database password' "${ERP_DB_PASSWORD:-}" 1
prompt_value DB_SSLMODE 'PostgreSQL SSL mode' "${ERP_DB_SSLMODE:-prefer}"
[[ -n "$DB_PASSWORD" ]] || fail 'database password is required'
}
validate_postgres() {
[[ "$DB_HOST" != *[[:space:]]* && "$DB_PORT" =~ ^[0-9]+$ ]] || fail 'invalid PostgreSQL host or port'
[[ "$DB_NAME" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]] || fail 'invalid database name'
[[ "$DB_USER" =~ ^[A-Za-z_][A-Za-z0-9_.-]*$ ]] || fail 'invalid database user'
case "$DB_SSLMODE" in disable|allow|prefer|require|verify-ca|verify-full) ;; *) fail 'invalid PostgreSQL SSL mode' ;; esac
local version
version="$(PGPASSWORD="$DB_PASSWORD" PGSSLMODE="$DB_SSLMODE" psql \
-h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -Atqc \
"select current_setting('server_version_num')::int")" \
|| fail 'cannot connect to PostgreSQL with the supplied credentials'
[[ "$version" =~ ^[0-9]+$ && "$version" -ge 150000 ]] || fail 'PostgreSQL 15 or newer is required'
PGPASSWORD="$DB_PASSWORD" PGSSLMODE="$DB_SSLMODE" psql \
-h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 \
-c 'CREATE EXTENSION IF NOT EXISTS pg_trgm' >/dev/null \
|| fail 'database user cannot install the required pg_trgm extension'
say "PostgreSQL connection verified (${version:0:2})"
fail 'release server must use HTTPS (use --allow-insecure only for an isolated test server)'
}
create_curl_config() {
@@ -226,7 +195,7 @@ create_curl_config() {
}
download() {
validate_https_url "$1"
validate_download_url "$1"
curl --config "$CURL_CONFIG" --output "$2" "$1"
}
@@ -235,9 +204,8 @@ download_release() {
local owner="${REPOSITORY%%/*}" repo="${REPOSITORY#*/}"
local release_json="$TMP_DIR/release.json"
download "${GITEA_BASE_URL%/}/api/v1/repos/$owner/$repo/releases/latest" "$release_json" \
|| fail 'unable to read latest Gitea Release'
local selection="$TMP_DIR/selection"
local selection_error="$TMP_DIR/selection-error"
|| fail 'unable to read the latest Gitea Release'
local selection="$TMP_DIR/selection" selection_error="$TMP_DIR/selection-error"
if ! python3 - "$release_json" "$REQUESTED_VERSION" > "$selection" 2> "$selection_error" <<'PY'
import json, re, sys
try:
@@ -256,7 +224,12 @@ requested = sys.argv[2].removeprefix("v")
if requested and requested != version:
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", [])}
names = [f"kaidi-erp-{version}.tar.gz", "SHA256SUMS", "SHA256SUMS.sig"]
names = [
f"kaidi-erp-{version}.tar.gz",
f"kaidi-erp-installer-{version}.jar",
"SHA256SUMS",
"SHA256SUMS.sig",
]
urls = [assets.get(name, "") 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")
@@ -267,37 +240,53 @@ PY
[[ ! -s "$selection_error" ]] || reason="$(<"$selection_error")"
fail "$reason"
fi
local archive_url sums_url signature_url
IFS=$'\t' read -r VERSION archive_url sums_url signature_url < "$selection"
local archive_url installer_url sums_url signature_url
IFS=$'\t' read -r VERSION archive_url installer_url sums_url signature_url < "$selection"
ARCHIVE_NAME="kaidi-erp-${VERSION}.tar.gz"
INSTALLER_NAME="kaidi-erp-installer-${VERSION}.jar"
ARCHIVE_PATH="$TMP_DIR/$ARCHIVE_NAME"
INSTALLER_PATH="$TMP_DIR/$INSTALLER_NAME"
download "$archive_url" "$ARCHIVE_PATH"
download "$installer_url" "$INSTALLER_PATH"
download "$sums_url" "$TMP_DIR/SHA256SUMS"
download "$signature_url" "$TMP_DIR/SHA256SUMS.sig"
}
checksum_for() {
python3 - "$TMP_DIR/SHA256SUMS" "$1" <<'PY'
import re, sys
for line in open(sys.argv[1], encoding="utf-8"):
match = re.fullmatch(r"([0-9a-fA-F]{64})\s+\*?(.+?)\s*", line)
if match and match.group(2) == sys.argv[2]:
print(match.group(1).lower())
break
PY
}
actual_checksum() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | awk '{print $1}'
else
shasum -a 256 "$1" | awk '{print $1}'
fi
}
verify_release() {
printf '%s\n' "$PUBLIC_KEY" > "$TMP_DIR/release-public-key.pem"
openssl pkeyutl -verify -rawin -pubin -inkey "$TMP_DIR/release-public-key.pem" \
-sigfile "$TMP_DIR/SHA256SUMS.sig" -in "$TMP_DIR/SHA256SUMS" >/dev/null \
|| fail 'Release Ed25519 signature verification failed'
local expected actual
expected="$(python3 - "$TMP_DIR/SHA256SUMS" "$ARCHIVE_NAME" <<'PY'
import re, sys
for line in open(sys.argv[1], encoding="utf-8"):
m = re.fullmatch(r"([0-9a-fA-F]{64})\s+\*?(.+?)\s*", line)
if m and m.group(2) == sys.argv[2]:
print(m.group(1).lower())
break
PY
)"
[[ "$expected" =~ ^[0-9a-f]{64}$ ]] || fail 'release checksum is missing'
if command -v sha256sum >/dev/null 2>&1; then
actual="$(sha256sum "$ARCHIVE_PATH" | awk '{print $1}')"
else
actual="$(shasum -a 256 "$ARCHIVE_PATH" | awk '{print $1}')"
fi
[[ "$actual" == "$expected" ]] || fail 'release checksum verification failed'
local name path expected actual
for name in "$ARCHIVE_NAME" "$INSTALLER_NAME"; do
if [[ "$name" == "$ARCHIVE_NAME" ]]; then path="$ARCHIVE_PATH"; else path="$INSTALLER_PATH"; fi
expected="$(checksum_for "$name")"
[[ "$expected" =~ ^[0-9a-f]{64}$ ]] || fail "release checksum is missing for $name"
actual="$(actual_checksum "$path")"
[[ "$actual" == "$expected" ]] || fail "release checksum verification failed for $name"
done
python3 - "$ARCHIVE_PATH" <<'PY'
import pathlib, sys, tarfile
with tarfile.open(sys.argv[1], "r:gz") as archive:
@@ -316,8 +305,11 @@ shell_setting() {
prepare_layout() {
if [[ "$PLATFORM" == "linux" ]]; then
ERP_USER="${ERP_SERVICE_USER:-kaidi-erp}"
ERP_GROUP="$ERP_USER"
id "$ERP_USER" >/dev/null 2>&1 || useradd --system --home-dir "$INSTALL_ROOT" --shell /usr/sbin/nologin "$ERP_USER"
[[ "$ERP_USER" =~ ^[a-z_][a-z0-9_-]*[$]?$ ]] || fail 'invalid Linux service user name'
id "$ERP_USER" >/dev/null 2>&1 \
|| useradd --system --home-dir "$INSTALL_ROOT" --shell /usr/sbin/nologin "$ERP_USER"
ERP_GROUP="$(id -gn "$ERP_USER" 2>/dev/null)" \
|| fail "unable to resolve the primary group for Linux service user: $ERP_USER"
CONFIG_ROOT="${ERP_CONFIG_ROOT:-/etc/kaidi-erp}"
STATE_ROOT="${ERP_STATE_ROOT:-/var/lib/kaidi-erp}"
LOG_ROOT="${ERP_LOG_ROOT:-/var/log/kaidi-erp}"
@@ -326,7 +318,6 @@ prepare_layout() {
[[ "$path" =~ ^/[A-Za-z0-9._/@:+-]+$ ]] \
|| fail "Linux installation paths must be absolute and contain only safe characters: $path"
done
[[ "$ERP_USER" =~ ^[a-z_][a-z0-9_-]*[$]?$ ]] || fail 'invalid Linux service user name'
else
ERP_USER="$(id -un)"
ERP_GROUP="$(id -gn)"
@@ -334,11 +325,15 @@ prepare_layout() {
STATE_ROOT="${ERP_STATE_ROOT:-$INSTALL_ROOT/state}"
LOG_ROOT="${ERP_LOG_ROOT:-$HOME/Library/Logs/KaidiERP}"
fi
mkdir -p "$INSTALL_ROOT/releases" "$INSTALL_ROOT/run" "$INSTALL_ROOT/backups" \
"$CONFIG_ROOT" "$STATE_ROOT" "$LOG_ROOT"
mkdir -p "$INSTALL_ROOT/releases" "$INSTALL_ROOT/installer" "$INSTALL_ROOT/run" \
"$INSTALL_ROOT/backups" "$CONFIG_ROOT" "$STATE_ROOT" "$LOG_ROOT"
if [[ "$PLATFORM" == "linux" ]]; then
chown -R "$ERP_USER:$ERP_GROUP" "$INSTALL_ROOT" "$STATE_ROOT" "$LOG_ROOT"
chown -R "$ERP_USER:$ERP_GROUP" "$INSTALL_ROOT" "$CONFIG_ROOT" "$STATE_ROOT" "$LOG_ROOT"
chmod 750 "$CONFIG_ROOT" "$STATE_ROOT"
fi
[[ ! -e "$STATE_ROOT/install.lock" ]] || fail 'Kaidi ERP is already installed; use the system update page'
[[ ! -e "$STATE_ROOT/install.pending" ]] || fail 'a previous installation is still pending; inspect the service before retrying'
}
install_release_files() {
@@ -348,9 +343,10 @@ install_release_files() {
local source="$unpack/kaidi-erp-${VERSION}"
[[ -r "$source/app/kaidi-erp.jar" && -x "$source/bin/erp-run" && -x "$source/bin/erp-update" ]] \
|| fail 'release archive is incomplete'
[[ "$(<"$source/VERSION")" == "$VERSION" ]] || fail 'release archive version mismatch'
local destination="$INSTALL_ROOT/releases/$VERSION"
local replaced=""
[[ -r "$source/VERSION" && "$(<"$source/VERSION")" == "$VERSION" ]] \
|| fail 'release archive version mismatch'
local destination="$INSTALL_ROOT/releases/$VERSION" replaced=""
if [[ -e "$destination" ]]; then
replaced="${destination}.replaced-$$"
mv "$destination" "$replaced"
@@ -360,47 +356,61 @@ install_release_files() {
fail 'unable to write the release directory'
fi
[[ -z "$replaced" ]] || rm -rf "$replaced"
local installer_tmp="$INSTALL_ROOT/installer/.kaidi-erp-installer.jar.tmp-$$"
cp "$INSTALLER_PATH" "$installer_tmp"
chmod 600 "$installer_tmp"
mv "$installer_tmp" "$INSTALL_ROOT/installer/kaidi-erp-installer.jar"
python3 - "$INSTALL_ROOT/current" "releases/$VERSION" <<'PY'
import os, sys
link, target = sys.argv[1:]
tmp = f"{link}.new-{os.getpid()}"
temporary = f"{link}.new-{os.getpid()}"
try:
os.symlink(target, tmp)
os.replace(tmp, link)
os.symlink(target, temporary)
os.replace(temporary, link)
finally:
if os.path.lexists(tmp): os.unlink(tmp)
if os.path.lexists(temporary):
os.unlink(temporary)
PY
if [[ "$PLATFORM" == "linux" ]]; then
chown -R "$ERP_USER:$ERP_GROUP" "$destination" "$INSTALL_ROOT/current"
chown -R "$ERP_USER:$ERP_GROUP" "$destination" "$INSTALL_ROOT/installer" "$INSTALL_ROOT/current"
fi
}
write_configuration() {
write_bootstrap_configuration() {
CONFIG_FILE="$CONFIG_ROOT/erp.env"
PUBLIC_KEY_FILE="$CONFIG_ROOT/release-public-key.pem"
local update_enabled=true
PENDING_FILE="$STATE_ROOT/install.pending"
INSTALL_LOCK_FILE="$STATE_ROOT/install.lock"
OPERATION_LOCK_FILE="$STATE_ROOT/install-operation.lock"
SETUP_TOKEN="$(openssl rand -hex 32)" || fail 'unable to generate the one-time setup token'
local update_enabled=true port="${ERP_SERVER_PORT:-8091}"
[[ "$NO_SERVICE" != "1" ]] || update_enabled=false
printf '%s\n' "$PUBLIC_KEY" > "$PUBLIC_KEY_FILE"
{
shell_setting ERP_INSTALL_ROOT "$INSTALL_ROOT"
shell_setting ERP_CONFIG_FILE "$CONFIG_FILE"
shell_setting ERP_RUN_DIR "$INSTALL_ROOT/run"
shell_setting ERP_STATE_FILE "$STATE_ROOT/update-state.json"
shell_setting ERP_HEALTH_URL "http://127.0.0.1:${ERP_SERVER_PORT:-8091}/api/oa/health"
shell_setting ERP_INSTALL_PENDING_FILE "$PENDING_FILE"
shell_setting ERP_INSTALL_LOCK_FILE "$INSTALL_LOCK_FILE"
shell_setting ERP_INSTALL_OPERATION_LOCK_FILE "$OPERATION_LOCK_FILE"
shell_setting ERP_INSTALL_LOG_FILE "$STATE_ROOT/install-formal.log"
shell_setting ERP_INSTALL_HEALTH_TIMEOUT_SECONDS "${ERP_INSTALL_HEALTH_TIMEOUT_SECONDS:-240}"
shell_setting ERP_SETUP_TOKEN "$SETUP_TOKEN"
shell_setting ERP_INSTALLER_JAR "$INSTALL_ROOT/installer/kaidi-erp-installer.jar"
shell_setting ERP_JAR_PATH "$INSTALL_ROOT/current/app/kaidi-erp.jar"
shell_setting ERP_JAVA_BIN "$JAVA_BIN"
shell_setting ERP_RELEASE_VERSION "$VERSION"
shell_setting ERP_HEALTH_URL "http://127.0.0.1:${port}/api/oa/health"
shell_setting ERP_UPDATE_PUBLIC_KEY_FILE "$PUBLIC_KEY_FILE"
shell_setting ERP_UPDATE_REQUIRE_SIGNATURE true
shell_setting ERP_UPDATE_BACKUP_MODE "${ERP_UPDATE_BACKUP_MODE:-none}"
shell_setting ERP_UPDATE_HEALTH_TIMEOUT_SECONDS "${ERP_UPDATE_HEALTH_TIMEOUT_SECONDS:-120}"
shell_setting ERP_UPDATE_HEALTH_POLL_SECONDS "${ERP_UPDATE_HEALTH_POLL_SECONDS:-2}"
shell_setting ERP_PGHOST "$DB_HOST"
shell_setting ERP_PGPORT "$DB_PORT"
shell_setting ERP_PGDATABASE "$DB_NAME"
shell_setting ERP_PGSSLMODE "$DB_SSLMODE"
shell_setting SPRING_PROFILES_ACTIVE postgres
shell_setting SERVER_PORT "${ERP_SERVER_PORT:-8091}"
shell_setting OA_DB_URL "jdbc:postgresql://${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE}"
shell_setting OA_DB_USERNAME "$DB_USER"
shell_setting OA_DB_PASSWORD "$DB_PASSWORD"
shell_setting SERVER_PORT "$port"
shell_setting OA_UPDATE_ENABLED "$update_enabled"
shell_setting OA_UPDATE_GITEA_BASE_URL "$GITEA_BASE_URL"
shell_setting OA_UPDATE_REPOSITORY "$REPOSITORY"
@@ -411,16 +421,22 @@ write_configuration() {
shell_setting OA_UPDATE_ALLOW_INSECURE_HTTP "$ALLOW_INSECURE"
} > "$CONFIG_FILE"
if [[ "$PLATFORM" == "linux" ]]; then
chown root:"$ERP_GROUP" "$CONFIG_FILE" "$PUBLIC_KEY_FILE"
chmod 640 "$CONFIG_FILE" "$PUBLIC_KEY_FILE"
else
chmod 600 "$CONFIG_FILE" "$PUBLIC_KEY_FILE"
chown "$ERP_USER:$ERP_GROUP" "$CONFIG_FILE" "$PUBLIC_KEY_FILE"
fi
chmod 600 "$CONFIG_FILE" "$PUBLIC_KEY_FILE"
}
install_service() {
[[ "$NO_SERVICE" == "1" ]] && return 0
start_service() {
if [[ "$NO_SERVICE" == "1" ]]; then
nohup env ERP_INSTALL_ROOT="$INSTALL_ROOT" ERP_CONFIG_FILE="$CONFIG_FILE" \
"$INSTALL_ROOT/current/bin/erp-run" >> "$LOG_ROOT/erp.log" 2>> "$LOG_ROOT/erp-error.log" &
printf '%s\n' "$!" > "$INSTALL_ROOT/run/launcher.pid"
return 0
fi
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
[Unit]
Description=Kaidi ERP
@@ -429,30 +445,36 @@ Wants=network-online.target
[Service]
Type=simple
KillMode=process
User=$ERP_USER
Group=$ERP_GROUP
Environment="ERP_INSTALL_ROOT=$INSTALL_ROOT"
Environment="ERP_CONFIG_FILE=$CONFIG_FILE"
WorkingDirectory="$INSTALL_ROOT"
ExecStart="$INSTALL_ROOT/current/bin/erp-run"
WorkingDirectory=$INSTALL_ROOT
ExecStart=$INSTALL_ROOT/current/bin/erp-run
Restart=always
RestartSec=3
TimeoutStopSec=90
SuccessExitStatus=143
UMask=0077
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectSystem=full
ProtectHome=true
ReadWritePaths="$INSTALL_ROOT" "$STATE_ROOT"
ReadWritePaths=$INSTALL_ROOT $CONFIG_ROOT $STATE_ROOT $LOG_ROOT
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now kaidi-erp.service
if command -v systemd-analyze >/dev/null 2>&1; then
systemd-analyze verify /etc/systemd/system/kaidi-erp.service \
|| fail 'generated systemd unit is invalid; run systemd-analyze verify /etc/systemd/system/kaidi-erp.service'
fi
systemctl enable kaidi-erp.service
systemctl restart kaidi-erp.service
else
local agents="$HOME/Library/LaunchAgents"
local plist="$agents/com.kaidi.erp.plist"
local agents="$HOME/Library/LaunchAgents" plist="$HOME/Library/LaunchAgents/com.kaidi.erp.plist"
mkdir -p "$agents"
python3 - "$plist" "$INSTALL_ROOT" "$CONFIG_FILE" "$LOG_ROOT" <<'PY'
import plistlib, sys
@@ -476,20 +498,104 @@ PY
fi
}
wait_for_health() {
[[ "$NO_SERVICE" == "1" ]] && return 0
local url="http://127.0.0.1:${ERP_SERVER_PORT:-8091}/api/oa/health" deadline=$((SECONDS + 120))
wait_for_installer() {
local url="http://127.0.0.1:${ERP_SERVER_PORT:-8091}/api/install/status"
local deadline=$((SECONDS + 120))
while (( SECONDS < deadline )); do
if curl -fsS --connect-timeout 2 --max-time 5 "$url" >/dev/null 2>&1; then
if curl -fsS --connect-timeout 2 --max-time 5 \
-H "X-Setup-Token: $SETUP_TOKEN" "$url" >/dev/null 2>&1; then
return 0
fi
sleep 2
done
fail 'ERP service did not become healthy within 120 seconds'
fail 'the web installer did not start within 120 seconds; inspect the kaidi-erp service log'
}
detect_lan_address() {
local address=""
if command -v hostname >/dev/null 2>&1; then
address="$(hostname -I 2>/dev/null | awk '{for (i=1; i<=NF; i++) if ($i ~ /^[0-9]+\./) {print $i; exit}}' || true)"
fi
if [[ -z "$address" && "$PLATFORM" == "linux" ]] && command -v ip >/dev/null 2>&1; then
address="$(ip route get 1.1.1.1 2>/dev/null | awk '/src/ {for (i=1; i<=NF; i++) if ($i=="src") {print $(i+1); exit}}' || true)"
fi
if [[ -z "$address" && "$PLATFORM" == "darwin" ]] && command -v ipconfig >/dev/null 2>&1; then
address="$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || true)"
fi
printf '%s' "${address:-127.0.0.1}"
}
is_ip_address() {
python3 - "$1" <<'PY' >/dev/null 2>&1
import ipaddress, sys
ipaddress.ip_address(sys.argv[1])
PY
}
detect_public_address() {
local endpoint address
for endpoint in \
https://api.ipify.org \
https://ifconfig.me/ip \
https://icanhazip.com; do
address="$(curl --silent --show-error --fail --location \
--proto '=https' --proto-redir '=https' \
--connect-timeout 3 --max-time 5 "$endpoint" 2>/dev/null \
| tr -d '[:space:]' | head -c 128 || true)"
if [[ -n "$address" ]] && is_ip_address "$address"; then
printf '%s' "$address"
return 0
fi
done
return 1
}
normalize_public_url() {
python3 - "$1" <<'PY'
import sys
from urllib.parse import urlsplit, urlunsplit
value = sys.argv[1].strip()
try:
parsed = urlsplit(value)
port = parsed.port
except ValueError:
raise SystemExit("invalid public URL")
if parsed.scheme.lower() not in {"http", "https"} or not parsed.hostname:
raise SystemExit("public URL must use http or https")
if parsed.username is not None or parsed.password is not None:
raise SystemExit("public URL must not contain credentials")
if port is not None and not 1 <= port <= 65535:
raise SystemExit("invalid public URL port")
path = parsed.path or "/"
print(urlunsplit((parsed.scheme.lower(), parsed.netloc, path, parsed.query, parsed.fragment)))
PY
}
ip_setup_base_url() {
python3 - "$1" "$2" <<'PY'
import ipaddress, sys
address = ipaddress.ip_address(sys.argv[1])
host = f"[{address}]" if address.version == 6 else str(address)
print(f"http://{host}:{int(sys.argv[2])}/")
PY
}
append_setup_token() {
python3 - "$1" "$2" <<'PY'
import sys
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
parsed = urlsplit(sys.argv[1])
query = [(key, value) for key, value in parse_qsl(parsed.query, keep_blank_values=True) if key != "token"]
query.append(("token", sys.argv[2]))
print(urlunsplit((parsed.scheme, parsed.netloc, parsed.path or "/", urlencode(query), parsed.fragment)))
PY
}
main() {
detect_platform
validate_service_manager
if [[ -z "$INSTALL_ROOT" ]]; then
if [[ "$PLATFORM" == "linux" ]]; then
INSTALL_ROOT=/opt/kaidi-erp
@@ -498,28 +604,51 @@ main() {
fi
fi
[[ -n "$GITEA_BASE_URL" ]] || fail 'Gitea URL is required; use --gitea-url or ERP_GITEA_BASE_URL'
validate_https_url "$GITEA_BASE_URL"
install_dependencies
case "$DB_MODE" in
existing) configure_existing_postgres ;;
local) configure_local_postgres ;;
*) fail 'db mode must be existing or local' ;;
validate_download_url "$GITEA_BASE_URL"
if [[ -n "$PUBLIC_URL" ]]; then
case "$PUBLIC_URL" in
http://*|https://*) ;;
*) fail 'public URL must start with http:// or https://' ;;
esac
validate_postgres
fi
say "Detected $PLATFORM/$ARCH"
check_and_install_dependencies
if [[ -n "$PUBLIC_URL" ]]; then
PUBLIC_URL="$(normalize_public_url "$PUBLIC_URL")" || fail 'invalid public URL'
fi
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/kaidi-erp-install.XXXXXX")"
create_curl_config
say 'Downloading signed release...'
say 'Downloading and verifying the signed release...'
download_release
verify_release
prepare_layout
install_release_files
write_configuration
install_service
wait_for_health
say "Installed Kaidi ERP $VERSION"
say "Local URL: http://127.0.0.1:${ERP_SERVER_PORT:-8091}"
write_bootstrap_configuration
start_service
wait_for_installer
local port="${ERP_SERVER_PORT:-8091}" lan_address public_address setup_base local_base lan_base
lan_address="$(detect_lan_address)"
local_base="http://127.0.0.1:${port}/"
lan_base="$(ip_setup_base_url "$lan_address" "$port")"
if [[ -n "$PUBLIC_URL" ]]; then
setup_base="$PUBLIC_URL"
elif public_address="$(detect_public_address)"; then
setup_base="$(ip_setup_base_url "$public_address" "$port")"
else
setup_base="$lan_base"
say 'Public IP detection was unavailable; using the LAN address'
fi
say "Kaidi ERP $VERSION installer is running"
say "Setup URL: $(append_setup_token "$setup_base" "$SETUP_TOKEN")"
say "Local URL: $(append_setup_token "$local_base" "$SETUP_TOKEN")"
if [[ "$lan_address" != "127.0.0.1" && "$lan_base" != "$setup_base" ]]; then
say "LAN URL: $(append_setup_token "$lan_base" "$SETUP_TOKEN")"
fi
say 'Complete PostgreSQL and administrator setup in the browser. The installer will remove itself after the formal service is healthy.'
}
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
if [[ "${BASH_SOURCE[0]:-$0}" == "$0" ]]; then
main "$@"
fi
+58 -3
View File
@@ -8,6 +8,8 @@ group = 'com.kaidi'
version = providers.gradleProperty('releaseVersion')
.orElse(System.getenv('ERP_RELEASE_VERSION') ?: '0.1.0')
.get()
def flywayVersion = '11.20.3'
def postgresqlDriverVersion = '42.7.13'
def productionBuild = providers.gradleProperty('productionBuild')
.map { it.toBoolean() }
.orElse(false)
@@ -21,6 +23,24 @@ repositories {
mavenCentral()
}
sourceSets {
installer {
java.srcDirs = ['src/installer/java']
resources.srcDirs = ['src/installer/resources']
}
installerTest {
java.srcDirs = ['src/installerTest/java']
resources.srcDirs = ['src/installerTest/resources']
compileClasspath += sourceSets.installer.output
runtimeClasspath += sourceSets.installer.output
}
}
configurations {
installerTestImplementation.extendsFrom testImplementation, installerImplementation
installerTestRuntimeOnly.extendsFrom testRuntimeOnly, installerRuntimeOnly
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
@@ -29,9 +49,9 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-websocket'
// Production database and versioned schema migrations.
implementation 'org.flywaydb:flyway-core:10.22.0'
runtimeOnly 'org.flywaydb:flyway-database-postgresql:10.22.0'
runtimeOnly 'org.postgresql:postgresql'
implementation "org.flywaydb:flyway-core:$flywayVersion"
runtimeOnly "org.flywaydb:flyway-database-postgresql:$flywayVersion"
runtimeOnly "org.postgresql:postgresql:$postgresqlDriverVersion"
// SQLite remains available to source-tree development and tests, but is
// deliberately absent from PostgreSQL-only production release artifacts.
@@ -48,12 +68,47 @@ dependencies {
implementation 'org.jsoup:jsoup:1.22.2'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
installerImplementation 'org.springframework.boot:spring-boot-starter-web'
installerImplementation 'org.springframework.boot:spring-boot-starter-validation'
installerImplementation "org.flywaydb:flyway-core:$flywayVersion"
installerRuntimeOnly "org.flywaydb:flyway-database-postgresql:$flywayVersion"
installerRuntimeOnly "org.postgresql:postgresql:$postgresqlDriverVersion"
}
tasks.named('test') {
useJUnitPlatform()
}
tasks.named('processInstallerResources') {
from('src/main/resources') {
include 'db/migration/postgresql/**'
}
}
tasks.register('installerTest', Test) {
description = 'Runs tests for the standalone web installer.'
group = 'verification'
testClassesDirs = sourceSets.installerTest.output.classesDirs
classpath = sourceSets.installerTest.runtimeClasspath
useJUnitPlatform()
}
tasks.register('installerBootJar', org.springframework.boot.gradle.tasks.bundling.BootJar) {
description = 'Builds the standalone first-run web installer.'
group = 'build'
archiveBaseName = 'kaidi-erp-installer'
archiveVersion = project.version
mainClass = 'com.kaidi.oa.install.InstallerApplication'
targetJavaVersion = JavaVersion.VERSION_17
classpath = sourceSets.installer.runtimeClasspath
dependsOn tasks.named('installerClasses')
}
tasks.named('check') {
dependsOn tasks.named('installerTest')
}
springBoot {
buildInfo()
}
@@ -0,0 +1,23 @@
package com.kaidi.oa.install;
import org.springframework.http.HttpStatus;
public final class InstallApiException extends RuntimeException {
private final HttpStatus status;
private final int code;
public InstallApiException(HttpStatus status, int code, String message) {
super(message);
this.status = status;
this.code = code;
}
public HttpStatus status() {
return status;
}
public int code() {
return code;
}
}
@@ -0,0 +1,44 @@
package com.kaidi.oa.install;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.resource.NoResourceFoundException;
@RestControllerAdvice
public class InstallApiExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(InstallApiExceptionHandler.class);
@ExceptionHandler(InstallApiException.class)
public ResponseEntity<InstallApiResponse<Void>> handleInstallError(InstallApiException exception) {
return ResponseEntity.status(exception.status())
.body(InstallApiResponse.error(exception.code(), exception.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<InstallApiResponse<Void>> handleValidation(MethodArgumentNotValidException exception) {
String message = exception.getBindingResult().getFieldErrors().stream()
.findFirst()
.map(error -> error.getDefaultMessage() == null ? "安装参数不正确" : error.getDefaultMessage())
.orElse("安装参数不正确");
return ResponseEntity.badRequest().body(InstallApiResponse.error(40001, message));
}
@ExceptionHandler(NoResourceFoundException.class)
public ResponseEntity<InstallApiResponse<Void>> handleMissingResource() {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(InstallApiResponse.error(40400, "资源不存在"));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<InstallApiResponse<Void>> handleUnexpected(Exception exception) {
log.error("Installer operation failed ({})", exception.getClass().getSimpleName());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(InstallApiResponse.error(50000, "安装操作失败,请检查配置后重试"));
}
}
@@ -0,0 +1,12 @@
package com.kaidi.oa.install;
public record InstallApiResponse<T>(int code, String message, T data) {
public static <T> InstallApiResponse<T> ok(T data) {
return new InstallApiResponse<>(0, "ok", data);
}
public static InstallApiResponse<Void> error(int code, String message) {
return new InstallApiResponse<>(code, message, null);
}
}
@@ -0,0 +1,54 @@
package com.kaidi.oa.install;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
public final class InstallRequest {
private InstallRequest() {
}
public record Database(
@NotBlank(message = "请填写 PostgreSQL 地址")
@Size(max = 253, message = "PostgreSQL 地址过长")
@Pattern(regexp = "[A-Za-z0-9._:\\-\\[\\]]+", message = "PostgreSQL 地址格式不正确")
String host,
@Min(value = 1, message = "PostgreSQL 端口不正确")
@Max(value = 65535, message = "PostgreSQL 端口不正确")
int port,
@NotBlank(message = "请填写数据库名")
@Pattern(regexp = "[A-Za-z_][A-Za-z0-9_-]{0,62}", message = "数据库名格式不正确")
String database,
@NotBlank(message = "请填写数据库账号")
@Pattern(regexp = "[A-Za-z_][A-Za-z0-9_.-]{0,127}", message = "数据库账号格式不正确")
String username,
@NotBlank(message = "请填写数据库密码")
@Size(max = 500, message = "数据库密码过长")
String password,
@NotBlank(message = "请选择 SSL 模式")
@Pattern(regexp = "disable|allow|prefer|require|verify-ca|verify-full", message = "SSL 模式不正确")
String sslMode) {
}
public record Administrator(
@NotBlank(message = "请填写管理员账号")
@Pattern(regexp = "[A-Za-z][A-Za-z0-9_.-]{2,63}", message = "管理员账号需以字母开头,长度为 3-64 位")
String loginName,
@NotBlank(message = "请填写管理员姓名")
@Size(max = 100, message = "管理员姓名不能超过 100 个字符")
String displayName,
@NotBlank(message = "请填写管理员密码")
@Size(min = 8, max = 200, message = "管理员密码长度需为 8-200 位")
String password) {
}
public record Complete(
@NotNull(message = "缺少 PostgreSQL 配置") @Valid Database database,
@NotNull(message = "缺少管理员配置") @Valid Administrator administrator) {
}
}
@@ -0,0 +1,19 @@
package com.kaidi.oa.install;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
@SpringBootApplication(exclude = {
DataSourceAutoConfiguration.class,
FlywayAutoConfiguration.class
})
@EnableConfigurationProperties(InstallerProperties.class)
public class InstallerApplication {
public static void main(String[] args) {
SpringApplication.run(InstallerApplication.class, args);
}
}
@@ -0,0 +1,38 @@
package com.kaidi.oa.install;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/api/install")
public class InstallerController {
private final InstallerService installerService;
public InstallerController(InstallerService installerService) {
this.installerService = installerService;
}
@GetMapping("/status")
public InstallApiResponse<Map<String, Object>> status() {
return InstallApiResponse.ok(installerService.status());
}
@PostMapping("/test-database")
public InstallApiResponse<Map<String, Object>> testDatabase(
@Valid @RequestBody InstallRequest.Database database) {
return InstallApiResponse.ok(installerService.testDatabase(database));
}
@PostMapping("/complete")
public InstallApiResponse<Map<String, Object>> complete(
@Valid @RequestBody InstallRequest.Complete request) {
return InstallApiResponse.ok(installerService.complete(request));
}
}
@@ -0,0 +1,35 @@
package com.kaidi.oa.install;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import java.util.Base64;
final class InstallerPasswordHasher {
private static final int ITERATIONS = 120_000;
private static final int KEY_LENGTH = 256;
private static final int SALT_BYTES = 16;
private static final SecureRandom RANDOM = new SecureRandom();
private InstallerPasswordHasher() {
}
static String hash(String raw) {
byte[] salt = new byte[SALT_BYTES];
RANDOM.nextBytes(salt);
try {
PBEKeySpec spec = new PBEKeySpec(raw.toCharArray(), salt, ITERATIONS, KEY_LENGTH);
byte[] derived = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
.generateSecret(spec)
.getEncoded();
spec.clearPassword();
return "pbkdf2$" + ITERATIONS + "$"
+ Base64.getEncoder().encodeToString(salt) + "$"
+ Base64.getEncoder().encodeToString(derived);
} catch (GeneralSecurityException exception) {
throw new IllegalStateException("PBKDF2 is not available", exception);
}
}
}
@@ -0,0 +1,21 @@
package com.kaidi.oa.install;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
import java.nio.file.Path;
@Validated
@ConfigurationProperties(prefix = "erp.install")
public record InstallerProperties(
@NotBlank String token,
@NotNull Path installRoot,
@NotNull Path configFile,
@NotNull Path pendingFile,
@NotNull Path lockFile,
@NotNull Path operationLockFile,
@NotBlank String version,
boolean exitAfterComplete) {
}
@@ -0,0 +1,525 @@
package com.kaidi.oa.install;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.output.MigrateResult;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.SpringApplication;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.PosixFilePermission;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.time.Instant;
import java.util.EnumSet;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
@Service
public class InstallerService {
private static final Logger log = LoggerFactory.getLogger(InstallerService.class);
private static final Set<PosixFilePermission> OWNER_ONLY = EnumSet.of(
PosixFilePermission.OWNER_READ,
PosixFilePermission.OWNER_WRITE);
private final InstallerProperties properties;
private final ConfigurableApplicationContext applicationContext;
private final ObjectMapper objectMapper;
public InstallerService(
InstallerProperties properties,
ConfigurableApplicationContext applicationContext,
ObjectMapper objectMapper) {
this.properties = properties;
this.applicationContext = applicationContext;
this.objectMapper = objectMapper;
}
public Map<String, Object> status() {
LinkedHashMap<String, Object> result = new LinkedHashMap<>();
result.put("version", properties.version());
result.put("javaVersion", System.getProperty("java.version"));
result.put("minimumJava", 17);
result.put("minimumPostgres", 15);
result.put("redisRequired", false);
result.put("locked", Files.exists(properties.lockFile()));
result.put("pending", Files.exists(properties.pendingFile()));
result.put("ready", !Files.exists(properties.lockFile()) && !Files.exists(properties.pendingFile()));
return result;
}
public Map<String, Object> testDatabase(InstallRequest.Database database) {
ensureUnlocked();
DatabaseCheck check = verifyDatabase(database);
LinkedHashMap<String, Object> result = new LinkedHashMap<>();
result.put("connected", true);
result.put("postgresVersion", check.productVersion());
result.put("postgresMajor", check.majorVersion());
result.put("pgTrgm", true);
result.put("message", "PostgreSQL 连接和扩展检查通过");
return result;
}
public Map<String, Object> complete(InstallRequest.Complete request) {
ensureUnlocked();
Path operationLock = properties.operationLockFile().toAbsolutePath().normalize();
try {
Files.createDirectories(operationLock.getParent());
try (FileChannel channel = FileChannel.open(
operationLock,
StandardOpenOption.CREATE,
StandardOpenOption.WRITE);
FileLock ignored = tryLock(channel)) {
if (ignored == null) {
throw new InstallApiException(HttpStatus.CONFLICT, 40901, "安装正在进行,请勿重复提交");
}
return completeLocked(request);
}
} catch (InstallApiException exception) {
throw exception;
} catch (IOException exception) {
throw new InstallApiException(HttpStatus.INTERNAL_SERVER_ERROR, 50011, "无法创建安装锁,请检查目录权限");
}
}
private Map<String, Object> completeLocked(InstallRequest.Complete request) {
ensureUnlocked();
DatabaseCheck check = verifyDatabase(request.database());
rejectExistingInstallation(request.database());
writeRuntimeConfiguration(request.database());
MigrateResult migration;
try {
migration = Flyway.configure()
.dataSource(jdbcUrl(request.database()), request.database().username(), request.database().password())
.locations("classpath:db/migration/postgresql")
.cleanDisabled(true)
.validateOnMigrate(true)
.baselineOnMigrate(false)
.load()
.migrate();
} catch (RuntimeException exception) {
log.error(
"PostgreSQL migration failed for database {} as user {}",
request.database().database().strip(),
request.database().username().strip(),
exception);
throw new InstallApiException(
HttpStatus.UNPROCESSABLE_ENTITY,
42203,
"数据库迁移失败,请确认数据库为空且账号拥有建表权限");
}
createAdministrator(request.database(), request.administrator());
writePendingMarker(check, migration.migrationsExecuted, request.administrator().loginName());
scheduleShutdown();
LinkedHashMap<String, Object> result = new LinkedHashMap<>();
result.put("installed", true);
result.put("restarting", true);
result.put("version", properties.version());
result.put("migrationsExecuted", migration.migrationsExecuted);
result.put("message", "初始化完成,正在启动正式服务");
return result;
}
private FileLock tryLock(FileChannel channel) throws IOException {
try {
return channel.tryLock();
} catch (OverlappingFileLockException exception) {
return null;
}
}
private void ensureUnlocked() {
if (Files.exists(properties.lockFile())) {
throw new InstallApiException(HttpStatus.GONE, 41001, "系统已经完成安装");
}
if (Files.exists(properties.pendingFile())) {
throw new InstallApiException(HttpStatus.CONFLICT, 40902, "系统正在启动正式服务,请稍候");
}
}
private DatabaseCheck verifyDatabase(InstallRequest.Database database) {
try (Connection connection = openConnection(database)) {
int versionNumber;
try (Statement statement = connection.createStatement();
ResultSet result = statement.executeQuery("SHOW server_version_num")) {
if (!result.next()) {
throw new SQLException("PostgreSQL did not report a version");
}
versionNumber = Integer.parseInt(result.getString(1));
}
int major = versionNumber / 10_000;
if (major < 15) {
throw new InstallApiException(
HttpStatus.UNPROCESSABLE_ENTITY,
42201,
"PostgreSQL 版本过低,需要 15 或更高版本");
}
requireDatabaseOwnership(connection);
try (Statement statement = connection.createStatement()) {
statement.execute("SELECT 1");
statement.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm");
}
try (PreparedStatement statement = connection.prepareStatement(
"SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_trgm')");
ResultSet result = statement.executeQuery()) {
if (!result.next() || !result.getBoolean(1)) {
throw new SQLException("pg_trgm is unavailable");
}
}
requirePgTrgmOwnership(connection);
return new DatabaseCheck(major, connection.getMetaData().getDatabaseProductVersion());
} catch (InstallApiException exception) {
throw exception;
} catch (SQLException | NumberFormatException exception) {
log.warn(
"PostgreSQL verification failed for database {} as user {}: {}",
database.database().strip(),
database.username().strip(),
exception.getMessage());
throw new InstallApiException(
HttpStatus.UNPROCESSABLE_ENTITY,
42202,
"无法连接 PostgreSQL,请检查地址、账号、密码、SSL 和网络设置");
}
}
private void requireDatabaseOwnership(Connection connection) throws SQLException {
try (Statement statement = connection.createStatement();
ResultSet result = statement.executeQuery(
"SELECT "
+ "pg_get_userbyid(datdba) = current_user AS owns_database, "
+ "has_schema_privilege(current_user, 'public', 'USAGE') AS can_use_schema, "
+ "has_schema_privilege(current_user, 'public', 'CREATE') AS can_create_in_schema "
+ "FROM pg_database WHERE datname = current_database()")) {
if (!result.next()
|| !result.getBoolean("owns_database")
|| !result.getBoolean("can_use_schema")
|| !result.getBoolean("can_create_in_schema")) {
throw new InstallApiException(
HttpStatus.UNPROCESSABLE_ENTITY,
42206,
"数据库账号必须是该空数据库的所有者,并拥有 public schema 建表权限");
}
}
}
private void requirePgTrgmOwnership(Connection connection) throws SQLException {
try (Statement statement = connection.createStatement();
ResultSet result = statement.executeQuery(
"SELECT pg_get_userbyid(extowner) = current_user "
+ "FROM pg_extension WHERE extname = 'pg_trgm'")) {
if (!result.next() || !result.getBoolean(1)) {
throw new InstallApiException(
HttpStatus.UNPROCESSABLE_ENTITY,
42207,
"数据库账号必须拥有 pg_trgm 扩展;请由管理员删除预建扩展后重新测试");
}
}
}
private void rejectExistingInstallation(InstallRequest.Database database) {
try (Connection connection = openConnection(database);
PreparedStatement tableQuery = connection.prepareStatement(
"SELECT to_regclass('public.sys_user') IS NOT NULL");
ResultSet tableResult = tableQuery.executeQuery()) {
if (!tableResult.next() || !tableResult.getBoolean(1)) {
return;
}
try (Statement countQuery = connection.createStatement();
ResultSet countResult = countQuery.executeQuery("SELECT COUNT(*) FROM public.sys_user")) {
if (countResult.next() && countResult.getLong(1) > 0) {
throw new InstallApiException(
HttpStatus.CONFLICT,
40903,
"该数据库已经包含业务用户,为防止覆盖现有系统已停止安装");
}
}
} catch (InstallApiException exception) {
throw exception;
} catch (SQLException exception) {
throw new InstallApiException(HttpStatus.UNPROCESSABLE_ENTITY, 42204, "无法检查数据库现有数据");
}
}
private void createAdministrator(
InstallRequest.Database database,
InstallRequest.Administrator administrator) {
try (Connection connection = openConnection(database)) {
connection.setAutoCommit(false);
connection.setTransactionIsolation(Connection.TRANSACTION_SERIALIZABLE);
try {
try (Statement statement = connection.createStatement()) {
statement.execute("LOCK TABLE public.sys_user IN SHARE ROW EXCLUSIVE MODE");
}
try (Statement statement = connection.createStatement();
ResultSet result = statement.executeQuery("SELECT COUNT(*) FROM public.sys_user")) {
if (!result.next() || result.getLong(1) != 0) {
throw new InstallApiException(
HttpStatus.CONFLICT,
40903,
"该数据库已经包含业务用户,为防止覆盖现有系统已停止安装");
}
}
long adminRoleId = upsertRole(connection, "ADMIN", "系统管理员", "系统内置管理员角色");
upsertRole(connection, "USER", "普通用户", "系统内置普通用户角色");
upsertRole(connection, "APPROVER", "审批人", "系统内置审批角色");
long userId;
try (PreparedStatement statement = connection.prepareStatement(
"INSERT INTO public.sys_user "
+ "(enabled, display_name, login_name, password, dept_id, email, phone, title) "
+ "VALUES (TRUE, ?, ?, ?, NULL, NULL, NULL, NULL) RETURNING id")) {
statement.setString(1, administrator.displayName().strip());
statement.setString(2, administrator.loginName().strip());
statement.setString(3, InstallerPasswordHasher.hash(administrator.password()));
try (ResultSet result = statement.executeQuery()) {
if (!result.next()) {
throw new SQLException("administrator insert returned no id");
}
userId = result.getLong(1);
}
}
try (PreparedStatement statement = connection.prepareStatement(
"INSERT INTO public.sys_user_role (user_id, role_id) VALUES (?, ?)")) {
statement.setLong(1, userId);
statement.setLong(2, adminRoleId);
statement.executeUpdate();
}
connection.commit();
} catch (Exception exception) {
connection.rollback();
if (exception instanceof InstallApiException installApiException) {
throw installApiException;
}
throw exception;
}
} catch (InstallApiException exception) {
throw exception;
} catch (Exception exception) {
log.error(
"Administrator initialization failed for database {} as user {}",
database.database().strip(),
database.username().strip(),
exception);
throw new InstallApiException(HttpStatus.UNPROCESSABLE_ENTITY, 42205, "管理员账号初始化失败");
}
}
private long upsertRole(Connection connection, String code, String name, String description) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(
"INSERT INTO public.sys_role (system, code, description, name) VALUES (TRUE, ?, ?, ?) "
+ "ON CONFLICT (code) DO UPDATE SET system = TRUE, description = EXCLUDED.description, "
+ "name = EXCLUDED.name RETURNING id")) {
statement.setString(1, code);
statement.setString(2, description);
statement.setString(3, name);
try (ResultSet result = statement.executeQuery()) {
if (!result.next()) {
throw new SQLException("role insert returned no id");
}
return result.getLong(1);
}
}
}
private Connection openConnection(InstallRequest.Database database) throws SQLException {
Properties connectionProperties = new Properties();
connectionProperties.setProperty("user", database.username().strip());
connectionProperties.setProperty("password", database.password());
return DriverManager.getConnection(jdbcUrl(database), connectionProperties);
}
private String jdbcUrl(InstallRequest.Database database) {
String host = database.host().strip();
if (host.startsWith("[") && host.endsWith("]")) {
host = host.substring(1, host.length() - 1);
}
if (host.contains(":")) {
host = "[" + host + "]";
}
return "jdbc:postgresql://" + host + ":" + database.port() + "/" + database.database().strip()
+ "?sslmode=" + database.sslMode()
+ "&connectTimeout=10&socketTimeout=20&ApplicationName=KaidiERPInstaller";
}
private void writeRuntimeConfiguration(InstallRequest.Database database) {
LinkedHashMap<String, String> values = new LinkedHashMap<>();
Path installRoot = properties.installRoot().toAbsolutePath().normalize();
Path stateRoot = properties.pendingFile().toAbsolutePath().normalize().getParent();
int port = integerEnvironment("SERVER_PORT", 8091);
values.put("ERP_INSTALL_ROOT", installRoot.toString());
values.put("ERP_CONFIG_FILE", properties.configFile().toAbsolutePath().normalize().toString());
values.put("ERP_RUN_DIR", installRoot.resolve("run").toString());
values.put("ERP_STATE_FILE", environment("ERP_STATE_FILE", stateRoot.resolve("update-state.json").toString()));
values.put("ERP_INSTALL_PENDING_FILE", properties.pendingFile().toAbsolutePath().normalize().toString());
values.put("ERP_INSTALL_LOCK_FILE", properties.lockFile().toAbsolutePath().normalize().toString());
values.put("ERP_INSTALL_OPERATION_LOCK_FILE", properties.operationLockFile().toAbsolutePath().normalize().toString());
values.put("ERP_INSTALL_LOG_FILE", stateRoot.resolve("install-formal.log").toString());
values.put("ERP_INSTALL_HEALTH_TIMEOUT_SECONDS", environment("ERP_INSTALL_HEALTH_TIMEOUT_SECONDS", "240"));
values.put("ERP_SETUP_TOKEN", properties.token());
values.put("ERP_INSTALLER_JAR", installRoot.resolve("installer/kaidi-erp-installer.jar").toString());
values.put("ERP_JAR_PATH", installRoot.resolve("current/app/kaidi-erp.jar").toString());
values.put("ERP_JAVA_BIN", environment("ERP_JAVA_BIN", ""));
values.put("ERP_JAVA_OPTS", environment("ERP_JAVA_OPTS", "-Xms512m -Xmx2g"));
values.put("ERP_HEALTH_URL", "http://127.0.0.1:" + port + "/api/oa/health");
values.put("ERP_UPDATE_PUBLIC_KEY_FILE", environment(
"ERP_UPDATE_PUBLIC_KEY_FILE",
properties.configFile().toAbsolutePath().normalize().getParent().resolve("release-public-key.pem").toString()));
values.put("ERP_UPDATE_REQUIRE_SIGNATURE", environment("ERP_UPDATE_REQUIRE_SIGNATURE", "true"));
values.put("ERP_UPDATE_BACKUP_MODE", environment("ERP_UPDATE_BACKUP_MODE", "none"));
values.put("ERP_UPDATE_HEALTH_TIMEOUT_SECONDS", environment("ERP_UPDATE_HEALTH_TIMEOUT_SECONDS", "120"));
values.put("ERP_UPDATE_HEALTH_POLL_SECONDS", environment("ERP_UPDATE_HEALTH_POLL_SECONDS", "2"));
values.put("ERP_PGHOST", database.host().strip());
values.put("ERP_PGPORT", Integer.toString(database.port()));
values.put("ERP_PGDATABASE", database.database().strip());
values.put("ERP_PGSSLMODE", database.sslMode());
values.put("SPRING_PROFILES_ACTIVE", "postgres");
values.put("SERVER_PORT", Integer.toString(port));
values.put("OA_DB_URL", jdbcUrl(database));
values.put("OA_DB_USERNAME", database.username().strip());
values.put("OA_DB_PASSWORD", database.password());
values.put("OA_DB_POOL_MAX", environment("OA_DB_POOL_MAX", "20"));
values.put("OA_DB_POOL_MIN", environment("OA_DB_POOL_MIN", "2"));
values.put("OA_SEED_DEMO", "false");
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_REPOSITORY", environment("OA_UPDATE_REPOSITORY", "awaioi/ERP"));
values.put("OA_UPDATE_CHANNEL", environment("OA_UPDATE_CHANNEL", "stable"));
values.put("OA_UPDATE_TOKEN", environment("OA_UPDATE_TOKEN", ""));
values.put("OA_UPDATE_HELPER_COMMAND", installRoot.resolve("current/bin/erp-update").toString());
values.put("OA_UPDATE_STATE_FILE", environment(
"OA_UPDATE_STATE_FILE",
stateRoot.resolve("update-state.json").toString()));
values.put("OA_UPDATE_ALLOW_INSECURE_HTTP", environment("OA_UPDATE_ALLOW_INSECURE_HTTP", "false"));
StringBuilder content = new StringBuilder();
values.forEach((key, value) -> content.append(key)
.append("='")
.append(shellSingleQuote(value))
.append("'\n"));
atomicWrite(properties.configFile(), content.toString().getBytes(StandardCharsets.UTF_8));
}
private void writePendingMarker(DatabaseCheck check, int migrations, String loginName) {
LinkedHashMap<String, Object> marker = new LinkedHashMap<>();
marker.put("status", "PENDING");
marker.put("version", properties.version());
marker.put("createdAt", Instant.now().toString());
marker.put("postgresMajor", check.majorVersion());
marker.put("migrationsExecuted", migrations);
marker.put("administrator", loginName.strip());
try {
atomicWrite(properties.pendingFile(), objectMapper.writeValueAsBytes(marker));
} catch (IOException exception) {
throw new InstallApiException(HttpStatus.INTERNAL_SERVER_ERROR, 50012, "无法写入安装状态,请检查目录权限");
}
}
private void atomicWrite(Path target, byte[] content) {
Path absolute = target.toAbsolutePath().normalize();
Path parent = absolute.getParent();
Path temporary = null;
try {
Files.createDirectories(parent);
temporary = Files.createTempFile(parent, "." + absolute.getFileName() + "-", ".tmp");
setOwnerOnly(temporary);
try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.WRITE)) {
channel.write(ByteBuffer.wrap(content));
channel.force(true);
}
try {
Files.move(temporary, absolute, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException exception) {
Files.move(temporary, absolute, StandardCopyOption.REPLACE_EXISTING);
}
setOwnerOnly(absolute);
temporary = null;
} catch (IOException exception) {
throw new InstallApiException(HttpStatus.INTERNAL_SERVER_ERROR, 50010, "无法安全写入安装配置,请检查目录权限");
} finally {
if (temporary != null) {
try {
Files.deleteIfExists(temporary);
} catch (IOException ignored) {
// Best effort cleanup of a file containing protected configuration.
}
}
}
}
private void setOwnerOnly(Path path) throws IOException {
try {
Files.setPosixFilePermissions(path, OWNER_ONLY);
} catch (UnsupportedOperationException ignored) {
// Windows is not a supported deployment target; this keeps local tests portable.
}
}
private String shellSingleQuote(String value) {
if (value.indexOf('\0') >= 0 || value.indexOf('\n') >= 0 || value.indexOf('\r') >= 0) {
throw new InstallApiException(HttpStatus.BAD_REQUEST, 40002, "配置值不能包含换行符");
}
return value.replace("'", "'\\''");
}
private String environment(String name, String fallback) {
String value = System.getenv(name);
return value == null ? fallback : value;
}
private int integerEnvironment(String name, int fallback) {
String value = environment(name, Integer.toString(fallback));
try {
int parsed = Integer.parseInt(value);
return parsed >= 1 && parsed <= 65535 ? parsed : fallback;
} catch (NumberFormatException exception) {
return fallback;
}
}
private void scheduleShutdown() {
if (!properties.exitAfterComplete()) {
return;
}
Thread shutdown = new Thread(() -> {
try {
Thread.sleep(1_500L);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
}
System.exit(SpringApplication.exit(applicationContext, () -> 0));
}, "installer-complete-shutdown");
shutdown.setDaemon(false);
shutdown.start();
}
private record DatabaseCheck(int majorVersion, String productVersion) {
}
}
@@ -0,0 +1,44 @@
package com.kaidi.oa.install;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
@Component
public class SetupTokenFilter extends OncePerRequestFilter {
private final byte[] expectedToken;
public SetupTokenFilter(InstallerProperties properties) {
this.expectedToken = properties.token().getBytes(StandardCharsets.UTF_8);
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !request.getRequestURI().startsWith("/api/install/")
|| "OPTIONS".equalsIgnoreCase(request.getMethod());
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
String supplied = request.getHeader("X-Setup-Token");
byte[] candidate = supplied == null ? new byte[0] : supplied.getBytes(StandardCharsets.UTF_8);
if (!MessageDigest.isEqual(expectedToken, candidate)) {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write("{\"code\":40301,\"message\":\"安装链接无效或已过期\",\"data\":null}");
return;
}
chain.doFilter(request, response);
}
}
@@ -0,0 +1,28 @@
server:
port: ${SERVER_PORT:8091}
shutdown: graceful
forward-headers-strategy: framework
spring:
application:
name: kaidi-erp-installer
lifecycle:
timeout-per-shutdown-phase: 5s
jackson:
default-property-inclusion: non_null
erp:
install:
token: ${ERP_SETUP_TOKEN:}
install-root: ${ERP_INSTALL_ROOT:./runtime}
config-file: ${ERP_CONFIG_FILE:./runtime/config/erp.env}
pending-file: ${ERP_INSTALL_PENDING_FILE:./runtime/install.pending}
lock-file: ${ERP_INSTALL_LOCK_FILE:./runtime/install.lock}
operation-lock-file: ${ERP_INSTALL_OPERATION_LOCK_FILE:./runtime/install-operation.lock}
version: ${ERP_RELEASE_VERSION:development}
exit-after-complete: ${ERP_INSTALL_EXIT_AFTER_COMPLETE:true}
logging:
level:
root: INFO
com.kaidi.oa.install: INFO
@@ -0,0 +1,725 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex,nofollow">
<title>凯迪 ERP 安装向导</title>
<style>
:root {
color-scheme: light;
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, sans-serif;
color: #172033;
background: #f3f5f8;
font-synthesis: none;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; background: #f3f5f8; }
button, input, select { font: inherit; letter-spacing: 0; }
button { cursor: pointer; }
.topbar {
height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 32px;
color: #fff;
background: #1d3557;
border-bottom: 3px solid #2f6fed;
}
.brand { display: flex; align-items: center; gap: 12px; min-width: 0; }
.brand-mark {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border: 1px solid #91b3e7;
border-radius: 6px;
background: #fff;
color: #1d3557;
font-size: 13px;
font-weight: 800;
}
.brand-name { font-size: 16px; font-weight: 700; white-space: nowrap; }
.topbar-label { color: #dbe7f8; font-size: 13px; white-space: nowrap; }
.shell {
width: min(1040px, calc(100% - 40px));
margin: 36px auto 48px;
display: grid;
grid-template-columns: 232px minmax(0, 1fr);
gap: 24px;
align-items: start;
}
.steps {
padding: 18px 0;
background: #fff;
border: 1px solid #dfe4ec;
border-radius: 8px;
}
.step {
position: relative;
display: grid;
grid-template-columns: 30px 1fr;
gap: 11px;
min-height: 62px;
padding: 8px 18px;
color: #748096;
}
.step:not(:last-child)::after {
content: "";
position: absolute;
left: 32px;
top: 42px;
width: 1px;
height: 28px;
background: #d8dee8;
}
.step-index {
width: 28px;
height: 28px;
display: grid;
place-items: center;
border: 1px solid #c9d1de;
border-radius: 50%;
background: #fff;
font-size: 12px;
font-weight: 700;
}
.step-copy { padding-top: 4px; }
.step-title { display: block; color: #4b586d; font-size: 14px; font-weight: 650; }
.step-state { display: block; margin-top: 4px; font-size: 12px; }
.step.active .step-index { border-color: #2f6fed; background: #2f6fed; color: #fff; }
.step.active .step-title { color: #183a72; }
.step.done .step-index { border-color: #25805a; background: #e8f5ee; color: #187149; }
.panel {
min-height: 540px;
padding: 30px 34px 28px;
background: #fff;
border: 1px solid #dfe4ec;
border-radius: 8px;
box-shadow: 0 6px 18px rgba(24, 38, 61, 0.06);
}
.panel-head { padding-bottom: 20px; border-bottom: 1px solid #e8ebf0; }
h1 { margin: 0; color: #172033; font-size: 24px; line-height: 1.3; letter-spacing: 0; }
.subtitle { margin: 8px 0 0; color: #69758a; font-size: 14px; line-height: 1.65; }
.section { padding-top: 24px; }
.check-list { display: grid; gap: 10px; }
.check-row {
min-height: 52px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 14px;
border: 1px solid #e1e6ed;
border-radius: 6px;
background: #fafbfc;
}
.check-label { font-size: 14px; font-weight: 600; }
.check-detail { margin-top: 3px; color: #778398; font-size: 12px; }
.badge {
flex: 0 0 auto;
padding: 4px 8px;
border-radius: 4px;
background: #e8f5ee;
color: #187149;
font-size: 12px;
font-weight: 650;
}
.badge.wait { background: #eef1f5; color: #667287; }
.form-grid { display: grid; grid-template-columns: 1fr 150px; gap: 18px 16px; }
.field.full { grid-column: 1 / -1; }
.field label { display: block; margin-bottom: 7px; color: #334057; font-size: 13px; font-weight: 650; }
.required::after { content: " *"; color: #bd3131; }
input, select {
width: 100%;
height: 42px;
padding: 0 12px;
border: 1px solid #cbd3df;
border-radius: 5px;
outline: none;
background: #fff;
color: #172033;
transition: border-color .15s, box-shadow .15s;
}
input:focus, select:focus { border-color: #2f6fed; box-shadow: 0 0 0 3px rgba(47, 111, 237, .12); }
input.invalid { border-color: #bd3131; }
.hint { margin-top: 6px; color: #7b8799; font-size: 12px; line-height: 1.5; }
.notice {
margin-top: 18px;
padding: 12px 14px;
border: 1px solid #bcdcca;
border-radius: 6px;
background: #f0f8f4;
color: #176542;
font-size: 13px;
line-height: 1.55;
}
.notice.error { border-color: #ebc1c1; background: #fff5f5; color: #a52a2a; }
.notice.info { border-color: #c8d7ee; background: #f3f7fd; color: #315b91; }
.actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 28px;
padding-top: 20px;
border-top: 1px solid #e8ebf0;
}
.btn {
min-width: 104px;
height: 40px;
padding: 0 17px;
border: 1px solid #c8d0dc;
border-radius: 5px;
background: #fff;
color: #344056;
font-weight: 650;
}
.btn:hover { border-color: #8fa1ba; background: #f7f8fa; }
.btn.primary { border-color: #2f6fed; background: #2f6fed; color: #fff; }
.btn.primary:hover { border-color: #245fcf; background: #245fcf; }
.btn:disabled { cursor: not-allowed; border-color: #d7dce4; background: #e9edf2; color: #939dad; }
.summary { display: grid; gap: 1px; overflow: hidden; border: 1px solid #dfe4ec; border-radius: 6px; background: #dfe4ec; }
.summary-row { display: grid; grid-template-columns: 148px 1fr; gap: 16px; padding: 13px 15px; background: #fff; font-size: 13px; }
.summary-key { color: #707c90; }
.summary-value { min-width: 0; overflow-wrap: anywhere; color: #263248; font-weight: 600; }
.progress-list { display: grid; gap: 12px; margin-top: 8px; }
.progress-item { display: flex; align-items: center; gap: 12px; color: #6b7689; font-size: 14px; }
.progress-dot { width: 10px; height: 10px; border: 2px solid #aab3c1; border-radius: 50%; }
.progress-item.running { color: #254e86; font-weight: 600; }
.progress-item.running .progress-dot { border-color: #2f6fed; background: #2f6fed; box-shadow: 0 0 0 4px #eaf1fd; }
.progress-item.done { color: #187149; }
.progress-item.done .progress-dot { border-color: #25805a; background: #25805a; }
.install-progress { margin-top: 22px; padding-top: 18px; border-top: 1px solid #e8ebf0; }
.install-progress-head, .install-progress-meta, .install-console-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.install-progress-label { color: #334057; font-size: 13px; font-weight: 650; }
.install-progress-value { color: #1d3557; font-size: 14px; font-variant-numeric: tabular-nums; }
.install-progress-track {
height: 10px;
margin-top: 10px;
overflow: hidden;
border-radius: 4px;
background: #e5eaf1;
}
.install-progress-bar {
width: 0;
height: 100%;
background: #2f6fed;
transition: width .35s ease;
}
.install-progress-meta { margin-top: 8px; color: #707c90; font-size: 12px; }
.install-console { margin-top: 14px; overflow: hidden; border: 1px solid #28364a; border-radius: 6px; background: #182233; }
.install-console-head { min-height: 36px; padding: 0 12px; border-bottom: 1px solid #314158; background: #111a28; }
.install-console-title { color: #dce5f2; font-size: 12px; font-weight: 650; }
.install-console-state { color: #7dd3a8; font-size: 12px; }
.install-log {
height: 148px;
margin: 0;
overflow: auto;
padding: 10px 12px;
color: #c9d5e5;
font: 12px/1.65 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
letter-spacing: 0;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.complete-mark {
width: 58px;
height: 58px;
display: grid;
place-items: center;
margin-bottom: 18px;
border-radius: 50%;
background: #e8f5ee;
color: #187149;
font-size: 28px;
font-weight: 800;
}
.hidden { display: none !important; }
@media (max-width: 760px) {
.topbar { height: 58px; padding: 0 18px; }
.topbar-label { display: none; }
.shell { width: calc(100% - 24px); margin: 18px auto 30px; display: block; }
.steps { display: grid; grid-template-columns: repeat(4, 1fr); margin-bottom: 14px; padding: 10px 4px; }
.step { display: flex; min-height: auto; padding: 4px; flex-direction: column; align-items: center; gap: 5px; text-align: center; }
.step:not(:last-child)::after { left: calc(50% + 18px); top: 18px; width: calc(100% - 36px); height: 1px; }
.step-copy { padding: 0; }
.step-title { font-size: 11px; }
.step-state { display: none; }
.panel { min-height: 0; padding: 23px 18px 20px; }
h1 { font-size: 21px; }
.form-grid { grid-template-columns: 1fr; gap: 15px; }
.field.full { grid-column: auto; }
.summary-row { grid-template-columns: 1fr; gap: 5px; }
.actions { flex-wrap: wrap-reverse; }
.btn { flex: 1 1 120px; }
.install-progress-meta { align-items: flex-start; flex-direction: column; gap: 4px; }
.install-log { height: 172px; }
}
</style>
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="brand-mark">KD</span>
<span class="brand-name">凯迪 ERP + OA</span>
</div>
<span class="topbar-label">首次安装向导</span>
</header>
<main class="shell">
<nav class="steps" aria-label="安装步骤">
<div class="step active" data-step-nav="1"><span class="step-index">1</span><span class="step-copy"><span class="step-title">环境检查</span><span class="step-state">运行环境</span></span></div>
<div class="step" data-step-nav="2"><span class="step-index">2</span><span class="step-copy"><span class="step-title">数据库</span><span class="step-state">PostgreSQL</span></span></div>
<div class="step" data-step-nav="3"><span class="step-index">3</span><span class="step-copy"><span class="step-title">管理员</span><span class="step-state">初始账号</span></span></div>
<div class="step" data-step-nav="4"><span class="step-index">4</span><span class="step-copy"><span class="step-title">安装</span><span class="step-state">初始化系统</span></span></div>
</nav>
<section class="panel">
<div id="fatal" class="hidden">
<div class="panel-head"><h1>安装链接不可用</h1><p class="subtitle">请回到服务器终端,使用安装程序输出的完整链接重新访问。</p></div>
<div class="notice error" id="fatal-message">链接缺少一次性安装令牌。</div>
</div>
<div id="view-1">
<div class="panel-head"><h1>环境检查</h1><p class="subtitle">确认安装程序和服务器运行环境已经准备就绪。</p></div>
<div class="section check-list">
<div class="check-row"><div><div class="check-label">Java 运行环境</div><div class="check-detail" id="java-detail">正在检测</div></div><span class="badge wait" id="java-badge">检测中</span></div>
<div class="check-row"><div><div class="check-label">安装程序</div><div class="check-detail" id="installer-detail">正在读取版本</div></div><span class="badge wait" id="installer-badge">检测中</span></div>
<div class="check-row"><div><div class="check-label">生产数据库要求</div><div class="check-detail">PostgreSQL 15 或更高版本</div></div><span class="badge">支持</span></div>
<div class="check-row"><div><div class="check-label">Redis</div><div class="check-detail">当前版本没有 Redis 运行依赖</div></div><span class="badge">无需配置</span></div>
</div>
<div id="status-message" class="notice info">正在连接本机安装服务。</div>
<div class="actions"><button class="btn primary" id="start-button" disabled>开始配置</button></div>
</div>
<div id="view-2" class="hidden">
<div class="panel-head"><h1>连接 PostgreSQL</h1><p class="subtitle">填写生产数据库连接信息,并完成真实连接测试。</p></div>
<form id="database-form" class="section form-grid" autocomplete="off">
<div class="field"><label class="required" for="db-host">服务器地址</label><input id="db-host" name="host" value="127.0.0.1" maxlength="253" required></div>
<div class="field"><label class="required" for="db-port">端口</label><input id="db-port" name="port" type="number" min="1" max="65535" value="5432" required></div>
<div class="field"><label class="required" for="db-name">数据库名</label><input id="db-name" name="database" value="kaidi_erp" maxlength="63" required></div>
<div class="field"><label class="required" for="db-ssl">SSL 模式</label><select id="db-ssl" name="sslMode"><option value="prefer">prefer</option><option value="require">require</option><option value="verify-full">verify-full</option><option value="verify-ca">verify-ca</option><option value="disable">disable</option><option value="allow">allow</option></select></div>
<div class="field full"><label class="required" for="db-user">数据库账号</label><input id="db-user" name="username" maxlength="128" autocomplete="username" required></div>
<div class="field full"><label class="required" for="db-password">数据库密码</label><input id="db-password" name="password" type="password" maxlength="500" autocomplete="new-password" required><div class="hint">密码只提交给当前服务器上的安装程序,不会显示在确认页。</div></div>
</form>
<div id="db-message" class="notice info">连接测试通过后才能继续。</div>
<div class="actions"><button class="btn" data-back="1">上一步</button><button class="btn" id="test-db-button">测试连接</button><button class="btn primary" id="database-next" disabled>下一步</button></div>
</div>
<div id="view-3" class="hidden">
<div class="panel-head"><h1>设置管理员</h1><p class="subtitle">创建系统的第一个管理员账号。</p></div>
<form id="admin-form" class="section form-grid" autocomplete="off">
<div class="field full"><label class="required" for="admin-login">管理员账号</label><input id="admin-login" name="loginName" value="admin" maxlength="64" autocomplete="username" required><div class="hint">以字母开头,可使用字母、数字、点、下划线和短横线。</div></div>
<div class="field full"><label class="required" for="admin-name">管理员姓名</label><input id="admin-name" name="displayName" maxlength="100" required></div>
<div class="field"><label class="required" for="admin-password">管理员密码</label><input id="admin-password" name="password" type="password" minlength="8" maxlength="200" autocomplete="new-password" required></div>
<div class="field"><label class="required" for="admin-confirm">确认密码</label><input id="admin-confirm" name="confirmPassword" type="password" minlength="8" maxlength="200" autocomplete="new-password" required></div>
</form>
<div id="admin-message" class="notice info">密码至少 8 位,请使用仅管理员本人知道的强密码。</div>
<div class="actions"><button class="btn" data-back="2">上一步</button><button class="btn primary" id="admin-next">下一步</button></div>
</div>
<div id="view-4" class="hidden">
<div id="confirm-view">
<div class="panel-head"><h1>确认并安装</h1><p class="subtitle">确认连接目标和管理员账号。安装开始后会执行数据库迁移。</p></div>
<div class="section summary" id="summary"></div>
<div class="notice info">安装不会覆盖已经包含业务用户的数据库。检测到现有系统时会立即停止。</div>
<div class="actions"><button class="btn" data-back="3">上一步</button><button class="btn primary" id="install-button">开始安装</button></div>
</div>
<div id="installing-view" class="hidden">
<div class="panel-head"><h1>正在安装</h1><p class="subtitle">请保持当前页面打开。</p></div>
<div class="section progress-list">
<div class="progress-item running" data-progress="1"><span class="progress-dot"></span><span>验证 PostgreSQL 连接</span></div>
<div class="progress-item" data-progress="2"><span class="progress-dot"></span><span>执行数据库迁移</span></div>
<div class="progress-item" data-progress="3"><span class="progress-dot"></span><span>创建管理员账号</span></div>
<div class="progress-item" data-progress="4"><span class="progress-dot"></span><span>启动正式服务并写入安装锁</span></div>
</div>
<div class="install-progress">
<div class="install-progress-head">
<span class="install-progress-label" id="install-phase">准备安装</span>
<strong class="install-progress-value" id="install-percent">0%</strong>
</div>
<div class="install-progress-track" id="install-progress-track" role="progressbar" aria-label="安装进度" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<div class="install-progress-bar" id="install-progress-bar"></div>
</div>
<div class="install-progress-meta">
<span id="install-elapsed">本页已等待 0 秒</span>
<span id="install-check">等待安装任务</span>
</div>
</div>
<div class="install-console">
<div class="install-console-head"><span class="install-console-title">安装状态</span><span class="install-console-state" id="install-console-state">运行中</span></div>
<pre class="install-log" id="install-log" aria-live="polite"></pre>
</div>
<div id="install-message" class="notice info">正在初始化,数据库规模较大时可能需要几分钟。</div>
</div>
<div id="complete-view" class="hidden">
<div class="complete-mark"></div>
<div class="panel-head"><h1>安装完成</h1><p class="subtitle">正式服务已经通过健康检查,安装向导已被删除。</p></div>
<div class="notice">管理员账号已创建,可以进入系统登录。</div>
<div class="actions"><button class="btn primary" id="enter-button">进入系统</button></div>
</div>
</div>
</section>
</main>
<script>
(() => {
const query = new URLSearchParams(location.search);
const queryToken = query.get('token') || '';
if (queryToken) sessionStorage.setItem('kaidi.setup.token', queryToken);
const token = queryToken || sessionStorage.getItem('kaidi.setup.token') || '';
if (queryToken) {
query.delete('token');
const clean = location.pathname + (query.toString() ? `?${query}` : '') + location.hash;
history.replaceState(null, '', clean);
}
const state = {
step: 1,
database: null,
databaseFingerprint: '',
administrator: null,
installing: false,
installStartedAt: 0,
installPercent: 0,
installLogEntries: [],
healthPolling: false,
};
const FORMAL_SERVICE_TIMEOUT_MS = 240000;
const $ = (selector) => document.querySelector(selector);
const $$ = (selector) => Array.from(document.querySelectorAll(selector));
function showFatal(message) {
['#view-1', '#view-2', '#view-3', '#view-4'].forEach((id) => $(id).classList.add('hidden'));
$('#fatal').classList.remove('hidden');
$('#fatal-message').textContent = message;
}
async function api(path, options = {}) {
const response = await fetch(path, {
...options,
headers: { 'Content-Type': 'application/json', 'X-Setup-Token': token, ...(options.headers || {}) },
});
let payload;
try { payload = await response.json(); } catch { throw new Error('安装服务响应无效'); }
if (!response.ok || payload.code !== 0) throw new Error(payload.message || '请求失败');
return payload.data;
}
function go(step) {
state.step = step;
for (let i = 1; i <= 4; i += 1) {
$(`#view-${i}`).classList.toggle('hidden', i !== step);
const nav = $(`[data-step-nav="${i}"]`);
nav.classList.toggle('active', i === step);
nav.classList.toggle('done', i < step);
nav.querySelector('.step-index').textContent = i < step ? '✓' : String(i);
}
window.scrollTo({ top: 0, behavior: 'smooth' });
}
function databaseValue() {
const data = new FormData($('#database-form'));
return {
host: String(data.get('host') || '').trim(),
port: Number(data.get('port')),
database: String(data.get('database') || '').trim(),
username: String(data.get('username') || '').trim(),
password: String(data.get('password') || ''),
sslMode: String(data.get('sslMode') || ''),
};
}
function fingerprint(database) { return JSON.stringify(database); }
function invalidateDatabaseTest() {
if (state.databaseFingerprint && fingerprint(databaseValue()) !== state.databaseFingerprint) {
state.databaseFingerprint = '';
state.database = null;
$('#database-next').disabled = true;
$('#db-message').className = 'notice info';
$('#db-message').textContent = '连接信息已修改,请重新测试。';
}
}
function administratorValue() {
const data = new FormData($('#admin-form'));
return {
loginName: String(data.get('loginName') || '').trim(),
displayName: String(data.get('displayName') || '').trim(),
password: String(data.get('password') || ''),
confirmPassword: String(data.get('confirmPassword') || ''),
};
}
function renderSummary() {
const rows = [
['PostgreSQL', `${state.database.host}:${state.database.port}`],
['数据库', state.database.database],
['数据库账号', state.database.username],
['SSL 模式', state.database.sslMode],
['管理员账号', state.administrator.loginName],
['管理员姓名', state.administrator.displayName],
];
$('#summary').replaceChildren(...rows.map(([key, value]) => {
const row = document.createElement('div');
row.className = 'summary-row';
const label = document.createElement('span');
label.className = 'summary-key';
label.textContent = key;
const content = document.createElement('span');
content.className = 'summary-value';
content.textContent = value;
row.append(label, content);
return row;
}));
}
function setProgress(index) {
$$('[data-progress]').forEach((item) => {
const value = Number(item.dataset.progress);
item.classList.toggle('done', value < index);
item.classList.toggle('running', value === index);
});
}
function formatElapsed(milliseconds) {
const seconds = Math.max(0, Math.floor(milliseconds / 1000));
if (seconds < 60) return `${seconds}`;
return `${Math.floor(seconds / 60)}${seconds % 60}`;
}
function setInstallMeter(percent, phase, detail) {
const value = Math.max(state.installPercent, Math.max(0, Math.min(100, Math.round(percent))));
state.installPercent = value;
$('#install-progress-bar').style.width = `${value}%`;
$('#install-percent').textContent = `${value}%`;
$('#install-phase').textContent = phase;
$('#install-check').textContent = detail;
$('#install-elapsed').textContent = `本页已等待 ${formatElapsed(Date.now() - state.installStartedAt)}`;
const track = $('#install-progress-track');
track.setAttribute('aria-valuenow', String(value));
track.setAttribute('aria-valuetext', `${phase}${value}%`);
}
function appendInstallLog(message) {
const timestamp = new Date().toLocaleTimeString('zh-CN', { hour12: false });
state.installLogEntries.push(`[${timestamp}] ${message}`);
state.installLogEntries = state.installLogEntries.slice(-16);
const output = $('#install-log');
output.textContent = state.installLogEntries.join('\n');
output.scrollTop = output.scrollHeight;
}
function beginInstallTracking(resumed) {
state.installStartedAt = Date.now();
state.installLogEntries = [];
$('#install-message').className = 'notice info';
$('#install-console-state').textContent = '运行中';
if (resumed) {
setInstallMeter(70, '恢复安装任务', '准备检查正式服务');
appendInstallLog('检测到尚未完成的安装任务,继续等待正式服务。');
} else {
setInstallMeter(5, '提交安装任务', '准备验证数据库');
appendInstallLog('安装请求已提交,正在验证 PostgreSQL 连接和权限。');
}
}
async function pollFormalService() {
if (state.healthPolling) return;
state.healthPolling = true;
if (!state.installStartedAt) beginInstallTracking(true);
setProgress(4);
setInstallMeter(Math.max(state.installPercent, 72), '启动正式服务', '等待第 1 次健康检查');
appendInstallLog('数据库初始化完成,安装器正在切换到正式服务。');
$('#install-message').textContent = '初始化完成,正在等待正式服务通过健康检查。页面会持续显示检查进度。';
const healthStartedAt = Date.now();
const deadline = healthStartedAt + FORMAL_SERVICE_TIMEOUT_MS;
let attempt = 0;
while (Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 2000));
attempt += 1;
const healthElapsed = Date.now() - healthStartedAt;
const estimated = 72 + Math.floor((healthElapsed / FORMAL_SERVICE_TIMEOUT_MS) * 23);
setInstallMeter(Math.min(95, estimated), '等待正式服务健康检查', `${attempt} 次检查`);
try {
const response = await fetch('/api/oa/health', { cache: 'no-store' });
const body = await response.json();
const serviceStatus = body?.data?.status || body?.status || `HTTP ${response.status}`;
const installLocked = body?.data?.installLocked ?? body?.installLocked;
if (response.ok && serviceStatus === 'UP' && (installLocked === true || installLocked === 'true')) {
setInstallMeter(100, '安装完成', '正式服务已就绪');
appendInstallLog(`健康检查第 ${attempt} 次通过,安装锁已确认。`);
$('#install-console-state').textContent = '已完成';
$$('[data-progress]').forEach((item) => { item.classList.remove('running'); item.classList.add('done'); });
$('#installing-view').classList.add('hidden');
$('#complete-view').classList.remove('hidden');
sessionStorage.removeItem('kaidi.setup.token');
state.healthPolling = false;
return;
}
if (response.ok && serviceStatus === 'UP') {
setInstallMeter(98, '正式服务已通过健康检查', '等待写入安装锁');
appendInstallLog(`健康检查第 ${attempt} 次通过,正在等待后台写入安装锁。`);
continue;
}
appendInstallLog(`健康检查第 ${attempt} 次:服务状态 ${serviceStatus}`);
} catch {
appendInstallLog(`健康检查第 ${attempt} 次:服务端口正在切换,继续等待。`);
}
}
state.healthPolling = false;
setInstallMeter(95, '正式服务尚未就绪', `${Math.round(FORMAL_SERVICE_TIMEOUT_MS / 1000)} 秒检查超时`);
appendInstallLog('健康检查超时,安装状态已保留,未写入完成锁。');
$('#install-console-state').textContent = '需要检查';
$('#install-message').className = 'notice error';
$('#install-message').textContent = '正式服务尚未就绪。请查看 systemctl 日志或 /var/lib/kaidi-erp/install-formal.log;安装文件和待确认状态均已保留。';
}
$('#start-button').addEventListener('click', () => go(2));
$$('[data-back]').forEach((button) => button.addEventListener('click', () => go(Number(button.dataset.back))));
$('#database-form').addEventListener('input', invalidateDatabaseTest);
$('#database-form').addEventListener('change', invalidateDatabaseTest);
$('#test-db-button').addEventListener('click', async () => {
const form = $('#database-form');
if (!form.reportValidity()) return;
const button = $('#test-db-button');
const database = databaseValue();
button.disabled = true;
button.textContent = '测试中';
$('#db-message').className = 'notice info';
$('#db-message').textContent = '正在连接 PostgreSQL 并检查 pg_trgm 扩展。';
try {
const result = await api('/api/install/test-database', { method: 'POST', body: JSON.stringify(database) });
state.database = database;
state.databaseFingerprint = fingerprint(database);
$('#database-next').disabled = false;
$('#db-message').className = 'notice';
$('#db-message').textContent = `${result.message}${result.postgresVersion}`;
} catch (error) {
state.database = null;
state.databaseFingerprint = '';
$('#database-next').disabled = true;
$('#db-message').className = 'notice error';
$('#db-message').textContent = error.message;
} finally {
button.disabled = false;
button.textContent = '测试连接';
}
});
$('#database-next').addEventListener('click', () => {
if (!state.database || fingerprint(databaseValue()) !== state.databaseFingerprint) return invalidateDatabaseTest();
go(3);
});
$('#admin-next').addEventListener('click', () => {
const form = $('#admin-form');
if (!form.reportValidity()) return;
const administrator = administratorValue();
if (administrator.password !== administrator.confirmPassword) {
$('#admin-message').className = 'notice error';
$('#admin-message').textContent = '两次输入的管理员密码不一致。';
return;
}
state.administrator = {
loginName: administrator.loginName,
displayName: administrator.displayName,
password: administrator.password,
};
$('#admin-message').className = 'notice info';
$('#admin-message').textContent = '管理员信息已填写。';
renderSummary();
go(4);
});
$('#install-button').addEventListener('click', async () => {
if (state.installing || !state.database || !state.administrator) return;
state.installing = true;
$('#confirm-view').classList.add('hidden');
$('#installing-view').classList.remove('hidden');
beginInstallTracking(false);
setProgress(1);
let migrationTimer;
try {
const request = { database: state.database, administrator: state.administrator };
migrationTimer = setTimeout(() => {
setProgress(2);
setInstallMeter(28, '执行数据库初始化', '迁移结构和基础数据');
appendInstallLog('数据库连接验证通过,正在执行结构迁移和基础数据初始化。');
}, 700);
try {
await api('/api/install/complete', { method: 'POST', body: JSON.stringify(request) });
} catch (error) {
// The installer intentionally exits immediately after committing. A
// browser may see the TCP connection close before the JSON response
// is flushed; the formal health probe is the authoritative result.
if (!(error instanceof TypeError) && error.message !== '安装服务响应无效') throw error;
}
clearTimeout(migrationTimer);
setProgress(3);
setInstallMeter(68, '创建管理员并切换服务', '数据库初始化已完成');
appendInstallLog('数据库迁移和管理员初始化已提交,准备启动正式服务。');
state.administrator.password = '';
state.database.password = '';
await pollFormalService();
} catch (error) {
clearTimeout(migrationTimer);
state.installing = false;
state.healthPolling = false;
setInstallMeter(state.installPercent, '安装已中断', '请处理错误后重试');
appendInstallLog(`安装中断:${error.message}`);
$('#install-console-state').textContent = '已中断';
$('#install-message').className = 'notice error';
$('#install-message').textContent = error.message;
const actions = document.createElement('div');
actions.className = 'actions';
const back = document.createElement('button');
back.className = 'btn';
back.textContent = '返回检查';
back.addEventListener('click', () => location.reload());
actions.append(back);
$('#installing-view').append(actions);
}
});
$('#enter-button').addEventListener('click', () => location.assign('/'));
async function boot() {
if (!token) return showFatal('链接缺少一次性安装令牌。请使用服务器终端输出的完整链接。');
try {
const status = await api('/api/install/status');
if (status.locked) return showFatal('系统已经完成安装,安装向导已锁定。');
if (status.pending) {
go(4);
$('#confirm-view').classList.add('hidden');
$('#installing-view').classList.remove('hidden');
beginInstallTracking(true);
return pollFormalService();
}
$('#java-detail').textContent = `Java ${status.javaVersion},最低要求 Java ${status.minimumJava}`;
$('#java-badge').className = 'badge';
$('#java-badge').textContent = '通过';
$('#installer-detail').textContent = `安装包版本 ${status.version}`;
$('#installer-badge').className = 'badge';
$('#installer-badge').textContent = '通过';
$('#status-message').className = 'notice';
$('#status-message').textContent = '服务器环境检查通过,可以开始配置。';
$('#start-button').disabled = false;
} catch (error) {
showFatal(error.message);
}
}
boot();
})();
</script>
</body>
</html>
@@ -0,0 +1,48 @@
package com.kaidi.oa.install;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.forwardedUrl;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest(
classes = InstallerApplication.class,
properties = {
"erp.install.token=test-token",
"erp.install.version=test",
"erp.install.exit-after-complete=false",
"erp.install.install-root=build/installer-test",
"erp.install.config-file=build/installer-test/config/erp.env",
"erp.install.pending-file=build/installer-test/state/install.pending",
"erp.install.lock-file=build/installer-test/state/install.lock",
"erp.install.operation-lock-file=build/installer-test/state/install-operation.lock"
})
@AutoConfigureMockMvc
class InstallerApiTest {
@Autowired
private MockMvc mockMvc;
@Test
void setupApiRequiresOneTimeToken() throws Exception {
mockMvc.perform(get("/api/install/status"))
.andExpect(status().isForbidden())
.andExpect(content().json("{\"code\":40301}"));
}
@Test
void setupStatusAndStaticPageAreAvailableWithToken() throws Exception {
mockMvc.perform(get("/api/install/status").header("X-Setup-Token", "test-token"))
.andExpect(status().isOk())
.andExpect(content().json("{\"code\":0,\"data\":{\"minimumPostgres\":15,\"redisRequired\":false,\"locked\":false}}", false));
mockMvc.perform(get("/"))
.andExpect(status().isOk())
.andExpect(forwardedUrl("index.html"));
}
}
@@ -15,6 +15,7 @@ public class UpdateProperties {
private String token = "";
private String helperCommand = "";
private String stateFile = "./runtime/update-state.json";
private String configFile = "";
private boolean allowInsecureHttp;
private int requestTimeoutSeconds = 15;
@@ -74,6 +75,14 @@ public class UpdateProperties {
this.stateFile = stateFile;
}
public String getConfigFile() {
return configFile;
}
public void setConfigFile(String configFile) {
this.configFile = configFile;
}
public boolean isAllowInsecureHttp() {
return allowInsecureHttp;
}
@@ -4,7 +4,6 @@ import com.kaidi.oa.domain.QualElecLicense;
import com.kaidi.oa.repository.QualElecLicenseRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.time.LocalDate;
@@ -17,7 +16,7 @@ import java.time.LocalDate;
*
* @Order(225) 在所有主数据 Seeder(最高 220)之后运行;幂等(count > 0 跳过)。
*/
@Component
@DemoSeed
@Order(225)
public class AdminQualElecSeeder implements CommandLineRunner {
@@ -14,7 +14,6 @@ import com.kaidi.oa.repository.StaffDossierItemRepository;
import com.kaidi.oa.repository.StaffDossierRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.time.Instant;
@@ -26,7 +25,7 @@ import java.time.LocalDate;
* 幂等 + 非破坏:以各表是否已有数据为哨兵,仅在空表时灌入少量演示数据,让深水页面首屏不空。
* 日期以"当前日期 +/- 偏移"动态生成,使到期分级预警在任何时间运行都有 即将到期/逾期 样本。
*/
@Component
@DemoSeed
@Order(6)
public class AdminQualSeeder implements CommandLineRunner {
@@ -6,7 +6,6 @@ import com.kaidi.oa.repository.ArchiveRepository;
import com.kaidi.oa.repository.ArchiveVersionRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.time.Instant;
@@ -26,7 +25,7 @@ import java.time.Instant;
* <p>幂等:同 sourceType 在 archive 表已有记录则跳过整批(count 检查)。
* @Order(205) 在所有基础 Seeder 后运行。
*/
@Component
@DemoSeed
@Order(205)
public class ArchiveCrossModuleSeeder implements CommandLineRunner {
@@ -11,7 +11,6 @@ import com.kaidi.oa.repository.LegalRegulationRepository;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@@ -31,7 +30,7 @@ import java.time.temporal.ChronoUnit;
* 幂等:各表 count > 0 即跳过。
* @Order(202) 在 FertPkgAccountingSeeder(@Order 201) 之后运行。
*/
@Component
@DemoSeed
@Order(202)
public class AuditDeptSeeder implements ApplicationRunner {
@@ -5,7 +5,6 @@ import com.kaidi.oa.repository.BidTenderCollabRepository;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.time.Instant;
@@ -16,7 +15,7 @@ import java.time.Instant;
* 让前端看板活体可见且流程闭环可演示。
* 幂等:若已有数据则跳过,防止重启重复写入。
*/
@Component
@DemoSeed
@Order(215)
public class BidTenderCollabSeeder implements ApplicationRunner {
@@ -8,7 +8,6 @@ import com.kaidi.oa.repository.BidRepository;
import com.kaidi.oa.repository.BidTenderDocRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.time.LocalDate;
@@ -18,7 +17,7 @@ import java.time.LocalDate;
*
* @Order(215) 在所有主数据 Seeder 之后运行;幂等(count > 0 则跳过)。
*/
@Component
@DemoSeed
@Order(215)
public class BidTenderDocSeeder implements CommandLineRunner {
@@ -8,7 +8,6 @@ import com.kaidi.oa.repository.OrgBranchRepository;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.util.ArrayList;
@@ -32,7 +31,7 @@ import java.util.List;
*
* @Order(15) 在 DataSeeder(@Order 1) 之后独立运行。
*/
@Component
@DemoSeed
@Order(15)
public class BranchFinDemoSeeder implements ApplicationRunner {
@@ -7,7 +7,6 @@ import com.kaidi.oa.repository.EmployeeShareMaterialRepository;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.util.ArrayList;
@@ -21,7 +20,7 @@ import java.util.List;
*
* @Order(220) 在所有主数据 Seeder 之后运行;幂等(count > 0 则跳过)。
*/
@Component
@DemoSeed
@Order(220)
public class BrandCultureGapSeeder implements ApplicationRunner {
@@ -8,7 +8,6 @@ import com.kaidi.oa.repository.ContentTaskRepository;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.util.ArrayList;
@@ -20,7 +19,7 @@ import java.util.List;
* 补全两个"活体返回空/无数据"缺口,让前端物料管理与任务分派功能可见可演示。
* @Order(22),在 DataSeeder(@Order 1) 之后运行,幂等(count > 0 跳过)。
*/
@Component
@DemoSeed
@Order(22)
public class BrandCultureSeeder implements ApplicationRunner {
@@ -12,7 +12,6 @@ import com.kaidi.oa.repository.StandardCostRepository;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@@ -37,7 +36,7 @@ import java.time.temporal.ChronoUnit;
* 幂等:各表 count() > 0 即跳过,重启不重复插入。
* @Order(9) 在 DataSeeder(@Order 1) 之后独立运行。
*/
@Component
@DemoSeed
@Order(9)
public class CostCtrlDemoSeeder implements ApplicationRunner {
@@ -20,7 +20,6 @@ import com.kaidi.oa.repository.RdEbomRepository;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@@ -50,7 +49,7 @@ import java.time.temporal.ChronoUnit;
*
* 幂等:各表 count() > 0 则跳过,重启安全。
*/
@Component
@DemoSeed
@Order(215)
public class CostCtrlGapSeeder implements ApplicationRunner {
@@ -8,7 +8,6 @@ import com.kaidi.oa.repository.CslTimesheetRepository;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@@ -21,7 +20,7 @@ import java.time.Instant;
*
* 幂等:按 code 查重后跳过,重启不重复插入。
*/
@Component
@DemoSeed
@Order(7)
public class CslDemoSeeder implements ApplicationRunner {
@@ -7,7 +7,6 @@ import com.kaidi.oa.repository.HrCultureKpiRepository;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.util.ArrayList;
@@ -21,7 +20,7 @@ import java.util.List;
*
* @Order(225) 在 BrandCultureGapSeeder(@Order 220) 之后运行,幂等(count>0跳过)。
*/
@Component
@DemoSeed
@Order(225)
public class CultureBriefingKpiSeeder implements ApplicationRunner {
@@ -9,7 +9,6 @@ import com.kaidi.oa.repository.HrEmpSatisfactionSurveyRepository;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.time.Instant;
@@ -28,7 +27,7 @@ import java.util.List;
* @Order(50),在 DataSeeder(@Order 1) 及 BrandCultureSeeder(@Order 22) 之后运行。
* 幂等:CultureGoal 有数据时跳过全部种子。
*/
@Component
@DemoSeed
@Order(50)
public class CultureProjectAssessSeeder implements ApplicationRunner {
@@ -158,7 +158,6 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.time.Instant;
@@ -171,7 +170,7 @@ import java.util.List;
* in-flight form instances, plus org/users/roles, meetings, documents,
* announcements and schedule events.
*/
@Component
@DemoSeed
@Order(1)
public class DataSeeder implements CommandLineRunner {
@@ -7,7 +7,6 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.time.Instant;
@@ -15,7 +14,7 @@ import java.time.Instant;
* 申报成果库演示数据补种(Gap 4:活体 decl-achievements 0条,功能可达但未预置演示数据)。
* Order(3)=在 DataSeeder(Order 1)/AdminQualSeeder 之后运行,避免依赖竞争。
*/
@Component
@DemoSeed
@Order(3)
public class DeclAchievementSeeder implements CommandLineRunner {
@@ -0,0 +1,18 @@
package com.kaidi.oa.seed;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component
@ConditionalOnProperty(name = "oa.seed.demo", havingValue = "true", matchIfMissing = true)
public @interface DemoSeed {
}
@@ -10,7 +10,6 @@ import com.kaidi.oa.repository.FertPkgMaterialLedgerRepository;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@@ -27,7 +26,7 @@ import java.time.Instant;
* 幂等:按唯一编码查重后跳过,重启不重复插入。
* Order(201) 保证在 DataSeeder(200) 之后执行。
*/
@Component
@DemoSeed
@Order(201)
public class FertPkgAccountingSeeder implements ApplicationRunner {
@@ -14,7 +14,6 @@ import com.kaidi.oa.repository.FinRdTimesheetRepository;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@@ -35,7 +34,7 @@ import java.time.temporal.ChronoUnit;
*
* @Order(13) 在 FinDeptSeeder(@Order 12) 之后运行。
*/
@Component
@DemoSeed
@Order(13)
public class FinDeptDeepSeeder implements ApplicationRunner {
@@ -8,7 +8,6 @@ import com.kaidi.oa.repository.TaxFilingRepository;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@@ -26,7 +25,7 @@ import java.time.temporal.ChronoUnit;
*
* @Order(215) 在既有 FinDeptSeeder(@Order 12)、BranchFinDemoSeeder(@Order 15) 之后。
*/
@Component
@DemoSeed
@Order(215)
public class FinDeptGapSeeder implements ApplicationRunner {
@@ -14,7 +14,6 @@ import com.kaidi.oa.repository.FinVoucherTemplateRepository;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@@ -35,7 +34,7 @@ import java.time.temporal.ChronoUnit;
*
* @Order(12) 在 DataSeeder(@Order 1) 之后运行。
*/
@Component
@DemoSeed
@Order(12)
public class FinDeptSeeder implements ApplicationRunner {
@@ -15,7 +15,6 @@ import com.kaidi.oa.repository.FinVoucherTemplateRepository;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.time.Instant;
@@ -36,7 +35,7 @@ import java.util.List;
*
* @Order(20) 在 DataSeeder(@Order 1) 和 BranchFinDemoSeeder(@Order 15) 之后运行。
*/
@Component
@DemoSeed
@Order(20)
public class FinPaymentDemoSeeder implements ApplicationRunner {
@@ -13,7 +13,6 @@ import com.kaidi.oa.repository.PmtBankRelationRepository;
import com.kaidi.oa.repository.RepaymentPlanRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.time.Instant;
@@ -24,7 +23,7 @@ import java.time.Instant;
* 只在对应表为空时执行防止重复@Order(20) DataSeeder(@Order(1)) 之后运行
*/
@Order(20)
@Component
@DemoSeed
public class FinancingDomainSeeder implements CommandLineRunner {
private final PmtBankRelationRepository bankRelRepo;
@@ -4,7 +4,6 @@ import com.kaidi.oa.domain.HrLaborTemplate;
import com.kaidi.oa.repository.HrLaborTemplateRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.time.LocalDate;
@@ -17,7 +16,7 @@ import java.time.LocalDate;
*
* 幂等count > 0 跳过@Order(226) AdminQualElecSeeder(225) 之后运行
*/
@Component
@DemoSeed
@Order(226)
public class HrLaborTemplateSeeder implements CommandLineRunner {
@@ -10,7 +10,6 @@ import com.kaidi.oa.repository.HrRecruitCandidateRepository;
import com.kaidi.oa.repository.HrRecruitJobRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.time.LocalDate;
@@ -25,7 +24,7 @@ import java.time.LocalDate;
* - HrInterviewQuestion 5 条面试题AI 生成标记
* - HrAiDocDraft 4 AI 生成文档入职资料/岗位说明/员工手册/转正考核多状态
*/
@Component
@DemoSeed
@Order(203)
public class HrRecruitSeeder implements CommandLineRunner {
@@ -15,7 +15,6 @@ import com.kaidi.oa.repository.ItSecPolicyConfigRepository;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.time.Instant;
@@ -27,7 +26,7 @@ import java.time.ZoneOffset;
* @Order(204) 在所有主数据Seeder后运行幂等count > 0 则跳过
*/
@Order(204)
@Component
@DemoSeed
public class ItDeptDemoSeeder implements ApplicationRunner {
private final ItMonitorItemRepository monitorRepo;
@@ -6,7 +6,6 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
@@ -19,7 +18,7 @@ import java.time.temporal.ChronoUnit;
*
* @Order(205) 在所有主数据 Seeder 之后幂等count > 0 则跳过
*/
@Component
@DemoSeed
@Order(205)
public class LabElnAccessLogSeeder implements CommandLineRunner {
@@ -6,7 +6,6 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.time.Instant;
@@ -16,7 +15,7 @@ import java.time.Instant;
* labkanban.vue 活体展示多状态卡片
* Order(8) = 在所有基础 Seeder 之后FtsIndexRunner(200) 之前
*/
@Component
@DemoSeed
@Order(8)
public class LabTestKanbanSeeder implements CommandLineRunner {
@@ -11,7 +11,6 @@ import com.kaidi.oa.repository.LegalCreditInvestRepository;
import com.kaidi.oa.repository.LitigationCaseRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.time.Instant;
@@ -27,7 +26,7 @@ import java.time.Instant;
* @Order(20) DataSeeder(@Order 1) 之后运行幂等count>0 则跳过
*/
@Order(20)
@Component
@DemoSeed
public class LegalDemoSeeder implements CommandLineRunner {
private final LitigationCaseRepository caseRepo;
@@ -6,7 +6,6 @@ import com.kaidi.oa.repository.ComplianceAnonReportRepository;
import com.kaidi.oa.repository.LegalPolicyRevisionRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.time.Instant;
@@ -17,7 +16,7 @@ import java.time.Instant;
* @Order(21) LegalDemoSeeder(@Order 20) 之后幂等
*/
@Order(21)
@Component
@DemoSeed
public class LegalPolicyAnonSeeder implements CommandLineRunner {
private final LegalPolicyRevisionRepository revisionRepo;
@@ -6,7 +6,6 @@ import com.kaidi.oa.repository.MarketCampaignRepository;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.util.ArrayList;
@@ -23,7 +22,7 @@ import java.util.List;
*
* @Order(10) DataSeeder(@Order 1) 之后独立运行幂等count > 0 则跳过
*/
@Component
@DemoSeed
@Order(10)
public class MarketCampaignSeeder implements ApplicationRunner {
@@ -7,7 +7,6 @@ import com.kaidi.oa.repository.SysDeptRepository;
import com.kaidi.oa.repository.SysUserRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
@@ -23,7 +22,7 @@ import java.util.Map;
* Idempotent + non-destructive: runs after the base seeder, only adds users that are
* missing (guard on a sentinel login). All accounts use password 123456.
*/
@Component
@DemoSeed
@Order(2)
public class OrgEnrichmentSeeder implements CommandLineRunner {
@@ -7,7 +7,6 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.time.Instant;
@@ -21,7 +20,7 @@ import java.time.Instant;
* 注意 seeder 独立于 DataSeederOrder=2 DataSeeder Order=1 之后运行
* 不修改 DataSeeder 的大构造函数
*/
@Component
@DemoSeed
@Order(2)
public class RdDeclPlanSeeder implements CommandLineRunner {
@@ -7,7 +7,6 @@ import com.kaidi.oa.repository.SewagePlantBenchmarkRepository;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@@ -23,7 +22,7 @@ import java.time.Instant;
*
* 幂等各表 count == 0 才插入
*/
@Component
@DemoSeed
@Order(206)
public class SewageBenchmarkSeeder implements ApplicationRunner {
@@ -8,7 +8,6 @@ import com.kaidi.oa.repository.SewageExtLabCommissionRepository;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
@@ -22,7 +21,7 @@ import java.time.temporal.ChronoUnit;
*
* 幂等linkageRepo.count() == 0 才插入commRepo.count() == 0 才插入
*/
@Component
@DemoSeed
@Order(30)
public class SewageCrossDeptSeeder implements ApplicationRunner {
@@ -11,7 +11,6 @@ import com.kaidi.oa.repository.SupervisionProjectRepository;
import com.kaidi.oa.repository.SvIndepClaimRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.util.List;
@@ -32,7 +31,7 @@ import java.util.List;
* <p>@Order(210) DataSeeder(@Order 1) 之后运行幂等SvIndepClaim.count>0 则跳过
*/
@Order(210)
@Component
@DemoSeed
public class SvSupervisionMinuteSeeder implements CommandLineRunner {
private final SvIndepClaimRepository claimRepo;
@@ -8,7 +8,6 @@ import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.stereotype.Component;
import java.io.InputStream;
@@ -21,7 +20,7 @@ import java.io.InputStream;
* JSON file under resources/seed-templates and rebuilding adds templates without
* any code change. This is how 凯迪科技's real approval processes are catalogued.
*/
@Component
@DemoSeed
@Order(100)
public class TemplateJsonSeeder implements CommandLineRunner {
@@ -10,7 +10,6 @@ import com.kaidi.oa.repository.WwMsdsFileRepository;
import com.kaidi.oa.repository.WwSamplingTaskRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.time.Instant;
@@ -24,7 +23,7 @@ import java.time.Instant;
* 4. WwSamplingTask废水采样任务演示数据覆盖不同状态和超标场景 Gap-11 桥接演示
*/
@Order(9)
@Component
@DemoSeed
public class WwOpsSeeder implements CommandLineRunner {
private final WwMsdsFileRepository msdsRepo;
@@ -0,0 +1,256 @@
package com.kaidi.oa.service;
import com.kaidi.oa.common.ApiException;
import com.kaidi.oa.config.UpdateProperties;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.net.URI;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.PosixFilePermission;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
/** Persists the administrator-managed updater source without exposing credentials. */
@Service
public class SystemUpdateConfigService {
private static final long MAX_CONFIG_BYTES = 1024 * 1024;
private static final Pattern REPOSITORY_PATTERN = Pattern.compile(
"^[A-Za-z0-9][A-Za-z0-9._-]{0,99}/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$");
private static final Pattern TOKEN_PATTERN = Pattern.compile("^[A-Za-z0-9._-]{1,512}$");
private static final Set<String> CHANNELS = Set.of("stable", "preview");
private final UpdateProperties properties;
public SystemUpdateConfigService(UpdateProperties properties) {
this.properties = properties;
}
public synchronized UpdateConfig get() {
return new UpdateConfig(
properties.isEnabled(),
value(properties.getGiteaBaseUrl()),
value(properties.getRepository()),
normalizedChannel(properties.getChannel()),
properties.getToken() != null && !properties.getToken().isBlank(),
properties.isAllowInsecureHttp()
);
}
public synchronized UpdateConfig save(UpdateConfigRequest request) {
if (request == null) {
throw new ApiException(400, "更新配置不能为空");
}
String baseUrl = normalizeBaseUrl(request.giteaBaseUrl(), request.allowInsecureHttp(), request.enabled());
String repository = normalizeRepository(request.repository());
String channel = normalizedChannel(request.channel());
String token = value(properties.getToken());
String suppliedToken = value(request.token());
if (request.clearToken() && !suppliedToken.isBlank()) {
throw new ApiException(400, "不能同时清除并设置 Gitea Token");
}
if (request.clearToken()) {
token = "";
} else if (!suppliedToken.isBlank()) {
if (!TOKEN_PATTERN.matcher(suppliedToken).matches()) {
throw new ApiException(400, "Gitea Token 格式无效");
}
token = suppliedToken;
}
LinkedHashMap<String, String> changes = new LinkedHashMap<>();
changes.put("OA_UPDATE_ENABLED", Boolean.toString(request.enabled()));
changes.put("OA_UPDATE_GITEA_BASE_URL", baseUrl);
changes.put("OA_UPDATE_REPOSITORY", repository);
changes.put("OA_UPDATE_CHANNEL", channel);
changes.put("OA_UPDATE_TOKEN", token);
changes.put("OA_UPDATE_ALLOW_INSECURE_HTTP", Boolean.toString(request.allowInsecureHttp()));
persist(changes);
properties.setEnabled(request.enabled());
properties.setGiteaBaseUrl(baseUrl);
properties.setRepository(repository);
properties.setChannel(channel);
properties.setToken(token);
properties.setAllowInsecureHttp(request.allowInsecureHttp());
return get();
}
private void persist(Map<String, String> changes) {
String configuredPath = value(properties.getConfigFile());
if (configuredPath.isBlank()) {
throw new ApiException(503, "当前运行方式未提供可写的 ERP_CONFIG_FILE");
}
Path target = Path.of(configuredPath).toAbsolutePath().normalize();
try {
if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS) || !Files.isWritable(target)) {
throw new ApiException(503, "ERP 配置文件不存在或不可写");
}
if (Files.size(target) > MAX_CONFIG_BYTES) {
throw new ApiException(503, "ERP 配置文件异常过大");
}
BasicFileAttributes before = Files.readAttributes(
target, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
List<String> original = Files.readAllLines(target, StandardCharsets.UTF_8);
List<String> updated = replaceWhitelistedSettings(original, changes);
byte[] content = (String.join("\n", updated) + "\n").getBytes(StandardCharsets.UTF_8);
atomicWrite(target, content, before);
} catch (ApiException exception) {
throw exception;
} catch (IOException | RuntimeException exception) {
throw new ApiException(503, "无法保存更新配置,请检查 ERP 配置文件权限");
}
}
private List<String> replaceWhitelistedSettings(List<String> original, Map<String, String> changes) {
List<String> result = new ArrayList<>(original.size() + changes.size());
Set<String> written = new java.util.HashSet<>();
for (String line : original) {
String matched = null;
for (String key : changes.keySet()) {
if (line.startsWith(key + "=")) {
matched = key;
break;
}
}
if (matched == null) {
result.add(line);
} else if (written.add(matched)) {
result.add(setting(matched, changes.get(matched)));
}
}
for (Map.Entry<String, String> entry : changes.entrySet()) {
if (written.add(entry.getKey())) {
result.add(setting(entry.getKey(), entry.getValue()));
}
}
return result;
}
private void atomicWrite(Path target, byte[] content, BasicFileAttributes before) throws IOException {
Path parent = target.getParent();
if (parent == null || !Files.isDirectory(parent, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("configuration parent is unavailable");
}
Path temporary = Files.createTempFile(parent, ".erp.env-", ".tmp");
try {
try {
Set<PosixFilePermission> permissions = Files.getPosixFilePermissions(
target, LinkOption.NOFOLLOW_LINKS);
Files.setPosixFilePermissions(temporary, permissions);
} catch (UnsupportedOperationException ignored) {
// Production targets POSIX systems; keep local non-POSIX tests portable.
}
try (FileChannel channel = FileChannel.open(
temporary, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) {
channel.write(ByteBuffer.wrap(content));
channel.force(true);
}
BasicFileAttributes current = Files.readAttributes(
target, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
if (current.size() != before.size() || !current.lastModifiedTime().equals(before.lastModifiedTime())) {
throw new IOException("configuration changed concurrently");
}
try {
Files.move(temporary, target,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException exception) {
throw new IOException("atomic configuration replacement is unavailable", exception);
}
} finally {
Files.deleteIfExists(temporary);
}
}
private static String normalizeBaseUrl(String input, boolean allowInsecureHttp, boolean required) {
String value = value(input).replaceAll("/+$", "");
if (value.isBlank()) {
if (required) {
throw new ApiException(400, "启用在线更新时必须填写 Gitea 地址");
}
return "";
}
URI uri;
try {
uri = URI.create(value);
} catch (IllegalArgumentException exception) {
throw new ApiException(400, "Gitea 地址格式无效");
}
String scheme = value(uri.getScheme()).toLowerCase(Locale.ROOT);
if (!("https".equals(scheme) || (allowInsecureHttp && "http".equals(scheme)))) {
throw new ApiException(400, "Gitea 地址必须使用 HTTPS");
}
if (uri.getHost() == null || uri.getUserInfo() != null || uri.getRawQuery() != null
|| uri.getRawFragment() != null || uri.getPort() > 65535) {
throw new ApiException(400, "Gitea 地址格式无效");
}
return value;
}
private static String normalizeRepository(String input) {
String repository = value(input);
if (!REPOSITORY_PATTERN.matcher(repository).matches()) {
throw new ApiException(400, "仓库格式必须为 owner/repository");
}
return repository;
}
private static String normalizedChannel(String input) {
String channel = value(input).toLowerCase(Locale.ROOT);
if (channel.isBlank()) {
return "stable";
}
if (!CHANNELS.contains(channel)) {
throw new ApiException(400, "更新通道只支持 stable 或 preview");
}
return channel;
}
private static String setting(String key, String value) {
if (value.indexOf('\0') >= 0 || value.indexOf('\n') >= 0 || value.indexOf('\r') >= 0) {
throw new ApiException(400, "更新配置不能包含换行符");
}
return key + "='" + value.replace("'", "'\\''") + "'";
}
private static String value(String value) {
return value == null ? "" : value.trim();
}
public record UpdateConfig(
boolean enabled,
String giteaBaseUrl,
String repository,
String channel,
boolean tokenConfigured,
boolean allowInsecureHttp
) {
}
public record UpdateConfigRequest(
boolean enabled,
String giteaBaseUrl,
String repository,
String channel,
String token,
boolean clearToken,
boolean allowInsecureHttp
) {
}
}
@@ -21,13 +21,16 @@ import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.PosixFilePermission;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Matcher;
@@ -43,6 +46,11 @@ public class SystemUpdateService {
+ "(?:-([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?"
+ "(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$");
private static final long MAX_STATE_BYTES = 64 * 1024;
private static final int MAX_RELEASE_NOTES_CHARS = 32_000;
private static final int MAX_RELEASE_HISTORY = 20;
private static final int MAX_RELEASE_ASSETS = 32;
private static final int MAX_ASSET_NAME_CHARS = 255;
private static final int MAX_ASSET_URL_CHARS = 4_096;
private final UpdateProperties properties;
private final ObjectMapper objectMapper;
@@ -76,12 +84,33 @@ public class SystemUpdateService {
public UpdateStatus status() {
UpdateStatus persisted = readHelperState();
return persisted == null ? state.get() : persisted;
if (persisted != null) {
state.set(persisted);
return persisted;
}
return state.get();
}
public void ensureConfigurationMutable() {
if (isActivePhase(status().phase())) {
throw new ApiException(409, "更新进行中,暂时不能修改更新源");
}
}
public synchronized void configurationChanged() {
UpdateStatus idle = UpdateStatus.idle(isConfigured(), currentVersion);
state.set(idle);
persistState(idle);
}
public synchronized UpdateStatus check() {
requireConfigured();
state.set(state.get().withPhase(UpdatePhase.CHECKING, 5, "正在检查 Gitea Release"));
if (isActivePhase(status().phase())) {
throw new ApiException(409, "更新任务正在执行");
}
UpdateStatus checking = state.get().withPhase(UpdatePhase.CHECKING, 5, "正在检查 Gitea Release");
state.set(checking);
persistState(checking);
try {
ReleaseInfo release = fetchLatestRelease();
boolean available = compareVersions(release.version(), currentVersion) > 0;
@@ -100,13 +129,34 @@ public class SystemUpdateService {
null
);
state.set(checked);
persistState(checked);
return checked;
} catch (RuntimeException e) {
state.set(state.get().failed(safeMessage(e)));
UpdateStatus failed = state.get().failed(safeMessage(e));
state.set(failed);
persistState(failed);
throw e;
}
}
public List<ReleaseHistoryItem> releases() {
requireConfigured();
List<ReleaseInfo> releases = new ArrayList<>(fetchReleaseHistory());
releases.sort((left, right) -> compareVersions(right.version(), left.version()));
String latestVersion = releases.isEmpty() ? null : releases.get(0).version();
List<ReleaseHistoryItem> history = new ArrayList<>(releases.size());
for (ReleaseInfo release : releases) {
history.add(new ReleaseHistoryItem(
release.version(),
release.publishedAt(),
release.notes(),
sameVersion(release.version(), currentVersion),
latestVersion != null && sameVersion(release.version(), latestVersion)
));
}
return List.copyOf(history);
}
public UpdateStatus install(String requestedVersion) {
requireConfigured();
if (!isVersion(requestedVersion)) {
@@ -150,6 +200,7 @@ public class SystemUpdateService {
UpdateStatus starting = checked.withPhase(UpdatePhase.STARTING, 1, "更新助手已启动");
state.set(starting);
persistState(starting);
process.onExit().thenAccept(completed -> {
installRunning.set(false);
if (completed.exitValue() != 0) {
@@ -165,13 +216,57 @@ public class SystemUpdateService {
}
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);
if (repository.length != 2 || repository[0].isBlank() || repository[1].isBlank()) {
throw new ApiException(500, "更新仓库配置无效");
}
String base = properties.getGiteaBaseUrl().replaceAll("/+$", "");
URI uri = URI.create(base + "/api/v1/repos/" + encode(repository[0]) + "/"
+ encode(repository[1]) + "/releases/latest");
return URI.create(base + "/api/v1/repos/" + encode(repository[0]) + "/"
+ encode(repository[1]) + "/releases" + suffix);
}
private JsonNode fetchReleaseJson(URI uri) {
HttpRequest.Builder builder = HttpRequest.newBuilder(uri)
.timeout(Duration.ofSeconds(Math.max(1, properties.getRequestTimeoutSeconds())))
.header("Accept", "application/json")
@@ -182,13 +277,14 @@ public class SystemUpdateService {
try {
HttpResponse<String> response = httpClient.send(builder.build(),
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
requireSameOrigin(response.uri(), configuredBaseUri(), "Gitea Release API");
if (response.statusCode() == 404) {
throw new ApiException(404, "Gitea 尚未发布 Release");
}
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new ApiException(502, "Gitea Release API 返回 HTTP " + response.statusCode());
}
return parseRelease(objectMapper.readTree(response.body()));
return objectMapper.readTree(response.body());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new ApiException(503, "检查更新被中断");
@@ -198,6 +294,22 @@ public class SystemUpdateService {
}
}
private ReleaseInfo parseReleaseHistoryItem(JsonNode root) {
String tag = text(root, "tag_name");
if (!isVersion(tag) || root.path("draft").asBoolean(false)) {
return null;
}
if (isPrerelease(root) && isStableChannel()) {
return null;
}
return new ReleaseInfo(
normalizeVersion(tag),
releaseNotes(root),
releasePublishedAt(root),
List.of()
);
}
private ReleaseInfo parseRelease(JsonNode root) {
String tag = text(root, "tag_name");
if (!isVersion(tag)) {
@@ -206,18 +318,24 @@ public class SystemUpdateService {
if (root.path("draft").asBoolean(false)) {
throw new ApiException(502, "最新 Release 仍是草稿");
}
Matcher versionMatcher = VERSION_PATTERN.matcher(tag);
versionMatcher.matches();
boolean taggedPrerelease = versionMatcher.group(4) != null;
if ((root.path("prerelease").asBoolean(false) || taggedPrerelease)
&& "stable".equalsIgnoreCase(properties.getChannel())) {
if (isPrerelease(root) && isStableChannel()) {
throw new ApiException(502, "稳定频道拒绝预发布版本");
}
if (!root.path("assets").isArray() || root.path("assets").size() > MAX_RELEASE_ASSETS) {
throw new ApiException(502, "Release 文件列表无效");
}
List<ReleaseAsset> assets = new ArrayList<>();
for (JsonNode node : root.path("assets")) {
String name = text(node, "name");
String downloadUrl = text(node, "browser_download_url");
if (name.isBlank() || name.length() > MAX_ASSET_NAME_CHARS
|| downloadUrl.isBlank() || downloadUrl.length() > MAX_ASSET_URL_CHARS) {
throw new ApiException(502, "Release 文件信息无效");
}
validateAssetUrl(downloadUrl);
assets.add(new ReleaseAsset(
text(node, "name"),
text(node, "browser_download_url"),
name,
downloadUrl,
node.path("size").asLong(0)
));
}
@@ -225,16 +343,36 @@ public class SystemUpdateService {
requireAsset(assets, "kaidi-erp-" + version + ".tar.gz");
requireAsset(assets, "SHA256SUMS");
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");
if (!published.isBlank()) {
if (published.isBlank()) {
return null;
}
try {
publishedAt = Instant.parse(published);
return Instant.parse(published);
} catch (RuntimeException ignore) {
// An invalid optional timestamp must not hide an otherwise valid release.
return null;
}
}
return new ReleaseInfo(version, text(root, "body"), publishedAt, List.copyOf(assets));
private static String releaseNotes(JsonNode root) {
String notes = text(root, "body");
if (notes.length() > MAX_RELEASE_NOTES_CHARS) {
return notes.substring(0, MAX_RELEASE_NOTES_CHARS) + "\n\n[发布说明过长,已截断]";
}
return notes;
}
private void requireConfigured() {
@@ -244,16 +382,15 @@ public class SystemUpdateService {
if (properties.getGiteaBaseUrl() == null || properties.getGiteaBaseUrl().isBlank()) {
throw new ApiException(503, "尚未配置 Gitea 地址");
}
URI uri;
try {
uri = URI.create(properties.getGiteaBaseUrl().trim());
} catch (IllegalArgumentException e) {
throw new ApiException(500, "Gitea 地址格式无效");
}
URI uri = configuredBaseUri();
String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(Locale.ROOT);
if (!"https".equals(scheme) && !(properties.isAllowInsecureHttp() && "http".equals(scheme))) {
throw new ApiException(503, "更新服务器必须使用 HTTPS");
}
String repository = properties.getRepository() == null ? "" : properties.getRepository().trim();
if (!repository.matches("^[A-Za-z0-9][A-Za-z0-9._-]{0,99}/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$")) {
throw new ApiException(500, "更新仓库配置无效");
}
}
private UpdateStatus readHelperState() {
@@ -272,10 +409,15 @@ public class SystemUpdateService {
String helperVersion = blankToNull(text(root, "version"));
boolean updateAvailable = helperVersion != null
&& phase != UpdatePhase.SUCCEEDED
&& phase != UpdatePhase.UP_TO_DATE
&& compareVersions(helperVersion, currentVersion) > 0;
Instant helperUpdatedAt = parseInstant(text(root, "updatedAt"));
Instant publishedAt = parseInstant(text(root, "publishedAt"));
String releaseNotes = root.path("releaseNotes").isTextual()
? root.path("releaseNotes").asText() : memory.releaseNotes();
List<ReleaseAsset> assets = readAssets(root.path("assets"), memory.assets());
return new UpdateStatus(
properties.isEnabled(),
isConfigured(),
currentVersion,
helperVersion,
updateAvailable,
@@ -283,9 +425,9 @@ public class SystemUpdateService {
Math.max(0, Math.min(100, root.path("progress").asInt(0))),
text(root, "message"),
helperUpdatedAt == null ? memory.checkedAt() : helperUpdatedAt,
memory.publishedAt(),
memory.releaseNotes(),
memory.assets(),
publishedAt == null ? memory.publishedAt() : publishedAt,
releaseNotes,
assets,
blankToNull(text(root, "error"))
);
} catch (Exception e) {
@@ -300,6 +442,137 @@ public class SystemUpdateService {
.toAbsolutePath().normalize();
}
private void persistState(UpdateStatus status) {
Path target = statePath();
Path parent = target.getParent();
Path temporary = null;
try {
if (parent != null) {
Files.createDirectories(parent);
}
byte[] content = objectMapper.writeValueAsBytes(java.util.Map.ofEntries(
java.util.Map.entry("phase", status.phase().name()),
java.util.Map.entry("progress", status.progress()),
java.util.Map.entry("message", Objects.requireNonNullElse(status.message(), "")),
java.util.Map.entry("version", Objects.requireNonNullElse(status.latestVersion(), "")),
java.util.Map.entry("error", Objects.requireNonNullElse(status.error(), "")),
java.util.Map.entry("updatedAt", Instant.now().toString()),
java.util.Map.entry("publishedAt", status.publishedAt() == null ? "" : status.publishedAt().toString()),
java.util.Map.entry("releaseNotes", Objects.requireNonNullElse(status.releaseNotes(), "")),
java.util.Map.entry("assets", Objects.requireNonNullElse(status.assets(), List.of()))
));
if (content.length > MAX_STATE_BYTES) {
throw new IOException("update state is too large");
}
Path directory = parent == null ? Path.of(".").toAbsolutePath().normalize() : parent;
temporary = Files.createTempFile(directory, ".update-state-", ".tmp");
Files.write(temporary, content, StandardOpenOption.TRUNCATE_EXISTING);
try {
Files.setPosixFilePermissions(temporary, Set.of(
PosixFilePermission.OWNER_READ,
PosixFilePermission.OWNER_WRITE,
PosixFilePermission.GROUP_READ));
} catch (UnsupportedOperationException ignored) {
// Keep local non-POSIX tests portable.
}
Files.move(temporary, target,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
temporary = null;
} catch (Exception exception) {
log.warn("Unable to persist update state {}: {}", target, exception.getMessage());
} finally {
if (temporary != null) {
try {
Files.deleteIfExists(temporary);
} catch (IOException ignored) {
// Best effort cleanup only.
}
}
}
}
private List<ReleaseAsset> readAssets(JsonNode node, List<ReleaseAsset> fallback) {
if (!node.isArray()) {
return fallback == null ? List.of() : fallback;
}
if (node.size() > MAX_RELEASE_ASSETS) {
return List.of();
}
List<ReleaseAsset> assets = new ArrayList<>();
for (JsonNode asset : node) {
String name = text(asset, "name");
String downloadUrl = text(asset, "downloadUrl");
if (name.isBlank() || downloadUrl.isBlank()) {
continue;
}
validateAssetUrl(downloadUrl);
assets.add(new ReleaseAsset(name, downloadUrl, asset.path("size").asLong(0)));
}
return List.copyOf(assets);
}
private URI configuredBaseUri() {
URI uri;
try {
uri = URI.create(Objects.requireNonNullElse(properties.getGiteaBaseUrl(), "").trim());
} catch (IllegalArgumentException exception) {
throw new ApiException(500, "Gitea 地址格式无效");
}
if (uri.getHost() == null || uri.getUserInfo() != null || uri.getRawQuery() != null
|| uri.getRawFragment() != null || uri.getPort() > 65535) {
throw new ApiException(500, "Gitea 地址格式无效");
}
return uri;
}
private void validateAssetUrl(String value) {
URI asset;
try {
asset = URI.create(value);
} catch (IllegalArgumentException exception) {
throw new ApiException(502, "Release 文件地址无效");
}
if (asset.getUserInfo() != null || asset.getRawFragment() != null) {
throw new ApiException(502, "Release 文件地址无效");
}
requireSameOrigin(asset, configuredBaseUri(), "Release 文件");
}
private static void requireSameOrigin(URI actual, URI expected, String label) {
String actualScheme = Objects.requireNonNullElse(actual.getScheme(), "").toLowerCase(Locale.ROOT);
String expectedScheme = Objects.requireNonNullElse(expected.getScheme(), "").toLowerCase(Locale.ROOT);
String actualHost = Objects.requireNonNullElse(actual.getHost(), "").toLowerCase(Locale.ROOT);
String expectedHost = Objects.requireNonNullElse(expected.getHost(), "").toLowerCase(Locale.ROOT);
if (!actualScheme.equals(expectedScheme) || !actualHost.equals(expectedHost)
|| effectivePort(actual) != effectivePort(expected)) {
throw new ApiException(502, label + "跳转到了未受信任的服务器");
}
}
private static int effectivePort(URI uri) {
if (uri.getPort() >= 0) {
return uri.getPort();
}
return "https".equalsIgnoreCase(uri.getScheme()) ? 443 : 80;
}
private boolean isConfigured() {
return properties.isEnabled()
&& properties.getGiteaBaseUrl() != null
&& !properties.getGiteaBaseUrl().isBlank()
&& properties.getRepository() != null
&& !properties.getRepository().isBlank();
}
private static boolean isActivePhase(UpdatePhase phase) {
return phase == UpdatePhase.STARTING
|| phase == UpdatePhase.DOWNLOADING
|| phase == UpdatePhase.VERIFYING
|| phase == UpdatePhase.INSTALLING
|| phase == UpdatePhase.RESTARTING
|| phase == UpdatePhase.ROLLING_BACK;
}
private static String text(JsonNode node, String field) {
JsonNode value = node.path(field);
return value.isTextual() ? value.asText().trim() : "";
@@ -422,6 +695,15 @@ public class SystemUpdateService {
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 {
IDLE,
CHECKING,
@@ -4,6 +4,7 @@ import com.kaidi.oa.common.ApiException;
import com.kaidi.oa.common.ApiResp;
import com.kaidi.oa.domain.SysUser;
import com.kaidi.oa.service.AuthService;
import com.kaidi.oa.service.AuthorizationService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.constraints.NotBlank;
import org.springframework.web.bind.annotation.GetMapping;
@@ -14,6 +15,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.TreeSet;
/**
* Authentication endpoints. Issues an opaque token on login; the frontend sends
@@ -25,10 +27,13 @@ public class AuthController {
private final AuthService authService;
private final CurrentUserResolver currentUser;
private final AuthorizationService authorizationService;
public AuthController(AuthService authService, CurrentUserResolver currentUser) {
public AuthController(AuthService authService, CurrentUserResolver currentUser,
AuthorizationService authorizationService) {
this.authService = authService;
this.currentUser = currentUser;
this.authorizationService = authorizationService;
}
public record LoginRequest(@NotBlank String loginName, @NotBlank String password) {
@@ -65,6 +70,7 @@ public class AuthController {
map.put("deptId", user.getDeptId());
map.put("title", user.getTitle());
map.put("email", user.getEmail());
map.put("roles", new TreeSet<>(authorizationService.roleCodesOf(user.getId())));
return map;
}
}
@@ -1,19 +1,67 @@
package com.kaidi.oa.web;
import com.kaidi.oa.common.ApiResp;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.sql.DataSource;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.Map;
/** Liveness probe. */
/** Readiness probe used by the installer and online-update rollback gate. */
@RestController
@RequestMapping("/api/oa")
public class HealthController {
private final DataSource dataSource;
private final Path installLockFile;
public HealthController(DataSource dataSource,
@Value("${ERP_INSTALL_LOCK_FILE:}") String installLockFile) {
this.dataSource = dataSource;
this.installLockFile = resolveInstallLockFile(installLockFile);
}
@GetMapping("/health")
public ApiResp<Map<String, String>> health() {
return ApiResp.ok(Map.of("status", "UP"));
public ResponseEntity<ApiResp<Map<String, String>>> health() {
try (Connection connection = dataSource.getConnection();
Statement statement = connection.createStatement();
ResultSet result = statement.executeQuery("SELECT 1")) {
if (connection.isValid(2) && result.next() && result.getInt(1) == 1) {
return ResponseEntity.ok(ApiResp.ok(readiness("UP", "UP")));
}
} catch (Exception ignored) {
// Health responses deliberately avoid exposing connection details.
}
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(new ApiResp<>(50301, "database unavailable", readiness("DOWN", "DOWN")));
}
private Map<String, String> readiness(String status, String database) {
return Map.of(
"status", status,
"database", database,
"installLocked", Boolean.toString(installLockFile != null && Files.isRegularFile(installLockFile))
);
}
private static Path resolveInstallLockFile(String value) {
if (value == null || value.isBlank()) {
return null;
}
try {
return Path.of(value).toAbsolutePath().normalize();
} catch (InvalidPathException ignored) {
return null;
}
}
}
@@ -1,25 +1,47 @@
package com.kaidi.oa.web;
import com.kaidi.oa.common.ApiResp;
import com.kaidi.oa.service.SystemUpdateConfigService;
import com.kaidi.oa.service.SystemUpdateConfigService.UpdateConfig;
import com.kaidi.oa.service.SystemUpdateConfigService.UpdateConfigRequest;
import com.kaidi.oa.service.SystemUpdateService;
import com.kaidi.oa.service.SystemUpdateService.ReleaseHistoryItem;
import com.kaidi.oa.service.SystemUpdateService.UpdateStatus;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/** Administrator-only API for checking and installing signed Gitea releases. */
@RestController
@RequestMapping("/api/oa/system-update")
public class SystemUpdateController {
private final SystemUpdateService updateService;
private final SystemUpdateConfigService configService;
public SystemUpdateController(SystemUpdateService updateService) {
public SystemUpdateController(SystemUpdateService updateService, SystemUpdateConfigService configService) {
this.updateService = updateService;
this.configService = configService;
}
@GetMapping("/config")
public ApiResp<UpdateConfig> config() {
return ApiResp.ok(configService.get());
}
@PutMapping("/config")
public ApiResp<UpdateConfig> saveConfig(@Valid @RequestBody UpdateConfigRequest request) {
updateService.ensureConfigurationMutable();
UpdateConfig saved = configService.save(request);
updateService.configurationChanged();
return ApiResp.ok(saved);
}
@GetMapping("/status")
@@ -27,6 +49,11 @@ public class SystemUpdateController {
return ApiResp.ok(updateService.status());
}
@GetMapping("/releases")
public ApiResp<List<ReleaseHistoryItem>> releases() {
return ApiResp.ok(updateService.releases());
}
@PostMapping("/check")
public ApiResp<UpdateStatus> check() {
return ApiResp.ok(updateService.check());
@@ -1,5 +1,8 @@
server:
port: 8090
# Reconstruct the public scheme/host when running behind Nginx, Caddy or a
# tunnel so same-origin HTTPS requests are not rejected as cross-origin.
forward-headers-strategy: framework
spring:
application:
@@ -54,6 +57,8 @@ logging:
# 要真正产出 AI 结果:把 api-key 填上,或设置环境变量 ANTHROPIC_API_KEY。
# ---------------------------------------------------------------------------
oa:
seed:
demo: ${OA_SEED_DEMO:true}
ai:
api-key: ""
model: claude-haiku-4-5-20251001
@@ -65,4 +70,5 @@ oa:
token: ${OA_UPDATE_TOKEN:}
helper-command: ${OA_UPDATE_HELPER_COMMAND:}
state-file: ${OA_UPDATE_STATE_FILE:./runtime/update-state.json}
config-file: ${ERP_CONFIG_FILE:}
allow-insecure-http: ${OA_UPDATE_ALLOW_INSECURE_HTTP:false}
@@ -0,0 +1,98 @@
package com.kaidi.oa.service;
import com.kaidi.oa.common.ApiException;
import com.kaidi.oa.config.UpdateProperties;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class SystemUpdateConfigServiceTest {
@TempDir
Path tempDir;
@Test
void atomicallyUpdatesOnlyUpdaterSettingsAndNeverReturnsToken() throws Exception {
Path configFile = tempDir.resolve("erp.env");
Files.writeString(configFile, """
OA_DB_PASSWORD='keep-this-secret'
OA_UPDATE_ENABLED='true'
OA_UPDATE_GITEA_BASE_URL='https://old.example'
OA_UPDATE_REPOSITORY='old/ERP'
OA_UPDATE_CHANNEL='stable'
OA_UPDATE_TOKEN='old-token'
OA_UPDATE_ALLOW_INSECURE_HTTP='false'
""");
UpdateProperties properties = properties(configFile);
properties.setToken("old-token");
SystemUpdateConfigService service = new SystemUpdateConfigService(properties);
SystemUpdateConfigService.UpdateConfig saved = service.save(
new SystemUpdateConfigService.UpdateConfigRequest(
true,
"http://38.76.196.225:10099/",
"awaioi/ERP",
"stable",
"new-token_123",
false,
true));
String persisted = Files.readString(configFile);
assertThat(persisted).contains("OA_DB_PASSWORD='keep-this-secret'");
assertThat(persisted).contains("OA_UPDATE_GITEA_BASE_URL='http://38.76.196.225:10099'");
assertThat(persisted).contains("OA_UPDATE_TOKEN='new-token_123'");
assertThat(saved.tokenConfigured()).isTrue();
assertThat(saved.toString()).doesNotContain("new-token_123");
assertThat(properties.getToken()).isEqualTo("new-token_123");
}
@Test
void blankTokenKeepsExistingTokenAndExplicitClearRemovesIt() throws Exception {
Path configFile = tempDir.resolve("erp.env");
Files.writeString(configFile, "OA_DB_PASSWORD='database-secret'\n");
UpdateProperties properties = properties(configFile);
properties.setToken("existing-token");
SystemUpdateConfigService service = new SystemUpdateConfigService(properties);
service.save(request("" , false));
assertThat(properties.getToken()).isEqualTo("existing-token");
assertThat(Files.readString(configFile)).contains("OA_UPDATE_TOKEN='existing-token'");
SystemUpdateConfigService.UpdateConfig cleared = service.save(request("", true));
assertThat(cleared.tokenConfigured()).isFalse();
assertThat(properties.getToken()).isEmpty();
assertThat(Files.readString(configFile)).contains("OA_UPDATE_TOKEN=''");
}
@Test
void rejectsPlainHttpWithoutExplicitOptIn() throws Exception {
Path configFile = tempDir.resolve("erp.env");
Files.writeString(configFile, "OA_UPDATE_ENABLED='false'\n");
SystemUpdateConfigService service = new SystemUpdateConfigService(properties(configFile));
assertThatThrownBy(() -> service.save(new SystemUpdateConfigService.UpdateConfigRequest(
true, "http://gitea.example", "awaioi/ERP", "stable", "", false, false)))
.isInstanceOf(ApiException.class)
.hasMessageContaining("HTTPS");
}
private UpdateProperties properties(Path configFile) {
UpdateProperties properties = new UpdateProperties();
properties.setEnabled(true);
properties.setGiteaBaseUrl("https://old.example");
properties.setRepository("awaioi/ERP");
properties.setChannel("stable");
properties.setConfigFile(configFile.toString());
return properties;
}
private SystemUpdateConfigService.UpdateConfigRequest request(String token, boolean clearToken) {
return new SystemUpdateConfigService.UpdateConfigRequest(
true, "https://gitea.example", "awaioi/ERP", "stable", token, clearToken, false);
}
}
@@ -5,6 +5,7 @@ import com.kaidi.oa.common.ApiException;
import com.kaidi.oa.config.UpdateProperties;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.info.BuildProperties;
@@ -13,6 +14,7 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.List;
import java.util.Properties;
import static org.assertj.core.api.Assertions.assertThat;
@@ -22,6 +24,9 @@ import static org.mockito.Mockito.when;
class SystemUpdateServiceTest {
@TempDir
Path tempDir;
@Test
void comparesReleaseVersionsWithoutLexicographicMistakes() {
assertThat(SystemUpdateService.compareVersions("v0.10.0", "0.9.9")).isPositive();
@@ -38,7 +43,8 @@ class SystemUpdateServiceTest {
void checksLatestGiteaReleaseAndRequiresSignedAssetSet() 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 = """
String origin = "http://127.0.0.1:" + exchange.getLocalAddress().getPort();
byte[] body = ("""
{
"tag_name": "v0.2.0",
"body": "PostgreSQL production release",
@@ -46,12 +52,12 @@ class SystemUpdateServiceTest {
"prerelease": false,
"published_at": "2026-08-03T10:00:00Z",
"assets": [
{"name":"kaidi-erp-0.2.0.tar.gz","browser_download_url":"https://example.test/app","size":123},
{"name":"SHA256SUMS","browser_download_url":"https://example.test/sums","size":64},
{"name":"SHA256SUMS.sig","browser_download_url":"https://example.test/sig","size":64}
{"name":"kaidi-erp-0.2.0.tar.gz","browser_download_url":"%s/app","size":123},
{"name":"SHA256SUMS","browser_download_url":"%s/sums","size":64},
{"name":"SHA256SUMS.sig","browser_download_url":"%s/sig","size":64}
]
}
""".getBytes(StandardCharsets.UTF_8);
""").formatted(origin, origin, origin).getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "application/json");
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
@@ -74,6 +80,148 @@ class SystemUpdateServiceTest {
}
}
@Test
void stableChannelFallsBackToNewestStableReleaseWhenLatestIsPrerelease() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/api/v1/repos/awaioi/ERP/releases/latest", exchange -> {
byte[] body = """
{"tag_name":"v0.3.7-rc.1","draft":false,"prerelease":true,"assets":[]}
""".getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
});
server.createContext("/api/v1/repos/awaioi/ERP/releases", exchange -> {
String origin = "http://127.0.0.1:" + exchange.getLocalAddress().getPort();
byte[] body = ("""
[
{"tag_name":"v0.3.7-rc.1","draft":false,"prerelease":true,"assets":[]},
{"tag_name":"v0.3.5","draft":false,"prerelease":false,"assets":[]},
{"tag_name":"v0.3.6","body":"Latest stable","draft":false,"prerelease":false,
"published_at":"2026-08-04T10:00:00Z","assets":[
{"name":"kaidi-erp-0.3.6.tar.gz","browser_download_url":"%s/app","size":123},
{"name":"SHA256SUMS","browser_download_url":"%s/sums","size":64},
{"name":"SHA256SUMS.sig","browser_download_url":"%s/sig","size":64}
]}
]
""").formatted(origin, origin, origin).getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
});
server.start();
try {
SystemUpdateService service = service(configuredProperties(server.getAddress().getPort()), "0.3.5");
SystemUpdateService.UpdateStatus status = service.check();
assertThat(status.latestVersion()).isEqualTo("0.3.6");
assertThat(status.updateAvailable()).isTrue();
assertThat(status.releaseNotes()).isEqualTo("Latest stable");
} finally {
server.stop(0);
}
}
@Test
void listsStableReleaseHistoryNewestFirstAndMarksCurrentVersion() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/api/v1/repos/awaioi/ERP/releases", exchange -> {
byte[] body = """
[
{"tag_name":"v0.3.4","body":"Older fixes","draft":false,"prerelease":false,
"published_at":"2026-08-03T10:00:00Z"},
{"tag_name":"v0.3.6","body":"Simplified updater","draft":false,"prerelease":false,
"published_at":"2026-08-04T10:00:00Z"},
{"tag_name":"v0.3.7-rc.1","body":"Preview","draft":false,"prerelease":true},
{"tag_name":"v0.3.8","body":"Draft","draft":true,"prerelease":false},
{"tag_name":"not-a-version","body":"Invalid","draft":false,"prerelease":false}
]
""".getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "application/json");
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
});
server.start();
try {
SystemUpdateService service = service(configuredProperties(server.getAddress().getPort()), "0.3.4");
List<SystemUpdateService.ReleaseHistoryItem> releases = service.releases();
assertThat(releases).extracting(SystemUpdateService.ReleaseHistoryItem::version)
.containsExactly("0.3.6", "0.3.4");
assertThat(releases.get(0).latest()).isTrue();
assertThat(releases.get(0).current()).isFalse();
assertThat(releases.get(1).current()).isTrue();
assertThat(releases.get(1).releaseNotes()).isEqualTo("Older fixes");
} finally {
server.stop(0);
}
}
@Test
void persistsReleaseMetadataAcrossServiceRestart() throws Exception {
Path stateFile = Files.createTempFile("system-update-metadata-", ".json");
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/api/v1/repos/awaioi/ERP/releases/latest", exchange -> {
String origin = "http://127.0.0.1:" + exchange.getLocalAddress().getPort();
byte[] body = ("""
{"tag_name":"v0.4.0","body":"Fix updater UI","draft":false,"prerelease":false,
"published_at":"2026-08-04T09:30:00Z","assets":[
{"name":"kaidi-erp-0.4.0.tar.gz","browser_download_url":"%s/app","size":123},
{"name":"SHA256SUMS","browser_download_url":"%s/sums","size":64},
{"name":"SHA256SUMS.sig","browser_download_url":"%s/sig","size":64}]}
""").formatted(origin, origin, origin).getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
});
server.start();
try {
UpdateProperties properties = configuredProperties(server.getAddress().getPort());
properties.setStateFile(stateFile.toString());
SystemUpdateService first = service(properties, "0.3.0");
first.check();
SystemUpdateService restarted = service(properties, "0.3.0");
SystemUpdateService.UpdateStatus restored = restarted.status();
assertThat(restored.releaseNotes()).isEqualTo("Fix updater UI");
assertThat(restored.publishedAt()).isEqualTo(Instant.parse("2026-08-04T09:30:00Z"));
assertThat(restored.assets()).hasSize(3);
assertThat(restored.updateAvailable()).isTrue();
} finally {
server.stop(0);
Files.deleteIfExists(stateFile);
}
}
@Test
void rejectsReleaseAssetsFromAnotherOrigin() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/api/v1/repos/awaioi/ERP/releases/latest", exchange -> {
byte[] body = """
{"tag_name":"v0.4.0","draft":false,"prerelease":false,"assets":[
{"name":"kaidi-erp-0.4.0.tar.gz","browser_download_url":"https://evil.example/app","size":123},
{"name":"SHA256SUMS","browser_download_url":"https://evil.example/sums","size":64},
{"name":"SHA256SUMS.sig","browser_download_url":"https://evil.example/sig","size":64}]}
""".getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
});
server.start();
try {
SystemUpdateService service = service(configuredProperties(server.getAddress().getPort()), "0.3.0");
assertThatThrownBy(service::check)
.isInstanceOf(ApiException.class)
.hasMessageContaining("未受信任");
} finally {
server.stop(0);
}
}
@Test
void rejectsPlainHttpUnlessDevelopmentOverrideIsExplicit() {
UpdateProperties properties = configuredProperties(1);
@@ -112,12 +260,13 @@ class SystemUpdateServiceTest {
}
}
private static UpdateProperties configuredProperties(int port) {
private UpdateProperties configuredProperties(int port) {
UpdateProperties properties = new UpdateProperties();
properties.setEnabled(true);
properties.setAllowInsecureHttp(true);
properties.setGiteaBaseUrl("http://127.0.0.1:" + port);
properties.setRepository("awaioi/ERP");
properties.setStateFile(tempDir.resolve("update-state-" + System.nanoTime() + ".json").toString());
return properties;
}
@@ -0,0 +1,56 @@
package com.kaidi.oa.web;
import com.kaidi.oa.common.ApiResp;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.http.ResponseEntity;
import javax.sql.DataSource;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class HealthControllerTest {
@TempDir
Path tempDir;
@Test
void reportsTheActualInstallationLockState() throws Exception {
DataSource dataSource = mock(DataSource.class);
Connection connection = mock(Connection.class);
Statement statement = mock(Statement.class);
ResultSet resultSet = mock(ResultSet.class);
when(dataSource.getConnection()).thenReturn(connection);
when(connection.createStatement()).thenReturn(statement);
when(connection.isValid(2)).thenReturn(true);
when(statement.executeQuery("SELECT 1")).thenReturn(resultSet);
when(resultSet.next()).thenReturn(true);
when(resultSet.getInt(1)).thenReturn(1);
Path lockFile = tempDir.resolve("install.lock");
HealthController controller = new HealthController(dataSource, lockFile.toString());
assertThat(healthData(controller.health())).containsEntry("installLocked", "false");
Files.writeString(lockFile, "locked\n");
assertThat(healthData(controller.health()))
.containsEntry("status", "UP")
.containsEntry("database", "UP")
.containsEntry("installLocked", "true");
}
private static Map<String, String> healthData(
ResponseEntity<ApiResp<Map<String, String>>> response) {
assertThat(response.getBody()).isNotNull();
return response.getBody().data();
}
}
@@ -49,6 +49,7 @@ export type OaMenuItem = {
/** page archetype the builder implements: list | form | detail | portal | tree | calendar | board | settings | report */
kind: 'list' | 'form' | 'detail' | 'portal' | 'tree' | 'calendar' | 'board' | 'settings' | 'report'
path: string
adminOnly?: boolean
}
export type OaModule = {
@@ -98,7 +99,7 @@ export const oaModules: OaModule[] = [
{ key: 'workbench', label: '工作台', kind: 'portal', path: '/appdev/workbench' },
{ key: 'appmgr', label: '应用管理中心', kind: 'list', path: '/appdev/appmgr' },
{ key: 'ops', label: '运维中心', kind: 'settings', path: '/appdev/ops' },
{ key: 'update', label: '系统更新', kind: 'settings', path: '/appdev/update' },
{ key: 'update', label: '系统更新', kind: 'settings', path: '/appdev/update', adminOnly: true },
{ key: 'monitor', label: '监测中心', kind: 'report', path: '/appdev/monitor' },
{ key: 'aiassist', label: 'AI 助手', kind: 'portal', path: '/appdev/aiassist' },
{ key: 'ruleconfig', label: '联动规则配置', kind: 'list', path: '/appdev/ruleconfig' }
@@ -26,13 +26,20 @@ import {
userShortcuts
} from './shortcuts'
import { useSession, currentUserName, currentDeptName } from './session'
import { oaApi, type OaMessage } from './api'
import { oaApi, type OaMessage, type SystemUpdateStatus } from './api'
const route = useRoute()
const router = useRouter()
const session = useSession()
const userInitial = computed(() => currentUserName.value.slice(0, 1) || '我')
const isAdmin = computed(() => session.user.value?.roles?.includes('ADMIN') === true)
const visibleModules = computed<OaModule[]>(() => oaModules
.map((module) => ({
...module,
children: module.children.filter((child) => !child.adminOnly || isAdmin.value)
}))
.filter((module) => module.children.length > 0))
async function handleLogout() {
await session.logout()
ElMessage.success('已退出登录')
@@ -42,6 +49,10 @@ async function handleLogout() {
const unreadCount = ref(0)
const messages = ref<OaMessage[]>([])
let unreadTimer: ReturnType<typeof setInterval> | undefined
let updateTimer: ReturnType<typeof setInterval> | undefined
const updateStatus = ref<SystemUpdateStatus | null>(null)
const updateAvailable = computed(() => updateStatus.value?.updateAvailable === true)
const updateVersion = computed(() => updateStatus.value?.latestVersion || '')
async function loadUnread() {
try {
@@ -58,6 +69,14 @@ async function loadMessages() {
messages.value = []
}
}
async function loadUpdateStatus() {
if (!isAdmin.value) return
try {
updateStatus.value = await oaApi.getSystemUpdateStatus()
} catch {
// The updater must never block the application shell.
}
}
async function readAllMessages() {
try {
await oaApi.markAllMessagesRead()
@@ -108,11 +127,14 @@ function msgTagType(type: string): 'warning' | 'danger' | 'success' | 'info' {
onMounted(() => {
void loadUnread()
void loadUpdateStatus()
// 30s/退/
unreadTimer = setInterval(() => void loadUnread(), 30000)
updateTimer = setInterval(() => void loadUpdateStatus(), 10 * 60 * 1000)
})
onBeforeUnmount(() => {
if (unreadTimer) clearInterval(unreadTimer)
if (updateTimer) clearInterval(updateTimer)
})
const globalQuery = ref('')
@@ -157,14 +179,14 @@ const currentSpaceLabel = computed(
// The active top module is whichever module owns the current route path.
const activeModuleId = computed(() => {
const match = oaModules.find((module) => route.path.startsWith(module.path))
const match = visibleModules.value.find((module) => route.path.startsWith(module.path))
return match?.id || ''
})
// ----- el-menu-----
// /collab/handle?id=
const activeMenu = computed(() => {
const paths = oaModules.flatMap((module) => module.children.map((child) => child.path))
const paths = visibleModules.value.flatMap((module) => module.children.map((child) => child.path))
if (paths.includes(route.path)) return route.path
const prefixHit = paths
.filter((p) => route.path.startsWith(p))
@@ -253,7 +275,7 @@ function runGlobalSearch() {
<div class="oa-appcenter__head">应用中心</div>
<div class="oa-appcenter__grid">
<button
v-for="module in oaModules"
v-for="module in visibleModules"
:key="module.id"
type="button"
class="oa-appcenter__item"
@@ -315,9 +337,18 @@ function runGlobalSearch() {
</el-scrollbar>
</div>
</el-popover>
<el-tooltip content="设置" placement="bottom">
<el-button class="oa-tools__btn" :icon="Setting" text circle @click="navigate('/hr/worktime')" />
<el-badge
v-if="isAdmin"
:is-dot="updateAvailable"
:hidden="!updateAvailable"
class="oa-tools__update-badge"
>
<el-tooltip :content="updateAvailable ? `发现新版本 ${updateVersion}` : '系统更新'" placement="bottom">
<el-button class="oa-tools__btn oa-update-entry" :icon="Setting" text @click="navigate('/appdev/update')">
系统更新
</el-button>
</el-tooltip>
</el-badge>
<el-dropdown trigger="click">
<span class="oa-avatar">
<el-avatar :size="30" class="oa-avatar__img">{{ userInitial }}</el-avatar>
@@ -327,6 +358,9 @@ function runGlobalSearch() {
<el-dropdown-item disabled>{{ currentUserName }}{{ currentDeptName ? ' · ' + currentDeptName : '' }}</el-dropdown-item>
<el-dropdown-item divided @click="navigate('/')">个人空间</el-dropdown-item>
<el-dropdown-item @click="navigate('/contacts')">通讯录</el-dropdown-item>
<el-dropdown-item v-if="isAdmin" @click="navigate('/appdev/update')">
{{ updateAvailable ? `系统更新 · ${updateVersion}` : '系统更新' }}
</el-dropdown-item>
<el-dropdown-item divided @click="handleLogout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
@@ -346,7 +380,7 @@ function runGlobalSearch() {
unique-opened
@select="onMenuSelect"
>
<el-sub-menu v-for="module in oaModules" :key="module.id" :index="module.id">
<el-sub-menu v-for="module in visibleModules" :key="module.id" :index="module.id">
<template #title>
<el-icon class="oa-menu__icon"><component :is="module.icon" /></el-icon>
<span class="oa-menu__label">{{ module.label }}</span>
@@ -457,8 +491,19 @@ function runGlobalSearch() {
</button>
</div>
<button
v-if="isAdmin"
type="button"
class="oa-mobilenav__update"
@click="goMobile('/appdev/update')"
>
<el-icon><Setting /></el-icon>
<span>系统更新</span>
<el-tag v-if="updateAvailable" type="warning" effect="plain" size="small">{{ updateVersion }}</el-tag>
</button>
<el-collapse class="oa-mobilenav__modules" accordion>
<el-collapse-item v-for="module in oaModules" :key="module.id" :name="module.id">
<el-collapse-item v-for="module in visibleModules" :key="module.id" :name="module.id">
<template #title>
<el-icon class="oa-mobilenav__mod-icon"><component :is="module.icon" /></el-icon>
<span class="oa-mobilenav__mod-label">{{ module.label }}</span>
@@ -569,6 +614,16 @@ function runGlobalSearch() {
right: 10px;
}
.oa-tools__update-badge :deep(.el-badge__content.is-dot) {
top: 6px;
right: 8px;
}
.oa-update-entry {
padding-inline: var(--erp-space-2);
font-size: var(--erp-font-size-sm);
}
.oa-search__hint {
margin: var(--erp-space-2) 0 0;
color: var(--erp-color-text-subtle);
@@ -977,6 +1032,27 @@ function runGlobalSearch() {
border-top: 0;
}
.oa-mobilenav__update {
display: flex;
align-items: center;
gap: var(--erp-space-2);
width: 100%;
min-height: 44px;
padding: var(--erp-space-2) var(--erp-space-3);
margin: var(--erp-space-3) 0;
color: var(--erp-color-text);
font-size: var(--erp-font-size-sm);
font-weight: 650;
text-align: left;
background: var(--erp-color-surface-muted);
border: 1px solid var(--erp-color-border-soft);
border-radius: var(--erp-radius-sm);
}
.oa-mobilenav__update span {
flex: 1;
}
.oa-mobilenav__mod-icon {
margin-right: var(--erp-space-2);
color: var(--erp-color-primary);
@@ -1036,6 +1112,14 @@ function runGlobalSearch() {
.oa-tools {
gap: 0;
}
.oa-update-entry {
width: 36px;
padding: 0;
font-size: 0;
}
.oa-update-entry :deep(.el-icon) {
font-size: var(--erp-font-size-lg);
}
.oa-avatar {
margin-left: var(--erp-space-1);
}
@@ -11,6 +11,7 @@ export interface OaSession {
deptId: number | null
title: string | null
email: string | null
roles: string[]
}
/** POST /auth/login -> stores the token and returns the session. */
@@ -190,6 +190,7 @@ export const oaApi = {
listAlerts: alertsApi.listAlerts,
// 系统更新
getSystemUpdateStatus: updateApi.getSystemUpdateStatus,
getSystemUpdateReleases: updateApi.getSystemUpdateReleases,
checkSystemUpdate: updateApi.checkSystemUpdate,
installSystemUpdate: updateApi.installSystemUpdate
}
@@ -216,7 +217,9 @@ export type {
ReportRunRow, ReportRunResult
} from './reportdefs'
export type { Alert } from './alerts'
export type { SystemUpdateStatus, UpdatePhase, ReleaseAsset } from './update'
export type {
SystemUpdateStatus, ReleaseHistoryItem, UpdatePhase, ReleaseAsset
} from './update'
export type {
CompanySubject, Contract, Supplier, Customer, BankAccount, Seal, Invoice, ContractMilestone
} from './masterdata'
@@ -36,10 +36,22 @@ export interface SystemUpdateStatus {
error: string | null
}
export interface ReleaseHistoryItem {
version: string
publishedAt: string | null
releaseNotes: string
current: boolean
latest: boolean
}
export function getSystemUpdateStatus() {
return http.get<SystemUpdateStatus>('/system-update/status')
}
export function getSystemUpdateReleases() {
return http.get<ReleaseHistoryItem[]>('/system-update/releases', undefined, { timeoutMs: 30000 })
}
export function checkSystemUpdate() {
return http.post<SystemUpdateStatus>('/system-update/check', undefined, { timeoutMs: 30000 })
}
@@ -1,15 +1,32 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Download, Refresh, Warning } from '@element-plus/icons-vue'
import { Check, Download, Refresh, Warning } from '@element-plus/icons-vue'
import ErpPageHeader from '../../../components/erp/ErpPageHeader.vue'
import { oaApi, OaApiError, type SystemUpdateStatus, type UpdatePhase } from '../../api'
import {
oaApi,
OaApiError,
type ReleaseHistoryItem,
type SystemUpdateStatus,
type UpdatePhase
} from '../../api'
import { useSession } from '../../session'
const router = useRouter()
const session = useSession()
const isAdmin = computed(() => session.user.value?.roles?.includes('ADMIN') === true)
const status = ref<SystemUpdateStatus | null>(null)
const releases = ref<ReleaseHistoryItem[]>([])
const loading = ref(false)
const historyLoading = ref(false)
const checking = ref(false)
const installing = ref(false)
const reconnecting = ref(false)
const pollFailures = ref(0)
let pollTimer: number | undefined
let pollRequestRunning = false
const activePhases = new Set<UpdatePhase>([
'STARTING', 'DOWNLOADING', 'VERIFYING', 'INSTALLING', 'RESTARTING', 'ROLLING_BACK'
@@ -27,55 +44,111 @@ const phaseLabels: Record<UpdatePhase, string> = {
RESTARTING: '正在重启',
SUCCEEDED: '更新完成',
ROLLING_BACK: '正在回滚',
ROLLED_BACK: '已回滚',
ROLLED_BACK: '已自动回滚',
FAILED: '更新失败'
}
const busy = computed(() => !!status.value && activePhases.has(status.value.phase))
const canInstall = computed(() => Boolean(
status.value?.configured && status.value.updateAvailable && status.value.latestVersion && !busy.value
const canCheck = computed(() => Boolean(
status.value?.configured
&& !busy.value
&& !reconnecting.value
&& !checking.value
&& !installing.value
))
const phaseTone = computed(() => {
const canInstall = computed(() => Boolean(
status.value?.configured
&& status.value.updateAvailable
&& status.value.latestVersion
&& !busy.value
&& !reconnecting.value
&& !checking.value
&& !installing.value
))
const remoteLatest = computed(() => releases.value.find(release => release.latest) || releases.value[0] || null)
const latestVersion = computed(() => status.value?.latestVersion || remoteLatest.value?.version || null)
const latestRelease = computed(() => {
const checkedVersion = status.value?.latestVersion
if (checkedVersion) {
return releases.value.find(release => release.version === checkedVersion) || remoteLatest.value
}
return remoteLatest.value
})
const latestPublishedAt = computed(() => status.value?.publishedAt || latestRelease.value?.publishedAt || null)
const latestReleaseNotes = computed(() => status.value?.releaseNotes || latestRelease.value?.releaseNotes || '')
const phaseTone = computed<'success' | 'warning' | 'danger' | 'info'>(() => {
const phase = status.value?.phase
if (phase === 'FAILED' || phase === 'ROLLED_BACK') return 'danger'
if (phase === 'AVAILABLE') return 'warning'
if (phase === 'SUCCEEDED' || phase === 'UP_TO_DATE') return 'success'
return 'info'
})
const progressStatus = computed<'success' | 'exception' | undefined>(() => {
if (status.value?.phase === 'SUCCEEDED') return 'success'
if (status.value?.phase === 'FAILED' || status.value?.phase === 'ROLLED_BACK') return 'exception'
return undefined
})
function apiMessage(error: unknown, fallback: string) {
return error instanceof OaApiError ? error.message : fallback
}
function formatDate(value: string | null) {
function formatDate(value: string | null | undefined) {
if (!value) return '-'
const date = new Date(value)
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false })
}
function formatBytes(size: number) {
if (size <= 0) return '-'
if (size < 1024 * 1024) return `${Math.ceil(size / 1024)} KB`
return `${(size / 1024 / 1024).toFixed(1)} MB`
function formatVersion(value: string | null | undefined) {
if (!value) return '-'
return /^v/i.test(value) ? value : `v${value}`
}
async function loadStatus(silent = false) {
if (!silent) loading.value = true
try {
status.value = await oaApi.getSystemUpdateStatus()
reconnecting.value = false
pollFailures.value = 0
if (busy.value) startPolling()
else stopPolling()
} catch (error) {
if (busy.value || status.value?.phase === 'RESTARTING' || reconnecting.value) {
reconnecting.value = true
pollFailures.value += 1
startPolling()
return
}
if (!silent) ElMessage.error(apiMessage(error, '更新状态加载失败'))
}
}
async function loadReleases(silent = false) {
historyLoading.value = true
try {
releases.value = await oaApi.getSystemUpdateReleases()
} catch (error) {
if (!silent) ElMessage.error(apiMessage(error, '历史版本加载失败'))
} finally {
if (!silent) loading.value = false
historyLoading.value = false
}
}
async function loadPage() {
loading.value = true
try {
await loadStatus()
if (status.value?.configured) await loadReleases()
} finally {
loading.value = false
}
}
async function checkUpdate() {
if (!canCheck.value) return
checking.value = true
try {
status.value = await oaApi.checkSystemUpdate()
await loadReleases(true)
ElMessage.success(status.value.updateAvailable ? '发现新版本' : '当前已是最新版本')
} catch (error) {
ElMessage.error(apiMessage(error, '检查更新失败'))
@@ -86,20 +159,24 @@ async function checkUpdate() {
}
async function installUpdate() {
if (!canInstall.value) return
const version = status.value?.latestVersion
if (!version) return
installing.value = true
try {
await ElMessageBox.confirm(
`确认安装 ${version} 并重启服务?`,
'安装更新',
`确认安装 ${formatVersion(version)}?服务将自动重启,健康检查失败会切回上一版本。`,
'安装正式更新',
{ type: 'warning', confirmButtonText: '安装并重启', cancelButtonText: '取消' }
)
} catch {
installing.value = false
return
}
installing.value = true
try {
status.value = await oaApi.installSystemUpdate(version)
reconnecting.value = false
pollFailures.value = 0
ElMessage.success('更新任务已启动')
startPolling()
} catch (error) {
@@ -111,7 +188,15 @@ async function installUpdate() {
function startPolling() {
if (pollTimer !== undefined) return
pollTimer = window.setInterval(() => loadStatus(true), 3000)
pollTimer = window.setInterval(async () => {
if (pollRequestRunning) return
pollRequestRunning = true
try {
await loadStatus(true)
} finally {
pollRequestRunning = false
}
}, 2500)
}
function stopPolling() {
@@ -120,19 +205,35 @@ function stopPolling() {
pollTimer = undefined
}
onMounted(() => loadStatus())
async function initializePage() {
if (!isAdmin.value) {
ElMessage.error('系统更新仅限管理员访问')
await router.replace('/')
return
}
await loadPage()
}
onMounted(initializePage)
onBeforeUnmount(stopPolling)
</script>
<template>
<div class="update-page" v-loading="loading">
<div v-if="isAdmin" class="update-page" v-loading="loading">
<ErpPageHeader
title="系统更新"
:crumbs="['应用定制平台', '系统更新']"
description="正式版本"
:description="`当前版本 ${formatVersion(status?.currentVersion)}`"
>
<template #actions>
<el-button :icon="Refresh" :loading="checking" @click="checkUpdate">检查更新</el-button>
<el-button
:icon="Refresh"
:loading="checking"
:disabled="!canCheck"
@click="checkUpdate"
>
检查更新
</el-button>
<el-button
type="primary"
:icon="Download"
@@ -150,17 +251,45 @@ onBeforeUnmount(stopPolling)
type="warning"
:closable="false"
show-icon
title="在线更新尚未配置"
title="服务器尚未启用在线更新"
description="请通过安装器完成正式服务配置。"
class="update-alert"
/>
<el-alert
v-else-if="reconnecting"
type="info"
:closable="false"
show-icon
title="正式服务正在重启,页面会自动重新连接"
:description="`已重试 ${pollFailures} 次`"
class="update-alert"
/>
<el-alert
v-else-if="status?.phase === 'SUCCEEDED'"
type="success"
:closable="false"
show-icon
title="新版本已启动并通过健康检查"
class="update-alert"
/>
<el-alert
v-else-if="status?.phase === 'ROLLED_BACK'"
type="error"
:closable="false"
show-icon
title="新版本健康检查失败,系统已自动切回上一版本"
:description="status.error || undefined"
class="update-alert"
/>
<section class="update-summary">
<section class="update-summary" aria-label="版本状态">
<div class="version-block">
<span class="field-label">当前版本</span>
<strong>{{ status?.currentVersion || '-' }}</strong>
<strong>{{ formatVersion(status?.currentVersion) }}</strong>
</div>
<div class="version-block">
<span class="field-label">最新版本</span>
<strong>{{ status?.latestVersion || '-' }}</strong>
<span class="field-label">在线最新版本</span>
<strong>{{ formatVersion(latestVersion) }}</strong>
</div>
<div class="version-block">
<span class="field-label">更新状态</span>
@@ -169,47 +298,62 @@ onBeforeUnmount(stopPolling)
</el-tag>
</div>
<div class="version-block">
<span class="field-label">检查时间</span>
<span>{{ formatDate(status?.checkedAt || null) }}</span>
<span class="field-label">最后检查时间</span>
<span>{{ formatDate(status?.checkedAt) }}</span>
</div>
</section>
<section v-if="status && (busy || status.phase === 'FAILED' || status.phase === 'ROLLED_BACK')" class="update-progress">
<section
v-if="status && (busy || status.phase === 'FAILED' || status.phase === 'ROLLED_BACK' || status.phase === 'SUCCEEDED')"
class="update-section"
>
<div class="section-heading">
<h3>{{ status.message }}</h3>
<div class="heading-title">
<el-icon v-if="status.phase === 'FAILED' || status.phase === 'ROLLED_BACK'"><Warning /></el-icon>
<el-icon v-else><Check /></el-icon>
<h2>更新进度</h2>
</div>
<span>{{ status.progress }}%</span>
</div>
<el-progress
:percentage="status.progress"
:status="status.phase === 'FAILED' || status.phase === 'ROLLED_BACK' ? 'exception' : undefined"
:status="progressStatus"
:stroke-width="10"
/>
<p class="progress-message">{{ status.message }}</p>
<p v-if="status.error" class="error-line"><el-icon><Warning /></el-icon>{{ status.error }}</p>
</section>
<section class="release-section">
<section class="update-section">
<div class="section-heading">
<h3>版本信息</h3>
<span>{{ formatDate(status?.publishedAt || null) }}</span>
<div class="heading-title"><h2>最新版本更新日志</h2></div>
<span>发布日期 {{ formatDate(latestPublishedAt) }}</span>
</div>
<div class="release-notes">{{ status?.releaseNotes || '暂无发布说明' }}</div>
<div class="release-notes">{{ latestReleaseNotes || '暂无发布说明' }}</div>
</section>
<section class="release-section">
<div class="section-heading"><h3>发布文件</h3></div>
<el-table :data="status?.assets || []" size="small" border empty-text="暂无发布文件">
<el-table-column prop="name" label="文件" min-width="280" />
<el-table-column label="大小" width="120">
<template #default="{ row }">{{ formatBytes(row.size) }}</template>
</el-table-column>
<el-table-column label="校验" width="140">
<template #default="{ row }">
<el-tag v-if="row.name === 'SHA256SUMS.sig'" type="success" effect="plain" size="small">Ed25519</el-tag>
<el-tag v-else-if="row.name === 'SHA256SUMS'" type="info" effect="plain" size="small">SHA-256</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
</el-table>
<section class="update-section history-section">
<div class="section-heading">
<div class="heading-title"><h2>历史版本</h2></div>
<span>{{ releases.length ? `${releases.length} 个正式版本` : '暂无记录' }}</span>
</div>
<el-skeleton v-if="historyLoading" :rows="4" animated />
<div v-else-if="releases.length" class="release-history">
<article v-for="release in releases" :key="release.version" class="release-entry">
<div class="release-version">
<strong>{{ formatVersion(release.version) }}</strong>
<div class="release-tags">
<el-tag v-if="release.current" type="primary" effect="plain" size="small">当前版本</el-tag>
<el-tag v-if="release.latest" type="success" effect="plain" size="small">最新版本</el-tag>
</div>
<time>{{ formatDate(release.publishedAt) }}</time>
</div>
<div class="release-notes release-notes--history">
{{ release.releaseNotes || '暂无发布说明' }}
</div>
</article>
</div>
<el-empty v-else description="暂无历史版本" :image-size="72" />
</section>
</div>
</template>
@@ -220,6 +364,10 @@ onBeforeUnmount(stopPolling)
margin: 0 auto;
}
.update-alert {
margin-bottom: var(--erp-space-4);
}
.update-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -238,8 +386,11 @@ onBeforeUnmount(stopPolling)
}
.version-block strong,
.version-block > span:last-child {
.version-block > span:last-child,
.version-block > .el-tag {
display: block;
width: fit-content;
max-width: 100%;
margin-top: var(--erp-space-2);
overflow-wrap: anywhere;
color: var(--erp-color-text);
@@ -251,40 +402,55 @@ onBeforeUnmount(stopPolling)
font-size: var(--erp-font-size-xs);
}
.update-progress,
.release-section {
.update-section {
padding: var(--erp-space-5) 0;
border-bottom: 1px solid var(--erp-color-border-soft);
}
.section-heading {
.section-heading,
.heading-title,
.release-tags {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--erp-space-3);
margin-bottom: var(--erp-space-3);
}
.section-heading h3 {
margin: 0;
.section-heading {
justify-content: space-between;
gap: var(--erp-space-3);
margin-bottom: var(--erp-space-4);
}
.heading-title {
gap: var(--erp-space-2);
min-width: 0;
color: var(--erp-color-text);
}
.heading-title h2 {
margin: 0;
font-size: var(--erp-font-size-base);
}
.section-heading span {
.section-heading > span {
color: var(--erp-color-text-subtle);
font-size: var(--erp-font-size-xs);
}
.release-notes {
min-height: 80px;
min-height: 72px;
color: var(--erp-color-text-muted);
font-size: var(--erp-font-size-sm);
line-height: 1.7;
line-height: 1.75;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.progress-message {
margin: var(--erp-space-2) 0 0;
color: var(--erp-color-text-muted);
font-size: var(--erp-font-size-sm);
}
.error-line {
display: flex;
align-items: center;
@@ -294,6 +460,49 @@ onBeforeUnmount(stopPolling)
font-size: var(--erp-font-size-sm);
}
.release-history {
border-top: 1px solid var(--erp-color-border-soft);
}
.release-entry {
display: grid;
grid-template-columns: 190px minmax(0, 1fr);
gap: var(--erp-space-5);
padding: var(--erp-space-4) 0;
border-bottom: 1px solid var(--erp-color-border-soft);
}
.release-entry:last-child {
border-bottom: 0;
}
.release-version {
min-width: 0;
}
.release-version > strong {
display: block;
color: var(--erp-color-text);
font-size: var(--erp-font-size-base);
}
.release-tags {
flex-wrap: wrap;
gap: var(--erp-space-1);
margin-top: var(--erp-space-2);
}
.release-version time {
display: block;
margin-top: var(--erp-space-2);
color: var(--erp-color-text-subtle);
font-size: var(--erp-font-size-xs);
}
.release-notes--history {
min-height: 0;
}
@media (max-width: 900px) {
.update-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -322,5 +531,15 @@ onBeforeUnmount(stopPolling)
.version-block:last-child {
border-bottom: 0;
}
.section-heading {
align-items: flex-start;
flex-wrap: wrap;
}
.release-entry {
grid-template-columns: 1fr;
gap: var(--erp-space-3);
}
}
</style>
Executable
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -Eeuo pipefail
TARGET=/root/kaidi-erp-reinstall-v031.sh
URL=http://38.76.196.225:10099/awaioi/ERP/raw/tag/recovery-v0.3.1-1/reinstall-centos9.sh
SHA256=0370fb17926f3b53d5ae849310fdc6cbbf019150415365a464b0972b8edf5d9a
curl -fsSL --connect-timeout 15 --max-time 300 "$URL" -o "$TARGET"
printf '%s %s\n' "$SHA256" "$TARGET" | sha256sum -c -
exec bash "$TARGET"
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env bash
set -Eeuo pipefail
GITEA_BASE_URL="http://38.76.196.225:10099"
REPOSITORY="awaioi/ERP"
VERSION="0.3.4"
TAG="v${VERSION}"
INSTALL_SHA256="89a3c45e76f500c9475cb596ea29e3518bfafdc59f36dd3c7b316ed3cdd0448c"
UNINSTALL_SHA256="98c56fed2fd4d01874e4ab5a1a4f3ec42ec3e29b315ffd87385a95488d587546"
PG_ROOT="${ERP_RECOVERY_PG_ROOT:-/www/server/pgsql}"
PG_DATA="${ERP_RECOVERY_PG_DATA:-/www/server/pgsql/data}"
PG_INIT_SCRIPT="${ERP_RECOVERY_PG_INIT_SCRIPT:-/etc/init.d/pgsql}"
TMP_DIR=""
say() { printf '[ERP Recovery] %s\n' "$*"; }
fail() { printf '[ERP Recovery] ERROR: %s\n' "$*" >&2; exit 1; }
cleanup() {
[[ -z "$TMP_DIR" || ! -d "$TMP_DIR" ]] || rm -rf -- "$TMP_DIR"
}
trap cleanup EXIT
postgres_port_open() {
timeout 2 bash -c 'exec 3<>/dev/tcp/127.0.0.1/5432' >/dev/null 2>&1
}
wait_for_postgres() {
local _
for _ in {1..60}; do
postgres_port_open && return 0
sleep 1
done
return 1
}
start_erp_postgres() {
if postgres_port_open; then
say 'PostgreSQL is already listening on 127.0.0.1:5432'
return 0
fi
[[ -f "$PG_DATA/PG_VERSION" ]] \
|| fail "PostgreSQL data directory is missing: $PG_DATA"
if [[ -x "$PG_INIT_SCRIPT" ]]; then
say "Starting PostgreSQL with $PG_INIT_SCRIPT"
if ! "$PG_INIT_SCRIPT" start; then
say 'The init script returned an error; checking whether PostgreSQL still started'
fi
else
local pg_ctl pg_user
pg_ctl="$(find "$PG_ROOT" -type f -name pg_ctl -print -quit 2>/dev/null || true)"
[[ -n "$pg_ctl" && -x "$pg_ctl" ]] || fail "pg_ctl was not found below $PG_ROOT"
pg_user="$(stat -c '%U' "$PG_DATA")" || fail 'Unable to determine the PostgreSQL owner'
id "$pg_user" >/dev/null 2>&1 || fail "PostgreSQL owner does not exist: $pg_user"
say "Starting PostgreSQL as $pg_user"
runuser -u "$pg_user" -- "$pg_ctl" \
-D "$PG_DATA" \
-l "$PG_DATA/startup.log" \
-w -t 60 start
fi
if ! wait_for_postgres; then
[[ ! -r "$PG_DATA/startup.log" ]] || tail -n 100 "$PG_DATA/startup.log" >&2
fail 'PostgreSQL did not listen on 127.0.0.1:5432 within 60 seconds'
fi
say 'PostgreSQL is ready on 127.0.0.1:5432'
}
download_verified_script() {
local name="$1" expected="$2" destination
destination="$TMP_DIR/$name"
curl -fsSL --connect-timeout 15 --max-time 300 \
"$GITEA_BASE_URL/$REPOSITORY/raw/tag/$TAG/$name" \
-o "$destination"
printf '%s %s\n' "$expected" "$destination" | sha256sum -c - >&2
printf '%s' "$destination"
}
main() {
[[ "$(id -u)" == "0" ]] || fail 'Run this recovery command as root'
for command in bash curl find runuser sha256sum stat timeout; do
command -v "$command" >/dev/null 2>&1 || fail "Missing command: $command"
done
start_erp_postgres
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/kaidi-erp-recovery.XXXXXX")"
local uninstaller installer
uninstaller="$(download_verified_script uninstall.sh "$UNINSTALL_SHA256")"
say 'Removing the pending installation and its PostgreSQL schema safely'
bash "$uninstaller" --purge-database --yes
installer="$(download_verified_script install.sh "$INSTALL_SHA256")"
say "Installing Kaidi ERP $VERSION"
bash "$installer" \
--gitea-url "$GITEA_BASE_URL" \
--repository "$REPOSITORY" \
--version "$VERSION" \
--allow-insecure
}
if [[ "${BASH_SOURCE[0]:-$0}" == "$0" ]]; then
main "$@"
fi
+19 -5
View File
@@ -39,11 +39,13 @@ fi
(
cd "$BACKEND_DIR"
./gradlew clean bootJar -PreleaseVersion="$VERSION" -PproductionBuild=true
./gradlew clean bootJar installerBootJar -PreleaseVersion="$VERSION" -PproductionBuild=true
)
JAR="$BACKEND_DIR/build/libs/oa-backend-${VERSION}.jar"
INSTALLER_JAR="$BACKEND_DIR/build/libs/kaidi-erp-installer-${VERSION}.jar"
[[ -r "$JAR" ]] || { printf 'bootJar not found: %s\n' "$JAR" >&2; exit 1; }
[[ -r "$INSTALLER_JAR" ]] || { printf 'installerBootJar not found: %s\n' "$INSTALLER_JAR" >&2; exit 1; }
JAR_ENTRIES="$(jar tf "$JAR")"
grep -Eq '^BOOT-INF/lib/postgresql-[^/]+\.jar$' <<< "$JAR_ENTRIES" \
|| { printf 'production JAR is missing the PostgreSQL driver\n' >&2; exit 1; }
@@ -51,6 +53,15 @@ if grep -Eq '^BOOT-INF/lib/(sqlite-jdbc|hibernate-community-dialects)-[^/]+\.jar
printf 'production JAR must not contain SQLite runtime libraries\n' >&2
exit 1
fi
INSTALLER_ENTRIES="$(jar tf "$INSTALLER_JAR")"
grep -Eq '^BOOT-INF/lib/postgresql-[^/]+\.jar$' <<< "$INSTALLER_ENTRIES" \
|| { printf 'installer JAR is missing the PostgreSQL driver\n' >&2; exit 1; }
grep -q '^BOOT-INF/classes/static/index.html$' <<< "$INSTALLER_ENTRIES" \
|| { printf 'installer JAR is missing the web setup page\n' >&2; exit 1; }
if grep -Eq '^BOOT-INF/lib/(sqlite-jdbc|hibernate-community-dialects)-[^/]+\.jar$' <<< "$INSTALLER_ENTRIES"; then
printf 'installer JAR must not contain SQLite runtime libraries\n' >&2
exit 1
fi
mkdir -p "$DIST_DIR"
STAGE="$(mktemp -d "${TMPDIR:-/tmp}/kaidi-erp-package.XXXXXX")"
@@ -68,12 +79,14 @@ printf '{"version":"%s","database":"postgresql","minimumPostgres":15,"rollbackCo
"$VERSION" > "$PACKAGE_ROOT/manifest.json"
ARCHIVE="kaidi-erp-${VERSION}.tar.gz"
INSTALLER_ASSET="kaidi-erp-installer-${VERSION}.jar"
tar -czf "$DIST_DIR/$ARCHIVE" -C "$STAGE" "kaidi-erp-${VERSION}"
cp "$INSTALLER_JAR" "$DIST_DIR/$INSTALLER_ASSET"
if command -v sha256sum >/dev/null 2>&1; then
(cd "$DIST_DIR" && sha256sum "$ARCHIVE" > SHA256SUMS)
(cd "$DIST_DIR" && sha256sum "$ARCHIVE" "$INSTALLER_ASSET" > SHA256SUMS)
else
(cd "$DIST_DIR" && shasum -a 256 "$ARCHIVE" > SHA256SUMS)
(cd "$DIST_DIR" && shasum -a 256 "$ARCHIVE" "$INSTALLER_ASSET" > SHA256SUMS)
fi
if [[ -n "$PRIVATE_KEY" && -r "$PRIVATE_KEY" ]]; then
@@ -90,5 +103,6 @@ else
: > "$DIST_DIR/SHA256SUMS.sig"
fi
printf 'release assets:\n %s\n %s\n %s\n' \
"$DIST_DIR/$ARCHIVE" "$DIST_DIR/SHA256SUMS" "$DIST_DIR/SHA256SUMS.sig"
printf 'release assets:\n %s\n %s\n %s\n %s\n' \
"$DIST_DIR/$ARCHIVE" "$DIST_DIR/$INSTALLER_ASSET" \
"$DIST_DIR/SHA256SUMS" "$DIST_DIR/SHA256SUMS.sig"
+164
View File
@@ -0,0 +1,164 @@
#!/usr/bin/env bash
set -Eeuo pipefail
GITEA_URL="${GITEA_URL:-http://38.76.196.225:10099/}"
GITEA_REPOSITORY="${GITEA_REPOSITORY:-awaioi/ERP}"
RUNNER_IMAGE="${RUNNER_IMAGE:-}"
RUNNER_CONTAINER="${RUNNER_CONTAINER:-gitea-runner}"
RUNNER_VOLUME="${RUNNER_VOLUME:-gitea-runner-data}"
RUNNER_LABELS="${RUNNER_LABELS:-ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest}"
log() {
printf '[Gitea Runner] %s\n' "$*"
}
fail() {
printf '[Gitea Runner] ERROR: %s\n' "$*" >&2
exit 1
}
command -v docker >/dev/null 2>&1 || fail "docker is not installed"
docker info >/dev/null 2>&1 || fail "docker is not running or the current user cannot access it"
find_gitea_container() {
local container_id image name
while read -r container_id image name; do
case "$image" in
gitea/gitea:* | gitea/gitea@* | */gitea/gitea:* | */gitea/gitea@* | docker.gitea.com/gitea:* | docker.gitea.com/gitea@*)
printf '%s\n' "$container_id"
return 0
;;
esac
done < <(docker ps --format '{{.ID}} {{.Image}} {{.Names}}')
while read -r container_id name; do
case "$name" in
*runner*) ;;
*gitea*)
printf '%s\n' "$container_id"
return 0
;;
esac
done < <(docker ps --format '{{.ID}} {{.Names}}')
return 1
}
pull_runner_image() {
local candidate
local -a candidates
if [[ -n "$RUNNER_IMAGE" ]]; then
candidates=("$RUNNER_IMAGE")
else
candidates=(
"docker.gitea.com/runner:3.0.2"
"docker.gitea.com/act_runner:3.0.2"
"gitea/runner:3.0.2"
)
fi
for candidate in "${candidates[@]}"; do
log "Pulling $candidate"
if docker pull "$candidate"; then
RUNNER_IMAGE="$candidate"
return 0
fi
log "Image source failed; trying the next source"
done
fail "all official Gitea Runner image sources failed"
}
GITEA_CONTAINER="$(find_gitea_container || true)"
[[ -n "$GITEA_CONTAINER" ]] || fail "no running Gitea container was found"
TMP_DIR="$(mktemp -d)"
TOKEN_FILE="$TMP_DIR/runner-token"
BOOTSTRAP_RUNNING=0
cleanup() {
if [[ "$BOOTSTRAP_RUNNING" == "1" ]]; then
docker rm -f "$RUNNER_CONTAINER" >/dev/null 2>&1 || true
fi
if [[ -f "$TOKEN_FILE" ]]; then
: >"$TOKEN_FILE"
fi
rm -rf -- "$TMP_DIR"
}
trap cleanup EXIT
log "Generating a repository-scoped registration token"
if ! docker exec -u git "$GITEA_CONTAINER" gitea actions generate-runner-token \
--scope "$GITEA_REPOSITORY" | tr -d '\r' | tail -n 1 >"$TOKEN_FILE"; then
fail "Gitea could not generate a runner token"
fi
[[ -s "$TOKEN_FILE" ]] || fail "Gitea returned an empty runner token"
chmod 600 "$TOKEN_FILE"
pull_runner_image
docker rm -f "$RUNNER_CONTAINER" >/dev/null 2>&1 || true
docker volume create "$RUNNER_VOLUME" >/dev/null
docker run --rm --entrypoint /bin/rm \
-v "$RUNNER_VOLUME:/data" \
"$RUNNER_IMAGE" -f /data/.runner
log "Registering the runner"
docker run -d \
--name "$RUNNER_CONTAINER" \
-v "$RUNNER_VOLUME:/data" \
-v "$TOKEN_FILE:/run/secrets/gitea_runner_token:ro,Z" \
-v /var/run/docker.sock:/var/run/docker.sock \
-e "GITEA_INSTANCE_URL=$GITEA_URL" \
-e GITEA_RUNNER_REGISTRATION_TOKEN_FILE=/run/secrets/gitea_runner_token \
-e "GITEA_RUNNER_NAME=erp-release-$(hostname -s)" \
-e "GITEA_RUNNER_LABELS=$RUNNER_LABELS" \
-e GITEA_MAX_REG_ATTEMPTS=24 \
"$RUNNER_IMAGE" >/dev/null
BOOTSTRAP_RUNNING=1
READY=0
for ((attempt = 1; attempt <= 60; attempt++)); do
if docker exec "$RUNNER_CONTAINER" test -s /data/.runner 2>/dev/null; then
READY=1
break
fi
if [[ "$(docker inspect -f '{{.State.Running}}' "$RUNNER_CONTAINER" 2>/dev/null || true)" != "true" ]]; then
break
fi
sleep 2
done
if [[ "$READY" != "1" ]]; then
docker logs --tail 100 "$RUNNER_CONTAINER" >&2 || true
fail "runner registration failed"
fi
docker logs --tail 30 "$RUNNER_CONTAINER" || true
docker rm -f "$RUNNER_CONTAINER" >/dev/null
BOOTSTRAP_RUNNING=0
: >"$TOKEN_FILE"
log "Starting the persistent runner without the registration token"
docker run -d \
--name "$RUNNER_CONTAINER" \
--restart unless-stopped \
-v "$RUNNER_VOLUME:/data" \
-v /var/run/docker.sock:/var/run/docker.sock \
-e "GITEA_INSTANCE_URL=$GITEA_URL" \
-e "GITEA_RUNNER_NAME=erp-release-$(hostname -s)" \
-e "GITEA_RUNNER_LABELS=$RUNNER_LABELS" \
"$RUNNER_IMAGE" >/dev/null
sleep 5
[[ "$(docker inspect -f '{{.State.Running}}' "$RUNNER_CONTAINER")" == "true" ]] || {
docker logs --tail 100 "$RUNNER_CONTAINER" >&2 || true
fail "runner container stopped after registration"
}
log "Runner is online; queued release jobs can now continue"
docker ps --filter "name=^/${RUNNER_CONTAINER}$" \
--format 'table {{.Names}}\t{{.Image}}\t{{.Status}}'
docker logs --tail 80 "$RUNNER_CONTAINER" || true
+328 -39
View File
@@ -30,8 +30,9 @@ test_erp_run_preserves_java_option_arguments() (
local tmp
tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-run-test.XXXXXX")" || return 1
trap 'rm -rf "$tmp"' EXIT
mkdir -p "$tmp/current/app" "$tmp/run"
mkdir -p "$tmp/current/app" "$tmp/run" "$tmp/state"
: > "$tmp/current/app/kaidi-erp.jar"
: > "$tmp/state/install.lock"
: > "$tmp/-Dpattern=expanded"
printf '%s\n' \
'#!/usr/bin/env bash' \
@@ -55,6 +56,83 @@ test_erp_run_preserves_java_option_arguments() (
grep -Fqx -- "$tmp/current/app/kaidi-erp.jar" <<< "$output"
)
test_erp_run_finalizes_healthy_pending_install() (
local tmp
tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-handoff-success.XXXXXX")" || return 1
trap 'rm -rf "$tmp"' EXIT
mkdir -p "$tmp/current/app" "$tmp/run" "$tmp/state" "$tmp/installer" "$tmp/bin"
: > "$tmp/current/app/kaidi-erp.jar"
: > "$tmp/installer/kaidi-erp-installer.jar"
printf 'pending\n' > "$tmp/state/install.pending"
printf '%s\n' \
'#!/usr/bin/env bash' \
'if [[ "${1:-}" == "-version" ]]; then printf '\''openjdk version "17.0.12"\n'\'' >&2; exit 0; fi' \
'printf '\''formal application output\n'\''' \
'sleep 0.1' \
'exit 0' > "$tmp/java"
printf '%s\n' '#!/usr/bin/env bash' 'exit 0' > "$tmp/bin/curl"
chmod +x "$tmp/java" "$tmp/bin/curl"
{
printf 'ERP_JAVA_BIN=%q\n' "$tmp/java"
printf 'ERP_RUN_DIR=%q\n' "$tmp/run"
printf 'ERP_JAR_PATH=%q\n' "$tmp/current/app/kaidi-erp.jar"
printf 'ERP_INSTALLER_JAR=%q\n' "$tmp/installer/kaidi-erp-installer.jar"
printf 'ERP_INSTALL_PENDING_FILE=%q\n' "$tmp/state/install.pending"
printf 'ERP_INSTALL_LOCK_FILE=%q\n' "$tmp/state/install.lock"
printf 'ERP_INSTALL_LOG_FILE=%q\n' "$tmp/state/install-formal.log"
printf 'ERP_INSTALL_HEALTH_TIMEOUT_SECONDS=3\n'
printf 'ERP_UPDATE_HEALTH_POLL_SECONDS=1\n'
} > "$tmp/erp.env"
PATH="$tmp/bin:$PATH" ERP_INSTALL_ROOT="$tmp" ERP_CONFIG_FILE="$tmp/erp.env" \
"$PROJECT_ROOT/distribution/bin/erp-run" > "$tmp/run.log" 2>&1 || return 1
[[ -f "$tmp/state/install.lock" ]] || return 1
[[ ! -e "$tmp/state/install.pending" ]] || return 1
[[ ! -d "$tmp/installer" ]] || return 1
[[ ! -e "$tmp/run/app.pid" ]] || return 1
grep -Fq 'formal application output' "$tmp/state/install-formal.log"
)
test_erp_run_preserves_failed_pending_install() (
local tmp output status=0
tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-handoff-failure.XXXXXX")" || return 1
trap 'rm -rf "$tmp"' EXIT
mkdir -p "$tmp/current/app" "$tmp/run" "$tmp/state" "$tmp/installer" "$tmp/bin"
: > "$tmp/current/app/kaidi-erp.jar"
: > "$tmp/installer/kaidi-erp-installer.jar"
printf 'pending\n' > "$tmp/state/install.pending"
printf '%s\n' \
'#!/usr/bin/env bash' \
'if [[ "${1:-}" == "-version" ]]; then printf '\''openjdk version "17.0.12"\n'\'' >&2; exit 0; fi' \
'printf '\''formal application exploded\n'\'' >&2' \
'sleep 0.1' \
'exit 23' > "$tmp/java"
printf '%s\n' '#!/usr/bin/env bash' 'exit 1' > "$tmp/bin/curl"
chmod +x "$tmp/java" "$tmp/bin/curl"
{
printf 'ERP_JAVA_BIN=%q\n' "$tmp/java"
printf 'ERP_RUN_DIR=%q\n' "$tmp/run"
printf 'ERP_JAR_PATH=%q\n' "$tmp/current/app/kaidi-erp.jar"
printf 'ERP_INSTALLER_JAR=%q\n' "$tmp/installer/kaidi-erp-installer.jar"
printf 'ERP_INSTALL_PENDING_FILE=%q\n' "$tmp/state/install.pending"
printf 'ERP_INSTALL_LOCK_FILE=%q\n' "$tmp/state/install.lock"
printf 'ERP_INSTALL_LOG_FILE=%q\n' "$tmp/state/install-formal.log"
printf 'ERP_INSTALL_HEALTH_TIMEOUT_SECONDS=3\n'
printf 'ERP_UPDATE_HEALTH_POLL_SECONDS=1\n'
} > "$tmp/erp.env"
output="$(PATH="$tmp/bin:$PATH" ERP_INSTALL_ROOT="$tmp" ERP_CONFIG_FILE="$tmp/erp.env" \
"$PROJECT_ROOT/distribution/bin/erp-run" 2>&1)" || status=$?
[[ "$status" -ne 0 ]] || return 1
[[ -f "$tmp/state/install.pending" ]] || return 1
[[ ! -e "$tmp/state/install.lock" ]] || return 1
[[ -d "$tmp/installer" ]] || return 1
grep -Fq 'formal application exploded' "$tmp/state/install-formal.log" || return 1
[[ "$output" == *"Formal application diagnostics: $tmp/state/install-formal.log"* ]]
)
prepare_signed_archive() {
local tmp="$1" unsafe="${2:-0}"
mkdir -p "$tmp/package/kaidi-erp-1.2.3/app"
@@ -63,7 +141,8 @@ prepare_signed_archive() {
ln -s /etc/passwd "$tmp/package/kaidi-erp-1.2.3/unsafe-link"
fi
tar -czf "$tmp/kaidi-erp-1.2.3.tar.gz" -C "$tmp/package" kaidi-erp-1.2.3
(cd "$tmp" && checksum_file kaidi-erp-1.2.3.tar.gz) > "$tmp/SHA256SUMS"
printf 'installer jar\n' > "$tmp/kaidi-erp-installer-1.2.3.jar"
(cd "$tmp" && checksum_file kaidi-erp-1.2.3.tar.gz && checksum_file kaidi-erp-installer-1.2.3.jar) > "$tmp/SHA256SUMS"
openssl pkeyutl -sign -rawin -inkey "$tmp/private.pem" \
-in "$tmp/SHA256SUMS" -out "$tmp/SHA256SUMS.sig"
}
@@ -82,6 +161,8 @@ test_installer_verifies_signed_safe_archive() (
PUBLIC_KEY="$(<"$tmp/public.pem")"
ARCHIVE_NAME=kaidi-erp-1.2.3.tar.gz
ARCHIVE_PATH="$tmp/$ARCHIVE_NAME"
INSTALLER_NAME=kaidi-erp-installer-1.2.3.jar
INSTALLER_PATH="$tmp/$INSTALLER_NAME"
verify_release
)
@@ -99,6 +180,8 @@ test_installer_rejects_archive_symlinks() (
PUBLIC_KEY="$(<"$tmp/public.pem")"
ARCHIVE_NAME=kaidi-erp-1.2.3.tar.gz
ARCHIVE_PATH="$tmp/$ARCHIVE_NAME"
INSTALLER_NAME=kaidi-erp-installer-1.2.3.jar
INSTALLER_PATH="$tmp/$INSTALLER_NAME"
! (verify_release) >/dev/null 2>&1
)
@@ -119,6 +202,44 @@ test_stable_channel_rejects_prerelease_tag() (
! select_release "$tmp/release.json" '' "$tmp/selection" >/dev/null 2>&1
)
test_update_helper_preserves_release_metadata() (
local tmp
tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-update-metadata.XXXXXX")" || return 1
trap 'rm -rf "$tmp"' EXIT
mkdir -p "$tmp/state"
printf 'OA_UPDATE_GITEA_BASE_URL=https://example.test\nOA_UPDATE_STATE_FILE=%q\n' \
"$tmp/state/update.json" > "$tmp/erp.env"
printf '%s\n' \
'{"phase":"AVAILABLE","releaseNotes":"Important fixes","publishedAt":"2026-08-04T10:00:00Z",' \
'"assets":[{"name":"SHA256SUMS","downloadUrl":"https://example.test/sums","size":64}]}' \
> "$tmp/state/update.json"
ERP_INSTALL_ROOT="$tmp" ERP_CONFIG_FILE="$tmp/erp.env" source "$PROJECT_ROOT/distribution/bin/erp-update"
trap 'rm -rf "$tmp"' EXIT
write_state DOWNLOADING 25 'downloading' 1.2.3
python3 - "$tmp/state/update.json" <<'PY'
import json, sys
with open(sys.argv[1], encoding="utf-8") as handle:
state = json.load(handle)
assert state["phase"] == "DOWNLOADING"
assert state["releaseNotes"] == "Important fixes"
assert state["publishedAt"] == "2026-08-04T10:00:00Z"
assert state["assets"][0]["name"] == "SHA256SUMS"
PY
)
test_installer_builds_public_setup_urls() (
source "$PROJECT_ROOT/install.sh"
local normalized setup ipv6
normalized="$(normalize_public_url 'https://erp.example.com:8443/setup?tenant=kaidi#start')" || return 1
setup="$(append_setup_token "$normalized" 'one-time-token')" || return 1
ipv6="$(ip_setup_base_url '2001:db8::8' 8091)" || return 1
[[ "$setup" == 'https://erp.example.com:8443/setup?tenant=kaidi&token=one-time-token#start' ]] || return 1
[[ "$ipv6" == 'http://[2001:db8::8]:8091/' ]]
)
prepare_update_release_archive() {
local tmp="$1" version="$2"
local root="$tmp/package/kaidi-erp-$version"
@@ -130,10 +251,11 @@ prepare_update_release_archive() {
printf '%s\n' "$version" > "$root/VERSION"
printf '{"version":"%s","database":"postgresql","rollbackCompatible":true}\n' \
"$version" > "$root/manifest.json"
printf 'installer jar %s\n' "$version" > "$tmp/assets/kaidi-erp-installer-$version.jar"
local archive="kaidi-erp-$version.tar.gz"
tar -czf "$tmp/assets/$archive" -C "$tmp/package" "kaidi-erp-$version"
(cd "$tmp/assets" && checksum_file "$archive") > "$tmp/assets/SHA256SUMS"
(cd "$tmp/assets" && checksum_file "$archive" && checksum_file "kaidi-erp-installer-$version.jar") > "$tmp/assets/SHA256SUMS"
: > "$tmp/assets/SHA256SUMS.sig"
}
@@ -180,6 +302,7 @@ class Handler(BaseHTTPRequestHandler):
"prerelease": False,
"assets": [
{"name": archive, "browser_download_url": base + "/" + archive},
{"name": f"kaidi-erp-installer-{version}.jar", "browser_download_url": base + f"/kaidi-erp-installer-{version}.jar"},
{"name": "SHA256SUMS", "browser_download_url": base + "/SHA256SUMS"},
{"name": "SHA256SUMS.sig", "browser_download_url": base + "/SHA256SUMS.sig"},
],
@@ -188,7 +311,7 @@ class Handler(BaseHTTPRequestHandler):
return
if self.path.startswith("/assets/"):
name = self.path.removeprefix("/assets/")
if name not in {archive, "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")
return
with open(os.path.join(assets, name), "rb") as handle:
@@ -224,23 +347,14 @@ PY
ERP_CONFIG_ROOT="$tmp/config"
ERP_STATE_ROOT="$tmp/state"
ERP_LOG_ROOT="$tmp/log"
DB_MODE=existing
NO_SERVICE=0
PUBLIC_KEY="$(<"$tmp/public.pem")"
detect_platform() { PLATFORM=darwin; }
install_dependencies() { return 0; }
configure_existing_postgres() {
DB_HOST=127.0.0.1
DB_PORT=5432
DB_NAME=kaidi_erp
DB_USER=kaidi_erp
DB_PASSWORD=test-password
DB_SSLMODE=require
}
validate_postgres() { return 0; }
install_service() { return 0; }
wait_for_health() { return 0; }
detect_platform() { PLATFORM=darwin; ARCH=amd64; }
check_and_install_dependencies() { JAVA_BIN="$(command -v java)"; return 0; }
start_service() { return 0; }
wait_for_installer() { return 0; }
detect_public_address() { printf '203.0.113.10'; }
main > "$tmp/install.log" 2>&1 || return 1
@@ -248,12 +362,14 @@ PY
[[ -r "$INSTALL_ROOT/current/app/kaidi-erp.jar" ]] || return 1
(
source "$ERP_CONFIG_ROOT/erp.env"
[[ "$SPRING_PROFILES_ACTIVE" == "postgres" ]]
[[ -z "${SPRING_PROFILES_ACTIVE:-}" ]]
[[ "$OA_UPDATE_ENABLED" == "true" ]]
[[ "$OA_UPDATE_GITEA_BASE_URL" == "http://127.0.0.1:$port" ]]
[[ "$OA_DB_URL" == "jdbc:postgresql://127.0.0.1:5432/kaidi_erp?sslmode=require" ]]
[[ "$ERP_PGSSLMODE" == "require" ]]
)
[[ "$ERP_SETUP_TOKEN" =~ ^[0-9a-f]{64}$ ]]
[[ -z "${OA_DB_URL:-}" ]]
) || return 1
grep -Eq '^\[ERP Install\] Setup URL: http://203\.0\.113\.10:8091/\?token=[0-9a-f]{64}$' \
"$tmp/install.log"
)
run_update_scenario() (
@@ -440,16 +556,10 @@ test_update_lock_rejects_concurrent_process() (
[[ "$rejected" == "1" ]]
)
test_noninteractive_installer_reports_missing_password() (
test_installer_moves_database_setup_to_web_wizard() (
local output status=0
output="$(ERP_INSTALL_NON_INTERACTIVE=1 bash -c '
root="$1"
set --
source "$root/install.sh"
unset ERP_DB_PASSWORD DB_PASSWORD
configure_existing_postgres
' _ "$PROJECT_ROOT" 2>&1)" || status=$?
[[ "$status" -ne 0 && "$output" == *'database password is required'* ]]
output="$(bash "$PROJECT_ROOT/install.sh" --db-mode existing 2>&1)" || status=$?
[[ "$status" -ne 0 && "$output" == *'unknown option: --db-mode'* ]]
)
test_installer_requires_explicit_gitea_url() (
@@ -459,11 +569,138 @@ test_installer_requires_explicit_gitea_url() (
set --
source "$root/install.sh"
GITEA_BASE_URL=
NO_SERVICE=1
main
' _ "$PROJECT_ROOT" 2>&1)" || status=$?
[[ "$status" -ne 0 && "$output" == *'Gitea URL is required'* ]]
)
test_linux_service_preflight_requires_systemd() (
local tmp output status=0
tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-systemd-test.XXXXXX")" || return 1
trap 'rm -rf "$tmp"' EXIT
mkdir -p "$tmp/bin"
source "$PROJECT_ROOT/install.sh"
PLATFORM=linux
NO_SERVICE=0
output="$(PATH="$tmp/bin" validate_service_manager 2>&1)" || status=$?
[[ "$status" -ne 0 && "$output" == *'systemd'* ]] || return 1
NO_SERVICE=1 PATH="$tmp/bin" validate_service_manager
)
test_systemd_unit_uses_compatible_protection() (
grep -Fqx 'ProtectSystem=full' <(sed -n '/^ cat > \/etc\/systemd\/system\/kaidi-erp.service/,/^ systemctl daemon-reload/p' "$PROJECT_ROOT/install.sh")
! grep -Fq 'ProtectSystem=strict' "$PROJECT_ROOT/install.sh"
)
test_systemd_unit_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() (
local unit
unit="$(sed -n '/^ cat > \/etc\/systemd\/system\/kaidi-erp.service/,/^ systemctl daemon-reload/p' "$PROJECT_ROOT/install.sh")"
grep -Fqx 'WorkingDirectory=$INSTALL_ROOT' <<< "$unit"
grep -Fqx 'ExecStart=$INSTALL_ROOT/current/bin/erp-run' <<< "$unit"
grep -Fqx 'ReadWritePaths=$INSTALL_ROOT $CONFIG_ROOT $STATE_ROOT $LOG_ROOT' <<< "$unit"
! grep -Fq 'WorkingDirectory="$INSTALL_ROOT"' <<< "$unit"
)
test_uninstaller_purges_database_before_removing_files() (
local tmp
tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-uninstall-test.XXXXXX")" || return 1
trap 'rm -rf "$tmp"' EXIT
mkdir -p "$tmp/bin" "$tmp/install" "$tmp/config" "$tmp/state" "$tmp/logs"
: > "$tmp/unit.service"
{
printf 'ERP_PGHOST=127.0.0.1\n'
printf 'ERP_PGPORT=5432\n'
printf 'ERP_PGDATABASE=kaidi_test\n'
printf 'ERP_PGSSLMODE=disable\n'
printf 'OA_DB_USERNAME=kaidi_test\n'
printf 'OA_DB_PASSWORD='\''test-password'\''\n'
printf 'ERP_INSTALL_ROOT=%q\n' "$tmp/install"
printf 'ERP_INSTALL_LOCK_FILE=%q\n' "$tmp/state/install.lock"
} > "$tmp/config/erp.env"
printf '%s\n' \
'#!/usr/bin/env bash' \
'printf '\''%s\n'\'' "$*" >> "$MOCK_SYSTEMCTL_LOG"' \
'if [[ "${1:-}" == "is-active" ]]; then exit 3; fi' \
'exit 0' > "$tmp/bin/systemctl"
printf '%s\n' \
'#!/usr/bin/env bash' \
'printf '\''ARGS %s\n'\'' "$*" >> "$MOCK_PSQL_LOG"' \
'if [[ " $* " == *" -c "* ]]; then printf '\''t\n'\''; exit 0; fi' \
'cat >> "$MOCK_PSQL_LOG"' \
'exit 0' > "$tmp/bin/psql"
chmod +x "$tmp/bin/systemctl" "$tmp/bin/psql"
PATH="$tmp/bin:$PATH" \
MOCK_SYSTEMCTL_LOG="$tmp/systemctl.log" \
MOCK_PSQL_LOG="$tmp/psql.log" \
ERP_UNINSTALL_TEST_MODE=1 \
ERP_UNINSTALL_SYSTEMD_UNIT_FILE="$tmp/unit.service" \
"$PROJECT_ROOT/uninstall.sh" --purge-database --yes \
--config-file "$tmp/config/erp.env" \
--install-root "$tmp/install" \
--config-root "$tmp/config" \
--state-root "$tmp/state" \
--log-root "$tmp/logs" > "$tmp/uninstall.log" 2>&1 || return 1
[[ ! -e "$tmp/install" && ! -e "$tmp/config" && ! -e "$tmp/state" && ! -e "$tmp/logs" ]] || return 1
[[ ! -e "$tmp/unit.service" ]] || return 1
grep -Fq 'DROP SCHEMA IF EXISTS public CASCADE;' "$tmp/psql.log" || return 1
grep -Fq 'stop kaidi-erp.service' "$tmp/systemctl.log" || return 1
grep -Fq 'disable kaidi-erp.service' "$tmp/systemctl.log"
)
test_uninstaller_rejects_unsafe_paths() (
! (source "$PROJECT_ROOT/uninstall.sh"; validate_remove_path /etc) >/dev/null 2>&1 || return 1
! (source "$PROJECT_ROOT/uninstall.sh"; validate_remove_path /etc/ssh) >/dev/null 2>&1 || return 1
! (source "$PROJECT_ROOT/uninstall.sh"; validate_remove_path /opt/kaidi-erp/../../etc) >/dev/null 2>&1
)
test_uninstaller_aborts_when_service_remains_active() (
local tmp output status=0
tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-uninstall-active.XXXXXX")" || return 1
trap 'rm -rf "$tmp"' EXIT
mkdir -p "$tmp/bin" "$tmp/install" "$tmp/config" "$tmp/state" "$tmp/logs"
{
printf 'ERP_PGHOST=127.0.0.1\n'
printf 'ERP_PGPORT=5432\n'
printf 'ERP_PGDATABASE=kaidi_test\n'
printf 'ERP_PGSSLMODE=disable\n'
printf 'OA_DB_USERNAME=kaidi_test\n'
printf 'OA_DB_PASSWORD=test-password\n'
printf 'ERP_INSTALL_ROOT=%q\n' "$tmp/install"
printf 'ERP_INSTALL_LOCK_FILE=%q\n' "$tmp/state/install.lock"
} > "$tmp/config/erp.env"
printf '%s\n' \
'#!/usr/bin/env bash' \
'if [[ "${1:-}" == "stop" ]]; then exit 1; fi' \
'if [[ "${1:-}" == "show" ]]; then printf '\''loaded\n'\''; exit 0; fi' \
'if [[ "${1:-}" == "is-active" ]]; then exit 0; fi' \
'exit 0' > "$tmp/bin/systemctl"
printf '%s\n' '#!/usr/bin/env bash' 'touch "$MOCK_PSQL_CALLED"' 'exit 0' > "$tmp/bin/psql"
chmod +x "$tmp/bin/systemctl" "$tmp/bin/psql"
output="$(PATH="$tmp/bin:$PATH" MOCK_PSQL_CALLED="$tmp/psql.called" \
ERP_UNINSTALL_TEST_MODE=1 ERP_UNINSTALL_SYSTEMD_UNIT_FILE="$tmp/unit.service" \
"$PROJECT_ROOT/uninstall.sh" --purge-database --yes \
--config-file "$tmp/config/erp.env" \
--install-root "$tmp/install" \
--config-root "$tmp/config" \
--state-root "$tmp/state" \
--log-root "$tmp/logs" 2>&1)" || status=$?
[[ "$status" -ne 0 && "$output" == *'unable to stop kaidi-erp.service'* ]] || return 1
[[ ! -e "$tmp/psql.called" ]] || return 1
[[ -d "$tmp/install" && -d "$tmp/config" && -d "$tmp/state" && -d "$tmp/logs" ]]
)
test_no_service_install_disables_online_update() (
local tmp
tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-no-service-test.XXXXXX")" || return 1
@@ -475,30 +712,82 @@ test_no_service_install_disables_online_update() (
INSTALL_ROOT="$tmp/install"
PLATFORM=darwin
NO_SERVICE=1
DB_HOST=127.0.0.1
DB_PORT=5432
DB_NAME=kaidi_erp
DB_USER=kaidi_erp
DB_PASSWORD=test-password
DB_SSLMODE=require
VERSION=1.0.0
JAVA_BIN="$(command -v java)"
GITEA_BASE_URL=https://example.test
REPOSITORY=awaioi/ERP
mkdir -p "$CONFIG_ROOT" "$STATE_ROOT" "$INSTALL_ROOT"
write_configuration || return 1
write_bootstrap_configuration || return 1
grep -Fqx 'OA_UPDATE_ENABLED=false' "$CONFIG_ROOT/erp.env"
)
test_release_workflow_uses_scoped_job_token() (
local workflow="$PROJECT_ROOT/.gitea/workflows/release.yml"
grep -Fqx ' contents: read' "$workflow"
grep -Fqx ' releases: write' "$workflow"
grep -Fq 'GITEA_BASE_URL: ${{ github.server_url }}' "$workflow"
grep -Fq 'GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}' "$workflow"
grep -Fq 'uses: actions/setup-java@v4' "$workflow"
grep -Fq 'java-version: "17"' "$workflow"
grep -Fq "git log --no-merges --format='- %s (\`%h\`)'" "$workflow"
grep -Fq 'release-notes.md' "$workflow"
! grep -Fq 'RELEASE_GITEA_TOKEN' "$workflow"
! grep -Fq 'vars.GITEA_BASE_URL' "$workflow"
)
test_installer_runs_when_piped_to_bash() (
local output status=0
output="$(ERP_GITEA_BASE_URL= bash -s -- --no-service < "$PROJECT_ROOT/install.sh" 2>&1)" || status=$?
[[ "$status" -ne 0 ]]
[[ "$output" == *'[ERP Install] ERROR:'* ]]
[[ "$output" != *'BASH_SOURCE'* ]]
)
test_installer_shows_live_health_progress() (
local page="$PROJECT_ROOT/oa-backend/src/installer/resources/static/index.html"
grep -Fq 'role="progressbar"' "$page"
grep -Fq 'id="install-log"' "$page"
grep -Fq 'Math.min(95, estimated)' "$page"
grep -Fq '健康检查第 ${attempt} 次' "$page"
grep -Fq '本页已等待 ${formatElapsed' "$page"
grep -Fq 'installLocked' "$page"
)
test_app_honors_reverse_proxy_headers() (
grep -Fqx ' forward-headers-strategy: framework' \
"$PROJECT_ROOT/oa-backend/src/main/resources/application.yml"
grep -Fqx ' forward-headers-strategy: framework' \
"$PROJECT_ROOT/oa-backend/src/installer/resources/application.yml"
)
run_test 'erp-run preserves Java option arguments' test_erp_run_preserves_java_option_arguments
run_test 'erp-run finalizes a healthy pending installation' test_erp_run_finalizes_healthy_pending_install
run_test 'erp-run preserves a failed pending installation' test_erp_run_preserves_failed_pending_install
run_test 'installer accepts a correctly signed archive' test_installer_verifies_signed_safe_archive
run_test 'installer rejects symlinks in release archives' test_installer_rejects_archive_symlinks
run_test 'stable update channel rejects prerelease tags' test_stable_channel_rejects_prerelease_tag
run_test 'update helper preserves release metadata' test_update_helper_preserves_release_metadata
run_test 'installer builds public setup URLs safely' test_installer_builds_public_setup_urls
run_test 'installer downloads and installs a signed release end to end' test_installer_downloads_and_installs_signed_release
run_test 'update helper installs a healthy release end to end' test_update_helper_installs_healthy_release
run_test 'update helper rolls back an unhealthy release end to end' test_update_helper_rolls_back_unhealthy_release
run_test 'update helper rejects a concurrent process' test_update_lock_rejects_concurrent_process
run_test 'noninteractive install reports a missing database password' test_noninteractive_installer_reports_missing_password
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 '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 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 'uninstaller purges database before removing files' test_uninstaller_purges_database_before_removing_files
run_test 'uninstaller rejects unsafe paths' test_uninstaller_rejects_unsafe_paths
run_test 'uninstaller aborts while the service remains active' test_uninstaller_aborts_when_service_remains_active
run_test 'no-service install disables online update' test_no_service_install_disables_online_update
run_test 'release workflow uses the scoped Gitea job token' test_release_workflow_uses_scoped_job_token
run_test 'installer starts correctly when piped to bash' test_installer_runs_when_piped_to_bash
run_test 'installer shows live formal-service health progress' test_installer_shows_live_health_progress
run_test 'application honors standard reverse-proxy headers' test_app_honors_reverse_proxy_headers
printf 'RESULT pass=%s fail=%s\n' "$PASS" "$FAIL"
[[ "$FAIL" -eq 0 ]]
Executable
+242
View File
@@ -0,0 +1,242 @@
#!/usr/bin/env bash
set -euo pipefail
CONFIG_FILE="${ERP_CONFIG_FILE:-/etc/kaidi-erp/erp.env}"
INSTALL_ROOT="${ERP_INSTALL_ROOT:-/opt/kaidi-erp}"
CONFIG_ROOT="${ERP_CONFIG_ROOT:-/etc/kaidi-erp}"
STATE_ROOT="${ERP_STATE_ROOT:-/var/lib/kaidi-erp}"
LOG_ROOT="${ERP_LOG_ROOT:-/var/log/kaidi-erp}"
PURGE_DATABASE=0
ASSUME_YES=0
say() { printf '[ERP Uninstall] %s\n' "$*"; }
fail() { printf '[ERP Uninstall] ERROR: %s\n' "$*" >&2; exit 1; }
usage() {
cat <<'EOF'
Usage: uninstall.sh [options]
--purge-database Drop and recreate the public schema using the configured ERP account
--yes Confirm destructive removal without an interactive prompt
--config-file PATH Installer-generated erp.env file
--install-root PATH Installation root (default: /opt/kaidi-erp)
--config-root PATH Configuration root (default: /etc/kaidi-erp)
--state-root PATH State root (default: /var/lib/kaidi-erp)
--log-root PATH Log root (default: /var/log/kaidi-erp)
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--purge-database) PURGE_DATABASE=1; shift ;;
--yes) ASSUME_YES=1; shift ;;
--config-file) [[ $# -ge 2 ]] || fail '--config-file requires a value'; CONFIG_FILE="$2"; shift 2 ;;
--install-root) [[ $# -ge 2 ]] || fail '--install-root requires a value'; INSTALL_ROOT="$2"; shift 2 ;;
--config-root) [[ $# -ge 2 ]] || fail '--config-root requires a value'; CONFIG_ROOT="$2"; shift 2 ;;
--state-root) [[ $# -ge 2 ]] || fail '--state-root requires a value'; STATE_ROOT="$2"; shift 2 ;;
--log-root) [[ $# -ge 2 ]] || fail '--log-root requires a value'; LOG_ROOT="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) fail "unknown option: $1" ;;
esac
done
require_root() {
if [[ "${ERP_UNINSTALL_TEST_MODE:-0}" != "1" && "$(id -u)" != "0" ]]; then
fail 'Linux uninstall requires root'
fi
}
require_confirmation() {
[[ "$PURGE_DATABASE" == "1" ]] || fail '--purge-database is required for a clean reinstall'
[[ "$ASSUME_YES" == "1" ]] || fail '--yes is required to confirm database and file removal'
}
validate_remove_path() {
local path="$1"
[[ "$path" == /* && "$path" != "/" && ${#path} -ge 8 ]] \
|| fail "refusing unsafe removal path: $path"
[[ "$path" =~ ^/[A-Za-z0-9._/@:+-]+$ ]] \
|| fail "refusing removal path with unsafe characters: $path"
local resolved
resolved="$(python3 - "$path" <<'PY'
import os
import sys
print(os.path.realpath(sys.argv[1]), end="")
PY
)" || fail "unable to resolve removal path: $path"
if [[ "${ERP_UNINSTALL_TEST_MODE:-0}" != "1" && "$resolved" != "$path" ]]; then
fail "removal path must be canonical and cannot traverse links: $path"
fi
case "$resolved" in
/bin|/boot|/dev|/etc|/home|/lib|/lib64|/opt|/proc|/root|/run|/sbin|/srv|/sys|/tmp|/usr|/var)
fail "refusing unsafe removal path: $path"
;;
esac
if [[ "${ERP_UNINSTALL_TEST_MODE:-0}" != "1" ]]; then
case "$resolved" in
/etc/kaidi-erp|/var/lib/kaidi-erp|/var/log/kaidi-erp) ;;
/bin/*|/boot/*|/dev/*|/etc/*|/lib/*|/lib64/*|/proc/*|/root/*|/run/*|/sbin/*|/sys/*|/tmp/*|/usr/*|/var/*)
fail "refusing removal below a protected system directory: $path"
;;
esac
fi
printf '%s' "$resolved"
}
prepare_paths() {
command -v python3 >/dev/null 2>&1 || fail 'python3 is required'
INSTALL_ROOT="$(validate_remove_path "$INSTALL_ROOT")"
CONFIG_ROOT="$(validate_remove_path "$CONFIG_ROOT")"
STATE_ROOT="$(validate_remove_path "$STATE_ROOT")"
LOG_ROOT="$(validate_remove_path "$LOG_ROOT")"
local config_resolved
config_resolved="$(python3 - "$CONFIG_FILE" <<'PY'
import os
import sys
print(os.path.realpath(sys.argv[1]), end="")
PY
)" || fail "unable to resolve configuration path: $CONFIG_FILE"
[[ "$config_resolved" == "$CONFIG_ROOT/erp.env" ]] \
|| fail "configuration file must be $CONFIG_ROOT/erp.env"
CONFIG_FILE="$config_resolved"
}
read_config_value() {
local key="$1"
python3 - "$CONFIG_FILE" "$key" <<'PY'
import re
import shlex
import sys
path, wanted = sys.argv[1:]
pattern = re.compile(r"[A-Z][A-Z0-9_]*")
seen = set()
matches = []
with open(path, encoding="utf-8") as handle:
for raw in handle:
line = raw.rstrip("\r\n")
if not line or line.startswith("#") or "=" not in line:
continue
key, encoded = line.split("=", 1)
if not pattern.fullmatch(key):
raise SystemExit("invalid configuration key")
if key in seen:
raise SystemExit(f"duplicate configuration key: {key}")
seen.add(key)
if key != wanted:
continue
values = shlex.split(encoded, posix=True)
if len(values) != 1:
raise SystemExit(f"invalid value for {wanted}")
matches.append(values[0])
if len(matches) != 1:
raise SystemExit(f"missing configuration value: {wanted}")
print(matches[0], end="")
PY
}
verify_installation_identity() {
[[ -f "$CONFIG_FILE" && ! -L "$CONFIG_FILE" ]] \
|| fail "configuration file is missing or unsafe: $CONFIG_FILE"
local configured_root configured_lock configured_state
configured_root="$(read_config_value ERP_INSTALL_ROOT)" \
|| fail 'unable to verify the configured installation root'
configured_lock="$(read_config_value ERP_INSTALL_LOCK_FILE)" \
|| fail 'unable to verify the configured installation state'
configured_root="$(validate_remove_path "$configured_root")"
configured_state="$(validate_remove_path "$(dirname "$configured_lock")")"
[[ "$configured_root" == "$INSTALL_ROOT" ]] \
|| fail 'requested installation root does not match erp.env'
[[ "$configured_state" == "$STATE_ROOT" ]] \
|| fail 'requested state root does not match erp.env'
}
purge_database() {
command -v psql >/dev/null 2>&1 || fail 'PostgreSQL client psql is required'
local host port database sslmode username password owner_check
host="$(read_config_value ERP_PGHOST)"
port="$(read_config_value ERP_PGPORT)"
database="$(read_config_value ERP_PGDATABASE)"
sslmode="$(read_config_value ERP_PGSSLMODE)"
username="$(read_config_value OA_DB_USERNAME)"
password="$(read_config_value OA_DB_PASSWORD)"
[[ "$host" != *[[:space:]]* && "$port" =~ ^[0-9]+$ && "$port" -ge 1 && "$port" -le 65535 ]] \
|| fail 'invalid database endpoint in configuration'
[[ "$database" =~ ^[A-Za-z_][A-Za-z0-9_-]{0,62}$ ]] || fail 'invalid database name in configuration'
[[ "$username" =~ ^[A-Za-z_][A-Za-z0-9_.-]{0,127}$ ]] || fail 'invalid database user in configuration'
case "$database" in postgres|template0|template1) fail "refusing to purge protected database: $database" ;; esac
case "$sslmode" in disable|allow|prefer|require|verify-ca|verify-full) ;; *) fail 'invalid database SSL mode' ;; esac
owner_check="$(PGPASSWORD="$password" PGSSLMODE="$sslmode" psql -XAt \
-h "$host" -p "$port" -U "$username" -d "$database" -v ON_ERROR_STOP=1 \
-c "SELECT pg_get_userbyid(datdba) = current_user FROM pg_database WHERE datname = current_database()")" \
|| fail 'unable to verify database ownership'
[[ "$owner_check" == "t" ]] \
|| fail 'configured ERP account is not the database owner; database was not changed'
say "Purging PostgreSQL schema $database/public"
PGPASSWORD="$password" PGSSLMODE="$sslmode" psql -X \
-h "$host" -p "$port" -U "$username" -d "$database" -v ON_ERROR_STOP=1 <<'SQL'
DROP SCHEMA IF EXISTS public CASCADE;
SELECT format('CREATE SCHEMA public AUTHORIZATION %I', current_user) \gexec
SQL
}
stop_service() {
if command -v systemctl >/dev/null 2>&1; then
local load_state
if ! systemctl stop kaidi-erp.service >/dev/null 2>&1; then
load_state="$(systemctl show kaidi-erp.service --property=LoadState --value 2>/dev/null || true)"
[[ "$load_state" == "not-found" ]] \
|| fail 'unable to stop kaidi-erp.service; database and files were not changed'
fi
if systemctl is-active --quiet kaidi-erp.service; then
fail 'kaidi-erp.service is still active; database and files were not changed'
fi
fi
}
remove_service() {
if command -v systemctl >/dev/null 2>&1; then
systemctl disable kaidi-erp.service >/dev/null 2>&1 || true
fi
local unit_file="${ERP_UNINSTALL_SYSTEMD_UNIT_FILE:-/etc/systemd/system/kaidi-erp.service}"
if [[ "${ERP_UNINSTALL_TEST_MODE:-0}" != "1" && "$unit_file" != "/etc/systemd/system/kaidi-erp.service" ]]; then
fail 'custom systemd unit path is only available in test mode'
fi
rm -f -- "$unit_file"
if command -v systemctl >/dev/null 2>&1; then
systemctl daemon-reload >/dev/null 2>&1 || true
systemctl reset-failed kaidi-erp.service >/dev/null 2>&1 || true
fi
}
remove_files() {
local path
for path in "$INSTALL_ROOT" "$CONFIG_ROOT" "$STATE_ROOT" "$LOG_ROOT"; do
[[ ! -e "$path" && ! -L "$path" ]] || rm -rf -- "$path"
done
}
main() {
require_root
require_confirmation
prepare_paths
verify_installation_identity
stop_service
purge_database
remove_service
remove_files
say 'Kaidi ERP service, files, state, logs, and public database schema were removed'
}
if [[ "${BASH_SOURCE[0]:-$0}" == "$0" ]]; then
main "$@"
fi