#!/usr/bin/env bash set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" RUNTIME_DIR="$ROOT_DIR/.dev-runtime" BACKEND_PID_FILE="$RUNTIME_DIR/backend.pid" FRONTEND_PID_FILE="$RUNTIME_DIR/frontend.pid" BACKEND_LOG_FILE="$RUNTIME_DIR/backend.log" FRONTEND_LOG_FILE="$RUNTIME_DIR/frontend.log" BACKEND_HOST="127.0.0.1" BACKEND_PORT="8000" FRONTEND_HOST="127.0.0.1" FRONTEND_PORT="5173" usage() { cat <<'EOF' Usage: scripts/dev.sh EOF } log() { printf '[dev] %s\n' "$*" } fail() { printf '[dev] %s\n' "$*" >&2 exit 1 } ensure_base_requirements() { [[ -x "$ROOT_DIR/backend/.venv/bin/uvicorn" ]] || fail "缺少 backend/.venv/bin/uvicorn" [[ -f "$ROOT_DIR/frontend/package.json" ]] || fail "缺少 frontend/package.json" [[ -d "$ROOT_DIR/frontend/node_modules" ]] || fail "缺少 frontend/node_modules,请先执行前端依赖安装" command -v npm >/dev/null 2>&1 || fail "当前环境缺少 npm" command -v setsid >/dev/null 2>&1 || fail "当前环境缺少 setsid" } ensure_runtime_dir() { mkdir -p "$RUNTIME_DIR" } pid_from_file() { local pid_file="$1" [[ -f "$pid_file" ]] || return 1 local pid pid="$(tr -d '[:space:]' < "$pid_file")" [[ -n "$pid" ]] || return 1 printf '%s\n' "$pid" } is_pid_running() { local pid="$1" kill -0 "$pid" 2>/dev/null } cleanup_stale_pid_file() { local service_name="$1" local pid_file="$2" local announce="${3:-true}" if pid="$(pid_from_file "$pid_file" 2>/dev/null)"; then if ! is_pid_running "$pid"; then rm -f "$pid_file" if [[ "$announce" == "true" ]]; then log "$service_name PID 文件已陈旧,已自动清理" fi fi elif [[ -f "$pid_file" ]]; then rm -f "$pid_file" if [[ "$announce" == "true" ]]; then log "$service_name PID 文件内容无效,已自动清理" fi fi } active_pid_from_file() { local pid_file="$1" if pid="$(pid_from_file "$pid_file" 2>/dev/null)"; then if is_pid_running "$pid"; then printf '%s\n' "$pid" return 0 fi fi return 1 } http_ready() { local url="$1" "$ROOT_DIR/backend/.venv/bin/python" - "$url" <<'PY' import sys import urllib.request url = sys.argv[1] try: with urllib.request.urlopen(url, timeout=1) as response: status = getattr(response, 'status', 200) raise SystemExit(0 if 200 <= status < 400 else 1) except Exception: raise SystemExit(1) PY } wait_for_service() { local service_name="$1" local pid_file="$2" local health_url="$3" local timeout_seconds="${4:-30}" local elapsed=0 while (( elapsed < timeout_seconds )); do if http_ready "$health_url"; then return 0 fi if ! active_pid_from_file "$pid_file" >/dev/null 2>&1; then log "$service_name 在就绪前已退出,请检查日志" return 1 fi sleep 1 elapsed=$((elapsed + 1)) done log "$service_name 在 ${timeout_seconds}s 内未就绪" return 1 } start_backend() { : > "$BACKEND_LOG_FILE" ( cd "$ROOT_DIR" exec setsid "$ROOT_DIR/backend/.venv/bin/uvicorn" \ backend.app.main:app \ --app-dir . \ --host "$BACKEND_HOST" \ --port "$BACKEND_PORT" \ --reload ) >>"$BACKEND_LOG_FILE" 2>&1 & echo "$!" > "$BACKEND_PID_FILE" } start_frontend() { : > "$FRONTEND_LOG_FILE" ( cd "$ROOT_DIR/frontend" exec setsid npm run dev -- --host "$FRONTEND_HOST" --port "$FRONTEND_PORT" ) >>"$FRONTEND_LOG_FILE" 2>&1 & echo "$!" > "$FRONTEND_PID_FILE" } stop_service() { local service_name="$1" local pid_file="$2" cleanup_stale_pid_file "$service_name" "$pid_file" false if ! pid="$(active_pid_from_file "$pid_file" 2>/dev/null)"; then log "$service_name 未运行" return 0 fi log "停止 $service_name (PID $pid)" kill -TERM -- "-$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null || true local attempt=0 while (( attempt < 10 )); do if ! is_pid_running "$pid"; then rm -f "$pid_file" log "$service_name 已停止" return 0 fi sleep 1 attempt=$((attempt + 1)) done log "$service_name 未在预期时间内退出,强制结束" kill -KILL -- "-$pid" 2>/dev/null || kill -KILL "$pid" 2>/dev/null || true rm -f "$pid_file" } show_service_status() { local service_name="$1" local pid_file="$2" local port="$3" local log_file="$4" if pid="$(active_pid_from_file "$pid_file" 2>/dev/null)"; then printf '%s: running (PID %s, port %s, log %s)\n' "$service_name" "$pid" "$port" "$log_file" return 0 fi if [[ -f "$pid_file" ]]; then rm -f "$pid_file" printf '%s: stale PID file removed, not running\n' "$service_name" return 0 fi printf '%s: not running\n' "$service_name" } start_all() { ensure_base_requirements ensure_runtime_dir cleanup_stale_pid_file "backend" "$BACKEND_PID_FILE" cleanup_stale_pid_file "frontend" "$FRONTEND_PID_FILE" if active_pid_from_file "$BACKEND_PID_FILE" >/dev/null 2>&1 || active_pid_from_file "$FRONTEND_PID_FILE" >/dev/null 2>&1; then log "检测到已有开发服务在运行,请先执行 stop 或 restart" show_service_status "backend" "$BACKEND_PID_FILE" "$BACKEND_PORT" "$BACKEND_LOG_FILE" show_service_status "frontend" "$FRONTEND_PID_FILE" "$FRONTEND_PORT" "$FRONTEND_LOG_FILE" return 0 fi log "启动 backend..." start_backend if ! wait_for_service "backend" "$BACKEND_PID_FILE" "http://$BACKEND_HOST:$BACKEND_PORT/api/health" 30; then stop_service "backend" "$BACKEND_PID_FILE" >/dev/null 2>&1 || true fail "backend 启动失败,请查看 $BACKEND_LOG_FILE" fi log "启动 frontend..." start_frontend if ! wait_for_service "frontend" "$FRONTEND_PID_FILE" "http://$FRONTEND_HOST:$FRONTEND_PORT" 30; then stop_service "frontend" "$FRONTEND_PID_FILE" >/dev/null 2>&1 || true stop_service "backend" "$BACKEND_PID_FILE" >/dev/null 2>&1 || true fail "frontend 启动失败,请查看 $FRONTEND_LOG_FILE" fi log "开发环境已启动" show_service_status "backend" "$BACKEND_PID_FILE" "$BACKEND_PORT" "$BACKEND_LOG_FILE" show_service_status "frontend" "$FRONTEND_PID_FILE" "$FRONTEND_PORT" "$FRONTEND_LOG_FILE" } stop_all() { stop_service "frontend" "$FRONTEND_PID_FILE" stop_service "backend" "$BACKEND_PID_FILE" } status_all() { show_service_status "backend" "$BACKEND_PID_FILE" "$BACKEND_PORT" "$BACKEND_LOG_FILE" show_service_status "frontend" "$FRONTEND_PID_FILE" "$FRONTEND_PORT" "$FRONTEND_LOG_FILE" } main() { local command="${1:-}" case "$command" in start) start_all ;; stop) stop_all ;; restart) stop_all start_all ;; status) status_all ;; *) usage exit 1 ;; esac } main "$@"