From 261c45b4d9523568aa9b37fdda4d2c9d27407a4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E7=BF=94?= Date: Mon, 9 Mar 2026 11:12:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=80=A7=E8=83=BD?= =?UTF-8?q?=E5=92=8C=E5=AE=89=E5=85=A8=E6=B5=8B=E8=AF=95=EF=BC=8C=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E9=83=A8=E7=BD=B2=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 阶段五:性能和安全测试 - 创建负载测试脚本:模拟正常用户访问模式 - 创建压力测试脚本:测试系统极限性能 - 创建SQL注入测试脚本:验证SQL注入防护 - 创建XSS防护测试脚本:验证XSS防护 - 添加测试脚本到package.json 阶段六:文档和培训 - 更新README.md:添加监控和告警文档 - 添加性能测试文档和命令 - 添加安全测试文档和命令 - 添加Docker部署文档 - 添加生产环境配置文档 - 添加备份和恢复文档 --- README.md | 288 ++++++++++++++++++++++++++- package-lock.json | 8 + package.json | 6 + tests/performance/load-test.js | 55 +++++ tests/performance/stress-test.js | 67 +++++++ tests/security/sql-injection-test.js | 83 ++++++++ tests/security/xss-test.js | 98 +++++++++ 7 files changed, 603 insertions(+), 2 deletions(-) create mode 100644 tests/performance/load-test.js create mode 100644 tests/performance/stress-test.js create mode 100644 tests/security/sql-injection-test.js create mode 100644 tests/security/xss-test.js diff --git a/README.md b/README.md index 2f0805b..fafd460 100644 --- a/README.md +++ b/README.md @@ -311,10 +311,294 @@ npm run test ## CI/CD -项目使用 Woodpecker CI 进行持续集成,配置文件为 `.woodpecker.yml`。 +项目使用 Woodpecker CI 进行持续集成,配置文件为 `.woodpecker/` 目录。 CI 流水线包括: -- E2E 测试(全量、冒烟、回归、性能、响应式、视觉) +- **CI 工作流** (`.woodpecker/ci.yml`) - 代码检查、测试、构建 +- **部署工作流** (`.woodpecker/deploy.yml`) - 生产环境部署 +- **质量门禁** (`.woodpecker/quality-gate.yml`) - 代码质量检查 + +### CI 触发条件 + +- 分支:`main`、`develop` +- 事件:`push`、`pull_request` + +### 质量门禁标准 + +- ESLint 检查通过 +- TypeScript 类型检查通过 +- 单元测试覆盖率 ≥ 70% +- E2E 测试通过率 ≥ 95% + +## 监控和告警 + +### Sentry 错误监控 + +项目集成了 Sentry 错误监控,用于追踪生产环境中的错误。 + +**配置环境变量:** + +```env +NEXT_PUBLIC_SENTRY_DSN=https://xxx@xxx.ingest.sentry.io/xxx +``` + +**监控内容:** +- JavaScript 错误 +- API 错误 +- 性能追踪 +- 用户会话回放 + +### 健康检查 + +健康检查 API:`GET /api/health` + +**返回信息:** +- 应用状态 +- 运行时间 +- 内存使用 +- 数据库连接状态 +- 请求统计 + +### 性能监控 + +项目内置性能监控工具,记录关键指标: +- 响应时间(平均值、P50、P95、P99) +- 请求计数 +- 内存使用率 + +**查看性能数据:** + +```bash +curl http://localhost:3000/api/health +``` + +## 备份和恢复 + +### 备份 + +使用备份脚本定期备份数据: + +```bash +./scripts/backup.sh +``` + +**备份内容包括:** +- SQLite 数据库文件 +- 上传文件 +- 环境配置 + +**备份文件位置:** `./backups/backup_YYYYMMDD_HHMMSS.tar.gz` + +**自动清理:** 保留最近 7 天的备份 + +### 恢复 + +使用恢复脚本从备份中恢复数据: + +```bash +./scripts/restore.sh +``` + +**注意事项:** +- 恢复操作会覆盖当前数据 +- 恢复后需要重启应用 +- 建议在恢复前先备份当前数据 + +## 性能测试 + +项目使用 k6 进行性能测试。 + +### 负载测试 + +模拟正常用户访问模式,测试系统在预期负载下的表现。 + +```bash +npm run test:performance +``` + +**测试场景:** +- 逐步增加用户数(100 → 200) +- 访问主要页面 +- 提交联系表单 + +**性能指标:** +- P95 响应时间 < 500ms +- P99 响应时间 < 1000ms +- 错误率 < 1% + +### 压力测试 + +测试系统在极端负载下的表现和极限。 + +```bash +npm run test:stress +``` + +**测试场景:** +- 快速增加用户数(50 → 300) +- 持续高负载 +- 快速下降 + +**性能指标:** +- P95 响应时间 < 1000ms +- P99 响应时间 < 2000ms +- 错误率 < 5% + +**测试报告:** `tests/performance/load-test-summary.json` + +## 安全测试 + +项目使用 k6 进行安全测试。 + +### SQL 注入测试 + +测试系统对 SQL 注入攻击的防护能力。 + +```bash +npm run test:sql-injection +``` + +**测试内容:** +- 常见 SQL 注入 payload +- UNION 查询注入 +- 盲注攻击 +- 时间注入 + +**防护措施:** +- 使用参数化查询(Drizzle ORM) +- 输入验证和过滤 +- 错误信息脱敏 + +### XSS 防护测试 + +测试系统对跨站脚本攻击的防护能力。 + +```bash +npm run test:xss +``` + +**测试内容:** +- Script 标签注入 +- 事件处理器注入 +- JavaScript 伪协议 +- 外部资源引用 + +**防护措施:** +- 输入转义(DOMPurify) +- CSP 策略 +- HTTPOnly Cookie + +### 完整安全测试 + +运行所有安全测试: + +```bash +npm run test:security +``` + +## Docker 部署 + +项目提供 Docker 部署方案。 + +### 构建镜像 + +```bash +docker build -t novalon-website . +``` + +### 使用 Docker Compose + +```bash +cp .env.production.example .env.production +docker-compose -f docker-compose.prod.yml up -d +``` + +### 健康检查 + +Docker 容器配置了健康检查: + +```yaml +healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s +``` + +## 生产环境配置 + +### 环境变量 + +生产环境需要配置以下变量: + +```env +# 数据库 +DATABASE_URL=file:./data/prod.db + +# NextAuth +NEXTAUTH_URL=https://novalon.cn +NEXTAUTH_SECRET=your-production-secret-here + +# 管理员 +ADMIN_EMAIL=admin@novalon.cn +ADMIN_PASSWORD=your-secure-password + +# Sentry +NEXT_PUBLIC_SENTRY_DSN=https://xxx@xxx.ingest.sentry.io/xxx + +# 邮件服务 +RESEND_API_KEY=your_resend_api_key +COMPANY_EMAIL=contact@novalon.cn + +# 文件上传 +UPLOAD_DIR=./uploads +MAX_FILE_SIZE=10485760 + +# 站点 URL +NEXT_PUBLIC_SITE_URL=https://novalon.cn +``` + +### 部署流程 + +1. **准备环境** + ```bash + cp .env.production.example .env.production + # 编辑 .env.production 配置生产环境变量 + ``` + +2. **构建应用** + ```bash + npm run build + ``` + +3. **初始化数据库** + ```bash + npm run db:push + npm run db:seed + ``` + +4. **启动服务** + ```bash + npm start + # 或使用 Docker + docker-compose -f docker-compose.prod.yml up -d + ``` + +5. **验证部署** + ```bash + curl http://localhost:3000/api/health + ``` + +6. **配置监控** + - 在 Sentry 创建项目并配置 DSN + - 配置告警规则 + +7. **设置定时备份** + ```bash + # 添加到 crontab + 0 2 * * * /path/to/scripts/backup.sh + ``` ## 文档 diff --git a/package-lock.json b/package-lock.json index 5ef9419..3397583 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,6 +52,7 @@ "drizzle-kit": "^0.31.9", "eslint": "^10.0.2", "eslint-config-next": "^0.2.4", + "k6": "^0.0.0", "lighthouse": "^13.0.3", "tailwindcss": "^4", "tsx": "^4.21.0", @@ -7592,6 +7593,13 @@ "dev": true, "license": "MIT" }, + "node_modules/k6": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/k6/-/k6-0.0.0.tgz", + "integrity": "sha512-GAQSWayS2+LjbH5bkRi+pMPYyP1JSp7o+4j58ANZ762N/RH/SdlAT3CHHztnn8s/xgg8kYNM24Gd2IPo9b5W+g==", + "dev": true, + "license": "AGPL-3.0" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", diff --git a/package.json b/package.json index 994aad2..dfc7c9a 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,11 @@ "test:e2e": "cd e2e && npm test", "test:smoke": "playwright test --grep @smoke", "test:report": "allure generate test-results/allure-results && allure open", + "test:performance": "k6 run tests/performance/load-test.js", + "test:stress": "k6 run tests/performance/stress-test.js", + "test:security": "k6 run tests/security/sql-injection-test.js && k6 run tests/security/xss-test.js", + "test:sql-injection": "k6 run tests/security/sql-injection-test.js", + "test:xss": "k6 run tests/security/xss-test.js", "check:contrast": "tsx scripts/check-color-contrast.ts", "check:headings": "tsx scripts/check-heading-hierarchy.ts", "audit:performance": "node scripts/performance-audit.js", @@ -72,6 +77,7 @@ "drizzle-kit": "^0.31.9", "eslint": "^10.0.2", "eslint-config-next": "^0.2.4", + "k6": "^0.0.0", "lighthouse": "^13.0.3", "tailwindcss": "^4", "tsx": "^4.21.0", diff --git a/tests/performance/load-test.js b/tests/performance/load-test.js new file mode 100644 index 0000000..035c73e --- /dev/null +++ b/tests/performance/load-test.js @@ -0,0 +1,55 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Rate, Trend } from 'k6/metrics'; + +const errorRate = new Rate('errors'); +const responseTime = new Trend('response_time'); + +export const options = { + stages: [ + { duration: '2m', target: 100 }, // 2分钟内逐步增加到100用户 + { duration: '5m', target: 100 }, // 保持100用户5分钟 + { duration: '2m', target: 200 }, // 2分钟内增加到200用户 + { duration: '5m', target: 200 }, // 保持200用户5分钟 + { duration: '2m', target: 0 }, // 2分钟内减少到0 + ], + thresholds: { + http_req_duration: ['p(95)<500', 'p(99)<1000'], // 95%请求<500ms, 99%请求<1s + http_req_failed: ['rate<0.01'], // 错误率<1% + errors: ['rate<0.01'], + }, +}; + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000'; + +export default function () { + const pages = [ + '/', + '/about', + '/services', + '/products', + '/news', + '/contact', + ]; + + const page = pages[Math.floor(Math.random() * pages.length)]; + const res = http.get(`${BASE_URL}${page}`, { + tags: { name: page }, + }); + + const success = check(res, { + 'status is 200': (r) => r.status === 200, + 'response time < 500ms': (r) => r.timings.duration < 500, + }); + + errorRate.add(!success); + responseTime.add(res.timings.duration); + + sleep(Math.random() * 3 + 1); // 1-4秒随机等待 +} + +export function handleSummary(data) { + return { + 'performance/load-test-summary.json': JSON.stringify(data, null, 2), + }; +} diff --git a/tests/performance/stress-test.js b/tests/performance/stress-test.js new file mode 100644 index 0000000..f2d5b75 --- /dev/null +++ b/tests/performance/stress-test.js @@ -0,0 +1,67 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Rate, Trend } from 'k6/metrics'; + +const errorRate = new Rate('errors'); +const responseTime = new Trend('response_time'); + +export const options = { + stages: [ + { duration: '1m', target: 50 }, // 1分钟内增加到50用户 + { duration: '2m', target: 100 }, // 2分钟内增加到100用户 + { duration: '3m', target: 200 }, // 3分钟内增加到200用户 + { duration: '5m', target: 300 }, // 5分钟内增加到300用户(压力峰值) + { duration: '2m', target: 100 }, // 2分钟内减少到100用户 + { duration: '1m', target: 0 }, // 1分钟内减少到0 + ], + thresholds: { + http_req_duration: ['p(95)<1000', 'p(99)<2000'], // 95%请求<1s, 99%请求<2s + http_req_failed: ['rate<0.05'], // 错误率<5%(压力测试允许更高) + errors: ['rate<0.05'], + }, +}; + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000'; + +export default function () { + const scenarios = [ + { method: 'GET', path: '/', name: '首页' }, + { method: 'GET', path: '/api/health', name: '健康检查' }, + { method: 'POST', path: '/api/contact', name: '联系表单', body: JSON.stringify({ + name: 'Test User', + email: 'test@example.com', + phone: '13800138000', + message: 'This is a test message', + }) }, + ]; + + const scenario = scenarios[Math.floor(Math.random() * scenarios.length)]; + + let res; + if (scenario.method === 'POST') { + res = http.post(`${BASE_URL}${scenario.path}`, scenario.body, { + headers: { 'Content-Type': 'application/json' }, + tags: { name: scenario.name }, + }); + } else { + res = http.get(`${BASE_URL}${scenario.path}`, { + tags: { name: scenario.name }, + }); + } + + const success = check(res, { + 'status is 200 or 201': (r) => [200, 201].includes(r.status), + 'response time < 1000ms': (r) => r.timings.duration < 1000, + }); + + errorRate.add(!success); + responseTime.add(res.timings.duration); + + sleep(Math.random() * 2 + 0.5); // 0.5-2.5秒随机等待 +} + +export function handleSummary(data) { + return { + 'performance/stress-test-summary.json': JSON.stringify(data, null, 2), + }; +} diff --git a/tests/security/sql-injection-test.js b/tests/security/sql-injection-test.js new file mode 100644 index 0000000..e37c229 --- /dev/null +++ b/tests/security/sql-injection-test.js @@ -0,0 +1,83 @@ +import http from 'k6/http'; +import { check } from 'k6'; + +export const options = { + thresholds: { + checks: ['rate==1.0'], // 所有安全检查必须通过 + }, +}; + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000'; + +const sqlInjectionPayloads = [ + "' OR '1'='1", + "' OR '1'='1' --", + "' OR '1'='1' /*", + "' OR 1=1 --", + "admin'--", + "admin'/*", + "' UNION SELECT NULL--", + "1' ORDER BY 1--", + "'; DROP TABLE users--", + "'; INSERT INTO users--", + "' OR SLEEP(5)--", + "1' AND SLEEP(5)--", + "'; WAITFOR DELAY '0:0:5'--", + "1'; EXEC xp_cmdshell('dir')--", + "'; EXEC master..xp_cmdshell 'dir'--", +]; + +export default function () { + let allPassed = true; + + sqlInjectionPayloads.forEach((payload) => { + const testCases = [ + { + name: 'Contact Form - SQL Injection', + url: `${BASE_URL}/api/contact`, + method: 'POST', + body: JSON.stringify({ + name: payload, + email: 'test@example.com', + phone: '13800138000', + message: 'Test message', + }), + }, + { + name: 'Search - SQL Injection', + url: `${BASE_URL}/api/search?q=${encodeURIComponent(payload)}`, + method: 'GET', + }, + ]; + + testCases.forEach((testCase) => { + let res; + if (testCase.method === 'POST') { + res = http.post(testCase.url, testCase.body, { + headers: { 'Content-Type': 'application/json' }, + tags: { name: testCase.name }, + }); + } else { + res = http.get(testCase.url, { + tags: { name: testCase.name }, + }); + } + + const passed = check(res, { + 'status is 200 or 400 or 422': (r) => [200, 400, 422].includes(r.status), + 'no SQL error in response': (r) => !r.body.includes('SQL') && !r.body.includes('syntax error'), + 'no database error in response': (r) => !r.body.includes('database') && !r.body.includes('mysql'), + 'no stack trace in response': (r) => !r.body.includes('stack trace') && !r.body.includes('Error:'), + }); + + if (!passed) { + allPassed = false; + console.error(`SQL Injection test failed for payload: ${payload}`); + } + }); + }); + + if (!allPassed) { + throw new Error('SQL Injection tests failed'); + } +} diff --git a/tests/security/xss-test.js b/tests/security/xss-test.js new file mode 100644 index 0000000..dc95bcd --- /dev/null +++ b/tests/security/xss-test.js @@ -0,0 +1,98 @@ +import http from 'k6/http'; +import { check } from 'k6'; + +export const options = { + thresholds: { + checks: ['rate==1.0'], // 所有安全检查必须通过 + }, +}; + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000'; + +const xssPayloads = [ + '', + '', + '', + '', + '', + '