Files
zkxw/backend/start-test.sh
T
2026-10-15 14:27:42 +08:00

590 lines
15 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
# ============================================
# 陇耘鲜达 Backend 测试环境启动脚本
# 适配: python3.9+(推荐 venv;显式不使用 python2)
#
# 测试服务器目录示例: /root/zkxw/backend
# 默认监听: 127.0.0.1:10002(裸机 Nginx 反代;勿与生产 8111 冲突)
# 旧双机拓扑 0.0.0.0:8112 已废弃;手动 start/restart 时也走本默认,避免 Nginx 502。
#
# 首次部署:
# cd /root/zkxw/backend
# cp .env.test.example .env.test # 或从 .env.example 复制后按测试库填写
# python3.9 -m venv .venv && ./.venv/bin/pip install -r requirements.txt
# ./start-test.sh activate # source .venv/bin/activate
# ./start-test.sh start
# ============================================
set -euo pipefail
BASE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$BASE_DIR"
APP_MODULE="${APP_MODULE:-app.main:app}"
HOST="${HOST:-127.0.0.1}"
PORT="${PORT:-10002}"
WORKERS="${WORKERS:-1}"
LOG_LEVEL="${LOG_LEVEL:-debug}"
ENV_FILE="${ENV_FILE:-$BASE_DIR/.env.test}"
VENV_DIR="${VENV_DIR:-$BASE_DIR/.venv}"
ACTIVATE_SCRIPT="$VENV_DIR/bin/activate"
PYTHON_BIN="${PYTHON_BIN:-$VENV_DIR/bin/python}"
MIN_PYTHON_MAJOR=3
MIN_PYTHON_MINOR=9
SYSTEM_PYTHON=""
LOG_DIR="$BASE_DIR/logs"
LOG_FILE="$LOG_DIR/backend-test.log"
PID_FILE="$BASE_DIR/backend-test.pid"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log_info() {
echo -e "${GREEN}[INFO]${NC} $1"
}
log_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
show_help() {
cat << 'EOF'
用法:
./start-test.sh [start|stop|restart|status|logs|activate]
命令:
start 启动服务(默认)
stop 停止服务
restart 重启服务
status 查看状态
logs 查看日志(最近100行)
activate 激活 .venv 并进入交互 shell(等价 source .venv/bin/activate)
可选环境变量:
HOST=127.0.0.1
PORT=10002
WORKERS=1
LOG_LEVEL=debug
APP_MODULE=app.main:app
VENV_DIR=./.venv
PYTHON_FOR_VENV=python3.9
ENV_FILE=./.env.test
测试部署目录(示例):
/root/zkxw/backend
cd /root/zkxw/backend && ./start-test.sh start
说明:
- 读取 .env.test(非 .env.production)
- 默认 127.0.0.1:10002(Nginx 反代);生产 start-prod.sh 使用 8111
- 可与生产共用 venv,但 PID/日志文件独立,勿混用 start/stop 命令
- stop/restart 除 PID 文件外,还会按端口清理本应用 uvicorn,避免僵尸进程占端口导致发版假成功
EOF
}
ensure_dirs() {
mkdir -p "$LOG_DIR"
}
python_version_ge() {
local ver="$1"
local major="${ver%%.*}"
local minor="${ver#*.}"
if [ "$major" -gt "$MIN_PYTHON_MAJOR" ]; then
return 0
fi
if [ "$major" -eq "$MIN_PYTHON_MAJOR" ] && [ "$minor" -ge "$MIN_PYTHON_MINOR" ]; then
return 0
fi
return 1
}
resolve_system_python() {
local candidate ver
if [ -n "${PYTHON_FOR_VENV:-}" ] && command -v "$PYTHON_FOR_VENV" >/dev/null 2>&1; then
ver="$("$PYTHON_FOR_VENV" -c 'import sys; print("{}.{}".format(sys.version_info[0], sys.version_info[1]))')"
if python_version_ge "$ver"; then
echo "$PYTHON_FOR_VENV"
return 0
fi
log_error "PYTHON_FOR_VENV=$PYTHON_FOR_VENV 版本为 $ver,需要 >= ${MIN_PYTHON_MAJOR}.${MIN_PYTHON_MINOR}"
exit 1
fi
for candidate in python3.11 python3.10 python3.9 python3; do
if command -v "$candidate" >/dev/null 2>&1; then
ver="$("$candidate" -c 'import sys; print("{}.{}".format(sys.version_info[0], sys.version_info[1]))')"
if python_version_ge "$ver"; then
echo "$candidate"
return 0
fi
fi
done
return 1
}
check_python() {
if ! SYSTEM_PYTHON="$(resolve_system_python)"; then
log_error "未找到 python${MIN_PYTHON_MAJOR}.${MIN_PYTHON_MINOR}+,请安装 python39 或设置 PYTHON_FOR_VENV=python3.9"
exit 1
fi
log_info "系统 Python: $SYSTEM_PYTHON ($("$SYSTEM_PYTHON" --version 2>&1))"
}
ensure_venv_python_version() {
local ver
ver="$("$PYTHON_BIN" -c 'import sys; print("{}.{}".format(sys.version_info[0], sys.version_info[1]))')"
if python_version_ge "$ver"; then
log_info "虚拟环境 Python: $ver ($VENV_DIR)"
return 0
fi
log_error "虚拟环境 Python 版本过低: $ver(需要 >= ${MIN_PYTHON_MAJOR}.${MIN_PYTHON_MINOR})"
log_error "当前虚拟环境目录: $VENV_DIR"
log_warn "请执行: rm -rf \"$VENV_DIR\" && $SYSTEM_PYTHON -m venv \"$VENV_DIR\" && \"$VENV_DIR/bin/pip\" install -r requirements.txt"
exit 1
}
has_uvicorn() {
"$PYTHON_BIN" -c "import uvicorn" >/dev/null 2>&1
}
ensure_venv_and_runtime() {
log_info "虚拟环境目录: $VENV_DIR"
if [ ! -d "$VENV_DIR" ]; then
log_warn "未找到虚拟环境,开始自动创建: $VENV_DIR"
if ! "$SYSTEM_PYTHON" -m venv "$VENV_DIR" >/dev/null 2>&1; then
log_error "自动创建 venv 失败,请先安装 python3-venv 或手动创建虚拟环境"
log_warn "可尝试: $SYSTEM_PYTHON -m venv \"$VENV_DIR\""
exit 1
fi
fi
if [ ! -x "$PYTHON_BIN" ]; then
log_error "虚拟环境 python 不存在: $PYTHON_BIN"
exit 1
fi
ensure_venv_python_version
if [ ! -x "$VENV_DIR/bin/pip" ]; then
log_warn "虚拟环境 pip 不存在,尝试引导安装"
"$PYTHON_BIN" -m ensurepip --upgrade >/dev/null 2>&1 || true
fi
if ! has_uvicorn; then
log_warn "未检测到 uvicorn,开始安装依赖 requirements.txt"
if [ ! -x "$VENV_DIR/bin/pip" ]; then
log_error "pip 仍不可用,请手动执行依赖初始化"
exit 1
fi
"$VENV_DIR/bin/pip" install --upgrade pip
"$VENV_DIR/bin/pip" install -r "$BASE_DIR/requirements.txt"
if ! has_uvicorn; then
log_error "依赖安装后仍无法 import uvicorn,请检查: $PYTHON_BIN -m pip install -r requirements.txt"
exit 1
fi
fi
}
# 读取 PID 文件中的进程号;必须存活且确认为本应用 uvicorn(防止脏 PID/PID=1 误杀)
read_pid_file() {
if [ ! -f "$PID_FILE" ]; then
return 0
fi
local pid
pid="$(tr -d '[:space:]' < "$PID_FILE" 2>/dev/null || true)"
if [ -z "$pid" ] || ! [[ "$pid" =~ ^[0-9]+$ ]]; then
return 0
fi
# 拒绝系统关键 PID 与明显非法值
if [ "$pid" -le 1 ]; then
return 0
fi
if ! ps -p "$pid" >/dev/null 2>&1; then
return 0
fi
if ! is_our_uvicorn_pid "$pid"; then
return 0
fi
echo "$pid"
}
# 进程命令行是否像本目录下的测试 uvicorn
is_our_uvicorn_pid() {
local pid="$1"
local cmd=""
if [ ! -r "/proc/$pid/cmdline" ]; then
return 1
fi
cmd="$(tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null || true)"
case "$cmd" in
*"${BASE_DIR}"*"uvicorn"*"${APP_MODULE}"*) return 0 ;;
*"${VENV_DIR}/bin/python"*"uvicorn"*"${APP_MODULE}"*"--port ${PORT}"*) return 0 ;;
*"${VENV_DIR}/bin/python"*"uvicorn"*"${APP_MODULE}"*"--port=${PORT}"*) return 0 ;;
esac
# 兼容无绝对路径、仅按 port + app 模块匹配(仍限制在本机测试端口)
case "$cmd" in
*"uvicorn"*"${APP_MODULE}"*"--port ${PORT}"*|*"uvicorn"*"${APP_MODULE}"*"--port=${PORT}"*)
return 0
;;
esac
return 1
}
# 列出占用 PORT 的监听进程 PID
list_listen_pids_on_port() {
local pids=""
if command -v ss >/dev/null 2>&1; then
pids="$(ss -lptn "sport = :${PORT}" 2>/dev/null | sed -n 's/.*pid=\([0-9][0-9]*\).*/\1/p' | sort -u)"
fi
if [ -z "$pids" ] && command -v lsof >/dev/null 2>&1; then
pids="$(lsof -tiTCP:"${PORT}" -sTCP:LISTEN 2>/dev/null | sort -u || true)"
fi
if [ -z "$pids" ] && command -v fuser >/dev/null 2>&1; then
pids="$(fuser "${PORT}/tcp" 2>/dev/null | tr -s '[:space:]' '\n' | grep -E '^[0-9]+$' | sort -u || true)"
fi
echo "$pids"
}
# 收集应停止的 PID:PID 文件 + 占用本端口的本应用进程
collect_managed_pids() {
local pid p seen=""
pid="$(read_pid_file || true)"
if [ -n "${pid:-}" ]; then
echo "$pid"
seen=" $pid "
fi
for p in $(list_listen_pids_on_port); do
if [ -z "$p" ]; then
continue
fi
case "$seen" in
*" $p "*) continue ;;
esac
if is_our_uvicorn_pid "$p"; then
echo "$p"
seen="${seen}${p} "
fi
done
}
port_is_in_use() {
local p
p="$(list_listen_pids_on_port | head -n 1)"
[ -n "$p" ]
}
wait_port_free() {
local i
for i in $(seq 1 20); do
if ! port_is_in_use; then
return 0
fi
sleep 1
done
return 1
}
wait_health_ok() {
local i url="http://127.0.0.1:${PORT}/health"
for i in $(seq 1 30); do
if curl -fsS --connect-timeout 1 --max-time 2 "$url" >/dev/null 2>&1; then
return 0
fi
sleep 1
done
return 1
}
is_running() {
local pid
pid="$(read_pid_file || true)"
if [ -n "${pid:-}" ]; then
return 0
fi
# PID 文件失效时,仍以「本端口上的本应用进程」为准,避免误判已停止
for pid in $(list_listen_pids_on_port); do
if is_our_uvicorn_pid "$pid"; then
return 0
fi
done
return 1
}
# 仅扩展「已确认属于本应用」的进程树;禁止对 PID<=1 或未校验进程做树展开
expand_our_pid_tree() {
local pid="$1"
local c
if [ -z "$pid" ] || [ "$pid" -le 1 ]; then
return 0
fi
if ! ps -p "$pid" >/dev/null 2>&1; then
return 0
fi
if ! is_our_uvicorn_pid "$pid"; then
return 0
fi
echo "$pid"
for c in $(pgrep -P "$pid" 2>/dev/null || true); do
# 子进程可能是纯 python worker,命令行仍含 uvicorn/app.main;否则跳过
if [ -n "$c" ] && [ "$c" -gt 1 ] && is_our_uvicorn_pid "$c"; then
expand_our_pid_tree "$c"
elif [ -n "$c" ] && [ "$c" -gt 1 ] && ps -p "$c" >/dev/null 2>&1; then
# worker 偶发 cmdline 不含完整模块名时:仅当父进程已是本应用才收其子进程
echo "$c"
fi
done
}
kill_pids_graceful_then_force() {
local pid
local any=0
local all_pids=""
local p
for pid in "$@"; do
if [ -z "$pid" ] || [ "$pid" -le 1 ]; then
continue
fi
for p in $(expand_our_pid_tree "$pid"); do
if [ -n "$p" ] && [ "$p" -gt 1 ]; then
all_pids="${all_pids}${p} "
fi
done
done
# shellcheck disable=SC2086
set -- $all_pids
for pid in "$@"; do
if [ -z "$pid" ] || [ "$pid" -le 1 ]; then
continue
fi
if ! ps -p "$pid" >/dev/null 2>&1; then
continue
fi
any=1
log_info "正在停止测试服务 (PID: $pid)"
kill -TERM "$pid" >/dev/null 2>&1 || true
done
if [ "$any" -eq 0 ]; then
return 0
fi
local i left
for i in $(seq 1 10); do
left=0
for pid in "$@"; do
if [ -n "$pid" ] && [ "$pid" -gt 1 ] && ps -p "$pid" >/dev/null 2>&1; then
left=1
break
fi
done
if [ "$left" -eq 0 ]; then
break
fi
sleep 1
done
for pid in "$@"; do
if [ -n "$pid" ] && [ "$pid" -gt 1 ] && ps -p "$pid" >/dev/null 2>&1; then
log_warn "优雅停止超时,执行强制停止 (PID: $pid)"
kill -9 "$pid" >/dev/null 2>&1 || true
fi
done
}
# 停止本应用在测试端口上的全部实例(含 PID 文件失效的僵尸进程)
stop_server() {
local pids
pids="$(collect_managed_pids | sort -u | tr '\n' ' ')"
# shellcheck disable=SC2086
set -- $pids
if [ "$#" -eq 0 ]; then
# 端口仍被非本应用占用时给出明确提示
if port_is_in_use; then
log_warn "测试服务未由本脚本托管,但端口 ${PORT} 仍被占用: $(list_listen_pids_on_port | tr '\n' ' ')"
log_warn "请人工确认后再启动,避免绑端口失败"
else
log_warn "测试服务未运行"
fi
rm -f "$PID_FILE"
return 0
fi
kill_pids_graceful_then_force "$@"
# 若仍有本应用占端口,再扫一遍强杀
pids="$(collect_managed_pids | sort -u | tr '\n' ' ')"
# shellcheck disable=SC2086
set -- $pids
if [ "$#" -gt 0 ]; then
log_warn "仍检测到本应用进程,执行二次强制停止"
for pid in "$@"; do
kill -9 "$pid" >/dev/null 2>&1 || true
done
fi
rm -f "$PID_FILE"
if ! wait_port_free; then
log_error "端口 ${PORT} 仍被占用,无法确认已停止: $(list_listen_pids_on_port | tr '\n' ' ')"
return 1
fi
log_info "测试服务已停止"
return 0
}
start_server() {
check_python
ensure_venv_and_runtime
ensure_dirs
# 已有本应用在跑(PID 文件或端口探测)则直接返回
if is_running; then
local running_pid
running_pid="$(read_pid_file || true)"
if [ -z "${running_pid:-}" ]; then
running_pid="$(collect_managed_pids | head -n 1)"
fi
log_warn "测试服务已在运行 (PID: ${running_pid:-unknown})"
# 回写 PID 文件,避免后续 stop 找不到
if [ -n "${running_pid:-}" ]; then
echo "$running_pid" > "$PID_FILE"
fi
exit 0
fi
# 端口被占用但不是本应用:拒绝启动,避免静默失败后健康检查打到别人的服务
if port_is_in_use; then
log_error "端口 ${PORT} 已被占用且无法识别为本测试服务: $(list_listen_pids_on_port | tr '\n' ' ')"
log_error "请先释放端口后再 start / restart"
exit 1
fi
rm -f "$PID_FILE"
log_info "启动测试环境服务..."
log_info "监听地址: $HOST:$PORT"
log_info "工作进程: $WORKERS"
log_info "日志文件: $LOG_FILE"
log_info "环境文件: $ENV_FILE"
if [ ! -f "$ENV_FILE" ]; then
log_error "环境文件不存在: $ENV_FILE"
log_warn "可先执行: cp .env.test.example .env.test 并按测试库修改"
exit 1
fi
export ENV_FILE
nohup "$PYTHON_BIN" -m uvicorn "$APP_MODULE" \
--host "$HOST" \
--port "$PORT" \
--workers "$WORKERS" \
--log-level "$LOG_LEVEL" \
>> "$LOG_FILE" 2>&1 &
echo $! > "$PID_FILE"
if ! wait_health_ok; then
log_error "测试服务启动失败(健康检查超时),请检查日志: $LOG_FILE"
# 启动失败时尽量清掉半拉子进程,避免留下占端口僵尸
stop_server || true
exit 1
fi
# 优先把 PID 文件写成实际监听端口的进程,避免 nohup 壳/worker 不一致
local listen_pid
listen_pid="$(list_listen_pids_on_port | head -n 1 || true)"
if [ -n "${listen_pid:-}" ]; then
echo "$listen_pid" > "$PID_FILE"
fi
log_info "测试服务启动成功 (PID: $(cat "$PID_FILE"))"
log_info "健康检查: http://127.0.0.1:$PORT/health"
log_info "API 文档: http://127.0.0.1:$PORT/docs"
}
status_server() {
local pid
if is_running; then
pid="$(read_pid_file || true)"
if [ -z "${pid:-}" ]; then
pid="$(collect_managed_pids | head -n 1)"
fi
echo -e "${GREEN}RUNNING (test)${NC} PID=${pid:-unknown} HOST=$HOST PORT=$PORT"
else
if port_is_in_use; then
echo -e "${YELLOW}PORT BUSY (test)${NC} PORT=$PORT pids=$(list_listen_pids_on_port | tr '\n' ' ')"
else
echo -e "${RED}STOPPED (test)${NC}"
fi
fi
}
show_logs() {
ensure_dirs
if [ ! -f "$LOG_FILE" ]; then
log_warn "暂无日志文件: $LOG_FILE"
exit 0
fi
tail -n 100 "$LOG_FILE"
}
activate_venv() {
if [ ! -f "$ACTIVATE_SCRIPT" ]; then
log_error "虚拟环境激活脚本不存在: $ACTIVATE_SCRIPT"
log_warn "可先执行: python3.9 -m venv .venv && ./.venv/bin/pip install -r requirements.txt"
exit 1
fi
log_info "激活虚拟环境: source $ACTIVATE_SCRIPT"
exec "$SHELL" -i -c "cd '$BASE_DIR' && source '$ACTIVATE_SCRIPT' && exec -i '$SHELL'"
}
ACTION="${1:-start}"
case "$ACTION" in
start)
start_server
;;
stop)
stop_server
;;
restart)
stop_server || true
start_server
;;
status)
status_server
;;
logs)
show_logs
;;
activate)
activate_venv
;;
-h|--help|help)
show_help
;;
*)
log_error "未知命令: $ACTION"
show_help
exit 1
;;
esac