From 1affcd9a5ea9872c4e0c917ef332baf22334545d Mon Sep 17 00:00:00 2001 From: Qiufeng Date: Tue, 4 Aug 2026 07:42:20 +0800 Subject: [PATCH] feat: add PostgreSQL web installation wizard --- .gitea/workflows/release.yml | 2 +- README.md | 55 +- distribution/bin/erp-run | 187 ++++-- docs/online-install-and-update.md | 26 +- install.sh | 451 +++++++------- oa-backend/build.gradle | 53 ++ .../kaidi/oa/install/InstallApiException.java | 23 + .../install/InstallApiExceptionHandler.java | 44 ++ .../kaidi/oa/install/InstallApiResponse.java | 12 + .../com/kaidi/oa/install/InstallRequest.java | 54 ++ .../oa/install/InstallerApplication.java | 19 + .../kaidi/oa/install/InstallerController.java | 38 ++ .../oa/install/InstallerPasswordHasher.java | 35 ++ .../kaidi/oa/install/InstallerProperties.java | 21 + .../kaidi/oa/install/InstallerService.java | 468 ++++++++++++++ .../kaidi/oa/install/SetupTokenFilter.java | 44 ++ .../src/installer/resources/application.yml | 27 + .../src/installer/resources/static/index.html | 571 ++++++++++++++++++ .../kaidi/oa/install/InstallerApiTest.java | 48 ++ .../kaidi/oa/seed/AdminQualElecSeeder.java | 3 +- .../com/kaidi/oa/seed/AdminQualSeeder.java | 3 +- .../oa/seed/ArchiveCrossModuleSeeder.java | 3 +- .../com/kaidi/oa/seed/AuditDeptSeeder.java | 3 +- .../kaidi/oa/seed/BidTenderCollabSeeder.java | 3 +- .../com/kaidi/oa/seed/BidTenderDocSeeder.java | 3 +- .../kaidi/oa/seed/BranchFinDemoSeeder.java | 3 +- .../kaidi/oa/seed/BrandCultureGapSeeder.java | 3 +- .../com/kaidi/oa/seed/BrandCultureSeeder.java | 3 +- .../com/kaidi/oa/seed/CostCtrlDemoSeeder.java | 3 +- .../com/kaidi/oa/seed/CostCtrlGapSeeder.java | 3 +- .../java/com/kaidi/oa/seed/CslDemoSeeder.java | 3 +- .../oa/seed/CultureBriefingKpiSeeder.java | 3 +- .../oa/seed/CultureProjectAssessSeeder.java | 3 +- .../java/com/kaidi/oa/seed/DataSeeder.java | 3 +- .../kaidi/oa/seed/DeclAchievementSeeder.java | 3 +- .../main/java/com/kaidi/oa/seed/DemoSeed.java | 18 + .../oa/seed/FertPkgAccountingSeeder.java | 3 +- .../com/kaidi/oa/seed/FinDeptDeepSeeder.java | 3 +- .../com/kaidi/oa/seed/FinDeptGapSeeder.java | 3 +- .../java/com/kaidi/oa/seed/FinDeptSeeder.java | 3 +- .../kaidi/oa/seed/FinPaymentDemoSeeder.java | 3 +- .../kaidi/oa/seed/FinancingDomainSeeder.java | 3 +- .../kaidi/oa/seed/HrLaborTemplateSeeder.java | 3 +- .../com/kaidi/oa/seed/HrRecruitSeeder.java | 3 +- .../com/kaidi/oa/seed/ItDeptDemoSeeder.java | 3 +- .../kaidi/oa/seed/LabElnAccessLogSeeder.java | 3 +- .../kaidi/oa/seed/LabTestKanbanSeeder.java | 3 +- .../com/kaidi/oa/seed/LegalDemoSeeder.java | 3 +- .../kaidi/oa/seed/LegalPolicyAnonSeeder.java | 3 +- .../kaidi/oa/seed/MarketCampaignSeeder.java | 3 +- .../kaidi/oa/seed/OrgEnrichmentSeeder.java | 3 +- .../com/kaidi/oa/seed/RdDeclPlanSeeder.java | 3 +- .../kaidi/oa/seed/SewageBenchmarkSeeder.java | 3 +- .../kaidi/oa/seed/SewageCrossDeptSeeder.java | 3 +- .../oa/seed/SvSupervisionMinuteSeeder.java | 3 +- .../com/kaidi/oa/seed/TemplateJsonSeeder.java | 3 +- .../java/com/kaidi/oa/seed/WwOpsSeeder.java | 3 +- .../com/kaidi/oa/web/HealthController.java | 28 +- oa-backend/src/main/resources/application.yml | 2 + scripts/package-release.sh | 24 +- tests/release-scripts.test.sh | 62 +- 61 files changed, 1992 insertions(+), 431 deletions(-) create mode 100644 oa-backend/src/installer/java/com/kaidi/oa/install/InstallApiException.java create mode 100644 oa-backend/src/installer/java/com/kaidi/oa/install/InstallApiExceptionHandler.java create mode 100644 oa-backend/src/installer/java/com/kaidi/oa/install/InstallApiResponse.java create mode 100644 oa-backend/src/installer/java/com/kaidi/oa/install/InstallRequest.java create mode 100644 oa-backend/src/installer/java/com/kaidi/oa/install/InstallerApplication.java create mode 100644 oa-backend/src/installer/java/com/kaidi/oa/install/InstallerController.java create mode 100644 oa-backend/src/installer/java/com/kaidi/oa/install/InstallerPasswordHasher.java create mode 100644 oa-backend/src/installer/java/com/kaidi/oa/install/InstallerProperties.java create mode 100644 oa-backend/src/installer/java/com/kaidi/oa/install/InstallerService.java create mode 100644 oa-backend/src/installer/java/com/kaidi/oa/install/SetupTokenFilter.java create mode 100644 oa-backend/src/installer/resources/application.yml create mode 100644 oa-backend/src/installer/resources/static/index.html create mode 100644 oa-backend/src/installerTest/java/com/kaidi/oa/install/InstallerApiTest.java create mode 100644 oa-backend/src/main/java/com/kaidi/oa/seed/DemoSeed.java diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index e01e62d..1622e95 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -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' diff --git a/README.md b/README.md index 93ce1e9..0afe2f2 100644 --- a/README.md +++ b/README.md @@ -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-.tar.gz` +- `kaidi-erp-installer-.jar` - `SHA256SUMS` - `SHA256SUMS.sig` -### Linux 使用已有 PostgreSQL +### Linux 一键安装 生产环境应先为 Gitea 配置 HTTPS: @@ -175,19 +176,15 @@ 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 +安装器启动后会输出带一次性令牌的局域网地址,例如: -```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= ``` -`--db-mode local` 当前只支持使用 apt 和 systemd 的 Linux。其他 Linux 发行版应先准备 PostgreSQL,再使用默认的 `existing` 模式。 +首次打开该地址进入网页向导,依次完成环境检查、PostgreSQL 地址/端口/库名/账号/密码/SSL 测试、管理员账号/姓名/密码设置、数据库迁移和初始化。项目当前没有 Redis 依赖,因此向导不会显示 Redis 配置项。正式服务真实健康检查通过后,启动器才会原子写入安装锁并物理删除 `installer/` 和 `install.pending`。 ### macOS @@ -200,23 +197,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 +220,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 +237,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`,将新版本写入独立目录。 @@ -311,7 +293,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 +318,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 +371,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 +391,7 @@ Gitea 仓库尚未发布首个可安装版本,或 Release 缺少三个必需 ### 签名验证失败 -不要跳过验证。确认 Release 的三个资产来自同一次构建、`SHA256SUMS` 未被改写、签名私钥与 `distribution/release-public-key.pem` 匹配。 +不要跳过验证。确认 Release 的四个资产来自同一次构建、`SHA256SUMS` 未被改写、签名私钥与 `distribution/release-public-key.pem` 匹配。 ## Git 协作 diff --git a/distribution/bin/erp-run b/distribution/bin/erp-run index 6768e24..91b489f 100755 --- a/distribution/bin/erp-run +++ b/distribution/bin/erp-run @@ -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 - if [[ -n "${JAVA_HOME:-}" && -x "$JAVA_HOME/bin/java" ]]; then - JAVA_BIN="$JAVA_HOME/bin/java" - else - JAVA_BIN="$(command -v java || true)" +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[@]}" \ - -jar "$JAR_PATH" \ - --spring.profiles.active=postgres \ - --server.port="${SERVER_PORT:-8091}" +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 "$@" diff --git a/docs/online-install-and-update.md b/docs/online-install-and-update.md index 4fd1ae7..6cfd3aa 100644 --- a/docs/online-install-and-update.md +++ b/docs/online-install-and-update.md @@ -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-.tar.gz` +- `kaidi-erp-installer-.jar` - `SHA256SUMS` - `SHA256SUMS.sig` @@ -41,28 +42,7 @@ curl -fsSL https://git.example.com/awaioi/ERP/raw/branch/main/install.sh \ --repository awaioi/ERP ``` -安装器会从终端读取现有 PostgreSQL 的主机、端口、数据库、用户和密码。通过管道运行时也会直接读取 `/dev/tty`,不会把管道中的脚本内容误当成密码。 - -无人值守安装示例: - -```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 -``` +命令行只检查并安装 Java 17+、curl、tar、Python 3 和 OpenSSL 3,然后启动独立安装器并输出带一次性 token 的网页地址。数据库、管理员和密码全部在首次网页向导填写;安装器会真实测试 PostgreSQL 15+ 和 `pg_trgm`,迁移完成并确认正式服务健康后才写 `install.lock`,随后物理删除安装器目录。 当前 `http://38.76.196.225:10099` 仅用于开发测试,必须同时传入 `--allow-insecure`。HTTP 会暴露安装脚本、Release 元数据和 Gitea token,不应作为生产部署方式。 @@ -70,7 +50,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 拉起新版本。 diff --git a/install.sh b/install.sh index 8ecdef3..529caf3 100755 --- a/install.sh +++ b/install.sh @@ -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,106 @@ 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' +java_major() { + local java_bin="${1:-java}" + "$java_bin" -version 2>&1 | awk -F'[".]' '/version/ {print $2; exit}' } -validate_https_url() { +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 - else - IFS= read -r -p "$label [$default]: " value /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 +184,7 @@ create_curl_config() { } download() { - validate_https_url "$1" + validate_download_url "$1" curl --config "$CURL_CONFIG" --output "$2" "$1" } @@ -235,9 +193,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 +213,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 +229,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: @@ -317,7 +295,9 @@ 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" 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 +306,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 +313,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 +331,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 +344,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 +407,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 </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() { @@ -498,26 +514,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 diff --git a/oa-backend/build.gradle b/oa-backend/build.gradle index 6863e5e..b9b671e 100644 --- a/oa-backend/build.gradle +++ b/oa-backend/build.gradle @@ -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() } diff --git a/oa-backend/src/installer/java/com/kaidi/oa/install/InstallApiException.java b/oa-backend/src/installer/java/com/kaidi/oa/install/InstallApiException.java new file mode 100644 index 0000000..b724507 --- /dev/null +++ b/oa-backend/src/installer/java/com/kaidi/oa/install/InstallApiException.java @@ -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; + } +} diff --git a/oa-backend/src/installer/java/com/kaidi/oa/install/InstallApiExceptionHandler.java b/oa-backend/src/installer/java/com/kaidi/oa/install/InstallApiExceptionHandler.java new file mode 100644 index 0000000..e24695c --- /dev/null +++ b/oa-backend/src/installer/java/com/kaidi/oa/install/InstallApiExceptionHandler.java @@ -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> handleInstallError(InstallApiException exception) { + return ResponseEntity.status(exception.status()) + .body(InstallApiResponse.error(exception.code(), exception.getMessage())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> 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> handleMissingResource() { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(InstallApiResponse.error(40400, "资源不存在")); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleUnexpected(Exception exception) { + log.error("Installer operation failed ({})", exception.getClass().getSimpleName()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(InstallApiResponse.error(50000, "安装操作失败,请检查配置后重试")); + } +} diff --git a/oa-backend/src/installer/java/com/kaidi/oa/install/InstallApiResponse.java b/oa-backend/src/installer/java/com/kaidi/oa/install/InstallApiResponse.java new file mode 100644 index 0000000..d5feb35 --- /dev/null +++ b/oa-backend/src/installer/java/com/kaidi/oa/install/InstallApiResponse.java @@ -0,0 +1,12 @@ +package com.kaidi.oa.install; + +public record InstallApiResponse(int code, String message, T data) { + + public static InstallApiResponse ok(T data) { + return new InstallApiResponse<>(0, "ok", data); + } + + public static InstallApiResponse error(int code, String message) { + return new InstallApiResponse<>(code, message, null); + } +} diff --git a/oa-backend/src/installer/java/com/kaidi/oa/install/InstallRequest.java b/oa-backend/src/installer/java/com/kaidi/oa/install/InstallRequest.java new file mode 100644 index 0000000..3e904b8 --- /dev/null +++ b/oa-backend/src/installer/java/com/kaidi/oa/install/InstallRequest.java @@ -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) { + } +} diff --git a/oa-backend/src/installer/java/com/kaidi/oa/install/InstallerApplication.java b/oa-backend/src/installer/java/com/kaidi/oa/install/InstallerApplication.java new file mode 100644 index 0000000..5ee8f91 --- /dev/null +++ b/oa-backend/src/installer/java/com/kaidi/oa/install/InstallerApplication.java @@ -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); + } +} diff --git a/oa-backend/src/installer/java/com/kaidi/oa/install/InstallerController.java b/oa-backend/src/installer/java/com/kaidi/oa/install/InstallerController.java new file mode 100644 index 0000000..ef38b7b --- /dev/null +++ b/oa-backend/src/installer/java/com/kaidi/oa/install/InstallerController.java @@ -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> status() { + return InstallApiResponse.ok(installerService.status()); + } + + @PostMapping("/test-database") + public InstallApiResponse> testDatabase( + @Valid @RequestBody InstallRequest.Database database) { + return InstallApiResponse.ok(installerService.testDatabase(database)); + } + + @PostMapping("/complete") + public InstallApiResponse> complete( + @Valid @RequestBody InstallRequest.Complete request) { + return InstallApiResponse.ok(installerService.complete(request)); + } +} diff --git a/oa-backend/src/installer/java/com/kaidi/oa/install/InstallerPasswordHasher.java b/oa-backend/src/installer/java/com/kaidi/oa/install/InstallerPasswordHasher.java new file mode 100644 index 0000000..7b6d868 --- /dev/null +++ b/oa-backend/src/installer/java/com/kaidi/oa/install/InstallerPasswordHasher.java @@ -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); + } + } +} diff --git a/oa-backend/src/installer/java/com/kaidi/oa/install/InstallerProperties.java b/oa-backend/src/installer/java/com/kaidi/oa/install/InstallerProperties.java new file mode 100644 index 0000000..e2e06f8 --- /dev/null +++ b/oa-backend/src/installer/java/com/kaidi/oa/install/InstallerProperties.java @@ -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) { +} diff --git a/oa-backend/src/installer/java/com/kaidi/oa/install/InstallerService.java b/oa-backend/src/installer/java/com/kaidi/oa/install/InstallerService.java new file mode 100644 index 0000000..0d45726 --- /dev/null +++ b/oa-backend/src/installer/java/com/kaidi/oa/install/InstallerService.java @@ -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 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 status() { + LinkedHashMap 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 testDatabase(InstallRequest.Database database) { + ensureUnlocked(); + DatabaseCheck check = verifyDatabase(database); + LinkedHashMap 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 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 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 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 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 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) { + } +} diff --git a/oa-backend/src/installer/java/com/kaidi/oa/install/SetupTokenFilter.java b/oa-backend/src/installer/java/com/kaidi/oa/install/SetupTokenFilter.java new file mode 100644 index 0000000..cd95c7b --- /dev/null +++ b/oa-backend/src/installer/java/com/kaidi/oa/install/SetupTokenFilter.java @@ -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); + } +} diff --git a/oa-backend/src/installer/resources/application.yml b/oa-backend/src/installer/resources/application.yml new file mode 100644 index 0000000..9ecfd8d --- /dev/null +++ b/oa-backend/src/installer/resources/application.yml @@ -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 diff --git a/oa-backend/src/installer/resources/static/index.html b/oa-backend/src/installer/resources/static/index.html new file mode 100644 index 0000000..e429675 --- /dev/null +++ b/oa-backend/src/installer/resources/static/index.html @@ -0,0 +1,571 @@ + + + + + + + 凯迪 ERP 安装向导 + + + +
+
+ KD + 凯迪 ERP + OA +
+ 首次安装向导 +
+ +
+ + +
+ + +
+

环境检查

确认安装程序和服务器运行环境已经准备就绪。

+
+
Java 运行环境
正在检测
检测中
+
安装程序
正在读取版本
检测中
+
生产数据库要求
PostgreSQL 15 或更高版本
支持
+
Redis
当前版本没有 Redis 运行依赖
无需配置
+
+
正在连接本机安装服务。
+
+
+ + + + + + +
+
+ + + + diff --git a/oa-backend/src/installerTest/java/com/kaidi/oa/install/InstallerApiTest.java b/oa-backend/src/installerTest/java/com/kaidi/oa/install/InstallerApiTest.java new file mode 100644 index 0000000..d5bd0e9 --- /dev/null +++ b/oa-backend/src/installerTest/java/com/kaidi/oa/install/InstallerApiTest.java @@ -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")); + } +} diff --git a/oa-backend/src/main/java/com/kaidi/oa/seed/AdminQualElecSeeder.java b/oa-backend/src/main/java/com/kaidi/oa/seed/AdminQualElecSeeder.java index d85c9b3..427ebef 100644 --- a/oa-backend/src/main/java/com/kaidi/oa/seed/AdminQualElecSeeder.java +++ b/oa-backend/src/main/java/com/kaidi/oa/seed/AdminQualElecSeeder.java @@ -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 { diff --git a/oa-backend/src/main/java/com/kaidi/oa/seed/AdminQualSeeder.java b/oa-backend/src/main/java/com/kaidi/oa/seed/AdminQualSeeder.java index eddd6fe..e4dae00 100644 --- a/oa-backend/src/main/java/com/kaidi/oa/seed/AdminQualSeeder.java +++ b/oa-backend/src/main/java/com/kaidi/oa/seed/AdminQualSeeder.java @@ -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 { diff --git a/oa-backend/src/main/java/com/kaidi/oa/seed/ArchiveCrossModuleSeeder.java b/oa-backend/src/main/java/com/kaidi/oa/seed/ArchiveCrossModuleSeeder.java index f24189c..3b5952b 100644 --- a/oa-backend/src/main/java/com/kaidi/oa/seed/ArchiveCrossModuleSeeder.java +++ b/oa-backend/src/main/java/com/kaidi/oa/seed/ArchiveCrossModuleSeeder.java @@ -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; *

幂等:同 sourceType 在 archive 表已有记录则跳过整批(count 检查)。 * @Order(205) 在所有基础 Seeder 后运行。 */ -@Component +@DemoSeed @Order(205) public class ArchiveCrossModuleSeeder implements CommandLineRunner { diff --git a/oa-backend/src/main/java/com/kaidi/oa/seed/AuditDeptSeeder.java b/oa-backend/src/main/java/com/kaidi/oa/seed/AuditDeptSeeder.java index 0236d83..91310e0 100644 --- a/oa-backend/src/main/java/com/kaidi/oa/seed/AuditDeptSeeder.java +++ b/oa-backend/src/main/java/com/kaidi/oa/seed/AuditDeptSeeder.java @@ -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 { diff --git a/oa-backend/src/main/java/com/kaidi/oa/seed/BidTenderCollabSeeder.java b/oa-backend/src/main/java/com/kaidi/oa/seed/BidTenderCollabSeeder.java index 5f2c047..2d20d42 100644 --- a/oa-backend/src/main/java/com/kaidi/oa/seed/BidTenderCollabSeeder.java +++ b/oa-backend/src/main/java/com/kaidi/oa/seed/BidTenderCollabSeeder.java @@ -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 { diff --git a/oa-backend/src/main/java/com/kaidi/oa/seed/BidTenderDocSeeder.java b/oa-backend/src/main/java/com/kaidi/oa/seed/BidTenderDocSeeder.java index b8bc807..06f4c87 100644 --- a/oa-backend/src/main/java/com/kaidi/oa/seed/BidTenderDocSeeder.java +++ b/oa-backend/src/main/java/com/kaidi/oa/seed/BidTenderDocSeeder.java @@ -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 { diff --git a/oa-backend/src/main/java/com/kaidi/oa/seed/BranchFinDemoSeeder.java b/oa-backend/src/main/java/com/kaidi/oa/seed/BranchFinDemoSeeder.java index 3afc6b8..5823e85 100644 --- a/oa-backend/src/main/java/com/kaidi/oa/seed/BranchFinDemoSeeder.java +++ b/oa-backend/src/main/java/com/kaidi/oa/seed/BranchFinDemoSeeder.java @@ -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 { diff --git a/oa-backend/src/main/java/com/kaidi/oa/seed/BrandCultureGapSeeder.java b/oa-backend/src/main/java/com/kaidi/oa/seed/BrandCultureGapSeeder.java index f9c3a96..b11be5d 100644 --- a/oa-backend/src/main/java/com/kaidi/oa/seed/BrandCultureGapSeeder.java +++ b/oa-backend/src/main/java/com/kaidi/oa/seed/BrandCultureGapSeeder.java @@ -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 { diff --git a/oa-backend/src/main/java/com/kaidi/oa/seed/BrandCultureSeeder.java b/oa-backend/src/main/java/com/kaidi/oa/seed/BrandCultureSeeder.java index 5d1223f..bbdfcbf 100644 --- a/oa-backend/src/main/java/com/kaidi/oa/seed/BrandCultureSeeder.java +++ b/oa-backend/src/main/java/com/kaidi/oa/seed/BrandCultureSeeder.java @@ -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 { diff --git a/oa-backend/src/main/java/com/kaidi/oa/seed/CostCtrlDemoSeeder.java b/oa-backend/src/main/java/com/kaidi/oa/seed/CostCtrlDemoSeeder.java index 9625eb0..58bcc30 100644 --- a/oa-backend/src/main/java/com/kaidi/oa/seed/CostCtrlDemoSeeder.java +++ b/oa-backend/src/main/java/com/kaidi/oa/seed/CostCtrlDemoSeeder.java @@ -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 { diff --git a/oa-backend/src/main/java/com/kaidi/oa/seed/CostCtrlGapSeeder.java b/oa-backend/src/main/java/com/kaidi/oa/seed/CostCtrlGapSeeder.java index 44c5741..33f25ee 100644 --- a/oa-backend/src/main/java/com/kaidi/oa/seed/CostCtrlGapSeeder.java +++ b/oa-backend/src/main/java/com/kaidi/oa/seed/CostCtrlGapSeeder.java @@ -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 { diff --git a/oa-backend/src/main/java/com/kaidi/oa/seed/CslDemoSeeder.java b/oa-backend/src/main/java/com/kaidi/oa/seed/CslDemoSeeder.java index 3d8b132..8bce79d 100644 --- a/oa-backend/src/main/java/com/kaidi/oa/seed/CslDemoSeeder.java +++ b/oa-backend/src/main/java/com/kaidi/oa/seed/CslDemoSeeder.java @@ -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 { diff --git a/oa-backend/src/main/java/com/kaidi/oa/seed/CultureBriefingKpiSeeder.java b/oa-backend/src/main/java/com/kaidi/oa/seed/CultureBriefingKpiSeeder.java index be16227..afdd8c2 100644 --- a/oa-backend/src/main/java/com/kaidi/oa/seed/CultureBriefingKpiSeeder.java +++ b/oa-backend/src/main/java/com/kaidi/oa/seed/CultureBriefingKpiSeeder.java @@ -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 { diff --git a/oa-backend/src/main/java/com/kaidi/oa/seed/CultureProjectAssessSeeder.java b/oa-backend/src/main/java/com/kaidi/oa/seed/CultureProjectAssessSeeder.java index 0c297c3..f666545 100644 --- a/oa-backend/src/main/java/com/kaidi/oa/seed/CultureProjectAssessSeeder.java +++ b/oa-backend/src/main/java/com/kaidi/oa/seed/CultureProjectAssessSeeder.java @@ -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 { diff --git a/oa-backend/src/main/java/com/kaidi/oa/seed/DataSeeder.java b/oa-backend/src/main/java/com/kaidi/oa/seed/DataSeeder.java index 8e9c24a..f0b6393 100644 --- a/oa-backend/src/main/java/com/kaidi/oa/seed/DataSeeder.java +++ b/oa-backend/src/main/java/com/kaidi/oa/seed/DataSeeder.java @@ -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 { diff --git a/oa-backend/src/main/java/com/kaidi/oa/seed/DeclAchievementSeeder.java b/oa-backend/src/main/java/com/kaidi/oa/seed/DeclAchievementSeeder.java index 8b65ff1..d5d3f89 100644 --- a/oa-backend/src/main/java/com/kaidi/oa/seed/DeclAchievementSeeder.java +++ b/oa-backend/src/main/java/com/kaidi/oa/seed/DeclAchievementSeeder.java @@ -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 { diff --git a/oa-backend/src/main/java/com/kaidi/oa/seed/DemoSeed.java b/oa-backend/src/main/java/com/kaidi/oa/seed/DemoSeed.java new file mode 100644 index 0000000..a702bf3 --- /dev/null +++ b/oa-backend/src/main/java/com/kaidi/oa/seed/DemoSeed.java @@ -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 { +} diff --git a/oa-backend/src/main/java/com/kaidi/oa/seed/FertPkgAccountingSeeder.java b/oa-backend/src/main/java/com/kaidi/oa/seed/FertPkgAccountingSeeder.java index d65f532..7bab328 100644 --- a/oa-backend/src/main/java/com/kaidi/oa/seed/FertPkgAccountingSeeder.java +++ b/oa-backend/src/main/java/com/kaidi/oa/seed/FertPkgAccountingSeeder.java @@ -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 { diff --git a/oa-backend/src/main/java/com/kaidi/oa/seed/FinDeptDeepSeeder.java b/oa-backend/src/main/java/com/kaidi/oa/seed/FinDeptDeepSeeder.java index 9e7c49b..e9e271d 100644 --- a/oa-backend/src/main/java/com/kaidi/oa/seed/FinDeptDeepSeeder.java +++ b/oa-backend/src/main/java/com/kaidi/oa/seed/FinDeptDeepSeeder.java @@ -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 { diff --git a/oa-backend/src/main/java/com/kaidi/oa/seed/FinDeptGapSeeder.java b/oa-backend/src/main/java/com/kaidi/oa/seed/FinDeptGapSeeder.java index 15f2e6d..f2a8587 100644 --- a/oa-backend/src/main/java/com/kaidi/oa/seed/FinDeptGapSeeder.java +++ b/oa-backend/src/main/java/com/kaidi/oa/seed/FinDeptGapSeeder.java @@ -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 { diff --git a/oa-backend/src/main/java/com/kaidi/oa/seed/FinDeptSeeder.java b/oa-backend/src/main/java/com/kaidi/oa/seed/FinDeptSeeder.java index b01fe63..84c4aa9 100644 --- a/oa-backend/src/main/java/com/kaidi/oa/seed/FinDeptSeeder.java +++ b/oa-backend/src/main/java/com/kaidi/oa/seed/FinDeptSeeder.java @@ -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 { diff --git a/oa-backend/src/main/java/com/kaidi/oa/seed/FinPaymentDemoSeeder.java b/oa-backend/src/main/java/com/kaidi/oa/seed/FinPaymentDemoSeeder.java index fa9c1cc..04610d8 100644 --- a/oa-backend/src/main/java/com/kaidi/oa/seed/FinPaymentDemoSeeder.java +++ b/oa-backend/src/main/java/com/kaidi/oa/seed/FinPaymentDemoSeeder.java @@ -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 { diff --git a/oa-backend/src/main/java/com/kaidi/oa/seed/FinancingDomainSeeder.java b/oa-backend/src/main/java/com/kaidi/oa/seed/FinancingDomainSeeder.java index 9961bed..29ffa16 100644 --- a/oa-backend/src/main/java/com/kaidi/oa/seed/FinancingDomainSeeder.java +++ b/oa-backend/src/main/java/com/kaidi/oa/seed/FinancingDomainSeeder.java @@ -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; diff --git a/oa-backend/src/main/java/com/kaidi/oa/seed/HrLaborTemplateSeeder.java b/oa-backend/src/main/java/com/kaidi/oa/seed/HrLaborTemplateSeeder.java index 9651261..9d94db6 100644 --- a/oa-backend/src/main/java/com/kaidi/oa/seed/HrLaborTemplateSeeder.java +++ b/oa-backend/src/main/java/com/kaidi/oa/seed/HrLaborTemplateSeeder.java @@ -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 { diff --git a/oa-backend/src/main/java/com/kaidi/oa/seed/HrRecruitSeeder.java b/oa-backend/src/main/java/com/kaidi/oa/seed/HrRecruitSeeder.java index 1c16ea1..b260cf1 100644 --- a/oa-backend/src/main/java/com/kaidi/oa/seed/HrRecruitSeeder.java +++ b/oa-backend/src/main/java/com/kaidi/oa/seed/HrRecruitSeeder.java @@ -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 { diff --git a/oa-backend/src/main/java/com/kaidi/oa/seed/ItDeptDemoSeeder.java b/oa-backend/src/main/java/com/kaidi/oa/seed/ItDeptDemoSeeder.java index 09a437c..ee89c6c 100644 --- a/oa-backend/src/main/java/com/kaidi/oa/seed/ItDeptDemoSeeder.java +++ b/oa-backend/src/main/java/com/kaidi/oa/seed/ItDeptDemoSeeder.java @@ -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; diff --git a/oa-backend/src/main/java/com/kaidi/oa/seed/LabElnAccessLogSeeder.java b/oa-backend/src/main/java/com/kaidi/oa/seed/LabElnAccessLogSeeder.java index e9c104d..0c56f7e 100644 --- a/oa-backend/src/main/java/com/kaidi/oa/seed/LabElnAccessLogSeeder.java +++ b/oa-backend/src/main/java/com/kaidi/oa/seed/LabElnAccessLogSeeder.java @@ -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 { diff --git a/oa-backend/src/main/java/com/kaidi/oa/seed/LabTestKanbanSeeder.java b/oa-backend/src/main/java/com/kaidi/oa/seed/LabTestKanbanSeeder.java index 1634fb6..d6ca4d3 100644 --- a/oa-backend/src/main/java/com/kaidi/oa/seed/LabTestKanbanSeeder.java +++ b/oa-backend/src/main/java/com/kaidi/oa/seed/LabTestKanbanSeeder.java @@ -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 { diff --git a/oa-backend/src/main/java/com/kaidi/oa/seed/LegalDemoSeeder.java b/oa-backend/src/main/java/com/kaidi/oa/seed/LegalDemoSeeder.java index c8a5827..1f95cff 100644 --- a/oa-backend/src/main/java/com/kaidi/oa/seed/LegalDemoSeeder.java +++ b/oa-backend/src/main/java/com/kaidi/oa/seed/LegalDemoSeeder.java @@ -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; diff --git a/oa-backend/src/main/java/com/kaidi/oa/seed/LegalPolicyAnonSeeder.java b/oa-backend/src/main/java/com/kaidi/oa/seed/LegalPolicyAnonSeeder.java index f41f5bb..1d45af4 100644 --- a/oa-backend/src/main/java/com/kaidi/oa/seed/LegalPolicyAnonSeeder.java +++ b/oa-backend/src/main/java/com/kaidi/oa/seed/LegalPolicyAnonSeeder.java @@ -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; diff --git a/oa-backend/src/main/java/com/kaidi/oa/seed/MarketCampaignSeeder.java b/oa-backend/src/main/java/com/kaidi/oa/seed/MarketCampaignSeeder.java index c1cafa8..8034a67 100644 --- a/oa-backend/src/main/java/com/kaidi/oa/seed/MarketCampaignSeeder.java +++ b/oa-backend/src/main/java/com/kaidi/oa/seed/MarketCampaignSeeder.java @@ -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 { diff --git a/oa-backend/src/main/java/com/kaidi/oa/seed/OrgEnrichmentSeeder.java b/oa-backend/src/main/java/com/kaidi/oa/seed/OrgEnrichmentSeeder.java index d6495ec..8a92760 100644 --- a/oa-backend/src/main/java/com/kaidi/oa/seed/OrgEnrichmentSeeder.java +++ b/oa-backend/src/main/java/com/kaidi/oa/seed/OrgEnrichmentSeeder.java @@ -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 { diff --git a/oa-backend/src/main/java/com/kaidi/oa/seed/RdDeclPlanSeeder.java b/oa-backend/src/main/java/com/kaidi/oa/seed/RdDeclPlanSeeder.java index 8ac2217..4a50979 100644 --- a/oa-backend/src/main/java/com/kaidi/oa/seed/RdDeclPlanSeeder.java +++ b/oa-backend/src/main/java/com/kaidi/oa/seed/RdDeclPlanSeeder.java @@ -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 { diff --git a/oa-backend/src/main/java/com/kaidi/oa/seed/SewageBenchmarkSeeder.java b/oa-backend/src/main/java/com/kaidi/oa/seed/SewageBenchmarkSeeder.java index 91d3ae1..d9d0512 100644 --- a/oa-backend/src/main/java/com/kaidi/oa/seed/SewageBenchmarkSeeder.java +++ b/oa-backend/src/main/java/com/kaidi/oa/seed/SewageBenchmarkSeeder.java @@ -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 { diff --git a/oa-backend/src/main/java/com/kaidi/oa/seed/SewageCrossDeptSeeder.java b/oa-backend/src/main/java/com/kaidi/oa/seed/SewageCrossDeptSeeder.java index f9356b7..ba6c32a 100644 --- a/oa-backend/src/main/java/com/kaidi/oa/seed/SewageCrossDeptSeeder.java +++ b/oa-backend/src/main/java/com/kaidi/oa/seed/SewageCrossDeptSeeder.java @@ -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 { diff --git a/oa-backend/src/main/java/com/kaidi/oa/seed/SvSupervisionMinuteSeeder.java b/oa-backend/src/main/java/com/kaidi/oa/seed/SvSupervisionMinuteSeeder.java index 7a6660c..03a5e0f 100644 --- a/oa-backend/src/main/java/com/kaidi/oa/seed/SvSupervisionMinuteSeeder.java +++ b/oa-backend/src/main/java/com/kaidi/oa/seed/SvSupervisionMinuteSeeder.java @@ -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; *

@Order(210) 在 DataSeeder(@Order 1) 之后运行;幂等(SvIndepClaim.count>0 则跳过)。 */ @Order(210) -@Component +@DemoSeed public class SvSupervisionMinuteSeeder implements CommandLineRunner { private final SvIndepClaimRepository claimRepo; diff --git a/oa-backend/src/main/java/com/kaidi/oa/seed/TemplateJsonSeeder.java b/oa-backend/src/main/java/com/kaidi/oa/seed/TemplateJsonSeeder.java index 9b481d0..e6fb29d 100644 --- a/oa-backend/src/main/java/com/kaidi/oa/seed/TemplateJsonSeeder.java +++ b/oa-backend/src/main/java/com/kaidi/oa/seed/TemplateJsonSeeder.java @@ -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 { diff --git a/oa-backend/src/main/java/com/kaidi/oa/seed/WwOpsSeeder.java b/oa-backend/src/main/java/com/kaidi/oa/seed/WwOpsSeeder.java index e827f49..f104b02 100644 --- a/oa-backend/src/main/java/com/kaidi/oa/seed/WwOpsSeeder.java +++ b/oa-backend/src/main/java/com/kaidi/oa/seed/WwOpsSeeder.java @@ -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; diff --git a/oa-backend/src/main/java/com/kaidi/oa/web/HealthController.java b/oa-backend/src/main/java/com/kaidi/oa/web/HealthController.java index 9d809fc..a02b79a 100644 --- a/oa-backend/src/main/java/com/kaidi/oa/web/HealthController.java +++ b/oa-backend/src/main/java/com/kaidi/oa/web/HealthController.java @@ -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> health() { - return ApiResp.ok(Map.of("status", "UP")); + public ResponseEntity>> 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"))); } } diff --git a/oa-backend/src/main/resources/application.yml b/oa-backend/src/main/resources/application.yml index c837e9f..8f460f8 100644 --- a/oa-backend/src/main/resources/application.yml +++ b/oa-backend/src/main/resources/application.yml @@ -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 diff --git a/scripts/package-release.sh b/scripts/package-release.sh index 06959d5..0527eb4 100755 --- a/scripts/package-release.sh +++ b/scripts/package-release.sh @@ -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" diff --git a/tests/release-scripts.test.sh b/tests/release-scripts.test.sh index 64b1aef..616d5f9 100755 --- a/tests/release-scripts.test.sh +++ b/tests/release-scripts.test.sh @@ -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"/assets/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() ( @@ -475,15 +467,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,7 +504,7 @@ 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 '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