#!/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" == *'
凯迪协同办公平台'* ]]
}
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" == *'凯迪协同办公平台'* ]]
}
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