#!/usr/bin/env bash # 客服云 — macOS 本地一键开发启动 # 启动:Docker(Postgres+MinIO) + Go API(:8080) + Vite 前端(:5173) # # 用法: # ./deploy/scripts/dev-local.sh # 一键启动(前台,Ctrl+C 停前后端) # ./deploy/scripts/dev-local.sh --seed # 启动后导入演示账号 # ./deploy/scripts/dev-local.sh --install # 仅安装依赖 # ./deploy/scripts/dev-local.sh --down # 停止本脚本拉起的服务 + 开发容器 # ./deploy/scripts/dev-local.sh --status # 查看状态 # ./deploy/scripts/dev-local.sh --seed-only # 仅跑种子(需 DB 已起) # ./deploy/scripts/dev-local.sh --logs # 跟踪 API/前端日志 # ./deploy/scripts/dev-local.sh --open # 启动后用浏览器打开登录页 set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" cd "$ROOT_DIR" COMPOSE_FILE="docker-compose.dev.yml" ENV_EXAMPLE="server/.env.example" ENV_FILE="server/.env" DEV_DIR="${ROOT_DIR}/.dev" API_PID_FILE="${DEV_DIR}/api.pid" WEB_PID_FILE="${DEV_DIR}/web.pid" API_LOG="${DEV_DIR}/api.log" WEB_LOG="${DEV_DIR}/web.log" API_PORT="${SERVER_PORT:-8080}" WEB_PORT="${WEB_PORT:-5173}" DB_PORT="${DB_PORT:-5433}" RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' CYAN='\033[0;36m' NC='\033[0m' log() { echo -e "${GREEN}[dev]${NC} $*"; } info() { echo -e "${CYAN}[info]${NC} $*"; } warn() { echo -e "${YELLOW}[warn]${NC} $*"; } err() { echo -e "${RED}[error]${NC} $*" >&2; } need_cmd() { if ! command -v "$1" >/dev/null 2>&1; then err "缺少命令: $1" case "$1" in go) err "请安装 Go 1.22+:https://go.dev/dl/ 或 brew install go" ;; node|npm) err "请安装 Node.js 20+:https://nodejs.org/ 或 brew install node" ;; docker) err "请安装 Docker Desktop for Mac:https://www.docker.com/products/docker-desktop/" ;; esac exit 1 fi } port_in_use() { local port="$1" if command -v lsof >/dev/null 2>&1; then lsof -iTCP:"${port}" -sTCP:LISTEN -n -P >/dev/null 2>&1 return $? fi (echo >/dev/tcp/127.0.0.1/"${port}") >/dev/null 2>&1 } pid_alive() { local pid="${1:-}" [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null } read_pid() { local f="$1" if [[ -f "$f" ]]; then tr -d ' \n\r' < "$f" fi } ensure_dev_dir() { mkdir -p "$DEV_DIR" } ensure_env() { if [[ ! -f "$ENV_FILE" ]]; then if [[ -f "$ENV_EXAMPLE" ]]; then log "未找到 $ENV_FILE,从 $ENV_EXAMPLE 复制…" cp "$ENV_EXAMPLE" "$ENV_FILE" else warn "未找到 $ENV_EXAMPLE,将使用代码内默认配置" fi fi } load_env() { if [[ -f "$ENV_FILE" ]]; then set -a # shellcheck disable=SC1090 source <(sed 's/\r$//' "$ENV_FILE" | grep -v '^\s*#' | grep -v '^\s*$' || true) set +a fi API_PORT="${SERVER_PORT:-8080}" DB_PORT="${DB_PORT:-5433}" } check_prereqs() { need_cmd go need_cmd node need_cmd npm need_cmd docker if ! docker info >/dev/null 2>&1; then err "Docker 未运行。请先启动 Docker Desktop。" exit 1 fi if ! docker compose version >/dev/null 2>&1; then err "需要 Docker Compose V2(docker compose)" exit 1 fi local go_major go_minor go_major="$(go env GOVERSION 2>/dev/null | sed -E 's/^go([0-9]+).*/\1/' || echo 0)" go_minor="$(go env GOVERSION 2>/dev/null | sed -E 's/^go[0-9]+\.([0-9]+).*/\1/' || echo 0)" if [[ "${go_major:-0}" -lt 1 ]] || { [[ "${go_major:-0}" -eq 1 && "${go_minor:-0}" -lt 22 ]]; }; then warn "建议 Go 1.22+,当前: $(go version 2>/dev/null || echo unknown)" fi local node_major node_major="$(node -v 2>/dev/null | sed -E 's/^v([0-9]+).*/\1/' || echo 0)" if [[ "${node_major:-0}" -lt 20 ]]; then warn "建议 Node.js 20+,当前: $(node -v 2>/dev/null || echo unknown)" fi } cmd_install() { check_prereqs ensure_env log "安装后端依赖(go mod download)…" (cd "$ROOT_DIR/server" && go mod download) log "安装前端依赖(npm install)…" (cd "$ROOT_DIR/web" && npm install) log "依赖安装完成" } db_up() { log "启动 PostgreSQL + MinIO…" docker compose -f "$COMPOSE_FILE" up -d } db_down() { log "停止开发容器(Postgres + MinIO,保留数据卷)…" docker compose -f "$COMPOSE_FILE" down } wait_postgres() { local i log "等待 PostgreSQL 就绪(localhost:${DB_PORT})…" for i in $(seq 1 60); do if docker compose -f "$COMPOSE_FILE" exec -T postgres \ pg_isready -U "${DB_USER:-postgres}" -d "${DB_NAME:-kefu_cloud}" >/dev/null 2>&1; then log "PostgreSQL 已就绪" return 0 fi sleep 1 done err "PostgreSQL 启动超时。查看: docker compose -f $COMPOSE_FILE logs postgres" return 1 } wait_http() { local url="$1" name="$2" tries="${3:-40}" local i for i in $(seq 1 "$tries"); do if curl -fsS "$url" >/dev/null 2>&1; then return 0 fi sleep 0.5 done warn "${name} 健康检查超时: $url" return 1 } # 递归结束进程树(go run / npm 会拉起子进程) kill_tree() { local pid="${1:-}" sig="${2:-TERM}" [[ -z "$pid" ]] && return 0 local child for child in $(pgrep -P "$pid" 2>/dev/null || true); do kill_tree "$child" "$sig" done kill "-${sig}" "$pid" 2>/dev/null || true } stop_pid_file() { local f="$1" name="$2" local pid pid="$(read_pid "$f")" if pid_alive "$pid"; then log "停止 ${name} (pid=${pid})…" kill_tree "$pid" TERM local i for i in $(seq 1 20); do pid_alive "$pid" || break sleep 0.2 done if pid_alive "$pid"; then warn "${name} 未退出,强制 kill" kill_tree "$pid" KILL fi fi # 兜底:按端口清理(防止 go run 子进程残留) if [[ "$name" == "API" ]] && port_in_use "$API_PORT"; then if command -v lsof >/dev/null 2>&1; then local p for p in $(lsof -tiTCP:"${API_PORT}" -sTCP:LISTEN 2>/dev/null || true); do warn "释放 API 端口 ${API_PORT} 上的 pid=${p}" kill_tree "$p" TERM sleep 0.2 pid_alive "$p" && kill_tree "$p" KILL done fi fi if [[ "$name" == "Web" ]] && port_in_use "$WEB_PORT"; then if command -v lsof >/dev/null 2>&1; then local p for p in $(lsof -tiTCP:"${WEB_PORT}" -sTCP:LISTEN 2>/dev/null || true); do # 只杀 node/vite,避免误伤其它服务 local cmd cmd="$(ps -p "$p" -o comm= 2>/dev/null || true)" if [[ "$cmd" == *node* ]] || [[ "$cmd" == *vite* ]]; then warn "释放 Web 端口 ${WEB_PORT} 上的 pid=${p}" kill_tree "$p" TERM sleep 0.2 pid_alive "$p" && kill_tree "$p" KILL fi done fi fi rm -f "$f" } stop_app() { stop_pid_file "$API_PID_FILE" "API" stop_pid_file "$WEB_PID_FILE" "Web" } is_our_api_running() { local pid pid="$(read_pid "$API_PID_FILE")" pid_alive "$pid" } is_our_web_running() { local pid pid="$(read_pid "$WEB_PID_FILE")" pid_alive "$pid" } start_api() { ensure_dev_dir if is_our_api_running; then log "API 已在运行 (pid=$(read_pid "$API_PID_FILE"))" return 0 fi if port_in_use "$API_PORT"; then err "端口 ${API_PORT} 已被占用,且不是本脚本启动的 API" if command -v lsof >/dev/null 2>&1; then lsof -iTCP:"${API_PORT}" -sTCP:LISTEN -n -P || true fi err "请先释放端口,或执行: $0 --down" exit 1 fi log "启动后端 API(: ${API_PORT})…" ( cd "$ROOT_DIR/server" if [[ -f .env ]]; then set -a # shellcheck disable=SC1091 source .env set +a fi exec go run ./cmd/main.go ) >>"$API_LOG" 2>&1 & echo $! >"$API_PID_FILE" disown $! 2>/dev/null || true if wait_http "http://127.0.0.1:${API_PORT}/health" "API" 60; then log "API 就绪: http://127.0.0.1:${API_PORT}/health" else err "API 启动失败,最近日志:" tail -n 40 "$API_LOG" 2>/dev/null || true exit 1 fi } start_web() { ensure_dev_dir if is_our_web_running; then log "前端已在运行 (pid=$(read_pid "$WEB_PID_FILE"))" return 0 fi if port_in_use "$WEB_PORT"; then # Vite 可能换端口;若占用则告警继续尝试 warn "端口 ${WEB_PORT} 已被占用,Vite 可能自动换端口,见 ${WEB_LOG}" fi if [[ ! -d "$ROOT_DIR/web/node_modules" ]]; then log "未检测到 node_modules,自动 npm install…" (cd "$ROOT_DIR/web" && npm install) fi log "启动前端 Vite(: ${WEB_PORT})…" ( cd "$ROOT_DIR/web" exec npm run dev -- --host 127.0.0.1 --port "$WEB_PORT" ) >>"$WEB_LOG" 2>&1 & echo $! >"$WEB_PID_FILE" disown $! 2>/dev/null || true # Vite 就绪:日志出现 Local 或端口可连 local i for i in $(seq 1 60); do if grep -qE "Local:|ready in" "$WEB_LOG" 2>/dev/null; then break fi if port_in_use "$WEB_PORT"; then break fi sleep 0.5 done if ! is_our_web_running; then err "前端启动失败,最近日志:" tail -n 40 "$WEB_LOG" 2>/dev/null || true exit 1 fi log "前端就绪: http://127.0.0.1:${WEB_PORT}" } cmd_seed() { load_env log "导入演示种子数据…" ( cd "$ROOT_DIR/server" if [[ -f .env ]]; then set -a # shellcheck disable=SC1091 source .env set +a fi go run ./cmd/seed ) log "种子完成。默认账号见下方摘要(密码均为 kefu_admin123)" } print_summary() { echo log "========== 本地开发已启动 ==========" echo " 前端登录: http://127.0.0.1:${WEB_PORT}/login" echo " 客服工作台: http://127.0.0.1:${WEB_PORT}/agent/dashboard" echo " 平台后台: http://127.0.0.1:${WEB_PORT}/admin" echo " Widget 预览: http://127.0.0.1:${WEB_PORT}/widget/preview" echo " API 健康检查: http://127.0.0.1:${API_PORT}/health" echo echo " 基础设施:" echo " PostgreSQL localhost:${DB_PORT} (postgres/postgres / kefu_cloud)" echo " MinIO API http://127.0.0.1:9100" echo " MinIO 控制台 http://127.0.0.1:9101 (minioadmin/minioadmin)" echo echo " 演示账号(需 --seed): platform_admin / kefu_admin / agent1 …" echo " 密码: kefu_admin123" echo echo " 日志: ${API_LOG}" echo " ${WEB_LOG}" echo " 状态: $0 --status" echo " 停止: $0 --down 或 Ctrl+C" echo "====================================" } cmd_status() { echo "=== 开发容器 ===" docker compose -f "$COMPOSE_FILE" ps 2>/dev/null || warn "compose 不可用" echo echo "=== 应用进程 ===" local apid wpid apid="$(read_pid "$API_PID_FILE")" wpid="$(read_pid "$WEB_PID_FILE")" if pid_alive "$apid"; then echo " API : 运行中 pid=${apid} port=${API_PORT}" else echo " API : 未运行" fi if pid_alive "$wpid"; then echo " Web : 运行中 pid=${wpid} port=${WEB_PORT}" else echo " Web : 未运行" fi echo if curl -fsS "http://127.0.0.1:${API_PORT}/health" >/dev/null 2>&1; then echo " health: ok ($(curl -fsS "http://127.0.0.1:${API_PORT}/health" 2>/dev/null || true))" else echo " health: 不可达" fi } cmd_logs() { ensure_dev_dir touch "$API_LOG" "$WEB_LOG" log "跟踪日志(Ctrl+C 退出跟踪,不停止服务)…" tail -n 20 -F "$API_LOG" "$WEB_LOG" } cmd_down() { stop_app db_down log "已停止。数据卷仍保留(再次启动数据还在)。" } cleanup_on_exit() { local code=$? # 仅在前台一键启动时,Ctrl+C / 退出会停前后端,保留 DB 容器 if [[ "${KEEP_RUNNING:-0}" -eq 1 ]]; then return 0 fi echo log "收到退出信号,停止前后端(开发容器保留)…" stop_app info "停止数据库: $0 --down" exit "$code" } cmd_up() { local do_seed=0 do_open=0 while [[ $# -gt 0 ]]; do case "$1" in --seed) do_seed=1 ;; --open) do_open=1 ;; *) ;; esac shift done check_prereqs ensure_dev_dir ensure_env load_env # 后台依赖 if [[ ! -d "$ROOT_DIR/server" ]] || [[ ! -f "$ROOT_DIR/server/go.mod" ]]; then err "找不到 server 模块,请在仓库根目录执行" exit 1 fi # go.sum 存在则依赖大致齐全;首次仍 download 一次较快 if [[ ! -d "$(go env GOMODCACHE 2>/dev/null)/cache" ]] 2>/dev/null; then : fi (cd "$ROOT_DIR/server" && go mod download) >/dev/null 2>&1 || { log "下载 Go 模块…" (cd "$ROOT_DIR/server" && go mod download) } db_up wait_postgres # 旧进程清理:若 pid 文件残留但进程死了 if [[ -f "$API_PID_FILE" ]] && ! is_our_api_running; then rm -f "$API_PID_FILE" fi if [[ -f "$WEB_PID_FILE" ]] && ! is_our_web_running; then rm -f "$WEB_PID_FILE" fi start_api start_web if [[ "$do_seed" -eq 1 ]]; then cmd_seed || warn "种子导入失败(可能数据已存在)" fi print_summary if [[ "$do_open" -eq 1 ]]; then if command -v open >/dev/null 2>&1; then open "http://127.0.0.1:${WEB_PORT}/login" fi fi # 前台挂住:Ctrl+C 清理前后端 trap cleanup_on_exit INT TERM log "前台运行中。按 Ctrl+C 停止 API/前端(Docker 数据库继续跑)。" log "看日志: tail -f ${API_LOG} ${WEB_LOG} 或 $0 --logs" # 若子进程退出则结束 while true; do if ! is_our_api_running; then err "API 进程已退出,见 ${API_LOG}" tail -n 30 "$API_LOG" 2>/dev/null || true stop_app exit 1 fi if ! is_our_web_running; then err "前端进程已退出,见 ${WEB_LOG}" tail -n 30 "$WEB_LOG" 2>/dev/null || true stop_app exit 1 fi sleep 2 done } main() { local action="${1:-up}" case "$action" in up|--up|"") shift || true cmd_up "$@" ;; --seed) # 兼容:./dev-local.sh --seed cmd_up --seed ;; --seed-only) check_prereqs ensure_env load_env wait_postgres || { err "数据库未就绪,请先: $0 或 make db-up" exit 1 } cmd_seed ;; --install|install) cmd_install ;; --down|down|stop) cmd_down ;; --status|status) load_env cmd_status ;; --logs|logs) cmd_logs ;; --open) cmd_up --open ;; --restart|restart) stop_app cmd_up ;; -h|--help|help) sed -n '2,14p' "$0" ;; *) err "未知参数: $action (见 --help)" exit 1 ;; esac } main "$@"