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

feat: 新增ERP自动上传与登录压测签名支持,优化执行器导航策略

- ERP自动上传:功能报告生成后自动上传到测试单/项目资料/协作文档
- 登录压测签名:支持X-RANDOM/X-TIMESTAMP/X-SIGN签名头与验证码UUID
- 执行器导航优化:新增策略5/6(表格行点击),登录后自动跳转目标页
- 部署服务重构:encrypt_password/decrypt_password 改为模块级函数
- 新增元素定位器基础服务与页面URL映射服务
- 新增PRD文档:元素映射定位方案、设备模拟、智能定位优化等
- 更新HANDOFF文档与settings.local.json权限配置
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 9477dc8d
......@@ -161,7 +161,43 @@
"Bash(awk '/^ # EMQX增强检测 - 综合指标统计/,/^ catch {/' \"C:\\\\Users\\\\UBAINS\\\\Desktop\\\\Test\\\\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(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(pip install *)",
"Bash(git commit -m 'feat\\(keyword-matcher\\): jieba分词优化 + 评分归一化 \\(Phase 1&2\\) *)",
"Bash(git push *)",
"Bash(git commit -m 'feat\\(smart-locate\\): 语义定位器+组合选择器+Claude增强+iframe穿透 \\(Phase 3-6\\) *)",
"Bash(netstat -ano)",
"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: $!\\)\")"
]
}
}
# 执行计划 — 元素映射表定位方案:前端 Key-Value 键值提升定位稳定性
> **文档版本**: v1.0
> **创建日期**: 2026-08-11
> **作者**: Claude Code
> **关联PRD**: `_PRD_元素映射表定位方案_前端key-value键值_提升定位稳定性.md`
> **状态**: 待确认
---
## 一、执行概览
### 1.1 目标
将前端提供的元素定位键值表转化为结构化 JSON 映射文件,作为 Playwright 执行器的最高优先级选择器来源,提升定位稳定性。以用例 `case_13650e0406e64779b66e6fa5de35c24a` 为实验验证目标,验证"登录→功能中心→对应分类下的模块点击"链路。
### 1.2 范围
- 后端新增 ElementMappingService
- 新增元素映射表 JSON 文件
- 改造 PlaywrightExecutor 选择器解析
- 目标用例实验验证
### 1.3 预估工时
| 阶段 | 工时 |
|------|------|
| Phase 1: 映射表 JSON 文件生成 | 1 小时 |
| Phase 2: ElementMappingService 实现 | 2 小时 |
| Phase 3: PlaywrightExecutor 集成 | 2 小时 |
| Phase 4: 目标用例实验验证 | 2 小时 |
| **总计** | **7 小时** |
---
## 二、Phase 1: 映射表 JSON 文件生成
### 2.1 任务清单
| # | 任务 | 文件 | 说明 |
|---|------|------|------|
| 1.1 | 解析前端文档 | `Docs/Doc_门户首页_首页元素获取键值_功能总结-新.md` | 提取全部键值对 |
| 1.2 | 生成映射表 JSON | `backend/app/data/elements_mapping.json` | 结构化存储元素映射 |
| 1.3 | 处理未开发功能 | JSON 中标记 | 排除 `publication_list.player` 等 3 个未开发功能 |
### 2.2 JSON 结构
```json
{
"version": "1.0",
"updated_at": "2026-08-11",
"source": "门户首页+功能中心元素定位键值表",
"elements": {
"导航栏-功能中心抽屉开关": {
"selector": ".home_nav_left",
"type": "css",
"description": "打开/关闭功能中心抽屉"
},
"全部功能-会议预约-创建会议": {
"selector": "[data-id=\"reserve_list.create\"]",
"type": "css",
"description": "创建会议按钮"
}
}
}
```
### 2.3 验收标准
| 检查项 | 预期 |
|--------|------|
| JSON 合法 | `python -c "import json; json.load(open(...))"` 通过 |
| 键值对数量 | ≥ 100 个 |
| data-key / data-id / id / class 覆盖 | 全部含定位优先级 |
---
## 三、Phase 2: ElementMappingService 实现
### 3.1 任务清单
| # | 任务 | 文件 | 说明 |
|---|------|------|------|
| 2.1 | 创建 ElementMappingService 类 | `backend/app/services/element_mapping_service.py` | 加载 + 匹配 + 查询 |
| 2.2 | 实现加载逻辑 | 同上 | 启动时加载 JSON,支持热重载 |
| 2.3 | 实现匹配逻辑 | 同上 | 精确匹配 + jieba 模糊匹配 + 上下文辅助 |
| 2.4 | 实现查询接口 | 同上 | `get_selector(step_name, action, context)` |
| 2.5 | 容错处理 | 同上 | JSON 损坏自动禁用,日志告警 |
### 3.2 核心接口
```python
class ElementMappingService:
def __init__(self, mapping_file: str = "elements_mapping.json"):
...
def get_selector(self, step_name: str, action: str,
context: Optional[Dict] = None) -> Optional[Dict]:
"""根据步骤描述和上下文返回映射表选择器"""
def get_all_keys(self) -> List[str]:
"""返回所有映射键列表"""
def reload(self) -> bool:
"""热重载映射表文件"""
```
### 3.3 匹配策略
| 匹配方式 | 说明 |
|---------|------|
| 精确匹配 | 步骤描述包含映射键中的关键词(分词后比对) |
| 模糊匹配 | jieba 分词后计算相似度,取最高分(阈值 ≥ 0.7) |
| 上下文辅助 | 结合当前页面 URL、前序步骤判断所在功能分类,缩小范围 |
### 3.4 验收标准
| 检查项 | 预期 |
|--------|------|
| 单测通过 | 精确/模糊/上下文匹配均正确 |
| 容错 | JSON 不存在/损坏时不抛异常 |
| 性能 | 单次查询 ≤ 10ms |
---
## 四、Phase 3: PlaywrightExecutor 集成
### 4.1 任务清单
| # | 任务 | 文件 | 说明 |
|---|------|------|------|
| 3.1 | 集成 ElementMappingService | `backend/app/executors/playwright_executor.py` | 作为最高优先级选择器来源 |
| 3.2 | 更新 SelectorMapper | `backend/app/utils/selector_mapper.py` | 增加映射表优先级 |
| 3.3 | 日志记录 | 同上 | 记录映射表命中/未命中 |
### 4.2 集成位置
`execute_step` 方法的选择器解析阶段,插入映射表查询作为最高优先级:
```python
# 步骤1: 查询元素映射表(最高优先级)
mapping_selector = element_mapping_service.get_selector(step_name, action)
if mapping_selector:
selector = mapping_selector["selector"]
logger.info(f"[映射表] 命中: {step_name} → {selector}")
# 直接使用映射表选择器执行
# 步骤2: 映射表未命中,回落 DB 选择器
# 步骤3: DB 选择器未命中,回落关键词匹配 + 智能定位
```
### 4.3 验收标准
| 检查项 | 预期 |
|--------|------|
| 集成无回归 | 原有用例执行不受影响 |
| 映射表命中优先 | 映射表覆盖的步骤优先使用映射表选择器 |
| 映射表未命中回退 | 自动回落原有策略 |
---
## 五、Phase 4: 目标用例实验验证
### 5.1 任务清单
| # | 任务 | 文件 | 说明 |
|---|------|------|------|
| 4.1 | 准备目标用例 | 服务器 MySQL | 从 `192.168.5.60:3307` 获取 `case_13650e0406e64779b66e6fa5de35c24a` |
| 4.2 | 执行实验 | `backend/scripts/` | 运行目标用例,验证步骤 1-9 |
| 4.3 | 分析结果 | 日志 | 检查步骤 7-9 是否命中映射表选择器 |
### 5.2 验证范围
| 步骤 | 描述 | 映射表预期命中 | 映射表选择器 |
|------|------|---------------|-------------|
| 1-6 | 登录操作 | ❌ | 使用原有登录模板 |
| 7 | 点击【功能中心】展开 | ✅ | `.home_nav_left` |
| 8 | 点击【会议预约】分类 | ✅ | `#reserve_enable` |
| 9 | 点击【新建会议】按钮 | ✅ | `[data-key="reserve_list.create"]` |
### 5.3 验收标准
| 指标 | 目标 |
|------|------|
| 步骤 7-9 映射表命中率 | 100% |
| 步骤 7-9 定位准确率 | 100% |
| 链路执行成功率 | 100%(登录→功能中心→模块点击) |
| 子页面操作 | 不考核(后续阶段) |
---
## 六、回退方案
| 场景 | 处理策略 |
|------|---------|
| 映射表 JSON 文件不存在 | 静默跳过,使用原有策略 |
| 映射表 JSON 解析失败 | 日志告警,使用原有策略 |
| 映射表查询无匹配 | 返回 None,继续下一优先级 |
| 映射表选择器执行失败 | 自动尝试下一优先级选择器 |
---
## 七、风险与缓解
| 风险 | 影响 | 缓解措施 |
|------|------|---------|
| data-key 随前端版本变化 | 映射表失效 | 与前端约定 data-key 稳定,版本同步 |
| 个性化菜单动态性 | 映射表与实际不一致 | 仅覆盖默认配置,实际以渲染为准 |
| 权限过滤导致元素不渲染 | 定位失败 | 确认测试账号权限 |
---
*本文档待确认后进入实现阶段。*
\ No newline at end of file
# 脚本录制经验总结
> 生成时间:2026-08-05
> 作者:czj / Claude Code
---
## 一、核心流程
```
编写录制脚本 → 运行并调试 → 记录有效选择器 → 通过API创建用例 → 验证执行
```
### 关键步骤
1. **启动浏览器**:必须忽略 SSL 证书(被测系统用自签名证书)
2. **登录流程**:需要处理协议复选框、验证码、跳转等待
3. **导航流程**:功能中心 → 抽屉菜单 → 目标页面
4. **记录步骤**:每一步操作记录 action、selector、value
5. **创建用例**:通过 API 写入数据库
6. **验证执行**:调用 `/api/executions/{id}/run` 触发执行
---
## 二、踩坑记录
### 2.1 登录相关
| # | 问题 | 原因 | 解决方案 |
|---|------|------|---------|
| 1 | 验证码输入框选错 | 登录页有 6 个输入框(账号登录/短信登录两套) | 用 `input[placeholder*="图"]` 精确匹配图形验证码 |
| 2 | 登录一直停在 login 页面 | 必须先勾选协议复选框 | 添加 `.el-checkbox` 点击步骤 |
| 3 | 登录跳转超时 | 微前端架构加载慢(7-8秒) | `wait` 的 timeout 设 15000ms |
| 4 | `wait` 操作报错 | 执行引擎要求 wait 必须有 selector | 修改为 `wait` 带选择器:`[class*='nav'], .container, .el-main` |
### 2.2 导航相关
| # | 问题 | 原因 | 解决方案 |
|---|------|------|---------|
| 5 | 功能中心 `.home_nav_left` 找不到 | 该 class 在当前页面不存在 | 使用 XPath:`//*[@id="Home"]/div[1]/div[1]` |
| 6 | 功能抽屉菜单项点击 | 抽屉中的菜单项是文本 | 使用组合选择器:`.el-drawer >> text="信息发布"` |
### 2.3 执行引擎相关
| # | 问题 | 原因 | 解决方案 |
|---|------|------|---------|
| 7 | 执行一直卡在 pending | 创建执行后需要调用 `/run` 端点 | POST `/api/executions/{id}/run` |
| 8 | 步骤结果为空 | `wait` 操作只传了 timeout 没有 selector | wait 必须传 selector |
---
## 三、有效选择器汇总
### 3.1 登录页面
| 元素 | 选择器 | 类型 |
|------|--------|------|
| 用户名输入框 | `input[placeholder*="手机号"]` | CSS |
| 密码输入框 | `input[type="password"]` | CSS |
| 图形验证码输入框 | `input[placeholder*="图"]` | CSS |
| 协议复选框 | `.el-checkbox` | CSS |
| 登录按钮 | `button:has-text("登录")` | CSS+文本 |
### 3.2 首页
| 元素 | 选择器 | 类型 |
|------|--------|------|
| 功能中心图标 | `//*[@id="Home"]/div[1]/div[1]` | XPath |
| 导航区域 | `[class*='nav'], .container, .el-main` | CSS |
### 3.3 功能抽屉
| 元素 | 选择器 | 类型 |
|------|--------|------|
| 抽屉容器 | `.el-drawer` | CSS |
| 菜单项 | `.el-drawer >> text="信息发布"` | CSS+文本 |
---
## 四、录制脚本模板
```python
from playwright.sync_api import sync_playwright
import json, time
steps = []
def add_step(order, name, action, params, expected=""):
step = {
"order": order, "name": name, "action": action,
"params": params, "expected": expected, "actual": "",
"selectors": None, "page_key": None, "element_key": None,
"force": None, "wait_after": None,
"locator_type": None, "locator_value": None
}
steps.append(step)
with sync_playwright() as p:
browser = p.chromium.launch(
headless=False,
args=['--ignore-certificate-errors']
)
context = browser.new_context(ignore_https_errors=True)
page = context.new_page()
# 1. 登录
page.goto('https://192.168.5.44/', wait_until='networkidle')
add_step(1, "访问登录页面", "navigate", {"url": "https://192.168.5.44/"})
page.wait_for_selector('input[placeholder*="手机号"]', timeout=10000)
add_step(2, "等待登录表单加载", "wait", {"selector": 'input[placeholder*="手机号"]', "timeout": 10000})
page.fill('input[placeholder*="手机号"]', 'admin@xty')
add_step(3, "输入用户名", "fill", {"selector": 'input[placeholder*="手机号"]', "value": "admin@xty"})
page.fill('input[type="password"]', 'Ubains@13579')
add_step(4, "输入密码", "fill", {"selector": 'input[type="password"]', "value": "Ubains@13579"})
page.fill('input[placeholder*="图"]', 'csba')
add_step(5, "输入验证码", "fill", {"selector": 'input[placeholder*="图"]', "value": "csba"})
page.locator('.el-checkbox').click()
add_step(6, "勾选协议复选框", "click", {"selector": '.el-checkbox'})
page.click('button:has-text("登录")')
add_step(7, "点击登录按钮", "click", {"selector": 'button:has-text("登录")'})
# 等待登录跳转
for i in range(15):
time.sleep(1)
if 'login' not in page.url:
break
add_step(8, "等待登录跳转完成", "wait", {"selector": "[class*='nav'], .container, .el-main", "timeout": 15000})
# 2. 导航到目标模块
page.click('//*[@id="Home"]/div[1]/div[1]')
add_step(9, "点击功能中心图标", "click", {"selector": '//*[@id="Home"]/div[1]/div[1]'})
page.wait_for_selector('.el-drawer', timeout=5000)
add_step(10, "等待功能抽屉打开", "wait", {"selector": '.el-drawer', "timeout": 5000})
page.click('.el-drawer >> text="信息发布"')
add_step(11, "点击信息发布菜单", "click", {"selector": '.el-drawer >> text="信息发布"'})
# 3. 在目标页面操作...
# page.wait_for_selector('.el-table', timeout=10000)
# add_step(12, "等待页面加载", "wait", {"selector": '.el-table', "timeout": 10000})
browser.close()
# 保存步骤
with open('recorded_steps.json', 'w', encoding='utf-8') as f:
json.dump(steps, f, ensure_ascii=False, indent=2)
```
---
## 五、与前端"获取定位"功能的对比
### 5.1 当前"获取定位"功能
- **入口**:用例管理页面 → 点击"获取定位"按钮
- **流程**:打开配置弹窗 → 调用后端 `/api/element/locate-batch` → 后端通过 Claude CLI 分析页面元素
- **局限**
- 需要手动填写步骤描述
- 依赖 Claude CLI 进行元素定位(可能不准确)
- 无法自动导航到目标页面(需要手动配置菜单)
### 5.2 脚本录制方式的优势
- **直接操作**:在浏览器中真实操作,所见即所得
- **选择器精确**:通过 Playwright 的 `page.fill()``page.click()` 等方法自动获取选择器
- **完整流程**:从登录到导航到操作,全流程录制
### 5.3 可应用的方向
1. **录制器增强**:将脚本录制模式集成到前端的"用例录制"功能
2. **智能选择器提取**:在录制过程中自动提取元素的所有可用选择器(CSS、XPath、文本)
3. **选择器回退策略**:录制时同时记录多个候选选择器,执行时按优先级回退
4. **登录流程模板化**:将登录步骤固化为模板,新用例自动包含
---
## 六、已验证的用例
| 用例名称 | 用例ID | 模块 | 步骤数 | 执行结果 |
|----------|--------|------|--------|---------|
| 信息发布-导航验证 | case_a6e96c9a38934456901a8a40b9786bf2 | 信息发布 | 12 | ✅ 全部通过 |
---
*本文档由 Claude Code 于 2026-08-05 生成,记录脚本录制经验总结。*
# 问题处理:智能定位选择器不准的5个核心缺陷
> **文档类型**: 问题处理记录
> **创建日期**: 2026-08-10
> **作者**: Claude Code
> **优先级**: P0(阻塞会议管理用例执行)
> **状态**: ⏳ 待修复
---
## 一、问题描述
### 1.1 现象
执行任务 `exec_5f48bf8852b54c5299fd7dd470aa4d83`(会议管理-新建会议-czj录入)时,步骤1-9通过,**步骤10失败**,后续步骤全部跳过。
**失败步骤详情**
| 步骤 | 名称 | 选择器 | 结果 |
|------|------|--------|------|
| 9 | 点击【新建会议】按钮 | `div:visible:has-text("新建会议")` | ✅ passed(但实际可能点错) |
| **10** | **会议名称输入:自动化新建会议** | **`input:visible`** | ❌ **failed** |
| 11-21 | 后续步骤 | - | ⏭ 跳过 |
**步骤10报错**`✗ 无法填充元素(已尝试 1 个选择器): ['input:visible'] 最后错误: Timeout 5000ms exceeded.`
### 1.2 关键证据
步骤9和步骤10的截图文件大小完全相同(945479字节),说明步骤9点击"新建会议"后页面状态无变化——**新建会议弹窗根本没有打开**
### 1.3 影响范围
| 影响项 | 说明 |
|--------|------|
| 会议管理-新建会议用例 | 步骤9-21选择器不准或为空,无法执行 |
| 智能定位整体准确率 | 回退选择器太宽泛,影响整体定位准确率 |
| 涉及文件 | `keyword_matcher.py``smart_locate_service.py``selector_extractor.py` |
---
## 二、根因分析
### 缺陷1:回退选择器太宽泛(P0)
**位置**`keyword_matcher.py:832-863` `find_element_by_semantic()` fill 回退
**问题**:当关键词匹配不到输入框时,回退到 `input:visible`,匹配页面上第一个可见 input,完全不考虑输入框用途。
```python
# 当前代码(第858-863行)
inputs = page.locator('input:visible, textarea:visible').all()
if inputs:
if len(inputs) == 1:
return inputs[0], [{
'type': 'css',
'value': 'input:visible', # ← 宽泛回退
'confidence': 0.70,
'priority': 1
}]
# ...
# 默认返回第一个输入框
return inputs[0], [{
'type': 'css',
'value': 'input:visible', # ← 宽泛回退
'confidence': 0.60,
'priority': 1
}]
```
**影响**:步骤10"会议名称输入"生成 `input:visible`,弹窗没打开时匹配到页面上的第一个 visible input(可能是搜索框),导致输入到错误位置。
**修复方向**:结合步骤名称关键词匹配 `input[placeholder*="{kw}"]`,优先使用 placeholder 关键词匹配。
---
### 缺陷2:文本匹配不分元素类型(P0)
**位置**`keyword_matcher.py:603-612` 文本匹配选择器生成
**问题**`div:visible:has-text("新建会议")` 匹配页面上任意包含该文本的可见 div,无法区分菜单项、按钮、文字标签。
```python
# 当前代码(第603-606行)
if tag in ['BUTTON', 'A']:
selector_value = f'{tag.lower()}:has-text("{text}")'
else:
selector_value = f'{tag.lower()}:visible:has-text("{text}")'
```
**影响**:步骤9点击"新建会议"可能点到了菜单项(`div:has-text("新建会议")`)而非弹窗按钮,导致弹窗未打开。
**修复方向**
1. 点击操作优先匹配 `button:has-text()``[role="button"]:has-text()`
2. 只有当找不到 button 时才回退到 `div:has-text()`
3.`find_element_by_semantic` 的 click 分支中,优先使用 button 定位
---
### 缺陷3:无页面状态感知(P1)
**位置**`smart_locate_service.py:721-785` `_execute_step()`
**问题**:点击操作后不验证页面状态是否改变(弹窗是否打开、页面是否跳转),直接走下一步。
```python
# 当前代码(第755-758行)
if action == 'click':
page.wait_for_selector(selector, timeout=5000)
page.click(selector)
page.wait_for_timeout(1000) # 只等1秒,不验证结果
```
**影响**:步骤9点击失败但标记为 passed,步骤10在错误页面状态下定位 `input:visible`
**修复方向**
1. 点击后检测 `.el-dialog`/`.el-drawer` 是否出现
2. 如果期望的弹窗未出现,标记为定位失败而非 passed
3. 根据步骤名称判断期望的页面状态变化(如"新建会议"→ 期望弹窗出现)
---
### 缺陷4:选择器生成无上下文限定(P1)
**位置**`keyword_matcher.py` 整体匹配逻辑
**问题**:选择器不限定父容器范围,多个同名元素(如多个"确定"按钮)无法区分。
**当前代码分析**`selector_extractor.py` 已有 `generate_combined_selectors()` 函数(Phase 4 实现),但 `keyword_matcher.py``match_element_by_keywords()` 生成选择器时未使用组合选择器逻辑。
**影响**:步骤14/16/20 都用 `div:visible:has-text("会议室列表")` 同一个宽泛选择器。
**修复方向**
1. 自动检测父容器(`.el-dialog`/`.el-drawer`/`form`
2.`match_element_by_keywords()` 中为选择器添加父容器前缀
3. 利用 `selector_extractor.py` 已有的组合选择器函数
---
### 缺陷5:用例数据本身有错(P2)
**位置**:用例 `case_13650e0406e64779b66e6fa5de35c24a` 步骤定义
**问题**
- 步骤17「点击确定创建按钮」选择器为空
- 步骤18/19/21 用 `input[placeholder*="搜索"]` 做点击操作(明显错误)
- 步骤14/16/20 用同一个 `div:visible:has-text("会议室列表")` 选择器
**修复方向**:重新对用例跑智能定位,或手动修正选择器。
---
## 三、修复方案
### 方案A:修复代码逻辑(本次实施)
| 缺陷 | 修复内容 | 涉及文件 | 预计工时 |
|------|---------|---------|---------|
| 缺陷1 | `find_element_by_semantic` fill 回退:用 placeholder 关键词匹配替代 `input:visible` | `keyword_matcher.py` | 1h |
| 缺陷2 | 文本匹配优先限定元素类型:`button:has-text()` 优先于 `div:visible:has-text()` | `keyword_matcher.py` | 1h |
| 缺陷3 | 步骤执行后验证页面状态:检测弹窗/对话框是否出现 | `smart_locate_service.py` | 2h |
| 缺陷4 | 选择器生成加父容器上下文限定:利用已有组合选择器 | `keyword_matcher.py` + `selector_extractor.py` | 2h |
### 方案B:重新对用例跑智能定位(方案A之后实施)
- 智能定位实际执行后提取选择器,准确率更高
- 新版有 Claude 语义增强 + 组合选择器(Phase 4)
- 可一次性修正所有错误选择器
---
## 四、验收标准
### 4.1 代码验收
| 验收项 | 验收标准 |
|--------|---------|
| fill 回退优化 | `input:visible` 不再出现,改为 `input[placeholder*="关键词"]` |
| 文本匹配优化 | `button:has-text()` 优先于 `div:has-text()` |
| 页面状态感知 | 点击后检测弹窗是否出现,未出现则标记失败 |
| 父容器上下文 | 选择器带 `.el-dialog`/`.el-drawer` 前缀 |
### 4.2 功能验收
| 验收项 | 验收标准 |
|--------|---------|
| 会议管理用例 | 重新智能定位后,步骤9-21选择器不为空 |
| 系统设置回归 | 6/6 步骤继续通过 |
| 信息发布回归 | 保持通过 |
---
## 五、相关文档
| 文档 | 路径 |
|------|------|
| 执行计划 | `_执行计划_修复智能定位选择器5个核心缺陷.md` |
| HANDOFF UI自动化 | `HANDOFF_UI自动化.md` |
| 智能定位准确率提升 PRD | `Docs/PRD/需求文档/用例管理/_PRD_智能定位准确率提升_聚焦核心快速见效.md` |
| 智能定位执行计划 | `Docs/PRD/需求文档/用例管理/_执行计划_智能定位准确率提升_聚焦核心快速见效.md` |
\ No newline at end of file
# 问题处理记录 — 创建模拟设备失败(已解决)
> **文档类型**: 问题处理记录
> **创建日期**: 2026-08-03
> **作者**: Claude Code
> **优先级**: P0(阻塞)
> **状态**: ✅ 已解决
> **修复版本**: v5.60
---
## 一、问题描述
### 1.1 错误现象
用户在前端点击"新增设备"时,后端返回多个连续错误:
```
错误1: 'SimulatorCreate' object has no attribute 'metadata'
错误2: SimulatorResponse validation error - metadata field type mismatch
错误3: 'SimulatorCreate' object has no attribute 'topic_params'
错误4: sqlite3.OperationalError: no such column: device_simulators.topic_params
```
### 1.2 影响范围
- **影响功能**: 所有设备类型的创建操作完全失败
- **影响用户**: 全部用户
- **严重程度**: P0(阻塞功能)
---
## 二、根因分析
### 2.1 多层问题叠加
1. **字段名不一致**: service 层使用 `data.metadata`,但 schema 中是 `extra_attrs`
2. **SQLAlchemy MetaData 冲突**: Pydantic 尝试序列化 SQLAlchemy 的内部 `metadata` 对象
3. **Schema 字段缺失**: git checkout 恢复文件后,`topic_params` 字段丢失
4. **数据库列缺失**: `topic_params` 列未添加到数据库表
### 2.2 触发过程
1. 主题配置改造时,字段名从 `metadata` 改为 `extra_attrs`,service 层未同步
2. 修复第一个问题后,Pydantic 的 `from_attributes=True` 读取到 SQLAlchemy 的 `MetaData` 对象
3. 尝试修复时,`git checkout` 恢复文件到原始状态,丢失主题配置改造的所有字段
4. 数据库表未执行迁移,缺少 `topic_params`
---
## 三、修复方案(全部完成)
### 3.1 修复字段名不一致
**文件**: `backend/app/services/device_sim_service.py:389`
```python
# 修改前
extra_attrs=data.metadata or {},
# 修改后
extra_attrs=data.extra_attrs or {},
```
### 3.2 添加 field_validator
**文件**: `backend/app/schemas/device_sim.py`
```python
# 1. 添加导入
from pydantic import BaseModel, Field, ConfigDict, field_validator
# 2. 在 SimulatorResponse 中添加验证器
@field_validator('metadata', mode='before')
@classmethod
def ignore_sqlalchemy_metadata(cls, v):
"""忽略 SQLAlchemy 的 MetaData 对象,只接受 dict 类型"""
if not isinstance(v, dict):
return None
return v
```
### 3.3 恢复缺失字段
**文件**: `backend/app/schemas/device_sim.py`
`SimulatorBase``SimulatorUpdate` 中添加:
```python
topic_params: Optional[Dict[str, str]] = Field(default=None, description="主题动态参数")
```
在文件末尾添加:
```python
class TopicTemplateResponse(BaseModel):
"""主题模板响应"""
key: str = Field(..., description="模板唯一标识")
# ... 其他字段
class TopicTemplateListResponse(BaseModel):
"""主题模板列表响应"""
device_type: str = Field(..., description="设备类型")
templates: List[TopicTemplateResponse] = Field(default=[], description="主题模板列表")
has_real_topics: bool = Field(default=False, description="是否有真实主题模板")
```
### 3.4 数据库迁移
```python
# 添加缺失的列
ALTER TABLE device_simulators ADD COLUMN topic_params TEXT
```
---
## 四、验证结果
### 4.1 后端验证(全部通过)
```bash
# 1. 健康检查
GET http://localhost:8001/health
Status: 200 ✓
# 2. 创建门口屏设备
POST http://localhost:8001/api/device-sim/devices
Status: 201 ✓
# 3. 创建无纸化设备
POST http://localhost:8001/api/device-sim/devices
Status: 201 ✓
# 4. 创建中控设备
POST http://localhost:8001/api/device-sim/devices
Status: 201 ✓
# 5. 获取主题模板
GET http://localhost:8001/api/device-sim/topic-templates/door
Status: 200 ✓
Templates: 8 个
```
### 4.2 功能验证(全部通过)
- ✓ 创建设备 API 返回 201
- ✓ 不同设备类型(door/paperless/central)均可创建
- ✓ 主题参数配置正常保存
- ✓ 主题模板 API 正常返回
- ✓ 无其他副作用
---
## 五、修改文件清单
| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `backend/app/services/device_sim_service.py` | 修改 | 第 389 行:字段名修正 |
| `backend/app/schemas/device_sim.py` | 修改 | 添加 `field_validator``topic_params` 字段、主题模板 Schema |
| `backend/data/test_platform.db` | 修改 | 添加 `topic_params` 列 |
| `Docs/PRD/需求文档/设备模拟/_PRD_需求文档_设备模拟模块.md` | 修改 | 更新版本至 v5.60 |
---
## 六、经验总结
### 6.1 Pydantic 验证机制
- `Field(exclude=True)` 只在序列化阶段排除字段
- `field_validator(mode='before')` 在类型检查前执行,可拦截并转换字段值
- SQLAlchemy 的 `metadata` 属性会与 Pydantic 冲突,需提前过滤
### 6.2 Git 操作风险
- `git checkout` 会丢失未提交的修改
- 应优先使用 `git stash` 保存工作区修改
- 重要修改应及时提交,避免丢失
### 6.3 数据库迁移
- 模型字段变更后需同步数据库表结构
- 可使用 Alembic 或手动 ALTER TABLE
- 测试环境可删除数据库文件重新创建
---
*本文档记录创建模拟设备失败问题的完整分析与修复过程,所有问题已解决。*
此差异已折叠。
此差异已折叠。
{
"version": "1.0",
"description": "页面URL映射表:根据用例步骤识别目标页面,直达URL跳过中间导航点击",
"pages": [
{
"id": "create_meeting",
"name": "新建会议",
"url": "https://192.168.5.44/#/meetingV3?meetingV3=%2FmeetingV3%2F%23%2FCreateMeeting",
"match_rules": [
{"type": "step_name_contains", "value": "新建会议"},
{"type": "selector_contains", "value": "新建会议"}
],
"skip_steps": {
"step_name_contains": ["新建会议", "功能中心", "会议预约"],
"action_is": ["click"]
}
}
]
}
\ No newline at end of file
......@@ -132,6 +132,8 @@ async def _ensure_columns(conn) -> None:
("executions", "case_type", "VARCHAR(20) DEFAULT 'ui'"),
# 设备模拟:环境配置多主题字段(旧库升级)
("device_env_configs", "topics", "JSON"),
# 性能测试:登录接口压测需签名(旧库升级)
("performance_tasks", "sign_request", "BOOLEAN DEFAULT 0"),
]
def _do_ensure(sync_conn) -> None:
......
......@@ -394,6 +394,14 @@ class PerformanceExecutor:
self._running = False
self._metrics: Optional[MetricsCollector] = None
self._validator = ResultValidator()
# 登录接口压测时复用的验证码 UUID(首次获取,后续复用)
self._login_uuid: Optional[str] = None
def _get_http_client(self) -> HttpClient:
"""获取或创建 HttpClient 实例"""
if not self._http_client:
self._http_client = HttpClient(self.http_client_config)
return self._http_client
def _ensure_token(self, task) -> str:
"""
......@@ -413,6 +421,52 @@ class PerformanceExecutor:
return self._token or ""
def _is_login_target(self, task) -> bool:
"""
判断目标任务是否为登录接口
登录接口需要特殊处理:请求体包含 username/password/code/uuid,
且请求头需要签名(X-RANDOM/X-TIMESTAMP/X-SIGN/RandomCode)。
"""
login_path = self.http_client_config.get("auth", {}).get(
"login_path", "/platform/api/auth/login"
)
url = (task.target_url or "").lower()
return login_path in url
def _get_login_body(self, task) -> Dict[str, Any]:
"""
构造登录接口请求体
使用加密后的密码和验证码 UUID,确保每次压测请求的登录请求合法。
Args:
task: PerformanceTask 任务对象
Returns:
dict: 登录请求体
"""
client = self._get_http_client()
accounts = self.http_client_config.get("accounts", {})
account = accounts.get(task.account_key, {})
username = account.get("username", "admin@xty")
password = account.get("password", "Ubains@13579")
captcha = accounts.get("captcha", "csba")
# 加密密码
encrypted_pwd = HttpClient.encrypt_password(password)
# 获取验证码 UUID(复用,避免每次压测请求都请求验证码接口)
if not self._login_uuid:
self._login_uuid = client.get_captcha_uuid() or ""
return {
"username": username,
"password": encrypted_pwd,
"code": captcha,
"uuid": self._login_uuid,
}
def _build_headers(self, task) -> Dict[str, str]:
"""
构造请求头
......@@ -430,7 +484,16 @@ class PerformanceExecutor:
headers.setdefault("Content-Type", "application/json")
headers.setdefault("Accept", "application/json, text/plain, */*")
if task.auth_required and self._token:
# 登录接口需要签名(X-RANDOM/X-TIMESTAMP/X-SIGN/RandomCode)
# 注意:登录接口不能带 Authorization 头,否则签名校验会失败
# (签名是用 bearer_token="" 生成的,带 Authorization 会导致签名不匹配)
if self._is_login_target(task):
body = self._get_login_body(task)
sign_headers = self._get_http_client()._generate_sign(
body_data=body, bearer_token=""
)
headers.update(sign_headers)
elif task.auth_required and self._token:
headers["Authorization"] = f"Bearer {self._token}"
return headers
......@@ -682,7 +745,12 @@ class PerformanceExecutor:
headers = self._build_headers(task)
method = task.method.upper()
url = task.target_url
json_data = task.body if method in ("POST", "PUT", "PATCH") else None
# 登录接口使用自动构造的请求体(含加密密码 + 验证码UUID)
if self._is_login_target(task):
json_data = self._get_login_body(task)
else:
json_data = task.body if method in ("POST", "PUT", "PATCH") else None
async with session.request(
method, url,
......
......@@ -109,6 +109,9 @@ class PerformanceTask(Base):
auth_required: Mapped[bool] = mapped_column(Boolean, default=True, comment="是否需要登录Token")
account_key: Mapped[str] = mapped_column(String(50), default="superadmin", comment="使用的账号")
# 签名配置
sign_request: Mapped[bool] = mapped_column(Boolean, default=False, comment="请求是否需要签名(X-RANDOM/X-TIMESTAMP/X-SIGN)")
# 断言配置
assertions: Mapped[list] = mapped_column(JSON, default=list, comment="断言规则列表")
......@@ -174,6 +177,7 @@ class PerformanceTask(Base):
"step_duration": self.step_duration,
"auth_required": self.auth_required,
"account_key": self.account_key,
"sign_request": self.sign_request,
"assertions": self.assertions or [],
"total_requests": self.total_requests,
"success_count": self.success_count,
......
此差异已折叠。
......@@ -27,6 +27,25 @@ class GenerateRequest(BaseModel):
project_name: str = Field("", description="项目名称")
report_format: str = Field("docx", description="报告格式: docx/md/both")
# ERP上传选项(可选,勾选后在生成报告后自动上传)
upload_to_erp: bool = Field(False, description="是否上传到ERP测试单")
upload_to_project: bool = Field(False, description="是否上传到项目资料")
upload_to_cooperation: bool = Field(False, description="是否上传到协作文档")
# ERP测试单配置
developtesting_id: Optional[int] = Field(None, description="测试单ID")
copyuser_names: List[str] = Field(default_factory=list, description="抄送人姓名列表")
createuser_name: Optional[str] = Field(None, description="创建人姓名")
# 项目资料配置
project_id: Optional[int] = Field(None, description="项目资料的项目ID")
project_file_name: Optional[str] = Field(None, description="项目资料的文件名称")
# 协作文档配置
cooperation_project_id: Optional[int] = Field(None, description="协作文档的项目ID")
cooperation_file_name: Optional[str] = Field(None, description="协作文档的文件名称")
cooperation_group_id: Optional[int] = Field(None, description="协作文档的分组ID")
class GenerateResponse(BaseModel):
"""生成报告响应"""
......@@ -37,6 +56,7 @@ class GenerateResponse(BaseModel):
case_bug_link_count: int = Field(0, description="用例-BUG关联数")
charts: Dict[str, str] = Field(default_factory=dict, description="图表文件路径")
reports: List[str] = Field(default_factory=list, description="报告文件路径列表")
erp_results: List[Dict] = Field(default_factory=list, description="ERP上传结果列表")
class DownloadResponse(BaseModel):
......
此差异已折叠。
此差异已折叠。
此差异已折叠。
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论