Files
ERP/run.command
T
Qiufeng 2524a37a07 Make local ERP previews one double-click away
Constraint: Finder launch must supervise Java and ngrok in one visible Terminal
Rejected: LaunchAgent | obscures ownership and makes safe cleanup harder
Confidence: high
Scope-risk: narrow
Directive: Never kill listeners not recorded as launcher-owned PIDs
Tested: Shell unit suite, repeat-start reuse, cold start, local/public login and protected API smoke
Not-tested: Finder Gatekeeper behavior on a different Mac
2026-07-15 16:29:05 +08:00

261 lines
7.2 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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"
NGROK_BIN=""
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
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 秒内就绪'
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
}
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
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