Compare commits
3
Commits
65e64cc717
...
f6e22cb670
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6e22cb670 | ||
|
|
2524a37a07 | ||
|
|
672569f199 |
@@ -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"
|
||||
Executable
+52
@@ -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}"
|
||||
Executable
+461
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MCowBQYDK2VwAyEAaErhcY8WZIZvPILmYnfjndVBAdOuWkvhaoHIWqNNdxI=
|
||||
-----END PUBLIC KEY-----
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
Executable
+94
@@ -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"
|
||||
Executable
+504
@@ -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
Reference in New Issue
Block a user