#!/usr/bin/env bash # ============================================================ # Novalon 统一发布脚本 # # 用法: # ./scripts/deploy.sh [命令] [选项] # # 命令: # build 构建静态产物 (npm run build / npm run build:clean) # deploy 构建并发布到生产服务器 (默认命令) # rollback 回滚到最近一次远程备份 # status 查看生产环境发布状态 # help 显示帮助 # # 选项: # --skip-build 跳过本地构建,直接使用现有 dist/ # --auto-rollback 部署/验证失败时自动回滚到最近备份 # --clean 构建前清理 .next 与 dist # --server-ip IP 服务器地址 (默认 139.155.109.62) # --server-user USER SSH 用户 (默认 root) # --deploy-root PATH 服务器部署根目录 (默认 /home/novalon/docker-app) # --project-name NAME 服务器项目目录名 (默认 novalon-website) # --nginx-container C Nginx 容器名 (默认 novalon-nginx-secure) # --domain URL 验证域名 (默认 https://novalon.cn) # --backup-retention N 远程备份保留份数 (默认 3) # --dist-dir DIR 本地构建产物目录 (默认 dist) # --dry-run 只打印远程命令,不实际执行 # -h, --help 显示帮助 # # 环境变量覆盖: # SERVER_IP SERVER_USER DEPLOY_ROOT PROJECT_NAME NGINX_CONTAINER # NEXTJS_CONTAINER COMPOSE_FILE COMPOSE_CMD # DOMAIN BACKUP_RETENTION_COUNT DIST_DIR # ============================================================ set -euo pipefail # ---------- 默认配置 ---------- SERVER_IP="${SERVER_IP:-139.155.109.62}" SERVER_USER="${SERVER_USER:-root}" DEPLOY_ROOT="${DEPLOY_ROOT:-/home/novalon/docker-app}" PROJECT_NAME="${PROJECT_NAME:-novalon-website}" NGINX_CONTAINER="${NGINX_CONTAINER:-novalon-nginx-secure}" NEXTJS_CONTAINER="${NEXTJS_CONTAINER:-novalon-website}" COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.server.yml}" COMPOSE_CMD="${COMPOSE_CMD:-docker-compose}" DOMAIN="${DOMAIN:-https://novalon.cn}" BACKUP_RETENTION="${BACKUP_RETENTION_COUNT:-${BACKUP_RETENTION:-3}}" DIST_DIR="${DIST_DIR:-dist}" REMOTE_PROJECT_DIR="$DEPLOY_ROOT/$PROJECT_NAME" REMOTE_DIST_DIR="$REMOTE_PROJECT_DIR/dist" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" LOG_DIR="$PROJECT_ROOT/logs" LOG_FILE="$LOG_DIR/deploy_$(date +%Y%m%d_%H%M%S).log" # ---------- 运行时状态 ---------- COMMAND="${1:-deploy}" SKIP_BUILD=0 AUTO_ROLLBACK=0 CLEAN_BUILD=0 DRY_RUN=0 DEPLOY_STARTED=0 # ---------- 工具函数 ---------- usage() { awk ' NR == 1 { next } /^# =+$/ { c++; if (c == 2) exit } { line = $0; sub(/^# ?/, "", line); print line } ' "$0" } log() { echo -e "$@" } require_local_cmd() { local cmd="$1" if ! command -v "$cmd" >/dev/null 2>&1; then log "❌ 缺少本地命令: $cmd" exit 1 fi } run_remote() { # 用法: run_remote <远程命令字符串> # 远程命令会在目标服务器的 bash 中执行 local remote_cmd="$1" if [[ "$DRY_RUN" == "1" ]]; then log "🔍 [dry-run] ssh $SERVER_USER@$SERVER_IP \"$remote_cmd\"" return 0 fi ssh -o ConnectTimeout=8 -o BatchMode=yes "$SERVER_USER@$SERVER_IP" "$remote_cmd" } verify_remote() { log "" log "==========================================" log "📋 步骤 7: 验证生产环境" log "==========================================" local pages=("/" "/about" "/contact" "/products" "/services") local failed=0 for page in "${pages[@]}"; do local url="${DOMAIN}${page}" local status status=$(curl -fsS -o /dev/null -w '%{http_code}' -L --connect-timeout 8 --max-time 15 "$url" 2>/dev/null || echo "000") if [[ "$status" == "200" ]]; then log " ✅ $page ($status)" else log " ❌ $page ($status)" failed=1 fi done if [[ "$failed" == "1" ]]; then log "❌ 生产环境验证失败" return 1 fi log "✅ 生产环境验证通过" } # ---------- 命令实现 ---------- cmd_build() { log "==========================================" log "📋 构建静态站点" log "==========================================" cd "$PROJECT_ROOT" # 生产构建必须注入 .env.production 中的前后端加密密钥。 # Next.js 加载 env 的优先级为 .env.local > .env.production, # 若不显式注入,构建时会使用 .env.local 中的开发密钥, # 导致前端 chunk 密钥与后端 ENCRYPTION_SECRET 不匹配, # CMS 所有列表/仪表盘解密失败(The operation failed for an operation-specific reason)。 if [[ -f "$PROJECT_ROOT/.env.production" ]]; then PROD_ENC_SECRET=$(grep '^NEXT_PUBLIC_ENCRYPTION_SECRET=' "$PROJECT_ROOT/.env.production" | head -1 | cut -d= -f2- | tr -d '"') if [[ -n "$PROD_ENC_SECRET" ]]; then export NEXT_PUBLIC_ENCRYPTION_SECRET="$PROD_ENC_SECRET" log "✅ 已注入生产加密密钥到构建环境" else log "⚠️ .env.production 中未找到 NEXT_PUBLIC_ENCRYPTION_SECRET,请检查配置" fi else log "⚠️ 未找到 .env.production,无法注入生产加密密钥" fi if [[ "$CLEAN_BUILD" == "1" ]]; then npm run build:clean else npm run build fi if [[ ! -d "$DIST_DIR" ]]; then log "❌ 构建失败:$DIST_DIR 目录不存在" exit 1 fi # standalone 构建不会把 public/ 输出到 dist 根目录; # Nginx 以 dist 作为站点根目录,因此手动同步 public 静态资源(logo、favicon、fonts、images 等) if [[ -d "$PROJECT_ROOT/public" ]]; then cp -R "$PROJECT_ROOT/public/." "$DIST_DIR/" log "✅ public 静态资源已同步到 $DIST_DIR" fi # standalone 构建的客户端静态资源位于 dist/static,而 HTML 引用 /_next/static/...; # 复制到 dist/_next/static 供 Nginx 直接服务 if [[ -d "$DIST_DIR/static" ]]; then mkdir -p "$DIST_DIR/_next" cp -R "$DIST_DIR/static" "$DIST_DIR/_next/static" log "✅ _next/static 已从 static 同步到 $DIST_DIR/_next/static" fi log "✅ 构建完成,产物大小: $(du -sh "$DIST_DIR" | cut -f1)" } cmd_deploy() { log "==========================================" log "🚀 Novalon 统一发布" log " 目标: $SERVER_USER@$SERVER_IP:$REMOTE_DIST_DIR" log " 域名: $DOMAIN" log "==========================================" if [[ "$SKIP_BUILD" != "1" ]]; then cmd_build elif [[ ! -d "$DIST_DIR" ]]; then log "❌ $DIST_DIR 目录不存在,请先构建或去掉 --skip-build" exit 1 fi require_local_cmd ssh require_local_cmd rsync require_local_cmd curl # 步骤 1: 检查本地产物 log "" log "📋 步骤 1: 检查构建产物" DIST_SIZE=$(du -sh "$DIST_DIR" | cut -f1) log "✅ $DIST_DIR 目录存在,大小: $DIST_SIZE" # 步骤 2: 验证 SSH 连接 log "" log "📋 步骤 2: 验证 SSH 连接" if ! run_remote "exit"; then log "❌ 无法连接到服务器 $SERVER_IP" exit 1 fi log "✅ SSH 连接验证成功" # 步骤 3: 远程备份旧版本 log "" log "📋 步骤 3: 备份服务器上的旧版本" run_remote " set -e PROJECT_DIR='$REMOTE_PROJECT_DIR' DIST_PATH='$REMOTE_DIST_DIR' if [ -d \"\$DIST_PATH\" ]; then TIMESTAMP=\$(date +%Y%m%d_%H%M%S) BACKUP_PATH=\"\$PROJECT_DIR/dist_backup_\$TIMESTAMP\" cp -r \"\$DIST_PATH\" \"\$BACKUP_PATH\" echo \"✅ 备份完成: \$BACKUP_PATH\" else echo \"ℹ️ 首次部署,无需备份\" fi " DEPLOY_STARTED=1 # 步骤 4: 清理旧备份(保留最近 N 份) log "" log "📋 步骤 4: 清理旧备份(保留最近 ${BACKUP_RETENTION} 份)" run_remote " set -e PROJECT_DIR='$REMOTE_PROJECT_DIR' cd \"\$PROJECT_DIR\" || exit 0 ls -dt ${REMOTE_PROJECT_DIR}/dist_backup_* 2>/dev/null | tail -n +$((BACKUP_RETENTION + 1)) | xargs rm -rf 2>/dev/null || true echo \"✅ 清理完成\" " # 步骤 5: 上传 dist 目录 log "" log "📋 步骤 5: 上传 dist 目录" if [[ "$DRY_RUN" == "1" ]]; then log "🔍 [dry-run] rsync -avz --delete \"$DIST_DIR/\" $SERVER_USER@$SERVER_IP:$REMOTE_DIST_DIR/" else ssh "$SERVER_USER@$SERVER_IP" "mkdir -p '$REMOTE_DIST_DIR'" rsync -avz --delete "$DIST_DIR/" "$SERVER_USER@$SERVER_IP:$REMOTE_DIST_DIR/" fi log "✅ dist 目录已上传" # 步骤 6: 设置权限并重载 Nginx log "" log "📋 步骤 6: 设置权限并重载 Nginx" run_remote " set -e DIST_PATH='$REMOTE_DIST_DIR' chown -R www-data:www-data \"\$DIST_PATH\" 2>/dev/null || true chmod -R 755 \"\$DIST_PATH\" echo \"✅ 权限设置完成\" " run_remote " set -e if docker exec $NGINX_CONTAINER nginx -t 2>/dev/null; then docker exec $NGINX_CONTAINER nginx -s reload echo \"✅ Nginx 重载成功\" else echo \"⚠️ Nginx 配置检测失败,重启容器...\" docker restart $NGINX_CONTAINER sleep 3 echo \"✅ Nginx 容器已重启\" fi " # 步骤 6.5: 同步 public 并重建 Next.js 容器 # 混合渲染下,Nginx 提供新客户端资源,Next.js 容器也必须运行同一份构建, # 否则会出现 /_next/static 404 或 Server Reference ID 不匹配。 log "" log "📋 步骤 6.5: 重建 Next.js 容器 ($NEXTJS_CONTAINER)" if [[ -d "$PROJECT_ROOT/public" ]]; then if [[ "$DRY_RUN" == "1" ]]; then log "🔍 [dry-run] rsync -az --delete \"$PROJECT_ROOT/public/\" $SERVER_USER@$SERVER_IP:$REMOTE_PROJECT_DIR/public/" else rsync -az --delete "$PROJECT_ROOT/public/" "$SERVER_USER@$SERVER_IP:$REMOTE_PROJECT_DIR/public/" fi fi run_remote " set -e cd '$REMOTE_PROJECT_DIR' if [ -f '$COMPOSE_FILE' ]; then $COMPOSE_CMD -f '$COMPOSE_FILE' build $COMPOSE_CMD -f '$COMPOSE_FILE' up -d echo \"✅ Next.js 容器已重建,等待就绪...\" READY=0 for i in \$(seq 1 30); do if curl -fsS -o /dev/null --max-time 3 http://127.0.0.1:3000/ 2>/dev/null; then READY=1 break fi sleep 2 done if [ \"\$READY\" = \"1\" ]; then echo \"✅ $NEXTJS_CONTAINER 就绪\" else echo \"❌ $NEXTJS_CONTAINER 未就绪,请检查容器日志\" exit 1 fi else echo \"⚠️ 未找到 ${COMPOSE_FILE},跳过 Next.js 容器重建\" fi " # 步骤 7: 验证生产环境 if ! verify_remote; then if [[ "$AUTO_ROLLBACK" == "1" ]]; then log "" log "🔄 自动回滚到最近备份..." cmd_rollback fi exit 1 fi log "" log "🎉 部署成功!" log "📋 访问地址: $DOMAIN" } cmd_rollback() { log "==========================================" log "🔄 回滚到最近一次远程备份" log "==========================================" require_local_cmd ssh require_local_cmd curl if ! run_remote "exit"; then log "❌ 无法连接到服务器 $SERVER_IP" exit 1 fi log "📋 查找最近备份..." LATEST_BACKUP=$(run_remote " ls -dt ${REMOTE_PROJECT_DIR}/dist_backup_* 2>/dev/null | head -1 " | tr -d '\r') if [[ -z "$LATEST_BACKUP" ]]; then log "❌ 未找到任何备份,无法回滚" exit 1 fi log "✅ 最近备份: $LATEST_BACKUP" run_remote " set -e PROJECT_DIR='$REMOTE_PROJECT_DIR' DIST_PATH='$REMOTE_DIST_DIR' BACKUP_PATH='$LATEST_BACKUP' rm -rf \"\$DIST_PATH\" cp -r \"\$BACKUP_PATH\" \"\$DIST_PATH\" chown -R www-data:www-data \"\$DIST_PATH\" 2>/dev/null || true chmod -R 755 \"\$DIST_PATH\" echo \"✅ 已从备份恢复: \$BACKUP_PATH\" " run_remote " set -e if docker exec $NGINX_CONTAINER nginx -t 2>/dev/null; then docker exec $NGINX_CONTAINER nginx -s reload echo \"✅ Nginx 重载成功\" else docker restart $NGINX_CONTAINER sleep 3 echo \"✅ Nginx 容器已重启\" fi " if verify_remote; then log "" log "🎉 回滚成功!" else log "❌ 回滚后验证失败,请手动介入" exit 1 fi } cmd_status() { log "==========================================" log "📋 生产环境发布状态" log "==========================================" require_local_cmd ssh if ! run_remote "exit"; then log "❌ 无法连接到服务器 $SERVER_IP" exit 1 fi run_remote " echo \"--- Nginx 容器 ---\" docker ps --filter name=$NGINX_CONTAINER --format '{{.Names}} {{.Status}}' || echo '❌ 容器未运行' echo echo \"--- 远程 dist 目录 ---\" ls -ld '$REMOTE_DIST_DIR' 2>/dev/null || echo '❌ 目录不存在' echo \"文件数: \$(find '$REMOTE_DIST_DIR' -type f 2>/dev/null | wc -l)\" echo echo \"--- 最近修改 ---\" find '$REMOTE_DIST_DIR' -maxdepth 1 -type f -printf '%T@ %f\n' 2>/dev/null | sort -rn | head -5 echo echo \"--- 可用备份 ---\" ls -dt ${REMOTE_PROJECT_DIR}/dist_backup_* 2>/dev/null | head -${BACKUP_RETENTION} || echo '无备份' " } on_error() { local rc=$? if [[ "$COMMAND" == "deploy" && "$AUTO_ROLLBACK" == "1" && "$DEPLOY_STARTED" == "1" ]]; then log "" log "❌ 部署失败(exit=$rc),执行自动回滚..." cmd_rollback >&2 || true fi exit "$rc" } trap on_error ERR # ---------- 参数解析 ---------- case "$COMMAND" in build|deploy|rollback|status|help|-h|--help) ;; *) log "❌ 未知命令: $COMMAND" usage exit 1 ;; esac if [[ "$COMMAND" == "help" || "$COMMAND" == "-h" || "$COMMAND" == "--help" ]]; then usage exit 0 fi shift || true while [[ $# -gt 0 ]]; do case "$1" in --skip-build) SKIP_BUILD=1; shift ;; --auto-rollback) AUTO_ROLLBACK=1; shift ;; --clean) CLEAN_BUILD=1; shift ;; --dry-run) DRY_RUN=1; shift ;; --server-ip) SERVER_IP="$2"; shift 2 ;; --server-user) SERVER_USER="$2"; shift 2 ;; --deploy-root) DEPLOY_ROOT="$2"; shift 2 ;; --project-name) PROJECT_NAME="$2"; shift 2 ;; --nginx-container) NGINX_CONTAINER="$2"; shift 2 ;; --domain) DOMAIN="$2"; shift 2 ;; --backup-retention) BACKUP_RETENTION="$2"; shift 2 ;; --dist-dir) DIST_DIR="$2"; shift 2 ;; -h|--help) usage; exit 0 ;; *) log "❌ 未知参数: $1" usage exit 1 ;; esac done REMOTE_PROJECT_DIR="$DEPLOY_ROOT/$PROJECT_NAME" REMOTE_DIST_DIR="$REMOTE_PROJECT_DIR/dist" # ---------- 日志 ---------- mkdir -p "$LOG_DIR" exec > >(tee -a "$LOG_FILE") 2>&1 log "📋 部署日志: $LOG_FILE" log "" # ---------- 执行 ---------- case "$COMMAND" in build) cmd_build ;; deploy) cmd_deploy ;; rollback) cmd_rollback ;; status) cmd_status ;; esac