pipeline { agent any environment { // 项目配置 PROJECT_NAME = 'gym-manage-system' FRONTEND_DIR = 'gym-manage-web' BACKEND_DIR = 'gym-manage-api' // Node.js 配置 NODE_VERSION = '20' PNPM_VERSION = '8.15.0' // Java 配置(项目使用 Java 21,与 gym-manage-api/pom.xml 保持一致) JAVA_VERSION = '21' MAVEN_VERSION = '3.9.0' // Docker 配置 DOCKER_REGISTRY = credentials('docker-registry') DOCKER_IMAGE_FRONTEND = "${PROJECT_NAME}-frontend" DOCKER_IMAGE_BACKEND = "${PROJECT_NAME}-backend" // 数据库配置(用于E2E测试) DB_HOST = 'localhost' DB_PORT = '5432' DB_NAME = 'gym_test' DB_USER = credentials('db-user') DB_PASSWORD = credentials('db-password') // 测试配置 TEST_TIMEOUT = '30' RETRY_COUNT = '2' // ===== 质量门禁阈值 ===== // 后端 JaCoCo 指令覆盖率(与 pom.xml 保持一致) JACOCO_INSTRUCTION_THRESHOLD = '0.55' // 后端 PIT 变异分数 PIT_MUTATION_THRESHOLD = '45' // 前端 StrykerJS 变异分数 STRYKE_MUTATION_THRESHOLD = '40' // E2E P0 通过率要求 E2E_PASS_THRESHOLD = '100' // flaky 率阈值(百分比) FLAKY_THRESHOLD = '3' } tools { nodejs "NodeJS-${NODE_VERSION}" maven "Maven-${MAVEN_VERSION}" jdk "JDK-${JAVA_VERSION}" // Jenkins 管理节点需预先配置 JDK-21 工具 } stages { stage('环境准备') { steps { echo '🔧 准备构建环境...' sh ''' # 安装 pnpm npm install -g pnpm@${PNPM_VERSION} # 验证工具版本 node --version pnpm --version java -version mvn --version ''' } } stage('代码检查') { parallel { stage('前端代码检查') { steps { dir(FRONTEND_DIR) { echo '🔍 执行前端代码检查...' sh ''' pnpm install pnpm run lint pnpm run type-check ''' } } } stage('后端代码检查') { steps { dir(BACKEND_DIR) { echo '🔍 执行后端代码检查...' sh 'mvn clean compile -DskipTests' } } } } } stage('单元测试') { parallel { stage('前端单元测试') { steps { dir(FRONTEND_DIR) { echo '🧪 执行前端单元测试...' sh 'pnpm run test:unit' } } post { always { dir(FRONTEND_DIR) { // 发布测试报告 publishHTML(target: [ allowMissing: false, alwaysLinkToLastBuild: true, keepAll: true, reportDir: 'coverage', reportFiles: 'index.html', reportName: '前端单元测试覆盖率报告' ]) } } } } stage('后端单元测试') { steps { dir(BACKEND_DIR) { echo '🧪 执行后端单元测试...' sh 'mvn test' } } post { always { dir(BACKEND_DIR) { // 发布测试报告 junit '**/target/surefire-reports/*.xml' // 收集 Allure 结果到统一目录 sh ''' mkdir -p target/allure-results find . -path '*/target/allure-results/*.json' -exec cp {} target/allure-results/ \; ''' } } } } } } stage('构建') { parallel { stage('前端构建') { steps { dir(FRONTEND_DIR) { echo '📦 构建前端项目...' sh ''' pnpm run build:prod # 创建构建产物归档 tar -czf frontend-dist.tar.gz dist/ ''' } } post { success { archiveArtifacts artifacts: "${FRONTEND_DIR}/frontend-dist.tar.gz", fingerprint: true } } } stage('后端构建') { steps { dir(BACKEND_DIR) { echo '📦 构建后端项目...' sh ''' mvn clean package -DskipTests # 创建构建产物归档 tar -czf backend-jars.tar.gz */target/*.jar ''' } } post { success { archiveArtifacts artifacts: "${BACKEND_DIR}/backend-jars.tar.gz", fingerprint: true } } } } } stage('报告聚合') { steps { echo '📊 生成聚合测试报告...' dir(BACKEND_DIR) { sh 'mvn verify -DskipTests -pl manage-test-report -am' } } post { always { dir(BACKEND_DIR) { // 发布 JaCoCo 聚合覆盖率报告 publishHTML(target: [ allowMissing: false, alwaysLinkToLastBuild: true, keepAll: true, reportDir: 'manage-test-report/target/site/jacoco-aggregate', reportFiles: 'index.html', reportName: '后端覆盖率聚合报告' ]) } } } } stage('E2E测试') { steps { echo '🎭 执行E2E测试...' dir(FRONTEND_DIR) { sh ''' # 安装Playwright浏览器 pnpm exec playwright install --with-deps chromium # 执行E2E测试 pnpm run test:e2e:journeys ''' } } post { always { dir(FRONTEND_DIR) { // 发布 JUnit 格式结果 junit 'test-results/junit.xml' // 发布E2E测试报告 publishHTML(target: [ allowMissing: false, alwaysLinkToLastBuild: true, keepAll: true, reportDir: 'test-results', reportFiles: 'custom-report.html', reportName: 'E2E测试报告' ]) // 归档测试失败截图和视频 archiveArtifacts artifacts: 'test-results/**/*.png, test-results/**/*.webm', allowEmptyArchive: true } } } } // ===== 阶段 5:变异测试(仅在 main 分支或 nightly 构建中执行) ===== stage('变异测试') { when { anyOf { branch 'main' branch 'develop' expression { return params.NIGHTLY_BUILD ?: false } } } parallel { stage('后端 PIT 变异测试') { steps { echo '🧬 执行后端 PIT 变异测试(manage-sys 模块)...' dir(BACKEND_DIR) { sh ''' mvn org.pitest:pitest-maven:mutationCoverage -pl manage-sys \\ -Dmaven.test.failure.ignore=true 2>&1 | tee pit-report.log # 提取变异分数 PIT_SCORE=$(grep -oP 'Generated \\d+ mutations Killed \\d+ \\(\\K\\d+' pit-report.log | tail -1) echo "PIT Mutation Score: ${PIT_SCORE}%" # 检查是否达到阈值 if [ "${PIT_SCORE}" -lt ${PIT_MUTATION_THRESHOLD} ]; then echo "WARNING: PIT mutation score ${PIT_SCORE}% is below threshold ${PIT_MUTATION_THRESHOLD}%" else echo "PIT mutation score ${PIT_SCORE}% meets threshold ${PIT_MUTATION_THRESHOLD}%" fi ''' } } post { always { dir("${BACKEND_DIR}/manage-sys/target/pit-reports") { publishHTML(target: [ allowMissing: true, alwaysLinkToLastBuild: true, keepAll: true, reportDir: '.', reportFiles: 'index.html', reportName: '后端 PIT 变异测试报告' ]) } } } } stage('前端 StrykerJS 变异测试') { steps { echo '🧬 执行前端 StrykerJS 变异测试...' dir(FRONTEND_DIR) { sh ''' pnpm run test:mutation 2>&1 | tee stryker-report.log # 提取变异分数 STRYKE_SCORE=$(grep -oP 'Mutation score.*?\\K\\d+\\.\\d+' stryker-report.log | tail -1) echo "StrykerJS Mutation Score: ${STRYKE_SCORE}%" ''' } } post { always { dir("${FRONTEND_DIR}/reports/mutation") { publishHTML(target: [ allowMissing: true, alwaysLinkToLastBuild: true, keepAll: true, reportDir: '.', reportFiles: 'mutation.html', reportName: '前端 StrykerJS 变异测试报告' ]) } } } } } } // ===== 质量门禁:统一检查所有质量标准 ===== stage('质量门禁') { steps { echo '🛡️ 执行质量门禁检查...' script { def qualityGatePassed = true def gateMessages = [] // 1. 检查后端 JaCoCo 覆盖率 dir(BACKEND_DIR) { def jacocoReport = "manage-test-report/target/site/jacoco-aggregate/jacoco.csv" if (fileExists(jacocoReport)) { // 解析 JaCoCo 报告提取指令覆盖率 def totalInstr = sh( script: "awk -F',' 'NR>1{sum+=\$5;covered+=\$6}END{printf \"%.2f\", covered/sum}' ${jacocoReport}", returnStdout: true ).trim() echo "后端 JaCoCo 指令覆盖率: ${totalInstr}" def threshold = JACOCO_INSTRUCTION_THRESHOLD.toDouble() if (totalInstr.toDouble() < threshold) { qualityGatePassed = false gateMessages.add("❌ 后端指令覆盖率 ${totalInstr}% 低于阈值 ${threshold * 100}%") } else { gateMessages.add("✅ 后端指令覆盖率 ${totalInstr}% 达标(阈值 ${threshold * 100}%)") } } else { gateMessages.add("⚠️ JaCoCo 报告未找到,跳过覆盖率检查") } } // 2. 检查 PIT 变异分数(仅当变异测试执行过) if (fileExists("${BACKEND_DIR}/manage-sys/target/pit-reports/mutations.xml")) { def pitScore = sh( script: "grep -oP 'mutationCoverage=\"\\K\\d+\\.?\\d*' ${BACKEND_DIR}/manage-sys/target/pit-reports/mutations.xml | head -1", returnStdout: true ).trim() if (pitScore) { echo "后端 PIT 变异分数: ${pitScore}%" def pitThreshold = PIT_MUTATION_THRESHOLD.toInteger() if (pitScore.toInteger() < pitThreshold) { qualityGatePassed = false gateMessages.add("❌ 后端 PIT 变异分数 ${pitScore}% 低于阈值 ${pitThreshold}%") } else { gateMessages.add("✅ 后端 PIT 变异分数 ${pitScore}% 达标(阈值 ${pitThreshold}%)") } } } // 3. 检查 E2E 测试通过率 def e2eResults = "${FRONTEND_DIR}/test-results/junit.xml" if (fileExists(e2eResults)) { def e2ePassRate = sh( script: "python3 -c \" import xml.etree.ElementTree as ET tree = ET.parse('${e2eResults}') root = tree.getroot() total = int(root.get('tests', '0')) failures = int(root.get('failures', '0')) errors = int(root.get('errors', '0')) pass_rate = ((total - failures - errors) / total * 100) if total > 0 else 0 print(f'{pass_rate:.1f}') \"", returnStdout: true ).trim() echo "E2E 测试通过率: ${e2ePassRate}%" if (e2ePassRate.toDouble() < E2E_PASS_THRESHOLD.toDouble()) { qualityGatePassed = false gateMessages.add("❌ E2E 通过率 ${e2ePassRate}% 未达到 100%") } else { gateMessages.add("✅ E2E 测试全部通过") } } // 4. 汇总结果 echo '=== 质量门禁检查结果 ===' for (msg in gateMessages) { echo msg } if (!qualityGatePassed) { error('❌ 质量门禁未通过,阻断构建!') } } } } // ===== 测试指标收集与看板 ===== stage('测试指标收集') { steps { echo '📊 收集测试指标并生成看板...' sh ''' # 收集测试指标 python3 scripts/collect-test-metrics.py \ --project-dir . \ --build-number "${BUILD_NUMBER}" \ --output-dir target/test-metrics \ --history-dir target/test-metrics/history # 生成看板 python3 scripts/generate-dashboard.py \ --project-dir . \ --history-dir target/test-metrics/history \ --output target/test-metrics/dashboard.html ''' } post { always { // 归档历史指标数据 archiveArtifacts artifacts: 'target/test-metrics/**/*', allowEmptyArchive: true // 发布看板 publishHTML(target: [ allowMissing: true, alwaysLinkToLastBuild: true, keepAll: true, reportDir: 'target/test-metrics', reportFiles: 'dashboard.html', reportName: '测试质量看板' ]) } } } stage('构建Docker镜像') { when { branch 'develop' } steps { echo '🐳 构建Docker镜像...' // 构建前端镜像 dir(FRONTEND_DIR) { sh """ docker build -t ${DOCKER_REGISTRY}/${DOCKER_IMAGE_FRONTEND}:${BUILD_NUMBER} . docker tag ${DOCKER_REGISTRY}/${DOCKER_IMAGE_FRONTEND}:${BUILD_NUMBER} ${DOCKER_REGISTRY}/${DOCKER_IMAGE_FRONTEND}:latest """ } // 构建后端镜像 dir(BACKEND_DIR) { sh """ docker build -t ${DOCKER_REGISTRY}/${DOCKER_IMAGE_BACKEND}:${BUILD_NUMBER} . docker tag ${DOCKER_REGISTRY}/${DOCKER_IMAGE_BACKEND}:${BUILD_NUMBER} ${DOCKER_REGISTRY}/${DOCKER_IMAGE_BACKEND}:latest """ } } } stage('推送Docker镜像') { when { branch 'develop' } steps { echo '📤 推送Docker镜像到仓库...' sh """ docker push ${DOCKER_REGISTRY}/${DOCKER_IMAGE_FRONTEND}:${BUILD_NUMBER} docker push ${DOCKER_REGISTRY}/${DOCKER_IMAGE_FRONTEND}:latest docker push ${DOCKER_REGISTRY}/${DOCKER_IMAGE_BACKEND}:${BUILD_NUMBER} docker push ${DOCKER_REGISTRY}/${DOCKER_IMAGE_BACKEND}:latest """ } } stage('部署到测试环境') { when { branch 'develop' } steps { echo '🚀 部署到测试环境...' sh """ echo "部署前端镜像: ${DOCKER_REGISTRY}/${DOCKER_IMAGE_FRONTEND}:${BUILD_NUMBER}" echo "部署后端镜像: ${DOCKER_REGISTRY}/${DOCKER_IMAGE_BACKEND}:${BUILD_NUMBER}" """ } } stage('部署到生产环境') { when { branch 'main' } steps { echo '🚀 部署到生产环境...' input message: '确认部署到生产环境?', ok: '确认部署' sh """ echo "部署前端镜像: ${DOCKER_REGISTRY}/${DOCKER_IMAGE_FRONTEND}:${BUILD_NUMBER}" echo "部署后端镜像: ${DOCKER_REGISTRY}/${DOCKER_IMAGE_BACKEND}:${BUILD_NUMBER}" """ } } } post { always { echo '📊 聚合 Allure 报告...' allure([ includeProperties: false, jdk: '', properties: [], reportBuildPolicy: 'ALWAYS', results: [ [path: 'gym-manage-api/target/allure-results'], [path: 'gym-manage-web/allure-results'] ] ]) echo '🧹 清理工作空间...' cleanWs() } success { echo '✅ 流水线执行成功!' // 可以添加通知,例如发送邮件或Slack消息 } failure { echo '❌ 流水线执行失败!' // 可以添加失败通知 } unstable { echo '⚠️ 流水线执行不稳定!' // 可以添加不稳定状态通知 } } }