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

fix(performance): 修复 curl 导入后保存任务 422 及错误信息不可读

- schema: qps_target/ramp_up/step_concurrency/step_duration 改为 Optional(default=None),Pydantic v2 拒绝 null 时不再 422
- service: create_task() 补齐 Optional 压测参数默认值(100/10/[]/60)
- frontend: 新增 extractApiError() 渲染 Pydantic 错误数组为可读 `字段: msg` 文本
- frontend: 修复 stepConcurrency 数组双重包裹(编辑回填时 Array.isArray 判断)
- frontend: handleCurlParse 错误处理改用 extractApiError()
- docs: HANDOFF_性能测试.md 记录本次会话进度
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 ff749e33
# HANDOFF_性能测试
> 生成时间:2026-08-12 15:00
> 最后更新:2026-08-19 17:46
> 最后更新:2026-08-19 18:05
> 当前分支:`platform-auto-test`
> 最近提交:`6380fb05 feat(security): 新增安全测试定时任务模块`
> 最近提交:`69c215fe feat(device-sim): 无纸化授权码 + 环境配置弹窗简化 + 批量删除性能优化`
> 主分支:`master`
---
......@@ -24,7 +24,7 @@
---
## 🆕 最新会话进度(2026-08-19 17:46
## 🆕 最新会话进度(2026-08-19 18:05
### 本次会话完成的核心工作
......@@ -184,6 +184,50 @@ if task_dict.get("login_username") and task_dict.get("login_password"):
详见本会话 「#### 0. 从 curl 自动生成压测任务」节,本小节原为待办,现已完成。
#### 4. 修复 curl 导入 `[object Object]`(P0 已修复 + 已部署 5.60)
**问题**:用户粘贴 Windows CMD 风格 curl 命令(含 `^` 续行符 + `^"` 转义引号)后,弹窗显示 `curl 解析失败: [object Object]`。
**根因(3 层叠加)**:
1. **主因**:`CurlParseRequest` 缺少 `model_config`,前端发送 camelCase `{curlCommand}` → Pydantic 无法映射到 `curl_command` → 422
2. **Windows CMD `^` 语法未处理**:`shlex.split()` 不认识 `^` 续行符和 `^"` 转义引号,导致 `^` 拼进 token(如 `^Authorization`)→ 认证/签名头检测失败
3. **缺失 `-b`/`--cookie` 支持**:`-b "sid=abc"` 被静默忽略
**修复(3 个文件)**:
| 文件 | 变更 |
|------|------|
| `backend/app/schemas/performance.py` | `CurlParseRequest` 补全 `model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)` |
| `backend/app/utils/curl_parser.py` | 新增 `_preprocess_cmd_style()` 还原 `^` 续行/`^"` 转义;新增 `-b`/`--cookie` 分支 |
| `backend/tests/test_curl_parser.py` | 新增 4 个测试用例(15 passed) |
**文档**:`Docs/PRD/性能测试/问题处理/_问题处理_curl导入解析失败[object Object].md` + `_执行计划_修复curl导入解析失败[object Object].md`
**部署**:scp → 清理 pyc → 重启容器 ✅
**验证**:camelCase `{curlCommand}` → 200 ✅;Windows CMD 完整 curl(Authorization + 签名头 + -b Cookie)→ authRequired=true / signRequest=true / cookie 有值 ✅
#### 5. 修复保存任务 422(P0 已修复 + 已部署 5.60)← 本次会话新增
**问题**:curl 导入解析成功后,点击保存按钮提示 `保存失败: [object Object],[object Object],[object Object],[object Object]`。用户质疑"确定更新了吗?怎么还是新增报错?"
**根因**:
1. **后端 schema 非 Optional 字段接收 null**:`PerformanceTaskCreate` 的 `qps_target / ramp_up / step_concurrency / step_duration` 定义为 `int`/`List[int]`(非 Optional)。前端在 `concurrent` 模式下给这些字段发 `null` → Pydantic v2 对非 Optional 字段拒绝 null → 422。
2. **前端错误信息不可读**:`e.response?.data?.detail` 是 Pydantic 错误对象数组,`.toString()` 变成 `[object Object],[object Object],[object Object],[object Object]`(4 个字段各一个校验错误)。
3. **`stepConcurrency` 数组双重包裹**:`[form.stepConcurrency]` 如果 `form.stepConcurrency` 本身已是数组(编辑回填时),会变成 `[[10, 20, 30]]` → 校验失败。
**修复(3 个文件)**:
| 文件 | 变更 |
|------|------|
| `backend/app/schemas/performance.py` | `qps_target/ramp_up/step_concurrency/step_duration` 改为 `Optional` + `default=None` |
| `backend/app/services/performance_service.py` | `create_task()` 中补齐 Optional 压测参数默认值(`100/10/[]/60`) |
| `frontend/src/views/performance/TaskList.vue` | 新增 `extractApiError()` 渲染 Pydantic 错误数组为可读文本;`handleSave()` 修正 `stepConcurrency` 双重包裹判断;`handleCurlParse` 错误处理改用 `extractApiError()` |
**部署(本次会话)**:
- 后端:scp `schemas/performance.py` + `services/performance_service.py` → 清理 pyc → `docker restart plat-auto-test-app` ✅
- 前端:`npm run build`(31.34s,无错误)→ scp `dist/*` 全量 ✅
- 验证:发送 `qpsTarget/rampUp/stepConcurrency/stepDuration` 全为 null → **201 创建成功**,DB 落库值 `100/10/[]/60` ✅
---
### 上一会话(2026-08-17)完成的核心工作
......@@ -668,13 +712,38 @@ frontend/src/App.vue (MODIFIED: 添加侧边栏
**2026-08-19 问题修复(curl 导入 `[object Object]`,P0):**
1. **根因**`CurlParseRequest` 缺少 `model_config` alias 配置,前端发送 camelCase `{curlCommand}` → 后端 422 → 前端 `Array.toString()` 显示 `[object Object]`
2. **已修复(本次会话,未部署)**
2. **已修复(本次会话,已部署 5.60)**
- `schemas/performance.py``CurlParseRequest` 补全 `model_config`
- `utils/curl_parser.py`:新增 `_preprocess_cmd_style()` 兼容 Windows CMD `^` 续行/`^"` 转义
- `utils/curl_parser.py`:新增 `-b`/`--cookie` 参数支持
- `tests/test_curl_parser.py`:新增 4 个测试用例(15 passed)
3. **文档**`Docs/PRD/性能测试/问题处理/_问题处理_curl导入解析失败[object Object].md` + `_执行计划_修复curl导入解析失败[object Object].md`
4. **待办**:提交代码 → 部署 5.60 → 用用户真实 CMD curl 复验
4. **待办**:提交代码;用用户真实 CMD curl 复验完整导入(已用 CMD 风格命令实测通过)
**2026-08-19 部署结果**:schemas/performance.py + utils/curl_parser.py 已 scp → 清理 *.pyc → 重启容器(healthy)✅
- 5.60 实测 camelCase `{curlCommand}` → 200 ✅
- 5.60 实测完整 Windows CMD curl(^ 续行 + Authorization + 签名头 + -b Cookie)→ authRequired=true / signRequest=true / cookie 有值 ✅
### P3.5 - 修复 curl 导入后保存任务 422 [object Object](P0 已修复 + 已部署 5.60)
**问题**:curl 导入解析成功 → 点保存 → `保存失败: [object Object],[object Object],[object Object],[object Object]`(用户质疑"确定更新了吗?怎么还是新增报错?")
**根因(3 层独立问题)**
1. **schema 非 Optional 字段拒绝 null**`PerformanceTaskCreate``qps_target/ramp_up/step_concurrency/step_duration``int`/`List[int]`,前端在非 step/qps 模式发 `null` → Pydantic v2 422
2. **错误信息不可读**`e.response.data.detail` 是 Pydantic 错误对象数组,`.toString()` → 4 个 `[object Object]`
3. **stepConcurrency 数组双重包裹**:编辑回填时 `[form.stepConcurrency]` 套已有数组 → `[[10,20,30]]` 校验失败
**修复(3 文件)**
| 文件 | 变更 |
|------|------|
| `backend/app/schemas/performance.py` | 4 字段改 `Optional` + `default=None``qps_target/ramp_up/step_concurrency/step_duration`) |
| `backend/app/services/performance_service.py` | `create_task()` 补齐 Optional 压测参数默认值(`100/10/[]/60`) |
| `frontend/src/views/performance/TaskList.vue` | 新增 `extractApiError()` 渲染 `字段: msg` 可读错误;`handleSave()` 修复 `stepConcurrency` 双重包裹;`handleCurlParse()` 错误处理走 `extractApiError()` |
**部署(2026-08-19 18:00 左右)**:scp schemas/performance.py + performance_service.py → 清理 *.pyc → 重启容器 healthy ✅;`npm run build`(31.34s)→ scp 前端 dist 全量 ✅
**验证**:5.60 实测带 null 的 POST /tasks → **201 创建成功**,DB 落库 `100/10/[]/60` ✅(测试任务已删除)
**⚠️ 用户侧**:浏览器需 **Ctrl+F5 强刷** 加载新前端(dist 静态文件无需重启容器,但浏览器可能缓存旧 JS)。
### P4 - 任务级自定义凭据的部署与验证 ✅(已部署)
......
......@@ -54,11 +54,11 @@ class PerformanceTaskCreate(BaseModel):
# 压测配置
mode: str = Field("concurrent", description="压测模式: concurrent/qps/step")
concurrency: int = Field(10, ge=1, le=500, description="并发数")
qps_target: int = Field(100, ge=1, description="目标QPS")
qps_target: Optional[int] = Field(default=None, ge=1, description="目标QPS(留空使用默认值100)")
duration: int = Field(60, ge=1, le=3600, description="压测时长(秒)")
ramp_up: int = Field(10, ge=0, description="预热时间(秒)")
step_concurrency: List[int] = Field(default_factory=list, description="阶梯并发配置")
step_duration: int = Field(60, ge=1, description="每阶梯时长(秒)")
ramp_up: Optional[int] = Field(default=None, ge=0, description="预热时间(秒)(留空使用默认值10)")
step_concurrency: Optional[List[int]] = Field(default=None, description="阶梯并发配置(留空使用默认值[])")
step_duration: Optional[int] = Field(default=None, ge=1, description="每阶梯时长(秒)(留空使用默认值60)")
# 认证配置
auth_required: bool = Field(True, description="是否需要登录Token")
......
......@@ -141,6 +141,16 @@ class PerformanceService:
# 用 schema 默认值补齐其余压测参数
payload = PerformanceTaskCreate(**explicit).model_dump()
# 补齐 Optional 压测参数的默认值(前端可能传 null)
if payload.get("qps_target") is None:
payload["qps_target"] = 100
if payload.get("ramp_up") is None:
payload["ramp_up"] = 10
if payload.get("step_concurrency") is None:
payload["step_concurrency"] = []
if payload.get("step_duration") is None:
payload["step_duration"] = 60
assertions = payload.pop("assertions", [])
task = PerformanceTask(
id=generate_id("perf"),
......
......@@ -583,7 +583,7 @@ async function handleCurlParse() {
applyCurlResult(result)
curlDialogVisible.value = false
} catch (e: any) {
ElMessage.error('curl 解析失败: ' + (e.response?.data?.detail || e.message || ''))
ElMessage.error('curl 解析失败: ' + extractApiError(e))
} finally {
curlParsing.value = false
}
......@@ -636,6 +636,20 @@ function methodTagType(method: string) {
return map[method] || 'info'
}
/** 提取后端校验错误的可读信息(Pydantic detail 可能是数组) */
function extractApiError(e: any): string {
const detail = e?.response?.data?.detail
if (Array.isArray(detail)) {
return detail
.map((d: any) => {
const loc = Array.isArray(d?.loc) ? d.loc.filter((x: any) => x !== 'body').join('.') : ''
return loc ? `${loc}: ${d?.msg || ''}` : (d?.msg || '')
})
.join(';')
}
return detail || e?.message || ''
}
/** 保存任务 */
async function handleSave() {
if (!formRef.value) return
......@@ -662,7 +676,12 @@ async function handleSave() {
qpsTarget: form.mode === 'qps' ? form.qpsTarget : null,
duration: form.duration,
rampUp: form.rampUp || null,
stepConcurrency: form.mode === 'step' && form.stepConcurrency ? [form.stepConcurrency] : null,
stepConcurrency:
form.mode === 'step' && form.stepConcurrency
? Array.isArray(form.stepConcurrency)
? form.stepConcurrency
: [form.stepConcurrency]
: null,
stepDuration: form.mode === 'step' ? (form.stepDuration || 30) : null,
authRequired: form.authRequired,
accountKey: form.authRequired ? form.accountKey : null,
......@@ -684,7 +703,7 @@ async function handleSave() {
dialogVisible.value = false
await loadTasks()
} catch (e: any) {
ElMessage.error('保存失败: ' + (e.response?.data?.detail || e.message || ''))
ElMessage.error('保存失败: ' + extractApiError(e))
} finally {
saving.value = false
}
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论