#!/bin/bash # iOS 自动打包服务 — 部署与管理脚本 # 用法: ./deploy.sh {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}" # 冷却时间(秒) # ========== 颜色输出 ========== 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 } # 日志同时写文件和终端 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 local existing_pid existing_pid=$(get_server_pid || true) if [ -n "$existing_pid" ]; then warn "服务已在运行 (PID: $existing_pid)" return 0 fi # 确保前端已构建 if [ ! -d "$SCRIPT_DIR/backend/static" ]; then warn "前端未构建,先执行 build..." do_build fi setup_venv mkdir -p "$LOG_DIR" info "启动后端服务 ($BIND_HOST:$BACKEND_PORT)..." 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" sleep 1 if kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then info "服务已启动 (PID: $(cat "$PID_FILE"))" info "访问地址: http://$BIND_HOST:$BACKEND_PORT" else error "启动失败,请查看日志: $LOG_DIR/server.log" rm -f "$PID_FILE" 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" info "按 Ctrl+C 停止" local restart_count=0 local last_restart_time=0 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" 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)" # 停止旧进程 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 # 重新启动 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" sleep 3 if kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then log_to_file "INFO" "Service restarted successfully (PID: $(cat "$PID_FILE"))" else log_to_file "ERROR" "Service restart failed" 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 } # ========== 主入口 ========== case "${1:-}" in 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 {build|start|stop|restart|status|test|watchdog|watchdog-stop}" echo "" 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 " MAX_RESTART_ATTEMPTS 连续重启上限(默认 3)" echo " COOLDOWN_SECONDS 超过重启上限后冷却时间(秒,默认 300)" exit 1 ;; esac