From 2fea5adb44b11617f8a9ca9f9d13c51d29071e51 Mon Sep 17 00:00:00 2001 From: qiaoxinjiu Date: Mon, 11 May 2026 14:29:16 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E4=BA=A4=E6=89=80=E6=9C=89=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E5=88=B0=20qiaoxinjiu=20=E5=88=86=E6=94=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/RBAC_API.md | 154 +- .plan/1ZFcjkLpHmrHbluVoOtA4.md | 2051 +++++++++++++++++ __pycache__/const.cpython-38.pyc | Bin 1662 -> 2006 bytes api_test_document.md | 416 ---- app/__init__.py | 2 +- app/__pycache__/__init__.cpython-38.pyc | Bin 685 -> 685 bytes app/__pycache__/scheduler.cpython-38.pyc | Bin 0 -> 2001 bytes app/api/__pycache__/views.cpython-38.pyc | Bin 37160 -> 43314 bytes .../automationController.cpython-38.pyc | Bin 0 -> 3144 bytes .../__pycache__/caseController.cpython-38.pyc | Bin 13001 -> 13057 bytes .../__pycache__/planController.cpython-38.pyc | Bin 8132 -> 7663 bytes .../projectController.cpython-38.pyc | Bin 7609 -> 7609 bytes .../projectHookController.cpython-38.pyc | Bin 6378 -> 6378 bytes .../__pycache__/rbacController.cpython-38.pyc | Bin 9084 -> 10476 bytes .../__pycache__/userController.cpython-38.pyc | Bin 4865 -> 5001 bytes app/api/controller/automationController.py | 53 + app/api/controller/caseController.py | 8 +- app/api/controller/planController.py | 37 +- app/api/controller/rbacController.py | 61 +- app/api/controller/userController.py | 5 +- .../__pycache__/automationDao.cpython-38.pyc | Bin 0 -> 6003 bytes .../dao/__pycache__/caseDao.cpython-38.pyc | Bin 3954 -> 9572 bytes .../__pycache__/projectHookDao.cpython-38.pyc | Bin 2580 -> 2580 bytes .../dao/__pycache__/rbacDao.cpython-38.pyc | Bin 7680 -> 8571 bytes app/api/dao/automationDao.py | 129 ++ app/api/dao/caseDao.py | 163 +- app/api/dao/rbacDao.py | 20 + .../automationModel.cpython-38.pyc | Bin 0 -> 3920 bytes .../__pycache__/planModel.cpython-38.pyc | Bin 2795 -> 2925 bytes .../projectHookModel.cpython-38.pyc | Bin 1484 -> 1484 bytes app/api/model/automationModel.py | 70 + app/api/model/planModel.py | 2 + .../automationService.cpython-38.pyc | Bin 0 -> 16753 bytes .../__pycache__/bugService.cpython-38.pyc | Bin 2489 -> 2489 bytes .../__pycache__/caseService.cpython-38.pyc | Bin 1954 -> 1987 bytes .../jenkinsPollService.cpython-38.pyc | Bin 0 -> 8582 bytes .../__pycache__/planService.cpython-38.pyc | Bin 1763 -> 2374 bytes .../projectHookService.cpython-38.pyc | Bin 1818 -> 1818 bytes .../__pycache__/rbacService.cpython-38.pyc | Bin 3695 -> 4067 bytes app/api/service/automationService.py | 521 +++++ app/api/service/caseService.py | 4 +- app/api/service/jenkinsPollService.py | 302 +++ app/api/service/planService.py | 20 + app/api/service/rbacService.py | 8 + .../__pycache__/authMiddleware.cpython-38.pyc | Bin 4802 -> 5970 bytes app/api/utils/authMiddleware.py | 50 +- app/api/views.py | 233 +- automation_menus.sql | 59 + automation_permission.sql | 96 + bug_api_document.md | 539 ----- check_permission_table.py | 36 + .../__pycache__/jenkinsRequest.cpython-38.pyc | Bin 0 -> 3059 bytes common/jenkinsRequest.py | 82 + const.py | 14 +- generate_automation_menus.py | 75 + resources/automation_api_doc.md | 592 +++++ resources/sql/automation_execution_pgsql.sql | 177 ++ test_fix.py | 47 + 数据库语句 | 534 ----- 59 files changed, 4957 insertions(+), 1603 deletions(-) create mode 100644 .plan/1ZFcjkLpHmrHbluVoOtA4.md delete mode 100644 api_test_document.md create mode 100644 app/__pycache__/scheduler.cpython-38.pyc create mode 100644 app/api/controller/__pycache__/automationController.cpython-38.pyc create mode 100644 app/api/controller/automationController.py create mode 100644 app/api/dao/__pycache__/automationDao.cpython-38.pyc create mode 100644 app/api/dao/automationDao.py create mode 100644 app/api/model/__pycache__/automationModel.cpython-38.pyc create mode 100644 app/api/model/automationModel.py create mode 100644 app/api/service/__pycache__/automationService.cpython-38.pyc create mode 100644 app/api/service/__pycache__/jenkinsPollService.cpython-38.pyc create mode 100644 app/api/service/automationService.py create mode 100644 app/api/service/jenkinsPollService.py create mode 100644 automation_menus.sql create mode 100644 automation_permission.sql delete mode 100644 bug_api_document.md create mode 100644 check_permission_table.py create mode 100644 common/__pycache__/jenkinsRequest.cpython-38.pyc create mode 100644 common/jenkinsRequest.py create mode 100644 generate_automation_menus.py create mode 100644 resources/automation_api_doc.md create mode 100644 resources/sql/automation_execution_pgsql.sql create mode 100644 test_fix.py delete mode 100644 数据库语句 diff --git a/.agents/RBAC_API.md b/.agents/RBAC_API.md index 74e1550..271859c 100644 --- a/.agents/RBAC_API.md +++ b/.agents/RBAC_API.md @@ -30,11 +30,11 @@ ### 1.2 错误码使用习惯 -| code | 说明 | -|---|---| -| 20000 | 成功 | +| code | 说明 | +| ----- | -------------- | +| 20000 | 成功 | | 40009 | 创建类失败 / 参数校验失败 | -| 40011 | 详情查询失败 | +| 40011 | 详情查询失败 | | 40012 | 更新 / 删除 / 分配失败 | ### 1.3 当前实现注意事项 @@ -43,22 +43,23 @@ - 分配类接口均为覆盖式保存 - 当前密码字段是占位实现,后续建议替换为真实 hash ---- +*** ## 2. 角色管理 ### 2.1 角色列表 + - 方法:`GET` - 路径:`/role/list` 请求参数: -| 参数名 | 类型 | 必填 | 说明 | -|---|---|---|---| -| keyword | string | 否 | 角色名称模糊搜索 | -| status | int | 否 | 1启用 0禁用 | -| pageNo | int | 否 | 页码,默认1 | -| pageSize | int | 否 | 每页条数,默认20 | +| 参数名 | 类型 | 必填 | 说明 | +| -------- | ------ | -- | --------- | +| keyword | string | 否 | 角色名称模糊搜索 | +| status | int | 否 | 1启用 0禁用 | +| pageNo | int | 否 | 页码,默认1 | +| pageSize | int | 否 | 每页条数,默认20 | 返回 `data`: @@ -82,18 +83,20 @@ ``` ### 2.2 角色详情 + - 方法:`GET` - 路径:`/role/detail` 请求参数: -| 参数名 | 类型 | 必填 | 说明 | -|---|---|---|---| -| roleId | int | 是 | 角色ID | +| 参数名 | 类型 | 必填 | 说明 | +| ------ | --- | -- | ---- | +| roleId | int | 是 | 角色ID | 返回:单个角色对象。 ### 2.3 创建角色 + - 方法:`POST` - 路径:`/role/create` @@ -119,6 +122,7 @@ ``` ### 2.4 更新角色 + - 方法:`POST` - 路径:`/role/update` @@ -141,6 +145,7 @@ ``` ### 2.5 删除角色 + - 方法:`POST` - 路径:`/role/delete` @@ -160,23 +165,24 @@ } ``` ---- +*** ## 3. 权限管理 ### 3.1 权限列表 + - 方法:`GET` - 路径:`/permission/list` 请求参数: -| 参数名 | 类型 | 必填 | 说明 | -|---|---|---|---| -| keyword | string | 否 | 权限名称模糊搜索 | -| module | string | 否 | 模块名 | -| status | int | 否 | 状态 | -| pageNo | int | 否 | 页码 | -| pageSize | int | 否 | 每页条数 | +| 参数名 | 类型 | 必填 | 说明 | +| -------- | ------ | -- | -------- | +| keyword | string | 否 | 权限名称模糊搜索 | +| module | string | 否 | 模块名 | +| status | int | 否 | 状态 | +| pageNo | int | 否 | 页码 | +| pageSize | int | 否 | 每页条数 | 返回 `data`: @@ -200,11 +206,13 @@ ``` ### 3.2 权限详情 + - 方法:`GET` - 路径:`/permission/detail` - 参数:`permissionId` ### 3.3 创建权限 + - 方法:`POST` - 路径:`/permission/create` @@ -222,26 +230,29 @@ ``` ### 3.4 更新权限 + - 方法:`POST` - 路径:`/permission/update` ### 3.5 删除权限 + - 方法:`POST` - 路径:`/permission/delete` ---- +*** ## 4. 菜单管理 ### 4.1 菜单树 + - 方法:`GET` - 路径:`/menu/tree` 请求参数: -| 参数名 | 类型 | 必填 | 说明 | -|---|---|---|---| -| status | int | 否 | 状态过滤 | +| 参数名 | 类型 | 必填 | 说明 | +| ------ | --- | -- | ---- | +| status | int | 否 | 状态过滤 | 返回 `data`: @@ -284,11 +295,13 @@ ``` ### 4.2 菜单详情 + - 方法:`GET` - 路径:`/menu/detail` - 参数:`menuId` ### 4.3 创建菜单 + - 方法:`POST` - 路径:`/menu/create` @@ -311,18 +324,21 @@ ``` ### 4.4 更新菜单 + - 方法:`POST` - 路径:`/menu/update` ### 4.5 删除菜单 + - 方法:`POST` - 路径:`/menu/delete` ---- +*** ## 5. 角色权限分配 ### 5.1 查询角色权限 + - 方法:`GET` - 路径:`/role/permission/list` - 参数:`roleId` @@ -336,6 +352,7 @@ ``` ### 5.2 分配角色权限 + - 方法:`POST` - 路径:`/role/permission/assign` @@ -356,11 +373,12 @@ } ``` ---- +*** ## 6. 角色菜单分配 ### 6.1 查询角色菜单 + - 方法:`GET` - 路径:`/role/menu/list` - 参数:`roleId` @@ -374,6 +392,7 @@ ``` ### 6.2 分配角色菜单 + - 方法:`POST` - 路径:`/role/menu/assign` @@ -394,22 +413,23 @@ } ``` ---- +*** ## 7. 用户管理 ### 7.1 用户列表 + - 方法:`GET` - 路径:`/user/list` 请求参数: -| 参数名 | 类型 | 必填 | 说明 | -|---|---|---|---| -| keyword | string | 否 | 用户名模糊搜索 | -| status | int | 否 | 状态 | -| pageNo | int | 否 | 页码 | -| pageSize | int | 否 | 每页条数 | +| 参数名 | 类型 | 必填 | 说明 | +| -------- | ------ | -- | ------- | +| keyword | string | 否 | 用户名模糊搜索 | +| status | int | 否 | 状态 | +| pageNo | int | 否 | 页码 | +| pageSize | int | 否 | 每页条数 | 返回 `data`: @@ -437,6 +457,7 @@ ``` ### 7.2 用户详情 + - 方法:`GET` - 路径:`/user/detail` - 参数:`userId` @@ -450,6 +471,7 @@ ``` ### 7.3 创建用户 + - 方法:`POST` - 路径:`/user/create` @@ -477,18 +499,21 @@ ``` ### 7.4 更新用户 + - 方法:`POST` - 路径:`/user/update` ### 7.5 删除用户 + - 方法:`POST` - 路径:`/user/delete` ---- +*** ## 8. 用户角色分配 ### 8.1 查询用户角色 + - 方法:`GET` - 路径:`/user/role/list` - 参数:`userId` @@ -502,6 +527,7 @@ ``` ### 8.2 分配用户角色 + - 方法:`POST` - 路径:`/user/role/assign` @@ -522,11 +548,12 @@ } ``` ---- +*** ## 9. 认证接口 ### 9.1 注册 + - 方法:`POST` - 路径:`/auth/register` @@ -546,15 +573,15 @@ 请求参数说明: -| 参数名 | 类型 | 必填 | 说明 | -|---|---|---|---| -| username | string | 是 | 登录用户名 | -| password | string | 是 | 登录密码,当前直接写入 `password_hash` | -| realName | string | 否 | 真实姓名 | -| mobile | string | 否 | 手机号 | -| email | string | 否 | 邮箱 | -| avatar | string | 否 | 头像 | -| createdBy | int | 否 | 创建人 | +| 参数名 | 类型 | 必填 | 说明 | +| --------- | ------ | -- | --------------------------- | +| username | string | 是 | 登录用户名 | +| password | string | 是 | 登录密码,当前直接写入 `password_hash` | +| realName | string | 否 | 真实姓名 | +| mobile | string | 否 | 手机号 | +| email | string | 否 | 邮箱 | +| avatar | string | 否 | 头像 | +| createdBy | int | 否 | 创建人 | 成功返回: @@ -565,10 +592,12 @@ ``` 失败场景: + - `username、password 为必传参数` - `用户名已存在!` ### 9.2 登录 + - 方法:`POST` - 路径:`/auth/login` @@ -583,10 +612,10 @@ 请求参数说明: -| 参数名 | 类型 | 必填 | 说明 | -|---|---|---|---| -| username | string | 是 | 登录用户名 | -| password | string | 是 | 登录密码 | +| 参数名 | 类型 | 必填 | 说明 | +| -------- | ------ | -- | ----- | +| username | string | 是 | 登录用户名 | +| password | string | 是 | 登录密码 | 成功返回 `data`: @@ -608,21 +637,23 @@ ``` 失败场景: + - `username、password 为必传参数` - `用户名或密码错误!` - `用户已禁用!` 登录成功额外返回: -| 字段 | 类型 | 说明 | -|---|---|---| -| token | string | 登录令牌,存入 Redis | -| token_type | string | 固定为 `Bearer` | -| expires_in | int | token 过期时间,单位秒,当前为 7200 | -| refresh_threshold_seconds | int | 自动续期阈值,单位秒,当前为 1800 | -| refresh_mechanism | string | 刷新机制说明 | +| 字段 | 类型 | 说明 | +| --------------------------- | ------ | ----------------------- | +| token | string | 登录令牌,存入 Redis | +| token\_type | string | 固定为 `Bearer` | +| expires\_in | int | token 过期时间,单位秒,当前为 7200 | +| refresh\_threshold\_seconds | int | 自动续期阈值,单位秒,当前为 1800 | +| refresh\_mechanism | string | 刷新机制说明 | 当前 token 机制: + - token 存储位置:Redis - Redis key 前缀:`effekt:token:` - token 过期时间:`7200` 秒(2小时) @@ -634,7 +665,7 @@ > 当前登录接口已返回 token、过期时间和刷新机制说明。 ---- +*** ## 10. 一组联调示例 @@ -768,19 +799,21 @@ Authorization: Bearer ``` 当前机制: + - token 存 Redis - 默认有效期:2 小时 - 剩余有效期小于 30 分钟时,访问受保护接口会自动续期 - 注册、登录接口不需要 token - 其他接口已逐步接入登录鉴权与权限限制 ---- +*** ## 11. 当前初始化 SQL 已包含的业务菜单 已补入以下可直接录入的菜单数据: ### 系统管理 + - `system` 系统管理 - `role_manage` 角色管理 - `user_manage` 用户管理 @@ -788,6 +821,7 @@ Authorization: Bearer - `menu_manage` 菜单管理 ### 测试平台 + - `test_platform` 测试平台 - `product_manage` 产品管理 - `project_manage` 项目管理 @@ -796,6 +830,7 @@ Authorization: Bearer - `report_manage` 测试报告 ### 造数工具 + - `data_tools` 造数工具 - `data_builder_manage` 数据库造数 - `data_factory_manage` 造数工厂 @@ -805,3 +840,4 @@ Authorization: Bearer 1. Swagger/OpenAPI 版本 2. Apifox / Postman 导入版 3. 初始化权限菜单角色的更完整种子数据 + diff --git a/.plan/1ZFcjkLpHmrHbluVoOtA4.md b/.plan/1ZFcjkLpHmrHbluVoOtA4.md new file mode 100644 index 0000000..f948580 --- /dev/null +++ b/.plan/1ZFcjkLpHmrHbluVoOtA4.md @@ -0,0 +1,2051 @@ +# pytest + Jenkins 自动化执行接入后端详细方案(MVC / 可直接交付 AI 编码) + +## 1. 目标与边界 + +### 1.1 目标 +基于当前项目 `D:\zhyy\effekt-interface` 的现有 MVC 结构,为“计划中的自动化用例执行”设计一套可直接落地的后端方案,支持: + +1. 单条功能用例触发自动化执行。 +2. 测试计划维度批量触发自动化执行。 +3. 自动化项目基于 `pytest` 执行指定 `case_id` 对应的用例。 +4. Jenkins 作为统一调度入口。 +5. 平台记录执行任务、执行明细、状态流转、Jenkins 构建信息、回调结果。 +6. 方案需适配当前项目的 MVC 分层: + - controller: `app/api/controller/*.py` + - service: `app/api/service/*.py` + - dao: `app/api/dao/*.py` + - model: `app/api/model/*.py` + - route: `app/api/views.py` + +### 1.2 已知现状 +经代码结构确认: + +- 当前项目是 Flask + SQLAlchemy Session 的轻量 MVC。 +- 已有测试计划、计划用例、测试用例表: + - `test_plan` + - `plan_case` + - `test_case` +- 当前计划执行 `plan_case_execute` 仅支持手工回填结果,不支持自动化编排。 +- 功能用例与自动化用例的桥梁是 `case_id`。 +- `test_case.is_auto` 已可区分是否自动化。 +- 当前 controller/service/dao 的风格是: + - controller 做参数校验和聚合返回 + - service 主要转发 dao 或封装少量业务 + - dao 负责数据库 CRUD +- 当前项目已有 HTTP 请求封装:`common/getRequest.py -> Request.go(...)` +- 当前有 `common/cronRequest.py` 作为外部系统请求示例,但不适合直接复用为 Jenkins 客户端,建议新增独立 Jenkins 请求封装。 + +### 1.3 本期边界 +只设计后端逻辑,不做前端实现,不写自动化项目代码,但会定义自动化项目与平台的接口契约。 + +--- + +## 2. 总体架构设计 + +推荐采用: + +**平台编排 + Jenkins 参数化触发 + pytest 项目按 execution_id 拉取执行清单 + 回调平台写回结果** + +### 2.1 责任划分 + +#### 平台(本项目) +负责: +- 识别单条/计划执行请求 +- 校验用例是否可自动化执行 +- 创建执行记录 +- 调用 Jenkins Job +- 提供执行清单查询接口给 pytest 项目拉取 +- 接收执行中/执行完成回调 +- 聚合执行结果并更新计划状态 + +#### Jenkins +负责: +- 接收平台触发请求 +- 以参数化方式启动自动化项目 +- 记录构建号、日志、报告地址 +- 失败兜底回调平台 + +#### pytest 自动化项目 +负责: +- 接收 Jenkins 参数 +- 通过 `execution_id` 调平台接口获取待执行 case 列表 +- 按 `case_id` 映射执行 pytest 用例 +- 回调平台单条结果和最终结果 +- 输出 junit/allure/json 等结果 + +### 2.2 为什么不直接把 case_id 列表塞进 Jenkins 参数 +不推荐直接传长列表,理由: +- 计划内用例多时参数长度不可控。 +- URL/参数转义复杂。 +- 后续扩展环境、标签、重跑等字段时耦合变高。 +- 执行审计不清晰。 + +推荐只传 `execution_id`,由 pytest 项目回平台拉取清单。 + +--- + +## 3. 与当前 MVC 框架的集成方式 + +### 3.1 新增模块建议 +在现有 `case / plan / report` 域外,新增一个独立“自动化执行域”: + +- controller: `app/api/controller/automationController.py` +- service: `app/api/service/automationService.py` +- dao: `app/api/dao/automationDao.py` +- model: `app/api/model/automationModel.py` +- util/client: `common/jenkinsRequest.py` + +### 3.2 为什么独立成 automation 域 +不要把自动化执行逻辑继续塞进 `planController.py` 或 `caseController.py`,原因: +- 自动化执行有独立生命周期,不只是计划或用例的附属字段。 +- 涉及外部系统 Jenkins 调用、结果回写、状态机、执行明细聚合。 +- 拆成独立域后,controller/service/dao 责任更清晰。 + +### 3.3 与现有域的关系 + +#### 与 Case 域的关系 +- `test_case.id` 作为桥梁。 +- 通过 `test_case.is_auto=1` 判断可自动化。 +- 后续如需补充自动化元信息,可扩展 `test_case` 字段或新增自动化映射表。 + +#### 与 Plan 域的关系 +- 批量执行来源于 `plan_case`。 +- 自动化执行结果需要同步回写 `plan_case.status / actual_result / executed_time / execution_duration`。 +- 自动化执行完成后,要复用或增强当前 `_update_plan_status` 的逻辑,更新 `test_plan.status`。 + +--- + +## 4. 数据库表结构设计 + +本方案采用: +- 尽量复用现有 `test_case / plan_case / test_plan` +- 新增执行主表和执行明细表 +- 可选新增执行事件表(如要完整审计) + +--- + +## 5. 现有表改造建议 + +### 5.1 `test_case` 表改造 +当前已有: +- `id` +- `case_key` +- `is_auto` + +由于你说明桥梁就是 `case_id`,pytest 项目也按 `case_id` 执行,因此**本期不强制新增自动化用例定位字段**。 + +但建议增加以下可选字段,便于后续扩展: + +```sql +ALTER TABLE test_case +ADD COLUMN auto_status SMALLINT DEFAULT 0, +ADD COLUMN auto_updated_time TIMESTAMP, +ADD COLUMN auto_meta JSONB DEFAULT '{}'::jsonb; +``` + +字段说明: +- `auto_status`: 0未接入 1已接入 2已失效 +- `auto_updated_time`: 自动化信息最近更新时间 +- `auto_meta`: 预留字段,后续可存 pytest marker、目录、owner 等 + +> 如果你要严格最小改动,这 3 个字段可以不做。 + +--- + +## 6. 新增核心表结构 + +### 6.1 自动化执行主表 `auto_execution` + +用途:记录一次自动化执行任务,支持单条执行和计划执行。 + +```sql +CREATE TABLE auto_execution ( + id BIGSERIAL PRIMARY KEY, + execution_no VARCHAR(64) NOT NULL UNIQUE, + trigger_type SMALLINT NOT NULL, + project_id BIGINT NOT NULL, + plan_id BIGINT, + plan_round_no INTEGER, + source_case_id BIGINT, + env_code VARCHAR(32) NOT NULL, + run_mode SMALLINT DEFAULT 1, + status SMALLINT NOT NULL DEFAULT 0, + jenkins_job_name VARCHAR(128), + jenkins_queue_id BIGINT, + jenkins_build_number BIGINT, + jenkins_build_url VARCHAR(512), + console_url VARCHAR(512), + report_url VARCHAR(512), + total_count INTEGER DEFAULT 0, + pending_count INTEGER DEFAULT 0, + running_count INTEGER DEFAULT 0, + passed_count INTEGER DEFAULT 0, + failed_count INTEGER DEFAULT 0, + blocked_count INTEGER DEFAULT 0, + skipped_count INTEGER DEFAULT 0, + not_found_count INTEGER DEFAULT 0, + trigger_by BIGINT, + trigger_source VARCHAR(32) DEFAULT 'platform', + trigger_message TEXT, + start_time TIMESTAMP, + end_time TIMESTAMP, + duration_seconds INTEGER, + callback_token VARCHAR(128), + ext JSONB DEFAULT '{}'::jsonb, + created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +字段定义: + +- `execution_no`: 平台执行编号,建议格式 `AE202502120001` +- `trigger_type`: + - 1 单条用例执行 + - 2 计划批量执行 +- `project_id`: 冗余存项目,方便查询 +- `plan_id`: 计划执行时必填,单条执行可空 +- `plan_round_no`: 若按轮次执行可记录 +- `source_case_id`: 单条执行来源 case_id +- `env_code`: 执行环境编码,如 `st/dev/pre` +- `run_mode`: + - 1 串行 + - 2 并行 +- `status`: 执行主状态,见后文状态机 +- `jenkins_job_name / queue_id / build_number / build_url` +- `console_url / report_url`: 用于前端直接跳转 +- `*_count`: 执行汇总 +- `trigger_by`: 触发用户 +- `trigger_message`: 触发失败或备注 +- `callback_token`: Jenkins/pytest 回调鉴权 +- `ext`: 预留字段,记录 branch、pytest args、report meta 等 + +索引建议: + +```sql +CREATE INDEX idx_auto_execution_plan_id ON auto_execution(plan_id); +CREATE INDEX idx_auto_execution_project_id ON auto_execution(project_id); +CREATE INDEX idx_auto_execution_status ON auto_execution(status); +CREATE INDEX idx_auto_execution_created_time ON auto_execution(created_time DESC); +CREATE INDEX idx_auto_execution_trigger_by ON auto_execution(trigger_by); +``` + +--- + +### 6.2 自动化执行明细表 `auto_execution_case` + +用途:记录本次执行下每个 `case_id` 的状态和结果。 + +```sql +CREATE TABLE auto_execution_case ( + id BIGSERIAL PRIMARY KEY, + execution_id BIGINT NOT NULL, + plan_case_id BIGINT, + case_id BIGINT NOT NULL, + case_key VARCHAR(64), + case_title VARCHAR(255), + run_order INTEGER DEFAULT 0, + status SMALLINT NOT NULL DEFAULT 0, + pytest_nodeid VARCHAR(512), + result_message TEXT, + error_message TEXT, + stack_trace TEXT, + report_url VARCHAR(512), + duration_seconds INTEGER, + started_time TIMESTAMP, + finished_time TIMESTAMP, + retry_count INTEGER DEFAULT 0, + ext JSONB DEFAULT '{}'::jsonb, + created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +字段定义: +- `execution_id`: 对应 `auto_execution.id` +- `plan_case_id`: 如果来源于计划执行,则关联 `plan_case.id` +- `case_id`: 功能用例主键,也是自动化桥梁 +- `case_key / case_title`: 冗余快照,避免后续名称变化影响历史展示 +- `run_order`: 执行顺序 +- `status`: 单用例执行状态,见后文状态机 +- `pytest_nodeid`: pytest 的 nodeid,如 `tests/test_login.py::test_xxx` +- `result_message`: 简要结果,如断言失败摘要 +- `error_message / stack_trace`: 异常信息 +- `report_url`: 若支持单用例报告可记录 +- `retry_count`: 失败重跑预留 +- `ext`: 可存截图、附件、标签等 + +索引建议: + +```sql +CREATE INDEX idx_auto_execution_case_execution_id ON auto_execution_case(execution_id); +CREATE INDEX idx_auto_execution_case_case_id ON auto_execution_case(case_id); +CREATE INDEX idx_auto_execution_case_plan_case_id ON auto_execution_case(plan_case_id); +CREATE INDEX idx_auto_execution_case_status ON auto_execution_case(status); +CREATE UNIQUE INDEX uk_auto_execution_case_unique ON auto_execution_case(execution_id, case_id, plan_case_id); +``` + +> 如果同一计划允许相同 `case_id` 在不同轮次、多次加入,则唯一索引可调整成 `(execution_id, plan_case_id, case_id)`,其中 `plan_case_id` 允许为空时要注意 PostgreSQL 对 NULL 唯一性的行为。 + +--- + +### 6.3 可选事件表 `auto_execution_event` + +如果你希望保留每次回调、状态变化、Jenkins 交互轨迹,建议加事件表。 + +```sql +CREATE TABLE auto_execution_event ( + id BIGSERIAL PRIMARY KEY, + execution_id BIGINT NOT NULL, + execution_case_id BIGINT, + event_type VARCHAR(64) NOT NULL, + event_status VARCHAR(32), + payload JSONB DEFAULT '{}'::jsonb, + created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +事件类型示例: +- `TRIGGER_REQUESTED` +- `JENKINS_QUEUED` +- `JENKINS_STARTED` +- `CASE_STARTED` +- `CASE_FINISHED` +- `EXECUTION_FINISHED` +- `CALLBACK_FAILED` + +> 若第一期追求最小化,可先不建此表,把关键日志写到应用日志中。 + +--- + +## 7. SQLAlchemy Model 设计(贴合当前项目风格) + +建议新增文件:`app/api/model/automationModel.py` + +包含: +- `AutoExecution` +- `AutoExecutionCase` +- (可选)`AutoExecutionEvent` + +建模风格应与当前 `planModel.py / caseModel.py` 一致: +- 使用 `declarative_base()` +- `Base.to_dict = to_dict` +- 字段命名与数据库下划线保持一致 +- `JSONB / TIMESTAMP / SmallInteger / BigInteger` + +--- + +## 8. 状态流转设计 + +这是后端实现的核心。 + +### 8.1 主执行状态 `auto_execution.status` + +建议枚举: + +- `0` 待触发(平台已建单,尚未请求 Jenkins) +- `1` 触发中(正在调用 Jenkins) +- `2` 排队中(Jenkins 已接收,尚未开始构建) +- `3` 执行中(Jenkins/pytest 已开始) +- `4` 成功(全部执行完成且失败数=0) +- `5` 失败(已结束,但存在失败/异常) +- `6` 已取消 +- `7` 触发失败(Jenkins API 调用失败) +- `8` 回调异常(任务可能跑完,但平台未完整接收结果) + +推荐状态流: + +```text +0待触发 + -> 1触发中 + -> 2排队中 + -> 3执行中 + -> 4成功 + -> 5失败 + -> 6已取消 + -> 7触发失败 +``` + +补偿状态: +- 任意执行中状态,如果最终结果缺失但监控发现 Jenkins 已完成,可修正到 4/5。 +- 回调鉴权失败/数据异常时,可标记 `8 回调异常`。 + +### 8.2 明细状态 `auto_execution_case.status` + +建议枚举: +- `0` 待执行 +- `1` 执行中 +- `2` 通过 +- `3` 失败 +- `4` 阻塞 +- `5` 跳过 +- `6` 未找到自动化实现 +- `7` 已取消 + +推荐状态流: + +```text +0待执行 -> 1执行中 -> 2通过 + -> 3失败 + -> 4阻塞 + -> 5跳过 + -> 6未找到自动化实现 + -> 7已取消 +``` + +### 8.3 与现有 `plan_case.status` 的映射关系 + +当前 `plan_case.status`: +- `0` 未开始 +- `1` 通过 +- `2` 失败 +- `3` 阻塞 + +自动化结果回写时建议映射: + +- `auto_execution_case.status=2` -> `plan_case.status=1` +- `auto_execution_case.status=3` -> `plan_case.status=2` +- `auto_execution_case.status=4` -> `plan_case.status=3` +- `auto_execution_case.status in (5,6,7)` -> **不自动覆盖为通过/失败**,建议保留原值或按业务约定: + - 若原来是 `0`,可保持 `0` + - 并把细节记入 `actual_result` + +### 8.4 与 `test_plan.status` 的映射关系 + +当前 `TestPlan.status`: +- `0` 草稿 +- `1` 进行中 +- `2` 已完成 +- `3` 已归档 +- `4` 已通过 + +建议复用现有 `_update_plan_status(plan_id)` 逻辑,但将其抽到 `PlanService` 或 `AutomationService` 中统一调用。 + +执行过程中: +- 计划中存在未执行 -> `1 进行中` +- 全部完成,且无失败/阻塞 -> `4 已通过` +- 全部完成,存在失败/阻塞 -> `2 已完成` +- 若原来 `3 已归档`,不自动改动 + +--- + +## 9. 接口设计 + +接口分三类: +1. 前端触发接口 +2. 自动化项目拉取执行清单接口 +3. Jenkins/pytest 回调接口 + +以下设计遵循当前项目风格: +- 路由统一注册在 `app/api/views.py` +- `GET` 查列表/详情 +- `POST` 创建/更新/触发 +- 返回结构统一 `ApiResponse.build_success/build_failure` + +--- + +## 10. 前端触发接口设计 + +### 10.1 单条用例执行 + +#### 路由 +`POST /api/automation/case/run` + +#### 权限 +建议新增:`automation:run` + +#### 请求体 +```json +{ + "caseId": 1001, + "envCode": "st", + "runMode": 1, + "jenkinsJobName": "pytest-auto-runner", + "remark": "单条回归验证" +} +``` + +#### 字段说明 +- `caseId`: 必填 +- `envCode`: 必填 +- `runMode`: 可选,默认 1 串行 +- `jenkinsJobName`: 可选,未传则走系统默认配置 +- `remark`: 可选 + +#### 返回 +```json +{ + "id": 123, + "executionId": 123, + "executionNo": "AE202502120001", + "status": 2 +} +``` + +#### 处理逻辑 +1. 校验 `caseId` 是否存在。 +2. 校验 `test_case.is_auto == 1`。 +3. 创建 `auto_execution`: + - `trigger_type=1` + - `source_case_id=caseId` + - `project_id` 从 `test_case.project_id` 带出 +4. 创建一条 `auto_execution_case`。 +5. 调用 Jenkins。 +6. 若调用成功: + - 更新主表 `status=2` + - 记录 `queue_id / job_name` +7. 若调用失败: + - 更新主表 `status=7` + - 返回失败原因 + +--- + +### 10.2 计划自动化执行 + +#### 路由 +`POST /api/automation/plan/run` + +#### 权限 +`automation:run` + +#### 请求体 +```json +{ + "planId": 2001, + "roundNo": 1, + "envCode": "st", + "runMode": 1, + "caseIds": [1001, 1002, 1003], + "jenkinsJobName": "pytest-auto-runner", + "remark": "计划批量回归" +} +``` + +#### 说明 +- `caseIds` 可选: + - 不传或空:执行当前计划下全部自动化用例 + - 传值:执行计划中指定子集 +- `roundNo` 可选:如果你已有轮次概念,则仅执行该轮次下的 `plan_case` + +#### 返回 +```json +{ + "id": 124, + "executionId": 124, + "executionNo": "AE202502120002", + "totalCount": 15, + "status": 2 +} +``` + +#### 处理逻辑 +1. 校验 `planId`。 +2. 查询 `plan_case + test_case`: + - 必须属于该计划 + - `test_case.is_auto=1` + - 若传 `caseIds`,则限制子集 + - 若传 `roundNo`,则限制轮次 +3. 若无可执行自动化用例,直接报错。 +4. 创建 `auto_execution`: + - `trigger_type=2` + - `plan_id=planId` + - `plan_round_no=roundNo` +5. 批量创建 `auto_execution_case`。 +6. 调 Jenkins。 +7. 更新状态与 Jenkins 信息。 + +--- + +### 10.3 自动化执行列表 + +#### 路由 +`GET /api/automation/execution/list` + +#### 请求参数 +- `projectId` +- `planId` +- `status` +- `triggerType` +- `pageNo` +- `pageSize` + +#### 返回字段 +- `id` +- `execution_no` +- `trigger_type` +- `plan_id` +- `source_case_id` +- `env_code` +- `status` +- `total_count` +- `passed_count` +- `failed_count` +- `report_url` +- `jenkins_build_number` +- `created_time` +- `start_time` +- `end_time` + +--- + +### 10.4 自动化执行详情 + +#### 路由 +`GET /api/automation/execution/detail` + +#### 请求参数 +- `executionId` + +#### 返回 +主表 + 汇总 + Jenkins 信息 + 用例统计。 + +--- + +### 10.5 自动化执行明细列表 + +#### 路由 +`GET /api/automation/execution/case/list` + +#### 请求参数 +- `executionId` +- `status` 可选 +- `pageNo` +- `pageSize` + +#### 返回 +每条 `auto_execution_case` 的详情。 + +--- + +## 11. 自动化项目拉取执行清单接口 + +这是 pytest 项目的核心入口。 + +### 11.1 获取执行清单 + +#### 路由 +`GET /api/automation/execution/case/pull` + +#### 鉴权 +建议两层: +1. 正常 token 鉴权(如果自动化项目能拿到平台 token) +2. 或使用 `executionId + callbackToken` 鉴权 + +建议本期采用: +- query: `executionId` +- header: `X-CALLBACK-TOKEN` + +#### 请求参数 +- `executionId` + +#### 请求头 +- `X-CALLBACK-TOKEN: xxx` + +#### 返回 +```json +{ + "executionId": 124, + "executionNo": "AE202502120002", + "triggerType": 2, + "projectId": 88, + "planId": 2001, + "envCode": "st", + "runMode": 1, + "items": [ + { + "executionCaseId": 501, + "planCaseId": 9001, + "caseId": 1001, + "caseKey": "TC-ZHYY-001", + "caseTitle": "登录成功", + "runOrder": 1 + } + ] +} +``` + +#### 处理逻辑 +1. 校验 `executionId`。 +2. 校验回调 token。 +3. 查询 `auto_execution` 和 `auto_execution_case`。 +4. 返回待执行清单。 + +> 如果未来 pytest 项目还需要额外上下文,如环境 URL、测试账号、marker,也可以从 `ext` 中一起返回。 + +--- + +## 12. 回调接口设计 + +推荐分 4 个回调点: +1. Jenkins 入队成功 +2. Jenkins 开始执行 +3. 单条用例结果回写 +4. 整体执行完成回写 + +--- + +### 12.1 Jenkins 入队回写 + +#### 路由 +`POST /api/automation/execution/queued` + +#### 作用 +Jenkins 已接受构建,回传 queue/build 基本信息。 + +#### 请求体 +```json +{ + "executionId": 124, + "queueId": 5678, + "jobName": "pytest-auto-runner", + "buildNumber": null, + "buildUrl": null +} +``` + +#### 处理 +- 主表状态 `1/2 -> 2` +- 回写 `queue_id / job_name / build_number / build_url` + +> 如果平台触发 Jenkins 后拿不到 queueId,也可省略这个接口,由后续 started 回调补齐。 + +--- + +### 12.2 Jenkins/pytest 开始执行回写 + +#### 路由 +`POST /api/automation/execution/start` + +#### 请求体 +```json +{ + "executionId": 124, + "jobName": "pytest-auto-runner", + "buildNumber": 89, + "buildUrl": "http://jenkins/job/pytest-auto-runner/89/", + "consoleUrl": "http://jenkins/job/pytest-auto-runner/89/console", + "startTime": "2025-02-12 10:00:00" +} +``` + +#### 处理 +- 主表状态更新为 `3 执行中` +- 记录 `buildNumber / buildUrl / consoleUrl / start_time` + +--- + +### 12.3 单条用例结果回写 + +#### 路由 +`POST /api/automation/execution/case/result` + +#### 请求体 +```json +{ + "executionId": 124, + "caseId": 1001, + "executionCaseId": 501, + "status": 2, + "pytestNodeid": "tests/test_login.py::test_login_success", + "resultMessage": "assert success", + "errorMessage": "", + "stackTrace": "", + "durationSeconds": 12, + "reportUrl": "http://allure/case/1001", + "startedTime": "2025-02-12 10:01:00", + "finishedTime": "2025-02-12 10:01:12", + "ext": { + "attachments": [] + } +} +``` + +#### 处理逻辑 +1. 按 `executionCaseId` 优先定位明细;若无则退化为 `executionId + caseId`。 +2. 更新 `auto_execution_case`。 +3. 若有关联 `plan_case_id`,同步回写 `plan_case`: + - `status` + - `actual_result` + - `executed_time` + - `execution_duration` +4. 重新聚合更新 `auto_execution` 汇总字段。 +5. 若是计划执行,可调用计划状态更新逻辑。 + +--- + +### 12.4 整体执行完成回写 + +#### 路由 +`POST /api/automation/execution/finish` + +#### 请求体 +```json +{ + "executionId": 124, + "status": 4, + "buildNumber": 89, + "buildUrl": "http://jenkins/job/pytest-auto-runner/89/", + "consoleUrl": "http://jenkins/job/pytest-auto-runner/89/console", + "reportUrl": "http://allure/report/89", + "startTime": "2025-02-12 10:00:00", + "endTime": "2025-02-12 10:08:00", + "durationSeconds": 480, + "summary": { + "total": 15, + "passed": 12, + "failed": 2, + "blocked": 0, + "skipped": 1, + "notFound": 0 + }, + "message": "执行完成" +} +``` + +#### 处理逻辑 +1. 更新主表 Jenkins 信息与时间。 +2. 若请求体有 summary,则直接写回;否则后台实时聚合明细表得到。 +3. 根据汇总决定主状态: + - `failed > 0 or blocked > 0 or notFound > 0` -> `5失败` + - 否则 -> `4成功` +4. 若是计划执行,调用计划状态刷新。 + +--- + +### 12.5 失败/取消回调 + +#### 路由 +`POST /api/automation/execution/abort` + +#### 请求体 +```json +{ + "executionId": 124, + "status": 6, + "message": "Jenkins aborted", + "buildNumber": 89, + "consoleUrl": "http://jenkins/job/pytest-auto-runner/89/console" +} +``` + +#### 处理 +- 主表置 `6 已取消` 或 `5 失败` +- 所有仍为 `0/1` 的明细置为 `7 已取消` +- 刷新汇总 + +--- + +## 13. Jenkins 参数设计 + +推荐只使用少量稳定参数。 + +### 13.1 Jenkins Job 名称 +建议固定为一个或少数几个通用参数化任务,例如: +- `pytest-auto-runner` +- 如果分项目,可 `pytest-auto-runner-zhyy` + +不建议“一计划一 Job”。 + +### 13.2 构建参数 + +```text +EXECUTION_ID +CALLBACK_TOKEN +PLATFORM_BASE_URL +ENV_CODE +RUN_MODE +TRIGGER_TYPE +``` + +#### 含义 +- `EXECUTION_ID`: 平台执行主键 +- `CALLBACK_TOKEN`: 回调鉴权 token +- `PLATFORM_BASE_URL`: 平台地址,如 `http://xxx/api` +- `ENV_CODE`: st/dev/pre +- `RUN_MODE`: 1串行 2并行 +- `TRIGGER_TYPE`: 1单条 2计划 + +### 13.3 可选参数 + +```text +GIT_BRANCH +PYTEST_ARGS +REPORT_BASE_URL +``` + +存放位置建议: +- 平台配置中心 / 环境变量 / 常量文件 +- 不建议写死在 controller + +### 13.4 Jenkins Build API 请求方式 + +推荐后端调用: + +```text +POST /job/{jobName}/buildWithParameters +``` + +鉴权建议: +- Basic Auth + API Token +- 若 Jenkins 开启 crumb 防护,还要先请求 crumb + +--- + +## 14. Jenkins 回调与结果回写逻辑 + +### 14.1 平台触发 Jenkins 时的处理 + +`AutomationService.trigger_execution(...)` 需要: + +1. 生成 `callback_token` +2. 创建执行单和明细 +3. 更新主状态 `0 -> 1` +4. 调 Jenkins API +5. 成功: + - 主状态 `1 -> 2` + - 保存 queue/build 基础信息 +6. 失败: + - 主状态 `1 -> 7` + - 保存错误信息到 `trigger_message` + +### 14.2 pytest 项目开始时的处理 + +pytest 启动脚本: +1. 根据 Jenkins 参数拿到 `EXECUTION_ID` +2. 调平台 `/execution/case/pull` +3. 成功拿清单后调用 `/execution/start` +4. 开始逐条执行 + +### 14.3 pytest 每执行一条用例 + +执行器需要: +1. 将 `case_id` 定位到 pytest 对应测试项 +2. 执行完成后回调 `/execution/case/result` +3. 回调内容至少包括: + - executionCaseId / caseId + - status + - durationSeconds + - nodeid + - errorMessage/resultMessage + +### 14.4 pytest 全量执行结束 + +执行器需要: +1. 汇总总数 +2. 计算开始/结束时间 +3. 发布 report_url(如 Allure) +4. 调 `/execution/finish` + +### 14.5 Jenkins 失败兜底 + +即使 pytest 未回调成功,Jenkins `post { failure { ... } }` 阶段也应该: +- 至少调用 `/execution/abort` 或 `/execution/finish(status=5)` +- 避免平台主单永久卡在 `2/3` + +--- + +## 15. 详细执行流程 + +### 15.1 单条用例执行流程 + +```text +前端点击“执行自动化” +-> POST /api/automation/case/run +-> controller 校验参数 +-> service 查询 test_case +-> 校验 is_auto=1 +-> dao 创建 auto_execution +-> dao 创建 auto_execution_case +-> service 调 Jenkins +-> Jenkins 接受构建 +-> 平台主单状态变更为排队中 +-> pytest 项目启动 +-> 拉取 execution 清单 +-> 回调 start +-> 执行 case_id 对应 pytest 用例 +-> 回调 case/result +-> 回调 finish +-> 平台更新 execution 汇总 +``` + +### 15.2 计划批量执行流程 + +```text +前端点击“执行自动化用例” +-> POST /api/automation/plan/run +-> controller 校验参数 +-> service 查询 plan_case + test_case +-> 过滤 is_auto=1 +-> dao 创建 auto_execution +-> dao 批量创建 auto_execution_case +-> service 调 Jenkins +-> Jenkins 启动 pytest 项目 +-> pytest 按 execution_id 拉取清单 +-> 回调 execution/start +-> 按顺序或并行执行每个 case_id +-> 每条执行后回调 case/result +-> 平台同步回写 plan_case +-> 全部完成后回调 finish +-> 平台刷新 auto_execution 汇总 +-> 平台刷新 test_plan 状态 +``` + +--- + +## 16. Controller / Service / DAO 详细职责设计 + +### 16.1 Controller 层 +新增 `AutomationController`,建议方法: + +- `case_run()` +- `plan_run()` +- `execution_list()` +- `execution_detail()` +- `execution_case_list()` +- `execution_case_pull()` +- `execution_queued()` +- `execution_start()` +- `execution_case_result()` +- `execution_finish()` +- `execution_abort()` + +Controller 责任: +- 参数读取 `_get` +- 基础必填校验 +- 调 service +- 返回统一结构 + +不要在 controller 里直接写 SQL 或请求 Jenkins。 + +--- + +### 16.2 Service 层 +新增 `AutomationService`,建议职责: + +#### 触发类 +- `create_case_execution(session, req_data, user_id)` +- `create_plan_execution(session, req_data, user_id)` +- `trigger_jenkins(session, execution_id, job_name)` + +#### 查询类 +- `get_execution_detail(session, execution_id)` +- `list_executions(session, filters, page, size)` +- `list_execution_cases(session, execution_id, filters, page, size)` +- `pull_execution_cases(session, execution_id, callback_token)` + +#### 回调类 +- `mark_execution_queued(...)` +- `mark_execution_started(...)` +- `save_case_result(...)` +- `finish_execution(...)` +- `abort_execution(...)` + +#### 聚合类 +- `refresh_execution_summary(session, execution_id)` +- `sync_plan_case_result(session, execution_case)` +- `refresh_plan_status(session, plan_id)` + +Service 层应承接主要业务逻辑与状态流转。 + +--- + +### 16.3 DAO 层 +新增 `AutomationDao`,建议方法: + +#### 主表 +- `create_execution(session, add_info)` +- `update_execution_by_id(session, execution_id, update_info)` +- `get_execution_by_id(session, execution_id)` +- `list_execution_by_filters(session, filters, page, size)` + +#### 明细表 +- `batch_create_execution_cases(session, batch_info_list)` +- `get_execution_case_by_id(session, execution_case_id)` +- `get_execution_case_by_unique(session, execution_id, case_id, plan_case_id=None)` +- `update_execution_case_by_id(session, execution_case_id, update_info)` +- `list_execution_case_by_filters(session, filters, page, size)` + +#### 聚合 +- `count_execution_case_summary(session, execution_id)` + +#### 执行清单查询 +- `query_plan_auto_cases(session, plan_id, round_no=None, case_ids=None)` +- `query_case_auto_item(session, case_id)` + +DAO 保持与当前项目风格一致: +- 独立 CRUD +- `session.done(close=False)` +- 失败返回 `(0, err_msg)` / 查询返回对象 + +--- + +## 17. 与现有代码的修改点清单 + +### 17.1 新增文件 + +1. `app/api/model/automationModel.py` +2. `app/api/dao/automationDao.py` +3. `app/api/service/automationService.py` +4. `app/api/controller/automationController.py` +5. `common/jenkinsRequest.py` + +### 17.2 修改文件 + +#### `app/api/views.py` +新增路由注册: +- `/automation/case/run` +- `/automation/plan/run` +- `/automation/execution/list` +- `/automation/execution/detail` +- `/automation/execution/case/list` +- `/automation/execution/case/pull` +- `/automation/execution/queued` +- `/automation/execution/start` +- `/automation/execution/case/result` +- `/automation/execution/finish` +- `/automation/execution/abort` + +#### `app/api/service/planService.py` +建议增加公共方法: +- `refresh_plan_status(session, plan_id)` + +把 `PlanController._update_plan_status()` 中的逻辑迁移出来,避免 controller 内部藏业务逻辑。 + +#### `app/api/controller/planController.py` +- `plan_case_execute()` 仍保留手工执行。 +- 自动化执行入口不要继续加到这个方法里,而是走新 automation 接口。 + +#### `const.py` +建议新增 Jenkins 配置: +- `JENKINS_BASE_URL` +- `JENKINS_USER` +- `JENKINS_TOKEN` +- `JENKINS_DEFAULT_JOB` +- `AUTOMATION_CALLBACK_SECRET` +- `PLATFORM_BASE_URL` + +> 生产上更建议来自环境变量,但当前项目已有常量直写风格,第一期可先保持一致。 + +--- + +## 18. `common/jenkinsRequest.py` 设计建议 + +参考 `common/getRequest.py` 风格,但 Jenkins 需要更灵活处理: + +建议封装: +- `get_crumb()` +- `build_with_parameters(job_name, params)` +- `get_build_info(job_name, build_number)` 可选 + +### 建议返回内容 +触发后尽量返回: +- success +- status_code +- queue_id(如果能解析到) +- location header +- error_message + +如果当前 Jenkins 网关层不方便拿 queueId,也可以第一期只判断触发成功与否。 + +--- + +## 19. 回写 `plan_case` 的规则细化 + +每次 `/execution/case/result` 到达后: + +如果 `auto_execution_case.plan_case_id` 不为空,则同步: + +### 19.1 状态映射 +- `2 通过` -> `plan_case.status = 1` +- `3 失败` -> `plan_case.status = 2` +- `4 阻塞` -> `plan_case.status = 3` +- `5 跳过 / 6未找到 / 7取消` -> 默认不改 `plan_case.status` + +### 19.2 文本字段 +- `plan_case.actual_result`: + - 成功:记录 `resultMessage` + - 失败:优先写 `errorMessage` +- `plan_case.executed_time`:写 `finished_time` 或当前时间 +- `plan_case.execution_duration`:写 `duration_seconds` +- `defect_links / attachments`:本期不自动写,后续有缺陷系统集成再扩展 + +### 19.3 幂等性 +同一个 `executionCaseId` 多次回调时: +- 允许覆盖 `status / duration / message` +- 但如果主状态已结束(4/5/6),应限制后续异常回调只做日志记录或按最后一次有效值覆盖,避免状态混乱 + +--- + +## 20. 聚合汇总逻辑 + +### 20.1 执行汇总字段计算规则 +基于 `auto_execution_case` 统计: +- `total_count = count(*)` +- `pending_count = status=0` +- `running_count = status=1` +- `passed_count = status=2` +- `failed_count = status=3` +- `blocked_count = status=4` +- `skipped_count = status=5` +- `not_found_count = status=6` + +### 20.2 主状态自动判定规则 +在 `refresh_execution_summary` 中: + +1. 若存在 `running_count > 0` -> 主状态至少为 `3` +2. 若全部结束: + - `failed + blocked + not_found > 0` -> `5` + - 否则 -> `4` +3. 若任务被外部取消 -> `6` +4. 若触发阶段失败 -> `7` + +### 20.3 结束时间规则 +当全部明细都进入结束态(2/3/4/5/6/7)时: +- 自动补 `end_time` +- 若 `start_time` 已有,则计算 `duration_seconds` + +--- + +## 21. 计划执行选取逻辑 + +计划执行时,从 `plan_case` 选出真正需要跑的明细。 + +### 21.1 查询条件 +- `PlanCase.plan_id = planId` +- 如果传 `roundNo`:`PlanCase.round_no = roundNo` +- 如果传 `caseIds`:`PlanCase.case_id in caseIds` +- 关联 `TestCase.is_delete = 0` +- `TestCase.is_auto = 1` +- 如有需要,也可加 `TestCase.status = 1 or 4`(正常/评审通过) + +### 21.2 排序规则 +默认: +- `plan_case.id asc` + +### 21.3 去重规则 +如果同一计划下同一轮次允许一个 `case_id` 出现多次: +- 不去重,按 `plan_case.id` 逐条执行 +- 这样更符合计划执行场景 + +如果你明确保证不重复,也可唯一化。 + +--- + +## 22. 单条执行选取逻辑 + +输入 `caseId` 后: +- 直接查询 `test_case` +- 校验 `is_auto=1` +- 创建 1 条 `auto_execution_case` +- `plan_case_id` 为空 + +这样单条执行与计划执行复用同一套执行主流程。 + +--- + +## 23. 幂等与并发控制 + +### 23.1 重复点击执行按钮 +建议在创建任务前校验: +- 是否存在同一 `planId + envCode + status in (0,1,2,3)` 的运行中任务 +- 是否存在同一 `caseId + envCode + status in (0,1,2,3)` 的运行中任务 + +策略建议: +- 默认不允许重复触发,返回“已有执行中任务” +- 如果后续需要“强制重跑”,再加 `force=true` + +### 23.2 回调幂等 +对于 `/execution/case/result`: +- 若同一条记录已是最终状态,再收到相同状态回调,应视为幂等成功 +- 若收到不同终态,以最后一次回调覆盖,但写日志 + +### 23.3 事务一致性 +`save_case_result()` 推荐事务内做: +1. 更新 `auto_execution_case` +2. 更新 `plan_case` +3. 提交 +4. 再调用聚合更新 `auto_execution` + +如果要更强一致性,也可在同一事务完成,但当前项目的 session 风格是轻量提交,可分两步。 + +--- + +## 24. 错误场景与兜底策略 + +### 24.1 用例不存在 +- 单条执行:直接返回失败 +- 计划执行:忽略已删除用例,仅对有效自动化用例建单;若最终为空则报错 + +### 24.2 用例未接入自动化 +- 单条执行:直接报错“该用例未实现自动化” +- 计划执行:过滤掉 `is_auto=0`;若全部都不是自动化则报错 + +### 24.3 Jenkins 触发失败 +- `auto_execution.status = 7` +- `trigger_message` 存失败原因 + +### 24.4 pytest 找不到 `case_id` 对应实现 +- 回调单条明细 `status = 6` +- 主单最终通常为 `5` + +### 24.5 部分用例执行完,finish 回调丢失 +- Jenkins `post` 阶段兜底调 `/finish` 或 `/abort` +- 平台可后续增加定时补偿任务,扫描长时间停留在 `3 执行中` 的记录 + +### 24.6 平台回调接口鉴权失败 +- 返回失败 +- 自动化项目应重试 +- 平台日志记录 `executionId` + +--- + +## 25. 接口返回码与错误语义建议 + +当前项目 `const.py` 中错误码较泛,第一期可以沿用现有: +- 40009 新增失败 +- 40011 获取失败 +- 40012 更新失败 + +但为了 AI 编码更明确,建议新增语义化错误码: + +```python +40020: '自动化执行任务创建失败' +40021: '自动化用例不存在' +40022: '该用例未接入自动化' +40023: '计划下无可执行自动化用例' +40024: 'Jenkins 触发失败' +40025: '执行记录不存在' +40026: '回调鉴权失败' +40027: '执行结果回写失败' +``` + +> 若你想最小改动,也可先不加新错误码,统一用已有 `40009/40011/40012`。 + +--- + +## 26. 权限设计建议 + +建议新增权限点: +- `automation:run` +- `automation:list` +- `automation:detail` +- `automation:callback` + +其中: +- 前端用户接口需要登录 + `automation:run/list/detail` +- Jenkins/pytest 回调接口建议: + - 放到 `should_skip_auth()` 白名单中,绕过用户 token + - 改为内部 token/header 校验 + +否则自动化项目拿用户 token 不稳定。 + +--- + +## 27. 回调鉴权设计 + +### 27.1 推荐方案 +每次创建执行任务时生成 `callback_token`,存 `auto_execution.callback_token`。 + +Jenkins/pytest 回调时传: +```text +X-CALLBACK-TOKEN: {callback_token} +``` + +平台校验: +- `executionId` 存在 +- header token 与库中一致 + +### 27.2 好处 +- 不依赖用户登录 token +- 每次执行隔离 +- token 泄露影响范围仅单次任务 + +--- + +## 28. pytest 自动化项目对接契约 + +虽然本期不写自动化项目代码,但后端方案必须给到契约。 + +### 28.1 自动化项目输入 +来自 Jenkins 参数: +- `EXECUTION_ID` +- `CALLBACK_TOKEN` +- `PLATFORM_BASE_URL` +- `ENV_CODE` +- `RUN_MODE` + +### 28.2 自动化项目行为 +1. 调 `/execution/case/pull` +2. 将 `case_id` 映射到 pytest 测试项 +3. 单条执行后调 `/execution/case/result` +4. 全部结束调 `/execution/finish` + +### 28.3 `case_id` 到 pytest 的映射方式 +你已经确定桥梁是 `case_id`,建议自动化项目采用以下之一: + +#### 方式 A:pytest marker +```python +@pytest.mark.case_id(1001) +def test_login_success(): + ... +``` + +执行器启动时收集 marker,建立 `case_id -> nodeid` 映射。 + +#### 方式 B:维护映射文件 +如 `case_map.json` + +但从维护性看,**推荐 marker 方式**。 + +### 28.4 找不到映射时 +回调: +- `status=6` +- `resultMessage='case_id 未映射到 pytest 用例'` + +--- + +## 29. MVC 代码落地顺序建议(交给 AI 编码的实施计划) + +### Phase 1:数据层 +1. 新建数据库表: + - `auto_execution` + - `auto_execution_case` +2. 新建 `automationModel.py` +3. 新建 `automationDao.py` +4. 编写: + - 创建主单 + - 批量创建明细 + - 查询详情/列表 + - 聚合汇总 + +### Phase 2:服务层 +1. 新建 `automationService.py` +2. 实现: + - 单条执行建单 + - 计划执行建单 + - Jenkins 触发 + - 执行清单拉取 + - 单条结果回写 + - 整体完成回写 + - 汇总刷新 +3. 把 `PlanController._update_plan_status()` 逻辑迁移成服务方法 + +### Phase 3:控制器与路由 +1. 新建 `automationController.py` +2. 在 `views.py` 注册接口 +3. 增加回调接口白名单处理 + +### Phase 4:Jenkins 客户端 +1. 新建 `common/jenkinsRequest.py` +2. 封装 Basic Auth + buildWithParameters +3. 在 service 中接入 + +### Phase 5:联调准备 +1. 给 pytest 项目提供接口文档 +2. 给 Jenkins Pipeline 提供参数清单 +3. 约定回调协议 + +--- + +## 30. 推荐的 AI 编码拆分任务 + +为了让 AI 更稳地写代码,建议拆成 5 个子任务: + +### 任务 1:建模与 DAO +输出: +- `automationModel.py` +- `automationDao.py` + +### 任务 2:查询与列表接口 +输出: +- execution list/detail/case list + +### 任务 3:单条执行 / 计划执行建单逻辑 +输出: +- case run +- plan run +- Jenkins trigger + +### 任务 4:回调与状态流转 +输出: +- pull/start/case_result/finish/abort +- plan_case 回写 +- plan 状态更新 + +### 任务 5:Jenkins 客户端与配置收口 +输出: +- `common/jenkinsRequest.py` +- `const.py` 新配置 +- 回调白名单 + +--- + +## 31. 最终推荐实现决策 + +### 必做 +1. 新增 `auto_execution`、`auto_execution_case` +2. 独立 automation MVC 模块 +3. 平台只传 `execution_id` 给 Jenkins +4. pytest 项目按 `execution_id` 拉清单 +5. 单条回写 + 整体回写 +6. 回写 `plan_case` 和 `test_plan` 状态 + +### 建议做 +1. `callback_token` 鉴权 +2. 主/明细状态机 +3. 幂等处理 +4. Jenkins 失败兜底回调 + +### 可后置 +1. 并行执行 +2. 失败重跑 +3. 定时补偿 +4. 执行事件表 +5. 自动创建缺陷/附件 + +--- + +## 32. 可直接交给 AI 的开发说明摘要 + +你可以把下面这段直接给 AI 作为实现要求: + +1. 基于当前 Flask MVC 结构,新增 automation 域:controller/service/dao/model。 +2. 新增表 `auto_execution`、`auto_execution_case`,字段按本方案实现。 +3. 提供接口: + - `POST /api/automation/case/run` + - `POST /api/automation/plan/run` + - `GET /api/automation/execution/list` + - `GET /api/automation/execution/detail` + - `GET /api/automation/execution/case/list` + - `GET /api/automation/execution/case/pull` + - `POST /api/automation/execution/queued` + - `POST /api/automation/execution/start` + - `POST /api/automation/execution/case/result` + - `POST /api/automation/execution/finish` + - `POST /api/automation/execution/abort` +4. 单条执行通过 `test_case.id` 建单,计划执行通过 `plan_case + test_case.is_auto=1` 批量建单。 +5. 主状态与明细状态按本方案的枚举实现。 +6. `/case/result` 回调时同步更新 `plan_case.status / actual_result / executed_time / execution_duration`。 +7. 每次明细回写后聚合更新 `auto_execution` 的计数和主状态。 +8. 计划执行时,执行结果变化后要刷新 `test_plan.status`,把当前 `PlanController._update_plan_status()` 逻辑迁到 service 层复用。 +9. 新增 `common/jenkinsRequest.py`,封装 Jenkins `buildWithParameters` 调用。 +10. Jenkins 参数至少包含:`EXECUTION_ID`、`CALLBACK_TOKEN`、`PLATFORM_BASE_URL`、`ENV_CODE`、`RUN_MODE`、`TRIGGER_TYPE`。 +11. 回调接口采用 `X-CALLBACK-TOKEN` 鉴权,并加入免登录白名单。 + +--- + +## 33. PostgreSQL 建表 SQL(可直接落库) + +> 说明:以下 SQL 仅覆盖本次自动化执行接入新增对象,不包含已有 `test_case / plan_case / test_plan` 表。 +> 当前代码已按 PostgreSQL + SQLAlchemy 映射实现,建议先在测试环境执行 SQL,再启动服务联调。 + +### 33.1 自动化执行主表 + +```sql +CREATE TABLE IF NOT EXISTS auto_execution ( + id BIGSERIAL PRIMARY KEY, + execution_no VARCHAR(64) NOT NULL UNIQUE, + trigger_type SMALLINT NOT NULL, + project_id BIGINT NOT NULL, + plan_id BIGINT, + plan_round_no INTEGER, + source_case_id BIGINT, + env_code VARCHAR(32) NOT NULL, + run_mode SMALLINT DEFAULT 1, + status SMALLINT NOT NULL DEFAULT 0, + jenkins_job_name VARCHAR(128), + jenkins_queue_id BIGINT, + jenkins_build_number BIGINT, + jenkins_build_url VARCHAR(512), + console_url VARCHAR(512), + report_url VARCHAR(512), + total_count INTEGER DEFAULT 0, + pending_count INTEGER DEFAULT 0, + running_count INTEGER DEFAULT 0, + passed_count INTEGER DEFAULT 0, + failed_count INTEGER DEFAULT 0, + blocked_count INTEGER DEFAULT 0, + skipped_count INTEGER DEFAULT 0, + not_found_count INTEGER DEFAULT 0, + trigger_by BIGINT, + trigger_source VARCHAR(32) DEFAULT 'platform', + trigger_message TEXT, + start_time TIMESTAMP, + end_time TIMESTAMP, + duration_seconds INTEGER, + callback_token VARCHAR(128), + ext JSONB DEFAULT '{}'::jsonb, + created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +### 33.2 自动化执行明细表 + +```sql +CREATE TABLE IF NOT EXISTS auto_execution_case ( + id BIGSERIAL PRIMARY KEY, + execution_id BIGINT NOT NULL, + plan_case_id BIGINT, + case_id BIGINT NOT NULL, + case_key VARCHAR(64), + case_title VARCHAR(255), + run_order INTEGER DEFAULT 0, + status SMALLINT NOT NULL DEFAULT 0, + pytest_nodeid VARCHAR(512), + result_message TEXT, + error_message TEXT, + stack_trace TEXT, + report_url VARCHAR(512), + duration_seconds INTEGER, + started_time TIMESTAMP, + finished_time TIMESTAMP, + retry_count INTEGER DEFAULT 0, + ext JSONB DEFAULT '{}'::jsonb, + created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +### 33.3 索引 + +```sql +CREATE INDEX IF NOT EXISTS idx_auto_execution_plan_id ON auto_execution(plan_id); +CREATE INDEX IF NOT EXISTS idx_auto_execution_project_id ON auto_execution(project_id); +CREATE INDEX IF NOT EXISTS idx_auto_execution_status ON auto_execution(status); +CREATE INDEX IF NOT EXISTS idx_auto_execution_created_time ON auto_execution(created_time DESC); +CREATE INDEX IF NOT EXISTS idx_auto_execution_trigger_by ON auto_execution(trigger_by); + +CREATE INDEX IF NOT EXISTS idx_auto_execution_case_execution_id ON auto_execution_case(execution_id); +CREATE INDEX IF NOT EXISTS idx_auto_execution_case_case_id ON auto_execution_case(case_id); +CREATE INDEX IF NOT EXISTS idx_auto_execution_case_plan_case_id ON auto_execution_case(plan_case_id); +CREATE INDEX IF NOT EXISTS idx_auto_execution_case_status ON auto_execution_case(status); +``` + +### 33.4 唯一约束建议 + +当前代码在 `get_execution_case_by_unique()` 中按: +- `execution_id + case_id` +- 有 `plan_case_id` 时再附加 `plan_case_id` + +如果你确认同一执行内允许同一个 `case_id` 因不同 `plan_case_id` 出现多次,建议使用下面唯一索引: + +```sql +CREATE UNIQUE INDEX IF NOT EXISTS uk_auto_execution_case_exec_plan_case +ON auto_execution_case(execution_id, plan_case_id, case_id); +``` + +如果你确认同一执行中 `case_id` 不会重复,则可改为更强约束: + +```sql +CREATE UNIQUE INDEX IF NOT EXISTS uk_auto_execution_case_exec_case +ON auto_execution_case(execution_id, case_id); +``` + +> 推荐和当前业务保持一致:计划里可能重复挂同一个 case 时,用第一种。 + +### 33.5 外键建议 + +如果你当前库中已有规范外键管理,可以加;如果历史库本身较轻、线上变更风险高,第一期可不加外键,只保留业务层约束。 + +可选外键: + +```sql +ALTER TABLE auto_execution_case +ADD CONSTRAINT fk_auto_execution_case_execution_id +FOREIGN KEY (execution_id) REFERENCES auto_execution(id); + +ALTER TABLE auto_execution_case +ADD CONSTRAINT fk_auto_execution_case_plan_case_id +FOREIGN KEY (plan_case_id) REFERENCES plan_case(id); + +ALTER TABLE auto_execution_case +ADD CONSTRAINT fk_auto_execution_case_case_id +FOREIGN KEY (case_id) REFERENCES test_case(id); +``` + +### 33.6 updated_time 自动更新时间建议 + +当前 SQLAlchemy 已声明 `server_onupdate`,但 PostgreSQL 本身不会仅靠字段定义自动刷新 `updated_time`。 +如果你希望数据库层自动维护更新时间,建议补 trigger: + +```sql +CREATE OR REPLACE FUNCTION update_modified_column() +RETURNS TRIGGER AS $ +BEGIN + NEW.updated_time = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$ language 'plpgsql'; + +DROP TRIGGER IF EXISTS trg_auto_execution_updated_time ON auto_execution; +CREATE TRIGGER trg_auto_execution_updated_time +BEFORE UPDATE ON auto_execution +FOR EACH ROW +EXECUTE PROCEDURE update_modified_column(); + +DROP TRIGGER IF EXISTS trg_auto_execution_case_updated_time ON auto_execution_case; +CREATE TRIGGER trg_auto_execution_case_updated_time +BEFORE UPDATE ON auto_execution_case +FOR EACH ROW +EXECUTE PROCEDURE update_modified_column(); +``` + +### 33.7 权限初始化 SQL + +你当前代码新增了以下权限点: +- `automation:run` +- `automation:list` +- `automation:detail` + +如果你们 RBAC 表结构支持直接插入权限编码,可参考下面 SQL,字段名按你实际库调整: + +```sql +-- 以下为示例,请按实际 rbac_permission 表结构调整字段 +INSERT INTO rbac_permission (name, code, type, parent_id, created_time, updated_time) +VALUES +('自动化执行', 'automation:run', 2, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), +('自动化执行列表', 'automation:list', 2, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), +('自动化执行详情', 'automation:detail', 2, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +ON CONFLICT (code) DO NOTHING; +``` + +如果你们权限还要挂菜单或角色,再补: +- 角色权限关联表 +- 菜单权限关联表 + +--- + +## 34. pytest + Jenkins 联调请求示例 + +以下示例是按你当前后端已实现接口整理的,可直接给自动化项目同学或 Jenkins Pipeline 使用。 + +### 34.1 Jenkins 触发参数约定 + +平台调用 Jenkins `buildWithParameters` 时传: + +```text +EXECUTION_ID=123 +CALLBACK_TOKEN=abc123token +PLATFORM_BASE_URL=http://127.0.0.1:5010/it/api +ENV_CODE=st +RUN_MODE=1 +TRIGGER_TYPE=2 +``` + +其中: +- `CALLBACK_TOKEN`:用于 `/automation/execution/case/pull` +- `X-CALLBACK-SECRET`:用于回调写入接口,由 Jenkins 环境变量统一注入 + +--- + +### 34.2 Jenkins Pipeline 示例 + +```groovy +pipeline { + agent any + parameters { + string(name: 'EXECUTION_ID', defaultValue: '', description: '平台执行ID') + string(name: 'CALLBACK_TOKEN', defaultValue: '', description: '拉取执行清单token') + string(name: 'PLATFORM_BASE_URL', defaultValue: 'http://127.0.0.1:5010/it/api', description: '平台地址') + string(name: 'ENV_CODE', defaultValue: 'st', description: '环境编码') + string(name: 'RUN_MODE', defaultValue: '1', description: '1串行 2并行') + string(name: 'TRIGGER_TYPE', defaultValue: '2', description: '1单条 2计划') + } + environment { + CALLBACK_SECRET = credentials('AUTOMATION_CALLBACK_SECRET') + } + stages { + stage('Checkout') { + steps { + checkout scm + } + } + stage('Notify Start') { + steps { + bat ''' + curl -X POST "%PLATFORM_BASE_URL%/automation/execution/start" ^ + -H "Content-Type: application/json" ^ + -H "X-CALLBACK-SECRET: %CALLBACK_SECRET%" ^ + -d "{\"executionId\": %EXECUTION_ID%, \"jobName\": \"pytest-auto-runner\", \"buildNumber\": %BUILD_NUMBER%, \"buildUrl\": \"%BUILD_URL%\", \"consoleUrl\": \"%BUILD_URL%console\"}" + ''' + } + } + stage('Run Pytest') { + steps { + bat ''' + python run_execution.py ^ + --execution-id %EXECUTION_ID% ^ + --callback-token %CALLBACK_TOKEN% ^ + --platform-base-url %PLATFORM_BASE_URL% ^ + --env-code %ENV_CODE% ^ + --run-mode %RUN_MODE% ^ + --callback-secret %CALLBACK_SECRET% + ''' + } + } + } + post { + failure { + bat ''' + curl -X POST "%PLATFORM_BASE_URL%/automation/execution/abort" ^ + -H "Content-Type: application/json" ^ + -H "X-CALLBACK-SECRET: %CALLBACK_SECRET%" ^ + -d "{\"executionId\": %EXECUTION_ID%, \"status\": 5, \"message\": \"Jenkins failed\", \"buildNumber\": %BUILD_NUMBER%, \"consoleUrl\": \"%BUILD_URL%console\"}" + ''' + } + aborted { + bat ''' + curl -X POST "%PLATFORM_BASE_URL%/automation/execution/abort" ^ + -H "Content-Type: application/json" ^ + -H "X-CALLBACK-SECRET: %CALLBACK_SECRET%" ^ + -d "{\"executionId\": %EXECUTION_ID%, \"status\": 6, \"message\": \"Jenkins aborted\", \"buildNumber\": %BUILD_NUMBER%, \"consoleUrl\": \"%BUILD_URL%console\"}" + ''' + } + } +} +``` + +--- + +### 34.3 pytest 项目:拉取执行清单示例 + +```bash +curl -X GET "http://127.0.0.1:5010/it/api/automation/execution/case/pull?executionId=123" \ + -H "X-CALLBACK-TOKEN: abc123token" +``` + +响应示例: + +```json +{ + "code": 20000, + "data": { + "executionId": 123, + "executionNo": "AE202502120001", + "triggerType": 2, + "projectId": 88, + "planId": 2001, + "envCode": "st", + "runMode": 1, + "items": [ + { + "executionCaseId": 501, + "planCaseId": 9001, + "caseId": 1001, + "caseKey": "TC-ZHYY-001", + "caseTitle": "登录成功", + "runOrder": 1 + } + ] + }, + "msg": "success" +} +``` + +--- + +### 34.4 pytest 项目:开始执行回调示例 + +```bash +curl -X POST "http://127.0.0.1:5010/it/api/automation/execution/start" \ + -H "Content-Type: application/json" \ + -H "X-CALLBACK-SECRET: your-callback-secret" \ + -d '{ + "executionId": 123, + "jobName": "pytest-auto-runner", + "buildNumber": 89, + "buildUrl": "http://jenkins/job/pytest-auto-runner/89/", + "consoleUrl": "http://jenkins/job/pytest-auto-runner/89/console", + "startTime": "2025-02-12 10:00:00" + }' +``` + +--- + +### 34.5 pytest 项目:单条用例结果回调示例 + +#### 通过 + +```bash +curl -X POST "http://127.0.0.1:5010/it/api/automation/execution/case/result" \ + -H "Content-Type: application/json" \ + -H "X-CALLBACK-SECRET: your-callback-secret" \ + -d '{ + "executionId": 123, + "executionCaseId": 501, + "caseId": 1001, + "status": 2, + "pytestNodeid": "tests/test_login.py::test_login_success", + "resultMessage": "assert success", + "errorMessage": "", + "stackTrace": "", + "durationSeconds": 12, + "reportUrl": "http://allure/case/1001", + "startedTime": "2025-02-12 10:01:00", + "finishedTime": "2025-02-12 10:01:12", + "ext": { + "attachments": [] + } + }' +``` + +#### 失败 + +```bash +curl -X POST "http://127.0.0.1:5010/it/api/automation/execution/case/result" \ + -H "Content-Type: application/json" \ + -H "X-CALLBACK-SECRET: your-callback-secret" \ + -d '{ + "executionId": 123, + "executionCaseId": 502, + "caseId": 1002, + "status": 3, + "pytestNodeid": "tests/test_login.py::test_login_fail", + "resultMessage": "assert failed", + "errorMessage": "AssertionError: 登录失败提示不匹配", + "stackTrace": "Traceback ...", + "durationSeconds": 8, + "reportUrl": "http://allure/case/1002", + "startedTime": "2025-02-12 10:01:13", + "finishedTime": "2025-02-12 10:01:21" + }' +``` + +#### 未找到自动化实现 + +```bash +curl -X POST "http://127.0.0.1:5010/it/api/automation/execution/case/result" \ + -H "Content-Type: application/json" \ + -H "X-CALLBACK-SECRET: your-callback-secret" \ + -d '{ + "executionId": 123, + "executionCaseId": 503, + "caseId": 1003, + "status": 6, + "resultMessage": "case_id 未映射到 pytest 用例", + "errorMessage": "", + "durationSeconds": 0, + "finishedTime": "2025-02-12 10:01:22" + }' +``` + +--- + +### 34.6 pytest 项目:执行完成回调示例 + +```bash +curl -X POST "http://127.0.0.1:5010/it/api/automation/execution/finish" \ + -H "Content-Type: application/json" \ + -H "X-CALLBACK-SECRET: your-callback-secret" \ + -d '{ + "executionId": 123, + "buildNumber": 89, + "buildUrl": "http://jenkins/job/pytest-auto-runner/89/", + "consoleUrl": "http://jenkins/job/pytest-auto-runner/89/console", + "reportUrl": "http://allure/report/89", + "startTime": "2025-02-12 10:00:00", + "endTime": "2025-02-12 10:08:00", + "durationSeconds": 480, + "summary": { + "total": 15, + "passed": 12, + "failed": 2, + "blocked": 0, + "skipped": 1, + "notFound": 0 + }, + "message": "执行完成" + }' +``` + +> 注意:当前后端实现里 `finish_execution()` 最终仍会以明细表聚合结果为准刷新主单状态,所以 pytest 回传 `summary` 更像补充信息,不是唯一可信来源。 + +--- + +### 34.7 Jenkins 失败/取消兜底回调示例 + +#### 失败 + +```bash +curl -X POST "http://127.0.0.1:5010/it/api/automation/execution/abort" \ + -H "Content-Type: application/json" \ + -H "X-CALLBACK-SECRET: your-callback-secret" \ + -d '{ + "executionId": 123, + "status": 5, + "message": "Jenkins failed", + "buildNumber": 89, + "consoleUrl": "http://jenkins/job/pytest-auto-runner/89/console" + }' +``` + +#### 取消 + +```bash +curl -X POST "http://127.0.0.1:5010/it/api/automation/execution/abort" \ + -H "Content-Type: application/json" \ + -H "X-CALLBACK-SECRET: your-callback-secret" \ + -d '{ + "executionId": 123, + "status": 6, + "message": "Jenkins aborted", + "buildNumber": 89, + "consoleUrl": "http://jenkins/job/pytest-auto-runner/89/console" + }' +``` + +--- + +### 34.8 pytest 侧最小执行脚本伪代码 + +```python +import requests +import pytest + + +def pull_cases(base_url, execution_id, callback_token): + resp = requests.get( + f"{base_url}/automation/execution/case/pull", + params={"executionId": execution_id}, + headers={"X-CALLBACK-TOKEN": callback_token}, + timeout=30, + ) + resp.raise_for_status() + return resp.json()["data"] + + +def callback_case_result(base_url, callback_secret, payload): + requests.post( + f"{base_url}/automation/execution/case/result", + json=payload, + headers={"X-CALLBACK-SECRET": callback_secret}, + timeout=30, + ).raise_for_status() + + +if __name__ == '__main__': + # 1. 拉取 execution 清单 + # 2. 建立 case_id -> pytest nodeid 映射 + # 3. 逐条执行 + # 4. 每条回调结果 + # 5. 最后回调 finish + pass +``` + +--- + +## 35. 结论 + +最适合你当前项目的方案不是改造现有 `plan_case_execute`,而是: + +- **在现有 MVC 上新增独立 automation 执行域** +- **以 `case_id` 作为功能用例与 pytest 自动化用例的桥梁** +- **平台负责建单、编排、回写和状态聚合** +- **Jenkins 负责调度** +- **pytest 项目负责按 `execution_id` 拉取清单并执行** + +这样设计: +- 和你当前项目结构最兼容 +- AI 最容易按层写代码 +- 后续扩展单条执行、计划批量、并行、重跑都不会推翻重来 diff --git a/__pycache__/const.cpython-38.pyc b/__pycache__/const.cpython-38.pyc index ea7ac1c7b6b48796ef0a123be02bbd10223319d6..d8989899ef076c1aa9b94b177ab70fd8dec3faed 100644 GIT binary patch delta 420 zcmeyzbB&)bl$V!_0SM;*|CjlRXCj{rlNsYgod;2=3|XQJ#8Qk>j2ALRi9>lNDW*W4 z1e9k6=1D?%<|!6HIVmX5GQ|qWlZNuFQ*3}dnH0ug22I<|LX01o>u(8ox%zo~`US^3 zIR?ANhX(mnNoACj6j}Zb}}pLS<_qWsd;6YMfrKRnA1~Biui!RTEq_`1VDr!h!6r1B0yp#LyxEJ delta 75 zcmcb{|Br_+l$V!_0SN4Ne#&g)n#d=^^oU`i&I7g-#$X0bqs?iIADNjnMJKDWo#kA~ dP$ULaStK@Do!yi%YjP~R4kOFtE_QoHMgS1@6E^?= diff --git a/api_test_document.md b/api_test_document.md deleted file mode 100644 index bcfe64f..0000000 --- a/api_test_document.md +++ /dev/null @@ -1,416 +0,0 @@ -# API 测试文档 - -## 1. 接口概览 - -本项目基于 Flask 框架实现,提供了完整的测试平台 API,包括项目管理、用例管理、计划执行、报告生成、权限管理等功能。 - -### 基础信息 -- 基础 URL: `http://localhost:8081/it/api` -- 认证方式: `accessToken` 头部 -- 响应格式: JSON - -## 2. 模块分类 - -| 模块 | 前缀 | 主要功能 | -|------|------|----------| -| SQL 项目 | `/` | SQL 项目管理 | -| 项目管理 | `/project` | 项目 CRUD、环境管理 | -| 项目成员 | `/project/member` | 项目成员管理 | -| 产品管理 | `/product` | 产品 CRUD | -| 模块管理 | `/module` | 测试模块管理 | -| 用例管理 | `/case` | 测试用例 CRUD、快照、评审 | -| 计划管理 | `/plan` | 测试计划、轮次、执行 | -| 报告管理 | `/report` | 测试报告生成与查询 | -| Bug 管理 | `/bug` | Bug 追踪与管理 | -| 造数器 | `/data` | 数据构建与任务管理 | -| 权限管理 | `/role`, `/permission`, `/menu` | 角色、权限、菜单管理 | -| 用户管理 | `/user` | 用户 CRUD、角色分配 | -| 认证 | `/auth` | 登录、注册 | - -## 3. 详细接口测试文档 - -### 3.1 SQL 项目模块 - -| 接口 | 方法 | 权限 | 描述 | 请求参数 | 响应 | -|------|------|------|------|----------|------| -| `/list` | GET | `sql_project:list` | 查询 SQL 项目列表 | pageNo, pageSize | `{"code": 20000, "data": {...}}` | -| `/create` | POST | `sql_project:create` | 创建 SQL 项目 | 详见控制器 | `{"code": 20000, "data": {"sqlId": 1}}` | -| `/detail` | GET | `sql_project:detail` | 查询 SQL 项目详情 | id | `{"code": 20000, "data": {...}}` | -| `/delete` | POST | `sql_project:delete` | 删除 SQL 项目 | id | `{"code": 20000, "data": {"sqlId": 1}}` | -| `/execute` | POST | `sql_project:execute` | 执行 SQL 项目 | id, envId | `{"code": 20000, "data": {...}}` | - -**测试用例:** -1. 正常查询:`GET /list?pageNo=1&pageSize=10` -2. 成功创建:`POST /create` 传入完整参数 -3. 详情查询:`GET /detail?id=1` -4. 成功删除:`POST /delete` 传入 id -5. 执行 SQL:`POST /execute` 传入 id 和环境 - -### 3.2 项目管理模块 - -| 接口 | 方法 | 权限 | 描述 | 请求参数 | 响应 | -|------|------|------|------|----------|------| -| `/project/list` | GET | `project:list` | 查询项目列表 | pageNo, pageSize, productId | `{"code": 20000, "data": {"list": [...], "total": 10}}` | -| `/project/detail` | GET | `project:detail` | 查询项目详情 | id | `{"code": 20000, "data": {...}}` | -| `/project/create` | POST | `project:create` | 创建项目 | name, productId, desc | `{"code": 20000, "data": {"id": 1}}` | -| `/project/update` | POST | `project:update` | 更新项目 | id, name, desc | `{"code": 20000, "data": {"id": 1}}` | -| `/project/delete` | POST | `project:delete` | 删除项目 | id | `{"code": 20000, "data": {"id": 1}}` | - -**测试用例:** -1. 列表查询:`GET /project/list?pageNo=1&pageSize=20` -2. 详情查询:`GET /project/detail?id=1` -3. 创建项目:`POST /project/create` 传入 name, productId -4. 更新项目:`POST /project/update` 传入 id, name -5. 删除项目:`POST /project/delete` 传入 id - -### 3.3 环境管理模块 - -| 接口 | 方法 | 权限 | 描述 | 请求参数 | 响应 | -|------|------|------|------|----------|------| -| `/environment/list` | GET | `environment:list` | 查询环境列表 | pageNo, pageSize, projectId | `{"code": 20000, "data": {"list": [...], "total": 5}}` | -| `/environment/create` | POST | `environment:create` | 创建环境 | projectId, name, url, config | `{"code": 20000, "data": {"id": 1}}` | -| `/environment/update` | POST | `environment:update` | 更新环境 | id, name, url, config | `{"code": 20000, "data": {"id": 1}}` | -| `/environment/delete` | POST | `environment:delete` | 删除环境 | id | `{"code": 20000, "data": {"id": 1}}` | - -**测试用例:** -1. 环境列表:`GET /environment/list?projectId=1` -2. 创建环境:`POST /environment/create` 传入完整参数 -3. 更新环境:`POST /environment/update` 传入 id 和更新字段 -4. 删除环境:`POST /environment/delete` 传入 id - -### 3.4 项目成员模块 - -| 接口 | 方法 | 权限 | 描述 | 请求参数 | 响应 | -|------|------|------|------|----------|------| -| `/project/member/list` | GET | `project_member:list` | 查询项目成员列表 | project_id, pageNo, pageSize | `{"code": 20000, "data": {"list": [...], "total": 3}}` | -| `/project/member/create` | POST | `project_member:create` | 批量添加项目成员 | project_id, user_ids | `{"code": 20000, "data": {"id": [1, 2, 3]}}` | - -**测试用例:** -1. 成员列表:`GET /project/member/list?project_id=1&pageNo=1&pageSize=10` -2. 批量添加:`POST /project/member/create` 传入 `{"project_id": 1, "user_ids": [2, 3]}` -3. 边界测试:user_ids 为空数组 -4. 错误测试:user_ids 不是数组 - -**响应示例:** -```json -{ - "code": 20000, - "data": { - "list": [ - { - "id": 1, - "project_id": 1, - "user_id": 2, - "role": 1, - "role_name": "测试经理", - "project_name": "示例项目", - "username": "zhangsan", - "real_name": "张三", - "joined_time": "2026-04-22T10:00:00" - } - ], - "total": 1 - } -} -``` - -### 3.5 产品管理模块 - -| 接口 | 方法 | 权限 | 描述 | 请求参数 | 响应 | -|------|------|------|------|----------|------| -| `/product/list` | GET | `product:list` | 查询产品列表 | pageNo, pageSize | `{"code": 20000, "data": {"list": [...], "total": 5}}` | -| `/product/detail` | GET | `product:detail` | 查询产品详情 | id | `{"code": 20000, "data": {...}}` | -| `/product/create` | POST | `product:create` | 创建产品 | name, desc | `{"code": 20000, "data": {"id": 1}}` | -| `/product/update` | POST | `product:update` | 更新产品 | id, name, desc | `{"code": 20000, "data": {"id": 1}}` | -| `/product/delete` | POST | `product:delete` | 删除产品 | id | `{"code": 20000, "data": {"id": 1}}` | - -**测试用例:** -1. 产品列表:`GET /product/list?pageNo=1&pageSize=20` -2. 详情查询:`GET /product/detail?id=1` -3. 创建产品:`POST /product/create` 传入 name -4. 更新产品:`POST /product/update` 传入 id, name -5. 删除产品:`POST /product/delete` 传入 id - -### 3.6 模块管理模块 - -| 接口 | 方法 | 权限 | 描述 | 请求参数 | 响应 | -|------|------|------|------|----------|------| -| `/module/tree` | GET | `module:list` | 获取模块树 | projectId | `{"code": 20000, "data": [...]}` | -| `/module/create` | POST | `module:create` | 创建模块 | projectId, name, parentId | `{"code": 20000, "data": {"id": 1}}` | -| `/module/update` | POST | `module:update` | 更新模块 | id, name | `{"code": 20000, "data": {"id": 1}}` | -| `/module/delete` | POST | `module:delete` | 删除模块 | id | `{"code": 20000, "data": {"id": 1}}` | - -**测试用例:** -1. 模块树:`GET /module/tree?projectId=1` -2. 创建模块:`POST /module/create` 传入 projectId, name -3. 更新模块:`POST /module/update` 传入 id, name -4. 删除模块:`POST /module/delete` 传入 id - -### 3.7 用例管理模块 - -| 接口 | 方法 | 权限 | 描述 | 请求参数 | 响应 | -|------|------|------|------|----------|------| -| `/case/list` | GET | `case:list` | 查询用例列表 | moduleId, pageNo, pageSize | `{"code": 20000, "data": {"list": [...], "total": 20}}` | -| `/case/detail` | GET | `case:detail` | 查询用例详情 | id | `{"code": 20000, "data": {...}}` | -| `/case/create` | POST | `case:create` | 创建用例 | moduleId, title, steps | `{"code": 20000, "data": {"id": 1}}` | -| `/case/update` | POST | `case:update` | 更新用例 | id, title, steps | `{"code": 20000, "data": {"id": 1}}` | -| `/case/delete` | POST | `case:delete` | 删除用例 | id | `{"code": 20000, "data": {"id": 1}}` | -| `/case/snapshot/create` | POST | `case_snapshot:create` | 创建用例快照 | caseId, reason | `{"code": 20000, "data": {"id": 1}}` | -| `/case/snapshot/list` | GET | `case_snapshot:list` | 查询用例快照 | caseId | `{"code": 20000, "data": [...]}` | -| `/case/review/create` | POST | `case_review:create` | 创建用例评审 | caseId, reviewerId | `{"code": 20000, "data": {"id": 1}}` | -| `/case/review/update` | POST | `case_review:update` | 更新评审状态 | id, status | `{"code": 20000, "data": {"id": 1}}` | -| `/case/review/list` | GET | `case_review:list` | 查询评审列表 | projectId | `{"code": 20000, "data": [...]}` | - -**测试用例:** -1. 用例列表:`GET /case/list?moduleId=1&pageNo=1&pageSize=10` -2. 详情查询:`GET /case/detail?id=1` -3. 创建用例:`POST /case/create` 传入完整参数 -4. 更新用例:`POST /case/update` 传入 id, title, steps -5. 删除用例:`POST /case/delete` 传入 id -6. 创建快照:`POST /case/snapshot/create` 传入 caseId -7. 快照列表:`GET /case/snapshot/list?caseId=1` -8. 创建评审:`POST /case/review/create` 传入 caseId, reviewerId -9. 更新评审:`POST /case/review/update` 传入 id, status -10. 评审列表:`GET /case/review/list?projectId=1` - -### 3.8 计划管理模块 - -| 接口 | 方法 | 权限 | 描述 | 请求参数 | 响应 | -|------|------|------|------|----------|------| -| `/plan/list` | GET | `plan:list` | 查询计划列表 | projectId, pageNo, pageSize | `{"code": 20000, "data": {"list": [...], "total": 5}}` | -| `/plan/detail` | GET | `plan:detail` | 查询计划详情 | id | `{"code": 20000, "data": {...}}` | -| `/plan/create` | POST | `plan:create` | 创建计划 | projectId, name, startDate, endDate | `{"code": 20000, "data": {"id": 1}}` | -| `/plan/update` | POST | `plan:update` | 更新计划 | id, name, startDate, endDate | `{"code": 20000, "data": {"id": 1}}` | -| `/plan/delete` | POST | `plan:delete` | 删除计划 | id | `{"code": 20000, "data": {"id": 1}}` | -| `/plan/round/create` | POST | `plan_round:create` | 创建轮次 | planId, name | `{"code": 20000, "data": {"id": 1}}` | -| `/plan/round/list` | GET | `plan_round:list` | 查询轮次列表 | planId | `{"code": 20000, "data": [...]}` | -| `/plan/case/add` | POST | `plan_case:add` | 添加用例到计划 | planId, caseIds | `{"code": 20000, "data": {"addedCount": 5}}` | -| `/plan/case/list` | GET | `plan_case:list` | 查询计划用例列表 | planId, roundId | `{"code": 20000, "data": [...]}` | -| `/plan/case/execute` | POST | `plan_case:execute` | 执行计划用例 | id, status, comment | `{"code": 20000, "data": {"id": 1}}` | -| `/plan/progress` | GET | `plan:progress` | 查询计划进度 | planId | `{"code": 20000, "data": {...}}` | - -**测试用例:** -1. 计划列表:`GET /plan/list?projectId=1&pageNo=1&pageSize=10` -2. 详情查询:`GET /plan/detail?id=1` -3. 创建计划:`POST /plan/create` 传入完整参数 -4. 更新计划:`POST /plan/update` 传入 id, name -5. 删除计划:`POST /plan/delete` 传入 id -6. 创建轮次:`POST /plan/round/create` 传入 planId, name -7. 轮次列表:`GET /plan/round/list?planId=1` -8. 添加用例:`POST /plan/case/add` 传入 planId, caseIds -9. 用例列表:`GET /plan/case/list?planId=1&roundId=1` -10. 执行用例:`POST /plan/case/execute` 传入 id, status -11. 进度查询:`GET /plan/progress?planId=1` - -### 3.9 报告管理模块 - -| 接口 | 方法 | 权限 | 描述 | 请求参数 | 响应 | -|------|------|------|------|----------|------| -| `/report/list` | GET | `report:list` | 查询报告列表 | projectId, pageNo, pageSize | `{"code": 20000, "data": {"list": [...], "total": 5}}` | -| `/report/detail` | GET | `report:detail` | 查询报告详情 | id | `{"code": 20000, "data": {...}}` | -| `/report/generate` | POST | `report:generate` | 生成测试报告 | planId, roundId, title | `{"code": 20000, "data": {"id": 1}}` | - -**测试用例:** -1. 报告列表:`GET /report/list?projectId=1&pageNo=1&pageSize=10` -2. 详情查询:`GET /report/detail?id=1` -3. 生成报告:`POST /report/generate` 传入 planId, roundId, title - -### 3.10 造数器模块 - -| 接口 | 方法 | 权限 | 描述 | 请求参数 | 响应 | -|------|------|------|------|----------|------| -| `/data/builder/list` | GET | `data_builder:list` | 查询造数器列表 | pageNo, pageSize | `{"code": 20000, "data": {"list": [...], "total": 5}}` | -| `/data/builder/detail` | GET | `data_builder:detail` | 查询造数器详情 | id | `{"code": 20000, "data": {...}}` | -| `/data/builder/create` | POST | `data_builder:create` | 创建造数器 | name, config | `{"code": 20000, "data": {"id": 1}}` | -| `/data/builder/update` | POST | `data_builder:update` | 更新造数器 | id, name, config | `{"code": 20000, "data": {"id": 1}}` | -| `/data/builder/delete` | POST | `data_builder:delete` | 删除造数器 | id | `{"code": 20000, "data": {"id": 1}}` | -| `/data/builder/execute` | POST | `data_builder:execute` | 执行造数器 | id, envId | `{"code": 20000, "data": {...}}` | -| `/data/task/status` | GET | `data_task:status` | 查询任务状态 | taskId | `{"code": 20000, "data": {...}}` | - -**测试用例:** -1. 造数器列表:`GET /data/builder/list?pageNo=1&pageSize=10` -2. 详情查询:`GET /data/builder/detail?id=1` -3. 创建造数器:`POST /data/builder/create` 传入 name, config -4. 更新造数器:`POST /data/builder/update` 传入 id, name, config -5. 删除造数器:`POST /data/builder/delete` 传入 id -6. 执行造数器:`POST /data/builder/execute` 传入 id, envId -7. 任务状态:`GET /data/task/status?taskId=1` - -### 3.11 权限管理模块 - -| 接口 | 方法 | 权限 | 描述 | 请求参数 | 响应 | -|------|------|------|------|----------|------| -| `/role/list` | GET | `role:list` | 查询角色列表 | - | `{"code": 20000, "data": [...]}` | -| `/role/page/list` | GET | `role:list` | 分页查询角色 | pageNo, pageSize | `{"code": 20000, "data": {"list": [...], "total": 5}}` | -| `/role/detail` | GET | `role:detail` | 查询角色详情 | id | `{"code": 20000, "data": {...}}` | -| `/role/create` | POST | `role:create` | 创建角色 | name, status | `{"code": 20000, "data": {"id": 1}}` | -| `/role/update` | POST | `role:update` | 更新角色 | id, name, status | `{"code": 20000, "data": {"id": 1}}` | -| `/role/delete` | POST | `role:delete` | 删除角色 | id | `{"code": 20000, "data": {"id": 1}}` | -| `/permission/list` | GET | `permission:list` | 查询权限列表 | - | `{"code": 20000, "data": [...]}` | -| `/permission/detail` | GET | `permission:detail` | 查询权限详情 | id | `{"code": 20000, "data": {...}}` | -| `/permission/create` | POST | `permission:create` | 创建权限 | name, code | `{"code": 20000, "data": {"id": 1}}` | -| `/permission/update` | POST | `permission:update` | 更新权限 | id, name, code | `{"code": 20000, "data": {"id": 1}}` | -| `/permission/delete` | POST | `permission:delete` | 删除权限 | id | `{"code": 20000, "data": {"id": 1}}` | -| `/menu/tree` | GET | `menu:list` | 获取菜单树 | - | `{"code": 20000, "data": [...]}` | -| `/menu/current/list` | GET | - | 获取当前用户菜单 | - | `{"code": 20000, "data": [...]}` | -| `/role/menu/tree` | GET | `role_menu:list` | 获取角色菜单树 | roleId | `{"code": 20000, "data": [...]}` | -| `/menu/detail` | GET | `menu:detail` | 查询菜单详情 | id | `{"code": 20000, "data": {...}}` | -| `/menu/create` | POST | `menu:create` | 创建菜单 | name, path, parentId | `{"code": 20000, "data": {"id": 1}}` | -| `/menu/update` | POST | `menu:update` | 更新菜单 | id, name, path | `{"code": 20000, "data": {"id": 1}}` | -| `/menu/delete` | POST | `menu:delete` | 删除菜单 | id | `{"code": 20000, "data": {"id": 1}}` | -| `/role/permission/list` | GET | `role_permission:list` | 查询角色权限 | roleId | `{"code": 20000, "data": [...]}` | -| `/role/permission/assign` | POST | `role_permission:assign` | 分配角色权限 | roleId, permissionIds | `{"code": 20000, "data": {"id": 1}}` | -| `/role/menu/list` | GET | `role_menu:list` | 查询角色菜单 | roleId | `{"code": 20000, "data": [...]}` | -| `/role/menu/assign` | POST | `role_menu:assign` | 分配角色菜单 | roleId, menuIds | `{"code": 20000, "data": {"id": 1}}` | - -**测试用例:** -1. 角色列表:`GET /role/list` -2. 分页角色:`GET /role/page/list?pageNo=1&pageSize=10` -3. 角色详情:`GET /role/detail?id=1` -4. 创建角色:`POST /role/create` 传入 name, status -5. 分配权限:`POST /role/permission/assign` 传入 roleId, permissionIds -6. 分配菜单:`POST /role/menu/assign` 传入 roleId, menuIds -7. 菜单树:`GET /menu/tree` -8. 当前菜单:`GET /menu/current/list` - -### 3.12 用户管理模块 - -| 接口 | 方法 | 权限 | 描述 | 请求参数 | 响应 | -|------|------|------|------|----------|------| -| `/user/list` | GET | `user:list` | 查询用户列表 | pageNo, pageSize | `{"code": 20000, "data": {"list": [...], "total": 10}}` | -| `/user/detail` | GET | `user:detail` | 查询用户详情 | id | `{"code": 20000, "data": {...}}` | -| `/user/create` | POST | `user:create` | 创建用户 | username, real_name, password | `{"code": 20000, "data": {"id": 1}}` | -| `/user/update` | POST | `user:update` | 更新用户 | id, real_name, password | `{"code": 20000, "data": {"id": 1}}` | -| `/user/delete` | POST | `user:delete` | 删除用户 | id | `{"code": 20000, "data": {"id": 1}}` | -| `/user/role/list` | GET | `user_role:list` | 查询用户角色 | userId | `{"code": 20000, "data": [...]}` | -| `/user/role/assign` | POST | `user_role:assign` | 分配用户角色 | userId, roleIds | `{"code": 20000, "data": {"id": 1}}` | - -**测试用例:** -1. 用户列表:`GET /user/list?pageNo=1&pageSize=20` -2. 详情查询:`GET /user/detail?id=1` -3. 创建用户:`POST /user/create` 传入 username, real_name, password -4. 更新用户:`POST /user/update` 传入 id, real_name -5. 删除用户:`POST /user/delete` 传入 id -6. 用户角色:`GET /user/role/list?userId=1` -7. 分配角色:`POST /user/role/assign` 传入 userId, roleIds - -### 3.14 认证模块 - -| 接口 | 方法 | 权限 | 描述 | 请求参数 | 响应 | -|------|------|------|------|----------|------| -| `/auth/register` | POST | - | 用户注册 | username, password, real_name | `{"code": 20000, "data": {"id": 1}}` | -| `/auth/login` | POST | - | 用户登录 | username, password | `{"code": 20000, "data": {"token": "...", "user": {...}}}` | - -**测试用例:** -1. 用户注册:`POST /auth/register` 传入 username, password, real_name -2. 用户登录:`POST /auth/login` 传入 username, password -3. 登录失败:传入错误的用户名或密码 -4. 注册失败:传入已存在的用户名 - -## 4. 认证与权限 - -### 4.1 认证方式 -所有接口(除了 `/auth/register` 和 `/auth/login`)都需要在请求头中携带 `accessToken`: - -```bash -curl -H "accessToken: your_token" http://localhost:8081/it/api/project/list -``` - -### 4.2 权限控制 -每个接口都有对应的权限标识,例如: -- `project:list` - 查看项目列表权限 -- `project:create` - 创建项目权限 -- `project:update` - 更新项目权限 -- `project:delete` - 删除项目权限 - -## 5. 响应格式 - -### 5.1 成功响应 -```json -{ - "code": 20000, - "data": {...} -} -``` - -### 5.2 失败响应 -```json -{ - "code": 40009, - "msg": "错误信息" -} -``` - -### 5.3 错误码说明 -| 错误码 | 描述 | -|--------|------| -| 40004 | 未登录或缺少token | -| 40008 | 数据库连接超时 | -| 40009 | 参数错误或业务逻辑错误 | -| 40011 | 资源不存在或查询失败 | -| 40012 | 更新/删除操作失败 | - -## 6. 测试工具与脚本 - -### 6.1 使用 curl 测试 - -**获取项目列表:** -```bash -curl 'http://localhost:8081/it/api/project/list?pageNo=1&pageSize=10' \ - -H 'accessToken: your_token' -``` - -**添加项目成员:** -```bash -curl 'http://localhost:8081/it/api/project/member/create' \ - -H 'Content-Type: application/json' \ - -H 'accessToken: your_token' \ - --data-raw '{"project_id": 1, "user_ids": [2, 3]}' -``` - -### 6.2 使用 Postman 测试 -1. 导入本测试文档 -2. 设置环境变量 `base_url` 为 `http://localhost:8081/it/api` -3. 设置环境变量 `token` 为登录后获取的 accessToken -4. 运行测试集合 - -## 7. 性能测试建议 - -1. **并发测试**:使用 JMeter 模拟 50-100 并发用户 -2. **响应时间**:目标平均响应时间 < 500ms -3. **吞吐量**:目标 QPS > 100 -4. **数据库性能**:监控 SQL 执行时间,优化慢查询 -5. **内存使用**:监控服务内存占用,避免内存泄漏 - -## 8. 安全测试建议 - -1. **认证绕过**:测试无 token 访问受保护接口 -2. **权限越权**:测试低权限用户访问高权限接口 -3. **SQL 注入**:测试输入参数中的 SQL 注入攻击 -4. **XSS 攻击**:测试输入参数中的 XSS 攻击 -5. **CSRF 攻击**:测试跨站请求伪造防护 - -## 9. 测试环境配置 - -### 9.1 本地环境 -- Python 3.8+ -- Flask 2.0+ -- MySQL 5.7+ -- Redis(可选,用于缓存) - -### 9.2 环境变量 -- `FLASK_APP`: `app.py` -- `FLASK_ENV`: `development` 或 `production` -- `DATABASE_URL`: 数据库连接字符串 -- `SECRET_KEY`: 用于 JWT 签名 - -## 10. 总结 - -本测试文档涵盖了项目所有 API 接口的测试用例,包括: -- 功能测试:验证接口正常功能 -- 边界测试:测试参数边界情况 -- 错误测试:测试错误处理 -- 性能测试:验证系统性能 -- 安全测试:验证系统安全性 - -通过系统性的测试,可以确保 API 接口的稳定性、可靠性和安全性,为前端应用提供可靠的后端支持。 \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py index 528ddb0..97e57a6 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -15,4 +15,4 @@ def create_app(): ) app.config["API_DOC_MEMBER"] = ["api", "platform"] logger.info("app start-------") - return app + return app \ No newline at end of file diff --git a/app/__pycache__/__init__.cpython-38.pyc b/app/__pycache__/__init__.cpython-38.pyc index d87475cbd4d4cf88f0d51548e435ccf3905055f9..9852d0871873610fbb89aa181037e01b303d5235 100644 GIT binary patch delta 22 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 Xd$!_ve_ioX2xE>;-%U#Er1}2=_^k6% literal 37160 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 Ix4M%44__nw2LJ#7 literal 0 HcmV?d00001 diff --git a/app/api/controller/__pycache__/caseController.cpython-38.pyc b/app/api/controller/__pycache__/caseController.cpython-38.pyc index 5579f8e9478c2d9f5b7e71a6ddb56d76913fad7c..92c9c249a7344c110a167259d8e2ba0d4344fe78 100644 GIT binary patch delta 970 zcmY+BO-vI(6vy{%+wIcr)>2wp31I!8AAAYoO=2V@A^{U4fhNWXq+1mlg)FNQvZd7e z6%aul0wQ9UH1Xhpko9Py7Zdg1p&m3fG4Y@W6Qc*Cp46EZ3G8NmGw=WY^Y-n`%Sht1 z*eQw@hCHG1KYbt8#H_<+CQ%_7&14J(u%6w4rJRU6i}u8s6r(czAel8bW>o)2Jc&#y zsHP32Kw}5T6;p~MZgVbU)Ql>-k+*29YS@Sbja7K!%-6O`qQp%alCVj$N!zG)DhV4l z`x%Bxn@ojy)0T}6sc5}efnt%I5|cC##kFLMail$gwx?KCPBU~c!D6&JTZC-EpgbU5 z2rI1;b~r~Njv41Xn6ckgT9mb{=eqkOxt$!3+=6QbMKc~Q5SAmoN+eB>Y##vwHuCad z1P7UoG-6p`pUbov3rxhrItUN~7No;k8ge!DePH`0n)wS(g&c6g`XD|ra;8JkOg!vZ#z)xlj-$7z!z7yBZY zy7T>h#i_DS;!U!Zw6H)cEMTHshadQjuhM&Vn81Zp4N%Nd+@YAG9pR+c3V(6Ydl=IC zNAE!}KaSa_jU$ro+tqHct4_@j3#dNTA7!RZ@-U87bRHNEG3Qkyqf|sKM3aGuRRS|| zQt~T;TA75DPh+Cov}veT3T##69<=*fh1py^6WP0hlCK87;EXQ}$ML5xNa*$ZVOc-o z7g=b~yMh7Oog&?mKU*PF7NYVYGUw4mSM;_d)GA$Ef`*s%CzWjg%g9$B1r58ZM}%us z7bvdd`|1$MHPlSN1AJQ3E97o3Omj)Bs$B*SU)83-u1EK}09G+kcMO&=Qzti$Q_n4m zDT*12d5T4fC5rnLuL-i&A*EO83!jT9X9pzt4UX0CFE8aCJjdI3GkFCv;*6UG*-q@i LVtExa^}qfBJJJQl delta 889 zcmYk4TSydP6vyYxUR-x}R^3awvR=u(wO%%QE20q6us{egqLOuI%Wb>J>|`XfUb~xS zo?4?e+qIyl9&8@67wM&kqCj5?di#Dr)c15%rX(enJ;w#W?6tT*_z8_RB!>Qa6kn*zpsy1c$ZbdO#!=lctnoz2n)6n(3J!D3VL8n!g@yl;))W>=wv}f2 zidnKt+^~)uN7|ay(`W)4O|hu@tZonIcVe}p2^B{l^x;>>Bp7hS>E`7ooXNN2OJ|X* zjy|0OE8Qm0gN>ego(Bt*YCoO(AdsY}+)42Bau?GKr2Cj=Al=XO5cU_h3&Tv7Gn~OU z#oOULdygnY!ii|d$!;mEMCY)vBn207v7`m&(c^ke`j6|LFvg0mFyv-?Qd$VFw1v|9 z0B`ZM`#YpiE^B~EYUscN6?r&UW`vLUwCtUDiK=Lf6e~{QqjC+t;AhhYEmdIv=T+wV z7$z8y;aYAgIocD8p6WOnODMl_zH$dlYsFPtA@5E&24`Q=tr!cWxYoL7w)1F#* zkIy|3XvI3OpSaiSg|s%{6?q70V}2i)rf5F;mJH4X)>Tf^rW5FB>PZ+mp8Em85A4j! z0}O-Ow?GR(8n*>^!yOz8_6ld2FEE@#du=<#;ZU-Ce0>J2 z+TD65zyo~T&Qhxa#Kt5#-+bY%kpOc diff --git a/app/api/controller/__pycache__/planController.cpython-38.pyc b/app/api/controller/__pycache__/planController.cpython-38.pyc index 37f9db8739b32776354e406da30042c08e631ed2..362c35419892391159d7ae9a48eef3f3964d39fd 100644 GIT binary patch delta 1069 zcmYL|U1%It6vywGnVp%PnVsFu&hG4P$i|QQv9z{{AcZ8)+jKun zbY_YW?pP&J@F8vV^udQ9GjCF%An~F2?3>`TEaI~iLElP!5YL^I?ZWxZ|D1Er|1S5= z{QTnI^X^iiU@`jr@!-GSsY5@w&jN5f4qfd6YuER`gIV1wQ~M-E_$$s@9^N-TJ0V!Y z1nUEz;FVO36w(K|$E)1s#T{)6=vmuyVl`3|jrzGr7iy}Kt&d-uKOPxb{qFjpw;nD9 z!;$?^cdk*Q_rjO2MH?fd7j{-jv>iO1pVQCMUkDcQll&&!!Uy?(z`}dh>DD<}mS`(& zHF#ZSXC%&Nz(srEN$M!Un=;QlB(l<5vb30C|4Z8fczger!vW6WO{XyxT#%g^36ntP zFG&50#LE6L_pSmR{IfIzE7&MM4qcot-+?Q5ugsMZ;8#=Lk>F6I^wmfcumuBU4c3)4 z7SmZhJUca2VpXVI8yLZPyfM)}nd3S)cGU4{nmjLaapZ>?!nO zHO~ff<16Y&RmD=#$3^cAx}H7n7rQTu1BoF9)$jG8lpkQ~`8Zb#4{uQVF-52obfz$k z1>=!r>;2WR+lhM7uuJ2=)#mBuJ^vJ0FZ<2X3XKHEC7zL3!)<>FhB)${gA$tc2!5ik zXA-}u?>do^*mUX|eGdxGH2wmCQ_V|-i!`@f3A$H;Zg>?xZ$1z2;N9jK%~duZ!#mXm zPEVTnlGDJOQ`@b}a+B5=Pmmg!9|jw1G#q}5v#rD6;^o${_L5w4N#Z?;RS7O}Rbo@( ey2Ph=W7@Za0%@Bftl{si2HtPI1|vK%{l)(jzzeAW delta 1551 zcmZuxU2hvj6rGt}?~d2&kJwIP$4&}SAX&mkL6nL#q7nrJl?Z_lRXBmfWZcAd0^NT4~PSJA3ZA zk(p7bm)@)I@LMkayoFUG@Zb+XrtR&FR!16v- zm#w_oH!lx}l*rz&0?pS#U6Mx3H>B3ppd_bm@h_QUsSo25>ihTw+E%S(zgo0@qFhZR zULJZCgj@uNIhy+lW@izmB2Zysa+ldARbHxhcPKec%TWP7bqse5Ne)5+;Wc$NH9+Tg zKTdtk=$v||=Qy2L*LxnMRkhP|i@vKCvx5CdRZn_M-Oa|6tjhvTYC>z8?zA#;PDd9p zVO-PTClFXvn1W;OvxX(&!n$V8vp^4GA|dQ6MCvb)_h?|$Nnpa_(%7Qz2-L;Hgl~6g znDp(26{e)or4Xe>vI!l-OqT?4cwuQ!h(`*h$V7T^J4!8Sp^&)o;0n zIyYsqk&|W<+uf8VWrjHRTveb2o$3`=-hiU0)loJlc_GHWQ(ppDs34Pg&PdUU7T_!a1wKBC%)PC=0#tMdYczkud3E@Rpo&jJGMs(sH(ks?d)vGP|b$_)6rL+B~ zV5}eYzc?Q0KMfU5#ytTy_j!b85atn1B8(y|DO#ANE9zX~F`82E7kv5^c(}Q%312`lfoebXK?6<^yUynOCR#Vk#{04g=!- zi$h?-iaw1#O3*Dpo4TtVg59X!TgzI0y1|@keUDG0pCCd_yzfqBmb@Cvks8h zAql^{|1?CQ&=i``m!bKAT+HWT9NE4lX;%vjX?R~mM=?NAhl70^WmWECBiuCvobFz1X>FtBM>MzH!R_eNe-0yj`!3GT;eYc38laNd zXw@#&mNxvFScnuVcDw3%N7?ymef2QY+pE=9#cI_qE_i;`-|*Zal#XB+qfLNqLf#-A z{sj?Qrkv_*gpSZhScd8lTd+9n8qBzd%%+>e)1Z514bH!|3SNHvz+ns6?x2(Uq_jQs z7WVjO%RSz9*OuLy=Y6A=%HuSxu9u&1UPt0NgbKpj2#W{;;R1q0*i^rkA1)UlgfkuE b3C_9A4Q|5Xe3WPTpc)>0iB?p7@Poeq%ZqH( diff --git a/app/api/controller/__pycache__/projectController.cpython-38.pyc b/app/api/controller/__pycache__/projectController.cpython-38.pyc index 84848ab38a03cbaddc5785ab9f09b42f0f432f74..a630bbabe1159520d070bbf5b6fb77f1c69c5920 100644 GIT binary patch delta 21 bcmdmKz0;Z}l$V!_0SMUi8M8L>td#`-HO2&$ delta 21 bcmdmKz0;Z}l$V!_0SFT9|733DSt| z!?>16Q33^$GL_&~`+`7RK>P!!M=rgAv?mZ(Zb%h}1o7UkleFPOVy*e@yf<&(y!pL1 z&-*X^_L}p0CX-g+`Si;JU!VQfaSM(GB92Qif;)0o&&ljipIj+8v|>q7c=E+_!VpyG z7nJpL%$`@4##^Q^g;FIogEM>!Gp~S!2^K7*9Tjw1s0$)!SG%gX8F{a;ATN!d&!~J8 zcH!?L2)R2kBEK0*8@Rh`$-Cpv=#g6UuFL!5a|3^9X`2iZi=-(GlBIHT!gVw-)nUZJ ze%=dKm(2;M-J*i>9YL!GAOz4cL#stkAtc4^1%DlJv0N0yn`GE;Sg zTI~nR%#mf5>CEs+mk{qaD|m*WPGo-iOk2=U-J*OR_)u0gWUr$`f=P>pzB`$cO-Ua2xE`%<$rN_=+Ef+OU3?1pfF6<$LyPm zFk>&4FremV7h5Tj5>^deUSztkL@KWohw5Xo`&^hw;uI<1C-bq2^|D9SpMVpeN=^)X zV9hv}`E$T3-$i%~;br-YonOPs9)b~!0#p>CEM;3nkR7FFa!ruibRDh|pTG|gzM^~q zUXj}LnM)$)xEzW;3lh6YG=39c0;)1FU}7i~^169e{(L$=U4rVV)Stev+9;P+gE^S^ zL0E3AHRpN-EW4TE_Wz8F--c}jQZwNWD;AP-2RjOv^6Tlbg+9#1 ztDski1AQKw6|^a6RcV+x@ej3j(zFgV&DhtBL1G+fEZvF!8Eim delta 506 zcmZ9J!7Bt&9LML)?u^OqjAb=#J7Wi})t6xuy)s$unf(Q`?_zGW3XoB94}NuIaKn}s#lfIBcZeyttuf2<%{UF~ILp}0FWh0cBasA&a^n}=lh^B8 zXMm6ocx!)v8>a>CI(ooJ=wujP)Fe2a>FvKhMVArYr7}SWxd=T%WD#Q=uJW&($M_s$ zLAg>|p0CuHsLPG~mE`gBj~cF*0-w<~)DXzvMuM&^&5Nv(P%tY$e3^+aBL;sQtbN0Q gLYD(UZauw+>HCNS#3AAsaRT4D@kmNjq1avj1xNXCOaK4? diff --git a/app/api/controller/__pycache__/userController.cpython-38.pyc b/app/api/controller/__pycache__/userController.cpython-38.pyc index 13bd97dbeeb5d5768e85f491663d717db9e5edb7..0d2fe690aa20877b32675ddc57778a8aa9f4ad95 100644 GIT binary patch delta 1642 zcmZux&uoz+q=6ewLk@&|vt9xQT zvwj)^xJQs?TM6sLd0os9#^}f!nF7|^S53TPqWA7 zD{y=Okh|rH1rGlhj#)sKAaz$+68>69&?a1e6$EwbX z=egSKPA|5NQ3=!>uTb;;SU`o;#-yBy;vhTGSpEfaWTlIf+0VSYm*oDJu!66vL`>F5 z^UXMJt#(6f63b%rVbr0JBxi3-*25=xT`!~as=tfGsCNzVys&q$1h@PI6N92-;UoZa z#ZM8uJ0``^EMs_a&D60*V5mz}d!|E9&3|Gn>09 zp5C)$3uG;_K3SV=v92XP*%HM~ku8(oiFU0R-)TYR{G0JM_%ZRUxKyp+L^~Hd%_hB{ zST_~(rJ3DnVf1elDrfoit2kBqb0Gf$ijzZd1?A)r$eOrX>S9($Guv)bliI!2RyRyq z)K*+5ZUY>^QGlEzi&)@JbUDq){w{0UKudjgbG^k`7_%F_ZWQzDl#y0ni=0^e5{1V` z2eDbHqeC?Qj6l delta 1439 zcmZ8gTWb?R6y8g+n{1LbjoMlbwqDDkB2`*%^;X5!3)Nb@5Rjz&;9~mB@e!cEx!5UgZkq6X4{%(7ry!C%$zgdoO9-0%Z-ipnN-SB z=<83-cz+wpc2?~!Z)(e^N|)jqu6avwO{U(|bMf-EeX3Ueu=U-aKFwH89(=jz)96>c966?1;&^1FJB~R5SE63PhJ3ynJi(biS_tw5&ku4jUM#Tu(4`T+0&8(O z^@gt$c^DlL8igMHMn}v>_qQ&EVHrRkmLTTDcoPiGfG9yFKEQ(fx z)g1L1hfSWOa~3Q*ivxC{x|WIupC0OC?VfVjK{fVfNsz%jxbsLkSMydkq7o9t5X4$)|B zNmPsNwaO&%WUp}1Jy$O zPOWZR$a|RG|81dS{~-Xnh#!X2d{c-M_R1Yn@)4{d15PAVuy9PyVq^4T^6274DS2*R zv`z9r>m$DZMI@HonJ6ifiIVP`ZfroC(ykkUHkm9bS0g3;YJ5s78E(8oDMe_sIutiS zW0K}k8dDugkfPBh`w|+{WH;QpD_Wo8WO}L^b1xk>isZ~bc{OwQKl!@wD!3(zb=e6tJ;zmf z8$cG?AVzsy{AMZBiiFwVm4Du N*X|O7OS4*9{|6o<67~Q9 diff --git a/app/api/controller/automationController.py b/app/api/controller/automationController.py new file mode 100644 index 0000000..2e7cc2f --- /dev/null +++ b/app/api/controller/automationController.py @@ -0,0 +1,53 @@ +# encoding: UTF-8 +from flask import g + +from const import AUTOMATION_CALLBACK_SECRET +from .baseCrudController import BaseCrudController +from ..service.automationService import AutomationService + + +class AutomationController(BaseCrudController): + def validate_callback_secret(self): + callback_secret = self.req_data.get('_callback_secret') + if AUTOMATION_CALLBACK_SECRET and callback_secret != AUTOMATION_CALLBACK_SECRET: + return False, '回调鉴权失败' + return True, '' + + def case_run(self): + return AutomationService.create_case_execution(self.session, self.req_data, getattr(g, 'current_user_id', None)) + + def plan_run(self): + return AutomationService.create_plan_execution(self.session, self.req_data, getattr(g, 'current_user_id', None)) + + def execution_list(self): + return AutomationService.list_executions(self.session, self.req_data) + + def execution_detail(self): + execution_id = self._get(self.req_data, 'executionId', 'id') + if not execution_id: + return {}, 'executionId 为必传参数' + return AutomationService.get_execution_detail(self.session, execution_id) + + def execution_case_list(self): + return AutomationService.list_execution_cases(self.session, self.req_data) + + def execution_case_pull(self): + execution_id = self._get(self.req_data, 'executionId', 'execution_id') + if not execution_id: + return {}, 'executionId 为必传参数' + return AutomationService.pull_execution_cases(self.session, execution_id, self.req_data.get('_callback_token')) + + def execution_queued(self): + return AutomationService.mark_execution_queued(self.session, self.req_data) + + def execution_start(self): + return AutomationService.mark_execution_started(self.session, self.req_data) + + def execution_case_result(self): + return AutomationService.save_case_result(self.session, self.req_data) + + def execution_finish(self): + return AutomationService.finish_execution(self.session, self.req_data) + + def execution_abort(self): + return AutomationService.abort_execution(self.session, self.req_data) diff --git a/app/api/controller/caseController.py b/app/api/controller/caseController.py index 5a61878..b5f361f 100644 --- a/app/api/controller/caseController.py +++ b/app/api/controller/caseController.py @@ -169,10 +169,12 @@ class CaseController(BaseCrudController): steps_value = self._get(self.req_data, 'steps', default='') if isinstance(steps_value, (list, dict)): steps_value = '' + product_id = self._get(self.req_data, 'productId') + module_id = self._get(self.req_data, 'moduleId') add_info = { 'project_id': project_id, - 'module_id': self._get(self.req_data, 'moduleId'), - 'case_key': self._get(self.req_data, 'caseKey') or CaseService.next_case_key(self.session, project_id), + 'module_id': module_id, + 'case_key': self._get(self.req_data, 'caseKey') or CaseService.next_case_key(self.session, project_id, module_id, product_id), 'title': title, 'preconditions': self._get(self.req_data, 'preconditions'), 'steps': steps_value, @@ -355,7 +357,7 @@ class CaseController(BaseCrudController): retry_count = 0 max_retries = 5 - case_key = CaseService.next_case_key(self.session, project_id) + case_key = CaseService.next_case_key(self.session, project_id, module_id) while retry_count < max_retries: try: diff --git a/app/api/controller/planController.py b/app/api/controller/planController.py index a866284..b7ee195 100644 --- a/app/api/controller/planController.py +++ b/app/api/controller/planController.py @@ -56,7 +56,7 @@ class PlanController(BaseCrudController): name = self._get(self.req_data, 'name') if not project_id or not name: return 0, 'projectId、name 为必传参数' - add_info = {'project_id': project_id, 'name': name, 'version': self._get(self.req_data, 'version'), 'description': self._get(self.req_data, 'description'), 'start_date': self._get(self.req_data, 'startDate', 'start_time'), 'end_date': self._get(self.req_data, 'endDate', 'end_time'), 'owner_id': self._get(self.req_data, 'ownerId', 'owner_id'), 'status': int(self._get(self.req_data, 'status', default=0)), 'environment_id': self._get(self.req_data, 'environmentId', 'environment_id'), 'is_delete': 0} + add_info = {'project_id': project_id, 'name': name, 'version': self._get(self.req_data, 'version'), 'description': self._get(self.req_data, 'description'), 'start_date': self._get(self.req_data, 'startDate', 'start_time'), 'end_date': self._get(self.req_data, 'endDate', 'end_time'), 'owner_id': self._get(self.req_data, 'ownerId', 'owner_id'), 'status': int(self._get(self.req_data, 'status', default=0)), 'environment_id': self._get(self.req_data, 'environmentId', 'environment_id'), 'jenkins_url': self._get(self.req_data, 'jenkinsUrl', 'jenkins_url'), 'is_auto': int(self._get(self.req_data, 'isAuto', 'is_auto', default=0)), 'is_delete': 0} return PlanService.create(self.session, TestPlan, add_info) def plan_update(self): @@ -65,7 +65,7 @@ class PlanController(BaseCrudController): if not plan_id: return 0, 'planId 为必传参数' update_info = {} - for req_keys, column_key in [(('name', 'name'), 'name'), (('version', 'version'), 'version'), (('description', 'description'), 'description'), (('startDate', 'start_time', 'start_date'), 'start_date'), (('endDate', 'end_time', 'end_date'), 'end_date'), (('ownerId', 'owner_id'), 'owner_id'), (('status', 'status'), 'status'), (('environmentId', 'environment_id'), 'environment_id')]: + for req_keys, column_key in [(('name', 'name'), 'name'), (('version', 'version'), 'version'), (('description', 'description'), 'description'), (('startDate', 'start_time', 'start_date'), 'start_date'), (('endDate', 'end_time', 'end_date'), 'end_date'), (('ownerId', 'owner_id'), 'owner_id'), (('status', 'status'), 'status'), (('environmentId', 'environment_id'), 'environment_id'), (('jenkinsUrl', 'jenkins_url'), 'jenkins_url'), (('isAuto', 'is_auto'), 'is_auto')]: value = self._get(self.req_data, *req_keys) if value is not None: update_info[column_key] = value @@ -99,7 +99,7 @@ class PlanController(BaseCrudController): return PlanService.batch_create(self.session, PlanCase, batch_info_list) def plan_case_list(self): - plan_id = self._get(self.req_data, 'planId') + plan_id = self._get(self.req_data, 'planId', 'plan_id') filters = [PlanCase.plan_id == int(plan_id)] if plan_id else [] round_no = self._get(self.req_data, 'roundNo') if round_no not in (None, ''): @@ -151,38 +151,9 @@ class PlanController(BaseCrudController): update_info = {'status': int(self._get(self.req_data, 'status', default=0)), 'actual_result': self._get(self.req_data, 'actualResult'), 'defect_links': self._get(self.req_data, 'defectLinks', default=[]), 'attachments': self._get(self.req_data, 'attachments', default=[]), 'executed_time': datetime.now(), 'execution_duration': self._get(self.req_data, 'executionDuration')} result = PlanService.update_by_id(self.session, PlanCase, plan_case_id, update_info, soft_delete=False) - self._update_plan_status(plan_id) + PlanService.refresh_plan_status(self.session, plan_id) return result - - def _update_plan_status(self, plan_id): - total = self.session.query(PlanCase).filter(PlanCase.plan_id == plan_id).count() - if total == 0: - return - - unexecuted_count = self.session.query(PlanCase).filter(PlanCase.plan_id == plan_id, PlanCase.status == 0).count() - passed_count = self.session.query(PlanCase).filter(PlanCase.plan_id == plan_id, PlanCase.status == 1).count() - failed_count = self.session.query(PlanCase).filter(PlanCase.plan_id == plan_id, PlanCase.status.in_([2, 3])).count() - - plan = PlanService.get_by_id(self.session, TestPlan, plan_id) - if not plan: - return - - if plan.status == 3: - return - - if unexecuted_count == 0: - if failed_count == 0: - new_status = 4 - else: - new_status = 2 - elif unexecuted_count < total: - new_status = 1 - else: - new_status = plan.status - - if new_status != plan.status: - PlanService.update_by_id(self.session, TestPlan, plan_id, {'status': new_status}) def progress(self): """查询计划进度统计。""" diff --git a/app/api/controller/rbacController.py b/app/api/controller/rbacController.py index 8c51c9f..1691379 100644 --- a/app/api/controller/rbacController.py +++ b/app/api/controller/rbacController.py @@ -208,9 +208,6 @@ class RbacController(BaseCrudController): (('name',), 'name'), (('code',), 'code'), (('type',), 'type'), - (('path',), 'path'), - (('component',), 'component'), - (('icon',), 'icon'), (('permissionCode', 'permission_code'), 'permission_code'), (('sort',), 'sort'), (('visible',), 'visible'), @@ -220,12 +217,30 @@ class RbacController(BaseCrudController): value = self._get(self.req_data, *req_keys) if value is not None: update_info[column_key] = value + + for key in ['path', 'component', 'icon']: + if key in self.req_data: + update_info[key] = self.req_data[key] + return RbacService.update_by_id(self.session, Menu, menu_id, update_info) def menu_delete(self): menu_id = self._get(self.req_data, 'menuId', 'id') if not menu_id: return 0, 'menuId 为必传参数' + + menu = RbacService.get_by_id(self.session, Menu, menu_id) + if menu and menu.permission_code: + permission = self.session.query(Permission).filter( + Permission.code == menu.permission_code, + Permission.is_delete == 0 + ).first() + if permission: + self.session.query(RolePermission).filter( + RolePermission.permission_id == permission.id, + RolePermission.is_delete == 0 + ).update({'is_delete': 1}) + return RbacService.delete_by_id(self.session, Menu, menu_id) def role_permission_list(self): @@ -254,4 +269,44 @@ class RbacController(BaseCrudController): menu_ids = self._get(self.req_data, 'menuIds', default=[]) if not role_id: return 0, 'roleId 为必传参数' + + if isinstance(menu_ids, str): + import json + menu_ids = json.loads(menu_ids) + if not isinstance(menu_ids, list): + menu_ids = [] + + menu_permission_codes = RbacService.get_menu_permission_codes(self.session, menu_ids) + + permission_ids = RbacService.get_permission_ids_by_codes(self.session, menu_permission_codes) + + existing_permission_ids = RbacService.get_role_permission_ids(self.session, role_id) + + deleted_permission_ids = [pid for pid in existing_permission_ids if pid not in permission_ids] + if deleted_permission_ids: + self.session.query(RolePermission).filter( + RolePermission.role_id == int(role_id), + RolePermission.permission_id.in_(deleted_permission_ids), + RolePermission.is_delete == 0 + ).update({'is_delete': 1}) + + new_permission_ids = [pid for pid in permission_ids if pid not in existing_permission_ids] + if new_permission_ids: + existing_deleted = self.session.query(RolePermission).filter( + RolePermission.role_id == int(role_id), + RolePermission.permission_id.in_(new_permission_ids), + RolePermission.is_delete == 1 + ).all() + existing_deleted_map = {rp.permission_id: rp for rp in existing_deleted} + + for permission_id in new_permission_ids: + if permission_id in existing_deleted_map: + existing_deleted_map[permission_id].is_delete = 0 + else: + self.session.add(RolePermission( + role_id=int(role_id), + permission_id=permission_id, + is_delete=0 + )) + return RbacService.assign_menus(self.session, role_id, menu_ids) diff --git a/app/api/controller/userController.py b/app/api/controller/userController.py index 48f4565..0f9a0b3 100644 --- a/app/api/controller/userController.py +++ b/app/api/controller/userController.py @@ -2,7 +2,7 @@ from .baseCrudController import BaseCrudController from ..model.userModel import User from ..service.userService import UserService -from ..utils.authMiddleware import TOKEN_REFRESH_THRESHOLD_SECONDS, create_token +from ..utils.authMiddleware import TOKEN_REFRESH_THRESHOLD_SECONDS, create_token, create_refresh_token class UserController(BaseCrudController): @@ -118,11 +118,14 @@ class UserController(BaseCrudController): return {}, '用户已禁用!' UserService.update_last_login_time(self.session, user.id) token, expire_seconds = create_token(user.id) + refresh_token, refresh_expire_seconds = create_refresh_token(user.id) ret = self.serialize(user, ['is_delete', 'password_hash']) ret['role_ids'] = UserService.get_user_role_ids(self.session, user.id) ret['token'] = token ret['token_type'] = 'Bearer' ret['expires_in'] = expire_seconds + ret['refresh_token'] = refresh_token + ret['refresh_expires_in'] = refresh_expire_seconds ret['refresh_threshold_seconds'] = TOKEN_REFRESH_THRESHOLD_SECONDS ret['refresh_mechanism'] = '请求任意已登录接口时,若token剩余有效期小于阈值则自动续期到完整有效期' return ret, '' diff --git a/app/api/dao/__pycache__/automationDao.cpython-38.pyc b/app/api/dao/__pycache__/automationDao.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7f35504ffcf2a91eaeb39adc5fe9cf93af1e5cd2 GIT binary patch literal 6003 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 XyUouGJ|!*nGzC<4m8iB|je7nU1ye{N delta 479 zcmXwzO-mb56o%*AjFV){Bpnf|1Tr&8TuiD_RK$h&z{c({qJhIM03GbI#?xe?C2>;+uw{D|GfBp11zS zj^bBvc66IsNbx?d_23XHny+$;hk4}1g~{1G#*G&jAs6R~ogC`o*jDC2R~u_sAJKxGVcok7f?(x^!5Au1K`vg2lupd7!RuX%01EESp~8~*_J Cfpdib diff --git a/app/api/dao/__pycache__/projectHookDao.cpython-38.pyc b/app/api/dao/__pycache__/projectHookDao.cpython-38.pyc index a0e1358a8e3b7e1214e50bc4185802d3b030008e..caa93ab49dcc463e6c0b9404f77a05a9e58b84af 100644 GIT binary patch delta 21 bcmbOtGDUaBu+tDAxl; delta 21 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 literal 0 HcmV?d00001 diff --git a/app/api/model/__pycache__/planModel.cpython-38.pyc b/app/api/model/__pycache__/planModel.cpython-38.pyc index 1c0a13876e6313bcf330b7b5e249fbfe018fb2c8..b4aa660e00c6ed05fafaa7d88f7a114a8b64d1e0 100644 GIT binary patch delta 486 zcmaDY`c{lDl$V!_0SMUi8ME@aH}XX?GJcud$Z9Zo24hYvM~Y}OV~SV`PcKuHNGeOJ zV2WUhc#3c@bCf8MCkEt6fO+CTo1qQ;JlIbT3Pk6haIxBa?59SXz=l`2dS0n-S2^BBROwS)Ak*fJ_cX7DfR^K1LxR$peLaKpH5vIfiu(lO#kv zSQCga1`#GeLT<7kha#iNWMd95M)S#a9J?6JCR=jGM_Pasu!0C{5Wx;2R6zs_h|mBL znjiw3xh_BnT_7&z0}?Qo@qt}db%7 delta 331 zcmaDW_F9xLl$V!_0SH#u|H(YTwUIB9k@4DOT~4FPvlz4NMN$M)L{o%&nWIEgSyIJP z#Z$zZ8B>^2#8V`CS)wEmVsIJB6sca;D9IG*U zn*q?SB7@1btWJzFlMk>;Z@$kun@JKP3YG#9h9JTSNXSf{%%R9=G|L}-8rO%Q?2SSO%_HV_x{0SOL97DfR^J|GleQ+{A(+kS+}4CcAPgvkD6E@i77b&h9AE_iF{}hu delta 21 bcmX@ZeTJJSl$V!_0SGMJ{$y_CIlu}4Il={+ diff --git a/app/api/model/automationModel.py b/app/api/model/automationModel.py new file mode 100644 index 0000000..2fa2339 --- /dev/null +++ b/app/api/model/automationModel.py @@ -0,0 +1,70 @@ +from sqlalchemy import BigInteger, Column, Integer, SmallInteger, String, TIMESTAMP, Text, text +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.ext.declarative import declarative_base + +from common.sqlSession import to_dict + +Base = declarative_base() +Base.to_dict = to_dict + + +class AutoExecution(Base): + __tablename__ = 'auto_execution' + id = Column(BigInteger, primary_key=True, autoincrement=True, comment='id') + execution_no = Column(String(64), nullable=False, unique=True, comment='执行编号') + trigger_type = Column(SmallInteger, nullable=False, comment='1:单条 2:计划') + project_id = Column(BigInteger, nullable=False, comment='项目id') + plan_id = Column(BigInteger, comment='计划id') + plan_round_no = Column(Integer, comment='计划轮次') + source_case_id = Column(BigInteger, comment='单条执行来源case_id') + env_code = Column(String(32), nullable=False, comment='环境编码') + run_mode = Column(SmallInteger, default=1, comment='1:串行 2:并行') + status = Column(SmallInteger, nullable=False, default=0, comment='0:待触发 1:触发中 2:排队中 3:执行中 4:成功 5:失败 6:已取消 7:触发失败 8:回调异常') + jenkins_job_name = Column(String(128), comment='Jenkins任务名称') + jenkins_queue_id = Column(BigInteger, comment='Jenkins队列id') + jenkins_build_number = Column(BigInteger, comment='Jenkins构建号') + jenkins_build_url = Column(String(512), comment='Jenkins构建地址') + console_url = Column(String(512), comment='控制台地址') + report_url = Column(String(512), comment='报告地址') + total_count = Column(Integer, default=0, comment='总数') + pending_count = Column(Integer, default=0, comment='待执行数') + running_count = Column(Integer, default=0, comment='执行中数') + passed_count = Column(Integer, default=0, comment='通过数') + failed_count = Column(Integer, default=0, comment='失败数') + blocked_count = Column(Integer, default=0, comment='阻塞数') + skipped_count = Column(Integer, default=0, comment='跳过数') + not_found_count = Column(Integer, default=0, comment='未找到数') + trigger_by = Column(BigInteger, comment='触发人') + trigger_source = Column(String(32), server_default=text("'platform'"), comment='触发来源') + trigger_message = Column(Text, comment='触发消息/失败原因') + start_time = Column(TIMESTAMP, comment='开始时间') + end_time = Column(TIMESTAMP, comment='结束时间') + duration_seconds = Column(Integer, comment='耗时秒数') + callback_token = Column(String(128), comment='回调token') + ext = Column(JSONB, server_default=text("'{}'::jsonb"), comment='扩展字段') + created_time = Column(TIMESTAMP, server_default=text('CURRENT_TIMESTAMP'), nullable=True, comment='创建时间') + updated_time = Column(TIMESTAMP, server_default=text('CURRENT_TIMESTAMP'), server_onupdate=text('CURRENT_TIMESTAMP'), nullable=True, comment='修改时间') + + +class AutoExecutionCase(Base): + __tablename__ = 'auto_execution_case' + id = Column(BigInteger, primary_key=True, autoincrement=True, comment='id') + execution_id = Column(BigInteger, nullable=False, comment='执行主单id') + plan_case_id = Column(BigInteger, comment='计划用例id') + case_id = Column(BigInteger, nullable=False, comment='用例id') + case_key = Column(String(64), comment='用例编号快照') + case_title = Column(String(255), comment='用例标题快照') + run_order = Column(Integer, default=0, comment='执行顺序') + status = Column(SmallInteger, nullable=False, default=0, comment='0:待执行 1:执行中 2:通过 3:失败 4:阻塞 5:跳过 6:未找到 7:已取消') + pytest_nodeid = Column(String(512), comment='pytest节点标识') + result_message = Column(Text, comment='结果摘要') + error_message = Column(Text, comment='错误信息') + stack_trace = Column(Text, comment='堆栈') + report_url = Column(String(512), comment='单用例报告地址') + duration_seconds = Column(Integer, comment='耗时秒数') + started_time = Column(TIMESTAMP, comment='开始时间') + finished_time = Column(TIMESTAMP, comment='结束时间') + retry_count = Column(Integer, default=0, comment='重试次数') + ext = Column(JSONB, server_default=text("'{}'::jsonb"), comment='扩展字段') + created_time = Column(TIMESTAMP, server_default=text('CURRENT_TIMESTAMP'), nullable=True, comment='创建时间') + updated_time = Column(TIMESTAMP, server_default=text('CURRENT_TIMESTAMP'), server_onupdate=text('CURRENT_TIMESTAMP'), nullable=True, comment='修改时间') diff --git a/app/api/model/planModel.py b/app/api/model/planModel.py index 048d5ce..e0367fe 100644 --- a/app/api/model/planModel.py +++ b/app/api/model/planModel.py @@ -20,6 +20,8 @@ class TestPlan(Base): owner_id = Column(BigInteger, comment='负责人') status = Column(SmallInteger, default=0, comment='0:草稿 1:进行中 2:已完成 3:已归档 4:已通过') environment_id = Column(BigInteger, comment='环境id') + jenkins_url = Column(String(512), comment='Jenkins构建URL') + is_auto = Column(SmallInteger, default=0, comment='是否自动化测试计划:0-否,1-是') is_delete = Column(Integer, default=0, comment='0:未删除;1:已删除') created_time = Column(TIMESTAMP, server_default=text('CURRENT_TIMESTAMP'), nullable=True, comment='创建时间') updated_time = Column(TIMESTAMP, server_default=text('CURRENT_TIMESTAMP'), server_onupdate=text('CURRENT_TIMESTAMP'), nullable=True, comment='修改时间') diff --git a/app/api/service/__pycache__/automationService.cpython-38.pyc b/app/api/service/__pycache__/automationService.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7403f0866e2b8d0794d0c9ccd2c4e09600316203 GIT binary patch literal 16753 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 delta 21 bcmdlfyi=Gbl$V!_0SJ75{m$ITvz8M8JzfSp diff --git a/app/api/service/__pycache__/caseService.cpython-38.pyc b/app/api/service/__pycache__/caseService.cpython-38.pyc index b7bd2ed040c9bcd1d3a1366ccd1a4ebeed14d711..2252b7f5bc8a7da53e614820a73b3897fce44ac7 100644 GIT binary patch delta 128 zcmZ3)f0&;)l$V!_0SMUi8MD?-@roj&)W&a@m?xiLvYPyl zgjZ3 diff --git a/app/api/service/__pycache__/jenkinsPollService.cpython-38.pyc b/app/api/service/__pycache__/jenkinsPollService.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0b9c14b6955a7fdf0f9fbdb169a2c834183a6083 GIT binary patch literal 8582 zcmb7JTW}o5b)A`=nVo%N@qX~36-7}11p$&Q+X+KRA}LT$LlOao(wg>au{{8Hxeu^s z#v-?79m$kavLi}J%92$vWiMQ|WqpWZ6ql^%5m)8Q->FJI<}0ZXAdwFzA4!$Vj-{O2 zy}MX|AX5Qqdhhh(_U+r}-oAb3cp?#1@b|BesKqOX73JUPrSmV2mk04U@1tM}Q%g!l z{;C-j?^;PKYZ*~DJb~=0f*b_m# zRdalH`dE-?z0E!O_%v}dOV!0in~!Nh0xYW2XYIV_7OIt?d(-QYoMY!ng3do3FAw5z zW>DA)=`o`*6}ru` z8k(yXL$2;)=F=M9jTNP?`I_@3*KCBD>Z?Cc-a~?b-o~urCK^d*uBi18TDt1go~j$Z?)(N+HxICzN~x!A`evh-SyZ>8)x&<6 z|AQZH^sxw}>4zpv)aqiMn6I~_IrAGKUz|p@>R4h{sq~5ZWQW|SbqAJJuDgSx9C8QT z!Sh;0V@lmxHfxuBW7%{};)Hg8lT=+BQlYJQy(KTyS}XrE3LWxcw-0Ky`nB_{22P|9#-&vOIy~o;LsuK z4Kb1S4cc+{*2bO{h245FL_6#&#oI9No(rItLuby4*>0oRoT8xjf(}9JgrnQVT>Dym zu-ZFXeIP9`qAHSmJ#k|xAj4iiA;yieu@#N&zg`mbx!qTMmEFn4FN7eaR@{g6f96IS zcc30`EATug6m}QTO!^rWQXUq&HzA~)Y}L2)2wr!0)M*Ra2?dxUgY&&W z;SgF!*%t)A`)Cha?P5Q_i2XG9zY7juqCV2X1QwzKL0<+IlFM2xDe#bNb?^l(aANRfzf0gGCHnrcpTaKu)*bUx=T%@n+@3838Q%9(EIh2#yZ!DKHUxEm z?SR$w2>MTem|j60WMR~jg~izvVVvFX_n>^h?*&(}i+<0t&KC$R_5R9`-^(7vT6KOF zHTzLx(jM0P1&2dII|6_39sO+%hTre^Va)qL#GQU0JG?_#Q8BWOzWM-WF`T`AFIKQu z*82nO$U&t;gQk$re9J`3L))}WiR7VJ%Zr$4f6@_!L{cxnR=(w1(lf5Ml))8E0K zzTN|1WyhMbGcp*?=&@&KyuAnt*1q%hwUt+{UAnyb>FZZMys-N2o2wUp zejshP!k%n_O^sIIJ*FL>;?Y$pw;Z=)_ryV!v zdJfpm1I8(cs;JAy+=*&&}2`iiM0#@)-DeX%yQEc7OyBiUV4u=@62Nc3%)2T^-pQHHwN&cz3& z1WpjeFL$#f`#Wl2-Q?)3xFIi0mq32JX`)^X-)`e_lI2Pg~=oDCXg$W6RE|(YRMv*Ps zj+0xo=SXi0Ri3xA3x!I-IR!i5w_=R2$4>57-~G<&-+vp>-YO+*h}${r=9JgnmZ+n5 zE!Ns8VZC!+*Y&*wsIR^HOW2H@Q(EupPyXiGFMhgp!eq;6v-v8srwZT0PJ0iunXa$= z9k#UXKK|pAS5_~-b?v>^*M9U6>Dcj}(Ig+)sHUVZh? zS6_QyGJ{?7(v(L!-qhFxY`*GM+^LKuxE3mlfyr&hE4e|0&;s0)mpsz-+Qk>v-nc9| zv}~pmYg08Wx!_3rfzK5AEy-#s2+tlra%6gTR&x5mFNl+HquZ{ra1Hk}z6X;A-J4;J zmDH@se6`|KVJxvGk(JfCQVEtd8AQ3gR0Y~m>+Nf6yi?a#KK<3z@BO@UMNkrTmAl)i zrq=FCLz0_W3j^g#d?`d3+Q-`4|ckI6}k3-e7=m za7}ewdS&h9<*m{f!ge<}a5~%M)}@%obCd_j;@)4aFvm+0YpJIuGvVjtBGRq1xqV=t^KN6iTkm1FgV1H1!X6Fvg z9iPoUeSG@(^g}_i`Fia5%*>-R4+nao0%t?n0oVeV6qbTi3x=(8h7Y4zEIZIR+_q!q zc>YviEb^+i6oi4mQZ8?E$}Vt|3d%Tau#txXgtfR0+HsvAv}n6QtOI6&PNz>`Fniux z#E(~Aasty^qTCIqj71QtRG$xYR;}3WB^^EV=o47dFt-Rrt^-%zYf;FyPq96 zSvz&^+(~<3!9MNY1x(v~0gyVGTUx?XIO)iI*2yL^J5pQ|OXq^&C8WHvE!4`1rsbLMpaWaH65j;CehNP_MpYosK-dNo?1+_MAad!M?-&%PpNT? z7}erv8&F474P(teSVjt5N6|8ZcTJ6=CXT+6LtNdd4rl|ar43;m@gPZvw$$C=Yhi{s zJ=)IZo28kcHEb(@A~CR{HNv2fJZvW#qtpW_z8F$kI!15)4_&kW( zGK`BB5>dW+UOlLsdB%OoCh3O%{=4XwT9G?v!fN_q&xCB@ZObGOs~h##ek zH_q|xe`uS5hL)ssp}LK6a?@uP%%x#<5WR zb}CLX{v=U{sdxy*Sdu?Yw3w0-0Ybt+=hf#O9B=Y?l?$t&H86yB`7HI!P_an0NqK{5 zsYlLBE?@Y4o+auiihsi6Jc2^e;&4;(&vi}z%+f)@#l_*eO*q47=l^fbSX_MsZal8` zsr%6j|ZtKEYAwc^S&NkPv>5~X~FAB4S2b;ms0nZ>k7!oUO52oG7gOT@Y zMBP|1jwgX9xrnql{9ISluccr;p#prHAA%Q4!HXI6sl(TO1K#VTuQ$4BG}%5rddRng zc(a;sN%00xfe&u@3|5aqt}xj(|3{LKEUOXrqnrF)AJdspz=s`Gsg&CG9rwXSc#il@ zN1Dx%_rh032B8t*jqbncn6MlJ3-6~-SZxcBN(<*X=onXBYYvgeQ;5M9K}#a)NTS_R_W}0%@U~a0?c3(GZb7{x+RalG)cPa0 zNeUJqzXSqX+40@4p}Pj)V-&-S_{Ik~SpY4*`2nzQU8~*a8jXlgUrnMb8UP%^ceogG z|0>lW$E%5)Zb*EU6WRY0%9&B*CljZm{I6A|jxWTuofrWiH%9>c$W)8`WHVE3eAA#z z^_kIU@BlgL?2dq-hJ`ulOgQ{%6XRrpE6AwM$cl`o{)2WsY#)LPka&QU~##NwhAH6O@{i zSIm%e&Iz=t6X;7hq$^E){kFLqgjs>p8iH_c-VrTWE-uZqL>#IWT+(!~?6|Pf&SrLP zb_JNGit87yand0OywK?!nAQ2Bop&W#wpnjr9h54)Uqg3i0ELCvo9vbzOPxRzj+;Q} Fe*xbg4I}^n literal 0 HcmV?d00001 diff --git a/app/api/service/__pycache__/planService.cpython-38.pyc b/app/api/service/__pycache__/planService.cpython-38.pyc index fa2a99c871f6e9102646f977b351da6de104d2fd..dd4fdcd70546815c38278e4e7d129f95c2c4fad7 100644 GIT binary patch literal 2374 zcmbVO-D@316rb78`;{imS6hp*3JNO{`~yUamHK255?_|Uve}s=+w9%Fo!PA>Y(Sd` zT73{f5EYWer#@KggCeMJ{uTRLV-qO&?t^&F?A_eWB|hjRGkeZ_ocYb~oHKLZo|xzm zD6^mb9X@Cg@(7h?F+jNvRlW=jBaB9*PhCn;)*`L1yL#Vn4NBf1OlQVEVFow%bk}0$ zGI6XE4VF2ywBL&S_;o*Vw6cZb4PWxIwZdhJS^$&I6ce=TP~{zHIB_W>uEwaVbAxG2 z-zR(2H9~Kcbgwwx`}OGFSC8Ji8|YxBE-2$kZ$On7ph?Lg%}Gvktw z9gGgLIM^08Olm`wXq;#}(~W~JR$KiSpuz$xHPqgkp9Y&=4Ol=>5rLbzlhp_U^`cOw z3TxGLaZ(SO38*!y%S}{Gt1AJrRu`1Ptp4SKx6A{v~7T4S1PvUqCigu;}QS!kAS2FPAr>d;W&7NB=A0WTa769yRCu0!M!-^yKN=OaEH6SL@+MA zAc?Ym?CQP@#t%3Hpsqlz4|uhCz%c~>KZ+}d4~f@DN2rJ&;~e>Zx&i4-9FJ1YDZn|{$p;a??Yz98q;>Icowo<+alsO zW`btns$DBr-z{AngWYarmqBkx3wX28o?|baz>$ei8KHx8f=&^b*QJG=XVU4p=IxA& zZGq#ZRYkG5g!3?mN;62ZI2Bhxb|L6Jl@9__Q7B`0QPhcmt6VcpQa^I1vY5ZigDm9? zLLN-Gy>&l~PBjBP=5}KKj`Dsc$J|}c2)-`3-1JUeo5|;41Bt0iv<2Ft27Q6PL@&Uf z0opWFOS5Q)?mW{BjdSccbOrwnMTNkdb?K%vQMNoU_WRuP%8uv3Ba}rb_dM@*=10R3 z+w)iwU^fyN27R7xCQQ9M>Y5iwNW~APv%mxr7`g&+Hx8*1O}!3y&Eu^ppMwTYrD?RI zSG5PbN? delta 828 zcmZuvKX21e5ciAi*lFU>f0UpGXw%6RFtAjD3Q>e$s8kiTFoZ~!<9lu$oH%-RhIDPe zK=eii#0EP9pMZ}*rMI%c%mxG8IaQNJJlUsr|9-!_d-rwmyHkDTICC1F_e;N`zs9Iq zf4PPlXux8v%bJX7FEl8Cex`xW3#WQh2jf6<&7TDfbD6UCW54&zPXbi0922!C_@|xG zhs|5p!2RL;g0q7xgkOLOCchYmi0+uH8<*(gf@Zo} z#prt8RbXym#yj(7gEExW#~r}|&BE8wxhB7vjTM(2(r1W8G>Ih?d(Yj9d|+?y(BtMW zB4}!Y8WvQSAUZ)1Q?Qf4=;)x-^tSwLukQ=e_gte{20yt^_2ABq_k>=L2U#BRKPY8y QcM}-I{4uRnN}{6w0|^_lMF0Q* diff --git a/app/api/service/__pycache__/projectHookService.cpython-38.pyc b/app/api/service/__pycache__/projectHookService.cpython-38.pyc index 59f3fd8e1d936ddb5be3d2551e2eff4ec90dd184..df5c85efc07bdd0913a5ff3e16dcdf4a9d6ad4a9 100644 GIT binary patch delta 21 bcmbQmH;a!al$V!_0SMUi8M8L>@Ua5`DC`4B delta 21 bcmbQmH;a!al$V!_0SGdD|733D;bR8?GOGl_ diff --git a/app/api/service/__pycache__/rbacService.cpython-38.pyc b/app/api/service/__pycache__/rbacService.cpython-38.pyc index 0b539cddf1de8a34167d2b465d3689639e1e2293..3a8ee649fbb1a24bf081948ded8b127a2256930f 100644 GIT binary patch delta 631 zcmZ`$PcH*e5Z|}jZEgFuC26YlU)xlvBK};61VMz8#-*1{yKf^a*>3Z;B$bE*2VCb1 zxVwD`zB@#fN$L8>omz;688|8~rA|vRBCi3l=~L&K@Op%(`xy)jhpt za@%rU%c<&y!%R0P^G+ch{zE^8+yc5QBS`W=W~)**iYDK;472d>M8oSA3N#V>BxnTE zN8Lj4<9Um@dZqDui*{^py{~}W68hH`+14%onQ8b?^kosLc@exNWCJQ{T^UE3Pr{?x zN4DTybx9taP?Ai*c_^c#L~$5yLRpf9r%)n3heRo8)lFNs%e4nTcbvM*r+|hFWEJMa zcL`6@@>`ZsWkZ~ 0: + update_info['status'] = AutomationService.STATUS_RUNNING + elif total > 0 and finished_count == total: + if summary.get(3, 0) + summary.get(4, 0) + summary.get(6, 0) > 0: + update_info['status'] = AutomationService.STATUS_FAILED + else: + update_info['status'] = AutomationService.STATUS_SUCCESS + if force_finish or (total > 0 and finished_count == total): + end_time = execution.end_time or datetime.now() + update_info['end_time'] = end_time + if execution.start_time: + update_info['duration_seconds'] = AutomationService.calc_duration_seconds(execution.start_time, end_time) + AutomationDao.update_execution_by_id(session, execution_id, update_info) + + @staticmethod + def refresh_plan_status(session, plan_id): + PlanService.refresh_plan_status(session, plan_id) + + @staticmethod + def calc_duration_seconds(start_time, end_time): + if not start_time or not end_time: + return None + if isinstance(start_time, str): + start_time = datetime.strptime(start_time, '%Y-%m-%d %H:%M:%S') + if isinstance(end_time, str): + end_time = datetime.strptime(end_time, '%Y-%m-%d %H:%M:%S') + return int((end_time - start_time).total_seconds()) diff --git a/app/api/service/caseService.py b/app/api/service/caseService.py index 724d026..e0c69e2 100644 --- a/app/api/service/caseService.py +++ b/app/api/service/caseService.py @@ -26,8 +26,8 @@ class CaseService(object): return CaseDao.delete_by_id(session, model_cls, obj_id) @staticmethod - def next_case_key(session, project_id): - return CaseDao.next_case_key(session, project_id) + def next_case_key(session, project_id, module_id=None, product_id=None): + return CaseDao.next_case_key(session, project_id, module_id, product_id) @staticmethod def next_snapshot_version(session, case_id): diff --git a/app/api/service/jenkinsPollService.py b/app/api/service/jenkinsPollService.py new file mode 100644 index 0000000..f59f683 --- /dev/null +++ b/app/api/service/jenkinsPollService.py @@ -0,0 +1,302 @@ +# encoding: UTF-8 +import json +import time +from datetime import datetime + +import requests +from requests.auth import HTTPBasicAuth + +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + +from const import JENKINS_BASE_URL, JENKINS_USER, JENKINS_TOKEN +from logger import logger +from app.api.model.automationModel import AutoExecution, AutoExecutionCase + + +class JenkinsPollService(object): + STATUS_QUEUED = 2 + STATUS_RUNNING = 3 + STATUS_SUCCESS = 4 + STATUS_FAILED = 5 + + @staticmethod + def poll_jenkins_build_status(session, execution_id): + execution = session.query(AutoExecution).filter(AutoExecution.id == int(execution_id)).first() + if not execution: + logger.error(f'执行记录不存在: execution_id={execution_id}') + return False, '执行记录不存在' + + if execution.status not in [JenkinsPollService.STATUS_QUEUED, JenkinsPollService.STATUS_RUNNING]: + logger.info(f'执行状态不需要轮询: execution_id={execution_id}, status={execution.status}') + return True, '' + + base_url = JENKINS_BASE_URL.rstrip('/') + job_name = execution.jenkins_job_name + build_number = execution.jenkins_build_number + + if not job_name: + if execution.jenkins_build_url: + import re + match = re.search(r'/job/([^/]+(?:/job/[^/]+)*)/\d+/', execution.jenkins_build_url) + if match: + job_name = match.group(1).replace('/job/', '/') + logger.info(f'从构建URL中提取job_name: {job_name}') + else: + logger.error(f'无法从构建URL中提取job_name: {execution.jenkins_build_url}') + return False, 'Jenkins job 名称为空' + else: + logger.error(f'Jenkins job 名称为空: execution_id={execution_id}') + return False, 'Jenkins job 名称为空' + + auth = HTTPBasicAuth(JENKINS_USER, JENKINS_TOKEN) if JENKINS_USER and JENKINS_TOKEN else None + + try: + if not build_number: + if execution.jenkins_build_url: + import re + match = re.search(r'/job/([^/]+(?:/job/[^/]+)*)/(\d+)/', execution.jenkins_build_url) + if match: + job_name = match.group(1).replace('/job/', '/') + build_number = match.group(2) + logger.info(f'从构建URL中提取: job_name={job_name}, build_number={build_number}') + else: + logger.error(f'无法从构建URL中提取信息: {execution.jenkins_build_url}') + + queue_id = execution.jenkins_queue_id + if queue_id: + queue_url = f'{base_url}/queue/item/{queue_id}/api/json' + response = requests.get(queue_url, auth=auth, timeout=30) + if response.status_code == 200: + queue_data = response.json() + logger.debug(f'队列数据: execution_id={execution_id}, queue_data={json.dumps(queue_data, ensure_ascii=False)[:500]}') + + if queue_data.get('executable'): + build_number = queue_data['executable'].get('number') + logger.info(f'队列任务已开始执行: execution_id={execution_id}, build_number={build_number}') + session.query(AutoExecution).filter(AutoExecution.id == int(execution_id)).update({ + 'jenkins_build_number': build_number, + 'status': JenkinsPollService.STATUS_RUNNING, + 'start_time': datetime.now() + }) + session.done(close=False) + elif queue_data.get('cancelled') or queue_data.get('blocked'): + logger.error(f'队列任务已取消或阻塞: execution_id={execution_id}, cancelled={queue_data.get("cancelled")}, blocked={queue_data.get("blocked")}') + end_time = datetime.now() + session.query(AutoExecution).filter(AutoExecution.id == int(execution_id)).update({ + 'status': JenkinsPollService.STATUS_FAILED, + 'end_time': end_time, + 'trigger_message': queue_data.get('why', '队列任务已取消或阻塞') + }) + session.done(close=False) + JenkinsPollService.refresh_execution_summary(session, execution_id, force_finish=True) + if execution.plan_id: + JenkinsPollService.refresh_plan_status(session, execution.plan_id) + return True, '队列任务已取消或阻塞' + elif queue_data.get('why'): + logger.info(f'队列任务等待中: execution_id={execution_id}, reason={queue_data.get("why")}') + return True, f'队列等待中: {queue_data.get("why")}' + else: + logger.info(f'队列任务等待中: execution_id={execution_id}, queue_id={queue_id}') + return True, '队列等待中' + else: + logger.warning(f'获取队列状态失败: execution_id={execution_id}, status_code={response.status_code}') + + if response.status_code == 404: + logger.info(f'队列项已不存在,尝试查询执行状态: execution_id={execution_id}') + builds_url = f'{base_url}/job/{job_name}/builds/api/json?limit=10' + try: + builds_response = requests.get(builds_url, auth=auth, timeout=30) + logger.info(f'构建历史查询: url={builds_url}, status_code={builds_response.status_code}') + + if builds_response.status_code == 200: + builds_data = builds_response.json() + logger.info(f'构建历史数据: count={len(builds_data) if builds_data else 0}') + + if builds_data: + latest_build = builds_data[0] + build_number = latest_build.get('number') + is_building = latest_build.get('building', False) + result = latest_build.get('result') + timestamp = latest_build.get('timestamp', 0) + + logger.info(f'最新构建信息: build_number={build_number}, is_building={is_building}, result={result}') + + if is_building: + status = JenkinsPollService.STATUS_RUNNING + elif result == 'SUCCESS': + status = JenkinsPollService.STATUS_SUCCESS + else: + status = JenkinsPollService.STATUS_FAILED + + logger.info(f'更新执行状态: execution_id={execution_id}, build_number={build_number}, status={status}') + update_info = { + 'jenkins_build_number': build_number, + 'status': status, + 'start_time': datetime.fromtimestamp(timestamp/1000) if timestamp else datetime.now() + } + + if not is_building and result: + update_info['end_time'] = datetime.now() + update_info['jenkins_build_url'] = f'{base_url}/job/{job_name}/{build_number}/' + update_info['console_url'] = f'{base_url}/job/{job_name}/{build_number}/console' + update_info['report_url'] = f'{base_url}/job/{job_name}/{build_number}/allure/' + + session.query(AutoExecution).filter(AutoExecution.id == int(execution_id)).update(update_info) + session.done(close=False) + + if not is_building: + JenkinsPollService.refresh_execution_summary(session, execution_id, force_finish=True) + if execution.plan_id: + JenkinsPollService.refresh_plan_status(session, execution.plan_id) + + return True, f'队列不存在,使用最新构建: {build_number}' + else: + logger.error(f'获取构建历史失败: status_code={builds_response.status_code}, body={builds_response.text[:200]}') + except Exception as err: + logger.error(f'查询构建历史异常: {err}') + + return True, '获取队列状态失败' + else: + logger.warning(f'缺少 queue_id 和 build_number: execution_id={execution_id}') + return False, '无法轮询,缺少构建信息' + + if build_number: + build_url = f'{base_url}/job/{job_name}/{build_number}/api/json' + response = requests.get(build_url, auth=auth, timeout=30) + if response.status_code == 200: + build_data = response.json() + is_running = build_data.get('building', False) + result = build_data.get('result') + + console_url = f'{base_url}/job/{job_name}/{build_number}/console' + build_url_full = f'{base_url}/job/{job_name}/{build_number}/' + + if is_running: + logger.info(f'构建执行中: execution_id={execution_id}, build_number={build_number}') + session.query(AutoExecution).filter(AutoExecution.id == int(execution_id)).update({ + 'status': JenkinsPollService.STATUS_RUNNING, + 'jenkins_build_url': build_url_full, + 'console_url': console_url + }) + session.done(close=False) + return True, '执行中' + else: + logger.info(f'构建完成: execution_id={execution_id}, result={result}') + end_time = datetime.now() + report_url = f'{base_url}/job/{job_name}/{build_number}/allure/' + update_info = { + 'status': JenkinsPollService.STATUS_SUCCESS if result == 'SUCCESS' else JenkinsPollService.STATUS_FAILED, + 'jenkins_build_url': build_url_full, + 'console_url': console_url, + 'report_url': report_url, + 'end_time': end_time + } + if execution.start_time: + update_info['duration_seconds'] = int((end_time - execution.start_time).total_seconds()) + session.query(AutoExecution).filter(AutoExecution.id == int(execution_id)).update(update_info) + session.done(close=False) + + JenkinsPollService.refresh_execution_summary(session, execution_id, force_finish=True) + if execution.plan_id: + JenkinsPollService.refresh_plan_status(session, execution.plan_id) + + return True, f'构建完成: {result}' + + except Exception as err: + logger.error(f'轮询 Jenkins 状态失败: execution_id={execution_id}, error={err}') + return False, str(err) + + return True, '' + + @staticmethod + def refresh_execution_summary(session, execution_id, force_finish=False): + from sqlalchemy import func + + rows = session.query(AutoExecutionCase.status, func.count(AutoExecutionCase.id)).filter( + AutoExecutionCase.execution_id == int(execution_id) + ).group_by(AutoExecutionCase.status).all() + + summary = {0: 0, 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0} + for status, count in rows: + summary[int(status)] = int(count) + total = sum(summary.values()) + + execution = session.query(AutoExecution).filter(AutoExecution.id == int(execution_id)).first() + if execution: + update_info = { + 'total_count': total, + 'pending_count': summary.get(0, 0), + 'running_count': summary.get(1, 0), + 'passed_count': summary.get(2, 0), + 'failed_count': summary.get(3, 0), + 'blocked_count': summary.get(4, 0), + 'skipped_count': summary.get(5, 0), + 'not_found_count': summary.get(6, 0) + } + + running_count = summary.get(1, 0) + finished_count = summary.get(2, 0) + summary.get(3, 0) + summary.get(4, 0) + summary.get(5, 0) + summary.get(6, 0) + summary.get(7, 0) + + if running_count > 0: + update_info['status'] = JenkinsPollService.STATUS_RUNNING + elif total > 0 and finished_count == total: + if summary.get(3, 0) + summary.get(4, 0) + summary.get(6, 0) > 0: + update_info['status'] = JenkinsPollService.STATUS_FAILED + else: + update_info['status'] = JenkinsPollService.STATUS_SUCCESS + + if force_finish or (total > 0 and finished_count == total): + end_time = execution.end_time or datetime.now() + update_info['end_time'] = end_time + if execution.start_time: + update_info['duration_seconds'] = int((end_time - execution.start_time).total_seconds()) + + session.query(AutoExecution).filter(AutoExecution.id == int(execution_id)).update(update_info) + session.done(close=False) + + @staticmethod + def refresh_plan_status(session, plan_id): + from sqlalchemy import func + + rows = session.query( + AutoExecution.status, func.count(AutoExecution.id) + ).filter( + AutoExecution.plan_id == int(plan_id), + AutoExecution.status.in_([JenkinsPollService.STATUS_RUNNING, JenkinsPollService.STATUS_SUCCESS, JenkinsPollService.STATUS_FAILED]) + ).group_by(AutoExecution.status).all() + + status_counts = {} + for status, count in rows: + status_counts[status] = count + + running_count = status_counts.get(JenkinsPollService.STATUS_RUNNING, 0) + success_count = status_counts.get(JenkinsPollService.STATUS_SUCCESS, 0) + failed_count = status_counts.get(JenkinsPollService.STATUS_FAILED, 0) + + from app.api.model.planModel import TestPlan + + if running_count > 0: + session.query(TestPlan).filter(TestPlan.id == int(plan_id)).update({'status': 1}) + elif success_count > 0 and failed_count == 0: + session.query(TestPlan).filter(TestPlan.id == int(plan_id)).update({'status': 4}) + elif success_count + failed_count > 0: + session.query(TestPlan).filter(TestPlan.id == int(plan_id)).update({'status': 2}) + + session.done(close=False) + + @staticmethod + def poll_all_pending_executions(session): + pending_executions = session.query(AutoExecution).filter( + AutoExecution.status.in_([JenkinsPollService.STATUS_QUEUED, JenkinsPollService.STATUS_RUNNING]) + ).all() + + for execution in pending_executions: + try: + success, msg = JenkinsPollService.poll_jenkins_build_status(session, execution.id) + logger.info(f'轮询执行 {execution.id}: success={success}, msg={msg}') + except Exception as err: + logger.error(f'轮询执行 {execution.id} 异常: {err}') + + session.done(close=False) \ No newline at end of file diff --git a/app/api/service/planService.py b/app/api/service/planService.py index 845a837..ecd055a 100644 --- a/app/api/service/planService.py +++ b/app/api/service/planService.py @@ -1,5 +1,6 @@ # encoding: UTF-8 from ..dao.planDao import PlanDao +from ..model.planModel import PlanCase, TestPlan class PlanService(object): @@ -32,3 +33,22 @@ class PlanService(object): @staticmethod def plan_stats(session, plan_id): return PlanDao.plan_stats(session, plan_id) + + @staticmethod + def refresh_plan_status(session, plan_id): + total = session.query(PlanCase).filter(PlanCase.plan_id == int(plan_id)).count() + if total == 0: + return + unexecuted_count = session.query(PlanCase).filter(PlanCase.plan_id == int(plan_id), PlanCase.status == 0).count() + failed_count = session.query(PlanCase).filter(PlanCase.plan_id == int(plan_id), PlanCase.status.in_([2, 3])).count() + plan = PlanDao.get_by_id(session, TestPlan, plan_id) + if not plan or plan.status == 3: + return + if unexecuted_count == 0: + new_status = 4 if failed_count == 0 else 2 + elif unexecuted_count < total: + new_status = 1 + else: + new_status = plan.status + if new_status != plan.status: + PlanDao.update_by_id(session, TestPlan, plan_id, {'status': new_status}) diff --git a/app/api/service/rbacService.py b/app/api/service/rbacService.py index 0a8abf7..ba03bce 100644 --- a/app/api/service/rbacService.py +++ b/app/api/service/rbacService.py @@ -59,6 +59,14 @@ class RbacService(object): def get_role_menu_ids(session, role_id): return RbacDao.get_role_menu_ids(session, role_id) + @staticmethod + def get_menu_permission_codes(session, menu_ids): + return RbacDao.get_menu_permission_codes(session, menu_ids) + + @staticmethod + def get_permission_ids_by_codes(session, permission_codes): + return RbacDao.get_permission_ids_by_codes(session, permission_codes) + @staticmethod def build_menu_tree(session, filters, role_ids=None, menu_ids=None): items = RbacDao.get_menu_tree_items(session, filters) diff --git a/app/api/utils/__pycache__/authMiddleware.cpython-38.pyc b/app/api/utils/__pycache__/authMiddleware.cpython-38.pyc index 464478e757da57bbdb3d2a0983cadf6fa0324514..ecb4384aeb91fbf988c2103a20d68abdb629bafa 100644 GIT binary patch delta 2196 zcmah~|8G-O6o2=<*RNaKts88>zO?(=u51OwF$P0rOe9Dd>x|e8YUz0!Z|gR=?{(_Z zr4mTw2O*NH@(YQZzbNrb3?>>3#sm`o01|&$jEON!x4Fa?^R`GS%PLkPlXTc8D6(Y8Vxw4-f< z4(LSN4iSi=?SL+bq3wjN(2X_%JrGA5g>A6?!fqwi1v}sgbi`mMJc)KI^ujK*-OvZS z(e^+;>_Hocy)b}w8?dAj-6s<4!_D&nV|?70virF)&JF8yzZq&!6!Z0dkNA`I7Uyf@ zrXA0mG2@xMeL7xFn(tWSot@6u=Il(|c-6?}(dRL42$h1Y0R6b;<&>*`iutOKd?csJzM63hH?*5Kxjz>AX5`#B4mzOSro> zVc5%>Ib$Q`W^zl8M1BoX$#dgeCTGHbnD%0-B^wNRdYO*MTg6GOu}DG`6dtsdMP{qS zZHZyGtn$#Lq{~h)a)xb4Qc+eS@vlZDiMTO`06A8o87D7YzaN4riQy@(zPOMUj@ zpu__h@mOF5c0f7NwxFUH6+(|z&}_Y+oM%(2{)I=I&T>~ZNkH*SxVohxD2&I%ciI?>i5>bNi;6itw&5s6|GAJVRz>Rch%fX+fJ?GLjB~0NQRCQF z^CD%JRP-}WWtY8DT`MLXMHUth9NoS#N+@sEE83h1^jprcz{~4?*e`x?zR7lpDOU^Y z6PI0~iai+TBs(7vH(i~~5I?%=*naVc>%69eLloVeVW+7qskSriDL5A0;F>7SymIEI znK6Esv{X#xw^RW&c&6B@80|J#{F;JSh@Bdrt-BM{oXX|6*J!Y2#K)l$mVA8#`@_1 zPI3gYdCrX)d-Wp2;#2Pt)+7G%0v;<8+F3>NjN#!E8Qh{BjH@!Q-57BZ| zAp8Kq5CK(%Ie1On@b~o|Bu5GXyZQ)?69kkIRk43_f8;_bk`y)t}ab&avr#F{e(pEfNE|3a{y&*n|6{f{tOuCYolKPmiG zlfIkeE0r7Ww_R#lty%IZBbGz{Cc02IVrPbxQ%U$dCyY;^TpDhG% delta 1098 zcmY*XUuYC(5dXg2z02O8y}ji2($n0Z6B%K1wEg>U8%oyg!rAE|33yfAJBg;yHr}f3>`IXvdc53(_RD;;9S)v zGhXIqc3DUPN$6Y@lR01Rllr_&R5ORsrN)j@houg^Ad zCNyks<v-aqQtYLgV@|7&(qexy3 z;cBEivW;~0F2ji5mm769?ctmlfuNp(Qj^-hhN1nVMyYlRr=u5i2?joo_GJQfv8*+N z)zCuVrU}8?niuxHHcVRwbrV7e)@3~m5nQo~<>h(*X(05j}tb?QM_aC-tfJD+gUk|sraS%A+GQt1NWuI@rQV-{UDih zTvpH!Vy-%ZkK-@o2;OQRlMQ^;UXsT#+3~QfK@g0qT18wD3t~ZjEi0nfHlg-$rJW4B z7PB^U~cr#wp z^S4ixxki>Z!;Q`~YK{43)xV?boVPX`gV*s4`NfhN!i&kLNk1vcnLP~K8BSrXGdq%F zGta>Hrv@4P+v7D=M;Lf4>LkNyhBNr2vsdoLKReyDsl}wJn{B3Hnr477Ks;bNX3RAH E1D|g3b^rhX diff --git a/app/api/utils/authMiddleware.py b/app/api/utils/authMiddleware.py index 35a7ce4..cef89cb 100644 --- a/app/api/utils/authMiddleware.py +++ b/app/api/utils/authMiddleware.py @@ -16,10 +16,22 @@ from common.sqlSession import SqlSession TOKEN_PREFIX = 'effekt:token:' TOKEN_CONTEXT_PREFIX = 'effekt:token:ctx:' +REFRESH_TOKEN_PREFIX = 'effekt:refresh:' TOKEN_EXPIRE_SECONDS = 7200 +REFRESH_TOKEN_EXPIRE_SECONDS = 86400 * 7 TOKEN_REFRESH_THRESHOLD_SECONDS = 1800 TOKEN_CONTEXT_EXPIRE_SECONDS = 300 -WHITELIST_PATHS = ['/it/api/auth/login', '/it/api/auth/register'] +WHITELIST_PATHS = [ + '/it/api/auth/login', + '/it/api/auth/register', + '/it/api/auth/refresh', + '/it/api/automation/execution/case/pull', + '/it/api/automation/execution/queued', + '/it/api/automation/execution/start', + '/it/api/automation/execution/case/result', + '/it/api/automation/execution/finish', + '/it/api/automation/execution/abort' +] _redis_client = redis.from_url(REDIS_URL, decode_responses=True) _redis_client.ping() @@ -32,6 +44,34 @@ def create_token(user_id): return token, TOKEN_EXPIRE_SECONDS +def create_refresh_token(user_id): + refresh_token = uuid.uuid4().hex + key = REFRESH_TOKEN_PREFIX + refresh_token + _redis_client.setex(key, REFRESH_TOKEN_EXPIRE_SECONDS, str(user_id)) + return refresh_token, REFRESH_TOKEN_EXPIRE_SECONDS + + +def validate_refresh_token(refresh_token): + key = REFRESH_TOKEN_PREFIX + refresh_token + user_id = _redis_client.get(key) + if user_id: + return int(user_id) + return None + + +def revoke_refresh_token(refresh_token): + if refresh_token: + _redis_client.delete(REFRESH_TOKEN_PREFIX + refresh_token) + + +def revoke_all_refresh_tokens(user_id): + keys = _redis_client.keys(REFRESH_TOKEN_PREFIX + '*') + for key in keys: + stored_user_id = _redis_client.get(key) + if stored_user_id == str(user_id): + _redis_client.delete(key) + + def get_token_ttl(token): return _redis_client.ttl(TOKEN_PREFIX + token) @@ -71,10 +111,10 @@ def login_required(func): def wrapper(*args, **kwargs): token = parse_token() if not token: - return ApiResponse.build_failure(40004, msg='未登录或缺少token!') + return ApiResponse.build_failure(40001, msg='缺少token!') user_id = get_current_user_id(token) if not user_id: - return ApiResponse.build_failure(40004, msg='token无效或已过期!') + return ApiResponse.build_failure(451, msg='token无效或已过期!') session = None try: token_context = get_token_context(token) @@ -133,10 +173,10 @@ def permission_required(permission_code): @wraps(func) def wrapper(*args, **kwargs): if not getattr(g, 'current_user_id', None): - return ApiResponse.build_failure(40004, msg='未登录或缺少token!') + return ApiResponse.build_failure(40001, msg='缺少token!') current_permission_codes = getattr(g, 'current_permission_codes', []) if not has_permission(permission_code, current_permission_codes): - return ApiResponse.build_failure(40004, msg='无权限访问该接口!') + return ApiResponse.build_failure(40003, msg='无权限访问该接口!') return func(*args, **kwargs) return wrapper return decorator diff --git a/app/api/views.py b/app/api/views.py index c0b73b0..3cf7147 100644 --- a/app/api/views.py +++ b/app/api/views.py @@ -17,6 +17,7 @@ from .controller.rbacController import RbacController from .controller.userController import UserController from .controller.bugController import BugController, BugUploadController from .controller.projectHookController import ProjectHookController +from .controller.automationController import AutomationController api = Blueprint('api', __name__) @@ -27,7 +28,7 @@ def api_before_request(): return None token = request.headers.get('accessToken') or request.headers.get('accesstoken') or request.headers.get('Authorization') if not token: - return ApiResponse.build_failure(40004, msg='未登录或缺少token!') + return ApiResponse.build_failure(40001, msg='缺少token!') return None @@ -728,6 +729,199 @@ def plan_progress(): return ApiResponse.build_success(20000, data=ret) +@api.route('/automation/case/run', methods=['POST']) +@login_required +@permission_required('automation:run') +def automation_case_run(): + controller = AutomationController(request.get_json() or {}) + try: + ret, err_msg = controller.case_run() + if err_msg: + return ApiResponse.build_failure(40009, msg=err_msg) + return ApiResponse.build_success(20000, data=ret) + finally: + controller.close_session() + + +@api.route('/automation/plan/run', methods=['POST']) +@login_required +@permission_required('automation:run') +def automation_plan_run(): + controller = AutomationController(request.get_json() or {}) + try: + ret, err_msg = controller.plan_run() + if err_msg: + return ApiResponse.build_failure(40009, msg=err_msg) + return ApiResponse.build_success(20000, data=ret) + finally: + controller.close_session() + + +@api.route('/automation/execution/list', methods=['GET']) +@login_required +@permission_required('automation:list') +def automation_execution_list(): + controller = AutomationController(request.args) + try: + return ApiResponse.build_success(20000, data=controller.execution_list()) + finally: + controller.close_session() + + +@api.route('/automation/execution/detail', methods=['GET']) +@login_required +@permission_required('automation:detail') +def automation_execution_detail(): + controller = AutomationController(request.args) + try: + ret, err_msg = controller.execution_detail() + if err_msg: + return ApiResponse.build_failure(40011, msg=err_msg) + return ApiResponse.build_success(20000, data=ret) + finally: + controller.close_session() + + +@api.route('/automation/execution/case/list', methods=['GET']) +@login_required +@permission_required('automation:detail') +def automation_execution_case_list(): + controller = AutomationController(request.args) + try: + ret, err_msg = controller.execution_case_list() + if err_msg: + return ApiResponse.build_failure(40011, msg=err_msg) + return ApiResponse.build_success(20000, data=ret) + finally: + controller.close_session() + + +@api.route('/automation/execution/poll', methods=['POST']) +@login_required +@permission_required('automation:detail') +def automation_execution_poll(): + from ..api.service.jenkinsPollService import JenkinsPollService + from ..api.dao.automationDao import AutomationDao + + req_data = request.get_json() or {} + execution_id = req_data.get('executionId') or req_data.get('execution_id') + + from ..api.controller.baseCrudController import BaseCrudController + controller = BaseCrudController(req_data) + + try: + if execution_id: + success, msg = JenkinsPollService.poll_jenkins_build_status(controller.session, execution_id) + if not success: + return ApiResponse.build_failure(40012, msg=msg) + execution = AutomationDao.get_execution_by_id(controller.session, execution_id) + return ApiResponse.build_success(20000, data=execution.to_dict() if execution else {'id': execution_id, 'message': msg}) + else: + JenkinsPollService.poll_all_pending_executions(controller.session) + return ApiResponse.build_success(20000, data={'message': '轮询完成'}) + finally: + controller.close_session() + + +@api.route('/automation/execution/case/pull', methods=['GET']) +def automation_execution_case_pull(): + req_data = dict(request.args) + req_data['_callback_token'] = request.headers.get('X-CALLBACK-TOKEN', '') + controller = AutomationController(req_data) + try: + ret, err_msg = controller.execution_case_pull() + if err_msg: + return ApiResponse.build_failure(40011, msg=err_msg) + return ApiResponse.build_success(20000, data=ret) + finally: + controller.close_session() + + +@api.route('/automation/execution/queued', methods=['POST']) +def automation_execution_queued(): + req_data = request.get_json() or {} + req_data['_callback_secret'] = request.headers.get('X-CALLBACK-SECRET', '') + controller = AutomationController(req_data) + try: + ok, err_msg = controller.validate_callback_secret() + if not ok: + return ApiResponse.build_failure(40004, msg=err_msg) + update_id, err_msg = controller.execution_queued() + if err_msg: + return ApiResponse.build_failure(40012, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': update_id}) + finally: + controller.close_session() + + +@api.route('/automation/execution/start', methods=['POST']) +def automation_execution_start(): + req_data = request.get_json() or {} + req_data['_callback_secret'] = request.headers.get('X-CALLBACK-SECRET', '') + controller = AutomationController(req_data) + try: + ok, err_msg = controller.validate_callback_secret() + if not ok: + return ApiResponse.build_failure(40004, msg=err_msg) + update_id, err_msg = controller.execution_start() + if err_msg: + return ApiResponse.build_failure(40012, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': update_id}) + finally: + controller.close_session() + + +@api.route('/automation/execution/case/result', methods=['POST']) +def automation_execution_case_result(): + req_data = request.get_json() or {} + req_data['_callback_secret'] = request.headers.get('X-CALLBACK-SECRET', '') + controller = AutomationController(req_data) + try: + ok, err_msg = controller.validate_callback_secret() + if not ok: + return ApiResponse.build_failure(40004, msg=err_msg) + update_id, err_msg = controller.execution_case_result() + if err_msg: + return ApiResponse.build_failure(40012, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': update_id}) + finally: + controller.close_session() + + +@api.route('/automation/execution/finish', methods=['POST']) +def automation_execution_finish(): + req_data = request.get_json() or {} + req_data['_callback_secret'] = request.headers.get('X-CALLBACK-SECRET', '') + controller = AutomationController(req_data) + try: + ok, err_msg = controller.validate_callback_secret() + if not ok: + return ApiResponse.build_failure(40004, msg=err_msg) + update_id, err_msg = controller.execution_finish() + if err_msg: + return ApiResponse.build_failure(40012, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': update_id}) + finally: + controller.close_session() + + +@api.route('/automation/execution/abort', methods=['POST']) +def automation_execution_abort(): + req_data = request.get_json() or {} + req_data['_callback_secret'] = request.headers.get('X-CALLBACK-SECRET', '') + controller = AutomationController(req_data) + try: + ok, err_msg = controller.validate_callback_secret() + if not ok: + return ApiResponse.build_failure(40004, msg=err_msg) + update_id, err_msg = controller.execution_abort() + if err_msg: + return ApiResponse.build_failure(40012, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': update_id}) + finally: + controller.close_session() + + # ========================= # 报告接口 # ========================= @@ -1250,6 +1444,43 @@ def auth_login(): controller.close_session() +@api.route('/auth/refresh', methods=['POST']) +def auth_refresh(): + from .utils.authMiddleware import validate_refresh_token, create_token, create_refresh_token, revoke_refresh_token, get_current_user_id + + req_json = request.get_json() or {} + refresh_token = req_json.get('refreshToken') or req_json.get('refresh_token') + access_token = req_json.get('accessToken') or req_json.get('access_token') + + if refresh_token: + user_id = validate_refresh_token(refresh_token) + if user_id: + revoke_refresh_token(refresh_token) + new_token, expire_seconds = create_token(user_id) + new_refresh_token, refresh_expire_seconds = create_refresh_token(user_id) + return ApiResponse.build_success(20000, data={ + 'token': new_token, + 'token_type': 'Bearer', + 'expires_in': expire_seconds, + 'refresh_token': new_refresh_token, + 'refresh_expires_in': refresh_expire_seconds + }) + return ApiResponse.build_failure(40001, msg='refresh_token无效或已过期') + + elif access_token: + user_id = get_current_user_id(access_token) + if user_id: + new_token, expire_seconds = create_token(user_id) + return ApiResponse.build_success(20000, data={ + 'token': new_token, + 'token_type': 'Bearer', + 'expires_in': expire_seconds + }) + return ApiResponse.build_failure(451, msg='access_token无效或已过期') + + return ApiResponse.build_failure(40003, msg='请提供refresh_token或access_token') + + @api.route('/bug/list', methods=['GET']) @login_required @permission_required('bug:list') diff --git a/automation_menus.sql b/automation_menus.sql new file mode 100644 index 0000000..d883cc5 --- /dev/null +++ b/automation_menus.sql @@ -0,0 +1,59 @@ +-- ============================================== +-- 自动化执行模块 - 权限对应的按钮菜单 +-- ============================================== + +-- 自动化执行 按钮 +INSERT INTO menu (name, code, type, path, component, icon, permission_code, parent_id, sort, visible, status, is_delete) +SELECT '自动化执行', 'automation_run', 3, '', '', '', 'automation:run', + (SELECT id FROM menu WHERE code = 'automation_list'), 1, 1, 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM menu WHERE code = 'automation_run'); + +-- 自动化执行详情 按钮 +INSERT INTO menu (name, code, type, path, component, icon, permission_code, parent_id, sort, visible, status, is_delete) +SELECT '自动化执行详情', 'automation_detail', 3, '', '', '', 'automation:detail', + (SELECT id FROM menu WHERE code = 'automation_list'), 2, 1, 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM menu WHERE code = 'automation_detail'); + +-- 自动化执行明细 按钮 +INSERT INTO menu (name, code, type, path, component, icon, permission_code, parent_id, sort, visible, status, is_delete) +SELECT '自动化执行明细', 'automation_case_list', 3, '', '', '', 'automation:case_list', + (SELECT id FROM menu WHERE code = 'automation_list'), 3, 1, 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM menu WHERE code = 'automation_case_list'); + +-- 用例拉取 按钮 +INSERT INTO menu (name, code, type, path, component, icon, permission_code, parent_id, sort, visible, status, is_delete) +SELECT '用例拉取', 'automation_pull', 3, '', '', '', 'automation:pull', + (SELECT id FROM menu WHERE code = 'automation_list'), 4, 1, 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM menu WHERE code = 'automation_pull'); + +-- 执行排队 按钮 +INSERT INTO menu (name, code, type, path, component, icon, permission_code, parent_id, sort, visible, status, is_delete) +SELECT '执行排队', 'automation_queued', 3, '', '', '', 'automation:queued', + (SELECT id FROM menu WHERE code = 'automation_list'), 5, 1, 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM menu WHERE code = 'automation_queued'); + +-- 执行开始 按钮 +INSERT INTO menu (name, code, type, path, component, icon, permission_code, parent_id, sort, visible, status, is_delete) +SELECT '执行开始', 'automation_start', 3, '', '', '', 'automation:start', + (SELECT id FROM menu WHERE code = 'automation_list'), 6, 1, 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM menu WHERE code = 'automation_start'); + +-- 用例结果 按钮 +INSERT INTO menu (name, code, type, path, component, icon, permission_code, parent_id, sort, visible, status, is_delete) +SELECT '用例结果', 'automation_result', 3, '', '', '', 'automation:result', + (SELECT id FROM menu WHERE code = 'automation_list'), 7, 1, 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM menu WHERE code = 'automation_result'); + +-- 执行完成 按钮 +INSERT INTO menu (name, code, type, path, component, icon, permission_code, parent_id, sort, visible, status, is_delete) +SELECT '执行完成', 'automation_finish', 3, '', '', '', 'automation:finish', + (SELECT id FROM menu WHERE code = 'automation_list'), 8, 1, 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM menu WHERE code = 'automation_finish'); + +-- 取消执行 按钮 +INSERT INTO menu (name, code, type, path, component, icon, permission_code, parent_id, sort, visible, status, is_delete) +SELECT '取消执行', 'automation_abort', 3, '', '', '', 'automation:abort', + (SELECT id FROM menu WHERE code = 'automation_list'), 9, 1, 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM menu WHERE code = 'automation_abort'); + +COMMIT; \ No newline at end of file diff --git a/automation_permission.sql b/automation_permission.sql new file mode 100644 index 0000000..967a434 --- /dev/null +++ b/automation_permission.sql @@ -0,0 +1,96 @@ +-- ============================================== +-- 自动化执行模块 - menu 表插入语句 +-- ============================================== + +-- 自动化执行菜单(一级菜单) +INSERT INTO menu (name, code, type, path, component, icon, permission_code, parent_id, sort, visible, status, is_delete) +SELECT '自动化执行', 'automation', 1, '/automation', 'automation/index', 'auto', 'automation:*', 0, 10, 1, 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM menu WHERE code = 'automation'); + +-- 自动化执行记录列表(二级菜单) +INSERT INTO menu (name, code, type, path, component, icon, permission_code, parent_id, sort, visible, status, is_delete) +SELECT '执行记录', 'automation_list', 2, '/automation/execution', 'automation/execution', 'list', 'automation:list', + (SELECT id FROM menu WHERE code = 'automation'), 1, 1, 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM menu WHERE code = 'automation_list'); + +-- ============================================== +-- 自动化执行模块 - permission 表插入语句 +-- ============================================== + +-- 自动化用例执行权限 +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'automation:run', '自动化执行', 'automation', 'run', '单条/计划自动化用例执行', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'automation:run'); + +-- 自动化执行记录列表权限 +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'automation:list', '自动化执行列表', 'automation', 'list', '查看自动化执行记录列表', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'automation:list'); + +-- 自动化执行详情权限 +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'automation:detail', '自动化执行详情', 'automation', 'detail', '查看自动化执行详情', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'automation:detail'); + +-- 自动化执行明细列表权限 +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'automation:case_list', '自动化执行明细', 'automation', 'case_list', '查看自动化执行明细列表', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'automation:case_list'); + +-- Jenkins回调相关权限(内部接口,无需前端权限) +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'automation:pull', '用例拉取', 'automation', 'pull', 'Jenkins拉取待执行用例', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'automation:pull'); + +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'automation:queued', '执行排队', 'automation', 'queued', 'Jenkins回调排队状态', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'automation:queued'); + +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'automation:start', '执行开始', 'automation', 'start', 'Jenkins回调执行开始', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'automation:start'); + +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'automation:result', '用例结果', 'automation', 'result', 'Jenkins回调用例结果', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'automation:result'); + +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'automation:finish', '执行完成', 'automation', 'finish', 'Jenkins回调执行完成', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'automation:finish'); + +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'automation:abort', '取消执行', 'automation', 'abort', 'Jenkins回调取消执行', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'automation:abort'); + +-- ============================================== +-- 更新 menu 表的 permission_code 关联 +-- ============================================== + +-- 更新自动化执行菜单的 permission_code +UPDATE menu +SET permission_code = 'automation:*' +WHERE code = 'automation' AND permission_code IS NULL; + +-- 更新执行记录菜单的 permission_code +UPDATE menu +SET permission_code = 'automation:list' +WHERE code = 'automation_list' AND permission_code IS NULL; + +COMMIT; + +-- ============================================== +-- 权限清单汇总 +-- ============================================== +-- | 权限代码 | 权限名称 | 对应接口 | +-- |---------------------|--------------|-----------------------------------| +-- | automation:run | 自动化执行 | POST /automation/case/run | +-- | | | POST /automation/plan/run | +-- | automation:list | 自动化执行列表 | GET /automation/execution/list | +-- | automation:detail | 自动化执行详情 | GET /automation/execution/detail | +-- | automation:case_list| 自动化执行明细 | GET /automation/execution/case/list| +-- | automation:pull | 用例拉取 | GET /automation/execution/case/pull| +-- | automation:queued | 执行排队 | POST /automation/execution/queued | +-- | automation:start | 执行开始 | POST /automation/execution/start | +-- | automation:result | 用例结果 | POST /automation/execution/case/result| +-- | automation:finish | 执行完成 | POST /automation/execution/finish | +-- | automation:abort | 取消执行 | POST /automation/execution/abort | \ No newline at end of file diff --git a/bug_api_document.md b/bug_api_document.md deleted file mode 100644 index de189b7..0000000 --- a/bug_api_document.md +++ /dev/null @@ -1,539 +0,0 @@ -# Bug 管理系统接口文档 - -## 一、接口清单 - -| 接口路径 | 方法 | 权限 | 说明 | -| ------------------ | ---- | ------------- | ---------- | -| `/bug/list` | GET | `bug:list` | Bug 列表(分页) | -| `/bug/detail` | GET | `bug:detail` | Bug 详情 | -| `/bug/create` | POST | `bug:create` | 创建 Bug | -| `/bug/update` | POST | `bug:update` | 更新 Bug | -| `/bug/delete` | POST | `bug:delete` | 删除 Bug | -| `/bug/comment/add` | POST | `bug:comment` | 添加评论 | -| `/bug/stats` | GET | `bug:stats` | Bug 统计 | -| `/bug/upload` | POST | `bug:create` | 图片上传 | - -*** - -## 二、接口详细说明 - -### 1. Bug 列表 - -**GET /bug/list** - -查询 Bug 列表,支持多维度筛选。 - -#### 请求参数 - -| 参数名 | 类型 | 必填 | 说明 | -| ------------------------- | ------ | -- | ---------- | -| productId / product\_id | Number | 否 | 产品 ID | -| projectId / project\_id | Number | 否 | 项目 ID | -| moduleId / module\_id | Number | 否 | 模块 ID | -| bugType / bug\_type | Number | 否 | Bug 类型 | -| severity | Number | 否 | 严重程度 | -| priority | Number | 否 | 优先级 | -| status | Number | 否 | 状态 | -| assigneeId / assignee\_id | Number | 否 | 负责人 ID | -| keyword | String | 否 | 关键词(标题/描述) | -| pageNo / page | Number | 否 | 页码,默认 1 | -| pageSize / size | Number | 否 | 每页数量,默认 20 | - -#### 响应示例 - -```json -{ - "code": 20000, - "message": "success", - "data": { - "list": [ - { - "id": 1, - "bug_key": "BUG-001", - "title": "登录页面无法加载", - "description": "点击登录按钮后页面无响应", - "bug_type": 1, - "severity": 1, - "priority": 1, - "status": 2, - "assignee_id": 1, - "reporter_id": 2, - "product_id": 1, - "project_id": 1, - "module_id": 1, - "case_id": 101, - "plan_id": 5, - "environment": "test", - "created_time": "2026-05-06 10:00:00", - "updated_time": "2026-05-06 11:00:00" - } - ], - "total": 1 - } -} -``` - -*** - -### 2. Bug 详情 - -**GET /bug/detail** - -查询 Bug 详细信息,包含评论和历史记录。 - -#### 请求参数 - -| 参数名 | 类型 | 必填 | 说明 | -| ---------- | ------ | -- | ------ | -| bugId / id | Number | 是 | Bug ID | - -#### 响应示例 - -```json -{ - "code": 20000, - "message": "success", - "data": { - "id": 1, - "bug_key": "BUG-001", - "title": "登录页面无法加载", - "description": "点击登录按钮后页面无响应...", - "bug_type": 1, - "severity": 1, - "priority": 1, - "status": 2, - "assignee_id": 1, - "reporter_id": 2, - "product_id": 1, - "project_id": 1, - "module_id": 1, - "case_id": 101, - "plan_id": 5, - "environment": "test", - "steps": "1. 打开登录页面\n2. 输入用户名密码\n3. 点击登录", - "attachments": [], - "created_time": "2026-05-06 10:00:00", - "updated_time": "2026-05-06 11:00:00", - "comments": [ - { - "id": 1, - "bug_id": 1, - "content": "已收到,正在处理", - "user_id": 1, - "created_time": "2026-05-06 10:30:00" - } - ], - "history": [ - { - "id": 1, - "bug_id": 1, - "field_name": "status", - "old_value": "0", - "new_value": "2", - "operator_id": 1, - "created_time": "2026-05-06 10:30:00" - } - ] - } -} -``` - -*** - -### 3. 创建 Bug - -**POST /bug/create** - -创建新的 Bug 报告。 - -#### 请求参数 - -| 参数名 | 类型 | 必填 | 说明 | -| ----------------------- | ------ | -- | ----------- | -| title | String | 是 | Bug 标题 | -| description | String | 否 | Bug 描述 | -| bugType / bug\_type | Number | 否 | Bug 类型,默认 1 | -| severity | Number | 否 | 严重程度,默认 2 | -| priority | Number | 否 | 优先级,默认 2 | -| productId / product\_id | Number | 是 | 产品 ID | -| projectId / project\_id | Number | 是 | 项目 ID | -| moduleId / module\_id | Number | 否 | 模块 ID | -| caseId / case\_id | Number | 否 | 关联测试用例 ID | -| planId / plan\_id | Number | 否 | 关联测试计划 ID | -| environment | String | 否 | 测试环境 | -| steps | String | 否 | 复现步骤 | - -#### 请求体示例 - -```json -{ - "title": "登录页面无法加载", - "description": "点击登录按钮后页面无响应", - "bugType": 1, - "severity": 1, - "priority": 1, - "productId": 1, - "projectId": 1, - "moduleId": 1, - "environment": "test", - "steps": "1. 打开登录页面\n2. 输入用户名密码\n3. 点击登录" -} -``` - -#### 响应示例 - -```json -{ - "code": 20000, - "message": "success", - "data": { - "id": 1 - } -} -``` - -*** - -### 4. 更新 Bug - -**POST /bug/update** - -更新 Bug 信息。 - -#### 请求参数 - -| 参数名 | 类型 | 必填 | 说明 | -| ------------------------- | ------ | -- | --------- | -| bugId / id | Number | 是 | Bug ID | -| title | String | 否 | Bug 标题 | -| description | String | 否 | Bug 描述 | -| bugType / bug\_type | Number | 否 | Bug 类型 | -| severity | Number | 否 | 严重程度 | -| priority | Number | 否 | 优先级 | -| status | Number | 否 | 状态 | -| assigneeId / assignee\_id | Number | 否 | 负责人 ID | -| moduleId / module\_id | Number | 否 | 模块 ID | -| caseId / case\_id | Number | 否 | 关联测试用例 ID | -| planId / plan\_id | Number | 否 | 关联测试计划 ID | -| environment | String | 否 | 测试环境 | -| steps | String | 否 | 复现步骤 | - -#### 请求体示例 - -```json -{ - "bugId": 1, - "status": 3, - "assigneeId": 1 -} -``` - -#### 响应示例 - -```json -{ - "code": 20000, - "message": "success", - "data": { - "id": 1 - } -} -``` - -*** - -### 5. 删除 Bug - -**POST /bug/delete** - -软删除 Bug。 - -#### 请求参数 - -| 参数名 | 类型 | 必填 | 说明 | -| ---------- | ------ | -- | ------ | -| bugId / id | Number | 是 | Bug ID | - -#### 请求体示例 - -```json -{ - "bugId": 1 -} -``` - -#### 响应示例 - -```json -{ - "code": 20000, - "message": "success", - "data": { - "id": 1 - } -} -``` - -*** - -### 6. 添加评论 - -**POST /bug/comment/add** - -为 Bug 添加评论。 - -#### 请求参数 - -| 参数名 | 类型 | 必填 | 说明 | -| ---------- | ------ | -- | ------ | -| bugId / id | Number | 是 | Bug ID | -| content | String | 是 | 评论内容 | - -#### 请求体示例 - -```json -{ - "bugId": 1, - "content": "已收到,正在处理" -} -``` - -#### 响应示例 - -```json -{ - "code": 20000, - "message": "success", - "data": { - "id": 1 - } -} -``` - -*** - -### 7. Bug 统计 - -**GET /bug/stats** - -获取 Bug 统计信息。 - -#### 请求参数 - -| 参数名 | 类型 | 必填 | 说明 | -| ----------------------- | ------ | -- | ----- | -| productId / product\_id | Number | 否 | 产品 ID | -| projectId / project\_id | Number | 否 | 项目 ID | - -#### 响应示例 - -```json -{ - "code": 20000, - "message": "success", - "data": { - "total": 100, - "new": 10, - "pending": 20, - "in_progress": 30, - "resolved": 25, - "closed": 10, - "rejected": 5, - "by_severity": { - "critical": 15, - "major": 30, - "medium": 40, - "minor": 15 - }, - "by_priority": { - "high": 40, - "medium": 45, - "low": 15 - }, - "by_type": { - "functional": 40, - "ui": 25, - "performance": 15, - "security": 10, - "compatibility": 10 - } - } -} -``` - -*** - -### 8. 图片上传 - -**POST /bug/upload** - -上传 Bug 相关图片,返回图片访问 URL。 - -#### 请求参数 - -| 参数名 | 类型 | 必填 | 说明 | -|--------|------|------|------| -| file | File | 是 | 图片文件 | - -#### 支持的文件格式 - -- png -- jpg / jpeg -- gif -- bmp - -#### 调用示例(curl) - -```bash -curl -X POST "http://39.170.26.156:8888/it/api/bug/upload" \ - -H "accessToken: your_token" \ - -F "file=@screenshot.png" -``` - -#### 响应示例 - -```json -{ - "code": 20000, - "message": "success", - "data": { - "url": "http://39.170.26.156:8888/uploads/bug/bug-20260506100000-abc12345.png" - } -} -``` - -#### 错误响应 - -```json -{ - "code": 40009, - "message": "未找到上传文件" -} -``` - -```json -{ - "code": 40009, - "message": "不支持的文件格式,仅支持:png, jpg, jpeg, gif, bmp" -} -``` - -*** - -## 三、枚举值说明 - -### Bug 类型 (bug\_type) - -| 值 | 名称 | 说明 | -| - | ----- | ---------- | -| 1 | 功能缺陷 | 核心功能不能正常工作 | -| 2 | UI 问题 | 界面显示、交互问题 | -| 3 | 性能问题 | 响应慢、资源占用高 | -| 4 | 安全漏洞 | 安全相关问题 | -| 5 | 兼容性问题 | 浏览器/平台兼容问题 | - -### 严重程度 (severity) - -| 值 | 名称 | 说明 | -| - | -- | --------- | -| 1 | 致命 | 系统崩溃、数据丢失 | -| 2 | 严重 | 核心功能不可用 | -| 3 | 中等 | 功能受限但可用 | -| 4 | 轻微 | 小问题,不影响使用 | - -### 优先级 (priority) - -| 值 | 名称 | 说明 | -| - | -- | ------ | -| 1 | 高 | 需要立即处理 | -| 2 | 中 | 按计划处理 | -| 3 | 低 | 空闲时处理 | - -### 状态 (status) - -| 值 | 名称 | 说明 | -| - | --- | ------------- | -| 0 | 新建 | Bug 刚创建,待审核 | -| 1 | 待处理 | 已确认,等待分配 | -| 2 | 进行中 | 已分配,正在修复 | -| 3 | 已解决 | 修复完成,待验证 | -| 4 | 已关闭 | 验证通过,已关闭 | -| 5 | 已拒绝 | 非 Bug 或重复,已拒绝 | - -*** - -## 四、状态流转规则 - -``` - ┌─────────────────┐ - │ 0-新建 │ - └────────┬────────┘ - │ - ┌──────────────┴──────────────┐ - ▼ ▼ - ┌─────────────┐ ┌─────────────┐ - │ 1-待处理 │ │ 5-已拒绝 │ - └──────┬──────┘ └─────────────┘ - │ - ▼ - ┌─────────────┐ - │ 2-进行中 │ - └──────┬──────┘ - │ - ▼ - ┌─────────────┐ - │ 3-已解决 │ - └──────┬──────┘ - │ - ▼ - ┌─────────────┐ - │ 4-已关闭 │ - └─────────────┘ -``` - -**转换规则:** - -- 新建 → 待处理 / 已拒绝 -- 待处理 → 进行中 / 已拒绝 -- 进行中 → 已解决 / 待处理 -- 已解决 → 已关闭 / 待处理 -- 已关闭 → 待处理(重新打开) -- 已拒绝 → 待处理(重新打开) - -*** - -## 五、通用状态码 - -| 状态码 | 说明 | -| ----- | ------------ | -| 20000 | 成功 | -| 40004 | 未登录或缺少 token | -| 40009 | 参数错误或新增失败 | -| 40011 | 未查询到对应记录 | -| 40012 | 更新或删除失败 | -| 40013 | 权限不足 | - -*** - -## 六、认证方式 - -所有接口(除登录/注册外)需在请求头携带 `accessToken`: - -```bash -curl -H "accessToken: your_token" http://localhost:8081/it/api/bug/list -``` - -*** - -## 七、权限配置 - -| 权限编码 | 权限名称 | -| ----------- | --------- | -| bug:list | 查看 Bug 列表 | -| bug:detail | 查看 Bug 详情 | -| bug:create | 创建 Bug | -| bug:update | 更新 Bug | -| bug:delete | 删除 Bug | -| bug:comment | 添加评论 | -| bug:stats | 查看统计 | - diff --git a/check_permission_table.py b/check_permission_table.py new file mode 100644 index 0000000..a5c9e7d --- /dev/null +++ b/check_permission_table.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python +# encoding: UTF-8 + +import psycopg2 + +def check_permission_table(): + try: + conn = psycopg2.connect( + host="39.170.26.156", + port=8366, + dbname="test", + user="postgres", + password="difyai123456" + ) + cursor = conn.cursor() + + cursor.execute(""" + SELECT column_name, data_type, is_nullable + FROM information_schema.columns + WHERE table_name = 'permission' + ORDER BY ordinal_position; + """) + + print("Permission 表结构:") + print("-" * 60) + print(f"{'字段名':<20} {'类型':<20} {'是否可空'}") + print("-" * 60) + for row in cursor.fetchall(): + print(f"{row[0]:<20} {row[1]:<20} {row[2]}") + + conn.close() + except Exception as e: + print("Error: " + str(e)) + +if __name__ == "__main__": + check_permission_table() \ No newline at end of file diff --git a/common/__pycache__/jenkinsRequest.cpython-38.pyc b/common/__pycache__/jenkinsRequest.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ee02a5fbc4893af1aa634227284550dcbc2c019d GIT binary patch literal 3059 zcma)8&2JmW6`z^?Bt^+GEz6dpB#LVkCTfXof#%>wMr>DZ;}}d}$#6m#SWujmy%f32 z%*;X-vCJV-(4epqw}o?P;+87Wrq@vZ_|guG%f9>a^UdtMz6x*UD9Mlzf+Pi`$!o+kSq-sE%;DNJ`EgSSFPcO-H}C zwDjt^nhfe^V|AlsrsEeEDlflOS@h1GU0m?4T)vbZ8fw0?aN+EgOH1CxSI(uQL*>#d zFE3Qm!mxW~ap5wUwVToP>%OR?oBfXgofqMgCqeiGw^L=@<%}Dfv}$mZTbrb6@*KCh z0~$*&H=mAO^uyI4l$ZTCVqdCC9b+_nY-r$j9zOXa5D68}C$tBV+olQeXu{gZ6nva@ z&BSPbRlx^sOPPt8SQ`w+Sm6m}KboPKSW*Ujreez2U?JlKM=7W0ZeuH#kcI_c4s`k6 z2sfZjKOo!$-R zi{^Z1_~~n% z8|&-W{FN1dRm}#W^2JK6?q91%tyUCX8~7L;-CTP;bv-W#1Lb)IFd!#D5Ngtbe#cmW z9;JDBJ4Xf)8qOTfs#BaXcrXl@PWtfw3=KF4BJ%+R9b0fD^e*et&P_a^21_VnZHskT z!Ww|LRYP1niycK%{HFX}(5YCn0+cyaQnJmCoUE zUBs>Bm!yn+QLeRvazjSp-ZQXCNHIqREkBCY-d~^}e+9(uyPx;pdw&qL89lfEi%)ky z`{ft+e)#d5xSP^$+Ky%6|Ko?2QJKLr+c-nrfX!Oxyx=k!cG{XaaEasrQ2jDtvx z>%K}yGN)cW;(mrcXRRi}AiSQMxD#;@hvpV;*8R2usFo~I1Wh%k7nEUA__7^^(y!(- zC9kPkYWPBE@MYqNHUl0#J!T5J5`#pjO-+CkqvHUlBJ^B30q-RAkJHZ4$KdtQKpj(Q z_~f90`_lL#kZ(XHwjzKZz>_E@OipvaR6te?jW{F$BZ1`S=3Nu8LwXD_1Vqy_kcb1I zBuX>}oCNU3U1c{MKqck5t%sCxn|98CeYcTIjD#j8&-0Njvuh<*BhL$~&TT4Af{hVA ziW;yo2EPR;2M69}8jqTI^yu+bhNJmG9Iv;0&{x8pmGS3DsXCOn z8x+@XL*~9te$E!rxBI3$%uG$?&sHwP$b19%Wc>Y)dt3dFZtdLptYeiM(Q>&nQC^ON zCche}8?V*`P&u%o>=e#NK#!rCU0QGZo#$%pb~C8g5d7tvvukT>vn!Ek%>o06MGSSfO+X4+_6&I2J7{7}Zi_iC~p1bZ*Q#FCMkI$@j} zehqR^iep+UFxU6~isEk|;-^6job3MkSN->X_&9BJoKrK)5nrDV{sr6{{1}8lK8R0& zIFBR+Gj{I0^TqvJ{ZH=hetc)|`&&Ejf07YN|98KIE-2^b=3=b-PjGcI8_~c0Zs`$O z?T_+f>q^~AQ5Lt9bP8p-6vTcxP=2c{Fk$z;geQLXk*_Rv8NGo6J>CnruC>j9rTHa@ zmkWpIVdYKT;h@wVj`)uS>2v7cf2)7*9#||*2)vSH6nFw!sU5c=_8IYMrV}%$ws_oX z%Zw$>b_AszsVcRA(jt+X%D<@u21($dqy|(|F`@@ZfQk~&X)VkGx<#@~?F;~7PX7U@ z$YvjhuJ}5NG72mYk2B_B4Nz|l5b9|o8lqo=k@5@(0&EFom=)o7jLk3;n6p5SGc3-a zp%cKYMUZ1qy7R!X)5x-H5;R3RLpvu1r#vj)Pjpr?DiyeeR^UPccLEH%=cReiYehV6 zV!PmZZ^X4`_K(0@lfdALZ==8@+$XRM2p0s#OX3{~HZbZIwXRgq;TL0|u8xYqEon~Q zob)BiszaGk`WBU)q`qJ2o0h&f+0k;tuPbeApHsaCU4YydvQ?i!a}uv4E^}!C;#DyI E4agd282|tP literal 0 HcmV?d00001 diff --git a/common/jenkinsRequest.py b/common/jenkinsRequest.py new file mode 100644 index 0000000..18c36b6 --- /dev/null +++ b/common/jenkinsRequest.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- +import json + +import requests +from requests.auth import HTTPBasicAuth + +from const import JENKINS_BASE_URL, JENKINS_DEFAULT_JOB, JENKINS_TOKEN, JENKINS_USER +from logger import logger + + +class JenkinsRequest(object): + def __init__(self, jenkins_url=None, username=None, password=None): + if jenkins_url: + self.base_url = jenkins_url.rstrip('/') + use_username = username or JENKINS_USER or 'jenkins' + use_password = password or JENKINS_TOKEN or 'jenkins' + self.auth = HTTPBasicAuth(use_username, use_password) + else: + self.base_url = JENKINS_BASE_URL.rstrip('/') + self.auth = HTTPBasicAuth(JENKINS_USER, JENKINS_TOKEN) if JENKINS_USER and JENKINS_TOKEN else None + self.session = requests.Session() + self.session.auth = self.auth + logger.info(f'Jenkins配置: base_url={self.base_url}, username={use_username if jenkins_url else JENKINS_USER}') + + def get_crumb(self): + if not self.base_url: + return {} + + crumb_url = f'{self.base_url}/crumbIssuer/api/json' + try: + response = self.session.get(crumb_url, timeout=30) + if response.status_code != 200: + logger.warning(f'获取Jenkins crumb失败:status={response.status_code}') + return {} + crumb_data = response.json() + if crumb_data.get('crumbRequestField') and crumb_data.get('crumb'): + logger.info(f'成功获取Jenkins crumb: {crumb_data["crumbRequestField"]}') + return {crumb_data['crumbRequestField']: crumb_data['crumb']} + except Exception as err: + logger.warning(f'获取Jenkins crumb失败:{err}') + return {} + + def build_with_parameters(self, params, job_name=None): + job_name = job_name or JENKINS_DEFAULT_JOB + if not self.base_url or not job_name: + return False, 'Jenkins配置不完整', {} + + url = f'{self.base_url}/job/{job_name}/buildWithParameters' + headers = self.get_crumb() + headers.update({'Content-Type': 'application/x-www-form-urlencoded'}) + + logger.info(f'Jenkins构建请求: url={url}, params={json.dumps(params, ensure_ascii=False)}') + + try: + response = self.session.post(url, data=params, headers=headers, timeout=60) + if response.status_code not in (200, 201, 202): + logger.error(f'Jenkins触发失败:status={response.status_code}, body={response.text[:500]}') + + if response.status_code == 403 and 'crumb' in response.text: + logger.info('Crumb失效,尝试重新获取并重试...') + headers = self.get_crumb() + headers.update({'Content-Type': 'application/x-www-form-urlencoded'}) + response = self.session.post(url, data=params, headers=headers, timeout=60) + + if response.status_code not in (200, 201, 202): + logger.error(f'Jenkins触发失败(重试后):status={response.status_code}') + return False, f'Jenkins触发失败:{response.status_code}', {} + + location = response.headers.get('Location', '') + queue_id = None + if '/queue/item/' in location: + try: + queue_id = int(location.rstrip('/').split('/')[-1]) + except Exception: + queue_id = None + + logger.info(f'Jenkins构建成功:queue_id={queue_id}, location={location}') + return True, '', {'job_name': job_name, 'queue_id': queue_id, 'location': location} + + except Exception as err: + logger.error(f'Jenkins请求异常:{err}, params={json.dumps(params, ensure_ascii=False)}') + return False, str(err), {} \ No newline at end of file diff --git a/const.py b/const.py index 5240e9c..b339198 100644 --- a/const.py +++ b/const.py @@ -29,8 +29,8 @@ RES_CODE = { 40013: 'scene_id不能为空!' } -# sparkatp_sql_uri = f'postgresql+psycopg2://postgres:{urlquote("difyai123456")}@39.170.26.156:8366/test' -sparkatp_sql_uri = f'postgresql+psycopg2://postgres:{urlquote("difyai123456")}@39.170.26.156:8366/test-platform-prod' +sparkatp_sql_uri = f'postgresql+psycopg2://postgres:{urlquote("difyai123456")}@39.170.26.156:8366/test' +# sparkatp_sql_uri = f'postgresql+psycopg2://postgres:{urlquote("difyai123456")}@39.170.26.156:8366/test-platform-prod' EXECUTE_DB_CONFIG = { 'ZHYY': { 'st': { @@ -83,4 +83,12 @@ STRESS_URI = 'https://qe.bg.huohua.cn' QE_DOMAIN = 'https://qe.bg.huohua.cn' PASSWORD = quote('AcUVeRb8lN') -REDIS_URL = 'redis://127.0.0.1:7379/15' +# REDIS_URL = 'redis://127.0.0.1:7379/15' +REDIS_URL = 'redis://124.220.32.45:7379/15' + +JENKINS_BASE_URL = os.environ.get('JENKINS_BASE_URL', 'http://39.170.26.156:8256/') +JENKINS_USER = os.environ.get('JENKINS_USER', 'jenkins') +JENKINS_TOKEN = os.environ.get('JENKINS_TOKEN', 'jenkins') +JENKINS_DEFAULT_JOB = os.environ.get('JENKINS_DEFAULT_JOB', 'pytest-auto-runner') +AUTOMATION_CALLBACK_SECRET = os.environ.get('AUTOMATION_CALLBACK_SECRET', '') +PLATFORM_BASE_URL = os.environ.get('PLATFORM_BASE_URL', 'http://127.0.0.1:5010/it/api') diff --git a/generate_automation_menus.py b/generate_automation_menus.py new file mode 100644 index 0000000..b57706c --- /dev/null +++ b/generate_automation_menus.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python +# encoding: UTF-8 + +import psycopg2 + +def generate_menu_sql(): + try: + conn = psycopg2.connect( + host="39.170.26.156", + port=8366, + dbname="test", + user="postgres", + password="difyai123456" + ) + cursor = conn.cursor() + + # 查询自动化模块的权限 + cursor.execute(""" + SELECT code, name, action + FROM permission + WHERE module = 'automation' AND is_delete = 0 + ORDER BY id; + """) + + permissions = cursor.fetchall() + + print("自动化模块权限列表:") + print("-" * 60) + print(f"{'权限代码':<30} {'权限名称':<20} {'action'}") + print("-" * 60) + for p in permissions: + print(f"{p[0]:<30} {p[1]:<20} {p[2]}") + + # 查询已存在的menu + cursor.execute("SELECT code FROM menu WHERE is_delete = 0") + existing_menus = {row[0] for row in cursor.fetchall()} + + conn.close() + + # 生成SQL + sql_lines = [] + sql_lines.append("-- ==============================================") + sql_lines.append("-- 自动化执行模块 - 权限对应的按钮菜单") + sql_lines.append("-- ==============================================") + sql_lines.append("") + + sort_order = 1 + for code, name, action in permissions: + menu_code = f"automation_{action}" + if menu_code in existing_menus: + print(f"跳过已存在的菜单: {menu_code}") + continue + + sql_lines.append(f"-- {name} 按钮") + sql_lines.append(f"INSERT INTO menu (name, code, type, path, component, icon, permission_code, parent_id, sort, visible, status, is_delete)") + sql_lines.append(f"SELECT '{name}', '{menu_code}', 3, '', '', '', '{code}',") + sql_lines.append(f" (SELECT id FROM menu WHERE code = 'automation_list'), {sort_order}, 1, 1, 0") + sql_lines.append(f"WHERE NOT EXISTS (SELECT 1 FROM menu WHERE code = '{menu_code}');") + sql_lines.append("") + sort_order += 1 + + sql_lines.append("COMMIT;") + + # 写入文件 + with open("automation_menus.sql", "w", encoding="utf-8") as f: + f.write("\n".join(sql_lines)) + + print(f"\n已生成 SQL 文件: automation_menus.sql") + print(f"共生成 {sort_order - 1} 条菜单记录") + + except Exception as e: + print("Error: " + str(e)) + +if __name__ == "__main__": + generate_menu_sql() \ No newline at end of file diff --git a/resources/automation_api_doc.md b/resources/automation_api_doc.md new file mode 100644 index 0000000..adb37c6 --- /dev/null +++ b/resources/automation_api_doc.md @@ -0,0 +1,592 @@ +# 自动化执行接口文档 + +## 1. 基础说明 + +- 接口前缀:`/it/api` +- 鉴权方式:沿用现有登录鉴权,前端请求需携带 token +- 返回格式:沿用现有项目统一返回结构 + +### 成功返回示例 + +```json +{ + "code": 20000, + "msg": "success", + "data": {} +} +``` + +### 失败返回示例 + +```json +{ + "code": 40009, + "msg": "参数错误", + "data": null +} +``` + +*** + +## 2. 状态枚举 + +### 2.1 执行主单状态 `status` + +| 值 | 含义 | +| - | ---- | +| 0 | 待触发 | +| 1 | 触发中 | +| 2 | 排队中 | +| 3 | 执行中 | +| 4 | 成功 | +| 5 | 失败 | +| 6 | 已取消 | +| 7 | 触发失败 | +| 8 | 回调异常 | + +### 2.2 执行明细状态 `status` + +| 值 | 含义 | +| - | --- | +| 0 | 待执行 | +| 1 | 执行中 | +| 2 | 通过 | +| 3 | 失败 | +| 4 | 阻塞 | +| 5 | 跳过 | +| 6 | 未找到 | +| 7 | 已取消 | + +### 2.3 触发类型 `trigger_type` + +| 值 | 含义 | +| - | ---- | +| 1 | 单条执行 | +| 2 | 计划执行 | + +### 2.4 执行模式 `run_mode` + +| 值 | 含义 | +| - | -- | +| 1 | 串行 | +| 2 | 并行 | + +*** + +## 3. 接口清单 + +| 接口 | 方法 | 说明 | +| --------------------------------- | ---- | --------- | +| `/automation/case/run` | POST | 单条自动化用例执行 | +| `/automation/plan/run` | POST | 计划自动化用例执行 | +| `/automation/execution/list` | GET | 自动化执行记录列表 | +| `/automation/execution/detail` | GET | 自动化执行详情 | +| `/automation/execution/case/list` | GET | 自动化执行明细列表 | + +> 以下接口为 Jenkins / pytest 内部调用,前端无需接入: +> +> - `/automation/execution/case/pull` +> - `/automation/execution/queued` +> - `/automation/execution/start` +> - `/automation/execution/case/result` +> - `/automation/execution/finish` +> - `/automation/execution/abort` + +*** + +## 4. 单条自动化用例执行 + +### 接口 + +- 方法:`POST` +- 路径:`/it/api/automation/case/run` + +### 请求体 + +```json +{ + "caseId": 1001, + "envCode": "st", + "runMode": 1, + "jenkinsJobName": "pytest-auto-runner", + "remark": "前端手动触发" +} +``` + +### 请求参数说明 + +| 字段 | 类型 | 必填 | 说明 | +| -------------- | ------ | -- | ----------------------- | +| caseId | number | 是 | 功能用例 ID,也是自动化桥梁 ID | +| envCode | string | 是 | 执行环境编码 | +| runMode | number | 否 | 执行模式,1串行,2并行;默认1 | +| jenkinsJobName | string | 否 | 指定 Jenkins Job,不传走后端默认值 | +| remark | string | 否 | 触发备注 | + +### 成功返回示例 + +```json +{ + "code": 20000, + "msg": "success", + "data": { + "id": 12, + "execution_no": "AE20250920153000123", + "trigger_type": 1, + "project_id": 3, + "plan_id": null, + "plan_round_no": null, + "source_case_id": 1001, + "env_code": "st", + "run_mode": 1, + "status": 2, + "jenkins_job_name": "pytest-auto-runner", + "jenkins_queue_id": 321, + "jenkins_build_number": null, + "jenkins_build_url": null, + "console_url": null, + "report_url": null, + "total_count": 1, + "pending_count": 1, + "running_count": 0, + "passed_count": 0, + "failed_count": 0, + "blocked_count": 0, + "skipped_count": 0, + "not_found_count": 0, + "trigger_by": 8, + "trigger_source": "platform", + "trigger_message": "http://jenkins/queue/item/321/", + "start_time": null, + "end_time": null, + "duration_seconds": null, + "ext": {}, + "created_time": "2025-09-20 15:30:00", + "updated_time": "2025-09-20 15:30:01" + } +} +``` + +### 失败返回示例 + +```json +{ + "code": 40009, + "msg": "caseId、envCode 为必传参数", + "data": null +} +``` + +```json +{ + "code": 40009, + "msg": "该用例不存在或未接入自动化", + "data": null +} +``` + +*** + +## 5. 计划自动化用例执行 + +### 接口 + +- 方法:`POST` +- 路径:`/it/api/automation/plan/run` + +### 请求体:执行计划下全部自动化用例 + +```json +{ + "planId": 2001, + "envCode": "st", + "runMode": 1, + "roundNo": 1, + "jenkinsJobName": "pytest-auto-runner", + "remark": "执行计划自动化" +} +``` + +### 请求体:执行计划下指定用例 + +```json +{ + "planId": 2001, + "envCode": "st", + "runMode": 1, + "roundNo": 1, + "caseIds": [1001, 1002, 1003], + "jenkinsJobName": "pytest-auto-runner", + "remark": "只执行勾选用例" +} +``` + +### 请求参数说明 + +| 字段 | 类型 | 必填 | 说明 | +| -------------- | --------- | -- | ------------------------------- | +| planId | number | 是 | 测试计划 ID | +| envCode | string | 是 | 执行环境编码 | +| runMode | number | 否 | 执行模式,1串行,2并行;默认1 | +| roundNo | number | 否 | 指定计划轮次 | +| caseIds | number\[] | 否 | 指定执行的功能用例 ID 列表;不传则执行计划下全部自动化用例 | +| jenkinsJobName | string | 否 | 指定 Jenkins Job | +| remark | string | 否 | 触发备注 | + +### 成功返回示例 + +```json +{ + "code": 20000, + "msg": "success", + "data": { + "id": 13, + "execution_no": "AE20250920153500123", + "trigger_type": 2, + "project_id": 3, + "plan_id": 2001, + "plan_round_no": 1, + "source_case_id": null, + "env_code": "st", + "run_mode": 1, + "status": 2, + "jenkins_job_name": "pytest-auto-runner", + "jenkins_queue_id": 322, + "total_count": 10, + "pending_count": 10, + "running_count": 0, + "passed_count": 0, + "failed_count": 0, + "blocked_count": 0, + "skipped_count": 0, + "not_found_count": 0 + } +} +``` + +### 失败返回示例 + +```json +{ + "code": 40009, + "msg": "planId、envCode 为必传参数", + "data": null +} +``` + +```json +{ + "code": 40009, + "msg": "计划下无可执行自动化用例", + "data": null +} +``` + +*** + +## 6. 自动化执行记录列表 + +### 接口 + +- 方法:`GET` +- 路径:`/it/api/automation/execution/list` + +### Query 参数 + +| 字段 | 类型 | 必填 | 说明 | +| ----------- | ------ | -- | --------------- | +| pageNo | number | 否 | 页码,默认1 | +| pageSize | number | 否 | 每页数量,默认20 | +| projectId | number | 否 | 按项目过滤 | +| planId | number | 否 | 按计划过滤 | +| status | number | 否 | 按执行主单状态过滤 | +| triggerType | number | 否 | 按触发类型过滤,1单条,2计划 | + +### 请求示例 + +```http +GET /it/api/automation/execution/list?pageNo=1&pageSize=20&planId=2001 +``` + +### 成功返回示例 + +```json +{ + "code": 20000, + "msg": "success", + "data": { + "list": [ + { + "id": 13, + "execution_no": "AE20250920153500123", + "trigger_type": 2, + "project_id": 3, + "plan_id": 2001, + "plan_round_no": 1, + "source_case_id": null, + "env_code": "st", + "run_mode": 1, + "status": 3, + "jenkins_job_name": "pytest-auto-runner", + "jenkins_queue_id": 322, + "jenkins_build_number": 108, + "jenkins_build_url": "http://jenkins/job/pytest-auto-runner/108/", + "console_url": "http://jenkins/job/pytest-auto-runner/108/console", + "report_url": "http://allure/report/108", + "total_count": 10, + "pending_count": 3, + "running_count": 2, + "passed_count": 4, + "failed_count": 1, + "blocked_count": 0, + "skipped_count": 0, + "not_found_count": 0, + "trigger_by": 8, + "trigger_source": "platform", + "trigger_message": "", + "start_time": "2025-09-20 15:36:00", + "end_time": null, + "duration_seconds": null, + "ext": {}, + "created_time": "2025-09-20 15:35:00", + "updated_time": "2025-09-20 15:38:20" + } + ], + "total": 1 + } +} +``` + +*** + +## 7. 自动化执行详情 + +### 接口 + +- 方法:`GET` +- 路径:`/it/api/automation/execution/detail` + +### Query 参数 + +| 字段 | 类型 | 必填 | 说明 | +| ----------- | ------ | -- | ------- | +| executionId | number | 是 | 执行主单 ID | + +### 请求示例 + +```http +GET /it/api/automation/execution/detail?executionId=13 +``` + +### 成功返回示例 + +```json +{ + "code": 20000, + "msg": "success", + "data": { + "id": 13, + "execution_no": "AE20250920153500123", + "trigger_type": 2, + "project_id": 3, + "plan_id": 2001, + "plan_round_no": 1, + "source_case_id": null, + "env_code": "st", + "run_mode": 1, + "status": 4, + "jenkins_job_name": "pytest-auto-runner", + "jenkins_queue_id": 322, + "jenkins_build_number": 108, + "jenkins_build_url": "http://jenkins/job/pytest-auto-runner/108/", + "console_url": "http://jenkins/job/pytest-auto-runner/108/console", + "report_url": "http://allure/report/108", + "total_count": 10, + "pending_count": 0, + "running_count": 0, + "passed_count": 9, + "failed_count": 1, + "blocked_count": 0, + "skipped_count": 0, + "not_found_count": 0, + "trigger_by": 8, + "trigger_source": "platform", + "trigger_message": "", + "start_time": "2025-09-20 15:36:00", + "end_time": "2025-09-20 15:45:00", + "duration_seconds": 540, + "ext": {}, + "created_time": "2025-09-20 15:35:00", + "updated_time": "2025-09-20 15:45:00", + "summary": { + "total": 10, + "pending": 0, + "running": 0, + "passed": 9, + "failed": 1, + "blocked": 0, + "skipped": 0, + "notFound": 0, + "canceled": 0 + } + } +} +``` + +### 失败返回示例 + +```json +{ + "code": 40011, + "msg": "executionId 为必传参数", + "data": null +} +``` + +*** + +## 8. 自动化执行明细列表 + +### 接口 + +- 方法:`GET` +- 路径:`/it/api/automation/execution/case/list` + +### Query 参数 + +| 字段 | 类型 | 必填 | 说明 | +| ----------- | ------ | -- | --------- | +| executionId | number | 是 | 执行主单 ID | +| status | number | 否 | 按执行明细状态过滤 | +| pageNo | number | 否 | 页码,默认1 | +| pageSize | number | 否 | 每页数量,默认20 | + +### 请求示例 + +```http +GET /it/api/automation/execution/case/list?executionId=13&pageNo=1&pageSize=100 +``` + +### 成功返回示例 + +```json +{ + "code": 20000, + "msg": "success", + "data": { + "list": [ + { + "id": 101, + "execution_id": 13, + "plan_case_id": 5001, + "case_id": 1001, + "case_key": "1001", + "case_title": "订单创建成功", + "run_order": 1, + "status": 2, + "pytest_nodeid": "tests/order/test_create_order.py::test_create_order", + "result_message": "执行通过", + "error_message": "", + "stack_trace": "", + "report_url": "http://allure/case/101", + "duration_seconds": 18, + "started_time": "2025-09-20 15:36:02", + "finished_time": "2025-09-20 15:36:20", + "retry_count": 0, + "ext": {}, + "created_time": "2025-09-20 15:35:00", + "updated_time": "2025-09-20 15:36:20" + }, + { + "id": 102, + "execution_id": 13, + "plan_case_id": 5002, + "case_id": 1002, + "case_key": "1002", + "case_title": "订单取消失败提示正确", + "run_order": 2, + "status": 3, + "pytest_nodeid": "tests/order/test_cancel_order.py::test_cancel_order", + "result_message": "断言失败", + "error_message": "AssertionError: xxx", + "stack_trace": "Traceback ...", + "report_url": "http://allure/case/102", + "duration_seconds": 12, + "started_time": "2025-09-20 15:36:21", + "finished_time": "2025-09-20 15:36:33", + "retry_count": 0, + "ext": {}, + "created_time": "2025-09-20 15:35:00", + "updated_time": "2025-09-20 15:36:33" + } + ], + "total": 10 + } +} +``` + +### 失败返回示例 + +```json +{ + "code": 40011, + "msg": "executionId 为必传参数", + "data": null +} +``` + +*** + +## 9. 前端接入建议 + +### 9.1 单条执行 + +- 页面位置:功能用例列表页 / 详情页 +- 按钮:`执行自动化` +- 调用接口:`POST /automation/case/run` +- 成功后拿返回 `data.id` 作为 `executionId` +- 再调用: + - `GET /automation/execution/detail` + - `GET /automation/execution/case/list` + +### 9.2 计划执行 + +- 页面位置:测试计划详情页 +- 按钮:`执行自动化用例` +- 调用接口:`POST /automation/plan/run` +- 支持: + - 执行全部自动化用例 + - 执行勾选用例(传 `caseIds`) + +### 9.3 轮询建议 + +执行为异步触发,前端建议轮询: + +- `GET /automation/execution/detail?executionId=xx` +- `GET /automation/execution/case/list?executionId=xx&pageNo=1&pageSize=100` + +轮询频率建议: + +- 执行中:每 3\~5 秒 1 次 +- 终态停止轮询 + +主单终态: + +- `4 成功` +- `5 失败` +- `6 已取消` +- `7 触发失败` +- `8 回调异常` + +*** + +## 10. 备注 + +1. 返回字段命名为下划线风格,如: + - `execution_no` + - `project_id` + - `created_time` +2. `summary` 仅在执行详情接口返回: + - `/automation/execution/detail` +3. `report_url`、`console_url` 在刚触发时可能为空,待 Jenkins / pytest 回调后更新。 + diff --git a/resources/sql/automation_execution_pgsql.sql b/resources/sql/automation_execution_pgsql.sql new file mode 100644 index 0000000..8caf629 --- /dev/null +++ b/resources/sql/automation_execution_pgsql.sql @@ -0,0 +1,177 @@ +-- 自动化执行建表脚本(PostgreSQL) +-- 说明: +-- 1. 本脚本为可直接执行脚本。 +-- 2. 使用 PostgreSQL 标准 COMMENT ON 语法为表和字段添加注释。 +-- 3. 当前脚本仅创建自动化执行主表、执行明细表、索引、更新时间触发器。 +-- 4. 如需外键约束,请根据你当前库中的 test_plan / test_case / plan_case 实际表名补充。 + +BEGIN; + +CREATE TABLE IF NOT EXISTS public.auto_execution ( + id BIGSERIAL PRIMARY KEY, + execution_no VARCHAR(64) NOT NULL, + trigger_type SMALLINT NOT NULL, + project_id BIGINT NOT NULL, + plan_id BIGINT NULL, + plan_round_no INTEGER NULL, + source_case_id BIGINT NULL, + env_code VARCHAR(32) NOT NULL, + run_mode SMALLINT NOT NULL DEFAULT 1, + status SMALLINT NOT NULL DEFAULT 0, + jenkins_job_name VARCHAR(128) NULL, + jenkins_queue_id BIGINT NULL, + jenkins_build_number BIGINT NULL, + jenkins_build_url VARCHAR(512) NULL, + console_url VARCHAR(512) NULL, + report_url VARCHAR(512) NULL, + total_count INTEGER NOT NULL DEFAULT 0, + pending_count INTEGER NOT NULL DEFAULT 0, + running_count INTEGER NOT NULL DEFAULT 0, + passed_count INTEGER NOT NULL DEFAULT 0, + failed_count INTEGER NOT NULL DEFAULT 0, + blocked_count INTEGER NOT NULL DEFAULT 0, + skipped_count INTEGER NOT NULL DEFAULT 0, + not_found_count INTEGER NOT NULL DEFAULT 0, + trigger_by BIGINT NULL, + trigger_source VARCHAR(32) NOT NULL DEFAULT 'platform', + trigger_message TEXT NULL, + start_time TIMESTAMP NULL, + end_time TIMESTAMP NULL, + duration_seconds INTEGER NULL, + callback_token VARCHAR(128) NULL, + ext JSONB NOT NULL DEFAULT '{}'::jsonb, + created_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uk_auto_execution_no UNIQUE (execution_no) +); + +COMMENT ON TABLE public.auto_execution IS '自动化执行主表'; +COMMENT ON COLUMN public.auto_execution.id IS '主键ID'; +COMMENT ON COLUMN public.auto_execution.execution_no IS '执行编号,平台生成,唯一'; +COMMENT ON COLUMN public.auto_execution.trigger_type IS '触发类型:1-单条执行,2-计划执行'; +COMMENT ON COLUMN public.auto_execution.project_id IS '项目ID'; +COMMENT ON COLUMN public.auto_execution.plan_id IS '计划ID,单条执行时可为空'; +COMMENT ON COLUMN public.auto_execution.plan_round_no IS '计划轮次快照'; +COMMENT ON COLUMN public.auto_execution.source_case_id IS '单条执行时的来源功能用例 case_id'; +COMMENT ON COLUMN public.auto_execution.env_code IS '执行环境编码,如 test/uat/prod-pre'; +COMMENT ON COLUMN public.auto_execution.run_mode IS '执行模式:1-串行,2-并行'; +COMMENT ON COLUMN public.auto_execution.status IS '执行状态:0-待触发,1-触发中,2-排队中,3-执行中,4-成功,5-失败,6-已取消,7-触发失败,8-回调异常'; +COMMENT ON COLUMN public.auto_execution.jenkins_job_name IS 'Jenkins 任务名称'; +COMMENT ON COLUMN public.auto_execution.jenkins_queue_id IS 'Jenkins 队列ID'; +COMMENT ON COLUMN public.auto_execution.jenkins_build_number IS 'Jenkins 构建号'; +COMMENT ON COLUMN public.auto_execution.jenkins_build_url IS 'Jenkins 构建地址'; +COMMENT ON COLUMN public.auto_execution.console_url IS 'Jenkins 控制台地址'; +COMMENT ON COLUMN public.auto_execution.report_url IS '聚合测试报告地址'; +COMMENT ON COLUMN public.auto_execution.total_count IS '执行总用例数'; +COMMENT ON COLUMN public.auto_execution.pending_count IS '待执行用例数'; +COMMENT ON COLUMN public.auto_execution.running_count IS '执行中用例数'; +COMMENT ON COLUMN public.auto_execution.passed_count IS '通过用例数'; +COMMENT ON COLUMN public.auto_execution.failed_count IS '失败用例数'; +COMMENT ON COLUMN public.auto_execution.blocked_count IS '阻塞用例数'; +COMMENT ON COLUMN public.auto_execution.skipped_count IS '跳过用例数'; +COMMENT ON COLUMN public.auto_execution.not_found_count IS '未找到自动化用例数'; +COMMENT ON COLUMN public.auto_execution.trigger_by IS '触发人用户ID'; +COMMENT ON COLUMN public.auto_execution.trigger_source IS '触发来源,默认 platform'; +COMMENT ON COLUMN public.auto_execution.trigger_message IS '触发说明、失败原因或补充消息'; +COMMENT ON COLUMN public.auto_execution.start_time IS '实际开始执行时间'; +COMMENT ON COLUMN public.auto_execution.end_time IS '实际结束执行时间'; +COMMENT ON COLUMN public.auto_execution.duration_seconds IS '执行耗时,单位秒'; +COMMENT ON COLUMN public.auto_execution.callback_token IS '本次执行给 pytest/Jenkins 使用的拉取 token'; +COMMENT ON COLUMN public.auto_execution.ext IS '扩展字段,JSONB'; +COMMENT ON COLUMN public.auto_execution.created_time IS '创建时间'; +COMMENT ON COLUMN public.auto_execution.updated_time IS '更新时间'; + +CREATE TABLE IF NOT EXISTS public.auto_execution_case ( + id BIGSERIAL PRIMARY KEY, + execution_id BIGINT NOT NULL, + plan_case_id BIGINT NULL, + case_id BIGINT NOT NULL, + case_key VARCHAR(64) NULL, + case_title VARCHAR(255) NULL, + run_order INTEGER NOT NULL DEFAULT 0, + status SMALLINT NOT NULL DEFAULT 0, + pytest_nodeid VARCHAR(512) NULL, + result_message TEXT NULL, + error_message TEXT NULL, + stack_trace TEXT NULL, + report_url VARCHAR(512) NULL, + duration_seconds INTEGER NULL, + started_time TIMESTAMP NULL, + finished_time TIMESTAMP NULL, + retry_count INTEGER NOT NULL DEFAULT 0, + ext JSONB NOT NULL DEFAULT '{}'::jsonb, + created_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uk_auto_execution_case_exec_case UNIQUE (execution_id, case_id) +); + +COMMENT ON TABLE public.auto_execution_case IS '自动化执行明细表'; +COMMENT ON COLUMN public.auto_execution_case.id IS '主键ID'; +COMMENT ON COLUMN public.auto_execution_case.execution_id IS '执行主单ID,对应 auto_execution.id'; +COMMENT ON COLUMN public.auto_execution_case.plan_case_id IS '计划用例ID,对应计划与用例关系表主键'; +COMMENT ON COLUMN public.auto_execution_case.case_id IS '功能用例ID,也是当前自动化与功能用例的桥梁'; +COMMENT ON COLUMN public.auto_execution_case.case_key IS '用例编号快照'; +COMMENT ON COLUMN public.auto_execution_case.case_title IS '用例标题快照'; +COMMENT ON COLUMN public.auto_execution_case.run_order IS '执行顺序'; +COMMENT ON COLUMN public.auto_execution_case.status IS '明细状态:0-待执行,1-执行中,2-通过,3-失败,4-阻塞,5-跳过,6-未找到,7-已取消'; +COMMENT ON COLUMN public.auto_execution_case.pytest_nodeid IS 'pytest 节点标识,如 tests/test_xx.py::test_yy'; +COMMENT ON COLUMN public.auto_execution_case.result_message IS '结果摘要'; +COMMENT ON COLUMN public.auto_execution_case.error_message IS '错误信息'; +COMMENT ON COLUMN public.auto_execution_case.stack_trace IS '失败堆栈'; +COMMENT ON COLUMN public.auto_execution_case.report_url IS '单用例报告地址'; +COMMENT ON COLUMN public.auto_execution_case.duration_seconds IS '执行耗时,单位秒'; +COMMENT ON COLUMN public.auto_execution_case.started_time IS '明细开始时间'; +COMMENT ON COLUMN public.auto_execution_case.finished_time IS '明细结束时间'; +COMMENT ON COLUMN public.auto_execution_case.retry_count IS '重试次数'; +COMMENT ON COLUMN public.auto_execution_case.ext IS '扩展字段,JSONB'; +COMMENT ON COLUMN public.auto_execution_case.created_time IS '创建时间'; +COMMENT ON COLUMN public.auto_execution_case.updated_time IS '更新时间'; + +CREATE INDEX IF NOT EXISTS idx_auto_execution_project_id ON public.auto_execution(project_id); +CREATE INDEX IF NOT EXISTS idx_auto_execution_plan_id ON public.auto_execution(plan_id); +CREATE INDEX IF NOT EXISTS idx_auto_execution_status ON public.auto_execution(status); +CREATE INDEX IF NOT EXISTS idx_auto_execution_trigger_by ON public.auto_execution(trigger_by); +CREATE INDEX IF NOT EXISTS idx_auto_execution_created_time ON public.auto_execution(created_time DESC); +CREATE INDEX IF NOT EXISTS idx_auto_execution_build_number ON public.auto_execution(jenkins_build_number); + +CREATE INDEX IF NOT EXISTS idx_auto_execution_case_execution_id ON public.auto_execution_case(execution_id); +CREATE INDEX IF NOT EXISTS idx_auto_execution_case_case_id ON public.auto_execution_case(case_id); +CREATE INDEX IF NOT EXISTS idx_auto_execution_case_plan_case_id ON public.auto_execution_case(plan_case_id); +CREATE INDEX IF NOT EXISTS idx_auto_execution_case_status ON public.auto_execution_case(status); +CREATE INDEX IF NOT EXISTS idx_auto_execution_case_run_order ON public.auto_execution_case(run_order); + +COMMENT ON INDEX public.idx_auto_execution_project_id IS '按项目查询执行记录索引'; +COMMENT ON INDEX public.idx_auto_execution_plan_id IS '按计划查询执行记录索引'; +COMMENT ON INDEX public.idx_auto_execution_status IS '按执行状态查询索引'; +COMMENT ON INDEX public.idx_auto_execution_trigger_by IS '按触发人查询索引'; +COMMENT ON INDEX public.idx_auto_execution_created_time IS '按创建时间倒序查询索引'; +COMMENT ON INDEX public.idx_auto_execution_build_number IS '按 Jenkins 构建号查询索引'; +COMMENT ON INDEX public.idx_auto_execution_case_execution_id IS '按执行主单查询明细索引'; +COMMENT ON INDEX public.idx_auto_execution_case_case_id IS '按功能用例ID查询明细索引'; +COMMENT ON INDEX public.idx_auto_execution_case_plan_case_id IS '按计划用例ID查询明细索引'; +COMMENT ON INDEX public.idx_auto_execution_case_status IS '按明细状态查询索引'; +COMMENT ON INDEX public.idx_auto_execution_case_run_order IS '按执行顺序查询索引'; + +CREATE OR REPLACE FUNCTION public.set_updated_time() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_time = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION public.set_updated_time() IS '通用更新时间触发器函数,更新 updated_time'; + +DROP TRIGGER IF EXISTS trg_auto_execution_updated_time ON public.auto_execution; +CREATE TRIGGER trg_auto_execution_updated_time +BEFORE UPDATE ON public.auto_execution +FOR EACH ROW +EXECUTE FUNCTION public.set_updated_time(); + +DROP TRIGGER IF EXISTS trg_auto_execution_case_updated_time ON public.auto_execution_case; +CREATE TRIGGER trg_auto_execution_case_updated_time +BEFORE UPDATE ON public.auto_execution_case +FOR EACH ROW +EXECUTE FUNCTION public.set_updated_time(); + +COMMIT; diff --git a/test_fix.py b/test_fix.py new file mode 100644 index 0000000..0002d89 --- /dev/null +++ b/test_fix.py @@ -0,0 +1,47 @@ +import sys +sys.path.insert(0, '.') +from const import sparkatp_sql_uri +from sqlalchemy import create_engine, text + +engine = create_engine(sparkatp_sql_uri) + +print('=== 测试前:角色5的project_hook权限 ===') +with engine.connect() as conn: + result = conn.execute(text(""" + SELECT p.code, p.name + FROM role_permission rp + JOIN permission p ON rp.permission_id = p.id + WHERE rp.role_id = 5 AND rp.is_delete = 0 AND p.code LIKE 'project_hook:%' + """)) + rows = result.fetchall() + if rows: + for row in rows: + print(f' {row[0]} - {row[1]}') + else: + print(' 没有project_hook权限') + +print('\n=== 手动删除角色5的project_hook权限 ===') +with engine.begin() as conn: + conn.execute(text(""" + UPDATE role_permission + SET is_delete = 1 + WHERE role_id = 5 AND permission_id IN ( + SELECT id FROM permission WHERE code LIKE 'project_hook:%' + ) AND is_delete = 0 + """)) + print('删除完成') + +print('\n=== 测试后:角色5的project_hook权限 ===') +with engine.connect() as conn: + result = conn.execute(text(""" + SELECT p.code, p.name + FROM role_permission rp + JOIN permission p ON rp.permission_id = p.id + WHERE rp.role_id = 5 AND rp.is_delete = 0 AND p.code LIKE 'project_hook:%' + """)) + rows = result.fetchall() + if rows: + for row in rows: + print(f' {row[0]} - {row[1]}') + else: + print(' 没有project_hook权限(已成功删除)') diff --git a/数据库语句 b/数据库语句 deleted file mode 100644 index 0502364..0000000 --- a/数据库语句 +++ /dev/null @@ -1,534 +0,0 @@ --- ========================================================= --- 测试管理模块数据库初始化脚本(_time 字段版本) --- 适用数据库:PostgreSQL --- 说明: --- 1. 本脚本与当前后端代码字段保持一致 --- 2. 所有时间字段统一使用 *_time 后缀 --- 3. 主表包含 is_delete 逻辑删除字段 --- ========================================================= - - --- ========================================================= --- 一、项目相关 --- ========================================================= - --- ------------------------- --- 1. 项目表 --- ------------------------- -CREATE TABLE IF NOT EXISTS project ( - id BIGSERIAL PRIMARY KEY, - key VARCHAR(32) UNIQUE NOT NULL, - name VARCHAR(128) NOT NULL, - description TEXT, - department VARCHAR(64), - status SMALLINT DEFAULT 1, - config JSONB DEFAULT '{}'::jsonb, - created_by BIGINT, - is_delete INTEGER DEFAULT 0, - created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - -COMMENT ON TABLE project IS '项目表'; -COMMENT ON COLUMN project.id IS '主键ID'; -COMMENT ON COLUMN project.key IS '项目唯一标识,如 XP2024'; -COMMENT ON COLUMN project.name IS '项目名称'; -COMMENT ON COLUMN project.description IS '项目描述'; -COMMENT ON COLUMN project.department IS '所属部门'; -COMMENT ON COLUMN project.status IS '项目状态:1启用 0禁用'; -COMMENT ON COLUMN project.config IS '扩展配置,JSON格式'; -COMMENT ON COLUMN project.created_by IS '创建人'; -COMMENT ON COLUMN project.is_delete IS '逻辑删除标记:0未删除 1已删除'; -COMMENT ON COLUMN project.created_time IS '创建时间'; -COMMENT ON COLUMN project.updated_time IS '更新时间'; - -CREATE INDEX IF NOT EXISTS idx_project_status ON project(status); -CREATE INDEX IF NOT EXISTS idx_project_is_delete ON project(is_delete); - - --- ------------------------- --- 2. 项目成员表 --- ------------------------- -CREATE TABLE IF NOT EXISTS project_member ( - id BIGSERIAL PRIMARY KEY, - project_id BIGINT NOT NULL REFERENCES project(id) ON DELETE CASCADE, - user_id BIGINT NOT NULL, - role SMALLINT NOT NULL, - joined_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - -COMMENT ON TABLE project_member IS '项目成员表'; -COMMENT ON COLUMN project_member.id IS '主键ID'; -COMMENT ON COLUMN project_member.project_id IS '项目ID'; -COMMENT ON COLUMN project_member.user_id IS '用户ID'; -COMMENT ON COLUMN project_member.role IS '角色:1测试经理 2测试工程师 3开发工程师 4访客'; -COMMENT ON COLUMN project_member.joined_time IS '加入时间'; - -CREATE UNIQUE INDEX IF NOT EXISTS uk_project_member ON project_member(project_id, user_id); -CREATE INDEX IF NOT EXISTS idx_member_user ON project_member(user_id); -CREATE INDEX IF NOT EXISTS idx_member_project_id ON project_member(project_id); - - --- ------------------------- --- 3. 环境配置表 --- ------------------------- -CREATE TABLE IF NOT EXISTS environment ( - id BIGSERIAL PRIMARY KEY, - project_id BIGINT NOT NULL REFERENCES project(id) ON DELETE CASCADE, - name VARCHAR(64) NOT NULL, - variables JSONB NOT NULL, - is_encrypted BOOLEAN DEFAULT FALSE, - is_delete INTEGER DEFAULT 0, - created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - -COMMENT ON TABLE environment IS '环境配置表'; -COMMENT ON COLUMN environment.id IS '主键ID'; -COMMENT ON COLUMN environment.project_id IS '项目ID'; -COMMENT ON COLUMN environment.name IS '环境名称,如 dev/test/staging/prod'; -COMMENT ON COLUMN environment.variables IS '环境变量配置,JSON格式'; -COMMENT ON COLUMN environment.is_encrypted IS '敏感信息是否已加密'; -COMMENT ON COLUMN environment.is_delete IS '逻辑删除标记:0未删除 1已删除'; -COMMENT ON COLUMN environment.created_time IS '创建时间'; - -CREATE INDEX IF NOT EXISTS idx_environment_project_id ON environment(project_id); -CREATE INDEX IF NOT EXISTS idx_environment_is_delete ON environment(is_delete); - - --- ------------------------- --- 4. 产品表 --- ------------------------- -CREATE TABLE IF NOT EXISTS product ( - id BIGSERIAL PRIMARY KEY, - name VARCHAR(128) NOT NULL, - code VARCHAR(64) UNIQUE NOT NULL, - description TEXT, - status SMALLINT DEFAULT 1, - is_delete INTEGER DEFAULT 0, - created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - -COMMENT ON TABLE product IS '产品表'; -COMMENT ON COLUMN product.id IS '主键ID'; -COMMENT ON COLUMN product.name IS '产品名称'; -COMMENT ON COLUMN product.code IS '产品编码'; -COMMENT ON COLUMN product.description IS '产品描述'; -COMMENT ON COLUMN product.status IS '产品状态:1启用 0禁用'; -COMMENT ON COLUMN product.is_delete IS '逻辑删除标记:0未删除 1已删除'; -COMMENT ON COLUMN product.created_time IS '创建时间'; -COMMENT ON COLUMN product.updated_time IS '更新时间'; - -CREATE INDEX IF NOT EXISTS idx_product_status ON product(status); -CREATE INDEX IF NOT EXISTS idx_product_is_delete ON product(is_delete); - - --- ========================================================= --- 二、用例相关 --- ========================================================= - --- ------------------------- --- 4. 模块表 --- ------------------------- -CREATE TABLE IF NOT EXISTS module ( - id BIGSERIAL PRIMARY KEY, - project_id BIGINT NOT NULL REFERENCES project(id) ON DELETE CASCADE, - parent_id BIGINT DEFAULT 0, - name VARCHAR(128) NOT NULL, - sort_order INTEGER DEFAULT 0, - path VARCHAR(512), - is_delete INTEGER DEFAULT 0 -); - -COMMENT ON TABLE module IS '模块树表,支持多层级模块结构'; -COMMENT ON COLUMN module.id IS '主键ID'; -COMMENT ON COLUMN module.project_id IS '项目ID'; -COMMENT ON COLUMN module.parent_id IS '父模块ID,0表示根节点'; -COMMENT ON COLUMN module.name IS '模块名称'; -COMMENT ON COLUMN module.sort_order IS '排序值'; -COMMENT ON COLUMN module.path IS '模块路径,如 /1/23/45'; -COMMENT ON COLUMN module.is_delete IS '逻辑删除标记:0未删除 1已删除'; - -CREATE INDEX IF NOT EXISTS idx_module_project ON module(project_id); -CREATE INDEX IF NOT EXISTS idx_module_parent_id ON module(parent_id); -CREATE INDEX IF NOT EXISTS idx_module_is_delete ON module(is_delete); - - --- ------------------------- --- 5. 用例表 --- ------------------------- -CREATE TABLE IF NOT EXISTS test_case ( - id BIGSERIAL PRIMARY KEY, - project_id BIGINT NOT NULL REFERENCES project(id) ON DELETE CASCADE, - module_id BIGINT REFERENCES module(id) ON DELETE SET NULL, - case_key VARCHAR(64) NOT NULL, - title VARCHAR(255) NOT NULL, - preconditions TEXT, - steps JSONB NOT NULL DEFAULT '[]'::jsonb, - priority SMALLINT DEFAULT 2, - case_type SMALLINT DEFAULT 1, - tags VARCHAR(64)[] DEFAULT '{}'::varchar[], - status SMALLINT DEFAULT 1, - created_by BIGINT, - is_delete INTEGER DEFAULT 0, - created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - -COMMENT ON TABLE test_case IS '测试用例表'; -COMMENT ON COLUMN test_case.id IS '主键ID'; -COMMENT ON COLUMN test_case.project_id IS '项目ID'; -COMMENT ON COLUMN test_case.module_id IS '所属模块ID'; -COMMENT ON COLUMN test_case.case_key IS '项目内唯一编号,如 TC-001'; -COMMENT ON COLUMN test_case.title IS '用例标题'; -COMMENT ON COLUMN test_case.preconditions IS '前置条件'; -COMMENT ON COLUMN test_case.steps IS '测试步骤,JSON数组'; -COMMENT ON COLUMN test_case.priority IS '优先级:0P0 1P1 2P2 3P3'; -COMMENT ON COLUMN test_case.case_type IS '用例类型:1功能 2性能 3安全 4接口'; -COMMENT ON COLUMN test_case.tags IS '标签数组'; -COMMENT ON COLUMN test_case.status IS '状态:1正常 2已废弃 3评审中'; -COMMENT ON COLUMN test_case.created_by IS '创建人'; -COMMENT ON COLUMN test_case.is_delete IS '逻辑删除标记:0未删除 1已删除'; -COMMENT ON COLUMN test_case.created_time IS '创建时间'; -COMMENT ON COLUMN test_case.updated_time IS '更新时间'; - -CREATE INDEX IF NOT EXISTS idx_case_project ON test_case(project_id); -CREATE INDEX IF NOT EXISTS idx_case_module ON test_case(module_id); -CREATE INDEX IF NOT EXISTS idx_case_priority ON test_case(priority); -CREATE INDEX IF NOT EXISTS idx_case_status ON test_case(status); -CREATE INDEX IF NOT EXISTS idx_case_is_delete ON test_case(is_delete); -CREATE UNIQUE INDEX IF NOT EXISTS uk_test_case_project_case_key ON test_case(project_id, case_key); - - --- ------------------------- --- 6. 用例快照表 --- ------------------------- -CREATE TABLE IF NOT EXISTS case_snapshot ( - id BIGSERIAL PRIMARY KEY, - case_id BIGINT NOT NULL REFERENCES test_case(id) ON DELETE CASCADE, - version INTEGER NOT NULL, - snapshot JSONB NOT NULL, - created_by BIGINT, - created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - -COMMENT ON TABLE case_snapshot IS '用例版本快照表'; -COMMENT ON COLUMN case_snapshot.id IS '主键ID'; -COMMENT ON COLUMN case_snapshot.case_id IS '用例ID'; -COMMENT ON COLUMN case_snapshot.version IS '版本号'; -COMMENT ON COLUMN case_snapshot.snapshot IS '完整快照内容'; -COMMENT ON COLUMN case_snapshot.created_by IS '创建人'; -COMMENT ON COLUMN case_snapshot.created_time IS '创建时间'; - -CREATE INDEX IF NOT EXISTS idx_case_snapshot_case_id ON case_snapshot(case_id); -CREATE UNIQUE INDEX IF NOT EXISTS uk_case_snapshot_case_version ON case_snapshot(case_id, version); - - --- ------------------------- --- 7. 用例评审表 --- ------------------------- -CREATE TABLE IF NOT EXISTS case_review ( - id BIGSERIAL PRIMARY KEY, - case_id BIGINT NOT NULL REFERENCES test_case(id) ON DELETE CASCADE, - reviewer_id BIGINT NOT NULL, - status SMALLINT DEFAULT 0, - comments TEXT, - diff_content TEXT, - created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - reviewed_time TIMESTAMP -); - -COMMENT ON TABLE case_review IS '用例评审表'; -COMMENT ON COLUMN case_review.id IS '主键ID'; -COMMENT ON COLUMN case_review.case_id IS '用例ID'; -COMMENT ON COLUMN case_review.reviewer_id IS '评审人ID'; -COMMENT ON COLUMN case_review.status IS '评审状态:0待评审 1通过 2驳回 3建议修改'; -COMMENT ON COLUMN case_review.comments IS '评审意见'; -COMMENT ON COLUMN case_review.diff_content IS '变更差异内容,通常为JSON diff字符串'; -COMMENT ON COLUMN case_review.created_time IS '创建时间'; -COMMENT ON COLUMN case_review.reviewed_time IS '评审时间'; - -CREATE INDEX IF NOT EXISTS idx_case_review_case_id ON case_review(case_id); -CREATE INDEX IF NOT EXISTS idx_case_review_reviewer_id ON case_review(reviewer_id); - - --- ========================================================= --- 三、测试计划相关 --- ========================================================= - --- ------------------------- --- 8. 测试计划表 --- ------------------------- -CREATE TABLE IF NOT EXISTS test_plan ( - id BIGSERIAL PRIMARY KEY, - project_id BIGINT NOT NULL REFERENCES project(id) ON DELETE CASCADE, - name VARCHAR(128) NOT NULL, - version VARCHAR(32), - description TEXT, - start_date DATE, - end_date DATE, - owner_id BIGINT, - status SMALLINT DEFAULT 0, - environment_id BIGINT REFERENCES environment(id), - is_delete INTEGER DEFAULT 0, - created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - -COMMENT ON TABLE test_plan IS '测试计划表'; -COMMENT ON COLUMN test_plan.id IS '主键ID'; -COMMENT ON COLUMN test_plan.project_id IS '项目ID'; -COMMENT ON COLUMN test_plan.name IS '计划名称'; -COMMENT ON COLUMN test_plan.version IS '测试版本号'; -COMMENT ON COLUMN test_plan.description IS '计划描述'; -COMMENT ON COLUMN test_plan.start_date IS '开始日期'; -COMMENT ON COLUMN test_plan.end_date IS '结束日期'; -COMMENT ON COLUMN test_plan.owner_id IS '负责人ID'; -COMMENT ON COLUMN test_plan.status IS '计划状态:0草稿 1进行中 2已完成 3已归档'; -COMMENT ON COLUMN test_plan.environment_id IS '关联环境ID'; -COMMENT ON COLUMN test_plan.is_delete IS '逻辑删除标记:0未删除 1已删除'; -COMMENT ON COLUMN test_plan.created_time IS '创建时间'; -COMMENT ON COLUMN test_plan.updated_time IS '更新时间'; - -CREATE INDEX IF NOT EXISTS idx_test_plan_project_id ON test_plan(project_id); -CREATE INDEX IF NOT EXISTS idx_test_plan_status ON test_plan(status); -CREATE INDEX IF NOT EXISTS idx_test_plan_is_delete ON test_plan(is_delete); - - --- ------------------------- --- 9. 计划用例表 --- ------------------------- -CREATE TABLE IF NOT EXISTS plan_case ( - id BIGSERIAL PRIMARY KEY, - plan_id BIGINT NOT NULL REFERENCES test_plan(id) ON DELETE CASCADE, - case_id BIGINT NOT NULL REFERENCES test_case(id), - assignee_id BIGINT, - round_no INTEGER DEFAULT 1, - status SMALLINT DEFAULT 0, - actual_result TEXT, - defect_links JSONB DEFAULT '[]'::jsonb, - attachments JSONB DEFAULT '[]'::jsonb, - executed_time TIMESTAMP, - execution_duration INTEGER -); - -COMMENT ON TABLE plan_case IS '计划与用例关联表,同时存储执行结果'; -COMMENT ON COLUMN plan_case.id IS '主键ID'; -COMMENT ON COLUMN plan_case.plan_id IS '计划ID'; -COMMENT ON COLUMN plan_case.case_id IS '用例ID'; -COMMENT ON COLUMN plan_case.assignee_id IS '执行人ID'; -COMMENT ON COLUMN plan_case.round_no IS '执行轮次'; -COMMENT ON COLUMN plan_case.status IS '执行状态:0未开始 1通过 2失败 3阻塞'; -COMMENT ON COLUMN plan_case.actual_result IS '实际执行结果'; -COMMENT ON COLUMN plan_case.defect_links IS '缺陷链接数组'; -COMMENT ON COLUMN plan_case.attachments IS '附件数组'; -COMMENT ON COLUMN plan_case.executed_time IS '执行时间'; -COMMENT ON COLUMN plan_case.execution_duration IS '执行耗时,单位秒'; - -CREATE UNIQUE INDEX IF NOT EXISTS uk_plan_case_round ON plan_case(plan_id, case_id, round_no); -CREATE INDEX IF NOT EXISTS idx_plan_case_plan ON plan_case(plan_id); -CREATE INDEX IF NOT EXISTS idx_plan_case_assignee ON plan_case(assignee_id); -CREATE INDEX IF NOT EXISTS idx_plan_case_status ON plan_case(status); - - --- ------------------------- --- 10. 测试轮次表 --- ------------------------- -CREATE TABLE IF NOT EXISTS test_round ( - id BIGSERIAL PRIMARY KEY, - plan_id BIGINT NOT NULL REFERENCES test_plan(id) ON DELETE CASCADE, - round_no INTEGER NOT NULL, - name VARCHAR(64), - start_date DATE, - end_date DATE -); - -COMMENT ON TABLE test_round IS '测试轮次表'; -COMMENT ON COLUMN test_round.id IS '主键ID'; -COMMENT ON COLUMN test_round.plan_id IS '计划ID'; -COMMENT ON COLUMN test_round.round_no IS '轮次编号'; -COMMENT ON COLUMN test_round.name IS '轮次名称'; -COMMENT ON COLUMN test_round.start_date IS '开始日期'; -COMMENT ON COLUMN test_round.end_date IS '结束日期'; - -CREATE UNIQUE INDEX IF NOT EXISTS uk_test_round_plan_round_no ON test_round(plan_id, round_no); -CREATE INDEX IF NOT EXISTS idx_test_round_plan_id ON test_round(plan_id); - - --- ========================================================= --- 四、报告相关 --- ========================================================= - --- ------------------------- --- 11. 报告表 --- ------------------------- -CREATE TABLE IF NOT EXISTS report ( - id BIGSERIAL PRIMARY KEY, - plan_id BIGINT NOT NULL REFERENCES test_plan(id) ON DELETE CASCADE, - name VARCHAR(128) NOT NULL, - report_type SMALLINT DEFAULT 1, - summary JSONB, - content TEXT, - file_url VARCHAR(512), - generated_by BIGINT, - generated_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - -COMMENT ON TABLE report IS '测试报告表'; -COMMENT ON COLUMN report.id IS '主键ID'; -COMMENT ON COLUMN report.plan_id IS '计划ID'; -COMMENT ON COLUMN report.name IS '报告名称'; -COMMENT ON COLUMN report.report_type IS '报告类型:1实时报告 2归档报告'; -COMMENT ON COLUMN report.summary IS '报告统计摘要,JSON格式'; -COMMENT ON COLUMN report.content IS '报告HTML内容'; -COMMENT ON COLUMN report.file_url IS '导出文件地址'; -COMMENT ON COLUMN report.generated_by IS '生成人'; -COMMENT ON COLUMN report.generated_time IS '生成时间'; - -CREATE INDEX IF NOT EXISTS idx_report_plan_id ON report(plan_id); -CREATE INDEX IF NOT EXISTS idx_report_generated_time ON report(generated_time); - - --- ------------------------- --- 12. 缺陷同步表 --- ------------------------- -CREATE TABLE IF NOT EXISTS defect_sync ( - id BIGSERIAL PRIMARY KEY, - project_id BIGINT NOT NULL REFERENCES project(id), - external_id VARCHAR(64) NOT NULL, - external_system VARCHAR(32), - plan_case_id BIGINT REFERENCES plan_case(id), - status VARCHAR(32), - last_sync_time TIMESTAMP -); - -COMMENT ON TABLE defect_sync IS '缺陷同步表,用于记录外部缺陷系统关联关系'; -COMMENT ON COLUMN defect_sync.id IS '主键ID'; -COMMENT ON COLUMN defect_sync.project_id IS '项目ID'; -COMMENT ON COLUMN defect_sync.external_id IS '外部缺陷ID,如 JIRA-123'; -COMMENT ON COLUMN defect_sync.external_system IS '外部系统,如 jira/tapd/zentao'; -COMMENT ON COLUMN defect_sync.plan_case_id IS '计划用例执行ID'; -COMMENT ON COLUMN defect_sync.status IS '外部缺陷状态'; -COMMENT ON COLUMN defect_sync.last_sync_time IS '最后同步时间'; - -CREATE INDEX IF NOT EXISTS idx_defect_sync_project_id ON defect_sync(project_id); -CREATE INDEX IF NOT EXISTS idx_defect_sync_plan_case_id ON defect_sync(plan_case_id); -CREATE INDEX IF NOT EXISTS idx_defect_sync_external_id ON defect_sync(external_id); - - --- ========================================================= --- 五、造数相关 --- ========================================================= - --- ------------------------- --- 13. 造数器表 --- ------------------------- -CREATE TABLE IF NOT EXISTS data_builder ( - id BIGSERIAL PRIMARY KEY, - project_id BIGINT NOT NULL REFERENCES project(id) ON DELETE CASCADE, - name VARCHAR(128) NOT NULL, - description TEXT, - builder_type SMALLINT DEFAULT 1, - definition JSONB NOT NULL, - input_schema JSONB, - output_example JSONB, - created_by BIGINT, - is_delete INTEGER DEFAULT 0, - created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - -COMMENT ON TABLE data_builder IS '数据构造器表'; -COMMENT ON COLUMN data_builder.id IS '主键ID'; -COMMENT ON COLUMN data_builder.project_id IS '项目ID'; -COMMENT ON COLUMN data_builder.name IS '造数器名称'; -COMMENT ON COLUMN data_builder.description IS '造数器描述'; -COMMENT ON COLUMN data_builder.builder_type IS '造数器类型:1流程编排 2SQL 3脚本'; -COMMENT ON COLUMN data_builder.definition IS '造数逻辑定义,JSON格式'; -COMMENT ON COLUMN data_builder.input_schema IS '输入参数结构定义'; -COMMENT ON COLUMN data_builder.output_example IS '输出示例'; -COMMENT ON COLUMN data_builder.created_by IS '创建人'; -COMMENT ON COLUMN data_builder.is_delete IS '逻辑删除标记:0未删除 1已删除'; -COMMENT ON COLUMN data_builder.created_time IS '创建时间'; -COMMENT ON COLUMN data_builder.updated_time IS '更新时间'; - -CREATE INDEX IF NOT EXISTS idx_data_builder_project_id ON data_builder(project_id); -CREATE INDEX IF NOT EXISTS idx_data_builder_is_delete ON data_builder(is_delete); - - --- ------------------------- --- 14. 造数任务表 --- ------------------------- -CREATE TABLE IF NOT EXISTS data_task ( - id BIGSERIAL PRIMARY KEY, - builder_id BIGINT NOT NULL REFERENCES data_builder(id), - project_id BIGINT NOT NULL, - params JSONB, - status SMALLINT DEFAULT 0, - result_data JSONB, - error_message TEXT, - created_by BIGINT, - created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - completed_time TIMESTAMP -); - -COMMENT ON TABLE data_task IS '数据生成任务表'; -COMMENT ON COLUMN data_task.id IS '主键ID'; -COMMENT ON COLUMN data_task.builder_id IS '造数器ID'; -COMMENT ON COLUMN data_task.project_id IS '项目ID'; -COMMENT ON COLUMN data_task.params IS '任务入参,JSON格式'; -COMMENT ON COLUMN data_task.status IS '任务状态:0等待 1执行中 2成功 3失败'; -COMMENT ON COLUMN data_task.result_data IS '生成结果数据'; -COMMENT ON COLUMN data_task.error_message IS '错误信息'; -COMMENT ON COLUMN data_task.created_by IS '创建人'; -COMMENT ON COLUMN data_task.created_time IS '创建时间'; -COMMENT ON COLUMN data_task.completed_time IS '完成时间'; - -CREATE INDEX IF NOT EXISTS idx_task_status ON data_task(status); -CREATE INDEX IF NOT EXISTS idx_data_task_builder_id ON data_task(builder_id); -CREATE INDEX IF NOT EXISTS idx_data_task_project_id ON data_task(project_id); - - --- ========================================================= --- 六、更新时间自动维护触发器 --- 说明: --- PostgreSQL 需要借助触发器维护 updated_time --- ========================================================= - -CREATE OR REPLACE FUNCTION update_updated_time_column() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_time = CURRENT_TIMESTAMP; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -DROP TRIGGER IF EXISTS trg_project_updated_time ON project; -CREATE TRIGGER trg_project_updated_time -BEFORE UPDATE ON project -FOR EACH ROW -EXECUTE FUNCTION update_updated_time_column(); - -DROP TRIGGER IF EXISTS trg_product_updated_time ON product; -CREATE TRIGGER trg_product_updated_time -BEFORE UPDATE ON product -FOR EACH ROW -EXECUTE FUNCTION update_updated_time_column(); - -DROP TRIGGER IF EXISTS trg_test_case_updated_time ON test_case; -CREATE TRIGGER trg_test_case_updated_time -BEFORE UPDATE ON test_case -FOR EACH ROW -EXECUTE FUNCTION update_updated_time_column(); - -DROP TRIGGER IF EXISTS trg_test_plan_updated_time ON test_plan; -CREATE TRIGGER trg_test_plan_updated_time -BEFORE UPDATE ON test_plan -FOR EACH ROW -EXECUTE FUNCTION update_updated_time_column(); - -DROP TRIGGER IF EXISTS trg_data_builder_updated_time ON data_builder; -CREATE TRIGGER trg_data_builder_updated_time -BEFORE UPDATE ON data_builder -FOR EACH ROW -EXECUTE FUNCTION update_updated_time_column(); \ No newline at end of file -- 2.49.1