diff --git a/.gitignore b/.gitignore index 9d814a3..c178c5d 100644 --- a/.gitignore +++ b/.gitignore @@ -10,5 +10,6 @@ deploy/.env.prod .vscode/ .idea/ server/bin/ +.dev/ *.exe *.test diff --git a/Makefile b/Makefile index a2b2fac..47b7631 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,18 @@ -.PHONY: dev db-up db-down install build clean +.PHONY: dev dev-seed dev-down dev-status install db-up db-down build clean -# 一键启动开发环境(数据库 + 后端 + 前端) -dev: db-up - @echo "=== 启动后端服务 ===" - cd server && (test -f .env && set -a && . ./.env && set +a; go run cmd/main.go &) - @sleep 2 - @echo "=== 启动前端开发服务器 ===" - cd web && npm run dev +# macOS 本地一键启动(Postgres+MinIO + API + Vite) +# 详见: ./deploy/scripts/dev-local.sh --help +dev: + @./deploy/scripts/dev-local.sh + +dev-seed: + @./deploy/scripts/dev-local.sh --seed + +dev-down: + @./deploy/scripts/dev-local.sh --down + +dev-status: + @./deploy/scripts/dev-local.sh --status # 仅启动数据库 + MinIO db-up: @@ -22,8 +28,7 @@ db-down: # 安装所有依赖 install: - cd web && npm install - cd server && go mod download + @./deploy/scripts/dev-local.sh --install # 编译后端 build: @@ -32,3 +37,4 @@ build: # 清理 clean: cd server && rm -rf bin/ + rm -rf .dev/ diff --git a/deploy/scripts/dev-local.sh b/deploy/scripts/dev-local.sh new file mode 100755 index 0000000..f21949a --- /dev/null +++ b/deploy/scripts/dev-local.sh @@ -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 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 "$@"