提交 33b33f0b authored 作者: 陈泽健's avatar 陈泽健

feat(performance): 从curl自动生成压测任务 + 任务级自定义登录凭据 + 读取系统配置被测系统URL

1. 从 curl 自动生成压测任务:
   - 新增 backend/app/utils/curl_parser.py 纯函数解析器(仅 shlex 标准库)
   - 新增 POST /api/performance/parse-curl 路由(ValueError → 400)
   - 支持 -X/-H/-d* 解析、Authorization 剥离置登录开关、签名头剥离、Cookie 保留
   - 新增 11 个单元测试 test_curl_parser.py
   - 前端新建弹窗增加「从 curl 导入」交互,自动回填表单

2. 任务级自定义登录凭据:
   - PerformanceTask 新增 login_username / login_password 字段(含库迁移)
   - _build_client_config() 优先使用自定义凭据,留空回退默认
   - 前端新建/编辑任务弹窗新增用户名/密码输入框

3. 性能测试读取系统配置被测系统 URL:
   - 删除 _DEFAULT_BASE_URL 模块常量,运行时读取 settings.TARGET_URL
   - 系统配置新增登录/验证码接口路径(login_path / captcha_path)字段
   - _build_client_config() 不再解析任务 target_url,切换环境无需改任务
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 69c215fe
...@@ -162,42 +162,11 @@ ...@@ -162,42 +162,11 @@
"Bash(awk 'NR>=2750 && NR<=2800' \"E:\\\\GithubData\\\\ubains-module-test\\\\AuxiliaryTool\\\\ScriptTool\\\\新服务自检\\\\check_server_health.ps1\")", "Bash(awk 'NR>=2750 && NR<=2800' \"E:\\\\GithubData\\\\ubains-module-test\\\\AuxiliaryTool\\\\ScriptTool\\\\新服务自检\\\\check_server_health.ps1\")",
"Bash(npx vue-tsc *)", "Bash(npx vue-tsc *)",
"Bash(npm run *)", "Bash(npm run *)",
"Bash(curl *)",
"Bash(tee explore_menu_output.log)",
"Bash(tasklist)",
"Bash(ssh *)",
"Bash(tee explore_menu_v3.log)",
"Bash(cat \"C:\\\\\\\\Users\\\\\\\\UBAINS\\\\\\\\AppData\\\\\\\\Local\\\\\\\\Temp\\\\\\\\claude\\\\\\\\E--GithubData-ubains-module-test-platform-auto-test\\\\\\\\16b7010d-dda1-49c0-8ae3-4d1e7f451517\\\\\\\\tasks\\\\\\\\bm0gqc5bz.output\")",
"Bash(pkill -f \"uvicorn app.main:app\")",
"Bash(nohup uvicorn app.main:app --reload --port 8001)",
"Bash(awk '{print $1}')",
"Bash(xargs kill -9)",
"Bash(PYTHONIOENCODING=utf-8 python scripts/verify_menu_v3.py)",
"Bash(PYTHONIOENCODING=utf-8 timeout 600 python scripts/verify_menu_v3.py)",
"Bash(tee verify_result.log)",
"Bash(scp *)",
"Bash(taskkill /F /IM chrome.exe)",
"Bash(taskkill //F //IM chrome.exe)",
"Bash(pip show *)",
"Bash(git pull *)",
"Bash(git stash *)",
"Bash(git reset *)",
"Bash(git add *)", "Bash(git add *)",
"Bash(pip install *)", "Bash(git commit *)",
"Bash(git commit -m 'feat\\(keyword-matcher\\): jieba分词优化 + 评分归一化 \\(Phase 1&2\\) *)",
"Bash(git push *)", "Bash(git push *)",
"Bash(git commit -m 'feat\\(smart-locate\\): 语义定位器+组合选择器+Claude增强+iframe穿透 \\(Phase 3-6\\) *)", "Bash(git pull *)",
"Bash(netstat -ano)", "Bash(git stash *)"
"Bash(findstr \":8001\")",
"Bash(taskkill /F /PID 30168)",
"Bash(MSYS_NO_PATHCONV=1 cmd.exe *)",
"Bash(sqlite3 backend/data/test_platform.db \"SELECT id, name, status, total_cases, passed, failed, skipped, duration, start_time, end_time FROM executions WHERE id = 'exec_5f48bf8852b54c5299fd7dd470aa4d83';\")",
"Bash(cp /tmp/step_8.png \"E:/GithubData/ubains-module-test/platform-auto-test/data/screenshots/debug_step_8.png\")",
"Bash(cp /tmp/step_9.png \"E:/GithubData/ubains-module-test/platform-auto-test/data/screenshots/debug_step_9.png\")",
"Bash(cp /tmp/step_10.png \"E:/GithubData/ubains-module-test/platform-auto-test/data/screenshots/debug_step_10.png\")",
"Bash(curl -s \"http://192.168.5.60/api/cases/case_13650e0406e64779b66e6fa5de35c24a\")",
"WebFetch(domain:192.168.5.60)",
"Bash(echo \"智能定位请求已发送,后台运行中 \\(PID: $!\\)\")"
] ]
} }
} }
# PRD - 性能测试 API 从 curl 自动生成压测任务
> **文档版本**: v1.0
> **创建日期**: 2026-08-19
> **关联模块**: 性能测试
> **优先级**: P3
> **关联需求**: `Docs/PRD/性能测试/需求文档/_PRD_性能测试模块需求文档.md`
---
## 一、背景与目标
### 1.1 背景
当前性能测试模块创建压测任务时,用户需要手动填写以下接口配置:
- HTTP 方法(GET/POST/PUT/DELETE)
- 目标 URL(完整路径)
- 请求头(JSON 格式)
- 请求体(JSON 格式)
- 是否需要登录(Token)
- 是否需要签名(X-RANDOM/X-TIMESTAMP/X-SIGN)
而这些信息在接口调试工具(如 Apifox、Postman 等)复制的 **curl 命令** 中已经完整携带。用户每次新建任务都要把 curl 里的信息逐字转抄到表单,既繁琐又容易出错(如漏掉签名头、URL 拼错)。
用户希望:**粘贴 curl 命令 → 自动解析 → 回填任务表单**,拿接口的 curl 直接生成压测任务,省略手工转抄。
### 1.2 目标
1. **curl 解析**:后端提供解析端点,将 curl 命令结构化(方法、URL、请求头、请求体)
2. **一键回填**:新建任务弹窗提供「从 curl 导入」入口,粘贴后自动回填表单
3. **智能识别认证**:自动识别 `Authorization` 头(置登录开关)与签名头(置签名开关)
4. **友好降级**:不支持的 curl 参数给出友好提示,不阻断回填
### 1.3 范围界定
- ✅ 纯前端解析回填 + 后端辅助解析端点
- ❌ 不联动「另存为接口预设」(预设为 P2 独立功能)
- ❌ 不解析文件上传(`-F`)、Basic Auth(`-u`)等复杂场景(友好提示)
---
## 二、功能范围
### 2.1 核心功能
| 功能 | 描述 | 优先级 |
|------|------|--------|
| curl 解析端点 | 后端 `POST /api/performance/parse-curl` 解析 curl 命令 | P0 |
| 前端导入入口 | 新建任务弹窗「从 curl 导入」按钮 + 粘贴区 | P0 |
| 表单自动回填 | 解析结果映射到任务表单字段 | P0 |
| 认证智能识别 | Authorization 剥离置登录、签名头剥离置签名 | P0 |
| 不支持参数提示 | `-F`/`-u`/`--data-urlencode` 等友好提示 | P1 |
### 2.2 支持的 curl 参数
| curl 参数 | 映射到 | 说明 |
|-----------|--------|------|
| `-X` / `--request` | `method` | HTTP 方法,缺省按是否有 body 推断(有 body 默认 POST,否则 GET) |
| (裸 URL 位置参数) | `target_url` | 第一个不带 `-` 的参数 |
| `-H` / `--header` | `headers` | 多个头合并,逐个处理 `Key: Value` 格式 |
| `-d` / `--data` / `--data-raw` / `--data-binary` / `--data-json` | `body` | 尝试 JSON 解析,失败保留原始字符串 |
| `--compressed` | — | 忽略,不报错 |
### 2.3 认证识别规则
| 检测条件 | 行为 |
|----------|------|
| 存在 `Authorization: Bearer xxx` 头 | 置 `auth_required=True`,并从 headers 中**剥离**该头 |
| 存在 `X-RANDOM` / `X-TIMESTAMP` / `X-SIGN` 头 | 置 `sign_request=True`,并从 headers 中**剥离**这些头 |
| 存在 `Cookie` 头 | **保留**在 headers 中(不剥离) |
### 2.4 不支持的参数(友好提示)
| 参数 | 原因 | 处理 |
|------|------|------|
| `-F` / `--form` | 文件/表单上传 | 提示"暂不支持文件上传" |
| `-u` / `--user` | Basic Auth | 提示"暂不支持 Basic 认证" |
| `--data-urlencode` | URL 编码参数 | 提示"暂不支持 URL 编码参数" |
---
## 三、详细需求
### 3.1 后端解析端点
#### 3.1.1 接口定义
```
POST /api/performance/parse-curl
Content-Type: application/json
```
**请求体**
```json
{
"curl_command": "curl -X PUT https://192.168.5.44/meetingV3/api/message/book -H \"Content-Type: application/json\" -H \"X-SIGN: abc\" -d '{\"messageName\": \"test\"}'"
}
```
**响应体**
```json
{
"method": "PUT",
"target_url": "https://192.168.5.44/meetingV3/api/message/book",
"headers": { "Content-Type": "application/json" },
"body": { "messageName": "test" },
"auth_required": false,
"sign_request": true,
"cookie": null,
"errors": []
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `method` | string | 解析出的 HTTP 方法 |
| `target_url` | string | 目标 URL(第一个裸位置参数) |
| `headers` | object | 解析出的请求头(Authorization/签名头已剥离) |
| `body` | any | 请求体(JSON 对象/数组/字符串;bodyTemplate 不填) |
| `auth_required` | bool | 是否检测到 Authorization 头 |
| `sign_request` | bool | 是否检测到签名头 |
| `cookie` | string \| null | Cookie 头的值(已保留在 headers 中) |
| `errors` | array | 不支持的参数提示列表(空数组表示全部支持) |
#### 3.1.2 解析逻辑
实现为纯函数 `parse_curl(curl_command: str) -> CurlParseResult`
1. 使用 `shlex.split()` 切分 curl 命令(标准库,处理引号嵌套)
2. 遍历参数:
- `-X` / `--request` 的值 → method
- 不带 `-` 前缀的裸参数 → target_url(取第一个)
- `-H` / `--header` 的值 → 按 `Key: Value` 拆分进 headers
- `-d` / `--data` / `--data-raw` / `--data-binary` / `--data-json` 的值 → body
- `-F` / `--form` → errors 追加提示
- `-u` / `--user` → errors 追加提示
- `--data-urlencode` → errors 追加提示
- `--compressed` → 忽略
3. 方法推断:若未显式指定 `-X`,有 body 默认 `POST`,无 body 默认 `GET`
4. 认证识别:遍历 headers,检测 `Authorization` / `X-RANDOM` / `X-TIMESTAMP` / `X-SIGN` 键(大小写不敏感),按 2.3 规则剥离/置开关
5. body 解析:尝试 `json.loads()`,成功返回解析后的对象,失败保留原始字符串
6. 同一键的多个 header 值:用 `; ` 拼接合并
#### 3.1.3 边界情况
| 场景 | 行为 |
|------|------|
| curl 命令为空 | 返回 400,错误信息"curl 命令不能为空" |
| 命令无法解析(无 URL) | 返回 400,错误信息"未识别到目标 URL" |
| 多个 `-d` | 按 `&` 拼接后尝试 JSON 解析(与 curl 行为一致) |
| header 值无冒号 | 忽略该 header,不报错 |
| URL 是第一个位置参数但中间出现 | 取第一个裸参数作为 URL |
### 3.2 前端交互
#### 3.2.1 入口
新建任务弹窗(`TaskList.vue`)工具栏新增「从 curl 导入」按钮。
#### 3.2.2 交互流程
1. 点击「从 curl 导入」→ 弹出 curl 导入对话框
2. 对话框中粘贴 curl 命令到 textarea
3. 点击「解析导入」→ 调用 `POST /api/performance/parse-curl`
4. 解析成功后关闭对话框并回填表单:
- `method` → 表单 method
- `target_url` → 表单 targetUrl(必填校验)
- `headers` → 表单 headers(JSON 字符串格式化 `JSON.stringify(obj, null, 2)`
- `body` → 表单 body(JSON 字符串格式化)
- `auth_required` → 表单 authRequired
- `sign_request` → 表单 signRequest
- 清空 `presetId`,解除接口字段 disabled 状态
5. 若有 `errors` 列表,回填后弹出 `ElMessage.warning` 逐条提示(不阻断)
#### 3.2.3 表单联动
- 回填后若之前选择了接口预设(`presetId` 非空),清空预设选择,恢复接口字段可编辑(回填值与预设无关)
- `authRequired``signRequest` 均为开关控件,直接置为解析结果
### 3.3 无需后端建表
本功能为**纯解析能力**,不新增数据库表、不新增模型字段,任务创建/存储链路完全不变。
---
## 四、非功能需求
### 4.1 兼容性
- 全部使用 Python 标准库(`shlex``json`),不新增第三方依赖
- 纯函数设计,解析逻辑与 HTTP/DB 解耦,便于单元测试
- 前端回填后用户仍可手动修改所有字段(回填只是预填,不锁定)
### 4.2 可靠性
- 解析器对异常输入不抛异常,未知/不支持参数收集到 `errors` 列表
- `-X` 与 body 推断冲突时以 `-X` 显式指定为准
### 4.3 约束
- curl 命令最长 10000 字符(前后端同步校验)
- headers 最多解析 50 个
---
## 五、影响范围
### 5.1 新增文件
| 文件 | 说明 |
|------|------|
| `backend/app/utils/curl_parser.py` | curl 解析纯函数模块 |
### 5.2 修改文件
| 文件 | 变更 |
|------|------|
| `backend/app/schemas/performance.py` | 新增 `CurlParseRequest` / `CurlParseResponse` |
| `backend/app/routers/performance.py` | 新增 `POST /parse-curl` 路由 |
| `frontend/src/api/performance.ts` | 新增 `parseCurl()` API 封装 |
| `frontend/src/types/performance.ts` | 新增 `CurlParseResult` 类型 |
| `frontend/src/views/performance/TaskList.vue` | 新增「从 curl 导入」按钮 + 对话框 + 回填逻辑 |
---
## 六、验收标准
| # | 验收项 | 验证方式 |
|---|--------|---------|
| 1 | 标准 POST curl 解析 | `curl -X POST https://example.com/api -H "Content-Type: application/json" -d '{"key":"val"}'` → method=POST、URL、headers、body 均正确 |
| 2 | GET 无 body 推断 | 无 `-X` 无 body → method=GET |
| 3 | Authorization 剥离 | 解析后 headers 不含 Authorization,authRequired=true |
| 4 | 签名头剥离 | 解析后 headers 不含 X-SIGN 等,signRequest=true |
| 5 | Cookie 保留 | 解析后 headers 含 Cookie |
| 6 | 不支持参数提示 | `-F` 类参数 → errors 列表含提示,其余字段正常解析 |
| 7 | 前端回填 | 粘贴 curl → 解析 → 表单 method/URL/headers/body/开关全部回填 |
| 8 | 预设解除 | 回填后 presetId 清空、接口字段可编辑 |
| 9 | 保存并创建 | 回填后直接保存,成功创建任务 |
| 10 | 空命令错误 | 空 curl → 后端 400 错误提示 |
---
*本文档为从 curl 自动生成压测任务功能的需求定义,后续将输出计划执行文档用于指导开发。*
\ No newline at end of file
# 从 curl 自动生成压测任务 - 计划执行文档
> **关联 PRD**:`_PRD_从curl自动生成压测任务.md`
> **文档版本**:v1.0
> **制定日期**:2026-08-19
> **执行分支**:`platform-auto-test`
> **优先级**:P3
---
## 一、执行目标
实现“粘贴 curl 命令自动生成压测任务配置”能力:后端负责将 curl 解析为结构化数据,前端在新建任务弹窗中提供导入入口并回填现有表单。功能不新增数据库表、不改变任务数据模型、不与接口预设联动。
---
## 二、实施原则
1. **先文档后代码**:本计划与 PRD 完成后再实施代码。
2. **最小改动**:复用现有性能测试任务创建表单、API 封装和 Pydantic camelCase 规则。
3. **纯函数解析**:解析器只使用 Python 标准库,和 FastAPI、数据库解耦。
4. **兼容优先**:未使用 curl 导入时,原有创建/编辑流程完全不变。
5. **安全默认**:Authorization 只用于识别登录需求,不把 Bearer Token 持久化到任务 headers;Cookie 按用户约定保留。
---
## 三、阶段计划
### Phase 0:文档准备
| 工作项 | 文件 | 结果 |
|---|---|---|
| 编写需求文档 | `_PRD_从curl自动生成压测任务.md` | 明确范围、输入输出、验收标准 |
| 编写本计划 | `_PRD_从curl自动生成压测任务_计划执行.md` | 明确代码阶段和验证方式 |
### Phase 1:后端解析能力
#### 1.1 新增解析器
文件:`backend/app/utils/curl_parser.py`
实现:
- `CurlParseResult` 数据结构
- `parse_curl(curl_command)` 纯函数
- 使用 `shlex.split()` 处理单引号、双引号和反斜杠续行
- 支持 `-X/--request``-H/--header``-d/--data*`、URL
- 自动推断 GET/POST
- 识别并剥离 Authorization Bearer 与签名 headers
- 保留 Cookie header
-`-F``-u``--data-urlencode` 返回友好 errors
- 对空命令、无法分词、缺失选项值、缺失 URL 返回可识别错误
#### 1.2 新增 API Schema
文件:`backend/app/schemas/performance.py`
- `CurlParseRequest``curl_command` 必填,最大 10000 字符
- `CurlParseResponse`:与解析器输出一致,使用项目既有 camelCase alias 规则
#### 1.3 新增解析端点
文件:`backend/app/routers/performance.py`
- `POST /parse-curl`
- 调用 `parse_curl()`
- 解析输入无效时返回 HTTP 400
- 成功时返回 `CurlParseResponse`
- 路由放在 `/tasks/{task_id}` 动态路由之前,避免路径匹配歧义
### Phase 2:前端导入交互
#### 2.1 API 与类型
文件:
- `frontend/src/api/performance.ts`:新增 `parseCurl(curlCommand)`
- `frontend/src/types/performance.ts`:新增 `CurlParseResult`
请求路径使用完整 `/api/performance/parse-curl`,遵循 axios `baseURL` 为空的项目约定。
#### 2.2 任务表单入口
文件:`frontend/src/views/performance/TaskList.vue`
- 新建任务弹窗增加“从 curl 导入”按钮
- 增加导入 dialog 和 textarea
- 对话框提供取消、解析导入按钮
- 解析期间按钮 loading,防止重复提交
- 成功后回填 method、targetUrl、headers、body、authRequired、signRequest
- 清空 presetId,恢复接口字段可编辑
- errors 非空时用 warning 提示,不阻断可解析字段回填
- body 为 null 时清空表单 body
- 对象/数组使用格式化 JSON 字符串,普通字符串同样保证可提交
### Phase 3:验证与收尾
#### 3.1 后端解析器验证
使用 Python 直接调用 `parse_curl()` 覆盖:
1. 标准 POST + JSON body
2. GET 无 body
3. Authorization Bearer 剥离
4. 签名 headers 剥离
5. Cookie 保留
6. `-F`/`-u`/`--data-urlencode` errors
7. 多行 curl(反斜杠续行)
8. 缺少 URL、空命令、无效引号
#### 3.2 API 验证
- 启动后端后访问 OpenAPI,确认 endpoint 注册
- 使用 HTTP 客户端调用成功和失败输入
- 确认响应字段为 camelCase(`targetUrl``authRequired``signRequest`)且前端类型一致
#### 3.3 前端验证
- 执行 `npm run build`
- 打开新建任务弹窗,确认“从 curl 导入”入口可见
- 粘贴 curl,确认字段回填
- 验证选择预设后导入会清空 presetId
- 验证不支持参数提示不阻断
- 回填后保存任务,确认原创建链路成功
---
## 四、文件变更清单
### 新增
| 文件 | 说明 |
|---|---|
| `backend/app/utils/curl_parser.py` | curl 解析器 |
| `Docs/PRD/性能测试/需求文档/_PRD_从curl自动生成压测任务.md` | 需求文档 |
| `Docs/PRD/性能测试/需求文档/_PRD_从curl自动生成压测任务_计划执行.md` | 本执行计划 |
### 修改
| 文件 | 变更 |
|---|---|
| `backend/app/schemas/performance.py` | curl 请求/响应 Schema |
| `backend/app/routers/performance.py` | curl 解析 API |
| `frontend/src/api/performance.ts` | API 封装 |
| `frontend/src/types/performance.ts` | 返回类型 |
| `frontend/src/views/performance/TaskList.vue` | 导入交互与回填 |
### 明确不修改
- `backend/app/models/performance.py`
- `backend/app/database.py`
- `backend/app/services/performance_service.py`
- 接口预设相关模型、路由和页面
---
## 五、风险与应对
| 风险 | 应对 |
|---|---|
| Shell 引号与 Windows curl 格式差异 | 使用 `shlex.split(posix=True)`,异常返回 400;支持常见单/双引号及反斜杠续行 |
| curl 中包含敏感 Bearer Token | 解析后不返回 Authorization,避免回填/持久化 |
| 不支持 multipart 或 Basic Auth | 正常解析其余字段,并在 errors 中提示 |
| 预设字段 disabled 导致无法回填 | 导入成功前清空 presetId,再赋接口字段 |
| 空 body 与 JSON body 格式不同 | 前端统一将 null 转为空字符串,对象/数组格式化为 JSON |
---
## 六、验收门禁
代码合并前必须满足:
- [ ] PRD 与计划执行文档已落盘
- [ ] 后端解析器覆盖计划中的 curl 变体
- [ ] 空命令和缺少 URL 能得到明确 400
- [ ] Authorization/签名头不出现在返回 headers
- [ ] Cookie 出现在返回 headers
- [ ] 前端导入可回填并清空 presetId
- [ ] `npm run build` 通过
- [ ] 后端解析器验证通过
- [ ] 未修改数据库结构和既有任务创建链路
---
*本计划执行完成后,应同步更新性能测试 HANDOFF 文档中的 P3 进度。*
\ No newline at end of file
# PRD - 性能测试模块读取系统配置的被测系统URL
> **文档版本**: v1.0
> **创建日期**: 2026-08-19
> **模块类型**: 性能测试
> **优先级**: P1
---
## 一、背景与目标
### 1.1 背景
当前平台已实现**系统管理 → 系统配置**页面,支持持久化配置被测系统 URL(`/api/sut-config`),配置后立即更新运行时的 `settings.TARGET_URL`,UI 自动化执行已使用该地址。
但性能测试模块存在以下问题:
1. **硬编码默认值**`performance_service.py``_DEFAULT_BASE_URL = settings.TARGET_URL` 是模块级常量,在服务启动时固定,修改系统配置后不会刷新
2. **从任务 URL 提取 base URL**`_build_client_config()` 优先从任务填写的 `target_url`(如 `https://192.168.5.44/platform/api/xxx`)中解析 base URL,覆盖了系统配置的值
3. **认证配置硬编码**:登录路径 `/platform/api/auth/login` 和验证码路径 `/platform/api/code` 是硬编码的,与被测系统的实际路径绑定
当被测系统 URL 变更(如从 `https://192.168.5.44` 改为 `https://192.168.5.45`)时:
- 现有性能测试任务的 `target_url` 仍指向旧地址
- 登录/认证的 base URL 仍使用旧地址
- 用户需要逐一修改每个任务,体验差且易出错
### 1.2 目标
1. **统一配置源**:性能测试模块的登录/认证 base URL 统一从系统配置(`settings.TARGET_URL`)读取,不依赖任务中填写的 `target_url`
2. **运行时动态读取**:执行任务时实时读取当前系统配置,而非模块导入时的快照值
3. **向后兼容**:现有任务数据无需修改,任务 `target_url` 仍保存完整 URL 用于展示,但认证/登录 base URL 独立于它
4. **认证路径可配置**:登录路径和验证码路径从系统配置中读取,而不是硬编码
---
## 二、功能范围
### 2.1 核心变更
| 变更 | 描述 | 影响范围 |
|------|------|---------|
| 移除模块级常量 | 删除 `_DEFAULT_BASE_URL`,改为运行时读取 `settings.TARGET_URL` | `performance_service.py` |
| 修正 base URL 来源 | `_build_client_config()` 不再从任务 `target_url` 中解析 base URL,直接使用 `settings.TARGET_URL` | `performance_service.py` |
| 认证路径可配置化 | 登录路径和验证码路径从配置中读取 | `performance_service.py` |
| 系统配置 API 扩展 | 增加 `login_path``captcha_path` 字段到系统配置中 | `schemas/dingtalk_config.py``system.py` |
### 2.2 不变的部分
- 任务的 `target_url` 字段结构不变(仍保存完整 URL)
- 任务的创建/编辑/列表/删除 API 不变
- 前端表单不变
- 性能执行引擎 `PerformanceExecutor` 不变
---
## 三、详细需求
### 3.1 运行时获取 base URL
**当前代码**`performance_service.py` 第 35 行):
```python
_DEFAULT_BASE_URL = settings.TARGET_URL
```
**问题**:模块级常量在导入时固定,后续 `settings.TARGET_URL` 变更不会同步。
**改为**
```python
@staticmethod
def _get_base_url() -> str:
"""获取当前系统配置的被测系统 URL(运行时动态读取)"""
return settings.TARGET_URL
```
### 3.2 `_build_client_config()` 不再从任务 URL 提取 base URL
**当前代码**
```python
base_url = _DEFAULT_BASE_URL
if target_url:
try:
from urllib.parse import urlparse
parsed = urlparse(target_url)
if parsed.scheme and parsed.netloc:
base_url = f"{parsed.scheme}://{parsed.netloc}"
except Exception:
pass
```
**改为**:直接使用 `_get_base_url()`,不再从 `target_url` 解析。
### 3.3 认证路径从系统配置读取
**当前代码**(硬编码):
```python
_DEFAULT_AUTH_CONFIG = {
"login_path": "/platform/api/auth/login",
"captcha_path": "/platform/api/code",
}
```
**改为**:从 `settings` 中读取,或从系统配置 API 扩展的字段中读取。
### 3.4 系统配置扩展
`SutConfigResponse``SutConfigUpdateRequest` 中增加两个可选字段:
| 字段 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `login_path` | str | `/platform/api/auth/login` | 登录接口路径 |
| `captcha_path` | str | `/platform/api/code` | 验证码接口路径 |
---
## 四、技术方案
### 4.1 修改文件清单
| 文件 | 修改内容 |
|------|---------|
| `backend/app/services/performance_service.py` | 移除 `_DEFAULT_BASE_URL``_build_client_config()` 改用 `settings.TARGET_URL`,认证路径从配置读取 |
| `backend/app/schemas/dingtalk_config.py` | `SutConfigResponse``SutConfigUpdateRequest` 增加 `login_path``captcha_path` 字段 |
| `backend/app/routers/system.py` | `/api/sut-config` 的 GET/PUT 接口处理新增字段 |
| `backend/app/config.py` | 增加 `AUTH_LOGIN_PATH``AUTH_CAPTCHA_PATH` 环境变量默认值 |
| `backend/app/models/dingtalk_config.py` | 增加 `login_path``captcha_path` 字段 |
| `frontend/src/views/system/Settings.vue` | 增加认证路径配置项 |
### 4.2 数据流
```
用户修改系统配置
→ PUT /api/sut-config {url, login_path, captcha_path}
→ 持久化到 DingTalkConfig 表
→ 更新 settings.TARGET_URL / settings.AUTH_LOGIN_PATH / settings.AUTH_CAPTCHA_PATH
→ 执行性能测试任务时,_build_client_config() 读取 settings 中的值
→ HttpClient 使用正确的 base URL + 认证路径进行登录
```
### 4.3 兼容性
- 现有任务数据无需迁移
- 新增字段有默认值,不影响现有 API 调用
- 性能测试任务的 `target_url` 仍保存完整 URL(用于展示和压测请求),但登录/认证不再依赖它
---
## 五、风险与缓解
| 风险 | 影响 | 缓解措施 |
|------|------|---------|
| 任务 `target_url` 指向旧系统,但认证使用新系统 | 登录 Token 与请求目标不一致 | 任务的 `target_url` 应指向新系统,由用户手动更新;认证 base URL 独立控制 |
| 系统配置尚未保存时执行任务 | 使用默认值 | `settings` 类已有默认值,不依赖数据库 |
| 认证路径变更后旧任务执行异常 | 登录失败 | 系统配置变更后,所有涉及登录的模块(UI/性能/安全)都会受影响,属于正常预期 |
---
*本文档为性能测试模块读取系统配置被测系统 URL 的需求定义,后续将输出计划执行文档用于指导开发。*
\ No newline at end of file
# 计划执行 - 性能测试模块读取系统配置被测系统URL
> **文档版本**: v1.0
> **创建日期**: 2026-08-19
> **关联 PRD**: `_PRD_性能测试模块读取系统配置被测系统URL.md`
> **优先级**: P1
---
## 一、目标
让性能测试模块的认证/登录 base URL 统一从系统配置(`settings.TARGET_URL`)运行时动态读取,并支持认证路径(登录/验证码)可配置化。
## 二、现状分析
### 2.1 关键代码位置
| 文件 | 第几行 | 当前逻辑 | 问题 |
|------|--------|---------|------|
| `backend/app/services/performance_service.py` | 34-45 | `_DEFAULT_BASE_URL = settings.TARGET_URL` 模块级常量 | 导入时固定,配置变更后不刷新 |
| 同上 | 605-613 | `_build_client_config()` 从任务 `target_url` 解析 base URL | 覆盖了系统配置的值 |
| 同上 | 42-45 | `_DEFAULT_AUTH_CONFIG` 硬编码登录/验证码路径 | 不可配置 |
| `backend/app/routers/system.py` | 262-314 | `/sut-config` GET/PUT 仅处理 `url` 字段 | 缺少认证路径字段 |
| `backend/app/schemas/dingtalk_config.py` | — | `SutConfigResponse` / `SutConfigUpdateRequest` 仅含 `url` | 缺少认证路径字段 |
| `backend/app/models/dingtalk_config.py` | — | 模型仅含 `target_url` | 缺少认证路径字段 |
| `frontend/src/views/system/Settings.vue` | 12-54 | 被测系统配置仅编辑 URL | 缺少认证路径输入框 |
### 2.2 参考实现(UI 自动化执行如何做)
`execution_service.py` 第 336 行:
```python
executor_config.setdefault("base_url", settings.TARGET_URL)
```
UI 自动化已经采用运行时读取 `settings.TARGET_URL` 的方式,性能测试需要对齐这一模式。
## 三、实施步骤
### Step 1: 扩展系统配置模型(后端)
**文件**`backend/app/models/dingtalk_config.py`
`DingTalkConfig` 模型中增加两个字段:
```python
login_path: Mapped[str] = mapped_column(String(200), default="/platform/api/auth/login", comment="登录接口路径")
captcha_path: Mapped[str] = mapped_column(String(200), default="/platform/api/code", comment="验证码接口路径")
```
### Step 2: 扩展 Schema
**文件**`backend/app/schemas/dingtalk_config.py`
- `SutConfigResponse` 增加:
```python
login_path: str = "/platform/api/auth/login"
captcha_path: str = "/platform/api/code"
```
- `SutConfigUpdateRequest` 增加:
```python
login_path: Optional[str] = None
captcha_path: Optional[str] = None
```
### Step 3: 扩展系统配置 API
**文件**`backend/app/routers/system.py`
- `GET /api/sut-config`:返回 `url` + `login_path` + `captcha_path`(从数据库读取,空则回退环境变量默认值)
- `PUT /api/sut-config`:新增字段持久化到 `DingTalkConfig.login_path` / `captcha_path`,并更新运行时 `settings.AUTH_LOGIN_PATH` / `settings.AUTH_CAPTCHA_PATH`
### Step 4: 扩展 config.py 环境变量
**文件**`backend/app/config.py`
```python
# 认证接口路径配置
AUTH_LOGIN_PATH: str = os.getenv("AUTH_LOGIN_PATH", "/platform/api/auth/login")
AUTH_CAPTCHA_PATH: str = os.getenv("AUTH_CAPTCHA_PATH", "/platform/api/code")
```
### Step 5: 重构 performance_service.py
**文件**:`backend/app/services/performance_service.py`
1. 删除 `_DEFAULT_BASE_URL` 常量
2. `_build_client_config()` 改为:
```python
@staticmethod
def _build_client_config(task_dict: Dict[str, Any]) -> Dict[str, Any]:
"""
构建 HttpClient 配置
被测系统 base_url 与认证路径均从系统配置(settings)运行时动态读取,
不依赖任务中填写的 target_url。
"""
return {
"target": {
"base_url": settings.TARGET_URL,
"verify_ssl": False,
"timeout": 30,
},
"accounts": dict(_DEFAULT_ACCOUNTS),
"auth": {
"login_path": settings.AUTH_LOGIN_PATH,
"captcha_path": settings.AUTH_CAPTCHA_PATH,
},
}
```
### Step 6: 前端 Settings.vue 增加认证路径配置
**文件**:`frontend/src/views/system/Settings.vue`
- `sutForm` 增加 `loginPath`、`captchaPath`
- 表单中增加两个输入项:
```vue
<el-form-item label="登录接口路径" prop="loginPath">
<el-input v-model="sutForm.loginPath" placeholder="/platform/api/auth/login" />
</el-form-item>
<el-form-item label="验证码接口路径" prop="captchaPath">
<el-input v-model="sutForm.captchaPath" placeholder="/platform/api/code" />
</el-form-item>
```
- 保存/重置逻辑同步更新
### Step 7: 验证
1. 后端单元测试:确认 `_build_client_config()` 返回的 base URL 等于 `settings.TARGET_URL`,不依赖任务 `target_url`
2. 手动验证:
- 在系统配置修改被测系统 URL → 创建性能测试任务(target_url 填旧地址)
- 执行任务 → 登录请求应发送到新地址
- 同时验证认证路径配置生效
## 四、涉及文件清单
| 文件 | 改动类型 |
|------|---------|
| `backend/app/models/dingtalk_config.py` | 修改(+2 字段) |
| `backend/app/schemas/dingtalk_config.py` | 修改(+2 字段在 2 个 Schema) |
| `backend/app/routers/system.py` | 修改(GET/PUT 处理新字段) |
| `backend/app/config.py` | 修改(+2 环境变量) |
| `backend/app/services/performance_service.py` | 修改(重构 `_build_client_config`) |
| `frontend/src/views/system/Settings.vue` | 修改(+2 输入项) |
无数据库迁移脚本(SQLite 自动加列;MySQL 生产库需手动 ALTER TABLE,见 Step 8)。
## 五、MySQL 生产库迁移(可选)
若部署在 MySQL,需执行:
```sql
ALTER TABLE dingtalk_configs
ADD COLUMN login_path VARCHAR(200) NOT NULL DEFAULT '/platform/api/auth/login',
ADD COLUMN captcha_path VARCHAR(200) NOT NULL DEFAULT '/platform/api/code';
```
## 六、验收标准
- [ ] `_build_client_config()` 返回的 `base_url` 恒等于 `settings.TARGET_URL`
- [ ] 修改系统配置后立即执行性能任务,登录请求使用新地址
- [ ] 登录路径/验证码路径可通过系统配置修改,且持久化生效
- [ ] 旧任务数据无需迁移即可正常工作
- [ ] 前端 Settings.vue 提供认证路径输入项,保存/重置正确
## 七、风险与回滚
- **风险**:任务 `target_url` 与系统配置的 base URL 不一致时,登录用新地址、压测请求仍用旧地址,可能导致 token 无效
- **缓解**:文档中注明用户应同步更新任务 `target_url`;如出现大量登录失败,优先检查系统配置与任务 URL 是否一致
- **回滚**`_build_client_config()` 恢复从 `target_url` 解析 base URL 的逻辑即可(保留原代码注释)
\ No newline at end of file
此差异已折叠。
...@@ -82,6 +82,10 @@ class Settings: ...@@ -82,6 +82,10 @@ class Settings:
# 被测系统配置 # 被测系统配置
TARGET_URL: str = os.getenv("TARGET_URL", "https://192.168.5.44") TARGET_URL: str = os.getenv("TARGET_URL", "https://192.168.5.44")
# 认证接口路径配置(默认对齐被测系统微前端路径)
AUTH_LOGIN_PATH: str = os.getenv("AUTH_LOGIN_PATH", "/platform/api/auth/login")
AUTH_CAPTCHA_PATH: str = os.getenv("AUTH_CAPTCHA_PATH", "/platform/api/code")
# 语义化用例执行配置(复杂用例通用执行机制,各阶段可独立开关回退) # 语义化用例执行配置(复杂用例通用执行机制,各阶段可独立开关回退)
# SEMANTIC_ENABLED: 语义目标解析(阶段 3) # SEMANTIC_ENABLED: 语义目标解析(阶段 3)
# USE_KEYWORD_MATCHER: legacy 自然语言步骤走 keyword_matcher(阶段 3 过渡路径) # USE_KEYWORD_MATCHER: legacy 自然语言步骤走 keyword_matcher(阶段 3 过渡路径)
......
...@@ -39,6 +39,12 @@ class DingTalkConfig(Base): ...@@ -39,6 +39,12 @@ class DingTalkConfig(Base):
app_secret: Mapped[str] = mapped_column(String(255), default="", comment="钉钉AppSecret") app_secret: Mapped[str] = mapped_column(String(255), default="", comment="钉钉AppSecret")
agent_id: Mapped[int] = mapped_column(Integer, default=0, comment="钉钉AgentId") agent_id: Mapped[int] = mapped_column(Integer, default=0, comment="钉钉AgentId")
target_url: Mapped[str] = mapped_column(String(500), default="", comment="被测系统URL") target_url: Mapped[str] = mapped_column(String(500), default="", comment="被测系统URL")
login_path: Mapped[str] = mapped_column(
String(200), default="/platform/api/auth/login", comment="登录接口路径"
)
captcha_path: Mapped[str] = mapped_column(
String(200), default="/platform/api/code", comment="验证码接口路径"
)
updated_at: Mapped[Optional[datetime]] = mapped_column( updated_at: Mapped[Optional[datetime]] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment="更新时间" DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment="更新时间"
) )
...@@ -52,4 +58,6 @@ class DingTalkConfig(Base): ...@@ -52,4 +58,6 @@ class DingTalkConfig(Base):
"app_secret": self.app_secret or "", "app_secret": self.app_secret or "",
"agent_id": self.agent_id or 0, "agent_id": self.agent_id or 0,
"target_url": self.target_url or "", "target_url": self.target_url or "",
"login_path": self.login_path or "/platform/api/auth/login",
"captcha_path": self.captcha_path or "/platform/api/code",
} }
\ No newline at end of file
...@@ -108,6 +108,12 @@ class PerformanceTask(Base): ...@@ -108,6 +108,12 @@ class PerformanceTask(Base):
# 认证配置 # 认证配置
auth_required: Mapped[bool] = mapped_column(Boolean, default=True, comment="是否需要登录Token") auth_required: Mapped[bool] = mapped_column(Boolean, default=True, comment="是否需要登录Token")
account_key: Mapped[str] = mapped_column(String(50), default="superadmin", comment="使用的账号") account_key: Mapped[str] = mapped_column(String(50), default="superadmin", comment="使用的账号")
login_username: Mapped[Optional[str]] = mapped_column(
String(200), nullable=True, default=None, comment="自定义登录用户名"
)
login_password: Mapped[Optional[str]] = mapped_column(
String(200), nullable=True, default=None, comment="自定义登录密码"
)
# 签名配置 # 签名配置
sign_request: Mapped[bool] = mapped_column(Boolean, default=False, comment="请求是否需要签名(X-RANDOM/X-TIMESTAMP/X-SIGN)") sign_request: Mapped[bool] = mapped_column(Boolean, default=False, comment="请求是否需要签名(X-RANDOM/X-TIMESTAMP/X-SIGN)")
...@@ -185,6 +191,8 @@ class PerformanceTask(Base): ...@@ -185,6 +191,8 @@ class PerformanceTask(Base):
"step_duration": self.step_duration, "step_duration": self.step_duration,
"auth_required": self.auth_required, "auth_required": self.auth_required,
"account_key": self.account_key, "account_key": self.account_key,
"login_username": self.login_username,
"login_password": self.login_password,
"sign_request": self.sign_request, "sign_request": self.sign_request,
"preset_id": self.preset_id, "preset_id": self.preset_id,
"assertions": self.assertions or [], "assertions": self.assertions or [],
......
...@@ -32,8 +32,11 @@ from app.schemas.performance import ( ...@@ -32,8 +32,11 @@ from app.schemas.performance import (
PerformanceReportResponse, PerformanceReportResponse,
PerformanceReportSummary, PerformanceReportSummary,
PerformanceReportMetrics, PerformanceReportMetrics,
CurlParseRequest,
CurlParseResponse,
) )
from app.services.performance_service import PerformanceService from app.services.performance_service import PerformanceService
from app.utils.curl_parser import parse_curl
from app.websocket.manager import manager from app.websocket.manager import manager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
...@@ -118,6 +121,36 @@ async def delete_task( ...@@ -118,6 +121,36 @@ async def delete_task(
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
# ==================== curl 解析 ====================
@router.post("/parse-curl", response_model=CurlParseResponse)
async def parse_curl_endpoint(data: CurlParseRequest):
"""
解析 curl 命令,返回结构化压测配置
支持 -X/--request、-H/--header、-d/--data* 及目标 URL;
自动识别并剥离 Authorization Bearer 与签名头(X-RANDOM/X-TIMESTAMP/X-SIGN);
Cookie 头保留在 headers 中。
不支持的参数(-F/-u/--data-urlencode)通过 errors 列表友好提示,不阻断解析。
"""
try:
result = parse_curl(data.curl_command)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return CurlParseResponse(
method=result.method,
target_url=result.target_url,
headers=result.headers,
body=result.body,
auth_required=result.auth_required,
sign_request=result.sign_request,
cookie=result.cookie,
errors=result.errors,
)
# ==================== Phase 3: 执行/停止 ==================== # ==================== Phase 3: 执行/停止 ====================
......
...@@ -262,17 +262,30 @@ async def update_dingtalk_config(request: DingTalkConfigUpdateRequest, db: Async ...@@ -262,17 +262,30 @@ async def update_dingtalk_config(request: DingTalkConfigUpdateRequest, db: Async
@router.get("/sut-config", response_model=SutConfigResponse, summary="获取被测系统配置") @router.get("/sut-config", response_model=SutConfigResponse, summary="获取被测系统配置")
async def get_sut_config(db: AsyncSession = Depends(get_db)): async def get_sut_config(db: AsyncSession = Depends(get_db)):
""" """
获取被测系统访问 URL 获取被测系统访问 URL 与认证接口路径
优先读取数据库持久化的值,无记录时回退到环境变量默认值。 优先读取数据库持久化的值,无记录时回退到环境变量默认值。
""" """
result = await db.execute(select(DingTalkConfig).where(DingTalkConfig.id == 1)) result = await db.execute(select(DingTalkConfig).where(DingTalkConfig.id == 1))
config = result.scalar_one_or_none() config = result.scalar_one_or_none()
login_path = settings.AUTH_LOGIN_PATH or "/platform/api/auth/login"
captcha_path = settings.AUTH_CAPTCHA_PATH or "/platform/api/code"
if config and config.target_url: if config and config.target_url:
return SutConfigResponse(url=config.target_url, source="db") return SutConfigResponse(
url=config.target_url,
source="db",
login_path=config.login_path or login_path,
captcha_path=config.captcha_path or captcha_path,
)
return SutConfigResponse(url=settings.TARGET_URL or "", source="env") return SutConfigResponse(
url=settings.TARGET_URL or "",
source="env",
login_path=login_path,
captcha_path=captcha_path,
)
@router.put("/sut-config", response_model=SutConfigResponse, summary="更新被测系统配置") @router.put("/sut-config", response_model=SutConfigResponse, summary="更新被测系统配置")
...@@ -281,10 +294,10 @@ async def update_sut_config( ...@@ -281,10 +294,10 @@ async def update_sut_config(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
""" """
更新被测系统访问 URL 更新被测系统访问 URL 与认证接口路径
持久化到数据库(dingtalk_configs.target_url)并立即更新运行时 持久化到数据库(dingtalk_configs)并立即更新运行时 settings 对应字段,
settings.TARGET_URL,UI 自动化执行即使用该地址;服务重启后仍生效 性能测试/UI 自动化执行即使用该地址与认证路径;服务重启后仍生效
(lifespan 启动时从数据库加载)。 (lifespan 启动时从数据库加载)。
""" """
url = (request.url or "").strip().rstrip("/") url = (request.url or "").strip().rstrip("/")
...@@ -307,8 +320,28 @@ async def update_sut_config( ...@@ -307,8 +320,28 @@ async def update_sut_config(
config.target_url = url config.target_url = url
settings.TARGET_URL = url settings.TARGET_URL = url
# 认证接口路径(未传时保持现有值)
if request.login_path is not None:
login_path = request.login_path.strip()
if not login_path.startswith("/"):
raise HTTPException(status_code=400, detail="登录接口路径须以 / 开头")
config.login_path = login_path
settings.AUTH_LOGIN_PATH = login_path
if request.captcha_path is not None:
captcha_path = request.captcha_path.strip()
if not captcha_path.startswith("/"):
raise HTTPException(status_code=400, detail="验证码接口路径须以 / 开头")
config.captcha_path = captcha_path
settings.AUTH_CAPTCHA_PATH = captcha_path
await db.commit() await db.commit()
await db.refresh(config) await db.refresh(config)
logger.info(f"被测系统 URL 已更新并持久化: {url}") logger.info(f"被测系统配置已更新并持久化: url={url}, login_path={config.login_path}, captcha_path={config.captcha_path}")
return SutConfigResponse(url=config.target_url, source="db") return SutConfigResponse(
url=config.target_url,
source="db",
login_path=config.login_path or "/platform/api/auth/login",
captcha_path=config.captcha_path or "/platform/api/code",
)
...@@ -32,8 +32,16 @@ class SutConfigResponse(BaseModel): ...@@ -32,8 +32,16 @@ class SutConfigResponse(BaseModel):
"""被测系统配置响应""" """被测系统配置响应"""
url: str = Field("", description="被测系统访问 URL") url: str = Field("", description="被测系统访问 URL")
source: str = Field("env", description="来源: db(数据库)/env(环境变量默认)") source: str = Field("env", description="来源: db(数据库)/env(环境变量默认)")
login_path: str = Field("/platform/api/auth/login", description="登录接口路径")
captcha_path: str = Field("/platform/api/code", description="验证码接口路径")
class SutConfigUpdateRequest(BaseModel): class SutConfigUpdateRequest(BaseModel):
"""被测系统配置更新请求""" """被测系统配置更新请求"""
url: str = Field(..., description="被测系统访问 URL,须以 http:// 或 https:// 开头") url: str = Field(..., description="被测系统访问 URL,须以 http:// 或 https:// 开头")
\ No newline at end of file login_path: Optional[str] = Field(
None, description="登录接口路径,默认 /platform/api/auth/login"
)
captcha_path: Optional[str] = Field(
None, description="验证码接口路径,默认 /platform/api/code"
)
\ No newline at end of file
...@@ -63,6 +63,8 @@ class PerformanceTaskCreate(BaseModel): ...@@ -63,6 +63,8 @@ class PerformanceTaskCreate(BaseModel):
# 认证配置 # 认证配置
auth_required: bool = Field(True, description="是否需要登录Token") auth_required: bool = Field(True, description="是否需要登录Token")
account_key: str = Field("superadmin", description="使用的账号") account_key: str = Field("superadmin", description="使用的账号")
login_username: Optional[str] = Field(None, description="自定义登录用户名,留空使用默认")
login_password: Optional[str] = Field(None, description="自定义登录密码,留空使用默认")
# 签名配置 # 签名配置
sign_request: bool = Field(False, description="请求是否需要签名(X-RANDOM/X-TIMESTAMP/X-SIGN)") sign_request: bool = Field(False, description="请求是否需要签名(X-RANDOM/X-TIMESTAMP/X-SIGN)")
...@@ -97,6 +99,8 @@ class PerformanceTaskUpdate(BaseModel): ...@@ -97,6 +99,8 @@ class PerformanceTaskUpdate(BaseModel):
step_duration: Optional[int] = Field(None, ge=1, description="每阶梯时长(秒)") step_duration: Optional[int] = Field(None, ge=1, description="每阶梯时长(秒)")
auth_required: Optional[bool] = Field(None, description="是否需要登录Token") auth_required: Optional[bool] = Field(None, description="是否需要登录Token")
account_key: Optional[str] = Field(None, description="使用的账号") account_key: Optional[str] = Field(None, description="使用的账号")
login_username: Optional[str] = Field(None, description="自定义登录用户名,留空使用默认")
login_password: Optional[str] = Field(None, description="自定义登录密码,留空使用默认")
sign_request: Optional[bool] = Field(None, description="请求是否需要签名(X-RANDOM/X-TIMESTAMP/X-SIGN)") sign_request: Optional[bool] = Field(None, description="请求是否需要签名(X-RANDOM/X-TIMESTAMP/X-SIGN)")
preset_id: Optional[str] = Field(None, description="关联的接口预设ID") preset_id: Optional[str] = Field(None, description="关联的接口预设ID")
assertions: Optional[List[AssertionRule]] = Field(None, description="断言规则列表") assertions: Optional[List[AssertionRule]] = Field(None, description="断言规则列表")
...@@ -126,6 +130,8 @@ class PerformanceTaskResponse(BaseModel): ...@@ -126,6 +130,8 @@ class PerformanceTaskResponse(BaseModel):
step_duration: int = 60 step_duration: int = 60
auth_required: bool = True auth_required: bool = True
account_key: str = "superadmin" account_key: str = "superadmin"
login_username: Optional[str] = None
login_password: Optional[str] = None
sign_request: bool = False sign_request: bool = False
assertions: List[Dict[str, Any]] = [] assertions: List[Dict[str, Any]] = []
capture_rules: Optional[List[Dict[str, Any]]] = None capture_rules: Optional[List[Dict[str, Any]]] = None
...@@ -276,6 +282,29 @@ class PerformanceTaskListResponse(BaseModel): ...@@ -276,6 +282,29 @@ class PerformanceTaskListResponse(BaseModel):
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel) model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
# ==================== curl 解析 ====================
class CurlParseRequest(BaseModel):
"""curl 命令解析请求"""
# 注意:不加 min_length=1 —— 空字符串需由 parse_curl() 抛出 ValueError,经路由转为 400
#(PRD 边界约定:空 curl 命令 → 400 提示;若加 min_length,Pydantic 会先返回 422)
curl_command: str = Field(..., max_length=10000, description="完整 curl 命令")
class CurlParseResponse(BaseModel):
"""curl 命令解析响应"""
method: str = "GET"
target_url: str = ""
headers: Dict[str, str] = Field(default_factory=dict)
body: Optional[Any] = None
auth_required: bool = False
sign_request: bool = False
cookie: Optional[str] = None
errors: List[str] = Field(default_factory=list)
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
class PerformanceSnapshotListResponse(BaseModel): class PerformanceSnapshotListResponse(BaseModel):
"""性能测试快照列表响应""" """性能测试快照列表响应"""
total: int total: int
......
...@@ -31,18 +31,13 @@ from app.config import settings ...@@ -31,18 +31,13 @@ from app.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# 默认的系统登录配置(与 playwright_executor.LOGIN_CONFIG / login_template_service 一致) # 默认的账号配置(与 playwright_executor.LOGIN_CONFIG / login_template_service 一致)
_DEFAULT_BASE_URL = settings.TARGET_URL
_DEFAULT_ACCOUNTS = { _DEFAULT_ACCOUNTS = {
"superadmin": {"username": "admin@xty", "password": "Ubains@13579"}, "superadmin": {"username": "admin@xty", "password": "Ubains@13579"},
"admin": {"username": "admin@xty", "password": "Ubains@13579"}, "admin": {"username": "admin@xty", "password": "Ubains@13579"},
"user": {"username": "admin@xty", "password": "Ubains@13579"}, "user": {"username": "admin@xty", "password": "Ubains@13579"},
"captcha": "csba", "captcha": "csba",
} }
_DEFAULT_AUTH_CONFIG = {
"login_path": "/platform/api/auth/login",
"captcha_path": "/platform/api/code",
}
class PerformanceService: class PerformanceService:
...@@ -593,8 +588,9 @@ class PerformanceService: ...@@ -593,8 +588,9 @@ class PerformanceService:
""" """
构建 HttpClient 配置 构建 HttpClient 配置
从任务的目标 URL 推导登录系统的基础地址, 被测系统 base_url 与认证路径(登录/验证码)均从系统配置
使用默认的账号密码和认证配置。 (settings,即系统管理-系统配置保存的值)运行时动态读取,
不再依赖任务中填写的 target_url——切换被测环境后无需改任务。
Args: Args:
task_dict: 任务数据字典 task_dict: 任务数据字典
...@@ -602,26 +598,26 @@ class PerformanceService: ...@@ -602,26 +598,26 @@ class PerformanceService:
Returns: Returns:
dict: HttpClient 配置字典 dict: HttpClient 配置字典
""" """
target_url = task_dict.get("target_url", "") # 构建 accounts 字典(优先使用自定义凭据)
# 从 target_url 提取 base_url(scheme + host) accounts = dict(_DEFAULT_ACCOUNTS)
base_url = _DEFAULT_BASE_URL if task_dict.get("login_username") and task_dict.get("login_password"):
if target_url: key = task_dict.get("account_key", "custom")
try: accounts[key] = {
from urllib.parse import urlparse "username": task_dict["login_username"],
parsed = urlparse(target_url) "password": task_dict["login_password"],
if parsed.scheme and parsed.netloc: }
base_url = f"{parsed.scheme}://{parsed.netloc}"
except Exception:
pass
return { return {
"target": { "target": {
"base_url": base_url, "base_url": settings.TARGET_URL or "",
"verify_ssl": False, "verify_ssl": False,
"timeout": 30, "timeout": 30,
}, },
"accounts": dict(_DEFAULT_ACCOUNTS), "accounts": accounts,
"auth": dict(_DEFAULT_AUTH_CONFIG), "auth": {
"login_path": settings.AUTH_LOGIN_PATH or "/platform/api/auth/login",
"captcha_path": settings.AUTH_CAPTCHA_PATH or "/platform/api/code",
},
} }
@staticmethod @staticmethod
......
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:curl_parser.py
模块描述:性能测试 curl 命令解析器(纯函数,无第三方依赖)
- 解析 curl -X/--request、-H/--header、-d/--data* 及 URL
- 自动识别 Authorization Bearer(置 auth_required)与签名头(置 sign_request)并剥离
- Cookie 头保留在 headers 中
- 不支持的参数(-F/-u/--data-urlencode)收集到 errors 友好提示
作者:czj
创建日期:2026-08-19
最后修改:2026-08-19
"""
import json
import logging
import shlex
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
# 签名相关请求头(剥离并置 sign_request=True)
_SIGN_HEADERS = ("X-RANDOM", "X-TIMESTAMP", "X-SIGN")
# 不支持的参数 → 友好提示(带值参数,如 -F file=@x)
_UNSUPPORTED_VALUE_FLAGS = {
"-F": "暂不支持文件上传参数 -F/--form,已忽略",
"--form": "暂不支持文件上传参数 -F/--form,已忽略",
"-u": "暂不支持 Basic 认证参数 -u/--user,已忽略",
"--user": "暂不支持 Basic 认证参数 -u/--user,已忽略",
"--data-urlencode": "暂不支持 URL 编码参数 --data-urlencode,已忽略",
}
# 不支持的布尔参数(无需取值)→ 友好提示
_UNSUPPORTED_BOOL_FLAGS = {}
@dataclass
class CurlParseResult:
"""curl 解析结果"""
method: str = "GET"
target_url: str = ""
headers: Dict[str, str] = field(default_factory=dict)
body: Optional[Any] = None
auth_required: bool = False
sign_request: bool = False
cookie: Optional[str] = None
errors: List[str] = field(default_factory=list)
def _split_header(header: str) -> Optional[Tuple[str, str]]:
"""将 'Key: Value' 拆分为 (key, value),无冒号返回 None"""
if ":" not in header:
return None
key, value = header.split(":", 1)
key = key.strip()
value = value.strip()
if not key:
return None
return key, value
def _parse_json(value: str) -> Any:
"""尝试 JSON 解析,失败返回原始字符串"""
try:
return json.loads(value)
except (json.JSONDecodeError, TypeError):
return value
def _merge_header(existing: Dict[str, str], key: str, value: str) -> None:
"""合并请求头,同一键多个值用 '; ' 拼接"""
if key in existing and existing[key]:
existing[key] = f"{existing[key]}; {value}"
else:
existing[key] = value
def parse_curl(curl_command: str) -> CurlParseResult:
"""
解析 curl 命令,返回结构化结果。
Args:
curl_command: 完整的 curl 命令字符串
Returns:
CurlParseResult: 解析结果(method/target_url/headers/body/认证开关/errors)
Raises:
ValueError: 命令为空、引号不匹配或无法识别目标 URL 时抛出
"""
if not curl_command or not curl_command.strip():
raise ValueError("curl 命令不能为空")
# shlex 分词:统一 posix=True 兼容单/双引号与反斜杠续行
try:
tokens = shlex.split(curl_command)
except ValueError as e:
raise ValueError(f"curl 命令无法解析(引号不匹配): {e}")
# 去掉开头的 curl 可执行文件名
if tokens and tokens[0].lower() in ("curl", "curl.exe"):
tokens = tokens[1:]
result = CurlParseResult()
method_explicit = False
body_parts: List[str] = []
i = 0
while i < len(tokens):
token = tokens[i]
next_token = tokens[i + 1] if i + 1 < len(tokens) else None
# ---------- 裸露参数 → 目标 URL(取第一个) ----------
if not token.startswith("-"):
if not result.target_url:
result.target_url = token
i += 1
continue
# ---------- --key=value 形式(长选项内联赋值) ----------
if token.startswith("--") and "=" in token:
flag, _, inline_value = token.partition("=")
if flag in ("--request", "-X"):
result.method = inline_value.upper()
method_explicit = True
elif flag in ("--header", "-H"):
parsed = _split_header(inline_value)
if parsed:
_merge_header(result.headers, parsed[0], parsed[1])
elif flag in ("--data", "--data-raw", "--data-binary", "--data-json", "-d"):
body_parts.append(inline_value)
elif flag in _UNSUPPORTED_VALUE_FLAGS:
result.errors.append(_UNSUPPORTED_VALUE_FLAGS[flag])
# 其余未知长选项(--compressed 等布尔旗标)忽略
i += 1
continue
# ---------- 带值选项(下一 token 为值) ----------
if token in ("-X", "--request"):
if next_token is None:
raise ValueError(f"参数 {token} 缺少值")
result.method = next_token.upper()
method_explicit = True
i += 2
continue
if token in ("-H", "--header"):
if next_token is None:
raise ValueError(f"参数 {token} 缺少值")
parsed = _split_header(next_token)
if parsed:
_merge_header(result.headers, parsed[0], parsed[1])
i += 2
continue
if token in ("-d", "--data", "--data-raw", "--data-binary", "--data-json"):
if next_token is None:
raise ValueError(f"参数 {token} 缺少值")
body_parts.append(next_token)
i += 2
continue
if token in _UNSUPPORTED_VALUE_FLAGS:
result.errors.append(_UNSUPPORTED_VALUE_FLAGS[token])
if next_token is not None and not next_token.startswith("-"):
# 带值参数跨过值
i += 2
else:
i += 1
continue
# ---------- 未知参数(-k/-L/-s/--compressed 等)忽略 ----------
i += 1
if not result.target_url:
raise ValueError("未识别到目标 URL")
# 方法推断:未显式指定 -X 时,有 body 默认 POST,否则 GET
if body_parts and not method_explicit:
result.method = "POST"
# 合并 body:多个 -d 按 & 拼接(与 curl 行为一致),再尝试 JSON 解析
if body_parts:
result.body = _parse_json("&".join(body_parts))
# 认证识别:剥离 Authorization / 签名头,Cookie 保留在 headers 并记录值
for key in list(result.headers.keys()):
upper_key = key.upper()
if upper_key == "AUTHORIZATION":
result.auth_required = True
del result.headers[key]
elif upper_key in _SIGN_HEADERS:
result.sign_request = True
del result.headers[key]
elif upper_key == "COOKIE":
result.cookie = result.headers[key]
logger.debug("parse_curl result: method=%s url=%s headers=%s body=%s errors=%s",
result.method, result.target_url, result.headers, result.body, result.errors)
return result
\ No newline at end of file
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:test_curl_parser.py
模块描述:curl 命令解析器 parse_curl() 单元测试
作者:czj
创建日期:2026-08-19
"""
import pytest
from app.utils.curl_parser import parse_curl
def test_parse_standard_post():
"""标准 POST 请求解析方法、请求头和 JSON body。"""
result = parse_curl(
"curl -X POST https://example.com/api "
'-H "Content-Type: application/json" -d \'{"key":"val"}\''
)
assert result.method == "POST"
assert result.target_url == "https://example.com/api"
assert result.headers == {"Content-Type": "application/json"}
assert result.body == {"key": "val"}
assert result.auth_required is False
assert result.sign_request is False
assert result.errors == []
def test_parse_get_is_inferred_without_body():
"""没有 body 且未指定方法时推断为 GET。"""
result = parse_curl("curl https://example.com/api/users")
assert result.method == "GET"
assert result.body is None
def test_parse_post_is_inferred_with_body():
"""没有 -X 但存在 body 时推断为 POST。"""
result = parse_curl('curl https://example.com/api -d "name=test"')
assert result.method == "POST"
def test_parse_authorization_is_removed_and_auth_required_is_set():
"""Authorization 头被剥离,同时启用认证标记。"""
result = parse_curl(
'curl -X GET https://example.com/api '
'-H "Authorization: Bearer token123"'
)
assert result.auth_required is True
assert "Authorization" not in result.headers
def test_parse_signature_headers_are_removed_and_sign_request_is_set():
"""签名相关请求头被剥离,同时启用签名标记。"""
result = parse_curl(
'curl -X POST https://e.com/api '
'-H "X-SIGN: sig" -H "X-RANDOM: r" -H "X-TIMESTAMP: t" -d "{}"'
)
assert result.sign_request is True
assert "X-SIGN" not in result.headers
assert "X-RANDOM" not in result.headers
assert "X-TIMESTAMP" not in result.headers
def test_parse_cookie_is_retained_in_headers_and_cookie_field():
"""Cookie 同时保留在请求头和 cookie 字段中。"""
result = parse_curl('curl https://e.com -H "Cookie: sid=abc"')
assert result.cookie == "sid=abc"
assert result.headers["Cookie"] == "sid=abc"
def test_parse_unsupported_parameter_reports_error():
"""不支持的 -F 参数被记录到 errors。"""
result = parse_curl('curl -F "file=@a" https://e.com')
assert result.errors
def test_parse_inline_long_option_assignments():
"""支持长选项的内联赋值形式。"""
result = parse_curl(
'curl --request=POST --header="Content-Type: application/json" '
"--data-raw='{\"x\":1}' https://e.com/api"
)
assert result.method == "POST"
assert result.headers == {"Content-Type": "application/json"}
assert result.body == {"x": 1}
def test_parse_empty_command_raises_value_error():
"""空命令抛出 ValueError。"""
with pytest.raises(ValueError):
parse_curl("")
def test_parse_command_without_url_raises_value_error():
"""没有 URL 的命令抛出 ValueError。"""
with pytest.raises(ValueError):
parse_curl("curl -X GET")
def test_parse_multiple_data_options_are_concatenated():
"""多个 -d 参数按 curl 语义以 & 拼接。"""
result = parse_curl('curl -d "a=1" -d "b=2" https://e.com/api')
assert result.body == "a=1&b=2"
...@@ -15,6 +15,7 @@ import type { ...@@ -15,6 +15,7 @@ import type {
PerformanceRunResponse, PerformanceRunResponse,
PerformanceSnapshotListResponse, PerformanceSnapshotListResponse,
PerformanceReportResponse, PerformanceReportResponse,
CurlParseResult,
} from '@/types/performance' } from '@/types/performance'
const BASE = '/api/performance' const BASE = '/api/performance'
...@@ -150,4 +151,11 @@ export function updatePreset(id: string, data: any): Promise<any> { ...@@ -150,4 +151,11 @@ export function updatePreset(id: string, data: any): Promise<any> {
/** 删除预设 */ /** 删除预设 */
export function deletePreset(id: string): Promise<void> { export function deletePreset(id: string): Promise<void> {
return request.delete(`${BASE}/presets/${id}`) return request.delete(`${BASE}/presets/${id}`)
}
// ==================== curl 解析 ====================
/** 解析 curl 命令,返回结构化压测配置 */
export function parseCurl(curlCommand: string): Promise<CurlParseResult> {
return request.post(`${BASE}/parse-curl`, { curlCommand })
} }
\ No newline at end of file
...@@ -67,7 +67,7 @@ export const systemApi = { ...@@ -67,7 +67,7 @@ export const systemApi = {
/** /**
* 获取被测系统配置 * 获取被测系统配置
*/ */
async getSutConfig(): Promise<{ url: string; source: string }> { async getSutConfig(): Promise<{ url: string; source: string; login_path: string; captcha_path: string }> {
const response = await request.get('/api/system/sut-config') const response = await request.get('/api/system/sut-config')
return response as any return response as any
}, },
...@@ -75,8 +75,12 @@ export const systemApi = { ...@@ -75,8 +75,12 @@ export const systemApi = {
/** /**
* 更新被测系统配置 * 更新被测系统配置
*/ */
async updateSutConfig(url: string): Promise<{ url: string; source: string }> { async updateSutConfig(data: {
const response = await request.put('/api/system/sut-config', { url }) url: string
login_path?: string
captcha_path?: string
}): Promise<{ url: string; source: string; login_path: string; captcha_path: string }> {
const response = await request.put('/api/system/sut-config', data)
return response as any return response as any
}, },
} }
...@@ -47,6 +47,8 @@ export interface PerformanceTask { ...@@ -47,6 +47,8 @@ export interface PerformanceTask {
stepDuration: number | null stepDuration: number | null
authRequired: boolean authRequired: boolean
accountKey: string | null accountKey: string | null
loginUsername: string | null
loginPassword: string | null
signRequest: boolean signRequest: boolean
assertions: AssertionRule[] assertions: AssertionRule[]
totalRequests: number | null totalRequests: number | null
...@@ -87,6 +89,8 @@ export interface PerformanceTaskCreate { ...@@ -87,6 +89,8 @@ export interface PerformanceTaskCreate {
stepDuration?: number | null stepDuration?: number | null
authRequired?: boolean authRequired?: boolean
accountKey?: string | null accountKey?: string | null
loginUsername?: string | null
loginPassword?: string | null
signRequest?: boolean signRequest?: boolean
assertions?: AssertionRule[] assertions?: AssertionRule[]
} }
...@@ -109,6 +113,8 @@ export interface PerformanceTaskUpdate { ...@@ -109,6 +113,8 @@ export interface PerformanceTaskUpdate {
stepDuration?: number | null stepDuration?: number | null
authRequired?: boolean authRequired?: boolean
accountKey?: string | null accountKey?: string | null
loginUsername?: string | null
loginPassword?: string | null
signRequest?: boolean signRequest?: boolean
assertions?: AssertionRule[] assertions?: AssertionRule[]
} }
...@@ -295,4 +301,18 @@ export interface ApiPresetListItem { ...@@ -295,4 +301,18 @@ export interface ApiPresetListItem {
export interface ApiPresetListResponse { export interface ApiPresetListResponse {
total: number total: number
items: ApiPresetListItem[] items: ApiPresetListItem[]
}
// ==================== curl 解析 ====================
/** curl 解析结果 */
export interface CurlParseResult {
method: string
targetUrl: string
headers: Record<string, string>
body: any | null
authRequired: boolean
signRequest: boolean
cookie: string | null
errors: string[]
} }
\ No newline at end of file
...@@ -147,6 +147,14 @@ ...@@ -147,6 +147,14 @@
</div> </div>
</el-card> </el-card>
<!-- 从 curl 导入 -->
<div style="margin-bottom: 12px">
<el-button type="info" size="small" @click="openCurlDialog">
<el-icon><Upload /></el-icon>从 curl 导入
</el-button>
<span style="color: #909399; font-size: 12px; margin-left: 8px;">粘贴 curl 命令自动回填接口配置</span>
</div>
<el-row :gutter="16"> <el-row :gutter="16">
<el-col :span="8"> <el-col :span="8">
<el-form-item label="HTTP 方法" prop="method"> <el-form-item label="HTTP 方法" prop="method">
...@@ -245,6 +253,19 @@ ...@@ -245,6 +253,19 @@
</el-col> </el-col>
</el-row> </el-row>
<el-row v-if="form.authRequired" :gutter="16">
<el-col :span="12">
<el-form-item label="登录用户名">
<el-input v-model="form.loginUsername" placeholder="自定义登录用户名,留空使用默认" clearable :disabled="!!form.presetId" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="登录密码">
<el-input v-model="form.loginPassword" type="password" show-password placeholder="自定义登录密码,留空使用默认" clearable :disabled="!!form.presetId" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="描述"> <el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="任务描述(可选)" /> <el-input v-model="form.description" type="textarea" :rows="2" placeholder="任务描述(可选)" />
</el-form-item> </el-form-item>
...@@ -255,6 +276,31 @@ ...@@ -255,6 +276,31 @@
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button> <el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
</template> </template>
</el-dialog> </el-dialog>
<!-- 从 curl 导入对话框 -->
<el-dialog v-model="curlDialogVisible" title="从 curl 导入" width="580px" top="20vh" :close-on-click-modal="false">
<el-input
v-model="curlCommand"
type="textarea"
:rows="6"
placeholder="粘贴 curl 命令,例如:&#10;curl -X POST https://192.168.5.44/api/xxx \&#10; -H &quot;Content-Type: application/json&quot; \&#10; -d '{&quot;key&quot;: &quot;value&quot;}'"
/>
<div v-if="curlErrors.length" style="margin-top: 12px">
<el-alert
v-for="(err, i) in curlErrors"
:key="i"
:title="err"
type="warning"
show-icon
:closable="false"
style="margin-bottom: 6px"
/>
</div>
<template #footer>
<el-button @click="curlDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="curlParsing" @click="handleCurlParse">解析导入</el-button>
</template>
</el-dialog>
</div> </div>
</template> </template>
...@@ -268,9 +314,9 @@ ...@@ -268,9 +314,9 @@
import { ref, onMounted, reactive, computed } from 'vue' import { ref, onMounted, reactive, computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Plus, Refresh } from '@element-plus/icons-vue' import { Plus, Refresh, Upload } from '@element-plus/icons-vue'
import { listTasks, getTask, createTask, updateTask, deleteTask, runTask, stopTask, listPresets, getPreset } from '@/api/performance' import { listTasks, getTask, createTask, updateTask, deleteTask, runTask, stopTask, listPresets, getPreset, parseCurl } from '@/api/performance'
import type { PerformanceTask, PerformanceTaskListItem, PerformanceTaskCreate, ApiPresetListItem, ApiPreset } from '@/types/performance' import type { PerformanceTask, PerformanceTaskListItem, PerformanceTaskCreate, ApiPresetListItem, ApiPreset, CurlParseResult } from '@/types/performance'
const router = useRouter() const router = useRouter()
...@@ -291,6 +337,12 @@ const formRef = ref() ...@@ -291,6 +337,12 @@ const formRef = ref()
const presets = ref<ApiPresetListItem[]>([]) const presets = ref<ApiPresetListItem[]>([])
const selectedPreset = ref<ApiPreset | null>(null) const selectedPreset = ref<ApiPreset | null>(null)
// curl 导入相关
const curlDialogVisible = ref(false)
const curlCommand = ref('')
const curlParsing = ref(false)
const curlErrors = ref<string[]>([])
interface TaskForm { interface TaskForm {
name: string name: string
description: string | null description: string | null
...@@ -308,6 +360,8 @@ interface TaskForm { ...@@ -308,6 +360,8 @@ interface TaskForm {
stepDuration: number | null stepDuration: number | null
authRequired: boolean authRequired: boolean
accountKey: string | null accountKey: string | null
loginUsername: string
loginPassword: string
signRequest: boolean signRequest: boolean
assertions: any[] assertions: any[]
presetId: string presetId: string
...@@ -330,6 +384,8 @@ const defaultForm = (): TaskForm => ({ ...@@ -330,6 +384,8 @@ const defaultForm = (): TaskForm => ({
stepDuration: null, stepDuration: null,
authRequired: true, authRequired: true,
accountKey: 'admin', accountKey: 'admin',
loginUsername: '',
loginPassword: '',
signRequest: false, signRequest: false,
assertions: [], assertions: [],
presetId: '', presetId: '',
...@@ -432,6 +488,8 @@ async function openEditDialog(task: PerformanceTaskListItem) { ...@@ -432,6 +488,8 @@ async function openEditDialog(task: PerformanceTaskListItem) {
stepDuration: fullTask.stepDuration, stepDuration: fullTask.stepDuration,
authRequired: fullTask.authRequired, authRequired: fullTask.authRequired,
accountKey: fullTask.accountKey, accountKey: fullTask.accountKey,
loginUsername: fullTask.loginUsername || '',
loginPassword: fullTask.loginPassword || '',
signRequest: fullTask.signRequest, signRequest: fullTask.signRequest,
assertions: fullTask.assertions || [], assertions: fullTask.assertions || [],
presetId: presetId && selectedPreset.value ? presetId : '', presetId: presetId && selectedPreset.value ? presetId : '',
...@@ -502,6 +560,70 @@ async function onPresetChange(presetId: string) { ...@@ -502,6 +560,70 @@ async function onPresetChange(presetId: string) {
} }
} }
/** 打开 curl 导入对话框 */
function openCurlDialog() {
curlCommand.value = ''
curlErrors.value = []
curlDialogVisible.value = true
}
/** 调用后端解析 curl,回填表单 */
async function handleCurlParse() {
const cmd = curlCommand.value.trim()
if (!cmd) {
ElMessage.warning('请先粘贴 curl 命令')
return
}
curlParsing.value = true
try {
const res = parseCurl(cmd)
// 后端 400 时走 catch,成功直接回填
const result: CurlParseResult = await res
applyCurlResult(result)
curlDialogVisible.value = false
} catch (e: any) {
ElMessage.error('curl 解析失败: ' + (e.response?.data?.detail || e.message || ''))
} finally {
curlParsing.value = false
}
}
/** 将解析结果回填到任务表单 */
function applyCurlResult(result: CurlParseResult) {
// 解除预设关联(回填值与预设无关,恢复接口字段可编辑)
selectedPreset.value = null
form.presetId = ''
form.method = result.method || 'GET'
form.targetUrl = result.targetUrl || ''
// headers → 格式化 JSON 字符串
form.headers = Object.keys(result.headers || {}).length
? JSON.stringify(result.headers, null, 2)
: ''
// body → 对象/数组格式化为 JSON,字符串原样,null 清空
if (result.body != null) {
form.body = typeof result.body === 'string' ? result.body : JSON.stringify(result.body, null, 2)
} else {
form.body = ''
}
form.authRequired = !!result.authRequired
form.signRequest = !!result.signRequest
if (!form.authRequired) form.accountKey = 'admin'
// 不支持参数提示(不阻断回填)
curlErrors.value = result.errors || []
if (curlErrors.value.length) {
// 延迟到对话框关闭后展示,避免被覆盖
setTimeout(() => {
curlErrors.value.forEach((err: string) => ElMessage.warning(err))
}, 100)
}
}
/** 账号标签 */ /** 账号标签 */
function accountKeyLabel(key: string) { function accountKeyLabel(key: string) {
const map: Record<string, string> = { superadmin: '超级管理员', admin: '管理员', user: '普通用户' } const map: Record<string, string> = { superadmin: '超级管理员', admin: '管理员', user: '普通用户' }
...@@ -544,6 +666,8 @@ async function handleSave() { ...@@ -544,6 +666,8 @@ async function handleSave() {
stepDuration: form.mode === 'step' ? (form.stepDuration || 30) : null, stepDuration: form.mode === 'step' ? (form.stepDuration || 30) : null,
authRequired: form.authRequired, authRequired: form.authRequired,
accountKey: form.authRequired ? form.accountKey : null, accountKey: form.authRequired ? form.accountKey : null,
loginUsername: form.loginUsername || null,
loginPassword: form.loginPassword || null,
signRequest: form.signRequest, signRequest: form.signRequest,
assertions: [], assertions: [],
presetId: form.presetId || null, presetId: form.presetId || null,
......
...@@ -36,8 +36,24 @@ ...@@ -36,8 +36,24 @@
/> />
</el-form-item> </el-form-item>
<el-form-item label="登录接口路径" prop="loginPath">
<el-input
v-model="sutForm.loginPath"
placeholder="/platform/api/auth/login"
clearable
/>
</el-form-item>
<el-form-item label="验证码接口路径" prop="captchaPath">
<el-input
v-model="sutForm.captchaPath"
placeholder="/platform/api/code"
clearable
/>
</el-form-item>
<el-alert <el-alert
title="保存后,UI 自动化执行即使用该地址作为被测系统;服务重启后依然生效。" title="保存后,UI 自动化与性能测试执行即使用该地址和认证路径;服务重启后依然生效。"
type="info" type="info"
:closable="false" :closable="false"
show-icon show-icon
...@@ -115,7 +131,7 @@ ...@@ -115,7 +131,7 @@
* 系统配置页面 * 系统配置页面
* *
* 功能: * 功能:
* 1. 被测系统配置:填写被测系统访问 URL,保存后 UI 自动化执行即使用该地址(重启持久化) * 1. 被测系统配置:填写被测系统访问 URL、登录接口路径、验证码接口路径,保存后 UI 自动化与性能测试执行即使用该地址和认证路径(重启持久化)
* 2. 钉钉对接配置:钉钉应用凭据持久化 * 2. 钉钉对接配置:钉钉应用凭据持久化
*/ */
...@@ -131,6 +147,8 @@ const sutSource = ref('') ...@@ -131,6 +147,8 @@ const sutSource = ref('')
const sutForm = reactive({ const sutForm = reactive({
url: '', url: '',
loginPath: '',
captchaPath: '',
}) })
/** 校验被测系统 URL:http/https + 合法主机(IP 段 0-255 / 合法域名)+ 合法端口 */ /** 校验被测系统 URL:http/https + 合法主机(IP 段 0-255 / 合法域名)+ 合法端口 */
...@@ -183,8 +201,10 @@ async function loadSutConfig() { ...@@ -183,8 +201,10 @@ async function loadSutConfig() {
try { try {
const res = await systemApi.getSutConfig() const res = await systemApi.getSutConfig()
sutForm.url = res.url || '' sutForm.url = res.url || ''
sutForm.loginPath = res.login_path || ''
sutForm.captchaPath = res.captcha_path || ''
sutSource.value = res.source || '' sutSource.value = res.source || ''
sutSnapshot = res.url || '' sutSnapshot = JSON.stringify({ ...sutForm })
} catch (err: any) { } catch (err: any) {
ElMessage.error('加载被测系统配置失败:' + (err?.message || '未知错误')) ElMessage.error('加载被测系统配置失败:' + (err?.message || '未知错误'))
} }
...@@ -196,9 +216,13 @@ async function handleSutSave() { ...@@ -196,9 +216,13 @@ async function handleSutSave() {
sutSaving.value = true sutSaving.value = true
try { try {
const res = await systemApi.updateSutConfig(sutForm.url.trim()) await systemApi.updateSutConfig({
url: sutForm.url.trim(),
login_path: sutForm.loginPath.trim(),
captcha_path: sutForm.captchaPath.trim(),
})
ElMessage.success('被测系统配置已保存') ElMessage.success('被测系统配置已保存')
sutSnapshot = res.url || sutForm.url sutSnapshot = JSON.stringify({ ...sutForm })
sutSource.value = 'db' sutSource.value = 'db'
} catch (err: any) { } catch (err: any) {
ElMessage.error('保存被测系统配置失败:' + (err?.message || '未知错误')) ElMessage.error('保存被测系统配置失败:' + (err?.message || '未知错误'))
...@@ -208,7 +232,8 @@ async function handleSutSave() { ...@@ -208,7 +232,8 @@ async function handleSutSave() {
} }
function handleSutReset() { function handleSutReset() {
sutForm.url = sutSnapshot const snapshot = JSON.parse(sutSnapshot)
Object.assign(sutForm, snapshot)
sutFormRef.value?.clearValidate() sutFormRef.value?.clearValidate()
} }
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论