#!/usr/bin/env bash # ============================================================ # qlib-platform 前后端一键管理脚本 # # 用法: # scripts/dev.sh start 启动后端(8000) + 前端(3000) # scripts/dev.sh stop 停止两者 # scripts/dev.sh restart 重启两者 # scripts/dev.sh status 查看状态 # scripts/dev.sh logs [api|web] 实时查看日志 # # 环境变量(可选): # API_PORT 后端端口(默认 8000) # WEB_PORT 前端端口(默认 3000) # BACKEND_API_URL 前端同源代理指向的后端地址(默认 http://127.0.0.1:8000, # 局域网访问前端时改为 http://<本机IP>:8000) # # 进程管理说明:pidfile 记录启动进程;停止时同时按「监听端口」定位真实服务进程 # (pnpm/uv 包装进程可能提前退出),并对整进程组停止,避免子进程残留。 # ============================================================ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" RUN_DIR="$ROOT/.run" LOG_DIR="$ROOT/logs" mkdir -p "$RUN_DIR" "$LOG_DIR" API_PORT="${API_PORT:-8000}" WEB_PORT="${WEB_PORT:-3000}" BACKEND_API_URL="${BACKEND_API_URL:-http://127.0.0.1:8000}" API_PID_FILE="$RUN_DIR/backend.pid" WEB_PID_FILE="$RUN_DIR/frontend.pid" API_LOG="$LOG_DIR/backend.log" WEB_LOG="$LOG_DIR/frontend.log" log() { echo "[dev.sh] $*"; } die() { echo "[dev.sh] 错误: $*" >&2; exit 1; } # ---------- 进程探测 ---------- # 监听指定端口的 pid(无则空) # 取值顺序:lsof → ss。 # 不能把 ss 放在首位:macOS 上 /usr/local/bin/ss 常由 iproute2mac 提供(netstat 包装), # 其输出以 TAB 分隔且 -p 不产出 pid=,会让匹配恒为空 → port_in_use 永远为假 → # start 重复拉起进程并报 [Errno 48] Address already in use,status 也误报"未运行"。 # 真实 iproute2(Linux)的 ss 仍作为兜底,此时可从 users:(("x",pid=NNN)) 取到 pid。 listener_pids() { # $1 port local pids="" if command -v lsof >/dev/null 2>&1; then pids="$(lsof -t -iTCP:"$1" -sTCP:LISTEN 2>/dev/null || true)" fi if [[ -z "$pids" ]] && command -v ss >/dev/null 2>&1; then # ([^0-9]|$) 界定端口边界:兼容 ss 的空格与 TAB 两种分隔,且避免匹配到更长的端口号 pids="$(ss -tlnp 2>/dev/null | awk -v p=":$1" ' $0 ~ (p "([^0-9]|$)") && /LISTEN/ { if (match($0, /pid=[0-9]+/)) print substr($0, RSTART+4, RLENGTH-4) }' || true)" fi if [[ -n "$pids" ]]; then printf '%s\n' "$pids" fi return 0 } port_in_use() { [[ -n "$(listener_pids "$1")" ]]; } pidfile_alive() { [[ -f "$1" ]] && kill -0 "$(cat "$1")" 2>/dev/null; } # 后台启动命令:Linux 用 setsid 脱离会话;macOS 无 setsid(BSD)则退回 nohup。 # 两种方式都写 pidfile + 按端口定位,stop 逻辑不依赖进程组。 spawn_bg() { # $1 日志文件;其余为命令 local logfile="$1"; shift if command -v setsid >/dev/null 2>&1; then setsid nohup "$@" >>"$logfile" 2>&1 & else nohup "$@" >>"$logfile" 2>&1 & fi } # 杀掉指定 pid 及其整进程组(uvicorn --reload / next dev 的子进程一起清理) # 注意:macOS 无 setsid 时子进程与当前脚本同进程组,此时禁止按 pgid 杀, # 否则会连带杀掉调用方自身;改为逐 pid + 子进程清理。 kill_tree() { # $1 pid local pid pgid self_pgid pid="$1" pgid="$(ps -o pgid= -p "$pid" 2>/dev/null | tr -d ' ' || true)" self_pgid="$(ps -o pgid= -p $$ 2>/dev/null | tr -d ' ' || true)" if [[ -n "${pgid:-}" && "$pgid" != "${self_pgid:-}" ]]; then kill -- "-$pgid" 2>/dev/null || true fi # 先清理子进程再杀父进程:父进程先退出会让其子进程被 reparent,pkill -P 不再匹配 pkill -P "$pid" 2>/dev/null || true kill "$pid" 2>/dev/null || true } # ---------- 启动 ---------- start_api() { if port_in_use "$API_PORT"; then log "后端已在 :$API_PORT 运行(pid $(listener_pids "$API_PORT" | head -1))" listener_pids "$API_PORT" | head -1 > "$API_PID_FILE" return 0 fi log "启动后端 :${API_PORT}(日志 ${API_LOG})" ( cd "$ROOT/backend" spawn_bg "$API_LOG" uv run uvicorn app.main:app --host 0.0.0.0 --port "$API_PORT" --reload ) || true for _ in $(seq 1 40); do if port_in_use "$API_PORT"; then listener_pids "$API_PORT" | head -1 > "$API_PID_FILE" return 0 fi sleep 0.5 done die "后端启动超时,请查看 $API_LOG" } start_web() { if port_in_use "$WEB_PORT"; then log "前端已在 :$WEB_PORT 运行(pid $(listener_pids "$WEB_PORT" | head -1))" listener_pids "$WEB_PORT" | head -1 > "$WEB_PID_FILE" return 0 fi log "启动前端 :${WEB_PORT}(日志 ${WEB_LOG})" ( cd "$ROOT/frontend/web" export BACKEND_API_URL # pnpm exec next 直接运行,避免 package.json dev 脚本自带 -p 3000 与端口参数重复 # -H 0.0.0.0:显式监听所有网卡(局域网可用 http://<本机IP>:3000 访问) spawn_bg "$WEB_LOG" pnpm exec next dev -H 0.0.0.0 -p "$WEB_PORT" ) || true for _ in $(seq 1 80); do if port_in_use "$WEB_PORT"; then listener_pids "$WEB_PORT" | head -1 > "$WEB_PID_FILE" return 0 fi sleep 0.5 done die "前端启动超时,请查看 $WEB_LOG" } # ---------- 停止 ---------- stop_service() { # $1 pidfile $2 port $3 名称 local killed=0 if pidfile_alive "$1"; then local pid pid="$(cat "$1")" log "停止 $3 (pid $pid)" kill_tree "$pid" killed=1 fi local lp for lp in $(listener_pids "$2"); do log "停止 $3 监听进程 (pid $lp)" kill_tree "$lp" killed=1 done if [[ "$killed" == 0 ]]; then log "$3 未在运行" fi for _ in $(seq 1 20); do port_in_use "$2" || break sleep 0.3 done if pidfile_alive "$1"; then kill -9 "$(cat "$1")" 2>/dev/null || true; fi for lp in $(listener_pids "$2"); do kill -9 "$lp" 2>/dev/null || true; done rm -f "$1" log "$3 已停止" } status() { local api_pid web_pid api_pid="$(listener_pids "$API_PORT" | head -1 | tr -d '\n ' || true)" web_pid="$(listener_pids "$WEB_PORT" | head -1 | tr -d '\n ' || true)" # 注意:不用 "${pid:+运行中 pid $pid}${pid:-未运行}" —— bash 3.2(macOS 自带) # 对 :+ 词内再引用同一变量会重复展开,导致 PID 显示为拼接值。 if [[ -n "$api_pid" ]]; then log "后端: 运行中 pid $api_pid"; else log "后端: 未运行"; fi if [[ -n "$web_pid" ]]; then log "前端: 运行中 pid $web_pid"; else log "前端: 未运行"; fi log "访问: 前端 http://127.0.0.1:$WEB_PORT | API http://127.0.0.1:$API_PORT/api/health" } # ---------- actions ---------- case "${1:-start}" in start) start_api start_web status ;; stop) stop_service "$API_PID_FILE" "$API_PORT" "后端" stop_service "$WEB_PID_FILE" "$WEB_PORT" "前端" ;; restart) "$0" stop sleep 1 "$0" start ;; status) status ;; logs) case "${2:-}" in api) tail -f "$API_LOG" ;; web) tail -f "$WEB_LOG" ;; *) tail -f "$API_LOG" "$WEB_LOG" ;; esac ;; *) echo "用法: $0 {start|stop|restart|status|logs [api|web]}" >&2 exit 1 ;; esac