diff --git a/.gitignore b/.gitignore index ef3cc8f..3cb4a90 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,8 @@ __pycache__/ .venv/ .env .env.example +.mrules +.catpaw/ # 规则忽略文件 CLAUDE.md diff --git a/DOCKER_BUILD_FIX.md b/DOCKER_BUILD_FIX.md deleted file mode 100644 index dc6ebe0..0000000 --- a/DOCKER_BUILD_FIX.md +++ /dev/null @@ -1,118 +0,0 @@ -# Docker 构建失败修复总结 - -## 问题描述 - -Docker 构建过程中出现了以下错误: - -``` -1.386 npm error [-w|--workspace [-w|--workspace ...]] -1.412 npm error A complete log of this run can be found in: /root/.npm/_logs/2026-02-13T15_39_20_118Z-debug-0.log -1.421 ERROR: typescript not installed -``` - -这表明 `npm ci` 命令在 Docker 容器中失败,导致 TypeScript 和其他 devDependencies 没有被正确安装。 - -## 根本原因 - -1. **package-lock.json 不匹配** - Docker 中的 npm 版本与本地开发环境的 npm 版本不同 -2. **npm 版本差异** - Node 20 Alpine 镜像中的 npm 版本可能与 package-lock.json 生成时的版本不兼容 -3. **网络问题** - 虽然已清除代理配置,但某些 npm 源可能在 Docker 环境中不可用 - -## 解决方案 - -在 `deployment/Dockerfile` 中对所有三个需要安装依赖的阶段应用了以下改进: - -### 改进 1: 添加 npm 缓存清理 - -```dockerfile -RUN npm cache clean --force -``` - -这确保了旧的缓存数据不会导致问题。 - -### 改进 2: npm ci 回退到 npm install - -**原始代码:** -```dockerfile -RUN npm ci --prefer-offline --no-audit 2>&1 | grep -v "^npm notice" -``` - -**改进后:** -```dockerfile -RUN npm ci --prefer-offline --no-audit 2>&1 | tail -20 || npm install --verbose -``` - -**优势:** -- 优先使用 `npm ci` 以保证依赖版本的一致性 -- 如果 `npm ci` 失败,自动回退到 `npm install` -- 添加 `--verbose` 标志帮助调试安装问题 -- 使用 `tail -20` 显示最后 20 行日志(而不是隐藏所有日志) - -## 修改的文件 - -- `deployment/Dockerfile` - 更新了三个构建阶段: - 1. **阶段 1 (frontend-builder)**: 前端构建阶段,第 7 行 - 2. **阶段 2 (backend-builder)**: 后端编译阶段,第 18 行(关键修复点,确保 TypeScript 被安装) - 3. **阶段 3 (prod-dependencies)**: 生产依赖阶段,第 28 行 - -## 变更详解 - -### 前端构建阶段 -```dockerfile -RUN npm ci --prefer-offline --no-audit 2>&1 | tail -20 || npm install --verbose -``` - -### 后端编译阶段(最关键) -```dockerfile -RUN npm ci --prefer-offline --no-audit 2>&1 | tail -20 || npm install --verbose -# 之后执行 npm run server:build 时,TypeScript 现在一定会被安装 -``` - -### 生产依赖阶段 -```dockerfile -RUN npm ci --prefer-offline --no-audit 2>&1 | tail -20 || npm install --verbose -RUN npm prune --omit=dev --no-audit -``` - -## 测试步骤 - -构建后,可以通过以下命令验证修复: - -```bash -# 1. 构建 Docker 镜像 -docker build -t automation-platform:latest -f deployment/Dockerfile . - -# 2. 验证 TypeScript 是否被正确安装 -docker run --rm automation-platform:latest npm list typescript - -# 3. 运行应用 -docker run -p 3000:3000 automation-platform:latest - -# 4. 检查健康状态 -curl http://localhost:3000/api/health -``` - -## 预期结果 - -✅ Docker 构建成功完成,无 TypeScript 缺失错误 -✅ 后端应用正常编译并运行 -✅ 前端资源正确打包 -✅ 生产依赖正确精简 - -## 性能影响 - -- **构建时间**: 可能增加 30-60 秒(因为可能需要执行 npm install 作为备选方案) -- **镜像大小**: 无变化 -- **运行时性能**: 无变化 - -## 长期建议 - -1. **保持 package-lock.json 最新** - 定期在本地运行 `npm ci` 并提交更新的 lock 文件 -2. **使用一致的 npm 版本** - 考虑在项目中添加 `.npmrc` 配置固定 npm 版本 -3. **容器化开发** - 使用 devcontainer 或 Docker Compose 确保开发环境与 CI/CD 环境一致 - -## 其他文件 - -- `Dockerfile` 位置: `/deployment/Dockerfile` -- 部署脚本: `/deployment/deploy.sh` -- 快速部署: `/deployment/scripts/setup.sh` diff --git a/DOCKER_BUILD_SOLUTION.md b/DOCKER_BUILD_SOLUTION.md deleted file mode 100644 index e69de29..0000000 diff --git a/DOCKER_BUILD_TEST.md b/DOCKER_BUILD_TEST.md deleted file mode 100644 index 2ac6d9e..0000000 --- a/DOCKER_BUILD_TEST.md +++ /dev/null @@ -1,215 +0,0 @@ -# Docker 构建修复验证指南 - -## 快速测试步骤 - -### 1. 清理旧的 Docker 镜像和缓存 - -```bash -# 删除旧镜像 -docker rmi automation-platform:latest || true -docker rmi $(docker images -q -f "dangling=true") || true - -# 清理 Docker 构建缓存(可选,用于完全重新构建) -docker builder prune -a --force -``` - -### 2. 构建新镜像 - -```bash -cd /Users/wb_caijinwei/Automation_Platform -docker build -t automation-platform:latest -f deployment/Dockerfile . -``` - -**预期结果**: 构建成功完成,没有 TypeScript 缺失错误 - -### 3. 验证 TypeScript 安装 - -```bash -# 方法 1: 检查 TypeScript 是否在容器中 -docker run --rm automation-platform:latest npm list typescript - -# 预期输出: -# automation-platform@1.0.0 /app -# └── typescript@5.3.3 -``` - -### 4. 验证后端编译产物 - -```bash -# 检查编译后的 JavaScript 是否存在 -docker run --rm automation-platform:latest ls -la dist/server/ | head -20 -``` - -### 5. 启动容器并测试应用 - -```bash -# 启动容器 -docker run -d -p 3000:3000 --name test-automation automation-platform:latest - -# 等待应用启动 -sleep 5 - -# 测试健康检查端点 -curl http://localhost:3000/api/health - -# 预期响应: -# {"status":"ok"} 或类似的成功响应 - -# 查看容器日志 -docker logs test-automation - -# 停止容器 -docker stop test-automation -docker rm test-automation -``` - -### 6. 完整测试场景 - -```bash -#!/bin/bash - -# 颜色定义 -GREEN='\033[0;32m' -RED='\033[0;31m' -NC='\033[0m' - -echo "==========================================" -echo "Docker 构建修复验证测试" -echo "==========================================" - -# 构建镜像 -echo -e "\n${GREEN}[1]${NC} 构建 Docker 镜像..." -if docker build -t automation-platform:test -f deployment/Dockerfile . > /tmp/docker-build.log 2>&1; then - echo -e "${GREEN}✓ 构建成功${NC}" -else - echo -e "${RED}✗ 构建失败${NC}" - tail -50 /tmp/docker-build.log - exit 1 -fi - -# 验证 TypeScript -echo -e "\n${GREEN}[2]${NC} 验证 TypeScript 安装..." -if docker run --rm automation-platform:test npm list typescript > /tmp/ts-check.log 2>&1; then - if grep -q "typescript@5.3.3" /tmp/ts-check.log; then - echo -e "${GREEN}✓ TypeScript 已安装${NC}" - else - echo -e "${RED}✗ TypeScript 版本不匹配${NC}" - cat /tmp/ts-check.log - exit 1 - fi -else - echo -e "${RED}✗ 无法检查 TypeScript${NC}" - cat /tmp/ts-check.log - exit 1 -fi - -# 验证后端编译产物 -echo -e "\n${GREEN}[3]${NC} 验证后端编译产物..." -if docker run --rm automation-platform:test test -f dist/server/index.js; then - echo -e "${GREEN}✓ 后端编译产物存在${NC}" -else - echo -e "${RED}✗ 后端编译产物缺失${NC}" - exit 1 -fi - -# 验证前端资源 -echo -e "\n${GREEN}[4]${NC} 验证前端资源..." -if docker run --rm automation-platform:test test -f dist/index.html && \ - docker run --rm automation-platform:test test -d dist/assets; then - echo -e "${GREEN}✓ 前端资源完整${NC}" -else - echo -e "${RED}✗ 前端资源缺失${NC}" - exit 1 -fi - -# 启动容器并测试 -echo -e "\n${GREEN}[5]${NC} 启动容器并测试应用..." -docker run -d -p 3000:3000 --name test-app automation-platform:test > /dev/null 2>&1 -sleep 3 - -if curl -s http://localhost:3000/api/health > /tmp/health-check.log 2>&1; then - echo -e "${GREEN}✓ 应用运行正常${NC}" - cat /tmp/health-check.log -else - echo -e "${RED}✗ 健康检查失败${NC}" - docker logs test-app - exit 1 -fi - -# 清理 -docker stop test-app > /dev/null 2>&1 -docker rm test-app > /dev/null 2>&1 -docker rmi automation-platform:test > /dev/null 2>&1 - -echo -e "\n${GREEN}==========================================" -echo "所有测试通过! ✓" -echo "==========================================${NC}" -``` - -## 常见问题排查 - -### Q: npm ci 仍然失败? - -**A**: 检查 package-lock.json 是否与 package.json 匹配。运行: - -```bash -npm install # 更新 package-lock.json -git add package-lock.json -git commit -m "Update package-lock.json" -``` - -然后重新构建镜像。 - -### Q: TypeScript 仍然缺失? - -**A**: 检查 Dockerfile 中的 `|| npm install --verbose` 是否已被添加。使用以下命令验证: - -```bash -grep "npm install --verbose" deployment/Dockerfile -# 应该看到 3 行结果 -``` - -### Q: 构建速度很慢? - -**A**: 这是因为回退机制可能在执行 `npm install`。这是正常的,后续构建会从 Docker 缓存中受益。 - -### Q: 镜像大小比预期大? - -**A**: 确保 `npm prune --omit=dev` 在生产依赖阶段被正确执行: - -```bash -docker run --rm automation-platform:latest npm list --all | wc -l -# 应该只列出生产依赖 -``` - -## 性能基准 - -| 阶段 | 时间 | 说明 | -|-----|------|------| -| 前端构建 | 60-120s | 取决于依赖安装 | -| 后端编译 | 30-60s | TypeScript 编译 | -| 生产依赖 | 60-120s | 完整安装 + 精简 | -| 总耗时 | ~3-5 分钟 | 首次构建 | - -## Docker 构建命令参考 - -```bash -# 基础构建 -docker build -t automation-platform:latest -f deployment/Dockerfile . - -# 不使用缓存(完全重新构建) -docker build --no-cache -t automation-platform:latest -f deployment/Dockerfile . - -# 显示构建详细过程 -docker build --progress=plain -t automation-platform:latest -f deployment/Dockerfile . - -# 构建并指定平台(用于跨平台构建) -docker buildx build --platform linux/amd64 -t automation-platform:latest -f deployment/Dockerfile . -``` - -## 后续改进建议 - -1. 在 GitHub Actions/GitLab CI 中添加 Docker 构建测试 -2. 添加 `.dockerignore` 文件优化上下文大小 -3. 考虑使用 docker buildx 构建多平台镜像 -4. 定期更新 Node Alpine 基础镜像版本 diff --git a/Jenkinsfile b/Jenkinsfile index 1c65127..ad1414c 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -11,7 +11,7 @@ pipeline { } environment { - PLATFORM_API_URL = 'http://117.72.182.23:3000' + PLATFORM_API_URL = 'http://autotest.wiac.xyz' PYTHON_ENV = "${WORKSPACE}/venv" } @@ -28,12 +28,12 @@ pipeline { // 标记执行开始(可选) if (params.RUN_ID) { - sh ''' - curl -X POST "${PLATFORM_API_URL}/api/executions/${RUN_ID}/start" \ - -H "Content-Type: application/json" \ - --connect-timeout 5 \ - --max-time 10 || echo "⚠️ 标记执行开始失败,继续处理" - ''' + sh """ + curl -X POST "${PLATFORM_API_URL}/api/executions/${params.RUN_ID}/start" \\ + -H 'Content-Type: application/json' \\ + --connect-timeout 5 \\ + --max-time 10 || echo '⚠️ 标记执行开始失败,继续处理' + """ } } } @@ -50,7 +50,7 @@ pipeline { sh 'git pull origin main' } } else { - sh 'git clone ${params.REPO_URL} test-cases' + sh "git clone ${params.REPO_URL} test-cases" } } else { echo "⚠️ 警告:REPO_URL 未设置,跳过代码检出" @@ -64,23 +64,34 @@ pipeline { steps { script { echo "准备Python环境..." - - sh ''' - cd test-cases - + + // 定时触发且无参数时,跳过本阶段 + if (!params.RUN_ID && !params.SCRIPT_PATHS && !params.MARKER) { + echo "⚠️ 未传入执行参数(可能是定时触发),跳过准备环境" + return + } + + def testDir = params.REPO_URL ? 'test-cases' : '.' + sh """ + if [ ! -d "${testDir}" ]; then + echo "❌ 测试目录 '${testDir}' 不存在,请确认 REPO_URL 或代码检出是否成功" + exit 1 + fi + cd ${testDir} + # 创建虚拟环境(如果不存在) if [ ! -d "${PYTHON_ENV}" ]; then python3 -m venv ${PYTHON_ENV} fi - + # 激活虚拟环境并安装依赖 source ${PYTHON_ENV}/bin/activate pip install -q pytest pytest-json-report - + # 列出可用的用例 echo "可用的测试文件:" find . -name "test_*.py" -o -name "*_test.py" | head -20 - ''' + """ } } } @@ -88,29 +99,31 @@ pipeline { stage('执行测试') { steps { script { + // 无参数时跳过 + if (!params.RUN_ID && !params.SCRIPT_PATHS && !params.MARKER) { + echo "⚠️ 未传入执行参数,跳过执行测试" + return + } + + def testDir = params.REPO_URL ? 'test-cases' : '.' def scriptPaths = params.SCRIPT_PATHS def marker = params.MARKER def testCommand = "source ${PYTHON_ENV}/bin/activate && " if (scriptPaths) { - // 执行指定的脚本路径 def paths = scriptPaths.split(',') testCommand += "pytest ${paths.join(' ')}" } else if (marker) { - // 使用marker标记执行 testCommand += "pytest -m ${marker}" } else { - // 执行所有测试 testCommand += "pytest" } - - // 添加报告输出参数 testCommand += " --json-report --json-report-file=test-report.json -v" - sh ''' - cd test-cases - ''' + testCommand + ''' || true - ''' + sh """ + cd ${testDir} + ${testCommand} || true + """ } } } @@ -119,18 +132,21 @@ pipeline { steps { script { echo "收集测试结果..." - - sh ''' - cd test-cases - - # 如果生成了报告文件,解析结果 + + if (!params.RUN_ID && !params.SCRIPT_PATHS && !params.MARKER) { + echo "⚠️ 未传入执行参数,跳过收集结果" + return + } + + def testDir = params.REPO_URL ? 'test-cases' : '.' + sh """ + cd ${testDir} if [ -f "test-report.json" ]; then cat test-report.json else - # 生成默认的结果 - echo "未生成详细报告,生成默认结果" + echo "未生成详细报告" fi - ''' + """ } } } @@ -138,58 +154,8 @@ pipeline { stage('回调平台') { steps { script { - echo "回调测试结果到平台..." - - sh ''' - cd test-cases - - # 解析测试结果(示例) - if [ -f "test-report.json" ]; then - TOTAL=$(jq '.summary.total' test-report.json || echo "0") - PASSED=$(jq '.summary.passed' test-report.json || echo "0") - FAILED=$(jq '.summary.failed' test-report.json || echo "0") - SKIPPED=$(jq '.summary.skipped' test-report.json || echo "0") - else - TOTAL=0 - PASSED=0 - FAILED=0 - SKIPPED=0 - fi - - # 计算执行时长 - BUILD_DURATION_MS=$((BUILD_DURATION * 1000)) - - # 确定状态 - if [ $FAILED -eq 0 ]; then - STATUS="success" - else - STATUS="failed" - fi - - echo "测试结果汇总:" - echo " 总数: $TOTAL" - echo " 通过: $PASSED" - echo " 失败: $FAILED" - echo " 跳过: $SKIPPED" - echo " 状态: $STATUS" - echo " 耗时: ${BUILD_DURATION_MS}ms" - - # 回调到平台 - if [ ! -z "${CALLBACK_URL}" ]; then - curl -X POST "${CALLBACK_URL}" \ - -H "Content-Type: application/json" \ - -d "{ - \"runId\": ${RUN_ID}, - \"status\": \"$STATUS\", - \"passedCases\": $PASSED, - \"failedCases\": $FAILED, - \"skippedCases\": $SKIPPED, - \"durationMs\": $BUILD_DURATION_MS, - \"buildUrl\": \"${BUILD_URL}\" - }" \ - || echo "回调请求失败,但继续处理" - fi - ''' + // 回调由 post { always } 统一处理,此阶段仅做日志记录 + echo "✅ 测试执行完成,回调将在 post 阶段统一处理" } } } @@ -276,6 +242,8 @@ pipeline { // 最终回调 - 确保状态同步 if (params.RUN_ID) { echo "========== 最终回调 ==========" + // CALLBACK_URL 由服务端构造,已包含完整路径(含 /api/jenkins/callback) + // 若未传入则使用平台默认回调地址 def callbackUrl = params.CALLBACK_URL ?: "${env.PLATFORM_API_URL}/api/jenkins/callback" def finalStatus = currentBuild.result == 'SUCCESS' ? 'success' : 'failed' def duration = currentBuild.duration ?: 0 @@ -287,17 +255,13 @@ pipeline { // 使用 curl 进行回调(简化方案) try { + def failedCount = (currentBuild.result == 'SUCCESS') ? 0 : 1 sh """ - curl -X POST '${callbackUrl}' \ - -H 'Content-Type: application/json' \ - -d '{ - "runId": ${params.RUN_ID}, - "status": "${finalStatus}", - "passedCases": 0, - "failedCases": ${currentBuild.result == 'SUCCESS' ? 0 : 1}, - "skippedCases": 0, - "durationMs": ${duration} - }' \ + curl -X POST '${callbackUrl}' \\ + -H 'Content-Type: application/json' \\ + --connect-timeout 10 \\ + --max-time 30 \\ + -d '{"runId": ${params.RUN_ID}, "status": "${finalStatus}", "passedCases": 0, "failedCases": ${failedCount}, "skippedCases": 0, "durationMs": ${duration}}' \\ || echo '❌ curl 回调失败' """ echo "✅ 回调成功" @@ -324,20 +288,14 @@ pipeline { // 回调平台,标记为失败 if (params.RUN_ID && params.CALLBACK_URL) { def duration = currentBuild.duration ?: 0 - + // CALLBACK_URL 由服务端构造,已包含完整路径(含 /api/jenkins/callback) sh """ echo "正在回调失败状态到平台..." - curl -X POST "${params.CALLBACK_URL}" \ - -H "Content-Type: application/json" \ - -d '{ - "runId": ${params.RUN_ID}, - "status": "failed", - "passedCases": 0, - "failedCases": 0, - "skippedCases": 0, - "durationMs": ${duration}, - "buildUrl": "${BUILD_URL}" - }' \ + curl -X POST '${params.CALLBACK_URL}' \\ + -H 'Content-Type: application/json' \\ + --connect-timeout 10 \\ + --max-time 30 \\ + -d '{"runId": ${params.RUN_ID}, "status": "failed", "passedCases": 0, "failedCases": 0, "skippedCases": 0, "durationMs": ${duration}, "buildUrl": "${BUILD_URL}"}' \\ || echo "失败回调请求失败,但继续处理" """ } diff --git a/Jenkinsfile.bak b/Jenkinsfile.bak deleted file mode 100644 index 96040dd..0000000 --- a/Jenkinsfile.bak +++ /dev/null @@ -1,419 +0,0 @@ -pipeline { - agent any - - parameters { - string(name: 'RUN_ID', description: '执行批次ID', defaultValue: '') - string(name: 'CASE_IDS', description: '用例ID列表(JSON)', defaultValue: '[]') - string(name: 'SCRIPT_PATHS', description: '脚本路径(逗号分隔)', defaultValue: '') - string(name: 'CALLBACK_URL', description: '回调URL', defaultValue: '') - string(name: 'MARKER', description: 'Pytest marker标记', defaultValue: '') - string(name: 'REPO_URL', description: '测试用例仓库URL', defaultValue: '') - } - - environment { - GIT_CREDENTIALS = credentials('git-credentials') - PLATFORM_API_URL = 'http://localhost:3000' - PYTHON_ENV = "${WORKSPACE}/venv" - } - - stages { - stage('准备') { - steps { - script { - echo "========== 执行信息 ==========" - echo "运行ID: ${params.RUN_ID}" - echo "用例IDs: ${params.CASE_IDS}" - echo "脚本路径: ${params.SCRIPT_PATHS}" - echo "回调地址: ${params.CALLBACK_URL}" - echo "===============================" - - // 标记执行开始(可选) - if (params.RUN_ID) { - sh ''' - curl -X POST "${PLATFORM_API_URL}/api/executions/${RUN_ID}/start" \ - -H "Content-Type: application/json" - ''' - } - } - } - } - - stage('检出代码') { - steps { - script { - echo "正在克隆/更新测试用例仓库..." - - if (params.REPO_URL) { - if (fileExists('test-cases')) { - dir('test-cases') { - sh 'git pull origin main' - } - } else { - sh 'git clone ${params.REPO_URL} test-cases' - } - } else { - echo "⚠️ 警告:REPO_URL 未设置,跳过代码检出" - echo "使用默认的测试用例目录" - } - } - } - } - - stage('准备环境') { - steps { - script { - echo "准备Python环境..." - - sh ''' - cd test-cases - - # 创建虚拟环境(如果不存在) - if [ ! -d "${PYTHON_ENV}" ]; then - python3 -m venv ${PYTHON_ENV} - fi - - # 激活虚拟环境并安装依赖 - source ${PYTHON_ENV}/bin/activate - pip install -q pytest pytest-json-report - - # 列出可用的用例 - echo "可用的测试文件:" - find . -name "test_*.py" -o -name "*_test.py" | head -20 - ''' - } - } - } - - stage('执行测试') { - steps { - script { - def scriptPaths = params.SCRIPT_PATHS - def marker = params.MARKER - def testCommand = "source ${PYTHON_ENV}/bin/activate && " - - if (scriptPaths) { - // 执行指定的脚本路径 - def paths = scriptPaths.split(',') - testCommand += "pytest ${paths.join(' ')}" - } else if (marker) { - // 使用marker标记执行 - testCommand += "pytest -m ${marker}" - } else { - // 执行所有测试 - testCommand += "pytest" - } - - // 添加报告输出参数 - testCommand += " --json-report --json-report-file=test-report.json -v" - - sh ''' - cd test-cases - ''' + testCommand + ''' || true - ''' - } - } - } - - stage('收集结果') { - steps { - script { - echo "收集测试结果..." - - sh ''' - cd test-cases - - # 如果生成了报告文件,解析结果 - if [ -f "test-report.json" ]; then - cat test-report.json - else - # 生成默认的结果 - echo "未生成详细报告,生成默认结果" - fi - ''' - } - } - } - - stage('回调平台') { - steps { - script { - echo "回调测试结果到平台..." - - sh ''' - cd test-cases - - # 解析测试结果(示例) - if [ -f "test-report.json" ]; then - TOTAL=$(jq '.summary.total' test-report.json || echo "0") - PASSED=$(jq '.summary.passed' test-report.json || echo "0") - FAILED=$(jq '.summary.failed' test-report.json || echo "0") - SKIPPED=$(jq '.summary.skipped' test-report.json || echo "0") - else - TOTAL=0 - PASSED=0 - FAILED=0 - SKIPPED=0 - fi - - # 计算执行时长 - BUILD_DURATION_MS=$((BUILD_DURATION * 1000)) - - # 确定状态 - if [ $FAILED -eq 0 ]; then - STATUS="success" - else - STATUS="failed" - fi - - echo "测试结果汇总:" - echo " 总数: $TOTAL" - echo " 通过: $PASSED" - echo " 失败: $FAILED" - echo " 跳过: $SKIPPED" - echo " 状态: $STATUS" - echo " 耗时: ${BUILD_DURATION_MS}ms" - - # 回调到平台 - if [ ! -z "${CALLBACK_URL}" ]; then - curl -X POST "${CALLBACK_URL}" \ - -H "Content-Type: application/json" \ - -d "{ - \"runId\": ${RUN_ID}, - \"status\": \"$STATUS\", - \"passedCases\": $PASSED, - \"failedCases\": $FAILED, - \"skippedCases\": $SKIPPED, - \"durationMs\": $BUILD_DURATION_MS, - \"buildUrl\": \"${BUILD_URL}\" - }" \ - || echo "回调请求失败,但继续处理" - fi - ''' - } - } - } - - stage('构建镜像') { - when { - expression { return currentBuild.result == 'SUCCESS' } - } - steps { - script { - echo "构建Docker镜像并推送到阿里云容器镜像服务..." - - def dockerRegistry = "crpi-dytkl1o45qyeksph.cn-hangzhou.personal.cr.aliyuncs.com" - def imageRepo = "caijinwei/auto_test" - def imageTag = "${BUILD_NUMBER}" - def fullImageName = "${dockerRegistry}/${imageRepo}:${imageTag}" - - try { - // 1. 登录阿里云容器镜像服务 - withCredentials([usernamePassword(credentialsId: 'aliyun-docker', usernameVariable: 'DOCKER_USERNAME', passwordVariable: 'DOCKER_PASSWORD')]) { - sh """ - echo "登录阿里云容器镜像服务..." - docker login --username=${DOCKER_USERNAME} --password=${DOCKER_PASSWORD} ${dockerRegistry} - """ - } - - // 2. 构建Docker镜像 - echo "构建Docker镜像..." - sh """ - docker build -t ${imageRepo}:${imageTag} . - """ - - // 3. 标签镜像 - echo "为阿里云仓库标签镜像..." - sh """ - docker tag ${imageRepo}:${imageTag} ${fullImageName} - """ - - // 4. 推送镜像到阿里云 - echo "推送镜像到阿里云容器镜像服务..." - sh """ - docker push ${fullImageName} - """ - - echo "✅ 镜像构建和推送成功: ${fullImageName}" - - // 5. 推送到GitHub(强制推送) - echo "推送代码到GitHub..." - withCredentials([usernamePassword(credentialsId: 'git-credentials', usernameVariable: 'GIT_USER', passwordVariable: 'GIT_PASS')]) { - sh """ - git push https://${GIT_USER}:${GIT_PASS}@github.com/ImAcaiy/Automation_Platform.git HEAD:master --force - echo "✅ GitHub推送成功" - """ - } - } catch (Exception e) { - echo "❌ 镜像构建或推送失败: ${e.message}" - currentBuild.result = 'FAILURE' - throw e - } - } - } - } - } - - post { - always { - script { - echo "清理环境..." - - try { - archiveArtifacts artifacts: 'test-cases/test-report.json', allowEmptyArchive: true, fingerprint: true - echo "测试报告已归档" - } catch (Exception e) { - echo "归档测试报告失败: ${e.message}" - } - - try { - junit allowEmptyResults: true, testResults: '**/test-cases/junit.xml,**/test-cases/.pytest_cache/**/junit.xml' - } catch (Exception e) { - echo "JUnit报告处理失败: ${e.message}" - } - - // 最终回调 - 确保状态同步 - if (params.RUN_ID) { - echo "========== 最终回调 ==========" - def callbackUrl = params.CALLBACK_URL ?: "${env.PLATFORM_API_URL}/api/jenkins/callback" - def finalStatus = currentBuild.result == 'SUCCESS' ? 'success' : 'failed' - - echo "回调地址: ${callbackUrl}" - echo "运行ID: ${params.RUN_ID}" - echo "最终状态: ${finalStatus}" - - // 尝试使用 httpRequest 插件(如果可用) - try { - def callbackData = [ - runId: params.RUN_ID.toInteger(), - status: finalStatus, - passedCases: 0, - failedCases: currentBuild.result == 'SUCCESS' ? 0 : 1, - skippedCases: 0, - durationMs: currentBuild.durationMillis ?: 0 - ] - - httpRequest( - url: callbackUrl, - httpMode: 'POST', - contentType: 'APPLICATION_JSON', - requestBody: groovy.json.JsonOutput.toJson(callbackData), - validResponseCodes: '200:299', - ignoreSslErrors: true - ) - echo "✅ httpRequest 回调成功" - } catch (Exception e) { - echo "⚠️ httpRequest 插件不可用或失败: ${e.message}" - echo "使用 curl 进行回调..." - - // 回退到 curl - sh """ - curl -X POST '${callbackUrl}' \ - -H 'Content-Type: application/json' \ - -d '{ - "runId": ${params.RUN_ID}, - "status": "${finalStatus}", - "passedCases": 0, - "failedCases": ${currentBuild.result == 'SUCCESS' ? 0 : 1}, - "skippedCases": 0, - "durationMs": ${currentBuild.durationMillis ?: 0} - }' \ - || echo '❌ curl 回调失败' - """ - } - echo "===============================" - } - } - } - - success { - script { - echo "✅ Pipeline执行成功" - } - } - - failure { - script { - echo "❌ Pipeline执行失败" - - // 回调平台,标记为失败 - if (params.RUN_ID && params.CALLBACK_URL) { - sh ''' - BUILD_DURATION_MS=$((BUILD_DURATION * 1000)) - - echo "正在回调失败状态到平台..." - curl -X POST "${CALLBACK_URL}" \ - -H "Content-Type: application/json" \ - -d "{ - \"runId\": ${RUN_ID}, - \"status\": \"failed\", - \"passedCases\": 0, - \"failedCases\": 0, - \"skippedCases\": 0, - \"durationMs\": $BUILD_DURATION_MS, - \"buildUrl\": \"${BUILD_URL}\" - }" \ - || echo "失败回调请求失败,但继续处理" - ''' - } - } - } - } -} - when { - expression { return currentBuild.result == 'SUCCESS' } - } - steps { - script { - echo "构建Docker镜像并推送到阿里云容器镜像服务..." - - def dockerRegistry = "crpi-dytkl1o45qyeksph.cn-hangzhou.personal.cr.aliyuncs.com" - def imageRepo = "caijinwei/auto_test" - def imageTag = "${BUILD_NUMBER}" - def fullImageName = "${dockerRegistry}/${imageRepo}:${imageTag}" - - try { - // 1. 登录阿里云容器镜像服务 - withCredentials([usernamePassword(credentialsId: 'aliyun-docker', usernameVariable: 'DOCKER_USERNAME', passwordVariable: 'DOCKER_PASSWORD')]) { - sh """ - echo "登录阿里云容器镜像服务..." - docker login --username=${DOCKER_USERNAME} --password=${DOCKER_PASSWORD} ${dockerRegistry} - """ - } - - // 2. 构建Docker镜像 - echo "构建Docker镜像..." - sh """ - docker build -t ${imageRepo}:${imageTag} . - """ - - // 3. 标签镜像 - echo "为阿里云仓库标签镜像..." - sh """ - docker tag ${imageRepo}:${imageTag} ${fullImageName} - """ - - // 4. 推送镜像到阿里云 - echo "推送镜像到阿里云容器镜像服务..." - sh """ - docker push ${fullImageName} - """ - - echo "✅ 镜像构建和推送成功: ${fullImageName}" - - // 5. 推送到GitHub(强制推送) - echo "推送代码到GitHub..." - withCredentials([usernamePassword(credentialsId: 'git-credentials', usernameVariable: 'GIT_USER', passwordVariable: 'GIT_PASS')]) { - sh """ - git push https://${GIT_USER}:${GIT_PASS}@github.com/ImAcaiy/Automation_Platform.git HEAD:master --force - echo "✅ GitHub推送成功" - """ - } - } catch (Exception e) { - echo "❌ 镜像构建或推送失败: ${e.message}" - currentBuild.result = 'FAILURE' - throw e - } - } - } - } - } diff --git a/Jenkinsfile.final b/Jenkinsfile.final deleted file mode 100644 index eb2bb80..0000000 --- a/Jenkinsfile.final +++ /dev/null @@ -1,335 +0,0 @@ -pipeline { - agent any - - parameters { - string(name: 'RUN_ID', description: '执行批次ID', defaultValue: '') - string(name: 'SCRIPT_PATHS', description: '脚本路径(逗号分隔)', defaultValue: '') - string(name: 'CALLBACK_URL', description: '回调URL', defaultValue: '') - string(name: 'MARKER', description: 'Pytest marker标记', defaultValue: '') - string(name: 'REPO_URL', description: '测试用例仓库URL', defaultValue: '') - string(name: 'JENKINS_API_KEY', description: 'Jenkins API密钥用于回调认证', defaultValue: '') - } - - environment { - PLATFORM_API_URL = 'http://localhost:3000' - PYTHON_ENV = "${WORKSPACE}/venv" - } - - stages { - stage('准备') { - steps { - script { - echo "========== 执行信息 ==========" - echo "运行ID: ${params.RUN_ID}" - echo "脚本路径: ${params.SCRIPT_PATHS}" - echo "回调地址: ${params.CALLBACK_URL}" - echo "===============================" - - // 标记执行开始(可选) - if (params.RUN_ID) { - sh ''' - curl -X POST "${PLATFORM_API_URL}/api/executions/${RUN_ID}/start" \ - -H "Content-Type: application/json" - ''' - } - } - } - } - - stage('检出代码') { - steps { - script { - echo "正在克隆/更新测试用例仓库..." - - if (params.REPO_URL) { - if (fileExists('test-cases')) { - dir('test-cases') { - sh 'git pull origin main' - } - } else { - sh 'git clone ${params.REPO_URL} test-cases' - } - } else { - echo "⚠️ 警告:REPO_URL 未设置,跳过代码检出" - echo "使用默认的测试用例目录" - } - } - } - } - - stage('准备环境') { - steps { - script { - echo "准备Python环境..." - - sh ''' - cd test-cases - - # 创建虚拟环境(如果不存在) - if [ ! -d "${PYTHON_ENV}" ]; then - python3 -m venv ${PYTHON_ENV} - fi - - # 激活虚拟环境并安装依赖 - source ${PYTHON_ENV}/bin/activate - pip install -q pytest pytest-json-report - - # 列出可用的用例 - echo "可用的测试文件:" - find . -name "test_*.py" -o -name "*_test.py" | head -20 - ''' - } - } - } - - stage('执行测试') { - steps { - script { - def scriptPaths = params.SCRIPT_PATHS - def marker = params.MARKER - def testCommand = "source ${PYTHON_ENV}/bin/activate && " - - if (scriptPaths) { - // 执行指定的脚本路径 - def paths = scriptPaths.split(',') - testCommand += "pytest ${paths.join(' ')}" - } else if (marker) { - // 使用marker标记执行 - testCommand += "pytest -m ${marker}" - } else { - // 执行所有测试 - testCommand += "pytest" - } - - // 添加报告输出参数 - testCommand += " --json-report --json-report-file=test-report.json -v" - - sh ''' - cd test-cases - ''' + testCommand + ''' || true - ''' - } - } - } - - stage('回调平台') { - steps { - script { - echo "回调测试结果到平台..." - - // 使用Jenkins native API获取执行时长 - def durationMs = currentBuild.duration ?: 0 - def callbackUrl = params.CALLBACK_URL ?: "${env.PLATFORM_API_URL}/api/jenkins/callback" - - try { - // 解析测试结果 - def testReport = null - def total = 0 - def passed = 0 - def failed = 0 - def skipped = 0 - def status = "success" - - // 检查测试报告文件是否存在 - if (fileExists('test-cases/test-report.json')) { - echo "✅ 找到测试报告文件,解析结果..." - def reportContent = sh( - script: ''' - cd test-cases - if command -v jq >/dev/null 2>&1; then - jq -c '.summary' test-report.json 2>/dev/null || echo '{"total":0,"passed":0,"failed":0,"skipped":0}' - else - echo '{"total":0,"passed":0,"failed":0,"skipped":0}' - fi - ''', - returnStdout: true - ).trim() - - testReport = readJSON text: reportContent - total = testReport.total ?: 0 - passed = testReport.passed ?: 0 - failed = testReport.failed ?: 0 - skipped = testReport.skipped ?: 0 - status = (failed == 0) ? "success" : "failed" - } else { - echo "⚠️ 未找到测试报告文件,使用默认值" - // 如果没有报告文件,根据构建状态推断 - def buildResult = currentBuild.result ?: 'SUCCESS' - status = (buildResult == 'SUCCESS') ? 'success' : 'failed' - failed = (status == 'failed') ? 1 : 0 - } - - echo "测试结果汇总:" - echo " 总数: ${total}" - echo " 通过: ${passed}" - echo " 失败: ${failed}" - echo " 跳过: ${skipped}" - echo " 状态: ${status}" - echo " 耗时: ${durationMs}ms" - - // 回调到平台 - if (params.RUN_ID && callbackUrl) { - echo "发送回调到: ${callbackUrl}" - def response = sh( - script: """ - curl -X POST '${callbackUrl}' \ - -H 'Content-Type: application/json' \ - -H 'X-Api-Key: ${params.JENKINS_API_KEY}' \ - -w '\\n%{http_code}' \ - -d '{ - "runId": ${params.RUN_ID}, - "status": "${status}", - "passedCases": ${passed}, - "failedCases": ${failed}, - "skippedCases": ${skipped}, - "durationMs": ${durationMs}, - "buildUrl": "${BUILD_URL}" - }' - """, - returnStdout: true - ).trim() - - def lines = response.split('\n') - def httpCode = lines[-1] - if (httpCode == '200' || httpCode == '201') { - echo "✅ 回调成功 (HTTP ${httpCode})" - } else { - echo "⚠️ 回调返回非成功状态码: HTTP ${httpCode}" - echo "响应内容: ${lines[0..-2].join('\n')}" - } - } else { - echo "⚠️ 跳过回调: RUN_ID=${params.RUN_ID}, CALLBACK_URL=${callbackUrl}" - } - } catch (Exception e) { - echo "❌ 回调处理失败: ${e.message}" - echo "堆栈跟踪: ${e.toString()}" - // 不抛出异常,允许pipeline继续执行 - } - } - } - } - - // ✅ 新增: 归档报告阶段 - 在 stages 中执行,有工作空间上下文 - stage('归档报告') { - steps { - script { - echo "归档测试报告..." - - try { - archiveArtifacts artifacts: 'test-cases/test-report.json', - allowEmptyArchive: true, - fingerprint: true - echo "✅ 测试报告已归档" - } catch (Exception e) { - echo "⚠️ 归档测试报告失败: ${e.message}" - } - - try { - junit allowEmptyResults: true, - testResults: '**/test-cases/junit.xml,**/test-cases/.pytest_cache/**/junit.xml' - echo "✅ JUnit报告已发布" - } catch (Exception e) { - echo "⚠️ JUnit报告处理失败: ${e.message}" - } - } - } - } - - stage('构建镜像') { - when { - expression { return currentBuild.result == 'SUCCESS' } - } - steps { - script { - echo "构建Docker镜像并推送到阿里云容器镜像服务..." - - def dockerRegistry = "crpi-dytkl1o45qyeksph.cn-hangzhou.personal.cr.aliyuncs.com" - def imageRepo = "caijinwei/auto_test" - def imageTag = "${BUILD_NUMBER}" - def fullImageName = "${dockerRegistry}/${imageRepo}:${imageTag}" - - try { - // 1. 登录阿里云容器镜像服务 - withCredentials([usernamePassword(credentialsId: 'aliyun-docker', usernameVariable: 'DOCKER_USERNAME', passwordVariable: 'DOCKER_PASSWORD')]) { - sh """ - echo "登录阿里云容器镜像服务..." - docker login --username=${DOCKER_USERNAME} --password=${DOCKER_PASSWORD} ${dockerRegistry} - """ - } - - // 2. 构建Docker镜像 - echo "构建Docker镜像..." - sh """ - docker build -t ${imageRepo}:${imageTag} . - """ - - // 3. 标签镜像 - echo "为阿里云仓库标签镜像..." - sh """ - docker tag ${imageRepo}:${imageTag} ${fullImageName} - """ - - // 4. 推送镜像到阿里云 - echo "推送镜像到阿里云容器镜像服务..." - sh """ - docker push ${fullImageName} - """ - - echo "✅ 镜像构建和推送成功: ${fullImageName}" - - // 5. 推送到GitHub(强制推送) - echo "推送代码到GitHub..." - withCredentials([usernamePassword(credentialsId: 'git-credentials', usernameVariable: 'GIT_USER', passwordVariable: 'GIT_PASS')]) { - sh """ - git push https://${GIT_USER}:${GIT_PASS}@github.com/ImAcaiy/Automation_Platform.git HEAD:master --force - echo "✅ GitHub推送成功" - """ - } - } catch (Exception e) { - echo "❌ 镜像构建或推送失败: ${e.message}" - currentBuild.result = 'FAILURE' - throw e - } - } - } - } - } - - // ✅ post 块只做简单的日志输出,不做文件操作 - post { - always { - script { - echo "========== Pipeline 执行完成 ==========" - echo "构建编号: ${BUILD_NUMBER}" - echo "构建结果: ${currentBuild.result ?: 'SUCCESS'}" - echo "构建时长: ${currentBuild.duration ?: 0}ms" - echo "======================================" - } - } - - success { - script { - echo "✅ Pipeline执行成功" - } - } - - failure { - script { - echo "❌ Pipeline执行失败" - echo "请查看构建日志了解详情" - } - } - - unstable { - script { - echo "⚠️ Pipeline执行不稳定" - } - } - - aborted { - script { - echo "🛑 Pipeline被中止" - } - } - } -} diff --git a/Jenkinsfile.jenkins-ui b/Jenkinsfile.jenkins-ui deleted file mode 100644 index eadb886..0000000 --- a/Jenkinsfile.jenkins-ui +++ /dev/null @@ -1,349 +0,0 @@ -pipeline { - agent any - - parameters { - string(name: 'RUN_ID', description: '执行批次ID', defaultValue: '') - string(name: 'CASE_IDS', description: '用例ID列表(JSON)', defaultValue: '[]') - string(name: 'SCRIPT_PATHS', description: '脚本路径(逗号分隔)', defaultValue: '') - string(name: 'CALLBACK_URL', description: '回调URL', defaultValue: '') - string(name: 'MARKER', description: 'Pytest marker标记', defaultValue: '') - string(name: 'REPO_URL', description: '测试用例仓库URL', defaultValue: '') - } - - environment { - GIT_CREDENTIALS = credentials('git-credentials') - PLATFORM_API_URL = 'http://localhost:3000' - PYTHON_ENV = "${WORKSPACE}/venv" - } - - stages { - stage('准备') { - steps { - script { - echo "========== 执行信息 ==========" - echo "运行ID: ${params.RUN_ID}" - echo "用例IDs: ${params.CASE_IDS}" - echo "脚本路径: ${params.SCRIPT_PATHS}" - echo "回调地址: ${params.CALLBACK_URL}" - echo "===============================" - - // 标记执行开始(可选) - if (params.RUN_ID) { - sh ''' - curl -X POST "${PLATFORM_API_URL}/api/executions/${RUN_ID}/start" \ - -H "Content-Type: application/json" - ''' - } - } - } - } - - stage('检出代码') { - steps { - script { - echo "正在克隆/更新测试用例仓库..." - - if (params.REPO_URL) { - if (fileExists('test-cases')) { - dir('test-cases') { - sh 'git pull origin main' - } - } else { - sh 'git clone ${params.REPO_URL} test-cases' - } - } else { - echo "⚠️ 警告:REPO_URL 未设置,跳过代码检出" - echo "使用默认的测试用例目录" - } - } - } - } - - stage('准备环境') { - steps { - script { - echo "准备Python环境..." - - sh ''' - cd test-cases - - # 创建虚拟环境(如果不存在) - if [ ! -d "${PYTHON_ENV}" ]; then - python3 -m venv ${PYTHON_ENV} - fi - - # 激活虚拟环境并安装依赖 - source ${PYTHON_ENV}/bin/activate - pip install -q pytest pytest-json-report - - # 列出可用的用例 - echo "可用的测试文件:" - find . -name "test_*.py" -o -name "*_test.py" | head -20 - ''' - } - } - } - - stage('执行测试') { - steps { - script { - def scriptPaths = params.SCRIPT_PATHS - def marker = params.MARKER - def testCommand = "source ${PYTHON_ENV}/bin/activate && " - - if (scriptPaths) { - // 执行指定的脚本路径 - def paths = scriptPaths.split(',') - testCommand += "pytest ${paths.join(' ')}" - } else if (marker) { - // 使用marker标记执行 - testCommand += "pytest -m ${marker}" - } else { - // 执行所有测试 - testCommand += "pytest" - } - - // 添加报告输出参数 - testCommand += " --json-report --json-report-file=test-report.json -v" - - sh ''' - cd test-cases - ''' + testCommand + ''' || true - ''' - } - } - } - - stage('收集结果') { - steps { - script { - echo "收集测试结果..." - - sh ''' - cd test-cases - - # 如果生成了报告文件,解析结果 - if [ -f "test-report.json" ]; then - cat test-report.json - else - # 生成默认的结果 - echo "未生成详细报告,生成默认结果" - fi - ''' - } - } - } - - stage('回调平台') { - steps { - script { - echo "回调测试结果到平台..." - - sh ''' - cd test-cases - - # 解析测试结果(示例) - if [ -f "test-report.json" ]; then - TOTAL=$(jq '.summary.total' test-report.json || echo "0") - PASSED=$(jq '.summary.passed' test-report.json || echo "0") - FAILED=$(jq '.summary.failed' test-report.json || echo "0") - SKIPPED=$(jq '.summary.skipped' test-report.json || echo "0") - else - TOTAL=0 - PASSED=0 - FAILED=0 - SKIPPED=0 - fi - - # 计算执行时长 - BUILD_DURATION_MS=$((BUILD_DURATION * 1000)) - - # 确定状态 - if [ $FAILED -eq 0 ]; then - STATUS="success" - else - STATUS="failed" - fi - - echo "测试结果汇总:" - echo " 总数: $TOTAL" - echo " 通过: $PASSED" - echo " 失败: $FAILED" - echo " 跳过: $SKIPPED" - echo " 状态: $STATUS" - echo " 耗时: ${BUILD_DURATION_MS}ms" - - # 回调到平台 - if [ ! -z "${CALLBACK_URL}" ]; then - curl -X POST "${CALLBACK_URL}" \ - -H "Content-Type: application/json" \ - -H "X-Api-Key: ${JENKINS_API_KEY}" \ - -d "{ - \"runId\": ${RUN_ID}, - \"status\": \"$STATUS\", - \"passedCases\": $PASSED, - \"failedCases\": $FAILED, - \"skippedCases\": $SKIPPED, - \"durationMs\": $BUILD_DURATION_MS, - \"buildUrl\": \"${BUILD_URL}\" - }" \ - || echo "回调请求失败,但继续处理" - fi - ''' - } - } - } - - stage('构建镜像') { - when { - expression { return currentBuild.result == 'SUCCESS' } - } - steps { - script { - echo "构建Docker镜像并推送到阿里云容器镜像服务..." - - def dockerRegistry = "crpi-dytkl1o45qyeksph.cn-hangzhou.personal.cr.aliyuncs.com" - def imageRepo = "caijinwei/auto_test" - def imageTag = "${BUILD_NUMBER}" - def fullImageName = "${dockerRegistry}/${imageRepo}:${imageTag}" - - try { - // 1. 登录阿里云容器镜像服务 - withCredentials([usernamePassword(credentialsId: 'aliyun-docker', usernameVariable: 'DOCKER_USERNAME', passwordVariable: 'DOCKER_PASSWORD')]) { - sh """ - echo "登录阿里云容器镜像服务..." - docker login --username=${DOCKER_USERNAME} --password=${DOCKER_PASSWORD} ${dockerRegistry} - """ - } - - // 2. 构建Docker镜像 - echo "构建Docker镜像..." - sh """ - docker build -t ${imageRepo}:${imageTag} . - """ - - // 3. 标签镜像 - echo "为阿里云仓库标签镜像..." - sh """ - docker tag ${imageRepo}:${imageTag} ${fullImageName} - """ - - // 4. 推送镜像到阿里云 - echo "推送镜像到阿里云容器镜像服务..." - sh """ - docker push ${fullImageName} - """ - - echo "✅ 镜像构建和推送成功: ${fullImageName}" - - // 5. 推送到GitHub(强制推送) - echo "推送代码到GitHub..." - withCredentials([usernamePassword(credentialsId: 'git-credentials', usernameVariable: 'GIT_USER', passwordVariable: 'GIT_PASS')]) { - sh """ - git push https://${GIT_USER}:${GIT_PASS}@github.com/ImAcaiy/Automation_Platform.git HEAD:master --force - echo "✅ GitHub推送成功" - """ - } - } catch (Exception e) { - echo "❌ 镜像构建或推送失败: ${e.message}" - currentBuild.result = 'FAILURE' - throw e - } - } - } - } - } - - post { - always { - script { - echo "清理环境..." - - // 归档测试报告 - 不需要 node 块,因为 agent any 已经提供了工作空间 - try { - archiveArtifacts artifacts: 'test-cases/test-report.json', allowEmptyArchive: true, fingerprint: true - echo "测试报告已归档" - } catch (Exception e) { - echo "归档测试报告失败: ${e.message}" - } - - // JUnit 报告 - try { - junit allowEmptyResults: true, testResults: '**/test-cases/junit.xml,**/test-cases/.pytest_cache/**/junit.xml' - } catch (Exception e) { - echo "JUnit报告处理失败: ${e.message}" - } - - // 最终回调 - 确保状态同步 - if (params.RUN_ID) { - echo "========== 最终回调 ==========" - def callbackUrl = params.CALLBACK_URL ?: "${env.PLATFORM_API_URL}/api/jenkins/callback" - def finalStatus = currentBuild.result ?: 'SUCCESS' - def status = (finalStatus == 'SUCCESS') ? 'success' : 'failed' - def duration = currentBuild.duration ?: 0 - - echo "回调地址: ${callbackUrl}" - echo "运行ID: ${params.RUN_ID}" - echo "最终状态: ${status}" - echo "执行时长: ${duration}ms" - - // 使用 curl 进行回调(简化方案) - try { - sh """ - curl -X POST '${callbackUrl}' \ - -H 'Content-Type: application/json' \ - -H 'X-Api-Key: ${env.JENKINS_API_KEY}' \ - -d '{ - "runId": ${params.RUN_ID}, - "status": "${status}", - "passedCases": 0, - "failedCases": ${status == 'failed' ? 1 : 0}, - "skippedCases": 0, - "durationMs": ${duration} - }' \ - || echo '❌ curl 回调失败' - """ - echo "✅ 回调成功" - } catch (Exception e) { - echo "⚠️ 回调失败: ${e.message}" - } - echo "===============================" - } - } - } - - success { - script { - echo "✅ Pipeline执行成功" - } - } - - failure { - script { - echo "❌ Pipeline执行失败" - - // 回调平台,标记为失败 - if (params.RUN_ID && params.CALLBACK_URL) { - def duration = currentBuild.duration ?: 0 - - sh """ - echo "正在回调失败状态到平台..." - curl -X POST "${params.CALLBACK_URL}" \ - -H "Content-Type: application/json" \ - -H "X-Api-Key: ${env.JENKINS_API_KEY}" \ - -d '{ - "runId": ${params.RUN_ID}, - "status": "failed", - "passedCases": 0, - "failedCases": 0, - "skippedCases": 0, - "durationMs": ${duration}, - "buildUrl": "${BUILD_URL}" - }' \ - || echo "失败回调请求失败,但继续处理" - """ - } - } - } - } -} diff --git a/QUICK_FIX_REFERENCE.md b/QUICK_FIX_REFERENCE.md deleted file mode 100644 index 4dae9d8..0000000 --- a/QUICK_FIX_REFERENCE.md +++ /dev/null @@ -1,14 +0,0 @@ -# Docker 构建修复 - 快速参考 - -## 问题与解决 - -**问题**: Docker 构建时 npm ci 失败,TypeScript 无法安装 - -**解决**: 在 Dockerfile 三处位置添加 npm install 回退 - -修改位置: deployment/Dockerfile 第 7, 18, 28 行 - -## 快速测试 - -docker build -t automation-platform:latest -f deployment/Dockerfile . -docker run --rm automation-platform:latest npm list typescript diff --git a/deployment/.env.production b/deployment/.env.production index fce4e79..4fb3099 100644 --- a/deployment/.env.production +++ b/deployment/.env.production @@ -33,8 +33,8 @@ JENKINS_JOB_UI=ui-automation JENKINS_JOB_PERF=performance-automation # 网络配置 -JENKINS_ALLOWED_IPS=localhost,127.0.0.1,jenkins.wiac.xyz -API_CALLBACK_URL=http://117.72.182.23:3000 +JENKINS_ALLOWED_IPS=localhost,127.0.0.1,jenkins.wiac.xyz,162.14.123.200 +API_CALLBACK_URL=http://autotest.wiac.xyz # 调试配置 JENKINS_DEBUG_IP=true @@ -56,7 +56,7 @@ EXECUTION_MONITOR_RATE_LIMIT=100 # ============================================ # 其他配置 # ============================================ -CORS_ORIGIN=http://117.72.182.23:5173 +CORS_ORIGIN=http://autotest.wiac.xyz # ===== 优化配置(2026-02-10 添加)===== # 混合同步服务配置 CALLBACK_TIMEOUT=30000 @@ -72,4 +72,4 @@ EXECUTION_CLEANUP_INTERVAL=3600000 # WebSocket 配置(暂时禁用以测试轮询优化) WEBSOCKET_ENABLED=true -FRONTEND_URL=http://localhost:5173 +FRONTEND_URL=http://autotest.wiac.xyz diff --git a/deployment/DOCKER_SECRET_GUIDE.md b/deployment/DOCKER_SECRET_GUIDE.md deleted file mode 100644 index d0ddbbc..0000000 --- a/deployment/DOCKER_SECRET_GUIDE.md +++ /dev/null @@ -1,298 +0,0 @@ -# Docker Secrets 配置指南 - -## 问题说明 - -当你看到以下错误: -``` -JENKINS_TOKEN environment variable is required for Jenkins authentication. Jenkins integration may not work. -``` - -这是因为 Docker secrets 没有被正确挂载到容器中。Docker secrets 只能在以下场景中使用: -- Docker Swarm mode -- docker-compose - -**普通的 `docker run` 命令不支持 Docker secrets!** - ---- - -## ✅ 方案 1: 使用 docker-compose (推荐用于生产环境) - -### 步骤 1: 创建 secrets 文件目录 - -```bash -mkdir -p /root/Automation_Platform/deployment/secrets -cd /root/Automation_Platform/deployment/secrets -``` - -### 步骤 2: 创建 secret 文件 (每个 secret 一个文件) - -```bash -# 数据库密码 -echo "your_db_password_here" > db_password.txt - -# Jenkins Token -echo "your_jenkins_token_here" > jenkins_token.txt - -# Jenkins API Key -echo "your_jenkins_api_key_here" > jenkins_api_key.txt - -# Jenkins JWT Secret -echo "your_jenkins_jwt_secret_here" > jenkins_jwt_secret.txt - -# Jenkins Signature Secret -echo "your_jenkins_signature_secret_here" > jenkins_signature_secret.txt - -# JWT Secret -echo "your_jwt_secret_here" > jwt_secret.txt -``` - -### 步骤 3: 设置文件权限 - -```bash -chmod 600 /root/Automation_Platform/deployment/secrets/*.txt -``` - -### 步骤 4: 创建 .env 文件 (非敏感配置) - -```bash -cat > /root/Automation_Platform/.env << 'EOF' -# 应用配置 -NODE_ENV=production -PORT=3000 - -# 数据库配置 -DB_HOST=your_db_host -DB_PORT=3306 -DB_USER=your_db_user -DB_NAME=automation_test - -# Jenkins 配置 -JENKINS_URL=http://your-jenkins-url:8080 -JENKINS_USER=your_jenkins_user -JENKINS_JOB_NAME=automation-test-job - -# JWT 配置 -JWT_EXPIRES_IN=7d -EOF -``` - -### 步骤 5: 停止并删除旧容器 - -```bash -docker stop auto_test -docker rm auto_test -``` - -### 步骤 6: 使用 docker-compose 启动 - -```bash -cd /root/Automation_Platform -docker-compose -f deployment/docker-compose.yml up -d -``` - -### 步骤 7: 查看日志验证 - -```bash -docker logs -f automation-platform -``` - ---- - -## ✅ 方案 2: 使用环境变量直接运行 (简单快速) - -这种方式不使用 Docker secrets,直接通过环境变量传递敏感信息。 - -### 步骤 1: 停止并删除旧容器 - -```bash -docker stop auto_test -docker rm auto_test -``` - -### 步骤 2: 使用环境变量启动容器 - -```bash -docker run -d \ - --name auto_test \ - -p 3000:3000 \ - -e NODE_ENV=production \ - -e PORT=3000 \ - -e DB_HOST=your_db_host \ - -e DB_PORT=3306 \ - -e DB_USER=your_db_user \ - -e DB_PASSWORD=your_db_password \ - -e DB_NAME=automation_test \ - -e JENKINS_URL=http://your-jenkins-url:8080 \ - -e JENKINS_USER=your_jenkins_user \ - -e JENKINS_TOKEN=your_jenkins_token \ - -e JENKINS_API_KEY=your_jenkins_api_key \ - -e JENKINS_JWT_SECRET=your_jenkins_jwt_secret \ - -e JENKINS_SIGNATURE_SECRET=your_jenkins_signature_secret \ - -e JENKINS_JOB_NAME=automation-test-job \ - -e JWT_SECRET=your_jwt_secret \ - -e JWT_EXPIRES_IN=7d \ - ghcr.io/acai1998/automation-platform:latest -``` - -### 步骤 3: 查看日志验证 - -```bash -docker logs -f auto_test -``` - ---- - -## ✅ 方案 3: 使用 .env 文件 + docker run (推荐开发环境) - -### 步骤 1: 创建完整的 .env 文件 - -```bash -cat > /root/.env << 'EOF' -# 应用配置 -NODE_ENV=production -PORT=3000 - -# 数据库配置 -DB_HOST=your_db_host -DB_PORT=3306 -DB_USER=your_db_user -DB_PASSWORD=your_db_password -DB_NAME=automation_test - -# Jenkins 配置 -JENKINS_URL=http://your-jenkins-url:8080 -JENKINS_USER=your_jenkins_user -JENKINS_TOKEN=your_jenkins_token -JENKINS_API_KEY=your_jenkins_api_key -JENKINS_JWT_SECRET=your_jenkins_jwt_secret -JENKINS_SIGNATURE_SECRET=your_jenkins_signature_secret -JENKINS_JOB_NAME=automation-test-job - -# JWT 配置 -JWT_SECRET=your_jwt_secret -JWT_EXPIRES_IN=7d -EOF -``` - -### 步骤 2: 停止并删除旧容器 - -```bash -docker stop auto_test -docker rm auto_test -``` - -### 步骤 3: 使用 .env 文件启动容器 - -```bash -docker run -d \ - --name auto_test \ - -p 3000:3000 \ - --env-file /root/.env \ - ghcr.io/acai1998/automation-platform:latest -``` - -### 步骤 4: 查看日志验证 - -```bash -docker logs -f auto_test -``` - ---- - -## 🔍 验证配置是否成功 - -### 1. 检查容器是否正常运行 - -```bash -docker ps | grep auto_test -``` - -### 2. 检查应用日志 - -```bash -docker logs -f auto_test -``` - -如果配置成功,你应该看到: -- ✅ 没有 "JENKINS_TOKEN environment variable is required" 错误 -- ✅ 应用正常启动 -- ✅ 数据库连接成功 - -### 3. 测试健康检查端点 - -```bash -curl http://localhost:3000/api/health -``` - -### 4. 测试 Jenkins 认证 - -```bash -curl -X POST http://localhost:3000/api/jenkins/callback/test \ - -H "X-Api-Key: your_jenkins_api_key" \ - -H "Content-Type: application/json" \ - -d '{"testMessage": "hello"}' -``` - ---- - -## 🚨 清理之前创建的 Docker Secrets - -你之前创建的 Docker secrets 不会被使用(除非使用 Docker Swarm),可以删除: - -```bash -# 查看 secrets -docker secret ls - -# 删除 secrets (如果不需要) -docker secret rm db_password -docker secret rm jenkins_token -docker secret rm jenkins_api_key -docker secret rm jenkins_jwt_secret -docker secret rm jenkins_signature_secret -``` - ---- - -## 📊 三种方案对比 - -| 方案 | 安全性 | 复杂度 | 适用场景 | -|-----|-------|--------|---------| -| docker-compose + secrets 文件 | 🔒🔒🔒 高 | ⭐⭐⭐ 复杂 | 生产环境 | -| docker run + 环境变量 | 🔒 低 | ⭐ 简单 | 快速测试 | -| docker run + .env 文件 | 🔒🔒 中 | ⭐⭐ 中等 | 开发环境 | - ---- - -## 💡 推荐方案 - -- **生产环境**: 使用方案 1 (docker-compose + secrets) -- **开发/测试环境**: 使用方案 3 (docker run + .env 文件) -- **快速验证**: 使用方案 2 (docker run + 环境变量) - ---- - -## 🆘 常见问题 - -### Q1: 为什么我创建的 Docker secrets 没有生效? - -A: Docker secrets 只能在 Docker Swarm 模式或 docker-compose 中使用,普通的 `docker run` 命令不支持。 - -### Q2: 如何选择方案? - -A: -- 如果你需要高安全性和完整的编排功能 → 使用 docker-compose (方案 1) -- 如果你只是想快速启动测试 → 使用环境变量 (方案 2) -- 如果你想要便于管理又相对安全 → 使用 .env 文件 (方案 3) - -### Q3: .env 文件放在哪里? - -A: -- 方案 1: `/root/Automation_Platform/.env` (项目根目录) -- 方案 3: 任意位置,在 `docker run` 命令中指定路径 - -### Q4: 如何更新配置? - -A: -- 修改 .env 文件或 secrets 文件 -- 重启容器: `docker restart auto_test` -- 或重新运行 `docker run` / `docker-compose up -d` diff --git a/deployment/DOCKER_SWARM_DEPLOY.md b/deployment/DOCKER_SWARM_DEPLOY.md deleted file mode 100644 index 7b8d8a9..0000000 --- a/deployment/DOCKER_SWARM_DEPLOY.md +++ /dev/null @@ -1,306 +0,0 @@ -# Docker Swarm 部署指南 - -使用 Docker Swarm 和 Docker Secrets 安全地部署自动化测试平台。 - ---- - -## 📋 前提条件 - -你已经创建了以下 Docker Secrets: -```bash -docker secret ls -# 应该看到: -# - db_password -# - jenkins_token -# - jenkins_api_key -# - jenkins_jwt_secret -# - jenkins_signature_secret -``` - ---- - -## 🚀 快速部署步骤 - -### 步骤 1: 初始化 Docker Swarm(如果还没有初始化) - -```bash -# 检查是否已经是 Swarm 节点 -docker info | grep "Swarm: active" - -# 如果不是,初始化 Swarm -docker swarm init -``` - -### 步骤 2: 验证 Secrets 是否存在 - -```bash -docker secret ls -``` - -确保以下 secrets 已创建: -- `db_password` -- `jenkins_token` -- `jenkins_api_key` -- `jenkins_jwt_secret` -- `jenkins_signature_secret` - -### 步骤 3: 上传 docker-stack.yml 到服务器 - -将 `deployment/docker-stack.yml` 文件上传到你的服务器: - -```bash -# 在本地(假设服务器 IP 是 192.168.1.100) -scp deployment/docker-stack.yml root@192.168.1.100:/root/ -``` - -### 步骤 4: 部署 Stack - -```bash -# 在服务器上执行 -cd /root -docker stack deploy -c docker-stack.yml automation -``` - -### 步骤 5: 查看部署状态 - -```bash -# 查看 stack 列表 -docker stack ls - -# 查看服务状态 -docker stack services automation - -# 查看服务日志 -docker service logs -f automation_app -``` - -### 步骤 6: 验证部署 - -```bash -# 等待服务启动(通常需要 30-60 秒) -sleep 60 - -# 测试健康检查 -curl http://localhost:3000/api/health - -# 测试 Jenkins 认证(使用你的实际 API Key) -curl -X POST http://localhost:3000/api/jenkins/callback/test \ - -H "X-Api-Key: 3512fc38e1882a9ad2ab88c436277c129517e24a76daad1849ef419f90fd8a4f" \ - -H "Content-Type: application/json" \ - -d '{"testMessage": "hello"}' -``` - ---- - -## 🔄 更新部署 - -### 更新镜像版本 - -```bash -# 拉取最新镜像 -docker pull ghcr.io/acai1998/automation-platform:latest - -# 更新服务(滚动更新) -docker service update --image ghcr.io/acai1998/automation-platform:latest automation_app -``` - -### 更新配置 - -```bash -# 修改 docker-stack.yml 后重新部署 -docker stack deploy -c docker-stack.yml automation -``` - ---- - -## 🛑 停止和删除 - -### 停止服务 - -```bash -# 删除整个 stack -docker stack rm automation - -# 等待清理完成 -docker stack ls -``` - -### 清理资源 - -```bash -# 删除 secrets(如果需要) -docker secret rm db_password jenkins_token jenkins_api_key jenkins_jwt_secret jenkins_signature_secret - -# 清理未使用的镜像 -docker image prune -a -``` - ---- - -## 🔍 故障排查 - -### 查看服务详情 - -```bash -# 查看服务详细信息 -docker service ps automation_app - -# 查看服务配置 -docker service inspect automation_app -``` - -### 查看日志 - -```bash -# 实时查看日志 -docker service logs -f automation_app - -# 查看最近 100 行日志 -docker service logs --tail 100 automation_app - -# 查看带时间戳的日志 -docker service logs -t automation_app -``` - -### 常见问题 - -#### 1. 服务一直在重启 - -```bash -# 查看具体错误 -docker service ps automation_app --no-trunc - -# 检查日志中的错误信息 -docker service logs automation_app | grep -i error -``` - -可能原因: -- ❌ Secrets 未正确挂载 → 检查 `docker secret ls` -- ❌ 数据库连接失败 → 检查 DB_HOST 和 db_password -- ❌ 镜像拉取失败 → 检查网络连接 - -#### 2. 无法访问服务 - -```bash -# 检查端口映射 -docker service inspect automation_app | grep -A 5 Ports - -# 检查防火墙 -firewall-cmd --list-ports -firewall-cmd --add-port=3000/tcp --permanent -firewall-cmd --reload -``` - -#### 3. Secrets 读取失败 - -```bash -# 进入运行中的容器检查 -docker exec -it $(docker ps -q -f name=automation_app) sh - -# 在容器内检查 secrets 文件 -ls -la /run/secrets/ -cat /run/secrets/jenkins_token -``` - ---- - -## 📊 监控和维护 - -### 查看资源使用情况 - -```bash -# 查看服务资源使用 -docker stats $(docker ps -q -f name=automation_app) - -# 查看详细资源信息 -docker service ps automation_app --format "table {{.Name}}\t{{.Node}}\t{{.CurrentState}}" -``` - -### 扩容服务 - -```bash -# 增加副本数量(不推荐,因为有数据库状态) -docker service scale automation_app=2 - -# 减少副本数量 -docker service scale automation_app=1 -``` - -### 健康检查 - -Stack 配置中已经包含健康检查: -```yaml -healthcheck: - test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/api/health"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 40s -``` - ---- - -## 🔐 安全最佳实践 - -1. **使用 Docker Secrets**: ✅ 已配置 -2. **限制网络访问**: 使用防火墙规则 -3. **定期更新镜像**: 及时更新到最新版本 -4. **监控日志**: 定期检查异常日志 -5. **备份数据**: 定期备份数据库 - ---- - -## 📝 配置说明 - -### 环境变量(在 docker-stack.yml 中) - -| 变量名 | 说明 | 示例值 | -|--------|------|--------| -| `NODE_ENV` | 运行环境 | `production` | -| `PORT` | 服务端口 | `3000` | -| `DB_HOST` | 数据库地址 | `117.72.182.23` | -| `DB_PORT` | 数据库端口 | `3306` | -| `DB_USER` | 数据库用户 | `root` | -| `DB_NAME` | 数据库名称 | `autotest` | -| `JENKINS_URL` | Jenkins 地址 | `http://jenkins.wiac.xyz:8080` | -| `JENKINS_USER` | Jenkins 用户 | `root` | - -### Docker Secrets(敏感信息) - -| Secret 名称 | 说明 | 环境变量 | -|------------|------|----------| -| `db_password` | 数据库密码 | `DB_PASSWORD` | -| `jenkins_token` | Jenkins Token | `JENKINS_TOKEN` | -| `jenkins_api_key` | Jenkins API Key | `JENKINS_API_KEY` | -| `jenkins_jwt_secret` | JWT 密钥 | `JENKINS_JWT_SECRET` | -| `jenkins_signature_secret` | 签名密钥 | `JENKINS_SIGNATURE_SECRET` | - ---- - -## 🎯 与 docker run 对比 - -| 特性 | docker run | Docker Swarm | -|------|-----------|--------------| -| Secrets 支持 | ❌ | ✅ | -| 自动重启 | 手动配置 | 内置支持 | -| 滚动更新 | ❌ | ✅ | -| 负载均衡 | ❌ | ✅ | -| 多节点部署 | ❌ | ✅ | -| 资源限制 | 手动配置 | 配置文件管理 | - ---- - -## 💡 提示 - -1. **首次部署**: 服务启动需要 30-60 秒,请耐心等待 -2. **日志查看**: 使用 `docker service logs` 而不是 `docker logs` -3. **配置更新**: 修改 stack 文件后重新运行 deploy 命令即可 -4. **密钥更新**: 更新 secret 需要先删除旧 secret,创建新 secret,然后重新部署 - ---- - -## 📚 相关文档 - -- [Docker Secrets 官方文档](https://docs.docker.com/engine/swarm/secrets/) -- [Docker Stack 部署指南](https://docs.docker.com/engine/swarm/stack-deploy/) -- 项目文档: `deployment/DOCKER_SECRET_GUIDE.md` diff --git a/docs/ABOUT.md b/docs/ABOUT.md deleted file mode 100644 index e6b5351..0000000 --- a/docs/ABOUT.md +++ /dev/null @@ -1,42 +0,0 @@ -# 📌 关于本项目 - -## 项目简介 - -**AutoTest** 是一个现代化的全栈自动化测试管理平台,用于管理测试用例、调度 Jenkins 执行任务、监控执行结果。 - -## 核心功能 - -- 📊 **仪表盘** - 实时展示测试统计和成功率趋势 -- 📝 **用例管理** - 创建、编辑、组织测试用例 -- ⏰ **任务调度** - 手动触发、定时调度、CI 触发 -- 🔗 **Jenkins 集成** - 自动触发执行、接收结果回调 -- 📈 **执行历史** - 完整的执行记录和详细结果 - -## 技术栈 - -**前端**: React 18 + TypeScript + Vite + TailwindCSS -**后端**: Express + TypeScript + SQLite -**部署**: Docker + Nginx + PM2 - -## 快速开始 - -```bash -# 自动部署 -bash deployment/scripts/setup.sh - -# 启动应用 -npm run start - -# 访问 -http://localhost:5173 -``` - -## 文档 - -- 📖 [README.md](./README.md) - 项目详细说明 -- 📖 [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md) - 部署指南 -- 📖 [PROJECT_STRUCTURE.md](./PROJECT_STRUCTURE.md) - 项目结构 - -## 许可证 - -MIT License \ No newline at end of file diff --git a/docs/CALLBACK_FIX_DIAGNOSTIC.md b/docs/CALLBACK_FIX_DIAGNOSTIC.md deleted file mode 100644 index 64f38ba..0000000 --- a/docs/CALLBACK_FIX_DIAGNOSTIC.md +++ /dev/null @@ -1,346 +0,0 @@ -# Jenkins 回调问题诊断和修复说明 - -## 问题症状 -- 点击"运行"后,任务显示为"运行中"状态,但一直不会更新为最终状态 -- 没有任何日志输出显示回调处理的详细信息 -- Jenkins 可能已经执行完成,但平台没有收到结果更新 - -## 根本原因分析 - -### 问题1:缓存未被回调流程使用 -**位置**: `server/services/ExecutionService.ts` 的 `completeBatchExecution` 方法 - -**问题**:虽然在 `triggerTestExecution` 方法中缓存了 `runId -> executionId` 的映射,但 `completeBatchExecution` 方法(处理回调的核心逻辑)中**没有使用这个缓存**。直接调用 `repository.completeBatch`,导致缓存作用不大。 - -**关键流程**: -1. ❌ **旧流程**:回调到达 → 调用 `completeBatchExecution` → 直接查询数据库(可能找不到) -2. ✅ **新流程**:回调到达 → 调用 `completeBatchExecution` → 先查询缓存 → 再查询数据库 → 传递给 Repository - -### 问题2:日志输出不足 -**位置**: `server/routes/jenkins.ts` 和其他服务文件 - -**问题**:大量使用 `console.log` 和 `console.error`,导致: -- 日志格式不统一 -- 缺少结构化上下文(context、module等) -- 难以通过日志追踪问题 - -## 已应用的修复 - -### 修复1:改进缓存使用策略 -**文件**: `server/services/ExecutionService.ts` - -```typescript -// 2. 尝试从缓存获取 executionId(最快) -let executionId = this.runIdToExecutionIdCache.get(runId); -if (executionId) { - logger.debug('ExecutionId found in cache', { - runId, - executionId, - cacheSize: this.runIdToExecutionIdCache.size, - }, LOG_CONTEXTS.EXECUTION); -} else { - logger.debug('ExecutionId not in cache, querying database', { - runId, - cacheSize: this.runIdToExecutionIdCache.size, - }, LOG_CONTEXTS.EXECUTION); - // 降级:从数据库查询 - executionId = await this.executionRepository.findExecutionIdByRunId(runId) || undefined; -} - -// 3. 完成批次执行,同时传递 executionId 以提高效率 -await this.executionRepository.completeBatch(runId, results, executionId); -``` - -**优势**: -- 三层查询策略:缓存 → 数据库 → 降级方案 -- 记录详细日志帮助诊断 - -### 修复2:更新 Repository 方法签名 -**文件**: `server/repositories/ExecutionRepository.ts` - -```typescript -async completeBatch( - runId: number, - results: { /* ... */ }, - executionId?: number // ← 新增参数 -): Promise -``` - -**改进**: -- 支持从 Service 层传递已知的 `executionId` -- 如果未提供则自动查询数据库 -- 增强错误日志的详细程度 - -### 修复3:统一日志输出 -**文件**: `server/routes/jenkins.ts`(以及其他路由文件) - -**替换统计**: -- ✅ 28+ 个 `console.log` → `logger.info/debug` -- ✅ 15+ 个 `console.error` → `logger.error` -- ✅ 所有日志都包含结构化上下文和 `LOG_CONTEXTS.JENKINS` - -**示例对比**: - -```typescript -// 旧代码 -console.log(`[CALLBACK-TEST] Processing real callback data:`, { runId, status }); - -// 新代码 -logger.info(`Processing real callback test data`, { - runId, - status, - passedCases: passedCases || 0, - failedCases: failedCases || 0, - skippedCases: skippedCases || 0, - durationMs: durationMs || 0, - resultsCount: results?.length || 0 -}, LOG_CONTEXTS.JENKINS); -``` - -## 回调流程图(修复后) - -``` -Jenkins 完成构建 - ↓ -[POST /api/executions/callback] - ↓ -[executionService.completeBatchExecution(runId, results)] - ↓ - [检查缓存] - ↓ - [缓存命中?] → 是 → 直接使用 executionId - ↓ - 否 - ↓ - [查询数据库] → 找到? → 使用找到的 executionId - ↓ - 否 - ↓ - [记录警告] → 继续处理批次统计 - ↓ -[executionRepository.completeBatch(runId, results, executionId)] - ↓ -[更新 Auto_TestRun 的状态] - ↓ -[更新 Auto_TestRunResults 的详细结果] - ↓ -[事务提交] - ↓ -[返回 200 OK] -``` - -## 测试步骤 - -### 方法1:使用测试回调接口(推荐) - -1. **测试连接**: -```bash -curl -X POST http://localhost:3000/api/jenkins/callback/test \ - -H "Content-Type: application/json" \ - -d '{"testMessage": "hello"}' -``` - -**预期输出**: -```json -{ - "success": true, - "message": "Callback test successful - 回调连接测试通过", - "mode": "CONNECTION_TEST" -} -``` - -2. **测试真实数据处理**: -```bash -curl -X POST http://localhost:3000/api/jenkins/callback/test \ - -H "Content-Type: application/json" \ - -d '{ - "runId": 1, - "status": "success", - "passedCases": 2, - "failedCases": 0, - "skippedCases": 0, - "durationMs": 5000, - "results": [ - { - "caseId": 1, - "caseName": "test_case_1", - "status": "passed", - "duration": 2500 - }, - { - "caseId": 2, - "caseName": "test_case_2", - "status": "passed", - "duration": 2500 - } - ] - }' -``` - -**预期输出**: -```json -{ - "success": true, - "message": "Test callback processed successfully - 测试回调数据已处理", - "mode": "REAL_DATA", - "details": { - "receivedAt": "2026-02-07T...", - "clientIP": "127.0.0.1", - "processedData": { - "runId": 1, - "status": "success", - "passedCases": 2, - "failedCases": 0, - "skippedCases": 0, - "durationMs": 5000, - "resultsCount": 2 - } - } -} -``` - -### 方法2:查看后端日志 - -启动后端并观察日志: -```bash -npm run server -``` - -**关键日志标记**: -- `[ExecutionService]` - 执行流程日志 -- `[JENKINS]` - Jenkins 相关操作 -- `Running` → `Pending` → `Success/Failed` 状态转换 - -**示例日志输出**: -``` -[ExecutionService] INFO: Batch execution processing started { - runId: 1, - status: "success", - passedCases: 2, - ... -} - -[ExecutionService] DEBUG: ExecutionId found in cache { - runId: 1, - executionId: 5, - cacheSize: 3 -} - -[ExecutionService] INFO: Batch execution completed successfully { - runId: 1, - status: "success", - durationMs: 123, - timestamp: "2026-02-07T..." -} -``` - -### 方法3:监控数据库状态 - -```sql --- 查询特定 runId 的执行状态 -SELECT - id, - status, - trigger_type, - total_cases, - passed_cases, - failed_cases, - start_time, - end_time, - created_at, - updated_at -FROM Auto_TestRun -WHERE id = -ORDER BY created_at DESC -LIMIT 1; - --- 查询相关的执行结果 -SELECT - id, - execution_id, - case_id, - case_name, - status, - duration, - created_at -FROM Auto_TestRunResults -WHERE execution_id IN ( - SELECT DISTINCT execution_id FROM Auto_TestRunResults - WHERE created_at >= DATE_SUB(NOW(), INTERVAL 1 HOUR) -) -LIMIT 20; -``` - -## 故障排查指南 - -### 症状:仍然显示"运行中" - -**检查清单**: -1. ✅ 检查后端日志中是否有 "ExecutionId found in cache" 或 "ExecutionId not in cache" -2. ✅ 确认 Jenkins 已配置回调 URL:`http://your-server:3000/api/executions/callback` -3. ✅ 检查 `/api/jenkins/health` 是否正常 -4. ✅ 运行诊断:`curl "http://localhost:3000/api/jenkins/diagnose?runId="` - -### 症状:日志输出混乱 - -**解决方案**: -- 所有日志现已使用结构化格式 -- 在日志聚合工具中使用 `LOG_CONTEXTS: "EXECUTION"` 或 `"JENKINS"` 过滤 -- 查看 `server/config/logging.ts` 了解日志配置 - -### 症状:缓存命中率低 - -**原因分析**: -- 缓存在应用重启后清空(这是正常的) -- 长时间运行的应用可能缓存爆满(10000+ 条目时自动清理) -- 应检查 10 分钟的清理间隔是否合适 - -**监控指标**: -在日志中查找: -``` -RunId cache size exceeds 10000, clearing oldest entries -``` - -## 性能影响 - -| 查询方式 | 延迟 | 命中率 | 备注 | -|---------|------|--------|------| -| 缓存查询 | <1ms | ~70-80% | 应用重启后下降 | -| 数据库查询 | 50-100ms | - | 降级方案 | -| 总耗时 | <200ms | - | 回调处理总时间 | - -## 推荐配置 - -### 环境变量 -```bash -# .env 或 docker-compose 配置 -JENKINS_URL=http://jenkins.wiac.xyz:8080 -JENKINS_USER=root -JENKINS_TOKEN= -JENKINS_ALLOWED_IPS=192.168.1.0/24,10.0.0.0/8 - -# 日志级别 -LOG_LEVEL=info # 或 debug 用于排查 - -# API 回调 URL(Jenkins 执行完后回调此 URL) -API_CALLBACK_URL=http://your-server:3000 -``` - -## 后续改进建议 - -1. **考虑添加 Redis 缓存**:当前使用内存缓存,重启后丢失。Redis 可以持久化。 - -2. **实现死信队列**:如果回调处理失败,保存到队列中定期重试。 - -3. **监控面板**:添加实时监控面板显示: - - 缓存命中率 - - 平均回调处理时间 - - 失败回调数量 - -4. **自动修复机制**:对于卡住的任务,自动触发手动同步。 - -## 参考链接 - -- [Jenkins 回调配置指南](/docs/JENKINS_CONFIG_GUIDE.md) -- [日志配置说明](/server/config/logging.ts) -- [数据库设计文档](/docs/database-design.md) diff --git a/docs/EXECUTION_MONITOR_OPTIMIZATION.md b/docs/EXECUTION_MONITOR_OPTIMIZATION.md deleted file mode 100644 index 9ba21d4..0000000 --- a/docs/EXECUTION_MONITOR_OPTIMIZATION.md +++ /dev/null @@ -1,319 +0,0 @@ -# ExecutionMonitorService 优化总结 - -## 📋 概述 - -本文档记录了对 `ExecutionMonitorService.ts` 进行的全面代码审查和优化工作。 - -## 🎯 优化目标 - -基于详细的代码审查报告,我们实施了以下优化: - -### P0 - 必须修复(已完成 ✅) - -#### 1. 添加配置验证(防止注入攻击) - -**问题**: 环境变量直接解析为配置值,没有范围验证,存在注入风险。 - -**解决方案**: -- 添加 `validateConfig()` 私有方法 -- 验证所有配置参数的有效范围: - - `checkInterval`: 5000-300000ms (5秒-5分钟) - - `compilationCheckWindow`: 10000-300000ms (10秒-5分钟) - - `batchSize`: 1-100 - - `rateLimitDelay`: 0-5000ms (0秒-5秒) - - `quickFailThresholdSeconds`: 5-300秒 (5秒-5分钟) - -**代码位置**: [ExecutionMonitorService.ts:93-122](../server/services/ExecutionMonitorService.ts#L93-L122) - -#### 2. 修复 WebSocket 服务可选链检查 - -**问题**: `webSocketService?.pushQuickFailAlert()` 使用可选链,但没有检查服务是否启用和是否有订阅者。 - -**解决方案**: -- 添加订阅者数量检查 -- 只在有订阅者时才推送告警 -```typescript -if (webSocketService && webSocketService.getSubscriptionStats().totalExecutions > 0) { - webSocketService.pushQuickFailAlert(runId, {...}); -} -``` - -**代码位置**: [ExecutionMonitorService.ts:394-406](../server/services/ExecutionMonitorService.ts#L394-L406) - -### P1 - 重要改进(已完成 ✅) - -#### 3. 抽取重复的快速失败检测逻辑 - -**问题**: 快速失败检测逻辑在两处重复(line 256-258 和 line 340-346)。 - -**解决方案**: -- 创建 `isQuickFail()` 私有方法 -- 统一快速失败检测逻辑 -- 使用配置的阈值而非硬编码的 30 秒 - -**代码位置**: [ExecutionMonitorService.ts:232-242](../server/services/ExecutionMonitorService.ts#L232-L242) - -#### 4. 优化错误处理机制 - -**问题**: -- 错误被捕获后又重新抛出,导致重复日志 -- 缺少堆栈跟踪信息 -- 错误处理逻辑重复 - -**解决方案**: -- 在 `ServiceError.ts` 中添加 `getErrorMessage()` 和 `getErrorStack()` 工具函数 -- `processSingleExecution()` 中不再重新抛出错误,而是返回失败状态 -- 统一错误日志格式,包含堆栈跟踪 - -**代码位置**: -- [ServiceError.ts:91-111](../server/utils/ServiceError.ts#L91-L111) -- [ExecutionMonitorService.ts:413-425](../server/services/ExecutionMonitorService.ts#L413-L425) - -#### 5. 添加健康检查接口 - -**问题**: 缺少监控服务自身的健康检查机制。 - -**解决方案**: -- 添加 `getHealth()` 方法 -- 检测以下异常情况: - - 监控服务未运行但应该启用 - - 监控周期卡住(超过 3 倍检查间隔) - - 高错误率(超过 50%) - -**返回值**: -```typescript -{ - healthy: boolean; - issues: string[]; - lastSuccessfulCycle?: Date; - consecutiveFailures: number; -} -``` - -**代码位置**: [ExecutionMonitorService.ts:197-230](../server/services/ExecutionMonitorService.ts#L197-L230) - -### P2 - 性能优化(已完成 ✅) - -#### 6. 创建工具函数 getErrorMessage - -**解决方案**: -- 在 `ServiceError.ts` 中添加 `getErrorMessage()` 函数 -- 在 `ServiceError.ts` 中添加 `getErrorStack()` 函数 -- 统一错误消息提取逻辑 - -**代码位置**: [ServiceError.ts:91-111](../server/utils/ServiceError.ts#L91-L111) - -#### 7. 优化批量日志记录 - -**问题**: 每个执行更新都单独记录日志,日志量大。 - -**解决方案**: -- 收集所有更新的执行 ID -- 批量记录日志(只记录前 10 个 ID) -- 减少日志输出量 - -**代码位置**: [ExecutionMonitorService.ts:323-338](../server/services/ExecutionMonitorService.ts#L323-L338) - -### P3 - 代码规范(已完成 ✅) - -#### 8. 补充环境变量文档到 .env.example - -**解决方案**: -- 在 `.env.example` 中添加所有监控相关的环境变量 -- 为每个变量添加详细注释 -- 说明有效范围和默认值 - -**新增环境变量**: -```bash -# 快速失败阈值(秒) -# 说明:执行时间小于此值且失败,则认为是快速失败(编译错误、配置错误等) -# 有效范围:5-300(5秒-5分钟) -# 默认:30 -QUICK_FAIL_THRESHOLD_SECONDS=30 -``` - -**代码位置**: [.env.example:155-186](../.env.example#L155-L186) - -## 🧪 测试覆盖 - -创建了完整的单元测试套件,覆盖以下场景: - -### 配置验证测试 -- ✅ 验证 checkInterval 范围 -- ✅ 验证 compilationCheckWindow 范围 -- ✅ 验证 batchSize 范围 -- ✅ 验证 rateLimitDelay 范围 -- ✅ 验证 quickFailThresholdSeconds 范围 - -### 快速失败检测测试 -- ✅ 正确检测快速失败 -- ✅ 不误报正常失败 -- ✅ 不误报成功执行 - -### 健康检查测试 -- ✅ 检测高错误率 -- ✅ 检测卡住的周期 - -### 统计追踪测试 -- ✅ 正确追踪统计数据 -- ✅ 正确追踪错误 - -### 批量日志测试 -- ✅ 收集更新的执行 ID -- ✅ 限制日志 ID 数量 - -### WebSocket 检查测试 -- ✅ 检查 WebSocket 可用性 -- ✅ 检查订阅者数量 - -**测试文件**: [ExecutionMonitorService.test.ts](../server/services/__tests__/ExecutionMonitorService.test.ts) - -**测试结果**: ✅ 13 个测试全部通过 - -## 📊 优化效果 - -### 安全性提升 -- ✅ 防止配置注入攻击 -- ✅ 参数范围验证 -- ✅ 更安全的错误处理 - -### 性能提升 -- ✅ 减少不必要的 WebSocket 推送 -- ✅ 批量日志记录,减少 I/O 操作 -- ✅ 优化的错误处理,避免重复操作 - -### 可维护性提升 -- ✅ 抽取重复逻辑 -- ✅ 统一的错误处理 -- ✅ 完善的健康检查 -- ✅ 详细的环境变量文档 - -### 可观测性提升 -- ✅ 健康检查接口 -- ✅ 详细的统计信息 -- ✅ 批量日志记录 - -## 🔧 使用示例 - -### 配置验证 - -```typescript -// 自动在构造函数中验证 -const service = new ExecutionMonitorService(); -// 如果配置无效,会抛出错误并阻止启动 -``` - -### 健康检查 - -```typescript -const health = executionMonitorService.getHealth(); - -if (!health.healthy) { - console.error('Monitor is unhealthy:', health.issues); -} - -// 返回示例: -// { -// healthy: false, -// issues: ['Monitor is not running but should be enabled'], -// consecutiveFailures: 0 -// } -``` - -### 快速失败检测 - -```typescript -// 自动检测并推送告警 -// 当执行时间 < 30秒 且失败时,会: -// 1. 标记为编译失败 -// 2. 推送 WebSocket 告警(如果有订阅者) -// 3. 记录警告日志 -``` - -## 📝 配置建议 - -### 生产环境配置 - -```bash -# 执行监控配置 -EXECUTION_MONITOR_ENABLED=true -EXECUTION_MONITOR_INTERVAL=30000 # 30秒(推荐) -COMPILATION_CHECK_WINDOW=30000 # 30秒 -EXECUTION_MONITOR_BATCH_SIZE=20 # 20条 -EXECUTION_MONITOR_RATE_LIMIT=100 # 100ms -QUICK_FAIL_THRESHOLD_SECONDS=30 # 30秒 -EXECUTION_MONITOR_MAX_AGE_HOURS=24 # 24小时 -EXECUTION_CLEANUP_INTERVAL=3600000 # 1小时 -``` - -### 开发环境配置 - -```bash -# 执行监控配置(更快的检测) -EXECUTION_MONITOR_ENABLED=true -EXECUTION_MONITOR_INTERVAL=15000 # 15秒(快速检测) -COMPILATION_CHECK_WINDOW=15000 # 15秒 -EXECUTION_MONITOR_BATCH_SIZE=10 # 10条 -EXECUTION_MONITOR_RATE_LIMIT=50 # 50ms -QUICK_FAIL_THRESHOLD_SECONDS=15 # 15秒 -EXECUTION_MONITOR_MAX_AGE_HOURS=12 # 12小时 -EXECUTION_CLEANUP_INTERVAL=1800000 # 30分钟 -``` - -## 🚀 后续优化建议 - -### 数据库优化 -建议添加以下索引以提升查询性能: - -```sql -CREATE INDEX idx_testrun_status_starttime -ON Auto_TestRun(status, start_time, created_at); -``` - -### 监控指标 -建议添加以下监控指标: - -- 监控周期平均耗时 -- 快速失败检测率 -- WebSocket 推送成功率 -- 数据库查询耗时 - -### API 端点 -建议添加以下管理 API: - -- `GET /api/monitor/health` - 健康检查 -- `GET /api/monitor/stats` - 统计信息 -- `POST /api/monitor/reset` - 重置统计 -- `POST /api/monitor/trigger` - 手动触发检查 - -## 📚 相关文档 - -- [代码审查报告](./CODE_REVIEW_REPORT.md)(如果需要) -- [环境变量配置](./.env.example) -- [测试文件](../server/services/__tests__/ExecutionMonitorService.test.ts) - -## ✅ 检查清单 - -- [x] P0: 添加配置验证(防止注入攻击) -- [x] P0: 修复 WebSocket 服务可选链检查 -- [x] P1: 抽取重复的快速失败检测逻辑 -- [x] P1: 优化错误处理机制 -- [x] P1: 添加健康检查接口 -- [x] P2: 创建工具函数 getErrorMessage -- [x] P2: 优化批量日志记录 -- [x] P3: 补充环境变量文档到 .env.example -- [x] 创建完整的测试套件 -- [x] 类型检查通过 -- [x] 所有测试通过 - -## 🎉 总结 - -通过本次优化,`ExecutionMonitorService` 的代码质量、安全性、性能和可维护性都得到了显著提升。所有 P0-P3 优先级的问题都已解决,并添加了完整的测试覆盖。 - -**总体评分提升**: 7.9/10 → **9.2/10** - ---- - -**优化完成日期**: 2026-02-10 -**优化负责人**: Claude Opus 4.5 -**代码审查者**: 自动化测试套件 diff --git a/docs/IMPLEMENTATION_SUMMARY.md b/docs/IMPLEMENTATION_SUMMARY.md deleted file mode 100644 index e013cb8..0000000 --- a/docs/IMPLEMENTATION_SUMMARY.md +++ /dev/null @@ -1,288 +0,0 @@ -# 用例执行功能实现总结 - -## 概述 - -本次实现完成了从前端点击"执行"到Jenkins运行测试、结果回调的完整流程。系统采用异步非阻塞设计,支持单用例和批量执行,实时展示执行进度。 - -## 技术架构 - -``` -┌─────────────┐ -│ 前端UI │ ExecutionModal + ExecutionProgress -├─────────────┤ -│ Hooks │ useExecuteCase, useBatchExecution -├─────────────┤ -│ API Route │ /api/jenkins/run-case, /api/jenkins/run-batch -├─────────────┤ -│ Services │ ExecutionService, JenkinsService -├─────────────┤ -│ Database │ Auto_TestRun 表 (MariaDB) -├─────────────┤ -│ Jenkins │ Pipeline 脚本执行测试 -└─────────────┘ -``` - -## 核心实现 - -### 1. 后端数据库 (Auto_TestRun) - -**表结构** (`server/db/schema.mariadb.sql`): -- `id`: 执行批次ID -- `project_id`: 项目ID -- `status`: 执行状态 (pending/running/success/failed/aborted) -- `case_ids`: 用例ID列表(JSON) -- `jenkins_build_id`: Jenkins构建ID -- `passed_cases/failed_cases/skipped_cases`: 执行结果统计 -- `duration_ms`: 执行耗时 -- 其他字段用于记录触发方式、触发人等 - -### 2. 后端 Services - -#### ExecutionService (`server/services/ExecutionService.ts`) - -新增方法: -- `triggerTestExecution()`: 创建执行批次,验证用例有效性 -- `getBatchExecution()`: 查询执行批次详情 -- `updateBatchJenkinsInfo()`: 更新Jenkins构建信息 -- `completeBatchExecution()`: 完成执行,记录最终结果 - -#### JenkinsService (`server/services/JenkinsService.ts`) - -新增方法: -- `triggerBatchJob()`: 批量触发Jenkins Job -- `getLatestBuildInfo()`: 获取最新构建信息 - -### 3. 后端 API 路由 - -#### Jenkins 路由 (`server/routes/jenkins.ts`) - -**新增端点:** - -| 方法 | 路由 | 说明 | -|------|------|------| -| POST | `/api/jenkins/run-case` | 执行单个用例 | -| POST | `/api/jenkins/run-batch` | 执行多个用例 | -| POST | `/api/jenkins/callback` | Jenkins回调结果 | -| GET | `/api/jenkins/batch/:runId` | 查询执行批次详情 | - -### 4. Jenkins Pipeline - -**文件**: `Jenkinsfile` - -执行流程: -1. **准备**: 标记执行开始 -2. **检出代码**: 克隆/更新测试仓库 -3. **准备环境**: 创建Python虚拟环境,安装依赖 -4. **执行测试**: 运行pytest -5. **收集结果**: 解析JSON报告 -6. **回调平台**: 向API发送结果 - -支持参数: -- `RUN_ID`: 执行批次ID -- `CASE_IDS`: 用例ID列表 -- `SCRIPT_PATHS`: 脚本路径 -- `CALLBACK_URL`: 回调地址 -- `MARKER`: Pytest marker - -### 5. 前端实现 - -#### Hooks (`src/hooks/useExecuteCase.ts`) - -- `useExecuteCase()`: 执行单个用例 -- `useExecuteBatch()`: 批量执行 -- `useBatchExecution()`: 实时轮询执行进度 -- `useTestExecution()`: 完整执行管理 - -#### 组件 - -**ExecutionModal** (`src/components/cases/ExecutionModal.tsx`) -- 执行前确认对话框 -- 显示用例数量和警告信息 -- 错误提示 - -**ExecutionProgress** (`src/components/cases/ExecutionProgress.tsx`) -- 实时显示执行进度 -- 统计数据展示(总数/通过/失败/跳过) -- 成功率进度条 -- Jenkins链接 - -## 数据流转 - -### 执行流程 - -``` -1. 用户点击"执行按钮" - └─> 前端弹出 ExecutionModal - -2. 用户确认执行 - └─> 调用 useExecuteCase/useExecuteBatch hook - └─> POST /api/jenkins/run-case 或 /api/jenkins/run-batch - -3. 后端接收请求 - └─> ExecutionService.triggerTestExecution() - └─> 验证用例、创建 Auto_TestRun 记录 - └─> JenkinsService.triggerBatchJob() - └─> 触发 Jenkins Job - └─> 返回 runId 和 buildUrl - -4. 前端获得 runId - └─> useBatchExecution 开始轮询 - └─> 显示 ExecutionProgress 组件 - └─> 每3秒查询 /api/jenkins/batch/:runId - -5. Jenkins 执行测试 - └─> Jenkinsfile 运行 pytest - └─> 收集测试结果 - └─> POST /api/jenkins/callback - -6. 后端处理回调 - └─> ExecutionService.completeBatchExecution() - └─> 更新 Auto_TestRun 记录状态 - -7. 前端检测到执行完成 - └─> 停止轮询 - └─> 展示最终结果 -``` - -## API 请求/响应示例 - -### 执行单个用例 - -```bash -POST /api/jenkins/run-case -{ - "caseId": 1, - "projectId": 1, - "triggeredBy": 1 -} - -Response: -{ - "success": true, - "data": { - "runId": 123, - "buildUrl": "http://jenkins.wiac.xyz/job/.../45/" - }, - "message": "Batch job triggered successfully" -} -``` - -### 批量执行用例 - -```bash -POST /api/jenkins/run-batch -{ - "caseIds": [1, 2, 3, 4], - "projectId": 1, - "triggeredBy": 1 -} - -Response: -{ - "success": true, - "data": { - "runId": 123, - "totalCases": 4, - "buildUrl": "http://jenkins.wiac.xyz/job/.../45/" - } -} -``` - -### 查询执行进度 - -```bash -GET /api/jenkins/batch/123 - -Response: -{ - "success": true, - "data": { - "id": 123, - "status": "running", - "total_cases": 4, - "passed_cases": 2, - "failed_cases": 0, - "skipped_cases": 0, - "jenkins_build_url": "http://jenkins.wiac.xyz/...", - "start_time": "2024-01-08 10:00:00", - "duration_ms": null - } -} -``` - -## 环境配置 - -### 后端 .env - -```env -# Jenkins 配置 -JENKINS_URL=https://jenkins.wiac.xyz -JENKINS_USER=root -JENKINS_TOKEN=your_api_token -JENKINS_JOB_API=SeleniumBaseCi-AutoTest - -# 回调 URL -API_CALLBACK_URL=http://your-platform:3000/api/jenkins/callback -``` - -## 异常处理 - -### 场景1: 用例验证失败 -``` -触发执行 → 后端查询用例 → 用例不存在或已禁用 -→ 返回错误消息 → 前端显示错误提示 -``` - -### 场景2: Jenkins 触发失败 -``` -Jenkins API 请求失败 → 返回 success: false -→ 前端捕获错误 → 显示错误提示 -``` - -### 场景3: 回调超时 -``` -Jenkins 执行完成 → 回调请求超时 -→ 手动查询 /api/jenkins/batch/:runId -→ 获取最终状态 -``` - -## 性能优化 - -1. **轮询间隔**: 3秒查询一次,平衡实时性和服务器负载 -2. **查询缓存**: TanStack Query 自动缓存,避免重复请求 -3. **异步处理**: Jenkins 执行为后台任务,不阻塞前端 -4. **批量执行**: 支持单次执行多个用例,提高效率 - -## 安全考虑 - -1. **权限验证**: API 调用应增加认证(JWT token) -2. **速率限制**: 限制单位时间内的执行次数,防止滥用 -3. **输入验证**: 严格验证 caseId、projectId 等参数 -4. **日志记录**: 记录所有执行操作便于审计 - -## 后续改进方向 - -1. **WebSocket 推送**: 替代轮询,实时推送执行进度 -2. **执行队列**: 支持任务优先级和队列管理 -3. **分布式执行**: Jenkins 分布式构建节点支持 -4. **报告详情**: 存储详细的用例执行日志和截图 -5. **邮件通知**: 执行完成后发送邮件通知 -6. **重试机制**: 失败用例自动重试 -7. **性能分析**: 记录执行时间趋势,分析性能变化 - -## 测试清单 - -- [ ] 单用例执行成功 -- [ ] 批量执行成功 -- [ ] 实时进度显示正确 -- [ ] 执行完成后结果准确 -- [ ] 网络断连时的恢复 -- [ ] 高并发情况下的稳定性 -- [ ] Jenkins 连接失败时的处理 -- [ ] 用例不存在时的错误提示 - -## 参考文档 - -- [Jenkins 集成指南](./JENKINS_INTEGRATION.md) -- [API 文档](../README.md) -- [项目架构](../CLAUDE.md) \ No newline at end of file diff --git a/docs/JENKINS_CALLBACK_IMPROVEMENTS.md b/docs/JENKINS_CALLBACK_IMPROVEMENTS.md deleted file mode 100644 index 013cf82..0000000 --- a/docs/JENKINS_CALLBACK_IMPROVEMENTS.md +++ /dev/null @@ -1,276 +0,0 @@ -# Jenkins 回调处理改进总结 - -## 问题描述 - -用户报告了两个关键问题: - -1. **任务卡在"运行中"状态**:点击运行后,任务一直显示为"running",不会自动更新为最终状态(success/failed) -2. **日志输出不足**:点击运行后没有任何日志输出,难以排查问题 - -## 深度分析 - -### 问题 1:runId → executionId 映射问题 - -**数据库架构**: -``` -Auto_TestRun (id) - ↓ (触发时同时创建) -Auto_TestCaseTaskExecutions (id) ← 我们称之为 executionId - ↑ (被引用) -Auto_TestRunResults (execution_id) -``` - -**问题流程**: -1. 执行触发时:创建 `Auto_TestRun` (runId=1) 和 `Auto_TestCaseTaskExecutions` (executionId=5) -2. Jenkins 执行完成:回调带来 `runId=1` -3. 回调处理:需要找到 `executionId=5` 来更新详细结果 -4. **关键问题**:回调立即到达时,`Auto_TestRunResults` 表中可能还没有数据,导致通过时间窗口的查询失败 - -### 问题 2:缓存未被利用 - -虽然 `triggerTestExecution` 中缓存了映射: -```typescript -this.runIdToExecutionIdCache.set(result.runId, result.executionId); -``` - -但 `completeBatchExecution` 中**直接调用 `repository.completeBatch`**,没有先检查缓存,导致: -- 缓存形同虚设 -- 每次都查询数据库 -- 在快速回调时仍然失败 - -## 实施的解决方案 - -### 方案 1:三层查询策略(ExecutionService) - -**文件修改**:`server/services/ExecutionService.ts` - -```typescript -async completeBatchExecution( - runId: number, - results: { /* ... */ } -): Promise { - // ... - - // Layer 1: 从缓存查询(最快,<1ms) - let executionId = this.runIdToExecutionIdCache.get(runId); - - if (!executionId) { - // Layer 2: 从数据库查询(降级,50-100ms) - executionId = await this.executionRepository.findExecutionIdByRunId(runId) || undefined; - } - - // Layer 3: 传递给 Repository 处理(允许为 undefined,仅更新批次统计) - await this.executionRepository.completeBatch(runId, results, executionId); - - // ... -} -``` - -**优势**: -- ✅ 充分利用缓存 -- ✅ 有数据库降级方案 -- ✅ 优雅降级:即使找不到 executionId,也不会崩溃 -- ✅ 详细日志记录每一层的查询过程 - -### 方案 2:Repository 方法签名更新 - -**文件修改**:`server/repositories/ExecutionRepository.ts` - -```typescript -async completeBatch( - runId: number, - results: { /* ... */ }, - executionId?: number // ← 新增参数 -): Promise -``` - -**改进**: -- ✅ 接受可选的 `executionId` 参数,避免重复查询 -- ✅ 如果未提供则自动查询 -- ✅ 增强错误处理和日志详细程度 - -### 方案 3:统一日志输出系统 - -**文件修改**:`server/routes/jenkins.ts`(主要)+ 其他路由文件 - -**替换统计**: -- 28+ 个 `console.log` → `logger.info/debug` -- 15+ 个 `console.error` → `logger.error` - -**日志改进**: -```typescript -// 旧方式 -console.log(`[CALLBACK-TEST] Processing real callback data:`, { - runId, - status, - passedCases: passedCases || 0, - failedCases: failedCases || 0, - skippedCases: skippedCases || 0, - durationMs: durationMs || 0, - resultsCount: results?.length || 0 -}); - -// 新方式(结构化、有上下文、可过滤) -logger.info(`Processing real callback test data`, { - runId, - status, - passedCases: passedCases || 0, - failedCases: failedCases || 0, - skippedCases: skippedCases || 0, - durationMs: durationMs || 0, - resultsCount: results?.length || 0 -}, LOG_CONTEXTS.JENKINS); -``` - -## 修改文件清单 - -### 核心业务逻辑 -| 文件 | 修改内容 | 影响 | -|------|--------|------| -| `server/services/ExecutionService.ts` | 添加缓存查询逻辑 | 高 - 直接解决问题 | -| `server/repositories/ExecutionRepository.ts` | 更新签名支持可选 executionId | 中 - 配合 Service 使用 | -| `server/routes/jenkins.ts` | 统一日志输出 | 中 - 改善可观察性 | - -### 配置和工具 -| 文件 | 类型 | 用途 | -|------|------|------| -| `docs/CALLBACK_FIX_DIAGNOSTIC.md` | 文档 | 诊断和测试指南 | -| `docs/JENKINS_CALLBACK_IMPROVEMENTS.md` | 文档 | 本文件 | -| `scripts/test-callback.sh` | 脚本 | 快速验证修复 | - -## 验证方式 - -### 快速验证(推荐) -```bash -# 使用测试脚本 -bash scripts/test-callback.sh 1 success 2 0 - -# 或手动测试 -curl -X POST http://localhost:3000/api/jenkins/callback/test \ - -H "Content-Type: application/json" \ - -d '{ - "runId": 1, - "status": "success", - "passedCases": 2, - "failedCases": 0, - "skippedCases": 0, - "durationMs": 5000, - "results": [...] - }' -``` - -### 日志验证 -启动后端后,观察日志中是否出现: -``` -[ExecutionService] INFO: Batch execution processing started -[ExecutionService] DEBUG: ExecutionId found in cache (或 not in cache) -[ExecutionService] INFO: Batch execution completed successfully -``` - -### 数据库验证 -```sql --- 查询 Auto_TestRun 的状态是否更新 -SELECT id, status, passed_cases, failed_cases, updated_at -FROM Auto_TestRun -WHERE id = -LIMIT 1; -``` - -## 性能指标 - -| 操作 | 耗时 | 说明 | -|------|------|------| -| 缓存命中 | <1ms | 正常情况下 70-80% 命中率 | -| 数据库查询 | 50-100ms | 降级方案 | -| 回调处理总耗时 | <200ms | 包括事务提交 | -| 日志写入 | <5ms | 结构化日志记录 | - -## 后向兼容性 - -✅ **完全兼容** -- 所有修改都是增强式,不修改现有接口行为 -- 缓存机制是透明的,无需修改调用代码 -- 日志修改仅影响输出格式,不影响功能 - -## 风险评估 - -| 风险 | 可能性 | 影响 | 缓解方案 | -|-----|--------|------|---------| -| 缓存内存泄漏 | 低 | 中 | 10分钟清理一次,10000条目限制 | -| 数据库查询变慢 | 极低 | 低 | 缓存命中率高,降级方案也不是瓶颈 | -| 日志输出过多 | 低 | 低 | 可调整日志级别 | - -## 最佳实践建议 - -### 1. 监控缓存命中率 -```bash -# 在后端日志中搜索 -grep "ExecutionId found in cache\|ExecutionId not in cache" logs/*.log -``` - -### 2. 设置告警 -监控以下指标: -- 回调处理失败次数 -- 平均回调处理耗时 -- 缓存命中率下降 - -### 3. 定期测试 -```bash -# 每周运行一次测试 -bash scripts/test-callback.sh -``` - -### 4. 记录关键指标 -```typescript -// 在日志中包含这些信息 -logger.info('Batch execution completed', { - runId, - executionId, - status, - processingTimeMs: duration, - cacheHit: cacheLookupSuccessful, - resultsCount: results.length -}, LOG_CONTEXTS.EXECUTION); -``` - -## 常见问题 - -### Q: 缓存在什么时候被清空? -A: -1. 应用重启时自动清空(内存缓存特性) -2. 每10分钟自动清理超过10000条目的缓存 -3. 可手动清空(需要重启应用) - -### Q: 如果找不到 executionId 会怎样? -A: -1. 批次统计仍会更新(Auto_TestRun 状态变化) -2. 详细结果(Auto_TestRunResults)可能不会更新 -3. 日志会记录警告信息,便于排查 - -### Q: 为什么日志中有重复的操作日志? -A: 因为回调处理和手动同步都可能调用相同的方法,这是正常的。 - -## 下一步改进 - -### 短期(1-2周) -- [ ] 添加 Redis 缓存持久化 -- [ ] 实现死信队列处理失败的回调 -- [ ] 添加监控面板展示关键指标 - -### 中期(1-2个月) -- [ ] 实现自动修复机制(卡住的任务自动恢复) -- [ ] 添加回调重试机制 -- [ ] 性能基准测试和优化 - -### 长期 -- [ ] WebSocket 实时推送替代轮询 -- [ ] 分布式缓存支持多实例部署 -- [ ] 完整的可观察性体系(tracing + metrics) - -## 联系和支持 - -如遇问题,请: -1. 查看 `docs/CALLBACK_FIX_DIAGNOSTIC.md` 中的故障排查指南 -2. 运行 `scripts/test-callback.sh` 进行诊断 -3. 检查后端日志查找 `[ExecutionService]` 或 `[JENKINS]` 标记的信息 -4. 如需帮助,提供完整的错误日志和重现步骤 diff --git a/docs/Jenkins/JENKINSFILE_COMPARISON.md b/docs/Jenkins/JENKINSFILE_COMPARISON.md deleted file mode 100644 index 1c638d9..0000000 --- a/docs/Jenkins/JENKINSFILE_COMPARISON.md +++ /dev/null @@ -1,537 +0,0 @@ -# Jenkinsfile 版本对比 - -## 快速对比 - -### 当前版本 vs 优化版本 - -| 方面 | 当前版本 | 优化版本 | 改进幅度 | -|-----|---------|---------|---------| -| 代码行数 | ~349 行 | ~600 行 | +72% (但可维护性更好) | -| 函数复用 | ❌ 无 | ✅ 12+ 个函数 | 🔥 大幅提升 | -| 配置管理 | ❌ 分散 | ✅ 集中化 | 🔥 大幅提升 | -| 错误处理 | ⚠️ 基础 | ✅ 完善 | 🔥 大幅提升 | -| 参数验证 | ❌ 无 | ✅ 完善 | 🔥 新增功能 | -| 并行执行 | ❌ 不支持 | ✅ 支持 | 🔥 新增功能 | -| 日志输出 | ⚠️ 简单 | ✅ 结构化 | 🔥 大幅提升 | -| 状态同步 | ⚠️ 不可靠 | ✅ 多重保障 | 🔥 大幅提升 | -| 可扩展性 | ⚠️ 较差 | ✅ 优秀 | 🔥 大幅提升 | - ---- - -## 核心改进点 - -### 1️⃣ 配置管理 - 从分散到集中 - -#### ❌ 当前版本 -```groovy -environment { - PLATFORM_API_URL = 'http://localhost:3000' - PYTHON_ENV = "${WORKSPACE}/venv" -} - -// 其他地方还有硬编码的值 -sh 'pip install pytest pytest-json-report' -``` - -#### ✅ 优化版本 -```groovy -def CONFIG = [ - platformUrl: 'http://localhost:3000', - pythonVersion: '3.9', - virtualEnvPath: "${WORKSPACE}/venv", - reportFile: 'test-report.json', - maxRetries: 3, - retryDelay: 5 -] - -// 统一引用配置 -sh "pip install -r requirements.txt" -``` - -**优势**: -- 🎯 配置集中管理,易于修改 -- 🎯 避免硬编码,提高可维护性 -- 🎯 便于环境切换 - ---- - -### 2️⃣ 代码复用 - 从重复到函数化 - -#### ❌ 当前版本 - 回调逻辑重复 3 次 -```groovy -// 第 1 次 - stage('回调平台') -curl -X POST "${CALLBACK_URL}" \ - -H "Content-Type: application/json" \ - -d "{ ... }" - -// 第 2 次 - post { always {} } -curl -X POST "${CALLBACK_URL}" \ - -H "Content-Type: application/json" \ - -d "{ ... }" - -// 第 3 次 - post { failure {} } -curl -X POST "${CALLBACK_URL}" \ - -H "Content-Type: application/json" \ - -d "{ ... }" -``` - -#### ✅ 优化版本 - 统一函数 -```groovy -def notifyPlatform(event, data = [:]) { - def callbackUrl = params.CALLBACK_URL ?: "${CONFIG.platformUrl}/api/jenkins/callback" - def payload = [event: event, timestamp: System.currentTimeMillis()] + data - - retry(CONFIG.maxRetries) { - sh """ - curl -X POST '${callbackUrl}' \ - -H 'Content-Type: application/json' \ - -H 'X-Api-Key: ${env.JENKINS_API_KEY}' \ - -d '${writeJSON(returnText: true, json: payload)}' \ - --max-time 30 --retry 3 - """ - } -} - -// 使用 -notifyPlatform('start', [runId: params.RUN_ID]) -notifyPlatform('complete', results) -notifyPlatform('failed', [runId: params.RUN_ID, status: 'failed']) -``` - -**优势**: -- 🎯 代码量减少 70% -- 🎯 逻辑统一,易于维护 -- 🎯 错误处理一致 - ---- - -### 3️⃣ 错误处理 - 从脆弱到健壮 - -#### ❌ 当前版本 -```groovy -sh ''' - curl ... || echo "回调失败,但继续处理" -''' -``` - -**问题**: -- ❌ 失败后没有重试 -- ❌ 没有超时控制 -- ❌ 错误信息不明确 - -#### ✅ 优化版本 -```groovy -retry(CONFIG.maxRetries) { - sh """ - curl -X POST '${callbackUrl}' \ - --max-time 30 \ - --retry 3 \ - --retry-delay ${CONFIG.retryDelay} \ - -w '\\nHTTP Status: %{http_code}\\n' \ - || (echo '❌ 回调失败' && exit 1) - """ -} -``` - -**优势**: -- ✅ 自动重试机制 -- ✅ 超时保护 -- ✅ 详细的错误信息 -- ✅ HTTP 状态码输出 - ---- - -### 4️⃣ 参数验证 - 从无到有 - -#### ❌ 当前版本 -```groovy -// 没有参数验证,直接执行 -stage('准备') { - echo "运行ID: ${params.RUN_ID}" -} -``` - -**问题**: -- ❌ 参数错误时浪费资源 -- ❌ 错误信息不明确 -- ❌ 可能导致后续步骤失败 - -#### ✅ 优化版本 -```groovy -def validateParameters() { - def errors = [] - - if (!params.RUN_ID || params.RUN_ID.trim() == '') { - errors.add("RUN_ID 不能为空") - } - - if (!params.SCRIPT_PATHS && !params.MARKER) { - errors.add("必须指定 SCRIPT_PATHS 或 MARKER 之一") - } - - if (errors.size() > 0) { - error("参数验证失败:\n" + errors.join("\n")) - } -} - -stage('初始化') { - validateParameters() -} -``` - -**优势**: -- ✅ 快速失败,节省资源 -- ✅ 明确的错误提示 -- ✅ 避免无效执行 - ---- - -### 5️⃣ 并行执行 - 从串行到并行 - -#### ❌ 当前版本 -```groovy -// 只能串行执行 -pytest test_case/test_login.py test_case/test_register.py -``` - -**问题**: -- ❌ 执行时间长 -- ❌ 资源利用率低 - -#### ✅ 优化版本 -```groovy -booleanParam( - name: 'ENABLE_PARALLEL', - defaultValue: false, - description: '启用并行执行' -) - -def buildTestCommand() { - def command = "pytest ${params.SCRIPT_PATHS}" - - if (params.ENABLE_PARALLEL) { - command += " -n auto" // 自动并行 - } - - return command -} -``` - -**优势**: -- ✅ 执行时间减少 50-70% -- ✅ 充分利用多核 CPU -- ✅ 可选启用,灵活控制 - ---- - -### 6️⃣ 日志输出 - 从简单到结构化 - -#### ❌ 当前版本 -```groovy -echo "运行ID: ${params.RUN_ID}" -echo "用例IDs: ${params.CASE_IDS}" -``` - -#### ✅ 优化版本 -```groovy -echo """ -╔════════════════════════════════════════════════════════════════╗ -║ 构建信息 ║ -╠════════════════════════════════════════════════════════════════╣ -║ 构建编号: ${BUILD_NUMBER} -║ 运行ID: ${params.RUN_ID ?: '未指定'} -║ 用例IDs: ${params.CASE_IDS} -║ 脚本路径: ${params.SCRIPT_PATHS ?: '未指定'} -║ Python版本: ${params.PYTHON_VERSION} -║ 并行执行: ${params.ENABLE_PARALLEL} -║ 构建时间: ${new Date()} -╚════════════════════════════════════════════════════════════════╝ -""" -``` - -**优势**: -- ✅ 信息一目了然 -- ✅ 便于问题追踪 -- ✅ 更专业的输出 - ---- - -### 7️⃣ 测试结果收集 - 从简单到详细 - -#### ❌ 当前版本 -```groovy -TOTAL=$(jq '.summary.total' test-report.json || echo "0") -PASSED=$(jq '.summary.passed' test-report.json || echo "0") -FAILED=$(jq '.summary.failed' test-report.json || echo "0") -``` - -**问题**: -- ❌ 只有汇总信息 -- ❌ 缺少每个用例的详情 -- ❌ 错误信息不完整 - -#### ✅ 优化版本 -```groovy -def collectTestResults() { - def results = [ - runId: params.RUN_ID.toInteger(), - status: 'success', - passedCases: 0, - failedCases: 0, - skippedCases: 0, - totalCases: 0, - durationMs: 0, - buildUrl: BUILD_URL, - buildNumber: BUILD_NUMBER, - results: [] // 🔥 详细的每个用例结果 - ] - - def report = readJSON(file: CONFIG.reportFile) - - // 提取汇总信息 - results.totalCases = report.summary.total ?: 0 - results.passedCases = report.summary.passed ?: 0 - results.failedCases = report.summary.failed ?: 0 - - // 🔥 提取每个用例的详细结果 - results.results = report.tests.collect { test -> - [ - caseName: test.nodeid, - status: test.outcome, - duration: (test.duration * 1000).toInteger(), - errorMessage: test.call?.longrepr ?: null // 🔥 错误信息 - ] - } - - return results -} -``` - -**优势**: -- ✅ 包含每个用例的详细结果 -- ✅ 完整的错误信息 -- ✅ 便于问题定位 - ---- - -### 8️⃣ 镜像构建 - 从强制到可选 - -#### ❌ 当前版本 -```groovy -stage('构建镜像') { - when { - expression { return currentBuild.result == 'SUCCESS' } - } - // 测试成功就构建镜像 -} -``` - -**问题**: -- ❌ 每次测试都构建镜像 -- ❌ 浪费时间和资源 -- ❌ 不够灵活 - -#### ✅ 优化版本 -```groovy -booleanParam( - name: 'BUILD_DOCKER_IMAGE', - defaultValue: false, // 🔥 默认不构建 - description: '构建并推送Docker镜像' -) - -stage('构建镜像') { - when { - expression { - return params.BUILD_DOCKER_IMAGE && currentBuild.result == null - } - } - steps { - buildAndPushDockerImage() - } -} -``` - -**优势**: -- ✅ 按需构建,节省资源 -- ✅ 灵活控制 -- ✅ 分离关注点 - ---- - -### 9️⃣ 状态同步 - 从不可靠到多重保障 - -#### ❌ 当前版本 -```groovy -// 只在一个地方回调 -stage('回调平台') { - curl ... -} -``` - -**问题**: -- ❌ 如果这个 stage 失败,状态不同步 -- ❌ 没有最终保障机制 - -#### ✅ 优化版本 -```groovy -// 1️⃣ 执行开始时通知 -stage('初始化') { - notifyPlatform('start', [runId: params.RUN_ID]) -} - -// 2️⃣ 执行完成时通知 -stage('回调平台') { - notifyPlatform('complete', results) -} - -// 3️⃣ 最终保障(无论成功失败都执行) -post { - always { - finalCallback() // 🔥 确保状态同步 - } -} -``` - -**优势**: -- ✅ 三重保障机制 -- ✅ 状态同步可靠性 99.9%+ -- ✅ 避免状态卡住 - ---- - -## 性能对比 - -### 执行时间对比(10个用例) - -| 场景 | 当前版本 | 优化版本 | 提升 | -|-----|---------|---------|------| -| 串行执行 | ~5分钟 | ~5分钟 | - | -| 并行执行(4核) | ❌ 不支持 | ~1.5分钟 | 🔥 70% | -| 错误重试 | ❌ 无 | +10秒 | 🔥 可靠性提升 | - -### 资源利用率 - -| 指标 | 当前版本 | 优化版本 | -|-----|---------|---------| -| CPU 利用率 | ~25% | ~90% (并行时) | -| 内存占用 | ~500MB | ~600MB | -| 网络重试 | 0 | 3次 | - ---- - -## 可维护性对比 - -### 代码复杂度 - -| 指标 | 当前版本 | 优化版本 | -|-----|---------|---------| -| 圈复杂度 | 高 | 低 | -| 代码重复率 | ~30% | <5% | -| 函数数量 | 0 | 12+ | -| 注释覆盖率 | ~10% | ~40% | - -### 可扩展性 - -| 需求 | 当前版本 | 优化版本 | -|-----|---------|---------| -| 添加新参数 | 修改多处 | 修改1处 | -| 修改回调逻辑 | 修改3处 | 修改1个函数 | -| 添加新通知渠道 | 困难 | 容易(扩展函数) | -| 支持多环境 | 困难 | 容易(配置化) | - ---- - -## 稳定性对比 - -### 错误处理覆盖率 - -| 场景 | 当前版本 | 优化版本 | -|-----|---------|---------| -| 网络超时 | ❌ 无处理 | ✅ 重试+超时 | -| 参数错误 | ⚠️ 运行时失败 | ✅ 预先验证 | -| 依赖安装失败 | ⚠️ 简单重试 | ✅ 3次重试 | -| 回调失败 | ❌ 状态不同步 | ✅ 多重保障 | -| 中途中止 | ⚠️ 状态不明 | ✅ 回调通知 | - ---- - -## 使用建议 - -### 何时使用优化版本? - -✅ **推荐使用优化版本的场景**: -1. 生产环境部署 -2. 需要并行执行多个用例 -3. 对稳定性要求高 -4. 需要详细的执行日志 -5. 团队协作开发 - -⚠️ **可以继续使用当前版本的场景**: -1. 简单的测试场景 -2. 临时性测试 -3. 学习和实验环境 - ---- - -## 迁移建议 - -### 渐进式迁移路径 - -``` -阶段 1: 测试环境试用(1-2周) - ↓ -阶段 2: 并行运行对比(1周) - ↓ -阶段 3: 部分用例迁移(1-2周) - ↓ -阶段 4: 全量迁移(1周) - ↓ -阶段 5: 监控和优化(持续) -``` - -### 风险控制 - -1. **保留回滚方案**: 保留当前版本的 Jenkinsfile -2. **小范围试点**: 先在测试环境验证 -3. **灰度发布**: 逐步迁移用例 -4. **监控告警**: 密切关注执行状态 -5. **团队培训**: 确保团队熟悉新版本 - ---- - -## 总结 - -### 核心优势 - -| 维度 | 评分(满分5分) | -|-----|-------------| -| 可维护性 | ⭐⭐⭐⭐⭐ | -| 可扩展性 | ⭐⭐⭐⭐⭐ | -| 稳定性 | ⭐⭐⭐⭐⭐ | -| 性能 | ⭐⭐⭐⭐ | -| 易用性 | ⭐⭐⭐⭐ | - -### 投入产出比 - -- **开发成本**: ~2-3 天(一次性) -- **迁移成本**: ~1-2 周 -- **长期收益**: - - 维护成本降低 50% - - 执行时间减少 30-70%(并行时) - - 故障率降低 80% - - 团队效率提升 40% - -### 最终建议 - -🎯 **强烈推荐迁移到优化版本**,理由: -1. 长期维护成本大幅降低 -2. 稳定性和可靠性显著提升 -3. 支持更多高级特性 -4. 更好的可扩展性 -5. 投入产出比高 - ---- - -**文档版本**: v1.0.0 -**最后更新**: 2025-02-12 -**作者**: Claude Code diff --git a/docs/Jenkins/JENKINSFILE_FILEPATH_FIX.md b/docs/Jenkins/JENKINSFILE_FILEPATH_FIX.md deleted file mode 100644 index 41f4e8c..0000000 --- a/docs/Jenkins/JENKINSFILE_FILEPATH_FIX.md +++ /dev/null @@ -1,482 +0,0 @@ -# Jenkinsfile FilePath 上下文错误修复 - -## 问题描述 - -在修复了 `node` 块缺少 `label` 参数的问题后,出现了新的错误: - -``` -Required context class hudson.FilePath is missing -Perhaps you forgot to surround the step with a step that provides this, such as: node -``` - -### 错误详情 - -``` -归档测试报告失败: Required context class hudson.FilePath is missing -Perhaps you forgot to surround the step with a step that provides this, such as: node - -JUnit报告处理失败: Required context class hudson.FilePath is missing -Perhaps you forgot to surround the junit step with a step that provides this, such as: node - -回调失败: No such field found: field org.jenkinsci.plugins.workflow.support.steps.build.RunWrapper durationMillis -``` - -### 涉及的步骤 - -1. `archiveArtifacts` - 归档测试报告 -2. `junit` - 发布 JUnit 测试结果 -3. `sh` - 执行 shell 命令(回调) -4. `currentBuild.durationMillis` - 不存在的属性 - -## 根本原因 - -### 1. FilePath 上下文缺失 - -某些 Jenkins Pipeline 步骤需要 `FilePath` 上下文才能访问文件系统: -- `archiveArtifacts` - 需要访问工作空间中的文件 -- `junit` - 需要读取测试报告文件 -- `sh` - 需要在工作空间中执行命令 - -这些步骤必须在 `node` 块中执行,因为 `node` 块提供了工作空间和文件系统访问能力。 - -### 2. 属性名称错误 - -- ❌ `currentBuild.durationMillis` - 不存在 -- ✅ `currentBuild.duration` - 正确的属性名 - -### 3. Declarative vs Scripted Pipeline 的矛盾 - -- **Declarative Pipeline**: 新版本 Jenkins 要求 `node` 必须指定 `label` -- **实际需求**: 某些步骤必须在 `node` 块中执行 -- **解决方案**: 使用 `node('')` - 空字符串表示使用任意可用节点 - -## 解决方案 - -### 修复前 - -```groovy -post { - always { - script { - // ❌ 没有 node 块,缺少 FilePath 上下文 - archiveArtifacts artifacts: 'test-cases/test-report.json' - junit testResults: '**/test-cases/junit.xml' - - sh """ - curl -X POST '${callbackUrl}' \ - -d '{"durationMs": ${currentBuild.durationMillis}}' - """ - } - } -} -``` - -### 修复后 - -```groovy -post { - always { - node('') { // ✅ 添加 node('') 提供 FilePath 上下文 - script { - archiveArtifacts artifacts: 'test-cases/test-report.json' - junit testResults: '**/test-cases/junit.xml' - - def duration = currentBuild.duration ?: 0 // ✅ 使用正确的属性 - - sh """ - curl -X POST '${callbackUrl}' \ - -d '{"durationMs": ${duration}}' - """ - } - } - } -} -``` - -## 关键修改点 - -### 1. 添加 node('') 块 - -```groovy -post { - always { - node('') { // 空字符串 = 任意可用节点 - script { - // 需要文件系统访问的步骤 - } - } - } - - failure { - node('') { // 同样需要 node 块 - script { - // 失败处理逻辑 - } - } - } -} -``` - -**为什么使用 `node('')`?** -- `node('label')` - 指定特定标签的节点 -- `node('')` - 任意可用节点(等同于 `agent any`) -- 满足新版本 Jenkins 的 `label` 参数要求 -- 提供必需的 FilePath 上下文 - -### 2. 修复 duration 属性 - -```groovy -// ❌ 错误 -def duration = currentBuild.durationMillis ?: 0 - -// ✅ 正确 -def duration = currentBuild.duration ?: 0 -``` - -**currentBuild 可用属性**: -- `currentBuild.result` - 构建结果(SUCCESS/FAILURE/UNSTABLE) -- `currentBuild.duration` - 构建时长(毫秒) -- `currentBuild.number` - 构建编号 -- `currentBuild.displayName` - 显示名称 -- `currentBuild.description` - 描述 -- `currentBuild.startTimeInMillis` - 开始时间戳 - -### 3. 简化字符串处理 - -```groovy -// ❌ 复杂的转义 -sh ''' - curl -d "{ - \\"runId\\": ${RUN_ID}, - \\"status\\": \\"failed\\" - }" -''' - -// ✅ 使用双引号字符串 -sh """ - curl -d '{ - "runId": ${params.RUN_ID}, - "status": "failed" - }' -""" -``` - -## 完整的修复代码 - -### Always 块 - -```groovy -post { - always { - node('') { - script { - echo "清理环境..." - - // 归档测试报告 - try { - archiveArtifacts artifacts: 'test-cases/test-report.json', - allowEmptyArchive: true, - fingerprint: true - echo "测试报告已归档" - } catch (Exception e) { - echo "归档测试报告失败: ${e.message}" - } - - // 发布 JUnit 报告 - try { - junit allowEmptyResults: true, - testResults: '**/test-cases/junit.xml,**/test-cases/.pytest_cache/**/junit.xml' - } catch (Exception e) { - echo "JUnit报告处理失败: ${e.message}" - } - - // 最终回调 - if (params.RUN_ID) { - echo "========== 最终回调 ==========" - def callbackUrl = params.CALLBACK_URL ?: "${env.PLATFORM_API_URL}/api/jenkins/callback" - def finalStatus = currentBuild.result == 'SUCCESS' ? 'success' : 'failed' - def duration = currentBuild.duration ?: 0 - - echo "回调地址: ${callbackUrl}" - echo "运行ID: ${params.RUN_ID}" - echo "最终状态: ${finalStatus}" - echo "执行时长: ${duration}ms" - - try { - sh """ - curl -X POST '${callbackUrl}' \ - -H 'Content-Type: application/json' \ - -H 'X-Api-Key: ${env.JENKINS_API_KEY}' \ - -d '{ - "runId": ${params.RUN_ID}, - "status": "${finalStatus}", - "passedCases": 0, - "failedCases": ${currentBuild.result == 'SUCCESS' ? 0 : 1}, - "skippedCases": 0, - "durationMs": ${duration} - }' \ - || echo '❌ curl 回调失败' - """ - echo "✅ 回调成功" - } catch (Exception e) { - echo "⚠️ 回调失败: ${e.message}" - } - echo "===============================" - } - } - } - } -} -``` - -### Failure 块 - -```groovy -post { - failure { - node('') { - script { - echo "❌ Pipeline执行失败" - - if (params.RUN_ID && params.CALLBACK_URL) { - def duration = currentBuild.duration ?: 0 - - sh """ - echo "正在回调失败状态到平台..." - curl -X POST "${params.CALLBACK_URL}" \ - -H "Content-Type: application/json" \ - -H "X-Api-Key: ${env.JENKINS_API_KEY}" \ - -d '{ - "runId": ${params.RUN_ID}, - "status": "failed", - "passedCases": 0, - "failedCases": 0, - "skippedCases": 0, - "durationMs": ${duration}, - "buildUrl": "${BUILD_URL}" - }' \ - || echo "失败回调请求失败,但继续处理" - """ - } - } - } - } -} -``` - -## 验证修复 - -### 1. 检查语法 - -```bash -# 使用 Jenkins CLI 验证 -java -jar jenkins-cli.jar -s http://jenkins.example.com/ \ - declarative-linter < Jenkinsfile -``` - -### 2. 测试构建 - -```bash -# 触发测试构建 -curl -X POST "http://jenkins.example.com/job/test-automation/buildWithParameters" \ - --user "username:token" \ - --data-urlencode "RUN_ID=123" \ - --data-urlencode "SCRIPT_PATHS=test_case/test_sample.py" -``` - -### 3. 检查日志 - -构建应该显示: -``` -✅ 测试报告已归档 -✅ JUnit报告处理成功 -✅ 回调成功 -``` - -而不是: -``` -❌ Required context class hudson.FilePath is missing -❌ No such field found: durationMillis -``` - -## 常见问题 - -### Q1: 为什么不直接用 `agent any`? - -**A**: 在 Declarative Pipeline 的顶层使用 `agent any` 后,`post` 块中仍然需要 `node` 块来访问文件系统。这是 Jenkins Pipeline 的设计限制。 - -### Q2: `node('')` 和 `node` 有什么区别? - -**A**: -- `node` - 旧语法,新版本 Jenkins 不允许(缺少 label 参数) -- `node('')` - 新语法,空字符串表示任意可用节点 -- `node('label')` - 指定特定标签的节点 - -### Q3: 能否在 post 块中不使用 node? - -**A**: 可以,但需要移除所有需要文件系统访问的步骤: -```groovy -post { - always { - script { - // ✅ 只能使用不需要文件系统的操作 - echo "构建完成" - - // ❌ 不能使用这些步骤 - // archiveArtifacts - // junit - // sh - } - } -} -``` - -### Q4: currentBuild 还有哪些可用属性? - -**A**: 常用属性列表: -```groovy -currentBuild.result // SUCCESS/FAILURE/UNSTABLE/ABORTED -currentBuild.duration // 构建时长(毫秒) -currentBuild.number // 构建编号 -currentBuild.displayName // 显示名称 -currentBuild.description // 描述 -currentBuild.startTimeInMillis // 开始时间戳 -currentBuild.previousBuild // 上一次构建 -currentBuild.nextBuild // 下一次构建 -``` - -### Q5: 如何在不同节点上执行不同的 post 操作? - -**A**: 使用多个 node 块: -```groovy -post { - always { - // 在归档节点上归档报告 - node('archive-node') { - archiveArtifacts artifacts: '**/*.json' - } - - // 在通知节点上发送通知 - node('notification-node') { - sh 'send-notification.sh' - } - } -} -``` - -## 最佳实践 - -### 1. 错误处理 - -```groovy -post { - always { - node('') { - script { - // 每个操作都用 try-catch 包装 - try { - archiveArtifacts artifacts: 'reports/**' - } catch (Exception e) { - echo "归档失败: ${e.message}" - // 不要抛出异常,避免影响后续步骤 - } - } - } - } -} -``` - -### 2. 条件执行 - -```groovy -post { - always { - node('') { - script { - // 检查文件是否存在 - if (fileExists('test-report.json')) { - archiveArtifacts artifacts: 'test-report.json' - } else { - echo "报告文件不存在,跳过归档" - } - } - } - } -} -``` - -### 3. 变量提取 - -```groovy -post { - always { - node('') { - script { - // 提取变量,避免重复计算 - def status = currentBuild.result ?: 'SUCCESS' - def duration = currentBuild.duration ?: 0 - def buildUrl = env.BUILD_URL - - // 使用变量 - echo "状态: ${status}, 时长: ${duration}ms, URL: ${buildUrl}" - } - } - } -} -``` - -### 4. 日志输出 - -```groovy -post { - always { - node('') { - script { - echo "========== 构建后处理 ==========" - echo "结果: ${currentBuild.result}" - echo "时长: ${currentBuild.duration}ms" - echo "================================" - - // 执行操作... - - echo "========== 处理完成 ==========" - } - } - } -} -``` - -## 相关文档 - -- [Jenkins Pipeline Syntax](https://www.jenkins.io/doc/book/pipeline/syntax/) -- [Jenkins Pipeline Steps Reference](https://www.jenkins.io/doc/pipeline/steps/) -- [Jenkinsfile Node 块修复](./JENKINSFILE_NODE_FIX.md) -- [Jenkinsfile 优化指南](./JENKINSFILE_OPTIMIZATION.md) - -## 总结 - -### 问题根源 - -1. `post` 块中的某些步骤需要 FilePath 上下文 -2. FilePath 上下文由 `node` 块提供 -3. 新版本 Jenkins 要求 `node` 必须指定 `label` 参数 - -### 解决方案 - -1. 在 `post` 块中使用 `node('')` -2. 修复 `currentBuild.durationMillis` 为 `currentBuild.duration` -3. 简化字符串处理,避免复杂的转义 - -### 验证结果 - -- ✅ 语法检查通过 -- ✅ 测试报告归档成功 -- ✅ JUnit 报告发布成功 -- ✅ 回调请求成功 -- ✅ 构建状态正确同步 - ---- - -**修复日期**: 2025-02-12 -**Jenkins 版本**: 2.x+ -**状态**: ✅ 已修复并测试通过 diff --git a/docs/Jenkins/JENKINSFILE_NODE_FIX.md b/docs/Jenkins/JENKINSFILE_NODE_FIX.md deleted file mode 100644 index fc0c986..0000000 --- a/docs/Jenkins/JENKINSFILE_NODE_FIX.md +++ /dev/null @@ -1,444 +0,0 @@ -# Jenkinsfile Node 块错误修复 - -## 问题描述 - -在运行 Jenkinsfile 时遇到以下错误: - -``` -org.codehaus.groovy.control.MultipleCompilationErrorsException: startup failed: -WorkflowScript: 259: Missing required parameter: "label" @ line 259, column 13. - node { - ^ - -WorkflowScript: 319: Missing required parameter: "label" @ line 319, column 13. - node { - ^ - -2 errors -``` - -## 根本原因 - -在 Jenkins Pipeline 的 `post` 块中使用了 `node {}` 而没有指定 `label` 参数。 - -### 为什么会出现这个错误? - -1. **Jenkins 版本变化**: 新版本的 Jenkins 要求在使用 `node` 时必须指定 `label` 参数 -2. **不必要的嵌套**: 由于 Pipeline 已经在顶层使用了 `agent any`,在 `post` 块中不需要再次分配节点 -3. **Declarative Pipeline 限制**: 在 Declarative Pipeline 的 `post` 块中,不应该使用 `node` 块 - -## 解决方案 - -### 修复前 - -```groovy -post { - always { - node { // ❌ 错误: 缺少 label 参数 - script { - echo "清理环境..." - // ... - } - } - } - - failure { - node { // ❌ 错误: 缺少 label 参数 - script { - echo "Pipeline执行失败" - // ... - } - } - } -} -``` - -### 修复后 - -```groovy -post { - always { - script { // ✅ 正确: 直接使用 script 块 - echo "清理环境..." - // ... - } - } - - failure { - script { // ✅ 正确: 直接使用 script 块 - echo "Pipeline执行失败" - // ... - } - } -} -``` - -## 为什么这样修复? - -### 1. Pipeline 已经有 Agent - -```groovy -pipeline { - agent any // 已经在顶层分配了节点 - - stages { - // ... - } - - post { - // 这里可以直接使用分配的节点,不需要再次分配 - always { - script { - // 直接执行命令 - } - } - } -} -``` - -### 2. Declarative Pipeline 的设计 - -在 Declarative Pipeline 中: -- `agent` 在顶层定义,整个 Pipeline 共享 -- `post` 块自动继承顶层的 agent -- 不需要(也不应该)在 `post` 块中再次使用 `node` - -### 3. 如果确实需要不同的节点 - -如果确实需要在 `post` 块中使用不同的节点,应该指定 `label`: - -```groovy -post { - always { - node('specific-label') { // ✅ 指定 label - script { - // ... - } - } - } -} -``` - -或者使用 `agent` 指令: - -```groovy -pipeline { - agent any - - stages { - // ... - } - - post { - always { - // 在特定节点上执行 - node('cleanup-node') { - script { - // ... - } - } - } - } -} -``` - -## 完整的修复步骤 - -### 步骤 1: 备份当前文件 - -```bash -cp Jenkinsfile Jenkinsfile.backup.$(date +%Y%m%d_%H%M%S) -``` - -### 步骤 2: 应用修复 - -修改 Jenkinsfile,移除 `post` 块中的所有 `node` 包装: - -```diff - post { - always { -- node { - script { - echo "清理环境..." - // ... - } -- } - } - - failure { -- node { - script { - echo "Pipeline执行失败" - // ... - } -- } - } - } -``` - -### 步骤 3: 验证语法 - -在 Jenkins 中: -1. 打开 Pipeline Job -2. 点击 "Pipeline Syntax" -3. 粘贴修改后的 Jenkinsfile -4. 点击 "Validate Declarative Pipeline" - -或使用命令行: - -```bash -# 安装 Jenkins CLI -java -jar jenkins-cli.jar -s http://jenkins.example.com/ declarative-linter < Jenkinsfile -``` - -### 步骤 4: 测试运行 - -```bash -# 触发一次测试构建 -curl -X POST "http://jenkins.example.com/job/test-automation/build" \ - --user "username:token" \ - --data-urlencode "RUN_ID=test-123" \ - --data-urlencode "SCRIPT_PATHS=test_case/test_sample.py" -``` - -## 其他常见的 Node 相关错误 - -### 错误 1: 在 steps 中使用 node - -```groovy -// ❌ 错误 -stage('测试') { - steps { - node { - sh 'pytest' - } - } -} - -// ✅ 正确 -stage('测试') { - steps { - sh 'pytest' - } -} -``` - -### 错误 2: 混用 Declarative 和 Scripted 语法 - -```groovy -// ❌ 错误 -pipeline { - agent any - stages { - stage('测试') { - steps { - node('test-node') { // Scripted 语法 - sh 'pytest' - } - } - } - } -} - -// ✅ 正确 - 使用 Declarative 语法 -pipeline { - agent { label 'test-node' } - stages { - stage('测试') { - steps { - sh 'pytest' - } - } - } -} - -// ✅ 正确 - 或在特定 stage 中使用不同节点 -pipeline { - agent any - stages { - stage('测试') { - agent { label 'test-node' } - steps { - sh 'pytest' - } - } - } -} -``` - -### 错误 3: 在 parallel 中使用 node - -```groovy -// ❌ 错误 -stage('并行测试') { - parallel { - stage('API测试') { - steps { - node { - sh 'pytest test_api/' - } - } - } - } -} - -// ✅ 正确 -stage('并行测试') { - parallel { - stage('API测试') { - agent { label 'test-node' } - steps { - sh 'pytest test_api/' - } - } - } -} -``` - -## 最佳实践 - -### 1. 使用 Declarative Pipeline - -优先使用 Declarative Pipeline 而不是 Scripted Pipeline: - -```groovy -// ✅ 推荐: Declarative Pipeline -pipeline { - agent any - stages { - stage('测试') { - steps { - sh 'pytest' - } - } - } -} - -// ⚠️ 不推荐: Scripted Pipeline (除非有特殊需求) -node { - stage('测试') { - sh 'pytest' - } -} -``` - -### 2. 在顶层定义 Agent - -```groovy -// ✅ 推荐 -pipeline { - agent any // 顶层定义 - stages { - // ... - } -} - -// ⚠️ 不推荐 -pipeline { - agent none - stages { - stage('测试') { - agent any // 每个 stage 都要定义 - steps { - // ... - } - } - } -} -``` - -### 3. 需要不同节点时使用 Agent - -```groovy -// ✅ 推荐 -pipeline { - agent any - stages { - stage('构建') { - agent { label 'build-node' } - steps { - sh 'make build' - } - } - stage('测试') { - agent { label 'test-node' } - steps { - sh 'pytest' - } - } - } -} -``` - -### 4. Post 块中避免使用 Node - -```groovy -// ✅ 推荐 -post { - always { - script { - // 清理操作 - } - } -} - -// ❌ 不推荐 -post { - always { - node('cleanup-node') { - // 清理操作 - } - } -} -``` - -## 验证修复 - -### 检查清单 - -- [ ] 移除 `post` 块中的所有 `node` 包装 -- [ ] 保留 `script` 块 -- [ ] 验证 Pipeline 语法 -- [ ] 测试运行成功 -- [ ] 检查回调是否正常工作 -- [ ] 查看构建日志确认无错误 - -### 测试命令 - -```bash -# 1. 语法验证 -java -jar jenkins-cli.jar -s http://jenkins.example.com/ \ - declarative-linter < Jenkinsfile - -# 2. 触发测试构建 -curl -X POST "http://jenkins.example.com/job/test-automation/buildWithParameters" \ - --user "username:token" \ - --data-urlencode "RUN_ID=123" \ - --data-urlencode "SCRIPT_PATHS=test_case/test_sample.py" - -# 3. 查看构建日志 -curl "http://jenkins.example.com/job/test-automation/lastBuild/consoleText" \ - --user "username:token" -``` - -## 相关文档 - -- [Jenkins Declarative Pipeline Syntax](https://www.jenkins.io/doc/book/pipeline/syntax/) -- [Jenkins Pipeline Best Practices](https://www.jenkins.io/doc/book/pipeline/pipeline-best-practices/) -- [Jenkinsfile 优化指南](./JENKINSFILE_OPTIMIZATION.md) - -## 总结 - -**问题**: `post` 块中使用 `node` 缺少 `label` 参数 - -**解决**: 移除 `node` 包装,直接使用 `script` 块 - -**原因**: Declarative Pipeline 的 `post` 块自动继承顶层 agent,不需要再次分配节点 - -**影响**: 修复后 Pipeline 可以正常运行,不影响功能 - ---- - -**修复日期**: 2025-02-12 -**Jenkins 版本**: 2.x+ -**状态**: ✅ 已修复 diff --git a/docs/Jenkins/JENKINSFILE_OPTIMIZATION.md b/docs/Jenkins/JENKINSFILE_OPTIMIZATION.md deleted file mode 100644 index d40c3de..0000000 --- a/docs/Jenkins/JENKINSFILE_OPTIMIZATION.md +++ /dev/null @@ -1,629 +0,0 @@ -# Jenkinsfile 优化方案 - -## 概述 - -本文档对比当前 Jenkinsfile 和优化后的版本,并提供迁移指南。 - ---- - -## 主要改进点 - -### 1. 结构化配置管理 - -**问题**: 当前配置分散在多处,难以维护 -**解决**: 集中配置管理 - -```groovy -// ✅ 优化后 - 集中配置 -def CONFIG = [ - platformUrl: env.PLATFORM_API_URL ?: 'http://localhost:3000', - apiKey: env.JENKINS_API_KEY ?: '', - pythonVersion: '3.9', - // ... 其他配置 -] -``` - -### 2. 消除代码重复 - -**问题**: 回调逻辑在多个地方重复 - -```groovy -// ❌ 当前版本 - 重复代码 -stage('回调平台') { - sh ''' - curl -X POST "${CALLBACK_URL}" \ - -H "Content-Type: application/json" \ - -d "{ ... }" - ''' -} - -post { - always { - sh ''' - curl -X POST "${CALLBACK_URL}" \ - -H "Content-Type: application/json" \ - -d "{ ... }" - ''' - } -} -``` - -```groovy -// ✅ 优化后 - 函数复用 -def notifyPlatform(event, data = [:]) { - // 统一的回调逻辑 -} - -stage('回调平台') { - notifyPlatform('complete', results) -} - -post { - always { - finalCallback() - } -} -``` - -### 3. 增强错误处理 - -**问题**: 错误处理不够健壮,可能导致状态不同步 - -```groovy -// ❌ 当前版本 - 简单错误处理 -sh ''' - curl ... || echo "回调失败" -''' -``` - -```groovy -// ✅ 优化后 - 完善的错误处理 -retry(CONFIG.maxRetries) { - sh """ - curl ... \ - --max-time 30 \ - --retry 3 \ - --retry-delay ${CONFIG.retryDelay} \ - || (echo '❌ 回调失败' && exit 1) - """ -} -``` - -### 4. 参数验证 - -**新增功能**: 在执行前验证所有必需参数 - -```groovy -def validateParameters() { - def errors = [] - - if (!params.RUN_ID || params.RUN_ID.trim() == '') { - errors.add("RUN_ID 不能为空") - } - - if (!params.SCRIPT_PATHS && !params.MARKER) { - errors.add("必须指定 SCRIPT_PATHS 或 MARKER 之一") - } - - if (errors.size() > 0) { - error("参数验证失败:\n" + errors.join("\n")) - } -} -``` - -### 5. 结构化日志输出 - -**改进**: 使用格式化的日志输出,便于追踪问题 - -```groovy -// ✅ 优化后 - 结构化输出 -echo """ -╔════════════════════════════════════════════════════════════════╗ -║ 构建信息 ║ -╠════════════════════════════════════════════════════════════════╣ -║ 构建编号: ${BUILD_NUMBER} -║ 运行ID: ${params.RUN_ID ?: '未指定'} -║ ... -╚════════════════════════════════════════════════════════════════╝ -""" -``` - -### 6. 并行执行支持 - -**新增功能**: 支持并行执行多个测试用例 - -```groovy -booleanParam( - name: 'ENABLE_PARALLEL', - defaultValue: false, - description: '启用并行执行(仅适用于多用例)' -) - -// 在测试命令中使用 -if (params.ENABLE_PARALLEL) { - command += " -n auto" -} -``` - -### 7. 灵活的 Python 版本选择 - -**新增功能**: 支持选择 Python 版本 - -```groovy -choice( - name: 'PYTHON_VERSION', - choices: ['3.9', '3.10', '3.11'], - description: 'Python版本' -) -``` - -### 8. 分离测试执行和镜像构建 - -**改进**: Docker 镜像构建作为可选步骤 - -```groovy -booleanParam( - name: 'BUILD_DOCKER_IMAGE', - defaultValue: false, - description: '构建并推送Docker镜像' -) - -stage('构建镜像') { - when { - expression { - return params.BUILD_DOCKER_IMAGE && currentBuild.result == null - } - } - steps { - buildAndPushDockerImage() - } -} -``` - -### 9. 增强的测试结果收集 - -**改进**: 更详细的结果解析和错误信息 - -```groovy -def collectTestResults() { - def results = [ - runId: params.RUN_ID.toInteger(), - status: 'success', - passedCases: 0, - failedCases: 0, - skippedCases: 0, - totalCases: 0, - durationMs: 0, - buildUrl: BUILD_URL, - buildNumber: BUILD_NUMBER, - results: [] // 详细的每个用例结果 - ] - - // 解析 JSON 报告 - if (fileExists(CONFIG.reportFile)) { - def report = readJSON(file: CONFIG.reportFile) - // 提取详细信息... - } - - return results -} -``` - -### 10. 完善的后处理逻辑 - -**改进**: 覆盖所有构建状态 - -```groovy -post { - always { /* 归档报告,最终回调,清理 */ } - success { /* 成功通知 */ } - failure { /* 失败处理 */ } - unstable { /* 不稳定处理 */ } - aborted { /* 中止处理 */ } -} -``` - ---- - -## 功能对比表 - -| 功能 | 当前版本 | 优化版本 | 说明 | -|-----|---------|---------|------| -| 配置管理 | ❌ 分散 | ✅ 集中 | 使用 CONFIG 对象 | -| 代码复用 | ❌ 重复 | ✅ 函数化 | 提取公共函数 | -| 参数验证 | ❌ 无 | ✅ 完善 | 执行前验证 | -| 错误处理 | ⚠️ 基础 | ✅ 健壮 | 重试机制 + 详细日志 | -| 并行执行 | ❌ 不支持 | ✅ 支持 | pytest -n auto | -| Python 版本 | ⚠️ 固定 | ✅ 可选 | 3.9/3.10/3.11 | -| 结果收集 | ⚠️ 基础 | ✅ 详细 | 包含每个用例详情 | -| 日志输出 | ⚠️ 简单 | ✅ 结构化 | 易于追踪 | -| 镜像构建 | ⚠️ 强制 | ✅ 可选 | 按需构建 | -| 状态同步 | ⚠️ 不完善 | ✅ 可靠 | 多重保障 | -| 报告归档 | ✅ 支持 | ✅ 增强 | 支持 HTML 报告 | -| 通知机制 | ❌ 无 | ✅ 预留 | 邮件/钉钉/企微 | - ---- - -## 迁移指南 - -### 步骤 1: 备份当前配置 - -```bash -# 备份当前 Jenkinsfile -cp Jenkinsfile Jenkinsfile.backup.$(date +%Y%m%d) -``` - -### 步骤 2: 更新环境变量 - -在 Jenkins 中配置以下凭据: - -1. **jenkins-api-key** (Secret text) - - 平台 API 密钥 - -2. **git-credentials** (Username with password) - - Git 仓库凭据 - -3. **aliyun-docker** (Username with password) - - 阿里云 Docker Registry 凭据 - -### 步骤 3: 创建新的 Pipeline Job - -```groovy -// 在 Jenkins 中创建新的 Pipeline Job -// 选择 "Pipeline script from SCM" -// 指定 Jenkinsfile 路径: Jenkinsfile.optimized -``` - -### 步骤 4: 配置 Job 参数 - -新版本的参数更丰富,需要在 Job 配置中确保以下参数可用: - -**必需参数**: -- `RUN_ID`: 执行批次ID -- `SCRIPT_PATHS`: 脚本路径(逗号分隔) - -**可选参数**: -- `CASE_IDS`: 用例ID列表 -- `CALLBACK_URL`: 回调URL -- `MARKER`: Pytest marker -- `REPO_URL`: 仓库URL -- `REPO_BRANCH`: 仓库分支 -- `PYTHON_VERSION`: Python版本(3.9/3.10/3.11) -- `ENABLE_PARALLEL`: 启用并行执行 -- `BUILD_DOCKER_IMAGE`: 构建Docker镜像 -- `SKIP_CLEANUP`: 跳过清理(调试用) - -### 步骤 5: 更新后端调用代码 - -如果后端代码需要调整,更新 JenkinsService: - -```typescript -// server/services/JenkinsService.ts - -async triggerJob(params: { - runId: number; - scriptPaths: string[]; - caseIds?: number[]; - marker?: string; - repoUrl?: string; - repoBranch?: string; - pythonVersion?: '3.9' | '3.10' | '3.11'; - enableParallel?: boolean; - buildDockerImage?: boolean; -}) { - const jobParams = { - RUN_ID: params.runId.toString(), - SCRIPT_PATHS: params.scriptPaths.join(','), - CASE_IDS: JSON.stringify(params.caseIds || []), - MARKER: params.marker || '', - REPO_URL: params.repoUrl || '', - REPO_BRANCH: params.repoBranch || 'main', - PYTHON_VERSION: params.pythonVersion || '3.9', - ENABLE_PARALLEL: params.enableParallel || false, - BUILD_DOCKER_IMAGE: params.buildDockerImage || false, - CALLBACK_URL: `${this.platformUrl}/api/jenkins/callback`, - }; - - // 触发 Jenkins Job... -} -``` - -### 步骤 6: 测试新配置 - -```bash -# 1. 测试单个用例执行 -curl -X POST "http://jenkins.example.com/job/test-automation/buildWithParameters" \ - --user "username:token" \ - --data-urlencode "RUN_ID=123" \ - --data-urlencode "SCRIPT_PATHS=test_case/test_login.py::TestLogin::test_user_login" - -# 2. 测试并行执行 -curl -X POST "http://jenkins.example.com/job/test-automation/buildWithParameters" \ - --user "username:token" \ - --data-urlencode "RUN_ID=124" \ - --data-urlencode "SCRIPT_PATHS=test_case/test_login.py,test_case/test_register.py" \ - --data-urlencode "ENABLE_PARALLEL=true" - -# 3. 测试镜像构建 -curl -X POST "http://jenkins.example.com/job/test-automation/buildWithParameters" \ - --user "username:token" \ - --data-urlencode "RUN_ID=125" \ - --data-urlencode "SCRIPT_PATHS=test_case/test_login.py" \ - --data-urlencode "BUILD_DOCKER_IMAGE=true" -``` - -### 步骤 7: 监控和调试 - -1. **查看构建日志**: - - 新版本提供更详细的结构化日志 - - 便于定位问题 - -2. **检查回调状态**: - ```bash - # 查看平台执行记录 - curl http://localhost:3000/api/executions/123 - ``` - -3. **调试模式**: - - 设置 `SKIP_CLEANUP=true` 保留测试环境 - - 手动检查测试报告和日志 - ---- - -## 性能优化建议 - -### 1. 使用 Jenkins Agent 池 - -```groovy -pipeline { - agent { - label 'python-test-agent' // 使用专用测试节点 - } - // ... -} -``` - -### 2. 启用工作空间缓存 - -```groovy -options { - skipDefaultCheckout() // 跳过默认检出 -} - -stage('检出代码') { - steps { - // 增量更新而非完整克隆 - checkout scm - } -} -``` - -### 3. 使用 Docker 容器化执行 - -```groovy -pipeline { - agent { - docker { - image 'python:3.9-slim' - args '-v /var/run/docker.sock:/var/run/docker.sock' - } - } - // ... -} -``` - -### 4. 并行执行多个测试套件 - -```groovy -stage('并行测试') { - parallel { - stage('API测试') { - steps { - sh 'pytest test_case/api/ -n auto' - } - } - stage('UI测试') { - steps { - sh 'pytest test_case/ui/ -n auto' - } - } - } -} -``` - ---- - -## 故障排查 - -### 问题 1: 参数验证失败 - -**症状**: Pipeline 在初始化阶段失败 -**原因**: 缺少必需参数或参数格式错误 -**解决**: -```bash -# 检查参数 -curl http://jenkins.example.com/job/test-automation/api/json | jq '.property[] | select(.parameterDefinitions)' - -# 确保传递所有必需参数 -RUN_ID=123 -SCRIPT_PATHS=test_case/test_login.py -``` - -### 问题 2: 回调失败 - -**症状**: 测试执行完成,但平台状态未更新 -**原因**: 网络问题或 API Key 错误 -**解决**: -```bash -# 1. 检查网络连接 -curl -v http://localhost:3000/api/jenkins/callback - -# 2. 验证 API Key -curl -X POST http://localhost:3000/api/jenkins/callback \ - -H "X-Api-Key: YOUR_API_KEY" \ - -H "Content-Type: application/json" \ - -d '{"runId": 123, "status": "success"}' - -# 3. 查看 Jenkins 日志 -tail -f /var/log/jenkins/jenkins.log | grep callback -``` - -### 问题 3: Python 环境问题 - -**症状**: 依赖安装失败或测试无法运行 -**原因**: Python 版本不兼容或依赖冲突 -**解决**: -```bash -# 1. 指定正确的 Python 版本 -PYTHON_VERSION=3.9 - -# 2. 清理虚拟环境 -rm -rf venv - -# 3. 使用 requirements.txt 锁定版本 -pip freeze > requirements.txt -``` - -### 问题 4: 并行执行冲突 - -**症状**: 并行执行时出现资源竞争 -**原因**: 测试用例之间存在依赖或共享资源 -**解决**: -```bash -# 1. 禁用并行执行 -ENABLE_PARALLEL=false - -# 2. 使用 pytest-xdist 的隔离模式 -pytest -n auto --dist loadscope - -# 3. 重构测试用例,消除依赖 -``` - ---- - -## 最佳实践 - -### 1. 版本控制 - -- ✅ 将 Jenkinsfile 纳入版本控制 -- ✅ 使用语义化版本号标记重大变更 -- ✅ 维护详细的变更日志 - -### 2. 安全性 - -- ✅ 使用 Jenkins Credentials 管理敏感信息 -- ✅ 限制 API Key 的权限范围 -- ✅ 定期轮换凭据 - -### 3. 可维护性 - -- ✅ 提取公共函数,避免代码重复 -- ✅ 使用有意义的变量名和注释 -- ✅ 保持 Pipeline 简洁,复杂逻辑移到共享库 - -### 4. 可观测性 - -- ✅ 记录详细的日志 -- ✅ 使用结构化输出 -- ✅ 集成监控和告警系统 - -### 5. 测试 - -- ✅ 在非生产环境测试 Pipeline 变更 -- ✅ 使用 Blue Ocean 可视化 Pipeline -- ✅ 定期审查和优化性能 - ---- - -## 进阶功能 - -### 1. 动态 Agent 分配 - -```groovy -pipeline { - agent none - - stages { - stage('轻量级任务') { - agent { label 'small' } - steps { /* ... */ } - } - - stage('重型任务') { - agent { label 'large' } - steps { /* ... */ } - } - } -} -``` - -### 2. 条件执行 - -```groovy -stage('性能测试') { - when { - expression { - return params.MARKER == 'performance' - } - } - steps { /* ... */ } -} -``` - -### 3. 输入确认 - -```groovy -stage('部署生产') { - input { - message "确认部署到生产环境?" - ok "部署" - parameters { - choice(name: 'ENVIRONMENT', choices: ['staging', 'production']) - } - } - steps { /* ... */ } -} -``` - -### 4. 矩阵构建 - -```groovy -matrix { - axes { - axis { - name 'PYTHON_VERSION' - values '3.9', '3.10', '3.11' - } - axis { - name 'OS' - values 'linux', 'windows' - } - } - stages { - stage('测试') { - steps { - sh "pytest --python=${PYTHON_VERSION}" - } - } - } -} -``` - ---- - -## 总结 - -优化后的 Jenkinsfile 提供了: - -✅ **更好的可维护性**: 模块化设计,代码复用 -✅ **更强的健壮性**: 完善的错误处理和重试机制 -✅ **更高的灵活性**: 丰富的参数配置和条件执行 -✅ **更好的可观测性**: 结构化日志和详细的状态报告 -✅ **更优的性能**: 并行执行和资源优化 - -建议逐步迁移,先在测试环境验证,确认无误后再应用到生产环境。 - ---- - -**最后更新**: 2025-02-12 -**版本**: v2.0.0 diff --git a/docs/Jenkins/JENKINSFILE_QUICK_FIX.md b/docs/Jenkins/JENKINSFILE_QUICK_FIX.md deleted file mode 100644 index 28447fa..0000000 --- a/docs/Jenkins/JENKINSFILE_QUICK_FIX.md +++ /dev/null @@ -1,324 +0,0 @@ -# Jenkinsfile 快速修复指南 - -## 🚨 常见错误速查 - -### 错误 1: Missing required parameter: "label" - -``` -Missing required parameter: "label" @ line 259, column 13. - node { - ^ -``` - -**原因**: 新版本 Jenkins 要求 `node` 必须指定 `label` 参数 - -**快速修复**: -```groovy -# ❌ 错误 -node { - script { ... } -} - -# ✅ 修复 -node('') { # 空字符串 = 任意节点 - script { ... } -} -``` - ---- - -### 错误 2: Required context class hudson.FilePath is missing - -``` -Required context class hudson.FilePath is missing -Perhaps you forgot to surround the step with a step that provides this, such as: node -``` - -**原因**: `archiveArtifacts`, `junit`, `sh` 等步骤需要在 `node` 块中执行 - -**快速修复**: -```groovy -# ❌ 错误 -post { - always { - script { - archiveArtifacts artifacts: '*.json' - } - } -} - -# ✅ 修复 -post { - always { - node('') { - script { - archiveArtifacts artifacts: '*.json' - } - } - } -} -``` - ---- - -### 错误 3: No such field found: durationMillis - -``` -No such field found: field org.jenkinsci.plugins.workflow.support.steps.build.RunWrapper durationMillis -``` - -**原因**: 属性名错误,应该是 `duration` 而不是 `durationMillis` - -**快速修复**: -```groovy -# ❌ 错误 -def duration = currentBuild.durationMillis - -# ✅ 修复 -def duration = currentBuild.duration -``` - ---- - -## 🔧 标准模板 - -### Post 块标准模板 - -```groovy -post { - always { - node('') { - script { - echo "清理环境..." - - // 归档报告 - try { - archiveArtifacts artifacts: 'test-cases/test-report.json', - allowEmptyArchive: true, - fingerprint: true - } catch (Exception e) { - echo "归档失败: ${e.message}" - } - - // JUnit 报告 - try { - junit allowEmptyResults: true, - testResults: '**/test-cases/junit.xml' - } catch (Exception e) { - echo "JUnit报告失败: ${e.message}" - } - - // 回调平台 - if (params.RUN_ID) { - def callbackUrl = params.CALLBACK_URL ?: "${env.PLATFORM_API_URL}/api/jenkins/callback" - def finalStatus = currentBuild.result == 'SUCCESS' ? 'success' : 'failed' - def duration = currentBuild.duration ?: 0 - - try { - sh """ - curl -X POST '${callbackUrl}' \ - -H 'Content-Type: application/json' \ - -H 'X-Api-Key: ${env.JENKINS_API_KEY}' \ - -d '{ - "runId": ${params.RUN_ID}, - "status": "${finalStatus}", - "durationMs": ${duration} - }' - """ - } catch (Exception e) { - echo "回调失败: ${e.message}" - } - } - } - } - } - - success { - script { - echo "✅ Pipeline执行成功" - } - } - - failure { - node('') { - script { - echo "❌ Pipeline执行失败" - - if (params.RUN_ID && params.CALLBACK_URL) { - def duration = currentBuild.duration ?: 0 - - sh """ - curl -X POST "${params.CALLBACK_URL}" \ - -H "Content-Type: application/json" \ - -H "X-Api-Key: ${env.JENKINS_API_KEY}" \ - -d '{ - "runId": ${params.RUN_ID}, - "status": "failed", - "durationMs": ${duration} - }' - """ - } - } - } - } -} -``` - ---- - -## 📋 检查清单 - -修复 Jenkinsfile 时,请检查以下项目: - -### 语法检查 -- [ ] 所有 `node` 块都有 `label` 参数(即使是空字符串) -- [ ] `post` 块中需要文件系统访问的步骤都在 `node` 块中 -- [ ] 使用 `currentBuild.duration` 而不是 `currentBuild.durationMillis` -- [ ] 字符串转义正确(建议使用双引号字符串) - -### 功能检查 -- [ ] 测试报告能正常归档 -- [ ] JUnit 报告能正常发布 -- [ ] 回调请求能成功发送 -- [ ] 错误处理逻辑完善(使用 try-catch) - -### 测试验证 -- [ ] 语法验证通过 -- [ ] 测试构建成功 -- [ ] 构建日志无错误 -- [ ] 平台状态同步正确 - ---- - -## 🚀 快速修复步骤 - -### 1. 备份当前文件 -```bash -cp Jenkinsfile Jenkinsfile.backup.$(date +%Y%m%d_%H%M%S) -``` - -### 2. 应用修复 -使用上面的标准模板替换 `post` 块 - -### 3. 验证语法 -```bash -java -jar jenkins-cli.jar -s http://jenkins.example.com/ \ - declarative-linter < Jenkinsfile -``` - -### 4. 提交并推送 -```bash -git add Jenkinsfile -git commit -m "fix: 修复 Jenkinsfile 的 node 和 FilePath 问题" -git push origin master -``` - -### 5. 测试构建 -在 Jenkins 中触发一次测试构建,验证修复是否成功 - ---- - -## 💡 关键要点 - -### Node 块使用规则 - -| 场景 | 是否需要 node | Label 参数 | -|-----|-------------|-----------| -| stages 中的 steps | ❌ 否 | - | -| post 块中的 script | ✅ 是 | `''` (空字符串) | -| 需要访问文件系统 | ✅ 是 | `''` 或具体 label | -| 只是打印日志 | ❌ 否 | - | - -### CurrentBuild 属性速查 - -| 属性 | 类型 | 说明 | 示例 | -|-----|------|------|------| -| `result` | String | 构建结果 | SUCCESS/FAILURE | -| `duration` | Long | 构建时长(毫秒) | 12345 | -| `number` | Integer | 构建编号 | 42 | -| `displayName` | String | 显示名称 | #42 | -| `startTimeInMillis` | Long | 开始时间戳 | 1234567890000 | - -### 字符串处理技巧 - -```groovy -# 单引号字符串 - 不支持变量插值 -sh ''' - echo "固定文本" -''' - -# 双引号字符串 - 支持变量插值 -sh """ - echo "变量值: ${params.RUN_ID}" -""" - -# JSON 数据最佳实践 -sh """ - curl -d '{ - "key": "${value}" - }' -""" -``` - ---- - -## 🔍 故障排查 - -### 问题: 修复后还是报错 - -**检查项**: -1. 确认修改已提交并推送到 Git -2. Jenkins 是否从正确的分支读取 Jenkinsfile -3. 清除 Jenkins 工作空间缓存 -4. 检查 Jenkins 版本是否支持语法 - -**解决步骤**: -```bash -# 1. 确认 Git 状态 -git status -git log -1 --oneline - -# 2. 检查远程分支 -git ls-remote --heads origin - -# 3. 强制 Jenkins 重新拉取 -# 在 Jenkins Job 配置中勾选 "Clean before checkout" - -# 4. 清除工作空间 -# 在 Jenkins Job 页面点击 "Wipe Out Workspace" -``` - -### 问题: 回调失败 - -**检查项**: -1. 网络连接是否正常 -2. API Key 是否正确 -3. 回调 URL 是否可访问 -4. JSON 格式是否正确 - -**测试命令**: -```bash -# 测试回调接口 -curl -X POST "http://localhost:3000/api/jenkins/callback" \ - -H "Content-Type: application/json" \ - -H "X-Api-Key: YOUR_API_KEY" \ - -d '{ - "runId": 123, - "status": "success", - "durationMs": 1000 - }' -``` - ---- - -## 📚 相关文档 - -- [JENKINSFILE_NODE_FIX.md](./JENKINSFILE_NODE_FIX.md) - Node 块错误详细说明 -- [JENKINSFILE_FILEPATH_FIX.md](./JENKINSFILE_FILEPATH_FIX.md) - FilePath 上下文错误详细说明 -- [JENKINSFILE_OPTIMIZATION.md](./JENKINSFILE_OPTIMIZATION.md) - 完整的优化方案 -- [JENKINSFILE_COMPARISON.md](./JENKINSFILE_COMPARISON.md) - 版本对比 - ---- - -**最后更新**: 2025-02-12 -**适用版本**: Jenkins 2.x+ diff --git a/docs/Jenkins/JENKINS_JENKINSFILE_FIX.md b/docs/Jenkins/JENKINS_JENKINSFILE_FIX.md deleted file mode 100644 index 1524be6..0000000 --- a/docs/Jenkins/JENKINS_JENKINSFILE_FIX.md +++ /dev/null @@ -1,364 +0,0 @@ -# Jenkins Jenkinsfile 修复指南 - -## 问题描述 -Jenkins 构建报错: -``` -org.codehaus.groovy.control.MultipleCompilationErrorsException: startup failed: -WorkflowScript: 201: Missing required parameter: "label" @ line 201, column 13. - node { - ^ - -WorkflowScript: 276: Missing required parameter: "label" @ line 276, column 13. - node { - ^ - -WorkflowScript: 284: Missing required parameter: "label" @ line 284, column 13. - node { - ^ - -3 errors -``` - -## 根本原因 - -**错误的诊断**:最初认为是 `post` 块中使用了无参数的 `node { }` 语句。 - -**实际原因**:`stage('构建镜像')` 块(原本在第 301-358 行)被**错误地放置在 `post` 块内部**,而不是在 `stages` 块中。这导致 Jenkins 声明式流水线解析器无法正确解析文件结构,从而报告了误导性的错误信息。 - -### 错误的结构(修复前) -```groovy -pipeline { - agent any - - stages { - stage('准备') { ... } - stage('检出代码') { ... } - stage('准备环境') { ... } - stage('执行测试') { ... } - stage('收集结果') { ... } - stage('回调平台') { ... } - } // ← stages 块应该在这里关闭 - - post { - always { script { ... } } - success { script { ... } } - failure { script { ... } } - stage('构建镜像') { ... } // ❌ 错误!stage 不能在 post 块中 - } -} -``` - -### 为什么会报告 "Missing required parameter: label" 错误? - -1. Jenkins 解析器在读到第 301 行时发现了结构错误(stage 在 post 块中) -2. 解析器回溯并尝试重新解释之前的代码块 -3. 它误将 `post` 块中的 `script { }` 块解释为可能的 `node { }` 块(脚本式流水线语法) -4. 在现代 Jenkins 中,`node` 块需要一个 `label` 参数来指定运行的代理 -5. 因此报告了"Missing required parameter: label"错误,尽管实际上代码中并没有使用 `node` 块 - -## 已修复内容 - -✅ **Jenkinsfile 已修复**(2026-02-09): - -1. **移动 `stage('构建镜像')` 块**: - - 从:第 301-358 行(在 `post` 块内) - - 到:第 196-253 行(在 `stages` 块内,`stage('回调平台')` 之后) - -2. **修复结构**: - - `stages` 块现在正确地在第 254 行关闭 - - `post` 块从第 256 行开始,包含 `always`、`success`、`failure` 三个部分 - - `pipeline` 块在第 361 行正确关闭 - -3. **验证结果**: - - ✅ 共 7 个 stage,全部在 `stages` 块内 - - ✅ 大括号平衡(109 个开括号,109 个闭括号) - - ✅ 结构符合 Jenkins 声明式流水线规范 - - ✅ 文件共 361 行 - -### 正确的结构(修复后) -```groovy -pipeline { - agent any - - parameters { ... } - environment { ... } - - stages { - stage('准备') { ... } - stage('检出代码') { ... } - stage('准备环境') { ... } - stage('执行测试') { ... } - stage('收集结果') { ... } - stage('回调平台') { ... } - - stage('构建镜像') { // ✅ 正确位置 - when { - expression { return currentBuild.result == 'SUCCESS' } - } - steps { - script { - // Docker 构建和推送逻辑 - } - } - } - } // ✅ stages 块正确关闭 - - post { - always { script { ... } } - success { script { ... } } - failure { script { ... } } - } // ✅ post 块正确关闭 -} // ✅ pipeline 块正确关闭 -``` - -## 解决方案 - -### 步骤 1:提交修复到 Git -```bash -cd /Users/wb_caijinwei/Automation_Platform - -# 查看修改 -git diff Jenkinsfile - -# 提交修复 -git add Jenkinsfile -git commit -m "fix: 修复 Jenkinsfile 结构错误 - 将 stage('构建镜像') 移动到 stages 块 - -- 将 stage('构建镜像') 从 post 块移动到 stages 块 -- 修复了导致 'Missing required parameter: label' 错误的结构问题 -- 确保所有 stage 都在 stages 块内,post 块仅包含 post 动作 -- 验证了大括号平衡和 Jenkins 声明式流水线规范" - -# 推送到远程仓库 -git push origin feature # 或你的分支名 -``` - -### 步骤 2:触发 Jenkins 构建 - -#### 方案 A:自动拉取(推荐) -如果 Jenkins Job 配置为从 Git 拉取 Jenkinsfile: -1. 在 Jenkins UI 中打开该 Job:http://jenkins.wiac.xyz:8080/job/SeleniumBaseCi-AutoTest/ -2. 点击 "Build Now" 重新运行 -3. Jenkins 会自动拉取最新的 Jenkinsfile - -#### 方案 B:手动强制刷新 -如果 Jenkins 没有自动拉取最新版本: -1. SSH 连接到 Jenkins 服务器 -2. 清除 Jenkins 工作区缓存: - ```bash - rm -rf /var/lib/jenkins/workspace/SeleniumBaseCi-AutoTest/* - rm -rf /var/lib/jenkins/workspace/SeleniumBaseCi-AutoTest@* - ``` -3. 点击 "Build Now" 重新构建 - -#### 方案 C:通过 Jenkins CLI 更新 -```bash -# 下载 Jenkins CLI jar -wget http://jenkins.wiac.xyz:8080/jnlpJars/jenkins-cli.jar - -# 重新加载 Job 配置 -java -jar jenkins-cli.jar -s http://jenkins.wiac.xyz:8080 \ - -auth username:password \ - reload-job SeleniumBaseCi-AutoTest -``` - -## 验证步骤 - -### 1. 本地验证 -```bash -cd /Users/wb_caijinwei/Automation_Platform - -# 验证文件结构 -python3 -c " -import re - -with open('Jenkinsfile', 'r') as f: - content = f.read() - -# 计数大括号 -open_braces = content.count('{') -close_braces = content.count('}') - -print(f'开括号: {open_braces}') -print(f'闭括号: {close_braces}') -print(f'平衡: {open_braces == close_braces}') - -# 检查关键结构 -print(f'\\npipeline 块: {\"pipeline {\" in content}') -print(f'stages 块: {\"stages {\" in content}') -print(f'post 块: {\"post {\" in content}') - -# 统计 stage 数量 -stage_count = len(re.findall(r\"stage\('[^']+'\) \{\", content)) -print(f'stage 数量: {stage_count}') -" - -# 列出所有 stage -echo "所有 stage:" -grep -n "stage(" Jenkinsfile -``` - -**预期输出**: -``` -开括号: 109 -闭括号: 109 -平衡: True - -pipeline 块: True -stages 块: True -post 块: True -stage 数量: 7 - -所有 stage: -20: stage('准备') { -41: stage('检出代码') { -62: stage('准备环境') { -87: stage('执行测试') { -117: stage('收集结果') { -137: stage('回调平台') { -196: stage('构建镜像') { -``` - -### 2. Git 提交验证 -```bash -# 查看最近的提交 -git log -1 --oneline -- Jenkinsfile - -# 查看提交的详细修改 -git show HEAD:Jenkinsfile | head -20 -``` - -### 3. Jenkins 构建验证 - -1. **触发新的 Jenkins 构建**: - - 访问 http://jenkins.wiac.xyz:8080/job/SeleniumBaseCi-AutoTest/ - - 点击 "Build Now" - - 查看新 build 的 Console Output - -2. **验证修复成功的标志**: - - ✅ **不再出现** "Missing required parameter: label" 错误 - - ✅ Pipeline 成功解析并开始执行 - - ✅ 能看到所有 7 个 stage 按顺序执行: - - 准备 - - 检出代码 - - 准备环境 - - 执行测试 - - 收集结果 - - 回调平台 - - 构建镜像(仅在成功时执行) - - ✅ `post` 块中的 `always`、`success`、`failure` 动作正确执行 - -3. **验证回调功能**: - - 平台应该能正确接收 Jenkins 回调 - - 执行状态应该从 "pending" → "running" → "success"/"failed" - - 测试结果应该正确更新到数据库 - -## 技术背景 - -### 声明式流水线 vs 脚本式流水线 - -**声明式流水线**(本项目使用): -```groovy -pipeline { - agent any - stages { - stage('Build') { - steps { - script { /* Groovy 代码 */ } - } - } - } - post { always { script { } } } -} -``` - -**脚本式流水线**(旧版): -```groovy -node('label') { // ← 需要 label 参数 - stage('Build') { - // Groovy 代码 - } -} -``` - -### 为什么 `node` 需要 `label`? - -在现代 Jenkins 中: -- **声明式流水线**使用 `agent` 指令(`agent any` 不需要 label) -- **脚本式流水线**使用 `node('label')` 块 -- 如果混用语法,`node` 块必须指定在哪个代理上运行 -- 在声明式流水线中使用不带 label 的 `node` 会导致此错误 - -### 为什么解析器报告错误的行号? - -错误报告行 201、276、284,但实际问题在第 301 行,因为: -1. 解析器按顺序读取文件 -2. 当到达第 301 行(错位的 stage)时,它意识到结构错误 -3. 它回溯到之前可能有歧义的块 -4. 它误将 `script {` 块解释为可能的 `node {` 块 -5. 它在这些位置报告错误,尽管它们不是根本原因 - -## 预防措施 - -### 1. 使用 IDE 插件 -- **VS Code**:安装 "Jenkins Pipeline Linter" 扩展 -- **IntelliJ IDEA**:启用 Groovy 和 Jenkins 插件 -- 这些工具可以实时检测语法错误 - -### 2. 添加 Pre-commit Hook -创建 `.git/hooks/pre-commit`: -```bash -#!/bin/bash - -# 验证 Jenkinsfile 结构 -if git diff --cached --name-only | grep -q "Jenkinsfile"; then - echo "验证 Jenkinsfile 语法..." - - # 检查大括号平衡 - OPEN=$(grep -o "{" Jenkinsfile | wc -l) - CLOSE=$(grep -o "}" Jenkinsfile | wc -l) - - if [ $OPEN -ne $CLOSE ]; then - echo "❌ 错误:Jenkinsfile 大括号不平衡" - echo " 开括号: $OPEN, 闭括号: $CLOSE" - exit 1 - fi - - echo "✅ Jenkinsfile 语法检查通过" -fi -``` - -### 3. Jenkins 语法验证 -在提交前使用 Jenkins API 验证: -```bash -# 使用 Jenkins Pipeline Linter -curl -X POST -F "jenkinsfile= 30 - --- 优化后:只查询最近 24 小时内的执行 -WHERE testRun.status IN ('pending', 'running') - AND testRun.startTime IS NOT NULL - AND TIMESTAMPDIFF(SECOND, testRun.startTime, NOW()) > 30 - AND testRun.createdAt > DATE_SUB(NOW(), INTERVAL 24 HOUR) -- 新增 -``` - -### 优化 3: 自动清理过期执行 - -**新增功能**: -- 每 **1 小时**自动清理超过 24 小时的卡住执行 -- 将这些执行标记为 `aborted` 状态 -- 防止数据库累积无效记录 - -**效果**: -- 数据库保持干净,无过期记录 -- 监控查询始终高效 -- 长期稳定运行 - -**代码位置**: -- `server/repositories/ExecutionRepository.ts:586-604`(新增方法) -- `server/services/ExecutionMonitorService.ts:195-225`(清理调度) -- 新增环境变量 `EXECUTION_CLEANUP_INTERVAL=3600000` - -**清理逻辑**: -```typescript -// 每小时执行一次 -async markOldStuckExecutionsAsAbandoned(maxAgeHours: number = 24): Promise { - const result = await this.testRunRepository.createQueryBuilder() - .update() - .set({ - status: 'aborted', - endTime: () => 'NOW()', - updatedAt: () => 'NOW()', - }) - .where('status IN (:...statuses)', { statuses: ['pending', 'running'] }) - .andWhere('createdAt < DATE_SUB(NOW(), INTERVAL :maxAgeHours HOUR)', { maxAgeHours }) - .execute(); - - return result.affected || 0; -} -``` - ---- - -## 📈 性能对比 - -### 优化前 - -| 指标 | 数值 | -|-----|------| -| 监控间隔 | 15 秒 | -| 每分钟周期数 | 4 次 | -| 查询执行数 | 10+ 个(包含旧执行) | -| 数据库查询/分钟 | ~40 次 | -| Jenkins API 调用/分钟 | ~40 次 | -| CPU 占用 | **80%** | - -### 优化后 - -| 指标 | 数值 | 改善 | -|-----|------|------| -| 监控间隔 | 30 秒 | ↓ 50% | -| 每分钟周期数 | 2 次 | ↓ 50% | -| 查询执行数 | 0-5 个(仅最近 24h) | ↓ 50-100% | -| 数据库查询/分钟 | ~10 次 | **↓ 75%** | -| Jenkins API 调用/分钟 | ~10 次 | **↓ 75%** | -| CPU 占用(预期) | **20-40%** | **↓ 50-75%** | - -### 长期效果 - -**优化前**: -- 旧执行持续累积 -- 查询负载随时间增加 -- CPU 占用持续上升 - -**优化后**: -- 自动清理过期执行 -- 查询负载保持稳定 -- CPU 占用长期稳定在低水平 - ---- - -## 🔧 配置说明 - -### 新增环境变量 - -在 `.env` 文件中添加: - -```env -# 监控检查间隔(毫秒) -# 推荐值:30000(30秒)- 平衡性能和响应速度 -EXECUTION_MONITOR_INTERVAL=30000 - -# 监控最大年龄(小时) -# 推荐值:24(24小时)- 只检查最近 24 小时内的执行 -EXECUTION_MONITOR_MAX_AGE_HOURS=24 - -# 清理间隔(毫秒) -# 推荐值:3600000(1小时)- 每小时清理一次过期执行 -EXECUTION_CLEANUP_INTERVAL=3600000 -``` - -### 配置调优建议 - -**如果服务器性能充足**: -```env -EXECUTION_MONITOR_INTERVAL=15000 # 15秒,更快检测 -EXECUTION_MONITOR_MAX_AGE_HOURS=48 # 48小时,更长保留期 -``` - -**如果服务器性能紧张**: -```env -EXECUTION_MONITOR_INTERVAL=60000 # 60秒,更低频率 -EXECUTION_MONITOR_MAX_AGE_HOURS=12 # 12小时,更短保留期 -EXECUTION_CLEANUP_INTERVAL=1800000 # 30分钟,更频繁清理 -``` - -**生产环境推荐**: -```env -EXECUTION_MONITOR_INTERVAL=30000 # 30秒(默认) -EXECUTION_MONITOR_MAX_AGE_HOURS=24 # 24小时(默认) -EXECUTION_CLEANUP_INTERVAL=3600000 # 1小时(默认) -``` - ---- - -## 🧪 验证方法 - -### 1. 检查配置生效 - -```bash -# 查看监控服务状态 -curl -s http://localhost:3000/api/jenkins/monitor/status | jq '.data.config' -``` - -**预期输出**: -```json -{ - "checkInterval": 30000, - "compilationCheckWindow": 30000, - "batchSize": 20, - "enabled": true, - "rateLimitDelay": 100 -} -``` - -### 2. 观察日志变化 - -**优化前**: -``` -[MONITOR] Monitor cycle started { count: 10, checkWindow: '30000ms' } -query: SELECT ... WHERE id = 66 -query: SELECT ... WHERE id = 83 -query: SELECT ... WHERE id = 84 -... (10+ 条查询) -``` - -**优化后**: -``` -[MONITOR] Monitor cycle started { count: 0-2, checkWindow: '30000ms' } -[MONITOR] No stuck executions found -``` - -### 3. 监控 CPU 使用率 - -```bash -# 持续监控 CPU 使用率 -top -p $(pgrep -f "npm run server") -``` - -**预期**: -- 优化前:CPU 80% -- 优化后:CPU 20-40%(↓ 50-75%) - -### 4. 检查清理功能 - -等待 1 小时后查看日志: - -``` -[MONITOR] Cleaned up old stuck executions { abandonedCount: 10, maxAgeHours: 24 } -``` - ---- - -## 🚨 注意事项 - -### 监控间隔权衡 - -**30 秒间隔**: -- ✅ 降低 50% CPU 占用 -- ✅ 仍能在 30-60 秒内检测快速失败 -- ⚠️ 检测延迟增加 15 秒 - -**如果需要更快检测**: -- 优先依赖 **WebSocket 实时推送**(< 1 秒) -- 监控服务作为兜底机制,30 秒已足够 - -### 时间过滤影响 - -**24 小时过滤**: -- ✅ 避免查询过期执行 -- ✅ 大幅减少数据库负载 -- ⚠️ 超过 24 小时的卡住执行需等待清理任务处理 - -**清理机制保障**: -- 清理任务每小时运行 -- 自动标记过期执行为 `aborted` -- 不会遗漏任何执行 - -### 数据一致性 - -**清理标准**: -- 只清理 `pending` 或 `running` 状态 -- 必须超过 `EXECUTION_MONITOR_MAX_AGE_HOURS` -- 标记为 `aborted`,保留记录供查询 - -**不影响**: -- 已完成的执行(success/failed) -- 最近 24 小时内的执行 -- 正在进行的正常执行 - ---- - -## 📊 监控指标 - -### 关键指标 - -| 指标 | 目标值 | 监控方法 | -|-----|--------|---------| -| 监控周期间隔 | 30 秒 | 后端日志 | -| 每周期查询执行数 | < 5 个 | 后端日志 | -| 数据库查询频率 | < 15 次/分钟 | 数据库监控 | -| Jenkins API 调用频率 | < 15 次/分钟 | 后端日志 | -| CPU 占用率 | < 40% | 系统监控 | -| 清理执行数/小时 | 视情况 | 后端日志 | - -### 监控命令 - -```bash -# 1. 查看监控服务状态 -curl http://localhost:3000/api/jenkins/monitor/status | jq - -# 2. 查询当前卡住的执行 -curl "http://localhost:3000/api/executions/stuck?timeout=1" | jq - -# 3. 监控后端日志 -tail -f logs/server.log | grep MONITOR - -# 4. 监控 CPU 使用率 -top -p $(pgrep -f "npm run server") -``` - ---- - -## 🎯 总结 - -### 核心改进 - -1. **监控间隔优化**:15秒 → 30秒(↓ 50%) -2. **时间过滤**:只查询最近 24 小时(↓ 50-100% 查询量) -3. **自动清理**:每小时清理过期执行(长期稳定) - -### 预期效果 - -| 指标 | 优化前 | 优化后 | 改善 | -|-----|--------|--------|------| -| CPU 占用 | 80% | 20-40% | **↓ 50-75%** | -| 数据库查询 | ~40/分钟 | ~10/分钟 | **↓ 75%** | -| 监控周期 | 15秒 | 30秒 | ↓ 50% | -| 查询执行数 | 10+ | 0-5 | ↓ 50-100% | - -### 架构优势 - -**多层防御保持不变**: -``` -优先级 1: WebSocket 实时推送(< 1秒)✅ - ↓ 失败 -优先级 2: HTTP 回调(3-5秒)✅ - ↓ 超时 30秒 -优先级 3: API 轮询(10秒间隔)✅ - ↓ 持续监控 -优先级 4: 执行监控服务(30秒检查)✅ 优化后 - ↓ 自动清理 -优先级 5: 清理任务(1小时清理)✅ 新增 -``` - -**优化不影响功能**: -- ✅ 快速失败仍能在 30-60 秒内检测 -- ✅ WebSocket 实时推送保持 < 1 秒 -- ✅ 所有同步机制正常工作 -- ✅ 长期稳定运行 - ---- - -## 🚀 下一步 - -### 立即操作 - -1. **重启后端服务**以加载新配置 - ```bash - # 停止当前服务(Ctrl+C) - npm run server - ``` - -2. **观察 CPU 使用率** - ```bash - top -p $(pgrep -f "npm run server") - ``` - -3. **检查日志输出** - ```bash - tail -f logs/server.log | grep MONITOR - ``` - -### 持续监控(1-2 天) - -- 观察 CPU 占用是否降低到 < 40% -- 检查清理任务是否正常运行(每小时) -- 验证快速失败检测仍然有效(30-60秒) -- 确认 WebSocket 实时推送正常(< 1秒) - -### 如需进一步优化 - -如果 CPU 仍然偏高,可以: -1. 增加监控间隔到 60 秒 -2. 减少最大年龄到 12 小时 -3. 增加清理频率到 30 分钟 -4. 减少批处理大小到 10 - ---- - -**优化完成时间**:2026-02-10 -**文档版本**:v1.0.0 -**状态**:✅ 代码实现完成,等待重启验证 diff --git a/docs/OPTIMIZATION_SUMMARY.md b/docs/OPTIMIZATION_SUMMARY.md deleted file mode 100644 index fc806e9..0000000 --- a/docs/OPTIMIZATION_SUMMARY.md +++ /dev/null @@ -1,305 +0,0 @@ -# Jenkins 回调延迟优化 - 完成报告 - -## 📊 优化成果总结 - -### 🎯 核心目标 -将 Jenkins 任务失败后的状态同步延迟从 **~150秒** 降低到 **< 5秒** - -### ✅ 已完成的优化 - -#### 阶段 A: 后端轮询优化(已验证) - -| 配置项 | 优化前 | 优化后 | 改善 | -|--------|--------|--------|------| -| 回调超时 | 120秒 | **30秒** | ↓ 75% | -| API 轮询间隔 | 30秒 | **10秒** | ↓ 67% | -| 监控检查间隔 | 60秒 | **15秒** | ↓ 75% | -| 编译检查窗口 | 120秒 | **30秒** | ↓ 75% | - -**实测效果**: -- runId 106 测试:快速失败延迟从 ~150秒 → **56秒**(↓ 63%) - -#### 阶段 B + C: WebSocket 实时推送(已完成) - -**后端实现**: -- ✅ WebSocketService.ts(~240行) - - 连接管理和房间订阅 - - 执行状态推送接口 - - 快速失败告警接口 -- ✅ ExecutionService 集成 - - completeBatchExecution() 回调推送 - - updateExecutionStatusFromJenkins() 轮询推送 -- ✅ ExecutionMonitorService 集成 - - 快速失败检测(< 30秒) - - WebSocket 告警推送 - -**前端实现**: -- ✅ websocket.ts 客户端(~200行) - - 自动连接和重连机制(最多 5 次) - - 订阅/取消订阅接口 - - 连接状态管理 -- ✅ useExecuteCase.ts Hook 集成 - - WebSocket 订阅执行更新 - - 立即更新 React Query 缓存 - - WebSocket 连接时降低轮询频率(30秒备份) - - 优雅降级到轮询 - ---- - -## 📈 预期性能对比 - -| 场景 | 优化前 | 轮询优化 | WebSocket | 总改善 | -|-----|--------|---------|-----------|--------| -| 正常回调 | 3-5秒 | 3-5秒 | **< 1秒** | **↓ 80%** | -| 快速失败(编译错误) | 150秒 | 56秒 | **< 5秒** | **↓ 97%** | -| 回调失败(API轮询) | 150秒 | 40-45秒 | **< 3秒** | **↓ 98%** | -| 执行卡住(监控介入) | 65秒 | 20-25秒 | **< 10秒** | **↓ 85%** | - ---- - -## 🏗️ 架构优势 - -### 多层防御机制 -``` -优先级 1: WebSocket 实时推送(< 1秒) - ↓ 失败 -优先级 2: HTTP 回调(3-5秒) - ↓ 超时 30秒 -优先级 3: API 轮询(10秒间隔) - ↓ 持续监控 -优先级 4: 执行监控服务(15秒检查) -``` - -### 优雅降级 -- WebSocket 断连 → 自动重连(5次) -- 重连失败 → 回退到轮询(10秒) -- 轮询失败 → 监控服务兜底(15秒) - -### 资源优化 -- WebSocket 连接时,轮询降低到 **30秒备份** -- 减少 **90%** 的 HTTP 请求 -- 降低 Jenkins API 压力 - ---- - -## 🔧 技术实现 - -### 核心文件清单 - -| 文件 | 类型 | 行数 | 说明 | -|------|------|------|------| -| server/services/WebSocketService.ts | 新建 | ~240 | WebSocket 服务端 | -| server/services/ExecutionService.ts | 修改 | +40 | 推送回调和轮询更新 | -| server/services/ExecutionMonitorService.ts | 修改 | +30 | 推送快速失败告警 | -| server/services/HybridSyncService.ts | 修改 | +20 | 环境变量配置 | -| server/index.ts | 修改 | +10 | 集成 WebSocket | -| src/services/websocket.ts | 新建 | ~200 | WebSocket 客户端 | -| src/hooks/useExecuteCase.ts | 修改 | +60 | 集成 WebSocket 订阅 | -| .env | 修改 | +13 | 优化配置 | -| .env.example | 修改 | +50 | 配置文档 | - -### 依赖项 -- **后端**:socket.io, @types/socket.io -- **前端**:socket.io-client - ---- - -## 📝 配置说明 - -### 环境变量(.env) - -```env -# 混合同步服务配置 -CALLBACK_TIMEOUT=30000 # 回调超时 30秒 -POLL_INTERVAL=10000 # 轮询间隔 10秒 -MAX_POLL_ATTEMPTS=40 # 最大轮询次数 40次 -CONSISTENCY_CHECK_INTERVAL=300000 # 一致性检查 5分钟 - -# 执行监控配置 -EXECUTION_MONITOR_ENABLED=true -EXECUTION_MONITOR_INTERVAL=15000 # 监控间隔 15秒 -COMPILATION_CHECK_WINDOW=30000 # 编译检查窗口 30秒 -EXECUTION_MONITOR_BATCH_SIZE=20 -EXECUTION_MONITOR_RATE_LIMIT=100 - -# WebSocket 配置 -WEBSOCKET_ENABLED=true # 启用 WebSocket -FRONTEND_URL=http://localhost:5173 # 前端 URL -``` - -### 快速回滚 - -如需回滚到原配置: -```env -CALLBACK_TIMEOUT=120000 -POLL_INTERVAL=30000 -EXECUTION_MONITOR_INTERVAL=60000 -COMPILATION_CHECK_WINDOW=120000 -WEBSOCKET_ENABLED=false -``` - ---- - -## 🧪 测试验证 - -### 快速验证 -```bash -# 检查所有配置 -./quick-verify.sh - -# 运行完整测试 -./test-websocket.sh -``` - -### 手动测试步骤 - -1. **启动服务** - ```bash - # 后端 - npm run server - - # 前端 - npm run dev - ``` - -2. **验证 WebSocket 连接** - - 打开 http://localhost:5173 - - 打开浏览器控制台 - - 查看:`[WebSocket] Connected successfully` - -3. **测试实时推送** - ```bash - curl -X POST http://localhost:3000/api/jenkins/run-case \ - -H "Content-Type: application/json" \ - -d '{"caseId": 2315, "projectId": 1}' - ``` - - 观察浏览器控制台:`[WebSocket] Execution update received` - - 观察状态更新延迟(应 < 1秒) - ---- - -## 📊 验收标准 - -### P0(必须满足)✅ -- [x] WebSocket 连接成功 -- [x] 执行状态实时推送(< 1秒) -- [x] 快速失败告警推送(< 30秒) -- [x] 优雅降级到轮询 -- [x] 轮询频率降低(WebSocket 连接时) -- [x] 配置生效验证(15秒监控间隔) - -### P1(应该满足) -- [ ] 前端页面无需刷新即可看到状态变化(待前端测试) -- [ ] 快速失败在 15-20 秒内检测到(待实测) -- [ ] WebSocket 自动重连工作正常(待实测) -- [ ] 端到端延迟 < 5秒(待实测) - -### P2(可以满足) -- [ ] WebSocket 连接状态指示器 -- [ ] 性能监控仪表盘 -- [ ] 详细的推送日志记录 - ---- - -## 🎓 使用指南 - -### 开发者 -1. 查看 `WEBSOCKET_TEST_GUIDE.md` 了解详细测试步骤 -2. 使用 `./quick-verify.sh` 快速验证配置 -3. 使用 `./test-websocket.sh` 运行自动化测试 -4. 查看浏览器控制台了解 WebSocket 状态 - -### 运维人员 -1. 通过环境变量调整配置(无需修改代码) -2. 监控 WebSocket 连接数和推送成功率 -3. 如有问题,可快速回滚到原配置 -4. 查看后端日志了解推送详情 - ---- - -## 🚀 下一步优化建议 - -### 短期(1-2周) -1. **添加 WebSocket 连接状态指示器** - - 在前端页面显示连接状态 - - 连接断开时显示警告 - -2. **实现 WebSocket 心跳检测** - - 定期发送 ping/pong 保持连接 - - 检测僵尸连接 - -3. **完善错误处理** - - 更友好的错误提示 - - 自动重试机制 - -### 中期(1-2个月) -1. **添加性能监控仪表盘** - - WebSocket 连接统计 - - 推送延迟分布 - - 回调成功率 - -2. **优化前端轮询策略** - - 根据 WebSocket 连接质量动态调整 - - 实现指数退避算法 - -3. **添加用户通知** - - 浏览器通知 API - - 快速失败桌面提醒 - -### 长期(3-6个月) -1. **Redis 缓存优化** - - 缓存 runId → executionId 映射 - - 缓存 Jenkins 构建状态 - -2. **分级超时策略** - - 根据用例类型设置不同超时 - - 动态调整轮询间隔 - -3. **集群支持** - - WebSocket 负载均衡 - - Redis Pub/Sub 跨实例推送 - ---- - -## 📞 支持与反馈 - -### 问题排查 -1. 查看 `WEBSOCKET_TEST_GUIDE.md` 故障排查部分 -2. 运行 `./quick-verify.sh` 检查配置 -3. 查看后端日志和前端控制台 -4. 使用诊断接口:`/api/jenkins/diagnose?runId=xxx` - -### 文档 -- 完整测试指南:`WEBSOCKET_TEST_GUIDE.md` -- 计划文档:`.claude/plans/cozy-snacking-orbit.md` -- 项目文档:`CLAUDE.md` - -### 联系方式 -- 开发团队:查看项目 README -- 问题反馈:GitHub Issues - ---- - -## 🏆 成果展示 - -### 关键指标改善 - -| 指标 | 优化前 | 优化后 | 改善幅度 | -|-----|--------|--------|---------| -| 快速失败延迟 | 150秒 | **< 5秒** | **↓ 97%** | -| 回调失败延迟 | 150秒 | **< 3秒** | **↓ 98%** | -| 监控检测速度 | 60秒 | **15秒** | **↓ 75%** | -| 轮询频率(WebSocket 连接时) | 5秒 | **30秒** | ↓ 83% | -| API 请求量 | 基准 | **↓ 90%** | 大幅降低 | - -### 用户体验提升 -- ✅ 实时状态更新,无需刷新页面 -- ✅ 快速失败立即通知,无需等待 -- ✅ 降低服务器负载,提升整体性能 -- ✅ 优雅降级,保证服务可靠性 - ---- - -**优化完成时间**:2026-02-10 -**文档版本**:v1.0.0 -**状态**:✅ 代码实现完成,待端到端测试验证 diff --git a/docs/START_HERE.md b/docs/START_HERE.md deleted file mode 100644 index 7a55a2d..0000000 --- a/docs/START_HERE.md +++ /dev/null @@ -1,309 +0,0 @@ -# 🚀 WebSocket 优化 - 快速启动指南 - -## ✅ 优化已完成! - -Jenkins 回调延迟优化已完成,预期将延迟从 **~150秒** 降低到 **< 5秒**(↓ 97%) - ---- - -## 📦 准备工作 - -### 1. 检查配置(已完成 ✅) -```bash -./quick-verify.sh -``` - -所有配置已就绪: -- ✅ 监控间隔:30秒(优化后,降低CPU占用) -- ✅ 编译检查窗口:30秒 -- ✅ 回调超时:30秒 -- ✅ 轮询间隔:10秒 -- ✅ WebSocket:已启用 -- ✅ 依赖:已安装 -- ✅ 自动清理:每小时清理过期执行 - ---- - -## 🎯 立即开始测试 - -### 方式 1:自动化测试(推荐) - -**重要**:需要先重启服务器以加载 WebSocket 配置 - -```bash -# 1. 停止当前后端服务(Ctrl+C) - -# 2. 重启后端 -npm run server - -# 3. 等待服务启动完成,查看日志中是否有: -# [WebSocket] WebSocket service initialized -# webSocketEnabled: true - -# 4. 运行自动化测试 -./test-websocket.sh -``` - -**预期结果**: -- 所有测试通过 ✓ -- 执行延迟 < 10 秒 -- WebSocket 实时推送工作正常 - ---- - -### 方式 2:手动测试 - -#### Step 1: 重启服务 - -```bash -# 终端 1 - 后端 -npm run server - -# 终端 2 - 前端 -npm run dev -``` - -#### Step 2: 验证 WebSocket 连接 - -1. 打开浏览器:http://localhost:5173 -2. 打开开发者工具(F12)→ Console -3. 查看日志: - -``` -[WebSocket] Connecting to: http://localhost:3000 -[WebSocket] Connected successfully { - socketId: "xxx", - transport: "websocket" -} -``` - -✅ **连接成功标志**:看到 "Connected successfully" 且 transport 为 "websocket" - -#### Step 3: 测试实时推送 - -1. 在前端页面触发一个测试用例 -2. 观察浏览器控制台: - -``` -[WebSocket] Subscribing to execution updates for runId: xxx -[WebSocket] Execution update received: { - runId: xxx, - status: "pending", - source: "callback" -} -[WebSocket] Execution update received: { - runId: xxx, - status: "failed", - source: "callback" -} -``` - -✅ **成功标志**: -- 收到实时推送 -- 延迟 < 1 秒 -- 页面无需刷新即更新 - ---- - -## 📊 验证优化效果 - -### 检查点 1: 监控配置 - -```bash -curl -s http://localhost:3000/api/jenkins/monitor/status | jq '.data.config' -``` - -**预期输出**: -```json -{ - "checkInterval": 15000, // ✓ 15秒 - "compilationCheckWindow": 30000, // ✓ 30秒 - "batchSize": 20, - "enabled": true, - "rateLimitDelay": 100 -} -``` - -### 检查点 2: WebSocket 服务 - -查看后端启动日志: -``` -[WebSocket] WebSocket service initialized -Server started successfully { - ... - wsUrl: 'ws://localhost:3000/api/ws', - webSocketEnabled: true -} -``` - -### 检查点 3: 实时推送 - -触发测试并计时: -```bash -START=$(date +%s) - -# 触发执行 -curl -X POST http://localhost:3000/api/jenkins/run-case \ - -H "Content-Type: application/json" \ - -d '{"caseId": 2315, "projectId": 1}' - -# 等待完成后 -END=$(date +%s) -echo "总耗时: $((END - START)) 秒" -``` - -**预期**: -- 优化前:~150 秒 -- 优化后:**< 10 秒**(WebSocket 实时推送) - ---- - -## 🎨 前端体验 - -### 正常流程 - -1. **触发执行** → 立即显示 "pending" 状态 -2. **Jenkins 接收** → < 1秒 更新为 "running" -3. **执行完成** → < 1秒 更新为 "success/failed" -4. **全程无需刷新页面** - -### 快速失败场景 - -1. **触发执行** → 立即显示 "pending" -2. **编译错误** → 15-30秒内检测到 -3. **WebSocket 告警** → 立即推送快速失败消息 -4. **状态更新** → < 1秒 显示 "failed" - ---- - -## 🐛 故障排查 - -### 问题 1: WebSocket 连接失败 - -**症状**: -``` -[WebSocket] Connection error: ... -[WebSocket] Not connected, using polling fallback -``` - -**解决方案**: -1. 确认后端已重启并启用 WebSocket -2. 检查 `.env` 中 `WEBSOCKET_ENABLED=true` -3. 检查后端日志是否有 WebSocket 初始化信息 -4. 刷新浏览器页面 - -### 问题 2: 没有收到推送 - -**症状**: -- WebSocket 已连接 -- 但状态不更新 - -**解决方案**: -1. 检查浏览器控制台是否有订阅日志 -2. 检查后端日志是否有推送日志 -3. 确认 runId 正确 -4. 刷新页面重新订阅 - -### 问题 3: 配置未生效 - -**症状**: -- 监控间隔仍是 60 秒 - -**解决方案**: -1. **必须重启后端服务** -2. 验证配置:`./quick-verify.sh` -3. 检查 `.env` 文件配置 - ---- - -## 📚 文档索引 - -| 文档 | 用途 | -|------|------| -| `START_HERE.md` | 本文档 - 快速启动 | -| `OPTIMIZATION_SUMMARY.md` | 优化总结报告 | -| `WEBSOCKET_TEST_GUIDE.md` | 详细测试指南 | -| `quick-verify.sh` | 快速验证脚本 | -| `test-websocket.sh` | 自动化测试脚本 | - ---- - -## 🎯 下一步行动 - -### 立即执行(5分钟) - -1. ✅ 配置已完成 -2. 🔄 **重启后端服务**(重要!) -3. ✅ 运行 `./test-websocket.sh` -4. ✅ 观察测试结果 - -### 深度测试(15分钟) - -1. 打开浏览器测试前端 -2. 触发多个测试用例 -3. 观察 WebSocket 实时推送 -4. 验证快速失败场景 -5. 测试优雅降级 - -### 生产部署(按需) - -1. 在测试环境验证稳定性(1-2天) -2. 监控关键指标 -3. 收集用户反馈 -4. 逐步推广到生产环境 - ---- - -## 💡 关键提示 - -1. **必须重启服务器**才能加载 WebSocket 配置 -2. 首次连接可能需要 1-2 秒,请耐心等待 -3. WebSocket 断开会自动重连,最多 5 次 -4. 重连失败会优雅降级到轮询模式 -5. 所有配置可通过 `.env` 快速调整 - ---- - -## 🎉 预期效果 - -### 性能提升 - -| 场景 | 优化前 | 优化后 | 改善 | -|-----|--------|--------|------| -| 快速失败 | 150秒 | **< 5秒** | **↓ 97%** | -| 正常回调 | 3-5秒 | **< 1秒** | **↓ 80%** | -| 回调失败 | 150秒 | **< 3秒** | **↓ 98%** | - -### 用户体验 - -- ✅ 实时状态更新 -- ✅ 无需刷新页面 -- ✅ 快速失败立即通知 -- ✅ 降低服务器负载 -- ✅ 可靠的降级机制 - ---- - -## 📞 需要帮助? - -1. 查看详细测试指南:`cat WEBSOCKET_TEST_GUIDE.md` -2. 运行验证脚本:`./quick-verify.sh` -3. 查看优化总结:`cat OPTIMIZATION_SUMMARY.md` -4. 检查后端日志和前端控制台 - ---- - -**准备好了吗?立即开始测试!** 🚀 - -```bash -# 重启后端(重要!) -npm run server - -# 运行测试 -./test-websocket.sh -``` - ---- - -**文档创建时间**:2026-02-10 -**状态**:✅ 就绪,等待测试验证 diff --git a/docs/TypeORM-Migration-Summary.md b/docs/TypeORM-Migration-Summary.md deleted file mode 100644 index 1eca505..0000000 --- a/docs/TypeORM-Migration-Summary.md +++ /dev/null @@ -1,386 +0,0 @@ -# TypeORM 迁移总结 - -## 概述 - -本次迁移将项目的数据访问层从原始的 `mysql2` SQL 查询迁移到了 TypeORM ORM 框架,提升了代码的类型安全性、可维护性和开发效率。 - -**迁移完成日期:** 2025-01-20 - ---- - -## 迁移范围 - -### ✅ 已完成迁移 - -#### 1. 核心基础设施 - -- **依赖安装** - - `typeorm@^0.3.20` - TypeORM 核心库 - - `reflect-metadata` - 装饰器元数据支持 - - `mysql2` - 保留作为底层驱动 - -- **TypeScript 配置** - - `tsconfig.json` & `tsconfig.server.json` - - 启用 `experimentalDecorators: true` - - 启用 `emitDecoratorMetadata: true` - - 添加 `strictPropertyInitialization: false` - -- **数据源配置** - - 新建 `server/config/dataSource.ts` - - 配置 MySQL/MariaDB 连接 - - 实体自动加载配置 - - 连接池优化设置 - -#### 2. Entity 实体层 (12/12 完成) - -所有实体均已创建并映射到远程数据库表: - -| 实体类 | 数据库表 | 说明 | -|--------|---------|------| -| `User` | `Auto_Users` | 用户信息 | -| `TestCase` | `Auto_TestCase` | 测试用例资产 | -| `TestRun` | `Auto_TestRun` | 测试执行批次 | -| `TestRunResult` | `Auto_TestRunResults` | 测试用例执行结果 | -| `TaskExecution` | `Auto_TestCaseTaskExecutions` | 测试任务执行记录 | -| `DailySummary` | `Auto_TestCaseDailySummaries` | 每日统计汇总 | -| `RepositoryConfig` | `Auto_RepositoryConfigs` | 仓库配置 | -| `RepositoryScriptMapping` | `Auto_RepositoryScriptMappings` | 仓库脚本映射 | -| `SyncLog` | `Auto_SyncLogs` | 同步日志 | -| `TestCaseProject` | `Auto_TestCaseProjects` | 测试项目 | -| `TestCaseTask` | `Auto_TestCaseTask` | 测试任务 | -| `TestEnvironment` | `Auto_TestEnvironments` | 测试环境 | - -**关键特性:** -- 使用装饰器定义表结构 -- 自动映射 snake_case ↔ camelCase -- 定义实体间关系 (如 TestCase.creator → User) -- 完整的类型定义 - -#### 3. Repository 数据访问层 (9/9 完成) - -创建了基于 TypeORM 的 Repository 模式: - -| Repository | 说明 | 关键方法 | -|-----------|------|---------| -| `BaseRepository` | 基础 Repository 类 | 通用 CRUD、事务管理 | -| `UserRepository` | 用户数据访问 | 用户认证、查询、更新 | -| `TestCaseRepository` | 测试用例数据访问 | 用例 CRUD、条件查询、关联查询 | -| `ExecutionRepository` | 执行记录数据访问 | 批次管理、结果记录、状态更新 | -| `DashboardRepository` | 仪表盘数据访问 | 统计查询、趋势分析 | -| `RepositoryConfigRepository` | 仓库配置数据访问 | 配置管理、查询 | -| `SyncLogRepository` | 同步日志数据访问 | 日志创建、更新、查询 | -| `TaskRepository` | 任务数据访问 | 任务管理、关联查询 | -| `EnvironmentRepository` | 环境数据访问 | 环境配置管理 | - -**Repository 模式优势:** -- 封装数据访问逻辑 -- 统一的事务管理接口 -- 类型安全的查询构建 -- 便于单元测试 - -#### 4. Service 服务层迁移 (7/7 完成) - -已迁移的核心服务: - -| 服务 | 迁移状态 | 说明 | -|-----|---------|------| -| `AuthService` | ✅ 完成 | 使用 UserRepository | -| `DashboardService` | ✅ 完成 | 使用 DashboardRepository | -| `ExecutionService` | ✅ 完成 | 使用 ExecutionRepository,事务管理 | -| `ExecutionScheduler` | ✅ 完成 | 字段名统一为 camelCase | -| `JenkinsService` | ✅ 完成 | 清理未使用的数据库导入 | -| `RepositoryService` | ✅ 完成 | 使用 RepositoryConfigRepository | -| `RepositorySyncService` | ✅ 完成 | 使用 SyncLogRepository、TestCaseRepository | - -#### 5. Routes 路由层迁移 (5/5 完成) - -| 路由 | 迁移状态 | 说明 | -|-----|---------|------| -| `/api/cases` | ✅ 完成 | 使用 TestCaseRepository | -| `/api/executions` | ✅ 完成 | 使用 ExecutionRepository | -| `/api/jenkins` | ✅ 完成 | 集成 ExecutionService | -| `/api/dashboard` | ✅ 完成 | 使用 DashboardRepository | -| `/api/tasks` | ✅ 完成 | 使用 TaskRepository、EnvironmentRepository | - ---- - -## 技术实现细节 - -### 1. 命名约定统一 - -**问题:** 数据库使用 snake_case,TypeScript 使用 camelCase - -**解决方案:** -- Entity 中使用 `@Column({ name: 'snake_case' })` 明确映射 -- Repository 查询返回自动转换为 camelCase -- Service 层统一使用 camelCase - -**示例:** -```typescript -// Entity 定义 -@Column({ type: 'varchar', name: 'jenkins_build_id' }) -jenkinsBuildId: string | null; - -// 查询使用 -const run = await repo.findById(id); -console.log(run.jenkinsBuildId); // camelCase -``` - -### 2. 事务管理 - -**旧方案 (mysql2):** -```typescript -const connection = await getConnection(); -await connection.beginTransaction(); -try { - // ...操作 - await connection.commit(); -} catch (error) { - await connection.rollback(); -} -``` - -**新方案 (TypeORM):** -```typescript -await this.executionRepository.runInTransaction(async (queryRunner) => { - // 所有操作自动在事务中 - await queryRunner.manager.save(entity); - // 自动提交或回滚 -}); -``` - -### 3. 复杂查询构建 - -**旧方案 (字符串拼接):** -```typescript -let sql = 'SELECT * FROM table WHERE 1=1'; -const params = []; -if (filter) { - sql += ' AND field = ?'; - params.push(filter); -} -const results = await query(sql, params); -``` - -**新方案 (QueryBuilder):** -```typescript -const queryBuilder = repo.createQueryBuilder('alias') - .where('1=1'); - -if (filter) { - queryBuilder.andWhere('alias.field = :filter', { filter }); -} - -const results = await queryBuilder.getMany(); -``` - -### 4. 关联查询 - -**一对多关系 (TestCase → User):** -```typescript -@ManyToOne(() => User, { nullable: true }) -@JoinColumn({ name: 'created_by' }) -creator: User | null; - -// 查询时自动加载关联 -const testCase = await repo - .createQueryBuilder('tc') - .leftJoinAndSelect('tc.creator', 'user') - .where('tc.id = :id', { id }) - .getOne(); -``` - ---- - -## 迁移效果 - -### 代码质量提升 - -| 指标 | 迁移前 | 迁移后 | 改善 | -|-----|-------|-------|------| -| 类型安全 | ⚠️ 部分 | ✅ 完全 | +100% | -| SQL 注入风险 | ⚠️ 中等 | ✅ 极低 | -90% | -| 代码行数 | ~2500 行 | ~2000 行 | -20% | -| 可维护性 | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | +66% | - -### 开发体验改善 - -- ✅ **IDE 智能提示**: 完整的类型推导 -- ✅ **编译时检查**: 字段名拼写错误在编译时发现 -- ✅ **重构支持**: 可安全重命名字段 -- ✅ **单元测试**: 易于 Mock Repository - -### 性能影响 - -- ✅ **连接池复用**: 保持原有性能 -- ✅ **查询优化**: QueryBuilder 生成优化的 SQL -- ✅ **批量操作**: 支持高效的批量插入/更新 - ---- - -## 遗留问题与后续工作 - -### 1. ~~未迁移的功能~~ ✅ 已全部完成 - -~~**原因:** 使用了项目中未定义的数据库表~~ - -**✅ 更新:** 所有功能已完成迁移 -- ✅ `RepositoryService` - 已使用 `RepositoryConfigRepository` -- ✅ `RepositorySyncService` - 已使用 `SyncLogRepository`、`TestCaseRepository` -- ✅ `/api/tasks` 路由 - 已使用 `TaskRepository`、`EnvironmentRepository` - -**完成内容:** -- 创建了 6 个额外的 Entity (RepositoryConfig、SyncLog、TestCaseProject、TestCaseTask、TestEnvironment、RepositoryScriptMapping) -- 创建了 4 个额外的 Repository (RepositoryConfigRepository、SyncLogRepository、TaskRepository、EnvironmentRepository) -- 完成了所有服务层和路由层的迁移 - -### 2. 数据库迁移管理 - -**当前状态:** 未启用 TypeORM 的 migration 功能 - -**建议:** -- 启用 `migrations: []` 配置 -- 使用 `typeorm migration:generate` 生成迁移文件 -- 版本化管理数据库结构变更 - -### 3. 测试覆盖 - -**当前状态:** 类型检查通过,功能测试待补充 - -**建议:** -- 添加 Repository 层单元测试 -- 添加 Service 层集成测试 -- 测试事务回滚逻辑 - ---- - -## 升级指南 - -### 开发环境设置 - -```bash -# 1. 安装依赖 (已完成) -npm install - -# 2. 检查 TypeScript 配置 -npx tsc --noEmit -p tsconfig.server.json - -# 3. 启动服务器 -npm run server -``` - -### 代码变更示例 - -#### 使用 Repository 替代原始查询 - -**旧代码:** -```typescript -import { query, queryOne } from '../config/database.js'; - -const cases = await query( - 'SELECT * FROM Auto_TestCase WHERE type = ?', - [type] -); -``` - -**新代码:** -```typescript -import { TestCaseRepository } from '../repositories/TestCaseRepository.js'; -import { AppDataSource } from '../config/dataSource.js'; - -const repo = new TestCaseRepository(AppDataSource); -const cases = await repo.findAll({ type }); -``` - -#### 字段名更新 - -所有数据库字段名统一使用 camelCase: - -```typescript -// ❌ 旧方式 -execution.jenkins_build_id -execution.start_time - -// ✅ 新方式 -execution.jenkinsBuildId -execution.startTime -``` - ---- - -## 检查清单 - -迁移完成后请确认以下项目: - -- [x] TypeScript 编译无错误 (**前后端均通过**) -- [x] 所有核心 Entity 已定义 (12个实体) -- [x] Repository 层实现完整 (9个 Repository) -- [x] Service 层已更新 (5个核心服务) -- [x] Routes 层已更新 (4个核心路由) -- [x] 字段命名统一为 camelCase -- [x] 事务管理已迁移 -- [x] 模块系统配置优化 (CommonJS + Node 解析) -- [ ] 单元测试已更新 -- [ ] 集成测试通过 -- [ ] 性能测试通过 - ---- - -## 参考资源 - -- [TypeORM 官方文档](https://typeorm.io/) -- [Entity 装饰器参考](https://typeorm.io/entities) -- [Repository 模式](https://typeorm.io/repository-api) -- [QueryBuilder API](https://typeorm.io/select-query-builder) -- [事务管理](https://typeorm.io/transactions) - ---- - -## 常见问题 (FAQ) - -### Q: 为什么保留 mysql2 依赖? - -A: TypeORM 底层使用 mysql2 作为 MySQL 驱动,需要保留该依赖。 - -### Q: 如何回滚到旧的实现? - -A: 保留了 `server/config/database.ts` 中的原始实现 (已更新导出 TypeORM),可暂时恢复使用。 - -### Q: 性能是否受影响? - -A: TypeORM 在底层仍使用 mysql2,性能影响极小。QueryBuilder 生成的 SQL 与手写 SQL 相当。 - -### Q: 如何调试生成的 SQL? - -A: 在 `dataSource.ts` 中设置 `logging: true` 即可查看所有执行的 SQL。 - -### Q: 多表关联查询如何处理? - -A: 使用 QueryBuilder 的 `leftJoin` / `innerJoin` 方法,或定义 Entity 关系后使用 `relations` 选项。 - ---- - -## 结论 - -TypeORM 迁移已 **100% 完成**,成功将整个项目从原始 SQL 查询迁移到 TypeORM ORM 框架,显著提升了代码质量和开发体验。 - -**迁移完成统计:** -- ✅ **12 个 Entity** - 100% 完成 -- ✅ **9 个 Repository** - 100% 完成 -- ✅ **7 个 Service** - 100% 完成 -- ✅ **5 个 Route** - 100% 完成 -- ✅ **TypeScript 类型检查** - 前后端均通过 -- ✅ **模块系统优化** - CommonJS + Node 解析 - -**总体评估:** ⭐⭐⭐⭐⭐ - -- ✅ 类型安全 - 完全消除 SQL 注入风险 -- ✅ 代码简洁 - 减少 20% 代码量 -- ✅ 易于维护 - Repository 模式封装 -- ✅ 开发效率提升 - IDE 智能提示、编译时检查 -- ✅ 性能无明显影响 - 保持原有连接池性能 - -**后续建议:** -- 补充单元测试和集成测试 -- 启用 TypeORM migrations 进行版本化管理 -- 持续优化查询性能 diff --git a/docs/WEBSOCKET_TEST_GUIDE.md b/docs/WEBSOCKET_TEST_GUIDE.md deleted file mode 100644 index 6ca76b4..0000000 --- a/docs/WEBSOCKET_TEST_GUIDE.md +++ /dev/null @@ -1,488 +0,0 @@ -# WebSocket 优化完整测试指南 - -## 📋 优化完成清单 - -### ✅ 已完成的工作 - -#### 阶段 A: 后端轮询优化 -- [x] HybridSyncService - 回调超时 2分钟 → 30秒 -- [x] HybridSyncService - 轮询间隔 30秒 → 10秒 -- [x] ExecutionMonitorService - 检查间隔 60秒 → 15秒 -- [x] ExecutionMonitorService - 编译窗口 2分钟 → 30秒 -- [x] 增强回调延迟日志 -- [x] 环境变量配置 - -#### 阶段 B: WebSocket 后端集成 -- [x] 安装 socket.io 依赖 -- [x] 实现 WebSocketService.ts(~240行) -- [x] 集成到 server/index.ts -- [x] ExecutionService 推送更新(回调 + 轮询) -- [x] ExecutionMonitorService 推送快速失败告警 - -#### 阶段 C: WebSocket 前端集成 -- [x] 安装 socket.io-client 依赖 -- [x] 实现 websocket.ts 客户端(~200行) -- [x] 集成到 useExecuteCase.ts Hook -- [x] WebSocket 订阅和实时更新 -- [x] 优雅降级到轮询 - ---- - -## 🚀 快速开始测试 - -### 1. 重启后端服务 - -```bash -# 停止当前服务(如果在运行) -# Ctrl+C 或者找到进程并 kill - -# 启动后端服务 -npm run server -``` - -**预期日志输出**: -``` -[WebSocket] WebSocket service initialized -Server started successfully { - port: 3000, - wsUrl: 'ws://localhost:3000/api/ws', - webSocketEnabled: true -} -[ExecutionMonitorService] Initialized with config: { - checkInterval: '15000ms', - compilationCheckWindow: '30000ms', - ... -} -``` - -### 2. 启动前端服务 - -```bash -# 新开一个终端窗口 -npm run dev -``` - -**预期日志输出**: -``` -VITE ready in xxx ms -➜ Local: http://localhost:5173/ -``` - -### 3. 运行自动化测试脚本 - -```bash -# 在项目根目录执行 -./test-websocket.sh -``` - -**预期输出**: -``` -================================== -WebSocket 集成测试 -================================== - -1. 检查服务器健康状态 ------------------------------------ -Testing Health Check... ✓ PASSED (HTTP 200) - -2. 检查监控服务状态 ------------------------------------ -Testing Monitor Status... ✓ PASSED (HTTP 200) -获取监控配置详情: -{ - "checkInterval": 15000, - "compilationCheckWindow": 30000, - "batchSize": 20, - "enabled": true, - "rateLimitDelay": 100 -} - -3. 触发测试执行 ------------------------------------ -触发用例 2315... -✓ 执行已触发 - Run ID: 107 - Build URL: http://jenkins.wiac.xyz:8080/job/SeleniumBaseCi-AutoTest/272/ - -4. 监控执行状态(30秒) ------------------------------------ -观察 WebSocket 实时推送效果... - -[1/10] 检查状态... - 状态: pending | 通过: 0 | 失败: 0 -[2/10] 检查状态... - 状态: running | 通过: 0 | 失败: 0 -[3/10] 检查状态... - 状态: failed | 通过: 0 | 失败: 1 - -✓ 执行已完成 - 最终状态: failed - 通过用例: 0 - 失败用例: 1 - -================================== -测试总结 -================================== -通过: 4 -失败: 0 - -✓ 所有测试通过! -``` - ---- - -## 🔍 详细验证步骤 - -### 测试 1: WebSocket 连接验证 - -**操作**: -1. 打开浏览器访问 http://localhost:5173 -2. 打开浏览器开发者工具(F12) -3. 切换到 Console 标签 - -**预期结果**: -``` -[WebSocket] Connecting to: http://localhost:3000 -[WebSocket] Connected successfully { - socketId: "xxx", - transport: "websocket" -} -``` - -**验证点**: -- ✅ 连接成功(无错误信息) -- ✅ transport 为 "websocket"(不是 "polling") -- ✅ 有 socketId - ---- - -### 测试 2: 实时状态推送验证 - -**操作**: -1. 在前端页面触发一个测试用例执行 -2. 观察浏览器控制台日志 -3. 观察后端服务器日志 - -**前端预期日志**: -``` -[WebSocket] Subscribing to execution updates for runId: 107 -[WebSocket] Execution update received: { - runId: 107, - status: "pending", - source: "callback", - timestamp: "2026-02-09T..." -} -[WebSocket] Execution update received: { - runId: 107, - status: "running", - source: "callback", - timestamp: "2026-02-09T..." -} -[WebSocket] Execution update received: { - runId: 107, - status: "failed", - passedCases: 0, - failedCases: 1, - durationMs: 63, - source: "callback", - timestamp: "2026-02-09T..." -} -``` - -**后端预期日志**: -``` -[WEBSOCKET] Client subscribed to execution { runId: 107, socketId: 'xxx' } -[EXECUTION] Jenkins callback received { runId: 107, status: 'failed', callbackLatency: '56000ms', source: 'callback' } -[WEBSOCKET] Execution update pushed via WebSocket { runId: 107, status: 'failed', source: 'callback', subscriberCount: 1 } -``` - -**验证点**: -- ✅ WebSocket 订阅成功 -- ✅ 收到状态更新推送 -- ✅ 推送延迟 < 1秒 -- ✅ 前端页面实时更新(无需刷新) - ---- - -### 测试 3: 快速失败告警验证 - -**操作**: -1. 触发一个会快速失败的用例(如编译错误) -2. 观察是否在 15-30 秒内检测到失败 -3. 检查是否收到快速失败告警 - -**预期前端日志**: -``` -[WebSocket] Quick fail detected: { - runId: 107, - message: "Execution failed quickly, likely a compilation or configuration error", - errorType: "quick_fail", - duration: 25000 -} -``` - -**预期后端日志**: -``` -[MONITOR] Quick fail detected and alert pushed { runId: 107, duration: '25000ms', status: 'failed' } -[WEBSOCKET] Quick fail alert pushed { runId: 107, errorType: 'quick_fail', duration: 25000 } -``` - -**验证点**: -- ✅ 快速失败在 30 秒内检测到 -- ✅ WebSocket 推送快速失败告警 -- ✅ 前端显示告警信息 - ---- - -### 测试 4: 优雅降级验证 - -**操作**: -1. 在浏览器控制台执行:`wsClient.disconnect()` -2. 触发测试执行 -3. 观察是否自动回退到轮询 - -**预期日志**: -``` -[WebSocket] Disconnecting... -[WebSocket] Disconnected: io client disconnect -[WebSocket] Not connected, using polling fallback -[Polling] WebSocket not connected, using normal polling (5 seconds) -``` - -**验证点**: -- ✅ WebSocket 断开后不报错 -- ✅ 自动回退到轮询模式 -- ✅ 轮询间隔为 5 秒(快速轮询) -- ✅ 仍能正常获取状态更新 - ---- - -### 测试 5: 轮询频率降低验证 - -**操作**: -1. 确保 WebSocket 已连接 -2. 触发测试执行 -3. 观察轮询间隔 - -**预期日志**: -``` -[Polling] WebSocket connected, using slow polling as backup (30 seconds) -``` - -**验证点**: -- ✅ WebSocket 连接时,轮询间隔为 30 秒 -- ✅ 减少了 API 请求频率(从 5 秒 → 30 秒) -- ✅ 主要通过 WebSocket 获取更新 - ---- - -## 📊 性能对比测试 - -### 测试场景 1: 正常回调 - -**测试步骤**: -1. 触发测试执行 -2. 记录从触发到状态更新的时间 - -**测试命令**: -```bash -# 记录开始时间 -START_TIME=$(date +%s) - -# 触发执行 -RESPONSE=$(curl -s -X POST http://localhost:3000/api/jenkins/run-case \ - -H "Content-Type: application/json" \ - -d '{"caseId": 2315, "projectId": 1}') - -RUN_ID=$(echo "$RESPONSE" | jq -r '.data.runId') - -# 等待并检查状态 -while true; do - STATUS=$(curl -s "http://localhost:3000/api/jenkins/batch/$RUN_ID" | jq -r '.data.status') - if [[ "$STATUS" != "pending" ]] && [[ "$STATUS" != "running" ]]; then - END_TIME=$(date +%s) - DURATION=$((END_TIME - START_TIME)) - echo "执行完成,总耗时: ${DURATION}秒" - break - fi - sleep 1 -done -``` - -**预期结果**: -- 优化前:~150 秒 -- 轮询优化后:~56 秒 -- WebSocket 优化后:**< 10 秒**(实时推送) - ---- - -### 测试场景 2: 快速失败 - -**测试步骤**: -1. 触发会快速失败的用例 -2. 观察检测时间 - -**预期结果**: -- 优化前:~150 秒 -- 轮询优化后:~30 秒 -- WebSocket 优化后:**< 5 秒**(监控服务 15 秒检测 + WebSocket 推送) - ---- - -## 🐛 故障排查 - -### 问题 1: WebSocket 连接失败 - -**症状**: -``` -[WebSocket] Connection error: Error: ... -[WebSocket] Max reconnection attempts reached, falling back to polling -``` - -**排查步骤**: -1. 检查后端服务是否启动:`curl http://localhost:3000/api/health` -2. 检查 WebSocket 服务是否启用:查看后端启动日志 -3. 检查防火墙/代理设置 -4. 验证 CORS 配置:`.env` 中的 `FRONTEND_URL` - -**解决方案**: -- 确保 `.env` 中 `WEBSOCKET_ENABLED=true` -- 确保 `FRONTEND_URL=http://localhost:5173` -- 重启后端服务 - ---- - -### 问题 2: 没有收到 WebSocket 推送 - -**症状**: -- WebSocket 已连接 -- 但执行状态不更新 - -**排查步骤**: -1. 检查是否订阅成功: - ``` - [WebSocket] Subscribing to execution updates for runId: xxx - ``` -2. 检查后端是否推送: - ``` - [WEBSOCKET] Execution update pushed via WebSocket - ``` -3. 检查 subscriberCount 是否 > 0 - -**解决方案**: -- 刷新页面重新连接 -- 检查 runId 是否正确 -- 查看后端日志确认推送逻辑执行 - ---- - -### 问题 3: 轮询频率没有降低 - -**症状**: -- WebSocket 已连接 -- 但轮询仍然是 5 秒间隔 - -**排查步骤**: -1. 检查 `wsConnected` 状态: - ```javascript - console.log('[Debug] wsConnected:', wsConnected) - ``` -2. 检查 `wsClient.isConnected()` 返回值 - -**解决方案**: -- 确保 WebSocket 完全连接后再触发执行 -- 等待 1-2 秒让 WebSocket 连接稳定 - ---- - -## 📈 监控指标 - -### 实时监控命令 - -```bash -# 查看监控服务状态 -curl -s http://localhost:3000/api/jenkins/monitor/status | jq - -# 查看 WebSocket 订阅统计(如果有接口) -curl -s http://localhost:3000/api/ws/stats | jq - -# 查看卡住的执行 -curl -s 'http://localhost:3000/api/executions/stuck?timeout=1' | jq -``` - -### 关键指标 - -| 指标 | 目标值 | 验证方法 | -|-----|--------|---------| -| WebSocket 连接成功率 | > 98% | 浏览器控制台日志 | -| 状态更新延迟 | < 1秒 | 对比触发时间和更新时间 | -| 快速失败检测时间 | < 30秒 | 监控服务日志 | -| 轮询频率(WebSocket 连接时) | 30秒 | 浏览器控制台日志 | -| API 请求减少 | 降低 90% | 网络面板观察 | - ---- - -## ✅ 验收标准 - -### 必须满足(P0) - -- [x] WebSocket 连接成功 -- [x] 执行状态实时推送(< 1秒) -- [x] 快速失败告警推送(< 30秒) -- [x] 优雅降级到轮询 -- [x] 轮询频率降低(WebSocket 连接时) - -### 应该满足(P1) - -- [ ] 前端页面无需刷新即可看到状态变化 -- [ ] 快速失败在 15-20 秒内检测到 -- [ ] WebSocket 自动重连(最多 5 次) -- [ ] 监控服务 15 秒检查间隔生效 - -### 可以满足(P2) - -- [ ] 完整的错误处理和用户提示 -- [ ] WebSocket 连接状态指示器 -- [ ] 性能监控仪表盘 -- [ ] 详细的推送日志记录 - ---- - -## 🎯 下一步优化建议 - -1. **添加 WebSocket 连接状态指示器** - - 在前端页面显示 WebSocket 连接状态 - - 连接断开时显示警告 - -2. **实现 WebSocket 心跳检测** - - 定期发送 ping/pong 保持连接 - - 检测僵尸连接 - -3. **添加 WebSocket 性能监控** - - 记录推送延迟 - - 统计推送成功率 - - 监控订阅数量 - -4. **优化前端轮询策略** - - 根据 WebSocket 连接质量动态调整 - - 实现指数退避算法 - -5. **添加用户通知** - - 浏览器通知 API - - 快速失败桌面提醒 - ---- - -## 📞 支持 - -如有问题,请: -1. 查看后端日志:`npm run server` -2. 查看前端控制台日志 -3. 运行测试脚本:`./test-websocket.sh` -4. 查看 WebSocket 服务状态 -5. 联系开发团队 - ---- - -**最后更新时间**:2026-02-10 -**文档版本**:v1.0.0 diff --git a/docs/components/ThemeToggle-Animation-Guide.md b/docs/components/ThemeToggle-Animation-Guide.md deleted file mode 100644 index 89db829..0000000 --- a/docs/components/ThemeToggle-Animation-Guide.md +++ /dev/null @@ -1,367 +0,0 @@ -# ThemeToggle 动画效果指南 - -## 📚 概述 - -ThemeToggle 组件现在包含了丰富的动画效果,提升用户体验并提供视觉反馈。 - ---- - -## 🎨 动画效果详解 - -### 1. **按钮状态过渡动画** - -#### 激活状态动画 -当用户点击主题按钮时,按钮会平滑地过渡到激活状态: - -```css -transition-all duration-300 ease-in-out -``` - -**效果:** -- 背景色平滑变化(300ms) -- 文字颜色平滑过渡 -- 阴影效果淡入 - -#### 悬停效果 -鼠标悬停时,按钮会轻微放大: - -```css -hover:scale-105 -``` - -**效果:** -- 按钮放大到 105% -- 背景色变化 -- 文字颜色加深 - -#### 点击效果 -按钮被点击时会有按下的视觉反馈: - -```css -active:scale-95 -``` - -**效果:** -- 按钮缩小到 95% -- 提供触觉反馈感 - ---- - -### 2. **图标旋转动画** - -当主题切换时,图标会执行旋转和缩放动画: - -```css -@keyframes themeSwitch { - 0% { - transform: rotate(0deg) scale(1); - opacity: 1; - } - 50% { - transform: rotate(180deg) scale(1.2); - opacity: 0.8; - } - 100% { - transform: rotate(360deg) scale(1); - opacity: 1; - } -} -``` - -**效果:** -- 图标旋转 360 度 -- 中途放大到 120% -- 透明度变化增强视觉效果 -- 动画时长:500ms - ---- - -### 3. **脉冲呼吸动画** - -激活状态的按钮会显示微妙的脉冲效果: - -```css -@keyframes pulseSubtle { - 0%, 100% { - opacity: 0.2; - } - 50% { - opacity: 0.3; - } -} -``` - -**效果:** -- 背景光晕效果 -- 循环呼吸动画(2s) -- 强调当前选中状态 - ---- - -### 4. **全局主题过渡** - -整个页面在主题切换时会有平滑的颜色过渡: - -```javascript -root.style.setProperty('transition', 'background-color 0.3s ease, color 0.3s ease'); -``` - -**效果:** -- 背景色平滑过渡 -- 文字颜色同步变化 -- 避免突兀的视觉跳变 - ---- - -### 5. **焦点指示动画** - -键盘导航时的焦点环动画: - -```css -focus:ring-2 focus:ring-primary focus:ring-offset-2 -``` - -**效果:** -- 显示清晰的焦点环 -- 符合可访问性标准 -- 2px 偏移增强可见性 - ---- - -## 🎯 动画时间轴 - -``` -用户点击按钮 - ↓ -[0ms] 按钮缩小到 95% (active:scale-95) - ↓ -[50ms] 触发 handleThemeChange - ↓ -[100ms] 图标开始旋转动画 (0-180度) - ↓ -[200ms] 全局背景色开始过渡 - ↓ -[300ms] 图标继续旋转 (180-360度) - ↓ -[400ms] 按钮背景色完成过渡 - ↓ -[500ms] 图标旋转动画完成 - ↓ -[持续] 脉冲呼吸动画循环 (2s 周期) -``` - ---- - -## 📊 性能优化 - -### 1. **使用 CSS Transform** -所有动画使用 `transform` 和 `opacity` 属性,触发 GPU 加速: - -```css -/* ✅ 高性能 */ -transform: scale(1.05); -opacity: 0.8; - -/* ❌ 避免使用 */ -width: 110%; -height: 110%; -``` - -### 2. **动画节流** -使用 `useEffect` 清理机制防止动画堆积: - -```typescript -const timer = setTimeout(() => { - setIsAnimating(false); - root.style.removeProperty('transition'); -}, 300); - -return () => { - clearTimeout(timer); - root.style.removeProperty('transition'); -}; -``` - -### 3. **条件渲染优化** -脉冲效果仅在激活状态渲染: - -```typescript -{isActive && ( - -)} -``` - ---- - -## 🎬 动画配置 - -### Tailwind 配置 - -在 `configs/tailwind.config.js` 中定义的自定义动画: - -```javascript -keyframes: { - themeSwitch: { - "0%": { transform: "rotate(0deg) scale(1)", opacity: "1" }, - "50%": { transform: "rotate(180deg) scale(1.2)", opacity: "0.8" }, - "100%": { transform: "rotate(360deg) scale(1)", opacity: "1" }, - }, - pulseSubtle: { - "0%, 100%": { opacity: "0.2" }, - "50%": { opacity: "0.3" }, - }, -}, -animation: { - "theme-switch": "themeSwitch 0.5s ease-in-out", - "pulse-subtle": "pulseSubtle 2s ease-in-out infinite", -}, -``` - ---- - -## 🔧 自定义动画 - -### 修改动画时长 - -```typescript -// 在 ThemeToggle.tsx 中修改 -className="transition-all duration-300" // 改为 duration-500 - -// 在 tailwind.config.js 中修改 -animation: { - "theme-switch": "themeSwitch 0.5s ease-in-out", // 改为 1s -} -``` - -### 修改动画曲线 - -```typescript -// 可选的缓动函数 -ease-linear // 线性 -ease-in // 加速 -ease-out // 减速 -ease-in-out // 先加速后减速 -``` - -### 禁用动画 - -如果用户启用了减少动画偏好,可以添加: - -```typescript -@media (prefers-reduced-motion: reduce) { - * { - animation-duration: 0.01ms !important; - transition-duration: 0.01ms !important; - } -} -``` - ---- - -## 🧪 测试覆盖 - -动画效果包含完整的测试覆盖: - -```typescript -describe('动画效果测试', () => { - it('should render pulse effect on active button', () => { - // 测试脉冲动画渲染 - }); - - it('should not render pulse effect on inactive buttons', () => { - // 测试非激活状态不渲染动画 - }); - - it('should have transform classes for animation', () => { - // 测试变换类存在 - }); - - it('should trigger animation on theme switch', () => { - // 测试主题切换触发动画 - }); -}); -``` - -**测试结果:33/33 通过 ✅** - ---- - -## 📱 响应式适配 - -动画在不同设备上的表现: - -### 桌面端 -- 完整的悬停效果 -- 流畅的过渡动画 -- 焦点环清晰可见 - -### 移动端 -- 触摸反馈优化 -- 减少悬停依赖 -- 简化动画以提升性能 - -### 低性能设备 -- 自动降级动画复杂度 -- 保留核心视觉反馈 -- 优先保证交互响应 - ---- - -## 🎨 视觉设计原则 - -### 1. **微妙而不过度** -动画增强体验但不喧宾夺主: -- 时长控制在 300-500ms -- 缩放范围 95%-120% -- 透明度变化 ≤ 20% - -### 2. **一致性** -所有交互使用统一的动画曲线: -- 过渡:`ease-in-out` -- 时长:300ms -- 变换:`transform` + `opacity` - -### 3. **可访问性优先** -动画不影响功能使用: -- 键盘导航完整支持 -- 焦点状态清晰 -- 支持减少动画偏好 - ---- - -## 🚀 性能指标 - -| 指标 | 目标值 | 实际值 | 状态 | -|------|--------|--------|------| -| 动画帧率 | ≥ 60 FPS | 60 FPS | ✅ | -| 首次渲染时间 | < 100ms | ~50ms | ✅ | -| 动画完成时间 | < 600ms | 500ms | ✅ | -| 内存占用 | < 1MB | ~0.5MB | ✅ | - ---- - -## 📚 相关资源 - -- [Tailwind CSS 动画文档](https://tailwindcss.com/docs/animation) -- [React 性能优化](https://react.dev/learn/render-and-commit) -- [Web 动画最佳实践](https://web.dev/animations/) -- [WCAG 动画指南](https://www.w3.org/WAI/WCAG21/Understanding/animation-from-interactions) - ---- - -## 🎯 总结 - -ThemeToggle 组件的动画系统提供了: - -✅ **流畅的视觉反馈** -✅ **高性能的 GPU 加速动画** -✅ **完整的可访问性支持** -✅ **灵活的自定义配置** -✅ **全面的测试覆盖** - -动画效果不仅提升了用户体验,还保持了代码的简洁性和可维护性。 - ---- - -**最后更新时间**:2026-02-13 -**文档版本**:v1.0.0 diff --git a/ecosystem.config.js b/ecosystem.config.js new file mode 100644 index 0000000..c105992 --- /dev/null +++ b/ecosystem.config.js @@ -0,0 +1,63 @@ +// PM2 生态系统配置文件 +// 用于在服务器上管理自动化测试平台的进程 +// 文档:https://pm2.keymetrics.io/docs/usage/application-declaration/ +module.exports = { + apps: [ + { + // ─── 应用基础配置 ───────────────────────────────────────── + name: 'autotest-platform', + // 生产模式:运行编译后的 JS 文件 + // tsconfig.server.json: outDir=dist/server,源码在 server/ 目录 + // 所以编译产物实际路径是 dist/server/server/index.js + script: 'node', + args: '-r tsconfig-paths/register dist/server/server/index.js', + cwd: '/www/wwwroot/autotest.wiac.xyz', + + // ─── 运行模式 ───────────────────────────────────────────── + // cluster 模式利用多核 CPU,fork 模式更简单稳定 + // 生产推荐 cluster,单核服务器用 fork + exec_mode: 'fork', + instances: 1, + + // ─── 环境变量 ───────────────────────────────────────────── + env_production: { + NODE_ENV: 'production', + PORT: 3000, + // 告知 tsconfig-paths 使用后端专属配置(路径别名解析) + TS_NODE_PROJECT: 'tsconfig.server.json', + }, + + // ─── 日志配置 ───────────────────────────────────────────── + output: '/www/wwwroot/autotest.wiac.xyz/logs/pm2-out.log', + error: '/www/wwwroot/autotest.wiac.xyz/logs/pm2-err.log', + log_date_format: 'YYYY-MM-DD HH:mm:ss', + // 日志单文件最大 50MB,超出自动轮转 + max_size: '50M', + // 保留最近 10 个日志文件 + retain: 10, + compress: true, + + // ─── 自动重启策略 ───────────────────────────────────────── + // 崩溃后自动重启 + autorestart: true, + // 最大内存限制(超出后自动重启) + max_memory_restart: '512M', + // 两次重启之间的最小间隔(毫秒) + min_uptime: '10s', + // 最大重启次数(防止启动错误导致无限重启) + max_restarts: 10, + + // ─── 优雅重启(零停机热部署)───────────────────────────── + // 等待旧进程处理完当前请求后再终止(毫秒) + kill_timeout: 5000, + // 新进程就绪后才停止旧进程(需配合 process.send('ready')) + wait_ready: false, + // 监听信号:SIGINT 触发优雅关闭(server/index.ts 中已处理) + listen_timeout: 8000, + + // ─── 文件监听(仅开发环境使用,生产环境关闭)──────────── + watch: false, + ignore_watch: ['node_modules', 'logs', 'dist', '.git'], + }, + ], +}; diff --git a/fix_jenkinsfile.py b/fix_jenkinsfile.py deleted file mode 100644 index e69de29..0000000 diff --git a/index.html b/index.html index a1aec44..68a8afd 100644 --- a/index.html +++ b/index.html @@ -2,7 +2,9 @@ - + + + 自动化测试平台 diff --git a/package-lock.json b/package-lock.json index 66e05ad..4d6672e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,11 +56,13 @@ "@types/react": "^18.2.48", "@types/react-dom": "^18.2.18", "@types/socket.io": "^3.0.1", + "@types/supertest": "^7.2.0", "@vitejs/plugin-react": "^4.2.1", "autoprefixer": "^10.4.17", "concurrently": "^8.2.2", "jsdom": "^27.4.0", "postcss": "^8.4.33", + "supertest": "^7.2.2", "tailwindcss": "^3.4.1", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", @@ -2025,6 +2027,20 @@ "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==", "license": "MIT" }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2060,6 +2076,16 @@ "node": ">= 8" } }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -4131,6 +4157,13 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmmirror.com/@types/cors/-/cors-2.8.19.tgz", @@ -4261,6 +4294,13 @@ "@types/node": "*" } }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmmirror.com/@types/mime/-/mime-1.3.5.tgz", @@ -4383,6 +4423,30 @@ "socket.io": "*" } }, + "node_modules/@types/superagent": { + "version": "8.1.9", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", + "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.0.tgz", + "integrity": "sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmmirror.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", @@ -4638,6 +4702,13 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -4648,6 +4719,13 @@ "node": ">=12" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/autoprefixer": { "version": "10.4.23", "resolved": "https://registry.npmmirror.com/autoprefixer/-/autoprefixer-10.4.23.tgz", @@ -5132,6 +5210,19 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmmirror.com/commander/-/commander-4.1.1.tgz", @@ -5141,6 +5232,16 @@ "node": ">= 6" } }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concurrently": { "version": "8.2.2", "resolved": "https://registry.npmmirror.com/concurrently/-/concurrently-8.2.2.tgz", @@ -5212,6 +5313,13 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmmirror.com/cors/-/cors-2.8.5.tgz", @@ -5540,6 +5648,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/denque": { "version": "2.1.0", "resolved": "https://registry.npmmirror.com/denque/-/denque-2.1.0.tgz", @@ -5584,6 +5702,17 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmmirror.com/didyoumean/-/didyoumean-1.2.2.tgz", @@ -5816,6 +5945,22 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-toolkit": { "version": "1.43.0", "resolved": "https://registry.npmmirror.com/es-toolkit/-/es-toolkit-1.43.0.tgz", @@ -6022,6 +6167,13 @@ "node": ">= 6" } }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-xml-parser": { "version": "5.2.5", "resolved": "https://registry.npmmirror.com/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz", @@ -6149,6 +6301,23 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -6161,6 +6330,24 @@ "node": ">=12.20.0" } }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmmirror.com/forwarded/-/forwarded-0.2.0.tgz", @@ -7305,6 +7492,16 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -8621,6 +8818,65 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-8.1.1.tgz", @@ -10223,6 +10479,13 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, "node_modules/ws": { "version": "8.19.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", diff --git a/package.json b/package.json index 3a0ac41..c0934dd 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,13 @@ "server:build": "tsc -p tsconfig.server.json", "server": "ts-node --project tsconfig.server.json server/index.ts", "start": "concurrently \"npm run dev\" \"npm run server\"", + "prod:start": "pm2 start ecosystem.config.js --env production", + "prod:stop": "pm2 stop autotest-platform", + "prod:restart": "pm2 restart autotest-platform", + "prod:reload": "pm2 reload autotest-platform --update-env", + "prod:logs": "pm2 logs autotest-platform", + "prod:status": "pm2 status autotest-platform", + "prod:deploy": "bash scripts/deploy.sh", "test": "vitest", "test:frontend": "vitest test_case/frontend", "test:backend": "vitest test_case/backend", @@ -63,11 +70,13 @@ "@types/react": "^18.2.48", "@types/react-dom": "^18.2.18", "@types/socket.io": "^3.0.1", + "@types/supertest": "^7.2.0", "@vitejs/plugin-react": "^4.2.1", "autoprefixer": "^10.4.17", "concurrently": "^8.2.2", "jsdom": "^27.4.0", "postcss": "^8.4.33", + "supertest": "^7.2.2", "tailwindcss": "^3.4.1", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..a1841bd Binary files /dev/null and b/public/favicon.ico differ diff --git a/rebuild-docker.sh b/rebuild-docker.sh new file mode 100644 index 0000000..f06304e --- /dev/null +++ b/rebuild-docker.sh @@ -0,0 +1,73 @@ +#!/bin/bash + +# Docker 完整重建脚本 +# 用于彻底清理缓存并重新构建 + +set -e + +echo "==========================================" +echo "Docker 完整重建脚本" +echo "==========================================" + +PROJECT_DIR="/root/Automation_Platform" +COMPOSE_FILE="$PROJECT_DIR/deployment/docker-compose.simple.yml" +DOCKERFILE="$PROJECT_DIR/deployment/Dockerfile" +ENV_FILE="$PROJECT_DIR/deployment/.env.production" + +cd "$PROJECT_DIR" + +echo "" +echo "[1] 停止所有容器..." +docker-compose -f "$COMPOSE_FILE" down || true + +echo "" +echo "[2] 删除旧容器(如果存在)..." +docker rm -f automation-platform-app || true + +echo "" +echo "[3] 删除旧镜像..." +docker rmi -f automation-platform:latest || true + +echo "" +echo "[4] 清理 Docker 构建缓存..." +docker builder prune -a -f + +echo "" +echo "[5] 重新构建镜像(禁用缓存)..." +docker build --no-cache -f "$DOCKERFILE" -t automation-platform:latest . + +echo "" +echo "[6] 验证镜像..." +docker images | grep automation-platform + +echo "" +echo "[7] 启动容器..." +docker-compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up -d + +echo "" +echo "[8] 等待容器启动..." +sleep 10 + +echo "" +echo "[9] 检查容器状态..." +docker ps -a | grep automation-platform-app + +echo "" +echo "[10] 检查日志..." +echo "日志输出(最后 30 行):" +docker logs --tail 30 automation-platform-app 2>&1 || echo "无法获取日志" + +echo "" +echo "[11] 测试连接..." +if timeout 5 curl -s http://localhost:3000/api/health > /dev/null 2>&1; then + echo "✓ localhost:3000 可以访问" +else + echo "✗ localhost:3000 无法访问,正在等待..." + sleep 20 + docker logs --tail 50 automation-platform-app 2>&1 +fi + +echo "" +echo "==========================================" +echo "重建完成!" +echo "==========================================" diff --git a/scripts/ai_refactor.ts b/scripts/ai_refactor.ts deleted file mode 100644 index 986aa91..0000000 --- a/scripts/ai_refactor.ts +++ /dev/null @@ -1,159 +0,0 @@ -#!/usr/bin/env node -/** - * AI Refactor CLI - * 自动化项目结构重构、依赖更新、路径重写、构建验证与报告生成 - * - * Usage: - * npx tsx scripts/ai_refactor.ts --analyze # 分析项目结构 - * npx tsx scripts/ai_refactor.ts --validate # 验证构建 - * npx tsx scripts/ai_refactor.ts --report # 生成报告 - */ - -import { execSync } from 'child_process'; -import * as fs from 'fs'; -import * as path from 'path'; - -const ROOT_DIR = path.resolve(__dirname, '..'); - -interface ValidationResult { - step: string; - success: boolean; - output?: string; - error?: string; -} - -function runCommand(command: string, description: string): ValidationResult { - console.log(`\n🔄 ${description}...`); - try { - const output = execSync(command, { - cwd: ROOT_DIR, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'] - }); - console.log(`✅ ${description} - 成功`); - return { step: description, success: true, output }; - } catch (err: any) { - console.log(`❌ ${description} - 失败`); - return { step: description, success: false, error: err.message }; - } -} - -function analyze(): void { - console.log('\n📊 分析项目结构...\n'); - - const dirs = ['src', 'server', 'configs', 'tests', 'scripts', 'docs', 'shared', 'public']; - const structure: Record = {}; - - for (const dir of dirs) { - const dirPath = path.join(ROOT_DIR, dir); - if (fs.existsSync(dirPath)) { - const files = fs.readdirSync(dirPath, { recursive: true }) as string[]; - structure[dir] = files.filter(f => !f.includes('node_modules')); - console.log(`📁 ${dir}/: ${structure[dir].length} 个文件`); - } else { - console.log(`📁 ${dir}/: 目录不存在`); - } - } - - // Check for config files - const configFiles = ['package.json', 'tsconfig.json', 'tsconfig.server.json', 'vite.config.ts']; - console.log('\n📋 配置文件:'); - for (const file of configFiles) { - const exists = fs.existsSync(path.join(ROOT_DIR, file)); - console.log(` ${exists ? '✅' : '❌'} ${file}`); - } -} - -function validate(): ValidationResult[] { - console.log('\n🔍 验证项目...\n'); - - const results: ValidationResult[] = []; - - // Type check frontend - results.push(runCommand( - 'npx tsc --noEmit -p tsconfig.json', - '前端类型检查' - )); - - // Type check backend - results.push(runCommand( - 'npx tsc --noEmit -p tsconfig.server.json', - '后端类型检查' - )); - - // Build frontend - results.push(runCommand( - 'npm run build', - '前端构建' - )); - - // Summary - const passed = results.filter(r => r.success).length; - const failed = results.filter(r => !r.success).length; - - console.log('\n📊 验证结果:'); - console.log(` ✅ 通过: ${passed}`); - console.log(` ❌ 失败: ${failed}`); - - return results; -} - -function showReport(): void { - console.log('\n📄 重构报告:\n'); - - const reportPath = path.join(ROOT_DIR, 'refactor_report.md'); - if (fs.existsSync(reportPath)) { - const content = fs.readFileSync(reportPath, 'utf-8'); - console.log(content); - } else { - console.log('报告文件不存在'); - } -} - -function showHelp(): void { - console.log(` -AI Refactor CLI - 项目结构自动化重构工具 - -用法: - npx tsx scripts/ai_refactor.ts [选项] - -选项: - --analyze 分析项目结构 - --validate 验证构建和类型检查 - --report 显示重构报告 - --help 显示帮助信息 - -示例: - npx tsx scripts/ai_refactor.ts --analyze - npx tsx scripts/ai_refactor.ts --validate - npx tsx scripts/ai_refactor.ts --analyze --validate -`); -} - -// Main -const args = process.argv.slice(2); - -if (args.length === 0 || args.includes('--help')) { - showHelp(); - process.exit(0); -} - -console.log('🤖 AI Refactor CLI v1.0.0\n'); -console.log('='.repeat(50)); - -if (args.includes('--analyze')) { - analyze(); -} - -if (args.includes('--validate')) { - const results = validate(); - const allPassed = results.every(r => r.success); - process.exit(allPassed ? 0 : 1); -} - -if (args.includes('--report')) { - showReport(); -} - -console.log('\n' + '='.repeat(50)); -console.log('✨ 完成'); diff --git a/scripts/clear-vscode-cache.sh b/scripts/clear-vscode-cache.sh deleted file mode 100755 index 2f1f764..0000000 --- a/scripts/clear-vscode-cache.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/bin/bash - -# 清理 VSCode 和 TypeScript 缓存的脚本 -# 用于解决 VSCode 显示已删除文件的错误问题 - -echo "🧹 开始清理 VSCode 和 TypeScript 缓存..." - -# 1. 清理 TypeScript 缓存 -echo "清理 TypeScript 缓存..." -rm -rf node_modules/.cache -rm -rf .tsbuildinfo -rm -rf tsconfig.tsbuildinfo - -# 2. 清理 Vite 缓存 -echo "清理 Vite 缓存..." -rm -rf node_modules/.vite - -# 3. 清理 VSCode 工作区缓存(如果存在) -if [ -d ".vscode" ]; then - echo "清理 VSCode 工作区缓存..." - rm -rf .vscode/.cache -fi - -# 4. 清理构建产物 -echo "清理构建产物..." -rm -rf dist -rm -rf build - -echo "✅ 缓存清理完成!" -echo "" -echo "📝 接下来请执行以下操作:" -echo "1. 在 VSCode 中按 Cmd+Shift+P (Mac) 或 Ctrl+Shift+P (Windows/Linux)" -echo "2. 输入 'TypeScript: Restart TS Server' 并执行" -echo "3. 或者直接重启 VSCode 窗口 (Developer: Reload Window)" -echo "" -echo "如果问题仍然存在,请关闭 VSCode 后重新打开项目。" diff --git a/scripts/deploy-aliyun.sh b/scripts/deploy-aliyun.sh deleted file mode 100755 index 17c519a..0000000 --- a/scripts/deploy-aliyun.sh +++ /dev/null @@ -1,287 +0,0 @@ -#!/bin/bash - -# 阿里云镜像部署脚本 -# 用于本地或服务器上拉取并部署阿里云镜像 - -set -e - -# 颜色输出 -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' # No Color - -# 配置变量 -ALIYUN_REGISTRY="crpi-dytkl1o45qyeksph.cn-hangzhou.personal.cr.aliyuncs.com" -NAMESPACE="caijinwei" -IMAGE_NAME="auto_test" -DEFAULT_TAG="latest" -DEPLOY_DIR="${DEPLOY_DIR:-/opt/auto-test}" -COMPOSE_FILE="${DEPLOY_DIR}/docker-compose.aliyun.yml" -ENV_FILE="${DEPLOY_DIR}/.env" - -# 函数: 打印信息 -info() { - echo -e "${GREEN}[INFO]${NC} $1" -} - -warn() { - echo -e "${YELLOW}[WARN]${NC} $1" -} - -error() { - echo -e "${RED}[ERROR]${NC} $1" - exit 1 -} - -# 函数: 检查命令是否存在 -check_command() { - if ! command -v $1 &> /dev/null; then - error "$1 未安装,请先安装" - fi -} - -# 函数: 登录阿里云容器镜像服务 -login_aliyun() { - info "正在登录阿里云容器镜像服务..." - - if [ -z "$ALIYUN_USERNAME" ] || [ -z "$ALIYUN_PASSWORD" ]; then - warn "未设置阿里云凭据环境变量,跳过自动登录" - warn "如果需要拉取私有镜像,请手动登录:" - warn " docker login $ALIYUN_REGISTRY" - return 0 - fi - - echo "$ALIYUN_PASSWORD" | docker login --username="$ALIYUN_USERNAME" --password-stdin "$ALIYUN_REGISTRY" - info "✅ 阿里云登录成功" -} - -# 函数: 拉取镜像 -pull_image() { - local tag=${1:-$DEFAULT_TAG} - local full_image="${ALIYUN_REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:${tag}" - - info "正在拉取镜像: $full_image" - - if docker pull "$full_image"; then - info "✅ 镜像拉取成功" - else - error "❌ 镜像拉取失败" - fi - - # 标记为 latest - if [ "$tag" != "latest" ]; then - docker tag "$full_image" "${ALIYUN_REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:latest" - info "已标记为 latest 版本" - fi - - # 显示镜像信息 - info "镜像信息:" - docker images | grep "$IMAGE_NAME" || true -} - -# 函数: 停止现有服务 -stop_services() { - info "正在停止现有服务..." - - if [ -f "$COMPOSE_FILE" ]; then - cd "$DEPLOY_DIR" - docker-compose -f "$COMPOSE_FILE" down - info "✅ 服务已停止" - else - warn "未找到 docker-compose 文件,跳过停止步骤" - fi -} - -# 函数: 创建必要的目录 -create_directories() { - info "创建必要的目录..." - - mkdir -p "$DEPLOY_DIR"/{data,logs,backups,configs} - mkdir -p /var/log/auto-test - - info "✅ 目录创建完成" -} - -# 函数: 准备部署文件 -prepare_deploy_files() { - info "准备部署文件..." - - # 复制 docker-compose 文件 - if [ ! -f "$COMPOSE_FILE" ]; then - warn "未找到 docker-compose.aliyun.yml,请确保文件存在" - warn "路径: $COMPOSE_FILE" - return 1 - fi - - # 准备 .env 文件 - if [ ! -f "$ENV_FILE" ]; then - if [ -f "${DEPLOY_DIR}/.env.aliyun.example" ]; then - cp "${DEPLOY_DIR}/.env.aliyun.example" "$ENV_FILE" - info "已创建 .env 文件,请根据需要编辑配置" - else - warn "未找到 .env 文件和示例文件" - fi - fi - - info "✅ 部署文件准备完成" -} - -# 函数: 启动服务 -start_services() { - local tag=${1:-$DEFAULT_TAG} - - info "正在启动服务..." - - cd "$DEPLOY_DIR" - - # 设置镜像标签环境变量 - export IMAGE_TAG="$tag" - - # 启动服务 - if docker-compose -f "$COMPOSE_FILE" up -d; then - info "✅ 服务启动成功" - else - error "❌ 服务启动失败" - fi - - # 显示运行状态 - info "服务状态:" - docker-compose -f "$COMPOSE_FILE" ps -} - -# 函数: 健康检查 -health_check() { - local max_retries=30 - local retry=0 - local health_url="http://localhost:3000/api/health" - - info "正在进行健康检查..." - - while [ $retry -lt $max_retries ]; do - if curl -f -s "$health_url" > /dev/null 2>&1; then - info "✅ 健康检查通过" - return 0 - fi - - retry=$((retry + 1)) - echo -n "." - sleep 2 - done - - echo - warn "⚠️ 健康检查超时,请检查服务日志" - warn "查看日志: docker-compose -f $COMPOSE_FILE logs -f app" -} - -# 函数: 显示使用帮助 -show_help() { - cat << EOF -阿里云镜像部署脚本 - -用法: $0 [命令] [选项] - -命令: - pull [tag] 拉取阿里云镜像 (默认: latest) - deploy [tag] 部署镜像并启动服务 - stop 停止服务 - restart [tag] 重启服务 - status 查看服务状态 - logs 查看服务日志 - health 执行健康检查 - update [tag] 更新到新版本 - help 显示帮助信息 - -环境变量: - ALIYUN_USERNAME 阿里云容器镜像服务用户名 - ALIYUN_PASSWORD 阿里云容器镜像服务密码 - DEPLOY_DIR 部署目录 (默认: /opt/auto-test) - -示例: - # 拉取 latest 标签的镜像 - $0 pull latest - - # 部署指定标签的镜像 - $0 deploy master - - # 停止服务 - $0 stop - - # 查看日志 - $0 logs - - # 更新到新版本 - $0 update d42144a - -EOF -} - -# 函数: 主函数 -main() { - local command=${1:-help} - local tag=${2:-$DEFAULT_TAG} - - # 检查必要的命令 - check_command docker - check_command docker-compose - - case $command in - pull) - login_aliyun - pull_image "$tag" - ;; - deploy) - check_command curl - login_aliyun - create_directories - prepare_deploy_files - stop_services - pull_image "$tag" - start_services "$tag" - health_check - info "部署完成! 访问: http://localhost:3000" - ;; - stop) - stop_services - ;; - restart) - stop_services - start_services "$tag" - health_check - ;; - status) - if [ -f "$COMPOSE_FILE" ]; then - cd "$DEPLOY_DIR" - docker-compose -f "$COMPOSE_FILE" ps - else - error "未找到 docker-compose 文件" - fi - ;; - logs) - if [ -f "$COMPOSE_FILE" ]; then - cd "$DEPLOY_DIR" - docker-compose -f "$COMPOSE_FILE" logs -f - else - error "未找到 docker-compose 文件" - fi - ;; - health) - health_check - ;; - update) - check_command curl - login_aliyun - stop_services - pull_image "$tag" - start_services "$tag" - health_check - info "更新完成!" - ;; - help|*) - show_help - ;; - esac -} - -# 执行主函数 -main "$@" diff --git a/scripts/deploy.sh b/scripts/deploy.sh index cf68cef..268f2f0 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -1,486 +1,103 @@ #!/bin/bash +# ============================================================ +# 热部署脚本 - 自动化测试平台 +# 用途:代码更新后零停机重新部署(前端重新构建 + 后端热重载) +# 用法:bash scripts/deploy.sh +# ============================================================ -# 自动化平台部署脚本 -# 用途: 在远程服务器上部署应用 (需要 docker-compose.yml) -# 注意: 对于快速部署,推荐使用 deployment/scripts/setup.sh -# 使用: ./deploy.sh +set -e # 任何命令失败立即退出 -set -euo pipefail - -# 脚本配置 -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -APP_NAME="automation-platform" -LOG_FILE="/var/log/${APP_NAME}/deploy.log" - -# 颜色输出 +# ─── 颜色输出 ─────────────────────────────────────────────── RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color -# 日志函数 -log() { - echo -e "${BLUE}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1" | tee -a "$LOG_FILE" -} - -log_success() { - echo -e "${GREEN}[$(date +'%Y-%m-%d %H:%M:%S')] ✅ $1${NC}" | tee -a "$LOG_FILE" -} - -log_error() { - echo -e "${RED}[$(date +'%Y-%m-%d %H:%M:%S')] ❌ $1${NC}" | tee -a "$LOG_FILE" -} - -log_warning() { - echo -e "${YELLOW}[$(date +'%Y-%m-%d %H:%M:%S')] ⚠️ $1${NC}" | tee -a "$LOG_FILE" -} - -# 错误处理 -error_exit() { - log_error "$1" +log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } +log_step() { echo -e "${BLUE}[STEP]${NC} $1"; } + +# ─── 配置 ────────────────────────────────────────────────── +APP_DIR="/www/wwwroot/autotest.wiac.xyz" +APP_NAME="autotest-platform" +LOG_DIR="${APP_DIR}/logs" +ENV_FILE="${APP_DIR}/.env" + +# ─── 前置检查 ─────────────────────────────────────────────── +log_step "=== 自动化测试平台 热部署 ===" +echo "" + +# 检查是否在正确的目录 +if [ ! -f "${APP_DIR}/package.json" ]; then + log_error "找不到 package.json,请确认部署目录:${APP_DIR}" + exit 1 +fi + +cd "${APP_DIR}" + +# 检查 .env 文件 +if [ ! -f "${ENV_FILE}" ]; then + log_warn ".env 文件不存在,将使用 deployment/.env.production 作为默认配置" + if [ -f "${APP_DIR}/deployment/.env.production" ]; then + cp "${APP_DIR}/deployment/.env.production" "${ENV_FILE}" + log_info "已从 deployment/.env.production 复制 .env 文件,请检查并修改配置" + else + log_error "找不到环境配置文件,请手动创建 .env" exit 1 -} - -# 显示帮助信息 -show_help() { - cat << EOF -自动化平台部署脚本 - -用法: - $0 - -参数: - environment 部署环境 (dev|staging|production) - strategy 部署策略 (rolling|blue-green|recreate) - image_tag Docker镜像标签 - -示例: - $0 production blue-green myregistry/automation-platform:1.0.0 - $0 dev rolling myregistry/automation-platform:latest - -环境变量: - BACKUP_RETENTION_DAYS 备份保留天数 (默认: 7) - MAX_ROLLBACK_VERSIONS 最大回滚版本数 (默认: 5) - HEALTH_CHECK_TIMEOUT 健康检查超时时间 (默认: 300秒) - -EOF -} - -# 参数验证 -validate_params() { - if [[ $# -ne 3 ]]; then - show_help - error_exit "参数数量错误" - fi - - ENVIRONMENT="$1" - STRATEGY="$2" - IMAGE_TAG="$3" - - # 验证环境 - if [[ ! "$ENVIRONMENT" =~ ^(dev|staging|production)$ ]]; then - error_exit "无效的环境: $ENVIRONMENT" - fi - - # 验证策略 - if [[ ! "$STRATEGY" =~ ^(rolling|blue-green|recreate)$ ]]; then - error_exit "无效的部署策略: $STRATEGY" - fi - - # 验证镜像标签 - if [[ -z "$IMAGE_TAG" ]]; then - error_exit "镜像标签不能为空" - fi - - log "部署参数验证通过" - log "环境: $ENVIRONMENT" - log "策略: $STRATEGY" - log "镜像: $IMAGE_TAG" -} - -# 环境准备 -prepare_environment() { - log "准备部署环境..." - - # 创建必要的目录 - sudo mkdir -p /opt/"$APP_NAME"/{data,logs,backups,configs} - sudo mkdir -p /var/log/"$APP_NAME" - - # 设置目录权限 - sudo chown -R "$USER:$USER" /opt/"$APP_NAME" - sudo chown -R "$USER:$USER" /var/log/"$APP_NAME" - - # 创建日志文件 - mkdir -p "$(dirname "$LOG_FILE")" - touch "$LOG_FILE" - - # 检查必要的工具 - command -v docker >/dev/null 2>&1 || error_exit "Docker 未安装" - command -v docker-compose >/dev/null 2>&1 || error_exit "docker-compose 未安装" - - log_success "环境准备完成" -} - -# 备份当前版本 -backup_current_version() { - log "备份当前版本..." - - local backup_dir="/opt/$APP_NAME/backups/$(date +%Y%m%d_%H%M%S)" - mkdir -p "$backup_dir" - - # 备份配置文件 - if [[ -f "/opt/$APP_NAME/.env" ]]; then - cp "/opt/$APP_NAME/.env" "$backup_dir/" - log "已备份环境配置" - fi - - # 备份 docker-compose.yml (如果存在) - if [[ -f "/opt/$APP_NAME/docker-compose.yml" ]]; then - cp "/opt/$APP_NAME/docker-compose.yml" "$backup_dir/" - log "已备份 Docker Compose 配置" - else - log "跳过 Docker Compose 配置备份 (文件不存在)" - fi - - # 备份数据库(如果是本地数据库) - if [[ -d "/opt/$APP_NAME/data/db" ]]; then - cp -r "/opt/$APP_NAME/data/db" "$backup_dir/" - log "已备份数据库" - fi - - # 记录当前运行的镜像版本 - if docker ps --format "table {{.Image}}" | grep -q "$APP_NAME"; then - docker ps --format "table {{.Image}}\t{{.Status}}" | grep "$APP_NAME" > "$backup_dir/current_images.txt" - log "已记录当前镜像版本" - fi - - # 清理旧备份 - local retention_days="${BACKUP_RETENTION_DAYS:-7}" - find /opt/"$APP_NAME"/backups -type d -mtime +"$retention_days" -exec rm -rf {} + 2>/dev/null || true - - echo "$backup_dir" > /opt/"$APP_NAME"/backups/latest_backup.txt - log_success "备份完成: $backup_dir" -} - -# 拉取新镜像 -pull_image() { - log "拉取新镜像: $IMAGE_TAG" - - # 登录到 Docker 仓库(如果需要) - if [[ -n "${DOCKER_REGISTRY_USER:-}" ]] && [[ -n "${DOCKER_REGISTRY_PASS:-}" ]]; then - echo "$DOCKER_REGISTRY_PASS" | docker login "${DOCKER_REGISTRY:-}" -u "$DOCKER_REGISTRY_USER" --password-stdin - fi - - # 拉取镜像 - docker pull "$IMAGE_TAG" || error_exit "镜像拉取失败" - - log_success "镜像拉取完成" -} - -# 更新配置文件 -update_configs() { - log "更新配置文件..." - - cd /opt/"$APP_NAME" - - # 更新 docker-compose.yml 中的镜像标签 - if [[ -f "docker-compose.yml" ]]; then - # 使用 sed 替换镜像标签 - sed -i.bak "s|image:.*$APP_NAME:.*|image: $IMAGE_TAG|g" docker-compose.yml - log "已更新 Docker Compose 镜像标签" - else - error_exit "docker-compose.yml 文件不存在。请使用 deployment/scripts/setup.sh 进行快速部署,或创建 docker-compose.yml 文件。" - fi - - # 验证配置文件 - docker-compose config >/dev/null || error_exit "Docker Compose 配置文件验证失败" - - log_success "配置文件更新完成" -} - -# 滚动更新部署 -deploy_rolling() { - log "执行滚动更新部署..." - - cd /opt/"$APP_NAME" - - # 滚动更新服务 - docker-compose up -d --no-deps --scale app=2 app || error_exit "启动新容器失败" - - # 等待新容器健康检查通过 - log "等待新容器启动..." - sleep 30 - - # 检查新容器状态 - if ! docker-compose ps app | grep -q "Up"; then - error_exit "新容器启动失败" - fi - - # 停止旧容器 - log "停止旧容器..." - docker-compose up -d --no-deps --scale app=1 app - - log_success "滚动更新部署完成" -} - -# 蓝绿部署 -deploy_blue_green() { - log "执行蓝绿部署..." - - cd /opt/"$APP_NAME" - - # 获取当前环境颜色 - local current_color - if docker-compose ps | grep -q "${APP_NAME}-blue"; then - current_color="blue" - new_color="green" - else - current_color="green" - new_color="blue" - fi - - log "当前环境: $current_color, 新环境: $new_color" - - # 创建新环境的 compose 文件 - cat > "docker-compose-${new_color}.yml" << EOF -version: '3.8' -services: - app-${new_color}: - image: ${IMAGE_TAG} - container_name: ${APP_NAME}-${new_color} - environment: - - NODE_ENV=${ENVIRONMENT} - env_file: - - .env - ports: - - "300${new_color == "green" ? "1" : "2"}:3000" - volumes: - - ./data:/app/data - - ./logs:/app/logs - restart: unless-stopped - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 40s -EOF - - # 启动新环境 - docker-compose -f "docker-compose-${new_color}.yml" up -d || error_exit "新环境启动失败" - - # 等待健康检查 - log "等待新环境健康检查..." - local timeout="${HEALTH_CHECK_TIMEOUT:-300}" - local count=0 - while [[ $count -lt $timeout ]]; do - if docker-compose -f "docker-compose-${new_color}.yml" ps app-"${new_color}" | grep -q "healthy"; then - log_success "新环境健康检查通过" - break - fi - sleep 10 - count=$((count + 10)) - done - - if [[ $count -ge $timeout ]]; then - log_error "新环境健康检查超时" - docker-compose -f "docker-compose-${new_color}.yml" down - error_exit "蓝绿部署失败" - fi - - # 切换流量(更新 Nginx 配置或负载均衡器) - switch_traffic "$new_color" - - # 停止旧环境 - log "停止旧环境..." - if [[ -f "docker-compose-${current_color}.yml" ]]; then - docker-compose -f "docker-compose-${current_color}.yml" down - fi - - # 更新主配置文件 - cp "docker-compose-${new_color}.yml" docker-compose.yml - - log_success "蓝绿部署完成" -} - -# 切换流量 -switch_traffic() { - local new_color="$1" - log "切换流量到 $new_color 环境..." - - # 更新 Nginx 配置(如果使用 Nginx) - if [[ -f "/etc/nginx/sites-available/$APP_NAME" ]]; then - local new_port - if [[ "$new_color" == "green" ]]; then - new_port="3001" - else - new_port="3002" - fi - - # 更新上游服务器配置 - sudo sed -i "s/server localhost:[0-9]*/server localhost:$new_port/" "/etc/nginx/sites-available/$APP_NAME" - sudo nginx -t && sudo systemctl reload nginx || log_warning "Nginx 配置更新失败" - fi - - log_success "流量切换完成" -} - -# 重建部署 -deploy_recreate() { - log "执行重建部署..." - - cd /opt/"$APP_NAME" - - # 停止所有服务 - docker-compose down || log_warning "停止服务时出现警告" - - # 清理旧镜像(可选) - docker image prune -f || true - - # 启动新服务 - docker-compose up -d || error_exit "重建部署失败" - - log_success "重建部署完成" -} - -# 执行部署 -execute_deployment() { - log "开始执行部署..." - - case "$STRATEGY" in - "rolling") - deploy_rolling - ;; - "blue-green") - deploy_blue_green - ;; - "recreate") - deploy_recreate - ;; - *) - error_exit "未知的部署策略: $STRATEGY" - ;; - esac - - log_success "部署执行完成" -} - -# 部署后验证 -post_deploy_verification() { - log "执行部署后验证..." - - cd /opt/"$APP_NAME" - - # 检查容器状态 - local unhealthy_containers - unhealthy_containers=$(docker-compose ps | grep -v "Up" | grep -v "Name" | wc -l) - - if [[ $unhealthy_containers -gt 0 ]]; then - log_error "发现 $unhealthy_containers 个不健康的容器" - docker-compose ps - error_exit "部署后验证失败" - fi - - # 检查服务端点 - local max_attempts=30 - local attempt=1 - - while [[ $attempt -le $max_attempts ]]; do - if curl -f -s "http://localhost:3000/api/health" >/dev/null 2>&1; then - log_success "健康检查端点响应正常" - break - fi - - log "健康检查尝试 $attempt/$max_attempts..." - sleep 10 - attempt=$((attempt + 1)) - done - - if [[ $attempt -gt $max_attempts ]]; then - error_exit "健康检查端点验证失败" - fi - - # 记录部署信息 - cat > /opt/"$APP_NAME"/deployment_info.txt << EOF -部署时间: $(date) -环境: $ENVIRONMENT -策略: $STRATEGY -镜像: $IMAGE_TAG -构建用户: ${BUILD_USER:-unknown} -构建号: ${BUILD_NUMBER:-unknown} -EOF - - log_success "部署后验证完成" -} - -# 清理资源 -cleanup() { - log "清理部署资源..." - - # 清理未使用的镜像 - docker image prune -f >/dev/null 2>&1 || true - - # 清理未使用的网络 - docker network prune -f >/dev/null 2>&1 || true - - # 清理未使用的卷 - docker volume prune -f >/dev/null 2>&1 || true - - # 限制回滚版本数量 - local max_versions="${MAX_ROLLBACK_VERSIONS:-5}" - local backup_count - backup_count=$(find /opt/"$APP_NAME"/backups -type d -name "20*" | wc -l) - - if [[ $backup_count -gt $max_versions ]]; then - find /opt/"$APP_NAME"/backups -type d -name "20*" | sort | head -n $((backup_count - max_versions)) | xargs rm -rf - log "已清理旧备份,保留最新 $max_versions 个版本" - fi - - log_success "资源清理完成" -} - -# 主函数 -main() { - echo "=========================================" - echo "🚀 自动化平台部署脚本" - echo "=========================================" - - # 参数验证 - validate_params "$@" - - # 环境准备 - prepare_environment - - # 备份当前版本 - backup_current_version - - # 拉取新镜像 - pull_image - - # 更新配置 - update_configs - - # 执行部署 - execute_deployment - - # 部署后验证 - post_deploy_verification - - # 清理资源 - cleanup - - echo "=========================================" - log_success "🎉 部署成功完成!" - echo "=========================================" - echo "环境: $ENVIRONMENT" - echo "策略: $STRATEGY" - echo "镜像: $IMAGE_TAG" - echo "时间: $(date)" - echo "=========================================" -} - -# 脚本入口 -if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then - main "$@" -fi \ No newline at end of file + fi +fi + +# 检查 PM2 是否安装 +if ! command -v pm2 &> /dev/null; then + log_error "PM2 未安装,请先运行:npm install -g pm2" + exit 1 +fi + +# 创建日志目录 +mkdir -p "${LOG_DIR}" + +echo "" +log_step "步骤 1/4:安装/更新依赖" +npm install --production=false +log_info "依赖安装完成" + +echo "" +log_step "步骤 2/4:构建前端(Vite 打包)" +# 注意:必须先构建前端,因为 vite build 会清空 dist/ 目录 +npm run build +log_info "前端构建完成 → dist/" + +echo "" +log_step "步骤 3/4:构建后端(TypeScript 编译)" +# 后端在前端构建完成后编译,避免被 vite 清空 +npm run server:build +log_info "后端编译完成 → dist/server/server/" + +echo "" +log_step "步骤 4/4:热重载应用(零停机)" + +# 检查 PM2 中是否已存在该应用 +if pm2 list | grep -q "${APP_NAME}"; then + log_info "检测到应用已在运行,执行热重载..." + # reload 命令:逐个重启进程,保证零停机 + pm2 reload "${APP_NAME}" --update-env + log_info "热重载完成!" +else + log_info "应用未运行,首次启动..." + pm2 start ecosystem.config.js --env production + log_info "应用启动成功!" +fi + +# 保存 PM2 进程列表(确保服务器重启后自动恢复) +pm2 save + +echo "" +log_info "=== 部署完成 ===" +echo "" +pm2 status "${APP_NAME}" +echo "" +log_info "应用地址:http://$(hostname -I | awk '{print $1}'):3000" +log_info "查看日志:pm2 logs ${APP_NAME}" +log_info "查看状态:pm2 status" diff --git a/scripts/health-check.sh b/scripts/health-check.sh deleted file mode 100755 index 637306b..0000000 --- a/scripts/health-check.sh +++ /dev/null @@ -1,565 +0,0 @@ -#!/bin/bash - -# 自动化平台健康检查脚本 -# 用途: 检查应用服务的健康状态 -# 使用: ./health-check.sh [options] - -set -euo pipefail - -# 脚本配置 -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -APP_NAME="automation-platform" -LOG_FILE="/var/log/${APP_NAME}/health-check.log" - -# 默认配置 -DEFAULT_TIMEOUT=300 -DEFAULT_RETRY_INTERVAL=10 -DEFAULT_MAX_RETRIES=30 - -# 颜色输出 -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -# 日志函数 -log() { - echo -e "${BLUE}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1" | tee -a "$LOG_FILE" -} - -log_success() { - echo -e "${GREEN}[$(date +'%Y-%m-%d %H:%M:%S')] ✅ $1${NC}" | tee -a "$LOG_FILE" -} - -log_error() { - echo -e "${RED}[$(date +'%Y-%m-%d %H:%M:%S')] ❌ $1${NC}" | tee -a "$LOG_FILE" -} - -log_warning() { - echo -e "${YELLOW}[$(date +'%Y-%m-%d %H:%M:%S')] ⚠️ $1${NC}" | tee -a "$LOG_FILE" -} - -# 错误处理 -error_exit() { - log_error "$1" - exit 1 -} - -# 显示帮助信息 -show_help() { - cat << EOF -自动化平台健康检查脚本 - -用法: - $0 [options] - -参数: - environment 部署环境 (dev|staging|production) - -选项: - -t, --timeout 健康检查超时时间 (默认: 300) - -i, --interval 重试间隔时间 (默认: 10) - -r, --retries 最大重试次数 (默认: 30) - -u, --url 自定义应用URL - -p, --port 自定义端口 (默认: 3000) - -s, --silent 静默模式 - -v, --verbose 详细输出 - -h, --help 显示帮助信息 - -示例: - $0 production - $0 dev --timeout 600 --interval 5 - $0 staging --url http://staging.example.com - -检查项目: - - Docker 容器状态 - - 应用健康检查端点 - - 数据库连接 - - API 端点响应 - - 系统资源使用情况 - - 日志错误检查 - -EOF -} - -# 参数解析 -parse_arguments() { - ENVIRONMENT="" - TIMEOUT="$DEFAULT_TIMEOUT" - RETRY_INTERVAL="$DEFAULT_RETRY_INTERVAL" - MAX_RETRIES="$DEFAULT_MAX_RETRIES" - CUSTOM_URL="" - CUSTOM_PORT="3000" - SILENT=false - VERBOSE=false - - while [[ $# -gt 0 ]]; do - case $1 in - -t|--timeout) - TIMEOUT="$2" - shift 2 - ;; - -i|--interval) - RETRY_INTERVAL="$2" - shift 2 - ;; - -r|--retries) - MAX_RETRIES="$2" - shift 2 - ;; - -u|--url) - CUSTOM_URL="$2" - shift 2 - ;; - -p|--port) - CUSTOM_PORT="$2" - shift 2 - ;; - -s|--silent) - SILENT=true - shift - ;; - -v|--verbose) - VERBOSE=true - shift - ;; - -h|--help) - show_help - exit 0 - ;; - -*) - error_exit "未知选项: $1" - ;; - *) - if [[ -z "$ENVIRONMENT" ]]; then - ENVIRONMENT="$1" - else - error_exit "多余的参数: $1" - fi - shift - ;; - esac - done - - # 验证必需参数 - if [[ -z "$ENVIRONMENT" ]]; then - show_help - error_exit "缺少环境参数" - fi - - # 验证环境 - if [[ ! "$ENVIRONMENT" =~ ^(dev|staging|production)$ ]]; then - error_exit "无效的环境: $ENVIRONMENT" - fi - - # 设置应用URL - if [[ -n "$CUSTOM_URL" ]]; then - APP_URL="$CUSTOM_URL" - else - case "$ENVIRONMENT" in - "production") - APP_URL="https://automation-platform.example.com" - ;; - "staging") - APP_URL="https://staging-automation-platform.example.com" - ;; - "dev") - APP_URL="http://localhost:${CUSTOM_PORT}" - ;; - *) - APP_URL="http://localhost:${CUSTOM_PORT}" - ;; - esac - fi -} - -# 检查 Docker 容器状态 -check_docker_containers() { - log "检查 Docker 容器状态..." - - cd /opt/"$APP_NAME" 2>/dev/null || { - log_warning "应用目录不存在,跳过 Docker 容器检查" - return 0 - } - - if [[ ! -f "docker-compose.yml" ]]; then - log_warning "docker-compose.yml 不存在,跳过容器检查 (使用 deployment/scripts/setup.sh 部署时正常)" - return 0 - fi - - # 检查容器运行状态 - local unhealthy_containers - unhealthy_containers=$(docker-compose ps --services --filter "status=running" | wc -l) - - if [[ $unhealthy_containers -eq 0 ]]; then - log_error "没有运行中的容器" - return 1 - fi - - # 显示容器详细状态 - if [[ "$VERBOSE" == "true" ]]; then - log "容器状态详情:" - docker-compose ps - fi - - # 检查容器健康状态 - local containers - containers=$(docker-compose ps --services) - - for container in $containers; do - local status - status=$(docker-compose ps "$container" | tail -n 1 | awk '{print $3}') - - if [[ "$status" == "Up" ]]; then - log_success "容器 $container 运行正常" - else - log_error "容器 $container 状态异常: $status" - return 1 - fi - done - - log_success "Docker 容器状态检查通过" - return 0 -} - -# 检查应用健康端点 -check_health_endpoint() { - log "检查应用健康端点..." - - local health_url="${APP_URL}/api/health" - local attempt=1 - - while [[ $attempt -le $MAX_RETRIES ]]; do - if [[ "$SILENT" != "true" ]]; then - log "健康检查尝试 $attempt/$MAX_RETRIES: $health_url" - fi - - # 执行健康检查请求 - local response - local http_code - response=$(curl -s -w "%{http_code}" --max-time 30 "$health_url" 2>/dev/null || echo "000") - http_code="${response: -3}" - response="${response%???}" - - if [[ "$http_code" == "200" ]]; then - log_success "健康检查端点响应正常" - - if [[ "$VERBOSE" == "true" ]]; then - log "响应内容: $response" - fi - - return 0 - else - if [[ "$VERBOSE" == "true" ]]; then - log_warning "健康检查失败 (HTTP $http_code): $response" - fi - fi - - if [[ $attempt -lt $MAX_RETRIES ]]; then - sleep "$RETRY_INTERVAL" - fi - - attempt=$((attempt + 1)) - done - - log_error "健康检查端点验证失败" - return 1 -} - -# 检查数据库连接 -check_database_connection() { - log "检查数据库连接..." - - local db_check_url="${APP_URL}/api/health/db" - - # 尝试访问数据库健康检查端点 - local response - local http_code - response=$(curl -s -w "%{http_code}" --max-time 30 "$db_check_url" 2>/dev/null || echo "000") - http_code="${response: -3}" - - if [[ "$http_code" == "200" ]]; then - log_success "数据库连接正常" - - if [[ "$VERBOSE" == "true" ]]; then - log "数据库响应: ${response%???}" - fi - - return 0 - else - log_warning "数据库健康检查端点不可用 (HTTP $http_code)" - - # 备用检查:尝试查询一个简单的 API 端点 - local api_response - local api_http_code - api_response=$(curl -s -w "%{http_code}" --max-time 30 "${APP_URL}/api/dashboard" 2>/dev/null || echo "000") - api_http_code="${api_response: -3}" - - if [[ "$api_http_code" == "200" ]]; then - log_success "API 端点响应正常,数据库连接可能正常" - return 0 - else - log_error "API 端点也无法访问,数据库连接可能有问题" - return 1 - fi - fi -} - -# 检查关键 API 端点 -check_api_endpoints() { - log "检查关键 API 端点..." - - local endpoints=( - "/api/dashboard" - "/api/executions" - "/api/cases" - "/api/tasks" - ) - - local failed_count=0 - - for endpoint in "${endpoints[@]}"; do - local url="${APP_URL}${endpoint}" - local response - local http_code - - response=$(curl -s -w "%{http_code}" --max-time 30 "$url" 2>/dev/null || echo "000") - http_code="${response: -3}" - - if [[ "$http_code" =~ ^(200|401|403)$ ]]; then - # 200 OK, 401 Unauthorized, 403 Forbidden 都算正常(可能需要认证) - log_success "端点 $endpoint 响应正常 (HTTP $http_code)" - else - log_error "端点 $endpoint 响应异常 (HTTP $http_code)" - failed_count=$((failed_count + 1)) - fi - - if [[ "$VERBOSE" == "true" ]]; then - log "端点 $endpoint 响应: ${response%???}" - fi - done - - if [[ $failed_count -gt 0 ]]; then - log_error "$failed_count 个 API 端点检查失败" - return 1 - else - log_success "所有 API 端点检查通过" - return 0 - fi -} - -# 检查系统资源 -check_system_resources() { - log "检查系统资源使用情况..." - - # 检查磁盘空间 - local disk_usage - disk_usage=$(df /opt/"$APP_NAME" 2>/dev/null | tail -1 | awk '{print $5}' | sed 's/%//' || echo "0") - - if [[ $disk_usage -gt 90 ]]; then - log_error "磁盘空间不足: ${disk_usage}%" - return 1 - elif [[ $disk_usage -gt 80 ]]; then - log_warning "磁盘空间紧张: ${disk_usage}%" - else - log_success "磁盘空间充足: ${disk_usage}%" - fi - - # 检查内存使用 - local memory_usage - memory_usage=$(free | grep Mem | awk '{printf "%.0f", $3/$2 * 100.0}') - - if [[ $memory_usage -gt 90 ]]; then - log_error "内存使用过高: ${memory_usage}%" - return 1 - elif [[ $memory_usage -gt 80 ]]; then - log_warning "内存使用较高: ${memory_usage}%" - else - log_success "内存使用正常: ${memory_usage}%" - fi - - # 检查 Docker 资源 - if command -v docker >/dev/null 2>&1; then - local docker_stats - docker_stats=$(docker system df --format "table {{.Type}}\t{{.TotalCount}}\t{{.Size}}" 2>/dev/null || echo "") - - if [[ -n "$docker_stats" ]] && [[ "$VERBOSE" == "true" ]]; then - log "Docker 资源统计:" - echo "$docker_stats" - fi - fi - - log_success "系统资源检查完成" - return 0 -} - -# 检查应用日志 -check_application_logs() { - log "检查应用日志..." - - local log_dirs=( - "/opt/$APP_NAME/logs" - "/var/log/$APP_NAME" - "/opt/$APP_NAME/data/logs" - ) - - local error_count=0 - - for log_dir in "${log_dirs[@]}"; do - if [[ ! -d "$log_dir" ]]; then - continue - fi - - # 检查最近的错误日志 - local recent_errors - recent_errors=$(find "$log_dir" -name "*.log" -mtime -1 -exec grep -i "error\|fatal\|exception" {} \; 2>/dev/null | wc -l) - - if [[ $recent_errors -gt 100 ]]; then - log_error "在 $log_dir 中发现大量错误日志: $recent_errors 条" - error_count=$((error_count + 1)) - elif [[ $recent_errors -gt 10 ]]; then - log_warning "在 $log_dir 中发现一些错误日志: $recent_errors 条" - else - log_success "日志目录 $log_dir 错误数量正常: $recent_errors 条" - fi - - # 显示最近的严重错误 - if [[ "$VERBOSE" == "true" ]] && [[ $recent_errors -gt 0 ]]; then - log "最近的错误日志示例:" - find "$log_dir" -name "*.log" -mtime -1 -exec grep -i "fatal\|exception" {} \; 2>/dev/null | head -5 || true - fi - done - - if [[ $error_count -gt 0 ]]; then - log_error "应用日志检查发现问题" - return 1 - else - log_success "应用日志检查正常" - return 0 - fi -} - -# 生成健康检查报告 -generate_health_report() { - local overall_status="$1" - local report_file="/tmp/health-check-report-$(date +%Y%m%d_%H%M%S).json" - - cat > "$report_file" << EOF -{ - "timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)", - "environment": "$ENVIRONMENT", - "overall_status": "$overall_status", - "app_url": "$APP_URL", - "checks": { - "docker_containers": ${docker_check_result:-false}, - "health_endpoint": ${health_check_result:-false}, - "database_connection": ${db_check_result:-false}, - "api_endpoints": ${api_check_result:-false}, - "system_resources": ${resource_check_result:-false}, - "application_logs": ${log_check_result:-false} - }, - "system_info": { - "hostname": "$(hostname)", - "uptime": "$(uptime -p 2>/dev/null || echo 'unknown')", - "load_average": "$(uptime | awk -F'load average:' '{print $2}' | xargs)" - } -} -EOF - - if [[ "$VERBOSE" == "true" ]]; then - log "健康检查报告已生成: $report_file" - cat "$report_file" - fi -} - -# 主函数 -main() { - echo "=========================================" - echo "🏥 自动化平台健康检查" - echo "=========================================" - - # 解析参数 - parse_arguments "$@" - - # 创建日志目录 - mkdir -p "$(dirname "$LOG_FILE")" - touch "$LOG_FILE" - - log "开始健康检查..." - log "环境: $ENVIRONMENT" - log "应用URL: $APP_URL" - log "超时时间: $TIMEOUT 秒" - - local failed_checks=0 - local total_checks=6 - - # 执行各项检查 - if check_docker_containers; then - docker_check_result=true - else - docker_check_result=false - failed_checks=$((failed_checks + 1)) - fi - - if check_health_endpoint; then - health_check_result=true - else - health_check_result=false - failed_checks=$((failed_checks + 1)) - fi - - if check_database_connection; then - db_check_result=true - else - db_check_result=false - failed_checks=$((failed_checks + 1)) - fi - - if check_api_endpoints; then - api_check_result=true - else - api_check_result=false - failed_checks=$((failed_checks + 1)) - fi - - if check_system_resources; then - resource_check_result=true - else - resource_check_result=false - failed_checks=$((failed_checks + 1)) - fi - - if check_application_logs; then - log_check_result=true - else - log_check_result=false - failed_checks=$((failed_checks + 1)) - fi - - # 生成报告 - local overall_status - if [[ $failed_checks -eq 0 ]]; then - overall_status="healthy" - log_success "所有健康检查通过 ($total_checks/$total_checks)" - else - overall_status="unhealthy" - log_error "健康检查失败 ($((total_checks - failed_checks))/$total_checks 通过)" - fi - - generate_health_report "$overall_status" - - echo "=========================================" - if [[ $failed_checks -eq 0 ]]; then - echo "✅ 健康检查完成 - 系统状态正常" - exit 0 - else - echo "❌ 健康检查完成 - 发现 $failed_checks 个问题" - exit 1 - fi -} - -# 脚本入口 -if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then - main "$@" -fi \ No newline at end of file diff --git a/scripts/rollback.sh b/scripts/rollback.sh deleted file mode 100755 index c4b82d8..0000000 --- a/scripts/rollback.sh +++ /dev/null @@ -1,627 +0,0 @@ -#!/bin/bash - -# 自动化平台回滚脚本 -# 用途: 回滚应用到之前的版本 (需要 docker-compose.yml) -# 注意: 对于快速部署,推荐使用 deployment/scripts/setup.sh -# 使用: ./rollback.sh [version] - -set -euo pipefail - -# 脚本配置 -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -APP_NAME="automation-platform" -LOG_FILE="/var/log/${APP_NAME}/rollback.log" - -# 颜色输出 -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -# 日志函数 -log() { - echo -e "${BLUE}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1" | tee -a "$LOG_FILE" -} - -log_success() { - echo -e "${GREEN}[$(date +'%Y-%m-%d %H:%M:%S')] ✅ $1${NC}" | tee -a "$LOG_FILE" -} - -log_error() { - echo -e "${RED}[$(date +'%Y-%m-%d %H:%M:%S')] ❌ $1${NC}" | tee -a "$LOG_FILE" -} - -log_warning() { - echo -e "${YELLOW}[$(date +'%Y-%m-%d %H:%M:%S')] ⚠️ $1${NC}" | tee -a "$LOG_FILE" -} - -# 错误处理 -error_exit() { - log_error "$1" - exit 1 -} - -# 显示帮助信息 -show_help() { - cat << EOF -自动化平台回滚脚本 - -用法: - $0 [version] - -参数: - environment 部署环境 (dev|staging|production) - version 回滚到的版本 (可选,默认为上一个版本) - -选项: - --list 列出可用的回滚版本 - --force 强制回滚,跳过确认 - --no-backup 跳过当前版本备份 - --dry-run 模拟回滚,不实际执行 - -h, --help 显示帮助信息 - -示例: - $0 production # 回滚到上一版本 - $0 staging 20240115_143022 # 回滚到指定版本 - $0 dev --list # 列出可用版本 - $0 production --force # 强制回滚 - -回滚策略: - 1. 自动备份当前版本 - 2. 停止当前服务 - 3. 恢复指定版本的配置和数据 - 4. 启动服务 - 5. 验证服务状态 - 6. 可选的数据库回滚 - -EOF -} - -# 参数解析 -parse_arguments() { - ENVIRONMENT="" - TARGET_VERSION="" - LIST_VERSIONS=false - FORCE_ROLLBACK=false - NO_BACKUP=false - DRY_RUN=false - - while [[ $# -gt 0 ]]; do - case $1 in - --list) - LIST_VERSIONS=true - shift - ;; - --force) - FORCE_ROLLBACK=true - shift - ;; - --no-backup) - NO_BACKUP=true - shift - ;; - --dry-run) - DRY_RUN=true - shift - ;; - -h|--help) - show_help - exit 0 - ;; - -*) - error_exit "未知选项: $1" - ;; - *) - if [[ -z "$ENVIRONMENT" ]]; then - ENVIRONMENT="$1" - elif [[ -z "$TARGET_VERSION" ]]; then - TARGET_VERSION="$1" - else - error_exit "多余的参数: $1" - fi - shift - ;; - esac - done - - # 验证必需参数 - if [[ -z "$ENVIRONMENT" ]]; then - show_help - error_exit "缺少环境参数" - fi - - # 验证环境 - if [[ ! "$ENVIRONMENT" =~ ^(dev|staging|production)$ ]]; then - error_exit "无效的环境: $ENVIRONMENT" - fi -} - -# 列出可用的回滚版本 -list_available_versions() { - log "列出可用的回滚版本..." - - local backup_dir="/opt/$APP_NAME/backups" - - if [[ ! -d "$backup_dir" ]]; then - log_error "备份目录不存在: $backup_dir" - return 1 - fi - - echo "可用的回滚版本:" - echo "========================================" - - local versions - versions=$(find "$backup_dir" -maxdepth 1 -type d -name "20*" | sort -r) - - if [[ -z "$versions" ]]; then - echo "没有可用的回滚版本" - return 1 - fi - - local current_version="" - if [[ -f "$backup_dir/latest_backup.txt" ]]; then - current_version=$(cat "$backup_dir/latest_backup.txt" | xargs basename) - fi - - local count=1 - for version_path in $versions; do - local version - version=$(basename "$version_path") - local size - size=$(du -sh "$version_path" 2>/dev/null | cut -f1) - local date_info - date_info=$(date -d "${version:0:8} ${version:9:2}:${version:11:2}:${version:13:2}" "+%Y-%m-%d %H:%M:%S" 2>/dev/null || echo "Unknown") - - local marker="" - if [[ "$version" == "$current_version" ]]; then - marker=" (当前备份)" - fi - - printf "%2d. %s - %s - %s%s\n" "$count" "$version" "$date_info" "$size" "$marker" - - # 显示备份内容概要 - if [[ -f "$version_path/deployment_info.txt" ]]; then - local info - info=$(grep "镜像:" "$version_path/deployment_info.txt" 2>/dev/null | head -1) - if [[ -n "$info" ]]; then - echo " $info" - fi - fi - - count=$((count + 1)) - done - - echo "========================================" - return 0 -} - -# 选择回滚版本 -select_rollback_version() { - local backup_dir="/opt/$APP_NAME/backups" - - if [[ -n "$TARGET_VERSION" ]]; then - # 验证指定版本是否存在 - if [[ ! -d "$backup_dir/$TARGET_VERSION" ]]; then - error_exit "指定的版本不存在: $TARGET_VERSION" - fi - ROLLBACK_VERSION="$TARGET_VERSION" - else - # 自动选择上一个版本 - local versions - versions=$(find "$backup_dir" -maxdepth 1 -type d -name "20*" | sort -r | head -2) - - local version_count - version_count=$(echo "$versions" | wc -l) - - if [[ $version_count -lt 2 ]]; then - error_exit "没有足够的版本可供回滚" - fi - - # 选择第二新的版本(跳过最新的,因为那可能是当前版本) - ROLLBACK_VERSION=$(echo "$versions" | tail -1 | xargs basename) - fi - - log "选择的回滚版本: $ROLLBACK_VERSION" - return 0 -} - -# 确认回滚操作 -confirm_rollback() { - if [[ "$FORCE_ROLLBACK" == "true" ]]; then - log "强制回滚模式,跳过确认" - return 0 - fi - - echo "" - echo "========================================" - echo "⚠️ 回滚确认" - echo "========================================" - echo "环境: $ENVIRONMENT" - echo "回滚版本: $ROLLBACK_VERSION" - echo "当前时间: $(date)" - echo "" - echo "此操作将:" - echo "1. 停止当前运行的服务" - echo "2. 恢复到指定版本的配置" - echo "3. 重新启动服务" - echo "4. 可能需要数据库回滚" - echo "" - echo "注意: 这可能会导致数据丢失!" - echo "========================================" - - read -p "确认执行回滚操作? (yes/no): " -r - if [[ ! $REPLY =~ ^[Yy][Ee][Ss]$ ]]; then - log "用户取消回滚操作" - exit 0 - fi - - log "用户确认执行回滚操作" -} - -# 备份当前版本 -backup_current_version() { - if [[ "$NO_BACKUP" == "true" ]]; then - log "跳过当前版本备份" - return 0 - fi - - log "备份当前版本..." - - local backup_dir="/opt/$APP_NAME/backups/rollback_$(date +%Y%m%d_%H%M%S)" - mkdir -p "$backup_dir" - - cd /opt/"$APP_NAME" - - # 备份配置文件 - if [[ -f ".env" ]]; then - cp ".env" "$backup_dir/" - log "已备份环境配置" - fi - - if [[ -f "docker-compose.yml" ]]; then - cp "docker-compose.yml" "$backup_dir/" - log "已备份 Docker Compose 配置" - fi - - # 备份当前运行的镜像信息 - if docker ps --format "table {{.Image}}" | grep -q "$APP_NAME"; then - docker ps --format "table {{.Image}}\t{{.Status}}" | grep "$APP_NAME" > "$backup_dir/current_images.txt" - log "已记录当前镜像版本" - fi - - # 记录回滚信息 - cat > "$backup_dir/rollback_info.txt" << EOF -回滚时间: $(date) -回滚环境: $ENVIRONMENT -回滚目标版本: $ROLLBACK_VERSION -回滚执行用户: ${USER} -回滚原因: 手动回滚操作 -EOF - - log_success "当前版本备份完成: $backup_dir" -} - -# 停止当前服务 -stop_current_services() { - log "停止当前服务..." - - cd /opt/"$APP_NAME" - - if [[ "$DRY_RUN" == "true" ]]; then - log "模拟模式: 将停止 Docker Compose 服务" - return 0 - fi - - # 停止 Docker Compose 服务 - if [[ -f "docker-compose.yml" ]]; then - docker-compose down || log_warning "停止服务时出现警告" - log_success "Docker Compose 服务已停止" - else - log_warning "docker-compose.yml 不存在,跳过服务停止" - fi - - # 等待服务完全停止 - sleep 10 - - # 验证服务已停止 - local running_containers - running_containers=$(docker ps | grep "$APP_NAME" | wc -l) - - if [[ $running_containers -gt 0 ]]; then - log_warning "仍有 $running_containers 个相关容器在运行" - docker ps | grep "$APP_NAME" || true - else - log_success "所有相关服务已停止" - fi -} - -# 恢复指定版本 -restore_version() { - log "恢复版本: $ROLLBACK_VERSION" - - local backup_path="/opt/$APP_NAME/backups/$ROLLBACK_VERSION" - - if [[ ! -d "$backup_path" ]]; then - error_exit "备份版本不存在: $backup_path" - fi - - cd /opt/"$APP_NAME" - - if [[ "$DRY_RUN" == "true" ]]; then - log "模拟模式: 将恢复以下文件:" - find "$backup_path" -type f | head -10 - return 0 - fi - - # 恢复环境配置 - if [[ -f "$backup_path/.env" ]]; then - cp "$backup_path/.env" ".env" - log_success "已恢复环境配置" - else - log_warning "备份中没有找到环境配置文件" - fi - - # 恢复 Docker Compose 配置 - if [[ -f "$backup_path/docker-compose.yml" ]]; then - cp "$backup_path/docker-compose.yml" "docker-compose.yml" - log_success "已恢复 Docker Compose 配置" - else - log_warning "备份中没有找到 Docker Compose 配置" - fi - - # 恢复数据(如果存在) - if [[ -d "$backup_path/data" ]]; then - log "恢复应用数据..." - cp -r "$backup_path/data/"* "data/" 2>/dev/null || log_warning "数据恢复失败" - log_success "应用数据已恢复" - fi - - # 恢复数据库(如果是本地数据库) - if [[ -d "$backup_path/db" ]]; then - log "恢复数据库..." - rm -rf "data/db" 2>/dev/null || true - cp -r "$backup_path/db" "data/db" - log_success "数据库已恢复" - fi - - log_success "版本恢复完成" -} - -# 拉取回滚版本的镜像 -pull_rollback_image() { - log "拉取回滚版本的镜像..." - - local backup_path="/opt/$APP_NAME/backups/$ROLLBACK_VERSION" - - # 从备份信息中获取镜像标签 - local image_tag="" - if [[ -f "$backup_path/deployment_info.txt" ]]; then - image_tag=$(grep "镜像:" "$backup_path/deployment_info.txt" | cut -d' ' -f2) - elif [[ -f "$backup_path/current_images.txt" ]]; then - image_tag=$(head -1 "$backup_path/current_images.txt" | awk '{print $1}') - fi - - if [[ -z "$image_tag" ]]; then - log_warning "无法确定回滚镜像标签,将使用配置文件中的镜像" - return 0 - fi - - log "回滚镜像标签: $image_tag" - - if [[ "$DRY_RUN" == "true" ]]; then - log "模拟模式: 将拉取镜像 $image_tag" - return 0 - fi - - # 拉取镜像 - docker pull "$image_tag" || log_warning "镜像拉取失败,可能使用本地缓存" - - # 更新 docker-compose.yml 中的镜像标签 - if [[ -f "docker-compose.yml" ]] && [[ -n "$image_tag" ]]; then - sed -i.bak "s|image:.*$APP_NAME:.*|image: $image_tag|g" docker-compose.yml - log_success "已更新 Docker Compose 镜像标签" - fi -} - -# 启动回滚版本的服务 -start_rollback_services() { - log "启动回滚版本的服务..." - - cd /opt/"$APP_NAME" - - if [[ "$DRY_RUN" == "true" ]]; then - log "模拟模式: 将启动 Docker Compose 服务" - return 0 - fi - - # 验证配置文件 - if [[ ! -f "docker-compose.yml" ]]; then - error_exit "docker-compose.yml 文件不存在。请使用 deployment/scripts/setup.sh 进行快速部署,或创建 docker-compose.yml 文件。" - fi - - # 验证配置 - docker-compose config >/dev/null || error_exit "Docker Compose 配置验证失败" - - # 启动服务 - docker-compose up -d || error_exit "服务启动失败" - - log_success "回滚版本服务已启动" - - # 等待服务启动 - log "等待服务启动..." - sleep 30 -} - -# 验证回滚结果 -verify_rollback() { - log "验证回滚结果..." - - cd /opt/"$APP_NAME" - - if [[ "$DRY_RUN" == "true" ]]; then - log "模拟模式: 将验证服务状态" - return 0 - fi - - # 检查容器状态 - local unhealthy_containers - unhealthy_containers=$(docker-compose ps | grep -v "Up" | grep -v "Name" | wc -l) - - if [[ $unhealthy_containers -gt 0 ]]; then - log_error "发现 $unhealthy_containers 个不健康的容器" - docker-compose ps - return 1 - fi - - log_success "所有容器状态正常" - - # 执行健康检查 - if [[ -f "$SCRIPT_DIR/health-check.sh" ]]; then - log "执行健康检查..." - if "$SCRIPT_DIR/health-check.sh" "$ENVIRONMENT" --timeout 120; then - log_success "健康检查通过" - else - log_error "健康检查失败" - return 1 - fi - else - log_warning "健康检查脚本不存在,跳过详细验证" - - # 简单的端点检查 - local max_attempts=12 - local attempt=1 - - while [[ $attempt -le $max_attempts ]]; do - if curl -f -s "http://localhost:3000/api/health" >/dev/null 2>&1; then - log_success "基本健康检查通过" - break - fi - - log "健康检查尝试 $attempt/$max_attempts..." - sleep 10 - attempt=$((attempt + 1)) - done - - if [[ $attempt -gt $max_attempts ]]; then - log_error "基本健康检查失败" - return 1 - fi - fi - - log_success "回滚验证完成" - return 0 -} - -# 记录回滚操作 -record_rollback() { - log "记录回滚操作..." - - local rollback_log="/opt/$APP_NAME/rollback_history.log" - - cat >> "$rollback_log" << EOF -======================================== -回滚时间: $(date) -环境: $ENVIRONMENT -回滚版本: $ROLLBACK_VERSION -执行用户: ${USER} -执行结果: 成功 -备份位置: $(cat /opt/"$APP_NAME"/backups/latest_backup.txt 2>/dev/null || echo "无") -======================================== - -EOF - - log_success "回滚操作已记录" -} - -# 清理资源 -cleanup_rollback() { - log "清理回滚资源..." - - # 清理临时文件 - rm -f /tmp/rollback_* 2>/dev/null || true - - # 清理旧的 Docker 镜像 - docker image prune -f >/dev/null 2>&1 || true - - # 清理过多的备份 - local max_backups=10 - local backup_count - backup_count=$(find /opt/"$APP_NAME"/backups -maxdepth 1 -type d -name "20*" | wc -l) - - if [[ $backup_count -gt $max_backups ]]; then - find /opt/"$APP_NAME"/backups -maxdepth 1 -type d -name "20*" | sort | head -n $((backup_count - max_backups)) | xargs rm -rf - log "已清理旧备份,保留最新 $max_backups 个" - fi - - log_success "资源清理完成" -} - -# 主函数 -main() { - echo "=========================================" - echo "🔄 自动化平台回滚脚本" - echo "=========================================" - - # 解析参数 - parse_arguments "$@" - - # 创建日志目录 - mkdir -p "$(dirname "$LOG_FILE")" - touch "$LOG_FILE" - - # 如果是列出版本模式 - if [[ "$LIST_VERSIONS" == "true" ]]; then - list_available_versions - exit 0 - fi - - log "开始回滚操作..." - log "环境: $ENVIRONMENT" - log "目标版本: ${TARGET_VERSION:-自动选择}" - - # 选择回滚版本 - select_rollback_version - - # 确认回滚操作 - confirm_rollback - - # 备份当前版本 - backup_current_version - - # 停止当前服务 - stop_current_services - - # 恢复指定版本 - restore_version - - # 拉取回滚镜像 - pull_rollback_image - - # 启动回滚服务 - start_rollback_services - - # 验证回滚结果 - if verify_rollback; then - # 记录回滚操作 - record_rollback - - # 清理资源 - cleanup_rollback - - echo "=========================================" - log_success "🎉 回滚操作成功完成!" - echo "=========================================" - echo "环境: $ENVIRONMENT" - echo "回滚版本: $ROLLBACK_VERSION" - echo "完成时间: $(date)" - echo "=========================================" - else - error_exit "回滚验证失败,请检查服务状态" - fi -} - -# 脚本入口 -if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then - main "$@" -fi \ No newline at end of file diff --git a/scripts/setup-server.sh b/scripts/setup-server.sh new file mode 100644 index 0000000..baa1a26 --- /dev/null +++ b/scripts/setup-server.sh @@ -0,0 +1,145 @@ +#!/bin/bash +# ============================================================ +# 服务器首次初始化脚本 - 自动化测试平台 +# 用途:在全新服务器上首次部署时运行 +# 用法:bash scripts/setup-server.sh +# ============================================================ + +set -e + +# ─── 颜色输出 ─────────────────────────────────────────────── +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } +log_step() { echo -e "${BLUE}[STEP]${NC} $1"; } + +# ─── 配置 ────────────────────────────────────────────────── +APP_DIR="/www/wwwroot/autotest.wiac.xyz" +APP_NAME="autotest-platform" +LOG_DIR="${APP_DIR}/logs" +ENV_FILE="${APP_DIR}/.env" + +log_step "=== 自动化测试平台 首次初始化 ===" +echo "" + +# ─── 检查 Node.js ────────────────────────────────────────── +log_step "步骤 1/6:检查运行环境" + +if ! command -v node &> /dev/null; then + log_error "Node.js 未安装!请先安装 Node.js 20+:" + echo " curl -fsSL https://rpm.nodesource.com/setup_20.x | sudo bash -" + echo " sudo yum install -y nodejs" + exit 1 +fi + +NODE_VERSION=$(node --version) +log_info "Node.js 版本:${NODE_VERSION}" + +if ! command -v npm &> /dev/null; then + log_error "npm 未安装" + exit 1 +fi + +NPM_VERSION=$(npm --version) +log_info "npm 版本:${NPM_VERSION}" + +# ─── 安装 PM2 ────────────────────────────────────────────── +log_step "步骤 2/6:安装/更新 PM2" + +if command -v pm2 &> /dev/null; then + PM2_VERSION=$(pm2 --version) + log_info "PM2 已安装,版本:${PM2_VERSION}" +else + log_info "正在安装 PM2..." + npm install -g pm2 + log_info "PM2 安装完成" +fi + +# 安装 PM2 日志轮转插件 +log_info "安装 PM2 日志轮转插件..." +pm2 install pm2-logrotate 2>/dev/null || log_warn "pm2-logrotate 安装失败(非致命)" + +# 配置开机自启 +log_step "步骤 3/6:配置开机自启" +pm2 startup | tail -1 | bash 2>/dev/null || { + log_warn "自动配置开机自启失败,请手动运行以下命令:" + pm2 startup +} +log_info "已配置 PM2 开机自启" + +# ─── 切换到项目目录 ───────────────────────────────────────── +log_step "步骤 4/6:检查项目目录" + +if [ ! -d "${APP_DIR}" ]; then + log_error "项目目录不存在:${APP_DIR}" + log_error "请先通过宝塔 Git 功能拉取代码到该目录" + exit 1 +fi + +cd "${APP_DIR}" +log_info "项目目录:${APP_DIR}" + +# ─── 配置环境变量 ────────────────────────────────────────── +log_step "步骤 5/6:配置环境变量" + +if [ -f "${ENV_FILE}" ]; then + log_info ".env 文件已存在,跳过创建" +else + if [ -f "${APP_DIR}/deployment/.env.production" ]; then + cp "${APP_DIR}/deployment/.env.production" "${ENV_FILE}" + log_warn "已从模板创建 .env 文件:${ENV_FILE}" + log_warn "请务必检查并修改以下配置项:" + echo " - DB_HOST / DB_USER / DB_PASSWORD" + echo " - JWT_SECRET(请改为随机字符串)" + echo " - JENKINS_URL / JENKINS_TOKEN" + echo " - API_CALLBACK_URL(改为服务器公网 IP)" + echo " - CORS_ORIGIN(改为前端访问地址)" + echo "" + read -p "确认已了解需要修改 .env 后按 Enter 继续,或 Ctrl+C 退出先修改..." + else + log_error "找不到 .env 模板文件" + log_error "请手动创建 ${ENV_FILE}" + exit 1 + fi +fi + +# ─── 创建日志目录 ────────────────────────────────────────── +mkdir -p "${LOG_DIR}" +log_info "日志目录:${LOG_DIR}" + +# ─── 首次构建并启动 ───────────────────────────────────────── +log_step "步骤 6/6:首次构建并启动应用" + +log_info "安装项目依赖..." +npm install --production=false + +log_info "构建前端(先构建,避免被 vite 清空 dist/)..." +npm run build + +log_info "编译后端 TypeScript..." +npm run server:build + +log_info "启动应用..." +pm2 start ecosystem.config.js --env production + +# 保存 PM2 进程列表(开机自启所需) +pm2 save + +echo "" +log_info "=== 初始化完成!===" +echo "" +pm2 status "${APP_NAME}" +echo "" +log_info "应用地址:http://$(hostname -I | awk '{print $1}'):3000" +echo "" +log_info "常用命令:" +echo " pm2 status # 查看进程状态" +echo " pm2 logs ${APP_NAME} # 查看实时日志" +echo " pm2 restart ${APP_NAME} # 重启应用" +echo " bash scripts/deploy.sh # 代码更新后热部署" diff --git a/server/config/dataSource.ts b/server/config/dataSource.ts index 1e4ffd4..59691f2 100644 --- a/server/config/dataSource.ts +++ b/server/config/dataSource.ts @@ -9,10 +9,17 @@ import * as path from 'path'; */ function getEntityPaths(): string[] { const isJsRuntime = path.extname(__filename) === '.js'; - const entityPath = isJsRuntime - ? path.resolve(process.cwd(), 'dist', 'server', 'entities', '*.js') - : path.resolve(process.cwd(), 'server', 'entities', '*.ts'); - return [entityPath]; + if (isJsRuntime) { + // 生产环境:TypeScript 编译时 outDir=dist/server,源码在 server/ 目录 + // 所以实体文件实际路径是 dist/server/server/entities/*.js + // 同时兼容旧路径 dist/server/entities/*.js(以防万一) + return [ + path.resolve(process.cwd(), 'dist', 'server', 'server', 'entities', '*.js'), + path.resolve(process.cwd(), 'dist', 'server', 'entities', '*.js'), + ]; + } + // 开发环境:直接读取 TypeScript 源文件 + return [path.resolve(process.cwd(), 'server', 'entities', '*.ts')]; } /** diff --git a/server/index.ts b/server/index.ts index 8a4b365..88df612 100644 --- a/server/index.ts +++ b/server/index.ts @@ -28,6 +28,10 @@ const MAX_PORT_ATTEMPTS = 10; // 初始化日志系统 initializeLogging(); +// 信任反向代理(Nginx),使 express-rate-limit 能正确识别客户端真实 IP +// 1 表示信任第一层代理(即 Nginx) +app.set('trust proxy', 1); + // 中间件 app.use(cors()); app.use(express.json({ limit: '10mb' })); @@ -145,7 +149,8 @@ app.get('/api/health', (req, res) => { }); // 静态文件服务 - 提供前端构建文件 -const distPath = path.join(__dirname, '../'); +// 编译后路径为 dist/server/server/index.js,需上溯3层到达 dist/ +const distPath = path.join(__dirname, '../../'); logger.info('Setting up static file serving', { distPath }, LOG_CONTEXTS.HTTP); app.use(express.static(distPath)); diff --git a/server/middleware/JenkinsAuthMiddleware.ts b/server/middleware/JenkinsAuthMiddleware.ts index 24c0947..82383ff 100644 --- a/server/middleware/JenkinsAuthMiddleware.ts +++ b/server/middleware/JenkinsAuthMiddleware.ts @@ -62,7 +62,8 @@ export class IPWhitelistMiddleware { const clientIP = ipStr.split(',')[0].trim(); if (process.env.NODE_ENV === 'development' && process.env.JENKINS_DEBUG_IP === 'true') { - console.debug(`[IP-DETECTION] Detected IP: ${clientIP}`, { + // 使用固定格式字符串作为第一参数,防止 clientIP 中包含 %s/%d 等格式说明符被解析(format string injection) + console.debug('[IP-DETECTION] Detected IP: %s', clientIP, { sources: { forwarded: Array.isArray(forwarded) ? forwarded[0] : forwarded, xRealIp, @@ -162,10 +163,8 @@ export class IPWhitelistMiddleware { }); if (!isAllowed) { - console.warn( - `Jenkins callback: IP ${clientIP} not in allowed list:`, - this.allowedIPs - ); + // 使用固定格式字符串,将 clientIP 作为数据参数传入,防止格式字符串注入 + console.warn('[Jenkins callback] IP %s not in allowed list:', clientIP, this.allowedIPs); } return isAllowed; @@ -190,7 +189,8 @@ export class IPWhitelistMiddleware { } const clientIP = this.getClientIP(req); - console.log(`[Jenkins IP Whitelist] ✅ Access allowed from IP: ${clientIP}`, { + // 使用固定格式字符串,将 clientIP 作为数据参数传入,防止格式字符串注入 + console.log('[Jenkins IP Whitelist] ✅ Access allowed from IP: %s', clientIP, { endpoint: `${req.method} ${req.path}`, timestamp: new Date().toISOString(), }); diff --git a/server/middleware/RequestValidator.ts b/server/middleware/RequestValidator.ts index e5f69ad..9088fb3 100644 --- a/server/middleware/RequestValidator.ts +++ b/server/middleware/RequestValidator.ts @@ -10,7 +10,7 @@ interface ValidationRule { max?: number; pattern?: RegExp; arrayItemType?: 'string' | 'number'; - allowedValues?: any[]; + allowedValues?: unknown[]; } interface ValidationSchema { @@ -234,24 +234,27 @@ export class RequestValidator { // 校验请求体 if (schema.body) { + const body = (req.body ?? {}) as Record; for (const rule of schema.body) { - const error = this.validateField(req.body, rule, 'body'); + const error = this.validateField(body, rule, 'body'); if (error) errors.push(error); } } // 校验路径参数 if (schema.params) { + const params: Record = req.params; for (const rule of schema.params) { - const error = this.validateField(req.params, rule, 'params'); + const error = this.validateField(params, rule, 'params'); if (error) errors.push(error); } } // 校验查询参数 if (schema.query) { + const query: Record = req.query as Record; for (const rule of schema.query) { - const error = this.validateField(req.query, rule, 'query'); + const error = this.validateField(query, rule, 'query'); if (error) errors.push(error); } } @@ -265,8 +268,8 @@ export class RequestValidator { /** * 校验单个字段 */ - private validateField(data: any, rule: ValidationRule, source: string): string | null { - const value = data[rule.field]; + private validateField(data: Record, rule: ValidationRule, source: string): string | null { + const value: unknown = data[rule.field]; const fieldPath = `${source}.${rule.field}`; // 必填字段检查 @@ -283,8 +286,8 @@ export class RequestValidator { const typeError = this.validateType(value, rule.type, fieldPath); if (typeError) return typeError; - // 字符串长度检查 - if (rule.type === 'string') { + // 字符串长度检查(通过 validateType 已确认为 string) + if (rule.type === 'string' && typeof value === 'string') { if (rule.minLength && value.length < rule.minLength) { return `${fieldPath} must be at least ${rule.minLength} characters long`; } @@ -296,8 +299,8 @@ export class RequestValidator { } } - // 数字范围检查 - if (rule.type === 'number') { + // 数字范围检查(通过 validateType 已确认为 number) + if (rule.type === 'number' && typeof value === 'number') { if (rule.min !== undefined && value < rule.min) { return `${fieldPath} must be at least ${rule.min}`; } @@ -306,8 +309,11 @@ export class RequestValidator { } } - // 数组类型检查 + // 数组类型检查:显式用 Array.isArray 确认,防止字符串被当作类数组对象遍历(type confusion) if (rule.type === 'array') { + if (!Array.isArray(value)) { + return `${fieldPath} must be an array`; + } if (rule.arrayItemType) { for (let i = 0; i < value.length; i++) { const itemTypeError = this.validateType(value[i], rule.arrayItemType, `${fieldPath}[${i}]`); @@ -327,7 +333,7 @@ export class RequestValidator { /** * 类型校验 */ - private validateType(value: any, expectedType: string, fieldPath: string): string | null { + private validateType(value: unknown, expectedType: string, fieldPath: string): string | null { switch (expectedType) { case 'string': if (typeof value !== 'string') { @@ -361,68 +367,71 @@ export class RequestValidator { /** * 校验测试结果数组 */ - private validateResults(results: any[]): { isValid: boolean; errors: string[] } { + private validateResults(results: unknown[]): { isValid: boolean; errors: string[] } { const errors: string[] = []; for (let i = 0; i < results.length; i++) { - const result = results[i]; + const result: unknown = results[i]; const prefix = `results[${i}]`; - if (typeof result !== 'object' || result === null) { + if (typeof result !== 'object' || result === null || Array.isArray(result)) { errors.push(`${prefix} must be an object`); continue; } + // 收窄类型为可索引对象 + const r = result as Record; + // 校验必填字段 - if (typeof result.caseId !== 'number' || result.caseId <= 0) { + if (typeof r['caseId'] !== 'number' || (r['caseId'] as number) <= 0) { errors.push(`${prefix}.caseId must be a positive number`); } - if (typeof result.caseName !== 'string' || result.caseName.trim().length === 0) { + if (typeof r['caseName'] !== 'string' || (r['caseName'] as string).trim().length === 0) { errors.push(`${prefix}.caseName must be a non-empty string`); } - if (!['passed', 'failed', 'skipped', 'error'].includes(result.status)) { + if (!['passed', 'failed', 'skipped', 'error'].includes(r['status'] as string)) { errors.push(`${prefix}.status must be one of: passed, failed, skipped, error`); } - if (typeof result.duration !== 'number' || result.duration < 0) { + if (typeof r['duration'] !== 'number' || (r['duration'] as number) < 0) { errors.push(`${prefix}.duration must be a non-negative number`); } // 可选字段校验 - if (result.errorMessage !== undefined && typeof result.errorMessage !== 'string') { + if (r['errorMessage'] !== undefined && typeof r['errorMessage'] !== 'string') { errors.push(`${prefix}.errorMessage must be a string`); } // 新增诊断字段校验 (可选) - if (result.stackTrace !== undefined && typeof result.stackTrace !== 'string') { + if (r['stackTrace'] !== undefined && typeof r['stackTrace'] !== 'string') { errors.push(`${prefix}.stackTrace must be a string`); } - if (result.screenshotPath !== undefined && typeof result.screenshotPath !== 'string') { + if (r['screenshotPath'] !== undefined && typeof r['screenshotPath'] !== 'string') { errors.push(`${prefix}.screenshotPath must be a string`); } - if (result.logPath !== undefined && typeof result.logPath !== 'string') { + if (r['logPath'] !== undefined && typeof r['logPath'] !== 'string') { errors.push(`${prefix}.logPath must be a string`); } - if (result.assertionsTotal !== undefined && (typeof result.assertionsTotal !== 'number' || result.assertionsTotal < 0)) { + if (r['assertionsTotal'] !== undefined && (typeof r['assertionsTotal'] !== 'number' || (r['assertionsTotal'] as number) < 0)) { errors.push(`${prefix}.assertionsTotal must be a non-negative number`); } - if (result.assertionsPassed !== undefined && (typeof result.assertionsPassed !== 'number' || result.assertionsPassed < 0)) { + if (r['assertionsPassed'] !== undefined && (typeof r['assertionsPassed'] !== 'number' || (r['assertionsPassed'] as number) < 0)) { errors.push(`${prefix}.assertionsPassed must be a non-negative number`); } - if (result.responseData !== undefined && typeof result.responseData !== 'string') { + if (r['responseData'] !== undefined && typeof r['responseData'] !== 'string') { errors.push(`${prefix}.responseData must be a string`); } // 逻辑校验:通过的断言数不能超过总断言数 - if (result.assertionsTotal !== undefined && result.assertionsPassed !== undefined) { - if (result.assertionsPassed > result.assertionsTotal) { + if (typeof r['assertionsTotal'] === 'number' && typeof r['assertionsPassed'] === 'number') { + if ((r['assertionsPassed'] as number) > (r['assertionsTotal'] as number)) { errors.push(`${prefix}.assertionsPassed cannot exceed assertionsTotal`); } } diff --git a/server/middleware/authRateLimiter.ts b/server/middleware/authRateLimiter.ts new file mode 100644 index 0000000..695aebb --- /dev/null +++ b/server/middleware/authRateLimiter.ts @@ -0,0 +1,151 @@ +import rateLimit from 'express-rate-limit'; +import { Request, Response } from 'express'; +import { logger, LOG_CONTEXTS } from '../config/logging'; + +/** + * Authentication Rate Limiter Middleware + * + * Provides rate limiting protection for authentication endpoints to prevent: + * - Brute force attacks + * - Account enumeration + * - Email bombing + * - Token flooding + * - Resource exhaustion + * + * Uses express-rate-limit with IP-based tracking for unauthenticated routes + * and IP+User tracking for authenticated routes. + */ + +// Base configuration shared by all auth rate limiters +const baseConfig = { + standardHeaders: true, // Return rate limit info in RateLimit-* headers (RFC draft) + legacyHeaders: false, // Disable X-RateLimit-* headers + handler: (req: Request, res: Response) => { + // Log security violation for monitoring and analysis + logger.warn('Rate limit exceeded', { + context: LOG_CONTEXTS.SECURITY, + ip: req.ip, + path: req.path, + method: req.method, + userAgent: req.headers['user-agent'], + }); + + // Return consistent 429 response with retry information + res.status(429).json({ + success: false, + error: 'Too many requests', + message: 'Please try again later', + retryAfter: res.getHeader('RateLimit-Reset'), + }); + }, +}; + +/** + * Login Rate Limiter + * + * Limit: 5 requests per 15 minutes per IP + * + * Rationale: + * - Matches existing account lockout (5 failed attempts) + * - Prevents distributed brute force attacks across multiple IPs + * - Complements user-level protection with network-level defense + */ +export const loginRateLimiter = rateLimit({ + ...baseConfig, + windowMs: 15 * 60 * 1000, // 15 minutes + max: 5, + message: 'Too many login attempts, please try again after 15 minutes', + skipSuccessfulRequests: false, // Count all requests (success or fail) +}); + +/** + * Registration Rate Limiter + * + * Limit: 3 requests per hour per IP + * + * Rationale: + * - Prevents spam account creation + * - Protects against account enumeration + * - Legitimate users rarely need multiple registration attempts + */ +export const registerRateLimiter = rateLimit({ + ...baseConfig, + windowMs: 60 * 60 * 1000, // 1 hour + max: 3, + message: 'Too many registration attempts, please try again later', +}); + +/** + * Forgot Password Rate Limiter + * + * Limit: 3 requests per hour per IP + * + * Rationale: + * - Prevents email bombing attacks + * - Protects mail server resources + * - Prevents user enumeration through password reset + */ +export const forgotPasswordRateLimiter = rateLimit({ + ...baseConfig, + windowMs: 60 * 60 * 1000, // 1 hour + max: 3, + message: 'Too many password reset requests, please try again later', +}); + +/** + * Reset Password Rate Limiter + * + * Limit: 5 requests per 15 minutes per IP + * + * Rationale: + * - Allows legitimate retry attempts + * - Prevents token guessing attacks + * - Balances security with user experience + */ +export const resetPasswordRateLimiter = rateLimit({ + ...baseConfig, + windowMs: 15 * 60 * 1000, // 15 minutes + max: 5, + message: 'Too many password reset attempts, please try again later', +}); + +/** + * Token Refresh Rate Limiter + * + * Limit: 10 requests per 15 minutes per IP+User + * + * Rationale: + * - Higher limit for legitimate token rotation + * - Prevents token refresh flooding + * - Supports active user sessions + * - Uses IP+User key to prevent abuse from multiple IPs + */ +export const refreshRateLimiter = rateLimit({ + ...baseConfig, + windowMs: 15 * 60 * 1000, // 15 minutes + max: 10, + message: 'Too many token refresh requests, please try again later', + // Uses default IP-based key generator (IPv6-safe) +}); + +/** + * General Auth Rate Limiter + * + * Limit: 100 requests per 15 minutes per IP+User + * + * Used for less sensitive authenticated endpoints: + * - GET /me (user info retrieval) + * - POST /logout (logout operation) + * + * Rationale: + * - Read-only or low-risk operations + * - Higher limit allows dashboard polling + * - Prevents resource exhaustion + */ +export const generalAuthRateLimiter = rateLimit({ + ...baseConfig, + windowMs: 15 * 60 * 1000, // 15 minutes + max: 100, + message: 'Too many requests, please try again later', + // Uses default IP-based key generator (IPv6-safe) +}); diff --git a/server/repositories/DashboardRepository.ts b/server/repositories/DashboardRepository.ts index c40e1bd..387fe88 100644 --- a/server/repositories/DashboardRepository.ts +++ b/server/repositories/DashboardRepository.ts @@ -245,13 +245,15 @@ export class DashboardRepository extends BaseRepository { } // 优化:使用 UNION ALL 分别查询,避免大表 JOIN + // 注意:使用 Auto_TestRun 表统计,因为 Jenkins 执行记录写入该表 + // 使用 created_at 而非 start_time,确保触发即统计(start_time 可能为 NULL) const result = await this.testCaseRepository.query(` SELECT (SELECT COUNT(*) FROM Auto_TestCase WHERE enabled = 1) as totalCases, - (SELECT COUNT(*) FROM Auto_TestCaseTaskExecutions WHERE DATE(start_time) = CURDATE()) as todayRuns, - (SELECT COALESCE(SUM(passed_cases), 0) FROM Auto_TestCaseTaskExecutions WHERE DATE(start_time) = CURDATE()) as passedCases, - (SELECT COALESCE(SUM(passed_cases + failed_cases + skipped_cases), 0) FROM Auto_TestCaseTaskExecutions WHERE DATE(start_time) = CURDATE()) as totalCasesRun, - (SELECT SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END) FROM Auto_TestCaseTaskExecutions) as runningTasks + (SELECT COUNT(*) FROM Auto_TestRun WHERE DATE(created_at) = CURDATE()) as todayRuns, + (SELECT COALESCE(SUM(passed_cases), 0) FROM Auto_TestRun WHERE DATE(created_at) = CURDATE()) as passedCases, + (SELECT COALESCE(SUM(passed_cases + failed_cases + skipped_cases), 0) FROM Auto_TestRun WHERE DATE(created_at) = CURDATE()) as totalCasesRun, + (SELECT COUNT(*) FROM Auto_TestRun WHERE status IN ('pending', 'running')) as runningTasks `) as StatsResult[]; const stats = this.parseStatsResult(result, { @@ -458,7 +460,7 @@ export class DashboardRepository extends BaseRepository { e.task_name as taskName, e.status, COALESCE(e.duration, 0) as duration, - e.start_time as startTime, + COALESCE(e.start_time, e.created_at) as startTime, COALESCE(e.total_cases, 0) as totalCases, COALESCE(e.passed_cases, 0) as passedCases, COALESCE(e.failed_cases, 0) as failedCases, @@ -466,8 +468,7 @@ export class DashboardRepository extends BaseRepository { u.id as executedById FROM Auto_TestCaseTaskExecutions e LEFT JOIN Auto_Users u ON e.executed_by = u.id - WHERE e.start_time IS NOT NULL - ORDER BY e.start_time DESC + ORDER BY e.created_at DESC LIMIT ? `, [limit]) as RecentRunRaw[]; @@ -674,14 +675,16 @@ export class DashboardRepository extends BaseRepository { */ async getTodayExecution(): Promise { try { + // 使用 Auto_TestRun 表,与 getStats() 保持一致 + // 用 created_at 确保触发即统计(start_time 可能为 NULL) const result = await this.taskExecutionRepository.query(` SELECT COUNT(*) as total, COALESCE(SUM(passed_cases), 0) as passed, COALESCE(SUM(failed_cases), 0) as failed, COALESCE(SUM(skipped_cases), 0) as skipped - FROM Auto_TestCaseTaskExecutions - WHERE DATE(start_time) = CURDATE() + FROM Auto_TestRun + WHERE DATE(created_at) = CURDATE() `) as ExecutionStats[]; // ✅ Type-safe null safety check with explicit interface @@ -736,7 +739,7 @@ export class DashboardRepository extends BaseRepository { avgDuration: string; } - // 计算当日统计 + // 计算当日统计 - 使用 Auto_TestRun 表,duration_ms 转换为秒 const stats = await this.taskExecutionRepository.query(` SELECT COUNT(*) as totalExecutions, @@ -744,9 +747,9 @@ export class DashboardRepository extends BaseRepository { COALESCE(SUM(passed_cases), 0) as passedCases, COALESCE(SUM(failed_cases), 0) as failedCases, COALESCE(SUM(skipped_cases), 0) as skippedCases, - COALESCE(AVG(duration), 0) as avgDuration - FROM Auto_TestCaseTaskExecutions - WHERE DATE(start_time) = ? + COALESCE(AVG(duration_ms / 1000), 0) as avgDuration + FROM Auto_TestRun + WHERE DATE(created_at) = ? `, [targetDate]) as DailyStats[]; const activeCases = await this.testCaseRepository.query(` diff --git a/server/repositories/ExecutionRepository.ts b/server/repositories/ExecutionRepository.ts index cbad9ad..6234e32 100644 --- a/server/repositories/ExecutionRepository.ts +++ b/server/repositories/ExecutionRepository.ts @@ -832,6 +832,8 @@ export class ExecutionRepository extends BaseRepository { triggerType: 'manual' | 'jenkins' | 'schedule'; jenkinsJob?: string; runConfig?: Record; + taskId?: number; + taskName?: string; }): Promise<{ runId: number; executionId: number; totalCases: number; caseIds: number[] }> { return this.executeInTransaction(async (queryRunner) => { // 1. 获取活跃用例 @@ -859,8 +861,8 @@ export class ExecutionRepository extends BaseRepository { // 3. 创建任务执行记录 const taskExecution = await this.createTaskExecution({ - taskId: undefined, - taskName: undefined, + taskId: input.taskId, + taskName: input.taskName, totalCases: cases.length, executedBy: input.triggeredBy, }); diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 8b607af..5bf4ccb 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -1,13 +1,24 @@ import { Router, Request, Response } from 'express'; import { authService } from '../services/AuthService'; import { authenticate } from '../middleware/auth'; +import { + loginRateLimiter, + registerRateLimiter, + forgotPasswordRateLimiter, + resetPasswordRateLimiter, + refreshRateLimiter, + generalAuthRateLimiter, +} from '../middleware/authRateLimiter'; const router = Router(); // 用户注册 -router.post('/register', async (req: Request, res: Response) => { +router.post('/register', registerRateLimiter, async (req: Request, res: Response) => { try { - const { email, password, username } = req.body; + const body = (req.body ?? {}) as Record; + const email = typeof body['email'] === 'string' ? body['email'] : ''; + const password = typeof body['password'] === 'string' ? body['password'] : ''; + const username = typeof body['username'] === 'string' ? body['username'] : ''; // 参数验证 if (!email || !password || !username) { @@ -16,7 +27,16 @@ router.post('/register', async (req: Request, res: Response) => { } // 邮箱格式验证 - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + // 先限制长度(防止超长输入对正则引擎造成 ReDoS 攻击) + if (email.length > 254) { + res.status(400).json({ success: false, message: '邮箱格式不正确' }); + return; + } + // 使用明确上界的正则,避免多项式级回溯(Polynomial ReDoS): + // - 本地部分限制在 1~64 个字符(RFC 5321 规范上限) + // - 域名部分限制在 1~255 个字符 + // - 使用具体字符集而非开放的否定字符类 [^\s@] + const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]{1,64}@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?){0,10}\.[a-zA-Z]{2,}$/; if (!emailRegex.test(email)) { res.status(400).json({ success: false, message: '邮箱格式不正确' }); return; @@ -43,9 +63,12 @@ router.post('/register', async (req: Request, res: Response) => { }); // 用户登录 -router.post('/login', async (req: Request, res: Response) => { +router.post('/login', loginRateLimiter, async (req: Request, res: Response) => { try { - const { email, password, remember = false } = req.body; + const body = (req.body ?? {}) as Record; + const email = typeof body['email'] === 'string' ? body['email'] : ''; + const password = typeof body['password'] === 'string' ? body['password'] : ''; + const remember = typeof body['remember'] === 'boolean' ? body['remember'] : false; if (!email || !password) { res.status(400).json({ success: false, message: '请提供邮箱和密码' }); @@ -61,7 +84,8 @@ router.post('/login', async (req: Request, res: Response) => { }); // 用户登出 -router.post('/logout', authenticate, async (req: Request, res: Response) => { +// generalAuthRateLimiter 放在 authenticate 之前,确保未认证请求(包括暴力攻击)也受速率限制保护 +router.post('/logout', generalAuthRateLimiter, authenticate, async (req: Request, res: Response) => { try { if (!req.user) { res.status(401).json({ success: false, message: '未认证' }); @@ -77,9 +101,10 @@ router.post('/logout', authenticate, async (req: Request, res: Response) => { }); // 忘记密码 -router.post('/forgot-password', async (req: Request, res: Response) => { +router.post('/forgot-password', forgotPasswordRateLimiter, async (req: Request, res: Response) => { try { - const { email } = req.body; + const body = (req.body ?? {}) as Record; + const email = typeof body['email'] === 'string' ? body['email'] : ''; if (!email) { res.status(400).json({ success: false, message: '请提供邮箱地址' }); @@ -95,9 +120,11 @@ router.post('/forgot-password', async (req: Request, res: Response) => { }); // 重置密码 -router.post('/reset-password', async (req: Request, res: Response) => { +router.post('/reset-password', resetPasswordRateLimiter, async (req: Request, res: Response) => { try { - const { token, password } = req.body; + const body = (req.body ?? {}) as Record; + const token = typeof body['token'] === 'string' ? body['token'] : ''; + const password = typeof body['password'] === 'string' ? body['password'] : ''; if (!token || !password) { res.status(400).json({ success: false, message: '请提供重置令牌和新密码' }); @@ -118,7 +145,8 @@ router.post('/reset-password', async (req: Request, res: Response) => { }); // 获取当前用户信息 -router.get('/me', authenticate, async (req: Request, res: Response) => { +// generalAuthRateLimiter 放在 authenticate 之前,确保未认证请求也受速率限制保护 +router.get('/me', generalAuthRateLimiter, authenticate, async (req: Request, res: Response) => { try { if (!req.user) { res.status(401).json({ success: false, message: '未认证' }); @@ -139,9 +167,10 @@ router.get('/me', authenticate, async (req: Request, res: Response) => { }); // 刷新 Token -router.post('/refresh', async (req: Request, res: Response) => { +router.post('/refresh', refreshRateLimiter, async (req: Request, res: Response) => { try { - const { refreshToken } = req.body; + const body = (req.body ?? {}) as Record; + const refreshToken = typeof body['refreshToken'] === 'string' ? body['refreshToken'] : ''; if (!refreshToken) { res.status(400).json({ success: false, message: '请提供刷新令牌' }); diff --git a/server/routes/dashboard.ts b/server/routes/dashboard.ts index 08494e2..8fbe30a 100644 --- a/server/routes/dashboard.ts +++ b/server/routes/dashboard.ts @@ -183,40 +183,47 @@ router.get('/recent-runs', async (req, res) => { }); // 辅助验证函数 -const validateStats = (stats: any) => { - if (stats && typeof stats === 'object') { +const validateStats = (stats: unknown) => { + if (stats !== null && typeof stats === 'object' && !Array.isArray(stats)) { + const s = stats as Record; return { - totalCases: Number.isInteger(stats.totalCases) ? stats.totalCases : 0, - todayRuns: Number.isInteger(stats.todayRuns) ? stats.todayRuns : 0, - todaySuccessRate: typeof stats.todaySuccessRate === 'number' ? stats.todaySuccessRate : null, - runningTasks: Number.isInteger(stats.runningTasks) ? stats.runningTasks : 0, + totalCases: Number.isInteger(s['totalCases']) ? (s['totalCases'] as number) : 0, + todayRuns: Number.isInteger(s['todayRuns']) ? (s['todayRuns'] as number) : 0, + todaySuccessRate: typeof s['todaySuccessRate'] === 'number' ? (s['todaySuccessRate'] as number) : null, + runningTasks: Number.isInteger(s['runningTasks']) ? (s['runningTasks'] as number) : 0, }; } return { totalCases: 0, todayRuns: 0, todaySuccessRate: null, runningTasks: 0 }; }; -const validateTodayExecution = (data: any) => { - if (data && typeof data === 'object') { +const validateTodayExecution = (data: unknown) => { + if (data !== null && typeof data === 'object' && !Array.isArray(data)) { + const d = data as Record; return { - total: Number.isInteger(data.total) ? data.total : 0, - passed: Number.isInteger(data.passed) ? data.passed : 0, - failed: Number.isInteger(data.failed) ? data.failed : 0, - skipped: Number.isInteger(data.skipped) ? data.skipped : 0, + total: Number.isInteger(d['total']) ? (d['total'] as number) : 0, + passed: Number.isInteger(d['passed']) ? (d['passed'] as number) : 0, + failed: Number.isInteger(d['failed']) ? (d['failed'] as number) : 0, + skipped: Number.isInteger(d['skipped']) ? (d['skipped'] as number) : 0, }; } return { total: 0, passed: 0, failed: 0, skipped: 0 }; }; -const validateTrendData = (data: any[]) => { +const validateTrendData = (data: unknown[]) => { if (Array.isArray(data)) { - return data.map(item => ({ - date: item?.date || '', - totalExecutions: Number.isInteger(item?.totalExecutions) ? item.totalExecutions : 0, - passedCases: Number.isInteger(item?.passedCases) ? item.passedCases : 0, - failedCases: Number.isInteger(item?.failedCases) ? item.failedCases : 0, - skippedCases: Number.isInteger(item?.skippedCases) ? item.skippedCases : 0, - successRate: typeof item?.successRate === 'number' ? item.successRate : 0, - })); + return data.map((rawItem: unknown) => { + const item = (rawItem !== null && typeof rawItem === 'object' && !Array.isArray(rawItem)) + ? (rawItem as Record) + : null; + return { + date: typeof item?.['date'] === 'string' ? item['date'] : '', + totalExecutions: Number.isInteger(item?.['totalExecutions']) ? (item!['totalExecutions'] as number) : 0, + passedCases: Number.isInteger(item?.['passedCases']) ? (item!['passedCases'] as number) : 0, + failedCases: Number.isInteger(item?.['failedCases']) ? (item!['failedCases'] as number) : 0, + skippedCases: Number.isInteger(item?.['skippedCases']) ? (item!['skippedCases'] as number) : 0, + successRate: typeof item?.['successRate'] === 'number' ? (item['successRate'] as number) : 0, + }; + }); } return []; }; diff --git a/server/routes/executions.ts b/server/routes/executions.ts index 560485e..9cf4a99 100644 --- a/server/routes/executions.ts +++ b/server/routes/executions.ts @@ -221,7 +221,7 @@ router.post('/:id/sync', async (req, res) => { }); } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Unknown error'; - console.error(`[MANUAL-SYNC] Failed to sync execution ${req.params.id}:`, message); + console.error('[MANUAL-SYNC] Failed to sync execution %s:', req.params.id, message); res.status(500).json({ success: false, message }); } }); @@ -232,7 +232,10 @@ router.post('/:id/sync', async (req, res) => { */ router.post('/sync-stuck', async (req, res) => { try { - const { timeoutMinutes = 10, maxExecutions = 20 } = req.body; + const rawTimeoutMinutes = (req.body as Record)['timeoutMinutes']; + const rawMaxExecutions = (req.body as Record)['maxExecutions']; + const timeoutMinutes = typeof rawTimeoutMinutes === 'number' ? rawTimeoutMinutes : 10; + const maxExecutions = typeof rawMaxExecutions === 'number' ? rawMaxExecutions : 20; const timeoutMs = timeoutMinutes * 60 * 1000; console.log(`[BULK-SYNC] Starting bulk sync for stuck executions (timeout: ${timeoutMinutes}min, max: ${maxExecutions})`); diff --git a/server/routes/jenkins.ts b/server/routes/jenkins.ts index 989775d..8852c0f 100644 --- a/server/routes/jenkins.ts +++ b/server/routes/jenkins.ts @@ -3,6 +3,7 @@ import { executionService } from '../services/ExecutionService'; import { jenkinsService } from '../services/JenkinsService'; import { ipWhitelistMiddleware, rateLimitMiddleware } from '../middleware/JenkinsAuthMiddleware'; import { requestValidator } from '../middleware/RequestValidator'; +import { generalAuthRateLimiter } from '../middleware/authRateLimiter'; import logger from '../utils/logger'; import { LOG_CONTEXTS, createTimer } from '../config/logging'; @@ -52,26 +53,63 @@ function sanitizeErrorMessage(error: unknown, context: string): string { * 触发 Jenkins Job 执行 * * 此接口创建执行记录并返回 executionId,供 Jenkins 后续回调使用 - * 实际触发 Jenkins Job 的逻辑需要在此处或由调用方完成 + * 支持两种模式: + * 1. 直接传入 caseIds 数组 + * 2. 传入 taskId,自动从数据库查找任务的 caseIds 和任务名称 */ -router.post('/trigger', async (req: Request, res: Response) => { +router.post('/trigger', generalAuthRateLimiter, rateLimitMiddleware.limit, async (req: Request, res: Response) => { try { - const { caseIds, projectId = 1, triggeredBy = 1, jenkinsJobName } = req.body; + const triggerBody = (req.body ?? {}) as Record; + let caseIds = triggerBody['caseIds']; + const projectId = typeof triggerBody['projectId'] === 'number' ? triggerBody['projectId'] : 1; + const triggeredBy = typeof triggerBody['triggeredBy'] === 'number' ? triggerBody['triggeredBy'] : 1; + const jenkinsJobName = typeof triggerBody['jenkinsJobName'] === 'string' ? triggerBody['jenkinsJobName'] : undefined; + const taskId = typeof triggerBody['taskId'] === 'number' ? triggerBody['taskId'] : undefined; + let taskName: string | undefined; + + // 如果传入了 taskId,从数据库查找任务信息 + if (taskId !== undefined) { + const { queryOne } = await import('../config/database'); + const task = await queryOne<{ id: number; name: string; case_ids: string; project_id: number }>( + 'SELECT id, name, case_ids, project_id FROM tasks WHERE id = ?', + [taskId] + ); + + if (!task) { + return res.status(404).json({ + success: false, + message: `Task with id ${taskId} not found` + }); + } + + taskName = task.name; + + // 如果没有直接传入 caseIds,从任务中解析 + if (!caseIds || !Array.isArray(caseIds) || caseIds.length === 0) { + try { + caseIds = JSON.parse(task.case_ids) as number[]; + } catch { + caseIds = []; + } + } + } if (!caseIds || !Array.isArray(caseIds) || caseIds.length === 0) { return res.status(400).json({ success: false, - message: 'caseIds is required and must be a non-empty array' + message: 'caseIds is required and must be a non-empty array (or provide a valid taskId with case_ids)' }); } // 创建执行记录 const execution = await executionService.triggerTestExecution({ - caseIds, + caseIds: caseIds as number[], projectId, triggeredBy, triggerType: 'jenkins', jenkinsJob: jenkinsJobName, + taskId, + taskName, }); res.json({ @@ -95,6 +133,7 @@ router.post('/trigger', async (req: Request, res: Response) => { * 触发单个用例执行 */ router.post('/run-case', [ + generalAuthRateLimiter, rateLimitMiddleware.limit, requestValidator.validateSingleExecution ], async (req: Request, res: Response) => { @@ -201,6 +240,7 @@ router.post('/run-case', [ * 触发批量用例执行 */ router.post('/run-batch', [ + generalAuthRateLimiter, rateLimitMiddleware.limit, requestValidator.validateBatchExecution ], async (req: Request, res: Response) => { @@ -309,7 +349,7 @@ router.post('/run-batch', [ * * Jenkins Job 可以调用此接口获取需要执行的用例信息 */ -router.get('/tasks/:taskId/cases', async (req: Request, res: Response) => { +router.get('/tasks/:taskId/cases', generalAuthRateLimiter, rateLimitMiddleware.limit, async (req: Request, res: Response) => { try { const taskId = parseInt(req.params.taskId); const cases = await executionService.getRunCases(taskId); @@ -330,7 +370,7 @@ router.get('/tasks/:taskId/cases', async (req: Request, res: Response) => { * * 用于查询 Jenkins Job 的执行状态 */ -router.get('/status/:executionId', async (req: Request, res: Response) => { +router.get('/status/:executionId', generalAuthRateLimiter, rateLimitMiddleware.limit, async (req: Request, res: Response) => { try { const executionId = parseInt(req.params.executionId); const detail = await executionService.getExecutionDetail(executionId); @@ -339,20 +379,20 @@ router.get('/status/:executionId', async (req: Request, res: Response) => { return res.status(404).json({ success: false, message: 'Execution not found' }); } - const execution = detail.execution as any; + const execution = detail.execution as unknown as Record; res.json({ success: true, data: { executionId, - status: execution.status, - totalCases: execution.total_cases, - passedCases: execution.passed_cases, - failedCases: execution.failed_cases, - skippedCases: execution.skipped_cases, - startTime: execution.start_time, - endTime: execution.end_time, - duration: execution.duration, + status: execution['status'], + totalCases: execution['total_cases'], + passedCases: execution['passed_cases'], + failedCases: execution['failed_cases'], + skippedCases: execution['skipped_cases'], + startTime: execution['start_time'], + endTime: execution['end_time'], + duration: execution['duration'], // Jenkins 相关字段(预留) jenkinsStatus: null, buildNumber: null, @@ -371,6 +411,7 @@ router.get('/status/:executionId', async (req: Request, res: Response) => { * 通过 IP 白名单验证,无需额外认证 */ router.post('/callback', [ + generalAuthRateLimiter, ipWhitelistMiddleware.verify, rateLimitMiddleware.limit, requestValidator.validateCallback @@ -509,7 +550,7 @@ router.post('/callback', [ * GET /api/jenkins/batch/:runId * 获取执行批次详情 */ -router.get('/batch/:runId', async (req: Request, res: Response) => { +router.get('/batch/:runId', generalAuthRateLimiter, rateLimitMiddleware.limit, async (req: Request, res: Response) => { try { const runId = parseInt(req.params.runId); const batch = await executionService.getBatchExecution(runId); @@ -532,6 +573,7 @@ router.get('/batch/:runId', async (req: Request, res: Response) => { * 通过 IP 白名单验证 */ router.post('/callback/test', [ + generalAuthRateLimiter, ipWhitelistMiddleware.verify, rateLimitMiddleware.limit ], async (req: Request, res: Response) => { @@ -713,20 +755,20 @@ router.post('/callback/test', [ * 通过 IP 白名单验证 */ router.post('/callback/manual-sync/:runId', [ + generalAuthRateLimiter, ipWhitelistMiddleware.verify, rateLimitMiddleware.limit ], async (req: Request, res: Response) => { try { const runId = parseInt(req.params.runId); - const { - status, - passedCases, - failedCases, - skippedCases, - durationMs, - results, - force = false - } = req.body; + const syncBody = (req.body ?? {}) as Record; + const status = syncBody['status']; + const passedCases = syncBody['passedCases']; + const failedCases = syncBody['failedCases']; + const skippedCases = syncBody['skippedCases']; + const durationMs = syncBody['durationMs']; + const results = syncBody['results']; + const force = typeof syncBody['force'] === 'boolean' ? syncBody['force'] : false; if (isNaN(runId)) { return res.status(400).json({ @@ -742,7 +784,7 @@ router.post('/callback/manual-sync/:runId', [ failedCases, skippedCases, durationMs, - resultsCount: results?.length || 0, + resultsCount: Array.isArray(results) ? results.length : 0, force, timestamp: new Date().toISOString() }, LOG_CONTEXTS.JENKINS); @@ -757,22 +799,22 @@ router.post('/callback/manual-sync/:runId', [ }); } - const executionData = execution.execution as any; - const currentStatus = executionData.status; + const executionData = execution.execution as unknown as Record; + const currentStatus = executionData['status']; // 检查是否允许更新 - if (!force && ['success', 'failed', 'cancelled'].includes(currentStatus)) { + if (!force && ['success', 'failed', 'cancelled'].includes(currentStatus as string)) { return res.status(400).json({ success: false, message: `Execution is already completed with status: ${currentStatus}. Use force=true to override.`, current: { id: runId, status: currentStatus, - totalCases: executionData.total_cases, - passedCases: executionData.passed_cases, - failedCases: executionData.failed_cases, - skippedCases: executionData.skipped_cases, - updatedAt: executionData.updated_at || executionData.created_at + totalCases: executionData['total_cases'], + passedCases: executionData['passed_cases'], + failedCases: executionData['failed_cases'], + skippedCases: executionData['skipped_cases'], + updatedAt: executionData['updated_at'] ?? executionData['created_at'] } }); } @@ -790,11 +832,11 @@ router.post('/callback/manual-sync/:runId', [ await executionService.completeBatchExecution(runId, { status: status as 'success' | 'failed' | 'cancelled', - passedCases: passedCases || 0, - failedCases: failedCases || 0, - skippedCases: skippedCases || 0, - durationMs: durationMs || 0, - results: results || [], + passedCases: typeof passedCases === 'number' ? passedCases : 0, + failedCases: typeof failedCases === 'number' ? failedCases : 0, + skippedCases: typeof skippedCases === 'number' ? skippedCases : 0, + durationMs: typeof durationMs === 'number' ? durationMs : 0, + results: Array.isArray(results) ? results : [], }); const processingTime = Date.now() - startTime; @@ -808,7 +850,7 @@ router.post('/callback/manual-sync/:runId', [ // 查询更新后的数据 const updated = await executionService.getBatchExecution(runId); - const updatedData = updated.execution as any; + const updatedData = updated.execution as unknown as Record; res.json({ success: true, @@ -816,20 +858,20 @@ router.post('/callback/manual-sync/:runId', [ previous: { id: runId, status: currentStatus, - totalCases: executionData.total_cases, - passedCases: executionData.passed_cases, - failedCases: executionData.failed_cases, - skippedCases: executionData.skipped_cases + totalCases: executionData['total_cases'], + passedCases: executionData['passed_cases'], + failedCases: executionData['failed_cases'], + skippedCases: executionData['skipped_cases'] }, updated: { id: runId, - status: updatedData.status, - totalCases: updatedData.total_cases, - passedCases: updatedData.passed_cases, - failedCases: updatedData.failed_cases, - skippedCases: updatedData.skipped_cases, - endTime: updatedData.end_time, - durationMs: updatedData.duration_ms + status: updatedData['status'], + totalCases: updatedData['total_cases'], + passedCases: updatedData['passed_cases'], + failedCases: updatedData['failed_cases'], + skippedCases: updatedData['skipped_cases'], + endTime: updatedData['end_time'], + durationMs: updatedData['duration_ms'] }, timing: { processingTimeMs: processingTime, @@ -870,6 +912,7 @@ router.post('/callback/manual-sync/:runId', [ * 诊断回调连接问题 - 通过 IP 白名单验证以保护系统信息 */ router.post('/callback/diagnose', + generalAuthRateLimiter, rateLimitMiddleware.limit, ipWhitelistMiddleware.verify, async (req: Request, res: Response) => { @@ -884,19 +927,27 @@ router.post('/callback/diagnose', }, LOG_CONTEXTS.JENKINS); // 分析回调配置 - const diagnostics: any = { + const envConfig = { + jenkins_url: !!process.env.JENKINS_URL, + jenkins_user: !!process.env.JENKINS_USER, + jenkins_token: !!process.env.JENKINS_TOKEN, + jenkins_allowed_ips: !!process.env.JENKINS_ALLOWED_IPS, + }; + const diagnostics: { + timestamp: string; + clientIP: string; + environmentVariablesConfigured: typeof envConfig; + requestHeaders: Record; + suggestions: string[]; + nextSteps?: string[]; + } = { timestamp, clientIP, - environmentVariablesConfigured: { - jenkins_url: !!process.env.JENKINS_URL, - jenkins_user: !!process.env.JENKINS_USER, - jenkins_token: !!process.env.JENKINS_TOKEN, - jenkins_allowed_ips: !!process.env.JENKINS_ALLOWED_IPS, - }, + environmentVariablesConfigured: envConfig, requestHeaders: { hasContentType: !!req.headers['content-type'], }, - suggestions: [] as string[], + suggestions: [], }; // 分析问题并给出建议 @@ -946,7 +997,7 @@ router.post('/callback/diagnose', * GET /api/jenkins/health * Jenkins 连接健康检查 - 包括详细的诊断信息 */ -router.get('/health', async (req: Request, res: Response) => { +router.get('/health', generalAuthRateLimiter, rateLimitMiddleware.limit, async (req: Request, res: Response) => { const startTime = Date.now(); try { @@ -958,7 +1009,14 @@ router.get('/health', async (req: Request, res: Response) => { const jenkinsToken = process.env.JENKINS_TOKEN || ''; // 健康检查数据 - const healthCheckData: any = { + const healthCheckData: { + timestamp: string; + duration: number; + checks: Record; + diagnostics: Record; + issues: string[]; + recommendations: string[]; + } = { timestamp: new Date().toISOString(), duration: 0, checks: { @@ -1023,7 +1081,7 @@ router.get('/health', async (req: Request, res: Response) => { }, LOG_CONTEXTS.JENKINS); if (response.ok) { - const data = await response.json() as any; + const data = await response.json() as Record; healthCheckData.checks.authenticationTest.success = true; healthCheckData.checks.apiResponseTest.success = true; @@ -1032,7 +1090,7 @@ router.get('/health', async (req: Request, res: Response) => { data: { connected: true, jenkinsUrl, - version: data.version || 'unknown', + version: typeof data['version'] === 'string' ? data['version'] : 'unknown', timestamp: new Date().toISOString(), details: healthCheckData, }, @@ -1125,6 +1183,7 @@ router.get('/health', async (req: Request, res: Response) => { * 诊断执行问题 - 通过 IP 白名单验证以保护系统信息 */ router.get('/diagnose', + generalAuthRateLimiter, rateLimitMiddleware.limit, ipWhitelistMiddleware.verify, async (req: Request, res: Response) => { @@ -1291,7 +1350,7 @@ router.get('/diagnose', * GET /api/jenkins/monitoring/stats * 获取监控统计信息 */ -router.get('/monitoring/stats', async (_req, res) => { +router.get('/monitoring/stats', generalAuthRateLimiter, rateLimitMiddleware.limit, async (_req, res) => { try { logger.info(`Getting monitoring statistics...`, {}, LOG_CONTEXTS.JENKINS); @@ -1355,9 +1414,11 @@ router.get('/monitoring/stats', async (_req, res) => { * POST /api/jenkins/monitoring/fix-stuck * 修复卡住的执行 */ -router.post('/monitoring/fix-stuck', async (req: Request, res: Response) => { +router.post('/monitoring/fix-stuck', generalAuthRateLimiter, rateLimitMiddleware.limit, async (req: Request, res: Response) => { try { - const { timeoutMinutes = 5, dryRun = false } = req.body; + const fixBody = (req.body ?? {}) as Record; + const timeoutMinutes = typeof fixBody['timeoutMinutes'] === 'number' ? fixBody['timeoutMinutes'] : 5; + const dryRun = typeof fixBody['dryRun'] === 'boolean' ? fixBody['dryRun'] : false; logger.info(`${dryRun ? 'Simulating' : 'Starting'} fix for stuck executions`, { timeoutMinutes, @@ -1421,7 +1482,7 @@ router.post('/monitoring/fix-stuck', async (req: Request, res: Response) => { * GET /api/jenkins/monitor/status * Get execution monitor service status and statistics */ -router.get('/monitor/status', async (_req: Request, res: Response) => { +router.get('/monitor/status', generalAuthRateLimiter, rateLimitMiddleware.limit, async (_req: Request, res: Response) => { try { const { executionMonitorService } = await import('../services/ExecutionMonitorService'); diff --git a/server/routes/tasks.ts b/server/routes/tasks.ts index 8fe8a07..9c0a06e 100644 --- a/server/routes/tasks.ts +++ b/server/routes/tasks.ts @@ -1,5 +1,6 @@ import { Router } from 'express'; import { query, queryOne, getPool } from '../config/database'; +import { generalAuthRateLimiter } from '../middleware/authRateLimiter'; const router = Router(); @@ -142,7 +143,7 @@ router.get('/:id', async (req, res) => { * POST /api/tasks * 创建任务 */ -router.post('/', async (req, res) => { +router.post('/', generalAuthRateLimiter, async (req, res) => { try { const { name, @@ -192,7 +193,7 @@ router.post('/', async (req, res) => { * PUT /api/tasks/:id * 更新任务 */ -router.put('/:id', async (req, res) => { +router.put('/:id', generalAuthRateLimiter, async (req, res) => { try { const id = parseInt(req.params.id); const { @@ -261,7 +262,7 @@ router.put('/:id', async (req, res) => { * DELETE /api/tasks/:id * 删除任务 */ -router.delete('/:id', async (req, res) => { +router.delete('/:id', generalAuthRateLimiter, async (req, res) => { try { const id = parseInt(req.params.id); diff --git a/server/services/ExecutionService.ts b/server/services/ExecutionService.ts index 29f0dda..6ef5c73 100644 --- a/server/services/ExecutionService.ts +++ b/server/services/ExecutionService.ts @@ -29,6 +29,8 @@ export interface CaseExecutionInput { triggerType: 'manual' | 'jenkins' | 'schedule'; jenkinsJob?: string; runConfig?: Record; + taskId?: number; + taskName?: string; } export interface ExecutionProgress { @@ -317,6 +319,8 @@ export class ExecutionService { triggerType: input.triggerType, jenkinsJob: input.jenkinsJob, runConfig: input.runConfig, + taskId: input.taskId, + taskName: input.taskName, }); // 3. 保存 runId 到 executionId 的映射到缓存(用于 Jenkins 回调) diff --git a/server/utils/logger.ts b/server/utils/logger.ts index f29d9d1..5e57ae5 100644 --- a/server/utils/logger.ts +++ b/server/utils/logger.ts @@ -199,18 +199,37 @@ class Logger { const logMessage = parts.join(' '); // 根据级别选择输出方法 + // 注意:使用固定字面量 '%s' 作为格式字符串,将 logMessage 作为数据参数传入, + // 防止外部输入(如用户提供的 message)中包含 %s/%d/%o 等格式说明符被解析(format string injection) + const sanitizedData = data ? this.sanitizeData(data) : undefined; switch (level) { case LogLevel.DEBUG: - console.debug(logMessage, data ? this.sanitizeData(data) : ''); + if (sanitizedData !== undefined) { + console.debug('%s', logMessage, sanitizedData); + } else { + console.debug('%s', logMessage); + } break; case LogLevel.INFO: - console.info(logMessage, data ? this.sanitizeData(data) : ''); + if (sanitizedData !== undefined) { + console.info('%s', logMessage, sanitizedData); + } else { + console.info('%s', logMessage); + } break; case LogLevel.WARN: - console.warn(logMessage, data ? this.sanitizeData(data) : ''); + if (sanitizedData !== undefined) { + console.warn('%s', logMessage, sanitizedData); + } else { + console.warn('%s', logMessage); + } break; case LogLevel.ERROR: - console.error(logMessage, data ? this.sanitizeData(data) : ''); + if (sanitizedData !== undefined) { + console.error('%s', logMessage, sanitizedData); + } else { + console.error('%s', logMessage); + } break; } } diff --git a/src/lib/api.ts b/src/lib/api.ts deleted file mode 100644 index 1b40b10..0000000 --- a/src/lib/api.ts +++ /dev/null @@ -1,17 +0,0 @@ -export { - dashboardApi, - executionApi, - casesApi, - tasksApi, - type DashboardStats, - type TodayExecution, - type DailySummary, - type ComparisonData, - type RecentRun, - type ExecutionResult, - type ExecutionDetail, - type TestCase, - type CreateCaseInput, - type Task, - type CreateTaskInput, -} from '@/api/index'; \ No newline at end of file diff --git a/test_case/backend/middleware/authRateLimiter.test.ts b/test_case/backend/middleware/authRateLimiter.test.ts new file mode 100644 index 0000000..3a0f6b2 --- /dev/null +++ b/test_case/backend/middleware/authRateLimiter.test.ts @@ -0,0 +1,366 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import request from 'supertest'; +import express, { Express } from 'express'; +import { + loginRateLimiter, + registerRateLimiter, + forgotPasswordRateLimiter, + resetPasswordRateLimiter, + refreshRateLimiter, + generalAuthRateLimiter, +} from '../../../server/middleware/authRateLimiter'; + +describe('Auth Rate Limiters', () => { + let app: Express; + + // Helper to create a fresh Express app for each test + const createApp = (limiter: any) => { + const testApp = express(); + testApp.use(express.json()); + testApp.post('/test', limiter, (req, res) => { + res.status(200).json({ success: true }); + }); + testApp.get('/test', limiter, (req, res) => { + res.status(200).json({ success: true }); + }); + return testApp; + }; + + afterEach(() => { + // Clear any timers or intervals + vi.clearAllTimers(); + }); + + describe('loginRateLimiter', () => { + beforeEach(() => { + app = createApp(loginRateLimiter); + }); + + it('should allow 5 requests within 15 minutes', async () => { + for (let i = 0; i < 5; i++) { + const res = await request(app) + .post('/test') + .send({ email: 'test@example.com', password: 'password' }); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + } + }); + + it('should block 6th request with 429 status', async () => { + // Exhaust the limit + for (let i = 0; i < 5; i++) { + await request(app) + .post('/test') + .send({ email: 'test@example.com', password: 'password' }); + } + + // 6th request should be blocked + const res = await request(app) + .post('/test') + .send({ email: 'test@example.com', password: 'password' }); + + expect(res.status).toBe(429); + expect(res.body).toHaveProperty('error', 'Too many requests'); + expect(res.body).toHaveProperty('message', 'Please try again later'); + expect(res.body.success).toBe(false); + }); + + it('should return rate limit headers', async () => { + const res = await request(app) + .post('/test') + .send({ email: 'test@example.com', password: 'password' }); + + expect(res.headers).toHaveProperty('ratelimit-limit'); + expect(res.headers).toHaveProperty('ratelimit-remaining'); + expect(res.headers['ratelimit-limit']).toBe('5'); + }); + + it('should include retry-after information in 429 response', async () => { + // Exhaust the limit + for (let i = 0; i < 5; i++) { + await request(app).post('/test'); + } + + const res = await request(app).post('/test'); + expect(res.status).toBe(429); + expect(res.body).toHaveProperty('retryAfter'); + expect(res.headers).toHaveProperty('ratelimit-reset'); + }); + }); + + describe('registerRateLimiter', () => { + beforeEach(() => { + app = createApp(registerRateLimiter); + }); + + it('should allow 3 requests within 1 hour', async () => { + for (let i = 0; i < 3; i++) { + const res = await request(app) + .post('/test') + .send({ + email: `test${i}@example.com`, + password: 'Test123!', + username: `test${i}` + }); + expect(res.status).toBe(200); + } + }); + + it('should block 4th request with 429 status', async () => { + // Exhaust the limit + for (let i = 0; i < 3; i++) { + await request(app) + .post('/test') + .send({ + email: `test${i}@example.com`, + password: 'Test123!', + username: `test${i}` + }); + } + + // 4th request should be blocked + const res = await request(app) + .post('/test') + .send({ email: 'test3@example.com', password: 'Test123!', username: 'test3' }); + + expect(res.status).toBe(429); + expect(res.body.error).toBe('Too many requests'); + }); + + it('should return correct rate limit for registration (3 requests)', async () => { + const res = await request(app).post('/test'); + expect(res.headers['ratelimit-limit']).toBe('3'); + }); + }); + + describe('forgotPasswordRateLimiter', () => { + beforeEach(() => { + app = createApp(forgotPasswordRateLimiter); + }); + + it('should enforce 3 requests per hour limit', async () => { + // First 3 requests should succeed + for (let i = 0; i < 3; i++) { + const res = await request(app) + .post('/test') + .send({ email: 'test@example.com' }); + expect(res.status).toBe(200); + } + + // 4th request should be blocked + const res = await request(app) + .post('/test') + .send({ email: 'test@example.com' }); + + expect(res.status).toBe(429); + expect(res.body.message).toContain('try again later'); + }); + + it('should return user-friendly error message', async () => { + // Exhaust limit + for (let i = 0; i < 3; i++) { + await request(app).post('/test'); + } + + const res = await request(app).post('/test'); + expect(res.status).toBe(429); + expect(res.body).toHaveProperty('success', false); + expect(res.body).toHaveProperty('error'); + expect(res.body).toHaveProperty('message'); + }); + }); + + describe('resetPasswordRateLimiter', () => { + beforeEach(() => { + app = createApp(resetPasswordRateLimiter); + }); + + it('should allow 5 requests within 15 minutes', async () => { + for (let i = 0; i < 5; i++) { + const res = await request(app) + .post('/test') + .send({ token: 'reset-token', password: 'NewPass123!' }); + expect(res.status).toBe(200); + } + }); + + it('should block 6th request', async () => { + for (let i = 0; i < 5; i++) { + await request(app).post('/test'); + } + + const res = await request(app).post('/test'); + expect(res.status).toBe(429); + }); + }); + + describe('refreshRateLimiter', () => { + beforeEach(() => { + app = createApp(refreshRateLimiter); + }); + + it('should allow 10 requests within 15 minutes', async () => { + for (let i = 0; i < 10; i++) { + const res = await request(app) + .post('/test') + .send({ refreshToken: 'some-refresh-token' }); + expect(res.status).toBe(200); + } + }); + + it('should block 11th request', async () => { + for (let i = 0; i < 10; i++) { + await request(app).post('/test'); + } + + const res = await request(app).post('/test'); + expect(res.status).toBe(429); + }); + + it('should have higher limit than login (10 vs 5)', async () => { + const res = await request(app).post('/test'); + expect(res.headers['ratelimit-limit']).toBe('10'); + }); + }); + + describe('generalAuthRateLimiter', () => { + beforeEach(() => { + app = createApp(generalAuthRateLimiter); + }); + + it('should allow 100 requests within 15 minutes', async () => { + // Test first 10 requests to verify it's working + for (let i = 0; i < 10; i++) { + const res = await request(app).get('/test'); + expect(res.status).toBe(200); + } + + // Verify the limit is set correctly + const res = await request(app).get('/test'); + expect(res.headers['ratelimit-limit']).toBe('100'); + }); + + it('should block request after 100 attempts', async () => { + // Exhaust the limit + for (let i = 0; i < 100; i++) { + await request(app).get('/test'); + } + + // 101st request should be blocked + const res = await request(app).get('/test'); + expect(res.status).toBe(429); + }); + + it('should have much higher limit for read operations', async () => { + const res = await request(app).get('/test'); + expect(res.headers['ratelimit-limit']).toBe('100'); + expect(parseInt(res.headers['ratelimit-limit'])).toBeGreaterThan(10); + }); + }); + + describe('Rate Limit Headers', () => { + beforeEach(() => { + app = createApp(loginRateLimiter); + }); + + it('should include RateLimit-Limit header', async () => { + const res = await request(app).post('/test'); + expect(res.headers).toHaveProperty('ratelimit-limit'); + expect(res.headers['ratelimit-limit']).toBe('5'); + }); + + it('should include RateLimit-Remaining header', async () => { + const res = await request(app).post('/test'); + expect(res.headers).toHaveProperty('ratelimit-remaining'); + expect(parseInt(res.headers['ratelimit-remaining'])).toBeLessThan(5); + }); + + it('should include RateLimit-Reset header', async () => { + const res = await request(app).post('/test'); + expect(res.headers).toHaveProperty('ratelimit-reset'); + + // Reset time is returned (could be absolute timestamp or relative seconds) + const resetTime = parseInt(res.headers['ratelimit-reset']); + expect(resetTime).toBeGreaterThan(0); + // Should be within reasonable range (15 minutes window = 900 seconds) + expect(resetTime).toBeLessThanOrEqual(15 * 60 + Date.now() / 1000); + }); + + it('should decrement RateLimit-Remaining with each request', async () => { + const res1 = await request(app).post('/test'); + const remaining1 = parseInt(res1.headers['ratelimit-remaining']); + + const res2 = await request(app).post('/test'); + const remaining2 = parseInt(res2.headers['ratelimit-remaining']); + + // Remaining should decrease (may be 0 if already at limit) + expect(remaining2).toBeLessThanOrEqual(remaining1); + // If not at limit, should decrease by exactly 1 + if (remaining1 > 0) { + expect(remaining2).toBe(remaining1 - 1); + } + }); + + it('should show 0 remaining when limit is reached', async () => { + // Exhaust limit + for (let i = 0; i < 5; i++) { + await request(app).post('/test'); + } + + const res = await request(app).post('/test'); + expect(res.status).toBe(429); + expect(res.headers['ratelimit-remaining']).toBe('0'); + }); + }); + + describe('Error Response Format', () => { + beforeEach(() => { + app = createApp(loginRateLimiter); + }); + + it('should return consistent error format on rate limit', async () => { + // Exhaust limit + for (let i = 0; i < 5; i++) { + await request(app).post('/test'); + } + + const res = await request(app).post('/test'); + + expect(res.status).toBe(429); + expect(res.body).toMatchObject({ + success: false, + error: 'Too many requests', + message: 'Please try again later', + }); + expect(res.body).toHaveProperty('retryAfter'); + }); + + it('should include retry information', async () => { + // Exhaust limit + for (let i = 0; i < 5; i++) { + await request(app).post('/test'); + } + + const res = await request(app).post('/test'); + expect(res.body.retryAfter).toBeDefined(); + }); + }); + + describe('IP-based Tracking', () => { + it('should track requests per IP address', async () => { + const app1 = createApp(loginRateLimiter); + + // First IP exhausts its limit + for (let i = 0; i < 5; i++) { + await request(app1).post('/test'); + } + + const blockedRes = await request(app1).post('/test'); + expect(blockedRes.status).toBe(429); + + // Different test instance simulates different IP (in real scenario) + // Note: supertest uses same IP, so this is more of a structural test + expect(blockedRes.headers).toHaveProperty('ratelimit-remaining', '0'); + }); + }); +}); diff --git a/tsconfig.server.json b/tsconfig.server.json index 55d87cb..1ab0ff6 100644 --- a/tsconfig.server.json +++ b/tsconfig.server.json @@ -12,19 +12,30 @@ "outDir": "./dist/server", "baseUrl": ".", "paths": { - "@shared/*": ["./shared/*"], - "@configs/*": ["./configs/*"], - "@test/*": ["./test_case/*"] + "@shared/*": [ + "./shared/*" + ], + "@configs/*": [ + "./configs/*" + ], + "@test/*": [ + "./test_case/*" + ] }, - "types": ["node"] + "types": [ + "node" + ] }, - "include": ["server", "shared", "test_case/backend"], - "exclude": ["node_modules", "dist", "archive", "tmp", ".aiconfig"], - "ts-node": { - "transpileOnly": true, - "compilerOptions": { - "module": "CommonJS" - }, - "require": ["tsconfig-paths/register"] - } -} + "include": [ + "server", + "shared", + "test_case/backend" + ], + "exclude": [ + "node_modules", + "dist", + "archive", + "tmp", + ".aiconfig" + ] +} \ No newline at end of file