From efd9544a618c10a7d294dc8cac586c3b07d1d80b Mon Sep 17 00:00:00 2001 From: acai1998 Date: Sun, 15 Feb 2026 00:29:08 +0800 Subject: [PATCH 01/28] =?UTF-8?q?refactor:=20=E8=B0=83=E6=95=B4=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E5=BC=95=E7=94=A8=E5=92=8C=E6=A0=BC=E5=BC=8F=E8=A7=84?= =?UTF-8?q?=E8=8C=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一修改 ExecutionRepository 中的路径引用,规范 tsconfig.server.json 中的路径配置和格式。 --- rebuild-docker.sh | 73 ++++++++++++++++++++++ server/repositories/ExecutionRepository.ts | 2 +- tsconfig.server.json | 39 +++++++----- 3 files changed, 99 insertions(+), 15 deletions(-) create mode 100644 rebuild-docker.sh 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/server/repositories/ExecutionRepository.ts b/server/repositories/ExecutionRepository.ts index e613085..cbad9ad 100644 --- a/server/repositories/ExecutionRepository.ts +++ b/server/repositories/ExecutionRepository.ts @@ -18,7 +18,7 @@ import { mapTaskExecutionStatusToTestRunStatus, isValidTestRunStatus, isValidTaskExecutionStatus, -} from '@shared/types/execution'; +} from '../../shared/types/execution'; /** * 带用户信息的 TaskExecution 接口 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 From b86d0115f3e045859a14b4159d083ae253eab087 Mon Sep 17 00:00:00 2001 From: acai1998 Date: Sun, 15 Feb 2026 00:34:15 +0800 Subject: [PATCH 02/28] 1 --- FINAL_FIX.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 FINAL_FIX.md diff --git a/FINAL_FIX.md b/FINAL_FIX.md new file mode 100644 index 0000000..e69de29 From 71d1dc44b0f3744f13c72cdd17f11906b9bd5c86 Mon Sep 17 00:00:00 2001 From: acai1998 Date: Sun, 15 Feb 2026 16:35:13 +0800 Subject: [PATCH 03/28] =?UTF-8?q?=E5=88=A0=E9=99=A4=E7=A9=BA=E6=96=87?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DOCKER_BUILD_SOLUTION.md | 0 FINAL_FIX.md | 0 fix_jenkinsfile.py | 0 3 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 DOCKER_BUILD_SOLUTION.md delete mode 100644 FINAL_FIX.md delete mode 100644 fix_jenkinsfile.py diff --git a/DOCKER_BUILD_SOLUTION.md b/DOCKER_BUILD_SOLUTION.md deleted file mode 100644 index e69de29..0000000 diff --git a/FINAL_FIX.md b/FINAL_FIX.md deleted file mode 100644 index e69de29..0000000 diff --git a/fix_jenkinsfile.py b/fix_jenkinsfile.py deleted file mode 100644 index e69de29..0000000 From 8e14b7b3dbff2aeb930d4eeca7201c61388820a7 Mon Sep 17 00:00:00 2001 From: acai1998 Date: Fri, 27 Feb 2026 11:17:12 +0800 Subject: [PATCH 04/28] =?UTF-8?q?refactor:=20=E6=9B=B4=E6=96=B0=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E6=89=A7=E8=A1=8C=E7=8A=B6=E6=80=81=E4=B8=BA'cancelle?= =?UTF-8?q?d'=EF=BC=8C=E5=B9=B6=E6=B7=BB=E5=8A=A0=E8=A7=A6=E5=8F=91?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DOCKER_BUILD_TEST.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DOCKER_BUILD_TEST.md b/DOCKER_BUILD_TEST.md index 2ac6d9e..9801be1 100644 --- a/DOCKER_BUILD_TEST.md +++ b/DOCKER_BUILD_TEST.md @@ -212,4 +212,4 @@ docker buildx build --platform linux/amd64 -t automation-platform:latest -f depl 1. 在 GitHub Actions/GitLab CI 中添加 Docker 构建测试 2. 添加 `.dockerignore` 文件优化上下文大小 3. 考虑使用 docker buildx 构建多平台镜像 -4. 定期更新 Node Alpine 基础镜像版本 +4. 定期更新 Node Alpine 基础镜像版本 \ No newline at end of file From fbd49f84c2f8c84ebaa9bba6db55e7e275469203 Mon Sep 17 00:00:00 2001 From: acai1998 Date: Fri, 27 Feb 2026 20:08:15 +0800 Subject: [PATCH 05/28] =?UTF-8?q?chore:=20=E6=B7=BB=E5=8A=A0=20supertest?= =?UTF-8?q?=20=E5=8F=8A=E7=9B=B8=E5=85=B3=E7=B1=BB=E5=9E=8B=E4=BE=9D?= =?UTF-8?q?=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refactor: 统一请求体及参数类型断言,增强类型安全 fix: 修正请求验证器中数组类型校验及字段类型收窄 fix: 优化邮箱格式校验,防止正则表达式拒绝服务攻击 feat: 为认证相关接口添加限流中间件 fix: 修正日志打印格式,防止格式化字符串注入 fix: 规范接口返回数据类型断言,避免类型错误 fix: 完善执行结果校验,增加字段类型及逻辑校验 --- package-lock.json | 263 +++++++++++++ package.json | 2 + server/middleware/RequestValidator.ts | 65 ++-- server/middleware/authRateLimiter.ts | 151 ++++++++ server/routes/auth.ts | 53 ++- server/routes/dashboard.ts | 49 ++- server/routes/executions.ts | 7 +- server/routes/jenkins.ts | 134 ++++--- server/utils/logger.ts | 27 +- .../middleware/authRateLimiter.test.ts | 366 ++++++++++++++++++ 10 files changed, 992 insertions(+), 125 deletions(-) create mode 100644 server/middleware/authRateLimiter.ts create mode 100644 test_case/backend/middleware/authRateLimiter.test.ts 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..6409bcd 100644 --- a/package.json +++ b/package.json @@ -63,11 +63,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/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/routes/auth.ts b/server/routes/auth.ts index 8b607af..b10c123 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,7 @@ router.post('/login', async (req: Request, res: Response) => { }); // 用户登出 -router.post('/logout', authenticate, async (req: Request, res: Response) => { +router.post('/logout', authenticate, generalAuthRateLimiter, async (req: Request, res: Response) => { try { if (!req.user) { res.status(401).json({ success: false, message: '未认证' }); @@ -77,9 +100,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 +119,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 +144,7 @@ router.post('/reset-password', async (req: Request, res: Response) => { }); // 获取当前用户信息 -router.get('/me', authenticate, async (req: Request, res: Response) => { +router.get('/me', authenticate, generalAuthRateLimiter, async (req: Request, res: Response) => { try { if (!req.user) { res.status(401).json({ success: false, message: '未认证' }); @@ -139,9 +165,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..4802e60 100644 --- a/server/routes/jenkins.ts +++ b/server/routes/jenkins.ts @@ -56,7 +56,11 @@ function sanitizeErrorMessage(error: unknown, context: string): string { */ router.post('/trigger', async (req: Request, res: Response) => { try { - const { caseIds, projectId = 1, triggeredBy = 1, jenkinsJobName } = req.body; + const triggerBody = (req.body ?? {}) as Record; + const 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; if (!caseIds || !Array.isArray(caseIds) || caseIds.length === 0) { return res.status(400).json({ @@ -339,20 +343,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, @@ -718,15 +722,14 @@ router.post('/callback/manual-sync/:runId', [ ], 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 +745,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 +760,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 +793,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 +811,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 +819,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, @@ -884,19 +887,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: [], }; // 分析问题并给出建议 @@ -958,7 +969,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 +1041,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 +1050,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, }, @@ -1357,7 +1375,9 @@ router.get('/monitoring/stats', async (_req, res) => { */ router.post('/monitoring/fix-stuck', 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, 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/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'); + }); + }); +}); From 1f464aed9e8a0085c95a9ed0fe72e728f7226759 Mon Sep 17 00:00:00 2001 From: acai1998 Date: Sat, 28 Feb 2026 10:53:29 +0800 Subject: [PATCH 06/28] =?UTF-8?q?refactor:=20=E7=BB=9F=E4=B8=80Jenkins?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E6=97=A5=E5=BF=97=E6=A0=BC=E5=BC=8F=E5=B9=B6?= =?UTF-8?q?=E8=B0=83=E6=95=B4=E6=8E=A5=E5=8F=A3=E9=80=9F=E7=8E=87=E9=99=90?= =?UTF-8?q?=E5=88=B6=E9=A1=BA=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 防止日志格式字符串注入,统一使用固定格式字符串输出IP信息 - 将部分接口添加速率限制中间件,增强请求保护 - 调整认证路由中速率限制中间件顺序,确保未认证请求也受限 --- server/middleware/JenkinsAuthMiddleware.ts | 12 ++++++------ server/routes/auth.ts | 6 ++++-- server/routes/jenkins.ts | 16 ++++++++-------- 3 files changed, 18 insertions(+), 16 deletions(-) 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/routes/auth.ts b/server/routes/auth.ts index b10c123..5bf4ccb 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -84,7 +84,8 @@ router.post('/login', loginRateLimiter, async (req: Request, res: Response) => { }); // 用户登出 -router.post('/logout', authenticate, generalAuthRateLimiter, 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: '未认证' }); @@ -144,7 +145,8 @@ router.post('/reset-password', resetPasswordRateLimiter, async (req: Request, re }); // 获取当前用户信息 -router.get('/me', authenticate, generalAuthRateLimiter, 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: '未认证' }); diff --git a/server/routes/jenkins.ts b/server/routes/jenkins.ts index 4802e60..2b2f230 100644 --- a/server/routes/jenkins.ts +++ b/server/routes/jenkins.ts @@ -54,7 +54,7 @@ function sanitizeErrorMessage(error: unknown, context: string): string { * 此接口创建执行记录并返回 executionId,供 Jenkins 后续回调使用 * 实际触发 Jenkins Job 的逻辑需要在此处或由调用方完成 */ -router.post('/trigger', async (req: Request, res: Response) => { +router.post('/trigger', rateLimitMiddleware.limit, async (req: Request, res: Response) => { try { const triggerBody = (req.body ?? {}) as Record; const caseIds = triggerBody['caseIds']; @@ -313,7 +313,7 @@ router.post('/run-batch', [ * * Jenkins Job 可以调用此接口获取需要执行的用例信息 */ -router.get('/tasks/:taskId/cases', async (req: Request, res: Response) => { +router.get('/tasks/:taskId/cases', rateLimitMiddleware.limit, async (req: Request, res: Response) => { try { const taskId = parseInt(req.params.taskId); const cases = await executionService.getRunCases(taskId); @@ -334,7 +334,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', rateLimitMiddleware.limit, async (req: Request, res: Response) => { try { const executionId = parseInt(req.params.executionId); const detail = await executionService.getExecutionDetail(executionId); @@ -513,7 +513,7 @@ router.post('/callback', [ * GET /api/jenkins/batch/:runId * 获取执行批次详情 */ -router.get('/batch/:runId', async (req: Request, res: Response) => { +router.get('/batch/:runId', rateLimitMiddleware.limit, async (req: Request, res: Response) => { try { const runId = parseInt(req.params.runId); const batch = await executionService.getBatchExecution(runId); @@ -957,7 +957,7 @@ router.post('/callback/diagnose', * GET /api/jenkins/health * Jenkins 连接健康检查 - 包括详细的诊断信息 */ -router.get('/health', async (req: Request, res: Response) => { +router.get('/health', rateLimitMiddleware.limit, async (req: Request, res: Response) => { const startTime = Date.now(); try { @@ -1309,7 +1309,7 @@ router.get('/diagnose', * GET /api/jenkins/monitoring/stats * 获取监控统计信息 */ -router.get('/monitoring/stats', async (_req, res) => { +router.get('/monitoring/stats', rateLimitMiddleware.limit, async (_req, res) => { try { logger.info(`Getting monitoring statistics...`, {}, LOG_CONTEXTS.JENKINS); @@ -1373,7 +1373,7 @@ 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', rateLimitMiddleware.limit, async (req: Request, res: Response) => { try { const fixBody = (req.body ?? {}) as Record; const timeoutMinutes = typeof fixBody['timeoutMinutes'] === 'number' ? fixBody['timeoutMinutes'] : 5; @@ -1441,7 +1441,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', rateLimitMiddleware.limit, async (_req: Request, res: Response) => { try { const { executionMonitorService } = await import('../services/ExecutionMonitorService'); From c8ff228b42b30a9d726b97b398ec432f5cfaca14 Mon Sep 17 00:00:00 2001 From: acai1998 Date: Sat, 28 Feb 2026 11:23:43 +0800 Subject: [PATCH 07/28] =?UTF-8?q?feat:=20=E4=B8=BA=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E6=8E=A5=E5=8F=A3=E6=B7=BB=E5=8A=A0=E9=80=9A?= =?UTF-8?q?=E7=94=A8=E8=AE=A4=E8=AF=81=E9=99=90=E6=B5=81=E4=B8=AD=E9=97=B4?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routes/tasks.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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); From aae0d9bd03467fa4537fb06e5bf8d58da8cf0662 Mon Sep 17 00:00:00 2001 From: acai1998 Date: Sat, 28 Feb 2026 16:35:12 +0800 Subject: [PATCH 08/28] =?UTF-8?q?feat:=20=E4=B8=BA=20Jenkins=20=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=E7=BB=9F=E4=B8=80=E6=B7=BB=E5=8A=A0=E9=80=9A=E7=94=A8?= =?UTF-8?q?=E8=AE=A4=E8=AF=81=E9=99=90=E6=B5=81=E4=B8=AD=E9=97=B4=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routes/jenkins.ts | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/server/routes/jenkins.ts b/server/routes/jenkins.ts index 2b2f230..d745d7a 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'; @@ -54,7 +55,7 @@ function sanitizeErrorMessage(error: unknown, context: string): string { * 此接口创建执行记录并返回 executionId,供 Jenkins 后续回调使用 * 实际触发 Jenkins Job 的逻辑需要在此处或由调用方完成 */ -router.post('/trigger', rateLimitMiddleware.limit, async (req: Request, res: Response) => { +router.post('/trigger', generalAuthRateLimiter, rateLimitMiddleware.limit, async (req: Request, res: Response) => { try { const triggerBody = (req.body ?? {}) as Record; const caseIds = triggerBody['caseIds']; @@ -99,6 +100,7 @@ router.post('/trigger', rateLimitMiddleware.limit, async (req: Request, res: Res * 触发单个用例执行 */ router.post('/run-case', [ + generalAuthRateLimiter, rateLimitMiddleware.limit, requestValidator.validateSingleExecution ], async (req: Request, res: Response) => { @@ -205,6 +207,7 @@ router.post('/run-case', [ * 触发批量用例执行 */ router.post('/run-batch', [ + generalAuthRateLimiter, rateLimitMiddleware.limit, requestValidator.validateBatchExecution ], async (req: Request, res: Response) => { @@ -313,7 +316,7 @@ router.post('/run-batch', [ * * Jenkins Job 可以调用此接口获取需要执行的用例信息 */ -router.get('/tasks/:taskId/cases', rateLimitMiddleware.limit, 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); @@ -334,7 +337,7 @@ router.get('/tasks/:taskId/cases', rateLimitMiddleware.limit, async (req: Reques * * 用于查询 Jenkins Job 的执行状态 */ -router.get('/status/:executionId', rateLimitMiddleware.limit, 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); @@ -375,6 +378,7 @@ router.get('/status/:executionId', rateLimitMiddleware.limit, async (req: Reques * 通过 IP 白名单验证,无需额外认证 */ router.post('/callback', [ + generalAuthRateLimiter, ipWhitelistMiddleware.verify, rateLimitMiddleware.limit, requestValidator.validateCallback @@ -513,7 +517,7 @@ router.post('/callback', [ * GET /api/jenkins/batch/:runId * 获取执行批次详情 */ -router.get('/batch/:runId', rateLimitMiddleware.limit, 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); @@ -536,6 +540,7 @@ router.get('/batch/:runId', rateLimitMiddleware.limit, async (req: Request, res: * 通过 IP 白名单验证 */ router.post('/callback/test', [ + generalAuthRateLimiter, ipWhitelistMiddleware.verify, rateLimitMiddleware.limit ], async (req: Request, res: Response) => { @@ -717,6 +722,7 @@ router.post('/callback/test', [ * 通过 IP 白名单验证 */ router.post('/callback/manual-sync/:runId', [ + generalAuthRateLimiter, ipWhitelistMiddleware.verify, rateLimitMiddleware.limit ], async (req: Request, res: Response) => { @@ -873,6 +879,7 @@ router.post('/callback/manual-sync/:runId', [ * 诊断回调连接问题 - 通过 IP 白名单验证以保护系统信息 */ router.post('/callback/diagnose', + generalAuthRateLimiter, rateLimitMiddleware.limit, ipWhitelistMiddleware.verify, async (req: Request, res: Response) => { @@ -957,7 +964,7 @@ router.post('/callback/diagnose', * GET /api/jenkins/health * Jenkins 连接健康检查 - 包括详细的诊断信息 */ -router.get('/health', rateLimitMiddleware.limit, async (req: Request, res: Response) => { +router.get('/health', generalAuthRateLimiter, rateLimitMiddleware.limit, async (req: Request, res: Response) => { const startTime = Date.now(); try { @@ -1143,6 +1150,7 @@ router.get('/health', rateLimitMiddleware.limit, async (req: Request, res: Respo * 诊断执行问题 - 通过 IP 白名单验证以保护系统信息 */ router.get('/diagnose', + generalAuthRateLimiter, rateLimitMiddleware.limit, ipWhitelistMiddleware.verify, async (req: Request, res: Response) => { @@ -1309,7 +1317,7 @@ router.get('/diagnose', * GET /api/jenkins/monitoring/stats * 获取监控统计信息 */ -router.get('/monitoring/stats', rateLimitMiddleware.limit, async (_req, res) => { +router.get('/monitoring/stats', generalAuthRateLimiter, rateLimitMiddleware.limit, async (_req, res) => { try { logger.info(`Getting monitoring statistics...`, {}, LOG_CONTEXTS.JENKINS); @@ -1373,7 +1381,7 @@ router.get('/monitoring/stats', rateLimitMiddleware.limit, async (_req, res) => * POST /api/jenkins/monitoring/fix-stuck * 修复卡住的执行 */ -router.post('/monitoring/fix-stuck', rateLimitMiddleware.limit, async (req: Request, res: Response) => { +router.post('/monitoring/fix-stuck', generalAuthRateLimiter, rateLimitMiddleware.limit, async (req: Request, res: Response) => { try { const fixBody = (req.body ?? {}) as Record; const timeoutMinutes = typeof fixBody['timeoutMinutes'] === 'number' ? fixBody['timeoutMinutes'] : 5; @@ -1441,7 +1449,7 @@ router.post('/monitoring/fix-stuck', rateLimitMiddleware.limit, async (req: Requ * GET /api/jenkins/monitor/status * Get execution monitor service status and statistics */ -router.get('/monitor/status', rateLimitMiddleware.limit, async (_req: Request, res: Response) => { +router.get('/monitor/status', generalAuthRateLimiter, rateLimitMiddleware.limit, async (_req: Request, res: Response) => { try { const { executionMonitorService } = await import('../services/ExecutionMonitorService'); From 3cf27ce39a48bf5876e6da923e98445040e4906e Mon Sep 17 00:00:00 2001 From: acai1998 Date: Thu, 5 Mar 2026 11:01:12 +0800 Subject: [PATCH 09/28] =?UTF-8?q?chore:=20=E5=88=A0=E9=99=A4=20Jenkinsfile?= =?UTF-8?q?.jenkins-ui=20=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Jenkinsfile.jenkins-ui | 349 ----------------------------------------- 1 file changed, 349 deletions(-) delete mode 100644 Jenkinsfile.jenkins-ui 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 "失败回调请求失败,但继续处理" - """ - } - } - } - } -} From d5601e446b5cb95b38689edf16434a3928779c1f Mon Sep 17 00:00:00 2001 From: acai1998 Date: Thu, 5 Mar 2026 16:02:31 +0800 Subject: [PATCH 10/28] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20PM2=20?= =?UTF-8?q?=E7=94=9F=E4=BA=A7=E9=83=A8=E7=BD=B2=E9=85=8D=E7=BD=AE=E5=92=8C?= =?UTF-8?q?=E7=83=AD=E9=83=A8=E7=BD=B2=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ecosystem.config.js | 63 +++++ package.json | 7 + scripts/deploy.sh | 567 +++++++--------------------------------- scripts/setup-server.sh | 145 ++++++++++ 4 files changed, 306 insertions(+), 476 deletions(-) create mode 100644 ecosystem.config.js create mode 100644 scripts/setup-server.sh diff --git a/ecosystem.config.js b/ecosystem.config.js new file mode 100644 index 0000000..5d47f61 --- /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 文件 + script: 'dist/server/index.js', + // 需要 tsconfig-paths 来解析路径别名(@shared/* 等) + // TS_NODE_PROJECT 指定使用后端专属的 tsconfig,确保路径别名正确解析 + node_args: '-r tsconfig-paths/register', + 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/package.json b/package.json index 6409bcd..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", diff --git a/scripts/deploy.sh b/scripts/deploy.sh index cf68cef..653bf34 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -1,486 +1,101 @@ #!/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:构建后端(TypeScript 编译)" +npm run server:build +log_info "后端编译完成 → dist/server/" + +echo "" +log_step "步骤 3/4:构建前端(Vite 打包)" +npm run build +log_info "前端构建完成 → dist/" + +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/setup-server.sh b/scripts/setup-server.sh new file mode 100644 index 0000000..d67505c --- /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 "编译后端 TypeScript..." +npm run server:build + +log_info "构建前端..." +npm run 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 # 代码更新后热部署" From cd373d91a05bab36c9d4dbdfe1124b4830617d6b Mon Sep 17 00:00:00 2001 From: acai1998 Date: Thu, 5 Mar 2026 16:05:38 +0800 Subject: [PATCH 11/28] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DDocker=E6=9E=84?= =?UTF-8?q?=E5=BB=BA=E4=B8=ADnpm=20ci=E5=A4=B1=E8=B4=A5=E5=AF=BC=E8=87=B4T?= =?UTF-8?q?ypeScript=E7=BC=BA=E5=A4=B1=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在Dockerfile中添加npm install回退机制,确保依赖安装成功,解决构建过程中TypeScript未安装导致的编译失败。删除相关冗余文档和旧Jenkins流水线配置。 --- .gitignore | 1 + DOCKER_BUILD_FIX.md | 118 ------------ DOCKER_BUILD_TEST.md | 215 --------------------- Jenkinsfile.bak | 419 ----------------------------------------- Jenkinsfile.final | 335 -------------------------------- QUICK_FIX_REFERENCE.md | 14 -- 6 files changed, 1 insertion(+), 1101 deletions(-) delete mode 100644 DOCKER_BUILD_FIX.md delete mode 100644 DOCKER_BUILD_TEST.md delete mode 100644 Jenkinsfile.bak delete mode 100644 Jenkinsfile.final delete mode 100644 QUICK_FIX_REFERENCE.md diff --git a/.gitignore b/.gitignore index ef3cc8f..62e709b 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ __pycache__/ .venv/ .env .env.example +.mrules # 规则忽略文件 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_TEST.md b/DOCKER_BUILD_TEST.md deleted file mode 100644 index 9801be1..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 基础镜像版本 \ No newline at end of file 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/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 From acccab8041d409c7f0b0623141bbf4871a994d3f Mon Sep 17 00:00:00 2001 From: acai1998 Date: Thu, 5 Mar 2026 16:17:30 +0800 Subject: [PATCH 12/28] =?UTF-8?q?docs:=20=E5=88=A0=E9=99=A4=20Docker=20Sec?= =?UTF-8?q?rets=20=E5=92=8C=20Docker=20Swarm=20=E9=83=A8=E7=BD=B2=E6=8C=87?= =?UTF-8?q?=E5=8D=97=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deployment/DOCKER_SECRET_GUIDE.md | 298 ----------------------------- deployment/DOCKER_SWARM_DEPLOY.md | 306 ------------------------------ 2 files changed, 604 deletions(-) delete mode 100644 deployment/DOCKER_SECRET_GUIDE.md delete mode 100644 deployment/DOCKER_SWARM_DEPLOY.md 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` From 4f7b91336ee490189c2d17191583551ee4f0fa4a Mon Sep 17 00:00:00 2001 From: acai1998 Date: Thu, 5 Mar 2026 17:17:40 +0800 Subject: [PATCH 13/28] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=E7=BC=96?= =?UTF-8?q?=E8=AF=91=E8=BE=93=E5=87=BA=E8=B7=AF=E5=BE=84=20dist/server/ser?= =?UTF-8?q?ver/index.js?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ecosystem.config.js | 2 +- scripts/deploy.sh | 2 +- server/index.ts | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/ecosystem.config.js b/ecosystem.config.js index 5d47f61..f764356 100644 --- a/ecosystem.config.js +++ b/ecosystem.config.js @@ -7,7 +7,7 @@ module.exports = { // ─── 应用基础配置 ───────────────────────────────────────── name: 'autotest-platform', // 生产模式:运行编译后的 JS 文件 - script: 'dist/server/index.js', + script: 'dist/server/server/index.js', // 需要 tsconfig-paths 来解析路径别名(@shared/* 等) // TS_NODE_PROJECT 指定使用后端专属的 tsconfig,确保路径别名正确解析 node_args: '-r tsconfig-paths/register', diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 653bf34..c315ba7 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -66,7 +66,7 @@ log_info "依赖安装完成" echo "" log_step "步骤 2/4:构建后端(TypeScript 编译)" npm run server:build -log_info "后端编译完成 → dist/server/" +log_info "后端编译完成 → dist/server/server/" echo "" log_step "步骤 3/4:构建前端(Vite 打包)" diff --git a/server/index.ts b/server/index.ts index 8a4b365..34c7498 100644 --- a/server/index.ts +++ b/server/index.ts @@ -145,7 +145,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)); From 110900ee850dba7ea9419720d17d495ec0671417 Mon Sep 17 00:00:00 2001 From: acai1998 Date: Thu, 5 Mar 2026 17:34:28 +0800 Subject: [PATCH 14/28] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=E6=9E=84?= =?UTF-8?q?=E5=BB=BA=E9=A1=BA=E5=BA=8F=EF=BC=8C=E5=85=88=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E5=90=8E=E5=90=8E=E7=AB=AF=E9=81=BF=E5=85=8D=20vite=20?= =?UTF-8?q?=E6=B8=85=E7=A9=BA=20dist/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/deploy.sh | 14 ++++++++------ scripts/setup-server.sh | 6 +++--- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/scripts/deploy.sh b/scripts/deploy.sh index c315ba7..268f2f0 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -64,15 +64,17 @@ npm install --production=false log_info "依赖安装完成" echo "" -log_step "步骤 2/4:构建后端(TypeScript 编译)" -npm run server:build -log_info "后端编译完成 → dist/server/server/" - -echo "" -log_step "步骤 3/4:构建前端(Vite 打包)" +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:热重载应用(零停机)" diff --git a/scripts/setup-server.sh b/scripts/setup-server.sh index d67505c..baa1a26 100644 --- a/scripts/setup-server.sh +++ b/scripts/setup-server.sh @@ -119,12 +119,12 @@ 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 "构建前端..." -npm run build - log_info "启动应用..." pm2 start ecosystem.config.js --env production From b0d27c8cfa1231eba2a025995578bb636ffc9df4 Mon Sep 17 00:00:00 2001 From: acai1998 Date: Fri, 6 Mar 2026 17:32:33 +0800 Subject: [PATCH 15/28] =?UTF-8?q?fix:=20=E6=9B=B4=E6=96=B0=E7=94=9F?= =?UTF-8?q?=E4=BA=A7=E7=8E=AF=E5=A2=83=E9=85=8D=E7=BD=AE=E4=B8=BA=E5=9F=9F?= =?UTF-8?q?=E5=90=8D=E8=AE=BF=E9=97=AE=E5=9C=B0=E5=9D=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deployment/.env.production | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/deployment/.env.production b/deployment/.env.production index fce4e79..f62dd2d 100644 --- a/deployment/.env.production +++ b/deployment/.env.production @@ -34,7 +34,7 @@ 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 +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 From 4873df91e2222d1fd3334185b63ecbc8f02ce21f Mon Sep 17 00:00:00 2001 From: acai1998 Date: Fri, 6 Mar 2026 18:18:44 +0800 Subject: [PATCH 16/28] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=20PM2=20?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=E8=B7=AF=E5=BE=84=EF=BC=8Cdist/server/server?= =?UTF-8?q?=20=E2=86=92=20dist/server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ecosystem.config.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/ecosystem.config.js b/ecosystem.config.js index f764356..a045576 100644 --- a/ecosystem.config.js +++ b/ecosystem.config.js @@ -6,11 +6,9 @@ module.exports = { { // ─── 应用基础配置 ───────────────────────────────────────── name: 'autotest-platform', - // 生产模式:运行编译后的 JS 文件 - script: 'dist/server/server/index.js', - // 需要 tsconfig-paths 来解析路径别名(@shared/* 等) - // TS_NODE_PROJECT 指定使用后端专属的 tsconfig,确保路径别名正确解析 - node_args: '-r tsconfig-paths/register', + // 生产模式:运行编译后的 JS 文件(tsconfig.server.json outDir=dist/server) + script: 'node', + args: '-r tsconfig-paths/register dist/server/index.js', cwd: '/www/wwwroot/autotest.wiac.xyz', // ─── 运行模式 ───────────────────────────────────────────── From a92151d4ab1e22ce76455714e2b4f34976f2aa6d Mon Sep 17 00:00:00 2001 From: acai1998 Date: Sat, 7 Mar 2026 23:11:34 +0800 Subject: [PATCH 17/28] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=20PM2=20?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E8=B7=AF=E5=BE=84=E4=B8=BA=20dist/server/ser?= =?UTF-8?q?ver/index.js?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ecosystem.config.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ecosystem.config.js b/ecosystem.config.js index a045576..c105992 100644 --- a/ecosystem.config.js +++ b/ecosystem.config.js @@ -6,9 +6,11 @@ module.exports = { { // ─── 应用基础配置 ───────────────────────────────────────── name: 'autotest-platform', - // 生产模式:运行编译后的 JS 文件(tsconfig.server.json outDir=dist/server) + // 生产模式:运行编译后的 JS 文件 + // tsconfig.server.json: outDir=dist/server,源码在 server/ 目录 + // 所以编译产物实际路径是 dist/server/server/index.js script: 'node', - args: '-r tsconfig-paths/register dist/server/index.js', + args: '-r tsconfig-paths/register dist/server/server/index.js', cwd: '/www/wwwroot/autotest.wiac.xyz', // ─── 运行模式 ───────────────────────────────────────────── From 40ad11c63749d43979fd707ee65e411bbd1dd962 Mon Sep 17 00:00:00 2001 From: acai1998 Date: Sat, 7 Mar 2026 23:12:01 +0800 Subject: [PATCH 18/28] =?UTF-8?q?chore:=20=E5=B0=86.catpaw=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E6=B7=BB=E5=8A=A0=E5=88=B0.gitignore=E4=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 62e709b..3cb4a90 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ __pycache__/ .env .env.example .mrules +.catpaw/ # 规则忽略文件 CLAUDE.md From 30dddd08a78efaaad09457fccb22044a94c5b611 Mon Sep 17 00:00:00 2001 From: acai1998 Date: Sat, 7 Mar 2026 23:23:35 +0800 Subject: [PATCH 19/28] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=E7=94=9F?= =?UTF-8?q?=E4=BA=A7=E7=8E=AF=E5=A2=83=20TypeORM=20=E5=AE=9E=E4=BD=93?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=EF=BC=88dist/server/server/entities=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/config/dataSource.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) 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')]; } /** From 9dbc43783857648c28a79886632e5bdef863b2b9 Mon Sep 17 00:00:00 2001 From: acai1998 Date: Sat, 7 Mar 2026 23:31:05 +0800 Subject: [PATCH 20/28] =?UTF-8?q?fix:=20=E6=B7=BB=E5=8A=A0=20trust=20proxy?= =?UTF-8?q?=20=E9=85=8D=E7=BD=AE=EF=BC=8C=E4=BF=AE=E5=A4=8D=20Nginx=20?= =?UTF-8?q?=E5=8F=8D=E5=90=91=E4=BB=A3=E7=90=86=E4=B8=8B=20rate-limit=20?= =?UTF-8?q?=E8=AD=A6=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/index.ts b/server/index.ts index 34c7498..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' })); From 90a946050b9435ed2d63d0f2ff7dc5bcc4b5d4b5 Mon Sep 17 00:00:00 2001 From: acai1998 Date: Sat, 7 Mar 2026 23:43:23 +0800 Subject: [PATCH 21/28] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20Jenkinsfile?= =?UTF-8?q?=20=E5=AE=9A=E6=97=B6=E8=A7=A6=E5=8F=91=E6=8A=A5=E9=94=99?= =?UTF-8?q?=E5=8F=8A=E7=A1=AC=E7=BC=96=E7=A0=81=20test-cases=20=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Jenkinsfile | 78 ++++++++++++++++++++++++++++++++++------------------- 1 file changed, 50 insertions(+), 28 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 1c65127..6c6ea1b 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" } @@ -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 - ''' + """ } } } @@ -140,8 +156,14 @@ pipeline { script { echo "回调测试结果到平台..." - sh ''' - cd test-cases + if (!params.RUN_ID && !params.CALLBACK_URL) { + echo "⚠️ 未传入 RUN_ID 或 CALLBACK_URL,跳过回调" + return + } + + def testDir = params.REPO_URL ? 'test-cases' : '.' + sh """ + cd ${testDir} # 解析测试结果(示例) if [ -f "test-report.json" ]; then @@ -189,7 +211,7 @@ pipeline { }" \ || echo "回调请求失败,但继续处理" fi - ''' + """ } } } From 717cb0a4770ebd9541e7b84b52f16e6e328294dc Mon Sep 17 00:00:00 2001 From: acai1998 Date: Sun, 8 Mar 2026 00:04:10 +0800 Subject: [PATCH 22/28] =?UTF-8?q?docs:=20=E5=88=A0=E9=99=A4=E8=BF=87?= =?UTF-8?q?=E6=97=B6=E6=96=87=E6=A1=A3=EF=BC=8C=E6=B8=85=E7=90=86=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E5=86=97=E4=BD=99=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ABOUT.md | 42 -- docs/CALLBACK_FIX_DIAGNOSTIC.md | 346 ---------- docs/EXECUTION_MONITOR_OPTIMIZATION.md | 319 --------- docs/IMPLEMENTATION_SUMMARY.md | 288 -------- docs/JENKINS_CALLBACK_IMPROVEMENTS.md | 276 -------- docs/Jenkins/JENKINSFILE_COMPARISON.md | 537 --------------- docs/Jenkins/JENKINSFILE_FILEPATH_FIX.md | 482 -------------- docs/Jenkins/JENKINSFILE_NODE_FIX.md | 444 ------------- docs/Jenkins/JENKINSFILE_OPTIMIZATION.md | 629 ------------------ docs/Jenkins/JENKINSFILE_QUICK_FIX.md | 324 --------- docs/Jenkins/JENKINS_JENKINSFILE_FIX.md | 364 ---------- docs/Jenkins/JENKINS_PIPELINE_GUIDE.md | 175 ----- docs/MONITOR_OPTIMIZATION.md | 416 ------------ docs/OPTIMIZATION_SUMMARY.md | 305 --------- docs/START_HERE.md | 309 --------- docs/TypeORM-Migration-Summary.md | 386 ----------- docs/WEBSOCKET_TEST_GUIDE.md | 488 -------------- .../components/ThemeToggle-Animation-Guide.md | 367 ---------- scripts/ai_refactor.ts | 159 ----- scripts/clear-vscode-cache.sh | 36 - scripts/deploy-aliyun.sh | 287 -------- scripts/health-check.sh | 565 ---------------- scripts/rollback.sh | 627 ----------------- src/lib/api.ts | 17 - 24 files changed, 8188 deletions(-) delete mode 100644 docs/ABOUT.md delete mode 100644 docs/CALLBACK_FIX_DIAGNOSTIC.md delete mode 100644 docs/EXECUTION_MONITOR_OPTIMIZATION.md delete mode 100644 docs/IMPLEMENTATION_SUMMARY.md delete mode 100644 docs/JENKINS_CALLBACK_IMPROVEMENTS.md delete mode 100644 docs/Jenkins/JENKINSFILE_COMPARISON.md delete mode 100644 docs/Jenkins/JENKINSFILE_FILEPATH_FIX.md delete mode 100644 docs/Jenkins/JENKINSFILE_NODE_FIX.md delete mode 100644 docs/Jenkins/JENKINSFILE_OPTIMIZATION.md delete mode 100644 docs/Jenkins/JENKINSFILE_QUICK_FIX.md delete mode 100644 docs/Jenkins/JENKINS_JENKINSFILE_FIX.md delete mode 100644 docs/Jenkins/JENKINS_PIPELINE_GUIDE.md delete mode 100644 docs/MONITOR_OPTIMIZATION.md delete mode 100644 docs/OPTIMIZATION_SUMMARY.md delete mode 100644 docs/START_HERE.md delete mode 100644 docs/TypeORM-Migration-Summary.md delete mode 100644 docs/WEBSOCKET_TEST_GUIDE.md delete mode 100644 docs/components/ThemeToggle-Animation-Guide.md delete mode 100644 scripts/ai_refactor.ts delete mode 100755 scripts/clear-vscode-cache.sh delete mode 100755 scripts/deploy-aliyun.sh delete mode 100755 scripts/health-check.sh delete mode 100755 scripts/rollback.sh delete mode 100644 src/lib/api.ts 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/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/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/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 From 4c486c08195c7b7b48b1288205b691a31ba6a2da Mon Sep 17 00:00:00 2001 From: acai1998 Date: Sun, 8 Mar 2026 00:05:25 +0800 Subject: [PATCH 23/28] =?UTF-8?q?fix(Jenkinsfile):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=87=86=E5=A4=87=E9=98=B6=E6=AE=B5=E5=8F=98=E9=87=8F=E6=9C=AA?= =?UTF-8?q?=E5=B1=95=E5=BC=80=E5=8F=8A=E5=9B=9E=E8=B0=83URL=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Jenkinsfile | 48 ++++++++++++++++++++---------------------------- 1 file changed, 20 insertions(+), 28 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 6c6ea1b..a71e9c9 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -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 '⚠️ 标记执行开始失败,继续处理' + """ } } } @@ -298,6 +298,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 @@ -309,17 +311,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 "✅ 回调成功" @@ -346,20 +344,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 "失败回调请求失败,但继续处理" """ } From 5a1f28aadd440540649391e6f590748971205c44 Mon Sep 17 00:00:00 2001 From: acai1998 Date: Sun, 8 Mar 2026 00:10:49 +0800 Subject: [PATCH 24/28] =?UTF-8?q?fix:=20=E6=B7=BB=E5=8A=A0=20Jenkins=20?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=99=A8=20IP(162.14.123.200)=20=E5=88=B0?= =?UTF-8?q?=E5=9B=9E=E8=B0=83=E7=99=BD=E5=90=8D=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deployment/.env.production | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deployment/.env.production b/deployment/.env.production index f62dd2d..4fb3099 100644 --- a/deployment/.env.production +++ b/deployment/.env.production @@ -33,7 +33,7 @@ JENKINS_JOB_UI=ui-automation JENKINS_JOB_PERF=performance-automation # 网络配置 -JENKINS_ALLOWED_IPS=localhost,127.0.0.1,jenkins.wiac.xyz +JENKINS_ALLOWED_IPS=localhost,127.0.0.1,jenkins.wiac.xyz,162.14.123.200 API_CALLBACK_URL=http://autotest.wiac.xyz # 调试配置 From 45f2ba5b412e74e1b3cfd63e553bd1dd230b7200 Mon Sep 17 00:00:00 2001 From: acai1998 Date: Sun, 8 Mar 2026 00:18:22 +0800 Subject: [PATCH 25/28] =?UTF-8?q?fix(Jenkinsfile):=20=E4=BF=AE=E5=A4=8D=20?= =?UTF-8?q?Groovy=20=E8=AF=AD=E6=B3=95=E9=94=99=E8=AF=AF=EF=BC=8C=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=20sh=20=E5=8F=8C=E5=BC=95=E5=8F=B7=E5=9D=97=E4=B8=AD?= =?UTF-8?q?=E7=9A=84=20shell=20=E5=8F=98=E9=87=8F=E5=86=B2=E7=AA=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Jenkinsfile | 62 +++-------------------------------------------------- 1 file changed, 3 insertions(+), 59 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index a71e9c9..ad1414c 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -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 未设置,跳过代码检出" @@ -154,64 +154,8 @@ pipeline { stage('回调平台') { steps { script { - echo "回调测试结果到平台..." - - if (!params.RUN_ID && !params.CALLBACK_URL) { - echo "⚠️ 未传入 RUN_ID 或 CALLBACK_URL,跳过回调" - return - } - - def testDir = params.REPO_URL ? 'test-cases' : '.' - sh """ - cd ${testDir} - - # 解析测试结果(示例) - 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 阶段统一处理" } } } From 5db020440bc48e14074baaa0d8e713c8e228a06d Mon Sep 17 00:00:00 2001 From: acai1998 Date: Sun, 8 Mar 2026 00:34:53 +0800 Subject: [PATCH 26/28] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E9=80=9A?= =?UTF-8?q?=E8=BF=87=20taskId=20=E8=A7=A6=E5=8F=91=20Jenkins=20=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E6=89=A7=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增通过 taskId 获取任务信息及用例列表的功能,完善 Jenkins 触发接口和执行服务,修正任务执行时间排序逻辑,更新 favicon 版本号。 --- index.html | 3 +- server/repositories/DashboardRepository.ts | 5 ++- server/repositories/ExecutionRepository.ts | 6 ++-- server/routes/jenkins.ts | 41 +++++++++++++++++++--- server/services/ExecutionService.ts | 4 +++ 5 files changed, 49 insertions(+), 10 deletions(-) diff --git a/index.html b/index.html index a1aec44..4b0ee06 100644 --- a/index.html +++ b/index.html @@ -2,7 +2,8 @@ - + + 自动化测试平台 diff --git a/server/repositories/DashboardRepository.ts b/server/repositories/DashboardRepository.ts index c40e1bd..487c57d 100644 --- a/server/repositories/DashboardRepository.ts +++ b/server/repositories/DashboardRepository.ts @@ -458,7 +458,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 +466,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[]; 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/jenkins.ts b/server/routes/jenkins.ts index d745d7a..8852c0f 100644 --- a/server/routes/jenkins.ts +++ b/server/routes/jenkins.ts @@ -53,30 +53,63 @@ function sanitizeErrorMessage(error: unknown, context: string): string { * 触发 Jenkins Job 执行 * * 此接口创建执行记录并返回 executionId,供 Jenkins 后续回调使用 - * 实际触发 Jenkins Job 的逻辑需要在此处或由调用方完成 + * 支持两种模式: + * 1. 直接传入 caseIds 数组 + * 2. 传入 taskId,自动从数据库查找任务的 caseIds 和任务名称 */ router.post('/trigger', generalAuthRateLimiter, rateLimitMiddleware.limit, async (req: Request, res: Response) => { try { const triggerBody = (req.body ?? {}) as Record; - const caseIds = triggerBody['caseIds']; + 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({ 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 回调) From cb46df9ed362cfde9a6b988125d96d0f840cc53f Mon Sep 17 00:00:00 2001 From: acai1998 Date: Sun, 8 Mar 2026 00:45:53 +0800 Subject: [PATCH 27/28] =?UTF-8?q?style:=20=E4=BC=98=E5=8C=96favicon?= =?UTF-8?q?=E5=9B=BE=E6=A0=87=E5=BC=95=E7=94=A8=E6=96=B9=E5=BC=8F=EF=BC=8C?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E7=89=88=E6=9C=AC=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- index.html | 3 ++- public/favicon.ico | Bin 0 -> 202403 bytes 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 public/favicon.ico diff --git a/index.html b/index.html index 4b0ee06..68a8afd 100644 --- a/index.html +++ b/index.html @@ -2,8 +2,9 @@ + + - 自动化测试平台 diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..a1841bde99196835175dfa0403b4604c3ad9a879 GIT binary patch literal 202403 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

rp>cV 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 R5FG#j002ovPDHLkV1h9~zjOcq literal 0 HcmV?d00001 From 1a483b53cadb08405cdf08c934c0afcfbc9ec6d4 Mon Sep 17 00:00:00 2001 From: acai1998 Date: Sun, 8 Mar 2026 00:52:24 +0800 Subject: [PATCH 28/28] =?UTF-8?q?fix(dashboard):=20=E4=BB=8A=E6=97=A5?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E7=BB=9F=E8=AE=A1=E6=94=B9=E4=BB=8E=20Auto?= =?UTF-8?q?=5FTestRun=20=E8=A1=A8=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/repositories/DashboardRepository.ts | 24 +++++++++++++--------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/server/repositories/DashboardRepository.ts b/server/repositories/DashboardRepository.ts index 487c57d..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, { @@ -673,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 @@ -735,7 +739,7 @@ export class DashboardRepository extends BaseRepository { avgDuration: string; } - // 计算当日统计 + // 计算当日统计 - 使用 Auto_TestRun 表,duration_ms 转换为秒 const stats = await this.taskExecutionRepository.query(` SELECT COUNT(*) as totalExecutions, @@ -743,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(`