#!/usr/bin/env bash # 恭学教育 — PM2 部署脚本 # 用法: # ./deploy.sh # 本地直跑(默认,适合 runner/代码就在生产服务器上,不需要 SSH) # ./deploy.sh local # 同上 # ./deploy.sh # 远程部署(构建后 rsync 到远端,需要 SSH 免密/key) # 前提: 服务器 MySQL 已在运行,PM2 已安装 set -euo pipefail MODE="${1:-local}" # 调用锁:mkdir 原子创建,已存在即代表已有部署在跑,直接退出 LOCK_DIR="/tmp/gongxue-deploy.lock" if ! mkdir "$LOCK_DIR" 2>/dev/null; then echo "检测到 $LOCK_DIR,已有部署在运行。若确认没有其他部署,请手动删除该锁目录。" >&2 exit 1 fi release_deploy_lock() { rmdir "$LOCK_DIR" 2>/dev/null || true } trap release_deploy_lock EXIT echo "=== 1/4 本地构建后端 ===" npm run build -w @gongxue/server echo "=== 2/4 本地构建前端 ===" npm run build -w @gongxue/admin # ── 健康检查(本地/远程共用逻辑,通过 pm2 jlist 按 name 判断 online 且无 unstable_restarts)── # online ≠ healthy:若后续后端提供健康端点,应优先 curl 探测。 run_health_check() { set -euo pipefail local healthy=0 local i for i in $(seq 1 15); do if pm2 jlist 2>/dev/null | node -e ' let data = ""; process.stdin.on("data", (c) => (data += c)); process.stdin.on("end", () => { const apps = JSON.parse(data || "[]"); const app = apps.find((a) => a && a.name === "gongxue-backend"); const env = (app || {}).pm2_env || {}; if (env.status === "online" && Number(env.unstable_restarts || 0) === 0) { process.exit(0); } process.exit(1); }); '; then healthy=1 break fi sleep 2 done if [ "${healthy}" -ne 1 ]; then echo "错误:后端进程未就绪(gongxue-backend 非 online 或存在不稳定重启)" >&2 pm2 status >&2 return 1 fi echo "健康检查通过:gongxue-backend 在线" } if [ "${MODE}" = "local" ]; then echo "=== 3/4 安装依赖 → 迁移 → PM2 重载(本机) ===" npm ci echo '执行数据库迁移...' npm run migration:run -w @gongxue/server echo 'PM2 重载...' pm2 startOrReload ecosystem.config.cjs --update-env pm2 save pm2 status echo "=== 4/4 健康检查 ===" run_health_check echo "" echo "部署完成!后端: http://127.0.0.1:$(grep '^PORT=' .env 2>/dev/null | cut -d= -f2- | tr -d '"' || echo 3000)/api" exit 0 fi # ── 远程模式(保留,用于部署到其他主机)── SSH_HOST="${MODE}" # 校验 SSH_HOST:非空、不以 - 开头、仅允许安全字符(防注入) if [[ -z "${SSH_HOST}" ]]; then echo "错误:SSH_HOST 不能为空" >&2 exit 1 fi if [[ "${SSH_HOST}" == -* ]]; then echo "错误:SSH_HOST 不能以 - 开头(会被 ssh/rsync 当作命令行选项)" >&2 exit 1 fi if [[ ! "${SSH_HOST}" =~ ^[A-Za-z0-9._:@%+=,-]+$ ]]; then echo "错误:SSH_HOST 含空白或 shell 元字符(仅允许字母、数字及 . _ : @ % + = , -)" >&2 exit 1 fi REMOTE_DIR="/opt/gongxue" echo "=== 3/5 同步到 ${SSH_HOST} ===" # --delete 保留,但必须排除服务器端独有文件(.env、数据、上传目录),防止被清掉 rsync -avz --delete \ -e "ssh -o BatchMode=yes -o ConnectTimeout=10" \ --exclude='node_modules' \ --exclude='.git' \ --exclude='*.db' \ --exclude='.DS_Store' \ --exclude='logs/' \ --exclude='.turbo/' \ --exclude='.env*' \ --exclude='data.sql' \ --exclude='uploads/' \ ./ "${SSH_HOST}:${REMOTE_DIR}/" echo "=== 4/5 安装依赖 → 迁移 → PM2 重载 ===" ssh -o BatchMode=yes -o ConnectTimeout=10 "${SSH_HOST}" " set -e cd ${REMOTE_DIR} mkdir -p logs echo '安装依赖...' # 注意:migration:run 依赖 ts-node/tsconfig-paths(devDependencies),不能 --omit=dev npm ci echo '执行数据库迁移...' npm run migration:run -w @gongxue/server echo 'PM2 重载...' pm2 startOrReload ecosystem.config.cjs --update-env pm2 save pm2 status " echo "=== 5/5 健康检查 ===" ssh -o BatchMode=yes -o ConnectTimeout=10 "${SSH_HOST}" bash -s <<'REMOTE_HEALTH' set -euo pipefail HEALTHY=0 for _i in $(seq 1 15); do if pm2 jlist 2>/dev/null | node -e ' let data = ""; process.stdin.on("data", (c) => (data += c)); process.stdin.on("end", () => { const apps = JSON.parse(data || "[]"); const app = apps.find((a) => a && a.name === "gongxue-backend"); const env = (app || {}).pm2_env || {}; if (env.status === "online" && Number(env.unstable_restarts || 0) === 0) { process.exit(0); } process.exit(1); }); '; then HEALTHY=1 break fi sleep 2 done if [ "${HEALTHY}" -ne 1 ]; then echo "错误:后端进程未就绪(gongxue-backend 非 online 或存在不稳定重启)" >&2 pm2 status >&2 exit 1 fi echo "健康检查通过:gongxue-backend 在线" REMOTE_HEALTH echo "" echo "部署完成!" echo "访问: http://$(ssh -o BatchMode=yes -o ConnectTimeout=10 "${SSH_HOST}" 'hostname -I 2>/dev/null | awk "{print \$1}" || curl -s ifconfig.me')"