From efd9544a618c10a7d294dc8cac586c3b07d1d80b Mon Sep 17 00:00:00 2001 From: acai1998 Date: Sun, 15 Feb 2026 00:29:08 +0800 Subject: [PATCH 01/14] =?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/14] 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/14] =?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/14] =?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/14] =?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/14] =?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/14] =?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/14] =?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/14] =?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/14] =?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/14] =?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/14] =?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/14] =?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/14] =?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