pipeline { agent any environment { NODE_VERSION = '18' NPM_REGISTRY = 'https://registry.npmmirror.com' PROJECT_NAME = 'novalon-website' SERVER_IP = '139.155.109.62' SERVER_USER = 'root' DEPLOY_ROOT = '/home/novalon/docker-app' STATIC_DIR = 'novalon-static' NGINX_CONTAINER = 'novalon-nginx-secure' DOMAIN = 'https://novalon.cn' BACKUP_RETENTION_COUNT = 3 } options { buildDiscarder(logRotator(numToKeepStr: '10', artifactNumToKeepStr: '5')) timeout(time: 45, unit: 'MINUTES') timestamps() ansiColor('xterm') disableConcurrentBuilds() } triggers { GenericTrigger( genericVariables: [ [key: 'ref', value: '$.ref'], [key: 'repository', value: '$.repository.full_name'], [key: 'commit_sha', value: '$.after'], [key: 'commit_message', value: '$.commits[0].message'] ], token: '${PROJECT_NAME}-ci-token', causeString: 'Triggered by $ref on $repository (Commit: $commit_sha)', printContributedVariables: true, printPostContent: true, silentResponse: false, regexpFilterText: '$ref', regexpFilterExpression: '^(refs/heads/main|refs/heads/develop)$' ) } parameters { booleanParam( defaultValue: false, description: '是否部署到生产环境(仅在 main 分支且测试通过后可用)', name: 'DEPLOY_TO_PRODUCTION' ) } stages { stage('🔧 环境检测与准备') { steps { echo "==========================================" echo "🚀 Novalon Website CI/CD Pipeline" echo "🐳 Docker Shell Mode (参考 deploy-dist.sh)" echo "==========================================" sh ''' echo "🔍 检测环境依赖..." echo "" echo "--- 系统信息 ---" uname -a whoami pwd echo "" echo "--- 工具版本检查 ---" node --version 2>/dev/null || echo "❌ Node.js 未安装" npm --version 2>/dev/null || echo "❌ npm 未安装" git --version || echo "❌ Git 未安装" ssh -V || echo "❌ SSH 未安装" rsync --version | head -1 || echo "❌ rsync 未安装" curl --version | head -1 || echo "❌ curl 未安装" echo "" echo "--- SSH 连接测试 ---" if ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 ${SERVER_USER}@${SERVER_IP} "hostname && whoami"; then echo "✅ SSH 连接成功" else echo "❌ SSH 连接失败!请检查:" echo " 1. SSH 密钥是否已挂载到 Jenkins 容器" echo " 2. 生产服务器的 authorized_keys 是否包含公钥" exit 1 fi echo "" echo "--- npm registry 测试 ---" npm config get registry || npm config set registry ${NPM_REGISTRY} ''' nodejs(nodeJSInstallationName: "${NODE_VERSION}") { sh 'node --version && npm --version' } } } stage('📥 安装依赖') { steps { checkout scm nodejs(nodeJSInstallationName: "${NODE_VERSION}") { sh ''' echo "📦 安装项目依赖..." if [ -f "package-lock.json" ]; then rm -rf node_modules package-lock.json npm ci --registry=${NPM_REGISTRY} --prefer-offline --no-audit --no-fund || { echo "⚠️ npm ci 失败,尝试 npm install..." npm install --registry=${NPM_REGISTRY} --legacy-peer-deps } else rm -rf node_modules npm install --registry=${NPM_REGISTRY} --legacy-peer-deps fi echo "✅ 依赖安装完成" du -sh node_modules | awk '{print "📊 大小: " $1}' ''' } } } stage('🔍 代码质量检查') { parallel { stage('ESLint') { steps { nodejs(nodeJSInstallationName: "${NODE_VERSION}") { sh 'npm run lint || exit 1' } } } stage('TypeScript') { steps { nodejs(nodeJSInstallationName: "${NODE_VERSION}") { sh 'npm run type-check || exit 1' } } } } } stage('🧪 单元测试') { steps { nodejs(nodeJSInstallationName: "${NODE_VERSION}") { sh ''' export CI=true npm run test:coverage:check || exit 1 ''' } } post { always { publishHTML(target: [ allowMissing: true, reportDir: 'coverage', reportFiles: 'index.html', reportName: 'Coverage Report' ]) } } } // ====== L3: E2E + 用户旅程测试 ====== stage('🌐 E2E 测试') { when { branch 'main' beforeAgent true } steps { nodejs(nodeJSInstallationName: "${NODE_VERSION}") { sh ''' echo "🔨 构建用于 E2E 测试..." npm run build echo "🚀 启动预览服务器..." npx serve dist -l 3000 & sleep 5 echo "🧪 运行 E2E 快速测试(@smoke + @critical)..." cd e2e && npx playwright test --grep "@smoke|@critical" || echo "⚠️ E2E 测试部分失败,继续执行" echo "🧪 运行用户旅程测试..." npx playwright test --grep @journey || echo "⚠️ 用户旅程测试部分失败,继续执行" ''' } } post { always { sh 'kill $(lsof -t -i:3000) 2>/dev/null || true' publishHTML(target: [ allowMissing: true, reportDir: 'e2e/playwright-report', reportFiles: 'index.html', reportName: 'E2E Test Report' ]) } failure { archiveArtifacts artifacts: 'e2e/test-results/**/*.png', allowEmptyArchive: true } } } // ====== L4: 视觉回归测试 ====== stage('👁️ 视觉回归测试') { when { branch 'main' beforeAgent true } steps { nodejs(nodeJSInstallationName: "${NODE_VERSION}") { sh ''' echo "🚀 启动预览服务器..." npx serve dist -l 3000 & sleep 5 echo "🧪 运行视觉回归测试..." cd e2e && npx playwright test visual-regression.spec.ts --project=visual-chromium-desktop || echo "⚠️ 视觉回归测试失败,请检查基线是否需要更新" ''' } } post { always { sh 'kill $(lsof -t -i:3000) 2>/dev/null || true' archiveArtifacts artifacts: 'e2e/test-results/**/*.png', allowEmptyArchive: true } } } // ====== L5: 安全扫描 ====== stage('🔒 安全扫描') { when { branch 'main' beforeAgent true } steps { nodejs(nodeJSInstallationName: "${NODE_VERSION}") { sh ''' echo "🔒 运行依赖安全审计..." npm audit --audit-level=high || echo "⚠️ 存在高危依赖漏洞,请检查" echo "🔒 检查安全响应头..." npm run test:security:headers -- --url https://novalon.cn || echo "⚠️ 安全头检查未通过,请检查 Nginx 配置" ''' } } } stage('🏗️ 构建 dist') { steps { nodejs(nodeJSInstallationName: "${NODE_VERSION}") { sh ''' set -e echo "🧹 清理旧构建..." rm -rf .next dist echo "🔨 执行 Next.js 构建..." npm run build:clean echo "" echo "✅ 构建完成!验证产物..." if [ ! -d "dist" ]; then echo "❌ dist 目录不存在" exit 1 fi FILE_COUNT=$(find dist -type f | wc -l) DIST_SIZE=$(du -sh dist | cut -f1) echo "📊 文件数: $FILE_COUNT, 大小: $DIST_SIZE" ''' } } post { success { archiveArtifacts artifacts: 'dist/**', fingerprint: true } } } stage('🚀 部署到生产环境') { when { allOf { branch 'main' expression { return params.DEPLOY_TO_PRODUCTION == true } } } steps { echo "⚠️ 准备部署到生产环境: ${DOMAIN}" sleep(time: 3, unit: 'SECONDS') sh """ set -e DIST_DIR='dist' echo "==========================================" echo "📋 步骤 1: 检查 dist 目录" echo "==========================================" if [ ! -d "\$DIST_DIR" ]; then echo "❌ dist 目录不存在,请先构建" exit 1 fi DIST_SIZE=\$(du -sh "\$DIST_DIR" | cut -f1) echo "✅ dist 目录大小: \$DIST_SIZE" echo "" echo "==========================================" echo "📋 步骤 2: 验证 SSH 连接" echo "==========================================" if ! ssh -o ConnectTimeout=5 ${SERVER_USER}@${SERVER_IP} exit; then echo "❌ 无法连接到服务器 ${SERVER_IP}" exit 1 fi echo "✅ SSH 连接验证成功" echo "" echo "==========================================" echo "📋 步骤 3: 备份旧版本" echo "==========================================" ssh ${SERVER_USER}@${SERVER_IP} " set -e STATIC_PATH='${DEPLOY_ROOT}/${STATIC_DIR}' TIMESTAMP=\$(date +%Y%m%d_%H%M%S) BACKUP_PATH='${DEPLOY_ROOT}/${STATIC_DIR}_backup_\${TIMESTAMP}' if [ -d \"\$STATIC_PATH\" ]; then echo \"📦 备份旧版本到: \$BACKUP_PATH\" cp -r \"\$STATIC_PATH\" \"\$BACKUP_PATH\" echo \"✅ 备份完成\" echo \"🧹 清理旧备份(保留最近 ${BACKUP_RETENTION_COUNT} 个)...\" cd '${DEPLOY_ROOT}' ls -dt ${STATIC_DIR}_backup_* 2>/dev/null | tail -n +$((BACKUP_RETENTION_COUNT + 1)) | xargs rm -rf 2>/dev/null || true echo \"✅ 清理完成\" else echo \"ℹ️ 无需备份(目录不存在),首次部署\" fi " echo "" echo "==========================================" echo "📋 步骤 4: 上传 dist 目录" echo "==========================================" ssh ${SERVER_USER}@${SERVER_IP} "mkdir -p '${DEPLOY_ROOT}/${STATIC_DIR}'" echo "📤 使用 rsync 同步文件..." rsync -avz --delete "\$DIST_DIR/" ${SERVER_USER}@${SERVER_IP}:${DEPLOY_ROOT}/${STATIC_DIR}/ FILE_COUNT=\$(find \$DIST_DIR -type f | wc -l) echo "✅ 上传完成: \$FILE_COUNT 个文件, 总大小 \$DIST_SIZE" echo "" echo "==========================================" echo "📋 步骤 5: 设置权限" echo "==========================================" ssh ${SERVER_USER}@${SERVER_IP} " set -e STATIC_PATH='${DEPLOY_ROOT}/${STATIC_DIR}' chown -R www-data:www-data \"\$STATIC_PATH\" 2>/dev/null || true chmod -R 755 \"\$STATIC_PATH\" echo \"✅ 权限设置完成\" " echo "" echo "==========================================" echo "📋 步骤 6: 重载 Nginx" echo "==========================================" ssh ${SERVER_USER}@${SERVER_IP} " set -e if docker exec ${NGINX_CONTAINER} nginx -t; then docker exec ${NGINX_CONTAINER} nginx -s reload echo \"✅ Nginx 重载成功\" else echo \"⚠️ Nginx 配置检测失败,尝试重启容器...\" docker restart ${NGINX_CONTAINER} sleep 3 echo \"✅ Nginx 容器已重启\" fi " echo "" echo "==========================================" echo "📋 步骤 7: 验证部署" echo "==========================================" sleep 3 HTTP_STATUS=\$(curl -s -o /dev/null -w '%{http_code}' --connect-timeout 10 ${DOMAIN}) if [ "\$HTTP_STATUS" = "200" ]; then echo "✅ 主页访问正常 (HTTP \$HTTP_STATUS)" else echo "❌ 主页返回异常状态码: \$HTTP_STATUS" exit 1 fi echo "" echo "🔍 检查关键页面..." for PAGE in "/contact" "/about" "/products" "/services"; do STATUS=\$(curl -s -o /dev/null -w '%{http_code}' --connect-timeout 5 \${DOMAIN}\$PAGE) if [ "\$STATUS" = "200" ]; then echo " ✅ \$PAGE (\$STATUS)" else echo " ⚠️ \$PAGE (\$STATUS) - 需要关注" fi done echo "" echo "🎉 部署成功!" echo "📋 访问地址:" echo " HTTP: http://${SERVER_IP}" echo " HTTPS: https://novalon.cn" """ } post { failure { echo "❌ 部署失败!正在执行自动回滚..." script { try { sh """ ssh ${SERVER_USER}@${SERVER_IP} ' set -e LATEST_BACKUP=\$(ls -dt ${DEPLOY_ROOT}/${STATIC_DIR}_backup_* 2>/dev/null | head -1) if [ -n "\$LATEST_BACKUP" ]; then echo "🔄 回滚到: \$LATEST_BACKUP" rm -rf "${DEPLOY_ROOT}/${STATIC_DIR}" cp -r "\$LATEST_BACKUP" "${DEPLOY_ROOT}/${STATIC_DIR}" if docker exec ${NGINX_CONTAINER} nginx -t; then docker exec ${NGINX_CONTAINER} nginx -s reload else docker restart ${NGINX_CONTAINER} fi echo "✅ 回滚完成" else echo "❌ 未找到可用备份!需要手动介入" exit 1 fi ' """ } catch (Exception e) { echo "❌ 自动回滚失败: ${e.getMessage()}" echo "🚨 需要立即手动介入!" } } } } } } post { always { script { def result = currentBuild.result ?: 'SUCCESS' def duration = currentBuild.durationString.replace(' and counting', '') echo """ ╔════════════════════════════════════════════╗ ║ 📊 Jenkins Pipeline 报告 ║ ╠════════════════════════════════════════════╣ ║ 项目: ${env.JOB_NAME} ║ 构建号: #${env.BUILD_NUMBER} ║ 结果: ${result} ║ 耗时: ${duration} ║ 详情: ${env.BUILD_URL} ╚════════════════════════════════════════════╝ """ } } success { echo "✅ Pipeline 执行成功!" } failure { echo "❌ Pipeline 执行失败!请查看日志。" script { // 邮件通知(使用 Jenkins 内置 mail step,无需额外插件) try { mail( to: 'team@novalon.cn', subject: "[FAILED] ${env.JOB_NAME} - #${env.BUILD_NUMBER}", body: """ Pipeline 执行失败! 项目: ${env.JOB_NAME} 构建号: #${env.BUILD_NUMBER} 分支: ${env.BRANCH_NAME} 提交: ${env.GIT_COMMIT} 详情: ${env.BUILD_URL}console 日志: ${env.BUILD_URL} """ ) echo "📧 邮件通知已发送至 team@novalon.cn" } catch (Exception e) { echo "⚠️ 邮件通知发送失败(mail plugin 可能未配置): ${e.getMessage()}" } // Webhook 通知(预留,可接入钉钉/企业微信/Gitee Webhook) try { def webhookUrl = env.WEBHOOK_NOTIFICATION_URL ?: '' if (webhookUrl) { sh """ curl -s -X POST '${webhookUrl}' \ -H 'Content-Type: application/json' \ -d '{ "msgtype": "markdown", "markdown": { "title": "❌ Pipeline 失败: ${env.JOB_NAME}", "text": "### ❌ Pipeline 执行失败\\n\\n**项目**: ${env.JOB_NAME}\\n**构建号**: #${env.BUILD_NUMBER}\\n**分支**: ${env.BRANCH_NAME}\\n**详情**: [查看日志](${env.BUILD_URL}console)" } }' || true """ echo "🔔 Webhook 通知已发送" } } catch (Exception e) { echo "⚠️ Webhook 通知发送失败: ${e.getMessage()}" } } } } }