3 Commits
Author SHA1 Message Date
Qiufeng f6e22cb670 feat: add signed PostgreSQL release updates 2026-08-03 23:45:32 +08:00
Qiufeng 2524a37a07 Make local ERP previews one double-click away
Constraint: Finder launch must supervise Java and ngrok in one visible Terminal
Rejected: LaunchAgent | obscures ownership and makes safe cleanup harder
Confidence: high
Scope-risk: narrow
Directive: Never kill listeners not recorded as launcher-owned PIDs
Tested: Shell unit suite, repeat-start reuse, cold start, local/public login and protected API smoke
Not-tested: Finder Gatekeeper behavior on a different Mac
2026-07-15 16:29:05 +08:00
Qiufeng 672569f199 Document the one-command startup contract
Constraint: macOS double-click launch must keep one Terminal window alive
Rejected: LaunchAgent | hides process ownership and complicates safe cleanup
Confidence: high
Scope-risk: narrow
Directive: Keep cleanup scoped to processes started by run.command
Tested: Spec self-review for placeholders, contradictions, ambiguity, and scope
Not-tested: Launcher implementation pending written-spec review
2026-07-15 15:26:42 +08:00
101 changed files with 45644 additions and 276 deletions
+116
View File
@@ -0,0 +1,116 @@
name: Signed Release
on:
push:
tags:
- "v*"
jobs:
release:
runs-on: ubuntu-latest
env:
GITEA_BASE_URL: ${{ vars.GITEA_BASE_URL }}
GITEA_REPOSITORY: ${{ github.repository }}
GITEA_ALLOW_INSECURE_HTTP: ${{ vars.GITEA_ALLOW_INSECURE_HTTP }}
GITEA_TOKEN: ${{ secrets.RELEASE_GITEA_TOKEN }}
RELEASE_PRIVATE_KEY_B64: ${{ secrets.RELEASE_PRIVATE_KEY_B64 }}
NODE_OPTIONS: --max-old-space-size=8192
steps:
- name: Check out source
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build, sign, and publish release
shell: bash
run: |
set -euo pipefail
tag="${GITHUB_REF_NAME:?missing tag name}"
version="${tag#v}"
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]] \
|| { printf 'invalid release tag: %s\n' "$tag" >&2; exit 2; }
api_base="${GITEA_BASE_URL:?missing GITEA_BASE_URL repository variable}"
repository="${GITEA_REPOSITORY:-awaioi/ERP}"
case "$api_base" in
https://*) curl_protocols=(--proto '=https' --proto-redir '=https') ;;
http://*)
[[ "${GITEA_ALLOW_INSECURE_HTTP:-0}" == "1" || "${GITEA_ALLOW_INSECURE_HTTP:-0}" == "true" ]] \
|| { printf 'GITEA_BASE_URL must use HTTPS\n' >&2; exit 1; }
curl_protocols=(--proto '=http,https' --proto-redir '=http,https')
;;
*) printf 'invalid GITEA_BASE_URL\n' >&2; exit 1 ;;
esac
[[ "$repository" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]] \
|| { printf 'invalid GITEA_REPOSITORY\n' >&2; exit 1; }
[[ -n "${GITEA_TOKEN:-}" ]] || { printf 'missing RELEASE_GITEA_TOKEN secret\n' >&2; exit 1; }
[[ -n "${RELEASE_PRIVATE_KEY_B64:-}" ]] \
|| { printf 'missing RELEASE_PRIVATE_KEY_B64 secret\n' >&2; exit 1; }
umask 077
key_file="${RUNNER_TEMP:-/tmp}/kaidi-erp-release-key.pem"
cleanup() { rm -f "$key_file" release.json release-payload.json; }
trap cleanup EXIT
printf '%s' "$RELEASE_PRIVATE_KEY_B64" | base64 --decode > "$key_file"
export ERP_RELEASE_PRIVATE_KEY_FILE="$key_file"
bash scripts/package-release.sh "$version"
owner="${repository%%/*}"
repo="${repository#*/}"
release_api="${api_base%/}/api/v1/repos/$owner/$repo/releases"
auth_header="Authorization: token $GITEA_TOKEN"
status="$(curl "${curl_protocols[@]}" --silent --show-error --location \
--output release.json --write-out '%{http_code}' \
--header "$auth_header" --header 'Accept: application/json' \
"$release_api/tags/$tag")"
python3 - "$tag" > release-payload.json <<'PY'
import json, sys
tag = sys.argv[1]
print(json.dumps({
"tag_name": tag,
"name": tag,
"body": f"Kaidi ERP {tag}",
"draft": False,
"prerelease": "-" in tag.split("+", 1)[0],
}, separators=(",", ":")))
PY
if [[ "$status" == "404" ]]; then
curl "${curl_protocols[@]}" --fail-with-body --silent --show-error --location --retry 3 \
--header "$auth_header" --header 'Content-Type: application/json' \
--data-binary @release-payload.json --output release.json "$release_api"
elif [[ "$status" == "200" ]]; then
release_id="$(python3 -c 'import json; print(json.load(open("release.json"))["id"])')"
curl "${curl_protocols[@]}" --fail-with-body --silent --show-error --location --retry 3 \
--request PATCH --header "$auth_header" --header 'Content-Type: application/json' \
--data-binary @release-payload.json --output release.json "$release_api/$release_id"
else
printf 'Gitea release lookup returned HTTP %s\n' "$status" >&2
cat release.json >&2
exit 1
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
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'
import json, sys
release = json.load(open("release.json"))
print(next((item["id"] for item in release.get("assets", []) if item.get("name") == sys.argv[1]), ""))
PY
)"
if [[ -n "$existing_id" ]]; then
curl "${curl_protocols[@]}" --fail-with-body --silent --show-error --location --retry 3 \
--request DELETE --header "$auth_header" \
"$release_api/$release_id/assets/$existing_id"
fi
curl "${curl_protocols[@]}" --fail-with-body --silent --show-error --location --retry 3 \
--request POST --header "$auth_header" \
--form "attachment=@$asset" \
"$release_api/$release_id/assets?name=$encoded_name" >/dev/null
done
printf 'Published %s release %s\n' "$repository" "$tag"
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
set -euo pipefail
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
set -a
# The installer creates this file with shell-escaped values and mode 0640.
# 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:-}"
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
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
mkdir -p "$RUN_DIR"
printf '%s\n' "$$" > "$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}"
+461
View File
@@ -0,0 +1,461 @@
#!/usr/bin/env bash
set -euo pipefail
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 Update] configuration not readable: %s\n' "$CONFIG_FILE" >&2
exit 1
fi
set -a
# shellcheck disable=SC1090
source "$CONFIG_FILE"
set +a
STATE_FILE="${OA_UPDATE_STATE_FILE:-${ERP_STATE_FILE:-$INSTALL_ROOT/state/update-state.json}}"
RUN_DIR="${ERP_RUN_DIR:-$INSTALL_ROOT/run}"
RELEASES_DIR="$INSTALL_ROOT/releases"
CURRENT_LINK="$INSTALL_ROOT/current"
HEALTH_URL="${ERP_HEALTH_URL:-http://127.0.0.1:${SERVER_PORT:-8091}/api/oa/health}"
GITEA_BASE_URL="${OA_UPDATE_GITEA_BASE_URL:-}"
REPOSITORY="${OA_UPDATE_REPOSITORY:-awaioi/ERP}"
CHANNEL="${OA_UPDATE_CHANNEL:-stable}"
TOKEN="${OA_UPDATE_TOKEN:-}"
ALLOW_INSECURE="${OA_UPDATE_ALLOW_INSECURE_HTTP:-false}"
PUBLIC_KEY_FILE="${ERP_UPDATE_PUBLIC_KEY_FILE:-$INSTALL_ROOT/config/release-public-key.pem}"
REQUIRE_SIGNATURE="${ERP_UPDATE_REQUIRE_SIGNATURE:-true}"
BACKUP_MODE="${ERP_UPDATE_BACKUP_MODE:-none}"
HEALTH_TIMEOUT_SECONDS="${ERP_UPDATE_HEALTH_TIMEOUT_SECONDS:-120}"
HEALTH_POLL_SECONDS="${ERP_UPDATE_HEALTH_POLL_SECONDS:-2}"
TMP_DIR=""
LOCK_FILE="$RUN_DIR/update.lock"
FINAL_STATE=0
say() { printf '[ERP Update] %s\n' "$*"; }
write_state() {
local phase="$1" progress="$2" message="$3" version="${4:-}" error="${5:-}"
mkdir -p "$(dirname "$STATE_FILE")"
python3 - "$STATE_FILE" "$phase" "$progress" "$message" "$version" "$error" <<'PY'
import json, os, sys, tempfile
from datetime import datetime, timezone
path, phase, progress, message, version, error = sys.argv[1:]
payload = {
"phase": phase,
"progress": int(progress),
"message": message,
"version": version or None,
"error": error or None,
"updatedAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
}
parent = os.path.dirname(path) or "."
fd, tmp = tempfile.mkstemp(prefix=".update-state-", dir=parent, text=True)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(payload, handle, ensure_ascii=False, separators=(",", ":"))
handle.write("\n")
os.chmod(tmp, 0o640)
os.replace(tmp, path)
finally:
if os.path.exists(tmp):
os.unlink(tmp)
PY
}
cleanup() {
if [[ -n "$TMP_DIR" && -d "$TMP_DIR" ]]; then
rm -rf "$TMP_DIR"
fi
}
on_exit() {
local code=$?
if [[ "$code" != "0" && "$FINAL_STATE" != "1" ]]; then
write_state FAILED 0 "更新失败" "${TARGET_VERSION:-}" "更新助手异常退出(code $code" || true
fi
cleanup
}
trap on_exit EXIT
fail() {
local message="$1"
write_state FAILED "${2:-0}" "更新失败" "${TARGET_VERSION:-}" "$message" || true
FINAL_STATE=1
printf '[ERP Update] ERROR: %s\n' "$message" >&2
exit 1
}
require_command() {
command -v "$1" >/dev/null 2>&1 || fail "缺少命令:$1"
}
validate_https_url() {
local value="$1"
case "$value" in
https://*) return 0 ;;
http://*) [[ "$ALLOW_INSECURE" == "true" || "$ALLOW_INSECURE" == "1" ]] && return 0 ;;
esac
fail "更新地址必须使用 HTTPS"
}
acquire_lock_or_reexec() {
[[ "${ERP_UPDATE_LOCK_HELD:-0}" == "1" ]] && return 0
mkdir -p "$RUN_DIR"
command -v python3 >/dev/null 2>&1 || {
printf '[ERP Update] ERROR: 缺少命令:python3\n' >&2
exit 1
}
exec python3 - "$LOCK_FILE" "$0" "$@" <<'PY'
import fcntl
import os
import shutil
import sys
import time
lock_path, script, *args = sys.argv[1:]
if os.path.isdir(lock_path):
owner = ""
try:
with open(os.path.join(lock_path, "pid"), encoding="ascii") as handle:
owner = handle.read().strip()
except OSError:
pass
alive = False
if owner.isdigit():
try:
os.kill(int(owner), 0)
alive = True
except OSError:
pass
try:
recent = time.time() - os.stat(lock_path).st_mtime < 30
except OSError:
recent = False
if alive or recent:
raise SystemExit(f"已有更新任务正在执行(PID {owner or 'unknown'}")
try:
shutil.rmtree(lock_path)
except FileNotFoundError:
pass
lock = open(lock_path, "a+", encoding="ascii")
try:
fcntl.flock(lock.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
lock.seek(0)
owner = lock.read().strip() or "unknown"
raise SystemExit(f"已有更新任务正在执行(PID {owner}")
lock.seek(0)
lock.truncate()
lock.write(str(os.getpid()) + "\n")
lock.flush()
os.fchmod(lock.fileno(), 0o640)
os.set_inheritable(lock.fileno(), True)
env = os.environ.copy()
env["ERP_UPDATE_LOCK_HELD"] = "1"
script = os.path.abspath(script)
os.execve(script, [script, *args], env)
PY
}
create_curl_config() {
CURL_CONFIG="$TMP_DIR/curl.conf"
{
printf 'silent\nshow-error\nfail\nlocation\n'
printf 'connect-timeout = 15\nmax-time = 900\n'
if [[ "$ALLOW_INSECURE" == "true" || "$ALLOW_INSECURE" == "1" ]]; then
printf 'proto = "=http,https"\nproto-redir = "=http,https"\n'
else
printf 'proto = "=https"\nproto-redir = "=https"\n'
fi
if [[ -n "$TOKEN" ]]; then
[[ "$TOKEN" =~ ^[A-Za-z0-9._-]+$ ]] || fail "Gitea Token 格式无效"
printf 'header = "Authorization: token %s"\n' "$TOKEN"
fi
} > "$CURL_CONFIG"
chmod 600 "$CURL_CONFIG"
}
download() {
local url="$1" output="$2"
validate_https_url "$url"
curl --config "$CURL_CONFIG" --output "$output" "$url"
}
select_release() {
local release_json="$1" requested="$2" output="$3"
python3 - "$release_json" "$requested" "$CHANNEL" > "$output" <<'PY'
import json, re, sys
path, requested, channel = sys.argv[1:]
with open(path, encoding="utf-8") as handle:
release = json.load(handle)
tag = str(release.get("tag_name") or "").strip()
match = re.fullmatch(r"v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?)", tag)
if not match:
raise SystemExit("invalid release tag")
version = match.group(1)
if requested and requested.removeprefix("v") != version:
raise SystemExit("requested version is no longer latest")
if release.get("draft"):
raise SystemExit("latest release is a draft")
if channel.lower() == "stable" and release.get("prerelease"):
raise SystemExit("prerelease rejected on stable channel")
if channel.lower() == "stable" and "-" in version.split("+", 1)[0]:
raise SystemExit("prerelease tag rejected on stable channel")
assets = {str(item.get("name")): str(item.get("browser_download_url") or "") for item in release.get("assets", [])}
names = [f"kaidi-erp-{version}.tar.gz", "SHA256SUMS", "SHA256SUMS.sig"]
urls = [assets.get(name, "") for name in names]
if any(not value or any(ch.isspace() for ch in value) for value in urls):
raise SystemExit("release assets are incomplete")
print("\t".join([version, *urls]))
PY
}
verify_signature_and_checksum() {
local archive="$1" sums="$2" signature="$3" archive_name="$4"
write_state VERIFYING 45 "正在校验发布签名" "$TARGET_VERSION"
if [[ "$REQUIRE_SIGNATURE" == "true" || "$REQUIRE_SIGNATURE" == "1" ]]; then
[[ -r "$PUBLIC_KEY_FILE" ]] || fail "找不到发布公钥" 45
require_command openssl
local openssl_version algorithms
openssl_version="$(openssl version 2>/dev/null || true)"
[[ "$openssl_version" =~ ^OpenSSL[[:space:]]3\. ]] \
|| fail "签名验证需要 OpenSSL 3" 45
algorithms="$(openssl list -public-key-algorithms 2>/dev/null || true)"
grep -qi ED25519 <<< "$algorithms" \
|| fail "当前 OpenSSL 不支持 Ed25519" 45
openssl pkeyutl -verify -rawin -pubin -inkey "$PUBLIC_KEY_FILE" \
-sigfile "$signature" -in "$sums" >/dev/null \
|| fail "Release Ed25519 签名验证失败" 45
fi
local expected actual
expected="$(python3 - "$sums" "$archive_name" <<'PY'
import re, sys
path, wanted = sys.argv[1:]
for line in open(path, encoding="utf-8"):
match = re.fullmatch(r"([0-9a-fA-F]{64})\s+\*?(.+?)\s*", line)
if match and match.group(2) == wanted:
print(match.group(1).lower())
break
PY
)"
[[ "$expected" =~ ^[0-9a-f]{64}$ ]] || fail "SHA256SUMS 缺少安装包校验值" 50
if command -v sha256sum >/dev/null 2>&1; then
actual="$(sha256sum "$archive" | awk '{print $1}')"
else
actual="$(shasum -a 256 "$archive" | awk '{print $1}')"
fi
[[ "$actual" == "$expected" ]] || fail "安装包 SHA-256 校验失败" 50
}
extract_archive() {
local archive="$1" destination="$2"
python3 - "$archive" <<'PY'
import pathlib, sys, tarfile
with tarfile.open(sys.argv[1], "r:gz") as archive:
for member in archive.getmembers():
path = pathlib.PurePosixPath(member.name)
if path.is_absolute() or ".." in path.parts or member.issym() or member.islnk():
raise SystemExit(f"unsafe archive member: {member.name}")
PY
mkdir -p "$destination"
tar -xzf "$archive" -C "$destination"
}
atomic_switch() {
local target="$1"
python3 - "$CURRENT_LINK" "$target" <<'PY'
import os, sys
link, target = sys.argv[1:]
tmp = f"{link}.new-{os.getpid()}"
try:
os.symlink(target, tmp)
os.replace(tmp, link)
finally:
if os.path.lexists(tmp):
os.unlink(tmp)
PY
}
wait_for_restarted_health() {
local old_pid="$1" deadline=$((SECONDS + HEALTH_TIMEOUT_SECONDS))
while (( SECONDS < deadline )); do
local new_pid=""
[[ -r "$RUN_DIR/app.pid" ]] && new_pid="$(<"$RUN_DIR/app.pid")"
if [[ "$new_pid" =~ ^[0-9]+$ && "$new_pid" != "$old_pid" ]] \
&& kill -0 "$new_pid" 2>/dev/null \
&& curl -fsS --connect-timeout 2 --max-time 5 "$HEALTH_URL" >/dev/null 2>&1; then
return 0
fi
sleep "$HEALTH_POLL_SECONDS"
done
return 1
}
wait_for_pid_exit() {
local pid="$1" deadline=$((SECONDS + ${2:-60}))
while kill -0 "$pid" 2>/dev/null; do
(( SECONDS < deadline )) || return 1
sleep 1
done
}
clear_pid_file() {
local expected="$1" current=""
[[ -r "$RUN_DIR/app.pid" ]] && current="$(<"$RUN_DIR/app.pid")"
if [[ "$current" == "$expected" ]] && ! kill -0 "$expected" 2>/dev/null; then
rm -f "$RUN_DIR/app.pid"
fi
}
terminate_application_pid() {
local pid="$1"
[[ "$pid" =~ ^[0-9]+$ && "$pid" != "$$" ]] || return 0
if kill -0 "$pid" 2>/dev/null; then
kill -TERM "$pid" 2>/dev/null || return 1
wait_for_pid_exit "$pid" 60 || return 1
fi
clear_pid_file "$pid"
}
stop_application() {
local pid=""
[[ -r "$RUN_DIR/app.pid" ]] && pid="$(<"$RUN_DIR/app.pid")"
[[ "$pid" =~ ^[0-9]+$ ]] || pid="${ERP_APP_PID:-}"
[[ "$pid" =~ ^[0-9]+$ ]] || fail "无法读取 ERP 服务 PID" 75
[[ "$pid" != "$$" ]] || fail "拒绝停止更新助手自身" 75
terminate_application_pid "$pid" || fail "ERP 服务 PID $pid 未能在 60 秒内停止" 75
printf '%s\n' "$pid"
}
backup_postgres_if_enabled() {
[[ "$BACKUP_MODE" == "pg_dump" ]] || return 0
require_command pg_dump
local backup_dir="$INSTALL_ROOT/backups"
mkdir -p "$backup_dir"
local output="$backup_dir/pre-${TARGET_VERSION}-$(date -u +%Y%m%dT%H%M%SZ).dump"
write_state INSTALLING 60 "正在创建 PostgreSQL 逻辑备份" "$TARGET_VERSION"
PGPASSWORD="${OA_DB_PASSWORD:-}" PGSSLMODE="${ERP_PGSSLMODE:-prefer}" pg_dump \
--host="${ERP_PGHOST:-127.0.0.1}" \
--port="${ERP_PGPORT:-5432}" \
--username="${OA_DB_USERNAME:-}" \
--dbname="${ERP_PGDATABASE:-oa}" \
--format=custom --file="$output" \
|| fail "PostgreSQL 备份失败,已取消更新" 60
chmod 600 "$output"
}
install_release() {
local requested="${1:-}"
validate_https_url "$GITEA_BASE_URL"
[[ "$HEALTH_TIMEOUT_SECONDS" =~ ^[1-9][0-9]*$ ]] \
|| fail "健康检查超时时间配置无效"
[[ "$HEALTH_POLL_SECONDS" =~ ^[1-9][0-9]*$ ]] \
|| fail "健康检查间隔配置无效"
[[ "$REPOSITORY" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]] || fail "更新仓库配置无效"
require_command curl
require_command python3
require_command tar
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/kaidi-erp-update.XXXXXX")"
create_curl_config
write_state CHECKING 5 "正在读取 Gitea Release" "$requested"
local owner="${REPOSITORY%%/*}" repo="${REPOSITORY#*/}"
local api_url="${GITEA_BASE_URL%/}/api/v1/repos/$owner/$repo/releases/latest"
local release_json="$TMP_DIR/release.json"
download "$api_url" "$release_json"
local selection="$TMP_DIR/selection"
select_release "$release_json" "$requested" "$selection" || fail "Release 元数据验证失败" 10
local archive_url sums_url signature_url
IFS=$'\t' read -r TARGET_VERSION archive_url sums_url signature_url < "$selection"
local archive_name="kaidi-erp-${TARGET_VERSION}.tar.gz"
local archive="$TMP_DIR/$archive_name" sums="$TMP_DIR/SHA256SUMS" signature="$TMP_DIR/SHA256SUMS.sig"
write_state DOWNLOADING 20 "正在下载版本 ${TARGET_VERSION}" "$TARGET_VERSION"
download "$archive_url" "$archive"
download "$sums_url" "$sums"
download "$signature_url" "$signature"
verify_signature_and_checksum "$archive" "$sums" "$signature" "$archive_name"
write_state INSTALLING 55 "正在准备版本 ${TARGET_VERSION}" "$TARGET_VERSION"
local unpack="$TMP_DIR/unpack"
extract_archive "$archive" "$unpack" || fail "安装包结构验证失败" 55
local source="$unpack/kaidi-erp-${TARGET_VERSION}"
[[ -r "$source/app/kaidi-erp.jar" && -x "$source/bin/erp-run" && -x "$source/bin/erp-update" ]] \
|| fail "安装包缺少运行文件" 55
[[ "$(<"$source/VERSION")" == "$TARGET_VERSION" ]] || fail "安装包版本与 Release 不一致" 55
python3 - "$source/manifest.json" "$TARGET_VERSION" <<'PY' || fail "版本清单不允许自动回滚" 55
import json, sys
manifest = json.load(open(sys.argv[1], encoding="utf-8"))
if manifest.get("version") != sys.argv[2] or manifest.get("database") != "postgresql":
raise SystemExit(1)
if manifest.get("rollbackCompatible") is not True:
raise SystemExit(1)
PY
mkdir -p "$RELEASES_DIR"
local release_dir="$RELEASES_DIR/$TARGET_VERSION"
if [[ -r "$CURRENT_LINK/VERSION" && "$(<"$CURRENT_LINK/VERSION")" == "$TARGET_VERSION" ]]; then
fail "目标版本已经安装" 55
fi
local replaced=""
if [[ -e "$release_dir" ]]; then
replaced="${release_dir}.replaced-$$"
mv "$release_dir" "$replaced"
fi
if ! mv "$source" "$release_dir"; then
[[ -z "$replaced" || ! -e "$replaced" ]] || mv "$replaced" "$release_dir"
fail "无法写入目标版本目录" 55
fi
[[ -z "$replaced" ]] || rm -rf "$replaced"
backup_postgres_if_enabled
local previous=""
[[ -L "$CURRENT_LINK" ]] && previous="$(readlink "$CURRENT_LINK")"
[[ -n "$previous" ]] || fail "当前版本链接不存在,拒绝无回滚点更新" 70
atomic_switch "releases/$TARGET_VERSION"
write_state RESTARTING 80 "正在重启 ERP 服务" "$TARGET_VERSION"
local old_pid
old_pid="$(stop_application)"
if wait_for_restarted_health "$old_pid"; then
write_state SUCCEEDED 100 "更新完成" "$TARGET_VERSION"
FINAL_STATE=1
say "updated to $TARGET_VERSION"
return 0
fi
write_state ROLLING_BACK 90 "新版本健康检查失败,正在回滚" "$TARGET_VERSION"
atomic_switch "$previous"
local failed_pid=""
[[ -r "$RUN_DIR/app.pid" ]] && failed_pid="$(<"$RUN_DIR/app.pid")"
terminate_application_pid "$failed_pid" || true
if wait_for_restarted_health "$failed_pid"; then
write_state ROLLED_BACK 100 "新版本不可用,已恢复上一版本" "$TARGET_VERSION" "健康检查失败"
FINAL_STATE=1
exit 1
fi
fail "新版本与回滚版本均未通过健康检查,需要人工处理" 100
}
main() {
local command="${1:-}" version="${2:-}"
case "$command" in
install)
acquire_lock_or_reexec "$@"
install_release "$version"
;;
*) printf 'usage: erp-update install <version>\n' >&2; exit 2 ;;
esac
}
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
main "$@"
fi
+3
View File
@@ -0,0 +1,3 @@
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAaErhcY8WZIZvPILmYnfjndVBAdOuWkvhaoHIWqNNdxI=
-----END PUBLIC KEY-----
+96
View File
@@ -0,0 +1,96 @@
# 在线安装与更新
Kaidi ERP 的生产安装不依赖 Docker。安装器支持 64 位 Linux 和 macOS,生产数据库固定为 PostgreSQL 15 或更高版本;生产 JAR 不包含 SQLite 运行库,SQLite 仅保留给源码目录下的本地开发和测试。
## 发布链路
推送 `v*` Git tag 后,[release.yml](../.gitea/workflows/release.yml) 会完成前端构建、Spring Boot 打包、Ed25519 签名,并在 Gitea 中创建或更新对应 Release。每个可安装 Release 必须包含:
- `kaidi-erp-<version>.tar.gz`
- `SHA256SUMS`
- `SHA256SUMS.sig`
Gitea Actions runner 需要预装 Java 17 或更高版本、Node.js/npm、Python 3、tar、curl 和 OpenSSL 3。
在仓库 Actions 设置中创建:
- Secret `RELEASE_GITEA_TOKEN`:具备当前仓库 Release 写权限的 Gitea token。
- Secret `RELEASE_PRIVATE_KEY_B64`Ed25519 私钥的单行 Base64 内容。
- Variable `GITEA_BASE_URL`:生产 Gitea 的 HTTPS 外部地址。
- Variable `GITEA_ALLOW_INSECURE_HTTP`:生产环境不要设置;当前 HTTP 测试服务器必须显式设为 `1`
本机现有签名私钥位于 `~/.config/kaidi-erp/release-signing-key.pem`,不得提交到 Git。macOS 可用以下命令生成 Secret 值:
```bash
base64 < ~/.config/kaidi-erp/release-signing-key.pem | tr -d '\n'
```
发布稳定版本:
```bash
git tag v0.2.0
git push origin v0.2.0
```
## 首次安装
推荐给 Gitea 配置 HTTPS 域名,然后执行:
```bash
curl -fsSL https://git.example.com/awaioi/ERP/raw/branch/main/install.sh \
| sudo -E bash -s -- \
--gitea-url https://git.example.com \
--repository awaioi/ERP
```
安装器会从终端读取现有 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
```
当前 `http://38.76.196.225:10099` 仅用于开发测试,必须同时传入 `--allow-insecure`。HTTP 会暴露安装脚本、Release 元数据和 Gitea token,不应作为生产部署方式。
## 在线更新
管理员进入“应用定制平台 -> 系统更新”,点击“检查更新”,确认版本和 Release notes 后执行更新。后端启动独立更新助手,更新助手会:
1. 下载三个 Release 资产并验证 Ed25519 签名和 SHA-256。
2. 拒绝路径穿越、符号链接和结构不完整的安装包。
3. 可选执行 `pg_dump`,再写入独立版本目录。
4. 原子切换 `current` 链接并终止旧进程,由 systemd 或 launchd 拉起新版本。
5. 等待健康检查;失败时切回上一版本并再次验证健康状态。
更新过程使用操作系统文件锁,同一安装目录同时只允许一个更新任务。手动触发可执行:
```bash
/opt/kaidi-erp/current/bin/erp-update install 0.2.0
```
在线更新依赖安装器注册的 systemd 或 launchd 服务来拉起新旧版本。使用 `--no-service` 时后台更新默认关闭;如由其他进程管理器接管,须先确认它会在 ERP 进程退出后自动重启,再手工启用 `OA_UPDATE_ENABLED=true`。健康检查默认最多等待 120 秒、每 2 秒轮询一次,可分别通过 `ERP_UPDATE_HEALTH_TIMEOUT_SECONDS``ERP_UPDATE_HEALTH_POLL_SECONDS` 调整。
Linux 默认目录:
- 程序:`/opt/kaidi-erp`
- 配置:`/etc/kaidi-erp/erp.env`
- 状态:`/var/lib/kaidi-erp/update-state.json`
- 服务:`kaidi-erp.service`
应用回滚不等于数据库回滚。发布包含不可逆 Flyway 迁移前,应先保证旧应用仍兼容新结构,并在安装时设置 `ERP_UPDATE_BACKUP_MODE=pg_dump`。数据库恢复仍需人工确认后使用 `pg_restore`,更新助手不会自动覆盖生产数据。
@@ -0,0 +1,516 @@
# ERP One-Command Launcher Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a Finder-double-clickable `run.command` that safely starts or reuses the ERP Spring Boot service and fixed-domain ngrok tunnel, opens the preview, and owns cleanup in one Terminal window.
**Architecture:** A Bash 3.2-compatible supervisor resolves all paths from its own location, exposes focused health/ownership functions for Shell tests, and executes `main` only when run directly. It records only child PIDs it creates, so signal cleanup cannot kill pre-existing Java or ngrok processes.
**Tech Stack:** macOS Bash, `curl`, `lsof`, Python 3 JSON parsing, project-bundled Temurin 17, ngrok 3.
---
## File map
- Create: `run.command` — user-facing launcher, readiness checks, process ownership, monitoring, and cleanup.
- Create: `tests/run-command.test.sh` — dependency-free Shell regression suite that sources launcher functions in isolated subshells.
- Existing reference only: `docs/superpowers/specs/2026-07-15-run-command-design.md` — approved behavioral contract.
### Task 1: Establish source-safe launcher structure
**Files:**
- Create: `tests/run-command.test.sh`
- Create: `run.command`
- [x] **Step 1: Write the failing path-resolution test**
Create the initial test runner with a test that sources the launcher from `/tmp` and verifies that it resolves the ERP root without starting services:
```bash
#!/bin/bash
set -uo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
SCRIPT="$PROJECT_ROOT/run.command"
PASS=0
FAIL=0
run_test() {
local name="$1"
shift
if ("$@"); then
printf 'PASS %s\n' "$name"
PASS=$((PASS + 1))
else
printf 'FAIL %s\n' "$name"
FAIL=$((FAIL + 1))
fi
}
test_resolves_root_when_sourced_elsewhere() (
[ -r "$SCRIPT" ] || return 1
cd /tmp || return 1
source "$SCRIPT"
[ "$ROOT_DIR" = "$PROJECT_ROOT" ]
[ -z "$BACKEND_PID" ]
[ -z "$NGROK_PID" ]
)
run_test 'resolves project root when sourced elsewhere' test_resolves_root_when_sourced_elsewhere
printf 'RESULT pass=%s fail=%s\n' "$PASS" "$FAIL"
[ "$FAIL" -eq 0 ]
```
- [x] **Step 2: Run the test and verify RED**
Run:
```bash
bash tests/run-command.test.sh
```
Expected: exit non-zero with `FAIL resolves project root when sourced elsewhere` because `run.command` does not exist.
- [x] **Step 3: Add the minimal source-safe launcher skeleton**
Create `run.command` with project-relative constants, empty ownership state, and a direct-execution guard:
```bash
#!/bin/bash
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
ROOT_DIR="${ERP_RUN_ROOT_DIR:-$SCRIPT_DIR}"
BACKEND_DIR="${ERP_RUN_BACKEND_DIR:-$ROOT_DIR/oa-backend}"
JAVA_BIN="${ERP_RUN_JAVA_BIN:-$ROOT_DIR/.jdks/jdk-17.0.19+10/Contents/Home/bin/java}"
JAR_PATH="${ERP_RUN_JAR_PATH:-$BACKEND_DIR/build/libs/oa-backend-0.1.0.jar}"
BACKEND_PORT="${ERP_RUN_BACKEND_PORT:-8091}"
NGROK_API_PORT="${ERP_RUN_NGROK_API_PORT:-4040}"
LOCAL_URL="http://127.0.0.1:$BACKEND_PORT"
PUBLIC_URL="${ERP_RUN_PUBLIC_URL:-https://resonant-elated-launder.ngrok-free.dev}"
NGROK_TARGET="http://localhost:$BACKEND_PORT"
LOG_DIR="${ERP_RUN_LOG_DIR:-${TMPDIR:-/tmp}/kaidi-erp-run}"
BACKEND_LOG="$LOG_DIR/backend.log"
NGROK_LOG="$LOG_DIR/ngrok.log"
BACKEND_PID=""
NGROK_PID=""
CLEANED_UP=0
main() {
return 0
}
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
main "$@"
fi
```
- [x] **Step 4: Run syntax and source tests and verify GREEN**
Run:
```bash
bash -n run.command
bash tests/run-command.test.sh
```
Expected: both commands exit 0 and the test reports `RESULT pass=1 fail=0`.
### Task 2: Add safe backend reuse and owned-process cleanup
**Files:**
- Modify: `tests/run-command.test.sh`
- Modify: `run.command`
- [x] **Step 1: Add failing backend and cleanup tests**
Insert these tests before the runner calls, then add their `run_test` calls:
```bash
test_reuses_healthy_backend() (
source "$SCRIPT"
backend_is_healthy() { return 0; }
port_listener_pid() { printf '4242\n'; }
start_backend() { return 77; }
ensure_backend
[ -z "$BACKEND_PID" ]
)
test_rejects_unhealthy_port_without_killing_owner() (
source "$SCRIPT"
sleep 30 &
local external_pid=$!
trap 'kill "$external_pid" 2>/dev/null || true' EXIT
backend_is_healthy() { return 1; }
port_listener_pid() { printf '%s\n' "$external_pid"; }
if ensure_backend; then return 1; fi
kill -0 "$external_pid" 2>/dev/null
)
test_cleanup_stops_owned_pid_only() (
source "$SCRIPT"
sleep 30 &
BACKEND_PID=$!
sleep 30 &
local external_pid=$!
cleanup
if kill -0 "$BACKEND_PID" 2>/dev/null; then return 1; fi
kill -0 "$external_pid" 2>/dev/null || return 1
kill "$external_pid" 2>/dev/null || true
)
run_test 'reuses a healthy backend' test_reuses_healthy_backend
run_test 'rejects a foreign 8091 listener without killing it' test_rejects_unhealthy_port_without_killing_owner
run_test 'cleanup stops owned PID only' test_cleanup_stops_owned_pid_only
```
- [x] **Step 2: Run the tests and verify RED**
Run `bash tests/run-command.test.sh`.
Expected: the original path test passes; new tests fail because `ensure_backend` and `cleanup` are undefined.
- [x] **Step 3: Implement minimal backend and ownership functions**
Add these functions above `main` in `run.command`:
```bash
say() { printf '[ERP] %s\n' "$*"; }
fail() { printf '[ERP] ERROR: %s\n' "$*" >&2; return 1; }
port_listener_pid() {
lsof -nP -iTCP:"$1" -sTCP:LISTEN -t 2>/dev/null | head -n 1
}
backend_is_healthy() {
local body
body="$(curl -fsS --connect-timeout 1 --max-time 3 "$LOCAL_URL/" 2>/dev/null)" || return 1
[[ "$body" == *'<title>凯迪协同办公平台</title>'* ]]
}
login_is_healthy() {
curl -fsS --connect-timeout 1 --max-time 5 \
-H 'Content-Type: application/json' \
-d '{"loginName":"admin","password":"123456"}' \
"$LOCAL_URL/api/oa/auth/login" 2>/dev/null \
| python3 -c 'import json,sys; d=json.load(sys.stdin); raise SystemExit(0 if d.get("code")==0 and d.get("data",{}).get("token") else 1)'
}
start_backend() {
: > "$BACKEND_LOG"
(
cd "$BACKEND_DIR" || exit 1
exec "$JAVA_BIN" -jar "$JAR_PATH" --server.port="$BACKEND_PORT"
) >> "$BACKEND_LOG" 2>&1 &
BACKEND_PID=$!
}
wait_for_backend() {
local deadline=$((SECONDS + 60))
while [ "$SECONDS" -lt "$deadline" ]; do
if backend_is_healthy && login_is_healthy; then return 0; fi
if [ -n "$BACKEND_PID" ] && ! kill -0 "$BACKEND_PID" 2>/dev/null; then return 1; fi
sleep 1
done
return 1
}
ensure_backend() {
local listener
listener="$(port_listener_pid "$BACKEND_PORT" || true)"
if [ -n "$listener" ]; then
if ! backend_is_healthy; then
fail "端口 $BACKEND_PORT 已被非本项目服务占用(PID $listener"
return 1
fi
say "复用已运行的 ERP 服务(PID $listener"
return 0
fi
say "启动 ERP 服务..."
start_backend || return 1
if ! wait_for_backend; then
tail -n 40 "$BACKEND_LOG" >&2 || true
fail "ERP 服务未在 60 秒内就绪"
fi
}
stop_owned_process() {
local pid="$1"
[ -n "$pid" ] || return 0
kill -0 "$pid" 2>/dev/null || return 0
kill "$pid" 2>/dev/null || true
local attempt=0
while kill -0 "$pid" 2>/dev/null && [ "$attempt" -lt 5 ]; do
sleep 1
attempt=$((attempt + 1))
done
if kill -0 "$pid" 2>/dev/null; then kill -9 "$pid" 2>/dev/null || true; fi
wait "$pid" 2>/dev/null || true
}
cleanup() {
[ "$CLEANED_UP" -eq 0 ] || return 0
CLEANED_UP=1
stop_owned_process "$NGROK_PID"
stop_owned_process "$BACKEND_PID"
}
```
- [x] **Step 4: Run the tests and verify GREEN**
Run `bash tests/run-command.test.sh`.
Expected: all four tests pass and no external test PID is terminated.
### Task 3: Add ngrok, browser gating, orchestration, and monitoring
**Files:**
- Modify: `tests/run-command.test.sh`
- Modify: `run.command`
- [x] **Step 1: Add failing orchestration tests**
Add these tests and runner calls:
```bash
test_reuses_healthy_ngrok() (
source "$SCRIPT"
ngrok_is_healthy() { return 0; }
port_listener_pid() { printf '5252\n'; }
start_ngrok() { return 78; }
ensure_ngrok
[ -z "$NGROK_PID" ]
)
test_no_open_mode_skips_browser() (
source "$SCRIPT"
local marker="${TMPDIR:-/tmp}/run-command-open-$$"
rm -f "$marker"
open() { : > "$marker"; }
ERP_RUN_NO_OPEN=1
maybe_open_browser
[ ! -e "$marker" ]
)
test_main_runs_steps_in_order() (
source "$SCRIPT"
local events="${TMPDIR:-/tmp}/run-command-events-$$"
: > "$events"
preflight() { printf 'preflight\n' >> "$events"; }
ensure_backend() { printf 'backend\n' >> "$events"; }
ensure_ngrok() { printf 'ngrok\n' >> "$events"; }
wait_for_public() { printf 'public\n' >> "$events"; }
maybe_open_browser() { printf 'open\n' >> "$events"; }
monitor_services() { printf 'monitor\n' >> "$events"; }
main
[ "$(tr '\n' ' ' < "$events")" = 'preflight backend ngrok public open monitor ' ]
)
run_test 'reuses a healthy ngrok tunnel' test_reuses_healthy_ngrok
run_test 'no-open mode skips browser launch' test_no_open_mode_skips_browser
run_test 'main orchestrates steps in order' test_main_runs_steps_in_order
```
- [x] **Step 2: Run the tests and verify RED**
Run `bash tests/run-command.test.sh`.
Expected: prior tests pass; the three new tests fail because ngrok/orchestration functions are not implemented.
- [x] **Step 3: Implement preflight, ngrok, public verification, and main**
Add the following functions above `main`, then replace the empty `main`:
```bash
resolve_ngrok_bin() {
if [ -n "${ERP_RUN_NGROK_BIN:-}" ]; then printf '%s\n' "$ERP_RUN_NGROK_BIN"; return; fi
if [ -x "$HOME/bin/ngrok" ]; then printf '%s\n' "$HOME/bin/ngrok"; return; fi
command -v ngrok 2>/dev/null || return 1
}
preflight() {
command -v curl >/dev/null || { fail '缺少 curl'; return 1; }
command -v lsof >/dev/null || { fail '缺少 lsof'; return 1; }
command -v python3 >/dev/null || { fail '缺少 python3'; return 1; }
[ -x "$JAVA_BIN" ] || { fail "找不到项目 JDK$JAVA_BIN"; return 1; }
[ -r "$JAR_PATH" ] || { fail "找不到可运行 JAR$JAR_PATH"; return 1; }
[ -r "$BACKEND_DIR/data/oa.db" ] || { fail "找不到 SQLite 数据库:$BACKEND_DIR/data/oa.db"; return 1; }
NGROK_BIN="$(resolve_ngrok_bin)" || { fail '找不到 ngrok(预期 $HOME/bin/ngrok 或 PATH'; return 1; }
mkdir -p "$LOG_DIR" || { fail "无法创建日志目录:$LOG_DIR"; return 1; }
}
ngrok_is_healthy() {
local payload
payload="$(curl -fsS --connect-timeout 1 --max-time 3 "http://127.0.0.1:$NGROK_API_PORT/api/tunnels" 2>/dev/null)" || return 1
python3 -c 'import json,sys; d=json.load(sys.stdin); pub,target=sys.argv[1:3]; raise SystemExit(0 if any(t.get("public_url")==pub and t.get("config",{}).get("addr")==target for t in d.get("tunnels",[])) else 1)' \
"$PUBLIC_URL" "$NGROK_TARGET" <<< "$payload"
}
start_ngrok() {
: > "$NGROK_LOG"
"$NGROK_BIN" http --url=resonant-elated-launder.ngrok-free.dev "$BACKEND_PORT" \
--log=stdout --log-format=json >> "$NGROK_LOG" 2>&1 &
NGROK_PID=$!
}
wait_for_ngrok() {
local deadline=$((SECONDS + 30))
while [ "$SECONDS" -lt "$deadline" ]; do
ngrok_is_healthy && return 0
if [ -n "$NGROK_PID" ] && ! kill -0 "$NGROK_PID" 2>/dev/null; then return 1; fi
sleep 1
done
return 1
}
ensure_ngrok() {
if ngrok_is_healthy; then
say '复用已运行的 ngrok 隧道'
return 0
fi
local listener
listener="$(port_listener_pid "$NGROK_API_PORT" || true)"
if [ -n "$listener" ]; then
fail "端口 $NGROK_API_PORT 已有其他 ngrok/服务(PID $listener),但固定隧道不匹配"
return 1
fi
say '启动 ngrok...'
start_ngrok || return 1
if ! wait_for_ngrok; then
tail -n 40 "$NGROK_LOG" >&2 || true
fail 'ngrok 未在 30 秒内建立固定隧道'
fi
}
public_is_healthy() {
local body
body="$(curl -fsS --connect-timeout 3 --max-time 10 -H 'ngrok-skip-browser-warning: true' "$PUBLIC_URL/" 2>/dev/null)" || return 1
[[ "$body" == *'<title>凯迪协同办公平台</title>'* ]]
}
wait_for_public() {
local deadline=$((SECONDS + 30))
while [ "$SECONDS" -lt "$deadline" ]; do
public_is_healthy && return 0
sleep 1
done
fail '公网地址未在 30 秒内可访问'
}
maybe_open_browser() {
[ "${ERP_RUN_NO_OPEN:-0}" = '1' ] && return 0
command -v open >/dev/null || { fail '找不到 macOS open 命令'; return 1; }
open "$PUBLIC_URL"
}
monitor_services() {
local backend_active ngrok_active
backend_active="$(port_listener_pid "$BACKEND_PORT" || true)"
ngrok_active="$(port_listener_pid "$NGROK_API_PORT" || true)"
say "本地:$LOCAL_URL"
say "公网:$PUBLIC_URL"
say "进程:ERP PID ${backend_active:-未知}ngrok PID ${ngrok_active:-未知}"
say "日志:$BACKEND_LOG$NGROK_LOG"
say '运行中;按 Ctrl+C 同时停止本次启动的服务。'
while :; do
if ! backend_is_healthy; then
[ -n "$BACKEND_PID" ] && tail -n 40 "$BACKEND_LOG" >&2 || true
fail 'ERP 服务失去响应'
return 1
fi
if ! ngrok_is_healthy; then
[ -n "$NGROK_PID" ] && tail -n 40 "$NGROK_LOG" >&2 || true
fail 'ngrok 隧道失去响应'
return 1
fi
sleep 5
done
}
handle_signal() {
printf '\n'
say '正在停止本次启动的服务...'
cleanup
exit 0
}
main() {
trap handle_signal HUP INT TERM
trap cleanup EXIT
say '检查运行环境...'
preflight || return 1
ensure_backend || return 1
ensure_ngrok || return 1
wait_for_public || return 1
maybe_open_browser || return 1
monitor_services
}
```
- [x] **Step 4: Run the complete unit suite and verify GREEN**
Run:
```bash
bash -n run.command
bash -n tests/run-command.test.sh
bash tests/run-command.test.sh
```
Expected: syntax checks exit 0; test suite reports seven passing tests and zero failures.
### Task 4: Verify executable behavior against live services
**Files:**
- Modify mode: `run.command`
- Modify mode: `tests/run-command.test.sh`
- [x] **Step 1: Mark both scripts executable**
Run:
```bash
chmod +x run.command tests/run-command.test.sh
```
- [x] **Step 2: Verify repeat-start safety while services already run**
Run the launcher with browser opening disabled, wait for `运行中`, send `INT`, and verify the pre-existing Java/ngrok PIDs still listen:
```bash
ERP_RUN_NO_OPEN=1 ./run.command
```
Expected: output says both services are reused; after `Ctrl+C`, ports `8091` and `4040` remain listening.
- [x] **Step 3: Verify cold start and owned cleanup**
Stop only the known current ERP/ngrok sessions, run `ERP_RUN_NO_OPEN=1 ./run.command`, and verify local/public behavior:
```bash
curl -fsS http://127.0.0.1:8091/ | grep -F '<title>凯迪协同办公平台</title>'
curl -fsS -H 'ngrok-skip-browser-warning: true' https://resonant-elated-launder.ngrok-free.dev/ | grep -F '<title>凯迪协同办公平台</title>'
```
Then post `admin/123456`, use the returned token for `/api/oa/dev-projects`, and require API `code=0`. Send `Ctrl+C` and verify both launcher-owned listeners disappear.
- [x] **Step 4: Start the final user-facing instance**
Run `./run.command` normally in a persistent terminal session.
Expected: the public URL opens, both listeners remain active, and the terminal shows the stop instruction.
- [x] **Step 5: Commit the implementation with Lore trailers**
Stage only the two implementation files and this plan:
```bash
git add run.command tests/run-command.test.sh docs/superpowers/plans/2026-07-15-run-command.md
git commit -m "Make local ERP previews one double-click away" -m "Constraint: Finder launch must supervise Java and ngrok in one visible Terminal
Rejected: LaunchAgent | obscures ownership and makes safe cleanup harder
Confidence: high
Scope-risk: narrow
Directive: Never kill listeners not recorded as launcher-owned PIDs
Tested: Shell unit suite, repeat-start reuse, cold start, local/public login and protected API smoke
Not-tested: Finder Gatekeeper behavior on a different Mac"
```
@@ -0,0 +1,84 @@
# `run.command` 一键启动器设计
日期:2026-07-15
状态:用户已确认设计方向,等待书面规格复核
## 目标
在 macOS Finder 中双击项目根目录的 `run.command`,用一个持续打开的终端窗口启动并监督当前 ERP 主链路:
- Spring Boot 单体应用监听 `8091`,同时提供 Vue 前端和 `/api/oa/*` 后端。
- ngrok 固定域名 `https://resonant-elated-launder.ngrok-free.dev` 转发到 `8091`
- 两项服务就绪后自动打开公网地址。
- 用户按 `Ctrl+C` 或关闭终端时,只停止本次脚本启动的进程。
不启动已弃用的 OFBiz、独立 Vite 开发服务器、PostgreSQL 或 nginx;当前可交付预览已包含在 Spring Boot JAR 中。
## 启动体验
1. 脚本以自身所在目录作为项目根目录,不依赖 Finder/Terminal 的当前工作目录。
2. 终端逐步显示“环境检查、后端启动、ngrok 启动、公网验证、运行中”。
3. 后端通常约 15 秒就绪;脚本按真实 HTTP 状态等待,不使用固定时长假定成功。
4. 成功后显示本地地址、公网地址、日志路径和停止方法,并调用 macOS `open` 打开公网地址。
5. 终端保持运行并监控服务;任一由脚本启动的子进程意外退出时,脚本报告错误并输出对应日志末尾。
## 组件与流程
### 1. 环境与路径
- 项目根目录:由 `run.command` 的绝对路径推导。
- 后端工作目录:固定为 `<项目>/oa-backend`,确保相对数据源 `./data/oa.db` 始终指向 `oa-backend/data/oa.db`
- Java:优先使用项目内 `.jdks/jdk-17.0.19+10/Contents/Home/bin/java`
- JAR`oa-backend/build/libs/oa-backend-0.1.0.jar`
- ngrok:优先使用 `$HOME/bin/ngrok`,否则回退到 `PATH` 中的 `ngrok`
- 日志:写入 `${TMPDIR:-/tmp}/kaidi-erp-run/`,不污染 Git 工作区。
缺少 Java、JAR、ngrok、`curl``lsof``python3` 时立即给出可操作错误并退出。
### 2. 后端复用与启动
-`8091` 无监听进程,脚本从 `oa-backend` 目录启动 Java,并记录为“本脚本拥有”。
-`8091` 已监听,脚本请求根路径并核对页面标题“凯迪协同办公平台”:匹配则复用;不匹配则报端口冲突,不杀进程。
- 启动后最多等待 60 秒,要求首页 HTTP 200 且登录接口能返回标准 JSON;超时或进程提前退出时显示后端日志末尾。
### 3. ngrok 复用与启动
- 查询本地 ngrok API `127.0.0.1:4040/api/tunnels`
- 若已有固定公网域名且目标为 `http://localhost:8091`,直接复用,不再启动第二个 ngrok。
-`4040` 被占用但不存在正确隧道,安全报错,不覆盖现有隧道。
- 否则启动 `ngrok http --url=resonant-elated-launder.ngrok-free.dev 8091`,记录为“本脚本拥有”,最多等待 30 秒确认隧道登记成功。
### 4. 公网验证与浏览器
- 使用 `ngrok-skip-browser-warning: true` 请求公网首页,必须返回 HTTP 200 且标题正确。
- 验证成功后自动打开公网地址。
- 环境变量 `ERP_RUN_NO_OPEN=1` 可禁止自动打开,供测试或无界面环境使用。
### 5. 生命周期与清理
- `INT``TERM``EXIT` 使用同一清理函数。
- 只向脚本保存的 Java/ngrok PID 发送 `TERM`,等待短时间后才对仍未退出的自有 PID 使用 `KILL`
- 复用的既有服务 PID 不写入“自有 PID”,因此 `Ctrl+C` 不会误杀它们。
- 运行阶段周期性检查本地首页和公网隧道;异常时提示并保留日志证据。
## 可测试性
脚本使用 Bash 函数组织,并仅在直接执行时进入 `main`;测试可 `source run.command` 后单独验证函数。
新增 `tests/run-command.test.sh`,覆盖:
1. `bash -n run.command` 语法检查。
2. 脚本从任意当前目录都能解析正确项目根目录。
3. 已存在且健康的 `8091` 服务会被复用。
4. 非本项目进程占用 `8091` 时返回错误且不会发送终止信号。
5. 清理函数只停止记录为本脚本启动的 PID,不停止外部 PID。
6. `ERP_RUN_NO_OPEN=1` 时不调用浏览器。
7. 实机冒烟:运行启动器,确认本地首页、公网首页、登录和受保护业务接口均成功;随后 `Ctrl+C` 验证自有进程退出。
## 验收标准
- Finder 双击一次即可完成后端、ngrok、公网验证和浏览器打开。
- 重复双击不会产生第二个 Java/ngrok,也不会杀死已运行实例。
- 成功路径明确显示两个地址和 `Ctrl+C` 停止说明。
- 失败路径在 60 秒内结束等待,说明失败阶段并展示相关日志。
- `run.command` 与测试脚本具有可执行权限,Shell 回归测试全部通过。
Executable
+525
View File
@@ -0,0 +1,525 @@
#!/usr/bin/env bash
set -euo pipefail
GITEA_BASE_URL="${ERP_GITEA_BASE_URL:-}"
REPOSITORY="${ERP_UPDATE_REPOSITORY:-awaioi/ERP}"
REQUESTED_VERSION="${ERP_INSTALL_VERSION:-}"
INSTALL_ROOT="${ERP_INSTALL_ROOT:-}"
DB_MODE="${ERP_DB_MODE:-existing}"
NON_INTERACTIVE="${ERP_INSTALL_NON_INTERACTIVE:-0}"
ALLOW_INSECURE="${ERP_UPDATE_ALLOW_INSECURE_HTTP:-0}"
NO_SERVICE="${ERP_INSTALL_NO_SERVICE:-0}"
TOKEN="${ERP_GITEA_TOKEN:-${OA_UPDATE_TOKEN:-}}"
PUBLIC_KEY='-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAaErhcY8WZIZvPILmYnfjndVBAdOuWkvhaoHIWqNNdxI=
-----END PUBLIC KEY-----'
TMP_DIR=""
say() { printf '[ERP Install] %s\n' "$*"; }
fail() { printf '[ERP Install] ERROR: %s\n' "$*" >&2; exit 1; }
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
--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
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--gitea-url) [[ $# -ge 2 ]] || fail '--gitea-url requires a value'; GITEA_BASE_URL="$2"; shift 2 ;;
--repository) [[ $# -ge 2 ]] || fail '--repository requires a value'; REPOSITORY="$2"; shift 2 ;;
--version) [[ $# -ge 2 ]] || fail '--version requires a value'; REQUESTED_VERSION="$2"; shift 2 ;;
--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 ;;
*) fail "unknown option: $1" ;;
esac
done
cleanup() {
if [[ -n "$TMP_DIR" && -d "$TMP_DIR" ]]; then
rm -rf "$TMP_DIR"
fi
}
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 ;;
Darwin) PLATFORM=darwin ;;
*) fail "unsupported operating system: $(uname -s)" ;;
esac
case "$(uname -m)" in
x86_64|amd64) ARCH=amd64 ;;
arm64|aarch64) ARCH=arm64 ;;
*) fail "unsupported CPU architecture: $(uname -m); 32-bit systems are not supported" ;;
esac
if [[ "$PLATFORM" == "linux" && "$(id -u)" != "0" ]]; then
fail 'Linux installation requires root; use: curl ... | sudo -E bash'
fi
}
install_dependencies() {
if [[ "$PLATFORM" == "darwin" ]] && command -v brew >/dev/null 2>&1; then
local java_prefix="" libpq_prefix="" openssl_prefix=""
java_prefix="$(brew --prefix openjdk@17 2>/dev/null || true)"
libpq_prefix="$(brew --prefix libpq 2>/dev/null || true)"
openssl_prefix="$(brew --prefix openssl@3 2>/dev/null || true)"
[[ -z "$java_prefix" ]] || export PATH="$java_prefix/bin:$PATH"
[[ -z "$libpq_prefix" ]] || export PATH="$libpq_prefix/bin:$PATH"
[[ -z "$openssl_prefix" ]] || export PATH="$openssl_prefix/bin:$PATH"
fi
local need=0
for command in curl tar python3 psql openssl java; do
command -v "$command" >/dev/null 2>&1 || need=1
done
if [[ "$need" == "1" && "$PLATFORM" == "linux" ]]; then
command -v apt-get >/dev/null 2>&1 || fail 'automatic dependency installation currently supports apt-based Linux only'
say 'Installing runtime dependencies...'
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y ca-certificates curl tar python3 openssl openjdk-17-jre-headless postgresql-client
elif [[ "$need" == "1" ]]; then
command -v brew >/dev/null 2>&1 || fail 'Homebrew is required to install missing macOS dependencies'
say 'Installing runtime dependencies...'
brew install openjdk@17 libpq openssl@3
export PATH="$(brew --prefix openjdk@17)/bin:$(brew --prefix libpq)/bin:$(brew --prefix openssl@3)/bin:$PATH"
fi
for command in curl tar python3 psql openssl java; do require_command "$command"; done
local java_major
java_major="$(java -version 2>&1 | awk -F'[".]' '/version/ {print $2; exit}')"
[[ "$java_major" =~ ^[0-9]+$ && "$java_major" -ge 17 ]] || fail 'Java 17 or newer is required'
local openssl_version algorithms
openssl_version="$(openssl version 2>/dev/null || true)"
[[ "$openssl_version" =~ ^OpenSSL[[:space:]]3\. ]] \
|| fail 'OpenSSL 3 with Ed25519 support is required'
algorithms="$(openssl list -public-key-algorithms 2>/dev/null || true)"
grep -qi ED25519 <<< "$algorithms" \
|| fail 'OpenSSL 3 with Ed25519 support is required'
}
validate_https_url() {
case "$1" in
https://*) return 0 ;;
http://*) [[ "$ALLOW_INSECURE" == "1" || "$ALLOW_INSECURE" == "true" ]] && return 0 ;;
esac
fail 'release server must use HTTPS (use --allow-insecure only for local development)'
}
prompt_value() {
local variable="$1" label="$2" default="$3" secret="${4:-0}" value="${!variable:-}"
if [[ -z "$value" && "$NON_INTERACTIVE" != "1" ]]; then
[[ -r /dev/tty && -w /dev/tty ]] \
|| fail "cannot prompt for $label; use --non-interactive with ERP_DB_* variables"
if [[ "$secret" == "1" ]]; then
IFS= read -r -s -p "$label: " value </dev/tty \
|| fail "cannot read $label; use --non-interactive with ERP_DB_* variables"
printf '\n' >/dev/tty
else
IFS= read -r -p "$label [$default]: " value </dev/tty \
|| fail "cannot read $label; use --non-interactive with ERP_DB_* variables"
fi
fi
value="${value:-$default}"
printf -v "$variable" '%s' "$value"
}
run_as_postgres() {
if [[ "$(id -un)" == "postgres" ]]; then
psql "$@"
elif command -v runuser >/dev/null 2>&1; then
runuser -u postgres -- psql "$@"
elif command -v sudo >/dev/null 2>&1; then
sudo -u postgres -- psql "$@"
else
fail 'cannot switch to the postgres operating-system user (runuser or sudo is required)'
fi
}
configure_local_postgres() {
[[ "$PLATFORM" == "linux" ]] || fail 'automatic local PostgreSQL provisioning is currently Linux-only'
command -v apt-get >/dev/null 2>&1 || fail 'local PostgreSQL provisioning requires apt'
require_command systemctl
apt-get update
apt-get install -y postgresql postgresql-contrib
systemctl enable --now postgresql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_NAME="${ERP_DB_NAME:-kaidi_erp}"
DB_USER="${ERP_DB_USER:-kaidi_erp}"
DB_PASSWORD="${ERP_DB_PASSWORD:-}"
if [[ -z "$DB_PASSWORD" ]]; then
DB_PASSWORD="$(openssl rand -hex 24)" || fail 'unable to generate a PostgreSQL password'
fi
DB_SSLMODE=disable
[[ "$DB_NAME" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]] || fail 'invalid local database name'
[[ "$DB_USER" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]] || fail 'invalid local database user'
run_as_postgres -v ON_ERROR_STOP=1 -v role="$DB_USER" -v password="$DB_PASSWORD" <<'SQL'
SELECT format('CREATE ROLE %I LOGIN PASSWORD %L', :'role', :'password')
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = :'role') \gexec
SELECT format('ALTER ROLE %I LOGIN PASSWORD %L', :'role', :'password') \gexec
SQL
run_as_postgres -v ON_ERROR_STOP=1 -v db="$DB_NAME" -v role="$DB_USER" <<'SQL'
SELECT format('CREATE DATABASE %I OWNER %I', :'db', :'role')
WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = :'db') \gexec
SQL
}
configure_existing_postgres() {
prompt_value DB_HOST 'PostgreSQL host' "${ERP_DB_HOST:-127.0.0.1}"
prompt_value DB_PORT 'PostgreSQL port' "${ERP_DB_PORT:-5432}"
prompt_value DB_NAME 'Database name' "${ERP_DB_NAME:-kaidi_erp}"
prompt_value DB_USER 'Database user' "${ERP_DB_USER:-kaidi_erp}"
prompt_value DB_PASSWORD 'Database password' "${ERP_DB_PASSWORD:-}" 1
prompt_value DB_SSLMODE 'PostgreSQL SSL mode' "${ERP_DB_SSLMODE:-prefer}"
[[ -n "$DB_PASSWORD" ]] || fail 'database password is required'
}
validate_postgres() {
[[ "$DB_HOST" != *[[:space:]]* && "$DB_PORT" =~ ^[0-9]+$ ]] || fail 'invalid PostgreSQL host or port'
[[ "$DB_NAME" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]] || fail 'invalid database name'
[[ "$DB_USER" =~ ^[A-Za-z_][A-Za-z0-9_.-]*$ ]] || fail 'invalid database user'
case "$DB_SSLMODE" in disable|allow|prefer|require|verify-ca|verify-full) ;; *) fail 'invalid PostgreSQL SSL mode' ;; esac
local version
version="$(PGPASSWORD="$DB_PASSWORD" PGSSLMODE="$DB_SSLMODE" psql \
-h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -Atqc \
"select current_setting('server_version_num')::int")" \
|| fail 'cannot connect to PostgreSQL with the supplied credentials'
[[ "$version" =~ ^[0-9]+$ && "$version" -ge 150000 ]] || fail 'PostgreSQL 15 or newer is required'
PGPASSWORD="$DB_PASSWORD" PGSSLMODE="$DB_SSLMODE" psql \
-h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 \
-c 'CREATE EXTENSION IF NOT EXISTS pg_trgm' >/dev/null \
|| fail 'database user cannot install the required pg_trgm extension'
say "PostgreSQL connection verified (${version:0:2})"
}
create_curl_config() {
CURL_CONFIG="$TMP_DIR/curl.conf"
{
printf 'silent\nshow-error\nfail\nlocation\nconnect-timeout = 15\nmax-time = 900\n'
if [[ "$ALLOW_INSECURE" == "1" || "$ALLOW_INSECURE" == "true" ]]; then
printf 'proto = "=http,https"\nproto-redir = "=http,https"\n'
else
printf 'proto = "=https"\nproto-redir = "=https"\n'
fi
if [[ -n "$TOKEN" ]]; then
[[ "$TOKEN" =~ ^[A-Za-z0-9._-]+$ ]] || fail 'invalid Gitea token format'
printf 'header = "Authorization: token %s"\n' "$TOKEN"
fi
} > "$CURL_CONFIG"
chmod 600 "$CURL_CONFIG"
}
download() {
validate_https_url "$1"
curl --config "$CURL_CONFIG" --output "$2" "$1"
}
download_release() {
[[ "$REPOSITORY" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]] || fail 'invalid Gitea repository'
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"
if ! python3 - "$release_json" "$REQUESTED_VERSION" > "$selection" 2> "$selection_error" <<'PY'
import json, re, sys
try:
with open(sys.argv[1], encoding="utf-8") as handle:
release = json.load(handle)
except (OSError, json.JSONDecodeError):
raise SystemExit("invalid release JSON")
tag = str(release.get("tag_name") or "").strip()
match = re.fullmatch(r"v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?)", tag)
if not match or release.get("draft") or release.get("prerelease"):
raise SystemExit("invalid stable release")
version = match.group(1)
if "-" in version.split("+", 1)[0]:
raise SystemExit("prerelease rejected by stable installer")
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"]
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")
print("\t".join([version, *urls]))
PY
then
local reason="invalid Release metadata"
[[ ! -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"
ARCHIVE_NAME="kaidi-erp-${VERSION}.tar.gz"
ARCHIVE_PATH="$TMP_DIR/$ARCHIVE_NAME"
download "$archive_url" "$ARCHIVE_PATH"
download "$sums_url" "$TMP_DIR/SHA256SUMS"
download "$signature_url" "$TMP_DIR/SHA256SUMS.sig"
}
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'
python3 - "$ARCHIVE_PATH" <<'PY'
import pathlib, sys, tarfile
with tarfile.open(sys.argv[1], "r:gz") as archive:
for item in archive.getmembers():
path = pathlib.PurePosixPath(item.name)
if path.is_absolute() or ".." in path.parts or item.issym() or item.islnk():
raise SystemExit(f"unsafe archive member: {item.name}")
PY
}
shell_setting() {
printf '%s=' "$1"
printf '%q\n' "$2"
}
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"
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}"
local path
for path in "$INSTALL_ROOT" "$CONFIG_ROOT" "$STATE_ROOT" "$LOG_ROOT"; do
[[ "$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)"
CONFIG_ROOT="${ERP_CONFIG_ROOT:-$INSTALL_ROOT/config}"
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"
if [[ "$PLATFORM" == "linux" ]]; then
chown -R "$ERP_USER:$ERP_GROUP" "$INSTALL_ROOT" "$STATE_ROOT" "$LOG_ROOT"
fi
}
install_release_files() {
local unpack="$TMP_DIR/unpack"
mkdir -p "$unpack"
tar -xzf "$ARCHIVE_PATH" -C "$unpack"
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=""
if [[ -e "$destination" ]]; then
replaced="${destination}.replaced-$$"
mv "$destination" "$replaced"
fi
if ! mv "$source" "$destination"; then
[[ -z "$replaced" || ! -e "$replaced" ]] || mv "$replaced" "$destination"
fail 'unable to write the release directory'
fi
[[ -z "$replaced" ]] || rm -rf "$replaced"
python3 - "$INSTALL_ROOT/current" "releases/$VERSION" <<'PY'
import os, sys
link, target = sys.argv[1:]
tmp = f"{link}.new-{os.getpid()}"
try:
os.symlink(target, tmp)
os.replace(tmp, link)
finally:
if os.path.lexists(tmp): os.unlink(tmp)
PY
if [[ "$PLATFORM" == "linux" ]]; then
chown -R "$ERP_USER:$ERP_GROUP" "$destination" "$INSTALL_ROOT/current"
fi
}
write_configuration() {
CONFIG_FILE="$CONFIG_ROOT/erp.env"
PUBLIC_KEY_FILE="$CONFIG_ROOT/release-public-key.pem"
local update_enabled=true
[[ "$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_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 OA_UPDATE_ENABLED "$update_enabled"
shell_setting OA_UPDATE_GITEA_BASE_URL "$GITEA_BASE_URL"
shell_setting OA_UPDATE_REPOSITORY "$REPOSITORY"
shell_setting OA_UPDATE_CHANNEL stable
shell_setting OA_UPDATE_TOKEN "$TOKEN"
shell_setting OA_UPDATE_HELPER_COMMAND "$INSTALL_ROOT/current/bin/erp-update"
shell_setting OA_UPDATE_STATE_FILE "$STATE_ROOT/update-state.json"
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"
fi
}
install_service() {
[[ "$NO_SERVICE" == "1" ]] && return 0
if [[ "$PLATFORM" == "linux" ]]; then
cat > /etc/systemd/system/kaidi-erp.service <<EOF
[Unit]
Description=Kaidi ERP
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=$ERP_USER
Group=$ERP_GROUP
Environment="ERP_INSTALL_ROOT=$INSTALL_ROOT"
Environment="ERP_CONFIG_FILE=$CONFIG_FILE"
WorkingDirectory="$INSTALL_ROOT"
ExecStart="$INSTALL_ROOT/current/bin/erp-run"
Restart=always
RestartSec=3
TimeoutStopSec=90
SuccessExitStatus=143
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths="$INSTALL_ROOT" "$STATE_ROOT"
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now kaidi-erp.service
else
local agents="$HOME/Library/LaunchAgents"
local plist="$agents/com.kaidi.erp.plist"
mkdir -p "$agents"
python3 - "$plist" "$INSTALL_ROOT" "$CONFIG_FILE" "$LOG_ROOT" <<'PY'
import plistlib, sys
path, root, config, logs = sys.argv[1:]
payload = {
"Label": "com.kaidi.erp",
"ProgramArguments": [f"{root}/current/bin/erp-run"],
"EnvironmentVariables": {"ERP_INSTALL_ROOT": root, "ERP_CONFIG_FILE": config},
"WorkingDirectory": root,
"RunAtLoad": True,
"KeepAlive": True,
"ThrottleInterval": 3,
"StandardOutPath": f"{logs}/erp.log",
"StandardErrorPath": f"{logs}/erp-error.log",
}
with open(path, "wb") as handle:
plistlib.dump(payload, handle)
PY
launchctl bootout "gui/$(id -u)/com.kaidi.erp" >/dev/null 2>&1 || true
launchctl bootstrap "gui/$(id -u)" "$plist"
fi
}
wait_for_health() {
[[ "$NO_SERVICE" == "1" ]] && return 0
local url="http://127.0.0.1:${ERP_SERVER_PORT:-8091}/api/oa/health" deadline=$((SECONDS + 120))
while (( SECONDS < deadline )); do
if curl -fsS --connect-timeout 2 --max-time 5 "$url" >/dev/null 2>&1; then
return 0
fi
sleep 2
done
fail 'ERP service did not become healthy within 120 seconds'
}
main() {
detect_platform
if [[ -z "$INSTALL_ROOT" ]]; then
if [[ "$PLATFORM" == "linux" ]]; then
INSTALL_ROOT=/opt/kaidi-erp
else
INSTALL_ROOT="$HOME/Library/Application Support/KaidiERP"
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
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/kaidi-erp-install.XXXXXX")"
create_curl_config
say 'Downloading 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}"
}
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
main "$@"
fi
+21 -5
View File
@@ -5,7 +5,12 @@ plugins {
}
group = 'com.kaidi'
version = '0.1.0'
version = providers.gradleProperty('releaseVersion')
.orElse(System.getenv('ERP_RELEASE_VERSION') ?: '0.1.0')
.get()
def productionBuild = providers.gradleProperty('productionBuild')
.map { it.toBoolean() }
.orElse(false)
java {
sourceCompatibility = JavaVersion.VERSION_17
@@ -23,10 +28,17 @@ dependencies {
// Real-time collaboration (yjs CRDT relay) over WebSocket.
implementation 'org.springframework.boot:spring-boot-starter-websocket'
// SQLite driver + Hibernate community dialects (SQLite dialect lives here).
// Version of hibernate-community-dialects is governed by Spring Boot's BOM.
runtimeOnly 'org.xerial:sqlite-jdbc:3.45.3.0'
implementation 'org.hibernate.orm:hibernate-community-dialects'
// Production database and versioned schema migrations.
implementation 'org.flywaydb:flyway-core:10.22.0'
runtimeOnly 'org.flywaydb:flyway-database-postgresql:10.22.0'
runtimeOnly 'org.postgresql:postgresql'
// SQLite remains available to source-tree development and tests, but is
// deliberately absent from PostgreSQL-only production release artifacts.
if (!productionBuild.get()) {
runtimeOnly 'org.xerial:sqlite-jdbc:3.45.3.0'
runtimeOnly 'org.hibernate.orm:hibernate-community-dialects'
}
// jackson-databind arrives transitively via starter-web; declared for clarity.
implementation 'com.fasterxml.jackson.core:jackson-databind'
@@ -41,3 +53,7 @@ dependencies {
tasks.named('test') {
useJUnitPlatform()
}
springBoot {
buildInfo()
}
@@ -36,7 +36,8 @@ public class AuthInterceptor implements HandlerInterceptor {
private static final Set<String> PUBLIC = Set.of(
"/api/oa/auth/login",
"/api/oa/auth/session",
"/api/oa/auth/logout"
"/api/oa/auth/logout",
"/api/oa/health"
);
/** 写操作方法(只有这些方法才触发授权门槛;GET/HEAD 等读操作不限制)。 */
@@ -44,6 +45,7 @@ public class AuthInterceptor implements HandlerInterceptor {
private static final Set<String> ADMIN = Set.of("ADMIN");
private static final Set<String> APPROVER_OR_ADMIN = Set.of("ADMIN", "APPROVER");
private static final List<String> ADMIN_READ_PREFIXES = List.of("/api/oa/system-update");
/**
* 系统 / 组织 / 权限 / 配置 类写接口 → 仅 ADMIN。
@@ -51,6 +53,7 @@ public class AuthInterceptor implements HandlerInterceptor {
*/
private static final List<String> ADMIN_PREFIXES = List.of(
"/api/oa/users", "/api/oa/depts", "/api/oa/roles", "/api/oa/settings",
"/api/oa/system-update",
"/api/oa/delegations", "/api/oa/form-templates", "/api/oa/declaration-templates",
"/api/oa/contract-templates", "/api/oa/report-definitions", "/api/oa/crawl-sources",
// 全文索引重建(POST /search/reindex)是全库重活,仅 ADMIN 可触发(杜绝任意角色发起整库扫描)。
@@ -432,6 +435,10 @@ public class AuthInterceptor implements HandlerInterceptor {
return false;
}
} else {
if (matchesPrefix(path, ADMIN_READ_PREFIXES) && ADMIN.stream().noneMatch(have::contains)) {
writeError(response, HttpStatus.FORBIDDEN, 403, "无权限:系统更新仅限 ADMIN 角色");
return false;
}
// 读:机密财务/PII 读口需 ADMIN/APPROVER,其余业务读维持"已登录且有角色"可读。
if (isSensitiveRead(path) && APPROVER_OR_ADMIN.stream().noneMatch(have::contains)) {
writeError(response, HttpStatus.FORBIDDEN, 403, "无权限:该数据需要 ADMIN/APPROVER 角色");
@@ -441,6 +448,15 @@ public class AuthInterceptor implements HandlerInterceptor {
return true;
}
private static boolean matchesPrefix(String path, List<String> prefixes) {
for (String prefix : prefixes) {
if (path.equals(prefix) || path.startsWith(prefix + "/")) {
return true;
}
}
return false;
}
private static boolean isSensitiveRead(String path) {
// 研发证据链聚合端点 /api/oa/rd-projects/{id}/evidence-chain 会端出研发费用金额/凭证号/申报/专利
// 等本应被 rd-expenses 读门槛拦下的明细(基路径 rd-projects 非敏感,故按后缀精确收口,
@@ -0,0 +1,92 @@
package com.kaidi.oa.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/** Runtime configuration for the Gitea release updater. */
@Component
@ConfigurationProperties(prefix = "oa.update")
public class UpdateProperties {
private boolean enabled;
private String giteaBaseUrl = "";
private String repository = "awaioi/ERP";
private String channel = "stable";
private String token = "";
private String helperCommand = "";
private String stateFile = "./runtime/update-state.json";
private boolean allowInsecureHttp;
private int requestTimeoutSeconds = 15;
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public String getGiteaBaseUrl() {
return giteaBaseUrl;
}
public void setGiteaBaseUrl(String giteaBaseUrl) {
this.giteaBaseUrl = giteaBaseUrl;
}
public String getRepository() {
return repository;
}
public void setRepository(String repository) {
this.repository = repository;
}
public String getChannel() {
return channel;
}
public void setChannel(String channel) {
this.channel = channel;
}
public String getToken() {
return token;
}
public void setToken(String token) {
this.token = token;
}
public String getHelperCommand() {
return helperCommand;
}
public void setHelperCommand(String helperCommand) {
this.helperCommand = helperCommand;
}
public String getStateFile() {
return stateFile;
}
public void setStateFile(String stateFile) {
this.stateFile = stateFile;
}
public boolean isAllowInsecureHttp() {
return allowInsecureHttp;
}
public void setAllowInsecureHttp(boolean allowInsecureHttp) {
this.allowInsecureHttp = allowInsecureHttp;
}
public int getRequestTimeoutSeconds() {
return requestTimeoutSeconds;
}
public void setRequestTimeoutSeconds(int requestTimeoutSeconds) {
this.requestTimeoutSeconds = requestTimeoutSeconds;
}
}
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import java.time.Instant;
@@ -24,7 +23,6 @@ public class Announcement {
private String title;
@Lob
@Column(columnDefinition = "TEXT")
private String content;
@@ -7,7 +7,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
/**
@@ -91,18 +91,18 @@ public class AnnualReport {
// ---------- 扩展填报字段(模板化,各类年报共享) ----------
/** 关键指标描述(校验摘要/主要指标文字汇总)。 */
@Lob
@Column(columnDefinition = "TEXT")
private String keyIndicatorSummary;
/** 数据一致性校验结果(自动校验后填入,如"研发费用占比符合要求")。 */
@Lob
@Column(columnDefinition = "TEXT")
private String validationResult;
/** 校验是否通过:true 全部通过,false 有警告或错误。 */
private Boolean validationPassed;
/** 备注/填报说明。 */
@Lob
@Column(columnDefinition = "TEXT")
private String remark;
/** 填报负责人。 */
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
/** 博客文章(知识社区 - 我的博客)。tags 为逗号分隔。 */
@@ -22,7 +21,6 @@ public class Blog {
@Column(length = 2000)
private String summary;
@Lob
@Column(columnDefinition = "TEXT")
private String body;
@@ -6,7 +6,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
/**
@@ -30,7 +30,7 @@ public class ChronicleEvent {
private String category;
@Lob
@Column(columnDefinition = "TEXT")
private String description;
/** 关联文档/单据(逗号分隔的引用,如 IP-2026-0001 / 合同号 / 文件名)。 */
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import java.time.Instant;
@@ -33,19 +32,16 @@ public class CollabDoc {
private String status;
@Lob
@Column(columnDefinition = "TEXT")
private String content;
private Instant updatedAt;
/** 历史版本 JSON 数组:[{version, content, savedAt, editor}]。每次更新正文前追加上一版。 */
@Lob
@Column(columnDefinition = "TEXT")
private String versionsJson;
/** 评论/批注 JSON 数组:[{author, content, createdAt}]。 */
@Lob
@Column(columnDefinition = "TEXT")
private String commentsJson;
@@ -6,8 +6,9 @@ import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.Instant;
@@ -34,8 +35,9 @@ public class CollabDocUpdate {
/** Monotonic per-document ordering (assigned at append time). */
private Long seq;
/** Raw yjs update bytes. Plain byte[] (NOT @Lob): SQLite JDBC can't read @Lob blobs; getBytes() works. */
@Column(name = "data", columnDefinition = "BLOB")
/** Raw yjs bytes mapped to SQLite BLOB and PostgreSQL bytea. */
@JdbcTypeCode(SqlTypes.LONGVARBINARY)
@Column(name = "data", length = Integer.MAX_VALUE)
private byte[] data;
private Instant createdAt;
@@ -6,7 +6,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
/**
@@ -40,7 +40,7 @@ public class CompetitorIp {
private String publicDate;
@Lob
@Column(columnDefinition = "TEXT")
private String summary;
/** 录入来源:手动 / API导入。 */
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import java.time.Instant;
@@ -61,13 +60,11 @@ public class ComplianceRiskReport {
private int findingCount;
/** 漏洞明细文本(可存 JSON 格式)。 */
@Lob
@Column(name = "findings_text")
@Column(name = "findings_text", columnDefinition = "TEXT")
private String findings;
/** 整改建议。 */
@Lob
@Column(name = "suggestion_text")
@Column(name = "suggestion_text", columnDefinition = "TEXT")
private String suggestion;
/** 报告生成人。 */
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import java.time.Instant;
@@ -46,8 +45,7 @@ public class ContentPieceVersion {
private String titleSnapshot;
/** 正文快照(完整内容,用于 diff 对比)。 */
@Lob
@Column(name = "body_snapshot")
@Column(name = "body_snapshot", columnDefinition = "TEXT")
private String bodySnapshot;
private Instant createdAt;
@@ -4,7 +4,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
import java.time.Instant;
@@ -31,7 +31,7 @@ public class ContractTemplate {
private String applicableSubject;
@Lob
@Column(columnDefinition = "TEXT")
private String bodyTemplate;
private String requiredClauses;
@@ -4,7 +4,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
import java.time.Instant;
@@ -36,7 +36,7 @@ public class DeclarationTemplate {
private String autoCheckRules;
@Lob
@Column(columnDefinition = "TEXT")
private String bodyTemplate;
private String version;
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import java.time.Instant;
@@ -28,7 +27,6 @@ public class Discussion {
private String category;
@Lob
@Column(columnDefinition = "TEXT")
private String content;
@@ -37,7 +35,6 @@ public class Discussion {
private Instant createdAt;
/** 回帖列表 JSON 数组:[{author, content, createdAt}]。 */
@Lob
@Column(columnDefinition = "TEXT")
private String repliesJson;
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
/** 享空间动态(文化建设 - 分享流)。tags 为逗号分隔。 */
@@ -24,7 +23,6 @@ public class Feed {
/** 动态 / 分享 / 图片 / 打卡 */
private String type;
@Lob
@Column(columnDefinition = "TEXT")
private String content;
@@ -37,7 +35,6 @@ public class Feed {
private int comments;
/** 评论正文列表 [{author,content,createdAt}] 的 JSON。 */
@Lob
@Column(columnDefinition = "TEXT")
private String commentsJson;
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import java.time.Instant;
@@ -39,12 +38,10 @@ public class FertProductQc {
private String standardCode;
/** 实测值 JSON{"有机质":45.2,"水分":28.1,...}(键=indicator)。 */
@Lob
@Column(columnDefinition = "text")
private String itemsJson;
/** 逐项判定明细 JSON(自动生成的报告口径)。 */
@Lob
@Column(columnDefinition = "text")
private String judgeJson;
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import java.time.Instant;
@@ -68,7 +67,6 @@ public class FertSupplierProfile {
private String licenseDoc;
/** 补充资质 JSON(扩展字段 {"有机认证":"有","产品标准":"QB/T xxxx",...})。 */
@Lob
@Column(columnDefinition = "text")
private String admitDocsJson;
@@ -42,7 +42,7 @@ public class Financing {
private String currency;
/** 年化利率(百分比,如 4.35 表示 4.35%)。 */
private Double rate;
private BigDecimal rate;
private String startDate;
@@ -122,11 +122,11 @@ public class Financing {
this.currency = currency;
}
public Double getRate() {
public BigDecimal getRate() {
return rate;
}
public void setRate(Double rate) {
public void setRate(BigDecimal rate) {
this.rate = rate;
}
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import java.time.Instant;
@@ -31,7 +30,6 @@ public class FlowTrace {
/** Who handled the step. */
private String who;
@Lob
@Column(columnDefinition = "TEXT")
private String opinion;
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import java.time.Instant;
@@ -33,7 +32,6 @@ public class FormInstance {
private String title;
@Lob
@Column(name = "data_json", columnDefinition = "TEXT")
private String dataJson;
@@ -3,7 +3,6 @@ package com.kaidi.oa.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
/**
@@ -28,15 +27,12 @@ public class FormTemplate {
private String org;
@Lob
@Column(name = "form_schema_json", columnDefinition = "TEXT")
private String formSchemaJson;
@Lob
@Column(name = "flow_schema_json", columnDefinition = "TEXT")
private String flowSchemaJson;
@Lob
@Column(columnDefinition = "TEXT")
private String instructions;
@@ -4,7 +4,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
import java.time.Instant;
@@ -40,7 +40,7 @@ public class HrLaborTemplate {
private String version;
/** 模板正文(含占位符 {员工姓名} {岗位} {薪资} 等)。 */
@Lob
@Column(columnDefinition = "TEXT")
private String bodyTemplate;
/** 必备条款(逗号分隔,如「试用期条款,保密条款,竞业限制条款」)。 */
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import java.time.Instant;
@@ -44,7 +43,7 @@ public class InternalNewsletter {
private String authorDept;
/** 正文(富文本,允许大段文字)。 */
@Lob
@Column(columnDefinition = "TEXT")
private String body;
/** 配图/附件 URL(逗号分隔,如 "https://…/img1.jpg,https://…/img2.jpg")。 */
@@ -7,7 +7,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
/**
@@ -92,7 +92,7 @@ public class IpAsset {
/** 责任人(IP 部门跟案人)。 */
private String owner;
@Lob
@Column(columnDefinition = "TEXT")
private String remark;
private Instant createdAt;
@@ -6,7 +6,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
/**
@@ -35,7 +35,7 @@ public class IpAssetEvent {
private String toValue;
@Lob
@Column(columnDefinition = "TEXT")
private String note;
/** 操作人(跟案人/复核人)。 */
@@ -6,7 +6,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
/**
@@ -33,7 +33,7 @@ public class IpKnowledge {
/** 技术关键词(逗号分隔,供按关键词检索)。 */
private String keywords;
@Lob
@Column(columnDefinition = "TEXT")
private String content;
private String source;
@@ -7,7 +7,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
/**
@@ -71,7 +71,7 @@ public class IpLicenseTransfer {
private String status;
/** 备注(合同关键条款摘要)。 */
@Lob
@Column(columnDefinition = "TEXT")
private String remark;
/** 经办人。 */
@@ -6,7 +6,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
/**
@@ -37,7 +37,7 @@ public class IpRegulation {
private String currentVersion;
/** 当前正文(最新草稿或已发布版)。 */
@Lob
@Column(columnDefinition = "TEXT")
private String content;
private String owner;
@@ -6,7 +6,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
/**
@@ -29,7 +29,7 @@ public class IpRegulationVersion {
private String action;
@Lob
@Column(columnDefinition = "TEXT")
private String contentSnapshot;
/** 修订说明 / 审批意见。 */
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import java.time.Instant;
@@ -77,7 +76,6 @@ public class IpSciCredArchive {
private String validUntil;
/** 文件备注/摘要。 */
@Lob
@Column(columnDefinition = "TEXT")
private String remark;
@@ -4,7 +4,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
import java.time.Instant;
@@ -34,7 +34,7 @@ public class ItKnowledgeArticle {
private String keywords;
/** 解决方案正文(步骤)。 */
@Lob
@Column(columnDefinition = "TEXT")
private String content;
/** 作者 / 维护人。 */
@@ -4,7 +4,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
import java.time.Instant;
@@ -28,7 +28,7 @@ public class LegalConsult {
private String subject;
/** 问题描述详情。 */
@Lob
@Column(columnDefinition = "TEXT")
private String detail;
/** 申请部门。 */
@@ -44,7 +44,7 @@ public class LegalConsult {
private String assignee;
/** 法律意见(回复内容)。 */
@Lob
@Column(columnDefinition = "TEXT")
private String legalOpinion;
/** 状态:待受理 / 处理中 / 已答复 / 已关闭。 */
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import java.time.Instant;
@@ -51,7 +50,6 @@ public class Meeting {
private String description;
/** 会议附件名列表的 JSON["文件名1","文件名2"],仅记录文件名,不存二进制)。 */
@Lob
@Column(columnDefinition = "TEXT")
private String attachmentJson;
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import java.time.Instant;
@@ -29,7 +28,6 @@ public class MeetingMinute {
private String title;
@Lob
@Column(columnDefinition = "TEXT")
private String content;
@@ -37,7 +35,6 @@ public class MeetingMinute {
private String status;
/** 决议项, TEXT/JSON 串. */
@Lob
@Column(columnDefinition = "TEXT")
private String decisions;
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import java.time.Instant;
@@ -34,7 +33,6 @@ public class Message {
private String title;
@Lob
@Column(columnDefinition = "TEXT")
private String body;
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import java.time.Instant;
@@ -84,7 +83,7 @@ public class MfgEnvSupplierProfile {
* 历史供货业绩(JSON 数组):[{projectName, deliveryYear, qty, qualifiedRate, customer}...]。
* Lob 存储,前端渲染为折叠卡片。
*/
@Lob
@Column(columnDefinition = "TEXT")
private String performanceJson;
/** 历史供货总次数(冗余,便于快速汇总)。 */
@@ -6,7 +6,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
/**
@@ -31,7 +31,7 @@ public class NetScanLog {
private String title;
@Lob
@Column(columnDefinition = "TEXT")
private String content;
private String link;
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
/** 通用键值配置:settingKey 唯一,valueJson 存任意 JSON 字符串。用于工时设置/信息项设置等页面级配置。 */
@@ -20,7 +19,6 @@ public class OaSetting {
@Column(unique = true, nullable = false)
private String settingKey;
@Lob
@Column(columnDefinition = "TEXT")
private String valueJson;
@@ -39,7 +39,7 @@ public class PmtFinancingContract {
private BigDecimal contractAmount = BigDecimal.ZERO;
/** 合同利率(年化,如 4.35)。 */
private Double contractRate;
private BigDecimal contractRate;
/** 合同签署日期(YYYY-MM-DD)。 */
private String signDate;
@@ -94,8 +94,8 @@ public class PmtFinancingContract {
public BigDecimal getContractAmount() { return contractAmount; }
public void setContractAmount(BigDecimal contractAmount) { this.contractAmount = contractAmount; }
public Double getContractRate() { return contractRate; }
public void setContractRate(Double contractRate) { this.contractRate = contractRate; }
public BigDecimal getContractRate() { return contractRate; }
public void setContractRate(BigDecimal contractRate) { this.contractRate = contractRate; }
public String getSignDate() { return signDate; }
public void setSignDate(String signDate) { this.signDate = signDate; }
@@ -39,7 +39,7 @@ public class PmtInternalTrans {
private BigDecimal amount = BigDecimal.ZERO;
/** 利率(内部计息时使用,年化,如 3.5 表示 3.5%)。 */
private Double interestRate;
private BigDecimal interestRate;
/** 计息起始日。 */
private String interestFrom;
@@ -104,8 +104,8 @@ public class PmtInternalTrans {
public BigDecimal getAmount() { return amount; }
public void setAmount(BigDecimal amount) { this.amount = amount; }
public Double getInterestRate() { return interestRate; }
public void setInterestRate(Double interestRate) { this.interestRate = interestRate; }
public BigDecimal getInterestRate() { return interestRate; }
public void setInterestRate(BigDecimal interestRate) { this.interestRate = interestRate; }
public String getInterestFrom() { return interestFrom; }
public void setInterestFrom(String interestFrom) { this.interestFrom = interestFrom; }
@@ -41,7 +41,7 @@ public class PmtLoanScheme {
private BigDecimal amount = BigDecimal.ZERO;
/** 年化利率(百分比,如 4.35 表示 4.35%)。 */
private Double annualRate;
private BigDecimal annualRate;
/** 期限(月)。 */
private Integer termMonths;
@@ -56,7 +56,7 @@ public class PmtLoanScheme {
private String guaranteeType;
/** 担保费率(年化,百分比)。 */
private Double guaranteeRate;
private BigDecimal guaranteeRate;
/** 提款条件(文字描述)。 */
private String drawdownConditions;
@@ -65,7 +65,7 @@ public class PmtLoanScheme {
* 系统自动计算的综合成本率(利息+费用+担保成本之和/本金,IRR 简化口径,百分比)。
* 创建/更新时服务端根据 rate+费用+担保自动回填。
*/
private Double effectiveCostRate;
private BigDecimal effectiveCostRate;
/** 综合排名(越小越优,由 /rank 端点服务端按综合成本排序后写入)。 */
private Integer rank;
@@ -104,8 +104,8 @@ public class PmtLoanScheme {
public BigDecimal getAmount() { return amount; }
public void setAmount(BigDecimal amount) { this.amount = amount; }
public Double getAnnualRate() { return annualRate; }
public void setAnnualRate(Double annualRate) { this.annualRate = annualRate; }
public BigDecimal getAnnualRate() { return annualRate; }
public void setAnnualRate(BigDecimal annualRate) { this.annualRate = annualRate; }
public Integer getTermMonths() { return termMonths; }
public void setTermMonths(Integer termMonths) { this.termMonths = termMonths; }
@@ -119,14 +119,14 @@ public class PmtLoanScheme {
public String getGuaranteeType() { return guaranteeType; }
public void setGuaranteeType(String guaranteeType) { this.guaranteeType = guaranteeType; }
public Double getGuaranteeRate() { return guaranteeRate; }
public void setGuaranteeRate(Double guaranteeRate) { this.guaranteeRate = guaranteeRate; }
public BigDecimal getGuaranteeRate() { return guaranteeRate; }
public void setGuaranteeRate(BigDecimal guaranteeRate) { this.guaranteeRate = guaranteeRate; }
public String getDrawdownConditions() { return drawdownConditions; }
public void setDrawdownConditions(String drawdownConditions) { this.drawdownConditions = drawdownConditions; }
public Double getEffectiveCostRate() { return effectiveCostRate; }
public void setEffectiveCostRate(Double effectiveCostRate) { this.effectiveCostRate = effectiveCostRate; }
public BigDecimal getEffectiveCostRate() { return effectiveCostRate; }
public void setEffectiveCostRate(BigDecimal effectiveCostRate) { this.effectiveCostRate = effectiveCostRate; }
public Integer getRank() { return rank; }
public void setRank(Integer rank) { this.rank = rank; }
@@ -6,7 +6,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
/**
@@ -38,7 +38,7 @@ public class StdApplication {
private String planNo;
@Lob
@Column(columnDefinition = "TEXT")
private String techContent;
private String drafters;
@@ -5,8 +5,9 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.Instant;
@@ -34,12 +35,9 @@ public class StoredFile {
/** Size in bytes. */
private Long size;
/**
* Raw bytes as a plain byte[] (NOT @Lob): SQLite's JDBC driver does not implement
* the streamed-Blob read path that @Lob triggers ("not implemented by SQLite JDBC
* driver"); a plain byte[] is read directly via getBytes() and works.
*/
@Column(columnDefinition = "BLOB")
/** Raw bytes mapped to SQLite BLOB and PostgreSQL bytea without JDBC Blob streaming. */
@JdbcTypeCode(SqlTypes.LONGVARBINARY)
@Column(length = Integer.MAX_VALUE)
private byte[] data;
/** Display name of the uploader (resolved from the auth token). */
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
/**
@@ -29,12 +28,10 @@ public class Survey {
private String status;
@Lob
@Column(name = "options_json", columnDefinition = "TEXT")
private String optionsJson;
// --> JSON array of voter usernames who already voted; used to reject duplicate votes.
@Lob
@Column(name = "voters_json", columnDefinition = "TEXT")
private String votersJson;
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import java.math.BigDecimal;
@@ -57,7 +56,6 @@ public class SvIndepClaim {
private String incidentEndDate;
/** 事件经过详细说明。 */
@Lob
@Column(columnDefinition = "TEXT")
private String incidentDetail;
@@ -73,7 +71,6 @@ public class SvIndepClaim {
private Integer criticalPathImpactDays;
/** 关键线路影响分析说明(如"该延误不在关键路径,不影响完工日期")。 */
@Lob
@Column(columnDefinition = "TEXT")
private String criticalPathImpact;
@@ -82,7 +79,6 @@ public class SvIndepClaim {
private BigDecimal claimAmount = BigDecimal.ZERO;
/** 费用影响评估说明。 */
@Lob
@Column(columnDefinition = "TEXT")
private String costImpactDetail;
@@ -102,7 +98,6 @@ public class SvIndepClaim {
private BigDecimal approvedAmount = BigDecimal.ZERO;
/** 监理意见详细说明。 */
@Lob
@Column(columnDefinition = "TEXT")
private String reviewOpinion;
@@ -22,6 +22,12 @@ public class SysRole {
@Column(nullable = false, unique = true)
private String code;
/** 角色说明(角色管理 UI 用)。 */
private String description;
/** 系统内置角色(ADMIN/APPROVER/USER)不可删;自定义部门角色为 false。 */
private boolean system = false;
public Long getId() {
return id;
}
@@ -45,4 +51,20 @@ public class SysRole {
public void setCode(String code) {
this.code = code;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public boolean isSystem() {
return system;
}
public void setSystem(boolean system) {
this.system = system;
}
}
@@ -6,7 +6,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
/**
@@ -47,7 +47,7 @@ public class TechAchievement {
private Long ipAssetId;
/** 佐证材料清单(论文/标准/软著/检测报告…)。 */
@Lob
@Column(columnDefinition = "TEXT")
private String evidence;
/** 成果评价等级。 */
@@ -7,7 +7,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
/**
@@ -58,11 +58,11 @@ public class TechContract {
private String ipAssetName;
/** 自动生成的创新技术方案(材料)。 */
@Lob
@Column(columnDefinition = "TEXT")
private String techScheme;
/** 自动生成的承诺书(材料)。 */
@Lob
@Column(columnDefinition = "TEXT")
private String commitmentLetter;
private String owner;
@@ -4,7 +4,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
import java.time.Instant;
@@ -62,7 +62,7 @@ public class WmDeclTemplate {
private String placeholders;
/** 模板正文(含占位符的完整模板文本)。 */
@Lob
@Column(columnDefinition = "TEXT")
private String bodyTemplate;
/** 更新说明(本版变更内容摘要,更新时通知相关人员)。 */
@@ -4,7 +4,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
import java.time.Instant;
@@ -46,7 +46,7 @@ public class WmDocVersion {
private String summary;
/** 文档正文(内联富文本)。 */
@Lob
@Column(columnDefinition = "TEXT")
private String content;
/** 外部附件 URL / 文件路径(可空;与通用文件模块结合时存储路径)。 */
@@ -6,7 +6,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
/**
@@ -64,7 +64,7 @@ public class WmPromotion {
private Integer attendeeCount;
/** 效果评估说明。 */
@Lob
@Column(columnDefinition = "TEXT")
private String effectNote;
/** 推广材料(作业指导书/PPT/视频等,逗号分隔文件路径或文件 id)。 */
@@ -7,7 +7,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
/**
@@ -70,41 +70,41 @@ public class WorkMethod {
private String stage;
// ---- 立项信息(需求功能1·工法立项) ----
@Lob
@Column(columnDefinition = "TEXT")
private String techBackground;
@Lob
@Column(columnDefinition = "TEXT")
private String innovation;
@Lob
@Column(columnDefinition = "TEXT")
private String applicableScope;
@Lob
@Column(columnDefinition = "TEXT")
private String expectedBenefit;
// ---- 工法文本九大要素(需求功能1·工法编制) ----
@Lob
@Column(columnDefinition = "TEXT")
private String contentFeature;
@Lob
@Column(columnDefinition = "TEXT")
private String contentPrinciple;
@Lob
@Column(columnDefinition = "TEXT")
private String contentProcess;
@Lob
@Column(columnDefinition = "TEXT")
private String contentMaterial;
@Lob
@Column(columnDefinition = "TEXT")
private String contentQuality;
@Lob
@Column(columnDefinition = "TEXT")
private String contentSafety;
@Lob
@Column(columnDefinition = "TEXT")
private String contentEnv;
@Lob
@Column(columnDefinition = "TEXT")
private String contentBenefit;
// ---- 证书与有效期(需求功能1·工法证书) ----
@@ -6,7 +6,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
/**
@@ -53,13 +53,13 @@ public class WorkMethodApplication {
* 申报材料清单:逗号分隔的 "材料名:0/1"0=缺,1=齐),如
* "申报书:1,工法文本:1,查新报告:0,应用证明:1,经济效益证明:0"。
*/
@Lob
@Column(columnDefinition = "TEXT")
private String materials;
/** 批准文号(批准后回填)。 */
private String approveDocNo;
@Lob
@Column(columnDefinition = "TEXT")
private String remark;
private Instant createdAt;
@@ -6,7 +6,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
/**
@@ -36,7 +36,7 @@ public class WorkMethodEvent {
private String toValue;
@Lob
@Column(columnDefinition = "TEXT")
private String note;
private String operator;
@@ -6,7 +6,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
/**
@@ -44,7 +44,7 @@ public class WorkMethodReview {
/** 评分(0-100,可空)。 */
private Integer score;
@Lob
@Column(columnDefinition = "TEXT")
private String opinion;
private String reviewedDate;
@@ -7,7 +7,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
/**
@@ -44,7 +44,7 @@ public class WorkMethodReward {
* 完成人贡献分配明细:逗号分隔的 "姓名:比例%:金额",如
* "张三:50:5000.00,李四:30:3000.00,王五:20:2000.00"。比例之和应为 100。
*/
@Lob
@Column(columnDefinition = "TEXT")
private String allocation;
/** 奖励状态:待审批 / 已审批 / 已发放 / 已驳回。 */
@@ -61,7 +61,7 @@ public class WorkMethodReward {
/** 审批后生成的资金支付中心付款单 id(联动财务)。 */
private Long paymentId;
@Lob
@Column(columnDefinition = "TEXT")
private String remark;
private Instant createdAt;
@@ -7,7 +7,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
/**
@@ -49,7 +49,7 @@ public class WorkMethodUsage {
private Integer shortenDays = 0;
/** 应用效果描述(质量提升/用户评价)。 */
@Lob
@Column(columnDefinition = "TEXT")
private String effect;
/** 登记人。 */
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
/** A work plan. period is e.g. 本周 / 本月 / 本季; status is 草稿 / 执行中 / 已完成. */
@@ -28,7 +27,6 @@ public class WorkPlan {
private String status;
@Lob
@Column(columnDefinition = "TEXT")
private String content;
@@ -131,7 +131,7 @@ public class FinancingDomainSeeder implements CommandLineRunner {
f1.setCreditLimit(Money.of(50000000.0));
f1.setAmount(Money.of(50000000.0));
f1.setCurrency("人民币");
f1.setRate(3.85);
f1.setRate(BigDecimal.valueOf(3.85));
f1.setStartDate("2024-01-15");
f1.setEndDate("2026-01-14");
f1.setTermMonths(24);
@@ -153,7 +153,7 @@ public class FinancingDomainSeeder implements CommandLineRunner {
f2.setCreditLimit(Money.of(100000000.0));
f2.setAmount(Money.of(80000000.0));
f2.setCurrency("人民币");
f2.setRate(3.20);
f2.setRate(BigDecimal.valueOf(3.20));
f2.setStartDate("2024-03-01");
f2.setEndDate("2029-02-28");
f2.setTermMonths(60);
@@ -173,7 +173,7 @@ public class FinancingDomainSeeder implements CommandLineRunner {
f3.setCreditLimit(Money.of(20000000.0));
f3.setAmount(Money.of(18000000.0));
f3.setCurrency("人民币");
f3.setRate(4.35);
f3.setRate(BigDecimal.valueOf(4.35));
f3.setStartDate("2024-06-01");
f3.setEndDate("2025-05-31");
f3.setTermMonths(12);
@@ -193,7 +193,7 @@ public class FinancingDomainSeeder implements CommandLineRunner {
f4.setCreditLimit(Money.of(30000000.0));
f4.setAmount(Money.of(30000000.0));
f4.setCurrency("人民币");
f4.setRate(4.10);
f4.setRate(BigDecimal.valueOf(4.10));
f4.setStartDate("2025-02-01");
f4.setEndDate("2026-01-31");
f4.setTermMonths(12);
@@ -214,7 +214,7 @@ public class FinancingDomainSeeder implements CommandLineRunner {
f5.setCreditLimit(Money.of(20000000.0));
f5.setAmount(Money.of(20000000.0));
f5.setCurrency("人民币");
f5.setRate(2.50);
f5.setRate(BigDecimal.valueOf(2.50));
f5.setStartDate("2025-07-01");
f5.setEndDate("2026-06-30");
f5.setTermMonths(12);
@@ -234,7 +234,7 @@ public class FinancingDomainSeeder implements CommandLineRunner {
f6.setCreditLimit(Money.of(10000000.0));
f6.setAmount(Money.of(10000000.0));
f6.setCurrency("人民币");
f6.setRate(4.60);
f6.setRate(BigDecimal.valueOf(4.60));
f6.setStartDate("2023-01-01");
f6.setEndDate("2024-12-31");
f6.setTermMonths(24);
@@ -0,0 +1,470 @@
package com.kaidi.oa.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kaidi.oa.common.ApiException;
import com.kaidi.oa.config.UpdateProperties;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.info.BuildProperties;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.math.BigInteger;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/** Checks Gitea releases and delegates installation to the external update helper. */
@Service
public class SystemUpdateService {
private static final Logger log = LoggerFactory.getLogger(SystemUpdateService.class);
private static final Pattern VERSION_PATTERN = Pattern.compile(
"^[vV]?(\\d+)(?:\\.(\\d+))?(?:\\.(\\d+))?"
+ "(?:-([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?"
+ "(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$");
private static final long MAX_STATE_BYTES = 64 * 1024;
private final UpdateProperties properties;
private final ObjectMapper objectMapper;
private final HttpClient httpClient;
private final String currentVersion;
private final AtomicReference<UpdateStatus> state;
private final AtomicBoolean installRunning = new AtomicBoolean(false);
@Autowired
public SystemUpdateService(UpdateProperties properties,
ObjectMapper objectMapper,
ObjectProvider<BuildProperties> buildProperties) {
this(properties, objectMapper, buildProperties,
HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(Math.max(1, properties.getRequestTimeoutSeconds())))
.followRedirects(HttpClient.Redirect.NORMAL)
.build());
}
SystemUpdateService(UpdateProperties properties,
ObjectMapper objectMapper,
ObjectProvider<BuildProperties> buildProperties,
HttpClient httpClient) {
this.properties = properties;
this.objectMapper = objectMapper;
this.httpClient = httpClient;
BuildProperties build = buildProperties.getIfAvailable();
this.currentVersion = build == null || build.getVersion() == null ? "dev" : build.getVersion();
this.state = new AtomicReference<>(UpdateStatus.idle(properties.isEnabled(), currentVersion));
}
public UpdateStatus status() {
UpdateStatus persisted = readHelperState();
return persisted == null ? state.get() : persisted;
}
public synchronized UpdateStatus check() {
requireConfigured();
state.set(state.get().withPhase(UpdatePhase.CHECKING, 5, "正在检查 Gitea Release"));
try {
ReleaseInfo release = fetchLatestRelease();
boolean available = compareVersions(release.version(), currentVersion) > 0;
UpdateStatus checked = new UpdateStatus(
true,
currentVersion,
release.version(),
available,
available ? UpdatePhase.AVAILABLE : UpdatePhase.UP_TO_DATE,
100,
available ? "发现新版本" : "当前已是最新版本",
Instant.now(),
release.publishedAt(),
release.notes(),
release.assets(),
null
);
state.set(checked);
return checked;
} catch (RuntimeException e) {
state.set(state.get().failed(safeMessage(e)));
throw e;
}
}
public UpdateStatus install(String requestedVersion) {
requireConfigured();
if (!isVersion(requestedVersion)) {
throw new ApiException(400, "版本号格式不正确");
}
UpdateStatus checked = state.get();
if (checked.latestVersion() == null || !sameVersion(checked.latestVersion(), requestedVersion)) {
checked = check();
}
if (!sameVersion(checked.latestVersion(), requestedVersion)) {
throw new ApiException(409, "目标版本已变化,请重新检查更新");
}
if (!checked.updateAvailable()) {
throw new ApiException(409, "当前版本不需要更新");
}
if (!installRunning.compareAndSet(false, true)) {
throw new ApiException(409, "已有更新任务正在执行");
}
Path helper = Path.of(properties.getHelperCommand()).toAbsolutePath().normalize();
if (!Files.isRegularFile(helper) || !Files.isExecutable(helper)) {
installRunning.set(false);
throw new ApiException(503, "更新助手不可用,请检查安装目录");
}
try {
Path statePath = statePath();
Path parent = statePath.getParent();
if (parent != null) {
Files.createDirectories(parent);
}
Path logFile = parent == null
? Path.of("update-helper.log").toAbsolutePath()
: parent.resolve("update-helper.log");
ProcessBuilder processBuilder = new ProcessBuilder(
helper.toString(), "install", normalizeVersion(requestedVersion));
processBuilder.environment().put("ERP_APP_PID", String.valueOf(ProcessHandle.current().pid()));
processBuilder.redirectErrorStream(true);
processBuilder.redirectOutput(ProcessBuilder.Redirect.appendTo(logFile.toFile()));
Process process = processBuilder.start();
UpdateStatus starting = checked.withPhase(UpdatePhase.STARTING, 1, "更新助手已启动");
state.set(starting);
process.onExit().thenAccept(completed -> {
installRunning.set(false);
if (completed.exitValue() != 0) {
state.updateAndGet(value -> value.failed("更新助手执行失败,退出码 " + completed.exitValue()));
}
});
return starting;
} catch (IOException e) {
installRunning.set(false);
log.error("Unable to start update helper", e);
throw new ApiException(500, "无法启动更新助手");
}
}
private ReleaseInfo fetchLatestRelease() {
String[] repository = properties.getRepository().split("/", 2);
if (repository.length != 2 || repository[0].isBlank() || repository[1].isBlank()) {
throw new ApiException(500, "更新仓库配置无效");
}
String base = properties.getGiteaBaseUrl().replaceAll("/+$", "");
URI uri = URI.create(base + "/api/v1/repos/" + encode(repository[0]) + "/"
+ encode(repository[1]) + "/releases/latest");
HttpRequest.Builder builder = HttpRequest.newBuilder(uri)
.timeout(Duration.ofSeconds(Math.max(1, properties.getRequestTimeoutSeconds())))
.header("Accept", "application/json")
.GET();
if (properties.getToken() != null && !properties.getToken().isBlank()) {
builder.header("Authorization", "token " + properties.getToken().trim());
}
try {
HttpResponse<String> response = httpClient.send(builder.build(),
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() == 404) {
throw new ApiException(404, "Gitea 尚未发布 Release");
}
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new ApiException(502, "Gitea Release API 返回 HTTP " + response.statusCode());
}
return parseRelease(objectMapper.readTree(response.body()));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new ApiException(503, "检查更新被中断");
} catch (IOException | IllegalArgumentException e) {
log.warn("Unable to check Gitea release: {}", e.getMessage());
throw new ApiException(503, "无法连接更新服务器");
}
}
private ReleaseInfo parseRelease(JsonNode root) {
String tag = text(root, "tag_name");
if (!isVersion(tag)) {
throw new ApiException(502, "Release 版本号格式无效");
}
if (root.path("draft").asBoolean(false)) {
throw new ApiException(502, "最新 Release 仍是草稿");
}
Matcher versionMatcher = VERSION_PATTERN.matcher(tag);
versionMatcher.matches();
boolean taggedPrerelease = versionMatcher.group(4) != null;
if ((root.path("prerelease").asBoolean(false) || taggedPrerelease)
&& "stable".equalsIgnoreCase(properties.getChannel())) {
throw new ApiException(502, "稳定频道拒绝预发布版本");
}
List<ReleaseAsset> assets = new ArrayList<>();
for (JsonNode node : root.path("assets")) {
assets.add(new ReleaseAsset(
text(node, "name"),
text(node, "browser_download_url"),
node.path("size").asLong(0)
));
}
String version = normalizeVersion(tag);
requireAsset(assets, "kaidi-erp-" + version + ".tar.gz");
requireAsset(assets, "SHA256SUMS");
requireAsset(assets, "SHA256SUMS.sig");
Instant publishedAt = null;
String published = text(root, "published_at");
if (!published.isBlank()) {
try {
publishedAt = Instant.parse(published);
} catch (RuntimeException ignore) {
// An invalid optional timestamp must not hide an otherwise valid release.
}
}
return new ReleaseInfo(version, text(root, "body"), publishedAt, List.copyOf(assets));
}
private void requireConfigured() {
if (!properties.isEnabled()) {
throw new ApiException(503, "在线更新尚未启用");
}
if (properties.getGiteaBaseUrl() == null || properties.getGiteaBaseUrl().isBlank()) {
throw new ApiException(503, "尚未配置 Gitea 地址");
}
URI uri;
try {
uri = URI.create(properties.getGiteaBaseUrl().trim());
} catch (IllegalArgumentException e) {
throw new ApiException(500, "Gitea 地址格式无效");
}
String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(Locale.ROOT);
if (!"https".equals(scheme) && !(properties.isAllowInsecureHttp() && "http".equals(scheme))) {
throw new ApiException(503, "更新服务器必须使用 HTTPS");
}
}
private UpdateStatus readHelperState() {
Path path = statePath();
try {
if (!Files.isRegularFile(path) || Files.size(path) > MAX_STATE_BYTES) {
return null;
}
JsonNode root = objectMapper.readTree(path.toFile());
String phaseText = text(root, "phase");
if (phaseText.isBlank()) {
return null;
}
UpdatePhase phase = UpdatePhase.valueOf(phaseText.toUpperCase(Locale.ROOT));
UpdateStatus memory = state.get();
String helperVersion = blankToNull(text(root, "version"));
boolean updateAvailable = helperVersion != null
&& phase != UpdatePhase.SUCCEEDED
&& compareVersions(helperVersion, currentVersion) > 0;
Instant helperUpdatedAt = parseInstant(text(root, "updatedAt"));
return new UpdateStatus(
properties.isEnabled(),
currentVersion,
helperVersion,
updateAvailable,
phase,
Math.max(0, Math.min(100, root.path("progress").asInt(0))),
text(root, "message"),
helperUpdatedAt == null ? memory.checkedAt() : helperUpdatedAt,
memory.publishedAt(),
memory.releaseNotes(),
memory.assets(),
blankToNull(text(root, "error"))
);
} catch (Exception e) {
log.debug("Ignoring unreadable update state {}: {}", path, e.getMessage());
return null;
}
}
private Path statePath() {
String value = properties.getStateFile();
return Path.of(value == null || value.isBlank() ? "./runtime/update-state.json" : value)
.toAbsolutePath().normalize();
}
private static String text(JsonNode node, String field) {
JsonNode value = node.path(field);
return value.isTextual() ? value.asText().trim() : "";
}
private static String encode(String pathSegment) {
return URLEncoder.encode(pathSegment, StandardCharsets.UTF_8).replace("+", "%20");
}
private static void requireAsset(List<ReleaseAsset> assets, String name) {
if (assets.stream().noneMatch(asset -> name.equals(asset.name()))) {
throw new ApiException(502, "Release 缺少文件 " + name);
}
}
private static boolean isVersion(String version) {
return version != null && VERSION_PATTERN.matcher(version.trim()).matches();
}
private static boolean sameVersion(String left, String right) {
return normalizeVersion(left).equals(normalizeVersion(right));
}
static int compareVersions(String left, String right) {
Matcher a = VERSION_PATTERN.matcher(Objects.requireNonNullElse(left, "").trim());
Matcher b = VERSION_PATTERN.matcher(Objects.requireNonNullElse(right, "").trim());
boolean aMatches = a.matches();
boolean bMatches = b.matches();
if (aMatches && !bMatches) {
return 1;
}
if (!aMatches && bMatches) {
return -1;
}
if (!aMatches) {
return normalizeVersion(left).compareToIgnoreCase(normalizeVersion(right));
}
for (int i = 1; i <= 3; i++) {
int compared = number(a.group(i)).compareTo(number(b.group(i)));
if (compared != 0) {
return compared;
}
}
String aPre = a.group(4);
String bPre = b.group(4);
if (aPre == null && bPre != null) {
return 1;
}
if (aPre != null && bPre == null) {
return -1;
}
if (aPre == null) {
return 0;
}
return comparePrerelease(aPre, bPre);
}
private static int comparePrerelease(String left, String right) {
String[] a = left.split("\\.");
String[] b = right.split("\\.");
for (int i = 0; i < Math.min(a.length, b.length); i++) {
if (a[i].equals(b[i])) {
continue;
}
boolean aNumeric = isNumericIdentifier(a[i]);
boolean bNumeric = isNumericIdentifier(b[i]);
if (aNumeric && bNumeric) {
int compared = new BigInteger(a[i]).compareTo(new BigInteger(b[i]));
if (compared != 0) {
return compared;
}
continue;
}
if (aNumeric != bNumeric) {
return aNumeric ? -1 : 1;
}
int compared = a[i].compareTo(b[i]);
if (compared != 0) {
return compared;
}
}
return Integer.compare(a.length, b.length);
}
private static boolean isNumericIdentifier(String value) {
return value.chars().allMatch(Character::isDigit);
}
private static BigInteger number(String value) {
return value == null || value.isBlank() ? BigInteger.ZERO : new BigInteger(value);
}
private static String normalizeVersion(String version) {
String value = Objects.requireNonNullElse(version, "").trim();
return value.startsWith("v") || value.startsWith("V") ? value.substring(1) : value;
}
private static String safeMessage(RuntimeException error) {
return error instanceof ApiException ? error.getMessage() : "检查更新失败";
}
private static String blankToNull(String value) {
return value == null || value.isBlank() ? null : value;
}
private static Instant parseInstant(String value) {
if (value == null || value.isBlank()) {
return null;
}
try {
return Instant.parse(value);
} catch (RuntimeException ignore) {
return null;
}
}
private record ReleaseInfo(String version, String notes, Instant publishedAt, List<ReleaseAsset> assets) {
}
public record ReleaseAsset(String name, String downloadUrl, long size) {
}
public enum UpdatePhase {
IDLE,
CHECKING,
AVAILABLE,
UP_TO_DATE,
STARTING,
DOWNLOADING,
VERIFYING,
INSTALLING,
RESTARTING,
SUCCEEDED,
ROLLING_BACK,
ROLLED_BACK,
FAILED
}
public record UpdateStatus(
boolean configured,
String currentVersion,
String latestVersion,
boolean updateAvailable,
UpdatePhase phase,
int progress,
String message,
Instant checkedAt,
Instant publishedAt,
String releaseNotes,
List<ReleaseAsset> assets,
String error
) {
static UpdateStatus idle(boolean configured, String currentVersion) {
return new UpdateStatus(configured, currentVersion, null, false, UpdatePhase.IDLE, 0,
configured ? "等待检查更新" : "在线更新尚未启用", null, null, "", List.of(), null);
}
UpdateStatus withPhase(UpdatePhase next, int nextProgress, String nextMessage) {
return new UpdateStatus(configured, currentVersion, latestVersion, updateAvailable, next,
nextProgress, nextMessage, checkedAt, publishedAt, releaseNotes, assets, null);
}
UpdateStatus failed(String reason) {
return new UpdateStatus(configured, currentVersion, latestVersion, updateAvailable,
UpdatePhase.FAILED, progress, "更新失败", checkedAt, publishedAt, releaseNotes, assets, reason);
}
}
}
@@ -113,10 +113,11 @@ public class FinancingController {
public record FinancingRequest(
String code, String lender, String financingType, Double creditLimit, Double amount,
String currency, Double rate, String startDate, String endDate, Integer termMonths,
String currency, BigDecimal rate, String startDate, String endDate, Integer termMonths,
String repayMethod, String status, String companySubject, String purpose, String owner) {
}
@Transactional
@PostMapping
public ApiResp<Financing> create(@RequestBody FinancingRequest req) {
if (req.lender() == null || req.lender().isBlank()) {
@@ -143,6 +144,7 @@ public class FinancingController {
return ApiResp.ok(financingRepo.save(f));
}
@Transactional
@PatchMapping("/{id}")
public ApiResp<Financing> update(@PathVariable Long id, @RequestBody FinancingRequest req) {
Financing f = financingRepo.findById(id)
@@ -165,7 +167,6 @@ public class FinancingController {
}
@DeleteMapping("/{id}")
@Transactional
public ApiResp<Void> delete(@PathVariable Long id) {
if (!financingRepo.existsById(id)) {
throw new NotFoundException("financing not found: " + id);
@@ -188,7 +189,6 @@ public class FinancingController {
* 已有任意期次「已还」时拒绝重算,避免抹掉还款历史。
*/
@PostMapping("/{id}/schedule")
@Transactional
public ApiResp<List<RepaymentPlan>> schedule(@PathVariable Long id) {
Financing f = financingRepo.findById(id)
.orElseThrow(() -> new NotFoundException("financing not found: " + id));
@@ -200,7 +200,7 @@ public class FinancingController {
planRepo.deleteByFinancingId(id);
int term = f.getTermMonths() == null || f.getTermMonths() <= 0 ? 12 : f.getTermMonths();
double annualRate = f.getRate() == null ? 0 : f.getRate();
double annualRate = f.getRate() == null ? 0 : f.getRate().doubleValue();
BigDecimal principalTotal = Money.nz(f.getAmount());
String method = f.getRepayMethod() == null ? "等额本息" : f.getRepayMethod();
LocalDate start = parseDateOrNull(f.getStartDate());
@@ -288,7 +288,6 @@ public class FinancingController {
* 这条联动把"融资还款"打通到结算/支付链(与需求"还款联动结算中心生成付款单"一致)。
*/
@PostMapping("/repayments/{planId}/pay")
@Transactional
public ApiResp<RepaymentPlan> payRepayment(@PathVariable Long planId) {
RepaymentPlan plan = planRepo.findById(planId)
.orElseThrow(() -> new NotFoundException("repayment plan not found: " + planId));
@@ -552,7 +551,7 @@ public class FinancingController {
rows.add(new DebtLedgerRow(f.getId(), f.getCode(), f.getLender(), f.getFinancingType(),
f.getCompanySubject(), f.getStatus(), principal.doubleValue(),
paid.doubleValue(), unpaid.doubleValue(),
f.getRate() == null ? 0 : f.getRate(),
f.getRate() == null ? 0.0 : f.getRate().doubleValue(),
f.getEndDate() == null ? "" : f.getEndDate(),
daysToMaturity == Long.MAX_VALUE ? -999 : daysToMaturity,
maturityLevel));
@@ -561,7 +560,7 @@ public class FinancingController {
totalUnpaid = Money.add(totalUnpaid, unpaid);
if (f.getRate() != null) {
weightedRateSum = Money.add(weightedRateSum,
unpaid.multiply(BigDecimal.valueOf(f.getRate())));
unpaid.multiply(f.getRate()));
}
}
@@ -590,7 +589,6 @@ public class FinancingController {
* 若余量 < 0 则拒绝提交,给出明确提示,要求先调整授信或融资金额。
*/
@PostMapping("/{id}/submit-approval")
@Transactional
public ApiResp<FormInstance> submitApproval(@PathVariable Long id,
@RequestBody(required = false) ApprovalSubmitRequest req) {
Financing f = financingRepo.findById(id)
@@ -791,7 +789,7 @@ public class FinancingController {
rateMap.put("7%以上", new long[]{0, 0, 0});
for (Financing f : all) {
if (List.of("已结清", "已驳回").contains(nvl(f.getStatus()))) continue;
double r = f.getRate() == null ? 0 : f.getRate();
double r = f.getRate() == null ? 0 : f.getRate().doubleValue();
String bucket = r < 3 ? "3%以下" : r < 5 ? "3%-5%" : r < 7 ? "5%-7%" : "7%以上";
long[] slot = rateMap.get(bucket);
slot[0]++;
@@ -807,7 +805,7 @@ public class FinancingController {
// --- 高成本融资识别(年化利率 > benchmarkRate ---
List<Map<String, Object>> highCost = all.stream()
.filter(f -> !List.of("已结清", "已驳回").contains(nvl(f.getStatus())))
.filter(f -> f.getRate() != null && f.getRate() > benchmarkRate)
.filter(f -> f.getRate() != null && f.getRate().doubleValue() > benchmarkRate)
.map(f -> {
Map<String, Object> m = new LinkedHashMap<>();
m.put("financingId", f.getId());
@@ -816,8 +814,9 @@ public class FinancingController {
m.put("financingType", f.getFinancingType());
m.put("amount", Money.nz(f.getAmount()).doubleValue());
m.put("rate", f.getRate());
m.put("excessBps", Math.round((f.getRate() - benchmarkRate) * 100));
m.put("suggestion", f.getRate() - benchmarkRate > 2 ? "建议择机置换或提前还款" : "关注,可在续授信时争取降利率");
double rateVal = f.getRate().doubleValue();
m.put("excessBps", Math.round((rateVal - benchmarkRate) * 100));
m.put("suggestion", rateVal - benchmarkRate > 2 ? "建议择机置换或提前还款" : "关注,可在续授信时争取降利率");
return m;
})
.toList();
@@ -928,7 +927,6 @@ public class FinancingController {
}
@PostMapping("/{id}/legal-reviews/link")
@Transactional
public ApiResp<LegalRiskEvent> linkLegalReview(@PathVariable Long id,
@RequestBody LinkLegalReviewRequest req) {
if (!financingRepo.existsById(id)) {
@@ -957,7 +955,6 @@ public class FinancingController {
}
@PostMapping("/{id}/push-cost-alloc")
@Transactional
public ApiResp<PmtCostAllocRule> pushCostAlloc(@PathVariable Long id,
@RequestBody(required = false) PushCostAllocRequest req) {
Financing f = financingRepo.findById(id)
@@ -982,7 +979,7 @@ public class FinancingController {
.reduce(BigDecimal.ZERO, Money::add);
// 若无还款计划,按月息估算(本金 × 年化利率 / 12)
if (periodInterest.signum() == 0) {
double annualRate = f.getRate() == null ? 0.0 : f.getRate();
double annualRate = f.getRate() == null ? 0.0 : f.getRate().doubleValue();
periodInterest = Money.of(Money.nz(f.getAmount()).doubleValue() * annualRate / 100.0 / 12.0);
}
@@ -77,6 +77,7 @@ public class FinancingPolicyController {
String description, String status, String createdBy) {
}
@Transactional
@PostMapping
public ApiResp<FinancingPolicy> create(@RequestBody PolicyRequest req) {
if (req.ruleName() == null || req.ruleName().isBlank()) {
@@ -99,6 +100,7 @@ public class FinancingPolicyController {
return ApiResp.ok(policyRepo.save(p));
}
@Transactional
@PatchMapping("/{id}")
public ApiResp<FinancingPolicy> update(@PathVariable Long id, @RequestBody PolicyRequest req) {
FinancingPolicy p = policyRepo.findById(id)
@@ -115,7 +117,6 @@ public class FinancingPolicyController {
}
@DeleteMapping("/{id}")
@Transactional
public ApiResp<Void> delete(@PathVariable Long id) {
if (!policyRepo.existsById(id)) {
throw new NotFoundException("融资政策规则不存在: " + id);
@@ -148,6 +149,7 @@ public class FinancingPolicyController {
* - 担保方式限制:预留(financing 暂无担保字段,返回通过)。
* hardBlock=true 规则违规时,整体 overallPassed=false,调用方可据此阻断提交。
*/
@Transactional
@PostMapping("/{financingId}/compliance-check")
public ApiResp<ComplianceCheckResult> complianceCheck(@PathVariable Long financingId) {
Financing f = financingRepo.findById(financingId)
@@ -176,7 +178,7 @@ public class FinancingPolicyController {
}
}
case "利率上限" -> {
double rate = f.getRate() == null ? 0 : f.getRate();
double rate = f.getRate() == null ? 0 : f.getRate().doubleValue();
double limit = rule.getLimitValue() == null ? 0 : rule.getLimitValue().doubleValue();
if (limit > 0 && rate > limit) {
passed = false;
@@ -114,7 +114,7 @@ public class PmtContractFeeItemController {
LocalDate maturity = LocalDate.parse(contract.getMaturityDate());
long days = java.time.temporal.ChronoUnit.DAYS.between(sign, maturity);
if (days > 0) {
BigDecimal rate = BigDecimal.valueOf(contract.getContractRate()).divide(
BigDecimal rate = contract.getContractRate().divide(
BigDecimal.valueOf(100), 10, java.math.RoundingMode.HALF_UP);
BigDecimal years = BigDecimal.valueOf(days).divide(
BigDecimal.valueOf(365), 10, java.math.RoundingMode.HALF_UP);
@@ -164,7 +164,6 @@ public class PmtContractFeeItemController {
}
@PostMapping
@Transactional
public ApiResp<PmtContractFeeItem> create(@RequestBody FeeItemRequest req) {
if (req.contractId() == null) {
throw new ApiException(400, "contractId 不能为空");
@@ -197,7 +196,6 @@ public class PmtContractFeeItemController {
}
@PatchMapping("/{id}")
@Transactional
public ApiResp<PmtContractFeeItem> update(@PathVariable Long id,
@RequestBody FeeItemRequest req) {
PmtContractFeeItem item = feeRepo.findById(id)
@@ -221,7 +219,6 @@ public class PmtContractFeeItemController {
}
@DeleteMapping("/{id}")
@Transactional
public ApiResp<Void> delete(@PathVariable Long id) {
PmtContractFeeItem item = feeRepo.findById(id)
.orElseThrow(() -> new NotFoundException("费用明细不存在: " + id));
@@ -21,6 +21,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
@@ -73,11 +74,12 @@ public class PmtFinancingContractController {
public record ContractRequest(
Long financingId, String contractNo, String lender,
Double contractAmount, Double contractRate, String signDate, String maturityDate,
Double contractAmount, BigDecimal contractRate, String signDate, String maturityDate,
String repayMethod, String guaranteeType, String guaranteeDesc,
Double contractFee, String status, String remark, String owner) {
}
@Transactional
@PostMapping
public ApiResp<PmtFinancingContract> create(@RequestBody ContractRequest req) {
if (req.lender() == null || req.lender().isBlank()) {
@@ -115,6 +117,7 @@ public class PmtFinancingContractController {
return ApiResp.ok(contractRepo.save(c));
}
@Transactional
@PatchMapping("/{id}")
public ApiResp<PmtFinancingContract> update(@PathVariable Long id, @RequestBody ContractRequest req) {
PmtFinancingContract c = contractRepo.findById(id)
@@ -138,7 +141,6 @@ public class PmtFinancingContractController {
}
@DeleteMapping("/{id}")
@Transactional
public ApiResp<Void> delete(@PathVariable Long id) {
PmtFinancingContract c = contractRepo.findById(id)
.orElseThrow(() -> new NotFoundException("融资合同不存在: " + id));
@@ -153,7 +155,6 @@ public class PmtFinancingContractController {
/** 签署:草稿 → 已签署。 */
@PostMapping("/{id}/sign")
@Transactional
public ApiResp<PmtFinancingContract> sign(@PathVariable Long id) {
PmtFinancingContract c = contractRepo.findById(id)
.orElseThrow(() -> new NotFoundException("融资合同不存在: " + id));
@@ -175,7 +176,6 @@ public class PmtFinancingContractController {
* - drawnAmount >= contractAmount → 已用款
*/
@PostMapping("/{id}/drawdown")
@Transactional
public ApiResp<PmtFinancingContract> drawdown(@PathVariable Long id, @RequestBody DrawdownRequest req) {
PmtFinancingContract c = contractRepo.findById(id)
.orElseThrow(() -> new NotFoundException("融资合同不存在: " + id));
@@ -219,7 +219,6 @@ public class PmtFinancingContractController {
/** 结清:将合同状态置「已结清」(通常在所有还款完成后调用)。 */
@PostMapping("/{id}/settle")
@Transactional
public ApiResp<PmtFinancingContract> settle(@PathVariable Long id) {
PmtFinancingContract c = contractRepo.findById(id)
.orElseThrow(() -> new NotFoundException("融资合同不存在: " + id));
@@ -232,7 +231,6 @@ public class PmtFinancingContractController {
/** 终止合同。 */
@PostMapping("/{id}/terminate")
@Transactional
public ApiResp<PmtFinancingContract> terminate(@PathVariable Long id) {
PmtFinancingContract c = contractRepo.findById(id)
.orElseThrow(() -> new NotFoundException("融资合同不存在: " + id));
@@ -82,7 +82,7 @@ public class PmtInternalTransController {
public record TransRequest(
String transType, String payerSubject, String receiverSubject,
Double amount, String bizDate, String reconPeriod, String summary,
Double interestRate, String interestFrom, String interestTo,
BigDecimal interestRate, String interestFrom, String interestTo,
String createdBy) {
}
@@ -91,7 +91,6 @@ public class PmtInternalTransController {
* 同时自动为付款方生成应付单(ArApItem T_AP)、为收款方生成应收单(ArApItem T_AR)。
*/
@PostMapping
@Transactional
public ApiResp<PmtInternalTrans> create(@RequestBody TransRequest req) {
if (req.payerSubject() == null || req.payerSubject().isBlank()) {
throw new ApiException(400, "付款方主体(payerSubject) 不能为空");
@@ -123,7 +122,7 @@ public class PmtInternalTransController {
// 利息自动计算(仅 transType=利息 且提供了利率和区间)
if ("利息".equals(t.getTransType()) && req.interestRate() != null
&& req.interestFrom() != null && req.interestTo() != null) {
BigDecimal interest = calcInterest(Money.of(req.amount()), req.interestRate(),
BigDecimal interest = calcInterest(Money.of(req.amount()), req.interestRate().doubleValue(),
req.interestFrom(), req.interestTo());
t.setInterestAmount(interest);
t.setAmount(interest); // 利息单,金额即利息
@@ -166,6 +165,7 @@ public class PmtInternalTransController {
return ApiResp.ok(transRepo.save(saved));
}
@Transactional
@PatchMapping("/{id}")
public ApiResp<PmtInternalTrans> update(@PathVariable Long id, @RequestBody TransRequest req) {
PmtInternalTrans t = transRepo.findById(id)
@@ -180,7 +180,6 @@ public class PmtInternalTransController {
}
@DeleteMapping("/{id}")
@Transactional
public ApiResp<Void> delete(@PathVariable Long id) {
PmtInternalTrans t = transRepo.findById(id)
.orElseThrow(() -> new NotFoundException("内部往来单不存在: " + id));
@@ -202,7 +201,6 @@ public class PmtInternalTransController {
* 「有差异」状态需登记差异调整后方可结清。
*/
@PostMapping("/{id}/confirm")
@Transactional
public ApiResp<PmtInternalTrans> confirm(@PathVariable Long id, @RequestBody ConfirmRequest req) {
PmtInternalTrans t = transRepo.findById(id)
.orElseThrow(() -> new NotFoundException("内部往来单不存在: " + id));
@@ -235,7 +233,6 @@ public class PmtInternalTransController {
/** 标记差异已调整,进入可结清状态。 */
@PostMapping("/{id}/resolve-diff")
@Transactional
public ApiResp<PmtInternalTrans> resolveDiff(@PathVariable Long id,
@RequestBody Map<String, String> body) {
PmtInternalTrans t = transRepo.findById(id)
@@ -251,7 +248,6 @@ public class PmtInternalTransController {
/** 手动结清内部往来单(在「已确认」或「差异已调整」状态下可执行)。 */
@PostMapping("/{id}/settle")
@Transactional
public ApiResp<PmtInternalTrans> settle(@PathVariable Long id) {
PmtInternalTrans t = transRepo.findById(id)
.orElseThrow(() -> new NotFoundException("内部往来单不存在: " + id));
@@ -290,7 +286,7 @@ public class PmtInternalTransController {
// ---------- 利息计算(内部计息,按日) ----------
public record InterestCalcRequest(Double principal, Double annualRate, String from, String to) {
public record InterestCalcRequest(Double principal, BigDecimal annualRate, String from, String to) {
}
public record InterestCalcResult(double principal, double annualRate, String from, String to,
@@ -301,6 +297,7 @@ public class PmtInternalTransController {
* 内部计息预览(不落库):金额 × 年化利率 / 365 × 天数。
* 支持活期(上存利率)与贷款利率,利率单位 % ,如 3.5 表示 3.5%。
*/
@Transactional
@PostMapping("/calc-interest")
public ApiResp<InterestCalcResult> calcInterestPreview(@RequestBody InterestCalcRequest req) {
if (req.principal() == null || req.annualRate() == null
@@ -312,9 +309,10 @@ public class PmtInternalTransController {
if (days <= 0) {
throw new ApiException(400, "计息截止日必须晚于起始日");
}
BigDecimal interest = calcInterest(principal, req.annualRate(), req.from(), req.to());
double annualRateDouble = req.annualRate().doubleValue();
BigDecimal interest = calcInterest(principal, annualRateDouble, req.from(), req.to());
return ApiResp.ok(new InterestCalcResult(
principal.doubleValue(), req.annualRate(), req.from(), req.to(),
principal.doubleValue(), annualRateDouble, req.from(), req.to(),
days, interest.doubleValue()));
}
@@ -326,7 +324,6 @@ public class PmtInternalTransController {
* 凭证生成后状态流转为「已结清」(已转凭证即代表本期利息已处理完毕)。
*/
@PostMapping("/{id}/to-voucher")
@Transactional
public ApiResp<Voucher> toVoucher(@PathVariable Long id) {
PmtInternalTrans t = transRepo.findById(id)
.orElseThrow(() -> new NotFoundException("内部往来单不存在: " + id));
@@ -73,18 +73,17 @@ public class PmtLoanSchemeController {
public record SchemeRequest(
Long financingId, String institution, String loanType,
Double amount, Double annualRate, Integer termMonths, String repayMethod,
Double handlingFee, String guaranteeType, Double guaranteeRate,
Double amount, BigDecimal annualRate, Integer termMonths, String repayMethod,
Double handlingFee, String guaranteeType, BigDecimal guaranteeRate,
String drawdownConditions, String status, String remark, String owner) {
}
@PostMapping
@Transactional
public ApiResp<PmtLoanScheme> create(@RequestBody SchemeRequest req) {
if (req.institution() == null || req.institution().isBlank()) {
throw new ApiException(400, "报价机构名称(institution) 不能为空");
}
if (req.annualRate() == null || req.annualRate() < 0) {
if (req.annualRate() == null || req.annualRate().compareTo(BigDecimal.ZERO) < 0) {
throw new ApiException(400, "年化利率(annualRate) 不能为空且须 >= 0");
}
PmtLoanScheme s = new PmtLoanScheme();
@@ -96,7 +95,7 @@ public class PmtLoanSchemeController {
s.setRepayMethod(req.repayMethod() == null ? "等额本息" : req.repayMethod());
s.setHandlingFee(Money.of(req.handlingFee()));
s.setGuaranteeType(req.guaranteeType());
s.setGuaranteeRate(req.guaranteeRate() == null ? 0.0 : req.guaranteeRate());
s.setGuaranteeRate(req.guaranteeRate() == null ? BigDecimal.ZERO : req.guaranteeRate());
s.setDrawdownConditions(req.drawdownConditions());
s.setOwner(req.owner());
s.setStatus(req.status() == null || req.status().isBlank() ? "待比选" : req.status());
@@ -118,7 +117,6 @@ public class PmtLoanSchemeController {
}
@PatchMapping("/{id}")
@Transactional
public ApiResp<PmtLoanScheme> update(@PathVariable Long id, @RequestBody SchemeRequest req) {
PmtLoanScheme s = schemeRepo.findById(id)
.orElseThrow(() -> new NotFoundException("融资方案不存在: " + id));
@@ -144,7 +142,6 @@ public class PmtLoanSchemeController {
}
@DeleteMapping("/{id}")
@Transactional
public ApiResp<Void> delete(@PathVariable Long id) {
PmtLoanScheme s = schemeRepo.findById(id)
.orElseThrow(() -> new NotFoundException("融资方案不存在: " + id));
@@ -162,7 +159,6 @@ public class PmtLoanSchemeController {
* 同时重算各方案的综合成本率(幂等)。
*/
@PostMapping("/rank")
@Transactional
public ApiResp<List<PmtLoanScheme>> rank(@RequestParam Long financingId) {
List<PmtLoanScheme> schemes = schemeRepo.findByFinancingId(financingId);
if (schemes.isEmpty()) {
@@ -172,9 +168,9 @@ public class PmtLoanSchemeController {
schemes.forEach(s -> s.setEffectiveCostRate(calcEffectiveCostRate(s)));
// 按综合成本升序排
schemes.sort((a, b) -> {
double ra = a.getEffectiveCostRate() == null ? Double.MAX_VALUE : a.getEffectiveCostRate();
double rb = b.getEffectiveCostRate() == null ? Double.MAX_VALUE : b.getEffectiveCostRate();
return Double.compare(ra, rb);
BigDecimal ra = a.getEffectiveCostRate() == null ? BigDecimal.valueOf(Double.MAX_VALUE) : a.getEffectiveCostRate();
BigDecimal rb = b.getEffectiveCostRate() == null ? BigDecimal.valueOf(Double.MAX_VALUE) : b.getEffectiveCostRate();
return ra.compareTo(rb);
});
for (int i = 0; i < schemes.size(); i++) {
schemes.get(i).setRank(i + 1);
@@ -188,7 +184,6 @@ public class PmtLoanSchemeController {
/** 标注推荐方案(首选)。同一 financingId 下只能有一个推荐方案;旧推荐自动清除。 */
@PostMapping("/{id}/recommend")
@Transactional
public ApiResp<PmtLoanScheme> recommend(@PathVariable Long id,
@RequestBody(required = false) RecommendRequest req) {
PmtLoanScheme s = schemeRepo.findById(id)
@@ -212,7 +207,6 @@ public class PmtLoanSchemeController {
* 同时回写 Financing 台账的 lender/rate/repayMethod,融资状态推进到「审批中」。
*/
@PostMapping("/{id}/select")
@Transactional
public ApiResp<PmtLoanScheme> select(@PathVariable Long id) {
PmtLoanScheme s = schemeRepo.findById(id)
.orElseThrow(() -> new NotFoundException("融资方案不存在: " + id));
@@ -258,9 +252,9 @@ public class PmtLoanSchemeController {
if (schemes.isEmpty()) {
return ApiResp.ok(new SchemeCompareSummary(schemes, 0, 0, ""));
}
double min = schemes.stream().mapToDouble(s -> s.getEffectiveCostRate() == null ? 0 : s.getEffectiveCostRate())
double min = schemes.stream().mapToDouble(s -> s.getEffectiveCostRate() == null ? 0 : s.getEffectiveCostRate().doubleValue())
.min().orElse(0);
double max = schemes.stream().mapToDouble(s -> s.getEffectiveCostRate() == null ? 0 : s.getEffectiveCostRate())
double max = schemes.stream().mapToDouble(s -> s.getEffectiveCostRate() == null ? 0 : s.getEffectiveCostRate().doubleValue())
.max().orElse(0);
String recommended = schemes.stream()
.filter(s -> Boolean.TRUE.equals(s.getRecommended()))
@@ -276,11 +270,11 @@ public class PmtLoanSchemeController {
* 利息按等额本息近似计算总利息;担保费按本金 × 年担保费率 × 期限(年) 计算。
* 真实 XIRR 需逐笔现金流,此处为可读近似值,误差在 0.1% 以内(满足决策比选精度)。
*/
private double calcEffectiveCostRate(PmtLoanScheme s) {
private BigDecimal calcEffectiveCostRate(PmtLoanScheme s) {
double principal = s.getAmount() == null ? 0 : s.getAmount().doubleValue();
if (principal <= 0) return 0.0;
if (principal <= 0) return BigDecimal.ZERO;
int term = s.getTermMonths() == null || s.getTermMonths() <= 0 ? 12 : s.getTermMonths();
double annualRate = s.getAnnualRate() == null ? 0 : s.getAnnualRate();
double annualRate = s.getAnnualRate() == null ? 0 : s.getAnnualRate().doubleValue();
double monthlyRate = annualRate / 100.0 / 12.0;
// 总利息(等额本息口径,兼容多还款方式近似)
@@ -297,7 +291,7 @@ public class PmtLoanSchemeController {
double handlingFee = s.getHandlingFee() == null ? 0 : s.getHandlingFee().doubleValue();
// 担保费(年化率 × 期限年数 × 本金)
double guaranteeRate = s.getGuaranteeRate() == null ? 0 : s.getGuaranteeRate();
double guaranteeRate = s.getGuaranteeRate() == null ? 0 : s.getGuaranteeRate().doubleValue();
double years = term / 12.0;
double guaranteeCost = principal * guaranteeRate / 100.0 * years;
@@ -305,6 +299,6 @@ public class PmtLoanSchemeController {
// 年化综合成本率(百分比)
double effectiveRate = (totalCost / principal) / years * 100.0;
return BigDecimal.valueOf(effectiveRate).setScale(4, RoundingMode.HALF_UP).doubleValue();
return BigDecimal.valueOf(effectiveRate).setScale(4, RoundingMode.HALF_UP);
}
}
@@ -191,7 +191,7 @@ public class StlDebtLedgerController {
activeCount++;
totalDebt = Money.add(totalDebt, principal);
if (f.getRate() != null) {
BigDecimal rateVal = BigDecimal.valueOf(f.getRate());
BigDecimal rateVal = f.getRate();
rateWeightedSum = rateWeightedSum.add(principal.multiply(rateVal));
rateWeightTotal = rateWeightTotal.add(principal);
}
@@ -212,7 +212,7 @@ public class StlDebtLedgerController {
principal.doubleValue(), remaining.doubleValue(),
paidPrin.doubleValue(), paidInterest.doubleValue(),
overdueAmt.doubleValue(),
f.getEndDate(), f.getRate() != null ? f.getRate() : 0.0,
f.getEndDate(), f.getRate() != null ? f.getRate().doubleValue() : 0.0,
f.getStatus(), planRows));
}
@@ -245,7 +245,6 @@ public class StlDebtLedgerController {
* <p>凭证科目:借方=长期借款(或按融资类型映射),贷方=银行存款,状态=草稿。
*/
@PostMapping("/repayments/{planId}/pay-and-voucher")
@Transactional
public ApiResp<RepayVoucherResult> payAndVoucher(@PathVariable Long planId) {
RepaymentPlan plan = planRepo.findById(planId)
.orElseThrow(() -> new NotFoundException("还款期次不存在: " + planId));
@@ -434,7 +434,7 @@ public class StlFundReportController {
if (ls.getAnnualRate() == null || ls.getAmount() == null) continue;
// 年利息 = 贷款金额 × 年化利率 / 100
BigDecimal yearInterest = Money.nz(ls.getAmount())
.multiply(BigDecimal.valueOf(ls.getAnnualRate() / 100.0))
.multiply(ls.getAnnualRate().divide(BigDecimal.valueOf(100), 10, RoundingMode.HALF_UP))
.setScale(2, RoundingMode.HALF_UP);
externalInterest = externalInterest.add(yearInterest);
Map<String, Object> row = new LinkedHashMap<>();
@@ -0,0 +1,42 @@
package com.kaidi.oa.web;
import com.kaidi.oa.common.ApiResp;
import com.kaidi.oa.service.SystemUpdateService;
import com.kaidi.oa.service.SystemUpdateService.UpdateStatus;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** Administrator-only API for checking and installing signed Gitea releases. */
@RestController
@RequestMapping("/api/oa/system-update")
public class SystemUpdateController {
private final SystemUpdateService updateService;
public SystemUpdateController(SystemUpdateService updateService) {
this.updateService = updateService;
}
@GetMapping("/status")
public ApiResp<UpdateStatus> status() {
return ApiResp.ok(updateService.status());
}
@PostMapping("/check")
public ApiResp<UpdateStatus> check() {
return ApiResp.ok(updateService.check());
}
@PostMapping("/install")
public ApiResp<UpdateStatus> install(@Valid @RequestBody InstallRequest request) {
return ApiResp.ok(updateService.install(request.version()));
}
public record InstallRequest(@NotBlank String version) {
}
}
@@ -1,22 +1,29 @@
# PostgreSQL profile stub. Activate with: --spring.profiles.active=postgres
# Add the Postgres driver to build.gradle before using:
# runtimeOnly 'org.postgresql:postgresql'
#
# Switching from SQLite to PostgreSQL is config-only: the JPA entities and the
# ddl-auto strategy below recreate/update the schema on the Postgres server.
# Production PostgreSQL profile. Activate with SPRING_PROFILES_ACTIVE=postgres.
# Credentials are intentionally supplied by the installer/runtime environment.
spring:
datasource:
url: jdbc:postgresql://localhost:5432/oa
url: ${OA_DB_URL}
driver-class-name: org.postgresql.Driver
username: oa
password: oa
username: ${OA_DB_USERNAME}
password: ${OA_DB_PASSWORD}
hikari:
maximum-pool-size: ${OA_DB_POOL_MAX:20}
minimum-idle: ${OA_DB_POOL_MIN:2}
connection-timeout: 10000
validation-timeout: 5000
jpa:
hibernate:
ddl-auto: update
ddl-auto: validate
database-platform: org.hibernate.dialect.PostgreSQLDialect
open-in-view: false
show-sql: false
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
flyway:
enabled: true
locations: classpath:db/migration/postgresql
clean-disabled: true
validate-on-migrate: true
baseline-on-migrate: false
+18 -5
View File
@@ -10,17 +10,17 @@ spring:
max-file-size: 50MB
max-request-size: 60MB
# ---------------------------------------------------------------------------
# Active datasource: SQLite (file-based, zero-setup). The MySQL and Postgres
# profiles are provided as stubs (application-mysql.yml / application-postgres.yml)
# so switching databases later is config-only: run with
# --spring.profiles.active=mysql (or postgres)
# and supply the JDBC url/credentials there. No Java code changes required.
# Local development defaults to SQLite. Production must activate the postgres
# profile and provide OA_DB_URL/OA_DB_USERNAME/OA_DB_PASSWORD.
# ---------------------------------------------------------------------------
datasource:
url: jdbc:sqlite:./data/oa.db
driver-class-name: org.sqlite.JDBC
username:
password:
hikari:
maximum-pool-size: 2
minimum-idle: 1
jpa:
hibernate:
ddl-auto: update
@@ -35,6 +35,10 @@ spring:
# 逐表读取列元数据,避免 SQLite 在表数量较多时报
# "too many terms in compound SELECT"(启动期 schema 校验崩溃)。
jdbc_metadata_extraction_strategy: individually
flyway:
# SQLite remains a zero-setup local development option only. Production
# migrations are enabled by application-postgres.yml.
enabled: false
# CORS origins allowed for /api/** are configured in WebConfig (CorsConfig).
# Allowed: http://localhost:8080, http://localhost:5173, http://127.0.0.1:*
@@ -53,3 +57,12 @@ oa:
ai:
api-key: ""
model: claude-haiku-4-5-20251001
update:
enabled: ${OA_UPDATE_ENABLED:false}
gitea-base-url: ${OA_UPDATE_GITEA_BASE_URL:}
repository: ${OA_UPDATE_REPOSITORY:awaioi/ERP}
channel: ${OA_UPDATE_CHANNEL:stable}
token: ${OA_UPDATE_TOKEN:}
helper-command: ${OA_UPDATE_HELPER_COMMAND:}
state-file: ${OA_UPDATE_STATE_FILE:./runtime/update-state.json}
allow-insecure-http: ${OA_UPDATE_ALLOW_INSECURE_HTTP:false}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,132 @@
package com.kaidi.oa.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kaidi.oa.common.ApiException;
import com.kaidi.oa.config.UpdateProperties;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.info.BuildProperties;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.Properties;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class SystemUpdateServiceTest {
@Test
void comparesReleaseVersionsWithoutLexicographicMistakes() {
assertThat(SystemUpdateService.compareVersions("v0.10.0", "0.9.9")).isPositive();
assertThat(SystemUpdateService.compareVersions("1.0.0", "1.0.0-rc.1")).isPositive();
assertThat(SystemUpdateService.compareVersions("1.2", "1.2.0")).isZero();
assertThat(SystemUpdateService.compareVersions("1.0.0+build.2", "1.0.0+build.1")).isZero();
assertThat(SystemUpdateService.compareVersions("1.0.0-alpha.10", "1.0.0-alpha.2")).isPositive();
assertThat(SystemUpdateService.compareVersions("1.0.0-alpha.1", "1.0.0-alpha.beta")).isNegative();
assertThat(SystemUpdateService.compareVersions("999999999999999999999.0.0", "2.0.0")).isPositive();
assertThat(SystemUpdateService.compareVersions("1.0.0", "dev")).isPositive();
}
@Test
void checksLatestGiteaReleaseAndRequiresSignedAssetSet() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/api/v1/repos/awaioi/ERP/releases/latest", exchange -> {
byte[] body = """
{
"tag_name": "v0.2.0",
"body": "PostgreSQL production release",
"draft": false,
"prerelease": false,
"published_at": "2026-08-03T10:00:00Z",
"assets": [
{"name":"kaidi-erp-0.2.0.tar.gz","browser_download_url":"https://example.test/app","size":123},
{"name":"SHA256SUMS","browser_download_url":"https://example.test/sums","size":64},
{"name":"SHA256SUMS.sig","browser_download_url":"https://example.test/sig","size":64}
]
}
""".getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "application/json");
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
});
server.start();
try {
UpdateProperties properties = configuredProperties(server.getAddress().getPort());
SystemUpdateService service = service(properties, "0.1.0");
SystemUpdateService.UpdateStatus status = service.check();
assertThat(status.updateAvailable()).isTrue();
assertThat(status.currentVersion()).isEqualTo("0.1.0");
assertThat(status.latestVersion()).isEqualTo("0.2.0");
assertThat(status.assets()).extracting(SystemUpdateService.ReleaseAsset::name)
.containsExactly("kaidi-erp-0.2.0.tar.gz", "SHA256SUMS", "SHA256SUMS.sig");
} finally {
server.stop(0);
}
}
@Test
void rejectsPlainHttpUnlessDevelopmentOverrideIsExplicit() {
UpdateProperties properties = configuredProperties(1);
properties.setAllowInsecureHttp(false);
SystemUpdateService service = service(properties, "0.1.0");
assertThatThrownBy(service::check)
.isInstanceOf(ApiException.class)
.hasMessageContaining("HTTPS");
}
@Test
void persistedHelperStateComputesAvailabilityFromVersionAndKeepsTimestamp() throws Exception {
Path stateFile = Files.createTempFile("system-update-state-", ".json");
try {
UpdateProperties properties = configuredProperties(1);
properties.setStateFile(stateFile.toString());
SystemUpdateService service = service(properties, "0.2.0");
Files.writeString(stateFile, """
{"phase":"FAILED","progress":0,"message":"update failed",\
"version":"0.2.0","updatedAt":"2026-08-03T12:34:56Z"}
""");
SystemUpdateService.UpdateStatus failed = service.status();
assertThat(failed.updateAvailable()).isFalse();
assertThat(failed.checkedAt()).isEqualTo(Instant.parse("2026-08-03T12:34:56Z"));
Files.writeString(stateFile, """
{"phase":"ROLLED_BACK","progress":100,"message":"rolled back",\
"version":"0.3.0","updatedAt":"2026-08-03T12:35:56Z"}
""");
assertThat(service.status().updateAvailable()).isTrue();
} finally {
Files.deleteIfExists(stateFile);
}
}
private static UpdateProperties configuredProperties(int port) {
UpdateProperties properties = new UpdateProperties();
properties.setEnabled(true);
properties.setAllowInsecureHttp(true);
properties.setGiteaBaseUrl("http://127.0.0.1:" + port);
properties.setRepository("awaioi/ERP");
return properties;
}
@SuppressWarnings("unchecked")
private static SystemUpdateService service(UpdateProperties properties, String version) {
Properties buildValues = new Properties();
buildValues.setProperty("version", version);
ObjectProvider<BuildProperties> provider = mock(ObjectProvider.class);
when(provider.getIfAvailable()).thenReturn(new BuildProperties(buildValues));
return new SystemUpdateService(properties, new ObjectMapper(), provider);
}
}
+20 -5
View File
@@ -5,12 +5,28 @@
set -uo pipefail
B="${OA:-http://localhost:8090}/api/oa"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
pass=0; fail=0; total=0
TOKEN=""
J(){ python3 -c "import json,sys;d=json.load(open('$1'));print(eval(sys.argv[1]))" "$2" 2>/dev/null; }
chk(){ total=$((total+1)); if [ "$2" = "$3" ]; then pass=$((pass+1)); printf " ok %-46s %s\n" "$1" "$3"; else fail=$((fail+1)); printf " XX %-46s got=%s want=%s\n" "$1" "$2" "$3"; fi; }
chkge(){ total=$((total+1)); if [ "${2:-0}" -ge "$3" ] 2>/dev/null; then pass=$((pass+1)); printf " ok %-46s >=%s (=%s)\n" "$1" "$3" "$2"; else fail=$((fail+1)); printf " XX %-46s got=%s want>=%s\n" "$1" "$2" "$3"; fi; }
GET(){ curl -s -o "$TMP/r.json" -w "%{http_code}" "$B/$1"; }
POST(){ curl -s -o "$TMP/r.json" -w "%{http_code}" -X POST "$B/$1" -H 'Content-Type: application/json' -d "$2"; }
GET(){
if [ -n "$TOKEN" ]; then
curl -sS -H "Authorization: Bearer $TOKEN" -o "$TMP/r.json" -w "%{http_code}" "$B/$1"
else
curl -sS -o "$TMP/r.json" -w "%{http_code}" "$B/$1"
fi
}
POST(){
if [ -n "$TOKEN" ]; then
curl -sS -H "Authorization: Bearer $TOKEN" -o "$TMP/r.json" -w "%{http_code}" \
-X POST "$B/$1" -H 'Content-Type: application/json' -d "$2"
else
curl -sS -o "$TMP/r.json" -w "%{http_code}" \
-X POST "$B/$1" -H 'Content-Type: application/json' -d "$2"
fi
}
echo "== AUTH =="
code=$(POST auth/login '{"loginName":"admin","password":"123456"}'); chk "login admin valid" "$(J $TMP/r.json "d['code']")" 0
@@ -19,7 +35,7 @@ code=$(POST auth/login '{"loginName":"admin","password":"wrong"}'); chk "login w
code=$(POST auth/login '{"loginName":"ghost","password":"x"}'); chk "login unknown user -> code!=0" "$(J $TMP/r.json "1 if d['code']!=0 else 0")" 1
echo "== READ ENDPOINTS (all should code=0) =="
for ep in "health" "form-templates" "form-templates?category=财务审批" "tasks?type=todo" "tasks?type=done" "tasks?type=sent" "tasks?type=draft" "meetings" "meeting-rooms" "minutes" "schedule-events" "announcements" "announcements?category=公告" "folders/tree" "documents" "users" "depts/tree" "roles"; do
for ep in "health" "form-templates" "form-templates?category=%E8%B4%A2%E5%8A%A1%E5%AE%A1%E6%89%B9" "tasks?type=todo" "tasks?type=done" "tasks?type=sent" "tasks?type=draft" "meetings" "meeting-rooms" "minutes" "schedule-events" "announcements" "announcements?category=%E5%85%AC%E5%91%8A" "folders/tree" "documents" "users" "depts/tree" "roles"; do
GET "$ep" >/dev/null; chk "GET $ep" "$(J $TMP/r.json "d['code']")" 0
done
@@ -61,12 +77,11 @@ POST "form-instances/$DID/send" '' >/dev/null; chk "send draft -> 待办" "$(J $
POST "form-instances/$DID/send" '' >/dev/null; chk "re-send -> 400" "$(J $TMP/r.json "d['code']")" 400
echo "== WRITE: meeting / schedule / announcement create =="
POST "meetings" '{"subject":"itest会议","startTime":"2026-07-01T02:00:00Z","endTime":"2026-07-01T03:00:00Z","roomId":1,"organizer":"itest","status":"已预定"}' >/dev/null; chk "createMeeting code=0" "$(J $TMP/r.json "d['code']")" 0
POST "meetings" '{"subject":"itest会议","startTime":"2026-07-01T02:00:00Z","endTime":"2026-07-01T03:00:00Z","organizer":"itest","status":"已预定"}' >/dev/null; chk "createMeeting code=0" "$(J $TMP/r.json "d['code']")" 0
POST "schedule-events" '{"title":"itest事件","type":"会议","startTime":"2026-07-01T02:00:00Z","endTime":"2026-07-01T03:00:00Z","owner":"itest"}' >/dev/null; chk "createEvent code=0" "$(J $TMP/r.json "d['code']")" 0
POST "announcements" '{"category":"公告","title":"itest公告","content":"x","author":"itest","top":false}' >/dev/null; chk "createAnnouncement code=0" "$(J $TMP/r.json "d['code']")" 0
POST "form-templates" '{"name":"itest模板","category":"测试","org":"信息中心","form":{"title":"t","rows":[]},"flow":{"nodes":[],"edges":[]}}' >/dev/null; chk "createTemplate code=0" "$(J $TMP/r.json "d['code']")" 0
rm -rf "$TMP"
echo
pct=$(python3 -c "print(round($pass/$total*100,1))")
printf "RESULT: %d/%d passed (%.1f%%), %d failed\n" "$pass" "$total" "$pct" "$fail"
+25 -12
View File
@@ -8,7 +8,10 @@
set -uo pipefail
OA="${OA:-http://localhost:8090}"
B="$OA/api/oa"
LOGIN_NAME="${OA_LOGIN_NAME:-admin}"
LOGIN_PASSWORD="${OA_LOGIN_PASSWORD:-123456}"
pass=0; fail=0
TOKEN=""
jqget() { python3 -c "import sys,json;d=json.load(sys.stdin);print(eval('d'+sys.argv[1]))" "$1" 2>/dev/null; }
step() { printf "\n\033[1m== %s ==\033[0m\n" "$1"; }
@@ -20,8 +23,18 @@ H=$(curl -s "$B/health")
echo " $H"
echo "$H" | grep -q '"code":0' && ok "health code=0" || no "health"
step "2. list form-templates"
T=$(curl -s "$B/form-templates")
step "2. authenticate"
L=$(curl -sS -X POST "$B/auth/login" -H 'Content-Type: application/json' \
-d "{\"loginName\":\"$LOGIN_NAME\",\"password\":\"$LOGIN_PASSWORD\"}")
TOKEN=$(echo "$L" | jqget "['data']['token']")
if [ -n "${TOKEN:-}" ] && [ "$TOKEN" != "None" ]; then
ok "authenticated as $LOGIN_NAME"
else
no "login failed: $L"
fi
step "3. list form-templates"
T=$(curl -sS -H "Authorization: Bearer $TOKEN" "$B/form-templates")
N=$(echo "$T" | jqget "['data'].__len__()")
echo " templates returned: ${N:-?}"
[ "${N:-0}" -ge 1 ] 2>/dev/null && ok "got >=1 template" || no "no templates seeded"
@@ -29,8 +42,8 @@ TPL=$(echo "$T" | jqget "['data'][0]['id']")
TPLNAME=$(echo "$T" | jqget "['data'][0]['name']")
echo " using template: $TPL ($TPLNAME)"
step "3. submit a form-instance"
S=$(curl -s -X POST "$B/form-instances" -H 'Content-Type: application/json' \
step "4. submit a form-instance"
S=$(curl -sS -H "Authorization: Bearer $TOKEN" -X POST "$B/form-instances" -H 'Content-Type: application/json' \
-d "{\"templateId\":\"$TPL\",\"data\":{\"_smoke\":\"1\"},\"title\":\"冒烟测试事项\"}")
ID=$(echo "$S" | jqget "['data']['id']")
ST=$(echo "$S" | jqget "['data']['status']")
@@ -38,16 +51,16 @@ NODE=$(echo "$S" | jqget "['data']['currentNode']")
echo " instance id=$ID status=$ST node=$NODE"
[ -n "${ID:-}" ] && [ "$ID" != "None" ] && ok "submitted, got id" || no "submit failed: $S"
step "4. get instance detail (template+flow+trace)"
D=$(curl -s "$B/form-instances/$ID")
step "5. get instance detail (template+flow+trace)"
D=$(curl -sS -H "Authorization: Bearer $TOKEN" "$B/form-instances/$ID")
TRN=$(echo "$D" | jqget "['data']['trace'].__len__()")
HASFLOW=$(echo "$D" | jqget "(1 if d['data'].get('flow') else 0)")
echo " trace steps=$TRN hasFlow=$HASFLOW"
[ "${TRN:-0}" -ge 1 ] 2>/dev/null && ok "trace has 发起 step" || no "no trace"
step "5. advance 同意 until 办结 (max 8 hops)"
step "6. advance 同意 until 办结 (max 8 hops)"
for i in $(seq 1 8); do
A=$(curl -s -X POST "$B/form-instances/$ID/advance" -H 'Content-Type: application/json' \
A=$(curl -sS -H "Authorization: Bearer $TOKEN" -X POST "$B/form-instances/$ID/advance" -H 'Content-Type: application/json' \
-d '{"action":"同意","opinion":"冒烟同意"}')
ST=$(echo "$A" | jqget "['data']['status']")
NODE=$(echo "$A" | jqget "['data']['currentNode']")
@@ -56,9 +69,9 @@ for i in $(seq 1 8); do
done
[ "${ST:-}" = "已办结" ] && ok "reached 已办结" || no "did not finish (status=$ST)"
step "6. list work boxes (GET /tasks?type=todo|done|sent|draft)"
step "7. list work boxes (GET /tasks?type=todo|done|sent|draft)"
for box in todo done sent draft; do
R=$(curl -s "$B/tasks?type=$box")
R=$(curl -sS -H "Authorization: Bearer $TOKEN" "$B/tasks?type=$box")
C=$(echo "$R" | jqget "['data'].__len__()")
if echo "$R" | grep -q '"code":0'; then
echo " type=$box -> ${C:-0} items"; pass=$((pass+1))
@@ -67,9 +80,9 @@ for box in todo done sent draft; do
fi
done
step "7. depts tree + users + announcements (module endpoints)"
step "8. depts tree + users + announcements (module endpoints)"
for ep in "depts/tree" "folders/tree"; do
R=$(curl -s "$B/$ep")
R=$(curl -sS -H "Authorization: Bearer $TOKEN" "$B/$ep")
echo "$R" | grep -q '"code":0' && ok "$ep ok" || no "$ep ($(echo "$R" | head -c 60))"
done
@@ -98,6 +98,7 @@ export const oaModules: OaModule[] = [
{ key: 'workbench', label: '工作台', kind: 'portal', path: '/appdev/workbench' },
{ key: 'appmgr', label: '应用管理中心', kind: 'list', path: '/appdev/appmgr' },
{ key: 'ops', label: '运维中心', kind: 'settings', path: '/appdev/ops' },
{ key: 'update', label: '系统更新', kind: 'settings', path: '/appdev/update' },
{ key: 'monitor', label: '监测中心', kind: 'report', path: '/appdev/monitor' },
{ key: 'aiassist', label: 'AI 助手', kind: 'portal', path: '/appdev/aiassist' },
{ key: 'ruleconfig', label: '联动规则配置', kind: 'list', path: '/appdev/ruleconfig' }
@@ -75,7 +75,15 @@ export interface RequestOptions {
}
function buildUrl(path: string, query?: RequestOptions['query']): string {
const base = path.startsWith('http') ? path : OA_API_BASE + (path.startsWith('/') ? path : '/' + path)
// Older generated pages sometimes pass the already-prefixed /api/oa path.
// Normalize it so both call styles resolve to the same backend endpoint.
const relativePath = OA_API_BASE.endsWith('/api/oa')
&& (path === '/api/oa' || path.startsWith('/api/oa/'))
? path.slice('/api/oa'.length) || '/'
: path
const base = relativePath.startsWith('http')
? relativePath
: OA_API_BASE + (relativePath.startsWith('/') ? relativePath : '/' + relativePath)
if (!query) return base
const usp = new URLSearchParams()
for (const [k, v] of Object.entries(query)) {
@@ -54,13 +54,14 @@ import * as designApi from './design'
import * as engExtApi from './engext'
import * as governanceApi from './governance'
import * as alertsApi from './alerts'
import * as updateApi from './update'
export {
authApi, templateApi, instanceApi, meetingApi, scheduleApi, announcementApi, docApi, orgApi,
projectApi, collabDocApi, communityApi, tripApi, reportApi, reportDefApi, masterApi, paymentApi, sealUseApi,
archiveApi, crmApi, ehsApi, budgetApi, bidApi, intelApi, rdApi, contractCenterApi, costingApi, crawlApi,
searchApi, fundPoolApi, rdExtraApi, qhseApi, opsApi, mfgApi, financeApi, labApi, designApi, engExtApi,
governanceApi, alertsApi
governanceApi, alertsApi, updateApi
}
export const oaApi = {
@@ -186,7 +187,11 @@ export const oaApi = {
// 监测中心(应用定制平台运行聚合)
getAppMonitoring: reportApi.getAppMonitoring,
// 统一预警(全平台期限/异常聚合)
listAlerts: alertsApi.listAlerts
listAlerts: alertsApi.listAlerts,
// 系统更新
getSystemUpdateStatus: updateApi.getSystemUpdateStatus,
checkSystemUpdate: updateApi.checkSystemUpdate,
installSystemUpdate: updateApi.installSystemUpdate
}
export type { OaSession } from './auth'
@@ -211,6 +216,7 @@ export type {
ReportRunRow, ReportRunResult
} from './reportdefs'
export type { Alert } from './alerts'
export type { SystemUpdateStatus, UpdatePhase, ReleaseAsset } from './update'
export type {
CompanySubject, Contract, Supplier, Customer, BankAccount, Seal, Invoice, ContractMilestone
} from './masterdata'
@@ -0,0 +1,49 @@
import { http } from './http'
export type UpdatePhase =
| 'IDLE'
| 'CHECKING'
| 'AVAILABLE'
| 'UP_TO_DATE'
| 'STARTING'
| 'DOWNLOADING'
| 'VERIFYING'
| 'INSTALLING'
| 'RESTARTING'
| 'SUCCEEDED'
| 'ROLLING_BACK'
| 'ROLLED_BACK'
| 'FAILED'
export interface ReleaseAsset {
name: string
downloadUrl: string
size: number
}
export interface SystemUpdateStatus {
configured: boolean
currentVersion: string
latestVersion: string | null
updateAvailable: boolean
phase: UpdatePhase
progress: number
message: string
checkedAt: string | null
publishedAt: string | null
releaseNotes: string
assets: ReleaseAsset[]
error: string | null
}
export function getSystemUpdateStatus() {
return http.get<SystemUpdateStatus>('/system-update/status')
}
export function checkSystemUpdate() {
return http.post<SystemUpdateStatus>('/system-update/check', undefined, { timeoutMs: 30000 })
}
export function installSystemUpdate(version: string) {
return http.post<SystemUpdateStatus>('/system-update/install', { version }, { timeoutMs: 30000 })
}
@@ -0,0 +1,326 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Download, Refresh, Warning } from '@element-plus/icons-vue'
import ErpPageHeader from '../../../components/erp/ErpPageHeader.vue'
import { oaApi, OaApiError, type SystemUpdateStatus, type UpdatePhase } from '../../api'
const status = ref<SystemUpdateStatus | null>(null)
const loading = ref(false)
const checking = ref(false)
const installing = ref(false)
let pollTimer: number | undefined
const activePhases = new Set<UpdatePhase>([
'STARTING', 'DOWNLOADING', 'VERIFYING', 'INSTALLING', 'RESTARTING', 'ROLLING_BACK'
])
const phaseLabels: Record<UpdatePhase, string> = {
IDLE: '等待检查',
CHECKING: '正在检查',
AVAILABLE: '可更新',
UP_TO_DATE: '已是最新',
STARTING: '准备更新',
DOWNLOADING: '正在下载',
VERIFYING: '正在验签',
INSTALLING: '正在安装',
RESTARTING: '正在重启',
SUCCEEDED: '更新完成',
ROLLING_BACK: '正在回滚',
ROLLED_BACK: '已回滚',
FAILED: '更新失败'
}
const busy = computed(() => !!status.value && activePhases.has(status.value.phase))
const canInstall = computed(() => Boolean(
status.value?.configured && status.value.updateAvailable && status.value.latestVersion && !busy.value
))
const phaseTone = computed(() => {
const phase = status.value?.phase
if (phase === 'FAILED' || phase === 'ROLLED_BACK') return 'danger'
if (phase === 'AVAILABLE') return 'warning'
if (phase === 'SUCCEEDED' || phase === 'UP_TO_DATE') return 'success'
return 'info'
})
function apiMessage(error: unknown, fallback: string) {
return error instanceof OaApiError ? error.message : fallback
}
function formatDate(value: string | null) {
if (!value) return '-'
const date = new Date(value)
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false })
}
function formatBytes(size: number) {
if (size <= 0) return '-'
if (size < 1024 * 1024) return `${Math.ceil(size / 1024)} KB`
return `${(size / 1024 / 1024).toFixed(1)} MB`
}
async function loadStatus(silent = false) {
if (!silent) loading.value = true
try {
status.value = await oaApi.getSystemUpdateStatus()
if (busy.value) startPolling()
else stopPolling()
} catch (error) {
if (!silent) ElMessage.error(apiMessage(error, '更新状态加载失败'))
} finally {
if (!silent) loading.value = false
}
}
async function checkUpdate() {
checking.value = true
try {
status.value = await oaApi.checkSystemUpdate()
ElMessage.success(status.value.updateAvailable ? '发现新版本' : '当前已是最新版本')
} catch (error) {
ElMessage.error(apiMessage(error, '检查更新失败'))
await loadStatus(true)
} finally {
checking.value = false
}
}
async function installUpdate() {
const version = status.value?.latestVersion
if (!version) return
try {
await ElMessageBox.confirm(
`确认安装 ${version} 并重启服务?`,
'安装更新',
{ type: 'warning', confirmButtonText: '安装并重启', cancelButtonText: '取消' }
)
} catch {
return
}
installing.value = true
try {
status.value = await oaApi.installSystemUpdate(version)
ElMessage.success('更新任务已启动')
startPolling()
} catch (error) {
ElMessage.error(apiMessage(error, '启动更新失败'))
} finally {
installing.value = false
}
}
function startPolling() {
if (pollTimer !== undefined) return
pollTimer = window.setInterval(() => loadStatus(true), 3000)
}
function stopPolling() {
if (pollTimer === undefined) return
window.clearInterval(pollTimer)
pollTimer = undefined
}
onMounted(() => loadStatus())
onBeforeUnmount(stopPolling)
</script>
<template>
<div class="update-page" v-loading="loading">
<ErpPageHeader
title="系统更新"
:crumbs="['应用定制平台', '系统更新']"
description="正式版本"
>
<template #actions>
<el-button :icon="Refresh" :loading="checking" @click="checkUpdate">检查更新</el-button>
<el-button
type="primary"
:icon="Download"
:disabled="!canInstall"
:loading="installing"
@click="installUpdate"
>
安装并重启
</el-button>
</template>
</ErpPageHeader>
<el-alert
v-if="status && !status.configured"
type="warning"
:closable="false"
show-icon
title="在线更新尚未配置"
/>
<section class="update-summary">
<div class="version-block">
<span class="field-label">当前版本</span>
<strong>{{ status?.currentVersion || '-' }}</strong>
</div>
<div class="version-block">
<span class="field-label">最新版本</span>
<strong>{{ status?.latestVersion || '-' }}</strong>
</div>
<div class="version-block">
<span class="field-label">更新状态</span>
<el-tag :type="phaseTone" effect="plain">
{{ status ? phaseLabels[status.phase] : '-' }}
</el-tag>
</div>
<div class="version-block">
<span class="field-label">检查时间</span>
<span>{{ formatDate(status?.checkedAt || null) }}</span>
</div>
</section>
<section v-if="status && (busy || status.phase === 'FAILED' || status.phase === 'ROLLED_BACK')" class="update-progress">
<div class="section-heading">
<h3>{{ status.message }}</h3>
<span>{{ status.progress }}%</span>
</div>
<el-progress
:percentage="status.progress"
:status="status.phase === 'FAILED' || status.phase === 'ROLLED_BACK' ? 'exception' : undefined"
:stroke-width="10"
/>
<p v-if="status.error" class="error-line"><el-icon><Warning /></el-icon>{{ status.error }}</p>
</section>
<section class="release-section">
<div class="section-heading">
<h3>版本信息</h3>
<span>{{ formatDate(status?.publishedAt || null) }}</span>
</div>
<div class="release-notes">{{ status?.releaseNotes || '暂无发布说明' }}</div>
</section>
<section class="release-section">
<div class="section-heading"><h3>发布文件</h3></div>
<el-table :data="status?.assets || []" size="small" border empty-text="暂无发布文件">
<el-table-column prop="name" label="文件" min-width="280" />
<el-table-column label="大小" width="120">
<template #default="{ row }">{{ formatBytes(row.size) }}</template>
</el-table-column>
<el-table-column label="校验" width="140">
<template #default="{ row }">
<el-tag v-if="row.name === 'SHA256SUMS.sig'" type="success" effect="plain" size="small">Ed25519</el-tag>
<el-tag v-else-if="row.name === 'SHA256SUMS'" type="info" effect="plain" size="small">SHA-256</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
</el-table>
</section>
</div>
</template>
<style scoped>
.update-page {
max-width: 1240px;
margin: 0 auto;
}
.update-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
border-top: 1px solid var(--erp-color-border-soft);
border-bottom: 1px solid var(--erp-color-border-soft);
}
.version-block {
min-width: 0;
padding: var(--erp-space-4);
border-right: 1px solid var(--erp-color-border-soft);
}
.version-block:last-child {
border-right: 0;
}
.version-block strong,
.version-block > span:last-child {
display: block;
margin-top: var(--erp-space-2);
overflow-wrap: anywhere;
color: var(--erp-color-text);
font-size: var(--erp-font-size-base);
}
.field-label {
color: var(--erp-color-text-subtle);
font-size: var(--erp-font-size-xs);
}
.update-progress,
.release-section {
padding: var(--erp-space-5) 0;
border-bottom: 1px solid var(--erp-color-border-soft);
}
.section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--erp-space-3);
margin-bottom: var(--erp-space-3);
}
.section-heading h3 {
margin: 0;
color: var(--erp-color-text);
font-size: var(--erp-font-size-base);
}
.section-heading span {
color: var(--erp-color-text-subtle);
font-size: var(--erp-font-size-xs);
}
.release-notes {
min-height: 80px;
color: var(--erp-color-text-muted);
font-size: var(--erp-font-size-sm);
line-height: 1.7;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.error-line {
display: flex;
align-items: center;
gap: var(--erp-space-1);
margin: var(--erp-space-2) 0 0;
color: var(--erp-color-danger);
font-size: var(--erp-font-size-sm);
}
@media (max-width: 900px) {
.update-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.version-block:nth-child(2) {
border-right: 0;
}
.version-block:nth-child(-n + 2) {
border-bottom: 1px solid var(--erp-color-border-soft);
}
}
@media (max-width: 560px) {
.update-summary {
grid-template-columns: 1fr;
}
.version-block,
.version-block:nth-child(2) {
border-right: 0;
border-bottom: 1px solid var(--erp-color-border-soft);
}
.version-block:last-child {
border-bottom: 0;
}
}
</style>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { http } from '../../api/http'
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { http } from '../../../utils/http'
import { http } from '../../api/http'
import { ElMessage } from 'element-plus'
import { Warning, BellFilled, Document, DataLine } from '@element-plus/icons-vue'
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { http } from '../../../utils/http'
import { http } from '../../api/http'
import { ElMessage } from 'element-plus'
import { Money, List, Document } from '@element-plus/icons-vue'
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { http } from '../../../utils/http'
import { http } from '../../api/http'
import { ElMessage } from 'element-plus'
import { Document, DataLine, Refresh } from '@element-plus/icons-vue'
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { http } from '../../../utils/http'
import { http } from '../../api/http'
import { ElMessage } from 'element-plus'
import { Setting, DataLine, Plus, Delete } from '@element-plus/icons-vue'
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { http } from '../../../utils/http'
import { http } from '../../api/http'
import { ElMessage } from 'element-plus'
import { Document, Setting, Refresh } from '@element-plus/icons-vue'
@@ -220,7 +220,7 @@
import { ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, View, Refresh } from '@element-plus/icons-vue'
import { http } from '../../../../utils/http'
import { http } from '../../api/http'
interface SupervisionProject {
id: number
@@ -220,7 +220,7 @@
import { ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, View, List, Delete, Refresh } from '@element-plus/icons-vue'
import { http } from '../../../../utils/http'
import { http } from '../../api/http'
interface SupervisionProject {
id: number
Executable
+344
View File
@@ -0,0 +1,344 @@
#!/bin/bash
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
ROOT_DIR="${ERP_RUN_ROOT_DIR:-$SCRIPT_DIR}"
BACKEND_DIR="${ERP_RUN_BACKEND_DIR:-$ROOT_DIR/oa-backend}"
ERP_ENV_FILE="${ERP_RUN_CONFIG_FILE:-${ERP_CONFIG_FILE:-}}"
if [ -z "$ERP_ENV_FILE" ] && [ -n "${ERP_INSTALL_ROOT:-}" ]; then
ERP_ENV_FILE="$ERP_INSTALL_ROOT/config/erp.env"
fi
if [ -n "$ERP_ENV_FILE" ] && [ -r "$ERP_ENV_FILE" ]; then
set -a
# The production installer writes shell-escaped values to this file.
# shellcheck disable=SC1090
source "$ERP_ENV_FILE"
set +a
fi
JAVA_BIN="${ERP_RUN_JAVA_BIN:-${ERP_JAVA_BIN:-}}"
JAR_PATH="${ERP_RUN_JAR_PATH:-}"
BACKEND_PORT="${ERP_RUN_BACKEND_PORT:-${SERVER_PORT:-8091}}"
SPRING_PROFILE="${ERP_RUN_PROFILE:-${SPRING_PROFILES_ACTIVE:-}}"
NGROK_API_PORT="${ERP_RUN_NGROK_API_PORT:-4040}"
LOCAL_URL="http://127.0.0.1:$BACKEND_PORT"
PUBLIC_URL="${ERP_RUN_PUBLIC_URL:-https://resonant-elated-launder.ngrok-free.dev}"
NGROK_TARGET="http://localhost:$BACKEND_PORT"
LOG_DIR="${ERP_RUN_LOG_DIR:-${TMPDIR:-/tmp}/kaidi-erp-run}"
BACKEND_LOG="$LOG_DIR/backend.log"
NGROK_LOG="$LOG_DIR/ngrok.log"
NGROK_RUNTIME_CONFIG="$LOG_DIR/ngrok-runtime.yml"
NGROK_BIN=""
NGROK_BASE_CONFIG=""
BACKEND_PID=""
NGROK_PID=""
CLEANED_UP=0
say() {
printf '[ERP] %s\n' "$*"
}
fail() {
printf '[ERP] ERROR: %s\n' "$*" >&2
return 1
}
port_listener_pid() {
lsof -nP -iTCP:"$1" -sTCP:LISTEN -t 2>/dev/null | head -n 1
}
backend_is_healthy() {
local body
body="$(curl -fsS --connect-timeout 1 --max-time 3 "$LOCAL_URL/" 2>/dev/null)" || return 1
[[ "$body" == *'<title>凯迪协同办公平台</title>'* ]]
}
login_is_healthy() {
curl -fsS --connect-timeout 1 --max-time 5 \
-H 'Content-Type: application/json' \
-d '{"loginName":"admin","password":"123456"}' \
"$LOCAL_URL/api/oa/auth/login" 2>/dev/null \
| python3 -c 'import json,sys; d=json.load(sys.stdin); raise SystemExit(0 if d.get("code")==0 and d.get("data",{}).get("token") else 1)'
}
start_backend() {
: > "$BACKEND_LOG"
(
cd "$BACKEND_DIR" || exit 1
IFS=$' \t' read -r -a java_opts <<< "${ERP_RUN_JAVA_OPTS:--Xms512m -Xmx2g}"
if [ -n "$SPRING_PROFILE" ]; then
exec "$JAVA_BIN" "${java_opts[@]}" -jar "$JAR_PATH" \
--spring.profiles.active="$SPRING_PROFILE" --server.port="$BACKEND_PORT"
fi
exec "$JAVA_BIN" "${java_opts[@]}" -jar "$JAR_PATH" --server.port="$BACKEND_PORT"
) >> "$BACKEND_LOG" 2>&1 &
BACKEND_PID=$!
}
wait_for_backend() {
local deadline=$((SECONDS + 60))
while [ "$SECONDS" -lt "$deadline" ]; do
if backend_is_healthy && login_is_healthy; then
return 0
fi
if [ -n "$BACKEND_PID" ] && ! kill -0 "$BACKEND_PID" 2>/dev/null; then
return 1
fi
sleep 1
done
return 1
}
ensure_backend() {
local listener
listener="$(port_listener_pid "$BACKEND_PORT" || true)"
if [ -n "$listener" ]; then
if ! backend_is_healthy; then
fail "端口 $BACKEND_PORT 已被非本项目服务占用(PID ${listener}"
return 1
fi
say "复用已运行的 ERP 服务(PID ${listener}"
return 0
fi
say '启动 ERP 服务...'
start_backend || return 1
if ! wait_for_backend; then
tail -n 40 "$BACKEND_LOG" >&2 || true
fail 'ERP 服务未在 60 秒内就绪'
return 1
fi
}
stop_owned_process() {
local pid="$1"
[ -n "$pid" ] || return 0
kill -0 "$pid" 2>/dev/null || return 0
kill "$pid" 2>/dev/null || true
local attempt=0
while kill -0 "$pid" 2>/dev/null && [ "$attempt" -lt 5 ]; do
sleep 1
attempt=$((attempt + 1))
done
if kill -0 "$pid" 2>/dev/null; then
kill -9 "$pid" 2>/dev/null || true
fi
wait "$pid" 2>/dev/null || true
}
cleanup() {
[ "$CLEANED_UP" -eq 0 ] || return 0
CLEANED_UP=1
stop_owned_process "$NGROK_PID"
stop_owned_process "$BACKEND_PID"
}
resolve_ngrok_bin() {
if [ -n "${ERP_RUN_NGROK_BIN:-}" ]; then
printf '%s\n' "$ERP_RUN_NGROK_BIN"
return 0
fi
if [ -x "$HOME/bin/ngrok" ]; then
printf '%s\n' "$HOME/bin/ngrok"
return 0
fi
command -v ngrok 2>/dev/null || return 1
}
resolve_ngrok_config() {
if [ -n "${ERP_RUN_NGROK_CONFIG:-}" ]; then
printf '%s\n' "$ERP_RUN_NGROK_CONFIG"
return 0
fi
local output
output="$("$NGROK_BIN" config check 2>/dev/null)" || return 1
output="${output#* at }"
[ -r "$output" ] || return 1
printf '%s\n' "$output"
}
resolve_java_bin() {
if [ -n "$JAVA_BIN" ]; then
printf '%s\n' "$JAVA_BIN"
return 0
fi
local project_java="$ROOT_DIR/.jdks/jdk-17.0.19+10/Contents/Home/bin/java"
if [ -x "$project_java" ]; then
printf '%s\n' "$project_java"
return 0
fi
command -v java 2>/dev/null || return 1
}
resolve_jar_path() {
if [ -n "$JAR_PATH" ]; then
printf '%s\n' "$JAR_PATH"
return 0
fi
if [ -n "${ERP_INSTALL_ROOT:-}" ] && [ -r "$ERP_INSTALL_ROOT/current/app/kaidi-erp.jar" ]; then
printf '%s\n' "$ERP_INSTALL_ROOT/current/app/kaidi-erp.jar"
return 0
fi
local candidate newest=""
for candidate in "$BACKEND_DIR"/build/libs/oa-backend-*.jar; do
[ -r "$candidate" ] || continue
case "$candidate" in *-plain.jar) continue ;; esac
if [ -z "$newest" ] || [ "$candidate" -nt "$newest" ]; then
newest="$candidate"
fi
done
[ -n "$newest" ] || return 1
printf '%s\n' "$newest"
}
preflight() {
command -v curl >/dev/null || { fail '缺少 curl'; return 1; }
command -v lsof >/dev/null || { fail '缺少 lsof'; return 1; }
command -v python3 >/dev/null || { fail '缺少 python3'; return 1; }
if [ -n "$ERP_ENV_FILE" ] && [ ! -r "$ERP_ENV_FILE" ]; then
fail "找不到运行配置:$ERP_ENV_FILE"
return 1
fi
JAVA_BIN="$(resolve_java_bin)" || { fail '找不到 Java 17+'; return 1; }
JAR_PATH="$(resolve_jar_path)" || { fail '找不到可运行 oa-backend JAR'; return 1; }
local java_major
java_major="$("$JAVA_BIN" -version 2>&1 | awk -F'[".]' '/version/ {print $2; exit}')"
[ -n "$java_major" ] && [ "$java_major" -ge 17 ] 2>/dev/null \
|| { fail '需要 Java 17 或更高版本'; return 1; }
if [ "$SPRING_PROFILE" = 'postgres' ]; then
[ -n "${OA_DB_URL:-}" ] || { fail 'PostgreSQL 模式缺少 OA_DB_URL'; return 1; }
[ -n "${OA_DB_USERNAME:-}" ] || { fail 'PostgreSQL 模式缺少 OA_DB_USERNAME'; return 1; }
fi
NGROK_BIN="$(resolve_ngrok_bin)" || { fail '找不到 ngrok(预期 $HOME/bin/ngrok 或 PATH'; return 1; }
NGROK_BASE_CONFIG="$(resolve_ngrok_config)" || {
fail '找不到有效 ngrok 配置(可设置 ERP_RUN_NGROK_CONFIG'
return 1
}
mkdir -p "$LOG_DIR" || { fail "无法创建日志目录:$LOG_DIR"; return 1; }
}
ngrok_is_healthy() {
local payload
payload="$(curl -fsS --connect-timeout 1 --max-time 3 "http://127.0.0.1:$NGROK_API_PORT/api/tunnels" 2>/dev/null)" || return 1
python3 -c 'import json,sys; d=json.load(sys.stdin); pub,target=sys.argv[1:3]; raise SystemExit(0 if any(t.get("public_url")==pub and t.get("config",{}).get("addr")==target for t in d.get("tunnels",[])) else 1)' \
"$PUBLIC_URL" "$NGROK_TARGET" <<< "$payload"
}
start_ngrok() {
: > "$NGROK_LOG"
printf 'version: "3"\nagent:\n web_addr: "127.0.0.1:%s"\n' \
"$NGROK_API_PORT" > "$NGROK_RUNTIME_CONFIG"
"$NGROK_BIN" http --config "$NGROK_BASE_CONFIG" --config "$NGROK_RUNTIME_CONFIG" \
--url="$PUBLIC_URL" "$BACKEND_PORT" \
--log=stdout --log-format=json >> "$NGROK_LOG" 2>&1 &
NGROK_PID=$!
}
wait_for_ngrok() {
local deadline=$((SECONDS + 30))
while [ "$SECONDS" -lt "$deadline" ]; do
if ngrok_is_healthy; then
return 0
fi
if [ -n "$NGROK_PID" ] && ! kill -0 "$NGROK_PID" 2>/dev/null; then
return 1
fi
sleep 1
done
return 1
}
ensure_ngrok() {
if ngrok_is_healthy; then
say '复用已运行的 ngrok 隧道'
return 0
fi
local listener
listener="$(port_listener_pid "$NGROK_API_PORT" || true)"
if [ -n "$listener" ]; then
fail "端口 $NGROK_API_PORT 已有其他 ngrok/服务(PID ${listener}),但固定隧道不匹配"
return 1
fi
say '启动 ngrok...'
start_ngrok || return 1
if ! wait_for_ngrok; then
tail -n 40 "$NGROK_LOG" >&2 || true
fail 'ngrok 未在 30 秒内建立固定隧道'
return 1
fi
}
public_is_healthy() {
local body
body="$(curl -fsS --connect-timeout 3 --max-time 10 \
-H 'ngrok-skip-browser-warning: true' "$PUBLIC_URL/" 2>/dev/null)" || return 1
[[ "$body" == *'<title>凯迪协同办公平台</title>'* ]]
}
wait_for_public() {
local deadline=$((SECONDS + 30))
while [ "$SECONDS" -lt "$deadline" ]; do
if public_is_healthy; then
return 0
fi
sleep 1
done
fail '公网地址未在 30 秒内可访问'
}
maybe_open_browser() {
[ "${ERP_RUN_NO_OPEN:-0}" = '1' ] && return 0
command -v open >/dev/null || { fail '找不到 macOS open 命令'; return 1; }
open "$PUBLIC_URL"
}
monitor_services() {
local backend_active ngrok_active
backend_active="$(port_listener_pid "$BACKEND_PORT" || true)"
ngrok_active="$(port_listener_pid "$NGROK_API_PORT" || true)"
say "本地:$LOCAL_URL"
say "公网:$PUBLIC_URL"
say "进程:ERP PID ${backend_active:-未知}ngrok PID ${ngrok_active:-未知}"
say "日志:${BACKEND_LOG}${NGROK_LOG}"
say '运行中;按 Ctrl+C 同时停止本次启动的服务。'
while :; do
if ! backend_is_healthy; then
[ -n "$BACKEND_PID" ] && tail -n 40 "$BACKEND_LOG" >&2 || true
fail 'ERP 服务失去响应'
return 1
fi
if ! ngrok_is_healthy; then
[ -n "$NGROK_PID" ] && tail -n 40 "$NGROK_LOG" >&2 || true
fail 'ngrok 隧道失去响应'
return 1
fi
sleep 5
done
}
handle_signal() {
printf '\n'
say '正在停止本次启动的服务...'
cleanup
exit 0
}
main() {
trap handle_signal HUP INT TERM
trap cleanup EXIT
say '检查运行环境...'
preflight || return 1
ensure_backend || return 1
ensure_ngrok || return 1
wait_for_public || return 1
maybe_open_browser || return 1
monitor_services
}
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
main "$@"
fi
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
BACKEND_DIR="$ROOT_DIR/oa-backend"
FRONTEND_DIR="$ROOT_DIR/ofbiz-framework/plugins/modern-ui/app"
DIST_DIR="${ERP_RELEASE_DIST_DIR:-$ROOT_DIR/dist}"
VERSION="${1:-${ERP_RELEASE_VERSION:-}}"
PRIVATE_KEY="${ERP_RELEASE_PRIVATE_KEY_FILE:-}"
REQUIRE_SIGNATURE="${ERP_REQUIRE_RELEASE_SIGNATURE:-1}"
if [[ "$VERSION" == v* ]]; then VERSION="${VERSION#v}"; fi
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]]; then
printf 'usage: %s <semver>\n' "$0" >&2
exit 2
fi
for command in java jar npm tar openssl cmp; do
command -v "$command" >/dev/null 2>&1 || { printf 'missing command: %s\n' "$command" >&2; exit 1; }
done
JAVA_MAJOR="$(java -version 2>&1 | awk -F'[".]' '/version/ {print $2; exit}')"
[[ "$JAVA_MAJOR" =~ ^[0-9]+$ && "$JAVA_MAJOR" -ge 17 ]] \
|| { printf 'Java 17 or newer is required\n' >&2; exit 1; }
OPENSSL_VERSION="$(openssl version 2>/dev/null || true)"
[[ "$OPENSSL_VERSION" =~ ^OpenSSL[[:space:]]3\. ]] \
|| { printf 'OpenSSL 3 with Ed25519 support is required\n' >&2; exit 1; }
OPENSSL_ALGORITHMS="$(openssl list -public-key-algorithms 2>/dev/null || true)"
grep -qi ED25519 <<< "$OPENSSL_ALGORITHMS" \
|| { printf 'OpenSSL 3 with Ed25519 support is required\n' >&2; exit 1; }
if [[ "${ERP_SKIP_FRONTEND_BUILD:-0}" != "1" ]]; then
(
cd "$FRONTEND_DIR"
npm ci
NODE_OPTIONS="${NODE_OPTIONS:---max-old-space-size=8192}" npm run build
)
fi
(
cd "$BACKEND_DIR"
./gradlew clean bootJar -PreleaseVersion="$VERSION" -PproductionBuild=true
)
JAR="$BACKEND_DIR/build/libs/oa-backend-${VERSION}.jar"
[[ -r "$JAR" ]] || { printf 'bootJar not found: %s\n' "$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; }
if grep -Eq '^BOOT-INF/lib/(sqlite-jdbc|hibernate-community-dialects)-[^/]+\.jar$' <<< "$JAR_ENTRIES"; then
printf 'production 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")"
trap 'rm -rf "$STAGE"' EXIT
PACKAGE_ROOT="$STAGE/kaidi-erp-${VERSION}"
mkdir -p "$PACKAGE_ROOT/app" "$PACKAGE_ROOT/bin" "$PACKAGE_ROOT/config"
cp "$JAR" "$PACKAGE_ROOT/app/kaidi-erp.jar"
cp "$ROOT_DIR/distribution/bin/erp-run" "$PACKAGE_ROOT/bin/erp-run"
cp "$ROOT_DIR/distribution/bin/erp-update" "$PACKAGE_ROOT/bin/erp-update"
cp "$ROOT_DIR/distribution/release-public-key.pem" "$PACKAGE_ROOT/config/release-public-key.pem"
chmod 755 "$PACKAGE_ROOT/bin/erp-run" "$PACKAGE_ROOT/bin/erp-update"
printf '%s\n' "$VERSION" > "$PACKAGE_ROOT/VERSION"
printf '{"version":"%s","database":"postgresql","minimumPostgres":15,"rollbackCompatible":true}\n' \
"$VERSION" > "$PACKAGE_ROOT/manifest.json"
ARCHIVE="kaidi-erp-${VERSION}.tar.gz"
tar -czf "$DIST_DIR/$ARCHIVE" -C "$STAGE" "kaidi-erp-${VERSION}"
if command -v sha256sum >/dev/null 2>&1; then
(cd "$DIST_DIR" && sha256sum "$ARCHIVE" > SHA256SUMS)
else
(cd "$DIST_DIR" && shasum -a 256 "$ARCHIVE" > SHA256SUMS)
fi
if [[ -n "$PRIVATE_KEY" && -r "$PRIVATE_KEY" ]]; then
GENERATED_PUBLIC_KEY="$STAGE/release-public-key.pem"
openssl pkey -in "$PRIVATE_KEY" -pubout -out "$GENERATED_PUBLIC_KEY" >/dev/null 2>&1
cmp -s "$GENERATED_PUBLIC_KEY" "$ROOT_DIR/distribution/release-public-key.pem" \
|| { printf 'release signing key does not match distribution/release-public-key.pem\n' >&2; exit 1; }
openssl pkeyutl -sign -rawin -inkey "$PRIVATE_KEY" \
-in "$DIST_DIR/SHA256SUMS" -out "$DIST_DIR/SHA256SUMS.sig"
elif [[ "$REQUIRE_SIGNATURE" == "1" ]]; then
printf 'ERP_RELEASE_PRIVATE_KEY_FILE is required for signed releases\n' >&2
exit 1
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"
+504
View File
@@ -0,0 +1,504 @@
#!/usr/bin/env bash
set -uo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
PASS=0
FAIL=0
run_test() {
local name="$1"
shift
if ("$@"); then
printf 'PASS %s\n' "$name"
PASS=$((PASS + 1))
else
printf 'FAIL %s\n' "$name"
FAIL=$((FAIL + 1))
fi
}
checksum_file() {
local file="$1"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$file"
else
shasum -a 256 "$file"
fi
}
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"
: > "$tmp/current/app/kaidi-erp.jar"
: > "$tmp/-Dpattern=expanded"
printf '%s\n' \
'#!/usr/bin/env bash' \
'if [[ "${1:-}" == "-version" ]]; then' \
' printf '\''openjdk version "17.0.12"\n'\'' >&2' \
' exit 0' \
'fi' \
'printf '\''%s\n'\'' "$@"' > "$tmp/java"
chmod +x "$tmp/java"
{
printf 'ERP_JAVA_BIN=%q\n' "$tmp/java"
printf 'ERP_JAVA_OPTS=%q\n' '-Dpattern=* -Xmx1g'
printf 'ERP_RUN_DIR=%q\n' "$tmp/run"
} > "$tmp/erp.env"
local output
output="$(cd "$tmp" && ERP_INSTALL_ROOT="$tmp" ERP_CONFIG_FILE="$tmp/erp.env" \
"$PROJECT_ROOT/distribution/bin/erp-run")" || return 1
grep -Fqx -- '-Dpattern=*' <<< "$output" || return 1
grep -Fqx -- '-Xmx1g' <<< "$output" || return 1
grep -Fqx -- "$tmp/current/app/kaidi-erp.jar" <<< "$output"
)
prepare_signed_archive() {
local tmp="$1" unsafe="${2:-0}"
mkdir -p "$tmp/package/kaidi-erp-1.2.3/app"
printf 'jar\n' > "$tmp/package/kaidi-erp-1.2.3/app/kaidi-erp.jar"
if [[ "$unsafe" == "1" ]]; then
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"
openssl pkeyutl -sign -rawin -inkey "$tmp/private.pem" \
-in "$tmp/SHA256SUMS" -out "$tmp/SHA256SUMS.sig"
}
test_installer_verifies_signed_safe_archive() (
local tmp
tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-signature-test.XXXXXX")" || return 1
trap 'rm -rf "$tmp"' EXIT
openssl genpkey -algorithm ED25519 -out "$tmp/private.pem" >/dev/null 2>&1 || return 1
openssl pkey -in "$tmp/private.pem" -pubout -out "$tmp/public.pem" >/dev/null 2>&1 || return 1
prepare_signed_archive "$tmp" || return 1
source "$PROJECT_ROOT/install.sh"
trap 'rm -rf "$tmp"' EXIT
TMP_DIR="$tmp"
PUBLIC_KEY="$(<"$tmp/public.pem")"
ARCHIVE_NAME=kaidi-erp-1.2.3.tar.gz
ARCHIVE_PATH="$tmp/$ARCHIVE_NAME"
verify_release
)
test_installer_rejects_archive_symlinks() (
local tmp
tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-unsafe-test.XXXXXX")" || return 1
trap 'rm -rf "$tmp"' EXIT
openssl genpkey -algorithm ED25519 -out "$tmp/private.pem" >/dev/null 2>&1 || return 1
openssl pkey -in "$tmp/private.pem" -pubout -out "$tmp/public.pem" >/dev/null 2>&1 || return 1
prepare_signed_archive "$tmp" 1 || return 1
source "$PROJECT_ROOT/install.sh"
trap 'rm -rf "$tmp"' EXIT
TMP_DIR="$tmp"
PUBLIC_KEY="$(<"$tmp/public.pem")"
ARCHIVE_NAME=kaidi-erp-1.2.3.tar.gz
ARCHIVE_PATH="$tmp/$ARCHIVE_NAME"
! (verify_release) >/dev/null 2>&1
)
test_stable_channel_rejects_prerelease_tag() (
local tmp
tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-prerelease-test.XXXXXX")" || return 1
trap 'rm -rf "$tmp"' EXIT
printf 'OA_UPDATE_GITEA_BASE_URL=https://example.test\n' > "$tmp/erp.env"
ERP_INSTALL_ROOT="$tmp" ERP_CONFIG_FILE="$tmp/erp.env" source "$PROJECT_ROOT/distribution/bin/erp-update"
trap 'rm -rf "$tmp"' EXIT
printf '%s\n' \
'{"tag_name":"v2.0.0-rc.1","draft":false,"prerelease":false,"assets":[' \
'{"name":"kaidi-erp-2.0.0-rc.1.tar.gz","browser_download_url":"https://example.test/app"},' \
'{"name":"SHA256SUMS","browser_download_url":"https://example.test/sums"},' \
'{"name":"SHA256SUMS.sig","browser_download_url":"https://example.test/sig"}]}' \
> "$tmp/release.json"
CHANNEL=stable
! select_release "$tmp/release.json" '' "$tmp/selection" >/dev/null 2>&1
)
prepare_update_release_archive() {
local tmp="$1" version="$2"
local root="$tmp/package/kaidi-erp-$version"
mkdir -p "$root/app" "$root/bin" "$root/config" "$tmp/assets"
printf 'mock jar %s\n' "$version" > "$root/app/kaidi-erp.jar"
cp "$PROJECT_ROOT/distribution/bin/erp-run" "$root/bin/erp-run"
cp "$PROJECT_ROOT/distribution/bin/erp-update" "$root/bin/erp-update"
chmod +x "$root/bin/erp-run" "$root/bin/erp-update"
printf '%s\n' "$version" > "$root/VERSION"
printf '{"version":"%s","database":"postgresql","rollbackCompatible":true}\n' \
"$version" > "$root/manifest.json"
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"
: > "$tmp/assets/SHA256SUMS.sig"
}
test_installer_downloads_and_installs_signed_release() (
local version=3.0.0 tmp server_pid=""
tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-install-e2e.XXXXXX")" || return 1
cleanup_install_scenario() {
[[ -z "$server_pid" ]] || kill "$server_pid" 2>/dev/null || true
[[ -z "$server_pid" ]] || wait "$server_pid" 2>/dev/null || true
rm -rf "$tmp"
}
trap cleanup_install_scenario EXIT
prepare_update_release_archive "$tmp" "$version" || return 1
openssl genpkey -algorithm ED25519 -out "$tmp/private.pem" >/dev/null 2>&1 || return 1
openssl pkey -in "$tmp/private.pem" -pubout -out "$tmp/public.pem" >/dev/null 2>&1 || return 1
openssl pkeyutl -sign -rawin -inkey "$tmp/private.pem" \
-in "$tmp/assets/SHA256SUMS" -out "$tmp/assets/SHA256SUMS.sig" || return 1
python3 - "$tmp" "$version" <<'PY' > "$tmp/server.log" 2>&1 &
import json
import os
import sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
root, version = sys.argv[1:]
assets = os.path.join(root, "assets")
archive = f"kaidi-erp-{version}.tar.gz"
class Handler(BaseHTTPRequestHandler):
def send_bytes(self, status, body, content_type="application/octet-stream"):
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.path == "/api/v1/repos/awaioi/ERP/releases/latest":
base = f"http://127.0.0.1:{self.server.server_port}/assets"
payload = {
"tag_name": "v" + version,
"draft": False,
"prerelease": False,
"assets": [
{"name": archive, "browser_download_url": base + "/" + archive},
{"name": "SHA256SUMS", "browser_download_url": base + "/SHA256SUMS"},
{"name": "SHA256SUMS.sig", "browser_download_url": base + "/SHA256SUMS.sig"},
],
}
self.send_bytes(200, json.dumps(payload).encode(), "application/json")
return
if self.path.startswith("/assets/"):
name = self.path.removeprefix("/assets/")
if name not in {archive, "SHA256SUMS", "SHA256SUMS.sig"}:
self.send_bytes(404, b"not found", "text/plain")
return
with open(os.path.join(assets, name), "rb") as handle:
self.send_bytes(200, handle.read())
return
self.send_bytes(404, b"not found", "text/plain")
def log_message(self, *_):
pass
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
with open(os.path.join(root, "server.port"), "w", encoding="ascii") as handle:
handle.write(str(server.server_port))
server.serve_forever()
PY
server_pid=$!
local attempt=0
while [[ ! -s "$tmp/server.port" && "$attempt" -lt 100 ]]; do
sleep 0.02
attempt=$((attempt + 1))
done
[[ -s "$tmp/server.port" ]] || return 1
local port
port="$(<"$tmp/server.port")"
source "$PROJECT_ROOT/install.sh"
trap cleanup_install_scenario EXIT
GITEA_BASE_URL="http://127.0.0.1:$port"
REPOSITORY=awaioi/ERP
ALLOW_INSECURE=1
INSTALL_ROOT="$tmp/install"
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; }
main > "$tmp/install.log" 2>&1 || return 1
[[ "$(readlink "$INSTALL_ROOT/current")" == "releases/$version" ]] || return 1
[[ -r "$INSTALL_ROOT/current/app/kaidi-erp.jar" ]] || return 1
(
source "$ERP_CONFIG_ROOT/erp.env"
[[ "$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" ]]
)
)
run_update_scenario() (
local mode="$1" version=2.0.0
local tmp server_pid="" supervisor_pid=""
tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-update-e2e.XXXXXX")" || return 1
cleanup_scenario() {
[[ -z "$supervisor_pid" ]] || kill "$supervisor_pid" 2>/dev/null || true
[[ -z "$server_pid" ]] || kill "$server_pid" 2>/dev/null || true
[[ -z "$supervisor_pid" ]] || wait "$supervisor_pid" 2>/dev/null || true
[[ -z "$server_pid" ]] || wait "$server_pid" 2>/dev/null || true
rm -rf "$tmp"
}
trap cleanup_scenario EXIT
prepare_update_release_archive "$tmp" "$version" || return 1
mkdir -p "$tmp/releases/1.0.0" "$tmp/run" "$tmp/state"
printf '1.0.0\n' > "$tmp/releases/1.0.0/VERSION"
ln -s releases/1.0.0 "$tmp/current"
python3 - "$tmp" "$mode" <<'PY' > "$tmp/server.log" 2>&1 &
import json
import os
import sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
root, mode = sys.argv[1:]
assets = os.path.join(root, "assets")
class Handler(BaseHTTPRequestHandler):
def send_bytes(self, status, body, content_type="application/octet-stream"):
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.path == "/api/v1/repos/awaioi/ERP/releases/latest":
base = f"http://127.0.0.1:{self.server.server_port}"
payload = {
"tag_name": "v2.0.0",
"draft": False,
"prerelease": False,
"assets": [
{"name": "kaidi-erp-2.0.0.tar.gz", "browser_download_url": base + "/assets/kaidi-erp-2.0.0.tar.gz"},
{"name": "SHA256SUMS", "browser_download_url": base + "/assets/SHA256SUMS"},
{"name": "SHA256SUMS.sig", "browser_download_url": base + "/assets/SHA256SUMS.sig"},
],
}
self.send_bytes(200, json.dumps(payload).encode(), "application/json")
return
if self.path == "/health":
current = os.path.basename(os.path.realpath(os.path.join(root, "current")))
healthy = current == "2.0.0" if mode == "success" else current == "1.0.0"
self.send_bytes(200 if healthy else 503, b'{"status":"UP"}' if healthy else b'{"status":"DOWN"}', "application/json")
return
if self.path.startswith("/assets/"):
name = self.path.removeprefix("/assets/")
if name not in {"kaidi-erp-2.0.0.tar.gz", "SHA256SUMS", "SHA256SUMS.sig"}:
self.send_bytes(404, b"not found", "text/plain")
return
with open(os.path.join(assets, name), "rb") as handle:
self.send_bytes(200, handle.read())
return
self.send_bytes(404, b"not found", "text/plain")
def log_message(self, *_):
pass
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
with open(os.path.join(root, "server.port"), "w", encoding="ascii") as handle:
handle.write(str(server.server_port))
server.serve_forever()
PY
server_pid=$!
local attempt=0
while [[ ! -s "$tmp/server.port" && "$attempt" -lt 100 ]]; do
sleep 0.02
attempt=$((attempt + 1))
done
[[ -s "$tmp/server.port" ]] || return 1
local port
port="$(<"$tmp/server.port")"
(
child=""
trap '[[ -z "$child" ]] || kill "$child" 2>/dev/null || true' EXIT
trap 'exit 0' TERM INT
while :; do
sleep 300 &
child=$!
printf '%s\n' "$child" > "$tmp/run/app.pid"
wait "$child" 2>/dev/null || true
child=""
sleep 0.05
done
) &
supervisor_pid=$!
attempt=0
while [[ ! -s "$tmp/run/app.pid" && "$attempt" -lt 100 ]]; do
sleep 0.02
attempt=$((attempt + 1))
done
[[ -s "$tmp/run/app.pid" ]] || return 1
{
printf 'ERP_RUN_DIR=%q\n' "$tmp/run"
printf 'OA_UPDATE_STATE_FILE=%q\n' "$tmp/state/update.json"
printf 'OA_UPDATE_GITEA_BASE_URL=http://127.0.0.1:%s\n' "$port"
printf 'OA_UPDATE_ALLOW_INSECURE_HTTP=true\n'
printf 'OA_UPDATE_REPOSITORY=awaioi/ERP\n'
printf 'ERP_HEALTH_URL=http://127.0.0.1:%s/health\n' "$port"
printf 'ERP_UPDATE_REQUIRE_SIGNATURE=false\n'
printf 'ERP_UPDATE_BACKUP_MODE=none\n'
printf 'ERP_UPDATE_HEALTH_TIMEOUT_SECONDS=3\n'
printf 'ERP_UPDATE_HEALTH_POLL_SECONDS=1\n'
} > "$tmp/erp.env"
local status=0 phase current
ERP_INSTALL_ROOT="$tmp" ERP_CONFIG_FILE="$tmp/erp.env" \
"$PROJECT_ROOT/distribution/bin/erp-update" install "$version" \
> "$tmp/update.log" 2>&1 || status=$?
phase="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["phase"])' "$tmp/state/update.json")" \
|| return 1
current="$(readlink "$tmp/current")"
if [[ "$mode" == "success" ]]; then
[[ "$status" == "0" && "$phase" == "SUCCEEDED" && "$current" == "releases/2.0.0" ]]
else
[[ "$status" != "0" && "$phase" == "ROLLED_BACK" && "$current" == "releases/1.0.0" ]]
fi
)
test_update_helper_installs_healthy_release() (
run_update_scenario success
)
test_update_helper_rolls_back_unhealthy_release() (
run_update_scenario rollback
)
test_update_lock_rejects_concurrent_process() (
local tmp
tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-lock-test.XXXXXX")" || return 1
trap 'rm -rf "$tmp"' EXIT
mkdir -p "$tmp/bin" "$tmp/run" "$tmp/state"
printf '%s\n' \
'#!/usr/bin/env bash' \
'printf '\''%s\n'\'' "$$" > "$MOCK_CURL_PID_FILE"' \
'trap '\''exit 1'\'' TERM INT' \
'sleep 30' \
'exit 1' > "$tmp/bin/curl"
chmod +x "$tmp/bin/curl"
{
printf 'ERP_RUN_DIR=%q\n' "$tmp/run"
printf 'OA_UPDATE_STATE_FILE=%q\n' "$tmp/state/update.json"
printf 'OA_UPDATE_GITEA_BASE_URL=%q\n' 'http://127.0.0.1:1'
printf 'OA_UPDATE_ALLOW_INSECURE_HTTP=true\n'
printf 'OA_UPDATE_REPOSITORY=awaioi/ERP\n'
} > "$tmp/erp.env"
PATH="$tmp/bin:$PATH" MOCK_CURL_PID_FILE="$tmp/curl.pid" \
ERP_INSTALL_ROOT="$tmp" ERP_CONFIG_FILE="$tmp/erp.env" \
"$PROJECT_ROOT/distribution/bin/erp-update" install > "$tmp/first.log" 2>&1 &
local first=$! attempt=0
while [[ ! -s "$tmp/run/update.lock" && "$attempt" -lt 100 ]]; do
sleep 0.02
attempt=$((attempt + 1))
done
[[ -s "$tmp/run/update.lock" ]] || { kill "$first" 2>/dev/null || true; return 1; }
local output status=0 rejected=0
output="$(PATH="$tmp/bin:$PATH" MOCK_CURL_PID_FILE="$tmp/curl-2.pid" \
ERP_INSTALL_ROOT="$tmp" ERP_CONFIG_FILE="$tmp/erp.env" \
"$PROJECT_ROOT/distribution/bin/erp-update" install 2>&1)" || status=$?
[[ "$status" -ne 0 && "$output" == *'已有更新任务正在执行'* ]] && rejected=1
[[ ! -r "$tmp/curl.pid" ]] || kill "$(<"$tmp/curl.pid")" 2>/dev/null || true
kill "$first" 2>/dev/null || true
wait "$first" 2>/dev/null || true
[[ "$rejected" == "1" ]]
)
test_noninteractive_installer_reports_missing_password() (
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'* ]]
)
test_installer_requires_explicit_gitea_url() (
local output status=0
output="$(ERP_GITEA_BASE_URL= bash -c '
root="$1"
set --
source "$root/install.sh"
GITEA_BASE_URL=
main
' _ "$PROJECT_ROOT" 2>&1)" || status=$?
[[ "$status" -ne 0 && "$output" == *'Gitea URL is required'* ]]
)
test_no_service_install_disables_online_update() (
local tmp
tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-no-service-test.XXXXXX")" || return 1
trap 'rm -rf "$tmp"' EXIT
source "$PROJECT_ROOT/install.sh"
trap 'rm -rf "$tmp"' EXIT
CONFIG_ROOT="$tmp/config"
STATE_ROOT="$tmp/state"
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
mkdir -p "$CONFIG_ROOT" "$STATE_ROOT" "$INSTALL_ROOT"
write_configuration || return 1
grep -Fqx 'OA_UPDATE_ENABLED=false' "$CONFIG_ROOT/erp.env"
)
run_test 'erp-run preserves Java option arguments' test_erp_run_preserves_java_option_arguments
run_test 'installer accepts a correctly signed archive' test_installer_verifies_signed_safe_archive
run_test 'installer rejects symlinks in release archives' test_installer_rejects_archive_symlinks
run_test 'stable update channel rejects prerelease tags' test_stable_channel_rejects_prerelease_tag
run_test 'installer downloads and installs a signed release end to end' test_installer_downloads_and_installs_signed_release
run_test 'update helper installs a healthy release end to end' test_update_helper_installs_healthy_release
run_test 'update helper rolls back an unhealthy release end to end' test_update_helper_rolls_back_unhealthy_release
run_test 'update helper rejects a concurrent process' test_update_lock_rejects_concurrent_process
run_test 'noninteractive install reports a missing database password' test_noninteractive_installer_reports_missing_password
run_test 'installer 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
printf 'RESULT pass=%s fail=%s\n' "$PASS" "$FAIL"
[[ "$FAIL" -eq 0 ]]

Some files were not shown because too many files have changed in this diff Show More