#!/bin/bash # iOS 自动打包服务 — 部署与管理脚本 # 用法: ./deploy.sh {up|build|start|stop|restart|status|watchdog|watchdog-stop} set -e SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" cd "$SCRIPT_DIR" PID_FILE="$SCRIPT_DIR/.server.pid" WATCHDOG_PID_FILE="$SCRIPT_DIR/.watchdog.pid" LOG_DIR="$SCRIPT_DIR/logs" VENV_DIR="$SCRIPT_DIR/.venv" # 加载 .env if [ -f .env ]; then set -a; source .env; set +a fi BACKEND_PORT="${BACKEND_PORT:-8000}" BIND_HOST="${BIND_HOST:-127.0.0.1}" WATCHDOG_INTERVAL="${WATCHDOG_INTERVAL:-30}" # 健康检查间隔(秒) WATCHDOG_TIMEOUT="${WATCHDOG_TIMEOUT:-10}" # 健康检查超时(秒) MAX_RESTART_ATTEMPTS="${MAX_RESTART_ATTEMPTS:-3}" # 连续重启上限,超过则冷却 COOLDOWN_SECONDS="${COOLDOWN_SECONDS:-300}" # 冷却时间(秒) WATCHDOG_STARTUP_GRACE="${WATCHDOG_STARTUP_GRACE:-10}" # watchdog 首次检查前的宽限(秒),避开正常启动窗口 STARTUP_READY_TIMEOUT="${STARTUP_READY_TIMEOUT:-20}" # 启动后等待 /api/health 就绪的最长时间(秒) # 重启方式:nohup(默认,PID 文件托管)| launchd(交回 launchctl kickstart 托管,避免与 KeepAlive 抢端口) WATCHDOG_RESTART_MODE="${WATCHDOG_RESTART_MODE:-nohup}" APP_LAUNCHD_LABEL="${APP_LAUNCHD_LABEL:-com.readoor.buildserver}" # ========== 颜色输出 ========== RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' NC='\033[0m' info() { echo -e "${GREEN}[INFO]${NC} $*"; } warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } error() { echo -e "${RED}[ERROR]${NC} $*"; } # 在 PID 文件丢失或进程 PID 被复用时,仍能安全识别本服务。 is_server_process() { local pid="$1" local command command=$(ps -p "$pid" -o command= 2>/dev/null || true) [[ "$command" == *"uvicorn backend.main:app"* ]] } get_server_pid() { local pid if [ -f "$PID_FILE" ]; then pid=$(cat "$PID_FILE") if kill -0 "$pid" 2>/dev/null && is_server_process "$pid"; then echo "$pid" return 0 fi rm -f "$PID_FILE" fi # 仅认领监听当前配置端口、且命令明确属于本项目的 uvicorn 进程。 for pid in $(lsof -tiTCP:"$BACKEND_PORT" -sTCP:LISTEN 2>/dev/null); do if is_server_process "$pid"; then echo "$pid" > "$PID_FILE" echo "$pid" return 0 fi done return 1 } # 返回占用目标端口、但命令不属于本项目的进程 PID(若有),用于区分“端口冲突”与“本服务在跑”。 foreign_port_holder() { local pid for pid in $(lsof -tiTCP:"$BACKEND_PORT" -sTCP:LISTEN 2>/dev/null); do if ! is_server_process "$pid"; then echo "$pid" return 0 fi done return 1 } # 启动 uvicorn 后端进程(单点维护启动参数,供 start / watchdog 复用),PID 写入 PID_FILE。 _spawn_server() { setup_venv mkdir -p "$LOG_DIR" nohup "$VENV_DIR/bin/python3" -m uvicorn backend.main:app \ --host "$BIND_HOST" \ --port "$BACKEND_PORT" \ --proxy-headers \ --forwarded-allow-ips 127.0.0.1 \ --workers 1 \ >> "$LOG_DIR/server.log" 2>&1 & echo $! > "$PID_FILE" } # 轮询 /api/health 直到返回 200 或超时;就绪返回 0,否则返回 1。 # 健康检查固定走 127.0.0.1(与 check_health 一致),不受 BIND_HOST 影响。 _wait_ready() { local timeout="${1:-$STARTUP_READY_TIMEOUT}" local deadline=$(( $(date +%s) + timeout )) while [ "$(date +%s)" -lt "$deadline" ]; do # 进程若已退出则无需再等 if [ -f "$PID_FILE" ] && ! kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then return 1 fi local code code=$(curl -s -o /dev/null -w "%{http_code}" \ --connect-timeout 2 --max-time 3 \ "http://127.0.0.1:$BACKEND_PORT/api/health" 2>/dev/null || echo "000") [ "$code" = "200" ] && return 0 sleep 1 done return 1 } # 日志同时写文件和终端 log_to_file() { local level="$1"; shift echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $*" >> "$LOG_DIR/watchdog.log" } # ========== 环境检查 ========== check_env() { if [ ! -f .env ]; then error ".env 文件不存在" echo " 请执行: cp .env.example .env && vim .env" exit 1 fi if ! command -v python3 &>/dev/null; then error "未找到 python3" exit 1 fi # 运行已构建的前端不需要 Node;仅在缺少静态资源时才需要它重新构建。 if [ ! -d "$SCRIPT_DIR/backend/static" ] && ! command -v node &>/dev/null; then error "未找到 node,且前端尚未构建" exit 1 fi # 检查 Xcode if ! command -v xcodebuild &>/dev/null; then warn "未找到 xcodebuild,打包功能将不可用" fi } # ========== Python 虚拟环境 ========== setup_venv() { # 项目目录移动后,venv 内 pip 的 shebang 会指向旧路径,需要重建。 if [ -d "$VENV_DIR" ] && ! "$VENV_DIR/bin/pip" --version >/dev/null 2>&1; then warn "检测到失效的 Python 虚拟环境,正在重建..." rm -rf "$VENV_DIR" fi if [ ! -d "$VENV_DIR" ]; then info "创建 Python 虚拟环境..." python3 -m venv "$VENV_DIR" fi "$VENV_DIR/bin/python3" -m pip install -r requirements.txt -q } # ========== 构建前端 ========== do_build() { # build 只需要 Python 和 Node,不要求 .env if ! command -v python3 &>/dev/null; then error "未找到 python3"; exit 1 fi if ! command -v node &>/dev/null; then error "未找到 node"; exit 1 fi # 首次部署时自动创建 .env if [ ! -f .env ]; then info "首次部署,创建 .env 配置文件..." cp .env.example .env warn "已创建 .env,请按需修改配置(当前为默认值)" fi info "安装前端依赖..." cd "$SCRIPT_DIR/frontend" npm install --silent info "构建前端..." npm run build cd "$SCRIPT_DIR" setup_venv info "构建完成" } # ========== 启动服务 ========== do_start() { check_env # 端口被非本项目进程占用:明确报错,避免 uvicorn 静默绑定失败。 local foreign foreign=$(foreign_port_holder || true) if [ -n "$foreign" ]; then error "端口 $BACKEND_PORT 被非本项目进程占用 (PID: $foreign):" ps -p "$foreign" -o pid,command= 2>/dev/null | sed 's/^/ /' error "请先释放该端口,或在 .env 修改 BACKEND_PORT" return 1 fi # 本项目实例已在运行:强制重来(用户选定语义)。 # 注意:这会中断正在进行的打包任务。 local existing_pid existing_pid=$(get_server_pid || true) if [ -n "$existing_pid" ]; then warn "检测到旧服务 (PID: $existing_pid),强制重启(若有打包任务将被中断)..." do_stop fi # 确保前端已构建 if [ ! -d "$SCRIPT_DIR/backend/static" ]; then warn "前端未构建,先执行 build..." do_build fi info "启动后端服务 ($BIND_HOST:$BACKEND_PORT)..." _spawn_server if _wait_ready; then info "服务已就绪 (PID: $(cat "$PID_FILE"))" info "访问地址: http://$BIND_HOST:$BACKEND_PORT" else error "服务启动后 ${STARTUP_READY_TIMEOUT}s 内未就绪,请查看日志: $LOG_DIR/server.log" tail -20 "$LOG_DIR/server.log" 2>/dev/null | sed 's/^/ /' return 1 fi } # ========== 停止服务 ========== do_stop() { local pid pid=$(get_server_pid || true) if [ -z "$pid" ]; then warn "服务未在运行" return 0 fi info "停止服务 (PID: $pid)..." kill "$pid" # 等待进程退出 for i in $(seq 1 10); do kill -0 "$pid" 2>/dev/null || break sleep 0.5 done # 还没退出就强杀 if kill -0 "$pid" 2>/dev/null; then warn "进程未响应,强制终止..." kill -9 "$pid" fi info "服务已停止" rm -f "$PID_FILE" } # ========== 查看状态 ========== do_status() { local pid pid=$(get_server_pid || true) if [ -n "$pid" ]; then info "服务运行中 (PID: $pid), 端口: $BACKEND_PORT" else warn "服务未运行" fi if [ -f "$WATCHDOG_PID_FILE" ] && kill -0 "$(cat "$WATCHDOG_PID_FILE")" 2>/dev/null; then info "Watchdog 运行中 (PID: $(cat "$WATCHDOG_PID_FILE")), 检查间隔: ${WATCHDOG_INTERVAL}s" else warn "Watchdog 未运行" rm -f "$WATCHDOG_PID_FILE" 2>/dev/null fi } # ========== 健康检查 ========== check_health() { # 检查进程是否存在 local pid pid=$(get_server_pid || true) if [ -z "$pid" ]; then echo "process_down" return fi # 检查 HTTP 是否响应 local http_code http_code=$(curl -s -o /dev/null -w "%{http_code}" \ --connect-timeout "$WATCHDOG_TIMEOUT" \ --max-time "$WATCHDOG_TIMEOUT" \ "http://127.0.0.1:$BACKEND_PORT/api/health" 2>/dev/null || echo "000") if [ "$http_code" != "200" ]; then echo "http_$http_code" return fi echo "ok" } # ========== Watchdog(前台模式,用于调试) ========== do_watchdog_fg() { info "Watchdog 启动(前台模式),检查间隔: ${WATCHDOG_INTERVAL}s,启动宽限: ${WATCHDOG_STARTUP_GRACE}s" info "按 Ctrl+C 停止" local restart_count=0 local last_restart_time=0 # 启动宽限:避开服务正常启动/重启的就绪窗口,防止误判触发重启。 sleep "$WATCHDOG_STARTUP_GRACE" while true; do local result result=$(check_health) if [ "$result" = "ok" ]; then restart_count=0 else local now now=$(date +%s) warn "健康检查失败: $result" # 冷却判断:连续重启超过上限,暂停一段时间 if [ "$restart_count" -ge "$MAX_RESTART_ATTEMPTS" ]; then local elapsed=$((now - last_restart_time)) if [ "$elapsed" -lt "$COOLDOWN_SECONDS" ]; then local wait=$((COOLDOWN_SECONDS - elapsed)) warn "连续重启 ${restart_count} 次,冷却中,${wait}s 后重试..." log_to_file "WARN" "Cooldown: ${restart_count} restarts, waiting ${wait}s" sleep "$wait" continue else warn "冷却结束,重新尝试..." restart_count=0 fi fi warn "正在重启服务..." log_to_file "WARN" "Restarting service (cause: $result, attempt: $((restart_count+1)))" do_stop sleep 2 do_start restart_count=$((restart_count + 1)) last_restart_time=$(date +%s) if [ "$restart_count" -ge "$MAX_RESTART_ATTEMPTS" ]; then error "已连续重启 ${restart_count} 次,进入冷却..." log_to_file "ERROR" "Entered cooldown after $restart_count restarts" fi fi sleep "$WATCHDOG_INTERVAL" done } # ========== Watchdog(后台守护) ========== do_watchdog() { mkdir -p "$LOG_DIR" if [ -f "$WATCHDOG_PID_FILE" ] && kill -0 "$(cat "$WATCHDOG_PID_FILE")" 2>/dev/null; then warn "Watchdog 已在运行 (PID: $(cat "$WATCHDOG_PID_FILE"))" return 0 fi info "启动 Watchdog(后台模式),检查间隔: ${WATCHDOG_INTERVAL}s" nohup bash -c " cd '$SCRIPT_DIR' source .env 2>/dev/null exec bash '$(realpath "$0")' _watchdog_loop " >> "$LOG_DIR/watchdog.log" 2>&1 & echo $! > "$WATCHDOG_PID_FILE" sleep 1 if kill -0 "$(cat "$WATCHDOG_PID_FILE")" 2>/dev/null; then info "Watchdog 已启动 (PID: $(cat "$WATCHDOG_PID_FILE"))" else error "Watchdog 启动失败" rm -f "$WATCHDOG_PID_FILE" return 1 fi } # 内部循环入口(由后台 watchdog 进程调用) do_watchdog_loop() { local restart_count=0 local last_restart_time=0 log_to_file "INFO" "Watchdog started, interval=${WATCHDOG_INTERVAL}s, grace=${WATCHDOG_STARTUP_GRACE}s, mode=${WATCHDOG_RESTART_MODE}" # 启动宽限:避开服务正常启动/重启的就绪窗口,防止误判触发重启。 sleep "$WATCHDOG_STARTUP_GRACE" while true; do local result result=$(check_health) if [ "$result" = "ok" ]; then restart_count=0 else local now now=$(date +%s) log_to_file "WARN" "Health check failed: $result" if [ "$restart_count" -ge "$MAX_RESTART_ATTEMPTS" ]; then local elapsed=$((now - last_restart_time)) if [ "$elapsed" -lt "$COOLDOWN_SECONDS" ]; then local wait=$((COOLDOWN_SECONDS - elapsed)) log_to_file "WARN" "Cooldown: ${restart_count} restarts, waiting ${wait}s" sleep "$wait" continue else log_to_file "INFO" "Cooldown ended, resetting counter" restart_count=0 fi fi restart_count=$((restart_count + 1)) last_restart_time=$(date +%s) log_to_file "WARN" "Restarting service (attempt: $restart_count, mode: $WATCHDOG_RESTART_MODE)" if [ "$WATCHDOG_RESTART_MODE" = "launchd" ]; then # launchd 托管模式:交回 launchctl 重启,避免与 KeepAlive 抢占端口。 if launchctl kickstart -k "gui/$(id -u)/$APP_LAUNCHD_LABEL" 2>/dev/null; then log_to_file "INFO" "launchctl kickstart issued for $APP_LAUNCHD_LABEL" else log_to_file "ERROR" "launchctl kickstart failed for $APP_LAUNCHD_LABEL" fi else # nohup 托管模式:停掉旧进程后重新拉起。 if [ -f "$PID_FILE" ]; then local old_pid old_pid=$(cat "$PID_FILE") kill "$old_pid" 2>/dev/null sleep 2 kill -9 "$old_pid" 2>/dev/null || true rm -f "$PID_FILE" fi _spawn_server fi # 无论哪种模式,都等待 /api/health 就绪再继续,避免连锁误判。 if _wait_ready; then log_to_file "INFO" "Service restarted successfully (PID: $(cat "$PID_FILE" 2>/dev/null))" else log_to_file "ERROR" "Service restart failed or not ready in ${STARTUP_READY_TIMEOUT}s" fi fi sleep "$WATCHDOG_INTERVAL" done } # ========== 停止 Watchdog ========== do_watchdog_stop() { if [ ! -f "$WATCHDOG_PID_FILE" ]; then warn "Watchdog 未在运行" return 0 fi local pid pid=$(cat "$WATCHDOG_PID_FILE") if kill -0 "$pid" 2>/dev/null; then info "停止 Watchdog (PID: $pid)..." kill "$pid" sleep 1 kill -9 "$pid" 2>/dev/null || true info "Watchdog 已停止" else warn "Watchdog 进程已不存在" fi rm -f "$WATCHDOG_PID_FILE" } # ========== 运行测试 ========== do_test() { local failed=0 # 确保依赖已安装 setup_venv "$VENV_DIR/bin/python3" -m pip install -r requirements-dev.txt -q if [ ! -d "frontend/node_modules" ]; then cd frontend && npm install --silent && cd .. fi # 后端测试 info "运行后端测试..." echo "────────────────────────────────────────" "$VENV_DIR/bin/python3" -m pytest tests/ -v "$@" || failed=1 echo "" # 前端测试 info "运行前端测试..." echo "────────────────────────────────────────" cd frontend && npx vitest run "$@" || failed=1 cd "$SCRIPT_DIR" echo "" # 结果 if [ "$failed" -eq 0 ]; then info "全部测试通过" else error "存在失败的测试" exit 1 fi } # ========== 一键部署(强制重来)========== # build → start(强制重启并等待就绪)→ watchdog,最后打印状态。 do_up() { info "一键部署(强制重来):build → start → watchdog" do_watchdog_stop >/dev/null 2>&1 || true # 先停监听,避免它在重启窗口内干预 do_build do_start # do_start 内部会强制停旧服务并等待 health 就绪 do_watchdog echo "" do_status } # ========== 主入口 ========== case "${1:-}" in up) do_up ;; build) do_build ;; start) do_start ;; stop) do_stop; do_watchdog_stop ;; restart) do_stop; do_start ;; status) do_status ;; test) shift; do_test "$@" ;; watchdog) do_watchdog ;; watchdog-stop) do_watchdog_stop ;; watchdog-fg) do_watchdog_fg ;; _watchdog_loop) do_watchdog_loop ;; *) echo "用法: $0 {up|build|start|stop|restart|status|test|watchdog|watchdog-stop}" echo "" echo " up 一键部署:build + start + watchdog(强制重来)" echo " build 构建前端、安装依赖" echo " start 启动服务(强制重启并等待就绪;端口被占用会报错)" echo " stop 停止服务(同时停止 watchdog)" echo " restart 重启服务" echo " status 查看运行状态" echo " test 运行全部测试(后端 + 前端)" echo " watchdog 启动健康监测(后台守护,自动重启故障服务)" echo " watchdog-stop 停止健康监测" echo " watchdog-fg 前台运行健康监测(调试用)" echo "" echo "环境变量(可在 .env 中配置):" echo " WATCHDOG_INTERVAL 健康检查间隔(秒,默认 30)" echo " WATCHDOG_TIMEOUT 健康检查超时(秒,默认 10)" echo " WATCHDOG_STARTUP_GRACE watchdog 首次检查前宽限(秒,默认 10)" echo " STARTUP_READY_TIMEOUT 启动后等待 health 就绪的最长时间(秒,默认 20)" echo " WATCHDOG_RESTART_MODE 重启方式 nohup|launchd(默认 nohup)" echo " MAX_RESTART_ATTEMPTS 连续重启上限(默认 3)" echo " COOLDOWN_SECONDS 超过重启上限后冷却时间(秒,默认 300)" exit 1 ;; esac