Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76a0f1ffbd | ||
|
|
70dd8e1d6b | ||
|
|
6e22b24996 | ||
|
|
1affcd9a5e | ||
|
|
b714f9851e |
@@ -15,7 +15,7 @@ jobs:
|
||||
env:
|
||||
GITEA_BASE_URL: ${{ github.server_url }}
|
||||
GITEA_REPOSITORY: ${{ github.repository }}
|
||||
GITEA_ALLOW_INSECURE_HTTP: ${{ vars.GITEA_ALLOW_INSECURE_HTTP }}
|
||||
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
|
||||
@@ -97,7 +97,7 @@ 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
|
||||
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'
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
- 当前有效后端位于 `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 校验、服务重启、健康检查和自动回滚。
|
||||
- 已实现非 Docker 一键安装、PostgreSQL Flyway 迁移、独立网页安装向导、Gitea Release 更新、Ed25519/SHA-256 校验、服务重启、健康检查和自动回滚。
|
||||
- 业务功能和外部系统接入仍需按项目需求验收;技术部署链路通过不等于可以跳过生产安全、备份和数据迁移评审。
|
||||
|
||||
## 两种运行模式
|
||||
@@ -158,13 +158,14 @@ https://resonant-elated-launder.ngrok-free.dev/
|
||||
|
||||
## 非 Docker 正式安装
|
||||
|
||||
安装器支持 64 位 Linux `amd64/arm64` 和 macOS `arm64/amd64`。Linux 安装需要 root;macOS 不应使用 sudo 启动 LaunchAgent。正式安装前必须已有包含下列三个资产的 Gitea Release:
|
||||
安装器支持 64 位 Linux `amd64/arm64` 和 macOS `arm64/amd64`。Linux 安装需要 root;macOS 不应使用 sudo 启动 LaunchAgent。正式安装前必须已有包含下列四个资产的 Gitea Release:
|
||||
|
||||
- `kaidi-erp-<version>.tar.gz`
|
||||
- `kaidi-erp-installer-<version>.jar`
|
||||
- `SHA256SUMS`
|
||||
- `SHA256SUMS.sig`
|
||||
|
||||
### Linux 使用已有 PostgreSQL
|
||||
### Linux 一键安装
|
||||
|
||||
生产环境应先为 Gitea 配置 HTTPS:
|
||||
|
||||
@@ -175,19 +176,17 @@ curl -fsSL https://git.example.com/awaioi/ERP/raw/branch/main/install.sh \
|
||||
--repository awaioi/ERP
|
||||
```
|
||||
|
||||
安装器会从 `/dev/tty` 询问数据库地址、端口、数据库名、账号、密码和 SSL 模式,校验 PostgreSQL 版本及连接后再下载 Release。
|
||||
命令行只做环境准备:优先使用已有的 Java 17+,缺少时通过当前系统的 `apt-get`、`dnf`、`yum`、`zypper` 或 Homebrew 安装 Java、curl、tar、Python 3 和 OpenSSL 3,然后下载并启动独立安装器。数据库信息不在命令行输入。
|
||||
|
||||
### apt 系 Linux 自动创建本机 PostgreSQL
|
||||
Linux 生产服务要求主机使用 systemd;没有 systemd 的容器、WSL 或精简系统只能显式使用 `--no-service` 做开发验收,在线更新也会保持关闭。
|
||||
|
||||
```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 \
|
||||
--repository awaioi/ERP
|
||||
安装器启动后会输出带一次性令牌的局域网地址,例如:
|
||||
|
||||
```text
|
||||
Setup URL: http://192.168.1.20:8091/?token=<one-time-token>
|
||||
```
|
||||
|
||||
`--db-mode local` 当前只支持使用 apt 和 systemd 的 Linux。其他 Linux 发行版应先准备 PostgreSQL,再使用默认的 `existing` 模式。
|
||||
首次打开该地址进入网页向导,依次完成环境检查、PostgreSQL 地址/端口/库名/账号/密码/SSL 测试、管理员账号/姓名/密码设置、数据库迁移和初始化。项目当前没有 Redis 依赖,因此向导不会显示 Redis 配置项。正式服务真实健康检查通过后,启动器才会原子写入安装锁并物理删除 `installer/` 和 `install.pending`。
|
||||
|
||||
### macOS
|
||||
|
||||
@@ -200,23 +199,6 @@ curl -fsSL https://git.example.com/awaioi/ERP/raw/branch/main/install.sh \
|
||||
--repository awaioi/ERP
|
||||
```
|
||||
|
||||
### 无人值守安装
|
||||
|
||||
```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 \
|
||||
--repository awaioi/ERP
|
||||
```
|
||||
|
||||
### 当前 HTTP 测试服务器
|
||||
|
||||
当前 Gitea 地址 `http://38.76.196.225:10099` 只允许用于开发验收:
|
||||
@@ -240,9 +222,11 @@ 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` 只安装文件,不注册 systemd/launchd,同时会默认设置 `OA_UPDATE_ENABLED=false`。在线更新需要能在 Java 进程退出后自动拉起新版本的进程管理器。
|
||||
使用 `--no-service` 只用于开发验收:启动器会在当前用户下运行,但默认设置 `OA_UPDATE_ENABLED=false`。生产环境应使用 systemd/launchd,让在线更新可以在 Java 进程退出后自动拉起新版本。
|
||||
|
||||
## 在线更新与回滚
|
||||
|
||||
@@ -255,7 +239,7 @@ Linux 默认路径:
|
||||
对应前端路由为 `/appdev/update`,后端 API 为 `/api/oa/system-update/*`。更新过程如下:
|
||||
|
||||
1. 从 Gitea 读取 stable channel 的最新 Release。
|
||||
2. 下载归档、`SHA256SUMS` 和 Ed25519 签名。
|
||||
2. 下载归档、`SHA256SUMS` 和 Ed25519 签名(Release 里的独立安装器资产只用于首次安装)。
|
||||
3. 先验证签名和 SHA-256,再拒绝路径穿越、绝对路径、符号链接、硬链接和结构不完整的归档。
|
||||
4. 校验 `manifest.json` 中的版本、`database=postgresql` 和 `rollbackCompatible=true`。
|
||||
5. 可选执行 `pg_dump`,将新版本写入独立目录。
|
||||
@@ -294,7 +278,7 @@ Gitea 1.27 仓库需要启用 Actions,并配置带 `ubuntu-latest` 标签的
|
||||
流水线使用 Gitea 内置短期 `GITEA_TOKEN`,权限限定为代码只读、当前仓库 Release 可写。仓库设置只需添加:
|
||||
|
||||
- Secret `RELEASE_PRIVATE_KEY_B64`
|
||||
- 当前纯 HTTP 测试服务器额外添加 Variable `GITEA_ALLOW_INSECURE_HTTP=1`
|
||||
- 当前纯 HTTP 测试服务器额外添加 Variable `ERP_RELEASE_ALLOW_INSECURE_HTTP=1`(Gitea 禁止变量名以保留前缀 `GITEA_` 或 `GITHUB_` 开头)
|
||||
|
||||
签名私钥不得提交到 Git。生成 Secret 值:
|
||||
|
||||
@@ -311,7 +295,7 @@ git tag -a v0.2.0 -m 'Kaidi ERP v0.2.0'
|
||||
git push origin v0.2.0
|
||||
```
|
||||
|
||||
发布完成后必须确认 Release 页面存在三个资产,并使用仓库中的 `distribution/release-public-key.pem` 验证签名。私钥与该公钥不匹配时打包脚本会直接失败。
|
||||
发布完成后必须确认 Release 页面存在四个资产,并使用仓库中的 `distribution/release-public-key.pem` 验证签名。私钥与该公钥不匹配时打包脚本会直接失败。
|
||||
|
||||
本地手工生成签名资产:
|
||||
|
||||
@@ -336,6 +320,7 @@ ERP_RELEASE_PRIVATE_KEY_FILE="$HOME/.config/kaidi-erp/release-signing-key.pem" \
|
||||
| `OA_UPDATE_CHANNEL` | 更新通道 | `stable` |
|
||||
| `OA_UPDATE_TOKEN` | 私有仓库下载令牌 | 空;公开仓库不需要 |
|
||||
| `OA_UPDATE_ALLOW_INSECURE_HTTP` | 允许 HTTP 更新地址 | `false` |
|
||||
| `OA_SEED_DEMO` | 是否生成演示数据 | 正式安装为 `false` |
|
||||
| `ERP_UPDATE_BACKUP_MODE` | 更新前数据库备份 | `none`,可设 `pg_dump` |
|
||||
| `ERP_UPDATE_HEALTH_TIMEOUT_SECONDS` | 新旧版本健康检查超时 | `120` |
|
||||
| `ERP_UPDATE_HEALTH_POLL_SECONDS` | 健康轮询间隔 | `2` |
|
||||
@@ -388,7 +373,11 @@ OA=http://127.0.0.1:8091 bash oa-itest.sh
|
||||
|
||||
### 安装器提示没有 Release
|
||||
|
||||
Gitea 仓库尚未发布首个可安装版本,或 Release 缺少三个必需资产。先检查 `/api/v1/repos/awaioi/ERP/releases/latest` 和 Release 页面。
|
||||
Gitea 仓库尚未发布首个可安装版本,或 Release 缺少四个必需资产。先检查 `/api/v1/repos/awaioi/ERP/releases/latest` 和 Release 页面。
|
||||
|
||||
### 如何填写数据库
|
||||
|
||||
数据库连接信息只在首次网页向导填写。安装器会执行 `SELECT 1`、检查 PostgreSQL 15+ 并创建/验证 `pg_trgm`,错误凭据不会启动正式应用,也不会把密码写入响应或日志。数据库中已经有业务用户时,安装器会拒绝接管。
|
||||
|
||||
### PostgreSQL profile 启动失败
|
||||
|
||||
@@ -404,7 +393,7 @@ Gitea 仓库尚未发布首个可安装版本,或 Release 缺少三个必需
|
||||
|
||||
### 签名验证失败
|
||||
|
||||
不要跳过验证。确认 Release 的三个资产来自同一次构建、`SHA256SUMS` 未被改写、签名私钥与 `distribution/release-public-key.pem` 匹配。
|
||||
不要跳过验证。确认 Release 的四个资产来自同一次构建、`SHA256SUMS` 未被改写、签名私钥与 `distribution/release-public-key.pem` 匹配。
|
||||
|
||||
## Git 协作
|
||||
|
||||
|
||||
+142
-31
@@ -5,48 +5,159 @@ 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}"
|
||||
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'
|
||||
"$JAVA_BIN" "${JAVA_OPTS[@]}" \
|
||||
-jar "$JAR_PATH" \
|
||||
--spring.profiles.active=postgres \
|
||||
--server.port="${SERVER_PORT:-8091}" &
|
||||
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"
|
||||
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"
|
||||
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 "$@"
|
||||
|
||||
@@ -7,6 +7,7 @@ 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`
|
||||
|
||||
@@ -15,7 +16,7 @@ Gitea Actions runner 需要预装 Java 17 或更高版本、Node.js/npm、Python
|
||||
流水线使用 Gitea 1.27 提供的短期 `GITEA_TOKEN`,权限限定为代码只读、当前仓库 Release 可写,不需要创建个人访问令牌。仓库 Actions 设置只需创建:
|
||||
|
||||
- Secret `RELEASE_PRIVATE_KEY_B64`:Ed25519 私钥的单行 Base64 内容。
|
||||
- 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 值:
|
||||
|
||||
@@ -41,28 +42,9 @@ 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+ 和 `pg_trgm`,迁移完成并确认正式服务健康后才写 `install.lock`,随后物理删除安装器目录。
|
||||
|
||||
无人值守安装示例:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
在 apt 系 Linux 上也可以让安装器创建本机 PostgreSQL:
|
||||
|
||||
```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
|
||||
```
|
||||
Linux 生产服务要求主机使用 systemd;没有 systemd 的容器、WSL 或精简系统只能显式使用 `--no-service` 做开发验收,在线更新也会保持关闭。
|
||||
|
||||
当前 `http://38.76.196.225:10099` 仅用于开发测试,必须同时传入 `--allow-insecure`。HTTP 会暴露安装脚本、Release 元数据和 Gitea token,不应作为生产部署方式。
|
||||
|
||||
@@ -70,7 +52,7 @@ curl -fsSL https://git.example.com/awaioi/ERP/raw/branch/main/install.sh \
|
||||
|
||||
管理员进入“应用定制平台 -> 系统更新”,点击“检查更新”,确认版本和 Release notes 后执行更新。后端启动独立更新助手,更新助手会:
|
||||
|
||||
1. 下载三个 Release 资产并验证 Ed25519 签名和 SHA-256。
|
||||
1. 下载正式归档、`SHA256SUMS` 和签名并验证 Ed25519/SHA-256;独立安装器资产只在首次安装使用。
|
||||
2. 拒绝路径穿越、符号链接和结构不完整的安装包。
|
||||
3. 可选执行 `pg_dump`,再写入独立版本目录。
|
||||
4. 原子切换 `current` 链接并终止旧进程,由 systemd 或 launchd 拉起新版本。
|
||||
|
||||
+245
-218
@@ -5,8 +5,6 @@ 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:-}}"
|
||||
@@ -22,13 +20,11 @@ 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
|
||||
--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 +34,6 @@ 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 ;;
|
||||
--allow-insecure) ALLOW_INSECURE=1; shift ;;
|
||||
--no-service) NO_SERVICE=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
@@ -54,10 +48,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 +64,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 +192,7 @@ create_curl_config() {
|
||||
}
|
||||
|
||||
download() {
|
||||
validate_https_url "$1"
|
||||
validate_download_url "$1"
|
||||
curl --config "$CURL_CONFIG" --output "$2" "$1"
|
||||
}
|
||||
|
||||
@@ -235,9 +201,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 +221,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 +237,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 +302,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 +315,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 +322,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 +340,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 +353,59 @@ 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_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,15 +416,19 @@ 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
|
||||
cat > /etc/systemd/system/kaidi-erp.service <<EOF
|
||||
[Unit]
|
||||
@@ -439,20 +448,21 @@ Restart=always
|
||||
RestartSec=3
|
||||
TimeoutStopSec=90
|
||||
SuccessExitStatus=143
|
||||
UMask=0077
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
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
|
||||
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 +486,36 @@ 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}"
|
||||
}
|
||||
|
||||
main() {
|
||||
detect_platform
|
||||
validate_service_manager
|
||||
if [[ -z "$INSTALL_ROOT" ]]; then
|
||||
if [[ "$PLATFORM" == "linux" ]]; then
|
||||
INSTALL_ROOT=/opt/kaidi-erp
|
||||
@@ -498,26 +524,27 @@ 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' ;;
|
||||
esac
|
||||
validate_postgres
|
||||
validate_download_url "$GITEA_BASE_URL"
|
||||
|
||||
say "Detected $PLATFORM/$ARCH"
|
||||
check_and_install_dependencies
|
||||
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}" address
|
||||
address="$(detect_lan_address)"
|
||||
say "Kaidi ERP $VERSION installer is running"
|
||||
say "Setup URL: http://${address}:${port}/?token=${SETUP_TOKEN}"
|
||||
say "Local URL: http://127.0.0.1:${port}/?token=${SETUP_TOKEN}"
|
||||
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}" == "$0" ]]; then
|
||||
|
||||
@@ -21,6 +21,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'
|
||||
@@ -48,12 +66,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:10.22.0'
|
||||
installerRuntimeOnly 'org.flywaydb:flyway-database-postgresql:10.22.0'
|
||||
installerRuntimeOnly 'org.postgresql:postgresql'
|
||||
}
|
||||
|
||||
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,468 @@
|
||||
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.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 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) {
|
||||
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 或更高版本");
|
||||
}
|
||||
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");
|
||||
}
|
||||
}
|
||||
return new DatabaseCheck(major, connection.getMetaData().getDatabaseProductVersion());
|
||||
} catch (InstallApiException exception) {
|
||||
throw exception;
|
||||
} catch (SQLException | NumberFormatException exception) {
|
||||
throw new InstallApiException(
|
||||
HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
42202,
|
||||
"无法连接 PostgreSQL,请检查地址、账号、密码、SSL 和网络设置");
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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_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,27 @@
|
||||
server:
|
||||
port: ${SERVER_PORT:8091}
|
||||
shutdown: graceful
|
||||
|
||||
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,571 @@
|
||||
<!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; }
|
||||
.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; }
|
||||
}
|
||||
</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 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 };
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
async function pollFormalService() {
|
||||
setProgress(4);
|
||||
$('#install-message').textContent = '初始化完成,正在等待正式服务通过健康检查。';
|
||||
const deadline = Date.now() + 240000;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
try {
|
||||
const response = await fetch('/api/oa/health', { cache: 'no-store' });
|
||||
const body = await response.json();
|
||||
if (response.ok && (body?.data?.status === 'UP' || body?.status === 'UP')) {
|
||||
$$('[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');
|
||||
return;
|
||||
}
|
||||
} catch { /* Service is changing from installer to the formal application. */ }
|
||||
}
|
||||
$('#install-message').className = 'notice error';
|
||||
$('#install-message').textContent = '正式服务尚未就绪。请在服务器查看 kaidi-erp 服务日志,安装文件和安装锁不会被误删。';
|
||||
}
|
||||
|
||||
$('#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');
|
||||
setProgress(1);
|
||||
try {
|
||||
const request = { database: state.database, administrator: state.administrator };
|
||||
setTimeout(() => setProgress(2), 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;
|
||||
}
|
||||
setProgress(3);
|
||||
state.administrator.password = '';
|
||||
state.database.password = '';
|
||||
await pollFormalService();
|
||||
} catch (error) {
|
||||
state.installing = false;
|
||||
$('#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');
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -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 独立于 DataSeeder(Order=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;
|
||||
|
||||
@@ -1,19 +1,41 @@
|
||||
package com.kaidi.oa.web;
|
||||
|
||||
import com.kaidi.oa.common.ApiResp;
|
||||
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.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;
|
||||
|
||||
public HealthController(DataSource dataSource) {
|
||||
this.dataSource = dataSource;
|
||||
}
|
||||
|
||||
@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(Map.of("status", "UP", "database", "UP")));
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// Health responses deliberately avoid exposing connection details.
|
||||
}
|
||||
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
.body(new ApiResp<>(50301, "database unavailable", Map.of("status", "DOWN", "database", "DOWN")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,8 @@ logging:
|
||||
# 要真正产出 AI 结果:把 api-key 填上,或设置环境变量 ANTHROPIC_API_KEY。
|
||||
# ---------------------------------------------------------------------------
|
||||
oa:
|
||||
seed:
|
||||
demo: ${OA_SEED_DEMO:true}
|
||||
ai:
|
||||
api-key: ""
|
||||
model: claude-haiku-4-5-20251001
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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' \
|
||||
@@ -63,7 +64,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 +84,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 +103,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
|
||||
)
|
||||
|
||||
@@ -130,10 +136,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 +187,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 +196,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 +232,13 @@ 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; }
|
||||
check_and_install_dependencies() { JAVA_BIN="$(command -v java)"; return 0; }
|
||||
start_service() { return 0; }
|
||||
wait_for_installer() { return 0; }
|
||||
|
||||
main > "$tmp/install.log" 2>&1 || return 1
|
||||
|
||||
@@ -251,8 +249,8 @@ PY
|
||||
[[ "$SPRING_PROFILES_ACTIVE" == "postgres" ]]
|
||||
[[ "$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:-}" ]]
|
||||
)
|
||||
)
|
||||
|
||||
@@ -440,16 +438,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() (
|
||||
@@ -464,6 +456,19 @@ test_installer_requires_explicit_gitea_url() (
|
||||
[[ "$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_no_service_install_disables_online_update() (
|
||||
local tmp
|
||||
tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-no-service-test.XXXXXX")" || return 1
|
||||
@@ -475,15 +480,13 @@ 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"
|
||||
)
|
||||
@@ -514,8 +517,9 @@ run_test 'installer downloads and installs a signed release end to end' test_ins
|
||||
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 '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
|
||||
|
||||
Reference in New Issue
Block a user