新增 mac 本地一键开发启动脚本

提供 deploy/scripts/dev-local.sh,统一拉起 Postgres/MinIO、API 与前端;Makefile 的 dev 相关目标改为调用该脚本。
This commit is contained in:
yml2213
2026-07-17 21:39:26 +08:00
parent ec84ddb35e
commit ff90d742ad
3 changed files with 575 additions and 10 deletions
+558
View File
@@ -0,0 +1,558 @@
#!/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 Machttps://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 V2docker 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 "$@"