提供 deploy/scripts/dev-local.sh,统一拉起 Postgres/MinIO、API 与前端;Makefile 的 dev 相关目标改为调用该脚本。
559 lines
14 KiB
Bash
Executable File
559 lines
14 KiB
Bash
Executable File
#!/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 "$@"
|