Files
yml2213 ff90d742ad 新增 mac 本地一键开发启动脚本
提供 deploy/scripts/dev-local.sh,统一拉起 Postgres/MinIO、API 与前端;Makefile 的 dev 相关目标改为调用该脚本。
2026-07-17 21:39:26 +08:00

559 lines
14 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 "$@"